fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,12 @@
|
||||
填完生辰和地址后,从首页点「家庭」等主题,会先按该主题排盘再写回答,不再出现「Agent 未完成必要的方法与计算步骤,本次不会扣点」。点卡不会改你看到的那句问题。自己在输入框里问的普通问题仍可一次看多个领域。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-09 — 健康题记不清后不再断在「没有下一个问题」
|
||||
|
||||
生时校正里健康题打字「记不清」后,不会再把同一条健康线当成没用过的盘外核对再问一遍。撞上已有焦点时也不会再静默断流。穷尽如果没有下一问、卡或门槛句,服务端会补一句可见出口。缺口按钮改成「接着问」,向服务端修复;连续两次仍接不上才提示新建一次校正。Skill 版本不变。
|
||||
|
||||
|
||||
|
||||
## 2026-09-09 — 生时校正拿掉「没有 / 记不清」按钮
|
||||
|
||||
口述采集不再出示「没有」「记不清」快捷按钮。想跳过仍可在输入框打这两句,语义不变。选择题卡选项不动。Skill 版本不变。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 版本、未改采用/确认门、未新增迁移。
|
||||
@@ -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) |
|
||||
|
||||
@@ -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. 健康题点「记不清」后职业答完必须出卡或门槛句
|
||||
|
||||
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。虚构经历。
|
||||
|
||||
采集走到健康题时打字「记不清」,再答完职业题。
|
||||
|
||||
期望:
|
||||
|
||||
- 职业答完后必须出现下一张卡、下一问或门槛句
|
||||
- 不得出现「没有拿到下一个问题。」
|
||||
- 缺口按钮若出现,文案是「接着问」,点了会向服务端修复,不是只重取快照
|
||||
- 连续两次仍无载体才显示「暂时接不上,请新建一次校正。」
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
function dossierResponse(
|
||||
export function dossierResponse(
|
||||
dossier: V9CaseDossier,
|
||||
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
|
||||
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
|
||||
|
||||
@@ -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<string | null>(() => 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<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||||
@@ -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" && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="outline" onClick={reloadQuestion}>
|
||||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||||
</Button>
|
||||
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="outline" onClick={() => void repairQuestion()} disabled={questionRepairing}>
|
||||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
|
||||
|
||||
@@ -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<V9Agen
|
||||
console.warn(
|
||||
`[rectification-v9] persist interview before collect attach failed case=${caseId} reason=${safeErrorCode(error)}`,
|
||||
);
|
||||
try {
|
||||
interviewIdle = await ensureNonTerminalTurnExit({ accounting, userId, caseId });
|
||||
} catch (repairError) {
|
||||
console.warn(
|
||||
`[rectification-v9] nonterminal exit after idle failure failed case=${caseId} reason=${safeErrorCode(repairError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action === "evidence") {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
withNakshatraBoundaryProbe,
|
||||
} from "./inference-adapter";
|
||||
import { datedEventCount } from "./divergence-panel.ts";
|
||||
import { logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
|
||||
import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
|
||||
import {
|
||||
decideAfterInferenceChange,
|
||||
decideFromDossier,
|
||||
@@ -114,6 +114,47 @@ export function isExhaustedGateState(input: {
|
||||
&& !input.accepted;
|
||||
}
|
||||
|
||||
export function looksLikeTerminalNoteNarration(text: string | null | undefined): boolean {
|
||||
const trimmed = text?.trim() ?? "";
|
||||
if (!trimmed) return false;
|
||||
return trimmed.includes(RECTIFICATION_USER_COPY.noCandidatesGate)
|
||||
|| trimmed.includes("这只是代表性候选")
|
||||
|| /还差(?: \d+ 件)?带月份的经历/.test(trimmed)
|
||||
|| trimmed.includes("两件事的日期还没对清")
|
||||
|| trimmed.includes("这次给出的范围");
|
||||
}
|
||||
|
||||
function terminalNoteHostPresent(input: {
|
||||
caseId: string;
|
||||
dossier: {
|
||||
turns?: readonly Readonly<{ role: string; text: string | null }>[];
|
||||
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<Record<string, unknown>>, optionId: ChoiceKey): string | null {
|
||||
|
||||
@@ -1778,6 +1778,15 @@ function holdoutAskFields(
|
||||
};
|
||||
}
|
||||
|
||||
function declinedForHoldout(declined: ReadonlySet<string>): Set<string> {
|
||||
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<MethodFollowup, "must_not_label" | "choice_frame"> | 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<typeof activeFocusFixture>; allowFocus?: boolean } = {}) {
|
||||
function idleHandlers(decision: DecisionDossier, extra: {
|
||||
activeFocus?: ReturnType<typeof activeFocusFixture>;
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<unknown>) {
|
||||
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<ReturnType<typeof persistNextInterviewIfIdle>>;
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user