Skip to content

变更日志

本文档记录项目的所有重要变更。

格式基于 Keep a Changelog, 版本号遵循 语义化版本


[Unreleased]

Fixed

  • 🔒 红线 RVH #6i:pull 分支②「本地已删 + remote 活」的 skip 改问「push 还会不会送它」 - (2026-08-31)

    • 缺陷:分支② 的注释一直写着「本地墓碑靠 push 传播,不复活」——那句话默认墓碑还是脏的。而按 RVH #6g 规则 W(软删时 deleted_atupdated_at 绑同一个参数)+ push 成功后的 _markSynced墓碑推完就恒不脏:于是 pull 指望 push、push 说没得推,两端永久停住。无异常、无告警,用户看到的是「这台设备上有、那台上永远没有」。真机实例 word_cloze_contexts 3a50ad2bcough up)。
    • 两端代码一字不差、都没做错 —— 写侧都刻意复活同句软删行(RB 红线 #7 精神),pull 侧分支② 也逐字相同。单方面改哪一端才是制造真正的不对称 ⇒ 走跨端裁决:docs/cross-end/47,本端回执 48
    • 判据是因果的、不是时序的:问「push 还会不会送它」,答案只由本地那一行自己的三列决定,全程不比较任何跨设备时间戳(那正是 RVH #6g 禁止的,真机反算门槛只有 27 秒)。②a 仍待推 → 照旧 skip;②b 已不待推 → 接受远端、复活本地行。远端赢的理由是可恢复性不对称:判错了再删一次就收敛,而分歧态下任何用户操作都修不了墓碑那一端(对已墓碑行再删是 no-op)。
    • 落地_kUserScopedDirty 常量(6 个 _pushXxx + getSyncStatus 的 pendingCount + 待推判据共用)· _tombstonePendingPush 由它拼出,禁止手抄 · _resolveTombstoneVsRemoteAlive 合判据/复活/AppLogger.w 于一处 · 6 处分支② 经 _keepSkippingTombstone 逐字同形。复活语句 SET deleted_at = NULL, updated_at = ?, synced_at = ?,两个绑参都是远端行自己的列(#6g P1 + P3)。
    • 🔴 前置 RVH-0 一并修_pullKnownWords 压根没有分支②,远端活行无条件走 ON CONFLICT … SET synced_at = <本端 now> ⇒ 本地行是墓碑时它把墓碑刷成 clean 却从未上行 = 已认识词的删除静默丢失(与本条独立,今天就在丢)。触发条件是「该轮 push 失败/被 _batchLimit 推迟,而同一轮 pull 又把这行带下来」——一次网络抖动就足以永久吃掉一次删除
    • 🔴 顺带修掉另一个独立真缺陷word_page_links 那份 push 脏检查整支 updated_at 条件都不在(六份内联副本已漂成三种形状)。于是 restoreNotebookEntry(删词 4 秒内的 Undo)复活出的 link deleted_at = NULL, updated_at = now 之后不脏 ⇒ 永不上行,对端那条 link 永远是墓碑,且任何后续操作都修不了(复活幂等)。收敛成统一常量顺带堵掉。
    • 验收 test/features/sync/tombstone_remote_alive_test.dart(20 例 = T1–T7 + 四条结构守卫),11 处反向注入全部按预期变红flutter test 1401/1401。
      • ⚠️ T1/T3 是防「过度修复」的一半:少了它们,一个无条件复活的实现会全绿,而那会把本端还没推上去的删除当场撤销。它们的夹具必须让 push 失败,否则脏墓碑当轮就上行变 clean,走的是 ②b 不是 ②a。
      • 🔴 复现了 RB 侧那条「守卫自己错了」:「复活漏写 synced_at」这个注入,只在远端 ts 晚于本地墓碑那一档让 T4 变红,另一档照绿。一条只在时间戳方向凑巧时才成立的断言等于没有断言 ⇒ T4 两个方向都跑。
    • 与 cap-5 池(#5g)的相互作用已判:②b 复活的行照常参加封顶、可能当场又被淘汰 —— 那是既定语义且收敛(淘汰产生的是一条新的、脏的墓碑,会上行),不加特例
    • 真机验收(2026-08-31,Android 24094RAD4C + 真实 Supabase)
      • §2 那个 word_page_links 缺陷在野外确认:安装前的旧代码进程连续两轮 pushed=0,新代码第一轮Push user_word_page_links: 6 rows synced,随后 pushed=0 收敛(不是回声环)。
      • 存量那 1 行按裁决单 §8 选 B 清零:单 id no-op touch(写回它自己的 updated_at 原值,业务列一字未变,只让 trigger 刷新 server_updated_at;行快照先存 backups/)→ 下一轮 pull 出现唯一一条 ⚠️ … 本地墓碑撞上远端活行且墓碑已不待推 → 接受远端并复活(RVH #6i ②b) → 设备上该行 deleted_at 清空、updated_at = synced_at = 2026-08-27T03:10:02.272361+00:00逐字等于远端行自己的 updated_at)⇒ T4/T5 各在真机实证一次
      • 对账差额归零word_cloze_contexts 119/120 → 120/120,本地独有全 0(push 侧无回归)。唯一剩下的 word_page_links dd052755 已查证是 #6f 的存量孤儿(父词条云端也不存在),属另一条线。
    • 🔴 真机当场抓到本次实现自己的一个缺陷,已修:②b 复活了行却没计入本轮 pull count ⇒ watermark 的推进闸门 totalPushed > 0 || totalPulled > 0 不开 ⇒ 同一批行被反复重拉(实测空转三轮,最后靠行数对账的全量自愈才顶过去)。闸门本身是刻意保守的(「返回了但全被 skip」的行若推进水位就永久丢失),所以要修的是 ②b 承认「我处理了这一行」—— 正是裁决单 §3.1 第四条明写的要求,上一版看 RB 也没显式计数就跟着漏了。补 T8,并留了一条对照组注入:去掉 cloze 的 total++ ⇒ T8 红,去掉 reading_notes 的 ⇒ 全绿(下游 ON CONFLICT 会补 count)—— 探针只能选 cloze / learning_entries
  • 🛡️ sync 漏掉的行不再永久丢失:收尾行数对账 → 全量自愈 pull - (2026-08-31)

    • 缺口:pull 侧任何一次「这行落不下去,跳过」或「这行根本没被返回」,后果都一样 —— 本端没有它,而 watermark 已经越过它(水位不回头),增量 pull 永远不会再返回它。全程无异常:getSyncStatus 只数本地脏行,云端多出来的行不在它口径里 ⇒ UI 永远显示「已同步」。
    • 实测现场(本机,同一账号):真丢 6 行 —— learning_entriespellet(vocabulary 三层回填失败 → continue)+ 它的 word_page_links 子行(父词条缺失,三态守卫 skip,守卫行为是对的)+ 4 条 word_cloze_contexts
    • 🔑 那 4 条 cloze 否掉了原定修法:它们不走任何已知跳过分支(该表 pull 的「本地无 + remote 活」是无条件 INSERT OR IGNORE,#6f 明写该表不加父行守卫),本地无 UNIQUE 索引、无同句行、reconcile 又是软删 ⇒ 这 4 行根本没被 pull 返回过,机制至今未定。按原因记账(记下被跳过的 id)要求枚举得全,对它无效 ⇒ 改成按结果对账
    • 机制syncNow 收尾对 6 张同步表各要一次云端 count=exact(HEAD,不下载行),与本地 WHERE user_id = ? 行数比;remote > local 则 streak +1 记进 app_metadata['pending_full_pull:<uid>'],streak 落在 [2,5] 的表下一轮以 since=null 全量重拉一次(pull 本身 idempotent)。
    • 四个判据都带反向注入实证test/features/sync/row_count_reconcile_test.dart,15 用例,注入后逐个变红):下界 2 防抖(对端在本轮 pull 之后写的行会造成一次无害差额)· 上界 5 防死循环(全量 pull 也消不掉的差额 —— 本机就有 1 条父词条云端也查不到的存量孤儿 word_page_links,没上限它会让该表每轮全量重拉,永远)· 只认 remote > local(反向是正常的未推送脏行)· 查询失败保留 streak(当成「对上了」的话,一次网络抖动清零账本 ⇒ 抖动频繁的网络下自愈永不触发且无现象)。
    • 🔒 没碰 watermark 推进逻辑(红线 #5d 原样):只改某张表这一轮的起点,全量 pull 的 maxObserved 仍是自己那批的 MAX,与增量同构;#5d 五条守卫 grep 全部仍成立。零 schema 改动(账本存 app_metadata,沿用 pending_vocab_prune 的既有形状)⇒ 不触发本仓开发期的「升版本即重建库」。
    • 判定逻辑抽成两个 @visibleForTesting 纯静态函数(tablesToHeal / nextMismatchStreak),让测试咬生产代码本体而非转录副本。
    • 真机验证(2026-08-31):判据先写死再看现象(P1–P5 + 三条假绿排除法)。库未重建(initialized_at 仍是首装值)、watermark 一直非空 ⇒ 排除「全量 pull 兜底顺带带回」这种假绿。实测 streak 1 → 不拉、streak 2 → 日志 Self-heal full pull this round: {...} 触发全量重拉,机制成立。
    • 🔴 验证当场抓到本次实现自己的两个缺陷,均已修
      • 口径把云端墓碑算成缺口:pull 的「本地无 + remote 删」分支按 FK-safe 惯例刻意不落墓碑 ⇒「对端删了、本端从来没有过」是正常终局,含墓碑去比会造成永远消不掉的差额(实测连空跑 5 轮全量 pull,行数纹丝不动)。改为两端都只数活行(deleted_at IS NULL),真实缺口从 1+2+4 缩到 1+2+1。
      • give-up 是个死结:缺口 → 涨到 give-up → 不再全量拉 → 那行永远回不来 → 差额永远在 → 永远 give-up。于是即使把真正的阻塞修好也不会再重试。改为越过 give-up 后每 20 轮仍试一次(约 20 分钟),并只在「刚放弃」与「重试轮」打 ⛔ 日志(原先 60s 三行纯噪声)。
    • 🔴 顺带订正上一版的一处结论:原写「4 条 cloze 真丢、机制未定」——它们全是云端墓碑,本地没有是正确行为。查它们时 select 里明明要了 deleted_at,但只打印了 word 和 sua,要了字段却没看。结论(改用按结果对账)仍成立,理由换成下面这条。
    • 真正的丢失只有 1 行,根因是一个类型转换 —— 见下条 cefr_inferred。这也反过来证明自愈是对的:它确实每轮重新取了那一行,是那行自己落不下去。
    • P3(自动捞回)也验过cefr_inferred 修好后,streak 到 40 触发周期性重试 → 全量重拉返回 pellet → backfill 成功 → 词条与其 word_page_links 子行一起落地 → 该表从账本消失。收尾对账 learning_entries 100/100、「本地独有」六张仍全 0。
    • 剩余两个不是缺陷的差额,保留并交给人:word_page_links 的存量孤儿(父词条云端也不存在,#6f 已裁决)· word_cloze_contexts 的「本地墓碑 + 云端活行」永久分歧(另立 P2 条目,属跨端契约裁决)。
    • 🔒 口径取舍明写:活行对活行仍有一类假阳(本地墓碑 + 云端活行)。放宽成「云端活行 vs 本地全部行」能消掉它,但会引入静默假阴(本地未推的新行抵消掉真实缺口)。两害相权保留假阳 —— 假阳有 give-up 上界兜底,假阴没有任何东西兜。

Fixed(vocabulary)

  • 🐛 cefr_inferred 类型转换写错,导致跨端丢词(实例 pellet - (2026-08-31)
    • supabase_vocabulary_service.dart 把它当 num? 转,而它在 Supabase 侧是 boolean NOT NULLsupabase/sql/vocabulary-buffer.sql)、Edge Function lookup-or-fetch-word 也声明 boolean、实测返回 true
    • 后果链:转换抛 type 'bool' is not a subtype of type 'num?' → 整条 backfill 失败 → sync 的 _pullLearningEntries 判「vocabulary not found」跳过该远端词条 → watermark 越过 → 该词永久拉不回来。现场只有一行 AppLogger.w。本机 pellet 一词因此丢了 5 天。
    • 改为 parseCefrInferred 同时吃 bool / 0-1 / 字符串,任何输入都不抛(回归测试专门断言这一点:事故代价不是判错值,是异常打断整条 backfill)。5 用例 + 反向注入(删掉 bool 分支即红)。

Added(工具链)

  • 🔍 scripts/sync_reconcile.sh —— 双端逐 id 对账,常驻判据 - (2026-08-31)
    • 固化的是两次一次性人工对账:「push 75→4」挂了三个月靠一次手工比行数才发现,比完就没了;于是同类问题(这次在 pull 侧)又靠另一次手工比才浮出来。判据不常驻就等于没有。
    • run-as cat 拷出设备库(只读,app 不必在跑)+ PostgREST GET,逐 id 比 5 张表。本地独有 > 0 直接退 1(push 侧回归:本地行已 clean 就永远不会重推);查询失败退 2 —— 本轮无结论,不算绿(写第一版时就踩到:一次 curl 抖动被当成「云端没有」,推出的丢失链条整个是反的)。
    • known_words 刻意不比:预装 211 个功能词由 mergeStopwordsIntoKnownsynced_at=now born-clean,按设计永不 push,比了会稳定报一格假红。

Added

  • 🎯 OCR 采集的词也有语境范式复习卡了(Task H,红线 #5g 双端共写) - (2026-08-26)
    • 补的是什么缺口:复习卡此前是双范式分裂 —— RB 双击查词采的词,背面是语境范式(真实读句 carousel + 目标词高亮 + sense_gloss 联动义项);OCR 采的词只有词典范式(策展例句/裸词)。卡面正面其实早就对等(拍摄原图 + 词位高亮 + 聚光灯),差异化缺口只在背面这一处。
    • 语境从哪来:直接取词位已存的 ocr_word_positions.line_text不做整页切句。真机实测 49/49 目标词字面出现在自己那一行里(ML Kit 的 word 本就是从 line 里抽的,结构性成立);而整页 reading order 在弯曲书页上不可靠 —— 切句要解的正是这个解不动的问题。
    • 三规则闸门length ≥ 25 ∧ 行左边缘落正文主列 ∧ 行末非连字符)+ C1 的两条断言。⚠️ 这道闸是全系统对 OCR 噪声的唯一防线 —— RB 的 pull 侧对远端活行无条件 INSERT、不做任何质量校验,放过去的坏句两端都没有第二次拦截机会。
    • 写坏一条句子的代价是静默的:RB 前端定位不到目标词会 continue,那行于是占着封顶 5 的一个坑位却在 RB 侧完全不可见(不报错、不降级、就是没有),还会把一条本来能用的 RB 句挤出池子。所以宁可没有语境,也不要坏语境。
    • 实现里比条文更细的三处:① 断词有两个方向,「行末非连字符」只挡得住前半截,后半截(ball bats with…)只能按「行首 token + 本页有断词行」从宽拒收 —— 要精确判断得依赖整页 y 序,而那不可靠;② 主列聚类的阈值基准必须是版面内容宽度,用「行左边缘自身的跨度」会让阈值缩到 2px,单列正文被判成两列(第一版就这么写的,被用例当场抓住);③ 不能直接读 ocr_word_positions.line_x —— 该列写入时被置成了 word_x。
    • 池语义(去重 / 软删复活 / 封顶留新删旧)抽进 ClozePoolWriter逐条镜像 RB crud.rs::insert_cloze_context:两端往同一个池子写,规则不一致会让池子来回拉扯、永不收敛。⚠️ 封顶 5 写在三处(ClozePoolWriter.poolCap / _clozePoolCap / RB CLOZE_POOL_CAP),改一处要改三处。
    • 回归 47 例:闸门 32(正负样本取自真机实测)+ 池语义 15(跑真实 DDL 与真实生产代码)。三处注入缺陷验证会红:裸 DateTime.now() → C3 用例红;封顶少腾一位 → 4 例红;删复活分支 → 1 例红。
    • push 侧零改动(评审时已确认):dirty-check 无来源过滤,自建行 synced_at IS NULL 下一轮 sync 自动上行。
    • 真机验证(2026-08-27):同一页 4 轮收割 → 27 词入库 / 27 词拿到语境,30 条行逐条复核 C1 / 长度 / UTC / source_url 全合格,重跑 新增 0 条(幂等)。过程中挖出两个只有真机版面才暴露的闸门缺陷:① 空格后的破折号被当成断词连字符,一条 "...his release -" 误拒 months 并连带拒掉 5 个行首词(27 词里 6 个白丢语境)→ 改为要求连字符紧贴词尾;② 主列规则的输入信号不可信 —— 词位 x 由 ML Kit 行框插值而来,实测 18 行正文里 2 行的行框报偏 140–240px,正文行被当成邻页碎片,而它要挡的碎片本就过不了长度闸 → 默认关闭(实现与单测保留,真出现长句侵入样本时再开)。
    • 跨端真机实测尚未做(RB 回执 §7 三步,需双端设备 + 同一账号),其中第 1 步同时验 C3。

Added(工具链)

  • 🛡️ 红线 #9 结构守卫(RB lemmatizer.rs::redline9_guard 镜像,v63) - (2026-08-28)
    • 枚举 lib/ 里全部写 vocabulary / learning_entries / known_words / reference_words 的文件,逐个要求落在「调归一」或「带书面理由的豁免」。它遍历文件系统,故不受 RB K4 那条限制(新目录里开写入方也抓得到)。
    • 首跑当场多抓两处自曝清单里没有的reference_words_repository_impl.dartword.toLowerCase()(与 RB 本轮修掉的 add_reference_word 同形状)· local_vocabulary_datasource.dart 的缓冲池回填。人工清单漏了两个,遍历文件系统的守卫没漏。
    • 🔴 写入侧有意不修(等 RB 裁定 canonical 规则):实测预装库里 118 个 vocabulary.word 不是 normalize 的不动点bearing→bear / found→find / according to→accord to),而消费侧是裸等值比 ⇒ 盲目补归一会把一个潜在违反变成 118 个词的现行缺陷。理由与重启条件见 CLAUDE.md 红线 #9 与 doc 42 §3.3。
    • 🔴 剥注释器自己被这个守卫抓出一个 bug:第一版把「块注释起点」判在「整行注释」之前,于是 /// …assets/nlp/*.json 里的 /* 被当成块注释开始,吞掉此后整个文件INSERT INTO vocabulary 从扫描结果里消失 → 假红。换个顺序它就是假绿(写入方凭空隐身)。

Fixed

  • 跨端待验项 C 验完:删扫描页 → cloze 行仍在(doc 23 §7.3,回执 cross-end/44) - (2026-08-28)

    • 先修后验:C 在 relay 收尾时被标成「🚫 未验」,理由是 RVH 删页当时是硬删 + FK CASCADE —— 那种状态下「cloze 行仍在」这个观测会成立,但成立的理由是错的(页行被物理删掉、删除信号根本不上行,同时 CASCADE 把 link 墓碑一并抹掉)。把它记成「既有行为」等于给一个真缺陷发通行证。软删修复落地后才验。
    • 夹具刻意避开 relay 的 cave 与 B 轨的 silence:新造一页地质题材渲染页走完整 OCR → 收割链路,33 词 / 33 条语境,source_url 33/33 全为 NULL。
    • 结果:本地 —— 页行仍在(deleted_at = updated_at 逐位相同)、33 条 link 全部转墓碑(CASCADE 下会整个消失)、33 条 cloze 仍活跃、生词条目未动;云端 —— 页与 33 条 link 墓碑正常上行,33 条 cloze deleted_at 全为 null;两轮 sync 后 pushed=0,无回声环。
    • 🔴 顺带订正 doc 23 §3.1 第 1 条的理由:那句「级联键是 source_url,NULL 匹配不上」说的是 RB 侧页级清理;RVH 侧的理由不是这个 —— RVH 的删页事务里压根不碰 word_cloze_contexts。两个理由指向同一观测但失效条件不同,日后给 RVH 删页加「按来源清语境」时,只读 doc 23 会以为 NULL 就万事大吉。
    • 🆕 一件请 RB 核实的新暴露面:修复前 RVH 删页不上行,现在会了 ⇒ RB 会开始收到来自 RVH 的页墓碑(relay 期间不存在的输入)。RVH 读码判断 RB 的 source_url 级联长在本地删除命令里、pull 不会调它,但未实测,已在回执里请 RB 自核。
    • 夹具已清理(用户确认后执行):删笔记顺带又验了一次 #6e —— 笔记 deleted_at = updated_at 逐位相同、页墓碑 1 / 活 0、33 个词仍在生词本(弹窗文案「The words stay in your notebook」与 #6e 语义一致)、33 条 cloze 仍活跃(删笔记同样不带走语境);云端笔记墓碑删后约 3 秒上行,之后连续 4 轮 pushed=0。推到设备相册的三张测试图也已删除。
  • 🗑️ reading_pages 仍是硬删 + FK CASCADE —— 删页不上行,还会抹掉刚立起来的 link 墓碑 - (2026-08-28)

    • 两个后果都不报错、两端 UI 都看不见:① 删除不上行 —— push 取不到已被物理删掉的行,云端那页永生、对端继续显示,本端还会在下一次全量 pull 时被复活;② CASCADE 物理抹掉 word_page_links 的墓碑 —— 那正是红线 #6f 刚立起来的东西,删词时给 link 打的墓碑,用户一删页就没了。两条 UI 路径都可达(来源列表删单页 / 存储管理「完全删除」)。代码注释还明写着「CASCADE 自动清理关联表」——是当年的有意设计,只是早于墓碑体系
    • 改法照抄同仓 deleteBook(#6e 样板):单事务软删 + 手工级联软删子树 + deleted_at/updated_at 同值(#6g W)。两处的差别写进注释:单页版找不到就抛(回滚,不留半截状态);批量版不抛 —— 它的入参是一批清理候选,其中可能有已是墓碑的,跳过即可。
    • pull 侧补了一处_pullReadingPages 分支①(远端页墓碑)原先只标页、不带子树,按 _pullReadingNotes 分支①的既有先例补上防御性级联 —— 对端漏传 link 墓碑时,本地不该留下挂在已删页下的活关联。
    • push 侧零改动(v55 早写好了),只订正了三处失效注释:两个 push 函数与 schema 里 deleted_at 列上那句「RVH 当前无本地删除入口 / pull-only」。
    • 📌 文件与行的取舍deleteSourcesCompletely图片文件真删、DB 行留墓碑。用户意图就是回收空间所以文件必须删;删除信号要跨端传播就必须留墓碑。墓碑行的 image_path 指向已不存在的文件 —— 无害,所有读查询都带 deleted_at IS NULL,没有任何路径会去打开它。
    • 回归 13 例(跑真实 DDL,FK 打开 —— 硬删才会真的触发 CASCADE,测试才抓得住「墓碑被抹掉」)。四处注入缺陷逐个实证会红:退回硬删 → 6 例;只写 deleted_at → 1 例;不带子树 → 3 例;pull 不级联 → 1 例。
    • 跨端待验项 C 的阻塞已解除(doc 23 §7.3「RVH 删扫描页 → cloze 行仍在」),可排期跑。
  • 🔤 ML Kit 词过滤把「句末带标点的词」整个丢掉 - (2026-08-28)

    • mlkit_engine.dart^[a-zA-Z]{2,}$ 直接作用在 element 原文上,而 ML Kit 会把紧贴的标点算进 element(Cave. / Canada,)⇒ 这些词连词位都不生成,候选列表 / 语境采集 / 原图高亮全当它不存在,UI 上没有任何异常可看。对语境采集(红线 #5g)尤其糟:C2「每词每次收割最多 1 条」靠的是词位,没有词位 = 该词这一页永远采不到语境
    • 跨端 relay 第一次拍页失败正是因此 —— 目标词 cave 恰好落在句末,整轮收割里一次都没出现过。
    • 抽成纯函数 MLKitEngine.normalizeElementText:先剥首尾再用原正则校验。🔑 剥的是「非字母非数字」,不是立项时写的「非字母」 —— 按后者会把尾部数字也吃掉(COVID19 → covid),那是另一种归一、超出本缺陷范围。第一版按立项原文写的,被 COVID19 那条用例当场抓住。只剥首尾不动词内don't / pellet-strewn 照旧被拒,与修复前一致,本次不放宽(涉及 lemmatizer 与跨端 PK,红线 #9,是另一件事)。
    • 顺带核实:同仓另一个 mlkit_ocr_datasource.dart\b[a-zA-Z]{2,}\b行文本,本来就没这个毛病,不用改。
    • 回归 11 例,四处注入缺陷逐个实证会红。
    • 真机验证:拿第一次 relay 失败的那张图重跑,同一张图 OCR 原始单词73 → 80,多出来的 7 个正是先前被吞的 old / canada / cave / one / itself / ago / explorescave 随后正常出现在收割站首屏 B1 组。验完即 Discard,未收割。
  • 🔴 复习排期写的是本地时钟:卡片晚 8 小时到期、且该端在跨端 merge 里系统性占优(RB doc 40 主项,v63) - (2026-08-28)

    • 谁抓到的:RB 建「学习循环」专题验证时,部署态普查发现 learning_entries.next_review_date 是共享表全部 text 时间戳列里唯一带裸值(无 UTC 后缀)的一列 —— 其余列 100% 合规,含刚收拾过的全部墓碑列。
    • 根因SM2Result.getNextReviewDate() 用裸 DateTime.now(),而同一次写回的兄弟列(lastReviewDate / updatedAt)全用 nowUtc()。不是风格差异,是遗漏。Dart 的 toIso8601String() 对 local DateTime 不输出时区后缀(offset 恰为 0 的 local 也不输出),裸串经 push 原样直达 Supabase。
    • 后果两条,都静默:① 两端到期判定都是字符串比较(RB get_due_cards / RVH getDueReviews)⇒ UTC+8 下卡片晚 8 小时到期;② merge 判据同样是字符串序 ⇒ 本端在 next_review 这一维系统性占优。负时区下方向翻转(提前到期 + 该维恒输)——在 UTC+8 单地测试永远只看到一半
    • 🔴 一行不够,实际改了三处:② 新词哨兵 DateTime(1970, 1, 1) 也是 local,它每加一个新词就再产一行裸值 —— 只改 ① 的话形状检查永远绿不了;③ statistics_repository_impl.dart 的读侧绑定串也是裸本地串,它此前与写侧缺陷互相抵消(自写的行碰巧正确),只修写侧会让 due 统计从「碰巧对」变成「早 8 小时」。修一半比不修更难查。
    • 为什么六道既有守卫全绿:SM-2 三端黄金向量 / sync-verify / sync 单测验的是「算得对不对」和「行搬得对不对」,没有一道在问「算出来的那个时刻是哪个时刻」。也因为单端自测必然自洽 —— 同一台机器写、同一台机器读,DateTime.parse 把裸串按本机时区解回同一个时刻。
    • 守卫拆成两条独立断言next_review_utc_test.dart):形状层断言 SQLite 里那一列的原始字符串(不是内存 isUtc —— 会退化的是「有人后来 .toLocal() 一下再存」);语义层用跨时区合成向量证明「补个 Z 但不换算」这种假修复会被判死。少任一条都能被「修好形状」骗过。⚠️ 本端 L-B 不能替代 RB 的 M3:进程内自写数据必然自洽。
    • 存量数据裁定「不动」:3 条真实卡下次复习时自愈;哨兵行首次复习时自愈。否掉「统一哨兵字面量」是因为它买不到它看起来买的东西 —— RVH 写 .000Z、RB 写 +00:00,字符串序里 RVH 依旧恒大,改了只有观感变绿却要付一轮重推。已作为开放问题交回 RB。
    • 八处注入实证。其中 I6 直接演示了「抓空判 PASS」:扫描目录取空后,「没有任何站点用本地时钟」安静全绿(空集合上的全称命题恒真),只有阳性对照红。
    • 交接见 docs/cross-end/42-rvh-learning-loop-confirmation.md(含五处对 RB 判断的订正);契约见 CLAUDE.md 红线 #6h。
  • 🔴 删过的词再也加不回生词本(红线 #7,v63) - (2026-08-28)

    • 自曝了很久的「潜在 dup PK 风险」其实是个硬失败isWordInNotebookdeleted_at IS NULL 看不见软删行 → 走 INSERT → 撞 UNIQUE(user_id, word COLLATE NOCASE)SqliteException 2067,用户看到的是加词失败,那个词从此加不回来
    • 修法逐字段镜像 RB crud.rs::save_word:新增 reviveSoftDeletedEntry,清 deleted_at + SM-2 归零 + 落哨兵 + bump updated_at复用同一行 id、不复活子树
    • ⚠️ 与 restoreNotebookEntry(4 秒 Undo,红线 #6f 按墓碑时间戳还子树)语义相反:Undo 是「这次删除不算数」,本路径是「这个词我重新学一遍」。把其中一条实现成另一条是这里最容易犯的错,故两条都有用例。
    • 五处注入实证,其中 R7-5 是反向的一格:没有软删行时必须一个字段都不许动活行 —— 否则「重复加一个已有的词」会清空用户的复习进度。
  • 🔍 多词性词在收割站首屏被静默吞掉(跨端 relay 第 2 棒实测挖出) - (2026-08-28)

    • 表象:一页 34 个有效词,收割站首屏只列出 20 个;被吞的 8 个全是 fire / use / light / share / go / cave 这类常用词——多词性恰恰是常用词的特征。用户没有任何线索知道少了东西,芯片上的数字反而告诉他有 7 个(B1 芯片 7 / B1 分组 1,同屏两个数自相矛盾且无任何报错)。
    • 根因FilterConfirmationState.initialactiveLevels.contains(cefrLevel) 整串等值匹配,而多词性词的 cefrLevel 是逗号串(cave = "B2,B1")。同文件的 sortedGroupedWords 与 notifier 的 _filterWordsByLevels 都正确地 split(',') ——三处实现只有 initial 没拆。于是「换个入口就好了」:随便点一下任意 CEFR 芯片走 _filterWordsByLevels 重建,那 8 个词就回来了(20 → 28)。
    • 修法是收成单点,不是只补那一处:缺陷本质是同一个判断有四处实现,只改一处等于把地雷留在原地。新增 CefrLevelSpecparse + matches),initial / _filterWordsByLevels / visibleHighlightedSources / sortedGroupedWords 四处全部改走它。
    • 顺带收了两处「芯片计数」cefrStats / levelCounts):它们本来就拆逗号,但用的是不带 .trim() 的裸 split,与过滤路径归一口径不一致。当前值里没有空格所以尚未发作,但芯片数与列表数分岔正是本缺陷的表象,不该留两套归一。
    • 🔴 顺带修掉一个会喊狼来了的自检toggleCefrLevelexpectedWordscefrStats 按档位求和,而多词性词在每档各计一次 ⇒ 重复计数 ⇒ 修复后每次切档都稳定打印 ⚠️ MISMATCH! Displayed (6) != Expected (11)。那是公式错不是过滤错,已改成「命中任一激活档的去重词数」。留着不管的话,下一个人会以为修复引入了新问题。
    • 有意没动两处isSelected 默认值判据仍是整串等值(与 word_card.dart::isDisabled 一致,改了会让 "A1,UNKNOWN" 这类混合值从默认选中变成默认不选中 —— 那是行为变更不是缺陷修复);_buildLemmaColorMap 需要有序首元素parse 返回 Set,不适用。
    • 回归 14 例(multi_pos_cefr_visibility_test.dart)。四处注入缺陷逐个实证会红:① initial 改回整串等值 → 8 例红;② 去掉 REFERENCE 恒合格 → 2 例红;③ sortedGroupedWords 不拆逗号 → 1 例红;④ _filterWordsByLevels 改成整串等值 → 5 例红,且只有等价性用例抓得住(破坏的是另一侧,「首屏含 cave」全部照过)——这正是当初写「首屏 == 切档后」那组用例的理由。全量 1298 例通过。
    • 真机验证(2026-08-28,24094RAD4C):同一页重新导入,首屏未点任何芯片,芯片与分组计数逐档一致(16/16 · 5/5 · 7/7 · 8/8 · 1/1;修复前是 16/11 · 5/3 · 7/1 · 8/5 · 1/0),cave 同时出现在 B1 与 B2 两个桶。验完即 Discard,未收割 —— 避免往 cave 语境池再加行、打乱交给 RB 的 relay 夹具。
  • 🟢 doc-consistency-check.sh 的词库数量检查是永久假绿 + Step 1 文案在吃变量(pre-commit 工具链) - (2026-08-28)

    • 假绿是怎么来的:Step 6 的判据是「文档里还有没有那个旧错值」,而旧值早已清干净 ⇒ 命中恒为 0 ⇒ 永远绿;它打印的 8614 则是个只用于打印、从不参与比较的硬编码常量,且本身也是错的。实测当时文档里同时并存 8614 / 18898 / 18925 / 18916 四个数,而这个专为抓这件事而存在的检查报「通过」。它挂在 pre-commit 上,每次提交都对着人说通过。
    • 改法:真值源改成预装库本身(sqlite3 …/lampio_dict.db "SELECT COUNT(*) FROM vocabulary;" → 18916),逐处比对权威文档,形状对齐同文件里本来就写对的 Step 5。sqlite3 缺失 / db 缺失 / 查询无结果三条兜底一律打 ⏭️ 跳过并说明原因,绝不打 ✅
    • 语料清理 8 处、有意保留 11 处:判据是「当前声明」还是「时间快照」。保留的是带日期的 ADR(decisions.md,同段落还写着 v27 / 9 张表 / vocabulary_items)、2026-04-08 生成的迁移参考快照、docs/plans/ 下的计划文档。顺带修掉同一行上的陈旧标识符:vocabulary_itemsvocabularyvocabulary.dblampio_dict.db
    • 🔴 第一版自己又假绿了一次:照抄 Step 5 的 HISTORY_RE 用了裸 ,而 CLAUDE.md 里声明词总数的那行是超长段落、段内有大量与数量无关的箭头(tag 改档 all along→context)⇒ 整行被当历史行排除,注入「词总数 18916→18925」竟报通过。收紧成「数字→数字」才生效。注入测试是唯一发现它的方式。
    • 🔴 顺带推翻一个当天早些时候的错误判断:Step 1 那行 ✅ Schema版本号一致: v…(4个权威文件) 显示异常,先前判为「色码转义的显示问题」——od -c 一看是真 bug:裸 $VAR 后面紧跟全角字符时,bash 3.2 在当前 locale 下把 的首字节并进变量名 ⇒ ${DART_VERSION\xEF} 未定义 ⇒ 变量整个消失、全角括号还被啃掉一个字节。受影响的还有 Step 1 的失败文案(真报错时把基准版本显示成空)。三处全改 ${VAR} 并加防复发注释。
    • 注入测试 5 正 + 3 负逐条实证:5 处声明各改错一个数字必报红;11 处快照里的旧值全部沉默;db 缺失打 ⏭️ 而非 ✅。
    • 代码侧还有一批不同的陈旧值 4953(含 vocabTestDescription 这条用户可见文案),与本次的 8614 不是同一批,另立 P3 见 backlog.md
  • 🔄 autoSync 的存活期绑在了页面挂载上 → 栈根不是 Review 就完全不同步(v63) - (2026-08-27)

    • 真实条件比立项时描述的更刁钻:不是「停在 Review 页」,而是「Review 页或 ProfilePage 在 Navigator 栈的任意一层」。MaterialPageRoute 默认 maintainState: true,从 Review push 进生词本时 Review 仍挂在栈里、定时器照旧活着;三个 tab 之间的 pushReplacement 才真的拆掉它。⇒ 同一个生词本页,从 Review 进去同步就跑、从 Me 进去就不跑。立项时那两条互相矛盾的观测(「删词 20 秒后上行」vs「等 30 分钟纹丝不动」)正是这两条路径的差别 —— 也是它难查的原因。冷启动落在 ScanPage ⇒ 不主动点进 Review 的话,周期性同步一次都不跑。
    • 不是有意的省电策略review_overview_page.dart:70 原注释写的就是「Start auto-sync when signed in (app-wide trigger)」—— 意图一直是 app 级,只是落点放错了页面。autoDispose 让一个后台服务的存活期绑在了页面挂载上。
    • 改法:宿主提到 app 根(main.dart::_AppServicesHost),前台 60s 轮询不变,切后台暂停 / 回前台恢复并立刻同步一次(AppLifecycleListener)。前台的耗电与流量特征与「修复前停在 Review 页时」完全一致,后台 0 请求。未加写操作触发 —— 触发点撒进各写路径,漏一个就是一个静默不上行的洞,而 60s 兜底已足够。
    • 🔴 宿主的位置是两个约束的交集,挪动前先读注释:① 必须挂在 MaterialApp.builder(Navigator 之上) —— home: 和任何页面都会被 tab 切换的 pushReplacement 换掉,那正是本缺陷本身;② 必须等 Supabase init 完成才 watchstartupInitializedProvider)—— _MyAppState.buildrunApp 时就跑,提前 watch 会经 syncRepositoryProviderauthStateProviderAuthRepositoryImplSupabase.instance 就绪前被构造并永久缓存成 error 状态_heavyInitAndResolve 里早写着这条告诫,本次第一版就踩了,靠 P0 自查捞回来。
    • 🔴 前置改动:syncNow 补防重入(合流语义 —— 后到的调用方拿到正在跑的那一轮,不额外起一轮)。旧代码只有一个定时器,两轮物理上撞不上;加了恢复触发后「定时器这一拍」与「回前台」会真并发,而 watermark 推进(红线 #5d,两轮各写一次 _setLastSyncAt 会把水位往回盖或提前盖)与 cloze reconcile(红线 #5g,两轮各自削同一个池子)都不是为并发设计的。已知代价:手动同步按钮若恰好按在某轮的执行窗口内(60s 里的 1–2s),拿到的是那一轮的结果,用户刚刚的改动等下一拍 —— 概率极低且下一拍必然补上,故不做「排队再跑一轮」。
    • 🔴 防重入自己开了个新暴露面,同批修掉了登出前的 flush 不能合流 —— 它之后紧接着 clearLearningDataForUser 清本地,合流到一轮可能已经跑过 push 阶段的 sync ⇒ 用户最后的改动永久丢失,没有「下一拍」补。注意方向:没有守卫时 flush 必然自己跑完整一轮,反而不会漏 ⇒ 这是防重入引入的风险,不是既有缺陷。故 syncNow{bool ensureFresh = false},true 时排队(等在跑的那轮结束再起一轮)而非合流, auth_notifier.dart::signOut 使用。终止性靠 whenComplete 早于 then 注册保证。⏳ 该路径未上真机(改完它之后设备掉线),代价与验证步骤见 backlog.md「🟢 P3 — syncNow(ensureFresh:) 的登出 flush 路径没上真机」。
    • 🧹 同批把 stopwordsMergeProvider 一起挪了known_words_providers.dart,它的注释里原本就写着「和 autoSyncProvider 同一机制」):同样只被那两个页面 watch ⇒ 一个从不点进 Review / Profile 的用户永远不会跑那次 default_stopwordsknown_words merge,过滤器里一直冒常见词。宿主因此从 _AutoSyncHost 改名 _AppServicesHost
    • 🔴 挪它之前必须先加空串守卫 —— 那不是防御性代码,是挪动新开的暴露面currentUserIdProvider 在「未登录」和「auth 尚未 resolve」时返回的是空串不是 null(为了登出过渡期不崩而保留 _lastKnownUserId,首次登录前初值就是 '')。挂在页面上时碰巧安全 —— 那两个 widget 只在已登录时渲染;挪到 app 根后宿主在 Supabase init 完成那一刻就 watch,此时 authState 很可能还在 loading。真跑一次的代价是永久的:插进 211 行 user_id = '' 的孤儿(id 形如 rec:<n>:)—— 读侧 getAllForUseruser_id IS ? 配真 id 看不见、push 按 user_id = ? 过滤(#5h)推不走、clearLearningDataForUseruser_id = ? OR user_id IS NULL 也匹配不上空串。就是 #5d / #5h 反复讲的那种孤儿行,只是值从 NULL 换成了空串。
    • 回归 9 例(auto_sync_trigger_test.dart):防重入 3 例(并发合流 / 释放守卫 / 失败也释放)+ 宿主形状 4 例(main.dart 挂着 / 在 builder 里 / 两个 provider 都不是 autoDispose / 没有页面把它们 watch 回去)+ 空串守卫 2 例。八处注入缺陷逐个实证会红 —— 其中去掉空串守卫那次红在 Actual: [''],即确实会带空串去 merge。
    • 真机验证(2026-08-27,24094RAD4C + 真实 Supabase) —— ⚠️ 范围:覆盖 autoSync 与 stopwords,不含 ensureFresh 的登出路径(那一改动在此轮之后才加,见上条):⓪ stopwords 挪动后冷启动复验 —— known_wordsuser_id = '' 的行数为 0(211 行全是真 userId),日志里 Stopwords merge: triggered by login 只出现一次且带真 id,同页 60s 节拍不受影响(08:53:07 / 54:07 / 55:07 / 56:07);① 冷启动停在 ScanPage,60s 节拍精确无漂移(07:42:56 / 43:55 / 44:55 / 45:55 / 46:55 / 47:55)—— 修复前这个页面一拍都不会有;② 走 Scan → Me → 生词本(nav log 证明 ReviewOverviewPage 整个会话从未入栈,正是过去零同步的栈形状)删掉 david全程不导航,墓碑 07:52:50.42 → Supabase server_updated_at 07:52:55.995.6 秒;同批 8 行(1 entry + 6 link + 1 cloze,#6f 子树完整、#6g 规则 W 同值);③ 切后台 100 秒 → 日志 Auto-sync: paused,期间两拍(55:55/56:55一次都没跑,回前台 1 秒内补一轮。
    • 🔑 防假绿对照照做last_sync_attempt_at 全程按 60s 推进。这条纪律不因本次修复而作废,只是理由换了 —— 修复后「同步停了」依然会合法发生(app 切后台就停),任何「观察若干轮后某值没变化」的验证仍必须拿它作对照。
  • ↩️ 撤销删除只把词还回来、来源和语境永久丢失(#6f 当天引入的不对称,真机验证挖出) - (2026-08-27)

    • #6f 让删词带走整棵子树(links + cloze),但 restoreNotebookEntry(UI 上删除后 4 秒 SnackBar 的 Undo)仍只清 entry 的 deleted_at ⇒ 撤销后词回来了,而它的 6 条来源关联和 1 条真实语境永久留在墓碑状态用户看得见词回来、看不见来源没了,全程无报错。#6f 之前删除不碰 links,撤销才恰好对称 —— 这是本次改动新引入的,不是既有缺陷。
    • RB 侧不存在这条路径(其复活走 save_wordON CONFLICT DO UPDATE SET deleted_at = NULL),所以 RB doc 34 §5 承诺的「删了又加回来,来源还在」在 RVH 只能靠撤销路径自己兑现。
    • 修法:撤销在同一事务里复活子树,且只复活「这一次删除」带走的行 —— 按 entry 的墓碑时间戳精确匹配(deleted_at = <stamp>),而不是无条件 deleted_at IS NOT NULL。否则更早因 deleteBook 等原因软删的 link 会被一起错误复活(有专门反向用例)。
    • 3 例回归 + 两处注入缺陷实证(退回「只清 entry」/ 改成无条件复活,各自变红)。真机前后对照:删 music → Undo → 8 行(1 entry + 6 link + 1 cloze)全部 deleted_at = nullupdated_at 同为恢复时刻(同一事务)。
  • ⏱️ 墓碑行的 synced_at 写成本端时钟 → 时钟慢半分钟就永久回声环(红线 #6g,RB doc 38 契约镜像) - (2026-08-27)

    • 6 个 _pullXxx 的墓碑分支里 5 个synced_at 写成本端 now= syncStartAt)。本端时钟慢于对端、且慢过「对端删除 → 本端 sync 起始」的真实间隔时,deleted_at > synced_at 恒真 → 该行每轮被重推 → 服务端 trigger 刷新 server_updated_at → 对端下轮又拉回来,永不收敛。触发门槛 = 那个间隔的长度,实测 27 秒(从设备库里既有的旧代码产物反算:RB 删除 09:23:16.717 → RVH 落地 09:23:43.603,余量 26.9s;另有几行 45.1s)—— 早先写的「几秒」是按 autoSync 60s 估的理论最坏情况。方向与 2026-08-03 RB 那 57 行相反、形状完全一样:不报错、不产生错数据、两端 UI 都看不出异常,只静默烧配额和电量。
    • _markSynced 是第二个入口,且是永久的:一条从对端拉回来的墓碑,deleted_at 用的是对端时钟;本端时钟慢时 push 完 mark 一遍还是脏,下轮再推。RB 侧同款改动只是防御性(他们没有可达路径),RVH 这条真实可达
    • 契约四条(RB 把 #6d 从实现细节升格为双端契约):W 本端软删时 deleted_atupdated_at 写同值 · P1 pull 墓碑分支的 synced_at 只能由该远端行自己的列拼出、禁止任何形式的本端时钟 · P2deleted_at 取 MAX 且操作数不许为 NULL · P3 「写完立刻 clean」对每条写 synced_at 的路径成立(含 push 后的 mark)。W 与 P2 互为兜底 —— 不能靠「反正对端会守 W」省掉 P2,W 是对端写侧的一行代码,随时可能因一次无关重构而改变,而破坏是无声的。
    • 🔴 两个换个身份就复发的坑:① SQLite 多参 MAX() 只要有一个操作数是 NULL 就整体返回 NULLsynced_at = NULL ⇒ 命中脏检查第一支,同一个环从 P2 的实现里跑出来;而 word_page_links / reading_pages / word_cloze_contexts 三张表的远端 updated_at 本就可空、双端 push 也常不写它,这不是边角情况是常态。② 禁止 parse 成 DateTime 再比大小 —— 脏检查是 SQL 里的字符串比较,MAX 取的不是「时间上更晚的那个」而是「让脏谓词为假的那个」;...00.000Z vs ...00.000001+00:00 两种序相反('Z' > '0'),Dart/Rust 的小数位数还会随值变化,一旦分岔你算出来的「更大」在谓词眼里就是「更小」。推论:两端时间戳字面格式不需要统一
    • 实际改了 6 处而非交接单要求的 5 处:word_cloze_contexts 从前安全全靠 RB clear_cloze_context_for 恰好写同值(doc 37 点名的「未写进契约的耦合」),只改兜底解除不了,一并加 MAX。
    • 规则 W 在 RVH 本就 100% 合规(全仓 11 处软删站点逐处核对绑参),本轮只是写进条文并补上会红的 grep —— 其中三处正是 #6f 那轮刚补的 bump。
    • 存量不需要 backfill:写歪的行正在环里,而环本身保证它们每轮都被服务端重新发回来 ⇒ 修复上线后第一轮 sync 即自愈。有专门用例(先断言在环里 → 跑一轮 → 不脏)。
    • 真机验证(2026-08-27,真实 Supabase):把一条已是墓碑的 link 行的 deleted_at 改成 2099-01-01(等价「对端时钟远快于本端」;adb 无 root 改不了设备时钟,且改真机时钟会连累其他 app)。该行远端 updated_at 恰好是 NULL ⇒ 一次同时覆盖 P1 / P2 / NULL 陷阱。本地 synced_at 落成 2099-01-01(取自远端行,旧代码会写本端 now);server_updated_at 在 8+ 轮 autoSync / 9 分钟里逐字节不变 ⇒ 没有重推。🔑 并做了防假绿对照:同期 last_sync_attempt_at 每 60s 推进 —— 否则「没重推」可能只是「压根没同步」(本次差点踩到,见下条 autoSync 条目)。
    • 回归 test/features/sync/tombstone_synced_at_test.dart 11 例,五处注入缺陷实证;脏谓词逐字抄自生产代码的 _pushXxx WHERE,远端 deleted_at 一律用 2099-… —— 🔴 本缺陷在时钟正常时完全不显形,随手取个过去时间的测试是假绿,这也是它在两端各活了这么久的原因。#6g 的 6 条 CI grep 同样逐条注入验证(含「加第 7 张同步表漏改就红」的数量硬断言)。
    • 交接 37 → 契约 RB doc 38 → 回执 39
  • 🪦 删词不带走 word_page_links、pull 父行守卫把「墓碑」当「活」(红线 #6f,RB doc 34 镜像) - (2026-08-27)

    • 写侧deleteNotebookEntry 只软删词条本身 + word_cloze_contextsword_page_links 完全不带走,而且那两条 UPDATE 根本不在一个事务里。删词后云端留下挂在墓碑词条下的活 link —— 两端读侧查询一律带 deleted_at IS NULL,所以 RVH 和 RB 的 UI 都看不见,本地也没有任何检查会说话。已改为单事务三件套(词条 + links + cloze)。
    • 读侧_pullWordPageLinks两个父行守卫都是裸 COUNT(*),把「墓碑」并进「活」→ 对端删词后推上来的活 link 会被落进本地墓碑词条下。连同 _pullReadingPages 一起收敛到单点 _parentState(三态:活 / 墓碑 / 缺失)。⚠️ 另一个方向同样是缺陷:给守卫加 AND deleted_at IS NULL 会把「墓碑」并进「缺失」,而 watermark 只按取回的行推进(红线 #5d)—— 被 skip 的行下一轮再也够不着,是永久丢行不是「下轮再试」。RVH 三个父列全 NOT NULL,故两态动作相同、但代码里仍显式分开写。
    • 🔴 顺带堵上 RB 红线 #6d 那 57 行回声环的原始出处deleteBook 软删 link 时只写 deleted_atupdated_at 停在旧值 —— 2026-08-03 RB 实测的 deleted_at=04:53 / updated_at=01:15 就是这行代码的产物,导致对端每 60s 重推、server_updated_at 被自己不断刷新、永不收敛,不报错、只静默烧配额。RB 当时用 synced_at = MAX(…) 兜住了症状,源头一直没修。本次三处软删子行全部补 updated_at bump。
    • RB 漏的第三条路径 RVH 不存在,且是产品语义差异不是遗漏:RB 的 add_known_word 会软删 learning_entries,而 RVH 标「已认识」只写 known_words、不碰生词本(是过滤器不是删除)⇒ 无子树义务。日后若改成「标已认识即移出生词本」,红线 #6f 立刻适用。
    • 四处判为不适用并写明理由:deleteBook 不带走 cloze(级联键 source_url 恒 NULL,红线 #5g 既定取舍)· cloze pull 无父行守卫(与 RB 同形,单端加会破坏 #5g reconcile 的「两端同规则」前提)· 巡检 tombstone_parent 不重复实现(服务端一份两端共用)· 跨端并发残余(设计残余非 bug,写侧根治不了)。
    • ⚠️ 两仓红线编号已分叉(RVH #5h = RB #5i、RVH #6c 是封面 BLOB 而 RB #6c 是父行三态、RVH #6d 是 last_opened_at 而 RB #6d 是 synced_at MAX)。没有重编号(交叉引用面太大),改为在 CLAUDE.md 头部加对照表,新条款取 RVH 侧的 #6f。跨仓引用红线请带仓名。
    • 回归 test/features/sync/tombstone_parent_test.dart 10 例(含两条反向断言:别的词的 link 一条不许动 / 两个父都活着必须正常落地),三处注入缺陷各自实测变红。另修一条被本改动搞失效的 #6e CI grep,以及新写的 #6f grep 第一版假红(误伤 _pullLearningEntries 里合法的本地行存在性检查)。
    • 回执 docs/cross-end/36-rvh-tombstone-parent-confirmation.md;留下的待办(pull 侧 synced_at 未与 deleted_at 取 MAX)见 backlog.md

Changed

  • 🔓 红线 #5g 改写:word_cloze_contexts 由「RVH pull-only」改为「双端共写」 - (2026-08-26)

    • RB 侧 2026-08-17 就已裁决批准(~/reading-browser/docs/cross-end/23-rb-ocr-cloze-context-decision.md:方案 A 双端共写 + 四条硬条件,不退 D),但 RVH 侧的红线 / 计划 / doc 22 / backlog 四处全停在「等裁决」9 天没人接。本次复核 scan-ocr-positioning-enhancement-plan.md 时发现并补齐。
    • 放开的依据不是「破例」:池子按 word 单键组织,无来源维度、无 FK、无 UNIQUE,封顶 5 是每词全局 5 —— 「RB 的池」这个对象从来不存在,pull-only 是当时的事实分布而非数据模型约束。RB 的 reconcile_cloze_pool 本就是为对称双写设计的(单向 pull 只需一次 INSERT)。
    • 四条硬条件(C1–C4)全文镜像进红线 #5g,其中 C3 是 RB 复核时新发现、RVH 自己没看出来的:cloze 的 created_at 是两端 reconcile 的排序键兼「留新删旧」的唯一依据,RB 写 UTC RFC3339 而 RVH 采集层用裸 DateTime.now()(无偏移本地串)—— UTC+8 下字典序让 RVH 的行恒显晚 8 小时,淘汰规则悄悄变成「留 RVH、删 RB」,无任何报错或不一致可被观测
    • ⚠️ RVH 侧采集代码尚未落地,红线放开的是许可不是既成事实。立项见 backlog.md「OCR 语境句入 word_cloze_contexts」。
  • 🎛️ Harvest Station 的 CEFR 按钮组去掉 PENDING / UNKNOWN 两档(6 档制) - (2026-08-26)

    • 这两个值是 CefrLevel 枚举的尾部诊断桶,不是用户可用的难度档,当初是被机械地当成第 4 个 band 塞进按钮组的。
    • PENDING 在本地恒为 0:预装库质量门槛明令禁止 PENDING 入库(app_database.dart::importPreinstalledVocabulary 撞上直接 throw),实测预装库 "cefr":"PENDING" 命中 0 行;唯一可能的来源 Supabase 共享池,其历史 PENDING 存量(94,063 条 kaikki 垃圾行)已在 v51 重建时清空。等于一个恒显「0 / 待补充」的死按钮。
    • UNKNOWN 是只读死胡同:计数通常不小(功能词 / 专名 / OCR 噪声,真机日志一页 10 个),但 WordCard 对它禁用点击、addSelectedToNotebook 保存时直接跳过 —— 用户看得见、点不动、存不进。
    • 收益:按钮从 8 个减到 6 个,A1–C2 每个宽约 +33%(此前 labelSmall 里还要塞「待补充」三个字,是该页最挤的一块);顺带修掉下面那两个只在这两档上触发的计数缺陷。
    • 诊断能力未丢:「本页有多少词没认出来」看日志 ❓ UNKNOWN 保留 (n): ...。⚠️ 若日后 Supabase 又冒出 PENDING 词,正确修法是在上游给它一个真实等级,不是把按钮加回来。
  • 📦 预装词库小补轮接收(v28 → v29,18,925 → 18,916 词) - (2026-08-26)

    • RB doc 33:例句层露骨清理(删 66 条例句 / 8 条义项,例句 86,817→86,693)+ 校准闸门 1(删 9 个露骨/网络垃圾词)+ 补 per-POS cefr。db SHA ae33c8a4…06cbb4dc…
    • 为什么例句层非清不可:round30 的露骨词清理只做了释义层,而同一轮又给全部例句配了中文译文 —— 此前中文母语学习者可能一眼扫过英文脏话,之后 slur 是用母语呈现的。最刺眼的一条挂在 A1 词 date 上。RVH 侧影响小于 RB(doc 32 已查明 buildFallbackCloze 无人调用,这些句子只出现在查词详情页、不进复习卡)。
    • 修掉了 RVH doc 32 §5.2 上报的 per-POS cefr 缺失:212 个 POS 块补齐,非短语词全 POS 齐全率 98.7% → 100%(本端复验缺口 0)。根因是 round30 两个载荷各漏一处:_applyFoldname 块时没写 cefr 键(157/157)+ _applyAdd 直灌素材(55/168)。
    • ⚠️ per-POS cefrprimary_cefr_level 本就允许不同,不是二选一(Oxford 5000 给的就是分词性 CEFR:above.adj=B1above.prep=A1)。RB 只补缺失、不覆盖已有值 —— 他们仓里记着一次把 per-POS 拍平成 top-level 的事故(~6,271 个多词性词丢了分词性区分)。
    • lemma_* 三表仍然一行未动 ⇒ assets/nlp/*.json fixture 无需同步。
    • 🔴 prune 的删除分支首次在真机升级路径上真实触发:doc 32 §7.1 当时如实标注过「全新安装时本地词库 = 资产词库,删除分支天然测不到」。本轮是 v28→v29 升级,设备上本来就有那 9 个词 —— 日志 🧹 Prune: 9 stale preinstalled words → [blowjob, bukkake, cunt, …] / deleted 9, deferred 0,设备库复查 18,916 行、9 词 0 存留、pending_vocab_prune=[]方案 A 的通用集合差设计得到验证:不是硬编码 53 词清单,本轮零改动自动生效。
    • ourselves 前后对照(真机,同一张图同一条路径):v28 CEFR 筛选 14 → 13 通过, 1 过滤 + 🚫 ourselves(UNKNOWN);v29 14 → 14 通过, 0 过滤📊 A2: rather, latter, ourselvesalways 仍 A1,P1 未回潮。
    • 详见 docs/cross-end/32-rvh-round30-confirmation.md §9(回执并进该文,按 RB doc 33 §1 的建议不单开一份)。

Removed

  • 🧹 Scan/OCR 计划复核的三处死代码清理 - (2026-08-26)
    • 6 个零引用 l10n keyphotoSwitch / photoSwitchNoteTitle / photoSwitchNote{Camera,Gallery,Share,Change}Msg):Task A 删掉「切换笔记会丢弃 N 张」冲突弹窗时留下的,计划里记了 5 个、实际还有第 6 个。
    • RecognizeAndFilterParams.noteId 整条管道:从 processImage 一路传到 params 却从未被读过。它是 Task A 风险点「processImage(noteId: null) 无副作用」成立的真实原因 —— 不是守卫写得好,是压根没人读。删 params 字段 + PhotoRecognitionService 三方法 + PhotoRecognitionNotifier 三方法 + 2 个调用点;FilterConfirmationPage(noteId:) 是真实消费方,保留。
    • 整条 PhotoRecognitionNotifier 链路(4 个文件)notifier / provider / state / state.freezed。Phase 2 重构留下的一套完整 StateNotifier,四文件互相引用、对外零消费 —— 实际识别入口早已是 capture_queue_notifierPhotoRecognitionService。保留 photoRecognitionServiceProvider 与 Service 本体(在用)。
    • RecognizeAndFilterParams.sourceName:与 noteId 同一种死法。reading_page 的 name 由收割时的 filter_confirmation_notifier 自己拼('Scan <时间>'),识别管线不参与命名。
    • capture_queue_notifier 的 dartdoc 修正指向已删除的 [ensureSession]
    • 全部删除后 flutter analyze lib test 0 error / 0 warning、flutter test 1202 passed 且一处引用都不必改写 —— 印证这些是纯悬空代码而非「暂时没人用」。

Fixed

  • 🐛 Harvest Station:切换 CEFR 档位时的两处选中态缺陷 - (2026-08-26)

    • ① 词数虚高且取消不掉_filterWordsByLevels 给新进入列表的词取 previousWord.isSelected,而 orElse 兜底恒为 isSelected: true —— 与 FilterConfirmationState.initial(UNKNOWN / REFERENCE 默认不选中)不一致。于是这批词以「已选中」进来、WordCard 又对它们禁用点击,底部「Add N words」和收尾 SnackBar 的 N 都比真正入库的多,差额静默丢弃。兜底改为与 initial 同一条规则。
    • ② 参考词被挤出列表:同一个过滤条件没像 initialvisibleHighlightedSources 那样给 REFERENCE 开豁免,导致用户点任意一个 CEFR 按钮都会把参考词整批挤出 words,原图视图上它们的高亮随之消失。补上豁免,三处判定归一。
    • 连带修:toggleCefrLevel 的自检日志把期望词数算成「选中级别计数之和」,参考词不在其中 —— 加豁免后会稳定误报 MISMATCH!,已把 referenceCount 计入期望值,免得日后有人照着假告警去查一个不存在的 bug。
    • 回归测试 test/features/vocabulary_filtering/presentation/cefr_toggle_default_selection_test.dart(5 例,驱动真实 toggleCefrLevel)。两处均已用注入缺陷反向验证会红:兜底改回恒 true → 第 2 例失败;删掉 REFERENCE 豁免那行 → 参考词用例失败(Actual: ['apple'],kilo 被挤掉)。
  • 🐛 预装库 prune 终于能生效:删掉的词条不再在存量安装上永远留着(预装库 v28 / round30) - (2026-08-26)

    • 现象:RB 侧从预装库里 prune 掉死词形(alway / rath / trie / ye …),存量安装照样留着它们 —— 光 bump _preinstalledVocabVersion 不生效。连带 P1(OCR 拍到 always 被模糊匹配纠回 alway)在存量安装上不治,因为 OCR 纠错器的候选表是 SELECT LOWER(word) FROM vocabulary 全表不分 sourcealway 还在库里就还是编辑距离 1 的候选。
    • 根因_checkAndImportPreinstalledVocabulary纯 UPSERT 只增不减,唯一的 DELETE 只清 source='backfilled'
    • 修法:UPSERT 之后加一步 prune —— 本地 source='preinstalled' 但新库已不含的词条要删掉。
    • 🔴 实施中翻掉了一个此前跨端文档双方都默认成立的前提:软删并不释放 FK。 learning_entries.word → vocabulary(word)ON DELETE RESTRICT,且本项目 PRAGMA foreign_keys 是真开的。软删只是给行打 deleted_at —— 行还在、word 列仍指向被删词条,RESTRICT 照样把 DELETE FROM vocabulary 拒掉。所以「软删用户那条 rath → 就能删词条」是错的。
    • 拆两阶段没有PRAGMA foreign_keys=OFF 绕,那会留下指向不存在词条的悬空 FK,正是红线 #6b 那类 footgun):阶段一 prunePreinstalledVocabulary —— 无人引用的直接删,被引用的先写墓碑(软删生词本条目 + 其 word_page_links + 该词的 word_cloze_contexts,红线 #5g)并把词名记进 app_metadata['pending_vocab_prune'];阶段二 retryPendingVocabPrune(每次启动跑一次,待办为空时是一次 KV 读的 no-op)—— 墓碑确认已 push(synced_at >= updated_at)后硬删残行释放 FK,再删词条。
    • 阶段二为什么必须等 push:删除要跨端传播(红线 #6)。墓碑没上行就把本地行抹了,删除信号永久丢失,RB 会把 alway 条目再推回来。word_page_links 也要单独等 —— 硬删 entry 会 CASCADE 掉它们,把尚未上行的 v55 墓碑物理抹掉
    • 产品决策(用户定):53 个被删词一律软删用户生词本条目,不做词形迁移。RB 给的 38 个迁移目标(31-deleted-word-migration.tsv)未采用 —— 迁移会改同步表的自然键 UNIQUE(user_id, word),保 id 改 word 则 push 时 on_conflict=(user_id,word) 匹配不上、退化成 INSERT 又撞 PK(id);而当时双端用户数据已全清,没有真实条目可迁。
    • 回归测试 test/shared/database/preinstalled_prune_test.dart(8 例,跑真实生产代码 + 真实 DDL,无 SQL 转录);两处注入式反向验证已实测会红。
    • 残留风险(未做,已知情):pull 路径对缺失词条有 backfill 兜底,若 RB 端仍留着 alway 的生词本条目并推上来,RVH 会把它以 source='backfilled' 回填复活 ⇒ P1 可能回潮。当前不可达(双端数据已全清、两端字典都无该词形),故不建 blocklist;已在 doc 32 §5 提请 RB 清理。P1 若复现先查这条。
    • 真机复验(Redmi 24094RAD4C):全新安装 v0 → v28、18,925 词导入、prune 步骤正常执行;设备库实查 53 个被删词全部不存在。P1 走真实 OCR 拍照路径验证(ACTION_SEND + image/jpeg 喂图,模糊匹配开着且本次实修 5 词):always / rather / during / latter 全部原样通过,未再变成 alway / rath / dure / letter。⚠️ 如实标注:全新安装时本地词库 = 资产词库,prune 的删除分支在全新安装上天然测不到,那部分由单测覆盖。
    • 详见 docs/cross-end/32-rvh-round30-confirmation.md(RVH 回执)+ RB 交接单 doc 31。

Changed

  • 📦 预装词库 round30 整库 reseed 接收(v27 → v28,18,898 → 18,925 词) - (2026-08-26)

    • RB 单点产出、RVH 整包接收(红线 #10 byte-equal,未改一字节)。db SHA 2880c504…ae33c8a4…(55MB → 63MB)。
    • 五项内容:prune 53 行(死词形 + 7 个露骨词)· 补 80 行(always / rather / during / ourselves 等正确词形首次有本地词条,不再走 Supabase 缓冲池兜底)· proper_noun 标签 871→1,055 · 157 个存量专名折入 name POS 释义 · 例句中文译文 86,817 处全覆盖
    • ⚠️ lemma_* 三表一行未动(140,277 / 101,713,与旧库逐行 hash 相等),所以 lemmatizer 行为与 v27 完全一致、assets/nlp/*.json fixture 本轮无需同步。这与 v26/v27 相反(那两轮改的正是 lemma 表),别惯性去改。跨端 phase0 已复验 byte-equal。
    • 🔴 例句译文进库了但绝不能上卡面正面pos_definitions[].definitions[].example_translations 是与 examples 等长的平行数组。cloze 防泄露只遮英文里目标词的各种形态、对中文一无所知,而中文译文必然含目标词词义 = 直接把答案递给用户,且这类泄露不报错不崩。揭晓侧 / 详情页可显示。核实结论:当前 lib/零消费PosDefinition.parseFromDatabaseJson 只读 examples。已补回归测试 test/features/vocabulary_notebook/cloze_example_translation_leak_test.dart(3 例,含注入式反向验证)守将来接线。
    • 🔶 真机实测挖出一个新缺陷(已记 backlog,RB 侧修):新补的 80 个词里 25 个的 pos_definitions 缺 per-POS cefr(全库非短语词的基线只有 29/12,315,其中 4 个还是存量专名 —— 基本是本轮新引入)。RVH 拍照管线的难度筛选读的正是 per-POS cefr,不是 primary_cefr_level ⇒ 这些词被判 UNKNOWN 静默丢掉。16 个是专名(筛掉反而符合专名治理意图),但 9 个正经可学词受损ourselves(A2) measles confetti impending insignia undersigned unqualified bioethics cheerleadingourselves 尤其讽刺 —— 它正是本轮为补 lemmatizer 缺口而加的词,结果「查得到」✅「拍照会推荐」❌。
    • 📝 对 RB 交接单的两处事实订正:doc 31 §4 的 SHA c875b3db… 写错(实际 ae33c8a4…);§6 说 buildFallbackCloze 是复习卡回落路径,实测它在 RVH 全仓无人调用 —— RVH 卡面走 buildContextCloze(真实语境),不碰预装库例句。
  • 🐛 lemmatizer 资产错映射修正(预装库 v26):rather 不再归一成古语词 rath - (2026-08-18)

    • 现象:分享含 rather 的英文 → 归一成 rath,而 rath 在预装词库里是有音标 /ɹɑːθ/ 的 A2 词(古语「早的」)→ 以正经生词身份进了复习卡和生词本。用户看到一个自己从没读到过的词。同类还有 alwaysalwayhishibasedbas
    • 根因不是 stemmer 猜错,是资产里就写着这条映射:AGID 把 rather 当作古形容词 rath 的比较级 —— 历史上成立,对现代学习者无意义。
    • 修在 RB 侧(红线 #5e:资产由 RB build_dict.rs 单点产出,RVH 纯消费)。全量扫描后共修 178 个 surface:REMAP 89 条(截断残根→正确 lemma:based→base · changed/changing→change · tries→try · latest→late · routing→route · batteries→battery)+ SELF 89 条(surface 本身即现代词条,强制自映射:rather always sometimes latter during yes clothes opera 等)。people→person / could→can / its→it / better→good / longer→long 等本就正确的映射有哨兵保护,未动。
    • ⚠️ 只删错映射不够 —— Layer 3 会把答案原样推回来rather 不在 base_forms,删掉 Layer 1 映射后落到 Layer 3,后缀规则 "er" 提议的 stem rath 恰在 base_forms 里、裁决通过 → 仍返回 rath(注入缺陷实测:rather → rath, layer=suffix-validated)。正解是两步:删映射 + 把该词补进 base_forms,让它在 Layer 2 就被截住。故回归用例除结果外必须连 layer == 'base' 一起断言,否则日后「只删映射、漏补 base_forms」的复发能蒙混过关。
    • 🔴 RVH 必须 bump _preinstalledVocabVersion 25→26(与 RB 侧「不 bump」结论相反,差异在读取路径):RVH lemmatizer 读 documents 目录的拷贝 preinstalled_vocab.db,刷新它的唯一动作是 _checkAndImportPreinstalledVocabularywriteAsBytes,而该函数开头 if (currentVersion >= _preinstalledVocabVersion) return; 会早退跳过;自愈 _ensureLemmaDbReady 只探测 lemma 表存不存在,「表在但数据旧」是它的盲区。不 bump 则本次修复对存量用户完全无效。
    • 两个资产 SHA256 都变(红线 #5e 已更新):surface_to_base 140,370→140,281base_forms 101,646→101,709。预装库只重烤 lemma_* 三张表vocabulary 18,898 行未动(整库重跑需为 ~12k 词重新付费 LLM 翻译且 pipeline 中间产物已丢失)——代价是 alway/rath/lat 等 91 个旧 headword 成不可达死行、always/rather 暂无本地词条走 Supabase 兜底,下次整库 reseed 自然收敛。
    • 另修一条陈旧断言lemmatizer_test.dartexpect(lemmatize('during'), 'dure') 把缺陷锁成了「已知 AGID 限制,待 T-B 解决」。during/outstanding 都在 SELF-89 名单里,资产修好后它是唯一变红的既存用例 —— 注释里那句「待 X 解决」不会在 X 解决时提醒任何人。
    • 跨端 byte-equal 复验:两端当场重跑 phase0,三个输入集(lemma-regression.txt 99 词 / phase0_inputs.txt 131 词 / 本轮改动的 178 个 surface)全部 byte-identical。⚠️ 顺带查明 docs/cross-end/{rb,rvh}.csv 存档证据提交于 2026-04-26、早于词典优先架构重构,layer 取值还是旧架构的 suffix/irregular/exception、内容留着 Bug B 过度剥离(Paris,pari / women,women),两端一起陈旧故彼此仍相同 —— 不可再作现行验收依据
    • 存量脏数据:裁定不做本地迁移(内测用户会重装),但重装清不掉 —— 那些行同时在 Supabase user_* 表里、重装重登会 pull 回来且拉得进来(rath 仍是 headword)。清理在云端软删一次即可,见 backlog。
    • 详见 docs/cross-end/25-rvh-lemmatizer-v26-confirmation.md(RVH 回执)+ RB 交接单 doc 24;待办见 backlog.md
  • 🐛 文本来源的复习卡一直是「无阅读来源」—— 一条三层深的断链(真机验证挖出) - (2026-08-17)

    • 分享正文 / 文本导入的词,复习卡正面从来没有显示过 TextSpotlightView,一律落到 NoSourcePlaceholder。三处缺陷叠在一起,每一层单独看都不报错,数据全程静默落错flutter analyze 与单测一个都发现不了。
    • 第 1 层 · 占位符逃逸buildMergedResult 把文本项的 imagePath(占位符字符串 'text_import',不是路径)当成「原图路径」回填进合并结果。下游 filter_confirmation_notifier 正是用 originalImagePath != null 判定这批是图还是文本 → 纯文本会话被当成图片来源,落库 source_type='image' + image_path='text_import'
    • 第 2 层 · 模型漏列ReadingPageModeltoDatabase()fromDatabase() 都没有 ocr_text —— v25 随列删除,v40 把列加回来时模型没跟上,于是 createReadingPage(ocrText:) 一直被静默丢弃。⚠️ 两个方向必须同时补:只补写入的话,任何一次 model 往返(读→改→写)都会把跨端同步来的 ocr_text 抹成 NULL。
    • 第 3 层 · 加载器只认整本全文_buildTextSourceInfo 只在 note 有 fullTextPath(电子书导入产物)时才返回来源,从不读 page 级的 ocr_text。挂在跨端同步来的笔记下时必然落空 —— 那些笔记根本没有 fullTextPath。改为页级文本优先(它本身就是用户当时选中的那段,即语境),整本全文降为回落;超长时围绕目标词截窗口并补省略号。
    • 真机验证:分享一段话 → 收割 → 复习卡正面正确渲染出原句。

Added

  • 🔗 选书对话框显示「最近阅读」+ scan 页「继续《X》」入口(跨端续读锚点) - (2026-08-15)
    • 动机:两端 reading_notes 早已是同一张表,但 scan 页对浏览器那一端完全无感知 —— 读起来像两个 app。红线 #6d 专门同步过来的 last_opened_at 也一直「无 UI 消费方」。
    • 来源判定用 last_opened_at 本身,不用 note_typewebArticle 在 RVH 侧的文本导入路径也会产生,并不专属浏览器端;而 last_opened_at 按红线 #6d 是「仅那端 touch 路径写入、本端 pull-only」,有值 ⟺ 在那边读过是确定的。一个字段同时承担来源与新鲜度。
    • 选书对话框:读过的排前(按最近阅读倒序),每行补一句「最近阅读 2 小时前」。排序走分组拼接而非整体 sort(Dart List.sort 不保证稳定,整体排会打乱「没读过」那组原有的 updated_at 次序)。
    • scan 页「继续《X》」:一行,点击开相机并预设该笔记。这是唯一一条快门前就归档的路径,但不是上面那条「归档后置」的回退 —— 归档目标印在用户点的那个按钮上(不是隐藏默认值),且收割时仍可改,这正是被否决的「隐式全局预选」所缺的性质。且只认领未归档的批次:若 in-flight session 已归到别的笔记,点它不会把批次悄悄拖过来。
    • 文案不出现内部简称:复用阅读笔记列表页的 readingNotesLastRead("最近阅读 {time}"),两处界面对同一件事说同一句话;品牌已统一为 Lampio,UI 里不该露出另一端的内部代号。
    • 克制边界:scan 是动作页不是 dashboard —— 一行封顶,不做卡片墙;那端的阅读时长 / 进度 / 书架不搬过来。
    • 测试test/features/photo_recognition/book_selection_sort_test.dart(5 例,含「未读组次序不被不稳定 sort 打乱」专项)。

Fixed

  • 🐛 选书对话框「加载更多」会把整个列表重复一遍 - (2026-08-15)
    • getAllBooks() 没有 limit/offset 参数,一次就返回全部笔记;_loadMoreBooks() 再调一次拿到的是同一份列表,然后 addAll 进去。笔记数恰好等于 _pageSize(20) 时 _hasMore 为 true,滚到底即触发。
    • 既然拿到的本就是全量,这里没有第二页可翻 —— 分页状态与 _onScroll 一并删除。

Changed

  • 📌 归档决策从快门前移到收割时(三入口一并) - (2026-08-15)
    • 动机:拍照 / 图库 / 分享三个入口此前无一例外首行就弹「这属于哪本书」。纸质书场景勉强成立(用户确实在读一本确定的书),但在字幕暂停、截图分享这类场景里,最轻的入口压着最重的决策 —— 而对端 RB 是双击查词、零归档决策、自动挂当前页。
    • 不削弱防误选:「三入口统一强制选 note」当初要根治的是隐式预选(以为选的是 A 书,其实预选的是 B 书)。显式确认没有取消,只是移到收割时 —— 那时识出的词已在屏幕上,判断成本低;且 ConfirmButton 在未选 note 时禁用,批次提交不出去。变的是何时问,不是问不问。
    • 「冲突」概念随之消失:旧语义下 session 从出生就归属某个 note,换 note 只能丢弃整队列(那个「切换会丢弃 N 张」对话框)。归档后置后 session 出生时无归属,换 note 变成重新归档,一张不丢ensureSession / _resolveSession / _showBookConflictDialog 三者随之成为不可达代码,删除。
      • ⚠️ 遗留:photoSwitchNoteTitle / photoSwitch / photoSwitchNote{Camera,Gallery,Share}Msg 五个 l10n key 变为零引用,尚未从 ARB 删除。
    • 顺带修掉一类空 session 垃圾:三处原先在「权限请求 / 图库选择 / 文本管线」之前就建 session,用户拒权限、取消选图或文本没识出词时,磁盘上会留下空 session。改为确实有内容要入队时才建。
    • 新增只有两个方法ensureSessionForCapture()(开一个不归属任何 note 的 session)+ assignSessionToNote()(归档 / 重新归档)。其余「可空化」工作量本就不存在 —— CaptureSession.noteIdCaptureCameraPage 的两个参数本来就可空,HarvestBar 早有 placeholder 机制,ConfirmButton 的选书行是完整实现只是被 showNoteInfo: false 关着。
    • 行为变化留意:现在可以不收割就连拍 A 书和 B 书,两批进同一 session,收割时只能归到一个笔记(旧模型靠丢弃对话框强行阻止)。判断是可接受 —— 收割页会列出所有图,用户是看着内容做归档决定的;想分开就先收割再拍。
    • 测试test/features/photo_recognition/capture_session_filing_test.dart(6 例,已用注入缺陷验证:把 ensureSessionForCapture 改成无条件新建后 2 例失败)。

Added

  • 📥 接收 text/plain 分享 —— 采集通道从「只收图」扩到「也收字」 - (2026-08-14)
    • 动机AndroidManifest.xml 此前只声明 image/* 的 SEND/SEND_MULTIPLE,用户在微信 / Chrome 里选中一段文字点分享,Lampio 根本不出现在分享面板里。而文字比同一段文字的截图在每个维度都更优 —— 不过 OCR、无识别错误、无版面猜测。
    • 下游全是现成的text_import_debug_page 早已跑通「文本 → RecognizeAndFilterUsecase」,addPrecomputedResult / isTextImport / source_type='text' / 复习卡 TextSpotlightView 都在位。本次只补了「入口 → 管线」这一段。
    • wire 格式取类型自判别:同一条 EventChannel 上 List = 图片路径(原样不动)、String = 分享文本。已上线的图片路径零改动。
    • 补了一处会让功能空转的回填filter_confirmation_notifier文本分支(单来源兜底)此前不传 ocrTextreading_pages.ocr_text 落 NULL → 复习卡 TextSpotlightView 渲染空白。
    • 两个护栏:① 裸 URL 拒收 —— 浏览器「分享页面」把 URL 塞进 EXTRA_TEXT,跑词汇管线会收出协议名和域名碎片;判据 ^https?://\S+$,含 URL 的正常段落有空白字符不受影响。② 整段词都已认识时给明确提示,否则分享看起来像什么都没发生。失败与空结果分两个标志位,因为 SnackBarUtils 显示前会 clearSnackBars(),共用一个分支会让「没找到新词」把真实错误顶掉。
    • 仅 Android;iOS Share Extension 另行排期(成本评估见 USER_NOTES 2026-03-05)。
  • 🔁 收割成功后可直接去复习 - (2026-08-14)
    • 收割完只弹一个 toast 就结束,采集与复习之间没有衔接。snackbar 加 action 跳 Review,duration 2s → 4s(够不着的 action 等于没有 action)。复用既有 commonReview,未新增 l10n。

Fixed

  • 🔑 recent_note_id / selected_note_id 两套 prefs key 各写各的 - (2026-08-14)
    • 收割成功后 harvest_station_pagerecent_note_id,但 scan 页选书对话框读的是 selected_note_id —— 对话框默认值学不到用户实际收割进了哪本,一直停在「上次在对话框里点过的」。四个调用点两套 key。
    • 统一走 SelectedReadingNoteService(此前有 provider 但零调用)。保留 selected_note_id 作 key:scan 页高频写入,存量值最新鲜,统一到它不会让用户的默认选项失忆。方法名同步改成 recent-* 语义 —— 「selected」在「三入口统一强制选 note」时就已经不是全局选中态了。

Removed

  • 🧹 清掉复习端单来源时代的遗留 - (2026-08-14)
    • ReviewWordItem.sourceImagePath / sourceId:都只从 multiSourceInfofirstSource 取值,被 v7 的 sources 取代后载入 state 却无任何渲染消费
    • multi_source_bottom_sheet.dart / source_reveal_panel.dart / original_image_viewer.dart(共 798 行):lib/ 与 test/ 内零引用。三者的功能(看来源、看原图)已被复习卡 GestureReviewCard → ImageSourcePage 完整取代且做得更好(原图 + OCR 词位高亮 + 聚光灯呼吸 + 多来源 PageView)。
    • ⚠️ 顺带纠正一个曾经的判断:「OCR 原图在复习端零渲染」不成立,原图一直在渲染,只是走 sources 而非 sourceImagePath。详见 docs/plans/scan-ocr-positioning-enhancement-plan.md §0。

Changed

  • ⬆️ sqlite3 3.5.0 → 3.5.1(随一次依赖整体重解析)- (2026-08-14)
    • third_party/sqlite3/ 的两个 vendored 预编译库同步换成 3.5.1 版本,sha256 与包内 asset_hashes.dart 期望值逐个核对;README 的版本 / 哈希 / 下载 URL 同步更新。
    • ⚠️ 同批 pubspec.lock 还升了 supabase 2.26.0→2.27.1 / gotrue 9.5.0→9.6.1 等约 10 个包,尚未做真机同步链路回归
  • 📖 词详情补齐 4 个「库里有、UI 从不显示」的字段(v19/v53/v54 预装库数据) - (2026-08-14)
    • 起因:对照 RB 端 MyLearning 对等功能时发现,预装库 lampio_dict.db 里的 emoji(505 词)、canonical_surface(6607 行)、per-sense differentiation(5333 词)、collocations(5601 词) 四组数据,RVH 导进了本地库却全项目零处渲染app_database.dart:644-645 有 import,grep 全仓无消费方),等于白背存储成本。RB 侧四者都已在用(WordSenses.tsx / WordIllustration.tsx)。
    • 零新增依赖、零联网:数据本就在本地且双端 byte-equal。emoji 不打包 OpenMoji SVG —— OpenMoji 的 hexcode 本身就是 Unicode 码点序列(1F34E / ZWJ 序列 1F9D1-200D-1F3A8),移动端系统 emoji 字体覆盖完整,emojiGlyph getter 转字素直接当文字渲染即可(RB 桌面端打 2.4MB SVG 是因为 webview 端字体不一致,那个理由在移动端不成立)。
    • 展示位:词图置于详情页顶部(图像先于文字被感知,正是具象名词配图的价值);搭配 / 辨析挂在各自义项之下(per-sense,不上提到 POS 级 —— 辨析的语义锚点是「在这个义项下 X 和 Y 差在哪」,跨义项合并会张冠李戴);canonical_surface 用于详情页标题,让习语显示 raining cats and dogs 而非归一键 rain cat and dog
    • ⚠️ 归一键零改动:新增 displayWord getter 只服务展示,word 仍是匹配 / 同步唯一依据(红线 #5e / #10)。测试里专门断言了这点。
    • 修掉两处会静默吞字段的既存路径(都是加字段时才暴露的):① VocabularyEntity.copyWith 是手写的,不加就会把新字段抹成 null;② 本地读走 PosDefinitionModel 的解析器 + toEntity(),与 PosDefinition.parseFromDatabaseJson两条各自解析同一份 JSON 的路径 —— 只给其中一条加字段,UI 上看到的仍是 null,故两条都加了。(顺带记录一个未修的既存缺口:senseValueTiers 只在后者解析,本地读路径上恒为 null,导致 v52 的 effective* 过滤在该路径退化为透传。不在本次范围。)
    • POS 合并处补了对齐 bug_mergePerSense 会先把两段 per-sense 列表各自补齐到自己的 definitions 长度再拼接。直接 [...?a, ...?b] 在一侧为 null 时会错位(3 义项无辨析 + 2 义项有辨析 → 拼出长度 2 却要对齐 5 条释义,第 0 条释义会挂上本属第 3 条的辨析)。
    • 测试test/features/translation/pos_definition_extras_test.dart(9 例,含畸形 JSON / 半截 ZWJ / value_tier 裁剪对齐) + test/features/vocabulary_filtering/vocabulary_model_extras_test.dart(5 例,固定住本地真实读路径 DB row → Model → Entity 不丢字段)。
    • 实机验证(2026-08-16,小米 24094RAD4C):frog 词图正常;artist 的 ZWJ 序列 1F9D1-200D-1F3A8 在设备字体上正确合成为单个 🧑‍🎨(不是「人 + 调色板」两个字素)—— 这条是"不打包 OpenMoji SVG"这个决定成立的前提,自动化测试验不了,必须真机看。rain cat and dog 标题显示 rain cats and dogs。搭配 / 辨析只出现在 frog 的义项 3 下,义项 1/2/4/5 干净,per-sense 索引对齐在真实数据上成立。
    • 已知观感问题:同一义项下搭配出中文、辨析出英文(数据形状不对称,collocations 只有 note_zh)。已记入 backlog「P3 — 词详情同一义项下搭配出中文、辨析出英文」。
  • 📚 阅读笔记列表:按类型筛选 + 按「最近阅读」排序 - (2026-08-14)
    • 动机:列表此前只按搜索词过滤reading_note_list_state.dartfilteredNotes 只看 searchQuery,页头注释写的「过滤器(书籍类型)」是过期描述)。笔记条目随阅读量单调增长,手机上需要收敛手段。
    • 筛选:NoteType 四类多选(实体书 / 电子书 / 网页文章 / 测试文章),空集 = 全部;复用既有 4 条 l10n,未新增类型文案。布局对齐生词本 / 已认识词页(搜索框 + FilterToggleButton 收起式面板 + WordResultCountBar)。
    • 排序:最近阅读(默认)/ 最近更新。前者启用了红线 #6d 那个「v52 落地至今无 UI 消费方」的 last_opened_at,且用的正是该条款当初预留的查询形状。
    • 实现要点getLastOpenedAtByNote() 一次查询取回全表 noteId → MAX(last_opened_at),不在既有循环里再加一次 N+1;排序走分组拼接而非整体 sort(Dart List.sort 不保证稳定,整体排会打乱「从未阅读」那组的 updated_at 次序)。RVH 自采笔记无 touch 路径故恒为 NULL,统一排在有值的之后。
    • 测试test/features/books/reading_note_list_state_test.dart(7 例,含「不稳定排序」那条专项)。
    • 实机验证(2026-08-16):4 条笔记的排序与 DB 预测逐条一致;类型筛选 Web Article→2 条 / E-Book→0 条 + 空态;切「最近更新」后顺序确实改变。「从未阅读」分组真机验不了 —— 该设备上 4 条笔记全部有 last_opened_at,这条只有单测覆盖。
    • 顺带让一个既存 bug 现形:RB 写 note_type="book" 而 RVH 枚举无此值 → 落 default: physical,两本 EPUB 被标成「实体书」。映射一直是错的,只是列表页从不显示 note_type 所以没人看见。已记入 backlog「P2 — note_type 双端取值表不匹配」。

Removed

  • 🧹 删掉生词本的多选批量态死码 - (2026-08-14)
    • notebook_list_notifier.dart 有完整的 enterSelectionMode / toggleSelection / selectAll / deselectAll / deleteSelected 状态机(约 160 行),但 notebook_page.dartisSelectionMode / selectedIds 解构成 _ 直接丢弃 —— 页面从未接线,全仓零消费方。
    • 是删不是补完,理由是产品分工而非工作量:多选批量运营属桌面端场景(RB VocabPanel 已有 ⌘/⇧ 多选 + 批量工具条),移动端生词本定位为「复习补给站 + 单条速查」。判据写进了 NotebookListState 的注释,避免以后又有人把它当「没做完的功能」捡起来。
    • 影响面为零(删的是无人调用的代码路径);known_words 页的选择模式是活的,未动
    • 未一并删除batchMarkAsMasteredbatchMarkMasteredByCefrLevels 同样零 UI 消费方,但属「按 CEFR 整档批量」而非「多选」,与仍在用的 batchMarkKnownByCefrLevels 同类。已记入 backlog「P3 — batchMarkMasteredByCefrLevels 死码 + 整档批量标记路径的存废」。

Fixed

  • 🔒 登出 / 换用户时清掉网页快照缓存(RB 回馈第 2 点的追查产物) - (2026-08-05)
    • 起因:RB 回馈建议「RVH 也核一下自己的 Storage / 文件上传路径」。核查结论:RB 那类归属问题在 RVH 不存在 —— 全仓零 .upload( / uploadBinarystorage_path 是 v41 定的「RB 上传、RVH 下载」单向消费字段,没有 {user_id}/{path} 构造。下载侧鉴权也正确(cache miss 时强制要 currentSession.accessToken,带用户 JWT 请求,Storage RLS 按用户生效;缓存文件名 sha256(storagePath) 含 RB 写入的 {user_id}/ 前缀,跨用户不撞名)。
    • 但顺着查出一个相邻问题SnapshotCacheService.clearAll()全仓无人调用的死代码clearLearningDataForUser 也完全不碰文件缓存 → 用户读过网页的完整 HTML 快照(上限 200 MB)在登出 / 换用户后永久留在设备上
    • 两条后果:① 隐私 —— 那是真实阅读内容,登出后没理由继续留存;② 安全 —— getOrDownload缓存命中分支早于取 token 的鉴权分支返回,命中即读盘、不经 Storage RLS。
    • 当前不构成实际泄漏(诚实标注):到来用户拿不到离开用户的 storage_pathreading_pages 在清库时被无条件全表删)。但那层保护完全依赖清库成功,而 clearLearningDataForUser 是会 rethrow 的;清库一旦失败,残留行会让缓存在零鉴权下被读出来。
    • 改法clearLearningDataForUser 收尾调 SnapshotCacheService().clearAll()。两条实现约束写进了注释:放在 DB 事务外(文件 IO 不该拉长事务,且其失败不该回滚清库)、异常必须吞掉绝不 rethrow(本函数抛异常会让 auth 广播丢失 → 登录成功却卡在登录页,同红线 #5h 的时序说明)。缓存目录不分用户故为全清 —— 到来用户本就没有合法存量缓存,不会误伤。
    • 测试(诚实标注范围):该路径需要 GoTrueClient + AppDatabase 单例 + path_provider 平台通道,flutter test VM 里造不出来,没有行为测试。改为源码级守卫 test/features/auth/snapshot_cache_wiring_test.dart(4 例):断言接线存在、在事务之外、异常被吞、以及「命中早于鉴权」这一论据仍成立。它抓不到「clearAll 实现错了」,但抓得到最可能的回归——重构时把这行删掉;已用注入缺陷验证(移除接线后 3 例变红)。
    • 未做、单独立项:缓存目录用的是 getApplicationDocumentsDirectory(),但网页快照是可重新下载的派生数据,本不该放这里 —— path_provider 对该 API 的文档写明它用于「用户生成的、无法被应用重新创建」的数据,并建议非此类数据改用 getApplicationCacheDirectory()(同仓 TtsService 已是这么做的)。迁移会让存量缓存失效(需重新下载),属行为变更,不混进本次。

      📌 澄清措辞:本条早先写的是「iOS 上会备份进 iCloud」。那指的是 iOS 系统自动把 app 的 Documents/ 收进设备备份NSDocumentDirectory,由设备主人在系统设置里开关,Library/Caches/ 则被排除),不是说本应用使用了 iCloud —— RVH 的多设备同步全部走 Supabase,全仓零 iCloud / CloudKit 依赖、零 entitlement(已 grep 核实:整个仓库提到 iCloud 的地方只有 tts_service.dart 那一条注释)。原措辞容易被读成后者,故改用上面这个平台无关的理由。 另外这条目前是潜在项而非现实问题:RVH 当前跑不了 iOS —— third_party/sqlite3/ 只 vendor 了 arm64-android 与 x64-macos,按该目录 README,跑 iOS 时 native asset hook 找不到对应二进制会直接构建失败。

  • 🧹 清库连带清掉 known_words 的 NULL 孤儿行,根治「永久残留」(RB 回馈落地,红线 #5h 补充) - (2026-08-05)
    • 来源:RB 会话完成 #5h 镜像后给 RVH 的回馈(docs/cross-end/21-rb-push-user-scoping-confirmation.md §4.1):RVH 的 clearLearningDataForUseruser_id = ?known_words,NULL 行任何用户都匹配不上 → 永久残留;RB 侧用 user_id IS NULL OR user_id != ?1 一并清掉,建议 RVH 对齐。
    • 这些行是纯死重:读侧 getAllForUseruser_id IS ?,真实 userId 匹配不上 NULL → 用户看不见;push 侧 #5h 的 dirty-check 用 user_id = ? → 也推不走。既看不见又同步不了,只剩一个副作用:让 syncNow 的 force-full-pull 兜底(6 表 synced_at IS NOT NULL 计数为 0 才触发)被永久禁用 —— 正是红线 #5d 记录的那条失效路径。清掉即恢复。
    • ⚠️ 没有照抄 RB 的条件,因为两端函数语义相反:RB 的 clear_learning_data_if_user_changed 传的是到来用户(删「不属于他的」);RVH 的 clearLearningDataForUser 传的是离开用户(_detectUserSwitchAuthNotifier.signOut 两个调用点lastUserId,已逐个 grep 确认)。字面搬 user_id IS NULL OR user_id != ? 会变成「删掉到来用户的数据、反而留下离开用户的」—— 正好删反。故改法是保持「删离开用户」语义、只补 NULL 清扫:user_id = ? OR user_id IS NULL
    • word_cloze_contexts 刻意不加:其 user_id 是 NOT NULL(红线 #5b),加了是永不命中的死谓词;已在代码注释里写明 schema 若放宽须同步补上。
    • push 侧那道过滤仍然必需,没有被本次替代:清库只在用户切换 / 登出时跑,两次清理之间 NULL 行照样可能存在。两处是纵深防御,不是二选一。
    • 回归测试test/features/sync/watermark_per_user_test.dart 的「兜底不再是唯一防线」用例补两条断言(NULL 行必须被清掉、第三方用户的行必须留下),并同步更新了该文件对生产 SQL 的转录。已用注入缺陷验证:把转录改回 user_id = ? 后该用例确实变红。
    • 该用例原本靠「NULL 行残留」来撑起 totalSynced > 0 的前提,现改由第三方 userC 的行来撑(清库只删离开用户的行),前提依旧成立。
    • 验证flutter test 1140 通过 / 5 skipped / 0 失败flutter analyze lib/ test/ 0 error / 0 warning / 3085 info
  • 📊 补齐 getSyncStatus pendingCount 的两处口径缺口:漏统计 known_words + learning_entries 缺墓碑分支 - (2026-08-05)
    • 背景:两处都是修红线 #5h 时顺带发现、当时刻意没扩大改动范围的既存缺口(见下方 #5h 条目末段)。方向与 #5h 相反 —— #5h 是「push 推不走、UI 却一直显示待同步」的清不掉的计数,这两处是「push 会真的推、UI 却显示 0 待同步」。
    • 缺口①:整张 known_words 没被统计 —— pendingCount 只算 5 张表(learning_entries / reading_notes / reading_pages / word_page_links / word_cloze_contexts),而同步表是 6 张。_pushKnownWords 是真实存在的 push 路径,用户改了已认识词却看不到待推送提示。
    • 缺口②:learning_entries 分支缺墓碑条件 —— pendingCount 是 synced_at IS NULL OR updated_at > synced_at,而 _pushLearningEntries 多一支 OR (deleted_at IS NOT NULL AND (synced_at IS NULL OR deleted_at > synced_at))。软删了词但尚未推送时数不到它(updated_at 没变,只有墓碑分支选得中)。
    • 改法:两处条件逐字抄自对应 push 函数的 dirty-check(口径漂移的根因就是两边各写各的);占位符 5 → 6,参数列表同步补到 6 个 _userIdknown_words 保留 = ? 不改成 IS ? —— 归属不明的 NULL 行读侧根本看不见,不能算进当前用户的待推数(红线 #5h「归属不明的行不认领」)。
    • 括号照旧是硬要求:新增的 known_words 子查询同样把 dirty 条件整体括起来,否则「改过」「软删」两支会绕开 user_id 过滤。#5h 的 awk 版 CI grep 覆盖得到这个新子查询(实测:注入漏括号版会被它拒,命中 line 288)。
    • 回归测试test/features/sync/push_user_scoping_test.dart 新增 3 例,全部用注入缺陷验证过确实抓得到,且每次注入都只有对应那条红、其余 18 条照过):① 删掉 known_words 子查询 → 「known_words 的脏行计入待推数」失败;② 删掉墓碑分支 → 「learning_entries 的墓碑行计入待推数」失败;③ known_words 漏外层括号 → 「新补的两处口径同样按 user_id 过滤」失败。
    • 顺带一提:原有那条「别的用户的脏行不计入」用例里 seed 的 known_null 行,在补 known_words 之前其实是空断言(整张表都没被统计,0 是白给的),这次之后才真正生效。
    • 不涉及 schema 变更不构成跨端契约变更getSyncStatus 是本端 UI 读数,不进 push/pull 矩阵)。
    • 验证(本条分支自测,基线是合并前的 787dc97):flutter test 1145 通过 / 0 失败(基线 1142 + 新增 3);flutter analyze lib/ test/ 0 error,181 warning / 3107 info —— 把这两个文件换回 HEAD 版本单独跑过基线对照,数字完全相同,即本次零新增 lint。

      📌 合入 master 后复测(与上方「analyze 归零」条目合并后的最终态):flutter test 1140 通过 / 5 skipped / 0 失败flutter analyze lib/ test/ 0 error / 0 warning / 3084 info。两组数字与本条自测不同不是回归,而是合并对象带来的:analyze 归零条目把 181 warning 清到 0,并把 5 个 OpenCV/DB placeholder 测试文件里位置写错(放在 void main() 上方而非库级)因而一直没生效@Skip 移到库级 —— 那 5 个文件各 17 行、零 expect(,跳过它们没有损失任何真实覆盖。本条新增的 3 例在合并后全部通过。

  • 🔐 6 个 push 函数全部按 user_id 过滤 dirty-check,修「别的用户的行被以当前用户身份推上云端」(新增红线 #5h) - (2026-08-04)
    • 缺陷_pushBooks / _pushReadingPages / _pushLearningEntries / _pushWordPageLinkRelations / _pushKnownWords / _pushWordClozeContexts 的 dirty-check SELECT 一个都不带 user_id 过滤,而 mapped payload 一律写 'user_id': _userId(红线 #5b 要求真值)。于是任何遗留在本地 6 张同步表里、不属于当前登录用户的未同步行,都会被以当前用户身份 upsert 到 Supabase —— 别人的生词、别人的读书笔记、别人的真实读句,落进当前用户的云端账号。这是数据归属 / 隐私问题,不只是同步瑕疵。
    • 实测复现test/features/sync/push_user_scoping_test.dart,驱动真实 syncNow()AppDatabase 走 mocktail implements 假件交出真实 DDL 建的 ffi 库,SupabaseSyncDatasource 换成捕获式假件记录实际 payload):6 张表各放 1 行 userA + 1 行 userB 的脏行,以 userB 身份同步 → 日志 pushed=1212 行全部带 user_id=userB 上行,6 张表无一幸免。修复前 8 个断言失败,修复后 16/16 通过。
    • 触发不依赖任何竞态(交接单把重点押在 auth 时序窗口上,实测表明那不是必要条件):① known_words.user_id 是 schema 里唯一可空的列,而 clearLearningDataForUserWHERE user_id = ? 删它 → NULL 行任何用户都删不掉,永久残留,被之后每个登录用户轮流认领;② clearLearningDataForUser 出错会 rethrow,前一个用户的行原样留库;③ auth 广播先于清库(auth_repository_impl.dart:39 vs :42)只是又一条路径,其是否赢得竞争依旧未被证明 —— 修复不建立在这个假设上。
    • 改法:6 处 dirty-check 加 WHERE user_id = ?(绑 _userId),getSyncStatus 的 pendingCount 逐表同口径跟改(否则会出现「push 推不走、UI 却一直显示待同步」的清不掉的计数)。
    • 归属不明的行(user_id NULL)刻意不认领known_words= ? 而非 IS ?。读侧 getAllForUseruser_id IS ?,真实 userId 匹配不上 NULL 行 → 这些行当前用户根本看不见;看不见的数据不该以当前用户身份上行。没有选「回填 user_id 再推」 —— 那等于主动把来路不明的数据算作自己的。核实过当前 app 强制登录(main.dart::_heavyInitAndResolve 未登录直接 LoginPage,lib/features/auth 无游客/跳过路径,known_words_providers.dart:28 明写「AuthGuard 保证 userId 非空」),已无产生 NULL 行的路径,过滤不搁浅任何可达数据。
    • ⚠️ 括号是这次改动最危险的地方:SQL 里 AND 比 OR 结合更紧,漏括号会退化成 (user_id = ? AND synced_at IS NULL) OR updated_at > synced_at OR ...,让「改过」「软删」两个分支完全绕开过滤 —— 比不加过滤还糟。且「新增行」(synced_at IS NULL抓不到这个错法,回归测试专门补了一组「已同步过、之后又被改/被软删」的外来行;该用例已用注入缺陷实测验证确实会失败(注入后主组 13 例照过、只有它红)。CI grep 同理不能用松匹配 grep -c 'AND (synced_at IS NULL'(墓碑子句自带这串,实测灌到 21 命中,删掉外层括号照样"通过"),改成 awk 逐行校验「WHERE user_id = ? 的下一行必须以 AND ( 开头」,也已用注入版验证会被拒。
    • auth 广播顺序维持原样(评估后不动):push 按 user_id 过滤 + watermark per-user(#5d)之后,清库与新用户首次 sync 读写的是不相交的行集,先后顺序不再影响正确性;而把 _controller.add 调到 _detectUserSwitch 之后,会让清库异常(clearLearningDataForUser 会 rethrow)导致广播永远不发 → authStateProvider 不更新 → main.dart 导航监听不触发 → 登录成功却卡在登录页,还会把登录跳转压在多表 DELETE 事务后面。用已经无害的时序窗口换一次硬锁死不划算。理由写进了 auth_repository_impl.dart 构造函数注释。
    • 不涉及 schema 变更不构成跨端契约变更(改的是本端 push 取数口径)。⚠️ RB 侧 push.rs 是否也存在同类问题未核实(RB 是单用户 profile 目录模型,可能天然不受影响),已在红线 #5h 里标注需 grep 实测后再决定是否镜像,未凭本端结论推断对端状态。
    • 顺带发现、本次未改getSyncStatus 的 pendingCount 完全没统计 known_words(只算 5 张表),以及其 learning_entries 分支缺墓碑条件(_pushLearningEntries 有)。两处都是既存口径缺口,与本缺陷独立,未扩大改动范围。(已于 2026-08-05 修复,见上方条目。)
  • 🔑 sync watermark 改 per-user,修用户切换后带着上一个用户 cursor 做增量 pull(红线 #5d 新增第 4 条) - (2026-08-04)
    • 缺陷auth_repository_impl.dart:238_prefs.remove('last_sync_at')纯 no-op 死代码 —— 全仓只有这一处 remove,没有任何地方往 SharedPreferences 写过这个 key;真正的 watermark 存在 app_metadata 表(_getLastSyncAt / _setLastSyncAt)。注释声称的「Reset sync timestamp so next login triggers full sync」从未兑现。
    • 实测复现(真实 DDL 建库 + 逐字复制的两段生产 SQL):① 清库后 app_metadata.last_sync_at 原封不动 → no-op 确认;② 干净 A→B 时 totalSynced==0,force-full-pull 兜底触发,结果正确但是撞上的;③ known_words 有第三方 user_id 残留时 totalSynced=1 → 兜底不触发,新用户带着旧 watermark 增量 pull;④ 更坏:known_words.user_id 在 schema 里可空word_cloze_contexts.user_id 是 NOT NULL,洞只在 known_words),而清库按 WHERE user_id = ? 过滤 —— 空值行任何 userId 都删不掉 → 兜底被永久禁用。
    • 另一条不需要任何残留的路径auth_repository_impl.dart:39_controller.add(user):42await _detectUserSwitch 之前,新用户身份先广播 → syncRepositoryProvider 重建 → autoSyncProviderauth_section.dart:38 / review_overview_page.dart:71 均 watch)的 Future.microtask(syncNow) 与清库事务并发,此刻旧用户的行仍在 → 兜底不触发。(时序窗口真实存在,但未实测证明 syncNow 会赢 —— 清库在 add 的下一条语句就启动,实际大概率清库先赢。)
    • 改法:watermark 改 per-user keylast_sync_at:<userId>,新增 lib/core/constants/sync_metadata_keys.dart,auth / sync 两侧共用同一份格式)。没有选「把 :238 改成清 app_metadata 里的行」,因为那个方案的正确性取决于赢得上述竞争;per-user 之后到来的用户读自己的 key(不存在 → null → 全量 pull),清理删的是离开用户的 key —— 读写碰不同 key,先后顺序不再影响正确性,force-full-pull 兜底不再是唯一防线。
    • 两个配套细节:① 删除动作放进 clearLearningDataForUser同一事务(数据清了而 watermark 留着,该用户下次回来会带陈旧 cursor 增量 pull);② 存量全局裸 key 在 syncNow 开头一次性 DELETE(不带用户身份、无法判断属于谁,按 server_updated_at_migration_v1 先例直接丢弃 → 换一次全量 pull;DELETE 幂等且精确匹配裸 key,per-user key 带 :<uid> 后缀不会被误删,故不需要 migration flag)。
    • 不涉及 schema 变更app_metadata 是 KV 表,只改 key 命名);不构成跨端契约app_metadata 不是同步表,RB 自行保存其 watermark)。
    • 回归测试:新增 test/features/sync/watermark_per_user_test.dart(5 例)—— A→B→A 往返、totalSynced>0 时新用户仍读到 null、清理与读取无先后依赖、legacy 裸 key 清理不误删 per-user 行。
    • 顺带开出的独立任务(本条目当时未改,已于 2026-08-04 同日修复,见上方红线 #5h 条目):_pushLearningEntriesWHERE synced_at IS NULL 无 user_id 过滤、payload 却写 'user_id': _userId → 同一时序窗口里旧用户的未同步行可能被以新用户身份推到云端。(后续实测补充:范围是全部 6 个 push 函数而非仅此一个,且触发不需要该时序窗口。)
  • 🧪 修复测试套件:822 通过 / 79 失败 → 1126 通过 / 0 失败 - (2026-08-04)
    • 背景:本机 flutter test 在 b9c609e(vendored macOS x64 libsqlite3)之前一直跑不起来,反馈回路断了不知多久,测试与实体代码严重脱节。79 个失败 = 15 个编译不过的文件 + 64 个断言失败(编译失败也计入 -N,不是 79+15)。
    • 1 个真 UI bugGroupRecommendationService.generateRecommendationText 返回拼好的英文串,被 recommendation_card.dart:44 原样渲染 → 中文界面显示 Recent: 0d ago / Due: 15 words / 🎯 Almost done (85%)这里测试是对的。改法遵循项目既有约定(domain 层不产出用户可见文案 + adapter 桥接):domain 改返回结构化语义 ({RecommendationHintKind kind, int value}),新增 lib/config/recommendation_descriptions.dart adapter + 4 个 ARB key(en/zh;ja 仅 84 key 走 fallback,按既有实践不加)。测试相应改为断言优先级判定,脱离语言。
    • 1 个真行为差异ocr_fuzzy_matcher_test 的「4 字母词允许编辑距离匹配」。239cce8(03-10) 同时建了实现和测试,两天后 55558e1(03-12) 刻意收紧(加 if (word.length <= 4) return nullmaxDistanceword.length >= 8 ? 2 : 1 改成恒 1,注释「短词误匹配率极高」),测试没跟上 —— 正因为当时跑不了。按收紧后的有意行为改断言,并补了「5 字母词才进编辑距离」正向用例。
    • 62 个过时断言:生产代码返回英文而测试期望中文 —— 不是「l10n 迁移后测试没跟上」,而是 i18n 迁移把 core/domain 层 display helper 统一改成英文(日志 / toString() / 开发者可见),用户可见文案搬去了 l10n。判据是「这个 helper 还在 UI 里渲染吗」,逐文件分别处理:posDisplayName(12) / FailureHandler 整组(20) / WordAlreadyExistsFailure(1) / getQualityDescription(2) / generateRecommendationReason(6) 在 lib/ 内零调用(死代码)→ 删用例,方法本体清理另开任务;getIntervalDescription(6,仅 AppLogger.d) / themeModeDescription+languageDescription(8,仅 toString()) / masteryLevelDescription(7) / cefrLevelDescription → 断言改英文;FontSizeMode.getDisplayName([useZh])(2) → 默认分支刻意为英文,断言改英文并保留 useZh: true 中文分支覆盖。
    • 9 个 fixture 过时pos_definition_testparseFromDatabaseJson 全部返回 null —— fixture 用 "definitions": ["纯字符串"](v15 形状),现行 _parsePosData 只认 Kaikki 义项对象 [{gloss, examples, ...}]核对预装库 lampio_dict.db 后确认是 fixture 过时:抽样 500 行共 3611 个义项元素全部{gloss:...} 对象、零纯字符串。
    • 15 个编译不过的文件:全是 v34 瘦身 / v39 books→reading_notes / v42 per-user / v43 word 升 PK / v47 封面 BLOB / v56 表改名遗留。ocr_pipeline_config_test.dart(312 行)整个删除 —— OcrPipelineConfig / PreprocessStrategy / EngineSelectionStrategy 在 lib/ 里完全不存在,功能已移除。vocabulary_entity_test.dart 整体重写(原文件建立在 v34 之前的实体上,测的字段大半已消失;保留分组骨架并把「原本存字段、现在是派生 getter」的部分改成断言派生逻辑)。其余按字段映射逐个修。
    • 两个测试基建 bug(不是断言过时,是 fake/mock 本身坏了):① FakeLocalKnownWordsDataSource 还实现着 v42 之前的方法名,@override 全部落空 → 真实调用经 implements + noSuchMethod 静默返回 null;② mockito 5 的 Mock.noSuchMethod 只在有 when() 注册时才用 returnValue,没注册时返回 returnValueForMissingStub(缺省 null)—— 从不 when() 的方法必须两个都给。
    • flutter analyze lib/ test/ 零 error(181 warning + 3103 info 全部是既有的、遍布全仓的未用 import / 未用局部变量,本次改动未新增;我改过的 lib/ 文件合计只带 1 个 warning,是 pos_definition_model.dart:26 的 Freezed JsonKey 注解,早于本次改动)。跨端红线 #5b / #5d / #6e 的 CI grep 全绿。

      ⚠️ 更正:commit 64b722c 的 message 里写的「零 error 零 warning」不准确 —— 当时用的计数命令是 grep -cE '^\s+(error|warning) •'\s 在本机 grep 下没匹配上、静默返回 0。正确口径见上。可靠的计数方式(不依赖 \s):

      bash
      flutter analyze lib/ test/ 2>&1 | awk '{for(i=1;i<=NF;i++) if($i=="•"){print $(i-1); break}}' | sort | uniq -c

Removed

  • 🗑️ 清理 5 处零调用死代码(测试套件修复的收尾) - (2026-08-04)
    • 上一条测试修复中删掉了这些符号的测试用例,本次删方法本体。每处都逐个 grep 核实过零外部引用:
      • lib/core/exceptions/failures.dartFailureHandler 整类(getUserFriendlyMessage / _getHttpFailureMessage / isNetworkFailure / isAuthFailure / isPermissionFailure)+ WordAlreadyExistsFailure
      • lib/core/algorithms/sm2_algorithm.dartgetQualityDescription
      • lib/features/vocabulary_notebook/domain/services/group_recommendation_service.dartgenerateRecommendationReason
      • posDisplayName 两份lib/features/translation/domain/entities/pos_definition.dart(domain)+ lib/features/vocabulary_filtering/data/models/pos_definition_model.dart(data)—— 同一份死代码的跨层复制。原计划只列了 domain 那一份,删完复核残留时才发现 data 层还有一份同名 getter,一并删除。UI 一直直接渲染原始 pos 字符串(translation_content.dart:928)。
    • 删除顺序有依赖FailureHandler 在其内部引用了 WordAlreadyExistsFailure,必须先删前者(或两者同删),否则留下悬空引用。
    • 同文件的兄弟符号刻意保留,删除处留了注释标注:posAbbreviation(两份都在用)、getIntervalDescription(被 mastery_tracking_datasource.dartAppLogger.d 使用,有测试覆盖)、各 Failure 子类。
    • 净减约 200 行;flutter test 保持 1126 通过 / 0 失败,flutter analyze error 数保持 0、warning 数不变(公开死方法本身不产生 warning,所以清理前后都是 181)。

Changed

  • 🧹 flutter analyze lib/ test/ 归零:181 warning → 0(0 error / 0 warning / 3084 info) - (2026-08-05)
    • 先纠正归因:这 181 个 warning 不是依赖重解析(e477c09)引入的。三条独立证据:① 9f0f8cf 的 commit message 已实测记录 64b722c 处就是 0 error / 181 warning / 3103 info,而 9f0f8cfe477c09 是从 64b722c 分叉的两条并行分支,前者不含后者;② analyzer / flutter_lints / json_annotation / freezed / json_serializable 五个包版本在重解析前后逐字未变(7.6.0 / 3.0.2 / 4.9.0 / 2.5.8 / 6.9.5),原条目"freezed / json_serializable 版本上移"与 lock 内容不符;③ 重跑 build_runner(80 outputs)后 git status 零 tracked 文件变动,生成物本就是最新的,warning 数纹丝不动仍是 181 —— 说明与代码生成无关。真实成因是 64b722c 的计数命令 grep -cE '^\s+(error|warning) •' 在 macOS BSD grep 下 \s 不匹配而静默返回 0,"零 warning"基线从未真实存在。本次是净新增的清理工作,不是回滚。(8379856 在 master 上独立得出同一结论,证据①与此处一致;本条另补了②版本比对与③build_runner 复跑两项,故一并保留。)
    • invalid_annotation_target(54 → 0):拆成两类,没有一刀切
      • 49 个 JsonKey:全部标在 freezed factory 构造参数上(手写源码里那些字段由 freezed 生成、根本不存在可标注的字段),而 json_annotationJsonKey 声明为 @Target({TargetKind.field, TargetKind.getter})TargetKind 无法表达"freezed factory 参数"→ 分析器表达力不足导致的误报。按 freezed 2.5.8 自带 README「Disabling invalid_annotation_target warning」一节的官方要求,在 analysis_options.yamlanalyzer.errors.invalid_annotation_target: ignore。改标到别处 = 放弃 freezed 或放弃 JSON 序列化,属功能降级,故不采纳。
      • 5 个 Skip是真错,不是误报。@Skip() 必须是 library 级注解,这 5 个测试文件把它标在了 void main() 上 → 跳过从未生效。改成 @Skip(...) + library; 置于文件首(真正修好,不靠上面那条全局 ignore 掩盖)。因为 5 个文件的 body 都只是"什么都不断言"的 placeholder,此前是假通过,修好后转为真正 skipped —— 这正是 flutter test 计数由 1142 通过变成 1137 通过 / 5 skipped / 0 失败的全部原因(1137 + 5 = 1142,逐一对得上)。
    • 死代码清理(76 → 0):34 unused_import + 20 unused_local_variable + 16 unused_element + 4 unused_field + unused_element_parameter / unused_catch_stack 各 1。逐个核实过,不是机械删除:
      • filter_confirmation_page.dartwordToEntryIdMap 保留了调用只删了变量 —— 它接的是 addSelectedToNotebook(...) 的返回值,那个调用有"把词存进笔记本"的真实副作用,dart fix 没有该项的自动修复,盲删声明会连副作用一起删掉。
      • config_validator.dartocrProvider 有 4 处写入、0 处读取,只删声明会编译不过,连同 4 个赋值一并删(hasOcrApi 有人读,保留);真正的输出一直是 summary['ocr_provider']
      • app_database.dart_backfillPhoneticFields(62 行 v6 数据迁移)唯一调用点是一行注释掉的代码,且其操作的 phonetic 列本身已在 v18 删除 —— 永无可能重新启用,方法体与那行注释一并清理。
      • recognize_and_filter_usecase.dart_saveWordPositions重复实现:真正在跑的是 filter_confirmation_notifier.dart:718 那份(有调用方),故删除不构成功能回退。
      • unified_vocabulary_service.dart_supabaseClient(注释写"用于测试模式")存了从不读取,字段 + 构造参数 + vocabulary_providers.dart 调用点整条链路一并清掉。
      • translation_content.dartmaxVisibleDefinitions 确实被读widget.maxVisibleDefinitions),只是从无调用方覆盖默认值 → 改为 static const(而非删除),语义不变。
      • 私有成员无法被 test/ 引用,故 unused_element 类均可安全判定为死代码。
    • 空安全 / 类型冗余(51 → 0):19 dead_null_aware_expression + 8 unnecessary_non_null_assertion + 8 override_on_non_overriding_member + 6 unnecessary_cast + 5 invalid_null_aware_operator + 3 unnecessary_null_comparison + unnecessary_type_check / equal_elements_in_set 各 1。主体是 v9「移除可空,添加默认值」重构后遗留的 ?? 默认值:字段已是非空类型,Dart 健全空安全下运行时不可能为 null,故移除不改变行为。override_on_non_overriding_member 8 处逐个核对过接口,确认是 impl 独有的额外方法(接口里根本没声明,故不是签名漂移那类真 bug),只摘掉多余的 @overrideAllTranslationApisFailed.props 同理 —— 基类 Failure 并非 Equatable。equal_elements_in_setocr_text_cleaner.dart 停用词表里 'her' 兼作宾格与所有格被列了两次(Set 去重,无功能影响)。
    • 验证flutter analyze lib/ test/0 error / 0 warning / 3084 info(用 CHANGELOG 里那条不依赖 \s 的可靠命令复核);flutter test1137 通过 / 5 skipped / 0 失败pubspec.lock sha256 全程未变(worktree 内先补齐 3 个软链再跑任何 flutter 命令,避免隐式 pub get 重解析)。
  • 📦 依赖整体重解析(231 个包变动,sqlite3 3.1.1 → 3.5.0),vendored sqlite3 二进制随之换版 - (2026-08-04)
    • 起因:worktree 里首次跑 flutter test 时隐式的 pub get 重新解析了 pubspec.lock(源也从 pub.flutter-io.cn 镜像换回 pub.dev)。经确认后保留升级结果,而非回滚 lock。
    • 连带必须换的两个二进制third_party/sqlite3/ 里 vendored 的预编译库带 sha256 校验,必须等于所用 sqlite3 版本 lib/src/hook/asset_hashes.dart 里的期望值。3.1.1 → 3.5.0 后两个都对不上,Android 侧 .so 同样失效(不只是跑测试用的 macOS dylib,flutter run 也会挂)。已按 3.5.0 的 release tag 重新取回并逐个校验 sha256 与包内期望值一致:libsqlite3.x64.macos.dylib = bd96fb24…libsqlite3.arm64.android.so = e99515af…
    • 静态分析无变化flutter analyze lib/ test/ = 0 error / 181 warning / 3108 info,本次改动的文件零 warning。

      ⚠️ 更正本条最初的写法:提交时(commit e477c09 的 message + 本条目原文)把这 181 个 warning 当成「依赖升级引起的已知回退」,说是「从零 error 零 warning 变成 0 error / 181 warning」,归因错了。真相是 181 个 warning 在升级之前就存在:所谓「零 warning」基线来自 commit 64b722c message 里的错误断言(计数命令 grep -cE '^\s+(error|warning) •'\s 在本机 grep 下没匹配上、静默返回 0),该错误已由 9f0f8cf 更正。 交叉验证:9f0f8cf(基于 64b722c、早于本次依赖升级)实测同样是 181 warning,且其树比本分支还少约 200 行死代码 —— 两次计数在不同的树上完全相等,说明依赖升级没有新增任何 warning。它点名的 pos_definition_model.dart:26 Freezed JsonKey 正属于 invalid_annotation_target,也就是本条原文错误归因给「json_serializable 版本上移」的那一类,对方在升级前就观察到了。 结论:清理这 181 个 warning 是一项独立的既有技术债(与本次依赖升级无因果关系),已于 2026-08-05 清零,见上方「flutter analyze lib/ test/ 归零」条目。

  • 🔊 发音改走 Azure Neural TTS,不再依赖 vocabulary.pronunciation_url(RB 查词换源的连带影响,跨端交接 19) - (2026-08-03)
    • 触发:RB 于 2026-08-03 把查词上游从 api.dictionaryapi.dev 换成 Wiktionary(换源理由:前者对不存在的词也返 502,让「查不到」与「没查成」在协议层无法区分)。Wiktionary REST 端点不提供音频 URL → 新回填的长尾词 pronunciation_url 恒为 null(音标不受影响,RB 改走 Action API 抽取)。
    • RVH 侧实测影响(本次确认,非推测):4 处发音入口全部audioUrl != null && isNotEmpty 作为按钮显示条件,换源后长尾词不是「点了没声」而是按钮整个消失;其中 translation_content / info_drawer 更进一步渲染 volume_off 静音图标 —— 最需要听发音的那批生僻词会永久显示「此词无发音」。
    • 改法:新增 lib/core/services/tts_service.dart,按词现合成(Supabase Edge Function tts-synthesize,复用 RB 已上线的 function 与 secret,无需新建)。voice en-US-JennyNeural / rate 0.9 与 RB src/lib/tts.ts 字面一致,保证两端听感相同。发音按钮改为「有词就有」。
    • 刻意不引入 provider 设置:RB 的教训(llm-server-unification-plan.md WP4e)是曾有 tts_provider 且默认系统语音,「验证 OK 后切默认」那步从没执行,用户只会归因为「这产品发音难听」;删该选项时必须连读取逻辑一起删,否则存量库里存过 web-speech 的用户永远卡住且再无 UI 可改。RVH 侧核实从未有过任何 TTS 设置项user_settings_entity 无相关字段;flutter_tts 虽在 pubspec 但代码零引用),故无存量偏好要清理,本次也不新增该设置,从源头堵掉这个坑。
    • 系统语音降为内部兜底:Azure 不可达(离线 / 未登录 / 超配额 / 上游抖动)时用 flutter_tts 出声 + 一次性提示「云端发音不可用,已改用系统语音」(标注过的降级,用户知道是异常态而非产品水平)。未登录时不发请求——tts-synthesize 带身份闸,无 sub 直接 401(已实测确认)。
    • 落盘缓存:按 sha256(text|voice|rate) 缓存 MP3,LRU 按 mtime 清理、阈值 20 MB。复习流反复播同一个词是常态,命中即省配额(该 function 按字符计量)也省延迟。放 getApplicationCacheDirectory() 而非 documents —— 可再生数据不该被 iOS 备份进 iCloud。
    • 并发保护speak() 入口停掉所有在放的声音(Azure 音频 + 系统语音),并用单调请求序号丢弃过期响应 —— 否则快速连点两个词会出现「点了 B 先响 A」或双声重叠。
    • AudioPlayerService 改造playPronunciation(url)playLocalFile(path)刻意不保留远端 URL 播放入口,避免「有 URL 才有发音」的旧逻辑重新长回来。
    • 改动范围:新增 1 个服务;改 2 处在用发音入口 —— ① phonetic_audio_rowword_header_rowdefinition_drawer ← 复习页 + 回看页;② translation_contentword_detail_page。另有 3 个死代码入口(info_drawer / definition_sheet / reading_note_card,全仓零引用,一并迁移仅为保持编译,已单独立项建议删除)。
    • 发音按钮三态 + 静音治理(用户实测反馈后的三轮收敛):
      • 三态图标:空心(闲置)→ 🔄 转圈(合成中)→ 实心绿(出声中)→ 空心。合成态单独画转圈是因为首次点击要等一趟网络,画成实心喇叭会让人以为已经在响、然后疑惑没声音。TtsService.speak()onPlaybackStart 回调,在真正出声前触发,让 widget 从 loading 切 playing。
      • 转圈延迟 150ms 显示:缓存命中时合成只花几毫秒,立刻切转圈的话它只存在 1–2 帧就被换成图标 —— 两个几何形状不同的 widget 快速交替 = 肉眼可见的闪动。改为超过 150ms 还没出声才显示转圈,缓存命中路径根本走不到。配套:点击门禁用独立的 _busy 而非 _phase,因为延迟窗口内 phase 仍是 idle,拿它挡不住第二次点击。
      • 服务端去首部静音:改 RB 的 tts-synthesizemstts:silence 并部署。逐项实测 —— Leading-exact 0ms ✅ 生效(0.200s → 0.040s);Tailing-exact 0ms ❌ 无效;Sentenceboundary-exact 0ms ❌ 无效(产物字节数逐字节相同,该行已撤)。
      • 客户端裁尾部静音:尾部那 1s SSML 去不掉,实测 8 个不同长度的词,尾静音 0.916–1.024s、极差仅 0.108s(与词长无关的固定量),故在 setClip 裁掉 850ms —— 取 850 而非实测均值 970,是给最短的那条留 ≥66ms 余量,宁可少裁也不切到词尾辅音。
      • stop()pause + seek(0):just_audio 的 stop() 会释放资源、状态打回 idle,而 speak() 每次入口都调 stop() —— 导致「已缓存的词再点一次」下次 play()重新加载整个音源(实测 ~1.2s,比首次 setFilePath 的 525ms 还慢),且这 1.2s 里 UI 已是实心却没声音。改后重复点击 22ms 出声。
      • _isPlaying 赋值时机修正play() resolve 于播放结束而非开始,原代码在 await 之后才置 true,语义反了。
      • 收敛结果(同一个词、缓存命中):点击→出声 ~1.2s → 22ms;播放耗时 2.509s → 1.176s
    • 顺带修掉一个 pre-existing UX 缺陷:喇叭图标在音频放完后还要空挂约 3 秒才从实心变回空心。根因不在图标:speak() 内部 await 到音频真正播完才返回(just_audio 的 play() 语义 + 兜底路径 awaitSpeakCompletion(true);日志里 Playback started successfully 排在 Playback completed 之后即为实证),而各 widget 在它返回后又硬等一个 Future.delayed(3s) 定时器。该定时器是接 Azure 前就有的老代码(原 URL 播放路径同样 await 到播完),单词音频不到 1s,等于纯空转。5 处发音入口一并改为 await 返回后立即复位,删掉定时器。真机验证:点击后 ~1.2s 实心绿(播放中)、~3s 已恢复空心灰。
    • ⚠️ 中途踩坑并修复translation_content 最初改成从 widget.translation['word'] 取词,真机一测发音按钮根本不渲染 —— 该 map 有多个生产方,而 word_detail_notifier 组装的 completeTranslation 就没有 'word'(原代码用的 audioUrl 恰好有),靠 map 取会静默拿到空串。改为宿主页 word_detail_page 显式传 word 参数,map 取值降为兼容兜底。教训:换判据时必须验证新判据在每个生产方都真的有值,不能因为旧判据在同一个 map 里就假定新的也在。
    • synonyms / antonyms:RVH 确实在渲染translation_content.dart:1003/1013,数据来自 pos_definitions sense 级),换源后长尾词该区块会空。属预期降级非 bug(优先级 释义 > 音标 > 同反义,已与用户确认),代码无需改动 —— 现有 isNotEmpty 判空会自然收起该区块。
    • 验证flutter analyze lib/ 0 errors(290 errors 全在 test/,是 pre-existing 的 coverImagePath/vocabularyId 基线问题,本次未碰 test/);线上探测确认 tts-synthesize 已部署且身份闸生效(仅带 anon key → 401 authentication_required,错误体 JSON、成功才是二进制,与代码假设一致)。
    • 真机验证通过(Android 24094RAD4C,已登录 session):
      • 复习卡入口:wash away(库里 pronunciation_url ipa_pronunciation 都是 NULL —— 旧代码下 PhoneticAudioRow 整行返回 SizedBox.shrink(),连按钮都没有)→ 按钮正常出现 → [TTS] Synthesized "wash away" (11664 B) → 落盘 cache/tts_cache/15c3b3565b0c6963.mp3 → just_audio loading→ready→playing→completed
      • 缓存命中:同一个词再点一次 → [TTS] Cache hit: "wash away",零合成零配额
      • 词详情入口:chuckle(有音标、pronunciation_url 为 NULL —— 旧代码下渲染 volume_off 静音图标)→ 按钮正常出现 → [TTS] Synthesized "chuckle" (10224 B)
      • 顺带现场坐实任务 2.2:chuckle 详情页的 Synonyms 区确实在渲染(chortle / giggle / snigger / titter)
    • pronunciation_url 列保留:不为它开跨端迁移删列(可空、无害,三端协调成本远超收益),RB 侧结论同此。

Fixed

  • 🗑️ reading_notes 删除跨端不同步(补齐 RB v23「笔记四级删除闭环」缺失的第四级,schema v57.1,跨端红线 #6e) - (2026-08-03)
    • 问题reading_notes 是 6 张同步表里唯一没有 deleted_at 墓碑列的表,且双端对称走硬删(RVH reading_note_datasource_impl.dart:104 / RB notes.rs:521),Supabase user_reading_notes 亦无该列(sync-tables.sql:134-149 实测)。删除信号在整条链路上无处承载:本地硬删 → push 取不到行 → 云端行永生 → 对端无条件 upsert。
    • 三级后果:① 删除永不上行,对端笔记继续存在;② 删除端自己会被复活 —— 因红线 #5d 的 server_updated_at > watermark filter 而潜伏,触发条件是「对端 touch 该行 / server_updated_at_migration_v1 全量 pull / 换设备重装」;③ PRAGMA foreign_keys = ON 下硬删经 FK CASCADE 物理抹掉 reading_pages + word_page_links 的 v55 墓碑,子树同样永生。
    • ⚠️ 排查陷阱:RB CLAUDE.md:366「Supabase 10 张用户表 + RLS + deleted_at」是对整组表的笼统概括,逐表 DDL 不成立;权威依据是同文件 :238「reading_notes 仍走硬删,不传播」+ sync-tables.sql 实际 DDL。
    • schemareading_notesdeleted_at TEXT,走 _ensureLatestColumns 幂等 ALTER-ADD,_schemaVersion 保持 57 不 bump(沿用 RB v23/v24 的 ALTER-ADD precedent,加一个可空墓碑列不值得触发开发期删库重建)。真机验证:ALTER 生效、本地 7 条笔记零丢失。
    • 删除路径deleteBook 改单事务软删三层(笔记 → 来源页 → 词-页关联),笔记本体先行noteRows == 0 在事务内抛以触发回滚。不依赖 FK CASCADE(只在硬删触发,且会抹掉子表墓碑)。learning_entries 不动 —— 词仍在生词本,只失去这个出处(镜像 RB remove_reading_page 语义)。
    • 查询过滤:8 处加 deleted_at IS NULLreading_note_datasource_impl 5 处 + statistics_repository_impl:559 + reading_page_datasource:496 + notebook_datasource:805)。
    • sync 双向_pushBooks dirty-check 加墓碑分支 + payload 加 deleted_at传真值,恒写 null 会让 PostgREST upsert 远程复活对端刚删的笔记);_pullReadingNotes 加三分支墓碑模板(分支①连带软删本地子树,防对端漏传),保持 ON CONFLICT DO UPDATE(红线 #6b)+ SET 子句保留 cover 三字段(红线 #6c)+ SET 不写 deleted_atgetSyncStatus pendingCount 对齐;_pullReadingPagesbookExists 守卫加 AND deleted_at IS NULL(软删后行仍在,裸 COUNT(*) 会放行远端活页重建子树)。
    • SupabaseALTER TABLE user_reading_notes ADD COLUMN IF NOT EXISTS deleted_at TEXTsupabase/migrations/20260803_v57_1_reading_notes_deleted_at.sql)。⚠️ 部署顺序硬约束:① ALTER → ② 双端 pull → ③ 双端 push;①最先是因为 PostgREST 对未知列返回 PGRST204、整批 push 失败。
    • 端到端验证(真机 + Supabase 实测):删除一条 2 来源页 / 57 词关联的笔记 → 本地 1+2+57 全部打上墓碑且行都还在(未被 CASCADE 物理删除)→ learning_entries 66 条仍活跃 → 60 行全部 synced_at > deleted_at_markSynced 只在 push 成功后写)→ Supabase 侧 deleted_at=2026-08-03T04:53:00Zserver_updated_at 被 trigger 刷新(证明软删走 UPDATE 路径能正常进对端 pull 批次)。
    • RB 侧待镜像:本次 RVH 先行,RB 7 条待办见 docs/cross-end/19-reading-notes-tombstone-handoff.md §4。RB 改完前,RB 发起的删除仍传不过来(与改动前一致,未变差)。
    • 存量僵尸行:本修复只管今后的删除;历史上删过但没留墓碑的 Supabase 行 deleted_at 仍是 NULL,需手工补墓碑(实测发现 1 条 CleanupVerify)。
    • 顺带修复:删除确认框文案「相关阅读来源不会被删除」/ "Related reading sources will not be deleted" 在本次改动前就是假的(硬删经 CASCADE 会连带删掉来源页),改为如实描述「其阅读来源和单词关联会一并移除,单词仍保留在生词本」(en/zh + 重生成 l10n,ja 无独立翻译回落 en)。
    • 顺带发现(已于 2026-08-04 修复,见下方 v58 条目)auth_repository_impl.dart:238 的 watermark 重置清错了 store —— 清的是 SharedPreferences 的 last_sync_at,而 watermark 实际存于 app_metadata 表(sync_repository_impl.dart::_setLastSyncAt),注释声称的「next login triggers full sync」没有兑现,且新用户会带着旧用户的 watermark 做增量 pull。详见交接文档 §7。

Removed

  • 🗑️ 删除 5 个零引用死代码 widget(含 3 个旧发音入口) - (2026-08-03)
    • 背景:上一条 Azure TTS 改造中逐个排查发音入口时发现,info_drawer / definition_sheet / reading_note_card 三个 widget 全仓零引用,功能早已被 definition_drawer.dart + DefinitionDrawerShell 体系取代。当时为保持编译把它们的发音逻辑一并迁到了新的 TtsService,等于在维护永不执行的代码 —— 本次连根删除。
    • 删除 5 个文件info_drawer.dart(402 行)/ definition_sheet.dart(503 行)/ reading_note_card.dart(978 行),以及随之失去唯一引用方的 pos_definition_card.dartPosDefinitionCard ← 仅 info_drawer 用)和 mastery_indicator.dartMasteryIndicator ← 仅 reading_note_card 用)。合计约 2600 行。
    • 零引用复核(删除前逐项验证):三者的类名在全仓仅命中各自文件内部;info_drawer|definition_sheet|reading_note_card 作为路径在全仓 .dart/.yaml/.json 零 import;仓内唯一 widget barrel definition_drawer.dart:11 只 re-export definition_drawer_shell.dart,不含三者;无 integration_test/ 目录。两个连带孤儿同强度复核后同样零引用,且其 import 全为多消费方共享文件(pos_definition.dart 尚有 19 个消费方),无连锁孤儿。
    • 同名私有类不受影响reading_note_card 内的 _RatingButton / _ImageShimmerPlaceholderconfidence_rating_buttons.dart / image_note_card.dart 各有独立同名定义(Dart 私有类为库内作用域),_AudioButton 则仅存于被删文件;translation_content.dart_PosDefinitionCardgroup_list_bottom_sheet.dart_buildMasteryIndicator 亦均为各自私有实现,与被删的公开类无关。
    • 同步清理 9 个随之孤儿的 l10n keyapp_zh.arb + app_en.arb 各 9 条,含 readingNotesReviewCount@ placeholder 块,1054 → 1045):notebookForgot / notebookPerfect / notebookRecalled / readingNotesFilterMastered / readingNotesHideDefinition / readingNotesNewWord / readingNotesRated / readingNotesReviewCount / readingNotesShowDefinition,已 flutter gen-l10n 重生成。⚠️ 另有 2 个 key 被删文件也用过但仍在用、未动commonLearning(3 个文件)、notebookFailedPlayPronunciationphonetic_audio_row.dart:92,走 AppLocalizations.of(context).xxx 形式,按 l10n. 前缀筛会漏判为孤儿)。
    • 顺带查明的存量问题(本次未处理):全仓 ARB 现有 295 个先前就存在的未引用 key(1054 个中占 28%,与本次删除无关),其中包括 notebookForgotDesc / notebookRecalledDesc / notebookPerfectDesc / readingNotesFilterNew / readingNotesFilterLearning 等与本次删除项配对的兄弟 key —— 因两半都早已是死键,删掉其中一半不产生半截语义对。i18n 迁移完成时记录的「0 未用」不变量实际已失效,需独立立项做全量清扫(建议 /i18n-check)。
    • 验证flutter analyze 全仓 0 errors(3775 issues 全为 info/warning);仅 lib/ 时 issue 3021 → 2954,减少的 67 条全部是随文件消失的 info 级 lint。flutter test 822 passed / 79 failed,失败项全部 pre-existing 且与本次无关 —— 4 个失败测试文件只 import domain 层 entity/service(group_summary_entity / word_mastery_entity / group_recommendation_service / sql_lexer),到 presentation 层被删 widget 无任何可达路径,失败原因是断言期望中文而实际取到英文的 locale 问题。
  • 🗑️ 清理 google-books / gutenberg 死代码(跨端交接 18 任务 3) - (2026-08-02)
    • 背景:RB 会话在「LLM 调用统一到服务端」落地后删除了两个 edge function(线上 + 源码):gutenberg-proxy(三端零引用)、google-books-proxy(RVH 侧数据流不可达)。RVH 侧对应管道随之永远走不通,本次一并清理。交接文档 ~/reading-browser/docs/cross-end/18-rvh-edge-jwt-handoff.md §四。
    • 可达性复核(清理前逐项验证,与 RB 静态追踪一致):_googleBooksCoverUrl 全仓仅 3 处赋值且全为 nullcreate_reading_note_page 两个下载分支永不进入 → coverImageDownloaderProvider 从不执行;readingNoteSearchProvider 零消费方(封面搜索 UI 入口早已拆除,只剩管道)。
    • 删除 14 个文件google_books_datasource{,_impl}.dart / google_books_repository{,_impl}.dart / google_book_entity.dart / google_book_model.dart{,.g,.freezed} / cover_image_downloader.dart / reading_note_search_notifier.dart / reading_note_search_state.dart{,.freezed} + 2 个对应测试文件。是完整纵向切片删除(domain 契约 + entity + data + presentation 一并移除),无跨层悬挂引用。
    • 改动app_config.dart 去掉 googleBooksEdgeFunctionUrl / gutenbergProxyEdgeFunctionUrl(指向已删除的 function);app_providers.dart 去掉 4 个 provider;create_reading_note_page.dart 去掉字段 + 2 处死分支 + _downloadCoverInBackgroundCompactBookCover 去掉 googleBooksCoverUrl 参数与 Image.network 分支(封面此后只有本地 BLOB 一条路径,红线 #6c);.env.example 去掉 GOOGLE_BOOKS_EDGE_FUNCTION_URL 段;ARB 去掉随之孤儿的 readingNotesSavedCoverDownloading / readingNotesGoogleBooks 并重生成 l10n。
    • 文档同步docs/deployment/edge-function-deployment-guide.md 头部去掉对两个已删 function 的引用;docs/rvh-me-features-for-rb-migration.md 标注 Google Books 相关 3 项「RVH 已删除,勿迁」(避免 RB 照单迁移扑空)。
    • 验证flutter analyze error 总数与改动前逐字一致(288,全部是既有 test 编译错误,与本次无关),4 个被改文件零新增 error/warning。⚠️ flutter test 本机跑不了(third_party/sqlite3/ 只 vendor 了 arm64.android.so,缺 libsqlite3.x64.macos.dylib),该缺口改动前即存在。

Changed

  • 🔍 确认 functions.invoke 自动携带用户 JWT(跨端交接 18 任务 1,零代码改动) - (2026-08-02)

    • 结论:RVH 调 lookup-or-fetch-word 时,只要有 session,SDK 就自动送用户 access_token(非 anon key);RB 侧可以给该 function 加「无 sub 即拒」的准入,RVH 无需改代码
    • 验证手法:客户端 wire 级探针——用与 supabase_flutter Supabase.initialize 完全相同的参数构造 SupabaseClient,只把 httpClient 换成录制用的 BaseClient(位于 AuthHttpClient 内层,录到的即真实出网 headers),分别测「无 session」「有 session」两态,全程不联网。探针未进仓;RB 仓零改动(未部署交接文档 §二方式 B 的服务端探针)。
    • 机制AuthHttpClient.send 对每个请求 putIfAbsent("Authorization", 'Bearer ${session?.accessToken ?? anonKey}')FunctionsClient 构造期 headers 不含 Authorization,故每次现取、跟随 token 刷新;硬过期且刷新失败会直接抛错,不会静默降级成 anon。全仓无 functions.setAuth / client.headers = / 自定义 invoke headers。
    • 「未登录 → anon key」分支不可达main.dart 是硬 auth gate(未登录直落 LoginPage,signOut 清栈回登录页),无匿名/游客模式。
    • RVH 侧调用量特征(供 RB 定配额):该 function 只剩 sync backfill 一条活路径(sync_repository_impl.dartgetVocabularyAndPersistoverrideEdgeFunctionFallback: true),逐词、低频、必然已登录;OCR 批量路径被 _enableEdgeFunctionFallback = false 关死,supabase_vocabulary_service.dart:110 那处 invoke 零调用方。
  • 🗄️ 预装库 asset 改名 reading_vocab.dblampio_dict.db(RVH 跟随 RB,byte-equal cutover) - (2026-07-26)

    • RB 会话已把共享预装库(红线 #10 byte-equal)改名 lampio_dict.db(纯改名、字节不变、无版本 bump,应 RVH 上次请求)。RVH 侧跟改:git mv assets/databases/reading_vocab.db lampio_dict.db + 更新 2 处 rootBundle.loadapp_database.dart + lemmatizer_flutter_loader.dart)+ 相关注释/日志。
    • byte-equal 校验sync-rvh-vocabulary.sh 报 "Already in sync",SHA b2821764… 双端一致。_preinstalledVocabVersion 不 bump(=25,内容零变更)。本地用户库 lampio.db 与预装 asset lampio_dict.db 是两个不同文件。
    • 真机 fresh-install 验证:seed 18898 词(source=preinstalled)+ lemmatizer 正常(allied→ally/tried→try)。
  • 🔧 build: vendor sqlite3 预编译库到 third_party/sqlite3/,免每次 GitHub 下载 - (2026-07-26)

    • sqlite3 包冷构建时从 GitHub 下 libsqlite3.arm64.android.so 常失败(本机直连 GitHub 不稳)。改用 hook 的本地源机制:pubspec.yaml hooks.user_defines.sqlite3.{source: test-sqlite3, directory: third_party/sqlite3/} → 读固定目录的 vendored .so永不下载。sha256 01388649…(= 包内 asset_hashes.dart 期望值)。.gitignore 加例外提交该 .so。仅 vendor arm64-android,见 third_party/sqlite3/README.md
  • 🏷️ 品牌统一改名 Lampio(移动端,镜像 RB 桌面端) - (2026-07-26)

    • 应用内文案appTitle(en/zh/ja) English Learning App/英语学习助手/英語学習アプリLampioAppConfig.appName(about 页 + 支持邮件)→ Lampio;authSignInDescription(en/zh/ja) 改统一跨设备措辞。
    • 身份项:iOS CFBundleDisplayName/CFBundleName + Android app_name → Lampio;bundle id/applicationId/namespace com.nikos.*com.lampio.app(含 MainActivity.kt 包迁移至 com/lampio/app/ + 平台 channel com.lampio.app/share 双端同改 + shortcuts.xml)。
    • 本地用户库reading_vocab.dblampio.db(无迁移,存量卸载重装)。⚠️ 预装 asset assets/databases/reading_vocab.db byte-equal 未碰(红线 #10);其重命名交 RB 会话驱动(docs/plans/rename-plan.md §5)。
    • Logobranding/lampio-*.svg(派生自 RB docs/brand/lampio-icon.svg)→ flutter_launcher_icons 生成 iOS/Android 全套。品牌绿 #1f4e3d 不变。
    • 真机验证通过(com.lampio.app + lampio.db + 预装 v25 加载正常)。计划:docs/plans/rename-plan.md
  • 🏗️ 预装词库 pipeline 迁出 RVH → RB 单一产源,RVH 降级纯消费方 - (2026-07-19)

    • 背景:消除「RB 产 lemmatizer JSON → 跨仓喂 RVH pipeline → 烤 db → sync 回 RB」的环状 split-brain。RB 会话已 lift-and-shift 迁入 pipeline(~/reading-browser/tools/vocabulary_builder_v3,commit 6a4d0a8),sync 方向反转为 RB→RVH,byte-equal 验收全绿(db 双端 SHA 恒 b2821764…)。本次 = RVH 镜像收尾(权威方案 ~/reading-browser/docs/plans/rvh-vocab-pipeline-migration-plan.md §八)。
    • 移除git rm -r tools/vocabulary_builder_v3(72 文件,已迁 RB,不迁 git 历史 Q4)+ git rm bin/normalize_phrases.dart(短语 pipeline 孤儿,输入/输出目录随 pipeline 删)。
    • 保留(方案 A):assets/nlp/{surface_to_base,base_forms}.json 作 CLI(phase0_normalize)/测试 fixture —— 是 RB build_dict 权威产物的 byte-equal 副本,非 shipped runtime 资产(运行时读 db 的 lemma_* 表)。新增 assets/nlp/README.md 说明归属 + 红线 #5e 义务。
    • skill 精简.claude/skills/preinstalled-db-update/(v3.0.0 producer → v4.0.0 consumer),删全部产库 orchestration,保留「接收 RB 产物 → byte-equal 校验 → bump 版本 → 设备 reseed 测 → commit」。
    • 措辞更新:CLAUDE.md #5e/#10「RVH pipeline 单点产出」→「RB 单点产出,RVH 纯消费」;pubspec.yaml / example_text_cleaner.dart / lemmatizer.dart 残留 pipeline 路径注释指向 RB。
    • 红线纪律:零内容变更 → _preinstalledVocabVersion(=25)不 bump不 reseed 用户;双端 db + assets/nlp byte-equal 保持;红线 #5e 归一回归 diff 空。

Added

  • 🧩 word_cloze_contexts 纳入跨端同步 + 复习卡真实语境挖空(schema v57,RB v24 镜像) - (2026-07-10)
    • 背景word_cloze_contexts(用户查词时正在读的真实句 + surface + 消歧 gloss,复习卡据此挖空生词)此前 RB 独有 local-only。RB 已把它纳入同步矩阵(migration v24 + Supabase user_word_cloze_contexts)。本次 RVH 镜像:建表 + sync 双向适配 + 复习卡消费。交接文档 ~/reading-browser/docs/cross-end/16-rb-cloze-context-sync-handoff.md
    • 建表(v57):新建本地 word_cloze_contexts(表数 10→11)+ idx_cloze_word。一词多语境池,应用层封顶 5,无 UNIQUE、无 FK(v16 松耦合)。source_platform(远端亦无,push 不得携带,参考 known_words PGRST204)。开发期升 v57 触发删库重建。
    • sync 适配(同步表 5→6)sync_repository_impl.dart 新增 _pushWordClozeContexts(业务键 upsert onConflict=user_id,word,sentence,红线 #5f)+ _pullWordClozeContexts(墓碑三分支 + 双活 sense_gloss 非空回填)+ 本表独有 _reconcileClozePool(合并后按句去重 + 重新封顶,忠实复刻 RB reconcile_cloze_pool:ASCII 折叠去重键,与 SQLite COLLATE NOCASE 一致,不用 Dart Unicode toLowerCase)。接入 syncNow push/pull 编排 + force-full-pull 计数 + getSyncStatus pending 计数。
    • RVH pull-only 定位:不新增本地采集路径;仍参与 reconcile 去重封顶 + 删词/切用户软删墓碑 push 传播收敛(两轮内两端一致)。删词级联软删 word_cloze_contextsdeleteNotebookEntry,镜像 RB clear_cloze_context_for);用户切换清理加 word_cloze_contextsclearLearningDataForUser)。
    • 复习卡消费(背面抽屉)notebook_datasource 三个 due 查询批量附着语境池(单次 IN 查询,镜像 RB srs.rs step 2)→ ReviewWordDto/ReviewWordItemclozeContextsDefinitionDrawer 顶部渲染「你读到的原句」挖空块。新增 ClozeBuilder(移植 RB cloze.ts:整词多形态挖空防同根泄露、句长 8..220 护栏、repetitions 确定性轮换、blankWidth 3–12)。跨端从 RB 采集的真实句在 RVH 复习时可见;sense_gloss 非空时揭晓义项。
    • 验证flutter analyze lib/ 0 error。删库重启 + 登录 sync + MCP 校验见 plan docs/plans/word-cloze-contexts-sync-rvh-plan.md

Changed

  • 🔤 三端表/列全量重命名(schema v56) - (2026-07-09)
    • 背景:镜像 RB 桌面端 + Supabase 已完成的统一改名,消除 notebook_entriesreading_notes 的 "note" 撞车 + "排除词"负面命名。三端(RB/RVH/Supabase)表/列/代码标识符/i18n 文案统一改名,不留新旧混杂。
    • 表/列改名notebook_entrieslearning_entriesreading_sourcesreading_pagesword_sourcesword_page_links(列 notebook_entry_idlearning_entry_idreading_source_idreading_page_id,含 ocr_word_positions 同名列,该表名本身不变)、excluded_wordsknown_words。远端 Supabase user_* 表名同步跟改。RVH 无 sites 表,跳过 favorite_sites。reference_words/reading_notes/ocr_word_positions(表名)保持不变。
    • 停用词架构简化(决策 5)recommended_excluded_wordsdefault_stopwords删除 Supabase 拉取路径SupabaseRecommendedExcludedWordsDataSource/RecommendedExcludedWordsRefreshService 整体删除),改纯本地预装种子(03_init_data.sql 新增 211 行确定性 sys-ew-* id,与 RB 端字节级一致,保证跨端 merge id 相同)。登录态本地 merge 触发点保留(stopwordsMergeProvider,零网络请求)。
    • Dart 全量改名ReadingSourceModel/Entity/Repository/RepositoryImpl/DatasourceReadingPage*WordSourceModel/EntityWordPageLinkModel/Entity(无品牌保护,全量改名,含文件/目录内文件名);NotebookEntryModel/Entity/DisplayDtoLearningEntry*(仅行级数据类改名,NotebookDatasource/NotebookRepository/NotebookRepositoryImpl/notebook_page.dart 等"生词本"品牌外壳及其方法名保留不变);lib/features/excluded_words/lib/features/known_words/ 整目录改名(ExcludedWordModel/Entity/RepositoryKnownWord*)。
    • i18n 语义翻转:ARB excluded*/commonExclude(d)/batchExclude* 等 ~35 个 key 改名 + 文案由"排除"翻转为"已认识/Known"(app_en.arb/app_zh.arbapp_ja.arb 无对应 key 无需改);AppRoutes.excludedWordsAppRoutes.knownWords(路由路径 /known-words);addToExcludedmarkAsKnown 等 UI 动作方法名同步跟改。
    • 数据策略:开发期升 v56(表结构变)触发删库重建,无迁移脚本。预装词库版本不受影响(预装库仅含 vocabulary/lemma_* 系统表)。
    • 实机验证:删库重装 → default_stopwords 211 行种子正确写入 → 登录态本地 merge 进 known_words(+211 行,纯本地无网络)→ 与 Supabase 全表同步往返成功(user_reading_pages/user_learning_entries/user_word_page_links/user_known_words 均正确拉取,PRAGMA foreign_key_check 空)。
    • 详见 docs/plans/archive/table-rename-rvh-handoff.md + table-rename-three-end-plan.md(2026-08-29 归档)。
  • 🏷️ 短语 idiomaticity v21.1 retag — 3 个 over-confident always 重判 + re-scan(预装 v22,A 喂 B 第一步) - (2026-06-24)
    • 背景:v21 上线后真实埋点(phrase_interaction_log,dev 库 2026-06-24)误报率 28%,100% 可归因到 ① boilerplate(RB 第 0 步 commit 2a2e0b3 扫描范围收窄已根治)+ ② 3 个类型层 always mistag(本次)。in the black / to die for / so much for 全是真习语,但 judge 只问「是不是真习语?」(都 yes→≳90%→always),漏了被匹配的 surface 裸串被高频字面搭配碾压so much for⊂"thank you so much for" / in the black⊂"in the black coat" / to die for⊂"to die for a cause")。v21 Pass-2 对抗也漏了——它问短语孤立的字面读法,而非串嵌在更大字面结构里的占比。
    • rubric 修补(前向保险,防下次 reseed 打回)lib/idiomaticity_generator.dart 双 prompt 显式加「surface 串字面搭配频率」criterion——_classifyPrompt 新增 rule 6(SURFACE-STRING TRAP)(判裸串、含嵌入占比,阈值套在那个占比上)+ _challengePrompt 加同款 demote 触发。下次 from-scratch 重跑用新判据。
    • 落档:3 个确定性(so much for→literal、in the black/to die for→context)+ re-scan always 桶(新 bin/rescan_always_idiomaticity.dart,v4-pro temp 0,demote-only)连带降级 219 条同模式误标 always→context(真习语但裸串被字面碾压,如 walk in the park/by the book/at a loss/make sense/out loud/give birth/the hard way)。demote-only 对 RB 单调更安全(只减假高亮、绝不新增;误降仅覆盖损失,选区/§9 可救回),符合「宁可漏不误报」非对称纪律。校准锚点全保留 always(by virtue of/break the ice/a dime a dozen…)。
    • 分布:always 3262→3040 / context 2979→3200 / literal 370→371(always 仍占 6611 的 46%,覆盖不过窄)。
    • 加性证明(红线 #10):row-content diff 实证 18899 行/word set/lemma 表全不变、仅 222 行 word_tags 变(= 2 确定性 + 219 re-scan→context + 1→literal)、0 行触碰非 idiomaticity tag。纯 word_tags tag 值增量、零 schema migration(schema v54 不变),沿 v21 committed 缓存先例(data/phrase_idiomaticity.json,222 条 tier 值变)。_preinstalledVocabVersion 21→22;新预装库 SHA 042c71aeb1…。RB 消费零代码改动(filter 已 === 'always'),据 ~/reading-browser/docs/cross-end/15-rb-phrase-tiering-reseed-confirmation.md §10 跑 /vocab-reseed
  • 🗂️ lemmatizer 资产折叠进 reading_vocab.db(跨端握手 13,停止打包 JSON) - (2026-06-15)
    • 背景:双端原各自打包 + 加载 3 个 byte-equal 资产(reading_vocab.db + surface_to_base.json + base_forms.json)。本次把后两个 JSON 烤成 db 内两张表,运行时改从 db 读 lemmatizer Layer 1/2,打包/加载资产 3→1。收益:① 同步面 3→1;② 消灭一类红线 #9 漂移(运行时归一器与「当初归一 PK 用的那份 surface_to_base」装在同一文件,字节级保证一致);③ app 包变小。RB 先定表契约(~/reading-browser/docs/cross-end/13-lemmatizer-fold-handoff.md),RVH 落地段二(pipeline 生成 + 运行时改读),RB 段三对侧。
    • 表契约(握手 §1,generate_db 单点 bake):reading_vocab.db 新增三张表与 vocabulary 并存 — lemma_surface_to_base(surface PK, base NOT NULL) 140,370 行 ← surface_to_base.json map;lemma_base_forms(base PK) 101,646 行 ← base_forms.json base;lemma_meta(key PK, value) 4 行 provenance(version/count)。确定性纪律:INSERT 按主键升序写入(run-to-run 可复现)。在 patch_lemmatizer_assets.py patch 之后的 JSON 上 bake(as 无 surface 映射、base_forms 含 as 自映射、picked/passed/trying 已修正)。
    • pipelineconfig.yamllemmatizer_base_forms 路径;DbGenerator._bakeLemmaTables(写完 vocabulary 表后建三表 + 排序写入)。db↔JSON 内容全量比对 byte-equal(diff 0)。
    • 运行时Lemmatizer.preloadFromMaps(绕过 JSON 反序列化,从 Set/Map 直装);lemmatizer_flutter_loader 改从 db SELECT lemma_surface_to_base/lemma_base_forms(复用预装库导入拷出的 preinstalled_vocab.db,缺表自愈重拷兜底)。启动顺序串行化:lemmatizer 加载从 main() 并行批移到 StartupInitializationService Stage 1.5(_initDatabase 之后),保证 db 先于 lemmatizer 就绪。pubspec.yaml 移除 assets/nlp/ 打包(JSON 文件留盘作 pipeline 构建输入)。CLI / 测试仍用 preloadFromJsonStrings(dart:io 读 JSON,不受影响)。
    • 回归lemma-regression.txt 行为零变化(as→as / picked→pick / passed→pass / trying→try / transferred→transfer / mice→mouse);vocabulary 内容 byte-identical(仅新增三表)。
    • 版本:app 主库 schema 不变(lemma_* 仅在预装库,不进主库 schema);_preinstalledVocabVersion 不变(vocabulary 内容零变化,loader 自愈兜底处理既存安装的旧 preinstalled_vocab.db)。预装库 SHA c81d137492…05facc9bbd…(新增三表)。交接 docs/cross-end/13-lemmatizer-fold-reseed-handoff.md,RB 段三 + 收口 14-rb-lemmatizer-fold-confirmation.md

Added

  • 🗑️ reading_sources / word_sources 补 deleted_at 软删列(schema v55,RVH 镜像 RB 笔记四级删除闭环) - (2026-07-07)
    • 背景:RB 端完成「笔记四级删除闭环」(commit 6fa0185),为跨端同步共享表 reading_sources/word_sources 加了软删列,支持"移除来源页"(来源软删,词仍留生词本)和"词级解除关联"两级删除。RVH 端此前对这两列一无所知,pull 时会把 RB 已删的行当活行落地,用户会看到"已删数据复现"。Supabase 端两条 ALTER TABLE ... ADD COLUMN IF NOT EXISTS deleted_at TEXT 已由 RB 会话在 Dashboard 执行。
    • RVH 无删除 UI 入口:本次改动纯粹是同步层的墓碑消费(pull-only),不新增任何删除按钮/交互。
    • Schema:两表各加 deleted_at TEXT(nullable);_schemaVersion 54→55。
    • Sync 引擎_pushReadingSources/_pushWordSourceRelations 加墓碑 dirty-check 分支(防御性一致,即便 RVH 当前无本地删除入口);_pullReadingSources/_pullWordSources 改造为三分支墓碑逻辑(remote 删+本地有→标记;本地已删+remote 活→跳过不复活;本地无+remote 删→跳过不落墓碑,FK-safe),模板照抄既有 notebook_entries/excluded_words
    • 复活语义(红线 #7 对齐)createWordSourceRelationConflictAlgorithm.ignore 改为 INSERT ... ON CONFLICT(notebook_entry_id, reading_source_id) DO UPDATE SET deleted_at=NULL,重新收词 = 复活关联,而非静默 no-op。
    • 消费查询reading_source_datasource.dart/notebook_datasource.dart/reading_note_datasource_impl.dart/statistics_repository_impl.dart 所有 JOIN/子查询涉及 word_sources/reading_sources 处补 deleted_at IS NULL 过滤(getReadingSourceById/getAllReadingSources 按 id 直查场景不过滤,对齐 RB 同款)。
    • 实机验证:删库重装后触发 sync,Supabase 上已存在的 2 条 user_word_sources 软删行 + 1 条 user_reading_sources 软删行均被正确跳过(本地无+remote 删→FK-safe skip),未在本地复现。
    • 交接文档:~/reading-browser/docs/plans/notes-crud-phase2-plan.md §八。
  • 🏷️ 短语「非组合性三档标签」idiomaticity(预装 v21,短语精度根治) - (2026-06-18)
    • 背景:RB 短语自动高亮 thin-slice v1 用 basic tag 当精度粗代理,实测误报率 41%phrase_interaction_log,dismissed/clicked),集中在「非-basic 但高度字面的动词+介词」短语(come to/work in/look on…)。根因:basic 刻画「高频」非「组合性」。修法(B 方案根治):给短语判类型层、与上下文无关的非组合性三档,RB「只自动高亮 always 桶」。RB 先定契约 ~/reading-browser/docs/plans/phrase-noncompositionality-tiering-crossend-plan.md,RVH 落地段一。交接 ~/reading-browser/docs/cross-end/15-rb-phrase-tiering-reseed-confirmation.md,计划 docs/plans/phrase-idiomaticity-tiering-rvh-plan.md
    • 数据:6611 条 phrasal_verb/idiom 各打一档 idiomaticity ∈ {always|context|literal},落为 vocabulary.word_tags JSON 数组新 tag 值 idiomaticity:<tier>riding 现有列、不增列、schema v54 不变、纯 additive——RB 现有 word_tags=excluded.word_tags merge SQL 不动,零 migration)。分布 always 3262 / context 2979 / literal 370 / bad_entry 0。phrasal verb 强偏 context(1587/1861≈85%),idiom 偏 always。⚠️ RB 取 tier 用 substr(value,14)(前缀 idiomaticity: 13 字符含冒号)。
    • 生成:deepseek-v4-pro temperature 0 + 两遍(Pass1 分类 + Pass2 仅对 always 对抗降级,保 always 严格;非对称风险:误判 always=假高亮,误判 context/literal=仅覆盖损失)。校准 23/23 锚点命中、0 dangerous false-always。一次性成本 ~$2。
    • 坏词条物理移除:机制就位(generate_db overlay 物理 skip),但本库 0 命中——Pass1 初版误标 116 条 bad,复核全是怪异 lemmatize 键的真习语common grind←"common ground"、good lay plan←"best laid plans"),收紧 bad_entry 规则(判 display 引用形+gloss 不判归一键)后重判→0 bad,故 0 移除、无 prune 负担、词数仍 18899。
    • 确定性(红线 #10):结果固化进提交进仓库的缓存 tools/vocabulary_builder_v3/data/phrase_idiomaticity.json(6611 键,排序+无时间戳),pipeline 复用不重判 → word_tags 双跑哈希一致。新增 lib/idiomaticity_{cache,generator}.dart + bin/classify_idiomaticity.dart;build_all Step 5.68(opt-in --classify-idiomaticity);回填走短语 overlay(generate_db::_applyPhraseOverlay,无独立 apply step)。
    • 整合 backlog:本特性是 finalize 短语库之上的叠加判定层(同 ⑤/⑦ 范式);长期可折进 score_phrases.dart P2(下次短语库从头重建时),现在不做(避免 churn 已提交 phrase_library*.json)。
    • 版本_preinstalledVocabVersion 20→21(schema v54 不变);预装库 SHA 05facc9bbd…23d4068718…,词数不变 18899。
  • 📜 词源 / 助记:word 级 vocabulary.etymology 列填充(预装 v20,跨端 ⑦) - (2026-06-15)
    • 背景:RB(桌面端)做「词源/助记」展示特性。词源是词本身属性、context-independent,按跨端红线 #10 由 RVH pipeline 单点离线产出进预装库,RB byte-equal 只读消费(零运行时 LLM)。RB 先定消费契约 ~/reading-browser/docs/cross-end/12-etymology-handoff.md,RVH 落地段一。交接 docs/cross-end/12-etymology-reseed-handoff.md
    • 数据:word 级 etymology 列(早在 baseline、原 0 填充)存 JSON 字符串 {summary_en, summary_zh, roots?:[{root, gloss_zh}]}。覆盖 12743 词(单词 7470 / 习语 5273;含 roots 10267 词 / 18275 词根);候选 = 习语全量 + B2/C1/C2 + B1 长词(len≥7),其余宁缺毋滥留 NULL(不硬凑覆盖率)。schema v54 不变(独立列)。
    • 反 folk etymology(契约核心铁律):三重网 = 规则层黑名单+acronym-claim 检测(35 词拦截)+ Pass2 对抗式事实核查(586 词显式 reject)+ 不确定显式 hedge / 留 NULL。全库扫描 0 folk 泄漏;陷阱词 posh/golf/tip 全数 NULL。v1 不做谐音/故事助记,助记价值由真实词根承载。
    • 生成:deepseek-v4-pro temperature 0 + 对抗式事实核查(Pass1 生成 → Pass2 核查)。一次性成本 $3.50 首跑 + $0.62 召回重跑。首跑发现 Pass2「评审漏回显→误空」过严,正向修复 _applyReview missing→keep(只显式 reject 才杀),召回 1094 合法词源(benevolent/ambassador 等)。
    • 确定性(红线 #10):结果固化进提交进仓库的缓存 tools/vocabulary_builder_v3/data/etymology.json(16769 键 / 13059 filled),pipeline 复用不重生成 → 双跑 etymology byte-equal。新增 lib/etymology_{cache,generator}.dart + bin/generate_etymology.dart;build_all Step 5.67(opt-in --generate-etymology)。回填走 emoji 同款 word 级模式generate_db::_applyEtymologyMapping,短语 overlay 后权威写入,覆盖单词+习语;非 jsonl-apply)。
    • 版本_preinstalledVocabVersion 19→20(schema v54 不变);预装库 SHA e180784393…c81d137492…,词数不变 18899。设备 reseed 实测通过(v19→v20,活库 etymology=12743 落库)。
  • 🔀 近义词辨析 / 搭配:pos_definitions 新增 differentiation + collocations 两键(预装 v19,跨端 ⑤) - (2026-06-14)
    • 背景:RB(桌面端)做「近义词辨析/搭配」展示特性。该数据是词/义项本身属性、context-independent,按跨端红线 #10 由 RVH pipeline 单点离线产出进预装库,RB byte-equal 只读消费(零运行时 LLM)。RB 先定消费契约 ~/reading-browser/docs/cross-end/10-synonym-differentiation-handoff.md,RVH 落地段一。计划 docs/plans/synonym-differentiation-rvh-plan.md,交接 docs/cross-end/10-synonym-differentiation-reseed-handoff.md
    • 数据pos_definitions 每个带非空 synonyms 的 definition 加两键(与 synonyms 同址同级,无新表无新列,schema v54 不变):differentiation:[{synonym, distinction_en, distinction_zh}](synonym 必为该 def synonyms 里真实 surface)+ collocations:[{pattern, note_zh}]。覆盖 5329 词 differentiation / 5600 词 collocations(9237/9759 义项);933 义项宁缺毋滥留空。短语 v1 不产(0 条短语义项带 synonyms),留 v2。
    • 生成:deepseek-v4-flash temperature 0 + 二次评审(gen→review),从噪声 synonyms(thesaurus dump,如 big 85 条)挑 3–5 个学习者易混高频词讲「何时用」(语域/强度/搭配/语气),禁循环定义、反幻觉。一次性成本 $0.98(2984 calls)。
    • 确定性(红线 #10):结果固化进提交进仓库的缓存 tools/vocabulary_builder_v3/data/differentiation.json(10745 sense 键),pipeline 复用不重生成 → 双跑 pos_definitions sha 一致。新增 lib/differentiation_{cache,generator}.dart + bin/{generate,apply}_differentiation*.dart,build_all Step 5.65(opt-in --generate-differentiation)/ 5.66(必跑 apply)。SenseDefinition model + generate_db 解析扩展两键(强类型 round-trip,非 JSON 透传);provider 加可选 temperature 参数。
    • 版本_preinstalledVocabVersion 18→19(schema v54 不变);预装库 SHA ef7a7297…e180784393…,词数不变 18899。设备 reseed 实测通过(v18→v19,两键落库形状匹配契约)。
  • 🪄 习语归一治理:lemmatizer 资产修正 + canonical_surface 引用形列(Schema v54 / 预装 v18) - (2026-06-08)
    • 背景:RB 短语高亮特性暴露预装短语库两类归一噪声(诊断 ~/reading-browser/docs/cross-end/08-rb-phrase-reseed-confirmation.md)。治理在 RVH pipeline 侧(短语库单点产出)。
    • 根因 1(资产错映射,红线 #5e)surface_to_base.json 修正 as(删映射,lemmatize("as") 改由 Layer 2 base_forms 自映射返回 as)+ picked→pick/passed→pass/trying→try。确定性 patch 脚本 tools/vocabulary_builder_v3/bin/patch_lemmatizer_assets.py(文本级精确替换保 byte-equal)。SHA 1a468a82…ca75b179…(140371→140370);base_forms.json 不变。→ 48 条含 as 习语 PK 还原(a a wholeas a whole 等)。
    • 根因 2(引用形被归一吃掉)vocabulary 新增 canonical_surface TEXT(红线 #10 正交列)。逐 token lemmatize 让 raining cats and dogs→归一键 rain cat and dog(匹配/跨端承重,不可动),canonical_surface 保留可读引用形(取 phrase_library_final.raw_forms[0])供 RB review/popup 显示。phrase 行 6608/6611 非空(3 个复合名词习语归一无损、NULL 回退 word)。
    • 数据模型澄清:归一键↔引用形非一对多屈折(屈折变体运行时由 normalize 吸收、永不入库);Kaikki 每习语一引用形;匹配只走归一键,canonical_surface 纯展示。
    • 回归lemma-regression.txt 加 as/picked/passed/trying;phrase-normalize-regression.txt 加 as 习语;rvh-phrase-normalize.csv(RB 内联单测同步)picked up pic→pick。
    • 版本_schemaVersion 53→54、_preinstalledVocabVersion 17→18;预装库 SHA 22ee5f1f…ef7a7297…,词数不变 18899。⚠️ 48 旧 PK 需 reseed prune。交接 docs/cross-end/09-phrase-asset-fix-reseed-handoff.md
  • 🔤 预装短语库:phrasal verb / idiom 作为 vocabulary 普通词条入库(v17) - (2026-06-07)
    • 背景:RB(桌面端)做「短语动词/习语高亮」特性,短语数据按跨端红线 #10 必须由 RVH pipeline 单点产出进预装库 reading_vocab.db,RB byte-equal 只读消费。本次落库 RVH 侧(Phase 1)。计划见 docs/plans/phrase-library-rvh-phase1-plan.md,交接见 docs/cross-end/07-phrase-library-reseed-handoff.md
    • 数据:新增 6611 条常用短语(phrasal_verb 1861 / idiom 5336,含重叠),作为 vocabulary 普通词条,无新表无新列(复用现有 schema)。词数 12291→18899
    • 建模word_tagsphrasal_verb/idiom;单 POS phrase + 覆盖度驱动义项(idiom 多 1 条、常用 PV 1–5 条,如 get up 起床/站起/攀登/增强/组织);frequency_rank=commonness×2000 代理(降噪);无 CEFR(短语不产)。basic tag 标 400 条「太简单=系统默认排除」短语。
    • pipeline(独立轻量,非单词主线):P1 bin/extract_phrases.dart(Kaikki 候选 12261)→ P2 bin/score_phrases.dart(deepseek 评分 keep/commonness/is_basic/senses,缓存 data/phrase_library.json,确定性,成本 $0.39)→ P3 bin/normalize_phrases.dart(app 真 lemmatizer normalizePhrase 逐 token 归一+去重,阈值 commonness≤3)→ P4 generate_db._applyPhraseOverlay overlay 注入。
    • 归一(红线 #9):新增 Lemmatizer.normalizePhrase(逐 token lemmatize+join+NFC),不改现有 normalize(避免回溯改 137 个多词条目 PK)。跨端 20 样例对齐 + test/data/phrase-normalize-regression.txt 回归。
    • 决定 A:「太简单=默认排除」复用 v42 recommended_excluded_words 机制;RVH 产 output/recommended_excluded_phrases.jsonl(400 条)供填 Supabase 公共表(需 RB 协调,对简报 §5「无 Supabase 动作」的有意偏离)。
    • 导入修复app_database._checkAndImportPreinstalledVocabulary CEFR 质量门豁免短语(phrasal_verb/idiom 行 NULL CEFR 合法,设备测试实测拦截后修复)。
    • 🔒 确定性 / byte-equal:确定性双跑内容 sha 一致;DB sha 22ee5f1f…_preinstalledVocabVersion 16→17(schema 不变)。设备 reseed 实测:v0→v17、Upserted 18899、版本 v17、ex>150=0。短语不进 Supabase 同步矩阵(预装库权威)。
    • 已知项:lemmatizer 资产 3 处错误映射(picked→pic/passed→pas/trying→trie,跨端一致不破,影响这几个动词屈折召回,待红线 #5e 独立修);prepositional verb(look into)当前标 idiom(particle 集未含纯介词,~19 条,功能无影响)。
  • 🖼️ 词汇插图:vocabulary 加 emoji 列 + 回填预装库(OpenMoji) - (2026-06-06)
    • 背景:RB(桌面端)做「具象名词配 OpenMoji 插图」增强,但 emoji 是词内禀、双端共需数据,按跨端红线 #10 必须由 RVH pipeline 单点产出进预装库 reading_vocab.db,RB byte-equal 只读消费。本次落库 RVH 侧。
    • Schema(v52→v53)vocabulary 新增 emoji TEXT(OpenMoji hexcode 大写如 '1F436' / NULL=无图),词级一词一图,正交于 CEFR / word_tags。4 处 DDL/写入对齐(assets/sql/01_create_tables.sql + db_generator.dart CREATE+INSERT + app_database._buildPreinstalledVocabRow);VocabularyEntryemoji 字段。
    • 回填(严格 / 质量优先档):RB 交付 726 候选 → 人工复核 505 采用(tier1 326 / tier2 中心词 114 / tier2 非中心词白名单 65;489 去重 hexcode)。误配=负收益,凡同形词主导义弱(bank/bat/watch…)、图被修饰词主导(face=monkey face)、抽象/颜色/介词假阳性一律弃;ambiguous 仅主导义强者留(star/ship/train/drum/pen/ring/bug/hammer/ruler/plate/shell)。复核固化为 tools/vocabulary_builder_v3/scripts/curate_word_emoji.py 覆盖名单(确定性可复现)+ data/word_emoji_mapping.tsv(提交进仓库锚点)。
    • pipeline 接入generate_db._applyEmojiMapping 在 Step 6 导出时按词回填(确定性,不调 LLM)。config.yamlword_emoji_mapping path。
    • 🔒 确定性 / byte-equal:重导出后 pos_definitions 内容 sha 完全不变bb065041…,仅 emoji 列新增);连跑两次(含 emoji)内容 sha 一致。词数 12291 不变、0 非 preinstalled、0 bad CEFR、0 例句>150。
    • 落地:重导出 assets/databases/reading_vocab.db(sha a8d7f81b…);_preinstalledVocabVersion 15→16;_schemaVersion 52→53(表结构变,开发期删库重建)。emoji 不进 Supabase 同步矩阵(预装库权威数据,未碰 9 表同步引擎)。RB 据 docs/cross-end/06-…/vocab-reseed。RVH 自己的 Flutter 插图 UI(SVG 打包 + 组件 + 署名)本次未做,留后续会话(emoji 列已就绪,不阻塞 RB)。

Changed

  • 预装词库 v15:例句 GDEX round 2 + LLM 例句生成补全(预生成缓存) - (2026-06-03)

    • 背景:v14 减法后仍有漏网(appeal 残留 ~190 字符丁尼生诗含 spake + 诗行)且约 51% 义项无例句。本轮增量收紧 + LLM 补足,不重做 v14 减法。
    • GDEX round 2lib/quality_governor.dart):例句长度门 200→150;丢含换行/" / " 诗行;古词表扩充 spake + 精选古语 -est(makest/canst/wouldst… 不按后缀盲丢,保留 best/latest 最高级)。例句 100211→37999(纯 corpus)。
    • LLM 例句生成补全(B 全量补充):deepseek-v4-flash 按词喂全部义项、为每个 sense 生成短·现代·贴义例句(两遍:生成 + 评审区分度),与清洗后语料混排(cap≤3,corpus 在前 + ≥1 llm 槽)。不可区分义留空(宁缺毋滥)。95% 义项有例句(corpus 35760 + llm 50896;4072 义项评审留空)。成本 $1.18 一次性。
    • 🔒 确定性:生成结果持久化进仓库缓存 tools/vocabulary_builder_v3/data/generated_examples.json(55369 sense 键);pipeline 复用不重生成,新增词才增量。实测连续两次重建 pos_definitions 内容 sha 完全一致(无 LLM 漂移)。
    • example_source 标签:sense 加与 examples 等长的并行数组(["corpus","llm"]),展示层可重排/打「AI 生成」角标。additive 跨端字段(RB/Edge 解析忽略未知键即可)。
    • pipeline 接入build_all.dart 加 Step 5.55(opt-in --generate-examples → 缓存)+ Step 5.6(必跑 apply:缓存→独立 applied_vocabulary.jsonl,govern 的 translated 保持纯 corpus 保证跨 build 确定性)。新增 lib/example_generator.dart / lib/example_cache.dart / bin/generate_examples.dart / bin/apply_example_cache.dartSenseDefinition + generate_dbexample_source 穿透。
    • 落地:重导出 assets/databases/reading_vocab.db(sha 688aafdf…);_preinstalledVocabVersion 14→15(schema 不变 v52)。RB 据 docs/cross-end/05-… reseed。
  • 🧹 预装词库 pos_definitions 质量治理(例句 + 释义,GDEX 式过滤) - (2026-06-02)

    • 背景pos_definitions 由 kaikki(wiktextract 解析自 Wiktionary)灌入,Wiktionary 例句为语文学考据而非教学设计——23.5% 例句 >200 字符(整段书摘 / 古拼写引文 / 书目 / 全大写标题 / OCR 残留),平均 5.09 义项/词、11.6% 义项 archaic,粒度过细抬高跨端 LLM 消歧成本。pos_definitions 三端 byte-equal 共用,RVH 源头治理后整包重导出。
    • 治理(后处理现有 translated_vocabulary.jsonl,不重跑 LLM,保留全部翻译/CEFR/audit)
      • 例句:长度门(丢 <15 / >200)、丢含省略号 /... 的截断引文、优先单句(多句丢,含缩写误报豁免)、过滤全大写标题 / 古拼写(long-s ſ、thou/hath/-eth 等)/ 书目引文(ISBN/vol./doi/年份括注)/ OCR 转义残留(含裸 #NN; 字符引用),每 sense 按「含目标词·短」排序取 ≤3。
      • 释义:每 POS 封顶 5、丢 archaic 义项 + 双重空保护(POS 全 archaic 留 1 条;词级 zh 锚点保护保证词数不掉)、近义启发式去重、裁剪 gloss 开头冗长 register 括注。
    • 效果(实测):词数 12291 → 12291(不变);义项 73120 → 55369(avg 4.50/词);例句 100211 → 45313,>200 字符 23561 → 0;archaic 义项 8498 → 988(仅保护残留)。45% 义项治理后无例句(留待 Backlog #1 LLM 生成补足)。
    • 落地:新增 tools/vocabulary_builder_v3/lib/quality_governor.dart + bin/govern_quality.dart,并接入 bin/build_all.dartStep 5.5(必跑)——Step 6 改为始终从治理后的 jsonl 重载,全量重建天然产出治理数据(不再需手动跑)。重建 DB → assets/databases/reading_vocab.db_preinstalledVocabVersion 13→14(既存安装 UPSERT 覆盖 pos_definitions);schema 不变(仍 v52)。
    • 跨端:RB 会话据新 DB sha256 + 版本号做 reseed migration(同一份 byte-equal 内容重导入)。详见 docs/plans/pos-definitions-quality-governance.md + docs/cross-end/
  • 🔍 词表页查询区精简:默认只显示搜索框,筛选条件折叠 - (2026-06-01)

    • 生词本 / 浏览词库 / 排除词 三页:搜索框右侧新增筛选切换按钮(tune 图标),筛选条件(书籍下拉 / 状态 / CEFR / 来源 / 类型 chips)默认隐藏,点按钮 AnimatedSize 平滑展开/收起。按钮在展开或有激活筛选时主色高亮,有激活筛选时右上角小圆点提示(即使收起也能感知)。复用现有 CollapsibleFilterPanelalwaysExpanded 模式)+ 各 notifier,筛选逻辑零改动。
    • 「我的」页「我的学习」区:将原生词本页 ⋮ 菜单的「浏览核心词库」「已排除的词」迁出为居中文字入口(置于生词本卡片下方,样式同「来源图片」);生词本页 ⋮ 菜单仅保留「帮助」。
    • 阅读笔记列表页:去掉「找到 X 本书」前的 filter_list 图标(该页无筛选条件,图标易误解)。
    • 新增组件shared/.../word_management/filter_toggle_button.dart(无状态,三页统一复用)。
    • 改动文件me_page.dart / notebook_page.dart / core_vocabulary_page.dart / excluded_words_page.dart / reading_note_list_page.dart / filter_toggle_button.dart(新)。
  • 🎀 释义抽屉头部精简:单词独占行 + 副信息行 + 翻译开关移设置 - (2026-05-31)

    • 单词独占一行WordTitleRow 精简为纯居中单词(去掉右侧两个图标按钮),单词作为主角更突出,钉顶块更简洁。
    • 新副信息行 WordMetaRow:单词下方一行,音标 + 发音居中(Stack 居中,与上方居中单词对齐),右侧低强调「✓ 已掌握」(muted TextButton)。已掌握从原来挤在单词行右侧下沉到此(中频操作、不喧宾夺主);复用 PhoneticAudioRow。副行与 chips 行间距 +14px(之前贴太近)。
    • 翻译开关移到设置页:drawer 去掉 onToggleTranslation(及随之失效的 _showActionsNotifier 折叠态隐藏逻辑);「我的」页翻译语言下方新增「复习时显示母语释义」SettingsToggleItem,绑 showNativeTranslationInReviewProvider。理由:该偏好"设一次不常改",更适合设置页而非每词的抽屉头部。drawer 仍读 showNativeTranslation 渲染释义。
    • 「词形/词族」chip 仅留图标:去掉文字、account_tree 图标 18px + tooltip 兜底,节省 chips 行横向空间。
    • 闪卡 + 重温共用 DefinitionDrawer,两模式同步生效。
    • 改动文件word_title_row.dart(精简) / word_meta_row.dart(新) / definition_drawer.dart(接入+删 onToggleTranslation/_showActionsNotifier) / definition_content.dart(forms chip 图标化) / review_session_page.dart+revisit_page.dart(去 onToggleTranslation 接线) / profile_page.dart(加设置开关)
  • 🪟 释义抽屉「sense 区下拉关闭」+ 速度方向 snap - (2026-05-31)

    • 下拉关闭(#6):释义 body 外包观察型 Listener(不进 arena、不消费)+ NotificationListener 跟踪内部 SCV 是否到顶。仅当「竖向为主 + 向下越过 slop + 手势起始即在顶部」三条件同时满足才接管为下拉关闭,接管瞬间 rebase 起点避免跳变;否则纯放行(SCV 滚动 / PageView 横滑 / 子组件 tap 不受影响)。内部 SCV 在顶部时(ClampingScrollPhysics)下拉本就不动,故「SCV 闲置 + 驱动抽屉」天然共存。闪卡 + 重温两模式均生效。
    • 速度方向 snap:松手用 VelocityTracker 的甩动方向决定 snap(上甩→展开、下甩→收起,阈值 350 px/s),优先于「起点→当前净位移」方向。修复「下拉再上拉甩开却仍折叠」——尤其 body 拖动起点锚在展开态时净位移恒判收起的问题。chrome 拖动同步获得更跟手的 fling 手感。
    • 灵敏度调校:下拉接管 slop 8→28px;且仅手势起始即在顶部才接管(去掉「内容中途滚到顶被动接管」),避免来回滚动 sense 触顶时误触发关闭。想关闭则松手后从顶部重新下拉,或用手柄/词头拖动、点暗色背景。
    • 改动文件definition_drawer.dart(+111/−8)。计划文档 ~/.claude/plans/pure-crunching-raccoon.md
  • ♻️ 重温模式释义弹窗复用 DefinitionDrawer(消除第二套实现) - (2026-05-31)

    • 背景:复习两个模式各有一套释义弹窗——闪卡模式 DefinitionDrawer(刚完成 A+B2 重构)、重温模式 RevisitPageshowModalBottomSheet+DraggableScrollableSheet+私有 _WordDefinitionSheet+buildAllPosDefinitions+WordHeaderRow 另组一套。重温这套没有闪卡的修复,尤其 chips 显示条件仍是 showChipsRow = totalPages > 1,单词性无词形的词(如 relatively)不显示词性 chip;且同样的「头部+chips+POS 翻页」组装写了两遍。
    • 复用RevisitPage 改用同一个 DefinitionDrawerGradientAppBarPage.overlay 挂 scrim+drawer,照搬 ReviewSessionPage 编排),自动获得全部修复。两模式共享同一组件,差异只在宿主「停靠态」——闪卡常驻停 peek+有评分;重温点词才挂载+展开、收起/点 scrim/拖到底则整个移除 overlay(完全隐藏,保留旧模态「点外消失」手感)、不传评分回调(无评分行)。ImageNoteWordInfo 字段与 DefinitionDrawer 入参 1:1,无需适配。关闭编排用 _drawerExpandedOnce guard:仅「展开过又收回 peek」才 setState 移除(防挂载初始的 minSize 被误判)。
    • chips 显示修复DefinitionDrawer):_multiPagesAvailable()(POS+词形页 >1)→ _hasStructuredData()(有 POS 即显示)。单词性也显示词性 chip(chip 承载词性+CEFR 标签,不只是翻页切换器)。重温复用后同步获得此修复。
    • 死代码清理(dedup 收益,净 −512 行)buildAllPosDefinitions/_AllPosDefinitionsView/_AdaptiveHeightPageView/FormsToggleButtondefinition_content.dart,含 buggy 的 showChipsRow>1)与整个 word_header_row.dart(迁移后唯一消费方已移除,grep 确认零引用)全部删除。保留 PosChipsRow/PosPage/PosPageContent/buildSimpleDefinition/buildFormsAndFamilyCard/hasFormsAndFamilyData/_FormsChipButton(DefinitionDrawer 在用)。
    • 改动文件revisit_page.dart(复用+删模态) / definition_content.dart(删死代码) / word_header_row.dart(整删) / definition_drawer.dart(chips 修复) / phonetic_audio_row.dart+word_title_row.dart(去 dartdoc 死引用)
    • 设备验证:重温点词展开(无评分行)/ chips 单词性显示 / 点外完全消失 / 拖动关闭 / 标记已掌握 / 连续切词 / 切回闪卡不变 — 全部通过。计划文档 ~/.claude/plans/pure-crunching-raccoon.md
  • 🧩 复习释义抽屉重构(A + B2:脱离 DraggableScrollableSheet + 渐显布局) - (2026-05-31)

    • 背景(两个已确诊根因):①overflow 死区 — 单一常量 expandedThreshold=0.20 同时承担「显示完整头部(音标+chips)」和「折叠阈值」两个语义,但完整头部+评分行实际要 ≈0.234 才装得下,size∈(0.20,0.234] 区间内容已渲染但 sheet 不够高 → Expanded 压到 0 → 评分行被顶出 → BOTTOM OVERFLOWED;②慢拉停住 — 手动 jumpToDraggableScrollableSheet 内置 scroll-extent↔size 耦合物理对抗,慢拉在死区附近被吸附卡住。
    • B2(驱动层):删除 DraggableScrollableSheet,改用 AnimationController 自管抽屉高度(SingleTickerProviderStateMixin,value∈[0,1] 线性映射 size∈[minSize,maxSize])。抽屉是宿主全屏 overlay 内 Positioned(bottom:0) 下的 SizedBox(height: screenH*size);手势 delta 直接 set controller.value(真跟手),松手 animateTo 按拖动方向 snap。释义 PageView 与每页滚动各自独立,与抽屉高度零耦合 → 「jumpTo 对抗物理」整类 bug 从架构上消除。
    • A(布局层):钉顶只放单词行(新 WordTitleRow,恒定高度),音标+发音(新 PhoneticAudioRow)、POS chips、释义 body 全部进入中部弹性渐显区,随抽屉长大用单一 opacity 连续淡入(无 compact↔full 翻转、无 AnimatedSize)。固定 chrome 恒为「手柄+单词行+评分行」,minSize 即装得下 → overflow 结构上不可能。弹性区用 OverflowBox(topCenter 锚定展开态目标高度)+ ClipRect,避免折叠态中部空间极小时内层 Column 撑爆触发 RenderFlex 溢出。
    • 必要参数调整minSize 0.165→0.18(钉顶改为常驻「单词+按钮」行,比原 compact 单词行高,需对应扩容才不在折叠态 overflow)。对 scrim / GestureReviewCard 两个消费方均无害(card 仅多预留 ~13px 底部空间)。
    • WordHeaderRow 拆分:音频播放态 + 音标行抽到 PhoneticAudioRow,单词+按钮抽到 WordTitleRowWordHeaderRow 本体保留(RevisitPage 仍用)。折叠态隐藏「翻译开关/标记已掌握」两个图标按钮(showActions 派生 notifier,跨 expandedThreshold 跳变),恢复重构前 peek 行为。
    • web 快照高亮词点击展开(修复既有接线缺口)WebSnapshotSourcePage 此前只接 onBackgroundTap、无 onWordTap,web 源(RB 跨端 HTML 快照)里点高亮 <mark> 无法展开抽屉。新增 RvhWordTap JavaScriptChannel,注入脚本给每个 <mark>click → 回传 Flutter → 展开抽屉,与 text/image 来源对齐。
    • 支撑重构definition_content.dartPosPage / PosChipsRow / PosPageContent 由私有改为公开供抽屉直接组合;GradientAppBarPage 新增 overlay 入参(在 Scaffold body Stack 末位渲染,z-order 高于 AppBar,供 modal scrim+抽屉遮盖 AppBar 与状态栏)。
    • 改动文件definition_drawer.dart(重写) / word_title_row.dart(新) / phonetic_audio_row.dart(新) / web_snapshot_source_page.dart / gesture_review_card.dart / review_session_page.dart / definition_content.dart / word_header_row.dart / gradient_appbar_page.dart
    • 设备验证:死区慢拉无停滞无 overflow、快甩 snap、评分按钮微抖不被吞、chip 切页、折叠态隐藏按钮、web 高亮词点击展开 — 全部实测通过。计划文档 ~/.claude/plans/pure-crunching-raccoon.md
  • 🖼️ 封面字节直传重构(Schema v47, RB plan 镜像) - (2026-05-03)

    • 背景reading_notes.cover_image_path 跨端语义分裂——RB 端写 HTTPS URL(google favicon 占位 / 站内 apple-touch-icon),RVH 端写设备本地路径;sync 只搬字符串,渲染端永远只在创建端工作(RB 创建的 note 在 RVH 上看不到封面,反之亦然)。
    • 重设计(双端协调,RB 已闭环):图片字节直存 BLOB,跨端 sync 搬字节,不依赖 google s2 / 源站可达性 / 任何外部服务。
    • Schema v46→v47reading_notes 删除 cover_image_path,新增 cover_image_data BLOB + cover_image_mime TEXT + cover_source_url TEXT。开发期 drop & recreate(assets/sql/01_create_tables.sql + _schemaVersion 升 1),不做迁移。Supabase user_reading_notes 由 RB 会话已 ALTER 完成。
    • 跨端契约(RB 镜像)
      • 字段名严格 cover_image_data / cover_image_mime / cover_source_url,差一个字符 push/pull 全炸
      • PostgREST bytea 走 \x<hex> transit(与 RB Rust format!("\\x{}", hex::encode(b)) / hex::decode 严格对齐),不是 base64 / 0x 前缀
      • MIME 始终 image/jpeg,双端都压成 192×192 JPEG q85,100KB 硬上限
      • 三字段同进同出(data NULL → mime / source_url 也 NULL)
      • cover_source_url 当前 RVH 用户上传 / RB webview 多源抓取均写 NULL,字段保留为日后扩展
    • 生成路径
      • lib/core/services/cover_image_processor.dartIsolate.run 内 decode → cover-fit 192×192 → JPEG q85 → 100KB cap
      • 用户从相册选图(_pickLocalCover)/ Google Books 搜索(_downloadCoverInBackground)/ webArticle 抓 favicon(book_import_page._downloadFavicon)三条入口全部输出 BLOB 字节,不再写 covers/ 本地文件
      • CoverImageDownloader.downloadCoverBytes 返回 Uint8List(替代 String localPath
    • Sync 协议
      • _pushBookscover_image_data 走 hex 编码、cover_image_mime / cover_source_url 透传
      • _pullReadingNotes:去前缀 hex decode → Uint8List,ON CONFLICT DO UPDATE SET 子句包含三 cover 列(否则远端封面更新永远拉不下来,红线 #6b 强化)
      • 红线 #6b 父表禁 INSERT OR REPLACE 模式保留
    • 渲染层:6 个 reading_note 封面渲染点(review summary / book selection dialog / storage management / group bottom sheet / current book display / reading note detail)从 Image.file(File(coverImagePath!)) 统一改 Image.memory(coverImageData!) + placeholder
    • 删除:临时桥接 widget lib/shared/presentation/widgets/smart_cover_image.dart(URL/file 双判断 hack 不再需要);book_cover_section.dart 内的 dead BookCoverSection(无引用)
    • 类型迁移链ReadingNoteEntity / ReadingNoteModel / BookStorageInfo / NoteDisplayDto / ReviewSessionParams / RevisitPage / GoogleBookEntity.toReadingNoteEntity 全部从 String? coverImagePath 迁移到 Uint8List? coverImageData + String? coverImageMime
    • SQL 列查询getGroupSummaries(review overview) / getSourceStatsByBook(storage management)SELECT 改 b.cover_image_data, b.cover_image_mime
    • 联调验证:双端 push / pull 实测通过,封面字节跨端可见(RB Notes 面板看到 RVH 上传的图,RVH review overview 看到 RB webview 抓的真实站点 logo)
    • 关联:RB 实施计划 ~/reading-browser/docs/plans/cover-image-blob-refactor-plan.md + cover-image-webview-refactor-plan.md
  • 🎨 Photo recognition 流程 UX 重构 + 视觉精修 - (2026-05-01)

    • 三入口统一选 note 流程:拍照 / 图库 / 分享 三条采集入口对齐 — 每次发起都弹「选 note」对话框,默认选中当前 active session 的 note(HarvestBar 显示的那本),fallback 到 SharedPreferences 上次记录。_promptForBook 重构为返回 (noteId, bookTitle) record 不再修改全局 state;_resolveSession 智能冲突处理(同 note 直接 append、不同 note 弹「丢弃 N 张」对话框)。
    • Scan 页职责精简:删除顶部 Book 信息栏(800-100px)、_selectedNoteId / _selectedBook / _isLoadingBook / _pulseController 等 state 字段、_loadSelectedBook / _setSelectedBook / _onCreateBook 方法、session recovery 自动预选逻辑。Scan 页回归「功能入口」单一职责。
    • HarvestBar 双行布局:顶行显示 📕 当前 note 标题(Expanded + ellipsis 截断),底行 progress + Harvest 按钮 — 替代被删的 Book 信息栏作为视觉锚。
    • 图库 / Share intent OCR 后台化:删除 _waitAndNavigateToHarvest 调用,OCR 在后台跑 + HarvestBar 反馈 + 导航栏跨页角标。仅拍照 autoProcess 路径保留蒙层。Gallery 路径用 PageRouteBuilder shield route(transitionDuration: zero + 黑底匹配 CropPage)消除 picker → crop 之间的「白闪」+ 多张 crop 间的过渡断裂。
    • 导航栏 Scan 红点提醒NavigationItem.showBadge + mainNavigation(scanShowBadge:),复习 / 我的 / Scan 三页都订阅 captureQueueProvider,OCR 完成 + 有未收割单词时「扫描」文字右上角浮 5×5 红点(Colors.red.shade600)。文字保持默认样式,红点跨 selected/unselected 都显示。
    • Crop 页 UX 改进:图片显示从 90% 缩放改为占满可用空间(仅留 28px handle margin),默认裁剪框 = 全图边界;Stack.clipBehavior: Clip.none + 扩大 AdjustableRectOverlay 内 SizedBox 由 imageSizeimageSize + handleHitSize 修复「图片外手柄不响应」(默认 RenderBox.hitTest 拒绝 size 外触摸);AppBar toolbarHeight: 56→36、删 title、底部按钮 padding 减半节省 ~36px chrome 高度;确认按钮改深灰纯色 (Colors.grey.shade800);删除「Foreground Area」文字 + 底部「拖动角点」提示;shield 路由 + unawaited(navigator.push) + try/finally 安全弹出。
    • Note 选择对话框重构initialSelectedNoteId 真正生效(之前白传);列表项从 single-tap-confirm 改 radio 选择 + 底部「取消 / 确认」按钮;选中态淡 primary border (1.2px @ 0.5 opacity) + tonal 背景;删除每行右侧 chevron icon;header 「创建笔记」按钮下沉到 actions 区上方(全宽低调 TextButton),与「取消/确认」之间留 20px 防误触;header/footer 分隔线淡化到 0.35 opacity;dialog maxHeight: 0.8 → 0.65 整体压缩。
    • Scan 页布局调优:删除外层 Card 包裹,三入口和 HarvestBar 各自独立卡片;主入口(拍照/相册)用 tonal 背景(primary/tertiary @ 8% opacity),分享入口用 surfaceContainerHigh 中灰背景,HarvestBar 用 surfaceContainerLow 浅灰背景,全部无 border 消除「线条感」;分享文案 EN「Share screenshot from other apps」/ ZH「从其他应用分享截图」明确主场景;CaptureActionCard 增加 backgroundColor 可选参数。
    • 底部导航视觉调优:文字从 labelSmall (~11pt) 升级到 labelMedium (~12pt);红点尺寸 7×7 → 5×5、位置 right:-7 top:-2 → right:-5 top:-1;修复 Expanded 必须是 Row 直接子的布局问题(FloatingNavButton 内部 Expanded 被 Badge wrap 后失效,外移到 floating_navigation_bar.dart)。
    • 关联文档docs/plans/gallery-share-background-ocr-nav-badge.mddocs/plans/scan-page-strip-down-three-entrance-unification.md

Fixed

  • 🌐 Bug C 修复路径 2 — Edge Function fallback 启用 + 错误返回标准化 - (2026-04-29)

    • 背景:vocab-pk Phase 3 用例 2 实证 RB 端 backfill_word_inner 调 PostgREST 查 brook / glade / pasture(基础英文词不在公共 vocabulary 表)返回 HTTP 500(应为 200 + 空数组)→ entry skip 永久丢失。立项 Bug C,转 backlog。
    • 本次修复(代码层面)
      • Edge Function lookup-or-fetch-word/index.ts 错误返回标准化:Free Dictionary API 失败(404 / 5xx 不可达)/ 内部异常时返回 200 + null(而非原先的 404 / 500)。让客户端 graceful skip 该词而非因 5xx 中断整批 sync。需要 redeploy(supabase functions deploy lookup-or-fetch-word)才生效。
      • RVH 端 sync backfill 路径启用 Edge Function 兜底SupabaseVocabularyService.getVocabulariesBatchoverrideEdgeFunctionFallback 参数,UnifiedVocabularyService.getVocabularyAndPersist(sync _pullNotebookEntries 调用入口)传 true 强制启用。OCR 批量路径(OCR 拍照识词)保持禁用以避免性能回归。Edge Function 调用超时从 10s 放宽到 30s(含外部 dict API 调用)。
    • 不在本会话范围(已登记 backlog)
      • 路径 1:Supabase Dashboard 排查 PostgREST 5xx 根因(schema cache / replica miss / RLS 冲突)— 需要用户 Dashboard 访问
      • 路径 3:公共词表预扩(治标不治本,新词永远会缺)
      • RB 端backfill_word_inner 改成调 Edge Function 而非直查 PostgREST — 跨端协议红线 #9(RVH 不改 RB Rust 代码),需 RB 仓新会话执行
    • 验收(代码层面):lib/ analyze 0 errors;Edge Function 改动符合 200+null 契约;RVH sync 路径 Edge Function 启用通过 overrideEdgeFunctionFallback: true 注入
    • 未实证(待重启 app + Edge Function deploy 后跑)
      • brook / glade / pasture 走 Edge Function fallback 拉到 200 数据 ✓
      • xyznotaword 走 Edge Function fallback 返回 200 + null(不再 5xx)✓
      • RB → Supabase push 后 RVH pull 不再因 vocabulary 缺词 skip notebook_entry
    • 关联:cross-end archive log [docs/cross-end/archive/01-vocabulary-word-pk-log.md「Bug C」段];roadmap 方向 2 [docs/plans/cross-end-sync-evolution-roadmap.md]
  • 🔒 Schema v46 + Bug D 修复:跨端契约镜像 — RVH _pull* 4 路径漏写 user_id + schema NOT NULL - (2026-04-29)

    • 背景:vocab-pk Phase 3 用例 3 实证 RVH 本地 notebook_entries 出现 dup row(user_id NULL 的 RB push 行 + 用户自加孤儿行共存),UNIQUE(user_id, word) 在 NULL 上判定 NULL ≠ NULL 静默失效。立项 Bug D。
    • 范围扩展:跨端契约镜像审计(temp/cross-end-contract-audit-2026-04-29.md)发现 Bug D pull 路径影响面比 cross-end log 估计更宽——_pullNotebookEntries / _pullReadingNotes / _pullReadingSources / _pullWordSources 4 张表的 INSERT 都未写 user_id,仅 _pullExcludedWords 合规。save 路径同款:4 张表的 Model 都没有 userId 字段,toDatabase() 默认落 NULL。
    • Schema v45→v46:4 张同步表 user_id TEXTuser_id TEXT NOT NULL(notebook_entries / reading_notes / reading_sources / word_sources);excluded_words schema 不变(已有 idx_excluded_words_user_word UNIQUE INDEX 与 RB 对齐);开发期删库重建。
    • save 路径修复:4 个 Model 加 required String userId 字段 + toDatabase/fromDatabase + fromEntity;4 个 Repository 构造时绑定 userId(参考 ExcludedWordsRepositoryImpl 模板);4 个 Provider 注入 currentUserIdProvider
      • NotebookEntryModel / NotebookRepositoryImpl / notebookRepositoryProvider
      • ReadingNoteModel / BookRepositoryImpl / readingNoteRepositoryProvider
      • ReadingSourceModel / ReadingSourceRepositoryImpl / readingSourceRepositoryProvider
      • WordSourceModel(数据源 createWordSourceRelationuserId 参数;通过 ReadingSourceRepositoryImpl 注入)
    • pull 路径修复sync_repository_impl.dart 4 个 _pull* INSERT 列表加 user_id 列 + _userId 值(红线 #5b RVH 镜像)。
    • CLAUDE.md 红线:新增 §跨端 Sync 协议红线 #5b 镜像(save + pull 必写 user_id);登记 backlog(红线 #2 LOWER → COLLATE NOCASE / #6 hard DELETE in clearLearningDataForUser / #7 notebook revive / #9 normalize 必经守门)。
    • 审计文档temp/cross-end-contract-audit-2026-04-29.md(9 红线 + 5 表 schema + push/pull 路径对照)
    • 关联:RB 端等价红线 [~/reading-browser/CLAUDE.md §4 #5b];cross-end archive log [docs/cross-end/archive/01-vocabulary-word-pk-log.md「Bug D」段];roadmap 方向 2 [docs/plans/cross-end-sync-evolution-roadmap.md]
    • 验收:lib/ analyze 0 errors(test/ 端有 14 个 userId 缺失 + 7 个 v18/v43 pre-existing 错误,留作后续测试 cleanup 任务);Phase 3 用例 3 等价 runtime 验证(RB DevTools save_word + RVH UI add → 双端各 1 行 user_id 真值)待重启 app 后跑
    • 不在本会话范围(已登记 backlog)
      • 红线 #2:LOWER()COLLATE NOCASE 全局替换
      • 红线 #6:clearLearningDataForUser hard DELETE 在多账号本地共存场景重审
      • 红线 #7:notebook 端 add 路径 soft-deleted 行 revive
      • 红线 #9:excluded_words 写入未走 lemmatizer normalize
      • test/ 端 v18/v43 pre-existing fallout(vocabularyId / chineseMeaning / exampleSentence 等已删字段引用)

Changed

  • 🔬 Bug B 修复:lemmatizer 词典优先架构 - (2026-04-28)
    • 背景:原 4 层启发式架构(例外表 → 不规则表 → 后缀剥离 → fallback)的 Layer 0 EXCEPTIONS 手工列 ~300 词救回是封闭世界假设——例外永远列不完。vocab-pk Phase 3 实测 RB 端误砍 27+ 个常见英文名词(sliver→sliv / splinter→splint / lawyer→lawy / marketing→market 等)。
    • 架构:转「词典优先」4 层(Layer 1 surface_to_base 屈折查表 → Layer 2 base_forms 自映射 → Layer 3 SUFFIX_RULES 提议 + BASE 裁决 → Layer 4 fallback)。准确率从 ~75-80% 提升到 ~95-97%。
    • 数据源:AGID 2016.01.19(Kevin Atkinson,公共领域)+ WordNet 5,637 不规则形人工策展。资产 assets/nlp/base_forms.json (101,646 条) + assets/nlp/surface_to_base.json (140,371 条),与 RB 端 SHA256 byte-equal。
    • 新增
      • 资产 assets/nlp/base_forms.json + assets/nlp/surface_to_base.json(双端 byte-equal)
      • lib/core/nlp/lemmatizer_flutter_loader.dart(rootBundle 加载)
      • bin/phase0_normalize.dart(双端 byte-equal CSV 跑分入口)
      • test/core/nlp/lemmatizer_dictionary_test.dart(6 个 Bug B 单测)
      • test/core/nlp/lemmatizer_test_setup.dart(公共 setUp)
      • test/data/lemma-regression.txt(90 词跨端回归清单)
    • 删除
      • lib/core/nlp/data/irregular_forms.dart(5,651 行 dead code,已被 surface_to_base.json 完全覆盖)
      • tools/phase0_normalize/(迁移到 bin/phase0_normalize.dart
      • _exceptions 手工词集(~300 词)
    • CLAUDE.md 红线:新增 §跨端 Sync 协议红线 #5e(lemmatizer 资产双端 SHA256 必须 byte-equal)
    • 关联:RB 端 commit 9a3e302;RVH 端见本批 commit
    • 计划文档docs/plans/lemmatizer-dictionary-refactor-rvh.md
    • 验收:双端 phase0_normalize 跑 90 词回归清单 → CSV diff 为空 ✅;Bug B 27 词全部 layer="base" ✅;52 个 lemmatizer 单测全过 ✅
    • 不在范围(独立任务):数据修复 SQL(清理已入库错 lemma)、T-A 人名地名治理(James→jam)、T-B 同形异类多值消歧(lives, saw, means, trying, during)、T-C 新词扩充(podcast/vlog)

Removed

  • 🗄️ Schema v44: 删除 word_contexts 表(v35 双端整合时新建,从未投入使用) - (2026-04-27)
    • 删除本地表 word_contexts + 2 个索引(idx_word_contexts_entry / idx_word_contexts_source
    • 删除 entity/modelWordContextEntityWordContextModel
    • 删除 datasource 方法(6 个):createWordContext / getContextsForEntry / getContextsForSource / deleteWordContext / deleteAllContextsForEntry / getContextSentencesForEntries
    • 删除 repository 接口/实现getContextSentencesForEntries
    • 删除 sync 模块相关_pushWordContexts / _pullWordContexts,syncNow / getSyncStatus / force-full-pull 中的 word_contexts 计数项;Supabase 端 user_word_contexts 已不存在
    • 删除 review session 中的语境句子加载ReviewWordItem.contextSentencesReviewSessionDataLoader._enrichWithSourceInfo 中的批量加载块
    • 删除 UI 展示DefinitionDrawer.contextSentences 字段 + _buildContextSentences 方法
    • auth 清表AuthRepositoryImpl.clearLearningDataForUsertxn.delete('word_contexts')
    • 📊 表数量:11 → 10 张
    • 🔧 schema 版本:v43 → v44(触发本地数据库重建)

Fixed

  • 🔄 Sync: watermark 推进减 5s buffer,防 PostgREST replica lag 永久丢失 - (2026-04-28)

    • 🐛 问题:sync 完成后将 last_sync_at 直接推进到 sync_start_at,但 PostgREST read replica 在 fetch 时可能尚未 propagate 推送方刚 commit 的行 → commit time ≤ sync_start_at 但因 replica lag 没被本轮 fetch 看到的那批行,会被下次 gt.{watermark} 永久 miss。RB 端实测 24 条 RVH→RB push 永久丢失。
    • 修复SyncRepositoryImpl.syncNow 中 watermark 推进减 5s buffer(syncStartAt - 5s)。各 pullXxx 已用 INSERT OR IGNORE / INSERT OR REPLACE,5s 重叠拉取无副作用(每轮多扫近 5s 数据,量级 < 100 行)
    • 可观测性Sync complete 日志增加 syncStartAt + watermarkAdvancedTo 字段,便于后续诊断
    • 🧪 实测验证:注入式跑通 — syncStartAt=2026-04-27T22:39:40.819Z 对应 watermarkAdvancedTo=2026-04-27T22:39:35.819Z,落点精确 -5.000s;INSERT OR IGNORE/REPLACE 处理 72 行重叠拉取无错
    • 🔗 跨端关联:对齐 RB 端 commit 3d9e356(RB 项目 ~/reading-browser/temp/bug-a-handoff-to-rvh.md
    • 📌 红线 #5d:见 CLAUDE.md "🔄 跨端 Sync 协议红线" 章节
  • 🔄 Sync: word_sources 增量 pull 拉不到(导致复习页"有 due 数但无笔记卡片") - (2026-04-27)

    • 🐛 问题user_word_sources.updated_at 在双端 push 时都不写(关联表语义无 update),始终为 NULL;PostgREST 增量 pull gt('updated_at', since)NULL > anything = NULL 永远过滤掉,导致 RVH 拉不到 RB 已推的 word_sources,进而引发"复习页 Today X to review 数对,但按笔记列表为空 / 笔记详情单词数=0"
    • SupabaseSyncDatasource.pullRows 增加 cursorCol 参数(默认 updated_at),_pullWordSources 调用时传 created_at(关联表"创建即不变",用 created_at 当游标在语义上更准确)
    • 一次性 backfill flag (word_sources_backfill_v1):首次跑新代码时强制 cursor=null 全量拉一次,把游标变更前已推但被跳过的行补回来

Changed

  • 启动性能优化:移除 Lottie splash 动画 - (2026-04-25)

    • ❌ 移除 AnimatedSplashPage(含 2500ms 强制最小动画时长 + 500ms 淡入过渡)
    • ✅ 新增私有 _StartupGate(main.dart):保留并行 heavy init(Supabase + DB + providers),无强制等待
    • FlutterNativeSplash.remove() 时机调整:从「第一帧后立即移除」改为「目标页渲染后移除」,原生 splash 覆盖 init 全过程,避免中间 Flutter splash 露出
    • ❌ 移除依赖:lottie ^3.1.2
    • ❌ 删除资源:assets/animations/book_reading.jsonlib/features/welcome/
    • 📊 实测节省 ~2.1s:旧流程 ~3000ms 强制等待,新流程 ~850ms 实际 init 即跳转
  • 🗄️ Schema v42: excluded_words 拆 per-user(对齐 RB 阶段 F) - (2026-04-22)

    • UNIQUE 索引UNIQUE(word)UNIQUE(user_id, word COLLATE NOCASE)
    • 新增表recommended_excluded_words(3 列:id / word / sort_order)作为 Supabase 公共推荐停用词的本地缓存(第 12 张表)
    • 删除 seed03_init_data.sql 的 213 条 source='system' INSERT;停用词改由 Supabase 公共表下发,登录后 fetch + merge
    • enum 重命名ExcludedWordSource.system.recommended
    • 数据层:新增 SupabaseRecommendedExcludedWordsDataSource + RecommendedExcludedWordsRefreshService,merge 用确定性 id rec:{rec_id}:{user_id}(跨端幂等 + synced_at=now 避免 211 行回推)
    • 登录态 hookrecommendedExcludedWordsRefreshProvider 挂在 AuthSection / ReviewOverviewPage(复用 autoSync 机制)
    • UI 对齐 RB:推荐词也允许用户软删,撤销 isSystemWord 防删守卫
    • 同步链路加固
      • AuthNotifier.signOut 改为 syncNow → auth.signOut → clearLearningDataForUser(flush 后再清)
      • AuthRepository._clearLearningData 事务内加入 excluded_words 的 per-user 清理分支
      • _pullExcludedWords tombstone 修复:本地无行时也 INSERT OR IGNORE 落地,避免与 mergeRecommended 竞速漏删

Added

  • 🗄️ Schema v35: 双端整合 0-E — reading_sources + word_contexts 扩展 - (2026-03-27)
    • reading_sources 新增 source_ref TEXT 字段(URL/文件路径引用,source_type='web' 时为 URL)
    • reading_sources.source_type 扩展支持 'web'(网页来源,RB 浏览器扩展同步)
    • 新建 word_contexts 表(第 11 张表):存储生词上下文句子,2 索引,CASCADE 外键
    • Entity/Model/Datasource 全链路:ReadingSourceEntity + WordContextEntity + ReadingSourceDatasource CRUD
    • 复习语境展示:ReviewSessionDataLoader → ReviewWordItem.contextSentences → DefinitionDrawer
    • UI 适配 source_type='web':source_info_bar / gesture_review_card / reading_note_card
    • 架构修复:ReviewSessionDataLoader 改用 Repository 接口访问 word_contexts(遵循 Clean Architecture)

Changed

  • 🗄️ Schema v34: vocabulary_items 表瘦身 - (2026-03-21)
    • 删除 9 个冗余/空壳字段definition, chinese_meaning, part_of_speech, example_sentence, difficulty_score, synonyms, antonyms, collocations, translations
    • 重命名cefr_levelprimary_cefr_level(明确表示"所有词性中最低的 CEFR")
    • pos_definitions 升级:每个 POS 块新增 translations 字段(从顶层下沉到 POS 级别)
    • VocabularyEntity 计算属性definition/chineseMeaning/exampleSentence/difficultyScore 从 pos_definitions 派生,向后兼容
    • 保留 word_family:单词级别数据,后续从 Kaikki 提取填充
    • vocabulary_items 列数:24 → 15
    • Pipeline 更新:vocabulary_builder_v3 工具链同步更新
    • Supabase 迁移:Edge Function + 迁移 SQL 同步更新
    • 文档同步:schema.md、CLAUDE.md、supabase-setup-guide.md 已更新

Added

  • 📚 参考词库(Reference Words) - (2026-03-10)
    • 新增 reference_words(Schema v29→v30):
      • 存储"识别但不学习"的单词(专有名词、缩写等)
      • 系统预装 ~160 个常见词(国家、城市、组织、媒体、缩写)
      • 支持用户自定义添加(source='user')
    • OCR 管道集成
      • 参考词在 excluded words 之后、vocabulary lookup 之前拦截
      • 防止模糊匹配误修正(如 iran→cran
      • 节省 Supabase/API 调用
    • UI 集成(Harvest Station)
      • REF 分组显示在 UNKNOWN 之后
      • 参考词显示中文释义和 bookmark 图标
      • 参考词不可选中(不进入 SM-2 学习系统)
      • Blue Grey 色调(#607D8B)
    • CefrLevel 枚举扩展
      • 新增 REFERENCE 枚举值
      • 所有 switch 语句已更新
    • i18n 支持:新增 cefrReferenceName 双语 key
    • 测试覆盖:Entity 测试 + ConfirmationWord isReference 测试

Changed

  • 🗄️ 数据库 Schema v27 - (2026-02-21)
    • 删除 learning_records
      • 该表几乎未使用(仅 last_studied_at 一处引用)
      • 学习记录功能已合并到 notebook_entries.last_review_date
    • 新增 getMasteredWordSet() 方法
      • 用于 OCR 识别时过滤已掌握单词
      • 单词掌握判断集中到 MasteryLevelEntityExtension
    • 新增 MasteryLevelEntityExtension
      • isMastered - level4/level5 判断
      • isFullyMastered - level5 判断
      • masteredLevelStrings - 掌握级别字符串列表
    • 修复 reviewWordWithQuality 接口缺失
      • 方法已在实现类存在,补充接口声明

Added

  • 🏠 Welcome Page(欢迎页) - (2026-02-06)

    • 新增欢迎页作为 App 启动首页
      • 不显示底部导航栏
      • 底部浮动关闭按钮跳转到 ReviewOverviewPage
    • Learning Flow 漏斗动画learning_funnel_animation.dart):
      • 展示词汇学习流程:Photos → Accumulated → [Learning, Mastered] ↔ Due
      • 椭圆形阶段卡片 + 粒子流动画
      • 双向粒子流(Learning ↔ Due)
    • CEFR 柱状图
      • 复用 statistics 模块组件
      • 简化选中样式(加粗加大,无背景色)
    • 快捷入口按钮:Scan + Review
    • 新增 Learning Home 统计实体learning_home_entity.dart):
      • 周拍照数、周累积词数、学习中、已掌握、今日待复习、连续天数
    • App 启动流程变更:SplashPage → WelcomePage(原为 ReviewOverviewPage)
  • 📚 电子书导入功能 - (2026-02-03)

    • 新增电子书导入页面ebook_import_page.dart):
      • 支持 TXT 和 EPUB 格式
      • 文件选择 → 解析 → 创建书籍 → 词汇提取 → 筛选
    • 新增电子书解析服务ebook_parser_service.dart):
      • TXT:直接读取文本
      • EPUB:解析 XML 结构,提取章节文本
      • 修复 EPUB 编码问题(UTF-8 + Latin-1 fallback)
    • 新增文本上下文提取服务text_context_extraction_service.dart):
      • 从电子书全文中提取单词上下文(±200字符)
      • 支持 Review Session 中显示文本来源
    • 书名时间戳:导入时自动添加时间后缀避免重复(如 "Book (02-03 10:47)")
    • 进度指示器优化:渐进式进度 + 动态状态文本
  • 🎨 Review Session UI 优化 - (2026-02-03)

    • 信息栏整合:将位置指示器、书名、页码整合为固定顶栏
      • 左侧:📍 位置数量
      • 中间:📖 书名(超长省略)
      • 右侧:页码(1/3)
      • 不随来源滑动,保持固定
    • 阴影减弱:来源图片/文本的阴影强度降低(0.7→0.35),微微立体感
    • 字体调整:definition+example 恢复为 bodyMedium
    • 样式统一
      • definition 区域上下内边距增加
      • 背景色统一为 Color(0xFFFAF8F5)
      • 信息栏与 definition 区域样式一致(圆角12px + 边框)

Changed

  • 🔧 UseCase 重构 - recognize_and_filter_usecase.dart (2026-02-03)
    • 提取共享方法 _filterAndBuildResult() 供 OCR 和文本流程复用
    • 实现 _processTextInput() 支持电子书文本处理
    • 统一 CEFR 筛选逻辑(从用户设置读取,不再硬编码)

Fixed

  • 🐛 EPUB 编码修复:中文智能引号显示为乱码 → 使用 utf8.decode + Latin-1 fallback
  • 🐛 sourceType 传递:filter_confirmation_notifier 正确传递来源类型(image/text)

Docs

  • 📝 Schema 版本号同步:v22 → v25(schema.md, README.md, optimization.md, product.md, CLAUDE.md)
  • 🔧 doc-consistency-check v2.0 升级 - (2026-02-04)
    • ✅ 动态计算代替硬编码(Schema 版本、表数量从代码提取)
    • ✅ 遍历所有 Markdown 链接检测断链(排除代码块)
    • ✅ ER 图列级交叉验证(SQL DDL vs schema.md)
    • ✅ 孤立文档检测(docs/ 中未被引用的文档)
    • ✅ 9 项完整检查覆盖
  • 🔗 doc-sync-check v3.1 重构 - (2026-02-04)
    • ✅ Step 0 重构:复用 doc-consistency-check 完整 9 项检查
    • ✅ 移除重复代码,遵循 DRY 原则
    • ✅ 两个 Skill 协同:一致性检查 + 同步检查各司其职
  • 🐛 文档一致性修复 - (2026-02-04)
    • ✅ docs/design.md:表数量 9→10
    • ✅ docs/decisions.md:Schema v13→v25,表数量 8→10

  • 🗄️ Excluded Words(排除词库)功能 - Schema v22 (2026-01-30)

    • 新增 excluded_words 表(第10张表):
      • 双来源设计:system(213个系统停用词)+ user(用户自定义排除词)
      • 字段:id, word, source, vocabulary_id, reason, added_at, created_at, updated_at
    • OCR 过滤集成
      • 替换硬编码停用词检查为数据库查询
      • recognize_and_filter_usecase.dart 使用 ExcludedWordsRepository
      • 支持动态过滤规则(系统 + 用户排除词)
    • SQL 脚本化停用词初始化
      • 213个系统停用词移至 assets/sql/03_init_data.sql
      • 分类清晰:冠词(3) + 代词(31) + Be动词(9) + 助动词(18) + 介词(20) + 连词(9) + 疑问词(8) + 功能词(9) + 基础词(106)
      • 移除代码中的 _initializeSystemStopwords() 方法,统一由 SQL 管理
    • Domain/Data/Presentation 完整实现
      • Repository: ExcludedWordsRepository(本地数据源)
      • Use Cases: Add/Remove/Get/Check(4个用例)
      • Providers: Riverpod 依赖注入
    • UI 集成完成(4个入口):
      • Profile 菜单:添加"排除词库管理"入口(与生词本管理并列)
      • 排除词列表页
        • 选择模式 + 批量删除功能
        • 统计信息卡片(系统词 213 + 用户词)
        • 系统停用词展示(可折叠)
      • 生词本单词菜单:添加"排除此词"选项(类似"标记为已掌握")
        • 确认对话框说明操作影响(OCR不再识别 + 从生词本移除)
        • 成功后自动从生词本删除
      • Look up word 页面:查询结果顶部显示"排除此词"按钮
        • OutlinedButton 样式(error 色边框)
        • 确认对话框 + SnackBar 反馈
    • 📊 影响范围
      • 7个文件修改(OCR用例、Profile页、排除词列表、生词本菜单、查词页、TranslationContent组件)
      • 1个文件新增(excluded_words_list_state.dart - 遵循项目规范)
      • 遵循 Material 3 设计规范(error色表示排除、12px圆角按钮、28px圆角对话框)
    • 🎯 功能完整度:100%(数据库 + 业务逻辑 + UI 集成)
  • 📁 标准目录结构 - 创建项目标准目录组织规范 (2026-01-28)

    • 新增标准目录
      • logs/ - 日志文件目录
      • backups/ - 备份文件目录(按日期组织)
      • temp/ - 临时文件目录
    • 目录说明文档
      • 每个目录包含 README.md(用途、命名规范、清理策略)
      • 添加 .gitkeep 确保空目录被追踪
    • CLAUDE.md 新增"文件组织规范"章节
      • Claude 必须遵循的规则(日志/备份/临时文件自动归档)
      • 详细的命名规范和工作流程
      • 禁止的做法和错误示例
      • Claude 行为检查清单
    • 更新 .gitignore
      • 配置 logs/, backups/, temp/* 忽略规则
      • 保留目录结构,忽略目录内容
  • 📚 7层文档体系架构 - 重构文档系统为7层架构 (2026-01-28)

    • 新增导航文档
      • docs/README.md(7层架构总导航,按受众/任务/层级查找)
      • docs/guides/README.md(Claude Code指南导航)
    • 文件重组
      • 新建 docs/guides/ 目录
      • 移动 Claude 指南到 guides/ (claude-code-tips.md, claude-skill-guide.md)
      • 重命名数据库文档为小写(schema.md, optimization.md)
      • 更新所有文档引用(15+ 文件)
    • Git Hooks自动化
      • scripts/doc-consistency-check.sh(文档一致性检查)
      • scripts/install-hooks.sh(Hooks安装脚本)
      • .git/hooks/pre-commit(提交前自动验证)
  • 🛡️ 文档架构维护保障机制 - 4层保障确保文档体系可持续性 (2026-01-28)

    • 预防机制(事前):
      • CLAUDE.md 新增"文档架构维护检查清单"章节(Claude自动感知)
      • 新增文档决策流程(5步决策树)
      • 每层准入标准(Layer 1-7详细说明)
      • 文档数量警戒阈值监控
    • 检测机制(事中):
      • Claude 自动提醒(3种触发条件:新建、批量修改、数量超标)
      • 层级归属智能判断(根据文件名/内容关键词)
    • 验证机制(事后):
      • 增强 pre-commit hook(新文档命名规范+导航更新检查)
      • 层级归属智能提示
      • 自动拦截不规范提交
    • 审查机制(定期):
      • 季度文档架构审查清单(Q1/Q2/Q3/Q4)
      • 文档数量统计和质量审查
    • 完整维护指南
      • docs/guides/doc-maintenance-guide.md(43KB,10章节详细指南)
      • 包含:决策树、典型案例、FAQ、最佳实践、命令参考
    • 测试验证机制
      • scripts/test-doc-maintenance.sh(自动化测试脚本)
      • 4个测试场景:新文档流程、命名规范、导航更新、完整流程

Changed

  • 📁 文档目录重组 - 创建 docs/deployment/ 子目录,优化专项技术文档结构 (2026-01-28)
    • 新增 docs/deployment/ 目录
      • 统一管理部署和配置相关文档
      • 符合 Layer 5 专项技术目录组织规范(参考 database/、ui-guidelines/)
    • 文件移动(使用 git mv 保留历史):
      • docs/supabase-setup-guide.md → docs/deployment/supabase-setup-guide.md
      • docs/edge-function-deployment-guide.md → docs/deployment/edge-function-deployment-guide.md
    • 新增导航文档:docs/deployment/README.md
      • 部署配置快速开始指南
      • 相关文档链接和常见问题
    • 更新所有引用(20+ 处):
      • CLAUDE.md(3处)- 核心文档表 + Layer 5描述 + 配置变更检查清单
      • docs/README.md(5处)- 快速查找 + 目录结构 + 配置和部署表
      • README.md(4处)- 环境变量说明 + 配置说明章节
      • scripts/README.md、supabase/functions/lookup-or-fetch-word/README.md
      • docs/guides/doc-maintenance-guide.md(5处)- 层级示例 + 文档清单 + 统计命令
      • .claude/skills/doc-sync-check/SKILL.md(2处)- 配置变更映射
      • .claude/skills/doc-consistency-check/SKILL.md(2处)- 文档数量统计
    • 🎯 核心价值
      • 语义清晰(deployment = 部署相关)
      • 可扩展性强(未来可添加应用商店发布、CI/CD 等指南)
      • 与 database/、guides/、ui-guidelines/ 结构一致
  • 📊 文档体系升级:CLAUDE.md从旧分层改为7层架构
    • Layer 1: 项目入口(3个)
    • Layer 2: 产品和业务(4个)
    • Layer 3: 开发规范(10个)
    • Layer 4: 架构设计(2个)
    • Layer 5: 专项技术(6个)
    • Layer 6: 辅助工具(7+个)
    • Layer 7: 自动化维护(6+个)
  • 📝 CLAUDE.md 结构优化 - 改善文档可读性和完整性 (2026-01-28)
    • 新增目录(TOC)
      • 46行详细目录,6大分类(项目概览、开发规范、文档维护、Skill、数据库、速查)
      • 内部锚点链接,快速定位章节
    • 新增"🔒 安全操作规则"章节
      • Git 危险操作规则(restore/reset/clean/force push 需用户确认)
      • 文件删除、数据库操作、环境配置的安全流程
      • 豁免规则和异常处理机制
    • 调整"⚠️ 缺陷修复流程"位置
      • 从 USER_NOTES.md 章节下(嵌套错误)移动到顶层
      • 放置在"安全操作规则"之后,逻辑更清晰
    • 文件大小:1303行 → 1484行(+14%,增加181行)
  • 🧹 项目清理 - 删除临时/日志/备份文件,释放约 148 MB 空间 (2026-01-28)
    • 阶段 1(必清理):删除 29 个文件(约 1.4 MB)
      • 清理日志文件(3个):vocab_build.log, update_*.log
      • 清理备份文件(5个):*.backup, backups/2026-01-23/
      • 清理 macOS 系统文件(3个):.DS_Store
      • 清理 CMake 日志(17个):CMakeOutput.log
      • 清理工具脚本状态文件(3个):update_*.json
      • 清理单元测试备份(1个):*.bak
    • 阶段 2(词汇输出):删除 tools/vocabulary_builder/output/(8.6 MB)
      • 198 个批次 JSON 文件(downloaded_data/)
      • 临时构建数据库(vocabulary.db)
      • 构建进度和错误日志(progress.json, errors.json)
    • 阶段 3(编译缓存):删除 CMake 编译缓存(137 MB)
      • local_packages/opencv_dart/android/.cxx/
      • build/.cxx/
    • 阶段 4(根目录清理):删除根目录下的旧文件(2个)
      • english_learning.db(旧数据库,0字节,已改用 reading_vocab.db)
      • check_docs_sync.sh(旧文档检查脚本,已被 scripts/doc-consistency-check.sh 替代)
    • ⚠️ 影响:下次 flutter run 需要额外 5-10 分钟重新编译 CMake
  • 🏗️ 文档架构优化 - 解决循环依赖+三层入口体系 (2026-01-28)
    • 解决循环依赖(建议1):
      • 问题:CLAUDE.md ↔ docs/README.md 双向引用(10处 ↔ 7处)
      • 方案:移除 docs/README.md 中 CLAUDE.md 的导航条目(Layer 3 → 不归层级)
      • 替换:任务工作流中的 CLAUDE.md 引用改为 Skill 引用
      • 效果:docs/README.md → CLAUDE.md 从 10处 减少到 5处
    • CLAUDE.md 大小评估(建议2):
      • 当前:52 KB(1484 行)
      • 决策:保持现有大小(行为规则、工作流程、检查清单为必需内容)
      • 理由:循环依赖解决已减少部分冗余,强制压缩会损失必要信息
    • 建立三层入口体系(建议3):
      • Layer 0(统一入口):README.md 新增"📚 快速导航"章节
      • Layer 1(角色分流):🆕 新用户 → QUICKSTART.md、👨‍💻 开发者 → docs/README.md、🤖 Claude Code → CLAUDE.md
      • Layer 2-7:按层级组织的 37 个专项文档
    • 创建架构可视化文档(建议4):
      • 新增:docs/architecture.md(依赖关系图、分级原则、维护机制)
      • 内容:依赖关系图、入度/出度统计、DAG 原则、架构演进历史
      • 价值:防止未来引入新的循环依赖,帮助新人理解文档体系
    • 文档数量更新
      • Layer 3:10 → 9(CLAUDE.md 移出层级,作为 Layer 1 角色入口)
      • 文档总数:38 → 37(重新统计)
    • 📊 影响文件
      • docs/README.md(移除 CLAUDE.md 条目,更新工作流引用)
      • CLAUDE.md(更新 Layer 3 数量)
      • README.md(新增三层入口导航)
      • docs/architecture.md(新建)

Fixed

  • 📚 文档一致性全面修复 - 修复40+处文档不一致问题 (2026-01-28)
    • Schema版本号统一:修正15处错误(v6/v10/v13/v14 → v16)
      • README.md, QUICKSTART.md (3), docs/design.md (3), docs/product.md
      • docs/database/README.md (2), docs/database/schema.md, docs/database/optimization.md
      • assets/sql/02_create_indexes.sql, assets/sql/03_init_data.sql
    • 断链修复:修复10处(testing.md、performance.md、DEVLOG.md等已删除文档的引用)
      • QUICKSTART.md (5处), README.md (1处)
      • docs/ui-guidelines/button-style-guide.md (2处), dialog-style-guide.md (2处)
    • CLAUDE.md全面修正
      • 体系名称统一:"分层文档体系"(替代"实用/极简"措辞)
      • 文档总数:40 → 38(实际统计)
      • Layer 1标题优化:"Layer 1:核心文档"
      • Layer 4 UI规范:保持8个(1+1+6,不是9个)
      • Layer 7 Skill数量:3 → 6(新增ui-compliance、clean-arch、doc-consistency-check)
      • 功能模块数:7 → 14(8核心+2基础设施+4工具),并添加分类说明
    • 数据描述统一
      • 表数量:修正4处(docs/database/README.md ×2, docs/design.md, docs/decisions.md)→ 8张
      • 词汇库大小:修正7处(QUICKSTART.md ×2, docs/product.md, docs/decisions.md ×3, .env.example)→ 8,614个

Added

  • 🆕 doc-consistency-check Skill v1.0 - 文档内部一致性检查 (2026-01-28)

    • 🔍 6类检查:Schema版本号、断链、数据一致性、文档引用完整性、过时引用、文档数量统计
    • 📊 简洁/详细双模式:默认简洁报告,可选详细修复建议
    • 🔗 与doc-sync-check区分
      • doc-sync-check:代码变更 → 需要更新哪些文档(外部触发)
      • doc-consistency-check:文档内部 → 发现不一致问题(内部检查)
    • 🤖 触发条件:"检查文档一致性"、"文档质量检查"、"文档自查"
    • 预期效果:Schema版本100%一致、0个断链、数据描述100%准确
  • ⬆️ doc-sync-check升级到v3.0 - 增加文档一致性预检 (2026-01-28)

    • 🔍 Step 0:文档一致性预检(新增)
      • 检测代码变更前,先快速检查文档本身的Schema版本号和断链
      • 发现问题时提示先运行 /doc-consistency-check 修复
      • 避免在不一致的文档基础上继续修改(错上加错)
    • Schema版本验证增强:数据库变更时自动验证所有文档中的版本号
    • 🔗 Skill协同:与doc-consistency-check协同工作
    • 📋 升级流程:预检 → 代码变更检测 → 文档更新清单
    • 🛡️ 质量保障:双重检查机制(预检 + 变更检测)

Added

  • 🤖 预装词库维护自动化 Skill - 保障数据质量的自动化工具 (2026-01-28)

    • 🆕 新增 /preinstalled-db-update Skill,自动化 6 步维护流程
    • 📋 完整流程:备份→更新→验证→测试→提交
    • 🔴 自动触发条件
      • SQL 脚本变更(assets/sql/*.sql
      • Schema 版本变更(app_database.dart_schemaVersion
      • 初始化数据变更(03_init_data.sql 的 INSERT 语句)
      • 预装库文件变更(assets/databases/reading_vocab.db
    • 🔒 严格验证机制
      • CEFR 级别必须是 A1-C2(禁止 PENDING/UNKNOWN)
      • source 字段必须全部为 'preinstalled'(禁止 backfilled)
      • 必需字段无 NULL 值
      • 验证失败自动中止并提示回滚
    • 🛡️ 安全机制:禁止从用户设备导出覆盖 assets(单向数据流)
    • 📊 完整日志:每步详细输出,方便追踪和调试
    • 🎯 用户价值:防止数据质量问题,自动化复杂流程,降低人为错误
    • 📄 参考文档
      • 技术参考:tools/vocabulary_builder/docs/vocabulary_maintenance.md
      • 工具使用:tools/vocabulary_builder/UPDATE_GUIDE_V2.md
      • Skill 定义:.claude/skills/preinstalled-db-update/SKILL.md
  • 📚 文档体系全面优化 + 2个新 Skill - 从3个Skill扩展到5个,覆盖开发全流程 (2026-01-28)

    • 🗂️ 7层文档体系(从简单2类扩展到完整7层,覆盖40个文档):
      • 1️⃣ 核心文档(6个) - 产品与开发规范
      • 2️⃣ 工具文档(2个) - Claude Code 相关
      • 3️⃣ 数据库文档(3个) - 数据库设计权威
      • 4️⃣ UI 规范文档(8个) - Material 3 设计系统
      • 5️⃣ 项目管理文档(5个) - 日常协作
      • 6️⃣ 技术工具文档(8个) - 自动生成/工具专用
      • 7️⃣ Skill 定义文档(5个) - 自动化工作流
    • 🆕 新增 Skill - ui-compliance-check (P0 高价值):
      • 🎨 自动检查 UI 代码是否符合 Material 3 设计规范
      • 🔴 P0检测:硬编码颜色(Colors.bluecolorScheme.primary
      • 🟠 P1检测:圆角值(按钮12px、对话框28px、卡片12px)
      • 🟡 P2检测:过时组件(RaisedButtonFilledButton
      • 🟢 P3检测:语义化文本样式
      • ✅ 自动化 UI 规范检查,减少人工审查,确保 UI 一致性
    • 🆕 新增 Skill - clean-arch-check (P1 高价值):
      • 🏗️ 自动检查代码是否符合 Clean Architecture 三层架构
      • 🔴 R1检测:Presentation 直接访问 Data 层(违反 DIP)
      • 🟠 R2检测:Domain 层依赖 Flutter 框架(降低可测试性)
      • 🟡 R3检测:Data 层包含业务逻辑(职责不清)
      • 🟢 R4检测:Repository 未注册到 Riverpod
      • 🔵 R5检测:目录结构不规范
      • ✅ 防止架构腐化,新人友好,自动化架构教学
    • 🔄 升级 doc-sync-check Skill 到 v2.0
      • 🔍 扩展检测范围:工作区 + 暂存区 + 已提交变更
      • 🎯 增强关键词:DML 操作(INSERT/UPDATE/DELETE)、UI 规范、配置服务
      • 🎨 新增 UI 规范变更检测(按钮、对话框、颜色、样式)
      • 📊 简洁输出:默认精简模式,可选详细报告
      • 🗂️ 7层文档映射:基于最新文档体系(40个文档)
    • 🧹 文档清理:删除4个临时/重复文档
      • ❌ DEVLOG.md(与 CHANGELOG.md 职责重叠)
      • ❌ 三层架构实施测试报告.md(临时测试报告)
      • ❌ docs/srs-button-design-analysis.md(临时设计分析)
      • ❌ docs/architecture/ocr_processing_history_design.md(未实施的设计方案)
    • 📊 Skill 覆盖维度(从3个扩展到5个):
      • 🎨 UI 层面 - ui-compliance-check(Material 3 规范)
      • 🏗️ 架构层面 - clean-arch-check(Clean Architecture 规范)
      • 📋 代码层面 - code-review(代码质量和规范)
      • 📚 文档层面 - doc-sync-check v2.0(文档同步)
      • 🗄️ 数据库层面 - preinstalled-db-update(预装库维护)
    • 🎯 用户价值
      • ✅ 文档体系清晰完整,从2类扩展到7层,全部40个文档纳入管理
      • ✅ 自动化质量保障,5个Skill覆盖开发全流程(UI、架构、代码、文档、数据库)
      • ✅ 减少人工审查工作量,提前发现问题,降低返工成本
      • ✅ 新人友好,自动化规范检查和教学
  • ⚙️ OCR调试模式开关 - 统一测试流程与正式流程 (2026-01-18)

    • 🆕 在开发者设置中新增"OCR调试模式"开关
    • 🔧 正式流程读取用户设置,动态控制是否收集中间步骤详情
    • ✅ 开启后:保存所有中间图片(裁剪后、增强后等),收集处理步骤详情
    • ❌ 关闭后:正常模式,节省存储空间和性能
    • 📊 测试一致性:确保测试页面看到的效果 = 用户实际使用的效果
    • 🎯 用户价值:灵活控制调试信息,生产环境可关闭,测试阶段可开启
    • 📄 文档:创建 docs/architecture/ocr_processing_history_design.md 详细设计文档
  • 🎨 测试页面应用撕纸效果 - 确保测试看到的 = 用户看到的 (2026-01-18)

    • 🆕 测试页面的中间图片展示使用 BookPageImage 组件
    • ✂️ 所有处理后的图片都显示撕纸边缘效果
    • 🌟 柔和阴影增强视觉层次感
    • 📊 一致性:测试流程展示效果与用户实际看到的完全一致

Changed

  • 🗄️ 数据库简化优化(Schema v20 → v21) - 删除过度设计字段 (2026-01-30)
    • reading_sources 字段删除
      • ❌ 删除 type 字段(未使用,所有来源统一为"阅读来源")
      • ❌ 删除 pageNumber 字段(OCR照片不适用页码概念)
      • ❌ 删除 chapterName 字段(OCR照片不适用章节概念)
      • 📊 影响范围:8个文件修改(datasource, model, repository, usecase, UI)
    • vocabulary_items 字段删除
      • ❌ 删除 part_of_speech 字段(与 pos_definitions 冗余,已弃用)
      • ✅ 保留 pos_definitions JSON 字段(97.68% 覆盖率,权威数据源)
      • 📊 影响范围:14个文件修改(datasource, model, repository, filtering, UI)
    • Schema 升级
      • 版本号:v20 → v21
      • SQL 脚本更新(01_create_tables.sql 变更历史记录)
      • 文档同步更新(schema.md, CLAUDE.md, CHANGELOG.md)
    • 🎯 核心价值
      • 简化数据模型,减少维护成本
      • 消除冗余字段,提升数据一致性
      • 为 Batch 5(Excluded Words)做准备
  • 🎨 复习页面UI细节优化 - 提升可读性和交互体验 (2026-01-27)
    • 音标格式优化 添加斜杠间空格(/ xxx /),符合国际音标书写规范
    • 单词字号放大headlineLarge 升级到 displaySmall,更醒目
    • 分段按钮背景 未选中状态从白色改为 surfaceContainer(浅主题色),与definition区域统一
    • 标题栏进度显示 使用胶囊样式(secondaryContainer + 圆角20px + titleLarge),参考分页指示器设计
    • Show more/less 优化TextButton 改为 InkWell,增加点击区域(padding: 12x6,高度32px),提升点击灵敏度
    • 代码简化 删除2个未使用的状态变量(_swipeDirection_previousSelectedPos),减少维护成本
    • 📊 Material 3 规范 全面使用语义化颜色(surfaceContainersecondaryContaineronSurfaceVariant),移除手动opacity计算
    • 🎯 用户价值 更清晰的视觉层次 + 更灵敏的交互反馈 + 更简洁的代码
  • 🔧 启用透视矫正功能 (2026-01-18)
    • ✅ 启用透视矫正(enablePerspectiveCorrection: true
    • ✅ 保持倾斜角度矫正(总是启用)
    • ✅ 保持图像增强(启用)
    • 📊 完整的图像处理流程
      1. 文本区域裁剪(去除空白边缘)
      2. 图像增强(灰度化、二值化、对比度)
      3. 透视矫正(3D→2D投影,矫正梯形畸变)
      4. 倾斜角度矫正(2D平面旋转)
    • 🎯 用户价值:更清晰的文字识别结果,更好的阅读体验
  • 🎨 导航栏样式优化 - 完全符合 Material 3 规范 (2026-01-18)
    • P1 修复 删除 height: 0.8 行高设置(提升可访问性)
    • P2 优化 图标大小 28px → 24px(Material 3 NavigationBar 标准)
    • P3 增强 添加 200ms 平滑动画过渡(AnimatedContainer + AnimatedSwitcher)
    • 代码重构 消除 if/else 分支,使用统一结构
    • 动画效果 背景色渐变 + 图标缩放切换 + 文字颜色渐变
    • 📊 规范评分 87% → 100%(完全符合 Material 3)
    • 🎯 用户价值 更流畅的交互体验 + 更好的无障碍支持

Removed

  • 🧹 清理废弃页面 - 删除3个不再使用的页面文件 (2026-01-18)
    • ❌ book_type_selection_page.dart (6.5K) - 功能已被 create_book_page 的 Tab 切换集成
    • ❌ book_create_page_new.dart (14K) - 实验性"新版本"从未启用
    • ❌ background_removal_test_page.dart (5.3K) - 孤立测试工具,无引用入口
    • 代码库减少 ~26K
    • 验证通过 flutter analyze 无错误
    • 🎯 价值 消除代码混淆,提升维护性

Added

  • 🎯 学习系统v9.1优化 - 按钮命名和颜色优化 (2026-01-17)

    • 命名优化 - Forgot/Recalled/Perfect(消除Good的"已经很好"歧义)
    • 颜色优化 - 橙色/蓝色/绿色(橙色代替红色,减少焦虑感)
    • 心理学依据 - 橙色:警示但不负面 | 蓝色:冷静中性 | 绿色:成功激励
    • 语义清晰化 - Recalled明确表示"回忆起来了",Perfect明确表示"零犹豫"
    • 📊 行业参考 - RemNote的描述性命名 + 颜色心理学研究(Smashing Magazine 2025)
    • 🎯 用户价值 - 焦虑感↓(橙色vs红色),语义歧义↓(Recalled vs Good),决策速度↑
  • 🎯 学习系统v9优化 - 3按钮设计+晋级降级机制 (2026-01-17)

    • 3按钮简化设计 - 删除Hard按钮,保留 Again(红)/Good(绿)/Easy(蓝)
    • Quality映射优化 - Again=1, Good=4, Easy=5(必须用4不能用3,否则Good无法晋级)
    • 晋级阈值提高 - Level5需10次成功(原5次),Level4需7次(原3次),Level3需4次(原2次)
    • 降级机制新增 - Again时降2级+EF-20%(Level5→3, Level4→2, Level3→1, Level2/1→0)
    • UI优化 - 按钮间距增加(8px→12px),左右padding减少(32px→16px),按钮更大更好按
    • 📊 行业调研 - 参考Anki、SuperMemo、Duolingo、RemNote等主流SRS产品最佳实践
    • 📚 设计文档 - docs/srs-button-design-analysis.md 完整分析报告(含8个权威来源)
    • 🎯 用户价值 - 认知负担↓40%,操作速度↑30%,Level5含金量↑100%,避免Anki的"Ease Hell"问题
  • 🎯 多词性结构化数据(Schema v16) - Oxford 5000权威CEFR数据集成 (2026-01-16)

    • 新增字段 - pos_definitions TEXT(JSON格式)存储per-POS CEFR/定义/例句
    • Oxford 5000集成 - 4,237个词(49.2%)获得准确的per-POS CEFR数据
    • 向后兼容 - 保留 cefr_leveldefinitionpart_of_speech 作为fallback字段
    • 数据质量提升 - Oxford覆盖词汇的CEFR准确性显著提升(A1-C1权威数据)
    • JSON Schema - {"noun": {"cefr": "A1", "definitions": [...], "examples": [...]}, "verb": {...}}
    • 迁移工具 - /tmp/migrate_oxford_to_pos_definitions.py(支持测试模式、全量迁移)
    • 📊 覆盖统计 - Oxford 5000: 4,954词/5,943词条,预装库8,614词,覆盖率49.2%
    • 🎯 用户价值 - 多词性词汇的CEFR更准确(如 book: noun=A1, verb=A2),统计更精确
  • 🚫 停用词过滤功能 - OCR识别自动过滤功能词 (2026-01-16)

    • 停用词库 - lib/core/nlp/english_stopwords.dart,107个核心功能词
    • OCR集成 - 在词形还原之前自动过滤(a, an, the, is, was等无学习价值词汇)
    • 分类完整 - 10大类别:冠词、代词、be动词、助动词、介词、连词等
    • 统计日志 - 每次OCR后显示过滤统计(总词数、过滤数、有效词数)
    • 📊 预期效果 - 初级文本50-60%过滤率,中级文本30-40%过滤率
    • 🎯 用户价值 - 聚焦有学习价值的词汇,统计更精准,学习更高效
  • 📚 词汇库多词性定义更新(Schema v15) - 8173个词汇全面升级 (2026-01-16)

    • 定义格式化 - 94.9%词汇(8173/8614)更新为多词性格式 【n.】1. xxx\n【v.】1. xxx
    • 发音增强 - pronunciation_url覆盖率:63.8% → 65.5%(5644词)
    • 音标补充 - ipa_pronunciation覆盖率:81.6%(7031词)
    • 数据保护 - CEFR等级、中文翻译、词族等高价值字段100%保留
    • 数据来源 - Free Dictionary API(免费、无API key需求)
    • 批量工具v2 - 新增 tools/vocabulary_builder/ 完整工具集
      • update_existing_vocabulary_v2.dart - 批量更新脚本(支持断点续传、进度跟踪)
      • lib/api_client_v2.dart - 扩展API客户端,支持多词性格式化
      • monitor_update.sh - 实时监控脚本
      • UPDATE_GUIDE_V2.md - 完整使用文档
    • 📊 更新统计 - 总耗时2小时49分钟,失败441个(5.1%,专有名词/缩写等)
    • 🎯 用户价值 - WordDetail页面现支持完整的多词性定义展示,学习内容更丰富

Fixed

  • 🐛 关键Bug修复 - 发音信息丢失 + Overview页面不刷新 (2026-01-15)
    • 问题1(部分修复):单词详情页先无发音后有发音 - 识别为旧数据问题,需进一步调查
    • 问题2:添加书籍后Overview页面不刷新 - 添加单词后自动刷新paginatedGroupsProvider和dateSummariesProvider
    • 问题3:Review Session看不到发音信息 - 修复review_session_notifier中audioUrl被硬编码为null的问题
    • 🎯 用户体验提升:发音功能完整可用,数据刷新及时

Changed

  • 🔧 数据库迁移逻辑重构 - 删除死代码,简化开发阶段逻辑 (2026-01-15)
    • 删除154行死代码 - _onUpgrade中的复杂迁移逻辑永远不会执行
    • 删除4个迁移SQL文件 - 04_migrate_v6_to_v7.sql、04_migrate_v7_to_v8.sql、05_migrate_v8_to_v9.sql、06_migrate_v9_to_v10.sql
    • 新增开发/生产标识 - _isDevelopmentPhase常量明确运行模式
    • 简化版本注释 - 只保留当前版本信息,删除冗长的历史记录
    • 改进异常处理 - _onUpgrade抛出清晰异常防止误用
    • 📊 代码减少611行 - 从780行减少到645行(-17.3%)
    • 🎯 维护成本显著降低:逻辑清晰、无死代码、符合开发阶段策略

Added

  • 🗄️ 预装词库v2更新 - pronunciation_url字段添加 + 维护工具完善 (2026-01-15)

    • 数据库更新 - 重命名为reading_vocab.db(统一命名),添加pronunciation_url字段
    • 数据质量 - 8614词,63.8%音频覆盖率(5497词有发音URL)
    • CEFR验证 - 100%正确级别(A1-C2),无PENDING/UNKNOWN脏数据
    • 来源纯净 - 100% source='preinstalled',无backfilled脏数据
    • 维护工具 - 新增 vocabulary_maintenance.md(完整流程文档)
    • 验证工具 - validate_preinstalled_db.py(数据质量验证)
    • 版本管理 - version_manager.py(备份与回滚管理)
    • 🎯 数据基础夯实:单词发音功能数据就绪,维护流程规范化
  • 📱 核心流程优化(Sprint 1.1) - 7项关键体验改进 (2026-01-13)

    • P1.1 书籍创建流程优化 - 新增"新闻文章"、"考试课程"模板
    • P1.2 书籍加载错误处理 - 错误提示+重试按钮
    • P1.3 分批学习功能 - 10词一批+批次完成对话框
    • P1.4 复习完成流程优化 - 动态祝贺级别+统计卡片
    • P1.12 权限请求优化 - 友好引导对话框+设置指引
    • P1.13 网络异常提示优化 - 全局状态指示器
    • P1.14 OCR动画进度展示 - Lottie动画+分步骤提示
    • 🎯 流程完善:核心功能路径无阻塞、异常处理友好、学习体验流畅
  • 📱 笔记本页面体验提升(Sprint 1.2) - 11项用户体验优化 (2026-01-14)

    • 代码优化 - 删除调试日志、分页调试信息框,pageSize 5→20
    • P2.8 多义词详细解释 - 显示所有词性和释义(【n.】/【v.】格式)
    • P2.13 长按菜单 - 单词操作快捷菜单(查看详情/来源/编辑笔记/分享/删除)
    • P2.14 滑动删除 - 左滑删除单词with确认对话框
    • P2.15 撤销功能 - 删除后4秒内可撤销恢复
    • P2.9 用户笔记功能 - 为单词添加个人笔记(amber标识)
    • P2.17 书籍创建流程简化 - 可折叠的高级选项,快速创建
    • P2.3 剪贴板快速添加 - 从剪贴板快速添加单词
    • P2.11 字体大小调整 - UI入口(占位功能,待实现)
    • P2.20 批量操作 - 选择模式+全选/取消+批量删除
    • P2.12 触觉反馈 - 所有关键交互添加触觉反馈(5种强度)
    • 🎯 用户体验提升:操作流畅度提升、交互反馈完善、功能更强大
  • 📱 ScanPage 专用页面 - OCR流程体验优化 (2026-01-12)

    • 新增 ScanPage - Scan按钮的默认页面,作为OCR流程中枢
      • 拍照按钮:180x180圆形,主入口
      • 相册按钮:90x90圆形,次要入口
      • 手动触发设计,给用户更好的控制感
    • 加载遮罩优化 - 全屏覆盖(包括AppBar),解决背景显示不合理问题
    • 相册选择功能 - 支持从相册选择图片进行OCR识别
    • 导航逻辑优化 - Confirm Words页面返回ScanPage(不返回首页)
    • 取消操作优化 - 相机/相册取消时留在ScanPage,不返回上一页
    • 🎯 用户体验提升:OCR流程逻辑清晰,加载状态显示合理

Changed

  • 🔄 页面命名优化 (2026-01-12)
    • 重命名 MyPageProfilePage(与导航标签"Profile"一致,符合业界命名惯例)
    • 删除已废弃的 HomePage(应用启动直接进入 ReviewOverviewPage)
    • 优化 deeplink 处理:直接导航到 ScanPage/ReviewOverviewPage
  • 🔧 重构OCR状态管理 (2026-01-12)
    • Provider监听统一到ScanPage,删除ReviewOverviewPage和ProfilePage中的重复逻辑
    • 简化导航流程:页面 → ScanPage → OCR → Confirm Words → ScanPage
  • 📦 依赖更新 (2026-01-12)
    • google_mlkit_text_recognition: 0.11.0 → 0.15.0
    • google_mlkit_commons: 0.6.1 → 0.11.0(支持Android SDK 36)
  • 📚 Books 书籍管理功能 - 阅读来源的概念升级
    • ✅ 书籍列表页 (book_list_page.dart) - 浏览所有书籍
    • ✅ 书籍详情页 (book_detail_page.dart) - 查看书籍信息和词汇列表
    • ✅ 创建书籍页 (create_book_page.dart) - 创建新书籍
    • ✅ 书籍选择对话框 (book_selection_dialog.dart) - OCR时关联书籍
    • 🎯 统一术语:Reading Material GroupsBooks

Removed

  • 🗑️ 删除废弃代码 (2026-01-12)

    • 删除 lib/shared/presentation/pages/home_page.dart(~860行)
    • 删除 AppRoutes.homeAppRoutes.splash 路由配置
    • 理由:HomePage已废弃,应用启动直接进入ReviewOverviewPage
  • 🔤 词形还原(Lemmatization)功能 (v11, 2026-01-10) - 智能单词去重,提升用户体验

    • 四层词形还原引擎 (lib/core/nlp/lemmatizer.dart)
      • 第0层:例外词检查(60+个,避免误还原 morning/news)
      • 第1层:不规则词查表(430+个,went→go, children→child)
      • 第2层:后缀规则匹配(13条规则,books→book, studied→study)
      • 第3层:原词保留(容错机制)
    • OCR 去重优化:books/book/Book → 统一显示 book
      • 减少重复单词约 20-30%
      • 降低 PENDING/UNKNOWN 数量(established → establish 可查到)
    • 高亮查询优化:用户点击 establish → 原图 established 正确高亮
      • ocr_word_positions 表增加 lemma 字段
      • 查询逻辑:WHERE lemma = 'establish' → 找到 word='established'
    • 🎯 用户体验提升:单词列表清爽,减少认知负担

Changed

  • 🔧 数据库 Schema v10 → v11 (2026-01-10) - 支持词形还原高亮查询

    • ocr_word_positions 表增加字段
      • 新增:lemma TEXT NOT NULL - 词根(如 book,用于查询)
      • 原有:word TEXT NOT NULL - 原词(如 books,用于定位)
    • 查询优化:高亮查询从 WHERE word = ? 改为 WHERE lemma = ?
    • 📊 影响范围:7个文件修改,3个新增文件
    • 🚀 性能影响:<1ms/词,几乎无性能损耗
  • 🔧 数据库 Schema 功能简化 (v8 → v10) - 删除上下文提取功能,聚焦原图查看

    • v10 变更 (2026-01-07)
      • 删除 word_source_relations 字段context_sentence, position_in_text, highlight_start, highlight_end
      • 📝 删除原因:上下文提取质量不稳定,功能效果不理想
      • 替代方案:保留原图查看 (reading_sources.ocr_text + ocr_word_positions)
      • 🗑️ 删除工具类TextExtractionUtils(200+ 行代码)
      • 📉 代码简化:净删除约 414 行代码
    • v9 变更 (2026-01-06)
      • 🔧 notebook_entries.mastery_level 改为非 nullable(默认值 level0
      • ✅ 简化类型系统,消除空值检查
  • 🔧 数据库 Schema 架构优化 (v7 → v8) - 合并表结构,消除一对一关系冗余

    • 删除 word_mastery_info(与 notebook_entries 一对一关系,浪费空间)
    • SM-2 算法字段合并到 notebook_entries
      • 添加:mastery_level, easy_factor, interval, repetitions, correct_count, incorrect_count, average_response_time
    • 删除冗余字段mastery_status, mastery_percentage, total_reviews, correct_reviews(被 SM-2 字段替代)
    • 🔄 字段重命名last_reviewed_atlast_review_date(与 next_review_date 命名一致)
    • 🔢 表数量减少:9 张 → 8 张
    • 📈 性能提升:减少一次 JOIN 查询,复习查询更高效
  • 🔧 数据库 Schema 优化 (v6 → v7) - 清理冗余字段,优化数据模型

    • ❌ 删除 books.current_page(改为动态计算)
    • ❌ 删除 notebook_entries.device_id, sync_status, tags, notebook_group(未使用或功能已被替代)
    • 🔄 重命名 notebook_entries.contextexample_sentence(消除命名歧义)
    • ❌ 删除 word_source_relations.highlight_start, highlight_end(冗余,由 ocr_word_positions 管理)
    • ❌ 删除未使用方法:getEntriesByGroup(), getAllGroups(), getEntriesByTag(), getAllTags()

Technical

  • 📊 数据库 Schema v8 → v10

  • 🗂️ 新增迁移脚本:assets/sql/06_migrate_v9_to_v10.sql

  • 📝 影响文件:8+ 个(Domain/Data/Presentation 全层)

    • word_source_relation_entity.dart - 删除 4 个字段
    • word_source_relation_model.dart - Freezed 模型更新
    • reading_source_repository.dart - 简化接口
    • link_word_to_source_usecase.dart - 删除参数
    • filtered_results_notifier.dart - 移除 TextExtractionUtils 调用
    • filter_confirmation_notifier.dart - 移除上下文提取逻辑
    • source_reveal_panel.dart - 移除上下文显示
    • immersive_context_panel.dart - 移除上下文方法
  • 🗑️ 删除文件:

    • lib/core/utils/text_extraction_utils.dart(200+ 行)
    • test/core/utils/text_extraction_utils_test.dart
    • test/integration/text_extraction_integration_test.dart
  • 📉 代码统计:净删除约 414 行

  • ⚡ 性能影响:减少 OCR 后的文本处理开销

  • ✅ 向后兼容:通过数据库重建处理(开发阶段策略)

  • 📊 数据库 Schema v7 → v8

  • 🗂️ 新增迁移脚本:assets/sql/04_migrate_v7_to_v8.sql

  • 🔄 使用 LEFT JOIN 合并数据,无数据丢失

  • 🆕 MasteryLevelEntity 枚举(level0-level5,替代旧的 MasteryStatus)

  • ⚡ 优化查询性能:复习单词查询减少一次 JOIN

  • 📝 更新所有代码引用(190+ 错误修复)

  • 🧹 清理 7 个冗余/未使用数据库字段

  • 🧹 删除 4 个未使用的 Repository 方法

  • 📝 明确字段语义:区分词库例句(example_sentence)与 OCR 上下文(context_sentence

  • 🔧 VocabularyTestService 重构 - 使用生产代码进行测试

    • 调用 getVocabulariesBatch() 替代独立的 testQueryWord()
    • 集成 VocabularyDebugCollector 提取层级执行详情
    • 支持 Layer 2/3 可选显示(null-safe)

计划中

  • 词汇笔记本完整功能
  • 翻译集成和优化
  • 间隔重复学习(SM-2 算法)优化

[0.5.0-alpha] - 2026-01-31

Added

  • 🎉 拍照识词完整流程(相机、相册)
  • ✨ OCR 文字识别(Google ML Kit、百度 OCR)
  • ✨ CEFR 等级词汇过滤
  • ✨ 词汇笔记本基础功能
  • ✨ 翻译集成(有道、百度)
  • ✨ 间隔重复学习基础版
  • 📚 完整的项目文档体系

Changed

  • 🔧 重构项目架构为 Clean Architecture
  • 🔧 使用 Riverpod 替换 Provider
  • 🔧 优化应用启动流程

Fixed

  • 🐛 修复 OCR 识别率低的问题(添加图像预处理)
  • 🐛 修复应用启动白屏问题

Performance

  • ⚡ 图片压缩优化(OCR 前压缩)
  • ⚡ 词汇列表懒加载

Documentation

  • 📖 添加产品愿景文档
  • 📖 添加架构设计文档
  • 📖 添加数据模型文档
  • 📖 添加 UI 流程文档
  • 📖 添加代码规范文档
  • 📖 添加常见问题文档

[0.1.0] - 2025-12-20

Added

  • 🎉 项目初始化
  • ✨ Flutter 基础项目结构
  • ✨ Material Design 主题
  • ✨ 底部导航栏
  • ✨ 主页框架

Technical

  • 使用 Flutter SDK ^3.10.4
  • Clean Architecture 搭建
  • Riverpod 状态管理集成
  • 移动平台支持(iOS、Android)

版本说明

版本号规则

  • 主版本号: 不兼容的 API 修改
  • 次版本号: 向下兼容的功能性新增
  • 修订号: 向下兼容的问题修正

发布阶段

  • Alpha: 内部测试,功能不完整
  • Beta: 公开测试,功能基本完整
  • RC: 候选发布版本,主要功能已冻结
  • Stable: 正式发布版本

变更类型说明

  • Added: 新增功能
  • Changed: 功能变更
  • Deprecated: 即将废弃的功能
  • Removed: 已删除的功能
  • Fixed: Bug 修复
  • Security: 安全性修复或改进
  • Performance: 性能优化
  • Documentation: 文档更新

时间线

2025-12 ↓
    ├─ v0.1.0 - 项目初始化

2026-01 ↓
    └─ v0.5.0-alpha - Alpha 版本

2026-03 ↓
    └─ v1.0.0-beta - Beta 版本

2026-05 ↓
    └─ v1.0.0 - MVP 正式发布

链接

  • GitHub Releases: [链接]
  • 下载地址: [链接]
  • 升级指南: [链接]

最后更新: 2025-12-26