0aaa0d702e
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>
963 lines
33 KiB
TypeScript
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);
|
|
});
|