diff --git a/api-report/apps/api/src/modules/loop-engine/port.d.ts b/api-report/apps/api/src/modules/loop-engine/port.d.ts index ea62b0b..13f8b17 100644 --- a/api-report/apps/api/src/modules/loop-engine/port.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/port.d.ts @@ -389,3 +389,25 @@ export type TaskContext = { */ machineCriteria(taskId: TaskId): Promise>; }; +/** + * **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。 + * + * 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账; + * 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。 + * **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。 + */ +export type AnalyzeGateway = { + run(input: { + readonly taskId: TaskId; + /** 活动声明过的模型名 */ + readonly model: string; + readonly instruction: string; + /** 系统组装好的入参 —— 一行一条 */ + readonly payload: readonly unknown[]; + readonly outputSchema: Readonly>; + /** 归因:`<步名>#<第几笔>` */ + readonly label: string; + }): Promise<{ + readonly object: Readonly>; + }>; +}; diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 663aa98..038039d 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -2289,6 +2289,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, tasks: taskContext, clock, + analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) }, }); /** diff --git a/apps/api/src/modules/loop-engine/CONTRACT.md b/apps/api/src/modules/loop-engine/CONTRACT.md index af0dca5..d698af5 100644 --- a/apps/api/src/modules/loop-engine/CONTRACT.md +++ b/apps/api/src/modules/loop-engine/CONTRACT.md @@ -12,7 +12,7 @@ - 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError` `AutomaticOnWorldTouchingError` - 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway` - `CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext` + `CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext` `AnalyzeGateway` - 用例工厂:`createTurnRunner(deps): LoopEngineService` - 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion` `transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem` 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 new file mode 100644 index 0000000..e1badee --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import type { AutoRecipe } from '@contracts'; +import { taskIdSchema, staffIdSchema } from '@contracts'; +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'); + +/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */ +function harness(seed: Record = {}) { + const drafts = new Map; patch: Record }>(); + const notes: string[] = []; + const view = (): Record => { + const mine: Record = {}; + for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch }); + return { ...seed, ...mine }; + }; + const deps = { + data: { + view: () => Promise.resolve(view()), + writable: () => + Promise.resolve([ + { domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] }, + { domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] }, + ]), + draftWrite: (i: { domain: string; object: string; key: Record; patch: Record }) => { + const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; + const before = JSON.stringify(drafts.get(id)?.patch ?? null); + drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch }); + return Promise.resolve({ changed: before !== JSON.stringify(i.patch) }); + }, + draftDrop: (i: { domain: string; object: string; key: Record }) => { + const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; + return Promise.resolve({ dropped: drafts.delete(id) }); + }, + }, + cards: { + issue: () => Promise.resolve({ id: 'card-1' }), + decide: () => Promise.resolve({ id: 'card-2' }), + entry: () => Promise.resolve({ id: 'card-3' }), + ask: () => Promise.resolve({ id: 'card-4' }), + unanswered: () => Promise.resolve([]), + }, + memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } }, + analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, + sfeel: { + condition: (expr: string, scope: Readonly>) => { + if (expr === 'never') return false; + if (expr === 'always') return true; + if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== ''; + throw new Error(`没教过这句:${expr}`); + }, + expression: () => undefined, + unaryTest: () => false, + }, + }; + const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false }; + // 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法 + return { runner: createAutoRunner(deps as never), drafts, notes, ctx }; +} + +describe('配方执行器', () => { + it('project once:写一行,主键与其余列分开', async () => { + const { runner, drafts, ctx } = harness({ pn: 'LM2596S-5.0' }); + const recipe: AutoRecipe = { + let: { evalRef: 'W05-1' }, + do: [ + { + kind: 'project', + into: 'sub_eval.part', + from: { once: true }, + fields: { eval_ref: { in: 'evalRef' }, internal_pn: { in: 'pn' }, role: 'original' }, + }, + ], + }; + const out = await runner.run(recipe, ctx); + expect(out.wrote).toBe(1); + expect([...drafts.values()][0]).toEqual({ + domain: 'sub_eval', + object: 'part', + key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' }, + patch: { role: 'original' }, + }); + }); + + it('主键算不出来的那一行**先不写** —— 空主键写下去就是一行永远对不上的垃圾', async () => { + const { runner, drafts, ctx } = harness({}); + const out = await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: { in: 'nope' }, role: 'x' } }] }, + ctx, + ); + expect(out.wrote).toBe(0); + expect(drafts.size).toBe(0); + }); + + it('when 为假就跳过这一笔', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx); + expect(drafts.size).toBe(0); + }); + + it('**每一笔之前刷读数**:第二笔 each 得看得见第一笔刚写下的行', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run( + { + do: [ + { kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }, + { kind: 'project', into: 'parts.parameter', from: { each: 'mine:sub_eval.part' }, fields: { internal_pn: { col: 'internal_pn' }, param_name: '输入电压范围', param_value: '' } }, + ], + }, + ctx, + ); + const params = [...drafts.values()].filter((d) => d.object === 'parameter'); + expect(params.map((p) => p.key['internal_pn']).sort()).toEqual(['A', 'B']); + }); + + it('值没变的写是空转:**不留痕、不计数**(配方不是模型,原地打转不必被看见)', async () => { + const { runner, ctx } = harness({}); + const recipe: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] }; + await runner.run(recipe, ctx); + const second = await runner.run(recipe, ctx); + expect(second.wrote).toBe(0); + }); + + it('prune:这一笔没投影出来的行,从草稿层撤掉', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx); + expect(drafts.size).toBe(2); + const out = await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, + ctx, + ); + expect(out.dropped).toBe(1); + expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); + }); + + it('say:进工作日志,归系统', async () => { + const { runner, notes, ctx } = harness({ n: 2 }); + await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx); + expect(notes).toContain('已核对 2 颗料'); + }); + + it('地址不在台账声明里 → 当场交人,不重试', async () => { + const { runner, ctx } = harness({}); + const out = await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx); + expect(out.failedAt?.index).toBe(0); + expect(out.failedAt?.message).toContain('nope.nope'); + }); +}); diff --git a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts index dfd583f..b0407bc 100644 --- a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts @@ -139,6 +139,7 @@ function harness( } as never; }, } as never, + analyze: { run: async () => ({ object: {} }) } as never, tools: { invoke: async (input: { tool: string }) => { toolCalls.push(input.tool); 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 new file mode 100644 index 0000000..10b5088 --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -0,0 +1,178 @@ +/** + * 配方执行器 —— 逐笔执行 `PlanStep.auto`(spec §5.1)。 + * + * 三条硬规矩: + * ① **每一笔之前刷读数**:后一笔常常要数前一笔刚写下的行。不刷新就恒为 0 —— + * 推荐等级会永远算成「全 MATCH」,而屏上明明摆着两条不符。 + * ② **幂等**:`draftWrite` 同 PK 覆盖,值没变**静默跳过**(不留痕、不计数)—— + * 「写了等于没写」那条判据是拦模型空转的,配方不是模型; + * 发卡先看 `carded`(worldTouched),发过就不再发。 + * ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。 + * 退回模型就是让它去凑,那正是这套机制要消灭的东西。 + */ +import type { AutoAction, AutoRecipe, StaffId, TaskId } from '@contracts'; +import { evalExpr, evalText, rowsAt, type ExprCtx } from '../domain/auto-expr.ts'; +import { expandSource } from '../domain/auto-source.ts'; +import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts'; + +export class AutoRecipeError extends Error { + constructor( + readonly actionIndex: number, + message: string, + ) { + super(message); + this.name = 'AutoRecipeError'; + } +} + +export type AutoRunnerDeps = { + readonly data: TaskDataGateway; + readonly cards: CardGateway; + readonly memory: MemoryRecorder; + readonly analyze: AnalyzeGateway; + readonly sfeel: Sfeel; +}; + +export type AutoContext = { + readonly taskId: TaskId; + readonly stepId: string; + readonly ownerId: StaffId; + readonly humanCallCap: number; + /** 有未答的卡在场:**只跑 project / say** —— 只算、只写,不发新卡、不 submit、不 analyze */ + readonly quiet: boolean; + /** 这一步已经发过卡(worldTouched) */ + readonly carded: boolean; +}; + +export type AutoOutcome = { + readonly wrote: number; + readonly dropped: number; + readonly issuedCardId?: string; + readonly submit?: { readonly rationale: string }; + readonly failedAt?: { readonly index: number; readonly message: string }; +}; + +const keyText = (key: Readonly>): string => Object.values(key).join('|'); + +export function createAutoRunner(deps: AutoRunnerDeps): { + run(recipe: AutoRecipe, ctx: AutoContext): Promise; +} { + return { + async run(recipe, ctx) { + const writable = await deps.data.writable(ctx.taskId); + const results: Record = {}; + let scope: Record = { ...(await deps.data.view(ctx.taskId)), ...results }; + let dirty = false; + let wrote = 0; + let dropped = 0; + let issuedCardId: string | undefined; + let submit: { rationale: string } | undefined; + + const condition = deps.sfeel.condition; + const lets: Record = {}; + for (const [name, expr] of Object.entries(recipe.let ?? {})) { + lets[name] = evalExpr(expr, { scope, lets, condition }); + } + + async function runAction( + action: AutoAction, + index: number, + base: ExprCtx, + context: AutoContext, + ): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> { + if (action.kind === 'say') { + await deps.memory.append({ + taskId: context.taskId, + kind: 'result', + title: evalText(action.text, base), + detail: { auto: `${context.stepId}#${index + 1}` }, + }); + return { wrote: 0, dropped: 0 }; + } + + if (action.kind === 'project') { + const [domain, object] = action.into.split('.'); + const decl = writable.find((w) => w.domain === domain && w.object === object); + if (decl === undefined) { + throw new AutoRecipeError(index, `台账地址不在本活动的声明里:${action.into}(写的授权面是台账声明)`); + } + const before = rowsAt(base.scope, `mine:${action.into}`); + const kept = new Set(); + let localWrote = 0; + for (const { row, outer } of expandSource(action.from, base)) { + const computed: Record = {}; + for (const [name, expr] of Object.entries(action.fields)) { + computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed }); + } + const key: Record = {}; + for (const column of decl.keys) key[column] = String(computed[column] ?? ''); + /* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */ + if (decl.keys.some((column) => key[column] === '')) continue; + const patch: Record = {}; + for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value; + const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch }); + if (changed) localWrote += 1; + kept.add(keyText(key)); + } + let localDropped = 0; + if (action.prune === true) { + for (const stale of before) { + const key: Record = {}; + for (const column of decl.keys) key[column] = String(stale[column] ?? ''); + if (kept.has(keyText(key))) continue; + const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key }); + if (gone) localDropped += 1; + } + } + if (localWrote > 0 || localDropped > 0) { + await deps.memory.append({ + taskId: context.taskId, + kind: 'result', + title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`, + detail: { auto: `${context.stepId}#${index + 1}`, internal: true, ledger: action.into }, + }); + } + return { wrote: localWrote, dropped: localDropped }; + } + + throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`); + } + + for (const [index, action] of recipe.do.entries()) { + try { + if (dirty) { + scope = { ...(await deps.data.view(ctx.taskId)), ...results }; + dirty = false; + } + const base: ExprCtx = { scope, lets, condition }; + if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue; + /* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */ + if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue; + + const done = await runAction(action, index, base, ctx); + wrote += done.wrote; + dropped += done.dropped; + if (done.wrote > 0 || done.dropped > 0) dirty = true; + if (done.cardId !== undefined) issuedCardId = done.cardId; + if (done.submit !== undefined) submit = done.submit; + if (done.stop) break; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await deps.memory.append({ + taskId: ctx.taskId, + kind: 'result', + title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`, + detail: { auto: `${ctx.stepId}#${index + 1}`, internal: false }, + }); + return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } }; + } + } + return { + wrote, + dropped, + ...(issuedCardId === undefined ? {} : { issuedCardId }), + ...(submit === undefined ? {} : { submit }), + }; + }, + }; +} diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts index 04db52a..af2393b 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -9,6 +9,7 @@ import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract import { ForbiddenTerminalError, TURN_ACTION_CAP } from '../../contract.ts'; import type { AgentPort, + AnalyzeGateway, CardGateway, Clock, ListenerRegistrar, @@ -53,6 +54,7 @@ export type TurnRunnerDeps = { readonly skills: SkillGateway; readonly tasks: TaskContext; readonly clock: Clock; + readonly analyze: AnalyzeGateway; }; /** diff --git a/apps/api/src/modules/loop-engine/port.ts b/apps/api/src/modules/loop-engine/port.ts index 7378d4d..c4f518b 100644 --- a/apps/api/src/modules/loop-engine/port.ts +++ b/apps/api/src/modules/loop-engine/port.ts @@ -314,3 +314,24 @@ export type TaskContext = { */ machineCriteria(taskId: TaskId): Promise>; }; + +/** + * **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。 + * + * 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账; + * 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。 + * **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。 + */ +export type AnalyzeGateway = { + run(input: { + readonly taskId: TaskId; + /** 活动声明过的模型名 */ + readonly model: string; + readonly instruction: string; + /** 系统组装好的入参 —— 一行一条 */ + readonly payload: readonly unknown[]; + readonly outputSchema: Readonly>; + /** 归因:`<步名>#<第几笔>` */ + readonly label: string; + }): Promise<{ readonly object: Readonly> }>; +};