aps-agent/server/aps_domain/workflow.py

366 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 排产工作流编排(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)