feat(loop-engine): analyze 动词 —— 系统组装入参、声明出参 schema、系统把结果落表

AI 在这里只是一个纯函数:不给工具、不给记忆、不多轮。
行数不由它决定 —— 出参是拿主键去 lookup 的一张查表,它漏一条那一格就是空,
required 立刻把它变成 _missing > 0,提交闸与放行门当场拦住。
出参不成形就地重问一次,再不成抛出去交人。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 03:54:14 +08:00
co-authored by Claude Opus 5
parent b0a3cc3a55
commit fe8de5319c
7 changed files with 228 additions and 4 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
*/
export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
export type { AgentPort, AnalyzeGateway, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { taskIdSchema } from '@contracts';
import { createAnalyzeGateway } from '../wiring/analyze-gateway.ts';
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
const SCHEMA = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } };
const call = { taskId: TASK, model: 'deepseek-v4-flash', instruction: '逐条判四态', payload: [{ p: 'A' }], outputSchema: SCHEMA, label: 'S2#1' };
describe('analyze 网关', () => {
it('出参是对象就直接回,入参 JSON 进 prompt、instruction 进 system', async () => {
const seen: Array<Record<string, unknown>> = [];
const gateway = createAnalyzeGateway({
generate: (c: Record<string, unknown>) => {
seen.push(c);
return Promise.resolve({ text: '', object: { items: [{ p: 'A' }] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
},
} as never);
const out = await gateway.run(call);
expect(out.object).toEqual({ items: [{ p: 'A' }] });
expect(seen[0]?.['system']).toBe('逐条判四态');
expect(String(seen[0]?.['prompt'])).toContain('"p": "A"');
expect(seen[0]?.['reasoning']).toBe('off');
});
it('出参不成形 → **就地重问一次**,第二次成了就用它', async () => {
let n = 0;
const gateway = createAnalyzeGateway({
generate: () => {
n += 1;
return Promise.resolve(
n === 1
? { text: 'not json', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
: { text: '', object: { items: [] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
);
},
} as never);
const out = await gateway.run(call);
expect(n).toBe(2);
expect(out.object).toEqual({ items: [] });
});
it('重问了还是不成形 → 抛,交人;**不重问第三次**', async () => {
let n = 0;
const gateway = createAnalyzeGateway({
generate: () => {
n += 1;
return Promise.resolve({ text: 'nope', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
},
} as never);
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
expect(n).toBe(2);
});
});
@@ -0,0 +1,40 @@
/**
* `analyze` 动词的实现 —— **一次结构化调用,没有第二次自由**(spec §5.2)。
*
* 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。
* 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 ——
* 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。
*/
import type { ModelGateway } from '@modules/llm';
import type { AnalyzeGateway } from '@modules/loop-engine';
const RETRY_HINT =
'上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。';
export function createAnalyzeGateway(llm: Pick<ModelGateway, 'generate'>): AnalyzeGateway {
return {
async run(input) {
const prompt = JSON.stringify(input.payload, null, 2);
const call = {
model: input.model,
system: input.instruction,
prompt,
/* 判断归判定表与规则,模型只做归类 —— 隐藏推理按秒计,这一档一律关 */
reasoning: 'off' as const,
temperature: 0.2,
outputSchema: input.outputSchema as Record<string, unknown>,
taskId: input.taskId,
label: input.label,
};
const first = await llm.generate(call);
if (typeof first.object === 'object' && first.object !== null) {
return { object: first.object as Record<string, unknown> };
}
const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` });
if (typeof second.object === 'object' && second.object !== null) {
return { object: second.object as Record<string, unknown> };
}
throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`);
},
};
}
+2 -1
View File
@@ -26,6 +26,7 @@ import { idempotencyKey } from '@modules/release';
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
import type { LoopEngineService } from '@modules/loop-engine';
import { createAnalyzeGateway } from './analyze-gateway.ts';
import { createSkillEngineService } from '@modules/skill-engine';
import { createTaskService, createTaskStore } from '@modules/task';
import type { TaskService } from '@modules/task';
@@ -2289,7 +2290,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
tasks: taskContext,
clock,
analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) },
analyze: createAnalyzeGateway(deps.modelGateway),
});
/**
@@ -6,8 +6,10 @@ import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');
type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record<string, unknown> }> };
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
function harness(seed: Record<string, unknown> = {}) {
function harness(seed: Record<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
const notes: string[] = [];
const view = (): Record<string, unknown> => {
@@ -42,7 +44,7 @@ function harness(seed: Record<string, unknown> = {}) {
unanswered: () => Promise.resolve([]),
},
memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } },
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
sfeel: {
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
if (expr === 'never') return false;
@@ -147,3 +149,100 @@ describe('配方执行器', () => {
expect(out.failedAt?.message).toContain('nope.nope');
});
});
describe('analyze 动词', () => {
it('入参由声明组装,出参落进 result: 供后一笔 each 消费', async () => {
let seen: readonly unknown[] = [];
const { runner, drafts, ctx } = harness(
{
'parts.type_param': [
{ type_id: 1, param_name: '输入电压范围', constraint_type: 'hard' },
{ type_id: 1, param_name: '输出电流', constraint_type: 'soft' },
],
typeId: 1,
},
{
analyze: {
run: (i) => {
seen = i.payload;
return Promise.resolve({
object: {
items: [
{ param_name: '输入电压范围', difference_status: 'match' },
{ param_name: '输出电流', difference_status: 'unknown' },
],
},
});
},
},
},
);
await runner.run(
{
do: [
{
kind: 'analyze',
model: 'deepseek-v4-flash',
into: 'items',
instruction: '逐条判四态',
input: {
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: { param_name: { col: 'param_name' }, constraint_type: { col: 'constraint_type' } },
},
output: { type: 'object' },
},
{
kind: 'project',
into: 'parts.parameter',
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
internal_pn: 'XL2596S-5.0',
param_name: { col: 'param_name' },
/* **行由字典决定,AI 出参只是查表** —— 它漏一条,那一格就是空 */
param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' },
},
},
],
},
ctx,
);
expect(seen).toEqual([
{ param_name: '输入电压范围', constraint_type: 'hard' },
{ param_name: '输出电流', constraint_type: 'soft' },
]);
const values = [...drafts.values()].filter((d) => d.object === 'parameter').map((d) => d.patch['param_value']);
expect(values.sort()).toEqual(['match', 'unknown']);
});
it('AI 漏了一条:那一行的格子是空的 —— 行数不由它决定', async () => {
const { runner, drafts, ctx } = harness(
{ 'parts.type_param': [{ type_id: 1, param_name: 'A' }, { type_id: 1, param_name: 'B' }], typeId: 1 },
{ analyze: { run: () => Promise.resolve({ object: { items: [{ param_name: 'A', difference_status: 'match' }] } }) } },
);
await runner.run(
{
do: [
{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { param_name: { col: 'param_name' } } }, output: { type: 'object' } },
{ kind: 'project', into: 'parts.parameter', from: { each: 'parts.type_param' }, fields: { internal_pn: 'P', param_name: { col: 'param_name' }, param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' } } },
],
},
ctx,
);
const rows = [...drafts.values()].filter((d) => d.object === 'parameter');
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.key['param_name'] === 'B')?.patch['param_value']).toBe('');
});
it('有卡在等人交回时不调 AI —— 只算、只写', async () => {
let called = 0;
const { runner, ctx } = harness(
{ 'parts.type_param': [{ type_id: 1, param_name: 'A' }] },
{ analyze: { run: () => { called += 1; return Promise.resolve({ object: { items: [] } }); } } },
);
await runner.run(
{ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { p: { col: 'param_name' } } }, output: { type: 'object' } }] },
{ ...ctx, quiet: true },
);
expect(called).toBe(0);
});
});
@@ -33,6 +33,7 @@ export {
export type {
AgentPort,
AnalyzeGateway,
CardGateway,
Clock,
ListenerRegistrar,
@@ -135,6 +135,36 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
return { wrote: localWrote, dropped: localDropped };
}
if (action.kind === 'analyze') {
const payload = expandSource(action.input.from, base).map(({ row, outer }) => {
const item: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(action.input.fields)) {
item[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed: item });
}
return item;
});
const { object } = await deps.analyze.run({
taskId: context.taskId,
model: action.model,
instruction: action.instruction,
payload,
outputSchema: action.output,
label: `${context.stepId}#${index + 1}`,
});
/* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */
const rows = object[action.into];
results[`result:${action.into}`] = Array.isArray(rows) ? rows : [];
await deps.memory.append({
taskId: context.taskId,
kind: 'result',
title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`,
detail: { auto: `${context.stepId}#${index + 1}`, internal: true, model: action.model },
});
/* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */
scope = { ...scope, ...results };
return { wrote: 0, dropped: 0 };
}
throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`);
}