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>
20 KiB
任务书 · 采集题答「没有」后旁白说「继续往下收」却直接出采用卡(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:06(18)/ 04:53(9),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),结论与线上一致:
-
决策层是对的。
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:45–04:51、05:08–05: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 分开的题;receiptreasons里也写着adjacent_minutes_indistinguishable。这一步不是 bug,是设计:先给范围与代表分钟,采用后再用保留的 holdout 事件和 OOS 三条线(family / finance / health_pressure)核对。
- 两道
-
旁白与落焦点层错了,而且错在 BUG-472 修过的那条路上。 链路如下(符号定位,行号会漂):
v9/method-followup.tsbuildMethodFollowupPlan的 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.tsinterviewToPersist取next_followup ?? deferred_followup,isRemainingDiscriminatorFollowup见source=nakshatra_boundary返回 true →shouldSkipFollowupPersist返回 false → BUG-472 加的「已可采用就不再落下一问、改出 adoptHostNarration」早退被绕过。- 接着
persistFocusAfterChoice→server-focus.tspersistServerOwnedFocus:无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 约束挡成了空旁白。
-
rectification-collect-stall.test.ts的「live five-evidence case opens adopt」为什么没拦住:该 fixture 的inference_state.probes里没有 nakshatra 探针、receipt 里没有window_scan,所以计划deferred_followup=null,早退成立,断言/可以从下面选一个先用着/通过。真实 receipt 两者都有。 -
潜在的第二个问题(本案未改变结局,列为 P2):
choice-action.tsfocusStatusForAnswer把区分题选 C「明确没有发生」记为declined;v_declined_skipped投影不带 question_id;method-followup.tsdeclinedDomains把任何 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. 决策记录(产品授权)
- 采用门语义不变。 候选分不开、探针池空时先出范围 + 代表分钟 + 采用卡,采用后再核对(BUG-463 / BUG-472 / BUG-501 的既定设计)。本单不把 holdout / OOS 提前到采用前——引擎剩余探针与保留事件都不能把相邻 6 分钟分开,提前问只是表演。若产品负责人希望改成「采用前先问保留事件」,另开任务书并在此处覆盖本条。
- 用户看到的旁白必须说清为什么停(剩下的问题分不开 05:00 和 05:06)、现在的范围与代表分钟、采用后拿什么核对;BUG-472 的
deliveryAdoptNarration模板句降为兜底,见第 3 条。 - 交付旁白由 Agent 写,不再是模板句(产品负责人 09-04 拍板)。用户「没有」这句已经是模型分类器(
classifyRectificationTurnIntent)判的,判得也对;问题出在分类之后的确定性快路径:decideFromDossier算完就把一句固定文案经persistV9DeterministicTurn写回并返回,Agent 没上场,所以「不明不白」。改为:决策层仍是唯一权威(分不分得开、能不能采用、范围与代表分钟、采用后拿什么核对,全部来自 receipt,模型不得参与也不得改写),但把这些结构化事实交给一个只写旁白的小 Agent,让它用自己的话解释「为什么停、现在的范围、采用后怎么核对」。模板句降级为模型失败 / 超时 / 校验不过时的兜底。多一次模型调用、多 1–2 秒等待是接受的代价。 - 「没有」= 对这一道题的否定,不等于「这条线以后都别问」。仅当焦点本身是采集题(
collect_method_evidence)时,否定才算拒答该领域;对计分区分题的否定只计分、不关线。这条覆盖 BUG-440 里「答 C declined 计分」的表述中「declined 即覆盖」的那一半:D12 同领域卡「没有」仍计分,但 career/relocation 等有年份的区分题答「没有」不再写成领域拒答。
3. 硬红线
- 不改
decideRectification分支顺序、publicCanAdopt、sessionOutcomeAllowsAdopt、确认门、MIN_SEPARATION_LEAD、minUncertaintyAnswers、maxPlateauRounds。 - 不改 Python 引擎、不 bump Skill 版本(保持 10.0.14)、不改探针身份 / hash /
dropped_probesreason 集合。 - 不得为了让
collect:appearance能落库而放宽 DB check;appearance / marks / horary / nakshatra这类非证据领域不得成为口述采集焦点。 - 不得再新增一处「先落焦点、失败了再兜底旁白」的路径;早退判定只在
shouldSkipFollowupPersist一处。 - 测试总数不得低于
origin/staging4d0a67a2的实测;改既有断言写「原值 / 新值 / 原因」。 - 旁白 Agent 只写话,不做决定:不能拿到工具、不能改焦点、不能改 sessionOutcome;输入只有服务端给的结构化事实,输出经校验后才落库。它说出的每一个
HH:MM、每一个年份、每一个「相对支持度」数字都必须能在输入事实里找到,否则整段作废走模板兜底,不得部分采用。 - 模型原文不得进
docs/BUG_HISTORY.md、进度记录或测试 fixture;测试用 fake model 返回固定文本。
4. 任务分解
4.1 P0 · 已丢弃的区分探针不得绕过采用早退(BUG-516)
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。answer-choice.tsisRemainingDiscriminatorFollowup:无choice_frame的 distinguish followup 不算「剩余区分题」(它本来就渲染不出来)。interviewToPersist对deferred_followup只在其可渲染时才取。server-focus.tspersistableFocusDomain:非白名单领域返回null(不得返回appearance / horary / nakshatra);persistSpokenChoiceFallback只允许collectQuestionDomain后仍在白名单内的领域,否则返回status: "skipped"且不触发persistFocusAfterChoice的二次重试。既有断言persistableFocusDomain("horary") === "horary"改为null:原值"horary"/ 新值null/ 原因:DB check 不含 horary,原断言锁的是一条必然写库失败的路径。- 交付旁白改为 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,允许。
- 输入
- 采用卡出现的那一轮,
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:00–05: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)
focusStatusForAnswer保持返回declined(DB 状态语义与uncertaintyHighFromAnswers的classified_from="declined"计数都依赖它,不动)。- 改消费端:
method-followup.tsdeclinedDomains只把intent === "collect_method_evidence"的 declined/skipped 焦点算成领域拒答;intent === "distinguish_candidates"的 declined 只是那一题的答案。v_declined_skipped投影已带intent字段,不需要动迁移。 dossierWithClosedFocus(answer-choice.ts)追加的行必须带intent,否则消费端无法区分。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. 让步顺序
- P0 全做。若 4.1.4 的旁白 Agent 本轮做不完,最低交付是模板句补上停止原因(4.1.4 里「模板本身也要能解释」那一半),并在 BUG-516 条目与进度记录里写明「Agent 旁白未做」,不得把模板版写成完成。模板句若与 BUG-503 已加的
stopReasonPrefix排版冲突,先保留 BUG-503 的原因句,新句放其后,不得删任何一句。 - P2 若时间不够可拆成
-fix单独做,但 BUG-517 条目必须以investigating状态先写进docs/BUG_HISTORY.md。 - 任何一步需要放宽采用门 / 确认门才能通过测试,立即停手写
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 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-516~518 已被时间线/选择题 UX 单占用;本单实际落点为 BUG-519(原 516)与 BUG-520(原 517)。
8. 真实环境复核清单(实现者做不了的,交给产品负责人)
见 docs/testing/(实现者补一条 rectification-adopt-narration-20260904.md):在 staging 新开一个校正会话,走到探针池耗尽;预期采用卡出现的那一轮旁白说出「分不开 A 和 B,先给范围……可以从下面选一个先用着」,composer 旁有「先这样」而没有再冒出开场题;采用后进入核对题(BUG-501/502 流程)。