主题
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.dart | Flutter 启动期 preload helper(rootBundle 读取 → 转发到 Lemmatizer.preloadFromJsonStrings) |
bin/phase0_normalize.dart | CLI 工具(dart:io File 读取资产 → preload → 输出 CSV)。替代旧 tools/phase0_normalize/main.dart。 |
test/data/lemma-regression.txt | 90 词回归清单(handoff 附录 A 原文) |
test/core/nlp/lemmatizer_test_setup.dart | 测试公共 setUp(dart:io 读取 assets/nlp/*.json 并 preload) |
test/core/nlp/lemmatizer_dictionary_test.dart | Bug 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.yaml — flutter.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.dart | 5,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.dart | 13 | 改写 setUp + 修正 spaces 断言 |
test/core/nlp/lemmatizer_test.dart | 待统计 | 加 setUp,按需更新 buggy 断言 |
test/core/nlp/lemmatizer_dictionary_test.dart | 6 | 新增(Bug B 27 词 + idempotent + 各 layer) |
验收标准
- ✅
flutter analyze0 warning - ✅
assets/nlp/{base_forms,surface_to_base}.jsonSHA256 与 RB 端完全一致 - ✅
flutter test test/core/nlp/全过 - ✅ 双端 phase0_normalize 跑 90 词清单 → CSV diff 为空
- ✅ Bug B 27 词全部
layer == 'base'(不再被错砍) - ✅
flutter run启动正常,OCR 流程词形还原行为正确 - ✅ 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.dartbin/phase0_normalize.darttest/data/lemma-regression.txttest/core/nlp/lemmatizer_test_setup.darttest/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 verifiedassets/nlp/surface_to_base.json✅ SHA256 verifiedlib/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)
提交策略
按"代码提交规则" + "阶段性提交":
- Commit 1:核心代码改造(assets + lemmatizer.dart + flutter_loader + pubspec + main.dart wiring + bin/phase0_normalize.dart + 删 irregular_forms
- 删 tools/phase0_normalize/)
- Commit 2:测试(test_setup + dictionary_test + 修正现有 normalize_test)
- 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 静默丢词。