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/main.py ADDED
@@ -0,0 +1,322 @@
1
+ """
2
+ CLI 入口 — MenuPilot 智能 POS 模板映射助手。
3
+ 一条命令完成 SOP 字段自动映射。
4
+
5
+ 用法:
6
+ menupilot --master 主数据表.xlsx --template POS模板.xlsx --output 填充结果.xlsx
7
+ menupilot expand -m 主数据表.xlsx -t 选项模板.xlsx -o 输出.xlsx
8
+ """
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ from typing import Optional
14
+
15
+ # Windows 终端默认 GBK,中文容易乱码。切换为 UTF-8 全局输出。
16
+ if sys.platform == "win32":
17
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
18
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ """构建 SOP 匹配管线命令行参数解析器。"""
23
+ parser = argparse.ArgumentParser(
24
+ prog="menupilot",
25
+ description="MenuPilot — 智能 POS 模板映射助手",
26
+ formatter_class=argparse.RawDescriptionHelpFormatter,
27
+ epilog="""
28
+ 示例:
29
+ menupilot --master 主数据表.xlsx --template POS模板.xlsx --output 结果.xlsx
30
+ menupilot -m 主数据表.xlsx -t POS模板.xlsx -o 结果.xlsx --target-col 配料
31
+ menupilot -m 主数据表.xlsx -t POS模板.xlsx -o 结果.xlsx -r 报告.txt
32
+
33
+ --sheet 参数(位置语义):
34
+ -t template.xlsx --sheet 1 → 模板表读取第 2 个 Sheet
35
+ -m master.xlsx --sheet 2 → 主数据表读取第 3 个 Sheet
36
+ """,
37
+ )
38
+ parser.add_argument("-m", "--master", required=True, help="主数据表 Excel 文件路径")
39
+ parser.add_argument("-t", "--template", required=True, help="POS 模板 Excel 文件路径")
40
+ parser.add_argument("-o", "--output", required=True, help="输出 Excel 文件路径")
41
+ parser.add_argument("--target-col", default="配料", help="模板中需要填充 SOP 的目标列名")
42
+ parser.add_argument("-r", "--report", default=None, help="校验报告输出路径")
43
+ parser.add_argument("--langgraph", action="store_true", default=True, help="使用 LangGraph 编排管线")
44
+ parser.add_argument("--no-langgraph", action="store_false", dest="langgraph", help="禁用 LangGraph")
45
+ return parser
46
+
47
+
48
+ def build_expand_parser() -> argparse.ArgumentParser:
49
+ """构建 expand 子命令参数解析器。"""
50
+ parser = argparse.ArgumentParser(
51
+ prog="menupilot expand",
52
+ description="选项规格模板展开器 — 将主数据表的选项值展开为空白模板行",
53
+ formatter_class=argparse.RawDescriptionHelpFormatter,
54
+ epilog="示例:\n menupilot expand -m 选项主数据.xlsx -t 选项模板.xlsx -o 输出.xlsx",
55
+ )
56
+ parser.add_argument("-m", "--master", required=True, help="选项规格主数据表 Excel 路径")
57
+ parser.add_argument("-t", "--template", required=True, help="空白选项模板 Excel 路径")
58
+ parser.add_argument("-o", "--output", required=True, help="输出 Excel 文件路径")
59
+ parser.add_argument("--sheet", type=int, default=0, help="Sheet 序号(默认 0)")
60
+ parser.add_argument("--template-sheet", type=int, default=None, help="模板表 Sheet 序号")
61
+ parser.add_argument("--master-sheet", type=int, default=None, help="主数据表 Sheet 序号")
62
+ parser.add_argument("--header-row", type=int, default=2, help="模板表头行号(默认 2)")
63
+ return parser
64
+
65
+
66
+ # ═══════════════════════════════════════════════════════════════════
67
+ # 入口(委托给 orchestration 层)
68
+ # ═══════════════════════════════════════════════════════════════════
69
+
70
+ def run(args: Optional[list] = None) -> int:
71
+ """执行 SOP 匹配管线(委托给 agent.orchestration.run_sop_pipeline)。"""
72
+ from menupilot.agent.orchestration import run_sop_pipeline
73
+ return run_sop_pipeline(args)
74
+
75
+
76
+ def run_expand(args: Optional[list] = None) -> int:
77
+ """执行选项展开管线(委托给 agent.orchestration.run_expand_pipeline)。"""
78
+ from menupilot.agent.orchestration import run_expand_pipeline
79
+ return run_expand_pipeline(args)
80
+
81
+
82
+ # ═══════════════════════════════════════════════════════════════════
83
+ # API Key 检查
84
+ # ═══════════════════════════════════════════════════════════════════
85
+
86
+ def _ensure_api_key():
87
+ """确保已配置 DeepSeek API Key,否则启动配置向导。"""
88
+ from menupilot import config
89
+ if not config.DEEPSEEK_API_KEY:
90
+ from menupilot.wizard import run_wizard
91
+ run_wizard()
92
+ import importlib
93
+ importlib.reload(config)
94
+ if not config.DEEPSEEK_API_KEY:
95
+ print("\n❌ 未配置 API Key,无法启动。"
96
+ "请设置环境变量 DEEPSEEK_API_KEY 或运行 menupilot 重新配置。")
97
+ sys.exit(1)
98
+
99
+
100
+ # ═══════════════════════════════════════════════════════════════════
101
+ # 入口调度
102
+ # ═══════════════════════════════════════════════════════════════════
103
+
104
+ def main():
105
+ """MenuPilot CLI 主入口。"""
106
+ # expand 子命令路由(纯规则引擎,无需 API Key)
107
+ if len(sys.argv) > 1 and sys.argv[1] == "expand":
108
+ sys.exit(run_expand(sys.argv[2:]))
109
+
110
+ # 自测模式
111
+ if "--self-test" in sys.argv:
112
+ sys.argv.remove("--self-test")
113
+ import tempfile, shutil
114
+ import pandas as pd
115
+
116
+ # ── 备份真实 memory.json(防止自测清空长期记忆)──
117
+ _mem_path = os.path.expanduser("~/.menupilot/memory.json")
118
+ _mem_backup = None
119
+ if os.path.exists(_mem_path):
120
+ _mem_backup_path = _mem_path + ".self_test_backup"
121
+ shutil.copy(_mem_path, _mem_backup_path)
122
+ _mem_backup = _mem_backup_path
123
+
124
+ from menupilot.agent.orchestration import (
125
+ set_batch_mode, set_column_prompt_hook,
126
+ _batch_mode,
127
+ )
128
+
129
+ os.environ["USE_MOCK_LLM"] = "1"
130
+ import importlib
131
+ from menupilot import config as _cfg
132
+ importlib.reload(_cfg)
133
+
134
+ passed = 0
135
+ failed = 0
136
+
137
+ def check(condition, msg):
138
+ nonlocal passed, failed
139
+ if condition:
140
+ passed += 1
141
+ print(f" PASS {msg}")
142
+ else:
143
+ failed += 1
144
+ print(f" FAIL {msg}")
145
+
146
+ print("=== main.py CLI 自测(Mock 模式)===\n")
147
+
148
+ tmpdir = tempfile.mkdtemp()
149
+ master_path = os.path.join(tmpdir, "master.xlsx")
150
+ template_path = os.path.join(tmpdir, "template.xlsx")
151
+ output_path = os.path.join(tmpdir, "output.xlsx")
152
+
153
+ pd.DataFrame({
154
+ "品名": ["浅浅清茶", "珍珠奶茶"],
155
+ "杯型": ["中杯", "中杯"],
156
+ "奶底": ["牛奶", "椰乳"],
157
+ "做法": ["少冰", "热"],
158
+ "糖": ["七分糖", "无糖"],
159
+ "SOP": ["T240", "T180"],
160
+ }).to_excel(master_path, index=False)
161
+
162
+ pd.DataFrame({
163
+ "菜品名称": ["浅浅清茶", "珍珠奶茶"],
164
+ "规格": ["中杯", "中杯"],
165
+ "口味做法组合": ["牛奶, 少冰, 七分糖", "椰乳, 热, 无糖"],
166
+ "配料": ["", ""],
167
+ }).to_excel(template_path, index=False)
168
+
169
+ try:
170
+ # ── 1. 基本 CLI 运行 ──
171
+ print("1. 基本 CLI 运行(--master --template --output)")
172
+ exit_code = run([
173
+ "--master", master_path,
174
+ "--template", template_path,
175
+ "--output", output_path,
176
+ ])
177
+ check(exit_code == 0, f"exit_code=0(实际 {exit_code})")
178
+ check(os.path.exists(output_path), "输出文件已生成")
179
+ report_path = output_path.replace(".xlsx", "_report.txt")
180
+ check(os.path.exists(report_path), "报告文件已生成")
181
+ print()
182
+
183
+ # ── 2. 自定义参数 ──
184
+ print("2. 自定义 --target-col 和 --report")
185
+ custom_report = os.path.join(tmpdir, "custom_report.txt")
186
+ exit_code2 = run([
187
+ "-m", master_path, "-t", template_path, "-o", output_path,
188
+ "--target-col", "配料", "-r", custom_report,
189
+ ])
190
+ check(exit_code2 == 0, f"exit_code=0(实际 {exit_code2})")
191
+ check(os.path.exists(custom_report), "自定义报告路径生效")
192
+ print()
193
+
194
+ # ── 3. 缺失文件错误 ──
195
+ print("3. 缺失文件错误处理")
196
+ exit_code3 = run(["-m", "不存在的文件.xlsx", "-t", template_path, "-o", output_path])
197
+ check(exit_code3 == 1, f"exit_code=1(实际 {exit_code3})")
198
+ print()
199
+
200
+ # ── 4. --help ──
201
+ print("4. --help 参数解析")
202
+ try:
203
+ help_text = build_parser().format_help()
204
+ check("--master" in help_text, "--master 出现在 help 中")
205
+ check("--target-col" in help_text, "--target-col 出现在 help 中")
206
+ except SystemExit:
207
+ check(False, "--help 不应触发 SystemExit")
208
+ print()
209
+
210
+ # ── 5. 简写参数 ──
211
+ print("5. 简写参数 -m -t -o")
212
+ exit_code5 = run(["-m", master_path, "-t", template_path, "-o", output_path])
213
+ check(exit_code5 == 0, "简写参数正常执行")
214
+ print()
215
+
216
+ # ── 6. --sheet 位置语义 ──
217
+ print("6. --sheet 位置语义")
218
+ master_multi = os.path.join(tmpdir, "master_multi.xlsx")
219
+ with pd.ExcelWriter(master_multi, engine="openpyxl") as writer:
220
+ pd.DataFrame({
221
+ "品名": ["浅浅清茶"], "杯型": ["中杯"], "奶底": ["牛奶"],
222
+ "做法": ["少冰"], "糖": ["七分糖"], "SOP": ["SHEET0_WRONG"],
223
+ }).to_excel(writer, sheet_name="Sheet0", index=False)
224
+ pd.DataFrame({
225
+ "品名": ["浅浅清茶"], "杯型": ["中杯"], "奶底": ["牛奶"],
226
+ "做法": ["少冰"], "糖": ["七分糖"], "SOP": ["T240_CORRECT"],
227
+ }).to_excel(writer, sheet_name="Sheet1", index=False)
228
+
229
+ template_multi = os.path.join(tmpdir, "template_multi.xlsx")
230
+ with pd.ExcelWriter(template_multi, engine="openpyxl") as writer:
231
+ pd.DataFrame({
232
+ "菜品名称": ["不相干商品"], "规格": ["大杯"],
233
+ "口味做法组合": ["红茶, 全糖, 正常冰"], "配料": [""],
234
+ }).to_excel(writer, sheet_name="Sheet0", index=False)
235
+ pd.DataFrame({
236
+ "菜品名称": ["浅浅清茶"], "规格": ["中杯"],
237
+ "口味做法组合": ["牛奶, 少冰, 七分糖"], "配料": [""],
238
+ }).to_excel(writer, sheet_name="Sheet1", index=False)
239
+
240
+ print(" 6a: --sheet 1 在 -t 后 → 模板 Sheet 1")
241
+ exit_code6a = run(["-m", master_multi, "-t", template_multi, "--sheet", "1", "-o", output_path])
242
+ check(exit_code6a == 0, f"exit_code=0(实际 {exit_code6a})")
243
+ df_6a = pd.read_excel(output_path)
244
+ check(df_6a.iloc[0]["配料"] == "SHEET0_WRONG",
245
+ f"主数据 Sheet 0,SOP=SHEET0_WRONG(实际 {df_6a.iloc[0]['配料']})")
246
+ print()
247
+
248
+ # ── 7. 交互式列分类(Mock hook)──
249
+ print("7. 交互式列分类(_interactive_classify_columns)")
250
+ from menupilot.data.memory import reset_memory as mem_reset, get_column_alias as mem_get_col
251
+ mem_reset()
252
+
253
+ df_interactive = pd.DataFrame({
254
+ "菜品名称": ["测试商品"], "原料类型": ["红茶"],
255
+ "规格": ["中杯"], "配料": [""], "备注": [""],
256
+ })
257
+ interactive_path = os.path.join(tmpdir, "interactive.xlsx")
258
+ df_interactive.to_excel(interactive_path, index=False)
259
+ interactive_out = os.path.join(tmpdir, "interactive_out.xlsx")
260
+
261
+ hook_calls = []
262
+ def mock_column_hook(col, sample):
263
+ hook_calls.append((col, sample))
264
+ mapping = {"原料类型": "tea_base", "备注": "ignore"}
265
+ return mapping.get(col, "ignore")
266
+
267
+ set_column_prompt_hook(mock_column_hook)
268
+ from menupilot import config as cfg_inner
269
+ orig_schema = dict(cfg_inner.MOCK_SCHEMA_RESPONSE)
270
+ cfg_inner.MOCK_SCHEMA_RESPONSE = {
271
+ "field_mapping": {"菜品名称": "product_name", "规格": "size"},
272
+ "composite_col": None, "target_col": "配料", "irrelevant_cols": [],
273
+ }
274
+
275
+ exit_code8 = run(["-m", master_path, "-t", interactive_path, "-o", interactive_out])
276
+ check(exit_code8 == 0, f"交互分类后正常执行(实际 {exit_code8})")
277
+ called_cols = {c for c, _ in hook_calls}
278
+ check("原料类型" in called_cols, "「原料类型」触发交互")
279
+ check(mem_get_col("原料类型") == "tea_base", "别名已持久化")
280
+ cfg_inner.MOCK_SCHEMA_RESPONSE = orig_schema
281
+ set_column_prompt_hook(None)
282
+ mem_reset()
283
+ print()
284
+
285
+ finally:
286
+ for f in [master_path, template_path, output_path,
287
+ output_path.replace(".xlsx", "_report.txt"),
288
+ os.path.join(tmpdir, "custom_report.txt"),
289
+ os.path.join(tmpdir, "master_multi.xlsx"),
290
+ os.path.join(tmpdir, "template_multi.xlsx"),
291
+ os.path.join(tmpdir, "interactive.xlsx"),
292
+ os.path.join(tmpdir, "interactive_out.xlsx"),
293
+ os.path.join(tmpdir, "interactive_out_report.txt")]:
294
+ if os.path.exists(f):
295
+ os.remove(f)
296
+ os.rmdir(tmpdir)
297
+
298
+ from menupilot.agent.token_classifier import reset_cache
299
+ reset_cache()
300
+
301
+ # ── 还原真实 memory.json ──
302
+ if _mem_backup:
303
+ from menupilot.data.memory import reload as mem_reload
304
+ shutil.move(_mem_backup, _mem_path)
305
+ mem_reload()
306
+
307
+ print(f"=== 结果: {passed} passed, {failed} failed ===")
308
+
309
+ elif len(sys.argv) <= 1:
310
+ # 无参数:进入交互 REPL 模式
311
+ _ensure_api_key()
312
+ from menupilot.cli.repl import repl_loop
313
+ repl_loop()
314
+ else:
315
+ # --help / -h 无需 API Key,直接放行
316
+ if not (set(sys.argv) & {"--help", "-h"}):
317
+ _ensure_api_key()
318
+ sys.exit(run())
319
+
320
+
321
+ if __name__ == "__main__":
322
+ main()
menupilot/wizard.py ADDED
@@ -0,0 +1,86 @@
1
+ """
2
+ 首次运行配置向导 — 引导用户设置 DeepSeek API Key。
3
+
4
+ 仅在 ~/.menupilot/config.json 不存在或缺少 API Key 时触发。
5
+ 配置写入 ~/.menupilot/config.json,后续运行自动读取。
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+
12
+ _CONFIG_DIR = os.path.expanduser("~/.menupilot")
13
+ _CONFIG_PATH = os.path.join(_CONFIG_DIR, "config.json")
14
+
15
+
16
+ def run_wizard() -> str:
17
+ """启动中文配置向导,返回用户输入的 API Key。
18
+
19
+ 如果 config.json 已存在,保留其中的其他配置项(BASE_URL、MODEL 等)。
20
+ """
21
+ print()
22
+ print("=" * 60)
23
+ print(" \U0001f375 欢迎使用 MenuPilot — 智能 POS 模板映射助手")
24
+ print("=" * 60)
25
+ print()
26
+ print(" 首次使用需要配置 DeepSeek API Key。")
27
+ print(" 申请地址: https://platform.deepseek.com/api_keys")
28
+ print()
29
+ print(" ⚠️ 你的 API Key 只会保存在本地,不会上传到任何服务器。")
30
+ print()
31
+
32
+ # ── 交互式输入 ──
33
+ while True:
34
+ try:
35
+ api_key = input(" 请输入你的 DeepSeek API Key: ").strip()
36
+ except (EOFError, KeyboardInterrupt):
37
+ print()
38
+ print(" ❌ 已取消配置。可设置环境变量 DEEPSEEK_API_KEY 后重试。")
39
+ sys.exit(1)
40
+
41
+ if api_key:
42
+ break
43
+ print(" ⚠️ API Key 不能为空,请重新输入。")
44
+ print()
45
+
46
+ # ── 写入配置文件(保留已有配置) ──
47
+ os.makedirs(_CONFIG_DIR, exist_ok=True)
48
+
49
+ existing = {}
50
+ if os.path.exists(_CONFIG_PATH):
51
+ try:
52
+ with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
53
+ existing = json.load(f)
54
+ except (json.JSONDecodeError, IOError):
55
+ pass
56
+
57
+ existing["DEEPSEEK_API_KEY"] = api_key
58
+ existing.setdefault("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
59
+ existing.setdefault("DEEPSEEK_MODEL", "deepseek-chat")
60
+
61
+ with open(_CONFIG_PATH, "w", encoding="utf-8") as f:
62
+ json.dump(existing, f, indent=2, ensure_ascii=False)
63
+
64
+ print()
65
+ print(f" ✅ 配置已保存到: {_CONFIG_PATH}")
66
+ print(" 如需修改,可直接编辑该文件,或删除后重新运行 menupilot。")
67
+ print("=" * 60)
68
+ print()
69
+
70
+ return api_key
71
+
72
+
73
+ def get_saved_api_key() -> str:
74
+ """静默读取已保存的 API Key(不触发向导)。"""
75
+ if os.path.exists(_CONFIG_PATH):
76
+ try:
77
+ with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
78
+ data = json.load(f)
79
+ return data.get("DEEPSEEK_API_KEY", "")
80
+ except (json.JSONDecodeError, IOError):
81
+ pass
82
+ return ""
83
+
84
+
85
+ if __name__ == "__main__":
86
+ run_wizard()