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
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
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/);
|
||
});
|