Files
Jyotisha/frontend/tests/rectification-adopt-narration-20260904.test.ts
T
Jesse_Chen 0aaa0d702e
Independent Staging Quality Gate / validate (push) Successful in 10m33s
Independent Staging Quality Gate / publish (push) Successful in 1m55s
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>
2026-09-04 14:22:13 +08:00

963 lines
33 KiB
TypeScript

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);
});