Files
Jyotisha/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts
T
jesse-ux a266b6b727
Independent Staging Quality Gate / validate (push) Failing after 10m16s
Independent Staging Quality Gate / publish (push) Skipped
fix(rectification): invite another dated event instead of saying the pool is closed (BUG-689)
When the seven collect lines are closed, the range card still appears, but the copy asks for one more exact-day event of any kind instead of saying the questions are finished. A new dated event after delivery stales the snapshot so the session does not stay delivered.
2026-09-14 22:56:01 +08:00

1144 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, 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",
].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/);
// 原值: spoken_prompt 含「能把 04:48 和 05:07 分开」
// 新值: 题干「结过婚或订过婚吗?」;spoken_prompt 说剩余候选个数
// 原因: BUG-661 / BUG-662
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");
// 原值: /目前范围/
// 新值: 旁白写「范围已经收到…能问的都问完了」;卡标题才是「目前范围」
// 原因: BUG-687
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 个候选/);
// 原值: 全部定向补事已拒答,收口「能问的都问完了」
// 新值: 收口改为不限领域的补充邀请
// 原因: BUG-689 采集线关闭不等于校正结束
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,
});
assert.match(idle.hostNarration ?? "", /目前范围|范围已经收到|能问的都问完了/);
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}`,
);
});