1a3e14e726
Choice legends and spoken collect prompts were hidden after BUG-461, and occupation still blocked canAdopt once dated coverage was done. Restore visible stems and stop polling extra collects when adopt is allowed. Co-authored-by: Cursor <cursoragent@cursor.com>
632 lines
23 KiB
TypeScript
632 lines
23 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 { containsBoundarySemantics } 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 opens provisional adopt", 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 activeFocus: Record<string, unknown> | 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: activeFocus,
|
||
},
|
||
}),
|
||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||
activeFocus = {
|
||
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,
|
||
};
|
||
return { focus: activeFocus, 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,
|
||
});
|
||
assert.equal(persisted.persisted, false);
|
||
assert.equal(activeFocus, null);
|
||
assert.match(persisted.hostNarration ?? "", /可以从下面选一个先用着/);
|
||
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 does not open another collect once dated coverage can adopt", 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: () => {
|
||
writes += 1;
|
||
throw new Error("adoptable offer must not persist another question");
|
||
},
|
||
});
|
||
const next = await persistNextInterviewAfterChoice({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
dossier,
|
||
decisionState: null,
|
||
nextAction: publicNextAction(decision),
|
||
});
|
||
assert.equal(next.persisted, false);
|
||
assert.equal(writes, 0);
|
||
assert.match(next.hostNarration, /可以从下面选一个先用着/);
|
||
});
|
||
|
||
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: adoptable offer produces adopt_representative with zero follow-up", 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, null);
|
||
const action = buildNextUserAction({
|
||
scorableCount: 5,
|
||
evidenceCount: 5,
|
||
hasLatestResult: true,
|
||
selectionAllowed: decision.selectionAllowed,
|
||
sessionOutcome: decision.sessionOutcome,
|
||
nextFollowup: plan.next_followup,
|
||
workingTime: "05:00",
|
||
});
|
||
assert.equal(action.id, "adopt_representative");
|
||
assert.doesNotMatch(action.user_meaning, /补|再问|记得住时间/);
|
||
|
||
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: () => {
|
||
writes += 1;
|
||
throw new Error("adoptable offer must not persist another question");
|
||
},
|
||
});
|
||
const idle = await persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
assert.equal(idle.persisted, false);
|
||
assert.equal(writes, 0);
|
||
assert.match(idle.hostNarration ?? "", /05:00–05:07/);
|
||
assert.match(idle.hostNarration ?? "", /05:00/);
|
||
assert.equal(containsBoundarySemantics(idle.hostNarration ?? ""), true);
|
||
assert.match(idle.hostNarration ?? "", /可以从下面选一个先用着/);
|
||
});
|
||
|
||
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/);
|
||
});
|
||
|