Files
Jyotisha/frontend/tests/rectification-adopt-flow-20260902.test.ts
T
Jesse_ChenandClaude Opus 5.5 9d302e515c feat(rectification): the server writes the evidence recap — 「记下了 N 件事」 with the ledger list collapsed under it (BUG-1136)
- Evidence turns whose batch accepted items: the body is the server recap
  built from those items (two or fewer listed inline, more as a count);
  the model no longer restates them (both prompt sets updated).
- The case snapshot carries each assistant turn's recorded evidence from
  the ledger (rejected/superseded excluded); the message shows it in a
  collapsed <details> list when more than two.
- Chinese date labels read straight into the event phrase (2016年入学);
  ISO labels keep their space.
- Assertions updated with original/new/reason notes.

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

571 lines
22 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 { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
publicCanAdopt,
publicDecisionFields,
decideRectification,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
parseRectificationCandidateResult,
} from "../src/lib/rectification-candidate-result.ts";
import {
attachOfferResultToTurns,
attachQuestionsToTurns,
parseTurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
import {
buildMethodFollowupPlan,
collectQuestionDomain,
OPENING_COLLECT_DOMAIN,
parsePersistedFollowupQuestionId,
projectRectificationChoiceCard,
spokenCollectFallbackFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
CHOICE_SKIP_QUESTION_LABEL,
CHOICE_STOP_LABEL,
parseRectificationChoiceCard,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { hasPersistedVedastroValidation } from "../src/lib/rectification-agentic/v9/confirmation-gate.ts";
import {
parseToolActivityDetail,
loadV9TurnReceipt,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS,
RECTIFICATION_SLOW_STEP_MS,
RECTIFICATION_TIMEOUT_WARN_BEFORE_MS,
rectificationLiveProgressLabel,
} from "../src/lib/rectification-activity-labels.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, FOCUS_ID, TURN_ID, USER_ID, fakeAccounting } from "./rectification-v9-test-support.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const preAdopt = JSON.parse(readFileSync(
new URL("./fixtures/rectification-case-bf7a8de6-pre-adopt.json", import.meta.url),
"utf8",
)) as Record<string, unknown>;
const postAdopt = JSON.parse(readFileSync(
new URL("./fixtures/rectification-case-bf7a8de6-post-adopt.json", import.meta.url),
"utf8",
)) as Record<string, unknown>;
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 EDUCATION_VERIFY = {
year: 2016,
year_label: "2016 年前后",
domain: "education" as const,
event_family: "升学结果或学习环境出现明显变化",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "2016 年前后升学结果或学习环境出现明显变化",
role: "reverse_verify" as const,
information_gain: 0.4,
semantic_key: "education.2016",
style_options: STYLE_OPTIONS,
};
const VERIFY_COPY = {
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,
})),
} as const;
function reverseVerifySchema() {
return {
prompt: VERIFY_COPY.prompt,
probe_year: 2016,
choice: VERIFY_COPY,
};
}
function collectWithInternalAdopt() {
return decideRectification({
methodCoverageAll: false,
datedMethodCollectOpen: true,
datedEventCount: 4,
datedDomainCount: 3,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: [
{ time: "05:06", score: 14 },
{ time: "04:53", score: 13 },
{ time: "05:03", score: 13 },
],
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
confirmationAllowed: false,
inferenceCredibleRange: ["04:53", "05:06"],
});
}
test("public can_adopt is closed during collect even when internal capability is open", () => {
const decision = collectWithInternalAdopt();
// 原值: sessionOutcome=collect_evidence,公开 can_adopt=false
// 新值: 训练门开、无带年月探针 → adopt_representative,公开 can_adopt=true
// 原因: BUG-651 带年月池空即交付,剩余采集不得挡住结果卡
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(decision.canAdopt, true);
const fields = publicDecisionFields(decision);
assert.equal(fields.can_adopt, true);
assert.equal(fields.selection_allowed, decision.selectionAllowed);
assert.equal(publicCanAdopt(decision), true);
});
test("public can_adopt stays open only for adopt outcomes", () => {
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "adopt_representative" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "awaiting_confirmation" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "validated_range" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "discriminate_candidates" }), false);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "validate_holdout" }), false);
assert.equal(publicCanAdopt({ canAdopt: false, sessionOutcome: "adopt_representative" }), false);
});
test("accept route 409s before the adopt RPC when public can_adopt is false", () => {
const accept = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
"utf8",
);
const gate = accept.indexOf("overlaid.can_adopt !== true");
const rpc = accept.indexOf("accept_agentic_rectification_candidate_for_case_v2");
assert.ok(gate >= 0, "accept must gate on overlaid.can_adopt");
assert.ok(rpc > gate, "RPC must not run before the session_outcome gate");
assert.match(accept, /adoption_not_allowed/);
assert.match(accept, /session_outcome: fields\.session_outcome/);
});
test("pre-adopt snapshot does not show adopt cards and does show a readonly range", () => {
const result = parseRectificationCandidateResult(preAdopt);
assert.ok(result);
assert.equal(result.sessionOutcome, "collect_evidence");
assert.equal(result.canAdopt, true);
assert.equal(canShowRectificationSelectionCards(result), false);
assert.equal(canShowRectificationReadonlyRange(result), true);
assert.deepEqual(result.credibleRange, ["04:53", "05:06"]);
});
test("post-adopt snapshot keeps the offer on the pre-verify message", () => {
const result = parseRectificationCandidateResult(postAdopt);
assert.ok(result);
assert.equal(result.selectedTime, "05:06");
const turns = Array.isArray(postAdopt.turns) ? postAdopt.turns as Array<{
id: string;
role: string;
question?: unknown;
}> : [];
const withQuestions = turns.map((turn) => ({
...turn,
text: null,
question: parseTurnQuestion(turn.question),
}));
const attached = attachOfferResultToTurns(withQuestions, {
resultId: result.resultId,
canAdopt: true,
acceptedTime: "05:06",
});
assert.equal(attached[0]?.offer_result_id, result.resultId);
assert.equal(attached[1]?.offer_result_id, null);
assert.equal(attached[1]?.question?.kind, "reverse_verify");
});
test("accepted reverse_verify keep reuses the persisted question id", () => {
const questionId = "reverse_verify:education_style:score";
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
const followup = plan.next_followup;
assert.equal(followup?.intent, "reverse_verify");
assert.equal(followup?.method_id, "reverse_verify");
assert.equal(followup?.ask_theme, "education_style");
assert.equal(stableFollowupQuestionId(followup!), questionId);
assert.match(followup?.user_prompt_hint ?? "", /当前排盘已采用该时间/);
const parsed = parsePersistedFollowupQuestionId(questionId);
assert.deepEqual(parsed, { method_id: "reverse_verify", ask_theme: "education_style", scoring: true });
const card = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.ok(card);
assert.equal(card.focus_id, FOCUS_ID);
assert.equal(card.question_id, questionId);
assert.equal(card.stop_label, CHOICE_SKIP_QUESTION_LABEL);
assert.ok(parseRectificationChoiceCard(card));
const again = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.equal(again?.question_id, card.question_id);
assert.equal(again?.focus_id, card.focus_id);
});
test("distinguish keep still uses probe: semantic_key ids", () => {
const questionId = "probe:education.2016";
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2020-01-01",
occurredTo: null,
}],
sessionOutcome: "discriminate_candidates",
eventProbes: [{
...EDUCATION_VERIFY,
role: "distinguish",
semantic_key: "education.2016",
candidate_split_hash: "education:2016:05:06|04:53",
candidate_ids: ["05:06", "04:53"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:06"], conflicts: ["04:53"] },
{ answer_class: "no", supports: ["04:53"], conflicts: ["05:06"] },
],
}],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "distinguish_candidates",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: {
semantic_key: "education.2016",
candidate_split_hash: "education:2016:05:06|04:53",
probe_id: "education.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,
},
},
},
});
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
assert.equal(plan.next_followup?.semantic_key, "education.2016");
assert.equal(stableFollowupQuestionId(plan.next_followup!), questionId);
assert.equal(parsePersistedFollowupQuestionId(questionId), null);
});
test("post-adopt live question identity matches the last reverse_verify turn", () => {
const last = Array.isArray(postAdopt.turns)
? postAdopt.turns[postAdopt.turns.length - 1] as { question?: unknown }
: null;
const question = parseTurnQuestion(last?.question);
assert.equal(question?.kind, "reverse_verify");
assert.equal(question?.options?.length, 4);
const card = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: question?.focus_id,
questionId: question?.question_id,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.equal(card?.focus_id, question?.focus_id);
assert.equal(card?.question_id, question?.question_id);
});
test("opening collect domain is other rather than unknown", () => {
// 旧 education → 新 other → 保留 不得为 unknown、stable id 不含 unknown
assert.equal(OPENING_COLLECT_DOMAIN, "other");
assert.equal(collectQuestionDomain(null), "other");
assert.equal(collectQuestionDomain("unknown"), "other");
const fallback = spokenCollectFallbackFollowup({
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
});
assert.equal(fallback.domain, "other");
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: 开场 domain 仍是 other,稳定 id 不得含 unknown
assert.equal(stableFollowupQuestionId(fallback), "collect:other:collect_method_evidence");
assert.doesNotMatch(stableFollowupQuestionId(fallback), /unknown/);
});
test("skipped collect questions stay attached to their asked turn", () => {
const attached = attachQuestionsToTurns(
[{ id: TURN_ID, role: "assistant", text: "记下了。", status: "completed" }],
[{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:family:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "family",
targetKind: null,
expectedAnswerSchema: {
collect: true,
prompt: "2021 年前后,家里有没有人住院、搬家或职责明显变化?",
},
status: "skipped",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: "2026-09-02T00:03:00.000Z",
askedTurnId: TURN_ID,
answerOption: "stop",
}],
);
assert.equal(attached[0]?.question?.status, "skipped");
assert.equal(attached[0]?.question?.kind, "collect_spoken");
});
test("chat owns adopt cards on the offering message and drops the list-end handoff", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
assert.match(chat, /canShowRectificationSelectionCards/);
assert.match(chat, /canShowRectificationReadonlyRange/);
assert.match(chat, /candidateOffer/);
assert.match(chat, /!candidateResult\?\.selectedTime/);
// 原值: 采用后状态条 rectification-adopt-status + 改选
// 新值: 采用状态只在卡片行内;composer-wrap 无状态条
// 原因: BUG-595 决策 3
assert.doesNotMatch(chat, /rectification-adopt-status/);
assert.doesNotMatch(chat, /用这个时间看盘/);
// 原值: 容器源码含 CHOICE_STOP_LABEL、已跳过(已采用)、scoring: question.kind。
// 新值: 这些 token 随 choiceCardFromQuestion / afterAnswer 进了行组件。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /CHOICE_STOP_LABEL/);
// 原值: 口述采集输入框上方必须有与点选卡同款停止按钮
// 新值: 停止只留选择题卡与生成中「停止回答」
// 原因: BUG-595 决策 2
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
assert.doesNotMatch(wrap, /rectification-collect-stop/);
assert.match(
readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8"),
/stop_label !== CHOICE_STOP_LABEL/,
);
assert.match(messageEntry, /已跳过(已采用/);
assert.match(chat, /rectificationLiveProgressLabel/);
assert.doesNotMatch(chat, /rectification-consult-handoff/);
assert.match(chat, /kind === "reverse_verify"/);
assert.match(messageEntry, /scoring: question\.kind !== "reverse_verify"/);
});
test("choice cards use 这题跳过 for reverse_verify and 先这样 for collect", () => {
assert.equal(CHOICE_SKIP_QUESTION_LABEL, "这题跳过");
assert.equal(CHOICE_STOP_LABEL, "先这样,先看当前范围");
const choiceCard = readFileSync(
new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url),
"utf8",
);
assert.match(choiceCard, /CHOICE_SKIP_QUESTION_LABEL/);
const prompt = readFileSync(
new URL("../src/mastra/agentic-rectification.ts", import.meta.url),
"utf8",
);
assert.match(prompt, /case\.accepted_time 非空时,正文第一句要说明已按该时间采用/);
// 原值: /必须先用一句话承接用户本轮给出的事实(年份\+事件)/(模型写承接句)。
// 新值: /证据轮的复述由服务器写:「记下了 N 件事」/(承接由服务器按入账条目写)。
// 原因: BUG-1136(TASK-rectification-message-cleanup-20261001 D2)——复述改由服务器写,模型不再逐条复述;采用后首句说明规则不变。
assert.match(prompt, /证据轮的复述由服务器写:「记下了 N 件事」/);
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
assert.match(voice, /不要预告选项/);
});
test("set-focus failures persist the spoken-prompt reason on the receipt", () => {
const tools = readFileSync(
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
"utf8",
);
const persist = readFileSync(
new URL("../src/lib/rectification-agentic/v9/score-persist.ts", import.meta.url),
"utf8",
);
assert.match(tools, /resultFingerprint: JSON\.stringify\(\{ reason: spoken\.reason \}\)/);
assert.match(persist, /readVedastroMinuteSensitiveStatus\(decisionReceipt\) !== "passed"/);
const detail = parseToolActivityDetail({
error: "invalid_spoken_prompt",
result_fingerprint: JSON.stringify({ reason: "probe_mismatch" }),
});
assert.equal(detail?.reason, "probe_mismatch");
assert.equal(detail?.error, "invalid_spoken_prompt");
});
test("elapsed_ms serializes from the turn receipt and 45s copy follows the live tool", () => {
const migration = readFileSync(
new URL("../supabase/migrations/20260902020000_rectification_tool_activity_timing.sql", import.meta.url),
"utf8",
);
assert.match(migration, /'elapsed_ms'/);
assert.match(migration, /'started_at'/);
assert.equal(existsSync(fileURLToPath(new URL("../db/migrations/20260902020000_rectification_tool_activity_timing.sql", import.meta.url))), false);
assert.equal(RECTIFICATION_SLOW_STEP_MS, 45_000);
assert.equal(
rectificationLiveProgressLabel({
tool: "rectification-compare-candidates",
baseLabel: "正在重新对照盘面…",
stepStartedAt: 0,
runStartedAt: 0,
now: RECTIFICATION_SLOW_STEP_MS,
}),
// 原值: "引擎在比较候选,可能需要一两分钟" / 新值: "还在重新对照盘面,可能需要一两分钟" / 原因: BUG-1050 步骤句改大白话(慢步骤句从进行中句派生)
"还在重新对照盘面,可能需要一两分钟",
);
assert.equal(
rectificationLiveProgressLabel({
tool: "rectification-set-focus",
baseLabel: "正在准备下一个问题…",
stepStartedAt: 0,
runStartedAt: 0,
now: RECTIFICATION_SLOW_STEP_MS,
}),
"还在准备下一个问题,可能需要一两分钟",
);
assert.equal(
rectificationLiveProgressLabel({
tool: "rectification-read-case",
// 原值: "正在读取校正记录…" / 新值: "正在看你的资料…" / 原因: BUG-1050(本断言只看超时句,不看这行)
baseLabel: "正在看你的资料…",
stepStartedAt: 0,
runStartedAt: 0,
now: RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS,
}),
"即将超时,会自动重试或提示",
);
});
test("read_only compare reuses a passed vedastro validation and still names the helper", () => {
assert.equal(hasPersistedVedastroValidation({
vedastro_event_validation: { status: "failed" },
}), true);
assert.equal(hasPersistedVedastroValidation({
gates: { exact_confirmation: { vedastro_event_validation: { status: "passed" } } },
}), true);
assert.equal(hasPersistedVedastroValidation({}), false);
});
test("GET turn receipts expose elapsed_ms and set-focus failure detail", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_receipt: () => ({
turn_id: TURN_ID,
attempt_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
status: "completed",
skill_name: "jyotish-birth-time-rectification",
skill_version: "9.0.0",
engine_version: null,
phases: [],
tool_activities: [
{
tool: "rectification-compare-candidates",
status: "completed",
methods: ["d1-rashi"],
started_at: "2026-09-02T13:27:10.000Z",
elapsed_ms: 120000,
result_fingerprint: JSON.stringify({
hash: "abc",
engine_compare_ms: 80000,
vedastro_validate_ms: 30000,
persist_ms: 10000,
}),
},
{
tool: "rectification-set-focus",
status: "failed",
methods: [],
started_at: "2026-09-02T13:29:50.000Z",
elapsed_ms: 400,
error: "invalid_spoken_prompt",
result_fingerprint: JSON.stringify({ reason: "probe_mismatch" }),
},
],
tools: ["rectification-compare-candidates"],
methods: ["d1-rashi"],
started_at: "2026-09-02T13:27:05.000Z",
completed_at: "2026-09-02T13:30:03.000Z",
}),
});
const receipt = await loadV9TurnReceipt(accounting.client, USER_ID, CASE_ID, TURN_ID);
assert.equal(receipt?.toolActivities[0]?.elapsedMs, 120000);
assert.equal(receipt?.toolActivities[0]?.detail?.engine_compare_ms, 80000);
assert.equal(receipt?.toolActivities[1]?.detail?.reason, "probe_mismatch");
});