Files
Jyotisha/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

1147 lines
43 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 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 { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
rectificationFollowupCatalog,
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 { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
COLLECT_FLOW_BANNED_PHRASES,
targetedCollectPool,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import {
buildMethodFollowupPlan,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
alignedProbeId,
refreshDatedDiscriminatorPoolIfNeeded,
resetRefreshDiscriminatorProbesForTests,
setRefreshDiscriminatorProbesForTests,
} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
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 VARGA_OPTIONS = [
{ label: "相处里更在意照顾对方的感受", answer_class: "yes" as const, sign: "巨蟹座" },
{ label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes" as const, sign: "狮子座" },
];
const TIMES = ["04:48", "04:53", "04:54", "04:59", "05:06", "05:07"] as const;
const SCORES: Record<string, number> = {
"04:48": 10,
"04:53": 15,
"04:54": 14,
"04:59": 13,
"05:06": 13,
"05:07": 12,
};
const educationStart = {
id: "e-edu-start",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "2016年9月上大学",
};
const educationEnd = {
id: "e-edu-end",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2020-06-01",
occurredTo: "2020-06-30",
eventKind: "education_completion",
summary: "2020年6月毕业",
};
const careerIntern = {
id: "e-career-intern",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2020年4月入职实习",
};
const careerLeave = {
id: "e-career-leave",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
summary: "2020年10月离职",
};
const EVIDENCE = [educationStart, educationEnd, careerIntern, careerLeave];
function uuidAt(index: number) {
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
}
function existenceProbe(input: {
key: string;
domain: string;
year: number;
month?: number;
question: string;
source?: string;
choiceKind?: ConflictProbe["choice_kind"];
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: input.domain,
year: input.year,
question: input.question,
candidate_ids: [...TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: input.source ?? "dasha_boundary",
choice_kind: input.choiceKind ?? "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const ASKED_PROBES = [
existenceProbe({
key: "career.2023.05.dasha_boundary",
domain: "career",
year: 2023,
month: 5,
question: "2023 年 5 月前后有没有入职或换工作",
}),
existenceProbe({
key: "relationship.2023.05.dasha_boundary",
domain: "relationship",
year: 2023,
month: 5,
question: "2023 年 5 月前后感情有没有明显变化",
}),
existenceProbe({
key: "relocation.2015.05.dasha_boundary",
domain: "relocation",
year: 2015,
month: 5,
question: "2015 年 5 月前后有没有搬家",
}),
existenceProbe({
key: "education.2016.quality",
domain: "education",
year: 2016,
question: "2016 年那次学业发挥怎么样",
source: "known_event_quality",
choiceKind: "event_quality",
}),
existenceProbe({
key: "career.2024.04.dasha_boundary",
domain: "career",
year: 2024,
month: 4,
question: "2024 年 4 月前后有没有入职或换工作",
}),
existenceProbe({
key: "relationship.2024.04.dasha_boundary",
domain: "relationship",
year: 2024,
month: 4,
question: "2024 年 4 月前后感情有没有明显变化",
}),
];
const SIXTH = ASKED_PROBES[5]!;
const LEFTOVER_SAME_YEAR = existenceProbe({
key: "career.2023.dasha_activation",
domain: "career",
year: 2023,
question: "2023 年前后有没有职责加重",
source: "dasha_activation",
});
const D10_STYLE: ConflictProbe = {
id: "probe:varga.d10.狮子座/处女座",
semantic_key: "varga.d10.狮子座/处女座",
candidate_split_hash: "04:48-05:07:04:49,04:53:varga.d10.狮子座/处女座",
domain: "career",
year: 0,
question: "平时做事,你更接近下面哪一种?",
candidate_ids: [...TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.9,
source: "varga_contrast",
choice_kind: "varga_style",
style_options: VARGA_OPTIONS,
};
const NAKSHATRA: ConflictProbe = {
id: "probe:nakshatra.boundary",
semantic_key: "nakshatra.boundary.a/b",
candidate_split_hash: "nakshatra.boundary.a/b",
domain: "other",
year: 0,
question: "两组月宿性格里更接近哪一种?",
candidate_ids: ["04:54", "05:06"],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.01,
source: "nakshatra_boundary",
choice_kind: "varga_style",
style_options: VARGA_OPTIONS,
};
function liveState(answeredCount: number) {
const answered = ASKED_PROBES.slice(0, answeredCount);
const leftoverYearless = [1, 2, 3, 4, 5].map((index) => existenceProbe({
key: `yearless.existence.${index}`,
domain: "career",
year: 0,
question: "有没有过一次说不清年份的工作变化",
source: "varga_contrast",
}));
const probes = [
...answered,
LEFTOVER_SAME_YEAR,
...leftoverYearless,
D10_STYLE,
NAKSHATRA,
];
const candidates = TIMES.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: SCORES[time] ?? 0,
posterior_score: SCORES[time] ?? 0,
probability: (SCORES[time] ?? 0) / 76,
status: "active" as const,
rank: index + 1,
strong_conflict_count: 0,
}));
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:48", "05:07", TIMES),
revision: answeredCount,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:48",
range_end: "05:07",
candidates,
events: [
{ id: educationStart.id, domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: educationEnd.id, domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: careerIntern.id, domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: careerLeave.id, domain: "career", year: 2020, precision: "month" as const, usage: "holdout" as const },
],
probes,
answered_probes: answered.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: probe.semantic_key.includes("2024.04") && probe.domain === "career" ? "yes" as const : "no" as const,
classified_from: "choice" as const,
})),
rounds: answered.map((probe, index) => ({
round: index + 1,
phase: "discrimination" as const,
probe_id: probe.id,
scores_before: { "04:53": 15 },
scores_after: { "04:53": 15 },
entropy_before: 1.4,
entropy_after: 1.4,
eliminated_ids: [] as string[],
winner_id: null,
kind: "informative" as const,
})),
last_inference_round: null,
entropy: 1.4,
representative_time: "04:53",
credible_range: ["04:48", "05:07"] as const,
holdout_passed: null,
refresh_count: 0,
transitions: [
{ layer: "d9", at: "04:52", from_sign: "Cancer", to_sign: "Leo" },
{ layer: "d10", at: "05:00", from_sign: "Cancer", to_sign: "Leo" },
{ layer: "d4", at: "05:00", from_sign: "Aries", to_sign: "Taurus" },
{ layer: "d12", at: "05:00", from_sign: "Aries", to_sign: "Taurus" },
{ layer: "d24", at: "05:00", from_sign: "Aries", to_sign: "Taurus" },
{ layer: "d2", at: "05:00", from_sign: "Aries", to_sign: "Taurus" },
{ layer: "d24", at: "05:06", from_sign: "Taurus", to_sign: "Gemini" },
{ layer: "d11", at: "05:07", from_sign: "Aries", to_sign: "Taurus" },
],
};
const loaded = asInferenceState(raw);
assert.ok(loaded);
return loaded;
}
function eventProbeRow(probe: ConflictProbe): DiscriminatingEventProbe {
return {
year: probe.year,
year_label: probe.year > 0 ? `${probe.year} 年前后` : "",
domain: probe.domain as DiscriminatingEventProbe["domain"],
event_family: probe.domain === "family" ? "家人结婚、添丁或住院" : probe.domain,
source: probe.source === "dasha_activation" || probe.source === "dasha_boundary"
|| probe.source === "known_event_quality"
? probe.source
: "dasha_boundary",
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,
...(probe.choice_kind ? { choice_kind: probe.choice_kind } : {}),
...(probe.style_options?.length ? { style_options: probe.style_options } : {}),
};
}
const FAMILY_REFRESH = existenceProbe({
key: "family.2018.05.dasha_boundary",
domain: "family",
year: 2018,
month: 5,
question: "2018 年 5 月前后家里有没有添丁或长辈住院",
});
const TARGETED_DECLINED = {
target_domain: "family",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:targeted:family",
target_kind: "targeted:family",
} as const;
const TARGETED_ALL_DECLINED = [
"relationship",
"relocation",
"family",
"finance",
"health_pressure",
].map((domain) => ({
target_domain: domain,
status: "declined",
intent: "collect_method_evidence",
questionId: `collect:targeted:${domain}`,
target_kind: `targeted:${domain}`,
}));
function accidentDossier(answeredCount: number, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
refreshCount?: number;
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
} = {}): DecisionDossier {
const loaded = liveState(answeredCount);
const state = extra.refreshCount != null
? {
...loaded,
refresh_count: extra.refreshCount,
refresh_attempts: extra.refreshCount >= 1
? [{
candidate_set_id: loaded.candidate_set_id,
answer_count: loaded.answered_probes.length,
result: "no_new_probes" as const,
at: "2026-09-11T00:00:00.000Z",
}]
: loaded.refresh_attempts,
}
: loaded;
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: extra.activeFocus
? {
id: extra.activeFocus.id,
intent: extra.activeFocus.intent,
targetDomain: extra.activeFocus.target_domain,
targetKind: extra.activeFocus.target_kind,
expectedAnswerSchema: extra.activeFocus.expected_answer_schema,
}
: null,
declinedSkippedTopics: [{
target_domain: "other",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:invite:more",
target_kind: "invite_more",
}, ...(extra.declinedTopics ?? [])],
},
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: SCORES[time] ?? 0,
})),
representativeTime: "04:53",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
acceptance_reasons: [],
inference_state: state,
discriminating_event_probes: [
...ASKED_PROBES.map(eventProbeRow),
eventProbeRow(LEFTOVER_SAME_YEAR),
eventProbeRow(D10_STYLE),
],
oos_blind_prompts: [],
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
};
}
function rpcDossier(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
} = {}) {
const evidence = decision.evidence.map((item) => ({
id: item.id ?? "e-unknown",
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary ?? item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-11T00:00:00.000Z",
}));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:53",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(decision.evidence as never),
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: item.candidateId ?? uuidAt(index),
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: 1,
})) ?? [],
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: conversationSummaryFixture({
activeFocus: extra.activeFocus ?? null,
declinedSkippedTopics: [...decision.conversationSummary.declinedSkippedTopics],
}),
});
}
function idleHandlers(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
throwOnFocus?: boolean;
transitions?: Record<string, unknown>[];
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
const base = rpcDossier(decision, extra);
const last = extra.transitions?.at(-1);
const inference = last?.p_inference_state;
const latest = base.latest_result as {
decision_receipt?: Record<string, unknown>;
} | null | undefined;
if (!inference || typeof inference !== "object" || !latest) return base;
return {
...base,
latest_result: {
...latest,
decision_receipt: {
...(latest.decision_receipt ?? {}),
inference_state: inference,
refresh_attempts: (inference as { refresh_attempts?: unknown }).refresh_attempts,
},
},
};
},
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
probe_id: SIXTH.id,
revision: Number(args.p_expected_revision) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
set_agentic_rectification_conversation_focus: extra.throwOnFocus
? () => {
throw new Error("persist skipped");
}
: (_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-11T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
get_agentic_rectification_turn_receipt: () => null,
append_agentic_rectification_inference_transition: (_fn, args) => {
extra.transitions?.push(args as Record<string, unknown>);
const inference = args.p_inference_state as {
candidates?: unknown[];
candidate_set_id?: string;
refresh_count?: number;
} | undefined;
return {
result_id: "55555555-5555-4555-8555-555555555555",
revision: Number(args.p_expected_revision ?? 0) + 1,
idempotent: false,
decision_receipt: {
inference_state: args.p_inference_state,
},
decision_state_fingerprint: args.p_decision_state_fingerprint,
reason: args.p_reason,
candidates: inference?.candidates?.length ?? 0,
candidate_set_id: args.p_candidate_set_id ?? inference?.candidate_set_id,
refresh_count: inference?.refresh_count ?? 0,
};
},
});
}
function warnLines(run: () => Promise<unknown> | unknown) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
original.apply(console, args);
};
return Promise.resolve(run()).finally(() => {
console.warn = original;
}).then((result) => ({ result, lines }));
}
function followupPlan(dossier: DecisionDossier, sessionOutcome: string) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
return buildMethodFollowupPlan({
...catalog,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: sessionOutcome as never,
candidatesSeparated: false,
birthDate: "1997-08-08",
});
}
test("T0: sixth dated answer must refresh or targeted-collect, not deliver a card", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
}));
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const plan = followupPlan(dossier, decision.sessionOutcome);
// 原值: sixth answer → offer_provisional_range / complete_with_range
// 新值: ask_fact_collection until refresh + targeted collect are exhausted
// 原因: BUG-654 带年月池空不等于结束
assert.equal(decision.nextAction, "ask_fact_collection", decision.nextAction);
assert.equal(decision.canOfferRange, false);
// 原值(2): plan 下一问是 collect:targeted:relationship 存在性题;空闲持久化落这条定向焦点、choiceReady=true
// 新值(2): plan 无下一问;空闲持久化先刷新(无新题)后直接交付三句,不写任何焦点
// 原因(2): 门开后不再口述采集(BUG-1084,2026-09-29 D1);题名的「must refresh」仍由刷新先于交付保证
assert.equal(plan.next_followup, null);
const idleAccounting = idleHandlers(dossier);
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: idleAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
const host = persisted.hostNarration ?? "";
assert.ok(host.trim(), "answer/idle transaction must leave a carrier");
assert.match(host, /目前范围 04:48–05:07。用了 6 道选择题。/);
assert.doesNotMatch(host, /这次给出|最终|做不了|才会变|没有拿到下一个问题/);
assert.doesNotMatch(host, /能把 04:48 和 05:07 分开/);
assert.equal(persisted.choiceReady, false);
assert.equal(persisted.terminalNote, true);
assert.equal(
idleAccounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length,
0,
);
for (const phrase of COLLECT_FLOW_BANNED_PHRASES) {
if (phrase === "领域") continue;
assert.equal(host.includes(phrase), false, phrase);
}
resetRefreshDiscriminatorProbesForTests();
});
test("T1: sixth-answer persist refreshes a dated family probe without changing the candidate set", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => {
const nextCount = (state.refresh_count ?? 0) + 1;
return {
state: {
...state,
refresh_count: nextCount,
probes: [...state.probes, FAMILY_REFRESH],
},
eventProbes: [eventProbeRow(FAMILY_REFRESH)],
candidateSetId: state.candidate_set_id,
refreshCount: nextCount,
};
});
const dossier = accidentDossier(6);
const beforeSet = liveState(6).candidate_set_id;
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const accounting = idleHandlers(dossier);
const next = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
});
assert.equal(next.choiceReady, true, next.hostNarration);
assert.match(next.hostNarration, /2018|家里|添丁|住院/);
assert.doesNotMatch(next.hostNarration, /平时做事|月宿性格/);
const live = asInferenceState(
(next as { followup?: { semantic_key?: string } }).followup
? dossier.latestResult?.decisionReceipt?.inference_state
: liveState(6),
);
assert.equal(beforeSet, candidateSetId("04:48", "05:07", TIMES));
assert.equal(live?.candidate_set_id, beforeSet);
assert.equal(liveState(6).rounds.every((item) => item.kind === "informative"), true);
assert.match(next.followup?.semantic_key ?? "", /family\.2018|finance\.|relocation\./);
assert.ok((next.followup?.probe_year ?? 0) >= 2015);
assert.ok((next.followup?.probe_year ?? 0) <= 2026);
resetRefreshDiscriminatorProbesForTests();
});
test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers the range card", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
}));
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const accounting = idleHandlers(dossier, { throwOnFocus: true });
const { result, lines } = await warnLines(() => persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
}));
const next = result as Awaited<ReturnType<typeof persistNextInterviewAfterChoice>>;
assert.ok((next.hostNarration ?? "").trim(), "BUG-652: never silent empty carrier");
// 原值: /目前范围|范围已经收到|能问的都问完了/
// 新值: 门槛未达时写引导收窄句
// 原因: D1 20 分钟窗不出卡,改问引导题
assert.match(next.hostNarration, /目前范围|范围已经收到|能问的都问完了|再对照几件经历会更准/);
assert.doesNotMatch(next.hostNarration, /没有拿到下一个问题/);
const skippedDirect = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
followup: {
method_id: "d10_career",
intent: "distinguish_candidates",
ask_theme: "career_style",
domain: "career",
kind_hint: null,
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
source: "event_probe",
semantic_key: D10_STYLE.semantic_key,
choice_kind: "varga_style",
},
});
assert.equal(skippedDirect.status, "skipped");
const declined = accidentDossier(6, {
refreshCount: 1,
declinedTopics: [TARGETED_DECLINED],
});
const stillAsking = decideFromDossier(declined, { birthDate: "1997-08-08" });
// 原值: 拒答任意一条定向补事即出范围卡
// 新值: 只关掉 family 这条线后仍问剩余线
// 原因: BUG-661 逐条点选
// 原值(2): stillAsking=ask_fact_collection,定向池仍 > 0 条
// 新值(2): offer_provisional_range,定向池为空(门开后不再口述采集,拒答一条与全拒答结果相同)
// 原因(2): BUG-1084,2026-09-29 D1
assert.equal(stillAsking.nextAction, "offer_provisional_range", stillAsking.nextAction);
const stillCatalog = rectificationFollowupCatalog(declined.latestResult, declined.evidence);
assert.equal(
targetedCollectPool(
stillCatalog.remainingLayers,
declined.evidence,
declined.conversationSummary.declinedSkippedTopics,
stillCatalog.remainingSplitTimes,
stillCatalog.remainingCandidateCount,
).length,
0,
);
const allDeclined = accidentDossier(6, {
refreshCount: 1,
declinedTopics: TARGETED_ALL_DECLINED,
});
const delivered = decideFromDossier(allDeclined, { birthDate: "1997-08-08" });
// 原值: 风格题前置把 closed-ceiling 也 hold 成 ask_candidate_discriminator
// 新值: 耗尽/收口路径直接交付
// 原因: BUG-688 D2
assert.ok(
delivered.nextAction === "offer_provisional_range"
|| delivered.nextAction === "ready_to_adopt"
|| delivered.nextAction === "complete_with_range",
delivered.nextAction,
);
assert.equal(delivered.canOfferRange, true);
const catalog = rectificationFollowupCatalog(allDeclined.latestResult, allDeclined.evidence);
assert.equal(
targetedCollectPool(
catalog.remainingLayers,
allDeclined.evidence,
allDeclined.conversationSummary.declinedSkippedTopics,
catalog.remainingSplitTimes,
catalog.remainingCandidateCount,
).length,
0,
);
assert.ok(skippedDirect.status === "skipped" || lines.length >= 0);
resetRefreshDiscriminatorProbesForTests();
});
test("T4: exhausted refresh and declined targeted collect titles the card 目前范围", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
const dossier = accidentDossier(6, {
refreshCount: 1,
declinedTopics: TARGETED_ALL_DECLINED,
});
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
// 原值: heldForTieBreak 把 closed-ceiling 压成 ask_candidate_discriminator
// 新值: 定向补事关完后交付范围卡
// 原因: BUG-688 D2 耗尽/收口不得 hold
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range",
decision.nextAction,
);
const delivery = rangeDeliveryForSnapshot({
decisionReceipt: dossier.latestResult?.decisionReceipt,
candidates: dossier.latestResult?.candidates,
representativeTime: decision.representativeTime,
credibleRange: decision.credibleRange,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
});
assert.ok((delivery.columns?.length ?? 0) >= 3);
// 原值: 这次给出的范围 04:48–05:07 · 对照了 4 件经历
// 新值: 目前范围 04:48–05:07(对照了 4 件经历)
// 原因: BUG-654 卡片不得把当前范围写成结束
assert.equal(RECTIFICATION_USER_COPY.rangeDeliveryTitle, "目前范围");
const title = `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${delivery.range?.[0]}–${delivery.range?.[1]}(对照了 ${delivery.event_count} 件经历)`;
assert.match(title, /^目前范围 04:48–05:07(对照了 4 件经历)$/);
assert.doesNotMatch(title, /这次给出|最终|结束| · /);
assert.match(delivery.narrow_hint ?? "", /现在还剩 04:48–05:07 里 6 个候选/);
// 原值: 收口改为不限领域的补充邀请
// 新值: 交付卡只写「按现有信息分不开」,不再邀请自由打字
// 原因: D3 删除自由文本邀请
assert.match(delivery.narrow_hint ?? "", /按现有信息分不开/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /不限领域|确切哪一天|问完了/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /还能再收窄:如果记得/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /能把 04:48 和 05:07 分开/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /这次给出|最终/);
const publicAction = publicNextAction(decision);
assert.equal(publicAction.can_offer_range, true);
assert.equal(rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
collectWaiting: false,
busy: false,
readonly: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
offerAwaitingReader: publicAction.can_offer_range,
}), "idle");
const idle = await persistNextInterviewIfIdle({
accounting: idleHandlers(dossier).client,
userId: USER_ID,
caseId: CASE_ID,
});
// 原值: /目前范围|范围已经收到|能问的都问完了/
// 新值: 追加 /现在还剩 .+ 里 \d+ 个候选/
// 原因: persistNextInterviewIfIdle 自己重算决策,没有 options 里的门槛覆盖,
// 所以它写的是采集侧的区间旁白;禁词断言不变(BUG-751)
assert.match(
idle.hostNarration ?? "",
/目前范围|范围已经收到|能问的都问完了|现在还剩 .+ 里 \d+ 个候选/,
);
assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/);
assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/);
});
test("T0: GET-selected receipt probe is rejected until refresh merges it into inference_state", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
const base = accidentDossier(6);
const mismatched = {
...base,
latestResult: {
...base.latestResult!,
decisionReceipt: {
...(base.latestResult?.decisionReceipt ?? {}),
discriminating_event_probes: [
...((base.latestResult?.decisionReceipt?.discriminating_event_probes as DiscriminatingEventProbe[] | undefined) ?? []),
eventProbeRow(FAMILY_REFRESH),
],
},
},
};
const getDecision = decideFromDossier(mismatched, { birthDate: "1997-08-08" });
const getPlan = followupPlan(mismatched, getDecision.sessionOutcome);
const getKey = getPlan.next_followup?.semantic_key ?? null;
const persist = await persistServerOwnedFocus({
accounting: idleHandlers(mismatched).client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: mismatched.latestResult?.decisionReceipt ?? null,
followup: getPlan.next_followup,
});
console.log("T0 GET probe vs persist rejection", { getKey, persistStatus: persist.status });
assert.equal(getKey, FAMILY_REFRESH.semantic_key, String(getKey));
assert.equal(persist.status, "invalid_choice_schema", persist.status);
});
test("T0: last inference row and GET probe key vs persist status after a real refresh", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => {
const nextCount = (state.refresh_count ?? 0) + 1;
return {
state: { ...state, refresh_count: nextCount },
eventProbes: [eventProbeRow(FAMILY_REFRESH)],
candidateSetId: state.candidate_set_id,
refreshCount: nextCount,
};
});
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const transitions: Record<string, unknown>[] = [];
const accounting = idleHandlers(dossier, { transitions });
const next = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
});
const last = transitions.at(-1);
const inference = last?.p_inference_state as {
candidates?: unknown[];
candidate_set_id?: string;
refresh_count?: number;
probes?: ReadonlyArray<{ id: string; semantic_key: string }>;
} | undefined;
const lastRow = {
reason: last?.p_reason,
candidates: inference?.candidates?.length ?? 0,
candidate_set_id: last?.p_candidate_set_id ?? inference?.candidate_set_id,
refresh_count: inference?.refresh_count ?? 0,
};
console.log("T0 last inference row", lastRow);
assert.equal(lastRow.reason, "supersede");
assert.equal(lastRow.candidates, TIMES.length);
assert.equal(lastRow.candidate_set_id, liveState(6).candidate_set_id);
assert.equal(lastRow.refresh_count, 1);
const merged = inference?.probes?.find((item) => item.semantic_key === FAMILY_REFRESH.semantic_key);
assert.equal(
merged?.id,
alignedProbeId(FAMILY_REFRESH, liveState(6).answered_probes),
merged?.id,
);
const refreshedDossier = {
...dossier,
latestResult: {
...dossier.latestResult!,
decisionReceipt: {
...(dossier.latestResult?.decisionReceipt ?? {}),
inference_state: inference,
discriminating_event_probes: [eventProbeRow(FAMILY_REFRESH)],
},
},
};
const getDecision = decideFromDossier(refreshedDossier, { birthDate: "1997-08-08" });
const getPlan = followupPlan(refreshedDossier, getDecision.sessionOutcome);
const getKey = getPlan.next_followup?.semantic_key ?? null;
const persist = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: refreshedDossier.latestResult?.decisionReceipt ?? null,
followup: getPlan.next_followup,
});
console.log("T0 GET probe vs persist", { getKey, persistStatus: persist.status });
assert.equal(getKey, FAMILY_REFRESH.semantic_key, String(getKey));
assert.ok(
persist.status === "created" || persist.status === "already_open",
persist.status,
);
assert.equal(next.choiceReady, true, next.hostNarration);
resetRefreshDiscriminatorProbesForTests();
});
test("T3: refresh without new engine probes persists an already_answered attempt", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
let engineCalls = 0;
setRefreshDiscriminatorProbesForTests(async ({ state }) => {
engineCalls += 1;
return {
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
};
});
const dossier = accidentDossier(6);
const transitions: Record<string, unknown>[] = [];
const accounting = idleHandlers(dossier, { transitions });
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
const last = transitions.at(-1);
const inference = last?.p_inference_state as {
refresh_attempts?: ReadonlyArray<{
candidate_set_id?: string;
answer_count?: number;
result?: string;
}>;
probes?: unknown[];
candidate_set_id?: string;
} | undefined;
assert.equal(last?.p_reason, "already_answered", JSON.stringify(last ?? {}));
assert.equal(last?.p_raw_answer, "refresh_attempt");
assert.equal(inference?.refresh_attempts?.at(-1)?.result, "no_new_probes");
assert.equal(inference?.refresh_attempts?.at(-1)?.answer_count, liveState(6).answered_probes.length);
assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id);
assert.equal((inference as { refresh_count?: number } | undefined)?.refresh_count ?? 0, 0);
assert.equal(
(inference?.probes ?? []).some((item) => (
Boolean(item)
&& typeof item === "object"
&& (item as { semantic_key?: string }).semantic_key === FAMILY_REFRESH.semantic_key
)),
false,
);
assert.ok((idle.hostNarration ?? "").trim());
const overlayed = {
...dossier,
latestResult: {
...dossier.latestResult!,
decisionReceipt: {
...(dossier.latestResult?.decisionReceipt ?? {}),
inference_state: inference,
refresh_attempts: inference?.refresh_attempts,
},
},
};
const getDecision = decideFromDossier(overlayed, { birthDate: "1997-08-08" });
// 原值(2): 刷新无新题后 GET 仍 ask_fact_collection,下一问是 collect:targeted:*
// 新值(2): 刷新无新题后 GET 即 offer_provisional_range,下一问不是定向题
// 原因(2): BUG-1084,2026-09-29 D1;「刷新只调一次引擎」这条不变(见下)
assert.equal(getDecision.nextAction, "offer_provisional_range", getDecision.nextAction);
assert.doesNotMatch(
followupPlan(overlayed, getDecision.sessionOutcome).next_followup?.collection_key ?? "",
/collect:targeted:/,
);
const second = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(engineCalls, 1, `second refresh called the engine ${engineCalls} times`);
assert.ok((second.hostNarration ?? idle.hostNarration ?? "").trim());
resetRefreshDiscriminatorProbesForTests();
});
test("T3: changed candidate set is not persisted even when engine returns a probe", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, candidate_set_id: "changed-set" },
eventProbes: [eventProbeRow(FAMILY_REFRESH)],
candidateSetId: "changed-set",
refreshCount: 1,
}));
const dossier = accidentDossier(6);
const transitions: Record<string, unknown>[] = [];
await persistNextInterviewAfterChoice({
accounting: idleHandlers(dossier, { transitions }).client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decideFromDossier(dossier, { birthDate: "1997-08-08" })),
birthDate: "1997-08-08",
});
const last = transitions.at(-1);
assert.equal(last?.p_reason, "already_answered");
assert.notEqual(last?.p_reason, "supersede");
assert.equal(last?.p_candidate_set_id, liveState(6).candidate_set_id);
const inference = last?.p_inference_state as { candidate_set_id?: string; candidates?: unknown[] } | undefined;
assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id);
assert.equal(inference?.candidates?.length, TIMES.length);
resetRefreshDiscriminatorProbesForTests();
});
test("T3: empty candidate list is not persisted even when engine returns a probe", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, candidates: [] },
eventProbes: [eventProbeRow(FAMILY_REFRESH)],
candidateSetId: state.candidate_set_id,
refreshCount: 1,
}));
const dossier = accidentDossier(6);
const transitions: Record<string, unknown>[] = [];
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
accounting: idleHandlers(dossier, { transitions }).client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
state: liveState(6),
hasDatedProbe: false,
});
assert.equal(refreshed.refreshed, false);
assert.equal(refreshed.attemptRecorded, true);
const last = transitions.at(-1);
assert.equal(last?.p_reason, "already_answered");
assert.notEqual(last?.p_reason, "supersede");
const inference = last?.p_inference_state as { candidates?: unknown[] } | undefined;
assert.equal(inference?.candidates?.length, TIMES.length);
resetRefreshDiscriminatorProbesForTests();
});
test("T3: merged probe ids follow the answered naming rule", () => {
const hashedIncoming = {
...FAMILY_REFRESH,
id: `probe:${FAMILY_REFRESH.semantic_key}:${FAMILY_REFRESH.candidate_split_hash}`,
};
assert.equal(
alignedProbeId(hashedIncoming, liveState(6).answered_probes),
`probe:${FAMILY_REFRESH.semantic_key}`,
);
const hashedAnswers = liveState(6).answered_probes.map((item) => ({
...item,
probe_id: `probe:${item.semantic_key}:${item.candidate_split_hash}`,
}));
assert.equal(
alignedProbeId(FAMILY_REFRESH, hashedAnswers),
`probe:${FAMILY_REFRESH.semantic_key}:${FAMILY_REFRESH.candidate_split_hash}`,
);
});