Stop domain-wheel collecting and age-band years in prompts. Ask until the training gate, then discriminate until convergence, then deliver a range plus a concrete follow-up. Reserve holdout only with four dated events. Co-authored-by: Cursor <cursoragent@cursor.com>
989 lines
37 KiB
TypeScript
989 lines
37 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
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, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||
import {
|
||
decideFromDossier,
|
||
type DecisionDossier,
|
||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||
import {
|
||
applyRectificationChoice,
|
||
ensureNonTerminalTurnExit,
|
||
persistNextInterviewIfIdle,
|
||
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||
import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
|
||
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts";
|
||
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
||
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
|
||
import { finalizeSuccessfulTurnExit } from "../src/lib/rectification-agentic/v9/turn-exit.ts";
|
||
import {
|
||
CASE_ID,
|
||
FOCUS_ID,
|
||
SESSION_ID,
|
||
TURN_ID,
|
||
USER_ID,
|
||
activeFocusFixture,
|
||
candidateSnapshotFixture,
|
||
computeFixture,
|
||
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 TIMES = [
|
||
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
|
||
] as const;
|
||
const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]);
|
||
const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const;
|
||
const SCORES: Record<string, number> = {
|
||
"04:47": 16,
|
||
"04:51": 20,
|
||
"04:53": 16,
|
||
"04:59": 10,
|
||
"05:00": 4,
|
||
"05:07": 3,
|
||
"05:12": 2,
|
||
"05:14": 1,
|
||
"05:15": 1,
|
||
};
|
||
const PROBABILITY: Record<string, number> = {
|
||
"04:47": 0.25,
|
||
"04:51": 0.4,
|
||
"04:53": 0.25,
|
||
"04:59": 0.1,
|
||
};
|
||
|
||
const EVIDENCE = [
|
||
{
|
||
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: "education start",
|
||
},
|
||
{
|
||
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: "education completion",
|
||
},
|
||
{
|
||
id: "e-rel-start",
|
||
status: "confirmed" as const,
|
||
domain: "relationship",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2024-05-01",
|
||
occurredTo: null,
|
||
eventKind: "relationship_start",
|
||
summary: "relationship start",
|
||
},
|
||
{
|
||
id: "e-rel-end",
|
||
status: "confirmed" as const,
|
||
domain: "relationship",
|
||
datePrecision: "day" as const,
|
||
occurredFrom: "2024-08-08",
|
||
occurredTo: null,
|
||
eventKind: "relationship_end",
|
||
summary: "relationship end",
|
||
},
|
||
{
|
||
id: "e-finance",
|
||
status: "confirmed" as const,
|
||
domain: "finance",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2026-01-01",
|
||
occurredTo: "2026-01-31",
|
||
eventKind: "finance_loss",
|
||
summary: "finance change",
|
||
},
|
||
{
|
||
id: "e-reloc",
|
||
status: "confirmed" as const,
|
||
domain: "relocation",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2023-07-01",
|
||
occurredTo: "2023-07-31",
|
||
eventKind: "relocation",
|
||
summary: "relocation",
|
||
},
|
||
{
|
||
id: "e-health",
|
||
status: "confirmed" as const,
|
||
domain: "health_pressure",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2026-01-01",
|
||
occurredTo: "2026-08-31",
|
||
eventKind: "pressure_period",
|
||
summary: "health pressure",
|
||
},
|
||
{
|
||
id: "e-occupation",
|
||
status: "confirmed" as const,
|
||
domain: "occupation",
|
||
datePrecision: "unknown" as const,
|
||
occurredFrom: null,
|
||
occurredTo: null,
|
||
eventKind: "occupation_note",
|
||
summary: "occupation note",
|
||
},
|
||
] as const;
|
||
|
||
function uuidAt(index: number) {
|
||
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
|
||
}
|
||
|
||
function existenceProbe(input: {
|
||
key: string;
|
||
domain: string;
|
||
year: number;
|
||
question: string;
|
||
}): 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: [...ACTIVE],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
|
||
{ answer_class: "weak_yes", supports: [], conflicts: [] },
|
||
{ answer_class: "no", supports: ["04:47"], conflicts: ["04:51"] },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: 0.4,
|
||
source: "dasha_boundary",
|
||
choice_kind: "existence",
|
||
style_options: EXISTENCE_OPTIONS,
|
||
};
|
||
}
|
||
|
||
const D9: ConflictProbe = {
|
||
id: "contrast:varga.d9.style",
|
||
semantic_key: "varga.d9.style",
|
||
candidate_split_hash: "varga.d9.style",
|
||
domain: "relationship",
|
||
year: 0,
|
||
question: "亲密关系里更接近哪一种",
|
||
candidate_ids: [...ACTIVE],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
|
||
{ answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] },
|
||
{ answer_class: "no", supports: [], conflicts: [] },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: 0.3,
|
||
source: "varga_contrast",
|
||
choice_kind: "varga_style",
|
||
};
|
||
|
||
const D10: ConflictProbe = {
|
||
id: "contrast:varga.d10.style",
|
||
semantic_key: "varga.d10.style",
|
||
candidate_split_hash: "varga.d10.style",
|
||
domain: "career",
|
||
year: 0,
|
||
question: "平时做事更接近哪一种",
|
||
candidate_ids: [...ACTIVE],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
|
||
{ answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] },
|
||
{ answer_class: "no", supports: [], conflicts: [] },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: 0.3,
|
||
source: "varga_contrast",
|
||
choice_kind: "varga_style",
|
||
};
|
||
|
||
const CAREER_MONTH = existenceProbe({
|
||
key: "career.2023.05.dasha_boundary",
|
||
domain: "career",
|
||
year: 2023,
|
||
question: "2023 年 5 月前后有没有入职或换工作",
|
||
});
|
||
const CAREER_YEAR = existenceProbe({
|
||
key: "career.2023.dasha_activation",
|
||
domain: "career",
|
||
year: 2023,
|
||
question: "2023 年前后工作上有没有入职或换工作",
|
||
});
|
||
const RELOC = existenceProbe({
|
||
key: "relocation.2018.05.dasha_boundary",
|
||
domain: "relocation",
|
||
year: 2018,
|
||
question: "2018 年 5 月前后有没有搬家",
|
||
});
|
||
const FINANCE = existenceProbe({
|
||
key: "finance.2024.03.dasha_boundary",
|
||
domain: "finance",
|
||
year: 2024,
|
||
question: "2024 年 3 月前后钱上有没有明显变化",
|
||
});
|
||
|
||
const ASKED_PROBES = [D9, D10, CAREER_MONTH, CAREER_YEAR, RELOC, FINANCE];
|
||
const LEFTOVER_PROBE = existenceProbe({
|
||
key: "career.2021.04.dasha_boundary",
|
||
domain: "career",
|
||
year: 2021,
|
||
question: "2021 年 4 月前后有没有入职或换工作",
|
||
});
|
||
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
|
||
const GATE_SENTENCE = /还差(?: \d+ 件)?带月份的经历|两件事的日期还没对清|当前还排不出可比较的候选时间|还差另一个领域的带月份经历/;
|
||
|
||
function liveState(extraProbes: readonly ConflictProbe[] = []): InferenceState {
|
||
const probes = [...ASKED_PROBES, ...extraProbes];
|
||
const rankedActive = [...ACTIVE].sort((left, right) => (
|
||
(PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0)
|
||
|| (SCORES[right] ?? 0) - (SCORES[left] ?? 0)
|
||
|| left.localeCompare(right)
|
||
));
|
||
const candidates = TIMES.map((time, index) => {
|
||
const eliminated = ELIMINATED.has(time);
|
||
const activeRank = (rankedActive as readonly string[]).indexOf(time);
|
||
return {
|
||
id: time,
|
||
time,
|
||
cluster_range: [time, time] as const,
|
||
prior_score: SCORES[time] ?? 0,
|
||
posterior_score: SCORES[time] ?? 0,
|
||
probability: eliminated ? 0 : (PROBABILITY[time] ?? 0),
|
||
status: eliminated ? "eliminated" as const : "active" as const,
|
||
rank: eliminated ? ACTIVE.length + index : activeRank + 1,
|
||
strong_conflict_count: eliminated ? 3 : 0,
|
||
};
|
||
});
|
||
const answered = [
|
||
{ probe: D9, answer_class: "yes" as const },
|
||
{ probe: D10, answer_class: "yes" as const },
|
||
{ probe: CAREER_MONTH, answer_class: "yes" as const },
|
||
{ probe: CAREER_YEAR, answer_class: "no" as const },
|
||
{ probe: RELOC, answer_class: "no" as const },
|
||
{ probe: FINANCE, answer_class: "no" as const },
|
||
].map((item) => ({
|
||
probe_id: item.probe.id,
|
||
semantic_key: item.probe.semantic_key,
|
||
candidate_split_hash: item.probe.candidate_split_hash,
|
||
answer_class: item.answer_class,
|
||
classified_from: "choice" as const,
|
||
}));
|
||
const raw = {
|
||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
|
||
revision: 6,
|
||
phase: "discrimination" as const,
|
||
result_status: "discriminating" as const,
|
||
range_start: "04:47",
|
||
range_end: "05:15",
|
||
candidates,
|
||
events: [
|
||
{ id: "e-edu-start", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
|
||
{ id: "e-edu-end", domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
|
||
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const },
|
||
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "holdout" as const },
|
||
{ id: "e-finance", domain: "finance", year: 2026, precision: "month" as const, usage: "training" as const },
|
||
{ id: "e-reloc", domain: "relocation", year: 2023, precision: "month" as const, usage: "training" as const },
|
||
{ id: "e-health", domain: "health_pressure", year: 2026, precision: "month" as const, usage: "training" as const },
|
||
],
|
||
probes,
|
||
answered_probes: answered,
|
||
rounds: [],
|
||
last_inference_round: null,
|
||
entropy: 1.2,
|
||
representative_time: "04:51",
|
||
credible_range: ["04:47", "04:53"] as const,
|
||
holdout_passed: null,
|
||
};
|
||
const loaded = asInferenceState(raw);
|
||
assert.ok(loaded, "accident-shape inference must pass asInferenceState");
|
||
return loaded;
|
||
}
|
||
|
||
function eventProbeRow(probe: ConflictProbe) {
|
||
return {
|
||
year: probe.year,
|
||
year_label: probe.year > 0 ? `${probe.year} 年前后` : "",
|
||
domain: probe.domain,
|
||
event_family: probe.domain === "career" ? "入职、换工作或职责加重" : probe.domain,
|
||
source: probe.source,
|
||
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,
|
||
choice_kind: probe.choice_kind,
|
||
style_options: probe.style_options,
|
||
};
|
||
}
|
||
|
||
function snapshotCandidates() {
|
||
return TIMES.map((time, index) => ({
|
||
candidate_id: uuidAt(index),
|
||
rank: index + 1,
|
||
time,
|
||
relative_support: Math.round(SCORES[time] ?? 0),
|
||
tied_minute_count: 1,
|
||
}));
|
||
}
|
||
|
||
function accidentDossier(extra: {
|
||
acceptanceAllowed?: boolean;
|
||
acceptanceReasons?: string[];
|
||
mismatchSnapshot?: boolean;
|
||
leftoverProbe?: ConflictProbe;
|
||
holdoutUnavailable?: boolean;
|
||
} = {}): DecisionDossier {
|
||
const base = liveState(extra.leftoverProbe ? [extra.leftoverProbe] : []);
|
||
const evidence = extra.holdoutUnavailable
|
||
? EVIDENCE.filter((item) => item.id !== "e-rel-end")
|
||
: EVIDENCE;
|
||
const state = {
|
||
...base,
|
||
events: extra.holdoutUnavailable
|
||
? base.events.filter((item) => item.usage !== "holdout" && item.id !== "e-rel-end")
|
||
: base.events,
|
||
holdout_passed: extra.holdoutUnavailable ? true : base.holdout_passed,
|
||
};
|
||
const fingerprint = evidenceLedgerFingerprint(evidence as never);
|
||
const acceptanceAllowed = extra.acceptanceAllowed !== false;
|
||
return {
|
||
evidence,
|
||
conversationSummary: {
|
||
activeFocus: null,
|
||
declinedSkippedTopics: [{ target_domain: "family", status: "declined" }],
|
||
},
|
||
latestResult: {
|
||
resultId: "55555555-5555-4555-8555-555555555555",
|
||
selectionAllowed: acceptanceAllowed,
|
||
confirmationAllowed: false,
|
||
evidenceLedgerFingerprint: fingerprint,
|
||
candidates: extra.mismatchSnapshot
|
||
? [{ candidateId: uuidAt(0), time: "03:00", rank: 1, relativeSupport: 10 }]
|
||
: TIMES.map((time, index) => ({
|
||
candidateId: uuidAt(index),
|
||
time,
|
||
rank: index + 1,
|
||
relativeSupport: Math.round(SCORES[time] ?? 0),
|
||
})),
|
||
representativeTime: "04:51",
|
||
decisionReceipt: {
|
||
accept_allowed: acceptanceAllowed,
|
||
acceptance_allowed: acceptanceAllowed,
|
||
propose_allowed: extra.acceptanceAllowed === false ? false : true,
|
||
selection_allowed: extra.acceptanceAllowed === false ? false : true,
|
||
confirmation_allowed: false,
|
||
...(extra.acceptanceReasons ? { acceptance_reasons: extra.acceptanceReasons } : {}),
|
||
inference_state: state,
|
||
discriminating_event_probes: [
|
||
...ASKED_PROBES.map(eventProbeRow),
|
||
...(extra.leftoverProbe ? [eventProbeRow(extra.leftoverProbe)] : []),
|
||
],
|
||
oos_blind_prompts: extra.holdoutUnavailable
|
||
? []
|
||
: [{
|
||
domain: "career",
|
||
user_meaning: "工作这条线还没用过。有没有记得大概时间的入职或换工作?",
|
||
used_for_scoring: false,
|
||
}],
|
||
},
|
||
},
|
||
case: { acceptedTime: null, status: "collecting_evidence" },
|
||
};
|
||
}
|
||
|
||
function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType<typeof activeFocusFixture> } = {}) {
|
||
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-06T00:00:00.000Z",
|
||
}));
|
||
return dossierFixture({
|
||
evidence,
|
||
latestResult: candidateSnapshotFixture({
|
||
selectionAllowed: decision.latestResult?.selectionAllowed ?? true,
|
||
confirmationAllowed: false,
|
||
representativeTime: "04:51",
|
||
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,
|
||
})) ?? snapshotCandidates(),
|
||
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
|
||
}),
|
||
conversationSummary: {
|
||
confirmed_evidence_summary: [],
|
||
pending_revisions: [],
|
||
active_focus: extra.activeFocus ?? null,
|
||
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
|
||
candidate_divergence_summary: null,
|
||
missing_evidence_categories: [],
|
||
last_result_policy: null,
|
||
summary_version: 1,
|
||
updated_at: "2026-09-06T00:00:00.000Z",
|
||
},
|
||
});
|
||
}
|
||
|
||
function idleHandlers(decision: DecisionDossier, extra: {
|
||
activeFocus?: ReturnType<typeof activeFocusFixture>;
|
||
allowFocus?: boolean;
|
||
turns?: unknown[];
|
||
} = {}) {
|
||
return fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => {
|
||
const row = rpcDossier(decision, extra);
|
||
return extra.turns ? { ...row, turns: extra.turns } : row;
|
||
},
|
||
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: args.p_inference && typeof args.p_inference === "object"
|
||
? (args.p_inference as { probe_id?: string }).probe_id ?? "p-cd"
|
||
: "p-cd",
|
||
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.allowFocus
|
||
? (_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-06T00:00:00.000Z",
|
||
resolved_at: null,
|
||
asked_turn_id: args.p_asked_turn_id ?? null,
|
||
},
|
||
idempotent: false,
|
||
})
|
||
: (_fn, args) => {
|
||
throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`);
|
||
},
|
||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
||
get_agentic_rectification_turn_receipt: () => null,
|
||
});
|
||
}
|
||
|
||
function leftoverFocus(probe: ConflictProbe) {
|
||
return activeFocusFixture({
|
||
intent: "distinguish_candidates",
|
||
targetDomain: probe.domain,
|
||
questionId: `probe:${probe.semantic_key}`,
|
||
expectedAnswerSchema: {
|
||
choice: {
|
||
prompt: probe.question,
|
||
option_a: EXISTENCE_OPTIONS[0].label,
|
||
option_b: EXISTENCE_OPTIONS[1].label,
|
||
option_c: EXISTENCE_OPTIONS[2].label,
|
||
option_d: EXISTENCE_OPTIONS[3].label,
|
||
options: EXISTENCE_OPTIONS.map((option, index) => ({
|
||
key: (["A", "B", "C", "D"] as const)[index]!,
|
||
label: option.label,
|
||
answer_class: option.answer_class,
|
||
})),
|
||
},
|
||
probe_id: probe.id,
|
||
semantic_key: probe.semantic_key,
|
||
candidate_split_hash: probe.candidate_split_hash,
|
||
},
|
||
});
|
||
}
|
||
|
||
function assistantAppendCalls(calls: Array<{ fn: string; args: Record<string, unknown> }>) {
|
||
return calls.filter((item) => (
|
||
item.fn === "append_agentic_rectification_turn"
|
||
&& typeof item.args.p_assistant_message === "string"
|
||
&& String(item.args.p_assistant_message).trim()
|
||
));
|
||
}
|
||
|
||
function exitAppendCalls(calls: Array<{ fn: string; args: Record<string, unknown> }>) {
|
||
return assistantAppendCalls(calls).filter((item) => (
|
||
GATE_SENTENCE.test(String(item.args.p_assistant_message))
|
||
|| /就能开始筛|现在记下的是|范围还能再收一截|这次给出的范围/.test(String(item.args.p_assistant_message))
|
||
));
|
||
}
|
||
|
||
function gateAppendCalls(calls: Array<{ fn: string; args: Record<string, unknown> }>) {
|
||
return assistantAppendCalls(calls).filter((item) => GATE_SENTENCE.test(String(item.args.p_assistant_message)));
|
||
}
|
||
|
||
function gateBodyCount(text: string | null | undefined) {
|
||
return (text ?? "").match(new RegExp(GATE_SENTENCE.source, "g"))?.length ?? 0;
|
||
}
|
||
|
||
function fakeAgentStream(chunks: Array<{ type: string; payload?: Record<string, unknown> }>) {
|
||
const streamResult = {
|
||
fullStream: (async function* () {
|
||
for (const item of chunks) yield item;
|
||
})(),
|
||
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
|
||
};
|
||
return {
|
||
stream: async () => streamResult,
|
||
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
|
||
};
|
||
}
|
||
|
||
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 }));
|
||
}
|
||
|
||
test("skill version is 10.0.23 after the collect-semantics bump", () => {
|
||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.23");
|
||
});
|
||
|
||
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
|
||
const srcRoot = new URL("../src/", import.meta.url);
|
||
const files = [
|
||
"lib/rectification-agentic/v9/method-followup.ts",
|
||
"lib/rectification-agentic/v9/answer-choice.ts",
|
||
"mastra/rectification-v9-tools.ts",
|
||
"app/api/rectification/agent/route.ts",
|
||
"lib/rectification-agentic/user-copy.ts",
|
||
];
|
||
const hits: string[] = [];
|
||
for (const relative of files) {
|
||
const source = readFileSync(new URL(relative, srcRoot), "utf8");
|
||
for (const line of source.split("\n")) {
|
||
if (line.includes("USER_COLLECT_QUESTION.other") || line.includes("collectQuestionRetryByDomain.other")) {
|
||
hits.push(`${relative}: ${line.trim()}`);
|
||
}
|
||
}
|
||
}
|
||
assert.deepEqual(hits, []);
|
||
});
|
||
|
||
test("collect spoken stop stays on choice cards; range line is status only", () => {
|
||
// 原值: 口述停止按钮和范围小字共用 CHOICE_STOP_LABEL / onStop
|
||
// 新值: CHOICE_STOP_LABEL 只在选择题卡;composer-wrap 不再挂先这样
|
||
// 原因: BUG-595 决策 2
|
||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||
const rangeFn = chat.slice(
|
||
chat.indexOf("function RectificationReadonlyRange"),
|
||
chat.indexOf("type RectificationAgenticChatProps"),
|
||
);
|
||
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
|
||
assert.doesNotMatch(wrap, /rectification-collect-stop/);
|
||
assert.match(chat, /CHOICE_STOP_LABEL/);
|
||
assert.match(chat, /kind === "collect_spoken"/);
|
||
assert.match(chat, /function submitStop/);
|
||
assert.match(chat, /RectificationReadonlyRange/);
|
||
assert.match(rangeFn, /role="status"/);
|
||
assert.doesNotMatch(rangeFn, /onStop/);
|
||
assert.doesNotMatch(rangeFn, /<button/);
|
||
assert.match(route, /ask_about_result/);
|
||
assert.match(route, /STOP_ACTION/);
|
||
assert.equal(CHOICE_STOP_LABEL, "先这样,先看当前范围");
|
||
});
|
||
|
||
test("covered accident shape adopts instead of collecting other", async () => {
|
||
const dossier = accidentDossier();
|
||
const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" });
|
||
assert.notEqual(decision.sessionOutcome, "collect_evidence");
|
||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||
assert.match(decision.stopReason ?? "", /probe_pool_exhausted/);
|
||
|
||
const accounting = idleHandlers(dossier);
|
||
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
}));
|
||
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
|
||
assert.equal(
|
||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||
false,
|
||
);
|
||
assert.ok(persisted.hostNarration);
|
||
// 原值: probePoolExhaustedStop「能分开候选的问题已经问完…」
|
||
// 新值: 三句交付正文
|
||
// 原因: BUG-595 决策 4
|
||
assert.match(persisted.hostNarration ?? "", /这次给出的范围 04:47–04:53/);
|
||
assert.doesNotMatch(persisted.hostNarration ?? "", /排盘用/);
|
||
assert.match(persisted.hostNarration ?? "", /对照了 7 件经历/);
|
||
assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false);
|
||
assert.match(persisted.hostNarration ?? "", /这只是代表性候选,不是已确认的唯一出生分钟/);
|
||
assert.equal(persisted.terminalNote, true);
|
||
});
|
||
|
||
test("closed ceiling with training open delivers a range and a concrete next-collect hint", async () => {
|
||
const dossier = accidentDossier({
|
||
acceptanceAllowed: false,
|
||
acceptanceReasons: ["insufficient_events"],
|
||
holdoutUnavailable: true,
|
||
});
|
||
const accounting = idleHandlers(dossier);
|
||
const { result: idle, lines } = await warnLines(() => persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
}));
|
||
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
|
||
assert.equal(
|
||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||
false,
|
||
);
|
||
assert.ok(persisted.hostNarration);
|
||
// 原值: 「还差…带月份的经历|领域不限」,不落焦点
|
||
// 新值: 训练门已开时写区间 + 「再补什么」;禁词不得出现
|
||
// 原因: 产品撤回门槛句终态,训练门开必须交付(BUG-646)
|
||
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
|
||
assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够|还差/);
|
||
assert.match(persisted.hostNarration ?? "", /范围还能再收一截|这次给出的范围|当前范围/);
|
||
|
||
const repaired = await ensureNonTerminalTurnExit({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
assert.ok(repaired.hostNarration === null || repaired.terminalNote !== false);
|
||
|
||
const log = lines.map((line) => {
|
||
try {
|
||
return JSON.parse(line) as Record<string, unknown>;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}).find((item) => item?.event === "rectification_exhaustion_collect");
|
||
assert.ok(log);
|
||
for (const key of [
|
||
"event", "case_id", "can_adopt", "ceiling", "training_gate",
|
||
"stop_class", "ranked_count", "probe_key", "budget", "next_domain", "next_source",
|
||
]) {
|
||
assert.ok(key in (log ?? {}), key);
|
||
}
|
||
assert.equal("summary" in (log ?? {}), false);
|
||
assert.equal("year" in (log ?? {}), false);
|
||
assert.equal("evidence" in (log ?? {}), false);
|
||
});
|
||
|
||
test("inconsistent projection logs ranked_count 0 and still delivers a gate", async () => {
|
||
const dossier = accidentDossier({ mismatchSnapshot: true, holdoutUnavailable: true });
|
||
const accounting = idleHandlers(dossier);
|
||
const { result: idle, lines } = await warnLines(() => persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
}));
|
||
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
|
||
assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false);
|
||
assert.equal(
|
||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||
false,
|
||
);
|
||
const log = lines.map((line) => {
|
||
try {
|
||
return JSON.parse(line) as Record<string, unknown>;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}).find((item) => item?.event === "rectification_exhaustion_collect");
|
||
assert.equal(log?.ranked_count, 0);
|
||
assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够/);
|
||
assert.match(persisted.hostNarration ?? "", /范围还能再收一截|这次给出的范围|就能开始筛|当前范围|排不出可比较的候选/);
|
||
});
|
||
|
||
test("finalizeSuccessfulTurnExit writes one gate body and repair does not add another", async () => {
|
||
const dossier = accidentDossier({
|
||
acceptanceAllowed: false,
|
||
acceptanceReasons: ["insufficient_events"],
|
||
holdoutUnavailable: true,
|
||
});
|
||
const accounting = idleHandlers(dossier);
|
||
await warnLines(() => finalizeSuccessfulTurnExit({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
action: "message",
|
||
}));
|
||
const first = exitAppendCalls(accounting.calls);
|
||
assert.ok(first.length <= 1, JSON.stringify(first.map((item) => item.args.p_assistant_message)));
|
||
if (first.length === 1) {
|
||
assert.doesNotMatch(String(first[0]?.args.p_assistant_message), /领域不限|做不了|材料不够|还差 \d+ 件/);
|
||
}
|
||
|
||
const beforeRepair = accounting.calls.filter((item) => item.fn === "append_agentic_rectification_turn").length;
|
||
await ensureNonTerminalTurnExit({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
assert.equal(
|
||
accounting.calls.filter((item) => item.fn === "append_agentic_rectification_turn").length,
|
||
beforeRepair,
|
||
);
|
||
});
|
||
|
||
test("agent evidence path writes one gate body", async () => {
|
||
const dossier = accidentDossier({
|
||
acceptanceAllowed: false,
|
||
acceptanceReasons: ["insufficient_events"],
|
||
holdoutUnavailable: true,
|
||
});
|
||
const accounting = idleHandlers(dossier);
|
||
const result = await runV9AgentTurn({
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
sessionId: SESSION_ID,
|
||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||
action: "evidence",
|
||
message: "2016年9月离开家去北京工作",
|
||
modelName: "gpt-4o-mini",
|
||
accounting: accounting.client,
|
||
billing: {
|
||
reserve: async () => ({ success: true, status: 200 }),
|
||
complete: async () => true,
|
||
release: async () => true,
|
||
},
|
||
emit: async () => {},
|
||
buildAgent: async () => fakeAgentStream([
|
||
{ type: "start" },
|
||
{ type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } },
|
||
{ type: "tool-result", payload: { toolName: "skill" } },
|
||
{ type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } },
|
||
{ type: "tool-result", payload: { toolName: "rectification-read-case" } },
|
||
{ type: "text-delta", payload: { text: "先记下这件事。" } },
|
||
{ type: "finish" },
|
||
]) as never,
|
||
});
|
||
assert.equal(result.ok, true);
|
||
// 原值: persistExhaustionGateTurn 再写一条门槛句
|
||
// 新值: 本轮已有 agent 正文(askedTurnId)时不再写第二条确定性门槛 turn
|
||
// 原因: BUG-596
|
||
const gates = gateAppendCalls(accounting.calls);
|
||
assert.equal(gates.length, 0, JSON.stringify(gates.map((item) => item.args.p_assistant_message)));
|
||
});
|
||
|
||
test("last closed-ceiling card concatenates the gate into one append", async () => {
|
||
const dossier = accidentDossier({
|
||
acceptanceAllowed: false,
|
||
acceptanceReasons: ["insufficient_events"],
|
||
leftoverProbe: LEFTOVER_PROBE,
|
||
holdoutUnavailable: true,
|
||
});
|
||
const accounting = idleHandlers(dossier, {
|
||
activeFocus: leftoverFocus(LEFTOVER_PROBE),
|
||
});
|
||
const applied = await applyRectificationChoice(accounting.client, {
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
sessionId: SESSION_ID,
|
||
actionId: ACTION_ID,
|
||
action: CHOICE_ACTION,
|
||
focusId: FOCUS_ID,
|
||
questionId: `probe:${LEFTOVER_PROBE.semantic_key}`,
|
||
probeId: LEFTOVER_PROBE.id,
|
||
optionId: "A",
|
||
expectedRevision: 6,
|
||
});
|
||
const gates = exitAppendCalls(accounting.calls);
|
||
assert.equal(gates.length, 1, JSON.stringify(gates.map((item) => item.args.p_assistant_message)));
|
||
assert.doesNotMatch(applied.narration, /领域不限|做不了|材料不够|还差 \d+ 件/);
|
||
assert.match(applied.narration, /范围还能再收一截|这次给出的范围|就能开始筛|当前范围/);
|
||
assert.equal(
|
||
(String(gates[0]?.args.p_assistant_message).match(/范围还能再收一截|这次给出的范围|就能开始筛/g) ?? []).length > 0,
|
||
true,
|
||
);
|
||
});
|
||
|
||
test("closed ceiling with a leftover discriminator persists the card not the gate", async () => {
|
||
const dossier = accidentDossier({
|
||
acceptanceAllowed: false,
|
||
leftoverProbe: LEFTOVER_PROBE,
|
||
});
|
||
const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" });
|
||
assert.equal(decision.nextAction, "ask_candidate_discriminator");
|
||
|
||
const accounting = idleHandlers(dossier, { allowFocus: true });
|
||
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
}));
|
||
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
|
||
const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||
assert.equal(focusCalls.length > 0, true);
|
||
assert.match(String(focusCalls[0]?.args.p_question_id ?? ""), /career\.2021/);
|
||
assert.equal(focusCalls[0]?.args.p_intent, "distinguish_candidates");
|
||
assert.equal(gateAppendCalls(accounting.calls).length, 0);
|
||
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
|
||
assert.match(persisted.hostNarration ?? "", /2021/);
|
||
});
|
||
|
||
test("closed ceiling with holdout still open persists holdout not the gate", async () => {
|
||
const dossier = accidentDossier({ acceptanceAllowed: false });
|
||
const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" });
|
||
assert.equal(decision.nextAction, "ask_holdout_validation");
|
||
|
||
const accounting = idleHandlers(dossier, { allowFocus: true });
|
||
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
}));
|
||
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
|
||
const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||
assert.equal(focusCalls.length > 0, true);
|
||
assert.equal(focusCalls[0]?.args.p_intent, "collect_method_evidence");
|
||
assert.equal(focusCalls[0]?.args.p_target_domain, "career");
|
||
assert.equal(gateAppendCalls(accounting.calls).length, 0);
|
||
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
|
||
assert.match(persisted.hostNarration ?? "", /入职|换工作|工作/);
|
||
});
|
||
|
||
test("two education-only events write a precise gap and no card", async () => {
|
||
const dossier = {
|
||
...accidentDossier({
|
||
acceptanceAllowed: false,
|
||
acceptanceReasons: ["insufficient_events"],
|
||
holdoutUnavailable: true,
|
||
}),
|
||
evidence: [EVIDENCE[0], EVIDENCE[1]],
|
||
conversationSummary: {
|
||
activeFocus: null,
|
||
declinedSkippedTopics: [
|
||
{
|
||
target_domain: "other",
|
||
status: "declined",
|
||
intent: "collect_method_evidence",
|
||
questionId: "collect:invite:more",
|
||
target_kind: "invite_more",
|
||
},
|
||
...["career", "relocation", "relationship", "family", "finance", "health_pressure"].map((domain) => ({
|
||
target_domain: domain,
|
||
status: "declined",
|
||
intent: "collect_method_evidence",
|
||
})),
|
||
],
|
||
},
|
||
};
|
||
const accounting = idleHandlers(dossier);
|
||
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
}));
|
||
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
|
||
assert.equal(
|
||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||
false,
|
||
);
|
||
// 原值: 「还差 1 件…领域不限」
|
||
// 新值: 精确缺口句,无卡、无禁词
|
||
// 原因: 训练门关只写缺口并保持开放(BUG-646)
|
||
assert.match(persisted.hostNarration ?? "", /现在记下的是/);
|
||
assert.match(persisted.hostNarration ?? "", /就能开始筛/);
|
||
assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够|还差/);
|
||
assert.equal(persisted.choiceReady, false);
|
||
assert.equal(persisted.terminalNote, true);
|
||
});
|
||
|
||
test("ensureNonTerminalTurnExit writes a host turn when exhausted with no carrier", async () => {
|
||
resetDeliveryTurnGuardForTests();
|
||
const dossier = accidentDossier({
|
||
acceptanceAllowed: false,
|
||
acceptanceReasons: ["insufficient_events"],
|
||
holdoutUnavailable: true,
|
||
});
|
||
const accounting = idleHandlers(dossier);
|
||
const repaired = await ensureNonTerminalTurnExit({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
assert.ok(repaired.hostNarration);
|
||
assert.equal(repaired.terminalNote, true);
|
||
assert.equal(exitAppendCalls(accounting.calls).length, 1);
|
||
});
|
||
|
||
test("ensureNonTerminalTurnExit does not rewrite when a terminal-note host turn exists", async () => {
|
||
resetDeliveryTurnGuardForTests();
|
||
const dossier = accidentDossier({
|
||
acceptanceAllowed: false,
|
||
acceptanceReasons: ["insufficient_events"],
|
||
holdoutUnavailable: true,
|
||
});
|
||
const accounting = idleHandlers(dossier, {
|
||
turns: [
|
||
{
|
||
id: "99999999-9999-4999-8999-999999999999",
|
||
role: "assistant",
|
||
text: RECTIFICATION_USER_COPY.noCandidatesGate,
|
||
status: "completed",
|
||
created_at: "2026-09-09T00:00:00.000Z",
|
||
},
|
||
],
|
||
});
|
||
const repaired = await ensureNonTerminalTurnExit({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
assert.equal(repaired.hostNarration, null);
|
||
assert.equal(gateAppendCalls(accounting.calls).length, 0);
|
||
});
|