Skip to content

Scan/OCR 端定位增强实施计划

需求来源:USER_NOTES.md 2026-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 _buildCardFaceGestureReviewCard
  • gesture_review_card.dart:323 _buildSourcePagesourceType 三分支:
    • imageImageSourcePage(原图 + source.positions 词位 + spotlight + 缩放 + 呼吸动画)
    • webWebSnapshotSourcePage(RB 端 HTML 快照)
    • textTextSpotlightView
    • 空 → NoSourcePlaceholder
  • 多来源走 PageView + SourceInfoBar stepper 切换

走的数据通道是 currentWord.sourcesWordPageLinkInfo.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 选书行 + 更换 chevronconfirm_button.dart:100-150✅ 完整实现,当前被关掉
未选 note 时禁用 + 文案confirm_button.dart:58,206filterSelectNoteFirst2 l10n 已有
Harvest 传参harvest_station_page.dart:689,701showNoteInfo: false / onChangeNote: null

改动

  1. capture_queue_notifier.dart 新增 startUnassignedSession()(或给 ensureSessionallowUnassigned):
    • noteId == null 的 session
  2. ensureSession 冲突语义修正(本任务最容易出错的一处):
    • 未归档 session + 任意 note → 不冲突,直接 append(未归档就是"还没决定",不存在覆盖)
    • 已归档 session + 同 note → append(不变)
    • 已归档 session + 异 note → 冲突弹窗(不变)
  3. scan_page.dart 三入口:_promptForBook()强制前置改为可选前置
    • 默认走 startUnassignedSession() → 直接进相机/图库/入队
    • 保留主动入口:HarvestBar 顶行可点 → 选 note(给「我就是想先定好」的用户)
  4. harvest_station_page.dart:685 ConfirmButton:showNoteInfo: trueonChangeNote: () => _promptForBook(...)
    • _promptForBook 需从 scan_page.dart:86 提取为可复用组件(两页共用)
  5. harvest_station_page.dart:216 的空判:从「报错返回」改为「引导选 note」(按钮已禁用,此处成为兜底)
  6. harvest_bar.dart 顶行:bookTitle == null 时显示「未归档 · N 张」

风险点

  • capture_queue_notifier.dart:545noteId: 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),与原计划的四处偏差:

  1. ensureSession 是删掉而不是改。原计划写「冲突语义修正:未归档 session + 任意 note = 不冲突直接 append」。实际改完发现整个「冲突」概念不复存在 —— 旧语义是「session 从出生就归属某个 note」,所以换 note 只能丢弃整队列;归档后置后 session 出生时无归属,换 note 变成 assignSessionToNote重新归档,不丢任何东西ensureSession 的最后一个调用方 _resolveSession 随之删除,连带 _showBookConflictDialog

    • ⚠️ 副作用:photoSwitchNoteTitle / photoSwitch / photoSwitchNote{Camera,Gallery,Share}Msg 五个 l10n key 变为零引用,尚未从 ARB 删除(ARB 当时与另一批在途工作混在同一文件里)。
  2. 多数「可空化」工作量不存在CaptureSession.noteIdstartSession(noteId:)CaptureCameraPage(noteId:, bookTitle:) 本来就可空;CaptureQueueState.hasActiveSession 只看 items 不看归属;harvest_bar._buildBookTitleRow 早有 placeholder 机制;ConfirmButton 的选书行完整存在只是被 showNoteInfo: false 关掉。真正新增的只有 ensureSessionForCapture / assignSessionToNote 两个方法。

  3. _promptForBook 提取为 presentation/utils/note_picker.dart::promptForNote,scan 页与收割页共用(原计划只说「提取」,未定位置)。

  4. 三处 session 创建时机后移:原写法在权限请求 / 图库选择 / 文本管线之前就建 session,用户取消或失败时会在磁盘上留下空 session。改为「确实有内容要入队时才建」。

  5. 收割页空判仍是「报错返回」,没改成「引导选 note」(原计划第 5 点)。_handleConfirmnoteId == null 仍走 photoNoNoteAssociated 报错 —— 因 ConfirmButton 已禁用,这条路径是纯兜底, 引导与否用户都碰不到。⚠️ 但那次判空排在「复制首图到 reading_source_images/之后, 真走到时会白做一次文件 IO(无害,未修)。

  6. 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 环节,又快又准,而且下游全是现成的

设施位置状态
文本 → 筛词 usecasetext_import_debug_page.dart:213-221 已跑通 RecognizeAndFilterUsecase✅ 仅 developer 页
队列接收预算结果capture_queue_notifier.dart:189 addPrecomputedResult
无图 item 标记capture_session_entity.dart:82 isTextImport
Harvest 跳过无图 itemharvest_station_page.dart:192,371,521✅ 三处已过滤
reading_pages.source_type='text'schema v23
复习卡文本来源渲染gesture_review_card.dart:336 TextSpotlightView

改动

  1. AndroidManifest 加 text/plainSEND intent-filter
  2. 原生侧透传文本(现有 EventChannel 只传 List<String> 路径 → 需加类型区分或新 channel)
  3. ShareReceiveServicelib/core/services/share_receive_service.dart)加 sharedTextStream + getInitialSharedText()
  4. main.dart:193 _resolveDestination + :244 热启动监听各加文本分支
  5. ScanPagesharedText 参数 → 调 usecase → addPrecomputedResult
  6. addSelectedToNotebookcreateReadingPage(sourceType: 'text', ocrText: <原文>)

iOS:本任务先只做 Android(Share Extension 的 iOS 侧成本见 USER_NOTES 2026-03-05 条目评估,另行排期)。

验收:微信/Chrome 选中英文段落 → 分享到 Lampio → 直接进筛词 → 收割入库 → 复习卡正面显示 TextSpotlightView 高亮该词。

✅ 已落地(2026-08-14),实现与原计划的三处偏差:

  1. wire 格式:EventChannel 用类型自判别而非新增 channel —— List = 图片路径(原样不动),String = 分享文本。已上线的图片路径零改动。
  2. 多补一处 ocrText 回填filter_confirmation_notifier.dart文本分支(单来源兜底,非 Task H 说的多图分支)此前不传 ocrText,导致 reading_pages.ocr_text 为 NULL → TextSpotlightView 渲染空白。补传后复习卡才真的有内容。
  3. 两个护栏(原计划未列):
    • 裸 URL 拒收 —— 浏览器「分享页面」把 URL 塞进 EXTRA_TEXT,跑词汇管线会收出协议名和域名碎片。判据 ^https?://\S+$,含 URL 的正常段落有空白字符,不受影响
    • 0 词提示 —— 整段词都已认识时给 photoSharedTextNoWords,否则分享看起来像什么都没发生

新增 l10n:photoSharedTextIsLink / photoSharedTextNoWords(en + zh;ja 是 91 行 stub,与 photoAddedWordsToNote 等既有 key 一致地跳过)。「去复习」复用既有 commonReview,未新增 key。

⚠️ 本任务的验收项直到 3 天后才真正成立 —— d3318e9(2026-08-17):真机验证时发现文本来源的复习卡 从来没有渲染过 TextSpotlightView,一律落 NoSourcePlaceholder。三层缺陷叠加,每层单独看都不报错, analyze 与单测一个都发现不了:

  1. 占位符逃逸capture_queue_notifier):buildMergedResult 把文本项的 imagePath(占位符字符串 'text_import',不是路径)当原图路径回填 → 下游正是用 originalImagePath != null 判定图/文 → 纯文本会话落库成 source_type='image' + image_path='text_import'
  2. 模型漏列reading_page_model):toDatabase() / fromDatabase() 都没有 ocr_text(v25 随列删除, v40 把列加回来时模型没跟上)→ 上面补传的 createReadingPage(ocrText:) 一直被静默丢弃。 🔴 两个方向必须同时补:只补写入方向的话,任何一次 model 往返(读→改→写)都会把跨端同步来的ocr_text 抹成 NULL
  3. 加载器只认整本全文review_source_loading_service):只在 note 有 fullTextPath(电子书导入产物) 时才返回来源,从不读 page 级 ocr_text → 挂在跨端同步来的笔记下必然落空

教训:本任务的下游「全是现成的」这个判断在数据形状上成立,在字段真的通到底上不成立 —— 表格里那些 ✅ 是「有这个设施」,不等于「这条路走得通」。


Task D — recent_note_id / selected_note_id key 不一致(bug)🟠

缺陷:收割成功后 harvest_station_page.dart:241recent_note_id,但 scan 页选书对话框 scan_page.dart:88 读的是 selected_note_id对话框的默认值学不到用户实际收割去向,一直停留在「上次在对话框里点过的」。

第三处 filter_confirmation_page.dart:180,193recent_note_id,第四处 selected_reading_note_service.dart:7selected_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 + bookTitleharvest_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_typewebArticle / 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),三处偏差:

  1. 来源判定改用 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 上被读过是确定的。一个字段同时承担来源与新鲜度。
  2. 文案不出现「RB」:复用阅读笔记列表页的 readingNotesLastRead("最近阅读 {time}")。① 两处界面对同一件事该说同一句话;② 品牌已统一为 Lampio,在 UI 里露出「RB」这个内部简称是错的;③ RVH 本无阅读功能,"最近阅读"隐含的就是那一端。
  3. 顺带删掉一个既有 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)

  • recentlyReadNoteProviderFutureProvider.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),可以做得更好。

数据已就位

  • SourceOcrDatarecognize_and_filter_result.dart:8-18)含 ocrText + ocrWords(带位置)
  • createReadingPagereading_page_repository.dart:17-25已接受 ocrText 参数,但 filter_confirmation_notifier.dart:323 的调用点没传 → 先补上,reading_pages.ocr_text 列即被填充
  • 入池闸门现成:8..220 字符(cloze_builder.dart:29-30,与 RB insert_cloze_context 同值)

改动(⚠️ 2026-08-17 按真机实测重写,原「整页切句」设想作废):

  1. 取语境 = 读 ocr_word_positions.line_text,不做整页切句 —— 该列一直存在且 NOT NULL,收割时已随词位写入。无需新提取管线,也无需给 image 来源补 reading_pages.ocr_text
  2. 三规则质量闸门(纯函数,可单测):length ≥ 25line_x 落在正文主列(滤邻页侵入)∧ 行末非连字符。
  3. 收割 commit 时为闸门通过的词写 word_cloze_contextsword 走 lemmatizer 归一,红线 #9;surface 存该行里的原始形;source_url NULL)。未通过则不写 —— 宁可没有语境,也不要坏语境占掉 5 个坑位之一。

为什么换方案:真机实拍一页装订书(含书脊弯曲、邻页侵入、连字符断词、页脚)跑完整 OCR 管线,49 个词位实测 ——

  • 49/49(100%)目标词字面出现在自己的 line_textClozeBuilder 挖空必然命中
  • 三规则闸门后 39/49 = 80% 通过,通过的质量可直接用
  • 而整页 reading order 在弯曲页上不可靠(实测 a weddingline_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(不采集), 而不是「先上线再补」:

条件违反后果
C1sentence 整词含 surfacesurface该行原始形(不用纠错后的形式),写前断言该行占掉封顶 5 的一个坑位却在 RB 侧完全不可见,还挤掉一条可用的 RB 句(唯一的静默型代价)
C2每 word 每次收割最多 1 条同页重复词最坏吃掉 5 个坑位,把该词历史语境整体清空
C3created_atnowUtc(),禁裸 DateTime.now()RB 新发现、本计划原先没列:UTC+8 下 RVH 的行恒显晚 8 小时,「留新删旧」退化成「留 RVH 删 RB」,无任何报错可观测
C4闸门必须有纯函数单测RB pull 侧无条件 INSERT、不校验质量 —— 这道闸是全系统唯一防线,无对端兜底

三规则闸门(length ≥ 25line_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)

三个文件 + 两组回归测试

文件职责
domainocr_cloze_gate.dart闸门(纯函数,无 IO / 无时钟 / 无随机)
presentationfilter_confirmation_notifier.dart::_saveOcrClozeContexts收割时收集候选 + 跨页择优
datacloze_pool_writer.dart::insertAll池语义:去重 / 复活 / 封顶 / C3 时钟

C1–C4 逐条对账

  • C1 落成两条断言,比原条文更细:① normalize(surface) == normalize(word) —— OCR 拼写 纠错改过的词在这里被拒(heerbeer:句里是 heer,要存的是 beer);② 直接调 ClozeBuilder.buildContextCloze,即用 RB 将要跑的同一套算法验证可挖空性,而不是自写近似正则。
  • C2 的口径是「每词每次收割最多 1 条」,不是每页 1 条 —— 闸门在每张照片内择优后, notifier 再跨照片择优一次。否则同一个词出现在 3 张照片上就一次吃掉 3 个坑位。
  • C3nowUtcIso()ClozePoolWriter 的时钟参数只为测试注入确定性时间,生产路径无法绕开。
  • C4 两组测试共 47 例(闸门 32 + 池语义 15),三处注入缺陷都验证过会红(裸 DateTime.now() → C3 用例红; 封顶少腾一位 → 4 例红;删掉复活分支 → 1 例红)。

实现里比原方案细的三处(都是写代码时才暴露的):

  1. 断词有两个方向,「行末非连字符」只挡得住一半。 后半截(ball bats with…)所在行末尾 并没有连字符,C1 也挡不住它(ball 确实字面在句里)。判据只能到「目标词是行首 token + 本页存在断词行」为止 —— 要精确知道谁接在谁后面就得依赖整页 y 序,而那正是弯曲书页上 不可靠的东西。故故意从宽拒收,代价由 C2 兜住(同词的其它词位仍可胜出)。
  2. 主列聚类的阈值基准是版面内容宽度,不是行左边缘自身的跨度。 后者在单列正文上只差 十几像素,拿它当基准会把阈值缩到 2px,随便一个缩进就被判成两列 —— 第一版就是这么写的, 被「单列不该分簇」的用例当场抓住。证据不足(行数 < 8 / 不分簇 / 两簇势均力敌)时不启用该规则。
  3. 不能直接读 ocr_word_positions.line_x —— 那一列在写入时被置成了 word_x (_saveWordPositionslineRect = wordRect,因为 ML Kit 给的是词级框)。行左边缘现算 = 同 line_text 的词位取 min(x)。

push 侧零改动(评审时已确认):dirty-check 无来源过滤,自建行 synced_at IS NULL 下一轮 sync 自动上行。删词 / 切用户的级联软删 v57 就已存在,无需补。

范围内不含文本分享text/plain 分享与电子书导入走 _processTextInputfilteredOcrEntity 为 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. 🔴 破折号被当成断词连字符(第 1 轮:27 词只有 20 词拿到语境) 判据原本只看行末字符。整页唯一以 - 结尾的行是 "months that have followed his release -" —— 那是空格后的破折号标点,不是断词。 后果是双份的:① months 被误拒(词根本没被截断);② pageHasHyphenBreak 被置真, 连带按规则 3b 拒掉 5 个行首词(david / fellow / helped / october / underground)。 一个宽判据吃掉了 7 个失败里的 6 个。 修法:连字符必须紧贴词尾\w[-‐‑–]$)—— 排版断词永远是 word- 不带空格。

  2. 🔴 主列规则的输入信号不可信 → 默认关闭(第 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,需双端设备与同一账号):

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

Task I — 死代码清理 🟢

  • ReviewSessionState.sourceImagePathreview_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:三项全删,sourceImagePathreview_session_state.dart 留了一行「❌ 已删除」注释说明它与 sources 的关系(避免日后有人「补回这个明显缺失的字段」)。

补充清理(2026-08-26 复核时发现并当场做掉)

  • capture_queue_notifier.dart 的 dartdoc 仍引用已删除的 [ensureSession] → 改指 ensureSessionForCapture

  • RecognizeAndFilterParams.noteIdprocessImage 一路传到底却从未被读过 —— 这恰好是 Task A 风险点 「processImage(noteId: null) 路径无副作用」成立的真实原因:它压根没被读过,而不是「守卫写得好」。 整条管道已删(params 字段 + PhotoRecognitionService 三个方法 + PhotoRecognitionNotifier 三个方法

    • 2 个调用点)。FilterConfirmationPage(noteId:) 是真实消费方,保留
  • 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_notifierPhotoRecognitionService。 ⚠️ 保留 photoRecognitionServiceProviderPhotoRecognitionService 本体 —— 那才是在用的管线。

  • 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 是重新归档不丢队列,没有可冲突的对象
AprocessImage(noteId: null) 路径无副作用(photo_recognition_service.dart:137 守卫)
B分享图片零对话框直达;HarvestBar 显示「未归档 · N 张」
C冷启动 / 热启动两种分享文本路径;超长文本;非英文文本;source_type='text' 行正确落库;复习卡 TextSpotlightView 正常
D收割后再开对话框,默认选中的是刚收割进的那本
Esnackbar action 跳转正确
FRB 来源笔记显示徽标;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
两端像两个 appF / G