Files
Jyotisha/docs/tasks/TASK-rectification-adopt-narration-20260904.md
T
Jesse_ChenandCursor 0aaa0d702e
Independent Staging Quality Gate / validate (push) Successful in 10m33s
Independent Staging Quality Gate / publish (push) Successful in 1m55s
fix(rectification): narrate exhausted-probe adopt instead of keep-collecting
When leftover probes cannot split adjacent minutes, skip frameless follow-ups and let a no-tool agent explain the stop. Distinguish-card "no" no longer closes a whole evidence domain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 14:22:13 +08:00

122 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务书 · 采集题答「没有」后旁白说「继续往下收」却直接出采用卡(2026-09-04)
基线:`origin/staging` `4d0a67a2`。本单只改 TypeScript 决策/落焦点层、新增一个只写旁白的小 Agent,以及测试;不改 Python 引擎、不改 Skill 包、不动确认门。与 `TASK-rectification-uncertainty-stop-20260903.md`(BUG-503,已合入)无文件级冲突。
## 0. 真实环境复现(用户 09-04 staging 反馈,事实)
用户答完 5 道区分题(D9 风格、D10 风格、两道事业年份题、一道搬家年份题)后,系统出了一道口述采集题:
> 2021 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。
用户回「没有」。系统回一句:
> 我按现有材料继续往下收。
然后**同一轮**直接出现「当前可能的出生时间」采用卡:05:00(相对支持度 21/ 05:0618/ 04:539),composer 没有任何问题。GET 返回 `interview.type = offer_provisional_range``session_outcome = adopt_representative``precision_stage = ready_to_adopt``stop_reason = null``current_question = null``question_source = "focus"`
用户原话:"这个问题我回答的没有 结果就直接弹出卡片了 但是不应该继续往下提问或者走流程吗"、"弹出的时候也不明不白的"、"在这个阶段不应该继续引导收敛吗"。
## 1. 事实核对(用本案 decision receipt 在本地重放,不是猜)
用本案 `latest_result.decisionReceipt` 原样喂给 `decideFromDossier``publicNextAction``persistNextInterviewAfterChoice`fake accounting),结论与线上一致:
1. **决策层是对的。** `inference_state` 里 14 条探针,已答 5 条;剩余 9 条对活跃集合 {05:00, 05:06, 04:53} 的 `expected_outcomes` 全部把 05:00 与 05:06 放在同一侧(或根本不含活跃候选):
- 两道 `relationship.2024.known_event_quality``candidate_ids` 只覆盖窗口两端(04:4504:51、05:0805:15),不含任何活跃候选。
- `career.2023.dasha_activation`:yes/no 都是三个活跃候选同进同退 → `no_split_among_active`
- `varga.d24 / d12 / d7 / d4 / d5` 五条无年份对比:至多把 04:53 单独分出来,05:00 与 05:06 永远同组;且按 BUG-444 规则作 `yearless_ungrounded_contrast` 丢弃。
- `nakshatra-boundary`:yes 同时支持三个活跃候选 → 无区分力。
于是 `decideRectification``!separation.sufficient → 无 probe → holdout 因 canAdopt 已真而跳过(BUG-463 决策)→ 无 dated collect → completeWithRange("offer")`,得到 `offer_provisional_range / adopt_representative / ready_to_adopt`。**引擎手里已经没有任何一道能把 05:00 和 05:06 分开的题**receipt `reasons` 里也写着 `adjacent_minutes_indistinguishable`。这一步不是 bug,是设计:先给范围与代表分钟,采用后再用保留的 holdout 事件和 OOS 三条线(family / finance / health_pressure)核对。
2. **旁白与落焦点层错了,而且错在 BUG-472 修过的那条路上。** 链路如下(符号定位,行号会漂):
- `v9/method-followup.ts` `buildMethodFollowupPlan` 的 nakshatra 分支(`if (!next && input.holdoutValidation !== "not_started" && !datedMethodCollectOpen(methods) && input.nakshatraProbe)`)只看「有没有 nakshatra 探针」,**不看决策层是否已把它丢弃**。`rectificationFollowupCatalog``inference.probes` 里的 nakshatra 探针原样传进来,于是计划产出一个 `intent=distinguish_candidates / source=nakshatra_boundary / domain="appearance" / choice_frame=null` 的 followup;因 `sessionOutcome=adopt_representative` 被放进 `deferred_followup`
- `v9/answer-choice.ts` `interviewToPersist``next_followup ?? deferred_followup``isRemainingDiscriminatorFollowup``source=nakshatra_boundary` 返回 true → `shouldSkipFollowupPersist` 返回 **false****BUG-472 加的「已可采用就不再落下一问、改出 adoptHostNarration」早退被绕过**
- 接着 `persistFocusAfterChoice``server-focus.ts` `persistServerOwnedFocus`:无 `choice_frame` 的 distinguish → `persistSpokenChoiceFallback``persistCollectFocus`,要写一个 `collect:appearance:collect_method_evidence` 焦点,题干是开场通用题「从你最容易想起来的一件事开始就好……」。
- `persistableFocusDomain("appearance")` 落到兜底 `return domain`,而 `agentic_rectification_conversation_focus.target_domain` 的 check 只允许 `education/career/relationship/relocation/finance/health/family/other` → 写库失败(非 idempotency 冲突)→ `status: "skipped"` → 重试一次仍 skipped → `open` 为 null → followup 既非 collect 也无 frame → 落到最后一行 `spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback` = **「我按现有材料继续往下收。」**
- 本地重放(fake accounting 不做 check 约束)得到的是更糟的形状:真的落下了 `collect:appearance` 焦点并把**开场题**再问一遍,同时采用卡照出。两种结果都是错的;线上只是碰巧被 DB 约束挡成了空旁白。
3. **`rectification-collect-stall.test.ts` 的「live five-evidence case opens adopt」为什么没拦住**:该 fixture 的 `inference_state.probes` 里没有 nakshatra 探针、receipt 里没有 `window_scan`,所以计划 `deferred_followup=null`,早退成立,断言 `/可以从下面选一个先用着/` 通过。真实 receipt 两者都有。
4. **潜在的第二个问题(本案未改变结局,列为 P2)**`choice-action.ts` `focusStatusForAnswer` 把区分题选 C「明确没有发生」记为 `declined``v_declined_skipped` 投影不带 question_id`method-followup.ts` `declinedDomains` 把任何 declined 焦点的 `target_domain` 当成「用户拒答该领域」。本案用户对事业 2023 题、搬家 2015 题都选了 C(这两个答案**都参与了计分**,把 05:00 从 23 压到 21、04:53 从 7 抬到 9),却顺带把 career、relocation 两条线整体标成 declined:后续 `remainingConflictProbes`、D10/D4 分盘观察分支、`d10_refine / d4_refine` 精度阶段分支都会因 `declined.has(domain)` 跳过。用本案 receipt 重放,declined 只留 family 与留 {career, relocation, family} 决策相同,所以本案没有被它改写;但换一个还有事业年份题可问的案子,一次「没发生」就会把整条线关掉。
## 2. 决策记录(产品授权)
1. **采用门语义不变。** 候选分不开、探针池空时先出范围 + 代表分钟 + 采用卡,采用后再核对(BUG-463 / BUG-472 / BUG-501 的既定设计)。本单**不**把 holdout / OOS 提前到采用前——引擎剩余探针与保留事件都不能把相邻 6 分钟分开,提前问只是表演。若产品负责人希望改成「采用前先问保留事件」,另开任务书并在此处覆盖本条。
2. 用户看到的旁白必须说清**为什么停**(剩下的问题分不开 05:00 和 05:06)、**现在的范围与代表分钟**、**采用后拿什么核对**;BUG-472 的 `deliveryAdoptNarration` 模板句降为兜底,见第 3 条。
3. **交付旁白由 Agent 写,不再是模板句**(产品负责人 09-04 拍板)。用户「没有」这句已经是模型分类器(`classifyRectificationTurnIntent`)判的,判得也对;问题出在分类**之后**的确定性快路径:`decideFromDossier` 算完就把一句固定文案经 `persistV9DeterministicTurn` 写回并返回,Agent 没上场,所以「不明不白」。改为:决策层仍是唯一权威(分不分得开、能不能采用、范围与代表分钟、采用后拿什么核对,全部来自 receipt,模型不得参与也不得改写),但把这些**结构化事实交给一个只写旁白的小 Agent**,让它用自己的话解释「为什么停、现在的范围、采用后怎么核对」。模板句降级为模型失败 / 超时 / 校验不过时的兜底。多一次模型调用、多 1–2 秒等待是接受的代价。
4. 「没有」= 对这一道题的否定,不等于「这条线以后都别问」。仅当焦点本身是**采集题**(`collect_method_evidence`)时,否定才算拒答该领域;对计分区分题的否定只计分、不关线。这条覆盖 BUG-440 里「答 C declined 计分」的表述中「declined 即覆盖」的那一半:D12 同领域卡「没有」仍计分,但 career/relocation 等**有年份**的区分题答「没有」不再写成领域拒答。
## 3. 硬红线
1. 不改 `decideRectification` 分支顺序、`publicCanAdopt``sessionOutcomeAllowsAdopt`、确认门、`MIN_SEPARATION_LEAD``minUncertaintyAnswers``maxPlateauRounds`
2. 不改 Python 引擎、不 bump Skill 版本(保持 10.0.14)、不改探针身份 / hash / `dropped_probes` reason 集合。
3. 不得为了让 `collect:appearance` 能落库而放宽 DB check`appearance / marks / horary / nakshatra` 这类非证据领域不得成为口述采集焦点。
4. 不得再新增一处「先落焦点、失败了再兜底旁白」的路径;早退判定只在 `shouldSkipFollowupPersist` 一处。
5. 测试总数不得低于 `origin/staging` `4d0a67a2` 的实测;改既有断言写「原值 / 新值 / 原因」。
6. 旁白 Agent 只写话,不做决定:不能拿到工具、不能改焦点、不能改 sessionOutcome;输入只有服务端给的结构化事实,输出经校验后才落库。它说出的每一个 `HH:MM`、每一个年份、每一个「相对支持度」数字都必须能在输入事实里找到,否则整段作废走模板兜底,不得部分采用。
7. 模型原文不得进 `docs/BUG_HISTORY.md`、进度记录或测试 fixture;测试用 fake model 返回固定文本。
## 4. 任务分解
### 4.1 P0 · 已丢弃的区分探针不得绕过采用早退(BUG-516)
1. `buildMethodFollowupPlan` 的 nakshatra 分支必须与决策层同一口径:只有 `nakshatraProbeIfFollowupCanAsk` 会选中的探针才产出 followup。实现方式二选一,实现者定并写进 BUG 条目:(a) `rectificationFollowupCatalog` 传入的 `nakshatraProbe` 先过 `inspectDiscriminatorProbes`(同 `askedKeys / topCandidateTimes`),不可渲染即传 null;(b) 分支内 `makeFollowup(..., true, true)` 产出无 `choice_frame` 时直接丢弃并进 `dropped_probes`。**两种都要保证** `plan.deferred_followup` 不再含无 `choice_frame``distinguish_candidates`
2. `answer-choice.ts` `isRemainingDiscriminatorFollowup`:无 `choice_frame` 的 distinguish followup 不算「剩余区分题」(它本来就渲染不出来)。`interviewToPersist``deferred_followup` 只在其可渲染时才取。
3. `server-focus.ts` `persistableFocusDomain`:非白名单领域返回 `null`(不得返回 `appearance / horary / nakshatra`);`persistSpokenChoiceFallback` 只允许 `collectQuestionDomain` 后仍在白名单内的领域,否则返回 `status: "skipped"` 且**不**触发 `persistFocusAfterChoice` 的二次重试。既有断言 `persistableFocusDomain("horary") === "horary"` 改为 `null`:原值 `"horary"` / 新值 `null` / 原因:DB check 不含 horary,原断言锁的是一条必然写库失败的路径。
4. **交付旁白改为 Agent 生成,模板兜底。** 新增 `v9/adopt-narration-agent.ts`(模式照抄 `turn-intent-classifier.ts`:单次 `Agent.generate`,无工具,`abortSignal` 透传,超时上限与分类器同级):
- 输入 `AdoptDeliveryFacts`(服务端从 `decideFromDossier` 结果 + receipt 拼,纯函数 `adoptDeliveryFacts(decision, dossier)`,可单测):`credible_range`(起止分钟)、`representative_minute`、活跃候选与相对支持度、`opening_window`(最初范围,`deliveryAdoptNarration` 已在用)、`answered_rounds``stop_facts`(本轮为何停:`dropped_probes` 按 reason 归并成用户能懂的几类——「剩下的题分不开 A 和 B」「没有年份的分盘题不再问」「婚恋题只覆盖窗口两端」;每类附计数,不附探针 hash)、`post_adopt_verification`(预留 holdout 事件的领域与年份、OOS 三条线的领域名;来自 `holdout_validation_probes` / `oos_blind_prompts`,不含用户原文)。
- 指令:只写 2–4 句中文旁白,对用户说清三件事:为什么这一轮不再往下问、现在给的范围与代表分钟是什么、采用之后会用哪些事核对;最后一句固定接 `RECTIFICATION_USER_COPY.adoptCue`(「可以从下面选一个先用着。」)由服务端拼,不让模型写。不得出现输入里没有的时间、年份、数字;不得承诺「确认」「精确」;不得再提问。
- 校验(服务端,`validateAdoptNarration(text, facts)`):所有 `\d{1,2}:\d{2}` 必须 ∈ facts 的分钟集合;所有四位年份必须 ∈ facts 的年份集合;不含「?」结尾的句子;长度 ≤ 240 字。不过就整段丢弃,用 `deliveryAdoptNarration` 模板(模板本身按本单 4.1.4 旧方案补一句停止原因,作为兜底也要能解释)。
- 接入点:**只在采用卡首次打开的那一轮**调用,三条入口都要接——点选区分题后(`answer_choice` 动作,`applied.narration`)、采集题否定后(`applyCollectFocusDenial``nextInterview.hostNarration`)、`persistNextInterviewIfIdle` 兜底。判定条件:`decision.precisionStage === "ready_to_adopt"` 且本轮无新焦点。模型来自 route 已解析的 `resolvedModel``answer-choice.ts` / `server-focus.ts` 保持零模型依赖,Agent 调用放在 route 层或一个薄的 `deliverAdoptNarration({ model, facts, fallback })`
- 计费:与分类器相同口径(若分类器不计费,这一次也不计费;若计费,写进 `CHANGELOG.md`)。
- `decisionReceipt` 已有 `dropped_probes` / `holdout_validation_probes` / `oos_blind_prompts`,不需要新字段;若需要在 `publicNextAction` 增加只读 `exhausted_probe_pool: boolean`,允许。
5. 采用卡出现的那一轮,`current_question` 必须为 null 且不得有任何 `set_agentic_rectification_conversation_focus` 调用(BUG-472 断言原样搬到新 fixture)。
验收:
- 新测试 `frontend/tests/rectification-adopt-narration-20260904.test.ts`:fixture 复刻本案形状——五件已确认证据(education×1 作 holdout、relationship×2、career×2)、`inference_state` 含 14 条探针(其中 nakshatra 一条、五条 yearless varga、一条 dasha_activation、两条 candidate_ids 不含活跃候选的 known_event_quality)、5 条 answered、活跃 {05:00, 05:06, 04:53} 分数 21/18/9、receipt 含 `window_scan.d*_candidates_differ=true``declined_skipped_topics=[family]`。断言:`decideFromDossier``offer_provisional_range / adopt_representative / canAdopt=true / probe=null``plan.next_followup``plan.deferred_followup` 均为 null 或均带 `choice_frame``persistNextInterviewAfterChoice` 返回 `persisted=false``choiceReady=false``hostNarration`(模板兜底路径)匹配 `/分不开 05:00 和 05:06/``/可以从下面选一个先用着/`、不匹配 `/继续往下收/``accounting.calls``set_agentic_rectification_conversation_focus`
- 同一 fixture 的 `adoptDeliveryFacts``representative_minute="05:00"`、范围 05:0005:06、`stop_facts` 含「分不开」一类且计数 ≥ 1、`post_adopt_verification` 含 education 2016 与 family/finance/health 三条线名。
- `adopt-narration-agent` 测试(fake model):(a) 模型返回含事实内分钟与年份的正常文本 → 采用,末尾拼上 `adoptCue`(b) 模型返回含 `04:58`(不在事实里)→ 整段丢弃,返回模板;(c) 模型返回以「?」结尾 → 丢弃;(d) 模型抛错 / abort → 模板,且不抛出到 route;(e) 三条入口各一条 route 级测试,断言只在 `ready_to_adopt` 首轮调用一次模型,采用后再刷新不再调用。
- 同一 fixture 走 `applyCollectFocusDenial`(family 采集焦点 + 否定)得到同样旁白。
- `rectification-server-focus``persistableFocusDomain("appearance") === null``("horary") === null``("nakshatra") === null`;对 `domain="appearance"` 的 distinguish followup(无 frame)调用 `persistServerOwnedFocus` 返回 `skipped` 且零次 RPC。
- `rectification-collect-stall` 的「live five-evidence case」fixture 补上 nakshatra 探针与 `window_scan`,原断言不变仍需通过(这是回归锁)。
- `docs/BUG_HISTORY.md` 新增 BUG-516:复发自 BUG-472(同一早退被 nakshatra deferred followup 绕过);相关 BUG-440、BUG-444、BUG-463。
### 4.2 P2 · 区分题答「明确没有发生」不再把整条领域标成拒答(BUG-517)
1. `focusStatusForAnswer` 保持返回 `declined`DB 状态语义与 `uncertaintyHighFromAnswers``classified_from="declined"` 计数都依赖它,不动)。
2. 改消费端:`method-followup.ts` `declinedDomains` 只把 **`intent === "collect_method_evidence"`** 的 declined/skipped 焦点算成领域拒答;`intent === "distinguish_candidates"` 的 declined 只是那一题的答案。`v_declined_skipped` 投影已带 `intent` 字段,不需要动迁移。
3. `dossierWithClosedFocus``answer-choice.ts`)追加的行必须带 `intent`,否则消费端无法区分。
4. `domainCollectFocusAsked` 语义不变。
验收:
- `rectification-collect-stall`「denying the dated family collect declines relatives」原断言不变(采集题否定仍算拒答)。
- 新增:同一 dossier,把 `declined_skipped_topics` 换成 `[{ target_domain: "career", intent: "distinguish_candidates", status: "declined" }]``buildMethodFollowupPlan``methods``d10_career` 不得因此变成 `covered`,且 career 域的剩余有年份探针仍可被 `remainingConflictProbes` 选中。
- 用本案 receipt 形状重放,declined 为 `[family(collect)]``[family(collect), career(distinguish), relocation(distinguish)]` 两种输入,决策与旁白相同(本案不受影响,作为不变量锁住)。
- `docs/BUG_HISTORY.md` 新增 BUG-517,关联 BUG-440(说明 BUG-440 的「答 C declined 计分」为什么当时对 D12 同领域卡成立,而对有年份区分题不成立)。
## 5. 让步顺序
1. P0 全做。若 4.1.4 的旁白 Agent 本轮做不完,最低交付是模板句补上停止原因(4.1.4 里「模板本身也要能解释」那一半),并在 BUG-516 条目与进度记录里写明「Agent 旁白未做」,不得把模板版写成完成。模板句若与 BUG-503 已加的 `stopReasonPrefix` 排版冲突,先保留 BUG-503 的原因句,新句放其后,不得删任何一句。
2. P2 若时间不够可拆成 `-fix` 单独做,但 BUG-517 条目必须以 `investigating` 状态先写进 `docs/BUG_HISTORY.md`
3. 任何一步需要放宽采用门 / 确认门才能通过测试,立即停手写 `BLOCKED.md`
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-adopt-narration-20260904 .worktrees/rectification-adopt-narration-20260904 origin/staging
cd .worktrees/rectification-adopt-narration-20260904/frontend
./node_modules/.bin/tsc --noEmit
npx tsx --test tests/rectification-collect-stall.test.ts tests/rectification-server-focus.test.ts tests/rectification-provisional-adopt.test.ts tests/rectification-adopt-flow-fix-20260903.test.ts
npx tsx --test tests/*.test.ts 2>&1 | tail -12 # 记下开工时总数与失败清单
```
验收命令(交付前):`tsc --noEmit` 0 错;`npm run lint` 0 error`tests/rectification-*.test.ts tests/agentic-rectification-*.test.ts tests/birth-time-rectification-contract.test.ts` fail=0;全量 `tests/*.test.ts` 失败清单与开工基线逐条一致;`next build``/` 仍 Static。
## 7. BUG 编号起点
开工时核对 `docs/BUG_HISTORY.md` 最大号;截至本单撰写为 **BUG-515**,本单从 **BUG-516** 起。同步 `origin/staging` `cfa82499`2026-09-04)后,BUG-516518 已被时间线/选择题 UX 单占用;本单实际落点为 **BUG-519**(原 516)与 **BUG-520**(原 517)。
## 8. 真实环境复核清单(实现者做不了的,交给产品负责人)
`docs/testing/`(实现者补一条 `rectification-adopt-narration-20260904.md`):在 staging 新开一个校正会话,走到探针池耗尽;预期采用卡出现的那一轮旁白说出「分不开 A 和 B,先给范围……可以从下面选一个先用着」,composer 旁有「先这样」而没有再冒出开场题;采用后进入核对题(BUG-501/502 流程)。