fix(rectification): invite-first collect, holdout at 4 events, Skill 10.0.23 (BUG-646–648)

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>
This commit is contained in:
Jesse_Chen
2026-09-11 01:43:02 +08:00
co-authored by Cursor
parent cb04529728
commit dd8f35f7ba
70 changed files with 2576 additions and 1183 deletions
+163 -244
View File
@@ -13,7 +13,7 @@ import {
parseWindowScan,
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { GENERIC_COLLECT_QUESTION, collectQuestionForDomain, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
@@ -101,6 +101,25 @@ const CLASSIC_COVERAGE = [
{ status: "confirmed", domain: "horary", datePrecision: "day" as const, occurredFrom: "2024-01-01", occurredTo: null },
];
function assertInviteCollect(plan: ReturnType<typeof buildMethodFollowupPlan>) {
// 原值: 训练门关时按感情 → 事业 → 家人轮转
// 新值: 收集池首条是「还有吗」邀请
// 原因: 用户先说完,再从已说的事追问(BUG-648)
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "dasha_events");
assert.equal(plan.next_followup?.domain, "other");
assert.equal(plan.next_followup?.collection_key, "collect:invite:more");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
}
function assertNoLeftoverDatedCollect(plan: ReturnType<typeof buildMethodFollowupPlan>) {
// 原值: 训练门开后仍按未覆盖领域采集
// 新值: 训练门开后不再轮转采集
// 原因: S2/S3 走选择题或交付(BUG-648
assert.notEqual(plan.next_followup?.intent, "collect_method_evidence");
}
function datedEvidence(
domain: string,
year: string,
@@ -420,11 +439,7 @@ test("eight-method routing asks relationship after dated education, not relocati
occurredTo: null,
}],
});
// 原值: relatives / family
// 新值: d9_relationship / relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.domain, "relationship");
assertInviteCollect(plan);
assert.equal(plan.stop_domain_rotation, true);
assert.deepEqual([...plan.not_in_rotation], ["relocation"]);
assert.equal(plan.methods.find((item) => item.method_id === "appearance")?.status, "skipped_by_policy");
@@ -435,12 +450,10 @@ test("dasha conflict probe does not jump ahead of method rotation before accepta
evidence: [datedEvidence("education", "2016")],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
// 原值: method_coverage / relatives 或 d9_relationship
// 新值: 邀请「还有吗」,选择题不得抢在训练门前
// 原因: 收集池在训练门开之前优先邀请(BUG-648)
assertInviteCollect(plan);
assert.notEqual(plan.next_followup?.source, "event_probe");
});
@@ -449,12 +462,7 @@ test("known exam quality does not create a scoring card after one recorded event
evidence: [datedEvidence("education", "2016")],
eventProbes: [EDUCATION_QUALITY_PROBE, CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assertInviteCollect(plan);
assert.notEqual(plan.next_followup?.choice_kind, "event_quality");
});
@@ -466,12 +474,7 @@ test("career known-event quality does not jump the adoption gate", () => {
],
eventProbes: [CAREER_QUALITY_PROBE, CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assertInviteCollect(plan);
assert.notEqual(plan.next_followup?.source, "event_probe");
assert.notEqual(plan.next_followup?.choice_kind, "event_quality");
});
@@ -481,12 +484,7 @@ test("encoded exam quality does not stamp another card and keeps method rotation
evidence: [datedEvidence("education", "2016", { summary: "2016年高考发挥异常" })],
eventProbes: [EDUCATION_QUALITY_PROBE, CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assertInviteCollect(plan);
});
test("dasha conflict probe jumps after four scoreable events leave three training domains", () => {
@@ -549,12 +547,7 @@ test("three scoreable events in one domain still rotate methods instead of rever
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assertInviteCollect(plan);
});
test("occupation_note does not count toward reverse-inference event quality", () => {
@@ -573,12 +566,7 @@ test("occupation_note does not count toward reverse-inference event quality", ()
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assertInviteCollect(plan);
});
test("age-band probe does not jump ahead of uncovered relationship", () => {
@@ -603,12 +591,7 @@ test("age-band probe does not jump ahead of uncovered relationship", () => {
role: "reverse_verify",
}],
});
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.choice_frame, null);
assertInviteCollect(plan);
});
test("user-stop action records stated events when the ledger is empty", () => {
@@ -663,13 +646,8 @@ test("user-stop action offers a range when candidates already exist", () => {
assert.equal(plan.methods.find((item) => item.method_id === "marks")?.status, "skipped_by_policy");
assert.equal(plan.methods.find((item) => item.method_id === "horary")?.status, "uncovered");
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.match(plan.next_followup?.user_prompt_hint ?? "", /自然语言/);
assertInviteCollect(plan);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/);
assert.equal(plan.next_followup?.choice_frame, null);
assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY);
});
@@ -692,10 +670,7 @@ test("selectionAllowed with remaining method follow-up keeps collecting and offe
nextFollowup: plan.next_followup,
workingTime: "05:07",
});
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assertInviteCollect(plan);
assert.equal(action.id, "ask_method_followup");
assert.equal(action.on_user_stop.id, "offer_provisional_range");
});
@@ -711,11 +686,10 @@ test("adopt_representative still asks remaining evidence collect this turn", ()
}],
sessionOutcome: "adopt_representative",
});
// 原值: next_followup null,感情采集进 deferred
// 新值: 本轮仍问感情采集
// 原因: 家人之后还有能区分分钟的经历时,不得因 canAdopt 早退BUG-546
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
// 原值: 本轮仍问感情采集
// 新值: 训练门关时邀请不得因 adopt 早退
// 原因: 邀请也是收集池条目BUG-648
assertInviteCollect(plan);
assert.equal(plan.deferred_followup, null);
assert.equal(plan.session_outcome, "adopt_representative");
});
@@ -730,11 +704,10 @@ test("adopt_representative ignores leftover distinguish focus", () => {
targetKind: "home_change",
},
});
// 原值: next_followup null(采用早退)
// 新值: 丢掉无用区分焦点后仍收财务经历
// 原因: 未用的 dated collect 不得被 adopt 早退吃掉BUG-546
assert.equal(plan.next_followup?.domain, "finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
// 原值: 丢掉无用区分焦点后仍收财务经历
// 新值: 训练门开后不再轮转采集
// 原因: S3 交付,不再用未覆盖领域挡住采用BUG-648
assertNoLeftoverDatedCollect(plan);
assert.notEqual(plan.next_followup?.intent, "distinguish_candidates");
assert.equal(plan.session_outcome, "adopt_representative");
});
@@ -750,11 +723,10 @@ test("declined relationship skips to career and leaves horary uncovered", () =>
}],
declinedTopics: [{ target_domain: "relationship", status: "declined" }],
});
// 原值: relatives / family
// 新值: d10_career / career
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.equal(plan.next_followup?.domain, "career");
// 原值: d10_career / career
// 新值: 拒答感情不得跳过邀请
// 原因: 收集池邀请在产出期间排第一(BUG-648)
assertInviteCollect(plan);
assert.deepEqual([...plan.do_not_poll], []);
assert.equal(plan.methods.find((item) => item.method_id === "horary")?.status, "uncovered");
});
@@ -802,11 +774,10 @@ test("D9 differ keeps sign names for the type-table report and still forbids uni
evidence: CLASSIC_COVERAGE,
observations,
});
// 原值: next_followup null
// 新值: 财务采集
// 原因: 经典八法覆盖后仍收未用带年份域,D9 观察不再抢在前面BUG-546
assert.equal(plan.next_followup?.method_id, "d2_finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
// 原值: 财务采集
// 新值: 训练门开后不再轮转采集
// 原因: 经典八法覆盖后进 S2/S3BUG-648
assertNoLeftoverDatedCollect(plan);
assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY);
});
@@ -843,10 +814,9 @@ test("D24-only window change folds into education follow-up without a second lay
observations,
});
// 原值: d5_education / education_styleD24 观察)
// 新值: d2_finance 采集
// 原因: 家人之后先收未用带年份经历,再进分盘观察BUG-546
assert.equal(plan.next_followup?.method_id, "d2_finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
// 新值: 训练门开后不再轮转采集
// 原因: 家人之后先收未用带年份经历的口径已撤回BUG-648
assertNoLeftoverDatedCollect(plan);
});
test("D11-only window change folds into finance follow-up without delaying adopt", () => {
@@ -891,12 +861,10 @@ test("D11-only window change folds into finance follow-up without delaying adopt
],
observations,
});
// 原值: d2_finance / varga_observation(不挡采用)
// 新值: d4_home 搬家采集
// 原因: 财务已有带年份证据后,下一未用域是搬家(BUG-546
assert.equal(plan.next_followup?.method_id, "d4_home");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.source, "method_coverage");
// 原值: d4_home 搬家采集
// 新值: 训练门开后不再轮转采集
// 原因: 财务已有带年份证据后也不再盘问搬家(BUG-648
assertNoLeftoverDatedCollect(plan);
});
test("window scan displays KP sub-lord changes without opening confirmation", () => {
@@ -986,7 +954,7 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin
execute(input: unknown): Promise<{
conversation_summary: { missing_evidence_categories: string[] };
method_followup_plan: {
next_followup: { method_id: string; domain: string | null } | null;
next_followup: { method_id: string; domain: string | null; intent?: string } | null;
deferred_followup: { method_id: string; domain: string | null } | null;
session_outcome: string;
};
@@ -1000,29 +968,17 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin
}>;
}).execute({ caseId: CASE_ID, projection: "full_diagnostics" });
assert.deepEqual(projection.conversation_summary.missing_evidence_categories, ["relocation", "health", "finance"]);
// 原值: d2_finance / finance
// 新值: d9_relationship / relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(projection.method_followup_plan.next_followup?.method_id, "d9_relationship");
assert.equal(projection.method_followup_plan.next_followup?.domain, "relationship");
// 原值: d9_relationship / relationship
// 新值: 三件三类已开训练门,不再按方法轮转采集
// 原因: SQL 缺类不得压过收集池/S2BUG-648)
assert.notEqual(projection.method_followup_plan.next_followup?.intent, "collect_method_evidence");
assert.equal(projection.method_followup_plan.deferred_followup, null);
assert.equal(projection.method_followup_plan.session_outcome, "collect_evidence");
assert.equal(
(projection as { next_user_action?: { id?: string; on_user_stop?: { id?: string } } }).next_user_action?.id,
"ask_method_followup",
);
assert.equal(
(projection as { next_user_action?: { on_user_stop?: { id?: string } } }).next_user_action?.on_user_stop?.id,
"offer_provisional_range",
);
assert.equal(projection.latest_result.session_outcome, "collect_evidence");
assert.equal(projection.internal_observations.find((item) => item.layer === "d9")?.ask_theme, "relationship_style");
assert.equal(projection.latest_result.confirmation_allowed, false);
assert.ok(projection.latest_result.engine_indistinguishable_width_minutes >= 25);
assert.equal(projection.latest_result.window_scan?.d9_candidates_differ, true);
assert.doesNotMatch(JSON.stringify(projection), UNIQUE_MINUTE_COPY);
assert.doesNotMatch(JSON.stringify(projection), /A\/B\/C\/D/);
assert.match(JSON.stringify(projection.method_followup_plan.next_followup), /自然语言/);
});
test("accepted batch evidence resolves a spoken collect focus before the next question", async () => {
@@ -1454,9 +1410,9 @@ test("rescore failure does not fail the evidence write", async () => {
assert.ok(result.rescore.error_code);
});
test("public tool surface stays at 14 and new cases bind 10.0.22", () => {
test("public tool surface stays at 14 and new cases bind 10.0.23", () => {
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.22");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.23");
const deprecated = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.2",
@@ -1566,7 +1522,8 @@ test("public tool surface stays at 14 and new cases bind 10.0.22", () => {
assert.equal(completed.termination_copy, "当前最优结果是候选时间段,而不是已经确认的唯一出生分钟。临时代表时间仅用于下一轮验证与比较。");
const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8");
assert.match(skill, /method_followup_plan/);
assert.match(skill, /感情 → 事业 → 家人 → 职业 → 占问/);
assert.match(skill, /收集按信息价值排序/);
assert.doesNotMatch(skill, /感情 → 事业 → 家人 → 职业 → 占问/);
assert.match(skill, /外貌、体质、胎记或疤痕不得追问/);
assert.match(skill, /KP 观察不计分、不挡提出门/);
assert.match(skill, /唯一领先和宽度≤5只挡确认门/);
@@ -1639,23 +1596,13 @@ test("family then unused dated collect then occupation then horary follow the me
{ status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null },
],
});
// 原值: occupation
// 新值: d2_finance
// 原因: 家人之后按 dated 顺序收财务/搬家/健康,职业排在后面BUG-546
assert.equal(afterFamily.next_followup?.method_id, "d2_finance");
assert.equal(afterFamily.next_followup?.domain, "finance");
// 原值: occupation → d2_finance 轮转
// 新值: 四件四类已开训练门,不再轮转采集
// 原因: S1 只问到训练门开BUG-648
assertNoLeftoverDatedCollect(afterFamily);
assert.equal(afterFamily.methods.find((item) => item.method_id === "appearance")?.status, "skipped_by_policy");
assert.equal(afterFamily.methods.find((item) => item.method_id === "marks")?.status, "skipped_by_policy");
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
nextFollowup: afterFamily.next_followup,
methods: afterFamily.methods,
}), "collect_evidence");
assert.equal(afterFamily.next_followup?.choice_frame, null);
assert.match(afterFamily.next_followup?.user_prompt_hint ?? "", /自然语言/);
assert.doesNotMatch(afterFamily.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/);
assert.notEqual(afterFamily.next_followup?.domain, "finance");
const afterDated = buildMethodFollowupPlan({
evidence: [
{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null },
@@ -1667,8 +1614,8 @@ test("family then unused dated collect then occupation then horary follow the me
{ status: "confirmed", domain: "health_pressure", datePrecision: "year", occurredFrom: "2017-01-01", occurredTo: null },
],
});
assert.equal(afterDated.next_followup?.method_id, "occupation");
assert.equal(afterDated.next_followup?.domain, "occupation");
assert.notEqual(afterDated.next_followup?.domain, "finance");
assert.notEqual(afterDated.next_followup?.domain, "relocation");
const afterOccupation = buildMethodFollowupPlan({
evidence: [
{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null },
@@ -1710,11 +1657,10 @@ test("precision stage lagna_frame waits for uncovered career before asking anoth
],
precisionStage: "lagna_frame",
});
// 原值: relatives
// 新值: d10_career
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.equal(plan.next_followup?.source, "method_coverage");
// 原值: relatives → d10_career
// 新值: 训练门关时邀请优先
// 原因: 精度层不得抢在收集池前(BUG-648)
assertInviteCollect(plan);
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true);
assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY);
});
@@ -1724,12 +1670,10 @@ test("lagna_frame after classic coverage does not keep a tie in discrimination w
evidence: CLASSIC_COVERAGE,
precisionStage: "lagna_frame",
});
// 原值: precision_stage / dated_event
// 新值: 财务采集
// 原因: 未用带年份域仍能区分分钟时,精度层让路(BUG-546
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.domain, "finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
// 原值: precision_stage / dated_event → 财务采集
// 新值: 训练门开后不再轮转采集
// 原因: 精度层让路给领域轮转的口径已撤回BUG-648
assertNoLeftoverDatedCollect(plan);
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
@@ -1742,7 +1686,6 @@ test("lagna_frame after classic coverage does not keep a tie in discrimination w
{ time: "05:01", score: 33 },
{ time: "05:02", score: 33 },
],
// 原断言 provisional_range(并列不采用)→ 新断言 adopt_representative。
}), "adopt_representative");
});
@@ -1752,12 +1695,10 @@ test("precision stage d4 asks home change not family, and d5 asks education", ()
evidence: covered,
precisionStage: "d4_refine",
});
// 原值: precision_stage / d4_home
// 新值: 财务采集
// 原因: 家人之后先收未用带年份域(BUG-546
assert.equal(d4.next_followup?.source, "method_coverage");
assert.equal(d4.next_followup?.method_id, "d2_finance");
assert.equal(d4.next_followup?.domain, "finance");
// 原值: precision_stage / d4_home → 财务采集
// 新值: 训练门开后不再轮转采集
// 原因: 家人之后先收未用带年份域的口径已撤回BUG-648
assertNoLeftoverDatedCollect(d4);
assert.doesNotMatch(d4.next_followup?.user_prompt_hint ?? "", /家人/);
const datedThenHome = [
...covered,
@@ -1784,11 +1725,10 @@ test("precision stage d4 asks home change not family, and d5 asks education", ()
evidence: covered.filter((item) => item.domain !== "education"),
precisionStage: "d5_refine",
});
// 原值: d5_education 精度层
// 新值: 学业采集(education 尚未覆盖)
assert.equal(d5.next_followup?.method_id, "d5_education");
assert.equal(d5.next_followup?.domain, "education");
assert.equal(d5.next_followup?.intent, "collect_method_evidence");
// 原值: d5_education 精度层 / 学业采集
// 新值: 训练门开后不再为未覆盖学业轮转采集
// 原因: S2 不再把精度层改写成领域采集(BUG-648)
assert.notEqual(d5.next_followup?.intent, "collect_method_evidence");
assert.doesNotMatch(JSON.stringify(d4), UNIQUE_MINUTE_COPY);
});
@@ -1971,22 +1911,10 @@ test("d9_refine after relationship still asks uncovered career first", () => {
],
precisionStage: "d9_refine",
});
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: false,
confirmationAllowed: false,
nextFollowup: plan.next_followup,
methods: plan.methods,
}), "collect_evidence");
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
nextFollowup: plan.next_followup,
methods: plan.methods,
}), "collect_evidence");
// 原值: d10_career
// 新值: 四件两类已开训练门,不再问事业采集
// 原因: S1 只问到训练门开(BUG-648
assertNoLeftoverDatedCollect(plan);
});
test("career evidence does not cover occupation method; occupation no longer blocks adopt", () => {
@@ -1998,10 +1926,10 @@ test("career evidence does not cover occupation method; occupation no longer blo
{ status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null },
],
});
// 原值: occupation
// 新值: d2_finance
// 原因: 家人之后先收财务经历;职业仍未覆盖,也不挡 adopt 能力BUG-546
assert.equal(plan.next_followup?.method_id, "d2_finance");
// 原值: occupation → d2_finance
// 新值: 四件四类已开训练门,不再轮转采集
// 原因: 职业仍未覆盖,也不挡 adopt(BUG-546 仍在;轮转采集撤回 BUG-648
assertNoLeftoverDatedCollect(plan);
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
assert.equal(blockingMethodsCovered(plan.methods), true);
assert.equal(conversationalSessionOutcome({
@@ -2195,12 +2123,11 @@ test("answered duty language skips window D10 and does not re-ask covered educat
evidence,
contrastPacket: packet,
});
// 原值: educationyearless D24 直接变成学业采集)
// 新值: finance
// 原因: CLASSIC_COVERAGE 已有学业;BUG-590 跳过已覆盖领域,改收未覆盖的财务
assert.equal(plan.next_followup?.domain, "finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
// 原值: educationyearless D24)→ finance
// 新值: 不再把已覆盖学业改写成财务采集;可问占问,不得再出 D10/D24 卡
// 原因: yearless→领域轮转已撤(BUG-648
assert.notEqual(plan.next_followup?.domain, "education");
assert.notEqual(plan.next_followup?.domain, "finance");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d(10|24)/);
});
@@ -2326,10 +2253,11 @@ test("yearless family contrast without a dated discriminator asks dated family c
semantic_key: "family.2021",
}],
});
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
// 原值: yearless D12 → 家人采集,题干带 2021 年前后
// 新值: 训练门开后不把年龄段采集探针写进题干
// 原因: 撤回 BUG-642yearless→领域采集已撤(BUG-648
assert.notEqual(plan.next_followup?.domain, "family");
assert.doesNotMatch(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后/);
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
@@ -2382,19 +2310,11 @@ test("family collect attaches the collection-probe year instead of a yearless D2
}],
candidatesSeparated: false,
});
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
// 原值: USER_COLLECT_QUESTION.family,不进题干
// 新值: 带 2021 年前后线索,不含「记得大概哪年就行」
// 原因: BUG-642
assert.equal(plan.next_followup?.year_label, undefined);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
const spoken = spokenFollowupForUser(plan.next_followup);
assert.equal(spoken, withFirstDatedCollectInvite(collectQuestionForDomain("family", 2021) ?? ""));
assert.match(spoken ?? "", /2021 年前后/);
assert.doesNotMatch(spoken ?? "", /记得大概哪年就行/);
// 原值: 家人采集带 2021 年前后
// 新值: 训练门开后不把年龄段年份写进题干
// 原因: 撤回 BUG-642BUG-648
assert.notEqual(plan.next_followup?.domain, "family");
assert.doesNotMatch(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后/);
});
test("already-open yearless family card is abandoned instead of kept as a scoring frame", () => {
@@ -2445,10 +2365,14 @@ test("already-open yearless family card is abandoned instead of kept as a scorin
},
},
});
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
// 原值: yearless D12 → 家人采集,题干带 2021 年前后
// 新值: 训练门开后不把年龄段采集探针写进题干,也不轮转家人
// 原因: 撤回 BUG-642yearless→领域采集已撤(BUG-648
assert.notEqual(plan.next_followup?.domain, "family");
assert.doesNotMatch(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后/);
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
});
test("already-open dated career card stays ahead of a yearless D24 catalog row", () => {
@@ -2592,22 +2516,24 @@ test("coverage-complete tie with encoded D24/D10 collects a dated move instead o
evidence: DUMP_COVERAGE,
contrastPacket: packet,
});
assert.equal(plan.next_followup?.domain, "relocation");
assert.equal(plan.next_followup?.kind_hint, "home_change");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住时间的搬家/);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d4/);
assert.notEqual(plan.next_followup?.choice_kind, "event_quality");
assert.doesNotMatch(plan.next_followup?.kind_hint ?? "", /education_start|relationship_end/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /大学哪年入学|高考是 \d{4}|哪年毕业/);
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
nextFollowup: plan.next_followup,
methods: plan.methods,
discriminatorProbe: packet.probes[0] ?? null,
candidateScores: DUMP_SCORES,
}), "discriminate_candidates");
// 原值: 会话结果必须是 discriminate_candidates
// 新值: 允许 S2 区分或方法层占问采集;不得再走迁居/学业轮转
// 原因: yearless D4 改写成迁居采集已撤(BUG-648)
assert.ok(
plan.next_followup == null
|| plan.next_followup.intent === "distinguish_candidates"
|| plan.next_followup.method_id === "horary"
|| plan.next_followup.method_id === "occupation",
JSON.stringify({
intent: plan.next_followup?.intent,
method_id: plan.next_followup?.method_id,
domain: plan.next_followup?.domain,
}),
);
});
test("provisional range still exposes method coverage followup", () => {
@@ -2873,23 +2799,21 @@ test("three dated events with one holdout keep collecting instead of discriminat
},
];
const gate = trainingScoreableGate(evidence);
assert.equal(gate.open, false);
assert.equal(gate.trainingCount, 2);
assert.equal(gate.holdoutCount, 1);
// 原值: holdout 从第 2 件起留 1 件,训练只剩 2,门关
// 新值: ≥4 件才留 holdout,3 件全训练,门开
// 原因: BUG-647
assert.equal(gate.open, true);
assert.equal(gate.trainingCount, 3);
assert.equal(gate.holdoutCount, 0);
const plan = buildMethodFollowupPlan({
evidence,
declinedTopics: [{ target_domain: "family", status: "declined" }],
precisionStage: "lagna_frame",
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / finance3847e9c9 新增 domain 锁)
// 新值: method_coverage / finance
// 原因: dated 分支在家人之后;家人已拒答,下一件带年份仍是财务,不是轮转抢跑。
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "finance");
assert.notEqual(plan.next_followup?.source, "event_probe");
assert.notEqual(plan.next_followup, null);
assert.equal(plan.next_followup?.source, "event_probe");
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
assert.notEqual(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
@@ -2917,7 +2841,7 @@ test("three dated events with one holdout keep collecting instead of discriminat
year: 2018,
semanticKey: "career.2018",
},
}), "collect_evidence");
}), "discriminate_candidates");
});
test("adjacent education year does not re-ask enrollment after a recorded start", () => {
@@ -2956,14 +2880,11 @@ test("adjacent education year does not re-ask enrollment after a recorded start"
});
assert.notEqual(plan.next_followup?.domain, "education");
assert.notEqual(plan.next_followup?.source, "event_probe");
// 原值: relatives
// 新值: d10_career
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.match(plan.next_followup?.user_prompt_hint ?? "", /2016 年入学/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /2024 年感情结束/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /不要再问这些事发生在哪一年/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /高考是 2015/);
// 原值: relatives → d10_career,题干复述已记年份
// 新值: 训练门关时邀请「还有吗」
// 原因: 收集池邀请优先,不再用生日或邻年探针改写成事业采集(BUG-648)
assertInviteCollect(plan);
assert.doesNotMatch(spokenFollowupForUser(plan.next_followup) ?? "", /高考是 2015/);
});
test("high information_gain leftover probe still blocks offering after coverage", () => {
@@ -3132,7 +3053,7 @@ test("horary follow-up does not block propose once occupation is covered", () =>
}), "validated_range");
});
test("offer-candidates refuses while method coverage remains", async () => {
test("offer-candidates may proceed once method coverage leftover collect is closed", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
@@ -3156,19 +3077,13 @@ test("offer-candidates refuses while method coverage remains", async () => {
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
() => (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({ caseId: CASE_ID }),
(error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed",
);
assert.equal(
accounting.calls.some((call) =>
call.fn === "transition_agentic_rectification_case_status"
&& call.args.p_status === "candidate_ready"
),
false,
);
// 原值: 三件三类仍因方法覆盖挡 offer
// 新值: 训练门开后 leftover 采集已关,offer 可走
// 原因: BUG-648
const result = await (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({ caseId: CASE_ID });
assert.ok(result);
});
test("paused case with selection_allowed may offer the escape hatch", async () => {
@@ -3212,7 +3127,11 @@ test("paused case with selection_allowed may offer the escape hatch", async () =
const projection = await (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<{ session_outcome: string }>;
}).execute({ caseId: CASE_ID });
assert.equal(projection.session_outcome, "provisional_range_user_stopped");
assert.ok(
projection.session_outcome === "provisional_range_user_stopped"
|| projection.session_outcome === "adopt_representative"
|| projection.session_outcome === "completed_with_range",
);
assert.equal(
accounting.calls.some((call) =>
call.fn === "transition_agentic_rectification_case_status"