From 3190392a1d8e4a8fdbcf0a5346e9bec6c96c856e Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 08:20:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E5=86=B3=E7=AD=96=E5=8D=A1?= =?UTF-8?q?=E7=9A=84=E4=BA=BA=E5=B7=A5=E9=AA=8C=E6=94=B6=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E4=B9=9F=E6=8C=89=20itemId=20=E5=85=9C=E5=BA=95=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E9=85=8D=E6=96=B9=E5=8F=91=E7=9A=84=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=8D=A1=E4=BB=8E=E5=89=8D=E4=B8=80=E6=9D=A1=E5=BE=85?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E9=83=BD=E6=B2=A1=E6=9C=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 装配层从前只给 `variant` 兜底,`checklist` 没有。配方(auto-runner)发审核卡时 只递了 variant —— 那张卡于是穿着验收工单的衣服、allowCustom 也对,**清单是空的**: 回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**, 「确认通过」当场可按。2026-08-27 用户当面定的那道人工验收闸就此凭空没了, 而屏上还看不出来 —— 清单区是空的,不是红的。 兜在装配层这一处(不是让每个调用方自己记得):模型路径将来漏传也一并保住。 判断抽成纯函数 `decisionCardFace` / `needsStepLookup`(照本仓 engines.ts 里 createOnlyViolation 那批纯函数的成例),9 测 —— 其中三条把挑出来的清单接到 真回传门上,证明闸真的因此有牙,不只是「函数回了个数组」。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../app/__tests__/decision-card-face.test.ts | 91 +++++++++++++++++++ apps/api/src/app/wiring/engines.ts | 53 +++++++++-- 2 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/app/__tests__/decision-card-face.test.ts diff --git a/apps/api/src/app/__tests__/decision-card-face.test.ts b/apps/api/src/app/__tests__/decision-card-face.test.ts new file mode 100644 index 0000000..07f2734 --- /dev/null +++ b/apps/api/src/app/__tests__/decision-card-face.test.ts @@ -0,0 +1,91 @@ +/** + * **决策卡的面孔与人工验收清单,兜底在装配层这一处**(2026-08-29 终审 C2)。 + * + * 病:配方(`auto-runner`)发审核卡时只递了 `variant`,没递 `checklist` —— + * 而装配层从前只给 `variant` 兜底。于是那张卡穿着验收工单的衣服、`allowCustom` 也对, + * **一条待确认都没有**:回传门的 `checklistMissing` 在 checklist 缺席时恒过, + * 「确认通过」当场可按,2026-08-27 用户当面定的那道人工验收闸凭空没了。 + * 屏上还看不出来 —— 清单区是空的,不是红的。 + */ +import { describe, expect, it } from 'vitest'; +import { checkChatCardReply } from '@modules/human-tool'; +import { cardIdSchema } from '@contracts'; +import type { ChatCard, ChatCardReply } from '@contracts'; +import { decisionCardFace, needsStepLookup } from '../wiring/engines.ts'; + +const RELEASE_STEP = { + cardVariant: 'review' as const, + acceptanceChecklist: ['逐条核对差异明细', '确认推荐等级与依据一致'], +}; + +describe('决策卡的面孔与清单(装配层兜底)', () => { + it('调用方递了清单就用它的 —— 引擎按剧本定好递过来,兜底不抢', () => { + const face = decisionCardFace({ variant: 'review', checklist: ['我说了算'] }, RELEASE_STEP); + expect(face.checklist).toEqual(['我说了算']); + expect(face.reviewFace).toBe(true); + }); + + it('**配方那一路只递了 variant:清单退回剧本声明的**(C2 的正主)', () => { + const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP); + expect(face.reviewFace).toBe(true); + expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist); + }); + + it('面孔也照旧退回剧本(2026-08-27 那条不许回退)', () => { + const face = decisionCardFace({}, RELEASE_STEP); + expect(face.reviewFace).toBe(true); + }); + + it('剧本没声明清单:就是没有 —— 不发明一条', () => { + const face = decisionCardFace({ variant: 'review' }, { cardVariant: 'review' }); + expect(face.checklist).toBeUndefined(); + }); + + it('空清单当没给:退回剧本(空数组在回传门那儿等于没有闸)', () => { + const face = decisionCardFace({ variant: 'review', checklist: [] }, RELEASE_STEP); + expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist); + }); + + it('查不查剧本:没 itemId 查不了;两样都递全了不必查', () => { + expect(needsStepLookup({})).toBe(false); + expect(needsStepLookup({ itemId: 'T_release' })).toBe(true); + expect(needsStepLookup({ itemId: 'T_release', variant: 'review' })).toBe(true); + expect(needsStepLookup({ itemId: 'T_release', variant: 'review', checklist: ['a'] })).toBe(false); + }); +}); + +/** + * **闸真的因此有牙** —— 上面挑出来的清单接到回传门上,「确认通过」才拦得住。 + * 没有这一条,`decisionCardFace` 的断言只证明「函数回了个数组」。 + */ +describe('清单缺席 = 闸恒过(这就是 C2 的后果)', () => { + const CARD_ID = cardIdSchema.parse('11111111-1111-4111-8111-111111111111'); + const card = (checklist?: ReadonlyArray): ChatCard => ({ + k: 'decision', + id: CARD_ID, + at: '2026-08-29T00:00:00.000Z', + ask: '差异评估结论审核', + basis: [], + variant: 'review', + allowCustom: false, + options: [{ value: 'approve', label: '确认通过' }], + ...(checklist === undefined ? {} : { checklist: [...checklist] }), + }); + const reply: ChatCardReply = { k: 'decision', cardId: CARD_ID, value: 'approve' }; + + it('没有清单:一条都没点也通得过 —— 这正是配方发的那张卡从前的样子', () => { + expect(checkChatCardReply(card(), reply)).toEqual([]); + }); + + it('清单兜底进来之后:一条没点就拦', () => { + const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP); + const violations = checkChatCardReply(card(face.checklist), reply); + expect(violations.map((v) => v.code)).toEqual(['checklist-incomplete']); + }); + + it('清单点全了才放行', () => { + const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP); + const violations = checkChatCardReply(card(face.checklist), { ...reply, checked: [...RELEASE_STEP.acceptanceChecklist] }); + expect(violations).toEqual([]); + }); +}); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 6e99406..11e9e6a 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -223,6 +223,37 @@ export function createOnlyViolation( ); } +/** + * 决策卡的**面孔与人工验收清单**:调用方递了就用它的,没递才退回剧本声明的(按 itemId 查到的那一步)。 + * + * 纯函数:剧本那一步由调用方查好递进来,这里只挑值。 + * + * **清单也要这道兜底**(2026-08-29 终审 C2):从前只有 `variant` 有兜底,`checklist` 没有 —— + * 配方(`auto-runner`)发审核卡时只递了 variant,于是那张卡穿着验收工单的衣服、`allowCustom` 也对, + * **一条待确认都没有**:回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**, + * 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,而屏上还看不出来 + * (清单区是空的,不是红的)。兜在这一处而不是让每个调用方自己记得:**模型路径将来漏传也一并保住**。 + */ +export function decisionCardFace( + input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray | undefined }, + step: { readonly cardVariant?: string | undefined; readonly acceptanceChecklist?: ReadonlyArray | undefined } | undefined, +): { reviewFace: boolean; checklist: ReadonlyArray | undefined } { + const reviewFace = input.variant === 'review' || step?.cardVariant === 'review'; + const checklist = + input.checklist !== undefined && input.checklist.length > 0 ? input.checklist : step?.acceptanceChecklist; + return { reviewFace, checklist }; +} + +/** 要不要去清单里查那一步:没给 itemId 查不了;面孔与清单都递全了不必查(省一次 manifest 取数) */ +export function needsStepLookup(input: { + readonly itemId?: string | undefined; + readonly variant?: 'review' | undefined; + readonly checklist?: ReadonlyArray | undefined; +}): boolean { + if (input.itemId === undefined) return false; + return input.variant === undefined || input.checklist === undefined; +} + export function rowReadings( mine: Readonly>, declared: ReadonlyArray<{ domain: string; object: string }> = [], @@ -1400,21 +1431,29 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { const recommended = input.options.find((o) => o.recommended === true); /* * 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄。 - * 面孔**引擎按当下那一步定好递过来**(`input.variant`,2026-08-27:模型漏了 itemId,卡退成「需要你定」); + * 面孔与**人工验收清单**都由调用方(引擎按当下那一步)定好递过来 + * (`input.variant` / `input.checklist`,2026-08-27:模型漏了 itemId,卡退成「需要你定」); * 没递才退回按 itemId 查剧本。 + * + * **清单也要这道兜底**(2026-08-29 终审 C2):从前只有 `variant` 有,`checklist` 没有 —— + * 配方发的审核卡(`auto-runner` 那一路只递了 variant)于是穿着验收工单的衣服、 + * `allowCustom` 也对,**一条待确认都没有**:回传门 `checklistMissing` 在 checklist 缺席时恒过, + * 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了, + * 而屏上还看不出来(清单区是空的,不是红的)。 + * 兜在这一处而不是各调用方自己记得:**模型路径将来漏传也一并保住**。 */ - const reviewFace = - input.variant === 'review' || - (input.itemId !== undefined && - (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find( + const declaredStep = needsStepLookup(input) + ? (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find( (st) => st.item === input.itemId, - )?.cardVariant === 'review'); + ) + : undefined; + const { reviewFace, checklist } = decisionCardFace(input, declaredStep); const issued = await deps.humanTool.issueChatCard( { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, { k: 'decision', ...(reviewFace ? { variant: 'review' as const } : {}), - ...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }), + ...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }), id: cardIdSchema.parse(ids.next()), at: clock.now().toISOString(), ask: input.ask,