Files
Jyotisha/frontend/tests/rectification-targeted-spoken-focus-recovery-20260913.test.ts
T
Jesse_ChenandClaude Opus 5.5 84eb23152a feat(rectification): remove the rectification regenerate button and endpoint (BUG-1056)
T2 of TASK-rectification-grounding-20260927 (product decision P1).
The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that
persisted unchecked text (invented range / fit rate reproduced). Removed:
the regenerate route, regenerate-turn.ts, the regeneration agent and its
read-only tool set, the client regenerate action/state and the
regenerating/canRegenerate props. ChatMessageActions renders the regenerate
button only when onRegenerate is passed; ordinary consultation is unchanged.
The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6,
retire in a later round). DESIGN.md / VOICE.md updated; source-contract
tests follow with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 03:28:27 +08:00

310 lines
10 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 } from "node:fs";
import test from "node:test";
import {
TARGETED_COLLECT_KEEP_HINT,
TARGETED_COLLECT_OPTION_A,
TARGETED_COLLECT_OPTION_B,
TARGETED_COLLECT_OPTION_C,
TARGETED_COLLECT_OPTION_D,
buildTargetedCollectExistenceFrame,
serverOwnedChoiceCopy,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
buildMethodFollowupPlan,
projectRectificationChoiceCard,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
persistSkippedCollectFocus,
persistServerOwnedFocus,
serverOwnedExpectedAnswerSchema,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
interviewChoiceCardUnavailable,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { CASE_ID, FOCUS_ID, USER_ID, fakeAccounting } from "./rectification-v9-test-support.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const FAMILY_PROMPT = "家里添过丁或长辈住过院吗?";
const DATED_EVIDENCE = [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
},
{
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
},
] as const;
function spokenTargetedSchema(prompt = FAMILY_PROMPT) {
return {
collect: true,
prompt,
collect_kind: "targeted:family",
};
}
function spokenTargetedFocus(questionId: string) {
return {
id: FOCUS_ID,
questionId,
intent: "collect_method_evidence" as const,
targetDomain: "family",
targetKind: "targeted:family",
expectedAnswerSchema: spokenTargetedSchema(),
};
}
function targetedFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
const frame = buildTargetedCollectExistenceFrame({
questionId: "collect:targeted:family",
methodId: "relatives",
prompt: FAMILY_PROMPT,
});
assert.ok(frame);
return {
method_id: "relatives",
intent: "collect_method_evidence",
ask_theme: "family_event",
domain: "family",
kind_hint: "targeted:family",
user_prompt_hint: TARGETED_COLLECT_KEEP_HINT,
must_not_label: false,
choice_frame: frame,
choice_kind: "existence",
source: "method_coverage",
collection_key: "collect:targeted:family",
spoken_prompt: FAMILY_PROMPT,
...overrides,
};
}
function focusRow(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 ?? 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-13T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
};
}
test("spoken targeted existence focus recasts to a scoring-false A–D card", () => {
const plan = buildMethodFollowupPlan({
evidence: DATED_EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
remainingLayers: ["d9", "d10", "d2", "d11"],
remainingSplitTimes: ["04:48", "05:07"],
remainingCandidateCount: 5,
activeFocus: spokenTargetedFocus("collect:targeted:family"),
});
const frame = plan.next_followup?.choice_frame;
assert.ok(frame);
assert.equal(frame.question_id, "collect:targeted:family");
assert.equal(frame.scoring, false);
assert.equal(frame.option_a_hint, TARGETED_COLLECT_OPTION_A);
assert.equal(frame.option_b_hint, TARGETED_COLLECT_OPTION_B);
assert.equal(frame.neither_label, TARGETED_COLLECT_OPTION_C);
assert.equal(frame.unsure_label, TARGETED_COLLECT_OPTION_D);
assert.equal(plan.next_followup?.user_prompt_hint, TARGETED_COLLECT_KEEP_HINT);
});
test("spoken targeted existence with :next suffix also recasts", () => {
const plan = buildMethodFollowupPlan({
evidence: DATED_EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
remainingLayers: ["d9", "d10"],
remainingSplitTimes: ["04:48", "05:07"],
remainingCandidateCount: 4,
activeFocus: spokenTargetedFocus("collect:targeted:family:next"),
});
assert.equal(plan.next_followup?.choice_frame?.question_id, "collect:targeted:family:next");
assert.equal(plan.next_followup?.choice_frame?.scoring, false);
});
test("projectCurrentQuestion turns spoken targeted existence into kind=choice", () => {
const question = projectCurrentQuestion(spokenTargetedFocus("collect:targeted:family"));
assert.equal(question?.kind, "choice");
assert.equal(question?.prompt, FAMILY_PROMPT);
const copy = serverOwnedChoiceCopy(
buildTargetedCollectExistenceFrame({
questionId: "collect:targeted:family",
methodId: "relatives",
prompt: FAMILY_PROMPT,
})!,
);
assert.ok(copy);
assert.equal(copy.options.length, 4);
});
test("GET projects a live card from a spoken targeted existence schema", () => {
const card = projectRectificationChoiceCard({
evidence: DATED_EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
remainingLayers: ["d9", "d10"],
remainingSplitTimes: ["04:48", "05:07"],
remainingCandidateCount: 4,
activeFocus: spokenTargetedFocus("collect:targeted:family:next"),
});
assert.ok(card);
assert.equal(card.question_id, "collect:targeted:family:next");
assert.equal(card.focus_id, FOCUS_ID);
assert.equal(card.scoring, false);
assert.equal(card.options.length, 4);
assert.equal(card.options[0]?.label, TARGETED_COLLECT_OPTION_A);
});
test("serverOwnedExpectedAnswerSchema never writes collect schema for targeted existence", () => {
const broken = targetedFollowup({
choice_frame: {
...buildTargetedCollectExistenceFrame({
questionId: "collect:targeted:family",
methodId: "relatives",
prompt: FAMILY_PROMPT,
})!,
option_a_hint: "有过这件事",
option_b_hint: "有过这件事",
},
});
const schema = serverOwnedExpectedAnswerSchema(broken, null);
assert.ok(schema?.choice);
assert.equal(schema.targeted_collect, true);
assert.notEqual(schema.collect, true);
const empty = serverOwnedExpectedAnswerSchema(targetedFollowup({
choice_frame: null,
spoken_prompt: "",
user_prompt_hint: "",
}), null);
assert.equal(empty, null);
});
test("persistServerOwnedFocus writes targeted existence as a choice schema", async () => {
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: focusRow(args),
idempotent: false,
}),
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: targetedFollowup({ choice_frame: null }),
});
assert.equal(result.status, "created");
assert.equal(result.focus?.expectedAnswerSchema.targeted_collect, true);
assert.ok(result.focus?.expectedAnswerSchema.choice);
assert.notEqual(result.focus?.expectedAnswerSchema.collect, true);
});
test("persistSkippedCollectFocus retries resolve and never returns an active focus", async () => {
let resolves = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: focusRow(args),
idempotent: false,
}),
resolve_agentic_rectification_conversation_focus: () => {
resolves += 1;
throw new Error("resolve failed");
},
});
const focus = await persistSkippedCollectFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
followup: targetedFollowup(),
});
assert.ok(focus);
assert.equal(focus.status, "skipped");
assert.notEqual(focus.status, "active");
assert.equal(resolves, 2);
assert.equal(focus.expectedAnswerSchema.targeted_collect, true);
assert.ok(focus.expectedAnswerSchema.choice);
});
test("spoken targeted existence without a GET card is unavailable; ordinary spoken collect is not", () => {
assert.equal(
interviewChoiceCardUnavailable({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionId: "collect:targeted:family",
}),
true,
);
assert.equal(
interviewChoiceCardUnavailable({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionId: "collect:targeted:family:next",
}),
true,
);
assert.equal(
interviewChoiceCardUnavailable({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionId: "collect:targeted:family:year",
}),
false,
);
assert.equal(
interviewChoiceCardUnavailable({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionId: "collect:other:collect_method_evidence",
}),
false,
);
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: false,
questionLoadFailed: true,
questionPersisted: false,
collectWaiting: false,
busy: false,
readonly: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
}),
"unavailable",
);
});
test("persistNextInterviewAfterChoice tail names persisted false", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
assert.match(source, /hostNarration: spoken,\s*choiceReady: false,\s*persisted: false,/);
assert.match(source, /nextFollowup: keptNextQuestion \? nextFollowup : null/);
});
test("chat passes questionId into the dead-choice helper", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
assert.match(chat, /interviewChoiceCardUnavailable\(\{[\s\S]*questionId: currentQuestion\?\.question_id/);
});