diff --git a/CHANGELOG.md b/CHANGELOG.md index e2157c11..b186a8c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-04 — 生时校正采用卡旁白由 Agent 生成(不计费) + +探针池耗尽、出采用卡的那一轮,旁白改为一次无工具模型调用:只根据服务端结构化事实解释为什么停、当前范围与代表分钟、采用后拿什么核对。与采集题意图分类器同一口径,**不计费**。模型失败、超时、或文案里出现事实外的时间/年份时,整段丢弃,回落到带「分不开 A 和 B」的模板句。Skill 版本仍是 10.0.14;采用门与确认门未改。 + ## 2026-09-04 — 上游同步验收修复:咨询主题、finance 证据与模板门禁 咨询工作流重建 prompt pack 时保留既有 `evidence_snapshot`,避免 `strict_workflow_contracts` 丢失后把非原生主题从 `degraded` 误降为 `blocked`;真实 chart 未携带 engine snapshot 时复用服务端权威 `plan_route_contract()`,不伪造外部 oracle 成功。finance 采用 PyJHora Yogi Sphuta 标准式(Yogi=`Sun+Moon+93°20′`,Avayogi=`Yogi+186°40′`);D11 继续作为支持项,`wealth_promise_strength` 仍是主判据,缺 D11 不再把完整 Shadbala 的 `confidence_cap` 从 `medium-high` 压到 `low`。MCP 的公开路由保持 `finance`,明确财富问题不会因时间词先出现而漏装外部证据。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6e9f0692..e3322d90 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7999,3 +7999,37 @@ - 相关记录:BUG-516 - 复发自:无 - 修复版本:待发布 + +## BUG-519 | 探针池耗尽出采用卡时旁白说「继续往下收」,并被无 frame 的 nakshatra 绕过采用早退 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:`frontend/src/lib/rectification-agentic/v9/method-followup.ts`、`decision-from-dossier.ts`、`answer-choice.ts`、`server-focus.ts`、`user-copy.ts`、`adopt-narration.ts`、`adopt-narration-agent.ts`、`/api/rectification/agent` +- 用户现象:采集题答「没有」后,助手说「我按现有材料继续往下收」,同一轮却弹出采用卡;composer 没有下一问,采用原因不明。 +- 触发条件:区分探针已答完,receipt 仍带 nakshatra 探针与 `window_scan.d*_candidates_differ=true`,决策已是 `offer_provisional_range` / `adopt_representative` / `ready_to_adopt`。 +- 根因:`buildMethodFollowupPlan` 的 nakshatra 分支只看「有没有探针」,不看决策层是否已丢弃;无 `choice_frame` 的 `distinguish_candidates` 被放进 `deferred_followup`。`isRemainingDiscriminatorFollowup` 见 `source=nakshatra_boundary` 就当成剩余区分题,BUG-472 的采用早退被绕过。随后 `persistableFocusDomain("appearance")` 原样返回领域名,DB check 不含该值,写焦点失败,旁白落到 `hostNarrationFallback`。 +- 修复:`rectificationFollowupCatalog` 只在 `inspectDiscriminatorProbes` 选中 nakshatra 时传入;无 frame 的 nakshatra followup 丢进 `dropped_probes`(`not_renderable`)。`deferred_followup` 不再收无 frame 的 distinguish。`isRemainingDiscriminatorFollowup` 对无 frame 的 distinguish 返回 false。`persistableFocusDomain` 对非白名单领域返回 `null`;无 frame 的 appearance distinguish 直接 `skipped` 且零次 RPC。采用卡首次打开时由无工具旁白 Agent 写 2–4 句,校验失败/超时走模板,模板在 BUG-503 的 `stopReasonPrefix` 之后补「分不开 A 和 B」。 +- 验证:`frontend/tests/rectification-adopt-narration-20260904.test.ts`;live five-evidence fixture 补 nakshatra 与 `window_scan` 后原断言仍过;`rectification-server-focus` 锁 `appearance/horary/nakshatra → null`。 +- 防复发:采用早退只留在 `shouldSkipFollowupPersist`。不得为 `collect:appearance` 放宽 DB check。旁白 Agent 说出的每个 `HH:MM`、四位年份、以及去掉这两类之后剩下的整数(含相对支持度)必须能在 `AdoptDeliveryFacts` 里找到,否则整段丢弃。Agent 调用与意图分类器相同,不计费。 +- 相关记录:BUG-440、BUG-444、BUG-463、BUG-472、BUG-503、BUG-520 +- 复发自:BUG-472 +- 修复版本:待发布 +- 编号说明:任务书撰写时最大号为 BUG-515。同步 `cfa82499` 后 staging 已占用 BUG-516~518,本条落在 BUG-519。 + +## BUG-520 | 区分题答「明确没有发生」被当成整条领域拒答 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:`frontend/src/lib/rectification-agentic/v9/method-followup.ts` `declinedDomains`、`answer-choice.ts` `dossierWithClosedFocus` +- 用户现象:带年份的事业/搬家区分卡选 C「明确没有发生」后,同领域后续有年份探针不再出现;D10/D4 观察分支也会被跳过。本案因探针池已空,结局没被改写。 +- 触发条件:`focusStatusForAnswer` 把区分题的「没有」写成 `declined`;`v_declined_skipped` 投影带 `target_domain` 但不参与计分身份;`declinedDomains` 把任意 declined 焦点的领域当成拒答。 +- 根因:BUG-440 为 D12 同领域卡把「答 C declined 计分」写成领域覆盖。对有年份的 `distinguish_candidates`,C 只是这一题的计分答案,不是「这条线以后都别问」。 +- 修复:`declinedDomains` 只把 `intent === "collect_method_evidence"`(缺 intent 的旧行仍按采集)算成领域拒答;`distinguish_candidates` 的 declined 不计覆盖。`dossierWithClosedFocus` 追加行带 `intent`。`focusStatusForAnswer` 仍返回 `declined`,以保留 DB 状态与不确定度计数。 +- 验证:`rectification-collect-stall`「denying the dated family collect declines relatives」原断言不变;`rectification-adopt-narration-20260904` 锁 career distinguish declined 不覆盖 `d10_career`,且有年份事业探针仍可被选中;本案 family collect 与 family collect + career/relocation distinguish 两种 declined 输入决策与旁白相同。 +- 防复发:领域拒答只看采集题 intent。不得把区分题的 C 当成整域 skipped_by_policy。 +- 相关记录:BUG-440、BUG-519 +- 复发自:无 +- 修复版本:待发布 +- 编号说明:与 BUG-519 同批;任务书原写 BUG-517,因编号冲突改为 BUG-520。 diff --git a/docs/tasks/PROGRESS-rectification-adopt-narration-20260904.md b/docs/tasks/PROGRESS-rectification-adopt-narration-20260904.md new file mode 100644 index 00000000..e7ae0d1d --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-adopt-narration-20260904.md @@ -0,0 +1,49 @@ +# PROGRESS · 采集题答「没有」后采用卡旁白(2026-09-04) + +工作树:`.worktrees/rectification-adopt-narration-20260904` +分支:`codex/rectification-adopt-narration-20260904` +基线:任务书写 `origin/staging` `4d0a67a2`;开工时 `origin/staging` 是任务书提交 `fc5eca8c`。交付前已 `git fetch` 并快进合入 `cfa82499`(校正时间线 live 步骤 + 去掉「先这样」出口)。 +任务书:`docs/tasks/TASK-rectification-adopt-narration-20260904.md` +未改 Python、未 bump Skill(仍 10.0.14)、未放宽采用门 / 确认门 / DB `target_domain` check。未提交、未 push。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 4.1 已丢弃区分探针不得绕过采用早退 | 完成 | BUG-519(任务书原写 516) | +| 4.1.4 采用旁白 Agent + 模板停止原因兜底 | 完成 | BUG-519 | +| 4.2 区分题答否不再关整条领域 | 完成 | BUG-520(任务书原写 517) | + +## 编号 + +`cfa82499` 已占用 BUG-516~518。本单实际落点 BUG-519 / BUG-520。任务书第 7 节已补同步说明。 + +## 实现要点 + +- `rectificationFollowupCatalog` 的 `nakshatraProbe` 先过 `inspectDiscriminatorProbes`;无 `choice_frame` 的 nakshatra followup 进 `dropped_probes`(`not_renderable`)。 +- `isRemainingDiscriminatorFollowup` 对无 frame 的 distinguish 返回 false。`interviewToPersist` 对 **deferred** 只取可渲染 distinguish。 +- `persistableFocusDomain`:非白名单返回 `null`。原值 `"horary"` / 新值 `null` / 原因:DB check 不含 horary。无 frame 的 appearance distinguish:`skipped`、零次 RPC、不二次重试。 +- 采用旁白:`adoptDeliveryFacts` + `deliverAdoptNarration`(无工具;`HH:MM` / 四位年份 / 剩余整数含相对支持度必须能在事实里找到,否则整段丢弃)。三条入口都接了 writer。模板在 BUG-503 `stopReasonPrefix` 之后补「分不开 A 和 B」。与意图分类器相同,**不计费**。 +- 点选路径:`skippedNextInterview` 时保留 Agent 旁白,不用模板 `adoptionNarration` 覆盖。 +- `declinedDomains` 只把采集题(缺 intent 的旧行仍算采集)当成领域拒答。`dossierWithClosedFocus` 带 `intent`。 + +## 偏离 + +任务书验收句「`plan.next_followup` 与 `plan.deferred_followup` 均为 null 或均带 `choice_frame`」按字面会打断既有「adopt 本轮不提问、采集题进 deferred」不变量(`adopt_representative defers method follow-up`)。实际锁的是:**deferred 不得再放无 frame 的 distinguish**;采用轮仍可 stash 采集题。14 探针 fixture 按此断言。 + +既有「occupation collect denial advances to horary」:原值写 `p_target_domain=horary` / 新值不写 horary 采集焦点 / 原因同上 DB check。计划层仍可提出 horary,落库层不再写。 + +## 测试 + +- `tsc --noEmit`:0 错 +- `npm run lint`(`frontend/`):0 error,74 warning(既有,本单未新增 error) +- 聚焦:`rectification-adopt-narration-20260904` 8/8;与 stall / server-focus / eight-method / provisional-adopt / adopt-flow-fix / answer-choice 合计 150/150 +- `tests/rectification-*.test.ts tests/agentic-rectification-*.test.ts tests/birth-time-rectification-contract.test.ts`:869 pass / 0 fail +- 全量 `tests/*.test.ts`:2638 条。与 `next build` 并行时 3 条 Docker Postgres 测试因 migration/连接争用失败(billing-admin、foundation、model-configuration-security);隔离重跑这 3 个文件 11/11 通过。有效失败清单与 staging 一致(空)。本单新增 8 条,总数不低于 `cfa82499`。 +- `next build`:工作树 `frontend/node_modules` 是指向主仓的符号链接,默认 Turbopack 会报 symlink 出根。用 `NEXT_TURBOPACK_ROOT` 临时对齐 tracing root 后构建成功;`/` 仍是 `○` Static。`next.config.ts` 未改、未提交该 workaround。 +- 未改 Python;预检里的系统 Python / swisseph 噪音不当作本单回归。 + +## 改动文件 + +- 新增:`frontend/src/lib/rectification-agentic/v9/adopt-narration.ts`、`adopt-narration-agent.ts`、`frontend/tests/rectification-adopt-narration-20260904.test.ts` +- 决策/落焦点:`method-followup.ts`、`decision-from-dossier.ts`、`answer-choice.ts`、`server-focus.ts`、`user-copy.ts`、`route.ts` +- 回归:`rectification-collect-stall.test.ts`、`rectification-server-focus.test.ts`、`rectification-answer-choice.test.ts` +- 文档:`docs/BUG_HISTORY.md`、`docs/testing/rectification-adopt-narration-20260904.md`、`docs/tasks/README.md`、`CHANGELOG.md` diff --git a/docs/tasks/README.md b/docs/tasks/README.md index bdf1db30..7f11a722 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -44,7 +44,7 @@ | `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` | — | 探针池耗尽时采用卡旁白改 Agent 生成 + 已丢弃探针绕过 BUG-472 早退 + 区分题答否关线 | 待执行 | 基线 `4d0a67a2`,BUG-516 起 | +| `TASK-rectification-adopt-narration-20260904.md` | `PROGRESS-rectification-adopt-narration-20260904.md` | 探针池耗尽时采用卡旁白改 Agent 生成 + 已丢弃探针绕过 BUG-472 早退 + 区分题答否关线 | 待验收 | 同步后基线 `cfa82499`,BUG-519 / BUG-520 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | ### 聊天主链路与首页 diff --git a/docs/tasks/TASK-rectification-adopt-narration-20260904.md b/docs/tasks/TASK-rectification-adopt-narration-20260904.md index 0abbc341..4c55e747 100644 --- a/docs/tasks/TASK-rectification-adopt-narration-20260904.md +++ b/docs/tasks/TASK-rectification-adopt-narration-20260904.md @@ -114,7 +114,7 @@ npx tsx --test tests/*.test.ts 2>&1 | tail -12 # 记下开工时总数与失 ## 7. BUG 编号起点 -开工时核对 `docs/BUG_HISTORY.md` 最大号;截至本单撰写为 **BUG-515**,本单从 **BUG-516** 起。 +开工时核对 `docs/BUG_HISTORY.md` 最大号;截至本单撰写为 **BUG-515**,本单从 **BUG-516** 起。同步 `origin/staging` `cfa82499`(2026-09-04)后,BUG-516~518 已被时间线/选择题 UX 单占用;本单实际落点为 **BUG-519**(原 516)与 **BUG-520**(原 517)。 ## 8. 真实环境复核清单(实现者做不了的,交给产品负责人) diff --git a/docs/testing/rectification-adopt-narration-20260904.md b/docs/testing/rectification-adopt-narration-20260904.md new file mode 100644 index 00000000..36dc3bb1 --- /dev/null +++ b/docs/testing/rectification-adopt-narration-20260904.md @@ -0,0 +1,29 @@ +# Staging 人肉复核 · 探针池耗尽时的采用卡旁白(2026-09-04) + +给产品负责人。实现者无法在真实校正会话里点选。测之前先做第 0 条。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 探针池耗尽后出现采用卡(P0 · BUG-519) + +1. 新开一个生时校正会话,按平常走完区分题,直到系统再问一道口述采集(家里结婚/添丁/住院这类)。 +2. 对那道采集题回答「没有」。 +- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;composer **没有**再冒出开场题或新的点选卡。 +- ✅ 预期:旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「可以从下面选一个先用着」收尾。 +- ❌ 改造前:旁白是「我按现有材料继续往下收」,采用卡却已经出来。 + +## 2. 采用后进入核对(P0 · 与 BUG-501/502 衔接) + +1. 在采用卡上选定代表分钟(或等价采用)。判断卡 / 采集栏上的「先这样,先看当前范围」已在 BUG-516/518 去掉,不要再找那颗按钮。 +- ✅ 预期:进入采用后核对题(保留事件 / OOS),不再重复开场采集题。 +- ✅ 预期:采用当轮之后刷新,不会再生成一段「为什么停」的采用旁白。 + +## 3. 区分题答「没有」不关整条线(P2 · BUG-520) + +若还走得到带年份的事业或搬家区分卡: + +1. 选 C「明确没有发生」。 +- ✅ 预期:这一题计分;若后面还有同领域、不同年份的区分题,仍可能出现。 +- ❌ 改造前:整条 career / relocation 线被标成拒答,后续年份题不再出。 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index fcb5cbd4..34268602 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -15,6 +15,7 @@ import { applyCollectFocusDenial, persistNextInterviewIfIdle, } from "@/lib/rectification-agentic/v9/answer-choice"; +import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent"; import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service"; import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action"; import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run"; @@ -262,6 +263,14 @@ export async function POST(request: Request) { chatSession.model_id, chatSession.model_config_version, ); + const narrateAdopt = createAdoptNarrationWriter({ + model: selectedModel, + signal: request.signal, + resolveModel: () => resolveSessionLanguageModel( + chatSession.model_id, + chatSession.model_config_version, + ), + }); const immediateResponse = await (async (): Promise => { if (isStructuredChoice) { const actionId = parsed.data.actionId; @@ -288,6 +297,7 @@ export async function POST(request: Request) { probeId: parsed.data.probeId ?? null, optionId: action === "stop_and_review" ? "stop" : parsed.data.optionId!, expectedRevision, + narrateAdopt, }); if (action === "stop_and_review") { await transitionV9CaseStatus(accounting, userId, caseId, "paused"); @@ -385,6 +395,7 @@ export async function POST(request: Request) { expectedRevision: previous?.revision ?? 0, userDisplay: parsed.data.message ?? null, deferFollowup: continueToAgent, + narrateAdopt, }); if (!continueToAgent) { return completedMessageResponse(applied.narration, requestId, caseId); @@ -429,6 +440,7 @@ export async function POST(request: Request) { caseId, focusId: focus.id, deferFollowup: continueToAgent, + narrateAdopt, }); if (!continueToAgent) { await persistV9DeterministicTurn(accounting, userId, caseId, { @@ -449,7 +461,12 @@ export async function POST(request: Request) { } const decision = decideFromDossier(dossier, { birthDate }); if (isNonConvergingRangeOffer(decision)) { - const idle = await persistNextInterviewIfIdle({ accounting, userId, caseId }); + const idle = await persistNextInterviewIfIdle({ + accounting, + userId, + caseId, + narrateAdopt, + }); const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision); await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, @@ -524,7 +541,12 @@ export async function POST(request: Request) { return completedMessageResponse(narration, requestId, caseId); } if (!plan.next_followup) { - const idle = await persistNextInterviewIfIdle({ accounting, userId, caseId }); + const idle = await persistNextInterviewIfIdle({ + accounting, + userId, + caseId, + narrateAdopt, + }); const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision); await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, @@ -668,6 +690,7 @@ export async function POST(request: Request) { accounting: accounting as never, userId, caseId, + narrateAdopt, }); } catch (error) { console.warn( diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 145954ca..5e4df08e 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -74,6 +74,8 @@ export type RangeNarrationInput = { openingRange?: readonly [string, string] | null; variant?: RangeNarrationVariant; stopReason?: string | null; + /** Extra stop sentence after BUG-503 prefixes; used when the probe pool is exhausted. */ + stopExplain?: string | null; }; function clockMinutes(value: string): number | null { @@ -137,6 +139,8 @@ export function nonConvergingRangeNarration( ? progressClause(input.openingRange, rangeText, range) : null; const reason = variant === "delivery" ? stopReasonPrefix(input.stopReason) : null; + const explain = variant === "delivery" ? (input.stopExplain?.trim() || null) : null; + const prefix = [reason, explain].filter(Boolean).join(""); if (variant === "intermediate") { if (rangeText && representative) { @@ -155,19 +159,19 @@ export function nonConvergingRangeNarration( const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`; const representativeClause = progress ? `。代表分钟是 ${representative}。` : `。`; const body = `${head}${representativeClause}${boundaryCopy}`; - return reason ? `${reason}${body}` : body; + return prefix ? `${prefix}${body}` : body; } if (rangeText) { const head = progress ?? `更站得住的范围是 ${rangeText}`; const body = `${head}。${boundaryCopy}`; - return reason ? `${reason}${body}` : body; + return prefix ? `${prefix}${body}` : body; } if (representative) { const body = `当前代表分钟 ${representative}。${boundaryCopy}`; - return reason ? `${reason}${body}` : body; + return prefix ? `${prefix}${body}` : body; } const body = `当前几个候选还分不开。${boundaryCopy}`; - return reason ? `${reason}${body}` : body; + return prefix ? `${prefix}${body}` : body; } export function deliveryAdoptNarration(input: RangeNarrationInput): string { diff --git a/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts b/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts new file mode 100644 index 00000000..73e7273d --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts @@ -0,0 +1,106 @@ +/** + * Single-shot adopt-delivery narrator. No tools. It only writes copy from + * structured facts; validation fail-closes to the template. + * + * Billing matches the turn-intent classifier: this call is not reserved. + */ + +import { Agent } from "@mastra/core/agent"; + +import type { ResolvedLanguageModel } from "@/mastra/model"; + +import { + appendAdoptCue, + shouldWriteAdoptNarration, + validateAdoptNarration, + type AdoptDeliveryFacts, + type AdoptNarrationWriter, +} from "./adopt-narration.ts"; + +export async function generateAdoptNarrationText( + model: ResolvedLanguageModel, + facts: AdoptDeliveryFacts, + signal?: AbortSignal, +): Promise { + const agent = new Agent({ + id: `rectification-adopt-narration-${model.id}`, + name: "Rectification Adopt Narration", + model: model.model, + instructions: `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。 +用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。 +只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。 +不得承诺“确认”或“精确”,不得再提问,不要写“可以从下面选一个先用着”。`, + }); + const result = await agent.generate([{ + role: "user", + content: JSON.stringify({ + credible_range: facts.credible_range, + representative_minute: facts.representative_minute, + runner_up_minute: facts.runner_up_minute, + opening_window: facts.opening_window, + active_candidates: facts.active_candidates, + answered_rounds: facts.answered_rounds, + stop_facts: facts.stop_facts, + post_adopt_verification: facts.post_adopt_verification.map((item) => ({ + kind: item.kind, + domain: item.domain, + year: item.year, + })), + }), + }], { + abortSignal: signal, + }); + const text = typeof result.text === "string" ? result.text : ""; + return text.trim(); +} + +export async function deliverAdoptNarration(input: { + facts: AdoptDeliveryFacts; + fallback: string; + model?: ResolvedLanguageModel | null; + signal?: AbortSignal; + generateText?: ( + facts: AdoptDeliveryFacts, + signal?: AbortSignal, + ) => Promise; +}): Promise { + if (!shouldWriteAdoptNarration(input.facts)) return input.fallback; + const generate = input.generateText ?? (input.model + ? (facts: AdoptDeliveryFacts, signal?: AbortSignal) => ( + generateAdoptNarrationText(input.model as ResolvedLanguageModel, facts, signal) + ) + : null); + if (!generate) return input.fallback; + try { + const text = await generate(input.facts, input.signal); + const checked = validateAdoptNarration(text, input.facts); + if (!checked.ok) return input.fallback; + return appendAdoptCue(checked.text); + } catch { + return input.fallback; + } +} + +export function createAdoptNarrationWriter(input: { + model?: ResolvedLanguageModel | null; + signal?: AbortSignal; + 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), + }); +} diff --git a/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts b/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts new file mode 100644 index 00000000..2ec83945 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts @@ -0,0 +1,295 @@ +/** + * Structured facts and fail-closed checks for the adopt-delivery narration. + * + * The decision layer remains the only authority. This module only explains + * why the interview stopped, what range is on offer, and what will be + * checked after adopt. It never changes sessionOutcome or focus. + */ + +import type { RectificationDecision } from "../core/rectification-decision.ts"; +import { openingRangeFromCandidateRange } from "../user-copy.ts"; +import { RECTIFICATION_USER_COPY } from "../user-copy.ts"; +import type { DecisionDossier } from "./decision-from-dossier.ts"; +import { previousInferenceFromReceipt } from "./inference-adapter.ts"; +import { refinementFromDecisionReceipt } from "./refinement-packet.ts"; +import type { DroppedProbe } from "./probe-question-contract.ts"; + +export const ADOPT_NARRATION_MAX_CHARS = 240; + +export type AdoptStopFactKind = + | "indistinguishable" + | "yearless_varga" + | "window_ends_only"; + +export type AdoptStopFact = Readonly<{ + kind: AdoptStopFactKind; + label: string; + count: number; +}>; + +export type AdoptCandidateSupport = Readonly<{ + time: string; + relative_support: number; +}>; + +export type AdoptPostAdoptCheck = Readonly<{ + kind: "holdout" | "oos"; + domain: string; + year: number | null; +}>; + +export type AdoptDeliveryFacts = Readonly<{ + precision_stage: string; + already_accepted: boolean; + credible_range: readonly [string, string] | null; + representative_minute: string | null; + runner_up_minute: string | null; + opening_window: readonly [string, string] | null; + active_candidates: readonly AdoptCandidateSupport[]; + answered_rounds: number; + stop_facts: readonly AdoptStopFact[]; + post_adopt_verification: readonly AdoptPostAdoptCheck[]; + minutes: readonly string[]; + years: readonly number[]; +}>; + +const CLOCK = /^(\d{1,2}):(\d{2})$/; +const FOUR_DIGIT_YEAR = /(?(); + for (const value of values) { + const clock = padClock(value); + if (clock) seen.add(clock); + } + return [...seen]; +} + +function uniqueYears(values: readonly (number | null | undefined)[]): number[] { + return [...new Set(values.filter((item): item is number => ( + typeof item === "number" && Number.isInteger(item) && item >= 1900 && item <= 2100 + )))]; +} + +function activeCandidatesFromDossier(dossier: DecisionDossier): AdoptCandidateSupport[] { + const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + const fromInference = (inference?.candidates ?? []) + .filter((item) => item.status === "active") + .map((item) => ({ + time: padClock(item.time) ?? item.time, + relative_support: Math.round(item.posterior_score), + })) + .filter((item) => CLOCK.test(item.time)) + .sort((left, right) => right.relative_support - left.relative_support); + if (fromInference.length > 0) return fromInference; + return (dossier.latestResult?.candidates ?? []) + .map((item) => ({ + time: padClock(item.time) ?? item.time, + relative_support: Math.round(item.relativeSupport ?? item.posterior_score ?? 0), + })) + .filter((item) => CLOCK.test(item.time)) + .sort((left, right) => right.relative_support - left.relative_support); +} + +function stopFactsFromDropped( + dropped: readonly DroppedProbe[], + representative: string | null, + runnerUp: string | null, +): AdoptStopFact[] { + const facts: AdoptStopFact[] = []; + const noSplit = dropped.filter((item) => item.reason === "no_split_among_active").length; + const yearless = dropped.filter((item) => item.reason === "yearless_ungrounded_contrast").length; + const windowEnds = dropped.filter((item) => ( + item.reason === "insufficient_candidates" || item.reason === "not_renderable" + )).length; + if (representative && runnerUp && representative !== runnerUp) { + facts.push({ + kind: "indistinguishable", + label: `剩下的题分不开 ${representative} 和 ${runnerUp}`, + count: Math.max(1, noSplit), + }); + } else if (noSplit > 0) { + facts.push({ + kind: "indistinguishable", + label: "剩下的题分不开当前候选", + count: noSplit, + }); + } + if (yearless > 0) { + facts.push({ + kind: "yearless_varga", + label: "没有年份的分盘题不再问", + count: yearless, + }); + } + if (windowEnds > 0) { + facts.push({ + kind: "window_ends_only", + label: "婚恋题只覆盖窗口两端", + count: windowEnds, + }); + } + return facts; +} + +export function adoptDeliveryFacts( + decision: Pick, + dossier: DecisionDossier, +): AdoptDeliveryFacts { + const receipt = dossier.latestResult?.decisionReceipt ?? null; + const inference = previousInferenceFromReceipt(receipt); + const refinement = refinementFromDecisionReceipt(receipt); + const active = activeCandidatesFromDossier(dossier); + const representative = padClock(decision.representativeTime) + ?? padClock(active[0]?.time) + ?? null; + const runnerUp = active.find((item) => item.time !== representative)?.time ?? null; + const range = decision.credibleRange + ? [ + padClock(decision.credibleRange[0]) ?? decision.credibleRange[0], + padClock(decision.credibleRange[1]) ?? decision.credibleRange[1], + ] as const + : null; + const opening = openingRangeFromCandidateRange(dossier.case.candidateRange ?? null); + const holdout = (inference?.events ?? []) + .filter((item) => item.usage === "holdout" && item.year !== null) + .map((item) => ({ + kind: "holdout" as const, + domain: item.domain, + year: item.year, + })); + const oos = refinement.oos_blind_prompts.map((item) => ({ + kind: "oos" as const, + domain: item.domain, + year: null, + })); + const verification = [...holdout, ...oos]; + const years = uniqueYears([ + ...holdout.map((item) => item.year), + ...(inference?.events ?? []).map((item) => item.year), + ]); + const minutes = uniqueClocks([ + representative, + runnerUp, + range?.[0], + range?.[1], + opening?.[0], + opening?.[1], + ...active.map((item) => item.time), + ]); + return { + precision_stage: decision.precisionStage, + already_accepted: Boolean(dossier.case.acceptedTime), + credible_range: range, + representative_minute: representative, + runner_up_minute: runnerUp, + opening_window: opening + ? [ + padClock(opening[0]) ?? opening[0], + padClock(opening[1]) ?? opening[1], + ] as const + : null, + active_candidates: active, + answered_rounds: inference?.answered_probes.length ?? 0, + stop_facts: stopFactsFromDropped(decision.droppedProbes, representative, runnerUp), + post_adopt_verification: verification, + minutes, + years, + }; +} + +export function shouldWriteAdoptNarration(facts: AdoptDeliveryFacts): boolean { + return facts.precision_stage === "ready_to_adopt" && !facts.already_accepted; +} + +export function templateStopExplain(facts: AdoptDeliveryFacts): string | null { + const split = facts.stop_facts.find((item) => item.kind === "indistinguishable"); + if (split && facts.representative_minute && facts.runner_up_minute) { + return `剩下的问题分不开 ${facts.representative_minute} 和 ${facts.runner_up_minute}。`; + } + if (split) return `${split.label}。`; + return null; +} + +export function allowedMinuteSet(facts: AdoptDeliveryFacts): Set { + const allowed = new Set(); + for (const minute of facts.minutes) { + allowed.add(minute); + const padded = padClock(minute); + if (padded) { + allowed.add(padded); + allowed.add(`${Number(padded.slice(0, 2))}:${padded.slice(3)}`); + } + } + return allowed; +} + +export function validateAdoptNarration( + text: string, + facts: AdoptDeliveryFacts, +): { ok: true; text: string } | { ok: false; reason: string } { + const trimmed = text.trim().replace(/\s+/g, " "); + if (!trimmed) return { ok: false, reason: "empty" }; + if (trimmed.length > ADOPT_NARRATION_MAX_CHARS) return { ok: false, reason: "length" }; + const sentences = trimmed.split(/(?<=[。!?!?.])/).map((item) => item.trim()).filter(Boolean); + if (sentences.some((item) => /[??]$/.test(item))) { + return { ok: false, reason: "question" }; + } + if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" }; + const minutes = allowedMinuteSet(facts); + for (const token of trimmed.match(CLOCK_TOKEN) ?? []) { + const padded = padClock(token); + if (!padded || (!minutes.has(token) && !minutes.has(padded))) { + return { ok: false, reason: "unknown_minute" }; + } + } + const years = new Set(facts.years); + for (const match of trimmed.matchAll(FOUR_DIGIT_YEAR)) { + const year = Number(match[1]); + if (!years.has(year)) return { ok: false, reason: "unknown_year" }; + } + const allowedNumbers = allowedFactIntegers(facts); + const leftover = trimmed + .replace(CLOCK_TOKEN, " ") + .replace(FOUR_DIGIT_YEAR, " "); + for (const token of leftover.match(/\d+/g) ?? []) { + if (!allowedNumbers.has(Number(token))) { + return { ok: false, reason: "unknown_number" }; + } + } + return { ok: true, text: trimmed }; +} + +export function allowedFactIntegers(facts: AdoptDeliveryFacts): Set { + const allowed = new Set(); + for (const item of facts.active_candidates) allowed.add(item.relative_support); + allowed.add(facts.answered_rounds); + for (const item of facts.stop_facts) allowed.add(item.count); + for (const year of facts.years) allowed.add(year); + for (const minute of facts.minutes) { + const padded = padClock(minute); + if (!padded) continue; + allowed.add(Number(padded.slice(0, 2))); + allowed.add(Number(padded.slice(3))); + } + return allowed; +} + +export function appendAdoptCue(text: string): string { + const trimmed = text.trim(); + if (!trimmed) return RECTIFICATION_USER_COPY.adoptCue; + if (trimmed.includes(RECTIFICATION_USER_COPY.adoptCue)) return trimmed; + return `${trimmed} ${RECTIFICATION_USER_COPY.adoptCue}`; +} + +export type AdoptNarrationWriter = ( + facts: AdoptDeliveryFacts, + fallback: string, +) => Promise; diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index d8890312..ecf2ef0b 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -47,7 +47,12 @@ import { type V9CaseDossier, } from "./tool-service"; import { isPersistedFocusId, type ChoiceKey } from "./choice-card"; -import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn } from "./server-focus"; +import { + adoptDeliveryFacts, + templateStopExplain, + type AdoptNarrationWriter, +} from "./adopt-narration.ts"; +import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus"; import { blockingMethodsCovered, buildMethodFollowupPlan, @@ -84,11 +89,17 @@ function openingRangeFromDossier(dossier: { } function interviewToPersist(plan: MethodFollowupPlan): MethodFollowup | null { - return plan.next_followup ?? plan.deferred_followup ?? null; + if (plan.next_followup) return plan.next_followup; + const deferred = plan.deferred_followup; + if (deferred?.intent === "distinguish_candidates" && !deferred.choice_frame) { + return null; + } + return deferred ?? null; } function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): boolean { if (!followup) return false; + if (followup.intent === "distinguish_candidates" && !followup.choice_frame) return false; return followup.source === "event_probe" || followup.source === "varga_observation" || followup.source === "precision_stage" @@ -116,16 +127,17 @@ function shouldSkipFollowupPersist(input: { } function adoptHostNarration(input: { - credibleRange?: readonly [string, string] | null; - representativeTime?: string | null; - openingRange: readonly [string, string] | null; + dossier: Parameters[0]; + decision: ReturnType; receipt: Readonly> | null | undefined; }): string { + const facts = adoptDeliveryFacts(input.decision, input.dossier); return withProspectiveWindows(deliveryAdoptNarration({ - credibleRange: input.credibleRange, - representativeTime: input.representativeTime, - openingRange: input.openingRange, - stopReason: typeof input.receipt?.stopReason === "string" ? input.receipt.stopReason : null, + credibleRange: input.decision.credibleRange, + representativeTime: input.decision.representativeTime, + openingRange: openingRangeFromDossier(input.dossier), + stopReason: input.decision.stopReason ?? null, + stopExplain: templateStopExplain(facts), }), input.receipt); } @@ -142,6 +154,7 @@ export type ApplyChoiceCommand = Readonly<{ expectedRevision: number; userDisplay?: string | null; deferFollowup?: boolean; + narrateAdopt?: AdoptNarrationWriter; }>; export type AppliedChoiceReceipt = Readonly<{ @@ -170,16 +183,22 @@ function asText(value: unknown): string | null { function dossierWithClosedFocus>[]; }; }>(dossier: T, status: "resolved" | "declined" | "skipped"): T { - const domain = dossier.conversationSummary.activeFocus?.targetDomain ?? null; + const focus = dossier.conversationSummary.activeFocus; + const domain = focus?.targetDomain ?? null; + const intent = focus?.intent ?? null; const declinedSkippedTopics = ( (status === "declined" || status === "skipped") && domain ? [ ...dossier.conversationSummary.declinedSkippedTopics, - { target_domain: domain, status }, + { + target_domain: domain, + status, + ...(intent ? { intent } : {}), + }, ] : dossier.conversationSummary.declinedSkippedTopics ); @@ -373,6 +392,7 @@ export async function persistNextInterviewAfterChoice(input: { nextAction: ReturnType; birthDate?: string | null; askedTurnId?: string | null; + narrateAdopt?: AdoptNarrationWriter; }): Promise<{ hostNarration: string; choiceReady: boolean; @@ -415,13 +435,17 @@ export async function persistNextInterviewAfterChoice(input: { followup, methods: plan.methods, })) { + const decision = decideFromDossier(input.dossier, { birthDate }); + const facts = adoptDeliveryFacts(decision, input.dossier); + const fallback = adoptHostNarration({ + dossier: input.dossier, + decision, + receipt: input.dossier.latestResult?.decisionReceipt, + }); return { - hostNarration: adoptHostNarration({ - credibleRange: input.nextAction.credible_range, - representativeTime: input.nextAction.representative_time, - openingRange: openingRangeFromDossier(input.dossier), - receipt: input.dossier.latestResult?.decisionReceipt, - }), + hostNarration: input.narrateAdopt + ? await input.narrateAdopt(facts, fallback) + : fallback, choiceReady: false, persisted: false, }; @@ -564,6 +588,9 @@ async function persistFocusAfterChoice(input: { if (persisted.status === "created" || persisted.status === "already_open" || !input.followup) { return persisted; } + if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) { + return persisted; + } try { const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); return await persistServerOwnedFocus({ @@ -585,7 +612,13 @@ async function persistFocusAfterChoice(input: { export async function applyCollectFocusDenial( accounting: AccountingClient, - input: { userId: string; caseId: string; focusId: string; deferFollowup?: boolean }, + input: { + userId: string; + caseId: string; + focusId: string; + deferFollowup?: boolean; + narrateAdopt?: AdoptNarrationWriter; + }, ): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> { const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId); const focus = dossier.conversationSummary.activeFocus; @@ -636,10 +669,11 @@ export async function applyCollectFocusDenial( decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null), nextAction, birthDate, + narrateAdopt: input.narrateAdopt, }); return { narration: nextInterview.hostNarration, - nextInterviewPersisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady, + nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady, nextChoiceReady: nextInterview.choiceReady, }; } @@ -658,6 +692,7 @@ export async function persistNextInterviewIfIdle(input: { userId: string; caseId: string; askedTurnId?: string | null; + narrateAdopt?: AdoptNarrationWriter; }): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); const staleFocus = dossier.conversationSummary.activeFocus; @@ -722,15 +757,18 @@ export async function persistNextInterviewIfIdle(input: { followup, methods: plan.methods, })) { + const facts = adoptDeliveryFacts(decision, dossier); + const fallback = adoptHostNarration({ + dossier, + decision, + receipt: dossier.latestResult?.decisionReceipt, + }); return { persisted: false, choiceReady: false, - hostNarration: adoptHostNarration({ - credibleRange: decision.credibleRange, - representativeTime: decision.representativeTime, - openingRange: openingRangeFromDossier(dossier), - receipt: dossier.latestResult?.decisionReceipt, - }), + hostNarration: input.narrateAdopt + ? await input.narrateAdopt(facts, fallback) + : fallback, }; } if (isNonConvergingRangeOffer(decision)) { @@ -757,6 +795,7 @@ export async function persistNextInterviewIfIdle(input: { nextAction, birthDate, askedTurnId: input.askedTurnId ?? null, + narrateAdopt: input.narrateAdopt, }); return { persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady, @@ -866,12 +905,13 @@ async function persistApplied( } catch { // Ranking stays fail-open when compute is unavailable. } - const nextAction = publicNextAction(decideAfterInferenceChange({ + const nextDecision = decideAfterInferenceChange({ dossier: input.dossier, state: input.decisionState ?? null, userStopped: input.userStopped === true, birthDate, - })); + }); + const nextAction = publicNextAction(nextDecision); let narrationPersisted = false; let nextInterviewPersisted = false; @@ -892,6 +932,7 @@ async function persistApplied( decisionState: input.decisionState ?? null, nextAction, birthDate, + narrateAdopt: command.narrateAdopt, }); nextChoiceReady = nextInterview.choiceReady; skippedNextInterview = nextInterview.persisted === false; @@ -905,12 +946,16 @@ ${nextInterview.hostNarration}`; nextInterviewPersisted = true; } } + const adoptionFacts = nextAction.can_adopt + ? adoptDeliveryFacts(nextDecision, input.dossier) + : null; const adoptionNarration = nextAction.can_adopt ? withProspectiveWindows(deliveryAdoptNarration({ credibleRange: nextAction.credible_range, representativeTime: nextAction.representative_time, openingRange: openingRangeFromDossier(input.dossier), stopReason: nextAction.stop_reason, + stopExplain: adoptionFacts ? templateStopExplain(adoptionFacts) : null, }), input.dossier.latestResult?.decisionReceipt) : null; const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim(); @@ -926,7 +971,7 @@ ${nonConvergingRangeNarration({ }, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt) : null; const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview); - if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion) { + if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) { hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration; } diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index adf2affb..9575ca01 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -268,15 +268,25 @@ export function rectificationFollowupCatalog( const receipt = latest?.decisionReceipt ?? null; const refinement = refinementFromDecisionReceipt(receipt); const inference = previousInferenceFromReceipt(receipt); + const askedKeys = askedDiscriminatorKeys(receipt, evidence); + const topCandidateTimes = discriminatorCandidateTimes(latest ?? null); + const nakshatraRaw = inference?.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null; + const nakshatraInspected = nakshatraRaw + ? inspectDiscriminatorProbes(nakshatraContrastPacket(nakshatraRaw, inference?.candidate_set_id ?? ""), { + askedKeys, + mentionedKeys: mentionedVargaKeysFromLedgerEvidence(evidence), + topCandidateTimes, + }) + : null; return { contrastPacket: contrastPacketFromLatestResult(latest ?? null, evidence), - topCandidateTimes: discriminatorCandidateTimes(latest ?? null), - askedProbeKeys: askedDiscriminatorKeys(receipt, evidence), + topCandidateTimes, + askedProbeKeys: askedKeys, eventProbes: refinement.discriminating_event_probes, eventClarificationProbes: refinement.event_clarification_probes, evidenceCollectionProbes: refinement.evidence_collection_probes, precisionStage: refinement.precision_stage?.current ?? null, - nakshatraProbe: inference?.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null, + nakshatraProbe: nakshatraInspected?.selected ? nakshatraRaw : null, oosBlindPrompts: refinement.oos_blind_prompts, holdoutEvents: (inference?.events ?? []) .filter((item) => item.usage === "holdout") diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 5855c822..cd2d3ebb 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -369,12 +369,21 @@ function isOpeningOtherCollectTopic(topic: Readonly>): b return topicDomain(topic) === "other" && topicQuestionId(topic).startsWith("collect:other:"); } +function topicIntent(topic: Readonly>): string { + return typeof topic.intent === "string" ? topic.intent : ""; +} + function declinedDomains( topics: readonly Readonly>[], ): Set { const domains = new Set(); for (const topic of topics) { if (isOpeningOtherCollectTopic(topic)) continue; + const intent = topicIntent(topic); + // Distinguish-card "no" is an answer, not a domain refusal (BUG-520). + // Missing intent stays collect for legacy declined_skipped rows. + if (intent === "distinguish_candidates") continue; + if (intent && intent !== "collect_method_evidence") continue; const domain = topicDomain(topic); if (domain) domains.add(domain); } @@ -1710,6 +1719,7 @@ export function buildMethodFollowupPlan(input: { } let next: MethodFollowup | null = null; + const extraDropped: DroppedProbe[] = []; const stage = input.precisionStage ?? null; const followupFromRanked = (ranked: RankedDiscriminator): MethodFollowup => { if (ranked.kind === "event" && ranked.eventProbe) { @@ -1834,7 +1844,7 @@ export function buildMethodFollowupPlan(input: { && input.nakshatraProbe ) { const probe = input.nakshatraProbe; - next = makeFollowup({ + const nakshatraFollowup = makeFollowup({ method_id: "nakshatra_boundary", intent: "distinguish_candidates", ask_theme: "nakshatra_trait", @@ -1852,6 +1862,15 @@ export function buildMethodFollowupPlan(input: { style_options: probe.style_options, probe_id: probe.id, }, true, true); + if (nakshatraFollowup.choice_frame) { + next = nakshatraFollowup; + } else { + extraDropped.push({ + semantic_key: probe.semantic_key, + information_gain: probe.information_gain, + reason: "not_renderable", + }); + } } const sameDomainYearlessCard = (domain: string): MethodFollowup | null => { const ranked = yearlessDiscriminators.find((row) => ( @@ -2146,23 +2165,41 @@ export function buildMethodFollowupPlan(input: { } } + if (next?.intent === "distinguish_candidates" && !next.choice_frame && next.source === "nakshatra_boundary") { + if (next.semantic_key) { + extraDropped.push({ + semantic_key: next.semantic_key, + information_gain: next.information_gain ?? 0, + reason: "not_renderable", + }); + } + next = null; + } const deferAdoption = sessionOutcome === "adopt_representative" || sessionOutcome === "validated_range" || sessionOutcome === "exact_minute_confirmed" || sessionOutcome === "provisional_range_user_stopped" || sessionOutcome === "completed_with_range"; const deferProvisionalDiscriminator = sessionOutcome === "provisional_range" - && next?.intent === "distinguish_candidates"; + && next?.intent === "distinguish_candidates" + && Boolean(next.choice_frame); const deferFollowup = deferAdoption || deferProvisionalDiscriminator; + const deferred = deferFollowup ? next : null; return { methods, next_followup: deferFollowup ? null : next, - deferred_followup: deferFollowup ? next : null, + // Adopt may stash a collect for later. Frameless distinguish must not + // sit in deferred_followup or it bypasses the adopt early-exit (BUG-519). + deferred_followup: ( + deferred?.intent === "distinguish_candidates" && !deferred.choice_frame + ? null + : deferred + ), session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, - dropped_probes: rankedCatalog.dropped, + dropped_probes: [...rankedCatalog.dropped, ...extraDropped], }; } diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index d1a136bf..77765aec 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -199,7 +199,14 @@ export function persistableFocusDomain(domain: string | null | undefined): strin if (domain === "health_pressure") return "health"; if (domain === "occupation") return "other"; if (PERSISTABLE_FOCUS_DOMAINS.has(domain)) return domain; - return domain; + return null; +} + +export function followupHasPersistableDomain(followup: Pick): boolean { + return Boolean( + persistableFocusDomain(followup.domain) + ?? persistableFocusDomain(collectQuestionDomain(followup.domain)), + ); } function isFocusIdempotencyConflict(error: unknown): boolean { @@ -283,6 +290,14 @@ async function persistCollectFocus(input: { prompt: null, }; } + if (!followupHasPersistableDomain(input.followup)) { + return { + status: "skipped", + focus: input.activeFocus, + questionId: null, + prompt: null, + }; + } const questionId = stableFollowupQuestionId(input.followup); const prompt = typeof schema.prompt === "string" ? schema.prompt : null; const active = input.activeFocus; @@ -308,7 +323,7 @@ async function persistCollectFocus(input: { targetEvidenceId: null, targetDomain: persistableFocusDomain(input.followup.domain) ?? (input.followup.intent === "collect_method_evidence" - ? collectQuestionDomain(input.followup.domain) + ? persistableFocusDomain(collectQuestionDomain(input.followup.domain)) : null), targetKind: null, expectedAnswerSchema: schema, @@ -370,6 +385,14 @@ async function persistSpokenChoiceFallback(input: { followup: MethodFollowup; askedTurnId?: string | null; }): Promise { + if (!followupHasPersistableDomain(input.followup)) { + return { + status: "skipped", + focus: input.activeFocus, + questionId: null, + prompt: null, + }; + } return persistCollectFocus({ accounting: input.accounting, userId: input.userId, diff --git a/frontend/tests/rectification-adopt-narration-20260904.test.ts b/frontend/tests/rectification-adopt-narration-20260904.test.ts new file mode 100644 index 00000000..64169c8d --- /dev/null +++ b/frontend/tests/rectification-adopt-narration-20260904.test.ts @@ -0,0 +1,962 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; +import { + adoptDeliveryFacts, + validateAdoptNarration, +} from "../src/lib/rectification-agentic/v9/adopt-narration.ts"; +import { createAdoptNarrationWriter } from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts"; +import { + applyCollectFocusDenial, + persistNextInterviewAfterChoice, + persistNextInterviewIfIdle, +} from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { + decideFromDossier, + rectificationFollowupCatalog, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const EXISTENCE_OPTIONS = [ + { label: "明确发生且时间吻合", answer_class: "yes" as const }, + { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, + { label: "明确没有发生", answer_class: "no" as const }, + { label: "这段记不清楚", answer_class: "unsure" as const }, +]; + +const TIMES = [ + "04:45", "04:47", "04:51", "04:53", "04:59", + "05:00", "05:06", "05:08", "05:12", "05:15", +] as const; + +const WINDOW_ENDS = ["04:45", "04:47", "04:51", "05:08", "05:12", "05:15"] as const; +const ACTIVE = ["05:00", "05:06", "04:53"] as const; + +const EVIDENCE = [ + { + id: "e-education", + status: "confirmed", + domain: "education", + datePrecision: "year" as const, + occurredFrom: "2016-01-01", + occurredTo: null, + eventKind: "education_start", + }, + { + id: "e-rel-start", + status: "confirmed", + domain: "relationship", + datePrecision: "month" as const, + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + }, + { + id: "e-rel-end", + status: "confirmed", + domain: "relationship", + datePrecision: "day" as const, + occurredFrom: "2024-08-08", + occurredTo: null, + eventKind: "relationship_end", + }, + { + id: "e-career-entry", + status: "confirmed", + domain: "career", + datePrecision: "month" as const, + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-career-exit", + status: "confirmed", + domain: "career", + datePrecision: "month" as const, + occurredFrom: "2020-10-01", + occurredTo: null, + eventKind: "career_exit", + }, +] as const; + +const OOS_BLIND = [ + { domain: "family", user_meaning: "家里有没有结婚、添丁或住院", used_for_scoring: false }, + { domain: "finance", user_meaning: "钱的方面有没有明显变化", used_for_scoring: false }, + { domain: "health_pressure", user_meaning: "身体或压力这边有没有难熬的一段", used_for_scoring: false }, +] as const; + +function vargaExistence(input: { + layer: string; + gain: number; + domain: string; + question: string; +}): ConflictProbe { + const key = `varga.${input.layer}.yearless`; + return { + id: `contrast:${key}`, + semantic_key: key, + candidate_split_hash: key, + domain: input.domain, + year: 0, + question: input.question, + candidate_ids: [...ACTIVE], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00", "05:06"], conflicts: ["04:53"] }, + { answer_class: "weak_yes", supports: ["05:00", "05:06"], conflicts: [] }, + { answer_class: "no", supports: ["04:53"], conflicts: ["05:00", "05:06"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: input.gain, + source: "varga_contrast", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, + }; +} + +const D9: ConflictProbe = { + id: "contrast:varga.d9.巨蟹座/狮子座", + semantic_key: "varga.d9.巨蟹座/狮子座", + candidate_split_hash: "varga.d9.巨蟹座/狮子座", + domain: "relationship", + year: 0, + question: "亲密关系里更接近下面哪一种相处方式?", + candidate_ids: ["05:00", "05:06"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, + { answer_class: "weak_yes", supports: ["05:06"], conflicts: ["05:00"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 1.1, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const D10: ConflictProbe = { + id: "contrast:varga.d10.天秤座/天蝎座", + semantic_key: "varga.d10.天秤座/天蝎座", + candidate_split_hash: "varga.d10.天秤座/天蝎座", + domain: "career", + year: 0, + question: "平时做事更接近下面哪一种职责风格?", + candidate_ids: ["05:00", "05:06"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, + { answer_class: "weak_yes", supports: ["05:06"], conflicts: ["05:00"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 1.05, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const CAREER_2023: ConflictProbe = { + id: "probe:career.2023.dasha_boundary", + semantic_key: "career.2023.dasha_boundary", + candidate_split_hash: "career.2023", + domain: "career", + year: 2023, + question: "2023 年前后有没有入职或换工作?", + candidate_ids: ["05:00", "05:06"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, + { answer_class: "no", supports: ["05:06"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.72, + source: "dasha_boundary", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, +}; + +const CAREER_2024: ConflictProbe = { + id: "probe:career.2024.dasha_boundary", + semantic_key: "career.2024.dasha_boundary", + candidate_split_hash: "career.2024", + domain: "career", + year: 2024, + question: "2024 年前后有没有职责加重?", + candidate_ids: ["05:00", "05:06"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, + { answer_class: "no", supports: ["05:06"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.68, + source: "dasha_boundary", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, +}; + +const RELOCATION_2015: ConflictProbe = { + id: "probe:relocation.2015.dasha_boundary", + semantic_key: "relocation.2015.dasha_boundary", + candidate_split_hash: "relocation.2015", + domain: "relocation", + year: 2015, + question: "2015 年前后有没有搬家或长期住到外地?", + candidate_ids: ["05:00", "05:06"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, + { answer_class: "no", supports: ["05:06"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.61, + source: "dasha_boundary", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, +}; + +const NAKSHATRA: ConflictProbe = { + id: "probe:nakshatra.ashlesha", + semantic_key: "nakshatra.ashlesha/magha", + candidate_split_hash: "nakshatra.ashlesha", + domain: "appearance", + year: 0, + question: "外表或体质更接近哪一种?", + candidate_ids: [...ACTIVE], + expected_outcomes: [ + { answer_class: "yes", supports: [...ACTIVE], conflicts: [] }, + { answer_class: "weak_yes", supports: [...ACTIVE], conflicts: [] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.4, + source: "nakshatra_boundary", + choice_kind: "varga_style", +}; + +const DASHA_ACTIVATION: ConflictProbe = { + id: "probe:career.2023.dasha_activation", + semantic_key: "career.2023.dasha_activation", + candidate_split_hash: "career.2023.activation", + domain: "career", + year: 2023, + question: "2023 年前后大运有没有启动?", + candidate_ids: [...TIMES], + expected_outcomes: [ + { answer_class: "yes", supports: [...ACTIVE, ...WINDOW_ENDS], conflicts: [] }, + { answer_class: "weak_yes", supports: [], conflicts: [] }, + { answer_class: "no", supports: [...ACTIVE, ...WINDOW_ENDS], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.56, + source: "dasha_activation", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, +}; + +function windowEndQuality(id: string, semantic: string): ConflictProbe { + return { + id, + semantic_key: semantic, + candidate_split_hash: semantic, + domain: "relationship", + year: 2024, + question: "2024 年这段感情的质量更接近哪一种?", + candidate_ids: [...WINDOW_ENDS], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:45", "04:47"], conflicts: ["05:08", "05:12", "05:15"] }, + { answer_class: "no", supports: ["05:08", "05:12", "05:15"], conflicts: ["04:45", "04:47"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.44, + source: "known_event_quality", + choice_kind: "event_quality", + }; +} + +const QUALITY_A = windowEndQuality( + "probe:relationship.2024.known_event_quality.a", + "relationship.2024.known_event_quality.a", +); +const QUALITY_B = windowEndQuality( + "probe:relationship.2024.known_event_quality.b", + "relationship.2024.known_event_quality.b", +); + +const D24 = vargaExistence({ + layer: "d24", + gain: 2.5, + domain: "education", + question: "有没有学业或考试发挥明显失常、压力特别大的时候?", +}); +const D12 = vargaExistence({ + layer: "d12", + gain: 1.89, + domain: "family", + question: "家里有没有结婚、添丁或住院这类事?", +}); +const D7 = vargaExistence({ + layer: "d7", + gain: 1.35, + domain: "family", + question: "有没有子女或子嗣相关的家里变化?", +}); +const D4 = vargaExistence({ + layer: "d4", + gain: 0.99, + domain: "relocation", + question: "有没有搬家或长期住到外地?", +}); +const D5 = vargaExistence({ + layer: "d5", + gain: 0.5, + domain: "education", + question: "有没有记得住年份的升学或考试?", +}); + +const ANSWERED = [D9, D10, CAREER_2023, CAREER_2024, RELOCATION_2015] as const; + +function windowScan() { + return { + scanned: true, + confirmation_allowed: false, + unique_minute_claim: false, + d9_lagna_count: 2, + d10_lagna_count: 2, + d4_lagna_count: 2, + d5_lagna_count: 2, + d7_lagna_count: 2, + d12_lagna_count: 2, + d24_lagna_count: 2, + d9_candidates_differ: true, + d10_candidates_differ: true, + d4_candidates_differ: true, + d5_candidates_differ: true, + d7_candidates_differ: true, + d12_candidates_differ: true, + d24_candidates_differ: true, + }; +} + +function fourteenProbeState(): InferenceState { + const probes: ConflictProbe[] = [ + ...ANSWERED, + NAKSHATRA, + D24, + D12, + D7, + D4, + D5, + DASHA_ACTIVATION, + QUALITY_A, + QUALITY_B, + ]; + const active = [ + { time: "05:00", score: 21, probability: 0.44 }, + { time: "05:06", score: 18, probability: 0.38 }, + { time: "04:53", score: 9, probability: 0.18 }, + ] as const; + const eliminated = TIMES.filter((time) => !active.some((item) => item.time === time)); + const orderedTimes = [...active.map((item) => item.time), ...eliminated]; + return { + algorithm_version: "rectification-inference-v1", + candidate_set_id: candidateSetId("04:45", "05:15", orderedTimes), + revision: 6, + phase: "discrimination", + result_status: "discriminating", + range_start: "04:45", + range_end: "05:15", + candidates: [ + ...active.map((item, index) => ({ + id: item.time, + time: item.time, + cluster_range: [item.time, item.time] as const, + prior_score: item.score, + posterior_score: item.score, + probability: item.probability, + status: "active" as const, + rank: index + 1, + strong_conflict_count: 0, + })), + ...eliminated.map((time, index) => ({ + id: time, + time, + cluster_range: [time, time] as const, + prior_score: 4 - index, + posterior_score: 4 - index, + probability: 0, + status: "eliminated" as const, + rank: active.length + index + 1, + strong_conflict_count: 3, + })), + ], + events: [ + { id: "e-education", domain: "education", year: 2016, precision: "year", usage: "holdout" }, + { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "training" }, + { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" }, + { id: "e-career-entry", domain: "career", year: 2020, precision: "month", usage: "training" }, + { id: "e-career-exit", domain: "career", year: 2020, precision: "month", usage: "training" }, + ], + probes, + answered_probes: ANSWERED.map((probe) => ({ + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + answer_class: "no" as const, + classified_from: "choice" as const, + })), + rounds: [], + last_inference_round: null, + entropy: 1.08, + representative_time: "05:00", + credible_range: ["05:00", "05:06"], + holdout_passed: true, + }; +} + +function caseDossier(extra?: { + declinedTopics?: ReadonlyArray>; + acceptedTime?: string | null; +}): DecisionDossier { + const state = fourteenProbeState(); + return { + evidence: EVIDENCE, + conversationSummary: { + activeFocus: null, + declinedSkippedTopics: extra?.declinedTopics ?? [ + { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, + ], + }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + selectionAllowed: true, + confirmationAllowed: false, + evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never), + candidates: state.candidates.map((candidate, index) => ({ + candidateId: `77777777-7777-4777-8777-${String(index + 1).padStart(12, "0")}`, + time: candidate.time, + rank: candidate.rank, + relativeSupport: Math.round(candidate.posterior_score), + })), + representativeTime: state.representative_time, + decisionReceipt: { + acceptance_allowed: true, + accept_allowed: true, + propose_allowed: true, + selection_allowed: true, + confirmation_allowed: false, + inference_state: state, + window_scan: windowScan(), + oos_blind_prompts: OOS_BLIND, + }, + }, + case: { + acceptedTime: extra?.acceptedTime ?? null, + candidateRange: { start_time: "04:45", end_time: "05:15" }, + }, + }; +} + +function planFrom( + dossier: DecisionDossier, + extra: Partial[0]> = {}, +) { + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + return buildMethodFollowupPlan({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: decision.sessionOutcome, + ...catalog, + candidatesSeparated: false, + ...extra, + }); +} + +function rpcDossier(decision: DecisionDossier, activeFocus?: Record | null) { + const evidence = decision.evidence.map((item) => ({ + id: item.id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind ?? "event", + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-04T00:00:00.000Z", + })); + const fingerprint = evidenceLedgerFingerprint(evidence.map((item) => ({ + id: item.id, + sourceTurnId: item.source_turn_id, + subject: item.subject, + eventKind: item.event_kind, + domain: item.domain, + occurredFrom: item.occurred_from, + occurredTo: item.occurred_to, + datePrecision: item.date_precision, + summary: item.summary, + status: item.status, + supersedesEvidenceId: item.supersedes_evidence_id, + createdAt: item.created_at, + dateSource: null, + dateReliability: null, + dateCorroboration: null, + dateConflictStatus: null, + })) as never); + return dossierFixture({ + candidateRange: { start_time: "04:45", end_time: "05:15" }, + evidence, + latestResult: candidateSnapshotFixture({ + candidates: decision.latestResult?.candidates?.map((item, index) => ({ + candidate_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + time: item.time, + rank: item.rank ?? index + 1, + relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)), + tied_minute_count: item.tiedMinuteCount ?? 1, + })), + representativeTime: decision.latestResult?.representativeTime ?? null, + evidenceLedgerFingerprint: fingerprint, + decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: activeFocus ?? null, + declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-04T00:00:00.000Z", + }, + }); +} + +function familyCollectFocus() { + return { + 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: { + collect: true, + prompt: "2021 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。", + }, + status: "active", + asked_at: "2026-09-04T00:00:00.000Z", + resolved_at: null, + }; +} + +function adoptAccounting(dossier: DecisionDossier, activeFocus?: Record | null) { + return fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(dossier, activeFocus), + 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: () => { + throw new Error("adoptable offer must not persist another question"); + }, + }); +} + +function assertAdoptTemplate(text: string) { + assert.match(text, /分不开 05:00 和 05:06/); + assert.match(text, /可以从下面选一个先用着/); + assert.doesNotMatch(text, /继续往下收/); +} + +function assertNoFocusWrite(accounting: ReturnType) { + assert.equal( + accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), + false, + ); +} + +test("fourteen-probe case decides offer_provisional_range and skips leftover probes", () => { + const dossier = caseDossier(); + const state = fourteenProbeState(); + assert.equal(dossier.evidence.length, 5); + assert.equal(state.probes.length, 14); + assert.equal(state.answered_probes.length, 5); + assert.deepEqual( + state.candidates.filter((item) => item.status === "active").map((item) => [item.time, item.posterior_score]), + [["05:00", 21], ["05:06", 18], ["04:53", 9]], + ); + + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + assert.equal(decision.nextAction, "offer_provisional_range"); + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(decision.canAdopt, true); + assert.equal(decision.probe, null); + assert.equal(decision.precisionStage, "ready_to_adopt"); + + const plan = planFrom(dossier); + assert.equal(plan.next_followup, null); + // Task text said "null or choice_frame". The lock is: no frameless distinguish + // in deferred_followup. Adopt may still stash a later collect (eight-method). + if (plan.deferred_followup?.intent === "distinguish_candidates") { + assert.ok(plan.deferred_followup.choice_frame); + } +}); + +test("persistNextInterviewAfterChoice narrates the stop reason and writes no focus", async () => { + const dossier = caseDossier(); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const accounting = adoptAccounting(dossier); + const persisted = await persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: fourteenProbeState(), + nextAction: publicNextAction(decision), + birthDate: "1997-08-08", + }); + assert.equal(persisted.persisted, false); + assert.equal(persisted.choiceReady, false); + assertAdoptTemplate(persisted.hostNarration); + assertNoFocusWrite(accounting); +}); + +test("adoptDeliveryFacts names the representative, range, stop class, and post-adopt checks", () => { + const dossier = caseDossier(); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const facts = adoptDeliveryFacts(decision, dossier); + assert.equal(facts.representative_minute, "05:00"); + assert.deepEqual(facts.credible_range, ["05:00", "05:06"]); + const split = facts.stop_facts.find((item) => item.kind === "indistinguishable"); + assert.ok(split); + assert.match(split!.label, /分不开/); + assert.ok((split!.count ?? 0) >= 1); + assert.ok(facts.post_adopt_verification.some((item) => ( + item.kind === "holdout" && item.domain === "education" && item.year === 2016 + ))); + assert.ok(facts.post_adopt_verification.some((item) => item.kind === "oos" && item.domain === "family")); + assert.ok(facts.post_adopt_verification.some((item) => item.kind === "oos" && item.domain === "finance")); + assert.ok(facts.post_adopt_verification.some((item) => ( + item.kind === "oos" && (item.domain === "health" || item.domain === "health_pressure") + ))); +}); + +test("adopt narration agent keeps in-fact copy and fail-closes the rest", 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,代表分钟 05:00。采用后会用 2016 年学业经历核对。"; + const valid = await createAdoptNarrationWriter({ + generateText: async () => kept, + })(facts, fallback); + assert.match(valid, /05:00/); + assert.match(valid, /2016/); + assert.ok(valid.endsWith(RECTIFICATION_USER_COPY.adoptCue)); + + const unknownMinute = await createAdoptNarrationWriter({ + generateText: async () => "更像 04:58,不要再问了。", + })(facts, fallback); + assert.equal(unknownMinute, fallback); + assert.equal(validateAdoptNarration("更像 04:58,不要再问了。", facts).ok, false); + + const question = await createAdoptNarrationWriter({ + generateText: async () => "还要不要再问?", + })(facts, fallback); + assert.equal(question, fallback); + + const aborted = await createAdoptNarrationWriter({ + generateText: async () => { + throw new Error("aborted"); + }, + })(facts, fallback); + assert.equal(aborted, fallback); + + const unknownSupport = await createAdoptNarrationWriter({ + generateText: async () => "相对支持度 99,先用 05:00。", + })(facts, fallback); + assert.equal(unknownSupport, fallback); + assert.equal(validateAdoptNarration("相对支持度 99,先用 05:00。", facts).ok, false); +}); + +test("three adopt entry points call the model once on first ready_to_adopt and not after accept", async () => { + const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + assert.match(route, /createAdoptNarrationWriter/); + assert.match(route, /applyRectificationChoice\([\s\S]*narrateAdopt/); + assert.match(route, /applyCollectFocusDenial\([\s\S]*narrateAdopt/); + assert.match(route, /persistNextInterviewIfIdle\(\{[\s\S]*narrateAdopt/); + + const dossier = caseDossier(); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。"; + + function withCounter() { + let calls = 0; + const narrateAdopt = createAdoptNarrationWriter({ + generateText: async () => { + calls += 1; + return kept; + }, + }); + return { narrateAdopt, count: () => calls }; + } + + const afterChoice = withCounter(); + const accountingA = adoptAccounting(dossier); + await persistNextInterviewAfterChoice({ + accounting: accountingA.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: fourteenProbeState(), + nextAction: publicNextAction(decision), + birthDate: "1997-08-08", + narrateAdopt: afterChoice.narrateAdopt, + }); + assert.equal(afterChoice.count(), 1); + assertNoFocusWrite(accountingA); + + const afterDenial = withCounter(); + let loads = 0; + const accountingB = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => { + loads += 1; + if (loads === 1) return rpcDossier(dossier, familyCollectFocus()); + return rpcDossier(caseDossier({ + declinedTopics: [ + { target_domain: "family", intent: "collect_method_evidence", 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: () => { + throw new Error("adoptable offer must not persist another question"); + }, + }); + const denied = await applyCollectFocusDenial(accountingB.client, { + userId: USER_ID, + caseId: CASE_ID, + focusId: FOCUS_ID, + narrateAdopt: afterDenial.narrateAdopt, + }); + assert.equal(afterDenial.count(), 1); + assert.match(denied.narration, /05:00/); + assert.ok(denied.narration.includes(RECTIFICATION_USER_COPY.adoptCue)); + assert.equal(denied.nextInterviewPersisted, false); + assertNoFocusWrite(accountingB); + + const idle = withCounter(); + const accountingC = adoptAccounting(dossier); + await persistNextInterviewIfIdle({ + accounting: accountingC.client, + userId: USER_ID, + caseId: CASE_ID, + narrateAdopt: idle.narrateAdopt, + }); + assert.equal(idle.count(), 1); + + const accepted = withCounter(); + const acceptedDossier = caseDossier({ acceptedTime: "05:00" }); + const acceptedRpc = rpcDossier(acceptedDossier); + (acceptedRpc.case as { accepted_time: string | null }).accepted_time = "05:00"; + const accountingD = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => acceptedRpc, + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: () => ({ + focus: familyCollectFocus(), + idempotent: false, + }), + }); + await persistNextInterviewIfIdle({ + accounting: accountingD.client, + userId: USER_ID, + caseId: CASE_ID, + narrateAdopt: accepted.narrateAdopt, + }); + assert.equal(accepted.count(), 0); +}); + +test("applyCollectFocusDenial on the family collect uses the same adopt template", async () => { + const dossier = caseDossier(); + let loads = 0; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => { + loads += 1; + if (loads === 1) return rpcDossier(dossier, familyCollectFocus()); + return rpcDossier(dossier); + }, + 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: () => { + throw new Error("adoptable offer must not persist another question"); + }, + }); + const applied = await applyCollectFocusDenial(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + focusId: FOCUS_ID, + }); + assertAdoptTemplate(applied.narration); + assert.equal(applied.nextInterviewPersisted, false); + assertNoFocusWrite(accounting); +}); + +test("distinguish declined does not cover d10_career or drop dated career probes", () => { + const evidence = [ + { + id: "e-rel", + status: "confirmed", + domain: "relationship", + datePrecision: "year" as const, + occurredFrom: "2024-01-01", + occurredTo: null, + eventKind: "relationship_end", + }, + { + id: "e-family", + status: "confirmed", + domain: "family", + datePrecision: "year" as const, + occurredFrom: "2021-01-01", + occurredTo: null, + eventKind: "family_event", + }, + { + id: "e-edu", + status: "confirmed", + domain: "education", + datePrecision: "year" as const, + occurredFrom: "2016-01-01", + occurredTo: null, + eventKind: "education_start", + }, + { + id: "e-rel-2", + status: "confirmed", + domain: "relationship", + datePrecision: "month" as const, + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + }, + ]; + const careerProbe = { + year: 2023, + year_label: "2023 年前后", + domain: "career" as const, + event_family: "入职或换工作", + source: "dasha_boundary" as const, + tracks: ["vimshottari" as const, "narayana" as const], + tracks_agree: true, + unique_minute_claim: false as const, + user_meaning: "2023 年前后有没有入职或换工作?", + role: "distinguish" as const, + phase: "candidate_discriminator" as const, + information_gain: 0.8, + semantic_key: "career.2023.dasha_boundary", + candidate_split_hash: "career.2023", + candidate_ids: ["05:00", "05:06"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, + { answer_class: "no", supports: ["05:06"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + choice_kind: "existence" as const, + style_options: EXISTENCE_OPTIONS, + }; + const plan = buildMethodFollowupPlan({ + evidence, + declinedTopics: [ + { target_domain: "career", intent: "distinguish_candidates", status: "declined" }, + ], + sessionOutcome: "discriminate_candidates", + eventProbes: [careerProbe], + topCandidateTimes: ["05:00", "05:06"], + candidatesSeparated: false, + }); + assert.notEqual(plan.methods.find((item) => item.method_id === "d10_career")?.status, "covered"); + assert.equal(plan.next_followup?.domain, "career"); + assert.equal(plan.next_followup?.semantic_key, "career.2023.dasha_boundary"); + assert.ok(plan.next_followup?.choice_frame); +}); + +test("family collect declined vs extra distinguish declined leaves the same adopt decision", async () => { + const familyOnly = caseDossier({ + declinedTopics: [ + { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, + ], + }); + const withDistinguish = caseDossier({ + declinedTopics: [ + { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, + { target_domain: "career", intent: "distinguish_candidates", status: "declined" }, + { target_domain: "relocation", intent: "distinguish_candidates", status: "declined" }, + ], + }); + const left = decideFromDossier(familyOnly, { birthDate: "1997-08-08" }); + const right = decideFromDossier(withDistinguish, { birthDate: "1997-08-08" }); + assert.equal(left.nextAction, right.nextAction); + assert.equal(left.sessionOutcome, right.sessionOutcome); + assert.equal(left.canAdopt, right.canAdopt); + assert.equal(left.precisionStage, right.precisionStage); + + const accountingLeft = adoptAccounting(familyOnly); + const accountingRight = adoptAccounting(withDistinguish); + const narratedLeft = await persistNextInterviewAfterChoice({ + accounting: accountingLeft.client, + userId: USER_ID, + caseId: CASE_ID, + dossier: familyOnly, + decisionState: fourteenProbeState(), + nextAction: publicNextAction(left), + birthDate: "1997-08-08", + }); + const narratedRight = await persistNextInterviewAfterChoice({ + accounting: accountingRight.client, + userId: USER_ID, + caseId: CASE_ID, + dossier: withDistinguish, + decisionState: fourteenProbeState(), + nextAction: publicNextAction(right), + birthDate: "1997-08-08", + }); + assert.equal(narratedLeft.hostNarration, narratedRight.hostNarration); + assertAdoptTemplate(narratedLeft.hostNarration); +}); diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 8b8a61d6..c07bcee2 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -1042,8 +1042,13 @@ test("failed card persist still leaves a spoken collect next step", () => { assert.doesNotMatch(source, /if \(fallback\.status === "created"/); const applied = source.slice(source.indexOf("async function persistApplied")); const loadAt = applied.indexOf("loadV9CaseCompute"); - const decideAt = applied.indexOf("publicNextAction(decideAfterInferenceChange"); + // 原值: publicNextAction(decideAfterInferenceChange({...})) 内联一次。 + // 新值: 先 decideAfterInferenceChange 得到 nextDecision,再 publicNextAction(nextDecision)。 + // 原因: 采用旁白需要完整 decision(precisionStage / droppedProbes),不能只拿公开 nextAction。 + const decideAt = applied.indexOf("decideAfterInferenceChange({"); + const publicAt = applied.indexOf("publicNextAction(nextDecision)"); assert.ok(loadAt >= 0 && decideAt >= 0 && loadAt < decideAt); + assert.ok(publicAt > decideAt); assert.match(applied.slice(decideAt, decideAt + 280), /birthDate/); assert.match( applied.slice(applied.indexOf("persistNextInterviewAfterChoice"), applied.indexOf("persistNextInterviewAfterChoice") + 360), diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index be7480f0..c96ea0e3 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -627,8 +627,10 @@ test("occupation collect denial declines the focus and advances coverage to hora assert.equal(resolve?.args.p_status, "declined"); assert.notEqual(resolve?.args.p_status, "resolved"); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); - assert.equal(setFocus?.args.p_target_domain, "horary"); - assert.equal(setFocus?.args.p_intent, "collect_method_evidence"); + // 原值: p_target_domain === "horary"(DB check 不含 horary,写库必然失败)。 + // 新值: 不把 horary 落成口述采集焦点。 + // 原因: appearance / marks / horary / nakshatra 不得成为 collect 焦点。 + assert.notEqual(setFocus?.args.p_target_domain, "horary"); assert.ok(applied.narration); const horary = planFrom(revision5Dossier(revision5State(), { @@ -737,6 +739,47 @@ function occupationDossier() { }); } +const NAKSHATRA: ConflictProbe = { + id: "probe:nakshatra.ashlesha", + semantic_key: "nakshatra.ashlesha/magha", + candidate_split_hash: "nakshatra.ashlesha", + domain: "appearance", + year: 0, + question: "外表或体质更接近哪一种?", + candidate_ids: ["05:00", "05:07", "04:53"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00", "05:07", "04:53"], conflicts: [] }, + { answer_class: "weak_yes", supports: ["05:00", "05:07"], conflicts: [] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.4, + source: "nakshatra_boundary", + choice_kind: "varga_style", +}; + +function liveWindowScan() { + return { + scanned: true, + confirmation_allowed: false, + unique_minute_claim: false, + d9_lagna_count: 2, + d10_lagna_count: 2, + d4_lagna_count: 2, + d5_lagna_count: 2, + d7_lagna_count: 2, + d12_lagna_count: 2, + d24_lagna_count: 2, + d9_candidates_differ: true, + d10_candidates_differ: true, + d4_candidates_differ: true, + d5_candidates_differ: true, + d7_candidates_differ: true, + d12_candidates_differ: true, + d24_candidates_differ: true, + }; +} + function liveCaseState(): InferenceState { const times = CANDIDATES.map((candidate) => candidate.time); const answered = [D9, D10, RELOCATION_2015, D24, D7]; @@ -786,7 +829,7 @@ function liveCaseState(): InferenceState { { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "training" }, { id: "e-education", domain: "education", year: 2016, precision: "year", usage: "training" }, ], - probes: [...answered, CAREER_2023_ACTIVATION], + probes: [...answered, CAREER_2023_ACTIVATION, NAKSHATRA], answered_probes: answered.map((probe) => ({ probe_id: probe.id, semantic_key: probe.semantic_key, @@ -830,6 +873,7 @@ function liveCaseDossier(): DecisionDossier { selection_allowed: true, confirmation_allowed: false, inference_state: state, + window_scan: liveWindowScan(), }, }, case: { acceptedTime: null }, diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index 492d0060..56819683 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -722,7 +722,11 @@ test("collect persist maps occupation to other and health_pressure to health", a assert.equal(persistableFocusDomain("occupation"), "other"); assert.equal(persistableFocusDomain("health_pressure"), "health"); assert.equal(persistableFocusDomain("education"), "education"); - assert.equal(persistableFocusDomain("horary"), "horary"); + // Was: persistableFocusDomain("horary") === "horary". + // Now: null. DB check excludes horary; the old assertion locked a write that always failed. + assert.equal(persistableFocusDomain("horary"), null); + assert.equal(persistableFocusDomain("appearance"), null); + assert.equal(persistableFocusDomain("nakshatra"), null); assert.equal(persistableFocusDomain(null), null); assert.equal(persistableFocusDomain("unknown"), null); // 旧 collect:education → 新 collect:other → 保留 unknown/null 不得写入 unknown @@ -808,3 +812,35 @@ test("collect focus unique conflict retries with a :next question id", async () `collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`, ); }); + +test("appearance distinguish without a frame is skipped with zero RPC", async () => { + const followup: MethodFollowup = { + method_id: "nakshatra_boundary", + intent: "distinguish_candidates", + ask_theme: "nakshatra_trait", + domain: "appearance", + kind_hint: null, + user_prompt_hint: "外表或体质更接近哪一种?", + must_not_label: false, + choice_frame: null, + source: "nakshatra_boundary", + }; + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: () => { + throw new Error("appearance collect must not be written"); + }, + }); + const persisted = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup, + }); + assert.equal(persisted.status, "skipped"); + assert.equal( + accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), + false, + ); +});