Files
Jyotisha/frontend/tests/rectification-provisional-adopt.test.ts
T
Jesse_Chen 1a3e14e726
Independent Staging Quality Gate / validate (push) Successful in 13m40s
Independent Staging Quality Gate / publish (push) Successful in 9m59s
fix(rectification): show question stems and unblock adopt after occupation
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>
2026-09-02 11:09:56 +08:00

632 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:0005: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/);
});