From fe8de5319ce372c4431208524cf980e0612c6f70 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 03:54:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(loop-engine):=20analyze=20=E5=8A=A8?= =?UTF-8?q?=E8=AF=8D=20=E2=80=94=E2=80=94=20=E7=B3=BB=E7=BB=9F=E7=BB=84?= =?UTF-8?q?=E8=A3=85=E5=85=A5=E5=8F=82=E3=80=81=E5=A3=B0=E6=98=8E=E5=87=BA?= =?UTF-8?q?=E5=8F=82=20schema=E3=80=81=E7=B3=BB=E7=BB=9F=E6=8A=8A=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=90=BD=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 在这里只是一个纯函数:不给工具、不给记忆、不多轮。 行数不由它决定 —— 出参是拿主键去 lookup 的一张查表,它漏一条那一格就是空, required 立刻把它变成 _missing > 0,提交闸与放行门当场拦住。 出参不成形就地重问一次,再不成抛出去交人。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../api/src/modules/loop-engine/index.d.ts | 2 +- .../src/app/__tests__/analyze-gateway.test.ts | 53 +++++++++ apps/api/src/app/wiring/analyze-gateway.ts | 40 +++++++ apps/api/src/app/wiring/engines.ts | 3 +- .../loop-engine/__tests__/auto-runner.test.ts | 103 +++++++++++++++++- apps/api/src/modules/loop-engine/index.ts | 1 + .../internal/usecase/auto-runner.ts | 30 +++++ 7 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/app/__tests__/analyze-gateway.test.ts create mode 100644 apps/api/src/app/wiring/analyze-gateway.ts diff --git a/api-report/apps/api/src/modules/loop-engine/index.d.ts b/api-report/apps/api/src/modules/loop-engine/index.d.ts index 98b217b..863ea96 100644 --- a/api-report/apps/api/src/modules/loop-engine/index.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/index.d.ts @@ -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'; diff --git a/apps/api/src/app/__tests__/analyze-gateway.test.ts b/apps/api/src/app/__tests__/analyze-gateway.test.ts new file mode 100644 index 0000000..7759180 --- /dev/null +++ b/apps/api/src/app/__tests__/analyze-gateway.test.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> = []; + const gateway = createAnalyzeGateway({ + generate: (c: Record) => { + 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); + }); +}); diff --git a/apps/api/src/app/wiring/analyze-gateway.ts b/apps/api/src/app/wiring/analyze-gateway.ts new file mode 100644 index 0000000..aecd401 --- /dev/null +++ b/apps/api/src/app/wiring/analyze-gateway.ts @@ -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): 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, + 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 }; + } + 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 }; + } + throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`); + }, + }; +} diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 038039d..cd76b8a 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -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), }); /** diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index e1badee..af0d392 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -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 }> }; + /** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */ -function harness(seed: Record = {}) { +function harness(seed: Record = {}, override: { analyze?: AnalyzeStub } = {}) { const drafts = new Map; patch: Record }>(); const notes: string[] = []; const view = (): Record => { @@ -42,7 +44,7 @@ function harness(seed: Record = {}) { 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>) => { 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); + }); +}); diff --git a/apps/api/src/modules/loop-engine/index.ts b/apps/api/src/modules/loop-engine/index.ts index 4174104..001ff79 100644 --- a/apps/api/src/modules/loop-engine/index.ts +++ b/apps/api/src/modules/loop-engine/index.ts @@ -33,6 +33,7 @@ export { export type { AgentPort, + AnalyzeGateway, CardGateway, Clock, ListenerRegistrar, diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index 10b5088..0d7d1ce 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -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 = {}; + 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}`); }