Files
Jyotisha/frontend/tests/rectification-adopt-flow-fix-20260903.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

436 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 { readFileSync, readdirSync } from "node:fs";
import test from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { publicCanAdopt } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
buildMethodFollowupPlan,
exhaustionSpokenCollectFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { ensureNonTerminalTurnExit } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const STYLE_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 },
];
const COLLECT_EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-exit",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
},
{
id: "e-rel-end",
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
},
{
id: "e-rel-start",
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
},
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
{
id: "e-relocation",
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2018-03-01",
occurredTo: null,
eventKind: "relocation",
},
] as const;
const OPENING_SKIPPED = {
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: 开场跳过记录仍按 other 身份,避免把 unknown 写入账本
questionId: "collect:other:collect_method_evidence",
target_domain: "other",
status: "skipped",
};
function collectDecisionDossier(declinedTopics: ReadonlyArray<Record<string, unknown>> = []): DecisionDossier {
const state = buildInferenceState({
range_start: "04:53",
range_end: "05:06",
candidates: [
{ id: "05:06", time: "05:06", relative_support: 14 },
{ id: "04:53", time: "04:53", relative_support: 13 },
{ id: "05:03", time: "05:03", relative_support: 13 },
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
// R1(BUG-1193):补第 3 域,保持原场景。
{ id: "e-relocation", domain: "relocation", year: 2018, precision: "month" },
],
probes: [],
});
return {
evidence: COLLECT_EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: declinedTopics,
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(COLLECT_EVIDENCE as never),
candidates: state.candidates.map((candidate) => ({
candidateId: candidate.id,
time: candidate.time,
rank: candidate.rank,
relativeSupport: Math.round(candidate.posterior_score),
})),
representativeTime: state.representative_time,
decisionReceipt: {
acceptance_allowed: true,
accept_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
},
},
case: { acceptedTime: null },
};
}
function rpcDossier(decision: DecisionDossier, activeFocus: Record<string, unknown> | null = null) {
const evidence = decision.evidence.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? "event",
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-03T00:00:00.000Z",
}));
return dossierFixture({
evidence,
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: evidenceLedgerFingerprint(evidence.map((item) => ({
id: item.id,
eventKind: item.event_kind,
domain: item.domain,
occurredFrom: item.occurred_from,
occurredTo: item.occurred_to,
datePrecision: item.date_precision,
summary: item.summary,
status: item.status,
})) as never),
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: activeFocus,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-03T00:00:00.000Z",
},
});
}
function createdFocusFromArgs(args: Record<string, unknown>) {
return {
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,
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-09-03T00:00:00.000Z",
resolved_at: null,
};
}
test("collect-phase nonterminal exit persists a spoken collect when public can_adopt is closed", async () => {
const decisionDossier = collectDecisionDossier();
const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" });
// 原值: adopt_representative / offer_provisional_range / publicCanAdopt=true(BUG-648)
// 新值: collect_evidence / ask_fact_collection / publicCanAdopt=false
// 原因: D1 门槛(13 分钟、14:13:13 并列)+ D6「线问完才交付」,
// 本用例回到 BUG-648 之前的采集出口(BUG-751)
// 原值(2): collect_evidence / ask_fact_collection / publicCanAdopt=false
// 新值(2): adopt_representative / offer_provisional_range / publicCanAdopt=true
// 原因(2): 门开后定向线不再挡卡(BUG-1084,2026-09-29 D1);题名描述的「采集出口」
// 只剩训练门关的情形,由 rectification-futile-collect-stop-20260929.test.ts 覆盖
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(decision.nextAction, "offer_provisional_range");
assert.equal(publicCanAdopt(decision), true);
const store: { focus: ReturnType<typeof createdFocusFromArgs> | null } = { focus: null };
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args.map((item) => String(item)).join(" "));
};
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decisionDossier, store.focus),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
store.focus = createdFocusFromArgs(args);
return { focus: store.focus, idempotent: false };
},
});
try {
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
// 原值: persisted=false / 无 repair 警告 / 不写 focus(S3 交付轮)
// 新值: persisted=true / 写 repair 警告 / 写一条采集 focus
// 原因: 同上——本轮回到采集,非终结轮出口必须补一个问题(BUG-751)
// 原值(2): persisted=true / 写 repair 警告 / 写一条采集 focus
// 新值(2): persisted=false / 无 repair 警告 / 不写 focus(交付轮,同下一条用例)
// 原因(2): BUG-1084,2026-09-29 D1
assert.equal(repaired.persisted, false);
} finally {
console.warn = originalWarn;
}
assert.equal(warnings.some((item) => item.includes("rectification_nonterminal_exit_repaired")), false);
assert.equal(store.focus, null);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
test("adopt_representative nonterminal exit stays satisfied without a new question", async () => {
const decisionDossier = collectDecisionDossier([
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
]);
const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" });
// 旧:家人拒答即可 adopt_representative。新:带年份采集未完不得出牌;本夹具把剩余域拒答后才满足。
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(publicCanAdopt(decision), true);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decisionDossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: () => {
throw new Error("adoptable offer must not persist another question");
},
});
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(repaired.persisted, false);
assert.equal(repaired.hostNarration, null);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
test("adopted time is shown on the card row, not a composer status bar", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// 原值: 「改选」是状态条按钮,滚动到出卡消息
// 新值: 点其他候选行即改选;无 rectification-adopt-status
// 原因: BUG-595 决策 3
assert.doesNotMatch(chat, /rectification-adopt-status__link/);
assert.doesNotMatch(chat, /offerSectionRef/);
assert.match(chat, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/);
assert.doesNotMatch(styles, /\.rectification-adopt-status__link:focus-visible/);
});
test("skipped opening collect other still yields education without collect_retry", () => {
const family = {
id: "e-family",
status: "confirmed",
domain: "family",
datePrecision: "year",
occurredFrom: "2021-01-01",
occurredTo: null,
};
const exhausted = exhaustionSpokenCollectFollowup({
evidence: [family],
declinedTopics: [OPENING_SKIPPED],
});
// 原值: 跳过开场 collect:other 后按轮转到 education
// 新值: 开场 other 过期后邀请可替换,domain=other / collect:invite:more
// 原因: 用户先说完再追问;跳过开场 other 不得当成拒答全部 other(BUG-648)
assert.equal(exhausted?.domain, "other");
assert.equal(exhausted?.collection_key, "collect:invite:more");
assert.equal(exhausted?.kind_hint, "invite_more");
assert.notEqual(exhausted?.collect_retry, true);
const plan = buildMethodFollowupPlan({
evidence: COLLECT_EVIDENCE,
closedCollectFocuses: [OPENING_SKIPPED],
sessionOutcome: "collect_evidence",
});
// 原值: plan.next_followup = null(训练门开后不再轮转)
// 新值: 学业线的定向存在性题 collect:targeted:education
// 原因: D5——七条线按 COLLECT_KIND_ORDER 全部轮到,不再按分盘层剔除;
// 题名说的「yields education」正是这一条(BUG-751)
// 原值(2): 学业线定向存在性题 collect:targeted:education
// 新值(2): next_followup === null(回到 BUG-751 之前「训练门开后不再轮转」)
// 原因(2): BUG-1084,2026-09-29 D1
assert.equal(plan.next_followup, null);
const educationPlan = buildMethodFollowupPlan({
evidence: [...COLLECT_EVIDENCE, family],
closedCollectFocuses: [
OPENING_SKIPPED,
{
questionId: "collect:occupation:collect_method_evidence",
target_domain: "occupation",
status: "skipped",
},
],
sessionOutcome: "collect_evidence",
precisionStage: "d5_refine",
});
// 原值: horary(leftover 带年份采集关闭后方法层轮到占问)
// 新值: education
// 原因: D5——定向七条线排在方法层之前,学业线仍未覆盖(BUG-751)
// 原值(2): education(定向学业线)
// 新值(2): horary(回到 BUG-751 之前的值:定向线关闭后方法层轮到占问)
// 原因(2): 门开后定向七条线不再问(BUG-1084,2026-09-29 D1)
assert.equal(educationPlan.next_followup?.domain, "horary");
});
test("consult handoff button and startConsultationAfterRectification are gone", () => {
const srcRoot = new URL("../src/", import.meta.url);
const files = readdirSync(srcRoot, { recursive: true, encoding: "utf8" })
.filter((entry) => /\.(ts|tsx|css)$/.test(entry));
const banned = [
"startConsultationAfterRectification",
"onStartConsultation",
"请用刚才采用的代表性出生时间看盘",
"rectification-consult-handoff",
];
for (const file of files) {
const source = readFileSync(new URL(file.split("\\").join("/"), srcRoot), "utf8");
for (const token of banned) {
assert.equal(source.includes(token), false, `${file} still contains ${token}`);
}
}
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
// 原值: 状态条「之后新建对话即按此时间排盘」
// 新值: 无状态条;采用后旁白走 postAdoptVerifyDoneCopy;开场仍可预告结束后新建对话
// 原因: BUG-595 决策 3
assert.match(chat, /postAdoptVerifyDoneCopy/);
assert.doesNotMatch(chat, /rectification-adopt-status/);
assert.match(chat, /结束后新建对话,按采用的时间再问/);
});
test("keepAcceptedFocus omits probe_year when schema and probes have none", () => {
const followup = readFileSync(
new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(followup, /\/19\\d\{2\}\|20\\d\{2\}\//);
const plan = buildMethodFollowupPlan({
evidence: COLLECT_EVIDENCE,
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [],
activeFocus: {
id: FOCUS_ID,
questionId: "reverse_verify:education_style:score",
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
choice: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: STYLE_OPTIONS[0].label,
option_b: STYLE_OPTIONS[1].label,
option_c: STYLE_OPTIONS[2].label,
option_d: STYLE_OPTIONS[3].label,
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
},
},
});
assert.equal(plan.next_followup?.intent, "reverse_verify");
assert.equal(plan.next_followup?.probe_year, undefined);
});