616 lines
23 KiB
TypeScript
616 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 { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
|
||
import {
|
||
inspectDiscriminatorProbes,
|
||
buildCandidateContrastPacket,
|
||
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
||
import {
|
||
decideRectification,
|
||
isNonConvergingRangeOffer,
|
||
nonConvergingRangeNarration,
|
||
REPRESENTATIVE_MINUTE_DISCLAIMER,
|
||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
|
||
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||
import {
|
||
decideAfterInferenceChange,
|
||
decideFromDossier,
|
||
type DecisionDossier,
|
||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||
import {
|
||
exhaustionSpokenCollectFollowup,
|
||
projectRectificationChoiceCard,
|
||
spokenFollowupForUser,
|
||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||
import { informationGainAmongActive } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
|
||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import {
|
||
CASE_ID,
|
||
FOCUS_ID,
|
||
TURN_ID,
|
||
USER_ID,
|
||
candidateSnapshotFixture,
|
||
computeFixture,
|
||
dossierFixture,
|
||
fakeAccounting,
|
||
receiptHandlers,
|
||
} from "./rectification-v9-test-support.ts";
|
||
|
||
const EXISTENCE_OPTIONS = [
|
||
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
|
||
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
|
||
{ label: "明确没有发生", answer_class: "no" as const },
|
||
{ label: "这段记不清楚", answer_class: "unsure" as const },
|
||
];
|
||
|
||
const TIMES = [
|
||
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
|
||
] as const;
|
||
const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]);
|
||
const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const;
|
||
const SCORES: Record<string, number> = {
|
||
"04:47": 16,
|
||
"04:51": 20,
|
||
"04:53": 16,
|
||
"04:59": 10,
|
||
"05:00": 4,
|
||
"05:07": 3,
|
||
"05:12": 2,
|
||
"05:14": 1,
|
||
"05:15": 1,
|
||
};
|
||
const PROBABILITY: Record<string, number> = {
|
||
"04:47": 0.25,
|
||
"04:51": 0.4,
|
||
"04:53": 0.25,
|
||
"04:59": 0.1,
|
||
};
|
||
|
||
const EVIDENCE = [
|
||
{
|
||
id: "e-career-entry",
|
||
status: "confirmed" as const,
|
||
domain: "career",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2020-04-01",
|
||
occurredTo: null,
|
||
eventKind: "career_entry",
|
||
summary: "2020-04-01 career_entry",
|
||
},
|
||
{
|
||
id: "e-career-exit",
|
||
status: "confirmed" as const,
|
||
domain: "career",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2020-10-01",
|
||
occurredTo: null,
|
||
eventKind: "career_exit",
|
||
summary: "2020-10-01 career_exit",
|
||
},
|
||
{
|
||
id: "e-rel-start",
|
||
status: "confirmed" as const,
|
||
domain: "relationship",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2024-05-01",
|
||
occurredTo: null,
|
||
eventKind: "relationship_start",
|
||
summary: "2024-05-01 relationship_start",
|
||
},
|
||
{
|
||
id: "e-rel-end",
|
||
status: "confirmed" as const,
|
||
domain: "relationship",
|
||
datePrecision: "day" as const,
|
||
occurredFrom: "2024-08-08",
|
||
occurredTo: null,
|
||
eventKind: "relationship_end",
|
||
summary: "2024-08-08 relationship_end",
|
||
},
|
||
] as const;
|
||
|
||
const DUAL_EXIT = "可以先按当前区间看盘,也可以再补一件记得时间的经历";
|
||
|
||
function existenceProbe(input: {
|
||
key: string;
|
||
year: number;
|
||
question: string;
|
||
gain: number;
|
||
source: string;
|
||
yes: readonly string[];
|
||
no: readonly string[];
|
||
}): ConflictProbe {
|
||
return {
|
||
id: `probe:${input.key}`,
|
||
semantic_key: input.key,
|
||
candidate_split_hash: input.key,
|
||
domain: "career",
|
||
year: input.year,
|
||
question: input.question,
|
||
candidate_ids: [...new Set([...input.yes, ...input.no])],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: input.yes, conflicts: input.no },
|
||
{ answer_class: "weak_yes", supports: [], conflicts: [] },
|
||
{ answer_class: "no", supports: input.no, conflicts: input.yes },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: input.gain,
|
||
source: input.source,
|
||
choice_kind: "existence",
|
||
style_options: EXISTENCE_OPTIONS,
|
||
};
|
||
}
|
||
|
||
const D9: ConflictProbe = {
|
||
id: "contrast:varga.d9.巨蟹座/狮子座",
|
||
semantic_key: "varga.d9.巨蟹座/狮子座",
|
||
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
|
||
domain: "relationship",
|
||
year: 0,
|
||
question: "亲密关系里更接近下面哪一种相处方式?",
|
||
candidate_ids: ["05:00", "05:07"],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
|
||
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
|
||
{ answer_class: "no", supports: [], conflicts: [] },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: 1.1,
|
||
source: "varga_contrast",
|
||
choice_kind: "varga_style",
|
||
};
|
||
|
||
const D10: ConflictProbe = {
|
||
id: "contrast:varga.d10.天秤座/天蝎座",
|
||
semantic_key: "varga.d10.天秤座/天蝎座",
|
||
candidate_split_hash: "varga.d10.天秤座/天蝎座",
|
||
domain: "career",
|
||
year: 0,
|
||
question: "平时做事更接近下面哪一种职责风格?",
|
||
candidate_ids: ["05:00", "05:07"],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
|
||
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
|
||
{ answer_class: "no", supports: [], conflicts: [] },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: 1.05,
|
||
source: "varga_contrast",
|
||
choice_kind: "varga_style",
|
||
};
|
||
|
||
const CAREER_2023_05 = existenceProbe({
|
||
key: "career.2023.05.dasha_boundary",
|
||
year: 2023,
|
||
question: "2023 年 5 月前后有没有入职或换工作?",
|
||
gain: 0.9,
|
||
source: "dasha_boundary",
|
||
yes: ["05:00"],
|
||
no: ["05:07"],
|
||
});
|
||
|
||
const RELOCATION_2015_05: ConflictProbe = {
|
||
...existenceProbe({
|
||
key: "relocation.2015.05.dasha_boundary",
|
||
year: 2015,
|
||
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
|
||
gain: 0.7,
|
||
source: "dasha_boundary",
|
||
yes: ["05:00"],
|
||
no: ["05:07"],
|
||
}),
|
||
domain: "relocation",
|
||
};
|
||
|
||
/** Live remaining probe: splits after/before 05:00. Zero split on 04:47–04:59. */
|
||
const CAREER_2024_04 = existenceProbe({
|
||
key: "career.2024.04.dasha_boundary",
|
||
year: 2024,
|
||
question: "2024 年 4 月前后有没有入职或换工作?",
|
||
gain: 1.1712,
|
||
source: "dasha_boundary",
|
||
yes: ["05:00", "05:07", "05:12", "05:14", "05:15"],
|
||
no: ["04:47", "04:51", "04:53", "04:59"],
|
||
});
|
||
|
||
/** Live remaining probe: isolates 05:15. Zero split on 04:47–04:59. */
|
||
const CAREER_2023_ACTIVATION = existenceProbe({
|
||
key: "career.2023.dasha_activation",
|
||
year: 2023,
|
||
question: "2023 年前后大运有没有启动?",
|
||
gain: 0.56,
|
||
source: "dasha_activation",
|
||
yes: ["05:15"],
|
||
no: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
|
||
});
|
||
|
||
/** P1 fixture: dated career probe that still splits the active four minutes. */
|
||
const CAREER_ACTIVE_SPLIT = existenceProbe({
|
||
key: "career.2022.dasha_boundary",
|
||
year: 2022,
|
||
question: "2022 年前后有没有入职或换工作?",
|
||
gain: 1.4,
|
||
source: "dasha_boundary",
|
||
yes: ["04:47", "04:51"],
|
||
no: ["04:53", "04:59"],
|
||
});
|
||
|
||
function uuidAt(index: number): string {
|
||
return `88888888-8888-4888-8888-8888888888${(10 + index).toString(16).padStart(2, "0")}`;
|
||
}
|
||
|
||
function liveState(remaining: readonly ConflictProbe[]): InferenceState {
|
||
const probes = [D9, D10, CAREER_2023_05, RELOCATION_2015_05, ...remaining];
|
||
const rankedActive = [...ACTIVE].sort((left, right) => (
|
||
(PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0)
|
||
|| (SCORES[right] ?? 0) - (SCORES[left] ?? 0)
|
||
|| left.localeCompare(right)
|
||
));
|
||
const candidates = TIMES.map((time, index) => {
|
||
const eliminated = ELIMINATED.has(time);
|
||
const activeRank = (rankedActive as readonly string[]).indexOf(time);
|
||
return {
|
||
id: time,
|
||
time,
|
||
cluster_range: [time, time] as const,
|
||
prior_score: SCORES[time] ?? 0,
|
||
posterior_score: SCORES[time] ?? 0,
|
||
probability: eliminated ? 0 : (PROBABILITY[time] ?? 0),
|
||
status: eliminated ? "eliminated" as const : "active" as const,
|
||
rank: eliminated ? ACTIVE.length + index : activeRank + 1,
|
||
strong_conflict_count: eliminated ? 3 : 0,
|
||
};
|
||
});
|
||
const answered = [D9, D10, CAREER_2023_05, RELOCATION_2015_05].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,
|
||
}));
|
||
const raw = {
|
||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
|
||
revision: 5,
|
||
phase: "discrimination" as const,
|
||
result_status: "discriminating" as const,
|
||
range_start: "04:47",
|
||
range_end: "05:15",
|
||
candidates,
|
||
events: [
|
||
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
|
||
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
|
||
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const },
|
||
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "training" as const },
|
||
],
|
||
probes,
|
||
answered_probes: answered,
|
||
rounds: [],
|
||
last_inference_round: null,
|
||
entropy: 1.2,
|
||
representative_time: "04:51",
|
||
credible_range: ["04:47", "04:53"] as const,
|
||
holdout_passed: null,
|
||
};
|
||
const loaded = asInferenceState(raw);
|
||
assert.ok(loaded, "live inference fixture must pass asInferenceState");
|
||
return loaded;
|
||
}
|
||
|
||
function liveDossier(
|
||
remaining: readonly ConflictProbe[],
|
||
extra: {
|
||
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
|
||
status?: string;
|
||
eventProbes?: boolean;
|
||
} = {},
|
||
): DecisionDossier {
|
||
const state = liveState(remaining);
|
||
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
|
||
return {
|
||
evidence: EVIDENCE,
|
||
conversationSummary: {
|
||
activeFocus: null,
|
||
declinedSkippedTopics: extra.declinedTopics ?? [{ target_domain: "family", status: "declined" }],
|
||
},
|
||
latestResult: {
|
||
resultId: "55555555-5555-4555-8555-555555555555",
|
||
selectionAllowed: true,
|
||
confirmationAllowed: false,
|
||
evidenceLedgerFingerprint: fingerprint,
|
||
candidates: TIMES.map((time, index) => ({
|
||
candidateId: uuidAt(index),
|
||
time,
|
||
rank: index + 1,
|
||
relativeSupport: Math.round(SCORES[time] ?? 0),
|
||
})),
|
||
representativeTime: "04:51",
|
||
decisionReceipt: {
|
||
accept_allowed: true,
|
||
propose_allowed: true,
|
||
selection_allowed: true,
|
||
inference_state: state,
|
||
...(extra.eventProbes
|
||
? {
|
||
discriminating_event_probes: remaining.map((probe) => ({
|
||
year: probe.year,
|
||
year_label: `${probe.year} 年前后`,
|
||
domain: probe.domain,
|
||
event_family: "入职、换工作或职责加重",
|
||
source: probe.source,
|
||
tracks: ["vimshottari", "narayana"],
|
||
tracks_agree: true,
|
||
unique_minute_claim: false,
|
||
user_meaning: probe.question,
|
||
role: "distinguish",
|
||
information_gain: probe.information_gain,
|
||
semantic_key: probe.semantic_key,
|
||
candidate_split_hash: probe.candidate_split_hash,
|
||
candidate_ids: probe.candidate_ids,
|
||
expected_outcomes: probe.expected_outcomes,
|
||
choice_kind: probe.choice_kind,
|
||
style_options: probe.style_options,
|
||
})),
|
||
}
|
||
: {}),
|
||
},
|
||
},
|
||
case: { acceptedTime: null, status: extra.status },
|
||
};
|
||
}
|
||
|
||
function snapshotCandidates() {
|
||
return TIMES.map((time, index) => ({
|
||
candidate_id: uuidAt(index),
|
||
rank: index + 1,
|
||
time,
|
||
relative_support: Math.round(SCORES[time] ?? 0),
|
||
tied_minute_count: 1,
|
||
}));
|
||
}
|
||
|
||
function rpcDossier(decision: DecisionDossier) {
|
||
const evidence = EVIDENCE.map((item) => ({
|
||
id: item.id,
|
||
source_turn_id: TURN_ID,
|
||
subject: "self",
|
||
event_kind: item.eventKind,
|
||
domain: item.domain,
|
||
occurred_from: item.occurredFrom,
|
||
occurred_to: item.occurredTo,
|
||
date_precision: item.datePrecision,
|
||
summary: `${item.occurredFrom} ${item.eventKind}`,
|
||
status: item.status,
|
||
supersedes_evidence_id: null,
|
||
created_at: "2026-08-29T00:00:00.000Z",
|
||
}));
|
||
return dossierFixture({
|
||
evidence,
|
||
latestResult: candidateSnapshotFixture({
|
||
selectionAllowed: true,
|
||
confirmationAllowed: false,
|
||
representativeTime: "04:51",
|
||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
|
||
candidates: snapshotCandidates(),
|
||
decisionReceipt: {
|
||
accept_allowed: true,
|
||
propose_allowed: true,
|
||
selection_allowed: true,
|
||
...(decision.latestResult?.decisionReceipt ?? {}),
|
||
},
|
||
}),
|
||
conversationSummary: {
|
||
confirmed_evidence_summary: [],
|
||
pending_revisions: [],
|
||
active_focus: null,
|
||
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
|
||
candidate_divergence_summary: null,
|
||
missing_evidence_categories: [],
|
||
last_result_policy: null,
|
||
summary_version: 1,
|
||
updated_at: "2026-08-30T00:00:00.000Z",
|
||
},
|
||
});
|
||
}
|
||
|
||
test("skill version stays 10.0.13 for the range-offer dead-end fix", () => {
|
||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
|
||
});
|
||
|
||
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
|
||
const decision = "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||
const answer = "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||
const route = "../src/app/api/rectification/agent/route.ts";
|
||
for (const relative of [decision, answer, route]) {
|
||
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
|
||
assert.doesNotMatch(source, new RegExp(DUAL_EXIT));
|
||
assert.doesNotMatch(source, /NON_CONVERGING_RANGE_NARRATION|DISCRIMINATOR_EXHAUSTED_NARRATION/);
|
||
assert.match(source, /nonConvergingRangeNarration/);
|
||
}
|
||
const text = nonConvergingRangeNarration({
|
||
credibleRange: ["04:47", "04:53"],
|
||
representativeTime: "04:51",
|
||
});
|
||
assert.match(text, /04:47–04:53/);
|
||
assert.match(text, /04:51/);
|
||
assert.match(text, new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
|
||
assert.doesNotMatch(text, /可以先按当前区间看盘/);
|
||
});
|
||
|
||
test("zero-split among active is dropped with an explicit reason, not kept silent", () => {
|
||
const after = informationGainAmongActive(CAREER_2024_04.expected_outcomes, ACTIVE);
|
||
const activation = informationGainAmongActive(CAREER_2023_ACTIVATION.expected_outcomes, ACTIVE);
|
||
const split = informationGainAmongActive(CAREER_ACTIVE_SPLIT.expected_outcomes, ACTIVE);
|
||
assert.equal(after.splits, false);
|
||
assert.equal(activation.splits, false);
|
||
assert.equal(split.splits, true);
|
||
const packet = buildCandidateContrastPacket({
|
||
candidateSetVersion: candidateSetId("04:47", "05:15", TIMES),
|
||
engineProbes: [CAREER_2024_04, CAREER_2023_ACTIVATION].map((probe) => ({
|
||
semantic_key: probe.semantic_key,
|
||
candidate_split_hash: probe.candidate_split_hash,
|
||
domain: probe.domain,
|
||
year: probe.year,
|
||
user_meaning: probe.question,
|
||
information_gain: probe.information_gain,
|
||
expected_outcomes: probe.expected_outcomes,
|
||
choice_kind: probe.choice_kind,
|
||
style_options: probe.style_options,
|
||
})),
|
||
providedDomains: ["career", "relationship"],
|
||
candidateTimes: [...TIMES],
|
||
});
|
||
assert.equal(packet.probes.some((item) => item.semanticKey === CAREER_2024_04.semantic_key), true);
|
||
const inspected = inspectDiscriminatorProbes(packet, { topCandidateTimes: ACTIVE });
|
||
assert.equal(inspected.selected, null);
|
||
assert.equal(inspected.dropped.some((item) => (
|
||
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active"
|
||
)), true);
|
||
assert.equal(inspected.dropped.some((item) => (
|
||
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active"
|
||
)), true);
|
||
});
|
||
|
||
test("P1: dated career probes live only in inference_state still ask when they split active minutes", () => {
|
||
const dossier = liveDossier([CAREER_ACTIVE_SPLIT]);
|
||
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||
assert.equal(fromDossier.nextAction, "ask_candidate_discriminator");
|
||
assert.equal(fromDossier.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
|
||
assert.equal(isNonConvergingRangeOffer(fromDossier), false);
|
||
const after = decideAfterInferenceChange({
|
||
dossier,
|
||
state: liveState([CAREER_ACTIVE_SPLIT]),
|
||
userStopped: false,
|
||
birthDate: "1997-08-08",
|
||
});
|
||
assert.equal(after.nextAction, "ask_candidate_discriminator");
|
||
assert.equal(after.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
|
||
});
|
||
|
||
test("live remaining probes with zero active split enter dropped_probes and the range-offer branch", () => {
|
||
const remaining = [CAREER_2024_04, CAREER_2023_ACTIVATION];
|
||
const dossier = liveDossier(remaining);
|
||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||
assert.equal(decision.probe, null);
|
||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||
assert.equal(isNonConvergingRangeOffer(decision), true);
|
||
assert.equal(decision.canAdopt, false);
|
||
assert.equal(decision.selectionAllowed, false);
|
||
assert.deepEqual(decision.credibleRange, ["04:47", "04:53"]);
|
||
assert.equal(decision.representativeTime, "04:51");
|
||
assert.equal(decision.droppedProbes.some((item) => (
|
||
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active"
|
||
)), true);
|
||
assert.equal(decision.droppedProbes.some((item) => (
|
||
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active"
|
||
)), true);
|
||
});
|
||
|
||
test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric range", async () => {
|
||
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]);
|
||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||
assert.equal(isNonConvergingRangeOffer(decision), true);
|
||
const card = projectRectificationChoiceCard({
|
||
evidence: dossier.evidence,
|
||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||
sessionOutcome: decision.sessionOutcome,
|
||
selectionAllowed: decision.selectionAllowed,
|
||
});
|
||
assert.equal(card, null);
|
||
const collect = exhaustionSpokenCollectFollowup({
|
||
evidence: dossier.evidence,
|
||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||
});
|
||
assert.equal(collect?.domain, "education");
|
||
assert.equal(collect?.intent, "collect_method_evidence");
|
||
assert.equal(collect?.choice_frame, null);
|
||
const spoken = spokenFollowupForUser(collect);
|
||
assert.ok(spoken);
|
||
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
|
||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||
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-08-30T00: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(idle.choiceReady, false);
|
||
assert.match(idle.hostNarration ?? "", /04:47–04:53/);
|
||
assert.match(idle.hostNarration ?? "", /04:51/);
|
||
assert.match(idle.hostNarration ?? "", new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
|
||
assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);
|
||
assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT));
|
||
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||
assert.equal(setFocus?.args.p_intent, "collect_method_evidence");
|
||
assert.equal(setFocus?.args.p_target_domain, "education");
|
||
const schema = setFocus?.args.p_expected_answer_schema as Record<string, unknown> | undefined;
|
||
const question = projectCurrentQuestion({
|
||
id: FOCUS_ID,
|
||
questionId: String(setFocus?.args.p_question_id ?? ""),
|
||
intent: "collect_method_evidence",
|
||
targetDomain: "education",
|
||
expectedAnswerSchema: schema ?? null,
|
||
});
|
||
assert.equal(question?.kind, "collect_spoken");
|
||
assert.ok(question?.prompt);
|
||
assert.equal(Boolean(idle.hostNarration) && Boolean(question), true);
|
||
});
|
||
|
||
test("userStopped still completes as provisional_range_user_stopped with canAdopt", () => {
|
||
const stopped = decideRectification({
|
||
methodCoverageAll: false,
|
||
trainingGateOpen: true,
|
||
userStopped: true,
|
||
engineAcceptAllowed: true,
|
||
engineProposeAllowed: true,
|
||
candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })),
|
||
});
|
||
assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped");
|
||
assert.equal(stopped.canAdopt, true);
|
||
assert.equal(stopped.canOfferRange, true);
|
||
assert.equal(isNonConvergingRangeOffer(stopped), false);
|
||
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" });
|
||
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||
assert.equal(fromDossier.sessionOutcome, "provisional_range_user_stopped");
|
||
assert.equal(fromDossier.canAdopt, true);
|
||
});
|
||
|
||
test("idle persist still decides from the dossier once and does not invent collect_evidence", () => {
|
||
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||
const idle = source.slice(
|
||
source.indexOf("export async function persistNextInterviewIfIdle"),
|
||
source.indexOf("async function persistApplied"),
|
||
);
|
||
assert.equal(idle.split("decideFromDossier").length - 1, 1);
|
||
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
|
||
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
|
||
assert.match(idle, /persistExhaustionCollect/);
|
||
});
|