Files
Jyotisha/frontend/tests/rectification-adopt-narration-20260904.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

1329 lines
49 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 } 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";
import { rectificationAgentRouteSurface } from "./rectification-agent-route-surface.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,
refresh_count: 1,
refresh_answer_count: ANSWERED.length,
};
}
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:00–05:06/);
assert.doesNotMatch(text, /排盘用/);
// 原值: assert.match(text, /对照了 5 件经历/)
// 新值: 「用了 5 道选择题」,且不含「吻合率」「对照了」「补一件」
// 原因: BUG-1085 交付正文数选择题、不数口述经历(TASK-rectification-futile-collect-stop-20260929 D2)
assert.match(text, /用了 5 道选择题/);
assert.doesNotMatch(text, /吻合率|对照了|补一件|再说一件/);
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" });
// 原值: leftover 财务采集挡住 / 再改成 ready_to_adopt
// 新值: D4 仍换升且迁居未覆盖,先定向补事
// 原因: 带年月池空不等于结束(BUG-654)
// 原值(2): nextAction ask_fact_collection / collect_evidence / canAdopt false;plan 下一问 relocation 定向补事
// 新值(2): 训练门已开、带年月池空 → ready_to_adopt / validated_range / canAdopt true;plan 无下一问
// 原因(2): 门开后不再口述采集,选择题问完即出卡(BUG-1084,2026-09-29 D1)
assert.equal(decision.nextAction, "ready_to_adopt");
assert.equal(decision.sessionOutcome, "validated_range");
assert.equal(decision.canAdopt, true);
assert.equal(decision.probe, null);
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 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",
});
// 原值: persist leftover 财务采集 / 再改成不写焦点、S3 交付
// 新值: 先落定向补事焦点
// 原因: 刷新后仍无带年月题时必须补事(BUG-654)
// 原值(2): persisted=true、下一问 relocation 定向补事、写焦点
// 新值(2): 不写焦点,直接交付三句(范围 + 用了几道选择题 + 边界句)
// 原因(2): 门开后不再口述采集(BUG-1084,2026-09-29 D1);测试名保留以便对照历史
assert.equal(persisted.persisted, false);
assert.equal(persisted.followup ?? null, null);
assertAdoptTemplate(persisted.hostNarration);
assertNoFocusWrite(accounting);
});
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 () => {
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
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("export async function requestTieBreakPersonality"),
);
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,
});
// 原值: 拒答家人后仍 persist 财务采集 / 再改成 S3 交付
// 新值: 拒答家人后改问迁居定向补事
// 原因: D4 仍换升(BUG-654)
// 原值(2): nextInterviewPersisted=true、旁白问搬家、写焦点
// 新值(2): 不再写采集焦点,旁白是交付三句
// 原因(2): 门开后不再口述采集(BUG-1084,2026-09-29 D1)
assert.doesNotMatch(applied.narration, /搬家|换城市|还能把剩下的候选分开|还能再收窄/);
assert.match(applied.narration, /目前范围 05:00–05:06/);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus, undefined);
});
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",
});
// 原值: /搬家|换城市|还能把剩下的候选分开/
// 新值: 两侧都是「现在还剩 …里 N 个候选」的定向补事行
// 原因: D5——七条线按 COLLECT_KIND_ORDER 轮到,多拒答一条区分题只少一条
// 线(搬家),两侧仍都停在采集、都落库(BUG-751)
// 原值(2): 两侧 persisted=true(仍在采集)
// 新值(2): 两侧都交付(persisted=false),旁白同为交付三句 + 「现在还剩 … 个候选」
// 原因(2): 门开后不再口述采集(BUG-1084,2026-09-29 D1);「两侧决策一致」这条不变
assert.match(narratedLeft.hostNarration, /现在还剩 .+ 里 \d+ 个候选/);
assert.match(narratedRight.hostNarration, /现在还剩 .+ 里 \d+ 个候选/);
assert.equal(narratedLeft.persisted, false);
assert.equal(narratedRight.persisted, false);
});
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;
}
});