Skip to content

16 — word_cloze_contexts 纳入跨端同步(RB 侧已完成 / RVH 会话交接)

物理位置:本文件在 RB 仓库 ~/reading-browser/docs/cross-end/16-rb-cloze-context-sync-handoff.md。 RVH 会话用绝对路径读:Read ~/reading-browser/docs/cross-end/16-rb-cloze-context-sync-handoff.md。 跨仓库引用一律 ~-锚定绝对路径(RB→~/reading-browser/...,RVH→~/reading_vocab_helper/...)。

创建:2026-07-09 · RB 先行(CLAUDE.md §9)· 关联 backlog 条目「word_cloze_contexts 纳入跨端同步」

⚠️ 2026-08-17 部分取代:本文件把 RVH 定位为「pull-only 消费方」的框定已失效—— word_cloze_contexts 现为双端共写(RVH 的 OCR 采集经跨端评审放开,附四条硬条件)。 见 23-rb-ocr-cloze-context-decision.md。 本文件的 §1 表契约 / §2 合并算法仍然有效,只是「谁会产行」那一句不再准。


0. 一句话目标

word_cloze_contexts(用户查词时正在读的真实句 + surface + sense_gloss,复习卡正面据此挖空生词)此前是 local-only(RB migration v16-v21)——用户在 RB 采集的语境,换到 RVH 复习完全看不到(RVH 复习卡只能 回落策展例句)。RB 侧已完成:本地 schema 加同步三列、全部硬删除改软删除、push/pull 双向同步 + 独有的 "合并后重新封顶"逻辑、Supabase 新建 user_word_cloze_contexts 表。

RVH 侧待做(本文件的交接目标):本地建这张表(RVH 目前没有)+ sync 适配(必须对齐 RB 的 merge 算法, 否则两端各按不同规则收敛会互相拉扯)+ 复习卡消费 cloze 语境(RVH 复习尚无此概念,这是"⑥ cloze v2" 主体价值所在,不只是同步管道)。


1. 表契约(握手核心 —— RVH 本地 schema 必须对齐)

1.1 本地 SQLite(RB word_cloze_contexts,migration v16→v17→v21→v24 累积形态)

sql
CREATE TABLE word_cloze_contexts (
    id TEXT PRIMARY KEY,
    word TEXT NOT NULL COLLATE NOCASE,   -- 归一 lemma,join learning_entries.word
    surface TEXT NOT NULL,               -- 原始点击形,挖空精确替换用
    sentence TEXT NOT NULL,              -- 真实语境句(8..220 字符护栏)
    source_url TEXT,
    user_id TEXT,
    created_at TEXT NOT NULL,
    sense_gloss TEXT,                    -- v17:消歧出的贴合语境义项 gloss 文本(非 index)
    updated_at TEXT,                     -- v24:同步脏检查
    deleted_at TEXT,                     -- v24:软删墓碑
    synced_at TEXT                       -- v24:同步水位
);
CREATE INDEX idx_cloze_word ON word_cloze_contexts(user_id, word COLLATE NOCASE, created_at DESC);

无 UNIQUE 约束(v21 去除)——一词多语境池,应用层维持"去重 + 封顶 5"语义。无 FK(v16 起有意 松耦合,孤儿行无害)。

1.2 Supabase(user_word_cloze_contexts,全新建表)

sql
CREATE TABLE IF NOT EXISTS user_word_cloze_contexts (
    id TEXT PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    word TEXT NOT NULL,
    surface TEXT NOT NULL,
    sentence TEXT NOT NULL,
    source_url TEXT,
    sense_gloss TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT,
    deleted_at TEXT,
    server_updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
    UNIQUE(user_id, word, sentence)
);

远端有本地没有的 UNIQUE(user_id, word, sentence)——这是 push 的 on_conflict 目标,不是本地约束 (本地去重靠应用层查询)。RLS:auth.uid() = user_id(FOR ALL)。触发器 set_server_updated_at() 挂 BEFORE INSERT/UPDATE(与其余 9 张同步表同款)。完整 DDL 见 ~/reading-browser/supabase/sql/sync-tables.sql "5. user_word_cloze_contexts" 小节。


2. 合并算法(RVH 若要双向同步,必须复刻这一套,否则两端拉扯)

为什么难:其余 9 张同步表要么内容不可变(word_page_links)、要么走"更优覆盖"简单 merge (learning_entries 取 higher repetitions)。cloze 是"一词多语境池,应用层封顶 5 条"——两端各自封顶 5 条 的池子,pull 合并后可能变成 10 条,必须有一步"合并后重新收敛"。

2.1 采集时的去重 + 封顶(本地写路径,insert_cloze_contextsrc-tauri/src/commands/vocabulary/crud.rs

  1. 命中同 (user_id, word, sentence COLLATE NOCASE) 的已有行:
    • 活跃(deleted_at IS NULL)→ no-op(幂等)。
    • 已软删 → 复活UPDATE SET deleted_at=NULL, updated_at=now),不再插一条新行(避免"新行+旧墓碑")。
  2. 无命中 → 若该 word 活跃行数已达 CLOZE_POOL_CAP(5) → 软删最旧一条(created_at ASC, id ASC 排序, correlated subquery LIMIT count-CAP+1)→ 再插入新行。

2.2 撤销 / 级联删除(全部软删)

  • remove_cloze_context(弹窗"撤销"):单行软删。
  • remove_cloze_contexts_for_sentence(笔记句子级删除级联):sentence 精确匹配 OR instr 子串兜底, 两路命中均软删。
  • clear_cloze_context_for(删词级联,delete_vocabulary/batch_delete_vocabulary 调用):按 word 软删。
  • remove_reading_page(来源页级删除,notes.rs):按 source_url 软删。

全部改软删除的原因:硬删除会被下一轮 pull 当作"remote 未删的活行"复活——这是同步表的通用约束 (红线 #6),不是 cloze 特有。

2.3 push(push_cloze_contextssrc-tauri/src/commands/sync/push.rs

脏检查:synced_at IS NULL OR updated_at > synced_at OR (deleted_at 新增)on_conflict=user_id,word,sentence

  • Prefer: resolution=merge-duplicates(等价 Postgres ON CONFLICT (user_id,word,sentence) DO UPDATE)。 mark_synced 仍按本地 id 标记——与 learning_entries/known_words/favorite_sites 等业务键 upsert 表同一套 既有行为:若两台设备各自为同一 (word,sentence) 生成了不同 id 的行(罕见竞态:都在离线时收集了同一句), 远端会以后写入者的 id 为准,先写入者下次 pull 时会走"本地无此 id → 插入"分支——这是全部业务键 upsert 表 共有的既有行为,本次未额外处理(与其它 9 张表一致的取舍,非 cloze 独有问题)。

2.4 pull(pull_cloze_contextssrc-tauri/src/commands/sync/pull.rs

三分支墓碑传播(模板 = pull_word_sources):

  • 本地存在 + remote 已删 → 标记本地墓碑
  • 本地已删 + remote 活 → skip(本地墓碑走 push 传播,不被复活)
  • 本地不存在 + remote 已删 → skip,不落墓碑
  • 本地不存在 + remote 活 → 直接 INSERT不做 FK 存在性检查——沿 v16 松耦合设计)
  • 本地存在 + remote 活 → sentence/surface 不可变;sense_gloss 取非空一方回填(双非空不覆盖)

合并后重新封顶reconcile_cloze_pool,本表独有,其余 9 张表都不需要): 对本批次 pull 涉及的每个 distinct word

  1. 拉出该词全部活跃行(deleted_at IS NULLORDER BY created_at ASC, id ASC)。
  2. 按句去重sentence.to_ascii_lowercase() 分组(与 SQLite 内置 COLLATE NOCASE 语义一致——都是 ASCII-only 折叠)。同组保留 created_at 最早一条为幸存行,sense_gloss 从同组其它行回填(若幸存行为 空),其余同组行软删。
  3. 重新封顶:去重后若活跃行数仍 > 5,软删最旧的超额行(同 §2.1 的 correlated subquery 写法)。

这一步产生的软删行不在本轮闭环——它们会在下一轮 sync_now(60s 轮询)的 push 阶段作为墓碑传播出去, 让另一台设备也收敛到同一份 ≤5 条的池子。两轮内最终一致,不要求单轮内完成。


3. RVH 会话任务清单

全部在 ~/reading_vocab_helper/必须新会话(CLAUDE.md §9,Flutter/Dart 工具链)。

3.1 本地建表

  1. word_cloze_contexts 本地表,schema 照抄 §1.1(Dart/sqflite 语法转换)。RVH 目前没有这张表, 这是全新建表不是 ALTER。
  2. word 列需与 RVH 现有 learning_entries.word 同一归一口径(红线 #9 lemmatizer normalize)。

3.2 sync 适配

  1. Dart 端 sync_repository_impl.dart(或等价 sync 模块)新增 push/pull 分支,完整复刻 §2.3/§2.4 的 算法——尤其是 pull 后的 reconcile_cloze_pool 步骤,这是防止两端互相拉扯的关键,不能省略或简化。
  2. 撤销/删除路径全部走软删除(若 RVH 复习 UI 提供撤销/删除 cloze 语境的入口)。

3.3 复习卡消费("⑥ cloze v2"主体工作量,非纯同步管道)

  1. RVH 复习卡目前完全没有 cloze 概念(回落策展例句 vocabulary.pos_definitions 挖空)。需要:
    • 复习卡正面按 word_cloze_contexts 的语境句挖空(若该词有 cloze 行)。
    • 多语境轮换(RB 侧按 repetitions % 语境数 轮换,见 docs/plans/archive/multi-context-cloze-plan.md)—— RVH 是否对齐同一轮换语义,或简化为"取最新一条",由 RVH 会话按 Dart 端复习卡架构决定。
    • sense_gloss 若非空,复习背面按 gloss 文本重定位高亮(RB 侧 record_cloze_sense/get_cloze_sense 的等价逻辑)。

4. RB 侧已完成清单(本次会话,供 RVH 会话核对现状)

  • [x] migration v24:word_cloze_contexts ALTER ADD updated_at/deleted_at/synced_at
  • [x] insert_cloze_context / record_cloze_sense / get_cloze_sense / remove_cloze_context / remove_cloze_contexts_for_sentence / clear_cloze_context_for / remove_reading_page(notes.rs)/ get_due_cards cloze 批量读(srs.rs)/ persist_sense_to_cloze(supabase.rs)全部适配软删除 + deleted_at IS NULL 过滤
  • [x] clear_user_learning_data(signout 清理)补齐 word_cloze_contexts / word_personalized_examples / phrase_interaction_log 三张遗漏表(隐私缺口顺手修)
  • [x] push_cloze_contexts + pull_cloze_contexts + reconcile_cloze_poolsync/push.rs / sync/pull.rs
  • [x] sync/mod.rs 编排:push/pull 顺序插入 + get_sync_status pending_push 计数加第 10 项
  • [x] Supabase DDL:supabase/sql/sync-tables.sql 新增 "5. user_word_cloze_contexts"
  • [x] scripts/reset-dev-data.sh TABLES 数组加 user_word_cloze_contexts
  • [x] CLAUDE.md / docs/database-schema.md / docs/database-tables-overview.md / docs/plans/cross-end-sync-evolution-roadmap.md 同步矩阵计数从 9 张更新为 10 张
  • [x] cargo check 通过

未改(有意):scripts/cross-end-check.shSHARED_SYNC 数组本次不加word_cloze_contexts——该数组是 RB↔RVH 双端都要 push 的"共享"表集合,RVH 完成 §3 之前加进去会让 cross-end-check 对 RVH 侧误报缺表。等 RVH 侧镜像完成后再加入。


5. 部署前置(Supabase)

user_word_cloze_contexts全新建表(非 ALTER 现有表)——必须先在 Supabase Dashboard 执行 supabase/sql/sync-tables.sql 里 "5. user_word_cloze_contexts" 那段 CREATE TABLE + 索引 + 触发器 + RLS policy,让带 v24 代码的新版客户端上线(新客户端 push/pull 到该 endpoint 前表必须已存在)。


6. 验证清单

  • [x] RB cargo check 通过
  • [ ] RB pnpm build
  • [ ] 删本地 dev DB 重跑 pnpm tauri dev,migration replay 到 v24 无报错
  • [ ] Supabase Dashboard 执行 DDL 后,登录 + 触发 sync,rb_supabase_query 确认 user_word_cloze_contexts 有行落地,rb_db_query 确认本地 synced_at 回写
  • [ ] 双设备(或模拟)各攒 5 条不同语境后互相 pull,确认 reconcile_cloze_pool 收敛回 ≤5 条而非 10 条
  • [ ] RVH 完成 §3 后:跨端复习同一词能看到 RB 采集的真实语境句