Archived
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:
+1
-1
@@ -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 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 { 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 type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
||||||
export { createTurnRunner } 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';
|
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}:模型出参不成形,重问一次仍不成 —— 交给你处理`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import { idempotencyKey } from '@modules/release';
|
|||||||
|
|
||||||
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
|
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
|
||||||
import type { LoopEngineService } from '@modules/loop-engine';
|
import type { LoopEngineService } from '@modules/loop-engine';
|
||||||
|
import { createAnalyzeGateway } from './analyze-gateway.ts';
|
||||||
import { createSkillEngineService } from '@modules/skill-engine';
|
import { createSkillEngineService } from '@modules/skill-engine';
|
||||||
import { createTaskService, createTaskStore } from '@modules/task';
|
import { createTaskService, createTaskStore } from '@modules/task';
|
||||||
import type { TaskService } from '@modules/task';
|
import type { TaskService } from '@modules/task';
|
||||||
@@ -2289,7 +2290,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
},
|
},
|
||||||
tasks: taskContext,
|
tasks: taskContext,
|
||||||
clock,
|
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 TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||||
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');
|
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 框架,是手写的依赖注入替身。** */
|
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 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 drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
const view = (): Record<string, unknown> => {
|
const view = (): Record<string, unknown> => {
|
||||||
@@ -42,7 +44,7 @@ function harness(seed: Record<string, unknown> = {}) {
|
|||||||
unanswered: () => Promise.resolve([]),
|
unanswered: () => Promise.resolve([]),
|
||||||
},
|
},
|
||||||
memory: { append: (m: { title: string }) => { notes.push(m.title); return 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: {
|
sfeel: {
|
||||||
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
||||||
if (expr === 'never') return false;
|
if (expr === 'never') return false;
|
||||||
@@ -147,3 +149,100 @@ describe('配方执行器', () => {
|
|||||||
expect(out.failedAt?.message).toContain('nope.nope');
|
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 {
|
export type {
|
||||||
AgentPort,
|
AgentPort,
|
||||||
|
AnalyzeGateway,
|
||||||
CardGateway,
|
CardGateway,
|
||||||
Clock,
|
Clock,
|
||||||
ListenerRegistrar,
|
ListenerRegistrar,
|
||||||
|
|||||||
@@ -135,6 +135,36 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
|||||||
return { wrote: localWrote, dropped: localDropped };
|
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}`);
|
throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user