Skip to content

Bug B RVH 端:lemmatizer 词典优先架构同步

Context

vocab-pk Phase 3 实测 RB 端原 lemmatizer 误砍 27+ 常见英文名词(sliver→sliv, splinter→splint, lawyer→lawy, marketing→market...)。原 4 层启发式(例外 表 → 不规则表 → 后缀剥离 → fallback)的 Layer 0 EXCEPTIONS 手工列 ~300 词救回 是封闭世界假设——例外永远列不完。

RB 端已转「词典优先」架构(commit 9a3e302):AGID 2016.01.19 + WordNet 5,637 不规则形 → JSON 资产 base_forms.json (101,646 lemmas) + surface_to_base.json (140,371 mappings)。准确率从 ~75-80% 提升到 ~95-97%。

双端协议红线:lemma 输出一致由「双端共享同一份正确数据」保证。任何一端 单方面改资产或算法 → vocabulary.word 跨端 PK 错位 → sync 静默丢词。RB 端已 就绪等待 RVH 同步发版。

本次任务范围:把 RB 端架构原样落 RVH Dart 端,保证双端 phase0_normalize CSV diff 为空。在范围:数据修复 SQL(待观察一周后单独写)、人名/地名治理 (T-A)、多义消歧(T-B)、新词扩充(T-C)。

资产已就位(SHA256 byte-equal)

assets/nlp/base_forms.json        3094c829fee1bcaf430917a9f77377d146cc93e8f6c591cd622b45381f7deadc  ✅
assets/nlp/surface_to_base.json   1a468a82d7f1d58844bc039484e3759562ea07d5078f12db59feca0fb90d8c99  ✅

关键架构决策

1. 保持 lib/core/nlp/lemmatizer.dart 纯 Dart(无 Flutter 依赖)

文件头注释明确:"故意不 import AppLogger,保持本模块为纯 Dart,以便 standalone 工具(如 tools/phase0_normalize)可在命令行直接运行"。这条约束保留。

实现:lemmatizer.dart 不读取 JSON 文件,而是接受 JSON 字符串作为预加载 输入。读取 I/O 由调用方负责(Flutter 用 rootBundle,CLI/test 用 dart:io File)。

2. 保持 lemmatize() / normalize() sync API

8 个 lib/ 调用点 + 现有测试都用 sync API(OCR pipeline 内部循环不能 await)。 新架构通过启动期 sync Lemmatizer.preloadFromJsonStrings(...) 一次性加载, 之后所有调用同步返回。未预加载即调用 → 抛 StateError

3. Layer 名映射(CSV byte-equal 关键)

RB 端 layer 字符串:empty | dictionary | base | suffix-validated | fallback。 RVH 必须使用完全相同的字符串(见 bin/phase0_normalize.dart 输出)。

4. propose_stem 行为对齐

RB Rust 伪代码 if let Some(stem) = propose_stem(&lower) 是单值返回——首条 命中规则的 stem。复用现有 _applySuffixRules(首规则命中即返回)即可对齐。 不改动 data/suffix_rules.dart 11 条规则。

5. NFC 步骤保留

Dart String.toLowerCase() 不做 Unicode normalization。现有 unorm.nfc(...) 步骤是双端 byte-equal 的核心保证(naïve NFD↔NFC 探针测试)。保留。

改动清单

A. 新增

路径内容
assets/nlp/base_forms.json✅ 已拷贝 (1.1MB, SHA256 verified)
assets/nlp/surface_to_base.json✅ 已拷贝 (3.2MB, SHA256 verified)
lib/core/nlp/lemmatizer_flutter_loader.dartFlutter 启动期 preload helper(rootBundle 读取 → 转发到 Lemmatizer.preloadFromJsonStrings)
bin/phase0_normalize.dartCLI 工具(dart:io File 读取资产 → preload → 输出 CSV)。替代旧 tools/phase0_normalize/main.dart
test/data/lemma-regression.txt90 词回归清单(handoff 附录 A 原文)
test/core/nlp/lemmatizer_test_setup.dart测试公共 setUp(dart:io 读取 assets/nlp/*.json 并 preload)
test/core/nlp/lemmatizer_dictionary_test.dartBug B 6 个新单测(详见下方"单测列表")

B. 改写

lib/core/nlp/lemmatizer.dart(核心改动):

dart
import 'dart:convert';
import 'package:unorm_dart/unorm_dart.dart' as unorm;
import 'data/suffix_rules.dart';  // 保留

class Lemmatizer {
  static Lemmatizer? _instance;

  // 数据由 preloadFromJsonStrings 注入;未预加载即调用 → StateError
  final Set<String> _baseForms;
  final Map<String, String> _surfaceToBase;
  final List<SuffixRule> _suffixRules = SuffixRules.rules;

  Lemmatizer._({
    required Set<String> baseForms,
    required Map<String, String> surfaceToBase,
  })  : _baseForms = baseForms,
        _surfaceToBase = surfaceToBase;

  factory Lemmatizer() {
    final i = _instance;
    if (i == null) {
      throw StateError(
        'Lemmatizer not loaded. Call Lemmatizer.preloadFromJsonStrings(...) '
        'at app startup (or test setUp) before any lemmatize/normalize call.',
      );
    }
    return i;
  }

  /// 启动期一次性加载。Flutter 端用 rootBundle 读 JSON 后调用;
  /// CLI/test 用 dart:io File 读 JSON 后调用。幂等:重复调用 no-op。
  static void preloadFromJsonStrings({
    required String baseFormsJson,
    required String surfaceToBaseJson,
  }) {
    if (_instance != null) return;
    final bf = json.decode(baseFormsJson) as Map<String, dynamic>;
    final s2b = json.decode(surfaceToBaseJson) as Map<String, dynamic>;
    final baseSet = (bf['base'] as List).cast<String>().toSet();
    final s2bMap = (s2b['map'] as Map<String, dynamic>).cast<String, String>();
    _instance = Lemmatizer._(baseForms: baseSet, surfaceToBase: s2bMap);
  }

  /// 测试用:reset for re-load. 仅 @visibleForTesting.
  static void resetForTesting() => _instance = null;

  /// 词形还原 + 命中层标记。
  ///
  /// Layer 顺序(RB Rust 端 byte-equal):
  /// 1. SURFACE_TO_BASE 屈折查表 → "dictionary"
  /// 2. BASE_FORMS 自映射 → "base"
  /// 3. _proposeStem + BASE_FORMS 裁决 → "suffix-validated"
  /// 4. fallback → "fallback"
  /// 空串 → ('', "empty")
  (String, String) lemmatizeWithLayer(String word) {
    if (word.isEmpty) return ('', 'empty');
    final lower = word.toLowerCase();

    final base = _surfaceToBase[lower];
    if (base != null) return (base, 'dictionary');

    if (_baseForms.contains(lower)) return (lower, 'base');

    final stem = _proposeStem(lower);
    if (stem != null && _baseForms.contains(stem)) {
      return (stem, 'suffix-validated');
    }

    return (lower, 'fallback');
  }

  /// 兼容老 API:丢弃 layer 信息。
  String lemmatize(String word) => lemmatizeWithLayer(word).$1;

  /// 跨端归一化:trim → toLowerCase → lemmatize → NFC。
  /// 公共契约不变(双端协议)。
  String normalize(String word) {
    if (word.isEmpty) return word;
    final lemma = lemmatize(word.trim().toLowerCase());
    return unorm.nfc(lemma);
  }

  /// Layer 3 stem 提议器:从 11 条 SUFFIX_RULES 拿首条命中的 stem,不做有效性
  /// 裁决(裁决在 lemmatizeWithLayer 用 BASE_FORMS 做)。
  String? _proposeStem(String word) {
    for (final rule in _suffixRules) {
      final result = rule.apply(word);
      if (result != null) return result;
    }
    return null;
  }

  /// 调试辅助:返回 input/output/layer/loaded-counts。
  Map<String, dynamic> lemmatizeDebug(String word) {
    final (out, layer) = lemmatizeWithLayer(word);
    return {'input': word, 'output': out, 'layer': layer};
  }

  Map<String, dynamic> getStats() => {
        'baseFormsCount': _baseForms.length,
        'surfaceToBaseCount': _surfaceToBase.length,
        'suffixRulesCount': _suffixRules.length,
      };
}

改动要点

  • ❌ 删除 _exceptions(~300 词手工集,被 _baseForms 完全覆盖)
  • ❌ 删除 import 'data/irregular_forms.dart'(5,637 词不规则表,已合并入 surface_to_base.json
  • ✅ 保留 _suffixRules + _proposeStem(语义从"剥离器"降级为"stem 提议器")
  • ✅ 保留 normalize() sync 公共契约 + NFC 步骤
  • ✅ 保留 Lemmatizer() factory 单例语义(现增加 preload 前置条件)

lib/main.dart(启动期 wiring):

final results = await Future.wait([...]) 数组中加入第 4 个并行任务:

dart
import 'core/nlp/lemmatizer_flutter_loader.dart';
...
await Future.wait([
  AppConfig.load(...),
  SharedPreferences.getInstance(),
  DeepLinkService.instance.initialize(),
  loadLemmatizerFromAssets(),  // ← 新增
]);

lemmatizer_flutter_loader.dart

dart
import 'package:flutter/services.dart' show rootBundle;
import 'lemmatizer.dart';

Future<void> loadLemmatizerFromAssets() async {
  final bf = await rootBundle.loadString('assets/nlp/base_forms.json');
  final s2b = await rootBundle.loadString('assets/nlp/surface_to_base.json');
  Lemmatizer.preloadFromJsonStrings(
    baseFormsJson: bf,
    surfaceToBaseJson: s2b,
  );
}

pubspec.yamlflutter.assets: 块新增:

yaml
    - assets/nlp/

bin/phase0_normalize.dart(替代旧 tools/phase0_normalize/main.dart):

dart
// CLI: 读 stdin(一行一词),输出 input,output,layer CSV 到 stdout。
// 用法:
//   dart run bin/phase0_normalize.dart < test/data/lemma-regression.txt > /tmp/rvh-output.csv
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:reading_vocab_helper/core/nlp/lemmatizer.dart';

Future<void> main() async {
  // 从仓库根目录解析 assets 路径(dart run 的 cwd 是项目根)。
  final bf = await File('assets/nlp/base_forms.json').readAsString();
  final s2b = await File('assets/nlp/surface_to_base.json').readAsString();
  Lemmatizer.preloadFromJsonStrings(baseFormsJson: bf, surfaceToBaseJson: s2b);
  final lem = Lemmatizer();

  stdout.writeln('input,output,layer');

  final lines = await stdin
      .transform(utf8.decoder)
      .transform(const LineSplitter())
      .toList();

  for (final raw in lines) {
    if (raw.isEmpty) continue;
    if (raw.startsWith('#')) continue;
    final input = raw == '<EMPTY>' ? '' : raw;
    final trimmed = input.trim();
    final lowered = trimmed.toLowerCase();
    // normalize 输出(含 NFC + lemma)
    final output = lem.normalize(input);
    // layer 来自 lemmatizeWithLayer(输入需先 trim+lower 与 normalize 内部一致)
    final (_, layer) = lem.lemmatizeWithLayer(lowered);
    final inputRepr = trimmed.isEmpty ? '<EMPTY>' : _csv(trimmed);
    final outputRepr = output.isEmpty ? '<EMPTY>' : _csv(output);
    stdout.writeln('$inputRepr,$outputRepr,$layer');
  }
}

String _csv(String s) =>
    (s.contains(',') || s.contains('"') || s.contains('\n'))
        ? '"${s.replaceAll('"', '""')}"'
        : s;

C. 删除

路径理由
lib/core/nlp/data/irregular_forms.dart5,651 行 WordNet 不规则表,已合并入 surface_to_base.json;除 lemmatizer.dart 外无其他引用。
tools/phase0_normalize/main.dart移到 bin/phase0_normalize.dart(更符合 Dart pkg 惯例 + 用户在交接稿明确指定 bin/)。
tools/phase0_normalize/phase0_normalize旧编译产物(已 gitignore,确认目录清理)。

D. 测试更新

test/core/nlp/lemmatizer_test_setup.dart(新增公共 setUp):

dart
import 'dart:io';
import 'package:reading_vocab_helper/core/nlp/lemmatizer.dart';

Future<void> setUpLemmatizerForTest() async {
  Lemmatizer.resetForTesting();
  final bf = await File('assets/nlp/base_forms.json').readAsString();
  final s2b = await File('assets/nlp/surface_to_base.json').readAsString();
  Lemmatizer.preloadFromJsonStrings(baseFormsJson: bf, surfaceToBaseJson: s2b);
}

test/core/nlp/lemmatizer_normalize_test.dart 改动:

  • setUpAll(() async { await setUpLemmatizerForTest(); ... })
  • ⚠️ 修正 "spaces → spac" assertion:新架构 spaces 命中 Layer 1(s/spaces 在 surface_to_base.json)→ 应为 space。这是 Bug B 修复后的正确行为; 原测试注释 "已知算法局限,双端等量翻车" 现已不适用,更新断言。
  • 其他断言(books→book, running→run, went→go, children→child, wolves→wolf, café/naïve NFC 探针)保持不变。

test/core/nlp/lemmatizer_test.dart(已存在的旧测试):

  • setUpAll(() async { await setUpLemmatizerForTest(); })
  • 检查并更新任何依赖旧 _exceptions 行为的具体断言(需读全文确认;初步预期 正确性提升的断言保持,记录 buggy-behavior 的断言更新)。

test/core/nlp/lemmatizer_dictionary_test.dart(新增 6 个 Bug B 单测):

1. normalize_collapses_nfc_and_nfd_to_equal_bytes
   — naïve NFC vs NFD → 输出 byte-equal(已有 test 覆盖,迁移到此)
2. normalize_pipeline_trim_lowercase_lemmatize
   — '  Running  ' → 'run'
3. normalize_with_layer_reports_dictionary_layer
   — running → ('run', 'dictionary')
4. bug_b_27_words_preserved_as_base
   — 27 词全部 lemmatizeWithLayer == (self, 'base'):
     sliver/splinter/holler/carrier/courier/terrier/marketing/lawyer/
     soldier/partner/lobster/plaster/slipper/quiver/shiver/beaver/
     fever/murder/lever/hover/rover/clever/barrier/pier/cashier/
     chandelier/glacier
5. layer3_suffix_validated_only_when_stem_is_real_base
   — tweeted → ('tweet', 'dictionary' 或 'suffix-validated' 视 AGID)
   — xyznotaword → ('xyznotaword', 'fallback')
6. normalize_is_idempotent
   — 对 1000 个常见词,normalize(normalize(w)) == normalize(w)

E. 双端 byte-equal 验证

bash
# RB 端跑(在 RB 仓)
cd /Users/larry/reading-browser/src-tauri
cargo run --example phase0_normalize --quiet \
  < /Users/larry/reading_vocab_helper/test/data/lemma-regression.txt \
  > /tmp/rb-output.csv

# RVH 端跑(在本仓)
cd /Users/larry/reading_vocab_helper
dart run bin/phase0_normalize.dart \
  < test/data/lemma-regression.txt \
  > /tmp/rvh-output.csv

# 必须为空
diff /tmp/rb-output.csv /tmp/rvh-output.csv

单测列表(汇总)

文件数量状态
test/core/nlp/lemmatizer_normalize_test.dart13改写 setUp + 修正 spaces 断言
test/core/nlp/lemmatizer_test.dart待统计加 setUp,按需更新 buggy 断言
test/core/nlp/lemmatizer_dictionary_test.dart6新增(Bug B 27 词 + idempotent + 各 layer)

验收标准

  1. flutter analyze 0 warning
  2. assets/nlp/{base_forms,surface_to_base}.json SHA256 与 RB 端完全一致
  3. flutter test test/core/nlp/ 全过
  4. ✅ 双端 phase0_normalize 跑 90 词清单 → CSV diff 为空
  5. ✅ Bug B 27 词全部 layer == 'base'(不再被错砍)
  6. flutter run 启动正常,OCR 流程词形还原行为正确
  7. ✅ vocabulary.word / notebook_entries 既有数据不动(数据修复独立任务)

关键文件路径

修改

  • lib/core/nlp/lemmatizer.dart (核心改写)
  • lib/main.dart (preload wiring)
  • pubspec.yaml (assets 声明)
  • test/core/nlp/lemmatizer_normalize_test.dart (setUp + spaces 断言)
  • test/core/nlp/lemmatizer_test.dart (setUp + 按需更新)

新增

  • lib/core/nlp/lemmatizer_flutter_loader.dart
  • bin/phase0_normalize.dart
  • test/data/lemma-regression.txt
  • test/core/nlp/lemmatizer_test_setup.dart
  • test/core/nlp/lemmatizer_dictionary_test.dart

删除

  • lib/core/nlp/data/irregular_forms.dart (5,651 行 dead code)
  • tools/phase0_normalize/main.dart (迁移到 bin/)
  • tools/phase0_normalize/phase0_normalize (旧编译产物)

已就位(不动)

  • assets/nlp/base_forms.json ✅ SHA256 verified
  • assets/nlp/surface_to_base.json ✅ SHA256 verified
  • lib/core/nlp/data/suffix_rules.dart (11 条规则保留)
  • unorm_dart 依赖(已在 pubspec.yaml)

范围内增项(用户已确认 2026-04-28)

lib/core/nlp/data/irregular_forms.dart 删除(5,651 行 dead code 清理) ✅ tools/phase0_normalize/ 删除,迁移到 bin/phase0_normalize.dart ✅ CLAUDE.md §"跨端 Sync 协议红线" 增加 #5e:lemmatizer 资产 base_forms.json + surface_to_base.json 双端 SHA256 必须 byte-equal。 任何一端单方面改资产 → vocabulary.word PK 双端错位 → sync 静默丢词。 资产更新 = 双端原子提交。

不在范围(独立任务)

  • 数据修复 SQL(清理 RVH 已入库错 lemma;先跑一周观察 fallback 日志再写)
  • T-A 人名/地名治理(James→jam, Paris→pari)
  • T-B 同形异类多值消歧(lives, saw)
  • T-C 新词扩充(podcasting/vlogging)
  • T-D 拼写检查模块
  • Edge Function lookup-or-fetch-word 配套
  • Bug C(公共 vocabulary 缺词 + 5xx)

提交策略

按"代码提交规则" + "阶段性提交":

  1. Commit 1:核心代码改造(assets + lemmatizer.dart + flutter_loader + pubspec + main.dart wiring + bin/phase0_normalize.dart + 删 irregular_forms
    • 删 tools/phase0_normalize/)
  2. Commit 2:测试(test_setup + dictionary_test + 修正现有 normalize_test)
  3. Commit 3:CLAUDE.md §5e 红线 + CHANGELOG.md 记录

每 commit 走 /code-review + /doc-sync-check 流程。

Plan 文件同步

按用户偏好,本 plan 同时保存两份:

  • ~/.claude/plans/zippy-twirling-hopper.md(Claude 默认随机名,本会话工作副本)
  • <project>/docs/plans/lemmatizer-dictionary-refactor-rvh.md(项目内语义化命名, 与 RB 端 ~/reading-browser/docs/plans/lemmatizer-dictionary-refactor-plan.md 对齐,进 git 留存)

执行时立即拷贝项目副本(ExitPlanMode 后第一步)。

双端发版协调

RB commit 9a3e302 已就绪。RVH 本批 commit 完成 + 双端 CSV diff 为空后,双端 同周发版。如只发一端 → vocabulary.word PK 双端不一致 → sync 静默丢词。