Files
Jyotisha/frontend/tests/rectification-turn-intent-classifier.test.ts
T
Jesse_ChenandClaude Opus 5.5 eb773f1299 refactor(rectification): split POST /api/rectification/agent into branch handlers
Move-only (TASK-rectification-code-split-20260926 T2/T4). No behavior, API,
copy, DB, Skill, billing or scoring change.

- route.ts: 1077 -> 152 lines; POST 927 -> 114. It builds the Supabase
  clients (so the setup-failure mapping stays here) and assembles:
  agent-route-request.ts (auth / product / schema / flag / Case-Session
  binding), agent-route-typed-message.ts (declared-window reply, typed-answer
  preflight, unfocused classification), agent-route-structured-choice.ts,
  agent-route-agent-turn.ts (opening / read-only / typed agent stream and its
  exit gate), agent-route-billing.ts, agent-route-support.ts (schema, one-shot
  NDJSON reply, context types).
- The four mutable preflight lets (expectedWrite, collectIntent,
  writeClassified, classifierDiagnostic) that crossed branches are one
  turnState object; values and flow unchanged.
- Tests: whole-source contracts read tests/rectification-agent-route-surface.ts;
  the typed fast-path slice is rebuilt from the new files; billing,
  declared-window and structured-choice slices now call the handlers
  (billing in a child process because feature-pricing imports server-only),
  each with 原值/新值/原因. Route growth caps added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:40:07 +08:00

308 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { rectificationAgentRouteSurface, rectificationTypedMessageFastPath } from "./rectification-agent-route-surface.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", () => {
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
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");
// 原值: fastPath = route.ts 里 `if (action === "message")` 到 `const requestTime` 的切片。
// 新值: rectificationTypedMessageFastPath():同一批代码的新位置(agent-route-typed-message.ts 全文 +
// route.ts 从 `if (action === "message")` 起 + agent-route-agent-turn.ts 到 `const requestTime` 为止)。
// 原因: TASK-rectification-code-split-20260926 快路径拆进多个文件;这是「生产代码不含语义正则」的静态合同,
// 只能按源码查,切片按代码去处重组,断言不变。
const fastPath = rectificationTypedMessageFastPath();
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", () => {
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
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/);
});