Files
Jyotisha/frontend/tests/rectification-unstampable-probe-20260914.test.ts
T
Jesse_ChenandClaude Opus 5.5 758fee954b feat(rectification): range delivery needs 4 dated events across 3 domains (R1, BUG-1193)
Product decision 2026-10-02 (TASK-upstream-sync5 R1): a time range is offered
only with at least 4 dated, primary-scoreable events covering 3 domains,
counted on all of them (training + reserved holdout). Was 3 training events /
2 domains in three TS copies and the Python acceptance gate while the policy
file already said 4/3.

- One definition: references/rectification_policy.v1.json
  (minConfirmationEvents / minConfirmationDomains). TS core/types MIN_DATED_*,
  rectification-decision MIN_STANDALONE_*, evidence-model MIN_ACCEPTANCE_*,
  the convergence evaluator and the post-inference trainingGateOpen all read
  it; Python decision_policy MIN_ACCEPTANCE_* alias MIN_CONFIRMATION_*.
- Python receipt counts all scoreable events / domains for event_quality and
  domain_diversity; decision policy identity v3 -> v4 (candidate UUIDs carry
  it). Candidate scores unchanged (77 v5 cases A/B identical), so the
  algorithm stays rectification-v5-matrix-scoring-10.
- Memoization golden v3 written by write_golden; v2 frozen by sha256 with a
  test that its scores equal v3 and only the receipt policy moved.
- Collect gap copy names the exact gap ("再来两件……其中至少一件不是……")
  instead of always "再来一件"; VOICE.md updated. Legacy life-events form copy
  4/3 as well.
- 30 frontend test files, 4 Python tests: fixtures extended to the same
  scenario at 4/3, or assertions changed with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 00:08:00 +08:00

592 lines
19 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年搬家",
},
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景(PERSIST_EVIDENCE 由 3 件变 4 件)。
{
id: "e-promo",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2022-03-01",
occurredTo: null,
eventKind: "promotion",
summary: "2022年3月升职",
},
];
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");
// 原值: choiceReady 或 下一问是 collect_method_evidence(定向补事接手)
// 新值: 同上,或者没有下一问、落到非空的范围旁白(门开后没有采集题可接手)
// 原因: BUG-1084,2026-09-29 D1;本题要锁的「不念无法落卡的区分题干」不变(上两行)
assert.ok(
first.choiceReady === true
|| first.followup?.intent === "collect_method_evidence"
|| (!first.followup && /现在还剩|目前范围|范围已经收到|已经从最初/.test(first.hostNarration)),
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);
});