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.
- {menupilot-0.1.1 → menupilot-0.2.0/MenuPilot.egg-info}/PKG-INFO +1 -1
- {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/SOURCES.txt +4 -0
- {menupilot-0.1.1/MenuPilot.egg-info → menupilot-0.2.0}/PKG-INFO +1 -1
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/__init__.py +1 -1
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/agent_loop.py +433 -26
- menupilot-0.2.0/menupilot/agent/composite_token_resolver.py +193 -0
- menupilot-0.2.0/menupilot/agent/conflict_resolver.py +101 -0
- menupilot-0.2.0/menupilot/agent/context_mode.py +57 -0
- menupilot-0.2.0/menupilot/agent/mapping_parser.py +133 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/matching_engine.py +218 -1
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/orchestration.py +42 -15
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/template_preprocessor.py +8 -2
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/tools.py +71 -6
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/workflow.py +22 -3
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/excel_io/excel_writer.py +15 -9
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/main.py +1 -1
- {menupilot-0.1.1 → menupilot-0.2.0}/pyproject.toml +1 -1
- {menupilot-0.1.1 → menupilot-0.2.0}/LICENSE +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/dependency_links.txt +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/entry_points.txt +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/requires.txt +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/MenuPilot.egg-info/top_level.txt +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/README.md +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/__main__.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/__init__.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/option_expander.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/rule_engine.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/sandbox.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/schema_analyzer.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/agent/token_classifier.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/cli/human_review.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/cli/repl.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/config.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/__init__.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/canonical_schema.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/mapping_rules.yaml +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/memory.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/data/token_dict.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/excel_io/__init__.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/excel_io/excel_reader.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/menupilot/wizard.py +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/setup.cfg +0 -0
- {menupilot-0.1.1 → menupilot-0.2.0}/tests/test_cli.py +0 -0
|
@@ -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
|
|
@@ -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
|
-
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
228
|
-
"error": f"
|
|
229
|
-
"hint": "
|
|
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
|
|
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
|
-
|
|
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 耗尽时,从
|
|
278
|
-
if not last_error and
|
|
279
|
-
|
|
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":
|
|
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'
|
|
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
|
|
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}")
|