From 1790a23a2d6b7ce8492517308cc0dfa186973d06 Mon Sep 17 00:00:00 2001 From: ssk Date: Thu, 3 Sep 2026 21:28:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AF=B9=E8=AF=9D=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E6=95=B0=E6=8D=AE=E5=8C=85=E4=B8=8E=E6=8E=92=E4=BA=A7?= =?UTF-8?q?=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为柔性排产增加数据包引用和 Optimize dispatch rule 解析,使用注册表安全加载世界数据并在结果卡片展示数据与算法证据。关键洞察是把数据源和算法作为两个独立槽位复用现有闭环管线,避免客户专用 loader 和重复调度逻辑。已验证 12 个定向测试、19 个闭环回归、前端构建及独立 HTTP SSE 聊天链路,实测产出 10 条虚拟产线和 72 个工单。 --- apps/web/src/chat/ChatPanel.tsx | 8 +++ apps/web/src/styles.css | 1 + server/agent_core/intent.py | 37 ++++++++-- server/aps_domain/workflow.py | 39 ++++++++++- server/data/packs/optimize-simulation-v1.json | 1 + server/state/packs.py | 33 +++++++++ .../test_optimize_chat_data_selection.py | 68 +++++++++++++++++++ 7 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 tests/golden/test_optimize_chat_data_selection.py diff --git a/apps/web/src/chat/ChatPanel.tsx b/apps/web/src/chat/ChatPanel.tsx index b885025..92f09a6 100644 --- a/apps/web/src/chat/ChatPanel.tsx +++ b/apps/web/src/chat/ChatPanel.tsx @@ -1346,6 +1346,8 @@ const CONFLICT_CN: Record = { function FlexScheduleBlock(props: { block: UIBlock }) { const p = props.block.props as { versionNo: string; modeCn: string; + dataPack?: { file?: string; name?: string; reference?: string } | null; + algorithm?: { engine?: string; rule?: string; algorithmId?: string | null } | null; stats: { vlCount: number; woCount: number; makespan: string | null; onTimeCount: number; conflictCount: number; utilization: number }; bottleneck: { name: string; count: number }[]; lines: FlexLine[]; @@ -1378,6 +1380,12 @@ function FlexScheduleBlock(props: { block: UIBlock }) { + {(p.dataPack || p.algorithm) && ( +
+ {p.dataPack && 数据:{p.dataPack.name || p.dataPack.file}} + {p.algorithm && 算法:{p.algorithm.engine || 'CLOSED_LOOP'}{p.algorithm.rule ? ` · ${p.algorithm.rule}` : ''}} +
+ )}
{s.vlCount}虚拟产线
{s.woCount}工单
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 770391d..fa2303d 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2001,6 +2001,7 @@ html.aps-desktop .app-menubar-spacer, .flex-mode-badge { margin-left: auto; flex: 0 0 auto; font-size: 11px; font-weight: 600; color: var(--accent-deep); background: var(--accent-soft); border-radius: 999px; padding: 2px 10px; } .flex-mode-badge.warn { color: var(--warn); background: var(--warn-soft); } +.flex-block-meta { display: flex; gap: 14px; flex-wrap: wrap; margin: -2px 0 10px; color: var(--muted); font-size: 11.5px; } .flex-bottleneck { font-size: 11.5px; color: var(--muted); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .flex-bn-chip { font-size: 11px; font-weight: 600; color: var(--warn); background: var(--warn-soft); diff --git a/server/agent_core/intent.py b/server/agent_core/intent.py index e88358e..fae0b79 100644 --- a/server/agent_core/intent.py +++ b/server/agent_core/intent.py @@ -485,7 +485,13 @@ def parse_fast(text: str, world: World) -> IntentResult | None: if re.search(r"多方案|多策略|方案对比|(对比|比较).{0,8}(策略|方案|排法)|对比一下|三种策略|沙盒对比", t): return hit("scenario.compare") # 报告生成(M3 §9.10):日报 / 版本对比 / 排产方案 - if re.search(r"排产方案报告|排产报告|方案报告|下载排产|导出排产|生成排产方案|下载方案报告", t): + 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 params: dict = {"reportType": "plan"} code = extract_code(t) @@ -690,9 +696,30 @@ def parse_fast(text: str, world: World) -> IntentResult | None: # 仅固定轨时走规则试排 return hit("schedule.run", params) # 柔性排产(能力池 + 虚拟产线):须在固定产线 schedule.run 之前判定 - if re.search(r"柔性排产|柔性排一?版|能力池排产|按能力池|虚拟产线|多品种小批量" + if explicit_data_algorithm_schedule or re.search(r"柔性排产|柔性排一?版|能力池排产|按能力池|虚拟产线|多品种小批量" r"|全流程排产|走一?个?全流程|正排|顺排|倒排|逆排|瓶颈锚|滚动排产|短窗排产|中窗排产" r"|实时排产|分钟级排产", t): + # Data pack and dispatch rule are independent slots in one command. + # Keep the reference unresolved here so the parser remains pure; the + # workflow resolves it against the registered pack catalog. + data_pack_ref = None + pack_match = re.search(r"(?:使用|用)\s*(.+?)\s*数据(?:包)?(?=\s*(?:[,,。]|用|使用|$))", t, re.I) + if pack_match: + data_pack_ref = pack_match.group(1).strip(" \t,,。") + + algorithm_rules = ( + ("EDD", r"\bEDD\b|最早交期|交期优先"), + ("SPT", r"\bSPT\b|最短工时|短工时优先"), + ("PRIORITY", r"\bPRIORITY\b|优先级(?:优先)?"), + ("FIFO", r"\bFIFO\b|先进先出"), + ("LPT", r"\bLPT\b|最长工时|长工时优先"), + ("CR", r"\bCR\b|临界比"), + ("ATC", r"\bATC\b|逾期成本"), + ) + selected_algorithm = next( + (rule for rule, pattern in algorithm_rules if re.search(pattern, t, re.I)), + None, + ) mode = ("ASC" if re.search(r"正排|顺排", t) else "DESC" if re.search(r"倒排|逆排", t) else "BOTTLENECK") @@ -702,14 +729,16 @@ def parse_fast(text: str, world: World) -> IntentResult | None: else "long" if re.search(r"长窗|周窗|7\s*天", t) else "full" if re.search(r"全量|全窗", t) else None) - params: dict = {"sortMode": mode} + params: dict = {"sortMode": selected_algorithm or mode} + if data_pack_ref: + params["dataPackRef"] = data_pack_ref if win: params["window"] = win elif re.search(r"滚动", t): params["window"] = "short" # Explicit Optimize wording selects the integrated Optimize V2 adapter; # ordinary flexible scheduling keeps the CLOSED_LOOP default. - if re.search(r"\boptimize\b|优化引擎|优化算法", t, re.I): + if selected_algorithm or re.search(r"\boptimize\b|优化引擎|优化算法", t, re.I): params["engine"] = "OPTIMIZE" return hit("flex.schedule", params) # 瓶颈产能评估(瓶颈产能法) diff --git a/server/aps_domain/workflow.py b/server/aps_domain/workflow.py index 0adf23b..d2813cf 100644 --- a/server/aps_domain/workflow.py +++ b/server/aps_domain/workflow.py @@ -1755,6 +1755,27 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply from server.timeutil import parse_dt mode = intent.params.get("sortMode") or "BOTTLENECK" window = intent.params.get("window") + data_pack = None + data_pack_ref = str(intent.params.get("dataPackRef") or "").strip() + if data_pack_ref: + from server.state.packs import load_pack, resolve_pack_reference + data_pack = resolve_pack_reference(data_pack_ref) + if not data_pack: + from server.state.packs import list_packs + available = "、".join(str(item.get("name") or item.get("file")) for item in list_packs()) + return AgentReply( + text=(f"找不到数据包「{data_pack_ref}」,没有执行排产。" + f"可用数据包:{available or '暂无'}。"), + ) + # An explicit pack reference intentionally starts this run from that + # registered world, making repeated debug commands deterministic. + store.data = load_pack(data_pack["path"]) + store._reset_counters() + store.data.setdefault("flexParams", {})["selectedPack"] = { + "file": data_pack.get("file"), + "name": data_pack.get("name"), + "reference": data_pack_ref, + } order_ids = intent.params.get("orderIds") if isinstance(order_ids, list): order_ids = [int(x) for x in order_ids if str(x).isdigit() or isinstance(x, int)] @@ -1787,10 +1808,14 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply result = run_flex_schedule(store, sort_mode=mode, order_ids=order_ids or None, actor=actor, window=window, engine_type=engine or None) # P1 - mode_key = str(result.get("sortMode") or result.get("engineType") or mode or "CLOSED_LOOP") + mode_key = str(result.get("dispatchRule") or result.get("sortMode") or result.get("engineType") or mode or "CLOSED_LOOP") mode_cn = _FLEX_MODE_CN.get(mode_key, mode_key) if engine == "OPTIMIZE": - mode_cn = f"Optimize · {mode_cn}" + optimize_rule_cn = { + "EDD": "EDD 最早交期", "SPT": "SPT 最短工时", "PRIORITY": "PRIORITY 优先级", + "FIFO": "FIFO 先进先出", "LPT": "LPT 最长工时", "CR": "CR 临界比", "ATC": "ATC 逾期成本", + } + mode_cn = f"Optimize · {optimize_rule_cn.get(mode_key.upper(), mode_cn)}" solve_status = str(result.get("solveStatus") or "FEASIBLE").upper() if result.get("sortMode") == "EXTERNAL" or use_ext: mode_cn = f"外部算法({result.get('skillId') or 'skill'})" @@ -1861,6 +1886,13 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply blockId=f"flex-sched-{result['versionId']}", type="flex-schedule", props={ "versionNo": result["versionNo"], "modeCn": mode_cn, + "algorithm": { + "engine": result.get("engineType") or engine or "CLOSED_LOOP", + "rule": result.get("dispatchRule") or mode, + "algorithmId": result.get("algorithmId"), + }, + "dataPack": ({"file": data_pack.get("file"), "name": data_pack.get("name"), + "reference": data_pack_ref} if data_pack else None), "stats": {"vlCount": result["vlCount"], "woCount": result["woCount"], "makespan": result.get("makespan"), "onTimeCount": result.get("onTimeCount", 0), "conflictCount": result["conflictCount"], @@ -1911,8 +1943,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 "" text = ( - f"柔性排产完成。{focus_hint}版本 {result['versionNo']}({mode_cn}·{win_cn}):" + f"柔性排产完成。{pack_hint}{focus_hint}版本 {result['versionNo']}({mode_cn}·{win_cn}):" f"{result['vlCount']} 条虚拟产线 / {result['woCount']} 个工单,冲突 {result['conflictCount']} 项" + (f",窗外延期 {deferred}" if deferred else "") + f"。{dl_hint}" diff --git a/server/data/packs/optimize-simulation-v1.json b/server/data/packs/optimize-simulation-v1.json index cd09a1b..0098f22 100644 --- a/server/data/packs/optimize-simulation-v1.json +++ b/server/data/packs/optimize-simulation-v1.json @@ -1,6 +1,7 @@ { "packVersion": 1, "name": "Optimize synthetic debug V1", + "aliases": ["Optimize 模拟数据", "Optimize synthetic data", "simulation"], "description": "Generic APS world pack: 10 synthetic orders, 72 operations, and 6 virtual resources for Optimize development debugging only.", "baseDate": "2026-09-03", "world": { diff --git a/server/state/packs.py b/server/state/packs.py index f9b940c..09eb06f 100644 --- a/server/state/packs.py +++ b/server/state/packs.py @@ -80,7 +80,40 @@ def list_packs() -> list[dict[str, Any]]: head = json.load(f) out.append({"file": fn, "name": head.get("name") or fn, "description": head.get("description") or "", + "aliases": list(head.get("aliases") or []), "baseDate": head.get("baseDate") or "", "path": path}) except (json.JSONDecodeError, OSError): continue return out + + +def _pack_ref_key(value: str) -> str: + """Normalize a user-facing pack reference without imposing a customer name.""" + value = str(value or "").strip().lower() + value = re.sub(r"(?:数据包?|data\s*pack|数据)$", "", value).strip() + return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", value) + + +def resolve_pack_reference(reference: str) -> dict[str, Any] | None: + """Resolve a natural-language pack reference against the registered pack catalog. + + Matching is deliberately limited to metadata (file/name/aliases) so a chat + command can never turn an arbitrary path into a data load operation. + """ + key = _pack_ref_key(reference) + if not key: + return None + packs = list_packs() + exact: list[dict[str, Any]] = [] + loose: list[dict[str, Any]] = [] + for pack in packs: + labels = [pack.get("file"), os.path.splitext(str(pack.get("file") or ""))[0], + pack.get("name"), *(pack.get("aliases") or [])] + keys = {_pack_ref_key(label) for label in labels if label} + if key in keys: + exact.append(pack) + continue + if any(key in label_key or label_key in key for label_key in keys if label_key): + loose.append(pack) + matches = exact if exact else loose + return matches[0] if len(matches) == 1 else None diff --git a/tests/golden/test_optimize_chat_data_selection.py b/tests/golden/test_optimize_chat_data_selection.py new file mode 100644 index 0000000..dfa0f4d --- /dev/null +++ b/tests/golden/test_optimize_chat_data_selection.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from server.agent_core.intent import parse_fast +from server.aps_domain.workflow import handle_intent +from server.state.packs import load_pack, resolve_pack_reference +from server.state.store import WorldStore + + +PACK_PATH = Path(__file__).resolve().parents[2] / "server" / "data" / "packs" / "optimize-simulation-v1.json" + + +def test_chat_command_parses_data_pack_and_algorithm_slots(): + world = load_pack(str(PACK_PATH)) + + result = parse_fast( + "使用 Optimize synthetic debug V1 数据用 EDD 算法生成排产方案", + world, + ) + + assert result is not None + assert result.intent == "flex.schedule" + assert result.params["dataPackRef"] == "Optimize synthetic debug V1" + assert result.params["engine"] == "OPTIMIZE" + assert result.params["sortMode"] == "EDD" + + +def test_pack_reference_resolves_registered_aliases(): + pack = resolve_pack_reference("Optimize 模拟数据") + + assert pack is not None + assert pack["file"] == "optimize-simulation-v1.json" + + +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")) + intent = parse_fast( + "使用 Optimize synthetic debug V1 数据用 EDD 算法生成排产方案", + store.data, + ) + assert intent is not None + + reply = asyncio.run(handle_intent(store, "round88", intent, actor="test")) + + assert "Optimize" in reply.text + assert "Optimize synthetic debug V1" in reply.text + block = next(block for block in reply.blocks if block.type == "flex-schedule") + assert block.props["dataPack"]["file"] == "optimize-simulation-v1.json" + assert block.props["algorithm"]["rule"] == "EDD" + assert block.props["stats"]["vlCount"] == 10 + assert block.props["stats"]["woCount"] == 72 + assert store.data["flexScheduleVersions"][-1]["engineType"] == "OPTIMIZE" + assert store.data["flexScheduleVersions"][-1]["algorithmId"] == "optimize.edd" + + +def test_unknown_pack_fails_closed_without_creating_schedule(tmp_path, monkeypatch): + monkeypatch.setenv("APS_DB_DISABLED", "1") + store = WorldStore(path=str(tmp_path / "world.json")) + intent = parse_fast("使用不存在的数据,用 EDD 算法生成排产方案", store.data) + assert intent is not None + + reply = asyncio.run(handle_intent(store, "round88", intent, actor="test")) + + assert "找不到数据包" in reply.text + assert not store.data.get("flexScheduleVersions")