diff --git a/docs/round-88-chat-data-algorithm-selection-plan.md b/docs/round-88-chat-data-algorithm-selection-plan.md index 19dfc7e..7ae72d9 100644 --- a/docs/round-88-chat-data-algorithm-selection-plan.md +++ b/docs/round-88-chat-data-algorithm-selection-plan.md @@ -2,15 +2,19 @@ ## 目标 -让用户可以用一条自然语言指令同时指定数据包和算法,例如: +让用户可以用一条自然语言指令同时指定数据来源和算法,例如: > 使用 Optimize synthetic debug V1 数据,用 EDD 算法生成排产方案 +也支持指定本机目录里的文件: + +> 使用 D:\\aps-data 目录下的 orders.xlsx 数据用 EDD 算法生成排产方案 + 系统应解析出 `dataPackRef`、`engine=OPTIMIZE` 和 `sortMode=EDD`,从已登记的数据包加载完整世界,再通过现有柔性闭环流程生成方案。 ## 设计 -1. 数据包使用 `server/state/packs.py` 的通用注册表解析,不增加某个客户或某个文件的专用 loader。支持文件名、名称和包内 aliases;找不到或匹配不唯一时不执行排产,直接提示可用数据包。 +1. 已登记数据包使用 `server/state/packs.py` 的通用注册表解析,不增加某个客户或某个文件的专用 loader。目录/文件指令复用 `analyze_project_deep` 的通用 Excel/CSV/SQL 读取器;路径不存在时不回退到旧世界。 2. 对话解析器识别 Optimize 算法及 EDD/SPT/PRIORITY/FIFO/LPT/CR/ATC 等排产规则,并保留原有未点名算法时的默认行为。 3. `_run_flex` 在明确指定数据包时替换当前会话世界、重置 ID 计数器并记录所选包;随后把算法交给现有 `run_flex_schedule(..., engine_type="OPTIMIZE")`,不复制调度逻辑。 4. 结果块增加数据包和算法证据,文本明确显示“使用哪个数据包、哪个算法、生成多少虚拟产线/工单”。 @@ -26,8 +30,10 @@ ## 验收标准 - 指令 `使用 Optimize synthetic debug V1 数据,用 EDD 算法生成排产方案` 命中 `flex.schedule`,参数包含 `dataPackRef`、`engine=OPTIMIZE`、`sortMode=EDD`。 +- 指令 `使用 D:\\aps-data 目录下的 orders.xlsx 数据用 EDD 算法生成排产方案` 命中 `flex.schedule`,参数包含 `dataDir`、`dataFile`、`dataPath`、`engine=OPTIMIZE`、`sortMode=EDD`。 - 在干净会话中执行后得到 `FEASIBLE`、10 条虚拟产线、72 条工单,结果块能看到数据包名称和算法。 - 未知数据包不会创建排产版本,并返回可用数据包提示。 +- 不存在的目录或文件不会使用当前旧数据排产,而是直接返回路径错误。 - 普通“生成一版柔性排产方案”仍走原来的 CLOSED_LOOP 默认路径。 ## 验证命令 @@ -52,4 +58,4 @@ Blocking issues: none Clarification needed: none -Non-blocking improvements: 后续可再把算法白名单从代码抽到能力描述接口。 +Non-blocking improvements: 后续可再把算法白名单从代码抽到能力描述接口;目录导入仍沿用现有 P2 确认流程的审计策略。 diff --git a/server/agent_core/intent.py b/server/agent_core/intent.py index fae0b79..cd2e46c 100644 --- a/server/agent_core/intent.py +++ b/server/agent_core/intent.py @@ -5,6 +5,7 @@ # ============================================================ from __future__ import annotations # 前向类型引用 +import os # 路径解析 import re # 正则快路 from typing import Any # 类型标注 @@ -206,6 +207,33 @@ def _is_imperative_schedule(text: str) -> bool: )) +def _extract_explicit_data_source(text: str) -> dict[str, str]: + """Extract an absolute data directory/file pair from a chat command.""" + t = str(text or "") + ext = r"(?:xlsx|xlsm|csv|txt|sql)" + file_match = re.search(rf"([A-Za-z]:[\\/][^\r\n\"'<>|,,。;;]*?\.{ext})", t, re.I) + dir_match = re.search(r"([A-Za-z]:[\\/][^\r\n\"'<>|,,。;;]+?)\s*目录下", t, re.I) + data_dir = "" + data_file = "" + data_path = "" + if file_match: + data_path = file_match.group(1).strip().rstrip("。,,;;") + data_dir = re.sub(r"[\\/][^\\/]+$", "", data_path) + data_file = re.split(r"[\\/]", data_path)[-1] + if dir_match: + data_dir = dir_match.group(1).strip().rstrip("\\/") + file_name_match = re.search( + rf"目录下(?:的)?\s*[`\"“”']?([^\s`\"“”',,。;;]+\.{ext})", + t, re.I, + ) + if file_name_match: + data_file = file_name_match.group(1).strip() + data_path = f"{data_dir}{os.sep}{data_file}" + if not data_dir and not data_path: + return {} + return {"dataDir": data_dir, "dataFile": data_file, "dataPath": data_path} + + # ---------------- 一级:规则快路(移植 POC parseNL,P0 纯解析) ---------------- def parse_fast(text: str, world: World) -> IntentResult | None: """正则词表快路:命中返回高置信意图;未命中返回 None(交给 LLM)。""" @@ -234,8 +262,14 @@ def parse_fast(text: str, world: World) -> IntentResult | None: return hit("data.analyze", params) # 带真实表格/SQL 路径的口令优先按数据处理,不能被句尾「知识库」误吞。 has_data_path = bool(re.search(r"[A-Za-z]:[\\/].*\.(xlsx|xlsm|csv|txt|sql)\b", t, re.I)) + explicit_data_algorithm_schedule = bool( + re.search(r"(?:使用|用).+?数据(?:包)?", t, re.I) + and re.search(r"(?:算法|\b(?:EDD|SPT|PRIORITY|FIFO|LPT|CR|ATC)\b|\boptimize\b)", t, re.I) + and re.search(r"排产", t) + ) if has_data_path and re.search(r"排产|试排|开排", t): - return hit("folder.schedule", {"query": t}) + if not explicit_data_algorithm_schedule: + return hit("folder.schedule", {"query": t}) if has_data_path and re.search(r"解析|分析|读取|识别|导入|入库|主数据|知识库", t): return hit("data.analyze", {"query": t}) # 工程目录:深度解析 / 按目录数据排产(靠前) @@ -485,11 +519,6 @@ def parse_fast(text: str, world: World) -> IntentResult | None: if re.search(r"多方案|多策略|方案对比|(对比|比较).{0,8}(策略|方案|排法)|对比一下|三种策略|沙盒对比", t): return hit("scenario.compare") # 报告生成(M3 §9.10):日报 / 版本对比 / 排产方案 - explicit_data_algorithm_schedule = bool( - re.search(r"(?:使用|用).+?数据(?:包)?", t, re.I) - and re.search(r"(?:算法|\b(?:EDD|SPT|PRIORITY|FIFO|LPT|CR|ATC)\b|\boptimize\b)", t, re.I) - and re.search(r"排产", t) - ) if re.search(r"排产方案报告|排产报告|方案报告|下载排产|导出排产|生成排产方案|下载方案报告", t) \ and not explicit_data_algorithm_schedule: from server.aps_domain.master_query import extract_code @@ -703,8 +732,9 @@ def parse_fast(text: str, world: World) -> IntentResult | None: # Keep the reference unresolved here so the parser remains pure; the # workflow resolves it against the registered pack catalog. data_pack_ref = None + source_slots = _extract_explicit_data_source(t) pack_match = re.search(r"(?:使用|用)\s*(.+?)\s*数据(?:包)?(?=\s*(?:[,,。]|用|使用|$))", t, re.I) - if pack_match: + if pack_match and not source_slots: data_pack_ref = pack_match.group(1).strip(" \t,,。") algorithm_rules = ( @@ -730,7 +760,9 @@ def parse_fast(text: str, world: World) -> IntentResult | None: else "full" if re.search(r"全量|全窗", t) else None) params: dict = {"sortMode": selected_algorithm or mode} - if data_pack_ref: + if source_slots: + params.update(source_slots) + elif data_pack_ref: params["dataPackRef"] = data_pack_ref if win: params["window"] = win diff --git a/server/aps_domain/workflow.py b/server/aps_domain/workflow.py index d2813cf..286cb31 100644 --- a/server/aps_domain/workflow.py +++ b/server/aps_domain/workflow.py @@ -1749,13 +1749,48 @@ def _external_audit_evidence(summary: dict, trace: dict) -> list[str]: return refs -def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply: +def _run_flex(store: WorldStore, intent: IntentResult, actor: str, + session_id: str | None = None) -> AgentReply: """触发柔性排产并回执(短文案 + 结构化 flex-schedule 块:KPI + 虚拟产线表 + 瓶颈)。""" from server.aps_domain.flex import run_flex_schedule from server.timeutil import parse_dt mode = intent.params.get("sortMode") or "BOTTLENECK" window = intent.params.get("window") data_pack = None + data_source = None + data_path = str(intent.params.get("dataPath") or "").strip() + data_dir = str(intent.params.get("dataDir") or "").strip() + data_file = str(intent.params.get("dataFile") or "").strip() + if data_path or data_dir: + from server.aps_domain.project_analyze import analyze_project_deep + source_query = data_path or (os.path.join(data_dir, data_file) if data_file else data_dir) + if data_file and not os.path.isfile(source_query): + return AgentReply(text=f"数据文件不存在:{source_query},没有执行排产。") + if not data_file and data_dir and not os.path.isdir(data_dir): + return AgentReply(text=f"数据目录不存在:{data_dir},没有执行排产。") + if data_path and not data_file and not os.path.isfile(data_path) and not os.path.isdir(data_path): + return AgentReply(text=f"数据路径不存在:{data_path},没有执行排产。") + try: + source_report = analyze_project_deep( + store.data, session_id, apply_sql=True, + query=source_query, next_id=store.next_id, + ) + except (OSError, PermissionError, ValueError) as exc: + return AgentReply(text=f"数据文件读取失败:{exc}") + if not source_report.get("ok"): + return AgentReply(text=f"数据目录分析失败:{source_report.get('error') or '目录不可用'}") + if not source_report.get("canSchedule"): + missing = (source_report.get("plan") or [])[:3] + detail = ";".join(str(item) for item in missing) + return AgentReply( + text=(f"数据已读取,但暂时不能排产:缺少可排所需的订单、工艺或设备信息。" + + (f"\n{detail}" if detail else "")), + ) + data_source = { + "directory": source_report.get("workDir") or data_dir, + "file": data_file or (os.path.basename(data_path) if data_path else None), + "paths": source_report.get("sourcePaths") or [], + } data_pack_ref = str(intent.params.get("dataPackRef") or "").strip() if data_pack_ref: from server.state.packs import load_pack, resolve_pack_reference @@ -1893,6 +1928,7 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply }, "dataPack": ({"file": data_pack.get("file"), "name": data_pack.get("name"), "reference": data_pack_ref} if data_pack else None), + "dataSource": data_source, "stats": {"vlCount": result["vlCount"], "woCount": result["woCount"], "makespan": result.get("makespan"), "onTimeCount": result.get("onTimeCount", 0), "conflictCount": result["conflictCount"], @@ -1943,7 +1979,9 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply + ("\n\n别担心,我把还缺的东西列在下面,你按顺序补就行。" if guide_block else "") ) else: - pack_hint = f"数据包「{data_pack.get('name')}」· " if data_pack else "" + pack_hint = (f"数据包「{data_pack.get('name')}」· " if data_pack else + f"数据文件「{data_source.get('file') or data_source.get('directory')}」· " + if data_source else "") text = ( f"柔性排产完成。{pack_hint}{focus_hint}版本 {result['versionNo']}({mode_cn}·{win_cn}):" f"{result['vlCount']} 条虚拟产线 / {result['woCount']} 个工单,冲突 {result['conflictCount']} 项" @@ -2942,7 +2980,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block]) # ---- 柔性排产(P1:能力池动态组虚拟产线,写草稿版本 §M5) ---- if name == "flex.schedule": - return _run_flex(store, intent, actor) + return _run_flex(store, intent, actor, session_id=session_id) if name == "flex.reschedule": return _flex_reschedule(store, intent, session_id, actor) if name == "flex.swap": diff --git a/tests/golden/test_optimize_chat_data_selection.py b/tests/golden/test_optimize_chat_data_selection.py index dfa0f4d..f14b4e5 100644 --- a/tests/golden/test_optimize_chat_data_selection.py +++ b/tests/golden/test_optimize_chat_data_selection.py @@ -34,6 +34,21 @@ def test_pack_reference_resolves_registered_aliases(): assert pack["file"] == "optimize-simulation-v1.json" +def test_chat_command_parses_directory_file_and_algorithm_slots(): + result = parse_fast( + r"使用 D:\aps-data 目录下的 orders.xlsx 数据用 EDD 算法生成排产方案", + {}, + ) + + assert result is not None + assert result.intent == "flex.schedule" + assert result.params["dataDir"] == r"D:\aps-data" + assert result.params["dataFile"] == "orders.xlsx" + assert result.params["dataPath"].endswith(r"aps-data\orders.xlsx") + assert result.params["sortMode"] == "EDD" + assert result.params["engine"] == "OPTIMIZE" + + def test_chat_command_loads_pack_and_materializes_optimize_result(tmp_path, monkeypatch): monkeypatch.setenv("APS_DB_DISABLED", "1") store = WorldStore(path=str(tmp_path / "world.json")) @@ -66,3 +81,18 @@ def test_unknown_pack_fails_closed_without_creating_schedule(tmp_path, monkeypat assert "找不到数据包" in reply.text assert not store.data.get("flexScheduleVersions") + + +def test_unreadable_directory_fails_closed_without_scheduling(tmp_path, monkeypatch): + monkeypatch.setenv("APS_DB_DISABLED", "1") + store = WorldStore(path=str(tmp_path / "world.json")) + intent = parse_fast( + r"使用 D:\missing-aps-data 目录下的 orders.xlsx 数据用 EDD 算法生成排产方案", + store.data, + ) + assert intent is not None + + reply = asyncio.run(handle_intent(store, "round88", intent, actor="test")) + + assert "数据文件不存在" in reply.text or "数据目录不存在" in reply.text + assert not store.data.get("flexScheduleVersions")