Files
Jyotisha/frontend/tests/rectification-range-offer-deadend.test.ts
T
Jesse_ChenandClaude Fable 5.1 dc732825d8 fix(rectification): 有题就接着问,题问完才出卡;引导窗口不再硬贴领域
出卡时机只看题源有没有空:撤回「门槛达标就短路采集线」的写法,同时
按 D2 保住「题源全空就按现行规则出卡」——门槛只在还有题可问时挡住
出卡,precision_gate_met 改成只上报(新挂在决策与公开投影上),不再
单独决定时机。引导窗口题在无领域轨道上改问开放题,一个时间窗只问一
次;录入卡提交的是「YYYY 年 M 月,<领域>方面有一件事」,不再是题干
的三选一列表。记忆化 golden 只补一个新键并冻结墙钟。离线回放改成注
入真值方向的边界事件,另跑一组反方向对照。Skill 10.0.28。

BUG-747~752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-16 12:16:55 +00:00

674 lines
25 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 { 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,
} 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 { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.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 {
OPEN_ENGINE_CAPABILITY_CEILING,
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:4704: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:4704: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,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
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,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
...(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 is 10.0.26 after the targeted-collect-cards bump", () => {
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
});
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:4704: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 drop and open provisional adopt", () => {
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);
// 原值: ready_to_adopt / sessionOutcome != collect_evidence / selectionAllowed=true
// 新值: ask_fact_collection / collect_evidence / selectionAllowed=false
// 原因: D5 + D6——剩余探针无拆分只说明没有点选题,定向线还没问完;
// 本题真正要的「零拆分探针被 drop」由下面两条仍然保证(BUG-751)
assert.equal(decision.nextAction, "ask_fact_collection");
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(isNonConvergingRangeOffer(decision), false);
assert.equal(decision.canAdopt, false);
assert.equal(decision.selectionAllowed, false);
assert.equal(decision.canConfirmExactMinute, 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("adoptable range still collects remaining dated events after family denial", async () => {
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
// 原值: canAdopt=true
// 新值: canAdopt=false
// 原因: D6——线没问完时交付能力位收起(BUG-751)
assert.equal(decision.canAdopt, false);
assert.equal(isNonConvergingRangeOffer(decision), false);
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,
});
// 原值: education 轮转采集
// 新值: 训练门开后 leftover dated collect 为 null
// 原因: BUG-648
assert.equal(collect, null);
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-09-04T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
// 原值: 训练门开后交付,不落领域轮转焦点
// 新值: 门槛未达时继续问未覆盖领域
// 原因: D1 / D5
assert.ok(idle.hostNarration);
assert.doesNotMatch(idle.hostNarration ?? "", /我按你说的经历认真分析过了|领域不限|做不了/);
});
test("userStopped completes with a review-only provisional range", () => {
const stopped = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
userStopped: true,
candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })),
});
assert.equal(stopped.sessionOutcome, "adopt_representative");
// 原断言 sessionOutcome=provisional_range_user_stopped 且 canAdopt=false
// → 新断言 adopt_representative 且 canAdopt=true。
// 用户停止结束追问;coverage 不再挡代表性采用。确认门仍关。
assert.equal(stopped.canAdopt, true);
assert.equal(stopped.canOfferRange, true);
assert.equal(stopped.canConfirmExactMinute, false);
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, "adopt_representative");
assert.equal(fromDossier.canAdopt, true);
assert.equal(fromDossier.canConfirmExactMinute, false);
assert.equal(fromDossier.completionStatus, "provisional_range_user_stopped");
});
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"),
);
// 原值: 1
// 新值: 2
// 原因: 带年月池空时先刷新再决定(BUG-653)
assert.equal(idle.split("decideFromDossier").length - 1, 2);
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
assert.match(idle, /persistExhaustionCollect/);
});
function datedCollectEvidence(domain: string, year: string, extra: { eventKind?: string } = {}) {
return {
status: "confirmed" as const,
domain,
datePrecision: extra.eventKind ? "unknown" as const : "year" as const,
occurredFrom: extra.eventKind ? null : `${year}-01-01`,
occurredTo: null,
...(extra.eventKind ? { eventKind: extra.eventKind } : {}),
};
}
test("exhaustion after family declined still fills remaining dated domains before occupation", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
// 原值: occupation → relocation 轮转
// 新值: 训练门开,leftover dated collect 为 null
// 原因: BUG-648
assert.equal(next, null);
});
test("exhaustion after coverage is closed does not fall back to domain other", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
datedCollectEvidence("relocation", "2022"),
datedCollectEvidence("health_pressure", "2021"),
datedCollectEvidence("occupation", "2020", { eventKind: "occupation_note" }),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
// 原值: domain=other / USER_COLLECT_QUESTION.other
// 新值: null
// 原因: 穷尽后交付,不再问开放式补问兜底句。
assert.equal(next, null);
});