Skip to content

22 — RVH 新增本地 cloze 采集(OCR 语境句):跨端评审请求

物理位置:本文件在 RVH 仓库 ~/reading_vocab_helper/docs/cross-end/22-rvh-ocr-cloze-context-review-request.md。 关联:红线 #5g(已改写为双端共写)· docs/cross-end/17-rvh-cloze-context-sync-confirmation.md(RVH 侧镜像完成确认)· RB ~/reading-browser/docs/cross-end/16-rb-cloze-context-sync-handoff.md(RB 先行交接)

创建:2026-08-15 · 补实测 2026-08-17 · 状态:✅ 已裁决(08-17)+ ✅ RVH 侧已落地(08-26);⏳ 跨端真机实测待做 上位计划:rvh/docs/plans/scan-ocr-positioning-enhancement-plan.md Task H


⬆️ 裁决结果(2026-08-17,RB 侧)—— 先读这一段

回执全文:~/reading-browser/docs/cross-end/23-rb-ocr-cloze-context-decision.md

本文件的问题裁决
Q1 池子归属双端共写,互相挤占。池子按 word 单键组织,从设计上就不存在「RB 的池」
Q2 噪声容忍度方案 A + §5 三规则闸门,附四条硬条件 C1–C4。不退 D
Q3 source_url NULL✅ 不影响 RB 渲染,且 NULL 是唯一正确取值。🚫 禁止造合成 URL
Q4 条文改写RB CLAUDE.md 已改;RVH 红线 #5g 已于 2026-08-26 改写

§2.1 / §2.2 两处本文件自行排除的风险,RB 复核同意排除。但复核发现本文件未列出的一条硬伤

🔴 C3 —— created_at 时区偏置。它是两端 reconcile 的排序键兼「留新删旧」的唯一依据。 RB 写 UTC RFC3339,而 RVH 采集层(filter_confirmation_notifier.dart:98/788)用的是裸 DateTime.now() → 无偏移的本地时区串。UTC+8 下字典序 18: > 10:RVH 的行恒定显得比 同时刻 RB 的行晚 8 小时,淘汰规则悄悄变成「留 RVH、删 RB」,且无任何报错或不一致可被观测。 修法:cloze 写入走 lib/core/utils/datetime_utils.dart:8nowUtc()不要走 notifier 层那个。

四条硬条件全文见回执 §2.2,已镜像进 RVH CLAUDE.md 红线 #5g。缺任何一条则退回方案 D(不采集), 而不是「先上线再补」。

✅ RVH 落地回执(2026-08-26)

代码见 ocr_cloze_gate.dart(闸门)/ filter_confirmation_notifier::_saveOcrClozeContexts(收集 + 跨页择优) / cloze_pool_writer.dart::insertAll(池语义,逐条镜像 RB crud.rs::insert_cloze_context)。 C1–C4 逐条对账、以及实现中比条文更细的三处(断词的两个方向 / 主列阈值基准 / line_x 列不可直接用), 写在 rvh/docs/plans/scan-ocr-positioning-enhancement-plan.md Task H「落地记录」。

回归 47 例(闸门 32 + 池语义 15),三处注入缺陷验证会红。push 侧如评审所料零改动

✅ 本端真机验证(2026-08-27,Android):同一页 CNN 文章 4 轮收割 → 27 词入库 / 27 词拿到语境, 30 条行逐条复核 C1(sentencesurface)、长度 ≥25、created_at UTC、source_url NULL 全部合格; 第 4 轮重跑 新增 0 条,幂等去重生效。过程中挖出并修掉两个闸门缺陷(破折号被当断词、主列规则 输入信号不可信 → 默认关闭),详见 rvh/docs/plans/scan-ocr-positioning-enhancement-plan.md Task H。 ⚠️ RB 侧知情项:三规则闸门中的「主列」一条已默认关闭,长度 + 连字符 + C1 两条断言仍在。

📄 正式回执已发出35-rvh-ocr-cloze-context-confirmation.md (C1–C4 对账 + 4 条 RB 需知情项 + 数据形态 + 仍欠事项)。

仍欠回执 §7 的三步跨端真机实测(需双端设备 + 同一账号),完成后回写本节与 doc 35。

⚠️ 下文 §3「需要 RB 侧裁决的问题」与 §6「若否决的退路」保留原样仅供追溯,不再是待决状态。


0. 一句话

RVH 想让 OCR 采集的词也带上真实语境句(写 word_cloze_contexts),这直接违反红线 #5g 的 「RVH pull-only:不新增本地采集路径」。本文件把动机、现状核实、风险点摊开,请 RB 侧裁决是 放开、还是走「本地 only 不上行」的退路。评审通过前 RVH 不动代码。

2026-08-17 修订:补入真机实测(§2.4)。实测把两件事钉死了 —— ① 提取方案换掉了:不做整页切句,直接用逐词已存的 ocr_word_positions.line_text, 工作量小一个量级(§5 已按此重写); ② Q2「OCR 噪声会不会污染池子」不再是空口担忧:拍照场景 80% 行文本可通过三规则闸门, 被挡掉的 20% 有明确可判定特征。


1. 为什么要做

RVH 复习卡已分裂成双范式(context_drawer_content.dart):

词的来源卡面(正面)抽屉(背面)
RB 双击查词web 快照语境范式:原句 carousel + 挖空 + sense_gloss 联动义项页
RVH OCR 采集原图聚光灯(不弱)词典范式:策展例句 / 裸词

差距只在背面这一处,根因是 word_cloze_contexts 在 RVH 全库只有一个写入点 —— sync pull (sync_repository_impl.dart:1289)。历史上 v10 删过 context_sentence 提取(注释写「功能效果 不理想」),但那时 cloze 还不存在,代价看不见;v57 上 cloze 后代价显形:用相机采的词在复习端 系统性差一档

RVH 侧数据其实已就位:SourceOcrDataocrText + 带位置的 ocrWordscreateReadingPage() 已接受 ocrText 参数;入池闸门 8..220 字符与 RB insert_cloze_context 同值 (cloze_builder.dart:29-30)。


2. 现状核实(含两个被推翻的猜想)

立项时的两处担心,读双端代码后均不成立,先排除以免评审跑偏:

2.1 ❌「client 时钟漂移会让两端选出不同幸存行」—— 不成立

两端 reconcile 都是 ORDER BY created_at ASC, id ASC (RVH sync_repository_impl.dart::_reconcileClozePool / RB pull.rs::reconcile_cloze_pool:870)。 排序键是已存储的值,两端读到的是同一批字符串 → 排序结果必然一致 → 幸存行一致。

时钟漂移影响的是「哪句语义上更早」,不影响两端是否一致。这与红线 #5d 的 watermark 场景不同 (那里比的是两端各自的 now)。

2.2 ❌「新采的句会被 reconcile 立刻删掉」—— 不成立,方向反了

两处封顶都是留新删旧

  • RB insert_cloze_contextcrud.rs:75-83):满池 → 软删最旧 LIMIT count - cap + 1 → 再插新行
  • 两端 reconcile:ORDER BY created_at ASC LIMIT count - cap 删掉最旧的超额行,留下最新 5 条

所以新采的 OCR 句会正常入池,被挤掉的是最旧的。两处策略一致。

2.3 ✅ push 侧无需改动(这条影响工作量估算)

_pushWordClozeContexts 的 dirty-check(sync_repository_impl.dart:1181-1188没有任何来源 过滤synced_at IS NULL 的行一律上行。RVH 自建的行会自动被推上去,零 push 侧代码改动

⇒ 红线 #5g 里「push 只传播 reconcile / 删除产生的墓碑」是对当前行为的描述(因为 RVH 从不 建行),不是一道被代码强制的闸门。这也意味着:一旦本地采集上线,上行是默认发生的, 不存在「先本地试试看」的中间态 —— 除非显式加过滤(见 §4 方案 B)。

2.4 ⭐ 真机实测:提取方案应该换一个(本节推翻本文档原先的实现设想)

样本:开发机相机实拍的装订书页(The House on Mango Street p63),走完整生产 OCR 管线 (ML Kit + 相机增强 + 手工裁剪),收割 49 个词位 / 30 条不同行文本。该样本集齐了拍照场景的 全部难点:书脊弯曲导致行倾斜、左侧邻页文字侵入、真实连字符断词(base- / ball)、 页脚 running head + 页码、斜体外文词。

测量结果

指标数值
目标词字面出现在自己的 line_text49/49 = 100%
line_text 通过现有 8..220 字符闸门48/49 = 98%
行末连字符断词3/49 = 6%
来自左侧邻页2/49 = 4%
字符级识别错误30 行里 2 处(beerheer、多出一个 Palm 1 Sunday

结论一:字符级质量不是瓶颈,版面才是。 弯曲页面上的词位框依然精准落位,识别错误率低。 真正的问题是三类版面结构问题,其中两类可确定性解决、一类是硬伤:

问题实测证据可解性
连字符断词...crazy base- / ball bats with thorns... → 收进词表的是 ball 而非 baseball好解:行末 - + 下行小写开头 → 合并
邻页侵入le with a rag bedrink your Koo 进了词表好解:line_x 干净分离(邻页 −2 / 302,正文 700+)
弯曲致 y 序不可靠a weddingline_y=3693,却属于 line_y=3611 那一行硬伤,解不动

结论二(关键):整页切句这个问题根本不需要解。

cloze 只需要目标词所在的那一句,不需要全页 reading order。而 ocr_word_positions.line_text 已经逐词存了所在行的完整文本(schema 里一直有,NOT NULL), 现成可用:

ordinary   → "They're not like ordinary playing cards, these cards."   ← 完整句
recognize  → "I saw it before and recognize the music and wish I could"
plastic    → "go sit on the plastic couch with Ernie and the baby, but"
mattress   → "bees and this a mattress of luxury. You will go to a"

坏样本也很清楚,且都能被闸门挡掉:

ball       → "ball bats with thorns. ..."   ← 连字符断词,词本身就错了
rag        → "le with a rag be"             ← 邻页碎片,16 字符
wedding    → "a wedding"                    ← 9 字符,过了 8 字符闸门但零语境价值

加一道三规则闸门length ≥ 25line_x 落在正文列 ∧ 行末非连字符):

39/49 = 80% 通过,且通过的那些质量都能直接用。

⚠️ 样本局限:单本书、单张照片、单次拍摄。上述比例是指示性的,不是统计结论。但 「目标词 100% 出现在自己的 line_text 里」这条是结构性的(ML Kit 的 word 就是从 line 里抽的), 不依赖样本。


3. 需要 RB 侧裁决的问题

Q1 —— 池子的语义归属:RB 的池,还是双端共享的池?

现状是「RB 采集、RVH 消费」。放开后变成双端共写同一个 5 条上限的池子。 这是个产品问题不是技术问题:用户在 RB 读到的句子和在 RVH 拍到的句子,是否应该互相挤占?

RVH 侧倾向:应该。语境池的价值是「你在哪读到它的」,两端都是真实阅读现场。

Q2 —— OCR 句质量会不会污染池子?(已有实测数据,见 §2.4

立项时这是 RVH 侧最没把握的一条。跑完真机实测后,它变成了一个有数可谈的问题:

  • 拍照场景下 80% 的行文本可通过三规则闸门,通过的部分质量可直接用
  • 被挡掉的 20% 有明确、可判定的特征(太短 / 邻页 / 连字符断词),不是"说不清的噪声"
  • 字符级识别错误率低(30 行 2 处),且不影响目标词本身的可读语境
  • 截图 / 分享正文场景没有上述任何一类问题(无弯曲、无邻页、屏幕文字不断字)

RB 的句子来自 DOM 文本,仍然更干净。需要 RB 侧表态的是容忍度

选项说明RVH 侧评价
应用层质量闸门(推荐)§2.4 的三规则,纯客户端,不改 schema成本最低;80% 通过率可量化、可回归测试
来源标记加列区分 OCR 来源⚠️ 远端 user_word_cloze_contexts source_platform 列,加列 = 三端 schema 变更 + migration,成本显著
不缓解接受噪声,靠 §2.2 的「留新删旧」自然轮换不推荐:坏句同样会占掉 5 个坑位之一
仅放开截图 / 分享正文,拍照不入池source_type 区分,image 来源不写 cloze折中方案。代价是把价值最大的场景(纸质书)排除在外

Q3 —— source_url 为 NULL 是否影响 RB 侧渲染?

OCR 句没有 URL。RVH 侧写 NULL。RB 的 review/popup 是否有 source_url 非空假设?

Q4 —— 红线 #5g 条文改写

若放开,#5g 的「RVH pull-only:不新增本地采集路径(RVH 无阅读/双击查词流)」一句失效,需双端 CLAUDE.md 同步改写。RVH 侧不单方面改跨端红线,等 RB 侧确认措辞。


4. 方案选项

方案说明RVH 侧评价
A. 放开,双端共写RVH 采集 + 正常上行,与 RB 句同池竞争价值最大;Q2 的风险现已可量化(§2.4)
B. 本地 only,不上行RVH 采集但 push 侧显式过滤掉本地建的行(需新增来源标记或本地列)拿到本端体验提升,不污染 RB;但「本地列」本身是新的跨端不对称,且 §2.3 说明这需要额外加过滤代码
D. 分场景放开仅截图 / 分享正文入池,拍照(source_type='image')不入噪声风险接近零,但排除了价值最大的纸质书场景
C. 否决维持 pull-only,OCR 词继续走词典范式复习端不对称保持现状

RVH 侧倾向:A + §2.4 的三规则质量闸门(不加列、不改 schema,纯应用层过滤)。 理由:成本最低、不引入三端 schema 变更,且闸门效果已在真机样本上量化(80% 通过, 拒收项特征明确)。若 RB 侧对噪声零容忍,优先退 D 而非 B —— D 不需要新增任何列或过滤 代码,只是按已有的 source_type 分流。


5. 若批准(方案 A)的 RVH 侧待办

⚠️ 本节已按 §2.4 的实测结论重写。 早先的设想是「新增 OcrSentenceExtractor,从整页 ocrText 里切句」——实测表明那是在解一个不必要且解不动的问题(整页 reading order 在 弯曲书页上不可靠)。改用逐词已存的 line_text,工作量小一个量级,质量反而更可控。

  1. 取语境 = 读 ocr_word_positions.line_text,不做整页切句。该列 schema 里一直存在且 NOT NULL,收割时已随词位一起写入 —— 无需新的提取管线,也无需给 image 来源补 reading_pages.ocr_text
  2. 质量闸门(新增,纯函数,可单测):
    • length ≥ 25(比现有 8..220 更严:8 字符能过但零语境价值,实测有 "a wedding" 这类)
    • line_x 落在正文主列(按本页所有 line_x 聚类取主簇,滤掉邻页侵入)
    • 行末非连字符(断词行的目标词本身就是错的,如 base-/ball
    • 可选增强:与下一行合并以补全句子(line_y 相邻 + 当前行无句末标点)
  3. 收割 commit 时为每个入库词写 word_cloze_contextsword 走 lemmatizer 归一(红线 #9)、 surface该行里的原始形(C1:不用拼写纠错后的形式,且写前断言 sentence 整词含 surface)、sentence = 闸门通过的 line_textsource_url NULL、created_atnowUtc()(C3,裁决新增:裸 DateTime.now() 会让 RVH 的行恒定挤掉 RB 的行)。 每 word 每次收割最多 1 条(C2,按闸门得分取最优行)。 闸门未通过 → 不写(宁可没有语境,也不要坏语境占掉 5 个坑位之一)。
  4. 双端 CLAUDE.md #5g 条文改写(Q4)。
  5. 回归测试:
    • 闸门的接受/拒收(用 §2.4 的真实样本做 fixture:ordinary 通过、ball/rag/wedding 拒收)
    • ClozeBuilder 能在 line_text 上挖空目标词(实测 100% 命中,需锁住)
    • 双端 reconcile 后收敛到同一 ≤5 池

相比原方案省掉的:整页 rawText 切句、reading order 重建、去连字符拼接、页眉页脚剔除 —— 这些在 line_text 方案下全部不需要。


6. 若否决(方案 C)的退路

RVH 侧不做任何事,rvh/docs/plans/scan-ocr-positioning-enhancement-plan.md Task H 标记为 「跨端评审否决」并保留分析,供日后重议。复习端双范式不对称作为已知且已接受的产品现状 记录在案 —— 至少不再是「没人注意到」的状态。