fix(rectification): prevent silent collect focus stalls
Independent Staging Quality Gate / validate (push) Successful in 20m38s
Independent Staging Quality Gate / publish (push) Successful in 24m14s

This commit is contained in:
Jesse_Chen
2026-08-30 23:07:17 +08:00
parent 0ae3e2d796
commit 79bfa73da3
5 changed files with 468 additions and 20 deletions
@@ -14,7 +14,7 @@ import {
userVisibleChoiceLine,
} from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts";
import { persistServerOwnedFocus, stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
spokenCollectFallbackFollowup,
spokenFollowupForUser,
@@ -538,14 +538,12 @@ test("clicking A applies the choice without invoking a language model", async ()
start: "2015-01-01",
end: "2015-12-31",
});
assert.equal(applied.narration, composeChoiceNarration({
optionId: "A",
scoring: true,
appliedInference: true,
}));
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /当前可信区间/);
assert.match(applied.narration, /家里有没有结婚、添丁或住院/);
const fns = accounting.calls.map((call) => call.fn);
assert.ok(fns.includes("apply_agentic_rectification_choice_action"));
assert.equal(fns.includes("append_agentic_rectification_turn"), false);
assert.equal(fns.includes("append_agentic_rectification_turn"), true);
assert.equal(fns.includes("record_agentic_rectification_evidence_batch"), false);
assert.equal(fns.some((fn) => fn === "create_agentic_rectification_run_attempt" || fn.includes("stream")), false);
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
@@ -2,14 +2,15 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { applyAnswerToState, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import { applyAnswerToState, buildInferenceState, candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
rectificationFollowupCatalog,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { buildMethodFollowupPlan, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
@@ -19,6 +20,7 @@ import {
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import {
applyCollectFocusDenial,
persistNextInterviewAfterChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
@@ -93,6 +95,19 @@ const EVIDENCE = [
},
] as const;
const LIVE_CASE_EVIDENCE = [
...EVIDENCE,
{
id: "e-education",
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
] as const;
const FAMILY_2021_COLLECT = {
year: 2021,
year_label: "2021 年前后",
@@ -200,6 +215,34 @@ const CAREER_2023: ConflictProbe = {
style_options: EXISTENCE_OPTIONS,
};
const CAREER_2023_ACTIVATION: ConflictProbe = {
id: "probe:career.2023.dasha_activation",
semantic_key: "career.2023.dasha_activation",
candidate_split_hash: "career.2023.activation",
domain: "career",
year: 2023,
question: "2023 年前后大运有没有启动?",
candidate_ids: CANDIDATES.map((candidate) => candidate.time),
expected_outcomes: [
{
answer_class: "yes",
supports: ["05:15"],
conflicts: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
},
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{
answer_class: "no",
supports: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
conflicts: ["05:15"],
},
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.56,
source: "dasha_activation",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
const RELOCATION_2015: ConflictProbe = {
id: "probe:relocation.2015.dasha_boundary",
semantic_key: "relocation.2015.dasha_boundary",
@@ -369,21 +412,30 @@ function planFrom(
function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unknown> | null) {
return dossierFixture({
evidence: EVIDENCE.map((item) => ({
evidence: decision.evidence.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind,
event_kind: item.eventKind ?? "event",
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: `${item.occurredFrom} ${item.eventKind}`,
summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-08-29T00:00:00.000Z",
})),
latestResult: candidateSnapshotFixture({
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: item.tiedMinuteCount ?? 1,
})),
representativeTime: decision.latestResult?.representativeTime ?? null,
evidenceLedgerFingerprint: decision.latestResult?.evidenceLedgerFingerprint,
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
@@ -638,6 +690,294 @@ test("free-text turns persist the next followup so current_question is not null"
assert.ok(question?.kind === "collect_spoken" || question?.kind === "choice");
});
function occupationCollectFocus(id = FOCUS_ID) {
return {
id,
case_id: CASE_ID,
question_id: "collect:occupation:collect_method_evidence",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "occupation",
target_kind: null,
expected_answer_schema: {
collect: true,
prompt: "你长期做什么工作?",
},
status: "active",
asked_at: "2026-08-30T00:00:00.000Z",
resolved_at: null,
};
}
function createdFocusFromArgs(args: Record<string, unknown>, id = FOCUS_ID) {
return {
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-08-30T00:00:00.000Z",
resolved_at: null,
};
}
function occupationDossier() {
return revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
}
function liveCaseState(): InferenceState {
const times = CANDIDATES.map((candidate) => candidate.time);
const answered = [D9, D10, RELOCATION_2015, D24, D7];
const active = [
{ time: "05:00", score: 18, probability: 0.4 },
{ time: "05:07", score: 16, probability: 0.33 },
{ time: "04:53", score: 14, probability: 0.27 },
] as const;
const eliminated = times.filter((time) => !active.some((candidate) => candidate.time === time));
const orderedTimes = [...active.map((candidate) => candidate.time), ...eliminated];
return {
algorithm_version: revision5State().algorithm_version,
candidate_set_id: candidateSetId("04:47", "05:15", orderedTimes),
revision: 6,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:47",
range_end: "05:15",
candidates: [
...active.map((candidate, index) => ({
id: candidate.time,
time: candidate.time,
cluster_range: [candidate.time, candidate.time] as const,
prior_score: candidate.score,
posterior_score: candidate.score,
probability: candidate.probability,
status: "active",
rank: index + 1,
strong_conflict_count: 0,
} as const)),
...eliminated.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: 4 - index,
posterior_score: 4 - index,
probability: 0,
status: "eliminated" as const,
rank: active.length + index + 1,
strong_conflict_count: 3,
})),
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "training" },
{ id: "e-education", domain: "education", year: 2016, precision: "year", usage: "training" },
],
probes: [...answered, CAREER_2023_ACTIVATION],
answered_probes: answered.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: probe === D24 ? "unsure" : "no",
classified_from: "choice",
})),
rounds: [],
last_inference_round: null,
entropy: 1.08,
representative_time: "05:00",
credible_range: ["04:53", "05:07"],
holdout_passed: null,
};
}
function liveCaseDossier(): DecisionDossier {
const state = liveCaseState();
return {
evidence: LIVE_CASE_EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [{ target_domain: "family", status: "declined" }],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(LIVE_CASE_EVIDENCE as never),
candidates: state.candidates.map((candidate, index) => ({
candidateId: `77777777-7777-4777-8777-${String(index + 1).padStart(12, "0")}`,
time: candidate.time,
rank: candidate.rank,
relativeSupport: Math.round(candidate.posterior_score),
})),
representativeTime: state.representative_time,
decisionReceipt: {
accept_allowed: true,
propose_allowed: true,
selection_allowed: true,
inference_state: state,
},
},
case: { acceptedTime: null },
};
}
async function persistOccupationAfterChoice(accounting: ReturnType<typeof fakeAccounting>["client"]) {
const dossier = occupationDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
return persistNextInterviewAfterChoice({
accounting,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: revision5State(),
nextAction: publicNextAction(decision),
birthDate: "1997-08-08",
});
}
test("duplicate collect focus reloads the active question instead of returning null narration", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), occupationCollectFocus()),
set_agentic_rectification_conversation_focus: () => {
throw new Error("focus_idempotency_conflict");
},
});
const persisted = await persistOccupationAfterChoice(accounting.client);
assert.equal(persisted.hostNarration, "你长期做什么工作?");
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: "collect:occupation:collect_method_evidence",
intent: "collect_method_evidence",
targetDomain: "occupation",
expectedAnswerSchema: { collect: true, prompt: "你长期做什么工作?" },
});
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
assert.equal(accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, 1);
});
test("skipped collect focus reloads once and retries persistence", async () => {
let writes = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
if (writes === 1) throw new Error("temporary focus write failure");
return { focus: createdFocusFromArgs(args), idempotent: false };
},
});
const persisted = await persistOccupationAfterChoice(accounting.client);
assert.equal(persisted.hostNarration, "你长期做什么工作?");
assert.equal(writes, 2);
const write = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").at(-1);
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(write?.args.p_question_id ?? ""),
intent: String(write?.args.p_intent ?? ""),
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
});
test("nonterminal turn exit deterministically restores a spoken question", async () => {
const answerChoiceModule = await import("../src/lib/rectification-agentic/v9/answer-choice.ts") as Record<string, unknown>;
const ensureExit = answerChoiceModule.ensureNonTerminalTurnExit as undefined | ((input: {
accounting: ReturnType<typeof fakeAccounting>["client"];
userId: string;
caseId: string;
adoptCarrierReady: boolean;
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
assert.equal(typeof ensureExit, "function");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
set_agentic_rectification_conversation_focus: (_fn, args) => {
return { focus: createdFocusFromArgs(args), idempotent: false };
},
});
const repaired = await ensureExit!({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
adoptCarrierReady: false,
});
assert.equal(repaired.persisted, true);
assert.match(repaired.hostNarration ?? "", /当前可信区间/);
assert.match(repaired.hostNarration ?? "", /05:07/);
assert.match(repaired.hostNarration ?? "", /代表分钟/);
const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(write?.args.p_question_id ?? ""),
intent: String(write?.args.p_intent ?? ""),
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.equal(currentQuestion?.kind, "collect_spoken");
assert.ok(currentQuestion?.prompt);
});
test("live five-evidence case ends on the occupation spoken collect", async () => {
const dossier = liveCaseDossier();
const state = liveCaseState();
assert.equal(dossier.evidence.length, 5);
assert.equal(state.answered_probes.length, 5);
assert.deepEqual(state.candidates.filter((candidate) => candidate.status === "active").map((candidate) => candidate.time), [
"05:00", "05:07", "04:53",
]);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.probe, null);
assert.equal(decision.nextAction, "offer_provisional_range");
assert.equal(decision.canAdopt, false);
assert.ok(decision.droppedProbes.some((probe) => (
probe.semantic_key === CAREER_2023_ACTIVATION.semantic_key
&& probe.reason === "no_split_among_active"
)), JSON.stringify(decision.droppedProbes));
const plan = planFrom(dossier);
assert.equal(plan.next_followup?.method_id, "occupation");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(plan.next_followup), "你长期做什么工作?");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: createdFocusFromArgs(args),
idempotent: false,
}),
});
const persisted = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: state,
nextAction: publicNextAction(decision),
birthDate: "1997-08-08",
});
assert.match(persisted.hostNarration, /你长期做什么工作?/);
const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(write?.args.p_question_id ?? ""),
intent: String(write?.args.p_intent ?? ""),
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
});
test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => {
const state = revision5State([DATED_RELOCATION_2016]);
const plan = planFrom(revision5Dossier(state));