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).
576 lines
18 KiB
TypeScript
576 lines
18 KiB
TypeScript
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:48–05: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:48–05: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:48–05: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);
|
||
});
|