Files
Jyotisha/frontend/tests/rectification-turn-intent-classifier.test.ts
T
jesse-ux 8b982baf64
Independent Staging Quality Gate / validate (push) Canceled after 4m2s
Independent Staging Quality Gate / publish (push) Canceled after 0s
fix(rectification): 分类器失败、引擎忙、整轮超时不再归因错
点选题把分类器两次异常说成用户没说清(BUG-722);引擎 429 被压成坏了且重算静默失败(BUG-723);两次 attempt 总预算大于路由 maxDuration(BUG-724)。本单只改归因:classifier_unavailable 请用户重发、busy 分档并可见「这次没有重新比较」、整轮 225s 预算不够则 host fallback。不改计费、分类模型、并发闸门。
2026-09-16 07:06:57 +08:00

303 lines
12 KiB
TypeScript

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<string, unknown>): 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/);
});