支持对话指定数据包与排产算法
为柔性排产增加数据包引用和 Optimize dispatch rule 解析,使用注册表安全加载世界数据并在结果卡片展示数据与算法证据。关键洞察是把数据源和算法作为两个独立槽位复用现有闭环管线,避免客户专用 loader 和重复调度逻辑。已验证 12 个定向测试、19 个闭环回归、前端构建及独立 HTTP SSE 聊天链路,实测产出 10 条虚拟产线和 72 个工单。
This commit is contained in:
parent
1e64e9c0ae
commit
1790a23a2d
|
|
@ -1346,6 +1346,8 @@ const CONFLICT_CN: Record<string, string> = {
|
|||
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 }) {
|
|||
<button type="button" className="btn-primary" style={{ marginLeft: 'auto', padding: '4px 12px', fontSize: 12 }}
|
||||
onClick={downloadXlsx}>下载 Excel 工作计划表</button>
|
||||
</div>
|
||||
{(p.dataPack || p.algorithm) && (
|
||||
<div className="flex-block-meta">
|
||||
{p.dataPack && <span>数据:{p.dataPack.name || p.dataPack.file}</span>}
|
||||
{p.algorithm && <span>算法:{p.algorithm.engine || 'CLOSED_LOOP'}{p.algorithm.rule ? ` · ${p.algorithm.rule}` : ''}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-stats">
|
||||
<div className="flex-stat"><b>{s.vlCount}</b><span>虚拟产线</span></div>
|
||||
<div className="flex-stat"><b>{s.woCount}</b><span>工单</span></div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# 瓶颈产能评估(瓶颈产能法)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
Loading…
Reference in New Issue