Empty refreshes were writing inference rows, and GET-selected probe keys could miss inference_state, so persist rejected the next card. Co-authored-by: Cursor <cursoragent@cursor.com>
998 lines
37 KiB
TypeScript
998 lines
37 KiB
TypeScript
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 } 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;
|
||
|
||
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 }
|
||
: 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: () => rpcDossier(decision, extra),
|
||
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, undefined);
|
||
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:/);
|
||
assert.match(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.equal(persisted.choiceReady, false);
|
||
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");
|
||
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 delivered = decideFromDossier(declined, { 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(declined.latestResult, declined.evidence);
|
||
assert.equal(
|
||
targetedCollectPool(
|
||
catalog.remainingLayers,
|
||
declined.evidence,
|
||
declined.conversationSummary.declinedSkippedTopics,
|
||
catalog.remainingSplitTimes,
|
||
).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_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:48–05:07(对照了 4 件经历)$/);
|
||
assert.doesNotMatch(title, /这次给出|最终|结束| · /);
|
||
assert.match(delivery.narrow_hint ?? "", /还能再收窄:如果记得/);
|
||
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 does not write inference", 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 transitions: Record<string, unknown>[] = [];
|
||
const idle = await persistNextInterviewIfIdle({
|
||
accounting: idleHandlers(dossier, { transitions }).client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
assert.equal(transitions.length, 0, JSON.stringify(transitions.at(-1) ?? {}));
|
||
assert.ok((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",
|
||
});
|
||
assert.equal(transitions.length, 0);
|
||
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(transitions.length, 0);
|
||
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}`,
|
||
);
|
||
});
|