# ============================================================ # 排产工作流编排(moduleId: domain-workflow, 可重生 ✅) # 职责:意图 → 动作 → AgentReply(文本 + 视口命令 + UI 块) # 权力路由:P0/P1 直接执行;P2(发布/重置)经门禁出确认卡(§3.3) # ============================================================ from __future__ import annotations # 前向类型引用 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.views import world_summary # KPI 摘要(query.kpi 用) from server.aps_domain.reports import build_report # 报告生成 v1(§9.10) from server.knowledge import get_knowledge, get_preferences # 知识库与偏好仓(M3 §8) from server.knowledge.retrieval import search as kn_search # 混合检索(带出处 §8.2) from server.contracts import UIBlock # 报告/证据块组装 # 帮助文案:能力清单(help 意图与拒识兜底共用) _HELP = "\n".join([ "可执行的排产能力:", "· 生成排产草案:试排一版交期优先 / 产能均衡重排 / 用规则引擎试排", "· 方案评估:多策略方案对比(沙盒运行,不影响当前版本)", "· 切到负荷热力图 / 交期承诺看板 / 甘特图", "· 只看VIP订单 / 只看海尔智家的订单", "· 聚焦产线A / 定位 L003", "· 标出超期订单 / 查看冲突 / 按小时看 / 重置视图", "· 建一个检查点 / 回滚到上一个检查点(需确认)", "· 换线有什么规定 / @知识:换线标准SOP(知识库带出处)", "· 生成日报 / 生成版本对比报告(可下载)", "· 查看当前KPI / 发布当前排产版本(需确认)/ 重置数据(需确认)", ]) # 策略模板的中文名(回复文案用) _STRATEGY_CN = {"DELIVERY_FIRST": "交期优先", "CAPACITY_BALANCE": "产能均衡", "COST_FIRST": "成本最优", "FIFO": "先进先出", "COMPREHENSIVE": "综合优化"} 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)} 次;点名策略可覆盖)" params = EngineParams( # 组装引擎入参 orderIds=[], # M1:全部待排订单 engineType=intent.params.get("engine", "HYBRID"), # 引擎类型(M1 由 RULE 代跑) strategyTemplate=strategy, # 策略模板(点名或偏好) planningHorizonDays=14, # 展望期 startDate=fmt_date(add_minutes(today0(), 24 * 60)), # 明天开排 ) engine = get_engine(params.engineType) # 取引擎(M1 恒为 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}) store.save() # 试排结果落盘(草稿版本也持久化) get_preferences().record(params.strategyTemplate, source="schedule.run", actor=actor) # 偏好信号沉淀(§8.3) engine_note = "" # 引擎诚实标注(M1 CP/GA 由 RULE 代跑) if params.engineType != "RULE": # 请求了未落地引擎 engine_note = f"\n(注:{params.engineType} 求解器将在 M5 接入,本次由规则引擎代跑)" text = (f"排产完成 ✅ 版本 {result.versionNo}({_STRATEGY_CN.get(result.strategy, result.strategy)})\n" f"生产订单 {result.poCount} 个 / 工单 {result.woCount} 个\n" f"冲突 {result.conflictCount} 项 · 总延迟 {round(result.totalTardiness)}h · " f"平均利用率 {round(result.avgUtilization * 100)}%{engine_note}{pref_note}\n" f"右侧甘特已刷新为该版本。执行“发布当前排产版本”可进入 P2 发布确认。") # 回复正文(含下一步引导) return AgentReply(text=text, 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: # 无版本可发布 return AgentReply(text="当前没有可发布的排产版本。请先生成排产草案,例如:试排一版交期优先。") v = versions[-1] # 最新版本 if v["status"] == "PUBLISHED": # 已发布防重 return AgentReply(text=f"版本 {v['versionNo']} 已是发布状态,无需重复发布。") 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_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"]) # 目标版本 # 回滚防线(§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"): # ---- 批准:订单写入 ---- 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"]}) store.save() if action == "order.upsert": verb = "已新增" if applied.get("created") else "已更新" return f"{verb}订单 {order['orderNo']} ✅ 后续试排会使用最新订单池。" if action == "order.cancel": return f"订单 {order['orderNo']} 已取消 ✅ 后续试排将不再纳入。" return f"订单 {order['orderNo']} 已完成 ✅ 后续试排将不再纳入。" 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 明细", "ROUTING_STEP": "工艺步骤"}[applied["kind"]] return f"{kind_cn}【{applied['name']}】主数据已更新 ✅ 只影响后续新排产版本(变更前已自动建档)。" return "未知动作,已忽略。" # 白名单外兜底(理论不可达) async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult, actor: str = "planner") -> AgentReply: """意图 → 回复 的总编排(gateway 调用;P0/P1 直通,P2 出卡)。""" name = intent.intent # 意图名 # ---- 排产(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 → 确认卡;对话与页面共用 action) ---- if name in ("order.cancel", "order.complete"): 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]) # ---- 订单分解(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)) # ---- 方案对比(P1:Explore 沙盒,不碰主干 §5.1/§9.7) ---- if name == "scenario.compare": text, block = compare_scenarios(store.data) # 沙盒并行试排三策略 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, 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": "已切换为【交期承诺看板】。"}.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) ---- if name == "knowledge.query": kb = get_knowledge() # 知识库 title_hint = intent.params.get("assetTitle") # @知识:标题 的直达路径 if title_hint: # 显式引用优先(§4.7) 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 = kn_search(kb.assets, str(intent.params.get("query") or ""), top_k=3) 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: # 未命中:诚实告知(禁止编造 §8.1) return AgentReply(text="知识库里没有找到相关内容(不编造)。\n" "可以换个说法,或说「知识库」查看已有资产清单。") # 回答正文:直接引用最相关资产内容 + 出处(强制带出处 §8.1) top = hits[0] # 最相关命中 text = (f"{top['content']}\n\n" f"—— 出处:【{top['title']}】{top['version']}({top['kind']})") block = UIBlock( # 证据块:全部命中带出处(前端渲染) blockId=f"evidence-{top['assetId']}", type="evidence", props={"hits": [{k: h[k] for k in ("assetId", "title", "kind", "version", "score", "snippet")} for h in hits]}) return AgentReply(text=text, blocks=[block]) # ---- 报告生成(P1:产出文档并入知识库 §9.10) ---- if name == "report.generate": report_type = str(intent.params.get("reportType") or "daily") # 报告类型(缺省日报) report = build_report(store.data, report_type) # 冻结快照 → 模板生成 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, "报告"]) 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"]}) # 报告留痕 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"]}) return AgentReply(text=f"已生成【{report['title']}】(模板化生成,数字全部取自冻结快照,可复现)。\n" f"报告已入知识库(资产 {asset['assetId']}),可下载 Markdown。", blocks=[block]) # ---- KPI 查询(P0) ---- if name == "query.kpi": s = world_summary(store.data) # 取 KPI 摘要 if not s["hasVersion"]: # 无版本引导 return AgentReply(text="当前没有排产版本,暂无可计算的版本 KPI。请先生成排产草案。") text = (f"当前版本 {s['versionNo']}({s['status']}):\n" f"生产订单 {s['poCount']} / 工单 {s['woCount']}\n" f"冲突 {s['conflictCount']} 项 · 总延迟 {s['totalTardiness']}h\n" f"平均利用率 {round(s['avgUtilization'] * 100)}% · 预估成本 ¥{int(s['totalCost']):,}") # KPI 文案 return AgentReply(text=text) # ---- 帮助(P0) ---- if name == "help": return AgentReply(text=_HELP) # ---- 拒识兜底 ---- return AgentReply(text="这句话我没有把握理解对(置信度不足)。\n" + _HELP)