Files
Jyotisha/frontend/tests/rectification-provisional-adopt.test.ts
T
Jesse_Chen 998406cee6
Independent Staging Quality Gate / validate (push) Failing after 10m10s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(rectification): keep collecting after family denial
Family "没有" was treated as adopt-ready once the training gate was open. Remaining dated domains still distinguish leftover minutes, so persist those questions instead of writing the adopt narration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 09:38:59 +08:00

674 lines
24 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 {
decideRectification,
engineCapabilityCeilingFromReceipt,
publicDecisionFields,
publicNextAction,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
overlayPublicDecision,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { persistNextInterviewAfterChoice, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
canRenderRectificationSelectionCards,
parseRectificationCandidateResult,
} from "../src/lib/rectification-candidate-result.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 INCIDENT_TIMES = ["05:00", "05:07", "04:53"] as const;
const INCIDENT_SCORES = { "05:00": 24, "05:07": 19, "04:53": 8 } as const;
const INCIDENT_CANDIDATE_IDS = {
"05:00": "88888888-8888-4888-8888-888888888881",
"05:07": "88888888-8888-4888-8888-888888888882",
"04:53": "88888888-8888-4888-8888-888888888883",
} as const;
const ENGINE_OPEN = {
acceptanceAllowed: true,
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: true,
} as const;
const INCIDENT_EVIDENCE = [
{
id: "e-education",
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
{
id: "e-rel-start",
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
},
{
id: "e-rel-end",
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
},
{
id: "e-career-entry",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-exit",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
},
] as const;
function answeredProbe(index: number, domain: string, year: number): ConflictProbe {
const semantic = `${domain}.${year}.dasha_boundary`;
return {
id: `probe:${semantic}`,
semantic_key: semantic,
candidate_split_hash: `${semantic}:split`,
domain,
year,
question: `${year} 年前后这件事有没有发生?`,
candidate_ids: [...INCIDENT_TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00", "05:07", "04:53"], conflicts: [] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: [], conflicts: ["05:00", "05:07", "04:53"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const ANSWERED_PROBES: ConflictProbe[] = [
answeredProbe(1, "education", 2016),
answeredProbe(2, "relationship", 2024),
answeredProbe(3, "relationship", 2023),
answeredProbe(4, "career", 2020),
answeredProbe(5, "career", 2023),
];
const REMAINING_UNSPLIT: 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: [...INCIDENT_TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00", "05:07", "04:53"], conflicts: [] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: [], conflicts: ["05:00", "05:07", "04:53"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.56,
source: "dasha_activation",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
function incidentEvents(evidence: typeof INCIDENT_EVIDENCE | readonly DecisionDossier["evidence"][number][]) {
return evidence.map((item) => {
const yearRaw = item.occurredFrom ? Number(String(item.occurredFrom).slice(0, 4)) : NaN;
const year = Number.isInteger(yearRaw) ? yearRaw : null;
return {
id: item.id ?? `${item.domain}:${item.occurredFrom ?? "undated"}`,
domain: item.domain,
year,
precision: (year === null
? "unknown"
: item.datePrecision === "day" || item.datePrecision === "month"
? item.datePrecision
: "year") as "day" | "month" | "year" | "unknown",
usage: item.domain === "education"
? "holdout" as const
: year === null
? "unused" as const
: "training" as const,
};
});
}
function incidentInference(evidence: typeof INCIDENT_EVIDENCE | readonly DecisionDossier["evidence"][number][]): InferenceState {
const ranked = [...INCIDENT_TIMES].sort((left, right) => INCIDENT_SCORES[right] - INCIDENT_SCORES[left]);
return {
algorithm_version: "rectification-inference-v1",
candidate_set_id: candidateSetId("04:53", "05:07", INCIDENT_TIMES),
revision: 6,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:53",
range_end: "05:07",
candidates: ranked.map((time, index) => ({
id: INCIDENT_CANDIDATE_IDS[time],
time,
cluster_range: [time, time] as const,
prior_score: INCIDENT_SCORES[time],
posterior_score: INCIDENT_SCORES[time],
probability: INCIDENT_SCORES[time] / 51,
status: "active" as const,
rank: index + 1,
strong_conflict_count: 0,
})),
events: incidentEvents(evidence),
probes: [...ANSWERED_PROBES, REMAINING_UNSPLIT],
answered_probes: ANSWERED_PROBES.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: 0.86,
representative_time: "05:00",
credible_range: ["05:00", "05:07"],
holdout_passed: null,
};
}
function openEngineReceipt(overrides: Record<string, unknown> = {}) {
return {
acceptance_allowed: true,
accept_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
diagnostic_quality: { passed: false, margin_percent: 19.94 },
event_fit_rate: { band: "high", rate: 0.8 },
oos_blind_prompts: [
{ 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 },
],
...overrides,
};
}
function incidentDossier(input: {
occupationCovered?: "declined" | "confirmed_note" | false;
evidence?: DecisionDossier["evidence"];
receipt?: Record<string, unknown>;
} = {}): DecisionDossier {
const occupationCovered = input.occupationCovered ?? "declined";
const evidence = [
...(input.evidence ?? INCIDENT_EVIDENCE),
...(occupationCovered === "confirmed_note"
? [{
id: "e-occupation",
status: "confirmed",
domain: "occupation",
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
}]
: []),
];
const declined = [
{ target_domain: "family", status: "declined" },
...(occupationCovered === "declined"
? [{ target_domain: "occupation", status: "declined", question_id: "collect:occupation:collect_method_evidence" }]
: []),
];
const state = incidentInference(evidence);
return {
evidence,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: declined,
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never),
candidates: state.candidates.map((candidate) => ({
candidateId: INCIDENT_CANDIDATE_IDS[candidate.time as keyof typeof INCIDENT_CANDIDATE_IDS],
time: candidate.time,
rank: candidate.rank,
relativeSupport: candidate.posterior_score,
tiedMinuteCount: 1,
})),
representativeTime: "05:00",
decisionReceipt: {
...openEngineReceipt(input.receipt),
inference_state: state,
},
},
case: { acceptedTime: null },
};
}
function publicFields(dossier: DecisionDossier) {
const parsed = dossier;
const decision = decideFromDossier(parsed, { birthDate: "1997-08-08" });
const overlaid = overlayPublicDecision(parsed.latestResult ?? {}, decision);
return { decision, overlaid, fields: publicDecisionFields(decision) };
}
function rpcEvidenceRows(decision: DecisionDossier) {
return 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"}`.trim(),
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-01T00:00:00.000Z",
}));
}
function rpcDossier(decision: DecisionDossier, activeFocus: Record<string, unknown> | null = null) {
const evidence = rpcEvidenceRows(decision);
const evidenceFingerprint = evidenceLedgerFingerprint(evidence.map((item) => ({
id: item.id,
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,
})) as never);
return dossierFixture({
evidence,
evidenceCount: decision.evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: decision.latestResult?.representativeTime ?? null,
evidenceLedgerFingerprint: evidenceFingerprint,
candidates: decision.latestResult?.candidates?.map((item) => ({
candidate_id: item.candidateId,
time: item.time,
rank: item.rank,
relative_support: item.relativeSupport,
tied_minute_count: item.tiedMinuteCount ?? 1,
})),
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: activeFocus,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-01T00:00:00.000Z",
},
});
}
test("incident shape 1a: occupation coverage must open provisional adopt without exact-minute confirm", () => {
for (const occupationCovered of ["declined", "confirmed_note"] as const) {
const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered }));
assert.equal(decision.separation.lead, 5, occupationCovered);
assert.equal(decision.separation.sufficient, false, occupationCovered);
assert.equal(overlaid.can_adopt, true, occupationCovered);
assert.equal(overlaid.selection_allowed, true, occupationCovered);
assert.deepEqual(overlaid.credible_range, ["05:00", "05:07"], occupationCovered);
assert.equal(overlaid.representative_time, "05:00", occupationCovered);
assert.equal(overlaid.can_confirm_exact_minute, false, occupationCovered);
assert.equal(decision.canConfirmExactMinute, false, occupationCovered);
}
});
test("incident shape 1b: exact-minute confirm stays closed on the accident case", () => {
for (const occupationCovered of ["declined", "confirmed_note", false] as const) {
const { overlaid } = publicFields(incidentDossier({ occupationCovered }));
assert.equal(overlaid.can_confirm_exact_minute, false, String(occupationCovered));
}
});
test("incident shape 1c: uncovered occupation still collects remaining dated events", async () => {
const dossier = incidentDossier({ occupationCovered: false });
const { decision, overlaid } = publicFields(dossier);
assert.equal(overlaid.can_adopt, true);
assert.equal(overlaid.selection_allowed, true);
assert.equal(overlaid.can_confirm_exact_minute, false);
let writtenDomain: string | null = null;
const raw = rpcDossier(dossier);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => ({
...raw,
conversation_summary: {
...(raw.conversation_summary as Record<string, unknown>),
active_focus: writtenDomain
? {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: `collect:${writtenDomain}:collect_method_evidence`,
intent: "collect_method_evidence",
target_domain: writtenDomain,
status: "active",
}
: null,
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writtenDomain = typeof args.p_target_domain === "string" ? args.p_target_domain : null;
return {
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-01T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
};
},
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
const persisted = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
// 旧:canAdopt 早退。新:家人拒答后仍收财务经历。
assert.equal(persisted.persisted, true);
assert.equal(writtenDomain, "finance");
assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.finance);
assert.equal(decision.canConfirmExactMinute, false);
assert.equal(canRenderRectificationSelectionCards(parseRectificationCandidateResult({
resultId: dossier.latestResult?.resultId,
candidates: overlaid.candidates,
selectionAllowed: overlaid.selection_allowed,
selection_allowed: overlaid.selection_allowed,
canAdopt: overlaid.can_adopt,
can_adopt: overlaid.can_adopt,
representativeTime: overlaid.representative_time,
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
})), true);
});
test("after-choice persist collects remaining dated events after family denial", async () => {
const dossier = incidentDossier({ occupationCovered: false });
const decision = decideFromDossier(dossier);
assert.equal(decision.canAdopt, true);
let writes = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
return {
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-01T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
};
},
});
const next = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: null,
nextAction: publicNextAction(decision),
});
// 旧:canAdopt 早退。新:家人拒答后仍收财务经历。
assert.equal(next.persisted, true);
assert.equal(writes, 1);
assert.equal(next.hostNarration, USER_COLLECT_QUESTION.finance);
assert.equal(next.followup?.domain, "finance");
});
test("incident shape 2: evidence floor still blocks adopt", () => {
const twoEvents = publicFields(incidentDossier({
occupationCovered: "declined",
evidence: INCIDENT_EVIDENCE.slice(0, 2),
}));
assert.equal(twoEvents.overlaid.can_adopt, false);
assert.equal(twoEvents.decision.nextAction, "ask_fact_collection");
const oneDomain = publicFields(incidentDossier({
occupationCovered: "declined",
evidence: INCIDENT_EVIDENCE.filter((item) => item.domain === "career"),
}));
assert.equal(oneDomain.overlaid.can_adopt, false);
assert.equal(oneDomain.decision.nextAction, "ask_fact_collection");
});
test("incident shape 3: engine ceiling still fail-closes adopt", () => {
const closed = publicFields(incidentDossier({
occupationCovered: "declined",
receipt: { acceptance_allowed: false },
}));
assert.equal(closed.overlaid.can_adopt, false);
const inconsistent = publicFields(incidentDossier({
occupationCovered: "declined",
receipt: { accept_allowed: false },
}));
assert.equal(inconsistent.overlaid.can_adopt, false);
assert.deepEqual(
engineCapabilityCeilingFromReceipt(inconsistent.decision && incidentDossier({
occupationCovered: "declined",
receipt: { accept_allowed: false },
}).latestResult?.decisionReceipt),
{
acceptanceAllowed: false,
selectionAllowed: false,
proposeAllowed: false,
confirmationAllowed: false,
},
);
});
test("incident shape 4: exact-minute confirm stays fail-closed even when local gates pass", () => {
const separated = [
{ time: "05:00", score: 58 },
{ time: "05:07", score: 42 },
];
const engineFalse = decideRectification({
methodCoverageAll: true,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: separated,
holdoutValidation: "passed",
confirmationAllowed: true,
datedEventCount: 5,
datedDomainCount: 3,
engineCeiling: {
acceptanceAllowed: true,
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
},
});
assert.equal(engineFalse.separation.sufficient, true);
assert.equal(engineFalse.canConfirmExactMinute, false);
const syntheticOpen = decideRectification({
methodCoverageAll: true,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: separated,
holdoutValidation: "passed",
confirmationAllowed: true,
datedEventCount: 5,
datedDomainCount: 3,
engineCeiling: ENGINE_OPEN,
});
assert.equal(syntheticOpen.canConfirmExactMinute, true);
});
test("delivery B1: GET overlay after occupation coverage renders representative cards", () => {
const dossier = incidentDossier({ occupationCovered: "declined" });
const { decision, overlaid } = publicFields(dossier);
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(decision.nextAction, "offer_provisional_range");
const parsed = parseRectificationCandidateResult({
resultId: dossier.latestResult?.resultId,
candidates: overlaid.candidates,
selectionAllowed: overlaid.selection_allowed,
selection_allowed: overlaid.selection_allowed,
canAdopt: overlaid.can_adopt,
can_adopt: overlaid.can_adopt,
representativeTime: overlaid.representative_time,
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
});
assert.ok(parsed);
assert.equal(canRenderRectificationSelectionCards(parsed), true);
assert.deepEqual(parsed.candidates.map((item) => item.time), ["05:00", "05:07", "04:53"]);
assert.equal(parsed.representativeTime, "05:00");
});
test("delivery B2: remaining dated collect stays in front of the adopt early-exit", async () => {
const dossier = incidentDossier({ occupationCovered: "declined" });
const { decision } = publicFields(dossier);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
});
assert.equal(plan.session_outcome, "adopt_representative");
assert.equal(plan.next_followup?.domain, "finance");
const action = buildNextUserAction({
scorableCount: 5,
evidenceCount: 5,
hasLatestResult: true,
selectionAllowed: decision.selectionAllowed,
sessionOutcome: decision.sessionOutcome,
nextFollowup: plan.next_followup,
workingTime: "05:00",
});
// 能力层仍可 adopt;真正下一问是财务采集。公开 next-action 仍跟 sessionOutcome。
assert.equal(action.id, "adopt_representative");
let writes = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
return {
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-01T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
};
},
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(idle.persisted, true);
assert.equal(writes, 1);
assert.equal(idle.hostNarration, USER_COLLECT_QUESTION.finance);
});
test("delivery B3: accept projection stays open from engine receipt, not exact-minute confirm", () => {
const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered: "declined" }));
assert.equal(decision.selectionAllowed, true);
assert.equal(overlaid.selection_allowed, true);
assert.equal(overlaid.can_confirm_exact_minute, false);
assert.equal(decision.canConfirmExactMinute, false);
});
test("capability is computed only in deliveryCapability and spread into every exit", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url), "utf8");
assert.match(source, /function deliveryCapability\(/);
assert.equal((source.match(/\.\.\.capability/g) ?? []).length >= 5, true);
assert.doesNotMatch(source, /canAdopt:\s*true/);
assert.doesNotMatch(source, /canConfirmExactMinute:\s*true/);
});