Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown. Co-authored-by: Cursor <cursoragent@cursor.com>
858 lines
32 KiB
TypeScript
858 lines
32 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, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
|
import {
|
|
applyRectificationChoice,
|
|
ensureNonTerminalTurnExit,
|
|
persistNextInterviewIfIdle,
|
|
} from "../src/lib/rectification-agentic/v9/answer-choice.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)] : []),
|
|
],
|
|
},
|
|
},
|
|
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 } = {}) {
|
|
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: 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 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 stays 10.0.14 for the exhaustion-exit fix", () => {
|
|
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14");
|
|
});
|
|
|
|
test("USER_COLLECT_QUESTION.other remains only on the opening collect path", () => {
|
|
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",
|
|
];
|
|
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.equal(hits.some((line) => line.includes("answer-choice.ts")), false, hits.join("\n"));
|
|
assert.equal(hits.some((line) => line.includes("rectification-v9-tools.ts")), false, hits.join("\n"));
|
|
assert.ok(hits.some((line) => line.includes("method-followup.ts")), hits.join("\n"));
|
|
});
|
|
|
|
test("collect spoken stop button keeps CHOICE_STOP_LABEL; range line is status only", () => {
|
|
// 原值: 口述停止按钮和范围小字共用 CHOICE_STOP_LABEL / onStop
|
|
// 新值: 仅口述态 `.rectification-collect-stop` 使用该文案;范围行是 status 句,没有 onStop
|
|
// 原因: BUG-567 范围小字伪装成停止按钮
|
|
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("function RectificationCandidateCards"),
|
|
);
|
|
assert.match(chat, /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);
|
|
assert.match(persisted.hostNarration ?? "", new RegExp(RECTIFICATION_USER_COPY.probePoolExhaustedStop));
|
|
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
|
|
assert.match(persisted.hostNarration ?? "", /04:47|04:51|范围/);
|
|
});
|
|
|
|
test("closed ceiling translates the gate and does not persist a focus", 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);
|
|
assert.match(persisted.hostNarration ?? "", /还差|带月份的经历|日期还没对清|排不出可比较的候选/);
|
|
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
|
|
|
|
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.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
|
|
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.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 = gateAppendCalls(accounting.calls);
|
|
assert.equal(first.length, 1, JSON.stringify(first.map((item) => item.args.p_assistant_message)));
|
|
assert.equal(gateBodyCount(String(first[0]?.args.p_assistant_message)), 1);
|
|
|
|
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);
|
|
const gates = gateAppendCalls(accounting.calls);
|
|
assert.equal(gates.length, 1, JSON.stringify(gates.map((item) => item.args.p_assistant_message)));
|
|
assert.equal(gateBodyCount(String(gates[0]?.args.p_assistant_message)), 1);
|
|
});
|
|
|
|
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 = gateAppendCalls(accounting.calls);
|
|
assert.equal(gates.length, 1, JSON.stringify(gates.map((item) => item.args.p_assistant_message)));
|
|
assert.equal(gateBodyCount(String(gates[0]?.args.p_assistant_message)), 1);
|
|
assert.match(applied.narration, GATE_SENTENCE);
|
|
assert.equal(gateBodyCount(applied.narration), 1);
|
|
});
|
|
|
|
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.match(
|
|
`${String(focusCalls[0]?.args.p_question_id ?? "")} ${String(focusCalls[0]?.args.p_intent ?? "")}`,
|
|
/holdout|out_of_sample|reverse_verify/,
|
|
);
|
|
assert.equal(gateAppendCalls(accounting.calls).length, 0);
|
|
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
|
|
});
|