From ca4e2408d8851f1da630537ef67000937c00d0a9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 5 Sep 2026 21:06:38 +0800 Subject: [PATCH] fix(rectification): keep collecting while dated evidence domains remain open Separated candidates were treated as adoptable while finance, relocation, health, or occupation collect was still pending, so clickable cards and a verification report appeared before those questions were asked. Co-authored-by: Cursor --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 17 ++ ...n-collect-vs-offer-consistency-20260905.md | 42 ++++ docs/tasks/README.md | 2 +- ...n-collect-vs-offer-consistency-20260905.md | 39 +++ .../core/rectification-decision.ts | 3 + .../v9/decision-from-dossier.ts | 7 +- ...tification-adopt-flow-fix-20260903.test.ts | 10 +- ...ification-adopt-narration-20260904.test.ts | 16 +- .../tests/rectification-answer-choice.test.ts | 8 +- ...ication-collect-direction-20260904.test.ts | 222 +++++++++++++++++- .../tests/rectification-collect-stall.test.ts | 26 +- .../rectification-coverage-collect.test.ts | 22 +- ...fication-credible-range-projection.test.ts | 27 +++ .../rectification-decide-next-action.test.ts | 40 ++++ .../tests/rectification-eight-method.test.ts | 42 ++++ .../rectification-holdout-renderable.test.ts | 27 +++ ...ification-occupation-coverage-exit.test.ts | 6 +- .../rectification-provisional-adopt.test.ts | 26 +- .../rectification-range-offer-deadend.test.ts | 3 +- 20 files changed, 543 insertions(+), 46 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-collect-vs-offer-consistency-20260905.md create mode 100644 docs/testing/rectification-collect-vs-offer-consistency-20260905.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b972ced6..b66edf17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-05 — 带年份经历没问完不出时间卡,也不写验证报告 + +生时校正里,学业、钱、搬家、身体或工作这些带年份的口述还没问完(也没说没有)时,页面只显示只读时间范围,没有可点的时间卡,助手也不写验证报告。这些问完之后,报告和卡片在同一轮出现,下面不再追问。你说「没有更多」、已经选定时间、或题目已经问尽,仍按原来的收口。Skill 版本仍是 10.0.14。 + ## 2026-09-05 — 区分卡答发生了之后不再问同一领域哪年 生时校正里,如果刚在选择卡上确认某类经历发生过,不会紧接着再问「还记得哪年…」同一领域的采集题。点选仍不记入证据账本。那年没有或记不清时,仍可以问别的年份。Skill 版本仍是 10.0.14。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 66b9e3f1..e3b24a0d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8437,6 +8437,7 @@ - 复发自:BUG-519(采用早退把未用采集域一并吃掉);BUG-539 把该收口写成非缺陷 - 修复版本:`998406ce` - 修正:`998406ce` 把家人之后的 dated 补采集做成无条件优先,现成搬家 A/B 卡被财务口述挤掉。见 BUG-547。 +- 修正:剩余采集豁免了 `deferAdoption`,但决策层在分离度够时仍判 `adopt_representative`,出牌与追问同轮。见 BUG-550。 ## BUG-547 | 现成区分卡被口述补采集挤掉 @@ -8486,3 +8487,19 @@ - 复发自:无 - 修复版本:`988d97ae` +## BUG-550 | 带年份采集没问完就出采用卡,同一轮又被下一道采集题挤掉 + +- 状态:resolved +- 首次发现:2026-09-05 +- 最近更新:2026-09-05 +- 影响面:`decideRectification`、`decideFromDossier`、`decideAfterInferenceChange`;`next_user_action`、offer 工具、采集焦点持久化、客户端时间卡四个消费者 +- 用户现象:家人题答「没有」后出现财务口述题,下面已经挂着可点的候选时间卡;答完财务后助手写出整份生时校正验证报告并说可以采用某个钟点,紧跟着又问搬家,时间卡消失。 +- 触发条件:学业 / 感情 / 事业已有带年份经历,家人已拒答,财务 / 搬家 / 健康 / 职业口述还没问也没拒答;候选分离度已经够给代表时间。 +- 根因:决策层与计划层各说各的。`datedMethodCollectOpen` 只数四个阻断方法(Dasha / D9 / D10 / 家人),不数 `DATED_COLLECT_ORDER` 里还没问的学业、财务、搬家、健康与职业口述。分离度够时直接 `adopt_representative`。BUG-546 只让剩余采集豁免 `deferAdoption`,所以 `session_outcome` 已经是可采用,`next_followup` 却仍是下一道采集。四个消费者听了不同的一层:`next_user_action` 让模型写出牌报告并调 offer;offer 工具放行;焦点持久化仍写下道采集题;客户端按 `session_outcome` 出卡,未答题在场时才被 BUG-544 藏掉。 +- 修复:传给 `decideRectification` 的 `datedMethodCollectOpen` 叠加「采集计划的下一问仍是剩余带年份采集」。分离度够、尚未采用、用户未停止、也未耗尽时,保持 `collect_evidence`,公开 `can_adopt=false`。问完(覆盖、拒答)之后同一轮才 `adopt_representative`,报告和卡片一起出,下面不再跟采集题。不改 Skill、不改引擎、不改 BUG-544 隐藏规则。用户说「没有更多」、已采用、已耗尽仍走原出口。 +- 验证:`rectification-decide-next-action`:分离且采集仍开 → `collect_evidence` / `ask_fact_collection` / 公开不可采用;`userStopped` 与 `accepted` 不因此改口。`rectification-collect-direction-20260904`:家人已拒、财务未问 → 决策采集、持久化财务、offer 工具 `offer_not_allowed`;财务 / 搬家 / 健康 / 职业补齐后 `adopt_representative` 且 `next_followup` 为空。`npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts` 904/904。 +- 防复发:剩余带年份采集未完时,不得因分离度够就判 `adopt_representative`。不得只改客户端藏卡或只改 offer 工具描述来掩盖决策与计划矛盾。不得把「卡先给、问题照问」当成默认;那条路要产品另开单。 +- 相关记录:BUG-544、BUG-546、BUG-547、BUG-549 +- 复发自:BUG-546(计划层继续采集,决策层已经可采用) +- 修复版本:待发布 + diff --git a/docs/tasks/PROGRESS-rectification-collect-vs-offer-consistency-20260905.md b/docs/tasks/PROGRESS-rectification-collect-vs-offer-consistency-20260905.md new file mode 100644 index 00000000..da7a5ce6 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-collect-vs-offer-consistency-20260905.md @@ -0,0 +1,42 @@ +# PROGRESS · 采集没问完就出采用卡(2026-09-05) + +工作树:`.worktrees/rectification-collect-vs-offer-consistency-20260905` +分支:`codex/rectification-collect-vs-offer-consistency-20260905` +基线:`origin/staging` @ `3edb628c`(任务书写的是 `1052a027`;开工时 staging 已含后续提交,以当时 `origin/staging` 为准)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`、未改引擎、未改 BUG-544 隐藏规则。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 剩余带年份采集未完不判可采用;出牌轮才出卡写报告 | 完成 | BUG-550 | + +## 编号 + +开工时最大号 BUG-549。本单 **BUG-550**。未占用预留的 BUG-542。 + +## 产品取舍 + +任务书决策 1:**采集优先**。学业 / 财务 / 搬家 / 健康 / 职业口述没问完(也没拒答)之前,决策保持 `collect_evidence`:页面只有只读区间、没有可点的卡、Agent 不写验证报告。问完之后一轮内报告 + 卡片一起出,下面不再跟问题。用户说「没有更多」、已采用、已耗尽出口不变。 + +未走决策记录 3 的否决项(卡先给、问题照问)。那条路要动 BUG-544 藏卡规则、不动决策层;本单按默认走决策层一处改。 + +未过滤流事件 JSON(`{"type":"skill.bound"}`),见任务书 §7。 + +## 实现要点 + +- `decideFromDossier` / `decideAfterInferenceChange`:`datedMethodCollectOpen(collecting.methods) || isRemainingEvidenceCollect(collecting.next_followup)`。`collecting` 仍用 `sessionOutcome: "collect_evidence"`、不带区分卡目录,所以下一问是账本上还没问的带年份采集。 +- `decideRectification`:分离度够、`accepted` 之后、确认门之前,若采集仍开且用户未停止 → `collect()`。`exhausted` 已在上一分支返回,条件里不再写 `stopClass?.kind !== "exhausted"`(tsc 会判无重叠)。 +- 四个消费者不改各自规则:`next_user_action` 变成 `ask_fact_collection`;offer 因 kind 不在 `offerSessionKinds` 而 `offer_not_allowed`;客户端走只读区间;`shouldSkipFollowupPersist` 行为不变。 +- 既有「可采用 / 出卡 / 采用旁白」夹具把剩余带年份域补覆盖或拒答,避免训练门未开或剩余采集把主题测成采集。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts` | 904 项;pass 904;fail 0 | +| `npx tsc --noEmit` | 0 错 | +| 改动文件 eslint `--quiet` | 0 error | +| `npm run lint` | 0 error;73 条既有 warning,不在本单文件 | +| `page.tsx` | 未改 | + +开工对照 BUG-549 进度记录为 898 项。本单新增决策层与端到端用例后为 904。 + +未做浏览器真人走查。清单:`docs/testing/rectification-collect-vs-offer-consistency-20260905.md`。旧会话在部署前仍可能先出卡再被采集题挤掉。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index bed0a247..39c17c75 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -55,7 +55,7 @@ | — | `PROGRESS-rectification-family-collect-continue-20260905.md` | 家人答「没有」后直接给结果,未继续收能区分分钟的经历 | 已由 cd704a92 修正并验收通过 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-546) | | `TASK-rectification-family-collect-continue-fix-20260905.md` | `PROGRESS-rectification-family-collect-continue-20260905.md` | `998406ce` 修复单:家人之后的 dated 补采集无条件抢在现成区分卡之前(choice-card A/B 卡用例回归),另两条顺序断言缺三栏说明 | 已验收通过(浏览器走查欠) | `cd704a92`(BUG-547) | | `TASK-rectification-probe-answer-covers-domain-20260905.md` | `PROGRESS-rectification-probe-answer-covers-domain-20260905.md` | 区分卡答 A/B 后紧接着又问同领域"哪年…"采集题:点选答案只进推断层不写账本,计划层领域覆盖只看账本;改为 yes/weak_yes 视为该领域采集已覆盖(不写账本、不动门) | 待验收 | `988d97ae`(BUG-549) | -| `TASK-rectification-collect-vs-offer-consistency-20260905.md` | `PROGRESS-rectification-collect-vs-offer-consistency-20260905.md` | 带年份采集没问完就出采用卡 + 报告,同一轮又被搬家采集题把卡挤掉:决策层判 `adopt_representative` 而计划层仍有 dated 采集(BUG-546 只修了一半);改为剩余采集未完保持 `collect_evidence`,出牌轮才出卡写报告 | 待执行 | `codex/rectification-collect-vs-offer-consistency-20260905`(BUG-550 起) | +| `TASK-rectification-collect-vs-offer-consistency-20260905.md` | `PROGRESS-rectification-collect-vs-offer-consistency-20260905.md` | 带年份采集没问完就出采用卡 + 报告,同一轮又被搬家采集题把卡挤掉:决策层判 `adopt_representative` 而计划层仍有 dated 采集(BUG-546 只修了一半);改为剩余采集未完保持 `collect_evidence`,出牌轮才出卡写报告 | 待验收 | `codex/rectification-collect-vs-offer-consistency-20260905`(BUG-550) | | `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`(BUG-542 起) | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-collect-vs-offer-consistency-20260905.md b/docs/testing/rectification-collect-vs-offer-consistency-20260905.md new file mode 100644 index 00000000..df4dc0ed --- /dev/null +++ b/docs/testing/rectification-collect-vs-offer-consistency-20260905.md @@ -0,0 +1,39 @@ +# Staging 人肉复核 · 带年份采集没问完不出卡、也不写报告(2026-09-05) + +给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。 + +对应 BUG-550。测之前先做第 0 条。本单部署前,旧会话仍可能在财务题还没答时就挂出可点时间卡,答完后又写整份验证报告并追问搬家。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 家人「没有」之后到财务问完之前:只有只读区间(P0) + +1. 新建生时校正。 +2. 先说清学业、感情、事业各一件记得住大概年份的事,把区分卡答完,直到出现家人那题。 +3. 回答「没有」。 +4. 出现财务口述题(「钱的方面…」)时看页面。 + +- ✅ 预期:只有只读时间范围。没有可点的候选时间卡。助手正文不是生时校正验证报告,也不说「当前可以采用」某个钟点。 +- ❌ 失败:财务题还没答,下面已经挂着可点的时间卡;或助手已经在写验证报告。 + +## 2. 搬家 / 健康 / 职业口述同样不出卡(P0) + +继续答财务(补一件带年份的事或说「没有」),再看搬家、身体、工作口述。 + +- ✅ 预期:这些题还没问完(也没拒答)时,仍然只有只读区间、没有可点的卡、没有验证报告。 +- ❌ 失败:某一题还在问,卡片或报告已经出现。 + +## 3. 问完之后一轮内报告 + 卡片,下面不再跟问题(P0) + +把财务、搬家、健康、职业口述都答完或都说没有。 + +- ✅ 预期:同一轮出现验证报告和可点的时间卡。这条助手消息下面不再跟财务 / 搬家 / 健康 / 职业采集题。 +- ❌ 失败:先出报告和卡,下面又问搬家;或报告出了但没有卡;或卡出了但下面还有口述题。 + +## 4. 三个旧出口不变(P1) + +- 用户说「没有更多了」:仍按原来的规则给区间或代表时间,不得因此改口继续追问财务 / 搬家。 +- 已经点选并采用某个时间:不得因为本单再开始问带年份采集。 +- 题目已经问尽(耗尽):仍按原来的收口,不得改口继续采集。 diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 2a41b3f4..a8235796 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -335,6 +335,9 @@ export function decideRectification(input: DecideRectificationInput): Rectificat capability, }); } + if (input.datedMethodCollectOpen === true && !input.userStopped) { + return collect(separation, holdout, range, null, capability, stopReason); + } if (confirmationAllowed) { return finish("awaiting_confirmation", { input, diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 7c43f9f5..7b58f567 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -33,6 +33,7 @@ import { blockingMethodsCovered, buildMethodFollowupPlan, datedMethodCollectOpen, + isRemainingEvidenceCollect, } from "./method-followup"; import { buildConfirmationGate } from "./confirmation-gate"; import { @@ -629,7 +630,8 @@ export function decideFromDossier( accepted: Boolean(dossier.case.acceptedTime), inferenceCredibleRange: inference?.credible_range ?? null, engineCeiling: engineCapabilityCeilingFromReceipt(latest?.decisionReceipt ?? null), - datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods), + datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods) + || isRemainingEvidenceCollect(collecting.next_followup), ...decisionBudget, ...evidenceStops, userUncertaintyHigh, @@ -715,7 +717,8 @@ export function decideAfterInferenceChange(input: { userStopped: input.userStopped, accepted: Boolean(input.dossier.case.acceptedTime), engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null), - datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods), + datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods) + || isRemainingEvidenceCollect(collecting.next_followup), ...decisionBudgetFromInference(input.state), ...evidenceStops, userUncertaintyHigh, diff --git a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts index d1f74b64..d39e9a4e 100644 --- a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts +++ b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts @@ -240,8 +240,16 @@ test("collect-phase nonterminal exit persists a spoken collect when public can_a }); test("adopt_representative nonterminal exit stays satisfied without a new question", async () => { - const decisionDossier = collectDecisionDossier([{ target_domain: "family", status: "declined" }]); + const decisionDossier = collectDecisionDossier([ + { target_domain: "family", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "finance", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "relocation", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "occupation", status: "declined", intent: "collect_method_evidence" }, + ]); const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" }); + // 旧:家人拒答即可 adopt_representative。新:带年份采集未完不得出牌;本夹具把剩余域拒答后才满足。 assert.equal(decision.sessionOutcome, "adopt_representative"); assert.equal(publicCanAdopt(decision), true); diff --git a/frontend/tests/rectification-adopt-narration-20260904.test.ts b/frontend/tests/rectification-adopt-narration-20260904.test.ts index ae706822..4aa053c1 100644 --- a/frontend/tests/rectification-adopt-narration-20260904.test.ts +++ b/frontend/tests/rectification-adopt-narration-20260904.test.ts @@ -676,11 +676,13 @@ test("fourteen-probe case decides offer_provisional_range and skips leftover pro ); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); - assert.equal(decision.nextAction, "offer_provisional_range"); - assert.equal(decision.sessionOutcome, "adopt_representative"); + // 旧:十四探针分不开就 offer_provisional_range / adopt_representative,财务采集只挂计划。 + // 新:财务口述未问完,决策保持采集;出牌轮才写报告。 + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.canAdopt, true); assert.equal(decision.probe, null); - assert.equal(decision.precisionStage, "ready_to_adopt"); + assert.equal(decision.precisionStage, "collect_events"); const plan = planFrom(dossier); assert.equal(plan.next_followup?.domain, "finance"); @@ -753,7 +755,7 @@ test("adoptDeliveryFacts names the representative, range, stop class, and post-a }); test("adopt narration agent keeps in-fact copy and fail-closes the rest", async () => { - const dossier = caseDossier(); + const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; @@ -949,7 +951,7 @@ test("three adopt entry points call the model once on first ready_to_adopt and n }); test("adopt narration diagnostics distinguish agent, validation, error, and not-ready", async () => { - const dossier = caseDossier(); + const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; @@ -991,7 +993,7 @@ test("adopt narration diagnostics distinguish agent, validation, error, and not- }); test("adopt narration times out to the template without throwing", async () => { - const dossier = caseDossier(); + const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; @@ -1006,7 +1008,7 @@ test("adopt narration times out to the template without throwing", async () => { }); test("adopt narration does not leave an active timeout after the model returns", async () => { - const dossier = caseDossier(); + const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 42393cbe..4659f481 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -477,7 +477,13 @@ function adoptionDossier() { }, }), conversationSummary: conversationSummaryFixture({ - declinedSkippedTopics: [{ target_domain: "family", status: "skipped" }], + declinedSkippedTopics: [ + { target_domain: "family", status: "skipped" }, + { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "finance", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "relocation", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, + ], activeFocus: activeFocusFixture({ intent: "distinguish_candidates", targetDomain: "relocation", diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index 5ed5ad82..3fb22dec 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -6,8 +6,10 @@ import { collectionProgressFromReceipt, meetsAcceptanceEventQuality, } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; +import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { buildMethodFollowupPlan, + buildNextUserAction, exhaustionSpokenCollectFollowup, holdoutFollowupFor, nextDatedCollectFollowup, @@ -15,14 +17,24 @@ import { type MethodFollowupEvidence, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; -import { persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { + persistNextInterviewAfterChoice, + persistNextInterviewIfIdle, +} from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; -import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + evidenceLedgerFingerprint, + parseV9CaseDossier, + RectificationToolServiceError, +} from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { + CANDIDATE_ID, CASE_ID, FOCUS_ID, + SECOND_CANDIDATE_ID, TURN_ID, USER_ID, candidateSnapshotFixture, @@ -569,3 +581,209 @@ test("ledger-classified career probe answers do not cover career collect", () => assert.equal(plan.next_followup?.domain, "career"); assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career); }); + +const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883"; + +const CORE_DATED_FOR_OFFER = [ + dated("education", "2008", { eventKind: "education_start" }), + dated("education", "2012", { eventKind: "education_completion" }), + dated("relationship", "2014", { eventKind: "relationship_start" }), + dated("career", "2011", { eventKind: "career_entry" }), +] as const; + +function rpcEvidenceFromDated( + item: MethodFollowupEvidence & { id: string }, +) { + return { + id: item.id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind ?? "event", + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: `${item.occurredFrom ?? "undated"} ${item.eventKind ?? "event"}`, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-04T00:00:00.000Z", + }; +} + +function declinedTopics( + domains: readonly string[], +): Array<{ target_domain: string; status: "declined"; intent: "collect_method_evidence" }> { + return domains.map((domain) => ({ + target_domain: domain, + status: "declined" as const, + intent: "collect_method_evidence" as const, + })); +} + +function conversationWithDeclines(domains: readonly string[]) { + return { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: null, + declined_skipped_topics: declinedTopics(domains), + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-04T00:00:00.000Z", + }; +} + +function separatedOfferSnapshot(rawEvidence: unknown[]) { + const parsed = parseV9CaseDossier(dossierFixture({ evidence: rawEvidence })); + assert.ok(parsed); + return candidateSnapshotFixture({ + selectionAllowed: true, + representativeTime: "05:00", + evidenceLedgerFingerprint: evidenceLedgerFingerprint(parsed.evidence), + candidates: [ + { candidate_id: CANDIDATE_ID, rank: 1, time: "05:00", relative_support: 62, tied_minute_count: 1 }, + { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "05:01", relative_support: 22, tied_minute_count: 1 }, + { candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "05:02", relative_support: 16, tied_minute_count: 1 }, + ], + }); +} + +function collectVsOfferDossier(input: { + extraEvidence?: readonly (MethodFollowupEvidence & { id: string })[]; + declined: readonly string[]; +}) { + const evidence = [...CORE_DATED_FOR_OFFER, ...(input.extraEvidence ?? [])]; + const raw = evidence.map(rpcEvidenceFromDated); + return dossierFixture({ + evidence: raw, + evidenceCount: raw.length, + latestResult: separatedOfferSnapshot(raw), + conversationSummary: conversationWithDeclines(input.declined), + }); +} + +function nextActionFromDecision( + dossier: NonNullable>, + decision: ReturnType, +) { + const catalog = { + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: decision.sessionOutcome, + }; + const plan = buildMethodFollowupPlan(catalog); + return { + plan, + userAction: buildNextUserAction({ + scorableCount: dossier.evidence.length, + evidenceCount: dossier.evidence.length, + hasLatestResult: true, + selectionAllowed: true, + sessionOutcome: decision.sessionOutcome, + nextFollowup: plan.next_followup, + workingTime: decision.representativeTime, + }), + }; +} + +async function persistIdleFocus(rawDossier: ReturnType) { + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rawDossier, + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-04T00:00:00.000Z", + resolved_at: null, + asked_turn_id: args.p_asked_turn_id ?? null, + }, + idempotent: false, + }), + }); + const persisted = await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + return { persisted, setFocus }; +} + +test("separated candidates keep collecting finance before offering time cards", async () => { + const raw = collectVsOfferDossier({ declined: ["family"] }); + const dossier = parseV9CaseDossier(raw); + assert.ok(dossier); + const decision = decideFromDossier(dossier); + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(publicDecisionFields(decision).can_adopt, false); + const { plan, userAction } = nextActionFromDecision(dossier, decision); + assert.equal(plan.next_followup?.domain, "finance"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(userAction.id, "ask_method_followup"); + assert.notEqual(userAction.id, "adopt_representative"); + const idle = await persistIdleFocus(raw); + assert.equal(idle.persisted.persisted, true); + assert.equal(idle.setFocus?.args.p_target_domain, "finance"); +}); + +test("dated collect plus occupation close lets one round offer without another question", async () => { + const extra = [ + dated("finance", "2017", { eventKind: "income_change" }), + dated("relocation", "2016", { eventKind: "home_change" }), + dated("health_pressure", "2015", { eventKind: "self_health_event" }), + dated("occupation", "2011", { eventKind: "occupation_note" }), + ]; + const raw = collectVsOfferDossier({ + extraEvidence: extra, + declined: ["family"], + }); + const dossier = parseV9CaseDossier(raw); + assert.ok(dossier); + const decision = decideFromDossier(dossier); + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(publicDecisionFields(decision).can_adopt, true); + const { plan, userAction } = nextActionFromDecision(dossier, decision); + assert.equal(plan.next_followup, null); + assert.equal(userAction.id, "adopt_representative"); + const idle = await persistIdleFocus(raw); + assert.equal(idle.persisted.persisted, false); + assert.equal(idle.setFocus, undefined); +}); + +test("offer-candidates refuses while remaining dated collect is still open", async () => { + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => collectVsOfferDossier({ declined: ["family"] }), + }); + const tools = createRectificationV9Tools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + accounting: accounting.client as never, + }); + await assert.rejects( + () => (tools["rectification-offer-candidates"] as unknown as { + execute(input: unknown): Promise; + }).execute({ caseId: CASE_ID }), + (error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed", + ); + assert.equal( + accounting.calls.some((call) => + call.fn === "transition_agentic_rectification_case_status" + && call.args.p_status === "candidate_ready" + ), + false, + ); +}); diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index bfd80077..64eb0b04 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -1049,6 +1049,7 @@ test("nonterminal turn exit is already satisfied once dated coverage can adopt", caseId: string; }) => Promise<{ hostNarration: string | null; persisted: boolean }>); assert.equal(typeof ensureExit, "function"); + // 旧:内部 canAdopt 即 satisfied。新:公开可采用才算出牌;本夹具把剩余带年份域拒答。 const decision = liveCaseDossier(); const current = rpcDossier(decision); const fingerprint = evidenceLedgerFingerprint( @@ -1064,7 +1065,26 @@ test("nonterminal turn exit is already satisfied once dated coverage can adopt", })) as never, ); (current.latest_result as { evidence_ledger_fingerprint: string }).evidence_ledger_fingerprint = fingerprint; - const next = decideFromDossier(decision, { birthDate: "1997-08-08" }); + (current.conversation_summary as { declined_skipped_topics: unknown[] }).declined_skipped_topics = [ + { target_domain: "family", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "finance", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "relocation", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "occupation", status: "declined", intent: "collect_method_evidence" }, + ]; + const next = decideFromDossier({ + ...decision, + conversationSummary: { + ...decision.conversationSummary, + declinedSkippedTopics: [ + { target_domain: "family", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "finance", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "relocation", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "occupation", status: "declined", intent: "collect_method_evidence" }, + ], + }, + }, { birthDate: "1997-08-08" }); assert.equal(publicCanAdopt(next), true); const accounting = fakeAccounting({ ...receiptHandlers, @@ -1380,6 +1400,10 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", const covered = revision5Dossier(revision5State([DATED_RELOCATION_2016]), { declinedTopics: [ { target_domain: "family", status: "declined" }, + { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "finance", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "relocation", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, { target_domain: "occupation", status: "declined" }, { target_domain: "horary", status: "declined" }, ], diff --git a/frontend/tests/rectification-coverage-collect.test.ts b/frontend/tests/rectification-coverage-collect.test.ts index fde03db9..1e0abf71 100644 --- a/frontend/tests/rectification-coverage-collect.test.ts +++ b/frontend/tests/rectification-coverage-collect.test.ts @@ -170,7 +170,9 @@ test("separated scores with uncovered relatives stay in collection and still ask test("covering family without occupation still opens adopt after scores already separated", () => { const familyOnly = producedDossier([...TRAINING_EVIDENCE, FAMILY_EVIDENCE]); const familyDecision = decideFromDossier(familyOnly.dossier); - assert.notEqual(familyDecision.nextAction, "ask_fact_collection"); + // 旧:家人覆盖即可出牌。新:财务/搬家/健康/职业口述未问完保持采集。 + assert.equal(familyDecision.nextAction, "ask_fact_collection"); + assert.equal(familyDecision.sessionOutcome, "collect_evidence"); assert.equal(familyDecision.canAdopt, true); assert.equal(familyDecision.canConfirmExactMinute, false); }); @@ -182,14 +184,9 @@ test("covering family and occupation still opens adopt after scores already sepa const covered = [...TRAINING_EVIDENCE, FAMILY_EVIDENCE, OCCUPATION_EVIDENCE]; const { dossier } = producedDossier(covered, { previous: state }); const decision = decideFromDossier(dossier); - assert.notEqual(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.canAdopt, true); - assert.ok( - decision.nextAction === "ask_holdout_validation" - || decision.nextAction === "ready_to_adopt" - || decision.nextAction === "offer_provisional_range", - decision.nextAction, - ); assert.equal(decision.canConfirmExactMinute, false); assert.equal(decision.separation.sufficient, true); }); @@ -206,13 +203,8 @@ test("declining family and occupation also opens adopt after scores already sepa assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered"); const decision = decideFromDossier(dossier); - assert.notEqual(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.canAdopt, true); - assert.ok( - decision.nextAction === "ask_holdout_validation" - || decision.nextAction === "ready_to_adopt" - || decision.nextAction === "offer_provisional_range", - decision.nextAction, - ); assert.equal(decision.canConfirmExactMinute, false); }); diff --git a/frontend/tests/rectification-credible-range-projection.test.ts b/frontend/tests/rectification-credible-range-projection.test.ts index b8e073f2..f0326b9f 100644 --- a/frontend/tests/rectification-credible-range-projection.test.ts +++ b/frontend/tests/rectification-credible-range-projection.test.ts @@ -63,6 +63,33 @@ const COVERAGE_EVIDENCE = [ occurredTo: null, eventKind: "occupation_note", }, + { + id: "e-fin", + status: "confirmed", + domain: "finance", + datePrecision: "year", + occurredFrom: "2017-01-01", + occurredTo: null, + eventKind: "income_change", + }, + { + id: "e-reloc", + status: "confirmed", + domain: "relocation", + datePrecision: "year", + occurredFrom: "2019-01-01", + occurredTo: null, + eventKind: "home_change", + }, + { + id: "e-health", + status: "confirmed", + domain: "health_pressure", + datePrecision: "year", + occurredFrom: "2021-01-01", + occurredTo: null, + eventKind: "self_health_event", + }, ] as const; function producedSnapshot( diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index b012a6f9..30b526c2 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -609,3 +609,43 @@ test("collect_evidence with open capability still publishes can_adopt=false", () assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true); }); +const SEPARATED_COLLECT_OPEN = { + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + methodCoverageAll: true, + datedMethodCollectOpen: true, + datedEventCount: 5, + datedDomainCount: 3, + trainingGateOpen: true, + snapshotCurrent: true, + candidateScores: SEPARATED, + holdoutValidation: "unavailable" as const, + confirmationAllowed: false, +}; + +test("separated candidates stay in collect while dated method collect is still open", () => { + const decision = decideRectification(SEPARATED_COLLECT_OPEN); + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(publicDecisionFields(decision).can_adopt, false); +}); + +test("user stop still delivers while dated method collect is open", () => { + const stopped = decideRectification({ + ...SEPARATED_COLLECT_OPEN, + userStopped: true, + }); + assert.equal(stopped.sessionOutcome, "adopt_representative"); + assert.equal(stopped.nextAction, "complete_with_range"); + assert.equal(stopped.completionStatus, "provisional_range_user_stopped"); + assert.equal(publicDecisionFields(stopped).can_adopt, true); +}); + +test("accepted representative adopt ignores open dated collect", () => { + const accepted = decideRectification({ + ...SEPARATED_COLLECT_OPEN, + accepted: true, + }); + assert.equal(accepted.sessionOutcome, "adopt_representative"); + assert.equal(accepted.nextAction, "ready_to_adopt"); +}); + diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 1334c904..2e88b12b 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -348,6 +348,48 @@ const methodCoverageTieEvidence = [ supersedes_evidence_id: null, created_at: "2026-08-12T10:00:10.000Z", }, + { + id: "44444444-4444-4444-8444-444444444447", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "income_change", + domain: "finance", + occurred_from: "2017-01-01", + occurred_to: null, + date_precision: "year", + summary: "收入变化", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:11.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444448", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "home_change", + domain: "relocation", + occurred_from: "2015-01-01", + occurred_to: null, + date_precision: "year", + summary: "搬家", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:12.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444449", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "self_health_event", + domain: "health_pressure", + occurred_from: "2013-01-01", + occurred_to: null, + date_precision: "year", + summary: "身体压力变化", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:13.000Z", + }, ]; function scoreableFingerprintForRawEvidence(evidence: unknown[]): string { diff --git a/frontend/tests/rectification-holdout-renderable.test.ts b/frontend/tests/rectification-holdout-renderable.test.ts index 868af901..de134044 100644 --- a/frontend/tests/rectification-holdout-renderable.test.ts +++ b/frontend/tests/rectification-holdout-renderable.test.ts @@ -63,6 +63,33 @@ const COVERED_EVIDENCE = [ occurredTo: null, eventKind: "occupation_note", }, + { + id: "e-fin", + status: "confirmed", + domain: "finance", + datePrecision: "year", + occurredFrom: "2017-01-01", + occurredTo: null, + eventKind: "income_change", + }, + { + id: "e-reloc", + status: "confirmed", + domain: "relocation", + datePrecision: "year", + occurredFrom: "2019-01-01", + occurredTo: null, + eventKind: "home_change", + }, + { + id: "e-health", + status: "confirmed", + domain: "health_pressure", + datePrecision: "year", + occurredFrom: "2021-01-01", + occurredTo: null, + eventKind: "self_health_event", + }, ] as const; function producedDossier( diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index 3323e074..c50b781b 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -369,8 +369,10 @@ test("decideFromDossier offers a range with adopt when training is complete and }, case: { acceptedTime: null }, }); - assert.equal(decision.canOfferRange, true); - assert.equal(decision.canAdopt, true); + // 旧:训练门开、职业未覆盖即可出牌。新:财务/健康/职业口述未问完保持采集。 + assert.equal(decision.canOfferRange, false); + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.nextAction, "ask_fact_collection"); assert.equal(decision.canConfirmExactMinute, false); }); diff --git a/frontend/tests/rectification-provisional-adopt.test.ts b/frontend/tests/rectification-provisional-adopt.test.ts index 7f41317b..e96facc8 100644 --- a/frontend/tests/rectification-provisional-adopt.test.ts +++ b/frontend/tests/rectification-provisional-adopt.test.ts @@ -365,10 +365,9 @@ test("incident shape 1a: occupation coverage must open provisional adopt without const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered })); assert.equal(decision.separation.lead, 5, occupationCovered); assert.equal(decision.separation.sufficient, false, occupationCovered); - assert.equal(overlaid.can_adopt, true, occupationCovered); - assert.equal(overlaid.selection_allowed, true, occupationCovered); - assert.deepEqual(overlaid.credible_range, ["05:00", "05:07"], occupationCovered); - assert.equal(overlaid.representative_time, "05:00", occupationCovered); + // 旧:职业覆盖即可公开采用。新:财务等带年份采集未问完,公开 can_adopt 保持关闭。 + assert.equal(overlaid.can_adopt, false, occupationCovered); + assert.equal(overlaid.session_outcome, "collect_evidence", occupationCovered); assert.equal(overlaid.can_confirm_exact_minute, false, occupationCovered); assert.equal(decision.canConfirmExactMinute, false, occupationCovered); } @@ -384,8 +383,8 @@ test("incident shape 1b: exact-minute confirm stays closed on the accident case" test("incident shape 1c: uncovered occupation still collects remaining dated events", async () => { const dossier = incidentDossier({ occupationCovered: false }); const { decision, overlaid } = publicFields(dossier); - assert.equal(overlaid.can_adopt, true); - assert.equal(overlaid.selection_allowed, true); + assert.equal(overlaid.can_adopt, false); + assert.equal(overlaid.session_outcome, "collect_evidence"); assert.equal(overlaid.can_confirm_exact_minute, false); let writtenDomain: string | null = null; @@ -449,7 +448,7 @@ test("incident shape 1c: uncovered occupation still collects remaining dated eve can_adopt: overlaid.can_adopt, representativeTime: overlaid.representative_time, decisionReceipt: dossier.latestResult?.decisionReceipt ?? null, - })), true); + })), false); }); test("after-choice persist collects remaining dated events after family denial", async () => { @@ -579,8 +578,9 @@ test("incident shape 4: exact-minute confirm stays fail-closed even when local g test("delivery B1: GET overlay after occupation coverage renders representative cards", () => { const dossier = incidentDossier({ occupationCovered: "declined" }); const { decision, overlaid } = publicFields(dossier); - assert.equal(decision.sessionOutcome, "adopt_representative"); - assert.equal(decision.nextAction, "offer_provisional_range"); + // 旧:职业拒答即可出可点卡。新:财务口述未问完只给只读区间。 + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.nextAction, "ask_fact_collection"); const parsed = parseRectificationCandidateResult({ resultId: dossier.latestResult?.resultId, candidates: overlaid.candidates, @@ -592,7 +592,7 @@ test("delivery B1: GET overlay after occupation coverage renders representative decisionReceipt: dossier.latestResult?.decisionReceipt ?? null, }); assert.ok(parsed); - assert.equal(canRenderRectificationSelectionCards(parsed), true); + assert.equal(canRenderRectificationSelectionCards(parsed), false); assert.deepEqual(parsed.candidates.map((item) => item.time), ["05:00", "05:07", "04:53"]); assert.equal(parsed.representativeTime, "05:00"); }); @@ -606,7 +606,7 @@ test("delivery B2: remaining dated collect stays in front of the adopt early-exi closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: decision.sessionOutcome, }); - assert.equal(plan.session_outcome, "adopt_representative"); + assert.equal(plan.session_outcome, "collect_evidence"); assert.equal(plan.next_followup?.domain, "finance"); const action = buildNextUserAction({ scorableCount: 5, @@ -617,8 +617,8 @@ test("delivery B2: remaining dated collect stays in front of the adopt early-exi nextFollowup: plan.next_followup, workingTime: "05:00", }); - // 能力层仍可 adopt;真正下一问是财务采集。公开 next-action 仍跟 sessionOutcome。 - assert.equal(action.id, "adopt_representative"); + // 旧:能力层 adopt、next-action 仍 adopt_representative。新:决策与计划同为采集。 + assert.equal(action.id, "ask_method_followup"); let writes = 0; const accounting = fakeAccounting({ diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index 13055ac9..bb00813d 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -505,7 +505,8 @@ test("live remaining probes with zero active split drop and open provisional ado const dossier = liveDossier(remaining); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(decision.probe, null); - assert.equal(decision.nextAction, "offer_provisional_range"); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(isNonConvergingRangeOffer(decision), false); assert.equal(decision.canAdopt, true); assert.equal(decision.selectionAllowed, true);