主题
Scan/OCR 端定位增强实施计划
需求来源:
USER_NOTES.md2026-08-14「Scan/OCR 端定位 — 场景盘点 + 摩擦力分析 + 增强优先级」 (该文件 2026-08-28 起转本地未跟踪,不在仓库里) 定位结论:OCR 不是 RB 的备胎,是覆盖「纸 / 封闭 app / 非文本媒介 / 半封闭」四类容器的唯一手段。 成败指标不是采集量,而是「RB 够不到的时段里学习链路是否没断」。
状态总表(2026-08-26 复核)
| 任务 | 状态 | 落地 commit |
|---|---|---|
| A 未归档 session + 归档决策后置 | ✅ 已落地 | 9c45991 |
| B 分享路径零决策 | ✅ 被 A 吸收 | 9c45991 |
C text/plain 分享入口 | ✅ 已落地(验收项由 d3318e9 补齐,见该节) | 0260892 + d3318e9 |
| D prefs key 不一致 | ✅ 已落地 | 0260892 |
| E 采集→复习闭环 | ✅ 已落地(未带 noteId,见该节) | 0260892 |
| F 书目对话框 RB 呼应 | ✅ 已落地 | a84021c |
| G scan 页「继续上次」 | ✅ 已落地 | a84021c |
| H OCR 语境句 | ✅ 已落地(2026-08-26)· ⏳ 跨端真机实测待做 | 见下 |
| I 死代码清理 | ✅ 已落地(两处残留见该节) | 0260892 |
复核证据:flutter analyze lib test 0 error / 0 warning;flutter test 1242 passed / 5 skipped。
本计划九个任务已全部落地。 唯一未完成的是 Task H 的跨端真机实测(RB 回执 §7 三步), 它需要双端设备与账号,不是代码工作。
移交产品决策、不在本计划内:scan 页 UI 权重(相机大方块主位 vs 分享底部灰字), 与「分享路径应优先于相机」的场景结论相反 —— 改它等于重排页面主操作。已记入 backlog.md。
0. 一处前期结论作废(重要)
USER_NOTES 草稿里的 P1-5「复习卡渲染 sourceImagePath」 基于错误判断,本文件作废该条并重写。
错误:草稿称「OCR 原图在复习端零渲染」。 实际:复习卡正面早已全屏渲染拍摄原图 + OCR 词位高亮 + 聚光灯呼吸动画:
review_session_page.dart:116_buildCardFace→GestureReviewCardgesture_review_card.dart:323_buildSourcePage按sourceType三分支:image→ImageSourcePage(原图 +source.positions词位 + spotlight + 缩放 + 呼吸动画)web→WebSnapshotSourcePage(RB 端 HTML 快照)text→TextSpotlightView- 空 →
NoSourcePlaceholder
- 多来源走
PageView+SourceInfoBarstepper 切换
走的数据通道是 currentWord.sources(WordPageLinkInfo.imagePath / .positions),不是 ReviewSessionState.sourceImagePath。后者才是死字段。
修正后的真实不对称(比草稿说的窄且更精确):
| 卡面(正面) | 抽屉(背面) | |
|---|---|---|
| OCR 采集词 | ✅ 原图聚光灯,体验并不弱 | ❌ 无 cloze → 词典范式(策展例句/裸词) |
| RB 采集词 | ✅ web 快照 | ✅ 语境范式(原句 carousel + 挖空 + sense_gloss 联动义项) |
→ 差异化缺口只在背面一处,即 Task H(语境句提取)。它因此从「P1 之一」升为唯一的价值型任务,其余都是摩擦力与叙事任务。原 P1-5 降级为 Task I(死代码清理,15 分钟)。
1. 设计原则(约束本计划所有改动)
原则 1:不回退「防误选」保证。docs/plans/scan-page-strip-down-three-entrance-unification.md 的三入口统一有用户明确签字:「愿意接受每次多点一次对话框的代价换取防误选 + 入口职责清晰」。它要根治的是隐式预选(以为选的是 A 书,其实预选的是 B 书)。
本计划不取消显式确认,只把它从「快门前」移到「收割时」——即从决策成本高、信息少的时刻,移到决策成本低、信息多的时刻。用户仍然必须显式指定归属,且 ConfirmButton 在未归档时禁用(confirm_button.dart:58 isEnabled 已含 hasSelectedNote),commit 不了 = 误选不了。
原则 2:顺着既有架构,不新造并行机制。reading_pages 本来就是「用户确认时才创建」(filter_confirmation_notifier.dart:309 v19 决策注释)。noteId 直到 addSelectedToNotebook 才被消费。归档后置是顺着这个既有设计,不是逆转它。
原则 3:触碰跨端红线的改动单独走评审。 只有 Task H 触及红线 #5g(RVH pull-only),单列 Phase 3 并前置跨端评审,不与摩擦力任务混排。
2. 任务分解
Task A — 未归档 session + 归档决策后置 🔴 核心
现状:scan_page.dart 三个入口 _openCapture:220 / _openGallery:277 / _handleSharedImages:410 无一例外首行调 _promptForBook()。用户按快门前必须先回答「这属于哪本书」。
已有的有利条件(本任务大部分是「打开开关」而非「造轮子」):
| 设施 | 位置 | 状态 |
|---|---|---|
CaptureSession.noteId 可空 | capture_session_entity.dart:126 | ✅ 已可空 |
startSession({String? noteId, String? bookTitle}) | capture_queue_notifier.dart:163 | ✅ 已接受 null |
| Harvest 期 noteId 空判分支 | harvest_station_page.dart:216 | ✅ 已存在(当前只报错) |
ConfirmButton 选书行 + 更换 chevron | confirm_button.dart:100-150 | ✅ 完整实现,当前被关掉 |
| 未选 note 时禁用 + 文案 | confirm_button.dart:58,206 | ✅ filterSelectNoteFirst2 l10n 已有 |
| Harvest 传参 | harvest_station_page.dart:689,701 | ⛔ showNoteInfo: false / onChangeNote: null |
改动:
capture_queue_notifier.dart新增startUnassignedSession()(或给ensureSession加allowUnassigned):- 建
noteId == null的 session
- 建
ensureSession冲突语义修正(本任务最容易出错的一处):- 未归档 session + 任意 note → 不冲突,直接 append(未归档就是"还没决定",不存在覆盖)
- 已归档 session + 同 note → append(不变)
- 已归档 session + 异 note → 冲突弹窗(不变)
scan_page.dart三入口:_promptForBook()从强制前置改为可选前置- 默认走
startUnassignedSession()→ 直接进相机/图库/入队 - 保留主动入口:HarvestBar 顶行可点 → 选 note(给「我就是想先定好」的用户)
- 默认走
harvest_station_page.dart:685ConfirmButton:showNoteInfo: true,onChangeNote: () => _promptForBook(...)_promptForBook需从scan_page.dart:86提取为可复用组件(两页共用)
harvest_station_page.dart:216的空判:从「报错返回」改为「引导选 note」(按钮已禁用,此处成为兜底)harvest_bar.dart顶行:bookTitle == null时显示「未归档 · N 张」
风险点:
capture_queue_notifier.dart:545把noteId: session.noteId传给processImage,已是可空参(photo_recognition_service.dart:31/79/128,:137 有if (noteId != null)守卫)→ 需实测确认 null 路径无副作用- session recovery(杀进程重启)后未归档 session 的展示与恢复
CaptureCameraPage(noteId:, bookTitle:)参数需改可空
验收:拍照/图库/分享三路径均可在未选 note 情况下完成到 Harvest;Harvest 未选 note 时确认按钮禁用且提示;选定后正常入库。
✅ 已落地(2026-08-15),与原计划的四处偏差:
ensureSession是删掉而不是改。原计划写「冲突语义修正:未归档 session + 任意 note = 不冲突直接 append」。实际改完发现整个「冲突」概念不复存在 —— 旧语义是「session 从出生就归属某个 note」,所以换 note 只能丢弃整队列;归档后置后 session 出生时无归属,换 note 变成assignSessionToNote的重新归档,不丢任何东西。ensureSession的最后一个调用方_resolveSession随之删除,连带_showBookConflictDialog。- ⚠️ 副作用:
photoSwitchNoteTitle/photoSwitch/photoSwitchNote{Camera,Gallery,Share}Msg五个 l10n key 变为零引用,尚未从 ARB 删除(ARB 当时与另一批在途工作混在同一文件里)。
- ⚠️ 副作用:
多数「可空化」工作量不存在:
CaptureSession.noteId、startSession(noteId:)、CaptureCameraPage(noteId:, bookTitle:)本来就可空;CaptureQueueState.hasActiveSession只看 items 不看归属;harvest_bar._buildBookTitleRow早有 placeholder 机制;ConfirmButton的选书行完整存在只是被showNoteInfo: false关掉。真正新增的只有ensureSessionForCapture/assignSessionToNote两个方法。_promptForBook提取为presentation/utils/note_picker.dart::promptForNote,scan 页与收割页共用(原计划只说「提取」,未定位置)。三处 session 创建时机后移:原写法在权限请求 / 图库选择 / 文本管线之前就建 session,用户取消或失败时会在磁盘上留下空 session。改为「确实有内容要入队时才建」。
收割页空判仍是「报错返回」,没改成「引导选 note」(原计划第 5 点)。
_handleConfirm里noteId == null仍走photoNoNoteAssociated报错 —— 因ConfirmButton已禁用,这条路径是纯兜底, 引导与否用户都碰不到。⚠️ 但那次判空排在「复制首图到reading_source_images/」之后, 真走到时会白做一次文件 IO(无害,未修)。HarvestBar 顶行显示的是「选择笔记」占位符(
filterSelectNote),不是原计划写的「未归档 · N 张」。 占位符可点即开选书对话框,比一个纯状态描述更可行动;张数已由进度行承载。
回归测试:test/features/photo_recognition/capture_session_filing_test.dart(6 例)。已用注入缺陷验证 —— 把 ensureSessionForCapture 改成无条件 startSession() 后 2 例失败。
Task B — 分享路径零决策 🔴 ✅ 已被 Task A 吸收(2026-08-15)
原计划:_handleSharedImages 去掉 _promptForBook + _resolveSession。
Task A 落地时三入口一并改为「不问归属直接入队」,本任务无独立残留工作。图片分享与文本分享现在都是零决策直达队列。
理由(保留备查):最轻的入口不该压最重的决策。公共场合举手机拍照有社交成本,分享路径本应优先于相机路径(当前 UI 恰好相反:相机是大方块主位,分享是底部灰字提示)—— 后半句的 UI 权重问题仍未处理,可在 Task G 一并考虑。
Task C — text/plain 分享入口 🟢 最高性价比
现状缺口:android/app/src/main/AndroidManifest.xml:49-62 只声明 image/* 的 SEND / SEND_MULTIPLE。用户在微信/浏览器里选中一段文字点分享,RVH 收不到。
为什么划算:文字分享跳过整个 OCR 环节,又快又准,而且下游全是现成的:
| 设施 | 位置 | 状态 |
|---|---|---|
| 文本 → 筛词 usecase | text_import_debug_page.dart:213-221 已跑通 RecognizeAndFilterUsecase | ✅ 仅 developer 页 |
| 队列接收预算结果 | capture_queue_notifier.dart:189 addPrecomputedResult | ✅ |
| 无图 item 标记 | capture_session_entity.dart:82 isTextImport | ✅ |
| Harvest 跳过无图 item | harvest_station_page.dart:192,371,521 | ✅ 三处已过滤 |
reading_pages.source_type='text' | schema v23 | ✅ |
| 复习卡文本来源渲染 | gesture_review_card.dart:336 TextSpotlightView | ✅ |
改动:
- AndroidManifest 加
text/plain的SENDintent-filter - 原生侧透传文本(现有 EventChannel 只传
List<String>路径 → 需加类型区分或新 channel) ShareReceiveService(lib/core/services/share_receive_service.dart)加sharedTextStream+getInitialSharedText()main.dart:193_resolveDestination+:244热启动监听各加文本分支ScanPage加sharedText参数 → 调 usecase →addPrecomputedResultaddSelectedToNotebook侧createReadingPage(sourceType: 'text', ocrText: <原文>)
iOS:本任务先只做 Android(Share Extension 的 iOS 侧成本见 USER_NOTES 2026-03-05 条目评估,另行排期)。
验收:微信/Chrome 选中英文段落 → 分享到 Lampio → 直接进筛词 → 收割入库 → 复习卡正面显示 TextSpotlightView 高亮该词。
✅ 已落地(2026-08-14),实现与原计划的三处偏差:
- wire 格式:EventChannel 用类型自判别而非新增 channel ——
List= 图片路径(原样不动),String= 分享文本。已上线的图片路径零改动。 - 多补一处
ocrText回填:filter_confirmation_notifier.dart的文本分支(单来源兜底,非 Task H 说的多图分支)此前不传ocrText,导致reading_pages.ocr_text为 NULL →TextSpotlightView渲染空白。补传后复习卡才真的有内容。 - 两个护栏(原计划未列):
- 裸 URL 拒收 —— 浏览器「分享页面」把 URL 塞进
EXTRA_TEXT,跑词汇管线会收出协议名和域名碎片。判据^https?://\S+$,含 URL 的正常段落有空白字符,不受影响 - 0 词提示 —— 整段词都已认识时给
photoSharedTextNoWords,否则分享看起来像什么都没发生
- 裸 URL 拒收 —— 浏览器「分享页面」把 URL 塞进
新增 l10n:photoSharedTextIsLink / photoSharedTextNoWords(en + zh;ja 是 91 行 stub,与 photoAddedWordsToNote 等既有 key 一致地跳过)。「去复习」复用既有 commonReview,未新增 key。
⚠️ 本任务的验收项直到 3 天后才真正成立 —— d3318e9(2026-08-17):真机验证时发现文本来源的复习卡 从来没有渲染过 TextSpotlightView,一律落 NoSourcePlaceholder。三层缺陷叠加,每层单独看都不报错, analyze 与单测一个都发现不了:
- 占位符逃逸(
capture_queue_notifier):buildMergedResult把文本项的imagePath(占位符字符串'text_import',不是路径)当原图路径回填 → 下游正是用originalImagePath != null判定图/文 → 纯文本会话落库成source_type='image'+image_path='text_import' - 模型漏列(
reading_page_model):toDatabase()/fromDatabase()都没有ocr_text(v25 随列删除, v40 把列加回来时模型没跟上)→ 上面补传的createReadingPage(ocrText:)一直被静默丢弃。 🔴 两个方向必须同时补:只补写入方向的话,任何一次 model 往返(读→改→写)都会把跨端同步来的ocr_text抹成 NULL - 加载器只认整本全文(
review_source_loading_service):只在 note 有fullTextPath(电子书导入产物) 时才返回来源,从不读 page 级ocr_text→ 挂在跨端同步来的笔记下必然落空
教训:本任务的下游「全是现成的」这个判断在数据形状上成立,在字段真的通到底上不成立 —— 表格里那些 ✅ 是「有这个设施」,不等于「这条路走得通」。
Task D — recent_note_id / selected_note_id key 不一致(bug)🟠
缺陷:收割成功后 harvest_station_page.dart:241 写 recent_note_id,但 scan 页选书对话框 scan_page.dart:88 读的是 selected_note_id。对话框的默认值学不到用户实际收割去向,一直停留在「上次在对话框里点过的」。
第三处 filter_confirmation_page.dart:180,193 用 recent_note_id,第四处 selected_reading_note_service.dart:7 用 selected_note_id —— 四个调用点两套 key。
改动:统一到 SelectedReadingNoteService 单点封装,全部调用点走它。先确认 SelectedReadingNoteService 现有语义再定保留哪个 key 名,别直接改 key 字符串(存量用户的 prefs 会失忆一次,可接受但要有意识)。
✅ 已落地(2026-08-14,commit 0260892):保留 selected_note_id(scan 页高频写入,存量值最新鲜 ⇒ 统一到它不会让用户的默认选项失忆),废掉 recent_note_id。4 个调用点全部改走 selectedReadingNoteServiceProvider,lib/ 内已无裸 prefs 读写该 key 的代码。
Task E — 采集 → 复习闭环(叙事断点 2)🟠
现状:scan_page.dart:492-498 收割成功只弹一个 snackbar(photoAddedWordsToNote)就结束了。采集与复习之间没有叙事衔接。
改动:snackbar 加 action —— 「去复习」→ 跳 ReviewOverviewPage(可带该 note 的 groupId)。数据现成:_openHarvestStation 返回值已含 wordCount + bookTitle(harvest_station_page.dart:258-260),只需补 noteId。
成本:< 1 小时。
✅ 已落地(2026-08-14,commit 0260892),一处偏差:
- 没带 noteId / groupId,跳的是通用
ReviewOverviewPage。原计划写「可带该 note 的 groupId…只需补 noteId」 —— 实际落地按「先接上叙事,不额外造筛选态」处理。snackbar duration 从默认 2s 提到 4s(够不到的 action 等于没有 action);跳转用pushReplacement与底部导航一致,避免 Scan→Review→Scan 堆栈。 若日后要按 note 收窄复习范围,是独立的一次改动,不算本任务欠账。
Task F — 书目对话框 RB 呼应 🟠
现状:book_selection_dialog.dart(514 行)不区分 RB / RVH 来源 —— 两端 reading_notes 早已是同一张表,UI 上看不出来。last_opened_at(红线 #6d 专门同步的字段)CLAUDE.md 自承「当前无 UI 消费方」。
改动:对话框每行补来源与新鲜度徽标:📖 RB · 2 小时前读过
- 来源判定:
reading_notes.note_type(webArticle/newsArticle多为 RB)或子页reading_pages.source_type='web'存在性 - 新鲜度:
SELECT MAX(last_opened_at) FROM reading_pages WHERE note_id = ? AND deleted_at IS NULL(红线 #6d 给的正是这个查询) - 排序:最近读过的排前
价值:两端从「两个 app」变成「一个系统」,并为 Task G 提供数据。
✅ 已落地(2026-08-15),三处偏差:
- 来源判定改用
last_opened_at本身,不用note_type。原计划说「webArticle/newsArticle多为 RB」—— 不成立:RVH 自己的文本导入路径(text_import_debug_page)也会造NoteType.webArticle。而last_opened_at按红线 #6d 是「仅 RB touch 路径写入、RVH pull-only」,有值 ⟺ 在 RB 上被读过是确定的。一个字段同时承担来源与新鲜度。 - 文案不出现「RB」:复用阅读笔记列表页的
readingNotesLastRead("最近阅读 {time}")。① 两处界面对同一件事该说同一句话;② 品牌已统一为 Lampio,在 UI 里露出「RB」这个内部简称是错的;③ RVH 本无阅读功能,"最近阅读"隐含的就是那一端。 - 顺带删掉一个既有 bug:
_loadMoreBooks()是坏的 ——getAllBooks()没有 limit/offset,一次返回全部笔记,"加载更多"再调一次拿到同一份列表并addAll进去;笔记数恰好 =_pageSize(20) 时_hasMore为 true,滚到底就把整个列表重复一遍。既然本就是全量,这里没有第二页可翻,分页状态与_onScroll一并删除。
依赖:本任务复用在途工作提供的 getLastOpenedAtByNote()(datasource/repo/domain 三层)与 readingNotesLastRead l10n key。
回归测试:test/features/photo_recognition/book_selection_sort_test.dart(5 例,含「未读组次序不被 Dart 不稳定 sort 打乱」专项)。为此把排序抽成顶层纯函数 sortNotesByRecentlyRead。
Task G — scan 页「继续上次」 🟡
依赖 Task F。scan 页顶部一行:继续《The Sellout》—— 你 2 小时前在 RB 读过,点击直接开相机并预设该 note。
注意:这是唯一一处会引入「预设 note」的改动,与原则 1 有张力。约束:必须显式显示书名在按钮上(用户看得见自己选的是什么),且 Harvest 期仍可改 —— 与被否决的「隐式全局预选」有本质区别。
克制边界:scan 是动作页不是 dashboard。战果反馈最多一行,不做卡片墙;RB 的阅读时长/进度/书架不搬过来。
✅ 已落地(2026-08-15):
recentlyReadNoteProvider(FutureProvider.autoDispose)取「last_opened_at最大的那本」。autoDispose 是因为显示的是相对时间,缓存住会越挂越不准;scan 页在标签间走pushReplacement,离开即释放、回来即重查,正好是刷新时机。ContinueReadingRow:一行,书名 + 「最近阅读 X 前」+ 相机图标。无数据 / 加载中 / 出错一律不显示 —— 空的「继续」槽位比没有更糟。- 与 Task A 的张力如何化解:这是唯一一条快门前就归档的路径,但它不是 Task A 的回退 —— 归档目标印在用户点的那个按钮上(不是隐藏默认值),且 HarvestStation 仍可改。这正是被否决的「隐式全局预选」所缺的性质。
- 只认领未归档的批次:若 in-flight session 已被归到别的 note,点「继续」不会把它悄悄拖过来(HarvestBar 正显示着它现在归属哪里);只开相机。
- 顺带把
_openCapture拆成_ensureCameraPermission()+_pushCameraPage()两段,与「继续」路径共用;权限检查保持在建 session 之前(否则拒权限会留下空 session,这正是 Task A 修掉的那类垃圾)。
未做(移交产品决策):Task B 留下的 UI 权重问题 —— scan 页仍是「相机大方块主位、分享底部灰字提示」,与「分享路径应优先于相机」的场景结论相反。改动它等于重排页面主操作,属产品取舍而非工程补齐,本次未动。
Task H — OCR 语境句提取 🔴 唯一的价值型任务 · ✅ 已落地(2026-08-26)
目标:把 OCR 采集词从词典范式提升到语境范式,与 RB 采集词平权(见 §0 表格)。
历史:filtered_results_notifier.dart:136 / filter_confirmation_notifier.dart:462 的 ❌ v10删除: context_sentence 提取逻辑(功能效果不理想)。v10 砍它时 cloze 还不存在,代价看不见;v57 上 cloze 后代价显形。当年用的是朴素切句,现在有 OCR 块/行结构(ocr_word_positions + word_position_calculator),可以做得更好。
数据已就位:
SourceOcrData(recognize_and_filter_result.dart:8-18)含ocrText+ocrWords(带位置)createReadingPage(reading_page_repository.dart:17-25)已接受ocrText参数,但filter_confirmation_notifier.dart:323的调用点没传 → 先补上,reading_pages.ocr_text列即被填充- 入池闸门现成:8..220 字符(
cloze_builder.dart:29-30,与 RBinsert_cloze_context同值)
改动(⚠️ 2026-08-17 按真机实测重写,原「整页切句」设想作废):
- 取语境 = 读
ocr_word_positions.line_text,不做整页切句 —— 该列一直存在且NOT NULL,收割时已随词位写入。无需新提取管线,也无需给 image 来源补reading_pages.ocr_text。 - 三规则质量闸门(纯函数,可单测):
length ≥ 25∧line_x落在正文主列(滤邻页侵入)∧ 行末非连字符。 - 收割 commit 时为闸门通过的词写
word_cloze_contexts(word走 lemmatizer 归一,红线 #9;surface存该行里的原始形;source_urlNULL)。未通过则不写 —— 宁可没有语境,也不要坏语境占掉 5 个坑位之一。
为什么换方案:真机实拍一页装订书(含书脊弯曲、邻页侵入、连字符断词、页脚)跑完整 OCR 管线,49 个词位实测 ——
- 49/49(100%)目标词字面出现在自己的
line_text里 →ClozeBuilder挖空必然命中 - 三规则闸门后 39/49 = 80% 通过,通过的质量可直接用
- 而整页 reading order 在弯曲页上不可靠(实测
a wedding的line_y=3693却属于line_y=3611那行)—— 原方案要解的正是这个解不动的问题
详见 docs/cross-end/22 §2.4。
⚠️ 红线冲突(必须先评审) → 已解除(2026-08-17):红线 #5g 原写「RVH pull-only:不新增本地采集路径」, 本任务曾直接违反该条。评审结论是放开而非豁免 —— #5g 已改写为「双端共写」,理由是池子按 word 单键组织、 无来源维度,pull-only 从来是事实分布而非数据模型约束。push 侧零改动(dirty-check 本就无来源过滤,自建行自动上行)。
当时列的评审问题与结论:
- 双端收敛 —— 仍收敛。
reconcile_cloze_pool本就是为对称双写设计的(单向 pull 只需一次 INSERT,不需要「合并后重新封顶」),RB 侧为双端共写付的代价早已付掉 - push 是否放开 —— 无需动作,见上
- OCR 噪声污染 —— 不加
source_platform区分,改用采集侧质量闸门(三规则 + C1 断言)。RB 明确 pull 侧不做校验,闸门是唯一防线 - #5g 条文改写 —— 双端均已落地
退路:C1–C4 中任何一条做不到,则退方案 D(仅截图/文本分享正文入池、拍照不入,按 source_type 分流,零成本), 不是「先上线再补」。
✅ 评审通过(2026-08-17 RB 裁决)· ✅ 代码已落地(2026-08-26)· ⏳ 跨端真机实测待做。
- 请求:
docs/cross-end/22(2026-08-15 创建 / 08-17 补实测) - 回执:
~/reading-browser/docs/cross-end/23-rb-ocr-cloze-context-decision.md(2026-08-17) - 红线 #5g 已按裁决改写为双端共写(2026-08-26),四条硬条件全文镜像在那里
- 落地回执:
docs/cross-end/35(2026-08-27 发出)
裁决:批准方案 A(双端共写),不退 D,附四条硬条件 —— 缺任何一条则退回 D(不采集), 而不是「先上线再补」:
| 条件 | 违反后果 | |
|---|---|---|
| C1 | sentence 整词含 surface,surface 存该行原始形(不用纠错后的形式),写前断言 | 该行占掉封顶 5 的一个坑位却在 RB 侧完全不可见,还挤掉一条可用的 RB 句(唯一的静默型代价) |
| C2 | 每 word 每次收割最多 1 条 | 同页重复词最坏吃掉 5 个坑位,把该词历史语境整体清空 |
| C3 | created_at 走 nowUtc(),禁裸 DateTime.now() | RB 新发现、本计划原先没列:UTC+8 下 RVH 的行恒显晚 8 小时,「留新删旧」退化成「留 RVH 删 RB」,无任何报错可观测 |
| C4 | 闸门必须有纯函数单测 | RB pull 侧无条件 INSERT、不校验质量 —— 这道闸是全系统唯一防线,无对端兜底 |
三规则闸门(length ≥ 25 ∧ line_x 落正文主列 ∧ 行末非连字符)RB 认可无修改意见; 其中行末连字符是刚性需求不是可选优化 —— C1 的断言挡不住断词行(断出的 ball 确实在句里)。 source_url 保持 NULL(🚫 禁止合成 URL)。落地后按回执 §7 做三步跨端实测并回写 doc 22。
读双端代码后,本条上文列的评审问题有两个已自行排除(写进 doc 22 §2,避免评审跑偏):
- ❌「client 时钟漂移会让两端选出不同幸存行」—— 不成立。两端 reconcile 都按已存储的
created_at ASC, id ASC排,读的是同一批字符串,排序结果必然一致。与红线 #5d 的 watermark 场景不同(那里比的是两端各自的 now)。 - ❌「新采的句会被 reconcile 立刻删掉」—— 方向反了。两端封顶都是留新删旧(RB
crud.rs:75-83满池先软删最旧再插入;两端 reconcile 删最旧的超额行)。
同时发现一条影响工作量估算的事实:_pushWordClozeContexts 的 dirty-check 没有来源过滤,RVH 自建的行会自动上行 —— push 侧零改动,且不存在「先本地试试」的中间态,除非显式加过滤。红线 #5g 里「push 只传播墓碑」是对当前行为的描述,不是代码强制的闸门。
上述两条排除 RB 复核同意。当时列的四个待裁决问题(池子归属 / 噪声缓解 / source_url NULL / #5g 条文) 已在 2026-08-17 全部裁决完毕,见本节开头的表。
✅ 落地记录(2026-08-26)
三个文件 + 两组回归测试:
| 层 | 文件 | 职责 |
|---|---|---|
| domain | ocr_cloze_gate.dart | 闸门(纯函数,无 IO / 无时钟 / 无随机) |
| presentation | filter_confirmation_notifier.dart::_saveOcrClozeContexts | 收割时收集候选 + 跨页择优 |
| data | cloze_pool_writer.dart::insertAll | 池语义:去重 / 复活 / 封顶 / C3 时钟 |
C1–C4 逐条对账:
- C1 落成两条断言,比原条文更细:①
normalize(surface) == normalize(word)—— OCR 拼写 纠错改过的词在这里被拒(heer→beer:句里是heer,要存的是beer);② 直接调ClozeBuilder.buildContextCloze,即用 RB 将要跑的同一套算法验证可挖空性,而不是自写近似正则。 - C2 的口径是「每词每次收割最多 1 条」,不是每页 1 条 —— 闸门在每张照片内择优后, notifier 再跨照片择优一次。否则同一个词出现在 3 张照片上就一次吃掉 3 个坑位。
- C3 走
nowUtcIso();ClozePoolWriter的时钟参数只为测试注入确定性时间,生产路径无法绕开。 - C4 两组测试共 47 例(闸门 32 + 池语义 15),三处注入缺陷都验证过会红(裸
DateTime.now()→ C3 用例红; 封顶少腾一位 → 4 例红;删掉复活分支 → 1 例红)。
实现里比原方案细的三处(都是写代码时才暴露的):
- 断词有两个方向,「行末非连字符」只挡得住一半。 后半截(
ball bats with…)所在行末尾 并没有连字符,C1 也挡不住它(ball确实字面在句里)。判据只能到「目标词是行首 token + 本页存在断词行」为止 —— 要精确知道谁接在谁后面就得依赖整页 y 序,而那正是弯曲书页上 不可靠的东西。故故意从宽拒收,代价由 C2 兜住(同词的其它词位仍可胜出)。 - 主列聚类的阈值基准是版面内容宽度,不是行左边缘自身的跨度。 后者在单列正文上只差 十几像素,拿它当基准会把阈值缩到 2px,随便一个缩进就被判成两列 —— 第一版就是这么写的, 被「单列不该分簇」的用例当场抓住。证据不足(行数 < 8 / 不分簇 / 两簇势均力敌)时不启用该规则。
- 不能直接读
ocr_word_positions.line_x—— 那一列在写入时被置成了 word_x (_saveWordPositions里lineRect = wordRect,因为 ML Kit 给的是词级框)。行左边缘现算 = 同line_text的词位取 min(x)。
push 侧零改动(评审时已确认):dirty-check 无来源过滤,自建行 synced_at IS NULL 下一轮 sync 自动上行。删词 / 切用户的级联软删 v57 就已存在,无需补。
范围内不含文本分享:text/plain 分享与电子书导入走 _processTextInput,filteredOcrEntity 为 null ⇒ result.ocrWords 为 null,采集路径的守卫直接跳过,不会误用 OCR 的版面判据去处理 一段本来就干净的文字。⚠️ 但这恰恰是下一个容易摘的果子 —— 分享的正文没有 OCR 噪声、有真句 边界,闸门可以更简单(复用 RB 的 8..220 即可,不需要主列/连字符那两条)。要做的话是独立一件事, 别塞进本任务的闸门里。
✅ 真机验证(2026-08-27,Android,同一页 CNN 文章 × 4 轮收割)
结果:27 词入库 → 27 词拿到语境(100%),30 条 OCR 行逐条复核 C1/长度/UTC/source_url 全部合格。 第 4 轮对同一页重跑,日志 新增 0 条 —— 幂等去重在真机上生效。
⚠️ 验证过程挖出两个闸门缺陷,都是单测样本覆盖不到、只有真机版面才暴露的:
🔴 破折号被当成断词连字符(第 1 轮:27 词只有 20 词拿到语境) 判据原本只看行末字符。整页唯一以
-结尾的行是"months that have followed his release -"—— 那是空格后的破折号标点,不是断词。 后果是双份的:①months被误拒(词根本没被截断);②pageHasHyphenBreak被置真, 连带按规则 3b 拒掉 5 个行首词(david/fellow/helped/october/underground)。 一个宽判据吃掉了 7 个失败里的 6 个。 修法:连字符必须紧贴词尾(\w[-‐‑–]$)—— 排版断词永远是word-不带空格。🔴 主列规则的输入信号不可信 → 默认关闭(第 2–3 轮:卡在 26/27) 词位 x 是从 ML Kit 的行框按字符比例插值出来的,而实测发现行框左边缘并不可靠。 加了诊断日志后拿到直接证据:
21 行, 内容宽 960, 行左边缘 [55×3, 56, 57, 58×11, 59×3, 200, 275, 298], 主列 [55,59] 栏外行 x=298: "E N World" ← 页眉,该拒 栏外行 x=275: "elusive. He spoke about the starvation" ← 正文,误伤 栏外行 x=200: "Gaza, where they spent most of their" ← 正文,误伤18 行正文里有 2 行的行框报偏了 140–240px(占版面宽 15–25%),而它们在原图上明明与 其余行左对齐。收益一侧同样站不住:doc 22 那份邻页侵入实测里,两条侵入碎片 (16 / 14 字符)本来就被长度闸拦下了。⇒ 代价可测、收益存疑,改为
OcrClozeGate.enableMainColumnRule = false,实现与单测整套保留, 真出现「长句邻页侵入」样本时打开开关即可。中间还修了一处相关脆弱点:
MainColumn.contains原本用簇的精确[min,max], 而正文栏很紧(18 行挤在 55–59)⇒ 接受窗口只有 4px 宽。已改为带容差(= 聚类阈值), 与「属于同一栏」的判定口径一致。
顺带在真机上确认的既有任务:Task A 的未归档流程(图库入口不问归属、HarvestBar 显示 「Select a note」、确认按钮禁用为「Select a note first」)、Task D 的默认笔记本预选、 Task E 的 snackbar「Review」action、Task F 的「Read 8h ago」徽标与排序、Task G 的「继续」行。
✅ 抽屉渲染已确认(用户手动拖开,我截屏核验):抽屉顶部渲染出本次采到的真实读句 his first international media interview,目标词高亮,下方才是词典释义 —— 语境范式在 OCR 采集的词上生效,本任务的目标达成。
⚠️ 顺带纠正一处长期失实的文档表述:
CLAUDE.md与多处文档写的「复习卡背面抽屉挖空 真实语境句」自 2026-07-11(bec150b)起就不成立。context_drawer_content.dart:79的_clozeBlankMode = false是明确的产品决策(注释:「小屏不挖空」),RVH 只把目标词 高亮,不遮空;挖空是 RB 侧的形态(RB 先挖空页、再揭晓页,RVH 只有揭晓侧)。 该措辞已在本批一并改正。🔑 由此得到一条对 C1 的重要认识:RVH 不挖空 ⇒
buildContextCloze失败时本端只是 退化成纯文本、看不出任何异常,而同一条行在 RB 那边是「占着坑位却完全不可见」。 C1 这道闸是替对端把的关 —— 本端显示正常不构成它合格的证据。已写进红线 #5g。
⏳ 仍未做 —— 跨端真机实测(RB 回执 §7,需双端设备与同一账号):
- 同一词在 RB 攒满 5 条 → RVH 拍一张含该词的页 → 两端各 sync 两轮 → 双端收敛到同一份 5 条, 且被淘汰的是真正最旧那条(这一步同时验 C3:若时区错,被淘汰的会恒定是 RB 那条)
- 手工造一条违反 C1 的行 → sync → RB 侧语境切换器
n/N的 N 少 1 而池子活跃行是 5 ⇒ 复现「静默占位」,据此确认闸门必要性 - RVH 删掉那张扫描页 → cloze 行仍在(§3.1 既有行为,别当 bug 报)
Task I — 死代码清理 🟢
ReviewSessionState.sourceImagePath(review_session_state.dart:184,235+review_session_data_loader.dart:179):与sources冗余,删multi_source_bottom_sheet.dart/source_reveal_panel.dart:lib/ 与 test/ 内零引用,删(或明确复活理由)
✅ 已落地(2026-08-14,commit 0260892):三项全删,sourceImagePath 在 review_session_state.dart 留了一行「❌ 已删除」注释说明它与 sources 的关系(避免日后有人「补回这个明显缺失的字段」)。
补充清理(2026-08-26 复核时发现并当场做掉):
capture_queue_notifier.dart的 dartdoc 仍引用已删除的[ensureSession]→ 改指ensureSessionForCaptureRecognizeAndFilterParams.noteId从processImage一路传到底却从未被读过 —— 这恰好是 Task A 风险点 「processImage(noteId: null)路径无副作用」成立的真实原因:它压根没被读过,而不是「守卫写得好」。 整条管道已删(params 字段 +PhotoRecognitionService三个方法 +PhotoRecognitionNotifier三个方法- 2 个调用点)。
FilterConfirmationPage(noteId:)是真实消费方,保留
- 2 个调用点)。
Task A 遗留的 6 个零引用 l10n key 已从
app_en.arb/app_zh.arb删除并重新 gen-l10n (计划原记 5 个,实际还有photoSwitchNoteChangeMsg)整条
PhotoRecognitionNotifier链路删除(4 个文件) ——notifier/provider/state/state.freezed。它是 Phase 2 重构留下的一套完整 StateNotifier,四个文件互相引用、对外零消费 (全库 grep 无调用方),实际的识别入口早已是capture_queue_notifier→PhotoRecognitionService。 ⚠️ 保留photoRecognitionServiceProvider与PhotoRecognitionService本体 —— 那才是在用的管线。RecognizeAndFilterParams.sourceName与 noteId 同一种死法(声明了从不读):reading_page的 name 是收割时由filter_confirmation_notifier自己拼的'Scan <时间>',识别管线不参与命名。一并删。
清理后 flutter analyze lib test 仍 0 error / 0 warning,flutter test 1202 passed —— 无任何引用需要改写, 印证这几处确实是纯悬空代码。
3. 依赖与排期
Phase 1(低风险快赢,可并行)
D (prefs key bug) ──┐
E (采集→复习闭环) ──┤
I (死代码清理) ──┤
F (书目 RB 呼应) ──┘
Phase 2(摩擦力核心)
A (未归档 session) ──→ B (分享零决策)
C (text/plain 分享) ← 独立,可与 A 并行
Phase 3(差异化价值)
F ──→ G (继续上次)
[跨端评审] ──→ H (OCR 语境句)建议起步:Task C。(已执行完毕,保留备查)成本最低、下游全现成,且能直接检验「摩擦力是不是真瓶颈」这个假设 —— 若 text 分享上线后使用量明显起来,说明 Task A/B 的归档后置值得投入;若没起来,则摩擦力假设需要重新审视,避免在 A(改动面最大)上押错。
实际执行顺序即按此:C/D/E/I → A(吸收 B) → F/G。当前只剩 Task H,其前置的跨端评审已于 2026-08-17 通过,可直接进入实现(先读该节的 C1–C4 硬条件)。
4. 验证矩阵
| 任务 | 关键回归 |
|---|---|
| A | 三入口未选 note 全程可达 Harvest;Harvest 未选时按钮禁用;选后正常入库;杀进程重启后未归档 session 正常恢复(noteId 可空往返,capture_queue_service.dart:129/160)⚠️ 原写「异书冲突弹窗仍生效」已作废 —— 冲突弹窗随 ensureSession 一并有意删除(见 Task A 落地说明 1)。防误选现由「ConfirmButton 未选 note 即禁用 ⇒ commit 不了 = 误选不了」承担,换 note 是重新归档不丢队列,没有可冲突的对象 |
| A | processImage(noteId: null) 路径无副作用(photo_recognition_service.dart:137 守卫) |
| B | 分享图片零对话框直达;HarvestBar 显示「未归档 · N 张」 |
| C | 冷启动 / 热启动两种分享文本路径;超长文本;非英文文本;source_type='text' 行正确落库;复习卡 TextSpotlightView 正常 |
| D | 收割后再开对话框,默认选中的是刚收割进的那本 |
| E | snackbar action 跳转正确 |
| F | RB 来源笔记显示徽标;last_opened_at 全 NULL 时不崩 |
| G | 按钮显示书名;Harvest 期可改 |
| H | 双端 cloze 池两轮内收敛到 ≤5;word 经 lemmatizer 归一(红线 #9);OCR 错句不入池 |
| 全部 | flutter analyze 0 errors;/code-check-before-restart;改 UI 跑 /ui-compliance-check;改架构层跑 /clean-arch-check;新增 UI 文案跑 /i18n-check |
5. 明确不做
- 生活场景 OCR(菜单 / 路牌 / 包装):翻译需求 ≠ 学习需求,用户会用系统翻译或 Google Lens,抢不过也不该抢,列进来只会稀释叙事
- 开放网页 OCR 优化:RB 的主场,优化它等于自问「那我为什么不用 RB」
- scan 页 dashboard 化:战果反馈最多一行
- 截屏自动检测:USER_NOTES 2026-03-05 已评估为 ROI 低(iOS/Android 14+ 拿不到图片,仍需手动选图)
6. 叙事内核
你在哪读,词就从哪来;复习时,你回到读它的地方。
RB 管有 DOM 的地方,相机管没有 DOM 的地方,两者汇入同一个书架、同一个复习流。
四个断点与对应任务:
| 断点 | 修复 |
|---|---|
| 拍之前先填表(做事前先做管理) | A / B |
| 采完之后无回响 | E |
| 复习时认不出出处(背面不对称) | H |
| 两端像两个 app | F / G |