主题
23 — RVH OCR 语境句入 word_cloze_contexts:RB 侧裁决
物理位置:本文件在 RB 仓库
~/reading-browser/docs/cross-end/23-rb-ocr-cloze-context-decision.md。 RVH 会话用绝对路径读:Read ~/reading-browser/docs/cross-end/23-rb-ocr-cloze-context-decision.md。对应请求:
~/reading_vocab_helper/docs/cross-end/22-rvh-ocr-cloze-context-review-request.md(2026-08-15 创建 / 08-17 补实测) 上游契约:16-rb-cloze-context-sync-handoff.md(本文件在「池子归属」这一点上取代它的框定)创建:2026-08-17 · 裁决:批准方案 A(双端共写)+ 四条硬条件(见 §2)· RB 侧无代码改动,仅改
CLAUDE.md
0. 裁决摘要
| 问题 | 裁决 |
|---|---|
| Q1 池子归属 | ✅ 双端共写,互相挤占。池子按 word 单键组织,从设计上就不是「RB 的池」 |
| Q2 噪声容忍度 | ✅ 方案 A + §2.4 三规则闸门,附 4 条硬条件(C1–C4)。不退 D |
Q3 source_url NULL | ✅ 不影响 RB 渲染,且 NULL 是唯一正确取值。🚫 禁止造合成 URL |
| Q4 条文改写 | 见 §5,RB 侧 CLAUDE.md:206 改写已随本文件落地 |
RVH §2.1 / §2.2 两处自行排除的风险,RB 侧复核同意排除(依据见 §4.1)。 但复核中发现 RVH 未列出的一条硬伤(created_at 时区 → 「留新删旧」退化成「留 RVH 删 RB」), 它是条件 C3 的由来,必须在动代码前处理。
1. Q1 —— 池子归属:批准双端共写
结论:应该互相挤占。同意 RVH 侧的倾向,且理由比「两端都是真实阅读现场」更硬。
三条 RB 侧的结构事实:
- 池子的组织键只有
word,没有来源维度。 本地表无 FK、无UNIQUE,索引是(user_id, word COLLATE NOCASE, created_at DESC);封顶 5 是每词全局 5,不是「每来源 5」。 消费侧srs.rs:380-382也只按word IN (...)批量取。从来就没有一个「RB 的池」这个对象 ——把它说成「RB 采集、RVH 消费」是在描述当时的事实分布,不是描述数据模型。 - RB 自己的守门脚本早就把它当双端共写表。
scripts/cross-end-check.sh:219的SHARED_SYNC数组(= 两端都要 push 的共享表集合)已包含word_cloze_contexts。 若裁决为否,那才是需要把它从数组里摘掉的一天。 - doc 16 §2.4 的
reconcile_cloze_pool本就是为对称双写设计的。 它解的问题是 「两端各自封顶 5 条、merge 后可能 10 条」——这个问题只在双端都产行时存在。 单向 pull 的话,一次简单的INSERT就够了,不需要「合并后重新封顶」这一步。 换句话说:RB 侧为双端共写付的代价已经付掉了,现在只是开始用它。
产品层的补充理由:cloze 语境的价值是「你在哪遇见它的」。用户在通勤时拍下的纸页, 和在桌面端读到的网页,对复习的回忆价值同级。刻意让前者拿不到语境范式,是把实现史 (RVH 先没有这张表)泄漏成产品差异。
2. Q2 —— 噪声容忍度:批准方案 A + 三规则闸门,附四条硬条件
2.1 为什么不退 D
D(仅截图/分享正文入池、拍照不入)的「零噪声」是真的,但它把 §2.4 实测覆盖的唯一场景 排除掉了——纸质书恰好是移动端相机存在的理由。且实测已把噪声从「说不清」变成 「可判定 + 可回归测试」:被挡掉的 20% 全部落在三类有明确特征的桶里。这种情况下退 D 是用「不做」代替「做对」。
若 C1–C4 中任何一条做不到,退路是 D 而不是 A。 那时 D 的判据(按 source_type 分流) 仍然成立且零成本。
2.2 四条硬条件
这四条不是「建议」,是 RB 侧批准的前提。RVH 侧落地后请在回执里逐条对账。
🔒 C1 —— surface 必须字面出现在 sentence 里,且 surface 存该行里的原始形
RB 前端 cloze.ts::buildContextCloze 用 \b<surface>\b(大小写不敏感)在句中定位挖空点, 定位失败返回 null;buildReviewPositions(cloze.ts:193)对 null 的处理是 continue —— 该语境不进 position list。
后果比「句子难看」严重得多:一条定位不到目标词的行,会占掉 5 个坑位之一,同时在 RB 侧完全不可见(不报错、不降级、就是没有)。它还会把一条本来能用的 RB 句挤出池子。 这是本次评审里唯一的静默型代价。
所以:写行前断言 sentence 含 surface(整词边界)。§2.4 实测这一条是结构性成立的 (ML Kit 的 word 从 line 里抽),但必须写成断言而不是依赖——它同时顺手挡住了字符级 识别错误:heer→beer 这类被纠正过的词,纠正后的 word 不在原始 line_text 里, 断言自然拒收。不要用纠正后的形式去写 surface。
🔒 C2 —— 每个 word 每次收割最多写 1 条
一次扫描 49 个词位 ≈ 49 个不同的词各 1 句,不构成冲刷。但同一个词在同一页出现多次时, 逐词位写行会让一个词在一次收割里吃掉多个坑位(最坏 5 个全占,把该词的历史语境整体清空)。 按闸门得分取最优一行即可(长度、是否落主列、是否有句末标点都是现成信号)。
🔒 C3 —— created_at 必须 UTC ISO8601(走 nowUtc(),不要 DateTime.now())
这是 RVH doc 22 未列出的一条,且是四条里最容易踩的。
created_at 是两端 reconcile 的排序键(RB pull.rs:857-940 / RVH _reconcileClozePool 均为 ORDER BY created_at ASC, id ASC),且是「留新删旧」语义的唯一依据。两端比的是 同一批字符串的字典序——RVH §2.1 说「排序结果必然一致」这点没错,不会 thrash。
但一致 ≠ 正确。RB 写的是 chrono::Utc::now().to_rfc3339()(2026-08-17T10:23:00.123+00:00)。 RVH 的采集层 filter_confirmation_notifier.dart:98/788 用的是裸 DateTime.now()(本地时区), toIso8601String() 不带偏移 → 2026-08-17T18:23:00.000。字典序上 18: > 10::
UTC+8 下,RVH 新建的行恒定「显得」比同时刻的 RB 行晚 8 小时。 「留新删旧」于是退化成「留 RVH、删 RB」——RVH 的 OCR 句几乎永不被淘汰, RB 的网页句被系统性挤出。两端仍收敛到同一份池子,所以没有任何报错或不一致可被观测到。
修法是现成的:RVH 已有 lib/core/utils/datetime_utils.dart:8 的 nowUtc(),数据层 (reading_page_datasource.dart:37)本就在用。cloze 写入走同一个 helper,别走 notifier 层 那个 DateTime.now()。
附带说明:同款混格式风险只对「被当作跨端比较键的列」有害。目前这类列只有
reading_pages.last_opened_at(取 MAX),而 RVH 不 push 该列,所以此前一直没有暴露面。 cloze 的created_at会是第二个这样的列。
🔒 C4 —— 闸门必须有单测;它是唯一的一道闸
RB 的 pull 侧不做任何质量校验:pull.rs:827-842 对远端活行无条件 INSERT,不检查 长度、不检查可挖空性(8..220 的入池闸只长在采集路径 crud.rs:49 上,展示侧 cloze.ts:32-33 的同值检查只是最后一道 belt,作用是不显示,不是不入库)。
⇒ RVH 的闸门就是全系统对 OCR 噪声的唯一防线,不存在 RB 侧兜底。请按 doc 22 §5.5 的 fixture(ordinary 通过 / ball·rag·wedding 拒收)把它锁成纯函数单测。
2.3 对三规则闸门本身的意见
三条规则(length ≥ 25 ∧ line_x 落正文主列 ∧ 行末非连字符)RB 侧认可,无修改意见。
两点说明,避免被误解成 RB 在施加双标:
≥25比 RB 自己的≥8严,这是对的。 RB 的 8 字符下限配的是 DOM 切句(有真句边界), 而crud.rs:44只额外要求「含空白字符」——所以"a wedding"在 RB 侧也会入池。 RVH 对更嘈杂的通道用更严的闸门是正确的工程判断,不是被强加的额外义务。- 行末连字符那条是刚性需求,不是可选优化。
\b匹配会让"ball bats with thorns..."成功挖空ball——即 C1 的断言挡不住断词行(断出来的ball确实在句里)。 这条规则是断词唯一的拦截点。doc 22 §5.2 的「可选增强:与下一行合并」若实现了, 合并后的行应重新过一遍全部三条规则。
3. Q3 —— source_url NULL:不影响渲染,且是唯一正确取值
RB 侧无 source_url 非空假设。 已验证的四段路径:
| 位置 | NULL 时的行为 |
|---|---|
srs.rs:381-400 | Option<String> 原样下发,无过滤 |
cloze.ts:194-199 | sourceIdx = null → 归入 orphan 桶,排在 position list 尾部。卡面照常挖空 |
useReviewStore.ts:296-317 | src === undefined → 落 else 分支 → 右侧 showReviewEmptyState('nosource')(既有空态,不是白屏) |
ReviewSession.tsx:289-295 | 语境切换器是数字 n/N,不显示来源页标题 → 不会出现空标签 |
并且 NULL 是唯一正确取值,不要造合成 URL(如 rvh-ocr://<page_id>):
- RVH 的 OCR
reading_pages本身source_ref就是 NULL——filter_confirmation_notifier.dart:323/374调createReadingPage时不传sourceRef。没有任何 URL 可以引用,合成值是无主的。 - RB 的页级级联清理按
word_cloze_contexts.source_url = reading_pages.source_ref精确等值匹配 (notes.rs:1010-1014页级、notes.rs:933-939笔记级)。合成 URL 若不等于任何source_ref, 清理照样匹配不上(白造一个值);若恰好等于某个source_ref,反而会让 RB 把该语境归组到那一页 并尝试打开它。两种结果都比 NULL 差。
3.1 附带确认两处既有行为(都不阻塞,供 RVH 知情)
- OCR 语境行落在页级删除的清理盲区里。 级联键是
source_url,NULL 匹配不上任何东西 ⇒ 在 RVH 删掉某张扫描页,不会带走它贡献的 cloze 行。可清理路径只剩:删词 (clear_cloze_context_for按 word 软删,正常工作)、句级删除、以及封顶轮换。 RB 侧判断可以接受——语境的价值本就绑在词上不绑在页上,且这与 RVH 侧 OCR 页source_ref IS NULL是同一个根,不是本次新增的破口。 - 多张 OCR 图片页在复习卡的 sources 列表里会折成一条。
srs.rs:357-358的去重判据是s.source_url == src.source_url,多张source_url IS NULL的页彼此相等 ⇒ 只留第一条。 这是本次之前就存在的行为(RVH 的 image 页早已同步过来),与 cloze 无关,本次不改。
4. 对 RVH 侧两处自行排除的风险的复核
4.1 同意排除
- §2.1(时钟漂移 → 幸存行不一致):同意。两端排序键取的是已存储字符串,字典序在 ASCII 范围内两端一致 ⇒ 幸存行一致,不会 thrash。⚠️ 但「一致」不等于「语义正确」——见 C3, 这是本文件在这条上唯一的补充。
- §2.2(新句被 reconcile 立刻删掉):同意,方向确实是反的。已核对
crud.rs:75-83(满池 先软删最旧LIMIT count-CAP+1再插新行)与pull.rs:930-940(reconcile 删最旧超额行), 两处都是留新删旧。 - §2.3(push 无来源过滤 ⇒ 上行默认发生):核对 RVH
sync_repository_impl.dart:1181-1188确认无来源过滤,仅有user_id+ 脏条件。同意「不存在先本地试试的中间态」这个判断 ——这也是方案 B 成本被低估的地方(B 需要新增列或过滤,而非「少做一步」)。
4.2 RB 侧新增的一条
**C3(created_at 时区偏置)**是本次复核唯一的新发现,也是唯一需要在动代码前就定下来的。 它的症状形态与红线 #6d(墓碑回声环)同类:不报错、不告警、只是行为长期地悄悄偏一边。
5. Q4 —— 条文改写
5.1 RB 侧(已落地)
CLAUDE.md §3「Auth & Sync」的 Merge 策略 一条已改写。改动只碰 word_cloze_contexts 那半句, 未动 reading_pages.last_opened_at 那半句(「RVH 无阅读功能,只 pull 不 push 此列」说的是 另一列的另一件事,本次裁决不涉及;但注意该措辞在本裁决后容易被误读成「RVH 无采集」—— 它的准确含义只是「RVH 不 push last_opened_at」)。
落地后的措辞:
word_cloze_contexts(一词多语境池,应用层封顶 5)双端共写——2026-08-17 评审放开 RVH 的 OCR 采集路径(见
docs/cross-end/23-rb-ocr-cloze-context-decision.md;此前的事实分布是 「RB 采集 / RVH 只消费」,那从来不是数据模型约束:池子按word单键组织,无来源维度)。 额外做合并后重新封顶:pull 墓碑传播 + sense_gloss 取非空一方后,对本批次每个 word 跑reconcile_cloze_pool(按句 COLLATE NOCASE 去重 + 软删最旧超额行)——否则两端各 5 条 merge 成 10 条。🔒 两条只对采集侧成立的约束(pull 侧无条件 INSERT,不校验质量,故任何新增采集路径 必须自带闸门):① 入池句必须能被目标词整词命中(cloze.ts::buildContextCloze定位失败 → 该语境不进 position list,于是那行占着 5 个坑位之一却在 UI 里不可见,属静默占位); ②created_at必须 UTC ISO8601——它是两端 reconcile 的排序键(created_at ASC, id ASC) 兼「留新删旧」的唯一依据,写本地时区串会让该端的行恒显更晚,把淘汰规则悄悄变成 「留某一端、删另一端」(无报错、无不一致可观测)。
5.2 RVH 侧(建议措辞,RVH 会话自行落地)
红线 #5g 的「RVH pull-only:不新增本地采集路径」一句失效,建议改为:
#5g
word_cloze_contexts双端共写(原 pull-only 已于 2026-08-17 跨端评审放开): RVH 可从 OCR / 拍照采集写入本地行并正常上行。🔒 采集必须过闸门,四条硬条件(见~/reading-browser/docs/cross-end/23-rb-ocr-cloze-context-decision.md§2.2): ①sentence必须整词包含surface,且surface存该行原始形(不用纠错后的形式); ② 每 word 每次收割最多 1 条;③created_at走nowUtc(),禁用裸DateTime.now(); ④ 闸门必须有单测——RB 的 pull 侧不做任何质量校验,这道闸是全系统唯一防线。 违反 ① 的行会占掉封顶 5 的坑位却在 RB 侧不可见;违反 ③ 会让本端的行系统性挤掉对端的行。
同时建议把 doc 22 §5 的待办清单里 created_at 一项显式化(原文未提)。
6. RB 侧待办
- [x]
CLAUDE.md:206改写(§5.1) - [x] 本回执 +
docs/cross-end/README.md编号总表加 23 - [x] doc 16 头部加「池子归属一节已被 23 取代」的指针
- [ ] 无代码改动。RVH 落地后若需要,可考虑给
pull_cloze_contexts加一道「不可挖空即不入库」 的受侧兜底(把 C1 从约定升级为强制)——但那会改变「pull 无条件插入」这条既有语义, 须单独立项,不搭本次便车 - [ ]
reconcile_cloze_pool的封顶策略本次不动(双端当前一致,RVH doc 22 也明确不改)
7. 验证建议(RVH 落地后跨端实测)
- 同一词在 RB 攒满 5 条语境 → RVH 拍一张含该词的页 → 两端各 sync 两轮 → 双端都收敛到同一份 5 条,且被淘汰的是真正最旧的那条(这一步同时验 C3:若时区错,被淘汰的会恒定是 RB 那条)。
- 造一条违反 C1 的行(
sentence不含surface)手工写入 RVH → sync → 确认 RB 侧该词的 语境切换器n/N里 N 少了 1,而池子活跃行数是 5 ⇒ 复现「静默占位」,据此确认闸门必要性。 - RVH 删掉那张扫描页 → 确认 cloze 行仍在(§3.1 第 1 条的既有行为,别当 bug 报)。