Host only says 记下了 after a real write; Mastra schema rejections fail closed. Ledger year keys no longer drop quality probes, dual-dasha agreement is per cluster, width uses cluster span, and public house tables follow the inference minute. Co-authored-by: Cursor <cursoragent@cursor.com>
18 KiB
TASK · 证据轮模型只说「记下了」却没写证据、没设下一问:财务一件落空后流程停在原题(2026-09-10)
- 基线:
origin/staging@d96b24c2(BUG-626/627/633/634 已合入并部署;/api/health的deployment.gitCommit=d96b24c2) - 分支:
codex/rectification-unwritten-evidence-claim-20260910,基于origin/staging - 串行:无同文件并行单。
TASK-rectification-evidence-turn-empty-answer-20260910.md(BUG-633/634)已合入基线,本单在其applyHostFallback/ 失败刷新之上叠加,不得回退它的任何断言。 - 执行方:coding agent;验收:Claude
- 涉及文件(行号按
d96b24c2,定位以符号为准):frontend/src/app/api/rectification/agent/route.ts(collect 焦点分支 L522–597、classifyRectificationTurnIntentL544、runV9AgentTurn({L847)、frontend/src/lib/rectification-agentic/v9/agent-run.ts(V9AgentRunOptionsL96–112、RETRYABLE_ERROR_CODESL150、applyHostFallbackL843、streamAttempt收尾 L1025–1090、RectificationRunDiagnostic.stateMutationCommittedL1109、buildAgentMessagesL1180)、v9/host-fallback.ts(publicWriteToolCompletedL16)、v9/stream-mapping.ts(mapStreamChunkToActivityL233–257)、frontend/src/lib/rectification-agentic/user-copy.ts(RECTIFICATION_USER_COPY)、frontend/src/components/rectification-agentic-chat.tsx(liveQuestionOnMessagesL1501、questionGapL1513、输入框占位 L1855)、frontend/src/lib/rectification-surface-state.ts(rectificationQuestionGapStateL300) - BUG 编号起点:BUG-635(
docs/BUG_HISTORY.md当前最大 BUG-634) - 优先级:BUG-635 P1(证据被静默丢弃、流程停住,用户看不出任何异常);BUG-636 P2(同一路径的 schema 拒绝会被报成 completed,诊断盲区)
- 不改 Skill 版本(保持
10.0.21),不改引擎、不改淘汰阈值、不改分类器提示词。
0. 给产品的即时绕行(不等修复)
在同一个校正会话里再发一次那句带年月的财务经历(原句即可)。当前活动焦点仍是财务采集题,分类器会再跑一次,模型大概率会走 batch。或者回「没有」关闭财务题,服务端会接着问下一条线。两种都不丢已有的 4 件证据和 4 道已答题。
1. 事故实证(staging 2026-09-10 13:50 +0800,Skill 10.0.21;只写结构,不写个人资料)
顺序:开场 → 学业两件 → 感情两件 → 三道带年月选择题(事业 / 迁居 / 事业)→ D9 风格题 → 家人题「不记得了」→ 财务采集题「钱的方面,还记得哪一年…」→ 用户答「某年某月开始欠债」→ 助手只回一句 「记下了:某年某月开始欠债。」,下面没有任何问题、没有卡。顶部时间线仍是「目前范围 04:45–05:15,还在核对」。
Case JSON 里这一轮:
| 项 | 值 |
|---|---|
用户 turn status |
completed;phases = run.started, skill.bound, case.loaded, intent.classified, answer.composed, billing.settled, run.completed;没有 evidence.proposed |
tool_activities |
只有 rectification-read-case completed(48 ms);没有 rectification-record-evidence-batch,也没有 rectification-set-focus(前两轮证据轮都是 read-case → batch → set-focus 三步) |
evidence |
仍是 4 件(学业 2、感情 2),没有 finance 域;latest_result.createdAt 仍是上一证据轮的时间 |
current_question |
仍是 collect:finance:collect_method_evidence,status=active,question_source=focus,焦点的 askedTurnId 是上一轮 |
turns[].question |
这一轮的 assistant 行 question=null |
step_state |
「第 1 步·收集经历 / 继续回答当前这题」 |
interview.collection_progress |
scoreable=3, minimum=3, missing=0 |
| 计费 | billing.settled 已出现:一轮什么都没写,照常扣点 |
同一 Case 里另一个与本单无关但产品会问的事实:4 道选择题全部有效计分(deltas ±2/±1),没有一个候选被淘汰,领先 04:52–04:53 为 14 分、落后 8 分。可信区间是全部未淘汰候选的包络,所以「范围没变」是算术结果(BUG-634 已让旁白说出领先/落后)。带年月的区分探针已经问完(dropped_probes 里剩下的全是无年份对照题,按 BUG-627 决策被丢弃),继续收窄只能靠再收一件带年月的经历——而这件恰好在本轮落空。
2. 根因
2.1 BUG-635(P1):模型声称「记下了」但没有调用任何写工具,宿主没有「证据轮必须落地」的不变量
- 路由 collect 焦点分支(
route.tsL522–597)先跑classifyRectificationTurnIntent。本句不是「没有 / 记不清」,分类结果应为provide_new_evidence或answer_current_focus+has_new_dated_event。这两种都直接落到 Agent,分类结果被丢掉:runV9AgentTurn只拿到action="evidence"和原话,没有任何字段告诉运行器「这一轮必须产生一次 batch 写入」。 - 模型只调了
rectification-read-case,然后按系统提示里「证据轮正文只写一句复述,格式『记下了:年 月 事件短语』」的格式要求写了正文,却没做格式前提的那件事(rectification-record-evidence-batch),也没调rectification-set-focus。模型为什么跳过,staging 没有落库的 run diagnostic 可查(RectificationRunDiagnostic只进console.info)。候选原因:「开始欠债」被当成持续状态而非带日期事件;2026 年对模型像未来时间(timeContext已给服务端时间,但不保证被采信);finance 域在 Skill 里写着「只有用户主动说才问」,模型可能把服务端主动问的财务题当成不计分。任何一种都不该由用户承担。 streamAttempt收尾(agent-run.tsL1025–1045)只有三种异常分支:answer_truncated、max_steps/provider_error、!answerText.trim()(empty_stream)。正文非空就直接completeAttempt(),不看toolTerminalStatus里有没有公开写工具完成。stateMutationCommitted = publicWriteToolCompleted(...) || hostFallbackUsed这个值算出来了(L1109),但只进日志,不参与决策。applyHostFallback(L843,BUG-633)只覆盖「batch 已完成、正文为空」。它的镜像——「正文说记下了、batch 没跑」——没有任何守卫。- 成功收尾的焦点守卫(L1056)只在
decision.nextAction === "ask_candidate_discriminator"时要求有已持久化的点选焦点;采集阶段里旧的财务焦点仍是active,decideFromDossier认为一切正常。askedFocus?.askedTurnId === turnId(L1066)也不成立,不会补collectHandoff。 - 客户端
liveQuestionOnMessages(L1501)只要求任一 settled assistant 消息上挂着与current_question.focus_id相同的问题——上一条消息的财务题满足条件,于是questionGap不报缺口、不补问题行、输入框占位照常写「请回答上面的问题…」。用户看到的是一句「记下了」和一片空白,要往上翻才知道「上面的问题」还是刚答过的那道。 - 没有重试:
empty_stream至少还有「没有写工具完成时可重试一次」(BUG-633);本情形正文非空,连这条路都不走。
2.2 BUG-636(P2):校正流对 Mastra 的 inputSchema 拒绝信封没有识别,会把被拒的 batch 报成 completed
BUG-278 已实测:createTool 的 inputSchema 校验失败时不抛,而是 resolve 一个 { error: true, message, validationErrors } 信封,流层会把它当普通 tool-result。咨询流当时按信封结构改发 tool.failed;校正流的 mapStreamChunkToActivity(stream-mapping.ts L233–257)的 tool-result 分支没有同样的判定,batchResultFromToolChunk(host-fallback.ts)也会把信封当 batch 返回值。本事故里模型根本没调 batch,所以这不是触发原因;但若模型传了一个不在 EVIDENCE_KINDS 的 kind(例如 debt),回执会显示 evidence.proposed completed、toolTerminalStatus 记 completed、publicWriteToolCompleted 为 true——BUG-635 的守卫会被这条假 completed 绕过,且「有写工具完成不得重试」会误判。必须一起补。
3. 决策记录(产品授权范围)
- 「记下了」只能由写入事实支撑。 任何正文含「记下了」且本 attempt 没有公开写工具 completed、也没走
applyHostFallback的,该正文不得到达用户;流式已发出的部分用既有retractSpoken()收回。这是对系统提示第 4 条格式要求的宿主侧兜底,不是改提示词。 - 允许重试一次,条件与 BUG-633 完全一致:仅当本 attempt 没有任何公开写工具 completed(无证据重放风险,不触犯 BUG-186)。新增错误码
evidence_not_written,与empty_stream同法——不进RETRYABLE_ERROR_CODES常量,由收尾分支按publicWriteToolCompleted决定retryable/failed。重试 attempt 的 bootstrap 追加一行:「【重试约束】上一 attempt 没有调用 rectification-record-evidence-batch 就写了『记下了』。本轮必须先把用户原话里的带日期事件提交 batch,再用 rectification-set-focus 写下一问,最后才写正文。」 - 两次都没写 → fail closed 但不断流。 主持人正文用新增文案
RECTIFICATION_USER_COPY.evidenceNotRecorded= 「这件我还没记上。请再说一次大概年月和发生的事。」(对照frontend/docs/VOICE.md,不写内部错误、不写「系统」「模型」)。turnstatus=completed,answer_origin=host_fallback,phases 含answer.host_fallback;不结算计费(用户这一轮什么都没得到)。若现有finalizeTurn("completed")与结算不可分离,允许改为failed+ 主持人正文,但客户端必须按 BUG-633 的失败路径loadCaseSnapshot并渲染问题行;两种实现选哪种写进进度记录。 - 守卫的触发信号(2026-09-10 产品口径修订:业务判断不得靠正则):路由在 collect 焦点分支已拿到分类结果,通过
runV9AgentTurn新 optionexpectedWrite: "evidence" | "none" | "unknown"传入(provide_new_evidence、或answer_current_focus且has_new_dated_event→"evidence")。消息不在 collect 焦点分支时,也对用户原话跑一次同一分类器(无焦点时current_question传空),拿has_new_dated_event/provide_new_evidence判定。分类器为 null / 出错:重试一次,仍失败则"unknown",守卫 fail-open 并在RectificationRunDiagnostic记expectedWrite=unknown;不得用年份正则或关键词兜底。action为opening/read_only时不触发。 - 客户端:完成轮的 assistant 行没有
question,而快照current_question.question_source === "focus"且该焦点不是本轮新设(askedTurnId≠ 本轮 / 本轮回执无rectification-set-focus)→ 在主持人正文下再渲染一次问题行(复用 BUG-633 加的主持人问题行,focus_id只能来自current_question)。liveQuestionOnMessages改为只认最后一条 settled assistant 消息;旧消息上的同focus_id不再算「仍在显示」。 - BUG-636:
tool-result分支按结构识别信封(error === true且validationErrors为对象),发tool.activitystatus=failed、code=tool_call_rejected;toolTerminalStatus记 failed;batchResultFromToolChunk对信封返回 null。不匹配上游英文文案。公开回执不泄漏validationErrors内容。 - 不做的事:不改
turn-intent-classifier.ts提示词;不改 Skill 10.0.21;不改引擎计分与淘汰阈值(「范围没变」另议);不给财务/健康域加特殊规则。
4. 硬红线
- 有任何公开写工具 completed 的 attempt 不得重试(BUG-186、BUG-633)。
- 兜底正文只能来自 batch 返回或本单的固定文案,不得由宿主自己复述用户原话里的「年月 + 事件」当成「记下了」——那等于宿主替模型撒谎。
- 不得出现第二套 spinner / 骨架;问题行复用既有组件。
- 既有测试总数不得降低;改任何既有断言写「原值 / 新值 / 原因」。
- Bug 历史、测试、进度记录不得出现真实用户的年月事件;fixture 用虚构年份。
- 不动
scripts/jyotish_api_server.py、不动迁移、不升依赖。
5. 任务分解
T1 · 路由把「本轮应有写入」传给运行器(BUG-635)
route.tscollect 焦点分支:分类结果落到 Agent 的两种情形计算expectedWrite;非 collect 的message路径也对原话跑同一分类器。分类失败两次则"unknown",守卫 fail-open,不用年份正则。V9AgentRunOptions新增expectedWrite?: "evidence" | "none" | "unknown"。- 验收:单测覆盖
provide_new_evidence→"evidence";answer_current_focus+has_new_dated_event→"evidence";answer_current_focus+no不进 Agent(既有);分类器两次都抛错 →"unknown"且守卫不触发、诊断里有记录;源码合同断言agent-run.ts/route.ts不含年份正则。
T2 · 收尾守卫:无写入的「记下了」不得交付,可重试一次(BUG-635)
streamAttempt收尾在empty_stream分支之后、loadV9CaseDossier之前加:needsWrite && !publicWriteToolCompleted(toolTerminalStatus) && !hostFallbackUsed→ 第一次返回status="retryable"、errorCode="evidence_not_written",retractSpoken(),不记answer.composed;第二次按决策 3 写主持人正文收口。- 验收(
frontend/tests/rectification-v9-stream.test.ts或新rectification-unwritten-evidence.test.ts,用假流):- read-case → 正文「记下了:…」→ finish:attempt
retryable,无answer.composed,spoken 已收回。 - 第二 attempt 同形:turn
completed,正文 =evidenceNotRecorded文案,phases 含answer.host_fallback,公开回执answer_origin=host_fallback,未结算计费(或按决策 3 的备选路径)。 - read-case → batch completed → set-focus → 正文:行为与基线完全一致(回归)。
- read-case → batch completed → 无正文:仍走 BUG-633 的
applyHostFallback(回归)。 action="read_only"+ 带年份原话:不触发。RectificationRunDiagnostic的stateMutationCommitted=false且新增expectedWrite字段。
- read-case → 正文「记下了:…」→ finish:attempt
T3 · 重试 attempt 的约束句(BUG-635)
buildAgentMessages在attempt > 1且上一 attempt 错误码为evidence_not_written时追加决策 2 的约束句;其他重试原因保持原句。- 验收:单测断言两种重试原因各自的 bootstrap 内容。
T4 · 客户端再显示仍未答完的问题(BUG-635)
liveQuestionOnMessages只认最后一条 settled assistant 消息;完成轮无question且current_question.question_source==="focus"→ 渲染主持人问题行。- 验收:
rectification-surface-state.test.ts/ 聊天组件测试:旧消息带同focus_id不算 live;问题行focus_id来自current_question;frontend/DESIGN.md补「主持人问题行也用于完成轮的未答焦点」。
T5 · 校正流识别 schema 拒绝信封(BUG-636)
mapStreamChunkToActivity/mapStreamChunkToPhase/batchResultFromToolChunk按决策 6 处理信封。- 验收:单测喂
tool-call+ 信封tool-result:事件流有tool.activity failed code=tool_call_rejected、无 completed;toolTerminalStatus为 failed;publicWriteToolCompleted为 false;composeHostFallbackNarration不把信封当 recap;公开回执序列化后不含validationErrors。
T6 · 记录
docs/BUG_HISTORY.md新增 BUG-635、BUG-636(状态、现象、触发、根因、修复、验证、防复发、关联 BUG-186/278/359/449/633)。docs/tasks/PROGRESS-rectification-unwritten-evidence-claim-20260910.md:每条 T 的测试名与结果、决策 3 选了哪条实现、next build后/是否仍 Static、首屏 gzip 变化。CHANGELOG.md:「证据没记上时助手会明说并再显示原题,不再只说记下了」。docs/testing/新增一条真人清单:在 staging 走到财务采集题,回一句带年月的欠债/收入变化;期望要么证据数 +1 且下一问出现,要么助手说「这件我还没记上」并再显示财务题;绝不出现「记下了」而证据数不变。
6. 让步顺序
T5 → T4 → T3 可依次延后(延后的写 BLOCKED.md 或进度记录并说明);T1 + T2 是本单的最低交付,不可省。若 T4 延后,T2 的 fail-closed 文案已含「请再说一次」,用户仍能直接回答。
7. 开工前置命令
git fetch origin --prune
git worktree add -b codex/rectification-unwritten-evidence-claim-20260910 .worktrees/rectification-unwritten-evidence-claim-20260910 origin/staging
cd .worktrees/rectification-unwritten-evidence-claim-20260910/frontend
grep -c "^## BUG-" ../docs/BUG_HISTORY.md # 核对最大号仍为 634
npm test -- tests/rectification-v9-stream.test.ts tests/rectification-host-fallback.test.ts tests/rectification-spoken-collect.test.ts tests/rectification-surface-state.test.ts # 记下基线 pass 数
8. 验收口径
tsc --noEmit0 错;npm run lint0 error;上述四个测试文件加新文件 fail=0,测试总数 ≥ 基线。next build后/仍○ Static,首屏 gzip ±2%。- 推 staging 后
/api/health的deployment.gitCommit等于含代码改动的最新提交。 - 真实环境清单见 T6;做不了的写成环境缺口,不得写「通过」。