Files
Jyotisha/frontend/tests/rectification-probe-pool-exhausted-20260911.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

1158 lines
44 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, FOCUS_TARGET_KIND_CHECK } 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);
assert.equal(plan.next_followup?.choice_kind, "existence");
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.notEqual(plan.next_followup?.source, "nakshatra_boundary");
assert.match(plan.next_followup?.collection_key ?? "", /collect:targeted:relationship/);
// 原值: 题干「结过婚或订过婚吗?」
// 新值: 领域全称问法「哪一年都算」
// 原因: D5 存在性题问整个领域
assert.equal(
plan.next_followup?.choice_frame?.prompt,
"感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?",
);
assert.match(plan.next_followup?.spoken_prompt ?? "", /现在还剩 04:4805:07 里 6 个候选/);
assert.doesNotMatch(plan.next_followup?.spoken_prompt ?? "", /能把 04:48 和 05:07 分开/);
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, /家里|收入|搬家|感情|还能再收窄|添丁|住院|结过婚|现在还剩/);
assert.doesNotMatch(host, /这次给出|最终|做不了|才会变|没有拿到下一个问题/);
assert.doesNotMatch(host, /能把 04:48 和 05:07 分开/);
assert.equal(persisted.choiceReady, true);
const focusWrite = idleAccounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(focusWrite, "targeted collect must become the active focus");
assert.match(String(focusWrite.args.p_question_id ?? ""), /collect:targeted:/);
assert.ok(
(FOCUS_TARGET_KIND_CHECK as readonly string[]).includes(String(focusWrite.args.p_target_kind ?? "")),
String(focusWrite.args.p_target_kind),
);
const schema = focusWrite.args.p_expected_answer_schema as Record<string, unknown> | undefined;
assert.equal(schema?.targeted_collect, true);
assert.equal(
(schema?.choice as { prompt?: string } | undefined)?.prompt,
"感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?",
);
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 逐条点选
assert.equal(stillAsking.nextAction, "ask_fact_collection", stillAsking.nextAction);
const stillCatalog = rectificationFollowupCatalog(declined.latestResult, declined.evidence);
assert.ok(
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:4805:07(对照了 4 件经历)$/);
assert.doesNotMatch(title, /这次给出|最终|结束| · /);
assert.match(delivery.narrow_hint ?? "", /现在还剩 04:4805: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,
regenerating: 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" });
assert.equal(getDecision.nextAction, "ask_fact_collection", getDecision.nextAction);
assert.match(
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}`,
);
});