Files
Jyotisha/frontend/tests/rectification-server-focus.test.ts
T
Jesse_ChenandClaude Opus 5.5 8fc1a5bded fix(rectification): plain-word opening with examples in the stem, plain de-duplicated step receipt (BUG-1049, BUG-1050)
- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and
  an example answer again and is server-owned on the zero-evidence opening;
  the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no
  question). Stem de-dup compares whole sentences / near-equality instead of a
  12-char prefix, which had deleted the body's examples sentence since
  aa7ccb30 (BUG-604) + dd8f35f7 (BUG-648).
- BUG-1050: plain step labels; a finished step label shows once and
  「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the
  in-progress wording.
- Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated.
- VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist
  and screenshots.

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

1014 lines
37 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 {
COLLECT_FOCUS_RETRY_SUFFIX,
FOCUS_TARGET_KIND_CHECK,
collectFocusTargetKind,
openQuestionFromPersistedFocus,
persistableFocusDomain,
persistServerOwnedFocus,
shouldSkipDiscriminatorFollowup,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { buildChoiceFrame, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
buildMethodFollowupPlan,
exhaustionSpokenCollectFollowup,
spokenCollectFallbackFollowup,
spokenFollowupForUser,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
GENERIC_COLLECT_QUESTION,
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
} from "../src/lib/rectification-agentic/user-copy.ts";
import type { EventProbeStyleOption } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { fakeAccounting, CASE_ID, FOCUS_ID, USER_ID, activeFocusFixture } from "./rectification-v9-test-support.ts";
const DYNAMIC_STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" },
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
{ label: "明确没有发生", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
] as const;
function discriminatorFollowup(overrides: Partial<MethodFollowup> = {}): 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: DYNAMIC_STYLE_OPTIONS,
}],
});
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"] },
],
...overrides,
};
}
function invalidStyleFollowup(
styleOptions: readonly EventProbeStyleOption[] | undefined,
eventFamily = "升学结果或学习环境出现明显变化",
choiceKind: "existence" | "varga_style" | "event_quality" = "existence",
): MethodFollowup {
const valid = discriminatorFollowup();
return {
...valid,
choice_kind: choiceKind,
choice_frame: buildChoiceFrame({
method_id: "dasha_events",
ask_theme: "dated_event",
domain: "education",
user_prompt_hint: "ask",
choice_kind: choiceKind,
}, {
probes: [{
year: 2016,
year_label: "2016 年前后",
domain: "education",
event_family: eventFamily,
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",
choice_kind: choiceKind,
style_options: styleOptions,
}],
}),
};
}
async function assertUnrenderableDistinguishIsSkipped(followup: MethodFollowup) {
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
throw new Error("unrenderable distinguish must not persist as collect");
},
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(result.status, "skipped");
assert.equal(result.focus, null);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
}
function persistedFocus(questionId = "probe:education:2016") {
const copy = serverOwnedChoiceCopy(discriminatorFollowup().choice_frame!);
assert.ok(copy);
return {
id: FOCUS_ID,
caseId: CASE_ID,
questionId,
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "education",
targetKind: null,
expectedAnswerSchema: {
choice: copy,
semantic_key: "education:2016",
candidate_split_hash: "education:2016",
},
status: "active" as const,
askedAt: "2026-08-27T00:00:00.000Z",
resolvedAt: null,
};
}
test("only a successfully persisted focus can expose its discriminator question", () => {
const focus = persistedFocus();
for (const status of ["created", "already_open"] as const) {
assert.deepEqual(openQuestionFromPersistedFocus({
status,
focus,
questionId: focus.questionId,
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
}), {
question_id: focus.questionId,
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
status,
kind: "choice",
});
}
});
test("an unpersisted, mismatched, or incomplete focus cannot expose a discriminator question", () => {
const focus = persistedFocus();
assert.equal(openQuestionFromPersistedFocus({
status: "duplicate_focus",
focus,
questionId: focus.questionId,
prompt: "不能展示",
}), null);
assert.equal(openQuestionFromPersistedFocus({
status: "created",
focus: { ...focus, questionId: "another-question" },
questionId: focus.questionId,
prompt: "不能展示",
}), null);
const incomplete = openQuestionFromPersistedFocus({
status: "created",
focus: { ...focus, expectedAnswerSchema: { choice: { prompt: "不完整" } } },
questionId: focus.questionId,
prompt: "不能展示",
});
assert.equal(incomplete?.unrenderable, true);
assert.equal(incomplete?.prompt, null);
assert.equal(incomplete?.reason, "invalid_choice_schema");
});
test("complete dynamic event options persist a server-owned focus", async () => {
const followup = discriminatorFollowup({ source: "precision_stage", information_gain: 0.2 });
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: null,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-27T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(result.status, "created");
assert.equal(accounting.calls.length, 1);
assert.deepEqual(result.focus?.expectedAnswerSchema.choice, {
prompt: "2016 年前后,有没有升学结果或学习环境出现明显变化?",
option_a: DYNAMIC_STYLE_OPTIONS[0].label,
option_b: DYNAMIC_STYLE_OPTIONS[1].label,
option_c: DYNAMIC_STYLE_OPTIONS[2].label,
option_d: DYNAMIC_STYLE_OPTIONS[3].label,
options: [
{ key: "A", label: DYNAMIC_STYLE_OPTIONS[0].label, answer_class: "yes" },
{ key: "B", label: DYNAMIC_STYLE_OPTIONS[1].label, answer_class: "weak_yes" },
{ key: "C", label: DYNAMIC_STYLE_OPTIONS[2].label, answer_class: "no" },
{ key: "D", label: DYNAMIC_STYLE_OPTIONS[3].label, answer_class: "unsure" },
],
});
});
test("non-renderable varga styles and empty event family skip distinguish instead of spoken collect", async () => {
await assertUnrenderableDistinguishIsSkipped(invalidStyleFollowup(
[{ label: "巨蟹相处主动热情", answer_class: "yes" }],
"升学结果或学习环境出现明显变化",
"varga_style",
));
await assertUnrenderableDistinguishIsSkipped(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS, " "));
});
test("does not ask an already-answered discriminator probe again", async () => {
const followup = discriminatorFollowup();
const active = activeFocusFixture({
expectedAnswerSchema: { probe_id: "education:2016", choice: serverOwnedChoiceCopy(followup.choice_frame!) },
});
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
throw new Error("should not create a second focus");
},
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: {
...active,
id: FOCUS_ID,
caseId: CASE_ID,
questionId: stableFollowupQuestionId(followup),
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "education",
targetKind: null,
expectedAnswerSchema: { probe_id: "education:2016" },
status: "active",
askedAt: "2026-08-24T00:00:00.000Z",
resolvedAt: null,
},
decisionReceipt: {
inference_state: {
answered_probes: [{ probe_id: "education:2016", semantic_key: "education:2016", answer_class: "yes" }],
probes: [],
},
},
followup,
});
assert.equal(result.status, "probe_already_answered");
assert.equal(accounting.calls.length, 0);
});
test("zero information gain does not open a discriminator", () => {
assert.equal(
shouldSkipDiscriminatorFollowup(discriminatorFollowup({ information_gain: 0 })),
"zero_information_gain",
);
});
test("recorded event quality cannot open a zero-information scoring card", () => {
assert.equal(
shouldSkipDiscriminatorFollowup(discriminatorFollowup({
choice_kind: "event_quality",
information_gain: 0,
})),
"zero_information_gain",
);
});
test("an unmatched scoring identity is skipped instead of being synthesized", async () => {
const followup = discriminatorFollowup({
semantic_key: "education.synthetic",
candidate_split_hash: "education:synthetic",
});
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
throw new Error("must not persist an unmatched scoring focus");
},
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: {
inference_state: {
algorithm_version: "birth-time-event-scoring-v1",
candidates: [
{ id: "05:00", birth_time: "05:00", posterior: 0.5 },
{ id: "05:20", birth_time: "05:20", posterior: 0.5 },
],
answered_probes: [],
probes: [{
id: "p-live",
semantic_key: "education.live",
candidate_split_hash: "education:live",
domain: "education",
year: 2016,
question: "live",
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"] },
],
information_gain: 0.2,
source: "dasha_activation",
}],
},
},
followup,
});
assert.equal(result.status, "invalid_choice_schema");
assert.equal(accounting.calls.length, 0);
});
test("duplicate focus conflict does not throw", async () => {
const followup = discriminatorFollowup({ source: "precision_stage", information_gain: 0.2 });
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
writes += 1;
throw new Error("agentic_rectification_focus_idempotency_conflict");
},
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(result.status, "duplicate_focus");
assert.equal(writes, 1);
assert.equal(result.questionId?.includes(":next"), false);
});
test("contrast probe is not replaced by an already-answered education quality probe", async () => {
const followup = discriminatorFollowup({
domain: "education",
ask_theme: "education_style",
information_gain: 0.16,
semantic_key: "varga.d24.05:00/05:06|05:07",
candidate_split_hash: "varga.d24.05:00/05:06|05:07",
probe_year: undefined,
});
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: null,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-25T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: {
inference_state: buildInferenceState({
range_start: "05:00",
range_end: "05:20",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 10 },
{ id: "05:20", time: "05:20", relative_support: 10 },
],
events: [],
probes: [{
id: "probe:education.2016",
semantic_key: "education.2016",
candidate_split_hash: "education:2016",
domain: "education",
year: 2016,
question: "发挥失常",
candidate_ids: [],
expected_outcomes: [],
information_gain: 0,
source: "known_event_quality",
}, {
id: "contrast:varga.d24.05:00/05:06|05:07",
semantic_key: "varga.d24.05:00/05:06|05:07",
candidate_split_hash: "varga.d24.05:00/05:06|05:07",
domain: "education",
year: 0,
question: "学业盘候选差异",
candidate_ids: ["05:00", "05:06", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] },
{ answer_class: "no", supports: ["05:06", "05:07"], conflicts: ["05:00"] },
],
information_gain: 0.16,
source: "varga_contrast",
}],
}),
},
followup,
});
assert.equal(result.status, "created");
assert.ok(result.focus);
const schema = result.focus.expectedAnswerSchema;
assert.equal(schema.semantic_key, "varga.d24.05:00/05:06|05:07");
assert.notEqual(schema.probe_id, "probe:education.2016");
assert.match(String(schema.probe_id), /varga\.d24/);
});
test("spoken collect followup persists a collect focus without a choice card", async () => {
const followup: MethodFollowup = {
method_id: "relatives",
intent: "collect_method_evidence",
ask_theme: "family_event",
domain: "family",
kind_hint: "family_event",
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
probe_year: 2021,
year_label: "2021 年前后",
semantic_key: "family.2021",
};
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: null,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-27T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(result.status, "created");
assert.equal(result.focus?.intent, "collect_method_evidence");
assert.equal(result.focus?.expectedAnswerSchema.collect, true);
assert.equal(result.focus?.expectedAnswerSchema.choice, undefined);
// 原值: 带 2021 年前后线索
// 新值: 年段不入口述题干,仍无选项卡
// 原因: 撤回 BUG-642(BUG-648)
assert.equal(result.focus?.expectedAnswerSchema.prompt, USER_COLLECT_QUESTION.family);
const openCollect = openQuestionFromPersistedFocus(result);
assert.equal(openCollect?.kind, "collect_spoken");
assert.equal(openCollect?.prompt, USER_COLLECT_QUESTION.family);
assert.doesNotMatch(openCollect?.prompt ?? "", /2021 年前后/);
assert.notEqual(openCollect?.unrenderable, true);
});
test("zero-evidence opening persists the server-owned first question", async () => {
const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup;
if (!followup) throw new Error("zero-evidence opening must produce a follow-up");
// 旧:开场 domain 落成 unknown,后一度写成 education。
// 新:开场是「随便一件带时间的经历」,domain = other。
// 保留语义:仍是 collect_spoken,仍由服务端 persist,Agent 不自拟开场题,id 不含 unknown。
assert.equal(followup.domain, "other");
assert.equal(followup.source, "method_coverage");
assert.doesNotMatch(stableFollowupQuestionId(followup), /unknown/);
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: 开场仍用 domain=other,GENERIC 题干;稳定 id 不得含 unknown
assert.equal(stableFollowupQuestionId(followup), "collect:other:collect_method_evidence");
// 旧:开场 GENERIC 带「比如哪年上的大学…」→ 新:题干只邀请一两件,领域清单在开场正文
// 原因:BUG-604
assert.equal(spokenFollowupForUser(followup), GENERIC_COLLECT_QUESTION);
assert.doesNotMatch(spokenFollowupForUser(followup) ?? "", /^也可以再/);
// 原值: /先说你最容易想起的一两件/ / 新值: /先说一两件你记得的大事,比如/ / 原因: BUG-1049(复发自 BUG-504)开场题干带例子
assert.match(spokenFollowupForUser(followup) ?? "", /先说一两件你记得的大事,比如/);
const expected = GENERIC_COLLECT_QUESTION;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: null,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-31T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
const open = openQuestionFromPersistedFocus(persisted);
assert.deepEqual({
narration: spokenFollowupForUser(followup),
persistedPrompt: persisted.focus?.expectedAnswerSchema.prompt,
openKind: open?.kind,
openPrompt: open?.prompt,
}, {
narration: expected,
persistedPrompt: expected,
openKind: "collect_spoken",
openPrompt: expected,
});
assert.equal(persisted.focus?.targetDomain, "other");
});
test("exhaustion other fallback and other retry keep the after-evidence copy", () => {
const dated = (domain: string, year: string, extra: { eventKind?: string } = {}) => ({
status: "confirmed" as const,
domain,
datePrecision: extra.eventKind ? "unknown" as const : "year" as const,
occurredFrom: extra.eventKind ? null : `${year}-01-01`,
occurredTo: null,
...(extra.eventKind ? { eventKind: extra.eventKind } : {}),
});
const exhausted = exhaustionSpokenCollectFollowup({
evidence: [
dated("education", "2016"),
dated("career", "2020"),
dated("relationship", "2018"),
dated("finance", "2024"),
dated("relocation", "2022"),
dated("health_pressure", "2021"),
dated("occupation", "2020", { eventKind: "occupation_note" }),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
// 原值: domain=other / USER_COLLECT_QUESTION.other
// 新值: null
// 原因: 穷尽后交付,other 只留给开场。
assert.equal(exhausted, null);
const retry: MethodFollowup = {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "other",
kind_hint: null,
user_prompt_hint: "retry",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
collect_retry: true,
};
// 原值: USER_COLLECT_QUESTION_RETRY.other
// 新值: null
// 原因: BUG-586 删除 other 补问文案,非开场 other 按无可问处理
assert.equal(spokenFollowupForUser(retry), null);
});
test("GENERIC_COLLECT_QUESTION invites one or two memories without listing years", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
// 旧:从你最容易想起来的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家
// 新:先说你最容易想起的一两件,年月大概就行。领域清单在开场正文。
// 原因:BUG-604 决策 1,题干不再列年份例子
// 原值: "先说你最容易想起的一两件,年月大概就行。",断言不含「比如」、不含任何四位年份
// 新值: 带六个例子(不带年份)+ 一个固定示例回答「2015 年夏天换了工作」;含「比如」「例如」;唯一的年份在示例回答的「」里
// 原因: BUG-1049(复发自 BUG-504,防复发「开场题文案必须带具体例子」):BUG-604 把例子挪进正文后被题干去重删掉,用户看到的开场没有例子。产品 2026-09-26 D1 决定例子回到题干并加示例回答
assert.equal(
GENERIC_COLLECT_QUESTION,
"先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。",
);
assert.match(GENERIC_COLLECT_QUESTION, /比如/);
assert.match(GENERIC_COLLECT_QUESTION, /例如/);
for (const example of ["上大学", "第一份工作", "搬到别的城市", "谈恋爱或结婚", "家里添丁", "生病住院"]) {
assert.ok(GENERIC_COLLECT_QUESTION.includes(example), example);
}
assert.deepEqual(GENERIC_COLLECT_QUESTION.match(/(?:19|20)\d{2}/g), ["2015"]);
assert.match(GENERIC_COLLECT_QUESTION, /「2015 年夏天换了工作」/);
assert.match(source, /GENERIC_COLLECT_QUESTION = "先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。"/);
});
test("degraded spoken collect does not keep discriminator identity or block a later card", async () => {
const discriminator = discriminatorFollowup({
year_label: "2016 年前后",
probe_id: "probe:education:2016",
});
const spokenFollowup = spokenCollectFallbackFollowup(discriminator);
const spoken = spokenFollowupForUser(spokenFollowup);
assert.equal("semantic_key" in spokenFollowup, false);
assert.equal("candidate_split_hash" in spokenFollowup, false);
assert.equal("probe_year" in spokenFollowup, false);
assert.equal("year_label" in spokenFollowup, false);
assert.equal("probe_id" in spokenFollowup, false);
assert.equal(stableFollowupQuestionId(spokenFollowup).includes("education:2016"), false);
assert.doesNotMatch(spoken ?? "", /2016/);
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: args.p_intent === "collect_method_evidence"
? FOCUS_ID
: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: null,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-27T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const collected = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: {
...spokenFollowup,
user_prompt_hint: spoken ?? spokenFollowup.user_prompt_hint,
},
});
assert.equal(collected.status, "created");
assert.equal(collected.questionId?.includes("education:2016"), false);
assert.equal(collected.focus?.expectedAnswerSchema.semantic_key, undefined);
assert.doesNotMatch(String(collected.focus?.expectedAnswerSchema.prompt ?? ""), /2016/);
assert.equal(collected.focus?.expectedAnswerSchema.prompt, spoken);
const openCollect = openQuestionFromPersistedFocus(collected);
assert.equal(openCollect?.kind, "collect_spoken");
assert.equal(openCollect?.prompt, spoken);
assert.notEqual(openCollect?.unrenderable, true);
const card = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: collected.focus ?? null,
decisionReceipt: null,
followup: discriminator,
});
assert.equal(card.status, "created");
assert.ok(card.focus?.expectedAnswerSchema.choice);
const open = openQuestionFromPersistedFocus(card);
assert.ok(open);
assert.notEqual(open?.unrenderable, true);
});
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "relationship",
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
...overrides,
};
}
function focusRowFromArgs(args: Record<string, unknown>) {
return {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-31T00:00:00.000Z",
resolved_at: null,
idempotent: false,
};
}
test("collect persist maps occupation to other and health_pressure to health", async () => {
assert.equal(persistableFocusDomain("occupation"), "other");
assert.equal(persistableFocusDomain("health_pressure"), "health");
assert.equal(persistableFocusDomain("education"), "education");
// Was: persistableFocusDomain("horary") === "horary".
// Now: null. DB check excludes horary; the old assertion locked a write that always failed.
assert.equal(persistableFocusDomain("horary"), null);
assert.equal(persistableFocusDomain("appearance"), null);
assert.equal(persistableFocusDomain("nakshatra"), null);
assert.equal(persistableFocusDomain(null), null);
assert.equal(persistableFocusDomain("unknown"), null);
// 旧 collect:education → 新 collect:other → 保留 unknown/null 不得写入 unknown
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: unknown/null 采集仍归一到开场 other id,不得写入 unknown
assert.equal(stableFollowupQuestionId(collectFollowup({ domain: "unknown" })), "collect:other:collect_method_evidence");
assert.equal(stableFollowupQuestionId(collectFollowup({ domain: null })), "collect:other:collect_method_evidence");
const occupation = collectFollowup({
method_id: "occupation",
ask_theme: "occupation",
domain: "occupation",
kind_hint: "occupation_note",
});
assert.equal(stableFollowupQuestionId(occupation), "collect:occupation:collect_method_evidence");
const health = collectFollowup({
method_id: "d30_health",
ask_theme: "health_pressure",
domain: "health_pressure",
});
const generic = collectFollowup({ domain: "other" });
assert.equal(stableFollowupQuestionId(generic), "collect:other:collect_method_evidence");
assert.doesNotMatch(stableFollowupQuestionId(generic), /unknown/);
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => focusRowFromArgs(args),
});
const occupationPersisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: occupation,
});
const healthPersisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: health,
});
const occupationWrite = accounting.calls.find((item) => (
item.fn === "set_agentic_rectification_conversation_focus"
&& String(item.args.p_question_id).startsWith("collect:occupation:")
));
const healthWrite = accounting.calls.find((item) => (
item.fn === "set_agentic_rectification_conversation_focus"
&& String(item.args.p_question_id).startsWith("collect:health_pressure:")
));
assert.equal(occupationPersisted.status, "created");
assert.equal(healthPersisted.status, "created");
assert.equal(occupationWrite?.args.p_target_domain, "other");
assert.equal(healthWrite?.args.p_target_domain, "health");
});
test("collect focus unique conflict retries with a :next question id", async () => {
// 原值: 仅 collect_retry 才 :next;否则 duplicate_focus、RPC 一次
// 新值: 采集题撞号一律试 :next / :next2 / :next3;collect_retry 只换文案
// 原因: BUG-644 被顶替的采集题号仍占 unique,没问过也必须能再问
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
if (writes === 1) throw new Error("agentic_rectification_focus_idempotency_conflict");
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup(),
});
assert.equal(persisted.status, "created");
assert.equal(writes, 2);
assert.equal(
persisted.questionId,
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION.relationship);
assert.notEqual(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship);
});
test("collect_retry unique conflict still retries with a :next question id", async () => {
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
if (writes === 1) throw new Error("agentic_rectification_focus_idempotency_conflict");
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({ collect_retry: true }),
});
assert.equal(persisted.status, "created");
assert.equal(writes, 2);
assert.equal(
persisted.questionId,
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship);
const retryWrite = accounting.calls.at(-1);
assert.equal(
retryWrite?.args.p_question_id,
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
);
});
test("appearance distinguish without a frame is skipped with zero RPC", async () => {
const followup: MethodFollowup = {
method_id: "nakshatra_boundary",
intent: "distinguish_candidates",
ask_theme: "nakshatra_trait",
domain: "appearance",
kind_hint: null,
user_prompt_hint: "外表或体质更接近哪一种?",
must_not_label: false,
choice_frame: null,
source: "nakshatra_boundary",
};
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
throw new Error("appearance collect must not be written");
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(persisted.status, "skipped");
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
test("frameless precision-stage distinguish is skipped instead of opening GENERIC", async () => {
const followup: MethodFollowup = {
method_id: "dasha_events",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: "窗口里本命上升还可能落在两段。",
must_not_label: false,
choice_frame: null,
source: "precision_stage",
};
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
throw new Error("frameless distinguish must not be written");
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(persisted.status, "skipped");
assert.equal(spokenFollowupForUser(followup), null);
assert.notEqual(
spokenFollowupForUser({
...spokenCollectFallbackFollowup(followup),
}, [{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
}]),
GENERIC_COLLECT_QUESTION,
);
});
function targetKindCheckFromSql(): string[] {
const sql = readFileSync(new URL(
"../supabase/migrations/20260814020000_rectification_v10_runtime.sql",
import.meta.url,
), "utf8");
const match = /target_kind text check \(\s*target_kind is null or target_kind in \(\s*([\s\S]*?)\)\s*\)/.exec(sql);
assert.ok(match?.[1], "target_kind CHECK missing");
return [...match[1].matchAll(/'([a-z_]+)'/g)].map((item) => item[1]);
}
test("T1: collect focus kinds map to the table CHECK enum", async () => {
const allowed = targetKindCheckFromSql();
assert.deepEqual([...FOCUS_TARGET_KIND_CHECK], allowed);
const cases: ReadonlyArray<{
kind_hint: string;
domain: string;
collectKind: string;
}> = [
{ kind_hint: "targeted:family", domain: "family", collectKind: "targeted:family" },
{ kind_hint: "anchor:education_start:2016", domain: "education", collectKind: "anchor:education_start:2016" },
{ kind_hint: "generic:relationship", domain: "relationship", collectKind: "generic:relationship" },
];
for (const item of cases) {
const mapped = collectFocusTargetKind({ kind_hint: item.kind_hint, domain: item.domain });
assert.ok(mapped && allowed.includes(mapped), `${item.kind_hint} -> ${mapped}`);
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => focusRowFromArgs(args),
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({
domain: item.domain,
kind_hint: item.kind_hint,
collection_key: item.kind_hint.startsWith("targeted:")
? `collect:targeted:${item.domain}`
: undefined,
}),
});
assert.equal(persisted.status, "created", item.kind_hint);
assert.ok(
persisted.focus?.targetKind && allowed.includes(persisted.focus.targetKind),
`${item.kind_hint} wrote ${persisted.focus?.targetKind}`,
);
assert.equal(persisted.focus?.expectedAnswerSchema.collect_kind, item.collectKind);
assert.equal(accounting.calls[0]?.args.p_target_kind, persisted.focus?.targetKind);
}
const inviteMapped = collectFocusTargetKind({ kind_hint: "invite_more", domain: "other" });
assert.equal(inviteMapped, null);
const inviteAccounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => focusRowFromArgs(args),
});
const invitePersisted = await persistServerOwnedFocus({
accounting: inviteAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({
domain: "other",
kind_hint: "invite_more",
collection_key: "collect:invite:more",
spoken_prompt: "还有吗?把能想起来的都说一声。",
}),
});
assert.equal(invitePersisted.status, "created");
assert.equal(invitePersisted.focus?.targetKind, null);
assert.equal(invitePersisted.focus?.expectedAnswerSchema.collect_kind, "invite_more");
});