支持目录文件指定算法排产
扩展对话解析以识别目录、文件和算法三个槽位,复用项目深度分析读取 Excel/CSV/SQL,并在路径不存在时阻断旧数据回退。关键洞察是显式文件输入必须先做路径校验,再进入现有 Optimize 闭环。已验证 29 个回归测试通过,并用 HTTP SSE 实测不存在路径会直接返回错误。
This commit is contained in:
parent
1790a23a2d
commit
f03596b134
|
|
@ -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 确认流程的审计策略。
|
||||
|
|
|
|||
|
|
@ -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,7 +262,13 @@ 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):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue