Files
Jyotisha/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

424 lines
17 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",
},
] 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" },
],
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);
});