import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { optionIdForAnswerClass, parseRectificationTurnIntent, shouldContinueAgentForDatedEvent, shouldDeclineCollectFocus, collectFocusCloseStatus, expectedWriteFromCollectIntent, classifyTurnIntentWithRetry, turnIntentOutcome, } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts"; import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; import type { ResolvedLanguageModel } from "../src/mastra/model.ts"; import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, FOCUS_ID } from "./rectification-v9-test-support.ts"; function focusWithOptions(expectedAnswerSchema: Record): ConversationFocus { return { id: FOCUS_ID, caseId: CASE_ID, questionId: "question-1", intent: "distinguish_candidates", targetEvidenceId: null, targetDomain: "career", targetKind: "career_change", expectedAnswerSchema, status: "active", askedAt: "2026-08-27T00:00:00.000Z", resolvedAt: null, }; } const SHUFFLED_CHOICE = { choice: { prompt: "2023 年前后,工作状态是否出现明显变化?", option_a: "这段时间没有明显变化", option_b: "记不清当时的情况", option_c: "变化明显而且时间吻合", option_d: "有变化但程度比较弱", options: [ { key: "A", label: "这段时间没有明显变化", answer_class: "no" }, { key: "B", label: "记不清当时的情况", answer_class: "unsure" }, { key: "C", label: "变化明显而且时间吻合", answer_class: "yes" }, { key: "D", label: "有变化但程度比较弱", answer_class: "weak_yes" }, ], }, }; test("turn intent parser enforces answer_class only for current-focus answers", () => { assert.deepEqual(parseRectificationTurnIntent({ intent: "answer_current_focus", answer_class: "no", }), { intent: "answer_current_focus", answer_class: "no", }); assert.deepEqual(parseRectificationTurnIntent({ intent: "provide_new_evidence", answer_class: null, }), { intent: "provide_new_evidence", answer_class: null, }); assert.equal(parseRectificationTurnIntent({ intent: "answer_current_focus", answer_class: null, }), null); assert.equal(parseRectificationTurnIntent({ intent: "stop_rectification", answer_class: "no", }), null); assert.equal(parseRectificationTurnIntent({ intent: "unclear", answer_class: null, extra: true, }), null); }); test("missing has_new_dated_event is treated as false and true is fail-closed optional", () => { const legacy = parseRectificationTurnIntent({ intent: "answer_current_focus", answer_class: "no", }); assert.equal(legacy?.intent, "answer_current_focus"); assert.equal(legacy?.answer_class, "no"); assert.equal(legacy?.has_new_dated_event, undefined); assert.equal(shouldContinueAgentForDatedEvent(legacy), false); assert.equal(shouldContinueAgentForDatedEvent(null), false); assert.equal(shouldContinueAgentForDatedEvent({ intent: "answer_current_focus", answer_class: "no", has_new_dated_event: false, }), false); const withEvent = parseRectificationTurnIntent({ intent: "answer_current_focus", answer_class: "no", has_new_dated_event: true, }); assert.equal(withEvent?.has_new_dated_event, true); assert.equal(shouldContinueAgentForDatedEvent(withEvent), true); assert.equal(shouldDeclineCollectFocus(withEvent), true); assert.equal(parseRectificationTurnIntent({ intent: "answer_current_focus", answer_class: "no", has_new_dated_event: "yes", }), null); }); test("answer classes resolve through each dynamic option instead of A/B/C/D position", () => { const focus = focusWithOptions(SHUFFLED_CHOICE); assert.equal(optionIdForAnswerClass(focus, "no"), "A"); assert.equal(optionIdForAnswerClass(focus, "unsure"), "B"); assert.equal(optionIdForAnswerClass(focus, "yes"), "C"); assert.equal(optionIdForAnswerClass(focus, "weak_yes"), "D"); }); test("missing or invalid dynamic choice schemas fail closed", () => { assert.equal(optionIdForAnswerClass(focusWithOptions({}), "no"), null); assert.equal(optionIdForAnswerClass(focusWithOptions({ choice: { ...SHUFFLED_CHOICE.choice, options: SHUFFLED_CHOICE.choice.options.map(({ key, label }) => ({ key, label })), }, }), "no"), null); }); test("production intent handling contains no semantic regex or positional text parser", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8"); const choiceCard = readFileSync(new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url), "utf8"); const inference = readFileSync(new URL("../src/lib/rectification-agentic/v9/inference-adapter.ts", import.meta.url), "utf8"); const source = [route, classifier, choiceCard, inference].join("\n"); const fastPath = route.slice( route.indexOf('if (action === "message")'), route.indexOf("const requestTime"), ); assert.doesNotMatch(source, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/); assert.doesNotMatch(classifier + fastPath, /\.test\([^\n]*(?:userMessage|user_message|message)/); assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/); assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({")); assert.ok(route.indexOf("persistServerOwnedFocus") < route.indexOf("runV9AgentTurn({")); assert.ok(fastPath.includes("ask_candidate_discriminator")); assert.doesNotMatch(fastPath, /目前没有可继续区分/); assert.match(route, /nonConvergingRangeNarration/); assert.match(fastPath, /persistNextInterviewIfIdle/); assert.match(fastPath, /!plan\.next_followup/); assert.doesNotMatch(route, /classified\.answer_class!/); assert.match(route, /classifyTurnIntentWithRetry/); assert.match(route, /expectedWrite,/); assert.equal(expectedWriteFromCollectIntent({ intent: "provide_new_evidence", answer_class: null, }), "evidence"); assert.equal(expectedWriteFromCollectIntent({ intent: "answer_current_focus", answer_class: "yes", has_new_dated_event: true, }), "evidence"); assert.equal(expectedWriteFromCollectIntent({ intent: "answer_current_focus", answer_class: "no", }), "none"); assert.equal(expectedWriteFromCollectIntent(null), "none"); assert.doesNotMatch(route, /isCollectDeclineUtterance|isCollectSkipUtterance/); assert.doesNotMatch(classifier, /isCollectDeclineUtterance|isCollectSkipUtterance/); assert.match(classifier, /没有、没发生过、这方面没什么/); assert.match(classifier, /记不清、不记得、忘了、想不起来、以后再说/); assert.match(classifier, /answer_class 为 unsure/); assert.match(classifier, /answer_class 为 yes/); assert.match(route, /collectFocusCloseStatus/); assert.match(route, /collectIntent/); }); test("collect focus close-status and expectedWrite map classifier classes", () => { assert.equal(collectFocusCloseStatus({ intent: "answer_current_focus", answer_class: "no", }), "declined"); assert.equal(collectFocusCloseStatus({ intent: "answer_current_focus", answer_class: "unsure", }), "skipped"); assert.equal(collectFocusCloseStatus({ intent: "answer_current_focus", answer_class: "yes", }), null); assert.equal(collectFocusCloseStatus({ intent: "provide_new_evidence", answer_class: null, }), null); const collectFocus = focusWithOptions({ collect: true, prompt: "钱的方面,还记得哪年收入明显变过吗?", }); assert.equal(expectedWriteFromCollectIntent({ intent: "answer_current_focus", answer_class: "yes", }, collectFocus), "evidence"); assert.equal(expectedWriteFromCollectIntent({ intent: "answer_current_focus", answer_class: "weak_yes", }, collectFocus), "evidence"); assert.equal(expectedWriteFromCollectIntent({ intent: "answer_current_focus", answer_class: "yes", }), "none"); assert.deepEqual(parseRectificationTurnIntent({ intent: "provide_new_evidence", answer_class: null, }), { intent: "provide_new_evidence", answer_class: null, }); const noPlusEvent = parseRectificationTurnIntent({ intent: "answer_current_focus", answer_class: "no", has_new_dated_event: true, }); assert.equal(noPlusEvent?.answer_class, "no"); assert.equal(noPlusEvent?.has_new_dated_event, true); }); test("classifyTurnIntentWithRetry fails open as unknown after two misses", () => { const src = readFileSync( new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8", ); assert.match(src, /export async function classifyTurnIntentWithRetry/); assert.match(src, /for \(let attempt = 0; attempt < 2/); assert.match(src, /expectedWrite: "unknown"/); assert.match(src, /outcome: "classifier_unavailable"/); assert.doesNotMatch(src, /\(\?:19\|20\)\\d\{2\}/); }); const dummyModel = { id: "test-model" } as ResolvedLanguageModel; test("two classifier throws are classifier_unavailable, not unclear", async () => { let calls = 0; const warnings: unknown[] = []; const original = console.warn; console.warn = (...args: unknown[]) => { warnings.push(args); }; try { const result = await classifyTurnIntentWithRetry( dummyModel, { userMessage: "2016年3月入学", caseStatus: "collecting_evidence" }, async () => { calls += 1; throw Object.assign(new Error("upstream 5xx"), { name: "APICallError" }); }, ); assert.equal(calls, 2); assert.equal(result.classified, null); assert.equal(result.expectedWrite, "unknown"); assert.equal(result.outcome, "classifier_unavailable"); assert.equal(turnIntentOutcome(null), "classifier_unavailable"); const line = JSON.stringify(warnings[0]); assert.match(line, /rectification_classifier_unavailable/); assert.match(line, /elapsedMs/); assert.doesNotMatch(line, /2016|入学|userMessage/); } finally { console.warn = original; } }); test("a model unclear intent is outcome unclear and uses a different reply than unavailable", async () => { const result = await classifyTurnIntentWithRetry( dummyModel, { userMessage: "随便吧", caseStatus: "collecting_evidence" }, async () => ({ intent: "unclear", answer_class: null }), ); assert.equal(result.outcome, "unclear"); assert.equal(result.classified?.intent, "unclear"); assert.equal(result.expectedWrite, "none"); assert.equal(turnIntentOutcome({ intent: "unclear", answer_class: null }), "unclear"); assert.notEqual( RECTIFICATION_USER_COPY.classifierUnavailableReply, RECTIFICATION_USER_COPY.unclearFocusReply, ); assert.match(RECTIFICATION_USER_COPY.classifierUnavailableReply, /再发一次/); assert.match(RECTIFICATION_USER_COPY.unclearFocusReply, /不太确定/); }); test("route no longer merges classifier null with user-unclear", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); assert.doesNotMatch(route, /!classified \|\| classified\.intent === "unclear"/); assert.match(route, /outcome === "classifier_unavailable"/); assert.match(route, /outcome === "unclear"/); assert.match(route, /classifierUnavailableReply/); const unavailableAt = route.indexOf('outcome === "classifier_unavailable"'); const unclearAt = route.indexOf('outcome === "unclear"'); assert.ok(unavailableAt >= 0 && unclearAt > unavailableAt); const unavailableBlock = route.slice(unavailableAt, unclearAt); assert.match(unavailableBlock, /classifierUnavailableReply/); assert.doesNotMatch(unavailableBlock, /applyRectificationChoice/); assert.doesNotMatch(unavailableBlock, /authorizeUsage/); assert.doesNotMatch(unavailableBlock, /record-evidence-batch|recordV10EvidenceBatch/); });