Skip to content

35 — RVH OCR 语境采集落地回执

物理位置:本文件在 RVH 仓库 ~/reading_vocab_helper/docs/cross-end/35-rvh-ocr-cloze-context-confirmation.md。 RB 会话用绝对路径读:Read ~/reading_vocab_helper/docs/cross-end/35-rvh-ocr-cloze-context-confirmation.md

对应裁决:~/reading-browser/docs/cross-end/23-rb-ocr-cloze-context-decision.md(2026-08-17,批准方案 A + C1–C4) 原始请求:22-rvh-ocr-cloze-context-review-request.md

创建:2026-08-27 · 状态:RVH 侧已落地并通过本端真机验证;⏳ 欠 doc 23 §7 的跨端三步实测


0. 一句话

RVH 的 OCR 采集路径已落地(commit d3d6255),C1–C4 逐条对账通过,本端真机验证 27 词入库 → 27 词拿到语境。本地已有 30 条 synced_at IS NULL 的 RVH 自建行等待上行, RB 下次 sync 就会看到它们。

RB 需要知情的有 4 条,都在 §2,其中 §2.1(三规则闸门里「主列」一条已默认关闭) 和 §2.3(RVH 不挖空 ⇒ 本端看不出 C1 违规)与你们 doc 23 §6 的待办直接相关。


1. C1–C4 对账

条件RVH 侧怎么落的
C1sentence 整词含 surfacesurface 存该行原始形落成两条断言:① normalize(surface) == normalize(word);② 直接调 ClozeBuilder.buildContextCloze(= RB cloze.ts 的 RVH 镜像)验可挖空性 —— 用你们将要跑的同一套算法,而不是自写近似正则
C2每 word 每次收割最多 1 条口径按「每次收割」而非「每页」:闸门在每张照片内择优后,notifier 再跨照片择优一次(一次收割可含多张图)
C3created_at 走 UTCnowUtcIso()ClozePoolWriter 的时钟参数只为测试注入确定性时间,生产路径绕不开。有专门用例断言 parsed.isUtc,注入裸 DateTime.now() 会红
C4闸门必须有纯函数单测47 例(闸门 32 + 池语义 15)。三处注入缺陷验证会红:裸 DateTime.now() → 1 例;封顶少腾一位 → 4 例;删复活分支 → 1 例

池语义逐条镜像 crud.rs::insert_cloze_contextClozePoolWriter.insertAll):句形闸 (含空白 + 8..220)→ 按 (user_id, word, sentence COLLATE NOCASE) 去重、命中软删行则复活 → 满池先软删最旧 count - cap + 1 条 → 插新行(updated_at / synced_at 留 NULL)。 跑在真实 DDL 上、测的是生产代码本身(不是转录 SQL)。

push 侧零改动,与你们 §4.1 的判断一致。


2. RB 需要知情的 4 条

2.1 ⚠️ 三规则闸门里的「主列」一条,RVH 侧默认关闭

doc 23 §2.3 认可的三规则是 length ≥ 25line_x 落正文主列 ∧ 行末非连字符。 中间那条实测不可用,已改为默认关闭OcrClozeGate.enableMainColumnRule = false, 实现与单测整套保留,随时可开)。

原因是输入信号本身不可信:RVH 的词位 x 是从 ML Kit 的行框按字符比例插值出来的 (wordX = lineLeft + lineWidth × charStart/len),而真机实测发现行框左边缘会大幅报偏 —— 一页 21 行里 18 行正文的左边缘挤在 55–59,另 3 行落在 200/275/298,打开逐行日志后:

栏外行 x=298: "E N World"                              ← CNN 页眉,该拒
栏外行 x=275: "elusive. He spoke about the starvation" ← 正文,误伤
栏外行 x=200: "Gaza, where they spent most of their"   ← 正文,误伤

后两行在原图上与其余行左对齐,是 ML Kit 的行框报偏了 140–240px(占版面宽 15–25%)。

而收益一侧同样站不住:doc 22 §2.4 那份邻页侵入实测里,两条侵入碎片 ("le with a rag be" 16 字符 / "drink your Koo" 14 字符)本来就过不了 length ≥ 25。 ⇒ 代价可测、收益未被证实,故关闭。真出现「长句邻页侵入」的样本再开。

这一条属于闸门内部组成,不改变 C1–C4 任何一条的语义。若 RB 认为「主列」是批准的 硬性组成部分而非工程细节,请回执说明,我们再议。

2.2 连字符判据比原文收紧了:必须紧贴词尾

原判据只看行末字符,实测踩了一个反例:整页唯一以 - 结尾的行是 "months that have followed his release -" —— 那是空格后的破折号标点,不是断词。 后果是双份的:① 误拒 months(词根本没被截断);② pageHasHyphenBreak 被置真, 连带按「行首 token」规则拒掉 5 个词。一个宽判据吃掉了 27 词里的 6 条语境。

现判据:\w[-‐‑–]$(排版断词永远是 word- 不带空格)。

另:doc 23 §2.3 说「行末连字符是断词唯一拦截点」——只对前半截成立。后半截 (ball bats with…)所在行末尾并没有连字符,C1 也挡不住它(ball 确实字面在句里)。 RVH 的实现补了一条:目标词是行首 token 且本页存在断词行 → 拒。判据只能到这一步为止 —— 要精确知道谁接在谁后面就得依赖整页 y 序,而那正是弯曲书页上不可靠的东西, 故故意从宽拒收,代价由 C2 兜住(同词的其它词位仍可胜出)。

2.3 🔑 RVH 不挖空 ⇒ 本端看不出 C1 违规(与你们 §6 的待办直接相关)

RVH 的复习卡只有揭晓侧:卡面正面是拍摄原图 + 词位聚光灯,抽屉里语境句完整显示、 目标词高亮,不遮空(context_drawer_content.dart:79 _clozeBlankMode = false, 2026-07-11 产品决策「小屏不挖空」)。RB 的「挖空页 → 揭晓页」两阶段 RVH 没有。

由此得到一条对 C1 的重要认识:

buildContextCloze 返回 null 时,RVH 抽屉只是退化成纯文本、句子照样完整显示, 本端不会暴露任何异常;而同一条行在 RB 那边是「占着封顶 5 的坑位却完全不可见」。 ⇒ C1 是替对端把的关,本端显示正常不构成它合格的证据。

这也是我们把 C1 做成写前断言、而不是依赖「结构上应该成立」的原因。 供你们判断 doc 23 §6 那条待办(给 pull_cloze_contexts 加「不可挖空即不入库」的受侧兜底) 时参考:受侧兜底在 RVH 这边是有价值的,因为本端 UI 天然不会把问题显出来。

2.4 created_at 字面格式两端不同(不影响排序,记录在案)

RB 写 2026-08-26T09:02:20.132146+00:00,RVH 写 2026-08-26T13:23:04.585835Z —— 都是 UTC,且共享 YYYY-MM-DDTHH:MM:SS.ffffff 前缀,字典序在毫秒粒度上仍与时间序一致。 差异只在后缀(+ = 0x2B < 数字 < Z = 0x5A),只有在小数部分完全相同时才影响并列次序, 此时由 id 断序、且两端读的是同一批字符串 ⇒ 结果一致,不发散。

⚠️ 但这意味着 C3 的校验**不能写成「字符串以 +00:00 结尾」**这类字面判断。 RVH 侧的用例断言的是 DateTime.parse(...).isUtc


3. 数据形态(RB 拉到这些行时会看到什么)

  • source_url 恒为 NULL(按 doc 23 §3,未造合成 URL)
  • sentenceOCR 行片段,不是完整句 —— 真机样本长度 29–40 字符,如 "his first international media interview" / "elusive. He spoke about the starvation"。 过了 length ≥ 25 且都能被目标词整词命中,但句首句尾常在语义中间断开
  • surface 存该行原始形,含正常屈折(endure/enduredfriend/friendsspeak/speaking
  • 残留 OCR 噪声可见但不影响目标词,例如 "since his release nearly a year ag0, and"agoag0)—— 这是 doc 23 §2.1 认定为可容忍的那类
  • 同一页多次收割会产生近似重复行:去重是精确串匹配,而两次拍摄的 OCR 结果可能差一个字符 (Gilboa/Gilb0a)⇒ 视作不同句各占一坑。封顶 5 兜住上限,不认为需要处理

4. 本端真机验证结果(Android,同一页 CNN 文章 4 轮收割)

结果
词覆盖27 词入库 → 27 词拿到语境
C1(sentence 整词含 surface30/30 ✓
长度 ≥ 2530/30 ✓
created_at UTC30/30 ✓
source_url NULL30/30 ✓
幂等同页重跑日志 新增 0 条
C2(每词每收割 1 条)✓(出现 2 条的 3 个词来自不同批次,句子确因 OCR 差异而不同)

flutter analyze lib test 0 error / 0 warning;flutter test 1249 passed / 5 skipped。


5. 仍欠 —— doc 23 §7 的跨端三步实测

需双端设备 + 同一账号,尚未做:

  1. 同一词在 RB 攒满 5 条 → RVH 拍含该词的页 → 两端各 sync 两轮 → 双端收敛到同一份 5 条, 且被淘汰的是真正最旧那条(同时验 C3:若时区错,被淘汰的会恒定是 RB 那条)
  2. 手工造一条违反 C1 的行 → sync → RB 侧语境切换器 n/N 的 N 少 1 而池子活跃行是 5 ⇒ 复现「静默占位」
  3. RVH 删掉那张扫描页 → cloze 行仍在(doc 23 §3.1 既有行为,别当 bug 报)

RVH 侧随时可配合,缺的是双端设备同时在手的时机。


6. 需要 RB 回应的(如无异议可不回)

  • [ ] §2.1「主列」规则默认关闭 —— 是否接受作为工程细节,还是视为改动了批准范围
  • [ ] §2.3 供你们判断 doc 23 §6 那条受侧兜底待办
  • [x] docs/cross-end/README.md 编号总表加 35(索引在 RB 仓,RVH 侧无此文件)—— 2026-08-27 已登记,与 36 同批补进(本仓文件不登记,下一轮就会重号)

RVH 侧落地记录:commit d3d6255(代码)/ 84caf2f(文档); 计划 rvh/docs/plans/scan-ocr-positioning-enhancement-plan.md Task H 「落地记录」+「真机验证」两节;红线 #5g 已同步改写。