From e9da8d25f5734c7c92ba1bdc0d4e745ad63bc78a Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 04:08:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(loop-engine):=20card=20=E4=B8=8E=20submit?= =?UTF-8?q?=20=E4=B8=A4=E4=B8=AA=E5=8A=A8=E8=AF=8D;=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E5=8D=A1=E6=94=BE=E5=AE=BD=E5=88=B0=E5=8D=95=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-014 §8 原写「不发决策卡、不 submit」—— 那是假设还有 agent 在场时的保守话。 全程无 agent 的活动没有别人来干这两件事,不开口最后两步就没人做。 护栏没松:submit 仍要过提交门(判据全绿),系统提交不比模型提交宽松。 发过的卡不再发第二张;发了卡就收拍,排在后面的笔一律不跑。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../human-tool/__tests__/card-rules.test.ts | 18 ++++ .../human-tool/internal/domain/card-rules.ts | 12 ++- .../loop-engine/__tests__/auto-runner.test.ts | 99 ++++++++++++++++++- .../internal/usecase/auto-runner.ts | 61 +++++++++++- 4 files changed, 185 insertions(+), 5 deletions(-) diff --git a/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts b/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts index 5c2e29c..e391f4c 100644 --- a/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts +++ b/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts @@ -333,3 +333,21 @@ describe('人工验收清单:点全才能通过', () => { expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]); }); }); + +describe('审核卡的选项数', () => { + it('普通决策卡仍要两个分支 —— 一个选项的岔路口不是岔路口', () => { + const findings = checkChatCard({ k: 'decision', ask: '往哪走', basis: [{ k: 'a', v: 'b' }], options: [{ value: 'a', label: 'A' }] } as never); + expect(findings.some((f) => f.code === 'options-missing')).toBe(true); + }); + + it('审核卡(variant:review)只有「确认通过」一个选项也发得出去 —— 改在件上,卡上只签字', () => { + const findings = checkChatCard({ + k: 'decision', + variant: 'review', + ask: '核对过了吗', + basis: [{ k: 'a', v: 'b' }], + options: [{ value: 'approve', label: '确认通过' }], + } as never); + expect(findings.some((f) => f.code === 'options-missing')).toBe(false); + }); +}); diff --git a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts index a53ecd4..c7be04f 100644 --- a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts +++ b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts @@ -89,7 +89,17 @@ export function checkChatCard(card: ChatCard): CardViolation[] { switch (card.k) { case 'decision': if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' }); - if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' }); + /* + * **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 —— + * 改全在件上就地做,卡上只有「确认通过」一个动作。 + * 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。 + */ + { + const floor = card.variant === 'review' ? 1 : 2; + if (card.options.length < floor) { + out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' }); + } + } break; case 'elicitation': if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' }); 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 af0d392..cf28724 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 @@ -12,6 +12,7 @@ type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; inst function harness(seed: Record = {}, override: { analyze?: AnalyzeStub } = {}) { const drafts = new Map; patch: Record }>(); const notes: string[] = []; + const cards = { issued: [] as Array> }; const view = (): Record => { const mine: Record = {}; for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch }); @@ -37,8 +38,8 @@ function harness(seed: Record = {}, override: { analyze?: Analy }, }, cards: { - issue: () => Promise.resolve({ id: 'card-1' }), - decide: () => Promise.resolve({ id: 'card-2' }), + issue: (i: Record) => { cards.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); }, + decide: (i: Record) => { cards.issued.push({ ...i, kind: 'decide' }); return Promise.resolve({ id: 'card-2' }); }, entry: () => Promise.resolve({ id: 'card-3' }), ask: () => Promise.resolve({ id: 'card-4' }), unanswered: () => Promise.resolve([]), @@ -58,7 +59,7 @@ function harness(seed: Record = {}, override: { analyze?: Analy }; 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 }; + return { runner: createAutoRunner(deps as never), drafts, notes, cards, ctx }; } describe('配方执行器', () => { @@ -246,3 +247,95 @@ describe('analyze 动词', () => { expect(called).toBe(0); }); }); + +describe('card 与 submit', () => { + it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => { + const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' }); + const out = await runner.run( + { + do: [ + { + kind: 'card', + as: 'execution', + variant: 'data', + title: { cat: ['数据工单 · 补齐 ', { in: 'pn' }] }, + guide: ['选一个芯片类型', '把参数值填齐'], + standard: '必填格全部填上,红带消失', + basis: [{ k: '候选料号', v: { in: 'pn' } }], + gateLedgers: ['parts.profile', 'parts.parameter'], + }, + ], + }, + ctx, + ); + expect(out.issuedCardId).toBe('card-1'); + expect(cards.issued[0]).toMatchObject({ + workOrder: 'execution', + itemId: 'S1', + card: { + mode: 'execution', + title: '数据工单 · 补齐 XL2596S-5.0', + variant: 'data', + steps: ['选一个芯片类型', '把参数值填齐'], + standard: '必填格全部填上,红带消失', + basis: [{ k: '候选料号', v: 'XL2596S-5.0' }], + gateLedgers: ['parts.profile', 'parts.parameter'], + fields: [], + }, + }); + }); + + it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => { + const { runner, notes, ctx } = harness({}); + const out = await runner.run( + { do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] }, + { ...ctx, carded: true }, + ); + expect(out.issuedCardId).toBeUndefined(); + expect(notes.some((n) => n.includes('已经发过'))).toBe(true); + }); + + it('decide + review:摆成验收工单,单一选项也发得出去', async () => { + const { runner, ctx } = harness({}); + const out = await runner.run( + { + do: [ + { + kind: 'card', + as: 'decide', + variant: 'review', + title: '差异评估结论审核', + ask: '逐条核对过了吗?', + options: [{ value: 'approve', label: '确认通过' }], + basis: [{ k: '候选', v: 'XL2596S-5.0' }], + }, + ], + }, + ctx, + ); + expect(out.issuedCardId).toBe('card-2'); + }); + + it('发了卡就**收拍**:排在后面的笔一律不跑', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run( + { + do: [ + { kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }, + { kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }, + ], + }, + ctx, + ); + expect(drafts.size).toBe(0); + }); + + it('submit:回给 turn-runner,理由现算;有卡在等人时不 submit', async () => { + const { runner, ctx } = harness({ n: 3 }); + const out = await runner.run({ do: [{ kind: 'submit', rationale: { cat: ['已判定 ', { in: 'n' }, ' 条参数'] } }] }, ctx); + expect(out.submit).toEqual({ rationale: '已判定 3 条参数' }); + + const quiet = await runner.run({ do: [{ kind: 'submit', rationale: 'x' }] }, { ...ctx, quiet: true }); + expect(quiet.submit).toBeUndefined(); + }); +}); 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 0d7d1ce..a0f4db5 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 @@ -165,7 +165,66 @@ export function createAutoRunner(deps: AutoRunnerDeps): { return { wrote: 0, dropped: 0 }; } - throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`); + if (action.kind === 'card') { + /* + * **发过就不再发**(幂等)。人工激活的 auto 步,**配方发卡就是启动** —— + * 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。 + */ + if (context.carded) { + await deps.memory.append({ + taskId: context.taskId, + kind: 'observation', + title: `这一步已经发过卡,不再发第二张`, + detail: { auto: `${context.stepId}#${index + 1}`, internal: true }, + }); + return { wrote: 0, dropped: 0, stop: true }; + } + const basis = (action.basis ?? []).map((b) => ({ k: b.k, v: evalText(b.v, base) })); + if (action.as === 'decide') { + const options = action.options ?? []; + if (options.length === 0) throw new AutoRecipeError(index, '决策卡一个选项都没有'); + const { id } = await deps.cards.decide({ + taskId: context.taskId, + ownerId: context.ownerId, + humanCallCap: context.humanCallCap, + ask: evalText(action.ask ?? action.title, base), + options: options.map((o) => ({ value: o.value, label: o.label, ...(o.detail === undefined ? {} : { detail: o.detail }) })), + basis, + itemId: context.stepId, + /* `CardGateway.decide` 的 variant 只认 'review'(审核卡);card.variant 的类型口子更宽(还有 'data'), + 那是给执行工单用的 —— 这里按值窄化,别把 'data' 误传给决策卡 */ + ...(action.variant === 'review' ? { variant: action.variant } : {}), + }); + return { wrote: 0, dropped: 0, cardId: id, stop: true }; + } + const { id } = await deps.cards.issue({ + taskId: context.taskId, + ownerId: context.ownerId, + humanCallCap: context.humanCallCap, + workOrder: 'execution', + itemId: context.stepId, + card: { + mode: 'execution', + title: evalText(action.title, base), + ask: evalText(action.title, base), + basis, + /* 零输入:填报全在工作区的件上(2026-08-02 用户定) */ + fields: [], + ...(action.variant === 'data' ? { variant: 'data' } : {}), + ...(action.guide === undefined ? {} : { steps: [...action.guide] }), + ...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }), + ...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }), + }, + }); + return { wrote: 0, dropped: 0, cardId: id, stop: true }; + } + + if (action.kind === 'submit') { + return { wrote: 0, dropped: 0, submit: { rationale: evalText(action.rationale, base) }, stop: true }; + } + + /* 五个动词(say/project/analyze/card/submit)上面已全部处理,这里在类型上不可达 —— 留作穷尽性兜底 */ + throw new AutoRecipeError(index, '动词尚未实现'); } for (const [index, action] of recipe.do.entries()) {