Keep targeted existence questions as A-D cards. Recover collect-schema stock by question-id prefix, surface the stem when persist fails, and send spoken targeted existence to the repair exit instead of a naked prompt.
179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
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:<domain> 存在题的口述形态是 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/);
|
||
assert.match(chat, /unansweredDeadChoice/);
|
||
});
|