From 7ca6ade2442afa7d4daf38d89bc1bde699a6a297 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 8 Sep 2026 12:27:40 +0800 Subject: [PATCH] fix(rectification): stop re-asking covered domains after adopt (BUG-590, BUG-591) Yearless-to-collect now skips domains already confirmed, declined, or asked; adopt-exhausted turns skip leftover collect persist. Collect focus collisions only retry with :next when collect_retry is set. Co-authored-by: Cursor --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 32 ++ ...ation-covered-domain-recollect-20260908.md | 44 +++ docs/tasks/README.md | 2 +- .../rectification-scenarios-20260907.md | 1 + .../rectification-agentic/v9/answer-choice.ts | 38 +- .../v9/method-followup.ts | 119 ++++-- .../rectification-agentic/v9/server-focus.ts | 8 + .../tests/rectification-eight-method.test.ts | 10 +- ...ification-occupation-coverage-exit.test.ts | 360 +++++++++++++++++- .../tests/rectification-server-focus.test.ts | 30 +- 11 files changed, 613 insertions(+), 36 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-covered-domain-recollect-20260908.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c7922f..8db4d471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-08 — 范围收到后不再把已答领域再问一遍 + +生时校正把七个带年月领域和职业问完、范围已经收到、决策已是采用时,下一步是区间交付卡。不会再把已经答过的财务或其他领域采集题拿出来问。同一道采集题撞过题号时,也不会改个后缀再问一次。Skill 版本仍是 10.0.15。 + + ## 2026-09-08 — 补经历后仍按已答对照题收范围,变宽会说出来 生时校正答完对照卡后再补一件经历,会把已经答过的题按分钟重算进去,不会因为候选分钟换了两个就把范围弹回开工窗口。如果这次比较让范围变了,旁白末尾会写「范围从 A–B 变为 C–D」。Skill 版本仍是 10.0.15。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5dd4be99..f2c4af74 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9142,3 +9142,35 @@ - 复发自:无 - 修复版本:待提交(守卫已加,现象仍 investigating) +## BUG-590 | 范围已收到 2 分钟、决策已是采用,却再问答过的财务采集题 + +- 状态:resolved +- 首次发现:2026-09-08 +- 最近更新:2026-09-08 +- 影响面:`method-followup.ts` 职业覆盖后的 yearless→采集分支、`shouldSkipFollowupPersist`、`holdoutFollowupFor` +- 用户现象:范围已经收到大约 2 分钟,决策已是采用,输入框上方却再问一遍已经答过的财务采集题,区间交付卡出不来。 +- 触发条件:七个带年月领域和职业都覆盖或拒答;yearless 目录里还有无年份分盘对照(本例 D11 财务);闲置路径带着 `contrastPacket` 持久化下一问;财务账本已有带年月确认事件且该领域采集焦点已 `resolved`。 +- 根因:职业覆盖后的 yearless→采集分支只取 `yearlessDiscriminators[0]`,只跳过拒答,不看该领域是否已有带年月事件、是否已经问过 `collect::*`。财务已覆盖仍被改写成 dated collect。`shouldSkipFollowupPersist` 只要 `isRemainingEvidenceCollect` 为真就不跳过,采用被这道题挡住。holdout 的 `occupied` 不认 `health` / `health_pressure` 同义,答过健康后仍可能再问健康线。 +- 修复:遍历全部 yearless 对照,跳过拒答、已确认(含健康同义)和已问过采集的领域,全部跳过则不再派采集。采用且 `probe_pool_exhausted` 时,剩余采集只计「该领域没有确认事件且未拒答」的题,否则跳过持久化并写 `terminalNote`。holdout `occupied` 把 `health` 与 `health_pressure` 归并。不动采用门、确认门、Skill 版本(仍 10.0.15)。 +- 验证:`frontend/tests/rectification-occupation-coverage-exit.test.ts` 事故形状:yearless→采集 `next_followup === null`;迁居未覆盖仍问迁居;idle persist `terminalNote: true` 且 0 次 set-focus;agent-run 闸门轮与 `canShowRectificationSelectionCards`;holdout 健康同义。`frontend/tests/rectification-server-focus.test.ts` 见 BUG-591。`frontend` `tsc --noEmit` 0;指定 TS 切片 1006/0。 +- 防复发:yearless→采集不得只取第一道分盘题。已覆盖或已问过的领域不得再派 dated collect。采用穷尽时不得把已覆盖领域的采集当剩余采集挡住出卡。holdout occupied 必须与 `hasConfirmedHealth` 同口径。`USER_COLLECT_QUESTION.other` 不得回潮。 +- 相关记录:BUG-586、BUG-587、BUG-591 +- 复发自:BUG-586(同一「职业覆盖后多问一题」现象,不同分支);BUG-587(收敛成功后才暴露) +- 修复版本:待提交(`codex/rectification-covered-domain-recollect-20260908`) + +## BUG-591 | 非 retry 采集焦点撞 id 时无条件加 `:next` 再插一次 + +- 状态:resolved +- 首次发现:2026-09-08 +- 最近更新:2026-09-08 +- 影响面:`server-focus.ts::persistCollectFocus`、`persistFocusAfterChoice` +- 用户现象:财务采集题已经问过并关闭,下一轮仍以 `collect:finance:collect_method_evidence:next` 落库,题干与已答财务题相同。 +- 触发条件:计划层再次派出同一领域的非 `collect_retry` 采集题;`set_agentic_rectification_conversation_focus` 报 `focus_idempotency_conflict`。 +- 根因:`75fc456d`(BUG-462 附带)在撞 id 时一律改 `${questionId}:next` 再插,原本给穷尽采集撞开场题号用。BUG-586 删掉 other 穷尽采集后,这条路径只剩副作用:已覆盖领域只要被计划层重复派出,就能以 `:next` 落地。 +- 修复:撞 id 时仅 `followup.collect_retry === true` 才用 `:next`;否则返回 `duplicate_focus`、不插入。`persistFocusAfterChoice` 把 `duplicate_focus` 当已处理、不再读 dossier 重试。 +- 验证:`frontend/tests/rectification-server-focus.test.ts`:非 retry 冲突 `status: "duplicate_focus"` 且 RPC 一次;`collect_retry: true` 仍以 `:next` 落地。 +- 防复发:非 retry 采集撞 id 不得再加 `:next`。retry 采集仍须能以 `:next` 落地。 +- 相关记录:BUG-462、BUG-586、BUG-590 +- 复发自:BUG-462(`:next` 重试本为穷尽采集撞开场题号) +- 修复版本:待提交(`codex/rectification-covered-domain-recollect-20260908`) + diff --git a/docs/tasks/PROGRESS-rectification-covered-domain-recollect-20260908.md b/docs/tasks/PROGRESS-rectification-covered-domain-recollect-20260908.md new file mode 100644 index 00000000..6b0c358f --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-covered-domain-recollect-20260908.md @@ -0,0 +1,44 @@ +# PROGRESS · 已覆盖领域再采集(2026-09-08) + +工作树:`.worktrees/rectification-covered-domain-recollect-20260908` +分支:`codex/rectification-covered-domain-recollect-20260908` +任务书:`docs/tasks/TASK-rectification-covered-domain-recollect-20260908.md` +基线:`origin/staging` @ `43fd8875` + +本单状态:**待验收**。本提交交付到 staging。 + +未改:采用门、确认门、`MIN_SEPARATION_LEAD`、`_relative_support`、`minute_step=1`、`SCORE_DELTA`、四选项合同、Skill 版本(仍 10.0.15)。 + +## 做了什么 + +- **决策 1** `method-followup.ts` 职业覆盖后的 yearless→采集:遍历全部 `yearlessDiscriminators`,跳过拒答、`hasConfirmedDomain`(含 `health`/`health_pressure`)和已问过 `collect::*` 的领域。全部跳过则 `next` 保持空。 +- **决策 2** `shouldSkipFollowupPersist`:`canAdopt` 且(`nextAction === "ready_to_adopt"` 或 `sessionOutcome === "adopt_representative"`)且 `stopReason === "probe_pool_exhausted"` 时,`isRemainingEvidenceCollect` 只有该领域没有确认事件且未拒答才算剩余采集;否则跳过持久化、`terminalNote`。 +- **决策 3** `persistCollectFocus` 撞 `focus_idempotency_conflict`:仅 `collect_retry === true` 才 `:next`,否则 `duplicate_focus`。`persistFocusAfterChoice` 不再为 `duplicate_focus` 读 dossier 重试。 +- **决策 4** `holdoutFollowupFor` 的 occupied 归并 `health` / `health_pressure`。 + +## 三栏(被触碰断言) + +| 用例 | 原值 | 新值 | 理由 | +| --- | --- | --- | --- | +| 事故形状 yearless→采集 | `next_followup.domain = finance`(取 yearless[0]=D11) | `null` | 决策 1,财务已覆盖 | +| 同形状但迁居未覆盖 | finance | relocation | 守卫只跳过已覆盖领域 | +| idle persist 事故形状 | 财务采集焦点 / `:next` | `terminalNote: true`,0 次 set-focus | 决策 1+2 | +| 非 retry 采集撞 id | 任何撞 id 都 `:next` | `duplicate_focus`,RPC 一次 | 决策 3 | +| `collect_retry` 撞 id | `:next` | 仍 `:next` | 保留 BUG-462 retry | +| holdout 账本 `health` + oos `health_pressure` | `health_pressure` | `null` | 决策 4 | + +## 测试 + +- `frontend` `./node_modules/.bin/tsc --noEmit`:exit 0 +- `npm run lint`:0 error(仓库既有 warning 未清) +- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**1006 passed / 0 failed** +- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked +- `block_scan` 壁钟(BUG-570 P2):既有缺口,本单不声称全量 Python 门通过 +- 浏览器七领域真人走查:无登录态,记环境缺口,见 `docs/testing/rectification-scenarios-20260907.md` §6 + +## 偏离 + +1. 任务书写 `nextAction === "adopt_representative"`。实现同时认 `ready_to_adopt` 与 `sessionOutcome === "adopt_representative"`,因为公开 nextAction 是前者。 +2. 验收形状扩在 `rectification-occupation-coverage-exit.test.ts`,没有新建文件。 +3. idle 夹具按解析后账本算 `evidenceLedgerFingerprint`,避免 RPC `summary` 与内存账本对不上把快照判成过期、决策掉回 `collect_evidence`。 +4. `rectification-eight-method.test.ts`:yearless D24 在 CLASSIC_COVERAGE 已有学业时,不再锁「再问一遍学业」;改为收未覆盖的财务。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index c496deef..f101d4d2 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -75,7 +75,7 @@ | `TASK-rectification-duplicate-narration-20260907.md` | `PROGRESS-rectification-duplicate-narration-20260907.md` | 每轮采集旁白说两遍 + 题干在正文与问题块各一次:`step-answer` 的 set-focus 例外(BUG-533)放行了 set-focus 前后两个 step 的正文并拼接;`detachCollectSpokenAssistantText` 只剥逐字后缀,模型写进正文的问句没有代码守卫(BUG-488 防线只在提示词) | 已验收通过 | `9d1c08ca`(BUG-584~585) | | `TASK-rectification-other-collect-fallback-20260908.md` | `PROGRESS-rectification-other-collect-fallback-20260908.md` | 七领域问完落到「也可以再说一件」、职业题从未出现、不自动出卡:`USER_COLLECT_QUESTION.other` 仍有三条活路(职业焦点 `target_domain` 压成 other 后被 active-focus 承接 followup 重建;set-focus 两次无效兜底查表;无领域采集归 other),`collect:other:*` 焦点挂着让 `persistNextInterviewIfIdle` 的出卡路径永远不跑 | 已验收通过(触发链仍 investigating,BUG-586 mitigated) | `9aec5029`(BUG-586) | | `TASK-rectification-probe-replay-loss-20260908.md` | `PROGRESS-rectification-probe-replay-loss-20260908.md` | **P0** 收敛不了的直接原因:答完 5 道题范围收到 8 分钟,之后每补一件经历触发的重算换了候选集,`buildInferenceState` 找不到已答题定义(引擎与对照包按 asked keys 排除、状态 probes 不携带、分盘 key 内嵌分钟列表)→ `rounds:[]`、posterior=prior,交付回到 29 分钟;证据轮旁白不报范围变化;交付卡闪现待证实 | 已验收通过(真实 Case 范围收到 2 分钟;BUG-589 闪现 investigating) | `df182c16`(BUG-587~589) | -| `TASK-rectification-covered-domain-recollect-20260908.md` | `PROGRESS-rectification-covered-domain-recollect-20260908.md` | 范围已收到 2 分钟、决策已是采用,却再问答过的财务题:职业覆盖后的 yearless→采集分支(L2224)不看领域是否已覆盖;`shouldSkipFollowupPersist` 把它当剩余采集挡住出卡;`persistCollectFocus` 撞 id 无条件 `:next` 再插;holdout `occupied` 不认 health/health_pressure 同义 | 待执行 | `codex/rectification-covered-domain-recollect-20260908`(BUG-590~591) | +| `TASK-rectification-covered-domain-recollect-20260908.md` | `PROGRESS-rectification-covered-domain-recollect-20260908.md` | 范围已收到 2 分钟、决策已是采用,却再问答过的财务题:职业覆盖后的 yearless→采集分支(L2224)不看领域是否已覆盖;`shouldSkipFollowupPersist` 把它当剩余采集挡住出卡;`persistCollectFocus` 撞 id 无条件 `:next` 再插;holdout `occupied` 不认 health/health_pressure 同义 | 待验收 | `codex/rectification-covered-domain-recollect-20260908`(BUG-590~591) | | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 410d8bfb..c96b5f9d 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -101,6 +101,7 @@ - 七领域问完后下一问是「你平时主要做什么工作?」,或已经可以交付时直接出区间卡 - 不得出现补问兜底句「也可以再说一件你记得大概时间的事」 - 职业答过、又没有剩余采集时,必须出现区间交付卡,不能只剩停止按钮 +- 职业也问完、决策已是采用后,下一步必须是区间交付卡,不得再出任何已答领域的采集题(例如已经答过的财务) ## 7. 每轮助手气泡只有一段旁白,题干只在问题块出现一次 diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 5f1825ef..59fc069e 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -82,12 +82,14 @@ import { buildNextUserAction, exhaustionSpokenCollectFollowup, isRemainingEvidenceCollect, + remainingEvidenceCollectStillOpen, planWithDateReliability, spokenCollectFallbackFollowup, spokenFollowupForUser, ledgerHasConfirmedDatedEvent, type MethodCoverage, type MethodFollowup, + type MethodFollowupEvidence, type MethodFollowupPlan, } from "./method-followup"; import { followupCaseArgs, isBlockChoiceSchema, isWidenWindowSchema } from "./block-scan.ts"; @@ -230,6 +232,10 @@ function shouldSkipFollowupPersist(input: { followup: MethodFollowup | null; methods?: readonly MethodCoverage[]; accepted?: boolean; + stopReason?: string | null; + sessionOutcome?: string | null; + evidence?: readonly MethodFollowupEvidence[]; + declinedTopics?: readonly Readonly>[]; }): boolean { if (input.accepted) return false; if (!input.canAdopt) return false; @@ -244,7 +250,22 @@ function shouldSkipFollowupPersist(input: { } if (input.methods && !blockingMethodsCovered(input.methods)) return false; if (isRemainingDiscriminatorFollowup(input.followup)) return false; - if (isRemainingEvidenceCollect(input.followup)) return false; + if (isRemainingEvidenceCollect(input.followup)) { + const adoptExhausted = ( + input.nextAction === "ready_to_adopt" + || input.sessionOutcome === "adopt_representative" + ) && input.stopReason === "probe_pool_exhausted"; + if ( + !adoptExhausted + || remainingEvidenceCollectStillOpen( + input.followup, + input.evidence ?? [], + input.declinedTopics ?? [], + ) + ) { + return false; + } + } return true; } @@ -791,6 +812,10 @@ export async function persistNextInterviewAfterChoice(input: { followup, methods: plan.methods, accepted: Boolean(input.dossier.case.acceptedTime), + stopReason: decision.stopReason ?? null, + sessionOutcome: input.nextAction.session_outcome, + evidence: input.dossier.evidence, + declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, })) { const facts = adoptDeliveryFacts(decision, liveDossier); const fallback = adoptHostNarration({ @@ -948,7 +973,12 @@ async function persistFocusAfterChoice(input: { prompt: null, }; } - if (persisted.status === "created" || persisted.status === "already_open" || !input.followup) { + if ( + persisted.status === "created" + || persisted.status === "already_open" + || persisted.status === "duplicate_focus" + || !input.followup + ) { return persisted; } if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) { @@ -1224,6 +1254,10 @@ export async function persistNextInterviewIfIdle(input: { followup, methods: plan.methods, accepted: Boolean(dossier.case.acceptedTime), + stopReason: decision.stopReason ?? null, + sessionOutcome: decision.sessionOutcome, + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, })) { const facts = adoptDeliveryFacts(decision, dossier); const fallback = adoptHostNarration({ diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 725beb79..40f6251d 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -1292,6 +1292,67 @@ export function isRemainingEvidenceCollect( && REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain); } +function collectFocusAskedForDomain( + topics: readonly Readonly>[], + domain: string, +): boolean { + if (domain === "health" || domain === "health_pressure") { + return domainCollectFocusAsked(topics, "health") + || domainCollectFocusAsked(topics, "health_pressure"); + } + return domainCollectFocusAsked(topics, domain); +} + +function yearlessCollectDomainBlocked( + domain: string, + evidence: readonly MethodFollowupEvidence[], + declined: ReadonlySet, + askedRows: readonly Readonly>[], +): boolean { + if (domain === "health" || domain === "health_pressure") { + return declinedHealth(declined) + || hasConfirmedHealth(evidence) + || collectFocusAskedForDomain(askedRows, domain); + } + return declined.has(domain) + || hasConfirmedDomain(evidence, domain) + || collectFocusAskedForDomain(askedRows, domain); +} + +/** Remaining spoken collect that can still add a dated event in an uncovered domain. */ +export function remainingEvidenceCollectStillOpen( + followup: Pick | null | undefined, + evidence: readonly MethodFollowupEvidence[] = [], + declinedTopics: readonly Readonly>[] = [], +): boolean { + if (!isRemainingEvidenceCollect(followup) || !followup?.domain) return false; + const declined = declinedDomains(declinedTopics); + if (followup.domain === "occupation") { + return !hasConfirmedDomain(evidence, "occupation") && !declined.has("occupation"); + } + if ((DATED_COLLECT_ORDER as readonly string[]).includes(followup.domain)) { + return !datedCollectDomainBlocked( + followup.domain as (typeof DATED_COLLECT_ORDER)[number], + evidence, + declined, + ); + } + return true; +} + +function holdoutOccupiedDomains(evidence: readonly MethodFollowupEvidence[]): Set { + const occupied = new Set( + evidence + .filter((item) => isConfirmedDated(item) && evidenceYear(item) != null) + .map((item) => item.domain), + ); + if (hasConfirmedHealth(evidence) || occupied.has("health") || occupied.has("health_pressure")) { + occupied.add("health"); + occupied.add("health_pressure"); + } + return occupied; +} + export function exhaustionSpokenCollectFollowup(input: { evidence: readonly MethodFollowupEvidence[]; declinedTopics?: readonly Readonly>[]; @@ -1682,17 +1743,22 @@ export function holdoutFollowupFor( declined: ReadonlySet, ): Omit | null { if (!meetsAcceptanceEventQuality(input.evidence)) return null; - const occupied = new Set( - input.evidence - .filter((item) => isConfirmedDated(item) && evidenceYear(item) != null) - .map((item) => item.domain), - ); - const prompt = (input.oosBlindPrompts ?? []).find((item) => ( - item.domain && !declined.has(item.domain) && !occupied.has(item.domain) - )) ?? null; - const reserved = (input.holdoutEvents ?? []).find((item) => ( - item.year != null && !declined.has(item.domain) && !occupied.has(item.domain) - )) ?? null; + const occupied = holdoutOccupiedDomains(input.evidence); + const healthDeclined = declinedHealth(declined); + const prompt = (input.oosBlindPrompts ?? []).find((item) => { + if (!item.domain || occupied.has(item.domain)) return false; + if (item.domain === "health" || item.domain === "health_pressure") { + return !healthDeclined; + } + return !declined.has(item.domain); + }) ?? null; + const reserved = (input.holdoutEvents ?? []).find((item) => { + if (item.year == null || occupied.has(item.domain)) return false; + if (item.domain === "health" || item.domain === "health_pressure") { + return !healthDeclined; + } + return !declined.has(item.domain); + }) ?? null; return holdoutAskFields(prompt, reserved); } @@ -2223,23 +2289,28 @@ export function buildMethodFollowupPlan(input: { } if (!next && dashaCovered && coverageComplete && occupationCovered) { const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated; - const yearless = yearlessDiscriminators[0]; - if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) { + const askedRows = [ + ...(input.closedCollectFocuses ?? []), + ...(input.declinedTopics ?? []), + ]; + for (const yearless of yearlessDiscriminators) { + if (!allowLowGainDiscriminator && yearless.score < 0.08) continue; const domain = contrastFollowupDomain( yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null, ); const lead = YEARLESS_COLLECT_LEAD[domain]; - if (lead && !declined.has(domain)) { - next = makeFollowup({ - method_id: PROBE_METHOD_ID[domain], - intent: "collect_method_evidence", - ask_theme: REVERSE_VERIFY_THEME[domain], - domain, - kind_hint: REVERSE_VERIFY_KIND[domain], - user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]), - source: "method_coverage", - }); - } + if (!lead) continue; + if (yearlessCollectDomainBlocked(domain, input.evidence, declined, askedRows)) continue; + next = makeFollowup({ + method_id: PROBE_METHOD_ID[domain], + intent: "collect_method_evidence", + ask_theme: REVERSE_VERIFY_THEME[domain], + domain, + kind_hint: REVERSE_VERIFY_KIND[domain], + user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]), + source: "method_coverage", + }); + break; } } if ( diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index d99d2103..77250bf6 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -356,6 +356,14 @@ async function persistCollectFocus(input: { prompt: null, }; } + if (input.followup.collect_retry !== true) { + return { + status: "duplicate_focus", + focus: input.activeFocus, + questionId, + prompt, + }; + } const retryId = `${questionId}:${COLLECT_FOCUS_RETRY_SUFFIX}`.slice(0, 160); if (retryId === questionId) { return { diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index beda287f..c6fccca7 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -2146,7 +2146,7 @@ test("D9/D10 contrast after occupation coverage asks a discriminator, not adopt" }), "discriminate_candidates"); }); -test("answered duty language skips window D10 and collects a dated education event instead of a yearless D24 card", () => { +test("answered duty language skips window D10 and does not re-ask covered education from a yearless D24 card", () => { const packet = { candidateSetVersion: "05:00-05:07", vargaDifferences: [ @@ -2185,11 +2185,13 @@ test("answered duty language skips window D10 and collects a dated education eve evidence, contrastPacket: packet, }); - assert.equal(plan.next_followup?.domain, "education"); + // 原值: education(yearless D24 直接变成学业采集) + // 新值: finance + // 原因: CLASSIC_COVERAGE 已有学业;BUG-590 跳过已覆盖领域,改收未覆盖的财务 + assert.equal(plan.next_followup?.domain, "finance"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.choice_frame, null); - assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住年份/); - assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d10/); + assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d(10|24)/); }); test("yearless D24 yields to a dated career dasha instead of borrowing the recorded education year", () => { diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index 1cb4742f..24fdb121 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -7,15 +7,31 @@ import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support. import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; -import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { buildMethodFollowupPlan, holdoutFollowupFor } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { + canShowRectificationSelectionCards, parseRectificationCandidateResult, workingRectificationHouseTable, } from "../src/lib/rectification-candidate-result.ts"; import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; -import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts"; import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { interviewQuestionBlocksAdoptOffer } from "../src/lib/rectification-agentic/v9/turn-question.ts"; +import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts"; +import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; +import { + CASE_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; const STYLE_OPTIONS = [ { label: "明确发生且时间吻合", answer_class: "yes" as const }, @@ -503,3 +519,343 @@ test("collect focus is still not a renderable choice card", () => { probe_id: null, }), false); }); + +const ACCIDENT_EVIDENCE = [ + dated("e-edu-1", "education", "education_start", "2016-09-01"), + dated("e-edu-2", "education", "education_completion", "2020-06-01"), + dated("e-rel-1", "relationship", "relationship_start", "2018-05-01"), + dated("e-rel-2", "relationship", "relationship_end", "2021-08-01"), + dated("e-fin-1", "finance", "finance_change", "2024-03-01"), + dated("e-career-1", "career", "career_entry", "2020-04-01"), + dated("e-career-2", "career", "career_change", "2023-07-01"), + dated("e-reloc-1", "relocation", "home_change", "2022-10-01"), + dated("e-health-1", "health", "self_health_event", "2021-11-01"), + { + id: "e-occ-1", + status: "confirmed" as const, + domain: "occupation", + datePrecision: "unknown" as const, + occurredFrom: null, + occurredTo: null, + eventKind: "occupation_note", + }, +]; + +const FAMILY_DECLINED_TOPIC = [{ + target_domain: "family", + status: "declined", + questionId: "collect:family:collect_method_evidence", + intent: "collect_method_evidence", +}]; + +const CLOSED_FINANCE_FOCUS = [{ + questionId: "collect:finance:collect_method_evidence", + targetDomain: "finance", + intent: "collect_method_evidence", + status: "resolved", +}]; + +const ACCIDENT_YEARLESS = { + candidateSetVersion: "04:51-04:53", + vargaDifferences: [] as const, + probes: [ + yearlessProbe("d11", 2.5, "finance"), + yearlessProbe("d2", 2.2, "finance"), + yearlessProbe("d24", 1.2, "education"), + yearlessProbe("d5", 0.5, "education"), + yearlessProbe("d4", 0.99, "relocation"), + yearlessProbe("d7", 1.22, "family"), + yearlessProbe("d12", 1.1, "family"), + ], +}; + +const ANSWERED_SIX = [ + { semantic_key: "varga.d9.天秤座|天蝎座", answer_class: "yes" }, + { semantic_key: "varga.d10.巨蟹座|狮子座", answer_class: "yes" }, + { semantic_key: "career.2020", answer_class: "yes" }, + { semantic_key: "career.2023", answer_class: "no" }, + { semantic_key: "relocation.2022", answer_class: "no" }, + { semantic_key: "finance.2024", answer_class: "no" }, +]; + +function coveredDomainPlan( + extra: Partial[0]> = {}, +) { + return buildMethodFollowupPlan({ + evidence: ACCIDENT_EVIDENCE, + contrastPacket: ACCIDENT_YEARLESS, + declinedTopics: FAMILY_DECLINED_TOPIC, + closedCollectFocuses: CLOSED_FINANCE_FOCUS, + answeredProbes: ANSWERED_SIX, + sessionOutcome: "adopt_representative", + ...extra, + }); +} + +test("yearless-to-collect does not re-ask a covered domain after occupation is done", () => { + const plan = coveredDomainPlan(); + // 原值: next_followup.domain = finance(L2224 取 yearless[0]=D11) + // 新值: null + // 原因: 财务已有带年月事件且采集焦点已关闭;守卫跳过已覆盖领域后交付 + assert.equal(plan.next_followup, null); +}); + +test("yearless-to-collect still asks an uncovered relocation domain", () => { + const plan = coveredDomainPlan({ + evidence: ACCIDENT_EVIDENCE.filter((item) => item.domain !== "relocation"), + closedCollectFocuses: CLOSED_FINANCE_FOCUS, + }); + // 原值: finance(D11 仍排第一) + // 新值: relocation + // 原因: 守卫只跳过已覆盖领域,不是删 yearless→采集分支 + assert.equal(plan.next_followup?.domain, "relocation"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.choice_frame, null); +}); + +function rpcAccidentEvidence(rows: readonly typeof ACCIDENT_EVIDENCE[number][]) { + return rows.map((item, index) => ({ + id: item.id ?? `e-${index}`, + source_turn_id: "33333333-3333-4333-8333-333333333333", + subject: "self", + event_kind: item.eventKind ?? item.domain, + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: "", + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-08T00:00:00.000Z", + })); +} + +function accidentEvidenceFingerprint() { + return evidenceLedgerFingerprint(rpcAccidentEvidence(ACCIDENT_EVIDENCE).map((item) => ({ + id: item.id, + eventKind: item.event_kind, + domain: item.domain, + occurredFrom: item.occurred_from, + occurredTo: item.occurred_to, + datePrecision: item.date_precision, + summary: item.summary, + status: item.status, + })) as never); +} + +function yearlessInferenceProbe(layer: string, domain: string, gain: number) { + return { + id: `contrast:varga.${layer}.unsigned`, + semantic_key: `varga.${layer}.unsigned`, + candidate_split_hash: `varga.${layer}.04:51/04:53`, + domain, + year: 0, + question: `当前几个候选在 ${layer} 上还分得开。`, + candidate_ids: ["04:51", "04:53"], + expected_outcomes: [ + { answer_class: "yes" as const, supports: ["04:51"], conflicts: ["04:53"] }, + { answer_class: "no" as const, supports: ["04:53"], conflicts: ["04:51"] }, + ], + information_gain: gain, + source: "varga_contrast", + choice_kind: "existence" as const, + style_options: STYLE_OPTIONS, + }; +} + +function accidentIdleState() { + const times = ["04:51", "04:52", "04:53"] as const; + const raw = { + algorithm_version: INFERENCE_ALGORITHM_VERSION, + candidate_set_id: candidateSetId("04:51", "04:53", times), + revision: 6, + phase: "discrimination" as const, + result_status: "discriminating" as const, + range_start: "04:51", + range_end: "04:53", + candidates: times.map((time, index) => ({ + id: time, + time, + cluster_range: [time, time] as const, + prior_score: 30 - index, + posterior_score: 30 - index, + probability: index === 0 ? 0.5 : 0.25, + status: "active" as const, + rank: index + 1, + strong_conflict_count: 0, + })), + events: [ + { id: "e-edu-1", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const }, + { id: "e-edu-2", domain: "education", year: 2020, precision: "month" as const, usage: "training" as const }, + { id: "e-rel-1", domain: "relationship", year: 2018, precision: "month" as const, usage: "training" as const }, + { id: "e-rel-2", domain: "relationship", year: 2021, precision: "month" as const, usage: "holdout" as const }, + { id: "e-fin-1", domain: "finance", year: 2024, precision: "month" as const, usage: "holdout" as const }, + { id: "e-career-1", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const }, + { id: "e-career-2", domain: "career", year: 2023, precision: "month" as const, usage: "training" as const }, + { id: "e-reloc-1", domain: "relocation", year: 2022, precision: "month" as const, usage: "training" as const }, + { id: "e-health-1", domain: "health", year: 2021, precision: "month" as const, usage: "training" as const }, + ], + probes: [ + yearlessInferenceProbe("d11", "finance", 2.5), + yearlessInferenceProbe("d24", "education", 1.2), + yearlessInferenceProbe("d4", "relocation", 0.99), + yearlessInferenceProbe("d7", "family", 1.22), + ], + answered_probes: ANSWERED_SIX.map((item) => ({ + probe_id: `probe:${item.semantic_key}`, + semantic_key: item.semantic_key, + candidate_split_hash: item.semantic_key, + answer_class: item.answer_class as "yes" | "no", + classified_from: "choice" as const, + })), + rounds: ANSWERED_SIX.map((item, index) => ({ + round: index + 1, + phase: "discrimination" as const, + probe_id: `probe:${item.semantic_key}`, + scores_before: { "04:51": 30, "04:52": 29, "04:53": 28 }, + scores_after: { "04:51": 32, "04:52": 29, "04:53": 28 }, + entropy_before: 1.1, + entropy_after: 1.0, + eliminated_ids: [] as string[], + winner_id: null, + kind: "informative" as const, + })), + last_inference_round: null, + entropy: 1.0, + representative_time: "04:51", + credible_range: ["04:51", "04:53"] as const, + holdout_passed: null, + }; + const loaded = asInferenceState(raw); + assert.ok(loaded, "covered-domain accident inference must parse"); + return loaded; +} + +function accidentIdleDossier() { + const fingerprint = accidentEvidenceFingerprint(); + const state = accidentIdleState(); + return dossierFixture({ + evidence: rpcAccidentEvidence(ACCIDENT_EVIDENCE), + evidenceCount: ACCIDENT_EVIDENCE.length, + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + representativeTime: "04:51", + evidenceLedgerFingerprint: fingerprint, + candidates: [ + { candidate_id: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:51", relative_support: 40, tied_minute_count: 1 }, + { candidate_id: "88888888-8888-4888-8888-888888888882", rank: 2, time: "04:52", relative_support: 35, tied_minute_count: 1 }, + { candidate_id: "88888888-8888-4888-8888-888888888883", rank: 3, time: "04:53", relative_support: 25, tied_minute_count: 1 }, + ], + decisionReceipt: { + accept_allowed: true, + acceptance_allowed: true, + propose_allowed: true, + selection_allowed: true, + confirmation_allowed: false, + inference_state: state, + }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: null, + declined_skipped_topics: FAMILY_DECLINED_TOPIC, + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-08T00:00:00.000Z", + }, + }); +} + +test("idle persist on the covered-domain accident delivers adopt and writes no focus", async () => { + const rpc = accidentIdleDossier(); + const dossier = parseV9CaseDossier(rpc); + assert.ok(dossier, "covered-domain accident RPC dossier must parse"); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(decision.canAdopt, true); + assert.equal(decision.stopReason, "probe_pool_exhausted"); + + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpc, + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: (_fn, args) => { + throw new Error(`must not persist ${String(args.p_question_id)}`); + }, + append_agentic_rectification_turn: () => ({ turn_id: "33333333-3333-4333-8333-333333333333", idempotent: false }), + finalize_agentic_rectification_turn: () => ({ + turn_id: "33333333-3333-4333-8333-333333333333", + status: "completed", + idempotent: false, + }), + get_agentic_rectification_turn_receipt: () => null, + }); + const idle = await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal( + accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), + false, + ); + assert.equal(idle.terminalNote, true); + assert.ok(idle.hostNarration); + assert.equal(projectCurrentQuestion(null), null); + assert.equal(interviewQuestionBlocksAdoptOffer(null, false), false); +}); + +test("agent-run still writes the exhaustion gate when idle persist returns terminalNote", () => { + const agent = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + assert.match(agent, /idle\.terminalNote && idle\.hostNarration/); + assert.match(agent, /persistExhaustionGateTurn/); + assert.match(chat, /canOfferCards = canShowRectificationSelectionCards/); + const offer = canShowRectificationSelectionCards( + parseRectificationCandidateResult({ + resultId: "55555555-5555-4555-8555-555555555555", + candidates: [ + { candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 1 }, + { candidateId: CANDIDATE_IDS[1], rank: 2, time: "04:53", relativeSupport: 35, tiedMinuteCount: 1 }, + ], + overallConfidence: "medium", + selectionAllowed: true, + canAdopt: true, + confirmationAllowed: false, + representativeTime: "04:51", + selectedTime: null, + sessionOutcome: "adopt_representative", + decisionReceipt: { + accept_allowed: true, + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + }, + }), + ); + assert.equal(offer, true); +}); + +test("holdout occupied treats health and health_pressure as the same line", () => { + const evidence = [ + ...ACCIDENT_EVIDENCE.filter((item) => item.domain !== "health"), + dated("e-health-ledger", "health", "self_health_event", "2021-11-01"), + ]; + const fields = holdoutFollowupFor({ + evidence, + oosBlindPrompts: [{ + domain: "health_pressure", + user_meaning: "身体这条线还没用过。", + used_for_scoring: false, + }], + }, new Set()); + // 原值: health_pressure + // 新值: null + // 原因: occupied 不认 health / health_pressure 同义时会再问一遍健康 + assert.equal(fields, null); +}); + diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index 2b005a29..dfdd741b 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -784,8 +784,10 @@ test("collect persist maps occupation to other and health_pressure to health", a assert.equal(healthWrite?.args.p_target_domain, "health"); }); -test("collect focus unique conflict retries with a :next question id", async () => { - const followup = collectFollowup(); +test("collect focus unique conflict is duplicate_focus unless collect_retry", async () => { + // 原值: 任何撞 id 都 :next + // 新值: 仅 collect_retry 才 :next;否则 duplicate_focus、RPC 一次 + // 原因: BUG-591 决策 3;撞 id 本身就是这题问过了 let writes = 0; const accounting = fakeAccounting({ set_agentic_rectification_conversation_focus: (_fn, args) => { @@ -800,7 +802,29 @@ test("collect focus unique conflict retries with a :next question id", async () caseId: CASE_ID, activeFocus: null, decisionReceipt: null, - followup, + followup: collectFollowup(), + }); + assert.equal(persisted.status, "duplicate_focus"); + assert.equal(writes, 1); + assert.equal(persisted.questionId, "collect:relationship:collect_method_evidence"); +}); + +test("collect_retry unique conflict still retries with a :next question id", async () => { + let writes = 0; + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: (_fn, args) => { + writes += 1; + if (writes === 1) throw new Error("agentic_rectification_focus_idempotency_conflict"); + return focusRowFromArgs(args); + }, + }); + const persisted = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup: collectFollowup({ collect_retry: true }), }); assert.equal(persisted.status, "created"); assert.equal(writes, 2);