Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41f973f036 | ||
|
|
c140191357 | ||
|
|
7cab9043fc | ||
|
|
45e00f461b | ||
|
|
285c572299 | ||
|
|
45bdb63eca | ||
|
|
5c9e98790b | ||
|
|
13dded9fff | ||
|
|
e18bd25b19 |
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## 2026-09-04 — 生时校正采用卡旁白由 Agent 生成(不计费)
|
||||
|
||||
探针池耗尽、出采用卡的那一轮,旁白改为一次无工具模型调用:只根据服务端结构化事实解释为什么停、当前范围与代表分钟、采用后拿什么核对。与采集题意图分类器同一口径,**不计费**。模型失败、超时、或文案里出现事实外的时间/年份时,整段丢弃,回落到带「分不开 A 和 B」的模板句。Skill 版本仍是 10.0.14;采用门与确认门未改。
|
||||
探针池耗尽、出采用卡的那一轮,旁白改为一次无工具模型调用:只根据服务端结构化事实解释为什么停、当前范围与代表分钟、采用后拿什么核对。与采集题意图分类器同一口径,**不计费**。模型失败、超时、或文案里出现事实外的时间/年份时,整段丢弃,回落到带「分不开 A 和 B」的模板句。点选最后一道区分题也走同一条 Agent 路径,不再用点选前的过期决策跳过。Skill 版本仍是 10.0.14;采用门与确认门未改。
|
||||
|
||||
## 2026-09-04 — 上游同步验收修复:咨询主题、finance 证据与模板门禁
|
||||
|
||||
|
||||
@@ -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、质量门实际输出
|
||||
@@ -8033,3 +8033,84 @@
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
- 编号说明:与 BUG-519 同批;任务书原写 BUG-517,因编号冲突改为 BUG-520。
|
||||
|
||||
## BUG-521 | 点选最后一道区分题时采用旁白 Agent 从不运行
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-04
|
||||
- 最近更新:2026-09-04
|
||||
- 影响面:`frontend/src/lib/rectification-agentic/v9/answer-choice.ts` `persistNextInterviewAfterChoice`
|
||||
- 用户现象:点完最后一道区分题才出采用卡时,旁白仍是模板句;只有采集题答「没有」和空闲兜底才会走 Agent。
|
||||
- 触发条件:`applyRectificationChoice` → `persistApplied` 传入的 dossier 仍是点选前的 `decisionReceipt`;本轮新状态在 `decisionState`,本轮决策在 `nextDecision`。
|
||||
- 根因:采用早退分支从旧 dossier 再算一遍 `decideFromDossier`。旧决策还是「还有题要问」,`precisionStage !== "ready_to_adopt"`,`shouldWriteAdoptNarration` 为 false,模型一次都没调。模板范围也来自这份过期决策。同一次调用里决策层算了两遍(BUG-440 防复发条款)。
|
||||
- 修复:`persistNextInterviewAfterChoice` 接受调用方传入的 `decision`;未传时用 `decideAfterInferenceChange({ dossier, state: decisionState })`。函数体内不再出现 `decideFromDossier(`。`adoptDeliveryFacts` / 模板兜底使用合成后的 receipt(本轮 `inference_state`)。三条调用方都传入本轮决策。
|
||||
- 验证:`rectification-adopt-narration-20260904` 点选用例改为点选前 dossier(revision 5、分数 23/16/7)+ 答完后 `decisionState` / `nextAction`;断言模型调用 1 次、facts 为 05:00 与 21/18/9、`ready_to_adopt`。非法模型输出时模板范围仍来自本轮决策。源码断言该函数体内无 `decideFromDossier(`。
|
||||
- 防复发:`persistNextInterviewAfterChoice` 不得再从旧 dossier 重算决策。点选路径测试不得用已答完 dossier 冒充点选前状态。
|
||||
- 相关记录:BUG-440、BUG-519、BUG-522
|
||||
- 复发自:BUG-440
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-522 | 采用旁白校验把「不是确认」整段打回,且真实环境无法证明 Agent 有没有写
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-04
|
||||
- 最近更新:2026-09-04
|
||||
- 影响面:`adopt-narration-agent.ts` 提示词、`deliverAdoptNarration` 超时与诊断字段
|
||||
- 用户现象:校验器全文匹配「确认|精确」,模型写「这不是确认的分钟」会被静默退到模板;部署后日志看不出 Agent 路径命中还是模板兜底。
|
||||
- 触发条件:采用旁白 Agent 生成含「确认」或「精确」的否定句;或模型挂起超过等待时间。
|
||||
- 根因:提示词只禁止承诺确认/精确,校验器却禁这两个字。分类器同款调用没有超时。结果没有可观测枚举。
|
||||
- 修复:提示词改为「不要出现『确认』『精确』这两个词」,与校验器同口径;校验器本身不放宽,「这不是确认的分钟」仍打回。`deliverAdoptNarration` 打 `adopt_narration=agent | template:<reason> | template:model_error | template:not_ready`(不含模型原文),并用 `AbortSignal.timeout(8000)` 超时走模板。意图分类器超时仍是既有缺口,本单不修。
|
||||
- 验证:提示词源码断言含「不要出现」;`deliverAdoptNarration` 对 agent / template:unknown_minute / template:model_error / template:not_ready 各有断言;短 timeout 挂起 generateText 走模板且不抛。
|
||||
- 防复发:采用旁白结果必须留下 `adopt_narration=` 日志。Agent.generate 必须带超时。不得把「确认」从校验器删掉却不改提示词。
|
||||
- 相关记录:BUG-521、BUG-523
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-523 | 采用旁白超时用 unref 计时器,测试挂死事件循环
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-04
|
||||
- 最近更新:2026-09-04
|
||||
- 影响面:`frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts` `composedAbortSignal`
|
||||
- 用户现象:staging 门禁 `npm test` 退出码 1,`13dded9f` 不能部署。生产请求有监听 socket 撑住事件循环,超时在真实流量里仍会触发;但每次调用都会留下一个无法 `clear` 的 8 秒计时器。
|
||||
- 触发条件:`deliverAdoptNarration` 用 `AbortSignal.timeout()`;测试把 `generateText` 挂成永不 resolve 的 Promise。
|
||||
- 根因:Node 的 `AbortSignal.timeout()` 内部计时器始终 unref,空事件循环会在 abort 前排空。测试运行器判 `cancelledByParent`,同文件后三条用例一并取消。验收只看了 `# fail`,没看 `# cancelled` 与退出码。
|
||||
- 修复:改为 `setTimeout` + `AbortController`(计时器保持 ref),返回 `{ signal, dispose }`;模型返回、校验完成或外部 abort 后 `clearTimeout` 并移除监听。超时仍走 `template:model_error`。
|
||||
- 验证:`rectification-adopt-narration-20260904` 超时用例不再 cancelled;新增「调用完不留活跃计时器」用例;源码不含 `AbortSignal.timeout`。定向套件与该文件摘要须含完整六行且 `# cancelled 0`、退出码 0。
|
||||
- 防复发:采用旁白超时不得再用 `AbortSignal.timeout`。验收必须贴 Node 摘要完整六行(tests / pass / fail / cancelled / skipped / todo)与进程退出码,不得只报 `# fail 0`。
|
||||
- 相关记录:BUG-522
|
||||
- 复发自:BUG-522
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-524 | consultation_workflow 在 float 时辰上崩溃,全量 HTTP 500
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-04
|
||||
- 最近更新:2026-09-04
|
||||
- 影响面:`POST /api/consultation_workflow`、`execute_consultation_workflow`、`_build_birth_time_sensitivity`、`_birth_datetime_from_args`;报告 worker 与聊天深度咨询共用该端点
|
||||
- 用户现象:staging 上 standard personal_full 生成失败,`failureCode = calculation_unavailable`。同一端点对 provisional 出生时间返回 HTTP 500。
|
||||
- 触发条件:API 出生 payload 的 `hour`/`minute` 为 float(`_high_rigor_birth_payload` 的既有口径),且 `birth_time_accuracy` 为 `provisional` 或 `approximate`,因而会走到 `_birth_datetime_from_args`。
|
||||
- 根因:上游同步把 `_build_birth_time_sensitivity` 无条件接入 consultation_workflow。CLI argparse 的 hour/minute 是 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`。
|
||||
- 防复发:float hour/minute 的 API body 必须能走 `execute_consultation_workflow` 且不 500;敏感度层失败必须降级,不得再变成未捕获 `TypeError`。不得把 `_high_rigor_birth_payload` 的 hour/minute 改成 int。
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## 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。
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# PROGRESS · 采用旁白点选入口修复(2026-09-04)
|
||||
|
||||
工作树:`.worktrees/rectification-adopt-narration-fix-20260904`
|
||||
分支:`codex/rectification-adopt-narration-fix-20260904`
|
||||
基线:任务书写 `0aaa0d70`;本工作树从当时 `origin/staging` `e18bd25b`(修复单提交)拉出。
|
||||
任务书:`docs/tasks/TASK-rectification-adopt-narration-fix-20260904.md`
|
||||
未改 Python、未 bump Skill、未放宽采用门。未提交、未 push。
|
||||
|
||||
| 任务 | 状态 | BUG |
|
||||
| --- | --- | --- |
|
||||
| 5.1 早退分支用本轮决策 | 完成 | BUG-521 |
|
||||
| 5.2 提示词同口径、诊断字段、8s 超时 | 完成 | BUG-522 |
|
||||
| 5.3 `stopFactsFromDropped` 婚恋标签 | 推迟 | — |
|
||||
|
||||
## 实现要点
|
||||
|
||||
- `persistNextInterviewAfterChoice` 新增可选 `decision`。`persistApplied` / `applyCollectFocusDenial` / `persistNextInterviewIfIdle` 都传入本轮决策。未传时走 `decideAfterInferenceChange`。函数体内不再出现 `decideFromDossier(`。
|
||||
- `adoptDeliveryFacts` 与模板兜底用 `dossierWithCurrentInference` 合成后的 receipt,活跃分数来自本轮 `decisionState`。
|
||||
- `deliverAdoptNarration` 返回 `adopt_narration` 枚举并打 `console.info`(无模型原文)。`AbortSignal.any([request.signal, AbortSignal.timeout(8000)])`;超时 / 抛错走 `template:model_error`。
|
||||
- 提示词改为「不要出现『确认』『精确』这两个词」。校验器仍打回「这不是确认的分钟」。
|
||||
|
||||
## 偏离 / 推迟
|
||||
|
||||
- 5.3 按让步顺序推迟:`not_renderable` 仍可能被标成「婚恋题只覆盖窗口两端」。
|
||||
- 意图分类器没有超时:既有缺口,本单不修,记在此处,不改 `BLOCKED.md`。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tsc --noEmit`:0 错
|
||||
- `npm run lint`:0 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,78 @@
|
||||
# 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 |
|
||||
| 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。
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
| `TASK-rectification-adopt-flow-20260902.md` | — | 采用流程整治 | 已验收 | `35e5781e`(BUG-497~500) |
|
||||
| `TASK-rectification-adopt-flow-fix-20260903.md` | — | 采用流程修复单(含删「用这个时间看盘」) | 已验收 | `e8c98c37`(BUG-501/502) |
|
||||
| `TASK-rectification-uncertainty-stop-20260903.md` | — | 不确定度停止规则加样本下限 | 已验收 | `0c0df426`(BUG-503) |
|
||||
| `TASK-rectification-adopt-narration-20260904.md` | `PROGRESS-rectification-adopt-narration-20260904.md` | 探针池耗尽时采用卡旁白改 Agent 生成 + 已丢弃探针绕过 BUG-472 早退 + 区分题答否关线 | 待验收 | 同步后基线 `cfa82499`,BUG-519 / BUG-520 |
|
||||
| `TASK-rectification-adopt-narration-20260904.md` | `PROGRESS-rectification-adopt-narration-20260904.md` | 探针池耗尽时采用卡旁白改 Agent 生成 + 已丢弃探针绕过 BUG-472 早退 + 区分题答否关线 | 已验收(4.1.4 点选入口未通过,见修复单) | `0aaa0d70`(BUG-519/520) |
|
||||
| `TASK-rectification-adopt-narration-fix-20260904.md` | `PROGRESS-rectification-adopt-narration-fix-20260904.md` | 采用旁白 Agent 在点选入口从不运行(早退分支重算过期决策)+ 校验器/可观测/超时 | 已验收(5.1/5.2 实现通过;超时测试挂死事件循环,见修复单 2) | `13dded9f`(BUG-521/522) |
|
||||
| `TASK-rectification-adopt-narration-fix2-20260904.md` | `PROGRESS-rectification-adopt-narration-fix2-20260904.md` | 采用旁白超时用了 unref 的 `AbortSignal.timeout`,测试挂死取消同文件后三条用例,门禁 `npm test` 退出码 1 | 已验收(门禁通过,staging 已部署) | `45bdb63e`(BUG-523) |
|
||||
| `TASK-rectification-collect-direction-20260904.md` | `PROGRESS-rectification-collect-direction-20260904.md` | 可评分事件 2 条时盘外核对抢跑到刚拒答的家人领域,Agent 只能改写成不指向任何领域的泛问;缺第三件带年份的事却先问职业 | 待执行 | — |
|
||||
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) |
|
||||
|
||||
### 聊天主链路与首页
|
||||
@@ -70,6 +73,7 @@
|
||||
| `TASK-report-skill-parity-20260901.md` | `PROGRESS-report-skill-parity-20260901.md` | 内容对齐 skill 解读深度 | 已验收 | `90bad10d`、`ef1bd6df` |
|
||||
| `TASK-report-blocked-repairs-20260902.md` | `PROGRESS-report-blocked-repairs-20260902.md` | 全主题 blocked 修复 | 已验收 | `7faf8555` |
|
||||
| `TASK-report-section-writer-failure-20260902.md` | `PROGRESS-report-writer-failure-20260902.md` | 写作阶段 report_schema_invalid | 已验收 | `eda37c15`(后续 `43294265`、`5c0bec0c`、`fbd6e480`、`cf6405ed`) |
|
||||
| `TASK-report-sensitivity-crash-20260904.md`(仓库根) | `PROGRESS-report-sensitivity-crash-20260904.md` | consultation_workflow float 时辰崩溃,全量 500 | 执行中 | `codex/report-sensitivity-crash-20260904`(BUG-524) |
|
||||
|
||||
### 前端基础与工程
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# 修复单 · 采用旁白 Agent 在点选入口从不运行(2026-09-04)
|
||||
|
||||
基线:`origin/staging` `0aaa0d70`(`TASK-rectification-adopt-narration-20260904.md` 的实现)。本单只改 `answer-choice.ts` 与新测试,不动 Agent 提示词以外的决策逻辑。
|
||||
|
||||
## 0. 验收结论(对照原任务书)
|
||||
|
||||
| 项 | 结论 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 4.1.1 nakshatra 计划/决策同口径,无 frame 的 distinguish 不进 `deferred_followup` | 通过 | `rectificationFollowupCatalog` 过 `inspectDiscriminatorProbes`;计划层兜底丢进 `dropped_probes(not_renderable)` |
|
||||
| 4.1.2 `isRemainingDiscriminatorFollowup` / `interviewToPersist` 忽略无 frame distinguish | 通过 | 代码 + 14 探针 fixture |
|
||||
| 4.1.3 `persistableFocusDomain` 非白名单返回 null,appearance 零 RPC;horary 断言三栏说明 | 通过 | `rectification-server-focus`、`rectification-collect-stall` |
|
||||
| 4.1.4 采用旁白 Agent + 模板兜底,三条入口都接 | **未通过(P1)** | 见 §1;点选入口 Agent 从不运行,且模板改用了过期决策 |
|
||||
| 4.1.5 采用轮 `current_question` 为 null、零次 set_focus | 通过 | 新测试 `assertNoFocusWrite` |
|
||||
| 4.2 区分题答否不关整条领域 | 通过 | `declinedDomains` 按 intent;`dossierWithClosedFocus` 带 intent;BUG-520 |
|
||||
| `rectification-collect-stall` live fixture 补 nakshatra + window_scan 后原断言仍过 | 通过 | 本地重跑 |
|
||||
| tsc / lint / 定向套件 | 通过 | `tsc` 0 错;lint 0 error 74 warning(既有);`rectification-* / agentic-rectification-* / birth-time-*` 869 条 fail=0 |
|
||||
| 全量 `tests/*.test.ts` | 环境缺口 | 2638 条,fail 25 全部为 `docker ENOENT` 的 DB / 部署 / 备份套件,与无 Docker 基线一致;无一条校正相关 |
|
||||
| BUG-519 / BUG-520、CHANGELOG、docs/testing 清单 | 通过 | 无案例 ID、无用户资料 |
|
||||
|
||||
## 1. 事故实证(P1 · 点选入口 Agent 从不运行)
|
||||
|
||||
`answer-choice.ts` `persistNextInterviewAfterChoice` 的采用早退分支:
|
||||
|
||||
```ts
|
||||
const decision = decideFromDossier(input.dossier, { birthDate });
|
||||
const facts = adoptDeliveryFacts(decision, input.dossier);
|
||||
const fallback = adoptHostNarration({ dossier: input.dossier, decision, ... });
|
||||
```
|
||||
|
||||
点选路径(`applyRectificationChoice` → `persistApplied`)传进来的 `input.dossier` 是**点选前**加载的 dossier,其 `latestResult.decisionReceipt.inference_state` 还是上一轮;本轮新状态在 `input.decisionState`,本轮决策在 `input.nextAction`(`persistApplied` 手里还有完整的 `nextDecision`)。函数开头已经把两者合成了 `latest`,但早退分支没有用它,而是从旧 dossier 重新 `decideFromDossier`:
|
||||
|
||||
- 旧 dossier 的决策是「还有一道题要问」(`theme_refine` / `ask_candidate_discriminator`,`probe` 非空),`precisionStage !== "ready_to_adopt"` → `shouldWriteAdoptNarration` 为 false → **`narrateAdopt` 直接返回 fallback,模型一次都没调**。
|
||||
- fallback 模板的 `credibleRange / representativeTime / stopReason` 也来自这份旧决策,而不是本轮的 `nextAction`。改造前模板用的是 `nextAction.credible_range`,这是回归。
|
||||
|
||||
本地复现(用 14 探针 fixture,把 dossier 里的 receipt 回退成「最后一道题未答、分数 23/16/7」,`decisionState` 用答完后的状态,`nextAction` 用答完后的决策):
|
||||
|
||||
```
|
||||
pre-click decision: theme_refine, probe=true
|
||||
agent called: false
|
||||
narration: <模板>
|
||||
fresh nextAction: offer_provisional_range [05:00, 05:06] 05:00
|
||||
```
|
||||
|
||||
新测试「three adopt entry points call the model once」没拦住,是因为它给点选入口传的 `dossier` 已经是答完后的状态(receipt 与 `decisionState` 相同),与生产的点选路径不一致。
|
||||
|
||||
用户可感知:**点最后一道区分题**(采用卡最常见的出现方式)时,旁白仍是模板句;只有采集题答「没有」和空闲兜底两条入口才会走 Agent。
|
||||
|
||||
## 2. 根因
|
||||
|
||||
早退分支把「决策」当成可以从 dossier 随手再算一遍的东西,违反 BUG-440 防复发条款「同一次调用里出题层与决策层必须用同一个 sessionOutcome」——这里是决策层自己算了两遍、用了两份不同输入。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
原任务书决策不变。本单不新增产品决策。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
原任务书 §3 全部沿用。追加:
|
||||
|
||||
1. `persistNextInterviewAfterChoice` 内不得再调用 `decideFromDossier`;决策由调用方传入,或用 `decideAfterInferenceChange({ dossier, state: input.decisionState })` 与 `persistApplied` 保持同一入口。
|
||||
2. 「三条入口」测试的点选用例必须用**点选前** dossier + 答完后 `decisionState` + 答完后 `nextAction` 的形状;不得再用已答完的 dossier 冒充。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### 5.1 P1 · 早退分支用本轮决策(BUG-521)
|
||||
|
||||
1. `persistNextInterviewAfterChoice` 新增可选入参 `decision?: RectificationDecision`;三条调用方都传:`persistApplied` 传 `nextDecision`,`persistNextInterviewIfIdle` 传已算好的 `decision`,`applyCollectFocusDenial` 传它用来生成 `nextAction` 的那份决策。未传时用 `decideAfterInferenceChange({ dossier: input.dossier, state: input.decisionState, userStopped: false, birthDate })`,不得用 `decideFromDossier(input.dossier)`。
|
||||
2. `adoptDeliveryFacts` 与 `adoptHostNarration` 的 dossier 参数改传合成后的 `{ ...input.dossier, latestResult: latest }`(函数开头已有的 `latest`),保证活跃候选与相对支持度是本轮的。
|
||||
3. `persistApplied` 里 `adoptionFacts = adoptDeliveryFacts(nextDecision, input.dossier)` 同样改用合成 receipt。
|
||||
|
||||
验收:
|
||||
- `rectification-adopt-narration-20260904.test.ts`「three adopt entry points」点选用例改成 §1 的形状:`dossier` receipt 为「最后一道题未答、分数 23/16/7、revision 5」,`decisionState = fourteenProbeState()`,`nextAction = publicNextAction(decideFromDossier(caseDossier()))`。断言:模型被调用 1 次;传给模型的 `facts.representative_minute === "05:00"`、`facts.active_candidates` 的分数为 21/18/9(不是 23/16/7)、`facts.precision_stage === "ready_to_adopt"`。
|
||||
- 同一形状下让 fake model 返回非法文本,断言 fallback 模板里的范围与代表分钟来自 `nextAction`(05:00–05:06 / 05:00),并含「分不开 05:00 和 05:06」。
|
||||
- 加一条源码级断言:`persistNextInterviewAfterChoice` 函数体内不出现 `decideFromDossier(`。
|
||||
- `docs/BUG_HISTORY.md` 新增 BUG-521,复发自 BUG-440(决策层重复计算)。
|
||||
|
||||
### 5.2 P2 · 校验器把「不是确认」也打回(BUG-522,可与 5.1 同批)
|
||||
|
||||
`validateAdoptNarration` 的 `/确认|精确/` 是全文匹配;提示词只说「不得承诺确认或精确」,模型很自然会写「这不是确认的分钟」——会被整段打回、静默退到模板,Agent 路径在真实环境的命中率会被这条拉低而且没人知道。
|
||||
|
||||
1. 提示词改成「不要出现『确认』『精确』这两个词」(与校验器同口径)。
|
||||
2. 增加一条可观测:Agent 路径的结果写进本轮 run diagnostic / 服务端日志一个枚举字段 `adopt_narration: "agent" | "template:<validate reason>" | "template:model_error" | "template:not_ready"`,不写模型原文。没有这条,部署后没人能证明 Agent 到底有没有写。
|
||||
3. `agent.generate` 加超时:`AbortSignal.any([request.signal, AbortSignal.timeout(8000)])`,超时走模板。分类器没有超时是既有缺口,不在本单修,写进 `BLOCKED.md` 或进度记录。
|
||||
|
||||
验收:fake model 返回「这不是确认的分钟,先用 05:00。」→ 仍打回(校验器不变),但提示词测试断言含「不要出现」;诊断字段在三种结果下各有一条测试;超时用 fake `generateText` 挂起 + 短 timeout 断言走模板且不抛。
|
||||
|
||||
### 5.3 P3 · `stopFactsFromDropped` 的「婚恋题只覆盖窗口两端」标签
|
||||
|
||||
`insufficient_candidates || not_renderable` 一律标成婚恋题,而 `not_renderable` 现在也用于 nakshatra。改成按 `semantic_key` 前缀分桶(`relationship.` → 婚恋,其它 → 「有几道题渲染不出来」),或去掉领域词。可推迟。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
1. 5.1 必做,否则 4.1.4 在最常见入口上等于没做。
|
||||
2. 5.2 的可观测字段与超时优先于提示词改动。
|
||||
3. 5.3 可推迟到下一轮。
|
||||
|
||||
## 7. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-adopt-narration-fix-20260904 .worktrees/rectification-adopt-narration-fix-20260904 origin/staging
|
||||
cd .worktrees/rectification-adopt-narration-fix-20260904/frontend
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
npx tsx --test tests/rectification-adopt-narration-20260904.test.ts tests/rectification-answer-choice.test.ts tests/rectification-collect-stall.test.ts
|
||||
```
|
||||
|
||||
## 8. BUG 编号起点
|
||||
|
||||
截至本单:BUG-520。本单从 **BUG-521** 起;开工时再核对。
|
||||
@@ -0,0 +1,101 @@
|
||||
# 修复单 2 · 采用旁白超时测试挂死事件循环,拖垮同文件后三条用例(2026-09-04)
|
||||
|
||||
基线:`origin/staging` `13dded9f`(`TASK-rectification-adopt-narration-fix-20260904.md` 的实现)。本单只改 `adopt-narration-agent.ts` 的超时实现与对应进度记录,不动决策逻辑、不动提示词。
|
||||
|
||||
## 0. 验收结论(对照修复单 1)
|
||||
|
||||
| 项 | 结论 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 5.1 早退分支用本轮决策;三条调用方传 `decision`;facts / 模板用合成 receipt | 通过 | `persistNextInterviewAfterChoice` 函数体无 `decideFromDossier(`;`dossierWithCurrentInference`;点选用例改为点选前 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(计划回落到带年份的采集题)。
|
||||
@@ -6,13 +6,20 @@
|
||||
|
||||
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
|
||||
|
||||
## 1. 探针池耗尽后出现采用卡(P0 · BUG-519)
|
||||
## 1. 探针池耗尽后出现采用卡(P0 · BUG-519 / BUG-521)
|
||||
|
||||
1. 新开一个生时校正会话,按平常走完区分题,直到系统再问一道口述采集(家里结婚/添丁/住院这类)。
|
||||
2. 对那道采集题回答「没有」。
|
||||
- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;composer **没有**再冒出开场题或新的点选卡。
|
||||
- ✅ 预期:旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「可以从下面选一个先用着」收尾。
|
||||
- ❌ 改造前:旁白是「我按现有材料继续往下收」,采用卡却已经出来。
|
||||
优先测这条(最常见入口):
|
||||
|
||||
1. 新开一个生时校正会话,按平常走完区分题。
|
||||
2. **点选最后一道区分题**(不要用采集题「没有」代替)。
|
||||
- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「可以从下面选一个先用着」收尾。
|
||||
- ❌ 改造前(BUG-521):点选入口旁白仍是模板句,Agent 没上场。
|
||||
|
||||
采集题路径仍要能走:
|
||||
|
||||
1. 若系统再问一道口述采集(家里结婚/添丁/住院这类),回答「没有」。
|
||||
- ✅ 预期:同一轮出现采用卡;composer **没有**再冒出开场题或新的点选卡;旁白同样说清为什么停。
|
||||
- ❌ 更早改造前:旁白是「我按现有材料继续往下收」,采用卡却已经出来。
|
||||
|
||||
## 2. 采用后进入核对(P0 · 与 BUG-501/502 衔接)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,7 +645,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
setCandidateResult(nextCandidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
if (nextQuestion !== null) setQuestionRetryAttempts(0);
|
||||
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
||||
setChoiceCard(nextChoice);
|
||||
setCaseStatus(nextCaseStatus);
|
||||
|
||||
@@ -62,6 +62,7 @@ export const RECTIFICATION_USER_COPY = {
|
||||
adoptCue: "可以从下面选一个先用着。",
|
||||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||||
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
|
||||
collectDeclinedAck: "记下了,这方面先跳过。",
|
||||
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
|
||||
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
|
||||
} as const;
|
||||
@@ -216,6 +217,7 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.adoptCue,
|
||||
RECTIFICATION_USER_COPY.hostNarrationFallback,
|
||||
RECTIFICATION_USER_COPY.continueCollectFallback,
|
||||
RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||||
RECTIFICATION_USER_COPY.tiedFirstStop,
|
||||
...Object.values(USER_COLLECT_QUESTION),
|
||||
|
||||
@@ -17,6 +17,85 @@ import {
|
||||
type AdoptNarrationWriter,
|
||||
} from "./adopt-narration.ts";
|
||||
|
||||
export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
|
||||
|
||||
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
|
||||
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
|
||||
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
|
||||
不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`;
|
||||
|
||||
export type AdoptNarrationOutcome =
|
||||
| "agent"
|
||||
| `template:${string}`;
|
||||
|
||||
export type AdoptNarrationDelivery = Readonly<{
|
||||
text: string;
|
||||
adopt_narration: AdoptNarrationOutcome;
|
||||
}>;
|
||||
|
||||
type DisposableAbort = Readonly<{
|
||||
signal: AbortSignal;
|
||||
dispose: () => void;
|
||||
}>;
|
||||
|
||||
function composedAbortSignal(
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): DisposableAbort {
|
||||
const controller = new AbortController();
|
||||
// Must stay ref'd. The platform timeout signal uses an unref timer, so a hanging
|
||||
// generateText lets the event loop drain before abort (BUG-523).
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(new DOMException("adopt narration timed out", "TimeoutError"));
|
||||
}
|
||||
}, timeoutMs);
|
||||
const onExternalAbort = () => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError"));
|
||||
}
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) onExternalAbort();
|
||||
else signal.addEventListener("abort", onExternalAbort);
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose: () => {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
signal?.removeEventListener("abort", onExternalAbort);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function whenAborted(signal: AbortSignal): {
|
||||
promise: Promise<never>;
|
||||
dispose: () => void;
|
||||
} {
|
||||
let onAbort: (() => void) | undefined;
|
||||
const promise = new Promise<never>((_, reject) => {
|
||||
const fail = () => {
|
||||
reject(signal.reason ?? new Error("aborted"));
|
||||
};
|
||||
if (signal.aborted) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
onAbort = fail;
|
||||
signal.addEventListener("abort", fail, { once: true });
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
dispose: () => {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function logAdoptNarrationOutcome(outcome: AdoptNarrationOutcome): void {
|
||||
console.info(`[rectification-v9] adopt_narration=${outcome}`);
|
||||
}
|
||||
|
||||
export async function generateAdoptNarrationText(
|
||||
model: ResolvedLanguageModel,
|
||||
facts: AdoptDeliveryFacts,
|
||||
@@ -26,10 +105,7 @@ export async function generateAdoptNarrationText(
|
||||
id: `rectification-adopt-narration-${model.id}`,
|
||||
name: "Rectification Adopt Narration",
|
||||
model: model.model,
|
||||
instructions: `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
|
||||
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
|
||||
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
|
||||
不得承诺“确认”或“精确”,不得再提问,不要写“可以从下面选一个先用着”。`,
|
||||
instructions: ADOPT_NARRATION_INSTRUCTIONS,
|
||||
});
|
||||
const result = await agent.generate([{
|
||||
role: "user",
|
||||
@@ -59,48 +135,87 @@ export async function deliverAdoptNarration(input: {
|
||||
fallback: string;
|
||||
model?: ResolvedLanguageModel | null;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
generateText?: (
|
||||
facts: AdoptDeliveryFacts,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string>;
|
||||
}): Promise<string> {
|
||||
if (!shouldWriteAdoptNarration(input.facts)) return input.fallback;
|
||||
}): Promise<AdoptNarrationDelivery> {
|
||||
if (!shouldWriteAdoptNarration(input.facts)) {
|
||||
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
}
|
||||
const generate = input.generateText ?? (input.model
|
||||
? (facts: AdoptDeliveryFacts, signal?: AbortSignal) => (
|
||||
generateAdoptNarrationText(input.model as ResolvedLanguageModel, facts, signal)
|
||||
)
|
||||
: null);
|
||||
if (!generate) return input.fallback;
|
||||
if (!generate) {
|
||||
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
}
|
||||
const composed = composedAbortSignal(
|
||||
input.signal,
|
||||
input.timeoutMs ?? ADOPT_NARRATION_TIMEOUT_MS,
|
||||
);
|
||||
const aborted = whenAborted(composed.signal);
|
||||
try {
|
||||
const text = await generate(input.facts, input.signal);
|
||||
const text = await Promise.race([
|
||||
generate(input.facts, composed.signal),
|
||||
aborted.promise,
|
||||
]);
|
||||
const checked = validateAdoptNarration(text, input.facts);
|
||||
if (!checked.ok) return input.fallback;
|
||||
return appendAdoptCue(checked.text);
|
||||
if (!checked.ok) {
|
||||
const delivery = {
|
||||
text: input.fallback,
|
||||
adopt_narration: `template:${checked.reason}` as const,
|
||||
};
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
}
|
||||
const delivery = {
|
||||
text: appendAdoptCue(checked.text),
|
||||
adopt_narration: "agent" as const,
|
||||
};
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
} catch {
|
||||
return input.fallback;
|
||||
const delivery = { text: input.fallback, adopt_narration: "template:model_error" as const };
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
} finally {
|
||||
aborted.dispose();
|
||||
composed.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdoptNarrationWriter(input: {
|
||||
model?: ResolvedLanguageModel | null;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
resolveModel?: () => Promise<ResolvedLanguageModel | null>;
|
||||
generateText?: (
|
||||
facts: AdoptDeliveryFacts,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string>;
|
||||
}): AdoptNarrationWriter {
|
||||
return async (facts, fallback) => deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
model: input.model,
|
||||
signal: input.signal,
|
||||
generateText: input.generateText ?? (input.resolveModel
|
||||
? async (nextFacts, signal) => {
|
||||
const model = input.model ?? await input.resolveModel!();
|
||||
if (!model) throw new Error("adopt_narration_model_unavailable");
|
||||
return generateAdoptNarrationText(model, nextFacts, signal);
|
||||
}
|
||||
: undefined),
|
||||
});
|
||||
return async (facts, fallback) => {
|
||||
const delivered = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
model: input.model,
|
||||
signal: input.signal,
|
||||
timeoutMs: input.timeoutMs,
|
||||
generateText: input.generateText ?? (input.resolveModel
|
||||
? async (nextFacts, signal) => {
|
||||
const model = input.model ?? await input.resolveModel!();
|
||||
if (!model) throw new Error("adopt_narration_model_unavailable");
|
||||
return generateAdoptNarrationText(model, nextFacts, signal);
|
||||
}
|
||||
: undefined),
|
||||
});
|
||||
return delivered.text;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
type AdoptNarrationWriter,
|
||||
} from "./adopt-narration.ts";
|
||||
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
|
||||
import { composeCollectSpokenAssistantText } from "./collect-prompt";
|
||||
import {
|
||||
blockingMethodsCovered,
|
||||
buildMethodFollowupPlan,
|
||||
@@ -141,6 +142,23 @@ function adoptHostNarration(input: {
|
||||
}), input.receipt);
|
||||
}
|
||||
|
||||
function dossierWithCurrentInference(
|
||||
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"],
|
||||
state: InferenceState | null,
|
||||
): Parameters<typeof decideAfterInferenceChange>[0]["dossier"] {
|
||||
if (!state) return dossier;
|
||||
return {
|
||||
...dossier,
|
||||
latestResult: {
|
||||
...(dossier.latestResult ?? {}),
|
||||
decisionReceipt: {
|
||||
...(dossier.latestResult?.decisionReceipt ?? {}),
|
||||
inference_state: state,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
@@ -175,6 +193,7 @@ export type AppliedChoiceReceipt = Readonly<{
|
||||
nextAction: ReturnType<typeof publicNextAction>;
|
||||
nextInterviewPersisted: boolean;
|
||||
nextChoiceReady: boolean;
|
||||
turnId: string | null;
|
||||
}>;
|
||||
|
||||
function asText(value: unknown): string | null {
|
||||
@@ -390,6 +409,7 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
|
||||
decisionState: InferenceState | null;
|
||||
nextAction: ReturnType<typeof publicNextAction>;
|
||||
decision?: ReturnType<typeof decideAfterInferenceChange>;
|
||||
birthDate?: string | null;
|
||||
askedTurnId?: string | null;
|
||||
narrateAdopt?: AdoptNarrationWriter;
|
||||
@@ -412,6 +432,13 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
decisionReceipt: input.decisionState ? { inference_state: input.decisionState } : null,
|
||||
};
|
||||
const birthDate = input.birthDate ?? null;
|
||||
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
|
||||
const decision = input.decision ?? decideAfterInferenceChange({
|
||||
dossier: input.dossier,
|
||||
state: input.decisionState,
|
||||
userStopped: false,
|
||||
birthDate,
|
||||
});
|
||||
const catalog = rectificationFollowupCatalog(latest, input.dossier.evidence);
|
||||
const sessionOutcome = typeof input.nextAction.session_outcome === "string"
|
||||
? input.nextAction.session_outcome as SessionOutcomeKind
|
||||
@@ -435,12 +462,11 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
followup,
|
||||
methods: plan.methods,
|
||||
})) {
|
||||
const decision = decideFromDossier(input.dossier, { birthDate });
|
||||
const facts = adoptDeliveryFacts(decision, input.dossier);
|
||||
const facts = adoptDeliveryFacts(decision, liveDossier);
|
||||
const fallback = adoptHostNarration({
|
||||
dossier: input.dossier,
|
||||
dossier: liveDossier,
|
||||
decision,
|
||||
receipt: input.dossier.latestResult?.decisionReceipt,
|
||||
receipt: liveDossier.latestResult?.decisionReceipt,
|
||||
});
|
||||
return {
|
||||
hostNarration: input.narrateAdopt
|
||||
@@ -619,7 +645,12 @@ export async function applyCollectFocusDenial(
|
||||
deferFollowup?: boolean;
|
||||
narrateAdopt?: AdoptNarrationWriter;
|
||||
},
|
||||
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
|
||||
): Promise<{
|
||||
narration: string;
|
||||
nextInterviewPersisted: boolean;
|
||||
nextChoiceReady: boolean;
|
||||
focus: ConversationFocus | null;
|
||||
}> {
|
||||
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
|
||||
const focus = dossier.conversationSummary.activeFocus;
|
||||
if (!focus || focus.id !== input.focusId) {
|
||||
@@ -632,9 +663,10 @@ export async function applyCollectFocusDenial(
|
||||
});
|
||||
if (input.deferFollowup === true) {
|
||||
return {
|
||||
narration: "记下了,这方面先跳过。",
|
||||
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
nextInterviewPersisted: false,
|
||||
nextChoiceReady: false,
|
||||
focus: null,
|
||||
};
|
||||
}
|
||||
let birthDate: string | null = null;
|
||||
@@ -660,7 +692,8 @@ export async function applyCollectFocusDenial(
|
||||
},
|
||||
},
|
||||
}, "declined");
|
||||
const nextAction = publicNextAction(decideFromDossier(withDeclined, { birthDate }));
|
||||
const decision = decideFromDossier(withDeclined, { birthDate });
|
||||
const nextAction = publicNextAction(decision);
|
||||
const nextInterview = await persistNextInterviewAfterChoice({
|
||||
accounting,
|
||||
userId: input.userId,
|
||||
@@ -668,6 +701,7 @@ export async function applyCollectFocusDenial(
|
||||
dossier: withDeclined,
|
||||
decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null),
|
||||
nextAction,
|
||||
decision,
|
||||
birthDate,
|
||||
narrateAdopt: input.narrateAdopt,
|
||||
});
|
||||
@@ -675,9 +709,57 @@ export async function applyCollectFocusDenial(
|
||||
narration: nextInterview.hostNarration,
|
||||
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
|
||||
nextChoiceReady: nextInterview.choiceReady,
|
||||
focus: nextInterview.focus ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type CollectDenialApplied = Awaited<ReturnType<typeof applyCollectFocusDenial>>;
|
||||
|
||||
/**
|
||||
* After a collect "没有", persist the deterministic turn then bind the next
|
||||
* focus to that turn so the stem can hang on the message. Stream only the
|
||||
* acknowledgment; the server-owned stem joins via asked_turn_id (BUG-525).
|
||||
*/
|
||||
export async function persistCollectDenialTurn(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
requestId: string;
|
||||
userMessage: string | null;
|
||||
applied: CollectDenialApplied;
|
||||
}): Promise<{ streamText: string; turnId: string }> {
|
||||
const hasNextStem = Boolean(
|
||||
input.applied.focus
|
||||
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
|
||||
&& input.applied.narration.trim(),
|
||||
);
|
||||
const stored = hasNextStem
|
||||
? composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, input.applied.narration)
|
||||
: input.applied.narration;
|
||||
const streamText = hasNextStem ? RECTIFICATION_USER_COPY.collectDeclinedAck : input.applied.narration;
|
||||
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: input.requestId,
|
||||
userMessage: input.userMessage,
|
||||
assistantMessage: stored,
|
||||
});
|
||||
if (input.applied.focus) {
|
||||
try {
|
||||
await linkFocusAskedTurn({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
focus: input.applied.focus,
|
||||
askedTurnId: turn.turnId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] link collect-denial focus to turn failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { streamText, turnId: turn.turnId };
|
||||
}
|
||||
|
||||
export function isStalePreAdoptFocus(
|
||||
acceptedTime: string | null | undefined,
|
||||
focus: { intent?: string | null } | null | undefined,
|
||||
@@ -793,6 +875,7 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
dossier,
|
||||
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
|
||||
nextAction,
|
||||
decision,
|
||||
birthDate,
|
||||
askedTurnId: input.askedTurnId ?? null,
|
||||
narrateAdopt: input.narrateAdopt,
|
||||
@@ -919,6 +1002,7 @@ async function persistApplied(
|
||||
let hostNarration = input.narration;
|
||||
let skippedNextInterview = false;
|
||||
let nextFocus: ConversationFocus | null = null;
|
||||
let turnId: string | null = null;
|
||||
if (
|
||||
command.deferFollowup !== true
|
||||
&& input.userStopped !== true
|
||||
@@ -931,6 +1015,7 @@ async function persistApplied(
|
||||
dossier: dossierWithClosedFocus(input.dossier, input.focusStatus),
|
||||
decisionState: input.decisionState ?? null,
|
||||
nextAction,
|
||||
decision: nextDecision,
|
||||
birthDate,
|
||||
narrateAdopt: command.narrateAdopt,
|
||||
});
|
||||
@@ -946,8 +1031,9 @@ ${nextInterview.hostNarration}`;
|
||||
nextInterviewPersisted = true;
|
||||
}
|
||||
}
|
||||
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
|
||||
const adoptionFacts = nextAction.can_adopt
|
||||
? adoptDeliveryFacts(nextDecision, input.dossier)
|
||||
? adoptDeliveryFacts(nextDecision, liveDossier)
|
||||
: null;
|
||||
const adoptionNarration = nextAction.can_adopt
|
||||
? withProspectiveWindows(deliveryAdoptNarration({
|
||||
@@ -956,7 +1042,7 @@ ${nextInterview.hostNarration}`;
|
||||
openingRange: openingRangeFromDossier(input.dossier),
|
||||
stopReason: nextAction.stop_reason,
|
||||
stopExplain: adoptionFacts ? templateStopExplain(adoptionFacts) : null,
|
||||
}), input.dossier.latestResult?.decisionReceipt)
|
||||
}), liveDossier.latestResult?.decisionReceipt)
|
||||
: null;
|
||||
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
|
||||
const completedRangeNarration = nextAction.type === "complete_with_range"
|
||||
@@ -987,6 +1073,7 @@ ${nonConvergingRangeNarration({
|
||||
});
|
||||
narrationPersisted = true;
|
||||
if (turn.turnId) {
|
||||
turnId = turn.turnId;
|
||||
try {
|
||||
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
|
||||
.conversationSummary.activeFocus;
|
||||
@@ -1042,6 +1129,7 @@ ${nonConvergingRangeNarration({
|
||||
nextAction,
|
||||
nextInterviewPersisted,
|
||||
nextChoiceReady,
|
||||
turnId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ test("delivery range narration puts the stop reason before the progress clause",
|
||||
assert.match(text, /已经从最初的 60 分钟收到 14: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", () => {
|
||||
|
||||
@@ -8,8 +8,13 @@ import type { ConflictProbe, InferenceState } from "../src/lib/rectification-age
|
||||
import {
|
||||
adoptDeliveryFacts,
|
||||
validateAdoptNarration,
|
||||
type AdoptDeliveryFacts,
|
||||
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
|
||||
import { createAdoptNarrationWriter } from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts";
|
||||
import {
|
||||
ADOPT_NARRATION_INSTRUCTIONS,
|
||||
createAdoptNarrationWriter,
|
||||
deliverAdoptNarration,
|
||||
} from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts";
|
||||
import {
|
||||
applyCollectFocusDenial,
|
||||
persistNextInterviewAfterChoice,
|
||||
@@ -426,11 +431,34 @@ function fourteenProbeState(): InferenceState {
|
||||
};
|
||||
}
|
||||
|
||||
function preClickFourteenProbeState(): InferenceState {
|
||||
const last = ANSWERED[ANSWERED.length - 1]!;
|
||||
const state = fourteenProbeState();
|
||||
return {
|
||||
...state,
|
||||
revision: 5,
|
||||
answered_probes: state.answered_probes.filter((item) => item.probe_id !== last.id),
|
||||
candidates: state.candidates.map((item) => {
|
||||
if (item.time === "05:00") {
|
||||
return { ...item, prior_score: 23, posterior_score: 23 };
|
||||
}
|
||||
if (item.time === "05:06") {
|
||||
return { ...item, prior_score: 16, posterior_score: 16 };
|
||||
}
|
||||
if (item.time === "04:53") {
|
||||
return { ...item, prior_score: 7, posterior_score: 7 };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function caseDossier(extra?: {
|
||||
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
|
||||
acceptedTime?: string | null;
|
||||
state?: InferenceState;
|
||||
}): DecisionDossier {
|
||||
const state = fourteenProbeState();
|
||||
const state = extra?.state ?? fourteenProbeState();
|
||||
return {
|
||||
evidence: EVIDENCE,
|
||||
conversationSummary: {
|
||||
@@ -700,6 +728,14 @@ test("adopt narration agent keeps in-fact copy and fail-closes the rest", async
|
||||
})(facts, fallback);
|
||||
assert.equal(unknownSupport, fallback);
|
||||
assert.equal(validateAdoptNarration("相对支持度 99,先用 05:00。", facts).ok, false);
|
||||
|
||||
const deniedPromise = "这不是确认的分钟,先用 05:00。";
|
||||
assert.equal(validateAdoptNarration(deniedPromise, facts).ok, false);
|
||||
const denied = await createAdoptNarrationWriter({
|
||||
generateText: async () => deniedPromise,
|
||||
})(facts, fallback);
|
||||
assert.equal(denied, fallback);
|
||||
assert.match(ADOPT_NARRATION_INSTRUCTIONS, /不要出现/);
|
||||
});
|
||||
|
||||
test("three adopt entry points call the model once on first ready_to_adopt and not after accept", async () => {
|
||||
@@ -709,36 +745,79 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
|
||||
assert.match(route, /applyCollectFocusDenial\([\s\S]*narrateAdopt/);
|
||||
assert.match(route, /persistNextInterviewIfIdle\(\{[\s\S]*narrateAdopt/);
|
||||
|
||||
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
const persistFn = source.slice(
|
||||
source.indexOf("export async function persistNextInterviewAfterChoice"),
|
||||
source.indexOf("async function persistFocusAfterChoice"),
|
||||
);
|
||||
assert.doesNotMatch(persistFn, /decideFromDossier\(/);
|
||||
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const preClick = caseDossier({ state: preClickFourteenProbeState() });
|
||||
const preClickDecision = decideFromDossier(preClick, { birthDate: "1997-08-08" });
|
||||
assert.notEqual(preClickDecision.precisionStage, "ready_to_adopt");
|
||||
assert.ok(preClickDecision.probe);
|
||||
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
|
||||
|
||||
function withCounter() {
|
||||
let calls = 0;
|
||||
const seen: AdoptDeliveryFacts[] = [];
|
||||
const narrateAdopt = createAdoptNarrationWriter({
|
||||
generateText: async () => {
|
||||
generateText: async (facts) => {
|
||||
calls += 1;
|
||||
seen.push(facts);
|
||||
return kept;
|
||||
},
|
||||
});
|
||||
return { narrateAdopt, count: () => calls };
|
||||
return { narrateAdopt, count: () => calls, seen };
|
||||
}
|
||||
|
||||
const afterChoice = withCounter();
|
||||
const accountingA = adoptAccounting(dossier);
|
||||
const accountingA = adoptAccounting(preClick);
|
||||
await persistNextInterviewAfterChoice({
|
||||
accounting: accountingA.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
dossier,
|
||||
dossier: preClick,
|
||||
decisionState: fourteenProbeState(),
|
||||
nextAction: publicNextAction(decision),
|
||||
decision,
|
||||
birthDate: "1997-08-08",
|
||||
narrateAdopt: afterChoice.narrateAdopt,
|
||||
});
|
||||
assert.equal(afterChoice.count(), 1);
|
||||
assert.equal(afterChoice.seen[0]?.representative_minute, "05:00");
|
||||
assert.equal(afterChoice.seen[0]?.precision_stage, "ready_to_adopt");
|
||||
assert.deepEqual(
|
||||
afterChoice.seen[0]?.active_candidates.slice(0, 3).map((item) => [item.time, item.relative_support]),
|
||||
[["05:00", 21], ["05:06", 18], ["04:53", 9]],
|
||||
);
|
||||
assertNoFocusWrite(accountingA);
|
||||
|
||||
const invalidChoice = withCounter();
|
||||
const invalidWriter = createAdoptNarrationWriter({
|
||||
generateText: async (facts) => {
|
||||
invalidChoice.seen.push(facts);
|
||||
return "更像 04:58,不要再问了。";
|
||||
},
|
||||
});
|
||||
const invalidAccounting = adoptAccounting(preClick);
|
||||
const invalid = await persistNextInterviewAfterChoice({
|
||||
accounting: invalidAccounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
dossier: preClick,
|
||||
decisionState: fourteenProbeState(),
|
||||
nextAction: publicNextAction(decision),
|
||||
decision,
|
||||
birthDate: "1997-08-08",
|
||||
narrateAdopt: invalidWriter,
|
||||
});
|
||||
assertAdoptTemplate(invalid.hostNarration);
|
||||
assert.match(invalid.hostNarration, /05:00/);
|
||||
assert.doesNotMatch(invalid.hostNarration, /04:58/);
|
||||
|
||||
const afterDenial = withCounter();
|
||||
let loads = 0;
|
||||
const accountingB = fakeAccounting({
|
||||
@@ -807,6 +886,106 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
|
||||
assert.equal(accepted.count(), 0);
|
||||
});
|
||||
|
||||
test("adopt narration diagnostics distinguish agent, validation, error, and not-ready", async () => {
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
|
||||
|
||||
const agent = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
generateText: async () => kept,
|
||||
});
|
||||
assert.equal(agent.adopt_narration, "agent");
|
||||
assert.match(agent.text, /05:00/);
|
||||
|
||||
const invalid = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
generateText: async () => "更像 04:58,不要再问了。",
|
||||
});
|
||||
assert.equal(invalid.adopt_narration, "template:unknown_minute");
|
||||
assert.equal(invalid.text, fallback);
|
||||
|
||||
const errored = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
generateText: async () => {
|
||||
throw new Error("model down");
|
||||
},
|
||||
});
|
||||
assert.equal(errored.adopt_narration, "template:model_error");
|
||||
assert.equal(errored.text, fallback);
|
||||
|
||||
const notReady = await deliverAdoptNarration({
|
||||
facts: { ...facts, already_accepted: true },
|
||||
fallback,
|
||||
generateText: async () => kept,
|
||||
});
|
||||
assert.equal(notReady.adopt_narration, "template:not_ready");
|
||||
assert.equal(notReady.text, fallback);
|
||||
});
|
||||
|
||||
test("adopt narration times out to the template without throwing", async () => {
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const timed = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
timeoutMs: 30,
|
||||
generateText: async () => new Promise(() => {}),
|
||||
});
|
||||
assert.equal(timed.adopt_narration, "template:model_error");
|
||||
assert.equal(timed.text, fallback);
|
||||
});
|
||||
|
||||
test("adopt narration does not leave an active timeout after the model returns", async () => {
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
|
||||
const source = readFileSync(
|
||||
new URL("../src/lib/rectification-agentic/v9/adopt-narration-agent.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /AbortSignal\.timeout/);
|
||||
assert.match(source, /clearTimeout/);
|
||||
|
||||
const pending = new Set<unknown>();
|
||||
let created = 0;
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
const realClearTimeout = globalThis.clearTimeout;
|
||||
globalThis.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
|
||||
created += 1;
|
||||
const id = realSetTimeout(handler, delay, ...args);
|
||||
pending.add(id);
|
||||
return id;
|
||||
}) as typeof setTimeout;
|
||||
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
|
||||
pending.delete(id);
|
||||
realClearTimeout(id);
|
||||
}) as typeof clearTimeout;
|
||||
try {
|
||||
const delivered = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
timeoutMs: 8_000,
|
||||
generateText: async () => kept,
|
||||
});
|
||||
assert.equal(delivered.adopt_narration, "agent");
|
||||
assert.ok(created >= 1);
|
||||
assert.equal(pending.size, 0);
|
||||
} finally {
|
||||
globalThis.setTimeout = realSetTimeout;
|
||||
globalThis.clearTimeout = realClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("applyCollectFocusDenial on the family collect uses the same adopt template", async () => {
|
||||
const dossier = caseDossier();
|
||||
let loads = 0;
|
||||
|
||||
@@ -24,12 +24,19 @@ import {
|
||||
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
|
||||
import {
|
||||
applyCollectFocusDenial,
|
||||
persistCollectDenialTurn,
|
||||
persistNextInterviewAfterChoice,
|
||||
persistNextInterviewIfIdle,
|
||||
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import {
|
||||
RECTIFICATION_USER_COPY,
|
||||
USER_COLLECT_QUESTION,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
ATTEMPT_ID,
|
||||
CASE_ID,
|
||||
FOCUS_ID,
|
||||
TURN_ID,
|
||||
@@ -642,6 +649,96 @@ test("occupation collect denial declines the focus and advances coverage to hora
|
||||
assert.equal(horary.next_followup?.domain, "horary");
|
||||
});
|
||||
|
||||
test("collect denial persists the next stem on the turn and binds asked_turn_id", async () => {
|
||||
const familyFocus = {
|
||||
id: FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: "collect:family:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
target_evidence_id: null,
|
||||
target_domain: "family",
|
||||
target_kind: null,
|
||||
expected_answer_schema: {
|
||||
prompt: "2021 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。",
|
||||
collect: true,
|
||||
},
|
||||
status: "active",
|
||||
asked_at: "2026-08-29T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
};
|
||||
let loads = 0;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => {
|
||||
loads += 1;
|
||||
if (loads === 1) {
|
||||
return rpcDossier(revision5Dossier(revision5State()), familyFocus);
|
||||
}
|
||||
return rpcDossier(revision5Dossier(revision5State(), {
|
||||
declinedTopics: [{ target_domain: "family", status: "declined" }],
|
||||
}));
|
||||
},
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus_id: args.p_focus_id,
|
||||
status: args.p_status,
|
||||
evidence_id: null,
|
||||
idempotent: false,
|
||||
}),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
id: "acacacac-acac-4cac-8cac-acacacacacac",
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: args.p_target_evidence_id,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-08-29T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
asked_turn_id: args.p_asked_turn_id ?? null,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
append_agentic_rectification_turn: () => ({
|
||||
turn_id: TURN_ID,
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const applied = await applyCollectFocusDenial(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
focusId: FOCUS_ID,
|
||||
});
|
||||
assert.equal(applied.nextInterviewPersisted, true);
|
||||
assert.ok(applied.focus);
|
||||
assert.equal(applied.narration, USER_COLLECT_QUESTION.occupation);
|
||||
|
||||
const finished = await persistCollectDenialTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: ATTEMPT_ID,
|
||||
userMessage: "没有",
|
||||
applied,
|
||||
});
|
||||
assert.equal(finished.turnId, TURN_ID);
|
||||
assert.equal(finished.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
|
||||
const stored = composeCollectSpokenAssistantText(
|
||||
RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
USER_COLLECT_QUESTION.occupation,
|
||||
);
|
||||
const append = accounting.calls.find((item) => item.fn === "append_agentic_rectification_turn");
|
||||
assert.equal(append?.args.p_assistant_message, stored);
|
||||
assert.ok(accounting.calls.some((item) => (
|
||||
item.fn === "set_agentic_rectification_conversation_focus"
|
||||
&& item.args.p_asked_turn_id === TURN_ID
|
||||
)));
|
||||
});
|
||||
|
||||
test("message and opening turns persist the next followup so current_question is not null", async () => {
|
||||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
|
||||
@@ -1162,10 +1259,19 @@ test("has_new_dated_event continues into the agent after applying the answer", (
|
||||
fastPath.indexOf("} else {"),
|
||||
);
|
||||
assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)"));
|
||||
assert.match(collectApply, /persistV9DeterministicTurn/);
|
||||
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn"));
|
||||
assert.match(collectApply, /persistCollectDenialTurn/);
|
||||
assert.match(collectApply, /completedMessageResponse\(finished\.streamText, requestId, caseId, finished\.turnId\)/);
|
||||
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistCollectDenialTurn"));
|
||||
assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({"));
|
||||
assert.match(route, /function completedMessageResponse\([\s\S]*?turnId\?: string \| null/);
|
||||
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
const persistDenial = answerChoice.slice(
|
||||
answerChoice.indexOf("export async function persistCollectDenialTurn"),
|
||||
answerChoice.indexOf("export function isStalePreAdoptFocus"),
|
||||
);
|
||||
assert.match(persistDenial, /composeCollectSpokenAssistantText\(RECTIFICATION_USER_COPY\.collectDeclinedAck/);
|
||||
assert.match(persistDenial, /linkFocusAskedTurn/);
|
||||
assert.match(persistDenial, /streamText = hasNextStem \? RECTIFICATION_USER_COPY\.collectDeclinedAck/);
|
||||
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
|
||||
assert.match(persistApplied, /command\.deferFollowup !== true/);
|
||||
assert.equal(shouldContinueAgentForDatedEvent({
|
||||
@@ -1250,6 +1356,8 @@ test("collect denial with a new dated event does not persist the next interview
|
||||
false,
|
||||
);
|
||||
assert.equal(applied.nextInterviewPersisted, false);
|
||||
assert.equal(applied.focus, null);
|
||||
assert.equal(applied.narration, RECTIFICATION_USER_COPY.collectDeclinedAck);
|
||||
});
|
||||
|
||||
test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => {
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
copyTextForMessage,
|
||||
parseTurnQuestion,
|
||||
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { persistCollectDenialTurn, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { RECTIFICATION_AGENT_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
||||
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
RECTIFICATION_USER_COPY,
|
||||
USER_COLLECT_QUESTION,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
FOCUS_ID,
|
||||
@@ -264,3 +269,86 @@ test("refresh rebuilds answered and live questions from asked_turn_id only", ()
|
||||
assert.equal(attached[2]?.question?.answer_option, null);
|
||||
assert.equal(attached[2]?.text, "2016 年入学记下了。");
|
||||
});
|
||||
|
||||
const DENIAL_REQUEST_ID = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
test("collect denial persists ack+stem then binds asked_turn_id; stream is ack only", async () => {
|
||||
const prompt = USER_COLLECT_QUESTION.occupation;
|
||||
const accounting = fakeAccounting({
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
...activeFocusFixture({
|
||||
questionId: String(args.p_question_id),
|
||||
intent: String(args.p_intent),
|
||||
askedTurnId: String(args.p_asked_turn_id ?? ""),
|
||||
}),
|
||||
asked_turn_id: args.p_asked_turn_id,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const result = await persistCollectDenialTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: DENIAL_REQUEST_ID,
|
||||
userMessage: "没有",
|
||||
applied: {
|
||||
narration: prompt,
|
||||
nextInterviewPersisted: true,
|
||||
nextChoiceReady: false,
|
||||
focus: {
|
||||
id: FOCUS_ID,
|
||||
caseId: CASE_ID,
|
||||
questionId: "collect:occupation:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
targetEvidenceId: null,
|
||||
targetDomain: "other",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: { prompt, collect: true },
|
||||
status: "active",
|
||||
askedAt: "2026-09-04T07:47:24.000Z",
|
||||
resolvedAt: null,
|
||||
askedTurnId: null,
|
||||
answerOption: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(result.turnId, TURN_ID);
|
||||
assert.equal(result.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
|
||||
assert.notEqual(result.streamText, prompt);
|
||||
const stored = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
||||
assert.equal(
|
||||
stored?.args.p_assistant_message,
|
||||
composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, prompt),
|
||||
);
|
||||
const linked = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.equal(linked?.args.p_asked_turn_id, TURN_ID);
|
||||
assert.ok(accounting.calls.findIndex((call) => call.fn === "append_agentic_rectification_turn")
|
||||
< accounting.calls.findIndex((call) => call.fn === "set_agentic_rectification_conversation_focus"));
|
||||
});
|
||||
|
||||
test("collect denial without a next stem still writes a turn and does not invent a focus link", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
||||
});
|
||||
const result = await persistCollectDenialTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: DENIAL_REQUEST_ID,
|
||||
userMessage: "没有",
|
||||
applied: {
|
||||
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
nextInterviewPersisted: false,
|
||||
nextChoiceReady: false,
|
||||
focus: null,
|
||||
},
|
||||
});
|
||||
assert.equal(result.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,8 +80,10 @@ test("the question gap is a live row with retries, then a reload; it never tells
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
|
||||
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
|
||||
// Attempts reset in handlers (a question arriving, a turn starting), never in an effect.
|
||||
assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
|
||||
// Attempts reset when a turn starts, never because the snapshot merely names a
|
||||
// current_question. Naming a question that no settled message carries live is
|
||||
// the gap itself (BUG-525); resetting here spun "正在准备下一个问题…" forever.
|
||||
assert.doesNotMatch(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
|
||||
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
|
||||
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
|
||||
// In flow: the gap is the last entry of the transcript, after the message loop, before the saved-time line.
|
||||
|
||||
@@ -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