主题
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 侧怎么落的 | |
|---|---|---|
| C1 | sentence 整词含 surface,surface 存该行原始形 | 落成两条断言:① normalize(surface) == normalize(word);② 直接调 ClozeBuilder.buildContextCloze(= RB cloze.ts 的 RVH 镜像)验可挖空性 —— 用你们将要跑的同一套算法,而不是自写近似正则 |
| C2 | 每 word 每次收割最多 1 条 | 口径按「每次收割」而非「每页」:闸门在每张照片内择优后,notifier 再跨照片择优一次(一次收割可含多张图) |
| C3 | created_at 走 UTC | nowUtcIso();ClozePoolWriter 的时钟参数只为测试注入确定性时间,生产路径绕不开。有专门用例断言 parsed.isUtc,注入裸 DateTime.now() 会红 |
| C4 | 闸门必须有纯函数单测 | 47 例(闸门 32 + 池语义 15)。三处注入缺陷验证会红:裸 DateTime.now() → 1 例;封顶少腾一位 → 4 例;删复活分支 → 1 例 |
池语义逐条镜像 crud.rs::insert_cloze_context(ClozePoolWriter.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 ≥ 25 ∧ line_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)sentence是 OCR 行片段,不是完整句 —— 真机样本长度 29–40 字符,如"his first international media interview"/"elusive. He spoke about the starvation"。 过了length ≥ 25且都能被目标词整词命中,但句首句尾常在语义中间断开surface存该行原始形,含正常屈折(endure/endured、friend/friends、speak/speaking)- 残留 OCR 噪声可见但不影响目标词,例如
"since his release nearly a year ag0, and"(ago→ag0)—— 这是 doc 23 §2.1 认定为可容忍的那类 - 同一页多次收割会产生近似重复行:去重是精确串匹配,而两次拍摄的 OCR 结果可能差一个字符 (
Gilboa/Gilb0a)⇒ 视作不同句各占一坑。封顶 5 兜住上限,不认为需要处理
4. 本端真机验证结果(Android,同一页 CNN 文章 4 轮收割)
| 项 | 结果 |
|---|---|
| 词覆盖 | 27 词入库 → 27 词拿到语境 |
C1(sentence 整词含 surface) | 30/30 ✓ |
| 长度 ≥ 25 | 30/30 ✓ |
created_at UTC | 30/30 ✓ |
source_url NULL | 30/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 的跨端三步实测
需双端设备 + 同一账号,尚未做:
- 同一词在 RB 攒满 5 条 → RVH 拍含该词的页 → 两端各 sync 两轮 → 双端收敛到同一份 5 条, 且被淘汰的是真正最旧那条(同时验 C3:若时区错,被淘汰的会恒定是 RB 那条)
- 手工造一条违反 C1 的行 → sync → RB 侧语境切换器
n/N的 N 少 1 而池子活跃行是 5 ⇒ 复现「静默占位」 - 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 已同步改写。