Compare commits

...
Author SHA1 Message Date
Jesse_ChenandCursor be6cf145c2 docs(changelog): restore the collect-direction-fix entry after rebase
Independent Staging Quality Gate / validate (push) Successful in 8m51s
Independent Staging Quality Gate / publish (push) Successful in 1m51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:17:46 +08:00
Jesse_ChenandCursor 7baf230066 fix(report): load candidate ranges through a read-only RPC
Accepted profiles were selecting a revoked rectification table and failing the report before the engine ran. Degrade to a null window when that optional read fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:17:14 +08:00
Jesse_ChenandClaude Fable 5 73a04e9970 docs(tasks): collect-direction fix accepted at 0be51e65
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-04 11:11:53 +00:00
Jesse_ChenandCursor 0be51e65f5 fix(rectification): ask relationship and career before dated collect
Independent Staging Quality Gate / validate (push) Successful in 10m6s
Independent Staging Quality Gate / publish (push) Has been cancelled
Move nextDatedCollectFollowup after family coverage so a new case still
asks D9 then D10. Dated third-event collect stays education → finance →
relocation → health. Admin input padding contract follows --space-3.

BUG-531 and BUG-532.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:06:54 +08:00
Jesse_ChenandClaude Fable 5 846b60642f docs(report): add BUG-526 candidate-range read task brief — degrade and RPC path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-04 10:39:27 +00:00
Jesse_ChenandClaude Fable 5 cce919fe8d docs(tasks): collect-direction accepted with fix brief — dated branch placement, gate blocker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-04 10:29:16 +00:00
Jesse_ChenandCursor 3847e9c91d fix(rectification): collect a dated third event before OOS and occupation
Independent Staging Quality Gate / validate (push) Failing after 8m39s
Independent Staging Quality Gate / publish (push) Has been skipped
Holdout stays closed until the training gate opens and skips declined domains.
Dated collect order is shared with exhaustion; spoken prompts must name the domain; progress copy uses collection_progress only.

BUG-527 through BUG-530.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 18:17:17 +08:00
37 changed files with 1814 additions and 154 deletions
+12
View File
@@ -1,5 +1,17 @@
# 印度占星 Skill 更新日志
## 2026-09-04 — 未确认生时的个人报告不再被候选窗读取打死
出生时间尚未 confirmed 时,报告会去读校正候选窗。那张表已经收回运行时表权限,读失败却被当成计算不可用,整份报告在排盘前就失败。现在改走只读 RPC;读不到窗口就按无窗口继续生成。Skill 版本未变。
## 2026-09-04 — 新案例前两问恢复感情与事业
训练门未开时,新案例仍先问感情、再问事业、再问家人;缺第三件带年份的事,才按学业 → 财务 → 搬家 → 健康补采集。原生输入框间距合同测试跟上 `var(--space-3)`。Skill 版本仍是 10.0.14。
## 2026-09-04 — 采集阶段第三件事必须指向领域,盘外核对不得抢跑
可评分事件还不到 3 条时,不再进入盘外核对,也不再先问没有日期的职业或「随便哪件事」。下一问按家人 → 学业 → 财务 → 搬家 → 健康 → 事业 → 感情补带年份的事;采集题干必须写出领域。进度句只能用服务端给出的还差几件,没有数字就不说。Skill 版本仍是 10.0.14;采用门与确认门未改。
## 2026-09-04 — 产品界面收束到三档字重、两档圆角和共用按钮
聊天正文改为 16px;产品界面字重只保留 400 / 500 / 600。控件圆角 8px、卡片 12px。会员购买、兑换和支付重试改走共用 `Button`。生时校正候选卡间距落到 4px 网格。登录与引导页仍用原来的按钮类名。Skill 版本未变。
@@ -0,0 +1,77 @@
# 任务书 · BUG-526 修复:报告链路不得直读已收权的校正表(2026-09-04)
基线:`origin/staging` @ `cce919fe`(开工时 `git fetch` 后以 `origin/staging` HEAD 为准)。
## 事故实证(沿用执行方 BUG-526 取证,已核对代码与迁移)
- BUG-524(float 崩溃)已修复并部署(`7b1354a7`),staging 引擎 smoke 200——但产品侧 personal_full 仍失败:创建后约 16 秒 `calculation_unavailable`,无章节行,**API 访问日志里没有 `POST /api/consultation_workflow`**(引擎从未被调用)。
- staging Postgres 日志三次 `permission denied for table agentic_rectification_cases`,与 job 三次 attempt 对齐。
- 权限事实链(委托方已核对):`20260812010000_agentic_rectification_v9_runtime.sql:234` 曾 `grant all ... to service_role`;`20260814010000_immutable_skill_registry.sql:25` **`revoke all on table public.agentic_rectification_cases from service_role`**,此后无任何再授权——该表运行时只许走 security definer RPC,v9 校正链路全部如此,因此校正功能正常。
- 上游同步 `a7041529` 新增了**两处** service_role 直读(BUG-526 只点名了第一处):
1. `frontend/src/lib/personal-report-worker.ts:278`(worker 的 `generate` 包装);
2. `frontend/src/app/api/reports/route.ts:205`(报告创建 route 的 `loadCandidateRange`,同样 `admin.from("agentic_rectification_cases")`,`if (error) throw error`)。
- 触发条件:资料 `birth_time_status = "accepted"`(非 confirmed)。两处读取都会 `permission denied`;错误被映射成可重试 `calculation_unavailable`,报告在调用引擎之前就死了。
## 决策记录
- **解除** `TASK-report-sensitivity-crash-20260904.md` 红线 4"前端不需要改动"的限制——仅限报告链路的候选窗读取(上述两处及其共享工具),其余 worker 逻辑仍不在授权内。
- **授权新增一个只读 security definer RPC 的数据库迁移**(方案 B),作为"不改表结构"红线的明确例外:只新增 function,不动表、列、RLS、既有 grant。
- 沿用既有原则:候选窗是敏感度的**可选增强层**,其读取失败绝不允许打死整份报告(与引擎侧"敏感度层失败降级"同一原则,BUG-524 修复已在引擎侧落实)。
## 硬红线
1. **不得恢复 `service_role` 对 `agentic_rectification_cases` 的表级权限**。20260814 的收权是安全设计(skill 身份不可变性防御),谁也不许 re-grant。
2. **读失败必须降级**:两处候选窗读取在任何错误(permission denied、超时、行不存在)下都返回 `null` range 并继续——报告以无窗口的敏感度输入正常生成,不得再映射成 `calculation_unavailable`。
3. 错误语义区分(BUG-526 防复发):`calculation_unavailable` 从此只代表引擎调用失败;候选窗读失败只记一条不含内容的 warn 级日志(错误码级别)。
4. 迁移(方案 B)照 v9 系列写法:`security definer`、`revoke all from public`、显式 `grant execute to service_role`、事务内、幂等、时间戳晚于现有迁移;**必须 `npm run test:db` 真跑**(需要 Docker;没有就走 BLOCKED 流程停下)。
5. 其余既有红线延续(不改 `.gitea/workflows/**`、不提升 main、`./node_modules/.bin/tsc` 而非 `npx tsc`、隐私日志边界)。
## 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/report-candidate-range-20260904 \
../.worktrees/report-candidate-range-20260904 origin/staging
```
先读:`frontend/src/lib/personal-report-worker.ts`(generate 包装)、`frontend/src/app/api/reports/route.ts:195-220`、`frontend/src/lib/personal-report-route-core.ts` 的 `loadCandidateRange` 消费处(创建路径的 catch 现在会把报告直接 markFailed——确认修复后该 catch 只兜真正的异常)、`frontend/supabase/migrations/20260814010000_immutable_skill_registry.sql`(RPC 的既有写法与 grant 模式)、`docs/BUG_HISTORY.md` BUG-526。
## 任务
### 任务 1(P0)· 两处读取降级 + 语义修正
- worker 与 create route 的候选窗读取统一为共享 helper:任何失败 → warn 日志(错误码)→ 返回 `null`,继续生成;`birth_time_rectification_cases` 那条查询(`.in("status", ["confirmed","completed"])`,其中 `completed` 不在该表状态枚举里,顺带修正为真实枚举值)同样降级。
- 验收:单元测试模拟 permission denied / 抛错 / 空行三态,报告链路继续且敏感度输入无窗口;`calculation_unavailable` 不再由候选窗路径产生。
### 任务 2(P0)· 合法读取通道(方案 B)
- 新增只读 RPC(如 `read_report_candidate_range(p_user_id uuid)`):返回该用户最新 `candidate_accepted` 行的 `candidate_range`(以及 `birth_time_rectification_cases` 的 confirmed 候选窗,若同函数覆盖更简洁可合并),security definer + 只授 `service_role`。
- 两处读取切到 RPC;RPC 失败仍走任务 1 的降级。
- 验收:`npm run test:db` 覆盖——`service_role` 可 execute、`authenticated`/`anon` 不可 execute、无行时返回空、RPC 不暴露除候选窗外的任何列。
### 任务 3(P0)· 部署后真实验证(历轮顺延的最终实测)
health SHA 对齐后真实生成 standard personal_full(默认 5 主题):报告 ready、各章有正文;telemetry 无 `length`;≥3 处 writer 输出回溯 bundle;每章实测 `inputTokens`/墙钟对照 `docs/tasks/PROGRESS-report-skill-parity-20260901.md` 的 2 倍线裁决。**取证要快**(容器 recreate 丢日志的教训)。accepted 生时下确认敏感度层状态合法(有 RPC 窗口则 `candidate_window_only`,降级则相应状态)。
### 任务 4(P1)· BUG-526 收口
`docs/BUG_HISTORY.md` BUG-526 补修复、验证、修复版本;注明第二读者(reports/route.ts:205)与 `completed` 枚举值错误的顺带修正。
## 不在本轮范围
- worker 其余逻辑、报告生成管线、writer 合同。
- `agentic_rectification_cases` 的表结构、RLS、既有 grant。
- 上游同步修复单(TASK-upstream-sync-fix)的其余项。
## 收尾
- `docs/tasks/PROGRESS-report-candidate-range-20260904.md`;推送后核对 health `.deployment.gitCommit`(注意 memory 记录显示 7ee7f825 曾有合同测试挡门禁——若门禁再挡,先修挡门项或走 BLOCKED,不得绕门禁)。
- 不提升 main。
## 交付物清单
1. 共享候选窗读取 helper:降级三态测试 + 错误语义修正
2. 只读 RPC 迁移 + `test:db` 实跑输出(权限矩阵四断言)
3. 两处调用切换 diff;`completed` 枚举修正说明
4. 部署后真实报告全套实测(章节正文 / telemetry / 回溯 / tokens 与 2 倍线对照)
5. BUG-526 收口 + PROGRESS + 全套质量门实际输出
+105 -8
View File
@@ -8117,17 +8117,114 @@
## BUG-526 | accepted 生时的个人报告 worker 直接读已收回权限的校正表,16 秒 calculation_unavailable
- 状态:investigating
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`frontend/src/lib/personal-report-worker.ts` `createProductionWorker` 的 `generate`;`public.agentic_rectification_cases`
- 影响面:`frontend/src/lib/personal-report-worker.ts` `createProductionWorker` 的 `generate`;`frontend/src/app/api/reports/route.ts` 的 `loadCandidateRange`;`public.agentic_rectification_cases`
- 用户现象:staging 上 standard personal_full 创建成功后约 16 秒失败,`failureCode = calculation_unavailable`,无章节行。表现与 BUG-524 事故码相同,但引擎访问日志里没有 `POST /api/consultation_workflow`。
- 触发条件:资料 `birth_time_status` 为 `accepted`(不是 `confirmed`),worker 因此去查 `agentic_rectification_cases` 的 `candidate_accepted` 行。
- 根因:`20260814010000_immutable_skill_registry.sql` 已从该表收回 `service_role` 的表级权限,只许走 security definer RPC。报告 worker 仍 `from("agentic_rectification_cases").select(...)`。Postgres 三次 `permission denied for table agentic_rectification_cases`(与 job 三次 attempt、默认 5s/10s 重试对齐)。查询失败被映射成可重试 `calculation_unavailable`,报告从未调用引擎。
- 修复:未做。本轮任务书禁止为 BUG-524 改前端 worker。候选方向:`accepted` 已有可用 `active_birth_time` 时不要读这张锁死表;或经允许的 RPC 取候选窗;查询失败时降级为无 range 继续生成,而不是打死整份报告。
- 验证:staging Postgres 日志三次 permission denied;同期 API 无 consultation_workflow;同机 web→api 虚构 smoke 为 200(BUG-524 已修好)。
- 防复发:报告 worker 不得再直接 SELECT 已收回 `service_role` 权限的校正表;`calculation_unavailable` 必须能区分「引擎 500」与「可选候选窗读失败」。
- 触发条件:资料 `birth_time_status` 为 `accepted`(不是 `confirmed`),worker 与 create route 因此去查 `agentic_rectification_cases` 的 `candidate_accepted` 行。
- 根因:`20260814010000_immutable_skill_registry.sql` 已从该表收回 `service_role` 的表级权限,只许走 security definer RPC。报告链路仍 `from("agentic_rectification_cases").select(...)`。Postgres 三次 `permission denied for table agentic_rectification_cases`(与 job 三次 attempt、默认 5s/10s 重试对齐)。查询失败被映射成可重试 `calculation_unavailable`,报告从未调用引擎。第二读者:create route 同样 `if (error) throw error`,会把创建请求打成 `calculation_unavailable`。
- 修复:新增只读 RPC `read_report_candidate_range`(不 re-grant 表权限)。worker 与 create route 经共享 helper 调用;任何读失败降级为无窗口并继续生成。`calculation_unavailable` 只保留给引擎调用失败。legacy 表终态以库约束为准:`confirmed` 与 `completed`(`20260720` 已纳入枚举;前端不再 `.in("status", ["confirmed", "completed"])` 直查)。
- 验证:`tests/report-candidate-range.test.ts` 三态降级;`tests/personal-report-api.test.ts` null 窗口仍 201;`tests/database-report-candidate-range.test.ts` 权限矩阵与列白名单。产品侧真实 personal_full 待本修复部署后补。
- 防复发:报告 worker 与 create route 不得再直接 SELECT 已收回 `service_role` 权限的校正表;候选窗读失败不得再变成 `calculation_unavailable`。
- 相关记录:BUG-524
- 复发自:无
- 修复版本:待修复
- 修复版本:待发布
- 编号说明:rebase 到 `origin/staging` 时 BUG-525 已被采集拒答占用,本条落在 BUG-526。
## BUG-527 | 可评分事件不足 3 条时盘外核对抢跑到已拒答领域
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`buildMethodFollowupPlan`、`holdoutFollowupFor`、`oos_blind` / holdout 提问
- 用户现象:账本只有 2 条可评分带日期事件时,家人采集刚答「没有」,下一问却变成盘外核对,焦点落在刚拒答的 family。
- 触发条件:`holdoutValidation === "not_started"` 或 `sessionOutcome === "validate_holdout"`,引擎给了 `oos_blind_prompts`,训练门 `event_quality.passed=false`。
- 根因:计划层的两处 OOS 分支只看 holdout 状态和引擎提示,不过 `meetsAcceptanceEventQuality`,也不跳过 `declinedDomains`(BUG-520 口径)。
- 修复:新增 `holdoutFollowupFor`:训练门未开返回 `null`;否则取第一个未拒答的 OOS 提示,没有则退到带年份 holdout 事件,再没有为 `null`。两处 OOS 分支都走它。不改 `holdoutValidationStatus` / `decideRectification` 的采用与确认门。
- 验证:`frontend/tests/rectification-collect-direction-20260904.test.ts`(2 条事件不出 OOS;4 条事件跳过 family 后问 education holdout;全部拒答且无 holdout 事件时 `validate_holdout` 的 `next_followup` 为 null)。
- 防复发:OOS 提问必须同时满足训练门与未拒答领域。不得只凭引擎给了 `oos_blind_prompts` 就提问。
- 相关记录:BUG-396、BUG-520
- 复发自:无
- 修复版本:待发布
- 编号说明:任务书写 BUG-524;rebase 后最大号已是 BUG-526(报告 worker),本条从 BUG-527 起。
## BUG-528 | 缺第三件带年份的事时先问职业或泛问
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`buildMethodFollowupPlan`、`nextDatedCollectFollowup`、`exhaustionSpokenCollectFollowup`、`persistNextInterviewAfterChoice`
- 用户现象:2 条可评分事件、家人拒答后,决定路径问「你平时主要做什么工作?」;职业没有日期,答完也不推进训练门。
- 触发条件:`!meetsAcceptanceEventQuality`,方法覆盖轮转里感情/事业已有记录、家人已拒答,剩下职业。
- 根因:带年份的学业/财务/搬家/健康只存在于穷尽路径;主流程在方法轮转之后才落到 `domain: null` 泛问。
- 修复:`DATED_COLLECT_ORDER`(家人 → 学业 → 财务 → 搬家 → 健康 → 事业 → 感情)由主流程与 `exhaustionSpokenCollectFollowup` 共用。训练门未开时,在方法轮转之前取下一件带年份采集题;职业与 `other` 泛问排在最后。`datedCollectFollowup` 的 `source` 为 `method_coverage`(采集,不是盘外核对)。
- 验证:新文件锁 education → finance → occupation → other;拒答路径落下 `USER_COLLECT_QUESTION.education`。`rectification-eight-method` 中训练门未开的下一问从感情改为家人(或财务),每条改断言附原值/新值/原因。4 条可评分事件时本分支不触发(`rectification-collect-stall`、`rectification-yearless-ungrounded` 的 OOS 结论不变)。
- 防复发:训练门未开时不得先问职业或无领域泛问。顺序只许有一处定义。
- 相关记录:BUG-426、BUG-442、BUG-472、BUG-527、BUG-531
- 复发自:无
- 修复版本:待发布
## BUG-529 | 采集题干可以不指向任何领域
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`validateSpokenPrompt`、`rectification-set-focus`、`COLLECT_DOMAIN_KEYWORDS`
- 用户现象:Agent 把家人盘外核对改写成「除了工作这条线,还有哪件事你能记起大概的年份?」,用户不知道该往哪想。
- 触发条件:`intent=collect_method_evidence`、无 `choice_frame`、领域在关键词表内;题干不含该领域日常词。
- 根因:校验器只查长度、选项字面、内部 token、`domain_mismatch`,不要求题干提到领域。
- 修复:采集题必须命中目标领域至少一个关键词,否则 `domain_missing`。第二次仍不合格时服务端用 `USER_COLLECT_QUESTION[domain]` 落焦点。工具描述要求写出领域。区分题与 OOS 题不套这层关键词。
- 验证:`rectification-spoken-prompt`(education 泛问 → `domain_missing`;含「上学」→ ok;OOS 不受影响);新文件两次泛问后焦点 prompt 为 `USER_COLLECT_QUESTION.education`。
- 防复发:采集题方向由服务端定。不得把关键词校验套到带 `choice_frame` 的区分题或 OOS 题上。
- 相关记录:BUG-527、BUG-528
- 复发自:无
- 修复版本:待发布
## BUG-530 | 采集进度句没有数字
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:读盘投影 `collection_progress`、`agentic-rectification.ts` 提示词、`MACHINE_VOICE_LEXICON`
- 用户现象:助手写「当前范围还在继续收窄中,接下来我们继续。」没有任何数值。
- 触发条件:下一问是采集题;Agent 在没有 `collection_progress` 或不用该字段时自行写进度。
- 根因:读盘投影没有给出可评分件数/下限/还差几件;提示词允许空进度句;词表不拦「继续收窄」。
- 修复:`collectionProgressFromReceipt` 从 `gates.event_quality` 取 `{ scoreable, minimum, missing }`,缺字段为 `null`。读盘与 GET interview 都带上。提示词要求数字只来自该字段,没有就不说进度。词表加入「继续收窄」「范围还在」,只对 `spokenPrompt` 校验生效(旁白无同一套校验)。
- 验证:投影锁 2/3/1 与缺 gate 时 `null`;提示词测试含 `collection_progress` 与「不得写『范围在收窄』」。
- 防复发:没有 `collection_progress` 不得写没有数字的收窄句。数字不得自算。
- 相关记录:BUG-527
- 复发自:无
- 修复版本:待发布
## BUG-531 | 新案例第二问被家人抢占感情 / 事业
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`buildMethodFollowupPlan` 里 `nextDatedCollectFollowup` 的插入位置
- 用户现象:新案例已有 1 件事业(或 1 件感情)时,第二问变成家人,而不是感情 / 事业。
- 触发条件:训练门未开(前 3 件事几乎总是如此);`3847e9c9` 把 dated 补采集插在方法覆盖轮转之前。
- 根因:上游任务书把「缺第三件带年份的事」的顺序写成了训练门未开阶段的总顺序。执行方按字面把 `nextDatedCollectFollowup` 放在 `!dashaCovered` 之后、感情 / 事业 / 家人轮转之前。这是任务书顺序错位,不是执行方偏离。
- 修复:删掉轮转前分支。轮转里 `!familyCovered` 之后、`!occupationCovered` 之前,训练门未开且 `nextDatedCollectFollowup` 有结果时取它。此时感情 / 事业 / 家人已 covered 或 declined,补采集只会给出学业 → 财务 → 搬家 → 健康。`DATED_COLLECT_ORDER` 与 `exhaustionSpokenCollectFollowup` 不动。
- 验证:`rectification-collect-direction-20260904` 锁 1 事业 → `d9_relationship`、1 感情 → `d10_career`、事业+感情 → `relatives`、再拒答家人 → `d5_education`、再拒答学业 → `d2_finance`。家人答「没有」后焦点仍是学业。`3847e9c9` 改写的感情 / 事业断言回到 `45bdb63e`。
- 防复发:dated 补采集不得插在感情 / 事业轮转之前。训练门未开时新案例前两问仍是 D9 / D10。
- 相关记录:BUG-528、BUG-463
- 复发自:BUG-528
- 修复版本:待发布
## BUG-532 | 原生 input 间距合同测试仍锁 12px
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`frontend/tests/admin-model-management-ui-contract.test.ts`;挡住 `backend-quality-gate` 的 `npm test --prefix frontend`
- 用户现象:无直接用户可见故障。`7ee7f825` 之后门禁因这一条失败,staging 部署停在更早的 SHA。
- 触发条件:`globals.css` 原生 input 为 `padding: 0 var(--space-3)`,合同测试仍匹配 `padding: 0 12px;`。
- 根因:`7ee7f825`(`fix(ui): collapse product weights and radii onto the shared Button`)把间距收敛到 token,未改合同测试。本单开工时 `origin/staging` 上这条仍红,别处未修。
- 修复:测试接受 `padding: 0 var(--space-3);`。不改 CSS。
- 验证:`admin-model-management-ui-contract` 全过;全量失败清单只剩 Docker 并发争用,隔离重跑通过。
- 防复发:改 `globals.css` 原生控件尺寸时同步合同测试。不得为迁就测试把 token 改回裸像素。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,105 @@
# PROGRESS · 采集阶段第三件事没有方向(2026-09-04)
工作树:`.worktrees/rectification-collect-direction-20260904`
分支:`codex/rectification-collect-direction-20260904`
基线:任务书写 `origin/staging` `45bdb63e`;开工时工作树落后 3 个提交,已 rebase。交付前再 `git fetch`,快进合入 `7ee7f825`(共用 Button)与 `5c5f25a3`(报告 worker BUG-526 记录)。当前与 `origin/staging` 对齐,改动未提交。
任务书:`docs/tasks/TASK-rectification-collect-direction-20260904.md`
未改 Python、未 bump Skill(仍 10.0.14)、未改 `decideRectification` 采用/确认门。未 push。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 盘外核对加训练门并跳过拒答领域 | 完成 | BUG-527(任务书原写 524) |
| 5.2 带年份采集顺序进入计划主流程 | 完成 | BUG-528(任务书原写 525) |
| 5.3 采集题干必须指向领域 | 完成 | BUG-529(任务书原写 526) |
| 5.4 进度句有数字才说 | 完成 | BUG-530(任务书原写 527) |
## 编号
rebase 后 `docs/BUG_HISTORY.md` 最大号已是 BUG-526(accepted 生时报告 worker 读校正表)。本单实际落点 **BUG-527~530**。
## 实现要点
- `holdoutFollowupFor`:`meetsAcceptanceEventQuality` 未开 → `null`;否则跳过 `declinedDomains`,先 OOS 提示再带年份 holdout 事件。`validate_holdout` 早退与 `holdoutValidation === "not_started"` 共用。
- `DATED_COLLECT_ORDER`:家人 → 学业 → 财务 → 搬家 → 健康 → 事业 → 感情。主流程在方法轮转之前、训练门未开时走 `nextDatedCollectFollowup`;`exhaustionSpokenCollectFollowup` 同一函数。职业与 `other` 排最后。
- `datedCollectFollowup.source = "method_coverage"`(采集,不是盘外核对),这样 `isOfferBlockingFollowup` 对 relatives/D9/D10 仍挡出牌。
- `COLLECT_DOMAIN_KEYWORDS` + `validateSpokenPrompt`:`collect_method_evidence` 且无 `choice_frame` 时题干必须命中领域词,否则 `domain_missing`。第二次失败用 `USER_COLLECT_QUESTION[domain]`。区分题 / OOS 不套这层。
- `collectionProgressFromReceipt`:`{ scoreable, minimum, missing }` 或 `null`。读盘 `projectTurnDecision` 与 GET `interview` 都带。提示词禁止无数字的「范围在收窄」。词表加「继续收窄」「范围还在」,只拦 `spokenPrompt`。
## 偏离与断言改写
`rectification-yearless-ungrounded` 的 `validate_holdout` / `oos_blind` **未改断言**:该 fixture 已有 4 条带日期证据,`meetsAcceptanceEventQuality` 为 true,OOS 结论仍成立。
训练门未开时下一问从方法轮转(感情/事业)改为 datedCollectOrder。凡改断言均在测试里写了原值/新值/原因。摘要:
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `rectification-eight-method` 一件学业 | `d9_relationship` | `relatives` / family | 训练门未开先补家人 |
| 同上,学业+家人+事业读盘 | `d9_relationship` | `d2_finance` | 1 条 holdout,训练门未开,家人/学业已有记录 |
| 同上,3 条+1 holdout、家人拒答 | 方法轮转 collect | `finance` collect | 同上,补财务而不是区分 |
| `rectification-range-offer-deadend` 穷尽 | occupation | relocation | 顺序合并后搬家在职业前 |
| `rectification-agentic-entry` 源码锁 | `interview: fields` | `{ ...fields, collection_progress }` | GET 带进度 |
| `rectification-question-ownership` / v10 set-focus | 「哪年上的大学」 | `USER_COLLECT_QUESTION[domain]` | 默认 dossier 一件事业 → 家人,大学不命中 |
| `rectification-walkthrough-polish` retry 计划 | relationship retry | family | 已关闭感情不挡家人采集 |
## 测试
开工聚焦套件(rebase 后、本单改动前未单独存档数字;交付时本套件全绿):
`tsx --test` eight-method / yearless-ungrounded / server-focus / collect-stall / adopt-narration-20260904 / answer-choice / collect-direction-20260904 / spoken-prompt / v9-agent / range-offer-deadend:
```
# tests 183
# pass 183
# fail 0
# cancelled 0
# skipped 0
# todo 0
exit=0
```
新文件 `rectification-collect-direction-20260904.test.ts`:8/8。
`tsc --noEmit`:本单文件 0 错。全量仍 2 个既有错误(`app-sidebar.tsx` ref、`calendar.tsx` Button `onSubmit`),与 `7ee7f825` 共用按钮合入后仍在,未触碰。exit=2。
校正相关 glob(含 v9-database Docker):
```
# tests 892
# pass 891
# fail 1
# cancelled 0
# skipped 0
# todo 0
exit=1
```
唯一失败 `v9 legacy backfill…`:并发 Docker 争用。该文件隔离重跑 **7/7**,exit=0。
全量 `tests/*.test.ts`(本机有 Docker;第一次跑在产品断言修完之前):
```
# tests 2611
# pass 2591
# fail 20
# cancelled 0
# skipped 0
# todo 0
exit=1
```
20 条拆开:
- **5 条本单产品失败**(已修,隔离复跑全绿):`interview: fields`、legacy quality 后 `d9_relationship`、set-focus 大学题干、v10 set-focus、walkthrough relationship retry。
- **12 条 Docker/Postgres 争用**(`database system is shutting down` / `database migration failed`),与 BUG-523 基线同类;v9-database 隔离 7/7。
- **3 条 `@base-ui/react/button/index.mjs` MODULE_NOT_FOUND**:`personal-report-entry`、`personal-report-view`、`report-polling-contract`。本单未改这些文件;`node_modules/@base-ui/react/button/` 实际只有 `Button.mjs` / `index.js`。
未改 Python;未声称系统 `python3` 预检通过。
## 改动文件
- 计划:`method-followup.ts`
- 校验/题干:`spoken-prompt.ts`、`user-copy.ts`、`rectification-v9-tools.ts`
- 进度:`evidence-model.ts`、`turn-decision.ts`、`route.ts`、`agentic-rectification.ts`、`agent-voice-lexicon.ts`
- 新增测试:`frontend/tests/rectification-collect-direction-20260904.test.ts`
- 回归:eight-method、range-offer-deadend、spoken-prompt、v9-agent、agentic-entry、choice-card、question-ownership、v10-conversation-focus、walkthrough-polish
- 文档:`docs/BUG_HISTORY.md`、`docs/tasks/README.md`、`CHANGELOG.md`、本文件
@@ -0,0 +1,86 @@
# PROGRESS · 采集方向修复单(2026-09-04)
工作树:`.worktrees/rectification-collect-direction-fix-20260904`
分支:`codex/rectification-collect-direction-fix-20260904`
基线:任务书写 `origin/staging` `3847e9c9`;开工时 tip 是纯文档 `cce919fe`。交付前再 `git fetch`,快进合入 `846b6064`(BUG-526 任务书,未占用 BUG-531)。
任务书:`docs/tasks/TASK-rectification-collect-direction-fix-20260904.md`
未改 Python、未 bump Skill(仍 10.0.14)、未改 `holdoutFollowupFor` / `validateSpokenPrompt` / `collectionProgressFromReceipt` / `declinedDomains` / 采用确认门。推 `origin/staging`。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 dated 分支移到轮转内(家人后、职业前) | 完成 | BUG-531 |
| 5.2 回退 17 条感情/事业断言 | 完成 | 随 BUG-531 |
| 5.3 门禁 CSS 合同测试 | 完成 | BUG-532 |
| 5.4 记录 | 完成 | — |
## 编号
开工时 `docs/BUG_HISTORY.md` 最大号 BUG-530。本单 **BUG-531~532**。`846b6064` 未占用。
## 实现要点
- 删掉 `buildMethodFollowupPlan` 里轮转前的 `if (!next && !meetsAcceptanceEventQuality) nextDatedCollectFollowup`。
- 轮转内 `!familyCovered` 之后、`!occupationCovered` 之前:训练门未开且 `nextDatedCollectFollowup` 有结果则取它。`source` 仍是 `method_coverage`。
- `DATED_COLLECT_ORDER` 与 `exhaustionSpokenCollectFollowup` 不动;`rectification-range-offer-deadend` 搬家在职业前保留。
- `3847e9c9` 把新案例第二问改成家人的断言回到 `45bdb63e`(感情 / 事业)。三栏:原值 = `3847e9c9`,新值 = `45bdb63e`,原因 = dated 分支移到家人之后。
- CSS 合同测试接受 `padding: 0 var(--space-3)`。这条是 `7ee7f825` 引入的,开工时 staging 仍红,别处未修。
## 偏离
- `rectification-question-ownership` / `rectification-v10-conversation-focus` 不能把 spokenPrompt 恢复成「哪年上的大学」:服务端下一问仍是采集,BUG-529 要求题干命中领域词。默认 1 件事业现在下一问是感情,改用 `USER_COLLECT_QUESTION.relationship`。
- `three dated events with one holdout` 仍锁 `domain: finance`(家人拒答后 dated 补采集,不是轮转抢跑)。`45bdb63e` 未锁 domain;本单不删这条,因为行为与 `3847e9c9` 补第三件事一致。
- `collect_retry: true` 与 `USER_COLLECT_QUESTION_RETRY.relationship` 实际在 `rectification-walkthrough-polish`,已恢复。v10 文件里没有这两条断言。
## 测试
`tsc --noEmit`:exit=0。
聚焦套件 `tsx --test tests/rectification-*.test.ts tests/admin-model-management-ui-contract.test.ts`:
```
# tests 875
# pass 875
# fail 0
# cancelled 0
# skipped 0
# todo 0
exit=0
```
新断言在 `rectification-collect-direction-20260904.test.ts`:9/9。
全量 `npm test`(本机有 Docker):
```
# tests 2656
# pass 2654
# fail 2
# cancelled 0
# skipped 0
# todo 0
exit=1
```
2 条失败均为 Docker 迁移争用(`database migration failed`):`database-personal-report-sections`、`rectification-v9-database` 里 `v9 agent api migration…`。`--test-concurrency=1` 隔离重跑:
```
# tests 8
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
exit=0
```
`eslint` 本单文件 0 error;`choice-card` / `walkthrough-polish` 各 1 条既有 unused 警告,未触碰。
未改 Python;未声称系统 `python3` 预检通过。
## 改动文件
- 计划:`frontend/src/lib/rectification-agentic/v9/method-followup.ts`
- 新断言:`frontend/tests/rectification-collect-direction-20260904.test.ts`
- 回退:eight-method、walkthrough-polish、question-ownership、choice-card、v10-conversation-focus
- 门禁:`frontend/tests/admin-model-management-ui-contract.test.ts`
- 文档:`docs/BUG_HISTORY.md`、`docs/tasks/README.md`、`CHANGELOG.md`、本文件
@@ -0,0 +1,43 @@
# PROGRESS · BUG-526 报告链路不得直读已收权的校正表(2026-09-04)
工作树:`/Users/jesse/Downloads/Copse/astrology/.worktrees/report-candidate-range-20260904`
分支:`codex/report-candidate-range-20260904`
基线:任务书写 `cce919fe`;开工 `origin/staging` HEAD 为 `846b6064`(含本任务书)。
任务书:仓库根 `TASK-report-candidate-range-read-20260904.md`
未恢复 `service_role` 表级权限,未改表/列/RLS/既有 grant,未改 worker 其余生成管线,未改 `.gitea/workflows/**`,不提升 main。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 1 两处读取降级 + 语义修正 | 完成 | BUG-526 |
| 2 只读 RPC + `test:db` | 完成(本文件定向套件已绿;全量 `test:db` 见下方门禁输出) | BUG-526 |
| 3 部署后真实 personal_full | 待部署 | BUG-526 |
| 4 BUG-526 收口 | 完成(修复版本待 staging SHA) | BUG-526 |
## 实现要点
- 共享 helper `frontend/src/lib/report-candidate-range.ts`:只调 `read_report_candidate_range`。permission denied / 抛错 → warn(仅 `event` + `reason` 错误码)→ `null`;空行 → `null` 且不 warn。非法时钟或 `start > end` 也当无窗口。
- worker `generate` 与 `POST /api/reports` 的 `loadCandidateRange` 都走 helper,不再 `from("agentic_rectification_cases")` / `from("birth_time_rectification_cases")`。
- 迁移 `20260904010000_read_report_candidate_range.sql`:`security definer`、`search_path=''`、`revoke` public/anon/authenticated、`grant execute` 只给 `service_role`。返回值只有 `start_time` / `end_time`。
- **`completed` 枚举:** 任务书以为它不在 `birth_time_rectification_cases` 状态枚举里。`20260720010000_conversational_rectification_schema.sql` 已纳入 `confirmed` 与 `completed` 两个终态。RPC 使用这两值;前端不再维护一份 `.in("status", …)` 列表。`abandoned` 不取窗。
## 测试
- `tests/report-candidate-range.test.ts`:permission denied / throw / 空行三态;合法窗;敏感度输入无窗口;源码锁两处调用。
- `tests/personal-report-api.test.ts`:`loadCandidateRange` 返回 `null` 时 create 仍 201,不是 `calculation_unavailable`。原「只信 confirmed/completed 直读」改为 RPC helper 源码锁。
- `tests/database-report-candidate-range.test.ts`:`service_role` 可 execute,`anon`/`authenticated` 不可;表 SELECT 仍 denied;无行返回空;窗口 JSON 只有两列;legacy confirmed 行优先。
## 让步
无。未 re-grant 表权限。
## 门禁解堵
`7ee7f825` 的 CSS 合同脱节已由 `0be51e65` / BUG-532 合入 staging,本单不再改那条测试。
## 门禁输出
- `./node_modules/.bin/tsc --noEmit`:0
- `npm run lint`:0 error / 74 warning(既有,未动)
- 定向:`report-candidate-range` + `personal-report-api` + `personal-report-worker` + migration 源码锁 **75 pass**
- `npm run test:db`(`tsx --test --test-concurrency=1 tests/database-*.test.ts`):**35 pass, 0 fail**(含新 RPC 套件)。第一次全量 34/35,`database-admin-identity` 迁移在套件开头失败,单跑即过,属 Docker 争用。
- 非数据库 `tsx --test tests/*.test.ts`:2626 pass;当时 2 fail 为 CSS 合同(已由 `0be51e65` 修)与 `identity-auth-integration`(与并行 `test:db` 抢 fixture,单跑已过)。
+3 -1
View File
@@ -47,7 +47,8 @@
| `TASK-rectification-adopt-narration-20260904.md` | `PROGRESS-rectification-adopt-narration-20260904.md` | 探针池耗尽时采用卡旁白改 Agent 生成 + 已丢弃探针绕过 BUG-472 早退 + 区分题答否关线 | 已验收(4.1.4 点选入口未通过,见修复单) | `0aaa0d70`(BUG-519/520) |
| `TASK-rectification-adopt-narration-fix-20260904.md` | `PROGRESS-rectification-adopt-narration-fix-20260904.md` | 采用旁白 Agent 在点选入口从不运行(早退分支重算过期决策)+ 校验器/可观测/超时 | 已验收(5.1/5.2 实现通过;超时测试挂死事件循环,见修复单 2) | `13dded9f`(BUG-521/522) |
| `TASK-rectification-adopt-narration-fix2-20260904.md` | `PROGRESS-rectification-adopt-narration-fix2-20260904.md` | 采用旁白超时用了 unref 的 `AbortSignal.timeout`,测试挂死取消同文件后三条用例,门禁 `npm test` 退出码 1 | 已验收(门禁通过,staging 已部署) | `45bdb63e`(BUG-523) |
| `TASK-rectification-collect-direction-20260904.md` | `PROGRESS-rectification-collect-direction-20260904.md` | 可评分事件 2 条时盘外核对抢跑到刚拒答的家人领域,Agent 只能改写成不指向任何领域的泛问;缺第三件带年份的事却先问职业 | 待执行 | — |
| `TASK-rectification-collect-direction-20260904.md` | `PROGRESS-rectification-collect-direction-20260904.md` | 可评分事件 2 条时盘外核对抢跑到刚拒答的家人领域,Agent 只能改写成不指向任何领域的泛问;缺第三件带年份的事却先问职业 | 已验收:5.1/5.3/5.4 通过;5.2 顺序错位(任务书责任)见修复单 | `codex/rectification-collect-direction-20260904`(BUG-527~530) |
| `TASK-rectification-collect-direction-fix-20260904.md` | `PROGRESS-rectification-collect-direction-fix-20260904.md` | dated 补采集分支插在方法轮转之前,新案例第二问被家人抢占感情/事业;`7ee7f825` CSS 与合同测试脱节挡住门禁 | 已验收通过(0be51e65),待 staging 部署核对 | `codex/rectification-collect-direction-fix-20260904`(BUG-531~532) |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) |
### 聊天主链路与首页
@@ -74,6 +75,7 @@
| `TASK-report-blocked-repairs-20260902.md` | `PROGRESS-report-blocked-repairs-20260902.md` | 全主题 blocked 修复 | 已验收 | `7faf8555` |
| `TASK-report-section-writer-failure-20260902.md` | `PROGRESS-report-writer-failure-20260902.md` | 写作阶段 report_schema_invalid | 已验收 | `eda37c15`(后续 `43294265`、`5c0bec0c`、`fbd6e480`、`cf6405ed`) |
| `TASK-report-sensitivity-crash-20260904.md`(仓库根) | `PROGRESS-report-sensitivity-crash-20260904.md` | consultation_workflow float 时辰崩溃,全量 500 | 执行中 | `codex/report-sensitivity-crash-20260904`(BUG-524) |
| `TASK-report-candidate-range-read-20260904.md`(仓库根) | `PROGRESS-report-candidate-range-20260904.md` | accepted 生时报告直读已收权校正表 | 待验收 | `codex/report-candidate-range-20260904`(BUG-526) |
### 前端基础与工程
@@ -0,0 +1,109 @@
# TASK · 采集方向修复单:带年份补采集不得抢在感情 / 事业前面(2026-09-04)
上游任务书:`docs/tasks/TASK-rectification-collect-direction-20260904.md`(实现 `3847e9c9`,已合入 `origin/staging`)。
本单是验收后的修复单,只处理验收未通过项与一条门禁阻塞;5.1 / 5.3 / 5.4 已验收通过,不重做。
## 0. 基线
- 基线 commit:`origin/staging` **`3847e9c9`**(`fix(rectification): collect a dated third event before OOS and occupation`)。
- 分支:`codex/rectification-collect-direction-fix-20260904`,工作树 `.worktrees/rectification-collect-direction-fix-20260904`。
- 验收环境:无 Docker(25 条 `docker ENOENT` 基线失败)、无登录态、无模型凭据。
## 1. 事故实证
### 1a. 新案例第二问从「感情」变成了「家人」(验收发现,P1)
任务书 §3 决策 3 写的顺序是「家人→学业→财务→搬家→健康→事业→感情」,并要求进入计划主流程。执行方按字面把 `nextDatedCollectFollowup` 插在 `buildMethodFollowupPlan` 的方法覆盖轮转**之前**(`method-followup.ts` 中 `if (!next && !meetsAcceptanceEventQuality(input.evidence))` 一段,位于 `!dashaCovered` 之后、`sameDomainYearlessCard` / `d9_relationship` 轮转之前)。
结果:训练门未开(前 3 件事几乎总是如此)时,任何新案例的路径变成:
| 已有证据 | `45bdb63e`(原) | `3847e9c9`(现) |
| --- | --- | --- |
| 1 件事业 | `d9_relationship` / relationship | `relatives` / family |
| 1 件感情 | `d10_career` / career | `relatives` / family |
| 1 件学业 | `d9_relationship` / relationship | `relatives` / family |
| 事业 + 感情 | `relatives` / family | `relatives` / family(一致) |
本地重放(`buildMethodFollowupPlan` 带 `holdoutValidation: "not_started"`)与 `3847e9c9` 改写的 17 条断言一致:`rectification-eight-method`(7 条 `d9_relationship → relatives`)、`rectification-walkthrough-polish`、`rectification-question-ownership`、`rectification-choice-card`、`rectification-v10-conversation-focus`(`relationship / collect_retry: true → family`,同时丢掉了「已关闭的感情采集 → retry 文案」这条路径)。
这不是执行方偏离,是任务书 §3 决策 3 给的顺序没有区分「新案例的前两问」与「缺第三件事时的补采集」。**这条由任务书作者承担。**
### 1b. 为什么感情 / 事业必须在前
- `BLOCKING_COVERAGE_IDS = { dasha_events, d9_relationship, d10_career, relatives }`:D9 / D10 / 六亲三层是采用门的前置(BUG-463),家人在第三位是既定顺序。
- `sameDomainYearlessCard(domain)` 只在轮转里生效:感情 / 事业若已有无年份的区分卡,轮转会直接出卡;提前的 dated 分支绕过了它。
- `closedCollectFocuses` 的 retry 路径(`USER_COLLECT_QUESTION_RETRY.relationship`)也只在轮转里;提前分支让它永远不会被触发。
### 1c. 门禁阻塞(非本单引入,但挡住本单上线)
`https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 停在 `7b1354a7`。其后 `7ee7f825`(`fix(ui): collapse product weights and radii onto the shared Button`)把 `globals.css` 里原生 input 的 `padding: 0 12px` 改成 `padding: 0 var(--space-3)`,但 `frontend/tests/admin-model-management-ui-contract.test.ts` 的 `global native input sizing excludes Ant Design picker internals` 仍锁 `padding: 0 12px;`。`backend-quality-gate` 跑 `npm test --prefix frontend`,所以 `7ee7f825`、`5c5f25a3`、`3847e9c9` 全部没有部署。本地全量:2655 tests / 26 fail,其中 25 条是无 Docker 基线,唯一新增就是这条。
## 2. 根因
1. 任务书顺序定义错位:把「补第三件带年份的事」的顺序当成了整个训练门未开阶段的顺序,覆盖了新案例的前两问。
2. 插入点错位:dated 分支应当是轮转里「家人」之后、「职业」之前的一个环节,而不是轮转前的总闸。
3. `7ee7f825` 改 CSS 未同步合同测试。
## 3. 决策记录
1. **`DATED_COLLECT_ORDER` 的定义不动**(家人→学业→财务→搬家→健康→事业→感情),仍是唯一定义;`exhaustionSpokenCollectFollowup`(Agent 穷尽路径)继续用它,`rectification-range-offer-deadend` 那条「搬家在职业前」的改写保留。
2. **计划主流程只改插入位置**:删掉轮转前的 `if (!next && !meetsAcceptanceEventQuality(...))` 分支;在轮转里 `!familyCovered` 分支之后、`!occupationCovered` 分支之前加一段:训练门未开且 `nextDatedCollectFollowup(input.evidence, declined)` 有结果时取它。此时感情 / 事业 / 家人已 covered 或 declined,`nextDatedCollectFollowup` 只会给出学业→财务→搬家→健康。
3. 本单顺带解堵 §1c:改测试不改 CSS(CSS 改动是设计意图,不为测试改业务代码)。若开工时 `origin/staging` 上这条测试已被 Button 会话修好,跳过并在进度记录写明。
4. 1a 表里被改写的 17 条断言全部**回退到原值**,每条保留三栏说明(原值 = `3847e9c9` 的值,新值 = `45bdb63e` 的值,原因 = 「dated 分支移到家人之后,前两问恢复感情 / 事业」)。
## 4. 硬红线
1. 不改 `holdoutValidationStatus` 语义;不动采用 / 确认门(BUG-463);不改 `declinedDomains`(BUG-520);不改 `holdoutFollowupFor`、`validateSpokenPrompt`、`collectionProgressFromReceipt`(已验收)。
2. 顺序只保留 `DATED_COLLECT_ORDER` 一处定义,不得再抄一份。
3. 事故案例的三种形状(上游任务书 §1:after-no / after-occupation / no-decline)改完后必须仍然得到学业采集题,不得回到 occupation 或 `other`。
4. 不写任何案例数据;不动 Python;不 bump Skill。
5. 进度记录必须贴六行汇总 + 退出码(`# tests / # pass / # fail / # cancelled / # skipped / exit`);`# cancelled` 非 0 视为未通过(BUG-523)。
## 5. 任务分解
### 5.1 dated 分支移到轮转内(BUG-531)
- 改 `frontend/src/lib/rectification-agentic/v9/method-followup.ts`:删轮转前分支;轮转内新增 `else if (!meetsAcceptanceEventQuality(input.evidence) && (dated = nextDatedCollectFollowup(input.evidence, declined)))` 形式的环节(写法自定,位置必须在家人之后、职业之前)。
- 验收:
- `buildMethodFollowupPlan` 一件事业 → `d9_relationship`;一件感情 → `d10_career`;事业 + 感情 → `relatives`;事业 + 感情 + 家人拒答 → `d5_education`(不是 occupation);事业 + 感情 + 家人拒答 + 学业拒答 → `d2_finance`;四件跨两域(训练门已开)→ 不进 dated 分支(沿用 `3847e9c9` 新测试)。
- `persistNextInterviewAfterChoice` 家人答「没有」后焦点 `collect:education:collect_method_evidence`(`rectification-collect-direction-20260904.test.ts` 既有用例不动)。
- 上述新增断言放进 `rectification-collect-direction-20260904.test.ts`。
### 5.2 回退 17 条断言(随 5.1 同一提交)
- 文件:`rectification-eight-method`、`rectification-walkthrough-polish`、`rectification-question-ownership`、`rectification-choice-card`、`rectification-v10-conversation-focus`。`rectification-range-offer-deadend`、`rectification-agentic-entry`、`rectification-spoken-prompt`、`rectification-v9-agent` 不动。
- `rectification-v10-conversation-focus` 必须恢复 `collect_retry: true` 与 `USER_COLLECT_QUESTION_RETRY.relationship` 两条断言。
- 验收:`git diff 3847e9c9 -- frontend/tests` 中每处改动都有三栏说明;测试总数 ≥ 2655。
### 5.3 门禁解堵(BUG-532,可能已被别的会话处理)
- 改 `frontend/tests/admin-model-management-ui-contract.test.ts`:正则接受 `padding: 0 var\(--space-3\);`(或与 `7ee7f825` 的实际值一致),三栏说明写明「`7ee7f825` 把间距收敛到 token」。
- 验收:`npx tsx --test tests/admin-model-management-ui-contract.test.ts` 全过;全量失败清单只剩无 Docker 基线。
- 进度记录同时写:这条阻塞是谁引入的(commit)、是否已在别处修。
### 5.4 记录
- `docs/BUG_HISTORY.md`:BUG-531(新案例第二问被家人抢占,关联 BUG-528,写明任务书顺序错位是根因)、BUG-532(`7ee7f825` CSS 与合同测试脱节;若别处已修则改为关联记录)。
- `docs/tasks/PROGRESS-rectification-collect-direction-fix-20260904.md`;`docs/tasks/README.md` 加一行;`CHANGELOG.md` 一句话。
## 6. 让步顺序
1. 5.1 + 5.2 是核心,不能让。
2. 5.3 若 Button 会话已修:跳过;若冲突:以先合入 staging 的为准,本单 rebase。
3. 三栏说明可以合并写在测试文件顶部一段(列明所有回退行),但每条断言旁至少留「见文件头」。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-collect-direction-fix-20260904 .worktrees/rectification-collect-direction-fix-20260904 origin/staging
ln -s /workspace/Jyotisha/frontend/node_modules .worktrees/rectification-collect-direction-fix-20260904/frontend/node_modules
grep -o "BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | sort -u | tail -1 # 核对起点
cd frontend && ./node_modules/.bin/tsc --noEmit && npm run lint
npx tsx --test tests/rectification-*.test.ts tests/admin-model-management-ui-contract.test.ts
npm test
```
## 8. BUG 编号起点
`origin/staging` 上最大号 BUG-530,本单从 **BUG-531** 起;开工时再核对一次,若被别的会话占用则顺延并在进度记录写明。
@@ -19,6 +19,7 @@ import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-deci
import { attachQuestionsToTurns, attachOfferResultToTurns } from "@/lib/rectification-agentic/v9/turn-question";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision";
import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/evidence-model";
import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection";
export const runtime = "nodejs";
@@ -140,7 +141,10 @@ export function dossierResponse(
latest_result: dossier.latestResult
? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true)
: null,
interview: fields,
interview: {
...fields,
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
},
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: choiceCardFromCaseDossier(dossier),
question_source: questionSourceFromFocusList(listed),
+5 -30
View File
@@ -24,6 +24,7 @@ import {
type PersonalReportService,
} from "@/lib/personal-report-service";
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
import { loadReportCandidateRange } from "@/lib/report-candidate-range";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
@@ -187,36 +188,10 @@ export async function POST(request: Request) {
const caseId = typeof profileRow.rectification_case_id === "string"
? profileRow.rectification_case_id
: null;
if (caseId) {
const { data, error } = await admin
.from("birth_time_rectification_cases")
.select("candidate_start,candidate_end")
.eq("id", caseId)
.eq("user_id", userId as string)
.in("status", ["confirmed", "completed"])
.maybeSingle();
if (error) throw error;
const row = data && typeof data === "object" ? data as Record<string, unknown> : null;
if (row && typeof row.candidate_start === "string" && typeof row.candidate_end === "string") {
return { startTime: row.candidate_start, endTime: row.candidate_end };
}
}
const { data, error } = await admin
.from("agentic_rectification_cases")
.select("candidate_range,updated_at")
.eq("user_id", userId as string)
.eq("status", "candidate_accepted")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
const row = data && typeof data === "object" ? data as Record<string, unknown> : null;
const range = row?.candidate_range && typeof row.candidate_range === "object"
? row.candidate_range as Record<string, unknown>
: null;
return range && typeof range.start_time === "string" && typeof range.end_time === "string"
? { startTime: range.start_time, endTime: range.end_time }
: null;
return loadReportCandidateRange(admin, {
userId: userId as string,
rectificationCaseId: caseId,
});
},
checkSessionOwned: async (sessionId) => {
const { data, error } = await supabase
+6 -33
View File
@@ -21,6 +21,7 @@ import {
resolveReportBirthClock,
resolveReportBirthTimeSensitivityInput,
} from "@/lib/personal-report-route-core";
import { loadReportCandidateRange } from "@/lib/report-candidate-range";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { completeUsage, releaseUsage } from "@/lib/consultation-billing";
import type { ConsultationInput } from "@/mastra/consultation-workflow";
@@ -256,39 +257,11 @@ function createProductionWorker(workerId: string) {
if (resolveReportBirthClock(profile)?.status === "confirmed") {
return generateProductionReport(context);
}
const caseId = text(profile.rectification_case_id);
if (caseId) {
const { data, error } = await admin
.from("birth_time_rectification_cases")
.select("candidate_start,candidate_end")
.eq("id", caseId)
.eq("user_id", context.report.userId)
.in("status", ["confirmed", "completed"])
.maybeSingle();
if (error) throw new PersonalReportWorkerError("calculation_unavailable", true);
const row = record(data);
if (typeof row?.candidate_start === "string" && typeof row.candidate_end === "string") {
return generateProductionReport(context, {
startTime: row.candidate_start,
endTime: row.candidate_end,
});
}
}
const { data, error } = await admin
.from("agentic_rectification_cases")
.select("candidate_range,updated_at")
.eq("user_id", context.report.userId)
.eq("status", "candidate_accepted")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw new PersonalReportWorkerError("calculation_unavailable", true);
const range = record(record(data)?.candidate_range);
return generateProductionReport(context, range
&& typeof range.start_time === "string"
&& typeof range.end_time === "string"
? { startTime: range.start_time, endTime: range.end_time }
: null);
const range = await loadReportCandidateRange(admin, {
userId: context.report.userId,
rectificationCaseId: text(profile.rectification_case_id),
});
return generateProductionReport(context, range);
},
});
}
@@ -27,6 +27,18 @@ export type CollectDomain =
export const GENERIC_COLLECT_QUESTION = "从你最容易想起来的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家,记得大概年份就行。";
/** Everyday words a collect spoken prompt must mention for its server domain. */
export const COLLECT_DOMAIN_KEYWORDS: Readonly<Record<string, readonly string[]>> = {
education: ["上学", "大学", "毕业", "考试", "学业"],
finance: ["收入", "买房", "贷款", "欠债", "钱"],
relocation: ["搬家", "住", "外地"],
health_pressure: ["生病", "受伤", "住院", "压力"],
family: ["家里", "父母", "家人", "添丁"],
career: ["工作", "入职", "换工作"],
relationship: ["交往", "分手", "结婚", "感情"],
occupation: ["工作", "做什么", "职业"],
};
export const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
relationship: "感情这边,还记得哪年认真在一起、分开,或结婚吗?有年份就很好。",
career: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
@@ -14,4 +14,6 @@ export const MACHINE_VOICE_LEXICON = [
"我没能确定这句话是否在回答当前问题",
"请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病",
"有没有记得住时间的收入变化、大笔支出或欠债",
"继续收窄",
"范围还在",
] as const;
@@ -310,6 +310,36 @@ export function meetsAcceptanceEventQuality(
return trainingScoreableGate(evidence).open;
}
export type CollectionProgress = Readonly<{
scoreable: number;
minimum: number;
missing: number;
}>;
export function collectionProgressFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): CollectionProgress | null {
const gates = receipt && typeof receipt.gates === "object" && receipt.gates && !Array.isArray(receipt.gates)
? receipt.gates as Record<string, unknown>
: null;
const quality = gates && typeof gates.event_quality === "object" && gates.event_quality && !Array.isArray(gates.event_quality)
? gates.event_quality as Record<string, unknown>
: null;
if (!quality) return null;
const scoreable = typeof quality.scoreable_event_count === "number" && Number.isFinite(quality.scoreable_event_count)
? quality.scoreable_event_count
: null;
const minimum = typeof quality.minimum === "number" && Number.isFinite(quality.minimum)
? quality.minimum
: null;
if (scoreable == null || minimum == null) return null;
return {
scoreable,
minimum,
missing: Math.max(0, minimum - scoreable),
};
}
/** Ledger/engine subject follows the domain. Family events must not stay on the tool default `self`. */
export function evidenceSubjectForDomain(
domain: string,
@@ -345,7 +345,7 @@ function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolea
return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health");
}
function declinedHealth(declined: Set<string>): boolean {
function declinedHealth(declined: ReadonlySet<string>): boolean {
return declined.has("health") || declined.has("health_pressure");
}
@@ -1001,10 +1001,12 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string |
return period ? `${period},${base}` : base;
}
const EXHAUSTION_OOS_COLLECT_ORDER = ["family", "education", "finance"] as const;
const EXHAUSTION_REMAINING_COLLECT_ORDER = [
"health_pressure",
export const DATED_COLLECT_ORDER = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
"career",
"relationship",
] as const;
@@ -1024,6 +1026,48 @@ function datedCollectFollowup(
user_prompt_hint: collectHint(lead, REVERSE_VERIFY_VARGA[domain], "", evidence),
must_not_label: false,
choice_frame: null,
source: "method_coverage",
};
}
function datedCollectDomainBlocked(
domain: (typeof DATED_COLLECT_ORDER)[number],
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
): boolean {
if (domain === "health_pressure") {
return declinedHealth(declined) || hasConfirmedHealth(evidence);
}
return declined.has(domain) || hasConfirmedDomain(evidence, domain);
}
export function nextDatedCollectFollowup(
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
): MethodFollowup | null {
for (const domain of DATED_COLLECT_ORDER) {
if (datedCollectDomainBlocked(domain, evidence, declined)) continue;
const next = datedCollectFollowup(domain, evidence);
if (next) return next;
}
return null;
}
function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "other",
kind_hint: null,
user_prompt_hint: collectHint(
USER_COLLECT_QUESTION.other,
"本命 Dasha + 行运(方法1)",
"",
evidence,
),
must_not_label: false,
choice_frame: null,
source: "oos_blind",
};
}
@@ -1033,11 +1077,8 @@ export function exhaustionSpokenCollectFollowup(input: {
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
}): MethodFollowup | null {
const declined = declinedDomains(input.declinedTopics ?? []);
for (const domain of EXHAUSTION_OOS_COLLECT_ORDER) {
if (declined.has(domain) || hasConfirmedDomain(input.evidence, domain)) continue;
const next = datedCollectFollowup(domain, input.evidence);
if (next) return next;
}
const dated = nextDatedCollectFollowup(input.evidence, declined);
if (dated) return dated;
if (
!declined.has("occupation")
&& !hasConfirmedDomain(input.evidence, "occupation")
@@ -1055,31 +1096,7 @@ export function exhaustionSpokenCollectFollowup(input: {
source: "method_coverage",
};
}
for (const domain of EXHAUSTION_REMAINING_COLLECT_ORDER) {
if (domain === "health_pressure") {
if (declinedHealth(declined) || hasConfirmedHealth(input.evidence)) continue;
} else if (declined.has(domain) || hasConfirmedDomain(input.evidence, domain)) {
continue;
}
const next = datedCollectFollowup(domain, input.evidence);
if (next) return next;
}
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "other",
kind_hint: null,
user_prompt_hint: collectHint(
USER_COLLECT_QUESTION.other,
"本命 Dasha + 行运(方法1)",
"",
input.evidence,
),
must_not_label: false,
choice_frame: null,
source: "oos_blind",
};
return otherCollectFollowup(input.evidence);
}
export const OPENING_COLLECT_DOMAIN = "other";
@@ -1410,6 +1427,22 @@ function holdoutAskFields(
};
}
export function holdoutFollowupFor(
input: {
evidence: readonly MethodFollowupEvidence[];
oosBlindPrompts?: readonly OosBlindPrompt[];
holdoutEvents?: readonly Readonly<{ domain: string; year: number | null }>[];
},
declined: ReadonlySet<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 reserved = (input.holdoutEvents ?? []).find((item) => (
item.year != null && !declined.has(item.domain)
)) ?? null;
return holdoutAskFields(prompt, reserved);
}
export function buildMethodFollowupPlan(input: {
evidence: readonly MethodFollowupEvidence[];
activeFocus?: MethodFollowupFocus | null;
@@ -1673,10 +1706,7 @@ export function buildMethodFollowupPlan(input: {
}
if (sessionOutcome === "validate_holdout") {
const fields = holdoutAskFields(
input.oosBlindPrompts?.[0],
(input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null,
);
const fields = holdoutFollowupFor(input, declined);
return {
methods,
next_followup: fields ? makeFollowup(fields, false) : null,
@@ -1795,10 +1825,7 @@ export function buildMethodFollowupPlan(input: {
}
}
if (!next && input.holdoutValidation === "not_started") {
const fields = holdoutAskFields(
input.oosBlindPrompts?.[0],
(input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null,
);
const fields = holdoutFollowupFor(input, declined);
if (fields) next = makeFollowup(fields, false);
}
if (!next && !dashaCovered) {
@@ -1880,6 +1907,7 @@ export function buildMethodFollowupPlan(input: {
const candidate = followupFromRanked(ranked);
return candidate.choice_frame ? candidate : null;
};
let datedCollect: MethodFollowup | null = null;
if (!next) {
if (!relationshipCovered && !declined.has("relationship")) {
next = sameDomainYearlessCard("relationship") ?? makeFollowup({
@@ -1925,6 +1953,11 @@ export function buildMethodFollowupPlan(input: {
source: "method_coverage",
...collectionYearFields(familyCollect),
});
} else if (
!meetsAcceptanceEventQuality(input.evidence)
&& (datedCollect = nextDatedCollectFollowup(input.evidence, declined))
) {
next = makeFollowup(datedCollect);
} else if (!occupationCovered) {
if (meetsAcceptanceEventQuality(input.evidence)) {
for (const ranked of yearlessDiscriminators) {
@@ -1965,19 +1998,7 @@ export function buildMethodFollowupPlan(input: {
&& !hasConfirmedDomain(input.evidence, "relocation")
&& !declined.has("relocation"))
) {
next = makeFollowup({
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: collect(
"再记一件记得大概时间的经历。当前用来区分候选的训练事件还不够。",
"本命 Dasha + 行运(方法1)",
"不要开始点选区分题。",
),
source: "method_coverage",
});
next = makeFollowup(otherCollectFollowup(input.evidence));
} else if (stage === "lagna_frame") {
next = makeFollowup({
method_id: "dasha_events",
@@ -1,6 +1,7 @@
import { parseAgentChoiceCopy } from "./choice-card";
import { persistableFocusDomain, stableFollowupQuestionId } from "./server-focus";
import { MACHINE_VOICE_LEXICON } from "./agent-voice-lexicon";
import { COLLECT_DOMAIN_KEYWORDS } from "../user-copy";
import type { MethodFollowup } from "./method-followup";
export const SPOKEN_PROMPT_MIN = 8;
@@ -58,6 +59,17 @@ export function validateSpokenPrompt(input: {
if (followupDomain && targetDomain && followupDomain !== targetDomain) {
return { ok: false, error: "invalid_spoken_prompt", reason: "domain_mismatch" };
}
if (
followup.intent === "collect_method_evidence"
&& !followup.choice_frame
&& followup.domain
&& COLLECT_DOMAIN_KEYWORDS[followup.domain]
) {
const keywords = COLLECT_DOMAIN_KEYWORDS[followup.domain];
if (!keywords.some((word) => prompt.includes(word))) {
return { ok: false, error: "invalid_spoken_prompt", reason: "domain_missing" };
}
}
if (followup.choice_frame && input.questionId && input.questionId !== stableFollowupQuestionId(followup)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "probe_mismatch" };
}
@@ -8,6 +8,7 @@
import { compactInferenceProjection, previousInferenceFromReceipt } from "./inference-adapter";
import { decideFromDossier } from "./decision-from-dossier";
import { evidenceLedgerFingerprint } from "./tool-service";
import { collectionProgressFromReceipt } from "./evidence-model";
import type { V9CaseDossier } from "./tool-service";
import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract";
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
@@ -221,6 +222,7 @@ export function projectTurnDecision(
?? null,
skill_version: RECTIFICATION_SKILL_VERSION,
},
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
};
return enforceTurnDecisionBudget(payload);
}
@@ -0,0 +1,76 @@
export const READ_REPORT_CANDIDATE_RANGE_RPC = "read_report_candidate_range";
export type ReportCandidateClockRange = Readonly<{
startTime: string;
endTime: string;
}>;
export type ReportCandidateRangeRpcClient = Readonly<{
rpc: (
fn: string,
args: Record<string, unknown>,
) => PromiseLike<{ data: unknown; error: unknown }>;
}>;
export type ReportCandidateRangeWarn = (payload: Readonly<{
event: "report_candidate_range_unavailable";
reason: string;
}>) => void;
const candidateClockPattern = /^((?:[01]\d|2[0-3]):[0-5]\d)(?::00(?:\.0+)?)?$/;
export function reportCandidateRangeErrorCode(error: unknown): string {
if (error && typeof error === "object" && "code" in error) {
const code = error.code;
if (typeof code === "string" && code.trim().length > 0) return code.trim();
}
if (error instanceof Error && error.name.trim().length > 0) return error.name;
return "unknown";
}
export function parseReportCandidateRange(value: unknown): ReportCandidateClockRange | null {
const row = value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
if (!row) return null;
const startTime = matchClock(row.start_time ?? row.startTime);
const endTime = matchClock(row.end_time ?? row.endTime);
if (!startTime || !endTime || startTime > endTime) return null;
return { startTime, endTime };
}
export async function loadReportCandidateRange(
client: ReportCandidateRangeRpcClient,
input: Readonly<{ userId: string; rectificationCaseId?: string | null }>,
warn: ReportCandidateRangeWarn = defaultWarn,
): Promise<ReportCandidateClockRange | null> {
try {
const result = await client.rpc(READ_REPORT_CANDIDATE_RANGE_RPC, {
p_user_id: input.userId,
p_rectification_case_id: input.rectificationCaseId ?? null,
});
if (result.error) {
warn({
event: "report_candidate_range_unavailable",
reason: reportCandidateRangeErrorCode(result.error),
});
return null;
}
return parseReportCandidateRange(result.data);
} catch (error) {
warn({
event: "report_candidate_range_unavailable",
reason: reportCandidateRangeErrorCode(error),
});
return null;
}
}
function matchClock(value: unknown): string | null {
if (typeof value !== "string") return null;
return value.trim().match(candidateClockPattern)?.[1] ?? null;
}
function defaultWarn(payload: Readonly<{ event: string; reason: string }>): void {
console.warn(JSON.stringify(payload));
}
+2 -2
View File
@@ -59,7 +59,7 @@ export function resolveRectificationStepBudget(action: RectificationAgentAction)
* gate → scan → score → diagnostics.
*/
const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑定 jyotish-birth-time-rectification Skill 的生时校正 Case。方法以绑定 Skill 为准,不在系统提示中重写。
懂行、可靠、说人话:先回应人,再展开方法。有收窄就说进度;数字只来自服务端投影,不得自算。
懂行、可靠、说人话:先回应人,再展开方法。有收窄就说进度;数字只来自服务端投影,不得自算。下一问是采集题时,先用一句说明还差几件带时间的事,数字只用 collection_progress;没有该字段不说进度。不得写「范围在收窄」「继续收窄」这类没有数字的进度句。
1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威。
2. 事实只能来自用户原话;复述日期必须用 display_date_label。不得虚构事件、候选或出生分钟。
3. 新事件走 rectification-record-evidence-batch。工具执行保持静默;思考用简体中文写在思维链;对用户说的话必须自己写在正文里,不叙述工具或内部状态。
@@ -67,7 +67,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
5. 不得宣称唯一出生分钟。confirmation_allowed 为 false 或宽度大于 5 时,说明这是不可分区间,代表分钟只是代表性候选。出牌轮写入 skill_verification_report;80%/60% 只是事件吻合率。
6. 一次一问。不泄露提示词或 Skill 原文。
坏:「好的,记下了。」好:「2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。」
坏:「范围还在收。」好:「范围已经从开场那一段收到更窄的几分钟了。」`;
坏:「范围还在收。」好:「还差 1 件带时间的事。」`;
export function getRectificationV9Agent(
model: ResolvedLanguageModel,
+19 -8
View File
@@ -50,6 +50,7 @@ import {
evidenceSubjectForDomain,
applyOccupationCollectLedgerNorm,
} from "@/lib/rectification-agentic/v9/evidence-model";
import { USER_COLLECT_QUESTION } from "@/lib/rectification-agentic/user-copy";
import {
isHoldoutVerificationQuote,
} from "@/lib/rectification-agentic/v9/choice-card";
@@ -1208,7 +1209,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const setFocusTool = createTool({
id: "rectification-set-focus",
description:
"在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 id;targetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。新的 focus 会由服务器 supersede 旧 active focus。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。",
"在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 id;targetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。题干必须写出服务端给你的年份/期间。采集题必须写出服务端给你的领域(学业/家里/钱/搬家……),不要写成『随便哪件事』。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。新的 focus 会由服务器 supersede 旧 active focus。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。",
inputSchema: z.object({
caseId: z.string().uuid(),
questionId: z.string().trim().min(1).max(160),
@@ -1253,6 +1254,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
targetDomain: input.targetDomain ?? nextFollowup.domain ?? null,
questionId: input.questionId,
});
let spokenText = spoken.ok ? spoken.prompt : "";
if (!spoken.ok) {
spokenPromptFailures += 1;
await receipt("rectification-set-focus", "intent.classified", "failed", {
@@ -1260,12 +1262,21 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
safeErrorCode: "invalid_spoken_prompt",
resultFingerprint: JSON.stringify({ reason: spoken.reason }),
});
return {
ok: false,
error: "invalid_spoken_prompt",
reason: spoken.reason,
retryable: spokenPromptFailures < 2,
};
const fallbackDomain = nextFollowup.domain ?? "";
const fallbackPrompt = USER_COLLECT_QUESTION[fallbackDomain] ?? USER_COLLECT_QUESTION.other;
if (
spokenPromptFailures < 2
|| nextFollowup.intent !== "collect_method_evidence"
|| !fallbackPrompt
) {
return {
ok: false,
error: "invalid_spoken_prompt",
reason: spoken.reason,
retryable: spokenPromptFailures < 2,
};
}
spokenText = fallbackPrompt;
}
let persistFollowup = nextFollowup;
let expectedAnswerSchema = serverOwnedExpectedAnswerSchema(persistFollowup, decisionReceipt);
@@ -1276,7 +1287,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
if (!expectedAnswerSchema) {
expectedAnswerSchema = { [COLLECT_FOCUS_SCHEMA_KEY]: true };
}
expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spoken.prompt);
expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spokenText);
await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint });
try {
const result = await setV10ConversationFocus(accounting, userId, input.caseId, {
@@ -0,0 +1,77 @@
-- Read-only candidate window for personal reports. Direct table grants on
-- agentic_rectification_cases stay revoked; this RPC is the only service_role
-- path. Failure to find a row returns null. The function never returns any
-- column except start_time / end_time.
begin;
do $migration$
begin
if current_user <> 'schema_owner' then
raise exception 'read_report_candidate_range_requires_schema_owner'
using errcode = '42501';
end if;
end
$migration$;
create or replace function public.read_report_candidate_range(
p_user_id uuid,
p_rectification_case_id uuid default null
)
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
v_start text;
v_end text;
v_range jsonb;
begin
if p_user_id is null then
return null;
end if;
if p_rectification_case_id is not null then
select to_char(c.candidate_start, 'HH24:MI'), to_char(c.candidate_end, 'HH24:MI')
into v_start, v_end
from public.birth_time_rectification_cases as c
where c.id = p_rectification_case_id
and c.user_id = p_user_id
and c.status in ('confirmed', 'completed')
and c.candidate_start is not null
and c.candidate_end is not null;
if found then
return jsonb_build_object('start_time', v_start, 'end_time', v_end);
end if;
end if;
select c.candidate_range
into v_range
from public.agentic_rectification_cases as c
where c.user_id = p_user_id
and c.status = 'candidate_accepted'
order by c.updated_at desc
limit 1;
if v_range is null then
return null;
end if;
v_start := nullif(btrim(coalesce(v_range->>'start_time', '')), '');
v_end := nullif(btrim(coalesce(v_range->>'end_time', '')), '');
if v_start is null or v_end is null then
return null;
end if;
return jsonb_build_object('start_time', v_start, 'end_time', v_end);
end;
$$;
revoke all on function public.read_report_candidate_range(uuid, uuid)
from public, anon, authenticated;
grant execute on function public.read_report_candidate_range(uuid, uuid)
to service_role;
commit;
@@ -115,9 +115,12 @@ test("model management renders localized labels without changing stored enum val
});
test("global native input sizing excludes Ant Design picker internals", () => {
// 原值: padding: 0 12px
// 新值: padding: 0 var(--space-3)
// 原因: 7ee7f825 把间距收敛到 token;合同测试跟随 CSS,不改业务代码。
assert.match(
globals,
/input:not\(\[type="radio"\]\):not\(\[type="checkbox"\]\):not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\), select[^\{]+\{ width: 100%; min-height: 44px; padding: 0 12px;/,
/input:not\(\[type="radio"\]\):not\(\[type="checkbox"\]\):not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\), select[^\{]+\{ width: 100%; min-height: 44px; padding: 0 var\(--space-3\);/,
);
assert.match(
globals,
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url),
);
function dockerAvailable(): boolean {
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
encoding: "utf8",
stdio: "ignore",
}).status === 0;
}
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
const ids = {
user: "a1111111-1111-4111-8111-111111111111",
otherUser: "a2222222-2222-4222-8222-222222222222",
session: "b1111111-1111-4111-8111-111111111111",
otherSession: "b2222222-2222-4222-8222-222222222222",
caseId: "c1111111-1111-4111-8111-111111111111",
otherCase: "c2222222-2222-4222-8222-222222222222",
legacyCase: "d1111111-1111-4111-8111-111111111111",
};
function sqlLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
test("read_report_candidate_range is service_role-only, returns only the window, and stays empty without rows", { skip: skipWithoutDocker }, () => {
const fixture = startPostgresFixture();
const migrate = () =>
spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl(
"schema_owner",
"schema-owner-test-password",
),
},
});
const serviceSql = (statement: string) =>
fixture
.psqlAs(
"service_runtime",
"service-runtime-test-password",
`set role service_role; ${statement}`,
)
.replace(/^SET\n/, "");
try {
const first = migrate();
assert.equal(first.status, 0, `${first.stdout}${first.stderr}`);
assert.match(first.stdout, /applied 20260904010000_read_report_candidate_range\.sql/);
const second = migrate();
assert.equal(second.status, 0, `${second.stdout}${second.stderr}`);
assert.match(second.stdout, /already applied 20260904010000_read_report_candidate_range\.sql/);
assert.equal(
fixture.psql(`
select concat_ws(':',
has_table_privilege('service_role', 'public.agentic_rectification_cases', 'SELECT'),
has_table_privilege('service_role', 'public.agentic_rectification_cases', 'INSERT'),
has_table_privilege('service_role', 'public.agentic_rectification_cases', 'UPDATE'),
has_table_privilege('service_role', 'public.agentic_rectification_cases', 'DELETE'),
has_function_privilege('service_role', 'public.read_report_candidate_range(uuid, uuid)', 'EXECUTE'),
has_function_privilege('anon', 'public.read_report_candidate_range(uuid, uuid)', 'EXECUTE'),
has_function_privilege('authenticated', 'public.read_report_candidate_range(uuid, uuid)', 'EXECUTE')
)
`),
"f:f:f:f:t:f:f",
);
const catalog = fixture.psql(`
select concat_ws(':',
pg_catalog.pg_get_userbyid(p.proowner),
p.prosecdef::text,
coalesce(array_to_string(p.proconfig, ','), ''),
p.provolatile::text
)
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'read_report_candidate_range'
`);
assert.match(catalog, /^schema_owner:true:search_path="":s$/);
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
`insert into identity.users (id, name, email, email_verified, email_verified_at)
values (${sqlLiteral(ids.user)}, 'Range User', 'range-user@example.com', true, now()),
(${sqlLiteral(ids.otherUser)}, 'Other User', 'range-other@example.com', true, now());`,
);
assert.equal(
serviceSql(`select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid) is null`),
"t",
);
fixture.psql(`
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages)
values (
${sqlLiteral(ids.session)}, ${sqlLiteral(ids.user)}, '生时校正', 'general',
'birth_time_rectification', '[]'::jsonb
), (
${sqlLiteral(ids.otherSession)}, ${sqlLiteral(ids.otherUser)}, '生时校正', 'general',
'birth_time_rectification', '[]'::jsonb
);
insert into public.agentic_rectification_cases (
id, user_id, session_id, status, skill_name, skill_version,
skill_sha256, skill_source_commit, baseline_profile_fingerprint,
baseline_birth_snapshot, candidate_range
) values (
${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, ${sqlLiteral(ids.session)},
'candidate_accepted', 'jyotish-birth-time-rectification', '9.0.0',
${sqlLiteral("a".repeat(64))}, ${sqlLiteral("0".repeat(40))}, ${sqlLiteral("a".repeat(64))},
'{"birth_date":"1997-08-08"}'::jsonb,
'{"start_time":"10:00","end_time":"10:04","secret":"must-not-leak"}'::jsonb
), (
${sqlLiteral(ids.otherCase)}, ${sqlLiteral(ids.otherUser)}, ${sqlLiteral(ids.otherSession)},
'candidate_accepted', 'jyotish-birth-time-rectification', '9.0.0',
${sqlLiteral("b".repeat(64))}, ${sqlLiteral("1".repeat(40))}, ${sqlLiteral("b".repeat(64))},
'{"birth_date":"1997-08-08"}'::jsonb,
'{"start_time":"11:00","end_time":"11:20"}'::jsonb
);
`);
assert.throws(
() => serviceSql(`select candidate_range from public.agentic_rectification_cases limit 1`),
/permission denied for table agentic_rectification_cases/,
);
assert.equal(
serviceSql(
`select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid) = '{"start_time":"10:00","end_time":"10:04"}'::jsonb`,
),
"t",
);
const keys = serviceSql(`
select array_to_string(array(
select jsonb_object_keys(public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid))
order by 1
), ',')
`);
assert.equal(keys, "end_time,start_time");
assert.equal(
serviceSql(
`select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid) ? 'secret'`,
),
"f",
);
try {
fixture.psql(`
insert into public.birth_time_rectification_cases (
id, user_id, status, reported_date, source, candidate_start, candidate_end
) values (
${sqlLiteral(ids.legacyCase)}, ${sqlLiteral(ids.user)}, 'confirmed',
'1997-08-08', 'family_exact', '08:16', '08:20'
);
`);
assert.equal(
serviceSql(
`select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid, ${sqlLiteral(ids.legacyCase)}::uuid) = '{"start_time":"08:16","end_time":"08:20"}'::jsonb`,
),
"t",
);
} catch (error) {
assert.ok(
false,
`legacy birth_time_rectification_cases seed failed: ${error instanceof Error ? error.message : error}`,
);
}
} finally {
fixture.stop();
}
});
+20 -5
View File
@@ -514,6 +514,22 @@ test("core create: accepted single-minute range becomes confirmed without a cand
assert.equal("candidate_range" in inputs[0], false);
});
test("core create: a null candidate window degrades instead of calculation_unavailable", async () => {
const inputs: Record<string, unknown>[] = [];
const response = await resolveReportCreate(baseDeps({
profile: profileFixture({ birth_time_status: "accepted" }),
loadCandidateRange: async () => null,
runWorkflow: async (input) => {
inputs.push(input as unknown as Record<string, unknown>);
return chartPayload();
},
}));
assert.equal(response.status, 201);
assert.equal(response.body.code, undefined);
assert.equal(inputs[0].birth_time_accuracy, "provisional");
assert.equal("candidate_range" in inputs[0], false);
});
test("core create: no adopted range uses the fixed accuracy fallback", async () => {
const inputs: Record<string, unknown>[] = [];
const response = await resolveReportCreate(baseDeps({
@@ -1205,11 +1221,10 @@ test("POST route uses dual clients: authenticated reads + admin persistence", ()
assert.doesNotMatch(createRoute, /not wired yet|尚未就绪/);
});
test("POST route only trusts terminal legacy rectification ranges", () => {
assert.match(
createRoute,
/from\("birth_time_rectification_cases"\)[\s\S]*?\.in\("status", \["confirmed", "completed"\]\)/,
);
test("POST route loads candidate windows through the shared RPC helper", () => {
assert.match(createRoute, /loadReportCandidateRange/);
assert.doesNotMatch(createRoute, /from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(createRoute, /from\("birth_time_rectification_cases"\)/);
});
test("GET/DELETE use the authenticated client (least privilege) and the core handlers", () => {
@@ -671,9 +671,8 @@ test("instrumentation retains the Skill guard and starts a singleton Node worker
);
assert.match(productionAdapter, /jyotishaPersonalReportWorker/);
assert.match(productionAdapter, /if \(state\.jyotishaPersonalReportWorker\) return/);
assert.match(
productionAdapter,
/from\("birth_time_rectification_cases"\)[\s\S]*?\.in\("status", \["confirmed", "completed"\]\)/,
);
assert.match(productionAdapter, /loadReportCandidateRange/);
assert.doesNotMatch(productionAdapter, /from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(productionAdapter, /from\("birth_time_rectification_cases"\)/);
assert.doesNotMatch(productionAdapter, /after\s*\(/);
});
@@ -598,7 +598,11 @@ test("time-selection cards use server adoption state and stay mutually exclusive
assert.match(caseRoute, /current_question: projectCurrentQuestion/);
assert.match(caseRoute, /overlayPublicDecision/);
assert.match(caseRoute, /const fields = publicDecisionFields\(decision\)/);
assert.match(caseRoute, /interview: fields/);
// 原值: interview: fields
// 新值: interview: { ...fields, collection_progress }
// 原因: GET 投影带上采集进度数字,没有 gate 时为 null。
assert.match(caseRoute, /interview: \{\s*\.\.\.fields,/);
assert.match(caseRoute, /collection_progress: collectionProgressFromReceipt/);
assert.doesNotMatch(chat, /已完成验证/);
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
});
@@ -605,6 +605,9 @@ test("GET hides a legacy known-event quality focus and resumes evidence collecti
expectedAnswerSchema: { choice: SAMPLE_COPY },
},
});
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
});
@@ -0,0 +1,447 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
collectionProgressFromReceipt,
meetsAcceptanceEventQuality,
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import {
buildMethodFollowupPlan,
exhaustionSpokenCollectFollowup,
holdoutFollowupFor,
nextDatedCollectFollowup,
spokenFollowupForUser,
type MethodFollowupEvidence,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
type ExecutableTool<T = unknown> = {
execute(input: unknown): Promise<T>;
};
const OOS_PROMPTS = [
{
domain: "family",
user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?",
used_for_scoring: false as const,
},
{
domain: "education",
user_meaning: "学业这条线还没单独核对。有没有记得大概时间的升学或考试?",
used_for_scoring: false as const,
},
{
domain: "finance",
user_meaning: "钱的方面还没单独核对。有没有记得大概时间的收入变化?",
used_for_scoring: false as const,
},
];
function dated(
domain: string,
year: string,
extra: { id?: string; eventKind?: string } = {},
): MethodFollowupEvidence & { id: string; eventKind?: string } {
const yearless = extra.eventKind === "occupation_note";
return {
id: extra.id ?? `e-${domain}-${year}`,
status: "confirmed",
domain,
datePrecision: yearless ? "unknown" : "year",
occurredFrom: yearless ? null : `${year}-03-01`,
occurredTo: null,
eventKind: extra.eventKind,
};
}
const TWO_SCOREABLE = [
dated("career", "2011", { eventKind: "career_entry" }),
dated("relationship", "2014", { eventKind: "relationship_start" }),
] as const;
const FOUR_SCOREABLE = [
...TWO_SCOREABLE,
dated("education", "2008", { eventKind: "education_start" }),
dated("finance", "2017", { eventKind: "income_change" }),
] as const;
const FAMILY_DECLINED = [{
target_domain: "family",
status: "declined",
intent: "collect_method_evidence",
}];
function collectPlan(
evidence: readonly MethodFollowupEvidence[],
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
) {
return buildMethodFollowupPlan({
evidence,
declinedTopics: FAMILY_DECLINED,
holdoutValidation: "not_started",
oosBlindPrompts: OOS_PROMPTS,
...extra,
});
}
test("method rotation asks relationship and career before dated collect", () => {
const careerOnly = buildMethodFollowupPlan({
evidence: [dated("career", "2011", { eventKind: "career_entry" })],
holdoutValidation: "not_started",
});
assert.equal(careerOnly.next_followup?.method_id, "d9_relationship");
assert.equal(careerOnly.next_followup?.domain, "relationship");
const relationshipOnly = buildMethodFollowupPlan({
evidence: [dated("relationship", "2014", { eventKind: "relationship_start" })],
holdoutValidation: "not_started",
});
assert.equal(relationshipOnly.next_followup?.method_id, "d10_career");
assert.equal(relationshipOnly.next_followup?.domain, "career");
const both = buildMethodFollowupPlan({
evidence: TWO_SCOREABLE,
holdoutValidation: "not_started",
});
assert.equal(both.next_followup?.method_id, "relatives");
assert.equal(both.next_followup?.domain, "family");
const familyDeclined = collectPlan(TWO_SCOREABLE);
assert.equal(familyDeclined.next_followup?.method_id, "d5_education");
assert.equal(familyDeclined.next_followup?.domain, "education");
assert.notEqual(familyDeclined.next_followup?.domain, "occupation");
const educationDeclined = collectPlan(TWO_SCOREABLE, {
declinedTopics: [
...FAMILY_DECLINED,
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
],
});
assert.equal(educationDeclined.next_followup?.method_id, "d2_finance");
assert.equal(educationDeclined.next_followup?.domain, "finance");
});
test("two scoreable events do not open OOS even when holdout prompts exist", () => {
assert.equal(meetsAcceptanceEventQuality(TWO_SCOREABLE), false);
const declined = new Set(["family"]);
assert.equal(holdoutFollowupFor({
evidence: TWO_SCOREABLE,
oosBlindPrompts: OOS_PROMPTS,
}, declined), null);
const plan = collectPlan(TWO_SCOREABLE);
assert.notEqual(plan.next_followup?.intent, "out_of_sample_check");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "education");
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /上学|升学/);
});
test("four scoreable events skip declined OOS domain and ask education holdout", () => {
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
const plan = collectPlan(FOUR_SCOREABLE, {
candidatesSeparated: true,
eventProbes: [],
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
});
assert.equal(plan.next_followup?.intent, "out_of_sample_check");
assert.equal(plan.next_followup?.source, "oos_blind");
assert.equal(plan.next_followup?.domain, "education");
});
test("validate_holdout with every OOS domain declined and no dated holdout asks nothing", () => {
const declinedAll = [
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
];
const plan = collectPlan(FOUR_SCOREABLE, {
sessionOutcome: "validate_holdout",
declinedTopics: declinedAll,
holdoutEvents: [],
candidatesSeparated: true,
eventProbes: [],
});
assert.equal(plan.next_followup, null);
});
test("dated collect order walks remaining year-bearing domains then occupation", () => {
const declined = new Set(["family"]);
assert.equal(nextDatedCollectFollowup(TWO_SCOREABLE, declined)?.domain, "education");
const educationClosed = collectPlan(TWO_SCOREABLE, {
declinedTopics: [
...FAMILY_DECLINED,
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
],
});
assert.equal(educationClosed.next_followup?.domain, "finance");
const remainingDeclined = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
].map((domain) => ({
target_domain: domain,
status: "declined",
intent: "collect_method_evidence",
}));
const occupation = collectPlan(TWO_SCOREABLE, { declinedTopics: remainingDeclined });
assert.equal(occupation.next_followup?.domain, "occupation");
const occupationClosed = collectPlan(TWO_SCOREABLE, {
declinedTopics: [
...remainingDeclined,
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
],
closedCollectFocuses: [{
questionId: "collect:occupation:collect_method_evidence",
target_domain: "occupation",
status: "declined",
intent: "collect_method_evidence",
}],
});
assert.equal(occupationClosed.next_followup?.domain, "other");
assert.equal(
exhaustionSpokenCollectFollowup({
evidence: TWO_SCOREABLE,
declinedTopics: remainingDeclined,
})?.domain,
"occupation",
);
});
test("persistNextInterviewAfterChoice after family denial asks education, not occupation", async () => {
const source = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
const persistFn = source.slice(
source.indexOf("export async function persistNextInterviewAfterChoice"),
source.indexOf("async function persistFocusAfterChoice"),
);
assert.doesNotMatch(persistFn, /decideFromDossier\(/);
const dossier = {
evidence: TWO_SCOREABLE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: FAMILY_DECLINED,
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
candidates: [
{ time: "04:52", relativeSupport: 40, rank: 1 },
{ time: "05:04", relativeSupport: 38, rank: 2 },
],
representativeTime: "04:52",
decisionReceipt: {
oos_blind_prompts: OOS_PROMPTS,
gates: {
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
},
},
},
case: { acceptedTime: null },
};
const accounting = fakeAccounting({
...receiptHandlers,
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-04T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
});
const persisted = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: null,
askedTurnId: TURN_ID,
nextAction: {
type: "ask_fact_collection",
session_outcome: "collect_evidence",
completion_status: null,
validated: false,
can_offer_range: false,
can_adopt: false,
can_confirm_exact_minute: false,
selection_allowed: false,
propose_allowed: false,
precision_stage: "collect_events",
representative_time: null,
credible_range: null,
stop_reason: null,
},
});
assert.equal(persisted.persisted, true);
assert.equal(persisted.focus?.targetDomain, "education");
assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.education);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus?.args.p_target_domain, "education");
assert.equal(
(setFocus?.args.p_expected_answer_schema as { prompt?: string } | undefined)?.prompt,
USER_COLLECT_QUESTION.education,
);
});
test("four scoreable events do not take the dated-collect branch", () => {
const plan = collectPlan(FOUR_SCOREABLE, {
holdoutValidation: "passed",
oosBlindPrompts: [],
candidatesSeparated: true,
});
assert.notEqual(plan.next_followup?.domain, "education");
assert.equal(plan.next_followup?.domain, "occupation");
});
test("set-focus twice with a domainless collect prompt falls back to USER_COLLECT_QUESTION.education", async () => {
const raw = dossierFixture({
evidence: TWO_SCOREABLE.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? "event",
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: `${item.occurredFrom} ${item.eventKind ?? "event"}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-04T00:00:00.000Z",
})),
latestResult: candidateSnapshotFixture({
decisionReceipt: {
oos_blind_prompts: OOS_PROMPTS,
gates: {
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
},
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: FAMILY_DECLINED,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-04T00:00:00.000Z",
},
});
const store: { prompt: string | null } = { prompt: null };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
const schema = args.p_expected_answer_schema as { prompt?: string };
store.prompt = typeof schema?.prompt === "string" ? schema.prompt : null;
return {
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-04T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
};
},
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const generic = {
caseId: CASE_ID,
questionId: "collect:education:collect_method_evidence",
intent: "collect_method_evidence",
spokenPrompt: "除了工作,还有哪件事记得年份?",
targetDomain: "education",
};
const first = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute(generic);
assert.equal((first as { error?: string }).error, "invalid_spoken_prompt");
assert.equal((first as { reason?: string }).reason, "domain_missing");
const second = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute(generic);
assert.equal((second as { error?: string }).error, undefined);
assert.equal((second as { target_domain?: string }).target_domain, "education");
assert.equal(
((second as { expected_answer_schema?: { prompt?: string } }).expected_answer_schema)?.prompt,
USER_COLLECT_QUESTION.education,
);
assert.equal(store.prompt, USER_COLLECT_QUESTION.education);
});
test("turn decision and GET interview expose collection_progress 2/3/1 or null", () => {
const withGate = parseV9CaseDossier(dossierFixture({
latestResult: candidateSnapshotFixture({
decisionReceipt: {
gates: {
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
},
},
}),
}));
assert.ok(withGate);
assert.deepEqual(projectTurnDecision(withGate).collection_progress, {
scoreable: 2,
minimum: 3,
missing: 1,
});
assert.deepEqual(collectionProgressFromReceipt({
gates: { event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 } },
}), { scoreable: 2, minimum: 3, missing: 1 });
const withoutGate = parseV9CaseDossier(dossierFixture({
latestResult: candidateSnapshotFixture({ decisionReceipt: {} }),
}));
assert.ok(withoutGate);
assert.equal(projectTurnDecision(withoutGate).collection_progress, null);
const route = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url),
"utf8",
);
assert.match(route, /collection_progress: collectionProgressFromReceipt/);
const tools = readFileSync(
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
"utf8",
);
assert.match(tools, /采集题必须写出服务端给你的领域/);
});
@@ -54,6 +54,8 @@ const FAMILY_ID = "44444444-4444-4444-8444-444444444443";
const CAREER_ID = "44444444-4444-4444-8444-444444444444";
const UNIQUE_MINUTE_COPY = /±5 分钟确定性/;
// 本单回退 3847e9c9 把训练门未开的下一问改成家人的断言。见各断言旁三栏。
function collectDecision(candidates: readonly { time: string; relativeSupport?: number }[]) {
return decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
@@ -375,6 +377,9 @@ test("eight-method routing asks relationship after dated education, not relocati
occurredTo: null,
}],
});
// 原值: relatives / family
// 新值: d9_relationship / relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.domain, "relationship");
assert.equal(plan.stop_domain_rotation, true);
@@ -387,6 +392,9 @@ test("dasha conflict probe does not jump ahead of method rotation before accepta
evidence: [datedEvidence("education", "2016")],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
@@ -398,6 +406,9 @@ test("known exam quality does not create a scoring card after one recorded event
evidence: [datedEvidence("education", "2016")],
eventProbes: [EDUCATION_QUALITY_PROBE, CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
@@ -412,6 +423,9 @@ test("career known-event quality does not jump the adoption gate", () => {
],
eventProbes: [CAREER_QUALITY_PROBE, CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
@@ -424,6 +438,9 @@ test("encoded exam quality does not stamp another card and keeps method rotation
evidence: [datedEvidence("education", "2016", { summary: "2016年高考发挥异常" })],
eventProbes: [EDUCATION_QUALITY_PROBE, CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
@@ -489,6 +506,9 @@ test("three scoreable events in one domain still rotate methods instead of rever
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
@@ -510,6 +530,9 @@ test("occupation_note does not count toward reverse-inference event quality", ()
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
@@ -537,6 +560,9 @@ test("age-band probe does not jump ahead of uncovered relationship", () => {
role: "reverse_verify",
}],
});
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.choice_frame, null);
@@ -594,6 +620,9 @@ test("user-stop action offers a range when candidates already exist", () => {
assert.equal(plan.methods.find((item) => item.method_id === "marks")?.status, "skipped_by_policy");
assert.equal(plan.methods.find((item) => item.method_id === "horary")?.status, "uncovered");
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.match(plan.next_followup?.user_prompt_hint ?? "", /自然语言/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/);
@@ -620,6 +649,9 @@ test("selectionAllowed with remaining method follow-up keeps collecting and offe
nextFollowup: plan.next_followup,
workingTime: "05:07",
});
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(action.id, "ask_method_followup");
assert.equal(action.on_user_stop.id, "offer_provisional_range");
@@ -637,6 +669,9 @@ test("adopt_representative defers method follow-up instead of asking this turn",
sessionOutcome: "adopt_representative",
});
assert.equal(plan.next_followup, null);
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.deferred_followup?.method_id, "d9_relationship");
assert.equal(plan.session_outcome, "adopt_representative");
});
@@ -666,6 +701,9 @@ test("declined relationship skips to career and leaves horary uncovered", () =>
}],
declinedTopics: [{ target_domain: "relationship", status: "declined" }],
});
// 原值: relatives / family
// 新值: d10_career / career
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.equal(plan.next_followup?.domain, "career");
assert.deepEqual([...plan.do_not_poll], []);
@@ -902,6 +940,9 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin
}>;
}).execute({ caseId: CASE_ID, projection: "full_diagnostics" });
assert.deepEqual(projection.conversation_summary.missing_evidence_categories, ["relocation", "health", "finance"]);
// 原值: d2_finance / finance
// 新值: d9_relationship / relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(projection.method_followup_plan.next_followup?.method_id, "d9_relationship");
assert.equal(projection.method_followup_plan.next_followup?.domain, "relationship");
assert.equal(projection.method_followup_plan.deferred_followup, null);
@@ -1587,6 +1628,9 @@ test("precision stage lagna_frame waits for uncovered career before asking anoth
],
precisionStage: "lagna_frame",
});
// 原值: relatives
// 新值: d10_career
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true);
@@ -2684,8 +2728,12 @@ test("three dated events with one holdout keep collecting instead of discriminat
precisionStage: "lagna_frame",
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / finance(3847e9c9 新增 domain 锁)
// 新值: method_coverage / finance
// 原因: dated 分支在家人之后;家人已拒答,下一件带年份仍是财务,不是轮转抢跑。
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "finance");
assert.notEqual(plan.next_followup?.source, "event_probe");
assert.notEqual(plan.next_followup, null);
assert.equal(conversationalSessionOutcome({
@@ -2754,6 +2802,9 @@ test("adjacent education year does not re-ask enrollment after a recorded start"
});
assert.notEqual(plan.next_followup?.domain, "education");
assert.notEqual(plan.next_followup?.source, "event_probe");
// 原值: relatives
// 新值: d10_career
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.match(plan.next_followup?.user_prompt_hint ?? "", /2016 年入学/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /2024 年感情结束/);
@@ -18,6 +18,7 @@ import {
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
attachQuestionsToTurns,
parseTurnQuestion,
@@ -401,7 +402,10 @@ test("collect set-focus writes collect:true even if Agent sends choice", async (
caseId: CASE_ID,
questionId: followup ? stableFollowupQuestionId(followup) : "collect:relationship:collect_method_evidence",
intent: "collect_method_evidence",
spokenPrompt: "你还记得哪年上的大学吗?",
// 原值: USER_COLLECT_QUESTION.family(3847e9c9;45bdb63e 为「哪年上的大学」)
// 新值: USER_COLLECT_QUESTION[followup.domain](默认 dossier 一件事业 → 感情)
// 原因: dated 分支移到家人之后,前两问恢复感情;BUG-529 仍要求题干命中领域词,大学不命中感情。
spokenPrompt: USER_COLLECT_QUESTION[followup?.domain ?? "relationship"] ?? USER_COLLECT_QUESTION.relationship,
expectedAnswerSchema: {
choice: {
prompt: "伪造采集",
@@ -614,7 +614,7 @@ function datedCollectEvidence(domain: string, year: string, extra: { eventKind?:
};
}
test("exhaustion after family declined and dated domains confirmed asks occupation", () => {
test("exhaustion after family declined still fills remaining dated domains before occupation", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
@@ -624,9 +624,12 @@ test("exhaustion after family declined and dated domains confirmed asks occupati
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
assert.equal(next?.domain, "occupation");
// 原值: occupation / USER_COLLECT_QUESTION.occupation
// 新值: relocation / USER_COLLECT_QUESTION.relocation
// 原因: datedCollectOrder 把搬家、健康放在职业之前,穷尽路径与计划主流程共用。
assert.equal(next?.domain, "relocation");
assert.equal(next?.choice_frame, null);
assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.occupation);
assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.relocation);
});
test("exhaustion generic fallback uses domain other, not unknown", () => {
@@ -89,6 +89,43 @@ test("spokenPrompt rejects a different year or domain than the server probe", ()
}).ok, true);
});
test("collect spokenPrompt must name the server domain; distinguish and OOS stay year-only", () => {
const educationCollect: MethodFollowup = {
method_id: "d5_education",
intent: "collect_method_evidence",
ask_theme: "education_style",
domain: "education",
kind_hint: "education_milestone",
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
};
const missing = validateSpokenPrompt({
spokenPrompt: "除了工作,还有哪件事记得年份?",
followup: educationCollect,
targetDomain: "education",
});
assert.equal(missing.ok, false);
assert.equal(missing.ok ? null : missing.reason, "domain_missing");
assert.equal(validateSpokenPrompt({
spokenPrompt: "上学那会儿,哪年升学或大考还记得吗?",
followup: educationCollect,
targetDomain: "education",
}).ok, true);
const oos: MethodFollowup = {
...educationCollect,
intent: "out_of_sample_check",
source: "oos_blind",
domain: "education",
};
assert.equal(validateSpokenPrompt({
spokenPrompt: "除了工作,还有哪件事记得年份?",
followup: oos,
targetDomain: "education",
}).ok, true);
});
test("spokenPrompt requires each year in a period when probe_year is empty", () => {
const ranged = followup({
probe_year: undefined,
@@ -25,6 +25,7 @@ import {
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
type ExecutableTool<T = unknown> = {
execute(input: unknown): Promise<T>;
@@ -139,7 +140,10 @@ test("set/resolve focus tools return safe projections and never infer the active
caseId: CASE_ID,
questionId: "career-month-question",
intent: "clarify_event_date",
spokenPrompt: "你还记得哪年上的大学吗?",
// 原值: USER_COLLECT_QUESTION.family(3847e9c9;45bdb63e 为「哪年上的大学」)
// 新值: USER_COLLECT_QUESTION.relationship
// 原因: dated 分支移到家人之后,前两问恢复感情;服务端下一问仍是采集,BUG-529 要求题干命中感情词。
spokenPrompt: USER_COLLECT_QUESTION.relationship,
}) as Record<string, unknown>;
assert.equal(setResult.ok, undefined);
assert.equal(setResult.focus_id, FOCUS_ID);
@@ -70,6 +70,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(prompt, /每轮正文 2-4 句/);
assert.match(prompt, /「先这样」由服务器/);
assert.match(prompt, /collection_progress/);
assert.match(prompt, /不得写「范围在收窄」/);
assert.doesNotMatch(prompt, /不得询问外貌、体质、胎记或疤痕/);
assert.doesNotMatch(prompt, /财务与健康只有用户主动说才问/);
assert.doesNotMatch(prompt, /id 不是 adopt_representative/);
@@ -459,6 +459,9 @@ test("same-domain collect retry uses structured closed-focus state, not body mat
}],
sessionOutcome: "collect_evidence",
});
// 原值: family
// 新值: relationship / collect_retry true / USER_COLLECT_QUESTION_RETRY.relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.domain, "relationship");
assert.equal(plan.next_followup?.collect_retry, true);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION_RETRY.relationship);
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const migration = readFileSync(
new URL("../supabase/migrations/20260904010000_read_report_candidate_range.sql", import.meta.url),
"utf8",
);
test("report candidate-range RPC is owner-held, service_role execute-only, and column-minimal", () => {
assert.match(migration, /create or replace function public\.read_report_candidate_range\(/);
assert.match(migration, /p_user_id uuid/);
assert.match(migration, /p_rectification_case_id uuid default null/);
assert.match(migration, /security definer/);
assert.match(migration, /set search_path = ''/);
assert.match(migration, /revoke all on function public\.read_report_candidate_range\(uuid, uuid\)/);
assert.match(migration, /from public, anon, authenticated/);
assert.match(migration, /grant execute on function public\.read_report_candidate_range\(uuid, uuid\)\s+to service_role/);
assert.match(migration, /status in \('confirmed', 'completed'\)/);
assert.match(migration, /status = 'candidate_accepted'/);
assert.match(migration, /jsonb_build_object\('start_time', v_start, 'end_time', v_end\)/);
assert.doesNotMatch(migration, /grant\s+(all|select|insert|update|delete)\s+on table public\.agentic_rectification_cases/i);
assert.doesNotMatch(migration, /alter table/);
assert.doesNotMatch(migration, /enable row level security|create policy/i);
});
@@ -0,0 +1,148 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
READ_REPORT_CANDIDATE_RANGE_RPC,
loadReportCandidateRange,
parseReportCandidateRange,
reportCandidateRangeErrorCode,
} from "../src/lib/report-candidate-range.ts";
import {
resolveReportBirthTimeSensitivityInput,
} from "../src/lib/personal-report-route-core.ts";
import { REPORT_STABLE_CODES } from "../src/lib/personal-report-codes.ts";
const workerSource = readFileSync(
new URL("../src/lib/personal-report-worker.ts", import.meta.url),
"utf8",
);
const createRoute = readFileSync(
new URL("../src/app/api/reports/route.ts", import.meta.url),
"utf8",
);
const helperSource = readFileSync(
new URL("../src/lib/report-candidate-range.ts", import.meta.url),
"utf8",
);
function capturingWarn() {
const reasons: string[] = [];
return {
reasons,
warn: (payload: { event: "report_candidate_range_unavailable"; reason: string }) => {
reasons.push(payload.reason);
},
};
}
test("permission denied, thrown errors, and empty rows all degrade to a null window", async () => {
const denied = capturingWarn();
assert.equal(
await loadReportCandidateRange(
{
rpc: async () => ({
data: null,
error: { code: "42501", message: "permission denied for table agentic_rectification_cases" },
}),
},
{ userId: "11111111-1111-4111-8111-111111111111" },
denied.warn,
),
null,
);
assert.deepEqual(denied.reasons, ["42501"]);
const thrown = capturingWarn();
assert.equal(
await loadReportCandidateRange(
{
rpc: async () => {
throw new TypeError("rpc exploded");
},
},
{ userId: "11111111-1111-4111-8111-111111111111" },
thrown.warn,
),
null,
);
assert.deepEqual(thrown.reasons, ["TypeError"]);
const empty = capturingWarn();
assert.equal(
await loadReportCandidateRange(
{ rpc: async () => ({ data: null, error: null }) },
{ userId: "11111111-1111-4111-8111-111111111111" },
empty.warn,
),
null,
);
assert.deepEqual(empty.reasons, []);
});
test("a valid RPC window is returned and extra columns are ignored", async () => {
const log = capturingWarn();
assert.deepEqual(
await loadReportCandidateRange(
{
rpc: async (fn, args) => {
assert.equal(fn, READ_REPORT_CANDIDATE_RANGE_RPC);
assert.equal(args.p_user_id, "11111111-1111-4111-8111-111111111111");
assert.equal(args.p_rectification_case_id, "22222222-2222-4222-8222-222222222222");
return {
data: {
start_time: "10:00:00",
end_time: "10:04:00",
user_id: "must-not-leak",
candidate_range: { secret: true },
},
error: null,
};
},
},
{
userId: "11111111-1111-4111-8111-111111111111",
rectificationCaseId: "22222222-2222-4222-8222-222222222222",
},
log.warn,
),
{ startTime: "10:00", endTime: "10:04" },
);
assert.deepEqual(log.reasons, []);
});
test("degraded candidate windows keep report sensitivity input unwindowed", () => {
const input = resolveReportBirthTimeSensitivityInput(
{},
"accepted",
"10:30",
null,
);
assert.equal(input.birth_time_accuracy, "provisional");
assert.equal("candidate_range" in input, false);
assert.equal(REPORT_STABLE_CODES.calculationUnavailable, "calculation_unavailable");
});
test("error codes never include messages or birth content", () => {
assert.equal(
reportCandidateRangeErrorCode({
code: "42501",
message: "permission denied for table agentic_rectification_cases",
}),
"42501",
);
assert.equal(parseReportCandidateRange({ start_time: "25:00", end_time: "10:00" }), null);
assert.doesNotMatch(helperSource, /error\.message|details|hint/);
});
test("worker and create route read candidate windows only through the shared RPC helper", () => {
for (const source of [workerSource, createRoute]) {
assert.match(source, /loadReportCandidateRange/);
assert.doesNotMatch(source, /from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(source, /from\("birth_time_rectification_cases"\)/);
assert.doesNotMatch(source, /\.in\("status", \["confirmed", "completed"\]\)/);
}
assert.doesNotMatch(
workerSource,
/loadReportCandidateRange[\s\S]{0,400}calculation_unavailable/,
);
});