From 13dded9fff25aa52be7fa1e56b2b22c6363c45d7 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 4 Sep 2026 14:59:15 +0800 Subject: [PATCH] fix(rectification): run adopt narration on the last discriminator click 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 --- CHANGELOG.md | 2 +- docs/BUG_HISTORY.md | 32 ++++ ...tification-adopt-narration-fix-20260904.md | 32 ++++ docs/tasks/README.md | 2 +- .../rectification-adopt-narration-20260904.md | 19 ++- .../v9/adopt-narration-agent.ts | 120 +++++++++++--- .../rectification-agentic/v9/answer-choice.ts | 43 ++++- ...ification-adopt-narration-20260904.test.ts | 148 +++++++++++++++++- 8 files changed, 353 insertions(+), 45 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-adopt-narration-fix-20260904.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b186a8c5..67d47df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 证据与模板门禁 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e3322d90..6c9c42cd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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: | 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 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-adopt-narration-fix-20260904.md b/docs/tasks/PROGRESS-rectification-adopt-narration-fix-20260904.md new file mode 100644 index 00000000..08f2f00d --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-adopt-narration-fix-20260904.md @@ -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 条诊断/超时测试) diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8b0172f6..7fce4e96 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -45,7 +45,7 @@ | `TASK-rectification-adopt-flow-fix-20260903.md` | — | 采用流程修复单(含删「用这个时间看盘」) | 已验收 | `e8c98c37`(BUG-501/502) | | `TASK-rectification-uncertainty-stop-20260903.md` | — | 不确定度停止规则加样本下限 | 已验收 | `0c0df426`(BUG-503) | | `TASK-rectification-adopt-narration-20260904.md` | `PROGRESS-rectification-adopt-narration-20260904.md` | 探针池耗尽时采用卡旁白改 Agent 生成 + 已丢弃探针绕过 BUG-472 早退 + 区分题答否关线 | 已验收(4.1.4 点选入口未通过,见修复单) | `0aaa0d70`(BUG-519/520) | -| `TASK-rectification-adopt-narration-fix-20260904.md` | — | 采用旁白 Agent 在点选入口从不运行(早退分支重算过期决策)+ 校验器/可观测/超时 | 待执行 | 基线 `0aaa0d70`,BUG-521 起 | +| `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) | ### 聊天主链路与首页 diff --git a/docs/testing/rectification-adopt-narration-20260904.md b/docs/testing/rectification-adopt-narration-20260904.md index 36dc3bb1..25497f4f 100644 --- a/docs/testing/rectification-adopt-narration-20260904.md +++ b/docs/testing/rectification-adopt-narration-20260904.md @@ -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 衔接) diff --git a/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts b/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts index 73e7273d..fb3998c1 100644 --- a/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts +++ b/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts @@ -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 { + 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; -}): Promise { - if (!shouldWriteAdoptNarration(input.facts)) return input.fallback; +}): Promise { + 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; generateText?: ( facts: AdoptDeliveryFacts, signal?: AbortSignal, ) => Promise; }): 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; + }; } diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index ecf2ef0b..ce2d34c1 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -141,6 +141,23 @@ function adoptHostNarration(input: { }), input.receipt); } +function dossierWithCurrentInference( + dossier: Parameters[0]["dossier"], + state: InferenceState | null, +): Parameters[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[0]["dossier"]; decisionState: InferenceState | null; nextAction: ReturnType; + decision?: ReturnType; 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" diff --git a/frontend/tests/rectification-adopt-narration-20260904.test.ts b/frontend/tests/rectification-adopt-narration-20260904.test.ts index 64169c8d..9313fc9b 100644 --- a/frontend/tests/rectification-adopt-narration-20260904.test.ts +++ b/frontend/tests/rectification-adopt-narration-20260904.test.ts @@ -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>; 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;