Compare commits

...
Author SHA1 Message Date
Jesse_ChenandCursor 13dded9fff fix(rectification): run adopt narration on the last discriminator click
Independent Staging Quality Gate / validate (push) Failing after 9m46s
Independent Staging Quality Gate / publish (push) Has been skipped
The early-exit reused the pre-click dossier, so the agent never ran on the common path. Use this-turn decision, log agent vs template outcome, and time out after 8s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 14:59:15 +08:00
Jesse_ChenandClaude Fable 5 e18bd25b19 docs(tasks): adopt narration accepted except click entry; fix brief
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-04 06:33:35 +00:00
9 changed files with 463 additions and 45 deletions
+1 -1
View File
@@ -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 证据与模板门禁
+32
View File
@@ -8033,3 +8033,35 @@
- 复发自:无
- 修复版本:待发布
- 编号说明:与 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
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,32 @@
# 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 条诊断/超时测试)
+2 -1
View File
@@ -44,7 +44,8 @@
| `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 在点选入口从不运行(早退分支重算过期决策)+ 校验器/可观测/超时 | 待验收 | 基线 `e18bd25b`(任务书写 `0aaa0d70`),BUG-521 / BUG-522 |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) |
### 聊天主链路与首页
@@ -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** 起;开工时再核对。
@@ -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 衔接)
@@ -17,6 +17,46 @@ 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;
}>;
function composedAbortSignal(
signal: AbortSignal | undefined,
timeoutMs: number,
): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
function whenAborted(signal: AbortSignal): Promise<never> {
return new Promise((_, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new Error("aborted"));
return;
}
signal.addEventListener("abort", () => {
reject(signal.reason ?? new Error("aborted"));
}, { once: true });
});
}
export function logAdoptNarrationOutcome(outcome: AdoptNarrationOutcome): void {
console.info(`[rectification-v9] adopt_narration=${outcome}`);
}
export async function generateAdoptNarrationText(
model: ResolvedLanguageModel,
facts: AdoptDeliveryFacts,
@@ -26,10 +66,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 +96,83 @@ 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 signal = composedAbortSignal(
input.signal,
input.timeoutMs ?? ADOPT_NARRATION_TIMEOUT_MS,
);
try {
const text = await generate(input.facts, input.signal);
const text = await Promise.race([
generate(input.facts, signal),
whenAborted(signal),
]);
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;
}
}
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;
};
}
@@ -141,6 +141,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;
@@ -390,6 +407,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 +430,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 +460,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
@@ -660,7 +684,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 +693,7 @@ export async function applyCollectFocusDenial(
dossier: withDeclined,
decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null),
nextAction,
decision,
birthDate,
narrateAdopt: input.narrateAdopt,
});
@@ -793,6 +819,7 @@ export async function persistNextInterviewIfIdle(input: {
dossier,
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
nextAction,
decision,
birthDate,
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
@@ -931,6 +958,7 @@ async function persistApplied(
dossier: dossierWithClosedFocus(input.dossier, input.focusStatus),
decisionState: input.decisionState ?? null,
nextAction,
decision: nextDecision,
birthDate,
narrateAdopt: command.narrateAdopt,
});
@@ -946,8 +974,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 +985,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"
@@ -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,63 @@ 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("applyCollectFocusDenial on the family collect uses the same adopt template", async () => {
const dossier = caseDossier();
let loads = 0;