Files
Jyotisha/docs/tasks/TASK-rectification-delivery-vs-collect-split-20260914.md
T
Jesse_ChenandClaude Fable 5 e59ef9fbfd 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
2026-09-14 07:00:55 +00:00

188 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 实际最大号为准)。