diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 095967e8..4a9d225d 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -184,6 +184,8 @@ | `TASK-rectification-targeted-card-dead-20260913.md` | `PROGRESS-rectification-targeted-card-dead-20260913.md` | **P0**:定向补事卡在快照投影里拿不到 `choice_card`(承接焦点分支不重建 `choice_frame`),卡片看得见点不动、流程停在采集等待态;模型还会把定向题改写成口述题(BUG-669~671)。先于 tie-break 修复单执行 | 待验收 | `codex/rectification-targeted-card-dead-20260913` | +| `TASK-rectification-delivery-vs-collect-split-20260914.md` | `PROGRESS-rectification-delivery-vs-collect-20260914.md` | **P0**:并列到顶(tied_first)时 POST 播了交付结论、GET 又算回 collect_evidence——刷新尝试落库失败仍回报「已尝试」+ `stillNeedNarrowing` 压过 exhausted(BUG-680);交付话术闸门与交付卡闸门不一致,`completed_with_range`/`provisional_range` 能说不能画(BUG-681);缺口状态机没有终态,兜底印「没有拿到下一个问题」(BUG-682) | 待执行 | `codex/rectification-delivery-vs-collect-20260914` | + | `TASK-rectification-spoken-orphan-and-engine-representative-20260914.md` | `PROGRESS-rectification-spoken-orphan-20260914.md` | **P0**:口述题(定向补事年份追问)没有 `askedTurnId` 时仍是无头像裸行——BUG-675 的挂回规则只覆盖选择题(BUG-678);同屏两个范围口径(旁白用活跃候选首尾、时间轴用 credible_range);引擎 `representative_time` 把已淘汰分钟的宫位表带进模型上下文(BUG-676 收敛为 resolved) | 待验收 | `codex/rectification-spoken-orphan-20260914` | | `TASK-rectification-unstampable-probe-and-naked-card-20260914.md` | `PROGRESS-rectification-unstampable-probe-20260914.md` | **P0**:探针池空后判别题盖不上 probe → 焦点写不进 → 题干被念进正文、同一题反复问且答了不算数(BUG-674,推翻前一单 D3);快照已有 `choice_card` 但 `persisted_question` 仍只画一行裸题(BUG-675);同一轮 receipt 里 `representative_time` 取到 `eliminated_ids` 里的候选(BUG-676,investigating) | 待验收 | `codex/rectification-unstampable-probe-20260914` | diff --git a/docs/tasks/TASK-rectification-delivery-vs-collect-split-20260914.md b/docs/tasks/TASK-rectification-delivery-vs-collect-split-20260914.md new file mode 100644 index 00000000..3057dcd3 --- /dev/null +++ b/docs/tasks/TASK-rectification-delivery-vs-collect-split-20260914.md @@ -0,0 +1,187 @@ +# TASK · 播了交付结论却回到采集态:卡不出、题没有、只剩「没有拿到下一个问题」 — 2026-09-14 + +- 基线:`origin/staging` @ `b50a8f94`(staging 已部署该 SHA,含 BUG-673~679 的修复)。 +- 分支:`codex/rectification-delivery-vs-collect-20260914`,worktree `.worktrees/rectification-delivery-vs-collect-20260914`。 +- 关联:BUG-674(同源:POST 与 GET 对同一状态判定不一致)、BUG-675、BUG-678、BUG-590、BUG-587。 +- 串行:改 `refresh-discriminator-probes.ts`、`rectification-decision.ts`、`rectification-candidate-result.ts`、`rectification-surface-state.ts`。开工前 `git log --oneline origin/staging -20` 复核无同文件在途分支。 + +## 1. 事故实证(产品负责人 staging 真机,2026-09-14,部署 SHA `b50a8f94`) + +### 用户看到的 + +跳过最后一道定向补事题(家人)之后,助手播出**完整的交付结论**: + +``` +再问下去也分不开 04:53 和 05:00,没有年份的分盘题不再问,所以这一轮不再往下问。 +现在给的范围是 04:48 到 05:07,代表分钟为 04:53。采用后没有还能核对的前事, +之后新建对话即按此时间排盘,对不上可改选。 +我按你说的经历认真分析过了,下面是这次的结果。 +``` + +然后**没有交付卡**、**没有下一题**,界面只剩: + +``` +目前范围 04:48–05:07,再说一件带年月的事就能继续 +没有拿到下一个问题。 +``` + +### 快照实证(同一会话 GET,产品负责人提供,已脱敏) + +```jsonc +"interview": { + "type": "ask_fact_collection", "session_outcome": "collect_evidence", + "can_offer_range": false, "can_adopt": false, "selection_allowed": false, "propose_allowed": false, + "precision_stage": "collect_events", "stop_reason": "tied_first", + "representative_time": "04:53", "credible_range": ["04:48","05:07"] +}, +"current_question": null, "choice_card": null, +"next_user_action": { "id": "explain_current_window", … } +``` + +活跃候选 posterior:04:53 = **16**、05:00 = **16**、05:06 = 15、04:51 = 14、04:59 = 13(前两名完全并列,probability 也都是 0.2592219508007118)。 + +### 这组字段只能由一条分支产生 + +`evaluateCandidateSeparation`:lead = 16 − 16 = 0 → `tiedForFirst = true`、`sufficient = false`。 +`classifyStop`(`core/rectification-decision.ts:237`):`if (separation.tiedForFirst) return { kind: "exhausted", reason: "tied_first" }`。 +`decideRectification` 的 `!separation.sufficient` 分支: + +```ts +if (stillNeedNarrowing(input)) { + return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason); +} +if (stopClass?.kind === "exhausted") { + return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); +} +``` + +`collect()` 给出 `session_outcome: collect_evidence`、`precision_stage: collect_events`、`can_offer_range:false`,`waitToNarrowCapability()` 把 `canAdopt / selectionAllowed / proposeAllowed` 全清零,`stopReason` 原样带出 `tied_first` —— **与快照逐字段吻合**。结论:GET 侧 `stillNeedNarrowing(input) === true`,即 `refreshExhausted === false || targetedCollectExhausted === false`。 + +### 为什么 POST 那一轮却交付了 + +交付话术出自 `persistNextInterviewIfIdle`(`answer-choice.ts:1645` 一带,由 `agent-run.ts:621` 在本轮没有持久化问题时调用)。它的算法是: + +```ts +let decision = decideFromDossier(dossier, …); // ① 第一次算:还要继续收窄 +const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({…}); +if (refreshed.refreshed || refreshed.attemptRecorded) { + dossier = { …dossier, latestResult: { …latest, decisionReceipt: overlay.decisionReceipt ?? … } }; + decision = decideFromDossier(dossier, …); // ② 第二次算:refreshExhausted 变真 → 交付 +} +``` + +而 `refreshDatedDiscriminatorPoolIfNeeded` 在写不进库时**仍然回报"已尝试"**: + +```ts +// frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts:568 +await persistRefreshAttempt({ … }); // ← 返回值 boolean 被丢弃 +return { dossier: …, state: attempted, refreshed: false, attemptRecorded: true }; +``` + +`persistRefreshAttempt` 用 `expectedRevision: previous.revision` 写 inference state;本轮的选择题回答刚刚把 revision 推进过,这里很容易撞版本冲突 → `catch` → `return false` → **落库失败**。但调用方不看返回值,`attemptRecorded: true` 照常让第 ② 次计算把 `refreshExhausted` 判为真 → `stillNeedNarrowing` 变假 → 走 `completeWithRange("exhausted", tied_first)` → `deliveryNarrationAllowed` 放行 → 播出交付结论。 + +下一次 GET 读的是库里的 receipt,没有这条 refresh attempt → `refreshExhausted` 回到 false → 又回到 `collect()`。**同一状态,POST 说交付,GET 说继续收集。** + +### 第二层:就算 GET 判成交付,卡也不会出 + +```ts +// core/rectification-decision.ts:126 +ADOPT_OUTCOMES = { adopt_representative, provisional_range_user_stopped, awaiting_confirmation, validated_range } + +// rectification-candidate-result.ts:426 +canShowRectificationSelectionCards = selectionAllowed && canAdopt + && rectificationDecisionReceiptAllowsAdoption(receipt) + && ADOPT_OUTCOMES.has(sessionOutcome); + +// core/rectification-decision.ts:148 +deliveryNarrationAllowed = publicCanAdopt(decision) + || (selectionAllowed === true && nextAction === "offer_provisional_range"); +``` + +`completed_with_range` 与 `provisional_range` —— 也就是"问不下去了,给你区间"这两种正常收尾 —— **不在 `ADOPT_OUTCOMES` 里**,而话术闸门认这两种。区间交付卡只在 `rectification-agentic-chat.tsx:1844` 一处渲染,挂在 `showSelectionCards` 之下,没有第二条路径。所以只要 `canAdopt` 为假,就必然"有结论、没有卡"。 + +### 第三层:这个状态没有终态,兜底成一句报错 + +```ts +// rectification-surface-state.ts:381 interviewCollectWaiting +if (sessionOutcome === "collect_evidence" && !stopReason) return true; +return stopReason === "insufficient_dated_events" || "insufficient_domains" || "insufficient_events"; +``` + +当前组合是「没有题 + collect_evidence + stopReason = tied_first」→ 三个条件都不满足 → 不是 `collect_waiting`、不是 `persisted_question`、不是 `verified_idle` → 掉到重试耗尽的 `unavailable` → 印出「没有拿到下一个问题」。 + +## 2. 根因 + +1. **BUG-680**:刷新尝试落库失败仍按"已尝试"推进决策(`attemptRecorded: true` 无视 `persistRefreshAttempt` 的返回值),使 POST 与 GET 对同一状态给出相反结论;叠加 `stillNeedNarrowing` 排在 `stopClass === "exhausted"` 之前,"并列到顶(tied_first)"这个终局信号被"还有收窄手段没用完"无条件压过。 +2. **BUG-681**:交付话术闸门与交付卡闸门不是同一套,`completed_with_range` / `provisional_range` 能说不能画。 +3. **BUG-682**:问题缺口状态机没有"已交付 / 没有更多可问"的终态,兜底文案是「没有拿到下一个问题」这种报错口吻。 + +## 3. 决策记录 + +| 决策 | 内容 | +| --- | --- | +| D1 | **落库失败不得当成已尝试。** `refreshDatedDiscriminatorPoolIfNeeded` 的 `attemptRecorded` 必须等于 `persistRefreshAttempt` 的真实返回值;写失败时按"未尝试"继续(本轮不交付、继续给定向补事或诚实说明),不得靠内存态播交付。 | +| D2 | **并列到顶是终局,不被收窄手段压过。** `decideRectification` 的 `!separation.sufficient` 分支里,`stopClass?.kind === "exhausted" && reason === "tied_first"` 的判断提到 `stillNeedNarrowing` 之前:候选前两名分数完全并列时,再多的定向补事也分不开,应当直接交付区间。其余 `exhausted` 原因维持现有顺序(不扩大改动面)。 | +| D3 | **能说就能画。** 把 `completed_with_range` 与 `provisional_range` 纳入交付卡可见集合(新增一个 `DELIVERY_OUTCOMES`,卡片闸门用它,`ADOPT_OUTCOMES` 仍只管"可采用"语义),使"播了交付话术"与"出了交付卡"永远同真同假。卡在这两种 outcome 下按只读区间卡呈现:可看候选、可选采用(若 `selection_allowed`),不得反过来把 `can_adopt` 提权。 | +| D4 | **补终态。** 缺口状态机新增 `delivered`:`current_question === null` 且 outcome ∈ 交付集合(或 `stop_reason` 属于 `tied_first` / `user_uncertainty_too_high` 这类终局)时,显示区间与出口说明,**禁止**再出现「没有拿到下一个问题」。 | +| D5 | 不动数据库结构与迁移、不动 `deploy/**` 与 workflow、不动 `page.tsx`、不新增依赖。不得为了让状态"看起来对"而放宽 `can_adopt` / `confirmation_allowed` 或任何置信度边界(§8 红线)。 | + +## 4. 硬红线 + +1. `tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败清单与基线逐条一致(本机基线 27 条,全是无 Docker / DB / rsync 缺口;工作树缺 `.venv` 软链会多一条 workflow YAML 假红);`next build` 通过且 `/` 仍 `○ Static`(Turbopack 拒软链 `node_modules`);首屏 gzip ±2%。 +2. 新增断言必须是行为断言,不得用 `readFileSync` + 正则匹配源码。 +3. 不得新增第二套交付卡渲染路径。 +4. 交付话术与交付卡必须由同一个判据驱动;任何新增分支都要同时过这两处。 + +## 5. 任务分解 + +### 任务 1 · 刷新尝试落库失败不得当成已尝试(BUG-680,P0) + +- `refresh-discriminator-probes.ts:568` 一带:接住 `persistRefreshAttempt` 的返回值,`attemptRecorded` 用真实结果;失败时 `console.warn(JSON.stringify({ event: "rectification_refresh_attempt_persist_failed", case_id, candidate_set_id, answer_count }))`。 +- `core/rectification-decision.ts`:按 D2 把 `tied_first` 的终局判断提到 `stillNeedNarrowing` 之前。 +- 验收标准: + - 行为单测:`persistRefreshAttempt` 抛错(模拟版本冲突)时 `refreshDatedDiscriminatorPoolIfNeeded` 返回 `attemptRecorded: false`,且随后的决策**不是**交付。 + - 行为单测(回归核心):同一份 dossier + inference state(前两名 posterior 并列 16/16,`refresh_attempts` 为空),`persistNextInterviewIfIdle` 的决策与 `decideFromDossier` 的决策**必须一致**——要么都交付,要么都继续收集。这条是本单的防复发锚点。 + - 行为单测:并列到顶时 `decideRectification` 返回 `completeWithRange`,`stop_reason = tied_first`,`can_offer_range = true`。 + +### 任务 2 · 能说就能画(BUG-681,P0) + +- `core/rectification-decision.ts`:新增 `DELIVERY_OUTCOMES`(= `ADOPT_OUTCOMES` ∪ `{ completed_with_range, provisional_range }`),导出。 +- `rectification-candidate-result.ts:426`:`canShowRectificationSelectionCards` 改用 `DELIVERY_OUTCOMES`;`canShowRectificationReadonlyRange` 相应排除交付 outcome(避免同屏既出卡又出"再说一件…")。 +- 验收标准: + - 行为单测:outcome = `completed_with_range`、`selection_allowed = true`、`can_adopt = false` 时,`canShowRectificationSelectionCards` 为真,`canShowRectificationReadonlyRange` 为假。 + - 行为单测:`deliveryNarrationAllowed` 为真的每一种 outcome,`canShowRectificationSelectionCards` 都为真(用表驱动遍历所有 outcome,锁死"同真同假")。 + +### 任务 3 · 补终态(BUG-682,P1) + +- `rectification-surface-state.ts`:`rectificationQuestionGapState` 新增 `delivered` 分支(排在 `collectWaiting` 之前),条件:无题 + outcome ∈ 交付集合,或 `stop_reason ∈ { tied_first, user_uncertainty_too_high }`。 +- 组件按该状态显示区间与出口说明(文案对照 `frontend/docs/VOICE.md`,不得出现「没有拿到下一个问题」「请稍候」)。 +- 验收标准:行为单测——该组合下 `rectificationQuestionGapState` 返回 `delivered`,不是 `unavailable`;普通"题没取到"仍返回 `unavailable`(不得误伤修复入口)。 + +### 任务 4 · 记录 + +- `docs/BUG_HISTORY.md`:BUG-680 / 681 / 682 三条 `resolved`。BUG-680 的防复发必须写明:**任何"是否已尝试/已耗尽"的标志,落库失败时一律按未完成处理;POST 与 GET 对同一状态的决策必须一致,并有一条对拍测试守着。** +- `CHANGELOG.md` 一行;`PROGRESS-rectification-delivery-vs-collect-20260914.md`;`docs/testing/` 真机清单(要覆盖:并列到顶时给出区间交付卡;不再出现「没有拿到下一个问题」;同屏不再既出卡又出"再说一件带年月的事")。 + +## 6. 让步顺序 + +1. 任务 1、任务 2 必做——缺任一条用户都会看到"有结论没有卡"。 +2. 任务 3 若文案需要产品确认,可先用既有区间文案,把待确认项写进进度记录,但**不得**保留「没有拿到下一个问题」。 +3. D2 的顺序调整若牵动其他用例,最小改动只对 `tied_first` 生效,其余 `exhausted` 原因保持原样。 + +## 7. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/rectification-delivery-vs-collect-20260914 \ + .worktrees/rectification-delivery-vs-collect-20260914 origin/staging +cd .worktrees/rectification-delivery-vs-collect-20260914 +ln -s /workspace/Jyotisha/.venv .venv +cd frontend && npm ci +``` + +按 `AGENTS.md` §5,开工前用「tied_first」「没有拿到下一个问题」「交付卡」「refresh attempt」「stillNeedNarrowing」检索 `docs/BUG_HISTORY.md`,至少读完 BUG-674、BUG-678、BUG-590、BUG-587 四条。 + +## 8. BUG 编号起点 + +- 起点 **BUG-680**(当前最大号 679,开工时以 `docs/BUG_HISTORY.md` 实际最大号为准)。