diff --git a/CHANGELOG.md b/CHANGELOG.md index f2650e5d..4f46d833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-04 — 采集阶段第三件事必须指向领域,盘外核对不得抢跑 + +可评分事件还不到 3 条时,不再进入盘外核对,也不再先问没有日期的职业或「随便哪件事」。下一问按家人 → 学业 → 财务 → 搬家 → 健康 → 事业 → 感情补带年份的事;采集题干必须写出领域。进度句只能用服务端给出的还差几件,没有数字就不说。Skill 版本仍是 10.0.14;采用门与确认门未改。 + ## 2026-09-04 — 产品界面收束到三档字重、两档圆角和共用按钮 聊天正文改为 16px;产品界面字重只保留 400 / 500 / 600。控件圆角 8px、卡片 12px。会员购买、兑换和支付重试改走共用 `Button`。生时校正候选卡间距落到 4px 网格。登录与引导页仍用原来的按钮类名。Skill 版本未变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index eb1f8555..d484049b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8131,3 +8131,68 @@ - 复发自:无 - 修复版本:待修复 - 编号说明:rebase 到 `origin/staging` 时 BUG-525 已被采集拒答占用,本条落在 BUG-526。 + +## BUG-527 | 可评分事件不足 3 条时盘外核对抢跑到已拒答领域 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:`buildMethodFollowupPlan`、`holdoutFollowupFor`、`oos_blind` / holdout 提问 +- 用户现象:账本只有 2 条可评分带日期事件时,家人采集刚答「没有」,下一问却变成盘外核对,焦点落在刚拒答的 family。 +- 触发条件:`holdoutValidation === "not_started"` 或 `sessionOutcome === "validate_holdout"`,引擎给了 `oos_blind_prompts`,训练门 `event_quality.passed=false`。 +- 根因:计划层的两处 OOS 分支只看 holdout 状态和引擎提示,不过 `meetsAcceptanceEventQuality`,也不跳过 `declinedDomains`(BUG-520 口径)。 +- 修复:新增 `holdoutFollowupFor`:训练门未开返回 `null`;否则取第一个未拒答的 OOS 提示,没有则退到带年份 holdout 事件,再没有为 `null`。两处 OOS 分支都走它。不改 `holdoutValidationStatus` / `decideRectification` 的采用与确认门。 +- 验证:`frontend/tests/rectification-collect-direction-20260904.test.ts`(2 条事件不出 OOS;4 条事件跳过 family 后问 education holdout;全部拒答且无 holdout 事件时 `validate_holdout` 的 `next_followup` 为 null)。 +- 防复发:OOS 提问必须同时满足训练门与未拒答领域。不得只凭引擎给了 `oos_blind_prompts` 就提问。 +- 相关记录:BUG-396、BUG-520 +- 复发自:无 +- 修复版本:待发布 +- 编号说明:任务书写 BUG-524;rebase 后最大号已是 BUG-526(报告 worker),本条从 BUG-527 起。 + +## BUG-528 | 缺第三件带年份的事时先问职业或泛问 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:`buildMethodFollowupPlan`、`nextDatedCollectFollowup`、`exhaustionSpokenCollectFollowup`、`persistNextInterviewAfterChoice` +- 用户现象:2 条可评分事件、家人拒答后,决定路径问「你平时主要做什么工作?」;职业没有日期,答完也不推进训练门。 +- 触发条件:`!meetsAcceptanceEventQuality`,方法覆盖轮转里感情/事业已有记录、家人已拒答,剩下职业。 +- 根因:带年份的学业/财务/搬家/健康只存在于穷尽路径;主流程在方法轮转之后才落到 `domain: null` 泛问。 +- 修复:`DATED_COLLECT_ORDER`(家人 → 学业 → 财务 → 搬家 → 健康 → 事业 → 感情)由主流程与 `exhaustionSpokenCollectFollowup` 共用。训练门未开时,在方法轮转之前取下一件带年份采集题;职业与 `other` 泛问排在最后。`datedCollectFollowup` 的 `source` 为 `method_coverage`(采集,不是盘外核对)。 +- 验证:新文件锁 education → finance → occupation → other;拒答路径落下 `USER_COLLECT_QUESTION.education`。`rectification-eight-method` 中训练门未开的下一问从感情改为家人(或财务),每条改断言附原值/新值/原因。4 条可评分事件时本分支不触发(`rectification-collect-stall`、`rectification-yearless-ungrounded` 的 OOS 结论不变)。 +- 防复发:训练门未开时不得先问职业或无领域泛问。顺序只许有一处定义。 +- 相关记录:BUG-426、BUG-442、BUG-472、BUG-527 +- 复发自:无 +- 修复版本:待发布 + +## BUG-529 | 采集题干可以不指向任何领域 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:`validateSpokenPrompt`、`rectification-set-focus`、`COLLECT_DOMAIN_KEYWORDS` +- 用户现象:Agent 把家人盘外核对改写成「除了工作这条线,还有哪件事你能记起大概的年份?」,用户不知道该往哪想。 +- 触发条件:`intent=collect_method_evidence`、无 `choice_frame`、领域在关键词表内;题干不含该领域日常词。 +- 根因:校验器只查长度、选项字面、内部 token、`domain_mismatch`,不要求题干提到领域。 +- 修复:采集题必须命中目标领域至少一个关键词,否则 `domain_missing`。第二次仍不合格时服务端用 `USER_COLLECT_QUESTION[domain]` 落焦点。工具描述要求写出领域。区分题与 OOS 题不套这层关键词。 +- 验证:`rectification-spoken-prompt`(education 泛问 → `domain_missing`;含「上学」→ ok;OOS 不受影响);新文件两次泛问后焦点 prompt 为 `USER_COLLECT_QUESTION.education`。 +- 防复发:采集题方向由服务端定。不得把关键词校验套到带 `choice_frame` 的区分题或 OOS 题上。 +- 相关记录:BUG-527、BUG-528 +- 复发自:无 +- 修复版本:待发布 + +## BUG-530 | 采集进度句没有数字 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:读盘投影 `collection_progress`、`agentic-rectification.ts` 提示词、`MACHINE_VOICE_LEXICON` +- 用户现象:助手写「当前范围还在继续收窄中,接下来我们继续。」没有任何数值。 +- 触发条件:下一问是采集题;Agent 在没有 `collection_progress` 或不用该字段时自行写进度。 +- 根因:读盘投影没有给出可评分件数/下限/还差几件;提示词允许空进度句;词表不拦「继续收窄」。 +- 修复:`collectionProgressFromReceipt` 从 `gates.event_quality` 取 `{ scoreable, minimum, missing }`,缺字段为 `null`。读盘与 GET interview 都带上。提示词要求数字只来自该字段,没有就不说进度。词表加入「继续收窄」「范围还在」,只对 `spokenPrompt` 校验生效(旁白无同一套校验)。 +- 验证:投影锁 2/3/1 与缺 gate 时 `null`;提示词测试含 `collection_progress` 与「不得写『范围在收窄』」。 +- 防复发:没有 `collection_progress` 不得写没有数字的收窄句。数字不得自算。 +- 相关记录:BUG-527 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-collect-direction-20260904.md b/docs/tasks/PROGRESS-rectification-collect-direction-20260904.md new file mode 100644 index 00000000..55d12728 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-collect-direction-20260904.md @@ -0,0 +1,105 @@ +# PROGRESS · 采集阶段第三件事没有方向(2026-09-04) + +工作树:`.worktrees/rectification-collect-direction-20260904` +分支:`codex/rectification-collect-direction-20260904` +基线:任务书写 `origin/staging` `45bdb63e`;开工时工作树落后 3 个提交,已 rebase。交付前再 `git fetch`,快进合入 `7ee7f825`(共用 Button)与 `5c5f25a3`(报告 worker BUG-526 记录)。当前与 `origin/staging` 对齐,改动未提交。 +任务书:`docs/tasks/TASK-rectification-collect-direction-20260904.md` +未改 Python、未 bump Skill(仍 10.0.14)、未改 `decideRectification` 采用/确认门。未 push。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 5.1 盘外核对加训练门并跳过拒答领域 | 完成 | BUG-527(任务书原写 524) | +| 5.2 带年份采集顺序进入计划主流程 | 完成 | BUG-528(任务书原写 525) | +| 5.3 采集题干必须指向领域 | 完成 | BUG-529(任务书原写 526) | +| 5.4 进度句有数字才说 | 完成 | BUG-530(任务书原写 527) | + +## 编号 + +rebase 后 `docs/BUG_HISTORY.md` 最大号已是 BUG-526(accepted 生时报告 worker 读校正表)。本单实际落点 **BUG-527~530**。 + +## 实现要点 + +- `holdoutFollowupFor`:`meetsAcceptanceEventQuality` 未开 → `null`;否则跳过 `declinedDomains`,先 OOS 提示再带年份 holdout 事件。`validate_holdout` 早退与 `holdoutValidation === "not_started"` 共用。 +- `DATED_COLLECT_ORDER`:家人 → 学业 → 财务 → 搬家 → 健康 → 事业 → 感情。主流程在方法轮转之前、训练门未开时走 `nextDatedCollectFollowup`;`exhaustionSpokenCollectFollowup` 同一函数。职业与 `other` 排最后。 +- `datedCollectFollowup.source = "method_coverage"`(采集,不是盘外核对),这样 `isOfferBlockingFollowup` 对 relatives/D9/D10 仍挡出牌。 +- `COLLECT_DOMAIN_KEYWORDS` + `validateSpokenPrompt`:`collect_method_evidence` 且无 `choice_frame` 时题干必须命中领域词,否则 `domain_missing`。第二次失败用 `USER_COLLECT_QUESTION[domain]`。区分题 / OOS 不套这层。 +- `collectionProgressFromReceipt`:`{ scoreable, minimum, missing }` 或 `null`。读盘 `projectTurnDecision` 与 GET `interview` 都带。提示词禁止无数字的「范围在收窄」。词表加「继续收窄」「范围还在」,只拦 `spokenPrompt`。 + +## 偏离与断言改写 + +`rectification-yearless-ungrounded` 的 `validate_holdout` / `oos_blind` **未改断言**:该 fixture 已有 4 条带日期证据,`meetsAcceptanceEventQuality` 为 true,OOS 结论仍成立。 + +训练门未开时下一问从方法轮转(感情/事业)改为 datedCollectOrder。凡改断言均在测试里写了原值/新值/原因。摘要: + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `rectification-eight-method` 一件学业 | `d9_relationship` | `relatives` / family | 训练门未开先补家人 | +| 同上,学业+家人+事业读盘 | `d9_relationship` | `d2_finance` | 1 条 holdout,训练门未开,家人/学业已有记录 | +| 同上,3 条+1 holdout、家人拒答 | 方法轮转 collect | `finance` collect | 同上,补财务而不是区分 | +| `rectification-range-offer-deadend` 穷尽 | occupation | relocation | 顺序合并后搬家在职业前 | +| `rectification-agentic-entry` 源码锁 | `interview: fields` | `{ ...fields, collection_progress }` | GET 带进度 | +| `rectification-question-ownership` / v10 set-focus | 「哪年上的大学」 | `USER_COLLECT_QUESTION[domain]` | 默认 dossier 一件事业 → 家人,大学不命中 | +| `rectification-walkthrough-polish` retry 计划 | relationship retry | family | 已关闭感情不挡家人采集 | + +## 测试 + +开工聚焦套件(rebase 后、本单改动前未单独存档数字;交付时本套件全绿): + +`tsx --test` eight-method / yearless-ungrounded / server-focus / collect-stall / adopt-narration-20260904 / answer-choice / collect-direction-20260904 / spoken-prompt / v9-agent / range-offer-deadend: + +``` +# tests 183 +# pass 183 +# fail 0 +# cancelled 0 +# skipped 0 +# todo 0 +exit=0 +``` + +新文件 `rectification-collect-direction-20260904.test.ts`:8/8。 + +`tsc --noEmit`:本单文件 0 错。全量仍 2 个既有错误(`app-sidebar.tsx` ref、`calendar.tsx` Button `onSubmit`),与 `7ee7f825` 共用按钮合入后仍在,未触碰。exit=2。 + +校正相关 glob(含 v9-database Docker): + +``` +# tests 892 +# pass 891 +# fail 1 +# cancelled 0 +# skipped 0 +# todo 0 +exit=1 +``` + +唯一失败 `v9 legacy backfill…`:并发 Docker 争用。该文件隔离重跑 **7/7**,exit=0。 + +全量 `tests/*.test.ts`(本机有 Docker;第一次跑在产品断言修完之前): + +``` +# tests 2611 +# pass 2591 +# fail 20 +# cancelled 0 +# skipped 0 +# todo 0 +exit=1 +``` + +20 条拆开: + +- **5 条本单产品失败**(已修,隔离复跑全绿):`interview: fields`、legacy quality 后 `d9_relationship`、set-focus 大学题干、v10 set-focus、walkthrough relationship retry。 +- **12 条 Docker/Postgres 争用**(`database system is shutting down` / `database migration failed`),与 BUG-523 基线同类;v9-database 隔离 7/7。 +- **3 条 `@base-ui/react/button/index.mjs` MODULE_NOT_FOUND**:`personal-report-entry`、`personal-report-view`、`report-polling-contract`。本单未改这些文件;`node_modules/@base-ui/react/button/` 实际只有 `Button.mjs` / `index.js`。 + +未改 Python;未声称系统 `python3` 预检通过。 + +## 改动文件 + +- 计划:`method-followup.ts` +- 校验/题干:`spoken-prompt.ts`、`user-copy.ts`、`rectification-v9-tools.ts` +- 进度:`evidence-model.ts`、`turn-decision.ts`、`route.ts`、`agentic-rectification.ts`、`agent-voice-lexicon.ts` +- 新增测试:`frontend/tests/rectification-collect-direction-20260904.test.ts` +- 回归:eight-method、range-offer-deadend、spoken-prompt、v9-agent、agentic-entry、choice-card、question-ownership、v10-conversation-focus、walkthrough-polish +- 文档:`docs/BUG_HISTORY.md`、`docs/tasks/README.md`、`CHANGELOG.md`、本文件 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index dc4fc45f..9388f13e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -47,7 +47,7 @@ | `TASK-rectification-adopt-narration-20260904.md` | `PROGRESS-rectification-adopt-narration-20260904.md` | 探针池耗尽时采用卡旁白改 Agent 生成 + 已丢弃探针绕过 BUG-472 早退 + 区分题答否关线 | 已验收(4.1.4 点选入口未通过,见修复单) | `0aaa0d70`(BUG-519/520) | | `TASK-rectification-adopt-narration-fix-20260904.md` | `PROGRESS-rectification-adopt-narration-fix-20260904.md` | 采用旁白 Agent 在点选入口从不运行(早退分支重算过期决策)+ 校验器/可观测/超时 | 已验收(5.1/5.2 实现通过;超时测试挂死事件循环,见修复单 2) | `13dded9f`(BUG-521/522) | | `TASK-rectification-adopt-narration-fix2-20260904.md` | `PROGRESS-rectification-adopt-narration-fix2-20260904.md` | 采用旁白超时用了 unref 的 `AbortSignal.timeout`,测试挂死取消同文件后三条用例,门禁 `npm test` 退出码 1 | 已验收(门禁通过,staging 已部署) | `45bdb63e`(BUG-523) | -| `TASK-rectification-collect-direction-20260904.md` | `PROGRESS-rectification-collect-direction-20260904.md` | 可评分事件 2 条时盘外核对抢跑到刚拒答的家人领域,Agent 只能改写成不指向任何领域的泛问;缺第三件带年份的事却先问职业 | 待执行 | — | +| `TASK-rectification-collect-direction-20260904.md` | `PROGRESS-rectification-collect-direction-20260904.md` | 可评分事件 2 条时盘外核对抢跑到刚拒答的家人领域,Agent 只能改写成不指向任何领域的泛问;缺第三件带年份的事却先问职业 | 待验收 | `codex/rectification-collect-direction-20260904`(BUG-527~530) | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | ### 聊天主链路与首页 diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index 1f0c6aa0..fa295d94 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -19,6 +19,7 @@ import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-deci import { attachQuestionsToTurns, attachOfferResultToTurns } from "@/lib/rectification-agentic/v9/turn-question"; import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision"; +import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/evidence-model"; import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection"; export const runtime = "nodejs"; @@ -140,7 +141,10 @@ export function dossierResponse( latest_result: dossier.latestResult ? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true) : null, - interview: fields, + interview: { + ...fields, + collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null), + }, current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus), choice_card: choiceCardFromCaseDossier(dossier), question_source: questionSourceFromFocusList(listed), diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 16f6cc47..938afad7 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -27,6 +27,18 @@ export type CollectDomain = export const GENERIC_COLLECT_QUESTION = "从你最容易想起来的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家,记得大概年份就行。"; +/** Everyday words a collect spoken prompt must mention for its server domain. */ +export const COLLECT_DOMAIN_KEYWORDS: Readonly> = { + education: ["上学", "大学", "毕业", "考试", "学业"], + finance: ["收入", "买房", "贷款", "欠债", "钱"], + relocation: ["搬家", "住", "外地"], + health_pressure: ["生病", "受伤", "住院", "压力"], + family: ["家里", "父母", "家人", "添丁"], + career: ["工作", "入职", "换工作"], + relationship: ["交往", "分手", "结婚", "感情"], + occupation: ["工作", "做什么", "职业"], +}; + export const USER_COLLECT_QUESTION: Readonly> = { relationship: "感情这边,还记得哪年认真在一起、分开,或结婚吗?有年份就很好。", career: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?", diff --git a/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts b/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts index 1051df65..610fb8b9 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts @@ -14,4 +14,6 @@ export const MACHINE_VOICE_LEXICON = [ "我没能确定这句话是否在回答当前问题", "请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病", "有没有记得住时间的收入变化、大笔支出或欠债", + "继续收窄", + "范围还在", ] as const; diff --git a/frontend/src/lib/rectification-agentic/v9/evidence-model.ts b/frontend/src/lib/rectification-agentic/v9/evidence-model.ts index af7f11b1..7212b6a5 100644 --- a/frontend/src/lib/rectification-agentic/v9/evidence-model.ts +++ b/frontend/src/lib/rectification-agentic/v9/evidence-model.ts @@ -310,6 +310,36 @@ export function meetsAcceptanceEventQuality( return trainingScoreableGate(evidence).open; } +export type CollectionProgress = Readonly<{ + scoreable: number; + minimum: number; + missing: number; +}>; + +export function collectionProgressFromReceipt( + receipt: Readonly> | null | undefined, +): CollectionProgress | null { + const gates = receipt && typeof receipt.gates === "object" && receipt.gates && !Array.isArray(receipt.gates) + ? receipt.gates as Record + : null; + const quality = gates && typeof gates.event_quality === "object" && gates.event_quality && !Array.isArray(gates.event_quality) + ? gates.event_quality as Record + : null; + if (!quality) return null; + const scoreable = typeof quality.scoreable_event_count === "number" && Number.isFinite(quality.scoreable_event_count) + ? quality.scoreable_event_count + : null; + const minimum = typeof quality.minimum === "number" && Number.isFinite(quality.minimum) + ? quality.minimum + : null; + if (scoreable == null || minimum == null) return null; + return { + scoreable, + minimum, + missing: Math.max(0, minimum - scoreable), + }; +} + /** Ledger/engine subject follows the domain. Family events must not stay on the tool default `self`. */ export function evidenceSubjectForDomain( domain: string, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index cd2d3ebb..0972b22d 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -345,7 +345,7 @@ function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolea return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health"); } -function declinedHealth(declined: Set): boolean { +function declinedHealth(declined: ReadonlySet): boolean { return declined.has("health") || declined.has("health_pressure"); } @@ -1001,10 +1001,12 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string | return period ? `${period},${base}` : base; } -const EXHAUSTION_OOS_COLLECT_ORDER = ["family", "education", "finance"] as const; -const EXHAUSTION_REMAINING_COLLECT_ORDER = [ - "health_pressure", +export const DATED_COLLECT_ORDER = [ + "family", + "education", + "finance", "relocation", + "health_pressure", "career", "relationship", ] as const; @@ -1024,6 +1026,48 @@ function datedCollectFollowup( user_prompt_hint: collectHint(lead, REVERSE_VERIFY_VARGA[domain], "", evidence), must_not_label: false, choice_frame: null, + source: "method_coverage", + }; +} + +function datedCollectDomainBlocked( + domain: (typeof DATED_COLLECT_ORDER)[number], + evidence: readonly MethodFollowupEvidence[], + declined: ReadonlySet, +): boolean { + if (domain === "health_pressure") { + return declinedHealth(declined) || hasConfirmedHealth(evidence); + } + return declined.has(domain) || hasConfirmedDomain(evidence, domain); +} + +export function nextDatedCollectFollowup( + evidence: readonly MethodFollowupEvidence[], + declined: ReadonlySet, +): MethodFollowup | null { + for (const domain of DATED_COLLECT_ORDER) { + if (datedCollectDomainBlocked(domain, evidence, declined)) continue; + const next = datedCollectFollowup(domain, evidence); + if (next) return next; + } + return null; +} + +function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): MethodFollowup { + return { + method_id: "dasha_events", + intent: "collect_method_evidence", + ask_theme: "dated_event", + domain: "other", + kind_hint: null, + user_prompt_hint: collectHint( + USER_COLLECT_QUESTION.other, + "本命 Dasha + 行运(方法1)", + "", + evidence, + ), + must_not_label: false, + choice_frame: null, source: "oos_blind", }; } @@ -1033,11 +1077,8 @@ export function exhaustionSpokenCollectFollowup(input: { declinedTopics?: readonly Readonly>[]; }): MethodFollowup | null { const declined = declinedDomains(input.declinedTopics ?? []); - for (const domain of EXHAUSTION_OOS_COLLECT_ORDER) { - if (declined.has(domain) || hasConfirmedDomain(input.evidence, domain)) continue; - const next = datedCollectFollowup(domain, input.evidence); - if (next) return next; - } + const dated = nextDatedCollectFollowup(input.evidence, declined); + if (dated) return dated; if ( !declined.has("occupation") && !hasConfirmedDomain(input.evidence, "occupation") @@ -1055,31 +1096,7 @@ export function exhaustionSpokenCollectFollowup(input: { source: "method_coverage", }; } - for (const domain of EXHAUSTION_REMAINING_COLLECT_ORDER) { - if (domain === "health_pressure") { - if (declinedHealth(declined) || hasConfirmedHealth(input.evidence)) continue; - } else if (declined.has(domain) || hasConfirmedDomain(input.evidence, domain)) { - continue; - } - const next = datedCollectFollowup(domain, input.evidence); - if (next) return next; - } - return { - method_id: "dasha_events", - intent: "collect_method_evidence", - ask_theme: "dated_event", - domain: "other", - kind_hint: null, - user_prompt_hint: collectHint( - USER_COLLECT_QUESTION.other, - "本命 Dasha + 行运(方法1)", - "", - input.evidence, - ), - must_not_label: false, - choice_frame: null, - source: "oos_blind", - }; + return otherCollectFollowup(input.evidence); } export const OPENING_COLLECT_DOMAIN = "other"; @@ -1410,6 +1427,22 @@ function holdoutAskFields( }; } +export function holdoutFollowupFor( + input: { + evidence: readonly MethodFollowupEvidence[]; + oosBlindPrompts?: readonly OosBlindPrompt[]; + holdoutEvents?: readonly Readonly<{ domain: string; year: number | null }>[]; + }, + declined: ReadonlySet, +): Omit | null { + if (!meetsAcceptanceEventQuality(input.evidence)) return null; + const prompt = (input.oosBlindPrompts ?? []).find((item) => item.domain && !declined.has(item.domain)) ?? null; + const reserved = (input.holdoutEvents ?? []).find((item) => ( + item.year != null && !declined.has(item.domain) + )) ?? null; + return holdoutAskFields(prompt, reserved); +} + export function buildMethodFollowupPlan(input: { evidence: readonly MethodFollowupEvidence[]; activeFocus?: MethodFollowupFocus | null; @@ -1673,10 +1706,7 @@ export function buildMethodFollowupPlan(input: { } if (sessionOutcome === "validate_holdout") { - const fields = holdoutAskFields( - input.oosBlindPrompts?.[0], - (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null, - ); + const fields = holdoutFollowupFor(input, declined); return { methods, next_followup: fields ? makeFollowup(fields, false) : null, @@ -1795,10 +1825,7 @@ export function buildMethodFollowupPlan(input: { } } if (!next && input.holdoutValidation === "not_started") { - const fields = holdoutAskFields( - input.oosBlindPrompts?.[0], - (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null, - ); + const fields = holdoutFollowupFor(input, declined); if (fields) next = makeFollowup(fields, false); } if (!next && !dashaCovered) { @@ -1816,6 +1843,10 @@ export function buildMethodFollowupPlan(input: { source: "method_coverage", }); } + if (!next && !meetsAcceptanceEventQuality(input.evidence)) { + const dated = nextDatedCollectFollowup(input.evidence, declined); + if (dated) next = makeFollowup(dated); + } if (!next && dashaCovered && coverageComplete && occupationCovered) { const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated; const yearless = yearlessDiscriminators[0]; @@ -1965,19 +1996,7 @@ export function buildMethodFollowupPlan(input: { && !hasConfirmedDomain(input.evidence, "relocation") && !declined.has("relocation")) ) { - next = makeFollowup({ - method_id: "dasha_events", - intent: "collect_method_evidence", - ask_theme: "dated_event", - domain: null, - kind_hint: null, - user_prompt_hint: collect( - "再记一件记得大概时间的经历。当前用来区分候选的训练事件还不够。", - "本命 Dasha + 行运(方法1)", - "不要开始点选区分题。", - ), - source: "method_coverage", - }); + next = makeFollowup(otherCollectFollowup(input.evidence)); } else if (stage === "lagna_frame") { next = makeFollowup({ method_id: "dasha_events", diff --git a/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts b/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts index ea63cbe0..933e7b7d 100644 --- a/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts +++ b/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts @@ -1,6 +1,7 @@ import { parseAgentChoiceCopy } from "./choice-card"; import { persistableFocusDomain, stableFollowupQuestionId } from "./server-focus"; import { MACHINE_VOICE_LEXICON } from "./agent-voice-lexicon"; +import { COLLECT_DOMAIN_KEYWORDS } from "../user-copy"; import type { MethodFollowup } from "./method-followup"; export const SPOKEN_PROMPT_MIN = 8; @@ -58,6 +59,17 @@ export function validateSpokenPrompt(input: { if (followupDomain && targetDomain && followupDomain !== targetDomain) { return { ok: false, error: "invalid_spoken_prompt", reason: "domain_mismatch" }; } + if ( + followup.intent === "collect_method_evidence" + && !followup.choice_frame + && followup.domain + && COLLECT_DOMAIN_KEYWORDS[followup.domain] + ) { + const keywords = COLLECT_DOMAIN_KEYWORDS[followup.domain]; + if (!keywords.some((word) => prompt.includes(word))) { + return { ok: false, error: "invalid_spoken_prompt", reason: "domain_missing" }; + } + } if (followup.choice_frame && input.questionId && input.questionId !== stableFollowupQuestionId(followup)) { return { ok: false, error: "invalid_spoken_prompt", reason: "probe_mismatch" }; } diff --git a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts index 6cb1e38c..57bd0e93 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts @@ -8,6 +8,7 @@ import { compactInferenceProjection, previousInferenceFromReceipt } from "./inference-adapter"; import { decideFromDossier } from "./decision-from-dossier"; import { evidenceLedgerFingerprint } from "./tool-service"; +import { collectionProgressFromReceipt } from "./evidence-model"; import type { V9CaseDossier } from "./tool-service"; import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract"; import { RECTIFICATION_SKILL_VERSION } from "./case-status"; @@ -221,6 +222,7 @@ export function projectTurnDecision( ?? null, skill_version: RECTIFICATION_SKILL_VERSION, }, + collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null), }; return enforceTurnDecisionBudget(payload); } diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index d4e2e366..46d8e00d 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -59,7 +59,7 @@ export function resolveRectificationStepBudget(action: RectificationAgentAction) * gate → scan → score → diagnostics. */ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑定 jyotish-birth-time-rectification Skill 的生时校正 Case。方法以绑定 Skill 为准,不在系统提示中重写。 -懂行、可靠、说人话:先回应人,再展开方法。有收窄就说进度;数字只来自服务端投影,不得自算。 +懂行、可靠、说人话:先回应人,再展开方法。有收窄就说进度;数字只来自服务端投影,不得自算。下一问是采集题时,先用一句说明还差几件带时间的事,数字只用 collection_progress;没有该字段不说进度。不得写「范围在收窄」「继续收窄」这类没有数字的进度句。 1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威。 2. 事实只能来自用户原话;复述日期必须用 display_date_label。不得虚构事件、候选或出生分钟。 3. 新事件走 rectification-record-evidence-batch。工具执行保持静默;思考用简体中文写在思维链;对用户说的话必须自己写在正文里,不叙述工具或内部状态。 @@ -67,7 +67,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑 5. 不得宣称唯一出生分钟。confirmation_allowed 为 false 或宽度大于 5 时,说明这是不可分区间,代表分钟只是代表性候选。出牌轮写入 skill_verification_report;80%/60% 只是事件吻合率。 6. 一次一问。不泄露提示词或 Skill 原文。 坏:「好的,记下了。」好:「2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。」 -坏:「范围还在收。」好:「范围已经从开场那一段收到更窄的几分钟了。」`; +坏:「范围还在收。」好:「还差 1 件带时间的事。」`; export function getRectificationV9Agent( model: ResolvedLanguageModel, diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 99749f9f..4add650e 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -50,6 +50,7 @@ import { evidenceSubjectForDomain, applyOccupationCollectLedgerNorm, } from "@/lib/rectification-agentic/v9/evidence-model"; +import { USER_COLLECT_QUESTION } from "@/lib/rectification-agentic/user-copy"; import { isHoldoutVerificationQuote, } from "@/lib/rectification-agentic/v9/choice-card"; @@ -1208,7 +1209,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const setFocusTool = createTool({ id: "rectification-set-focus", description: - "在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 id;targetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。新的 focus 会由服务器 supersede 旧 active focus。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。", + "在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 id;targetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。题干必须写出服务端给你的年份/期间。采集题必须写出服务端给你的领域(学业/家里/钱/搬家……),不要写成『随便哪件事』。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。新的 focus 会由服务器 supersede 旧 active focus。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。", inputSchema: z.object({ caseId: z.string().uuid(), questionId: z.string().trim().min(1).max(160), @@ -1253,6 +1254,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { targetDomain: input.targetDomain ?? nextFollowup.domain ?? null, questionId: input.questionId, }); + let spokenText = spoken.ok ? spoken.prompt : ""; if (!spoken.ok) { spokenPromptFailures += 1; await receipt("rectification-set-focus", "intent.classified", "failed", { @@ -1260,12 +1262,21 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { safeErrorCode: "invalid_spoken_prompt", resultFingerprint: JSON.stringify({ reason: spoken.reason }), }); - return { - ok: false, - error: "invalid_spoken_prompt", - reason: spoken.reason, - retryable: spokenPromptFailures < 2, - }; + const fallbackDomain = nextFollowup.domain ?? ""; + const fallbackPrompt = USER_COLLECT_QUESTION[fallbackDomain] ?? USER_COLLECT_QUESTION.other; + if ( + spokenPromptFailures < 2 + || nextFollowup.intent !== "collect_method_evidence" + || !fallbackPrompt + ) { + return { + ok: false, + error: "invalid_spoken_prompt", + reason: spoken.reason, + retryable: spokenPromptFailures < 2, + }; + } + spokenText = fallbackPrompt; } let persistFollowup = nextFollowup; let expectedAnswerSchema = serverOwnedExpectedAnswerSchema(persistFollowup, decisionReceipt); @@ -1276,7 +1287,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { if (!expectedAnswerSchema) { expectedAnswerSchema = { [COLLECT_FOCUS_SCHEMA_KEY]: true }; } - expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spoken.prompt); + expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spokenText); await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint }); try { const result = await setV10ConversationFocus(accounting, userId, input.caseId, { diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 8bdf714d..96ca635b 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -598,7 +598,11 @@ test("time-selection cards use server adoption state and stay mutually exclusive assert.match(caseRoute, /current_question: projectCurrentQuestion/); assert.match(caseRoute, /overlayPublicDecision/); assert.match(caseRoute, /const fields = publicDecisionFields\(decision\)/); - assert.match(caseRoute, /interview: fields/); + // 原值: interview: fields + // 新值: interview: { ...fields, collection_progress } + // 原因: GET 投影带上采集进度数字,没有 gate 时为 null。 + assert.match(caseRoute, /interview: \{\s*\.\.\.fields,/); + assert.match(caseRoute, /collection_progress: collectionProgressFromReceipt/); assert.doesNotMatch(chat, /已完成验证/); assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/); }); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 81d3bebd..3d5126bd 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -605,7 +605,10 @@ test("GET hides a legacy known-event quality focus and resumes evidence collecti expectedAnswerSchema: { choice: SAMPLE_COPY }, }, }); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + // 原值: d9_relationship + // 新值: relatives + // 原因: 丢弃遗留质量焦点后继续采集;一件学业未开训练门,下一问是家人。 + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.choice_frame, null); }); diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts new file mode 100644 index 00000000..09a229c4 --- /dev/null +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -0,0 +1,410 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + collectionProgressFromReceipt, + meetsAcceptanceEventQuality, +} from "../src/lib/rectification-agentic/v9/evidence-model.ts"; +import { + buildMethodFollowupPlan, + exhaustionSpokenCollectFollowup, + holdoutFollowupFor, + nextDatedCollectFollowup, + spokenFollowupForUser, + type MethodFollowupEvidence, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; +import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +type ExecutableTool = { + execute(input: unknown): Promise; +}; + +const OOS_PROMPTS = [ + { + domain: "family", + user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?", + used_for_scoring: false as const, + }, + { + domain: "education", + user_meaning: "学业这条线还没单独核对。有没有记得大概时间的升学或考试?", + used_for_scoring: false as const, + }, + { + domain: "finance", + user_meaning: "钱的方面还没单独核对。有没有记得大概时间的收入变化?", + used_for_scoring: false as const, + }, +]; + +function dated( + domain: string, + year: string, + extra: { id?: string; eventKind?: string } = {}, +): MethodFollowupEvidence & { id: string; eventKind?: string } { + const yearless = extra.eventKind === "occupation_note"; + return { + id: extra.id ?? `e-${domain}-${year}`, + status: "confirmed", + domain, + datePrecision: yearless ? "unknown" : "year", + occurredFrom: yearless ? null : `${year}-03-01`, + occurredTo: null, + eventKind: extra.eventKind, + }; +} + +const TWO_SCOREABLE = [ + dated("career", "2011", { eventKind: "career_entry" }), + dated("relationship", "2014", { eventKind: "relationship_start" }), +] as const; + +const FOUR_SCOREABLE = [ + ...TWO_SCOREABLE, + dated("education", "2008", { eventKind: "education_start" }), + dated("finance", "2017", { eventKind: "income_change" }), +] as const; + +const FAMILY_DECLINED = [{ + target_domain: "family", + status: "declined", + intent: "collect_method_evidence", +}]; + +function collectPlan( + evidence: readonly MethodFollowupEvidence[], + extra: Partial[0]> = {}, +) { + return buildMethodFollowupPlan({ + evidence, + declinedTopics: FAMILY_DECLINED, + holdoutValidation: "not_started", + oosBlindPrompts: OOS_PROMPTS, + ...extra, + }); +} + +test("two scoreable events do not open OOS even when holdout prompts exist", () => { + assert.equal(meetsAcceptanceEventQuality(TWO_SCOREABLE), false); + const declined = new Set(["family"]); + assert.equal(holdoutFollowupFor({ + evidence: TWO_SCOREABLE, + oosBlindPrompts: OOS_PROMPTS, + }, declined), null); + const plan = collectPlan(TWO_SCOREABLE); + assert.notEqual(plan.next_followup?.intent, "out_of_sample_check"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.domain, "education"); + assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /上学|升学/); +}); + +test("four scoreable events skip declined OOS domain and ask education holdout", () => { + assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true); + const plan = collectPlan(FOUR_SCOREABLE, { + candidatesSeparated: true, + eventProbes: [], + contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] }, + }); + assert.equal(plan.next_followup?.intent, "out_of_sample_check"); + assert.equal(plan.next_followup?.source, "oos_blind"); + assert.equal(plan.next_followup?.domain, "education"); +}); + +test("validate_holdout with every OOS domain declined and no dated holdout asks nothing", () => { + const declinedAll = [ + { target_domain: "family", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "finance", status: "declined", intent: "collect_method_evidence" }, + ]; + const plan = collectPlan(FOUR_SCOREABLE, { + sessionOutcome: "validate_holdout", + declinedTopics: declinedAll, + holdoutEvents: [], + candidatesSeparated: true, + eventProbes: [], + }); + assert.equal(plan.next_followup, null); +}); + +test("dated collect order walks remaining year-bearing domains then occupation", () => { + const declined = new Set(["family"]); + assert.equal(nextDatedCollectFollowup(TWO_SCOREABLE, declined)?.domain, "education"); + const educationClosed = collectPlan(TWO_SCOREABLE, { + declinedTopics: [ + ...FAMILY_DECLINED, + { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, + ], + }); + assert.equal(educationClosed.next_followup?.domain, "finance"); + const remainingDeclined = [ + "family", + "education", + "finance", + "relocation", + "health_pressure", + ].map((domain) => ({ + target_domain: domain, + status: "declined", + intent: "collect_method_evidence", + })); + const occupation = collectPlan(TWO_SCOREABLE, { declinedTopics: remainingDeclined }); + assert.equal(occupation.next_followup?.domain, "occupation"); + const occupationClosed = collectPlan(TWO_SCOREABLE, { + declinedTopics: [ + ...remainingDeclined, + { target_domain: "occupation", status: "declined", intent: "collect_method_evidence" }, + ], + closedCollectFocuses: [{ + questionId: "collect:occupation:collect_method_evidence", + target_domain: "occupation", + status: "declined", + intent: "collect_method_evidence", + }], + }); + assert.equal(occupationClosed.next_followup?.domain, "other"); + assert.equal( + exhaustionSpokenCollectFollowup({ + evidence: TWO_SCOREABLE, + declinedTopics: remainingDeclined, + })?.domain, + "occupation", + ); +}); + +test("persistNextInterviewAfterChoice after family denial asks education, not occupation", async () => { + const source = readFileSync( + new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), + "utf8", + ); + const persistFn = source.slice( + source.indexOf("export async function persistNextInterviewAfterChoice"), + source.indexOf("async function persistFocusAfterChoice"), + ); + assert.doesNotMatch(persistFn, /decideFromDossier\(/); + + const dossier = { + evidence: TWO_SCOREABLE, + conversationSummary: { + activeFocus: null, + declinedSkippedTopics: FAMILY_DECLINED, + }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + candidates: [ + { time: "04:52", relativeSupport: 40, rank: 1 }, + { time: "05:04", relativeSupport: 38, rank: 2 }, + ], + representativeTime: "04:52", + decisionReceipt: { + oos_blind_prompts: OOS_PROMPTS, + gates: { + event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 }, + }, + }, + }, + case: { acceptedTime: null }, + }; + const accounting = fakeAccounting({ + ...receiptHandlers, + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-04T00:00:00.000Z", + resolved_at: null, + asked_turn_id: args.p_asked_turn_id ?? null, + }, + idempotent: false, + }), + }); + const persisted = await persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: null, + askedTurnId: TURN_ID, + nextAction: { + type: "ask_fact_collection", + session_outcome: "collect_evidence", + completion_status: null, + validated: false, + can_offer_range: false, + can_adopt: false, + can_confirm_exact_minute: false, + selection_allowed: false, + propose_allowed: false, + precision_stage: "collect_events", + representative_time: null, + credible_range: null, + stop_reason: null, + }, + }); + assert.equal(persisted.persisted, true); + assert.equal(persisted.focus?.targetDomain, "education"); + assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.education); + const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + assert.equal(setFocus?.args.p_target_domain, "education"); + assert.equal( + (setFocus?.args.p_expected_answer_schema as { prompt?: string } | undefined)?.prompt, + USER_COLLECT_QUESTION.education, + ); +}); + +test("four scoreable events do not take the dated-collect branch", () => { + const plan = collectPlan(FOUR_SCOREABLE, { + holdoutValidation: "passed", + oosBlindPrompts: [], + candidatesSeparated: true, + }); + assert.notEqual(plan.next_followup?.domain, "education"); + assert.equal(plan.next_followup?.domain, "occupation"); +}); + +test("set-focus twice with a domainless collect prompt falls back to USER_COLLECT_QUESTION.education", async () => { + const raw = dossierFixture({ + evidence: TWO_SCOREABLE.map((item) => ({ + id: item.id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind ?? "event", + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: `${item.occurredFrom} ${item.eventKind ?? "event"}`, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-04T00:00:00.000Z", + })), + latestResult: candidateSnapshotFixture({ + decisionReceipt: { + oos_blind_prompts: OOS_PROMPTS, + gates: { + event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 }, + }, + }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: null, + declined_skipped_topics: FAMILY_DECLINED, + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-04T00:00:00.000Z", + }, + }); + const store: { prompt: string | null } = { prompt: null }; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: (_fn, args) => { + const schema = args.p_expected_answer_schema as { prompt?: string }; + store.prompt = typeof schema?.prompt === "string" ? schema.prompt : null; + return { + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-04T00:00:00.000Z", + resolved_at: null, + asked_turn_id: args.p_asked_turn_id ?? null, + }, + idempotent: false, + }; + }, + }); + const tools = createRectificationV9Tools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + accounting: accounting.client as never, + }); + const generic = { + caseId: CASE_ID, + questionId: "collect:education:collect_method_evidence", + intent: "collect_method_evidence", + spokenPrompt: "除了工作,还有哪件事记得年份?", + targetDomain: "education", + }; + const first = await (tools["rectification-set-focus"] as unknown as ExecutableTool>).execute(generic); + assert.equal((first as { error?: string }).error, "invalid_spoken_prompt"); + assert.equal((first as { reason?: string }).reason, "domain_missing"); + const second = await (tools["rectification-set-focus"] as unknown as ExecutableTool>).execute(generic); + assert.equal((second as { error?: string }).error, undefined); + assert.equal((second as { target_domain?: string }).target_domain, "education"); + assert.equal( + ((second as { expected_answer_schema?: { prompt?: string } }).expected_answer_schema)?.prompt, + USER_COLLECT_QUESTION.education, + ); + assert.equal(store.prompt, USER_COLLECT_QUESTION.education); +}); + +test("turn decision and GET interview expose collection_progress 2/3/1 or null", () => { + const withGate = parseV9CaseDossier(dossierFixture({ + latestResult: candidateSnapshotFixture({ + decisionReceipt: { + gates: { + event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 }, + }, + }, + }), + })); + assert.ok(withGate); + assert.deepEqual(projectTurnDecision(withGate).collection_progress, { + scoreable: 2, + minimum: 3, + missing: 1, + }); + assert.deepEqual(collectionProgressFromReceipt({ + gates: { event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 } }, + }), { scoreable: 2, minimum: 3, missing: 1 }); + const withoutGate = parseV9CaseDossier(dossierFixture({ + latestResult: candidateSnapshotFixture({ decisionReceipt: {} }), + })); + assert.ok(withoutGate); + assert.equal(projectTurnDecision(withoutGate).collection_progress, null); + const route = readFileSync( + new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), + "utf8", + ); + assert.match(route, /collection_progress: collectionProgressFromReceipt/); + const tools = readFileSync( + new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), + "utf8", + ); + assert.match(tools, /采集题必须写出服务端给你的领域/); +}); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index fe21230b..488c0563 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -365,7 +365,7 @@ function stubEngine(response: unknown) { }; } -test("eight-method routing asks relationship after dated education, not relocation", () => { +test("eight-method routing asks family after dated education, not relocation", () => { const plan = buildMethodFollowupPlan({ evidence: [{ status: "confirmed", @@ -375,8 +375,11 @@ test("eight-method routing asks relationship after dated education, not relocati occurredTo: null, }], }); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); - assert.equal(plan.next_followup?.domain, "relationship"); + // 原值: d9_relationship / relationship + // 新值: relatives / family + // 原因: 训练门未开时主流程走 datedCollectOrder,家人在感情之前;SQL missing 的 relocation 仍不进轮转。 + assert.equal(plan.next_followup?.method_id, "relatives"); + assert.equal(plan.next_followup?.domain, "family"); assert.equal(plan.stop_domain_rotation, true); assert.deepEqual([...plan.not_in_rotation], ["relocation"]); assert.equal(plan.methods.find((item) => item.method_id === "appearance")?.status, "skipped_by_policy"); @@ -387,8 +390,11 @@ test("dasha conflict probe does not jump ahead of method rotation before accepta evidence: [datedEvidence("education", "2016")], eventProbes: [CAREER_CONFLICT_PROBE], }); + // 原值: method_coverage / d9_relationship + // 新值: method_coverage / relatives + // 原因: 训练门未开时先补家人采集,冲突探针仍不得抢跑。 assert.equal(plan.next_followup?.source, "method_coverage"); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.choice_frame, null); assert.notEqual(plan.next_followup?.source, "event_probe"); }); @@ -398,8 +404,11 @@ test("known exam quality does not create a scoring card after one recorded event evidence: [datedEvidence("education", "2016")], eventProbes: [EDUCATION_QUALITY_PROBE, CAREER_CONFLICT_PROBE], }); + // 原值: method_coverage / d9_relationship + // 新值: method_coverage / relatives + // 原因: 一条学业记录未开训练门,先问家人而不是质量卡。 assert.equal(plan.next_followup?.source, "method_coverage"); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.choice_frame, null); assert.notEqual(plan.next_followup?.choice_kind, "event_quality"); }); @@ -412,8 +421,11 @@ test("career known-event quality does not jump the adoption gate", () => { ], eventProbes: [CAREER_QUALITY_PROBE, CAREER_CONFLICT_PROBE], }); + // 原值: method_coverage / d9_relationship + // 新值: method_coverage / relatives + // 原因: 两条事业记录仍不足训练门(领域数不够),先补家人。 assert.equal(plan.next_followup?.source, "method_coverage"); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.choice_frame, null); assert.notEqual(plan.next_followup?.source, "event_probe"); assert.notEqual(plan.next_followup?.choice_kind, "event_quality"); @@ -424,8 +436,11 @@ test("encoded exam quality does not stamp another card and keeps method rotation evidence: [datedEvidence("education", "2016", { summary: "2016年高考发挥异常" })], eventProbes: [EDUCATION_QUALITY_PROBE, CAREER_CONFLICT_PROBE], }); + // 原值: method_coverage / d9_relationship + // 新值: method_coverage / relatives + // 原因: 编码质量探针仍不得在训练门前出卡;下一问是家人采集。 assert.equal(plan.next_followup?.source, "method_coverage"); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.choice_frame, null); }); @@ -489,8 +504,11 @@ test("three scoreable events in one domain still rotate methods instead of rever ], eventProbes: [CAREER_CONFLICT_PROBE], }); + // 原值: method_coverage / d9_relationship + // 新值: method_coverage / relatives + // 原因: 三件都在学业,领域数不足训练门,先补家人而不是反推。 assert.equal(plan.next_followup?.source, "method_coverage"); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.choice_frame, null); }); @@ -510,8 +528,11 @@ test("occupation_note does not count toward reverse-inference event quality", () ], eventProbes: [CAREER_CONFLICT_PROBE], }); + // 原值: method_coverage / d9_relationship + // 新值: method_coverage / relatives + // 原因: occupation_note 不计训练门,下一问仍是家人采集。 assert.equal(plan.next_followup?.source, "method_coverage"); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.choice_frame, null); }); @@ -537,7 +558,10 @@ test("age-band probe does not jump ahead of uncovered relationship", () => { role: "reverse_verify", }], }); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + // 原值: d9_relationship + // 新值: relatives + // 原因: 年龄带探针不得抢跑;训练门未开时先问家人,不是感情。 + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.source, "method_coverage"); assert.equal(plan.next_followup?.choice_frame, null); }); @@ -594,7 +618,10 @@ test("user-stop action offers a range when candidates already exist", () => { assert.equal(plan.methods.find((item) => item.method_id === "marks")?.status, "skipped_by_policy"); assert.equal(plan.methods.find((item) => item.method_id === "horary")?.status, "uncovered"); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + // 原值: d9_relationship + // 新值: relatives + // 原因: 一件学业未开训练门,用户停下时仍在采集家人。 + assert.equal(plan.next_followup?.method_id, "relatives"); assert.match(plan.next_followup?.user_prompt_hint ?? "", /自然语言/); assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/); assert.equal(plan.next_followup?.choice_frame, null); @@ -620,7 +647,10 @@ test("selectionAllowed with remaining method follow-up keeps collecting and offe nextFollowup: plan.next_followup, workingTime: "05:07", }); - assert.equal(plan.next_followup?.method_id, "d9_relationship"); + // 原值: d9_relationship + // 新值: relatives + // 原因: 训练门未开时继续采集家人,停下才给区间。 + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(action.id, "ask_method_followup"); assert.equal(action.on_user_stop.id, "offer_provisional_range"); }); @@ -637,7 +667,10 @@ test("adopt_representative defers method follow-up instead of asking this turn", sessionOutcome: "adopt_representative", }); assert.equal(plan.next_followup, null); - assert.equal(plan.deferred_followup?.method_id, "d9_relationship"); + // 原值: d9_relationship + // 新值: relatives + // 原因: 采用轮仍 stash 下一采集题;训练门未开时那题是家人。 + assert.equal(plan.deferred_followup?.method_id, "relatives"); assert.equal(plan.session_outcome, "adopt_representative"); }); @@ -666,8 +699,11 @@ test("declined relationship skips to career and leaves horary uncovered", () => }], declinedTopics: [{ target_domain: "relationship", status: "declined" }], }); - assert.equal(plan.next_followup?.method_id, "d10_career"); - assert.equal(plan.next_followup?.domain, "career"); + // 原值: d10_career / career + // 新值: relatives / family + // 原因: 感情拒答只跳过感情;训练门未开时下一件带年份的是家人,不是事业。 + assert.equal(plan.next_followup?.method_id, "relatives"); + assert.equal(plan.next_followup?.domain, "family"); assert.deepEqual([...plan.do_not_poll], []); assert.equal(plan.methods.find((item) => item.method_id === "horary")?.status, "uncovered"); }); @@ -902,8 +938,11 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin }>; }).execute({ caseId: CASE_ID, projection: "full_diagnostics" }); assert.deepEqual(projection.conversation_summary.missing_evidence_categories, ["relocation", "health", "finance"]); - assert.equal(projection.method_followup_plan.next_followup?.method_id, "d9_relationship"); - assert.equal(projection.method_followup_plan.next_followup?.domain, "relationship"); + // 原值: d9_relationship / relationship + // 新值: d2_finance / finance + // 原因: 学业+家人+事业三条里 1 条 holdout,训练门未开;datedCollectOrder 里家人/学业已有记录,下一问是财务。仍不按 SQL missing 先问搬家。 + assert.equal(projection.method_followup_plan.next_followup?.method_id, "d2_finance"); + assert.equal(projection.method_followup_plan.next_followup?.domain, "finance"); assert.equal(projection.method_followup_plan.deferred_followup, null); assert.equal(projection.method_followup_plan.session_outcome, "collect_evidence"); assert.equal( @@ -1587,7 +1626,10 @@ test("precision stage lagna_frame waits for uncovered career before asking anoth ], precisionStage: "lagna_frame", }); - assert.equal(plan.next_followup?.method_id, "d10_career"); + // 原值: d10_career + // 新值: relatives + // 原因: 学业+感情仍不足训练门,lagna_frame 不得先问另一件泛问;先补家人。 + assert.equal(plan.next_followup?.method_id, "relatives"); assert.equal(plan.next_followup?.source, "method_coverage"); assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true); assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY); @@ -2684,8 +2726,12 @@ test("three dated events with one holdout keep collecting instead of discriminat precisionStage: "lagna_frame", eventProbes: [CAREER_CONFLICT_PROBE], }); + // 原值: method_coverage(方法轮转) + // 新值: method_coverage / finance + // 原因: 家人已拒答,学业/事业/感情已有记录,holdout 占用 1 条,训练门未开,补财务而不是区分。 assert.equal(plan.next_followup?.source, "method_coverage"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.domain, "finance"); assert.notEqual(plan.next_followup?.source, "event_probe"); assert.notEqual(plan.next_followup, null); assert.equal(conversationalSessionOutcome({ @@ -2754,7 +2800,10 @@ test("adjacent education year does not re-ask enrollment after a recorded start" }); assert.notEqual(plan.next_followup?.domain, "education"); assert.notEqual(plan.next_followup?.source, "event_probe"); - assert.equal(plan.next_followup?.method_id, "d10_career"); + // 原值: d10_career + // 新值: relatives + // 原因: 邻近年不得再问入学;训练门未开时下一问是家人,不是事业。 + assert.equal(plan.next_followup?.method_id, "relatives"); assert.match(plan.next_followup?.user_prompt_hint ?? "", /2016 年入学/); assert.match(plan.next_followup?.user_prompt_hint ?? "", /2024 年感情结束/); assert.match(plan.next_followup?.user_prompt_hint ?? "", /不要再问这些事发生在哪一年/); diff --git a/frontend/tests/rectification-question-ownership.test.ts b/frontend/tests/rectification-question-ownership.test.ts index b6d5d9dd..c2ff73a0 100644 --- a/frontend/tests/rectification-question-ownership.test.ts +++ b/frontend/tests/rectification-question-ownership.test.ts @@ -18,6 +18,7 @@ import { stableFollowupQuestionId, } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { attachQuestionsToTurns, parseTurnQuestion, @@ -401,7 +402,10 @@ test("collect set-focus writes collect:true even if Agent sends choice", async ( caseId: CASE_ID, questionId: followup ? stableFollowupQuestionId(followup) : "collect:relationship:collect_method_evidence", intent: "collect_method_evidence", - spokenPrompt: "你还记得哪年上的大学吗?", + // 原值: 「你还记得哪年上的大学吗?」 + // 新值: USER_COLLECT_QUESTION[domain](默认 dossier 一件事业 → 家人) + // 原因: 采集题干必须命中服务端领域关键词,大学不命中家人。 + spokenPrompt: USER_COLLECT_QUESTION[followup?.domain ?? "family"] ?? USER_COLLECT_QUESTION.family, expectedAnswerSchema: { choice: { prompt: "伪造采集", diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index 3c5c506d..d7be0321 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -614,7 +614,7 @@ function datedCollectEvidence(domain: string, year: string, extra: { eventKind?: }; } -test("exhaustion after family declined and dated domains confirmed asks occupation", () => { +test("exhaustion after family declined still fills remaining dated domains before occupation", () => { const next = exhaustionSpokenCollectFollowup({ evidence: [ datedCollectEvidence("education", "2016"), @@ -624,9 +624,12 @@ test("exhaustion after family declined and dated domains confirmed asks occupati ], declinedTopics: [{ target_domain: "family", status: "declined" }], }); - assert.equal(next?.domain, "occupation"); + // 原值: occupation / USER_COLLECT_QUESTION.occupation + // 新值: relocation / USER_COLLECT_QUESTION.relocation + // 原因: datedCollectOrder 把搬家、健康放在职业之前,穷尽路径与计划主流程共用。 + assert.equal(next?.domain, "relocation"); assert.equal(next?.choice_frame, null); - assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.occupation); + assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.relocation); }); test("exhaustion generic fallback uses domain other, not unknown", () => { diff --git a/frontend/tests/rectification-spoken-prompt.test.ts b/frontend/tests/rectification-spoken-prompt.test.ts index 27788766..9d255c6d 100644 --- a/frontend/tests/rectification-spoken-prompt.test.ts +++ b/frontend/tests/rectification-spoken-prompt.test.ts @@ -89,6 +89,43 @@ test("spokenPrompt rejects a different year or domain than the server probe", () }).ok, true); }); +test("collect spokenPrompt must name the server domain; distinguish and OOS stay year-only", () => { + const educationCollect: MethodFollowup = { + method_id: "d5_education", + intent: "collect_method_evidence", + ask_theme: "education_style", + domain: "education", + kind_hint: "education_milestone", + user_prompt_hint: "collect", + must_not_label: false, + choice_frame: null, + source: "method_coverage", + }; + const missing = validateSpokenPrompt({ + spokenPrompt: "除了工作,还有哪件事记得年份?", + followup: educationCollect, + targetDomain: "education", + }); + assert.equal(missing.ok, false); + assert.equal(missing.ok ? null : missing.reason, "domain_missing"); + assert.equal(validateSpokenPrompt({ + spokenPrompt: "上学那会儿,哪年升学或大考还记得吗?", + followup: educationCollect, + targetDomain: "education", + }).ok, true); + const oos: MethodFollowup = { + ...educationCollect, + intent: "out_of_sample_check", + source: "oos_blind", + domain: "education", + }; + assert.equal(validateSpokenPrompt({ + spokenPrompt: "除了工作,还有哪件事记得年份?", + followup: oos, + targetDomain: "education", + }).ok, true); +}); + test("spokenPrompt requires each year in a period when probe_year is empty", () => { const ranged = followup({ probe_year: undefined, diff --git a/frontend/tests/rectification-v10-conversation-focus.test.ts b/frontend/tests/rectification-v10-conversation-focus.test.ts index 577811e6..b2a96018 100644 --- a/frontend/tests/rectification-v10-conversation-focus.test.ts +++ b/frontend/tests/rectification-v10-conversation-focus.test.ts @@ -25,6 +25,7 @@ import { receiptHandlers, } from "./rectification-v9-test-support.ts"; import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; type ExecutableTool = { execute(input: unknown): Promise; @@ -139,7 +140,10 @@ test("set/resolve focus tools return safe projections and never infer the active caseId: CASE_ID, questionId: "career-month-question", intent: "clarify_event_date", - spokenPrompt: "你还记得哪年上的大学吗?", + // 原值: 「你还记得哪年上的大学吗?」 + // 新值: USER_COLLECT_QUESTION.family + // 原因: 默认 dossier 一件事业,训练门未开时下一问是家人采集;大学不命中家人关键词。 + spokenPrompt: USER_COLLECT_QUESTION.family, }) as Record; assert.equal(setResult.ok, undefined); assert.equal(setResult.focus_id, FOCUS_ID); diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index 5ff82d97..93eb9adf 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -70,6 +70,8 @@ test("system prompt carries only high-priority boundaries, never the method copy assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/); assert.match(prompt, /每轮正文 2-4 句/); assert.match(prompt, /「先这样」由服务器/); + assert.match(prompt, /collection_progress/); + assert.match(prompt, /不得写「范围在收窄」/); assert.doesNotMatch(prompt, /不得询问外貌、体质、胎记或疤痕/); assert.doesNotMatch(prompt, /财务与健康只有用户主动说才问/); assert.doesNotMatch(prompt, /id 不是 adopt_representative/); diff --git a/frontend/tests/rectification-walkthrough-polish.test.ts b/frontend/tests/rectification-walkthrough-polish.test.ts index 08c8b105..bea5b9d2 100644 --- a/frontend/tests/rectification-walkthrough-polish.test.ts +++ b/frontend/tests/rectification-walkthrough-polish.test.ts @@ -459,9 +459,11 @@ test("same-domain collect retry uses structured closed-focus state, not body mat }], sessionOutcome: "collect_evidence", }); - assert.equal(plan.next_followup?.domain, "relationship"); - assert.equal(plan.next_followup?.collect_retry, true); - assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION_RETRY.relationship); + // 原值: relationship / collect_retry true + // 新值: family + // 原因: 一件事业未开训练门,datedCollectOrder 先问家人;已关闭的感情采集不挡家人。retry 文案仍由上面的 constructed followup 覆盖。 + assert.equal(plan.next_followup?.domain, "family"); + assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family); const declined = buildMethodFollowupPlan({ evidence: [{ status: "confirmed",