# 任务书 · 出卡加精度门槛 + 引导式补经历(2026-09-16) ## 0. 基线 - 基线:`origin/staging` @ `071317f7`;staging 部署 `7061d0d1`(含 BUG-689 邀请语)。 - 分支:`codex/rectification-precision-gate-guided-collect-20260916`,worktree `.worktrees/rectification-precision-gate-guided-collect-20260916`。 - 校正 Skill 当前 `10.0.26`(`skills/jyotish-birth-time-rectification/SKILL.md`)。本单要 bump 到 `10.0.27`。 - 与本单同文件的待执行单:`TASK-rectification-collection-redesign-v2`(BUG-661~663,`da048676`)、BUG-626/627/628、631/632、633/634。**若那些单仍未领取,本单优先;执行方开工前 `git log origin/staging` 核对它们是否已合入,已合入的以 staging 为准 rebase,不得回退它们。** ## 1. 事故实证(2026-09-16 真机,脱敏) 30 分钟搜索窗,用户给了 5 件带年月的事、3 个领域(学业、事业、居所),其中 3 件落在同一年 6 个月内(同一段大运,等于 1 件证据)。家人、婚恋、收入三条线全部关闭。6 道带年月点选题 + 1 道性格题答完后,流程直接出交付卡: | 项目 | 数值 | | --- | ---: | | 交付区间宽度 | 20 分钟 | | 区间内候选 | 5 | | 前三名相对可能性 | 26% / 26% / 20% | | 5 件经历对每个候选的吻合 | 5/5(零区分) | 卡下是 BUG-689 的自由文本邀请:「你要是还记得确切哪一天的事,不限领域,说出来我接着算」。产品负责人反馈:**精度没达到要求就出卡了;之后也不该让用户随便打字,要引导用户填。** 代码定位(行号按符号): 1. `frontend/src/lib/rectification-agentic/core/rectification-decision.ts` `decideRectification`:分支 `stopClass.kind === "exhausted" && stopClass.reason === "tied_first" && !probe && input.targetedCollectExhausted !== false` 直接 `deliverRange(..., "exhausted")`。**没有任何宽度或领先度条件**。`classifyStop` 里 `tied_first` 来自 `candidate-separation.ts` 的 `tiedForFirst = top.score === runnerUp.score`。`stillNeedNarrowing` 只看 `refreshExhausted` / `targetedCollectExhausted`。 2. `core/credible-range.ts` `unionStillValidRange`:区间 = 落后头名不足 `MIN_SEPARATION_LEAD = 8` 分的簇并集。区间宽度从未参与出卡判断。 3. `v9/collection-question-pool.ts` `targetedDomainClosed` / `remainingTargetedDomains`:`declined` 与 `skipped` 同样永久关闭该线;`targetedCollectExhausted` 只看这个池是否为空。池空 → `rangeNarrowHint` 落到 `user-copy.ts` `rangeDeliveryCollectClosed`(自由文本邀请)。 4. `v9/answer-choice.ts` `isTargetedCollectExistenceSchema` 分支:B「没有发生过」→ `declined`;C「记不太清楚」/ D「这条先跳过」→ `skipped`(`choice-card.ts` 给 D 的 answer class 是 `weak_yes`)。`targetedCollectExistenceAck`:`declined` 回「记下了,这方面先跳过」,`skipped` 回「记下了,这题先放着」。**文案与语义对调**:答「没有发生过」的用户看到的是「先跳过」。这次真机三条线的回执都是「这方面先跳过」,按代码等于用户点的是 B;若用户点的其实是 D,则是落库错位,执行方按 T6 核实。 5. `scripts/rectification/event_probes.py` `_boundary_windows` / `_union_boundary_dates`:候选间大运边界差 `< MIN_BOUNDARY_DAYS = 45` 天不出题。窗口收到 20 分钟时边界位移约 22 天,**自动点选题池必然为空**,这是「问完了」的算术原因(`docs/tasks/PROGRESS-rectification-precision-gate-research-20260914.md` 已量过:放宽这个闸门对自动是非题无收益,本单不动它)。 6. 未定性观察:性格题(只做 ±1 排序微调、不淘汰)答完后,范围从 20 分钟弹到 28 分钟,下一题又缩回。疑似某簇卡在 `lead = 8` 边界,被 ±1 推过线。见 T6。 7. **时间点题答「没发生」之后整个领域被关掉**(产品负责人补充的实证):先问「某年某月前后,有没有开始一段认真关系、分手或结婚」,用户答「明确没有发生」(那个时间点确实没有);随后定向线问的是 `TARGETED_EXISTENCE_PROMPT.relationship = "结过婚或订过婚吗?"`,用户如实答「没有发生过」→ `declined` → 该领域永久关闭。用户在**别的时间**有过认真关系,从头到尾没被问到。同一张表里其余六条线同样是「领域名 + 两个例子」缩成一句是非题:`family` 只问「添丁或长辈住院」、`finance` 只问「收入变过或大笔进出」,答「没有」就等于整个领域没有。另外 `remainingTargetedDomains(layers, …)` 只从 `remainingLayers`(分盘上还能切开候选的层)映射领域,某层切不开候选时,那条线根本不问;但事件按大运时点计分与分盘层无关,这条限制没有依据。 ## 2. 根因 - 出口条件里没有精度项:七条线关闭 + 自动题池空 + 头名并列 = 出卡,宽度多少都出。 - 自动题池空之后没有第二个**有方向**的题源;剩下的只有一句让用户自由发挥的邀请。 - 「跳过」和「没有发生过」在数据层同义,用户想先放一放的线再也回不来。 - 定向线的是非题只覆盖领域里的一两个例子,却按整个领域记「没有」;时间点题的「没发生」也没有接一句「那别的时间有没有」。领域被关掉的不是用户的经历,是题干。 研究定论(`docs/research/rectification_minute_resolution_closure_2026_09_14.md`):调打分拉不开区间内并列,**唯一被证明有效的信息源是新的带年月经历**。所以本单只改出口条件和采集方式,不改打分。 ## 3. 决策记录(产品负责人 2026-09-16 拍板) | # | 决策 | 推翻 / 修改的既有口径 | | --- | --- | --- | | D1 | **出卡门槛**:交付区间宽度 ≤ 10 分钟,且头名比第二名的相对可能性至少多 3 个百分点(`RANGE_DELIVERY_TIE_PERCENT`)、`tiedForFirst = false`。三项同时满足才出交付卡。 | 现行「七条线关 + 题池空即出卡」 | | D2 | **用户确实补不出来时**(明确说「没有了 / 就这些」,或引导题源也空了):**按现在的规则出卡**,卡片、采用按钮、三句正文都不变。不加「未达门槛」标注。 | 保留 09-10「永远给结果」 | | D3 | **引导式补经历**:门槛未达时,系统点名逐题问,一次只问一件,题目指定「哪几年几月之间、哪一类事」。用户用**类型选项 + 年 / 月选择器(日可选)**录入,不再出现自由文本邀请句。输入框不禁用(BUG-551),用户打字仍然照记,但系统不再主动邀请打字。 | BUG-689 的自由文本邀请(卡上那句删除) | | D4 | **跳过的线再问一次**:答「记不太清楚」或「这条先跳过」的线,在引导阶段换一种更具体的问法再问一次;再次跳过才永久关闭。答「没有发生过」的线不再问。 | Skill §流程「用户已拒绝或跳过的目标不得换词重问」改为只限「拒绝 / 没有发生过」;BUG-687 防复发条改为「不得再列已答没有发生过的线」 | | D5 | **时间点的「没发生」不等于领域没有**(产品负责人会话中补充):某年某月的是非题答「明确没有发生」只关闭那个时间点,领域保持开放;领域的存在性问题必须问整个领域(任何时间、该领域全部事件类型),「没有发生过」的选项文案要写成「这类事都没有过」。**规则对七条线用同一张表驱动,不得因为这次是感情线就只改感情线,也不得给任何单个领域写死特殊流程。** | 现行「时间点题 + 窄题干答没有 = 领域关闭」;`remainingTargetedDomains` 只问分盘层还能切开候选的领域 | 不改的:打分尺度、`MIN_SEPARATION_LEAD = 8`、自动是非题的 45 天闸门、`MIN_ACCEPTANCE_DOMAINS = 2`、记录优先(BUG-691)、性格题 ±1 只排序不淘汰、「不用生日推年份」。 ## 4. 硬红线 1. 不得为了让门槛更容易达到而改打分、改 lead、改簇合并;门槛只是出口条件。 2. 引导题的年份、月份区间必须来自引擎的候选边界,不得由模型或前端发明;不得用生日推年份(BUG-648)。 3. 引导问法不得列已答「没有发生过」的线(BUG-687 改后的口径);跳过的线最多再问一次。 4. 引导录入的证据必须走现有带年月证据路径(同 `batch` / `set-focus` 落库、改账本指纹、快照过期重算),不得另开一条评分旁路。 5. 用户说「没有了 / 就这些」立即出卡,不得追问第二遍(D2)。 6. 不得在同一回复里一边要引导题一边给采用按钮(Skill 既有红线)。 7. `page.tsx` 不增长;新组件进 `frontend/src/components/`,新逻辑进 `lib/rectification-agentic/`。 8. Skill bump 后必须验证历史校正会话仍能打开(BUG-621 教训)。 9. 不得把研究里的离线命中率写成线上承诺;用户文案不得承诺「再补几件就能定到分钟」。 10. **七条线一张表**:题干、例子、重问句、录入芯片都从 `CollectKind` 的表里取;决策逻辑里不得出现 `if (domain === "relationship")` 这类按领域分叉。时间点题的负答案对任何领域都不关闭领域。 ## 5. 任务分解 ### T1 出卡门槛(前端决策层 + 策略常量) - `references/rectification_policy.v1.json` 增 `deliveryMaxWidthMinutes: 10`;`scripts/rectification_policy.py` 与 TS 侧各读一次,常量只定义在这一个文件里。 - `decideFromDossier` 计算 `precisionGateMet`:`credible_range` 宽度 ≤ 10 且交付列前两名 `probability_percent` 差 > 3 且 `!separation.tiedForFirst`。 - `decideRectification`:`tied_first` 耗尽分支、`!separation.sufficient` 的耗尽 / offer 分支、`stopClass.kind === "exhausted"` 分支,都加同一条判断:`precisionGateMet || input.userStopped || input.guidedCollectExhausted` 才 `deliverRange`;否则 `collect(...)` 并带上 T3 的引导题。`user_uncertainty_too_high` 分支保持原样(那是用户不配合的止损)。 - `withholdAdoptOnUnnarrowedRange`、`waitToNarrowCapability` 不动。 - 验收: - 单测:20 分钟 / 5 候选并列 → `collect` + 引导题,`canOfferRange = false`;8 分钟 / 前两名差 5 → `deliverRange`;8 分钟并列 → `collect`;门槛未达但 `guidedCollectExhausted = true` → 现行交付卡(含采用);`userStopped` → 现行交付卡。 - 既有测试改断言的,写「原值 / 新值 / 原因」三栏。 ### T2 引导题源(后端,`scripts/rectification/event_probes.py`,新函数不改旧函数) - 新增 `guided_collect_windows(request, built, *, candidate_times, today)`:复用 `_representative_pairs` + `_union_boundary_dates`,参数 `min_boundary_days = 0`、`include_pratyantar = True`、`varga_narayana = True`,得到剩余候选之间大运 / 中运 / 小运边界不一致的日期。每个窗口输出 `{year, month_lo, month_hi, domain, split: {left: n, right: n}}`: - `month_lo` / `month_hi` 为该边界在所有剩余候选上的最早与最晚月份(跨年拆两条); - `domain` 按 `_probe_domains` 的层序取该层对应领域,且剔除用户已答「没有发生过」的领域(请求里带 `declined_domains`); - `split` 是把剩余候选按边界前 / 后分成两组的人数,**按两组越接近一半越靠前**排序,其次按年份靠近今天;上限 6 条。 - 挂进 `refinement_packet.py` / `decision_policy.py` / `api_service.py` 的 packet,键名 `guided_collect_windows`,与 `discriminating_event_probes` 并列;自动题池非空时该列表可以为空(自动题优先)。 - 不改 `MIN_BOUNDARY_DAYS`、不改 `_discriminating_event_probe_lists`。 - 验收: - `tests/test_event_probes_guided_windows.py`(新):golden 用真实引擎输出(不得手造);20 分钟窗至少产出 1 条窗口;`declined_domains` 被剔除;排序按 split 均衡;跨年拆分正确。 - 用 `references/real_case_calibration/minute_rectification_holdout_v4.json` 离线回放:每例在六题后按 `guided_collect_windows` 顺序注入真值方向的带年月事件,记录**达到 D1 门槛所需件数**的中位数与 20 例中达标数(三档半径)。这组数字进进度记录,**不是合入门槛**,但必须报出来,产品据此决定门槛是否要调。 - `.venv/bin/python -m pytest tests/test_event_probes*.py tests/test_candidate_discriminator_contract.py tests/test_rectification_refresh_r3_r4.py` 全绿;快速门 quick 与基线逐条一致。 ### T3 引导问法与选择器录入(前端) - `collection-question-pool.ts` 新题型 `guided`,排在自动题之后、定向七条线之前: 1. **边界窗口题**(来自 T2):题干「YYYY 年 M 到 M 月之间,有没有<领域口语>?」,领域口语沿用 `USER_COLLECT_QUESTION` 的短语,不得复述服务器标签。选项:A「有,我来填时间」/ B「这段没有」/ C「记不清」。B、C 只关闭这一条窗口,不关闭领域。 2. **跳过线重问**(T4)。 3. **未覆盖领域的开放时间题**(含时间点题答「没发生」后的领域,T4b):七条线里尚无带年月证据、也没答过「这类事都没有过」的领域,问「<领域全称问法>,哪一年都算」,选项同上。 - A 之后出**录入卡**:类型芯片(沿用七条线的 `CollectKind`,默认选中题目的领域,可改)+ 年 / 月选择器(日可选)。新组件 `frontend/src/components/event-date-picker.tsx`,复用 `birth-date-picker.tsx` 的 `Calendar` / `Popover`,但以月为粒度、日可留空;年份范围出生年到今年。提交 → 走现有带年月证据落库路径(与 `TARGETED_YEAR_PROMPT` 答案落库同一条:`collect_kind` + 年月)→ 改账本指纹 → 快照过期 → 重算。 - `rangeNarrowHint`:门槛未达时**不再拼** `rangeDeliveryCollectClosed`;卡下提示改为「现在还剩 HH:MM–HH:MM 里 N 个候选,再对照几件经历会更准」。门槛已达或 D2 出卡时,卡片与现在完全一样(`rangeDeliveryShowsOpenCollectInvite` 那条自由文本邀请一并删除,`RANGE_DELIVERY_OPEN_COLLECT_*` 常量删掉,不留死代码)。 - 用户说「没有了 / 就这些 / 想不起来了」:由现有分类器判为 `userStopped`(不靠正则,BUG-641~643 口径)→ D2 出卡。 - 常驻区间条(`ca020498` 加的代表分钟与已答题数)加一段「已对照 N 件」即可,不加进度条、不写「还差几件」(`COLLECT_FLOW_BANNED_PHRASES` 照旧)。 - 验收: - `tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` fail 数与基线逐条一致(无 Docker 的 27 条照旧)。 - 新测试:引导题排序(自动题 > 边界窗口 > 跳过重问 > 未覆盖领域);A 后出录入卡;录入卡提交后证据落库、指纹变化、`snapshotCurrent = false`;B / C 只关窗口不关领域;门槛未达时 hint 不含邀请句、不含 `RANGE_DELIVERY_OPEN_COLLECT_*` 任何例子。 - 合同测试 fixture 来自真实引擎响应(golden)。 - `next build` 后 `/` 仍 Static;首屏 gzip ±2%(选择器组件按需加载,不进首屏)。 - `frontend/DESIGN.md` 增录入卡与选择器条目;文案对照 `frontend/docs/VOICE.md`。 ### T4 跳过的线再问一次 - `targetedDomainClosed`:`declined` 永久关闭;`skipped` 且尚无 `retry` 标记的线**不算关闭**,进 T3 第 2 类题源,题干用 `TARGETED_EXISTENCE_PROMPT_RETRY[domain]`(新表,每条比第一次更具体,例如家人线改问「父母或祖辈有没有住过院、做过手术,或者家里添过小孩」);重问后再答 C / D → 写 `retry: true` 并永久关闭。 - 修文案:B「没有发生过」回「记下了,这条按没有发生过记」;C / D 回「记下了,这题先放着,后面换个问法再问一次」。两句先过 VOICE。 ### T4b 领域存在性问题问整个领域(D5,七条线同一张表) - `TARGETED_EXISTENCE_PROMPT` 重写为「领域 + 全部事件类型 + 任何时间」的问法,例如感情线「感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?」;七条线都按这个格式改,表驱动,不许某条线特殊。B 选项文案改为「这类事都没有过」,`TARGETED_COLLECT_OPTION_B` 一处改全局生效。 - **时间点题(`source = event_probe` 的年月是非题)答「明确没有发生」不得写入任何会让 `targetedDomainClosed` / `declinedKinds` / `coveredCollectKinds` 判定领域关闭的状态**;它只是那一个探针的答案。若该领域尚无带年月证据,引导阶段紧接着出该领域的开放时间题(T3 第 3 类),最多一次。 - `remainingTargetedDomains` 不再以 `remainingLayers` 过滤:七个 `CollectKind` 按 `COLLECT_KIND_ORDER` 全部轮到,只剔除 `declined` 与已覆盖的。`remainingLayers` 仍可用于排序(能切开候选的层先问),不能用于剔除。 - 验收:单测「时间点题答 C → 领域仍在引导池」「窄题干旧文案不再出现(`agent-voice-copy-contract` 断言七条新题干)」「某层切不开候选时该领域仍被问到」;测试用表遍历七个领域,不许只测感情线。 - Skill 与 BUG-687 防复发条按 §3 D4 改写。 - 验收:单测覆盖「skipped 一次 → 重问 → 再 skipped → 关闭」「declined → 不重问」;`agent-voice-copy-contract` 跟上;`docs/BUG_HISTORY.md` BUG-687 条目补「2026-09-16 产品修改防复发口径」并链接本单。 ### T5 Skill 10.0.27 - `skills/jyotish-birth-time-rectification/SKILL.md`: - §流程那段(「带年月池空时先按剩余候选刷新一批…所有线问完或用户说没有了后才交付」)改为:自动题池空 → 按 `guided_collect_windows` 逐条问 → 跳过线重问 → 未覆盖领域;**出卡须 `precision_gate_met = true` 或用户说没有了**;引导题用选项 + 选择器录入,不得邀请自由打字。 - 「已拒绝或跳过的目标不得换词重问」改为「已拒绝(没有发生过)的不得重问;跳过的按服务器计划最多重问一次」。 - 出牌三句正文不变。 - bump `version: 10.0.27`,复制到 `versions/10.0.27/`,更新 `skills/skill-package-registry.json`;`CHANGELOG.md` 写明 bump。 - 验收:Skill 合同测试绿;**历史校正会话能打开**(`open` RPC 与版本绑定的既有测试 + 手工清单一条)。 ### T6 顺带核实(不阻塞 T1~T5 合入) - **回执文案对调**(§1 第 4 条):确认 B / C / D 三个选项各自落什么状态、回什么话;若 D 落成 `declined`,是 BUG;若只是文案对调,随 T4 修并记 BUG。 - **性格题后范围回弹**(§1 第 6 条):查 `varga_style` 的 ±1 是否写进 `posterior_score` 并进入 `unionStillValidRange`。若是,区间并集改用不含风格微调的分数(风格题只影响排序,本就不该改范围)。能复现写 `resolved` 并附回归测试;不能复现写 `investigating`,附已排除的路径。 - 两条各占一个 BUG 号。 ### T7 记录 - `docs/BUG_HISTORY.md`:出卡无精度门槛(1 条)、自由文本邀请无方向(可并入前一条)、跳过与拒绝同义(1 条)、T6 两条。编号从 **BUG-740** 起,开工时核对最大号。 - `CHANGELOG.md`、`frontend/DESIGN.md`、`frontend/docs/VOICE.md`(新句)、`docs/testing/rectification-guided-collect-20260916.md`(真机清单:门槛未达不出卡、引导题带年月区间、选择器录入后范围变化、说「没有了」出卡、跳过线重问一次、历史会话可开)。 - `docs/tasks/README.md` 状态板那一行随合入一起改。 ## 6. 让步顺序 1. T2 的 `include_pratyantar = True` 若单次重算超过 `JYOTISH_HEAVY_COMPUTE` 现有预算(进度记录里给毫秒数),先只用大运 + 中运边界,小运留 BLOCKED。 2. T3 选择器若让首屏 gzip 超 ±2% 且按需加载解决不了,退成「系统点名的题 + 年月文本框(只收 YYYY-MM)」,仍然不出自由文本邀请。 3. T2 的离线回放若超过 3 小时,只跑 ±10 与 ±30 两档,±60 写未测。 4. T6 可留 `investigating`,不得为了收口编根因。 5. 不得让步的:D1 三个条件、D2 立即出卡、D4 跳过只重问一次、Skill bump。 ## 7. 开工前置命令 ```bash git fetch origin --prune git status -sb # 确认不在别人的分支上 git worktree add -b codex/rectification-precision-gate-guided-collect-20260916 \ .worktrees/rectification-precision-gate-guided-collect-20260916 origin/staging grep -o "^## BUG-[0-9]*" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1 # 应为 BUG-739 grep -n "分不开\|跳过" skills/jyotish-birth-time-rectification/SKILL.md | head cd frontend && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -5 # 记基线失败清单 cd .. && .venv/bin/python -m pytest tests/test_event_probes*.py tests/test_candidate_discriminator_contract.py -q ``` Bug 检索(§5 硬约束):`BUG-687`、`BUG-689`、`BUG-646`~`648`、`BUG-651`、`BUG-653`、`BUG-654`、`BUG-621`、`BUG-641`~`643`、`BUG-594`、`BUG-623`,全部读完再动手。 ## 8. 验收口径(Claude 事后逐条) - T1~T5 每条验收标准通过 / 未通过 / 环境缺口三选一。 - 前端:tsc 0 错、lint 0 error、测试失败清单与基线逐条一致、`/` Static、gzip ±2%。 - Python:定向测试全绿、quick 门与基线一致。 - 部署:`/api/health` 的 `deployment.gitCommit` = 本单最后一次含门禁路径的 staging 提交;staging 若需迁移先 Migrate。 - 真机清单交产品负责人。 ## 9. BUG 编号起点 **BUG-740**(基线最大 BUG-739)。开工时重新核对。