合并第88轮对话排产集成
支持在对话中指定目录或文件数据,并选择 Optimize、EDD、SPT、FIFO 等算法生成排产方案;同时补充通用数据加载、结果展示和回归测试。GitNexus 提交前检查为 LOW:11 个文件、无额外受影响流程。
This commit is contained in:
commit
cd8806f649
|
|
@ -1346,6 +1346,8 @@ const CONFLICT_CN: Record<string, string> = {
|
||||||
function FlexScheduleBlock(props: { block: UIBlock }) {
|
function FlexScheduleBlock(props: { block: UIBlock }) {
|
||||||
const p = props.block.props as {
|
const p = props.block.props as {
|
||||||
versionNo: string; modeCn: string;
|
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 };
|
stats: { vlCount: number; woCount: number; makespan: string | null; onTimeCount: number; conflictCount: number; utilization: number };
|
||||||
bottleneck: { name: string; count: number }[];
|
bottleneck: { name: string; count: number }[];
|
||||||
lines: FlexLine[];
|
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 }}
|
<button type="button" className="btn-primary" style={{ marginLeft: 'auto', padding: '4px 12px', fontSize: 12 }}
|
||||||
onClick={downloadXlsx}>下载 Excel 工作计划表</button>
|
onClick={downloadXlsx}>下载 Excel 工作计划表</button>
|
||||||
</div>
|
</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-stats">
|
||||||
<div className="flex-stat"><b>{s.vlCount}</b><span>虚拟产线</span></div>
|
<div className="flex-stat"><b>{s.vlCount}</b><span>虚拟产线</span></div>
|
||||||
<div className="flex-stat"><b>{s.woCount}</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;
|
.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; }
|
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-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;
|
.flex-bottleneck { font-size: 11.5px; color: var(--muted); margin-bottom: 10px;
|
||||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
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);
|
.flex-bn-chip { font-size: 11px; font-weight: 600; color: var(--warn); background: var(--warn-soft);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Round 86: 通用 Optimize 模拟数据包
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把 `optimize/deliverables/kangni-simulation-package-v1` 的 10 个模拟订单、72 条工序转换成 APS 已有的通用 `world pack`,用于对话式 Optimize 调试和回归测试。
|
||||||
|
|
||||||
|
## 设计约束
|
||||||
|
|
||||||
|
- 不在 APS 生产代码中增加康尼专用 loader,也不让运行时识别客户名或外部包目录。
|
||||||
|
- 数据包只填充现有 `flexOrders`、`flexRoutings`、`flexOperations`、`flexEquipment`、`flexMaterials`、`salesOrders` 和 `flexCalendar` 契约。
|
||||||
|
- `flexParams.dataGrade=synthetic`,并保留来源包名;该数据只用于开发调试,不作为生产排产输入。
|
||||||
|
- 原始数据的 8:00-17:00 班次保存在 `flexParams.sourceCalendar`。由于当前 PoolEngine 按分钟落盘且不拆跨班次工序,调试日历使用全天可排,保证严格 V2 校验能验证结果。
|
||||||
|
- 小数分钟工时向上取整到分钟,原值写入 `sourceStdTimePerUnit`/`sourceStandardTime`。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- `load_pack` 能加载数据包并出现在 `list_packs()`。
|
||||||
|
- 7 个 Optimize 调度规则(EDD、SPT、PRIORITY、FIFO、LPT、CR、ATC)均产生 10 条虚拟产线、72 条工单,且 V2 严格校验通过。
|
||||||
|
- 目标分支仍保持为 `codex/integrate-optimize`;本轮只在 `round/86-generic-world-pack` 开发,合并前单独确认。
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Round 88:对话指定数据包与排产算法
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让用户可以用一条自然语言指令同时指定数据来源和算法,例如:
|
||||||
|
|
||||||
|
> 使用 Optimize synthetic debug V1 数据,用 EDD 算法生成排产方案
|
||||||
|
|
||||||
|
也支持指定本机目录里的文件:
|
||||||
|
|
||||||
|
> 使用 D:\\aps-data 目录下的 orders.xlsx 数据用 EDD 算法生成排产方案
|
||||||
|
|
||||||
|
系统应解析出 `dataPackRef`、`engine=OPTIMIZE` 和 `sortMode=EDD`,从已登记的数据包加载完整世界,再通过现有柔性闭环流程生成方案。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
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. 结果块增加数据包和算法证据,文本明确显示“使用哪个数据包、哪个算法、生成多少虚拟产线/工单”。
|
||||||
|
|
||||||
|
## 实施范围
|
||||||
|
|
||||||
|
- `server/state/packs.py`:包别名和通用引用解析。
|
||||||
|
- `server/agent_core/intent.py`:数据包引用、算法槽位和自然语言参数。
|
||||||
|
- `server/aps_domain/workflow.py`:加载包、重置计数器、结果证据和未找到包的错误提示。
|
||||||
|
- `server/data/packs/optimize-simulation-v1.json`:补充可读 aliases(仅包元数据)。
|
||||||
|
- `tests/golden/test_optimize_chat_data_selection.py`:解析和端到端柔性排产验证。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 指令 `使用 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 默认路径。
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
```text
|
||||||
|
pytest -q tests/golden/test_optimize_chat_data_selection.py tests/golden/test_optimize_chat_trigger.py tests/golden/test_optimize_simulation_world_pack.py tests/golden/test_optimize_engine.py
|
||||||
|
```
|
||||||
|
|
||||||
|
再使用独立 `APS_HOME` 启动后端和前端,通过 `/api/chat` 发送上述完整中文指令,检查 SSE 中的 intent 参数、`FEASIBLE` 结果及 10/72 统计。
|
||||||
|
|
||||||
|
## 风险与边界
|
||||||
|
|
||||||
|
- 数据包选择是显式的会话世界替换,只允许来自注册表的路径;本轮不开放任意路径加载。
|
||||||
|
- 规则名由 Optimize 适配器支持的 dispatch rule 白名单约束,未知算法不会被静默当成 EDD。
|
||||||
|
- 当前轮次只在 `round/88-chat-data-algorithm-selection` 验证;完成后停在合并前,等待用户确认是否合并到 `codex/integrate-optimize`。
|
||||||
|
|
||||||
|
## 计划审计
|
||||||
|
|
||||||
|
PLAN AUDIT: PASS
|
||||||
|
|
||||||
|
Blocking issues: none
|
||||||
|
|
||||||
|
Clarification needed: none
|
||||||
|
|
||||||
|
Non-blocking improvements: 后续可再把算法白名单从代码抽到能力描述接口;目录导入仍沿用现有 P2 确认流程的审计策略。
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
# ============================================================
|
# ============================================================
|
||||||
from __future__ import annotations # 前向类型引用
|
from __future__ import annotations # 前向类型引用
|
||||||
|
|
||||||
|
import os # 路径解析
|
||||||
import re # 正则快路
|
import re # 正则快路
|
||||||
from typing import Any # 类型标注
|
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 纯解析) ----------------
|
# ---------------- 一级:规则快路(移植 POC parseNL,P0 纯解析) ----------------
|
||||||
def parse_fast(text: str, world: World) -> IntentResult | None:
|
def parse_fast(text: str, world: World) -> IntentResult | None:
|
||||||
"""正则词表快路:命中返回高置信意图;未命中返回 None(交给 LLM)。"""
|
"""正则词表快路:命中返回高置信意图;未命中返回 None(交给 LLM)。"""
|
||||||
|
|
@ -234,7 +262,13 @@ def parse_fast(text: str, world: World) -> IntentResult | None:
|
||||||
return hit("data.analyze", params)
|
return hit("data.analyze", params)
|
||||||
# 带真实表格/SQL 路径的口令优先按数据处理,不能被句尾「知识库」误吞。
|
# 带真实表格/SQL 路径的口令优先按数据处理,不能被句尾「知识库」误吞。
|
||||||
has_data_path = bool(re.search(r"[A-Za-z]:[\\/].*\.(xlsx|xlsm|csv|txt|sql)\b", t, re.I))
|
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 has_data_path and re.search(r"排产|试排|开排", t):
|
||||||
|
if not explicit_data_algorithm_schedule:
|
||||||
return hit("folder.schedule", {"query": t})
|
return hit("folder.schedule", {"query": t})
|
||||||
if has_data_path and re.search(r"解析|分析|读取|识别|导入|入库|主数据|知识库", t):
|
if has_data_path and re.search(r"解析|分析|读取|识别|导入|入库|主数据|知识库", t):
|
||||||
return hit("data.analyze", {"query": t})
|
return hit("data.analyze", {"query": t})
|
||||||
|
|
@ -485,7 +519,8 @@ def parse_fast(text: str, world: World) -> IntentResult | None:
|
||||||
if re.search(r"多方案|多策略|方案对比|(对比|比较).{0,8}(策略|方案|排法)|对比一下|三种策略|沙盒对比", t):
|
if re.search(r"多方案|多策略|方案对比|(对比|比较).{0,8}(策略|方案|排法)|对比一下|三种策略|沙盒对比", t):
|
||||||
return hit("scenario.compare")
|
return hit("scenario.compare")
|
||||||
# 报告生成(M3 §9.10):日报 / 版本对比 / 排产方案
|
# 报告生成(M3 §9.10):日报 / 版本对比 / 排产方案
|
||||||
if re.search(r"排产方案报告|排产报告|方案报告|下载排产|导出排产|生成排产方案|下载方案报告", t):
|
if re.search(r"排产方案报告|排产报告|方案报告|下载排产|导出排产|生成排产方案|下载方案报告", t) \
|
||||||
|
and not explicit_data_algorithm_schedule:
|
||||||
from server.aps_domain.master_query import extract_code
|
from server.aps_domain.master_query import extract_code
|
||||||
params: dict = {"reportType": "plan"}
|
params: dict = {"reportType": "plan"}
|
||||||
code = extract_code(t)
|
code = extract_code(t)
|
||||||
|
|
@ -690,9 +725,31 @@ def parse_fast(text: str, world: World) -> IntentResult | None:
|
||||||
# 仅固定轨时走规则试排
|
# 仅固定轨时走规则试排
|
||||||
return hit("schedule.run", params)
|
return hit("schedule.run", params)
|
||||||
# 柔性排产(能力池 + 虚拟产线):须在固定产线 schedule.run 之前判定
|
# 柔性排产(能力池 + 虚拟产线):须在固定产线 schedule.run 之前判定
|
||||||
if re.search(r"柔性排产|柔性排一?版|能力池排产|按能力池|虚拟产线|多品种小批量"
|
if explicit_data_algorithm_schedule or re.search(r"柔性排产|柔性排一?版|能力池排产|按能力池|虚拟产线|多品种小批量"
|
||||||
r"|全流程排产|走一?个?全流程|正排|顺排|倒排|逆排|瓶颈锚|滚动排产|短窗排产|中窗排产"
|
r"|全流程排产|走一?个?全流程|正排|顺排|倒排|逆排|瓶颈锚|滚动排产|短窗排产|中窗排产"
|
||||||
r"|实时排产|分钟级排产", t):
|
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
|
||||||
|
source_slots = _extract_explicit_data_source(t)
|
||||||
|
pack_match = re.search(r"(?:使用|用)\s*(.+?)\s*数据(?:包)?(?=\s*(?:[,,。]|用|使用|$))", t, re.I)
|
||||||
|
if pack_match and not source_slots:
|
||||||
|
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)
|
mode = ("ASC" if re.search(r"正排|顺排", t)
|
||||||
else "DESC" if re.search(r"倒排|逆排", t)
|
else "DESC" if re.search(r"倒排|逆排", t)
|
||||||
else "BOTTLENECK")
|
else "BOTTLENECK")
|
||||||
|
|
@ -702,11 +759,19 @@ def parse_fast(text: str, world: World) -> IntentResult | None:
|
||||||
else "long" if re.search(r"长窗|周窗|7\s*天", t)
|
else "long" if re.search(r"长窗|周窗|7\s*天", t)
|
||||||
else "full" if re.search(r"全量|全窗", t)
|
else "full" if re.search(r"全量|全窗", t)
|
||||||
else None)
|
else None)
|
||||||
params: dict = {"sortMode": mode}
|
params: dict = {"sortMode": selected_algorithm or mode}
|
||||||
|
if source_slots:
|
||||||
|
params.update(source_slots)
|
||||||
|
elif data_pack_ref:
|
||||||
|
params["dataPackRef"] = data_pack_ref
|
||||||
if win:
|
if win:
|
||||||
params["window"] = win
|
params["window"] = win
|
||||||
elif re.search(r"滚动", t):
|
elif re.search(r"滚动", t):
|
||||||
params["window"] = "short"
|
params["window"] = "short"
|
||||||
|
# Explicit Optimize wording selects the integrated Optimize V2 adapter;
|
||||||
|
# ordinary flexible scheduling keeps the CLOSED_LOOP default.
|
||||||
|
if selected_algorithm or re.search(r"\boptimize\b|优化引擎|优化算法", t, re.I):
|
||||||
|
params["engine"] = "OPTIMIZE"
|
||||||
return hit("flex.schedule", params)
|
return hit("flex.schedule", params)
|
||||||
# 瓶颈产能评估(瓶颈产能法)
|
# 瓶颈产能评估(瓶颈产能法)
|
||||||
if re.search(r"瓶颈产能|产能池|产能评估|各?工序产能|瓶颈在哪|产能瓶颈|能做多少|日产能", t):
|
if re.search(r"瓶颈产能|产能池|产能评估|各?工序产能|瓶颈在哪|产能瓶颈|能做多少|日产能", t):
|
||||||
|
|
|
||||||
|
|
@ -1794,12 +1794,68 @@ def _external_audit_evidence(summary: dict, trace: dict) -> list[str]:
|
||||||
return refs
|
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 + 虚拟产线表 + 瓶颈)。"""
|
"""触发柔性排产并回执(短文案 + 结构化 flex-schedule 块:KPI + 虚拟产线表 + 瓶颈)。"""
|
||||||
from server.aps_domain.flex import run_flex_schedule
|
from server.aps_domain.flex import run_flex_schedule
|
||||||
from server.timeutil import parse_dt
|
from server.timeutil import parse_dt
|
||||||
mode = intent.params.get("sortMode") or "BOTTLENECK"
|
mode = intent.params.get("sortMode") or "BOTTLENECK"
|
||||||
window = intent.params.get("window")
|
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
|
||||||
|
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")
|
order_ids = intent.params.get("orderIds")
|
||||||
if isinstance(order_ids, list):
|
if isinstance(order_ids, list):
|
||||||
order_ids = [int(x) for x in order_ids if str(x).isdigit() or isinstance(x, int)]
|
order_ids = [int(x) for x in order_ids if str(x).isdigit() or isinstance(x, int)]
|
||||||
|
|
@ -1830,9 +1886,16 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply
|
||||||
store.save()
|
store.save()
|
||||||
else:
|
else:
|
||||||
result = run_flex_schedule(store, sort_mode=mode, order_ids=order_ids or None,
|
result = run_flex_schedule(store, sort_mode=mode, order_ids=order_ids or None,
|
||||||
actor=actor, window=window) # P1
|
actor=actor, window=window,
|
||||||
mode_key = str(result.get("sortMode") or result.get("engineType") or mode or "CLOSED_LOOP")
|
engine_type=engine or None) # P1
|
||||||
|
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)
|
mode_cn = _FLEX_MODE_CN.get(mode_key, mode_key)
|
||||||
|
if engine == "OPTIMIZE":
|
||||||
|
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()
|
solve_status = str(result.get("solveStatus") or "FEASIBLE").upper()
|
||||||
if result.get("sortMode") == "EXTERNAL" or use_ext:
|
if result.get("sortMode") == "EXTERNAL" or use_ext:
|
||||||
mode_cn = f"外部算法({result.get('skillId') or 'skill'})"
|
mode_cn = f"外部算法({result.get('skillId') or 'skill'})"
|
||||||
|
|
@ -1903,6 +1966,14 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply
|
||||||
blockId=f"flex-sched-{result['versionId']}", type="flex-schedule",
|
blockId=f"flex-sched-{result['versionId']}", type="flex-schedule",
|
||||||
props={
|
props={
|
||||||
"versionNo": result["versionNo"], "modeCn": mode_cn,
|
"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),
|
||||||
|
"dataSource": data_source,
|
||||||
"stats": {"vlCount": result["vlCount"], "woCount": result["woCount"],
|
"stats": {"vlCount": result["vlCount"], "woCount": result["woCount"],
|
||||||
"makespan": result.get("makespan"), "onTimeCount": result.get("onTimeCount", 0),
|
"makespan": result.get("makespan"), "onTimeCount": result.get("onTimeCount", 0),
|
||||||
"conflictCount": result["conflictCount"],
|
"conflictCount": result["conflictCount"],
|
||||||
|
|
@ -1953,8 +2024,11 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply
|
||||||
+ ("\n\n别担心,我把还缺的东西列在下面,你按顺序补就行。" if guide_block else "")
|
+ ("\n\n别担心,我把还缺的东西列在下面,你按顺序补就行。" if guide_block else "")
|
||||||
)
|
)
|
||||||
else:
|
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 = (
|
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"{result['vlCount']} 条虚拟产线 / {result['woCount']} 个工单,冲突 {result['conflictCount']} 项"
|
||||||
+ (f",窗外延期 {deferred}" if deferred else "")
|
+ (f",窗外延期 {deferred}" if deferred else "")
|
||||||
+ f"。{dl_hint}"
|
+ f"。{dl_hint}"
|
||||||
|
|
@ -2951,7 +3025,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
|
||||||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||||||
# ---- 柔性排产(P1:能力池动态组虚拟产线,写草稿版本 §M5) ----
|
# ---- 柔性排产(P1:能力池动态组虚拟产线,写草稿版本 §M5) ----
|
||||||
if name == "flex.schedule":
|
if name == "flex.schedule":
|
||||||
return _run_flex(store, intent, actor)
|
return _run_flex(store, intent, actor, session_id=session_id)
|
||||||
if name == "flex.reschedule":
|
if name == "flex.reschedule":
|
||||||
return _flex_reschedule(store, intent, session_id, actor)
|
return _flex_reschedule(store, intent, session_id, actor)
|
||||||
if name == "flex.swap":
|
if name == "flex.swap":
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -80,7 +80,40 @@ def list_packs() -> list[dict[str, Any]]:
|
||||||
head = json.load(f)
|
head = json.load(f)
|
||||||
out.append({"file": fn, "name": head.get("name") or fn,
|
out.append({"file": fn, "name": head.get("name") or fn,
|
||||||
"description": head.get("description") or "",
|
"description": head.get("description") or "",
|
||||||
|
"aliases": list(head.get("aliases") or []),
|
||||||
"baseDate": head.get("baseDate") or "", "path": path})
|
"baseDate": head.get("baseDate") or "", "path": path})
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
continue
|
continue
|
||||||
return out
|
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,98 @@
|
||||||
|
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_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"))
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from server.agent_core.intent import parse_fast
|
||||||
|
from server.state.packs import load_pack
|
||||||
|
|
||||||
|
|
||||||
|
PACK_PATH = Path(__file__).resolve().parents[2] / "server" / "data" / "packs" / "optimize-simulation-v1.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_optimize_language_selects_optimize_flex_engine():
|
||||||
|
world = load_pack(str(PACK_PATH))
|
||||||
|
|
||||||
|
result = parse_fast("用 Optimize 算法生成一版柔性排产", world)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.intent == "flex.schedule"
|
||||||
|
assert result.params["engine"] == "OPTIMIZE"
|
||||||
|
assert result.params["sortMode"] == "BOTTLENECK"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_flexible_schedule_keeps_closed_loop_default():
|
||||||
|
world = load_pack(str(PACK_PATH))
|
||||||
|
|
||||||
|
result = parse_fast("生成一版柔性排产方案", world)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.intent == "flex.schedule"
|
||||||
|
assert "engine" not in result.params
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
|
||||||
|
from server.state.packs import load_pack, list_packs
|
||||||
|
|
||||||
|
|
||||||
|
PACK_PATH = Path(__file__).resolve().parents[2] / "server" / "data" / "packs" / "optimize-simulation-v1.json"
|
||||||
|
RULES = ("EDD", "SPT", "PRIORITY", "FIFO", "LPT", "CR", "ATC")
|
||||||
|
|
||||||
|
|
||||||
|
def _next_id_factory():
|
||||||
|
counters: dict[str, int] = {}
|
||||||
|
|
||||||
|
def next_id(kind: str) -> int:
|
||||||
|
counters[kind] = counters.get(kind, 0) + 1
|
||||||
|
return counters[kind]
|
||||||
|
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_simulation_world_pack_is_a_generic_aps_fixture():
|
||||||
|
pack = json.loads(PACK_PATH.read_text(encoding="utf-8"))
|
||||||
|
world = load_pack(str(PACK_PATH))
|
||||||
|
|
||||||
|
assert pack["packVersion"] == 1
|
||||||
|
assert pack["name"] == "Optimize synthetic debug V1"
|
||||||
|
assert world["flexParams"]["dataGrade"] == "synthetic"
|
||||||
|
assert len(world["flexOrders"]) == 10
|
||||||
|
assert len(world["salesOrders"]) == 10
|
||||||
|
assert len(world["flexRoutings"]) == 72
|
||||||
|
assert len(world["flexEquipment"]) == 6
|
||||||
|
assert all(row["source"] == "SYNTHETIC" for row in world["flexOrders"])
|
||||||
|
assert {row["productCode"] for row in world["flexRoutings"]} == {
|
||||||
|
row["productCode"] for row in world["flexOrders"]
|
||||||
|
}
|
||||||
|
assert any(item["file"] == PACK_PATH.name for item in list_packs())
|
||||||
|
|
||||||
|
|
||||||
|
def test_simulation_world_pack_runs_all_optimize_dispatch_rules():
|
||||||
|
source_world = load_pack(str(PACK_PATH))
|
||||||
|
|
||||||
|
for rule in RULES:
|
||||||
|
world = copy.deepcopy(source_world)
|
||||||
|
result = run_closed_loop_candidate(
|
||||||
|
world,
|
||||||
|
_next_id_factory(),
|
||||||
|
business_date="2026-08-02",
|
||||||
|
engine_type="OPTIMIZE",
|
||||||
|
sort_mode=rule,
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["solveStatus"] == "FEASIBLE", rule
|
||||||
|
assert result["engineType"] == "OPTIMIZE", rule
|
||||||
|
assert result["algorithmId"] == f"optimize.{rule.lower()}", rule
|
||||||
|
assert result["orderCount"] == 10, rule
|
||||||
|
assert result["vlCount"] == 10, rule
|
||||||
|
assert result["woCount"] == 72, rule
|
||||||
|
assert result["validation"]["valid"] is True, rule
|
||||||
|
assert result["validation"]["hardViolations"] == [], rule
|
||||||
Loading…
Reference in New Issue