Stop domain-wheel collecting and age-band years in prompts. Ask until the training gate, then discriminate until convergence, then deliver a range plus a concrete follow-up. Reserve holdout only with four dated events. Co-authored-by: Cursor <cursoragent@cursor.com>
212 lines
7.3 KiB
TypeScript
212 lines
7.3 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
templatePostAdoptExplain,
|
||
validateAdoptNarration,
|
||
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
|
||
import {
|
||
buildMethodFollowupPlan,
|
||
buildNextUserAction,
|
||
spokenFollowupForUser,
|
||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import {
|
||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
rectificationQuestionGapState,
|
||
rectificationQuestionRetryActive,
|
||
type RectificationQuestionGapInput,
|
||
} from "../src/lib/rectification-surface-state.ts";
|
||
|
||
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 },
|
||
];
|
||
|
||
function verifyProbe(input: {
|
||
domain: "finance" | "relocation";
|
||
year: number;
|
||
semantic: string;
|
||
split: string;
|
||
}) {
|
||
return {
|
||
year: input.year,
|
||
year_label: `${input.year} 年前后`,
|
||
domain: input.domain,
|
||
event_family: input.domain === "finance"
|
||
? "收入明显变化、大笔支出或欠债"
|
||
: "搬家或长期住到外地",
|
||
source: "dasha_activation" as const,
|
||
tracks: ["vimshottari", "narayana"] as const,
|
||
tracks_agree: true,
|
||
unique_minute_claim: false as const,
|
||
user_meaning: `${input.year} 年前后是否有相关经历。`,
|
||
role: "distinguish" as const,
|
||
style_options: STYLE_OPTIONS,
|
||
semantic_key: input.semantic,
|
||
candidate_split_hash: input.split,
|
||
};
|
||
}
|
||
|
||
const FINANCE_2013 = verifyProbe({
|
||
domain: "finance",
|
||
year: 2013,
|
||
semantic: "finance.2013.verify",
|
||
split: "finance-2013-split",
|
||
});
|
||
const RELOC_2017 = verifyProbe({
|
||
domain: "relocation",
|
||
year: 2017,
|
||
semantic: "relocation.2017.verify",
|
||
split: "reloc-2017-split",
|
||
});
|
||
|
||
const EDUCATION_EVIDENCE = [{
|
||
status: "confirmed" as const,
|
||
domain: "education",
|
||
datePrecision: "year" as const,
|
||
occurredFrom: "2008-01-01",
|
||
occurredTo: null,
|
||
}];
|
||
|
||
const gapBase: RectificationQuestionGapInput = {
|
||
liveQuestionVisible: false,
|
||
questionMissing: true,
|
||
questionLoadFailed: false,
|
||
busy: false,
|
||
readonly: false,
|
||
regenerating: false,
|
||
snapshotLoaded: true,
|
||
resumableCase: true,
|
||
retryAttempts: 0,
|
||
};
|
||
|
||
test("post-adopt reverse-verify skips a probe already present in askedKeys", () => {
|
||
const remaining = buildMethodFollowupPlan({
|
||
evidence: EDUCATION_EVIDENCE,
|
||
accepted: true,
|
||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||
askedProbeKeys: [FINANCE_2013.semantic_key],
|
||
});
|
||
assert.equal(remaining.next_followup?.domain, "relocation");
|
||
assert.equal(remaining.next_followup?.semantic_key, RELOC_2017.semantic_key);
|
||
|
||
const none = buildMethodFollowupPlan({
|
||
evidence: EDUCATION_EVIDENCE,
|
||
accepted: true,
|
||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||
askedProbeKeys: [FINANCE_2013.semantic_key, RELOC_2017.semantic_key],
|
||
});
|
||
assert.equal(none.next_followup, null);
|
||
const action = buildNextUserAction({
|
||
scorableCount: 1,
|
||
evidenceCount: 1,
|
||
hasLatestResult: true,
|
||
selectionAllowed: true,
|
||
sessionOutcome: "adopt_representative",
|
||
nextFollowup: none.next_followup,
|
||
workingTime: "05:07",
|
||
accepted: true,
|
||
});
|
||
assert.equal(action.id, "start_consultation");
|
||
});
|
||
|
||
test("post-adopt reverse-verify also dedupes by candidate_split_hash", () => {
|
||
const plan = buildMethodFollowupPlan({
|
||
evidence: EDUCATION_EVIDENCE,
|
||
accepted: true,
|
||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||
askedProbeKeys: [FINANCE_2013.candidate_split_hash],
|
||
});
|
||
assert.equal(plan.next_followup?.domain, "relocation");
|
||
assert.notEqual(plan.next_followup?.semantic_key, FINANCE_2013.semantic_key);
|
||
});
|
||
|
||
test("empty reverse-verify list forbids a 会拿…核对 promise", () => {
|
||
const facts = {
|
||
precision_stage: "ready_to_adopt" as const,
|
||
already_accepted: false,
|
||
credible_range: ["05:00", "05:06"] as const,
|
||
representative_minute: "05:00",
|
||
runner_up_minute: "05:06",
|
||
opening_window: ["04:45", "05:15"] as const,
|
||
active_candidates: [],
|
||
answered_rounds: 5,
|
||
stop_facts: [],
|
||
post_adopt_verification: [],
|
||
minutes: ["05:00", "05:06"],
|
||
years: [],
|
||
};
|
||
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
|
||
const denied = validateAdoptNarration("采用后会拿 2016 年教育题来核对。", facts);
|
||
assert.equal(denied.ok, false);
|
||
if (!denied.ok) assert.equal(denied.reason, "promise");
|
||
});
|
||
|
||
test("question gap: start_consultation is verified_idle; missing next_user_action keeps the old path", () => {
|
||
assert.equal(rectificationQuestionGapState({
|
||
...gapBase,
|
||
nextUserActionId: "start_consultation",
|
||
}), "verified_idle");
|
||
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
|
||
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
|
||
assert.equal(rectificationQuestionGapState({
|
||
...gapBase,
|
||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
}), "unavailable");
|
||
});
|
||
|
||
test("verified_idle UI is a closing line without reload, gap copy, or consult handoff", () => {
|
||
const chat = readFileSync(
|
||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
assert.match(chat, /questionGap === "verified_idle"/);
|
||
assert.match(chat, /postAdoptVerifyDoneCopy/);
|
||
assert.match(chat, /skip_this_probe/);
|
||
assert.match(chat, /SKIP_PROBE_ACTION/);
|
||
assert.doesNotMatch(chat, /rectification-consult-handoff/);
|
||
const idle = chat.slice(chat.indexOf('questionGap === "verified_idle"'));
|
||
assert.doesNotMatch(idle.slice(0, 400), /rectification-question-gap__copy/);
|
||
assert.doesNotMatch(idle.slice(0, 400), /RECTIFICATION_QUESTION_RELOAD_LABEL/);
|
||
// 原值: 状态条「已采用 {savedTime}」+「之后新建对话即按此时间排盘」
|
||
// 新值: verified_idle 走 postAdoptVerifyDoneCopy;无核对题用新句
|
||
// 原因: BUG-595 决策 3
|
||
assert.doesNotMatch(chat, /之后新建对话即按此时间排盘/);
|
||
});
|
||
|
||
test("family collect spoken stem uses a year cue while probe_year stays dated", () => {
|
||
const plan = buildMethodFollowupPlan({
|
||
evidence: [
|
||
{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2011-01-01", occurredTo: null },
|
||
{ status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2014-01-01", occurredTo: null },
|
||
],
|
||
evidenceCollectionProbes: [{
|
||
year: 2021,
|
||
year_label: "2021 年前后",
|
||
domain: "family",
|
||
event_family: "家人结婚、添丁或住院",
|
||
source: "age_band",
|
||
tracks: ["vimshottari", "narayana"],
|
||
tracks_agree: false,
|
||
unique_minute_claim: false,
|
||
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
|
||
role: "collect",
|
||
phase: "evidence_collection",
|
||
information_gain: 0,
|
||
semantic_key: "family.2021",
|
||
}],
|
||
});
|
||
// 原值: family + 2021 年前后
|
||
// 新值: 训练门关时邀请优先,题干不含年龄段年份
|
||
// 原因: 撤回 BUG-642(BUG-648)
|
||
assert.equal(plan.next_followup?.domain, "other");
|
||
assert.equal(plan.next_followup?.collection_key, "collect:invite:more");
|
||
assert.equal(plan.next_followup?.choice_frame, null);
|
||
const spoken = spokenFollowupForUser(plan.next_followup);
|
||
assert.match(spoken ?? "", /还有吗/);
|
||
assert.doesNotMatch(spoken ?? "", /2021 年前后/);
|
||
});
|