MenuPilot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,816 @@
1
+ """
2
+ Token Classifier — 纯规则组合字段解析 + 未知词兜底。
3
+ 将模板中逗号分隔的复合字段(如"口味做法组合")拆分为结构化 Token,
4
+ 识别每个 Token 的类型(茶底/奶底/糖度/温度)和缺失维度。
5
+
6
+ 四级兜底机制:
7
+ Step 1: data.token_dict 标准词典
8
+ Step 2: data.memory 长期记忆(用户确认过的词)
9
+ Step 3: LLM 猜测(同词仅调一次,进程内缓存)
10
+ Step 4: 交互式询问 / 批量模式自动处理
11
+ """
12
+
13
+ import json
14
+ import re
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from menupilot import config
18
+ from menupilot.data.memory import add_token as mem_add_token
19
+ from menupilot.data.memory import get_token_type as mem_get_token_type
20
+ from menupilot.data.token_dict import lookup, normalize_token, UNKNOWN_TOKEN
21
+
22
+ # Token Classifier 关注的 4 个维度(规格不在组合字段中,有独立列)
23
+ ALL_DIMENSIONS = ["茶底", "奶底", "糖度", "温度"]
24
+
25
+ # UNKNOWN_TOKEN 映射为 "UNKNOWN" 以保持输出兼容
26
+ _UNKNOWN_TYPE = "UNKNOWN"
27
+
28
+ # 自动分类标记(批量模式 LLM 高置信)
29
+ _AUTO_CLASSIFIED_HIGH = "AUTO_CLASSIFIED_HIGH"
30
+
31
+ # 合法类型列表(供交互式询问展示 + LLM 输出校验)
32
+ _VALID_TYPE_NAMES = ["茶底", "奶底", "糖度", "温度", "规格"]
33
+
34
+ # ── API 调用计数器(纯规则模式始终为 0) ─────────────────────────
35
+
36
+ _api_call_count: int = 0
37
+
38
+
39
+ def get_api_call_count() -> int:
40
+ return _api_call_count
41
+
42
+
43
+ def reset_api_call_count() -> None:
44
+ global _api_call_count
45
+ _api_call_count = 0
46
+
47
+
48
+ # ── 缓存 ────────────────────────────────────────────────────────
49
+
50
+ _cache: Dict[str, Dict[str, Any]] = {}
51
+
52
+ # 进程内「已询问过」的未知词集合(用户确认过的映射,同词不重复确认)
53
+ _asked_this_session: Dict[str, str] = {}
54
+
55
+ # LLM 猜测缓存(同词仅调一次 LLM)
56
+ _llm_guess_cache: Dict[str, str] = {}
57
+
58
+
59
+ def reset_cache() -> None:
60
+ """清空所有缓存:分类缓存 + 会话询问缓存 + LLM 猜测缓存。"""
61
+ _cache.clear()
62
+ global _asked_this_session, _llm_guess_cache
63
+ _asked_this_session = {}
64
+ _llm_guess_cache = {}
65
+
66
+
67
+ def reset_session_asked() -> None:
68
+ """清空「已询问」集合(仅测试用)。"""
69
+ global _asked_this_session, _llm_guess_cache
70
+ _asked_this_session = {}
71
+ _llm_guess_cache = {}
72
+
73
+
74
+ # ── LLM Token 类型猜测 ──────────────────────────────────────────
75
+
76
+ _LLM_SUGGEST_SYSTEM = """\
77
+ You are a token classifier for beverage recipes.
78
+ Given an unknown token and its context, classify which category it belongs to.
79
+
80
+ Categories: 茶底(tea_base), 奶底(milk_base), 糖度(sugar), 温度(temperature), 规格(size)
81
+
82
+ Rules:
83
+ - If the token looks like a tea/coffee ingredient → 茶底
84
+ - If it looks like dairy/milk/plant milk → 奶底
85
+ - If it contains numbers or describes sweetness → 糖度
86
+ - If it describes ice/heat level → 温度
87
+ - If it mentions cup size/bottle → 规格
88
+ - If truly unsure → 未知
89
+
90
+ Return ONLY one word: 茶底 / 奶底 / 糖度 / 温度 / 规格 / 未知
91
+ No explanation, no JSON, just the category name."""
92
+
93
+ _LLM_SUGGEST_USER = 'Token: "{word}"\nContext: {context}\nCategory:'
94
+
95
+
96
+ def _llm_suggest_type(word: str, context: str) -> Optional[str]:
97
+ """调用 LLM 猜测未知 token 的类型。
98
+
99
+ Args:
100
+ word: 未知 token(已 normalize)。
101
+ context: 所在行完整值。
102
+
103
+ Returns:
104
+ 猜测的类型(茶底/奶底/糖度/温度/规格)或 None(失败/返回"未知")。
105
+ """
106
+ if word in _llm_guess_cache:
107
+ return _llm_guess_cache[word]
108
+
109
+ try:
110
+ client = _get_client()
111
+ response = client.chat.completions.create(
112
+ model=config.DEEPSEEK_MODEL,
113
+ messages=[
114
+ {"role": "system", "content": _LLM_SUGGEST_SYSTEM},
115
+ {"role": "user", "content": _LLM_SUGGEST_USER.format(
116
+ word=word, context=context
117
+ )},
118
+ ],
119
+ temperature=0.1,
120
+ max_tokens=10,
121
+ )
122
+ raw = response.choices[0].message.content or ""
123
+ guessed = raw.strip()
124
+ except Exception:
125
+ _llm_guess_cache[word] = None
126
+ return None
127
+
128
+ # 校验返回值:在合法类型列表内 → high confidence
129
+ if guessed in _VALID_TYPE_NAMES:
130
+ _llm_guess_cache[word] = guessed
131
+ return guessed
132
+
133
+ # "未知" 或无效值 → low confidence
134
+ _llm_guess_cache[word] = None
135
+ return None
136
+
137
+
138
+ def _get_client():
139
+ """获取 DeepSeek API 客户端(延迟导入)。"""
140
+ from openai import OpenAI
141
+ return OpenAI(api_key=config.DEEPSEEK_API_KEY, base_url=config.DEEPSEEK_BASE_URL)
142
+
143
+
144
+ # ── 交互式未知词确认 ────────────────────────────────────────────
145
+
146
+
147
+ def prompt_user_for_unknown(
148
+ word: str,
149
+ context: str,
150
+ llm_suggestion: Optional[str] = None,
151
+ ) -> Optional[Dict[str, Any]]:
152
+ """交互式询问用户如何处理未知词(含 LLM 猜测提示)。
153
+
154
+ 此函数可被外部 mock 替换(测试时注入自定义回调)。
155
+
156
+ Args:
157
+ word: 未知 token 文本(已 normalize)。
158
+ context: 所在行的组合字段完整值,供用户参考。
159
+ llm_suggestion: LLM 猜测的类型(茶底/奶底/糖度/温度/规格)或 None。
160
+
161
+ Returns:
162
+ {"action": "add", "type": "茶底"} → 加入记忆并继续
163
+ {"action": "unknown"} → 标记为 UNKNOWN 继续
164
+ {"action": "skip"} → 跳过此行
165
+ """
166
+ has_suggestion = llm_suggestion and llm_suggestion in _VALID_TYPE_NAMES
167
+
168
+ print(f"\n{'='*56}")
169
+ print(f"[未知词] 无法识别的 Token: 「{word}」")
170
+ print(f" 所在行上下文: {context}")
171
+ if has_suggestion:
172
+ print(f" LLM 猜测: {llm_suggestion}")
173
+ print(f"{'='*56}")
174
+
175
+ if has_suggestion:
176
+ print(f" [y] 确认,加入{llm_suggestion}词典")
177
+ print(f" [n] 不对,我手动选择类型")
178
+ print(f" [s] 跳过")
179
+ while True:
180
+ choice = input(" 请输入 y/n/s: ").strip().lower()
181
+ if choice == "y":
182
+ return {"action": "add", "type": llm_suggestion}
183
+ elif choice == "n":
184
+ break # 进入手动选择流程
185
+ elif choice == "s":
186
+ return {"action": "skip"}
187
+ else:
188
+ print(" [错误] 无效输入,请输入 y、n 或 s")
189
+
190
+ # LLM 低置信 / 失败 / 用户选 n → 手动选择
191
+ print(" 请选择处理方式:")
192
+ print(" 1. 加入词典(需选择类型)")
193
+ print(" 2. 标记为 UNKNOWN(继续处理)")
194
+ print(" 3. 跳过此行")
195
+
196
+ while True:
197
+ choice = input(" 请输入 1/2/3: ").strip()
198
+ if choice == "1":
199
+ while True:
200
+ print(f" 可选类型: {', '.join(_VALID_TYPE_NAMES)}")
201
+ type_choice = input(
202
+ f" 请选择「{word}」的类型 (1=茶底 2=奶底 3=糖度 4=温度 5=规格): "
203
+ ).strip()
204
+ type_map = {
205
+ "1": "茶底", "2": "奶底", "3": "糖度",
206
+ "4": "温度", "5": "规格",
207
+ }
208
+ if type_choice in type_map:
209
+ return {"action": "add", "type": type_map[type_choice]}
210
+ if type_choice in _VALID_TYPE_NAMES:
211
+ return {"action": "add", "type": type_choice}
212
+ print(f" [错误] 无效类型,请重新选择")
213
+ elif choice == "2":
214
+ return {"action": "unknown"}
215
+ elif choice == "3":
216
+ return {"action": "skip"}
217
+ else:
218
+ print(" [错误] 无效输入,请输入 1、2 或 3")
219
+
220
+
221
+ # 用于测试时注入自定义回调的钩子
222
+ _prompt_hook: Optional[callable] = None
223
+
224
+
225
+ def set_prompt_hook(hook: Optional[callable]) -> None:
226
+ """注入自定义未知词处理回调(用于自动化测试)。
227
+
228
+ hook 签名应与 prompt_user_for_unknown 一致:
229
+ def hook(word: str, context: str) -> dict
230
+ 设为 None 恢复默认交互式行为。
231
+ """
232
+ global _prompt_hook
233
+ _prompt_hook = hook
234
+
235
+
236
+ # ── 纯规则分类核心 ──────────────────────────────────────────────
237
+
238
+
239
+ def _classify_one(composite_value: str) -> Dict[str, Any]:
240
+ """对单个组合字段值执行纯规则分类 + 未知词兜底。
241
+
242
+ 流程:
243
+ 1. 逗号切割 → 每段 trim
244
+ 2. normalize_token() 去后缀
245
+ 3. token_dict.lookup() 分类(Step 1)
246
+ 4. 未命中 → 查 memory.py(Step 2)
247
+ 5. 未命中 → 查 _asked_this_session(用户确认过)
248
+ 6. 未命中 → LLM 猜测(Step 3,同词仅调一次)
249
+ 7. LLM 高置信 + 批量模式 → AUTO_CLASSIFIED_HIGH
250
+ 8. LLM 低置信 + 批量模式 → UNKNOWN(调 hook 兜底)
251
+ 9. LLM 高置信 + 交互 → 展示 y/n 确认
252
+ 10. LLM 低置信/失败 + 交互 → 手动选择
253
+
254
+ Args:
255
+ composite_value: 组合字段原始字符串(如 "红茶, 十二分糖, 温热")。
256
+
257
+ Returns:
258
+ {"tokens": [{"value": "...", "type": "茶底"}, ...], "missing": ["奶底"]}
259
+ 若用户选择跳过此行,附带 "_skipped": True。
260
+ """
261
+ key = composite_value.strip() if composite_value else ""
262
+ if not key:
263
+ return {"tokens": [], "missing": list(ALL_DIMENSIONS)}
264
+
265
+ # Step 1: 逗号切割
266
+ parts = [p.strip() for p in key.split(",") if p.strip()]
267
+
268
+ tokens: List[Dict[str, str]] = []
269
+ types_found: set = set()
270
+ skipped = False
271
+
272
+ for part in parts:
273
+ # normalize_token() 处理带后缀的情况(如 "七分糖|推荐" → "七分糖")
274
+ cleaned = normalize_token(part)
275
+
276
+ # Step 1: 查标准词典
277
+ token_type = lookup(cleaned)
278
+
279
+ if token_type == UNKNOWN_TOKEN:
280
+ # Step 2: 查长期记忆
281
+ mem_type = mem_get_token_type(cleaned)
282
+ if mem_type:
283
+ token_type = mem_type
284
+ types_found.add(token_type)
285
+ tokens.append({"value": cleaned, "type": token_type})
286
+ continue
287
+
288
+ # Step 3: 查 _asked_this_session(本进程已确认过)
289
+ if cleaned in _asked_this_session:
290
+ token_type = _asked_this_session[cleaned]
291
+ if token_type == _UNKNOWN_TYPE:
292
+ tokens.append({"value": cleaned, "type": token_type})
293
+ continue
294
+ types_found.add(token_type)
295
+ tokens.append({"value": cleaned, "type": token_type})
296
+ continue
297
+
298
+ # Step 4: LLM 猜测(同词仅调一次)
299
+ llm_type = _llm_suggest_type(cleaned, composite_value)
300
+
301
+ # Step 5: 统一分发 — hook 或默认交互
302
+ handler = _prompt_hook if _prompt_hook else prompt_user_for_unknown
303
+ response = handler(cleaned, composite_value, llm_type)
304
+
305
+ if response["action"] == "add":
306
+ mem_add_token(cleaned, response["type"])
307
+ _asked_this_session[cleaned] = response["type"]
308
+ token_type = response["type"]
309
+ types_found.add(token_type)
310
+ tokens.append({"value": cleaned, "type": token_type})
311
+ continue
312
+ elif response["action"] == "skip":
313
+ skipped = True
314
+ _asked_this_session[cleaned] = _UNKNOWN_TYPE
315
+ token_type = _UNKNOWN_TYPE
316
+ else:
317
+ types_found.add(token_type)
318
+
319
+ tokens.append({"value": cleaned, "type": token_type})
320
+
321
+ # Step 5: 计算缺失维度
322
+ missing = [d for d in ALL_DIMENSIONS if d not in types_found]
323
+ result = {"tokens": tokens, "missing": missing}
324
+ if skipped:
325
+ result["_skipped"] = True
326
+ return result
327
+
328
+
329
+ # ── 公开 API ────────────────────────────────────────────────────
330
+
331
+
332
+ def classify_single(composite_value: str, use_cache: bool = True) -> Dict[str, Any]:
333
+ """对单个组合字段值进行分类。
334
+
335
+ 结果按值缓存:相同字符串只解析一次。
336
+
337
+ Args:
338
+ composite_value: 组合字段原始字符串(如 "红茶, 十二分糖, 温热")。
339
+ use_cache: 是否使用缓存。默认 True。
340
+
341
+ Returns:
342
+ {"tokens": [{"value": "红茶", "type": "茶底"}, ...], "missing": ["奶底"]}
343
+ """
344
+ key = composite_value.strip() if composite_value else ""
345
+ if use_cache and key in _cache:
346
+ return _cache[key]
347
+
348
+ result = _classify_one(composite_value)
349
+
350
+ if use_cache and key:
351
+ _cache[key] = result
352
+ return result
353
+
354
+
355
+ def classify_batch(
356
+ composite_values: List[str],
357
+ use_cache: bool = True,
358
+ ) -> List[Dict[str, Any]]:
359
+ """批量分类组合字段值。
360
+
361
+ 先查缓存,仅对未命中缓存的条目执行规则分类。
362
+
363
+ Args:
364
+ composite_values: 组合字段值列表。
365
+ use_cache: 是否使用缓存。
366
+
367
+ Returns:
368
+ 分类结果列表,与输入一一对应。每项:
369
+ {"tokens": [{"value": "...", "type": "茶底"}, ...], "missing": ["奶底", ...]}
370
+
371
+ Raises:
372
+ ValueError: 输入为空列表。
373
+ """
374
+ if not composite_values:
375
+ raise ValueError("composite_values 不能为空列表")
376
+
377
+ results: List[Dict[str, Any]] = []
378
+
379
+ for val in composite_values:
380
+ key = val.strip() if val else ""
381
+
382
+ if not key:
383
+ results.append({"tokens": [], "missing": list(ALL_DIMENSIONS)})
384
+ continue
385
+
386
+ if use_cache and key in _cache:
387
+ results.append(_cache[key])
388
+ continue
389
+
390
+ result = _classify_one(val)
391
+ if use_cache:
392
+ _cache[key] = result
393
+ results.append(result)
394
+
395
+ return results
396
+
397
+
398
+ def classify_from_dataframe(
399
+ df: "pd.DataFrame",
400
+ composite_col: str,
401
+ ) -> List[Dict[str, Any]]:
402
+ """从模板 DataFrame 的组合列直接分类(便捷方法)。
403
+
404
+ Args:
405
+ df: 模板 DataFrame。
406
+ composite_col: 组合字段列名。
407
+
408
+ Returns:
409
+ 同 classify_batch()。
410
+
411
+ Raises:
412
+ ValueError: composite_col 不在 DataFrame 中。
413
+ """
414
+ if composite_col not in df.columns:
415
+ raise ValueError(
416
+ f"组合列 '{composite_col}' 不在 DataFrame 列中: {list(df.columns)}"
417
+ )
418
+ values = df[composite_col].astype(str).tolist()
419
+ return classify_batch(values)
420
+
421
+
422
+ # ── 自测 ────────────────────────────────────────────────────────
423
+
424
+ if __name__ == "__main__":
425
+ import os as _os, shutil as _shutil
426
+ import pandas as pd
427
+ from menupilot.data.memory import reset_memory, get_token_type as mem_get
428
+
429
+ # ── 备份真实 memory.json ──
430
+ _mem_path = _os.path.expanduser("~/.menupilot/memory.json")
431
+ _mem_backup = None
432
+ if _os.path.exists(_mem_path):
433
+ _mem_backup_path = _mem_path + ".self_test_backup"
434
+ _shutil.copy(_mem_path, _mem_backup_path)
435
+ _mem_backup = _mem_backup_path
436
+
437
+ # 自测使用临时记忆,避免污染真实数据
438
+ reset_memory()
439
+
440
+ passed = 0
441
+ failed = 0
442
+
443
+ def check(condition, msg):
444
+ global passed, failed
445
+ if condition:
446
+ passed += 1
447
+ print(f" PASS {msg}")
448
+ else:
449
+ failed += 1
450
+ print(f" FAIL {msg}")
451
+
452
+ print("=== Token Classifier 自测(纯规则 + 记忆兜底)===\n")
453
+
454
+ # ── 0. 清空状态 ──
455
+ reset_cache()
456
+
457
+ # ── 1. 标准词:直接返回,不触发询问 ──
458
+ print("1. 标准词 — 直接返回,不触发询问")
459
+ result1 = classify_single("红茶, 燕麦奶, 正常冰, 七分糖")
460
+ check(len(result1["tokens"]) == 4, "4 个 token")
461
+ t1 = {t["type"]: t["value"] for t in result1["tokens"]}
462
+ check(t1.get("茶底") == "红茶", "红茶 type=茶底")
463
+ check(t1.get("奶底") == "燕麦奶", "燕麦奶 type=奶底")
464
+ check(t1.get("温度") == "正常冰", "正常冰 type=温度")
465
+ check(t1.get("糖度") == "七分糖", "七分糖 type=糖度")
466
+ check(result1["missing"] == [], "完整四项 → 无缺失")
467
+ print()
468
+
469
+ # ── 2. 长期记忆中的词 → 直接返回,不触发询问 ──
470
+ print("2. 长期记忆中的词 — 直接返回(mem_get_token_type)")
471
+ # 预先写入记忆
472
+ from menupilot.data.memory import add_token as mem_add
473
+ mem_add("黑芝麻仙草", "茶底")
474
+ check(mem_get("黑芝麻仙草") == "茶底", "记忆写入了 '黑芝麻仙草'")
475
+
476
+ result2 = classify_single("黑芝麻仙草, 牛奶, 少冰, 全糖")
477
+ check(len(result2["tokens"]) == 4, "4 个 token")
478
+ t2 = {t["type"]: t["value"] for t in result2["tokens"]}
479
+ check(t2.get("茶底") == "黑芝麻仙草", "记忆中 '黑芝麻仙草' type=茶底")
480
+ check(t2.get("奶底") == "牛奶", "牛奶 type=奶底")
481
+ check(result2["missing"] == [], "无缺失")
482
+ print()
483
+
484
+ # ── 3. 全新未知词 → 模拟用户选择「加入词典」──
485
+ print("3. 全新未知词 — 模拟用户输入 1 + 1(加入茶底)")
486
+
487
+ # 模拟 hook:第一次询问 → add as 茶底
488
+ call_count = [0]
489
+
490
+ def mock_hook_1(word, context, llm_suggestion=None):
491
+ call_count[0] += 1
492
+ print(f" [MOCK] 询问未知词: '{word}', 上下文: '{context}'")
493
+ print(f" [MOCK] 用户选择 1 → 加入词典,类型 1(茶底)")
494
+ return {"action": "add", "type": "茶底"}
495
+
496
+ set_prompt_hook(mock_hook_1)
497
+ reset_cache()
498
+ reset_session_asked()
499
+ # 预置 LLM 缓存为 None,强制走 hook 兜底流程(模拟 LLM 低置信)
500
+ _llm_guess_cache["豆乳奶茶"] = None
501
+
502
+ result3 = classify_single("豆乳奶茶, 正常冰, 七分糖")
503
+ check(call_count[0] == 1, f"触发了 1 次询问(实际 {call_count[0]})")
504
+ check(mem_get("豆乳奶茶") == "茶底", "写入记忆后可查到 '豆乳奶茶' = 茶底")
505
+ t3 = {t["type"]: t["value"] for t in result3["tokens"]}
506
+ check(t3.get("茶底") == "豆乳奶茶", "分类结果中 '豆乳奶茶' type=茶底")
507
+ check(t3.get("温度") == "正常冰", "正常冰 仍正确")
508
+ check(t3.get("糖度") == "七分糖", "七分糖 仍正确")
509
+ print()
510
+
511
+ # ── 4. 同词第二次出现 → 不重复询问(已记忆) ──
512
+ print("4. 已记忆词第二次出现 — 不触发询问")
513
+ call_count[0] = 0
514
+ result4 = classify_single("豆乳奶茶, 牛奶, 温热, 五分糖")
515
+ check(call_count[0] == 0, "不再触发询问(记忆命中)")
516
+ t4 = {t["type"]: t["value"] for t in result4["tokens"]}
517
+ check(t4.get("茶底") == "豆乳奶茶", "记忆命中后 type 正确")
518
+ check(result4["missing"] == [], "无缺失")
519
+ print()
520
+
521
+ # ── 5. 同进程内同词不重复询问(_asked_this_session 缓存) ──
522
+ print("5. 同进程内同词不重复询问(_asked_this_session 缓存)")
523
+ reset_memory()
524
+ call_count[0] = 0
525
+
526
+ def mock_hook_2(word, context, llm_suggestion=None):
527
+ call_count[0] += 1
528
+ print(f" [MOCK] 询问: '{word}' → 用户选 2(标 UNKNOWN)")
529
+ return {"action": "unknown"}
530
+
531
+ set_prompt_hook(mock_hook_2)
532
+ reset_cache()
533
+ reset_session_asked()
534
+ _llm_guess_cache["抹茶粉"] = None # 模拟 LLM 低置信,强制走 hook
535
+
536
+ # 同一个词出现两次,两次都在不同行
537
+ r5a = classify_single("抹茶粉, 去冰")
538
+ r5b = classify_single("抹茶粉, 少冰")
539
+ check(call_count[0] == 1, f"同词只问 1 次(实际 {call_count[0]})")
540
+ # 两次结果中该词都应是 UNKNOWN
541
+ types_a = {t["type"]: t["value"] for t in r5a["tokens"]}
542
+ types_b = {t["type"]: t["value"] for t in r5b["tokens"]}
543
+ check(types_a.get("UNKNOWN") == "抹茶粉", "第一次 UNKNOWN")
544
+ check(types_b.get("UNKNOWN") == "抹茶粉", "第二次 UNKNOWN(会话缓存)")
545
+ print()
546
+
547
+ # ── 6. 模拟用户选择「跳过此行」 ──
548
+ print("6. 模拟用户选择「跳过此行」")
549
+
550
+ def mock_hook_3(word, context, llm_suggestion=None):
551
+ print(f" [MOCK] 询问: '{word}' → 用户选 3(跳过)")
552
+ return {"action": "skip"}
553
+
554
+ set_prompt_hook(mock_hook_3)
555
+ reset_cache()
556
+ reset_session_asked()
557
+ _llm_guess_cache["未知成分X"] = None # 模拟 LLM 低置信
558
+
559
+ result6 = classify_single("未知成分X, 正常冰")
560
+ check(result6.get("_skipped") is True, "结果标记 _skipped=True")
561
+ check(any(t["type"] == "UNKNOWN" for t in result6["tokens"]), "未知词标为 UNKNOWN")
562
+ print()
563
+
564
+ # ── 7. 空值 / 纯空白 ──
565
+ print("7. 空值 / 纯空白处理")
566
+ set_prompt_hook(None)
567
+ reset_cache()
568
+ empty_result = classify_single("")
569
+ check(empty_result["tokens"] == [], "空 tokens")
570
+ check(len(empty_result["missing"]) == 4, "4 维全缺失")
571
+ ws_result = classify_single(" ")
572
+ check(ws_result["tokens"] == [], "纯空白 → 空 tokens")
573
+ print()
574
+
575
+ # ── 8. classify_batch: 批量(含内存命中) ──
576
+ print("8. classify_batch(批量,含内存命中)")
577
+ reset_cache()
578
+ reset_session_asked()
579
+
580
+ # 预置记忆
581
+ reset_memory()
582
+ mem_add("茉莉绿茶", "茶底")
583
+
584
+ batch_results = classify_batch([
585
+ "红茶, 十二分糖, 温热",
586
+ "", # 空值
587
+ "茉莉绿茶, 牛奶, 无糖, 去冰", # 茉莉绿茶在记忆中
588
+ ])
589
+ check(len(batch_results) == 3, f"3 条结果(实际 {len(batch_results)})")
590
+ check(len(batch_results[0]["tokens"]) == 3, "第 1 行 3 个 token")
591
+ check("奶底" in batch_results[0]["missing"], "第 1 行 missing 奶底")
592
+ check(batch_results[1]["tokens"] == [], "第 2 行(空)→ 空 tokens")
593
+ t8 = {t["type"]: t["value"] for t in batch_results[2]["tokens"]}
594
+ check(t8.get("茶底") == "茉莉绿茶", "记忆中 '茉莉绿茶' → 茶底")
595
+ check(batch_results[2]["missing"] == [], "第 3 行无缺失")
596
+ print()
597
+
598
+ # ── 9. classify_from_dataframe ──
599
+ print("9. classify_from_dataframe 便捷方法")
600
+ df = pd.DataFrame({
601
+ "菜品名称": ["测试A", "测试B"],
602
+ "口味做法组合": ["红茶, 温热", "绿茶, 少冰"],
603
+ })
604
+ df_results = classify_from_dataframe(df, "口味做法组合")
605
+ check(len(df_results) == 2, "2 条结果")
606
+ check(df_results[0]["tokens"][0]["value"] == "红茶", "DataFrame 第 1 行正确")
607
+
608
+ try:
609
+ classify_from_dataframe(df, "不存在的列")
610
+ check(False, "不存在的列应抛异常")
611
+ except ValueError as e:
612
+ check("不在 DataFrame 列中" in str(e), f"ValueError: {e}")
613
+ print()
614
+
615
+ # ── 10. API 调用计数器 ──
616
+ print("10. API 调用计数器始终为 0")
617
+ reset_api_call_count()
618
+ check(get_api_call_count() == 0, "初始 = 0")
619
+ classify_single("红茶, 温热")
620
+ check(get_api_call_count() == 0, "规则执行后仍 = 0")
621
+ print()
622
+
623
+ # ── 11. set_prompt_hook(None) 恢复默认 ──
624
+ print("11. set_prompt_hook(None) 恢复默认交互")
625
+ set_prompt_hook(None)
626
+ check(_prompt_hook is None, "hook 已清除")
627
+ print()
628
+
629
+ # ── 12. 缓存验证 ──
630
+ print("12. 缓存验证(含记忆命中)")
631
+ reset_cache()
632
+ reset_session_asked()
633
+ reset_memory()
634
+ mem_add("抹茶", "茶底")
635
+
636
+ r12a = classify_single("抹茶, 温热")
637
+ r12b = classify_single("抹茶, 温热")
638
+ check(r12a == r12b, "相同值命中缓存,结果一致")
639
+ print()
640
+
641
+ # ── 13. LLM 猜测 + 交互模式:高置信,选 y 确认 ──
642
+ print("13. LLM 猜测 + 交互模式:高置信,选 y 确认")
643
+ reset_cache()
644
+ reset_session_asked()
645
+ reset_memory()
646
+
647
+ _llm_guess_cache["龙井茶底"] = "茶底"
648
+
649
+ def mock_y_hook(word, context, llm_suggestion=None):
650
+ print(f" [MOCK] LLM 猜测: {llm_suggestion}, 用户选 y 确认")
651
+ return {"action": "add", "type": llm_suggestion}
652
+
653
+ set_prompt_hook(mock_y_hook)
654
+ r13 = classify_single("龙井茶底, 正常冰")
655
+ t13 = {t["type"]: t["value"] for t in r13["tokens"]}
656
+ check(t13.get("茶底") == "龙井茶底", "选 y 后 type=茶底")
657
+ check(_asked_this_session.get("龙井茶底") == "茶底",
658
+ f"_asked_this_session 存为茶底(实际 {_asked_this_session.get('龙井茶底')})")
659
+ check(mem_get("龙井茶底") == "茶底", "已写入长期记忆")
660
+ print()
661
+ set_prompt_hook(None)
662
+
663
+ # ── 14. LLM 猜测 + 交互模式:高置信,选 n → 手动流程 ──
664
+ print("14. LLM 猜测 + 交互模式:高置信,选 n → 手动")
665
+ reset_cache()
666
+ reset_session_asked()
667
+ set_prompt_hook(None)
668
+ reset_memory()
669
+
670
+ def mock_n_hook(word, context, llm_suggestion=None):
671
+ print(f" [MOCK] LLM 猜测: {llm_suggestion}, 用户选 n")
672
+ # 返回 None 表示进入手动流程
673
+ return {"action": "add", "type": "奶底"} # 模拟用户手动选了奶底
674
+
675
+ _llm_guess_cache["抹茶拿铁"] = "茶底" # LLM 猜茶底,但用户选 n 改成奶底
676
+
677
+ set_prompt_hook(mock_n_hook)
678
+ r14 = classify_single("抹茶拿铁, 去冰")
679
+ t14 = {t["type"]: t["value"] for t in r14["tokens"]}
680
+ check(t14.get("奶底") == "抹茶拿铁", "用户选 n 后手动选奶底 → type=奶底")
681
+ check(_asked_this_session["抹茶拿铁"] == "奶底", "_asked_this_session 存的是奶底(覆盖 LLM 猜测)")
682
+ print()
683
+ set_prompt_hook(None)
684
+
685
+ # ── 15. LLM 低置信 + 交互模式 → 直接手动流程 ──
686
+ print("15. LLM 低置信 + 交互模式 → 直接手动流程")
687
+ reset_cache()
688
+ reset_session_asked()
689
+
690
+ def mock_manual_hook(word, context, llm_suggestion=None):
691
+ print(f" [MOCK] LLM 猜测: {llm_suggestion}, 进入手动流程")
692
+ return {"action": "add", "type": "温度"} # 用户手动选温度
693
+
694
+ _llm_guess_cache["冰博客"] = None # 模拟 LLM 低置信
695
+
696
+ set_prompt_hook(mock_manual_hook)
697
+ r15 = classify_single("冰博客, 少冰")
698
+ t15 = {t["type"]: t["value"] for t in r15["tokens"]}
699
+ check("奶底" != t15.get("奶底"), "LLM 低置信时 llm_suggestion=None,hook 直接收到 None")
700
+ print()
701
+ set_prompt_hook(None)
702
+
703
+ # ── 16. LLM 高置信 + 批量模式 → AUTO_CLASSIFIED_HIGH ──
704
+ print("16. LLM 高置信 + 批量模式 → AUTO_CLASSIFIED_HIGH")
705
+ reset_cache()
706
+ reset_session_asked()
707
+ reset_memory()
708
+
709
+ def batch_hook(word, context, llm_suggestion=None):
710
+ print(f" [MOCK] 批量 hook: LLM 猜测={llm_suggestion}")
711
+ if llm_suggestion:
712
+ return {"action": "add", "type": llm_suggestion}
713
+ return {"action": "unknown"}
714
+
715
+ _llm_guess_cache["玫瑰普洱"] = "茶底"
716
+
717
+ set_prompt_hook(batch_hook)
718
+ r16 = classify_single("玫瑰普洱, 正常冰, 七分糖")
719
+ t16 = {t["type"]: t["value"] for t in r16["tokens"]}
720
+ check(t16.get("茶底") == "玫瑰普洱", "批量 LLM 高置信 → hook 接受 → type=茶底")
721
+ check(_asked_this_session.get("玫瑰普洱") == "茶底",
722
+ f"_asked_this_session 存为茶底(实际 {_asked_this_session.get('玫瑰普洱')})")
723
+ checked_mem = mem_get_token_type("玫瑰普洱")
724
+ check(checked_mem == "茶底", f"已写入长期记忆(实际 {checked_mem})")
725
+ print()
726
+ set_prompt_hook(None)
727
+
728
+ # ── 17. LLM 低置信 + 批量模式 → UNKNOWN,无交互 ──
729
+ print("17. LLM 低置信 + 批量模式 → UNKNOWN,无交互")
730
+ reset_cache()
731
+ reset_session_asked()
732
+
733
+ call_count[0] = 0
734
+
735
+ def batch_hook_2(word, context, llm_suggestion=None):
736
+ call_count[0] += 1
737
+ return {"action": "unknown"}
738
+
739
+ _llm_guess_cache["奇异果酱"] = None # LLM 低置信
740
+
741
+ set_prompt_hook(batch_hook_2)
742
+ r17 = classify_single("奇异果酱, 少冰")
743
+ t17 = {t["type"]: t["value"] for t in r17["tokens"]}
744
+ check(t17.get("UNKNOWN") == "奇异果酱", "LLM 低置信 → UNKNOWN")
745
+ check(call_count[0] == 1, "批量 hook 被调 1 次作为兜底")
746
+ check(_asked_this_session.get("奇异果酱") == _UNKNOWN_TYPE,
747
+ "_asked_this_session 标为 UNKNOWN")
748
+ print()
749
+ set_prompt_hook(None)
750
+
751
+ # ── 18. LLM 失败 + 批量模式 → UNKNOWN,继续运行 ──
752
+ print("18. LLM 失败 + 批量模式 → UNKNOWN,继续运行")
753
+ reset_cache()
754
+ reset_session_asked()
755
+
756
+ call_count[0] = 0
757
+
758
+ def batch_hook_3(word, context, llm_suggestion=None):
759
+ call_count[0] += 1
760
+ return {"action": "unknown"}
761
+
762
+ set_prompt_hook(batch_hook_3)
763
+ r18 = classify_single("火星陨石粉, 去冰")
764
+ t18 = {t["type"]: t["value"] for t in r18["tokens"]}
765
+ check(t18.get("UNKNOWN") == "火星陨石粉", "LLM 失败 → UNKNOWN")
766
+ check(call_count[0] == 1, "批量 hook 被调 1 次兜底")
767
+ print()
768
+ set_prompt_hook(None)
769
+
770
+ # ── 19. 同词第二次不重复调 LLM(_llm_guess_cache 命中) ──
771
+ print("19. 同词第二次不重复调 LLM(_llm_guess_cache 命中)")
772
+ reset_cache()
773
+ reset_session_asked()
774
+
775
+ call_count[0] = 0
776
+
777
+ def cache_hit_hook(word, context, llm_suggestion=None):
778
+ call_count[0] += 1
779
+ if llm_suggestion:
780
+ return {"action": "add", "type": llm_suggestion}
781
+ return {"action": "unknown"}
782
+
783
+ _llm_guess_cache["茉莉花茶"] = "茶底"
784
+
785
+ set_prompt_hook(cache_hit_hook)
786
+ r19a = classify_single("茉莉花茶, 温热")
787
+ r19b = classify_single("茉莉花茶, 少冰")
788
+ t19a = {t["type"]: t["value"] for t in r19a["tokens"]}
789
+ t19b = {t["type"]: t["value"] for t in r19b["tokens"]}
790
+ check(t19a.get("茶底") == "茉莉花茶", "第一次命中 LLM 缓存 → hook 收到 llm_suggestion")
791
+ check(call_count[0] == 1, f"hook 仅调用 1 次(第一次;第二次命中 _asked_this_session)实际 {call_count[0]}")
792
+ check(t19b.get("茶底") == "茉莉花茶", "第二次命中 _asked_this_session,不触发 hook")
793
+ print()
794
+
795
+ # ── 20. reset_cache() 清空全部三个缓存 ──
796
+ print("20. reset_cache() 清空全部三个缓存")
797
+ _cache["test_key"] = {"dummy": True}
798
+ _asked_this_session["test_word"] = "茶底"
799
+ _llm_guess_cache["test_word"] = "茶底"
800
+ reset_cache()
801
+ check(len(_cache) == 0, "_cache 已清空")
802
+ check(len(_asked_this_session) == 0, "_asked_this_session 已清空")
803
+ check(len(_llm_guess_cache) == 0, "_llm_guess_cache 已清空")
804
+ print()
805
+
806
+ # 清理
807
+ set_prompt_hook(None)
808
+
809
+ # ── 还原真实 memory.json ──
810
+ if _mem_backup:
811
+ from menupilot.data.memory import reload as _mem_reload
812
+ _shutil.move(_mem_backup, _mem_path)
813
+ _mem_reload()
814
+
815
+ # ── 汇总 ──
816
+ print(f"=== 结果: {passed} passed, {failed} failed ===")