From 75553371b09e02b816de063a435fad0df049f05e Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 14:46:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E9=80=9A=E8=BF=87=E4=B8=8E?= =?UTF-8?q?=E5=90=A6=E8=AF=BB=E5=9F=BA=E5=BA=A7=E6=95=B0;=E3=80=8C?= =?UTF-8?q?=E9=87=8D=E5=81=9A=E3=80=8D=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=8C=87?= =?UTF-8?q?=E5=90=91=20finish;=E5=AE=A1=E6=A0=B8=E5=8D=A1=E6=91=86?= =?UTF-8?q?=E6=88=90=E9=AA=8C=E6=94=B6=E5=B7=A5=E5=8D=95(W05=201.4.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户两报:①「我上一步明确要求重做了,怎么会继续向下推进」②「验收工单弹出的是需要你定,明显不对」。 ①病根两处合谋:人两次答 custom:重做一次,模型把一模一样的问法又发了一遍被闸拦下;而拒回的话 不分答案一律说「照这个选择把结论落进台账那面旗,再 finish」—— 对通过是对的,对重做是把模型往错路上推, 它照办写了 review_approved_total = 1,而 S4 的哨兵读的正是这面模型自写的旗(被叫的那一方自己记账,A6 那一课)。 - 基座新读数 decision_last / decision_<值>_total(按 card-reply 数);S4 哨兵改读 decision_last = "approve", 人最后一次真点了通过才开门;静态审认这几个名字。 - 拒回 / 观察 / 事实行三处同一段 chosenGuidance 按答案分岔:custom: 不是通过 —— 按他写的改、换问法重发、 不许写通过旗、不许 finish;选项值指回说明书那一档。 - S4 剧本驳回分支明写不许写旗、ask 开头必须写明这一轮改了什么(字面一样的卡会被吞)。 ②平台的验收工单是终局,中间审核只能靠决策卡循环 —— 但不该穿「需要你定」的衣服:契约 decisionCard.variant:'review', PlanStep.cardVariant 扩到 'review',cards.decide 带 itemId,引擎按剧本决定;对话区摆成「工单 · 验收 / 审核」。 2106 测 / typecheck / lint / depcruise 绿;api-report 已刷;CONTRACT.md 同步。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh --- .../__tests__/steps.test.ts | 11 +++++- .../src/case-model.ts | 16 ++++++-- .../src/manifest.ts | 2 +- api-report/packages/contracts/src/card.d.ts | 9 +++++ .../packages/contracts/src/manifest.d.ts | 5 +++ .../src/app/__tests__/view-table-edit.test.ts | 18 ++++++++- apps/api/src/app/wiring/engines.ts | 37 ++++++++++++++++++- .../internal/domain/static-audit.ts | 2 + apps/api/src/modules/loop-engine/CONTRACT.md | 4 ++ .../__tests__/turn-runner-acceptance.test.ts | 11 +++++- .../internal/usecase/turn-runner.ts | 36 +++++++++++++----- apps/api/src/modules/loop-engine/port.ts | 2 + .../desk-task/internal/chat-bubbles.tsx | 15 +++++--- packages/contracts/src/card.ts | 7 ++++ packages/contracts/src/manifest.ts | 5 ++- 15 files changed, 154 insertions(+), 26 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 09171a4..9170823 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -146,7 +146,8 @@ describe('说明书与剧本的骨架', () => { for (const item of ['S0d_p08_master', 'S0d_p10_document']) { expect(ASSESSMENT_STEPS.find((s) => s.item === item)?.cardVariant, item).toBe('data'); } - for (const step of ASSESSMENT_STEPS) if (!['S0d_p08_master', 'S0d_p10_document'].includes(step.item)) expect(step.cardVariant, step.item).toBeUndefined(); + expect(ASSESSMENT_STEPS.find((s) => s.item === 'S4_review')?.cardVariant).toBe('review'); + for (const step of ASSESSMENT_STEPS) if (!['S0d_p08_master', 'S0d_p10_document', 'S4_review'].includes(step.item)) expect(step.cardVariant, step.item).toBeUndefined(); expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)'); expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)'); expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认'); @@ -330,6 +331,14 @@ describe('说明书与剧本的骨架', () => { } }); + it('审核通过与否读基座数 decision_last,不由模型自写的旗说了算;驳回分支明写不许写旗、不许 finish(2026-08-27 实测:人答「重做」它照样推到放行)', () => { + expect(CMMN_XML).toContain('decision_last = "approve"'); + expect(CMMN_XML).toContain(' s.item === 'S4_review')?.guide ?? ''; + expect(g).toMatch(/不许写 review_approved_total、不许 finish/); + expect(g).toMatch(/ask 的开头必须写明这一轮改了什么/); + }); + it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => { expect(ASSESSMENT_PHASES).toHaveLength(5); for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 0ef3a51..19e2438 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -64,6 +64,7 @@ export const CMMN_XML = ` + @@ -113,7 +114,12 @@ export const CMMN_XML = ` update - review_approved_total > 0 and substitution_difference_item_missing = 0 + + review_approved_total > 0 and decision_last = "approve" and substitution_difference_item_missing = 0 @@ -540,6 +546,8 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis }, { item: 'S4_review', + /* 中间审核摆成验收工单的样子(机制是可循环的决策卡);由剧本声明,模型抄不抄都一样 */ + cardVariant: 'review' as const, intent: '差异评估结论审核:决策卡循环,通过或驳回 + AI 按意见改台账重发', writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`], reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`], @@ -550,11 +558,13 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis 轨迹里找这一项已答过、还没处理的 [card-reply]: · 没有 → 照说明书⑥发第一张决策卡(ask 里把候选数、比对条数、四态各几条、逐候选推荐等级说全),发完这一拍结束。 · "approve" → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项。别再发卡。 -· "reject" / "custom:…" → **驳回**。按意见改台账(同 PK 覆盖写,不开新行、不改行数): +· "reject" / "custom:…" → **驳回**。**不许写 review_approved_total、不许 finish** —— 下一步的门读的是基座数 + \`decision_last\`(人最后一次真点了 approve 才开),写了旗也开不了门,只会把自己绕进死循环。按意见改台账(同 PK 覆盖写,不开新行、不改行数): · 意见点名某个参数 → 改那一行 \`substitution.difference_item\` 的 impact_statement / unknown_disposition / evidence_ref;要挂验证的补一行 \`validation.record\`; · 意见说推荐理由站不住 → 改 \`substitution.relation.recommendation_reason\`(等级不许改 —— 那是工具算的;要改先回 S3 补证据重比,本活动不回头,写清楚为什么维持); · 没写具体意见("reject")→ 自己挑最站不住的一处改实(证据最含糊的那条 evidence_ref、去向最空的那条 unknown_disposition),**至少改对一处**; - 然后**换一种问法**再发一张决策卡(把改了什么写进 ask),发完结束。 + 然后**换一种问法**再发一张决策卡 —— **ask 的开头必须写明这一轮改了什么**(与上一张字面不同; + 字面一样的卡会被当成同一张吞掉,连吞两次就算被拒),发完结束。 **基座对着字面比对意见与草稿**:观察里点名了哪一行,就改那一行,不挪到别的行头上。`, }, { diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 77226b3..39d7e0b 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -89,7 +89,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', - packageVersion: '1.4.6', + packageVersion: '1.4.7', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts index 408e53d..9c8d4a6 100644 --- a/api-report/packages/contracts/src/card.d.ts +++ b/api-report/packages/contracts/src/card.d.ts @@ -1825,6 +1825,9 @@ export declare const AI_TAG_LABEL: Readonly>; /** 决策卡:分支条件选择表;含自定义收尾项 */ export declare const decisionCardSchema: z.ZodObject<{ k: z.ZodLiteral<"decision">; + variant: z.ZodOptional>; ask: z.ZodString; basis: z.ZodArray; export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ k: z.ZodLiteral<"decision">; + variant: z.ZodOptional>; ask: z.ZodString; basis: z.ZodArray, z.ZodObject<{ k: z.ZodLiteral<"decision">; + variant: z.ZodOptional>; ask: z.ZodString; basis: z.ZodArray; cardVariant: z.ZodOptional>; }, z.core.$strip>; export type PlanStep = z.infer; @@ -267,6 +268,7 @@ export declare const loopModelSchema: z.ZodObject<{ doneWhen: z.ZodOptional; cardVariant: z.ZodOptional>; }, z.core.$strip>>>; phases: z.ZodOptional; cardVariant: z.ZodOptional>; }, z.core.$strip>>>; phases: z.ZodOptional; cardVariant: z.ZodOptional>; }, z.core.$strip>>>; phases: z.ZodOptional; cardVariant: z.ZodOptional>; }, z.core.$strip>>>; phases: z.ZodOptional { expect(createOnlyViolation(sub, { internal_pn: 'XL2596S-5.0', param_name: '输出电压' }, rows)).toBeUndefined(); }); }); + +/** 决策卡的回答是留痕里的事实(2026-08-27:审核哨兵不再读模型自写的旗) */ +describe('decisionReadings:按 card-reply 数', () => { + it('最后一次选的值 + 按值计数;custom: 一律归 custom', () => { + const trail = [ + { kind: 'card-sent', detail: {} }, + { kind: 'card-reply', detail: { value: 'custom:重做一次' } }, + { kind: 'card-reply', detail: { value: 'custom:再改一下' } }, + { kind: 'card-reply', detail: { value: 'approve' } }, + ]; + expect(decisionReadings(trail)).toEqual({ decision_last: 'approve', decision_approve_total: 1, decision_reject_total: 0, decision_custom_total: 2 }); + }); + it('一张都没答 → last 为空串、各计数铺 0(哨兵里 decision_last = "approve" 恒假,门关着)', () => { + expect(decisionReadings([])).toEqual({ decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 }); + }); +}); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index c847e39..f5516b6 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -443,6 +443,31 @@ export function touchedRows( * 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假, * 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。 */ +/** + * **决策卡的回答是留痕里的事实,不是模型的记账**(2026-08-27 W05 实测逼出): + * 审核那一步的入场哨兵读的是模型自写的 `review_approved_total`,人答了「重做」它照写 1,一路推到放行。 + * 与 A6「该不该叫人不能由被叫的那一方说了算」同一课。基座按 card-reply 数: + * `decision_last` —— 这一单最后一张决策卡人选的值(原样;`custom:…` 保留前缀) + * `decision_<值>_total` —— 按值计数(`custom:` 开头的一律归 `decision_custom_total`) + * 值的语义(approve 是通过)归活动的哨兵去写;这里只把事实摆出来。 + */ +export function decisionReadings( + trail: ReadonlyArray<{ readonly kind: string; readonly detail?: Record | undefined }>, +): Record { + const out: Record = { decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 }; + for (const entry of trail) { + if (entry.kind !== 'card-reply') continue; + const raw = entry.detail?.['value']; + if (typeof raw !== 'string' || raw.trim() === '') continue; + const value = raw.trim(); + out['decision_last'] = value; + const token = value.startsWith('custom:') ? 'custom' : value.replace(/[^0-9a-zA-Z]+/g, '_'); + const key = `decision_${token}_total`; + out[key] = Number(out[key] ?? 0) + 1; + } + return out; +} + export function toolCallReadings( trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>, declared: ReadonlyArray<{ readonly name: string }>, @@ -935,6 +960,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { ...tallyReadings(mine, declared), ...distinctReadings(mine, declared), ...toolCallReadings(trail, manifest.declarations.tools), + ...decisionReadings(trail), ...mine, ...touched, }; @@ -1325,6 +1351,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }>; basis?: ReadonlyArray<{ k: string; v: string }>; allowCustom?: boolean; + itemId?: string | undefined; }) { const cardSpan = deps.trace.beginAction({ taskId: input.taskId, @@ -1333,10 +1360,17 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { input: { options: input.options.map((o) => o.value) }, }); const recommended = input.options.find((o) => o.recommended === true); + /* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄 */ + const reviewFace = + input.itemId !== undefined && + (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find( + (st) => st.item === input.itemId, + )?.cardVariant === 'review'; const issued = await deps.humanTool.issueChatCard( { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, { k: 'decision', + ...(reviewFace ? { variant: 'review' as const } : {}), id: cardIdSchema.parse(ids.next()), at: clock.now().toISOString(), ask: input.ask, @@ -1744,7 +1778,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { : (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find( (s) => s.item === input.itemId, )?.cardVariant; - const variant = stepVariant ?? (input.card['variant'] === 'data' ? ('data' as const) : undefined); + // 执行工单这一路只认 data(review 是决策卡那一路的面孔) + const variant = stepVariant === 'data' || input.card['variant'] === 'data' ? ('data' as const) : undefined; const issued = await deps.humanTool.issueExecution(context, { title, basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }], diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index b520877..a4cfe3a 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -311,6 +311,8 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet { for (const tool of manifest.declarations.tools ?? []) { out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`); } + // 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算 + for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name); for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) { const m = /id="([^"]+)"/.exec(id); if (m?.[1] !== undefined) out.add(m[1]); diff --git a/apps/api/src/modules/loop-engine/CONTRACT.md b/apps/api/src/modules/loop-engine/CONTRACT.md index 78d3366..038f2f7 100644 --- a/apps/api/src/modules/loop-engine/CONTRACT.md +++ b/apps/api/src/modules/loop-engine/CONTRACT.md @@ -132,3 +132,7 @@ (`PlanStep.cardVariant`:这一步派的是数据工单),**不靠模型抄卡骨架**(实测它会把 `variant` 抄丢)。 引擎自己发的卡(验收 / 兜底)没有项,不传。 +近变(2026-08-27):`cards.decide` 同样多一项可选 `itemId`(`cardVariant: 'review'` = 审核卡摆成验收工单的样子,机制仍是决策卡); +决策卡的回答进基座读数 `decision_last` / `decision_<值>_total`(按 card-reply 数)—— 审核通过与否不再由模型自写的旗说了算; +拒回 / 观察 / 事实行按答案分岔(`chosenGuidance`):`custom:` 不是通过,不许写通过旗、不许 finish。 + 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 ebff5f4..9035eac 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 @@ -1729,7 +1729,16 @@ describe('「这张卡人已经答过了」摆成每拍都在的事实行', () = const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]); expect(line).toContain('人已经答过了'); expect(line).toContain('他选的是「approve」'); - expect(line).toContain('finish'); + expect(line).toContain('通过才收步'); + }); + + it('**custom: 的答复不是通过**(2026-08-27 W05:人两次答「重做一次」,旧话术「把结论落进台账再 finish」把模型推去写了通过旗,一路推到放行)', () => { + const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'custom:重做一次' } }]); + expect(line).toContain('写了自己的话:「重做一次」'); + expect(line).toContain('这不是通过'); + expect(line).toContain('换一种问法'); + expect(line).toContain('不许 finish'); + expect(line).not.toContain('把结论落进台账'); }); it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => { 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 cdb2c24..a7fd30e 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 @@ -330,7 +330,28 @@ export function answeredCardFact( const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`; return chose === undefined ? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。` - : `${head} → **他选的是「${chose}」**。别再发一张一样的卡 —— 照说明书处理这个选择(通常是把结论落进台账的那一面记账旗,再 finish 这一项)。`; + : `${head} → ${chosenGuidance(chose)}`; +} + +/** + * **人选了什么,下一步就该怎么走** —— 拒回、观察、事实行三处同一句话,按答案分岔。 + * + * 从前不分岔,一律说「照这个选择把结论落进台账那面旗,再 finish」—— 对「通过」是对的, + * 对「重做」是把模型往错路上推:2026-08-27 W05 实测,人两次答 `custom:重做一次`,模型照这句 + * 写了 review_approved_total = 1,审核那一步就被收掉、一路推到放行。 + * `custom:` 是平台定义的自由答复,平台知道它**不是任何一个选项**;选项值的语义平台不知道,只能指回说明书。 + */ +export function chosenGuidance(chose: string | undefined): string { + if (chose === undefined) return '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值),照说明书里那一档办。'; + if (chose.startsWith('custom:')) { + const said = chose.slice(7).trim(); + return ( + `**他没选任何一个选项,写了自己的话:「${said}」** —— 这不是通过。按他写的去改台账(同 PK 覆盖写),` + + '改完**换一种问法**再发一张新卡(标题里写明这一轮改了什么;字面一样的卡会被当成同一张吞掉);' + + '**不许写「已通过」那类记账旗、不许 finish 这一项**。' + ); + } + return `**他选的是「${chose}」** —— 照说明书里「${chose}」那一档办(通过才收步;驳回就改台账、换问法重发)。`; } /** @@ -1211,11 +1232,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { if (verdict.kind === 'reject') { throw new Error( `这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` + - (verdict.chose === undefined - ? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' - : `**他选的是「${verdict.chose}」** —— `) + - '照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' + - '再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。', + chosenGuidance(verdict.chose) + + '真有**另一个**岔路口要人定,换一件事、换一个问法。', ); } await deps.memory.append({ @@ -1224,10 +1242,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { title: echoTitle, detail: { internal: true, - note: - verdict.chose === undefined - ? '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。' - : `人已经在这个岔路口选过了,他选的是「${verdict.chose}」。别再发 —— 照这个选择把结论落进台账,再 finish 这一项。`, + note: '人已经在这个岔路口选过了。别再发同一张 —— ' + chosenGuidance(verdict.chose), }, }); // 什么也没做,局面原样交回 @@ -1236,6 +1251,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId); const card = await deps.cards.decide({ taskId, + itemId: action.itemId, ownerId: context.ownerId, humanCallCap: context.humanCallCap, ask: action.ask, diff --git a/apps/api/src/modules/loop-engine/port.ts b/apps/api/src/modules/loop-engine/port.ts index 3772961..14d4184 100644 --- a/apps/api/src/modules/loop-engine/port.ts +++ b/apps/api/src/modules/loop-engine/port.ts @@ -208,6 +208,8 @@ export type CardGateway = { }>; basis?: ReadonlyArray<{ k: string; v: string }>; allowCustom?: boolean; + /** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */ + itemId?: string | undefined; }): Promise<{ id: string }>; }; diff --git a/apps/web/src/features/desk-task/internal/chat-bubbles.tsx b/apps/web/src/features/desk-task/internal/chat-bubbles.tsx index 6850fd9..f9c0505 100644 --- a/apps/web/src/features/desk-task/internal/chat-bubbles.tsx +++ b/apps/web/src/features/desk-task/internal/chat-bubbles.tsx @@ -504,13 +504,16 @@ function Msg({

{m.ask}

-
+ {/* + * 审核卡(契约 `variant:'review'`)摆成验收工单的样子(2026-08-27 用户:「验收工单弹出的是 + * 需要你定,明显不对」):平台的验收工单是终局,中间审核只能靠决策卡循环 —— 面孔换成 + * 「工单 · 验收」,选项就是通过 / 驳回,机制不变。 + */} +
- - 需要你定 - - {m.options.length + (m.allowCustom ? 1 : 0)} 选 1 - + + {m.variant === 'review' ? '工单 · 验收' : '需要你定'} + {m.variant === 'review' ? '审核' : `${m.options.length + (m.allowCustom ? 1 : 0)} 选 1`}
diff --git a/packages/contracts/src/card.ts b/packages/contracts/src/card.ts index 61d147a..692f1fd 100644 --- a/packages/contracts/src/card.ts +++ b/packages/contracts/src/card.ts @@ -520,6 +520,13 @@ const chatBase = { id: cardIdSchema, at: z.iso.datetime() }; export const decisionCardSchema = z.object({ ...chatBase, k: z.literal('decision'), + /** + * **审核卡**(2026-08-27 用户:「验收工单弹出的是需要你定,明显不对,需要是验收工单」): + * 平台的验收工单是终局(`submit` 一单只发一次,交出去就是放行),中间那道「审核 → 驳回 → 修订 → 再审」 + * 只能用决策卡循环 —— 但它不该穿着「需要你定」的衣服。`review` = 按验收工单的样子摆 + * (工单 · 验收 / 通过 · 驳回),机制仍是决策卡。由剧本 `cardVariant` 声明、引擎决定,不靠模型抄。 + */ + variant: z.enum(['review']).optional(), ask: z.string(), basis: basisSchema, recommend: z.string().optional(), diff --git a/packages/contracts/src/manifest.ts b/packages/contracts/src/manifest.ts index c7f29d9..265ba65 100644 --- a/packages/contracts/src/manifest.ts +++ b/packages/contracts/src/manifest.ts @@ -160,13 +160,14 @@ export const planStepSchema = z.object({ */ doneWhen: z.string().optional(), /** - * **这一步派的执行工单是数据工单**(2026-08-27)—— 由剧本声明、引擎决定,不靠模型抄卡骨架。 + * **这一步派的卡穿哪副面孔**(2026-08-27)—— 由剧本声明、引擎决定,不靠模型抄卡骨架。 + * `data` = 执行工单摆成数据工单;`review` = 决策卡摆成验收工单(中间审核,可循环;终局验收仍是 submit)。 * * 起因:说明书的卡骨架里写着 `"variant":"data"`,模型抄卡时把它丢了(看着像元数据), * 对话区于是照旧摆「执行工单」。与 `view` 同一条道理:这一步派的是什么单,是写剧本时就定死的事, * 归活动声明、不归模型临场发挥;模型写不写都一样。 */ - cardVariant: z.enum(['data']).optional(), + cardVariant: z.enum(['data', 'review']).optional(), }); export type PlanStep = z.infer;