主题
RVH Backlog
跨会话待办池。新会话起步时扫这里。
待办写这里,不要写进 CHANGELOG。 CHANGELOG 只记 as-built(做了什么、为什么这么做), 「未修 / 待定 / 需产品决策」的条目写进来 —— 否则会被埋在版本记录里,没人会去 CHANGELOG 找待办。CHANGELOG 侧只留一句话 + 指向本文件的链接。(2026-08-16 实际踩过:三项待办被 写进 CHANGELOG 的 Added 段落里。)
🔄 归档节律:条目做完标
## ✅后,攒够一批就移进backlog-archive.md(保留裁决推理,别删 —— CHANGELOG 只记 as-built, 不记「为什么否掉另一个方案」)。根目录旧
TODO.md(四象限法,2026-01-14 停更)已于 2026-08-17 全量迁入backlog-archive.md,19 条待办逐条核实后只有 4 条仍有效(已并入本文件)。🔀 跨端条目落点 = 「谁执行谁持有」(2026-08-30 定,根
docs/plans/archive/post-merge-repo-optimization-plan.mdT4-3): 一条跨端任务的正文只存一份,落在将来真正动手那一端的 backlog;另一端只留一行指针。 RVH 要做的(改 Dart / 跑flutter test/ 动 RVH schema)写这里; RB 要做的(改 Rust/TS、动 Supabase DDL、改预装库 pipeline)写根侧那份。 两端都要动的(契约类)落根侧 —— 契约的稳定归属本来就在根CLAUDE.md§4/§9。 ⚠️ 判据是执行端不是发现端:RVH 发现的 RB bug 写根侧,反之亦然。 两本账各记一半是本仓 2026-08-30 实测踩过的(三处重复,且证据更全的那份每次都在执行端)。
🔴 P2 — 预装词库的「数量口径」两仓都记错,待 RB 定口径后镜像(2026-08-28 立项,阻塞在对端)
卡在哪:预装库是 byte-equal 共享资产,红线 #10 下 RB 是单一产源、RVH 是纯消费方 ⇒ 口径由产源方定,RVH 等着镜像。不要在 RVH 侧自行改数字或自行定义 —— 2026-08-28 曾按「等 RVH 定口径」写过一版交接,方向反了,RB 已订正并接手。
实测事实(都可复现,RVH 侧已核)
bash
sqlite3 assets/databases/lampio_dict.db "SELECT COUNT(*) FROM vocabulary;" # 18916
# 分解(同一个 db)
无空格(单词) 12172 含空格(短语) 6744 # 12172+6744 = 18916 ✓
无 idiomaticity 标签 12306
canonical_surface 为空 12309
idiomaticity always/context/literal 2776 / 3421 / 413(合计 6610)
proper_noun 1055 · emoji 非空 505⚠️ 预装库里
word_tags是vocabulary表的一个 JSON 数组文本列,不是独立表 (WHERE word_tags LIKE '%idiomaticity:context%')。全库只有 4 张表:vocabulary+lemma_{surface_to_base,base_forms,meta}。
对不上的地方
| 声称 | 出处 | 实测 | 判定 |
|---|---|---|---|
| 「12291 单词」 | RVH CLAUDE.md「关键设计」段 + 「性能」段(两处) | 12172 / 12306 / 12309 | ❌ 三种口径都不是 12291 |
| 「12,291 条预装词汇」 | RB CLAUDE.md:336 | 总行数 18916 | ❌ 数错 + 把子集当总数标注;RB 已核实该行冻在 v10,而 RB backlog 的 v28 记录里就写着 18,916 |
idiomaticity 2776 / 3451 / 383 | 两仓 CLAUDE.md | 2776 / 3421 / 413 | ⚠️ 合计 6610 对得上,但 30 条从 context 挪到了 literal 没回写文档 |
proper_noun 1055 · emoji 505 | RVH CLAUDE.md | 同值 | ✅ |
🔴 绝不能把 12291 直接换成 18916 —— 那是总行数,是另一个量。
RVH 侧要做什么
等 RB 通知,然后按 RB 定下的口径改 RVH CLAUDE.md 那两处 + idiomaticity 三档数字。 改 idiomaticity 前先翻 docs/cross-end/14、15 与 RB 的 cross-end 编号总表, 确认那次 context→literal 的 retag 有没有交接文档,有的话在 CLAUDE.md 里带一句出处, 别只改数字。
⚠️ doc-consistency-check Step 6 对这些零覆盖
它只校验库总量。子计数(12291 / 三档 / proper_noun / emoji)一律不在判据内 —— 正则要求数字紧邻总量标签,12291 单词 因为「单」刻意不匹配(放宽会把散文里的 子集计数全卷进来,2026-08-28 已实测踩过一次误报)。 ⇒ 子计数的正确性只能靠上面那些 SQL 逐条对,别指望 pre-commit 变红。
若日后想把子计数也纳入检查:先想清楚误报面,并把新样本加进那份误报清单 (见 scripts/doc-consistency-check.sh Step 6 的注释)。
相关
- 代码侧另有一批不同的陈旧值
4953(本文件另一条 P3),与 8614/12291 不是同一批 - 已关闭的 P2「Step 6 是永久假绿」——本条是它清语料时暴露出来的下一层问题
🟡 P2 — next_review_date 存量裸串与哨兵字面量(2026-08-28 立项)
代码侧已修(红线 #6h),存量数据裁定「不动」:真实卡下次复习时自愈,哨兵行首次复习时自愈。 所以 RB 侧 M1/M3 会红一段时间,别因为它红得久就把闸门关掉。
开放问题(已交回 RB):两端要不要约定同一个哨兵字面量? 现状 RVH 写 1970-01-01T00:00:00.000Z、RB 写 1970-01-01T00:00:00+00:00, 字符串序里 RVH 恒大 ⇒ merge 的 next_review 维度上 RVH 恒胜。 今天无害(两边都是「立即到期 + 全默认值」,覆盖幂等), 但它无害的理由和它是对的不是一回事 —— 哪天某端给新词一个非零 interval, 这条就变成「RVH 的新词状态无条件覆盖 RB 的」。 统一字面量时才是顺带规整存量行的合适时机。
🟢 P3 — mastery 阶梯进黄金向量(2026-08-28 立项,等 RB 加键)
RVH 已在 doc 42 §3.4 表态同意消费。 落地顺序有硬依赖,不要抢跑:
- RB 在
sm2-golden-vectors.json加compute_mastery_level键 - RVH 把内联在
mastery_tracking_datasource.dartif 链里的阶梯抽成纯函数 + 消费向量 - 同一轮 RB 更新
learning-loop-verify.shL4 的正则提取器
第 2 步会让 L4 抓空(RB K3 说好的 SKIP)。RVH 先动就等于让 L4 无声地失去保障。
🟢 P3 — syncNow(ensureFresh:) 的登出 flush 路径没上真机(v63 遗留,2026-08-27 立项)
没验的是哪一条:v63 给 syncNow 加防重入(合流语义)时,顺带发现登出前的 flush 不能合流 —— 它之后紧接着 clearLearningDataForUser 清本地,合流到一轮可能已经 跑过 push 阶段的 sync ⇒ 用户最后的改动永久丢失,没有「下一拍」补。 故加了 ensureFresh: true 排队分支(auth_notifier.dart::signOut)。
🔑 这是防重入自己开的新暴露面,不是既有缺陷:没有守卫时 flush 必然自己跑 完整一轮,反而不会漏。所以它属于 v63 引入的风险,不是顺手加固。
为什么没验:改完它之后设备掉线(adb devices 空,重启 adb server 未恢复)。 v63 那轮真机验证跑的是加 ensureFresh 之前的构建,覆盖 autoSync 与 stopwords 的 行为,不含登出路径。
现有覆盖(够不够由验的人判断):
- 机制级:
auto_sync_trigger_test.dart「ensureFresh 必须排队而不是合流」—— 断言不合流 +pullCalls == 12(两轮)。注入「退回合流」会红。 - 调用点:同文件「登出 flush 必须传 ensureFresh」源码形状用例。 ⚠️ 补这条是因为注入验证时发现,把
ensureFresh: true删掉照样全绿 —— 机制有用例守、调用点没有。行为级用例够不到(要拉起真实 auth + signOut 全流程)。
真机验证怎么做(含代价,先看完再决定要不要做)
🔴 代价:必须真的登出一次 —— 随后 clearLearningDataForUser 会清掉本地数据, 需要重新登录并全量 pull 回来。不是只读观测。
怎么造出那个窗口(默认命中率只有 60s 里的 1–2s ≈ 2–3%,得掐点):
- 先摸清节拍:
last_sync_attempt_at的秒位是固定的(如 v63 实测的:07/:55), 查两轮就能推算下一轮起点。 - 在生词本删一个词(制造待推的墓碑),不要等到下一轮 sync 把它推走。
- 掐在下一轮 sync 起点的那一两秒内按登出。
判据(可观测、不用读日志):重新登录后,第 2 步删掉的词不该复活。 若 flush 漏推,本地已被清空而云端仍是活行 ⇒ 全量 pull 回来时那个词会重新出现。 词复活 = 漏推,这是最干净的黑盒断言。
辅助证据:日志里应出现 syncNow(ensureFresh): queueing behind in-flight run; 没出现说明没撞进窗口,本次验证不算数(不是「通过」—— 同 #6g 那条防假绿纪律)。
我的判断(供参考,不是结论):机制有单测、调用点有形状用例,可以不做。 真要做,建议攒到下次本来就要重新登录/换账号测跨端时顺手带上, 别为它单独付一次「清库 + 全量 pull」的成本。
⚫ 已结案(证据灭失·不复现) — RVH push 75→4 漏推根因调查(2026-05-26 立项 → 2026-08-31 结案)
🔚 结案结论(2026-08-31 调查)
一句话:立项时的 ground truth 样本两端都已不存在,当前 build 不复现; 且原条目两根支柱之一(「关键反推:UPSERT 命中证据」)推理不成立。 本条不再作为待办;它留下的唯一活的东西是一条结构性缺口(见下面「留下的真问题」) 和一条新查实的缺陷(另立条目:「pull 跳过的行会被 watermark 永久跳过」)。
A. 样本灭失(两端各自独立证实)
| 端 | 实测 | 判据 |
|---|---|---|
| RVH 本机 | 当前 lampio.db`` 是 **2026-08-26 13:41 首装时新建的**,库里唯一 user = <test-user-id-A>,``95d7104d-… 一行不剩 | dumpsys package com.lampio.app 的 firstInstallTime=2026-08-26 13:41:12 + app_metadata.initialized_at=2026-08-26T13:41:29 |
| Supabase | 95d7104d-… 在10 张同步表里全是 0 行;该账号本身也没了 | GET /auth/v1/admin/users/95d7104d-… → User not found;sync-tables.sql 的 user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE ⇒ 删账号即级联清空 |
⚠️ 所以原条目最后那条护栏(「不要清空 RVH 本地数据重跑」)在本次会话开始之前就已经失效—— 样本是 2026-08-26 重装设备 + 删旧账号时没的,不是这次调查动的。记在这里是为了下次别再照着它 以为样本还在。教训:把「唯一一份现场」留在一台会被重装的开发机 + 一个会被删的测试账号上, 等于没留 —— 真要留证据,当场 adb pull 一份 db 快照进 rvh/docs/ 之外的归档位,或把关键查询结果 抄进条目正文(这条目抄了聚合,没抄逐行,所以今天只能验聚合不能验逐行)。
B. 原条目的「关键反推」不成立(必须划掉,别再照它推理)
原文推理:那 8 个 RB-pulled 词走过 push 路径 → 按 RVH #5f 的 onConflict='user_id,word' UPSERT 应把 source_platform 覆盖成 'rvh' → 实测仍是 'rb' → 所以 push 请求根本没到 server。
错在第一步。查 May 当时的代码(git show 8399101d:…/sync_repository_impl.dart): _pullNotebookEntries 的四条分支全部写 synced_at = now —— 墓碑分支、merge 分支、 「Local is more advanced, just mark synced」分支、以及新行 INSERT;而 now 是 syncNow 开头算一次、整轮 push + pull 共用同一个字符串。 ⇒ 「一批行共享同一个 synced_at 到微秒」既是 push 的签名,也是 pull 的签名,二者无法区分。
那 8 行(economist/estimate/component/company @05-25 08:40:22、 constant/eventually/gamble/prohibition @05-26 10:13:11)正是 RVH pull 下来那一刻被标的, 之后再没变脏 ⇒ 从来没走过 push ⇒ Supabase 上仍是 'rb' 完全正常,什么也证明不了。
但另一根支柱仍然站得住:那 63 行是本地创建、server 上从来没有过的行,pull 不可能返回它们、 更不可能标它们;而 _markSynced 是 rvh/lib 内唯一还会成批写 synced_at=now 的路径 (2026-08-31 全仓核过:app_database.dart 的迁移不写、auth / notebook / known_words / reading_notes 的 datasource 都不写)。⇒ push 确实跑过、确实没抛异常、server 确实没收。 「silent success」这个方向本身没错,错的只是它的第二条支撑。
C. 统计口径订正:不是「5.3% 随机成功率」,是一刀切的时间分界
去掉那 8 个 pull 批次之后,9 个批次里真正是 push 的只有 7 个:
- ❌ 05-21 的 4 批:
18:47:57(27) +18:48:57(26) +18:53:57(8) +18:57:57(2) = 63 行全灭 - ✅ 05-25 的 3 批:
08:45:22(1) +08:50:22(2) +08:51:22(1) = 4 行全成
⇒ 05-21 全失败、05-25 之后全成功,中间没有交叉。而这段窗口里 sync_repository_impl.dart 只有 8399101d(05-22) 一次提交,且它一行 push 侧代码都没碰 (改的是 pull cursor / watermark / server_updated_at 迁移)。 ⇒ 修好它的不是代码。 剩下能解释「代码没变、行为却变了」的只剩环境侧 (账号 / Supabase 项目指向 / 服务端 DDL / 网络),而这三样今天全部无从取证—— 这正是本条只能结案、不能钉死根因的原因。
D. 当前 build 不复现(2026-08-31 双端逐词对账)
- RVH 本地
learning_entries:103 行(78 rvh + 19 rb,含 6 个墓碑),never_pushed=0 / dirty=0 / clean=103 - Supabase
user_learning_entries(同 user):104 行(78 rvh + 20 rb,含 6 个墓碑) - 逐词 diff:本地独有 0 行 —— 每一行本地行在云端都在,
source_platform与墓碑态逐个吻合 - 唯一的差是 remote-only 的
pellet(RB 创建),那是 pull 侧的问题 → 见新条目
E. 留下的真问题(结构性,是本条唯一值得接着做的东西)
push 的「成功」判据到今天仍然是**「await upsert 没抛异常」**,三处叠加:
supabase_sync_datasource.dart::pushRows丢掉响应,无条件return rows.length;syncNow的 push 循环把任何 push 异常降级成AppLogger.w('Push error (continuing)');_markSynced无条件跟在pushRows后面执行。
⇒ 只要发生一次静默丢弃,那批行立刻永久 clean,再也不会被重推,而且 没有任何断言会红、没有任何计数会对不上 —— 这正是 2026-05 那次三个月没人发现的原因, 也是它今天无法复现的原因(现场自己把自己抹平了)。
建议做法(不在本条做,要做另立一轮):把「服务端真的收了」变成可断言的 —— pushRows 带 .select('id') 或 Prefer: count=exact 回读实际影响行数,与提交行数不等就抛; _markSynced 只在这个断言通过后才执行。判据照旧是那句:「它错了的话,什么会红?」
现象(ground truth 已通过双端工具对账 · 2026-05-26 记述,样本已灭失)
- RVH 本地
notebook_entries:75 行(user=95d7104d-2e8d-4cdd-99a0-e7efabc75412,deleted_at IS NULL),全部source_platform='rvh' - Supabase
user_notebook_entries:同 user 12 行 = 8 'rb' + 4 'rvh' - RB 本地:12 行(与 Supabase 完全一致)
- 缺口:RVH 创建的本地词应该出现在 Supabase 但没有 = 63 词(不是表面的 71;扣掉 8 个 RB 创建但 RVH pull 时被错标 'rvh' 的)
synced_at 状态分布(关键证据)
sql
SELECT
SUM(CASE WHEN synced_at IS NULL THEN 1 ELSE 0 END) AS never_pushed, -- = 0
SUM(CASE WHEN updated_at > synced_at THEN 1 ELSE 0 END) AS dirty, -- = 0
SUM(CASE WHEN updated_at <= synced_at THEN 1 ELSE 0 END) AS clean -- = 75
FROM notebook_entries WHERE user_id = '...' AND deleted_at IS NULL;对应判断:clean=75 → push 自报成功并写入 synced_at,但 Supabase 实际只收 4 条(成功率 5.3%)。
📥 根侧重复条目已于 2026-08-30 合并到这里(T4-3「谁执行谁持有」)。 根
docs/plans/backlog.md曾另有一条「RVH push learning_entries 95% 失败根因调查」, 内容是本条的更早、更粗版本(写「71 条没推上去」,未扣掉那 8 个被错标 'rvh' 的 RB 行; 三个never_pushed / dirty / clean分支还都当作待判,而本条已用实测定死在clean=75那支)。 从它那里唯一值得带过来的一句:RB 端 sync 路径已验证正常 (8 条 push + 12 条 pull 全部到位),联调经过见docs/plans/archive/rb-debug-mcp-plan.md§Phase F「Live 验证」。 ⚠️ 该表当时叫notebook_entries,2026-07 全端改名为learning_entries(本条正文未改,是写作当时的记述)。
synced_at 9 个批次聚合
2026-05-26T10:13:11.838769Z → 4 行 (constant/eventually/gamble/prohibition — 全是 RB 创建 RVH pull 下来的,但 RVH 又把它们 push 上去 → supabase 应该被 UPSERT 改 source_platform,但实测仍 'rb' → push 没真发到 server)
2026-05-25T08:51:22.393541Z → 1 行 (science ← Supabase 收到 ✅)
2026-05-25T08:50:22.383428Z → 2 行 (enough, study ← Supabase 收到 ✅)
2026-05-25T08:45:22.388074Z → 1 行 (expenditure ← Supabase 收到 ✅)
2026-05-25T08:40:22.621704Z → 4 行 (RB 创建 RVH pull 下来的 economist/estimate/component/company,同上)
2026-05-21T18:57:57.410384Z → 2 行 (本地创建,supabase 没有)
2026-05-21T18:53:57.410631Z → 8 行 (本地创建,supabase 没有)
2026-05-21T18:48:57.836370Z → 26 行 (本地创建,supabase 没有)
2026-05-21T18:47:57.541029Z → 27 行 (本地创建,supabase 没有)4 个成功 push 集中在 05-25 08:45-08:51 这 6 分钟窗口;其他 65 行(4 + 2 + 8 + 26 + 27 - 算法误差)都 push 自报成功但 server 没收。
关键反推:UPSERT 命中证据 ⛔ 2026-08-31 判定推理不成立,见上面「结案结论 B」
RVH 本地 8 个 RB-pulled 词(economist/estimate/component/company/constant/eventually/prohibition/gamble)被 RVH 本地标记 dirty 后 push 路径走过——按红线 #5f onConflict='user_id,word',UPSERT 命中 supabase 已有的 'rb' 行应当把 source_platform 覆盖为 'rvh'。实测 supabase 上这 8 行仍是 'rb',证明 RVH push 的请求根本没到 Supabase(或者发了但被吞)。
代码层面可疑点(已 grep,待新会话深查)
1. pushRows() 不检查 upsert 返回值,无 try/catch
lib/features/sync/data/datasources/supabase_sync_datasource.dart:28-47:
dart
Future<int> pushRows(String table, List<Map<String, dynamic>> rows, {String? onConflict}) async {
if (rows.isEmpty) return 0;
if (onConflict != null) {
await _client.from(table).upsert(rows, onConflict: onConflict); // 没接返回值
} else {
await _client.from(table).upsert(rows);
}
AppLogger.d('Sync', 'Push $table: ${rows.length} rows synced ...');
return rows.length; // ← 总是返回提交的 rows.length,不反映 server 实际写入
}supabase_flutter 的 upsert() 默认 Prefer: return=minimal,server 返回 204 No Content。若 server 静默拒绝(RLS USING denial / NOT NULL violation 部分行)SDK 不一定 throw。
2. 外层 catch 吞 Push 错误为 warn
sync_repository_impl.dart:111-124:
dart
for (final pushFn in [..., () => _pushNotebookEntries(db, now), ...]) {
try {
totalPushed += await pushFn();
} catch (e) {
AppLogger.w('Sync', 'Push error (continuing): $e'); // ← warn level 吞掉
}
}即便 pushRows() 抛 PostgrestException,外层只 warn-log 然后跑下一个 push。但 _markSynced 在 catch 内 reached 不到——实测 75 行都 clean 意味着 _markSynced 跑了,说明异常根本没抛,是 silent success 路径。
3. _pushNotebookEntries SELECT 不带 user_id 过滤
sync_repository_impl.dart:291-297:
dart
SELECT * FROM notebook_entries
WHERE synced_at IS NULL
OR updated_at > synced_at
OR (deleted_at IS NOT NULL AND (synced_at IS NULL OR deleted_at > synced_at))
LIMIT $_batchLimit如果跨账号本地存在他人 row(v46 后单设备单用户应该不存在但理论上可能),会被一起 push。当前 75 行都同 user_id,不是这个问题。
调查方向(按优先级)
- 加 push 路径反馈检查:把
pushRows()改成显式.select()或读 PostgrestResponse status,confirm server 实际接收 - 加 PostgrestException 详细日志:在外层 catch 把 stack trace + table name + sample row dump 出来
- 逐批次复现:找 2026-05-21 那 27 行所在的 batch,手工调 supabase_flutter 的 upsert 看实际响应
- 怀疑 batch payload 大小:
_batchLimit看是多少;如果是 100,27 行 + 字段全的 batch 可能撞 PostgREST body 限制或某行 reject 致全 batch 拒 - 怀疑历史 schema drift:05-21 那批是不是 schema 还没对齐时建的?看那批 row 的
vocabulary_id/ 删字段是否存在
不要在这个 task 里做的事
- ❌ 不要修 RB 端 pull 漏 source_platform 列(独立 bug,见下条)
- ❌ 不要顺势改 watermark 推进逻辑(红线 #5d 刚验证完)
- ❌ 不要清空 RVH 本地数据重跑——会丢失这个 ground truth 样本
⛔ 2026-08-31:第三条已经没有对象了(样本 2026-08-26 随重装设备 + 删账号一起没的, 见「结案结论 A」)。前两条仍然有效,且第二条现在有了新的对象 —— 新条目「pull 跳过的行会被 watermark 永久跳过」正是要动 watermark 推进逻辑的那一类, 动它之前先把 RVH #5d 读完。
✅ 已完成 — pull 丢的行 watermark 会永久跳过(2026-08-31 查实 · 落地自愈 · 真机验证通过)
✅ 代码已落地 + 真机验证过(见本条「落地」与「真机验证」两段)。 验证当场抓到本次实现自己的两个缺陷(口径把云端墓碑算成缺口 · give-up 是死结), 均已修并补了回归测试;也订正了「4 条 cloze 真丢」那个错误结论。 ✅ P3 也过了:streak 到 40 触发周期性重试 → 全量重拉返回
pellet→ 修好的 backfill 成功 → 词条与它的word_page_links子行一起落地 →learning_entries从账本里消失(对上了即清账)。 收尾对账:learning_entries100/100、word_page_links由缺 2 → 缺 1、 「本地独有」六张仍全 0。本条结案。剩下的 2 行是已知非缺陷:存量孤儿
dd052755· 本地墓碑撞云端活行3a50ad2b(后者另立 P2 条目)。它们会让这两张表的 streak 一直涨,按设计退到「20 轮试一次」, 每次重试失败后继续等 —— 这正是 give-up 上界要的行为。
必须 RVH 会话(改 rvh/lib/**,要跑 flutter analyze + flutter test,见根 CLAUDE.md §9)。
从上面那条「push 75→4」结案时顺手查出来的,但不是同一个 bug:那条在 push 侧且已不可复现, 这条在 pull 侧、当前 build、当前账号上就有一个活样本。 ⚠️ 也不是「RB 端 pull 漏 source_platform 列」那条 —— 那条丢的是列,这条丢的是整行。
现场(2026-08-31 实测)
pellet 一词:RB 创建于 2026-08-26T08:59:57,Supabase 上 server_updated_at=2026-08-26T09:00:55; RVH 的 watermark app_metadata['last_sync_at:<test-user-id-A>'] 已经是 2026-08-28T05:19:16 —— 早就越过它 2 天。而 RVH 本地 learning_entries 里没有这一行。
它不是「整批没拉到」:同一分钟窗口的邻居 RVH 全都在(archaeologist 08:59:25 · base on 09:00:00 · campus 09:00:07 · occasionally 09:01:32 · unclear · community · unlawful …), 唯独中间这一行缺。
判据补一刀:RVH 本地 vocabulary 里 SELECT COUNT(*) WHERE word='pellet' COLLATE NOCASE = 0。
规模(2026-08-31 六张共享表全量对账,同 user)
| 表 | 本地 | 云端 | 云端独有 | 本地独有 |
|---|---|---|---|---|
| learning_entries | 103 | 104 | 1 | 0 |
| reading_notes | 12 | 12 | 0 | 0 |
| reading_pages | 18 | 19 | 1 | 0 |
| word_page_links | 249 | 251 | 2 | 0 |
| word_cloze_contexts | 125 | 129 | 4 | 0 |
| known_words | 211 | 0 | — | — |
🔑 「本地独有」六张全 0 ⇒ push 侧当前完全健康,本条纯粹是 pull 侧的问题。
⚠️ known_words 云端 0 行是刻意设计,不是缺陷 —— LocalKnownWordsDataSource .mergeStopwordsIntoKnown 把预装的 211 个功能词 merge 进来时写 synced_at=now, 「让未被用户触碰的行不会 push 到 Supabase」(类注释原文)。所以这 211 行 born-clean、 永不进 push,是对的。下次做对账的人别把这一格当 bug(本轮差点误报)。
⚠️ 上一版这里的「8 行 / 3 起事故」链条是错的,已订正(2026-08-31 当天)
写那段时 reading_pages 的远端查询静默失败了一次(curl 抖动,被当成了结论), 于是少数了一行,并据此推出「reading_pages 5fce68dc 没落地 → 带走 2 条 links」。 实际 5fce68dc 就在本地、活得好好的。 这正是 sync_reconcile.sh 后来补上重试 + 把「查询失败」与「本地独有」分成两个退出码的原因 —— 一次失败的查询若被当成 「云端没有」,结论会整个反过来。
订正后的账(逐条查实):
| 云端独有 | 判定 | 依据 |
|---|---|---|
learning_entries pellet | 🔴 真丢 | sua 08-26T09:00:55 早于 watermark 08-28T05:19;本地 vocabulary 无 pellet |
word_page_links 1fb7d14c | 🔴 真丢(连带) | 父词条正是 pellet;三态守卫 skip —— 守卫行为是对的,根在上一行 |
word_cloze_contexts ×4 | 🔴 真丢,机制未定 | 见下 |
word_page_links dd052755 | ⚪ 不是本轮 bug | 父词条 04e20dd9 在云端也查不到([])= #6f 点名的「存量孤儿」,已裁决由人清理 |
reading_pages ×2 | ⚪ 不是丢 | sua 是 08-30 / 08-31,晚于 watermark,只是还没拉 |
⇒ 真丢 6 行,不是 8 行。
🔴 4 条 cloze 推翻了原定修法
silence×2 / cave×2,sua 08-27T13:31 与 21:08,都早于 watermark。但:
_pullWordClozeContexts的「本地无 + remote 活」分支是无条件INSERT OR IGNORE, 没有任何跳过分支(#6f 明写该表不加父行守卫)- 本地
word_cloze_contexts无 UNIQUE 索引(实测sqlite_master只有一个普通idx_cloze_word),所以不是OR IGNORE撞唯一键 - 本地也没有同句行(逐句
COLLATE NOCASE比过),所以不是「同内容不同 id」的良性重复 _reconcileClozePool是软删不是硬删,被它收掉的行会留下墓碑 —— 而这 4 个 id 本地压根不存在
⇒ 这 4 行根本没被 pull 返回过,机制至今未定。
🔑 这一条直接否掉了「记下被跳过的行 id,下轮按 id 重取」那个方案 —— 按原因记账要求枚举得全,而这里有一类丢失压根不经过任何已知跳过点。 改用「按结果对账」:只比两端最终行数,与丢失机制无关。落地见下。
机制(sync_repository_impl.dart::_pullLearningEntries)
远端行的 word 在本地 vocabulary 不存在时,先试三层回填 getVocabularyAndPersist; 回填抛异常或返回 null 时只留一条 AppLogger.w/d,然后 continue 跳过这一行。 而批次末尾:
dart
final batchMax = rows.last['server_updated_at'] as String?;
if (batchMax != null) { maxObserved = batchMax; cursor = batchMax; }无条件推进,不问本批次有没有行被跳过。⇒ 下一轮 pull 的 server_updated_at > since永远不会再返回这一行 ⇒ 永久丢失。
病形与 push 75→4 那条完全同科:一行静默消失 + 游标照常前进 + 没有任何断言会红。 差别只在这条证据还在。
为什么它能瞒住所有现有守卫
- RVH #5d 守的是「watermark 不能跑到没处理过的行前面」,而这里的行处理过了——只是处理结果是「扔掉」。 形式上不违反 #5d,语义上正是 #5d 要防的后果。
getSyncStatus的 pendingCount 只数本地脏行,云端多出来的行不在它口径里 ⇒ 永远显示同步完成。- 双端行数对账没有任何地方在跑(今天这次是手工 curl + sqlite3 比出来的)。
修法方向(2026-08-31 已裁定,选了第 4 条)
立项时列的三条都被上面那 4 条 cloze 否掉了 —— 它们都是「按原因记账」, 而那 4 行不经过任何已知跳过点。留档备查:
| 方案 | 为什么没选 |
|---|---|
| ① 本轮有跳过就不推进 watermark | 一个永远回填不了的词会把 cursor 钉死,后面所有行一起卡 —— 把「丢一行」换成「丢全部」 |
| ② pending-retry 表:跳过的行按 id 记账 | 要求枚举全所有跳过点;对那 4 条 cloze 无效。且加表要升 _schemaVersion,本仓开发期策略是直接重建不保留数据(rvh/CLAUDE.md §数据库管理策略)= 会清掉设备上的现场 |
| ③ 回填失败插 vocabulary 占位行 | 只治 pellet 那一类,同样盖不住 cloze;且要先确认 FK / UI 吃不吃占位行 |
| ④ 按结果对账 + 全量自愈 pull ✅ | 与丢失机制无关,一次覆盖全部三类;零 schema 改动(账本存 app_metadata,沿用 pending_vocab_prune 的既有形状)⇒ 不动设备数据 |
落地(2026-08-31)
机制:syncNow 收尾 _reconcileRowCounts 对 6 张同步表各要一次云端 count=exact(HEAD 请求,不下载行),与本地 WHERE user_id = ? 的行数比。 remote > local ⇒ 该表 streak +1,记进 app_metadata['pending_full_pull:<uid>']; streak 落在 [2, 5] 的表,下一轮 since 传 null 全量重拉一次(pull 本身 idempotent)。
四个判据各有理由,都带反向注入实证(test/features/sync/row_count_reconcile_test.dart,15 用例):
| 判据 | 为什么 | 注入后 |
|---|---|---|
| 下界 2(不是 1) | 对端在本轮 pull 之后写的行会造成一次无害差额,那行下一轮增量本来就会拉到 | 红 ✅ |
| 上界 5 | 全量 pull 也消不掉的差额(如那条存量孤儿)会让该表每轮全量重拉,永远 | 红 ✅ |
只认 remote > local | 反向是正常的未推送脏行;当成缺陷的话每加一个新词都触发全量 pull | 红 ✅ |
| 查询失败保留 streak | 当成「对上了」的话,一次网络抖动清零账本 ⇒ 抖动频繁的网络下自愈永不触发且无现象 | 红 ✅ |
🔒 没碰 watermark 推进逻辑(红线 RVH #5d 原样,立项时那条「不要顺势改 watermark」仍然遵守): 只改某张表这一轮的起点 since,全量 pull 的 maxObserved 仍是它自己那批的 MAX, 与增量同构;#5d 的五条守卫 grep 全部仍然成立。
判定逻辑抽成两个 @visibleForTesting 纯静态函数(tablesToHeal / nextMismatchStreak), 让测试咬生产代码本体而不是转录副本 —— 本仓测试惯例是转录 SQL,而转录副本会漂。
改动清单:sync_repository_impl.dart(对账 + per-table since)· supabase_sync_datasource.dart(countRows)· sync_metadata_keys.dart(账本 key)· auth_repository_impl.dart(用户切换时随 watermark 一起删账本)。
找回那 6 行
✅ 自愈落地后不用手动做 —— 行数对账会发现它们,连续 2 轮后自动对相关表全量重拉;
pellet会重新触发 vocabulary 三层回填。手动清 watermark 的老做法只在自愈也救不回来时 才需要(那说明遇到的是 give-up 那一类,得先去 Supabase 侧查)。
⚠️ 真机验证前先跑一次 bash rvh/scripts/sync_reconcile.sh 把当前对账数存下来, 否则「修没修好」没有比较基准。
🔬 真机验证(2026-08-31)—— 机制成立,但当场抓到我自己两个错
验证前先把判据写死(P1–P5 + 三条假绿排除法),再看现象。结果:
| 判据 | 结果 |
|---|---|
R2 库没被重建(initialized_at 仍是 2026-08-26T13:41:29) | ✅ 排除 |
| R3 watermark 一直非空(不是 force-full-pull 兜底) | ✅ 排除 |
| P1 第 1 轮 streak=1 且不触发全量拉(下界防抖) | ✅ 账本实测 {learning_entries:1, word_page_links:1, word_cloze_contexts:1} |
| P2 第 2 轮触发自愈 | ✅ 日志 Self-heal full pull this round: {...} (streak={...: 2}) |
| P3 那几行被捞回 | ❌ 没回来 —— 但原因不是机制,见下 |
🔴 错误一:我的对账口径把云端墓碑算成了缺口
countRows 原本数含墓碑的云端行,而 pull 的「本地无 + remote 删」分支按 FK-safe 惯例刻意不落墓碑 ⇒「对端删了、本端从来没有过这行」是正常终局,本端永远不会有它。 含墓碑去比 ⇒ 差额永远消不掉 ⇒ streak 一路涨到 give-up,自愈每轮空跑一次全量 pull 却什么也修不了。真机实测连空跑 5 轮,行数纹丝不动。
已修:两端都只数活行(deleted_at IS NULL / deleted_at=is.null),脚本同步改。 修完真实缺口从「1 + 2 + 4」缩到 1 + 2 + 1。
🔴 错误二:上一版说「4 条 cloze 真丢,机制未定」——它们全是云端墓碑
查它们时 select 里明明要了 deleted_at,但打印只输出了 word 和 sua—— 要了字段却没看。本地没有它们是正确行为,不是丢失。 ⚠️ 这条连带否掉了上面「4 条 cloze 否掉了原定修法」那段的论据 —— 但结论仍然成立,只是理由换了:见下面真正的那一条。
✅ 真正的丢失只有 1 行,根因是一个类型转换
pellet 落不下去,日志给了完整链条:
[Supabase] Edge Function failed for "pellet": type 'bool' is not a subtype of type 'num?'
→ Vocabulary missing for "pellet", triggering backfill... → 失败
→ Skip notebook entry 80f8af2d…: vocabulary not found (word=pellet)supabase_vocabulary_service.dart 把 cefr_inferred 当 num? 转,而它在 Supabase 侧是 boolean NOT NULL(supabase/sql/vocabulary-buffer.sql)、 Edge Function 也声明 boolean、实测返回 true。一个类型转换写错 ⇒ 整条 backfill 抛异常 ⇒ sync 判「vocabulary not found」跳过 ⇒ watermark 越过 ⇒ 该词永久拉不回来。 现场只有一行 AppLogger.w。已修(parseCefrInferred 同时吃 bool / 0/1 / 字符串, 带反向注入实证),这也恰好证明了自愈机制是对的:它确实每轮重新尝试了那一行, 是那行自己落不下去。
剩下两个不是缺陷的差额(保留,交给人)
word_page_linksdd052755:父词条04e20dd9云端也不存在 = #6f 裁决的存量孤儿word_cloze_contexts3a50ad2b(cough up):本地是墓碑、云端是活行 (本地 08-26 删,RB 08-27 又更新了它)。pull 分支②「本地已删 + remote 活 → skip, 不复活」是正确行为。⚠️ 但本地那行synced_at == deleted_at已 clean ⇒ 墓碑也不会再 push ⇒ 两端永久分歧。这是 #6f/#6g 家族的一个窄缺陷,另立条目,不在本条修。
🔒 口径的取舍(明写,别以为是疏漏)
活行对活行仍有一类假阳:正是上面那条「本地墓碑 + 云端活行」。 放宽成「云端活行 vs 本地全部行」能消掉它,但会引入静默假阴(本地多几行未推的新行时, 真实缺口被抵消掉看不见)。两害相权保留假阳 —— 假阳有 give-up 上界兜底 (最多浪费 5 轮全量 pull,之后只告警不再拉),假阴没有任何东西兜。
常驻判据
rvh/scripts/sync_reconcile.sh(2026-08-31 建)是同一判据的人工/CI 版: 逐 id 比而不只比数量,本地独有 > 0 直接退 1(push 侧回归),查询失败退 2(本轮无结论,不算绿)。 引擎里那份只比数量,是为了便宜到能每轮跑 —— 「丢 1 行同时多 1 行未推的新行」这种 数量相等而集合不同的情形,只有脚本那份测得出来。
🔀 「本地墓碑 + 云端活行」会永久分歧 —— ✅ 已结案(2026-08-31,含真机验收),正文在根侧
一行指针。正文在根
docs/plans/backlog.md, 因为它两端都要动(RBpull/vocab.rs+ RVHsync_repository_impl.dart是 一字不差的同一条规则),按「谁执行谁持有」的第三款——契约类落根侧。一句话:本端软删并推走墓碑后,对端写路径刻意把同一行复活(红线 #7 精神, 两端写侧对称),而两端 pull 又都写着「本地已删 + remote 活 → skip,靠 push 传播墓碑」 —— 而那条墓碑早已 clean、没有任何东西可传播。前提落空,两端就此各说各话。 实例
word_cloze_contexts3a50ad2b(cough up)。✅ 裁决单
docs/cross-end/47-rb-tombstone-vs-remote-alive-adjudication.md: 判据 = 「push 会不会把这条墓碑送出去」(不比任何时间戳),分支② 拆 ②a skip / ②b 复活。✅ RVH 侧已落地(2026-08-31),新立红线 RVH #6i(正文
lib/features/sync/CLAUDE.md)。回执docs/cross-end/49-rvh-tombstone-vs-remote-alive-confirmation.md。 RVH-0(_pullKnownWords补三分支)一并修完 —— 它此前今天就在静默丢已认识词的删除。✅ 真机验收已完成(Android 24094RAD4C):存量那 1 行按 §8 选 B 做了 no-op touch, 下一轮 pull 命中 ②b 复活,
sync_reconcile.sh的word_cloze_contexts差额 119/120 → 120/120 归零,本地独有全 0。 顺带在真机上抓到并修掉两件:②b 漏计 count 会卡住 watermark(回执 §4.5)·scripts/sync_reconcile.sh读错凭据键名、干净环境下从来跑不通。
🔥 P3 — 代码里还有一批陈旧词数 4953(2026-08-28 清 8614 时顺带发现)
与已关闭的 8614 那条不是同一批。 文档侧的 8614 已清完,但代码侧停在另一个更早的 迁移目标值 4953(见 docs/plans/rvh-schema-alignment-tasks.md:133-135,当年那轮把代码 从 8614 改成了 4953)。真值现在是 18916。
站点清单
| 文件 | 性质 | 影响 |
|---|---|---|
lib/l10n/app_zh.arb:937 / app_en.arb:887(vocabTestDescription) | 用户可见文案 | 「第1层:本地数据库(4953词)」,调用点 vocabulary_test_page.dart:120 |
lib/features/vocabulary/data/services/unified_vocabulary_service.dart:14/54 | 文档注释 | 「预装4953词」 |
lib/shared/data/database/app_database.dart:538 | 文档注释 | 「批量导入预装的 4953 词」 |
lib/shared/presentation/widgets/word_management/word_stats_overview.dart:47 | docstring 里的使用示例 | value: 4953(示例代码,非运行值) |
⚠️ lib/l10n/app_localizations*.dart 是 gen-l10n 产物,不要手改 —— 改 .arb 后重新生成。 app_ja.arb 里没有这个键的旧值(日文文案是英文兜底),改完顺带确认三语一致。
为什么是 P3 而不是 P1
唯一用户可见的那处在 vocabulary_test 模块(CLAUDE.md 列为「查询测试工具」,非主流程), 其余全是注释 / 示例。不会让任何人丢数据或做错决定,只是过期信息。
做的时候顺手
word_stats_overview.dart那处是 docstring 示例,写成具体数字本身就没必要 —— 改成value: totalCount之类的占位比追着更新真实数字更省事。- 改完把这些文件纳不纳入 Step 6 的
VOCAB_AUTH_FILES?建议不纳入:那个检查的 正则是按中文散文调的,套到 Dart 源码上误报面很大;代码注释靠 review 兜住即可。
🔥 P3 — OCR 语境闸门的「主列」规则默认关闭,待条件重开(2026-08-27 真机实证后关闭)
触发条件:出现「长句邻页侵入」的真实样本时(短碎片不算 —— 那本来就被长度闸拦掉); 或 RB 对回执 doc 35 §2.1 提出异议。
⚠️ 这不是遗漏,是有意关闭:OcrClozeGate.enableMainColumnRule = false, 实现与单测整套保留(detectMainColumn + 聚类 + 容差 + 5 例回归)。看到它是关的 不要「顺手打开」或删掉死代码 —— 先读完下面的实测数据。
为什么关
三规则闸门(length ≥ 25 ∧ 行左边缘落正文主列 ∧ 行末非连字符)是 RB doc 23 §2.3 认可的。 中间那条输入信号本身不可信:词位 x 是从 ML Kit 的行框按字符比例插值出来的 (wordX = lineLeft + lineWidth × charStart/len,mlkit_ocr_datasource.dart), 而真机实测发现行框左边缘会大幅报偏。一页 21 行,18 行正文的左边缘挤在 55–59,另 3 行:
栏外行 x=298: "E N World" ← CNN 页眉,该拒
栏外行 x=275: "elusive. He spoke about the starvation" ← 正文,误伤
栏外行 x=200: "Gaza, where they spent most of their" ← 正文,误伤后两行在原图上与其余行左对齐,纯粹是行框报偏了 140–240px(占版面宽 15–25%)。 代价实测可见:27 词里 1 词(starvation)因此拿不到语境。
而收益一侧站不住:doc 22 §2.4 那份邻页侵入实测里,两条侵入碎片 ("le with a rag be" 16 字符 / "drink your Koo" 14 字符)本来就过不了 length ≥ 25。 ⇒ 代价可测、收益未被证实。
重开之前要先解决的根因
不是把开关打开就行 —— 得先有更可靠的列信号。已知不可用的:
- ❌
ocr_word_positions.line_x:该列写入时被置成了 word_x(_saveWordPositions里lineRect = wordRect,因为 ML Kit 给的是词级框),不是行左边缘 - ❌ 行内首词的 x:它就是行框左边缘插值出来的,与行框同源、同样会偏
可能的方向(都未验证):按 y 聚行后取该行所有词的 x 最小值做稳健估计(中位数而非最小值)、 或直接从图像做投影分析。动手前先确认新信号在同一批真机样本上不会重现上面那两行的误伤。
相关
- 落地与实测:
scan-ocr-positioning-enhancement-plan.mdTask H「真机验证」节 - 跨端回执:
docs/cross-end/35§2.1 (已请 RB 判断这算工程细节还是改动了批准范围 —— 若 RB 认为是硬性组成,本条升级为 P1) - 顺带一提:同轮修的「连字符必须紧贴词尾」是已生效的修复,不在本条范围内
🔥 P3 — scan 页 UI 权重与场景结论相反(2026-08-15 立项,需产品决策)
触发条件:下次重排 scan 页主操作时;或有了分享路径 vs 相机路径的实际使用数据。
场景盘点的结论是「最轻的入口不该压最重的决策,分享路径应优先于相机路径」—— 公共场合举手机拍照有社交成本,而分享是零社交成本的采集通道。但当前 UI 恰好相反: 相机是大方块主位,分享只是底部一行灰字提示。
Task A/B 已经把决策成本这一半修掉了(三入口都不再前置问归属),剩下的是视觉权重这一半。
为什么没顺手改:调整它等于重排页面主操作,属产品取舍而非工程补齐。且相机大方块本身是 4d789f5「入口改主次结构」的有意设计,推翻前应该有依据。
出处:scan-ocr-positioning-enhancement-plan.md Task B / Task G 末尾。
🔥 P2 — RB pull notebook_entries 漏 source_platform 列(2026-05-26 立项,RB 端修)
详见 RB 端 backlog(同日立项)。RVH 端无关。
⚠️ 2026-08-30 核实:这个「详见 RB 端 backlog」的指针已悬挂 —— 根
docs/plans/backlog.md里现在没有 notebook_entries 的对应条目(表 2026-07 已改名learning_entries)。 根侧只有一条形近但不是同一件事的:reading_notes.source_platform跨端漂移 (不同的表、修复在 RVH 侧),已于同日按「谁执行谁持有」迁入本文件。 动手前先重新确认这条还成不成立,别照这行字直接开工。
🔥 P2 — note_type 双端取值表不匹配:RB 的 'book' 在 RVH 落成「实体书」(2026-08-16 立项,需定跨端契约)
触发条件:下次动 reading_notes 跨端契约时;或用户反馈「我的 EPUB 怎么标成实体书了」。
现象(真机实证)
阅读笔记列表加了类型筛选之后(commit 691da32),两本 EPUB 出现在「实体书」筛选下:
| 标题 | DB 里的 note_type | RVH 显示 |
|---|---|---|
| The City of God, Volume I | book | 实体书 ❌ |
| joseph-conrad-ford-madox-ford_romance | book | 实体书 ❌ |
根因
RB 侧写入 note_type = "book"(src-tauri/src/commands/notes.rs:254,文件 / EPUB 导入路径), 而 RVH 的 NoteType 枚举只有 physical / ebook / webArticle / courseMaterial 四值 —— 'book' 落到 reading_note_model.dart 的 default: NoteType.physical。
⚠️ 映射一直是错的,不是本次改动引入的。RVH 列表页此前从不显示 / 不筛选 note_type, 所以这个错误没有渲染出口,没人看得见。加筛选器只是让它现形。
为什么不能顺手修
改 RVH 的 switch 加一个 case 'book' 是一行的事,但取值表本身是跨端契约: RB 自己的注释(notes.rs:267)就写着「note_type 复用 'webArticle'——没有更贴切的既有取值, 新增取值需评估 RVH 侧」。两端各自扩枚举必然再次漂移。
需要定的三件事
- 权威取值表放哪:写进双端 CLAUDE.md 红线,还是
docs/cross-end/单独一份契约文档 'book'归到哪一档:语义上 RB 的'book'= 本地文件 / EPUB 导入 → 对应 RVH 的ebook更准;但 RVH 的physical是「拍照识词的实体书」,两者不是一回事,别混- 存量行怎么处理:Supabase 上已有的
'book'行是改写还是保留 + 双端都认
影响面
纯展示错误。不影响同步(两端一致地存 'book')、不影响 PK、不丢数据。
🔥 P3 — 词详情同一义项下搭配出中文、辨析出英文(2026-08-16 立项,pipeline 或 UI 二选一)
触发条件:做词详情文案打磨时;或下次跑 pipeline 顺手补字段时。
现象(真机实证)
frog 义项 3「A French person.」下方:
Collocations eat frog 吃青蛙(俚语指吃法国菜) ← 中文
How they differ
Frencher Frencher is rare slang; 'frog' is offensive slang. ← 英文
Frenchy Frenchy is informal; 'frog' is derogatory.根因(数据形状不对称,不是渲染 bug)
| 字段 | 注释字段 | 结果 |
|---|---|---|
differentiation[] | distinction_en + distinction_zh 两份 | 英文界面走 en 分支(translation_content.dart::_SenseDistinctions,符合实现) |
collocations[] | 只有 note_zh 一份 | 没得选,只能出中文 |
用户当前 translationLanguage = "auto" + 英文 locale → preferZh == false。
两条路(各有代价)
- UI 侧:非中文界面时只显示
pattern、隐藏note_zh。零 pipeline 改动,但英文用户失去搭配释义 - pipeline 侧:给
collocations补note_en。信息更全,但要重跑预装库 + 走/preinstalled-db-update全流程(红线 #10 双端 byte-equal 资产)
倾向 pipeline 侧 —— 搭配的中文注释对英文界面用户本就是噪音,但删掉又损失信息;补 en 才是正解。 不过这属于「攒够一批资产改动一起 reseed」的类型,别为它单独跑一次 pipeline。
🔥 P3 — batchMarkMasteredByCefrLevels 死码 + 整档批量标记路径的存废(2026-08-16 立项,需产品决策)
触发条件:决定「按 CEFR 整档批量标已认识」这条路径存废时,两者一起处理。
现状
batchMarkAsMastered(notifier)→ batchMarkMasteredByCefrLevels(domain 接口 + repo impl
- datasource)整条链路零 UI 消费方(已 grep 确认)。2026-08-16 删生词本多选死码 (commit
691da32)时刻意没连带删 —— 它不是「多选」,而是「按 CEFR 整档批量」, 与仍在使用的batchMarkKnownByCefrLevels(known_words_page.dart:148入口)同类。
为什么两者要一起定
RVH 的「整档批量标已认识」与它自己的 CEFR 档位设置功能重复:
| 声明式档位过滤 | 整档批量写 known_words | |
|---|---|---|
| RVH | settings.cefrLevelsList → recognize_and_filter_usecase.dart:340-343 | batchMarkKnownByCefrLevels |
| RB | discovery_cefr_levels → get_discoverable_words() | ❌ 无 |
同一个「我不想看 A1/A2」的诉求,一条是改一个设置,另一条是往 known_words 灌 ~3000 行。 后者因为 known_words 是同步表,有三个真实代价:
- 跨端搬运:点一次,3000+ 行 push 到 Supabase 再 pull 进 RB
- 语义污染:这些行
source='user'/reason='batch_exclude_cefr',落到 RB 的 KnownTab 后 与用户逐个确认的熟词在 source 筛选上不可区分(RB 只有 recommended / user 两档) - 难撤销:想重学 A2 得删 3000 行;设置项只需勾回来
建议方向(未拍板)
下掉 batchMarkKnownByCefrLevels 这条路径,「整档不看」统一收敛到设置里的 CEFR 档位, 「逐词确认认识」交给 Quick Triage(两端都有,交互更好)。同时删掉 batchMarkAsMastered 这条死链。
⚠️ 前置:存量已写入的 reason='batch_exclude_cefr' 行怎么处理要单独定 (留着 / 清掉),涉及已同步到 Supabase 的数据。
🔥 P3 — 背景移除升级到平台原生 API(原 TODO.md REFACTOR-002,先重估价值再动手)
触发条件:真有用户反馈拍照抠图质量差时;或做 OCR 质量专项时顺手评估。
现状:lib/core/services/document_preprocessor.dart 用 OpenCV GrabCut (removeBackgroundGrabCut),经 documentPreprocessorProvider → app_providers.dart:254 接线。 注意主 OCR 链路(recognize_and_filter_usecase.dart)实际走的是 TextRegionCropper + PostCropImageProcessor,背景移除是否还在关键路径上需要先确认。
原提案(2026-01 写的,未验证过):
- iOS → VisionKit Subject Lifting API(系统级、硬件加速)
- Android → MediaPipe Selfie Segmentation(ML Kit 推荐、实时分割)
- 检测平台 / API 可用性,不可用时降级回 GrabCut
- 原文声称收益「质量 +30-50%,速度 +50-70%」——无实测来源,别当结论引用
⚠️ 先回答再动手:① 背景移除现在还在正式拍照流程里吗,还是只剩开发页入口? ② 当前 GrabCut 的实际失败率有多少(有没有真实 badcase)?两个都没有肯定答案就别做—— 这是 2026-01 的技术设想,不是被验证过的需求。
🔥 P3 — 测试覆盖率(原 TODO.md TEST-001)
触发条件:改动核心算法 / 同步逻辑前;或准备发行时。
现状(2026-08-17):66 个测试文件、1176 个用例,但分布极不均:sync / lemmatizer / SM-2 这些跨端红线相关的有扎实覆盖(且多条用注入缺陷验证过),而 CLAUDE.md 记录的 整体水位仍是 Domain < 20% / Data < 10% / Presentation 无系统测试。
建议方向:别追百分比指标(原目标 Domain > 80% / Data > 60% 在 MVP 阶段没有意义)。 按「改到哪补到哪 + 红线路径必须有回归」推进——这也是本仓已经在做的实际做法。 真要立指标,先把 CLAUDE.md「测试覆盖率」一节的数字更新到当前真实水位。
🔥 P3 — 用户手册(原 TODO.md DOCS-002)
触发条件:准备发行 / 上架前。当前 Alpha 阶段不做。
功能使用指南 + 常见问题解答。目前面向用户的说明只有 README / QUICKSTART(都是面向开发者的)。
🔥 P3 — 翻译能力现状待澄清(原 TODO.md FEAT-006 改写)
触发条件:下次动 translation 模块前先花 10 分钟答清楚。
为什么是「澄清」而不是「实现」:原条目写的是「集成有道翻译 API + 批量翻译 + 缓存」, 但有道从未接入,实际架构早已换成 translation_api_datasource.dart (Cambridge / Oxford / Merriam-Webster 三个实现)+ 3 层回填(本地 → Supabase → Edge Function)。原描述不对应任何真实工作,照搬会误导。
要答清的:CLAUDE.md「开发阶段」里翻译集成仍标 🚧,但词详情已能显示 per-POS / per-sense 的多语翻译、3 层回填也在跑 —— 那个 🚧 具体指什么还没做?答清后要么改成 ✅, 要么把真正的缺口写成可执行条目。
📋 历史 backlog 入口(其他计划文档)
quick-triage-phase-a1.md/a2.md/b.md/c.md— 词库瘦身分阶段cefr-llm-evaluation-and-ui-passthrough.md— CEFR LLM 重评 + UI 透传lemmatizer-dictionary-refactor-rvh.md— 词典优先 4 层 lemmatizerrvh-debug-mcp-plan.md— 本调试 MCP 项目主文档rvh-schema-alignment-tasks.md— 跨端 schema 对齐vocabulary-supabase-authoritative-10k-pivot.md— 词库 supabase 权威化
新条目优先记到本文件,沉淀后再拆独立 plan。
🟡 文档一致性检查有两套并行实现,该收敛成一套(2026-08-30 实测发现)
同一件事有两个实现,交集只有版本号与词汇库大小:
| 形态 | 步数 | 谁在跑 | |
|---|---|---|---|
rvh:doc-consistency-check skill | SKILL.md 内联 9 个检查(Claude 逐步执行) | 9 | 活的路径 |
../../scripts/doc-consistency-check.sh | shell 脚本 | 6 | 长期没人跑 |
后果已经发生:脚本那道 Step 4「Markdown断链检查」名字叫断链检查,实现却是一张 写死 3 个文件名的黑名单(testing.md / performance.md / DEVLOG.md),对任何新造的 断链恒绿 —— 2026-08-30 归档两份 plan 当天在 rvh/docs/README.md 造出 2 处真悬挂,它照样 打印「✅ 无常见断链」。同日已就地改指真判据(委托仓根 scripts/check-doc-links.mjs), 但两套并行这件事本身没解决:skill 那 9 条里同样有「Step 2/9 断链检查」, 两边迟早再分叉一次。
做法(未裁定):
- A:脚本保留为「机械层」,skill 只留「判断层」+ 调用脚本。符合
../../../docs/verification/README.md那条三层分工铁律 (能用数字或集合表达的一律下沉成断言,文档只留问句)。倾向 A。 - B:删脚本,只留 skill。代价是失去「不开 Claude 也能跑」的能力,且 CI 接不了。
⚠️ 不管选哪个,别两边都留着各自演进 —— 那正是现在这个状态。
🟢 移动端发版流程没有文档归属(2026-08-30 记录)
rvh/scripts/release/build-android-apk-arm64.sh 是唯一的发版脚本,没有任何 runbook 或 skill 指向它。仓根 /release skill 只管 Windows 桌面端(0 处提到 rvh), docs/verification/release-chain.md 与 scripts/release-verify.sh 同理。
不是 bug,是空白 —— 移动端至今没对外发版,所以一直没人需要它。 真要发的时候会撞上:签名密钥放哪、版本号真相源是什么(桌面端是 src-tauri/tauri.conf.json,移动端是 pubspec.yaml?)、产物往哪发、 _preinstalledVocabVersion 与预装库 symlink 在发版时怎么处理(红线 #10)。
做法:真要发的前一轮再写,写成 rvh:release scoped skill 或 rvh/docs/deployment/ 下的 runbook。现在只登记,不预先设计。
🟡 rvh/docs/plans/ 还有 23 份 plan 没分诊(2026-08-30 建 archive/ 时留下)
背景:2026-08-30 由根 docs/plans/archive/post-merge-repo-optimization-plan.md T4-3 建了 archive/ 并补上归档节律 —— 在那之前 rvh/docs/plans/ 没有归档目录, 29 份 plan 做完的和没做的全堆在一起。
已归档 4 份(判据都是 RB 会话能机械核实的:app_database.dart 的 schema 版本注释、目录存在性): rvh-debug-mcp-plan · rvh-vocab-pipeline-removal-rvh-plan · word-illustration-emoji-rvh · word-cloze-contexts-sync-rvh-plan。逐份依据见 archive/README.md。
剩 23 份要逐份判,判据是 Dart 代码现状 ⇒ 按根 CLAUDE.md §9 会话隔离规则,这是 RVH 会话的活。
⚠️ 别照状态行判(这是本条最重要的一句):已归档那 4 份里,rvh-debug-mcp-plan.md 的状态行 明写「未启动」,而 rvh/rvh-debug-mcp/dist 早已建成、根 .mcp.json 一直在用它。 另有 4 份 quick-triage-phase-*.md 状态行全写「等待用户最终确认」(2026 年初的话), 14 份根本没有状态行。
做法:对每份交叉核 ① rvh/CHANGELOG.md 的 as-built;② app_database.dart 的 schema 版本注释; ③ 本文件与 backlog-archive.md 里的对应条目。 判完的先补一行 as-built 尾注再 git mv(归档节律见 archive/README.md), 仍在推进的补一条真实状态行。归档后 grep 一次引用点补 archive/ 前缀。
📌 2026-08-30 补测的落差(分诊时顺手把状态行也补齐):按「头 15 行内出现『状态』」 这个宽松判据数,根侧一次性 plan 13/13 全有状态行,本目录 11/22 没有。 约定本身已经补齐(新建的
README.md照根侧写了状态行纪律 + 归档节律 + 红线归属), 缺的只是逐份落实——那正是本条要做的事。
📥 从根 docs/plans/backlog.md 迁入 —— 11 条(2026-08-30)
由根
docs/plans/archive/post-merge-repo-optimization-plan.mdT4-3 按 **「谁执行谁持有」**原则迁入:条目正文落在将来真正动手那一端的 backlog,另一端只留一行指针。 根侧对应位置现在是一张索引表(§跨端条目落点)。为什么不是「根侧统一持有」(那是计划正文的原建议,执行时改判):核实时发现三处两本账各记一半的条目里, 证据更全的那份恰好都在执行端 —— 例如
push 75→4,根侧写「71 条没推上去」, 而本文件早已算出「缺口是 63 不是表面的 71(扣掉 8 个 RB 创建但 pull 时被错标 'rvh' 的)」。 按「根侧统一持有」搬,会把更差的那一份留下来。内容逐字保留,只换位置;条目里对
~/reading-browser/...或根侧文档的引用未改写(是写作当时的记述)。
🟡 RVH 的 Dart 格式没有机械守卫了 —— 要不要做一次全仓 tall-style 重排版(2026-08-29 T4-10 遗留)
ci-rvh.yml 原有一步 dart format --output=none --set-exit-if-changed .,2026-08-29 删除。
为什么删:它从来没跑起来过。T3-6 为保它把 FLUTTER_VERSION 钉在 3.24.0, 但 3.24.0 连 flutter pub get 都过不去 —— pubspec.yaml 要 intl: ^0.20.2,而 flutter_localizations 把 intl 钉死在随 SDK 走的单一版本:实测 3.24 / 3.27 / 3.29 都是 0.19.0,0.20.2 最早出现在 3.32.0(Dart 3.8)。而 tall-style formatter 从 Dart 3.7 起 ⇒ 凡能解析本项目依赖的 Flutter 都带 tall style ⇒ 那道门要么在 3.24 上根本执行不到 (此前的真实状态),要么在任何可用版本上当场红掉 591 个文件里的 502 个。
未裁定的是:要不要提交一次全仓 tall-style 重排版,把格式门加回来。
- 收益:风格统一 + 恢复一道机械守卫。
- 代价:几乎每个
.dart的git blame被冲掉 —— 正是 T3-2 否决 squash 导入时要保住的东西。 - 折中(T4-10 当时列过但未选):格式门只扫本次改动的文件(新代码 tall、老文件不动, blame 零影响),代价是仓内两种风格长期混存,且 push 到 main 时 diff base 要靠
github.event.before定,比看上去脆。
真做的话是独立一轮、需要 Flutter 工具链 ⇒ 新会话,且别和别的改动混在一起。
🟢 RVH 有两套 DI 装配写法并存(2026-08-29 T4-10 发现)
rvh/ 里「Riverpod provider 装配具体实现」有两个落点,同时存在且都在用:
- feature 内:
lib/features/*/presentation/providers/*.dart(7 处 —— reading_tracking · sync · auth · quick_triage · statistics) - 集中式:
lib/shared/providers/vocabulary_filtering_providers.dart
这不是违规(组合根必须知道具体实现类,Clean Architecture 禁的是业务/UI 逻辑依赖 data 类型), 是一致性问题。之所以现在才浮出来:ci-rvh.yml 的架构 grep 检查 2/3 只扫 features/*/presentation/,够不到 shared/;而那两条 grep 从建立起就没真正执行过 (检查 1 一失败整个 job 就 exit),T4-10 修好检查 1 后它们首次跑起来,一次抓到那 7 处。
T4-10 的处置:给检查 2/3 加 presentation/providers/ 豁免(判据写在 .github/workflows/ci-rvh.yml 检查 2 上方),不改代码 —— 把 7 处挪进 shared/ 能让 grep 闭嘴,但那是为迎合检查器而改结构,且会降低 feature 的内聚。
要不要统一、往哪边统一,未裁定。 真做的话:动 7 个文件的 provider 定义 + 所有消费方的 import,全部编译期可查(flutter analyze 一条不漏),行为由 1359 个测试兜底;唯一的静默风险 是复制而非移动(同名 provider 变成两个对象 → 状态分裂、不报错)。属独立一轮的活, 需要 Flutter 工具链 ⇒ 新会话。
🟡 RVH OCR 配置校验与实际引擎集合不一致(2026-08-29 从「百度 OCR 弃用收尾」③ 拆出)
必须 RVH 会话(改 rvh/lib/**,要跑 flutter analyze + flutter test,见根 CLAUDE.md §9)。
A. 原本要做的:删百度 OCR 死代码。 引擎存在但永不启用 (ocr_engine_providers.dart:36 的 baiduOcrEngineProvider 在 key 未配置时返回 null, 优先级 Google Vision > Baidu OCR > ML Kit 直接跳过)。涉及: lib/features/ocr/data/engines/baidu_ocr_engine.dart(286 行)· lib/features/photo_recognition/data/datasources/baidu_ocr_datasource.dart(262 行)· lib/features/ocr/presentation/providers/ocr_engine_providers.dart 的 provider · lib/config/app_config.dart 的两个 getter · lib/config/config_validator.dart 的分支 · scripts/check_api_config.sh 的检查项 · .env.example 的注释块。
B. 🔴 复核时查出一个更值得修的问题,顺序应该反过来。
原条目写的注意事项是「hasOcrApi 的生产环境断言会从『百度 OR Google』变成只剩 Google, 需确认这是想要的」—— 这句话不对。config_validator.dart:115-137 实际是四路链: 百度 → Google Vision → 腾讯 → 阿里云,删掉百度只是去掉第一支。
而真问题是:腾讯与阿里云在 lib/ 里根本没有引擎实现 (find rvh/lib -iname '*tencent*' -o -iname '*aliyun*' 命中 0;Google 有 3 个文件、百度有 2 个)。 也就是说只配了腾讯或阿里云的 key 时,validator 会报 ocr_provider: ✅ 腾讯OCR 并让 hasOcrApi = true,而没有任何代码能用它 —— 实际会一路落到 ML Kit。这是本仓反复在抓的那种形状:看起来配好了,其实那条路不存在,且不报错。
⇒ 建议做法:先把 validator 的分支集合对齐到真实存在的引擎(Google Vision + ML Kit, 外加百度这个待删的),再删百度。只删百度不动 validator,腾讯/阿里云那两条假绿会原样留着。
⚠️ 动手前先确认一件事:腾讯/阿里云那两支是从来没实现过,还是实现过又被删了 (后者的话 validator 是残留而非笔误,git log 能分辨)。这决定是删分支还是补引擎。
🟡 cloze 验证的 C 项仍未验:RVH 删扫描页后 cloze 行是否还在(2026-08-28 挂起)
doc 23 §7.3 那步,跨端 relay 三项里唯一没验的一项。证据与全部推理在 docs/verification/cross-end-cloze-pool-2026-08.md (该 relay 已收尾、停止更新)。
为什么挂起而不是「验了没问题」:RVH 的 reading_pages 删除是硬删 + 依赖 FK CASCADE (reading_page_datasource.dart:131 / :580,两条 UI 均可达)⇒ 页删除不上行, 且 CASCADE 会物理抹掉 word_page_links 的墓碑。 doc 23 §3.1 第 1 条说这个行为「可以接受」,那句话是在软删前提下说的(红线 #6a)。 现在跑 C,等于把一个坏行为记成「既有行为」。
重开条件:RVH 侧把那两条路径改成软删之后。届时按 doc 23 §7.3 单端跑即可, 不需要再起一次双端 relay。
动作在 RVH 侧,RB 无待办 —— 记在这里只是免得这一项随 relay 收尾一起被忘掉。
🟡 RVH 侧核对:写 known_words 是否漏归一(红线 #9 对称问题,RVH 新会话,2026-08-12 记录)
触发:RB 侧 2026-08-12 在
add_known_word里发现了这个缺陷并已修 (commit 894f875)。RVH 有同名表、同类写入路径,很可能中了同一枪。
RB 侧的原缺陷长什么样(供 RVH 会话按图索骥):
rust
// src-tauri/src/commands/known_words.rs::add_known_word —— 修复前
let lower = word.to_lowercase(); // ❌ 只小写,不过 lemmatizer而调用方传的是页面原始 surface(用户双击 "cats",不是词元 "cat")。 后果链条全程不报错:
| 环节 | 表现 |
|---|---|
| 存库 | known_words 落 "cats",而 vocabulary / learning_entries 的 PK 是 "cat" |
| 高亮过滤 | v.word NOT IN (SELECT word FROM known_words …) 比不上 → 换页后照旧高亮 |
| 级联软删 | UPDATE learning_entries … WHERE word = ? 比不上 → 没真的移出生词本 |
| 客户端 | excludedWordsSet 是拿去跟词元集合做差集的 → 同样滤不掉 |
最难发现的地方:当场看起来是好的 —— 客户端按 surface 删 span 会成功, 高亮立刻消失;要等换页重扫才复发。所以肉眼验收极易漏过。
RVH 会话要做的:
- 找 RVH 侧所有写
known_words.word的路径,确认是否都过了与save_word同一个 归一函数(RB 是lemmatizer::normalize,含 NFC;RVH 对应实现见其 lemmatizer 端口)。 - 一并核 读侧:过滤/差集用的键是否与写侧同形。
- 若确认中招:修法照 RB —— 改用同一归一函数;存量脏行不回填 (惰性无害,且面板按原值仍删得掉,回填反而有误伤风险)。
为什么值得单独记:红线 #9 的条文只点名了 vocabulary / learning_entries / known_words / reference_words 四张表要归一, 但 RB 这处正是写了 known_words 却没归一——说明"文档写了"不等于"代码做了", 跨端另一侧同样需要实证核对而非假定。
关联:同轮还改了 save_word 补软删 known_words(熟词可重回生词本, 否则进死状态)——RVH 若也有「标为熟词」入口,这条对称性一并核。 as-built 见 popup-and-entry-points-plan.md §4.2 / §4.5。
🟢 知会 RVH:隐私政策已改(默认开启口径),双端共用对外文本(RVH 新会话,2026-08-12 记录)
RB 2026-08-12 把「AI 语境助手」(context_disambiguation) 从 opt-in 翻成 opt-out(默认开), 配套把隐私政策里「当你主动使用查词消歧…」改成如实描述(默认开启、可随时关闭), 中英双语同改并已部署上线(lampio.app/{zh,en}/privacy,LAST_UPDATED = 2026-08-12)。
为什么要知会 RVH:landing/lib/legal.ts 是双端共用的对外法务文本—— 它描述的是"Lampio 这个产品"怎么处理数据,不是"RB 这个客户端"。 RVH 若有任何 AI 文本处理路径的开关默认值与该文不符,就会出现"站点承诺 ≠ RVH 实际行为"。
动作:RVH 会话核一遍自己的 AI 相关开关默认值,与 ~/reading-browser/landing/lib/legal.ts 的现文对齐;不一致就报回来改文案(不要单方面改 RVH 行为去迁就文案,两边都可能是对的一方)。
context_disambiguation本身是 RB 本地设置、不在同步矩阵,所以这条只是文案对齐, 没有 schema / sync 影响。
📌 2026-08-14 追加一处同文件改动(同一次知会里一并说明):隐私政策补了「AI 阅读回顾」 条目(LAST_UPDATED → 2026-08-14),如实披露周报会发送**读过的页面标题与网站域名 + 新存生词
- 聚合统计**;同时把语境助手那句「任何情况下只发送这些句子」收窄为「这项功能任何情况下…」。 RVH 侧无对应功能(周报是 RB-only),所以这条不要求 RVH 改行为——但共用对外文本变了, RVH 会话核对开关默认值时读的是新版(见本文件 §「隐私政策补上周报 payload」as-built)。
🟢 RVH 接 Azure TTS —— 两端发音统一(RVH 新会话,2026-08-03 记录)
触发:用户 2026-08-03 明确表态 ——「RVH 还没发版,但接 Azure 是早晚要跟进的事, 可以认为 RVH 也只使用 Azure,两端必须统一」。
RB 侧现状(已完成,可作参照):WP4e 已删掉「系统语音」选项、恒走 Azure Neural (issue-speech-token + tts-synthesize 两个 edge function,secret 见 /edge-deploy 矩阵)。 连读取一起删是关键 —— 只删 UI 不删读取会让存量库里存过 web-speech 的用户永远卡住。
为什么记这条:vocabulary.pronunciation_url(Free Dictionary 给的音频 URL)在 RB 全仓 只有写入、无任何读取路径,因为发音走 Azure。2026-08-03 查词主源换成 Wiktionary 后 该列对新回填词恒为 null。若 RVH 还在播这个 URL,换源后它那边的长尾词发音会静默失效 —— 这是本条的真实紧迫性来源,不只是"体验统一"。
动作:见交接文档 ~/reading-browser/docs/cross-end/19-rvh-tts-and-dict-source-handoff.md (① 接 Azure,复用 RB 已部署的 issue-speech-token / tts-synthesize,无需新 secret; ② 确认 RVH 是否消费 pronunciation_url 与 synonyms/antonyms;③ 不建议为该列开跨端迁移删除)。
🟠 reading_notes.source_platform 跨端漂移 —— 已诊断(P2,修复在 RVH 会话)
触发条件:用户口头「修那个 reading_notes source_platform」即触发;必须 RVH 新会话执行(CLAUDE.md §9)。
现象(2026-06-06 首跑 cross-end-check 发现):共享表 reading_notes 列集合三端不齐——RB 有 source_platform,RVH 无。另两处列差异(reading_pages cached_file_path/content_html↔linked_at、known_words added_at)已判为合法 local-only。
诊断结论(2026-06-06 RB 侧只读核实完成,确认是真漂移非 local-only):
- RB 本地
reading_notes有该列,push 写进载荷(push.rs:151)、pull 读回(pull/library.rs::pull_reading_notes的unwrap_or("rvh"))。 - Supabase
user_reading_notes.source_platform定义 =TEXT NOT NULL DEFAULT 'rb'(线上已确认列存在;该 user 现有 1 条均 RB 来源标对)。 - RVH 本地
reading_notes无此列 → RVH push 时载荷带不了 → Supabase 用 DEFAULT 盖成'rb'→ RVH 建的笔记云端被误标 'rb',RB pull 下来也认成 'rb',来源归属错乱。 - 潜伏性:不 break 同步(DEFAULT 吸收缺失,不 400),仅 RVH 来源 reading_notes 溯源不准;只在 RVH 真建/同步 reading_note 时发作,当前线上未暴露。
- 病根 = 不对称:
learning_entries两端都有 source_platform(RVH 侧 DEFAULT 'rvh' 显式 set,溯源正确),唯独reading_notesRVH 漏镜像。
修法(确定,改 RVH 一端):RVH 给本地 reading_notes 补 source_platform TEXT DEFAULT 'rvh' + push 载荷带上它——照抄 RVH 自己 learning_entries 已有写法。RB 零改动(push/pull 已正确)、Supabase 零改动(列已存在)。
关联:/cross-end-check 首个真实捕获案例(验证了 skill 价值)。
🟣 RVH 构建预装短语库(phrasal verb / idiom)—— P0 短语高亮 Phase 1(必须 RVH 新会话)
触发条件:用户说「建短语库 / 推进短语高亮 / RVH 短语」即触发;必须 RVH 新会话执行(CLAUDE.md §9)。RB 侧已完成 Phase 0 设计 + 交接。
做什么:按 docs/cross-end/06-rvh-phrase-library-brief.md 规格,RVH pipeline 把常用 phrasal verb(~1–2k)+ idiom(~0.5–1k)作为 vocabulary 普通词条入库——word=lemmatizer+NFC 归一基础形、word_tags 含 phrasal_verb/idiom、带 gloss+中文+CEFR+frequency(CEFR/freq 供 RB 高亮降噪)。不加新表/新列。
分发:byte-equal 整包覆盖 → RB 走 /vocab-reseed 重装 + bump VOCABULARY_SEED_VERSION + 验收(条数 + 归一一致 + gloss 齐备)。
阻塞关系:此步是 Phase 1,阻塞 RB Phase 2(识别/高亮/explain-phrase Edge Function/popup/save/review,见 phrasal-idiom-highlight-plan.md)。库不到位 RB 无数据可匹配。
红线:#9 归一一致(跨端 PK)、#10 byte-equal 只读消费。无 Supabase schema 变更(短语属预装库)。
🟠 RVH 短语/习语归一噪声治理(lemmatizer 资产 + idiom 展示形)—— 必须 RVH 新会话
触发条件:用户说「治理短语噪声 / 修 lemmatizer 资产 / as→a / 习语展示形」即触发;必须 RVH 新会话(CLAUDE.md §9,Flutter/Dart + pipeline)。RB 侧已诊断(2026-06-07,本次短语高亮特性中暴露)。
两个根因(RB 端只读核实):
lemmatizer 资产错映射(
surface_to_base.json,双端 byte-equal 共享,红线 #5e):"as":"a"—— 高影响:as long as→a long a、as soon as→a soon a、as a whole→a a whole,所有含 as 的习语 PK 被毁。- 另已知:
picked→pic/passed→pas/trying→trie(tried→try却对)。 - 修法 = 在 RVH pipeline 修正这些条目(at least
as),双端原子提交 + 全量重算受影响的 vocabulary.word PK + reseed(红线 #5e/#9/#10)。注意:改归一会变 PK → 跨端 + Supabase 缓冲池需一并核对,属 schema-ish 变更,按 §9 协议走。 - 新失效模式(2026-06-25 gate-A 实测新增,与上面相反症状):过度折叠制造幻影 always 匹配(误报)——
fixed→fix让正文 "in a fixed proportion" 撞上习语 PKin a fix;rights→right+reserved→reserve让 "All rights reserved" 撞上all right reserve。上面as→a是折叠破坏 PK 致漏标,这里是折叠过头致误标,同一资产两个方向的 bug。修surface_to_base时一并收紧这些(fixed/rights/reserved等屈折→基形的过度折叠)。all right reserve还可能本就是坏词条("All rights reserved" 是法律 boilerplate 非习语)→ RVH 评估直接从短语库 prune。
idiom 展示形缺失:即便归一正确,逐 token lemmatize 会让
raining cats and dogs→rain cat and dog(cats→cat 合法但读着怪)。matching 用归一 PK 没问题,但弹窗/复习卡显示需要可读的原始 surface。建议 RVH 在pos_definitions或新增列存 idiom 的 canonical surface(展示用),与 matching PK 分离。RB 端 popup/WordRow 改读展示形(RB 跟进会话)。
关联:docs/cross-end/08-rb-phrase-reseed-confirmation.md(噪声样例 + 诊断);RVH reference_lemmatizer_asset_bugs memory;本次特性 docs/plans/archive/phrasal-idiom-highlight-plan.md。
优先级:P2(不阻塞) → gate-A 阻塞项(2026-06-25 升级)。原判"仅影响显示美观 + 覆盖"只看了 as→a 的漏标方向;gate-A 实测发现过度折叠还制造误报(占残余 FP 的 2/5),直接卡 phrase_auto_highlight 默认 on。as→a 因影响面大仍可优先单独修。本条与 backlog §短语 B 方案 #1b 是同一工作(gate-A 复测的两个 RVH 前置之一)。
RVH 会话启动 prompt(复制即用,2026-06-25 备):
任务:lemmatizer 资产过度折叠治理 —— gate-A 残余误报的另一轨(必须 RVH 新会话)
【会话隔离】RVH = 本仓子目录 `rvh/`(Flutter/Dart;2026-08-28 合仓)。仍要**新开会话**,
判据是工具链(flutter/dart vs cargo/pnpm),不是目录 —— 见 CLAUDE.md §9「会话隔离规则」。
路径一律**仓根相对**(原写在这里的「~-锚定绝对路径」规则随 §9「跨仓库路径约定」整节于
2026-08-29 T4-7 删除,那条规则的前提就是两个仓)。⚠️ 本轮比 idiomaticity retag 重:改 lemmatizer
会变 vocabulary.word PK → schema-ish 变更,按 §9 协议走(双端原子 + Supabase 缓冲池核对 + byte-equal
reseed),不是纯 additive。
【先读真相源(仓根相对路径)】
1. Read docs/plans/backlog.md →「🟠 RVH 短语/习语归一噪声治理」整条(本条)
2. Read ~/reading-browser/docs/plans/archive/phrase-auto-highlight-thinslice-plan.md §gate-A 实测(lemma 折叠 2 实例 + 背景)
3. Read ~/reading-browser/docs/cross-end/08-rb-phrase-reseed-confirmation.md(噪声样例 + 早期诊断)
【一句话目标】
surface_to_base(双端 byte-equal,红线 #5e)有两个方向的折叠 bug,制造短语高亮的漏标 + 误报。
本轮收紧映射 + 全量重算受影响 vocabulary.word PK + byte-equal reseed。
【两个方向的 bug】
A. 折叠破坏 PK → 漏标:as→a(高影响:as long as→a long a 等所有含 as 习语 PK 被毁);picked→pic / passed→pas / trying→trie
B. 折叠过头 → 幻影 always 匹配=误报(gate-A 新增):fixed→fix("in a fixed proportion"撞 in a fix);
rights→right + reserved→reserve("All rights reserved"撞 all right reserve)
【关键判断】屈折→基形要分清合法 lemma vs 过度折叠(fixed/rights/reserved 在这些语境不该折到动词基形)。
`all right reserve` 可能本就是坏词条(法律 boilerplate 非习语)→ 评估直接 prune(沿 v21 bad_entry 机制)。
【交付(schema-ish,照 §9)】
1. 修 surface_to_base 错误/过度折叠条目(at least as→a + fixed/rights/reserved)
2. 全量重算受影响 vocabulary.word PK,重建 lemma_* 表(surface_to_base/base_forms)
3. byte-equal 重导出 reading_vocab.db + bump 版本(v23 或与 retag round-2 合并版本)
4. ⚠️ 跨端 + Supabase 缓冲池 PK 核对(红线 #5b/#9)
5. 续写交接确认 cross-end 文档(改了哪些映射 / 受影响 PK 数 / lemma_* 新行数 / SHA / 版本 / 是否需 RB migration)
6. 知会 RB:跑 /vocab-reseed;若 lemma_* 行数变,RB sync 脚本 4 表白名单的期望行数要同步更新
【红线】#5e 双端 byte-equal、#9 跨端 PK 一致、#10 整包覆盖。改归一是 PK 级变更,务必双端原子。
【与 ① 的关系】① retag round-2(surface 陷阱降 context,tiering §11)和本轮(②)是 gate-A 两个并行前置;
可合并成一次 reseed(同版本号)或分两次,RVH 定。🟡 Supabase 统一维护到 RB — 阶段 2 RVH 侧(新会话,2026-06-15 记录)
触发条件:RB 阶段 1 已 commit + 3 个 function 从 RB deploy 验证通过后启动。必须新开 RVH 会话(§9)。
RB 侧(阶段 1)已完成:13 个 function 全入 RB/supabase/functions/(含迁自 RVH 的 google-books-proxy / gutenberg-proxy / lookup-or-fetch-word);RB/supabase/sql/sync-tables.sql 确立为共享表唯一真相源。 RVH 会话待办:① 删除 RVH/supabase/functions/{google-books-proxy,gutenberg-proxy,lookup-or-fetch-word,_shared} ② 约定 RVH 不再为共享表写 migration(仅留词库 pipeline)③ RVH 文档指向 RB 为后端真相源。 完整顺序/风险/回滚见 docs/plans/archive/supabase-consolidation-plan.md。红线:预装词库 pipeline 留 RVH(红线 #10)。