Files
Jyotisha/frontend/tests/rectification-unstampable-probe-20260914.test.ts
T
jesse-ux 69ffa07d6b
Independent Staging Quality Gate / validate (push) Failing after 20s
Independent Staging Quality Gate / publish (push) Skipped
fix(rectification): stop speaking unstampable distinguish stems (BUG-674/675)
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).
2026-09-14 03:42:48 +08:00

576 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});