docs(tasks): brief for delivery narrated while decision falls back to collect

tied_first 并列到顶时,persistNextInterviewIfIdle 因刷新尝试「落库失败仍回报
已尝试」而在第二次计算时判交付并播出结论,GET 重算又回到 collect_evidence 且
清零交付能力(BUG-680)。交付话术闸门认 completed_with_range/provisional_range,
交付卡闸门只认 ADOPT_OUTCOMES,能说不能画(BUG-681)。缺口状态机没有终态,
兜底印「没有拿到下一个问题」(BUG-682)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
This commit is contained in:
Jesse_Chen
2026-09-14 07:00:55 +00:00
co-authored by Claude Fable 5
parent b50a8f94ac
commit e59ef9fbfd
2 changed files with 189 additions and 0 deletions
+2
View File
@@ -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` 压过 exhaustedBUG-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-676investigating | 待验收 | `codex/rectification-unstampable-probe-20260914` |
@@ -0,0 +1,187 @@
# TASK · 播了交付结论却回到采集态:卡不出、题没有、只剩「没有拿到下一个问题」 — 2026-09-14
- 基线:`origin/staging` @ `b50a8f94`staging 已部署该 SHA,含 BUG-673679 的修复)。
- 分支:`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", }
```
活跃候选 posterior04: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-682P1
- `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` 实际最大号为准)。