fix(rectification): stop speaking unstampable distinguish stems (BUG-674/675)
Independent Staging Quality Gate / validate (push) Failing after 20s
Independent Staging Quality Gate / publish (push) Skipped

Distinguish followups that cannot stamp a probe now go to persistExhaustionCollect instead of repeating the card stem in the assistant body. Choice focuses without asked_turn_id hang on the last assistant turn, and persisted_question with a live choice_card renders the existing card. Representative-time inconsistency is warn-only (BUG-676 investigating).
This commit is contained in:
jesse-ux
2026-09-14 03:42:48 +08:00
parent 9375012f17
commit 69ffa07d6b
16 changed files with 887 additions and 46 deletions
@@ -89,7 +89,10 @@ test("runtime no longer composes the stem into assistant_message", () => {
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(attach, /stripQuestionSentences/);
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
// 原值: if (!focus.askedTurnId) continue
// 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue
// 原因: BUG-675 焦点没有 asked_turn_id 时卡片仍要出现在消息上
assert.match(attach, /if \(!focus\.askedTurnId\) \{/);
});
test("stripQuestionSentences drops a rewritten trailing question and its tag", () => {
@@ -77,7 +77,10 @@ test("GET turns attach question by asked_turn_id and never by timestamps", () =>
assert.match(caseProjection, /question: turn\.question/);
assert.match(caseProjection, /question_source: questionSourceFromFocusList\(listed\)/);
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
// 原值: if (!focus.askedTurnId) continue
// 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue
// 原因: BUG-675
assert.match(attach, /if \(!focus\.askedTurnId\) \{/);
assert.doesNotMatch(attach, /askedAt|createdAt/);
});
@@ -16,6 +16,7 @@ import {
rectificationQuestionRetryActive,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
persistedQuestionSurface,
interviewStopReasonFromSnapshot,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
rectificationReadonlyRangeCopy,
@@ -111,6 +112,17 @@ test("question gap: nothing is shown while busy, readonly, regenerating, or for
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle");
});
test("a persisted choice with a live card is not the spoken-only prompt surface", () => {
assert.equal(
persistedQuestionSurface({
questionKind: "choice",
hasChoiceCard: true,
questionPersisted: true,
}),
"choice_card",
);
});
test("question gap: a persisted focus question is shown instead of preparing", () => {
assert.equal(
rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionPersisted: true }),
@@ -0,0 +1,575 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyRectificationChoice, persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
expectedAnswerSchemaFor,
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { persistedQuestionSurface } from "../src/lib/rectification-surface-state.ts";
import {
inspectRepresentativeTime,
warnRepresentativeTimeInconsistency,
} from "../src/lib/rectification-agentic/core/representative-time-guard.ts";
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
CASE_ID,
FOCUS_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const STEM = "2023 年 5 月前后,有没有开始一段认真关系、分手或结婚?";
const 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 },
];
function emptyInference() {
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: "04:48-05:07",
revision: 6,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:48",
range_end: "05:07",
candidates: [
{
id: "04:53",
time: "04:53",
cluster_range: ["04:48", "05:07"] as const,
prior_score: 16,
posterior_score: 16,
probability: 0.4,
status: "active" as const,
rank: 1,
strong_conflict_count: 0,
},
{
id: "05:06",
time: "05:06",
cluster_range: ["04:48", "05:07"] as const,
prior_score: 15,
posterior_score: 15,
probability: 0.35,
status: "active" as const,
rank: 2,
strong_conflict_count: 0,
},
],
events: [],
probes: [],
answered_probes: [],
rounds: [],
entropy: 1,
representative_time: "04:53",
credible_range: ["04:48", "05:07"] as const,
};
}
const WINDOW_SCAN = {
scanned: true,
d9_lagna_count: 2,
d10_lagna_count: 2,
d4_lagna_count: 2,
d9_candidates_differ: true,
d10_candidates_differ: true,
d4_candidates_differ: true,
};
function distinguishFollowup(): MethodFollowup {
const frame = buildChoiceFrame({
method_id: "d9_relationship",
ask_theme: "dated_event",
domain: "relationship",
user_prompt_hint: "ask",
}, {
probes: [{
year: 2023,
year_label: "2023 年 5 月前后",
domain: "relationship",
event_family: "开始一段认真关系、分手或结婚",
source: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: STEM,
role: "distinguish",
information_gain: 0.4,
semantic_key: "relationship.2023.05.dasha_boundary",
style_options: OPTIONS,
}],
});
assert.ok(frame);
return {
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: frame,
source: "precision_stage",
information_gain: 0.4,
semantic_key: "relationship.2023.05.dasha_boundary",
probe_year: 2023,
year_label: "2023 年 5 月前后",
candidate_ids: ["04:48", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:48"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["04:48"] },
],
};
}
const DATED_EVIDENCE = [
{
id: "e-edu",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "2016年9月上大学",
},
{
id: "e-career",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2020年4月入职",
},
{
id: "e-rel",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "year" as const,
occurredFrom: "2018-01-01",
occurredTo: "2018-12-31",
eventKind: "relationship_start",
summary: "2018年恋爱",
},
{
id: "e-move",
status: "confirmed" as const,
domain: "relocation",
datePrecision: "year" as const,
occurredFrom: "2015-01-01",
occurredTo: "2015-12-31",
eventKind: "relocation",
summary: "2015年搬家",
},
];
const PERSIST_EVIDENCE = DATED_EVIDENCE.filter((row) => row.domain !== "relationship");
function focusAccounting() {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_compute: () => computeFixture(),
get_agentic_rectification_case_dossier: () => snakeDossier(),
set_agentic_rectification_conversation_focus: (_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 ?? null,
target_domain: args.p_target_domain ?? null,
target_kind: args.p_target_kind ?? null,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-14T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
resolve_agentic_rectification_conversation_focus: () => ({
focus: { id: FOCUS_ID, status: "resolved" },
idempotent: false,
}),
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: "p-cd",
revision: Number(args.p_expected_revision ?? 6) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
});
}
function snakeEvidence(rows: typeof DATED_EVIDENCE = DATED_EVIDENCE) {
return rows.map((row) => ({
id: row.id,
status: row.status,
domain: row.domain,
date_precision: row.datePrecision,
occurred_from: row.occurredFrom,
occurred_to: row.occurredTo,
event_kind: row.eventKind,
summary: row.summary,
}));
}
function snakeDossier(overrides: Parameters<typeof dossierFixture>[0] = {}) {
return dossierFixture({
evidence: snakeEvidence(PERSIST_EVIDENCE),
evidenceCount: PERSIST_EVIDENCE.length,
stage: "minute",
candidateRange: { start_time: "04:48", end_time: "05:07" },
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:53",
candidates: [
{ candidate_id: "04:53", rank: 1, time: "04:53", relative_support: 16 },
{ candidate_id: "05:06", rank: 2, time: "05:06", relative_support: 15 },
],
decisionReceipt: {
inference_state: emptyInference(),
prospective_probes: [],
precision_stage: { current: "d9_refine" },
discriminating_event_probes: [],
window_scan: WINDOW_SCAN,
},
}),
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
expectedAnswerSchema: {
prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?",
probe_id: "p-cd",
semantic_key: "career.2015",
scoring: true,
choice: {
prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?",
option_a: "是,大概就在那段时间",
option_b: "有类似,但年份不对或不够重大",
option_c: "没有明显发生",
option_d: "不记得 / 不确定",
options: [
{ key: "A", label: "是,大概就在那段时间", answer_class: "yes" },
{ key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" },
{ key: "C", label: "没有明显发生", answer_class: "no" },
{ key: "D", label: "不记得 / 不确定", answer_class: "unsure" },
],
},
},
}),
}),
turns: [
{
id: TURN_ID,
role: "assistant",
text: "已记录,范围收到 04:4805:07。",
status: "completed",
},
],
...overrides,
});
}
function camelDossier() {
return {
evidence: PERSIST_EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
decisionReceipt: {
inference_state: emptyInference(),
prospective_probes: [],
precision_stage: { current: "d9_refine" },
discriminating_event_probes: [],
window_scan: WINDOW_SCAN,
},
selectionAllowed: true,
candidates: [
{ time: "04:53", rank: 1, relativeSupport: 16 },
{ time: "05:06", rank: 2, relativeSupport: 15 },
],
},
case: {
acceptedTime: null,
status: "collecting_evidence",
candidateRange: { start_time: "04:48", end_time: "05:07" },
stage: "minute" as const,
},
turns: [
{ role: "assistant" as const, text: "已记录,范围收到 04:4805:07。" },
],
};
}
test("empty probe pool cannot stamp a scoring distinguish schema", () => {
const followup = distinguishFollowup();
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
stableFollowupQuestionId(followup),
{ inference_state: emptyInference() },
followup,
);
assert.equal(schema, null);
});
test("unstampable distinguish persist is invalid_choice_schema", async () => {
const accounting = focusAccounting();
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: { inference_state: emptyInference() },
followup: distinguishFollowup(),
});
assert.equal(result.status, "invalid_choice_schema");
});
test("persistNextInterviewAfterChoice does not speak an unstampable distinguish stem", async () => {
const followup = distinguishFollowup();
const stem = followup.choice_frame?.prompt ?? STEM;
const accounting = focusAccounting();
const dossier = camelDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const first = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: emptyInference() as never,
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
skipRefresh: true,
followup,
});
assert.equal(first.hostNarration.includes(stem), false, first.hostNarration);
assert.notEqual(first.followup?.intent, "distinguish_candidates");
assert.ok(
first.choiceReady === true || first.followup?.intent === "collect_method_evidence",
JSON.stringify({ choiceReady: first.choiceReady, intent: first.followup?.intent, host: first.hostNarration }),
);
const second = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier: {
...dossier,
turns: [{ role: "assistant" as const, text: stem }],
},
decisionState: emptyInference() as never,
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
skipRefresh: true,
followup,
});
assert.equal(second.hostNarration.includes(stem), false, second.hostNarration);
});
test("collect stem already in the last assistant turn is not spoken again", async () => {
const stem = "请再补充一件带年月的搬家或长期住到外地的经历。";
const followup: MethodFollowup = {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "relocation",
kind_hint: "home_change",
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
spoken_prompt: stem,
source: "method_coverage",
};
const accounting = focusAccounting();
const dossier = {
...camelDossier(),
turns: [{ role: "assistant" as const, text: `记下了。\n\n${stem}` }],
};
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const result = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: emptyInference() as never,
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
skipRefresh: true,
followup,
});
assert.equal(result.hostNarration.includes(stem), false, result.hostNarration);
});
test("applyRectificationChoice narration does not re-ask an unstampable distinguish stem", async () => {
const followup = distinguishFollowup();
const stem = followup.choice_frame?.prompt ?? STEM;
const accounting = focusAccounting();
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: "question-1",
probeId: "p-cd",
optionId: "C",
expectedRevision: 6,
});
assert.equal(applied.applied, true);
assert.equal(applied.narration.includes(stem), false, applied.narration);
const nextMeaning = JSON.stringify(applied.nextUserAction ?? {});
assert.equal(nextMeaning.includes(stem), false, nextMeaning);
});
test("active choice focus without asked_turn_id hangs on the last assistant turn", () => {
const prompt = "结过婚或订过婚吗?";
const attached = attachQuestionsToTurns(
[
{ id: TURN_ID, role: "assistant" as const, text: "已记录,范围收到 04:4805:07。" },
],
[{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:targeted:relationship",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: "targeted:relationship",
expectedAnswerSchema: {
targeted_collect: true,
prompt,
choice: {
prompt,
option_a: "有过这件事",
option_b: "没有发生过",
option_c: "记不太清楚",
option_d: "这条先跳过",
options: [
{ key: "A", label: "有过这件事", answer_class: "yes" },
{ key: "B", label: "没有发生过", answer_class: "no" },
{ key: "C", label: "记不太清楚", answer_class: "unsure" },
{ key: "D", label: "这条先跳过", answer_class: "weak_yes" },
],
},
},
status: "active",
askedAt: "2026-09-14T00:00:00.000Z",
resolvedAt: null,
askedTurnId: null,
answerOption: null,
}],
);
assert.equal(attached[0]?.question?.kind, "choice");
assert.equal(attached[0]?.question?.focus_id, FOCUS_ID);
assert.equal(attached[0]?.question?.options?.length, 4);
assert.equal(attached[0]?.question?.prompt, prompt);
});
test("persisted question with a live choice card is not the spoken-only surface", () => {
assert.equal(
persistedQuestionSurface({
questionKind: "choice",
hasChoiceCard: true,
questionPersisted: true,
}),
"choice_card",
);
assert.equal(
persistedQuestionSurface({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionPersisted: true,
}),
"spoken",
);
assert.equal(
persistedQuestionSurface({
questionKind: "choice",
hasChoiceCard: true,
questionPersisted: false,
}),
"none",
);
});
test("representative time in eliminated ids or outside the range warns once each", () => {
const lines: string[] = [];
const original = console.warn;
console.warn = (value: unknown) => {
lines.push(String(value));
};
try {
const eliminated = warnRepresentativeTimeInconsistency({
representativeTime: "05:14",
eliminatedIds: ["05:14", "05:08"],
credibleRange: ["04:48", "05:07"],
winnerId: "04:53",
scoresAfter: { "04:53": 16, "05:14": 7 },
});
assert.equal(eliminated.ok, false);
assert.equal(eliminated.inEliminated, true);
const outside = warnRepresentativeTimeInconsistency({
representativeTime: "05:14",
eliminatedIds: [],
credibleRange: ["04:48", "05:07"],
winnerId: "04:53",
scoresAfter: { "04:53": 16, "05:14": 7 },
});
assert.equal(outside.ok, false);
assert.equal(outside.outsideRange, true);
const healthy = warnRepresentativeTimeInconsistency({
representativeTime: "04:53",
eliminatedIds: ["05:14"],
credibleRange: ["04:48", "05:07"],
winnerId: "04:53",
scoresAfter: { "04:53": 16, "05:14": 7 },
});
assert.equal(healthy.ok, true);
assert.equal(inspectRepresentativeTime({
representativeTime: "04:53",
eliminatedIds: ["05:14"],
credibleRange: ["04:48", "05:07"],
}).ok, true);
} finally {
console.warn = original;
}
assert.equal(lines.filter((line) => line.includes("rectification_representative_time_inconsistent")).length, 2);
assert.match(lines[0] ?? "", /"representative_time":"05:14"/);
assert.doesNotMatch(lines.join("\n"), /birth|email|jwt/i);
});