From cd704a92e7fa1eb9033b23bb8d79a77479eff84c Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 5 Sep 2026 10:34:24 +0800 Subject: [PATCH] fix(rectification): ask a ready discriminator before dated collect Family coverage still continues dated collection when nothing can split leftover minutes, but an already-built choice card must not be displaced by spoken finance collect. Co-authored-by: Cursor --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 17 ++ ...cation-family-collect-continue-20260905.md | 43 +++++ docs/tasks/README.md | 2 +- ...cation-family-collect-continue-20260905.md | 9 + .../v9/method-followup.ts | 169 +++++++++++------- .../tests/rectification-choice-card.test.ts | 48 +++++ ...ification-occupation-coverage-exit.test.ts | 10 +- 8 files changed, 232 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ced55166..ce717dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-05 — 有现成区分卡时先问卡,没有卡才补采集 + +生时校正里,家人那题已经覆盖或说「没有」之后,如果引擎已经算出能分开剩余分钟的选择卡,就先问那张卡。只有这一轮拿不出可点的区分卡时,才继续问还没问过的钱、搬家、身体这些带年份的经历。Skill 版本仍是 10.0.14。 + ## 2026-09-05 — 家人说没有之后继续收能区分时间的经历 生时校正里,家里那题答「没有」之后,如果学业、感情、事业已经有带年份的事,会接着问钱、搬家、身体或工作,用来分开还分得开的几个分钟。不会因为已经可以给代表时间就直接结束。Skill 版本仍是 10.0.14。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index fd4623f7..bcd72a48 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8435,5 +8435,22 @@ - 防复发:训练门已开不得跳过未用的 dated collect。`canAdopt` 不得单独构成跳过财务/搬家/健康/职业采集的理由。不得把 BUG-539「家人没有后采用」读成「未问完的经历域也可以结束」。 - 相关记录:BUG-519、BUG-539、BUG-544 - 复发自:BUG-519(采用早退把未用采集域一并吃掉);BUG-539 把该收口写成非缺陷 +- 修复版本:`998406ce` +- 修正:`998406ce` 把家人之后的 dated 补采集做成无条件优先,现成搬家 A/B 卡被财务口述挤掉。见 BUG-547。 + +## BUG-547 | 现成区分卡被口述补采集挤掉 + +- 状态:resolved +- 首次发现:2026-09-05 +- 最近更新:2026-09-05 +- 影响面:`buildMethodFollowupPlan`、`nextDatedCollectFollowup`、精度层 / yearless 区分卡 +- 用户现象:学业、感情、事业、家人都已有带年份经历,引擎已经给出搬家 A/B 探针,下一问却变成财务口述采集。 +- 触发条件:经典八法覆盖、精度阶段 `d4_refine`、存在可渲染 `choice_frame` 的搬家探针,同时 `DATED_COLLECT_ORDER` 里还有未覆盖域。 +- 根因:BUG-546 把家人之后的 `nextDatedCollectFollowup` 从「训练门未开才走」改成无条件执行,且排在精度层搬家卡和 yearless 卡之前。BUG-546 的触发条件本是「没有可用区分题」,实现扩成了「不管有没有区分题都先采集」。 +- 修复:家人之后若 yearless 或精度层能拿出 `choice_frame`,先问那张区分卡;只有本轮没有任何可渲染区分卡时才走 dated 补采集。`isRemainingEvidenceCollect` 对 `deferAdoption` 的豁免保留。不放宽精确分钟确认门,不改 `DATED_COLLECT_ORDER`。 +- 验证:`rectification-choice-card` 搬家 A/B 卡用例原样通过;无探针时仍问财务采集;occupation 顺序断言改为 `d2_finance` 并写三栏说明。 +- 防复发:dated 补采集不得抢在可渲染区分卡之前。没有区分卡时仍必须继续收未问过的带年份域(BUG-546)。 +- 相关记录:BUG-546、BUG-519 +- 复发自:BUG-546 - 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-family-collect-continue-20260905.md b/docs/tasks/PROGRESS-rectification-family-collect-continue-20260905.md index c99299f8..d6a765b7 100644 --- a/docs/tasks/PROGRESS-rectification-family-collect-continue-20260905.md +++ b/docs/tasks/PROGRESS-rectification-family-collect-continue-20260905.md @@ -41,3 +41,46 @@ | `page.tsx` | 未改 | 未做浏览器真人走查。清单:`docs/testing/rectification-family-collect-continue-20260905.md`。旧会话在部署前仍会按旧逻辑结束。 + +## BUG-547 · 现成区分卡优先于口述补采集 + +工作树:`.worktrees/rectification-family-collect-continue-fix-20260905` +分支:`codex/rectification-family-collect-continue-fix-20260905` +基线:`origin/staging` @ `309c7424`(含 BUG-546 的 `998406ce`)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 补采集让位于可渲染区分卡 | 完成 | BUG-547 | +| 无区分卡时仍走 dated 补采集 | 完成 | BUG-546 保留 | + +### 编号 + +开工时最大号 BUG-546。本单 **BUG-547**。未占用预留的 BUG-542。 + +### 实现取舍 + +在 `if (!next)` 链里,家人之后先看 yearless / 精度层能否拿出 `choice_frame`,有卡才问卡;没有卡才 `nextDatedCollectFollowup`。精度层抢在采集前必须训练门已开,避免 `lagna_frame` 借走无关探针、在 holdout 未齐时改成区分题。没有把 dated 分支整段挪到 `d4_refine` 之后,因为精度层在没有探针时也会生成无框 `distinguish` 题,整段后移会把 BUG-546 的八方法财务采集再改回去。 + +### 断言变更 + +| 项 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `career evidence alone still does not cover occupation` 下一问 | `occupation` | `d2_finance` | 家人已覆盖且无区分卡,BUG-546 继续收未用带年份域;职业仍 uncovered | +| `training gate open does not mint yearless…` 下一问 | `occupation` | `d2_finance` | yearless 存在/质量卡被丢掉后没有可渲染区分卡,先财务采集 | + +`distinguish follow-up copy forbids competing-chart ranking` 未改断言。新增无探针对照:同样证据集、没有搬家探针时下一问仍是财务采集。 + +### 测试 + +| 命令 | 结果 | +| --- | --- | +| choice-card + occupation-coverage-exit | 55/55 | +| eight-method + choice-card + occupation | 117/117 | +| collect-stall / collect-direction / provisional-adopt / range-offer / adopt-narration / adopt-flow-fix / eight-method / choice-card / occupation / agent-voice | 192/192 | +| `npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts` | 895 项;产品失败 0。整包曾出现 1 次 Docker 迁移夹具失败(`v9 agent api migration…`),单测重跑 1/1 通过 | +| `tsc --noEmit` | 0 错 | +| 改动文件 eslint `--quiet` | 0 error | +| `page.tsx` | 未改 | + +未做浏览器真人走查。旧会话在部署前仍会按 `998406ce` 把现成区分卡挤掉。 + diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1d78b9aa..250ca1fe 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -53,7 +53,7 @@ | `TASK-rectification-quality-probe-dedupe-20260904.md` | `PROGRESS-rectification-quality-probe-dedupe-20260904.md` | 候选区分阶段同一道"上大学"题问两次:`pickProbe` 按种类取第一条质量探针无视 `semantic_key`(第二次答案绑到错的证据);引擎对毕业等学业 kind 也套"高考发挥"模板;同域同分组质量探针不去重 | 已验收通过(浏览器走查欠) | `codex/rectification-quality-probe-dedupe-20260904`(BUG-540~541) | | — | `PROGRESS-rectification-adopt-card-anchor-20260905.md` | 采用卡钉在历史采集题下,最新旁白没有卡;单分钟交付旁白机器腔 | 已验收通过(浏览器走查欠) | `codex/rectification-quality-probe-dedupe-20260904`(BUG-544~545) | | — | `PROGRESS-rectification-family-collect-continue-20260905.md` | 家人答「没有」后直接给结果,未继续收能区分分钟的经历 | 验收未通过(1 项行为回归 + 2 条断言未更新,见修复单) | `codex/rectification-quality-probe-dedupe-20260904`(BUG-546) | -| `TASK-rectification-family-collect-continue-fix-20260905.md` | — | `998406ce` 修复单:家人之后的 dated 补采集无条件抢在现成区分卡之前(choice-card A/B 卡用例回归),另两条顺序断言缺三栏说明 | 待执行 | `codex/rectification-family-collect-continue-fix-20260905`(BUG-547 起) | +| `TASK-rectification-family-collect-continue-fix-20260905.md` | `PROGRESS-rectification-family-collect-continue-20260905.md` | `998406ce` 修复单:家人之后的 dated 补采集无条件抢在现成区分卡之前(choice-card A/B 卡用例回归),另两条顺序断言缺三栏说明 | 待验收 | `codex/rectification-family-collect-continue-fix-20260905`(BUG-547) | | `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`(BUG-542 起) | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-family-collect-continue-20260905.md b/docs/testing/rectification-family-collect-continue-20260905.md index 301722f2..adb054e4 100644 --- a/docs/testing/rectification-family-collect-continue-20260905.md +++ b/docs/testing/rectification-family-collect-continue-20260905.md @@ -17,6 +17,15 @@ - ✅ 预期:下一句是还没问过的带年份经历。学业/感情/事业都有了的话,应是「钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗?」不是采用旁白,也不是「再问下去也分不开」。 - ❌ 失败:直接出现「我按你说的经历认真分析过了,下面是这次的结果。」或时间卡。 +## 1b. 已有搬家区分卡时不要改口问钱(P0 · BUG-547) + +同一轮里如果已经出现过搬家/离乡的 A/B 卡(「某年前后,有没有明显搬家…」),答完家人「没有」之后: + +- ✅ 预期:下一问仍是那张能分开剩余分钟的选择卡,不是财务口述。 +- ❌ 失败:改口问「钱的方面,还记得哪年收入明显变过…」。 + +没有区分卡、只说「没有」时,仍走第 1 条的财务采集。 + ## 2. 这题还没答时不要出时间卡(P0 · 叠 BUG-544) 家人答「没有」后,财务(或下一域)口述题还在: diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 49c00b5d..4805668b 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -1974,8 +1974,98 @@ export function buildMethodFollowupPlan(input: { const candidate = followupFromRanked(ranked); return candidate.choice_frame ? candidate : null; }; + const firstRenderableYearlessFollowup = (): MethodFollowup | null => { + for (const ranked of yearlessDiscriminators) { + const domain = contrastFollowupDomain( + ranked.eventProbe?.domain ?? ranked.contrastProbe?.domain ?? null, + ); + if (declined.has(domain)) continue; + const key = rankedDiscriminatorKey(ranked); + if (key && askedKeys.has(key)) continue; + const candidate = followupFromRanked(ranked); + if (candidate.choice_frame) return candidate; + } + return null; + }; + const precisionStageFollowup = (): MethodFollowup | null => { + if (stage === "lagna_frame") { + return makeFollowup({ + method_id: "dasha_events", + intent: "distinguish_candidates", + ask_theme: "dated_event", + domain: null, + kind_hint: null, + user_prompt_hint: ask( + "窗口里本命上升还可能落在两段。按 choice_frame.period 与探针问一件前事是否发生,用来筛这两段。不要问两套盘哪个更像。", + "本命上升 / Dasha", + ), + source: "precision_stage", + }); + } + if (stage === "d9_refine" && !relationshipCovered && !declined.has("relationship")) { + return makeFollowup({ + method_id: "d9_relationship", + intent: "distinguish_candidates", + ask_theme: "relationship_style", + domain: "relationship", + kind_hint: "relationship_change", + user_prompt_hint: ask( + "关系盘仍会换升。按探针年份问感情这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。", + "D9", + "对照 D9 上升类型表只作校时方法。", + ), + source: "precision_stage", + }); + } + if (stage === "d10_refine" && !declined.has("career")) { + return makeFollowup({ + method_id: "d10_career", + intent: "distinguish_candidates", + ask_theme: "career_style", + domain: "career", + kind_hint: "career_change", + user_prompt_hint: ask( + "事业盘仍会换升。按探针年份问事业这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。", + "D10", + "可用 D10 事业类型表作校时对照。", + ), + source: "precision_stage", + }); + } + if ((stage === "d4_refine" || stage === "theme_refine") && !declined.has("relocation")) { + return makeFollowup({ + method_id: "d4_home", + intent: "distinguish_candidates", + ask_theme: "home_change", + domain: "relocation", + kind_hint: "home_change", + user_prompt_hint: ask( + "居所盘仍会换升。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。", + "D4", + ), + source: "precision_stage", + }); + } + if (stage === "d5_refine" && !declined.has("education")) { + return makeFollowup({ + method_id: "d5_education", + intent: "distinguish_candidates", + ask_theme: "education_style", + domain: "education", + kind_hint: "education_milestone", + user_prompt_hint: ask( + "成就盘或学业盘仍会换升。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。", + "D5 / D24", + ), + source: "precision_stage", + }); + } + return null; + }; let datedCollect: MethodFollowup | null = null; + let renderableYearless: MethodFollowup | null = null; if (!next) { + const precisionCard = precisionStageFollowup(); if (!relationshipCovered && !declined.has("relationship")) { next = sameDomainYearlessCard("relationship") ?? makeFollowup({ method_id: "d9_relationship", @@ -2020,6 +2110,16 @@ export function buildMethodFollowupPlan(input: { source: "method_coverage", ...collectionYearFields(familyCollect), }); + } else if ((renderableYearless = firstRenderableYearlessFollowup())) { + next = renderableYearless; + } else if ( + // BUG-547: a renderable precision card beats dated collect, but only + // after the training gate is open. lagna_frame can borrow an unrelated + // probe into choice_frame while holdout is still short. + meetsAcceptanceEventQuality(input.evidence) + && precisionCard?.choice_frame + ) { + next = precisionCard; } else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined))) { next = makeFollowup(datedCollect); } else if (!occupationCovered) { @@ -2063,73 +2163,8 @@ export function buildMethodFollowupPlan(input: { && !declined.has("relocation")) ) { next = makeFollowup(otherCollectFollowup(input.evidence)); - } else if (stage === "lagna_frame") { - next = makeFollowup({ - method_id: "dasha_events", - intent: "distinguish_candidates", - ask_theme: "dated_event", - domain: null, - kind_hint: null, - user_prompt_hint: ask( - "窗口里本命上升还可能落在两段。按 choice_frame.period 与探针问一件前事是否发生,用来筛这两段。不要问两套盘哪个更像。", - "本命上升 / Dasha", - ), - source: "precision_stage", - }); - } else if (stage === "d9_refine" && !relationshipCovered && !declined.has("relationship")) { - next = makeFollowup({ - method_id: "d9_relationship", - intent: "distinguish_candidates", - ask_theme: "relationship_style", - domain: "relationship", - kind_hint: "relationship_change", - user_prompt_hint: ask( - "关系盘仍会换升。按探针年份问感情这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。", - "D9", - "对照 D9 上升类型表只作校时方法。", - ), - source: "precision_stage", - }); - } else if (stage === "d10_refine" && !declined.has("career")) { - next = makeFollowup({ - method_id: "d10_career", - intent: "distinguish_candidates", - ask_theme: "career_style", - domain: "career", - kind_hint: "career_change", - user_prompt_hint: ask( - "事业盘仍会换升。按探针年份问事业这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。", - "D10", - "可用 D10 事业类型表作校时对照。", - ), - source: "precision_stage", - }); - } else if ((stage === "d4_refine" || stage === "theme_refine") && !declined.has("relocation")) { - next = makeFollowup({ - method_id: "d4_home", - intent: "distinguish_candidates", - ask_theme: "home_change", - domain: "relocation", - kind_hint: "home_change", - user_prompt_hint: ask( - "居所盘仍会换升。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。", - "D4", - ), - source: "precision_stage", - }); - } else if (stage === "d5_refine" && !declined.has("education")) { - next = makeFollowup({ - method_id: "d5_education", - intent: "distinguish_candidates", - ask_theme: "education_style", - domain: "education", - kind_hint: "education_milestone", - user_prompt_hint: ask( - "成就盘或学业盘仍会换升。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。", - "D5 / D24", - ), - source: "precision_stage", - }); + } else if (precisionCard) { + next = precisionCard; } else { const d9 = input.observations?.find((item) => item.layer === "d9"); const d10 = input.observations?.find((item) => item.layer === "d10"); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index dc351e81..8b86e7db 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -751,6 +751,54 @@ test("distinguish follow-up copy forbids competing-chart ranking", () => { assert.match(plan.next_followup?.user_prompt_hint ?? "", /不得发明年份/); }); +test("family coverage without a renderable discriminator still collects the next dated domain", () => { + const plan = buildMethodFollowupPlan({ + evidence: [{ + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: null, + }, { + status: "confirmed", + domain: "relationship", + datePrecision: "year", + occurredFrom: "2018-01-01", + occurredTo: null, + }, { + status: "confirmed", + domain: "career", + datePrecision: "year", + occurredFrom: "2019-01-01", + occurredTo: null, + }, { + status: "confirmed", + domain: "family", + datePrecision: "year", + occurredFrom: "2020-01-01", + occurredTo: null, + }, { + status: "confirmed", + domain: "occupation", + datePrecision: "unknown", + occurredFrom: null, + occurredTo: null, + }, { + status: "confirmed", + domain: "horary", + datePrecision: "day", + occurredFrom: "2024-01-01", + occurredTo: null, + }], + precisionStage: "d4_refine", + birthDate: "1997-08-08", + }); + assert.equal(plan.next_followup?.method_id, "d2_finance"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.choice_frame, null); + assert.equal(plan.deferred_followup, null); +}); + test("GET A/B card stays visible after coverage when candidates remain tied", () => { const card = projectRectificationChoiceCard({ evidence: [{ diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index 53f38886..3323e074 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -248,7 +248,10 @@ test("career evidence alone still does not cover occupation", () => { contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, }); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); - assert.equal(plan.next_followup?.method_id, "occupation"); + // 原值: occupation + // 新值: d2_finance + // 原因: 家人已覆盖且无区分卡时,BUG-546 继续收未用带年份域;职业仍未覆盖,只是还没轮到 + assert.equal(plan.next_followup?.method_id, "d2_finance"); }); test("training gate open does not mint yearless existence or quality varga cards", () => { @@ -256,7 +259,10 @@ test("training gate open does not mint yearless existence or quality varga cards assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); assert.equal(plan.dropped_probes.some((item) => item.reason === "yearless_ungrounded_contrast"), true); - assert.equal(plan.next_followup?.method_id, "occupation"); + // 原值: occupation + // 新值: d2_finance + // 原因: yearless 存在/质量卡被丢掉后没有可渲染区分卡,BUG-546 先收财务采集 + assert.equal(plan.next_followup?.method_id, "d2_finance"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.choice_frame, null); });