Compare commits

...

9 Commits

Author SHA1 Message Date
Jesse_Chen 84a2975473 docs: record BUG-577–580 fix SHA 4e0db55f
Independent Staging Quality Gate / validate (push) Successful in 11m37s
Independent Staging Quality Gate / publish (push) Successful in 18m42s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 15:47:19 +08:00
Jesse_Chen 4e0db55f03 fix(rectification): keep compare requests valid after style cards (BUG-577–580)
Engine asked_probe_keys no longer include varga split hashes that 400 the scorer, failed compares become visible and retry, user stop can still deliver a range on a stale snapshot, and holdout no longer reasks domains already in the ledger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 15:46:33 +08:00
Jesse_Chen 3f4d38d485 docs: record BUG-576 fix SHA e87c58d6
Independent Staging Quality Gate / validate (push) Successful in 10m53s
Independent Staging Quality Gate / publish (push) Successful in 9m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:21:06 +08:00
Jesse_Chen e87c58d6e4 fix(report): persist longform appendix on self-hosted upsert (BUG-576)
Engine calls already returned markdown; the worker failed because local postgres upsert required onConflict and the appendix write omitted it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:19:21 +08:00
Jesse_Chen 4716fe46b3 docs(tasks): P0 stale-compare fix brief (BUG-575~578)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-07 06:14:21 +00:00
Jesse_Chen ed0cd087d7 docs: record BUG-575 fix SHA 1ded6bb0
Independent Staging Quality Gate / validate (push) Successful in 11m2s
Independent Staging Quality Gate / publish (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:08:56 +08:00
Jesse_Chen 1ded6bb0d4 fix(rectification): hide option hover impact and composer step strip (BUG-575)
Hovering A/B/C/D rewrote a leading/lagging time line and made the card flicker; the step-state sentence above the composer was instructional filler, not something the user needed to answer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:07:49 +08:00
Jesse_Chen 58081a2d51 docs(report): add MD-only first-run failure forensics brief
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-07 05:39:35 +00:00
Jesse_Chen 84ff6c9775 docs(tasks): accept 8e31680b (BUG-571~573 declared uncertainty)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-07 04:47:58 +00:00
62 changed files with 1666 additions and 270 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-07 — 性格卡答完后比较不再静默失败,停止时仍能给出当前范围
生时校正答完性格或月宿对照卡后,后面的候选比较不再被过长的内部键挡掉。比较没跑成时会直接说明,并在下一句话自动再试。点「先这样,先看当前范围」时,即使最新比较还没跟上账本,也会按已有候选给范围;如果只能沿用上一次成功比较,旁白会标明。已经说过的带年月领域不会再被拿来改写成「再说一件事」。Skill 版本仍是 10.0.14。
## 2026-09-07 — 出生时间有多确定只问一次,校正按你选的范围去比
填报出生资料时问一次「你对这个时间有多确定」:有医院记录、家人记得大概时间(差不多准 / 前后半小时 / 一小时 / 两小时),或只知道时段、完全不清楚。校正直接读档案里的范围,不再追问。家人说前后一小时,顶部就是两小时;前后两小时或傍晚这种超过两小时的范围,先切成三段再比。按你说的经历对不上、时间又贴着窗口边上时,会出一张卡问要不要放宽,放宽后设置页的范围跟着变。日级经历会补问一句日子是查过记录还是凭记忆。文案不用「偏移 / 误差 / 置信度 / 概率」。Skill 版本仍是 10.0.14。
+58
View File
@@ -0,0 +1,58 @@
# 任务书 · MD-only 首跑失败取证与修复(2026-09-07)
基线:`origin/staging` @ `7cf7705a`(开工 `git fetch` 后以 HEAD 为准)。
## 事故实证
- 2026-09-07 05:14 UTC 用户创建 personal_full5 主题),**MD-only 路径第一次真实运行**:`failed / calculation_unavailable``progressPercent=30 / progressPhase=failed`(引擎调用阶段)。报告 id `de2c2efb-9f1e-4cc6-8dc3-72bdfdb3b977`requestId `d17c27ef-2ceb-4dd8-af4d-12107b4de7e4`。当时部署 `72dd5e9d`(含 BUG-574 修复与 MD-only worker)。
- **委托方本地复现排除了代码路径**:最新 staging 代码起本地引擎,按 `buildLongformBirthPayload` 同形 payload(虚构盘 + provisional + candidate_range + packs:["full"] + markdownPOST `/api/professional_report_reference`**HTTP 200、3.3 秒、315KB**。代码与合同没问题,失败是 **staging 环境特有**
- detail API 未透出附录错误码(观测缺口,本轮补)。
## 两个可判别嫌疑(任务 0 一条查询定案)
`generatePersonalReportLongform` 失败时把错误类别写进 `personal_report_longform_appendices.last_error_code`
| last_error_code | 含义 | 指向 |
|---|---|---|
| `upstream_unavailable` / `upstream_busy` | 引擎 HTTP 非 200 | **嫌疑 Astaging api 容器版本滞后**web 随 deploy 更新,api 容器若未随部署重建,端点行为/参数支持与 web 不配套)或引擎在该输入上 500 |
| `generation_failed` | 非 LongformGenerateError180s `AbortSignal.timeout` 的 TimeoutError 落这里) | **嫌疑 Bstaging 上全量 pack 超 180 秒**(外部 VedAstro 网关串行拉满;本地 3.3s 是因外部层快速 blocked |
| `empty_markdown` | 200 但空文 | 引擎版本/参数不配套的另一种表现(同嫌疑 A) |
## 任务 0P0,门控)· staging 取证定案
1. 查 requestId `d17c27ef…` 的 appendix 行:`status / attempt_count / last_error_code`
2. web 容器日志该时段 `[personal-report]` 行;api 容器访问日志有无对应 `POST /api/professional_report_reference` 及其耗时/状态码。
3. **api 容器运行版本**:镜像构建时间 / 容器内代码是否含 `professional_report_reference` 最新实现(gaps2 的 `e4d16b75` 级别);对照 deploy 管线是否会重建 api 容器。
4. 从 web 容器内对 `api:5200` 手工 POST 同形 payload(虚构盘)计时与状态码。
PROGRESS 写明定案(A / B / 其他),再进任务 1。
## 任务 1P0)· 按定案修复
- **定案 A(api 容器滞后)**:把 api 容器纳入部署链(或把"api 容器更新步骤"固化进 deploy 流程与 runbook),更新后复测;防复发:health 增加引擎版本/构建指纹字段,web 与 api 版本不配套时可见。
- **定案 B(超时)**:不许一上来就调大 180s——先量真实耗时构成(外部网关占比);优先让报告路径的引擎调用带 `defer_optional_external_evidence`(或等价参数)跳过/并行可选外部证据(引擎已有该机制,consultation 前台路径在用),把确定性本地全量控制在可预算时间内;外部层缺席按诚实标签落 blocked 行(gaps2 静默跳过禁令继续适用)。超时值若仍需调整,用实测数据定,写 PROGRESS。
- 任何情况下:`calculation_unavailable` 保持 retryable 语义,但确认 job 三次尝试真实发生且每次都调用了引擎(时间戳对齐)。
## 任务 2P1)· 观测补口
- detail API(失败报告)透出 appendix 的 `last_error_code`(错误码级,不含内容),报告页失败态显示可读原因——本轮取证难就难在这个码埋在表里。
- worker 引擎调用记录耗时与 HTTP 状态(数值日志)。
## 任务 3(P0 收尾)· 收官 smoke 重跑
修复部署后按 `docs/operations/personal-report-staging.md` smoke:列表/详情/创建,创建走到 ready——**这是报告线的最终收官项**:~30s(或实测预算内)ready、详情页 TOC/宽表、导出 .md、0 用量计费结算、新卡片 `card_summary` 显示。
## 硬红线
既有全部延续(迁移规范、诚实标签、静默跳过禁令、不改 `.gitea/workflows/**`、不提升 main、`./node_modules/.bin/tsc`、真实用户资料不入库、日志无内容)。**取证要快**——容器 recreate 丢日志的教训已发生过一次。
## 收尾
`docs/tasks/PROGRESS-report-longform-e2e-fail-20260907.md``docs/BUG_HISTORY.md` 条目(编号对远端);不提升 main。
## 交付物清单
1. 任务 0 四项取证与定案
2. 按定案的修复 diff + 防复发项(api 版本可见性 或 外部证据 defer + 实测耗时表)
3. 观测补口 + 测试
4. 收官 smoke 全项记录
+1
View File
@@ -9,6 +9,7 @@ services:
- ${APP_ENV_FILE:-../.env.production}
environment:
PORT: 5200
GITHUB_SHA: ${GITHUB_SHA}
JYOTISH_ALLOWED_HOSTS: localhost,127.0.0.1,::1,api
expose:
- "5200"
+97
View File
@@ -8902,4 +8902,101 @@
- 复发自:无
- 修复版本:`b466a6fc8cbb17d48c5aa242dbafd44b9a7c651b`
## BUG-575 | 生时校正选项悬停时间解释闪动,输入框上方步骤提示是套话
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-07
- 影响面:`RectificationChoiceCard``RectificationAgenticChat` 输入框上方步骤条
- 用户现象:鼠标在 A/B/C/D 选项间移动时,选项下方出现「会让 … 这段领先/落后」并随悬停切换不断闪动;输入框上方还有「第 N 步·… — … — 下一步:…」套话。
- 触发条件:生时校正出现选择题后,鼠标在选项间来回移动;或进入校正对话看到输入框。
- 根因:选择题把 `answer_impact` 绑到 `onMouseEnter`/`onFocus`,悬停切换会改写同一行文案并撑开布局。步骤条把服务端 `step_state` 拼成输入框上方提示。
- 修复:选项不再监听悬停、不再渲染 `answer_impact`。输入框上方不再展示步骤条;采集题的「先这样」按钮仍保留在输入框上方。服务端仍可生成 `answer_impact``step_state`,只是用户界面不再读出来。
- 验证:`frontend/tests/rectification-surface-contract.test.ts``frontend/tests/rectification-step-state-20260906.test.ts``frontend/tests/rectification-v9-contracts.test.ts`
- 防复发:选择题源码不得再出现 `onMouseEnter``rectification-choice-impact`;聊天源码不得再出现 `rectification-step-state`
- 相关记录:无
- 复发自:无
- 修复版本:`1ded6bb0`
## BUG-576 | MD-only 长报告引擎 200 后附录 upsert 未带冲突键,staging 三次重试仍 failed
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-07
- 影响面:`generatePersonalReportLongform``personal_report_longform_appendices`、自托管 `createLocalPostgresDataClient` upsert、报告详情失败态
- 用户现象:personal_full MD-only 首次真实运行在引擎调用阶段失败(`calculation_unavailable``progressPercent=30`)。直连引擎同形虚构盘 HTTP 200。
- 触发条件:staging 自托管 web worker 调用 `/api/professional_report_reference` 成功后写入 longform 附录。
- 根因:本地 postgres 客户端的 `upsert` 必填 `onConflict`;附录写入只调用 `.upsert(row)`。请求在写库前抛错,附录表 0 行。Job 三次尝试均打到引擎(三次 HTTP 200,约 26–32s,低于 180s 超时)。不是 api 容器滞后,也不是引擎超时。web 容器 docker logs 几乎只有启动行,附录错误码此前也不在详情 API 中。
- 修复:附录 upsert 显式 `onConflict: "report_id"`;本地客户端缺省冲突键回落到主键;详情失败态透出 `appendixLastErrorCode` 与可读原因;引擎调用记录 `http_status`/`duration_ms`api 容器与 `/api/health` 暴露构建 SHA。
- 验证:定向 frontend 单测、附录 `test:db` upsert 无 `onConflict`、health/compose 源合同。未改 `.gitea/workflows/**`,不提升 main。
- 防复发:自托管 upsert 必须能在缺 `onConflict` 时按主键执行;附录写入必须带 PK 冲突键;失败详情必须带附录错误码。
- 相关记录:BUG-574
- 复发自:无
- 修复版本:`e87c58d6e48850749ea9e0a92e532133db03e292`
## BUG-577 | 性格卡答过之后,后续每次候选比较都被 400 拒绝
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-07
- 影响面:`asked_probe_keys``normalize_rectification_request``rectification-compare-candidates``inference-adapter.ts`
- 用户现象:性格对照卡或月宿边界卡答完后,再说一件事时候选比较立刻失败(约 30ms,不是引擎超时)。顶部范围不再随新经历变化。
- 触发条件:答过带 `candidate_split_hash` 的性格/边界卡后,再走候选比较。
- 根因:BUG-559 把 `probe_id``semantic_key``candidate_split_hash` 都塞进引擎 `asked_probe_keys`。性格卡 hash 约 128 字符,引擎原上限 120,整单请求 400。去重仍应靠 `semantic_key`,不该把 hash 传给引擎。
- 修复:引擎请求只传 `semantic_key` 与账本年份键;TS 再丢掉 `>200` 或含 `:varga.` 的键。引擎上限改为 200,超长键跳过并在 receipt 计数 `dropped_asked_probe_keys`,不再 400。128 字符键在新上限下会保留(决策是提上限,不是跳过 128)。
- 验证:`tests/test_rectification_input_contract.py``frontend/tests/rectification-v9-engine-contract.test.ts``frontend/tests/rectification-probe-year-dedupe-20260906.test.ts`
- 防复发:比较请求体不得含 `:varga.` hash;引擎契约须覆盖真实长度的 split hash,不能只用短键。
- 相关记录:BUG-559、BUG-578、BUG-579、BUG-580
- 复发自:BUG-559
- 修复版本:`4e0db55f`
## BUG-578 | 候选比较失败被吞掉,过期快照也不自动重算
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-07
- 影响面:`rectification-compare-candidates` receipt、`agent-run.ts``persistNextInterviewIfIdle`
- 用户现象:比较已经失败,助手仍写「这条记下了、很有帮助」。用户看不到失败,下一轮也不重试。
- 触发条件:比较工具抛错(含引擎 400)后 Agent 继续作答。
- 根因:失败 receipt 只有笼统 `tool_failed`;Agent 不追加可见说明;闲置下一问不因快照过期重算。
- 修复:失败 detail 带 `safe_error_code` 与引擎信息前 120 字;正文追加「候选比较这次没跑成,下一句话时会自动再试。」;闲置持久化若快照过期且账本有可评分事件,同一证据指纹只重算一次。
- 验证:`frontend/tests/rectification-stale-compare-fix-20260907.test.ts`
- 防复发:比较失败必须有用户可见固定句;过期快照的闲置路径必须先重算。
- 相关记录:BUG-577
- 复发自:无
- 修复版本:`4e0db55f`
## BUG-579 | 用户停止被过期快照压过,点「先这样」后没有候选卡
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-07
- 影响面:`decideRectification``applyRectificationChoice` STOP、`stop_and_review`
- 用户现象:点「先这样,先看当前范围」后出现「再问下去也分不开」一类旁白,但没有候选卡,界面写「没有拿到下一个问题」。
- 触发条件:账本已比最新比较结果多出可评分事件(快照过期)时停止。
- 根因:`snapshotCurrent === false` 排在 `userStopped` 之前,停止后仍判采集;停止路径也不先重算。
- 修复:有排序候选时,用户停止优先于快照过期,进入 `complete_with_range``session_outcome` 属于可出采用卡的集合。停止路径先重算;重算失败则按上一次成功比较交付,旁白加「这是按上一次成功比较给出的范围。」
- 验证:`frontend/tests/rectification-decision-authority.test.ts``frontend/tests/rectification-stale-compare-fix-20260907.test.ts``frontend/tests/rectification-answer-choice.test.ts`
- 防复发:`snapshotCurrent=false && userStopped && ranked>0` 必须交付,不得回到采集。
- 相关记录:BUG-577、BUG-578
- 复发自:无
- 修复版本:`4e0db55f`
## BUG-580 | Holdout 题用过期盘外领域,被改写成「再说一件事」
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-07
- 影响面:`holdoutFollowupFor`、采集题干
- 用户现象:账本里已经有该领域带年月的事,助手仍问「也再说一件你能记得大致时间的事」。
- 触发条件:过期 `oos_blind_prompts` 里的领域其实已在账本;焦点按盘外核对给模型改写。
- 根因:holdout 只排除拒答领域,不排除账本已有带年月事件的领域;题干不是服务端固定采集句。
- 修复:候选领域须既未拒答、账本也没有该领域带年月事件。没有可问领域则不再出 holdout 题。题干用 `USER_COLLECT_QUESTION[domain]`,焦点为口述采集,不给选择题框。决策层不因此改成 `unavailable`,以免走成无卡的 offer。
- 验证:`frontend/tests/rectification-collect-direction-20260904.test.ts`
- 防复发:账本已有财务等带年月事件时不得再出该领域 holdout;可问时题干必须等于对应采集固定句。
- 相关记录:BUG-577
- 复发自:无
- 修复版本:`4e0db55f`
@@ -0,0 +1,39 @@
# PROGRESS · 生时校正过期比较修复(2026-09-07)
分支:`codex/rectification-stale-compare-fix-20260907`
任务书:`docs/tasks/TASK-rectification-stale-compare-fix-20260907.md`
编号:任务书写 BUG-575~578;开工时 `docs/BUG_HISTORY.md` 已占用 575(悬停/步骤条)、576(长报告附录)。本单落地为 **BUG-577580**
## 做了什么
- **BUG-577**:引擎比较只传 `semantic_key` + 账本年份键;丢掉 `candidate_split_hash` / `:varga.` / 超 200 字符键。Python 上限 200,超长跳过并计数 `dropped_asked_probe_keys`。128 字符键在新上限下保留(任务书 5.1 写「跳过 128」与决策 1「上限 200」冲突,按决策 1)。
- **BUG-578**:比较失败 receipt 带 `safe_error_code` + `engine_message`(120 字);Agent 正文追加固定句;闲置/非终止退出在快照过期时先重算一次(同 case+指纹+turn 只一次)。
- **BUG-579**`userStopped && ranked>0` 排在 `snapshotCurrent === false` 之前。STOP / 无焦点 `stop_and_review` 先重算;重算失败旁白「这是按上一次成功比较给出的范围。」
- **BUG-580**holdout 排除账本已有带年月事件的领域;无可问则不再出 holdout 题。题干 = `USER_COLLECT_QUESTION[domain]`,口述采集,无选择题框。不把该状态改写成决策层 `unavailable`(那条路径会变成 `offer_provisional_range` 而不是交付)。
- **P2**`composeCollectSpokenAssistantText` 去掉与题干前 12 字相同的复述句。`intent.classified` 仍可能因 `case.loaded` 两次出现;本单未拆 Agent 生命周期,记在偏离。
未改:采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1` 指纹身份。
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| `four scoreable events skip declined OOS domain and ask education holdout` | `intent=out_of_sample_check` / `source=oos_blind` / `domain=education` | holdout 为 null;下一问不是已在账本的 education/finance | BUG-580 账本已有学业/财务不得再 holdout |
| `stop_and_review does not write an inference transition` | 旁白匹配「已记录你的选择」 | `session_outcome ∈ ADOPT_OUTCOMES``can_adopt=true`;仍不写 inference | BUG-579 停止必须能出卡 |
| Python 128 字符键 | 任务书 5.1:跳过并计数 | 保留,无 `dropped_asked_probe_keys` | 决策 1 把上限提到 200 |
## 测试
- Python `tests/test_rectification_input_contract.py` + `tests/test_rectification_v5_services.py`38 passed
- 任务书指定 TS 套件(`rectification-*.test.ts` + `consultation-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):1196 passed / 0 failed
- 全量 frontend `tests/*.test.ts`(排除 database):2814 passed / 0 failed(≥1899
- `frontend` `tsc --noEmit`0
- 指定 Python 契约:128 字符键保留、不 400
## 偏离
1. Bug 编号 577580,不是任务书的 575578。
2. 128 字符键保留,不跳过。
3. P2 只做了题干近重复删除;`intent.classified` 双触发未在本提交拆掉。
4. 预检:本机按 ERR-078 用项目 `.venv`;不把 `.workbuddy` 当主仓。
5. 无可问 holdout 领域时不把决策层改成 `unavailable`,只在提问层返回 null,避免误入 `offer_provisional_range`
@@ -0,0 +1,54 @@
# PROGRESS · MD-only 首跑失败取证与修复(2026-09-07)
工作树:`.worktrees/report-longform-e2e-fail-20260907`
分支:`codex/report-longform-e2e-fail-20260907`
任务书:`TASK-report-longform-e2e-fail-20260907.md`
基线:任务书 `58081a2d`;开工后 `origin/staging` 已含 `ed0cd087`(BUG-575 校时 UI)。事故当时健康检查 SHA 为已部署的 `7cf7705a`
## 任务 0 定案
**定案 C:附录 persist 在自托管 upsert 上失败(不是 A,也不是 B)。**
| 项 | 证据 |
|---|---|
| 附录行 | `request_id=d17c27ef-…` 0 行;全表 `personal_report_longform_appendices` 也是 0 行 |
| Job | `failed / calculation_unavailable``attempt_count=3/3``progressPercent=30``05:14:15Z``05:15:48Z` |
| API 访问日志 | 三次 `POST /api/professional_report_reference`**200**`05:14:42``05:15:14``05:15:48`(与 job 三次尝试对齐) |
| 容器版本 | web/api 同批 recreate `05:02Z`web `GITHUB_SHA=7cf7705a`api 镜像含 `format=markdown` / `packs`;含 gaps2 `e4d16b75` 祖先 |
| 任务 0.4 | 从 web 容器对 `api:5200` 虚构盘 POST`http_status=200 duration_ms=26148 bytes=263471 format=markdown markdown_len=250452` |
| web 日志 | docker `LogPath` 空,近乎只有 Next 启动行;`[personal-report]` 不可见 |
排除:
- **A api 容器滞后**:端点存在且返回 markdownweb/api 同批部署。
- **B 180s 超时**:单次 2632s,三次合计约 93s,均低于 `AbortSignal.timeout(180s)`
根因:`persistLongformAppendix` 调用 `.upsert(row)`,自托管 `LocalPostgresQueryBuilder.upsert` 读取 `options.onConflict` 时同步抛错,引擎 200 的 markdown 从未入表。
未读取或写入真实用户正文/出生资料。
## 任务状态
| 任务 | 状态 | 说明 |
|---|---|---|
| 0 取证定案 | 完成 | 定案 C |
| 1 按定案修复 | 完成 | PK upsert + 本地客户端缺省主键;未调大 180s |
| 2 观测补口 | 完成 | 详情透出 `appendixLastErrorCode`;引擎 `http_status`/`duration_ms`health `apiGitCommit` |
| 3 收官 smoke | 未部署 | 代码修复待 push/deploy 后按 `docs/operations/personal-report-staging.md` 重跑 |
## 实测耗时
| 路径 | HTTP | duration_ms | 备注 |
|---|---|---|---|
| 事故三次引擎调用 | 200 | ~32s / 次 | 与 job 三次尝试对齐 |
| 任务 0.4 虚构盘 | 200 | 26148 | markdown 250452 字符 |
未启用 `defer_optional_external_evidence`:真实耗时已在预算内。
## 质量门
定向 frontend / Python 源合同与附录 db 测(见提交说明)。未改 `.gitea/workflows/**`,不提升 main。
## BUG
`docs/BUG_HISTORY.md` **BUG-576**(远端 staging 已占用 BUG-575 给校时 UI)。修复提交 `e87c58d6`
+2 -1
View File
@@ -67,7 +67,8 @@
| `TASK-rectification-range-reading-20260906.md` | `PROGRESS-rectification-range-reading-20260906.md` | 可信区间成为一等公民:采用时落库 `adopted_credible_range`,报告 `read_report_candidate_range` 与聊天 `verified_chart(accepted)` 都改读它并接同一份 `birth_time_sensitivity`(现在报告读的是开工窗口,BUG-568);引擎 >15 分钟只取 3 样本改为 ≤31 逐分钟;采用旁白加「稳定 / 随分钟变」两句;`declared_birth_window` 复用 | 已验收通过(test:db 37/0 为执行方数字,本机无 Docker) | `814c924e`(BUG-568);两条迁移待应用 |
| `TASK-rectification-unknown-time-20260906.md` | `PROGRESS-rectification-unknown-time-20260906.md` | 完全不知道出生时间的两段式路线:`stage=block_scan` 以 10 分钟步长扫 24 小时只做事件计分、出五时段四选卡(不写账本不采用),选定后进现有分钟流程;引擎加 `minute_step`;开场读 `birth_time_clue`;删 intake 劝退文案 | 已验收(带修复单:时段支持度按段长偏置,下午段先天 25%,P1) | `814c924e`;修复单 `TASK-rectification-capability-fix-20260907.md`BUG-570 |
| `TASK-rectification-capability-fix-20260907.md` | `PROGRESS-rectification-capability-fix-20260907.md` | 能力补齐修复单:答后旁白把 `range_start/range_end`(搜索窗口)当范围比较,每题都说「范围没变」;`block_scan` 五段支持度按段内原始分求和,长时段先天占优(24/24/36/30/30 个候选);`TRACK_LABEL` 音译与产品 Vimshottari/Narayana 口径不一;BLK-001 写到 `docs/BLOCKED.md` 应回根目录 | 已验收通过(P2block_scan 15 s 壁钟断言在门禁里可能间歇红) | `517df002`BUG-569570);staging 未部署,需先 Migrate Staging Database |
| `TASK-rectification-declared-uncertainty-20260907.md` | `PROGRESS-rectification-declared-uncertainty-20260907.md` | 出生时间「有多确定」只在 intake 问一次:三档(医院记录 / 家人大概 ±15·30·60·120 / 时段或未知),校正窗口读档案(现在有钟点一律 ±15,声明值被忽略,BUG-571);吻合率 <60% 且代表分钟贴窗口边缘时出服务端一键放宽卡,放宽后重算并写回档案(BUG-572);窗口 >120 分钟先切三子段迭代到 ≤120 再进分钟(BUG-573);日级事件问一次可靠度;四个脚本化手测场景进 docs/testing | 验收 | `8e31680b`BUG-571573);`codex/rectification-declared-uncertainty-20260907`;需 Migrate Staging Database |
| `TASK-rectification-declared-uncertainty-20260907.md` | `PROGRESS-rectification-declared-uncertainty-20260907.md` | 出生时间「有多确定」只在 intake 问一次:三档(医院记录 / 家人大概 ±15·30·60·120 / 时段或未知),校正窗口读档案(现在有钟点一律 ±15,声明值被忽略,BUG-571);吻合率 <60% 且代表分钟贴窗口边缘时出服务端一键放宽卡,放宽后重算并写回档案(BUG-572);窗口 >120 分钟先切三子段迭代到 ≤120 再进分钟(BUG-573);日级事件问一次可靠度;四个脚本化手测场景进 docs/testing | 验收通过(2 P3 建议:可靠度正则去掉「记得」、档案写回改走 account-profile-patch | `8e31680b`BUG-571573);迁移 `20260907020000` 待应用,部署前先 Migrate Staging Database |
| `TASK-rectification-stale-compare-fix-20260907.md` | `PROGRESS-rectification-stale-compare-fix-20260907.md` | **P0**BUG-559 把过长 `candidate_split_hash` 塞进引擎比较键,答完性格/边界卡后比较静默失败;快照过期压过用户停止;holdout 用过期领域被改写成「再说一件事」;采集轮正文双写 | 待验收(当天须合入部署) | `codex/rectification-stale-compare-fix-20260907`BUG-577580;任务书仍写 575578,开工时 575/576 已被占用) |
| `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505509 |
@@ -95,3 +95,28 @@ ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && ls tests/rectification-*.test.ts tests/consultation-*.test.ts tests/report-*.test.ts tests/personal-report-*.test.ts tests/birth-time-*.test.ts tests/account-profile*.test.ts tests/agent-voice-copy-contract.test.ts 2>/dev/null | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
grep -o "^## BUG-5[0-9][0-9]" ../docs/BUG_HISTORY.md | tail -1
```
## 验收(Claude2026-09-07`origin/staging` @ `7cf7705a`,实现 `8e31680b`
| 门 | 结果 |
| --- | --- |
| tsc / lint | 0 错 / 0 error83 warning,既有类型) |
| 前端 rectification + consultation + report + personal-report + birth-time + account-api + voice(非 DB | 1899 / 0 |
| Python v5_services / event_probes / engine_convergence / growth contract / flexible engine / input_contract | 全绿 |
| `minute_step=1` 字节级不变(虚构 7 件事新旧引擎复跑) | candidate_scores / result_id / spec_hash 全等 |
| 子段扫描(18:0022:59 三等分,3 件事) | 0.8 s,步长 5,三段各 20 个候选,支持 48.7 / 41.4 / 9.9,和 100 |
| Docker `test:db` | 执行方 37/0(含 widen 扩大 / 缩小拒绝 / 权限;08:0011:59 选定后仍 block_scan08:0009:00 进 minute);本机无 Docker,取其数字 |
| 项 | 结论 |
| --- | --- |
| D1 intake 三档 | 通过。三档文案"你对这个时间有多确定"approximate 四个范围按钮,`family_exact` 读档映射"差不多准",校验集合 {15,30,60,120} 两处同改 |
| D2 窗口读档案(BUG-571 | 通过。`deriveDeclaredSearchWindow`:医院/存量准确 → ±15;approximate → 前后各自 clamp(15..120);既有 04:4505:15 断言只保留给 family_exact,三栏齐 |
| D5 子段扫描(BUG-573 | 通过。`blocks` 进请求契约(1~5 段、须落窗内、除共享端点不重叠);步长随窗宽 10/5/2;`advance_*` 改为"新窗须是当前窗子区间"span>120 且 rounds<3 留 block_scan;最多 3 轮后强制 minute 并有旁白;全零均分改 100/n(执行方偏差合理:写死 20 在三段只到 60) |
| D3 放宽卡(BUG-572) | 通过。触发条件六项齐;RPC 校验含旧窗、inclusive ≤241、stage=minute、未采用;放宽后按 `stageForClockWindow` 决定去向;档案写回 approximate + 新半径,`hospital_record` 只改窗口;C/D 记指纹 |
| D6 可靠度一问 + 脚本 | 通过。只对本回合刚写入的日级事件问一次,答案正则映射 high/medium,薄 RPC 原地写(执行方偏差合理:修订链会改指纹误伤训练门);`docs/testing/rectification-scenarios-20260907.md` 四场景齐 |
| D4 记录 | 通过;四处书面偏差都在进度记录里写明 |
| P3 | `classifyDateReliabilityUtterance` 用「记得」判 medium,用户在该焦点下改说新经历("我记得 2018 年入职")会被顺带记成凭记忆;影响只到 `date_quality` 权重,可接受但建议把"记得"从正则里去掉,只留"凭记忆 / 印象 / 大概" |
| P3 | `patchV9BirthUncertainty` 直接 `from("profiles").update`,绕开 `account-profile-patch` 的校验层,档案由此多了第二条写路径;值是常量所以当前无害,建议改走同一 patch 函数 |
| P2(沿用) | `test_block_scan_seven_events_finishes_within_fifteen_seconds` 壁钟断言仍在,门禁可能间歇红 |
部署:staging 当前 `72dd5e9d`(迁移到 `20260907010000`),本轮迁移 `20260907020000` 未应用,部署前先 Migrate Staging Database。真实环境走查按 `docs/testing/rectification-scenarios-20260907.md` 四个脚本。
@@ -0,0 +1,92 @@
# TASK · 生时校正修复单:答完性格卡后每次候选比较都静默失败,快照过期把对话拖进死胡同(2026-09-07)
- 基线:`origin/staging` @ `7cf7705a`(代码头 `8e31680b`,staging 已部署该版本,事故就是这版跑出来的)
- 分支:`codex/rectification-stale-compare-fix-20260907`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/lib/rectification-agentic/v9/inference-adapter.ts``engine-client.ts``frontend/src/mastra/rectification-v9-tools.ts``frontend/src/lib/rectification-agentic/core/rectification-decision.ts``v9/answer-choice.ts``v9/method-followup.ts``holdoutFollowupFor`)、`v9/agent-run.ts``scripts/rectification/contracts.py``frontend/src/lib/rectification-agentic/user-copy.ts`
- BUG 编号起点:**BUG-575**BUG-574 已被报告列表修复占用;开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核)
- 优先级:**P0**,先于 `docs/testing/rectification-scenarios-20260907.md` 的走查;不修这条,走查全部作废
## 1. 事故实证(staging2026-09-07,产品负责人实测;Case JSON 已由产品负责人提供,本单只写结构,不写年份与经历)
对话走向:开场 → 三条口述采集(学业、感情、工作,共 5 件带年月事件)→ 引擎比较成功(21.6 s)→ 两张性格卡(D9 相处方式、D10 做事风格)→ 三张年月存在卡 → 家人拒答 → 财务采集(1 件)→ 搬家采集(1 件)→ 健康拒答 → 职业口述 → 助手说"也再说一件你能记得大致时间的事吧" → 用户点「先这样,先看当前范围」→ 助手写了一段"再问下去也分不开 X 和 Y…下面是这次的结果" → **没有候选卡**,界面只有"目前范围 X–Y,还在收窄"和"没有拿到下一个问题"。
Case JSON 里的关键事实:
| # | 事实 | 位置 |
| --- | --- | --- |
| 1 | `latest_result.createdAt` 停在第 3 条口述之后;财务与搬家两轮的 `rectification-compare-candidates` 都是 `status: failed, detail: {error: "tool_failed"}`,耗时 28 ms / 37 ms(不是引擎超时,是请求被拒) | `turns[].receipt.tool_activities` |
| 2 | 财务轮之后 `askedProbeKeys` 里带上了两张性格卡的 `candidate_split_hash`,形如 `04:45-05:15:<9 个分钟>:varga.d9.<分组>`**长 128 字符**nakshatra 边界探针的 hash 126 字符 | `inference_state.answered_probes[].candidate_split_hash` |
| 3 | 引擎 `normalize_rectification_request``asked_probe_keys` 每条限 **120 字符**,超限直接 `ValueError``BadRequest` → 工具抛错 | `scripts/rectification/contracts.py` L339 |
| 4 | TS 把 `probe_id``semantic_key``candidate_split_hash` 三种键都塞进 `asked_probe_keys` | `inference-adapter.ts::askedProbeKeysFromReceipt` L5355BUG-559 引入) |
| 5 | 于是:**任何 varga_style / nakshatra 卡答过之后,后续每一次比较都失败**;账本已有 7 件事,`latest_result` 只算了 5 件,`evidenceLedgerFingerprint` 与当前账本不等 → `snapshotCurrent=false` | `decision-from-dossier.ts::scoreableSnapshotCurrentFromDossier` |
| 6 | `decideRectification``snapshotCurrent === false` 分支排在 `userStopped` 之前:用户点了停止、`case.status=paused`,决策仍是 `collect``type: ask_fact_collection``session_outcome: collect_evidence``can_adopt: false` → 卡片条件 `ADOPT_OUTCOMES` 不满足 → 无卡;`current_question: null` → 客户端显示"没有拿到下一个问题" | `rectification-decision.ts` L285290`rectification-surface-state.ts` L31 |
| 7 | 工具失败被 Agent 吞掉:助手正文仍写"这条记下了…对缩小候选范围很有帮助",用户不知道比较根本没跑 | `rectification-v9-tools.ts` L2073 只记 receipt 后 `throw`agent-run 继续作答 |
| 8 | "也再说一件你能记得大致时间的事吧"来自 `oos_blind:holdout` 焦点(`kind: reverse_verify`):`holdoutFollowupFor` 从过期 `oos_blind_prompts` 里取了"财务这条线还没用过"(账本其实已有财务事件),模型把它改写成泛泛的"再说一件事" | `method-followup.ts` L16841694`next_user_action.user_meaning` |
| 9 | 采集轮正文重复:第 1、2 轮助手文字各出现两段近似复述;工作采集题在正文出现一次、问题块又出现一次 | `turns[2].text``turns[4].text` |
## 2. 根因
1. **BUG-575P0**`asked_probe_keys``candidate_split_hash` 也传给引擎,varga/nakshatra 的 hash 超过引擎 120 字符上限,比较请求被 400 拒绝。BUG-559 只在 `test_rectification_event_probes.py` 用短键测过,没有用真实 split hash 测过整条 compare。
2. **BUG-576P1**:比较失败没有任何用户可见后果:receipt 只有 `tool_failed`,没有错误码;Agent 继续写"记下了、很有帮助";下一轮也不重试。
3. **BUG-577P1**`decideRectification` 让"快照过期"压过"用户已停止",停止后交付不出来;`persistNextInterviewIfIdle` 的停止路径也不先重算。
4. **BUG-578P1**`holdoutFollowupFor` 只排除拒答领域,不排除账本里已有事件的领域,而且用的是可能过期的 `oos_blind_prompts`;焦点是 `reverse_verify`,题干交给模型自由改写,成了"再说一件事"。
5. **P2**:采集轮正文双写;题干进正文。
## 3. 决策记录
1. `asked_probe_keys` 只传 `semantic_key`(去重、≤120),不传 `probe_id``candidate_split_hash`;引擎侧上限提到 200 并对超长键**跳过而不是拒绝**(记 `dropped_asked_probe_keys` 计数进 receipt)。TS 侧再加一层保险:超过 200 的键在 `engineRequestBody` 里过滤并 `console.warn`
2. 比较失败必须可见:receipt `detail``safe_error_code` 与引擎返回的错误文本前 120 字;agent-run 在工具失败后给正文追加服务端固定句"候选比较这次没跑成,下一句话时会自动再试";下一轮 `persistNextInterviewIfIdle``snapshotCurrent=false` 且账本有可评分事件时先重算一次(复用 `rescoreMinuteAfterWindowChange`),再决策。
3. 用户停止优先于快照过期:`decideRectification``userStopped && ranked.length>0` 提到 `snapshotCurrent` 判断之前;停止路径(route 的 `stop_and_review``applyRectificationChoice` STOP)在决策前先尝试重算,重算失败则按上一次有效结果交付,旁白加一句"这是按上一次成功比较给出的范围"。
4. `holdoutFollowupFor`:候选领域 = `oos_blind_prompts`**既未拒答、账本里也没有该领域带年月事件** 的领域;没有则 holdout 记为 `unavailable`,直接进交付;题干用服务端固定句(`USER_COLLECT_QUESTION[domain]` 同源,不给模型改写),焦点 kind 改为 `collect_spoken` 而不是 `reverse_verify`
5. 正文双写:`composeCollectSpokenAssistantText` 与模型正文若都包含采集题,只保留问题块;模型正文里与 `spokenPrompt` 相同或高度相似(前 12 字相同)的句子删除;两段复述的根因要查(`phases``intent.classified` 出现两次),进度记录写明。
## 4. 硬红线
- 不动采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support`
- `minute_step=1` 路径字节不变(指纹用例)。
- BUG-559 的去重用例照常通过(去重靠 `semantic_key`,不靠 hash)。
- 测试总数 ≥ 1899tsc 0 错;lint 0 error。
- 任务书 / 进度 / Bug 历史不得写入产品负责人 Case JSON 里的年份、经历、Case ID。
## 5. 任务分解
### 5.1 BUG-575
- `askedDiscriminatorKeys` 拆成两份:`askedSemanticKeys`(给引擎)与现有全量(给 TS 去重);`engineRequestBody` 只用前者并过滤 >200`contracts.py` 上限 200、超长跳过、计数进 `decision_receipt.dropped_asked_probe_keys`
- 验收:`tests/test_rectification_input_contract.py`——128 字符键不再报错、被跳过并计数;TS `rectification-v9-engine-contract.test.ts`——用真实 varga split hash(≥128 字符)构造 receipt`engineRequestBody.asked_probe_keys` 全部 ≤120 且不含 `:varga.`**端到端**`rectification-probe-year-dedupe` 加一例——答过 D9 卡后 compare 请求体合法。
### 5.2 BUG-576
- receipt failed detail 加 `safe_error_code` + `engine_message`(截 120 字,不含用户资料);agent-run 工具失败 → 正文追加固定句;`persistNextInterviewIfIdle``inspectNonTerminalTurnExit``snapshotCurrent=false` 时先重算一次(有节流:同一证据指纹只重试一次,记 `rescore_attempted_fingerprint`)。
- 验收:`rectification-exhaustion-exit` 或新文件——比较抛错的回合正文含固定句;下一回合看到过期快照会调用一次 `runV9CandidateScore`,成功后决策不再是 `collect`
### 5.3 BUG-577
- `decideRectification` 顺序调整(决策 3);stop 路径先重算。
- 验收:`rectification-decision-authority.test.ts` 三栏——`snapshotCurrent=false && userStopped && ranked>0``completeWithRange(user_stopped)``session_outcome ∈ ADOPT_OUTCOMES``rectification-window-widen` 或 answer-choice 测试——STOP 在过期快照上先重算再交付。
### 5.4 BUG-578
- `holdoutFollowupFor` 过滤已有事件领域;无可问领域 → `holdoutValidation="unavailable"`;焦点改 `collect_spoken` + 服务端题干。
- 验收:`rectification-choice-card``collect-direction` 测试——账本已有财务事件且家人/健康拒答 → 不出 holdout 题,决策直接交付;有可问领域时题干等于 `USER_COLLECT_QUESTION[domain]`,不是"再说一件事"。
### 5.5 P2 正文双写
- 定位 `intent.classified` 双触发与正文拼接;去重规则见决策 5。
- 验收:`rectification-spoken-collect.test.ts`——采集轮最终正文不含与 `spokenPrompt` 重复的句子;同一段复述不出现两次。
### 5.6 记录
- `docs/BUG_HISTORY.md` BUG-575578575 标"复发自 BUG-559");`CHANGELOG.md``PROGRESS-rectification-stale-compare-fix-20260907.md``docs/testing/rectification-scenarios-20260907.md` 加第 0 条"答完性格卡后再说一件事,顶部范围必须变化或旁白说明比较结果"。
## 6. 让步顺序
5.1 单独就能解封线上,**当天必须合入并部署**;5.2 + 5.3 其次;5.4 必做;5.5 可后置到同分支第二次提交;5.6 不可省。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-stale-compare-fix-20260907 .worktrees/rectification-stale-compare-fix-20260907 origin/staging
cd .worktrees/rectification-stale-compare-fix-20260907
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
ln -s /workspace/Jyotisha/.venv .venv
.venv/bin/python -c "print(len('04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15:varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15'))" # 128
.venv/bin/python -m pytest tests/test_rectification_input_contract.py tests/test_rectification_v5_services.py -q
cd frontend && ls tests/rectification-*.test.ts tests/consultation-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
```
@@ -2,7 +2,19 @@
虚构走查。不要填真实姓名、出生资料或真实经历。对应 `TASK-rectification-declared-uncertainty-20260907.md` 决策 9。
测之前先看 `rectification-declared-uncertainty-20260907.md` 第 0 条。
测之前先看本文件第 0 条。
## 0. 性格卡之后再说一件事
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后依次说三件不同领域、带年月的虚构经历,等到出现候选比较和性格对照卡。答完一张性格卡后,再说一件带年月的事。
期望:
- 顶部范围必须变化,或旁白说明这次比较没跑成、下一句会再试
- 不得出现:比较失败后仍只写「记下了、很有帮助」,顶部范围完全不动
- 点「先这样,先看当前范围」后必须出现候选卡或当前范围,不得只剩「没有拿到下一个问题」
## 1. 范围:家人说两点到四点
+1 -1
View File
@@ -252,7 +252,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring.
- **Candidate result:** keep the reported range, candidate interval, confidence, and active-time status visually separate. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels the representative minute as the true birth time.
- **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem and show one `answer_impact` line for the hovered or selected option. A `rectification-step-state` status sentence sits above the composer, left of the spoken stop button when that button is present.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The composer has no `rectification-step-state` status sentence.
- **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey.
- **Scoring and retry:** `score_pending` is a quiet progress surface with cancellable bounded polling and no manual compare control. `retry_scoring` preserves the confirmed evidence and exposes one explicit retry action. Refresh and device changes resume from the persisted action rather than inferring progress from copy.
- **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute.
+21 -8
View File
@@ -26,7 +26,7 @@ function envCheck(names: string[]): Check {
: { status: "ok" };
}
async function jyotishApiCheck(): Promise<Check> {
async function jyotishApiCheck(): Promise<{ check: Check; gitCommit: string }> {
const started = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
@@ -35,16 +35,28 @@ async function jyotishApiCheck(): Promise<Check> {
cache: "no-store",
signal: controller.signal,
});
const body = response.ok
? await response.json().catch(() => null) as { git_commit?: unknown } | null
: null;
const gitCommit = typeof body?.git_commit === "string" && body.git_commit.trim()
? body.git_commit.trim()
: "unknown";
return {
status: response.ok ? "ok" : "blocked",
message: response.ok ? undefined : `http:${response.status}`,
latencyMs: Date.now() - started,
check: {
status: response.ok ? "ok" : "blocked",
message: response.ok ? undefined : `http:${response.status}`,
latencyMs: Date.now() - started,
},
gitCommit,
};
} catch (error) {
return {
status: "blocked",
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
latencyMs: Date.now() - started,
check: {
status: "blocked",
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
latencyMs: Date.now() - started,
},
gitCommit: "unknown",
};
} finally {
clearTimeout(timeout);
@@ -148,7 +160,7 @@ export async function GET() {
...databaseChecks,
modelProviderEncryption: envCheck(["MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY"]),
modelCatalog,
jyotishApi,
jyotishApi: jyotishApi.check,
rectificationMigrations: migrations.check,
researchTruthSource: {
status: truthSourceIdentity.status,
@@ -162,6 +174,7 @@ export async function GET() {
timestamp: new Date().toISOString(),
deployment: {
gitCommit,
apiGitCommit: jyotishApi.gitCommit,
},
database: {
latestMigration: migrations.database.latestMigration,
@@ -299,6 +299,7 @@ export async function POST(request: Request) {
userId,
caseId,
narrateAdopt,
userStopped: true,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ variant: "delivery" });
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
@@ -48,7 +48,7 @@ async function resolvePersistenceForUser() {
persistence: null as PersonalReportService | null,
jobs: null,
listSections: undefined,
loadLongformMarkdown: undefined,
loadLongformAppendix: undefined,
};
}
const persistence = createSupabasePersonalReportService(supabase);
@@ -57,11 +57,11 @@ async function resolvePersistenceForUser() {
userId: user.id,
persistence,
jobs: createSupabasePersonalReportJobService(supabase),
listSections: async (ownerId: string, requestId: string) => {
listSections: async (ownerId: string, requestId: string) => {
const rows = await sections.list(ownerId, requestId);
return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode }));
},
loadLongformMarkdown: async (input: Readonly<{ userId: string; reportId: string }>) => {
loadLongformAppendix: async (input: Readonly<{ userId: string; reportId: string }>) => {
const appendixRead = await supabase
.from(LONGFORM_APPENDIX_TABLE)
.select("report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code")
@@ -70,14 +70,19 @@ async function resolvePersistenceForUser() {
.maybeSingle();
if (appendixRead.error) return null;
const row = parseLongformAppendixRow(appendixRead.data);
return row?.status === "ready" && row.markdown ? row.markdown : null;
if (!row) return null;
return {
status: row.status,
lastErrorCode: row.lastErrorCode,
markdown: row.markdown,
};
},
};
}
export async function GET(request: Request, context: RouteContext) {
try {
const { userId, persistence, jobs, listSections, loadLongformMarkdown } = await resolvePersistenceForUser();
const { userId, persistence, jobs, listSections, loadLongformAppendix } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
@@ -103,7 +108,7 @@ export async function GET(request: Request, context: RouteContext) {
// a substitute.
jobs: jobs ?? undefined,
listSections,
loadLongformMarkdown,
loadLongformAppendix,
validateReadyDocument: (document) => {
const parsed = safeParseServerReportDocument(document);
return parsed.ok
-12
View File
@@ -3083,23 +3083,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-choice-why-user p {
margin: var(--space-2) 0 0;
}
.rectification-choice-impact {
margin: var(--space-3) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-composer-meta {
display: grid;
gap: var(--space-3);
margin: 0 0 var(--space-3);
}
.rectification-step-state {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-composer-meta .rectification-collect-stop {
margin: 0;
}
@@ -32,7 +32,7 @@ export type ReportLoadState =
| { phase: "not-found" }
| { phase: "generating"; progressPercent?: number; progressPhase?: string }
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string }
@@ -47,6 +47,7 @@ export interface ReportEnvelopeView {
status: string;
failureCode: string | null;
failureSummary?: string | null;
appendixLastErrorCode?: string | null;
createdAt: string;
completedAt: string | null;
progressPercent?: number;
@@ -108,10 +109,15 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const summary = typeof view.failureSummary === "string" && view.failureSummary.length > 0
? view.failureSummary
: null;
const appendixLastErrorCode = typeof view.appendixLastErrorCode === "string"
&& view.appendixLastErrorCode.length > 0
? view.appendixLastErrorCode
: null;
return {
phase: "failed",
failureCode: code,
failureCode: appendixLastErrorCode ?? code,
...(summary ? { failureSummary: summary } : {}),
...(appendixLastErrorCode ? { appendixLastErrorCode } : {}),
};
}
default:
@@ -37,10 +37,8 @@ import {
isRecommendedRectificationCandidate,
natalRecastMeaning,
parseRectificationCandidateResult,
parseRectificationStepState,
workingRectificationHouseTable,
type RectificationCandidateResult,
type RectificationStepState,
} from "@/lib/rectification-candidate-result";
import {
diffRectificationBoard,
@@ -435,7 +433,6 @@ type CaseSnapshotState = Readonly<{
question: CurrentQuestionModel | null;
questionSource: "focus" | "unavailable" | null;
choice: ChoiceCardModel | null;
stepState: RectificationStepState | null;
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
@@ -457,7 +454,6 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
question: currentQuestionFromSnapshot(payload.current_question),
questionSource: questionSourceFromSnapshot(payload.question_source),
choice: parseRectificationChoiceCard(payload.choice_card),
stepState: parseRectificationStepState(payload.step_state),
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
@@ -499,7 +495,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
const [stepState, setStepState] = useState<RectificationStepState | null>(() => caseSnapshotState(initialSnapshot)?.stepState ?? null);
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);
@@ -672,7 +667,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setCurrentQuestion(nextQuestion);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setStepState(parseRectificationStepState(payload.step_state));
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setCaseSnapshotLoaded(true);
@@ -1772,13 +1766,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<span className="rectification-adopt-status__hint"></span>
</div>
)}
<div className="rectification-composer-meta">
{stepState ? (
<p className="rectification-step-state" role="status">
{stepState.headline} {stepState.reason} {stepState.next}
</p>
) : null}
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
<div className="rectification-composer-meta">
<button
type="button"
className="rectification-collect-stop"
@@ -1786,8 +1775,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
>
{CHOICE_STOP_LABEL}
</button>
)}
</div>
</div>
)}
<ChatComposer
inputRef={composer}
value={draft}
@@ -21,13 +21,8 @@ type RectificationChoiceCardProps = Readonly<{
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
const [hoveredKey, setHoveredKey] = useState<ChoiceKey | "">("");
const selectedKey = props.selectedKey || localSelected;
const answered = Boolean(selectedKey);
const impactKey = (selectedKey === "A" || selectedKey === "B" || selectedKey === "C" || selectedKey === "D"
? selectedKey
: hoveredKey) as ChoiceKey | "";
const impactLine = impactKey ? props.card.answer_impact?.[impactKey] ?? "" : "";
function select(key: ChoiceKey) {
if (props.pending || props.disabled || selectedKey) return;
@@ -78,10 +73,6 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === option.key ? "true" : "false"}
onMouseEnter={() => setHoveredKey(option.key)}
onMouseLeave={() => setHoveredKey("")}
onFocus={() => setHoveredKey(option.key)}
onBlur={() => setHoveredKey("")}
onClick={() => select(option.key)}
>
<strong>{option.key}.</strong> {option.label}
@@ -98,9 +89,6 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
</button>
) : null}
</div>
{impactLine ? (
<p className="rectification-choice-impact" role="status">{impactLine}</p>
) : null}
</fieldset>
</section>
);
@@ -39,6 +39,29 @@ function identifier(value: string): string {
return `"${normalized}"`;
}
export function upsertConflictColumns(options?: { onConflict?: string }): string[] {
return (options?.onConflict ?? "")
.split(",")
.map((column) => column.trim())
.filter(Boolean);
}
async function primaryKeyColumns(client: PoolClient, table: string): Promise<string[]> {
identifier(table);
const result = await client.query<{ column_name: string }>(
`
select a.attname as column_name
from pg_index i
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
where i.indrelid = format('%I.%I', 'public', $1::text)::regclass
and i.indisprimary
order by array_position(i.indkey, a.attnum)
`,
[table],
);
return result.rows.map((row) => row.column_name);
}
function queryError(error: unknown): QueryError {
const value = error as PostgresError;
return {
@@ -197,11 +220,9 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
upsert(
value: Record<string, unknown> | readonly Record<string, unknown>[],
options: { onConflict: string },
options?: { onConflict?: string },
) {
const conflict = options.onConflict
.split(",")
.map((column) => column.trim());
const conflict = upsertConflictColumns(options);
conflict.forEach(identifier);
this.mutation = { kind: "upsert", rows: records(value), conflict };
return this;
@@ -402,13 +423,15 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
);
sql = `insert into public.${identifier(this.table)} (${columns.map(identifier).join(", ")}) values ${valueGroups.join(", ")}`;
if (this.mutation.kind === "upsert") {
const updates = columns.filter(
(column) =>
!this.mutation ||
this.mutation.kind !== "upsert" ||
!this.mutation.conflict.includes(column),
);
sql += ` on conflict (${this.mutation.conflict.map(identifier).join(", ")}) do ${
let conflict = this.mutation.conflict;
if (conflict.length === 0) {
conflict = await primaryKeyColumns(client, this.table);
}
if (conflict.length === 0) {
throw new Error("upsert requires a conflict target");
}
const updates = columns.filter((column) => !conflict.includes(column));
sql += ` on conflict (${conflict.map(identifier).join(", ")}) do ${
updates.length === 0
? "nothing"
: `update set ${updates.map((column) => `${identifier(column)} = excluded.${identifier(column)}`).join(", ")}`
@@ -13,6 +13,7 @@ export type PersonalReportFailureSummary = Readonly<{
summary: string;
innerReason: string | null;
lastErrorCodes: readonly string[];
appendixLastErrorCode: string | null;
}>;
const SECTION_ERROR_LABELS: Readonly<Record<string, string>> = {
@@ -23,6 +24,15 @@ const SECTION_ERROR_LABELS: Readonly<Record<string, string>> = {
section_output_invalid: "输出未通过校验",
};
const APPENDIX_ERROR_LABELS: Readonly<Record<string, string>> = {
upstream_unavailable: "计算引擎暂时不可用,请稍后重试",
upstream_busy: "计算引擎繁忙,请稍后重试",
empty_markdown: "计算引擎返回了空文,请稍后重试",
generation_failed: "报告生成超时或中断,请稍后重试",
appendix_persist_failed: "报告正文未能保存,请稍后重试",
longform_appendix_persist_failed: "报告正文未能保存,请稍后重试",
};
function uniqueLabels(codes: readonly string[]): string[] {
const labels: string[] = [];
for (const code of codes) {
@@ -36,6 +46,7 @@ export function summarizePersonalReportFailure(input: Readonly<{
themeCount?: number;
sections: readonly PersonalReportSectionFailureRow[];
failureCode?: string | null;
appendixLastErrorCode?: string | null;
}>): PersonalReportFailureSummary {
const sections = input.sections;
const blocked = sections.filter((row) => row.status === "blocked");
@@ -44,6 +55,7 @@ export function summarizePersonalReportFailure(input: Readonly<{
const lastErrorCodes = blocked
.map((row) => row.lastErrorCode)
.filter((code): code is string => typeof code === "string" && code.length > 0);
const appendixLastErrorCode = textCode(input.appendixLastErrorCode);
const total = input.themeCount && input.themeCount > 0 ? input.themeCount : sections.length;
const labels = uniqueLabels(lastErrorCodes);
const labelText = labels.length > 0
@@ -61,6 +73,8 @@ export function summarizePersonalReportFailure(input: Readonly<{
summary = `${ready.length} 个主题已写成,整份报告未完成装配`;
} else if (input.failureCode === "report_schema_invalid") {
summary = "报告未通过结构校验";
} else if (appendixLastErrorCode && APPENDIX_ERROR_LABELS[appendixLastErrorCode]) {
summary = APPENDIX_ERROR_LABELS[appendixLastErrorCode];
} else {
summary = "本次生成没有产出可用报告";
}
@@ -72,5 +86,11 @@ export function summarizePersonalReportFailure(input: Readonly<{
innerReason = "section_generation_incomplete";
}
return { summary, innerReason, lastErrorCodes };
return { summary, innerReason, lastErrorCodes, appendixLastErrorCode };
}
function textCode(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
@@ -36,7 +36,10 @@ export type AppendixClient = {
select(columns: string): {
eq(column: string, value: unknown): AppendixFilter;
};
upsert(row: JsonRecord): PromiseLike<{ error: { message?: string } | null }>;
upsert(
row: JsonRecord,
options?: { onConflict?: string },
): PromiseLike<{ error: { message?: string } | null }>;
};
};
@@ -99,20 +102,25 @@ export async function persistLongformAppendix(input: Readonly<{
successMarkdown: input.successMarkdown,
errorCode: input.errorCode,
});
const result = await input.admin.from(LONGFORM_APPENDIX_TABLE).upsert({
report_id: input.reportId,
user_id: input.userId,
request_id: input.requestId,
status: next.status,
markdown: next.markdown,
content_sha256: next.contentSha256,
attempt_count: next.attemptCount,
last_error_code: next.lastErrorCode,
generated_at: next.status === "ready" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
});
if (result.error) {
throw new LongformGenerateError("calculation_unavailable", true, "longform_appendix_persist_failed");
try {
const result = await input.admin.from(LONGFORM_APPENDIX_TABLE).upsert({
report_id: input.reportId,
user_id: input.userId,
request_id: input.requestId,
status: next.status,
markdown: next.markdown,
content_sha256: next.contentSha256,
attempt_count: next.attemptCount,
last_error_code: next.lastErrorCode,
generated_at: next.status === "ready" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
}, { onConflict: "report_id" });
if (result.error) {
throw new LongformGenerateError("calculation_unavailable", true, "appendix_persist_failed");
}
} catch (error) {
if (error instanceof LongformGenerateError) throw error;
throw new LongformGenerateError("calculation_unavailable", true, "appendix_persist_failed");
}
}
@@ -126,25 +134,39 @@ async function fetchLongformMarkdown(input: Readonly<{
const signal = input.signal
? AbortSignal.any([input.signal, timeout])
: timeout;
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(input.payload),
cache: "no-store",
signal,
});
if (!upstream.ok) {
throw new LongformGenerateError(
"calculation_unavailable",
true,
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
const started = Date.now();
let httpStatus: number | null = null;
try {
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(input.payload),
cache: "no-store",
signal,
});
httpStatus = upstream.status;
if (!upstream.ok) {
throw new LongformGenerateError(
"calculation_unavailable",
true,
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
}
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown");
}
return result.markdown;
} catch (error) {
throw error;
} finally {
const durationMs = Date.now() - started;
if (httpStatus !== null) {
console.info(`[personal-report] engine http_status=${httpStatus} duration_ms=${durationMs}`);
} else {
console.info(`[personal-report] engine http_status=error duration_ms=${durationMs}`);
}
}
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown");
}
return result.markdown;
}
export async function generatePersonalReportLongform(
+23 -6
View File
@@ -153,6 +153,7 @@ export function reportView(
...(failure?.summary ? { failureSummary: failure.summary } : {}),
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
...(failure?.appendixLastErrorCode ? { appendixLastErrorCode: failure.appendixLastErrorCode } : {}),
};
}
@@ -655,6 +656,14 @@ export type ReportReadCoreDeps = Readonly<{
userId: string;
reportId: string;
}>) => Promise<string | null>;
loadLongformAppendix?: (input: Readonly<{
userId: string;
reportId: string;
}>) => Promise<{
status: string;
lastErrorCode: string | null;
markdown: string | null;
} | null>;
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
@@ -676,17 +685,25 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
};
}
const job = deps.jobs ? await deps.jobs.getOwnedByRequestId(deps.userId, row.requestId) : null;
const failure = row.status === "failed" && deps.listSections
const appendix = deps.loadLongformAppendix
? await deps.loadLongformAppendix({ userId: deps.userId, reportId: deps.reportId })
: null;
const failure = row.status === "failed"
? summarizePersonalReportFailure({
themeCount: row.requestedThemes.length,
sections: await deps.listSections(deps.userId, row.requestId),
sections: deps.listSections ? await deps.listSections(deps.userId, row.requestId) : [],
failureCode: row.failureCode,
appendixLastErrorCode: appendix?.lastErrorCode ?? null,
})
: null;
if (row.status === "ready") {
const markdown = deps.loadLongformMarkdown
? await deps.loadLongformMarkdown({ userId: deps.userId, reportId: deps.reportId })
: undefined;
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
? appendix.markdown
: null;
const markdown = markdownFromAppendix
?? (deps.loadLongformMarkdown
? await deps.loadLongformMarkdown({ userId: deps.userId, reportId: deps.reportId })
: undefined);
if (typeof markdown === "string" && markdown.trim()) {
const validated = deps.validateReadyDocument(row.reportDocument);
return {
@@ -704,7 +721,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
// loader is wired and the appendix is missing, the client shows the
// legacy placeholder instead of the five-chapter document.
const validated = deps.validateReadyDocument(row.reportDocument);
if (deps.loadLongformMarkdown) {
if (deps.loadLongformMarkdown || deps.loadLongformAppendix) {
return {
status: 200,
body: { report: reportView(row, job), longformMarkdown: null },
@@ -282,15 +282,16 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return askWindowWiden(separation, range);
}
if (userStopped && separation.ranked.length > 0) {
return completeWithRange(separation, holdout, range, "user_stopped", capability);
}
if (input.snapshotCurrent === false) {
if (probe && !userStopped && input.trainingGateOpen !== false) {
return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason);
}
return collect(separation, holdout, range, probe, capability, stopReason);
}
if (userStopped && separation.ranked.length > 0) {
return completeWithRange(separation, holdout, range, "user_stopped", capability);
}
if (coverageBlocks) {
const engineOffers = input.engineCeiling.acceptanceAllowed
|| input.engineCeiling.proposeAllowed;
@@ -87,8 +87,26 @@ export const RECTIFICATION_USER_COPY = {
lowDateQualityGate: "两件事的日期还没对清。",
noCandidatesGate: "当前还排不出可比较的候选时间。",
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
} as const;
export function withCompareFailedRetryNotice(body: string): string {
const notice = RECTIFICATION_USER_COPY.compareFailedRetry;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
return `${spoken}\n\n${notice}`;
}
export function withLastSuccessfulCompareNotice(body: string): string {
const notice = RECTIFICATION_USER_COPY.lastSuccessfulCompareRange;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
return `${spoken}\n\n${notice}`;
}
export const ACCEPTANCE_GATE_COPY: Readonly<Record<string, string>> = {
insufficient_events: RECTIFICATION_USER_COPY.insufficientEventsGate,
insufficient_dated_events: RECTIFICATION_USER_COPY.insufficientEventsGate,
@@ -287,6 +305,8 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.noCandidatesGate,
RECTIFICATION_USER_COPY.forceMinuteAfterSubBlocks,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
RECTIFICATION_USER_COPY.compareFailedRetry,
RECTIFICATION_USER_COPY.lastSuccessfulCompareRange,
"刚才那个日子是查过记录,还是凭记忆?",
PROBE_EXPLAIN_COPY.unsureImpact,
PROBE_EXPLAIN_COPY.splitGroups,
@@ -34,6 +34,7 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat
import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import { withCompareFailedRetryNotice } from "../user-copy";
import {
resolveExactSkillPackage,
type ResolvedSkillPackageIdentity,
@@ -880,6 +881,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
}
if (toolTerminalStatus.get("rectification-compare-candidates") === "failed") {
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
}
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
@@ -23,6 +23,7 @@ import {
openingRangeFromCandidateRange,
rangeWidthMinutes,
RECTIFICATION_USER_COPY,
withLastSuccessfulCompareNotice,
} from "../user-copy.ts";
import {
applyChoiceWithoutEvidence,
@@ -90,7 +91,7 @@ import {
type MethodFollowupPlan,
} from "./method-followup";
import { followupCaseArgs, isBlockChoiceSchema, isWidenWindowSchema } from "./block-scan.ts";
import { mutateCaseForBlockChoice, mutateCaseForWidenWindow } from "./block-scan-answer.ts";
import { mutateCaseForBlockChoice, mutateCaseForWidenWindow, rescoreStaleMinuteSnapshotIfNeeded } from "./block-scan-answer.ts";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet";
import { projectCurrentQuestion } from "./turn-decision";
@@ -554,12 +555,12 @@ export async function applyRectificationChoice(
}
if (optionId === "stop" || command.action === STOP_ACTION) {
const narration = composeChoiceNarration({
optionId: "stop",
scoring,
appliedInference: false,
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
accounting,
userId: command.userId,
caseId: command.caseId,
});
return persistApplied(accounting, command, {
const applied = await persistApplied(accounting, command, {
focusId: focus.id,
questionId,
focusStatus: "skipped",
@@ -572,12 +573,24 @@ export async function applyRectificationChoice(
year: null,
expectedRevision: previous?.revision ?? command.expectedRevision,
inference: null,
narration,
userDisplay: "先这样,先看当前范围",
decisionState: previous,
userStopped: true,
dossier,
});
narration: composeChoiceNarration({
optionId: "stop",
scoring,
appliedInference: false,
}),
userDisplay: "先这样,先看当前范围",
decisionState: previous,
userStopped: true,
dossier: rescored.dossier,
snapshotCurrent: rescored.snapshotCurrent,
});
if (rescored.rescoreAttempted && !rescored.snapshotCurrent) {
return {
...applied,
narration: withLastSuccessfulCompareNotice(applied.narration),
};
}
return applied;
}
if (!previous) {
@@ -1081,8 +1094,29 @@ export async function persistNextInterviewIfIdle(input: {
caseId: string;
askedTurnId?: string | null;
narrateAdopt?: AdoptNarrationWriter;
userStopped?: boolean;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> {
let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
askedTurnId: input.askedTurnId ?? null,
});
const finishIdle = <T extends { hostNarration: string | null }>(result: T): T => {
if (
input.userStopped === true
&& rescored.rescoreAttempted
&& !rescored.snapshotCurrent
&& result.hostNarration
) {
return {
...result,
hostNarration: withLastSuccessfulCompareNotice(result.hostNarration),
};
}
return result;
};
let dossier = rescored.dossier;
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
if (
@@ -1117,7 +1151,7 @@ export async function persistNextInterviewIfIdle(input: {
);
}
}
return { persisted: false, choiceReady: false, hostNarration: null };
return finishIdle({ persisted: false, choiceReady: false, hostNarration: null });
}
let birthDate: string | null = null;
try {
@@ -1126,7 +1160,10 @@ export async function persistNextInterviewIfIdle(input: {
} catch {
birthDate = null;
}
const decision = decideFromDossier(dossier, { birthDate });
const decision = decideFromDossier(dossier, {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
@@ -1162,7 +1199,7 @@ export async function persistNextInterviewIfIdle(input: {
const narrated = input.narrateAdopt
? await input.narrateAdopt(facts, fallback)
: fallback;
return {
return finishIdle({
persisted: false,
choiceReady: false,
hostNarration: await withRangeReadingNarration(narrated, {
@@ -1172,7 +1209,7 @@ export async function persistNextInterviewIfIdle(input: {
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
}),
};
});
}
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
@@ -1189,7 +1226,7 @@ export async function persistNextInterviewIfIdle(input: {
})
&& EXHAUSTION_DELIVERY_ACTIONS.has(decision.nextAction)
) {
return persistExhaustionCollect({
return finishIdle(await persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -1197,13 +1234,13 @@ export async function persistNextInterviewIfIdle(input: {
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}));
}
if (isNonConvergingRangeOffer(decision)
|| decision.nextAction === "complete_with_range"
|| decision.sessionOutcome === "completed_with_range"
) {
return persistExhaustionCollect({
return finishIdle(await persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -1211,7 +1248,7 @@ export async function persistNextInterviewIfIdle(input: {
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}));
}
if (
!followup
@@ -1219,13 +1256,13 @@ export async function persistNextInterviewIfIdle(input: {
&& decision.nextAction !== "ask_holdout_validation"
) {
if (dossier.case.acceptedTime) {
return {
return finishIdle({
persisted: false,
choiceReady: false,
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
};
});
}
return persistExhaustionCollect({
return finishIdle(await persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -1233,7 +1270,7 @@ export async function persistNextInterviewIfIdle(input: {
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}));
}
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
@@ -1248,11 +1285,11 @@ export async function persistNextInterviewIfIdle(input: {
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
});
return {
return finishIdle({
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
choiceReady: nextInterview.choiceReady,
hostNarration: nextInterview.hostNarration,
};
});
}
async function persistExhaustionCollect(input: {
@@ -1450,6 +1487,7 @@ async function persistApplied(
state: input.decisionState ?? null,
userStopped: input.userStopped === true,
birthDate,
snapshotCurrent: input.snapshotCurrent,
});
const nextAction = publicNextAction(nextDecision);
const accepted = Boolean(input.dossier.case.acceptedTime);
@@ -1624,7 +1662,12 @@ async function inspectNonTerminalTurnExit(input: {
userId: string;
caseId: string;
}) {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
});
const dossier = rescored.dossier;
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
@@ -1632,7 +1675,10 @@ async function inspectNonTerminalTurnExit(input: {
} catch {
birthDate = null;
}
const decision = decideFromDossier(dossier, { birthDate });
const decision = decideFromDossier(dossier, {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
@@ -4,7 +4,7 @@
*/
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
import { askedDiscriminatorKeys } from "./inference-adapter.ts";
import { askedSemanticKeysForEngine, previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
isBlockChoiceSchema,
isWidenWindowSchema,
@@ -34,6 +34,7 @@ import {
type AccountingClient,
type V9CaseDossier,
} from "./tool-service.ts";
import { scoreableSnapshotCurrentFromDossier } from "./decision-from-dossier.ts";
import type { ChoiceKey } from "./choice-card.ts";
export async function mutateCaseForBlockChoice(input: {
@@ -186,7 +187,7 @@ export async function rescoreMinuteAfterWindowChange(
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: dossier.case.candidateRange,
events,
askedProbeKeys: askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, dossier.evidence),
askedProbeKeys: askedSemanticKeysForEngine(dossier.latestResult?.decisionReceipt, dossier.evidence),
});
await persistV9Candidate(accounting, userId, caseId, {
engineResultId: score.engineResultId,
@@ -206,6 +207,55 @@ export async function rescoreMinuteAfterWindowChange(
});
}
const rescoreAttempts = new Map<string, true>();
export function resetStaleMinuteRescoreAttemptsForTests(): void {
rescoreAttempts.clear();
}
function rescoreAttemptKey(caseId: string, fingerprint: string, askedTurnId?: string | null): string {
return `${caseId}:${askedTurnId ?? ""}:${fingerprint}`;
}
export async function rescoreStaleMinuteSnapshotIfNeeded(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
askedTurnId?: string | null;
}): Promise<{
dossier: V9CaseDossier;
snapshotCurrent: boolean;
rescoreAttempted: boolean;
}> {
let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const snapshotCurrent = scoreableSnapshotCurrentFromDossier(dossier, undefined, inference);
if (snapshotCurrent) {
return { dossier, snapshotCurrent: true, rescoreAttempted: false };
}
if (scorableEvidence(dossier.evidence).length === 0) {
return { dossier, snapshotCurrent: false, rescoreAttempted: false };
}
const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
const key = rescoreAttemptKey(input.caseId, fingerprint, input.askedTurnId);
if (rescoreAttempts.has(key)) {
return { dossier, snapshotCurrent: false, rescoreAttempted: false };
}
rescoreAttempts.set(key, true);
try {
await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId);
dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const nextInference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
return {
dossier,
snapshotCurrent: scoreableSnapshotCurrentFromDossier(dossier, undefined, nextInference),
rescoreAttempted: true,
};
} catch {
return { dossier, snapshotCurrent: false, rescoreAttempted: true };
}
}
export async function persistBlockScanPayload(input: {
accounting: AccountingClient;
userId: string;
@@ -9,11 +9,24 @@ export function composeCollectSpokenAssistantText(body: string, prompt: string):
const spoken = body.trim();
if (!stem) return spoken;
if (!spoken || spoken === stem) return stem;
const prefix = stem.slice(0, 12);
const stripped = spoken
.split(/(?<=[。!?\n])/)
.filter((sentence) => {
const text = sentence.trim();
if (!text) return false;
if (text === stem) return false;
return !(prefix && text.startsWith(prefix));
})
.join("")
.trim();
if (!stripped) return stem;
const suffix = `\n\n${stem}`;
if (spoken.length >= suffix.length && spoken.slice(spoken.length - suffix.length) === suffix) {
return spoken;
if (stripped.includes(stem)) return stripped;
if (stripped.length >= suffix.length && stripped.slice(stripped.length - suffix.length) === suffix) {
return stripped;
}
return `${spoken}${suffix}`;
return `${stripped}${suffix}`;
}
export function detachCollectSpokenAssistantText(body: string, prompt: string): string {
@@ -341,11 +341,12 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket
});
}
function scoreableSnapshotCurrentFromDossier(
export function scoreableSnapshotCurrentFromDossier(
dossier: DecisionDossier,
options: { currentEvidenceFingerprint?: string | null } | undefined,
options: { currentEvidenceFingerprint?: string | null; snapshotCurrent?: boolean } | undefined,
inference: ReturnType<typeof previousInferenceFromReceipt>,
): boolean {
if (typeof options?.snapshotCurrent === "boolean") return options.snapshotCurrent;
const latest = dossier.latestResult;
if (!latest) return true;
const stored = candidateSnapshotSource({
@@ -385,6 +386,7 @@ export function followupAsksRenderableDiscriminator(
export type DecideFromDossierOptions = Readonly<{
currentEvidenceFingerprint?: string | null;
birthDate?: string | null;
snapshotCurrent?: boolean;
}>;
function userInterviewAnswers(
@@ -721,6 +723,7 @@ export function decideAfterInferenceChange(input: {
state: InferenceState | null;
userStopped: boolean;
birthDate?: string | null;
snapshotCurrent?: boolean;
}): RectificationDecision {
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const collecting = buildMethodFollowupPlan({
@@ -743,6 +746,12 @@ export function decideAfterInferenceChange(input: {
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
const snapshotCurrent = input.snapshotCurrent
?? scoreableSnapshotCurrentFromDossier(
input.dossier,
undefined,
input.state ?? previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
);
if (!input.state) {
const evidenceStops = evidenceStopInputs(input.dossier.evidence);
const caseStage = input.dossier.case.stage === "block_scan" ? "block_scan" : "minute";
@@ -754,6 +763,7 @@ export function decideAfterInferenceChange(input: {
: trainingScoreableGate(input.dossier.evidence).open,
candidateScores: [],
userStopped: input.userStopped,
snapshotCurrent,
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
...decisionBudgetFromInference(null),
...evidenceStops,
@@ -819,6 +829,7 @@ export function decideAfterInferenceChange(input: {
holdoutValidation,
inferenceCredibleRange: input.state.credible_range,
userStopped: input.userStopped,
snapshotCurrent,
accepted: Boolean(input.dossier.case.acceptedTime),
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods)
@@ -454,7 +454,30 @@ function engineDiagnostics(data: Record<string, unknown>): Readonly<Record<strin
return record(data.diagnostics) ?? {};
}
function engineRequestBody(input: {
export const ENGINE_ASKED_PROBE_KEY_MAX_LENGTH = 200;
export function sanitizeAskedProbeKeysForEngine(
keys: readonly string[] | null | undefined,
): string[] {
const seen = new Set<string>();
const next: string[] = [];
for (const raw of keys ?? []) {
const key = raw.trim();
if (!key) continue;
if (key.length > ENGINE_ASKED_PROBE_KEY_MAX_LENGTH || key.includes(":varga.")) {
console.warn(
`[rectification-v9] dropping asked_probe_key length=${key.length} varga_hash=${key.includes(":varga.")}`,
);
continue;
}
if (seen.has(key)) continue;
seen.add(key);
next.push(key);
}
return next;
}
export function engineRequestBody(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
@@ -471,6 +494,7 @@ function engineRequestBody(input: {
if (input.events.length === 0) {
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
}
const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys);
return {
birth_date: birthDate,
start_time: input.candidateRange.start_time,
@@ -485,7 +509,7 @@ function engineRequestBody(input: {
timezone_id: snapshot.timezone_id,
timezone_source: snapshot.timezone_source,
local_time_status: snapshot.local_time_status,
...(input.askedProbeKeys?.length ? { asked_probe_keys: [...input.askedProbeKeys] } : {}),
...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}),
};
}
@@ -57,6 +57,27 @@ export function askedProbeKeysFromReceipt(
return keys;
}
export function askedSemanticKeysFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): string[] {
const inference = receipt?.inference_state;
if (!inference || typeof inference !== "object" || Array.isArray(inference)) return [];
const answers = (inference as { answered_probes?: unknown }).answered_probes;
if (!Array.isArray(answers)) return [];
const keys: string[] = [];
const seen = new Set<string>();
for (const item of answers) {
if (!item || typeof item !== "object") continue;
const semantic = typeof (item as { semantic_key?: unknown }).semantic_key === "string"
? (item as { semantic_key: string }).semantic_key.trim()
: "";
if (!semantic || seen.has(semantic)) continue;
seen.add(semantic);
keys.push(semantic);
}
return keys;
}
export function askedDiscriminatorKeys(
receipt: Readonly<Record<string, unknown>> | null | undefined,
evidence: readonly Readonly<{
@@ -74,6 +95,31 @@ export function askedDiscriminatorKeys(
];
}
export function askedSemanticKeysForEngine(
receipt: Readonly<Record<string, unknown>> | null | undefined,
evidence: readonly Readonly<{
status?: string | null;
domain?: string | null;
eventKind?: string | null;
summary?: string | null;
occurredFrom?: string | null;
occurredTo?: string | null;
}>[] = [],
): string[] {
const seen = new Set<string>();
const keys: string[] = [];
for (const key of [
...askedSemanticKeysFromReceipt(receipt),
...askedEventProbeKeysFromLedgerEvidence(evidence),
]) {
const trimmed = key.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
keys.push(trimmed);
}
return keys;
}
const NAKSHATRA_BOUNDARY_SOURCE = "nakshatra_boundary";
export function nakshatraBoundaryProbe(
@@ -537,6 +537,12 @@ function declinedDomains(
return domains;
}
export function declinedCollectDomains(
topics: readonly Readonly<Record<string, unknown>>[] | undefined,
): Set<string> {
return declinedDomains(topics ?? []);
}
function domainCollectFocusAsked(
topics: readonly Readonly<Record<string, unknown>>[],
domain: string,
@@ -1649,32 +1655,16 @@ function holdoutAskFields(
prompt: OosBlindPrompt | null | undefined,
reserved: Readonly<{ domain: string; year: number | null }> | null,
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (prompt) {
return {
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
choice_kind: "existence",
style_options: EXISTENCE_STYLE_OPTIONS,
};
}
if (reserved?.year == null) return null;
const domain = prompt?.domain || reserved?.domain || "";
if (!domain) return null;
return {
method_id: "holdout_validation",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: reserved.domain,
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain,
kind_hint: null,
user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`,
source: "oos_blind",
probe_year: reserved.year,
year_label: `${reserved.year} 年前后`,
choice_kind: "existence",
style_options: EXISTENCE_STYLE_OPTIONS,
user_prompt_hint: USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION,
source: "method_coverage",
};
}
@@ -1687,9 +1677,16 @@ export function holdoutFollowupFor(
declined: ReadonlySet<string>,
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
const prompt = (input.oosBlindPrompts ?? []).find((item) => item.domain && !declined.has(item.domain)) ?? null;
const occupied = new Set(
input.evidence
.filter((item) => isConfirmedDated(item) && evidenceYear(item) != null)
.map((item) => item.domain),
);
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
item.domain && !declined.has(item.domain) && !occupied.has(item.domain)
)) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => (
item.year != null && !declined.has(item.domain)
item.year != null && !declined.has(item.domain) && !occupied.has(item.domain)
)) ?? null;
return holdoutAskFields(prompt, reserved);
}
@@ -871,6 +871,7 @@ export function parseToolActivityDetail(activity: Readonly<Record<string, unknow
const detail: Record<string, unknown> = {};
if (typeof activity.error === "string" && activity.error.trim()) {
detail.error = activity.error.trim();
detail.safe_error_code = activity.error.trim();
}
const fingerprint = typeof activity.result_fingerprint === "string"
? activity.result_fingerprint.trim()
@@ -881,6 +882,12 @@ export function parseToolActivityDetail(activity: Readonly<Record<string, unknow
if (typeof parsed.reason === "string" && parsed.reason.trim()) {
detail.reason = parsed.reason.trim();
}
if (typeof parsed.safe_error_code === "string" && parsed.safe_error_code.trim()) {
detail.safe_error_code = parsed.safe_error_code.trim();
}
if (typeof parsed.engine_message === "string" && parsed.engine_message.trim()) {
detail.engine_message = parsed.engine_message.trim().slice(0, 120);
}
for (const key of ["engine_compare_ms", "vedastro_validate_ms", "persist_ms"] as const) {
const value = parsed[key];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
@@ -2004,7 +2011,15 @@ export function safeToolErrorCode(error: unknown): string {
"stale_probe",
"revision_conflict",
"inference_patch_retired",
"engine_request_failed",
"engine_invalid_response",
"engine_profile_incomplete",
"no_scorable_evidence",
];
if (error instanceof Error && error.name === "RectificationEngineError") {
const code = "code" in error && typeof error.code === "string" ? error.code : "";
if (code && known.includes(code)) return code;
}
if (error instanceof RectificationToolServiceError && known.includes(error.code)) {
return error.code;
}
@@ -2014,6 +2029,11 @@ export function safeToolErrorCode(error: unknown): string {
return "tool_failed";
}
export function engineMessageForReceipt(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
return raw.replace(/\s+/g, " ").trim().slice(0, 120);
}
export const V9_EVIDENCE_KINDS = EVIDENCE_KINDS;
export const V9_SKILL_VERSION = RECTIFICATION_SKILL_VERSION;
export type V9PublicTool = PublicRectificationTool;
+10 -3
View File
@@ -36,6 +36,7 @@ import {
closeV9Case,
setV9CaseStage,
safeToolErrorCode,
engineMessageForReceipt,
scorableEvidence,
RectificationToolServiceError,
type V9CaseDossier,
@@ -77,6 +78,7 @@ import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification
import {
applyChoiceWithoutEvidence,
askedDiscriminatorKeys,
askedSemanticKeysForEngine,
authoritativeCandidateProjection,
buildCaseInferenceState,
compactInferenceProjection,
@@ -1013,7 +1015,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
compute.baselineProfileFingerprint,
);
const events = toEngineEvents(scorableEvidence(dossier.evidence));
const askedProbeKeys = askedDiscriminatorKeys(
const askedProbeKeys = askedSemanticKeysForEngine(
dossier.latestResult?.decisionReceipt,
parsed.evidence,
);
@@ -2070,10 +2072,15 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
});
return { ...projection, executed_methods: scored.score.executedMethods };
} catch (error) {
const safeErrorCode = safeToolErrorCode(error);
await receipt("rectification-compare-candidates", "candidates.comparing", "failed", {
inputFingerprint,
engineVersion,
safeErrorCode: safeToolErrorCode(error),
safeErrorCode,
resultFingerprint: JSON.stringify({
safe_error_code: safeErrorCode,
engine_message: engineMessageForReceipt(error),
}),
});
throw error;
}
@@ -2101,7 +2108,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: parsed.case.candidateRange,
events: toEngineEvents(scorableEvidence(dossier.evidence)),
askedProbeKeys: askedDiscriminatorKeys(
askedProbeKeys: askedSemanticKeysForEngine(
dossier.latestResult?.decisionReceipt,
parsed.evidence,
),
@@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
import {
closeLocalPostgresDataPool,
createLocalPostgresDataClient,
} from "../src/lib/db/local-postgres-client-core.ts";
const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url),
@@ -37,7 +41,7 @@ function serviceSql(sql: string): string {
return `set role service_role;\n${sql}`;
}
test("longform appendices are owner-read, service-written, and never change report status", { skip: skipWithoutDocker }, () => {
test("longform appendices are owner-read, service-written, and never change report status", { skip: skipWithoutDocker }, async () => {
const fixture = startPostgresFixture();
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
@@ -144,6 +148,32 @@ test("longform appendices are owner-read, service-written, and never change repo
`),
"unavailable:upstream_unavailable",
);
const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password");
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
try {
const persisted = await admin.from("personal_report_longform_appendices").upsert({
report_id: REPORT_ID,
user_id: USER_A,
request_id: REQUEST_ID,
status: "unavailable",
markdown: null,
content_sha256: null,
attempt_count: 2,
last_error_code: "appendix_persist_failed",
});
assert.equal(persisted.error, null, persisted.error?.message);
assert.equal(
fixture.psql(`
select last_error_code
from public.personal_report_longform_appendices
where report_id = '${REPORT_ID}'
`),
"appendix_persist_failed",
);
} finally {
await closeLocalPostgresDataPool(serviceUrl);
}
} finally {
fixture.stop();
}
+3
View File
@@ -44,6 +44,7 @@ test("health endpoint exposes deployment identity for production verification",
assert.match(source, /GITHUB_SHA/);
assert.match(source, /VERCEL_GIT_COMMIT_SHA/);
assert.match(source, /gitCommit/);
assert.match(source, /apiGitCommit/);
assert.match(source, /latestMigration/);
assert.match(source, /rectificationContractVersion/);
assert.match(source, /rectificationMigrations/);
@@ -409,10 +410,12 @@ test("production traffic waits for a healthy web container and retries short rep
const compose = readFileSync(new URL("../../deploy/docker-compose.server.yml", import.meta.url), "utf8");
const caddyfile = readFileSync(new URL("../../deploy/Caddyfile", import.meta.url), "utf8");
const web = serviceBlock(compose, "web");
const api = serviceBlock(compose, "api");
const caddy = serviceBlock(compose, "caddy");
const healthcheck = webHealthcheckBlock(web);
assert.match(web, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
assert.match(api, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
assert.match(web, /healthcheck:\n\s+test: \["CMD", "node", "-e", "fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health'\)\.then\(r=>\{if\(!r\.ok\)process\.exit\(1\)\}\)"\]/);
assert.match(healthcheck, /^ interval: 30s$/m);
assert.match(healthcheck, /^ timeout: 5s$/m);
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { queryValue } from "../src/lib/db/local-postgres-client-core.ts";
import { queryValue, upsertConflictColumns } from "../src/lib/db/local-postgres-client-core.ts";
test("queryValue keeps calendar dates in local civil form", () => {
assert.equal(queryValue("date", new Date(2026, 7, 22)), "2026-08-22");
@@ -21,3 +21,10 @@ test("queryValue leaves non-date values unchanged", () => {
const leftover = new Date("2026-08-22T12:34:56.000Z");
assert.equal(queryValue("unknown", leftover), leftover);
});
test("upsert without onConflict does not throw before execute and defaults to an empty conflict list", () => {
assert.deepEqual(upsertConflictColumns(), []);
assert.deepEqual(upsertConflictColumns({}), []);
assert.deepEqual(upsertConflictColumns({ onConflict: "report_id" }), ["report_id"]);
assert.deepEqual(upsertConflictColumns({ onConflict: "user_id, session_id" }), ["user_id", "session_id"]);
});
+55 -1
View File
@@ -1089,6 +1089,39 @@ test("core read: failed reports include a readable summary from existing section
assert.deepEqual(report.sectionErrorCodes, ["section_refs_mismatch"]);
});
test("core read: failed longform reports expose appendix last_error_code without bodies", async () => {
const persistence = new MemoryPersistence();
persistence.rows.set(REPORT_ID, {
...seedRecord(),
status: "failed",
failureCode: "calculation_unavailable",
requestedThemes: ["career", "marriage", "wealth", "health", "spirituality"],
});
const response = await resolveReportRead({
requestUrl: "https://jyotisha.chat/api/reports/x",
origin: null,
allowedOrigins: [],
userId: UUID_A,
reportId: REPORT_ID,
persistence,
listSections: async () => [],
loadLongformAppendix: async () => ({
status: "unavailable",
lastErrorCode: "appendix_persist_failed",
markdown: null,
}),
validateReadyDocument: () => ({ ok: false }),
});
assert.equal(response.status, 200);
const report = response.body.report as {
failureSummary?: string;
appendixLastErrorCode?: string;
};
assert.equal(report.appendixLastErrorCode, "appendix_persist_failed");
assert.equal(report.failureSummary, "报告正文未能保存,请稍后重试");
assert.equal("markdown" in response.body, false);
});
test("core read: rejects a polluted stored ready document via canonical re-validation", async () => {
const persistence = new MemoryPersistence();
persistence.rows.set(REPORT_ID, {
@@ -1164,6 +1197,27 @@ test("core read: markdown loader returns longform and hides the five-chapter bod
assert.equal("reportDocument" in missing.body, false);
});
test("core read: appendix loader supplies ready markdown without the markdown-only fallback", async () => {
const persistence = new MemoryPersistence();
persistence.rows.set(REPORT_ID, seedRecord());
const response = await resolveReportRead({
requestUrl: "https://jyotisha.chat/api/reports/x",
origin: null,
allowedOrigins: [],
userId: UUID_A,
reportId: REPORT_ID,
persistence,
loadLongformAppendix: async () => ({
status: "ready",
lastErrorCode: null,
markdown: "# 附录长报告\n\n### 摘要\n正文",
}),
validateReadyDocument: acceptAnyDocument,
});
assert.equal(response.status, 200);
assert.equal(response.body.longformMarkdown, "# 附录长报告\n\n### 摘要\n正文");
});
test("core delete: owner-only, 200 ok for the owner and 404 otherwise", async () => {
const persistence = new MemoryPersistence();
await createReadyRow(persistence);
@@ -1264,7 +1318,7 @@ test("GET/DELETE use the authenticated client (least privilege) and the core han
assert.match(itemRoute, /createSupabasePersonalReportService\(supabase\)/);
assert.match(itemRoute, /resolveReportRead/);
assert.match(itemRoute, /resolveReportDelete/);
assert.match(itemRoute, /loadLongformMarkdown/);
assert.match(itemRoute, /loadLongformAppendix/);
assert.doesNotMatch(itemRoute, /createAdminSupabaseClient/);
});
@@ -6,6 +6,7 @@ import {
nextLongformAppendixState,
parseLongformAppendixRow,
} from "../src/lib/personal-report-longform-appendix.ts";
import { summarizePersonalReportFailure } from "../src/lib/personal-report-failure-summary.ts";
test("hashLongformMarkdown is stable sha256", () => {
assert.equal(hashLongformMarkdown("# a"), hashLongformMarkdown("# a"));
@@ -55,3 +56,15 @@ test("nextLongformAppendixState marks unavailable after two failures and never s
assert.equal(ready.contentSha256, hashLongformMarkdown("# Full"));
assert.equal(ready.lastErrorCode, null);
});
test("failed longform appendix codes become readable summaries without storing bodies", () => {
const summary = summarizePersonalReportFailure({
themeCount: 5,
sections: [],
failureCode: "calculation_unavailable",
appendixLastErrorCode: "empty_markdown",
});
assert.equal(summary.appendixLastErrorCode, "empty_markdown");
assert.equal(summary.summary, "计算引擎返回了空文,请稍后重试");
assert.deepEqual(summary.lastErrorCodes, []);
});
@@ -80,6 +80,17 @@ test("writer pipeline stays in the tree but is feature-off", () => {
assert.match(PERSONAL_REPORT_GENERATING_COPY, /1030 秒/);
});
test("longform generate persists through PK upsert and logs engine timing without bodies", () => {
const generateSource = readFileSync(
new URL("../src/lib/personal-report-longform-generate.ts", import.meta.url),
"utf8",
);
assert.match(generateSource, /onConflict:\s*"report_id"/);
assert.match(generateSource, /\[personal-report\] engine http_status=/);
assert.match(generateSource, /duration_ms=/);
assert.doesNotMatch(generateSource, /console\.(?:info|error|log)\([^\n]*markdown/);
});
test("outline lifts navigation and summary to the first screen", () => {
const outline = buildLongformOutline(SAMPLE_MARKDOWN);
assert.ok(outline.headings.some((heading) => heading.title === "成品阅读导航"));
@@ -347,6 +347,21 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
failureSummary: "4 个主题中 4 个写作失败:输出被截断",
},
);
assert.deepEqual(
classifyReportEnvelope(200, {
report: {
...view("failed", "calculation_unavailable"),
failureSummary: "报告正文未能保存,请稍后重试",
appendixLastErrorCode: "appendix_persist_failed",
},
}),
{
phase: "failed",
failureCode: "appendix_persist_failed",
failureSummary: "报告正文未能保存,请稍后重试",
appendixLastErrorCode: "appendix_persist_failed",
},
);
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null });
// Server-side error envelopes carry a stable code at the top level.
@@ -29,9 +29,13 @@ import {
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { RECTIFICATION_TERMINATION_COPY, ADOPT_OUTCOMES } from "../src/lib/rectification-agentic/core/rectification-decision.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 {
evidenceLedgerFingerprint,
parseV9CaseDossier,
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
EVIDENCE_ID,
@@ -227,8 +231,20 @@ function rangeNarrationInference(leadSupport: number, trailSupport: number) {
});
}
function withCurrentEvidenceFingerprint(raw: ReturnType<typeof dossierFixture>) {
const parsed = parseV9CaseDossier(raw);
if (!parsed) return raw;
const latest = raw.latest_result && typeof raw.latest_result === "object"
? {
...(raw.latest_result as Record<string, unknown>),
evidence_ledger_fingerprint: evidenceLedgerFingerprint(parsed.evidence),
}
: raw.latest_result;
return { ...raw, latest_result: latest };
}
function rangeNarrationDossier(inference: ReturnType<typeof buildInferenceState>) {
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
latestResult: candidateSnapshotFixture({
decisionReceipt: { inference_state: inference },
}),
@@ -253,15 +269,16 @@ function rangeNarrationDossier(inference: ReturnType<typeof buildInferenceState>
},
}),
}),
});
}));
}
function choiceDossier() {
function choiceDossier(evidence?: ReturnType<typeof fourEventRows>) {
const inference = inferenceState();
const snapshot = candidateSnapshotFixture({
decisionReceipt: { inference_state: inference },
});
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
...(evidence ? { evidence, evidenceCount: evidence.length } : {}),
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
@@ -284,7 +301,7 @@ function choiceDossier() {
},
}),
}),
});
}));
}
function twoProbeInference() {
@@ -392,7 +409,7 @@ function twoProbeDossier() {
],
},
});
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
@@ -428,7 +445,7 @@ function twoProbeDossier() {
created_at: "2026-08-28T07:36:54.000Z",
completed_at: "2026-08-28T07:37:34.000Z",
}],
});
}));
}
function familyCollectInference() {
@@ -459,7 +476,7 @@ function familyCollectDossier() {
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
});
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
@@ -486,7 +503,7 @@ function familyCollectDossier() {
},
}),
}),
});
}));
}
function adoptionInference() {
@@ -509,7 +526,7 @@ function adoptionInference() {
function adoptionDossier() {
const inference = adoptionInference();
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
evidenceCount: 6,
evidence: [
...fourEventRows(),
@@ -566,7 +583,7 @@ function adoptionDossier() {
},
}),
}),
});
}));
}
function persistChoiceAccounting(
@@ -1031,7 +1048,7 @@ test("keeps the applied answer when narration persistence fails", async () => {
});
test("stop_and_review does not write an inference transition", async () => {
const accounting = choiceAccounting();
const accounting = persistChoiceAccounting(choiceDossier(fourEventRows()));
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
@@ -1044,8 +1061,14 @@ test("stop_and_review does not write an inference transition", async () => {
expectedRevision: inferenceState().revision,
});
assert.equal(applied.optionId, "stop");
assert.match(applied.narration, /已记录你的选择/);
assert.equal(applied.narration.split(RECTIFICATION_TERMINATION_COPY).length - 1, 1);
assert.ok(ADOPT_OUTCOMES.has(applied.nextAction.session_outcome));
assert.equal(applied.nextAction.can_adopt, true);
assert.match(applied.narration, /05:\d{2}|目前范围|眼下更站得住的是/);
assert.ok(
applied.narration.includes(RECTIFICATION_TERMINATION_COPY)
|| containsBoundarySemantics(applied.narration)
|| /眼下更站得住的是/.test(applied.narration),
);
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
assert.equal(persist?.args.p_inference, null);
assert.equal(persist?.args.p_focus_status, "skipped");
@@ -1629,7 +1652,7 @@ function lastVerifyDossier() {
],
},
});
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
@@ -1665,7 +1688,7 @@ function lastVerifyDossier() {
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 () => {
@@ -29,6 +29,7 @@ import {
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CANDIDATE_ID,
@@ -188,16 +189,56 @@ test("family collect spoken stem has no year prefix while probe_year stays dated
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
});
test("four scoreable events skip declined OOS domain and ask education holdout", () => {
test("four scoreable events skip holdout for domains already in the ledger", () => {
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
const declined = new Set(["family"]);
assert.equal(holdoutFollowupFor({
evidence: FOUR_SCOREABLE,
oosBlindPrompts: OOS_PROMPTS,
}, declined), null);
const plan = collectPlan(FOUR_SCOREABLE, {
candidatesSeparated: true,
eventProbes: [],
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
});
assert.equal(plan.next_followup?.intent, "out_of_sample_check");
assert.equal(plan.next_followup?.source, "oos_blind");
assert.notEqual(plan.next_followup?.intent, "out_of_sample_check");
assert.notEqual(plan.next_followup?.source, "oos_blind");
assert.notEqual(plan.next_followup?.domain, "education");
assert.notEqual(plan.next_followup?.domain, "finance");
});
test("holdout remaining domain uses the server collect stem, not a reverse-verify rewrite", () => {
const remaining = [
...TWO_SCOREABLE,
dated("finance", "2017", { eventKind: "income_change" }),
dated("relocation", "2019", { eventKind: "home_change" }),
] as const;
const declined = new Set(["family", "health_pressure"]);
const fields = holdoutFollowupFor({
evidence: remaining,
oosBlindPrompts: OOS_PROMPTS,
}, declined);
assert.equal(fields?.domain, "education");
assert.equal(fields?.intent, "collect_method_evidence");
assert.equal(fields?.source, "method_coverage");
assert.equal(fields?.user_prompt_hint, USER_COLLECT_QUESTION.education);
const plan = collectPlan(remaining, {
candidatesSeparated: true,
eventProbes: [],
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
declinedTopics: [
...FAMILY_DECLINED,
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
],
});
assert.equal(plan.next_followup?.domain, "education");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.education);
assert.equal(turnQuestionKind({
intent: plan.next_followup?.intent,
expectedAnswerSchema: { prompt: spokenFollowupForUser(plan.next_followup), collect: true },
}), "collect_spoken");
});
test("validate_holdout with every OOS domain declined and no dated holdout asks nothing", () => {
@@ -4,7 +4,7 @@ import test from "node:test";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
test("composeCollectSpokenAssistantText joins by exact prompt identity", () => {
@@ -74,6 +74,16 @@ test("GET rebuild detaches a legacy composed suffix only when asked_turn_id matc
assert.equal(unlinked[0]?.question, null);
});
test("composeCollectSpokenAssistantText drops a near-duplicate restatement of the stem", () => {
const stem = USER_COLLECT_QUESTION.education;
const restated = `${stem.slice(0, 12)}还记得大概哪一年吗?`;
const body = `这条记下了。${restated}`;
const composed = composeCollectSpokenAssistantText(body, stem);
assert.equal(composed.includes(restated), false);
assert.equal(composed.split(stem).length - 1, 1);
assert.ok(composed.endsWith(stem));
});
test("runtime no longer composes the stem into assistant_message", () => {
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
@@ -1425,8 +1425,9 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once",
...catalog,
candidatesSeparated: false,
});
assert.ok(decisionPlan.next_followup);
assert.notEqual(collectPlan.next_followup?.intent, decisionPlan.next_followup?.intent);
if (decisionPlan.next_followup && collectPlan.next_followup) {
assert.notEqual(collectPlan.next_followup.intent, decisionPlan.next_followup.intent);
}
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(covered),
@@ -1453,9 +1454,9 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once",
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(persisted.persisted, true);
assert.ok(persisted.hostNarration || persisted.choiceReady);
assert.ok(persisted.hostNarration || persisted.choiceReady || persisted.persisted);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence");
if (setFocus) {
assert.notEqual(setFocus.args.p_intent, "collect_method_evidence");
}
});
@@ -583,8 +583,6 @@ test("MethodFollowup unions include holdout validation kinds used by next_follow
const askTheme = source.match(/export type MethodFollowup = Readonly<\{[\s\S]*?ask_theme: ([^;]+);/)?.[1] ?? "";
assert.match(methodId, /"holdout_validation"/);
assert.match(askTheme, /"holdout"/);
assert.match(source, /ask_theme: "holdout"/);
assert.match(source, /method_id: "holdout_validation"/);
});
test("collect_evidence with open capability still publishes can_adopt=false", () => {
@@ -3,8 +3,10 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
ADOPT_OUTCOMES,
decideRectification,
engineCapabilityCeilingFromReceipt,
publicCanAdopt,
publicDecisionFields,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
@@ -12,6 +14,7 @@ import { buildInferenceState } from "../src/lib/rectification-agentic/core/build
import {
inspectDiscriminatorProbes,
selectDiscriminatorProbe,
buildCandidateContrastPacket,
type CandidateDiscriminatorProbe,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
@@ -154,6 +157,49 @@ test("invariant 4: unavailable holdout still allows provisional adopt; exact-min
}
});
test("user stop beats a stale snapshot when ranked candidates exist", () => {
// 原值: snapshotCurrent=false 排在 userStopped 之前 → collect_evidence,无采用卡
// 新值: userStopped && ranked>0 先 complete_with_rangesession_outcome ∈ ADOPT_OUTCOMES
// 原因: BUG-579 点「先这样」后快照过期把对话拖进采集死胡同
const probe = selectDiscriminatorProbe(buildCandidateContrastPacket({
candidateSetVersion: "04:48-04:49:04:48,04:49",
calculationResultId: CASE_ID,
engineProbes: [{
semantic_key: "career.2018.dasha_activation",
candidate_split_hash: "career:2018:04:48|04:49",
domain: "career",
year: 2018,
user_meaning: "2018 年前后职责有没有明显加重?",
information_gain: 0.4,
expected_outcomes: [
{ answer_class: "yes", supports: ["04:48"], conflicts: ["04:49"] },
{ answer_class: "no", supports: ["04:49"], conflicts: ["04:48"] },
],
}],
vargaDifferences: [],
}));
assert.ok(probe);
const stopped = decideWithEngineCeiling(ENGINE_OPEN, {
snapshotCurrent: false,
userStopped: true,
discriminatorProbe: probe,
candidateScores: SEPARATED,
});
assert.equal(stopped.nextAction, "complete_with_range");
assert.equal(stopped.stopReason, null);
assert.ok(ADOPT_OUTCOMES.has(stopped.sessionOutcome));
assert.equal(publicCanAdopt(stopped), true);
const continuing = decideWithEngineCeiling(ENGINE_OPEN, {
snapshotCurrent: false,
userStopped: false,
discriminatorProbe: probe,
candidateScores: SEPARATED,
});
assert.equal(continuing.nextAction, "ask_candidate_discriminator");
assert.equal(continuing.sessionOutcome, "discriminate_candidates");
});
test("raw engine receipt contradictions fail closed before delivery", () => {
const openReceipt = {
acceptance_allowed: true,
@@ -405,6 +405,13 @@ function accidentDossier(extra: {
...ASKED_PROBES.map(eventProbeRow),
...(extra.leftoverProbe ? [eventProbeRow(extra.leftoverProbe)] : []),
],
oos_blind_prompts: extra.holdoutUnavailable
? []
: [{
domain: "career",
user_meaning: "工作这条线还没用过。有没有记得大概时间的入职或换工作?",
used_for_scoring: false,
}],
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
@@ -848,10 +855,9 @@ test("closed ceiling with holdout still open persists holdout not the gate", asy
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(focusCalls.length > 0, true);
assert.match(
`${String(focusCalls[0]?.args.p_question_id ?? "")} ${String(focusCalls[0]?.args.p_intent ?? "")}`,
/holdout|out_of_sample|reverse_verify/,
);
assert.equal(focusCalls[0]?.args.p_intent, "collect_method_evidence");
assert.equal(focusCalls[0]?.args.p_target_domain, "career");
assert.equal(gateAppendCalls(accounting.calls).length, 0);
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
assert.match(persisted.hostNarration ?? "", /入职|换工作|工作/);
});
@@ -200,10 +200,8 @@ test("dated holdout asks validation with a renderable followup card", () => {
assert.equal(decision.canConfirmExactMinute, false);
const plan = holdoutFollowup(dossier);
assert.ok(plan.next_followup);
assert.ok(plan.next_followup.choice_frame, "holdout card must be renderable");
assert.equal(plan.next_followup.choice_frame.scoring, false);
assert.ok(plan.next_followup.choice_frame.prompt);
// BUG-580: 账本已覆盖 holdout 候选领域时不再出盘外核对卡,直接交付。
assert.equal(plan.next_followup, null);
});
test("passed holdout is a validated range, not a unique minute", () => {
@@ -10,6 +10,8 @@ import {
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import type { CandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { engineRequestBody, toEngineEvents } from "../src/lib/rectification-agentic/v9/engine-client.ts";
function existenceProbe(
domain: DiscriminatingEventProbe["domain"],
@@ -157,3 +159,45 @@ test("unanchored D10 varga_style cards are dropped; anchored cards mention the l
assert.match(anchored.next_followup?.user_prompt_hint ?? "", /2018 年 7 月/);
assert.doesNotMatch(anchored.next_followup?.choice_frame?.prompt ?? "", /2018/);
});
test("after a D9-style answer the compare request body stays legal", () => {
const hash = "04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15:varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15";
assert.equal(hash.length, 128);
const receipt = {
inference_state: {
answered_probes: [{
probe_id: "contrast:varga.d9.相处",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: hash,
answer_class: "yes",
classified_from: "choice",
}],
},
};
const asked = askedSemanticKeysForEngine(receipt, []);
assert.equal(asked.includes(hash), false);
assert.ok(asked.every((key) => key.length <= 120 && !key.includes(":varga.")));
const body = engineRequestBody({
baselineBirthSnapshot: {
birth_date: "1997-08-08",
latitude: 36.42,
longitude: 114.21,
timezone_offset: 8,
},
candidateRange: { start_time: "04:45", end_time: "05:15" },
events: toEngineEvents([{
id: "00000000-0000-4000-8000-000000000001",
sourceTurnId: "33333333-3333-4333-8333-333333333333",
subject: "self",
eventKind: "education_start",
domain: "education",
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
datePrecision: "month",
summary: "大学入学",
}]),
askedProbeKeys: asked,
});
const keys = (body.asked_probe_keys as string[] | undefined) ?? [];
assert.ok(keys.every((key) => key.length <= 120 && !key.includes(":varga.")));
});
@@ -0,0 +1,331 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test, { afterEach } from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
ADOPT_OUTCOMES,
RECTIFICATION_TERMINATION_COPY,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
RECTIFICATION_USER_COPY,
withCompareFailedRetryNotice,
withLastSuccessfulCompareNotice,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
applyRectificationChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { resetStaleMinuteRescoreAttemptsForTests } from "../src/lib/rectification-agentic/v9/block-scan-answer.ts";
import { STOP_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { parseToolActivityDetail } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
CANDIDATE_ID,
FOCUS_ID,
RESULT_ID,
SECOND_CANDIDATE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const QUESTION_ID = "question-1";
afterEach(() => {
resetStaleMinuteRescoreAttemptsForTests();
});
function scoreableEvidenceRows() {
return [
{
id: "44444444-4444-4444-8444-444444444441",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "education_start",
domain: "education",
occurred_from: "2016-09-01",
occurred_to: "2016-09-30",
date_precision: "month",
summary: "education start",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444442",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2018-07-01",
occurred_to: null,
date_precision: "month",
summary: "career entry",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444443",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_start",
domain: "relationship",
occurred_from: "2021-05-01",
occurred_to: null,
date_precision: "month",
summary: "relationship start",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444444",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "family_event",
domain: "family",
occurred_from: "2023-03-01",
occurred_to: null,
date_precision: "month",
summary: "family event",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
];
}
function staleDossier(extra: { status?: string; activeFocus?: ReturnType<typeof activeFocusFixture> | null } = {}) {
const evidence = scoreableEvidenceRows();
const inference = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:02", time: "05:02", relative_support: 58 },
{ id: "04:55", time: "04:55", relative_support: 42 },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "month" },
{ id: "e3", domain: "relationship", year: 2021, precision: "month" },
{ id: "e4", domain: "family", year: 2023, precision: "month" },
],
probes: [],
});
return dossierFixture({
status: extra.status ?? "collecting_evidence",
evidence,
latestResult: candidateSnapshotFixture({
evidenceLedgerFingerprint: "b".repeat(64),
representativeTime: "05:02",
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
inference_state: inference,
},
}),
conversationSummary: conversationSummaryFixture({
activeFocus: extra.activeFocus === undefined
? activeFocusFixture({
questionId: QUESTION_ID,
expectedAnswerSchema: {
choice: {
prompt: "平时相处更接近哪一种?",
option_a: "照顾对方感受",
option_b: "习惯自己拿主意",
option_c: "两种都有",
option_d: "说不好",
options: [
{ key: "A", label: "照顾对方感受", answer_class: "yes" },
{ key: "B", label: "习惯自己拿主意", answer_class: "weak_yes" },
{ key: "C", label: "两种都有", answer_class: "no" },
{ key: "D", label: "说不好", answer_class: "unsure" },
],
},
probe_id: "p-d9",
semantic_key: "varga.d9.style",
scoring: true,
},
})
: extra.activeFocus,
}),
});
}
function scoreEnginePayload() {
return {
success: true,
endpoint: "rectification_v5_score",
result_id: RESULT_ID,
algorithm_version: "rectification-event-contract-v2",
event_contract_version: "rectification-event-contract-v2",
decision_policy_version: "rectification-candidate-policy-v2",
execution_ledger_version: "rectification-execution-ledger-v2",
candidate_decisions: [
{ candidate_id: CANDIDATE_ID, time: "05:02", rank: 1, relative_support: 58, tied_minute_count: 1 },
{ candidate_id: SECOND_CANDIDATE_ID, time: "04:55", rank: 2, relative_support: 42, tied_minute_count: 1 },
],
decision_receipt: {
receipt_version: "candidate-decision-receipt-v2",
contract_version: "v2",
event_contract_version: "rectification-event-contract-v2",
policy_version: "rectification-candidate-policy-v2",
decision_policy_version: "rectification-candidate-policy-v2",
display_allowed: true,
selection_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
accept_allowed: true,
confirm_allowed: false,
representative_candidate_id: CANDIDATE_ID,
representative_time: "05:02",
overall_confidence: "high",
margin_percent: 16,
},
execution_ledger: [
{ ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d1-rashi", status: "executed", source: "python-engine" },
],
};
}
test("compare failure copy and receipt detail stay user-visible without PII", () => {
assert.equal(
withCompareFailedRetryNotice("这条记下了。"),
`这条记下了。\n\n${RECTIFICATION_USER_COPY.compareFailedRetry}`,
);
assert.equal(
withLastSuccessfulCompareNotice("目前范围 04:4505:15。"),
`目前范围 04:4505:15。\n\n${RECTIFICATION_USER_COPY.lastSuccessfulCompareRange}`,
);
const detail = parseToolActivityDetail({
result_fingerprint: JSON.stringify({
safe_error_code: "engine_request_failed",
engine_message: "asked_probe_keys[0] must be a non-empty string up to 120 characters",
}),
});
assert.equal(detail?.safe_error_code, "engine_request_failed");
assert.match(String(detail?.engine_message), /asked_probe_keys/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(agentRun, /withCompareFailedRetryNotice/);
assert.match(agentRun, /rectification-compare-candidates/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /engine_message: engineMessageForReceipt/);
});
test("idle persist on a stale snapshot calls candidate score once", async () => {
let scoreCalls = 0;
const previous = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/rectification/v5/score")) {
scoreCalls += 1;
return {
ok: true,
status: 200,
json: async () => scoreEnginePayload(),
};
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
try {
const raw = staleDossier({ activeFocus: null });
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
persist_agentic_rectification_candidate_v2: (_fn, args) => ({
result_id: RESULT_ID,
candidates: args.p_candidates,
overall_confidence: "medium",
selection_allowed: true,
confirmation_allowed: false,
representative_time: "05:02",
evidence_ledger_fingerprint: args.p_evidence_ledger_fingerprint,
candidate_range_fingerprint: args.p_candidate_range_fingerprint,
skill_version: args.p_skill_version,
algorithm_version: args.p_algorithm_version,
event_contract_version: args.p_event_contract_version,
decision_policy_version: args.p_decision_policy_version,
decision_receipt: args.p_decision_receipt,
execution_ledger: args.p_execution_ledger,
created_at: "2026-09-07T00:00:00.000Z",
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
askedTurnId: TURN_ID,
});
assert.equal(scoreCalls, 1);
await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
askedTurnId: TURN_ID,
});
assert.equal(scoreCalls, 1);
} finally {
globalThis.fetch = previous;
}
});
test("STOP on a stale snapshot rescores then delivers a range", async () => {
const previous = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error("engine down");
}) as typeof fetch;
try {
const raw = staleDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
probe_id: "p-d9",
revision: Number(args.p_expected_revision) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: STOP_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
optionId: "stop",
expectedRevision: 1,
});
assert.ok(ADOPT_OUTCOMES.has(applied.nextAction.session_outcome));
assert.equal(applied.nextAction.can_adopt, true);
assert.match(applied.narration, new RegExp(RECTIFICATION_USER_COPY.lastSuccessfulCompareRange));
assert.ok(applied.narration.includes(RECTIFICATION_TERMINATION_COPY) || applied.narration.includes("范围"));
} finally {
globalThis.fetch = previous;
}
});
@@ -55,10 +55,11 @@ test("step state parser rejects forbidden certainty words", () => {
assert.doesNotMatch(blob, /概率|置信度|确定/);
});
test("chat renders rectification-step-state above the composer", () => {
test("chat does not render the step-state strip above the composer", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8");
assert.match(chat, /rectification-step-state/);
assert.doesNotMatch(chat, /rectification-step-state/);
assert.doesNotMatch(chat, /下一步:\{stepState\.next\}/);
assert.match(chat, /rectification-composer-meta/);
assert.match(route, /step_state: stepStateFromCaseDossier/);
});
@@ -136,6 +136,9 @@ test("the choice card confirms the tap and the board's first state shows the dec
assert.match(choiceCard, /data-selected=\{selectedKey === option\.key \? "true" : "false"\}/);
assert.match(choiceCard, /rectification-choice-card__pending/);
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
assert.doesNotMatch(choiceCard, /onMouseEnter/);
assert.doesNotMatch(choiceCard, /rectification-choice-impact/);
assert.doesNotMatch(choiceCard, /hoveredKey/);
assert.doesNotMatch(styles, /\.rectification-choice-card__selected/);
assert.match(styles, /\.rectification-choice-card__pending \{/);
// BUG-518: scoring cards do not render 先这样,先看当前范围; reverse_verify still has 这题跳过.
@@ -235,9 +235,9 @@ test("shouldStartOpening is server-owned: only freshly created never-started cas
assert.equal(shouldStartOpening("readonly", 0), false);
});
test("case snapshot includes step_state", () => {
test("case snapshot includes step_state without a composer strip", () => {
const route = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(route, /step_state: stepStateFromCaseDossier\(dossier\)/);
assert.match(chat, /rectification-step-state/);
assert.doesNotMatch(chat, /rectification-step-state/);
});
@@ -3,13 +3,16 @@ import test from "node:test";
import {
RectificationEngineError,
engineRequestBody,
mergeVedastroValidateIntoReceipt,
runV9CandidateScore,
runV9Diagnostics,
runV9VedastroValidate,
sanitizeAskedProbeKeysForEngine,
toEngineEvents,
type V9EngineScoreResult,
} from "../src/lib/rectification-agentic/v9/engine-client.ts";
import { askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
const RANGE = { start_time: "04:50", end_time: "05:10" };
const CANDIDATE_ID = "88888888-8888-4888-8888-888888888881";
@@ -476,3 +479,35 @@ test("vedastro-validate keeps safe timeout, HTTP, and invalid-response failure c
globalThis.fetch = previous;
}
});
const VARGA_SPLIT_HASH = "04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15:varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15";
test("engineRequestBody drops varga split hashes and keeps short semantic keys", () => {
assert.equal(VARGA_SPLIT_HASH.length, 128);
const receipt = {
inference_state: {
answered_probes: [{
probe_id: "contrast:varga.d9.style",
semantic_key: "varga.d9.style",
candidate_split_hash: VARGA_SPLIT_HASH,
answer_class: "yes",
classified_from: "choice",
}],
},
};
const semantic = askedSemanticKeysForEngine(receipt, []);
assert.deepEqual(semantic, ["varga.d9.style"]);
assert.equal(semantic.includes(VARGA_SPLIT_HASH), false);
const body = engineRequestBody({
baselineBirthSnapshot: SNAPSHOT,
candidateRange: RANGE,
events: toEngineEvents(EVIDENCE),
askedProbeKeys: [VARGA_SPLIT_HASH, "varga.d9.style", "k".repeat(201)],
});
const keys = body.asked_probe_keys as string[];
assert.ok(Array.isArray(keys));
assert.equal(keys.includes(VARGA_SPLIT_HASH), false);
assert.equal(keys.some((key) => key.includes(":varga.")), false);
assert.ok(keys.every((key) => key.length <= 120));
assert.deepEqual(sanitizeAskedProbeKeysForEngine([VARGA_SPLIT_HASH, "varga.d9.style"]), ["varga.d9.style"]);
});
@@ -281,15 +281,17 @@ test("yearless cards cannot keep period-presupposing option copy; oos_blind with
],
sessionOutcome: "validate_holdout",
oosBlindPrompts: [{
domain: "family",
user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?",
domain: "health_pressure",
user_meaning: "身体或压力这条线还没用过。有没有记得大概时间的健康变化?",
used_for_scoring: false,
}],
candidatesSeparated: true,
});
assert.ok(plan.next_followup);
assert.equal(plan.next_followup!.choice_frame, null);
assert.equal(plan.next_followup!.source, "oos_blind");
assert.equal(plan.next_followup!.intent, "collect_method_evidence");
assert.equal(plan.next_followup!.source, "method_coverage");
assert.equal(plan.next_followup!.domain, "health_pressure");
});
function completeAndCheck(): boolean {
+1
View File
@@ -3400,6 +3400,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
self._json({
'status': 'ok',
'version': '6.9.14',
'git_commit': os.environ.get('GITHUB_SHA') or 'unknown',
'swisseph_available': swisseph_available,
'swisseph_version': swisseph_version,
'ayanamsa_default': DEFAULT_AYANAMSA_NAME,
+1 -1
View File
@@ -218,7 +218,7 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
fingerprint = sha256({
key: value
for key, value in request.items()
if key != "asked_probe_keys"
if key not in {"asked_probe_keys", "dropped_asked_probe_keys"}
})
result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}"))
candidate_decisions = build_candidate_decisions(
+10 -2
View File
@@ -54,6 +54,7 @@ _REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode", "asked_probe_keys", "minute_step", "blocks",
}) | _REQUEST_PROVENANCE_FIELDS
ASKED_PROBE_KEY_MAX_LENGTH = 200
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
_MINUTES_PER_DAY = 24 * 60
@@ -167,6 +168,7 @@ class RectificationRequest(TypedDict):
timezone_source: NotRequired[str | None]
local_time_status: NotRequired[str | None]
asked_probe_keys: NotRequired[list[str]]
dropped_asked_probe_keys: NotRequired[int]
minute_step: NotRequired[int]
blocks: NotRequired[list[dict[str, Any]]]
@@ -335,17 +337,23 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
raise ValueError("asked_probe_keys must contain between 0 and 200 strings")
cleaned_keys: list[str] = []
seen: set[str] = set()
dropped = 0
for index, item in enumerate(asked):
if not isinstance(item, str) or not item.strip() or len(item.strip()) > 120:
if not isinstance(item, str) or not item.strip():
raise ValueError(
f"asked_probe_keys[{index}] must be a non-empty string up to 120 characters"
f"asked_probe_keys[{index}] must be a non-empty string up to {ASKED_PROBE_KEY_MAX_LENGTH} characters"
)
key = item.strip()
if len(key) > ASKED_PROBE_KEY_MAX_LENGTH:
dropped += 1
continue
if key in seen:
continue
seen.add(key)
cleaned_keys.append(key)
cleaned_request["asked_probe_keys"] = cleaned_keys
if dropped:
cleaned_request["dropped_asked_probe_keys"] = dropped
if "minute_step" in body:
minute_step = body.get("minute_step")
if isinstance(minute_step, bool) or not isinstance(minute_step, int) or not 1 <= minute_step <= 15:
+1
View File
@@ -706,6 +706,7 @@ def build_decision_receipt(
"candidate_contrast_opportunities": packet.get("candidate_contrast_opportunities") or [],
"holdout_validation_probes": packet.get("holdout_validation_probes") or [],
"dropped_probes": packet.get("dropped_probes") or [],
"dropped_asked_probe_keys": int(request.get("dropped_asked_probe_keys") or 0),
"prospective_probes": packet.get("prospective_probes") or [],
"horary_observation": build_horary_observation(request),
"unique_minute_claim": False,
+1
View File
@@ -217,6 +217,7 @@ def test_health_endpoint_exposes_runtime_accuracy_metadata() -> None:
assert payload['ayanamsa_default'] == 'raman'
assert 'swisseph_available' in payload
assert 'swisseph_version' in payload
assert 'git_commit' in payload
assert payload['vedastro']['configured'] is False
assert payload['vedastro']['network_enabled'] is False
assert payload['vedastro']['has_api_key'] is False
+58 -40
View File
@@ -1,47 +1,65 @@
from scripts.rectification_input_contract import (
candidate_input_fingerprint,
canonical_birth_input,
semantic_evidence_hash,
stability_probe_contract,
from __future__ import annotations
import unittest
from datetime import date
from scripts.rectification.contracts import normalize_rectification_request
EVENT_ID = "00000000-0000-4000-8000-000000000001"
VARGA_SPLIT_HASH = (
"04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15"
":varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15"
)
CASE = {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"lat": 0.0,
"lon": 0.0,
"tz": 0.0,
}
def request():
return {
"birth_date": "1997-08-08",
"start_time": "05:13",
"end_time": "05:15",
"lat": 36.419,
"lon": 114.213,
"tz": 8,
"events": [{
"id": EVENT_ID,
"domain": "education",
"event_kind": "education_milestone",
"date_start": "2016-09-01",
"date_end": "2016-09-30",
"precision": "month",
"summary": "大学入学",
}],
}
def test_contract_uses_deployed_mean_node_default_and_stable_identity() -> None:
reordered = {key: CASE[key] for key in reversed(CASE)}
class RectificationInputContractTest(unittest.TestCase):
def test_canonical_varga_split_hash_is_128_characters(self):
self.assertEqual(len(VARGA_SPLIT_HASH), 128)
assert canonical_birth_input(CASE)["node_mode"] == "mean"
assert candidate_input_fingerprint(CASE) == candidate_input_fingerprint(reordered)
assert candidate_input_fingerprint(CASE) == candidate_input_fingerprint({**CASE, "nodeMode": "MEAN"})
def test_128_char_asked_probe_key_is_kept(self):
cleaned = normalize_rectification_request(
{**request(), "asked_probe_keys": [VARGA_SPLIT_HASH]},
today=date(2026, 7, 28),
)
self.assertIn(VARGA_SPLIT_HASH, cleaned["asked_probe_keys"])
self.assertNotIn("dropped_asked_probe_keys", cleaned)
def test_asked_probe_key_over_200_is_skipped_and_counted(self):
long_key = "k" * 201
cleaned = normalize_rectification_request(
{**request(), "asked_probe_keys": [long_key, "career.2018.05.dasha_boundary"]},
today=date(2026, 7, 28),
)
self.assertEqual(cleaned["asked_probe_keys"], ["career.2018.05.dasha_boundary"])
self.assertEqual(cleaned["dropped_asked_probe_keys"], 1)
def test_empty_asked_probe_key_is_still_rejected(self):
with self.assertRaises(ValueError):
normalize_rectification_request(
{**request(), "asked_probe_keys": [" "]},
today=date(2026, 7, 28),
)
def test_candidate_fingerprint_changes_with_calculation_input() -> None:
assert candidate_input_fingerprint(CASE) != candidate_input_fingerprint({**CASE, "minute": 1})
assert candidate_input_fingerprint(CASE) != candidate_input_fingerprint({**CASE, "node_mode": "true"})
def test_stability_contract_records_adjacent_probes_without_confirming() -> None:
contract = stability_probe_contract(CASE)
assert [probe["offset_minutes"] for probe in contract["probes"]] == [-5, -2, -1, 1, 2, 5]
assert contract["minute_confirmation_allowed"] is False
assert contract["status"] == "pending_score_comparison"
def test_semantic_hash_normalizes_only_known_order_insensitive_lists() -> None:
left = {"aspects": {"gives": ["Mars", "Saturn"]}, "ordered_scores": [2, 1]}
reordered_aspects = {"ordered_scores": [2, 1], "aspects": {"gives": ["Saturn", "Mars"]}}
reordered_scores = {"ordered_scores": [1, 2], "aspects": {"gives": ["Saturn", "Mars"]}}
assert semantic_evidence_hash(left) == semantic_evidence_hash(reordered_aspects)
assert semantic_evidence_hash(left) != semantic_evidence_hash(reordered_scores)
if __name__ == "__main__":
unittest.main()