From d96b24c2767e0fa8ae91aff67dfa26e2c80def51 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 9 Sep 2026 23:02:45 +0800 Subject: [PATCH] fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627) Co-authored-by: Cursor --- CHANGELOG.md | 6 + docs/BUG_HISTORY.md | 32 ++++ ...ication-skipped-health-deadend-20260909.md | 39 +++++ docs/tasks/README.md | 2 +- .../rectification-scenarios-20260907.md | 18 +- frontend/DESIGN.md | 6 +- .../cases/[caseId]/repair-exit/route.ts | 93 ++++++++++ .../api/rectification/cases/[caseId]/route.ts | 2 +- .../components/rectification-agentic-chat.tsx | 63 ++++++- .../lib/rectification-agentic/v9/agent-run.ts | 9 +- .../rectification-agentic/v9/answer-choice.ts | 144 +++++++++++----- .../v9/method-followup.ts | 31 ++-- .../src/lib/rectification-surface-state.ts | 6 +- ...ication-collect-direction-20260904.test.ts | 21 +++ ...ification-exhaustion-exit-20260906.test.ts | 57 +++++- ...on-skipped-health-deadend-20260909.test.ts | 162 ++++++++++++++++++ 16 files changed, 615 insertions(+), 76 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-skipped-health-deadend-20260909.md create mode 100644 frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts create mode 100644 frontend/tests/rectification-skipped-health-deadend-20260909.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c43f2a..60b4d206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ 填完生辰和地址后,从首页点「家庭」等主题,会先按该主题排盘再写回答,不再出现「Agent 未完成必要的方法与计算步骤,本次不会扣点」。点卡不会改你看到的那句问题。自己在输入框里问的普通问题仍可一次看多个领域。Skill 版本不变。 +## 2026-09-09 — 健康题记不清后不再断在「没有下一个问题」 + +生时校正里健康题打字「记不清」后,不会再把同一条健康线当成没用过的盘外核对再问一遍。撞上已有焦点时也不会再静默断流。穷尽如果没有下一问、卡或门槛句,服务端会补一句可见出口。缺口按钮改成「接着问」,向服务端修复;连续两次仍接不上才提示新建一次校正。Skill 版本不变。 + + + ## 2026-09-09 — 生时校正拿掉「没有 / 记不清」按钮 口述采集不再出示「没有」「记不清」快捷按钮。想跳过仍可在输入框打这两句,语义不变。选择题卡选项不动。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ca24641e..c8ff6c7c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9716,6 +9716,38 @@ - 复发自:无 - 修复版本:待发布 +## BUG-626 | 跳过的健康线在 holdout 里被当成没用过,撞同 id 焦点后静默断流 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`holdoutFollowupFor`、`persistNextInterviewAfterChoice`、`persistExhaustionCollect` +- 用户现象:健康采集点「记不清」后再答完职业,界面只剩「没有拿到下一个问题。」 +- 触发条件:七领域采集接近穷尽,健康焦点 `target_domain=health` 且 `skipped`,盘外提示仍是 `health_pressure`。 +- 根因:BUG-590 只给 occupied 归并 health / health_pressure,holdout 的 declined 也曾漏过这一层;即便归并后,`duplicate_focus` 仍可能落到空旁白。DB 约束只有 `health`,计划层用 `health_pressure`。 +- 修复:holdout 的 declined 与 occupied 一样归并 health / health_pressure。`duplicate_focus` 记 `rectification_focus_duplicate` 并走 `persistExhaustionCollect`;穷尽旁白为空时用 `noCandidatesGate` 兜底。 +- 验证:`frontend/tests/rectification-collect-direction-20260904.test.ts`、`frontend/tests/rectification-skipped-health-deadend-20260909.test.ts`。 +- 防复发:跳过的健康线本会话不得再作 holdout;`duplicate_focus` 必须留下可见载体。 +- 相关记录:BUG-558、BUG-590、BUG-591、BUG-605 +- 复发自:BUG-590 决策 4(occupied 已归并,declined 曾漏) +- 修复版本:待发布 + +## BUG-627 | 出口闸门把穷尽当成已交付,断流后不修复 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`inspectNonTerminalTurnExit`、`ensureNonTerminalTurnExit`、`agent-run` idle catch、`POST /api/rectification/cases/[caseId]/repair-exit` +- 用户现象:穷尽后没有任何问题、卡或句子落库,闸门仍放行;「重新加载」只重取空快照。 +- 触发条件:`exhausted` 为真,无活动焦点、未采用、无 terminalNote host turn。 +- 根因:`inspectNonTerminalTurnExit.satisfied` 含 `exhausted`。穷尽只是状态,不是用户能看见的出口。idle 异常被 `agent-run` 吞掉后也到不了闸门。 +- 修复:satisfied 只认活动问题 / 已采用 / 已确认 / 已落库的 terminalNote host turn / `publicCanAdopt`。无载体时 `ensureNonTerminalTurnExit` 写出门槛 host turn。idle 失败后仍调用闸门。缺口按钮改调 `repair-exit`,文案「接着问」;连续两次仍无载体才提示新建。 +- 验证:`frontend/tests/rectification-exhaustion-exit-20260906.test.ts`、`frontend/tests/rectification-skipped-health-deadend-20260909.test.ts`。 +- 防复发:穷尽不得单独满足出口闸门;无可见载体必须写出 host turn。 +- 相关记录:BUG-558、BUG-626 +- 复发自:BUG-558 防复发条款被闸门绕过 +- 修复版本:待发布 + ## BUG-628 | 口述采集「没有 / 记不清」按钮被用户差评 - 状态:resolved diff --git a/docs/tasks/PROGRESS-rectification-skipped-health-deadend-20260909.md b/docs/tasks/PROGRESS-rectification-skipped-health-deadend-20260909.md new file mode 100644 index 00000000..88adbef6 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-skipped-health-deadend-20260909.md @@ -0,0 +1,39 @@ +# PROGRESS · 跳过健康后 holdout 断流 + 出口闸门(2026-09-09) + +工作树:`.worktrees/rectification-skipped-health-deadend-20260909` +分支:`codex/rectification-skipped-health-deadend-20260909` +任务书:`docs/tasks/TASK-rectification-skipped-health-deadend-20260909.md` +基线:开工时 `origin/staging` @ `4eea9c10` + +## 开工回执 + +- 目标:BUG-626 跳过的健康线不再进 holdout,`duplicate_focus` 必须留下可见载体;BUG-627 出口闸门不再把穷尽当成已交付;缺口按钮改为服务端修复。 +- 编号:BUG-628 已被「没有 / 记不清」按钮占用,本单只用 626/627。 +- 决策 1 在基线里已有 `declinedHealth` 特判;本单改成与 occupied 一样把两个别名写入 blocked 集合,并补 skipped `health` 回归。 + +## 已完成 + +- **4.1 / BUG-626**:`holdoutFollowupFor` 用 `declinedForHoldout`。`duplicate_focus` 记 `rectification_focus_duplicate` 并走 `persistExhaustionCollect`;穷尽旁白为空时用 `noCandidatesGate`。`persistExhaustionCollect` 只在 `duplicate_focus` 时落到门槛句,其它 persist 失败仍返回采集旁白,避免证据轮被误标 `terminalNote` 而不裁句。 +- **4.2 / BUG-627**:`inspectNonTerminalTurnExit.satisfied` 去掉 `exhausted`,改认活动问题 / 采用 / 确认 / terminalNote host turn / `publicCanAdopt`。无载体时写出门槛 host turn。idle catch 仍调 `ensureNonTerminalTurnExit`。 +- **4.3**:`POST /api/rectification/cases/[caseId]/repair-exit`;按钮「接着问」;两次仍无载体显示「暂时接不上,请新建一次校正。」 +- **4.4**:BUG-626 / 627、CHANGELOG、本文件、走查第 12 条。 + +## 验收命令 + +在 `frontend/`: + +- `./node_modules/.bin/tsc --noEmit`:通过 +- `npm run lint`:0 error(既有 107 warning) +- 定向 `tsx --test`: + - `rectification-skipped-health-deadend-20260909.test.ts` + - `rectification-collect-direction-20260904.test.ts` + - `rectification-exhaustion-exit-20260906.test.ts` + - `rectification-surface-contract.test.ts` + - `rectification-agentic-entry.test.ts` + - `rectification-spoken-collect.test.ts` + - `rectification-collect-stall.test.ts` + → **195 / 195 通过**(含 `rectification-v9-stream.test.ts`、`rectification-answer-choice.test.ts`) +- 全量 `tests/rectification-*.test.ts`(排除 database):**1067 / 1067 通过** +- 相关补充:`rectification-server-focus.test.ts`、`rectification-decision-authority.test.ts`、`rectification-covered-domain-recollect-20260908.test.ts` → **39 / 39 通过** + +本单状态:**待验收**。未改 Skill 版本、未改采用/确认门、未新增迁移。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f0852270..60719346 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -90,7 +90,7 @@ | `TASK-rectification-compare-card-polish-20260909.md` | `PROGRESS-rectification-compare-card-polish-20260909.md` | 三列卡两列经历对照 0、三列无未来窗:引擎按引擎前三算 by_time,卡片按推断前三查表,键不匹配(BUG-614);交付旁白被 BUG-606 裁成一句(BUG-615);设计减法:相同性格句只写一次、经历对照说人话、删小标题、右栏本命宫位改北印星盘、换升时刻默认折叠 | 已验收通过(by_time 只算 9 个 → BUG-632) | (BUG-614~615) | | `TASK-rectification-history-open-20260909.md` | `PROGRESS-rectification-history-open-20260909.md` | 历史生时校正打不开:`open_agentic_rectification_case_v2` 把 Case 绑定 Skill 身份与当前注册版本做相等比较,两天内 10.0.15→10.0.19 五次 bump 让所有旧 Case 被拒;错误码未映射成 500;错误只画在首页卡下 → 表现为点了没反应。决策:session 打开用绑定身份(v1 取 Case → 读身份 → 注册表核验 → v2),不改迁移 | 已验收通过 | `a31a5e24`(BUG-621) | | `TASK-rectification-window-cluster-cap-20260909.md` | `PROGRESS-rectification-window-cluster-cap-20260909.md` | **P0** 真实用户:一小时窗口分 17 个签名簇,`select_signature_representatives` 按时间取前 12 个,14:40 之后整簇丢弃(本机复现表);区间按代表分钟跨度而非簇覆盖;中途说出真实时段时助手口头答应却未改 → 改为固定回复"范围开始时按资料定、中途不改"(产品否决口头改范围);intake 自定义范围等产品答复 | 已验收通过(申报拦截误伤经历 → BUG-631;intake 自定义范围等产品) | `6008c07c`(BUG-623~625,Skill 10.0.20) | -| `TASK-rectification-skipped-health-deadend-20260909.md` | `PROGRESS-rectification-skipped-health-deadend-20260909.md` | 真实用户:健康题「记不清」后职业答完即断(『没有拿到下一个问题』):holdout 的 `declined` 未归并 health/health_pressure,把跳过的健康线再问一次,撞同 id 焦点 `duplicate_focus` 静默;出口闸门把 `exhausted` 当已交付不修复;『重新加载』只重取快照 | 待执行 | `codex/rectification-skipped-health-deadend-20260909`(BUG-626~627) | +| `TASK-rectification-skipped-health-deadend-20260909.md` | `PROGRESS-rectification-skipped-health-deadend-20260909.md` | 真实用户:健康题「记不清」后职业答完即断(『没有拿到下一个问题』):holdout 的 `declined` 未归并 health/health_pressure,把跳过的健康线再问一次,撞同 id 焦点 `duplicate_focus` 静默;出口闸门把 `exhausted` 当已交付不修复;『重新加载』只重取快照 | 待验收 | `codex/rectification-skipped-health-deadend-20260909`(BUG-626~627) | | `TASK-rectification-domain-alias-audit-20260909.md` | `PROGRESS-rectification-domain-alias-audit-20260909.md` | 领域命名审计:健康线 `health`(账本/焦点)与 `health_pressure`(计划/引擎)在十处比较里六处未归并(holdout declined、reverse-verify、conflict probes、probeYearAlreadyCovered、引擎 oos_blind_prompts / _event_years / volunteered);职业线焦点存为 `other` 只靠 questionId 兜底。其余六领域三层同名无问题。决策:TS/Python 各一个归并函数 + 源码合同禁字面量比较 | 待执行 | `codex/rectification-domain-alias-audit-20260909`(BUG-628) | | `TASK-rectification-yearless-probe-downgrade-20260909.md` | `PROGRESS-rectification-yearless-probe-downgrade-20260909.md` | 产品拍板:无年月性格题(D9/D10 风格、月宿边界)降级为平局裁决——带年月题问完且候选仍分不开才问,分值减半(±1)、不计淘汰,报告标「参考」;三列卡性格描述不动;新增离线命中率测量脚本 + 导出 SQL(有出生证用户,聚合);Skill 10.0.21 | 已验收通过 | `fef54b6e`(BUG-629,Skill 10.0.21) | | `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待验收 | `codex/rectification-followups-20260909`(BUG-631~632) | diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 6beabfa7..81e773ba 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -175,7 +175,7 @@ - 每轮助手正文一句复述(可另接一句范围变化);气泡里没有「本轮对照了…」,也没有「很有帮助 / 很有价值 / 很有分量 / 特别有用」 - 报经历时带钟点(「20:00 左右分手」「下午 3 点到 5 点被车撞」)不得被当成申报出生时段,不得回「搜索范围开始时按资料定、过程中不改」 -## 8. 性格题只作平局参考(BUG-629) +## 12. 性格题只作平局参考(BUG-629) 资料与开场同第 0 条。虚构经历。 @@ -187,7 +187,7 @@ - 交付后展开验证报告,「D9 / D10 类型对照」和「月宿边界」状态是 `reference`,说明是性格自评、只作排序参考、不参与淘汰 - 三列对照卡上的性格句子仍按类型表展示,不改写 -## 8. 证据轮模型没写正文时必须出现「记下了」和下一问 +## 13. 证据轮模型没写正文时必须出现「记下了」和下一问 资料与开场同第 0 条。虚构经历说到搬家或入职一件带年月的事。 @@ -197,3 +197,17 @@ - 下一问可见(气泡里或主持人问题行),输入框在问题可见时才写「请回答上面的问题…」 - 点选题范围没变时应能看到领先或落后;全零才只说「范围没变」 - 顶部时间线不得写「还在收窄」 + +## 14. 健康题点「记不清」后职业答完必须出卡或门槛句 + +资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。虚构经历。 + +采集走到健康题时打字「记不清」,再答完职业题。 + +期望: + +- 职业答完后必须出现下一张卡、下一问或门槛句 +- 不得出现「没有拿到下一个问题。」 +- 缺口按钮若出现,文案是「接着问」,点了会向服务端修复,不是只重取快照 +- 连续两次仍无载体才显示「暂时接不上,请新建一次校正。」 + diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index abcc801e..b6444298 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -229,7 +229,7 @@ The birth-time rectification session is the consultation transcript plus a house | `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card | | `question-gap`, persisted question | snapshot already has the next prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” | | `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled | -| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled | +| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled | | `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled | | `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible | | `candidates` | one range-delivery card of up to three compare columns (highest posterior first; “更像这个” adopts) under the offering message; a closed “查看验证报告” fold | enabled | @@ -238,9 +238,9 @@ The birth-time rectification session is the consultation transcript plus a house | `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled | | stopped | the row settles with what streamed; a grey caption “已停止,已生成的内容保留;本次不会扣点。” under the body, never `role="alert"` | enabled | -- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a reload button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves. +- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a repair button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves. - **Board:** before any candidate exists the header clock shows the declared birth minute and the body reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one. -- **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the reload and start actions are real 44px buttons. +- **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the repair and start actions are real 44px buttons. ### Birth time intake diff --git a/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts new file mode 100644 index 00000000..76754080 --- /dev/null +++ b/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + loadV9CaseDossier, + loadV9CaseSkillIdentityStatus, + loadV9TurnReceipt, + RectificationToolServiceError, + listV10ConversationFocuses, +} from "@/lib/rectification-agentic/v9/tool-service"; +import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice"; +import { dossierResponse } from "../route"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ caseId: string }> }; + +const repairSchema = z.object({ + sessionId: z.string().uuid().optional(), +}).strict(); + +/** + * POST /api/rectification/cases/[caseId]/repair-exit + * + * Repair a turn that settled without a visible next question or card. + * Only runs ensureNonTerminalTurnExit, then returns the same snapshot as GET. + */ +export async function POST(request: Request, context: RouteContext) { + let supabase; + let accounting; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit"); + } + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const { caseId } = await context.params; + if (!z.string().uuid().safeParse(caseId).success) { + return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 }); + } + const parsed = repairSchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: "请求内容不正确", code: "invalid_repair_request" }, { status: 400 }); + } + + try { + await ensureNonTerminalTurnExit({ + accounting, + userId: user.id, + caseId, + }); + const dossier = await loadV9CaseDossier(accounting, user.id, caseId); + if (parsed.data.sessionId && dossier.case.sessionId !== parsed.data.sessionId) { + return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 }); + } + const skillIdentity = await loadV9CaseSkillIdentityStatus(accounting, user.id, caseId); + const receipts = await Promise.all( + dossier.turns.map(async (turn) => { + try { + return await loadV9TurnReceipt(accounting, user.id, caseId, turn.id); + } catch { + return null; + } + }), + ); + const listed = await listV10ConversationFocuses(accounting, user.id, caseId); + return NextResponse.json({ + ok: true, + ...dossierResponse(dossier, receipts, skillIdentity, { listed }), + }); + } catch (error) { + if (error instanceof RectificationToolServiceError) { + const message = error.message; + if (message.includes("agentic_rectification_case_not_found")) { + return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 }); + } + } + return NextResponse.json( + { error: "暂时无法接上下一个问题", code: "rectification_repair_failed" }, + { status: 503 }, + ); + } +} diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index fad85716..0bc9e9d7 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -93,7 +93,7 @@ export async function GET(request: Request, context: RouteContext) { } } -function dossierResponse( +export function dossierResponse( dossier: V9CaseDossier, receipts: Array>>, skillIdentity: Awaited>, diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 7c1ace3c..98cf32c4 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -52,6 +52,8 @@ import { RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE, RECTIFICATION_QUESTION_PREPARING_LABEL, RECTIFICATION_QUESTION_RELOAD_LABEL, + RECTIFICATION_QUESTION_REPAIR_FAILED_COPY, + RECTIFICATION_QUESTION_REPAIR_LIMIT, RECTIFICATION_QUESTION_RETRY_INTERVAL_MS, RECTIFICATION_QUESTION_RETRY_LIMIT, RECTIFICATION_QUESTION_UNAVAILABLE_COPY, @@ -484,6 +486,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [nextUserActionId, setNextUserActionId] = useState(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null); const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0); + const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0); + const [questionRepairing, setQuestionRepairing] = useState(false); const [openingRequested, setOpeningRequested] = useState(false); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); @@ -547,6 +551,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setBusy(value); // A turn starting is a fresh chance for the next question to arrive. if (value) setQuestionRetryAttempts(0); + if (value) setQuestionRepairAttempts(0); onPendingChange?.(value); }, [onPendingChange]); @@ -649,6 +654,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setCaseStatus(nextCaseStatus); setNextUserActionId(nextActionId || null); setCaseSnapshotLoaded(true); + if (nextQuestion || nextChoice || acceptedTime || confirmedTime) { + setQuestionRepairAttempts(0); + } if (confirmedTime) { setSavedTime(confirmedTime); setSavedStatus("confirmed"); @@ -1500,7 +1508,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { )); // The gap between a settled turn and its next question has two visible // states: `preparing` (one live timeline row, timed refetches) and - // `unavailable` (copy and a reload button) — never bare copy telling the + // `unavailable` (copy and a repair button) — never bare copy telling the // reader to wait for the server. const questionGap = rectificationQuestionGapState({ liveQuestionVisible: liveQuestionOnMessages, @@ -1532,7 +1540,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }); // Question recovery: the turn already refetched once on completion; while // the gap is `preparing`, refetch on a timer up to the retry limit, then - // hand the reader a reload button. Attempts reset once a question arrives + // hand the reader a repair button. Attempts reset once a question arrives // or another turn starts (both in handlers, never in an effect). useVisibilityAwarePoll({ enabled: questionGap === "preparing", @@ -1546,9 +1554,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { async function refetchQuestion() { await loadCaseSnapshot(); } - function reloadQuestion() { - setQuestionRetryAttempts(0); - void refetchQuestion(); + async function repairQuestion() { + if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return; + setQuestionRepairing(true); + try { + const response = await fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}/repair-exit`, + { + method: "POST", + headers: { "content-type": "application/json" }, + cache: "no-store", + body: JSON.stringify({ sessionId }), + }, + ); + const payload = await response.json().catch(() => null); + if (response.ok && payload) { + startTransition(() => { + applyCaseSnapshot(payload); + }); + const recovered = Boolean( + currentQuestionFromSnapshot(payload.current_question) + || parseRectificationChoiceCard(payload.choice_card) + || payload.case?.accepted_time + || payload.case?.confirmed_time + ); + if (!recovered) setQuestionRepairAttempts((current) => current + 1); + } else { + await loadCaseSnapshot(); + setQuestionRepairAttempts((current) => current + 1); + } + } catch { + setQuestionRepairAttempts((current) => current + 1); + } finally { + setQuestionRepairing(false); + } } // A Case with no turns and no automatic first turn (the server never starts @@ -1768,10 +1807,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { )} {questionGap === "unavailable" && (
-

{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}

- + {questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? ( +

{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}

+ ) : ( + <> +

{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}

+ + + )}
)} {questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && ( diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index a7c3ae98..670596cf 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -32,7 +32,7 @@ import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from " import { toAgentModelFinishReason } from "../../agent-observability.ts"; import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts"; import { decideFromDossier } from "./decision-from-dossier"; -import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice"; +import { ensureNonTerminalTurnExit, persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice"; import { alreadyDelivered } from "./delivery-turn-guard"; import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card"; import { @@ -622,6 +622,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise[]; + latestResult?: { resultId?: string } | null; + }; +}): boolean { + const resultId = input.dossier.latestResult?.resultId ?? ""; + if (alreadyDelivered({ + caseId: input.caseId, + resultId, + hasUserMessage: false, + })) return true; + return (input.dossier.turns ?? []).some((turn) => ( + turn.role === "assistant" && looksLikeTerminalNoteNarration(turn.text) + )); +} + +function logDuplicateFocus(caseId: string, questionId: string | null | undefined): void { + console.warn(JSON.stringify({ + event: "rectification_focus_duplicate", + case_id: caseId, + question_id: questionId ?? null, + })); +} + +function exhaustionCarrierNarration(range: string | null | undefined, gate: string | null | undefined): string { + return [range, gate].filter((item): item is string => Boolean(item?.trim())).join("") + || RECTIFICATION_USER_COPY.noCandidatesGate; +} + export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string { return `${askedTurnId ?? caseId}:gate`; } @@ -132,6 +173,9 @@ export async function persistExhaustionGateTurn(input: { resultId: input.resultId ?? null, terminalNote: true, }); + if (input.resultId) { + markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId }); + } await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, { requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId), userMessage: null, @@ -868,6 +912,9 @@ export async function persistNextInterviewAfterChoice(input: { } if (followup?.intent === "collect_method_evidence") { const spoken = spokenFollowupForUser(followup); + if (persistedFocus.status === "duplicate_focus") { + logDuplicateFocus(input.caseId, persistedFocus.questionId); + } if ( spoken && (persistedFocus.status === "created" || persistedFocus.status === "already_open") @@ -945,6 +992,9 @@ async function persistFocusAfterChoice(input: { || persisted.status === "duplicate_focus" || !input.followup ) { + if (persisted.status === "duplicate_focus") { + logDuplicateFocus(input.caseId, persisted.questionId); + } return persisted; } if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) { @@ -1422,12 +1472,28 @@ async function persistExhaustionCollect(input: { const persisted = Boolean(spoken) && ( persistedFocus.status === "created" || persistedFocus.status === "already_open" ); - return { - persisted, - choiceReady: false, - hostNarration: spoken ?? range, - focus: persistedFocus.focus, - }; + if (persisted) { + return { + persisted, + choiceReady: false, + hostNarration: spoken ?? range, + focus: persistedFocus.focus, + }; + } + // duplicate_focus: this ask is already closed; fall through to adopt/gate. + // Other persist misses still return the spoken collect so mid-session + // turns keep a next question instead of a terminal gate. + if (persistedFocus.status !== "duplicate_focus") { + const hostNarration = (spoken ?? "").trim() || (range ?? "").trim(); + if (hostNarration) { + return { + persisted: false, + choiceReady: false, + hostNarration, + focus: persistedFocus.focus, + }; + } + } } if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) { const adopted = { @@ -1463,7 +1529,7 @@ async function persistExhaustionCollect(input: { openingRange: openingRangeFromDossier(dossier), variant: "intermediate", }); - const hostNarration = [range, gate].filter(Boolean).join(""); + const hostNarration = exhaustionCarrierNarration(range, gate); return { persisted: false, choiceReady: false, @@ -1713,42 +1779,13 @@ async function inspectNonTerminalTurnExit(input: { birthDate, snapshotCurrent: rescored.snapshotCurrent, }); - const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); - const remainingCollect = exhaustionSpokenCollectFollowup({ - evidence: dossier.evidence, - declinedTopics: dossier.conversationSummary.declinedSkippedTopics, - answeredProbes: catalog.answeredProbes, - eventProbes: catalog.eventProbes, - }); - const plan = buildMethodFollowupPlan({ - evidence: dossier.evidence, - activeFocus: null, - declinedTopics: dossier.conversationSummary.declinedSkippedTopics, - closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, - sessionOutcome: decision.sessionOutcome, - ...catalog, - birthDate, - accepted: Boolean(dossier.case.acceptedTime), - ...followupCaseArgs({ - stage: dossier.case.stage, - blockScan: dossier.case.blockScan, - reportedBirthTime: dossier.case.reportedBirthTime, - candidateRange: dossier.case.candidateRange, - }), - }); - const exhausted = isExhaustedGateState({ - remainingCollect, - methods: plan.methods, - canAdopt: decision.canAdopt, - accepted: Boolean(dossier.case.acceptedTime), - }); const satisfied = Boolean( projectCurrentQuestion(dossier.conversationSummary.activeFocus) || dossier.case.acceptedTime || dossier.case.confirmedTime || decision.completionStatus === "provisional_range_user_stopped" || publicCanAdopt(decision) - || exhausted + || terminalNoteHostPresent({ caseId: input.caseId, dossier }) ); return { dossier, decision, satisfied }; } @@ -1775,11 +1812,38 @@ export async function ensureNonTerminalTurnExit(input: { decision: before.decision, decisionReceipt: before.dossier.latestResult?.decisionReceipt, }); + if (repaired.persisted && repaired.focus) { + const afterFocus = await inspectNonTerminalTurnExit(input); + if (!afterFocus.satisfied && !repaired.hostNarration) { + throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing"); + } + return repaired; + } + const hostNarration = (repaired.hostNarration ?? "").trim() + || RECTIFICATION_USER_COPY.noCandidatesGate; + try { + await persistExhaustionGateTurn({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + hostNarration, + resultId: before.dossier.latestResult?.resultId ?? null, + }); + } catch (error) { + console.warn( + `[rectification-v9] persist exhaustion gate during exit repair failed case=${input.caseId} reason=${safeToolErrorCode(error)}`, + ); + } const after = await inspectNonTerminalTurnExit(input); - if (!after.satisfied && !repaired.terminalNote) { + if (!after.satisfied && !hostNarration) { throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing"); } - return repaired; + return { + ...repaired, + persisted: true, + hostNarration, + terminalNote: true, + }; } function optionQuoteFromSchema(schema: Readonly>, optionId: ChoiceKey): string | null { diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 1596f2e6..2d998588 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -1778,6 +1778,15 @@ function holdoutAskFields( }; } +function declinedForHoldout(declined: ReadonlySet): Set { + const next = new Set(declined); + if (declinedHealth(declined)) { + next.add("health"); + next.add("health_pressure"); + } + return next; +} + export function holdoutFollowupFor( input: { evidence: readonly MethodFollowupEvidence[]; @@ -1788,21 +1797,13 @@ export function holdoutFollowupFor( ): Omit | null { if (!meetsAcceptanceEventQuality(input.evidence)) return 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; + const blocked = declinedForHoldout(declined); + const prompt = (input.oosBlindPrompts ?? []).find((item) => ( + Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain) + )) ?? null; + const reserved = (input.holdoutEvents ?? []).find((item) => ( + item.year != null && !occupied.has(item.domain) && !blocked.has(item.domain) + )) ?? null; return holdoutAskFields(prompt, reserved); } diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 6a040d12..82e1f904 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -29,7 +29,9 @@ export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000; export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…"; export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。"; -export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载"; +export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问"; +export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。"; +export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2; export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。"; export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问"; export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。"; @@ -291,7 +293,7 @@ export type RectificationQuestionGapInput = Readonly<{ * The gap between a settled turn and its next question. While retries remain * it is `preparing` (one live row, timed refetches); once they run out, or the * server itself reports the question unavailable, it is `unavailable` with a - * manual reload. A snapshot that never arrived (hydration timed out) is a gap + * repair button. A snapshot that never arrived (hydration timed out) is a gap * too: the same retries fill it. Questions themselves live inside assistant * messages, so a visible live question means there is no gap. */ diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index 2678227d..f7ef14e0 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -896,3 +896,24 @@ test("holdout waits until dated collect and occupation are asked", () => { assert.equal(plan.next_followup?.domain, "relocation"); assert.notEqual(plan.next_followup?.domain, "health_pressure"); }); + +test("skipped ledger health blocks health_pressure holdout", () => { + const evidence: MethodFollowupEvidence[] = [ + { id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" }, + { id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "career_entry" }, + { id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" }, + { id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" }, + { id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2017-04-01", occurredTo: null, eventKind: "income_change" }, + { id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" }, + { id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" }, + ]; + const oos = [{ + domain: "health_pressure", + user_meaning: "身体这条线还没用过。", + used_for_scoring: false as const, + }]; + assert.equal(holdoutFollowupFor({ + evidence, + oosBlindPrompts: oos, + }, new Set(["family", "health"])), null); +}); diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index e7869f29..9d7e19e2 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -16,6 +16,7 @@ import { ensureNonTerminalTurnExit, persistNextInterviewIfIdle, } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts"; import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts"; @@ -463,10 +464,17 @@ function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType }); } -function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType; allowFocus?: boolean } = {}) { +function idleHandlers(decision: DecisionDossier, extra: { + activeFocus?: ReturnType; + allowFocus?: boolean; + turns?: unknown[]; +} = {}) { return fakeAccounting({ ...receiptHandlers, - get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra), + get_agentic_rectification_case_dossier: () => { + const row = rpcDossier(decision, extra); + return extra.turns ? { ...row, turns: extra.turns } : row; + }, get_agentic_rectification_case_compute: () => computeFixture(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), apply_agentic_rectification_choice_action: (_fn, args) => ({ @@ -869,3 +877,48 @@ test("closed ceiling with holdout still open persists holdout not the gate", asy assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE); assert.match(persisted.hostNarration ?? "", /入职|换工作|工作/); }); + +test("ensureNonTerminalTurnExit writes a host turn when exhausted with no carrier", async () => { + resetDeliveryTurnGuardForTests(); + const dossier = accidentDossier({ + acceptanceAllowed: false, + acceptanceReasons: ["insufficient_events"], + holdoutUnavailable: true, + }); + const accounting = idleHandlers(dossier); + const repaired = await ensureNonTerminalTurnExit({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.ok(repaired.hostNarration); + assert.equal(repaired.terminalNote, true); + assert.equal(gateAppendCalls(accounting.calls).length, 1); +}); + +test("ensureNonTerminalTurnExit does not rewrite when a terminal-note host turn exists", async () => { + resetDeliveryTurnGuardForTests(); + const dossier = accidentDossier({ + acceptanceAllowed: false, + acceptanceReasons: ["insufficient_events"], + holdoutUnavailable: true, + }); + const accounting = idleHandlers(dossier, { + turns: [ + { + id: "99999999-9999-4999-8999-999999999999", + role: "assistant", + text: RECTIFICATION_USER_COPY.noCandidatesGate, + status: "completed", + created_at: "2026-09-09T00:00:00.000Z", + }, + ], + }); + const repaired = await ensureNonTerminalTurnExit({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(repaired.hostNarration, null); + assert.equal(gateAppendCalls(accounting.calls).length, 0); +}); diff --git a/frontend/tests/rectification-skipped-health-deadend-20260909.test.ts b/frontend/tests/rectification-skipped-health-deadend-20260909.test.ts new file mode 100644 index 00000000..34bb276a --- /dev/null +++ b/frontend/tests/rectification-skipped-health-deadend-20260909.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + CASE_ID, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; +import { + RECTIFICATION_QUESTION_RELOAD_LABEL, + RECTIFICATION_QUESTION_REPAIR_FAILED_COPY, +} from "../src/lib/rectification-surface-state.ts"; +import { + holdoutFollowupFor, + type MethodFollowupEvidence, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; + +const SEVEN_WITHOUT_HEALTH: MethodFollowupEvidence[] = [ + { id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" }, + { id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-04-01", occurredTo: null, eventKind: "career_entry" }, + { id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" }, + { id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" }, + { id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "income_change" }, + { id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" }, + { id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" }, +]; + +function rpcEvidence(item: MethodFollowupEvidence & { id: string }) { + return { + id: item.id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind ?? item.domain, + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: item.domain, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-09T00:00:00.000Z", + }; +} + +function skippedHealthDossier() { + const evidence = SEVEN_WITHOUT_HEALTH.map((item) => rpcEvidence({ ...item, id: item.id ?? `e-${item.domain}` })); + return dossierFixture({ + evidence, + evidenceCount: evidence.length, + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + representativeTime: "14:45", + evidenceLedgerFingerprint: evidenceLedgerFingerprint(SEVEN_WITHOUT_HEALTH as never), + decisionReceipt: { + oos_blind_prompts: [{ + domain: "health_pressure", + user_meaning: "身体这条线还没用过。", + used_for_scoring: false, + }], + }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: null, + declined_skipped_topics: [ + { target_domain: "family", status: "declined", intent: "collect_method_evidence" }, + { + target_domain: "health", + status: "skipped", + intent: "collect_method_evidence", + question_id: "collect:health_pressure:collect_method_evidence", + }, + ], + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-09T00:00:00.000Z", + }, + }); +} + +function warnLines(run: () => Promise) { + const lines: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + lines.push(args.map((item) => String(item)).join(" ")); + }; + return Promise.resolve() + .then(run) + .then((result) => ({ result, lines })) + .finally(() => { + console.warn = original; + }); +} + +test("skipped health ledger domain keeps holdoutFollowupFor null", () => { + assert.equal(holdoutFollowupFor({ + evidence: SEVEN_WITHOUT_HEALTH, + oosBlindPrompts: [{ + domain: "health_pressure", + user_meaning: "身体这条线还没用过。", + used_for_scoring: false, + }], + }, new Set(["health"])), null); +}); + +test("persistNextInterviewIfIdle still narrates after duplicate_focus", async () => { + const raw = skippedHealthDossier(); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + get_agentic_rectification_case_compute: () => computeFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + set_agentic_rectification_conversation_focus: () => { + throw new Error("focus_idempotency_conflict"); + }, + }); + const { result, lines } = await warnLines(() => persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + })); + const idle = result as Awaited>; + assert.ok((idle.hostNarration ?? "").trim()); + assert.notEqual(idle.hostNarration, ""); + assert.ok( + idle.terminalNote === true || Boolean(idle.hostNarration), + "duplicate_focus must still leave a visible carrier", + ); + assert.ok(lines.some((line) => line.includes("rectification_focus_duplicate") || line.includes("rectification_exhaustion_collect"))); +}); + +test("unavailable gap button posts repair-exit and says 接着问", () => { + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + const surface = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8"); + const repair = readFileSync( + new URL("../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts", import.meta.url), + "utf8", + ); + const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); + assert.equal(RECTIFICATION_QUESTION_RELOAD_LABEL, "接着问"); + assert.match(surface, /RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问"/); + assert.match(surface, /暂时接不上,请新建一次校正/); + assert.equal(RECTIFICATION_QUESTION_REPAIR_FAILED_COPY, "暂时接不上,请新建一次校正。"); + assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/); + assert.match(chat, /RECTIFICATION_QUESTION_REPAIR_FAILED_COPY/); + assert.doesNotMatch(chat, /function reloadQuestion/); + assert.match(repair, /ensureNonTerminalTurnExit/); + assert.match(repair, /POST \/api\/rectification\/cases\/\[caseId\]\/repair-exit/); + const catchBlock = agentRun.slice(agentRun.indexOf("persist interview before collect attach failed")); + assert.match(catchBlock.slice(0, 500), /ensureNonTerminalTurnExit/); +});