Files
Jyotisha/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts
T
Jesse_ChenandCursor 530f260f76
Independent Staging Quality Gate / validate (push) Successful in 12m9s
Independent Staging Quality Gate / publish (push) Successful in 2m17s
fix(rectification): ask remaining split lines one card at a time (BUG-661~663)
Dated-pool empty now yields existence cards per remaining layer, leftover-candidate copy, and an optional D9/D10 tie-break on the range card. Skill 10.0.25.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 16:50:25 +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,
buildNextUserAction,
} 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");
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" });
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" });
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 个候选/);
assert.match(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);
const nextUser = buildNextUserAction({
scorableCount: dossier.evidence.length,
evidenceCount: dossier.evidence.length,
hasLatestResult: true,
selectionAllowed: publicAction.can_adopt,
sessionOutcome: decision.sessionOutcome,
nextFollowup: followupPlan(dossier, decision.sessionOutcome).next_followup,
workingTime: decision.representativeTime,
});
assert.ok(
nextUser.id === "offer_provisional_range" || nextUser.id === "adopt_representative",
nextUser.id,
);
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}`,
);
});