import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { CHOICE_SKIP_QUESTION_LABEL, TARGETED_COLLECT_KEEP_HINT, TARGETED_COLLECT_OPTION_A, TARGETED_COLLECT_OPTION_B, TARGETED_COLLECT_OPTION_C, TARGETED_COLLECT_OPTION_D, parseRectificationChoiceCard, } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { buildMethodFollowupPlan, projectRectificationChoiceCard, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { interviewChoiceCardUnavailable, interviewCollectWaiting, rectificationQuestionGapState, } from "../src/lib/rectification-surface-state.ts"; const FOCUS_ID = "abababab-abab-4bab-8bab-abababababab"; const TARGETED_PROMPT = "收入明显变过或有过大笔进出吗?"; const TARGETED_COPY = { prompt: TARGETED_PROMPT, option_a: TARGETED_COLLECT_OPTION_A, option_b: TARGETED_COLLECT_OPTION_B, option_c: TARGETED_COLLECT_OPTION_C, option_d: TARGETED_COLLECT_OPTION_D, options: [ { key: "A" as const, label: TARGETED_COLLECT_OPTION_A, answer_class: "yes" as const }, { key: "B" as const, label: TARGETED_COLLECT_OPTION_B, answer_class: "no" as const }, { key: "C" as const, label: TARGETED_COLLECT_OPTION_C, answer_class: "unsure" as const }, { key: "D" as const, label: TARGETED_COLLECT_OPTION_D, answer_class: "weak_yes" as const }, ], }; 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 targetedSchema() { return { targeted_collect: true, collect_kind: "targeted:finance", scoring: false, choice: TARGETED_COPY, prompt: TARGETED_PROMPT, }; } function targetedFocus(questionId: string) { return { id: FOCUS_ID, questionId, intent: "collect_method_evidence", targetDomain: "finance", targetKind: "targeted:finance", expectedAnswerSchema: targetedSchema(), }; } function project(questionId: string) { return projectRectificationChoiceCard({ evidence: DATED_EVIDENCE, sessionOutcome: "discriminate_candidates", candidatesSeparated: false, remainingLayers: ["d9", "d10", "d2", "d11"], remainingSplitTimes: ["04:48", "05:07"], remainingCandidateCount: 5, activeFocus: targetedFocus(questionId), }); } test("GET projects a live targeted existence card from the persisted copy", () => { const card = project("collect:targeted:finance"); const question = projectCurrentQuestion(targetedFocus("collect:targeted:finance")); assert.ok(card, "targeted existence must stay tappable after a snapshot reread"); assert.equal(card.focus_id, FOCUS_ID); assert.equal(card.question_id, "collect:targeted:finance"); assert.equal(card.prompt, TARGETED_PROMPT); assert.equal(card.options.length, 4); assert.equal(card.options[0]?.label, TARGETED_COLLECT_OPTION_A); assert.equal(card.scoring, false); assert.equal(card.stop_label, CHOICE_SKIP_QUESTION_LABEL); assert.equal(card.skip_this_probe, undefined); assert.equal(question?.kind, "choice"); assert.equal(card.focus_id, question?.focus_id); assert.equal(parseRectificationChoiceCard(card)?.focus_id, FOCUS_ID); }); test("GET still projects when persist added a :next suffix", () => { const card = project("collect:targeted:finance:next"); assert.ok(card, ":next must not be the identity of a targeted card"); assert.equal(card.question_id, "collect:targeted:finance:next"); assert.equal(card.focus_id, FOCUS_ID); assert.equal(card.scoring, false); assert.equal(card.options.length, 4); }); test("recast keep rebuilds the targeted choice_frame and does not ask the model to rewrite the stem", () => { const plan = buildMethodFollowupPlan({ evidence: DATED_EVIDENCE, sessionOutcome: "discriminate_candidates", candidatesSeparated: false, remainingLayers: ["d9", "d10", "d2", "d11"], remainingSplitTimes: ["04:48", "05:07"], remainingCandidateCount: 5, activeFocus: targetedFocus("collect:targeted:relocation:next"), }); const followup = plan.next_followup; assert.ok(followup?.choice_frame, "keep must rebuild the A–D frame"); assert.equal(followup.choice_frame.question_id, "collect:targeted:relocation:next"); assert.equal(followup.choice_frame.scoring, false); assert.equal(followup.user_prompt_hint, TARGETED_COLLECT_KEEP_HINT); assert.doesNotMatch(followup.user_prompt_hint, /spokenPrompt 写出题干/); }); test("a choice question without a GET card is the repair path, not collect waiting", () => { assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true); assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false); assert.equal(interviewChoiceCardUnavailable({ questionKind: "collect_spoken", hasChoiceCard: false }), false); // 原值: collect_spoken 不是 deadChoice // 新值: collect:targeted: 存在题的口述形态是 deadChoice // 原因: BUG-673 复发路径不经过 kind=choice assert.equal( interviewChoiceCardUnavailable({ questionKind: "collect_spoken", hasChoiceCard: false, questionId: "collect:targeted:family", }), true, ); assert.equal( interviewCollectWaiting({ sessionOutcome: "collect_evidence", questionMissing: false, }), false, ); assert.equal( rectificationQuestionGapState({ liveQuestionVisible: false, questionMissing: false, questionLoadFailed: true, questionPersisted: false, collectWaiting: false, busy: false, readonly: false, regenerating: false, snapshotLoaded: true, resumableCase: true, retryAttempts: 0, }), "unavailable", ); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(chat, /interviewChoiceCardUnavailable/); assert.match(chat, /questionSource === "unavailable" \|\| deadChoice/); assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/); // 原值: 容器 map 内有 `unansweredDeadChoice`。 // 新值: 同一判定在行组件内。 // 原因: BUG-725。 const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8"); assert.match(messageEntry, /unansweredDeadChoice/); });