fix(rectification): narrate exhausted-probe adopt instead of keep-collecting
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:
@@ -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`,明确财富问题不会因时间词先出现而漏装外部证据。
|
||||
|
||||
@@ -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`
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user