0be51e65f5
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>
706 lines
28 KiB
TypeScript
706 lines
28 KiB
TypeScript
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.family(3847e9c9;45bdb63e 为「哪年上的大学」)
|
||
// 新值: 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);
|
||
});
|