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
Jesse_ChenandCursor 5c5f25a33b docs(report): record staging engine smoke and BUG-525 report-worker blocker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 17:31:33 +08:00
Jesse_ChenandCursor 7ee7f8258a fix(ui): collapse product weights and radii onto the shared Button
Independent Staging Quality Gate / validate (push) Failing after 9m39s
Independent Staging Quality Gate / publish (push) Has been skipped
Chat body, type weights, and radii were still a second scale; membership purchase now uses the shared Button so the filled terracotta action is one implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 17:29:11 +08:00
Jesse_ChenandCursor 7b1354a79b fix(ui): restyle personal report pages to the product reading room
Independent Staging Quality Gate / validate (push) Successful in 8m31s
Independent Staging Quality Gate / publish (push) Successful in 1m58s
The report centre and reader were a second newspaper identity. They now reuse canvas, hairline, and serif display tokens so the document feels like the rest of Jyotisha.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 16:30:44 +08:00
51 changed files with 2747 additions and 481 deletions
+20
View File
@@ -1,5 +1,25 @@
# 印度占星 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 版本未变。
## 2026-09-04 — 个人报告中心与阅读页接入产品阅读室视觉
报告页不再用第二套报纸纸色和无衬线加粗标题。中心与阅读页改走产品画布、发丝线、衬线标题(字重 400),terracotta 只留给生成和打印;星盘 SVG 跟主题走,深色模式可读。打印仍钉回浅色纸。Skill 版本未变。
## 2026-09-04 — 生时校正采用卡旁白由 Agent 生成(不计费)
探针池耗尽、出采用卡的那一轮,旁白改为一次无工具模型调用:只根据服务端结构化事实解释为什么停、当前范围与代表分钟、采用后拿什么核对。与采集题意图分类器同一口径,**不计费**。模型失败、超时、或文案里出现事实外的时间/年份时,整段丢弃,回落到带「分不开 A 和 B」的模板句。点选最后一道区分题也走同一条 Agent 路径,不再用点选前的过期决策跳过。Skill 版本仍是 10.0.14;采用门与确认门未改。
@@ -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 + 全套质量门实际输出
+117 -3
View File
@@ -8092,11 +8092,11 @@
- 触发条件:API 出生 payload 的 `hour`/`minute` 为 float(`_high_rigor_birth_payload` 的既有口径),且 `birth_time_accuracy` 为 `provisional` 或 `approximate`,因而会走到 `_birth_datetime_from_args`。
- 根因:上游同步把 `_build_birth_time_sensitivity` 无条件接入 consultation_workflow。CLI argparse 的 hour/minute 是 int;API 路径是 float。`datetime()` 不能接受 float,抛 `TypeError`。异常只被 `except ValueError` 包住,于是冒成 500。
- 修复:`_birth_datetime_from_args` 对年月日时分做 `int()` 规范化,不改变 API float 口径。敏感度构建失败时写入 blocked/`not_available` 状态对象并继续 workflow,不再打死整个端点。
- 验证:`tests/test_consultation_workflow_birth_time_sensitivity.py`(已列入 `CORE_PYTEST_TARGETS`);本地 HTTP 对 career/marriage/wealth/timing/health 与不带敏感度字段的聊天形状均为 200/`success=true`;五份响应喂给 `buildReportEvidenceBundleV2` 得到 5 张 claim card、`blockedSections` 为空。staging quick 门 `585 passed, 1 skipped`。
- 验证:`tests/test_consultation_workflow_birth_time_sensitivity.py`(已列入 `CORE_PYTEST_TARGETS`);本地 HTTP 对 career/marriage/wealth/timing/health 与不带敏感度字段的聊天形状均为 200/`success=true`;五份响应喂给 `buildReportEvidenceBundleV2` 得到 5 张 claim card、`blockedSections` 为空。staging quick 门 `585 passed, 1 skipped`。staging 上从 web 容器对虚构 smoke 盘 `POST http://api:5200/api/consultation_workflow`(provisional + float 时分)HTTP 200、`success=true`、`birth_time_sensitivity.status=candidate_window_only`,约 74s。产品侧真实 personal_full 仍失败,见 BUG-526,不是本条 TypeError。
- 防复发:float hour/minute 的 API body 必须能走 `execute_consultation_workflow` 且不 500;敏感度层失败必须降级,不得再变成未捕获 `TypeError`。不得把 `_high_rigor_birth_payload` 的 hour/minute 改成 int。
- 相关记录:无
- 相关记录:BUG-526
- 复发自:无
- 修复版本:待发布
- 修复版本:`7b1354a79bb8e9b92c3c816fdc01f5f8de2860b7`(含本修复的 staging 部署 SHA)
## BUG-525 | 采集题答「没有」后「正在准备下一个问题…」不消失
@@ -8114,3 +8114,117 @@
- 复发自:BUG-491
- 修复版本:待发布
- 编号说明:rebase 到 `origin/staging` 时 BUG-524 已被 consultation_workflow 占用,本条落在 BUG-525。
## BUG-526 | accepted 生时的个人报告 worker 直接读已收回权限的校正表,16 秒 calculation_unavailable
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`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 与 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,单跑已过)。
@@ -9,8 +9,8 @@
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 1 类型修复 + 降级加固 + 回归测试 | 完成 | BUG-524 |
| 2 staging 部署后真实 personal_full | 待部署 | BUG-524 |
| 3 聊天路径回归确认 | 待部署 | BUG-524 |
| 2 staging 部署后真实 personal_full | 引擎已修好;产品报告被另一处挡住 | BUG-524 / BUG-526 |
| 3 聊天路径回归确认 | 默认模型缺计费配置,未跑到引擎 | BUG-524 |
## 开工复现
@@ -76,3 +76,26 @@ elapsed_ms: 751689
10 passed。
## Staging 真实验证(2026-09-04)
`GET https://staging.jyotisha.chat/api/health` → `.deployment.gitCommit` = `7b1354a79bb8e9b92c3c816fdc01f5f8de2860b7`。运行中 API 已含 `int(args.hour/minute)` 与敏感度降级。未提升 main。下文无账号、姓名、出生资料。
### 引擎(BUG-524)已在部署树上修好
从 web 容器对虚构 smoke 盘 `POST http://api:5200/api/consultation_workflow`(`birth_time_accuracy=provisional`,float 时分):HTTP 200,`success=true`,`birth_time_sensitivity.status=candidate_window_only`,约 74s,响应约 1.2MB。
### 任务 2 · 产品 personal_full 未闭环
登录后点「生成完整报告」。`request_id` `527f95f3-ac2e-4ed7-9b49-3c6ea4be02e8`:`failed` / `calculation_unavailable`,约 16.5s,job 3/3,进度停在 30%(`generating_report`),无 `personal_report_sections` 行。
同期 API 访问日志**没有** `POST /api/consultation_workflow`。Postgres 在 08:59:04 / 08:59:10 / 08:59:20 UTC 三次 `permission denied for table agentic_rectification_cases`,SQL 即 worker 对 `candidate_accepted` 的直接 SELECT。`20260814010000_immutable_skill_registry.sql` 已收回该表的 `service_role` 表级权限。资料状态为 `accepted`,worker 因此走进这张锁死表,从未到达已修好的引擎。记为 **BUG-526**(BUG-525 已被采集拒答占用)。
事故行 `85616e32-…`(07:38 UTC)同样是约 18s / 3 次 attempt / 无章节;当时 web 容器已 recreate,无法回收那一窗的 Postgres 句。不能把 07:38 也写成 TypeError,只能说与本次 16s 失败形态相同。
任务 2 欠的章节正文、telemetry、`inputTokens` / 2 倍线对照因此仍缺。
### 任务 3 · 聊天未跑到引擎
新开对话点「时运」芯片。产品提示「当前服务的计费配置尚未完成,本次不会扣点」。web 日志 `reason=feature_pricing_missing`。API 无 consultation_workflow。默认聊天模型缺咨询计费配置,与 BUG-524 无关。
+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** 起;开工时再核对一次,若被别的会话占用则顺延并在进度记录写明。
+35 -10
View File
@@ -35,15 +35,16 @@ Jyotisha feels like a private reading room: warm, editorial, grounded, and quiet
| Error | `--color-danger` | `#9a2f2f` | Errors and destructive actions |
| Accessible focus | `--color-focus` | `#85432f` | Keyboard focus and input focus |
The personal report carries a second, narrower palette for its printed-paper
surface. It is deliberately separate — the report is a document, not app chrome —
and nothing outside `.personal-report-*` may use it.
The personal report reuses the product palette. `--report-*` tokens exist so
print can pin a light sheet independently of the screen theme; they are the
canvas, hairline, and action colors, not a second visual identity. Nothing
outside `.personal-report-*` and `.report-center-*` may use them.
| Role | Token | Value | Usage |
|---|---|---:|---|
| Report paper | `--report-paper` | `#f8f5ee` | The report sheet itself |
| Report rule | `--report-rule` | `#c9c2b7` | Rules and dividers inside the sheet |
| Report accent | `--report-accent` | `#85432f` | Report headings, same hue as the action color |
| Report paper | `--report-paper` | `#fbfaf7` | Same as `--color-canvas`; the reading sheet |
| Report rule | `--report-rule` | `#d8d6cf` | Same as `--color-border`; rules inside the sheet |
| Report accent | `--report-accent` | `#85432f` | Same as `--color-action`; scarce evidence links |
Rules: neutrals stay warm; the action color is scarce; roughly ninety percent of the interface remains light. Dark ink is punctuation, never a page-scale surface. No raw color may appear in UI styles outside these tokens and their documented alpha mixes.
@@ -154,7 +155,7 @@ is read through an external store so a change in one tab reaches the others.
| `--type-caption` | `13px` | 500 | 1.4 | 0 | Labels and metadata |
| `--type-overline` | `12px` | 500 | 1.4 | `1.5px` | Eyebrows and badges |
Display headings use the serif stack at weight 400. Body copy never drops below 14px; 12–13px is reserved for short labels and metadata. CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
Display headings use the serif stack at weight 400. Body copy never drops below 14px; 12–13px is reserved for short labels and metadata. No product UI text is smaller than `--type-overline` (12px). Product UI uses three font weights: 400 (display and body), 500 (UI titles, labels, buttons), and 600 (emphasis only). CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
One documented exception: the thinking text inside a timeline step (`.consultation-run-timeline__thinking`) and the fallback thinking trace (`.message-thinking-body`) render at 13px. They are working notes shown on request inside a collapsed row, not reading copy; the answer itself never inherits that size.
@@ -162,6 +163,8 @@ One documented exception: the thinking text inside a timeline step (`.consultati
The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: 12px`, `--space-4: 16px`, `--space-5: 20px`, `--space-6: 24px`, `--space-8: 32px`, `--space-10: 40px`, `--space-12: 48px`, `--space-16: 64px`, and `--space-24: 96px`.
Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and `--radius-sm` alias that value). Cards and sheets use 12px (`--radius-lg`; `--radius-xl` aliases it). Circles stay `50%`; pills stay `999px`.
- Chat reading width: 760px for the welcome/composer and 900px for long answers. Both chat surfaces share the 900px transcript width; the rectification session no longer narrows it to 720px.
- Admin content width: 1200px, centered.
- Desktop shell: 288px sidebar plus flexible reading panel.
@@ -180,6 +183,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
### Button
- **Implementation:** report surfaces and membership use `@/components/ui/button`. Login, onboarding, and birth-time dialogs still use `.button-primary` / `.button-secondary`, which share the same 44px height, 8px radius, and action tokens. Those CSS classes stay until those surfaces can move without growing `page.tsx`.
- **Variants:** ink primary, cream secondary, text, circular icon, deep-brown emphasis.
- **Spacing:** 44px minimum height; radii 8px for standard and full radius for icon-only.
- **States:** default, hover, active, focus-visible, disabled, loading.
@@ -307,7 +311,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Variants:** assistant editorial text on canvas; user text on warm card surface; streaming; error. Streaming uses a timeline of completed steps plus the current step; the thinking body expands while streaming, collapses when answer text appears, and is stored with the assistant message.
- **Identity:** every assistant message carries the 32px Jyotisha logo avatar; user messages stay visually lighter and avatar-free.
- **Typography:** assistant body 17px (16px below 768px) with serif subheadings; user body 14px.
- **Typography:** assistant body `--type-body-md` (16px) with serif subheadings; user body 14px.
- **Tables:** three-column technique audit tables keep 状态 on one line. Below 768px they stack each row as title + status, then the note, instead of squeezing 已执行 into a vertical glyph column.
- **Follow-up:** the latest settled consultation answer may offer two or three grounded next questions under that answer. Clicking one sends it in the current session. The composer never hosts suggestion chips. If the answer does not support a grounded continuation, nothing is shown.
- **Motion:** a new row enters once, through the GSAP tween in `chat-message-row.tsx` at the 160ms Message duration; there is no CSS entrance keyframe beside it. The trailing assistant reply is one component (`LatestAssistantEntry`) from its first streamed token through settlement, so settling never remounts it and never replays the entrance.
@@ -337,8 +341,24 @@ Text release is paced, not animated: the frame buffer commits at most once per a
### Personal report centre
- A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
- Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
- **Structure:** full-page archive on the page floor. A compact back control, serif page title, supporting copy, one generate action, then a list of report cards.
- **Surface:** `--color-canvas-soft` floor; cards use `--color-canvas`, a warm hairline, and `--radius-lg`. No drop shadow. Status is a caption badge, not a colored block.
- **Typography:** page title uses `--type-display-lg` / `--font-display` at weight 400 with `text-wrap: balance`. Card titles are `--type-title-md` at weight 500. Body stays `--type-body-md` or `--type-body-sm`.
- **Width:** 900px centered, matching long-form chat reading. Cards stack below 720px.
- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
- **Accessibility:** back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating.
### Personal report reader
- **Structure:** sticky screen chrome (back, print), then an answer-first document: cover, executive judgement, natal chart evidence, thematic sections, appendix, provenance.
- **Surface:** page floor `--color-canvas-soft`; the document is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome, and pins the light palette.
- **Typography:** the subject name uses `--type-display-lg` serif at weight 400. Section titles use `--type-display-sm` serif at weight 400. Theme headings are `--type-title-md` sans at weight 500. Narrative is `--type-body-md` at 1.65, the same measure as chat answers. Labels stay 12–13px.
- **Accent:** claim-status pills and evidence links may use the action color; headings stay ink. Dark ink is never a page-scale rule or card edge.
- **Charts:** North-Indian SVG uses theme ink and canvas fills, never hardcoded light-only hex.
- **Width:** 900px for the document; chart and evidence columns stack at 760px.
- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, ready. Waiting uses `InlineSpinner`.
- **Accessibility:** back, print, and appendix disclosure are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button.
### Product entrypoint card
@@ -373,6 +393,11 @@ Text release is paced, not animated: the frame buffer commits at most once per a
- **Width:** 420px desktop maximum.
- **States:** open, submitting, success, and error.
### Membership
- **Structure:** full-page archive on the page floor. Header chrome is back, orders, and redeem; the hero names the current plan; tabs switch membership vs credits; each plan/credit card carries one purchase action.
- **Actions:** header chrome stays hairline `--color-ink-secondary`. The one filled terracotta control is the recommended-plan purchase (`Button` default). Other plan and credit buys use `Button` outline. Redeem opens a dialog; it is not a second filled button on the same screen.
### Logout dialog
- **Structure:** confirmation title and explanation, cancel action, and destructive confirm action.
@@ -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
File diff suppressed because it is too large Load Diff
+19 -16
View File
@@ -6,6 +6,7 @@ import type { FormEvent } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ArrowLeft, CheckCircle2, Gift, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
@@ -348,14 +349,14 @@ function MembershipContent() {
<div className="membership-payment-error" role="alert">
<p>{paymentError}</p>
{selectedProductId && (
<button
className="button-secondary"
<Button
variant="outline"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(selectedProductId)}
>
{payingProductId === selectedProductId ? "创建中…" : "重新支付"}
</button>
</Button>
)}
</div>
)}
@@ -406,14 +407,15 @@ function MembershipContent() {
<li>支付由合作渠道处理;如长时间未到账,请联系支持并出示订单号。</li>
</ul>
</details>
<button
className={recommended ? "button-primary membership-buy" : "button-secondary membership-buy"}
<Button
className="membership-buy"
variant={recommended ? "default" : "outline"}
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingProductId === product.id ? "创建中…" : isCurrent ? "续费" : product.productType === "trial" ? "立即开通" : "立即购买"}
</button>
</Button>
</article>
);
})}
@@ -444,14 +446,15 @@ function MembershipContent() {
<li>点数充值支付成功后立即到账。</li>
</ul>
</details>
<button
className="button-secondary membership-buy"
<Button
className="membership-buy"
variant="outline"
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingProductId === product.id ? "创建中…" : "立即购买"}
</button>
</Button>
</article>
))}
</div>
@@ -491,16 +494,16 @@ function MembershipContent() {
</div>
<div className="dialog-actions">
{paymentOrder.status === "failed" && (
<button
className="button-secondary"
<Button
variant="outline"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(paymentOrder.productId)}
>
{payingProductId === paymentOrder.productId ? "创建中…" : "重新支付"}
</button>
</Button>
)}
<button className="button-primary" type="button" onClick={goBack}>返回</button>
<Button type="button" onClick={goBack}>返回</Button>
</div>
</section>
)}
@@ -544,15 +547,15 @@ function MembershipContent() {
}}
placeholder="输入完整兑换码"
/>
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming || redeemDone}>
<Button type="submit" disabled={!redeemCode.trim() || redeeming || redeemDone}>
{redeeming ? "兑换中" : "立即兑换"}
</button>
</Button>
</div>
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
{redeemDone && (
<div className="dialog-actions">
<button className="button-primary" type="button" onClick={closeRedeem}>完成</button>
<Button type="button" onClick={closeRedeem}>完成</Button>
</div>
)}
</form>
@@ -20,12 +20,10 @@ export default function ReportError({
}, [error]);
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1 className="text-xl font-semibold text-ink">报告页面加载出错</h1>
<p className="max-w-md text-sm text-ink-secondary">
页面渲染时发生异常,未展示任何报告内容。
</p>
<h1>报告页面加载出错</h1>
<p>页面渲染时发生异常,未展示任何报告内容。</p>
<Button type="button" variant="outline" onClick={() => unstable_retry()}>
重试
</Button>
@@ -3,11 +3,9 @@ import "../../site-styles";
export default function ReportLoading() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p className="text-ink" role="status">
正在加载报告…
</p>
<p role="status">正在加载报告…</p>
</main>
);
}
@@ -7,11 +7,9 @@ import "../../site-styles";
export default function ReportNotFound() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink">报告不存在</h1>
<p className="max-w-md text-sm text-ink-secondary">
该报告不存在、已删除,或不属于当前账号。
</p>
<main className="personal-report-state">
<h1>报告不存在</h1>
<p>该报告不存在、已删除,或不属于当前账号。</p>
<Button render={<Link href="/" />} nativeButton={false} variant="outline">
返回对话
</Button>
@@ -20,6 +20,8 @@ import { useRouter } from "next/navigation";
import { FileText } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing", "health"] as const;
export interface PersonalReportCreateRequest {
@@ -190,15 +192,15 @@ export function GeneratePersonalReportButton({
}
return (
<button
<Button
type="button"
className="inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-border bg-canvas px-3 py-1.5 text-sm text-ink transition-colors hover:bg-canvas-muted disabled:pointer-events-none disabled:opacity-50"
className="shrink-0 whitespace-nowrap"
onClick={() => void handleGenerate()}
disabled={submitting}
title="根据已保存的具体出生分钟生成完整报告;未校正会标明方向性参考"
>
<FileText aria-hidden="true" className="size-4" />
<FileText aria-hidden="true" />
{submitting ? "正在创建…" : "生成完整报告"}
</button>
</Button>
);
}
@@ -221,7 +221,7 @@ export function PersonalReportCenter() {
return (
<article className="report-center-card" key={report.id}>
<div className="report-center-card-body">
<div className={`report-center-status is-${report.status}`}>
<div className={`report-center-status is-${report.status}`} role={report.status === "generating" ? "status" : undefined}>
<StatusIcon status={report.status} />{copy.label}
</div>
<h3>{report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}</h3>
@@ -229,7 +229,7 @@ export function PersonalReportCenter() {
<small>{formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"}</small>
</div>
{report.status === "ready" ? (
<div className="flex flex-wrap items-center justify-end gap-2 max-[720px]:justify-start">
<div className="report-center-card-actions">
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
查看报告
</Button>
@@ -243,7 +243,7 @@ export function PersonalReportCenter() {
专业参考版(导出)
</Button>
{exportError?.reportId === report.id ? (
<span className="basis-full text-right text-sm text-destructive max-[720px]:text-left" role="alert">
<span className="report-center-export-error" role="alert">
{exportError.message}
</span>
) : null}
@@ -197,6 +197,7 @@ function ReportCover({ document, d1 }: { document: ReportDocument; d1: ChartV1 |
<header className="personal-report-cover personal-report-section">
<div className="personal-report-cover-grid">
<div>
<p className="personal-report-kicker">个人报告</p>
<h1>{document.subject.displayName}</h1>
<p className="personal-report-deck">{document.executiveSummary.headline}</p>
</div>
@@ -229,17 +229,15 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "loading" || state.phase === "generating") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p className="text-ink" role="status">
<p role="status">
{generating ? (progressLabel ?? "报告正在生成中,请稍候…") : "正在加载报告…"}
</p>
{generating && (
<>
<p className="text-sm text-ink-tertiary">已等待 {formatWaitedDuration(waitedMs)}</p>
<p className="max-w-md text-sm text-ink-secondary">
生成完成后页面会自动显示;你也可以返回报告中心,后台会继续处理。
</p>
<p>已等待 {formatWaitedDuration(waitedMs)}</p>
<p>生成完成后页面会自动显示;你也可以返回报告中心,后台会继续处理。</p>
<Button render={<Link href="/reports" />} nativeButton={false} variant="ghost">返回报告中心</Button>
</>
)}
@@ -249,13 +247,11 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "timed-out") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<Clock3 aria-hidden="true" className="size-8 text-ink-secondary" />
<h1 className="text-xl font-semibold text-ink">生成时间超出预期</h1>
<p className="text-sm text-ink-tertiary">已等待 {formatWaitedDuration(waitedMs)},页面已暂停自动刷新。</p>
<p className="max-w-md text-sm text-ink-secondary">
报告仍在后台生成,不会因为你离开而中断。你可以继续等待,也可以稍后回到报告中心查看结果。
</p>
<h1>生成时间超出预期</h1>
<p>已等待 {formatWaitedDuration(waitedMs)},页面已暂停自动刷新。</p>
<p>报告仍在后台生成,不会因为你离开而中断。你可以继续等待,也可以稍后回到报告中心查看结果。</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button type="button" variant="default" onClick={() => keepWaiting()}>
继续等待
@@ -270,11 +266,9 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "unauthorized") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink">请先登录</h1>
<p className="max-w-md text-sm text-ink-secondary">
个人报告仅对登录用户开放。请登录后重试。
</p>
<main className="personal-report-state">
<h1>请先登录</h1>
<p>个人报告仅对登录用户开放。请登录后重试。</p>
<Button render={<Link href="/login" />} nativeButton={false} variant="default">
去登录
</Button>
@@ -284,11 +278,9 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "not-found") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink">报告不存在</h1>
<p className="max-w-md text-sm text-ink-secondary">
该报告不存在、已删除,或不属于当前账号。
</p>
<main className="personal-report-state">
<h1>报告不存在</h1>
<p>该报告不存在、已删除,或不属于当前账号。</p>
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
返回报告中心
</Button>
@@ -298,14 +290,12 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "failed") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
<h1 className="text-xl font-semibold text-ink">报告生成失败</h1>
<p className="max-w-md text-sm text-ink-secondary">
{state.failureSummary ?? "生成过程中出现问题,未产出可用报告。请稍后重试。"}
</p>
<h1>报告生成失败</h1>
<p>{state.failureSummary ?? "生成过程中出现问题,未产出可用报告。请稍后重试。"}</p>
{state.failureCode && (
<p className="font-mono text-xs text-ink-tertiary">错误码:{state.failureCode}</p>
<p>错误码:{state.failureCode}</p>
)}
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
返回报告中心重新生成
@@ -316,10 +306,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "invalid" || state.phase === "network-error") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1 className="text-xl font-semibold text-ink">报告暂时无法显示</h1>
<p className="max-w-md text-sm text-ink-secondary">
<h1>报告暂时无法显示</h1>
<p>
{state.phase === "invalid"
? `报告数据未通过校验(${state.message}),已停止渲染。`
: "网络连接失败,请检查网络后重试。"}
@@ -91,7 +91,6 @@ function PlanetList({ lines }: { lines: string[] }) {
x={6 + column * 49}
y={45 + row * rowStep}
fontSize={fontSize}
fill="#1d1d1f"
>
{truncateSvgLabel(line, maxCharacters)}
</text>
@@ -141,15 +140,13 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
y={cell.y}
width={CELL}
height={CELL}
fill="none"
stroke="#32322f"
strokeWidth={1.25}
className="personal-report-chart-cell"
/>
<text x={cell.x + 5} y={cell.y + 15} fontSize={10} fill="#6a6963">
<text x={cell.x + 5} y={cell.y + 15} fontSize={10} className="personal-report-chart-muted">
{houseNumber}
</text>
{house && (
<text x={cell.x + 5} y={cell.y + 30} fontSize={12} fontWeight={600} fill="#32322f">
<text x={cell.x + 5} y={cell.y + 30} fontSize={12} fontWeight={600}>
{house.sign}
</text>
)}
@@ -164,18 +161,14 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
y={CELL}
width={CELL * 2}
height={CELL * 2}
fill="#f3f2ee"
stroke="#32322f"
strokeWidth={1.25}
className="personal-report-chart-core"
/>
<path
d={`M ${CELL * 2} ${CELL} L ${CELL * 3} ${CELL * 2} L ${CELL * 2} ${CELL * 3} L ${CELL} ${CELL * 2} Z`}
fill="none"
stroke="#32322f"
strokeWidth={1.25}
className="personal-report-chart-diamond"
/>
{hasRetrogradeMarker && (
<text x={CHART_VIEWBOX_WIDTH - 6} y={CHART_VIEWBOX_HEIGHT - 4} textAnchor="end" fontSize={9} fill="#6a6963">
<text x={CHART_VIEWBOX_WIDTH - 6} y={CHART_VIEWBOX_HEIGHT - 4} textAnchor="end" fontSize={9} className="personal-report-chart-muted">
“逆”=逆行
</text>
)}
+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();
}
});
+10 -2
View File
@@ -162,7 +162,12 @@ test("plan cards carry price, duration, audience, entitlements, purchase and rul
assert.match(pageSource, /entitlementLabel\(entitlement\)/);
assert.match(pageSource, /<details className="membership-rules">/);
assert.match(pageSource, /<summary>详细规则<\/summary>/);
assert.match(pageSource, /className=\{recommended \? "button-primary membership-buy" : "button-secondary membership-buy"\}/);
// 原值: className={recommended ? "button-primary membership-buy" : "button-secondary membership-buy"}
// 新值: Button className="membership-buy" + variant default|outline
// 原因: 会员购买走共用 Button;登录/引导仍用 .button-primary 类(page.tsx 冻结与破坏确认合同)
assert.match(pageSource, /import \{ Button \} from "@\/components\/ui\/button"/);
assert.match(pageSource, /className="membership-buy"/);
assert.match(pageSource, /variant=\{recommended \? "default" : "outline"\}/);
});
test("renewal semantics map the active subscription to the matching plan", () => {
@@ -305,7 +310,10 @@ test("redeem modal reuses the existing account dialog classes", () => {
assert.match(pageSource, /className="redeem-form"/);
assert.match(pageSource, /className="form-error" role="alert"/);
assert.match(pageSource, /className="form-success" role="status"/);
assert.match(pageSource, /className="button-primary"/);
// 原值: className="button-primary"
// 新值: <Button type="submit">
// 原因: 兑换提交改走共用 Button,不再复用登录页的 .button-primary 类名
assert.match(pageSource, /<Button type="submit"/);
});
test("desktop uses three columns, tablet two and mobile one with monthly first", () => {
+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", () => {
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const luminance = (hex: string) => {
@@ -66,5 +68,22 @@ test("print pins the palette back to light so paper is always paper", () => {
assert.ok(printRoot.has(token), `@media print must reset --${token}`);
}
assert.equal(printRoot.get("color-ink"), "#1d1d1f");
assert.equal(printRoot.get("report-paper"), "#f8f5ee");
assert.equal(printRoot.get("report-paper"), "#fbfaf7");
assert.equal(printRoot.get("report-rule"), "#d8d6cf");
});
test("report titles use the product serif display stack at weight 400", () => {
const centreTitle = cssDeclarations(".report-center-hero h1");
const coverTitle = cssDeclarations(".personal-report-cover h1");
const sectionTitle = cssDeclarations(".personal-report-section-heading h2");
for (const [name, body] of [["centre", centreTitle], ["cover", coverTitle], ["section", sectionTitle]] as const) {
assert.match(body, /font-family:\s*var\(--font-display\)/, `${name} title must use --font-display`);
assert.match(body, /font-weight:\s*400/, `${name} title must stay weight 400`);
}
});
test("report chrome and appendix disclosure keep 44px targets", () => {
assert.match(cssDeclarations(".personal-report-back"), /min-height:\s*44px/);
assert.match(cssDeclarations(".personal-report-disclosure"), /min-height:\s*44px/);
assert.match(cssDeclarations(".report-center-back"), /min-height:\s*44px/);
});
@@ -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/,
);
});