Compare commits

..
Author SHA1 Message Date
Jesse_ChenandCursor d4627a5019 fix(rectification): dedupe post-adopt verify and skip this probe only
Independent Staging Quality Gate / validate (push) Successful in 14m31s
Independent Staging Quality Gate / publish (push) Successful in 1m59s
Adopted reverse-verify repeated already-asked collect stems, treated 「这题跳过」 as stopping the case, and promised holdout/OOS checks that never ran. Collect spoken stems no longer prefix a year.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 21:51:59 +08:00
Jesse_ChenandCursor 6b038e6d0d docs(report): record BUG-535 fix SHA 61194690
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 21:34:34 +08:00
Jesse_ChenandCursor 6119469069 fix(report): bind v2 chart cap to CHART_IDS.length
Five-theme personal_full assembled nine charts after health vargas landed, but the document schema still capped at six. Bind the count to the enum, align JSON/Python contracts, and log final-parse paths without values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 21:33:27 +08:00
Jesse_ChenandClaude Fable 5 d2955c6280 docs(report): close the section-cost line with the measured verdict
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-04 12:50:30 +00:00
Jesse_ChenandClaude Fable 5 40387ff9c7 docs(report): add BUG-535 chart-cap task brief — charts.max lagged CHART_IDS
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-04 12:48:51 +00:00
Jesse_ChenandCursor e674cd3871 docs(report): record staging personal_full after BUG-526 and BUG-534
Engine and five chapters now complete on accepted profiles; final assemble still fails as BUG-535.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:43:17 +08:00
Jesse_Chen ea28521aa0 docs(tasks): post-adopt verification brief (dedupe, skip-only, closing state, no year prefix) 2026-09-04 12:37:15 +00:00
Jesse_ChenandCursor e27d5dc5a7 fix(report): accept section: job progress phases on read-back
Independent Staging Quality Gate / validate (push) Successful in 8m41s
Independent Staging Quality Gate / publish (push) Successful in 1m53s
Heartbeat and GET were parsing progress_phase with a pattern that rejects the colon prefix the writer already persists, which aborted generation after the first chapter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:10:52 +08:00
Jesse_ChenandCursor a793edb1ad fix(rectification): keep opening greeting across set-focus
Independent Staging Quality Gate / validate (push) Successful in 9m24s
Independent Staging Quality Gate / publish (push) Successful in 1m57s
set-focus only attaches the question stem. Retracting live Chinese before
it wiped the opening hello with an empty replace. Other public tools still
retract planning prose.

BUG-533.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:51:24 +08:00
53 changed files with 1666 additions and 109 deletions
+1
View File
@@ -38,6 +38,7 @@
- **分章生成路径还需要 PostgreSQL。** `generateSectionedPersonalReport` 依赖 `PersonalReportSectionService`(`personal_report_sections` 表)。本机没有 Docker / postgres / initdb / psql,与 BLOCKED.md 2026-08-30 条目同一缺口。本轮未改任何库结构、未新增迁移,`npm run test:db` 因此也未跑。
- **每章 prompt 原始 token p50 为改前的 2.83×(计费加权约 2.28×),超过任务书红线 6 的 2 倍线,等待裁决。** 不属于任务 3 的止损分支:`cachedSystemMessage` 已确认生效,通用知识包在缓存断点之内且缓存前缀逐字节稳定(有测试锁)。止损条款明确禁止「砍证据来凑成本」,因此本轮**未削减任何证据**,只把数据与四个可选降本项摆在 PROGRESS 里。请委托方决定是接受这个成本、还是选其中某项降本再推。
**裁决(2026-09-01,产品确认):接受当前成本,直接推 staging。** 依据:离线估算方法偏保守(CJK 按 1 字符 1 token),绝对量约每份报告 2.2 万输入 token。以部署后第一份真实报告的实测 `inputTokens` 为准复核;实测仍超线时再从 PROGRESS 的降本项 2/3 起做。
**终裁(2026-09-04,产品确认):成本线收口,接受实测成本。** 实测每章 inputTokens p50=5921(request 20c94bf4,五章 5804–6405),名义 5.22×,但对照基线 1135.5 是离线载荷估算、实测含系统提示/知识包/schema 注入,口径错位,真实涨幅低于名义值;绝对量约每份 3.5 万 input + 1 万 output tokens。2 倍线裁决就此完结,降本项 2/3 不启动,后续只做常规监控。
- **`/api/consultation_workflow` 的 `ai_prompt_pack` 是精简版,不含叙事种子 / dasa convergence / SAV。** 它来自 `scripts/jyotish_api_server.py` 的 `_build_chart_prompt_pack`,不是 `scripts/jyotish_engine.py` 的 `_build_ai_prompt_pack`。任务书列出的 `evidence_snapshot.timing.vimshottari`、`.convergence_top_domains`、`.strength.sav_scores`、`career_narrative` / `relationship_narrative` / `finance_narrative` 在该响应里**都不存在**(9 个主题一致)。本轮按验收条款标记为不可提取并改用同一响应里真实存在的等价路径,未改 Python 引擎(任务书把引擎输出改动列为不在范围内)。若要拿到真正的服务器叙事种子,需要另立项让 `/api/consultation_workflow` 附带完整 prompt pack——那是「响应新增字段」,属于任务 0 的止损分支,本轮未走。
## 客户端交互收尾(2026-08-30,分支 `codex/interaction-20260830`)
+16
View File
@@ -1,5 +1,21 @@
# 印度占星 Skill 更新日志
## 2026-09-04 — 采用后核对不再重复已问题,跳过只跳本题
采用代表分钟后,核对题会跳过采集阶段已经问过的前事。核对卡上的「这题跳过」只关掉这一题;没有下一题时收一行「前事核对到这里…」,不再念「选一个先用着」,也不再出现「没有拿到下一个问题」。采用旁白只承诺服务端真会问的核对;采集口语不再加「某年前后,」前缀。Skill 版本仍是 10.0.14。
## 2026-09-04 — 五主题完整报告不再因分盘张数上限装不出来
默认五章(含健康)会带上全部正式分盘。以前文档最多只许 6 张图,五章写完后整份报告仍会被判失败。现在张数上限跟着分盘枚举走。Skill 版本未变。
## 2026-09-04 — 开场打招呼不再被 set-focus 清掉
生时校正开场先播出的打招呼会留在同一条消息里。`rectification-set-focus` 只负责题干,不再发空的 `replace` 把正文抹掉。Skill 版本未变。
## 2026-09-04 — 报告写到一半时阅读页不再误报失败
分章生成会把进度写成 `section:主题`。后台读回这一行时校验过严,心跳中断,打开报告会看到失败。现在按写入时同一套规则读回进度。Skill 版本未变。
## 2026-09-04 — 未确认生时的个人报告不再被候选窗读取打死
出生时间尚未 confirmed 时,报告会去读校正候选窗。那张表已经收回运行时表权限,读失败却被当成计算不可用,整份报告在排盘前就失败。现在改走只读 RPC;读不到窗口就按无窗口继续生成。Skill 版本未变。
+53
View File
@@ -0,0 +1,53 @@
# 任务书 · BUG-535 修复:文档 charts 上限未随 CHART_IDS 扩容(2026-09-04)
基线:`origin/staging` @ `e674cd38`(开工时 `git fetch` 后以 `origin/staging` HEAD 为准)。
## 事故实证
- staging request `20c94bf4`:五章正文全部 ready(narrative 757–1960 字、telemetry 全 stop、回溯通过),装配后终稿校验拒绝 → `report_schema_invalid / final_parse_rejected`(BUG-535)。
- 委托方本地在 `origin/staging` 最新代码上**确定性复现并闭环验证**(真实引擎 + schema 合规假 writer + 内存 sectionService,无模型依赖):
- 5 主题(career/marriage/timing/wealth/health)→ `PIPELINE-FAILED final_parse_rejected`,与 staging 一致;
- 把 `personal-report-contract.ts` 的 `charts: z.array(reportDocumentV2ChartSchema).min(1).max(6)` 改为 `.max(CHART_IDS.length)` 后,同一管线 → **`PIPELINE-READY sections=5`**;
- 4 主题(不含 health)在未修复代码上一直 READY——解释了为什么此前四主题复现从未触发。
## 根因
`c2f23131`(health pipeline,09-03 上游同步)把 `CHART_IDS` 从 6 个(D1/D2/D9/D10/D11/D24)扩成 **9 个**(新增 D6/D8/D30,health 主题 `chartIds: ["D1","D6","D8","D30"]`),`DOCUMENT_VARGA_CHART_IDS` 同步扩到 8 张分盘;但文档 v2 合同的 **`charts.max(6)` 没有跟着改**。在分盘提取修好(blocked-repairs 轮)之后,5 主题 bundle 的 charts = D1+D2+D6+D8+D9+D10+D11+D24+D30 = 9 张 > 6 → `safeParseServerReportDocument` 拒绝。此前分盘提取是坏的,永远凑不满 6 张,所以这个半拉子改动一直没被触发。
## 硬红线
1. **只许放宽这一个"计数上限"**,且必须改成与 `CHART_IDS.length` 绑定(单一真源,防止下次扩枚举再脱节),不得写裸数字 9。任何**字段级内容校验**(长度、枚举、正则、evidenceRefs 绑定)一律不得放宽。
2. v1 文档合同(`REPORT_DOCUMENT_V1_CHART_IDS` 3 张、`charts.max(3)` 类)不动。
3. 顺带核对:grep `CHART_IDS` / `DOCUMENT_VARGA_CHART_IDS` 的**全部消费点**,找出其它随枚举硬编码的计数(bundle schema、按章过滤、UI 渲染、导出),有同类脱节一并对齐并逐条列进 PROGRESS;没有就写明核对结论。
4. 既有其余红线延续(不改 `.gitea/workflows/**`、不提升 main、`./node_modules/.bin/tsc`、隐私日志边界)。
## 任务
### 任务 1(P0)· 上限绑定 + 回归测试
- `charts.max(CHART_IDS.length)`(v2);红线 3 的消费点核对。
- 回归测试:9 张图(全枚举)的文档过 `safeParseServerReportDocument`;5 主题(含 health)装配 fixture 走全管线 READY;4 主题不回归。
### 任务 2(P0)· 终稿校验失败可观测
BUG-535 取证难的根源是日志没有 parse 明细。`final_parse_rejected` 时把 zod error 的 **path 列表**(只有字段路径与 message 代号,不含任何值/正文)写进 `generation_failed` 日志。测试锁:日志包含 path、不含文档内容。
### 任务 3(P0)· 部署后真实收口
health SHA 对齐后真实生成 standard personal_full(5 主题):报告 **ready 可打开**、五章正文、blocked 披露为空;BUG-535 收口(修复版本、验证、与 c2f23131 关联)。每章实测 tokens/墙钟继续如实记录(成本裁决另行处理,不在本单)。
## 不在本轮范围
- 成本(5.22× 实测的处置由产品另行裁决);writer 合同;知识包;上游同步修复单其余项。
## 收尾
`docs/tasks/PROGRESS-report-chart-cap-20260904.md`;`docs/BUG_HISTORY.md` BUG-535 收口;推送后核对 health `.deployment.gitCommit`;不提升 main。
## 交付物清单
1. 上限绑定 diff + 枚举消费点核对清单
2. 回归测试(9 图文档 / 5 主题 READY / 4 主题不回归)
3. final parse 失败的 path 级日志 + 测试
4. 部署后真实报告 ready 截图或 JSON + BUG-535 收口
5. 全套质量门实际输出 + PROGRESS
@@ -109,10 +109,13 @@
"enum": [
"D1",
"D2",
"D6",
"D8",
"D9",
"D10",
"D11",
"D24"
"D24",
"D30"
]
},
"title": {
@@ -736,7 +739,8 @@
"$ref": "#/definitions/chart"
},
"minItems": 1,
"maxItems": 6
"maxItems": 9,
"description": "maxItems must equal CHART_IDS.length in frontend/src/lib/personal-report-contract.ts"
},
"thematicNarrative": {
"type": "array",
+115 -3
View File
@@ -8125,11 +8125,11 @@
- 触发条件:资料 `birth_time_status` 为 `accepted`(不是 `confirmed`),worker 与 create route 因此去查 `agentic_rectification_cases` 的 `candidate_accepted` 行。
- 根因:`20260814010000_immutable_skill_registry.sql` 已从该表收回 `service_role` 的表级权限,只许走 security definer RPC。报告链路仍 `from("agentic_rectification_cases").select(...)`。Postgres 三次 `permission denied for table agentic_rectification_cases`(与 job 三次 attempt、默认 5s/10s 重试对齐)。查询失败被映射成可重试 `calculation_unavailable`,报告从未调用引擎。第二读者:create route 同样 `if (error) throw error`,会把创建请求打成 `calculation_unavailable`。
- 修复:新增只读 RPC `read_report_candidate_range`(不 re-grant 表权限)。worker 与 create route 经共享 helper 调用;任何读失败降级为无窗口并继续生成。`calculation_unavailable` 只保留给引擎调用失败。legacy 表终态以库约束为准:`confirmed` 与 `completed`(`20260720` 已纳入枚举;前端不再 `.in("status", ["confirmed", "completed"])` 直查)。
- 验证:`tests/report-candidate-range.test.ts` 三态降级;`tests/personal-report-api.test.ts` null 窗口仍 201;`tests/database-report-candidate-range.test.ts` 权限矩阵与列白名单。产品侧真实 personal_full 待本修复部署后补。
- 验证:`tests/report-candidate-range.test.ts` 三态降级;`tests/personal-report-api.test.ts` null 窗口仍 201;`tests/database-report-candidate-range.test.ts` 权限矩阵与列白名单。staging @ `e27d5dc5`:accepted 档案两次 standard personal_full 均 5× `POST /api/consultation_workflow` 200,五章有正文;不再出现 16 秒 `calculation_unavailable`。终稿被 BUG-535 `final_parse_rejected` 拦住,见该条。
- 防复发:报告 worker 与 create route 不得再直接 SELECT 已收回 `service_role` 权限的校正表;候选窗读失败不得再变成 `calculation_unavailable`。
- 相关记录:BUG-524
- 相关记录:BUG-524、BUG-534、BUG-535
- 复发自:无
- 修复版本:待发布
- 修复版本:`7baf2300`
- 编号说明:rebase 到 `origin/staging` 时 BUG-525 已被采集拒答占用,本条落在 BUG-526。
## BUG-527 | 可评分事件不足 3 条时盘外核对抢跑到已拒答领域
@@ -8228,3 +8228,115 @@
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-533 | 开场打招呼被 set-focus 空 replace 清掉
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`applyStepAnswerChunk`、`retractSpoken`、开场轮 `answer.delta`
- 用户现象:开场先流出「你好,很高兴一起做这次生时校正…」,页面最终只剩「我们慢慢来就好…」和采集题干。
- 触发条件:模型在 `rectification-set-focus` 之前写出带句号的中文正文;该步已 `live` 播出。
- 根因:任一公开工具的 `tool-call` 都会 `retractLive`,发出 `answer.delta` `{text:"", replace:true}`。set-focus 只是把题干挂到同一条消息,前面的打招呼不是过程自述。
- 修复:`rectification-set-focus` 的 call/result 不再收回已播出正文。读诊断、记证据仍收回。
- 验证:`rectification-step-answer`:打招呼 + set-focus 为 `none`,其后短句仍 `live`;read-diagnostics 仍 `retract`。
- 防复发:set-focus 不得把同轮已播出的正文 replace 成空。不得把这个例外扩到 record/compare/diagnostics。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-534 | 分章进度 `section:` 让 job 行无法 round-trip,心跳中断、阅读页误报失败
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`personal-report-job-service-core` `recordFromRow`;心跳 RPC 返回解析;`GET /api/reports/:id` 读 generating 行
- 用户现象:standard personal_full 已经调到引擎并写出至少一章后,阅读页变成 `report_generation_failed` /「报告暂时无法读取」。后台 job 停在 `section:theme-*`,心跳不再刷新,直到租约过期才重试。
- 触发条件:worker `onProgress` 把 `progress_phase` 写成 `section:<section_id>`(SQL 与 `isValidPersonalReportJobProgressPhase` 允许冒号)。随后心跳或 GET 把该行再读进 `recordFromRow`。
- 根因:`recordFromRow` 用 `operationalCodePattern`(`^[a-z][a-z0-9_]{0,63}$`)校验 `progress_phase`,不接受冒号。`updateProgress` 写入成功后解析 RETURNING 行抛 `storage_invalid`;`onProgress` 吞掉非租约错误,生成继续。下一次心跳解析同一行再抛,worker 中止。GET 同一路径变成 HTTP 500。
- 修复:`progress_phase` 改走已有的 `isValidPersonalReportJobProgressPhase`(与 SQL check、写入校验同一套,允许 `section:`)。
- 验证:`personal-report-job-service`:写入 `section:theme-health_pressure` 后心跳与 `getOwnedByRequestId` 仍成功。staging @ `e27d5dc5` 干净一次跑完五章到 assemble 85%,心跳未再中断。
- 防复发:job 行 round-trip 不得比写入校验更严。分章进度相位必须能读回。
- 相关记录:BUG-526、BUG-535
- 复发自:无
- 修复版本:`e27d5dc5`
## BUG-535 | 五章写完后终稿 final_parse_rejected,accepted 报告仍无 ready 文档
- 状态:mitigated
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`assembleReportDocumentV2` 之后的 `safeParseServerReportDocument`;standard `personal_full` 终装
- 用户现象:报告中心显示生成失败。错误码 `report_schema_invalid`,日志 `innerReason=final_parse_rejected`。五章其实都已写完。
- 触发条件:accepted 生时、默认五主题、staging @ `e27d5dc5`。干净 attempt 1 与此前被租约打断后恢复的 attempt 都复现。
- 根因:`c2f23131` 把 `CHART_IDS` 扩到 9 个(加 D6/D8/D30),文档 v2 仍 `charts.max(6)`。分盘提取修好后,五主题装配 9 张图,终稿 parse 拒绝。四主题一直 ≤6 所以从未踩中。
- 修复:v2 `charts.max(CHART_IDS.length)`;JSON Schema / Python 合同同步;装配允许集改为 `CHART_IDS`。`final_parse_rejected` 日志增加 `parsePaths`(仅 path + code)。
- 验证:合同测试 9 图过 parse;五主题分章夹具 READY;四主题不回归;超限仍 `too_big`。staging 真实 standard personal_full 待任务 3。
- 防复发:上限必须绑定 `CHART_IDS.length`,不得再写裸 6/9。扩枚举必须同时改 Zod / JSON Schema enum / Python `CHART_IDS`。终稿失败必须带 parse path。
- 相关记录:BUG-526、BUG-534;引入半拉子改动的提交 `c2f23131`
- 复发自:无
- 修复版本:`61194690`
## BUG-536 | 采用后第一道核对题重复已问过的采集题
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`remainingReverseVerifyProbes`、采用后 `buildMethodFollowupPlan` 的 `accepted` 分支
- 用户现象:采用代表分钟后,第一道核对题与采集阶段已经问过并回答过的带年份题几乎逐字相同。
- 触发条件:采集阶段已按 `semantic_key` 问过该 (domain, year);采用后反向核对从 `eventProbes` 再取第 0 条。
- 根因:冲突探针去重用了 `askedKeys`;BUG-350 单独写下的反向核对漏接。过滤了拒答领域、年份已覆盖、成年下限,没有用 `semantic_key` / `candidate_split_hash` / `domain.year`。
- 修复:`remainingReverseVerifyProbes` 增加 `askedKeys`,与 `remainingConflictProbes` 同口径。已问或已跳过的探针不再作为核对题。
- 验证:`rectification-post-adopt-verify-20260904`:`askedProbeKeys` 含第一条 `semantic_key` 时下一问是另一条;两条都在则 `next_followup === null` 且 `next_user_action.id === "start_consultation"`;只含 `candidate_split_hash` 也能去重。`rectification-eight-method` 采用后有剩余探针 / 无剩余探针两条既有断言仍过。
- 防复发:采用后核对必须走 `askedKeys`。不得只靠 `probeYearAlreadyCovered` 当已问去重。
- 相关记录:BUG-350
- 复发自:无
- 修复版本:待发布
## BUG-537 | 核对卡「这题跳过」等于整案停止,已采用仍念采用提示,前端报没有下一问
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`choice-card` 核对卡 stop 槽、`SKIP_PROBE_ACTION`、`applyRectificationChoice`、`rectificationQuestionGapState`、校正聊天面
- 用户现象:点「这题跳过」后助手又说可以从下面选一个先用着;随后出现「没有拿到下一个问题。」和重新加载。
- 触发条件:已采用,当前焦点是反向核对卡;用户点跳过。
- 根因:三层叠加。(1) 核对卡只把停止按钮改了标签,动作仍是整案 `STOP_ACTION`,`userStopped:true`。(2) 结构化旁白只看 `can_adopt`,已采用后 `publicCanAdopt` 仍为 true,再念一遍 `adoptCue`。(3) 服务端没有核对结束状态,前端把「无焦点 + 可续」当成问题还没准备好。
- 修复:核对卡绑定 `skip_probe`:焦点 `skipped`、探针进 `answered_probes`(不计分)、不置 `userStopped`,再取下一道核对题。已采用禁止 `adoptionNarration` / `adoptCue`。GET 与结构化答题暴露 `next_user_action`;已采用且无下一问时为 `start_consultation`。前端该态为 `verified_idle`:一行 `postAdoptVerifyDone`,无重载、无 handoff。关闭核对结束时不得把已跳过的焦点重新挂到收尾消息上。
- 验证:两道核对题跳第一道 → 下一焦点是第二道,`sessionOutcome` 不是 `user_stopped`,旁白不含 `adoptCue`。跳最后一道 → 无新焦点,`next_user_action.id === "start_consultation"`,旁白等于 `postAdoptVerifyDone`。`verified_idle` 无 gap copy、无重载、无 consult handoff。采用前区分题停止按钮仍是 `STOP_ACTION`。
- 防复发:核对卡「这题跳过」不得走 `STOP_ACTION`。已采用旁白不得再念采用提示。`start_consultation` 不得进 preparing/unavailable。
- 相关记录:BUG-350、BUG-499、BUG-502、BUG-518、BUG-520
- 复发自:无
- 修复版本:待发布
## BUG-538 | 采用旁白承诺的核对与采用后计划不同源
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`adoptDeliveryFacts.post_adopt_verification`、采用旁白模板与 Agent 校验器
- 用户现象:采用卡旁白说会拿若干年前事和几类域外题来核对;采用后一道都没问。
- 触发条件:旁白事实包按 holdout 事件与 `oos_blind_prompts` 生成;采用后实际计划走 `remainingReverseVerifyProbes`。
- 根因:`TASK-rectification-adopt-narration-20260904.md` §4 把 `post_adopt_verification` 写成 holdout + OOS。BUG-350 已定采用后不得把不计分盘外核对当默认下一步。两套规格矛盾,旁白说的是不会发生的事。这是任务书错误,不是执行方偏离 BUG-350。
- 修复:`post_adopt_verification` 改为与采用后计划同一过滤(同一函数、同一 `askedKeys`、同一 `declined`),项为 `{kind:"reverse_verify"; domain; year_label}`。空列表时模板说没有还能核对的前事;校验器把「会拿……核对」判为 `promise` 失败。holdout / OOS 只留在决策收据,不进旁白。
- 验证:`rectification-adopt-narration-20260904` 原 holdout/oos 三项改写为 reverse_verify 且与 `buildMethodFollowupPlan({accepted:true})` 首题一致。空列表 +「会拿……核对」→ `promise`。模板含「没有还能核对的前事」。
- 防复发:旁白承诺必须与 `remainingReverseVerifyProbes` 同源。空列表不得承诺会核对。不得把 holdout/OOS 写回旁白事实包。
- 相关记录:BUG-350、BUG-519、BUG-520、BUG-521、BUG-522、BUG-523
- 复发自:无
- 修复版本:待发布
## BUG-539 | 家庭采集题带年份前缀,又让用户报年份
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`collectionYearFields`、`spokenFollowupForUser` 对 `collect_method_evidence`
- 用户现象:家庭采集题写成「某年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。」既指定了年份又让用户报年份。
- 触发条件:家庭轮转把 dated 探针的 `year_label` 拼进 `USER_COLLECT_QUESTION.family`。
- 根因:`68f0759e` 为 BUG-414/418「带年份的口语问、不出无年份分盘选择卡」把 dated 探针年份塞进采集口语。`USER_COLLECT_QUESTION.family` 本身以「记得大概哪年就行」结尾,拼在一起自相矛盾。答「没有」后出采用卡本身不是缺陷(预算已满、家庭是最后一条必答覆盖线),本单不改该收口。
- 修复:采集口语不再拼「某年前后,」。`probe_year` / `semantic_key` 仍留给计分与去重。不出无年份分盘选择卡的半边保留。
- 验证:家庭采集口语等于 `USER_COLLECT_QUESTION.family`,同时 `probe_year > 0`。答完最后一道区分题后仍落家庭采集焦点,旁白不含探针年份。BUG-418 无年份分盘卡测试仍过。
- 防复发:`collect_method_evidence` 口语不得加年份前缀。不得为去前缀而丢掉 `probe_year` 或放回无年份分盘选择卡。
- 相关记录:BUG-414、BUG-418
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,103 @@
# PROGRESS · 采用后核对线(2026-09-04)
工作树:`.worktrees/rectification-post-adopt-verify-20260904`
分支:`codex/rectification-post-adopt-verify-20260904`
基线:任务书写 `origin/staging` `e27d5dc5`(含 `0be51e65`、`a793edb1`);任务书提交 `ea28521a`。交付前 `origin/staging` 已到 `6b038e6d`(占用 BUG-535),本单 rebase 上去以便快进推 staging。
任务书:`docs/tasks/TASK-rectification-post-adopt-verify-20260904.md`
未改 Python、未 bump Skill(仍 10.0.14)、未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未改 `page.tsx`(2041 行,与 `e27d5dc5` 相同)。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 反向核对 `askedKeys` 去重 | 完成 | BUG-536 |
| 5.2 旁白事实包与采用后计划同源 | 完成 | BUG-538 |
| 5.3 「这题跳过」只跳本题 | 完成 | BUG-537 |
| 5.4 `verified_idle` + `next_user_action` + `postAdoptVerifyDone` | 完成 | BUG-537 |
| 5.5 采集题去年份前缀 | 完成(未否决) | BUG-539 |
| 5.6 记录 | 完成 | — |
## 编号
开工时 `docs/BUG_HISTORY.md` 最大号 BUG-534。本单 **BUG-536~539**。交付前 `origin/staging` 已占用 BUG-535(报告分盘张数上限),故后移。
## 实现要点
- `remainingReverseVerifyProbes` 增加 `askedKeys`(`semantic_key` / `candidate_split_hash` / `domain.year`)。`if (input.accepted)` 与采用旁白共用 `reverseVerifyRemainingForAdopt`。
- `post_adopt_verification` 项改为 `{kind:"reverse_verify"; domain; year_label}`。空列表模板:「采用后没有还能核对的前事…」。校验器空列表 +「会拿…核对」→ `promise`。holdout / OOS 不进旁白。
- 核对卡 `skip_this_probe` 绑 `SKIP_PROBE_ACTION` / `optionId:"skip_probe"`。焦点 `skipped`,探针 `answered_probes` 记 `unsure`/`declined`,**不**置 `userStopped`。采用前停止按钮仍是 `STOP_ACTION`。
- GET 案例与结构化答题返回 `next_user_action`。已采用且无下一问 → `start_consultation`。前端 `verified_idle`:一行 `postAdoptVerifyDone`,无 gap copy、无重载、无 handoff。状态栏仍走 BUG-502。
- 已采用禁止 `adoptionNarration` / `adoptCue`。跳过但还有下一问时,旁白就是下一题干。
- 采集口语不再拼「YYYY 年前后,」。`probe_year` 保留。答「没有」出采用卡不改(预算已满、家庭最后一条必答线)。
- 跳过最后一道核对后,收尾消息**不**把已关闭焦点重新 `set-focus` 挂回去(否则测试夹具会把跳过的核对卡又钉到收尾句上)。
## 偏离
- 任务书 5.3 写「随后跑 `persistNextInterviewIfIdle`」。实际走 `persistApplied` 里已有的 `persistNextInterviewAfterChoice`(skip / accepted 强制续问)。效果相同:下一道核对或收尾,不另开 idle 入口。
- 任务书验收「无新焦点」在假会计里还要断言不再 `set_agentic_rectification_conversation_focus`。夹具的 GET 仍返回已跳过的 active focus;若收尾后再 fallback `linkFocusAskedTurn`,会把旧卡挂到收尾句。已按收尾不挂焦点修。
- 5.5 按决策 7 做了,产品未否决。
- 交付前 `origin/staging` 已占用 BUG-535,本单实落 BUG-536~539,并 rebase 到 `6b038e6d` 以便快进推送。
## 测试
`frontend/src/app/page.tsx` 相对 `e27d5dc5`:2041 → 2041,未增长。
`npx tsc --noEmit`:
```
exit=0
```
聚焦 `tsx --test`(answer-choice / post-adopt-verify / adopt-narration / surface-state / surface-contract / collect-direction / eight-method):
```
# tests 138
# pass 138
# fail 0
# cancelled 0
# skipped 0
# todo 0
exit=0
```
`rectification-post-adopt-verify-20260904` 新文件 6 条:去重、空承诺、`verified_idle`、家庭口语等于 `USER_COLLECT_QUESTION.family`。
契约改写后补跑 decision-authority / agentic-entry / application-billing-contract / collect-stall / server-focus:102/102。
eslint 本单文件:0 error。`rectification-agentic-chat.tsx` 1 条既有 `react-hooks/exhaustive-deps` 警告(`3659519bd`),未扩依赖数组。
全量 `npx tsx --test tests/*.test.ts`(并行,本机有 Docker):
```
# tests 2674
# pass 2661
# fail 13
# cancelled 0
# skipped 0
# todo 0
exit=1
```
13 条里 5 条是本单契约未改完(enum 加 `skip_probe`、家庭采集不再含年份前缀),已改断言后聚焦 102/102。其余 8 条为 Docker 迁移/连接争用:`admin-database`、`database-admin-owner-recovery`、`database-billing-adjustments`、`database-local-business`、`database-report-candidate-range`、`rectification-ingest-p0-database`、`rectification-v9-database`(migration + evidence lifecycle)。`--test-concurrency=1` 隔离重跑上述 7 个文件:
```
# tests 13
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
exit=0
```
有效失败清单为空。总数 2674 不低于近期 staging 全量(collect-direction-fix 记 2656)。
未改 Python;预检系统 Python / swisseph 噪音不当作本单回归。MEVG 豁免:本单是产品/代码,不是运势解读。
## 改动文件
- 计划/旁白:`method-followup.ts`、`adopt-narration.ts`、`adopt-narration-agent.ts`、`user-copy.ts`
- 跳过/收尾:`choice-action.ts`、`choice-card.ts`、`answer-choice.ts`、`interview-state.ts`、`turn-exit.ts`、`message-origin.ts`
- 投影:`cases/[caseId]/route.ts`、`agent/route.ts`、`rectification-surface-state.ts`、`rectification-agentic-chat.tsx`
- 文案:`frontend/DESIGN.md`、`frontend/docs/VOICE.md`
- 测试:`rectification-post-adopt-verify-20260904.test.ts`(新)、answer-choice、adopt-narration、collect-direction、eight-method、surface-state、surface-contract、collect-stall、server-focus、decision-authority、agentic-entry、application-billing-contract
- 类型:`turn-question.ts`、`rectification-choice-card.tsx` 接受 `skip_probe`
- 文档:`docs/BUG_HISTORY.md`、`docs/testing/rectification-post-adopt-verify-20260904.md`、`docs/tasks/README.md`、`CHANGELOG.md`、本文件
@@ -10,7 +10,7 @@
| --- | --- | --- |
| 1 两处读取降级 + 语义修正 | 完成 | BUG-526 |
| 2 只读 RPC + `test:db` | 完成(本文件定向套件已绿;全量 `test:db` 见下方门禁输出) | BUG-526 |
| 3 部署后真实 personal_full | 待部署 | BUG-526 |
| 3 部署后真实 personal_full | 部分完成(引擎与五章正文已通;终稿 `final_parse_rejected`,见 BUG-535) | BUG-526 / BUG-534 / BUG-535 |
| 4 BUG-526 收口 | 完成(修复版本待 staging SHA) | BUG-526 |
## 实现要点
@@ -41,3 +41,43 @@
- 定向:`report-candidate-range` + `personal-report-api` + `personal-report-worker` + migration 源码锁 **75 pass**
- `npm run test:db`(`tsx --test --test-concurrency=1 tests/database-*.test.ts`):**35 pass, 0 fail**(含新 RPC 套件)。第一次全量 34/35,`database-admin-identity` 迁移在套件开头失败,单跑即过,属 Docker 争用。
- 非数据库 `tsx --test tests/*.test.ts`:2626 pass;当时 2 fail 为 CSS 合同(已由 `0be51e65` 修)与 `identity-auth-integration`(与并行 `test:db` 抢 fixture,单跑已过)。
## 任务 3 · staging 实测
health:`e27d5dc5`(含 BUG-534),`latestMigration=20260904010000_read_report_candidate_range.sql`。RPC 对 `candidate_accepted` 行返回窗口。无 `report_candidate_range_unavailable`。
### 第一次(`3c4d8311`,`be6cf145` 镜像)
| 项 | 结果 |
| --- | --- |
| 创建 | generating,未在 16 秒内 `calculation_unavailable` |
| 引擎 | 5× `POST /api/consultation_workflow` 200 |
| 章节 | career / health 先落库;随后心跳死在 `section:theme-health_pressure`(BUG-534) |
| telemetry | career `stop` `inputTokens=5866`;health 首次 `tripwire`×2(无 `length`) |
| 阅读页 | GET 500 假失败(同一 `recordFromRow`) |
租约曾延长,避免第三次耗尽被标成 `calculation_unavailable`。
### 第二次(`20c94bf4`,`e27d5dc5` 干净 attempt 1)
| 项 | 结果 |
| --- | --- |
| 引擎 | 5× `consultation_workflow` 200(12:32:43–12:34:12Z) |
| 五章 | 全部 ready,narrative 757–1960 字,均有 `evidenceRefs` |
| 心跳 | 一次跑到 85% assemble,BUG-534 已不再打断 |
| 终稿 | `report_schema_invalid` / `final_parse_rejected`(不是 guard,不是 evidenceHash) |
| telemetry | 无 `finishReason=length`(五章 + 摘要均为 `stop`) |
Writer 回溯(只记 evidenceRef,不记正文):
| 章 | 绑定的 bundle 技法 |
| --- | --- |
| career | `ev-tech-d10` `ev-tech-a10` `ev-tech-amk` `ev-tech-vimshottari` |
| marriage | `ev-tech-d9` `ev-tech-ul` `ev-tech-dk` |
| wealth | `ev-tech-d2` `ev-tech-d11` `ev-tech-yoga` `ev-tech-ashtakavarga` |
### 2 倍线(对照 `PROGRESS-report-skill-parity-20260901.md` 改前 p50 est.tok 1135.5,2×=2271)
干净一次的五章 `inputTokens`:5804 / 5866 / 5921 / 6031 / 6405。p50=**5921 ≈ 5.22×**。墙钟 7.9–16.8s。摘要 1542 token / 2.9s。按红线 6 摆数据,不记成通过。
敏感度:RPC 有窗、无降级 warn、引擎 200;assemble 会在 sensitive 主题追加条件句。终稿未落库,无法从文档字段再确认 `candidate_window_only`。
@@ -0,0 +1,59 @@
# PROGRESS · BUG-535 文档 charts 上限绑定 CHART_IDS(2026-09-04)
工作树:`/Users/jesse/Downloads/Copse/astrology/.worktrees/report-chart-cap-20260904`
分支:`codex/report-chart-cap-20260904`
基线:任务书写 `e674cd38`;开工 `origin/staging` HEAD 为 `d2955c62`(含本任务书 `40387ff9`)。
任务书:仓库根 `TASK-report-chart-cap-20260904.md`
未改 `.gitea/workflows/**`,不提升 main,未放宽字段级校验,未改 v1 `charts.max(3)`。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 1 上限绑定 + 消费点核对 + 回归 | 完成 | BUG-535 |
| 2 终稿 parse path 日志 | 完成 | BUG-535 |
| 3 部署后真实 personal_full | 待 health SHA 对齐后做 | BUG-535 |
## 实现要点
- v2 文档 `charts.max(CHART_IDS.length)`,单一真源,不写裸数字 9。
- 装配侧 `assembleReportDocumentV2` 的允许分盘集合改为 `new Set(CHART_IDS)`;`DOCUMENT_VARGA_CHART_IDS` 从 `CHART_IDS` 去掉 `D1` 派生。
- 终稿 `safeParseServerReportDocument` 失败时,`generation_failed` 增加 `parsePaths: [{path, code}]`。只记路径与 zod/guard 代号,不记 `message`、不记正文或 hash 值。
- JSON Schema `maxItems` / chart `enum`、Python `CHART_IDS` 与 `len(CHART_IDS)` 与 TS 对齐(合同三份真源)。冻结 skill 包 `6.9.14` / `6.9.15` 未动。
## 消费点核对(红线 3)
| 点 | 结论 |
| --- | --- |
| `personal-report-contract.ts` `z.enum(CHART_IDS)` + `charts.max` | 上限已绑定 `CHART_IDS.length`;枚举本来就是 9 个 |
| `REQUIRED_THEME_CHARTS` | 按主题要哪些分盘,不是张数上限;未改 |
| `personal-report-generation.ts` `DOCUMENT_VARGA_CHART_IDS` | 已改为从 `CHART_IDS` 去掉 D1 |
| 同文件装配 `Set([...])` | 已改为 `Set(CHART_IDS)` |
| `canonicalDocumentVargaChartId` 正则 | 引擎别名提取,不是计数上限;当前覆盖 D2/D6/D8/D9/D10/D11/D24/D30 |
| `report-evidence-bundle-v2.ts` `charts.max(24)` | bundle 上限 24,大于文档 9,无脱节 |
| `personal-report-document-view.tsx` | 按数组渲染,无硬编码 6 |
| `personal-report-route-core.ts` `themes.max(6)` | 主题数不是分盘数 |
| `report-theme-evidence-plan.ts` health `chartIds` | 证据计划,不是文档张数 |
| `contracts/personal-report/report-document.v2.schema.json` | 原 `maxItems: 6` 且 enum 缺 D6/D8/D30;已对齐,测试锁 `maxItems === CHART_IDS.length` |
| `scripts/personal_report_contract.py` | 原 enum 6 个、硬编码 `else 6`;已 `len(CHART_IDS)` 且补 D6/D8/D30 |
| `skills/.../6.9.14` 与 `6.9.15` 的合同副本 | 冻结包,仍是历史 v1 三盘;未改 |
## 测试
- 9 张全枚举文档过 `safeParseServerReportDocument`
- 超过 `CHART_IDS.length` 仍 `too_big`
- 四主题既有用例不回归
- 五主题(含 health)分章管线 READY,文档 charts 正好 9 张、blocked 为空
- `final_parse_rejected` 日志含 `thematicNarrative.0.title` / `too_big`,不含超长标题正文
- Python `test_v2_accepts_full_chart_ids_set`
## 门禁输出
- `./node_modules/.bin/tsc --noEmit`:0
- `npm run lint`:0 error / 74 warning(既有,未动)
- 定向:`personal-report-contract` + `personal-report-generation-v2` + `personal-report-api`:**110 pass / 0 fail**(含新增 9 图 parse、五主题 READY、超限 too_big、parsePaths 日志)
- `.venv/bin/python -m pytest tests/test_personal_report_contract.py`:**40 pass**(含 `test_v2_accepts_full_chart_ids_set`)
- 全量 `npm test`:Docker 库测争用超时(`database-*` 与部分会起 Postgres 的套件),与既有环境缺口同类;与本单无关
- `next build`:本单未改 UI/路由,未跑;任务 3 部署后用 health SHA 验收
## 让步
无。JSON Schema 的 `maxItems` 只能写数字,用合同测试把它锁到 `CHART_IDS.length`。
+3 -1
View File
@@ -49,6 +49,7 @@
| `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 只能改写成不指向任何领域的泛问;缺第三件带年份的事却先问职业 | 已验收:5.1/5.3/5.4 通过;5.2 顺序错位(任务书责任)见修复单 | `codex/rectification-collect-direction-20260904`(BUG-527~530) |
| `TASK-rectification-collect-direction-fix-20260904.md` | `PROGRESS-rectification-collect-direction-fix-20260904.md` | dated 补采集分支插在方法轮转之前,新案例第二问被家人抢占感情/事业;`7ee7f825` CSS 与合同测试脱节挡住门禁 | 已验收通过(0be51e65),待 staging 部署核对 | `codex/rectification-collect-direction-fix-20260904`(BUG-531~532) |
| `TASK-rectification-post-adopt-verify-20260904.md` | `PROGRESS-rectification-post-adopt-verify-20260904.md` | 采用后核对题重复已问题;核对卡「这题跳过」等于整案停止、已采用仍念"选一个先用着"、前端"没有拿到下一个问题";采用旁白承诺的 holdout/OOS 核对与实际计划不同源(原任务书 §4 错误);家庭采集题带年份前缀自相矛盾 | 待验收 | `codex/rectification-post-adopt-verify-20260904`(BUG-536~539) |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) |
### 聊天主链路与首页
@@ -75,7 +76,8 @@
| `TASK-report-blocked-repairs-20260902.md` | `PROGRESS-report-blocked-repairs-20260902.md` | 全主题 blocked 修复 | 已验收 | `7faf8555` |
| `TASK-report-section-writer-failure-20260902.md` | `PROGRESS-report-writer-failure-20260902.md` | 写作阶段 report_schema_invalid | 已验收 | `eda37c15`(后续 `43294265`、`5c0bec0c`、`fbd6e480`、`cf6405ed`) |
| `TASK-report-sensitivity-crash-20260904.md`(仓库根) | `PROGRESS-report-sensitivity-crash-20260904.md` | consultation_workflow float 时辰崩溃,全量 500 | 执行中 | `codex/report-sensitivity-crash-20260904`(BUG-524) |
| `TASK-report-candidate-range-read-20260904.md`(仓库根) | `PROGRESS-report-candidate-range-20260904.md` | accepted 生时报告直读已收权校正表 | 待验收 | `codex/report-candidate-range-20260904`(BUG-526) |
| `TASK-report-candidate-range-read-20260904.md`(仓库根) | `PROGRESS-report-candidate-range-20260904.md` | accepted 生时报告直读已收权校正表 | 待验收 | `e27d5dc5`(BUG-526/534;终稿见 BUG-535) |
| `TASK-report-chart-cap-20260904.md`(仓库根) | `PROGRESS-report-chart-cap-20260904.md` | 文档 charts 上限绑定 CHART_IDS | 执行中 | `61194690`(BUG-535) |
### 前端基础与工程
@@ -0,0 +1,110 @@
# TASK · 采用后核对线:不重复已问题、「这题跳过」只跳本题、核对结束有收尾、采集题去年份前缀(2026-09-04)
## 0. 基线
- 基线:`origin/staging` @ `e27d5dc5`(含 `0be51e65` 补采集顺序修复、`a793edb1` BUG-533)。
- 开工分支:`codex/rectification-post-adopt-verify-20260904`,worktree `.worktrees/rectification-post-adopt-verify-20260904`。
- 串行关系:本任务书改 `method-followup.ts`、`answer-choice.ts`、`rectification-agentic-chat.tsx`,与当天其他校正任务书无并行;`TASK-rectification-collect-direction-fix-20260904.md` 已合入基线,不再冲突。
- BUG 编号起点:任务书写 **BUG-535**;交付前 `origin/staging` 已占用该号(报告 chart-cap `61194690`),本单实落 **BUG-536~539**。
## 1. 事故实证(staging,真实案例,资料已抽象)
同一案例在采用代表分钟之后,四个现象连着出现。以下只写形状,不写年份、事件与候选分钟。
| # | 用户看到的 | 期望 | 定位(符号) |
| --- | --- | --- | --- |
| A | 采用后第一道"核对题",是采集阶段**已经问过并回答过**的带年份职业题,题干几乎逐字重复 | 采用后核对只问**还没问过**的可计分前事 | `v9/method-followup.ts` `remainingReverseVerifyProbes`(L548–571)过滤了拒答领域、`probeYearAlreadyCovered`、成年下限,**没有**用 `askedKeys`(L554 起同函数的 `remainingConflictProbes` 用了 L612)。`if (input.accepted)` 分支(L1722)直接取第 0 条 |
| B | 点「这题跳过」后,机器人又说"剩下的题分不开当前候选……可以从下面选一个先用着。",随后界面出现"没有拿到下一个问题。"和重新加载按钮 | 跳过这一题 → 出下一道核对题;没有了 → 一句收尾,界面安静 | `v9/choice-card.ts` L402/L549 与 `components/rectification-agentic-chat.tsx` L326:核对卡的 stop 按钮只是**改了标签**为 `CHOICE_SKIP_QUESTION_LABEL`,动作仍是 `STOP_ACTION`;`v9/answer-choice.ts` L265–289 `optionId==="stop"` → `focusStatus:"skipped"` + `userStopped:true`;`core/rectification-decision.ts` L276 `userStopped` → `completeWithRange` → `publicCanAdopt` 仍 true → `answer-choice.ts` L1035–1061 走 `adoptionNarration`(带 `adoptCue`),不看 `case.acceptedTime`;无下一焦点 → `lib/rectification-surface-state.ts` L250 `rectificationQuestionGapState` 走 preparing → unavailable |
| C | 采用卡旁白承诺"采用后会拿 X 年的教育题以及家庭、财务、健康压力三类域外题来核对",采用后一道都没问 | 旁白只承诺服务端真的会问的题 | `v9/adopt-narration.ts` L161–173 `post_adopt_verification` = holdout 事件 + `oos_blind_prompts`;而采用后的计划(A 行)用的是 `remainingReverseVerifyProbes`,两者是两套数据源。这一段是 `TASK-rectification-adopt-narration-20260904.md` §4 我方规格错误,见 §3 决策 3 |
| D | 家庭采集题写成"YYYY 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。"——既指定了年份又让用户报年份;答"没有"后直接收口 | 采集题不带年份前缀;"没有"后收口本身是**设计如此**(见 §2 D) | `method-followup.ts` L1941–1954 家庭轮转分支塞入 `collectionYearFields(familyCollect)`;`spokenFollowupForUser` L999–1001 把 `year_label`/`probe_year` 拼成"YYYY 年前后,"前缀到 `USER_COLLECT_QUESTION.family` |
## 2. 根因
- **A**:采用前的探针去重(`askedProbeKeysFromReceipt` → `askedKeys`)只接到了冲突探针与轮转,反向核对分支是 BUG-350 单独写的,漏接。引擎的 `eventProbes` 里同一 (domain, year) 的题在采集阶段已按 `semantic_key` 问过,采用后又被当"未核对"取出。
- **B**:两层叠加。(1) 核对卡的「这题跳过」是纯标签替换,语义仍是"整案停止";(2) 结构化答案的旁白选择只看 `nextAction.can_adopt`,已采用后 `publicCanAdopt` 依旧 true,于是又念一遍"选一个先用着"。服务端没有"核对结束"这个状态,前端也没收到 `next_user_action`(仅 Agent 工具投影 `mastra/rectification-v9-tools.ts` L265/L316),只能把"无焦点 + 可续"当成"问题还没准备好"。
- **C**:采用旁白的事实包按我方任务书 §4 用 holdout + OOS 生成,但 BUG-350 已定"采用后不得把不计分盘外核对当默认下一步",采用后真正会问的是 `remainingReverseVerifyProbes` 的结果。两个规格互相矛盾,旁白说的是不会发生的事。
- **D(前半,年份前缀)**:`68f0759e` 为了满足 BUG-414/418"带年份的口语问、不出无年份 D24 卡",把 dated 探针的年份塞进家庭采集题;`USER_COLLECT_QUESTION.family` 本身以"记得大概哪年就行"结尾,两者拼在一起就自相矛盾。
- **D(后半,答"没有"即收口)**:**不是缺陷**。该案例已答满 5 轮探针预算,`relatives` 是 `BLOCKING_COVERAGE_IDS` 里最后一条未覆盖线;用户答"没有"记为该领域已回应,覆盖闭合、预算耗尽、剩余探针分不开候选,`decideRectification` 走 `completeWithRange` → 采用卡。这道题本来就是"开放报事"而不是选择题:用户若报出一件带年份的事,会作为新证据进入账本重算并可能再出区分题;答"没有"就等于这条线没有可用证据。本任务书**不改**这个收口逻辑,只把这个解释写进 `docs/testing/` 清单,让真人验收知道预期。
## 3. 决策记录(产品负责人 2026-09-04 授权)
1. **保留 BUG-350 的采用后设计**:采用后默认只问最多 `MAX_REVERSE_VERIFY` 道**计分**核对题,不把 holdout / OOS 盘外核对当默认下一步。本任务书不推翻它。
2. **反向核对必须做已问去重**:`remainingReverseVerifyProbes` 增加 `askedKeys` 过滤(`semantic_key` / `candidate_split_hash` / `probe_id` 三种键,与 `remainingConflictProbes` L612、L765–767 同口径)。已问过的题不得再作为核对题出现,无论它在采集阶段是被回答还是被跳过。
3. **推翻 `TASK-rectification-adopt-narration-20260904.md` §4 的 `post_adopt_verification` 规格**(该规格是我方错误):`post_adopt_verification` 改为由**与采用后计划完全相同**的过滤结果生成(同一函数、同一 `askedKeys`、同一 `declined`),输出 `{domain, year_label}`;当结果为空,事实包必须给出 `post_adopt_verification: []`,旁白改说"采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。"(模板兜底与 Agent 版都要遵守;校验器把"会拿……核对"类承诺在空列表时判为 `promise` 失败)。holdout / OOS 只保留在决策收据里,不再进旁白。
4. **核对卡的「这题跳过」只跳本题**:新增 `SKIP_PROBE_ACTION`(或等价的 `optionId:"skip_probe"`),语义 = 该焦点 `skipped` + 该探针进 `answered_probes`(记 declined-for-verify,不计分)+ **不置 `userStopped`**;随后按第 2 条重新取下一道核对题。采用前的区分题/采集题的停止按钮语义**不变**(仍是 `STOP_ACTION`)。
5. **核对结束是显式状态**:服务端在 GET 案例投影与结构化答题返回里都暴露 `next_user_action`(现在只有 Agent 工具投影有);已采用且没有下一道核对题时 `next_user_action.id === "start_consultation"`。前端收到该状态:不进 `rectificationQuestionGapState` 的 preparing/unavailable,不出重载按钮,只显示一行收尾文案(见第 6 条),状态栏保持 BUG-502 的"已采用 … · 改选 / 之后新建对话即按此时间排盘。",**不加**任何新按钮、不出 `rectification-consult-handoff`(BUG-499/502 红线)。
6. **已采用后的旁白不得再念采用提示**:`answer-choice.ts` 的旁白选择必须先看 `case.acceptedTime`;已采用时禁止 `adoptionNarration` / `adoptCue` / `completedRangeNarration`,改用一条收尾文案(放进 `RECTIFICATION_USER_COPY`,新键 `postAdoptVerifyDone`,措辞对照 `frontend/docs/VOICE.md`,建议:"前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。")。核对题被跳过但还有下一题时,旁白就是下一题,不加收尾。
7. **采集题去掉年份前缀(产品决定,可否决)**:`collectionYearFields` 不再喂给采集题的口语期间;`spokenFollowupForUser` 对 `collect_method_evidence` 不拼"YYYY 年前后,"。BUG-418 的另一半(不出无年份 D24 选择卡)**保留**:dated 探针的年份仍写进 `probe_year` 供计分与去重,只是不进用户题干。若产品负责人否决此条,执行方把本条标记 skipped 并保留现状,其余任务不受影响。
8. 答"没有"即收口的逻辑**不改**(§2 D 后半);只补真人验收清单的说明。
## 4. 硬红线
- BUG-350(采用后不默认 OOS/holdout)、BUG-463(holdout 只在 `not_started && !canAdopt`)、BUG-499/502(无 handoff 组件、无「用这个时间看盘」按钮、状态栏文案不变)、BUG-518、BUG-520(`declinedDomains` 只认 collect 意图)全部保留,任何一条被改都要先回到本任务书决策记录。
- `frontend/src/app/page.tsx` 不增长;无第二套加载动画;核对结束状态不得出现 spinner / 骨架 / "正在加载"。
- 不得把真实案例的年份、事件、候选分钟、案例 ID 写进测试、Bug 历史、进度记录、CHANGELOG;测试用抽象形状或公开名人数据。
- 改任何既有断言写"原值 / 新值 / 原因"三栏;测试总数不低于基线实测。
- 不顺手改 `.gitea/workflows/**`、不动迁移、不升级依赖。
- Agent 旁白(adopt narration)的校验器只能收紧不能放宽。
## 5. 任务分解
### 5.1 反向核对去重(决策 2)
- 改 `remainingReverseVerifyProbes` 增加 `askedKeys` 参数并在 `if (input.accepted)` 分支传入 L1554 的 `askedKeys`。
- 验收:新测试 `tests/rectification-post-adopt-verify-20260904.test.ts`:
- 给 `accepted:true`、`eventProbes` 两条(domain X/year Y1,domain Z/year Y2),`askedProbeKeys` 含第一条的 `semantic_key` → `next_followup.domain === Z`;
- 两条都在 `askedProbeKeys` → `next_followup === null` 且 `buildNextUserAction(...).id === "start_consultation"`;
- `askedProbeKeys` 只含 `candidate_split_hash` 也能去重。
- `tests/rectification-eight-method.test.ts` L1750/L1790 既有断言不变。
### 5.2 旁白事实包与采用后计划同源(决策 3)
- `adoptDeliveryFacts` 的 `post_adopt_verification` 改由 `remainingReverseVerifyProbes`(同 `askedKeys`、`declined`)生成;类型改为 `{kind:"reverse_verify"; domain; year_label}`;模板兜底与 Agent prompt 相应改口。
- `tests/rectification-adopt-narration-20260904.test.ts` L684–688 三条断言改写,三栏说明:原值"holdout/oos 出现在 post_adopt_verification";新值"仅出现采用后真会问的 reverse_verify 项,且与 `buildMethodFollowupPlan({accepted:true})` 首题一致";原因"决策 3 推翻原任务书 §4"。
- 验收:新增用例——探针全部已问 → `post_adopt_verification` 为 `[]`,`validateAdoptNarration` 对含"会拿……核对"的文本返回 `promise` 失败;模板兜底输出含"没有还能核对的前事"。
### 5.3 「这题跳过」只跳本题(决策 4)
- `choice-action.ts` 新增动作;`choice-card.ts` 核对卡(`kind==="reverse_verify"`)的 stop 槽位改绑新动作,标签沿用 `CHOICE_SKIP_QUESTION_LABEL`;`rectification-agentic-chat.tsx` L326 同步;`answer-choice.ts` 新分支:`focusStatus:"skipped"`、探针写入 `answered_probes`(标记 skipped,不计分)、`userStopped` 不置位、随后跑 `persistNextInterviewIfIdle` 取下一题。
- 验收:
- 采用后有两道核对题 → 跳第一道 → 返回的下一焦点是第二道,`sessionOutcome` 不变为 `user_stopped`,旁白不含 `adoptCue`;
- 跳最后一道 → 无新焦点,`next_user_action.id === "start_consultation"`,旁白等于 `RECTIFICATION_USER_COPY.postAdoptVerifyDone`;
- 采用前区分题的停止按钮仍触发 `STOP_ACTION`(既有测试不改)。
### 5.4 核对结束显式状态 + 前端安静收尾(决策 5、6)
- GET 案例投影与结构化答题返回增加 `next_user_action`;`rectificationQuestionGapState` 增加输入 `nextUserActionId`:为 `start_consultation` 时返回新态 `"verified_idle"`(不 preparing、不 unavailable、不重试);组件在该态渲染一行 `postAdoptVerifyDone`,不渲染重载按钮。`frontend/DESIGN.md` 记录该态;`VOICE.md` 对照文案。
- 验收:
- `tests/rectification-surface-state*.test.ts`:已采用 + 无焦点 + `start_consultation` → `verified_idle`;已采用 + 无焦点 + 无 `next_user_action`(旧数据)→ 仍走原路径(不回归);
- 组件测试:`verified_idle` 下 DOM 无 `rectification-question-gap__copy`、无重载按钮、无 `rectification-consult-handoff`;状态栏文案与 BUG-502 断言一致。
### 5.5 采集题去年份前缀(决策 7,可否决)
- `spokenFollowupForUser` 对 `collect_method_evidence` 不再拼期间前缀;家庭轮转分支保留 `probe_year` 但不填 `year_label`。
- 验收:`tests/rectification-collect-direction-20260904.test.ts` 中断言家庭题以"YYYY 年前后,"开头的用例(若有)三栏改写;新增:家庭采集题文本等于 `USER_COLLECT_QUESTION.family`,同时 `probe_year > 0`;BUG-418 的"不出无年份 D24 卡"测试保持通过。
### 5.6 记录
- `docs/BUG_HISTORY.md` 从 BUG-536 起:A(重复核对题)、B(跳过=停止 + 已采用仍念采用提示 + 前端 unavailable)、C(旁白承诺与计划不同源,关联 BUG-519~523 并写明原任务书 §4 是根因)、D 前半(年份前缀,关联 BUG-414/418)。每条含现象、触发、根因、修复、验证、防复发。
- `docs/testing/rectification-post-adopt-verify-20260904.md`:真人清单——采用后核对题不重复;「这题跳过」出下一题;最后一题跳过后一行收尾且无重载按钮;家庭题无年份前缀;**答"没有"后直接出采用卡属预期**(写明原因:预算已满、家庭是最后一条必答线)。
- `CHANGELOG.md`、`docs/tasks/PROGRESS-rectification-post-adopt-verify-20260904.md`(含 tsc / lint / 测试六行汇总 + 退出码、无 Docker 时失败清单与基线逐条比对)。
## 6. 让步顺序
5.1 → 5.2 → 5.3 → 5.4 → 5.6 必做;5.5 可被产品否决或最后做。5.4 若前端投影改动超预期,允许先只做服务端 `next_user_action` 暴露 + 旁白收尾(决策 6),前端 `verified_idle` 拆到修复单,但必须在进度记录里写明,且 B 现象中的"没有拿到下一个问题"在此情况下**不得**写成已修。
## 7. 开工前置命令
```bash
git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/rectification-post-adopt-verify-20260904 .worktrees/rectification-post-adopt-verify-20260904 origin/staging
cd .worktrees/rectification-post-adopt-verify-20260904/frontend && ln -s /workspace/Jyotisha/frontend/node_modules node_modules
grep -o "BUG-5[0-9][0-9]" ../docs/BUG_HISTORY.md | sort -u | tail -1
grep -n "BUG-350\|BUG-463\|BUG-499\|BUG-502\|BUG-518\|BUG-520" ../docs/BUG_HISTORY.md | head
npx tsx --test tests/*.test.ts 2>&1 | tail -8 # 记基线失败清单
```
## 8. BUG 编号起点
BUG-536~539(交付前 `origin/staging` 已占用 BUG-535;基线任务书最大号曾是 BUG-534)。
@@ -0,0 +1,57 @@
# Staging 人肉复核 · 采用后核对线(2026-09-04)
给产品负责人。实现者无法在真实校正会话里点选。测之前先做第 0 条。
本单对应 BUG-536~539。不要对照真实案例里的年份、事件或候选分钟写进任何记录。
## 0. 确认测的是新版本
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
状态栏在已采用之后仍应是「已采用 {时间} · 改选 / 之后新建对话即按此时间排盘。」不要找「用这个时间看盘」按钮,也不该出现咨询交接条。
## 1. 采用后核对题不重复已问过的采集题(P0 · BUG-536)
1. 新开生时校正,按平常走完采集与区分,直到出现采用卡。
2. 采用一个代表分钟。
- ✅ 预期:若还有核对题,题干**不是**采集阶段已经问过并回答过的那道带年份题(领域+年份同一条不得再出)。
- ❌ 改造前:采用后第一道核对几乎逐字重复采集阶段的职业题。
## 2. 「这题跳过」只跳本题(P0 · BUG-537)
若采用后出现反向核对选择卡:
1. 点「这题跳过」(不要点采用前区分题上的「先这样,先看当前范围」)。
- ✅ 预期:案例不停;不出现「可以从下面选一个先用着」;若还有下一道核对题,正文就是下一题,下方仍是选择卡。
- ❌ 改造前:整案当成停止,再念一遍采用提示,随后「没有拿到下一个问题。」和重新加载。
## 3. 最后一道核对跳过后安静收尾(P0 · BUG-537)
1. 把采用后剩余核对题都跳过,或跳过当时仅剩的最后一道。
- ✅ 预期:一行收尾「前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。」没有「没有拿到下一个问题。」、没有重新加载、没有转圈、没有咨询交接。状态栏仍是已采用那句。
- ❌ 改造前:收尾走问题缺口,强迫刷新。
## 4. 采用旁白不承诺不会问的核对(P1 · BUG-538)
1. 在探针池耗尽、采用卡刚出来时读旁白(点选最后一道区分题,或下面第 5 条的「没有」路径)。
- ✅ 预期:若采用后不会再问核对题,旁白说没有还能核对的前事,并提到之后新建对话即按此时间排盘。不得写「会拿某年某题 / 域外题来核对」。
- ✅ 预期:若旁白点了具体核对领域和年份,采用后第一道核对应是同一类题,而不是 holdout / 盘外核对。
## 5. 家庭采集题无年份前缀;答「没有」出采用卡是预期(P1 · BUG-539)
若走到家人结婚/添丁/住院这类口述采集:
1. 看题干。
- ✅ 预期:等于「家里如果有结婚、添丁或住院这类事,记得大概哪年就行。」**不要**以「某年前后,」开头。
- ❌ 改造前:既指定年份又让用户报年份。
2. 在已经答满区分预算、家人是最后一条必答覆盖线时,回答「没有」。
- ✅ 预期:同一轮出现采用卡。这是设计如此,不是漏问:这条线没有可用证据,覆盖闭合后剩余探针分不开候选。
- ❌ 不要把这条报成本单回归。本单不改这个收口。
## 6. 采用前区分题的停止按钮未改(回归)
若还走得到采用前的区分卡:
1. 停止槽位仍应是整案停下来看当前范围,而不是「这题跳过」。
- ✅ 预期:点了之后进入采用/区间,而不是跳到下一道核对题。
+2 -1
View File
@@ -228,6 +228,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`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` after adopted reverse-verify is exhausted; 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 | disabled, stop visible |
| `candidates` | the offer cards under the message that offered them | enabled |
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | disabled during the chain |
@@ -472,7 +473,7 @@ or user IDs.
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载。
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
+1
View File
@@ -31,6 +31,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。 | 我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。 | 承认不确定,不训人。 |
| 当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 | 已经从最初的 30 分钟收到 05:00–05:07 这 7 分钟。代表分钟是 05:00,这只是代表性候选,不是已确认的唯一出生分钟。 | 同一边界语义,带进度,少法务腔。 |
| 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 |
| 采用后会拿盘外核对来验证。 | 前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 已采用且没有下一道核对题时用 `postAdoptVerifyDone`,不再念 `adoptCue`。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
@@ -18,7 +18,7 @@ import {
} from "@/lib/rectification-agentic/v9/answer-choice";
import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent";
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { CHOICE_ACTION, SKIP_PROBE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run";
import { safePublicEvent } from "@/lib/rectification-agentic/v9/stream-mapping";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "@/lib/rectification-agentic/v9/case-status";
@@ -82,14 +82,14 @@ const agentRequestSchema = z.object({
caseId: z.string().uuid(),
sessionId: z.string().uuid(),
requestId: z.string().uuid(),
action: z.enum(["opening", "message", "read_only", "answer_choice", "stop_and_review"]),
action: z.enum(["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"]),
message: z.string().trim().min(1).max(4000).optional(),
modelId: z.string().trim().min(1).max(64).optional(),
actionId: z.string().uuid().optional(),
focusId: z.string().uuid().optional(),
questionId: z.string().trim().min(1).max(200).optional(),
probeId: z.string().trim().min(1).max(200).nullable().optional(),
optionId: z.enum(["A", "B", "C", "D"]).optional(),
optionId: z.enum(["A", "B", "C", "D", "skip_probe"]).optional(),
expectedRevision: z.number().int().min(0).max(10_000).optional(),
origin: z.enum([
"typed",
@@ -300,11 +300,19 @@ export async function POST(request: Request) {
caseId,
sessionId,
actionId,
action: action === "stop_and_review" ? STOP_ACTION : CHOICE_ACTION,
action: action === "stop_and_review"
? STOP_ACTION
: action === "skip_probe"
? SKIP_PROBE_ACTION
: CHOICE_ACTION,
focusId,
questionId: parsed.data.questionId,
probeId: parsed.data.probeId ?? null,
optionId: action === "stop_and_review" ? "stop" : parsed.data.optionId!,
optionId: action === "stop_and_review"
? "stop"
: action === "skip_probe"
? "skip_probe"
: parsed.data.optionId!,
expectedRevision,
narrateAdopt,
});
@@ -327,6 +335,7 @@ export async function POST(request: Request) {
sourceQuote: applied.sourceQuote,
derivedContext: applied.derivedContext,
nextAction: applied.nextAction,
next_user_action: applied.nextUserAction,
nextInterviewPersisted: applied.nextInterviewPersisted,
nextChoiceReady: applied.nextChoiceReady,
});
@@ -600,7 +609,7 @@ export async function POST(request: Request) {
);
return response;
}
if (action === "answer_choice" || action === "stop_and_review") {
if (action === "answer_choice" || action === "stop_and_review" || action === "skip_probe") {
return NextResponse.json(
{ error: "选择题处理失败", message: "请稍后重试。" },
{ status: 500 },
@@ -14,7 +14,7 @@ import {
type ConversationFocusList,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } from "@/lib/rectification-agentic/v9/interview-state";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision, nextUserActionFromDossier } from "@/lib/rectification-agentic/v9/interview-state";
import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision";
import { attachQuestionsToTurns, attachOfferResultToTurns } from "@/lib/rectification-agentic/v9/turn-question";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
@@ -147,6 +147,7 @@ export function dossierResponse(
},
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: choiceCardFromCaseDossier(dossier),
next_user_action: nextUserActionFromDossier(dossier),
question_source: questionSourceFromFocusList(listed),
};
}
@@ -70,12 +70,14 @@ import {
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
isStructuredChoiceUserText,
shouldContinueAfterStructuredChoice,
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
@@ -272,7 +274,7 @@ function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[])
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
selected: ChoiceKey | "stop" | "typed",
selected: ChoiceKey | "stop" | "skip_probe" | "typed",
): RenderMessage[] {
return current.map((message) => {
const question = message.question;
@@ -329,6 +331,7 @@ function choiceCardFromQuestion(
probe_id: question.probe_id,
case_revision: null,
focus_id: question.focus_id,
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
@@ -418,8 +421,14 @@ type CaseSnapshotState = Readonly<{
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
nextUserActionId: string | null;
}>;
function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
const id = payload?.next_user_action?.id;
return typeof id === "string" && id.trim() ? id.trim() : null;
}
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
if (!payload) return null;
@@ -433,6 +442,7 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
nextUserActionId: nextUserActionIdFromSnapshot(payload),
};
}
@@ -470,6 +480,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [openingRequested, setOpeningRequested] = useState(false);
@@ -628,6 +639,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
choice_card?: unknown;
turns?: unknown;
question_source?: unknown;
next_user_action?: { id?: unknown };
case?: {
status?: unknown;
accepted_time?: unknown;
@@ -643,11 +655,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
const nextActionId = typeof payload.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setCaseSnapshotLoaded(true);
if (confirmedTime) {
setSavedTime(confirmedTime);
@@ -1051,8 +1067,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}, []);
const submitStructuredChoice = useCallback(async (
action: typeof CHOICE_ACTION | typeof STOP_ACTION,
optionId: ChoiceKey | "stop",
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION,
optionId: ChoiceKey | "stop" | "skip_probe",
override?: Readonly<{
focusId: string;
questionId: string | null;
@@ -1124,6 +1140,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
const payloadNextActionId = typeof payload?.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
onCompleted?.();
const snapshot = await loadCaseSnapshot();
const turns = snapshot?.turns ?? [];
@@ -1392,6 +1412,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable",
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
nextUserActionId,
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
@@ -1447,6 +1468,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
function submitStop() {
if (!choiceCard) return;
if (choiceCard.skip_this_probe) {
void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe");
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
}
@@ -1622,6 +1647,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</Button>
</div>
)}
{questionGap === "verified_idle" && (
<p className="rectification-pending-note" role="status">
{RECTIFICATION_USER_COPY.postAdoptVerifyDone}
</p>
)}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
已确认校正时间:{savedTime}
@@ -12,7 +12,7 @@ type RectificationChoiceCardProps = Readonly<{
card: ChoiceCard;
pending: boolean;
disabled: boolean;
selectedKey?: ChoiceKey | "stop" | "";
selectedKey?: ChoiceKey | "stop" | "skip_probe" | "";
onSelect: (key: ChoiceKey) => void;
onStop: () => void;
variant?: "card" | "embedded";
@@ -20,7 +20,7 @@ type RectificationChoiceCardProps = Readonly<{
}>;
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "">("");
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
const selectedKey = props.selectedKey || localSelected;
const answered = Boolean(selectedKey);
@@ -77,7 +77,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
<button
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === "stop" ? "true" : "false"}
data-selected={selectedKey === "stop" || selectedKey === "skip_probe" ? "true" : "false"}
onClick={stop}
>
{props.card.stop_label}
@@ -38,6 +38,7 @@ export function safeParseServerReportDocument(input: unknown): ReportDocumentPar
{
path: "provenance.evidenceHash",
message: `does not match recomputed evidence hash ${recomputed}`,
code: "evidence_hash",
},
],
};
+4 -2
View File
@@ -282,7 +282,7 @@ export const reportDocumentV2Schema = z.strictObject({
natalFoundation: natalFoundationSchema,
currentPhase: currentPhaseSchema.nullable(),
actionNotes: z.array(actionNoteSchema).min(1).max(24),
charts: z.array(reportDocumentV2ChartSchema).min(1).max(6),
charts: z.array(reportDocumentV2ChartSchema).min(1).max(CHART_IDS.length),
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
evidenceAppendix: evidenceAppendixSchema,
@@ -310,6 +310,7 @@ export type BlockedConflictDisclosureV2 = ReportDocumentV2["blockedConflictDiscl
export type ReportDocumentParseError = Readonly<{
path: string;
message: string;
code: string;
}>;
export class ReportDocumentValidationError extends Error {
@@ -592,6 +593,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
errors: parsed.error.issues.map((issue) => ({
path: issue.path.join(".") || "(root)",
message: issue.message,
code: issue.code,
})),
};
}
@@ -600,7 +602,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
if (guardErrors.length > 0) {
return {
ok: false,
errors: guardErrors.map((message) => ({ path: "(guard)", message })),
errors: guardErrors.map((message) => ({ path: "(guard)", message, code: "guard" })),
};
}
return { ok: true, document };
+36 -8
View File
@@ -4,10 +4,12 @@ import {
safeParseServerReportDocument,
} from "./personal-report-contract.server-core.ts";
import {
CHART_IDS,
REQUIRED_THEME_CHARTS,
type ClaimStatus,
type EvidenceAppendix,
type ReportDepth,
type ReportDocumentParseError,
type ReportDocumentV1,
type ReportDocumentV2,
} from "./personal-report-contract.ts";
@@ -318,8 +320,10 @@ function readHouses(
return houses;
}
const DOCUMENT_VARGA_CHART_IDS = ["D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number];
type DocumentVargaChartId = Exclude<(typeof CHART_IDS)[number], "D1">;
const DOCUMENT_VARGA_CHART_IDS = CHART_IDS.filter(
(id): id is DocumentVargaChartId => id !== "D1",
);
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
D2: ["D2_Hora", "D2"],
@@ -2691,7 +2695,7 @@ export function assembleReportDocumentV2(
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
const chartIds = new Set(["D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"]);
const chartIds = new Set<string>(CHART_IDS);
const charts: ReportDocumentV2["charts"] = [];
for (const chart of bundle.charts) {
if (!chartIds.has(chart.id)) continue;
@@ -3220,15 +3224,39 @@ export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSi
return error instanceof Error && error.name === "AbortError";
}
function failSchema(innerReason: ReportSchemaInnerReason): GeneratePersonalReportResult {
console.info("[personal-report]", JSON.stringify({
function failSchema(
innerReason: ReportSchemaInnerReason,
parseErrors?: readonly ReportDocumentParseError[],
): GeneratePersonalReportResult {
const payload: {
event: "generation_failed";
failureCode: "report_schema_invalid";
innerReason: ReportSchemaInnerReason;
parsePaths?: readonly Readonly<{ path: string; code: string }>[];
} = {
event: "generation_failed",
failureCode: "report_schema_invalid",
innerReason,
}));
};
if (parseErrors && parseErrors.length > 0) {
payload.parsePaths = parseErrors.map((error) => ({
path: error.path,
code: error.code,
}));
}
console.info("[personal-report]", JSON.stringify(payload));
return { status: "failed", failureCode: "report_schema_invalid", innerReason };
}
function failFinalParse(
parsed: ReturnType<typeof safeParseServerReportDocument>,
): GeneratePersonalReportResult {
if (!parsed.ok) {
return failSchema(classifyFinalParseInnerReason(parsed.errors), parsed.errors);
}
return failSchema("final_parse_rejected");
}
function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
if (isPersonalReportGenerationAbort(error, signal)) throw error;
}
@@ -3473,7 +3501,7 @@ async function generateSectionedPersonalReport(
const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle));
if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" };
const parsed = safeParseServerReportDocument(guarded.document);
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failFinalParse(parsed);
return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.() };
} catch (error) {
rethrowIfAborted(error, deps.signal);
@@ -3575,7 +3603,7 @@ export async function generatePersonalReport(
}
const parsed = safeParseServerReportDocument(guarded.document);
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
return failFinalParse(parsed);
}
return {
status: "ready",
@@ -352,7 +352,7 @@ function recordFromRow(value: unknown): PersonalReportJobRecord {
}
const status = statusValue as PersonalReportJobStatus;
if (attemptCount > maxAttempts) storageInvalid("invalid personal report job retry budget");
if (!operationalCodePattern.test(progressPhase)) storageInvalid("invalid progress phase");
if (!isValidPersonalReportJobProgressPhase(progressPhase)) storageInvalid("invalid progress phase");
if (lastErrorCode !== null && !operationalCodePattern.test(lastErrorCode)) {
storageInvalid("invalid last error code");
}
@@ -77,6 +77,7 @@ export const RECTIFICATION_USER_COPY = {
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。",
} as const;
export type RangeNarrationVariant = "delivery" | "intermediate";
@@ -232,6 +233,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
...Object.values(USER_COLLECT_QUESTION),
...Object.values(USER_COLLECT_QUESTION_RETRY),
];
@@ -20,7 +20,8 @@ import {
export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些前事核对。
若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`;
@@ -120,7 +121,7 @@ export async function generateAdoptNarrationText(
post_adopt_verification: facts.post_adopt_verification.map((item) => ({
kind: item.kind,
domain: item.domain,
year: item.year,
year_label: item.year_label,
})),
}),
}], {
@@ -10,7 +10,8 @@ import type { RectificationDecision } from "../core/rectification-decision.ts";
import { openingRangeFromCandidateRange } from "../user-copy.ts";
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
import type { DecisionDossier } from "./decision-from-dossier.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts";
import { reverseVerifyChecksFromProbes, reverseVerifyRemainingForAdopt } from "./method-followup.ts";
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
import type { DroppedProbe } from "./probe-question-contract.ts";
@@ -33,9 +34,9 @@ export type AdoptCandidateSupport = Readonly<{
}>;
export type AdoptPostAdoptCheck = Readonly<{
kind: "holdout" | "oos";
kind: "reverse_verify";
domain: string;
year: number | null;
year_label: string;
}>;
export type AdoptDeliveryFacts = Readonly<{
@@ -158,21 +159,23 @@ export function adoptDeliveryFacts(
] as const
: null;
const opening = openingRangeFromCandidateRange(dossier.case.candidateRange ?? null);
const holdout = (inference?.events ?? [])
.filter((item) => item.usage === "holdout" && item.year !== null)
.map((item) => ({
kind: "holdout" as const,
domain: item.domain,
year: item.year,
}));
const oos = refinement.oos_blind_prompts.map((item) => ({
kind: "oos" as const,
domain: item.domain,
year: null,
}));
const verification = [...holdout, ...oos];
const askedKeys = askedDiscriminatorKeys(receipt, dossier.evidence);
const remaining = reverseVerifyRemainingForAdopt({
eventProbes: refinement.discriminating_event_probes,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
askedProbeKeys: askedKeys,
});
const verification = reverseVerifyChecksFromProbes(remaining);
const years = uniqueYears([
...holdout.map((item) => item.year),
...remaining.map((item) => item.year),
...verification.flatMap((item) => {
const found: number[] = [];
for (const match of item.year_label.matchAll(FOUR_DIGIT_YEAR)) {
found.push(Number(match[1]));
}
return found;
}),
...(inference?.events ?? []).map((item) => item.year),
]);
const minutes = uniqueClocks([
@@ -209,6 +212,16 @@ export function shouldWriteAdoptNarration(facts: AdoptDeliveryFacts): boolean {
return facts.precision_stage === "ready_to_adopt" && !facts.already_accepted;
}
export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string {
if (facts.post_adopt_verification.length === 0) {
return "采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。";
}
const labels = facts.post_adopt_verification.map((item) => (
item.year_label ? `${item.year_label}${item.domain}` : item.domain
));
return `采用后会拿${labels.join("、")}来核对。`;
}
export function templateStopExplain(facts: AdoptDeliveryFacts): string | null {
const split = facts.stop_facts.find((item) => item.kind === "indistinguishable");
if (split && facts.representative_minute && facts.runner_up_minute) {
@@ -243,6 +256,12 @@ export function validateAdoptNarration(
return { ok: false, reason: "question" };
}
if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" };
if (
facts.post_adopt_verification.length === 0
&& /会拿[\s\S]{0,40}核对/.test(trimmed)
) {
return { ok: false, reason: "promise" };
}
const minutes = allowedMinuteSet(facts);
for (const token of trimmed.match(CLOCK_TOKEN) ?? []) {
const padded = padClock(token);
@@ -23,6 +23,7 @@ import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCat
import type { AnswerClass, InferenceState } from "../core/types.ts";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
composeChoiceNarration,
focusStatusForAnswer,
@@ -46,9 +47,10 @@ import {
type PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import { isPersistedFocusId, type ChoiceKey } from "./choice-card";
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, type ChoiceKey } from "./choice-card";
import {
adoptDeliveryFacts,
templatePostAdoptExplain,
templateStopExplain,
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
@@ -57,6 +59,7 @@ import { composeCollectSpokenAssistantText } from "./collect-prompt";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
buildNextUserAction,
exhaustionSpokenCollectFollowup,
GENERIC_COLLECT_QUESTION,
spokenCollectFallbackFollowup,
@@ -105,7 +108,9 @@ function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): bool
|| followup.source === "varga_observation"
|| followup.source === "precision_stage"
|| followup.source === "nakshatra_boundary"
|| followup.intent === "distinguish_candidates";
|| followup.source === "reverse_verify"
|| followup.intent === "distinguish_candidates"
|| followup.intent === "reverse_verify";
}
function shouldSkipFollowupPersist(input: {
@@ -113,7 +118,9 @@ function shouldSkipFollowupPersist(input: {
nextAction: string;
followup: MethodFollowup | null;
methods?: readonly MethodCoverage[];
accepted?: boolean;
}): boolean {
if (input.accepted) return false;
if (!input.canAdopt) return false;
if (
input.nextAction === "ask_fact_collection"
@@ -133,13 +140,19 @@ function adoptHostNarration(input: {
receipt: Readonly<Record<string, unknown>> | null | undefined;
}): string {
const facts = adoptDeliveryFacts(input.decision, input.dossier);
return withProspectiveWindows(deliveryAdoptNarration({
const base = withProspectiveWindows(deliveryAdoptNarration({
credibleRange: input.decision.credibleRange,
representativeTime: input.decision.representativeTime,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: input.decision.stopReason ?? null,
stopExplain: templateStopExplain(facts),
}), input.receipt);
if (facts.post_adopt_verification.length > 0) return base;
const explain = templatePostAdoptExplain(facts);
if (base.includes("没有还能核对的前事")) return base;
return `${base.replace(RECTIFICATION_USER_COPY.adoptCue, "").trim()} ${explain} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
}
function dossierWithCurrentInference(
@@ -164,7 +177,7 @@ export type ApplyChoiceCommand = Readonly<{
caseId: string;
sessionId: string;
actionId: string;
action: typeof CHOICE_ACTION | typeof STOP_ACTION;
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
focusId: string;
questionId?: string;
probeId?: string | null;
@@ -191,6 +204,7 @@ export type AppliedChoiceReceipt = Readonly<{
narration: string;
userDisplay: string | null;
nextAction: ReturnType<typeof publicNextAction>;
nextUserAction: ReturnType<typeof buildNextUserAction>;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
turnId: string | null;
@@ -200,6 +214,38 @@ function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function markVerifyProbeSkipped(
state: InferenceState,
schema: Readonly<Record<string, unknown>>,
fallbackProbeId: string | null,
): InferenceState {
const probeId = asText(schema.probe_id) ?? fallbackProbeId ?? asText(schema.semantic_key);
const semantic = asText(schema.semantic_key) ?? probeId;
const split = asText(schema.candidate_split_hash) ?? semantic;
if (!probeId || !semantic || !split) return state;
if (state.answered_probes.some((item) => (
item.probe_id === probeId
|| item.semantic_key === semantic
|| item.candidate_split_hash === split
))) {
return state;
}
return {
...state,
revision: state.revision + 1,
answered_probes: [
...state.answered_probes,
{
probe_id: probeId,
semantic_key: semantic,
candidate_split_hash: split,
answer_class: "unsure",
classified_from: "declined",
},
],
};
}
function dossierWithClosedFocus<T extends {
conversationSummary: {
activeFocus: { targetDomain?: string | null; intent?: string | null } | null;
@@ -257,11 +303,71 @@ export async function applyRectificationChoice(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
const answerClass = optionId === "stop" ? null : outcomeIdForOption(optionId, schema);
if (optionId !== "stop" && !answerClass) {
const answerClass = optionId === "stop" || optionId === "skip_probe"
? null
: outcomeIdForOption(optionId, schema);
if (optionId !== "stop" && optionId !== "skip_probe" && !answerClass) {
throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema");
}
if (optionId === "skip_probe" || command.action === SKIP_PROBE_ACTION) {
const skippedState = previous ? markVerifyProbeSkipped(previous, schema, schemaProbeId ?? command.probeId ?? null) : null;
const probeId = schemaProbeId ?? command.probeId ?? asText(schema.semantic_key) ?? "skip_probe";
const semanticKey = asText(schema.semantic_key) ?? probeId;
const splitHash = asText(schema.candidate_split_hash) ?? semanticKey;
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
const inference = skippedState && previous
? {
expectedRevision: previous.revision,
probeId,
openProbeId: probeId,
semanticKey,
candidateSplitHash: splitHash,
answerClass: "unsure" as const,
rawAnswer: "skip_probe",
inferenceState: skippedState as unknown as Record<string, unknown>,
posteriorBefore: posteriorMap(previous.candidates),
posteriorAfter: posteriorMap(skippedState.candidates),
scoreDeltas: scoreDeltas(
posteriorMap(previous.candidates),
posteriorMap(skippedState.candidates),
),
decisionStateFingerprint: inferenceFingerprintForState(
command.caseId,
evidenceFp,
skippedState,
),
reason: "choice" as const,
idempotencyKey: `skip_probe:${probeId}:unsure`,
candidateSetId: skippedState.candidate_set_id,
}
: null;
return persistApplied(accounting, command, {
focusId: focus.id,
questionId,
focusStatus: "skipped",
probeId,
optionId: "skip_probe",
scoring: false,
appliedInference: Boolean(inference),
answerClass: null,
sourceQuote: null,
year: typeof schema.probe_year === "number" ? schema.probe_year : null,
expectedRevision: previous?.revision ?? command.expectedRevision,
inference,
narration: composeChoiceNarration({
optionId: "skip_probe",
scoring: false,
appliedInference: false,
}),
userDisplay: CHOICE_SKIP_QUESTION_LABEL,
decisionState: skippedState,
userStopped: false,
dossier,
});
}
if (optionId === "stop" || command.action === STOP_ACTION) {
const narration = composeChoiceNarration({
optionId: "stop",
@@ -419,6 +525,7 @@ export async function persistNextInterviewAfterChoice(input: {
persisted?: boolean;
focusId?: string | null;
focus?: ConversationFocus | null;
followup?: MethodFollowup | null;
}> {
const latest = input.dossier.latestResult
? {
@@ -461,6 +568,7 @@ export async function persistNextInterviewAfterChoice(input: {
nextAction: input.nextAction.type,
followup,
methods: plan.methods,
accepted: Boolean(input.dossier.case.acceptedTime),
})) {
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
@@ -474,6 +582,15 @@ export async function persistNextInterviewAfterChoice(input: {
: fallback,
choiceReady: false,
persisted: false,
followup: null,
};
}
if (input.dossier.case.acceptedTime && !followup) {
return {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
choiceReady: false,
persisted: false,
followup: null,
};
}
const persistedFocus = await persistFocusAfterChoice({
@@ -491,6 +608,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
followup,
};
}
if (open?.kind === "collect_spoken" && open.prompt) {
@@ -500,6 +618,7 @@ export async function persistNextInterviewAfterChoice(input: {
persisted: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
followup,
};
}
if (followup?.choice_frame) {
@@ -521,6 +640,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: false,
focusId: fallback.focus?.id ?? null,
focus: fallback.focus,
followup,
};
}
if (followup?.intent === "collect_method_evidence") {
@@ -529,7 +649,7 @@ export async function persistNextInterviewAfterChoice(input: {
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
) {
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus };
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus, followup };
}
return persistExhaustionCollect({
accounting: input.accounting,
@@ -545,6 +665,13 @@ export async function persistNextInterviewAfterChoice(input: {
});
}
if (!followup) {
if (input.dossier.case.acceptedTime) {
return {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
choiceReady: false,
followup: null,
};
}
if (isNonConvergingRangeOffer({
canOfferRange: input.nextAction.can_offer_range,
canAdopt: input.nextAction.can_adopt,
@@ -578,6 +705,7 @@ export async function persistNextInterviewAfterChoice(input: {
return {
hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback,
choiceReady: false,
followup,
};
}
@@ -838,6 +966,7 @@ export async function persistNextInterviewIfIdle(input: {
nextAction: decision.nextAction,
followup,
methods: plan.methods,
accepted: Boolean(dossier.case.acceptedTime),
})) {
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = adoptHostNarration({
@@ -865,6 +994,13 @@ export async function persistNextInterviewIfIdle(input: {
});
}
if (!followup) {
if (dossier.case.acceptedTime) {
return {
persisted: false,
choiceReady: false,
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
};
}
return { persisted: false, choiceReady: false, hostNarration: null };
}
const nextAction = publicNextAction(decision);
@@ -995,6 +1131,8 @@ async function persistApplied(
birthDate,
});
const nextAction = publicNextAction(nextDecision);
const accepted = Boolean(input.dossier.case.acceptedTime);
const skipThisProbe = input.optionId === "skip_probe";
let narrationPersisted = false;
let nextInterviewPersisted = false;
@@ -1002,11 +1140,16 @@ async function persistApplied(
let hostNarration = input.narration;
let skippedNextInterview = false;
let nextFocus: ConversationFocus | null = null;
let nextFollowup: MethodFollowup | null = null;
let turnId: string | null = null;
if (
command.deferFollowup !== true
&& input.userStopped !== true
&& shouldContinueAfterStructuredChoice(nextAction)
&& (
shouldContinueAfterStructuredChoice(nextAction)
|| skipThisProbe
|| accepted
)
) {
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
@@ -1022,8 +1165,11 @@ async function persistApplied(
nextChoiceReady = nextInterview.choiceReady;
skippedNextInterview = nextInterview.persisted === false;
nextFocus = nextInterview.focus ?? null;
nextFollowup = nextInterview.followup ?? null;
if (nextInterview.hostNarration) {
if (skippedNextInterview) {
if (accepted || skipThisProbe) {
hostNarration = nextInterview.hostNarration;
} else if (skippedNextInterview) {
hostNarration = `${input.narration}
${nextInterview.hostNarration}`;
@@ -1032,10 +1178,10 @@ ${nextInterview.hostNarration}`;
}
}
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
const adoptionFacts = nextAction.can_adopt
const adoptionFacts = !accepted && nextAction.can_adopt
? adoptDeliveryFacts(nextDecision, liveDossier)
: null;
const adoptionNarration = nextAction.can_adopt
const adoptionNarration = !accepted && nextAction.can_adopt
? withProspectiveWindows(deliveryAdoptNarration({
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
@@ -1045,7 +1191,7 @@ ${nextInterview.hostNarration}`;
}), liveDossier.latestResult?.decisionReceipt)
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
const completedRangeNarration = nextAction.type === "complete_with_range"
const completedRangeNarration = !accepted && nextAction.type === "complete_with_range"
? withProspectiveWindows(`${completedRangePrefix}
${nonConvergingRangeNarration({
@@ -1057,7 +1203,10 @@ ${nonConvergingRangeNarration({
}, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt)
: null;
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
if (accepted && !keptNextQuestion) {
hostNarration = RECTIFICATION_USER_COPY.postAdoptVerifyDone;
nextFollowup = null;
} else if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
}
@@ -1075,8 +1224,10 @@ ${nonConvergingRangeNarration({
if (turn.turnId) {
turnId = turn.turnId;
try {
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus;
const focus = nextFocus ?? (keptNextQuestion
? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus
: null);
if (focus) {
await linkFocusAskedTurn({
accounting,
@@ -1127,6 +1278,18 @@ ${nonConvergingRangeNarration({
narration,
userDisplay: input.userDisplay,
nextAction,
nextUserAction: buildNextUserAction({
scorableCount: input.dossier.evidence.length,
evidenceCount: input.dossier.evidence.length,
hasLatestResult: Boolean(input.dossier.latestResult),
selectionAllowed: nextAction.can_adopt,
sessionOutcome: typeof nextAction.session_outcome === "string"
? nextAction.session_outcome as SessionOutcomeKind
: "collect_evidence",
nextFollowup: keptNextQuestion ? nextFollowup : null,
workingTime: input.dossier.case.acceptedTime ?? nextAction.representative_time ?? null,
accepted,
}),
nextInterviewPersisted,
nextChoiceReady,
turnId,
@@ -20,10 +20,11 @@ import {
export const CHOICE_ACTION = "answer_choice" as const;
export const STOP_ACTION = "stop_and_review" as const;
export const SKIP_PROBE_ACTION = "skip_probe" as const;
export const DETERMINISTIC_CHOICE_MODEL = "deterministic:choice";
export type ChoiceActionKind = typeof CHOICE_ACTION | typeof STOP_ACTION;
export type ChoiceOptionId = ChoiceKey | "stop";
export type ChoiceActionKind = typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
export type ChoiceOptionId = ChoiceKey | "stop" | "skip_probe";
export type ChoiceActionStatus = "received" | "applied" | "narrated";
export type StructuredProbeDerivedContext = Readonly<{
@@ -38,7 +39,7 @@ export function outcomeIdForOption(optionId: ChoiceKey, schema?: unknown): Answe
}
export function focusStatusForAnswer(answerClass: AnswerClass | null, optionId: ChoiceOptionId): "resolved" | "declined" | "skipped" {
if (optionId === "stop" || answerClass === "unsure") return "skipped";
if (optionId === "stop" || optionId === "skip_probe" || answerClass === "unsure") return "skipped";
if (answerClass === "no") return "declined";
return "resolved";
}
@@ -99,6 +100,9 @@ export function composeChoiceNarration(input: {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
}
if (input.optionId === "skip_probe") {
return "已记录。这题先不计分,换一件事问。";
}
if (input.answerClass === "unsure") {
return "已记录。这题先不计分,换一件事问。";
}
@@ -56,6 +56,7 @@ export type RectificationChoiceFrame = Readonly<{
stop_message: string;
scoring: boolean;
choice_kind?: EventProbeChoiceKind;
skip_this_probe?: boolean;
}>;
export type AgentChoiceCopy = Readonly<{
@@ -86,6 +87,7 @@ export type RectificationChoiceCard = Readonly<{
case_revision: number | null;
focus_id: string | null;
choice_kind?: EventProbeChoiceKind;
skip_this_probe?: boolean;
}>;
export type ChoiceCardFollowup = Readonly<{
@@ -379,9 +381,10 @@ export function buildChoiceFrame(
);
if (!hypothesis) return null;
const domain = followupDomain(followup);
const skipQuestion = followup.intent === "reverse_verify"
const skipThisProbe = followup.intent === "reverse_verify"
|| followup.source === "reverse_verify";
const skipQuestion = skipThisProbe
|| followup.intent === "out_of_sample_check"
|| followup.source === "reverse_verify"
|| followup.source === "oos_blind";
return {
question_id: `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`,
@@ -403,6 +406,7 @@ export function buildChoiceFrame(
stop_message: skipQuestion ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring,
choice_kind: hypothesisKind(followup, input.probes),
...(skipThisProbe ? { skip_this_probe: true } : {}),
};
}
@@ -521,6 +525,7 @@ export function mergeChoiceCard(
case_revision: meta.case_revision ?? null,
focus_id: meta.focus_id ?? null,
choice_kind: frame.choice_kind,
...(frame.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
@@ -553,6 +558,7 @@ export function choiceCardFromPersistedVerifyCopy(input: {
case_revision: input.caseRevision,
focus_id: input.focusId,
choice_kind: "existence",
skip_this_probe: true,
};
}
@@ -614,5 +620,6 @@ export function parseRectificationChoiceCard(value: unknown): RectificationChoic
...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality"
? { choice_kind: row.choice_kind }
: {}),
...(row.skip_this_probe === true ? { skip_this_probe: true } : {}),
};
}
@@ -13,7 +13,7 @@ import {
rectificationFollowupCatalog,
} from "./decision-from-dossier";
import { evidenceLedgerFingerprint } from "./tool-service";
import { projectRectificationChoiceCard } from "./method-followup";
import { projectRectificationChoiceCard, buildMethodFollowupPlan, buildNextUserAction } from "./method-followup";
import {
internalObservationsFromWindowScan,
windowScanFromDecisionReceipt,
@@ -102,4 +102,37 @@ export function choiceCardFromCaseDossier(dossier: {
});
}
export function nextUserActionFromDossier(dossier: Parameters<typeof choiceCardFromCaseDossier>[0]) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
const accepted = Boolean(dossier.case.acceptedTime);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: dossier.conversationSummary.activeFocus,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
observations,
sessionOutcome: decision.sessionOutcome,
...catalog,
accepted,
candidatesSeparated: decision.separation.sufficient,
holdoutValidation: decision.holdoutValidation,
});
return buildNextUserAction({
scorableCount: dossier.evidence.length,
evidenceCount: dossier.evidence.length,
hasLatestResult: Boolean(dossier.latestResult),
selectionAllowed: decision.selectionAllowed,
sessionOutcome: decision.sessionOutcome,
nextFollowup: plan.next_followup,
workingTime: dossier.case.acceptedTime ?? decision.representativeTime ?? null,
accepted,
});
}
export { decideFromDossier, overlayPublicDecision } from "./decision-from-dossier";
@@ -17,7 +17,7 @@ export function isRectificationMessageOrigin(value: unknown): value is Rectifica
}
export function defaultMessageOrigin(action: string): RectificationMessageOrigin {
if (action === "answer_choice" || action === "stop_and_review") return "choice_click";
if (action === "answer_choice" || action === "stop_and_review" || action === "skip_probe") return "choice_click";
if (action === "opening") return "system_recovery";
return "typed";
}
@@ -545,10 +545,22 @@ const CONFLICT_PROBE_SOURCES = new Set<string>([
"dasha_activation",
]);
function remainingReverseVerifyProbes(
function reverseVerifyProbeAsked(
probe: DiscriminatingEventProbe,
askedKeys: ReadonlySet<string>,
): boolean {
const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
const split = probe.candidate_split_hash ?? "";
return askedKeys.has(semantic)
|| (split !== "" && askedKeys.has(split))
|| askedKeys.has(`${probe.domain}.${probe.year}`);
}
export function remainingReverseVerifyProbes(
probes: readonly DiscriminatingEventProbe[] | undefined,
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
askedKeys: ReadonlySet<string> = new Set(),
birthDate?: string | null,
): DiscriminatingEventProbe[] {
const dasha: DiscriminatingEventProbe[] = [];
@@ -562,6 +574,7 @@ function remainingReverseVerifyProbes(
if (declined.has(probe.domain)) continue;
if (!anchoredQuality && probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
if (probeBelowAdultFloor(probe, birthDate)) continue;
if (reverseVerifyProbeAsked(probe, askedKeys)) continue;
if (CONFLICT_PROBE_SOURCES.has(probe.source)) {
dasha.push(probe);
} else {
@@ -571,6 +584,32 @@ function remainingReverseVerifyProbes(
return [...dasha, ...fallback].slice(0, MAX_REVERSE_VERIFY);
}
export function reverseVerifyChecksFromProbes(
probes: readonly DiscriminatingEventProbe[],
): ReadonlyArray<{ kind: "reverse_verify"; domain: string; year_label: string }> {
return probes.map((probe) => ({
kind: "reverse_verify" as const,
domain: probe.domain,
year_label: probe.year_label,
}));
}
export function reverseVerifyRemainingForAdopt(input: {
eventProbes?: readonly DiscriminatingEventProbe[];
evidence: readonly MethodFollowupEvidence[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
askedProbeKeys?: readonly string[];
birthDate?: string | null;
}): DiscriminatingEventProbe[] {
return remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declinedDomains(input.declinedTopics ?? []),
new Set(input.askedProbeKeys ?? []),
input.birthDate,
);
}
function datedCollectionProbe(
probes: readonly DiscriminatingEventProbe[] | undefined,
domain: string,
@@ -581,12 +620,11 @@ function datedCollectionProbe(
function collectionYearFields(probe: DiscriminatingEventProbe | null): Pick<
MethodFollowup,
"probe_year" | "year_label" | "probe_month" | "semantic_key"
"probe_year" | "probe_month" | "semantic_key"
> {
if (!probe) return {};
return {
probe_year: probe.year,
year_label: probe.year_label,
...(probe.month ? { probe_month: probe.month } : {}),
semantic_key: probe.semantic_key,
};
@@ -996,9 +1034,7 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string |
: openingOther
? GENERIC_COLLECT_QUESTION
: (USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION);
const period = followup.year_label
?? (followup.probe_year && followup.probe_year > 0 ? `${followup.probe_year} 年前后` : null);
return period ? `${period},${base}` : base;
return base;
}
export const DATED_COLLECT_ORDER = [
@@ -1720,7 +1756,13 @@ export function buildMethodFollowupPlan(input: {
}
if (input.accepted) {
const probe = remainingReverseVerifyProbes(input.eventProbes, input.evidence, declined, input.birthDate)[0] ?? null;
const probe = remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declined,
askedKeys,
input.birthDate,
)[0] ?? null;
const theme = probe ? REVERSE_VERIFY_THEME[probe.domain] : null;
const next = probe && theme
? makeFollowup({
@@ -1734,6 +1776,17 @@ export function buildMethodFollowupPlan(input: {
REVERSE_VERIFY_VARGA[probe.domain],
),
source: "reverse_verify",
semantic_key: probe.semantic_key ?? `${probe.domain}.${probe.year}`,
candidate_split_hash: probe.candidate_split_hash,
probe_year: probe.year,
year_label: probe.year_label,
probe_month: probe.month,
choice_kind: probe.choice_kind,
candidate_ids: probe.candidate_ids ?? candidateIdsFromProbe(probe),
expected_outcomes: probe.expected_outcomes,
style_options: probe.style_options,
information_gain: probe.information_gain ?? 0,
probe_id: probe.semantic_key,
}, true, true)
: null;
return {
@@ -6,7 +6,8 @@
* Once the current step looks like the spoken reply — Chinese sentence end,
* or live-unlock after compare/offer/result tools — tokens may stream as
* `live`. A later public tool-call retracts that speculative stream so
* "Let me set" never stays in `answer.delta`.
* "Let me set" never stays in `answer.delta`. Exception: `rectification-set-focus`
* only attaches the question stem; live greeting/handoff in the same step stays.
*/
export type StepAnswerChunk = Readonly<{
@@ -45,6 +46,11 @@ const SPOKEN_LIVE_UNLOCK_TOOLS = new Set([
"rectification-read-diagnostics",
]);
/** set-focus writes the stem onto the same turn; it is not planning prose. */
const KEEP_LIVE_SPOKEN_TOOLS = new Set([
"rectification-set-focus",
]);
function resetBuffers(state: StepAnswerState): void {
state.text = "";
state.pieces = [];
@@ -153,14 +159,19 @@ export function applyStepAnswerChunk(
}
case "tool-call":
if (isPublicToolCall(chunk, isPublicTool)) {
const retracted = retractLive(state);
state.calledTool = true;
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) return { kind: "none" };
const retracted = retractLive(state);
return retracted.kind === "retract" ? retracted : { kind: "none" };
}
return { kind: "none" };
case "tool-result":
case "tool-error": {
if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true;
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
resetBuffers(state);
return { kind: "none" };
}
const discarded = state.calledTool || state.text.length > 0;
const retracted = retractLive(state);
resetBuffers(state);
@@ -9,7 +9,8 @@ export type RectificationRouteAction =
| "message"
| "read_only"
| "answer_choice"
| "stop_and_review";
| "stop_and_review"
| "skip_probe";
export const RECTIFICATION_ACTION_EXECUTION = {
opening: "stream",
@@ -17,6 +18,7 @@ export const RECTIFICATION_ACTION_EXECUTION = {
read_only: "read_only",
answer_choice: "immediate",
stop_and_review: "immediate",
skip_probe: "immediate",
} as const satisfies Record<RectificationRouteAction, "stream" | "hybrid" | "read_only" | "immediate">;
export function dossierHasNonemptyAssistantBody(
@@ -16,11 +16,11 @@ export type TurnQuestion = Readonly<{
prompt: string;
options: readonly TurnQuestionOption[] | null;
status: string;
answer_option: ChoiceKey | "stop" | null;
answer_option: ChoiceKey | "stop" | "skip_probe" | null;
probe_id: string | null;
}>;
const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop"]);
const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop", "skip_probe"]);
export function focusSpokenPrompt(schema: Readonly<Record<string, unknown>> | null | undefined): string | null {
if (!schema) return null;
@@ -91,7 +91,8 @@ export function parseTurnQuestion(value: unknown): TurnQuestion | null {
prompt,
options: options.length === 4 ? options : (row.kind === "choice" || row.kind === "reverse_verify" ? options : null),
status: typeof row.status === "string" ? row.status : "active",
answer_option: answer === "A" || answer === "B" || answer === "C" || answer === "D" || answer === "stop"
answer_option: answer === "A" || answer === "B" || answer === "C" || answer === "D"
|| answer === "stop" || answer === "skip_probe"
? answer
: null,
probe_id: typeof row.probe_id === "string" ? row.probe_id : null,
@@ -55,6 +55,7 @@ export type RectificationCaseSnapshotPayload = Readonly<{
current_question?: unknown;
choice_card?: unknown;
question_source?: unknown;
next_user_action?: Readonly<{ id?: unknown }>;
case?: Readonly<{
status?: unknown;
accepted_time?: unknown;
@@ -219,7 +220,7 @@ export function rectificationConversationState(input: Readonly<{
return "empty";
}
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable";
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable" | "verified_idle";
export type RectificationQuestionGapInput = Readonly<{
/** The current question is rendered live inside an assistant message. */
@@ -230,6 +231,8 @@ export type RectificationQuestionGapInput = Readonly<{
questionLoadFailed: boolean;
/** Candidate cards are offered and not yet adopted: the reader, not the server, holds the next move. */
offerAwaitingReader?: boolean;
/** GET / structured-choice `next_user_action.id`; `start_consultation` means post-adopt verify is done. */
nextUserActionId?: string | null;
busy: boolean;
readonly: boolean;
regenerating: boolean;
@@ -254,6 +257,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
if (input.nextUserActionId === "start_consultation") return "verified_idle";
if (input.questionLoadFailed) return "unavailable";
// Either the snapshot names no question, or it names one that no settled
// message carries live yet: both are a gap the next read may close.
@@ -52,9 +52,12 @@ test("free Agentic rectification turns bypass reservation, completion, and cance
const completeBilling = sourceBetween(rectificationRoute, "async complete(usage) {", "async release() {");
const releaseBilling = sourceBetween(rectificationRoute, "async release() {", " };\n\n try {");
// 旧:enum 到 stop_and_review
// 新:增加 skip_probe;非 message 仍不计费
// 原因:决策 4,核对跳过走即时结构化动作
assert.match(
rectificationRoute,
/action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review"\]\)/,
/action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/,
);
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 3);
assert.match(reserveBilling, /if \(action !== "message"\) \{[\s\S]*return \{ success: true, status: 200 \};/);
@@ -1294,6 +1294,7 @@ test("list timestamps accept Date objects and ISO strings from self-hosted postg
assert.match(coreSource, /export function reportListTimestamp/);
assert.match(generationSource, /innerReason/);
assert.match(generationSource, /isPersonalReportGenerationAbort/);
assert.match(generationSource, /parsePaths/);
assert.doesNotMatch(generationSource, /failSchema\([^)]*error\.message/);
});
@@ -7,6 +7,7 @@ import {
parseServerReportDocument,
} from "../src/lib/personal-report-contract.server-core.ts";
import {
CHART_IDS,
findBlockedDeterministicClaims,
findChartSetViolations,
findDanglingEvidenceRefs,
@@ -336,6 +337,60 @@ test("v2 guard rejects unsupported dates, medical diagnoses, and deterministic f
assert.equal(safeParseReportDocument(financial).ok, false);
});
test("v2 chart cap and enum stay bound to CHART_IDS", () => {
const contractSource = readFileSync(
new URL("../src/lib/personal-report-contract.ts", import.meta.url),
"utf8",
);
assert.match(
contractSource,
/charts: z\.array\(reportDocumentV2ChartSchema\)\.min\(1\)\.max\(CHART_IDS\.length\)/,
);
assert.doesNotMatch(contractSource, /charts: z\.array\(reportDocumentV1ChartSchema\)\.min\(1\)\.max\(CHART_IDS/);
const schema = JSON.parse(readFileSync(
new URL("../../contracts/personal-report/report-document.v2.schema.json", import.meta.url),
"utf8",
)) as {
properties: { charts: { maxItems: number } };
definitions: { chart: { properties: { id: { enum: string[] } } } };
};
assert.equal(schema.properties.charts.maxItems, CHART_IDS.length);
assert.deepEqual(schema.definitions.chart.properties.id.enum, [...CHART_IDS]);
});
test("a document with every CHART_IDS entry passes the server parse", () => {
const document = cloneV2();
const template = document.charts[0];
const present = new Set(document.charts.map((chart) => chart.id));
for (const id of CHART_IDS) {
if (present.has(id)) continue;
document.charts.push({
...structuredClone(template),
id,
title: `${id} 分盘`,
});
}
assert.equal(document.charts.length, CHART_IDS.length);
const parsed = safeParseServerReportDocument(document);
assert.equal(parsed.ok, true);
});
test("one chart beyond CHART_IDS.length is still rejected", () => {
const document = cloneV2();
const template = document.charts[0];
while (document.charts.length < CHART_IDS.length + 1) {
document.charts.push({
...structuredClone(template),
id: template.id,
title: `${template.id} extra ${document.charts.length}`,
});
}
const result = safeParseReportDocument(document);
assert.equal(result.ok, false);
assert.ok(result.errors.some((error) => error.path === "charts" && error.code === "too_big"));
});
test("v2 guard rejects HTML and CSS while allowing ordinary Chinese prose", () => {
for (const poison of ["<div>报告</div>", "body { color: red; }", 'style="color:red"']) {
const document = cloneV2();
@@ -13,6 +13,7 @@ import {
type ReportEvidenceBundleV2,
type TechniqueExecutionReceipt,
} from "../src/lib/report-evidence-bundle-v2.ts";
import { CHART_IDS } from "../src/lib/personal-report-contract.ts";
import {
classifyFinalParseInnerReason,
classifyReportSchemaInnerReason,
@@ -320,6 +321,14 @@ const fullThemes: readonly ThemeSpec[] = [
{ theme: "education", section: "学习与成长", refs: ["ev-tech-d1", "ev-tech-d24"] },
];
const standardFiveThemes: readonly ThemeSpec[] = [
{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] },
{ theme: "marriage", section: "关系与婚恋", refs: ["ev-tech-d1", "ev-tech-d9"] },
{ theme: "wealth", section: "财富结构", refs: ["ev-tech-d2", "ev-tech-d11"] },
{ theme: "timing", section: "当前阶段", refs: ["ev-tech-vimshottari", "ev-tech-narayana"] },
{ theme: "health", section: "压力与恢复", refs: ["ev-tech-d6", "ev-tech-d8", "ev-tech-d24", "ev-tech-d30"] },
];
test("planner -> writer -> ReportDocument v2 preserves four-theme coverage, real charts, depth and report-skill provenance", async () => {
const bundle = makeBundle({
themes: fullThemes,
@@ -358,6 +367,31 @@ test("planner -> writer -> ReportDocument v2 preserves four-theme coverage, real
assert.equal(document.schemaVersion, "report_document.v2");
});
test("five-theme sectioned pipeline with the full CHART_IDS set finishes READY", async () => {
const bundle = makeBundle({
themes: standardFiveThemes,
charts: CHART_IDS.map((id, offset) => chart(id, offset)),
});
const sectionCalls: string[] = [];
const result = await runSectioned(
bundle,
sectionedAgent({ sectionCalls }),
inMemorySectionService(),
);
const document = readyV2(result);
assert.deepEqual(
document.thematicNarrative.map((section) => section.theme),
["career", "health", "marriage", "timing", "wealth"],
);
assert.deepEqual(document.blockedConflictDisclosure, []);
assert.deepEqual(
[...document.charts.map((entry) => entry.id)].sort(),
[...CHART_IDS].sort(),
);
assert.equal(document.charts.length, CHART_IDS.length);
assert.deepEqual(sectionCalls, ["career", "health", "marriage", "timing", "wealth"]);
});
test("generatePersonalReport passes the worker lease signal to the writer and settles when aborted", async () => {
const bundle = makeBundle({
themes: [{
@@ -728,6 +762,50 @@ test("deterministic guard rejection and final schema rejection remain distinct t
});
});
test("final parse rejection logs issue paths without document body", async () => {
const bundle = makeBundle({
themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] }],
charts: [chart("D1"), chart("D10", 3)],
});
const poison = "过".repeat(161);
const logs: unknown[][] = [];
const originalInfo = console.info;
console.info = (...args: unknown[]) => logs.push(args);
let result: GeneratePersonalReportResult;
try {
result = await run(bundle, fakeWriter((inputBundle, plan) => {
const output = writerOutputFor(inputBundle, plan);
return {
...output,
thematicNarrative: [{
...output.thematicNarrative[0],
title: poison,
}],
};
}));
} finally {
console.info = originalInfo;
}
expectSchemaRejected(result, "final_parse_rejected");
const payload = logs
.filter((args) => args[0] === "[personal-report]")
.map((args) => JSON.parse(String(args[1])) as {
event?: string;
innerReason?: string;
parsePaths?: readonly Readonly<{ path: string; code: string }>[];
})
.find((entry) => entry.event === "generation_failed");
assert.ok(payload);
assert.equal(payload.innerReason, "final_parse_rejected");
assert.ok(payload.parsePaths?.some((entry) => (
entry.path === "thematicNarrative.0.title" && entry.code === "too_big"
)));
const serialized = JSON.stringify(payload);
assert.equal("message" in (payload.parsePaths?.[0] ?? {}), false);
assert.doesNotMatch(serialized, new RegExp(poison));
assert.doesNotMatch(serialized, /正式证据支持多个主题/);
});
function sectionPayloadFor(
bundle: ReportEvidenceBundleV2,
section: Readonly<{ id: string; theme: string | null; evidenceRefs: readonly string[] }>,
@@ -479,6 +479,23 @@ test("claim and heartbeat require the exact live lease token", async () => {
});
assert.equal(progressed.progressPhase, "building_evidence");
assert.equal(progressed.progressPercent, 40);
const sectionProgress = await service.updateProgress({
jobId: claimed.id,
leaseToken: claimed.leaseToken,
phase: "section:theme-health_pressure",
percent: 52,
});
assert.equal(sectionProgress.progressPhase, "section:theme-health_pressure");
backend.now = new Date("2026-08-14T03:00:20.000Z");
const afterSection = await service.heartbeatLease({
jobId: claimed.id,
leaseToken: claimed.leaseToken,
leaseSeconds: 60,
});
assert.equal(afterSection.progressPhase, "section:theme-health_pressure");
const owned = await service.getOwnedByRequestId(USER_A, REQUEST_A);
assert.equal(owned?.progressPhase, "section:theme-health_pressure");
});
test("atomic completion binds the canonical v2 document to the exact live lease", async () => {
@@ -7,6 +7,7 @@ import { publicNextAction } from "../src/lib/rectification-agentic/core/rectific
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
adoptDeliveryFacts,
templatePostAdoptExplain,
validateAdoptNarration,
type AdoptDeliveryFacts,
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
@@ -681,14 +682,17 @@ test("adoptDeliveryFacts names the representative, range, stop class, and post-a
assert.ok(split);
assert.match(split!.label, /分不开/);
assert.ok((split!.count ?? 0) >= 1);
assert.ok(facts.post_adopt_verification.some((item) => (
item.kind === "holdout" && item.domain === "education" && item.year === 2016
)));
assert.ok(facts.post_adopt_verification.some((item) => item.kind === "oos" && item.domain === "family"));
assert.ok(facts.post_adopt_verification.some((item) => item.kind === "oos" && item.domain === "finance"));
assert.ok(facts.post_adopt_verification.some((item) => (
item.kind === "oos" && (item.domain === "health" || item.domain === "health_pressure")
)));
// 旧:holdout/oos 出现在 post_adopt_verification
// 新:仅出现采用后真会问的 reverse_verify 项,且与 buildMethodFollowupPlan({accepted:true}) 首题一致
// 原因:决策 3 推翻原任务书 §4
assert.equal(facts.post_adopt_verification.every((item) => item.kind === "reverse_verify"), true);
const acceptedPlan = planFrom(dossier, { accepted: true });
if (acceptedPlan.next_followup) {
assert.equal(facts.post_adopt_verification[0]?.domain, acceptedPlan.next_followup.domain);
} else {
assert.deepEqual(facts.post_adopt_verification, []);
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
}
});
test("adopt narration agent keeps in-fact copy and fail-closes the rest", async () => {
@@ -107,7 +107,10 @@ test("opening is server-owned: shouldStartOpening drives the first turn, never c
assert.match(chat, /onOpeningConsumed\?\.\(\)/);
assert.match(chat, /void send\("opening", ""\)/);
assert.match(route, /import \{ createServerSupabaseClient \} from "@\/lib\/supabase\/server"/);
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review"\]\)/);
// 旧:enum 到 stop_and_review
// 新:增加 skip_probe(核对卡「这题跳过」)
// 原因:决策 4,跳过只关本题
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/);
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
@@ -6,6 +6,7 @@ import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/an
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
composeChoiceNarration,
isStructuredChoiceUserText,
@@ -28,7 +29,7 @@ import {
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { containsBoundarySemantics } from "../src/lib/rectification-agentic/user-copy.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
@@ -500,7 +501,7 @@ function adoptionDossier() {
}
function persistChoiceAccounting(
dossier: ReturnType<typeof twoProbeDossier>,
dossier: ReturnType<typeof dossierFixture>,
extra: Parameters<typeof fakeAccounting>[0] = {},
) {
return choiceAccounting({
@@ -884,7 +885,8 @@ test("answering the last discriminator persists a year-locked family collect foc
const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean };
assert.equal(schema.choice, undefined);
assert.equal(schema.collect, true);
assert.match(schema.prompt ?? "", /2021/);
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family → 决策 7 采集题不带年份前缀
assert.equal(schema.prompt, RECTIFICATION_USER_COPY.collectQuestionByDomain.family);
assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/);
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, false);
@@ -1198,6 +1200,9 @@ test("the public agent route treats structured choice as a non-model command", (
assert.doesNotMatch(block, /authorizeUsage/);
assert.match(route, /"answer_choice"/);
assert.match(route, /"stop_and_review"/);
assert.match(route, /"skip_probe"/);
assert.match(route, /SKIP_PROBE_ACTION/);
assert.doesNotMatch(route, /action === "skip_probe"[\s\S]{0,80}paused/);
assert.match(route, /export const maxDuration = 240/);
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /isPersistedFocusId\(focusId\)/);
@@ -1358,3 +1363,156 @@ test("degraded spoken collect strips discriminator identity", () => {
assert.doesNotMatch(spoken ?? "", /2016/);
assert.equal(spoken, spokenFollowupForUser(spokenFollowup));
});
function withAcceptedTime<T extends { case: { accepted_time: string | null } }>(dossier: T): T {
return {
...dossier,
case: { ...dossier.case, accepted_time: "05:07" },
};
}
function nextFocusHandler() {
return {
set_agentic_rectification_conversation_focus: (_fn: string, args: Record<string, unknown>) => ({
focus: {
id: NEXT_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-04T08:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
};
}
test("post-adopt skip_probe keeps the case open and asks the next verify question", async () => {
const accounting = persistChoiceAccounting(withAcceptedTime(twoProbeDossier()), nextFocusHandler());
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: SKIP_PROBE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: RELOCATION_2015_PROBE.id,
optionId: "skip_probe",
expectedRevision: twoProbeInference().revision,
});
assert.equal(applied.optionId, "skip_probe");
assert.notEqual(applied.nextAction.session_outcome, "provisional_range_user_stopped");
assert.doesNotMatch(applied.narration, new RegExp(RECTIFICATION_USER_COPY.adoptCue));
assert.equal(applied.nextChoiceReady, true);
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.equal(setFocus.args.p_intent, "reverse_verify");
const schema = setFocus.args.p_expected_answer_schema as { semantic_key?: string; choice?: { prompt?: string } };
assert.equal(schema.semantic_key, EDUCATION_2014_PROBE.semantic_key);
assert.match(applied.narration, /2014/);
assert.doesNotMatch(applied.narration, /2015 年 5 月/);
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
assert.equal(persist?.args.p_focus_status, "skipped");
const inference = persist?.args.p_inference as { answer_class?: string; raw_answer?: string } | null;
assert.equal(inference?.answer_class, "unsure");
assert.equal(inference?.raw_answer, "skip_probe");
});
function lastVerifyDossier() {
const inference = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 16 },
{ id: "05:10", time: "05:10", relative_support: 14 },
],
events: [
{ id: "e1", domain: "career", year: 2020, precision: "month" },
{ id: "e2", domain: "career", year: 2024, precision: "day" },
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
{ id: "e4", domain: "career", year: 2026, precision: "day" },
{ id: "e5", domain: "relationship", year: 2024, precision: "day" },
],
probes: [EDUCATION_2014_PROBE],
});
const snapshot = candidateSnapshotFixture({
decisionReceipt: {
inference_state: inference,
discriminating_event_probes: [
eventProbeFromConflict(EDUCATION_2014_PROBE),
],
},
});
return dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_style",
expectedAnswerSchema: {
choice: {
prompt: "2014 年前后,有没有升学、转学或换学习环境?",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
probe_id: EDUCATION_2014_PROBE.id,
semantic_key: EDUCATION_2014_PROBE.semantic_key,
candidate_split_hash: EDUCATION_2014_PROBE.candidate_split_hash,
},
}),
}),
turns: [{
id: TURN_ID,
role: "assistant",
text: "2014 年前后,有没有升学、转学或换学习环境?",
status: "completed",
created_at: "2026-08-28T07:36:54.000Z",
completed_at: "2026-08-28T07:37:34.000Z",
}],
});
}
test("skipping the last post-adopt verify question closes with start_consultation", async () => {
const dossier = withAcceptedTime(lastVerifyDossier());
const accounting = persistChoiceAccounting(dossier, nextFocusHandler());
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: SKIP_PROBE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: EDUCATION_2014_PROBE.id,
optionId: "skip_probe",
expectedRevision: (
dossier.latest_result as {
decision_receipt: { inference_state: { revision: number } };
}
).decision_receipt.inference_state.revision,
});
assert.equal(applied.nextUserAction.id, "start_consultation");
assert.equal(applied.narration, RECTIFICATION_USER_COPY.postAdoptVerifyDone);
assert.equal(applied.nextChoiceReady, false);
assert.doesNotMatch(applied.narration, new RegExp(RECTIFICATION_USER_COPY.adoptCue));
assert.equal(
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
@@ -151,6 +151,30 @@ test("two scoreable events do not open OOS even when holdout prompts exist", ()
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /上学|升学/);
});
test("family collect spoken stem has no year prefix while probe_year stays dated", () => {
const plan = buildMethodFollowupPlan({
evidence: TWO_SCOREABLE,
evidenceCollectionProbes: [{
year: 2021,
year_label: "2021 年前后",
domain: "family",
event_family: "家人结婚、添丁或住院",
source: "age_band",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect",
phase: "evidence_collection",
information_gain: 0,
semantic_key: "family.2021",
}],
});
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
});
test("four scoreable events skip declined OOS domain and ask education holdout", () => {
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
const plan = collectPlan(FOUR_SCOREABLE, {
@@ -14,7 +14,7 @@ import {
rectificationFollowupCatalog,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { buildMethodFollowupPlan, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
@@ -484,7 +484,10 @@ test("revision 5 with uncovered relatives asks the dated family collect, not a y
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
assert.match(plan.next_followup?.year_label ?? "", /2021/);
// 旧:year_label 含 2021 → 新:采集口语不带年份前缀,year_label 不进 followup
// 原因:决策 7;probe_year 仍锁定 dated 家庭采集,不出无年份 D12 卡
assert.equal(plan.next_followup?.year_label, undefined);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
@@ -239,6 +239,7 @@ const ADVANCING_RECTIFICATION_ACTIONS = [
"message",
"answer_choice",
"stop_and_review",
"skip_probe",
] as const;
function routeExitContract(route: string, turnExit: string) {
@@ -284,7 +285,10 @@ test("nonterminal invariant 1: every advancing action exits through an awaited c
failures.push("schema actions and execution declarations differ");
}
for (const action of ADVANCING_RECTIFICATION_ACTIONS) {
const immediate = action === "message" || action === "answer_choice" || action === "stop_and_review";
const immediate = action === "message"
|| action === "answer_choice"
|| action === "stop_and_review"
|| action === "skip_probe";
const streamed = action === "opening" || action === "message";
if (immediate && !contract.immediateAwaited) {
failures.push(`${action}: HTTP 200 response can return without awaiting the common exit gate`);
@@ -13,6 +13,7 @@ import {
parseWindowScan,
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
@@ -2266,9 +2267,12 @@ test("family collect attaches the collection-probe year instead of a yearless D2
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
assert.match(plan.next_followup?.year_label ?? "", /2021/);
// 旧:year_label / spoken 带「2021 年前后,」前缀
// 新:probe_year 仍是 2021,口语等于 USER_COLLECT_QUESTION.family,不填 year_label
// 原因:决策 7,年份只用于计分与去重,不进采集题干
assert.equal(plan.next_followup?.year_label, undefined);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后,家里/);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
});
test("already-open yearless family card is abandoned instead of kept as a scoring frame", () => {
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
templatePostAdoptExplain,
validateAdoptNarration,
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationQuestionGapState,
rectificationQuestionRetryActive,
type RectificationQuestionGapInput,
} from "../src/lib/rectification-surface-state.ts";
const STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
function verifyProbe(input: {
domain: "finance" | "relocation";
year: number;
semantic: string;
split: string;
}) {
return {
year: input.year,
year_label: `${input.year} 年前后`,
domain: input.domain,
event_family: input.domain === "finance"
? "收入明显变化、大笔支出或欠债"
: "搬家或长期住到外地",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: `${input.year} 年前后是否有相关经历。`,
role: "distinguish" as const,
style_options: STYLE_OPTIONS,
semantic_key: input.semantic,
candidate_split_hash: input.split,
};
}
const FINANCE_2013 = verifyProbe({
domain: "finance",
year: 2013,
semantic: "finance.2013.verify",
split: "finance-2013-split",
});
const RELOC_2017 = verifyProbe({
domain: "relocation",
year: 2017,
semantic: "relocation.2017.verify",
split: "reloc-2017-split",
});
const EDUCATION_EVIDENCE = [{
status: "confirmed" as const,
domain: "education",
datePrecision: "year" as const,
occurredFrom: "2008-01-01",
occurredTo: null,
}];
const gapBase: RectificationQuestionGapInput = {
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
};
test("post-adopt reverse-verify skips a probe already present in askedKeys", () => {
const remaining = buildMethodFollowupPlan({
evidence: EDUCATION_EVIDENCE,
accepted: true,
eventProbes: [FINANCE_2013, RELOC_2017],
askedProbeKeys: [FINANCE_2013.semantic_key],
});
assert.equal(remaining.next_followup?.domain, "relocation");
assert.equal(remaining.next_followup?.semantic_key, RELOC_2017.semantic_key);
const none = buildMethodFollowupPlan({
evidence: EDUCATION_EVIDENCE,
accepted: true,
eventProbes: [FINANCE_2013, RELOC_2017],
askedProbeKeys: [FINANCE_2013.semantic_key, RELOC_2017.semantic_key],
});
assert.equal(none.next_followup, null);
const action = buildNextUserAction({
scorableCount: 1,
evidenceCount: 1,
hasLatestResult: true,
selectionAllowed: true,
sessionOutcome: "adopt_representative",
nextFollowup: none.next_followup,
workingTime: "05:07",
accepted: true,
});
assert.equal(action.id, "start_consultation");
});
test("post-adopt reverse-verify also dedupes by candidate_split_hash", () => {
const plan = buildMethodFollowupPlan({
evidence: EDUCATION_EVIDENCE,
accepted: true,
eventProbes: [FINANCE_2013, RELOC_2017],
askedProbeKeys: [FINANCE_2013.candidate_split_hash],
});
assert.equal(plan.next_followup?.domain, "relocation");
assert.notEqual(plan.next_followup?.semantic_key, FINANCE_2013.semantic_key);
});
test("empty reverse-verify list forbids a 会拿…核对 promise", () => {
const facts = {
precision_stage: "ready_to_adopt" as const,
already_accepted: false,
credible_range: ["05:00", "05:06"] as const,
representative_minute: "05:00",
runner_up_minute: "05:06",
opening_window: ["04:45", "05:15"] as const,
active_candidates: [],
answered_rounds: 5,
stop_facts: [],
post_adopt_verification: [],
minutes: ["05:00", "05:06"],
years: [],
};
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
const denied = validateAdoptNarration("采用后会拿 2016 年教育题来核对。", facts);
assert.equal(denied.ok, false);
if (!denied.ok) assert.equal(denied.reason, "promise");
});
test("question gap: start_consultation is verified_idle; missing next_user_action keeps the old path", () => {
assert.equal(rectificationQuestionGapState({
...gapBase,
nextUserActionId: "start_consultation",
}), "verified_idle");
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
assert.equal(rectificationQuestionGapState({
...gapBase,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
}), "unavailable");
});
test("verified_idle UI is a closing line without reload, gap copy, or consult handoff", () => {
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(chat, /questionGap === "verified_idle"/);
assert.match(chat, /RECTIFICATION_USER_COPY\.postAdoptVerifyDone/);
assert.match(chat, /skip_this_probe/);
assert.match(chat, /SKIP_PROBE_ACTION/);
assert.doesNotMatch(chat, /rectification-consult-handoff/);
const idle = chat.slice(chat.indexOf('questionGap === "verified_idle"'));
assert.doesNotMatch(idle.slice(0, 400), /rectification-question-gap__copy/);
assert.doesNotMatch(idle.slice(0, 400), /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /已采用 \{savedTime\}/);
assert.match(chat, /之后新建对话即按此时间排盘/);
});
test("family collect spoken stem equals USER_COLLECT_QUESTION.family while probe_year stays dated", () => {
const plan = buildMethodFollowupPlan({
evidence: [
{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2011-01-01", occurredTo: null },
{ status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2014-01-01", occurredTo: null },
],
evidenceCollectionProbes: [{
year: 2021,
year_label: "2021 年前后",
domain: "family",
event_family: "家人结婚、添丁或住院",
source: "age_band",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect",
phase: "evidence_collection",
information_gain: 0,
semantic_key: "family.2021",
}],
});
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(plan.next_followup?.choice_frame, null);
const spoken = spokenFollowupForUser(plan.next_followup);
assert.equal(spoken, USER_COLLECT_QUESTION.family);
assert.doesNotMatch(spoken ?? "", /^\d{4} 年前后,/);
});
@@ -505,10 +505,13 @@ test("spoken collect followup persists a collect focus without a choice card", a
assert.equal(result.focus?.intent, "collect_method_evidence");
assert.equal(result.focus?.expectedAnswerSchema.collect, true);
assert.equal(result.focus?.expectedAnswerSchema.choice, undefined);
assert.match(String(result.focus?.expectedAnswerSchema.prompt ?? ""), /2021/);
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family
// 原因:决策 7,采集题不拼年份前缀;fixture 仍带 probe_year 供计分
assert.equal(result.focus?.expectedAnswerSchema.prompt, USER_COLLECT_QUESTION.family);
const openCollect = openQuestionFromPersistedFocus(result);
assert.equal(openCollect?.kind, "collect_spoken");
assert.match(openCollect?.prompt ?? "", /2021/);
assert.equal(openCollect?.prompt, USER_COLLECT_QUESTION.family);
assert.doesNotMatch(openCollect?.prompt ?? "", /2021/);
assert.notEqual(openCollect?.unrenderable, true);
});
@@ -106,6 +106,42 @@ test("retracts a live spoken prefix if that step later calls a public tool", ()
assert.deepEqual(spoken, { kind: "live", text: "诊断已经看过。接下来确认入职年份。" });
});
test("keeps a live opening greeting when the next public tool is set-focus", () => {
const state = createStepAnswerState();
assert.deepEqual(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "你好,我们从你最容易想起的经历开始就行。" }),
isPublicTool,
),
{ kind: "live", text: "你好,我们从你最容易想起的经历开始就行。" },
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-result", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"none",
);
assert.deepEqual(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" }),
isPublicTool,
),
{ kind: "live", text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" },
);
});
test("does not live-publish English planning before a tool-call", () => {
const state = createStepAnswerState();
assert.equal(
@@ -77,6 +77,7 @@ test("the question gap is a live row with retries, then a reload; it never tells
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{/);
assert.match(chat, /questionGap === "preparing" && \(/);
assert.match(chat, /questionGap === "unavailable" && \(/);
assert.match(chat, /questionGap === "verified_idle" && \(/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
@@ -56,6 +56,7 @@ test("question gap: preparing while retries remain, then unavailable with a relo
assert.equal(rectificationQuestionRetryActive("preparing"), true);
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
assert.equal(rectificationQuestionRetryActive("idle"), false);
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
});
test("question gap: a snapshot that never arrived is a gap the same retries fill", () => {
+8 -2
View File
@@ -43,7 +43,7 @@ TECHNIQUE_STATUSES = ("verified", "partial", "blocked")
CONFLICT_STATUSES = ("unresolved", "partial", "resolved")
REPORT_ACTION_PRIORITIES = ("now", "next", "watch")
V1_CHART_IDS = ("D1", "D9", "D10")
CHART_IDS = ("D1", "D2", "D9", "D10", "D11", "D24")
CHART_IDS = ("D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30")
HOUSE_NUMBERS = tuple(range(1, 13))
FAILURE_CODES = (
@@ -797,7 +797,13 @@ def validate_report_document(document: Any) -> ValidationResult:
_validate_current_phase(result, document.get("currentPhase"))
_validate_action_notes(result, document.get("actionNotes"))
charts = _check_array(result, "charts", document.get("charts"), 3 if version == V1_SCHEMA_VERSION else 6, 1)
charts = _check_array(
result,
"charts",
document.get("charts"),
3 if version == V1_SCHEMA_VERSION else len(CHART_IDS),
1,
)
if charts is not None:
for index, chart in enumerate(charts):
_validate_chart(result, f"charts[{index}]", chart, version)
+19
View File
@@ -316,3 +316,22 @@ def test_validator_accepts_synthetic_producer_output() -> None:
}
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
assert validate_report_document(document).valid
def test_v2_accepts_full_chart_ids_set() -> None:
from scripts.personal_report_contract import CHART_IDS
document = load_report_document(str(ROOT / "tests" / "fixtures" / "personal_report_document.v2.json"))
template = document["charts"][0]
present = {chart["id"] for chart in document["charts"]}
for chart_id in CHART_IDS:
if chart_id in present:
continue
extra = json.loads(json.dumps(template))
extra["id"] = chart_id
extra["title"] = f"{chart_id} 分盘"
document["charts"].append(extra)
assert len(document["charts"]) == len(CHART_IDS)
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
result = validate_report_document(document)
assert result.valid, result.errors