Files
Jyotisha/frontend/tests/rectification-question-ownership.test.ts
T
Jesse_Chen 0be51e65f5
Independent Staging Quality Gate / validate (push) Successful in 10m6s
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(rectification): ask relationship and career before dated collect
Move nextDatedCollectFollowup after family coverage so a new case still
asks D9 then D10. Dated third-event collect stays education → finance →
relocation → health. Admin input padding contract follows --space-3.

BUG-531 and BUG-532.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:06:54 +08:00

706 lines
28 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 { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { buildChoiceFrame, parseAgentChoiceCopy, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
buildMethodFollowupPlan,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
decideFromDossier,
rectificationFollowupCatalog,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
expectedAnswerSchemaFor,
serverOwnedExpectedAnswerSchema,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
attachQuestionsToTurns,
parseTurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
import {
listV10ConversationFocuses,
parseV9CaseDossier,
questionSourceFromFocusList,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
internalObservationsFromWindowScan,
windowScanFromDecisionReceipt,
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CASE_ID,
EVIDENCE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
type ExecutableTool<T = unknown> = {
execute(input: unknown): Promise<T>;
};
const STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const RELOCATION_2015 = {
id: "p-reloc-2015",
semantic_key: "relocation.2015.05.dasha_boundary",
candidate_split_hash: "reloc-2015-split",
domain: "relocation",
year: 2015,
month: 5,
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
information_gain: 0.87,
source: "dasha_boundary",
};
function eventProbeFromConflict(probe: typeof RELOCATION_2015) {
return {
year: probe.year,
year_label: `${probe.year}${probe.month} 月前后`,
month: probe.month,
domain: probe.domain,
event_family: "搬家或长期住到外地",
source: probe.source,
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
phase: "candidate_discriminator",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
style_options: STYLE_OPTIONS,
choice_kind: "existence" as const,
};
}
function coverageEvidence() {
return [
evidenceRow(EVIDENCE_ID, "career_entry", "career", "2020-04-01", "month", "2020 年 4 月开始实习"),
evidenceRow("44444444-4444-4444-8444-444444444445", "education_start", "education", "2016-09-01", "month", "2016 年入学"),
evidenceRow("44444444-4444-4444-8444-444444444446", "relationship_start", "relationship", "2024-05-01", "month", "2024 年 5 月认识一位女生"),
evidenceRow("44444444-4444-4444-8444-444444444447", "family_event", "family", "2021-03-01", "month", "2021 年家里有人住院"),
];
}
function evidenceRow(
id: string,
eventKind: string,
domain: string,
occurredFrom: string,
datePrecision: string,
summary: string,
) {
return {
id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: eventKind,
domain,
occurred_from: occurredFrom,
occurred_to: occurredFrom,
date_precision: datePrecision,
summary,
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-28T07:35:45.000Z",
};
}
function distinguishDossier(activeFocus: unknown = null) {
const inference = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 16 },
{ id: "05:10", time: "05:10", relative_support: 14 },
],
events: [
{ id: "e1", domain: "career", year: 2020, precision: "month" },
{ id: "e2", domain: "education", year: 2016, precision: "month" },
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
{ id: "e4", domain: "family", year: 2021, precision: "month" },
],
probes: [RELOCATION_2015],
});
return dossierFixture({
evidenceCount: 4,
evidence: coverageEvidence(),
latestResult: candidateSnapshotFixture({
decisionReceipt: {
inference_state: inference,
discriminating_event_probes: [eventProbeFromConflict(RELOCATION_2015)],
},
}),
conversationSummary: conversationSummaryFixture({ activeFocus }),
});
}
function nextFollowupFromRaw(raw: unknown): MethodFollowup | null {
const parsed = parseV9CaseDossier(raw);
assert.ok(parsed);
const decision = decideFromDossier(parsed);
const catalog = rectificationFollowupCatalog(parsed.latestResult, parsed.evidence);
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(parsed.latestResult?.decisionReceipt ?? null),
);
return buildMethodFollowupPlan({
evidence: parsed.evidence,
activeFocus: parsed.conversationSummary.activeFocus,
declinedTopics: parsed.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: parsed.conversationSummary.declinedSkippedTopics,
observations,
sessionOutcome: decision.sessionOutcome,
...catalog,
birthDate: null,
accepted: Boolean(parsed.case.acceptedTime),
candidatesSeparated: decision.separation.sufficient,
holdoutValidation: decision.holdoutValidation,
}).next_followup;
}
function identityMinusPrompt(schema: Record<string, unknown>) {
const next = { ...schema };
delete next.prompt;
if (next.choice && typeof next.choice === "object" && !Array.isArray(next.choice)) {
const choice = { ...(next.choice as Record<string, unknown>) };
delete choice.prompt;
next.choice = choice;
}
return next;
}
function setFocusHandler(store: { focus: ReturnType<typeof activeFocusFixture> | null }) {
return (_fn: string, args: Record<string, unknown>) => {
const incoming = (args.p_expected_answer_schema as Record<string, unknown>) ?? {};
const existing = store.focus;
const sameIdentity = Boolean(
existing
&& existing.question_id === args.p_question_id
&& existing.intent === args.p_intent
&& JSON.stringify(identityMinusPrompt(existing.expected_answer_schema as Record<string, unknown>))
=== JSON.stringify(identityMinusPrompt(incoming)),
);
store.focus = {
...activeFocusFixture({
id: existing && sameIdentity ? existing.id : FOCUS_ID,
questionId: String(args.p_question_id),
intent: String(args.p_intent),
targetEvidenceId: args.p_target_evidence_id ? String(args.p_target_evidence_id) : null,
targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null,
targetKind: args.p_target_kind == null ? null : String(args.p_target_kind),
expectedAnswerSchema: incoming,
askedTurnId: args.p_asked_turn_id ? String(args.p_asked_turn_id) : null,
}),
};
return { focus: store.focus, idempotent: sameIdentity };
};
}
function toolsFor(accounting: ReturnType<typeof fakeAccounting>) {
return createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
}
function discriminatorFollowup(): MethodFollowup {
const frame = buildChoiceFrame({
method_id: "dasha_events",
ask_theme: "dated_event",
domain: "education",
user_prompt_hint: "ask",
}, {
probes: [{
year: 2016,
year_label: "2016 年前后",
domain: "education",
event_family: "升学结果或学习环境出现明显变化",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "2016 年前后升学结果或学习环境出现明显变化",
role: "distinguish",
information_gain: 0.4,
semantic_key: "education:2016",
style_options: STYLE_OPTIONS,
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
}],
});
assert.ok(frame);
return {
method_id: "dasha_events",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: "education",
kind_hint: null,
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: frame,
source: "event_probe",
information_gain: 0.4,
semantic_key: "education:2016",
probe_year: 2016,
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
};
}
test("server-owned schema matches choice_frame copy and ignores Agent choice", () => {
const followup = discriminatorFollowup();
const copy = serverOwnedChoiceCopy(followup.choice_frame!);
assert.ok(copy);
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
stableFollowupQuestionId(followup),
null,
followup,
);
assert.ok(schema?.choice);
const parsed = parseAgentChoiceCopy(schema);
assert.deepEqual(parsed?.options, copy.options);
const viaWrapper = serverOwnedExpectedAnswerSchema(followup, null);
assert.deepEqual(
parseAgentChoiceCopy(viaWrapper)?.options,
copy.options,
);
});
test("Agent set-focus with only spokenPrompt persists server choice options", async () => {
const raw = distinguishDossier();
const followup = nextFollowupFromRaw(raw);
assert.ok(followup?.choice_frame, JSON.stringify({
intent: followup?.intent,
source: followup?.source,
method_id: followup?.method_id,
}));
const questionId = stableFollowupQuestionId(followup);
const copy = serverOwnedChoiceCopy(followup.choice_frame);
assert.ok(copy);
const store: { focus: ReturnType<typeof activeFocusFixture> | null } = { focus: null };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
set_agentic_rectification_conversation_focus: setFocusHandler(store),
});
const tools = toolsFor(accounting);
const spokenPrompt = "记下了。2015 年前后,有没有搬家或长期住到外地?";
const first = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
caseId: CASE_ID,
questionId,
intent: "distinguish_candidates",
spokenPrompt,
}) as Record<string, unknown>;
const withAgentChoice = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
caseId: CASE_ID,
questionId,
intent: "distinguish_candidates",
spokenPrompt,
expectedAnswerSchema: {
choice: {
prompt: "伪造题干",
option_a: "模型自编A",
option_b: "模型自编B",
option_c: "模型自编C",
option_d: "模型自编D",
options: [
{ key: "A", label: "模型自编A", answer_class: "no" },
{ key: "B", label: "模型自编B", answer_class: "yes" },
{ key: "C", label: "模型自编C", answer_class: "unsure" },
{ key: "D", label: "模型自编D", answer_class: "weak_yes" },
],
},
},
}) as Record<string, unknown>;
assert.equal(first.error, undefined);
assert.equal(withAgentChoice.error, undefined);
const firstSchema = first.expected_answer_schema as Record<string, unknown>;
const secondSchema = withAgentChoice.expected_answer_schema as Record<string, unknown>;
const firstCopy = parseAgentChoiceCopy(firstSchema);
assert.ok(firstCopy);
assert.deepEqual(firstCopy.options, copy.options);
assert.equal(typeof firstSchema.probe_id, "string");
assert.ok(String(firstSchema.probe_id).length > 0);
assert.equal(projectCurrentQuestion({
id: String(first.focus_id),
questionId: String(first.question_id),
intent: String(first.intent),
targetDomain: typeof first.target_domain === "string" ? first.target_domain : null,
expectedAnswerSchema: firstSchema,
})?.kind, "choice");
assert.deepEqual(identityMinusPrompt(firstSchema), identityMinusPrompt(secondSchema));
assert.equal(firstCopy.option_a, copy.option_a);
assert.notEqual(firstCopy.option_a, "模型自编A");
const attached = attachQuestionsToTurns(
[{ id: TURN_ID, role: "assistant", text: "记下了。", status: "completed" }],
[{
id: String(first.focus_id),
caseId: CASE_ID,
questionId: String(first.question_id),
intent: String(first.intent),
targetEvidenceId: null,
targetDomain: typeof first.target_domain === "string" ? first.target_domain : null,
targetKind: null,
expectedAnswerSchema: firstSchema,
status: "active",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
askedTurnId: TURN_ID,
answerOption: null,
}],
);
assert.equal(attached[0]?.question?.kind, "choice");
assert.equal(attached[0]?.question?.options?.length, 4);
});
test("collect set-focus writes collect:true even if Agent sends choice", async () => {
const raw = dossierFixture({ latestResult: candidateSnapshotFixture() });
const followup = nextFollowupFromRaw(raw);
assert.equal(followup?.choice_frame ?? null, null);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
set_agentic_rectification_conversation_focus: setFocusHandler({ focus: null }),
});
const result = await (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
caseId: CASE_ID,
questionId: followup ? stableFollowupQuestionId(followup) : "collect:relationship:collect_method_evidence",
intent: "collect_method_evidence",
// 原值: USER_COLLECT_QUESTION.family3847e9c945bdb63e 为「哪年上的大学」)
// 新值: USER_COLLECT_QUESTION[followup.domain](默认 dossier 一件事业 → 感情)
// 原因: dated 分支移到家人之后,前两问恢复感情;BUG-529 仍要求题干命中领域词,大学不命中感情。
spokenPrompt: USER_COLLECT_QUESTION[followup?.domain ?? "relationship"] ?? USER_COLLECT_QUESTION.relationship,
expectedAnswerSchema: {
choice: {
prompt: "伪造采集",
option_a: "模型自编A",
option_b: "模型自编B",
option_c: "模型自编C",
option_d: "模型自编D",
},
},
}) as Record<string, unknown>;
assert.equal(result.error, undefined);
const schema = result.expected_answer_schema as Record<string, unknown>;
assert.equal(schema.collect, true);
assert.equal(schema.choice, undefined);
assert.equal(projectCurrentQuestion({
id: String(result.focus_id),
questionId: String(result.question_id),
intent: String(result.intent),
expectedAnswerSchema: schema,
})?.kind, "collect_spoken");
});
test("set-focus returns no_pending_question when there is no next followup", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
});
const result = await (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
caseId: CASE_ID,
questionId: "career-month-question",
intent: "clarify_event_date",
spokenPrompt: "你还记得哪年上的大学吗?",
}) as Record<string, unknown>;
assert.deepEqual(result, { ok: false, error: "no_pending_question" });
assert.equal(
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
test("second set-focus on the same choice probe is idempotent and keeps options", async () => {
const raw = distinguishDossier();
const followup = nextFollowupFromRaw(raw);
assert.ok(followup?.choice_frame);
const questionId = stableFollowupQuestionId(followup);
const store: { focus: ReturnType<typeof activeFocusFixture> | null } = { focus: null };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
set_agentic_rectification_conversation_focus: setFocusHandler(store),
});
const execute = (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute;
const first = await execute({
caseId: CASE_ID,
questionId,
intent: "distinguish_candidates",
spokenPrompt: "记下了。2015 年前后,有没有搬家或长期住到外地?",
}) as Record<string, unknown>;
const second = await execute({
caseId: CASE_ID,
questionId,
intent: "distinguish_candidates",
spokenPrompt: "工作记下了。2015 年 5 月前后,还记不记得搬过家?",
}) as Record<string, unknown>;
assert.equal(first.focus_id, second.focus_id);
assert.equal(second.idempotent, true);
assert.deepEqual(
parseAgentChoiceCopy(first.expected_answer_schema as Record<string, unknown>)?.options,
parseAgentChoiceCopy(second.expected_answer_schema as Record<string, unknown>)?.options,
);
});
test("list focuses RPC error marks GET questions unavailable and warns", async () => {
const warns: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(args.map(String).join(" "));
};
try {
const accounting = fakeAccounting({
list_agentic_rectification_conversation_focuses: () => {
throw new Error("agentic_rectification_focus_not_found");
},
});
const listed = await listV10ConversationFocuses(accounting.client, USER_ID, CASE_ID);
assert.equal(listed.available, false);
assert.deepEqual(listed.focuses, []);
assert.equal(questionSourceFromFocusList(listed), "unavailable");
assert.match(warns.join("\n"), /list focuses failed/);
assert.match(warns.join("\n"), /focus_not_found/);
const attached = attachQuestionsToTurns(
[
{ id: TURN_ID, role: "assistant", text: "你好。", status: "completed" },
{ id: "44444444-4444-4444-8444-444444444444", role: "assistant", text: "记下了。", status: "completed" },
],
listed.focuses,
);
assert.ok(attached.every((turn) => turn.question === null));
} finally {
console.warn = original;
}
const casesRoute = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8");
assert.match(casesRoute, /question_source: questionSourceFromFocusList\(listed\)/);
assert.match(casesRoute, /return NextResponse\.json\(dossierResponse/);
});
test("walkthrough-shaped chain rebuilds each assistant question from asked_turn_id", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /question: parseTurnQuestion\(turn\.question\)/);
const collect = (id: string, prompt: string, status: "active" | "resolved") => ({
id,
caseId: CASE_ID,
questionId: `collect:${id}:collect_method_evidence`,
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: { prompt, collect: true },
status,
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: status === "resolved" ? "2026-09-02T00:01:00.000Z" : null,
askedTurnId: id,
answerOption: null,
});
const choice = (
id: string,
prompt: string,
year: string,
status: "active" | "resolved",
answer: "A" | "B" | "C" | "D" | null,
) => ({
id,
caseId: CASE_ID,
questionId: `probe:${year}`,
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "relocation",
targetKind: null,
expectedAnswerSchema: {
prompt,
probe_id: `probe:${year}`,
choice: {
prompt,
option_a: STYLE_OPTIONS[0]!.label,
option_b: STYLE_OPTIONS[1]!.label,
option_c: STYLE_OPTIONS[2]!.label,
option_d: STYLE_OPTIONS[3]!.label,
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
},
status,
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: status === "resolved" ? "2026-09-02T00:01:00.000Z" : null,
askedTurnId: id,
answerOption: answer,
});
const opening = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const collectTwo = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2";
const collectThree = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3";
const tapOne = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4";
const tapTwo = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5";
const tapThree = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa6";
const typed = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa7";
const reverse = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa8";
const turns = [
{ id: opening, role: "assistant" as const, text: "从你最容易想起来的开始。", status: "completed" },
{ id: collectTwo, role: "assistant" as const, text: "工作记下了。", status: "completed" },
{ id: collectThree, role: "assistant" as const, text: "感情这块也记下了。", status: "completed" },
{ id: tapOne, role: "assistant" as const, text: "接下来对一下这段经历。", status: "completed" },
{ id: tapTwo, role: "assistant" as const, text: "候选比较更新了。", status: "completed" },
{ id: tapThree, role: "assistant" as const, text: "再看一件相近的。", status: "completed" },
{ id: typed, role: "assistant" as const, text: "也可以打字回答。", status: "completed" },
{ id: reverse, role: "assistant" as const, text: "采用之后再核对一件前事。", status: "completed" },
];
const attached = attachQuestionsToTurns(turns, [
collect(opening, "从你最容易想起来的开始就好,记得大概年份就行。", "resolved"),
collect(collectTwo, "钱的方面,还记得哪年收入明显变过吗?", "resolved"),
collect(collectThree, "家里有没有结婚、添丁或住院这类事?", "resolved"),
choice(tapOne, "2015 年前后,有没有搬家或长期住到外地?", "2015", "resolved", "A"),
choice(tapTwo, "2014 年前后,有没有升学、转学或换学习环境?", "2014", "resolved", "B"),
choice(tapThree, "2018 年前后,有没有换过工作?", "2018", "resolved", "C"),
choice(typed, "2023 年前后,有没有一段认真开始或结束的关系?", "2023", "resolved", null),
{
id: reverse,
caseId: CASE_ID,
questionId: "reverse:family:2021",
intent: "reverse_verify",
targetEvidenceId: null,
targetDomain: "family",
targetKind: null,
expectedAnswerSchema: {
prompt: "2021 年前后,家里有没有结婚、添丁或住院?",
collect: true,
},
status: "active",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
askedTurnId: reverse,
answerOption: null,
},
]);
const shapes = attached.map((turn) => {
const question = parseTurnQuestion(turn.question);
return {
id: turn.id,
kind: question?.kind ?? null,
focus_id: question?.focus_id ?? null,
answer_option: question?.answer_option ?? null,
status: question?.status ?? null,
options: question?.options?.length ?? 0,
};
});
assert.deepEqual(shapes, [
{ id: opening, kind: "collect_spoken", focus_id: opening, answer_option: null, status: "resolved", options: 0 },
{ id: collectTwo, kind: "collect_spoken", focus_id: collectTwo, answer_option: null, status: "resolved", options: 0 },
{ id: collectThree, kind: "collect_spoken", focus_id: collectThree, answer_option: null, status: "resolved", options: 0 },
{ id: tapOne, kind: "choice", focus_id: tapOne, answer_option: "A", status: "resolved", options: 4 },
{ id: tapTwo, kind: "choice", focus_id: tapTwo, answer_option: "B", status: "resolved", options: 4 },
{ id: tapThree, kind: "choice", focus_id: tapThree, answer_option: "C", status: "resolved", options: 4 },
{ id: typed, kind: "choice", focus_id: typed, answer_option: null, status: "resolved", options: 4 },
{ id: reverse, kind: "reverse_verify", focus_id: reverse, answer_option: null, status: "active", options: 0 },
]);
assert.ok(attached[3]?.question?.prompt.includes("2015"));
});
test("set-focus description no longer tells the Agent to write option_a", () => {
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const hints = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8");
assert.match(tools, /选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt/);
assert.match(tools, /题干必须写出服务端给你的年份\/期间/);
assert.doesNotMatch(tools, /写入 option_a/);
assert.doesNotMatch(tools, /parseAgentChoiceCopy\(expectedAnswerSchemaInput\)/);
assert.match(hints, /选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt/);
assert.doesNotMatch(hints, /不要写 expectedAnswerSchema\.choice/);
});
test("adopt continuation set-focus keeps the persisted reverse_verify question id", async () => {
const questionId = "reverse_verify:education_style:score";
const schema = {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
probe_year: 2016,
choice: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: STYLE_OPTIONS[0].label,
option_b: STYLE_OPTIONS[1].label,
option_c: STYLE_OPTIONS[2].label,
option_d: STYLE_OPTIONS[3].label,
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
};
const raw = distinguishDossier(activeFocusFixture({
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: schema,
}));
(raw.case as { accepted_time: string | null }).accepted_time = "05:06:00";
const store: { focus: ReturnType<typeof activeFocusFixture> | null } = {
focus: activeFocusFixture({
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: schema,
}),
};
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
set_agentic_rectification_conversation_focus: setFocusHandler(store),
});
const result = await (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
caseId: CASE_ID,
questionId,
intent: "reverse_verify",
spokenPrompt: "2016 年前后升学或学习环境有没有明显变化?",
}) as Record<string, unknown>;
assert.equal(result.error, undefined);
assert.equal(result.question_id, questionId);
assert.equal(result.focus_id, FOCUS_ID);
const copy = parseAgentChoiceCopy(result.expected_answer_schema);
assert.equal(copy?.options.length, 4);
const schemaRow = result.expected_answer_schema && typeof result.expected_answer_schema === "object"
? result.expected_answer_schema as Record<string, unknown>
: {};
assert.notEqual(schemaRow.semantic_key, RELOCATION_2015.semantic_key);
assert.equal(schemaRow.probe_year, 2016);
});