MenuPilot 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (43) hide show
  1. {menupilot-0.1.1 → menupilot-0.2.0/MenuPilot.egg-info}/PKG-INFO +1 -1
  2. {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/SOURCES.txt +4 -0
  3. {menupilot-0.1.1/MenuPilot.egg-info → menupilot-0.2.0}/PKG-INFO +1 -1
  4. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/__init__.py +1 -1
  5. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/agent_loop.py +433 -26
  6. menupilot-0.2.0/menupilot/agent/composite_token_resolver.py +193 -0
  7. menupilot-0.2.0/menupilot/agent/conflict_resolver.py +101 -0
  8. menupilot-0.2.0/menupilot/agent/context_mode.py +57 -0
  9. menupilot-0.2.0/menupilot/agent/mapping_parser.py +133 -0
  10. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/matching_engine.py +218 -1
  11. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/orchestration.py +42 -15
  12. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/template_preprocessor.py +8 -2
  13. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/tools.py +71 -6
  14. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/workflow.py +22 -3
  15. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/excel_io/excel_writer.py +15 -9
  16. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/main.py +1 -1
  17. {menupilot-0.1.1 → menupilot-0.2.0}/pyproject.toml +1 -1
  18. {menupilot-0.1.1 → menupilot-0.2.0}/LICENSE +0 -0
  19. {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/dependency_links.txt +0 -0
  20. {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/entry_points.txt +0 -0
  21. {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/requires.txt +0 -0
  22. {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/top_level.txt +0 -0
  23. {menupilot-0.1.1 → menupilot-0.2.0}/README.md +0 -0
  24. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/__main__.py +0 -0
  25. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/__init__.py +0 -0
  26. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/option_expander.py +0 -0
  27. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/rule_engine.py +0 -0
  28. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/sandbox.py +0 -0
  29. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/schema_analyzer.py +0 -0
  30. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/token_classifier.py +0 -0
  31. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/cli/human_review.py +0 -0
  32. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/cli/repl.py +0 -0
  33. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/config.py +0 -0
  34. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/__init__.py +0 -0
  35. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/canonical_schema.py +0 -0
  36. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/mapping_rules.yaml +0 -0
  37. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/memory.py +0 -0
  38. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/token_dict.py +0 -0
  39. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/excel_io/__init__.py +0 -0
  40. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/excel_io/excel_reader.py +0 -0
  41. {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/wizard.py +0 -0
  42. {menupilot-0.1.1 → menupilot-0.2.0}/setup.cfg +0 -0
  43. {menupilot-0.1.1 → menupilot-0.2.0}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MenuPilot
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: 智能 POS 模板映射助手 — 自动将主数据 SOP 代码填入 POS 模板
5
5
  Author-email: yuan wu <694904422@qq.com>
6
6
  License-Expression: MIT
@@ -14,6 +14,10 @@ menupilot/main.py
14
14
  menupilot/wizard.py
15
15
  menupilot/agent/__init__.py
16
16
  menupilot/agent/agent_loop.py
17
+ menupilot/agent/composite_token_resolver.py
18
+ menupilot/agent/conflict_resolver.py
19
+ menupilot/agent/context_mode.py
20
+ menupilot/agent/mapping_parser.py
17
21
  menupilot/agent/matching_engine.py
18
22
  menupilot/agent/option_expander.py
19
23
  menupilot/agent/orchestration.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MenuPilot
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: 智能 POS 模板映射助手 — 自动将主数据 SOP 代码填入 POS 模板
5
5
  Author-email: yuan wu <694904422@qq.com>
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """MenuPilot — 智能 POS 模板映射助手。"""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"
@@ -11,10 +11,14 @@ import hashlib
11
11
  import json
12
12
  import os
13
13
  from collections import deque
14
+ from dataclasses import dataclass
14
15
  from datetime import datetime
15
16
  from typing import Any, Callable, Dict, List, Optional
16
17
 
17
18
  from menupilot import config
19
+ from menupilot.agent.context_mode import ContextMode, filter_tools
20
+ from menupilot.agent.conflict_resolver import FieldBinding, apply_patch
21
+ from menupilot.agent.mapping_parser import ParseResult
18
22
 
19
23
  # ── 常量 ──────────────────────────────────────────────────────────
20
24
 
@@ -77,6 +81,100 @@ class SessionMemory:
77
81
  return len(self.messages) // 2
78
82
 
79
83
 
84
+ # ═══════════════════════════════════════════════════════════════════
85
+ # ProgressTracker — 基于业务语义的卡死检测
86
+ # ═══════════════════════════════════════════════════════════════════
87
+
88
+ @dataclass(frozen=True)
89
+ class ProgressSnapshot:
90
+ """一次工具调用后的进度快照。"""
91
+ stage: str
92
+ confirmed_matches: frozenset
93
+ unresolved_fields: frozenset
94
+ turn: int
95
+
96
+ @staticmethod
97
+ def _extract_unresolved(result: dict) -> frozenset:
98
+ """从工具返回值中提取 unresolved 信息。"""
99
+ items = set()
100
+ if not isinstance(result, dict):
101
+ return frozenset()
102
+ err = result.get("error", "")
103
+ if err:
104
+ items.add(str(err)[:80])
105
+ low = result.get("low_conf", 0)
106
+ if low:
107
+ items.add(f"low_conf:{low}")
108
+ if "缺少" in str(err) or "缺失" in str(err):
109
+ items.add("missing_fields")
110
+ return frozenset(items)
111
+
112
+ @staticmethod
113
+ def _extract_confirmed(result: dict) -> frozenset:
114
+ """从工具返回值中提取已确认的匹配。"""
115
+ items = set()
116
+ if not isinstance(result, dict):
117
+ return frozenset()
118
+ high = result.get("high_conf", 0)
119
+ if high:
120
+ items.add(f"high_conf:{high}")
121
+ if result.get("ok") is True:
122
+ items.add("ok")
123
+ return frozenset(items)
124
+
125
+
126
+ class ProgressTracker:
127
+ """基于业务语义的进度追踪器,区分「推进中」和「真卡死」。"""
128
+
129
+ def __init__(self, patience: int = 3):
130
+ self.history: list[ProgressSnapshot] = []
131
+ self.patience = patience
132
+
133
+ def push(self, snap: ProgressSnapshot):
134
+ self.history.append(snap)
135
+
136
+ def is_stuck(self) -> bool:
137
+ if len(self.history) < self.patience:
138
+ return False
139
+
140
+ recent = self.history[-self.patience:]
141
+ last = recent[-1]
142
+
143
+ # 收敛到终态 → 不是 stuck
144
+ if last.stage in ("done", "finalization"):
145
+ return False
146
+ # 初始态(什么都没有)→ 不判断
147
+ if not last.unresolved_fields and not last.confirmed_matches:
148
+ return False
149
+
150
+ # unresolved_fields 在缩小 → 有推进,不是 stuck
151
+ fields_shrinking = bool(
152
+ recent[-1].unresolved_fields and
153
+ recent[-1].unresolved_fields < recent[0].unresolved_fields
154
+ )
155
+ if fields_shrinking:
156
+ return False
157
+
158
+ # confirmed_matches 单调不减才算推进,回滚/抖动不算
159
+ matches_monotone = all(
160
+ recent[i].confirmed_matches >= recent[i - 1].confirmed_matches
161
+ for i in range(1, len(recent))
162
+ )
163
+ matches_growing = bool(
164
+ recent[-1].confirmed_matches and
165
+ recent[-1].confirmed_matches > recent[0].confirmed_matches
166
+ )
167
+
168
+ no_progress = (
169
+ not fields_shrinking
170
+ and not matches_growing
171
+ and not matches_monotone
172
+ )
173
+
174
+ # stage 没变 且 业务没推进 → stuck
175
+ return len({r.stage for r in recent}) == 1 and no_progress
176
+
177
+
80
178
  def _build_system_prompt(cwd: str = "") -> str:
81
179
  from menupilot.agent.tools import TOOLS
82
180
 
@@ -112,13 +210,23 @@ def _build_system_prompt(cwd: str = "") -> str:
112
210
  禁止:收到 ok:false 后调用 execute_python 自行调试 —— 你应该问用户,不是自己查数据
113
211
  禁止:在无法获取新信息的情况下,重复读取同一个 Excel 文件超过 2 次
114
212
 
213
+ ## 确认阶段规则
214
+ - 所有待确认项必须在一条 ask_user 消息里一次性提出,禁止分多轮分别询问
215
+ - 进入确认阶段后,禁止调用 query_token_dict / read_excel_info 等分析工具
216
+ - 用户回复后:
217
+ - 若用户明确确认(是/yes/ok/执行等),立即进入执行阶段
218
+ - 若用户补充了新规则或修改了参数,将补充内容纳入方案后重新确认,但只允许追加确认一次
219
+
115
220
  ## 交互效率
116
221
  - 展示信息和确认操作合并为一次 ask_user 调用
117
222
  - 禁止对同一任务的不同字段分多次 ask_user 确认
118
223
  - 格式:先展示完整方案,再问"是否执行"
119
224
  - 用户说 --sheet N → 传 template_sheet=N
120
- - 管线完成后用 execute_python 读取 report_path 指向的 txt 报告
121
- - 以表格展示:商品名 | 不匹配原因 | 行数,表格后给出检查建议
225
+ - 管线完成后,工具返回值中已包含 product_summary(按产品聚合的全量明细)
226
+ - 你无需再读取任何文件,直接从返回值的 report 字段中提取数据
227
+ - 以表格展示摘要(按涉及行数降序),分析主要原因分布规律,给出修复优先级建议
228
+ - 例:"杨枝甘露奶茶的奶底「牛奶」缺失,涉及 1000 行不匹配,建议优先补充主数据中杨枝甘露的牛奶奶底"
229
+ - 禁止用 execute_python 读取 report_path 文件
122
230
 
123
231
  ## 领域知识
124
232
  - 奶茶规格维度: 糖度/温度/规格/奶底/茶底
@@ -156,28 +264,268 @@ class AgentLoop:
156
264
  self.memory = SessionMemory()
157
265
  self.memory.system_prompt = _build_system_prompt(self.cwd)
158
266
 
267
+ # ── Context Mode 状态机 ──
268
+ self.context_mode: ContextMode = ContextMode.NORMAL
269
+ self.pending_mapping: dict[str, FieldBinding] = {}
270
+ self._current_turn: int = 0
271
+
159
272
  def run(self, user_input: str) -> str:
160
- """新会话:清空记忆,开始 Agent loop。"""
273
+ """新会话:清空记忆 + 重置 context_mode,开始 Agent loop。"""
161
274
  if config.DEBUG:
162
275
  print(f"[DEBUG agent] run() — new session, user_input: {user_input[:200]}")
163
276
  self.memory.reset_task()
277
+ self.context_mode = ContextMode.NORMAL
278
+ self.pending_mapping = {}
164
279
  self.memory.add({"role": "user", "content": user_input})
165
280
  return self._loop()
166
281
 
167
282
  def continue_conversation(self, user_input: str) -> str:
168
- """继续已有会话:追加用户消息,继续 loop。"""
283
+ """继续已有会话:根据 context_mode 路由到对应处理逻辑。"""
284
+ if config.DEBUG:
285
+ print(f"[DEBUG agent] continue_conversation() — mode={self.context_mode}, input={user_input[:200]}")
286
+
287
+ # ── AWAITING_CONFIRMATION:只接受 yes/no ──
288
+ if self.context_mode == ContextMode.AWAITING_CONFIRMATION:
289
+ return self._handle_awaiting_confirmation(user_input)
290
+
291
+ # ── MAPPING_BUILDING:直接进 MappingParser,不走通用 LLM ──
292
+ if self.context_mode == ContextMode.MAPPING_BUILDING:
293
+ return self._handle_mapping_building(user_input)
294
+
295
+ # ── EXECUTING:只允许执行工具,跳过 Intent Router ──
296
+ if self.context_mode == ContextMode.EXECUTING:
297
+ self.memory.add({"role": "user", "content": user_input})
298
+ return self._loop()
299
+
300
+ # ── NORMAL:先检测意图,可能切换 mode ──
301
+ intent = self._detect_intent(user_input)
169
302
  if config.DEBUG:
170
- print(f"[DEBUG agent] continue_conversation() — user_input: {user_input[:200]}")
303
+ print(f"[DEBUG agent] Intent detected: {intent}")
304
+
305
+ if intent == "PROVIDE_MAPPING_RULE":
306
+ self.context_mode = ContextMode.MAPPING_BUILDING
307
+ return self._handle_mapping_building(user_input)
308
+
309
+ if intent == "EXECUTE_TASK":
310
+ self.context_mode = ContextMode.EXECUTING
311
+ self.memory.add({"role": "user", "content": user_input})
312
+ return self._loop()
313
+
314
+ # intent == "ASK_QUESTION" 或其他 → 正常 LLM 回复
171
315
  self.memory.add({"role": "user", "content": user_input})
172
316
  return self._loop()
173
317
 
318
+ # ═════════════════════════════════════════════════════════════
319
+ # Intent Detection + Context Mode Handlers
320
+ # ═════════════════════════════════════════════════════════════
321
+
322
+ def _detect_intent(self, user_input: str) -> str:
323
+ """判断用户意图。动词不参与路由,对象类型是主分类。
324
+
325
+ Step 1: classify_object_type → FILE / FIELD / UNKNOWN
326
+ Step 2: 同时命中裁决 — FILE 降级为上下文,FIELD 是真正的操作对象
327
+ """
328
+ # Step 0: 快速 yes/no
329
+ stripped = user_input.strip().lower()
330
+ if stripped in ("是", "yes", "y", "执行", "开始", "确认", "ok", "好的", "可以"):
331
+ return "EXECUTE_TASK"
332
+
333
+ # Step 1: 对象分类
334
+ schema_keys = self._get_schema_keys()
335
+ has_file = self._is_file_object(user_input)
336
+ has_field = self._is_field_object(user_input, schema_keys)
337
+
338
+ if config.DEBUG:
339
+ print(f"[DEBUG agent] Intent: has_file={has_file}, has_field={has_field}, schema_keys={schema_keys}")
340
+
341
+ # Step 3: 同时命中裁决
342
+ # FILE 降级为上下文("在 mainproduct.xlsx 里…"),FIELD 是操作对象
343
+ if has_file and has_field:
344
+ return "PROVIDE_MAPPING_RULE"
345
+ elif has_file:
346
+ return "EXECUTE_TASK"
347
+ elif has_field:
348
+ return "PROVIDE_MAPPING_RULE"
349
+ else:
350
+ return "ASK_QUESTION" # 对象不明确,交给 LLM 正常回复
351
+
352
+ def _is_file_object(self, text: str) -> bool:
353
+ """判断输入是否涉及文件/路径/数据源。"""
354
+ file_indicators = (
355
+ ".xlsx", ".xls", ".csv", "Sheet", "sheet",
356
+ "testdata", "输出", "文件", "路径",
357
+ )
358
+ # 路径特征:盘符:\ 或 \\
359
+ has_path = ":" in text and "\\" in text
360
+ return has_path or any(k in text for k in file_indicators)
361
+
362
+ def _is_field_object(self, text: str, schema_keys: frozenset) -> bool:
363
+ """判断输入是否涉及 schema 字段。
364
+
365
+ schema_keys 从 pending_mapping 的 key 集合 + 对话中已知的列名动态生成。
366
+ 不用写死关键词。
367
+ """
368
+ if not schema_keys:
369
+ return False
370
+ return any(k in text for k in schema_keys)
371
+
372
+ def _get_schema_keys(self) -> frozenset:
373
+ """从 pending_mapping + 对话中 LLM 展示的列名动态提取。"""
374
+ keys = set(self.pending_mapping.keys())
375
+
376
+ # 从最近的 assistant 消息中提取列名
377
+ # LLM 展示映射方案时用的格式:
378
+ # | 规格价格 | → | 规格名称 | 或 规格价格 → 规格名称
379
+ # | **一级商品分类** | **默认值 AUUUU** |
380
+ import re
381
+ recent_text = ""
382
+ for msg in list(self.memory.messages)[-10:]:
383
+ if msg.get("role") == "assistant":
384
+ recent_text += str(msg.get("content", ""))[:3000]
385
+
386
+ # 匹配 markdown 表格中的中文 cell(含可选的 * 标记)
387
+ # "| 规格价格 |"、"| *规格名称_1 |"、"| **一级商品分类** |"
388
+ col_names = set(re.findall(
389
+ r'\|\s*\*{0,2}([一-鿿][一-鿿_a-zA-Z0-9\*]{1,20})\*{0,2}\s*\|',
390
+ recent_text
391
+ ))
392
+ # 也匹配 "中文名 → 中文名" 格式
393
+ arrow_names = set(re.findall(
394
+ r'([一-鿿]{2,12})\s*[→]\s*',
395
+ recent_text
396
+ ))
397
+ keys.update(col_names)
398
+ keys.update(arrow_names)
399
+ # 过滤掉非列名的通用词
400
+ generic = {"是", "否", "说明", "注意", "默认值", "必填项", "模板列", "主数据列",
401
+ "请问", "是否", "确认", "执行", "输出", "路径", "文件"}
402
+ keys.difference_update(generic)
403
+
404
+ return frozenset(keys)
405
+
406
+ def _handle_mapping_building(self, user_input: str) -> str:
407
+ """MAPPING_BUILDING 模式:用 MappingParser 解析用户映射规则。"""
408
+ self.memory.add({"role": "user", "content": user_input})
409
+
410
+ # 调用 MappingParser(专用 LLM prompt)
411
+ from menupilot.agent.mapping_parser import (
412
+ build_parser_messages, parse_llm_response,
413
+ )
414
+ msgs = build_parser_messages(
415
+ self.pending_mapping,
416
+ user_input,
417
+ list(self.memory.messages),
418
+ )
419
+
420
+ try:
421
+ completion = self.llm.chat.completions.create(
422
+ model=self.llm.model,
423
+ messages=msgs,
424
+ temperature=0.1,
425
+ max_tokens=1024,
426
+ )
427
+ raw = completion.choices[0].message.content or ""
428
+ result = parse_llm_response(raw)
429
+ except Exception as e:
430
+ result = ParseResult(is_unambiguous=False, patch=None, ambiguity_reason=str(e))
431
+
432
+ if config.DEBUG:
433
+ print(f"[DEBUG agent] MappingParser result: is_unambiguous={result.is_unambiguous}")
434
+
435
+ if result.is_unambiguous and result.patch:
436
+ # 通过 Conflict Resolver 应用 patch
437
+ new_mapping, conflicts = apply_patch(self.pending_mapping, result.patch)
438
+ self.pending_mapping = new_mapping
439
+
440
+ conflict_msgs = []
441
+ for c in conflicts:
442
+ if c["decision"] == "BLOCK":
443
+ conflict_msgs.append(
444
+ f"⚠️ 「{c['field']}」已有用户指定值「{c['existing_value']}」,"
445
+ f"新值「{c['incoming_value']}」优先级较低,已忽略。"
446
+ )
447
+ elif c["decision"] == "REQUIRE_CONFIRMATION":
448
+ conflict_msgs.append(
449
+ f"❓ 「{c['field']}」之前设为「{c['existing_value']}」,"
450
+ f"现在改为「{c['incoming_value']}」,需要确认。"
451
+ )
452
+
453
+ summary_lines = ["已更新字段映射:"]
454
+ for k, v in result.patch.items():
455
+ if k in new_mapping and new_mapping[k] is v:
456
+ summary_lines.append(f" ✅ {k} → {v.value}")
457
+ summary = "\n".join(summary_lines)
458
+ if conflict_msgs:
459
+ summary += "\n\n" + "\n".join(conflict_msgs)
460
+
461
+ # 进入等待确认
462
+ self.context_mode = ContextMode.AWAITING_CONFIRMATION
463
+
464
+ return (
465
+ f"{summary}\n\n"
466
+ f"当前映射配置:\n{self._format_pending_mapping()}\n\n"
467
+ f"是否确认此映射方案?(输入是/否)"
468
+ )
469
+
470
+ # is_unambiguous=False
471
+ self.context_mode = ContextMode.AWAITING_CONFIRMATION
472
+ reason = result.ambiguity_reason or "无法唯一确定映射"
473
+ return (
474
+ f"❓ 无法完全确定您的映射意图。\n\n"
475
+ f"原因:{reason}\n\n"
476
+ f"当前映射配置:\n{self._format_pending_mapping()}\n\n"
477
+ f"请补充说明,或输入「是」确认当前方案、输入「否」放弃。"
478
+ )
479
+
480
+ def _handle_awaiting_confirmation(self, user_input: str) -> str:
481
+ """AWAITING_CONFIRMATION 模式:只接受 yes/no。"""
482
+ stripped = user_input.strip().lower()
483
+ is_yes = stripped in ("是", "yes", "y", "确认", "ok", "好的", "可以", "执行")
484
+ is_no = stripped in ("否", "no", "n", "取消", "不要", "不对")
485
+
486
+ if is_yes:
487
+ EXECUTE_MIN_TURNS = 4
488
+ turns_remaining = MAX_TURNS - self._current_turn
489
+ if turns_remaining < EXECUTE_MIN_TURNS:
490
+ msg = (
491
+ f"当前已用 {self._current_turn} 轮,剩余 {turns_remaining} 轮"
492
+ f"不足以完成执行(至少需要 {EXECUTE_MIN_TURNS} 轮)。"
493
+ f"请重新发起任务。"
494
+ )
495
+ self.memory.add({"role": "assistant", "content": msg})
496
+ return msg
497
+ self.context_mode = ContextMode.EXECUTING
498
+ self.memory.add({"role": "user", "content": "确认执行。当前映射配置已生效,请使用 run_sop_matching 执行。"})
499
+ return self._loop()
500
+
501
+ if is_no:
502
+ self.context_mode = ContextMode.MAPPING_BUILDING
503
+ return "已取消确认。请重新描述映射规则,或输入 /exit 退出。"
504
+
505
+ # 不是 yes/no → 当作映射规则补充
506
+ self.context_mode = ContextMode.MAPPING_BUILDING
507
+ return self._handle_mapping_building(user_input)
508
+
509
+ def _format_pending_mapping(self) -> str:
510
+ """格式化 pending_mapping 为可读文本。"""
511
+ if not self.pending_mapping:
512
+ return " (暂无映射配置)"
513
+ lines = []
514
+ for k, v in self.pending_mapping.items():
515
+ src_label = {"explicit_user": "用户指定", "llm_inferred": "LLM推断", "default": "默认"}.get(v.source, v.source)
516
+ lines.append(f" {k} → {v.value} ({src_label})")
517
+ return "\n".join(lines)
518
+
174
519
  def _loop(self) -> str:
175
520
  """内部循环:LLM ↔ 工具执行。"""
176
521
  last_error = None
177
522
  last_tool_ok = True # 上一个工具是否返回 ok(非 False)
178
- recent_names: list = [] # 滑动窗口:最近调用的工具名
523
+ _last_failed_name = None # 上一个 ok:false 的工具名
524
+ _last_failed_args = None # 上一个 ok:false 的工具参数
525
+ tracker = ProgressTracker(patience=3)
179
526
 
180
527
  for turn in range(1, MAX_TURNS + 1):
528
+ self._current_turn = turn
181
529
  if config.DEBUG:
182
530
  print(f"\n[DEBUG agent] === Turn {turn}/{MAX_TURNS} ===")
183
531
  msgs = self.memory.to_llm_input()
@@ -200,6 +548,35 @@ class AgentLoop:
200
548
 
201
549
  for tc in response["tool_calls"]:
202
550
  name = tc.get("_name", "")
551
+ args = tc.get("_parsed_args", {})
552
+
553
+ # ── 工程兜底 0:ok:false 后禁止同工具同参数重试 ──
554
+ if not last_tool_ok and _last_failed_name is not None:
555
+ if name == _last_failed_name and args == _last_failed_args:
556
+ real_error = ""
557
+ if isinstance(last_error, dict):
558
+ real_error = last_error.get("error") or ""
559
+ block_msg = {
560
+ "error_type": "identical_retry",
561
+ "error": (
562
+ f"工具 '{name}' 上一次调用已失败(ok:false),"
563
+ f"当前参数与上次完全相同,禁止无意义重试。"
564
+ ),
565
+ "hint": (
566
+ f"上一次失败原因: {real_error}\n"
567
+ f"请根据此错误信息修改参数后重试(如补充 column_mapping),"
568
+ f"或调用 ask_user 将错误展示给用户。"
569
+ ),
570
+ "fatal": False,
571
+ }
572
+ if config.DEBUG:
573
+ print(f"[DEBUG agent] Guard 0: BLOCKED identical retry of '{name}'")
574
+ self.memory.add({"role": "assistant", "content": None,
575
+ "tool_calls": [self._make_tool_msg(tc)]})
576
+ self.memory.add({"role": "tool", "tool_call_id": tc["id"],
577
+ "content": json.dumps(block_msg, ensure_ascii=False)})
578
+ # 不更新 _last_failed_* — 保持原始失败信息,防止 LLM 反复试探
579
+ continue
203
580
 
204
581
  # ── 工程兜底 1:ok:false 之后禁止调用 execute_python ──
205
582
  if not last_tool_ok and name == "execute_python":
@@ -218,15 +595,12 @@ class AgentLoop:
218
595
  f"💡 {fatal_msg['hint']}"
219
596
  )
220
597
 
221
- # ── 工程兜底 2:滑动窗口频率检测 ──
222
- recent_names.append(name)
223
- if len(recent_names) > 5:
224
- recent_names.pop(0)
225
- if recent_names.count("execute_python") >= 3:
598
+ # ── 工程兜底 2:ProgressTracker 卡死检测 ──
599
+ if tracker.is_stuck():
226
600
  fatal_msg = {
227
- "error_type": "debug_loop",
228
- "error": f"检测到 execute_python 在最近 5 轮中调用了 {recent_names.count('execute_python')} 次,疑似进入调试死循环。",
229
- "hint": "请停止自行调试,用 ask_user 告知用户当前状态,询问下一步操作。",
601
+ "error_type": "stuck_loop",
602
+ "error": f"最近 {tracker.patience} 轮无业务推进(stage 未变,confirmed_matches 无增长,unresolved_fields 无缩小),疑似卡死。",
603
+ "hint": "请用 ask_user 告知用户当前状态,询问下一步操作。",
230
604
  "fatal": True,
231
605
  }
232
606
  self.memory.add({"role": "assistant", "content": None,
@@ -240,11 +614,24 @@ class AgentLoop:
240
614
 
241
615
  result = self._execute_tool(tc)
242
616
 
243
- # 更新 last_tool_ok
617
+ # ── 更新 last_tool_ok ──
244
618
  if isinstance(result, dict) and "ok" in result:
245
619
  last_tool_ok = result["ok"] is not False
620
+ if not last_tool_ok:
621
+ _last_failed_name = name
622
+ _last_failed_args = dict(args) if isinstance(args, dict) else args
246
623
  else:
247
- last_tool_ok = True # 非 ok:false 的返回视为正常
624
+ last_tool_ok = True
625
+
626
+ # ── 推送进度快照 ──
627
+ if isinstance(result, dict) and "ok" in result:
628
+ snapshot = ProgressSnapshot(
629
+ stage=name,
630
+ confirmed_matches=ProgressSnapshot._extract_confirmed(result),
631
+ unresolved_fields=ProgressSnapshot._extract_unresolved(result),
632
+ turn=turn,
633
+ )
634
+ tracker.push(snapshot)
248
635
 
249
636
  # 不可恢复错误 → 立即终止,告知用户
250
637
  if isinstance(result, dict) and result.get("fatal"):
@@ -272,23 +659,22 @@ class AgentLoop:
272
659
  print(f"[DEBUG agent] MAX_TURNS ({MAX_TURNS}) reached! Task incomplete.")
273
660
  print(f"[DEBUG agent] last_error: {last_error}")
274
661
  print(f"[DEBUG agent] Recent calls: {list(self.recent_calls)[-5:]}")
275
- print(f"[DEBUG agent] Recent names: {recent_names}")
662
+ history_stages = [s.stage for s in tracker.history[-5:]]
663
+ print(f"[DEBUG agent] Recent stages: {history_stages}")
276
664
 
277
- # 工程兜底 3:MAX_TURNS 耗尽时,从 recent_names 推断原因
278
- if not last_error and recent_names:
279
- py_count = recent_names.count("execute_python")
280
- ask_count = recent_names.count("ask_user")
281
- if py_count >= 3:
665
+ # 工程兜底 3:MAX_TURNS 耗尽时,从 tracker 推断原因
666
+ if not last_error and tracker.history:
667
+ if tracker.is_stuck():
282
668
  last_error = {
283
- "error": f"最近 {len(recent_names)} 轮中 execute_python 被调用 {py_count} 次,疑似调试死循环。",
669
+ "error": "进度追踪器判定卡死:最近几轮无业务推进。",
284
670
  "hint": "用命令行模式直接执行 menupilot -m ... -t ... -o ... 可绕过 Agent 交互避免轮次消耗。",
285
671
  }
286
672
 
287
673
  if last_error:
288
674
  return (
289
675
  f"已执行 {MAX_TURNS} 轮,仍未能完成任务。\n\n"
290
- f"最后一次失败:{last_error.get('error', '未知')}\n"
291
- f"💡 {last_error.get('hint', '请简化需求后重试')}"
676
+ f"最后一次失败:{last_error.get('error') or '未知'}\n"
677
+ f"💡 {last_error.get('hint') or '请简化需求后重试'}"
292
678
  )
293
679
  return f"已执行 {MAX_TURNS} 轮工具调用,仍未完成任务。请简化需求后重试。"
294
680
 
@@ -302,12 +688,17 @@ class AgentLoop:
302
688
  }
303
689
 
304
690
  def _call_llm(self, messages: list) -> dict:
691
+ # 根据 context_mode 过滤可用工具
692
+ active_tools = filter_tools(self.tools, self.context_mode)
693
+ if config.DEBUG and active_tools != self.tools:
694
+ removed = set(self.tools) - set(active_tools)
695
+ print(f"[DEBUG agent] Tools filtered by mode={self.context_mode}: removed={removed}")
305
696
  tool_schemas = [
306
697
  {"type": "function", "function": {
307
698
  "name": t["name"], "description": t["description"],
308
699
  "parameters": t["parameters"],
309
700
  }}
310
- for t in self.tools.values()
701
+ for t in active_tools.values()
311
702
  ]
312
703
  if config.DEBUG and messages:
313
704
  last = messages[-1]
@@ -369,7 +760,23 @@ class AgentLoop:
369
760
  print(f"[DEBUG agent] Tool result (duplicate): {json.dumps(result, ensure_ascii=False)[:200]}")
370
761
  return result
371
762
  try:
763
+ # ── task_context 注入:LLM 遗漏的 sheet_name/master_sheet 等 ──
764
+ if name in ("run_option_expansion", "run_sop_matching"):
765
+ tc = self.memory.task_context
766
+ if "sheet_name" not in args and "master_sheet" in tc:
767
+ args = dict(args)
768
+ args["sheet_name"] = tc["master_sheet"]
769
+ if "template_sheet" not in args and "template_sheet" in tc:
770
+ args = dict(args)
771
+ args["template_sheet"] = tc["template_sheet"]
372
772
  result = tool["handler"](**args)
773
+ # ── 提取 task_context 信息(read_excel_info 结果) ──
774
+ if name == "read_excel_info" and isinstance(result, dict):
775
+ fp = result.get("filepath", "")
776
+ if fp and "sheet_name" in args:
777
+ # 记住:此文件在本会话中已读取的 sheet
778
+ pass
779
+ # 如果有明确的 sheet 分配,写入 task_context
373
780
  if config.DEBUG:
374
781
  rstr = json.dumps(self._sanitize(result), ensure_ascii=False, default=str)[:300]
375
782
  print(f"[DEBUG agent] Tool result: {rstr}")