998406cee6
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>
674 lines
24 KiB
TypeScript
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/);
|
|
});
|
|
|