Files
Jyotisha/frontend/tests/rectification-question-in-message.test.ts
T
Jesse_Chen 8dfe457f21
Independent Staging Quality Gate / validate (push) Successful in 9m30s
Independent Staging Quality Gate / publish (push) Successful in 1m50s
fix(rectification): keep choice options and scoring on the server
Agent set-focus was writing or dropping choice schema, so probes never scored. Force server-owned options, fail closed when focus list RPC errors, and require the probe year in spokenPrompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 20:47:16 +08:00

263 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import {
attachQuestionsToTurns,
copyTextForMessage,
parseTurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { RECTIFICATION_AGENT_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const OTHER_TURN = "44444444-4444-4444-8444-444444444444";
const EXISTENCE_OPTIONS = [
{ key: "A" as const, label: "明确发生且时间吻合", answer_class: "yes" },
{ key: "B" as const, label: "发生过但时间偏了", answer_class: "weak_yes" },
{ key: "C" as const, label: "没有这回事", answer_class: "no" },
{ key: "D" as const, label: "这段记不清楚", answer_class: "unsure" },
];
function choiceSchema(prompt: string) {
return {
prompt,
probe_id: "probe:test",
choice: {
prompt,
option_a: EXISTENCE_OPTIONS[0]!.label,
option_b: EXISTENCE_OPTIONS[1]!.label,
option_c: EXISTENCE_OPTIONS[2]!.label,
option_d: EXISTENCE_OPTIONS[3]!.label,
options: EXISTENCE_OPTIONS,
},
};
}
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const casesRoute = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8");
const migration = readFileSync(
new URL("../supabase/migrations/20260902010000_rectification_question_on_turn.sql", import.meta.url),
"utf8",
);
test("question-on-turn migration adds asked_turn_id and answer_option", () => {
assert.match(migration, /add column if not exists asked_turn_id uuid/);
assert.match(migration, /add column if not exists answer_option text/);
assert.match(migration, /p_asked_turn_id uuid default null/);
assert.match(migration, /p_answer_option text default null/);
assert.match(migration, /list_agentic_rectification_conversation_focuses/);
assert.equal(
existsSync(new URL("../db/migrations/20260902010000_rectification_question_on_turn.sql", import.meta.url)),
false,
);
});
test("GET turns attach question by asked_turn_id and never by timestamps", () => {
assert.match(casesRoute, /attachQuestionsToTurns\(dossier\.turns, listed\.focuses/);
assert.match(casesRoute, /question: turn\.question/);
assert.match(casesRoute, /question_source: questionSourceFromFocusList\(listed\)/);
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
assert.doesNotMatch(attach, /askedAt|createdAt/);
});
test("attachQuestionsToTurns joins only the matching asked turn", () => {
const prompt = GENERIC_COLLECT_QUESTION;
const turns = [
{ id: TURN_ID, role: "assistant" as const, text: "你好。", status: "completed" },
{ id: OTHER_TURN, role: "assistant" as const, text: "我们继续。", status: "completed" },
];
const attached = attachQuestionsToTurns(turns, [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:relationship:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: { prompt, collect: true },
status: "active",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
askedTurnId: TURN_ID,
answerOption: null,
}]);
assert.equal(attached[0]?.question?.focus_id, FOCUS_ID);
assert.equal(attached[0]?.question?.prompt, prompt);
assert.equal(attached[0]?.question?.kind, "collect_spoken");
assert.equal(attached[1]?.question, null);
});
test("copy text is body, blank line, stem, then option lines", () => {
const question = parseTurnQuestion({
focus_id: FOCUS_ID,
question_id: "d9:relationship:2023",
kind: "choice",
prompt: "2023 年前后,你有没有一段认真开始或结束的关系?",
options: [
{ key: "A", label: "明确发生且时间吻合" },
{ key: "B", label: "发生过但时间偏了" },
{ key: "C", label: "没有这回事" },
{ key: "D", label: "记不清" },
],
status: "resolved",
answer_option: "A",
probe_id: null,
});
const copied = copyTextForMessage("2016 年 9 月上大学,记下了。", question);
assert.match(copied, /记下了。\n\n2023 年前后/);
assert.match(copied, /A\. 明确发生且时间吻合 ✓/);
assert.match(copied, /B\. 发生过但时间偏了/);
});
test("one assistant article owns the stem and options; the slot class is gone", () => {
assert.match(row, /afterAnswer/);
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
assert.match(chat, /variant="embedded"/);
assert.match(chat, /function markQuestionAnswered/);
assert.match(chat, /"typed"/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
assert.match(chat, /role="status"/);
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
assert.match(chat, /题目加载失败,请刷新/);
assert.match(chat, /question_source/);
});
test("agent toolbag includes set-focus so spokenPrompt can run", () => {
assert.equal(RECTIFICATION_AGENT_TOOLS.includes("rectification-set-focus"), true);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /"rectification-set-focus",/);
assert.doesNotMatch(tools, /"rectification-set-focus": _omitted/);
});
test("message-path idle persist links a pre-built choice focus to the current turn", async () => {
const prompt = "2023 年前后,你有没有一段认真开始或结束的关系?";
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "distinguish_candidates",
questionId: "d9:relationship:2023",
targetDomain: "relationship",
expectedAnswerSchema: choiceSchema(prompt),
}),
}),
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
...activeFocusFixture({
intent: "distinguish_candidates",
questionId: String(args.p_question_id),
askedTurnId: String(args.p_asked_turn_id ?? ""),
}),
asked_turn_id: args.p_asked_turn_id,
},
idempotent: true,
}),
});
const result = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
askedTurnId: TURN_ID,
});
assert.equal(result.persisted, false);
const linked = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.equal(linked?.args.p_asked_turn_id, TURN_ID);
});
test("typed answers keep the previous question on the same message", () => {
assert.match(chat, /markQuestionAnswered\(current, pendingFocusId, "typed"\)/);
assert.match(chat, /answer_option: selected === "typed" \? null : selected/);
assert.match(chat, /status: "resolved"/);
});
test("refresh rebuilds answered and live questions from asked_turn_id only", () => {
const liveTurn = "55555555-5555-4555-8555-555555555555";
const collectPrompt = GENERIC_COLLECT_QUESTION;
const answeredPrompt = "2015 年前后,有没有搬家或换过主要居住地?";
const livePrompt = "2014 年前后,有没有升学、转学或换学习环境?";
const attached = attachQuestionsToTurns(
[
{ id: TURN_ID, role: "assistant" as const, text: "你好,可以慢慢说。", status: "completed" },
{ id: OTHER_TURN, role: "assistant" as const, text: "记下了,并更新了候选比较。", status: "completed" },
{ id: liveTurn, role: "assistant" as const, text: "2016 年入学记下了。", status: "completed" },
],
[
{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:relationship:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: { prompt: collectPrompt, collect: true },
status: "resolved",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: "2026-09-02T00:01:00.000Z",
askedTurnId: TURN_ID,
answerOption: null,
},
{
id: "66666666-6666-4666-8666-666666666666",
caseId: CASE_ID,
questionId: "relocation:2015",
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "relocation",
targetKind: null,
expectedAnswerSchema: choiceSchema(answeredPrompt),
status: "resolved",
askedAt: "2026-09-02T00:02:00.000Z",
resolvedAt: "2026-09-02T00:03:00.000Z",
askedTurnId: OTHER_TURN,
answerOption: "A",
},
{
id: "77777777-7777-4777-8777-777777777777",
caseId: CASE_ID,
questionId: "education:2014",
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "education",
targetKind: null,
expectedAnswerSchema: choiceSchema(livePrompt),
status: "active",
askedAt: "2026-09-02T00:04:00.000Z",
resolvedAt: null,
askedTurnId: liveTurn,
answerOption: null,
},
],
);
assert.equal(attached[0]?.question?.kind, "collect_spoken");
assert.equal(attached[0]?.question?.status, "resolved");
assert.equal(attached[1]?.question?.kind, "choice");
assert.equal(attached[1]?.question?.options?.length, 4);
assert.equal(attached[1]?.question?.answer_option, "A");
assert.equal(attached[1]?.question?.prompt, answeredPrompt);
assert.ok(attached[1]?.question?.prompt.includes("2015"));
assert.equal(attached[2]?.question?.kind, "choice");
assert.equal(attached[2]?.question?.options?.length, 4);
assert.equal(attached[2]?.question?.prompt, livePrompt);
assert.ok(attached[2]?.question?.prompt.includes("2014"));
assert.equal(attached[2]?.question?.answer_option, null);
assert.equal(attached[2]?.text, "2016 年入学记下了。");
});