From 998406cee6ab28f651574e3eac57f0a5412435c0 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 5 Sep 2026 09:38:59 +0800 Subject: [PATCH] fix(rectification): keep collecting after family denial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Family "没有" was treated as adopt-ready once the training gate was open. Remaining dated domains still distinguish leftover minutes, so persist those questions instead of writing the adopt narration. Co-authored-by: Cursor --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 20 ++- ...cation-family-collect-continue-20260905.md | 43 ++++++ docs/tasks/README.md | 1 + ...cation-family-collect-continue-20260905.md | 32 ++++ .../rectification-agentic/v9/answer-choice.ts | 2 + .../v9/method-followup.ts | 44 ++++-- ...ification-adopt-narration-20260904.test.ts | 109 +++++++++++--- ...ication-collect-direction-20260904.test.ts | 7 +- .../tests/rectification-collect-stall.test.ts | 43 +++--- .../tests/rectification-eight-method.test.ts | 141 ++++++++++++++---- .../rectification-provisional-adopt.test.ts | 116 +++++++++----- .../rectification-range-offer-deadend.test.ts | 34 +++-- 13 files changed, 464 insertions(+), 132 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-family-collect-continue-20260905.md create mode 100644 docs/testing/rectification-family-collect-continue-20260905.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cadf541c..ced55166 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 908480b8..fd4623f7 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8333,11 +8333,11 @@ - 影响面:`collectionYearFields`、`spokenFollowupForUser` 对 `collect_method_evidence` - 用户现象:家庭采集题写成「某年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。」既指定了年份又让用户报年份。 - 触发条件:家庭轮转把 dated 探针的 `year_label` 拼进 `USER_COLLECT_QUESTION.family`。 -- 根因:`68f0759e` 为 BUG-414/418「带年份的口语问、不出无年份分盘选择卡」把 dated 探针年份塞进采集口语。`USER_COLLECT_QUESTION.family` 本身以「记得大概哪年就行」结尾,拼在一起自相矛盾。答「没有」后出采用卡本身不是缺陷(预算已满、家庭是最后一条必答覆盖线),本单不改该收口。 +- 根因:`68f0759e` 为 BUG-414/418「带年份的口语问、不出无年份分盘选择卡」把 dated 探针年份塞进采集口语。`USER_COLLECT_QUESTION.family` 本身以「记得大概哪年就行」结尾,拼在一起自相矛盾。本单只去年份前缀。家人拒答后若还有未问的财务/搬家/健康/职业采集,不得直接给结果——见 BUG-546(推翻本条原先「出采用卡不是缺陷」的收口)。 - 修复:采集口语不再拼「某年前后,」。`probe_year` / `semantic_key` 仍留给计分与去重。不出无年份分盘选择卡的半边保留。 - 验证:家庭采集口语等于 `USER_COLLECT_QUESTION.family`,同时 `probe_year > 0`。答完最后一道区分题后仍落家庭采集焦点,旁白不含探针年份。BUG-418 无年份分盘卡测试仍过。 - 防复发:`collect_method_evidence` 口语不得加年份前缀。不得为去前缀而丢掉 `probe_year` 或放回无年份分盘选择卡。 -- 相关记录:BUG-414、BUG-418 +- 相关记录:BUG-414、BUG-418、BUG-546 - 复发自:无 - 修复版本:待发布 @@ -8421,3 +8421,19 @@ - 复发自:无 - 修复版本:待发布 +## BUG-546 | 家人采集答「没有」后直接给结果,未继续收能区分分钟的经历 + +- 状态:resolved +- 首次发现:2026-09-05 +- 最近更新:2026-09-05 +- 影响面:`buildMethodFollowupPlan`、`shouldSkipFollowupPersist`、`deferAdoption`、`isRemainingEvidenceCollect` +- 用户现象:学业、感情、事业都已有带年份经历,家人题答「没有」后,助手直接给出代表分钟和采用旁白。剩下两三个分钟还分得开,财务、搬家、健康、职业都还没问。 +- 触发条件:训练门已开(至少 5 条带日期事件、2 个以上领域),家人采集拒答,剩余区分探针被标成 `no_split_among_active`,决策层 `canAdopt=true` / `sessionOutcome=adopt_representative`。 +- 根因:两层早退叠在一起。(1) `buildMethodFollowupPlan` 的 dated 补采集只在训练门未开时运行;门已开就跳到职业,再被 `deferAdoption` 清掉 `next_followup`。(2) `shouldSkipFollowupPersist` 见 `canAdopt` 且阻断方法已覆盖、当前题不是剩余区分题,就跳过写焦点、改写采用旁白。BUG-539 曾写「家人没有后出采用卡不是缺陷」,那是把采用能力门和「还有未问的经历域」混成一件事。 +- 修复:家人覆盖或拒答后**总是**走 `nextDatedCollectFollowup`(学业 → 财务 → 搬家 → 健康 → 感情/事业补洞)。`isRemainingEvidenceCollect` 覆盖这些域和职业口述。`deferAdoption` 与 `shouldSkipFollowupPersist` 都不得把这类采集早退掉。`canAdopt` 能力仍可开着;未答采集在场时采用卡仍按 BUG-544 不出。不放宽精确分钟确认门。职业仍不是 adopt 能力门槛。 +- 验证:`rectification-collect-stall` live five-evidence 家人拒答后持久化财务采集;`rectification-adopt-narration-20260904` 仅家人拒答仍问财务;`rectification-provisional-adopt` 1c / after-choice / B2 锁财务采集写焦点;`rectification-eight-method` 家人之后先财务再职业再占问。 +- 防复发:训练门已开不得跳过未用的 dated collect。`canAdopt` 不得单独构成跳过财务/搬家/健康/职业采集的理由。不得把 BUG-539「家人没有后采用」读成「未问完的经历域也可以结束」。 +- 相关记录:BUG-519、BUG-539、BUG-544 +- 复发自:BUG-519(采用早退把未用采集域一并吃掉);BUG-539 把该收口写成非缺陷 +- 修复版本:待发布 + diff --git a/docs/tasks/PROGRESS-rectification-family-collect-continue-20260905.md b/docs/tasks/PROGRESS-rectification-family-collect-continue-20260905.md new file mode 100644 index 00000000..c99299f8 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-family-collect-continue-20260905.md @@ -0,0 +1,43 @@ +# PROGRESS · 家人拒答后继续采集(2026-09-05) + +工作树:`.worktrees/rectification-quality-probe-dedupe-20260904` +分支:`codex/rectification-quality-probe-dedupe-20260904` +基线:本分支已合入 staging 的 `329f8a21`(采用卡锚点)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 家人答「没有」后继续收能区分分钟的经历 | 完成 | BUG-546 | + +## 编号 + +开工时最大号 BUG-545。本单 **BUG-546**。未占用预留的 BUG-542。 + +## 实现要点 + +- `buildMethodFollowupPlan`:家人覆盖或拒答后总是 `nextDatedCollectFollowup`,不再要求训练门未开。 +- `isRemainingEvidenceCollect`:dated 顺序域 + 职业口述。 +- `deferAdoption` / `shouldSkipFollowupPersist`:这类采集不得因 `canAdopt` 早退。 +- `canAdopt` 能力仍可开;未答采集在场时采用卡按 BUG-544 隐藏。不放宽精确分钟确认门。 + +## 断言变更 + +| 项 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| live five-evidence 家人拒答后 | 采用旁白 / 职业采集 | 财务口述「钱的方面…」 | 训练门已开仍要收未用带年份域 | +| 家人拒答 persist stem | 职业 | 学业(revision5 尚无学业证据) | dated 顺序:家人之后先学业 | +| `adopt_representative` 计划 | `next_followup` null,采集进 deferred | 本轮仍问剩余经历采集 | 采用能力 ≠ 结束采集 | +| 八方法家人之后 | 职业 → 占问 | 财务 → … → 职业 → 占问 | 与 `DATED_COLLECT_ORDER` 对齐 | +| 精度层 / D24 观察(经典八法已覆盖) | 直接 D5/D4 观察 | 先财务采集 | 未用带年份域优先于精度层 | +| B2 `buildNextUserAction` | `ask_method_followup` | 仍 `adopt_representative` | 能力层跟 sessionOutcome;真正下一问由 persist 写财务焦点 | + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| collect-stall / collect-direction / provisional-adopt / range-offer / adopt-narration / coverage-collect | 64/64 | +| eight-method + adopt-flow-fix + answer-choice + 上列 | 163/163 | +| `tsc --noEmit` | 0 错 | +| 改动文件 eslint `--quiet` | 0 error | +| `page.tsx` | 未改 | + +未做浏览器真人走查。清单:`docs/testing/rectification-family-collect-continue-20260905.md`。旧会话在部署前仍会按旧逻辑结束。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index c5f46c9e..e0a9fcc4 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -52,6 +52,7 @@ | `TASK-rectification-post-adopt-verify-20260904.md` | `PROGRESS-rectification-post-adopt-verify-20260904.md` | 采用后核对题重复已问题;核对卡「这题跳过」等于整案停止、已采用仍念"选一个先用着"、前端"没有拿到下一个问题";采用旁白承诺的 holdout/OOS 核对与实际计划不同源(原任务书 §4 错误);家庭采集题带年份前缀自相矛盾 | 待验收 | `codex/rectification-post-adopt-verify-20260904`(BUG-536~539) | | `TASK-rectification-quality-probe-dedupe-20260904.md` | `PROGRESS-rectification-quality-probe-dedupe-20260904.md` | 候选区分阶段同一道"上大学"题问两次:`pickProbe` 按种类取第一条质量探针无视 `semantic_key`(第二次答案绑到错的证据);引擎对毕业等学业 kind 也套"高考发挥"模板;同域同分组质量探针不去重 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-540~541) | | — | `PROGRESS-rectification-adopt-card-anchor-20260905.md` | 采用卡钉在历史采集题下,最新旁白没有卡;单分钟交付旁白机器腔 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-544~545) | +| — | `PROGRESS-rectification-family-collect-continue-20260905.md` | 家人答「没有」后直接给结果,未继续收能区分分钟的经历 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-546) | | `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-family-collect-continue-20260905.md b/docs/testing/rectification-family-collect-continue-20260905.md new file mode 100644 index 00000000..301722f2 --- /dev/null +++ b/docs/testing/rectification-family-collect-continue-20260905.md @@ -0,0 +1,32 @@ +# Staging 人肉复核 · 家人说没有之后继续采集(2026-09-05) + +给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。 + +对应 BUG-546。测之前先做第 0 条。本单部署前,旧会话仍可能在家人「没有」后直接给结果。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 家人「没有」之后要继续问(P0) + +1. 新建生时校正。 +2. 先说清学业、感情、事业各一件记得住大概年份的事,把区分卡答完,直到出现家人那题:「家里如果有结婚、添丁或住院这类事,记得大概哪年就行。」 +3. 回答「没有」。 + +- ✅ 预期:下一句是还没问过的带年份经历。学业/感情/事业都有了的话,应是「钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗?」不是采用旁白,也不是「再问下去也分不开」。 +- ❌ 失败:直接出现「我按你说的经历认真分析过了,下面是这次的结果。」或时间卡。 + +## 2. 这题还没答时不要出时间卡(P0 · 叠 BUG-544) + +家人答「没有」后,财务(或下一域)口述题还在: + +- ✅ 预期:这题下面没有采用卡。 +- ❌ 失败:口述题还在,旁边或上面已经出现「当前可能的出生时间」。 + +## 3. 钱、搬家、身体也可以说没有(P1) + +对财务、搬家、身体依次答「没有」或补一件带年份的事: + +- ✅ 预期:按这个顺序往下问,问完这些才会问「你平时主要做什么工作?」。全部问完或都说没有之后,才允许出现这次的结果。 +- ❌ 失败:财务才说没有就给结果。 diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 83948066..2fd81587 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -62,6 +62,7 @@ import { buildNextUserAction, exhaustionSpokenCollectFollowup, GENERIC_COLLECT_QUESTION, + isRemainingEvidenceCollect, spokenCollectFallbackFollowup, spokenFollowupForUser, type MethodCoverage, @@ -131,6 +132,7 @@ function shouldSkipFollowupPersist(input: { } if (input.methods && !blockingMethodsCovered(input.methods)) return false; if (isRemainingDiscriminatorFollowup(input.followup)) return false; + if (isRemainingEvidenceCollect(input.followup)) return false; return true; } diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 81b937a2..49c00b5d 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -18,17 +18,17 @@ * A draft/confirmed occupation_note without a date still covers this layer. * 8. Horary — ask once for the first question time; recast if given; never blocks cards * - * Relocation stays out of domain rotation and is only asked at d4_refine. - * Finance/health score if volunteered; they are not method-layer rotation. - * Method coverage finishes before repeating a precision-stage ask. - * Appearance and marks are skipped_by_policy. Horary does not block offering - * time cards. Occupation is a method-layer note, not an adopt gate: dated - * dasha/D9/D10/relatives coverage plus the engine ceiling can offer - * representative cards while occupation remains uncovered. Yearless-to-collect - * conversion still waits until occupation is covered, so that note can be - * asked later without blocking adopt. Career evidence - * plus a closed occupation collect focus still covers the occupation method - * without depending on the model picking domain=occupation. + * Relocation / finance / health are not adopt gates, but after family is + * covered or declined they are still asked while the remaining minutes + * need distinguishing (BUG-546). Occupation is a method-layer note, not an + * adopt gate: dated dasha/D9/D10/relatives coverage plus the engine ceiling + * can mark representative cards as available while occupation remains + * uncovered. Persist still writes remaining dated collect and occupation + * before the adopt early-exit. Yearless-to-collect conversion still waits + * until occupation is covered. Career evidence plus a closed occupation + * collect focus still covers the occupation method without depending on + * the model picking domain=occupation. Appearance and marks are + * skipped_by_policy. Horary does not block offering time cards. * Method coverage asks for dated events in natural language. * Known-event quality probes (exam went badly for a year already * in the ledger) are not reverse-inference cards. Dasha existence @@ -1089,6 +1089,20 @@ export function nextDatedCollectFollowup( return null; } +const REMAINING_EVIDENCE_COLLECT_DOMAINS = new Set([ + ...DATED_COLLECT_ORDER, + "occupation", +]); + +/** Spoken collect that can still add distinguishing evidence. Not `other` / horary. */ +export function isRemainingEvidenceCollect( + followup: Pick | null | undefined, +): boolean { + if (!followup || followup.intent !== "collect_method_evidence") return false; + return typeof followup.domain === "string" + && REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain); +} + function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): MethodFollowup { return { method_id: "dasha_events", @@ -2006,10 +2020,7 @@ export function buildMethodFollowupPlan(input: { source: "method_coverage", ...collectionYearFields(familyCollect), }); - } else if ( - !meetsAcceptanceEventQuality(input.evidence) - && (datedCollect = nextDatedCollectFollowup(input.evidence, declined)) - ) { + } else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined))) { next = makeFollowup(datedCollect); } else if (!occupationCovered) { if (meetsAcceptanceEventQuality(input.evidence)) { @@ -2257,7 +2268,8 @@ export function buildMethodFollowupPlan(input: { const deferProvisionalDiscriminator = sessionOutcome === "provisional_range" && next?.intent === "distinguish_candidates" && Boolean(next.choice_frame); - const deferFollowup = deferAdoption || deferProvisionalDiscriminator; + const keepEvidenceCollect = isRemainingEvidenceCollect(next); + const deferFollowup = (deferAdoption || deferProvisionalDiscriminator) && !keepEvidenceCollect; const deferred = deferFollowup ? next : null; return { methods, diff --git a/frontend/tests/rectification-adopt-narration-20260904.test.ts b/frontend/tests/rectification-adopt-narration-20260904.test.ts index 162b6874..ae706822 100644 --- a/frontend/tests/rectification-adopt-narration-20260904.test.ts +++ b/frontend/tests/rectification-adopt-narration-20260904.test.ts @@ -27,7 +27,7 @@ import { type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; -import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; +import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, @@ -104,6 +104,14 @@ const EVIDENCE = [ }, ] as const; +const EXHAUSTED_COLLECT_TOPICS = [ + { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, + { target_domain: "finance", intent: "collect_method_evidence", status: "declined" }, + { target_domain: "relocation", intent: "collect_method_evidence", status: "declined" }, + { target_domain: "health_pressure", intent: "collect_method_evidence", status: "declined" }, + { target_domain: "occupation", intent: "collect_method_evidence", status: "declined" }, +] as const; + const OOS_BLIND = [ { domain: "family", user_meaning: "家里有没有结婚、添丁或住院", used_for_scoring: false }, { domain: "finance", user_meaning: "钱的方面有没有明显变化", used_for_scoring: false }, @@ -613,6 +621,36 @@ function adoptAccounting(dossier: DecisionDossier, activeFocus?: Record | null) { + return fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(dossier, activeFocus), + get_agentic_rectification_case_compute: () => computeFixture(), + resolve_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus_id: args.p_focus_id, + status: args.p_status, + evidence_id: null, + idempotent: false, + }), + 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, + }, + idempotent: false, + }), + }); +} + function assertAdoptTemplate(text: string) { assert.match(text, /分不开 05:00 和 05:06/); assert.match(text, /我按你说的经历认真分析过了/); @@ -645,7 +683,8 @@ test("fourteen-probe case decides offer_provisional_range and skips leftover pro assert.equal(decision.precisionStage, "ready_to_adopt"); const plan = planFrom(dossier); - assert.equal(plan.next_followup, null); + assert.equal(plan.next_followup?.domain, "finance"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); // Task text said "null or choice_frame". The lock is: no frameless distinguish // in deferred_followup. Adopt may still stash a later collect (eight-method). if (plan.deferred_followup?.intent === "distinguish_candidates") { @@ -653,9 +692,27 @@ test("fourteen-probe case decides offer_provisional_range and skips leftover pro } }); -test("persistNextInterviewAfterChoice narrates the stop reason and writes no focus", async () => { +test("persistNextInterviewAfterChoice after family denial collects remaining dated events", async () => { const dossier = caseDossier(); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const accounting = collectAccounting(dossier); + const persisted = await persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: fourteenProbeState(), + nextAction: publicNextAction(decision), + birthDate: "1997-08-08", + }); + assert.equal(persisted.persisted, true); + assert.equal(persisted.followup?.domain, "finance"); + assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.finance); +}); + +test("persistNextInterviewAfterChoice narrates the stop reason once dated collect is exhausted", async () => { + const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const accounting = adoptAccounting(dossier); const persisted = await persistNextInterviewAfterChoice({ accounting: accounting.client, @@ -756,9 +813,12 @@ test("three adopt entry points call the model once on first ready_to_adopt and n ); assert.doesNotMatch(persistFn, /decideFromDossier\(/); - const dossier = caseDossier(); + const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); - const preClick = caseDossier({ state: preClickFourteenProbeState() }); + const preClick = caseDossier({ + declinedTopics: EXHAUSTED_COLLECT_TOPICS, + state: preClickFourteenProbeState(), + }); const preClickDecision = decideFromDossier(preClick, { birthDate: "1997-08-08" }); assert.notEqual(preClickDecision.precisionStage, "ready_to_adopt"); assert.ok(preClickDecision.probe); @@ -830,9 +890,7 @@ test("three adopt entry points call the model once on first ready_to_adopt and n loads += 1; if (loads === 1) return rpcDossier(dossier, familyCollectFocus()); return rpcDossier(caseDossier({ - declinedTopics: [ - { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, - ], + declinedTopics: EXHAUSTED_COLLECT_TOPICS, })); }, get_agentic_rectification_case_compute: () => computeFixture(), @@ -990,7 +1048,7 @@ test("adopt narration does not leave an active timeout after the model returns", } }); -test("applyCollectFocusDenial on the family collect uses the same adopt template", async () => { +test("applyCollectFocusDenial on the family collect keeps dated collect instead of adopting", async () => { const dossier = caseDossier(); let loads = 0; const accounting = fakeAccounting({ @@ -1007,18 +1065,32 @@ test("applyCollectFocusDenial on the family collect uses the same adopt template evidence_id: null, idempotent: false, }), - set_agentic_rectification_conversation_focus: () => { - throw new Error("adoptable offer must not persist another question"); - }, + 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, + }, + idempotent: false, + }), }); const applied = await applyCollectFocusDenial(accounting.client, { userId: USER_ID, caseId: CASE_ID, focusId: FOCUS_ID, }); - assertAdoptTemplate(applied.narration); - assert.equal(applied.nextInterviewPersisted, false); - assertNoFocusWrite(accounting); + assert.equal(applied.nextInterviewPersisted, true); + assert.equal(applied.narration, USER_COLLECT_QUESTION.finance); + const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + assert.equal(setFocus?.args.p_target_domain, "finance"); }); test("distinguish declined does not cover d10_career or drop dated career probes", () => { @@ -1120,8 +1192,8 @@ test("family collect declined vs extra distinguish declined leaves the same adop assert.equal(left.canAdopt, right.canAdopt); assert.equal(left.precisionStage, right.precisionStage); - const accountingLeft = adoptAccounting(familyOnly); - const accountingRight = adoptAccounting(withDistinguish); + const accountingLeft = collectAccounting(familyOnly); + const accountingRight = collectAccounting(withDistinguish); const narratedLeft = await persistNextInterviewAfterChoice({ accounting: accountingLeft.client, userId: USER_ID, @@ -1141,5 +1213,6 @@ test("family collect declined vs extra distinguish declined leaves the same adop birthDate: "1997-08-08", }); assert.equal(narratedLeft.hostNarration, narratedRight.hostNarration); - assertAdoptTemplate(narratedLeft.hostNarration); + assert.equal(narratedLeft.hostNarration, USER_COLLECT_QUESTION.finance); + assert.equal(narratedLeft.persisted, true); }); diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index d997cebf..f3e4145d 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -335,14 +335,17 @@ test("persistNextInterviewAfterChoice after family denial asks education, not oc ); }); -test("four scoreable events do not take the dated-collect branch", () => { +test("four scoreable events still collect remaining dated domains after family denial", () => { const plan = collectPlan(FOUR_SCOREABLE, { holdoutValidation: "passed", oosBlindPrompts: [], candidatesSeparated: true, }); + // 旧:训练门已开 → occupation。新:家人拒答后仍收搬家,用来区分剩余分钟。 assert.notEqual(plan.next_followup?.domain, "education"); - assert.equal(plan.next_followup?.domain, "occupation"); + assert.notEqual(plan.next_followup?.domain, "occupation"); + assert.equal(plan.next_followup?.domain, "relocation"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); }); test("set-focus twice with a domainless collect prompt falls back to USER_COLLECT_QUESTION.education", async () => { diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 4a7dd5b4..bfd80077 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -4,7 +4,6 @@ import test from "node:test"; import { applyAnswerToState, buildInferenceState, candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; import { - isNonConvergingRangeOffer, publicCanAdopt, publicNextAction, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; @@ -508,7 +507,9 @@ test("denying the dated family collect declines relatives and leaves the D12 car assert.equal(next.next_followup.intent, "distinguish_candidates"); assert.doesNotMatch(next.next_followup.semantic_key ?? "", /^varga\.d(12|24|7|4|5)\./); } else { - assert.equal(next.next_followup?.domain, "occupation"); + // 旧:训练门已开时跳过 dated 补采集,落到 occupation。 + // 新:家人拒答后仍收学业等带年份经历,用来继续区分剩余分钟。 + assert.equal(next.next_followup?.domain, "education"); assert.equal(next.next_followup?.intent, "collect_method_evidence"); } }); @@ -566,7 +567,8 @@ test("occupation collect denial declines the focus and advances coverage to hora const occupationPlan = planFrom(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], }), { askedProbeKeys: askedYearless }); - assert.equal(occupationPlan.next_followup?.domain, "occupation"); + // 旧:训练门已开 → occupation。新:家人拒答后仍收学业。 + assert.equal(occupationPlan.next_followup?.domain, "education"); assert.equal(occupationPlan.next_followup?.intent, "collect_method_evidence"); assert.equal(occupationPlan.next_followup?.choice_frame, null); @@ -643,13 +645,13 @@ test("occupation collect denial declines the focus and advances coverage to hora assert.notEqual(setFocus?.args.p_target_domain, "horary"); assert.ok(applied.narration); - const horary = planFrom(revision5Dossier(revision5State(), { + const nextCollect = planFrom(revision5Dossier(revision5State(), { declinedTopics: [ { target_domain: "family", status: "declined" }, { target_domain: "occupation", status: "declined" }, ], }), { askedProbeKeys: askedYearless }); - assert.equal(horary.next_followup?.domain, "horary"); + assert.equal(nextCollect.next_followup?.domain, "education"); }); test("collect denial persists the next stem on the turn and binds asked_turn_id", async () => { @@ -718,7 +720,7 @@ test("collect denial persists the next stem on the turn and binds asked_turn_id" }); assert.equal(applied.nextInterviewPersisted, true); assert.ok(applied.focus); - assert.equal(applied.narration, USER_COLLECT_QUESTION.occupation); + assert.equal(applied.narration, USER_COLLECT_QUESTION.education); const finished = await persistCollectDenialTurn({ accounting: accounting.client, @@ -732,7 +734,7 @@ test("collect denial persists the next stem on the turn and binds asked_turn_id" assert.equal(finished.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck); const stored = composeCollectSpokenAssistantText( RECTIFICATION_USER_COPY.collectDeclinedAck, - USER_COLLECT_QUESTION.occupation, + USER_COLLECT_QUESTION.education, ); const append = accounting.calls.find((item) => item.fn === "append_agentic_rectification_turn"); assert.equal(append?.args.p_assistant_message, stored); @@ -1084,7 +1086,7 @@ test("nonterminal turn exit is already satisfied once dated coverage can adopt", ); }); -test("live five-evidence case opens adopt instead of occupation collect", async () => { +test("live five-evidence case keeps dated collect after family denial instead of adopting", async () => { const dossier = liveCaseDossier(); const state = liveCaseState(); assert.equal(dossier.evidence.length, 5); @@ -1097,22 +1099,23 @@ test("live five-evidence case opens adopt instead of occupation collect", async assert.equal(decision.probe, null); assert.equal(decision.canAdopt, true); assert.equal(decision.canConfirmExactMinute, false); - assert.notEqual(decision.nextAction, "ask_fact_collection"); - assert.equal(isNonConvergingRangeOffer(decision), false); assert.ok(decision.droppedProbes.some((probe) => ( probe.semantic_key === CAREER_2023_ACTIVATION.semantic_key && probe.reason === "no_split_among_active" )), JSON.stringify(decision.droppedProbes)); const plan = planFrom(dossier, { sessionOutcome: decision.sessionOutcome }); - assert.equal(plan.next_followup, null); + assert.equal(plan.next_followup?.domain, "finance"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.choice_frame, null); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier), - set_agentic_rectification_conversation_focus: () => { - throw new Error("adoptable offer must not persist another question"); - }, + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: createdFocusFromArgs(args), + idempotent: false, + }), }); const persisted = await persistNextInterviewAfterChoice({ accounting: accounting.client, @@ -1123,12 +1126,12 @@ test("live five-evidence case opens adopt instead of occupation collect", async nextAction: publicNextAction(decision), birthDate: "1997-08-08", }); - assert.equal(persisted.persisted, false); - assert.match(persisted.hostNarration, /我按你说的经历认真分析过了/); - assert.equal( - accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), - false, - ); + assert.equal(persisted.persisted, true); + assert.equal(persisted.followup?.domain, "finance"); + assert.match(persisted.hostNarration, /收入|钱|欠债/); + assert.doesNotMatch(persisted.hostNarration, /我按你说的经历认真分析过了/); + const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + assert.equal(setFocus?.args.p_target_domain, "finance"); }); test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => { diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 65a2905a..1334c904 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -658,7 +658,7 @@ test("selectionAllowed with remaining method follow-up keeps collecting and offe assert.equal(action.on_user_stop.id, "offer_provisional_range"); }); -test("adopt_representative defers method follow-up instead of asking this turn", () => { +test("adopt_representative still asks remaining evidence collect this turn", () => { const plan = buildMethodFollowupPlan({ evidence: [{ status: "confirmed", @@ -669,11 +669,12 @@ test("adopt_representative defers method follow-up instead of asking this turn", }], sessionOutcome: "adopt_representative", }); - assert.equal(plan.next_followup, null); - // 原值: relatives - // 新值: d9_relationship - // 原因: dated 分支移到家人之后,前两问恢复感情 / 事业 - assert.equal(plan.deferred_followup?.method_id, "d9_relationship"); + // 原值: next_followup null,感情采集进 deferred + // 新值: 本轮仍问感情采集 + // 原因: 家人之后还有能区分分钟的经历时,不得因 canAdopt 早退(BUG-546) + assert.equal(plan.next_followup?.method_id, "d9_relationship"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.deferred_followup, null); assert.equal(plan.session_outcome, "adopt_representative"); }); @@ -687,7 +688,12 @@ test("adopt_representative ignores leftover distinguish focus", () => { targetKind: "home_change", }, }); - assert.equal(plan.next_followup, null); + // 原值: next_followup null(采用早退) + // 新值: 丢掉无用区分焦点后仍收财务经历 + // 原因: 未用的 dated collect 不得被 adopt 早退吃掉(BUG-546) + assert.equal(plan.next_followup?.domain, "finance"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.notEqual(plan.next_followup?.intent, "distinguish_candidates"); assert.equal(plan.session_outcome, "adopt_representative"); }); @@ -754,7 +760,11 @@ test("D9 differ keeps sign names for the type-table report and still forbids uni evidence: CLASSIC_COVERAGE, observations, }); - assert.equal(plan.next_followup, null); + // 原值: next_followup null + // 新值: 财务采集 + // 原因: 经典八法覆盖后仍收未用带年份域,D9 观察不再抢在前面(BUG-546) + assert.equal(plan.next_followup?.method_id, "d2_finance"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY); }); @@ -790,8 +800,11 @@ test("D24-only window change folds into education follow-up without a second lay evidence: CLASSIC_COVERAGE, observations, }); - assert.equal(plan.next_followup?.method_id, "d5_education"); - assert.equal(plan.next_followup?.ask_theme, "education_style"); + // 原值: d5_education / education_style(D24 观察) + // 新值: d2_finance 采集 + // 原因: 家人之后先收未用带年份经历,再进分盘观察(BUG-546) + assert.equal(plan.next_followup?.method_id, "d2_finance"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); }); test("D11-only window change folds into finance follow-up without delaying adopt", () => { @@ -836,8 +849,12 @@ test("D11-only window change folds into finance follow-up without delaying adopt ], observations, }); - assert.equal(plan.next_followup?.method_id, "d2_finance"); - assert.equal(plan.next_followup?.source, "varga_observation"); + // 原值: d2_finance / varga_observation(不挡采用) + // 新值: d4_home 搬家采集 + // 原因: 财务已有带年份证据后,下一未用域是搬家(BUG-546) + assert.equal(plan.next_followup?.method_id, "d4_home"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.source, "method_coverage"); }); test("window scan displays KP sub-lord changes without opening confirmation", () => { @@ -1571,7 +1588,7 @@ test("Mastra hides active candidates when the receipt range is corrupted", () => assert.equal(invalid.selection_allowed, false); }); -test("family then occupation then horary follow the method plan without appearance or marks", () => { +test("family then unused dated collect then occupation then horary follow the method plan without appearance or marks", () => { const afterFamily = buildMethodFollowupPlan({ evidence: [ { status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null }, @@ -1580,8 +1597,11 @@ test("family then occupation then horary follow the method plan without appearan { status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null }, ], }); - assert.equal(afterFamily.next_followup?.method_id, "occupation"); - assert.equal(afterFamily.next_followup?.domain, "occupation"); + // 原值: occupation + // 新值: d2_finance + // 原因: 家人之后按 dated 顺序收财务/搬家/健康,职业排在后面(BUG-546) + assert.equal(afterFamily.next_followup?.method_id, "d2_finance"); + assert.equal(afterFamily.next_followup?.domain, "finance"); assert.equal(afterFamily.methods.find((item) => item.method_id === "appearance")?.status, "skipped_by_policy"); assert.equal(afterFamily.methods.find((item) => item.method_id === "marks")?.status, "skipped_by_policy"); assert.equal(conversationalSessionOutcome({ @@ -1594,12 +1614,28 @@ test("family then occupation then horary follow the method plan without appearan assert.equal(afterFamily.next_followup?.choice_frame, null); assert.match(afterFamily.next_followup?.user_prompt_hint ?? "", /自然语言/); assert.doesNotMatch(afterFamily.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/); + const afterDated = buildMethodFollowupPlan({ + evidence: [ + { status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null }, + { status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2018-01-01", occurredTo: null }, + { status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2019-01-01", occurredTo: null }, + { status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null }, + { status: "confirmed", domain: "finance", datePrecision: "year", occurredFrom: "2021-01-01", occurredTo: null }, + { status: "confirmed", domain: "relocation", datePrecision: "year", occurredFrom: "2015-01-01", occurredTo: null }, + { status: "confirmed", domain: "health_pressure", datePrecision: "year", occurredFrom: "2017-01-01", occurredTo: null }, + ], + }); + assert.equal(afterDated.next_followup?.method_id, "occupation"); + assert.equal(afterDated.next_followup?.domain, "occupation"); const afterOccupation = buildMethodFollowupPlan({ evidence: [ { status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null }, { status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2018-01-01", occurredTo: null }, { status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2019-01-01", occurredTo: null }, { status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null }, + { status: "confirmed", domain: "finance", datePrecision: "year", occurredFrom: "2021-01-01", occurredTo: null }, + { status: "confirmed", domain: "relocation", datePrecision: "year", occurredFrom: "2015-01-01", occurredTo: null }, + { status: "confirmed", domain: "health_pressure", datePrecision: "year", occurredFrom: "2017-01-01", occurredTo: null }, { status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null }, ], }); @@ -1614,6 +1650,9 @@ test("family then occupation then horary follow the method plan without appearan { status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2018-01-01", occurredTo: null }, { status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2019-01-01", occurredTo: null }, { status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null }, + { status: "confirmed", domain: "finance", datePrecision: "year", occurredFrom: "2021-01-01", occurredTo: null }, + { status: "confirmed", domain: "relocation", datePrecision: "year", occurredFrom: "2015-01-01", occurredTo: null }, + { status: "confirmed", domain: "health_pressure", datePrecision: "year", occurredFrom: "2017-01-01", occurredTo: null }, { status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null }, { status: "confirmed", domain: "horary", datePrecision: "day", occurredFrom: "2024-01-01", occurredTo: null }, ], @@ -1643,10 +1682,12 @@ test("lagna_frame after classic coverage does not keep a tie in discrimination w evidence: CLASSIC_COVERAGE, precisionStage: "lagna_frame", }); - assert.equal(plan.next_followup?.source, "precision_stage"); - assert.equal(plan.next_followup?.ask_theme, "dated_event"); - assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true); - assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods, { separated: true }), false); + // 原值: precision_stage / dated_event + // 新值: 财务采集 + // 原因: 未用带年份域仍能区分分钟时,精度层让路(BUG-546) + assert.equal(plan.next_followup?.source, "method_coverage"); + assert.equal(plan.next_followup?.domain, "finance"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, @@ -1669,13 +1710,29 @@ test("precision stage d4 asks home change not family, and d5 asks education", () evidence: covered, precisionStage: "d4_refine", }); - assert.equal(d4.next_followup?.source, "precision_stage"); - assert.equal(d4.next_followup?.method_id, "d4_home"); - assert.equal(d4.next_followup?.domain, "relocation"); - assert.equal(d4.next_followup?.ask_theme, "home_change"); + // 原值: precision_stage / d4_home + // 新值: 财务采集 + // 原因: 家人之后先收未用带年份域(BUG-546) + assert.equal(d4.next_followup?.source, "method_coverage"); + assert.equal(d4.next_followup?.method_id, "d2_finance"); + assert.equal(d4.next_followup?.domain, "finance"); assert.doesNotMatch(d4.next_followup?.user_prompt_hint ?? "", /家人/); + const datedThenHome = [ + ...covered, + { status: "confirmed" as const, domain: "finance", datePrecision: "year" as const, occurredFrom: "2021-01-01", occurredTo: null }, + { status: "confirmed" as const, domain: "relocation", datePrecision: "year" as const, occurredFrom: "2015-01-01", occurredTo: null }, + { status: "confirmed" as const, domain: "health_pressure", datePrecision: "year" as const, occurredFrom: "2017-01-01", occurredTo: null }, + ]; + const d4AfterDated = buildMethodFollowupPlan({ + evidence: datedThenHome, + precisionStage: "d4_refine", + }); + assert.equal(d4AfterDated.next_followup?.source, "precision_stage"); + assert.equal(d4AfterDated.next_followup?.method_id, "d4_home"); + assert.equal(d4AfterDated.next_followup?.domain, "relocation"); + assert.equal(d4AfterDated.next_followup?.ask_theme, "home_change"); const legacyTheme = buildMethodFollowupPlan({ - evidence: [...covered], + evidence: datedThenHome, precisionStage: "theme_refine", }); assert.equal(legacyTheme.next_followup?.ask_theme, "home_change"); @@ -1683,8 +1740,11 @@ test("precision stage d4 asks home change not family, and d5 asks education", () evidence: covered.filter((item) => item.domain !== "education"), precisionStage: "d5_refine", }); + // 原值: d5_education 精度层 + // 新值: 学业采集(education 尚未覆盖) assert.equal(d5.next_followup?.method_id, "d5_education"); assert.equal(d5.next_followup?.domain, "education"); + assert.equal(d5.next_followup?.intent, "collect_method_evidence"); assert.doesNotMatch(JSON.stringify(d4), UNIQUE_MINUTE_COPY); }); @@ -1884,7 +1944,10 @@ test("career evidence does not cover occupation method; occupation no longer blo { status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null }, ], }); - assert.equal(plan.next_followup?.method_id, "occupation"); + // 原值: occupation + // 新值: d2_finance + // 原因: 家人之后先收财务经历;职业仍未覆盖,也不挡 adopt 能力(BUG-546) + assert.equal(plan.next_followup?.method_id, "d2_finance"); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); assert.equal(blockingMethodsCovered(plan.methods), true); assert.equal(conversationalSessionOutcome({ @@ -2507,6 +2570,24 @@ test("provisional range still exposes method coverage followup", () => { eventKind: "home_change", summary: "搬家离乡", }, + { + status: "confirmed" as const, + domain: "finance", + datePrecision: "year" as const, + occurredFrom: "2021-01-01", + occurredTo: null, + eventKind: "finance_change", + summary: "收入变化", + }, + { + status: "confirmed" as const, + domain: "health_pressure", + datePrecision: "year" as const, + occurredFrom: "2017-01-01", + occurredTo: null, + eventKind: "self_health_event", + summary: "健康压力", + }, ], contrastPacket: packet, sessionOutcome: "provisional_range", @@ -2933,6 +3014,9 @@ test("declining occupation covers the method; declining horary is skipped_by_pol declinedTopics: [ { target_domain: "occupation", status: "declined" }, { target_domain: "horary", status: "declined" }, + { target_domain: "finance", status: "declined" }, + { target_domain: "relocation", status: "declined" }, + { target_domain: "health_pressure", status: "declined" }, ], }); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered"); @@ -2955,7 +3039,12 @@ test("declining occupation covers the method; declining horary is skipped_by_pol test("horary follow-up does not block propose once occupation is covered", () => { const plan = buildMethodFollowupPlan({ - evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"), + evidence: [ + ...CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"), + { status: "confirmed", domain: "finance", datePrecision: "year", occurredFrom: "2021-01-01", occurredTo: null }, + { status: "confirmed", domain: "relocation", datePrecision: "year", occurredFrom: "2015-01-01", occurredTo: null }, + { status: "confirmed", domain: "health_pressure", datePrecision: "year", occurredFrom: "2017-01-01", occurredTo: null }, + ], }); assert.equal(plan.next_followup?.method_id, "horary"); assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), false); diff --git a/frontend/tests/rectification-provisional-adopt.test.ts b/frontend/tests/rectification-provisional-adopt.test.ts index 2195e1e1..7f41317b 100644 --- a/frontend/tests/rectification-provisional-adopt.test.ts +++ b/frontend/tests/rectification-provisional-adopt.test.ts @@ -16,7 +16,7 @@ import { type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { persistNextInterviewAfterChoice, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; -import { containsBoundarySemantics } from "../src/lib/rectification-agentic/user-copy.ts"; +import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { buildMethodFollowupPlan, buildNextUserAction, @@ -381,14 +381,14 @@ test("incident shape 1b: exact-minute confirm stays closed on the accident case" } }); -test("incident shape 1c: uncovered occupation still opens provisional adopt", async () => { +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_confirm_exact_minute, false); - let activeFocus: Record | null = null; + let writtenDomain: string | null = null; const raw = rpcDossier(dossier); const accounting = fakeAccounting({ ...receiptHandlers, @@ -396,25 +396,37 @@ test("incident shape 1c: uncovered occupation still opens provisional adopt", as ...raw, conversation_summary: { ...(raw.conversation_summary as Record), - active_focus: activeFocus, + active_focus: writtenDomain + ? { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: `collect:${writtenDomain}:collect_method_evidence`, + intent: "collect_method_evidence", + target_domain: writtenDomain, + status: "active", + } + : null, }, }), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => { - activeFocus = { - 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-01T00:00:00.000Z", - resolved_at: null, + writtenDomain = typeof args.p_target_domain === "string" ? args.p_target_domain : null; + return { + 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-01T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, }; - return { focus: activeFocus, idempotent: false }; }, append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), }); @@ -423,9 +435,10 @@ test("incident shape 1c: uncovered occupation still opens provisional adopt", as userId: USER_ID, caseId: CASE_ID, }); - assert.equal(persisted.persisted, false); - assert.equal(activeFocus, null); - assert.match(persisted.hostNarration ?? "", /我按你说的经历认真分析过了/); + // 旧:canAdopt 早退。新:家人拒答后仍收财务经历。 + assert.equal(persisted.persisted, true); + assert.equal(writtenDomain, "finance"); + assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.finance); assert.equal(decision.canConfirmExactMinute, false); assert.equal(canRenderRectificationSelectionCards(parseRectificationCandidateResult({ resultId: dossier.latestResult?.resultId, @@ -439,7 +452,7 @@ test("incident shape 1c: uncovered occupation still opens provisional adopt", as })), true); }); -test("after-choice persist does not open another collect once dated coverage can adopt", async () => { +test("after-choice persist collects remaining dated events after family denial", async () => { const dossier = incidentDossier({ occupationCovered: false }); const decision = decideFromDossier(dossier); assert.equal(decision.canAdopt, true); @@ -448,9 +461,24 @@ test("after-choice persist does not open another collect once dated coverage can ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier), get_agentic_rectification_case_compute: () => computeFixture(), - set_agentic_rectification_conversation_focus: () => { + set_agentic_rectification_conversation_focus: (_fn, args) => { writes += 1; - throw new Error("adoptable offer must not persist another question"); + return { + 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-01T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, + }; }, }); const next = await persistNextInterviewAfterChoice({ @@ -461,9 +489,11 @@ test("after-choice persist does not open another collect once dated coverage can decisionState: null, nextAction: publicNextAction(decision), }); - assert.equal(next.persisted, false); - assert.equal(writes, 0); - assert.match(next.hostNarration, /我按你说的经历认真分析过了/); + // 旧:canAdopt 早退。新:家人拒答后仍收财务经历。 + assert.equal(next.persisted, true); + assert.equal(writes, 1); + assert.equal(next.hostNarration, USER_COLLECT_QUESTION.finance); + assert.equal(next.followup?.domain, "finance"); }); test("incident shape 2: evidence floor still blocks adopt", () => { @@ -567,7 +597,7 @@ test("delivery B1: GET overlay after occupation coverage renders representative assert.equal(parsed.representativeTime, "05:00"); }); -test("delivery B2: adoptable offer produces adopt_representative with zero follow-up", async () => { +test("delivery B2: remaining dated collect stays in front of the adopt early-exit", async () => { const dossier = incidentDossier({ occupationCovered: "declined" }); const { decision } = publicFields(dossier); const plan = buildMethodFollowupPlan({ @@ -577,7 +607,7 @@ test("delivery B2: adoptable offer produces adopt_representative with zero follo sessionOutcome: decision.sessionOutcome, }); assert.equal(plan.session_outcome, "adopt_representative"); - assert.equal(plan.next_followup, null); + assert.equal(plan.next_followup?.domain, "finance"); const action = buildNextUserAction({ scorableCount: 5, evidenceCount: 5, @@ -587,17 +617,32 @@ test("delivery B2: adoptable offer produces adopt_representative with zero follo nextFollowup: plan.next_followup, workingTime: "05:00", }); + // 能力层仍可 adopt;真正下一问是财务采集。公开 next-action 仍跟 sessionOutcome。 assert.equal(action.id, "adopt_representative"); - assert.doesNotMatch(action.user_meaning, /补|再问|记得住时间/); let writes = 0; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier), get_agentic_rectification_case_compute: () => computeFixture(), - set_agentic_rectification_conversation_focus: () => { + set_agentic_rectification_conversation_focus: (_fn, args) => { writes += 1; - throw new Error("adoptable offer must not persist another question"); + return { + 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-01T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, + }; }, }); const idle = await persistNextInterviewIfIdle({ @@ -605,12 +650,9 @@ test("delivery B2: adoptable offer produces adopt_representative with zero follo userId: USER_ID, caseId: CASE_ID, }); - assert.equal(idle.persisted, false); - assert.equal(writes, 0); - assert.match(idle.hostNarration ?? "", /05:00–05:07/); - assert.match(idle.hostNarration ?? "", /05:00/); - assert.equal(containsBoundarySemantics(idle.hostNarration ?? ""), true); - assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/); + assert.equal(idle.persisted, true); + assert.equal(writes, 1); + assert.equal(idle.hostNarration, USER_COLLECT_QUESTION.finance); }); test("delivery B3: accept projection stays open from engine receipt, not exact-minute confirm", () => { diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index 236c3c10..13055ac9 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -520,7 +520,7 @@ test("live remaining probes with zero active split drop and open provisional ado )), true); }); -test("adoptable range offer does not persist another spoken collect", async () => { +test("adoptable range still collects remaining dated events after family denial", async () => { const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(decision.canAdopt, true); @@ -546,24 +546,36 @@ test("adoptable range offer does not persist another spoken collect", async () = ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier), get_agentic_rectification_case_compute: () => computeFixture(), - set_agentic_rectification_conversation_focus: () => { - throw new Error("adoptable offer must not persist another question"); - }, + 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, + }, + idempotent: false, + }), }); const idle = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); - assert.equal(idle.persisted, false); + // 旧:canAdopt 早退,旁白 adoptCue,不落下一问。 + // 新:家人拒答后仍收学业经历,用来区分剩余分钟。 + assert.equal(idle.persisted, true); assert.equal(idle.choiceReady, false); - assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/); - assert.match(idle.hostNarration ?? "", /04:47–04:53/); - assert.match(idle.hostNarration ?? "", /04:51/); - assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/); - assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT)); + assert.equal(idle.hostNarration, USER_COLLECT_QUESTION.education); + assert.doesNotMatch(idle.hostNarration ?? "", /我按你说的经历认真分析过了/); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); - assert.equal(setFocus, undefined); + assert.equal(setFocus?.args.p_target_domain, "education"); }); test("userStopped completes with a review-only provisional range", () => {