Compare commits

...

12 Commits

Author SHA1 Message Date
Jesse_Chen 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_Chen 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_Chen 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
Jesse_Chen 41f973f036 fix(rectification): bind collect-denial follow-up to the asked turn
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
Saying no to a spoken collect left the next stem only on current_question, so the preparing spinner never cleared. Persist the ack, link asked_turn_id, and emit run.completed with turnId (BUG-525).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 16:27:53 +08:00
Jesse_Chen c140191357 fix(api): keep consultation_workflow alive when API hours are floats
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
API birth payloads store hour/minute as floats, and datetime() rejected them
after sensitivity was wired into every consultation_workflow call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 16:24:56 +08:00
Jesse_Chen 7cab9043fc docs(tasks): rectification collect-direction task brief — OOS before training gate, undirected collect question
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-04 08:03:53 +00:00
Jesse_Chen 45e00f461b docs(report): add sensitivity float-crash task brief — consultation_workflow 500s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-04 07:49:37 +00:00
Jesse_Chen 285c572299 docs(tasks): adopt narration fix2 accepted and deployed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-04 07:37:09 +00:00
Jesse_Chen 45bdb63eca fix(rectification): keep adopt narration timeout on a clearable ref timer
Independent Staging Quality Gate / validate (push) Successful in 9m16s
Independent Staging Quality Gate / publish (push) Successful in 1m52s
AbortSignal.timeout() is unref'd, so a hanging generateText drained the test event loop and cancelled the rest of the file. Clear the timer after the model returns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 15:23:13 +08:00
Jesse_Chen 5c9e98790b docs(tasks): adopt narration fix accepted except hanging timeout test; fix2 brief
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-04 07:06:55 +00:00
Jesse_Chen 13dded9fff fix(rectification): run adopt narration on the last discriminator click
Independent Staging Quality Gate / validate (push) Failing after 9m46s
Independent Staging Quality Gate / publish (push) Has been skipped
The early-exit reused the pre-click dossier, so the agent never ran on the common path. Use this-turn decision, log agent vs template outcome, and time out after 8s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 14:59:15 +08:00
Jesse_Chen e18bd25b19 docs(tasks): adopt narration accepted except click entry; fix brief
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-04 06:33:35 +00:00
38 changed files with 2536 additions and 403 deletions
+9 -1
View File
@@ -1,8 +1,16 @@
# 印度占星 Skill 更新日志
## 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」的模板句。Skill 版本仍是 10.0.14;采用门与确认门未改。
探针池耗尽、出采用卡的那一轮,旁白改为一次无工具模型调用:只根据服务端结构化事实解释为什么停、当前范围与代表分钟、采用后拿什么核对。与采集题意图分类器同一口径,**不计费**。模型失败、超时、或文案里出现事实外的时间/年份时,整段丢弃,回落到带「分不开 A 和 B」的模板句。点选最后一道区分题也走同一条 Agent 路径,不再用点选前的过期决策跳过。Skill 版本仍是 10.0.14;采用门与确认门未改。
## 2026-09-04 — 上游同步验收修复:咨询主题、finance 证据与模板门禁
+77
View File
@@ -0,0 +1,77 @@
# 任务书 · consultation_workflow 全量 500(出生时间敏感度 float 崩溃)(2026-09-04
基线:`origin/staging` @ `285c5722`(开工时 `git fetch` 后以 `origin/staging` HEAD 为准)。
## 事故实证
- 2026-09-04 07:38 UTCstaging 真实 personal_fullrequestId `85616e32-5a5d-4ed8-b75b-7778dabff4ad`5 主题含新默认 `health`**failedfailureCode = `calculation_unavailable`**。
- 委托方已在最新 `origin/staging``285c5722`,与部署 `45bdb63e` 同源)本地完整复现:本地起引擎后按报告 worker 的真实输入(5 主题 + `birth_time_accuracy: "provisional"` + `representative_time`)调 `/api/consultation_workflow`**5/5 全部 HTTP 500** → 前端 `ConsultationWorkflowError` → worker 抛 `calculation_unavailable` → job 3 次尝试全败。
- 引擎 traceback(每次请求相同):
```
File "scripts/jyotish_api_server.py", line 2160, in execute_consultation_workflow
birth_time_sensitivity = _load_local_module('jyotish_engine')._build_birth_time_sensitivity(sensitivity_args)
File "scripts/jyotish_engine.py", line 2170, in _build_birth_time_sensitivity
center = _birth_datetime_from_args(args)
File "scripts/jyotish_engine.py", line 9752, in _birth_datetime_from_args
return datetime(args.year, args.month, args.day, args.hour, args.minute, _arg_second(args))
TypeError: 'float' object cannot be interpreted as an integer
```
## 根因(已定位并本地验证修复)
1. `_high_rigor_birth_payload``jyotish_api_server.py:4726`)历来把 `hour` / `minute` 解析为 **float**`_get_float`,API 路径的历史口径,下游算小数时角没问题)。
2. 上游同步 `a7041529`09-04 02:11 合入)在 `execute_consultation_workflow` 里**无条件**执行 `_build_birth_time_sensitivity(sensitivity_args)`(:2158-2160,不管请求带不带敏感度字段),其中 `_birth_datetime_from_args``jyotish_engine.py:9752`)拿 float 的 `hour`/`minute` 直接构造 `datetime()``TypeError`。CLI/argparse 路径的 hour/minute 是 int,所以上游在 CLI 侧自测不炸;API 路径必炸。
3. 异常只被 `except ValueError` 包住,`TypeError` 直接冒成 500。
4. **影响面是全量**`/api/consultation_workflow` 的**每一个**调用都 500——不只报告,聊天的深度咨询工具链同样走这个端点。部署 `45bdb63e`(含 `a7041529`)起,该端点在 staging 上完全不可用。
5. 委托方已本地验证一行修复:`_birth_datetime_from_args` 改为 `datetime(int(args.year), int(args.month), int(args.day), int(args.hour), int(args.minute), _arg_second(args))` 后重跑同一复现,**5/5 主题成功**bundle 产出 5 张 claim card`health` 归一为 `health_pressure`)、blockedSections 为空。
## 与其他在途工作的关系
- `docs/tasks/TASK-upstream-sync-fix-20260903.md` 的修复在用户侧工作树完成但**未提交**,其十主题 HTTP smoke 是在基线 `779717f4`(早于 `a7041529`)跑的,当时引擎还没有本崩溃——两单不矛盾,本单是独立 P0,可先行合入;执行方若同时持有两个工作树,注意 rebase 顺序即可。
- 报告链路前三轮修复(varga 形状 / Transit / karakas / writer 预算与隔离)均已验收,本崩溃与它们无关,是上游同步引入的回归。
## 硬红线
1. 修复必须在 `_birth_datetime_from_args``sensitivity_args` 构造处做**类型规范化**,不得把 `_high_rigor_birth_payload` 的 float 口径改成 int——那是聊天/排盘路径的既有合同,动它影响面不可控。
2. `_build_birth_time_sensitivity` 是辅助证据层:除类型修复外,将 :2158-2160 的异常处理加固为**降级不崩全局**——构建失败时把 `birth_time_sensitivity` 置为 blocked/not_available 状态对象并继续 workflow`BadRequest` 语义保留给真正的输入非法)。敏感度层的失败不允许再打死整个端点。
3. 必须加回归测试:float `hour`/`minute` 的 API body 走 `execute_consultation_workflow` 不 500,且 `birth_time_sensitivity` 输出正确;测试用虚构 smoke 出生数据。
4. 不改 `.gitea/workflows/**`;不提升 main;前端不需要改动(`calculation_unavailable` 的 worker 语义是对的)。
5. Python 测试用 `.venv` 真实跑过;`python3 scripts/run_quality_gate.py` 按仓内现行门跑。
## 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/report-sensitivity-crash-20260904 \
../.worktrees/report-sensitivity-crash-20260904 origin/staging
```
`docs/BUG_HISTORY.md`(本单与上游同步 `a7041529` / `c2f23131` 直接相关)。
## 复现与验收(同一方法)
本地起引擎 `.venv/bin/python scripts/jyotish_api_server.py --port 5200`,用虚构出生数据(1993-06-15 10:30lat 36.42 / lon 114.21 / tz 8)对 career / marriage / wealth / timing / health 各发一次 `/api/consultation_workflow`body 含 `birth_time_accuracy: "provisional"``representative_time: "10:30"`):
- 修复前:5/5 HTTP 500traceback 同上(确认复现)。
- 修复后:5/5 `success=true``birth_time_sensitivity` 字段存在且状态合法;喂给 `buildReportEvidenceBundleV2` 得 5 张 claim card、blockedSections 空。
- 另发一次**不带**敏感度字段的请求(聊天路径形状),确认同样 200。
## 任务
1. **(P0)类型修复 + 降级加固 + 回归测试**(见硬红线 1–3)。
2. **P0staging 部署后真实验证**health SHA 对齐后,真实生成一份 standard personal_full5 主题默认)——这同时是前几轮顺延至今的任务 4:四/五章有正文、telemetry 无 `length`、≥3 处 writer 输出回溯 bundle、每章实测 `inputTokens`/墙钟对照 `docs/tasks/PROGRESS-report-skill-parity-20260901.md` 的 2 倍线裁决。**取证要快**——容器 recreate 会带走日志窗口(09-02 已吃过一次亏)。
3. **(P1)聊天路径回归确认**:staging 上发一次深度咨询,确认聊天侧 consultation 恢复正常。
## 收尾
- `PROGRESS-report-sensitivity-crash-20260904.md`(按仓内现行位置放 `docs/tasks/`);`docs/BUG_HISTORY.md` 条目(编号对远端确认)。
- 推送后核对 `https://staging.jyotisha.chat/api/health``.deployment.gitCommit`;流水线约 20 分钟。
## 交付物清单
1. 修复 diff(类型规范化 + 敏感度层降级)+ 回归测试
2. 复现方法修复前后对照(5/5 500 → 5/5 success
3. 部署后真实报告:章节正文、telemetry、回溯抽查、实测 tokens/墙钟 2 倍线对照
4. 聊天路径恢复确认
5. BUG_HISTORY、PROGRESS、质量门实际输出
+98
View File
@@ -8033,3 +8033,101 @@
- 复发自:无
- 修复版本:待发布
- 编号说明:与 BUG-519 同批;任务书原写 BUG-517,因编号冲突改为 BUG-520。
## BUG-521 | 点选最后一道区分题时采用旁白 Agent 从不运行
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`frontend/src/lib/rectification-agentic/v9/answer-choice.ts` `persistNextInterviewAfterChoice`
- 用户现象:点完最后一道区分题才出采用卡时,旁白仍是模板句;只有采集题答「没有」和空闲兜底才会走 Agent。
- 触发条件:`applyRectificationChoice``persistApplied` 传入的 dossier 仍是点选前的 `decisionReceipt`;本轮新状态在 `decisionState`,本轮决策在 `nextDecision`
- 根因:采用早退分支从旧 dossier 再算一遍 `decideFromDossier`。旧决策还是「还有题要问」,`precisionStage !== "ready_to_adopt"``shouldWriteAdoptNarration` 为 false,模型一次都没调。模板范围也来自这份过期决策。同一次调用里决策层算了两遍(BUG-440 防复发条款)。
- 修复:`persistNextInterviewAfterChoice` 接受调用方传入的 `decision`;未传时用 `decideAfterInferenceChange({ dossier, state: decisionState })`。函数体内不再出现 `decideFromDossier(``adoptDeliveryFacts` / 模板兜底使用合成后的 receipt(本轮 `inference_state`)。三条调用方都传入本轮决策。
- 验证:`rectification-adopt-narration-20260904` 点选用例改为点选前 dossierrevision 5、分数 23/16/7+ 答完后 `decisionState` / `nextAction`;断言模型调用 1 次、facts 为 05:00 与 21/18/9、`ready_to_adopt`。非法模型输出时模板范围仍来自本轮决策。源码断言该函数体内无 `decideFromDossier(`
- 防复发:`persistNextInterviewAfterChoice` 不得再从旧 dossier 重算决策。点选路径测试不得用已答完 dossier 冒充点选前状态。
- 相关记录:BUG-440、BUG-519、BUG-522
- 复发自:BUG-440
- 修复版本:待发布
## BUG-522 | 采用旁白校验把「不是确认」整段打回,且真实环境无法证明 Agent 有没有写
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`adopt-narration-agent.ts` 提示词、`deliverAdoptNarration` 超时与诊断字段
- 用户现象:校验器全文匹配「确认|精确」,模型写「这不是确认的分钟」会被静默退到模板;部署后日志看不出 Agent 路径命中还是模板兜底。
- 触发条件:采用旁白 Agent 生成含「确认」或「精确」的否定句;或模型挂起超过等待时间。
- 根因:提示词只禁止承诺确认/精确,校验器却禁这两个字。分类器同款调用没有超时。结果没有可观测枚举。
- 修复:提示词改为「不要出现『确认』『精确』这两个词」,与校验器同口径;校验器本身不放宽,「这不是确认的分钟」仍打回。`deliverAdoptNarration``adopt_narration=agent | template:<reason> | template:model_error | template:not_ready`(不含模型原文),并用 `AbortSignal.timeout(8000)` 超时走模板。意图分类器超时仍是既有缺口,本单不修。
- 验证:提示词源码断言含「不要出现」;`deliverAdoptNarration` 对 agent / template:unknown_minute / template:model_error / template:not_ready 各有断言;短 timeout 挂起 generateText 走模板且不抛。
- 防复发:采用旁白结果必须留下 `adopt_narration=` 日志。Agent.generate 必须带超时。不得把「确认」从校验器删掉却不改提示词。
- 相关记录:BUG-521、BUG-523
- 复发自:无
- 修复版本:待发布
## BUG-523 | 采用旁白超时用 unref 计时器,测试挂死事件循环
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts` `composedAbortSignal`
- 用户现象:staging 门禁 `npm test` 退出码 1`13dded9f` 不能部署。生产请求有监听 socket 撑住事件循环,超时在真实流量里仍会触发;但每次调用都会留下一个无法 `clear` 的 8 秒计时器。
- 触发条件:`deliverAdoptNarration``AbortSignal.timeout()`;测试把 `generateText` 挂成永不 resolve 的 Promise。
- 根因:Node 的 `AbortSignal.timeout()` 内部计时器始终 unref,空事件循环会在 abort 前排空。测试运行器判 `cancelledByParent`,同文件后三条用例一并取消。验收只看了 `# fail`,没看 `# cancelled` 与退出码。
- 修复:改为 `setTimeout` + `AbortController`(计时器保持 ref),返回 `{ signal, dispose }`;模型返回、校验完成或外部 abort 后 `clearTimeout` 并移除监听。超时仍走 `template:model_error`
- 验证:`rectification-adopt-narration-20260904` 超时用例不再 cancelled;新增「调用完不留活跃计时器」用例;源码不含 `AbortSignal.timeout`。定向套件与该文件摘要须含完整六行且 `# cancelled 0`、退出码 0。
- 防复发:采用旁白超时不得再用 `AbortSignal.timeout`。验收必须贴 Node 摘要完整六行(tests / pass / fail / cancelled / skipped / todo)与进程退出码,不得只报 `# fail 0`
- 相关记录:BUG-522
- 复发自:BUG-522
- 修复版本:待发布
## BUG-524 | consultation_workflow 在 float 时辰上崩溃,全量 HTTP 500
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`POST /api/consultation_workflow``execute_consultation_workflow``_build_birth_time_sensitivity``_birth_datetime_from_args`;报告 worker 与聊天深度咨询共用该端点
- 用户现象:staging 上 standard personal_full 生成失败,`failureCode = calculation_unavailable`。同一端点对 provisional 出生时间返回 HTTP 500。
- 触发条件: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 是 intAPI 路径是 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`。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 | 采集题答「没有」后「正在准备下一个问题…」不消失
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`applyCollectFocusDenial``persistCollectDenialTurn``/api/rectification/agent` `completedMessageResponse``rectification-agentic-chat` 问题缺口重试
- 用户现象:采集口述题答「没有」后,助手气泡已经给出下一问,底部仍转圈显示「正在准备下一个问题…」,不会变成「没有拿到下一个问题」。范围条「目前范围 … 还在收窄」是采集阶段的只读提示,不是本缺陷。
- 触发条件:当前焦点为 `collect_spoken`;意图分类 `answer_current_focus` + `answer_class: "no"`;走采集拒答快路径(不进 Agent)。
- 根因:拒答快路径把下一问题干整段当成正文流出去,但 turn 上 `question` 为空、新焦点没有 `asked_turn_id``run.completed` 也不带 `turnId`。前端 `liveQuestionOnMessages` 要求已结算消息的 `question.focus_id` 等于 `current_question.focus_id`,缺口因此一直是 `preparing``applyCaseSnapshot` 只要快照里有 `current_question` 就把重试次数清零,定时重拉永远到不了 `unavailable`
- 修复:拒答后先落确定性 turn,再 `linkFocusAskedTurn`。库内正文为确认句 + 精确题干后缀(GET 仍按 `asked_turn_id` detach);直播只推确认句。`run.completed` 带上 `turnId`。快照里出现 `current_question` 不再重置缺口重试。
- 验证:`rectification-collect-stall` 锁 family collect 拒答 → occupation 题干写入 turn、`p_asked_turn_id`、直播确认句;route 源码锁 `persistCollectDenialTurn``finished.turnId``rectification-surface-contract` 禁止 `if (nextQuestion !== null) setQuestionRetryAttempts(0)`
- 防复发:采集拒答快路径建立的下一焦点必须有 `asked_turn_id`,且 `run.completed` 必须带该 turn。不得把「快照已有 current_question」当成缺口已闭合。直播不得把下一问题干当作整段回复(题干走 turn.question)。
- 相关记录:BUG-440、BUG-490、BUG-491、BUG-505、BUG-520
- 复发自:BUG-491
- 修复版本:待发布
- 编号说明:rebase 到 `origin/staging` 时 BUG-524 已被 consultation_workflow 占用,本条落在 BUG-525。
## BUG-526 | accepted 生时的个人报告 worker 直接读已收回权限的校正表,16 秒 calculation_unavailable
- 状态:investigating
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`frontend/src/lib/personal-report-worker.ts` `createProductionWorker``generate``public.agentic_rectification_cases`
- 用户现象:staging 上 standard personal_full 创建成功后约 16 秒失败,`failureCode = calculation_unavailable`,无章节行。表现与 BUG-524 事故码相同,但引擎访问日志里没有 `POST /api/consultation_workflow`
- 触发条件:资料 `birth_time_status``accepted`(不是 `confirmed`),worker 因此去查 `agentic_rectification_cases``candidate_accepted` 行。
- 根因:`20260814010000_immutable_skill_registry.sql` 已从该表收回 `service_role` 的表级权限,只许走 security definer RPC。报告 worker 仍 `from("agentic_rectification_cases").select(...)`。Postgres 三次 `permission denied for table agentic_rectification_cases`(与 job 三次 attempt、默认 5s/10s 重试对齐)。查询失败被映射成可重试 `calculation_unavailable`,报告从未调用引擎。
- 修复:未做。本轮任务书禁止为 BUG-524 改前端 worker。候选方向:`accepted` 已有可用 `active_birth_time` 时不要读这张锁死表;或经允许的 RPC 取候选窗;查询失败时降级为无 range 继续生成,而不是打死整份报告。
- 验证:staging Postgres 日志三次 permission denied;同期 API 无 consultation_workflow;同机 web→api 虚构 smoke 为 200BUG-524 已修好)。
- 防复发:报告 worker 不得再直接 SELECT 已收回 `service_role` 权限的校正表;`calculation_unavailable` 必须能区分「引擎 500」与「可选候选窗读失败」。
- 相关记录:BUG-524
- 复发自:无
- 修复版本:待修复
- 编号说明:rebase 到 `origin/staging` 时 BUG-525 已被采集拒答占用,本条落在 BUG-526。
@@ -0,0 +1,52 @@
# PROGRESS · 采用旁白点选入口修复(2026-09-04)
工作树:`.worktrees/rectification-adopt-narration-fix-20260904`
分支:`codex/rectification-adopt-narration-fix-20260904`
基线:任务书写 `0aaa0d70`;本工作树从当时 `origin/staging` `e18bd25b`(修复单提交)拉出。
任务书:`docs/tasks/TASK-rectification-adopt-narration-fix-20260904.md`
未改 Python、未 bump Skill、未放宽采用门。未提交、未 push。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 早退分支用本轮决策 | 完成 | BUG-521 |
| 5.2 提示词同口径、诊断字段、8s 超时 | 完成 | BUG-522 |
| 5.3 `stopFactsFromDropped` 婚恋标签 | 推迟 | — |
## 实现要点
- `persistNextInterviewAfterChoice` 新增可选 `decision``persistApplied` / `applyCollectFocusDenial` / `persistNextInterviewIfIdle` 都传入本轮决策。未传时走 `decideAfterInferenceChange`。函数体内不再出现 `decideFromDossier(`
- `adoptDeliveryFacts` 与模板兜底用 `dossierWithCurrentInference` 合成后的 receipt,活跃分数来自本轮 `decisionState`
- `deliverAdoptNarration` 返回 `adopt_narration` 枚举并打 `console.info`(无模型原文)。`AbortSignal.any([request.signal, AbortSignal.timeout(8000)])`;超时 / 抛错走 `template:model_error`
- 提示词改为「不要出现『确认』『精确』这两个词」。校验器仍打回「这不是确认的分钟」。
## 偏离 / 推迟
- 5.3 按让步顺序推迟:`not_renderable` 仍可能被标成「婚恋题只覆盖窗口两端」。
- 意图分类器没有超时:既有缺口,本单不修,记在此处,不改 `BLOCKED.md`
## 测试
- `tsc --noEmit`0 错
- `npm run lint`0 error74 warning(既有)
- `rectification-adopt-narration-20260904` 10/10;与 answer-choice / collect-stall / provisional-adopt 合计 66/66
- `tests/rectification-*.test.ts tests/agentic-rectification-*.test.ts tests/birth-time-rectification-contract.test.ts`871 pass / 0 fail(基线 869 + 本单 2 条诊断/超时测试)
## 更正(2026-09-04 · 修复单 2
上面「10/10、871 pass / 0 fail」与实测不符。`AbortSignal.timeout()` 的计时器是 unref 的;超时用例把 `generateText` 挂死后事件循环排空,运行器把该条及同文件后三条标成 `cancelledByParent`
任务书在 Node 20.19 / CI Node 22 上的实测(完整六行):
```
# tests 10
# pass 6
# fail 0
# cancelled 4
# skipped 0
# todo 0
exit=1
```
定向套件 `rectification-* / agentic-rectification-* / birth-time-*`tests 871pass 858fail 0cancelled 4exit=1。
本机 Node 24.15.0 用 `tsx --test` 时 tsx IPC 会撑住事件循环,看起来像 10/10;独立脚本 `AbortSignal.timeout(30)` + 永不 settle 的 `Promise.race` 仍以退出码 13 结束,abort 从未触发。根因与修复见 `TASK-rectification-adopt-narration-fix2-20260904.md` / BUG-523。
@@ -0,0 +1,90 @@
# PROGRESS · 采用旁白超时改为可清理的 ref 计时器(2026-09-04
工作树:`.worktrees/rectification-adopt-narration-fix2-20260904`
分支:`codex/rectification-adopt-narration-fix2-20260904`
基线:任务书写 `13dded9f`;本工作树从当时 `origin/staging` `5c9e9879`(修复单 2 文档提交)拉出。
任务书:`docs/tasks/TASK-rectification-adopt-narration-fix2-20260904.md`
未改决策逻辑、未改提示词、未改 Python、未 bump Skill。未提交、未 push。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 超时改为可清理的 ref 计时器 | 完成 | BUG-523 |
## 开工复现
本机 Node 24.15.0。`tsx --test` 因 IPC socket 撑住事件循环,超时用例看起来能过。独立脚本复现了 unref 计时器:
```
const s = AbortSignal.timeout(30);
await Promise.race([new Promise(() => {}), new Promise((_, rej) => s.addEventListener("abort", () => rej(s.reason)))]);
# abort 从未触发
exit=13
```
任务书在 Node 20.19 / CI Node 22 上的套件实测见 `PROGRESS-rectification-adopt-narration-fix-20260904.md` 更正节(pass 6 / cancelled 4 / exit 1)。
## 实现要点
- `composedAbortSignal` 返回 `{ signal, dispose }``setTimeout` + `AbortController`(不 unref);外部 `signal` 的 abort 转发到 controller`dispose()` `clearTimeout` 并移除外部监听。
- `whenAborted` 同样返回 `dispose`,在 `finally` 里与 composed 一起清掉 abort 监听。
- `deliverAdoptNarration``Promise.race` 包在 `try / finally`;超时仍走 `template:model_error`
- 源码不再出现 `AbortSignal.timeout`
## 偏离
无。5.3 婚恋标签仍按修复单 1 推迟。
## 测试
`npx`/`tsx --test tests/rectification-adopt-narration-20260904.test.ts`
```
# tests 11
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
exit=0
```
Node spec 报告器打印为 ` tests` 等六行,含义相同。比修复前多 1 条「调用完不留活跃计时器」。)
`tsc --noEmit`0 错,exit=0。
`npm run lint`0 error74 warning(既有),exit=0。
定向套件 `tests/rectification-*.test.ts tests/agentic-rectification-*.test.ts tests/birth-time-rectification-contract.test.ts`
```
# tests 872
# pass 872
# fail 0
# cancelled 0
# skipped 0
# todo 0
exit=0
```
(基线任务书写 871;本单 +1 条计时器用例。)
`npm test`(全量 `tests/*.test.ts`;本机有 Docker,无 ENOENT 基线那 25 条):
```
# tests 2641
# pass 2641
# fail 0
# cancelled 0
# skipped 0
# todo 0
exit=0
```
## 改动文件
- `frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts`
- `frontend/tests/rectification-adopt-narration-20260904.test.ts`
- `docs/BUG_HISTORY.md`BUG-523
- `docs/tasks/PROGRESS-rectification-adopt-narration-fix-20260904.md`(更正)
- `docs/tasks/README.md`
- 本文件
@@ -0,0 +1,101 @@
# PROGRESS · consultation_workflow 全量 500(出生时间敏感度 float 崩溃)(2026-09-04
工作树:`/Users/jesse/Downloads/Copse/astrology/.worktrees/report-sensitivity-crash-20260904`
分支:`codex/report-sensitivity-crash-20260904`
基线:任务书写 `285c5722`;开工 `git fetch``origin/staging` HEAD 为 `45e00f46`(含本任务书)。
任务书:仓库根 `TASK-report-sensitivity-crash-20260904.md`
未改 `_high_rigor_birth_payload` 的 float 口径,未改前端 worker,未改 `.gitea/workflows/**`,不提升 main。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 1 类型修复 + 降级加固 + 回归测试 | 完成 | BUG-524 |
| 2 staging 部署后真实 personal_full | 引擎已修好;产品报告被另一处挡住 | BUG-524 / BUG-526 |
| 3 聊天路径回归确认 | 默认模型缺计费配置,未跑到引擎 | BUG-524 |
## 开工复现
`.venv/bin/python -m pytest tests/test_consultation_workflow_birth_time_sensitivity.py -q --tb=short` 修复前:
```
.FFFFFF.F
TypeError: 'float' object cannot be interpreted as an integer
```
栈与任务书一致:`execute_consultation_workflow``_build_birth_time_sensitivity``_birth_datetime_from_args`。payload 仍把 hour/minute 解析为 float(该用例本身通过)。不带敏感度字段的聊天形状因默认 `confirmed` 早退,修复前也能过。
## 实现要点
- `_birth_datetime_from_args``datetime(int(year), int(month), int(day), int(hour), int(minute), _arg_second(args))`
- `execute_consultation_workflow`:敏感度构建的 `ValueError` 不再升成打死端点的 `BadRequest`;其余异常降级为:
```
schema=jyotish.report_birth_time_sensitivity.v1
status=not_applicable
availability=not_available
blocked=true
reason=birth_time_sensitivity_unavailable
```
`status` 留在前端已有 Zod 枚举(`not_applicable` / `candidate_window_only`)内,避免降级对象被 `consultationWorkflowResponseSchema` 打成 `workflow_contract_invalid`。真正的缺字段/非法主题仍由 `_high_rigor_birth_payload` 等抛 `BadRequest`
## 本地 HTTP 对照(虚构 1993-06-15 10:30
引擎 `.venv/bin/python scripts/jyotish_api_server.py --port 5200``swisseph_available=true`
| 请求 | HTTP | success | sensitivity.status |
| --- | --- | --- | --- |
| career / marriage / wealth / timing / healthprovisional + representative_time 10:30 | 200 | true | candidate_window_only |
| 不带敏感度字段(聊天形状) | 200 | true | not_applicable |
五份主题响应喂给 `buildReportEvidenceBundleV2`claimCards = career / health_pressure / marriage / timing / wealth5 张),blockedSections 空。
## 测试
`.venv/bin/python -m pytest tests/test_consultation_workflow_birth_time_sensitivity.py tests/test_flexible_birth_time_engine.py tests/test_consultation_workflow_domains.py -q`
```
............................................................
```
9 条新回归 + 既有 flexible/domains 全部通过。
质量门(staging 现行:`--profile quick --skip-yoga-logic --skip-frontend-runtime`):
```
585 passed, 1 skipped, 201 warnings in 436.00s (0:07:15)
Quality gate passed.
elapsed_ms: 751689
```
该次 pytest argv 尚未包含新文件(CORE 钉是门跑完后补上的)。随后把 `tests/test_consultation_workflow_birth_time_sensitivity.py` 列入 `CORE_PYTEST_TARGETS`,并加 `test_quality_gate_runs_this_file`。补钉后:
```
.venv/bin/python -m pytest tests/test_consultation_workflow_birth_time_sensitivity.py -q
.......... [100%]
```
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.5sjob 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 无关。
+5 -1
View File
@@ -44,7 +44,10 @@
| `TASK-rectification-adopt-flow-20260902.md` | — | 采用流程整治 | 已验收 | `35e5781e`BUG-497500 |
| `TASK-rectification-adopt-flow-fix-20260903.md` | — | 采用流程修复单(含删「用这个时间看盘」) | 已验收 | `e8c98c37`BUG-501/502 |
| `TASK-rectification-uncertainty-stop-20260903.md` | — | 不确定度停止规则加样本下限 | 已验收 | `0c0df426`BUG-503 |
| `TASK-rectification-adopt-narration-20260904.md` | `PROGRESS-rectification-adopt-narration-20260904.md` | 探针池耗尽时采用卡旁白改 Agent 生成 + 已丢弃探针绕过 BUG-472 早退 + 区分题答否关线 | 验收 | 同步后基线 `cfa82499`BUG-519 / BUG-520 |
| `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-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505509 |
### 聊天主链路与首页
@@ -70,6 +73,7 @@
| `TASK-report-skill-parity-20260901.md` | `PROGRESS-report-skill-parity-20260901.md` | 内容对齐 skill 解读深度 | 已验收 | `90bad10d``ef1bd6df` |
| `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 |
### 前端基础与工程
@@ -0,0 +1,109 @@
# 修复单 · 采用旁白 Agent 在点选入口从不运行(2026-09-04)
基线:`origin/staging` `0aaa0d70``TASK-rectification-adopt-narration-20260904.md` 的实现)。本单只改 `answer-choice.ts` 与新测试,不动 Agent 提示词以外的决策逻辑。
## 0. 验收结论(对照原任务书)
| 项 | 结论 | 证据 |
| --- | --- | --- |
| 4.1.1 nakshatra 计划/决策同口径,无 frame 的 distinguish 不进 `deferred_followup` | 通过 | `rectificationFollowupCatalog``inspectDiscriminatorProbes`;计划层兜底丢进 `dropped_probes(not_renderable)` |
| 4.1.2 `isRemainingDiscriminatorFollowup` / `interviewToPersist` 忽略无 frame distinguish | 通过 | 代码 + 14 探针 fixture |
| 4.1.3 `persistableFocusDomain` 非白名单返回 nullappearance 零 RPChorary 断言三栏说明 | 通过 | `rectification-server-focus``rectification-collect-stall` |
| 4.1.4 采用旁白 Agent + 模板兜底,三条入口都接 | **未通过(P1** | 见 §1;点选入口 Agent 从不运行,且模板改用了过期决策 |
| 4.1.5 采用轮 `current_question` 为 null、零次 set_focus | 通过 | 新测试 `assertNoFocusWrite` |
| 4.2 区分题答否不关整条领域 | 通过 | `declinedDomains` 按 intent`dossierWithClosedFocus` 带 intentBUG-520 |
| `rectification-collect-stall` live fixture 补 nakshatra + window_scan 后原断言仍过 | 通过 | 本地重跑 |
| tsc / lint / 定向套件 | 通过 | `tsc` 0 错;lint 0 error 74 warning(既有);`rectification-* / agentic-rectification-* / birth-time-*` 869 条 fail=0 |
| 全量 `tests/*.test.ts` | 环境缺口 | 2638 条,fail 25 全部为 `docker ENOENT` 的 DB / 部署 / 备份套件,与无 Docker 基线一致;无一条校正相关 |
| BUG-519 / BUG-520、CHANGELOG、docs/testing 清单 | 通过 | 无案例 ID、无用户资料 |
## 1. 事故实证(P1 · 点选入口 Agent 从不运行)
`answer-choice.ts` `persistNextInterviewAfterChoice` 的采用早退分支:
```ts
const decision = decideFromDossier(input.dossier, { birthDate });
const facts = adoptDeliveryFacts(decision, input.dossier);
const fallback = adoptHostNarration({ dossier: input.dossier, decision, ... });
```
点选路径(`applyRectificationChoice``persistApplied`)传进来的 `input.dossier` 是**点选前**加载的 dossier,其 `latestResult.decisionReceipt.inference_state` 还是上一轮;本轮新状态在 `input.decisionState`,本轮决策在 `input.nextAction``persistApplied` 手里还有完整的 `nextDecision`)。函数开头已经把两者合成了 `latest`,但早退分支没有用它,而是从旧 dossier 重新 `decideFromDossier`
- 旧 dossier 的决策是「还有一道题要问」(`theme_refine` / `ask_candidate_discriminator``probe` 非空),`precisionStage !== "ready_to_adopt"``shouldWriteAdoptNarration` 为 false → **`narrateAdopt` 直接返回 fallback,模型一次都没调**。
- fallback 模板的 `credibleRange / representativeTime / stopReason` 也来自这份旧决策,而不是本轮的 `nextAction`。改造前模板用的是 `nextAction.credible_range`,这是回归。
本地复现(用 14 探针 fixture,把 dossier 里的 receipt 回退成「最后一道题未答、分数 23/16/7」,`decisionState` 用答完后的状态,`nextAction` 用答完后的决策):
```
pre-click decision: theme_refine, probe=true
agent called: false
narration: <模板>
fresh nextAction: offer_provisional_range [05:00, 05:06] 05:00
```
新测试「three adopt entry points call the model once」没拦住,是因为它给点选入口传的 `dossier` 已经是答完后的状态(receipt 与 `decisionState` 相同),与生产的点选路径不一致。
用户可感知:**点最后一道区分题**(采用卡最常见的出现方式)时,旁白仍是模板句;只有采集题答「没有」和空闲兜底两条入口才会走 Agent。
## 2. 根因
早退分支把「决策」当成可以从 dossier 随手再算一遍的东西,违反 BUG-440 防复发条款「同一次调用里出题层与决策层必须用同一个 sessionOutcome」——这里是决策层自己算了两遍、用了两份不同输入。
## 3. 决策记录
原任务书决策不变。本单不新增产品决策。
## 4. 硬红线
原任务书 §3 全部沿用。追加:
1. `persistNextInterviewAfterChoice` 内不得再调用 `decideFromDossier`;决策由调用方传入,或用 `decideAfterInferenceChange({ dossier, state: input.decisionState })``persistApplied` 保持同一入口。
2. 「三条入口」测试的点选用例必须用**点选前** dossier + 答完后 `decisionState` + 答完后 `nextAction` 的形状;不得再用已答完的 dossier 冒充。
## 5. 任务分解
### 5.1 P1 · 早退分支用本轮决策(BUG-521)
1. `persistNextInterviewAfterChoice` 新增可选入参 `decision?: RectificationDecision`;三条调用方都传:`persistApplied``nextDecision``persistNextInterviewIfIdle` 传已算好的 `decision``applyCollectFocusDenial` 传它用来生成 `nextAction` 的那份决策。未传时用 `decideAfterInferenceChange({ dossier: input.dossier, state: input.decisionState, userStopped: false, birthDate })`,不得用 `decideFromDossier(input.dossier)`
2. `adoptDeliveryFacts``adoptHostNarration` 的 dossier 参数改传合成后的 `{ ...input.dossier, latestResult: latest }`(函数开头已有的 `latest`),保证活跃候选与相对支持度是本轮的。
3. `persistApplied``adoptionFacts = adoptDeliveryFacts(nextDecision, input.dossier)` 同样改用合成 receipt。
验收:
- `rectification-adopt-narration-20260904.test.ts`「three adopt entry points」点选用例改成 §1 的形状:`dossier` receipt 为「最后一道题未答、分数 23/16/7、revision 5」,`decisionState = fourteenProbeState()``nextAction = publicNextAction(decideFromDossier(caseDossier()))`。断言:模型被调用 1 次;传给模型的 `facts.representative_minute === "05:00"``facts.active_candidates` 的分数为 21/18/9(不是 23/16/7)、`facts.precision_stage === "ready_to_adopt"`
- 同一形状下让 fake model 返回非法文本,断言 fallback 模板里的范围与代表分钟来自 `nextAction`05:0005:06 / 05:00),并含「分不开 05:00 和 05:06」。
- 加一条源码级断言:`persistNextInterviewAfterChoice` 函数体内不出现 `decideFromDossier(`
- `docs/BUG_HISTORY.md` 新增 BUG-521,复发自 BUG-440(决策层重复计算)。
### 5.2 P2 · 校验器把「不是确认」也打回(BUG-522,可与 5.1 同批)
`validateAdoptNarration``/确认|精确/` 是全文匹配;提示词只说「不得承诺确认或精确」,模型很自然会写「这不是确认的分钟」——会被整段打回、静默退到模板,Agent 路径在真实环境的命中率会被这条拉低而且没人知道。
1. 提示词改成「不要出现『确认』『精确』这两个词」(与校验器同口径)。
2. 增加一条可观测:Agent 路径的结果写进本轮 run diagnostic / 服务端日志一个枚举字段 `adopt_narration: "agent" | "template:<validate reason>" | "template:model_error" | "template:not_ready"`,不写模型原文。没有这条,部署后没人能证明 Agent 到底有没有写。
3. `agent.generate` 加超时:`AbortSignal.any([request.signal, AbortSignal.timeout(8000)])`,超时走模板。分类器没有超时是既有缺口,不在本单修,写进 `BLOCKED.md` 或进度记录。
验收:fake model 返回「这不是确认的分钟,先用 05:00。」→ 仍打回(校验器不变),但提示词测试断言含「不要出现」;诊断字段在三种结果下各有一条测试;超时用 fake `generateText` 挂起 + 短 timeout 断言走模板且不抛。
### 5.3 P3 · `stopFactsFromDropped` 的「婚恋题只覆盖窗口两端」标签
`insufficient_candidates || not_renderable` 一律标成婚恋题,而 `not_renderable` 现在也用于 nakshatra。改成按 `semantic_key` 前缀分桶(`relationship.` → 婚恋,其它 → 「有几道题渲染不出来」),或去掉领域词。可推迟。
## 6. 让步顺序
1. 5.1 必做,否则 4.1.4 在最常见入口上等于没做。
2. 5.2 的可观测字段与超时优先于提示词改动。
3. 5.3 可推迟到下一轮。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-adopt-narration-fix-20260904 .worktrees/rectification-adopt-narration-fix-20260904 origin/staging
cd .worktrees/rectification-adopt-narration-fix-20260904/frontend
./node_modules/.bin/tsc --noEmit
npx tsx --test tests/rectification-adopt-narration-20260904.test.ts tests/rectification-answer-choice.test.ts tests/rectification-collect-stall.test.ts
```
## 8. BUG 编号起点
截至本单:BUG-520。本单从 **BUG-521** 起;开工时再核对。
@@ -0,0 +1,101 @@
# 修复单 2 · 采用旁白超时测试挂死事件循环,拖垮同文件后三条用例(2026-09-04)
基线:`origin/staging` `13dded9f``TASK-rectification-adopt-narration-fix-20260904.md` 的实现)。本单只改 `adopt-narration-agent.ts` 的超时实现与对应进度记录,不动决策逻辑、不动提示词。
## 0. 验收结论(对照修复单 1)
| 项 | 结论 | 证据 |
| --- | --- | --- |
| 5.1 早退分支用本轮决策;三条调用方传 `decision`facts / 模板用合成 receipt | 通过 | `persistNextInterviewAfterChoice` 函数体无 `decideFromDossier(``dossierWithCurrentInference`;点选用例改为点选前 dossierrevision 5、23/16/7),断言模型 1 次、facts 05:00 / 21/18/9 / `ready_to_adopt`;非法输出时模板范围来自本轮决策 |
| 5.2 提示词「不要出现」;诊断枚举 `adopt_narration=`8s 超时 | 通过(实现)/ **未通过(测试,见 §1** | 提示词与校验器同口径;`deliverAdoptNarration` 返回 `adopt_narration``console.info` 不含模型原文;四种结果各有断言 |
| 5.3 `stopFactsFromDropped` 婚恋标签 | 推迟(允许) | 进度记录已写明 |
| BUG-521 / BUG-522 | 通过 | 无案例 ID、无用户资料;复发链接 BUG-440 |
| CHANGELOG、`docs/testing` 清单 | 通过 | 点选入口列为优先手测项 |
| tsc / lint | 通过 | `tsc` 0 错;lint 0 error 74 warning(既有) |
| 定向套件 | **未通过** | `rectification-* / agentic-rectification-* / birth-time-*`871 条,pass 858fail 0**cancelled 4**,进程退出码 1 |
## 1. 事故实证(P1 · 超时测试挂死)
`adopt-narration-agent.ts`
```ts
function composedAbortSignal(signal, timeoutMs) {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
```
Node 的 `AbortSignal.timeout()` 内部计时器是 **unref** 的(所有版本,含 CI 用的 Node 22):它不会让事件循环保持活跃。测试「adopt narration times out to the template without throwing」把 `generateText` 挂成永不 resolve 的 Promise,此时进程里唯一待办就是这个 unref 计时器 → 事件循环直接排空,测试运行器判 `cancelledByParent: Promise resolution is still pending but the event loop has already resolved`。同一文件后面三条用例(`applyCollectFocusDenial …``distinguish declined …``family collect declined vs extra distinguish declined …`——后两条正是 BUG-520 的回归锁)也被一并取消。
本地复现(Node 20.19`npx tsx --test tests/rectification-adopt-narration-20260904.test.ts`):
```
ok 1 … ok 6
not ok 7 - adopt narration times out to the template without throwing (cancelledByParent)
not ok 8 / 9 / 10 (cancelledByParent)
# tests 10 # pass 6 # fail 0 # cancelled 4
exit=1
```
独立验证(去掉所有业务代码):
```js
const s = AbortSignal.timeout(30);
await Promise.race([new Promise(() => {}), new Promise((_, rej) => s.addEventListener("abort", () => rej(s.reason)))]);
// 进程直接退出,退出码 13,abort 从未触发
```
后果:`backend-quality-gate``npm test --prefix frontend` 会以退出码 1 失败,`13dded9f` 不会部署(本单落笔时 staging `/api/health` 仍是 `0aaa0d70`)。
进度记录写的「adopt-narration 10/10、871 pass / 0 fail」与实测不符:Node 摘要行 `# fail 0` 后面还有 `# cancelled 4`,且退出码为 1。
生产侧:`next start` 有监听 socket 撑住事件循环,超时在真实请求里会生效,本单**不是**线上功能故障;但计时器不随模型返回而清理,每次调用都会留一个 8 秒的悬挂计时器。
## 2. 根因
超时用了 `AbortSignal.timeout()`,没意识到它的计时器是 unref 的;测试又依赖它在空事件循环里触发。验收只看了 `# fail`,没看 `# cancelled` 与退出码。
## 3. 决策记录
原任务书与修复单 1 的决策不变。本单不新增产品决策。
## 4. 硬红线
修复单 1 §4 全部沿用。追加:
1. 超时必须用 **ref** 的计时器(`setTimeout` + `AbortController`),并在模型返回、校验完成或外部 signal 触发后 `clearTimeout`;不得靠 `AbortSignal.timeout()`
2. 不得为了让测试过而删掉超时用例或把它改成 `todo` / `skip`
3. 进度记录必须贴 Node 摘要的完整六行(tests / pass / fail / cancelled / skipped / todo)与进程退出码。
## 5. 任务分解
### 5.1 P1 · 超时改为可清理的 ref 计时器(BUG-523
1. `composedAbortSignal` 改为返回 `{ signal, dispose }`:内部 `new AbortController()``setTimeout(() => controller.abort(new DOMException("adopt narration timed out", "TimeoutError")), timeoutMs)`(不 `unref`),若有外部 `signal` 则监听其 `abort` 转发到 controller`dispose()` 清计时器并移除监听。
2. `deliverAdoptNarration``Promise.race``try / finally` 调用 `dispose()`;超时仍归 `template:model_error`(枚举不变)。
3. `whenAborted` 的监听在 `dispose` 时一并移除,避免每次调用留下悬挂监听器。
验收:
- `npx tsx --test tests/rectification-adopt-narration-20260904.test.ts``# tests 10 # pass 10 # cancelled 0`,退出码 0。
- 定向套件 `rectification-* / agentic-rectification-* / birth-time-*``# fail 0 # cancelled 0`,退出码 0。
- `npm test`(全量):与无 Docker 基线一致,即除 `docker ENOENT` 的 25 条外无其它失败,`# cancelled 0`
- 新增一条用例:fake `generateText` 立即返回合法文案时,`deliverAdoptNarration` resolve 后不再有活跃计时器(可用 `setTimeout` 计数或断言 `dispose` 被调用),防止每次调用漏一个 8 秒计时器。
- `docs/BUG_HISTORY.md` 新增 BUG-523,关联 BUG-522;防复发写明「超时不用 `AbortSignal.timeout`」与「验收看 cancelled 与退出码」。
- `PROGRESS-rectification-adopt-narration-fix-20260904.md` 补一节更正:原「10/10、871 pass / 0 fail」实测为 pass 858 / cancelled 4 / exit 1。
## 6. 让步顺序
只有 5.1 一条,必做;没有它 `13dded9f` 进不了 staging。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-adopt-narration-fix2-20260904 .worktrees/rectification-adopt-narration-fix2-20260904 origin/staging
cd .worktrees/rectification-adopt-narration-fix2-20260904/frontend
npx tsx --test tests/rectification-adopt-narration-20260904.test.ts; echo "exit=$?" # 开工前应复现 cancelled 4 / exit 1
```
## 8. BUG 编号起点
截至本单:BUG-522。本单从 **BUG-523** 起;开工时再核对。
@@ -0,0 +1,125 @@
# 任务书 · 采集阶段第三件事没有方向:盘外核对抢跑到已拒答领域,问题不指向任何领域(2026-09-04)
基线:`origin/staging` 代码 `45bdb63e`(文档头 `45e00f46`)。本单只改 `frontend/src/lib/rectification-agentic/v9/method-followup.ts``spoken-prompt.ts``rectification-v9-tools.ts`(读盘投影 + set-focus 校验)与 `agentic-rectification.ts` 提示词,以及对应测试。不改 Python 引擎、不改 `decideRectification` 的采用/确认门、不动 Skill 版本。
`TASK-report-sensitivity-float-crash-20260904.md` 无文件交集,可并行。
## 0. 用户可感知的现象
staging`45bdb63e`)真实会话,出生窗 30 分钟,账本里 2 条带日期事件(事业 1、感情 1)加 1 条只有年份没有日期的感情事:
| 轮 | 用户 | 助手 | 问题 |
| --- | --- | --- | --- |
| 1 | 家人某年采集题答「没有」 | 「你平时主要做什么工作?」 | 可评分事件 2 < 3,缺的是**带年份的第三件事**;职业备注没有日期,答完也不推进 |
| 2 | 说了职业 | 「当前范围还在继续收窄中,接下来我们继续。」+「除了工作这条线,还有哪件事你能记起大概的年份?」 | 第一句是空话(没有任何数值收窄);第二句**没有指向任何领域**,用户不知道该往哪想 |
第 2 轮服务端落下的焦点是 `oos_blind:holdout``intent=out_of_sample_check``domain=family`——**用户上一轮刚拒答的领域**。Agent 拿到的写作提示是「校时还没用过家人这条线……」,它知道家人刚被否,于是把问题改写成不指向任何领域的泛问。
## 1. 事故实证
用该 Case 的 receipt 形状(9 个候选、2 条可评分事件、`event_quality.passed=false, minimum=3, scoreable_event_count=2``oos_blind_prompts=[family, education, finance]`、7 条 varga 对照探针全部 `year=0` 被丢成 `yearless_ungrounded_contrast`)本地重放,隐私数据不入库:
**1a. 工具路径(Agent 轮)选中已拒答领域的盘外核对。** `buildMethodFollowupPlan` 传入 `holdoutValidation: "not_started"`(与 `rectification-v9-tools.ts` `followupPlanForParsed` / 读盘投影一致):
```
declined=[family] → next = { method: oos_blind, intent: out_of_sample_check, domain: family, frame: false }
```
无论账本里有没有职业备注、家人有没有拒答,三种形状结果相同。
**1b. 决定路径(答「没有」那轮)问的是不带日期的职业。** `persistNextInterviewAfterChoice` 不传 `holdoutValidation`,计划走方法覆盖轮转 relationship → career → family(拒答视作 covered)→ **occupation**。学业、财务、搬家这些带年份的领域根本不在这条轮转里。
**1c. 决定路径再走一轮也是泛问。** 职业答完后若仍走决定路径,计划落到 `!meetsAcceptanceEventQuality` 分支:`domain: null``spokenFollowupForUser``GENERIC_COLLECT_QUESTION`(「从你最容易想起来的一件事开始就好……」)。也就是说,两条路径都不会把用户引到一个具体领域。
代码定位(`method-followup.ts` @45bdb63e,按符号):
- `holdoutValidationStatus` / `canAskHoldout``decision-from-dossier.ts`):只要引擎给了 `oos_blind_prompts` 就判 `not_started`,不看 `event_quality`
- `buildMethodFollowupPlan``if (!next && input.holdoutValidation === "not_started")` 分支:直接取 `input.oosBlindPrompts?.[0]`,不过 `declined`,不看 `meetsAcceptanceEventQuality``sessionOutcome === "validate_holdout"` 早退分支同样不过 `declined`
- 方法覆盖轮转(`!relationshipCovered``!careerCovered``!familyCovered``!occupationCovered`)之后才是 `!meetsAcceptanceEventQuality` 的泛问分支,且该分支 `domain: null`
- `exhaustionSpokenCollectFollowup` 已经有正确的顺序(family → education → finance → occupation → health/relocation/career/relationship → other)和按领域的 `datedCollectFollowup`,但只在拒答/穷尽路径被调用,计划主流程不用它。
- `validateSpokenPrompt`:对采集题只查长度、选项字面、内部 token、`domain_mismatch`(仅当 Agent 显式传了不同 `targetDomain`),**不要求题干提到领域**。Agent 把家人题改写成泛问,服务端照收。
## 2. 根因
1. **盘外核对没有前置门。** BUG-396 已钉死「2 条继续收集,训练门只计 training 事件」,但计划层的 OOS 分支只看 `holdoutValidation === "not_started"`,而这个状态只要引擎输出了提示就成立。于是训练门都没开就开始「盘外核对」,并且用的是被 `declinedDomains`BUG-520)明确排除的领域。
2. **计划里没有「补第三件带年份的事」这一步。** 方法覆盖轮转的四个领域里有三个已覆盖或拒答,剩下的职业不带日期;带年份的学业/财务/搬家/健康只存在于穷尽路径。
3. **Agent 的写作提示与用户上下文矛盾,校验器又不要求方向。** 提示说问家人,用户刚否掉家人,Agent 只能写泛问;服务端没有任何一条规则要求采集题写出领域。
## 3. 决策记录(产品负责人 2026-09-04
1. **可评分事件不足 3 条时,不得进入盘外核对(OOS / holdout 提问)。** 计划的两处 OOS 分支都加 `meetsAcceptanceEventQuality` 前置;不满足就当作没有 OOS 提示。这不推翻 BUG-463(holdout 不绑采用门)——本单只管「什么时候问」,不碰 `canAdopt` / `canConfirmExactMinute`
2. **OOS 提示必须跳过已拒答领域**`declinedDomains`,沿用 BUG-520 的 intent 口径)。全部被拒则退到带年份的 holdout 事件,再没有就 `null`
3. **缺第三件事时,先补带年份的领域,再问职业。** 顺序:家人 → 学业 → 财务 → 搬家 → 健康/压力 → 事业 → 感情,跳过已拒答与已有确认证据的领域;全部走完才轮到职业,职业也关了才用泛问。计划主流程与 `exhaustionSpokenCollectFollowup` 共用同一份顺序,不得再复制一份。这条**只在 `!meetsAcceptanceEventQuality` 时生效**,不改 BUG-442/472 关于职业覆盖不挡采用的结论。
4. **采集题必须指向领域,方向由服务端定、话由 Agent 说。** 这是 Agent 产品,题干继续由 Agent 用自己的话写,但服务端校验题干必须包含目标领域的至少一个关键词;不合格返回 `invalid_spoken_prompt: domain_missing`,两次不合格后服务端用 `USER_COLLECT_QUESTION[domain]` 落焦点(现有 `spokenPromptFailures` 机制)。领域为 `other` 的泛问只允许在 §3.3 全部走完之后出现。
5. **进度句必须有数字,没数字就不说。** 「当前范围还在继续收窄中」这种没有数据支撑的句子不得出现。服务端在读盘投影里给出 `collection_progress = { scoreable, minimum, missing }`(来自 receipt `event_quality`),提示词要求:下一问是采集题时,先用一句话说明还差几件带时间的事,数字只来自这个字段;没有这个字段就不说进度。
## 4. 硬红线
1. 不改 `holdoutValidationStatus` 的返回语义、不改 `decideRectification` / `deliveryCapability` 的采用与确认门(BUG-463 防复发条款)。修法在计划层的分支条件,不在决策层。
2. 不改 `declinedDomains` 的 intent 口径(BUG-520)。
3. 顺序只能有一处定义;`exhaustionSpokenCollectFollowup` 与计划主流程必须调用同一个函数。
4. `validateSpokenPrompt` 的领域关键词表放在 `user-copy.ts``agent-voice-lexicon.ts` 旁边,每个领域至少 3 个日常词(例:education → 上学/大学/毕业/考试/学业;finance → 收入/买房/贷款/欠债/钱;relocation → 搬家/住/外地;health_pressure → 生病/受伤/住院/压力;family → 家里/父母/家人/添丁;career → 工作/入职/换工作;relationship → 交往/分手/结婚/感情)。关键词命中是 `includes`,不做分词。不得把这个校验套到区分题(`choice_frame` 非空)或 OOS 题上——它们已有年份校验。
5. 既有断言若要改,写「原值 / 新值 / 原因」三栏。预计要动的:`rectification-server-focus` 中「exhaustion `oos_blind` 仍走 `USER_COLLECT_QUESTION.other`」相关用例(若受顺序合并影响);`rectification-yearless-ungrounded``validate_holdout` 用例需要补足 4 条带日期证据才能维持 `oos_blind` 结论(它现在的 evidence 是 4 条 year 精度 + 1 条职业,`meetsAcceptanceEventQuality` 是否满足由执行方实测后写明)。
6. 任务书、进度记录、Bug 历史、测试 fixture 不得出现该 Case 的 ID、日期、职业描述;fixture 用虚构年份。
7. 进度记录必须贴 `npm test` 摘要六行(tests/pass/fail/cancelled/skipped/todo)与退出码,以及无 Docker 基线失败清单比对(BUG-523 教训)。
## 5. 任务分解
### 5.1 P0 · 盘外核对加前置门并跳过拒答领域(BUG-524)
1. 新增 `holdoutFollowupFor(input, declined)``meetsAcceptanceEventQuality(input.evidence)` 不满足 → `null`;否则取 `oosBlindPrompts` 中第一个 `!declined.has(domain)` 的提示 → `holdoutAskFields`;没有则退到带年份的 holdout 事件;再没有 → `null`
2. `sessionOutcome === "validate_holdout"` 早退与 `holdoutValidation === "not_started"` 分支都改用它。
验收(新文件 `frontend/tests/rectification-collect-direction-20260904.test.ts`,fixture 用 §1 的形状但年份虚构):
- 2 条可评分事件 + `oosBlindPrompts=[family, education, finance]` + family 拒答 + `holdoutValidation: "not_started"``next_followup.intent !== "out_of_sample_check"`
- 4 条可评分事件(3 个领域)+ family 拒答 + `holdoutValidation: "not_started"` 且无可渲染区分探针 → `next_followup``oos_blind``domain === "education"`
- 同上但三个领域全部拒答、无带年份 holdout 事件 → 不出 OOS`sessionOutcome: "validate_holdout"``next_followup === null`
### 5.2 P0 · 补第三件带年份的事进入计划主流程(BUG-525)
1.`exhaustionSpokenCollectFollowup` 里的领域顺序抽成 `datedCollectOrder`(§3.3 顺序,含跳过规则),穷尽路径与计划主流程共用。
2. `buildMethodFollowupPlan`:在方法覆盖轮转之前、OOS 分支之后加一步:`!next && !meetsAcceptanceEventQuality(input.evidence)` → 走 `datedCollectOrder`,取到带领域的 `datedCollectFollowup`;没有再进原轮转(职业)与原泛问分支。
3. `spokenFollowupForUser` 对这些 followup 必须返回 `USER_COLLECT_QUESTION[domain]`(不是 `GENERIC_COLLECT_QUESTION`),这是决定路径的题干。
验收:
- §1 形状、family 拒答、`holdoutValidation: "not_started"``next_followup = { intent: collect_method_evidence, domain: education }``spokenFollowupForUser` 含「上学」或「升学」。
- 再把 education 也拒答 → `domain: finance`family/education/finance/relocation/health_pressure 全拒答且 career/relationship 已有确认证据 → `domain: occupation`;职业也关闭 → 才出 `domain: other`
- `persistNextInterviewAfterChoice`(答「没有」路径,形状同 `rectification-adopt-narration-20260904.test.ts` 的 denial 用例)在 2 条可评分事件下落下的焦点 `target_domain` 为 education 且 prompt 为 `USER_COLLECT_QUESTION.education`;零次 `decideFromDossier`(沿用 BUG-521 源码断言)。
- 4 条可评分事件时本分支不触发(`rectification-collect-stall``rectification-eight-method` 全绿;BUG-442/472 的采用出口用例不变)。
### 5.3 P1 · 采集题干必须指向领域(BUG-526)
1. `validateSpokenPrompt`:当 `followup.intent === "collect_method_evidence"``followup.domain` 在关键词表内且 `!followup.choice_frame` 时,题干必须命中该领域至少一个关键词,否则 `{ ok: false, reason: "domain_missing" }`
2. `rectification-set-focus` 描述加一句:「采集题必须写出服务端给你的领域(学业/家里/钱/搬家……),不要写成『随便哪件事』」。
3. 第二次仍不合格时的服务端兜底沿用现有 `spokenPromptFailures`;确认兜底题干是 `USER_COLLECT_QUESTION[domain]` 而不是泛问。
验收:`rectification-spoken-prompt`(或新文件):education 题写「除了工作,还有哪件事记得年份?」→ `domain_missing`;写「上学那会儿,哪年升学或大考还记得吗?」→ ok;区分题与 OOS 题不受影响;`rectification-set-focus` 集成用例两次泛问后焦点 prompt 为 `USER_COLLECT_QUESTION.education`
### 5.4 P2 · 进度句有数字才说(BUG-527)
1. 读盘投影(`rectification-read-case` 与 route 的 interview 投影)加 `collection_progress: { scoreable, minimum, missing }`,来自 `decisionReceipt.gates.event_quality`;缺字段时为 `null`
2. `agentic-rectification.ts` 提示词:「下一问是采集题时,先用一句说明还差几件带时间的事,数字只用 `collection_progress`;没有该字段不说进度。不得写『范围在收窄』『继续收窄』这类没有数字的进度句。」把「继续收窄」「范围还在」加进 `MACHINE_VOICE_LEXICON` 只对旁白校验生效(若旁白无校验,则只改提示词并在进度记录写明)。
验收:投影测试锁 `collection_progress` 取值(2/3/1)与缺 gate 时 `null`;提示词测试断言含「collection_progress」与「不得」句。
## 6. 让步顺序
1. 5.1 与 5.2 必做,缺一条用户仍然拿到没方向的问题。
2. 5.3 可以退到只加提示词 + 兜底题干为领域题,但校验器改动是防复发的钉子,不做要在进度记录写明理由。
3. 5.4 可推迟。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-collect-direction-20260904 .worktrees/rectification-collect-direction-20260904 origin/staging
cd .worktrees/rectification-collect-direction-20260904/frontend
./node_modules/.bin/tsc --noEmit
npx tsx --test tests/rectification-eight-method.test.ts tests/rectification-yearless-ungrounded.test.ts tests/rectification-server-focus.test.ts tests/rectification-collect-stall.test.ts tests/rectification-adopt-narration-20260904.test.ts tests/rectification-answer-choice.test.ts
```
记下开工时这批套件的 tests/pass/fail 数与全量 `npm test` 的失败清单(无 Docker 基线 25 条),交付时逐条比对。
## 8. BUG 编号起点
截至本单:BUG-523。本单从 **BUG-524** 起(5.1 → 5245.2 → 5255.3 → 5265.4 → 527);开工时再核对 `docs/BUG_HISTORY.md` 最大号。BUG-524 关联 BUG-396(训练门未开不得进入 holdout)与 BUG-520(拒答口径);BUG-525 关联 BUG-442/472(职业覆盖)与 BUG-426(计划回落到带年份的采集题)。
@@ -6,13 +6,20 @@
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
## 1. 探针池耗尽后出现采用卡(P0 · BUG-519)
## 1. 探针池耗尽后出现采用卡(P0 · BUG-519 / BUG-521
1. 新开一个生时校正会话,按平常走完区分题,直到系统再问一道口述采集(家里结婚/添丁/住院这类)。
2. 对那道采集题回答「没有」。
- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;composer **没有**再冒出开场题或新的点选卡
- ✅ 预期:旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「可以从下面选一个先用着」收尾
- ❌ 改造前:旁白是「我按现有材料继续往下收」,采用卡却已经出来
优先测这条(最常见入口):
1. 新开一个生时校正会话,按平常走完区分题
2. **点选最后一道区分题**(不要用采集题「没有」代替)
- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「可以从下面选一个先用着」收尾
- ❌ 改造前(BUG-521):点选入口旁白仍是模板句,Agent 没上场。
采集题路径仍要能走:
1. 若系统再问一道口述采集(家里结婚/添丁/住院这类),回答「没有」。
- ✅ 预期:同一轮出现采用卡;composer **没有**再冒出开场题或新的点选卡;旁白同样说清为什么停。
- ❌ 更早改造前:旁白是「我按现有材料继续往下收」,采用卡却已经出来。
## 2. 采用后进入核对(P0 · 与 BUG-501/502 衔接)
+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; 1213px 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; 1213px 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 1213px.
- **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.
@@ -13,6 +13,7 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
import {
applyRectificationChoice,
applyCollectFocusDenial,
persistCollectDenialTurn,
persistNextInterviewIfIdle,
} from "@/lib/rectification-agentic/v9/answer-choice";
import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent";
@@ -52,10 +53,18 @@ import {
export const runtime = "nodejs";
export const maxDuration = 240;
function completedMessageResponse(text: string, requestId: string, caseId: string) {
function completedMessageResponse(
text: string,
requestId: string,
caseId: string,
turnId?: string | null,
) {
const completed = turnId
? { type: "run.completed", turnId }
: { type: "run.completed" };
const body = [
JSON.stringify({ type: "answer.delta", text }),
JSON.stringify({ type: "run.completed" }),
JSON.stringify(completed),
"",
].join("\n");
return new Response(body, {
@@ -363,12 +372,12 @@ export async function POST(request: Request) {
}
if (!classified || classified.intent === "unclear") {
const narration = RECTIFICATION_USER_COPY.unclearFocusReply;
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId);
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (classified.intent === "answer_current_focus") {
if (!classified.answer_class) {
@@ -398,7 +407,7 @@ export async function POST(request: Request) {
narrateAdopt,
});
if (!continueToAgent) {
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
}
if (classified.intent === "stop_rectification") {
@@ -419,7 +428,7 @@ export async function POST(request: Request) {
userDisplay: parsed.data.message ?? null,
});
await transitionV9CaseStatus(accounting, userId, caseId, "paused");
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema)) {
let classified = null;
@@ -443,12 +452,15 @@ export async function POST(request: Request) {
narrateAdopt,
});
if (!continueToAgent) {
await persistV9DeterministicTurn(accounting, userId, caseId, {
const finished = await persistCollectDenialTurn({
accounting,
userId,
caseId,
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: applied.narration,
applied,
});
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(finished.streamText, requestId, caseId, finished.turnId);
}
}
} else {
@@ -468,12 +480,12 @@ export async function POST(request: Request) {
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage,
});
return completedMessageResponse(assistantMessage, requestId, caseId);
return completedMessageResponse(assistantMessage, requestId, caseId, turn.turnId);
}
if (decision.nextAction === "ask_candidate_discriminator") {
const catalog = rectificationFollowupCatalog(
@@ -529,16 +541,16 @@ export async function POST(request: Request) {
expectedRevision: previous?.revision ?? 0,
userDisplay: parsed.data.message ?? null,
});
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
}
const narration = RECTIFICATION_USER_COPY.choicePrompt;
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId);
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (!plan.next_followup) {
const idle = await persistNextInterviewIfIdle({
@@ -548,12 +560,12 @@ export async function POST(request: Request) {
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage,
});
return completedMessageResponse(assistantMessage, requestId, caseId);
return completedMessageResponse(assistantMessage, requestId, caseId, turn.turnId);
}
}
}
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>
)}
@@ -645,7 +645,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
if (nextQuestion !== null) setQuestionRetryAttempts(0);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
@@ -62,6 +62,7 @@ export const RECTIFICATION_USER_COPY = {
adoptCue: "可以从下面选一个先用着。",
hostNarrationFallback: "我按现有材料继续往下收。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
} as const;
@@ -216,6 +217,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
...Object.values(USER_COLLECT_QUESTION),
@@ -17,6 +17,85 @@ import {
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
2 4
使
`;
export type AdoptNarrationOutcome =
| "agent"
| `template:${string}`;
export type AdoptNarrationDelivery = Readonly<{
text: string;
adopt_narration: AdoptNarrationOutcome;
}>;
type DisposableAbort = Readonly<{
signal: AbortSignal;
dispose: () => void;
}>;
function composedAbortSignal(
signal: AbortSignal | undefined,
timeoutMs: number,
): DisposableAbort {
const controller = new AbortController();
// Must stay ref'd. The platform timeout signal uses an unref timer, so a hanging
// generateText lets the event loop drain before abort (BUG-523).
const timeoutId = globalThis.setTimeout(() => {
if (!controller.signal.aborted) {
controller.abort(new DOMException("adopt narration timed out", "TimeoutError"));
}
}, timeoutMs);
const onExternalAbort = () => {
if (!controller.signal.aborted) {
controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError"));
}
};
if (signal) {
if (signal.aborted) onExternalAbort();
else signal.addEventListener("abort", onExternalAbort);
}
return {
signal: controller.signal,
dispose: () => {
globalThis.clearTimeout(timeoutId);
signal?.removeEventListener("abort", onExternalAbort);
},
};
}
function whenAborted(signal: AbortSignal): {
promise: Promise<never>;
dispose: () => void;
} {
let onAbort: (() => void) | undefined;
const promise = new Promise<never>((_, reject) => {
const fail = () => {
reject(signal.reason ?? new Error("aborted"));
};
if (signal.aborted) {
fail();
return;
}
onAbort = fail;
signal.addEventListener("abort", fail, { once: true });
});
return {
promise,
dispose: () => {
if (onAbort) signal.removeEventListener("abort", onAbort);
},
};
}
export function logAdoptNarrationOutcome(outcome: AdoptNarrationOutcome): void {
console.info(`[rectification-v9] adopt_narration=${outcome}`);
}
export async function generateAdoptNarrationText(
model: ResolvedLanguageModel,
facts: AdoptDeliveryFacts,
@@ -26,10 +105,7 @@ export async function generateAdoptNarrationText(
id: `rectification-adopt-narration-${model.id}`,
name: "Rectification Adopt Narration",
model: model.model,
instructions: `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
2 4
使
`,
instructions: ADOPT_NARRATION_INSTRUCTIONS,
});
const result = await agent.generate([{
role: "user",
@@ -59,48 +135,87 @@ export async function deliverAdoptNarration(input: {
fallback: string;
model?: ResolvedLanguageModel | null;
signal?: AbortSignal;
timeoutMs?: number;
generateText?: (
facts: AdoptDeliveryFacts,
signal?: AbortSignal,
) => Promise<string>;
}): Promise<string> {
if (!shouldWriteAdoptNarration(input.facts)) return input.fallback;
}): Promise<AdoptNarrationDelivery> {
if (!shouldWriteAdoptNarration(input.facts)) {
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
}
const generate = input.generateText ?? (input.model
? (facts: AdoptDeliveryFacts, signal?: AbortSignal) => (
generateAdoptNarrationText(input.model as ResolvedLanguageModel, facts, signal)
)
: null);
if (!generate) return input.fallback;
if (!generate) {
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
}
const composed = composedAbortSignal(
input.signal,
input.timeoutMs ?? ADOPT_NARRATION_TIMEOUT_MS,
);
const aborted = whenAborted(composed.signal);
try {
const text = await generate(input.facts, input.signal);
const text = await Promise.race([
generate(input.facts, composed.signal),
aborted.promise,
]);
const checked = validateAdoptNarration(text, input.facts);
if (!checked.ok) return input.fallback;
return appendAdoptCue(checked.text);
if (!checked.ok) {
const delivery = {
text: input.fallback,
adopt_narration: `template:${checked.reason}` as const,
};
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
}
const delivery = {
text: appendAdoptCue(checked.text),
adopt_narration: "agent" as const,
};
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
} catch {
return input.fallback;
const delivery = { text: input.fallback, adopt_narration: "template:model_error" as const };
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
} finally {
aborted.dispose();
composed.dispose();
}
}
export function createAdoptNarrationWriter(input: {
model?: ResolvedLanguageModel | null;
signal?: AbortSignal;
timeoutMs?: number;
resolveModel?: () => Promise<ResolvedLanguageModel | null>;
generateText?: (
facts: AdoptDeliveryFacts,
signal?: AbortSignal,
) => Promise<string>;
}): AdoptNarrationWriter {
return async (facts, fallback) => deliverAdoptNarration({
facts,
fallback,
model: input.model,
signal: input.signal,
generateText: input.generateText ?? (input.resolveModel
? async (nextFacts, signal) => {
const model = input.model ?? await input.resolveModel!();
if (!model) throw new Error("adopt_narration_model_unavailable");
return generateAdoptNarrationText(model, nextFacts, signal);
}
: undefined),
});
return async (facts, fallback) => {
const delivered = await deliverAdoptNarration({
facts,
fallback,
model: input.model,
signal: input.signal,
timeoutMs: input.timeoutMs,
generateText: input.generateText ?? (input.resolveModel
? async (nextFacts, signal) => {
const model = input.model ?? await input.resolveModel!();
if (!model) throw new Error("adopt_narration_model_unavailable");
return generateAdoptNarrationText(model, nextFacts, signal);
}
: undefined),
});
return delivered.text;
};
}
@@ -53,6 +53,7 @@ import {
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
import { composeCollectSpokenAssistantText } from "./collect-prompt";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
@@ -141,6 +142,23 @@ function adoptHostNarration(input: {
}), input.receipt);
}
function dossierWithCurrentInference(
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"],
state: InferenceState | null,
): Parameters<typeof decideAfterInferenceChange>[0]["dossier"] {
if (!state) return dossier;
return {
...dossier,
latestResult: {
...(dossier.latestResult ?? {}),
decisionReceipt: {
...(dossier.latestResult?.decisionReceipt ?? {}),
inference_state: state,
},
},
};
}
export type ApplyChoiceCommand = Readonly<{
userId: string;
caseId: string;
@@ -175,6 +193,7 @@ export type AppliedChoiceReceipt = Readonly<{
nextAction: ReturnType<typeof publicNextAction>;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
turnId: string | null;
}>;
function asText(value: unknown): string | null {
@@ -390,6 +409,7 @@ export async function persistNextInterviewAfterChoice(input: {
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
decisionState: InferenceState | null;
nextAction: ReturnType<typeof publicNextAction>;
decision?: ReturnType<typeof decideAfterInferenceChange>;
birthDate?: string | null;
askedTurnId?: string | null;
narrateAdopt?: AdoptNarrationWriter;
@@ -412,6 +432,13 @@ export async function persistNextInterviewAfterChoice(input: {
decisionReceipt: input.decisionState ? { inference_state: input.decisionState } : null,
};
const birthDate = input.birthDate ?? null;
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
const decision = input.decision ?? decideAfterInferenceChange({
dossier: input.dossier,
state: input.decisionState,
userStopped: false,
birthDate,
});
const catalog = rectificationFollowupCatalog(latest, input.dossier.evidence);
const sessionOutcome = typeof input.nextAction.session_outcome === "string"
? input.nextAction.session_outcome as SessionOutcomeKind
@@ -435,12 +462,11 @@ export async function persistNextInterviewAfterChoice(input: {
followup,
methods: plan.methods,
})) {
const decision = decideFromDossier(input.dossier, { birthDate });
const facts = adoptDeliveryFacts(decision, input.dossier);
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
dossier: input.dossier,
dossier: liveDossier,
decision,
receipt: input.dossier.latestResult?.decisionReceipt,
receipt: liveDossier.latestResult?.decisionReceipt,
});
return {
hostNarration: input.narrateAdopt
@@ -619,7 +645,12 @@ export async function applyCollectFocusDenial(
deferFollowup?: boolean;
narrateAdopt?: AdoptNarrationWriter;
},
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
): Promise<{
narration: string;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
focus: ConversationFocus | null;
}> {
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
if (!focus || focus.id !== input.focusId) {
@@ -632,9 +663,10 @@ export async function applyCollectFocusDenial(
});
if (input.deferFollowup === true) {
return {
narration: "记下了,这方面先跳过。",
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
nextInterviewPersisted: false,
nextChoiceReady: false,
focus: null,
};
}
let birthDate: string | null = null;
@@ -660,7 +692,8 @@ export async function applyCollectFocusDenial(
},
},
}, "declined");
const nextAction = publicNextAction(decideFromDossier(withDeclined, { birthDate }));
const decision = decideFromDossier(withDeclined, { birthDate });
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
userId: input.userId,
@@ -668,6 +701,7 @@ export async function applyCollectFocusDenial(
dossier: withDeclined,
decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null),
nextAction,
decision,
birthDate,
narrateAdopt: input.narrateAdopt,
});
@@ -675,9 +709,57 @@ export async function applyCollectFocusDenial(
narration: nextInterview.hostNarration,
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
nextChoiceReady: nextInterview.choiceReady,
focus: nextInterview.focus ?? null,
};
}
export type CollectDenialApplied = Awaited<ReturnType<typeof applyCollectFocusDenial>>;
/**
* After a collect "没有", persist the deterministic turn then bind the next
* focus to that turn so the stem can hang on the message. Stream only the
* acknowledgment; the server-owned stem joins via asked_turn_id (BUG-525).
*/
export async function persistCollectDenialTurn(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
userMessage: string | null;
applied: CollectDenialApplied;
}): Promise<{ streamText: string; turnId: string }> {
const hasNextStem = Boolean(
input.applied.focus
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
&& input.applied.narration.trim(),
);
const stored = hasNextStem
? composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, input.applied.narration)
: input.applied.narration;
const streamText = hasNextStem ? RECTIFICATION_USER_COPY.collectDeclinedAck : input.applied.narration;
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: input.requestId,
userMessage: input.userMessage,
assistantMessage: stored,
});
if (input.applied.focus) {
try {
await linkFocusAskedTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
focus: input.applied.focus,
askedTurnId: turn.turnId,
});
} catch (error) {
console.warn(
`[rectification-v9] link collect-denial focus to turn failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
}
}
return { streamText, turnId: turn.turnId };
}
export function isStalePreAdoptFocus(
acceptedTime: string | null | undefined,
focus: { intent?: string | null } | null | undefined,
@@ -793,6 +875,7 @@ export async function persistNextInterviewIfIdle(input: {
dossier,
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
nextAction,
decision,
birthDate,
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
@@ -919,6 +1002,7 @@ async function persistApplied(
let hostNarration = input.narration;
let skippedNextInterview = false;
let nextFocus: ConversationFocus | null = null;
let turnId: string | null = null;
if (
command.deferFollowup !== true
&& input.userStopped !== true
@@ -931,6 +1015,7 @@ async function persistApplied(
dossier: dossierWithClosedFocus(input.dossier, input.focusStatus),
decisionState: input.decisionState ?? null,
nextAction,
decision: nextDecision,
birthDate,
narrateAdopt: command.narrateAdopt,
});
@@ -946,8 +1031,9 @@ ${nextInterview.hostNarration}`;
nextInterviewPersisted = true;
}
}
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
const adoptionFacts = nextAction.can_adopt
? adoptDeliveryFacts(nextDecision, input.dossier)
? adoptDeliveryFacts(nextDecision, liveDossier)
: null;
const adoptionNarration = nextAction.can_adopt
? withProspectiveWindows(deliveryAdoptNarration({
@@ -956,7 +1042,7 @@ ${nextInterview.hostNarration}`;
openingRange: openingRangeFromDossier(input.dossier),
stopReason: nextAction.stop_reason,
stopExplain: adoptionFacts ? templateStopExplain(adoptionFacts) : null,
}), input.dossier.latestResult?.decisionReceipt)
}), liveDossier.latestResult?.decisionReceipt)
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
const completedRangeNarration = nextAction.type === "complete_with_range"
@@ -987,6 +1073,7 @@ ${nonConvergingRangeNarration({
});
narrationPersisted = true;
if (turn.turnId) {
turnId = turn.turnId;
try {
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus;
@@ -1042,6 +1129,7 @@ ${nonConvergingRangeNarration({
nextAction,
nextInterviewPersisted,
nextChoiceReady,
turnId,
};
}
@@ -96,6 +96,7 @@ test("delivery range narration puts the stop reason before the progress clause",
assert.match(text, /已经从最初的 60 分钟收到 14:0214:43 这 41 分钟/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectDeclinedAck));
});
test("question stem ownership stays on set-focus spokenPrompt, not a slot or a second turn", () => {
+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", () => {
@@ -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/);
});
@@ -8,8 +8,13 @@ import type { ConflictProbe, InferenceState } from "../src/lib/rectification-age
import {
adoptDeliveryFacts,
validateAdoptNarration,
type AdoptDeliveryFacts,
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
import { createAdoptNarrationWriter } from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts";
import {
ADOPT_NARRATION_INSTRUCTIONS,
createAdoptNarrationWriter,
deliverAdoptNarration,
} from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts";
import {
applyCollectFocusDenial,
persistNextInterviewAfterChoice,
@@ -426,11 +431,34 @@ function fourteenProbeState(): InferenceState {
};
}
function preClickFourteenProbeState(): InferenceState {
const last = ANSWERED[ANSWERED.length - 1]!;
const state = fourteenProbeState();
return {
...state,
revision: 5,
answered_probes: state.answered_probes.filter((item) => item.probe_id !== last.id),
candidates: state.candidates.map((item) => {
if (item.time === "05:00") {
return { ...item, prior_score: 23, posterior_score: 23 };
}
if (item.time === "05:06") {
return { ...item, prior_score: 16, posterior_score: 16 };
}
if (item.time === "04:53") {
return { ...item, prior_score: 7, posterior_score: 7 };
}
return item;
}),
};
}
function caseDossier(extra?: {
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
acceptedTime?: string | null;
state?: InferenceState;
}): DecisionDossier {
const state = fourteenProbeState();
const state = extra?.state ?? fourteenProbeState();
return {
evidence: EVIDENCE,
conversationSummary: {
@@ -700,6 +728,14 @@ test("adopt narration agent keeps in-fact copy and fail-closes the rest", async
})(facts, fallback);
assert.equal(unknownSupport, fallback);
assert.equal(validateAdoptNarration("相对支持度 99,先用 05:00。", facts).ok, false);
const deniedPromise = "这不是确认的分钟,先用 05:00。";
assert.equal(validateAdoptNarration(deniedPromise, facts).ok, false);
const denied = await createAdoptNarrationWriter({
generateText: async () => deniedPromise,
})(facts, fallback);
assert.equal(denied, fallback);
assert.match(ADOPT_NARRATION_INSTRUCTIONS, /不要出现/);
});
test("three adopt entry points call the model once on first ready_to_adopt and not after accept", async () => {
@@ -709,36 +745,79 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
assert.match(route, /applyCollectFocusDenial\([\s\S]*narrateAdopt/);
assert.match(route, /persistNextInterviewIfIdle\(\{[\s\S]*narrateAdopt/);
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 = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const preClick = caseDossier({ state: preClickFourteenProbeState() });
const preClickDecision = decideFromDossier(preClick, { birthDate: "1997-08-08" });
assert.notEqual(preClickDecision.precisionStage, "ready_to_adopt");
assert.ok(preClickDecision.probe);
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
function withCounter() {
let calls = 0;
const seen: AdoptDeliveryFacts[] = [];
const narrateAdopt = createAdoptNarrationWriter({
generateText: async () => {
generateText: async (facts) => {
calls += 1;
seen.push(facts);
return kept;
},
});
return { narrateAdopt, count: () => calls };
return { narrateAdopt, count: () => calls, seen };
}
const afterChoice = withCounter();
const accountingA = adoptAccounting(dossier);
const accountingA = adoptAccounting(preClick);
await persistNextInterviewAfterChoice({
accounting: accountingA.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
dossier: preClick,
decisionState: fourteenProbeState(),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
narrateAdopt: afterChoice.narrateAdopt,
});
assert.equal(afterChoice.count(), 1);
assert.equal(afterChoice.seen[0]?.representative_minute, "05:00");
assert.equal(afterChoice.seen[0]?.precision_stage, "ready_to_adopt");
assert.deepEqual(
afterChoice.seen[0]?.active_candidates.slice(0, 3).map((item) => [item.time, item.relative_support]),
[["05:00", 21], ["05:06", 18], ["04:53", 9]],
);
assertNoFocusWrite(accountingA);
const invalidChoice = withCounter();
const invalidWriter = createAdoptNarrationWriter({
generateText: async (facts) => {
invalidChoice.seen.push(facts);
return "更像 04:58,不要再问了。";
},
});
const invalidAccounting = adoptAccounting(preClick);
const invalid = await persistNextInterviewAfterChoice({
accounting: invalidAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier: preClick,
decisionState: fourteenProbeState(),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
narrateAdopt: invalidWriter,
});
assertAdoptTemplate(invalid.hostNarration);
assert.match(invalid.hostNarration, /05:00/);
assert.doesNotMatch(invalid.hostNarration, /04:58/);
const afterDenial = withCounter();
let loads = 0;
const accountingB = fakeAccounting({
@@ -807,6 +886,106 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
assert.equal(accepted.count(), 0);
});
test("adopt narration diagnostics distinguish agent, validation, error, and not-ready", async () => {
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
const agent = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => kept,
});
assert.equal(agent.adopt_narration, "agent");
assert.match(agent.text, /05:00/);
const invalid = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => "更像 04:58,不要再问了。",
});
assert.equal(invalid.adopt_narration, "template:unknown_minute");
assert.equal(invalid.text, fallback);
const errored = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => {
throw new Error("model down");
},
});
assert.equal(errored.adopt_narration, "template:model_error");
assert.equal(errored.text, fallback);
const notReady = await deliverAdoptNarration({
facts: { ...facts, already_accepted: true },
fallback,
generateText: async () => kept,
});
assert.equal(notReady.adopt_narration, "template:not_ready");
assert.equal(notReady.text, fallback);
});
test("adopt narration times out to the template without throwing", async () => {
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const timed = await deliverAdoptNarration({
facts,
fallback,
timeoutMs: 30,
generateText: async () => new Promise(() => {}),
});
assert.equal(timed.adopt_narration, "template:model_error");
assert.equal(timed.text, fallback);
});
test("adopt narration does not leave an active timeout after the model returns", async () => {
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
const source = readFileSync(
new URL("../src/lib/rectification-agentic/v9/adopt-narration-agent.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(source, /AbortSignal\.timeout/);
assert.match(source, /clearTimeout/);
const pending = new Set<unknown>();
let created = 0;
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
globalThis.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
created += 1;
const id = realSetTimeout(handler, delay, ...args);
pending.add(id);
return id;
}) as typeof setTimeout;
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
pending.delete(id);
realClearTimeout(id);
}) as typeof clearTimeout;
try {
const delivered = await deliverAdoptNarration({
facts,
fallback,
timeoutMs: 8_000,
generateText: async () => kept,
});
assert.equal(delivered.adopt_narration, "agent");
assert.ok(created >= 1);
assert.equal(pending.size, 0);
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
}
});
test("applyCollectFocusDenial on the family collect uses the same adopt template", async () => {
const dossier = caseDossier();
let loads = 0;
@@ -24,12 +24,19 @@ import {
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import {
applyCollectFocusDenial,
persistCollectDenialTurn,
persistNextInterviewAfterChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
RECTIFICATION_USER_COPY,
USER_COLLECT_QUESTION,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
ATTEMPT_ID,
CASE_ID,
FOCUS_ID,
TURN_ID,
@@ -642,6 +649,96 @@ test("occupation collect denial declines the focus and advances coverage to hora
assert.equal(horary.next_followup?.domain, "horary");
});
test("collect denial persists the next stem on the turn and binds asked_turn_id", async () => {
const familyFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: "collect:family:collect_method_evidence",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "family",
target_kind: null,
expected_answer_schema: {
prompt: "2021 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。",
collect: true,
},
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
};
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) {
return rpcDossier(revision5Dossier(revision5State()), familyFocus);
}
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}));
},
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
focus_id: args.p_focus_id,
status: args.p_status,
evidence_id: null,
idempotent: false,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "acacacac-acac-4cac-8cac-acacacacacac",
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-08-29T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
append_agentic_rectification_turn: () => ({
turn_id: TURN_ID,
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
});
assert.equal(applied.nextInterviewPersisted, true);
assert.ok(applied.focus);
assert.equal(applied.narration, USER_COLLECT_QUESTION.occupation);
const finished = await persistCollectDenialTurn({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: ATTEMPT_ID,
userMessage: "没有",
applied,
});
assert.equal(finished.turnId, TURN_ID);
assert.equal(finished.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
const stored = composeCollectSpokenAssistantText(
RECTIFICATION_USER_COPY.collectDeclinedAck,
USER_COLLECT_QUESTION.occupation,
);
const append = accounting.calls.find((item) => item.fn === "append_agentic_rectification_turn");
assert.equal(append?.args.p_assistant_message, stored);
assert.ok(accounting.calls.some((item) => (
item.fn === "set_agentic_rectification_conversation_focus"
&& item.args.p_asked_turn_id === TURN_ID
)));
});
test("message and opening turns persist the next followup so current_question is not null", async () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
@@ -1162,10 +1259,19 @@ test("has_new_dated_event continues into the agent after applying the answer", (
fastPath.indexOf("} else {"),
);
assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)"));
assert.match(collectApply, /persistV9DeterministicTurn/);
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn"));
assert.match(collectApply, /persistCollectDenialTurn/);
assert.match(collectApply, /completedMessageResponse\(finished\.streamText, requestId, caseId, finished\.turnId\)/);
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistCollectDenialTurn"));
assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({"));
assert.match(route, /function completedMessageResponse\([\s\S]*?turnId\?: string \| null/);
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const persistDenial = answerChoice.slice(
answerChoice.indexOf("export async function persistCollectDenialTurn"),
answerChoice.indexOf("export function isStalePreAdoptFocus"),
);
assert.match(persistDenial, /composeCollectSpokenAssistantText\(RECTIFICATION_USER_COPY\.collectDeclinedAck/);
assert.match(persistDenial, /linkFocusAskedTurn/);
assert.match(persistDenial, /streamText = hasNextStem \? RECTIFICATION_USER_COPY\.collectDeclinedAck/);
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
assert.match(persistApplied, /command\.deferFollowup !== true/);
assert.equal(shouldContinueAgentForDatedEvent({
@@ -1250,6 +1356,8 @@ test("collect denial with a new dated event does not persist the next interview
false,
);
assert.equal(applied.nextInterviewPersisted, false);
assert.equal(applied.focus, null);
assert.equal(applied.narration, RECTIFICATION_USER_COPY.collectDeclinedAck);
});
test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => {
@@ -7,9 +7,14 @@ import {
copyTextForMessage,
parseTurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { persistCollectDenialTurn, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { RECTIFICATION_AGENT_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
GENERIC_COLLECT_QUESTION,
RECTIFICATION_USER_COPY,
USER_COLLECT_QUESTION,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
FOCUS_ID,
@@ -264,3 +269,86 @@ test("refresh rebuilds answered and live questions from asked_turn_id only", ()
assert.equal(attached[2]?.question?.answer_option, null);
assert.equal(attached[2]?.text, "2016 年入学记下了。");
});
const DENIAL_REQUEST_ID = "99999999-9999-4999-8999-999999999999";
test("collect denial persists ack+stem then binds asked_turn_id; stream is ack only", async () => {
const prompt = USER_COLLECT_QUESTION.occupation;
const accounting = fakeAccounting({
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
...activeFocusFixture({
questionId: String(args.p_question_id),
intent: String(args.p_intent),
askedTurnId: String(args.p_asked_turn_id ?? ""),
}),
asked_turn_id: args.p_asked_turn_id,
},
idempotent: false,
}),
});
const result = await persistCollectDenialTurn({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: DENIAL_REQUEST_ID,
userMessage: "没有",
applied: {
narration: prompt,
nextInterviewPersisted: true,
nextChoiceReady: false,
focus: {
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:occupation:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "other",
targetKind: null,
expectedAnswerSchema: { prompt, collect: true },
status: "active",
askedAt: "2026-09-04T07:47:24.000Z",
resolvedAt: null,
askedTurnId: null,
answerOption: null,
},
},
});
assert.equal(result.turnId, TURN_ID);
assert.equal(result.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
assert.notEqual(result.streamText, prompt);
const stored = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(
stored?.args.p_assistant_message,
composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, prompt),
);
const linked = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.equal(linked?.args.p_asked_turn_id, TURN_ID);
assert.ok(accounting.calls.findIndex((call) => call.fn === "append_agentic_rectification_turn")
< accounting.calls.findIndex((call) => call.fn === "set_agentic_rectification_conversation_focus"));
});
test("collect denial without a next stem still writes a turn and does not invent a focus link", async () => {
const accounting = fakeAccounting({
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
const result = await persistCollectDenialTurn({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: DENIAL_REQUEST_ID,
userMessage: "没有",
applied: {
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
nextInterviewPersisted: false,
nextChoiceReady: false,
focus: null,
},
});
assert.equal(result.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
assert.equal(
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
@@ -80,8 +80,10 @@ test("the question gap is a live row with retries, then a reload; it never tells
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
// Attempts reset in handlers (a question arriving, a turn starting), never in an effect.
assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
// Attempts reset when a turn starts, never because the snapshot merely names a
// current_question. Naming a question that no settled message carries live is
// the gap itself (BUG-525); resetting here spun "正在准备下一个问题…" forever.
assert.doesNotMatch(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
// In flow: the gap is the last entry of the transcript, after the message loop, before the saved-time line.
+15 -2
View File
@@ -2144,6 +2144,17 @@ def _join_foreground_vedastro(future, *, timeout: float) -> dict:
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason='gateway_invocation_error')
def _blocked_birth_time_sensitivity(*, error_type: str) -> dict:
return {
'schema': 'jyotish.report_birth_time_sensitivity.v1',
'status': 'not_applicable',
'availability': 'not_available',
'blocked': True,
'reason': 'birth_time_sensitivity_unavailable',
'error_type': error_type,
}
def execute_consultation_workflow(
handler,
*,
@@ -2158,8 +2169,10 @@ def execute_consultation_workflow(
sensitivity_args = type('BirthTimeSensitivityArgs', (), birth_payload)()
try:
birth_time_sensitivity = _load_local_module('jyotish_engine')._build_birth_time_sensitivity(sensitivity_args)
except ValueError as exc:
raise BadRequest(str(exc)) from exc
except BadRequest:
raise
except Exception as exc:
birth_time_sensitivity = _blocked_birth_time_sensitivity(error_type=type(exc).__name__)
themes = handler._high_rigor_requested_themes(body)
events = handler._high_rigor_events(body)
question = body.get('question') or ''
+8 -1
View File
@@ -9749,7 +9749,14 @@ def _birth_time_string(hour, minute, second=0):
def _birth_datetime_from_args(args):
return datetime(args.year, args.month, args.day, args.hour, args.minute, _arg_second(args))
return datetime(
int(args.year),
int(args.month),
int(args.day),
int(args.hour),
int(args.minute),
_arg_second(args),
)
def _compute_chart_from_args(args):
+2
View File
@@ -79,6 +79,8 @@ CORE_PYTEST_TARGETS = [
"tests/test_api_heavy_compute_gate.py",
# Upstream-sync acceptance regressions must fail the automatic staging gate.
"tests/test_consultation_workflow_domains.py",
# Float hour/minute from the API payload must not 500 consultation_workflow (BUG-524).
"tests/test_consultation_workflow_birth_time_sensitivity.py",
"tests/test_mcp_strict_workflow_finance.py",
"tests/test_interpretation_template_registry.py",
"tests/test_vedastro_external_technique_evidence.py",
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Regression: API float hour/minute must not crash consultation_workflow."""
from __future__ import annotations
from datetime import datetime
import os
import sys
import pytest
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
import flexible_birth_time_profile as profile_module # noqa: E402
from jyotish_engine import _birth_datetime_from_args # noqa: E402
from scripts.consultation_domain_registry import CANONICAL_DOMAINS # noqa: E402
from scripts.jyotish_api_server import JyotishAPIHandler, _load_local_module # noqa: E402
_SMOKE_BIRTH = {
"year": 1993,
"month": 6,
"day": 15,
"hour": 10.0,
"minute": 30.0,
"lat": 36.42,
"lon": 114.21,
"tz": 8,
}
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _stub_consultation_runtime(monkeypatch, handler: JyotishAPIHandler) -> None:
chart = {
"success": True,
"birth_info": {"date": "1993-06-15", "time": "10:30", "tz": 8},
"planets": {},
"ascendant": {},
"modules": {},
"ai_prompt_pack": {
"evidence_snapshot": {
"strict_workflow_contracts": {
domain: {"status": "available", "domain": domain}
for domain in CANONICAL_DOMAINS
}
}
},
}
monkeypatch.setattr(handler, "_compute_chart", lambda body: chart)
monkeypatch.setattr(
handler,
"_compute_rectification_gate",
lambda body: {
"success": True,
"endpoint": "rectification_gate",
"summary": {"recommended_events": [], "warned": [], "disabled": []},
},
)
monkeypatch.setattr(
handler,
"_compute_muhurta_panchanga",
lambda body: {"status": "ok", "scope": "muhurta_panchanga"},
)
monkeypatch.setattr(
handler,
"_compute_vedastro_gateway_run",
lambda body: {
"scope": "vedastro_gateway_run",
"status": "official_blocked",
"official_closure_state": "official_blocked",
"official_closure_reason": "test_stub",
},
)
monkeypatch.setattr(
profile_module,
"_recast_candidate_layers",
lambda candidate, **_kwargs: {
"ascendant": {"sign": "Aries" if candidate.minute < 30 else "Taurus"},
"varga_lagna": {"D9": {"sign": "Gemini"}, "D10": {"sign": "Cancer"}},
"arudha": {"A7": {"sign": "Leo"}, "A10": {"sign": "Virgo"}, "UL": {"sign": "Libra"}},
"kp_cusps": {"house_10": {"sub_lord": "Saturn"}},
},
)
def _provisional_body(theme: str) -> dict:
return {
"entry_mode": "direct_chart",
"question": theme,
"theme": [theme],
**_SMOKE_BIRTH,
"birth_time_accuracy": "provisional",
"representative_time": "10:30",
"western_mode": False,
"defer_optional_external_evidence": True,
}
def test_quality_gate_runs_this_file() -> None:
from scripts.run_quality_gate import CORE_PYTEST_TARGETS
assert "tests/test_consultation_workflow_birth_time_sensitivity.py" in CORE_PYTEST_TARGETS
def test_high_rigor_birth_payload_keeps_hour_minute_as_float() -> None:
payload = _handler()._high_rigor_birth_payload(_SMOKE_BIRTH)
assert isinstance(payload["hour"], float)
assert isinstance(payload["minute"], float)
assert payload["hour"] == 10.0
assert payload["minute"] == 30.0
def test_birth_datetime_from_args_accepts_float_clock_fields() -> None:
args = type("Args", (), {
"year": 1993,
"month": 6,
"day": 15,
"hour": 10.0,
"minute": 30.0,
"second": 0.0,
})()
assert _birth_datetime_from_args(args) == datetime(1993, 6, 15, 10, 30, 0)
@pytest.mark.parametrize("theme", ["career", "marriage", "wealth", "timing", "health"])
def test_consultation_workflow_accepts_float_hour_minute_with_provisional_accuracy(
monkeypatch,
theme: str,
) -> None:
handler = _handler()
_stub_consultation_runtime(monkeypatch, handler)
result = handler._compute_consultation_workflow(_provisional_body(theme))
assert result["success"] is True
packet = result["birth_time_sensitivity"]
assert packet["schema"] == "jyotish.report_birth_time_sensitivity.v1"
assert packet["status"] == "candidate_window_only"
assert packet["accuracy"] == "provisional"
assert packet["window"]["representative_time"] == "10:30"
def test_consultation_workflow_without_sensitivity_fields_still_succeeds(monkeypatch) -> None:
handler = _handler()
_stub_consultation_runtime(monkeypatch, handler)
result = handler._compute_consultation_workflow({
"entry_mode": "direct_chart",
"question": "career",
"theme": ["career"],
**_SMOKE_BIRTH,
"western_mode": False,
"defer_optional_external_evidence": True,
})
assert result["success"] is True
packet = result["birth_time_sensitivity"]
assert packet["schema"] == "jyotish.report_birth_time_sensitivity.v1"
assert packet["status"] in {"not_applicable", "candidate_window_only"}
def test_consultation_workflow_degrades_sensitivity_failure_instead_of_raising(monkeypatch) -> None:
handler = _handler()
_stub_consultation_runtime(monkeypatch, handler)
engine = _load_local_module("jyotish_engine")
def _boom(_args):
raise TypeError("'float' object cannot be interpreted as an integer")
monkeypatch.setattr(engine, "_build_birth_time_sensitivity", _boom)
result = handler._compute_consultation_workflow(_provisional_body("career"))
assert result["success"] is True
assert result["birth_time_sensitivity"] == {
"schema": "jyotish.report_birth_time_sensitivity.v1",
"status": "not_applicable",
"availability": "not_available",
"blocked": True,
"reason": "birth_time_sensitivity_unavailable",
"error_type": "TypeError",
}