8dfe457f21
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>
263 lines
11 KiB
TypeScript
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 年入学记下了。");
|
|
});
|