fix(rectification): narrate exhausted-probe adopt instead of keep-collecting
Independent Staging Quality Gate / validate (push) Successful in 10m33s
Independent Staging Quality Gate / publish (push) Successful in 1m55s

When leftover probes cannot split adjacent minutes, skip frameless follow-ups and let a no-tool agent explain the stop. Distinguish-card "no" no longer closes a whole evidence domain.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 14:22:13 +08:00
co-authored by Cursor
parent cfa824994e
commit 0aaa0d702e
18 changed files with 1757 additions and 51 deletions
+4
View File
@@ -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`,明确财富问题不会因时间词先出现而漏装外部证据。
+34
View File
@@ -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。
@@ -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`
+1 -1
View File
@@ -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) |
### 聊天主链路与首页
@@ -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. 真实环境复核清单(实现者做不了的,交给产品负责人)
@@ -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 线被标成拒答,后续年份题不再出。
@@ -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<Response | null> => {
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(
@@ -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 {
@@ -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<string> {
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<string>;
}): Promise<string> {
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<ResolvedLanguageModel | null>;
generateText?: (
facts: AdoptDeliveryFacts,
signal?: AbortSignal,
) => Promise<string>;
}): AdoptNarrationWriter {
return async (facts, fallback) => deliverAdoptNarration({
facts,
fallback,
model: input.model,
signal: input.signal,
generateText: input.generateText ?? (input.resolveModel
? async (nextFacts, signal) => {
const model = input.model ?? await input.resolveModel!();
if (!model) throw new Error("adopt_narration_model_unavailable");
return generateAdoptNarrationText(model, nextFacts, signal);
}
: undefined),
});
}
@@ -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 = /(?<!\d)((?:19|20)\d{2})(?!\d)/g;
const CLOCK_TOKEN = /\d{1,2}:\d{2}/g;
export function padClock(value: string | null | undefined): string | null {
const match = CLOCK.exec((value ?? "").trim());
if (!match) return null;
return `${match[1]!.padStart(2, "0")}:${match[2]}`;
}
function uniqueClocks(values: readonly (string | null | undefined)[]): string[] {
const seen = new Set<string>();
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<RectificationDecision, "precisionStage" | "credibleRange" | "representativeTime" | "droppedProbes">,
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<string> {
const allowed = new Set<string>();
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<number> {
const allowed = new Set<number>();
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<string>;
@@ -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<typeof decideFromDossier>[0];
decision: ReturnType<typeof decideFromDossier>;
receipt: Readonly<Record<string, unknown>> | 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<T extends {
conversationSummary: {
activeFocus: { targetDomain?: string | null } | null;
activeFocus: { targetDomain?: string | null; intent?: string | null } | null;
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
};
}>(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<typeof publicNextAction>;
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;
}
@@ -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")
@@ -369,12 +369,21 @@ function isOpeningOtherCollectTopic(topic: Readonly<Record<string, unknown>>): b
return topicDomain(topic) === "other" && topicQuestionId(topic).startsWith("collect:other:");
}
function topicIntent(topic: Readonly<Record<string, unknown>>): string {
return typeof topic.intent === "string" ? topic.intent : "";
}
function declinedDomains(
topics: readonly Readonly<Record<string, unknown>>[],
): Set<string> {
const domains = new Set<string>();
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],
};
}
@@ -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<MethodFollowup, "domain">): 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<PersistServerFocusResult> {
if (!followupHasPersistableDomain(input.followup)) {
return {
status: "skipped",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
}
return persistCollectFocus({
accounting: input.accounting,
userId: input.userId,
@@ -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<Record<string, unknown>>;
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<Parameters<typeof buildMethodFollowupPlan>[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<string, unknown> | 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<string, unknown> | 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<typeof fakeAccounting>) {
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);
});
@@ -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),
@@ -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 },
@@ -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,
);
});