fix(rectification): ask relationship and career before dated collect
Independent Staging Quality Gate / validate (push) Successful in 10m6s
Independent Staging Quality Gate / publish (push) Has been cancelled

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>
This commit is contained in:
Jesse_Chen
2026-09-04 19:06:54 +08:00
parent 846b60642f
commit 0be51e65f5
12 changed files with 257 additions and 90 deletions
@@ -1843,10 +1843,6 @@ export function buildMethodFollowupPlan(input: {
source: "method_coverage",
});
}
if (!next && !meetsAcceptanceEventQuality(input.evidence)) {
const dated = nextDatedCollectFollowup(input.evidence, declined);
if (dated) next = makeFollowup(dated);
}
if (!next && dashaCovered && coverageComplete && occupationCovered) {
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
const yearless = yearlessDiscriminators[0];
@@ -1911,6 +1907,7 @@ export function buildMethodFollowupPlan(input: {
const candidate = followupFromRanked(ranked);
return candidate.choice_frame ? candidate : null;
};
let datedCollect: MethodFollowup | null = null;
if (!next) {
if (!relationshipCovered && !declined.has("relationship")) {
next = sameDomainYearlessCard("relationship") ?? makeFollowup({
@@ -1956,6 +1953,11 @@ export function buildMethodFollowupPlan(input: {
source: "method_coverage",
...collectionYearFields(familyCollect),
});
} else if (
!meetsAcceptanceEventQuality(input.evidence)
&& (datedCollect = nextDatedCollectFollowup(input.evidence, declined))
) {
next = makeFollowup(datedCollect);
} else if (!occupationCovered) {
if (meetsAcceptanceEventQuality(input.evidence)) {
for (const ranked of yearlessDiscriminators) {
@@ -115,9 +115,12 @@ test("model management renders localized labels without changing stored enum val
});
test("global native input sizing excludes Ant Design picker internals", () => {
// 原值: padding: 0 12px
// 新值: padding: 0 var(--space-3)
// 原因: 7ee7f825 把间距收敛到 token;合同测试跟随 CSS,不改业务代码。
assert.match(
globals,
/input:not\(\[type="radio"\]\):not\(\[type="checkbox"\]\):not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\), select[^\{]+\{ width: 100%; min-height: 44px; padding: 0 12px;/,
/input:not\(\[type="radio"\]\):not\(\[type="checkbox"\]\):not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\), select[^\{]+\{ width: 100%; min-height: 44px; padding: 0 var\(--space-3\);/,
);
assert.match(
globals,
@@ -605,10 +605,10 @@ test("GET hides a legacy known-event quality focus and resumes evidence collecti
expectedAnswerSchema: { choice: SAMPLE_COPY },
},
});
// 原值: d9_relationship
// 新值: relatives
// 原因: 丢弃遗留质量焦点后继续采集;一件学业未开训练门,下一问是家人。
assert.equal(plan.next_followup?.method_id, "relatives");
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
});
@@ -100,6 +100,43 @@ function collectPlan(
});
}
test("method rotation asks relationship and career before dated collect", () => {
const careerOnly = buildMethodFollowupPlan({
evidence: [dated("career", "2011", { eventKind: "career_entry" })],
holdoutValidation: "not_started",
});
assert.equal(careerOnly.next_followup?.method_id, "d9_relationship");
assert.equal(careerOnly.next_followup?.domain, "relationship");
const relationshipOnly = buildMethodFollowupPlan({
evidence: [dated("relationship", "2014", { eventKind: "relationship_start" })],
holdoutValidation: "not_started",
});
assert.equal(relationshipOnly.next_followup?.method_id, "d10_career");
assert.equal(relationshipOnly.next_followup?.domain, "career");
const both = buildMethodFollowupPlan({
evidence: TWO_SCOREABLE,
holdoutValidation: "not_started",
});
assert.equal(both.next_followup?.method_id, "relatives");
assert.equal(both.next_followup?.domain, "family");
const familyDeclined = collectPlan(TWO_SCOREABLE);
assert.equal(familyDeclined.next_followup?.method_id, "d5_education");
assert.equal(familyDeclined.next_followup?.domain, "education");
assert.notEqual(familyDeclined.next_followup?.domain, "occupation");
const educationDeclined = collectPlan(TWO_SCOREABLE, {
declinedTopics: [
...FAMILY_DECLINED,
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
],
});
assert.equal(educationDeclined.next_followup?.method_id, "d2_finance");
assert.equal(educationDeclined.next_followup?.domain, "finance");
});
test("two scoreable events do not open OOS even when holdout prompts exist", () => {
assert.equal(meetsAcceptanceEventQuality(TWO_SCOREABLE), false);
const declined = new Set(["family"]);
@@ -54,6 +54,8 @@ const FAMILY_ID = "44444444-4444-4444-8444-444444444443";
const CAREER_ID = "44444444-4444-4444-8444-444444444444";
const UNIQUE_MINUTE_COPY = /±5 分钟确定性/;
// 本单回退 3847e9c9 把训练门未开的下一问改成家人的断言。见各断言旁三栏。
function collectDecision(candidates: readonly { time: string; relativeSupport?: number }[]) {
return decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
@@ -365,7 +367,7 @@ function stubEngine(response: unknown) {
};
}
test("eight-method routing asks family after dated education, not relocation", () => {
test("eight-method routing asks relationship after dated education, not relocation", () => {
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
@@ -375,11 +377,11 @@ test("eight-method routing asks family after dated education, not relocation", (
occurredTo: null,
}],
});
// 原值: d9_relationship / relationship
// 新值: relatives / family
// 原因: 训练门未开时主流程走 datedCollectOrder,家人在感情之前;SQL missing 的 relocation 仍不进轮转。
assert.equal(plan.next_followup?.method_id, "relatives");
assert.equal(plan.next_followup?.domain, "family");
// 原值: relatives / family
// 新值: d9_relationship / relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.domain, "relationship");
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");
@@ -390,11 +392,11 @@ test("dasha conflict probe does not jump ahead of method rotation before accepta
evidence: [datedEvidence("education", "2016")],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / d9_relationship
// 新值: method_coverage / relatives
// 原因: 训练门未开时先补家人采集,冲突探针仍不得抢跑。
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "relatives");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assert.notEqual(plan.next_followup?.source, "event_probe");
});
@@ -404,11 +406,11 @@ 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 / d9_relationship
// 新值: method_coverage / relatives
// 原因: 一条学业记录未开训练门,先问家人而不是质量卡。
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "relatives");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assert.notEqual(plan.next_followup?.choice_kind, "event_quality");
});
@@ -421,11 +423,11 @@ test("career known-event quality does not jump the adoption gate", () => {
],
eventProbes: [CAREER_QUALITY_PROBE, CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / d9_relationship
// 新值: method_coverage / relatives
// 原因: 两条事业记录仍不足训练门(领域数不够),先补家人。
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "relatives");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assert.notEqual(plan.next_followup?.source, "event_probe");
assert.notEqual(plan.next_followup?.choice_kind, "event_quality");
@@ -436,11 +438,11 @@ 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 / d9_relationship
// 新值: method_coverage / relatives
// 原因: 编码质量探针仍不得在训练门前出卡;下一问是家人采集。
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "relatives");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
});
@@ -504,11 +506,11 @@ test("three scoreable events in one domain still rotate methods instead of rever
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / d9_relationship
// 新值: method_coverage / relatives
// 原因: 三件都在学业,领域数不足训练门,先补家人而不是反推。
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "relatives");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
});
@@ -528,11 +530,11 @@ test("occupation_note does not count toward reverse-inference event quality", ()
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage / d9_relationship
// 新值: method_coverage / relatives
// 原因: occupation_note 不计训练门,下一问仍是家人采集。
// 原值: method_coverage / relatives
// 新值: method_coverage / d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "relatives");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
});
@@ -558,10 +560,10 @@ test("age-band probe does not jump ahead of uncovered relationship", () => {
role: "reverse_verify",
}],
});
// 原值: d9_relationship
// 新值: relatives
// 原因: 年龄带探针不得抢跑;训练门未开时先问家人,不是感情。
assert.equal(plan.next_followup?.method_id, "relatives");
// 原值: 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);
});
@@ -618,10 +620,10 @@ 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");
// 原值: d9_relationship
// 新值: relatives
// 原因: 一件学业未开训练门,用户停下时仍在采集家人。
assert.equal(plan.next_followup?.method_id, "relatives");
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.match(plan.next_followup?.user_prompt_hint ?? "", /自然语言/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/);
assert.equal(plan.next_followup?.choice_frame, null);
@@ -647,10 +649,10 @@ test("selectionAllowed with remaining method follow-up keeps collecting and offe
nextFollowup: plan.next_followup,
workingTime: "05:07",
});
// 原值: d9_relationship
// 新值: relatives
// 原因: 训练门未开时继续采集家人,停下才给区间。
assert.equal(plan.next_followup?.method_id, "relatives");
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(action.id, "ask_method_followup");
assert.equal(action.on_user_stop.id, "offer_provisional_range");
});
@@ -667,10 +669,10 @@ test("adopt_representative defers method follow-up instead of asking this turn",
sessionOutcome: "adopt_representative",
});
assert.equal(plan.next_followup, null);
// 原值: d9_relationship
// 新值: relatives
// 原因: 采用轮仍 stash 下一采集题;训练门未开时那题是家人。
assert.equal(plan.deferred_followup?.method_id, "relatives");
// 原值: relatives
// 新值: d9_relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.deferred_followup?.method_id, "d9_relationship");
assert.equal(plan.session_outcome, "adopt_representative");
});
@@ -699,11 +701,11 @@ test("declined relationship skips to career and leaves horary uncovered", () =>
}],
declinedTopics: [{ target_domain: "relationship", status: "declined" }],
});
// 原值: d10_career / career
// 新值: relatives / family
// 原因: 感情拒答只跳过感情;训练门未开时下一件带年份的是家人,不是事业
assert.equal(plan.next_followup?.method_id, "relatives");
assert.equal(plan.next_followup?.domain, "family");
// 原值: relatives / family
// 新值: d10_career / career
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.equal(plan.next_followup?.domain, "career");
assert.deepEqual([...plan.do_not_poll], []);
assert.equal(plan.methods.find((item) => item.method_id === "horary")?.status, "uncovered");
});
@@ -938,11 +940,11 @@ 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"]);
// 原值: d9_relationship / relationship
// 新值: d2_finance / finance
// 原因: 学业+家人+事业三条里 1 条 holdout,训练门未开;datedCollectOrder 里家人/学业已有记录,下一问是财务。仍不按 SQL missing 先问搬家。
assert.equal(projection.method_followup_plan.next_followup?.method_id, "d2_finance");
assert.equal(projection.method_followup_plan.next_followup?.domain, "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");
assert.equal(projection.method_followup_plan.deferred_followup, null);
assert.equal(projection.method_followup_plan.session_outcome, "collect_evidence");
assert.equal(
@@ -1626,10 +1628,10 @@ test("precision stage lagna_frame waits for uncovered career before asking anoth
],
precisionStage: "lagna_frame",
});
// 原值: d10_career
// 新值: relatives
// 原因: 学业+感情仍不足训练门,lagna_frame 不得先问另一件泛问;先补家人。
assert.equal(plan.next_followup?.method_id, "relatives");
// 原值: relatives
// 新值: d10_career
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true);
assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY);
@@ -2726,9 +2728,9 @@ test("three dated events with one holdout keep collecting instead of discriminat
precisionStage: "lagna_frame",
eventProbes: [CAREER_CONFLICT_PROBE],
});
// 原值: method_coverage(方法轮转
// 原值: method_coverage / finance3847e9c9 新增 domain 锁
// 新值: method_coverage / finance
// 原因: 家人已拒答,学业/事业/感情已有记录,holdout 占用 1 条,训练门未开,补财务不是区分
// 原因: 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");
@@ -2800,10 +2802,10 @@ 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");
// 原值: d10_career
// 新值: relatives
// 原因: 邻近年不得再问入学;训练门未开时下一问是家人,不是事业
assert.equal(plan.next_followup?.method_id, "relatives");
// 原值: 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 ?? "", /不要再问这些事发生在哪一年/);
@@ -402,10 +402,10 @@ test("collect set-focus writes collect:true even if Agent sends choice", async (
caseId: CASE_ID,
questionId: followup ? stableFollowupQuestionId(followup) : "collect:relationship:collect_method_evidence",
intent: "collect_method_evidence",
// 原值: 「你还记得哪年上的大学吗?
// 新值: USER_COLLECT_QUESTION[domain](默认 dossier 一件事业 → 家人
// 原因: 采集题干必须命中服务端领域关键词,大学不命中家人
spokenPrompt: USER_COLLECT_QUESTION[followup?.domain ?? "family"] ?? USER_COLLECT_QUESTION.family,
// 原值: USER_COLLECT_QUESTION.family3847e9c945bdb63e 为「哪年上的大学」
// 新值: USER_COLLECT_QUESTION[followup.domain](默认 dossier 一件事业 → 感情
// 原因: dated 分支移到家人之后,前两问恢复感情;BUG-529 仍要求题干命中领域词,大学不命中感情
spokenPrompt: USER_COLLECT_QUESTION[followup?.domain ?? "relationship"] ?? USER_COLLECT_QUESTION.relationship,
expectedAnswerSchema: {
choice: {
prompt: "伪造采集",
@@ -140,10 +140,10 @@ test("set/resolve focus tools return safe projections and never infer the active
caseId: CASE_ID,
questionId: "career-month-question",
intent: "clarify_event_date",
// 原值: 「你还记得哪年上的大学吗?
// 新值: USER_COLLECT_QUESTION.family
// 原因: 默认 dossier 一件事业,训练门未开时下一问是家人采集;大学不命中家人关键词。
spokenPrompt: USER_COLLECT_QUESTION.family,
// 原值: USER_COLLECT_QUESTION.family3847e9c945bdb63e 为「哪年上的大学」
// 新值: USER_COLLECT_QUESTION.relationship
// 原因: dated 分支移到家人之后,前两问恢复感情;服务端下一问仍是采集,BUG-529 要求题干命中感情词。
spokenPrompt: USER_COLLECT_QUESTION.relationship,
}) as Record<string, unknown>;
assert.equal(setResult.ok, undefined);
assert.equal(setResult.focus_id, FOCUS_ID);
@@ -459,11 +459,12 @@ test("same-domain collect retry uses structured closed-focus state, not body mat
}],
sessionOutcome: "collect_evidence",
});
// 原值: relationship / collect_retry true
// 新值: family
// 原因: 一件事业未开训练门,datedCollectOrder 先问家人;已关闭的感情采集不挡家人。retry 文案仍由上面的 constructed followup 覆盖。
assert.equal(plan.next_followup?.domain, "family");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 原值: family
// 新值: relationship / collect_retry true / USER_COLLECT_QUESTION_RETRY.relationship
// 原因: dated 分支移到家人之后,前两问恢复感情 / 事业
assert.equal(plan.next_followup?.domain, "relationship");
assert.equal(plan.next_followup?.collect_retry, true);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION_RETRY.relationship);
const declined = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",