Files
Jyotisha/frontend/tests/rectification-adopt-narration-20260904.test.ts
T
Jesse_ChenandCursor a43a6db884
Independent Staging Quality Gate / validate (push) Successful in 14m8s
Independent Staging Quality Gate / publish (push) Successful in 10m21s
fix(rectification): three-column candidate compare card (BUG-597, BUG-598)
Replace the minute-row delivery card with up to three compare columns so users can pick the time that fits, and apply the adult-year floor on inspect fallbacks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 00:12:20 +08:00

1298 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
templatePostAdoptExplain,
templateRangeReadingExplain,
validateAdoptNarration,
RANGE_READING_COPY,
type AdoptDeliveryFacts,
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
import {
ADOPT_NARRATION_INSTRUCTIONS,
createAdoptNarrationWriter,
deliverAdoptNarration,
} 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, USER_COLLECT_QUESTION } 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 EXHAUSTED_COLLECT_TOPICS = [
{ target_domain: "family", intent: "collect_method_evidence", status: "declined" },
{ target_domain: "finance", intent: "collect_method_evidence", status: "declined" },
{ target_domain: "relocation", intent: "collect_method_evidence", status: "declined" },
{ target_domain: "health_pressure", intent: "collect_method_evidence", status: "declined" },
{ target_domain: "occupation", intent: "collect_method_evidence", status: "declined" },
] 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 preClickFourteenProbeState(): InferenceState {
const last = ANSWERED[ANSWERED.length - 1]!;
const state = fourteenProbeState();
return {
...state,
revision: 5,
answered_probes: state.answered_probes.filter((item) => item.probe_id !== last.id),
candidates: state.candidates.map((item) => {
if (item.time === "05:00") {
return { ...item, prior_score: 23, posterior_score: 23 };
}
if (item.time === "05:06") {
return { ...item, prior_score: 16, posterior_score: 16 };
}
if (item.time === "04:53") {
return { ...item, prior_score: 7, posterior_score: 7 };
}
return item;
}),
};
}
function caseDossier(extra?: {
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
acceptedTime?: string | null;
state?: InferenceState;
}): DecisionDossier {
const state = extra?.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 collectAccounting(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: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-04T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
}
function assertAdoptTemplate(text: string) {
// 原值: 「分不开 05:00 和 05:06」+ adoptCue
// 新值: 三句交付,不含八法报告
// 原因: BUG-595 决策 4
assert.match(text, /这次给出的范围 05:0005:06/);
assert.doesNotMatch(text, /排盘用/);
assert.match(text, /对照了 5 件经历/);
assert.match(text, /这只是代表性候选,不是已确认的唯一出生分钟/);
assert.doesNotMatch(text, /继续往下收/);
assert.doesNotMatch(text, /方法1|Technique Audit/);
}
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" });
// 旧:十四探针分不开就 offer_provisional_range / adopt_representative,财务采集只挂计划。
// 新:财务口述未问完,决策保持采集;出牌轮才写报告。
assert.equal(decision.nextAction, "ask_fact_collection");
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.canAdopt, true);
assert.equal(decision.probe, null);
assert.equal(decision.precisionStage, "collect_events");
const plan = planFrom(dossier);
assert.equal(plan.next_followup?.domain, "finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
// 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 after family denial collects remaining dated events", async () => {
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const accounting = collectAccounting(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, true);
assert.equal(persisted.followup?.domain, "finance");
assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.finance);
});
test("persistNextInterviewAfterChoice narrates the stop reason once dated collect is exhausted", async () => {
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
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);
// 旧:holdout/oos 出现在 post_adopt_verification
// 新:仅出现采用后真会问的 reverse_verify 项,且与 buildMethodFollowupPlan({accepted:true}) 首题一致
// 原因:决策 3 推翻原任务书 §4
assert.equal(facts.post_adopt_verification.every((item) => item.kind === "reverse_verify"), true);
const acceptedPlan = planFrom(dossier, { accepted: true });
if (acceptedPlan.next_followup) {
assert.equal(facts.post_adopt_verification[0]?.domain, acceptedPlan.next_followup.domain);
} else {
assert.deepEqual(facts.post_adopt_verification, []);
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
}
});
test("adopt narration agent keeps in-fact copy and fail-closes the rest", async () => {
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
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);
const deniedPromise = "这不是确认的分钟,先用 05:00。";
assert.equal(validateAdoptNarration(deniedPromise, facts).ok, false);
const denied = await createAdoptNarrationWriter({
generateText: async () => deniedPromise,
})(facts, fallback);
assert.equal(denied, fallback);
assert.match(ADOPT_NARRATION_INSTRUCTIONS, /不要出现/);
});
test("three adopt entry points call the model once on first ready_to_adopt and not after accept", async () => {
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 source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const persistFn = source.slice(
source.indexOf("export async function persistNextInterviewAfterChoice"),
source.indexOf("async function persistFocusAfterChoice"),
);
assert.doesNotMatch(persistFn, /decideFromDossier\(/);
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const preClick = caseDossier({
declinedTopics: EXHAUSTED_COLLECT_TOPICS,
state: preClickFourteenProbeState(),
});
const preClickDecision = decideFromDossier(preClick, { birthDate: "1997-08-08" });
assert.notEqual(preClickDecision.precisionStage, "ready_to_adopt");
assert.ok(preClickDecision.probe);
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
function withCounter() {
let calls = 0;
const seen: AdoptDeliveryFacts[] = [];
const narrateAdopt = createAdoptNarrationWriter({
generateText: async (facts) => {
calls += 1;
seen.push(facts);
return kept;
},
});
return { narrateAdopt, count: () => calls, seen };
}
const afterChoice = withCounter();
const accountingA = adoptAccounting(preClick);
await persistNextInterviewAfterChoice({
accounting: accountingA.client,
userId: USER_ID,
caseId: CASE_ID,
dossier: preClick,
decisionState: fourteenProbeState(),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
narrateAdopt: afterChoice.narrateAdopt,
});
assert.equal(afterChoice.count(), 1);
assert.equal(afterChoice.seen[0]?.representative_minute, "05:00");
assert.equal(afterChoice.seen[0]?.precision_stage, "ready_to_adopt");
assert.deepEqual(
afterChoice.seen[0]?.active_candidates.slice(0, 3).map((item) => [item.time, item.relative_support]),
[["05:00", 21], ["05:06", 18], ["04:53", 9]],
);
assertNoFocusWrite(accountingA);
const invalidChoice = withCounter();
const invalidWriter = createAdoptNarrationWriter({
generateText: async (facts) => {
invalidChoice.seen.push(facts);
return "更像 04:58,不要再问了。";
},
});
const invalidAccounting = adoptAccounting(preClick);
const invalid = await persistNextInterviewAfterChoice({
accounting: invalidAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier: preClick,
decisionState: fourteenProbeState(),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
narrateAdopt: invalidWriter,
});
assertAdoptTemplate(invalid.hostNarration);
assert.match(invalid.hostNarration, /05:00/);
assert.doesNotMatch(invalid.hostNarration, /04:58/);
const afterDenial = withCounter();
let loads = 0;
const accountingB = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) return rpcDossier(dossier, familyCollectFocus());
return rpcDossier(caseDossier({
declinedTopics: EXHAUSTED_COLLECT_TOPICS,
}));
},
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("adopt narration diagnostics distinguish agent, validation, error, and not-ready", async () => {
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
const agent = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => kept,
});
assert.equal(agent.adopt_narration, "agent");
assert.match(agent.text, /05:00/);
const invalid = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => "更像 04:58,不要再问了。",
});
assert.equal(invalid.adopt_narration, "template:unknown_minute");
assert.equal(invalid.text, fallback);
const errored = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => {
throw new Error("model down");
},
});
assert.equal(errored.adopt_narration, "template:model_error");
assert.equal(errored.text, fallback);
const notReady = await deliverAdoptNarration({
facts: { ...facts, already_accepted: true },
fallback,
generateText: async () => kept,
});
assert.equal(notReady.adopt_narration, "template:not_ready");
assert.equal(notReady.text, fallback);
});
test("adopt narration times out to the template without throwing", async () => {
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
const timed = await deliverAdoptNarration({
facts,
fallback,
timeoutMs: 30,
generateText: async () => new Promise(() => {}),
});
assert.equal(timed.adopt_narration, "template:model_error");
assert.equal(timed.text, fallback);
});
test("adopt narration does not leave an active timeout after the model returns", async () => {
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
const source = readFileSync(
new URL("../src/lib/rectification-agentic/v9/adopt-narration-agent.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(source, /AbortSignal\.timeout/);
assert.match(source, /clearTimeout/);
const pending = new Set<unknown>();
let created = 0;
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
globalThis.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
created += 1;
const id = realSetTimeout(handler, delay, ...args);
pending.add(id);
return id;
}) as typeof setTimeout;
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
pending.delete(id);
realClearTimeout(id);
}) as typeof clearTimeout;
try {
const delivered = await deliverAdoptNarration({
facts,
fallback,
timeoutMs: 8_000,
generateText: async () => kept,
});
assert.equal(delivered.adopt_narration, "agent");
assert.ok(created >= 1);
assert.equal(pending.size, 0);
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
}
});
test("applyCollectFocusDenial on the family collect keeps dated collect instead of adopting", 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: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-04T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
});
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.narration, USER_COLLECT_QUESTION.finance);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus?.args.p_target_domain, "finance");
});
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 = collectAccounting(familyOnly);
const accountingRight = collectAccounting(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);
assert.equal(narratedLeft.hostNarration, USER_COLLECT_QUESTION.finance);
assert.equal(narratedLeft.persisted, true);
});
test("range-reading explain uses theme_sensitivity labels and the unique-minute boundary", () => {
const text = templateRangeReadingExplain({
widthMinutes: 27,
stableThemes: ["career", "general"],
sensitiveThemes: ["marriage"],
});
assert.match(text ?? "", /这 27 分钟里/);
assert.match(text ?? "", /事业方向/);
assert.match(text ?? "", /性格底色/);
assert.match(text ?? "", /稳定/);
assert.match(text ?? "", /婚恋(D9/);
assert.match(text ?? "", /随分钟变/);
assert.match(text ?? "", /按范围读/);
assert.ok(text?.includes(RANGE_READING_COPY.boundary));
assert.doesNotMatch(text ?? "", /这只是粗看/);
});
test("adopt skip-followup appends range-reading sentences when the engine answers", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/rectification/v5/range_reading")) {
return new Response(JSON.stringify({
stable_themes: ["career", "general"],
sensitive_themes: ["marriage"],
claim_boundary: RANGE_READING_COPY.boundary,
}), { status: 200, headers: { "content-type": "application/json" } });
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
try {
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
const persisted = await persistNextInterviewIfIdle({
accounting: adoptAccounting(dossier).client,
userId: USER_ID,
caseId: CASE_ID,
});
// 原值: 交付旁白拼接 range_reading 事业方向 / 婚恋(D9
// 新值: 只写三句交付;八法/主题对照不进气泡
// 原因: BUG-595 决策 4
assertAdoptTemplate(persisted.hostNarration ?? "");
assert.doesNotMatch(persisted.hostNarration ?? "", /事业方向/);
assert.doesNotMatch(persisted.hostNarration ?? "", /性格底色/);
assert.doesNotMatch(persisted.hostNarration ?? "", /婚恋(D9/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("adopt skip-followup omits range-reading sentences when the engine is down", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error("engine down");
}) as typeof fetch;
try {
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
const persisted = await persistNextInterviewIfIdle({
accounting: adoptAccounting(dossier).client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.ok(persisted.hostNarration);
assert.doesNotMatch(persisted.hostNarration ?? "", /随分钟变/);
assert.doesNotMatch(persisted.hostNarration ?? "", /这只是粗看/);
} finally {
globalThis.fetch = originalFetch;
}
});