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.
menupilot/cli/repl.py ADDED
@@ -0,0 +1,821 @@
1
+ """
2
+ 交互式 REPL — /指令系统,用于查看和编辑长期记忆。
3
+
4
+ 从 main.py 无参数启动时进入此模式。
5
+ 提示符: pos-agent>
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from typing import Any, Callable, Dict, List, Optional, Tuple
11
+
12
+ # ── 类型名称映射 ──────────────────────────────────────────────────
13
+
14
+ # 内部中文类型名 ↔ 命令行英文类型名
15
+ _TYPE_EN_TO_CN: Dict[str, str] = {
16
+ "tea_base": "茶底",
17
+ "milk_base": "奶底",
18
+ "temperature": "温度",
19
+ "sugar": "糖度",
20
+ "size": "规格",
21
+ }
22
+
23
+ _TYPE_CN_TO_EN: Dict[str, str] = {v: k for k, v in _TYPE_EN_TO_CN.items()}
24
+
25
+ _VALID_TYPES_EN = list(_TYPE_EN_TO_CN.keys())
26
+ _VALID_TYPES_CN = list(_TYPE_CN_TO_EN.keys())
27
+
28
+
29
+ def _resolve_type(raw: str) -> Optional[str]:
30
+ """将用户输入的类型名转换为内部中文类型名。
31
+
32
+ 支持中英文:tea_base→茶底,茶底→茶底。
33
+ 返回 None 表示非法类型。
34
+ """
35
+ if raw in _TYPE_CN_TO_EN:
36
+ return raw # 已是中文
37
+ if raw in _TYPE_EN_TO_CN:
38
+ return _TYPE_EN_TO_CN[raw]
39
+ return None
40
+
41
+
42
+ # ── 命令处理器 ────────────────────────────────────────────────────
43
+
44
+ # 每个处理器签名为: handler(args: List[str]) -> str
45
+ # 对于需要确认的操作,返回以 "[CONFIRM]" 开头的字符串,主循环会跟进确认提示。
46
+
47
+
48
+ def _cmd_help(_args: List[str]) -> str:
49
+ """显示所有可用指令。"""
50
+ return """
51
+ ══════════════════════════════════════════════════
52
+ POS Agent /指令系统
53
+ ══════════════════════════════════════════════════
54
+
55
+ 记忆管理 (/memory):
56
+ /memory list 列出所有 token 别名
57
+ /memory add <词语> <类型> 添加 token 别名
58
+ /memory edit <词语> <类型> 修改已有 token 别名类型
59
+ /memory delete <词语> 删除 token 别名
60
+ /memory reset 清空所有长期记忆
61
+
62
+ 模板管理 (/template):
63
+ /template list 列出已缓存的模板
64
+ /template show <指纹前N位> 查看模板字段映射配置
65
+ /template clear <指纹前N位> 删除指定模板缓存
66
+
67
+ 映射任务 (/run):
68
+ /run -m <主数据表> -t <模板> -o <输出> [--target-col <列名>] [-r <报告>]
69
+
70
+ 通用:
71
+ /help 显示此帮助信息
72
+ /exit 退出 REPL
73
+
74
+ 类型合法值:
75
+ tea_base / milk_base / temperature / sugar / size
76
+ ══════════════════════════════════════════════════"""
77
+
78
+
79
+ def _cmd_memory_list(_args: List[str]) -> str:
80
+ """列出所有 token 别名。"""
81
+ from menupilot.data.memory import list_aliases
82
+
83
+ aliases = list_aliases()
84
+ if not aliases:
85
+ return "(暂无 token 别名记录)"
86
+
87
+ lines = [" 词语 类型 添加时间", " " + "-" * 48]
88
+ for word, info in sorted(aliases.items()):
89
+ t = info.get("type", "?")
90
+ added = info.get("added", "?")
91
+ # 对齐:中文按 2 字符宽度算
92
+ word_pad = word.ljust(18)
93
+ type_pad = t.ljust(10)
94
+ lines.append(f" {word_pad}{type_pad}{added}")
95
+ return "\n".join(lines)
96
+
97
+
98
+ def _cmd_memory_add(args: List[str]) -> str:
99
+ """添加 token 别名:/memory add <词语> <类型>"""
100
+ if len(args) < 2:
101
+ return "用法: /memory add <词语> <类型>\n类型合法值: " + ", ".join(_VALID_TYPES_EN)
102
+
103
+ word = args[0]
104
+ raw_type = args[1]
105
+ cn_type = _resolve_type(raw_type)
106
+
107
+ if cn_type is None:
108
+ return (
109
+ f"非法类型「{raw_type}」\n"
110
+ f"合法值: {', '.join(_VALID_TYPES_EN)}\n"
111
+ f"中文别名: {', '.join(_VALID_TYPES_CN)}"
112
+ )
113
+
114
+ from menupilot.data.memory import add_token
115
+
116
+ add_token(word, cn_type)
117
+ en_type = _TYPE_CN_TO_EN.get(cn_type, cn_type)
118
+ return f"已添加: 「{word}」→ {cn_type} ({en_type})"
119
+
120
+
121
+ def _cmd_memory_edit(args: List[str]) -> str:
122
+ """编辑 token 别名类型:/memory edit <词语> <新类型>"""
123
+ if len(args) < 2:
124
+ return "用法: /memory edit <词语> <新类型>\n类型合法值: " + ", ".join(_VALID_TYPES_EN)
125
+
126
+ word = args[0]
127
+ raw_type = args[1]
128
+ cn_type = _resolve_type(raw_type)
129
+
130
+ if cn_type is None:
131
+ return (
132
+ f"非法类型「{raw_type}」\n"
133
+ f"合法值: {', '.join(_VALID_TYPES_EN)}\n"
134
+ f"中文别名: {', '.join(_VALID_TYPES_CN)}"
135
+ )
136
+
137
+ from menupilot.data.memory import edit_token, get_token_type
138
+
139
+ old_type = get_token_type(word)
140
+ if old_type is None:
141
+ return f"词条「{word}」不存在,无法编辑。请先用 /memory add 添加"
142
+
143
+ ok = edit_token(word, cn_type)
144
+ if ok:
145
+ en_type = _TYPE_CN_TO_EN.get(cn_type, cn_type)
146
+ return f"已修改: 「{word}」{old_type} → {cn_type} ({en_type})"
147
+ return f"编辑失败:词条「{word}」不存在"
148
+
149
+
150
+ def _cmd_memory_delete(args: List[str]) -> str:
151
+ """删除 token 别名:/memory delete <词语>(需确认)"""
152
+ if len(args) < 1:
153
+ return "用法: /memory delete <词语>"
154
+
155
+ word = args[0]
156
+ from menupilot.data.memory import get_token_type
157
+
158
+ if get_token_type(word) is None:
159
+ return f"词条「{word}」不存在"
160
+
161
+ return f'[CONFIRM] delete_token {word} 确认删除「{word}」?(y/n)'
162
+
163
+
164
+ def _cmd_memory_reset(_args: List[str]) -> str:
165
+ """清空所有记忆(需二次确认)。"""
166
+ from menupilot.data.memory import get_stats
167
+
168
+ stats = get_stats()
169
+ return (
170
+ f"[CONFIRM] reset_memory 此操作将清空所有长期记忆,"
171
+ f"包括 {stats['aliases']} 条词典别名 "
172
+ f"和 {stats['templates']} 个模板规则,确认?(yes/不可撤销)"
173
+ )
174
+
175
+
176
+ def _cmd_template_list(_args: List[str]) -> str:
177
+ """列出已缓存的模板。"""
178
+ from menupilot.data.memory import get_template_rules
179
+
180
+ rules = get_template_rules()
181
+ if not rules:
182
+ return "(暂无模板缓存)"
183
+
184
+ lines = [" 指纹(前8位) 列数 缓存时间", " " + "-" * 44]
185
+ for fp, entry in sorted(rules.items()):
186
+ fp8 = fp[:8]
187
+ if isinstance(entry, dict):
188
+ rule = entry.get("rule", entry)
189
+ n_cols = len(rule.get("field_mapping", {})) if isinstance(rule, dict) else "?"
190
+ cached = entry.get("cached_at", "?")
191
+ else:
192
+ n_cols = "?"
193
+ cached = "?"
194
+ lines.append(f" {fp8} {str(n_cols).ljust(6)} {cached}")
195
+ return "\n".join(lines)
196
+
197
+
198
+ def _cmd_template_show(args: List[str]) -> str:
199
+ """查看模板字段映射配置:/template show <指纹前N位>"""
200
+ if len(args) < 1:
201
+ return "用法: /template show <指纹前N位>"
202
+
203
+ prefix = args[0]
204
+ from menupilot.data.memory import get_template_rules
205
+
206
+ rules = get_template_rules()
207
+ matches = [(fp, entry) for fp, entry in rules.items() if fp.startswith(prefix)]
208
+
209
+ if len(matches) == 0:
210
+ return f"未找到指纹以「{prefix}」开头的模板缓存"
211
+ if len(matches) > 1:
212
+ fps = ", ".join(m[0][:8] for m in matches)
213
+ return f"前缀「{prefix}」匹配到多个模板: {fps}\n请提供更长的前缀"
214
+
215
+ fp, entry = matches[0]
216
+ if isinstance(entry, dict):
217
+ rule = entry.get("rule", entry)
218
+ else:
219
+ rule = entry
220
+
221
+ lines = [
222
+ f"模板指纹: {fp}",
223
+ f"缓存时间: {entry.get('cached_at', '?') if isinstance(entry, dict) else '?'}",
224
+ "",
225
+ "字段映射 (模板列 → 标准字段):",
226
+ ]
227
+ fm = rule.get("field_mapping", {}) if isinstance(rule, dict) else {}
228
+ for tcol, cfield in fm.items():
229
+ lines.append(f" {tcol} → {cfield}")
230
+
231
+ composite = rule.get("composite_col") if isinstance(rule, dict) else None
232
+ target = rule.get("target_col") if isinstance(rule, dict) else None
233
+ irrelevant = rule.get("irrelevant_cols", []) if isinstance(rule, dict) else []
234
+
235
+ lines.append(f"\n复合列: {composite or '(无)'}")
236
+ lines.append(f"目标列: {target or '(无)'}")
237
+ if irrelevant:
238
+ lines.append(f"忽略列: {', '.join(irrelevant)}")
239
+
240
+ return "\n".join(lines)
241
+
242
+
243
+ def _cmd_template_clear(args: List[str]) -> str:
244
+ """删除指定模板缓存:/template clear <指纹前N位>(需确认)"""
245
+ if len(args) < 1:
246
+ return "用法: /template clear <指纹前N位>"
247
+
248
+ prefix = args[0]
249
+ from menupilot.data.memory import get_template_rules
250
+
251
+ rules = get_template_rules()
252
+ matches = [(fp, entry) for fp, entry in rules.items() if fp.startswith(prefix)]
253
+
254
+ if len(matches) == 0:
255
+ return f"未找到指纹以「{prefix}」开头的模板缓存"
256
+ if len(matches) > 1:
257
+ fps = ", ".join(m[0][:8] for m in matches)
258
+ return f"前缀「{prefix}」匹配到多个模板: {fps}\n请提供更长的前缀"
259
+
260
+ fp = matches[0][0]
261
+ return f"[CONFIRM] clear_template {fp} {prefix}"
262
+
263
+
264
+ def _cmd_run(args: List[str]) -> str:
265
+ """在 REPL 内执行映射任务:/run -m <主数据表> -t <模板> -o <输出> [...]"""
266
+ if not args:
267
+ return "用法: /run -m <主数据表> -t <模板> -o <输出> [--target-col <列名>] [-r <报告>]"
268
+
269
+ # 委托给 main.run()
270
+ from main import run
271
+
272
+ exit_code = run(args)
273
+ if exit_code == 0:
274
+ return "" # run() 自己打印了完整输出,这里只返回空
275
+ else:
276
+ return f"\n[!] 映射任务失败 (exit_code={exit_code})"
277
+
278
+
279
+ def _cmd_exit(_args: List[str]) -> str:
280
+ """退出 REPL。"""
281
+ return "[EXIT]"
282
+
283
+
284
+ # ── 二级路由 ──────────────────────────────────────────────────────
285
+
286
+
287
+ def _cmd_memory_dispatch(args: List[str]) -> str:
288
+ """二级路由:/memory <subcommand> [...]"""
289
+ if not args:
290
+ return "用法: /memory list|add|delete|reset"
291
+ sub = args[0].lower()
292
+ rest = args[1:]
293
+ if sub == "list":
294
+ return _cmd_memory_list(rest)
295
+ elif sub == "add":
296
+ return _cmd_memory_add(rest)
297
+ elif sub == "edit":
298
+ return _cmd_memory_edit(rest)
299
+ elif sub == "delete":
300
+ return _cmd_memory_delete(rest)
301
+ elif sub == "reset":
302
+ return _cmd_memory_reset(rest)
303
+ else:
304
+ return f"未知 /memory 子指令: {sub}\n可用: list, add, edit, delete, reset"
305
+
306
+
307
+ def _cmd_template_dispatch(args: List[str]) -> str:
308
+ """二级路由:/template <subcommand> [...]"""
309
+ if not args:
310
+ return "用法: /template list|show|clear"
311
+ sub = args[0].lower()
312
+ rest = args[1:]
313
+ if sub == "list":
314
+ return _cmd_template_list(rest)
315
+ elif sub == "show":
316
+ return _cmd_template_show(rest)
317
+ elif sub == "clear":
318
+ return _cmd_template_clear(rest)
319
+ else:
320
+ return f"未知 /template 子指令: {sub}\n可用: list, show, clear"
321
+
322
+
323
+ # ── 指令路由表 ────────────────────────────────────────────────────
324
+
325
+ _COMMANDS: Dict[str, Callable[[List[str]], str]] = {
326
+ "help": _cmd_help,
327
+ "memory": _cmd_memory_dispatch,
328
+ "template": _cmd_template_dispatch,
329
+ "run": _cmd_run,
330
+ "exit": _cmd_exit,
331
+ }
332
+
333
+
334
+ # ── 命令解析与分发 ────────────────────────────────────────────────
335
+
336
+
337
+ def _parse_line(line: str) -> Tuple[str, List[str]]:
338
+ """将输入行解析为指令名和参数列表。
339
+
340
+ 支持双引号包裹的参数(用于含空格的文件路径)。
341
+ 示例:
342
+ /run -m "my data.xlsx" -t t.xlsx -o out.xlsx
343
+ → ("run", ["-m", "my data.xlsx", "-t", "t.xlsx", "-o", "out.xlsx"])
344
+ """
345
+ import shlex
346
+
347
+ # shlex.split 处理引号内的空格
348
+ try:
349
+ parts = shlex.split(line)
350
+ except ValueError:
351
+ # 引号不匹配,降级为简单 split
352
+ parts = line.split()
353
+
354
+ if not parts:
355
+ return ("", [])
356
+
357
+ cmd = parts[0]
358
+ if cmd.startswith("/"):
359
+ cmd = cmd[1:] # 去掉前导 /
360
+ return (cmd.lower(), parts[1:])
361
+
362
+
363
+ def process_command(line: str) -> str:
364
+ """解析并执行一条斜杠指令,返回输出文本。
365
+
366
+ 此函数是 REPL 的核心入口,也可供外部测试直接调用。
367
+
368
+ 特殊返回值:
369
+ "[EXIT]" → 调用方应退出 REPL
370
+ "[CONFIRM] ..." → 调用方应启动确认流程
371
+
372
+ Args:
373
+ line: 用户输入的一行文本。
374
+
375
+ Returns:
376
+ 指令执行结果文本。
377
+ """
378
+ cmd_name, args = _parse_line(line)
379
+
380
+ if not cmd_name:
381
+ return ""
382
+
383
+ if cmd_name not in _COMMANDS:
384
+ return f"未知指令「/{cmd_name}」,输入 /help 查看可用指令"
385
+
386
+ try:
387
+ return _COMMANDS[cmd_name](args)
388
+ except Exception as e:
389
+ return f"[错误] 指令执行失败: {e}"
390
+
391
+
392
+ # ── 确认流程 ──────────────────────────────────────────────────────
393
+
394
+
395
+ def _handle_confirm(action: str, payload: str) -> str:
396
+ """执行确认后的实际操作(delete_token / reset_memory / clear_template)。
397
+
398
+ Args:
399
+ action: "delete_token", "reset_memory", "clear_template"
400
+ payload: 附加参数
401
+
402
+ Returns:
403
+ 操作结果文本。
404
+ """
405
+ if action == "delete_token":
406
+ word = payload
407
+ from menupilot.data.memory import delete_token as mem_delete_token
408
+
409
+ ok = mem_delete_token(word)
410
+ return f"已删除词条「{word}」" if ok else f"删除失败:词条「{word}」不存在"
411
+
412
+ elif action == "reset_memory":
413
+ from menupilot.data.memory import reset_memory as mem_reset
414
+
415
+ mem_reset()
416
+ return "已清空所有长期记忆"
417
+
418
+ elif action == "clear_template":
419
+ parts = payload.split(" ", 1)
420
+ fp = parts[0]
421
+ prefix = parts[1] if len(parts) > 1 else fp[:8]
422
+ from menupilot.data.memory import delete_template_rule as mem_delete_template
423
+
424
+ deleted = mem_delete_template(fp)
425
+ if deleted:
426
+ return f"已删除模板缓存(指纹: {deleted[:16]}...)"
427
+ else:
428
+ return f"删除失败:未找到指纹为「{prefix}」的模板缓存"
429
+
430
+ return f"未知确认操作: {action}"
431
+
432
+
433
+ # ── REPL 主循环 ────────────────────────────────────────────────────
434
+
435
+ _nl_agent = None # 进程级短期记忆:REPL 会话内跨轮次持久,退出进程即销毁
436
+
437
+
438
+ def _get_nl_agent():
439
+ global _nl_agent
440
+ if _nl_agent is None:
441
+ from openai import OpenAI
442
+ from menupilot import config
443
+ llm = OpenAI(api_key=config.DEEPSEEK_API_KEY, base_url=config.DEEPSEEK_BASE_URL)
444
+ llm.model = config.DEEPSEEK_MODEL
445
+ from menupilot.agent.agent_loop import AgentLoop
446
+ _nl_agent = AgentLoop(llm)
447
+ return _nl_agent
448
+
449
+
450
+ def repl_loop() -> None:
451
+ """REPL 主循环。读取用户输入 → 分发执行 → 打印结果。"""
452
+ print("══════════════════════════════════════════════════")
453
+ print(" POS Template Mapping Agent — 交互模式")
454
+ print(" 输入 /help 查看可用指令,/exit 退出")
455
+ print(" 不带 / 前缀的参数将执行映射任务")
456
+ print("══════════════════════════════════════════════════")
457
+ print()
458
+
459
+ pending_confirm: Optional[Tuple[str, str]] = None # (action, payload)
460
+
461
+ while True:
462
+ try:
463
+ prompt = "pos-agent> " if pending_confirm is None else " 确认? (y/n/yes/不可撤销) > "
464
+ line = input(prompt).strip()
465
+ except (KeyboardInterrupt, EOFError):
466
+ print("\n再见")
467
+ break
468
+
469
+ if not line:
470
+ continue
471
+
472
+ # ── 处理待确认状态 ──
473
+ if pending_confirm is not None:
474
+ action, payload = pending_confirm
475
+ low = line.lower().strip()
476
+
477
+ if action == "reset_memory":
478
+ # /memory reset 需要完整输入 "yes" 或 "不可撤销"
479
+ if low in ("yes", "不可撤销"):
480
+ print(_handle_confirm(action, payload))
481
+ else:
482
+ print("已取消")
483
+ pending_confirm = None
484
+ continue
485
+ else:
486
+ # delete / clear 只需要 y/n
487
+ if low in ("y", "yes"):
488
+ print(_handle_confirm(action, payload))
489
+ elif low in ("n", "no"):
490
+ print("已取消")
491
+ else:
492
+ print("请输入 y 或 n")
493
+ continue
494
+ pending_confirm = None
495
+ continue
496
+
497
+ # ── 如果是斜杠指令 ──
498
+ if line.startswith("/"):
499
+ result = process_command(line)
500
+
501
+ if result.startswith("[EXIT]"):
502
+ print("再见")
503
+ break
504
+
505
+ if result.startswith("[CONFIRM]"):
506
+ # 解析确认元数据
507
+ # 格式: [CONFIRM] <action> <payload...>
508
+ parts = result.split(" ", 2)
509
+ action = parts[1] if len(parts) > 1 else ""
510
+ payload = parts[2] if len(parts) > 2 else ""
511
+ pending_confirm = (action, payload)
512
+ # 打印确认提示
513
+ print(result.split(" ", 2)[2] if len(parts) > 2 else result)
514
+ continue
515
+
516
+ if result:
517
+ print(result)
518
+ else:
519
+ # 非斜杠开头 → 自然语言模式(Agent Loop)或 CLI 参数(/run 向后兼容)
520
+ parts = _parse_line(line)
521
+ if parts and parts[0].startswith("-"):
522
+ # 以 - 开头 → 传统 CLI 模式
523
+ run_result = _cmd_run(parts)
524
+ if run_result:
525
+ print(run_result)
526
+ else:
527
+ # 自然语言 → Agent Loop(短期记忆跨轮次保持)
528
+ print()
529
+ try:
530
+ result = _get_nl_agent().continue_conversation(line.strip())
531
+ print(f"\n{result}")
532
+ except ImportError:
533
+ print("[ERROR] Agent 模式需要 openai 库。请使用 /run 命令。")
534
+ except Exception as e:
535
+ print(f"[ERROR] Agent 执行失败: {e}")
536
+
537
+
538
+ # ── 自测 ──────────────────────────────────────────────────────────
539
+
540
+ if __name__ == "__main__":
541
+ import os as _os
542
+
543
+ _os.environ["USE_MOCK_LLM"] = "1"
544
+ import importlib
545
+ importlib.reload(__import__("config"))
546
+
547
+ # ── 备份真实 memory.json ──
548
+ import shutil as _shutil
549
+ _mem_path = _os.path.expanduser("~/.menupilot/memory.json")
550
+ _mem_backup = None
551
+ if _os.path.exists(_mem_path):
552
+ _mem_backup_path = _mem_path + ".self_test_backup"
553
+ _shutil.copy(_mem_path, _mem_backup_path)
554
+ _mem_backup = _mem_backup_path
555
+
556
+ from menupilot.data.memory import reset_memory
557
+
558
+ reset_memory()
559
+
560
+ passed = 0
561
+ failed = 0
562
+
563
+ def check(condition, msg):
564
+ global passed, failed
565
+ if condition:
566
+ passed += 1
567
+ print(f" PASS {msg}")
568
+ else:
569
+ failed += 1
570
+ print(f" FAIL {msg}")
571
+
572
+ print("=== /指令系统 REPL 自测 ===\n")
573
+
574
+ # ── 1. /help ──
575
+ print("1. /help 输出完整指令列表")
576
+ help_out = process_command("/help")
577
+ check("记忆管理" in help_out, "含「记忆管理」")
578
+ check("模板管理" in help_out, "含「模板管理」")
579
+ check("映射任务" in help_out, "含「映射任务」")
580
+ check("/exit" in help_out, "含 /exit")
581
+ check("tea_base" in help_out, "含类型合法值")
582
+ print()
583
+
584
+ # ── 2. /memory add 合法类型 ──
585
+ print("2. /memory add 合法/非法类型")
586
+ r = process_command("/memory add 珍珠奶茶 tea_base")
587
+ check("已添加" in r and "茶底" in r, f"合法 tea_base → 成功: {r[:50]}")
588
+ r2 = process_command("/memory add 豆乳奶茶 milk_base")
589
+ check("已添加" in r2 and "奶底" in r2, f"合法 milk_base → 成功: {r2[:50]}")
590
+ print()
591
+
592
+ # ── 3. /memory add 非法类型 ──
593
+ print("3. /memory add 非法类型 → 给出合法值提示")
594
+ r3 = process_command("/memory add 测试 invalid_type")
595
+ check("非法类型" in r3, "返回「非法类型」")
596
+ check("tea_base" in r3, "含英文合法值列表")
597
+ print()
598
+
599
+ # ── 4. /memory add 参数不足 ──
600
+ print("4. /memory add 参数不足 → 用法提示")
601
+ r4 = process_command("/memory add")
602
+ check("用法" in r4, "参数不足时返回用法")
603
+ r4b = process_command("/memory add 只有一个参数")
604
+ check("用法" in r4b, "只有一个参数也返回用法")
605
+ print()
606
+
607
+ # ── 5. /memory list ──
608
+ print("5. /memory list 展示所有词条")
609
+ r5 = process_command("/memory list")
610
+ check("珍珠奶茶" in r5, "包含刚添加的「珍珠奶茶」")
611
+ check("豆乳奶茶" in r5, "包含刚添加的「豆乳奶茶」")
612
+ check("茶底" in r5, "包含类型信息")
613
+ print()
614
+
615
+ # ── 6. /memory delete 存在 → CONFIRM,然后确认 ──
616
+ print("6. /memory delete 确认流程")
617
+ r6 = process_command("/memory delete 珍珠奶茶")
618
+ check(r6.startswith("[CONFIRM]"), f"返回 CONFIRM: {r6[:60]}")
619
+ # 模拟确认
620
+ confirm_result = _handle_confirm("delete_token", "珍珠奶茶")
621
+ check("已删除" in confirm_result, f"确认后删除成功: {confirm_result}")
622
+ # 验证已删除
623
+ from menupilot.data.memory import get_token_type
624
+ check(get_token_type("珍珠奶茶") is None, "删除后词条不存在")
625
+ print()
626
+
627
+ # ── 7. /memory delete 不存在 ──
628
+ print("7. /memory delete 不存在的词")
629
+ r7 = process_command("/memory delete 不存在的词")
630
+ check("不存在" in r7, "提示词条不存在")
631
+ print()
632
+
633
+ # ── 8. /memory edit 修改已有词条类型 ──
634
+ print("8. /memory edit 修改已有词条类型")
635
+ # 先添加一个词
636
+ process_command("/memory add 选错的词 茶底")
637
+ check(get_token_type("选错的词") == "茶底", "初始类型为茶底")
638
+ # 编辑修改
639
+ r8a = process_command("/memory edit 选错的词 milk_base")
640
+ check("已修改" in r8a, f"编辑成功: {r8a[:50]}")
641
+ check(get_token_type("选错的词") == "奶底", "类型已改为奶底")
642
+ # 编辑不存在的词
643
+ r8b = process_command("/memory edit 不存在的词 茶底")
644
+ check("不存在" in r8b, "不存在的词提示错误")
645
+ # 编辑非法类型
646
+ r8c = process_command("/memory edit 选错的词 invalid")
647
+ check("非法类型" in r8c, "非法类型提示错误")
648
+ # 参数不足
649
+ r8d = process_command("/memory edit")
650
+ check("用法" in r8d, "参数不足提示用法")
651
+ r8e = process_command("/memory edit 只有一个参数")
652
+ check("用法" in r8e, "只有一个参数也提示用法")
653
+ print()
654
+
655
+ # ── 9. /memory reset 确认流程 ──
656
+ print("9. /memory reset 确认流程")
657
+ r8 = process_command("/memory reset")
658
+ check(r8.startswith("[CONFIRM]"), f"返回 CONFIRM: {r8[:60]}")
659
+ check("yes/不可撤销" in r8, "提示需要完整确认词")
660
+ # 模拟取消: 只返回 y 不够
661
+ # 模拟确认
662
+ reset_result = _handle_confirm("reset_memory", "")
663
+ check("已清空" in reset_result, f"确认后清空成功: {reset_result}")
664
+ print()
665
+
666
+ # ── 10. /memory list 空列表 ──
667
+ print("10. /memory list 空列表")
668
+ r9 = process_command("/memory list")
669
+ check("暂无" in r9, "空列表时提示「暂无」")
670
+ print()
671
+
672
+ # ── 11. /template list 有缓存 ──
673
+ print("11. /template list 有缓存时展示")
674
+ from menupilot.data.memory import save_template_rule
675
+ save_template_rule("a1b2c3d4e5f6a7b8", {
676
+ "field_mapping": {"菜品名称": "product_name", "规格": "size"},
677
+ "composite_col": "口味做法组合",
678
+ "target_col": "配料",
679
+ "irrelevant_cols": [],
680
+ })
681
+ r10 = process_command("/template list")
682
+ check("a1b2c3d4" in r10, "显示指纹前8位")
683
+ check("2" in r10, "显示列数")
684
+ print()
685
+
686
+ # ── 12. /template list 无缓存 ──
687
+ print("12. /template list 无缓存")
688
+ reset_memory()
689
+ r11 = process_command("/template list")
690
+ check("暂无" in r11, "无缓存时提示「暂无」")
691
+ # 恢复一个缓存供后续测试
692
+ save_template_rule("a1b2c3d4e5f6a7b8", {
693
+ "field_mapping": {"菜品名称": "product_name", "规格": "size"},
694
+ "composite_col": "口味做法组合",
695
+ "target_col": "配料",
696
+ "irrelevant_cols": [],
697
+ })
698
+ print()
699
+
700
+ # ── 13. /template show ──
701
+ print("13. /template show")
702
+ r12 = process_command("/template show a1b2c3d4")
703
+ check("菜品名称" in r12, "显示字段映射中的模板列")
704
+ check("product_name" in r12, "显示标准字段名")
705
+ check("口味做法组合" in r12, "显示 composite_col")
706
+ check("配料" in r12, "显示 target_col")
707
+ print()
708
+ r12b = process_command("/template show 不存在的指纹")
709
+ check("未找到" in r12b, "不存在的指纹提示未找到")
710
+ print()
711
+ r12c = process_command("/template show")
712
+ check("用法" in r12c, "无参数时提示用法")
713
+ print()
714
+
715
+ # ── 14. /template clear 确认流程 ──
716
+ print("14. /template clear 确认流程")
717
+ r13 = process_command("/template clear a1b2c3d4")
718
+ check(r13.startswith("[CONFIRM]"), f"返回 CONFIRM: {r13[:60]}")
719
+ # 模拟确认
720
+ parts = r13.split(" ", 2)
721
+ clear_action = parts[1]
722
+ clear_payload = parts[2]
723
+ clear_confirm = _handle_confirm(clear_action, clear_payload)
724
+ check("已删除" in clear_confirm, f"确认后删除成功: {clear_confirm}")
725
+ # 验证已删除
726
+ from menupilot.data.memory import get_template_rules
727
+ check(len(get_template_rules()) == 0, "缓存已清空")
728
+ print()
729
+
730
+ # ── 15. 未知指令 ──
731
+ print("15. 未知指令")
732
+ r14 = process_command("/unknown_command")
733
+ check("未知指令" in r14, "提示未知指令")
734
+ check("/help" in r14, "建议查看 /help")
735
+ print()
736
+
737
+ # ── 16. /exit ──
738
+ print("16. /exit")
739
+ r15 = process_command("/exit")
740
+ check(r15 == "[EXIT]", "返回 [EXIT] 信号")
741
+ print()
742
+
743
+ # ── 17. /run 在 REPL 内执行映射任务 ──
744
+ print("17. /run 在 REPL 内执行映射任务")
745
+ import tempfile
746
+ import pandas as pd
747
+
748
+ tmpdir = tempfile.mkdtemp()
749
+ master_path = os.path.join(tmpdir, "master.xlsx")
750
+ template_path = os.path.join(tmpdir, "template.xlsx")
751
+ output_path = os.path.join(tmpdir, "output.xlsx")
752
+
753
+ pd.DataFrame({
754
+ "品名": ["浅浅清茶", "珍珠奶茶"],
755
+ "杯型": ["中杯", "中杯"],
756
+ "奶底": ["牛奶", "椰乳"],
757
+ "做法": ["少冰", "热"],
758
+ "糖": ["七分糖", "无糖"],
759
+ "SOP": ["T240", "T180"],
760
+ }).to_excel(master_path, index=False)
761
+
762
+ pd.DataFrame({
763
+ "菜品名称": ["浅浅清茶", "珍珠奶茶"],
764
+ "规格": ["中杯", "中杯"],
765
+ "口味做法组合": ["牛奶, 少冰, 七分糖", "椰乳, 热, 无糖"],
766
+ "配料": ["", ""],
767
+ }).to_excel(template_path, index=False)
768
+
769
+ from menupilot.agent.token_classifier import reset_cache
770
+ reset_cache()
771
+
772
+ run_cmd = f'/run -m "{master_path}" -t "{template_path}" -o "{output_path}"'
773
+ r16 = process_command(run_cmd)
774
+ check(os.path.exists(output_path), "输出文件已生成")
775
+ print()
776
+
777
+ # cleanup
778
+ for f in [master_path, template_path, output_path,
779
+ output_path.replace(".xlsx", "_report.txt")]:
780
+ if os.path.exists(f):
781
+ os.remove(f)
782
+ os.rmdir(tmpdir)
783
+ reset_cache()
784
+
785
+ # ── 18. /run 参数不足 ──
786
+ print("18. /run 参数不足 → 用法提示")
787
+ r17 = process_command("/run")
788
+ check("用法" in r17, "返回用法提示")
789
+ print()
790
+
791
+ # ── 19. /memory add 支持中文类型 ──
792
+ print("19. /memory add 支持中文类型名")
793
+ r18 = process_command("/memory add 黑芝麻仙草 茶底")
794
+ check("已添加" in r18, f"中文类型名有效: {r18[:50]}")
795
+ check(get_token_type("黑芝麻仙草") == "茶底", "中文类型正确存储")
796
+ print()
797
+
798
+ # ── 20. 非斜杠输入 → /run ──
799
+ print("20. 空指令/边角情况")
800
+ r19a = process_command("")
801
+ check(r19a == "", "空输入返回空")
802
+ r19b = process_command("/memory")
803
+ check("用法" in r19b, "/memory 无子指令 → 用法")
804
+ r19c = process_command("/template")
805
+ check("用法" in r19c, "/template 无子指令 → 用法")
806
+ r19d = process_command("/memory unknown_sub")
807
+ check("未知" in r19d, "未知 memory 子指令 → 提示")
808
+ r19e = process_command("/template unknown_sub")
809
+ check("未知" in r19e, "未知 template 子指令 → 提示")
810
+ print()
811
+
812
+ # cleanup
813
+ reset_memory()
814
+
815
+ # ── 还原真实 memory.json ──
816
+ if _mem_backup:
817
+ from menupilot.data.memory import reload as _mem_reload
818
+ _shutil.move(_mem_backup, _mem_path)
819
+ _mem_reload()
820
+
821
+ print(f"=== 结果: {passed} passed, {failed} failed ===")