2446 lines
147 KiB
Python
2446 lines
147 KiB
Python
# ============================================================
|
||
# 排产工作流编排(moduleId: domain-workflow, 可重生 ✅)
|
||
# 职责:意图 → 动作 → AgentReply(文本 + 视口命令 + UI 块)
|
||
# 权力路由:P0/P1 直接执行;P2(发布/重置)经门禁出确认卡(§3.3)
|
||
# ============================================================
|
||
from __future__ import annotations # 前向类型引用
|
||
|
||
import re
|
||
import os
|
||
from typing import Any # 类型标注
|
||
|
||
from server.agent_core import harness # 门禁 v1
|
||
from server.agent_core.audit import write_audit # 审计写入
|
||
from server.contracts import ( # 跨层契约
|
||
AgentReply, IntentResult, ScheduleResult, ViewportCommand,
|
||
)
|
||
from server.engines import get_engine # 引擎工厂
|
||
from server.engines.base import EngineParams # 引擎入参
|
||
from server.state.checkpoints import get_checkpoints # 成对快照仓(§4.3)
|
||
from server.state.store import WorldStore # 世界状态存储
|
||
from server.timeutil import add_minutes, fmt_date, today0 # 日期工具
|
||
from server.aps_domain.scenario import compare_scenarios # 方案对比(Explore 沙盒 §9.7)
|
||
from server.aps_domain.reports import build_report, persist_report_xlsx # 报告生成 v1(§9.10)
|
||
from server.knowledge import get_knowledge, get_preferences # 知识库与偏好仓(M3 §8)
|
||
from server.contracts import UIBlock # 报告/证据块组装
|
||
|
||
# 帮助文案:能力清单(help 意图与拒识兜底共用)
|
||
_HELP = "\n".join([
|
||
"可执行的排产能力:",
|
||
"· 查询现场数据:有哪些订单 / 查订单 102285668 / 这个订单有委外吗 / 查物料 / 查 BOM / 查工艺路线",
|
||
"· 知识库:机加工工艺怎么生成 / 车削工艺模式 / 装配工艺模式 / 知识清单 / 导入知识文档",
|
||
"· 生成排产草案:试排一版交期优先 / 产能均衡重排 / 用规则引擎试排",
|
||
"· 柔性排产(能力池+虚拟产线):跑一版柔性排产 / 把这个订单排产 / 正排 / 倒排 / 瓶颈锚",
|
||
"· 外部算法:用外部算法试排 / 查看已接入算法 / skill 健康检查",
|
||
"· 瓶颈产能:瓶颈产能 / 各工序能做多少(按能力池实时算)",
|
||
"· 交期承诺:某产品 N 套什么时候能交",
|
||
"· 方案评估:多策略方案对比(沙盒运行,不影响当前版本)",
|
||
"· 紧急插单:插单快评 / 采用插单(需确认)",
|
||
"· 预测订单:查看预测 / 新建预测 / 预测纳入试排",
|
||
"· 切到负荷热力图 / 交期承诺看板 / 甘特图 / 柔性甘特",
|
||
"· 标出超期订单 / 查看冲突 / 按小时看 / 重置视图",
|
||
"· 建一个检查点 / 回滚到上一个检查点(需确认)",
|
||
"· 换线有什么规定 / @知识:机加工工艺路线生成总则(知识库带出处)",
|
||
"· 生成排产方案报告(下载 Excel 工作计划表) / 生成日报 / 生成版本对比报告",
|
||
"· 查看当前KPI / 下一步建议 / 发布当前排产版本(需确认)/ 重置数据(需确认)",
|
||
])
|
||
|
||
# 策略模板的中文名(回复文案用)
|
||
_STRATEGY_CN = {"DELIVERY_FIRST": "交期优先", "CAPACITY_BALANCE": "产能均衡",
|
||
"COST_FIRST": "成本最优(换型代理)", "CHANGEOVER_MIN": "换型最小化",
|
||
"CAMPAIGN": "战役合并", "FIFO": "先进先出", "COMPREHENSIVE": "综合优化"}
|
||
|
||
# OR-04:最近一次插单快评(供「采用插单」复用;进程内存)
|
||
_LAST_RUSH_EVAL: dict[str, Any] = {}
|
||
|
||
|
||
def _run_schedule(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply:
|
||
"""执行试排(P1:写草稿版本 + 落盘;发布另走 P2 门禁)。"""
|
||
# 偏好个性化(§8.3 第一层):用户没点名策略时用其历史最常用策略
|
||
explicit = intent.params.get("strategy") # 用户明确点名的策略(可能为空)
|
||
pref_note = "" # 个性化说明(透明可解释)
|
||
if explicit: # 点名 → 直接用
|
||
strategy = explicit
|
||
else: # 未点名 → 查偏好仓
|
||
strategy, scores = get_preferences().preferred_strategy(default="COMPREHENSIVE")
|
||
if scores: # 有历史信号才标注(避免误导)
|
||
pref_note = f"\n(按你的使用偏好选了{_STRATEGY_CN.get(strategy, strategy)},历史使用 {scores.get(strategy, 0)} 次;点名策略可覆盖)"
|
||
from server.aps_domain.params import get_schedule_params
|
||
from server.aps_domain.constraints import engine_constraint_flags
|
||
sp = get_schedule_params(store.data)
|
||
params = EngineParams( # 组装引擎入参
|
||
orderIds=[], # M1:全部待排订单
|
||
engineType=intent.params.get("engine") or sp.get("defaultEngine") or "HYBRID",
|
||
strategyTemplate=strategy, # 策略模板(点名或偏好)
|
||
planningHorizonDays=int(sp.get("planningHorizonDays") or 14), # 展望期(OR-02 可配)
|
||
startDate=fmt_date(add_minutes(today0(), 24 * 60)), # 明天开排
|
||
includeForecast=bool(intent.params.get("includeForecast")), # OR-05:预测纳入试排
|
||
constraints=engine_constraint_flags(store.data), # SC-04 约束剖面 → 引擎开关
|
||
timeLimitSeconds=float(sp["cpTimeLimitSeconds"]) if sp.get("cpTimeLimitSeconds") is not None else 8.0,
|
||
)
|
||
engine = get_engine(params.engineType) # CP / HYBRID 真管线;GA 仍 RULE 代跑
|
||
result: ScheduleResult = engine.solve(store.data, params, store.next_id) # 求解(写内存世界)
|
||
# 审计:算法运行留痕(§3.6.2 算法运行类:引擎/策略/计数可复算)
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="schedule.run",
|
||
target={"type": "SCHEDULE_VERSION", "id": result.versionId},
|
||
power="P1", rationale={"strategy": params.strategyTemplate, "engine": params.engineType,
|
||
"evidence": result.evidenceRefs,
|
||
"solveStatus": result.solveStatus,
|
||
"solveTimeSec": result.solveTimeSec,
|
||
"gap": result.optimalityGap})
|
||
store.save() # 试排结果落盘(草稿版本也持久化)
|
||
get_preferences().record(params.strategyTemplate, source="schedule.run", actor=actor) # 偏好信号沉淀(§8.3)
|
||
engine_note = ""
|
||
if params.engineType in ("CP", "HYBRID"):
|
||
st = result.solveStatus or "—"
|
||
gap = result.optimalityGap
|
||
gap_txt = f"{gap:.2%}" if gap is not None else "—"
|
||
tsec = result.solveTimeSec
|
||
t_txt = f"{tsec:.2f}s" if tsec is not None else "—"
|
||
pipe = "RULE→CP-SAT" if params.engineType == "HYBRID" else "CP-SAT"
|
||
engine_note = (
|
||
f"\n({pipe}:状态 {st} · 耗时 {t_txt} · gap {gap_txt};工序落槽=班次占槽)"
|
||
)
|
||
elif params.engineType == "GA":
|
||
engine_note = "\n(注:GA 真管线尚未接入,本次由规则引擎代跑并留 engineType 痕)"
|
||
text = (f"排产完成 ✅ 版本 {result.versionNo}({_STRATEGY_CN.get(result.strategy, result.strategy)})\n"
|
||
f"生产订单 {result.poCount} 个 / 工单 {result.woCount} 个"
|
||
+ ("(含预测)" if params.includeForecast else "") + "\n"
|
||
f"冲突 {result.conflictCount} 项 · 总延迟 {round(result.totalTardiness)}h · "
|
||
f"平均利用率 {round(result.avgUtilization * 100)}%{engine_note}{pref_note}\n"
|
||
f"右侧甘特已刷新为该版本。执行“发布当前排产版本”可进入 P2 发布确认。") # 回复正文(含下一步引导)
|
||
blocks: list[UIBlock] = []
|
||
if result.conflictCount >= 3:
|
||
from server.aps_domain.guidance import build_guidance
|
||
blocks.append(UIBlock(
|
||
blockId="guidance-after-run", type="guidance",
|
||
props=build_guidance(store.data, context="high_conflict")))
|
||
return AgentReply(text=text, blocks=blocks, commands=[ # 联动命令:切甘特 + 重拉世界数据
|
||
ViewportCommand(cmd="viewport.mode", params={"mode": "gantt"}, issuedBy="LLM"),
|
||
ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM"),
|
||
])
|
||
|
||
|
||
def _stage_publish(store: WorldStore, session_id: str, actor: str) -> AgentReply:
|
||
"""把“发布版本”压入门禁(P2:出确认卡,不立即执行)。"""
|
||
versions = store.data["scheduleVersions"] # 版本表
|
||
if not versions: # 无版本可发布
|
||
from server.aps_domain.guidance import build_guidance
|
||
block = UIBlock(blockId="guidance-no-version", type="guidance",
|
||
props=build_guidance(store.data, context="empty_schedule"))
|
||
return AgentReply(
|
||
text="当前没有可发布的排产版本。请先生成排产草案,例如:试排一版交期优先。",
|
||
blocks=[block])
|
||
v = versions[-1] # 最新版本
|
||
if v["status"] == "PUBLISHED": # 已发布防重
|
||
return AgentReply(text=f"版本 {v['versionNo']} 已是发布状态,无需重复发布。")
|
||
from server.aps_domain.constraints import hard_blocking_conflicts
|
||
blockers = hard_blocking_conflicts(store.data, version_id=v["id"], track="fixed")
|
||
if blockers:
|
||
names = "、".join(sorted({b.get("constraintId", "?") for b in blockers})[:5])
|
||
return AgentReply(
|
||
text=(f"版本 {v['versionNo']} 存在 {len(blockers)} 项硬约束冲突({names}),"
|
||
"按约束配置不可发布。请先解决冲突,或将对应约束改为软约束/关闭后再试。\n"
|
||
"可说「查看冲突」或「打开约束配置」。"))
|
||
block = harness.stage_confirmation( # 生成确认卡并登记待确认队列
|
||
session_id, "schedule.publish", {"versionId": v["id"]},
|
||
title=f"发布排产版本 {v['versionNo']}",
|
||
summary_lines=[ # 影响面说明(计划员据此决策)
|
||
f"生产订单 {v['poCount']} 个 / 工单 {v['woCount']} 个将转入执行准备",
|
||
f"未解决冲突 {v['conflictCount']} 项 · 总延迟 {round(v['totalTardiness'])}h",
|
||
"发布后该版本成为执行基准(P2 写主干世界状态)",
|
||
])
|
||
# 审计:门禁出卡事件(GATE 类)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="schedule.publish.stage",
|
||
target={"type": "SCHEDULE_VERSION", "id": v["id"]}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save() # 审计落盘
|
||
return AgentReply(text=f"发布版本 {v['versionNo']} 属于 P2 写操作,需要你确认(见下方确认卡)。", blocks=[block])
|
||
|
||
|
||
def _stage_order_upsert(store: WorldStore, session_id: str, intent: IntentResult, actor: str) -> AgentReply:
|
||
"""对话侧新建/编辑订单(P2 → 确认卡):解析槽位 + 成品,缺项则追问。"""
|
||
from server.aps_domain.orders import (confirmation_for_order_action, find_order,
|
||
find_product_by_hint)
|
||
p = dict(intent.params or {})
|
||
order_no = str(p.get("orderNo") or "")
|
||
existing = find_order(store.data, order_no=order_no) if order_no else None
|
||
if order_no and existing is None:
|
||
return AgentReply(text=f"没找到订单 {order_no},无法修改。可在订单面板核对订单号。")
|
||
|
||
payload: dict[str, Any] = {}
|
||
if existing: # 编辑:以现单兜底未给字段
|
||
first = (existing.get("items") or [{}])[0]
|
||
payload.update({
|
||
"id": existing["id"], "customerName": existing["customerName"],
|
||
"customerLevel": existing["customerLevel"], "deliveryDate": existing["deliveryDate"],
|
||
"priority": existing.get("priority", 5), "status": existing.get("status", "CONFIRMED"),
|
||
"productId": first.get("productId"), "quantity": first.get("quantity", 1),
|
||
"isRush": existing.get("isRush", False),
|
||
"specialRequirements": existing.get("specialRequirements", ""),
|
||
})
|
||
# 覆盖用户明确给出的字段
|
||
for k in ("customerName", "customerLevel", "deliveryDate", "priority", "quantity"):
|
||
if p.get(k) not in (None, ""):
|
||
payload[k] = p[k]
|
||
if "isRush" in p:
|
||
payload["isRush"] = p["isRush"]
|
||
prod_hint = p.get("productCode") or p.get("productName")
|
||
if prod_hint:
|
||
prod = find_product_by_hint(store.data, str(prod_hint))
|
||
if prod is None:
|
||
names = "、".join(m["name"] for m in store.data["materials"] if m["type"] == "FINISHED_PRODUCT")
|
||
return AgentReply(text=f"没找到成品「{prod_hint}」。可选成品:{names}。")
|
||
payload["productId"] = prod["id"]
|
||
|
||
missing = [label for key, label in (("customerName", "客户名称"), ("productId", "产品"),
|
||
("deliveryDate", "交期"), ("quantity", "数量"))
|
||
if not payload.get(key)]
|
||
if missing:
|
||
verb = "修改" if existing else "新建"
|
||
return AgentReply(text=f"要{verb}订单还差:{'、'.join(missing)}。\n"
|
||
"可以一次说清,例如:「新建订单 客户 康尼机电 产品 PDU配电单元 数量 500 交期 2026-08-20」。")
|
||
try:
|
||
title, lines = confirmation_for_order_action(store.data, "order.upsert", payload)
|
||
except ValueError as exc:
|
||
return AgentReply(text=f"订单信息有误:{exc}")
|
||
block = harness.stage_confirmation(session_id, "order.upsert", payload, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="order.upsert.stage",
|
||
target={"type": "SALES_ORDER", "id": payload.get("id") or "NEW"}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 订单写入,需要你确认。", blocks=[block])
|
||
|
||
|
||
def _stage_reset(store: WorldStore, session_id: str, actor: str) -> AgentReply:
|
||
"""把“重置数据”压入门禁(P2:破坏性操作必须确认)。"""
|
||
block = harness.stage_confirmation( # 生成确认卡
|
||
session_id, "data.reset", {},
|
||
title="清空并重置演示数据",
|
||
summary_lines=["将删除全部排产版本/工单/审计并重新播种(不可恢复)"])
|
||
return AgentReply(text="重置数据是破坏性操作(P2),需要你确认。", blocks=[block])
|
||
|
||
|
||
def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor: str) -> str:
|
||
"""执行/驳回一条已出卡的 P2 动作(确认卡回传入口;一次性令牌)。
|
||
|
||
权力等级:P2(本函数是 P2 动作的唯一执行通道,§3.3 门禁放行后)。
|
||
Returns: 面向用户的结果文案。
|
||
"""
|
||
pending = harness.take_confirmation(confirm_id) # 取出待确认动作(取后即失效)
|
||
if pending is None: # 过期/重复点击
|
||
return "该确认卡已失效(可能已处理过)。"
|
||
action, params = pending["action"], pending["params"] # 解构动作
|
||
if not approve: # ---- 驳回分支 ----
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action=action + ".reject",
|
||
target=params, power="P2", rationale={"confirmId": confirm_id}, result="DENIED") # 驳回留痕
|
||
store.save() # 落盘
|
||
return "已驳回,未做任何变更。"
|
||
if action == "schedule.publish": # ---- 批准:发布版本 ----
|
||
v = next(x for x in store.data["scheduleVersions"] if x["id"] == params["versionId"]) # 目标版本
|
||
from server.aps_domain.constraints import hard_blocking_conflicts
|
||
blockers = hard_blocking_conflicts(store.data, version_id=v["id"], track="fixed")
|
||
if blockers:
|
||
return (f"发布被硬约束门禁拦截:仍有 {len(blockers)} 项未解决硬约束冲突。"
|
||
"请先处理冲突或调整约束配置。")
|
||
# 回滚防线(§3.3 防线 3):P2 写主干前自动·强制建成对快照(回滚锚点)
|
||
get_checkpoints().create(store.data, label=f"发布前基线 {v['versionNo']}",
|
||
reason="auto:publish", conversation_note=f"批准发布 {v['versionNo']}")
|
||
v["status"] = "PUBLISHED" # 置为已发布
|
||
from datetime import datetime # 局部导入避免顶部循环
|
||
from server.timeutil import fmt_dt # 时间格式化
|
||
v["publishedAt"] = fmt_dt(datetime.now()) # 发布时间
|
||
for po in store.data["productionOrders"]: # 该版本 PO 推进为已确认
|
||
if po["schedulingVersionId"] == v["id"]:
|
||
po["status"] = "CONFIRMED"
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="schedule.publish",
|
||
target={"type": "SCHEDULE_VERSION", "id": v["id"]}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor}) # 发布留痕(谁批的)
|
||
store.save() # 主干写入落盘
|
||
return f"版本 {v['versionNo']} 已发布 ✅ 生产订单已转入执行准备(审计已留痕)。"
|
||
if action == "data.reset": # ---- 批准:重置数据 ----
|
||
# 回滚防线:重置前自动建档(重置本身也可被撤销)
|
||
get_checkpoints().create(store.data, label="重置前基线", reason="auto:reset",
|
||
conversation_note="批准重置数据")
|
||
store.reset() # 重新播种(含落盘)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="data.reset",
|
||
target={"type": "WORLD", "id": 0}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor}) # 重置留痕
|
||
store.save() # 审计落盘
|
||
return "数据已重置为种子状态 ✅(重置前状态已自动存档,可回滚)"
|
||
if action == "checkpoint.rollback": # ---- 批准:回滚到检查点 ----
|
||
pair = get_checkpoints().get(params["pairId"]) # 取目标快照(完整世界)
|
||
if pair is None: # 快照不存在(被淘汰)
|
||
return "目标检查点不存在(可能已被容量策略淘汰)。"
|
||
# 回滚防线:回滚前先把"现在"也存档(允许撤销这次回滚——时间旅行可往返 §4.4)
|
||
get_checkpoints().create(store.data, label="回滚前状态", reason="auto:rollback",
|
||
conversation_note=f"回滚到 {pair['label']}")
|
||
store.restore(pair["world"]) # 整体替换主干世界(成对恢复的世界侧)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="checkpoint.rollback",
|
||
target={"type": "CHECKPOINT", "id": params["pairId"]}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor, "label": pair["label"]}) # 回滚留痕
|
||
store.save() # 落盘
|
||
return f"已回滚到检查点【{pair['label']}】({pair['createdAt']})✅ 回滚前状态已自动存档。"
|
||
if action in ("order.upsert", "order.cancel", "order.complete", "order.delete", "order.clear",
|
||
"order.approve", "order.reject"): # ---- 批准:订单写入 / 审核 ----
|
||
from server.aps_domain.orders import apply_order_action
|
||
get_checkpoints().create(store.data, label="订单变更前基线", reason=f"auto:{action}",
|
||
conversation_note=f"批准执行 {action}") # P2 写前自动建档
|
||
applied = apply_order_action(store.data, store.next_id, action, params)
|
||
order = applied["order"]
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action=action,
|
||
target={"type": "SALES_ORDER", "id": order["id"], "orderNo": order["orderNo"]},
|
||
power="P2", rationale={"confirmId": confirm_id, "approver": actor,
|
||
"beforeStatus": applied.get("beforeStatus"),
|
||
"afterStatus": order["status"],
|
||
"approvedCount": applied.get("approvedCount"),
|
||
"rejectedCount": applied.get("rejectedCount")})
|
||
store.save()
|
||
if action == "order.approve":
|
||
return (f"已批准 ✅ {applied.get('approvedCount', 1)} 条订单 → APPROVED,"
|
||
f"可参与正式排产:{'、'.join(applied.get('orderNos') or [order['orderNo']])}")
|
||
if action == "order.reject":
|
||
return (f"已驳回 ✅ {applied.get('rejectedCount', 1)} 条:"
|
||
f"{'、'.join(applied.get('orderNos') or [order['orderNo']])}")
|
||
if action == "order.upsert":
|
||
verb = "已新增" if applied.get("created") else "已更新"
|
||
return f"{verb}订单 {order['orderNo']} ✅ 后续试排会使用最新订单池。"
|
||
if action == "order.cancel":
|
||
return f"订单 {order['orderNo']} 已取消 ✅ 后续试排将不再纳入。"
|
||
if action == "order.delete":
|
||
return f"订单 {order['orderNo']} 已删除 ✅ 已从订单池移除。"
|
||
if action == "order.clear":
|
||
return (f"订单池已清空 ✅ 销售 {applied.get('clearedOrders', 0)} 条 / "
|
||
f"采购 {applied.get('clearedPurchase', 0)} / 委外 {applied.get('clearedOutsource', 0)}。"
|
||
"后续可重新导入或新建订单。")
|
||
return f"订单 {order['orderNo']} 已完成 ✅ 后续试排将不再纳入。"
|
||
if action == "rush.apply": # ---- OR-04:采用紧急插单 ----
|
||
from server.aps_domain.rush import apply_rush
|
||
get_checkpoints().create(store.data, label="插单采用前基线", reason="auto:rush.apply",
|
||
conversation_note="采用紧急插单前自动建档")
|
||
applied = apply_rush(store.data, store.next_id, params)
|
||
order = applied["order"]
|
||
result = applied["result"]
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="rush.apply",
|
||
target={"type": "SALES_ORDER", "id": order["id"], "orderNo": order["orderNo"]},
|
||
power="P2", rationale={"confirmId": confirm_id, "approver": actor,
|
||
"versionNo": result.versionNo,
|
||
"strategy": applied.get("strategy"),
|
||
"evalId": params.get("evalId")})
|
||
store.save()
|
||
_LAST_RUSH_EVAL.clear()
|
||
return (f"插单已采用 ✅ {order['orderNo']}(急单)已入池;"
|
||
f"草稿版本 {result.versionNo} · 订单 {result.orderCount} · "
|
||
f"冲突 {result.conflictCount} · 延迟 {round(result.totalTardiness)}h。"
|
||
f"可用检查点回滚到采用前。")
|
||
if action in ("forecast.upsert", "forecast.delete", "forecast.convert"):
|
||
from server.aps_domain.forecast import apply_forecast_action
|
||
get_checkpoints().create(store.data, label="预测变更前基线", reason=f"auto:{action}",
|
||
conversation_note=f"批准执行 {action}")
|
||
applied = apply_forecast_action(store.data, store.next_id, action, params)
|
||
fc = applied["forecast"]
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action=action,
|
||
target={"type": "FORECAST_ORDER", "id": fc.get("id"), "forecastNo": fc.get("forecastNo")},
|
||
power="P2", rationale={"confirmId": confirm_id, "approver": actor,
|
||
"beforeStatus": applied.get("beforeStatus"),
|
||
"convertedOrderNo": applied.get("convertedOrderNo")})
|
||
store.save()
|
||
if action == "forecast.convert":
|
||
return (f"预测已转正 ✅ {fc.get('forecastNo')} → 销售订单 {applied.get('convertedOrderNo')},"
|
||
"可参与正式排产。")
|
||
if action == "forecast.delete":
|
||
return f"预测 {fc.get('forecastNo')} 已删除 ✅"
|
||
verb = "已新建" if applied.get("created") else "已更新"
|
||
return f"{verb}预测 {fc.get('forecastNo')} ✅(默认不进正式排产,可说「预测纳入试排」)。"
|
||
if action == "data.import": # ---- 批准:批量导入 ----
|
||
from server.aps_domain.intake import apply_import
|
||
get_checkpoints().create(store.data, label="批量导入前基线", reason="auto:data.import",
|
||
conversation_note=f"批准导入 {params.get('kind')}")
|
||
applied = apply_import(store.data, store.next_id, params)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="data.import",
|
||
target={"type": "IMPORT", "id": params.get("kind")}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor, "count": applied["count"]})
|
||
store.save()
|
||
label = "订单" if applied["kind"] == "orders" else "物料"
|
||
sample = "、".join(applied["created"][:5])
|
||
more = f" 等 {applied['count']} 条" if applied["count"] > 5 else f"共 {applied['count']} 条"
|
||
return f"已导入{label} ✅ {sample}{more}。"
|
||
if action == "flex.site.load": # ---- 批准:现场生产路线 ----
|
||
from server.importers import import_site_excel
|
||
get_checkpoints().create(store.data, label="现场路线导入前基线", reason="auto:flex.site.load",
|
||
conversation_note="批准加载完整生产路线")
|
||
meta = import_site_excel(
|
||
store.data,
|
||
profile_id=params.get("profileId") or "kangni",
|
||
route_path=params.get("routePath") or None,
|
||
data_dir=params.get("dataDir") or None,
|
||
include_sibling_orders=params.get("includeSiblings", False) is True,
|
||
station_count=int(params.get("stationCount") or 0) or None,
|
||
)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="flex.site.load",
|
||
target={"type": "FLEX_SITE", "id": meta.get("primaryOrder")}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor, "meta": meta})
|
||
store.save()
|
||
return (f"现场生产路线已加载 ✅ 主单 {meta.get('primaryOrder')},"
|
||
f"订单 {meta.get('orderCount')} / 工序 {meta.get('operationCount')} / BOM {meta.get('bomCount')}。"
|
||
f"演示 flex* 已清空。可说「柔性排产」。")
|
||
if action == "import.commit": # ---- 批准:Excel/CSV 入库 ----
|
||
from server.aps_domain.importers import apply_import_commit
|
||
get_checkpoints().create(store.data, label="文件导入前基线", reason="auto:import.commit",
|
||
conversation_note=f"批准导入 {params.get('filename')}")
|
||
applied = apply_import_commit(store.data, store.next_id, params.get("batches") or [])
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="import.commit",
|
||
target={"type": "IMPORT", "id": params.get("filename")}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor, "summary": applied["summary"]})
|
||
store.save()
|
||
detail = "、".join(f"{k}×{v}" for k, v in applied["summary"].items()) or "无行"
|
||
return f"文件导入完成 ✅ 共 {applied['total']} 条({detail})。"
|
||
if action == "folder.schedule":
|
||
from server.aps_domain.importers import apply_import_commit
|
||
from server.aps_domain.folder_pack import analyze_work_dir
|
||
get_checkpoints().create(store.data, label="工程目录导入前基线",
|
||
reason="auto:folder.schedule",
|
||
conversation_note="批准导入工程目录并试排")
|
||
batches = params.get("batches") or []
|
||
sql_already = bool(params.get("sqlApplied"))
|
||
if not batches and not sql_already:
|
||
report = analyze_work_dir(
|
||
store.data, params.get("sessionId"), force_sql_replace=True)
|
||
batches = report.get("batches") or []
|
||
sql_already = bool(report.get("sqlApplied"))
|
||
applied = {"total": 0, "summary": {}}
|
||
if batches:
|
||
applied = apply_import_commit(store.data, store.next_id, batches)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="folder.schedule.import",
|
||
target={"type": "FOLDER_PACK", "id": params.get("workDir") or "workDir"},
|
||
power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor,
|
||
"summary": applied.get("summary"), "sqlApplied": sql_already})
|
||
store.save()
|
||
from server.aps_domain.readiness import check_readiness, readiness_text
|
||
ready = check_readiness(store.data)
|
||
detail = "、".join(f"{k}×{v}" for k, v in (applied.get("summary") or {}).items()) or (
|
||
"SQL 已入库" if sql_already else "无行"
|
||
)
|
||
head = f"工程目录已导入 ✅({detail})。"
|
||
# 有订单+工艺+设备即可开排(工时推断/缺 BOM 不拦死)
|
||
has_core = (
|
||
bool(store.data.get("flexOrders"))
|
||
and bool(store.data.get("flexRoutings"))
|
||
and any(e.get("status") == "RUNNING" for e in (store.data.get("flexEquipment") or []))
|
||
)
|
||
if ready["summary"].get("ready", 0) == 0 and not has_core:
|
||
return head + "\n\n导入后仍不具备开排条件:\n" + readiness_text(ready)
|
||
from server.aps_domain.flex import run_flex_schedule
|
||
result = run_flex_schedule(
|
||
store, sort_mode=params.get("sortMode") or "BOTTLENECK", actor=actor)
|
||
ver = (result or {}).get("versionNo") or ""
|
||
return (head + f"\n已按目录数据试排一版:**{ver}**"
|
||
f"(虚拟产线 {(result or {}).get('vlCount', 0)} · "
|
||
f"冲突 {(result or {}).get('conflictCount', 0)})。"
|
||
"可在右侧柔性工作台看甘特,或再说「查看冲突」。")
|
||
if action == "flex.reschedule":
|
||
from server.aps_domain.flex import reschedule_by_level
|
||
get_checkpoints().create(store.data, label=f"{params.get('level', 'L2')} 重排前基线",
|
||
reason="auto:flex.reschedule",
|
||
conversation_note=f"批准 {params.get('level')} 重排")
|
||
r = reschedule_by_level(store, level=params.get("level") or "L2",
|
||
sort_mode=params.get("sortMode"), actor=actor)
|
||
sch = r["schedule"]
|
||
# EX-03:若从冲突修复卡进入,标记原冲突已解决
|
||
cid = params.get("resolveConflictId")
|
||
if cid is not None:
|
||
for cf in store.data.get("flexConflicts", []):
|
||
if cf.get("id") == cid:
|
||
cf["isResolved"] = True
|
||
cf["resolutionAction"] = f"flex.reschedule:{r['level']}"
|
||
cf["resolvedBy"] = actor
|
||
break
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="flex.reschedule.approve", target={"type": "FLEX_VERSION",
|
||
"id": sch.get("versionId")},
|
||
power="P2", rationale={"confirmId": confirm_id, "level": r["level"],
|
||
"frozenWo": r.get("frozenWo"),
|
||
"mutableOrders": r.get("mutableOrders"),
|
||
"resolveConflictId": cid})
|
||
store.save()
|
||
return (f"{r['level']} 重排已执行 ✅ 版本 {sch.get('versionNo')}:"
|
||
f"冻结 {r.get('frozenWo', 0)} / 重排 {r.get('mutableOrders', 0)} 单,"
|
||
f"冲突 {sch.get('conflictCount', 0)}。")
|
||
if action == "flex.adjust.commit":
|
||
from server.aps_domain.adjust import commit_flex_adjust
|
||
get_checkpoints().create(store.data, label="甘特调程前基线",
|
||
reason="auto:flex.adjust.commit",
|
||
conversation_note=f"批准拖拽工单 #{params.get('woId')}")
|
||
r = commit_flex_adjust(store, int(params["woId"]), str(params["newStart"]), actor=actor)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="flex.adjust.approve",
|
||
target={"type": "FLEX_VERSION", "id": r.get("versionId")},
|
||
power="P2", rationale={"confirmId": confirm_id, "woId": params.get("woId")})
|
||
store.save()
|
||
return r.get("message") or "调程已执行 ✅"
|
||
if action == "schedule.adjust.commit":
|
||
from server.aps_domain.adjust import commit_fixed_adjust
|
||
get_checkpoints().create(store.data, label="固定甘特调程前基线",
|
||
reason="auto:schedule.adjust.commit",
|
||
conversation_note=f"批准拖拽工单 #{params.get('woId')}")
|
||
r = commit_fixed_adjust(store, int(params["woId"]), str(params["newStart"]), actor=actor)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="schedule.adjust.approve",
|
||
target={"type": "SCHEDULE_VERSION", "id": r.get("versionId")},
|
||
power="P2", rationale={"confirmId": confirm_id, "woId": params.get("woId")})
|
||
store.save()
|
||
return r.get("message") or "调程已执行 ✅"
|
||
if action == "sap.sync.inbound":
|
||
from server.aps_domain.sap_sync import apply_inbound
|
||
get_checkpoints().create(store.data, label="SAP 入站前基线",
|
||
reason="auto:sap.sync.inbound",
|
||
conversation_note="批准 SAP 入站同步")
|
||
r = apply_inbound(store, actor=actor)
|
||
return r.get("message") or "SAP 入站完成 ✅"
|
||
if action == "sap.sync.outbound":
|
||
from server.aps_domain.sap_sync import apply_outbound
|
||
get_checkpoints().create(store.data, label="SAP 出站前基线",
|
||
reason="auto:sap.sync.outbound",
|
||
conversation_note="批准 SAP 出站回写")
|
||
r = apply_outbound(store, actor=actor)
|
||
return r.get("message") or "SAP 出站完成 ✅"
|
||
if action == "mes.dispatch":
|
||
from server.aps_domain.mes import apply_dispatch
|
||
get_checkpoints().create(store.data, label="MES 下发前基线",
|
||
reason="auto:mes.dispatch",
|
||
conversation_note=f"批准 MES 下发 {params.get('track')}")
|
||
r = apply_dispatch(store, track=params.get("track") or "flex", actor=actor)
|
||
return r.get("message") or "MES 下发完成 ✅"
|
||
if action == "mrp.release": # ---- 批准:MRP 建议单下达 ----
|
||
from server.aps_domain.mrp import apply_mrp_release
|
||
get_checkpoints().create(store.data, label="MRP 下达前基线", reason="auto:mrp.release",
|
||
conversation_note="批准下达 MRP 建议单") # P2 写前自动建档
|
||
applied = apply_mrp_release(store.data, params.get("orderNo"), params.get("kind", "all"))
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="mrp.release",
|
||
target={"type": "MRP", "id": params.get("orderNo") or "ALL"}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor,
|
||
"purchase": applied["purchaseCount"], "outsource": applied["outsourceCount"]})
|
||
store.save()
|
||
return (f"已下达 ✅ 采购单 {applied['purchaseCount']} 条 / 委外单 {applied['outsourceCount']} 条转为正式(RELEASED),"
|
||
"可在订单面板「分解建议」查看状态。")
|
||
if action.startswith("master."): # ---- 批准:主数据写入(白名单见 masterdata.MASTER_ACTIONS) ----
|
||
from server.aps_domain.masterdata import apply_master_action
|
||
get_checkpoints().create(store.data, label="主数据变更前基线", reason=f"auto:{action}",
|
||
conversation_note=f"批准执行 {action}") # P2 写前自动建档
|
||
applied = apply_master_action(store.data, store.next_id, action, params)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action=action,
|
||
target={"type": f"MASTER_{applied['kind']}", "id": applied["id"], "name": applied["name"]},
|
||
power="P2", rationale={"confirmId": confirm_id, "approver": actor,
|
||
"before": applied.get("beforeStatus", applied.get("beforeStock")),
|
||
"after": applied.get("afterStatus", applied.get("afterStock"))})
|
||
store.save()
|
||
kind_cn = {
|
||
"LINE": "产线", "MATERIAL": "物料", "MAINTENANCE": "维保计划",
|
||
"BOM_ITEM": "BOM 明细", "BOM": "BOM", "ROUTING_STEP": "工艺步骤",
|
||
"ROUTING": "工艺路线", "OPERATION": "工序", "LINE_PRODUCT": "产线绑定",
|
||
"MASTER_CLEAR": "主数据清理",
|
||
}.get(applied["kind"], applied["kind"])
|
||
if applied["kind"] == "MASTER_CLEAR":
|
||
detail = ", ".join(applied.get("cleared") or applied.get("restored") or []) or "无数据"
|
||
return f"主数据已按【{applied['name']}】清空 ✅ {detail}(变更前已自动建档)。"
|
||
return f"{kind_cn}【{applied['name']}】主数据已更新 ✅ 只影响后续新排产版本(变更前已自动建档)。"
|
||
if action == "params.update": # ---- 批准:排产参数(OR-02) ----
|
||
from server.aps_domain.params import apply_params_update
|
||
get_checkpoints().create(store.data, label="排产参数变更前基线", reason="auto:params.update",
|
||
conversation_note="批准更新排产参数")
|
||
applied = apply_params_update(store.data, params)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="params.update",
|
||
target={"type": "SCHEDULE_PARAMS", "id": "scheduleParams"}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor,
|
||
"before": applied["before"].get("customerLevelWeights"),
|
||
"after": applied["after"].get("customerLevelWeights"),
|
||
"reset": applied.get("reset")})
|
||
store.save()
|
||
lw = applied["after"]["customerLevelWeights"]
|
||
return (f"排产参数已更新 ✅ 客户等级权重 VIP={lw['VIP']} / A={lw['A']} / B={lw['B']} / C={lw['C']}。"
|
||
"只影响后续新排产版本(变更前已自动建档)。")
|
||
if action == "constraint.profile.save": # ---- 批准:约束剖面(SC-04) ----
|
||
from server.aps_domain.constraints import apply_profile_save
|
||
get_checkpoints().create(store.data, label="约束配置变更前基线", reason="auto:constraint.profile.save",
|
||
conversation_note="批准更新约束配置")
|
||
applied = apply_profile_save(store.data, params)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="constraint.profile.save",
|
||
target={"type": "CONSTRAINT_PROFILE", "id": applied["id"]}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor, "reset": applied.get("reset")})
|
||
store.save()
|
||
enabled = sum(1 for c in applied["after"]["constraints"] if c["enabled"])
|
||
hard_n = sum(1 for c in applied["after"]["constraints"] if c["kind"] == "hard" and c["enabled"])
|
||
return (f"约束配置已更新 ✅ 启用 {enabled} 条 / 其中硬约束 {hard_n} 条。"
|
||
"硬约束违反将阻止发布(变更前已自动建档)。")
|
||
if action == "sop.apply": # ---- 批准:SOP 规则包(IND-02) ----
|
||
from server.aps_domain.sop_rules import apply_sop_pack, compile_sop_by_query
|
||
pack = params.get("pack")
|
||
if not isinstance(pack, dict):
|
||
compiled = compile_sop_by_query(get_knowledge().assets, params.get("query"))
|
||
if not compiled.get("ok"):
|
||
return compiled.get("error") or "SOP 编译失败"
|
||
pack = compiled["pack"]
|
||
get_checkpoints().create(store.data, label="SOP规则包应用前基线", reason="auto:sop.apply",
|
||
conversation_note="批准应用 SOP→约束")
|
||
applied = apply_sop_pack(store.data, pack)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="sop.apply",
|
||
target={"type": "RULE_PACK", "id": applied["packId"]}, power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor,
|
||
"applied": applied.get("applied"), "source": pack.get("source")})
|
||
store.save()
|
||
note = ";".join(applied.get("notes") or []) or "无额外流程提示"
|
||
return (
|
||
f"已应用 SOP 规则包 ✅ {pack.get('title')}({applied['packId']})。\n"
|
||
f"生效项:{', '.join(applied.get('applied') or [])}\n{note}"
|
||
)
|
||
if action == "knowledge.import":
|
||
from server.knowledge.embedding import index_units
|
||
from server.knowledge.ingest import apply_knowledge_import, preview_ingest
|
||
path = params.get("path")
|
||
if not path:
|
||
return "缺少文件路径,无法导入知识文档"
|
||
try:
|
||
preview = preview_ingest(
|
||
params.get("filename") or path, path=path,
|
||
kind=params.get("kind") or "sop", title=params.get("title"),
|
||
)
|
||
applied = apply_knowledge_import(preview)
|
||
# 尝试建向量索引(无嵌入后端则跳过)
|
||
from server.knowledge.assets import get_knowledge
|
||
index_units(get_knowledge().iter_search_units())
|
||
except (FileNotFoundError, ValueError) as exc:
|
||
return str(exc)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="knowledge.import",
|
||
target={"type": "KNOWLEDGE", "id": applied["assetId"]}, power="P2",
|
||
rationale={"confirmId": confirm_id, "chunkCount": applied.get("chunkCount"),
|
||
"title": applied.get("title")})
|
||
store.save()
|
||
return (f"知识文档已入库 ✅ 《{applied['title']}》{applied['version']},"
|
||
f"切块 {applied['chunkCount']} 段(资产 {applied['assetId']})。")
|
||
if action == "skill.register":
|
||
from server.agent_core.skills import get_skills
|
||
payload = {
|
||
"skill_id": params.get("skill_id") or params.get("skillId"),
|
||
"name": params.get("name") or params.get("skill_id") or params.get("skillId"),
|
||
"endpoint": params.get("endpoint"),
|
||
"auth": params.get("auth") or "",
|
||
"track": params.get("track") or "flex",
|
||
"enabled": params.get("enabled", True),
|
||
"description": params.get("description") or "",
|
||
}
|
||
saved = get_skills().upsert(payload)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="skill.register",
|
||
target={"type": "SKILL", "id": saved["skill_id"]}, power="P2",
|
||
rationale={"confirmId": confirm_id, "endpoint": saved.get("endpoint")})
|
||
store.save()
|
||
return f"已登记算法 Skill ✅ {saved['skill_id']}({saved['name']})→ {saved['endpoint']}"
|
||
if action == "skill.enable":
|
||
from server.agent_core.skills import get_skills
|
||
sid = params.get("skill_id") or params.get("skillId")
|
||
enabled = bool(params.get("enabled", True))
|
||
saved = get_skills().set_enabled(sid, enabled)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="skill.enable",
|
||
target={"type": "SKILL", "id": sid}, power="P2",
|
||
rationale={"confirmId": confirm_id, "enabled": enabled})
|
||
store.save()
|
||
return f"算法 Skill {sid} 已{'启用' if enabled else '停用'} ✅"
|
||
if action == "flex.time.update": # ---- 批准:工时维护 ----
|
||
op_code = str(params.get("operationCode") or "")
|
||
op_codes = set(params.get("operationCodes") or ([op_code] if op_code else []))
|
||
pc = str(params.get("productCode") or "")
|
||
std_min = float(params.get("stdMin") or 0)
|
||
source = params.get("source") or "实测"
|
||
hits = 0
|
||
for step in store.data.get("flexRoutings") or []:
|
||
if op_codes and step.get("operationCode") not in op_codes:
|
||
continue
|
||
if pc and step.get("productCode") != pc:
|
||
continue
|
||
step["stdTimePerUnit"] = std_min
|
||
step["stdTimeSource"] = source
|
||
hits += 1
|
||
# 同步工序库标准工时(主数据页显示口径)
|
||
for op in store.data.get("flexOperations") or []:
|
||
if op.get("code") in op_codes and not pc:
|
||
op["stdTimeMin"] = std_min
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="flex.time.update",
|
||
target={"type": "ROUTING_TIME", "id": f"{pc or '*'}/{op_code or '*'}"}, power="P2",
|
||
rationale={"confirmId": confirm_id, "stdMin": std_min, "hits": hits, "source": source})
|
||
store.save()
|
||
ops_label = "、".join(sorted(op_codes)) or "全部工序"
|
||
if not hits:
|
||
return f"未找到匹配的路线步骤(产品={pc or '任意'},工序={ops_label}),未做修改。"
|
||
return (f"工时已更新 ✅ {pc or '全部产品'} × {ops_label} → "
|
||
f"{std_min} 分钟/件(来源={source},{hits} 条路线步骤)。")
|
||
if action == "routing.template.apply": # ---- 批准:模板生成工艺路线 ----
|
||
from server.knowledge.routing_templates import apply_template_to_product
|
||
get_checkpoints().create(store.data, label="模板生成路线前基线",
|
||
reason="auto:routing.template.apply",
|
||
conversation_note=f"模板 {params.get('templateCode')} → {params.get('productCode')}")
|
||
try:
|
||
applied = apply_template_to_product(
|
||
store.data, str(params.get("templateCode") or ""),
|
||
str(params.get("productCode") or ""),
|
||
str(params.get("productName") or ""))
|
||
except ValueError as exc:
|
||
return str(exc)
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="routing.template.apply",
|
||
target={"type": "ROUTING", "id": applied["productCode"]}, power="P2",
|
||
rationale={"confirmId": confirm_id, "template": applied["template"],
|
||
"steps": applied["steps"], "assetId": applied.get("assetId")})
|
||
store.save()
|
||
return (f"工艺路线已生成 ✅ {applied['productCode']} ← 模板「{applied['templateName']}」"
|
||
f"({applied['steps']} 步,工时标「模板」,出处 {applied.get('assetId') or '内置'})。"
|
||
f"可说「数据齐备度」复查后「柔性排产」。")
|
||
return "未知动作,已忽略。" # 白名单外兜底(理论不可达)
|
||
|
||
|
||
# ---------------- 柔性排产(M5:能力池 + 虚拟产线 / 瓶颈产能 / 交期承诺) ----------------
|
||
_FLEX_MODE_CN = {"ASC": "正排(交期优先)", "DESC": "倒排(交期倒推)", "BOTTLENECK": "瓶颈锚"}
|
||
|
||
|
||
def _flex_product_name(store: WorldStore, code: str) -> str:
|
||
"""物料/产品编码 → 中文名(回复文案友好化)。"""
|
||
m = next((x for x in store.data.get("flexMaterials", []) if x["code"] == code), None)
|
||
return f"{m['name']}({code})" if m else code
|
||
|
||
|
||
def _resolve_order_reference(store: WorldStore, session_id: str, intent: IntentResult) -> IntentResult:
|
||
"""把 orderRef=last / 口令中的订单号 解析为 flex orderIds 或 orderNo。"""
|
||
from server.agent_core.session_focus import get_last_order_no, set_focus
|
||
|
||
params = dict(intent.params or {})
|
||
wanted_ref = params.get("orderRef") in ("last", "this", "focus")
|
||
order_no = str(params.get("orderNo") or "").strip()
|
||
if not order_no and wanted_ref:
|
||
order_no = get_last_order_no(session_id) or ""
|
||
if not order_no:
|
||
# 世界里只有一单时自动锁定(现场常见:刚导入的主单)
|
||
flex = store.data.get("flexOrders") or []
|
||
sales = store.data.get("salesOrders") or []
|
||
if len(flex) == 1:
|
||
order_no = str(flex[0].get("orderNo") or "")
|
||
elif len(sales) == 1:
|
||
order_no = str(sales[0].get("orderNo") or "")
|
||
if order_no:
|
||
params.pop("_missingFocus", None)
|
||
params["orderNo"] = order_no
|
||
set_focus(session_id, orderNo=order_no)
|
||
fo = next((o for o in (store.data.get("flexOrders") or [])
|
||
if str(o.get("orderNo")) == order_no), None)
|
||
if fo and intent.intent == "flex.schedule":
|
||
params["orderIds"] = [fo["id"]]
|
||
elif wanted_ref:
|
||
# 「这个订单」但会话未锁定、且池内多单 → 不瞎排全池
|
||
params["_missingFocus"] = True
|
||
return IntentResult(intent=intent.intent, params=params,
|
||
confidence=intent.confidence, source=intent.source)
|
||
|
||
|
||
def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> 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")
|
||
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)]
|
||
else:
|
||
order_ids = None
|
||
engine = str(intent.params.get("engine") or "").upper()
|
||
use_ext = bool(engine.startswith("EXTERNAL") or intent.params.get("skillId")
|
||
or intent.params.get("useExternal"))
|
||
if use_ext:
|
||
from server.engines.external_engine import run_external_flex
|
||
try:
|
||
result = run_external_flex(
|
||
store, skill_id=intent.params.get("skillId"),
|
||
order_ids=order_ids, actor=actor,
|
||
)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN",
|
||
action="flex.schedule.external",
|
||
target={"type": "FLEX", "id": result.get("versionNo")}, power="P1",
|
||
rationale={"skillId": result.get("skillId"), "runId": result.get("runId")})
|
||
store.save()
|
||
else:
|
||
result = run_flex_schedule(store, sort_mode=mode, order_ids=order_ids or None,
|
||
actor=actor, window=window) # P1
|
||
mode_cn = _FLEX_MODE_CN.get(result["sortMode"], result["sortMode"])
|
||
if result.get("sortMode") == "EXTERNAL" or use_ext:
|
||
mode_cn = f"外部算法({result.get('skillId') or 'skill'})"
|
||
win_cn = {"short": "短窗2h", "mid": "中窗2d", "long": "长窗7d", "full": "全量"}.get(
|
||
result.get("window") or "full", result.get("window") or "full")
|
||
vls = [v for v in store.data["flexVirtualLines"] if v["versionId"] == result["versionId"]]
|
||
orders_by_no = {o["orderNo"]: o for o in store.data["flexOrders"]}
|
||
|
||
def _on_time(v) -> bool: # 完工是否不晚于交期
|
||
o = orders_by_no.get(v["orderNo"])
|
||
return bool(o and v["plannedEnd"] and parse_dt(v["plannedEnd"]) <= parse_dt(o["dueDate"] + " 18:00"))
|
||
|
||
wos_by_vl: dict[int, list] = {} # 工单按虚拟产线归集(下钻依据)
|
||
for w in store.data["flexWorkOrders"]:
|
||
if w["versionId"] == result["versionId"]:
|
||
wos_by_vl.setdefault(w["vlId"], []).append(w)
|
||
|
||
def _steps(vl_id: int) -> list[dict]: # 单条虚拟产线的工序级明细
|
||
rows = sorted(wos_by_vl.get(vl_id, []), key=lambda w: w["seq"])
|
||
return [{
|
||
"seq": w["seq"], "operationName": w["operationName"],
|
||
"equipmentName": w["equipmentName"], "equipmentCode": w["equipmentCode"],
|
||
"zone": w.get("zone"), "moldCode": w.get("moldCode"),
|
||
"changeoverMin": w.get("changeoverMin", 0), "moveMin": w.get("moveMin", 0),
|
||
"runMin": w.get("runMin", 0),
|
||
"start": w["plannedStartTime"], "end": w["plannedEndTime"],
|
||
"isBottleneck": w.get("isBottleneck", False),
|
||
} for w in rows]
|
||
|
||
lines = [{
|
||
"vlNo": v["vlNo"], "orderNo": v["orderNo"], "product": _flex_product_name(store, v["productCode"]),
|
||
"quantity": v["quantity"], "start": v["plannedStart"], "end": v["plannedEnd"],
|
||
"due": (orders_by_no.get(v["orderNo"]) or {}).get("dueDate"),
|
||
"priority": (orders_by_no.get(v["orderNo"]) or {}).get("priority"),
|
||
"stepCount": len(wos_by_vl.get(v["id"], [])),
|
||
"onTime": _on_time(v),
|
||
"status": "scheduled",
|
||
"steps": _steps(v["id"]), # 工单级依据(点开可见)
|
||
} for v in vls]
|
||
# 未排出的订单也上表,避免「有 KPI、表却空」
|
||
scheduled_nos = {ln["orderNo"] for ln in lines}
|
||
for c in store.data.get("flexConflicts") or []:
|
||
if c.get("versionId") != result["versionId"]:
|
||
continue
|
||
ono = c.get("orderNo")
|
||
if not ono or ono in scheduled_nos:
|
||
continue
|
||
o = orders_by_no.get(ono) or {}
|
||
lines.append({
|
||
"vlNo": "—", "orderNo": ono,
|
||
"product": _flex_product_name(store, o.get("productCode") or ""),
|
||
"quantity": o.get("quantity"), "start": None, "end": None,
|
||
"due": o.get("dueDate"), "priority": o.get("priority"),
|
||
"stepCount": 0, "onTime": False, "status": "blocked",
|
||
"steps": [],
|
||
"blockReason": c.get("description") or c.get("conflictType"),
|
||
})
|
||
scheduled_nos.add(ono)
|
||
lines.sort(key=lambda x: (0 if x.get("status") == "scheduled" else 1, x.get("due") or "", x.get("orderNo") or ""))
|
||
|
||
conflicts = [{
|
||
"type": c["conflictType"], "orderNo": c.get("orderNo"),
|
||
"severity": c.get("severity"), "description": c.get("description"),
|
||
"suggestion": c.get("suggestedSolution"),
|
||
} for c in store.data["flexConflicts"] if c["versionId"] == result["versionId"]]
|
||
|
||
block = UIBlock(
|
||
blockId=f"flex-sched-{result['versionId']}", type="flex-schedule",
|
||
props={
|
||
"versionNo": result["versionNo"], "modeCn": mode_cn,
|
||
"stats": {"vlCount": result["vlCount"], "woCount": result["woCount"],
|
||
"makespan": result.get("makespan"), "onTimeCount": result.get("onTimeCount", 0),
|
||
"conflictCount": result["conflictCount"],
|
||
"utilization": round(result["avgUtilization"] * 100)},
|
||
"bottleneck": [{"name": b["operationName"], "count": b["poolEquipmentCount"]}
|
||
for b in result.get("bottleneck", [])],
|
||
"lines": lines,
|
||
"conflicts": conflicts,
|
||
"orderNo": intent.params.get("orderNo"),
|
||
"downloadUrl": None,
|
||
"filename": None,
|
||
"emptyHint": None if lines else "本版没有排出虚拟产线:多半是订单缺工艺/设备。请先「分析一下这个项目」。",
|
||
})
|
||
# 同步生成 Excel 工作计划表,排产结果块可直接下载
|
||
try:
|
||
plan = build_report(store.data, "plan", order_no=intent.params.get("orderNo"))
|
||
if plan.get("reportId") and plan.get("xlsxBytes"):
|
||
saved = persist_report_xlsx(plan["reportId"], plan["xlsxBytes"], plan.get("filename"))
|
||
block.props["downloadUrl"] = f"/api/reports/files/{plan['reportId']}"
|
||
block.props["filename"] = saved["filename"]
|
||
block.props["reportId"] = plan["reportId"]
|
||
except Exception:
|
||
pass
|
||
deferred = result.get("deferredCount") or 0
|
||
focus_no = intent.params.get("orderNo")
|
||
focus_hint = f"订单 {focus_no} · " if focus_no and order_ids else ""
|
||
dl_hint = "点结果块「下载 Excel 工作计划表」可导出完整工序计划。" if block.props.get("downloadUrl") else "可说「生成排产方案报告」下载。"
|
||
text = (f"柔性排产完成 ✅ {focus_hint}版本 {result['versionNo']}({mode_cn}·{win_cn}):"
|
||
f"{result['vlCount']} 条虚拟产线 / {result['woCount']} 个工单,冲突 {result['conflictCount']} 项"
|
||
+ (f",窗外延期 {deferred}" if deferred else "")
|
||
+ f"。{dl_hint}")
|
||
return AgentReply(text=text, blocks=[block],
|
||
commands=[ # 联动:切柔性甘特 + 重拉世界数据
|
||
ViewportCommand(cmd="viewport.mode", params={"mode": "flex"}, issuedBy="LLM"),
|
||
ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM")])
|
||
|
||
|
||
def _flex_reschedule(store: WorldStore, intent: IntentResult, session_id: str, actor: str) -> AgentReply:
|
||
"""分级重排(DY-01):L2/L3/L4 一律出 P2 确认卡。"""
|
||
from server.aps_domain.flex import preview_reschedule
|
||
from server.agent_core import harness
|
||
level = str(intent.params.get("level") or "L2").upper()
|
||
title, lines = preview_reschedule(store, level)
|
||
params = {"level": level, "sortMode": intent.params.get("sortMode")}
|
||
block = harness.stage_confirmation(session_id, "flex.reschedule", params,
|
||
title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="flex.reschedule.stage", target={"type": "FLEX", "id": level},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"], "level": level})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2,需要你确认后执行。", blocks=[block])
|
||
|
||
|
||
def _flex_swap(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply:
|
||
"""L1 局部换机:池内备机接手。"""
|
||
from server.aps_domain.flex import local_swap_equipment
|
||
code = intent.params.get("equipmentCode")
|
||
wo_id = intent.params.get("woId")
|
||
try:
|
||
r = local_swap_equipment(
|
||
store, equipment_code=code, wo_id=int(wo_id) if wo_id else None,
|
||
target_code=intent.params.get("targetCode"),
|
||
mark_fault=bool(intent.params.get("markFault", True)), actor=actor)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
lines = [f"{s['orderNo']} {s['operationCode']}:{s['from']} → {s['to']}" for s in r["swapped"][:5]]
|
||
more = f" 等 {len(r['swapped'])} 条" if len(r["swapped"]) > 5 else ""
|
||
fail_n = len(r.get("failed") or [])
|
||
text = (f"L1 局部换机完成 ✅ {len(r['swapped'])} 条工单改挂备机"
|
||
+ (f";{fail_n} 条无备机" if fail_n else "")
|
||
+ (f";设备 {r['faulted']} 已标故障" if r.get("faulted") else "")
|
||
+ (":" + ";".join(lines) + more if lines else "。"))
|
||
return AgentReply(text=text, commands=[
|
||
ViewportCommand(cmd="viewport.mode", params={"mode": "flex"}, issuedBy="LLM"),
|
||
ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM"),
|
||
])
|
||
|
||
|
||
def _flex_capacity(store: WorldStore) -> AgentReply:
|
||
"""瓶颈产能法:短文案 + flex-capacity 块(各池日产能条形 + 瓶颈标注)。"""
|
||
from server.aps_domain.flex import capacity_analysis
|
||
cap = capacity_analysis(store.data)
|
||
if not cap["pools"]:
|
||
return AgentReply(text="尚无可用的柔性产能池数据(无在运设备)。")
|
||
max_daily = max((p["dailyCapacity"] for p in cap["pools"]), default=1) or 1
|
||
pools = [{
|
||
"name": p["operationName"], "equipmentCount": p["equipmentCount"],
|
||
"dailyCapacity": p["dailyCapacity"], "workDays": p["workDays"],
|
||
"isBottleneck": p["isBottleneck"],
|
||
"pct": round(p["dailyCapacity"] / max_daily * 100), # 相对最大池的产能占比(条形宽度)
|
||
} for p in cap["pools"]]
|
||
bp = cap["bottleneckPool"]
|
||
block = UIBlock(blockId="flex-cap", type="flex-capacity",
|
||
props={"bottleneckName": bp["operationName"], "bottleneckDaily": bp["dailyCapacity"],
|
||
"pools": pools})
|
||
text = (f"瓶颈产能评估完成:限制性瓶颈是【{bp['operationName']}】,"
|
||
f"全厂节拍上界≈{bp['dailyCapacity']}件/天(按能力池实时算)。")
|
||
return AgentReply(text=text, blocks=[block],
|
||
commands=[ # EX-10:切产能池仪表盘 + 刷新世界
|
||
ViewportCommand(cmd="viewport.mode", params={"mode": "pool"}, issuedBy="LLM"),
|
||
ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM")])
|
||
|
||
|
||
def _flex_simulate_due(store: WorldStore, intent: IntentResult) -> AgentReply:
|
||
"""交期承诺模拟:短文案 + flex-due 块(乐观/预计/悲观 + 缺口)。"""
|
||
from server.aps_domain.flex import simulate_due
|
||
code = str(intent.params.get("productCode") or "")
|
||
qty = int(intent.params.get("quantity") or 0)
|
||
if not code or qty <= 0:
|
||
return AgentReply(text="请告诉我产品与数量,例如:「PDU 500套什么时候能交」或「高压线束 800件多久能做完」。")
|
||
r = simulate_due(store, code, qty, sort_mode=intent.params.get("sortMode"))
|
||
block = UIBlock(blockId=f"flex-due-{code}", type="flex-due",
|
||
props={"productName": _flex_product_name(store, code), "quantity": qty,
|
||
"optimistic": r.get("optimisticFinish"), "expected": r.get("expectedFinish"),
|
||
"pessimistic": r.get("pessimisticFinish"),
|
||
"gaps": r.get("gaps") or [],
|
||
"bottleneck": [{"name": b["operationName"], "count": b["poolEquipmentCount"]}
|
||
for b in r.get("bottleneck", [])]})
|
||
gaps_txt = ";".join(g["message"] for g in (r.get("gaps") or [])[:2])
|
||
text = (f"交期承诺模拟完成:{_flex_product_name(store, code)} × {qty}"
|
||
f"(乐观 {r.get('optimisticFinish') or '—'} / 预计 {r.get('expectedFinish') or '—'} / "
|
||
f"悲观 {r.get('pessimisticFinish') or '—'})。{gaps_txt}")
|
||
return AgentReply(text=text, blocks=[block])
|
||
|
||
|
||
def _flex_compare(store: WorldStore) -> AgentReply:
|
||
from server.aps_domain.analytics import matrix_from_flex_rows
|
||
from server.aps_domain.flex import compare_sort_modes
|
||
cmp = compare_sort_modes(store, compress_due=True)
|
||
block = UIBlock(blockId="flex-compare", type="flex-compare",
|
||
props={"rows": cmp["rows"], "hint": cmp["hint"], "compressedDue": cmp["compressedDue"],
|
||
"table": matrix_from_flex_rows(cmp["rows"], hint=cmp.get("hint"))})
|
||
best = min(cmp["rows"], key=lambda r: (r["totalTardiness"], -r["onTimeCount"]))
|
||
text = (f"三模式对比完成(已压缩交期制造压力)。当前压力下较优:"
|
||
f"【{best['label']}】延迟 {best['totalTardiness']}h、准时 {best['onTimeCount']}/{best['orderCount']}。"
|
||
"已打开对比表视口。")
|
||
return AgentReply(
|
||
text=text, blocks=[block],
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "compare"}, issuedBy="LLM")])
|
||
|
||
|
||
def _flex_rush(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply:
|
||
from server.aps_domain.flex import insert_rush_order
|
||
code = str(intent.params.get("productCode") or "HV-HARNESS")
|
||
qty = int(intent.params.get("quantity") or 50)
|
||
try:
|
||
r = insert_rush_order(store, code, qty, due_date=intent.params.get("dueDate"),
|
||
priority=int(intent.params.get("priority") or 1), actor=actor)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
o = r["order"]
|
||
s = r["schedule"]
|
||
text = (f"急单 {o['orderNo']} 已插入({_flex_product_name(store, code)} × {qty},交期 {o['dueDate']}),"
|
||
f"瓶颈锚重排完成:版本 {s['versionNo']},虚拟产线 {s['vlCount']},"
|
||
f"急单完工 {r.get('plannedEnd') or '—'},冲突 {s['conflictCount']}。")
|
||
return AgentReply(text=text, commands=[
|
||
ViewportCommand(cmd="viewport.mode", params={"mode": "flex"}, issuedBy="LLM"),
|
||
ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM"),
|
||
])
|
||
|
||
|
||
def _flex_fault(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply:
|
||
from server.aps_domain.flex import apply_equipment_fault
|
||
code = str(intent.params.get("equipmentCode") or "PRESS-01")
|
||
status = str(intent.params.get("status") or "MAINTENANCE").upper()
|
||
try:
|
||
r = apply_equipment_fault(store, code, status=status, reschedule=True, actor=actor)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
eq = r["equipment"]
|
||
bn_before = r.get("capacityBefore") or {}
|
||
bn_after = r.get("capacityAfter") or {}
|
||
no_cap = [c for c in r.get("conflicts") or [] if c.get("conflictType") == "NO_CAPABILITY"]
|
||
level = r.get("responseLevel")
|
||
text = (f"设备 {eq['code']}({eq['name']}){eq['beforeStatus']} → {eq['afterStatus']}。"
|
||
f"瓶颈日产能 {bn_before.get('dailyCapacity', '—')} → {bn_after.get('dailyCapacity', '—')} 件/天。")
|
||
if level == "L1":
|
||
n = len((r.get("swap") or {}).get("swapped") or [])
|
||
text += f" 已按 L1 局部换机处理({n} 条工单改挂备机,他单不动)。"
|
||
elif no_cap:
|
||
text += f" 出现 {len(no_cap)} 条无能力设备冲突(单点瓶颈停摆风险)。"
|
||
elif eq["afterStatus"] == "MAINTENANCE" and "PRESS" in eq["code"]:
|
||
text += " 压接池仍有冗余;L1 不可用时已缩池重排。"
|
||
s = r.get("schedule") or {}
|
||
if s:
|
||
text += f" 已重排版本 {s.get('versionNo')},冲突 {s.get('conflictCount', 0)}。"
|
||
return AgentReply(text=text, commands=[
|
||
ViewportCommand(cmd="viewport.mode", params={"mode": "pool" if level != "L1" else "flex"},
|
||
issuedBy="LLM"),
|
||
ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM"),
|
||
])
|
||
|
||
|
||
def _conflict_list(store: WorldStore, intent: IntentResult) -> AgentReply:
|
||
from server.aps_domain.conflicts import list_conflict_center
|
||
from server.contracts import UIBlock
|
||
scope = str(intent.params.get("scope") or "flex")
|
||
data = list_conflict_center(store.data, scope=scope)
|
||
if not data["rows"]:
|
||
return AgentReply(text="当前最新版本无未解决冲突 ✅")
|
||
lines = []
|
||
for r in data["rows"][:8]:
|
||
top = (r["fixes"][0]["label"] if r.get("fixes") else "—")
|
||
lines.append(f"#{r['id']} [{r['severity']}] {r['conflictType']}:{r['description']} → {top}")
|
||
more = f"\n…共 {data['total']} 条" if data["total"] > 8 else ""
|
||
block = UIBlock(
|
||
blockId="conflict-center", type="conflict-center",
|
||
props={"total": data["total"], "critical": data["critical"], "major": data["major"],
|
||
"rows": data["rows"][:20]})
|
||
return AgentReply(
|
||
text=(f"冲突中心:共 {data['total']} 条(危急 {data['critical']} / 重大 {data['major']})。\n"
|
||
+ "\n".join(lines) + more + "\n可在柔性工作台「冲突」页一键修复。"),
|
||
blocks=[block],
|
||
commands=[ViewportCommand(cmd="viewport.highlight", params={"what": "conflict"}, issuedBy="LLM")],
|
||
)
|
||
|
||
|
||
def _conflict_resolve(store: WorldStore, intent: IntentResult, session_id: str, actor: str) -> AgentReply:
|
||
from server.aps_domain.conflicts import apply_conflict_fix, list_conflict_center
|
||
from server.contracts import UIBlock
|
||
cid = intent.params.get("conflictId")
|
||
action = intent.params.get("action")
|
||
if not cid:
|
||
data = list_conflict_center(store.data, scope="flex")
|
||
if not data["rows"]:
|
||
return AgentReply(text="没有可修复的冲突。")
|
||
row = data["rows"][0]
|
||
fixes = ";".join(f["label"] for f in row["fixes"][:3])
|
||
return AgentReply(text=f"建议先处理 #{row['id']}:{row['description']}\n可选:{fixes}")
|
||
try:
|
||
r = apply_conflict_fix(store, int(cid), str(action or "note"),
|
||
params=intent.params.get("fixParams") or {},
|
||
actor=actor, session_id=session_id)
|
||
except (ValueError, PermissionError) as exc:
|
||
return AgentReply(text=str(exc))
|
||
blocks = []
|
||
if r.get("block"):
|
||
blocks.append(UIBlock.model_validate(r["block"]))
|
||
cmds = [ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM")]
|
||
if r.get("applied"):
|
||
cmds.insert(0, ViewportCommand(cmd="viewport.mode", params={"mode": "flex"}, issuedBy="LLM"))
|
||
return AgentReply(text=r.get("message") or "已处理", blocks=blocks, commands=cmds)
|
||
|
||
|
||
async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult, actor: str = "planner") -> AgentReply:
|
||
"""意图 → 回复 的总编排(gateway 调用;P0/P1 直通,P2 出卡)。"""
|
||
name = intent.intent # 意图名
|
||
# 解析「这个订单」等指代 → 具体订单号 / flex orderIds
|
||
if name in ("flex.schedule", "schedule.run", "plan.trace", "master.query", "report.generate"):
|
||
intent = _resolve_order_reference(store, session_id, intent)
|
||
if intent.params.get("_missingFocus") and name != "master.query":
|
||
return AgentReply(
|
||
text="刚才没有锁定具体订单。请先说「查询订单 102285668」,"
|
||
"或直接说「给订单 102285668 排产」。")
|
||
if intent.params.get("_missingFocus") and name == "master.query" and intent.params.get("entity") == "mrp":
|
||
return AgentReply(
|
||
text="刚才没有锁定具体订单。请先说「查询订单 102285668」,"
|
||
"或直接问「订单 102285668 有委外吗」。")
|
||
# ---- 排产(P1) ----
|
||
if name == "schedule.run":
|
||
return _run_schedule(store, intent, actor) # 试排并回执
|
||
# ---- 发布(P2 → 确认卡) ----
|
||
if name == "schedule.publish":
|
||
return _stage_publish(store, session_id, actor)
|
||
# ---- 重置数据(P2 → 确认卡) ----
|
||
if name == "data.reset":
|
||
return _stage_reset(store, session_id, actor)
|
||
# ---- 新建/编辑订单(P2 → 确认卡;对话侧建单) ----
|
||
if name == "order.upsert":
|
||
return _stage_order_upsert(store, session_id, intent, actor)
|
||
# ---- 订单状态动作(P2 → 确认卡;对话与页面共用 action) ----
|
||
if name in ("order.cancel", "order.complete", "order.delete"):
|
||
from server.aps_domain.orders import confirmation_for_order_action, find_order
|
||
order_no = str(intent.params.get("orderNo") or "")
|
||
order = find_order(store.data, order_no=order_no)
|
||
if order is None:
|
||
return AgentReply(text=f"没找到订单 {order_no},可以在订单面板核对订单号。")
|
||
title, lines = confirmation_for_order_action(store.data, name, {"id": order["id"]})
|
||
block = harness.stage_confirmation(session_id, name, {"id": order["id"]}, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action=name + ".stage",
|
||
target={"type": "SALES_ORDER", "id": order["id"], "orderNo": order["orderNo"]},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 订单写入,需要你确认。", blocks=[block])
|
||
if name == "order.clear":
|
||
from server.aps_domain.orders import confirmation_for_order_action
|
||
try:
|
||
title, lines = confirmation_for_order_action(store.data, "order.clear", {})
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
block = harness.stage_confirmation(session_id, "order.clear", {}, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="order.clear.stage",
|
||
target={"type": "SALES_ORDER", "id": "ALL"}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 破坏性写入,需要你确认。", blocks=[block])
|
||
# ---- OR-03 订单池:提交(P1)/ 批准驳回(P2)/ 摘要(P0) ----
|
||
if name == "order.pool":
|
||
from server.aps_domain.orders import list_orders, pool_summary
|
||
sm = pool_summary(store.data)
|
||
pending = [o for o in list_orders(store.data) if o["status"] == "SUBMITTED"]
|
||
lines = [
|
||
f"订单池:全部 {sm['all']} · 可排产 {sm['schedulable']} · 待审 {sm['pending']} · "
|
||
f"已批 {sm['approved']} · 变更 {sm['changed']} · 草稿/驳回 {sm['draft']}",
|
||
]
|
||
if pending:
|
||
lines.append("待审:" + "、".join(f"{o['orderNo']}({o['customerName']})" for o in pending[:8]))
|
||
lines.append("可说「批量批准」或「批准订单 SOxxx」。")
|
||
else:
|
||
lines.append("当前无待审订单。新建订单默认为 DRAFT,需「提交审核」后再批准。")
|
||
return AgentReply(text="\n".join(lines))
|
||
# ---- OR-04 紧急插单:快评(P1 沙盒)/ 采用(P2) ----
|
||
if name == "rush.evaluate":
|
||
from server.aps_domain.rush import evaluate_rush, format_impact_text, impact_to_block
|
||
p = dict(intent.params or {})
|
||
try:
|
||
impact = harness.guard("rush.evaluate", p, lambda: evaluate_rush(store.data, p))
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
_LAST_RUSH_EVAL.clear()
|
||
_LAST_RUSH_EVAL.update(impact)
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="rush.evaluate",
|
||
target={"type": "SANDBOX", "id": impact["evalId"]}, power="P1",
|
||
rationale={"affected": impact["affectedOrderCount"],
|
||
"delayDelta": impact["delayDelta"],
|
||
"conflictDelta": impact["conflictDelta"]})
|
||
store.save()
|
||
return AgentReply(
|
||
text=format_impact_text(impact),
|
||
blocks=[impact_to_block(impact)],
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "due"}, issuedBy="LLM")])
|
||
if name == "rush.apply":
|
||
p = dict(intent.params or {})
|
||
if not p.get("payload") and not p.get("orderNo") and not p.get("productCode") and not p.get("productId"):
|
||
if not _LAST_RUSH_EVAL:
|
||
return AgentReply(text="还没有插单快评结果。请先说「插单快评」或「紧急插单 200件控制器A」。")
|
||
p = {
|
||
"payload": _LAST_RUSH_EVAL.get("payload"),
|
||
"strategy": _LAST_RUSH_EVAL.get("strategy"),
|
||
"evalId": _LAST_RUSH_EVAL.get("evalId"),
|
||
}
|
||
rush = p.get("payload") or p
|
||
label = rush.get("orderNo") or f"{rush.get('customerName', '急单')}×{rush.get('quantity', '?')}"
|
||
block = harness.stage_confirmation(
|
||
session_id, "rush.apply", p,
|
||
title=f"采用紧急插单 · {label}",
|
||
summary_lines=[
|
||
f"策略:{p.get('strategy') or 'DELIVERY_FIRST'}",
|
||
"将写入主干订单池(APPROVED + isRush),并生成 DRAFT 排产版本",
|
||
"基准已发布版本不回写;执行前自动建档,可回滚",
|
||
f"快评 ID:{p.get('evalId') or '(直接采用)'}",
|
||
],
|
||
)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="rush.apply.stage",
|
||
target={"type": "SALES_ORDER", "id": rush.get("orderNo") or "NEW"}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text="采用插单属于 P2 写操作,需要你确认(见下方确认卡)。", blocks=[block])
|
||
# ---- OR-05 预测 / 长周期订单 ----
|
||
if name == "forecast.query":
|
||
from server.aps_domain.forecast import forecast_summary, list_forecasts
|
||
sm = forecast_summary(store.data)
|
||
rows = list_forecasts(store.data)
|
||
lines = [
|
||
f"预测订单:全部 {sm['all']} · 生效 {sm['active']} · 草稿 {sm['draft']} · "
|
||
f"已转正 {sm['consumed']} · 取消 {sm['cancelled']}",
|
||
]
|
||
for f in rows[:8]:
|
||
lines.append(
|
||
f"· {f['forecastNo']} {f.get('productName')}×{f.get('quantity')} "
|
||
f"{f.get('periodStart')}~{f.get('periodEnd')} · {f.get('status')} · "
|
||
f"置信{int((f.get('confidence') or 0)*100)}%"
|
||
)
|
||
if sm["active"]:
|
||
lines.append("可说「预测纳入试排」或「预测转正 FCxxx」。")
|
||
return AgentReply(text="\n".join(lines))
|
||
if name in ("forecast.upsert", "forecast.delete", "forecast.convert"):
|
||
from server.aps_domain.forecast import confirmation_for_forecast_action
|
||
p = dict(intent.params or {})
|
||
try:
|
||
title, lines = confirmation_for_forecast_action(store.data, name, p)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
block = harness.stage_confirmation(session_id, name, p, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action=f"{name}.stage",
|
||
target={"type": "FORECAST_ORDER", "id": p.get("id") or p.get("forecastNo") or "NEW"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||
# ---- PL-01/PL-02 时间分桶 + 粗能力 ----
|
||
if name == "plan.buckets":
|
||
from server.aps_domain.planning import build_plan_buckets
|
||
p = dict(intent.params or {})
|
||
try:
|
||
report = build_plan_buckets(
|
||
store.data,
|
||
mode=str(p.get("mode") or "HYBRID"),
|
||
start_date=p.get("startDate"),
|
||
horizon_days=int(p.get("horizonDays") or 90),
|
||
include_forecast=bool(p.get("includeForecast", True)),
|
||
capacity_mode=str(p.get("capacityMode") or "FINITE"),
|
||
)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
sm = report["summary"]
|
||
cap_label = "无限" if report.get("capacityMode") == "INFINITE" else "有限"
|
||
lines = [
|
||
f"时间分桶({report['mode']} · {cap_label}产能):桶 {sm['bucketCount']} · "
|
||
f"超载 {sm['overCount']} · 预警 {sm['warnCount']} · 正常 {sm['okCount']}",
|
||
f"日产能 {report['dailyCapacity']} 件 · 展望 {report['horizonDays']} 天 · "
|
||
+ (
|
||
f"峰值所需日产能 {sm.get('peakRequiredDaily')} · 有限参照超载桶 {sm.get('finiteOverCount')}"
|
||
if report.get("capacityMode") == "INFINITE"
|
||
else f"总负荷 {round(sm['overallLoad']*100)}%"
|
||
)
|
||
+ ("(含预测)" if report["includeForecast"] else "(仅确定订单)"),
|
||
]
|
||
for b in report["buckets"]:
|
||
show = b["status"] in ("OVER", "WARN") or b["demandQty"] > 0 or b.get("finiteStatus") == "OVER"
|
||
if not show:
|
||
continue
|
||
if report.get("capacityMode") == "INFINITE":
|
||
lines.append(
|
||
f"· {b['label']}({b['kind']}) 需求{b['demandQty']} 需日产{b.get('requiredDaily')} "
|
||
f"有限参照{round((b.get('finiteLoadRatio') or 0)*100)}% [{b.get('finiteStatus')}]"
|
||
)
|
||
else:
|
||
lines.append(
|
||
f"· {b['label']}({b['kind']}) 需求{b['demandQty']}/能力{b['capacity']} "
|
||
f"负荷{round(b['loadRatio']*100)}% [{b['status']}]"
|
||
)
|
||
if len(lines) > 14:
|
||
lines.append("…(完整表见右侧「分桶计划」视口)")
|
||
break
|
||
return AgentReply(
|
||
text="\n".join(lines),
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "plan"}, issuedBy="LLM")],
|
||
)
|
||
if name == "plan.rccp":
|
||
from server.aps_domain.planning import build_rccp_compare
|
||
p = dict(intent.params or {})
|
||
try:
|
||
cmp_ = build_rccp_compare(
|
||
store.data,
|
||
mode=str(p.get("mode") or "HYBRID"),
|
||
start_date=p.get("startDate"),
|
||
horizon_days=int(p.get("horizonDays") or 90),
|
||
include_forecast=bool(p.get("includeForecast", True)),
|
||
)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
d = cmp_["delta"]
|
||
lines = [
|
||
f"RCCP 粗能力对照({cmp_['mode']}):{d['message']}",
|
||
f"日产能缺口(峰值所需−当前)≈ {d['capacityGapDaily']} 件/天",
|
||
]
|
||
for s in (cmp_.get("shortfallBuckets") or [])[:6]:
|
||
lines.append(
|
||
f"· {s['label']} 有限缺口 {s['finiteGap']} · 所需日产 {s['requiredDaily']} "
|
||
f"· 负荷 {round((s.get('finiteLoadRatio') or 0)*100)}%"
|
||
)
|
||
lines.append("可说「无限产能粗评」或在分桶视口切换有限/无限。")
|
||
return AgentReply(
|
||
text="\n".join(lines),
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "plan"}, issuedBy="LLM")],
|
||
)
|
||
if name == "plan.feasibility":
|
||
from server.aps_domain.planning import build_feasibility
|
||
p = dict(intent.params or {})
|
||
try:
|
||
report = build_feasibility(
|
||
store.data,
|
||
start_date=p.get("startDate"),
|
||
horizon_days=int(p.get("horizonDays") or 90),
|
||
include_forecast=bool(p.get("includeForecast", True)),
|
||
)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
sm = report["summary"]
|
||
verdict = report["verdict"]
|
||
lines = [
|
||
f"可行性结论:【{verdict}】 · 订单 {sm['all']} "
|
||
f"(可行 {sm['feasible']} / 风险 {sm['atRisk']} / 不可行 {sm['infeasible']} / 过期 {sm['late']})",
|
||
f"日产能 {report['dailyCapacity']} · 超载桶 {sm['shortfallBucketCount']} · "
|
||
f"分桶总负荷 {round((sm.get('overallLoad') or 0)*100)}%"
|
||
+ ("(含预测)" if report["includeForecast"] else "(仅确定订单)"),
|
||
]
|
||
for g in (report.get("gaps") or [])[:5]:
|
||
lines.append(f"· 缺口桶 {g['label']} 负荷{round((g.get('loadRatio') or 0)*100)}% 缺口{g.get('gap')}")
|
||
risky = [o for o in report["orders"] if o["status"] in ("INFEASIBLE", "AT_RISK", "LATE")]
|
||
for o in risky[:6]:
|
||
reason = (";".join(o.get("reasons") or []) or o["status"])
|
||
lines.append(f"· {o['ref']} 交期{o['dueDate']} slack={o['slack']} [{o['status']}] {reason}")
|
||
if not risky:
|
||
lines.append("当前累计粗能力下,已批准需求均可按期覆盖。")
|
||
lines.append("完整表见右侧「分桶计划」→ 可行性。")
|
||
return AgentReply(
|
||
text="\n".join(lines),
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "plan"}, issuedBy="LLM")],
|
||
)
|
||
if name == "plan.inventory":
|
||
from server.aps_domain.inventory import build_inventory_projection
|
||
p = dict(intent.params or {})
|
||
try:
|
||
report = build_inventory_projection(
|
||
store.data,
|
||
material_code=p.get("materialCode"),
|
||
material_type=p.get("materialType"),
|
||
horizon_days=int(p.get("horizonDays") or 30),
|
||
bucket=str(p.get("bucket") or "DAY"),
|
||
include_forecast=bool(p.get("includeForecast", True)),
|
||
start_date=p.get("startDate"),
|
||
)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
sm = report["summary"]
|
||
lines = [
|
||
f"库存投影({report['bucket']} · {report['horizonDays']}天):"
|
||
f"物料 {sm['materialCount']} · 断料 {sm['stockoutCount']} · "
|
||
f"低于安全库存 {sm['belowSafetyCount']} · 正常 {sm['okCount']}",
|
||
]
|
||
risky = [m for m in report["materials"] if m["alert"] != "OK"]
|
||
for m in (risky or report["materials"])[:8]:
|
||
lines.append(
|
||
f"· {m['code']} {m['name']} 期初{m['openingStock']}→末{m['endingProjected']} "
|
||
f"[{m['alert']}]"
|
||
+ (f" 首警{m['firstAlertDate']}" if m.get("firstAlertDate") else "")
|
||
)
|
||
lines.append("完整曲线见右侧「分桶计划」→ 库存投影。")
|
||
return AgentReply(
|
||
text="\n".join(lines),
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "plan"}, issuedBy="LLM")],
|
||
)
|
||
if name == "plan.leveling":
|
||
from server.aps_domain.planning import build_leveling
|
||
p = dict(intent.params or {})
|
||
try:
|
||
report = build_leveling(
|
||
store.data,
|
||
mode=str(p.get("mode") or "WEEK"),
|
||
start_date=p.get("startDate"),
|
||
horizon_days=int(p.get("horizonDays") or 90),
|
||
include_forecast=bool(p.get("includeForecast", True)),
|
||
target_load=float(p.get("targetLoad") or 0.85),
|
||
)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
b, a, imp = report["before"], report["after"], report["improvement"]
|
||
lines = [
|
||
f"产能削峰({report['mode']} · 目标≤{int(report['targetLoad']*100)}%):"
|
||
f"峰值负荷 {round(b['peakLoad']*100)}%→{round(a['peakLoad']*100)}% · "
|
||
f"超载桶 {b['overCount']}→{a['overCount']} · 建议挪动 {imp['moveCount']} 笔/{imp['movedQty']} 件",
|
||
]
|
||
for m in report["moves"][:8]:
|
||
arrow = "↑提前" if m["direction"] == "PULL_AHEAD" else "↓延后"
|
||
lines.append(
|
||
f"· {arrow} {m['quantity']}×{m.get('productCode') or '?'} "
|
||
f"{m['fromLabel']}→{m['toLabel']}"
|
||
)
|
||
if not report["moves"]:
|
||
lines.append("当前分桶无明显超载,无需削峰。")
|
||
lines.append("完整对照见右侧「分桶计划」→ 削峰。本切片不改交期。")
|
||
return AgentReply(
|
||
text="\n".join(lines),
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "plan"}, issuedBy="LLM")],
|
||
)
|
||
if name == "plan.supply":
|
||
from server.aps_domain.planning import build_supply_decisions
|
||
p = dict(intent.params or {})
|
||
try:
|
||
report = build_supply_decisions(
|
||
store.data,
|
||
mode=str(p.get("mode") or "WEEK"),
|
||
start_date=p.get("startDate"),
|
||
horizon_days=int(p.get("horizonDays") or 90),
|
||
include_forecast=bool(p.get("includeForecast", True)),
|
||
target_load=float(p.get("targetLoad") or 0.85),
|
||
)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
lines = [
|
||
f"产供决策【{report['verdict']}】:{report['message']}",
|
||
f"削峰挪动 {report['leveling']['movedQty']} 件 · 残留缺口 {report['summary']['residualGap']} · "
|
||
f"选项 {report['summary']['optionCount']} 条",
|
||
]
|
||
for o in report["options"][:8]:
|
||
lines.append(
|
||
f"· [{o['label']}] {o['bucketLabel']} 覆盖{o['coverQty']} "
|
||
f"成本指数{o['costIndex']} · {o['detail']}"
|
||
)
|
||
if report["verdict"] == "BALANCED":
|
||
lines.append("可继续试排或查看库存投影。")
|
||
else:
|
||
lines.append("完整方案见右侧「分桶计划」→ 产供。本切片不写主干。")
|
||
return AgentReply(
|
||
text="\n".join(lines),
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "plan"}, issuedBy="LLM")],
|
||
)
|
||
if name == "order.submit":
|
||
from server.aps_domain.orders import apply_order_submit, find_order
|
||
p = dict(intent.params or {})
|
||
if not p.get("id") and not p.get("orderNo"):
|
||
# 无单号:取最近一条可提交订单
|
||
cand = next((o for o in reversed(store.data.get("salesOrders", []))
|
||
if o.get("status") in ("DRAFT", "REJECTED", "CHANGED")), None)
|
||
if not cand:
|
||
return AgentReply(text="没有可提交审核的订单(需 DRAFT/REJECTED/CHANGED)。")
|
||
p["id"] = cand["id"]
|
||
try:
|
||
applied = harness.guard("order.submit", p, lambda: apply_order_submit(store.data, p))
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
order = applied["order"]
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="order.submit",
|
||
target={"type": "SALES_ORDER", "id": order["id"], "orderNo": order["orderNo"]},
|
||
power="P1", rationale={"from": applied.get("beforeStatus"), "to": "SUBMITTED"})
|
||
store.save()
|
||
return AgentReply(
|
||
text=(f"订单 {order['orderNo']} 已提交审核 ✅({applied.get('beforeStatus')} → SUBMITTED)。"
|
||
f"可说「批准订单 {order['orderNo']}」或在订单面板批量批准。"),
|
||
commands=[ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM")])
|
||
if name in ("order.approve", "order.reject"):
|
||
from server.aps_domain.orders import confirmation_for_order_action, _resolve_order_ids
|
||
payload = dict(intent.params or {})
|
||
if payload.get("orderIds") == "pending":
|
||
payload["orderIds"] = "pending"
|
||
elif payload.get("orderNo") and not payload.get("id"):
|
||
pass
|
||
try:
|
||
# 预检可解析到订单
|
||
ids = _resolve_order_ids(store.data, payload)
|
||
if not ids and payload.get("orderNo"):
|
||
return AgentReply(text=f"订单 {payload['orderNo']} 不在待审状态(需 SUBMITTED/CHANGED)。")
|
||
if not ids:
|
||
return AgentReply(text="没有可审核的订单。可先「提交审核」或查看「订单池」。")
|
||
payload["orderIds"] = ids
|
||
title, lines = confirmation_for_order_action(store.data, name, payload)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
block = harness.stage_confirmation(session_id, name, payload, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action=name + ".stage",
|
||
target={"type": "SALES_ORDER", "id": ids[0] if len(ids) == 1 else "BATCH"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"], "count": len(ids)})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||
if name == "master.clear":
|
||
from server.aps_domain.masterdata import confirmation_for_master_action
|
||
scope = str(intent.params.get("scope") or "all")
|
||
try:
|
||
title, lines = confirmation_for_master_action(store.data, "master.clear", {"scope": scope})
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
block = harness.stage_confirmation(session_id, "master.clear", {"scope": scope}, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="master.clear.stage",
|
||
target={"type": "MASTER_CLEAR", "id": scope}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 主数据写入,需要你确认。", blocks=[block])
|
||
# ---- 对话新建物料(P2) ----
|
||
if name == "master.material.upsert":
|
||
from server.aps_domain.masterdata import confirmation_for_master_action
|
||
payload = dict(intent.params or {})
|
||
missing = [label for key, label in (("code", "编码"), ("name", "名称")) if not payload.get(key)]
|
||
if missing:
|
||
return AgentReply(text=f"要新建物料还差:{'、'.join(missing)}。\n"
|
||
"例如:「新建物料 编码 SCR-NEW 名称 不锈钢螺丝 类型 原料 库存 800」。")
|
||
payload.setdefault("type", "RAW_MATERIAL")
|
||
payload.setdefault("unit", "件")
|
||
try:
|
||
title, lines = confirmation_for_master_action(store.data, "master.material.upsert", payload)
|
||
except ValueError as exc:
|
||
return AgentReply(text=f"物料信息有误:{exc}")
|
||
block = harness.stage_confirmation(session_id, "master.material.upsert", payload, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="master.material.upsert.stage",
|
||
target={"type": "MATERIAL", "id": payload.get("code")}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 主数据写入,需要你确认。", blocks=[block])
|
||
if name == "changeover.query":
|
||
from server.aps_domain.changeover import build_changeover_view
|
||
view = build_changeover_view(store.data)
|
||
lines = [
|
||
f"换型矩阵:{len(view['rows'])} 条显式规则 · 产品族 {len(view['families'])} 个",
|
||
f"缺省跨族 {view['defaultCrossFamilyMin']} 分;同族 0 分",
|
||
]
|
||
for r in view["rows"][:12]:
|
||
lines.append(f"· {r['fromFamily']} → {r['toFamily']} = {r['setupMinutes']} 分"
|
||
+ (f"({r['note']})" if r.get("note") else ""))
|
||
if not view["rows"]:
|
||
lines.append("尚无显式规则,跨族走缺省。可说「设置换型 CTRL-STD 到 CTRL-HF 45分」。")
|
||
lines.append("完整编辑见主数据 → 工艺模型 → 换型。")
|
||
return AgentReply(text="\n".join(lines))
|
||
if name == "campaign.preview":
|
||
from server.aps_domain.campaign import preview_campaigns
|
||
p = dict(intent.params or {})
|
||
report = preview_campaigns(
|
||
store.data,
|
||
window_days=int(p.get("windowDays") or 7),
|
||
include_forecast=bool(p.get("includeForecast", False)),
|
||
)
|
||
s = report["summary"]
|
||
lines = [
|
||
f"战役预览:输入 {s['inputOrders']} 单 → {s['campaignCount']} 场"
|
||
f"(合并 {s['mergedCampaigns']} 场,省 {s['poSaved']} 个 PO)",
|
||
f"交期窗口 {report['windowDays']} 天",
|
||
]
|
||
for c in report["campaigns"][:10]:
|
||
tag = "战役" if c["isCampaign"] else "单排"
|
||
nos = "/".join(str(x) for x in (c.get("sourceOrderNos") or [])[:4])
|
||
lines.append(
|
||
f"· [{tag}] {c['productCode']} ×{c['totalQty']} · {c['memberCount']} 单"
|
||
f" · 交期 {c.get('earliestDue')}~{c.get('latestDue')} · {nos}"
|
||
)
|
||
lines.append("确认后可说「战役合并试排」。")
|
||
return AgentReply(text="\n".join(lines))
|
||
if name == "master.changeover.upsert":
|
||
from server.aps_domain.masterdata import confirmation_for_master_action
|
||
payload = dict(intent.params or {})
|
||
try:
|
||
title, lines = confirmation_for_master_action(store.data, "master.changeover.upsert", payload)
|
||
except ValueError as exc:
|
||
return AgentReply(text=f"换型矩阵有误:{exc}")
|
||
block = harness.stage_confirmation(
|
||
session_id, "master.changeover.upsert", payload, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="master.changeover.upsert.stage",
|
||
target={"type": "CHANGEOVER",
|
||
"id": f"{payload.get('fromFamily')}→{payload.get('toFamily')}"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 主数据写入,需要你确认。", blocks=[block])
|
||
# ---- 自然语言批量导入订单/物料(P2) ----
|
||
if name == "data.import":
|
||
from server.aps_domain.intake import confirmation_for_import, parse_import
|
||
text = str(intent.params.get("text") or "")
|
||
parsed = parse_import(text, store.data)
|
||
if intent.params.get("kind") in ("orders", "materials"):
|
||
parsed["kind"] = intent.params["kind"]
|
||
if not parsed["rows"]:
|
||
hint = ("订单格式示例:\n导入订单\n客户A,成品料号,200,2026-08-01,VIP\n客户 客户B 产品 成品名称 数量 100 交期 8月20日"
|
||
if parsed["kind"] == "orders" else
|
||
"物料格式示例:\n导入物料\nSCR-NEW,不锈钢螺丝,原料,包,800\n编码 PCB-X 名称 试验板 类型 原料 库存 200")
|
||
err = ";".join(parsed.get("errors", [])[:3])
|
||
return AgentReply(text=f"没有解析出可导入的行。{err}\n{hint}")
|
||
title, lines = confirmation_for_import(parsed)
|
||
payload = {"kind": parsed["kind"], "rows": parsed["rows"]}
|
||
block = harness.stage_confirmation(session_id, "data.import", payload, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="data.import.stage",
|
||
target={"type": "IMPORT", "id": parsed["kind"]}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"], "count": len(parsed["rows"])})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写入,需要你确认。", blocks=[block])
|
||
# ---- 订单分解(P1:MRP 建议草稿,不碰主数据与订单本体) ----
|
||
if name == "order.decompose":
|
||
from server.aps_domain.mrp import decompose_orders, summarize_decomposition
|
||
try:
|
||
result = decompose_orders(store.data, store.next_id, intent.params.get("orderNo"))
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="order.decompose",
|
||
target={"type": "MRP", "id": intent.params.get("orderNo") or "ALL"}, power="P1",
|
||
rationale={"purchase": len(result["purchase"]), "outsource": len(result["outsource"])})
|
||
store.save()
|
||
return AgentReply(text=summarize_decomposition(result))
|
||
|
||
# ---- 主数据/订单自然语言查询(P0 只读) ----
|
||
if name == "master.query":
|
||
from server.agent_core.session_focus import set_focus
|
||
from server.aps_domain.master_query import format_master_query, run_master_query
|
||
code = intent.params.get("code") or intent.params.get("orderNo")
|
||
result = run_master_query(
|
||
store.data,
|
||
entity=intent.params.get("entity"),
|
||
code=code,
|
||
text=str(intent.params.get("query") or ""),
|
||
aspect=intent.params.get("aspect"),
|
||
)
|
||
# 记住刚查到的订单,供「把这个订单排产 / 有委外吗」指代
|
||
rows = result.get("rows") or []
|
||
if result.get("entity") == "order" and rows:
|
||
set_focus(session_id, orderNo=rows[0].get("orderNo"),
|
||
productCode=rows[0].get("productCode"))
|
||
elif result.get("entity") == "mrp" and result.get("orderNo"):
|
||
set_focus(session_id, orderNo=result.get("orderNo"),
|
||
productCode=result.get("productCode"))
|
||
elif result.get("productCode"):
|
||
set_focus(session_id, productCode=result.get("productCode"))
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="master.query",
|
||
target={"type": "MASTER", "id": result.get("entity") or "overview"}, power="P0",
|
||
rationale={"entity": result.get("entity"), "code": code,
|
||
"count": result.get("count")})
|
||
store.save()
|
||
return AgentReply(text=format_master_query(result))
|
||
# ---- 计划追溯(P0:SO→主数据→分解→PO/WO→负荷/库存) ----
|
||
if name == "plan.trace":
|
||
from server.aps_domain.trace import plan_trace, summarize_trace
|
||
track = intent.params.get("track")
|
||
try:
|
||
result = plan_trace(store.data, intent.params.get("orderNo"), track=track)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
# both 形态:聊天只摘要;单轨带前 3 单
|
||
if result.get("track") == "both":
|
||
block = UIBlock(blockId="plan-trace", type="plan-trace",
|
||
props={"track": "both", "count": result["count"],
|
||
"fixedCount": (result.get("fixed") or {}).get("count", 0),
|
||
"flexCount": (result.get("flex") or {}).get("count", 0),
|
||
"orders": ((result.get("flex") or {}).get("orders")
|
||
or (result.get("fixed") or {}).get("orders") or [])[:2]})
|
||
else:
|
||
preview = {**result, "orders": result["orders"][:3]}
|
||
block = UIBlock(blockId="plan-trace", type="plan-trace", props=preview)
|
||
hint = ("完整链路可在「柔性工作台 → 钉扎」或「订单 → 追溯」查看。"
|
||
if (result.get("track") == "flex"
|
||
or (intent.params.get("orderNo") or "").upper().startswith("FO-"))
|
||
else "完整链路可在右侧「订单 → 追溯」查看。")
|
||
return AgentReply(text=summarize_trace(result) + "\n" + hint, blocks=[block])
|
||
# ---- MRP 建议单下达(P2 → 确认卡:DRAFT 采购/委外转正式) ----
|
||
if name == "mrp.release":
|
||
from server.aps_domain.mrp import confirmation_for_mrp_release
|
||
order_no = str(intent.params.get("orderNo") or "") or None
|
||
kind = str(intent.params.get("kind") or "all")
|
||
try:
|
||
title, lines = confirmation_for_mrp_release(store.data, order_no, kind)
|
||
except ValueError as exc:
|
||
return AgentReply(text=str(exc))
|
||
params = {"orderNo": order_no, "kind": kind}
|
||
block = harness.stage_confirmation(session_id, "mrp.release", params, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="mrp.release.stage",
|
||
target={"type": "MRP", "id": order_no or "ALL"}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"], "kind": kind})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||
# ---- 现场完整生产路线(P2:替换 flex*) ----
|
||
if name == "flex.site.load":
|
||
from server.aps_domain.kangni_intake import build_flex_bundle, confirmation_for_site_load
|
||
try:
|
||
bundle = build_flex_bundle(
|
||
intent.params.get("routePath") or None,
|
||
intent.params.get("dataDir") or None,
|
||
include_sibling_orders=intent.params.get("includeSiblings", False) is True,
|
||
station_count=int(intent.params.get("stationCount") or 4),
|
||
)
|
||
except (FileNotFoundError, ValueError) as exc:
|
||
return AgentReply(text=f"无法加载现场路线:{exc}")
|
||
meta = bundle.get("_meta") or {}
|
||
title, lines = confirmation_for_site_load(meta)
|
||
params = {
|
||
"routePath": intent.params.get("routePath"),
|
||
"dataDir": intent.params.get("dataDir"),
|
||
"includeSiblings": intent.params.get("includeSiblings", False) is True,
|
||
"stationCount": int(intent.params.get("stationCount") or 4),
|
||
}
|
||
block = harness.stage_confirmation(session_id, "flex.site.load", params, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="flex.site.load.stage",
|
||
target={"type": "FLEX_SITE", "id": meta.get("primaryOrder")}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||
# ---- 柔性排产(P1:能力池动态组虚拟产线,写草稿版本 §M5) ----
|
||
if name == "flex.schedule":
|
||
return _run_flex(store, intent, actor)
|
||
if name == "flex.reschedule":
|
||
return _flex_reschedule(store, intent, session_id, actor)
|
||
if name == "flex.swap":
|
||
return _flex_swap(store, intent, actor)
|
||
# ---- 瓶颈产能评估(P0:只读,瓶颈产能法) ----
|
||
if name == "flex.capacity":
|
||
return _flex_capacity(store)
|
||
# ---- 交期承诺模拟(P0:深拷贝沙盒试插单,不碰主干) ----
|
||
if name == "flex.simulate_due":
|
||
return _flex_simulate_due(store, intent)
|
||
if name == "flex.compare":
|
||
return _flex_compare(store)
|
||
if name == "flex.rush":
|
||
return _flex_rush(store, intent, actor)
|
||
if name == "flex.fault":
|
||
return _flex_fault(store, intent, actor)
|
||
if name == "conflict.list":
|
||
return _conflict_list(store, intent)
|
||
if name == "flex.conflict.resolve":
|
||
return _conflict_resolve(store, intent, session_id, actor)
|
||
# ---- SAP 集成(MD-05:状态 P0 / 入出站 P2 确认卡) ----
|
||
if name == "sap.status":
|
||
from server.aps_domain.sap_sync import sap_connection_status
|
||
st = sap_connection_status()
|
||
return AgentReply(
|
||
text=(f"SAP 连接:{'在线' if st.get('connected') else '离线'} · "
|
||
f"{st.get('mode')} · {st.get('system')}/{st.get('plant')} · "
|
||
f"待拉单 {st.get('openOrders')} · 已回写 {st.get('receiptCount')}。"))
|
||
if name in ("sap.sync.inbound", "sap.sync.outbound"):
|
||
from server.aps_domain.sap_sync import stage_sap_sync
|
||
direction = "inbound" if name.endswith("inbound") else "outbound"
|
||
r = stage_sap_sync(store, direction, session_id=session_id, actor=actor)
|
||
return AgentReply(text=r["message"], blocks=[r["block"]])
|
||
# ---- MES 下发 / 报工(EX-05 / EX-09) ----
|
||
if name == "mes.status":
|
||
from server.aps_domain.mes import list_execution, mes_connection_status
|
||
st = mes_connection_status()
|
||
ex = list_execution(store.data, "flex")
|
||
return AgentReply(
|
||
text=(f"MES:{'在线' if st.get('connected') else '离线'} · {st.get('system')} · "
|
||
f"外部工单 {st.get('woCount')}(未完 {st.get('openCount')})· "
|
||
f"柔性已下发 {ex.get('total', 0)} / 完工 {ex.get('completed', 0)}。"))
|
||
if name == "mes.dispatch":
|
||
from server.aps_domain.mes import stage_dispatch
|
||
r = stage_dispatch(store, track=intent.params.get("track") or "flex",
|
||
session_id=session_id, actor=actor)
|
||
if r.get("block"):
|
||
return AgentReply(text=r["message"], blocks=[r["block"]])
|
||
return AgentReply(text=r.get("message") or "无法下发")
|
||
if name == "mes.report":
|
||
from server.aps_domain.mes import apply_report, list_execution
|
||
track = intent.params.get("track") or "flex"
|
||
wo_id = intent.params.get("woId")
|
||
finish = bool(intent.params.get("finish") or intent.params.get("finishAll"))
|
||
if intent.params.get("finishAll") or wo_id is None:
|
||
ex = list_execution(store.data, track)
|
||
targets = [r for r in ex.get("rows") or []
|
||
if r.get("status") != "COMPLETED" and (r.get("progressPct") or 0) < 100]
|
||
if not targets:
|
||
return AgentReply(text="没有已下发且未完工的工单。请先「下发MES」。")
|
||
msgs = []
|
||
for row in targets[:30]:
|
||
try:
|
||
r = apply_report(store, int(row["woId"]), track=track,
|
||
finish=True, actor=actor)
|
||
msgs.append(r["message"])
|
||
except (ValueError, PermissionError) as exc:
|
||
msgs.append(str(exc))
|
||
return AgentReply(
|
||
text=f"批量报工完成 ✅ {len(msgs)} 条。\n" + "\n".join(msgs[:5]),
|
||
commands=[ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM")])
|
||
try:
|
||
r = apply_report(store, int(wo_id), track=track,
|
||
progress_pct=intent.params.get("progressPct"),
|
||
finish=finish, actor=actor)
|
||
except (ValueError, PermissionError) as exc:
|
||
return AgentReply(text=str(exc))
|
||
return AgentReply(text=r["message"],
|
||
commands=[ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM")])
|
||
# ---- 方案对比(P1:Explore 沙盒,不碰主干 §5.1/§9.7) ----
|
||
if name == "scenario.compare":
|
||
from server.aps_domain.analytics import matrix_from_fixed_cards
|
||
text, block = compare_scenarios(store.data) # 沙盒并行试排三策略
|
||
block.props["table"] = matrix_from_fixed_cards(
|
||
block.props.get("cards") or [], baseline=block.props.get("baseline"))
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="scenario.compare",
|
||
target={"type": "SANDBOX", "id": block.blockId}, power="P1",
|
||
rationale={"strategies": [c["strategy"] for c in block.props["cards"]]}) # 沙盒运行留痕
|
||
store.save() # 审计落盘(世界业务数据未变)
|
||
return AgentReply(
|
||
text=text + "\n已打开方案对比表视口。", blocks=[block],
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "compare"}, issuedBy="LLM")])
|
||
if name == "scenario.sensitivity":
|
||
from server.aps_domain.sensitivity import run_sensitivity, sensitivity_as_block
|
||
strategy = str((intent.params or {}).get("strategy") or "COMPREHENSIVE")
|
||
report = run_sensitivity(store.data, strategy=strategy)
|
||
block = sensitivity_as_block(report)
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN",
|
||
action="scenario.sensitivity",
|
||
target={"type": "SANDBOX", "id": block.blockId}, power="P1",
|
||
rationale={"strategy": strategy, "factors": [r["factorId"] for r in report["rows"]]})
|
||
store.save()
|
||
top = report["rows"][0] if report["rows"] else None
|
||
head = (
|
||
f"敏感性分析完成(策略 {strategy})。基线延期 {report['baseline']['tardiness']} h。"
|
||
)
|
||
if top:
|
||
head += f"\n最敏感因子:{top['label']}(摆幅 {top['swing']})。"
|
||
head += f"\n{report.get('hint') or ''}"
|
||
return AgentReply(text=head, blocks=[block])
|
||
# ---- 手动建档(P1:只新增快照 §4.3) ----
|
||
if name == "checkpoint.create":
|
||
label = intent.params.get("label") or f"手动存档 {fmt_date(today0())}" # 缺省自动命名
|
||
meta = get_checkpoints().create(store.data, label=label, reason="manual",
|
||
conversation_note="用户口令建档") # 建成对快照
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="checkpoint.create",
|
||
target={"type": "CHECKPOINT", "id": meta["pairId"]}, power="P1",
|
||
rationale={"label": label}) # 建档留痕
|
||
store.save() # 审计落盘
|
||
return AgentReply(text=f"已创建检查点【{label}】(ID: {meta['pairId']})✅\n"
|
||
f"随时可以说“回滚到检查点 {meta['pairId']}”回到此刻。",
|
||
commands=[ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM")]) # 刷新时间线导轨
|
||
# ---- 回滚(P2 → 确认卡 §4.3) ----
|
||
if name == "checkpoint.rollback":
|
||
ckpt = get_checkpoints() # 快照仓
|
||
pair_id = intent.params.get("pairId") # 指定目标(时间线点击合成口令携带)
|
||
pair = ckpt.get(pair_id) if pair_id else ckpt.latest() # 缺省=最近一个
|
||
if pair is None: # 无档可回
|
||
return AgentReply(text="当前没有可回滚的检查点。可先执行“建一个检查点”。")
|
||
block = harness.stage_confirmation( # 回滚是 P2:出确认卡
|
||
session_id, "checkpoint.rollback", {"pairId": pair["pairId"]},
|
||
title=f"回滚到检查点【{pair['label']}】",
|
||
summary_lines=[ # 影响面说明
|
||
f"目标时刻:{pair['createdAt']}(当时版本 {pair.get('versionNo') or '无'})",
|
||
"当前世界状态将被整体替换(对话与世界成对回滚 §4.2)",
|
||
"回滚前会自动再存档一次,本次回滚可撤销",
|
||
])
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="checkpoint.rollback.stage",
|
||
target={"type": "CHECKPOINT", "id": pair["pairId"]}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]}) # 出卡留痕
|
||
store.save() # 审计落盘
|
||
return AgentReply(text=f"回滚是 P2 写操作,需要你确认(目标:{pair['label']})。", blocks=[block])
|
||
# ---- 视口命令族(P0:直接回传命令由前端执行) ----
|
||
if name.startswith("viewport."):
|
||
replies = { # 各命令的回执文案
|
||
"viewport.mode": {"gantt": "已切换为【甘特图】。", "load": "已切换为【负荷热力图】。",
|
||
"due": "已切换为【交期承诺看板】。",
|
||
"pool": "已切换为【产能池】。",
|
||
"flex": "已切换为【柔性甘特】。",
|
||
"kpi": "已切换为【KPI 仪表盘】。",
|
||
"compare": "已切换为【方案对比表】。",
|
||
"util": "已切换为【资源利用率】。",
|
||
"plan": "已切换为【分桶计划】。",
|
||
}.get(intent.params.get("mode", ""), "已切换视图。"),
|
||
"viewport.filter": "已应用过滤,右侧只显示匹配的工单。",
|
||
"viewport.focus": f"已聚焦产线 {intent.params.get('lineCode', '')},其他产线已隐藏。",
|
||
"viewport.highlight": "已高亮" + ("【超期风险】(红)" if intent.params.get("what") == "overdue" else "【冲突】(黄)") + ",其余淡出。",
|
||
"viewport.timescale": "已切换时间粒度。",
|
||
"viewport.reset": "已重置视口:清除过滤、聚焦与高亮。",
|
||
}
|
||
return AgentReply(text=replies.get(name, "已执行。"), commands=[ # 回传结构化视口命令
|
||
ViewportCommand(cmd=name, params=intent.params, # 命令与槽位原样下发
|
||
target=intent.params.get("lineCode"), issuedBy="LLM")])
|
||
# ---- 知识库检索(P0:只读带出处 §8.2;可选 LLM 综合) ----
|
||
if name == "knowledge.query":
|
||
from server.agent_core.providers import get_provider
|
||
from server.knowledge.retrieval import hybrid_search
|
||
kb = get_knowledge()
|
||
kb.ensure_seed_assets()
|
||
q = str(intent.params.get("query") or "")
|
||
title_hint = intent.params.get("assetTitle")
|
||
if re.search(r"^(知识库|知识清单)$|有哪些知识|知识库(有什么|清单|目录)|工艺模式(有哪些|清单)", q.strip()):
|
||
metas = kb.list_meta()
|
||
by_kind: dict[str, list] = {}
|
||
for m in metas:
|
||
by_kind.setdefault(m.get("kind") or "other", []).append(m["title"])
|
||
lines = [f"知识库共 {len(metas)} 条资产:"]
|
||
kind_cn = {"sop": "SOP", "algorithm": "算法", "process": "工艺", "case": "案例", "report": "报告"}
|
||
for k, titles in by_kind.items():
|
||
lines.append(f"【{kind_cn.get(k, k)}】{len(titles)} 条")
|
||
for t in titles[:12]:
|
||
lines.append(f" · {t}")
|
||
if len(titles) > 12:
|
||
lines.append(f" · …另有 {len(titles) - 12} 条")
|
||
lines.append("可说「@知识:机加工工艺路线生成总则」或「导入知识文档」扩展语料。")
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="knowledge.query",
|
||
target={"type": "KNOWLEDGE", "id": "catalog"}, power="P0",
|
||
rationale={"query": q, "mode": "catalog", "count": len(metas)})
|
||
store.save()
|
||
return AgentReply(text="\n".join(lines))
|
||
if title_hint:
|
||
asset = kb.find_by_title(str(title_hint))
|
||
hits = [{"assetId": asset["assetId"], "title": asset["title"], "kind": asset["kind"],
|
||
"version": asset["version"], "score": 1.0,
|
||
"snippet": asset["content"][:120], "content": asset["content"]}] if asset else []
|
||
else:
|
||
hits = hybrid_search(kb.iter_search_units(), q, top_k=5)
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="knowledge.query",
|
||
target={"type": "KNOWLEDGE", "id": hits[0]["assetId"] if hits else "miss"}, power="P0",
|
||
rationale={"query": intent.params.get("query"), "hits": [h["assetId"] for h in hits]})
|
||
store.save()
|
||
if not hits:
|
||
from server.aps_domain.guidance import build_guidance
|
||
block = UIBlock(blockId="guidance-kb-miss", type="guidance",
|
||
props=build_guidance(store.data, context="knowledge_miss"))
|
||
return AgentReply(
|
||
text="知识库里没有找到相关内容(不编造)。\n"
|
||
"可以换个说法,或说「知识库」查看已有资产清单。",
|
||
blocks=[block])
|
||
# 尝试 LLM 综合;失败则吐最相关原文
|
||
top = hits[0]
|
||
text = None
|
||
ctx_parts = []
|
||
for i, h in enumerate(hits[:4], 1):
|
||
ctx_parts.append(
|
||
f"[{i}] 《{h['title']}》{h['version']}"
|
||
+ (f" §{h['heading']}" if h.get("heading") else "")
|
||
+ f"\n{h['content'][:800]}"
|
||
)
|
||
sys_prompt = (
|
||
"你是 APS 排产知识助手。只根据给定资料回答,禁止编造资料外的数字与规定。"
|
||
"回答末尾列出引用的资料标题与版本。"
|
||
)
|
||
user_prompt = f"问题:{q}\n\n资料:\n" + "\n\n".join(ctx_parts)
|
||
try:
|
||
text = await get_provider().chat_text(sys_prompt, user_prompt)
|
||
except Exception:
|
||
text = None
|
||
if not text:
|
||
text = (f"{top['content']}\n\n"
|
||
f"—— 出处:【{top['title']}】{top['version']}({top['kind']})")
|
||
else:
|
||
cites = ";".join(f"【{h['title']}】{h['version']}" for h in hits[:4])
|
||
if "出处" not in text and "【" not in text:
|
||
text = text.rstrip() + f"\n\n—— 出处:{cites}"
|
||
block = UIBlock(
|
||
blockId=f"evidence-{top['assetId']}", type="evidence",
|
||
props={"hits": [{k: h.get(k) for k in ("assetId", "title", "kind", "version", "score",
|
||
"snippet", "chunkId", "heading", "page")}
|
||
for h in hits]})
|
||
return AgentReply(text=text, blocks=[block])
|
||
|
||
# ---- 知识文档导入(P2 确认卡) ----
|
||
if name == "knowledge.import":
|
||
from server.knowledge.ingest import (
|
||
confirmation_for_knowledge_import, preview_ingest,
|
||
)
|
||
path = intent.params.get("path") or intent.params.get("filePath")
|
||
if not path:
|
||
return AgentReply(text="请指定文件路径,例如:「导入知识文档 D:/docs/sop.md」")
|
||
try:
|
||
preview = preview_ingest(
|
||
os.path.basename(str(path)), path=str(path),
|
||
kind=str(intent.params.get("kind") or "sop"),
|
||
title=intent.params.get("title"),
|
||
)
|
||
except (FileNotFoundError, ValueError) as exc:
|
||
return AgentReply(text=str(exc))
|
||
# 确认卡不塞全文 chunks,只留摘要;执行时再读 path
|
||
title, lines = confirmation_for_knowledge_import(preview)
|
||
slim = {
|
||
"path": str(path), "filename": preview["filename"], "title": preview["title"],
|
||
"kind": preview["kind"], "chunkCount": preview["chunkCount"],
|
||
"sectionCount": preview["sectionCount"],
|
||
}
|
||
block = harness.stage_confirmation(
|
||
session_id, "knowledge.import", slim, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="knowledge.import.stage",
|
||
target={"type": "KNOWLEDGE", "id": preview["filename"]}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"],
|
||
"chunkCount": preview["chunkCount"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2,需要你确认后入库。", blocks=[block])
|
||
|
||
# ---- 外部算法 Skill ----
|
||
if name == "skill.list":
|
||
from server.agent_core.skills import get_skills
|
||
rows = get_skills().list()
|
||
if not rows:
|
||
return AgentReply(text="尚未登记外部算法 skill。")
|
||
lines = ["已接入算法 Skill:"]
|
||
for s in rows:
|
||
flag = "启用" if s.get("enabled") else "停用"
|
||
lines.append(f"· {s['skill_id']} 【{s['name']}】{flag} · {s.get('endpoint')} · 轨={s.get('track')}")
|
||
lines.append("可说「用外部算法试排」或「skill 健康检查」。")
|
||
return AgentReply(text="\n".join(lines))
|
||
if name == "skill.health":
|
||
from server.agent_core.skills import get_skills
|
||
health = get_skills().health(intent.params.get("skillId"))
|
||
lines = ["Skill 健康检查:"]
|
||
for h in health:
|
||
ok = "OK" if h.get("ok") else "FAIL"
|
||
lines.append(f"· {h['skill_id']} [{ok}] {h.get('detail')} "
|
||
f"{('· ' + str(h.get('latencyMs')) + 'ms') if h.get('latencyMs') is not None else ''}")
|
||
return AgentReply(text="\n".join(lines))
|
||
# ---- 引导式排产向导(M-F:readiness→缺口引导→模板推荐→试排) ----
|
||
if name == "schedule.wizard":
|
||
from server.agent_core.dialog import start_wizard
|
||
return start_wizard(store, session_id, actor=actor)
|
||
# ---- 数据齐备度 / 工时维护(M-B:排产数据闭环) ----
|
||
if name == "readiness.query":
|
||
from server.aps_domain.readiness import check_readiness, readiness_text, time_matrix
|
||
report = harness.guard("readiness.query", {}, lambda: check_readiness(store.data))
|
||
matrix = time_matrix(store.data)
|
||
block = UIBlock(
|
||
blockId="readiness-report", type="readiness",
|
||
props={"summary": report["summary"], "orders": report["orders"],
|
||
"globalIssues": report["globalIssues"],
|
||
"timeMatrix": [r for r in matrix if r["source"] in ("推断", "待维护")][:40]},
|
||
)
|
||
return AgentReply(text=readiness_text(report), blocks=[block])
|
||
if name == "data.analyze":
|
||
from server.aps_domain.project_analyze import analyze_project_deep
|
||
deep = harness.guard(
|
||
"data.analyze", {},
|
||
lambda: analyze_project_deep(
|
||
store.data, session_id,
|
||
apply_sql=True,
|
||
query=str(intent.params.get("query") or ""),
|
||
),
|
||
)
|
||
store.save()
|
||
block = UIBlock(blockId="project-analyze", type="project-analyze", props={
|
||
"projectName": deep.get("projectName"),
|
||
"workDir": deep.get("workDir"),
|
||
"sources": deep.get("sources") or [],
|
||
"summary": deep.get("summary") or {},
|
||
"orders": deep.get("orders") or [],
|
||
"materials": deep.get("materials") or [],
|
||
"routings": deep.get("routings") or [],
|
||
"equipment": deep.get("equipment") or [],
|
||
"plan": deep.get("plan") or [],
|
||
"kbActions": deep.get("kbActions") or [],
|
||
"sqlPreview": deep.get("sqlPreview"),
|
||
"canSchedule": bool(deep.get("canSchedule")),
|
||
"applied": deep.get("applied") or {},
|
||
})
|
||
return AgentReply(text=deep.get("markdown") or "分析完成。", blocks=[block])
|
||
if name == "folder.analyze":
|
||
from server.aps_domain.folder_pack import analyze_work_dir
|
||
report = harness.guard(
|
||
"folder.analyze", {},
|
||
lambda: analyze_work_dir(store.data, session_id),
|
||
)
|
||
block = UIBlock(blockId="folder-pack", type="folder-pack", props={
|
||
"projectName": report.get("projectName"),
|
||
"workDir": report.get("workDir"),
|
||
"files": report.get("files") or [],
|
||
"coverage": report.get("coverage") or {},
|
||
"coverageDetail": report.get("coverageDetail") or [],
|
||
"missing": report.get("missing") or [],
|
||
"softMissing": report.get("softMissing") or [],
|
||
"canSchedule": bool(report.get("canSchedule")),
|
||
"totalOk": report.get("totalOk") or 0,
|
||
"totalErrors": report.get("totalErrors") or 0,
|
||
"error": report.get("error"),
|
||
})
|
||
return AgentReply(
|
||
text=(
|
||
f"**工程目录数据包 · {report.get('projectName') or '当前项目'}**\n\n"
|
||
f"目录:`{report.get('workDir') or ''}`\n\n"
|
||
+ (report.get("error") or (
|
||
f"共读到 **{len(report.get('files') or [])}** 个表格,"
|
||
f"有效行 {report.get('totalOk') or 0},问题行 {report.get('totalErrors') or 0}。"
|
||
+ (" **还不能开排**,详见下表。" if not report.get("canSchedule")
|
||
else " **基本可以开排**,详见下表。")
|
||
))
|
||
),
|
||
blocks=[block],
|
||
)
|
||
if name == "folder.schedule":
|
||
from server.aps_domain.folder_pack import analyze_work_dir, confirmation_for_folder_schedule
|
||
# 排产口令:强制用工程目录 SQL/表覆盖当前项目世界
|
||
report = analyze_work_dir(store.data, session_id, force_sql_replace=True)
|
||
store.save()
|
||
analyze_block = UIBlock(blockId="folder-pack", type="folder-pack", props={
|
||
"projectName": report.get("projectName"),
|
||
"workDir": report.get("workDir"),
|
||
"files": report.get("files") or [],
|
||
"coverage": report.get("coverage") or {},
|
||
"coverageDetail": report.get("coverageDetail") or [],
|
||
"missing": report.get("missing") or [],
|
||
"softMissing": report.get("softMissing") or [],
|
||
"canSchedule": bool(report.get("canSchedule")),
|
||
"totalOk": report.get("totalOk") or 0,
|
||
"totalErrors": report.get("totalErrors") or 0,
|
||
"error": report.get("error"),
|
||
"sqlApplied": bool(report.get("sqlApplied")),
|
||
"sqlStats": report.get("sqlStats") or {},
|
||
})
|
||
sql_ok = bool(report.get("sqlApplied")) and bool(report.get("canSchedule"))
|
||
has_core = (
|
||
bool(store.data.get("flexOrders"))
|
||
and bool(store.data.get("flexRoutings"))
|
||
and any(e.get("status") == "RUNNING" for e in (store.data.get("flexEquipment") or []))
|
||
)
|
||
# SQL 完整库:用户已说「根据这个排产」→ 直接开排,不再卡在「缺 Excel 批次/再确认」
|
||
if sql_ok or (report.get("sqlApplied") and has_core):
|
||
st = report.get("sqlStats") or {}
|
||
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="folder.schedule.sql_direct",
|
||
target={"type": "FOLDER_PACK", "id": report.get("workDir") or "workDir"},
|
||
power="P2",
|
||
rationale={"sqlStats": st, "direct": True})
|
||
store.save()
|
||
flex_reply = _run_flex(
|
||
store,
|
||
IntentResult(intent="flex.schedule",
|
||
params={"sortMode": "BOTTLENECK"},
|
||
confidence=1.0, source="RULE_FAST"),
|
||
actor,
|
||
)
|
||
head = (
|
||
f"已按工程目录 SQL 入库并开排:"
|
||
f"订单 {st.get('orders', 0)} · 工艺步 {st.get('routing', 0)} · "
|
||
f"已挂工艺产品 {st.get('routedProducts', 0)}/"
|
||
f"{st.get('orderProducts', 0)}。\n\n"
|
||
)
|
||
return AgentReply(
|
||
text=head + (flex_reply.text or ""),
|
||
blocks=[analyze_block, *(flex_reply.blocks or [])],
|
||
commands=flex_reply.commands or [],
|
||
)
|
||
if not report.get("batches") and not report.get("sqlApplied"):
|
||
return AgentReply(
|
||
text=(report.get("markdown") or "")
|
||
+ "\n\n目录里还没有可入库的有效行,我没法按这些表开排。"
|
||
"请先把订单/物料/工艺/设备表或 SQL 库补齐。",
|
||
blocks=[analyze_block],
|
||
)
|
||
if report.get("missing") and not has_core:
|
||
return AgentReply(
|
||
text=(report.get("markdown") or "")
|
||
+ "\n\n**还不能排:**还缺 "
|
||
+ "、".join(report["missing"])
|
||
+ "。补齐后再说「根据这些数据排产」。",
|
||
blocks=[analyze_block],
|
||
)
|
||
title, lines = confirmation_for_folder_schedule(report)
|
||
slim = [{"kind": b["kind"], "sheet": b.get("sheet"), "okRows": b.get("okRows") or []}
|
||
for b in report["batches"] if b.get("okRows")]
|
||
params = {
|
||
"workDir": report.get("workDir"),
|
||
"sessionId": session_id,
|
||
"batches": slim,
|
||
"sortMode": "BOTTLENECK",
|
||
"sqlApplied": bool(report.get("sqlApplied")),
|
||
}
|
||
block = harness.stage_confirmation(
|
||
session_id, "folder.schedule", params, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="folder.schedule.stage",
|
||
target={"type": "FOLDER_PACK", "id": report.get("workDir") or "workDir"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"],
|
||
"totalOk": report.get("totalOk")})
|
||
store.save()
|
||
return AgentReply(
|
||
text=(report.get("markdown") or "")
|
||
+ "\n\n必备数据已齐。下面请确认:**导入这些表格并试排一版**(可回滚)。",
|
||
blocks=[analyze_block, block],
|
||
)
|
||
if name in ("assistant.reply", "unknown"):
|
||
from server.agent_core.assistant import reply as assistant_reply
|
||
q = str(intent.params.get("query") or intent.params.get("text") or "")
|
||
hist = intent.params.get("_history") or []
|
||
return await assistant_reply(store.data, q, history=hist, session_id=session_id)
|
||
if name == "flex.time.update":
|
||
pc = str(intent.params.get("productCode") or "")
|
||
op_code = str(intent.params.get("operationCode") or "")
|
||
std_min = intent.params.get("stdMin")
|
||
if not op_code or not std_min:
|
||
return AgentReply(text="请说明工序与工时,例如「把部装工时改成 50 分钟」"
|
||
"或「把 0503727Z1M 工时改成 45 分钟」。")
|
||
# 允许用工序名匹配(「部装」→ 所有名称含部装的工序编码)
|
||
codes = [op_code]
|
||
if not any(r.get("operationCode") == op_code for r in store.data.get("flexRoutings") or []):
|
||
named = [o["code"] for o in store.data.get("flexOperations") or []
|
||
if op_code in (o.get("name") or "")]
|
||
if named:
|
||
codes = named
|
||
staged_blocks = []
|
||
title = f"更新工时:{pc or '全部产品'} × {'、'.join(codes)} → {std_min} 分钟/件"
|
||
block = harness.stage_confirmation(
|
||
session_id, "flex.time.update",
|
||
{"productCode": pc, "operationCode": codes[0] if len(codes) == 1 else "",
|
||
"operationCodes": codes, "stdMin": float(std_min), "source": "实测"},
|
||
title=title,
|
||
summary_lines=[f"匹配工序:{'、'.join(codes)}",
|
||
f"单件工时 → {std_min} 分钟(来源标记为「实测」)",
|
||
"确认后写入柔性工艺路线并同步数据库。"])
|
||
staged_blocks.append(block)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="flex.time.update.stage",
|
||
target={"type": "ROUTING_TIME", "id": f"{pc or '*'}/{op_code}"}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"], "stdMin": std_min})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写操作,请确认。", blocks=staged_blocks)
|
||
if name == "skill.register":
|
||
payload = dict(intent.params or {})
|
||
if not payload.get("skill_id") or not payload.get("endpoint"):
|
||
return AgentReply(text="登记 skill 需要 skill_id 与 endpoint,例如:"
|
||
"「登记算法skill id=algo.x endpoint=http://127.0.0.1:8101 name=我的算法」")
|
||
title = f"登记算法 Skill {payload.get('skill_id')}"
|
||
lines = [f"ID:{payload.get('skill_id')}", f"端点:{payload.get('endpoint')}",
|
||
f"名称:{payload.get('name') or payload.get('skill_id')}",
|
||
f"轨道:{payload.get('track') or 'flex'}"]
|
||
block = harness.stage_confirmation(session_id, "skill.register", payload,
|
||
title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="skill.register.stage",
|
||
target={"type": "SKILL", "id": payload.get("skill_id")}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2,确认后写入配置。", blocks=[block])
|
||
if name == "skill.enable":
|
||
sid = intent.params.get("skillId") or intent.params.get("skill_id")
|
||
if not sid:
|
||
return AgentReply(text="请指定 skillId,例如「启用 skill algo.stub」或「停用 skill algo.stub」。")
|
||
enabled = intent.params.get("enabled")
|
||
if enabled is None:
|
||
enabled = not re.search(r"停用|禁用|关闭", str(intent.params.get("query") or ""))
|
||
title = f"{'启用' if enabled else '停用'}算法 Skill {sid}"
|
||
block = harness.stage_confirmation(
|
||
session_id, "skill.enable", {"skill_id": sid, "enabled": bool(enabled)},
|
||
title=title, summary_lines=[f"目标:{sid}", f"状态 → {'启用' if enabled else '停用'}"])
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="skill.enable.stage",
|
||
target={"type": "SKILL", "id": sid}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"], "enabled": enabled})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2,请确认。", blocks=[block])
|
||
|
||
# ---- 报告生成(P1:产出文档并入知识库 §9.10) ----
|
||
if name == "report.generate":
|
||
report_type = str(intent.params.get("reportType") or "daily") # 报告类型(缺省日报)
|
||
order_no = str(intent.params.get("orderNo") or "").strip() or None
|
||
report = build_report(store.data, report_type, order_no=order_no) # 冻结快照 → 模板生成
|
||
if not report["reportId"]: # 前置条件不满足(无版本/不足两版)
|
||
return AgentReply(text=report["markdown"])
|
||
# 报告入知识库(§9.10 规则3:报告本身成为 RAG 语料)
|
||
asset = get_knowledge().add(kind="report", title=report["title"],
|
||
content=report["markdown"], tags=[report_type, "报告"])
|
||
download_url = None
|
||
download_name = None
|
||
fmt = "md"
|
||
xlsx = report.get("xlsxBytes")
|
||
if xlsx and report_type in ("plan", "schedule-plan", "flex-plan"):
|
||
saved = persist_report_xlsx(report["reportId"], xlsx, report.get("filename"))
|
||
download_url = f"/api/reports/files/{report['reportId']}"
|
||
download_name = saved["filename"]
|
||
fmt = "xlsx"
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="report.generate",
|
||
target={"type": "REPORT", "id": report["reportId"]}, power="P1",
|
||
rationale={"reportType": report_type, "assetId": asset["assetId"],
|
||
"orderNo": order_no, "format": fmt})
|
||
store.save()
|
||
block = UIBlock(
|
||
blockId=f"report-{report['reportId']}", type="report",
|
||
props={"reportId": report["reportId"], "title": report["title"],
|
||
"markdown": report["markdown"], "reportType": report_type,
|
||
"assetId": asset["assetId"], "format": fmt,
|
||
"downloadUrl": download_url, "filename": download_name})
|
||
tip = ("点下方「下载 Excel 工作计划表」保存完整工序计划。"
|
||
if fmt == "xlsx" else "可预览并下载 Markdown。")
|
||
return AgentReply(
|
||
text=f"已生成【{report['title']}】(数字取自冻结快照)。\n{tip}"
|
||
f"报告已入知识库(资产 {asset['assetId']})。",
|
||
blocks=[block])
|
||
# ---- KPI 查询 / 仪表盘(P0,EX-08) ----
|
||
if name == "query.kpi":
|
||
from server.aps_domain.analytics import build_kpi_dashboard
|
||
dash = build_kpi_dashboard(store.data)
|
||
lines = [f"· {c['label']}:{c['value']}" for c in dash.get("cards") or []]
|
||
text = "运营 KPI 仪表盘:\n" + ("\n".join(lines) if lines else "暂无数据,请先排产。")
|
||
block = UIBlock(blockId="kpi-dash", type="kpi-dashboard", props=dash)
|
||
return AgentReply(
|
||
text=text, blocks=[block],
|
||
commands=[ViewportCommand(cmd="viewport.mode", params={"mode": "kpi"}, issuedBy="LLM")])
|
||
# ---- 主动引导(P0,AG-07) ----
|
||
if name == "guidance.next":
|
||
from server.aps_domain.guidance import build_guidance
|
||
guide = build_guidance(store.data)
|
||
lines = [f"· {s['label']}({s['reason']})" for s in guide.get("suggestions") or []]
|
||
sig = guide.get("signals") or []
|
||
head = "当前建议下一步:\n" if not sig else f"状态:{sig[0]['title']}。建议:\n"
|
||
return AgentReply(
|
||
text=head + ("\n".join(lines) if lines else "暂无特别建议,可说「帮助」。"),
|
||
blocks=[UIBlock(blockId="guidance-next", type="guidance", props=guide)])
|
||
# ---- 排产参数查询 / 更新(OR-02) ----
|
||
if name == "params.query":
|
||
from server.aps_domain.params import get_schedule_params
|
||
p = get_schedule_params(store.data)
|
||
lw = p["customerLevelWeights"]
|
||
ow = p["weights"]
|
||
text = (
|
||
"当前排产参数:\n"
|
||
f"· 客户等级权重:VIP={lw['VIP']} / A={lw['A']} / B={lw['B']} / C={lw['C']}\n"
|
||
f"· 目标权重:交期={ow['tardiness']} / 成本={ow['cost']} / 利用率={ow['utilization']} / 均衡={ow['balance']}\n"
|
||
f"· 展望期 {p['planningHorizonDays']} 天\n"
|
||
"可在设置 → 排产参数调整,或说「提高VIP权重到10」「恢复默认排产参数」。"
|
||
)
|
||
return AgentReply(text=text)
|
||
if name == "params.update":
|
||
from server.aps_domain.params import confirmation_for_params_update
|
||
raw = dict(intent.params or {})
|
||
payload: dict[str, Any] = {}
|
||
if raw.get("resetDefaults"):
|
||
payload["resetDefaults"] = True
|
||
else:
|
||
levels: dict[str, float] = {}
|
||
mapping = {"vipWeight": "VIP", "aWeight": "A", "bWeight": "B", "cWeight": "C",
|
||
"VIP": "VIP", "A": "A", "B": "B", "C": "C"}
|
||
for src, dst in mapping.items():
|
||
if raw.get(src) is not None and src in ("vipWeight", "aWeight", "bWeight", "cWeight", "VIP", "A", "B", "C"):
|
||
try:
|
||
levels[dst] = float(raw[src])
|
||
except (TypeError, ValueError):
|
||
return AgentReply(text=f"{dst} 权重必须是数字。")
|
||
if raw.get("customerLevelWeights"):
|
||
payload["customerLevelWeights"] = raw["customerLevelWeights"]
|
||
elif levels:
|
||
payload["customerLevelWeights"] = levels
|
||
if raw.get("weights"):
|
||
payload["weights"] = raw["weights"]
|
||
if raw.get("planningHorizonDays") is not None:
|
||
payload["planningHorizonDays"] = raw["planningHorizonDays"]
|
||
try:
|
||
title, lines = confirmation_for_params_update(store.data, payload)
|
||
except ValueError as exc:
|
||
return AgentReply(text=f"排产参数有误:{exc}")
|
||
block = harness.stage_confirmation(session_id, "params.update", payload, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE", action="params.update.stage",
|
||
target={"type": "SCHEDULE_PARAMS", "id": "scheduleParams"}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||
# ---- 约束配置(SC-04) ----
|
||
if name == "constraint.profile.query":
|
||
from server.aps_domain.constraints import get_constraint_profile
|
||
profile = get_constraint_profile(store.data)
|
||
lines = []
|
||
for c in profile["constraints"]:
|
||
if not c.get("configurable") and c["enabled"]:
|
||
continue
|
||
state = "开" if c["enabled"] else "关"
|
||
kind = "硬" if c["kind"] == "hard" else "软"
|
||
flag = "" if c.get("configurable") else "(内建)"
|
||
lines.append(f"· [{c['code']}] {c['name']}:{state}/{kind}{flag}")
|
||
text = f"约束剖面【{profile['name']}】:\n" + "\n".join(lines)
|
||
text += "\n可说「关闭物料齐套」「齐套改为硬约束」或在设置 → 约束配置调整。"
|
||
return AgentReply(text=text)
|
||
if name == "constraint.profile.save":
|
||
from server.aps_domain.constraints import confirmation_for_profile_save
|
||
raw = dict(intent.params or {})
|
||
payload: dict[str, Any] = {}
|
||
if raw.get("resetDefaults"):
|
||
payload["resetDefaults"] = True
|
||
elif raw.get("constraints"):
|
||
payload["constraints"] = raw["constraints"]
|
||
else:
|
||
cid = str(raw.get("constraintId") or "")
|
||
if not cid:
|
||
return AgentReply(text="请指定约束,例如:「关闭物料齐套」或「齐套改为硬约束」。")
|
||
patch: dict[str, Any] = {}
|
||
if "enabled" in raw:
|
||
patch["enabled"] = bool(raw["enabled"])
|
||
if raw.get("kind") in ("hard", "soft"):
|
||
patch["kind"] = raw["kind"]
|
||
payload["constraints"] = {cid: patch}
|
||
try:
|
||
title, lines = confirmation_for_profile_save(store.data, payload)
|
||
except ValueError as exc:
|
||
return AgentReply(text=f"约束配置有误:{exc}")
|
||
block = harness.stage_confirmation(
|
||
session_id, "constraint.profile.save", payload, title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="constraint.profile.save.stage",
|
||
target={"type": "CONSTRAINT_PROFILE", "id": "default"}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||
# ---- IND-02 SOP→约束 ----
|
||
if name == "sop.compile":
|
||
from server.aps_domain.sop_rules import compile_sop_by_query, list_compilable_sops, sop_as_block
|
||
q = str((intent.params or {}).get("query") or "换线")
|
||
compiled = compile_sop_by_query(get_knowledge().assets, q)
|
||
if not compiled.get("ok"):
|
||
avail = compiled.get("available") or list_compilable_sops(get_knowledge().assets)
|
||
titles = "、".join(a.get("title") or "?" for a in avail[:6])
|
||
return AgentReply(text=f"{compiled.get('error')}\n可编译 SOP:{titles or '(无)'}")
|
||
pack = compiled["pack"]
|
||
write_audit(store.data, store.next_id, actor=actor, category="ALGO_RUN", action="sop.compile",
|
||
target={"type": "SOP", "id": (pack.get("source") or {}).get("assetId")}, power="P0",
|
||
rationale={"query": q, "effects": len(pack.get("effects") or [])})
|
||
store.save()
|
||
text = (
|
||
f"已编译 SOP《{pack.get('title')}》→ 规则包预览。\n"
|
||
f"{pack.get('summary')}\n"
|
||
"确认落地请说「应用换线SOP」或「应用该规则包」(P2)。"
|
||
)
|
||
return AgentReply(text=text, blocks=[sop_as_block(pack)])
|
||
if name == "sop.apply":
|
||
from server.aps_domain.sop_rules import (
|
||
compile_sop_by_query, confirmation_for_sop_apply,
|
||
)
|
||
raw = dict(intent.params or {})
|
||
pack = raw.get("pack") if isinstance(raw.get("pack"), dict) else None
|
||
if pack is None:
|
||
compiled = compile_sop_by_query(get_knowledge().assets, raw.get("query") or "换线")
|
||
if not compiled.get("ok"):
|
||
return AgentReply(text=compiled.get("error") or "SOP 编译失败")
|
||
pack = compiled["pack"]
|
||
title, lines = confirmation_for_sop_apply(pack)
|
||
block = harness.stage_confirmation(
|
||
session_id, "sop.apply", {"pack": pack, "query": raw.get("query")},
|
||
title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="sop.apply.stage",
|
||
target={"type": "RULE_PACK", "id": pack.get("packId")}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||
# ---- 帮助(P0) ----
|
||
if name == "help":
|
||
return AgentReply(text=_HELP)
|
||
# ---- 拒识兜底:走通用助理(带上下文)----
|
||
from server.agent_core.assistant import reply as assistant_reply
|
||
return await assistant_reply(
|
||
store.data,
|
||
str(intent.params.get("query") or ""),
|
||
history=intent.params.get("_history") or [],
|
||
session_id=session_id,
|
||
)
|