Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c5f25a33b | |||
| 7ee7f8258a | |||
| 7b1354a79b | |||
| 41f973f036 | |||
| c140191357 | |||
| 7cab9043fc | |||
| 45e00f461b | |||
| 285c572299 | |||
| 45bdb63eca | |||
| 5c9e98790b |
@@ -1,5 +1,13 @@
|
||||
# 印度占星 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」的模板句。点选最后一道区分题也走同一条 Agent 路径,不再用点选前的过期决策跳过。Skill 版本仍是 10.0.14;采用门与确认门未改。
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 任务书 · consultation_workflow 全量 500(出生时间敏感度 float 崩溃)(2026-09-04)
|
||||
|
||||
基线:`origin/staging` @ `285c5722`(开工时 `git fetch` 后以 `origin/staging` HEAD 为准)。
|
||||
|
||||
## 事故实证
|
||||
|
||||
- 2026-09-04 07:38 UTC,staging 真实 personal_full(requestId `85616e32-5a5d-4ed8-b75b-7778dabff4ad`,5 主题含新默认 `health`)**failed,failureCode = `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:30,lat 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 500,traceback 同上(确认复现)。
|
||||
- 修复后:5/5 `success=true`;`birth_time_sensitivity` 字段存在且状态合法;喂给 `buildReportEvidenceBundleV2` 得 5 张 claim card、blockedSections 空。
|
||||
- 另发一次**不带**敏感度字段的请求(聊天路径形状),确认同样 200。
|
||||
|
||||
## 任务
|
||||
|
||||
1. **(P0)类型修复 + 降级加固 + 回归测试**(见硬红线 1–3)。
|
||||
2. **(P0)staging 部署后真实验证**:health SHA 对齐后,真实生成一份 standard personal_full(5 主题默认)——这同时是前几轮顺延至今的任务 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、质量门实际输出
|
||||
+67
-1
@@ -8062,6 +8062,72 @@
|
||||
- 修复:提示词改为「不要出现『确认』『精确』这两个词」,与校验器同口径;校验器本身不放宽,「这不是确认的分钟」仍打回。`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-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 是 int;API 路径是 float。`datetime()` 不能接受 float,抛 `TypeError`。异常只被 `except ValueError` 包住,于是冒成 500。
|
||||
- 修复:`_birth_datetime_from_args` 对年月日时分做 `int()` 规范化,不改变 API float 口径。敏感度构建失败时写入 blocked/`not_available` 状态对象并继续 workflow,不再打死整个端点。
|
||||
- 验证:`tests/test_consultation_workflow_birth_time_sensitivity.py`(已列入 `CORE_PYTEST_TARGETS`);本地 HTTP 对 career/marriage/wealth/timing/health 与不带敏感度字段的聊天形状均为 200/`success=true`;五份响应喂给 `buildReportEvidenceBundleV2` 得到 5 张 claim card、`blockedSections` 为空。staging quick 门 `585 passed, 1 skipped`。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 为 200(BUG-524 已修好)。
|
||||
- 防复发:报告 worker 不得再直接 SELECT 已收回 `service_role` 权限的校正表;`calculation_unavailable` 必须能区分「引擎 500」与「可选候选窗读失败」。
|
||||
- 相关记录:BUG-524
|
||||
- 复发自:无
|
||||
- 修复版本:待修复
|
||||
- 编号说明:rebase 到 `origin/staging` 时 BUG-525 已被采集拒答占用,本条落在 BUG-526。
|
||||
|
||||
@@ -30,3 +30,23 @@
|
||||
- `npm run lint`:0 error,74 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 871,pass 858,fail 0,cancelled 4,exit=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 error,74 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 / health(provisional + representative_time 10:30) | 200 | true | candidate_window_only |
|
||||
| 不带敏感度字段(聊天形状) | 200 | true | not_applicable |
|
||||
|
||||
五份主题响应喂给 `buildReportEvidenceBundleV2`:claimCards = career / health_pressure / marriage / timing / wealth(5 张),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.5s,job 3/3,进度停在 30%(`generating_report`),无 `personal_report_sections` 行。
|
||||
|
||||
同期 API 访问日志**没有** `POST /api/consultation_workflow`。Postgres 在 08:59:04 / 08:59:10 / 08:59:20 UTC 三次 `permission denied for table agentic_rectification_cases`,SQL 即 worker 对 `candidate_accepted` 的直接 SELECT。`20260814010000_immutable_skill_registry.sql` 已收回该表的 `service_role` 表级权限。资料状态为 `accepted`,worker 因此走进这张锁死表,从未到达已修好的引擎。记为 **BUG-526**(BUG-525 已被采集拒答占用)。
|
||||
|
||||
事故行 `85616e32-…`(07:38 UTC)同样是约 18s / 3 次 attempt / 无章节;当时 web 容器已 recreate,无法回收那一窗的 Postgres 句。不能把 07:38 也写成 TypeError,只能说与本次 16s 失败形态相同。
|
||||
|
||||
任务 2 欠的章节正文、telemetry、`inputTokens` / 2 倍线对照因此仍缺。
|
||||
|
||||
### 任务 3 · 聊天未跑到引擎
|
||||
|
||||
新开对话点「时运」芯片。产品提示「当前服务的计费配置尚未完成,本次不会扣点」。web 日志 `reason=feature_pricing_missing`。API 无 consultation_workflow。默认聊天模型缺咨询计费配置,与 BUG-524 无关。
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
| `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 早退 + 区分题答否关线 | 已验收(4.1.4 点选入口未通过,见修复单) | `0aaa0d70`(BUG-519/520) |
|
||||
| `TASK-rectification-adopt-narration-fix-20260904.md` | `PROGRESS-rectification-adopt-narration-fix-20260904.md` | 采用旁白 Agent 在点选入口从不运行(早退分支重算过期决策)+ 校验器/可观测/超时 | 待验收 | 基线 `e18bd25b`(任务书写 `0aaa0d70`),BUG-521 / BUG-522 |
|
||||
| `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-505~509) |
|
||||
|
||||
### 聊天主链路与首页
|
||||
@@ -71,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,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`;点选用例改为点选前 dossier(revision 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 858,fail 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 → 524,5.2 → 525,5.3 → 526,5.4 → 527);开工时再核对 `docs/BUG_HISTORY.md` 最大号。BUG-524 关联 BUG-396(训练门未开不得进入 holdout)与 BUG-520(拒答口径);BUG-525 关联 BUG-442/472(职业覆盖)与 BUG-426(计划回落到带年份的采集题)。
|
||||
+35
-10
@@ -35,15 +35,16 @@ Jyotisha feels like a private reading room: warm, editorial, grounded, and quiet
|
||||
| Error | `--color-danger` | `#9a2f2f` | Errors and destructive actions |
|
||||
| Accessible focus | `--color-focus` | `#85432f` | Keyboard focus and input focus |
|
||||
|
||||
The personal report carries a second, narrower palette for its printed-paper
|
||||
surface. It is deliberately separate — the report is a document, not app chrome —
|
||||
and nothing outside `.personal-report-*` may use it.
|
||||
The personal report reuses the product palette. `--report-*` tokens exist so
|
||||
print can pin a light sheet independently of the screen theme; they are the
|
||||
canvas, hairline, and action colors, not a second visual identity. Nothing
|
||||
outside `.personal-report-*` and `.report-center-*` may use them.
|
||||
|
||||
| Role | Token | Value | Usage |
|
||||
|---|---|---:|---|
|
||||
| Report paper | `--report-paper` | `#f8f5ee` | The report sheet itself |
|
||||
| Report rule | `--report-rule` | `#c9c2b7` | Rules and dividers inside the sheet |
|
||||
| Report accent | `--report-accent` | `#85432f` | Report headings, same hue as the action color |
|
||||
| Report paper | `--report-paper` | `#fbfaf7` | Same as `--color-canvas`; the reading sheet |
|
||||
| Report rule | `--report-rule` | `#d8d6cf` | Same as `--color-border`; rules inside the sheet |
|
||||
| Report accent | `--report-accent` | `#85432f` | Same as `--color-action`; scarce evidence links |
|
||||
|
||||
Rules: neutrals stay warm; the action color is scarce; roughly ninety percent of the interface remains light. Dark ink is punctuation, never a page-scale surface. No raw color may appear in UI styles outside these tokens and their documented alpha mixes.
|
||||
|
||||
@@ -154,7 +155,7 @@ is read through an external store so a change in one tab reaches the others.
|
||||
| `--type-caption` | `13px` | 500 | 1.4 | 0 | Labels and metadata |
|
||||
| `--type-overline` | `12px` | 500 | 1.4 | `1.5px` | Eyebrows and badges |
|
||||
|
||||
Display headings use the serif stack at weight 400. Body copy never drops below 14px; 12–13px is reserved for short labels and metadata. CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
|
||||
Display headings use the serif stack at weight 400. Body copy never drops below 14px; 12–13px is reserved for short labels and metadata. No product UI text is smaller than `--type-overline` (12px). Product UI uses three font weights: 400 (display and body), 500 (UI titles, labels, buttons), and 600 (emphasis only). CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
|
||||
|
||||
One documented exception: the thinking text inside a timeline step (`.consultation-run-timeline__thinking`) and the fallback thinking trace (`.message-thinking-body`) render at 13px. They are working notes shown on request inside a collapsed row, not reading copy; the answer itself never inherits that size.
|
||||
|
||||
@@ -162,6 +163,8 @@ One documented exception: the thinking text inside a timeline step (`.consultati
|
||||
|
||||
The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: 12px`, `--space-4: 16px`, `--space-5: 20px`, `--space-6: 24px`, `--space-8: 32px`, `--space-10: 40px`, `--space-12: 48px`, `--space-16: 64px`, and `--space-24: 96px`.
|
||||
|
||||
Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and `--radius-sm` alias that value). Cards and sheets use 12px (`--radius-lg`; `--radius-xl` aliases it). Circles stay `50%`; pills stay `999px`.
|
||||
|
||||
- Chat reading width: 760px for the welcome/composer and 900px for long answers. Both chat surfaces share the 900px transcript width; the rectification session no longer narrows it to 720px.
|
||||
- Admin content width: 1200px, centered.
|
||||
- Desktop shell: 288px sidebar plus flexible reading panel.
|
||||
@@ -180,6 +183,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
|
||||
|
||||
### Button
|
||||
|
||||
- **Implementation:** report surfaces and membership use `@/components/ui/button`. Login, onboarding, and birth-time dialogs still use `.button-primary` / `.button-secondary`, which share the same 44px height, 8px radius, and action tokens. Those CSS classes stay until those surfaces can move without growing `page.tsx`.
|
||||
- **Variants:** ink primary, cream secondary, text, circular icon, deep-brown emphasis.
|
||||
- **Spacing:** 44px minimum height; radii 8px for standard and full radius for icon-only.
|
||||
- **States:** default, hover, active, focus-visible, disabled, loading.
|
||||
@@ -307,7 +311,7 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
|
||||
- **Variants:** assistant editorial text on canvas; user text on warm card surface; streaming; error. Streaming uses a timeline of completed steps plus the current step; the thinking body expands while streaming, collapses when answer text appears, and is stored with the assistant message.
|
||||
- **Identity:** every assistant message carries the 32px Jyotisha logo avatar; user messages stay visually lighter and avatar-free.
|
||||
- **Typography:** assistant body 17px (16px below 768px) with serif subheadings; user body 14px.
|
||||
- **Typography:** assistant body `--type-body-md` (16px) with serif subheadings; user body 14px.
|
||||
- **Tables:** three-column technique audit tables keep 状态 on one line. Below 768px they stack each row as title + status, then the note, instead of squeezing 已执行 into a vertical glyph column.
|
||||
- **Follow-up:** the latest settled consultation answer may offer two or three grounded next questions under that answer. Clicking one sends it in the current session. The composer never hosts suggestion chips. If the answer does not support a grounded continuation, nothing is shown.
|
||||
- **Motion:** a new row enters once, through the GSAP tween in `chat-message-row.tsx` at the 160ms Message duration; there is no CSS entrance keyframe beside it. The trailing assistant reply is one component (`LatestAssistantEntry`) from its first streamed token through settlement, so settling never remounts it and never replays the entrance.
|
||||
@@ -337,8 +341,24 @@ Text release is paced, not animated: the frame buffer commits at most once per a
|
||||
|
||||
### Personal report centre
|
||||
|
||||
- A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
|
||||
- Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
|
||||
- **Structure:** full-page archive on the page floor. A compact back control, serif page title, supporting copy, one generate action, then a list of report cards.
|
||||
- **Surface:** `--color-canvas-soft` floor; cards use `--color-canvas`, a warm hairline, and `--radius-lg`. No drop shadow. Status is a caption badge, not a colored block.
|
||||
- **Typography:** page title uses `--type-display-lg` / `--font-display` at weight 400 with `text-wrap: balance`. Card titles are `--type-title-md` at weight 500. Body stays `--type-body-md` or `--type-body-sm`.
|
||||
- **Width:** 900px centered, matching long-form chat reading. Cards stack below 720px.
|
||||
- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
|
||||
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
|
||||
- **Accessibility:** back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating.
|
||||
|
||||
### Personal report reader
|
||||
|
||||
- **Structure:** sticky screen chrome (back, print), then an answer-first document: cover, executive judgement, natal chart evidence, thematic sections, appendix, provenance.
|
||||
- **Surface:** page floor `--color-canvas-soft`; the document is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome, and pins the light palette.
|
||||
- **Typography:** the subject name uses `--type-display-lg` serif at weight 400. Section titles use `--type-display-sm` serif at weight 400. Theme headings are `--type-title-md` sans at weight 500. Narrative is `--type-body-md` at 1.65, the same measure as chat answers. Labels stay 12–13px.
|
||||
- **Accent:** claim-status pills and evidence links may use the action color; headings stay ink. Dark ink is never a page-scale rule or card edge.
|
||||
- **Charts:** North-Indian SVG uses theme ink and canvas fills, never hardcoded light-only hex.
|
||||
- **Width:** 900px for the document; chart and evidence columns stack at 760px.
|
||||
- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, ready. Waiting uses `InlineSpinner`.
|
||||
- **Accessibility:** back, print, and appendix disclosure are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button.
|
||||
|
||||
### Product entrypoint card
|
||||
|
||||
@@ -373,6 +393,11 @@ Text release is paced, not animated: the frame buffer commits at most once per a
|
||||
- **Width:** 420px desktop maximum.
|
||||
- **States:** open, submitting, success, and error.
|
||||
|
||||
### Membership
|
||||
|
||||
- **Structure:** full-page archive on the page floor. Header chrome is back, orders, and redeem; the hero names the current plan; tabs switch membership vs credits; each plan/credit card carries one purchase action.
|
||||
- **Actions:** header chrome stays hairline `--color-ink-secondary`. The one filled terracotta control is the recommended-plan purchase (`Button` default). Other plan and credit buys use `Button` outline. Redeem opens a dialog; it is not a second filled button on the same screen.
|
||||
|
||||
### Logout dialog
|
||||
|
||||
- **Structure:** confirmation title and explanation, cancel action, and destructive confirm action.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+759
-236
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
|
||||
@@ -33,24 +33,63 @@ export type AdoptNarrationDelivery = Readonly<{
|
||||
adopt_narration: AdoptNarrationOutcome;
|
||||
}>;
|
||||
|
||||
type DisposableAbort = Readonly<{
|
||||
signal: AbortSignal;
|
||||
dispose: () => void;
|
||||
}>;
|
||||
|
||||
function composedAbortSignal(
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
): 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<never> {
|
||||
return new Promise((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
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;
|
||||
}
|
||||
signal.addEventListener("abort", () => {
|
||||
reject(signal.reason ?? new Error("aborted"));
|
||||
}, { once: true });
|
||||
onAbort = fail;
|
||||
signal.addEventListener("abort", fail, { once: true });
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
dispose: () => {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function logAdoptNarrationOutcome(outcome: AdoptNarrationOutcome): void {
|
||||
@@ -117,14 +156,15 @@ export async function deliverAdoptNarration(input: {
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
}
|
||||
const signal = composedAbortSignal(
|
||||
const composed = composedAbortSignal(
|
||||
input.signal,
|
||||
input.timeoutMs ?? ADOPT_NARRATION_TIMEOUT_MS,
|
||||
);
|
||||
const aborted = whenAborted(composed.signal);
|
||||
try {
|
||||
const text = await Promise.race([
|
||||
generate(input.facts, signal),
|
||||
whenAborted(signal),
|
||||
generate(input.facts, composed.signal),
|
||||
aborted.promise,
|
||||
]);
|
||||
const checked = validateAdoptNarration(text, input.facts);
|
||||
if (!checked.ok) {
|
||||
@@ -145,6 +185,9 @@ export async function deliverAdoptNarration(input: {
|
||||
const delivery = { text: input.fallback, adopt_narration: "template:model_error" as const };
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
} finally {
|
||||
aborted.dispose();
|
||||
composed.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -192,6 +193,7 @@ export type AppliedChoiceReceipt = Readonly<{
|
||||
nextAction: ReturnType<typeof publicNextAction>;
|
||||
nextInterviewPersisted: boolean;
|
||||
nextChoiceReady: boolean;
|
||||
turnId: string | null;
|
||||
}>;
|
||||
|
||||
function asText(value: unknown): string | null {
|
||||
@@ -643,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) {
|
||||
@@ -656,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;
|
||||
@@ -701,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,
|
||||
@@ -946,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
|
||||
@@ -1016,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;
|
||||
@@ -1071,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:02–14: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", () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -943,6 +943,49 @@ test("adopt narration times out to the template without throwing", async () => {
|
||||
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.
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user