Skip to content

Commit e4213e3

Browse files
committed
OPT: 查词归一化统一为单一 normalizeWord 路径
- normalizeWord:trim + 弯撇号(’‘ʼ'`´)归一为直撇号 + 剥首尾标点(右直撇号除外) + 小写 - 去掉全大写缩写特殊处理,统一小写化 - 归一化收敛到点击入口(word_dictionary_sheet),查一次词只归一化一次; controller/本地/AI/网页源均透传 request.word,不再各自归一 - 修复弯撇号导致的 I’d 查词不命中 / 粗体标题渲染异常 - AI 查词成功后标题用 result.headword(补测试锁定) - 补充 normalizeWord / 各源 / controller / widget 测试
1 parent 49c825b commit e4213e3

14 files changed

Lines changed: 203 additions & 66 deletions

lib/providers/dictionary/lookup_controller.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ class DictionaryLookupController extends _$DictionaryLookupController {
187187
bool _isStale(String id, int seq) => _seq[id] != seq;
188188

189189
DictionaryLookupRequest _buildRequest(DictionarySource source) {
190-
// 不需联网的源无需鉴权/语言上下文,避免无谓读取
190+
// word(= family key)已由调用方归一化一次(见 DictionaryLookupRequest.word
191+
// 「已清洗」契约 + word_dictionary_sheet._normalizedWord)。此处及各源均不再归一,
192+
// 保证「查一次词只归一化一次」,各端共用同一清洗结果。
191193
if (!source.requiresNetwork) {
192194
return DictionaryLookupRequest(word: word);
193195
}

lib/services/dictionary/ai_dictionary_source.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ class AiDictionarySource implements DictionarySource {
7575
throw const DictionaryAuthRequiredException();
7676
}
7777
final language = request.targetLanguage ?? _defaultLanguage;
78-
final key = hashText('${request.word}|$language');
78+
// request.word 已由 controller 归一化(见 DictionaryLookupRequest.word 契约),
79+
// 缓存键、发往后端的词、LLM 输入三者共用同一清洗结果
80+
final word = request.word;
81+
final key = hashText('$word|$language');
7982

8083
// L1 内存
8184
final mem = _memCache[key];
@@ -87,7 +90,7 @@ class AiDictionarySource implements DictionarySource {
8790

8891
final future = _fetch(
8992
key: key,
90-
word: request.word,
93+
word: word,
9194
accessToken: token,
9295
language: language,
9396
);

lib/services/dictionary/web_dictionary_source.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ class WebDictionarySource implements DictionarySource {
7373
DictionaryLookupRequest request, {
7474
CancelToken? cancelToken,
7575
}) async {
76-
final slug = Uri.encodeComponent(request.word.trim().toLowerCase());
76+
// request.word 已由 controller 归一化(见 DictionaryLookupRequest.word 契约)
77+
final slug = Uri.encodeComponent(request.word);
7778
return WebDictResult(
7879
sourceId: config.id,
7980
url: Uri.parse(config.buildUrl(slug)),

lib/services/dictionary_service.dart

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:lemmatizerx/lemmatizerx.dart';
99
import 'package:sqlite3/sqlite3.dart';
1010

1111
import '../models/dict_entry.dart';
12+
import '../utils/text_normalize.dart';
1213

1314
/// 词典服务单例
1415
class DictionaryService {
@@ -34,10 +35,6 @@ class DictionaryService {
3435
Database? _db;
3536
final Lemmatizer _lemmatizer = Lemmatizer();
3637

37-
static final RegExp _edgePunctuationPattern = RegExp(
38-
r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$',
39-
);
40-
4138
/// 词典数据库是否已就绪
4239
bool get isAvailable => _db != null;
4340

@@ -84,9 +81,7 @@ class DictionaryService {
8481
return null;
8582
}
8683

87-
String _normalizeLookupWord(String word) {
88-
return word.trim().replaceAll(_edgePunctuationPattern, '').toLowerCase();
89-
}
84+
String _normalizeLookupWord(String word) => normalizeWord(word);
9085

9186
/// 直接查询数据库
9287
DictEntry? _queryWord(String word) {

lib/utils/text_normalize.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,25 @@ String hashText(String text) {
2323
final normalized = normalizeForCache(text);
2424
return sha256.convert(utf8.encode(normalized)).toString();
2525
}
26+
27+
/// 各种弯/花撇号(U+2019 ’ / U+2018 ‘ / U+02BC ʼ / U+FF07 ' / 反引号 ` / U+00B4 ´)
28+
/// 统一为直撇号 `'`:排版文本(如 "I'd")多用弯撇号,而词典库(ECDICT / 网页源)
29+
/// 用直撇号存储,不归一会查不中且粗体标题渲染异常。
30+
final RegExp _smartApostrophes = RegExp('[’‘ʼ'`´]');
31+
32+
/// 剥离查词输入首尾的非字母数字字符(保留词内连字符/点,如 COVID-19)。
33+
///
34+
/// 右侧不剥离直撇号 `'`,保留所有格/缩写形式(dogs' / it's / library's)。
35+
final RegExp _edgeNonAlnum = RegExp(r"^[^A-Za-z0-9]+|[^A-Za-z0-9']+$");
36+
37+
/// 归一化查词输入,供本地 / AI / 网页等所有词典源共用,保证大小写处理一致。
38+
///
39+
/// 处理步骤:去首尾空白 → 弯撇号归一为直撇号 → 剥离首尾标点(右侧直撇号除外)
40+
/// → 一律转小写。全大写缩写(NASA / FBI 等)不做特殊保留,统一小写化。
41+
String normalizeWord(String word) {
42+
return word
43+
.trim()
44+
.replaceAll(_smartApostrophes, "'")
45+
.replaceAll(_edgeNonAlnum, '')
46+
.toLowerCase();
47+
}

lib/widgets/intensive_listen/word_dictionary_sheet.dart

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import '../../providers/dictionary_provider.dart';
1818
import '../../providers/saved_word_provider.dart';
1919
import '../../services/dictionary/web_dictionary_source.dart';
2020
import '../../services/tts_service.dart';
21+
import '../../utils/text_normalize.dart';
2122
import '../../theme/app_theme.dart';
2223
import '../animated_bookmark_icon.dart';
2324
import '../common/text_context_menu.dart';
@@ -190,26 +191,24 @@ class _WordDictionarySheetState extends ConsumerState<WordDictionarySheet> {
190191
}
191192
}
192193

193-
/// 清洗后的词形(去首尾标点),用于查询与展示
194-
String get _normalizedWord => widget.word.trim().replaceAll(
195-
RegExp(r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$'),
196-
'',
197-
);
194+
/// 归一化后的词形,用于查询(family key)与展示,
195+
/// 与各词典源、后端共用同一 [normalizeWord](trim + 剥首尾标点[右撇号除外] + 小写)
196+
String get _normalizedWord => normalizeWord(widget.word);
198197

199-
/// 标题展示词:优先用当前结果的 headword(本地原形/AI 词头),否则用清洗词形
198+
/// 标题展示词:优先用当前结果的 headword(本地原形/AI 词头),否则用归一化词形
200199
String _displayWord(DictionaryLookupState state) {
201200
final cur = state.current;
202201
if (cur is LookupLoaded) return cur.result.headword;
203202
return _normalizedWord;
204203
}
205204

206-
/// 收藏用 lemma:优先用本地词典返回的原形,否则用清洗词形
205+
/// 收藏用 lemma:优先用本地词典返回的原形,否则用归一化词形
207206
String _lemmaWord(DictionaryLookupState state) {
208207
final local = state.bySource['local'];
209208
if (local case LookupLoaded(result: final LocalDictResult r)) {
210209
return r.entry.word.toLowerCase();
211210
}
212-
return _normalizedWord.toLowerCase();
211+
return _normalizedWord;
213212
}
214213

215214
Future<void> _toggleSave(String lemma, bool currentlySaved) async {

test/providers/dictionary/lookup_controller_test.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ class ControllableSource implements DictionarySource {
2020

2121
final List<Completer<DictionaryLookupResult?>> calls = [];
2222

23+
/// 记录每次 lookup 收到的请求,供断言归一化结果
24+
final List<DictionaryLookupRequest> requests = [];
25+
2326
@override
2427
IconData get icon => Icons.abc;
2528
@override
@@ -30,6 +33,7 @@ class ControllableSource implements DictionarySource {
3033
DictionaryLookupRequest request, {
3134
CancelToken? cancelToken,
3235
}) {
36+
requests.add(request);
3337
final c = Completer<DictionaryLookupResult?>();
3438
calls.add(c);
3539
return c.future;
@@ -225,6 +229,15 @@ void main() {
225229
// 跑到这里无未捕获异常即通过
226230
});
227231

232+
test('word(已由调用方归一化)原样透传给各源,controller 不再归一', () async {
233+
final a = ControllableSource('a');
234+
final c = makeContainer({'a': a});
235+
// family key 已是归一化结果(由 widget 层 normalizeWord 产出)
236+
start(c, "dogs'");
237+
await pump();
238+
expect(a.requests.single.word, "dogs'");
239+
});
240+
228241
test('不需联网的源不读取上下文也能查', () async {
229242
final a = ControllableSource('a', requiresNetwork: false);
230243
// 不 override context:若 controller 误读会抛错

test/services/dictionary/ai_dictionary_source_test.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,35 @@ void main() {
8080
verify(() => dao.upsert(any(), 'ai_dictionary', any())).called(1);
8181
});
8282

83+
test('request.word(已归一化)原样发往后端', () async {
84+
// 归一化由 controller 统一完成;源拿到的已是清洗结果,原样透传
85+
when(
86+
() => dao.getByHash(any(), 'ai_dictionary'),
87+
).thenAnswer((_) async => null);
88+
when(
89+
() => api.lookupDictionary(
90+
any(),
91+
accessToken: any(named: 'accessToken'),
92+
targetLanguage: any(named: 'targetLanguage'),
93+
cancelToken: any(named: 'cancelToken'),
94+
),
95+
).thenAnswer((_) async => _entry('run'));
96+
when(
97+
() => dao.upsert(any(), 'ai_dictionary', any()),
98+
).thenAnswer((_) async {});
99+
100+
await source.lookup(tokenReq);
101+
102+
verify(
103+
() => api.lookupDictionary(
104+
'run',
105+
accessToken: any(named: 'accessToken'),
106+
targetLanguage: any(named: 'targetLanguage'),
107+
cancelToken: any(named: 'cancelToken'),
108+
),
109+
).called(1);
110+
});
111+
83112
test('L1 内存命中 → 第二次不再调 API', () async {
84113
when(
85114
() => dao.getByHash(any(), 'ai_dictionary'),

test/services/dictionary/local_dictionary_source_test.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ void main() {
2727

2828
test('词典就绪且命中 → LocalDictResult', () async {
2929
when(() => service.isAvailable).thenReturn(true);
30-
when(() => service.lookup('run'))
31-
.thenReturn(const DictEntry(word: 'run', phonetic: 'rʌn'));
30+
when(
31+
() => service.lookup('run'),
32+
).thenReturn(const DictEntry(word: 'run', phonetic: 'rʌn'));
3233

3334
final result = await source.lookup(req);
3435

test/services/dictionary/web_dictionary_source_test.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,13 @@ void main() {
5050
expect(web.url.toString(), config.buildUrl('nice'));
5151
});
5252

53-
test('查询词被规整为小写并 URL 编码', () async {
53+
test('对已归一化的查询词做 URL 编码(词内空格编码为 %20)', () async {
54+
// 归一化由 controller 统一完成,源只对 request.word 做 URL 编码
5455
final result =
5556
await source.lookup(
56-
const DictionaryLookupRequest(word: ' A Posteriori '),
57+
const DictionaryLookupRequest(word: 'a posteriori'),
5758
)
5859
as WebDictResult;
59-
// trim + toLowerCase + 空格编码为 %20
6060
expect(result.url.toString(), contains('a%20posteriori'));
6161
expect(result.url.toString(), isNot(contains(' ')));
6262
});

0 commit comments

Comments
 (0)