Skip to content

RVH Backlog 归档

已完成 / 已失效的 backlog 条目存放处,保留裁决推理——CHANGELOG 只记 as-built, 不记「为什么否掉另一个方案」「为什么这条不做了」。

活跃条目见 backlog.md

🔄 归档节律backlog.md 里标 ## ✅ 的条目攒够一批就移进来。 创建:2026-08-17(首批 = 旧 TODO.md 全量迁入)


📦 旧 TODO.md 全量归档(2026-08-17 迁入)

背景

根目录 TODO.md 是 2026-01 之前的待办机制(四象限法则 + FEAT/BUG/REFACTOR 编号 + 周计划/月计划)。最后更新 2026-01-14,此后停更 7 个月,而 docs/plans/backlog.md (2026-05-26 建)承担了同一职责。两套并存期间 CLAUDE.md 只指向前者、从未提过后者, 导致新会话按 CLAUDE.md 走就会把待办记错地方(2026-08-16 实际踩过)。

本次把 TODO.md 全量迁入本文件,逐条核实状态后只把仍有效的搬进 backlog.md

逐条核实结论(2026-08-17,对照当时代码库实查)

✅ 早已完成 —— 不搬运

编号原描述核实依据
FEAT-005完成词汇笔记本基础功能(列表页/详情页/书籍管理)notebook_page.dart / word_detail_page.dart / reading_note_list_page.dart 均已实现且经多轮迭代
FEAT-007实现间隔重复学习(SM-2 算法)lib/core/algorithms/sm2_algorithm.dart + golden vectors 回归测试;CLAUDE.md 标 ✅
FEAT-008学习统计看板statistics 模块 + Me 页 ProgressCard(CEFR 分布柱状图);CLAUDE.md 标「基础完成」
FEAT-009CEFR 等级设置设置页「CEFR Levels」+ 冷启动 Quick Triage 校准(超出原需求
BUG-001OCR 识别率低(图像预处理、手动裁剪)image_quality_enhancer.dart / post_crop_image_processor.dart / text_region_cropper.dartcropMode 默认 manual
REFACTOR-001重构图像预处理为独立 Servicelib/core/services/ 下已拆成 6 个独立 processor service
DEP-001升级 Flutter 3.10.4 → 3.24.0实测 3.38.5 —— 连目标版本本身都已过时
CONFIG-001配置 CI/CD(GitHub Actions).github/workflows/ci.yml 已存在
UX-002支持深色模式ThemeModeNotifier 已在 main.dart:65 接线

❌ 描述已失效 —— 不按原样搬运

编号原描述为什么失效
FEAT-006集成翻译服务:有道翻译 API + 批量翻译 + 缓存有道从未接入。实际架构是 translation_api_datasource.dart(Cambridge / Oxford / Merriam-Webster)+ 3 层回填(本地 → Supabase → Edge Function)。原描述已不对应任何真实工作。→ 改写后进 backlog(「翻译能力现状待澄清」)
PERF-001优化启动速度,目标 < 1 秒目标已改口径:CLAUDE.md 现记「应用启动时间 < 2 秒」。原指标作废

🗑 价值过低 / 非离散任务 —— 不搬运

编号原描述判定
DEP-002升级 Riverpod ^2.4→^2.5、Freezed ^2.4→^2.5pubspec 的 caret 约束本就解析到 2.x 最新,实际锁定版本早已更高。改约束声明本身无收益
PERF-002列表滚动性能(ListView.builder / 图片懒加载)列表统一走 WordListView无任何实测卡顿证据。没有问题就不立条目
UX-001动画效果(转场/翻卡/加载)OCR Lottie 动画已做(见 UX-003 Sprint 1.1);剩余部分无具体缺口描述,不可执行
UX-003(📌 那条)修复 Confirm Words 页背景色(surface 在浅色下为纯白)filter_confirmation_page.dart:471 确实仍用 colorScheme.surface问题可能仍在;但已建立 docs/ui-guidelines/ 体系 + /ui-compliance-check Skill 覆盖此类,属日常检查职责,不单独立条目
DOCS-001补充 API 文档注释永续性工作,不是可完成的离散任务

⚠️ 仍有效 —— 已搬进 backlog.md

  • REFACTOR-002 背景移除混合方案(iOS VisionKit / Android MediaPipe)→ P3,需先重估价值
  • TEST-001 单元测试覆盖率 → P3
  • DOCS-002 用户手册 → P3
  • FEAT-006(改写后)翻译能力现状待澄清 → P3

📄 原 TODO.md 全文(2026-01-14 最后状态)

以下为原样保留,供追溯。勿在此处继续维护

markdown
# 待办事项

本文档记录所有待办事项,使用四象限法则管理优先级。

## 紧急重要 🔥

### 开发任务
- [ ] [FEAT-005] 完成词汇笔记本基础功能
  - 词汇列表页面 / 词汇详情页面 / 书籍管理功能增强
  - 截止日期: 2026-01-15
- [ ] [FEAT-006] 集成翻译服务
  - 有道翻译 API 集成 / 批量翻译优化 / 翻译结果缓存
  - 截止日期: 2026-01-20

### Bug 修复
- [ ] [BUG-001] 修复 OCR 识别率低的问题
  - 添加图像预处理(灰度化、二值化)/ 支持手动裁剪区域
  - 截止日期: 2026-01-10

## 重要不紧急 ⭐

### 功能开发
- [ ] [FEAT-007] 实现间隔重复学习(SM-2 算法)— 算法实现 / 学习会话 UI / 复习调度系统(P0)
- [ ] [FEAT-008] 学习统计看板 — 词汇掌握度分布 / 学习趋势图 / 复习准确率统计(P1)
- [ ] [FEAT-009] CEFR 等级设置 — 用户水平评估 / 动态调整过滤等级(P1)

### 代码质量
- [ ] [REFACTOR-001] 重构图像预处理模块 — 提取为独立 Service / 支持插件化预处理策略(P1)
- [ ] [REFACTOR-002] 背景移除混合方案(优化)
  - 当前: OpenCV GrabCut(快速实现,质量中等)
  - iOS 优化: VisionKit Subject Lifting API(系统级、质量极高、硬件加速)
  - Android 优化: MediaPipe Selfie Segmentation(ML Kit 推荐、实时分割)
  - 降级方案: OpenCV GrabCut(当前实现)
  - 实施策略: 检测平台和 API 可用性 → 优先原生 → 不可用时降级
  - 优先级 P1;预期收益: 质量提升 30-50%,速度提升 50-70%
- [ ] [TEST-001] 添加单元测试 — Domain > 80% / Data > 60% / Presentation 关键测试(P1)

## 紧急不重要 ⚡

### 依赖更新
- [ ] [DEP-001] 升级 Flutter SDK — 3.10.4 → 3.24.0(P2)
- [ ] [DEP-002] 升级依赖包 — Riverpod ^2.4.0→^2.5.0 / Freezed ^2.4.0→^2.5.0 / 依赖安全性检查(P2)

### 配置优化
- [ ] [CONFIG-001] 配置 CI/CD — GitHub Actions 工作流 / 自动化测试 / 自动化构建(P2)

## 不紧急不重要 📌

### 性能优化
- [ ] [PERF-001] 优化应用启动速度 — 延迟加载非核心模块 / 优化初始化流程 / 目标 < 1 秒(P3)
- [ ] [PERF-002] 优化列表滚动性能 — ListView.builder / 图片懒加载(P3)

### 体验优化
- [ ] [UX-001] 添加动画效果 — 页面转场 / 卡片翻转 / 加载动画(P3)
- [ ] [UX-002] 支持深色模式 — 主题配置 / 跟随系统设置(P3)
- [ ] [UX-003] 修复 Confirm Words 页面背景色
  - 问题:Material 3 colorScheme.surface 在浅色模式下为白色
  - 需求:使用带色调的背景(浅灰/奶白色)
  - 方案:可能需要使用 surfaceContainer 或自定义背景色(P3)

### 文档完善
- [ ] [DOCS-001] 补充 API 文档 — 为所有公共 API 添加注释 / 生成文档网站(可选)(P3)
- [ ] [DOCS-002] 编写用户手册 — 功能使用指南 / 常见问题解答(P3)

## 已完成 ✅

- [x] [FEAT-001] 基础 UI 框架搭建
- [x] [FEAT-002] 拍照识词基础功能
- [x] [FEAT-003] OCR 文字识别(Google ML Kit)
- [x] [FEAT-004] 词汇提取和过滤(CEFR)
- [x] [DOCS-003] 项目文档体系搭建
- [x] [FEAT-010] 图像质量增强三大功能 (2025-12-27)
      背景移除(OpenCV GrabCut)/ 白底黑字效果 / 方向矫正增强
- [x] [FEAT-013] Books 书籍管理功能 (2026-01-05)
      BookListPage / BookDetailPage / CreateBookPage / 书籍选择对话框
      术语统一:Reading Material Groups → Books
- [x] [REFACTOR-003] 数据库 Schema 优化 v7 → v8 (2026-01-05)
      合并 word_mastery_info 表到 notebook_entries / SM-2 字段整合 / 清理 7 个冗余字段
- [x] [BUG-002] 修复应用启动白屏问题 (2026-01-09)
      根因:数据库版本号不一致(代码 v11 vs SQL v10);修复:统一为 v10 + v8/v10 迁移
- [x] [UX-003] Sprint 1.1 - 核心流程优化 (2026-01-13)
      P1.1 书籍创建模板 / P1.2 加载错误处理 / P1.3 分批学习 / P1.4 复习完成流程
      P1.12 权限引导 / P1.13 网络状态指示器 / P1.14 OCR Lottie 动画
- [x] [UX-004] Sprint 1.2 - 笔记本页面体验提升 (2026-01-14)
      P2.8 多义词详解 / P2.13 长按菜单 / P2.14 滑动删除 / P2.15 撤销
      P2.9 用户笔记 / P2.17 书籍创建简化 / P2.3 剪贴板添加 / P2.11 字体大小入口
      P2.20 批量操作(选择模式+全选+批量删除)/ P2.12 触觉反馈

## 任务统计(2026-01-14 时点)

总计 25 / 已完成 10 (40%) / 进行中 0 / 待办 15
优先级分布:紧急重要 3 · 重要不紧急 5 · 紧急不重要 2 · 不紧急不重要 8

## 计划(已过期)

下周计划(2026-01-13 ~ 2026-01-19):BUG-001 / FEAT-005 / FEAT-006
本月计划(2026-01):完成 Alpha (v0.5.0),里程碑 01-15 笔记本 / 01-20 翻译 / 01-31 Alpha 发布

## 备注

任务编号:FEAT 新功能 · BUG 修复 · REFACTOR 重构 · TEST 测试 · PERF 性能
          · UX 体验 · DEP 依赖 · DOCS 文档 · CONFIG 配置
优先级:P0 必须 · P1 应该 · P2 可以 · P3 暂不考虑

最后更新: 2026-01-14 · 更新频率: 每周更新

📌 :原文里 [UX-003] 编号被用了两次(一次是「Sprint 1.1 核心流程优化」已完成项, 一次是「Confirm Words 背景色」待办项)。这是原文的编号冲突,归档时原样保留。


📦 第二批 —— 18 条(2026-08-30 移入)

由根 docs/plans/archive/post-merge-repo-optimization-plan.md T4-3 一次性移出。 移出前 backlog.md 里 35 条顶层条目有 18 条是 ✅(占 84.9 KB / 全文 74%), 真正的待办被埋在中间 —— 正是本文件头部说的那个病。 内容逐字保留(含裁决推理与反例),只换了位置。

✅ 合仓全部完成并归档(原文 2026-08-28 · 二次订正 2026-08-29)

本节是重启入口。恢复工作前先读完这一节,再往下翻条目。

状态

用户 2026-08-28 决定:RB 推进「RVH 并入 RB 仓」,RVH 侧其余任务全部暂停。

合仓已于 2026-08-29 全部收尾:五个阶段、T4-1 ~ T4-10 十项全 ✅, 最后一项(老 gitee 仓归档)由用户当日在 gitee 置为「关闭」。计划与评估两份文档 已归档到仓根 docs/plans/archive/rvh-merge-plan.md / archive/rvh-merge-evaluation.md

上面那句「其余任务全部暂停」彻底作废,下方条目正常开工,也不再需要「先扫台账别撞车」 (原文要人防的 T4-3 / T4-5 / T4-6 都已完成)。那两份归档件从此是历史记述, 不是进度真相源 —— 不要照着它们当操作指南(里面的交接 prompt、~/reading_vocab_helper 路径、 sync-rvh-vocabulary.sh 都是双仓时期的写法)。

合仓沉淀下来的活的东西:跨端契约红线 → 仓根 CLAUDE.md §4 · 本端落地形状与守卫判据 → rvh/lib/features/sync/CLAUDE.md · 机械守卫 → scripts/cross-end-check.sh / scripts/check-vocab-asset.sh / .github/workflows/ci-cross-end.yml / ci-rvh.yml · as-built 日志 → 仓根 CHANGELOG.md。改动去这些地方。

⚠️ 路径基准提醒(合仓后仍成立):那两份在仓根 docs/plans/archive/, 不是本文件所在的 rvh/docs/plans/

📌 阶段 2 进展(2026-08-28 当时的记述,全部已完成,留作教训)

本节写于阶段 2 进行中,当时「只剩 T2-5」。T2-5 及其后的阶段 3/4 都已完成 (2026-08-29 全案收尾)。保留它只为记住一条教训:计划里写死的"实测清单" 过期得很快,冷启动会话必须先跑状态比对再动手 —— 这一节自己就是例子。

  • T2-2 ✅ 僵尸分支 claude/brave-tereshkova-9911f3git branch -d;worktree 早已被清 (backup-before-reword 在此之前就已不存在)
  • T2-3 ✅ 用户裁定:USER_NOTES.md + PDF 转本地未跟踪并加 .gitignore,且要清历史 (已写进 T3-2 的 --path 参数);QUICKSTART.md 保留原地
  • T2-4 ✅ supabase/migrations/ 从仓库删除(内容留 git 历史,7 处活跃文档引用已改); tools/vocabulary_builder/ 退出版本控制并加 .gitignore(磁盘上 828M 本地产物按裁定不动)

📌 下面是 2026-08-28 立项当时的快照(已过期,留作教训)

T2-1 的描述写的是「16 个未提交文件 + ahead 6 未推」,那是立项当时的快照。 2026-08-28 本会话结束时的实测

工作区        干净(0 个未提交、0 个未跟踪)  ← T2-1 的「16 个未提交」那半已由本会话做完
ahead         0(已全部推 origin/master,末位 44c055e)  ← T2-1 的「ahead 未推」那半也已做完
僵尸分支      backup-before-reword · claude/brave-tereshkova-9911f3      ← T2-2 未做
额外 worktree .claude/worktrees/elegant-turing-503c83 (detached 8379856)  ← T2-2 未做
              ⚠️ 建于 2026-08-05,不是本会话产物,别当临时目录随手删 —— 按 T2-2 裁定
个人文件      USER_NOTES.md · QUICKSTART.md · USER_NOTES.md完整流程使用说明.pdf  ← T2-3 未裁定
死历史        supabase/migrations/ · tools/vocabulary_builder/            ← T2-4 未做

本地与 origin 已完全一致(2026-08-28 推送后 fetch 复核 ahead=0)。 原先的风险点「T3-1/T3-2 从镜像克隆造前缀化历史,会漏掉只存在于本地的 commit」 已消除 —— 现在无论镜像取自本地还是 origin,内容都一样。 ⚠️ 但这只对本次快照成立:阶段 3 开工前仍要再确认一次 ahead=0, 中间任何一次 RVH 会话提交都会重新打开这个风险面。

T2-1 可视为已完成(两半都做完了),但仍请做 T2-2/T2-3/T2-4 的人自己复测一遍 再回写台账 —— 上面这份是 2026-08-28 的快照,不是台账本身。

我没有替 RB 改它那份计划的台账(阶段 1 正在 RB 会话进行,并发编辑同一份 plan 会撞车)。 上面这份实测就是给做 T2-1 的人用的,别照台账里的旧数字开工

合仓会改写哪些条目(重启时逐条对照,别盲目恢复)

① 路径全变 —— 合仓后本仓整树落到 rvh/ 前缀下。下方条目里所有 lib/… / docs/… / scripts/… / assets/… 路径都要读成 rvh/…这是重启最容易踩的坑。

② 这几条的问题形态会被合仓改掉,恢复前先看阶段 4 做到哪了

条目受哪个阶段 4 任务影响
🔴 P2 预装词库「数量口径」两仓都记错T4-4(红线 #10 等收敛为单一定义)——「两仓各记一份数字」这个问题本身可能随之消失
🟡 P1 红线 #9 canonical 规则等 RB 落地T4-4 点名 #9
🔥 P2 RB pull 漏 source_platform(RB 端修)T4-1 / T4-2 —— 合仓后不再是「跨端交接」,直接同仓改
🔥 P2 note_type 双端取值表不匹配(需定跨端契约)同上;契约形态从「两仓握手」变成「同仓单一定义」
🟢 P3 mastery 阶梯进黄金向量(等 RB 加键)T4-3(黄金向量去重,RVH fixture 改引用)

③ 不受合仓影响、可原样恢复(只是路径加前缀): 4953 陈旧词数 P3 · next_review_date 存量裸串 P2 · OCR 主列规则 P3 · scan 页 UI 权重 P3 · 词详情中英混排 P3 · batchMarkMasteredByCefrLevels 死码 P3 · 背景移除 P3 · 测试覆盖率 P3 · 用户手册 P3 · 翻译能力澄清 P3 · 🔥 P1 RVH push 75→4 漏推根因调查(2026-05-26 老账,纯本端)

⚠️ 一条合仓可能悄悄弄坏的东西(本会话刚建,容易被忽略)

scripts/doc-consistency-check.sh Step 6 的真值源是预装库本体:

bash
PROJECT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
VOCAB_DB="$PROJECT_ROOT/assets/databases/lampio_dict.db"

PROJECT_ROOT 由脚本自身位置推导 ⇒ 整树搬到 rvh/仍能解析,这条没问题。 但 **T4-5「65MB 预装库去重」**会动这个文件:

  • 改成指向 RB 那份的 symlinksqlite3 跟随 symlink,Step 6 照常工作 ✅
  • 直接删掉 RVH 这份、只留 RB 的 ⇒ 命中 [ ! -f "$VOCAB_DB" ] 守卫, 打印 ⏭️ 跳过:预装库不存在 —— 不会假绿(守卫刻意如此设计), 但会永久静默跳过,等于这条检查死了而没人知道。 ⇒ 走这条路时必须同时把 VOCAB_DB 指到保留的那份

同理 T3-5(Skill 作用域)/ T3-6(ci-rvh.yml 会动 pre-commit 的接线方式, 本仓 scripts/git-hooks 那套要跟着走。


✅ 已关闭 · P1 — 红线 #9 canonical 键空间(2026-08-28 立项 → 同日裁定 → 同日两端落地)

RB doc 43 裁定 + §6 落地,RVH 镜像落地见 doc 46。 规则、单点位置、三侧共用、分流判断的载体,全部写进 CLAUDE.md 红线 #9,不在这里重复

留下的两条尾巴(都在对端):

  • [ ] 🔴 RB 定 A/B/C:那 3 行调试夹具已软删,但软删按定义不删行 ⇒ 墓碑上的裸 created_at 会让 RB 的机械层 M1 一直红。 A(M1 加 deleted_at IS NULL,RVH 倾向)/ B(UPDATE,要重推一轮)/ C(硬删,违反红线 #6,不做)。见 doc 46 §3.2
  • [ ] RB 复核其删除路径是否也走了 canonical_word —— RVH 这边正是漏了删侧、 被计数守卫抓到的(doc 46 §1.2)

未核项(与本条独立,仍开着):缓冲池回填落库的是远端返回的 entity.word, 不一定等于查询串(RB 同域 Q3)。补一条「回填行 word == 查询串」的断言。

✅ P2 — autoSync 只在 Review 页活着,其余页面完全不同步(2026-08-27 立项,当日修复 v63

现象autoSyncProviderlib/features/sync/presentation/providers/sync_providers.dart:74) 是 Provider.autoDisposeref.onDisposetimer.cancel()。全仓只有 2 个 watcher

  • lib/features/auth/presentation/widgets/auth_section.dart:38
  • lib/features/vocabulary_notebook/presentation/pages/review_overview_page.dart:71

⇒ 只要不停在 Review 页(或含 auth_section 的页),那个 60 秒轮询就被销毁,同步完全不运行。

怎么发现的:验证红线 #6g 的时钟漂移时,改完 Supabase 等了 75 秒去查,行纹丝不动。 差点当成「通过」—— 实际是 last_sync_attempt_at 停在 30 分钟前,autoSync 压根没在跑 (我当时导航到了生词本页和词详情页)。进 Review 页后 provider 重建、Future.microtask 立刻触发一次 sync,行才拉下来。

用户可见后果:在生词本里删了词 / 改了掌握度,只要不进 Review 页,就一直不上行。 下次进 Review 或重启 app 才补推。对跨端场景尤其别扭 —— RB 那端看不到变化,而 RVH 这边毫无提示。

为什么值得单独立项而不是顺手改(需要产品决策,别直接把 autoDispose 去掉):

  1. 改成常驻会让 sync 在后台一直跑 —— 耗电 / 流量,且 app 被切到后台时 Timer 行为要另想
  2. 也可能是有意为之 —— 「只在复习时同步」是一种省电策略,只是没写在任何文档里
  3. 更可能的正解是换触发时机(写操作后触发一次 + 前台恢复时触发一次),而不是让轮询常驻

⚠️ 对验证方法的连带影响(记在这里防止重蹈):它会让任何 「观察若干轮之后某个值没变化」的验证默认假绿。 今后凡是这类验证,必须同时查 app_metadata.last_sync_attempt_at 作对照, 证明观察窗口内同步真的在跑。已写进 doc 39 §5.3 与 §6.4(后者也提醒了 RB)。

关联docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md §5.3 / §6.4。


✅ 结论(v63,2026-08-27 当日修复)

立项时的现象描述有一处不准,先订正:存活条件不是「停在 Review 页」,而是 「Review 页或 ProfilePage 在 Navigator 栈的任意一层」MaterialPageRoute 默认 maintainState: true,从 Review push 进生词本 / 词详情时 Review 仍挂在栈里, 定时器照旧活着。三个 tab 之间的 pushReplacement 才真的拆掉它。

同一个生词本页,从 Review 进去同步就跑、从 Me 进去就不跑。这正是当时那两条 互相矛盾的观测(「删词 20 秒后上行」vs「等 30 分钟纹丝不动」)的来源,也是它难查的原因。

「是否有意的省电策略」的答案:不是。 review_overview_page.dart:70 原注释写的就是 「Start auto-sync when signed in (app-wide trigger)」—— 意图一直是 app 级,只是落点 放错了页面。

采纳方案 A+B(用户拍板):宿主提到 app 根 + 生命周期感知,前台 60s 不变、切后台暂停。 未加写操作触发(评估过:触发点撒进各写路径,漏一个就是一个静默不上行的洞, 而 60s 兜底已足够)。

  • 宿主 = main.dart_AppServicesHost,挂在 MaterialApp.builder(Navigator 之上)。 🔴 两个位置约束缺一不可:① 必须在 Navigator 之上,home: 和任何页面都会被 tab 切换的 pushReplacement 换掉;② 必须等 Supabase init 完成才 watch (startupInitializedProvider)—— _MyAppState.buildrunApp 时就跑, 提前 watch 会经 syncRepositoryProviderauthStateProviderAuthRepositoryImplSupabase.instance 就绪前被构造并永久缓存成 error 状态_heavyInitAndResolve 里早有这条告诫,本次差点重蹈)。
  • autoSyncProviderProvider.autoDispose 改普通 Provider
  • 🔴 前置syncNow 补了防重入(合流语义)。旧代码只有一个定时器所以撞不上, 加了恢复触发后「定时器这一拍」与「回前台」会真并发,而 watermark 推进(#5d) 与 cloze reconcile(#5g)都不是为并发设计的。

真机验证(2026-08-27,24094RAD4C + 真实 Supabase)见 CHANGELOG 同条。

有一处遗留没验:防重入顺带修的 ensureFresh(登出 flush 不合流)在那轮真机 之后才加,未上真机 —— 见本文件「🟢 P3 — syncNow(ensureFresh:) 的登出 flush 路径 没上真机」。本条其余部分不受影响

⚠️ 验证方法论那一条不作废,只是理由换了last_sync_attempt_at 对照仍然必须做 —— 修复后「同步停了」依然会合法发生(app 切后台就停)。


✅ P3 — stopwordsMergeProvider 还挂在页面上(v63 顺带发现,同批修复

这是 v63 那个缺陷的同款,只是没跟着一起修 —— 修 autoSync 时发现的,属另一件事, 没有顺手扩大改动范围。

stopwordsMergeProviderknown_words_providers.dart:90Provider.autoDispose) 的 watcher 同样只有 review_overview_page.dartauth_section.dart 两个 —— 它的文档注释里甚至明写着「和 autoSyncProvider 同一机制」。autoSync 的宿主已提到 app 根,这条还留在原地。

后果比 autoSync 轻:纯本地一次性 merge(default_stopwords → 当前用户 known_words),无网络、无跨端影响。但一个从不点进 Review / Profile 的用户 永远不会跑这次 merge,过滤器里会一直冒常见词。

修法:宿主同样挪到 main.dart::_AppServicesHost(或并列一个 host), 约束与 v63 相同 —— 必须在 Navigator 之上、必须等 Supabase init 之后。 决策 18 里已经写清了这两条约束的理由,照抄即可。

⚠️ 顺带核查一下 Provider.autoDispose 的其它用法:判据不是「有没有用 autoDispose」, 而是「这个 provider 是不是后台服务」 —— 页面级派生状态用 autoDispose 是对的, 只有「存活期本该跟 app 走」的东西放进去才是缺陷。


✅ 结论(与 v63 同批修,2026-08-27)

挪到同一个宿主 main.dart::_AppServicesHost(该 widget 因此从 _AutoSyncHost 改名), Provider.autoDisposeProvider

🔴 挪之前必须先加空串守卫 —— 这不是防御性代码,是这次挪动新开的暴露面currentUserIdProvider 在「未登录」和「auth 尚未 resolve」时返回的是空串不是 null (它为了登出过渡期不崩而保留 _lastKnownUserId,首次登录前初值就是 '')。 挂在页面上时这一步碰巧安全 —— auth_section_buildSignedIn 和 Review 页 都只在已登录时渲染;挪到 app 根后宿主在 Supabase init 完成那一刻就 watch, 此时 authState 很可能还在 loading。

真跑一次的代价是永久的:插进 211 行 user_id = '' 的孤儿(id 形如 rec:<n>:)—— 读侧 getAllForUseruser_id IS ? 配真 id 看不见、push 按 user_id = ? 过滤(#5h) 推不走、clearLearningDataForUser 删的是 user_id = ? OR user_id IS NULL(#5h NULL 清扫) 也匹配不上空串。就是 #5d / #5h 反复讲的那种孤儿行,只是值从 NULL 换成了空串。

注入验证过:去掉守卫后用例红在 Actual: [''] —— 确实会带空串去 merge。 真机复验:冷启动后 known_wordsuser_id = '' 的行数为 0(211 行全是真 userId), 日志里 Stopwords merge: triggered by login 只出现一次且带真 id。


✅ 已关闭 · P1 — reading_pages 仍是硬删 + FK CASCADE(2026-08-28 立项 → 当日修复,跨端待验项 C 的阻塞已解除)

一句话:删扫描页走 db.delete 硬删,靠 ON DELETE CASCADE 带走 word_page_links ⇒ ① 删除不上行(取不到行就 push 不出去,云端那页永生,对端继续显示); ② CASCADE 物理抹掉 word_page_links 的墓碑 —— 那正是红线 #6f / #6g 刚立起来的东西。

现状(2026-08-28 实测)

lib/features/reading_tracking/data/datasources/reading_page_datasource.dart
  :131  deleteReadingPage(String id)          db.delete('reading_pages', ...)   ← 硬删
  :580  deleteReadingPages(List<String>)      db.delete(...)                    ← 硬删(批量)
assets/sql/01_create_tables.sql
  :377 / :403  word_page_links.reading_page_id REFERENCES reading_pages(id) ON DELETE CASCADE

两条 UI 路径都可达

入口链路
阅读来源列表里删单页reading_page_list_page.dart:112deleteReadingPage:131
存储管理里「完全删除」storage_management_page.dart:112 / source_list_notifier.dart:152deleteSourcesCompletelydeleteReadingPages:580

代码注释还明写着「word_page_links会通过外键CASCADE自动删除」「CASCADE 自动清理关联表」—— 这是当年有意的设计,不是笔误,只是它早于墓碑体系。

为什么是 P1

reading_pages 是 6 张同步表之一。#6e 已经为 reading_notes 走完了完全一样的推理 (硬删 ⇒ 删除信号无处承载 ⇒ 对端永生 + 自己会被复活),reading_pages 是同一个洞的 另一半,且它还会反过来破坏刚落地的 #6f 子树墓碑:删词时好不容易给 links 打上的墓碑, 用户一删页就被 CASCADE 物理抹掉,对端永远收不到。

修法:照抄 reading_note_datasource_impl.dart::deleteBook

同一仓里已有正确样板(#6e 落地时写的),三条红线一起满足:

  1. #6e:单事务软删,父行先行rows == 0 在事务内抛(异常回滚,不留半截状态)
  2. #6f:同事务里手工级联软删子树 word_page_links(CASCADE 只在硬删时触发,改软删后 子表必须手工带走)
  3. #6g 规则 Wdeleted_atupdated_at 写同一个值、同一条 UPDATE、同一个参数 —— 不是「也写一下」。漏了对端每轮重推(RB 2026-08-03 那 57 行就是这么来的)

配套:

  • 所有读查询加 deleted_at IS NULL(#6e 当时改了 8 处,这次要逐个数)
  • _pushReadingPages dirty-check 加墓碑分支;payload 的 deleted_at 传本地真值 (🔴 恒写 null 会远程复活对端刚删的页 —— #6e「反向 footgun 1」)
  • _pullReadingPages 已经是三分支模板 + _parentState 守卫,大概率不用动,但要复核
  • 封面文件删除(deleteSourcesCompletely 里删 coverImagePath)与软删的关系要定: 软删之后本地文件还删不删?删了对端 pull 回来会不会指向空路径?这条是新问题, #6e 没遇到过(reading_notes 的封面是 BLOB 直传,不是文件路径)

顺带发现(同类,但当前无 UI 调用点)

deleteWordPageLinkRelation:253)/ deleteAllRelationsForWord:264) 也是对 word_page_links硬删。目前只在 repository 接口里声明、没有上层调用点 (grep 全仓只有接口与实现两处)。不用现在修,但也别顺手复活它们 —— 哪天接回 UI 就是同一个洞。修本条时建议一并改成软删或直接删掉死方法。

验收

修完才能跑跨端待验项 C(doc 23 §7.3「RVH 删扫描页 → cloze 行仍在」)—— 台账 ~/reading-browser/docs/verification/cross-end-cloze-pool-2026-08.md §0 已把 C 标为「🚫 本轮不排期」,理由就是这条。现在跑 C 等于把坏行为记成「既有行为」。

回归测试形状照抄 test/features/sync/tombstone_parent_test.dart / tombstone_synced_at_test.dart (跑真实 DDL + 真实生产代码),并逐处注入缺陷验证会红


✅ as-built(2026-08-28 当日修完)

写侧:两个删除方法都改成「单事务软删 + 手工级联软删 word_page_links」, 形状逐条对齐 deleteBook(#6e 样板)。两处的一个差别写进了代码注释: 单页版找不到就抛(事务回滚,不留半截状态);批量版不抛 —— 它的调用方是「存储管理 → 完全删除可清理来源」,传进来的一批 id 里可能有已是墓碑的 (上一轮清过、或对端删了同步下来),那不是错误,跳过即可,返回真正新打墓碑的页数。

读侧:补了 3 处 deleted_at IS NULLgetReadingPageById / getAllReadingPages / getSourceCountByBook)。其余读查询本来就带。

push 侧零改动 —— v55 早就把墓碑分支和 deleted_at 真值 payload 写好了, 只是注释里那句「RVH 当前无本地删除入口」现在失效了,一并订正(连同 _pushWordPageLinkRelations 与 schema 里 deleted_at 列的同款注释)。

pull 侧补了一处_pullReadingPages 分支①(远端页墓碑)原先只标页、不带子树。 按 _pullReadingNotes 分支①的既有先例补上防御性级联 —— 对端漏传 link 墓碑时, 本地不该留下挂在已删页下的活关联(那正是 #6f 服务端巡检 tombstone_parent 要报的 隐身孤儿)。

📌 文件与行的取舍(立项时标为「新问题」,现已定):deleteSourcesCompletely图片文件真删、DB 行留墓碑。本入口的用户意图就是回收空间,文件必须删;而删除信号 要跨端传播就必须留墓碑行。代价是墓碑行的 image_path 指向已不存在的文件 —— 无害: 所有读查询都带 deleted_at IS NULL,取不到这些行,没有任何路径会去打开那个路径。

顺带核实、确认无回归getCleanableSourcesByBook 本来就滤 deleted_at IS NULL, 所以给 getReadingPageById 加过滤对存储清理路径是 no-op,不会漏删文件。

回归测试 test/features/sync/reading_page_soft_delete_test.dart 13 例 (单页 8 + 批量 3 + 上行 1 + 下行 1),跑真实 DDL(FK 打开 —— 硬删才会真的触发 CASCADE,测试才抓得住「墓碑被抹掉」)+ 真实 datasource + 真实 syncNow()四处注入缺陷逐个实证会红

① deleteReadingPage 退回硬删 + CASCADE   → 6 例红
② link 只写 deleted_at 不 bump updated_at → 1 例红
③ 删页不带走 link 子树                    → 3 例红
④ pull 分支① 不连带软删本地子树           → 1 例红

全量 1353 例通过。

跨端待验项 C 已于同日验完(本地/云端各 33 条 cloze 全部存活,页与 33 条 link 墓碑 正常上行,两轮 sync 后 pushed=0)。回执 docs/cross-end/44, 其中含一处对 doc 23 §3.1 理由的订正 + 一件请 RB 自核的新暴露面(RVH 删页现在会上行)。 实测发现 C 其实不需要双端在场 —— 判据全在 RVH 本地与云端,RB 侧无需动作。


✅ 已关闭 · P2 — doc-consistency-check.sh 的词库数量检查是永久假绿(2026-08-28 发现 → 当日修复

一句话:Step 6 检查的是「文档里还有没有那个旧错值」(CEFR-J 初版的四位数词数, 字面量见 scripts/doc-consistency-check.sh:180),而不是「文档说的数对不对」。 旧值早已清干净 ⇒ 命中数恒为 0 ⇒ 永远绿;与此同时它打印的那句 「✅ 词汇库大小描述一致(8614)」里的 8614 是个硬编码常量、脚本从没核对过, 而且它本身也是错的 —— 真值 18916。这脚本挂在 pre-commit 上,每次提交都跑。

代码(scripts/doc-consistency-check.sh:175-188

bash
VOCAB_COUNT=8614                                   # ← 只用于打印,从不参与比较
VOCAB_ERRORS=$(grep -rn "<旧值>" docs/ README.md QUICKSTART.md .env.example \
  | grep -v "历史\|v1\|删除" | wc -l)             # ← 唯一的真实判据:旧值还在不在
                                                   #   <旧值> 的字面量见脚本第 180 行;
                                                   #   本文件故意不抄,理由见下
...
echo "✅ 词汇库大小描述一致(${VOCAB_COUNT})"      # ← 断言了一件没检查过的事

实测(2026-08-28)

$ <脚本第 180 行那条 grep 原样执行>
(空)                                    ⇒ VOCAB_ERRORS=0 ⇒ 恒绿

$ sqlite3 assets/databases/lampio_dict.db "SELECT COUNT(*) FROM vocabulary;"
18916                                   ⇒ 真值(与 CLAUDE.md「项目当前状态」一致)

文档语料里同时并存四个数:
  8614   13 处   ← 脚本嘴上说的「一致值」,实为 CEFR-J 时代遗留
  18898  11 处   ← round30 之前
  18925   5 处   ← round30
  18916   6 处   ← 当前真值

这正是本检查存在的目的所要抓的情况,而它报「通过」。 8614 分布在 docs/product.md:118 · docs/decisions.md:156/202/215 · docs/database/optimization.md:298 · docs/plans/quick-triage-roadmap.md:107/263 · docs/rvh-me-features-for-rb-migration.md:26/50/134

🔁 本条目自己撞上了这个检查(顺手记下的实证)

初稿在正文里原样引用了那个旧值字面量,pre-commit 当场报「发现 3 处词汇库大小不一致 (应为8614)」,3 处正是本条目里提到它的那 3 行。

两个推论:

  1. 判据确实是「旧值出现过没有」而非「数对不对」 —— 一篇只是在谈论该值的 待办文档就能把它点亮,而 13 处真正声称 8614 的文档它一个也没看见。
  2. 连报错文案里的「应为8614」也是错的(真值 18916)—— 它报警时给的也是错误指引。

所以本条目通篇以 <旧值> 指代,字面量只留在脚本里。修脚本时顺手把 docs/plans/ 排除掉(同 Step 5 已经把 docs/decisions.md / docs/plans/ / docs/cross-end/ 当时间快照排除的做法),否则待办文档永远没法讨论自己要修的东西。

修法:照抄同文件 Step 5 的形状

同一个脚本里 Step 5(表数量)和 Step 1(Schema 版本)的判据是对的 —— 它们从真值源推导出基准再逐处比对:

⚠️ 订正:立项时写的「Step 1 无事,那行像乱码只是色码转义的显示问题」是错的, 详见下面「as-built」里的第 3 项 —— 那是真 bug,两条文案都在丢版本号。

bash
TABLE_COUNT=$(grep -c "^CREATE TABLE" assets/sql/01_create_tables.sql)   # Step 5 ✅
DART_VERSION=$(grep "_schemaVersion =" .../app_database.dart | grep -o "[0-9]\+")  # Step 1 ✅

Step 6 应同构改成:

bash
VOCAB_COUNT=$(sqlite3 assets/databases/lampio_dict.db "SELECT COUNT(*) FROM vocabulary;")

再逐处比对文档里的声明(模式同 Step 5:只在声明当前总量的写法上匹配, 用 TABLE_HISTORY_RE 同款规则排除历史行 —— 词库数量的历史行特别多, docs/cross-end/docs/plans/ 里满是「round30 前 18898 → 后 18925」这类时间快照, 拉着它们跟真值走是错的)。

⚠️ 真值源选 .db 而不是 CLAUDE.md 里的数字:预装库是唯一不会说谎的一方, 而 CLAUDE.md 那个数本身就是要被检查的对象之一。代价是 pre-commit 多一次 sqlite3 调用 (实测 <50ms,库 65MB 但 COUNT(*) 走索引);若嫌慢,退而求其次可用 docs/database/schema.md 做真值源 —— 但那样就退化成「文档自洽」而非「文档与产物一致」,不推荐

顺带:修完脚本还得清语料

脚本改对之后会立刻报出上面那 13 处(外加 18898/18925 里真正属于「当前声明」的那些)。 那是真的,不是误报。 清理时逐条判断是「当前声明」还是「历史快照」—— 后者保留原值,别一把 sed 替换。


✅ as-built(2026-08-28 当日修完)

1. Step 6 重写:真值源改成预装库本身 (sqlite3 assets/databases/lampio_dict.db "SELECT COUNT(*) FROM vocabulary;" → 18916), 逐处比对权威文档里的声明,形状对齐 Step 5。sqlite3 缺失 / db 缺失 / 查询无结果三条兜底 一律打 ⏭️ 跳过并说明原因,绝不打 ✅ —— 这条是本次教训的直接产物。

2. 语料清理:8 处改、11 处有意保留。 判据 = 「当前声明」还是「时间快照」:

改(当前声明)留(时间快照)
docs/product.md:118 · docs/database/optimization.md:298docs/decisions.md:156/202/215(带日期 ADR,且同段落还写着 v27 / 9 张表 / vocabulary_items
docs/deployment/supabase-setup-guide.md:153/304docs/rvh-me-features-for-rb-migration.md:26/50/134生成时间 2026-04-08 的快照,含当时的 UI 示例数字)
docs/deployment/edge-function-deployment-guide.md:239docs/plans/quick-triage-*.md · docs/plans/rvh-schema-alignment-tasks.md(计划快照)
QUICKSTART.md:31/44 · .env.example:28docs/plans/backlog.md(就是本条目自己)

顺带修掉同一行上的三个陈旧标识符(不只是数字错): vocabulary_itemsvocabulary(表早已改名)、vocabulary.dblampio_dict.db、 QUICKSTART 里指向 app_database.dart 的「CEFR 词库」→ 指向真正的 assets/databases/lampio_dict.db

3. 🔴 修的过程中发现 Step 1 自己也有 bug(立项时判错了,此处推翻)

echo "…v$DART_VERSION(4个权威文件)" —— $VAR 后面紧跟全角字符会被 bash 3.2 吃掉。 当前 locale 下 EF BC 88)的首字节被当成标识符字符并进变量名 ⇒ 解析成 ${DART_VERSION\xEF} (未定义)⇒ 变量整个消失,全角括号还被啃掉一个字节

bash
$ /bin/bash -c 'V=57; echo "[$V(]"; echo "[${V}(]"'
[<乱码>]        ← 裸 $V:57 没了
[57(]          ← ${V}:正常

受影响三处:Step 1 的成功文案、Step 1 的失败文案(权威版本: app_database.dart v$DART_VERSION —— 真报错时把最关键的基准版本显示成空)、以及本次新写的 Step 6 跳过文案。 全部改成 ${VAR},并在文件头加了防复发注释。

这条能被抓到,纯粹是因为没有相信自己「那只是显示问题」的第一判断od -c 看了原始字节。 与本条目主题同源:成功文案不可信,得看它凭什么说成功。

4. 注入测试(5 正 + 3 负,逐条实证)

正向 —— 每处声明改错一个数字,必须报红:

CLAUDE.md 词总数 18916→18925          ⚠️ 抓到
CLAUDE.md 首次启动导入 18916→8614      ⚠️ 抓到
CLAUDE.md 个预装词条 18916→18898       ⚠️ 抓到
docs/product.md 18,916→8,614          ⚠️ 抓到
supabase-setup-guide 18,916→8614      ⚠️ 抓到

🔴 第一版没抓到「CLAUDE.md 词总数」那条(报了通过)。根因:照抄 Step 5 的 HISTORY_RE 用了裸 ,而 CLAUDE.md 里声明词总数的那行是超长段落,段内有大量与数量无关的 箭头(all along→context 这类 tag 改档)⇒ 整行被当成历史行排除。已把判据收紧成 「数字→数字」([0-9][[:space:]]*(→|->)[[:space:]]*[0-9])才真正生效。 这正好又是一次假绿,且是我自己新写的 —— 记在这里当反面样本。

负向 —— 必须报:11 处快照文件里的 8614 全部沉默;db 缺失时打 ⏭️ 而非 ✅(实测)。

5. 未纳入本次范围(另见下方新条目):代码里还有一批不同的陈旧值 4953 (含用户可见的 l10n 文案 vocabTestDescription),与本条目的 8614 不是同一批。

为什么定 P2 而不是 P3

不影响运行时,但它挂在 pre-commit 上、每次提交都对着人说"通过" —— 假绿比没有检查更糟,因为它让人停止怀疑。本仓已经吃过同款亏:红线 #5e 那组 phase0 证据文件在旧架构上陈旧了整整四个月无人察觉,失效方式同样是 「检查照旧输出通过」(~/reading-browser/docs/cross-end/26-rvh-phase0-evidence-stale-report.md)。

已核实不受影响的部分(别顺手一起改)

  • Step 1(Schema 版本)的判据DART_VERSION 实测提取为干净的 57, 四个权威文件(01/02/03_*.sql + schema.md)全是 v57,比对逻辑正确终端里那行看着像乱码,是色码转义的显示问题这句是错的,已在修复时推翻, 见 as-built 第 3 项。
  • Step 2/3/4(大写文件名 / 旧 guide 路径 / 已删文档引用):它们本来就是 「旧字符串不许再出现」的迁移守卫,grep 旧值正是其正确语义,成功文案也没有 多断言什么。不要拿 Step 6 的结论去套它们。

✅ 已关闭 · P1 — 多词性词在收割站首屏被静默吞掉(2026-08-28 跨端 relay 实测发现 → 当日修复

一句话FilterConfirmationState.initial整串等值匹配 CEFR 档, 而多词性词的 cefrLevel 是逗号串(cave = "B2,B1")⇒ 首屏 words根本没有它们, 用户既看不见也选不了。同一份数据换个入口就回来了 —— 随便点一下任意 CEFR 芯片, 列表就从 20 词跳到 28 词。

实测原始输出(2026-08-28,24094RAD4C,v63 3d51449

一页 34 个有效词,收割站首屏只列出 20 个;点一下 C2(该档 0 词,逻辑上不该有任何变化):

[VocabFilter] Before - Displayed words: 20
[VocabFilter] Action - Removing level: C2
[VocabFilter] After - Active levels: A1, A2, B1, B2, C1
[VocabFilter] After - Displayed words: 28          ← 凭空多出 8 个
[VocabFilter] MISMATCH! Displayed (28) != Expected (37)

被吞掉的 8 个正是 pipeline 日志里 CEFR 值带逗号的那些:

[Pipeline] 📊 A1,B1: fire        📊 B1,A2: explore      📊 B2,B1: cave
[Pipeline] 📊 B1,A1: go, share   📊 B2,B1,A1: light     📊 A2,A1: use

B1 芯片计数是 7,而首屏 B1 分组只有 1 个(iron)—— 芯片按逗号拆分统计、列表不拆, 同屏两个数就自相矛盾,但没有任何报错。

根因(三处实现,只有一处没拆逗号)

位置是否拆逗号结果
filter_confirmation_state.dart initial(约 :250)activeLevels.contains(cefrLevel)❌ 整串等值首屏丢词
filter_confirmation_notifier.dart::_filterWordsByLevels(约 :687)split(',')切档后恢复
filter_confirmation_state.dart::sortedGroupedWords(约 :200)split(',')分组本身没问题

⇒ 修法就是把 initial 的判据换成与另外两处同构的拆分匹配(REFERENCE 那条豁免要保留)。 isSelected 的默认值口径也要一起对齐(_filterWordsByLevels 里是 cefrLevel != 'UNKNOWN' && != 'REFERENCE')。

为什么定 P1

不是显示瑕疵,是采词漏斗最后一米的静默损失:这些词已经通过了 OCR、词典查询、CEFR 筛选, 到了用户面前那一屏才消失,而用户没有任何线索知道少了东西(芯片上的数字反而告诉他有 7 个)。 多词性恰恰是常用词的特征 —— fire / use / light / share / go 全中招。

回归测试要断言什么

纯函数,直接测 FilterConfirmationState.initial:给一个 wordCefrLevels = {'cave': 'B2,B1'} 的 result + userSelectedLevels = {'B1'},断言 wordscave。 再补一条「首屏 words 数量 == 切档一次后的数量」的等价性用例 —— 这条才挡得住日后又改歪一处。


✅ as-built(2026-08-28 当日修完)

修法:收成单点,而不是只改那一处。 缺陷的本质是「同一个判断有四处实现、只有一处没拆 逗号」,只补那一处等于把地雷留在原地。新增 CefrLevelSpecfilter_confirmation_state.dartparse + matches 两个静态方法),四处全部改走它:

位置原状改后
FilterConfirmationState.initial❌ 整串等值 —— 缺陷本体CefrLevelSpec.matches
_filterWordsByLevels(notifier)✅ 手搓 split + REFERENCE 分支CefrLevelSpec.matches
visibleHighlightedSources✅ 手搓 splitCefrLevelSpec.matches
sortedGroupedWords✅ 手搓 splitCefrLevelSpec.parse(分组是一词进多桶,形不同但口径同源)

顺带收了两处「芯片计数」filter_confirmation_notifier.dartcefrStatscefr_level_button_group.dartlevelCounts):它们本来就拆逗号,但用的是不带 .trim() 的裸 split —— 与过滤路径的归一口径不一致。当前值里没有空格所以尚未发作, 但芯片数与列表数分岔正是本缺陷的表象,不该留两套归一。

🔴 顺带修掉一个会喊狼来了的自检toggleCefrLevel 里的 expectedWords 是把 cefrStats 按档位求和,而多词性词在每个档里各计一次 ⇒ 求和重复计数 ⇒ 修复后每次切档 都稳定打印 ⚠️ MISMATCH! Displayed (6) != Expected (11)。那是公式错不是过滤错, 已改成「命中任一激活档的去重词数」。(留着不管的话,下一个人会以为修复引入了新问题。)

没有动的两处,有意为之

  • isSelected 的默认值判据仍是整串等值cefrLevel != 'UNKNOWN' && != 'REFERENCE'), 与 word_card.dart::isDisabled 的判据一致。改成拆分匹配会让 "A1,UNKNOWN" 这类混合值 从「默认选中」变成「默认不选中」—— 那是行为变更不是缺陷修复,且当前数据里没有这种值。
  • harvest_station_page.dart::_buildLemmaColorMap 取「第一个非空档」当代表色, 需要有序首元素,而 parse 返回 Set(无序)⇒ 不适用,保留原写法。

回归测试test/features/vocabulary_filtering/presentation/multi_pos_cefr_visibility_test.dart 14 例(CefrLevelSpec 纯函数 4 例 + 首屏 5 例 + 等价性 5 例)。 四处注入缺陷逐个实证会红

① initial 改回整串等值(原缺陷)        → 8 例红
② 去掉 REFERENCE 恒合格                 → 2 例红
③ sortedGroupedWords 不拆逗号           → 1 例红
④ _filterWordsByLevels 改成整串等值      → 5 例红(**只有等价性用例抓得住**)

④ 尤其说明问题:破坏的是另一侧,「首屏含 cave」全部照过,只有 「首屏 == 切档后」那组红 —— 这正是当初写等价性用例的理由。全量 1298 例通过。


✅ 已关闭 · P1 — ML Kit 词过滤把「句末带标点的词」整个丢掉(2026-08-28 跨端 relay 实测发现 → 当日修复

一句话mlkit_engine.dart:112wordPattern = RegExp(r'^[a-zA-Z]{2,}$') 直接作用在 ML Kit element 原文上,而 ML Kit 会把紧贴的标点算进 element(Cave. / Canada. / old.) ⇒ 这些词连词位都不生成,后面整条链路(候选列表、语境采集、高亮)都当它不存在。

实测原始输出(2026-08-28,同上)

一页 7 行,6 行以句号结尾、1 行没有终止标点。OCR 原始词表(73 词)里 6 行的行末词全部消失,唯独没有句号那行的行末词 sparks 留下了

渲染文本                                                     行末词  OCR 是否收到
These traces of fire could be anywhere from ... years old.   old.    ❌
This archaeologist works at the University of Toronto in Canada.  Canada.  ❌
He leads excavations at Wonderwerk Cave.                     Cave.   ❌
Chazan was part of a team that shared the new findings in PLoS One.  One.  ❌
The researchers do not think H. erectus lit the fires by itself.  itself.  ❌
The earliest evidence of people using iron pyrite to make sparks   sparks  ✅ ← 无句号
dates to about four hundred thousand years ago.              ago.    ❌
[Pipeline] 📥 OCR 原始单词 (73): ..., he, leads, excavations, at, wonderwerk, chazan, was, ...
                                                              ↑ "Cave." 本该在这里

这就是本次 relay 第一次拍页失败的原因 —— 目标词 cave 恰好落在句末, 整轮收割里它一次都没出现过,而 UI 上没有任何异常可看。

影响面

每个句子的最后一个词、每个从句末尾带逗号的词(Cave,)都拿不到词位。对 语境采集(红线 #5g)尤其糟:C2 规定「每词每次收割最多 1 条」,靠的是词位; 没有词位 = 该词这一页永远采不到语境(本次日志里 无词位候选 1 词 [finding] 就是同一类)。

修法(别只改正则)

不能简单放宽成 ^[a-zA-Z]{2,}[.,;:!?]?$ —— 还得把标点从 word 里剥掉再 lowercase, 否则 cave. 会带着句号进 OcrWordEntity.word,下游 lemmatizer / PK 全错(红线 #9)。 建议:先 trim 掉首尾非字母字符(含引号 "'" 与破折号),再用现有正则校验剩余部分, bbox 保持 element 原框(多一个句号的宽度,对高亮无实质影响)。 ⚠️ 剥离只能动首尾,不能动词内连字符/撇号,否则 don't / pellet-strewn 会被切碎。

回归测试要断言什么

_extractWordEntities 是私有的,测试从 MlkitEngine 的公开出口进,或把清洗抽成纯函数 normalizeElementText(String) 单独测:"Cave." → "cave""Canada," → "canada""don't" → "don't"(不切)、"1.07" → null"—" → null


✅ as-built(2026-08-28 当日修完)

抽成纯函数 MLKitEngine.normalizeElementText:先剥首尾字符再用原正则校验。

🔑 剥的是「非字母非数字**」,不是立项时写的「非字母」** —— 这处收窄是实现时才想清楚的: 按「非字母」剥会把尾部数字也吃掉,COVID19 → covidpage12 → page。那是另一种归一, 超出本缺陷(标点紧贴)的范围,会在没人要求的地方改变行为。保留数字 ⇒ 字母数字混合词 照旧被拒,与修复前一致;1.07 / 400,000 同理仍拒(首字符是数字,不剥)。 第一版按立项原文写的,被 COVID19 那条用例当场抓住。

只剥首尾、不动词内don't / pellet-strewn 剥完仍含 ' / -,照旧被拒 —— 与修复前行为一致,本次不放宽这一类(想支持涉及 lemmatizer 与跨端 PK,红线 #9, 是另一件事,不在此处夹带)。

bbox 用 element 原框(含被剥掉那个标点的宽度):行左边缘取 min(x),尾部标点不影响; 首部标点让 x 略微右移,量级远小于 OcrClozeGate 的容差。

顺带核实:同仓另一个 mlkit_ocr_datasource.dart\b[a-zA-Z]{2,}\b行文本, 本来就没有这个毛病,不用改。线上路径是 mlkit_engine.dart(日志 tag OCR 来自 base_ocr_engine.dart:53,已核)。

回归测试 test/features/ocr/mlkit_element_word_test.dart 11 例。 四处注入缺陷逐个实证会红:① 不剥首尾(原缺陷)→ 5 例;② 尾部改剥「非字母」→ 1 例; ③ 放宽 {2,} → 1 例;④ 去掉最终校验 → 4 例。

真机验证(2026-08-28,24094RAD4C):拿第一次 relay 失败的那张图cave 落在 He leads excavations at Wonderwerk Cave. 句末)重跑,同一张图:

修复前  OCR 原始单词 (73)  …at, wonderwerk, chazan…        ← Cave. 整词消失
修复后  OCR 原始单词 (80)  …at, wonderwerk, cave, chazan…  ← 回来了

73 + 7 = 80 逐个对上:old / canada / cave / one / itself / ago / explores —— 正是先前被吞的那 7 个(6 个句末词 + 副标题里的 Explores)。 cave 随后正常出现在收割站首屏 B1 组。验完即 Discard,未收割。


✅ 已关闭 · P1 — OCR 语境句入 word_cloze_contexts(2026-08-15 立项 → 08-17 RB 批准 → 08-26 落地

2026-08-26 闭环:代码已落地(ocr_cloze_gate.dart / _saveOcrClozeContexts / cloze_pool_writer.dart), C1–C4 逐条对账 + 47 例回归(三处注入缺陷验证会红)。落地记录见 scan-ocr-positioning-enhancement-plan.md Task H。

剩两件事,都不是代码

  1. RB 回执 §7 的三步跨端真机实测(需双端设备 + 同一账号)。其中第 1 步同时验 C3 —— 若时区写错,被淘汰的会恒定是 RB 那条。
  2. 等 RB 对回执 doc 35 §2.1 的回应 (闸门「主列」一条已默认关闭是否被接受)→ 见上方 P3 条目。

以下保留立项时的分析,供追溯。

触发条件随时可做,无前置阻塞(已完成)。上位计划 scan-ocr-positioning-enhancement-plan.md Task H。

为什么值得做

RVH 复习卡目前是双范式分裂:RB 采集的词有语境范式(原句 carousel + 挖空 + sense_gloss 联动义项), OCR 采集的词只有词典范式(策展例句/裸词)。差异化缺口只在背面这一处 —— 卡面正面早已全屏渲染 拍摄原图 + 词位高亮 + 聚光灯,OCR 词的体验并不弱。补上语境句即与 RB 采集词平权。

状态:许可已拿到,别再当成「待评审」

  • 请求 docs/cross-end/22
  • 回执 ~/reading-browser/docs/cross-end/23-rb-ocr-cloze-context-decision.md(2026-08-17,批准方案 A 双端共写,不退 D
  • 红线 #5g 已于 2026-08-26 改写为双端共写,四条硬条件全文在那里

⚠️ 这条裁决在 RB 仓库里躺了 9 天没人接 —— RVH 侧的计划 / doc 22 / 红线 #5g / backlog 四处 全停在「等裁决」。别再重新发起一次评审。

方案(已按真机实测定稿,不是「整页切句」)

取语境 = 直接读 ocr_word_positions.line_text(该列一直存在且 NOT NULL,收割时已随词位写入)。 实测 49/49 目标词字面出现在自己的 line_text 里;三规则闸门后 39/49 = 80% 通过。 不需要整页 reading order 重建 —— 弯曲书页上它本就不可靠。

🔒 四条硬条件(缺一退方案 D「不采集」,不是「先上线再补」)

条件违反后果
C1sentence 整词含 surfacesurface 存该行原始形(不用拼写纠错后的),写前断言该行占掉封顶 5 的坑位却在 RB 侧完全不可见,还挤掉一条可用的 RB 句
C2每 word 每次收割最多 1 条同页重复词最坏吃掉 5 个坑位,清空该词历史语境
C3created_atnowUtc(),禁裸 DateTime.now()UTC+8 下 RVH 的行恒显晚 8 小时 → 「留新删旧」退化成「留 RVH 删 RB」,无任何报错可观测
C4闸门必须有纯函数单测RB pull 侧无条件 INSERT、不校验质量,这道闸是全系统唯一防线

三规则闸门:length ≥ 25line_x 落正文主列 ∧ 行末非连字符(第三条是刚性需求 —— C1 的断言挡不住断词行,断出的 ball 确实在句里)。source_url 留 NULL,🚫 禁止合成 URL。

落地后

按回执 §7 做三步跨端实测(含「被淘汰的必须是真正最旧那条」= 验 C3),回写 doc 22。


✅ 已关闭 · P2 — pull 侧 synced_at 未与 deleted_at 取 MAX(2026-08-27 立项 → 当日 RB 出契约 doc 38 → RVH 落地 doc 39

来源:复核 RB doc 34 时顺带发现(回执 docs/cross-end/36-rvh-tombstone-parent-confirmation.md §7.1)。

是什么:RB 红线 #6d 要求「pull 墓碑分支写 synced_at = MAX(远端 updated_at, deleted_at)」, RB 侧 10 处已全改。RVH 侧没有对称实现

6 个 _pullXxx 墓碑分支逐条核对(2026-08-27 复核订正 —— 初稿写「两处」并把 cloze 列为风险项, 两处都不对):

行号写的 synced_at判定
reading_notes600now(= syncStartAt,本端时钟)⚠️ 暴露
reading_pages705now⚠️ 暴露
learning_entries837now⚠️ 暴露
word_page_links1028now⚠️ 暴露
known_words1180now⚠️ 暴露
word_cloze_contexts1320远端 updated_at ?? created_at✅ 当前安全

6 个 push 的 dirty-check 全部带 deleted_at > synced_at 分支 ⇒ 6 张表都在射程内,实际暴露 5 张。 cloze 安全是因为它从远端行synced_at,而 RB clear_cloze_context_forSET deleted_at = ?3, updated_at = ?3(同值)⇒ 相等而非小于。这是未写进契约的耦合, RB 改动那条路径就会无声破功。

只要对端写的 deleted_at 在字典序上大于这个 synced_at(对端时钟快于本端 / 对端软删时 updated_at 早于 deleted_at),本端 push 脏检查 deleted_at > synced_at 就恒真 → 该行每轮 sync 重推 = 回声环。症状与 2026-08-03 RB 实测那 57 行完全同形:不报错,只静默烧配额

为什么本轮没顺手改

  1. 超出 doc 34 的范围(那单只裁定写侧子树 + 读侧父行三态)
  2. synced_at 语义应当两端一起对齐,单端改容易造出新的不对称
  3. RVH 写侧本轮已补齐 updated_at bump(回执 §2),源头那一侧已经堵上 —— 剩下的是 「对端时钟快于本端」这条理论路径,未观测到实例

已关闭:RB 当日出契约单 doc 38(把 #6d 从实现细节升格为双端契约,拆成 W / P1 / P2 / P3 四条), RVH 按 §7 六项待办全部落地,回执 docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md

落地结果:6 处墓碑分支走 MAX(?, ?)(比交接单要求多一处 —— cloze 的 P2 也补了)· P1 收成单点 _remoteSyncTs · _markSynced 落 P3 · 规则 W 全仓 11 处核对本就合规。 条文进 RVH 红线 #6g存量确认自愈,未做 backfill 或 migration。

⚠️ 剩一项可选的跨端真机复验:双端设备 + 故意把一端时钟调慢几分钟,观察是否还有行每轮重推。 本缺陷在时钟正常时完全不显形,所以这一步真要做就必须动时钟。


✅ 已关闭 · P1 — OCR 模糊匹配把 v26 修好的词又改错回去(2026-08-24 立项 → 08-25 方案 A → 08-26 round30 收另一半并真机验证

2026-08-26 闭环(另一半):RB round30 把 alway / rath 等 53 个死词形从预装库删掉, RVH 同轮落 prune 导入逻辑(见下方已关闭的 P2 条目)。

真机实测(Redmi 24094RAD4C):造一张文字铺满的图,走 ACTION_SEND + image/jpeg 喂进 app 跑完整 OCR 管线 —— 模糊匹配确实是开着的(本次实修 5 词:not→notefor→fore would→world shall→shallow opti→optic),而:

✅ 通过单词 (13): world, rather, always, wait, fore, the, latter,
                 shallow, note, deceive, during, outer, optic
   📊 A1: world, always, during   ← always 原样通过,未变 alway
   📊 A2: rather, latter          ← rather 未变 rath;latter 未变 letter

机制闭合:always 现在在 vocabulary 里 → matchBatch 第一步 if (dictionaryWords.contains(lowerWord)) continue; 直接跳过、不进纠错; 即便进了,alway 已不在候选集(候选只从 dictionary.contains(candidate) 产生)也生不出来。

⚠️ 门控测试里「OCR 路径仍会纠错」的断言依旧成立、不该改 —— 变的是候选词表里没有 alway 了,不是门控行为变了。

关联doc 32 §7.1(真机实测记录 + 两个环境坑:READ_MEDIA_IMAGES 未授权导致图片分享静默失败、留白图 ML Kit 读不出字)。

2026-08-25 更新:已按方案 A 修复并真机验证。applyDifficultyFilterenableOcrFuzzyMatch(默认 true),文本输入路径传 false。真机复现日志:

⏭️  跳过模糊匹配: 非 OCR 来源,10 个未收录词保持原样
❓ UNKNOWN 保留 (10): would, rather, always, the, latter, and, outer, pants, be, during

修复前同一句话里 always→alwaylatter→letterwould→world;现在全部保持原样。 回归 test/features/vocabulary_filtering/data/ocr_fuzzy_match_gating_test.dart(3 例, 已用注入缺陷验证有牙:门控改恒 false 后测试失败并打印 {'always': 'alway', 'latter': 'letter'})。commit 95d6d82

⚠️ 当时只修了一半:OCR 拍照路径仍会 always→alway —— 那对 OCR 场景是设计内行为 (相机确实可能把 alway 识成 always)。根治那一半要靠下次整库 reseed prune 掉死 headword + 补上正确词形词条(见 doc 25 §10 第 1 条,已记为知情取舍)。测试里对这条有显式断言 + 注释,避免后人误以为「OCR 路径也该关掉」。

📌 那次 reseed 就是 2026-08-26 的 round30(见本条目顶部)。注意:门控测试里 「OCR 路径仍会纠错」的断言依旧成立且不该改 —— 变的是候选词表里没有 alway 了, 不是门控行为变了。


立项时的原始分析(2026-08-24,点开看根因链与影响面测算)

现象(真机,v26 装机后第一次分享测试就撞上)

分享 "…he always tries harder than the latter group…",日志:

🔍 模糊匹配: 对 8 个未找到词尝试修正...
📊 8 unknown → 0 混淆表 + 0 前缀补全 + 3 编辑距离 = 3 修正成功
   ✅ "latter" → "letter" (editDistance, 歧义候选: [letter, litter])
   查询 3 词: world, alway, letter → Found 3 matches

always 被"修正"成 alway —— 正是 v26 刚删掉的那个古语死词条(A1、/ˈɔːl.weɪ/)。 latter 被改成 letter(完全不相干的词),would 被改成 world

根因链(三个各自合理的设计叠出来的)

  1. v26 删掉了 always→alway 错映射 ✅ 归一现在正确返回 always
  2. 但预装库 vocabulary 只重烤了 lemma_* 表、没动 vocabulary(v26 知情取舍): alway 作为死 headword 仍在,而 always 没有本地词条
  3. 于是 always 成了"词典查不到的词" → 落进 OcrFuzzyMatcher → 编辑距离 1 → 命中还躺在库里的 alway → "修正成功"

v26 的取舍文档只写了「查询走 Supabase 缓冲池兜底」,漏了这一步:缓冲池也没有时, OCR 纠错器会接手,并且专挑那些刚被判死刑的旧词条下手(它们和正确词形天然编辑距离 1)。

影响面(脚本精确复刻 _tryEditDistance 全部守卫算出,非估算)

SELF 组 89 个词里 36 个会被改写(守卫:长度>4、长度差≤1、首字符必须相同、距离=1):

  • 【A】27 个改回 v26 刚删掉的死词条 —— 修复被完全抵消: always→alway · clothes→clothe · sometimes→sometime · outskirts→outskirt · upstairs→upstair · overseas→oversea · outdated→outdate · crossroads→crossroad · grassroots→grassroot · congratulations→congratulation · emirates→emirate · philippines→philippine · wales→wale · laden→lade · sears→sear
  • 【B】9 个改成另一个不相干的词 —— v26 之前没有的新错: latter→letter · outer→otter · inner→infer · pants→panty · genus→genius · overlay→overlap · vegas→vegan · jesus→jess · ellis→elvis

rather 侥幸躲过rath 距离 2,而 father/gather 距离 1 但被首字符守卫挡掉。 即本次修复的旗舰用例是靠守卫巧合而非设计保住的。

严重度:内容错,PK 不错(比原 P2 轻一档)

filter_confirmation_state.dart:274 建的是 ConfirmationWord(word: word, …) —— 保留原词correctedTo 只作 UI 提示。所以入库的仍是 always(跨端 PK 正确、不破坏红线 #5e/#9), 但挂在它身上的释义/音标/CEFR 是 alway("Alternative form of always… archaic")。 【B】组更明显:latter 显示 letter 的释义。

⚠️ 「入库的是原词」这条是读代码得出的,尚未真机存一条验证。定案前先手动存一次 always 看 learning_entries。

三个候选修法(需产品决策)

方案做法评价
A(推荐,最小)模糊匹配只在 OCR 来源启用,文本分享 / 电子书导入路径跳过分享的文本根本没有 OCR 噪声,用 OCR 纠错器本就是错配。日志里 📚 Text input mode (ebook import) 与模糊匹配同时出现即是明证。改动小、无副作用
B把 91 个死 headword 从模糊匹配的候选词典里排除治【A】组不治【B】组(latter→letter 与死词条无关),且要维护一份死词表
C(根治)下次整库 reseed 时 prune 死 headword + 补正确词条已在 backlog,届时【A】组自然消失。但【B】组仍在,且 reseed 无明确时间表

A 治「分享文本」这条路径(本次实测的场景),C 治 OCR 拍照路径。两者不互斥,建议 A 先落、C 随 reseed。 单独做 B 性价比最低。

复现

bash
adb shell "am start -n com.lampio.app/.MainActivity -a android.intent.action.SEND \
  -t text/plain --es android.intent.extra.TEXT 'he always tries harder than the latter group'"
# 看 logs/run_android_*.log 里的「模糊匹配」段

关联:v26 接收见 docs/cross-end/25-rvh-lemmatizer-v26-confirmation.md; 死 headword 取舍见该文 §10 第 1 条;匹配器实现 lib/core/nlp/ocr_fuzzy_matcher.dart::_tryEditDistance


✅ 已关闭 · P2 — round30 新补的词缺 per-POS cefr,9 个真词在拍照识词里被静默丢掉(2026-08-26 真机实测发现 → 当日 RB 修完并接收

2026-08-26 闭环:上报后 RB 当轮修完(doc 33 §4),随预装库 v29 一起接收。 212 个 POS 块全部补齐(取值 = 该行 primary_cefr_level),非短语词全 POS 齐全率 98.7% → 100%(本端复验:非短语词 12,306 个,缺口 0;剩余 6,607 个缺口全在短语库, 设计上就无 CEFR)。点名的 9 个受损可学词现已全部带 per-POS cefr。

根因(RB 查明,100% 是 round30 自己造成的):不按词的属性分 —— 按哪条载荷写进去的分。 _applyFoldname 块时代码里压根没写 cefr 键(157/157 全缺)+ _applyAdd 从素材直灌 不补(55/168)= 212,与全库缺口总数一个不多。这也解释了我们为什么用 cefr_source 和 POS 键都切不开。RB 侧已加「缺口数只许降不许升」的机械守卫 + 注入式反向验证。

🔴 顺带纠正本条目原先的一个判断:下方「RVH 自己的设计债」那段把 primary_cefr_level 与 per-POS cefr 当成「两个真相源、需要二选一」—— 这个前提是错的。per-POS cefrcefr_source='oxford' 的词是权威且本就允许与词级值 不同(Oxford 5000 给的就是分词性 CEFR:above.adj=B1above.prep=A1)。词级值是 「这个词大致什么档」,per-POS 是「这个词性什么档」,二者是不同问题。RB 仓 _archive/backfill_oxford_pos_cefr.dart 记着一次把 per-POS 拍平成 top-level 的事故 (~6,271 个多词性词丢了分词性区分)——合并它们是有先例的破坏性操作

⇒ 真正待定的只剩「谁在什么场景读哪个」这个约定(拍照管线读 per-POS、快速分级/直方图读词级), 以及「per-POS 缺失时是否回落到词级」。已降级为下方的 P3 记录,不再是 P2。

关联doc 33 §4(RB 交接,含根因与修法)。

一句话

ourselvesprimary_cefr_levelA2,但 pos_definitions"pron"没有 cefr;而拍照管线的难度筛选读的正是 per-POS cefr → 判成 UNKNOWN → 被筛掉。

怎么发现的

round30 接收后的真机 OCR 复验,日志里多出一行:

🚫 CEFR 不匹配 (-1): ourselves(UNKNOWN)

单测、SHA 校验、行数核对全都发现不了这个 —— 数据结构合法、词条也确实在库里。

量化(非短语词,按「该词每个 POS 是否都带 cefr」统计)

集合全 POS 都有部分缺全缺
存量词(12,226)98.7%1.3%0.03%(4 个,全专名)
round30 新增 80 词46.2%22.5%31.2%(25 个)

98.7% vs 46.2% ⇒ 新增行是由一条基本不写 per-POS cefr 的路径产出的,不是零星漏。 已排除两个假设:不是按 cefr_source 分(两组都含 freq 的 C1/C2),也不是按 POS 键分 (('name','noun')('noun',) 在两组都出现)。具体哪一步要 RB 看 pipeline 代码。

25 个全缺的里 16 个是专名(被筛掉反而符合专名治理意图,不算问题), 真正受损的是 9 个正经可学词

ourselves(A2)  measles(C2)  confetti(C2)  impending(C2)  insignia(C2)
undersigned(C2)  unqualified(C2)  bioethics(C2)  cheerleading(C2)

ourselves 尤其讽刺 —— 它正是本轮为补 lemmatizer 缺口而加的词。加完之后「查词查得到」✅ 但「拍照会被推荐」❌,只修好一半。

归属与修法

RB 侧修:预装库是 RB pipeline 单点产出(红线 #10),RVH 就地改会破坏 byte-equal。 已在 doc 32 §5.2 提请 —— 给新增词补上 per-POS cefr,取值与 primary_cefr_level 对齐即可。不必为此单开一轮,搭下一轮车。

🔶 P3 遗留 · 「谁在什么场景读哪个」还没有约定(独立于 RB,RB 补完数据后仍在)

⚠️ 本段原文把二者当「两个真相源、需统一成一个」,该前提已被上面的闭环纠正 —— 它们本就允许不同,不该合并。留下的问题只是读取约定

primary_cefr_level 列与 pos_definitions[].cefr 目前各有各的消费方,没有成文约定:

  • 拍照识词管线(applyDifficultyFilter)→ 用 per-POS
  • 快速分级 / 难度直方图 / getTriageCandidates → 用 primary_cefr_level

待定的两点:① 各消费方该读哪个(拍照场景有 POS 上下文,读 per-POS 是对的; 直方图/分级是词级视角,读词级也是对的 —— 可能根本不需要统一,只需写下来); ② per-POS 缺失时是否回落到词级值。②是本次缺陷之所以表现为「静默丢词」的直接原因 —— 缺 per-POS 时 applyDifficultyFilter 判 UNKNOWN 并丢弃,而不是回落到词级的 A2。 RB 已把数据补齐,但回落缺失本身仍是脆弱点:下次再出现缺口,症状还是静默丢词。


✅ 已关闭 · P2 — 预装库 prune 走不通:删掉的词条在存量安装上永远留着(2026-08-25 立项 → 2026-08-26 round30 落 A

2026-08-26 闭环:RB 产出 round30 prune 过的预装库(vocabulary 18898→18925, 删 53 行含 alway/rath/trie/ye + 7 个露骨词),RVH 同轮落方案 A —— 导入逻辑加删除步骤,_preinstalledVocabVersion 27→28。

产品决策(用户定):53 个被删词一律软删用户生词本条目,不做词形迁移 (RB 31-deleted-word-migration.tsv 给的 38 个迁移目标未采用)—— 迁移会改同步表的 自然键 UNIQUE(user_id, word),且当时内测数据已全清、无真实条目可迁。

🔴 实施中翻案的一个前提:立项时(含本条目下方原文与选项设计)默认「软删用户行 → 就能删词条」。这是错的 —— 软删并不释放 FK:软删只是给行打 deleted_at,行还在、 word 列仍指向被删词条,ON DELETE RESTRICT 照样挡住 DELETE FROM vocabulary。 故实现拆成两阶段(没有PRAGMA foreign_keys=OFF 绕,那会留下悬空 FK): 阶段一写墓碑 + 记待办;阶段二在墓碑确认已 push 后硬删残行 + 删词条。 见 app_database.dart::prunePreinstalledVocabulary / retryPendingVocabPrune, 回归测试 test/shared/database/preinstalled_prune_test.dart(8 例,两处注入式反向验证)。

残留风险(未做,已知情):pull 路径对缺失词条有 backfill 兜底 (sync_repository_impl.dart vocabExists==0getVocabularyAndPersist)。若 RB 端 仍留着 alway 的生词本条目并推上来,RVH 会把 alwaysource='backfilled' 回填复活, 而 OCR 纠错候选表是全表不分 source ⇒ P1 可能回潮。当前不可达(双端用户数据已全清、 两端字典都已无该词形,用户造不出这种条目),故本轮不建 blocklist;已在 doc 32 §5 提请 RB 侧清理自己的 learning_entries。若日后 P1 复现,先查这条。

关联:doc 31 ~/reading-browser/docs/cross-end/31-rb-round30-reseed-handoff.md(RB 交接,在 RB 仓)· doc 32(RVH 回执)。

立项时的原始分析(保留存档;注意上面「软删不释放 FK」那条订正)

一句话

预装库导入是只增不减的。下次整库 reseed 若把死词条(alway/rath/lat/ye/opus… 共 91 个)从新库里拿掉,存量安装照样留着它们 —— 光 bump _preinstalledVocabVersion 不生效。

机制(app_database.dart::_checkAndImportPreinstalledVocabulary

导入只做两件事:

sql
-- ① 唯一的 DELETE,但只清「缓冲池回填」的行,不碰预装行
DELETE FROM vocabulary
 WHERE source = 'backfilled' AND word NOT IN (SELECT word FROM learning_entries);

-- ② 然后纯 UPSERT
INSERT INTO vocabulary (...) ... ON CONFLICT(word) DO UPDATE SET ...

source = 'preinstalled' 的行没有任何删除路径。新库里消失的词,旧安装上原样保留。

什么时候会咬人

下次整库 reseed。doc 25 §10 记的知情取舍是「91 个死 headword + 缺失的正确词形, 下次整库 reseed 时自然收敛」—— 这句话在存量安装上不成立,只对全新安装成立。

而且这正是 P1(OCR 模糊匹配)另一半的根治手段:always 之所以被改写成 alway, 就是因为 alway 还躺在库里当编辑距离 1 的候选。prune 不生效 ⇒ 那一半也治不好。

两条出路

方案做法代价
A · 改导入逻辑加一步「删掉新库里已不存在的 preinstalled 行」⚠️ 要处理 FK(见下)。一次性改动,之后所有 reseed 受益
B · 删 app 重装内测期让用户重装零代码,但每次 prune 都要全员重装;且云端 user_* 里的行会 sync 拉回(2026-08-18 实测过这个坑),只清本地不够

⚠️ 方案 A 必须处理的 FK(已核实,别照抄 RB 的结论)

  • learning_entries.word REFERENCES vocabulary(word) **ON DELETE RESTRICT**01_create_tables.sql:264,v51 由 CASCADE 改来)
  • RVH 的 PRAGMA foreign_keys = ON 是真开的app_database.dart:342)—— 不同于 RB(其 rusqlite 通道从未开 FK,CASCADE 一次都没真正触发过)。所以这条 RESTRICT 会真的拒绝删除:用户生词本里存着 rath 时,删 vocabulary 的 rath 行直接失败。
  • 好消息:只有 learning_entries 一张表引用 vocabulary(全库 grep REFERENCES vocabulary 仅 1 处命中)。known_words / word_cloze_contexts / word_page_links 都没有该 FK, 不构成阻碍。

⇒ 方案 A 的删除步骤必须先决定「用户已收藏的死词怎么办」:跳过(留着,日志告警)/ 软删用户行再删词条 / 迁移到正确词形。这是产品决策,不是纯技术选择 —— 用户生词本里那条 rath 是他真的点过"加入生词本"的。

结论(2026-08-25 定):B 过渡 → RB 整库 reseed 时做 A

  • 现在~RB 整库 reseed 之前:走 B。内测期重装成本低,且这段时间本来就没有 「被 prune 掉的词」需要清 —— 见下方⚠️。
  • RB 真正跑整库 reseed 那一轮:落 A(改导入逻辑加删除步骤)。 选在那个时点不是拖延,而是那时产品决策的选项才第一次完整:reseed 后 always / rather 会拿到正经词条,「把用户的 rath 迁移到 rather」才第一次可行; 在此之前目标词不在本地 vocabulary,迁移必然撞 FK(v26 交接单 §9.2 已分析过)。
  • RB 侧已知会并对齐(doc 28 §7:「那条约束记下了,会在 RB 下次整库 reseed 时一并考虑」)。

⚠️ 别误以为「删 app + 清云端」就等于执行了 B

2026-08-25 内测用户删了 app 并跑了 reset-dev-data.sh --remote。那件事清的是用户数据learning_entries / known_words / 云端 user_*),与死词条无关

  • 91 个死 headword 在预装库资产本身里(assets/databases/lampio_dict.dbvocabulary 表,18,898 行至今一行未动)。实测 alway / rath / lat / ye / opus / bas / dure / trie 全部仍在。
  • 所以下次安装会把它们原样装回来。删 app 不会让它们消失。

B 只有在「RB 已经产出一个 prune 过的库」之后才有意义 —— 那时全新安装才会拿到 没有死词条的库。当前 RB 还没 prune,所以现在 B 是空转。

在 RB 产出 prune 过的库之前,本条目实质上无事可做;一旦那个库产出, 要么当轮就落 A,要么明确通知内测用户重装(B)。别在中间态误判为「已解决」。

对 P1 的连带影响(记清楚,别乐观)

P1 的 OCR 那一半至今未治,且短期不会好always 在 OCR 路径仍会被模糊匹配改写成 alway,因为 alway 还在库里当编辑距离 1 的候选。它要等的正是本条目的 A/B 落地。

关联doc 25 §10(知情取舍原文, 需按本条订正)· 上面 P1 条目「只修了一半」那段 · v26 bump 机制见 doc 25 §7。


✅ 已关闭 · P2 — lemmatizer 映射表:v26 撒网截断带漏了同类错映射(2026-08-25 复扫立项 → 当日 RB 修完并接收

2026-08-25 闭环:RB commit fd3bfa6 修完 4 条(全走 §2.5 force_as_base); overjoyed 的边界裁定 RB 判「修」(PARK 的判据是「两读都成立需 POS 上下文」,而 overjoyed 只有一读,且 overjoy 连 33 万词频表都不在)。RVH 侧:JSON fixture 同步 + _preinstalledVocabVersion 26→27 + 红线 #5e SHA 更新 + 回归 v27-A/v27-B; 跨端 phase0 两组输入 byte-identical;真机全新安装实测 uninteresting/unconcerned 保持原样。 资产 140,281→140,277 / 101,709→101,713。详见 doc 27 顶部回执。

立项时的复扫分析(点开看方法、阈值口径验算与完整 31 条截断带)

结论先说

v26 修表这条路是对的、也做得扎实 —— 用当时的撒网条件(surface 排名 ≤20000 ∧ base 排名 > surface×3)在修后资产上复扫,165 条命中里几乎全是正确映射 (people→person data→datum based→base women→woman media→mediumcriteria→criterion sat→sit…)+ 已知 PARK 的 bit→bite / ground→grind高频段没有漏网。

漏的是截断带:交接单 §10 自己写了「频率排名 > 30000 的低频错映射按危害排序截断了」。

漏网清单(4 条,与 v26 已修的 unbiased/outdated 是同一模式)

形容词被当成一个几乎不存在的动词/名词的屈折形,而那个 base 在预装库里还顶着正经 CEFR:

surface词频排名当前归一到那个 base 在预装库里
uninteresting#53115uninterestB1 名词「Lack of interest; indifference」
uninterested#71943uninterest同上
unconcerned#68910unconcernB2 名词「Freedom from worry…」
overjoyed#59425overjoyB1 动词(边界情况:overjoy 是真动词,overjoyed 可视作其过去分词;现代英语里几乎只作形容词。建议修,但优先级低于前三条)

对照 v26 已修的同类:unbiasedunbias(C2) · outdatedoutdate(C1) · unqualified · undersigned · unwilling。一模一样,只是掉在阈值下面。

uninteresting / uninterested 是学习者读得到的普通词,uninterest 顶着 B1 —— rath 陷阱的缩小版(危害同型,量级小一档)。

为什么 RVH 不能自己修

红线 #5e:资产由 RB build_dict.rs 单点产出(§2.4 corrections / §2.5 force_as_base), RVH 纯消费且双端 byte-equal。→ 交接单 docs/cross-end/27-rvh-lemmatizer-residual-scan-handoff.md

⚠️ 修表治不了上面那条 P1(两件事别混)

① 归一错   always --Layer1 表写错--> alway    → v26 修表,已根治 ✅
② 纠错器错 always --表已修对--> always
                  --本地 vocabulary 无此词条--> unknown
                  --OcrFuzzyMatcher 编辑距离1--> alway   ❌ 仍在

②里归一是对的,错在下游。表修到完美也不影响②。见上面 P1 条目。

给 RB 的阈值建议(已验算,别用错口径)

  • 不要用「base 是预装库词条」单条件定危害边界 —— 实测命中 21,249 条,人工过不完
  • ✅ 用「频率反转 ∧ base 是预装库词条」组合条件,把 surface 排名上限从 30000 放宽到 120000: 候选集 196 条(≤30000 时是 179 条,即放宽只多 17 条),人工逐条过一遍成本可接受
  • 再往下(排名 >120000)的 surface 基本不是现代英语常用词,可继续截断

关联:v26 见 docs/cross-end/25-rvh-lemmatizer-v26-confirmation.md; RB 交接单 doc 27(含完整 31 条截断带清单 + 可复现扫描脚本)。


✅ 已关闭 · P2 — lemmatizer 资产:高频功能词被映射成生僻/错误 base(2026-08-17 立项 → 2026-08-18 修复并接收

结论:RB 侧 build_dict.rs 修正层全量扫描后修了 178 个 surface 的归一结果 (REMAP 89 条截断残根→正确 lemma + SELF 89 条强制自映射),RVH 侧已整包接收资产 + bump _preinstalledVocabVersion 25→26 + 补回归用例,双端 byte-equal 复验通过。

  • 立项时的现象、约 130 词样本扫描表见 git 历史(本条目原文)
  • RB 交接单:~/reading-browser/docs/cross-end/24-rb-lemmatizer-asset-v26-reseed-handoff.md
  • RVH 回执:docs/cross-end/25-rvh-lemmatizer-v26-confirmation.md
  • 当前资产 SHA256 / 行数:CLAUDE.md 红线 #5e

与立项时预判不同的两点(值得记住):

  1. 立项时写「修复路径与 v18 完全一致:删除错映射,让 Layer 2 自映射返回原词」—— 该路径在这批词上不成立rather 不在 base_forms,删掉 Layer 1 映射后会落到 Layer 3,后缀规则 "er" 提议的 stem rath 恰在 base_forms 里、裁决通过 → 原样推回。 正解是两步:删映射 + 把该词补进 base_forms。故回归用例必须连 layer == 'base' 一起断言。
  2. 立项时写「修 patch_lemmatizer_assets.py」—— 该脚本早已坏掉(路径停在迁仓前 + 断言 「原串恰好出现 1 次」在 v18 应用后必然失败),且是与 build_dict.rs 并行的第二本账, 已由 RB 删除。修正层单一真相源 = build_dict.rs §2.4 / §2.5。

仍未做(不是遗漏,是知情取舍)

  • 存量脏数据清理:云端软删一次(SQL 见 RB supabase/sql/cleanup-lemma-v26-2026-08-18.sql), 之后设备复验✅ 2026-08-25 已由更彻底的动作取代,本条作废: 内测用户删了 app 并跑了 ~/reading-browser/scripts/reset-dev-data.sh --remote (10 张 user_* 表 + Storage 全清)。那份定向清理 SQL 及其 §2「18 个需人工判断的词」 都不用再执行了。⚠️ 但注意:清的是用户数据,与预装库里的死词条无关(见下条)。
  • 预装库死 headword + 缺失的正确词条always/rather 暂无本地词条、走 Supabase 缓冲池兜底):本轮只重烤 lemma_* 三表、vocabulary 未动(整库重跑需为 ~12k 词重新付费 LLM 翻译且 pipeline output/ 中间产物已丢失)。 ⚠️ 「下次整库 reseed 时自然收敛」这句话只对全新安装成立 —— RVH 导入只 UPSERT 不删 preinstalled 行,详见上方 P2「预装库 prune 走不通」。已于 2026-08-25 向 RB 提请 doc 29(含算好的候选清单)。
  • 7 条 POS 歧义映射bit→bite · ground→grind 等):两读都成立需上下文,Layer 1 是无 上下文查表 = 架构限制。真要治得引入 POS 标注层,属独立设计问题。

✅ P2 — RVH pull/push notebook_entries source_platform 标签错乱(2026-05-26 修复)

现象(修复前)

  1. pull:Supabase 上 'rb' source 行被 RVH pull 下来后,在 RVH 本地 source_platform 列全部标 'rvh'(应保留 'rb')
  2. push:本地标记 'rb' 的行(pull 来的跨端行)变 dirty 再 push 时,supabase 上 source_platform 会被覆盖成 'rvh'

根因

sync_repository_impl.dart

  • _pullNotebookEntries INSERT 列表漏写 source_platform → schema DEFAULT 'rvh' 兜底覆盖远端 'rb'
  • _pushBooks (line 235) + _pushNotebookEntries (line 315) 写死 'source_platform': 'rvh' → 覆盖跨端来源标签

修复

镜像 RB commit 75dd4a0(RB 修 pull)+ 补 RB 未修的对偶 push 写死 bug:

位置改动作用
_pushNotebookEntries:315'rvh'r['source_platform'] ?? 'rvh'push 时保留远端标签
_pullNotebookEntries:684 INSERTsource_platform 列 + r['source_platform'] ?? 'rvh'pull 时镜像 RB 75dd4a0
_pushBooks:235 (reading_notes)留 TODO 注释(本地表无此列)完整对称需 schema v53;场景较少先记账

Live 验证(2026-05-26)

  • P2-pull:用户在 RB 端添加 domain / kingdom 两个新词 → RVH auto-sync pull → 本地 source_platform='rb'
  • P2-push:因 P3 db_closed regression 阻断 sync,未完成 live 验证。代码逻辑镜像 P2-pull 反向,信件度高

影响

  • 跨端去重逻辑(双端整合 2-B-6)依赖此标签准确性
  • 老脏数据(fixup 已恢复 8 个 RB 词为 'rb')

关联

  • RB 端等价修复:commit 75dd4a0(只修 pull,没修 push 写死 "rb")
  • RVH 端修复 commit:见本次提交

✅ P3 — database_closed regression(2026-05-26 立项,2026-05-27 修复)

用户可见症状(修复前)

  • "我的"页面:Exception: Error getting settings: DatabaseException(error database_closed)
  • 复习页面:Failed to get group summaries: Exception: Error fetching book summaries: DatabaseException(error database_closed)
  • Sync 状态:「同步出错」

根因

lib/debug/debug_server.dart::_dbQueryopenReadOnlyDatabase(dbPath) + db.close() 处理每次 SQL 查询。

但 sqflite 的 singleInstance: true(默认)语义:

Subsequent calls to openDatabase with the same path will return the same instance, and will discard all other parameters such as callbacks for that invocation.

意思同一 dbPath 第二次 open 返回 app 已持有的 read-write Database instance(readOnly 标志被默默丢弃)。db.close() 关掉的是 app 全局共享的 handle → AppDatabase 缓存的 _database 引用变 stale → 后续所有 db.transaction 抛 database_closed

为什么之前没炸:MCP rvh_db_query 工具调用频率低时碰巧不命中后续 transaction;高强度诊断会话(昨天就大量用 db_query)必然触发。

修复(commit 待提交)

lib/debug/debug_server.dart::_dbQuery

  • 删除 openReadOnlyDatabase + db?.close()
  • 改为 await AppDatabase().database 拿 singleton 共享 instance
  • SQL 写操作的安全性由 validateAndRewrite 白名单保证(只允许 SELECT/EXPLAIN/PRAGMA/WITH)

Live 验证(2026-05-27 04:40)

步骤修复前预期实测
5x 密集 db_query关掉共享 db handle5 次都返回正确数据
1st periodic sync (60s)okok
2nd periodic sync (120s)db_closed failcomplete pushed=0 pulled=0
复习页面 UI报错"Today 23 to review" 正常加载 ✅

关联

  • 强烈怀疑 P1 75→4 漏推也是 P3 间接受害者(_markSynced + pushRows 在 db_closed 边界 silent fail)
  • RB 端等价工具 rb_db_query 若用相同 open/close 模式可能也有此问题(建议 RB 端 audit)


✅ 已落地(2026-05-26 本会话)

rvh-debug-mcp Phase F — rvh_supabase_query 工具

镜像 RB 端 Phase F~/reading-browser/docs/plans/rb-debug-mcp-plan.md Phase F)。

新增/修改:

  • 新建 lib/debug/supabase_proxy.dart(~130 行 Dart):PostgREST 代理,GET only,从 SupabaseConfig.url + SupabaseConfig.anonKey + Supabase.instance.client.auth.currentSession.accessToken 拿凭据;filter 用 key=op.value 逗号分隔语法;401 不 refresh 返回明确错误
  • 修改 lib/debug/debug_server.dart:加 import + GET /debug/supabase/query route + handler
  • 修改 rvh-debug-mcp/src/index.ts:加 rvh_supabase_query tool,描述完全镜像 RB 端措辞;version 0.1.0 → 0.2.0
  • 修改 rvh-debug-mcp/package.json:version → 0.2.0

设计要点(保留与 RB 端一致):

  • PostgREST GET only,无 raw SQL,无需 SQL 白名单
  • 复用 supabase_flutter SDK 已存的 access_token,不引入新凭据
  • RLS 保护,用户看到的就是 RLS 允许范围
  • 401 不自动 refresh(MVP),返回明确错误
  • limit 默认 100,最大 500

Live 验证

测试结果
Dart flutter analyze lib/debug/✅ 0 issues
MCP pnpm build✅ clean,dist/index.js 含 2 处 rvh_supabase_query
Live curl(adb forward → device:38745)✅ 返回 4 行 source_platform='rvh'(expenditure/enough/study/science)与 RB 端预测完全一致
端到端诊断✅ 3 端对账(RVH local 75 + Supabase 12 + RB local 12)定位到 RVH push 75→4 漏推

会话内已用此工具:用 curl 直调 /debug/supabase/query 端点(绕过 MCP server 层,等价 tool call)跑完了 RVH push 漏推根因诊断。MCP server 需 /mcp 重载后才能从 Claude 工具表调到。