Files
Jyotisha/docs/tasks/TASK-rectification-adopt-narration-20260904.md
T
Jesse_Chen 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

20 KiB
Raw Blame History

任务书 · 采集题答「没有」后旁白说「继续往下收」却直接出采用卡(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_rangesession_outcome = adopt_representativeprecision_stage = ready_to_adoptstop_reason = nullcurrent_question = nullquestion_source = "focus"

用户原话:"这个问题我回答的没有 结果就直接弹出卡片了 但是不应该继续往下提问或者走流程吗"、"弹出的时候也不明不白的"、"在这个阶段不应该继续引导收敛吗"。

1. 事实核对(用本案 decision receipt 在本地重放,不是猜)

用本案 latest_result.decisionReceipt 原样喂给 decideFromDossierpublicNextActionpersistNextInterviewAfterChoicefake accounting),结论与线上一致:

  1. 决策层是对的。 inference_state 里 14 条探针,已答 5 条;剩余 9 条对活跃集合 {05:00, 05:06, 04:53} 的 expected_outcomes 全部把 05:00 与 05:06 放在同一侧(或根本不含活跃候选):

    • 两道 relationship.2024.known_event_qualitycandidate_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 探针」,不看决策层是否已把它丢弃rectificationFollowupCataloginference.probes 里的 nakshatra 探针原样传进来,于是计划产出一个 intent=distinguish_candidates / source=nakshatra_boundary / domain="appearance" / choice_frame=null 的 followup;因 sessionOutcome=adopt_representative 被放进 deferred_followup
    • v9/answer-choice.ts interviewToPersistnext_followup ?? deferred_followupisRemainingDiscriminatorFollowupsource=nakshatra_boundary 返回 true → shouldSkipFollowupPersist 返回 falseBUG-472 加的「已可采用就不再落下一问、改出 adoptHostNarration」早退被绕过
    • 接着 persistFocusAfterChoiceserver-focus.ts persistServerOwnedFocus:无 choice_frame 的 distinguish → persistSpokenChoiceFallbackpersistCollectFocus,要写一个 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「明确没有发生」记为 declinedv_declined_skipped 投影不带 question_idmethod-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 分支顺序、publicCanAdoptsessionOutcomeAllowsAdopt、确认门、MIN_SEPARATION_LEADminUncertaintyAnswersmaxPlateauRounds
  2. 不改 Python 引擎、不 bump Skill 版本(保持 10.0.14)、不改探针身份 / hash / dropped_probes reason 集合。
  3. 不得为了让 collect:appearance 能落库而放宽 DB checkappearance / 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_framedistinguish_candidates
  2. answer-choice.ts isRemainingDiscriminatorFollowup:无 choice_frame 的 distinguish followup 不算「剩余区分题」(它本来就渲染不出来)。interviewToPersistdeferred_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_roundsstop_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)、采集题否定后(applyCollectFocusDenialnextInterview.hostNarration)、persistNextInterviewIfIdle 兜底。判定条件:decision.precisionStage === "ready_to_adopt" 且本轮无新焦点。模型来自 route 已解析的 resolvedModelanswer-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=truedeclined_skipped_topics=[family]。断言:decideFromDossieroffer_provisional_range / adopt_representative / canAdopt=true / probe=nullplan.next_followupplan.deferred_followup 均为 null 或均带 choice_framepersistNextInterviewAfterChoice 返回 persisted=falsechoiceReady=falsehostNarration(模板兜底路径)匹配 /分不开 05:00 和 05:06//可以从下面选一个先用着/、不匹配 /继续往下收/accounting.callsset_agentic_rectification_conversation_focus
  • 同一 fixture 的 adoptDeliveryFactsrepresentative_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-focuspersistableFocusDomain("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 保持返回 declinedDB 状态语义与 uncertaintyHighFromAnswersclassified_from="declined" 计数都依赖它,不动)。
  2. 改消费端:method-followup.ts declinedDomains 只把 intent === "collect_method_evidence" 的 declined/skipped 焦点算成领域拒答;intent === "distinguish_candidates" 的 declined 只是那一题的答案。v_declined_skipped 投影已带 intent 字段,不需要动迁移。
  3. dossierWithClosedFocusanswer-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" }]buildMethodFollowupPlanmethodsd10_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. 开工前置命令

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 errortests/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 cfa824992026-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 流程)。