Skip to content

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 侧的结构事实:

  1. 池子的组织键只有 word,没有来源维度。 本地表无 FK、无 UNIQUE,索引是 (user_id, word COLLATE NOCASE, created_at DESC);封顶 5 是每词全局 5,不是「每来源 5」。 消费侧 srs.rs:380-382 也只按 word IN (...) 批量取。从来就没有一个「RB 的池」这个对象 ——把它说成「RB 采集、RVH 消费」是在描述当时的事实分布,不是描述数据模型。
  2. RB 自己的守门脚本早就把它当双端共写表。 scripts/cross-end-check.sh:219SHARED_SYNC 数组(= 两端都要 push 的共享表集合)已包含 word_cloze_contexts。 若裁决为否,那才是需要把它从数组里摘掉的一天。
  3. 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(大小写不敏感)在句中定位挖空点, 定位失败返回 nullbuildReviewPositionscloze.ts:193)对 null 的处理是 continue —— 该语境不进 position list

后果比「句子难看」严重得多:一条定位不到目标词的行,会占掉 5 个坑位之一,同时在 RB 侧完全不可见(不报错、不降级、就是没有)。它还会把一条本来能用的 RB 句挤出池子。 这是本次评审里唯一的静默型代价。

所以:写行前断言 sentencesurface(整词边界)。§2.4 实测这一条是结构性成立的 (ML Kit 的 word 从 line 里抽),但必须写成断言而不是依赖——它同时顺手挡住了字符级 识别错误:heerbeer 这类被纠正过的词,纠正后的 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:8nowUtc(),数据层 (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 ≥ 25line_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-400Option<String> 原样下发,无过滤
cloze.ts:194-199sourceIdx = null → 归入 orphan 桶,排在 position list 尾部。卡面照常挖空
useReviewStore.ts:296-317src === 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/374createReadingPage 时不传 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 知情)

  1. OCR 语境行落在页级删除的清理盲区里。 级联键是 source_url,NULL 匹配不上任何东西 ⇒ 在 RVH 删掉某张扫描页,不会带走它贡献的 cloze 行。可清理路径只剩:删词 (clear_cloze_context_for 按 word 软删,正常工作)、句级删除、以及封顶轮换。 RB 侧判断可以接受——语境的价值本就绑在词上不绑在页上,且这与 RVH 侧 OCR 页 source_ref IS NULL 是同一个根,不是本次新增的破口。
  2. 多张 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_atnowUtc(),禁用裸 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 落地后跨端实测)

  1. 同一词在 RB 攒满 5 条语境 → RVH 拍一张含该词的页 → 两端各 sync 两轮 → 双端都收敛到同一份 5 条,且被淘汰的是真正最旧的那条(这一步同时验 C3:若时区错,被淘汰的会恒定是 RB 那条)。
  2. 造一条违反 C1 的行(sentence 不含 surface)手工写入 RVH → sync → 确认 RB 侧该词的 语境切换器 n/NN 少了 1,而池子活跃行数是 5 ⇒ 复现「静默占位」,据此确认闸门必要性。
  3. RVH 删掉那张扫描页 → 确认 cloze 行仍在(§3.1 第 1 条的既有行为,别当 bug 报)。