Skip to content

技术决策记录

本文档记录项目中的重大技术决策,说明选择的背景、理由和影响。

采用精简 ADR 格式:按时间顺序记录,无编号,聚焦"为什么"。


决策1:采用 Clean Architecture

日期:2025-09 状态:已接受 ✅

背景

项目初期需要选择一个架构模式,考虑到:

  • 这是一个长期维护的个人项目
  • 需要良好的可测试性
  • 未来可能扩展到更多平台
  • 需要清晰的代码组织结构

决策

采用 Clean Architecture(整洁架构),分为三层:

  • Domain Layer:纯业务逻辑,无框架依赖
  • Data Layer:数据获取和持久化
  • Presentation Layer:UI 和用户交互

理由

优势

  1. 依赖倒置:外层依赖内层,内层不依赖外层,符合 SOLID 原则
  2. 可测试性:每层可独立测试,Domain 层完全不依赖 Flutter
  3. 可维护性:层次清晰,职责分明,易于定位问题
  4. 可扩展性:易于替换数据源(SQLite → Hive)、UI 框架(Flutter → Web)

对比其他方案

  • MVC:职责不够清晰,Controller 容易臃肿
  • MVVM:比 Clean Architecture 简单,但扩展性较差
  • 没有架构:短期快速,但长期维护困难

代价与应对

代价

  • ⚠️ 初始代码量较多(每个功能需要 Entity、Repository、DataSource)
  • ⚠️ 学习曲线较陡(需要理解依赖倒置原则)
  • ⚠️ 文件数量多(一个功能可能有 10+ 个文件)

应对措施

  • 使用代码生成工具(Riverpod Generator、Freezed)减少样板代码
  • 编写详细的架构文档和示例代码
  • 使用 IDE 模板快速创建新功能模块

实施结果

成功指标

  • ✅ Domain 层单元测试覆盖率 > 80%
  • ✅ 新功能开发遵循统一架构模式
  • ✅ 代码审查时架构违规较少

经验教训

  • 对于个人项目,Clean Architecture 的投入是值得的
  • 严格的分层带来了长期维护的便利
  • 代码生成工具大幅减少了样板代码的负担

决策2:选择 Riverpod 作为状态管理方案

日期:2025-09 状态:已接受 ✅

背景

Flutter 有多种状态管理方案可选:

  • Provider(官方推荐)
  • Riverpod(Provider 的改进版)
  • Bloc(事件驱动)
  • GetX(轻量级)
  • MobX(响应式)

需要选择一个适合 Clean Architecture 的状态管理方案。

决策

选择 Riverpod + riverpod_generator

理由

对比 Provider

  • 编译时安全:不会忘记提供 Provider,避免运行时错误
  • 支持 Provider 容器测试:可以在测试中覆盖 Provider
  • 更好的性能优化:细粒度的依赖追踪
  • 自动依赖管理:Provider 之间的依赖自动处理
  • 无需 BuildContext:可以在任何地方访问 Provider

对比 Bloc

  • ✅ 代码更简洁(无需定义 Event、State 类)
  • ✅ 学习曲线更低
  • ✅ 更适合个人项目(Bloc 更适合大团队)

对比 GetX

  • ✅ 类型安全(GetX 使用字符串标识)
  • ✅ 更符合 Flutter 官方推荐风格
  • ✅ 社区支持更好

代价与应对

代价

  • ⚠️ 需要使用代码生成(riverpod_generator)
  • ⚠️ 迁移成本(如果之前使用 Provider)
  • ⚠️ 学习新的 API(与 Provider 有差异)

应对措施

  • 集成 build_runner 到开发流程
  • 编写 Riverpod 使用指南和最佳实践
  • 统一 Provider 命名规范(xxxProvider)

实施结果

成功指标

  • ✅ 所有状态管理使用 Riverpod
  • ✅ 使用 riverpod_generator 减少样板代码
  • ✅ Provider 测试覆盖率 > 70%

关键代码模式

dart
@riverpod
class VocabularyFiltering extends _$VocabularyFiltering {
  @override
  FutureOr<FilteringResultEntity> build() {
    return FilteringResultEntity.initial();
  }

  Future<void> filterVocabularies(List<String> words) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      final repository = ref.read(vocabularyFilteringRepositoryProvider);
      return await repository.filterVocabularies(...);
    });
  }
}

决策3:使用 SQLite 作为本地存储方案

日期:2025-09 状态:已接受 ✅

背景

应用需要本地存储以下数据:

  • 用户词汇笔记本(可能数千条记录)
  • CEFR 词库(8,614 个单词)
  • 学习记录和统计数据
  • 词汇来源和关联关系

需要选择合适的本地存储方案。

决策

选择 SQLite(通过 sqflite 包)作为主要本地存储方案。

理由

对比 Hive

  • 支持复杂查询:JOIN、GROUP BY、聚合函数
  • 支持事务:保证数据一致性
  • 支持索引:查询性能优化
  • 更适合关系型数据:词汇、来源、关联关系等

对比 SharedPreferences

  • ✅ 支持结构化数据(SharedPreferences 只适合简单键值对)
  • ✅ 支持批量操作和事务
  • ✅ 数据量不受限制

对比 Isar

  • ✅ 更成熟稳定(SQLite 存在 30+ 年)
  • ✅ Flutter 官方推荐
  • ✅ 工具生态更丰富

代价与应对

代价

  • ⚠️ 需要编写 SQL 语句(学习成本)
  • ⚠️ 数据库迁移复杂(需要版本管理)
  • ⚠️ 性能依赖索引设计

应对措施

  • 封装数据库操作到 DataSource 层
  • 建立完善的数据库迁移机制(v1 → v13)
  • 为高频查询字段建立索引
  • 使用事务保证数据一致性

实施结果

数据库规模

  • Schema 版本:v27
  • 表数量:9 张
  • 预置词库:CEFR-J 8,614 个单词
  • 数据库大小:~2-3 MB
  • 迁移历史:13 次版本升级

性能指标

  • 查询响应:< 100ms(有索引)
  • 批量插入:1000 条/秒
  • 数据库启动:< 50ms

关键设计

sql
-- 核心表
- user_settings(用户设置)
- vocabulary_items(词汇表,8,614 条预置数据)
- notebook_entries(笔记本条目)
- word_mastery_info(SM-2 算法数据)
- learning_records(学习记录)
- reading_sources(阅读来源)
- word_source_relations(词汇-来源关联)
- translation_cache(翻译缓存)
- ocr_cache(OCR 缓存)
- daily_statistics(每日统计)

经验教训

  • SQLite 的关系型特性非常适合我们的数据模型
  • 索引设计对查询性能影响巨大
  • 数据库迁移需要谨慎设计,保证向后兼容

决策4:选择 OpenCV 作为图像处理方案

日期:2025-12-26 状态:已接受 ✅

背景

应用需要图像处理功能来提升 OCR 准确率:

  1. 自动文档边缘检测:识别拍摄的书页边界
  2. 透视矫正:将斜拍的文档变为正视图
  3. 图像增强:去噪、二值化、提升对比度
  4. 背景移除:去除文档背景干扰

候选方案:

  • OpenCV(opencv_dart):强大的计算机视觉库
  • image_cropper:Flutter 原生裁剪 UI 组件
  • 混合方案:OpenCV + image_cropper

决策

选择 OpenCV(opencv_dart)作为主要图像处理方案,保留 image_cropper 作为可选的手动调整工具

理由

OpenCV 的核心优势

  1. 自动边缘检测:Canny 边缘检测 + findContours 轮廓查找
  2. 透视矫正:getPerspectiveTransform 四点透视变换
  3. 图像增强:高斯模糊、Otsu 二值化、形态学操作
  4. 背景移除:GrabCut 算法自动分离前景和背景
  5. 完全自动化:无需用户手动操作,快速处理
  6. 可控性强:可在应用层优化算法参数

image_cropper 的局限

  • ❌ 仅提供手动裁剪 UI,无自动检测功能
  • ❌ 无透视矫正能力
  • ❌ 无图像增强功能
  • ⚠️ 完全手动,处理速度慢(用户操作需 5-30 秒)

技术对比

功能OpenCVimage_cropper
自动边缘检测
透视矫正
图像增强
手动裁剪 UI⚠️ 需自行实现✅ 原生 UI
处理速度2-3 秒(自动)6-30 秒(手动)
准确率85-90%100%(用户决定)
适用场景批量处理、快速识别单张精确处理

代价与应对

代价

  • ⚠️ 库体积较大(~15MB)
  • ⚠️ Mat 对象内存管理复杂
  • ⚠️ 算法参数需要调优
  • ⚠️ 边缘检测可能失败(复杂背景、低对比度)

应对措施

  1. 内存管理:实现 MatScope 自动管理 Mat 对象生命周期
  2. 算法优化
    • 图像预缩放到 1200px(减少处理时间 60%)
    • 高斯模糊降噪(减少轮廓数量 80%)
    • 限制处理轮廓数量(避免超时)
  3. 降级方案:自动检测失败时,提供 image_cropper 手动裁剪入口
  4. 用户反馈:预览检测结果,允许用户调整

实施结果

已实现功能

  • ✅ 文档边缘检测和透视矫正
  • ✅ 图像增强(去噪、二值化)
  • ✅ 背景移除(GrabCut 算法)
  • ✅ Mat 对象自动内存管理(MatScope)
  • ✅ 性能优化(预缩放、降噪、限制轮廓数)

性能指标

  • 边缘检测:< 1000ms(优化前 ~2000ms)
  • 透视矫正:< 100ms
  • 图像增强:< 400ms
  • 背景移除:< 2000ms(1200px 图像)

内存管理

dart
// MatScope 自动管理 Mat 对象
MatScope.run(() {
  final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
  final edges = cv.Canny(gray, 50, 150);
  final contours = cv.findContours(edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
  // ... 处理逻辑
  // 所有 Mat 对象在作用域结束时自动释放
});

混合方案(未来)

  • 优先使用 OpenCV 自动处理(80% 场景)
  • 失败时降级到 image_cropper 手动裁剪(20% 场景)

经验教训

成功经验

  • OpenCV 的自动化能力大幅提升用户体验
  • MatScope 模式有效解决了内存管理问题
  • 算法参数优化带来显著性能提升

遇到的问题

  • 边缘检测在复杂背景下准确率较低(已通过预处理优化)
  • 大图片处理耗时长(已通过预缩放解决)
  • Mat 对象泄漏导致崩溃(已通过 MatScope 解决)

未来改进

  • 集成机器学习模型提升边缘检测准确率
  • 实现自动/手动混合模式
  • 优化算法参数(根据实际使用数据调整)

最后更新:2026-02-04 更新频率:每次重大技术决策时新增 文档状态:v1.0


决策:NotebookRepository 接口隔离重构

日期:2026-02-04 状态:已接受 ✅

背景

NotebookRepository 接口膨胀到 35 个方法(221 行),违反接口隔离原则(ISP):

  • 需要完整功能的调用方被迫依赖所有方法
  • 接口职责不清晰,难以维护
  • 测试时需要 mock 大量不相关的方法

决策

NotebookRepository 拆分为 5 个子接口:

接口职责方法数
INotebookEntryWriter条目写入(CRUD)8
INotebookEntryReader条目查询10
ILearningRecordRepository学习记录管理7
INotebookDataExchange数据导入导出3
IReviewSessionRepository复习会话查询4

主接口 NotebookRepository 聚合所有子接口:

dart
abstract class NotebookRepository
    implements
        INotebookEntryWriter,
        INotebookEntryReader,
        ILearningRecordRepository,
        INotebookDataExchange,
        IReviewSessionRepository {}

理由

优势

  1. 接口隔离:调用方只依赖需要的接口
  2. 单一职责:每个接口职责明确
  3. 易于测试:只需 mock 相关接口
  4. 渐进式采用:现有代码无需修改

对比其他方案

  • 不拆分:接口继续膨胀,可维护性下降
  • 完全拆分:需要修改所有调用方,改动太大

代价与应对

代价

  • ⚠️ 增加 5 个接口文件
  • ⚠️ 需要同步维护接口定义

应对措施

  • 主接口通过 export 导出子接口
  • 实现类不变,仍是单个 NotebookRepositoryImpl

决策:NotebookRepositoryImpl 应用 RepositoryMixin

日期:2026-02-04 状态:已接受 ✅

背景

NotebookRepositoryImpl 有 896 行代码,存在大量重复的 try-catch 模式:

  • 几乎每个方法都有相同的错误处理样板代码
  • 违反 DRY 原则
  • 增加代码维护成本

决策

NotebookRepositoryImpl 混入 RepositoryMixin,使用统一的错误处理方法:

dart
class NotebookRepositoryImpl with RepositoryMixin implements NotebookRepository {
  // 使用 safeCall、safeCallList、safeCallVoid 等方法
}

简化的方法(12 个)

  • getTotalEntryCountgetMasteredEntryCount
  • clearAlldeleteEntrydeleteEntriesBatch
  • getLearningRecordsrecordLearning
  • getDueReviewsByGroupgetGroupSummariesgetDateSummariesgetDueReviewsByDate
  • importFromJson

理由

优势

  1. 减少重复代码:36 行减少(896 → 860)
  2. 统一错误处理:所有方法使用相同的错误处理模式
  3. 易于维护:修改错误处理只需改一处
  4. 提高可读性:方法主体更专注于业务逻辑

未简化的方法

  • 包含复杂业务逻辑的方法(如 addNotebookEntryreviewWordWithQuality
  • 需要特殊错误处理的方法(如 getEntryById 需要 NotFound 判断)

代价与应对

代价

  • ⚠️ 部分复杂方法无法简化
  • ⚠️ 需要熟悉 RepositoryMixin API

应对措施

  • 复杂方法保持原有 try-catch 模式
  • RepositoryMixin 已有详细文档说明

决策:MapParser 辅助类用于 Map 解析

日期:2026-02-04 状态:已接受 ✅

背景

项目中存在大量重复的 Map 解析代码,特别是在 fromDatabase() 和 fromJoinedMap() 方法中:

  • DateTime.parse() 重复出现 20+ 次
  • 类型转换和默认值处理模式重复
  • 空值检查逻辑冗余

决策

创建 lib/core/utils/map_parser.dart 辅助类:

dart
class MapParser {
  static String getString(Map<String, dynamic> map, String key, {String defaultValue = ''});
  static DateTime getDateTime(Map<String, dynamic> map, String key, {DateTime? defaultValue});
  static int getInt(Map<String, dynamic> map, String key, {int defaultValue = 0});
  static double getDouble(Map<String, dynamic> map, String key, {double defaultValue = 0.0});
  // ... 更多方法
}

理由

优势

  1. 减少重复代码:统一的 Map 解析方法
  2. 类型安全:处理各种类型转换场景
  3. 空值处理:内置默认值支持
  4. 易于使用:静态方法,无需实例化

为什么不重构现有代码

  • 大部分 Model 由 Freezed 生成,不应手动修改
  • 现有代码已稳定运行
  • 渐进式采用:新代码优先使用

代价与应对

代价

  • ⚠️ 现有代码未使用新辅助类
  • ⚠️ 需要团队了解新工具

应对措施

  • 新代码优先使用 MapParser
  • 重构时逐步替换旧代码
  • 文档说明使用场景

决策:Skill 与文档职责分离

日期:2026-02-21 状态:已接受 ✅

背景

项目有 7 个自动化 Skill(.claude/skills/),其中 ui-compliance-checkcode-review 存在严重的内容重复问题:

问题发现

  • ui-compliance-check/SKILL.md:1126 行,其中约 60% 与 docs/ui-guidelines/ 重复
  • code-review/SKILL.md:558 行,其中约 20% 与 docs/development.md 重复
  • 维护时需要同时更新 2 处,容易遗漏导致不一致

重复内容示例

  • 硬编码颜色修复表格(SKILL.md + material3-design-system.md)
  • ElevatedButton 废弃说明(SKILL.md + button-style-guide.md)
  • 圆角标准值(SKILL.md + README.md + button-style-guide.md + dialog-style-guide.md)
  • Null Safety 代码示例(SKILL.md + development.md)

决策

职责分离原则

角色职责内容
docs/权威知识库完整的设计原则、代码示例、修复建议
Skill自动化检测检测规则、执行命令、报告格式、文档链接

Skill 精简规则

  1. 每个检查规则只保留:检测模式 + 标准值 + 文档链接
  2. 移除所有重复的代码示例和修复建议表格
  3. 输出报告提供文档链接,而非复制内容

架构示意

docs/(权威文档)
├── 完整的设计原则
├── 详细的代码示例
└── 修复建议和最佳实践
     ↓ 引用(不复制)
.claude/skills/(自动化检测)
├── 检测规则(正则/命令)
├── 执行流程
└── 报告格式 + 文档链接

理由

优势

  1. 单点维护:规范只在文档中维护,Skill 自动引用
  2. 减少重复:代码量减少 68%(1684 → 539 行)
  3. 一致性保障:不会出现 SKILL.md 与文档不一致的情况
  4. 职责清晰:文档负责"是什么",Skill 负责"检测什么"

对比其他方案

  • 不分离:继续维护 2 处,容易遗漏,不一致风险高
  • 删除 Skill 中的规则详情:Skill 失去独立执行能力
  • 删除文档中的规则详情:开发者学习时缺少参考资料

代价与应对

代价

  • ⚠️ Skill 执行时需要查阅文档获取详细修复建议
  • ⚠️ 文档链接可能失效(如果文档结构变化)

应对措施

  • Skill 输出报告中包含直接的文档链接
  • 使用锚点链接(如 #颜色系统规范)定位到具体章节
  • 定期运行 /doc-consistency-check 检查断链

实施结果

精简效果

Skill优化前优化后减少
ui-compliance-check1126 行263 行-78%
code-review558 行276 行-50%
合计1684 行539 行-68%

版本变更

  • ui-compliance-check:v2.0.0 → v3.0.0
  • code-review:v2.0.0 → v3.0.0

经验教训

成功经验

  • 职责分离使维护更简单
  • 文档链接比复制内容更可维护
  • 精简后 Skill 更聚焦于"检测"职责

适用范围

  • 此原则适用于所有 Skill,后续新增 Skill 应遵循此模式
  • 其他 Skill(如 doc-sync-checkclean-arch-check)重复程度低,暂不需要精简

决策13:代码审核体系支持全量检查模式

日期:2026-02-21 状态:已接受 ✅

背景

现有的代码审核 Skill(code-review、ui-compliance-check、clean-arch-check)只支持增量检查(基于 git diff),存在以下限制:

  • 无法检查历史遗留问题
  • 无法做全量合规性审计
  • 重构后无法验证整体一致性

决策

为现有检查类 Skill 添加 --full 参数,支持全量代码检查模式:

bash
# 增量检查(默认)
/code-review
/ui-compliance-check
/clean-arch-check

# 全量检查(审计模式)
/code-review --full
/ui-compliance-check --full
/clean-arch-check --full

同时统一问题分级标准:

级别颜色UI Skill架构 Skill含义
🔴红色P0R1严重问题
🟠橙色P1R2重要问题
🟡黄色P2R3建议修复
🟢绿色P3R4可选优化
🔵蓝色P4R5信息提示

理由

优势

  1. 审计能力:支持全量代码库的合规性审计
  2. 重构验证:大规模重构后可验证整体一致性
  3. 历史问题发现:能发现历史遗留的规范问题
  4. 一致性:统一分级标准使报告更易理解

对比其他方案

  • 创建独立的全量检查 Skill:会造成代码重复,维护成本增加
  • 只支持增量检查:无法满足审计需求,限制了使用场景

代价与应对

代价

  • ⚠️ 全量检查耗时较长(大型项目可能需要几分钟)
  • ⚠️ 全量检查结果可能很多,需要分批处理

应对措施

  • 默认使用增量模式,全量模式需显式指定 --full
  • 全量模式输出清晰的统计信息,便于分批修复
  • 问题按优先级分类,可先处理高优先级问题

实施结果

版本变更

  • code-review:v3.0.0 → v4.0.0
  • ui-compliance-check:v3.0.0 → v4.0.0
  • clean-arch-check:v1.1.0 → v2.0.0

覆盖矩阵(更新后)

代码对象代码范围Skill 支持
普通代码增量✅ code-review
普通代码全量✅ code-review --full
UI代码增量✅ ui-compliance-check
UI代码全量✅ ui-compliance-check --full
架构代码增量✅ clean-arch-check
架构代码全量✅ clean-arch-check --full

经验教训

设计原则

  • 增量检查是日常开发的默认选择(快速)
  • 全量检查用于特定场景(审计、重构验证)
  • 两种模式共用检测规则,只是检查范围不同

决策14:复习按钮从 3-Button 改为 2-Button + 序列推导

日期:2026-03-27 状态:已接受 ✅

背景

当前复习系统使用 3 个按钮(Forgot / Recalled / Perfect),映射 SM-2 Quality 为 1/4/5。实际使用中发现:

  • 用户难以区分 Recalled 和 Perfect("回忆起来但犹豫" vs "零犹豫")
  • 决策疲劳:3 个选项增加认知负担
  • 行业趋势:Anki、FSRS、Duolingo 等均支持或转向二元输入

决策

采用 Plan B:2-Button + 前向序列推导,用 Hard/Easy 两个按钮 + 上一次评分历史推导出 4 级质量分。

质量映射

上次按钮本次按钮Quality含义
HardHard1持续困难,需要重置
EasyHard2退步,降低间隔但不重置
HardEasy4进步中,适度加速
EasyEasy5稳定掌握,最大加速

首次复习(无历史)默认按 Hard→X 处理(Hard=1, Easy=4)。

核心算法

dart
int getQuality({required bool isEasy, required int? lastQuality}) {
  final lastWasEasy = lastQuality != null && lastQuality >= 3;
  if (isEasy) {
    return lastWasEasy ? 5 : 4;  // Easy→Easy=5, Hard→Easy=4
  } else {
    return lastWasEasy ? 2 : 1;  // Easy→Hard=2, Hard→Hard=1
  }
}

Mastery 降级细化

  • q=1(Hard→Hard):降 2 级(与原 Forgot 一致)
  • q=2(Easy→Hard):降 1 级(新增,比原设计更精细)

数据库变更

  • notebook_entries 新增 last_review_quality INTEGER

理由

选择 Plan B 而非 Plan A(回溯修正)的原因

  1. 避免回溯复杂度:Plan A 需要根据本次按钮重新计算上次的质量并修正 EF/interval,存在时间间隔混淆问题
  2. 前向推导更简洁:只需存储上次质量,本次直接算出 4 级分数
  3. SM-2 兼容性好:质量 {1,2,4,5} 覆盖 SM-2 的关键区间,q≥3 晋级 / q<3 重置的核心逻辑不变

选择 {1,2,4,5} 而非 {1,2,3,4} 的原因

  • q=4 是 SM-2 的 EF 不变点(ΔEF ≈ 0),如果最大值只有 4,EF 永远无法增长
  • q=5 允许 EF 增长(ΔEF = +0.1),稳定掌握的词间隔会加速拉长
  • 跳过 q=3 是刻意的:q=3 在 SM-2 中效果平庸(ΔEF = -0.14),2-Button 不需要这个中间值

竞品验证

产品输入方式质量推导策略
Anki Pass/Fail 插件2 按钮Pass/Fail 3 版本用响应时间细化
FSRS官方支持 Again/Good 二元完整复习历史优化难度参数
Duolingo HLR纯二元 correct/incorrectHalf-Life Regression 模型
SuperMemo SM-17/18多按钮但核心是历史推导从整个复习历史推导难度

行业趋势明确支持二元输入 + 历史推导方向。

代价与应对

代价

  • ⚠️ 需新增 last_review_quality 数据库列(Schema v36)
  • ⚠️ 首次复习无历史,退化为 2 级质量(q=1 或 q=4)
  • ⚠️ 现有用户 3-Button 评分历史无法直接映射到新 last_quality

应对措施

  • 数据库迁移:新增列默认 NULL,首次复习按无历史处理
  • 现有数据兼容:last_review_quality 为 NULL 时视为首次复习
  • RB(Tauri)和 RVH(Flutter)同步更新,保持双端一致

影响范围

需要修改的文件

  • lib/core/algorithms/sm2_algorithm.dart — 新增 getQuality() 方法
  • lib/features/vocabulary_notebook/data/datasources/mastery_tracking_datasource.dart — 降级逻辑细化
  • notebook_entries 数据库表 — 新增列
  • 复习页面 UI — 3 按钮 → 2 按钮
  • RB 端 src-tauri/src/commands/srs.rs — 同步修改

决策15:lemmatizer 转词典优先架构

日期:2026-04-28 状态:已接受 ✅

背景

vocab-pk Phase 3 跨端跑分实测原 lemmatizer 误砍 27+ 个常见英文名词:sliver→slivsplinter→splintlawyer→lawymarketing→marketcourier→couryglacier→glac 等。原架构是 4 层启发式:

  1. Layer 0 EXCEPTIONS(手工 ~300 词例外集)
  2. Layer 1 不规则词表(WordNet 5,637 条)
  3. Layer 2 后缀剥离(11 条 SUFFIX_RULES)
  4. Layer 3 fallback

误砍根因:Layer 2 的 -er/-or/-ier/-ar 后缀规则过于激进,靠 Layer 0 手工列例外救回是封闭世界假设 — AGID 词典里 -ier 结尾的英语单词约 1,200 个(marketing/glacier/cashier/...),手工维护例外表永远列不全。

决策

放弃打补丁修例外表,转「词典优先」4 层架构:

  1. Layer 1: SURFACE_TO_BASE 屈折查表"dictionary"
  2. Layer 2: BASE_FORMS 自映射"base"
  3. Layer 3: SUFFIX_RULES 提议 stem + BASE_FORMS 裁决"suffix-validated"
  4. Layer 4: fallback"fallback"

数据源:AGID 2016.01.19(Kevin Atkinson 整理的英语屈折词典,公共领域)+ WordNet 5,637 不规则形人工策展覆盖。资产:

  • assets/nlp/base_forms.json (1.1 MB / 101,646 条)
  • assets/nlp/surface_to_base.json (3.2 MB / 140,371 条)

公共契约保留:normalize(w) := nfc(lemmatize(w.trim().toLowerCase())) 签名 sync 不变,8 个 lib/ 调用点零侵入。

理由

核心变化:原则从「lemma 输出双端等量错也行」转向「lemma 正确优先,跨端一致性由双端共享同一份正确数据保证」。

为什么不修例外表

维度修例外表词典优先
准确率~75-80%~95-97%
维护成本每次发现误砍手工加一条一次切换,长期受益
边界封闭(永远不全)开放(覆盖 AGID 全部)
跨端不变式"等量错"脆弱"byte-equal 数据"明确

为什么选 AGID + WordNet

数据源规模许可屈折覆盖不规则形
AGID 2016100K+公共领域✅ 完整部分
WordNet 3.1155KWordNet 许可部分✅ 完整
AGID + WordNet 合并100K+双兼容

WordNet 不规则形(went→go, children→child, better→good)覆盖 AGID 自动展开的薄弱处。

为什么 Layer 3 改成"提议 + 裁决"

Layer 3 复用现有 _proposeStem(11 条 SUFFIX_RULES),但只在提议的 stem 在 BASE_FORMS 里时才接受。这给新词(AGID 2016 之后出现的词,如 tweeted)一个 fallback 通道:tweeted-ed 提议 tweettweet ∈ BASE → 接受。语义从 Layer 2 主动剥离器降级为 Layer 3 的 stem 提议器,行为不变但调用链变保守。

代价与应对

代价

  • ⚠️ APK 包体增加 ~4.4 MB(2 个 JSON 资产)
  • ⚠️ 启动期增加 ~30-50ms(Future.wait 并行加载,可被其他 init 任务遮蔽)
  • ⚠️ 已入库错 lemma(splinter→splint 等历史脏数据)需独立 SQL 修复任务清理
  • ⚠️ AGID 2016 后停维护,新词(podcasting / vlogging / tweeting 等)部分进 Layer 4 fallback
  • ⚠️ AGID 数据有少量不理想映射(trying→trie / during→dure / means→mean),属同形异类多义词需消歧,待 T-B 解决

应对措施

  • 启动期 Future.wait 并行加载,不阻塞 runApp
  • 新词覆盖通过定期重跑 build_dict 合并 Wiktionary dump 解决(T-C 季度任务)
  • 多义消歧通过 Map<String, List<String>> + UI 选择解决(T-B 独立任务)
  • 跨端不变式:CLAUDE.md §跨端 Sync 协议红线 #5e — 资产 SHA256 双端 byte-equal,资产更新 = 双端原子提交。任何一端单方面改资产会立刻破坏 vocabulary.word PK 一致性 → sync 静默丢词

影响范围

必须同步的双端

  • RB (Reading Browser, Tauri/Rust) — commit 9a3e302
  • RVH (Reading Vocab Helper, Flutter/Dart) — 本批 commit
  • 必须同周发版,否则 vocabulary.word PK 双端不一致

修改/新增

  • lib/core/nlp/lemmatizer.dart — 4 层重写(679 → 134 行)
  • lib/core/nlp/lemmatizer_flutter_loader.dart — 新增(rootBundle 加载)
  • lib/main.dart — 启动期并行 preload
  • bin/phase0_normalize.dart — 双端 byte-equal CSV 跑分入口
  • assets/nlp/base_forms.json + assets/nlp/surface_to_base.json — 新增资产
  • pubspec.yaml — 资产声明
  • 测试:6 个新 Bug B 单测 + 公共 setUp helper + 90 词回归清单

删除

  • lib/core/nlp/data/irregular_forms.dart — 5,651 行 dead code(AGID + WordNet 已覆盖)
  • lemmatizer.dart_exceptions 手工词集 — 约 300 词
  • tools/phase0_normalize/ — 迁移到 bin/phase0_normalize.dart

关联


决策16:CEFR 评估改用 LLM 重评 + 正交字段建模

日期:2026-05-12 / 2026-05-13 状态:已接受 ✅

背景

预装词库(Oxford 5000 + CEFR-J)权威覆盖 A1-B2 + 部分 C1 共 7140 词。剩余 C1/C2 由 wordlist_builder.dart 用 Google web 频率反推:

dart
final inferred = rank < 10000 ? 'C1' : 'C2';

频率反推对 C1/C2 估算严重偏离实际难度:

  • 低频但简单的词被高估(aardvark → C2)
  • 高频但抽象的词被低估
  • 5193 个 freq 反推词中存在大量明显错评

决策

1. LLM 二次评估:用 DeepSeek 对所有 cefrInferred=true 词重新评估 CEFR,输出按"语言学习难度"而非频率分级。

2. CEFR 正交字段建模(v48/v49/v50 三步加列):

  • primary_cefr_level (TEXT) — 严格 A1-C2 6 值
  • cefr_inferred (BOOLEAN) — 是否为推断值
  • cefr_source (TEXT) — 'oxford' / 'cefr_j' / 'llm' / 'freq' / 'fallback'
  • word_tags (TEXT, JSON array) — 词类型标签(proper_noun / abbreviation / symbol)

3. UI 层透传 "约 X" 标识:cefr_inferred=true 时显示 "约 C2" + 灰化 + tooltip "基于词频/AI 估算"。

理由

为什么 LLM 重评(vs 接受 freq 反推 / vs 找权威数据集)

  • EVP(English Vocabulary Profile)商用付费,无法用
  • Octanove (CC BY-SA 4.0) SA 传染性,闭源商业化风险
  • LLM 重评是当前最佳折中:DeepSeek $0.34 / 5193 词,准确率从 60-65% 提升到 80-85%(基于人工抽样估算)

为什么正交字段建模(vs 把"inferred"塞进 enum)

  • 「值」和「可信度」是两个正交维度
  • 不要污染 enum:A1, A2, ..., C2, APPROX_C2 会让 SQL/筛选/统计全爆炸
  • 字段语义纯粹:cefr_source 只描述"难度等级怎么来的",不描述"是否参考词"
  • UI 字符串拼接 ≠ 数据建模:DB 永远存 'C2' + inferred=true,UI 渲染 "约 C2"

为什么加 word_tags 而非合并到 cefr_source

  • 词类型(proper_noun / abbreviation / symbol)跟 CEFR 难度正交
  • 多 tag 共存场景(NASA = proper_noun ∧ abbreviation)单字段 enum 无法表达
  • JSON array 方便未来扩展 'archaic' / 'medical' 等类型

实际数据效果

LLM 重评 5193 词:

  • 90.2% CEFR 改变(C2→B2 / C2→B1 / C1→B2 最常见)
  • CEFR 分布从「头重脚轻(C2 占 40%)」修正到「中部最厚(B1+B2 占 53.7%)」
  • 956 词识别为参考词(proper_noun 765 / abbreviation 199 / symbol 8)

预装库总数:4953 → 12291(含 LLM 重评 + multi-CEFR 数据修正)。

代价与应对

代价

  • LLM 评估有 ~5-10% 错评率(用户报告 abbreviation → B1 等案例)
  • Schema 增加 3 列(cefr_inferred / cefr_source / word_tags)跨端同步成本
  • 维护 pipeline 增加 Step 5(evaluate_cefr)依赖 LLM API

应对

  • 已立项「演进 5:双模型交叉验证」用 Claude Sonnet 二轮 review,预期错评率 ~10% → ~3%
  • 跨端 schema 一致性原则(plan 原则 4)+ schema 对照文档显式追溯
  • evaluator 加 checkpoint + resume + SIGINT graceful shutdown 保证可重跑

关联

  • 实施计划:plans/cefr-llm-evaluation-and-ui-passthrough.md
  • Schema 对照:database/cross-end-schema-compatibility.md
  • 关键设计原则(plan 原则 1-5):值⊥可信度 / 字段语义纯粹 / UI 拼接 ≠ 数据建模 / 三端 schema 一致 / 主表 vs 参考词表边界
  • 实施 commits:bbedeab (v48/v49) / 3722fab (v50 word_tags) / 34248b7 (evaluate_cefr 全量) / 313f501 (预装库 v7) / 87797fa (UI "约 X" + 单 path 重构) / c90f5b1 (wordlist loader CSV bug + multi-CEFR 恢复) / 8a471a6 (Supabase v51 + FK RESTRICT) / affca84 (Edge Function pos.cefr 一致性)
  • 验收:5193 词全评估 0 失败;abatement / hong / paramount / arm / above 等典型词数据一致性 ✅;设备 v51 部署 12291 词导入 2.8s ✅

决策17:vocabulary → user 表 FK CASCADE → RESTRICT

日期:2026-05-13 状态:已接受 ✅

背景

notebook_entries.word REFERENCES vocabulary(word) ON DELETE CASCADE 在两类场景下成为 footgun:

  1. 红线 #6b 实证:sync pull 路径用 INSERT OR REPLACE 触发 DELETE+INSERT,CASCADE 删子表 notebook_entries
  2. vocabulary 重建讨论实证(2026-05-13):直接 DROP/TRUNCATE vocabulary 会 CASCADE 清掉 user_notebook_entries (276 行) + user_excluded_words (15 行)

vocabulary 是「字典层数据」,业务上不应该被业务删除。CASCADE 把"父表行删除"的破坏力放大到子表,违反 least surprise。

决策

vocabulary → user 表的 FK 改 ON DELETE RESTRICT

  • 本地 SQLite notebook_entries.word → vocabulary.word
  • Supabase user_notebook_entries.word → vocabulary.word
  • Supabase user_excluded_words.word → vocabulary.word

保留 CASCADE 的关系(业务语义合理):

  • reading_sources.note_id → reading_notes.id(删 note 时清相关 sources)
  • word_sources.notebook_entry_id → notebook_entries.id(删 entry 时清词源)
  • word_sources.reading_source_id → reading_sources.id(删 source 时清词源)

理由

判断原则:

  • CASCADE 适用:业务上"父行被删 ⇒ 子行必然失去意义"(如 note→sources 链)
  • RESTRICT 适用:父行是"字典/参考数据",业务上不应被随便删(如 vocabulary)

RESTRICT 的副作用:未来真正需要清 vocabulary 行(如过期 backfilled 词)时要先显式清子表引用——这是特性而非 bug,强制开发者意识到子表影响。

代价与应对

代价

  • 跨端 schema 一致性 — RVH + RB + Supabase 三端要同步改
  • 真正需要清 vocabulary 行时增加显式步骤

应对

  • v51 一次性彻底改三端 schema(commit 8a471a6
  • 加 schema 对照文档(cross-end-schema-compatibility.md)让"三端必须同步改"显式可追溯
  • RB 端同步改动需用户在 RB 项目下次会话单独执行(schema 对照文档已列出 checklist)

关联

  • 红线 #6b 同源:CLAUDE.md §跨端 Sync 协议红线
  • 实施 commit:8a471a6
  • 验收:Supabase 端 SQL SELECT delete_rule FROM information_schema.referential_constraints WHERE constraint_name LIKE '%_word_fkey' → 全 RESTRICT ✅

修订 2026-05-21:Supabase 端两条 word_fkey 整体 DROP

背景:v51 把 Supabase vocabulary 从 94063 全量词典清成「共享缓冲池」(仅靠 Edge Function 兜底回填)。12,291 预装词只在双端本地 reading_vocab.db 字节资产里,不在 Supabase vocabulary。RB 端干净测试流程暴露:用户 push notebook/excluded 行时撞 PostgREST 23503 Key is not present in table vocabulary —— 缓冲池里没有这个词。

决策:DROP Supabase 端两条 FK:

  • user_notebook_entries.word → vocabulary.word → DROP
  • user_excluded_words.word → vocabulary.word → DROP

保留:RVH/RB 本地 SQLitenotebook_entries.word → vocabulary.word FK(RESTRICT 不变),设备内一致性仍由本地 FK 把关。

理由:缓冲池模型下 Supabase FK 无业务价值——

  1. 双端本地 FK 已经守门,设备内不会出现 dangling word
  2. Supabase vocabulary 是「按需回填的共享缓存」,非权威字典
  3. 跨端 word PK 一致性由红线 #5e(双端 byte-equal lemmatizer 资产)保证,不依赖 Supabase FK

实施

  • Supabase Dashboard 已执行 DROP(RB 端 2026-05-21 发起)
  • 落地文件:supabase/migrations/20260521_drop_user_word_fkeys.sql(幂等记录;该目录 2026-08-28 已删,见 git 历史)
  • RB 跨端日志:~/reading-browser/docs/cross-end/02-rvh-v51-sync-log.md

决策18:autoSync 的宿主放在 app 根(Navigator 之上),不放页面

日期:2026-08-27 状态:已接受 ✅

背景

autoSyncProvider(60s 轮询 + 登录后首次同步)原本是 Provider.autoDispose, 全仓只有两个 watcher:review_overview_page.dartauth_section.dart(在 ProfilePage 里)。 autoDispose 把一个后台服务的存活期绑在了页面挂载上。

实际存活条件比看上去更刁钻:不是「停在 Review 页」,而是 「Review 页或 ProfilePage 在 Navigator 栈的任意一层」 —— MaterialPageRoute 默认 maintainState: true,从 Review push 进生词本时 Review 仍挂着,定时器照旧活着; 三个 tab 之间的 pushReplacement 才真的拆掉它。

同一个生词本页,从 Review 进去同步就跑、从 Me 进去就不跑。冷启动落在 ScanPage, 不主动点进 Review 的话周期性同步一次都不跑;用户在生词本删了词,可以长时间不上行。 2026-08-27 真机验证红线 #6g 时先后观测到「删词 20 秒后上行」与「等 30 分钟纹丝不动」, 两条互相矛盾的现象正是这两条路径的差别。

不是有意的省电策略review_overview_page.dart 原注释写的就是 「Start auto-sync when signed in (app-wide trigger)」—— 意图一直是 app 级,落点放错了。

决策

  1. autoSyncProviderProvider.autoDispose 改普通 Provider
  2. 唯一宿主 = main.dart::_AppServicesHost,挂在 MaterialApp.builder
  3. 生命周期由 provider 内部的 AppLifecycleListener 管:前台 60s 轮询(频率不变), 切后台停表,回前台恢复并立刻同步一次(10s 防抖);
  4. 前置SyncRepositoryImpl.syncNow 加防重入守卫(合流语义);
  5. stopwordsMergeProvider 是同款缺陷(同样只被那两个页面 watch),一并挪进同一个宿主 —— 宿主因此叫 _AppServicesHost 而不是 _AutoSyncHost

理由

为什么宿主必须在 MaterialApp.builder(= Navigator 之上)home: 和任何页面都会被 tab 切换的 pushReplacement 换掉 —— 那正是本缺陷本身。builder 的子树跨路由切换一直活着。

为什么必须等 Supabase init 完成才 watchstartupInitializedProvider): _MyAppState.buildrunApp 时就跑,远早于 init。提前 watch 会经 syncRepositoryProviderauthStateProviderAuthRepositoryImplSupabase.instance 就绪前被构造,并永久缓存成 error 状态_heavyInitAndResolve 里早有这条告诫)。本次第一版就踩了,靠重启前 P0 自查捞回来。

为什么防重入是硬前提:旧代码只有一个定时器,两轮物理上撞不上。加了恢复触发后 「定时器这一拍」与「回前台」会真并发,而 syncNow 内部机制都不是为并发设计的 —— watermark 推进(红线 #5d)两轮各写一次 _setLastSyncAt,会把水位往回盖、 或把另一轮尚未落地的行的水位提前盖掉(水位不回头 ⇒ 永久丢行); cloze reconcile(红线 #5g)两轮各自削同一个池子,可能双方都软删、削到 cap 以下。

防重入的合流语义对登出 flush 不成立signOut 的 flush 之后紧接着 clearLearningDataForUser 清本地,合流到一轮可能已跑过 push 阶段的 sync ⇒ 用户最后的改动永久丢失。方向值得记住 —— 没有守卫时 flush 必然自己跑完整一轮、 反而不会漏,所以这是防重入引入的风险。故 syncNow{bool ensureFresh = false},true 走排队分支,仅该路径使用。 推广判据:凡「之后会销毁本地状态」的 flush 型调用,都不能合流到 in-flight 轮次。

stopwordsMergeProvider 时必须先加空串守卫currentUserIdProvider 在「未登录」 和「auth 尚未 resolve」时返回的是空串不是 null。挂在页面上时碰巧安全(那两个 widget 只在已登录时渲染);挪到 app 根后宿主在 init 完成那一刻就 watch,authState 很可能 还在 loading ⇒ 会插进 211 行 user_id = '' 的孤儿,且读侧看不见、push 推不走、 clearLearningDataForUser 也删不掉(它匹配 = ?IS NULL,不匹配 '')。 这是挪动新开的暴露面,不是顺手加的防御性代码。

📌 可复用的判据:要不要挂进 _AppServicesHost,看的不是「有没有用 autoDispose」,而是「它是不是后台服务 / 登录态 hook」 —— 页面级派生状态用 autoDispose 挂在页面上是对的。而凡是往 app 根挪的,都要重新检查它对 「未登录 / auth 未 resolve」这两个此前够不到的状态是否安全。

被否掉的两个方案

  • 纯事件驱动(删掉轮询,改「写操作后 debounce + 前台恢复」):触发点要撒进各写路径, 漏一个就是一个静默不上行的洞;且拉不到对端的变更(pull 只在恢复时发生)。
  • 维持现状 + UI 提示「有 N 条待同步」:把问题推给用户,跨端场景依然别扭。

同时没有加写操作触发 —— 60s 兜底已足够,而每多一个触发点就多一份「漏挂/重复触发」 的维护面。日后若真需要,防重入守卫已经就位。

代价与应对

代价:前台任何页面都在跑 60s 轮询,耗电/流量高于「只在 Review 页跑」。 应对:切后台 0 请求(实测背景两拍一次都没跑);前台特征与「修复前停在 Review 页时」完全一致。

已知代价(不修):手动同步按钮若恰好按在某轮 sync 的执行窗口内(60s 里的 1–2s), 拿到的是那一轮的结果,用户刚刚的改动等下一拍。概率极低且下一拍必然补上, 故不做「排队再跑一轮」。

关联

  • 立项与裁决过程:backlog.md「✅ P2 — autoSync 只在 Review 页活着」
  • as-built:CHANGELOG.md(v63 条)
  • 跨端知会:docs/cross-end/39 §6.4
  • 回归:test/features/sync/auto_sync_trigger_test.dart(9 例,八处注入缺陷实证)
  • stopwordsMergeProvider 的立项与裁决:backlog.md「✅ P3」
  • ⏳ 遗留:ensureFresh 的登出路径未上真机,见 backlog.md「🟢 P3」