aps-agent/server/aps_domain/workflow.py

3781 lines
213 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 # 前向类型引用
import copy
import re
import os
from typing import TYPE_CHECKING, Any # 类型标注
if TYPE_CHECKING:
from server.agent_core.session_ref import RefResolutionError
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.knowledge.preferences import extract_features # 多特征规则归纳输入(矩阵 66)
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": "综合优化",
"KITTING_FIRST": "齐套优先", "SKILL_FIRST": "技能优先"}
# OR-04:最近一次插单快评(供「采用插单」复用;进程内存)
_LAST_RUSH_EVAL: dict[str, Any] = {}
def _build_run_trace(result: ScheduleResult, params: EngineParams,
intent: IntentResult, store: WorldStore) -> dict:
"""构造 schedule.run 可追溯链(尽力而为;失败返回空链摘要不影响求解)。
链哈希只包含确定性信息:版本/run/引擎/策略/求解状态/gap/输入包指纹。
墙钟 solveTimeSec 不进入链哈希(同输入同种子跨引擎可复算),
但仍保留在 ALGO_RUN 审计 rationale 顶层字段 solveTimeSec 供人查看。
"""
try:
from server.agent_core.evidence import EvidenceItem, trace_chain, evidence_ref
version_ref = evidence_ref("schedule-version", result.versionId)
items = [
EvidenceItem(kind="schedule-version", ref=str(result.versionId),
version=str(result.versionId)),
EvidenceItem(kind="run", ref=f"run-{result.versionId}", runId=str(result.versionId),
engine=params.engineType,
meta={"strategy": params.strategyTemplate,
"solveStatus": result.solveStatus,
"gap": result.optimalityGap}),
EvidenceItem(kind="algorithm", ref=params.engineType,
version=params.engineType,
inputsHash=_run_inputs_hash(store.data, params)),
]
chain = trace_chain(items)
return {
"chainHash": chain["chainHash"],
"count": chain["count"],
"summary": [{"kind": it["kind"], "ref": it["ref"]} for it in chain["items"]],
"items": chain["items"],
"versionRef": version_ref,
}
except Exception:
return {"chainHash": None, "count": 0, "summary": [], "versionRef": None}
def _run_inputs_hash(world: dict, params: EngineParams) -> str | None:
"""输入包指纹(可复算):订单池 + 参数 + 引擎的稳定投影。"""
import hashlib, json as _json
try:
payload = {
"orders": len(world.get("salesOrders") or []) + len(world.get("flexOrders") or []),
"engine": params.engineType,
"strategy": params.strategyTemplate,
"horizon": params.planningHorizonDays,
"constraints": sorted(params.constraints.items()) if params.constraints else [],
}
return hashlib.sha256(
_json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
except Exception:
return None
def _run_audit_evidence(result: ScheduleResult, trace: dict) -> list[str]:
"""合并引擎 evidenceRefs 与 trace 的 schedule-version 引用(去重保序)。
对齐 round-15 计划假设 2:evidenceRefs 保留 schedule-version ref;
trace 构造失败(versionRef=None)时仅保留引擎原始引用,不阻断审计。
"""
refs = list(result.evidenceRefs or [])
version_ref = trace.get("versionRef")
if version_ref and version_ref not in refs:
refs.append(version_ref)
return refs
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", project_id=getattr(store, "world_key", None))
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) # 求解(写内存世界)
# 可追溯链:run-id / 算法版本 / 种子 / 知识版本 / 用户确认 串成一条链(§8.4)
trace = _build_run_trace(result, params, intent, store)
# 审计:算法运行留痕(§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": _run_audit_evidence(result, trace),
"solveStatus": result.solveStatus,
"solveTimeSec": result.solveTimeSec,
"gap": result.optimalityGap,
"traceChainHash": trace["chainHash"],
"traceCount": trace["count"],
"traceSummary": trace["summary"],
"traceItems": trace.get("items")})
store.save() # 试排结果落盘(草稿版本也持久化)
get_preferences().record(params.strategyTemplate, source="schedule.run", actor=actor,
project_id=getattr(store, "world_key", None),
features=extract_features(store.data)) # 偏好信号沉淀(§8.3 项目隔离 + 矩阵 66 特征规则输入)
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_schedule_publish(
store: WorldStore,
*,
session_id: str,
actor: str,
track: str = "fixed",
version_id: int | None = None,
) -> AgentReply:
"""Stage a P2 publication card for one exact fixed or flex version."""
normalized_track = str(track or "fixed").lower()
if normalized_track not in {"fixed", "flex"}:
return AgentReply(text=f"未知排产轨道:{normalized_track}。")
version_key = "flexScheduleVersions" if normalized_track == "flex" else "scheduleVersions"
versions = store.data.get(version_key) or []
version = (
next((row for row in versions if row.get("id") == version_id), None)
if version_id is not None
else (versions[-1] if versions else None)
)
if version is None:
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],
)
status = str(version.get("status") or "").upper()
if status in {"PUBLISHED", "DISPATCHED"}:
return AgentReply(text=f"版本 {version.get('versionNo') or version['id']} 已发布,无需重复发布。")
if status != "DRAFT":
return AgentReply(text=f"版本 {version.get('versionNo') or version['id']} 状态为 {status or 'UNKNOWN'},不可发布。")
evidence_refs = [f"schedule-version:{version['id']}"]
from server.aps_domain.mes import validate_dispatchable_version
validation = validate_dispatchable_version(
store.data, normalized_track, int(version["id"]),
)
if not validation.get("publishReady"):
reasons = validation.get("publishBlockingReasons") or []
detail = ";".join(
str(reason.get("message") or reason.get("code"))
for reason in reasons[:5]
)
track_name = "柔性" if normalized_track == "flex" else "固定"
return AgentReply(
text=(
f"{track_name}版本 {version.get('versionNo') or version['id']} "
f"未通过发布校验:{detail or '结构不完整'}"
)
)
if validation.get("evidenceRef"):
evidence_refs.append(str(validation["evidenceRef"]))
if normalized_track == "flex":
summary_lines = [
f"闭环制造需求 {int(version.get('demandCount') or version.get('orderCount') or 0)} 项",
f"虚拟产线 {int(version.get('vlCount') or 0)} 条 / 工单 {int(version.get('woCount') or 0)} 个",
"发布只建立执行基准,不触发 MES;MES 下发仍需独立 P3 双人确认",
]
target_type = "FLEX_VERSION"
else:
summary_lines = [
f"生产订单 {int(version.get('poCount') or 0)} 个 / 工单 {int(version.get('woCount') or 0)} 个将转入执行准备",
f"未解决冲突 {int(version.get('conflictCount') or 0)} 项 · 总延迟 {round(float(version.get('totalTardiness') or 0))}h",
"发布后该版本成为执行基准(P2 写主干世界状态)",
]
target_type = "SCHEDULE_VERSION"
params = {
"track": normalized_track,
"versionId": int(version["id"]),
"evidenceRefs": evidence_refs,
}
block = harness.stage_confirmation(
session_id,
"schedule.publish",
params,
title=f"发布排产版本 {version.get('versionNo') or version['id']}",
summary_lines=summary_lines,
evidence_refs=evidence_refs,
)
write_audit(
store.data,
store.next_id,
actor=actor,
category="GATE",
action="schedule.publish.stage",
target={"type": target_type, "id": version["id"]},
power="P2",
rationale={
"confirmId": block.props["confirmId"],
"track": normalized_track,
"validationEvidence": evidence_refs[-1],
},
evidence_refs=evidence_refs,
)
store.save()
return AgentReply(
text=f"发布版本 {version.get('versionNo') or version['id']} 属于 P2 写操作,需要确认后执行。",
blocks=[block],
)
def _stage_publish(store: WorldStore, session_id: str, actor: str, *, prefer_flex: bool = False) -> AgentReply:
"""Stage publication, preferring the latest flexible draft for chat commands."""
flex_versions = store.data.get("flexScheduleVersions") or []
latest_flex = flex_versions[-1] if flex_versions else None
use_flex = bool(
prefer_flex
and latest_flex
and str(latest_flex.get("status") or "").upper() == "DRAFT"
)
return stage_schedule_publish(
store,
session_id=session_id,
actor=actor,
track="flex" if use_flex else "fixed",
version_id=int(latest_flex["id"]) if use_flex else None,
)
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 _version_resolvable(world: dict, version_id: int) -> bool:
"""对象域版本一致性:versionId 指向的版本必须仍可解析(供统一证据校验)。
仅当世界确实带版本表(scheduleVersions/flexScheduleVersions)时执行解析;
世界根本没有版本表(测试用极简 store/空世界)视为不适用并放行,
由各动作自身逻辑(mes preview 重校验、publish 目标版本查找等)兜底。
"""
has_tables = "scheduleVersions" in world or "flexScheduleVersions" in world
if not has_tables:
return True
for key in ("scheduleVersions", "flexScheduleVersions"):
for v in world.get(key) or []:
if v.get("id") == version_id:
return True
return False
def _conversation_side_snapshot() -> dict[str, Any] | None:
"""收集对话侧完整快照(project workspace + messages + Plan + 分支,尽力而为)。
矩阵 52 行成对快照验收:快照须包含完整对话、Plan、活动分支和世界。
任一部分不可用(如测试环境无认证身份)时返回 None,仅保存摘要,不阻断建档。
"""
try:
from server.state.projects import get_project_store
from server.agent_core.plan_runtime import get_plan_store
side: dict[str, Any] = {
"workspace": get_project_store().snapshot(include_messages=True),
}
try:
plan_store = get_plan_store()
side["plans"] = {
pid: list(versions)
for pid, versions in plan_store._plans.items()
}
except Exception:
side["plans"] = None
try:
from server.state.branches import get_branches
store = getattr(get_project_store(), "data", None)
session_id = None
if isinstance(store, dict):
session_id = store.get("activeSessionId")
if session_id:
side["branches"] = get_branches().tree(session_id)
else:
side["branches"] = None
except Exception:
side["branches"] = None
return side
except Exception:
return None
def _restore_conversation_side(side: dict[str, Any] | None) -> None:
"""回滚时成对恢复对话侧快照(project workspace + messages;尽力而为)。
对话侧快照不可用(None / 缺 workspace)时静默跳过——世界侧回滚仍有效;
恢复失败不阻断回滚主流程(世界已恢复,对话侧缺失由时间线/会话树补)。
"""
if not isinstance(side, dict):
return
workspace = side.get("workspace")
if not isinstance(workspace, dict):
return
try:
from server.state.projects import get_project_store
get_project_store().replace_workspace(workspace)
except Exception:
pass
def _create_checkpoint(store: WorldStore, *, label: str, reason: str,
conversation_note: str = "") -> dict[str, Any] | None:
"""统一建档入口:世界侧快照 + 对话侧完整快照成对入库(矩阵 52 行)。
建档后把新 checkpointId 锚定到当前活动分支(分支↔快照联动):
分支切换时可据此成对恢复该时刻的世界+对话侧。
"""
meta = get_checkpoints().create(
store.data, label=label, reason=reason,
conversation_note=conversation_note,
conversation_side=_conversation_side_snapshot(),
)
if meta:
try:
from server.state.branches import get_branches
bs = get_branches(getattr(store, "tenant_uuid", "platform"),
getattr(store, "world_key", "default"))
session_id = _active_session_id()
if session_id:
bs.ensure_session(session_id)
active_id = bs.active(session_id)
branch = bs._branch(session_id, active_id)
branch["checkpointId"] = meta["pairId"]
bs._write()
except Exception:
pass
return meta
class _FolderCandidateStore:
"""Process-local candidate world; save is a no-op until the P2 transaction commits."""
def __init__(self, source: Any) -> None:
self.data = copy.deepcopy(source.data)
self.tenant_uuid = getattr(source, "tenant_uuid", "platform")
self.world_key = getattr(source, "world_key", "default")
self.path = getattr(source, "path", "memory://folder-candidate")
self._counters = copy.deepcopy(getattr(source, "_counters", {}))
self._fallback_max = max(
(
int(row.get("id"))
for rows in self.data.values()
if isinstance(rows, list)
for row in rows
if isinstance(row, dict) and isinstance(row.get("id"), int)
),
default=0,
)
def next_id(self, kind: str) -> int:
current = max(int(self._counters.get(kind) or 0), self._fallback_max)
self._counters[kind] = current + 1
self._fallback_max = max(self._fallback_max, self._counters[kind])
return self._counters[kind]
def save(self) -> None:
return None
def _active_session_id() -> str | None:
"""当前活动会话 id(尽力而为;无认证上下文时返回 None)。"""
try:
from server.state.projects import get_project_store
snapshot = get_project_store().snapshot(include_messages=False)
return snapshot.get("activeSessionId")
except Exception:
return None
def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor: str,
note: str | None = None, *, governance_service: Any | None = None) -> str:
"""Execute or reject a staged P2/P3 action through the sole confirmation path.
P3 actions execute only after the trusted decision record carries the final grant.
note:审批意见(可选),随批准/驳回记录进审批历史。
Returns: 面向用户的结果文案。
"""
target_store = harness.resolve_confirmation_store(confirm_id, store)
try:
pending = harness.take_confirmation(confirm_id, approve=approve, note=note)
except PermissionError as exc:
try:
from server.agent_core.plan_orchestration import decide_plan_node
decide_plan_node(
confirm_id=confirm_id,
approve=False,
note=f"approval envelope denied: {exc}",
actor=actor,
)
except Exception:
pass
write_audit(
store.data,
store.next_id,
actor=actor,
category="GATE",
action="approval.envelope.denied",
target={"type": "CONFIRMATION", "id": confirm_id},
power="P2",
rationale={"confirmId": confirm_id, "reason": str(exc)},
result="DENIED",
)
store.save()
return f"确认信封证据校验未通过,未执行任何变更:{exc}"
if pending is None: # 过期/重复点击
return "该确认卡已失效(可能已处理过)。"
if target_store is not None:
store = target_store
action, params = pending["action"], pending["params"] # 解构动作
evidence_refs = list(pending.get("evidenceRefs") or params.get("evidenceRefs") or [])
staged_snapshot = pending.get("beforeSnapshot")
cp = {"pairId": staged_snapshot} if staged_snapshot else None # GATE audit uses staged snapshot
folder_envelope = (
params.get("boundAction") == "folder.schedule"
or "folderPayloadDigest" in params
or any(str(ref).startswith("folder-") for ref in evidence_refs)
)
if folder_envelope and action != "folder.schedule":
write_audit(
store.data,
store.next_id,
actor=actor,
category="GATE",
action="folder.schedule.envelope.denied",
target={"type": "FOLDER_PACK", "id": params.get("projectId") or "project"},
power="P2",
rationale={"confirmId": confirm_id, "reason": "ACTION_BINDING_MISMATCH"},
result="DENIED",
before_snapshot=str(staged_snapshot) if staged_snapshot else None,
evidence_refs=evidence_refs,
)
store.save()
return "工程目录确认信封已损坏,未执行任何变更。"
if pending.get("approvalDenied") and pending.get("separationRequired"):
write_audit(
store.data,
store.next_id,
actor=actor,
category="GATE",
action=action + ".confirm.denied",
target=params,
power="P3",
rationale={
"confirmId": confirm_id,
"reason": "SEPARATION_OF_DUTIES",
"approvalStep": pending.get("approvalStep"),
},
result="DENIED",
before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
return "P3 二次审批必须由另一名用户完成;当前批准未执行,确认卡仍有效。"
if approve and pending.get("needsSecondConfirm"):
write_audit(store.data, store.next_id, actor=actor, category="GATE",
action=action + ".confirm.first", target=params, power="P3",
rationale={"confirmId": confirm_id, "approvalStep": 1,
"requiredApprovals": pending.get("requiredApprovals"),
"approvers": pending.get("approvals")},
result="PENDING_SECOND_CONFIRM", before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
return "第一重确认已记录。该 P3 外部副作用仍未执行,请再次批准完成二次确认。"
if not approve: # ---- 驳回分支 ----
# Agent 自动编排(矩阵 110 行):驳回 → L2 Plan 节点 FAILED(尽力而为)
try:
from server.agent_core.plan_orchestration import decide_plan_node
decide_plan_node(confirm_id=confirm_id, approve=False, note=note, actor=actor)
except Exception:
pass
write_audit(store.data, store.next_id, actor=actor, category="GATE", action=action + ".reject",
target=params, power=harness.power_of(action),
rationale={"confirmId": confirm_id}, result="DENIED", before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs) # 驳回留痕
store.save() # 落盘
return "已驳回,未做任何变更。"
# ---- 统一证据强校验(§3.4 证据链 v1):fail closed,缺项/漂移/版本失效拒绝 ----
try:
harness.verify_pending_evidence(
pending,
checkpoint_store=get_checkpoints(),
version_lookup=lambda vid: _version_resolvable(store.data, vid),
current_world=store.data,
)
except PermissionError as exc:
write_audit(store.data, store.next_id, actor=actor, category="GATE",
action=action + ".evidence.denied", target=params,
power=harness.power_of(action),
rationale={"confirmId": confirm_id, "reason": str(exc)},
result="DENIED", before_snapshot=str(cp["pairId"]) if cp else None,
evidence_refs=evidence_refs)
store.save()
return f"证据校验未通过,未执行任何变更:{exc}"
if action == "folder.schedule":
from server.aps_domain.folder_pack import verify_folder_schedule_binding
try:
binding = verify_folder_schedule_binding(params, store.data)
expected_refs = {
f"folder-source:{binding['sourceManifestDigest']}",
f"folder-world:{binding['folderWorldFingerprint']}",
f"folder-payload:{binding['folderPayloadDigest']}",
f"folder-snapshot:{binding['beforeSnapshot']}",
}
if not staged_snapshot:
raise PermissionError("工程目录确认缺少审批前快照,请重新发起")
if binding["beforeSnapshot"] != str(staged_snapshot):
raise PermissionError("工程目录确认的审批前快照不一致,请重新发起")
if str(params.get("projectId")) != str(pending.get("projectId")):
raise PermissionError("工程目录确认的项目作用域不一致,请重新发起")
if str(params.get("sessionId")) != str(pending.get("sessionId")):
raise PermissionError("工程目录确认的会话作用域不一致,请重新发起")
if str(params.get("targetWorldKey")) != str(pending.get("worldKey")):
raise PermissionError("工程目录确认的世界作用域不一致,请重新发起")
if not expected_refs.issubset(set(evidence_refs)):
raise PermissionError("工程目录确认缺少完整证据引用,请重新发起")
except (OSError, PermissionError, ValueError) as exc:
try:
from server.agent_core.plan_orchestration import decide_plan_node
decide_plan_node(
confirm_id=confirm_id,
approve=False,
note=f"folder evidence denied: {exc}",
actor=actor,
)
except Exception:
pass
write_audit(
store.data,
store.next_id,
actor=actor,
category="GATE",
action="folder.schedule.evidence.denied",
target={"type": "FOLDER_PACK", "id": params.get("projectId") or "project"},
power="P2",
rationale={"confirmId": confirm_id, "reason": str(exc)},
result="DENIED",
before_snapshot=str(staged_snapshot) if staged_snapshot else None,
evidence_refs=evidence_refs,
)
store.save()
return f"工程目录证据校验未通过,未执行任何变更:{exc}"
# folder.schedule is approved only after its candidate world commits.
if action != "folder.schedule":
try:
from server.agent_core.plan_orchestration import decide_plan_node
decide_plan_node(confirm_id=confirm_id, approve=True, note=note, actor=actor)
except Exception:
pass
if action in {
"governance.rule.create",
"governance.rule.update",
"governance.rule.delete",
"governance.rule.enable",
"governance.rule.disable",
}:
from server.aps_domain.governance_rules import GovernanceRuleService
from server.auth.context import get_identity
identity = get_identity(required=True)
authenticated_actor = identity.username or str(identity.user_id)
cp = _create_checkpoint(
store,
label="治理规则变更前基线",
reason=f"auto:{action}",
conversation_note=f"批准执行 {action}",
)
service = governance_service or GovernanceRuleService(store_provider=lambda: store)
try:
result = service.apply_confirmed_change(
action,
params,
actor=authenticated_actor,
confirm_id=confirm_id,
before_snapshot=str(cp["pairId"]),
evidence_refs=evidence_refs,
)
except Exception:
try:
get_checkpoints().delete(str(cp["pairId"]))
except (KeyError, OSError, TypeError, ValueError):
pass
raise
if action == "governance.rule.delete":
return f"治理规则 {result['ruleId']} 已删除 ✅(变更前已自动建档)。"
return f"治理规则 {result['ruleId']} 已更新 ✅(变更前已自动建档)。"
if action == "schedule.publish": # ---- approve publication ----
track = str(params.get("track") or "fixed").lower()
version_key = "flexScheduleVersions" if track == "flex" else "scheduleVersions"
version = next(
(row for row in store.data.get(version_key) or [] if row.get("id") == params.get("versionId")),
None,
)
if version is None:
return "发布被拒绝:确认卡绑定的排产版本已不存在。"
version_status = str(version.get("status") or "").upper()
if version_status in {"PUBLISHED", "DISPATCHED"}:
cp = _create_checkpoint(
store,
label=f"发布幂等前基线 {version.get('versionNo') or version['id']}",
reason="auto:publish-idempotent",
conversation_note=f"重复批准发布 {version.get('versionNo') or version['id']}",
)
write_audit(
store.data,
store.next_id,
actor=actor,
category="WORLD_WRITE",
action="schedule.publish",
target={
"type": "FLEX_VERSION" if track == "flex" else "SCHEDULE_VERSION",
"id": version["id"],
},
power="P2",
rationale={
"confirmId": confirm_id,
"approver": actor,
"track": track,
"idempotent": True,
"existingStatus": version_status,
},
result="SUCCESS",
before_snapshot=str(cp["pairId"]) if cp else None,
evidence_refs=evidence_refs,
)
store.save()
return f"版本 {version.get('versionNo') or version['id']} 已发布,本次批准按幂等执行并留痕。"
if version_status != "DRAFT":
return f"发布被拒绝:版本 {version.get('versionNo') or version['id']} 已不再是 DRAFT。"
staged_evidence = [
ref for ref in evidence_refs if str(ref).startswith("schedule-evidence:")
]
current_evidence_ref = None
if track == "flex" or staged_evidence:
from server.aps_domain.mes import validate_dispatchable_version
validation = validate_dispatchable_version(store.data, track, int(version["id"]))
if not validation.get("publishReady"):
reasons = validation.get("publishBlockingReasons") or []
detail = "\uFF1B".join(
str(reason.get("message") or reason.get("code"))
for reason in reasons[:5]
)
track_name = "柔性" if track == "flex" else "固定"
return f"{track_name}版本发布被结构校验拦截:{detail or '版本不可发布'}"
current_evidence_ref = validation.get("evidenceRef")
if staged_evidence and current_evidence_ref not in staged_evidence:
track_name = "柔性" if track == "flex" else "固定"
return f"{track_name}版本发布被拒绝:出卡后排产结构或证据已漂移。"
else:
from server.aps_domain.constraints import hard_blocking_conflicts
blockers = hard_blocking_conflicts(
store.data, version_id=version["id"], track="fixed",
)
if blockers:
return (
f"发布被硬约束门禁拦截:仍有 {len(blockers)} 项未解决硬约束冲突。"
"请先处理冲突或调整约束配置。"
)
cp = _create_checkpoint(
store,
label=f"发布前基线 {version.get('versionNo') or version['id']}",
reason="auto:publish",
conversation_note=f"批准发布 {version.get('versionNo') or version['id']}",
)
version["status"] = "PUBLISHED"
from datetime import datetime
from server.timeutil import fmt_dt
version["publishedAt"] = fmt_dt(datetime.now())
if track == "fixed":
for production_order in store.data.get("productionOrders") or []:
if production_order.get("schedulingVersionId") == version["id"]:
production_order["status"] = "CONFIRMED"
write_audit(
store.data,
store.next_id,
actor=actor,
category="WORLD_WRITE",
action="schedule.publish",
target={
"type": "FLEX_VERSION" if track == "flex" else "SCHEDULE_VERSION",
"id": version["id"],
},
power="P2",
rationale={
"confirmId": confirm_id,
"approver": actor,
"track": track,
"validationEvidence": current_evidence_ref,
},
before_snapshot=str(cp["pairId"]) if cp else None,
evidence_refs=evidence_refs,
)
store.save()
suffix = "MES 下发仍需独立 P3 双人确认。" if track == "flex" else "生产订单已转入执行准备。"
return f"版本 {version.get('versionNo') or version['id']} 已发布 ✅ {suffix}"
if action == "data.reset": # ---- 批准:重置数据 ----
# 回滚防线:重置前自动建档(重置本身也可被撤销)
cp = _create_checkpoint(store, 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}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs) # 重置留痕
store.save() # 审计落盘
return "数据已重置为种子状态 ✅(重置前状态已自动存档,可回滚)"
if action == "checkpoint.rollback": # ---- 批准:回滚到检查点 ----
pair = get_checkpoints().get(params["pairId"]) # 取目标快照(完整世界)
if pair is None: # 快照不存在(被淘汰)
return "目标检查点不存在(可能已被容量策略淘汰)。"
# 回滚防线:回滚前先把"现在"也存档(允许撤销这次回滚——时间旅行可往返 §4.4)
cp = _create_checkpoint(store, label="回滚前状态", reason="auto:rollback",
conversation_note=f"回滚到 {pair['label']}")
store.restore(pair["world"]) # 整体替换主干世界(成对恢复的世界侧)
_restore_conversation_side(pair.get("conversationSide")) # 成对恢复对话侧(矩阵 52 行)
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"]}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs) # 回滚留痕
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
cp = _create_checkpoint(store, 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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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:采用紧急插单(LNS 局部 / 升级全量)----
from server.aps_domain.rush import apply_rush
lns = params.get("lns") or {}
cp = _create_checkpoint(store, label="插单采用前基线", reason="auto:rush.apply",
conversation_note="采用紧急插单前自动建档")
if lns.get("status") == "LOCAL": # LNS 成功 → 局部方案入 DRAFT 草稿(窗口内最小扰动)
from server.aps_domain.lns import apply_lns_local
applied = apply_lns_local(store.data, store.next_id, params)
order = applied["order"]
result = applied["result"]
d = applied["disturbance"] or {}
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"),
"mode": "LNS_LOCAL",
"lnsId": lns.get("lnsId"),
"movedOrderCount": d.get("movedOrderCount"),
"shiftedHours": d.get("shiftedHours"),
"tardinessDelta": d.get("tardinessDelta")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
_LAST_RUSH_EVAL.clear()
return (f"LNS 局部插单已采用 ✅ {order['orderNo']}(急单)已入池;"
f"窗口内最小扰动:移动 {d.get('movedOrderCount', 0)} 单 · 位移 {d.get('shiftedHours', 0)}h · "
f"延迟变化 {d.get('tardinessDelta', 0):+.1f}h;草稿版本 {result.versionNo}。可用检查点回滚到采用前。")
applied = apply_rush(store.data, store.next_id, params) # 升级(ESCALATE)/ 直接采用 → 全量重排
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"),
"mode": "FULL_RESCHEDULE" if lns.get("status") == "ESCALATE" else "DIRECT",
"escalateReasons": lns.get("escalateReasons")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
_LAST_RUSH_EVAL.clear()
prefix = "LNS 超阈值升级全量重排 ✅ " if lns.get("status") == "ESCALATE" else "插单已采用 ✅ "
return (prefix + 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
cp = _create_checkpoint(store, 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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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
cp = _create_checkpoint(store, 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"]}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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
cp = _create_checkpoint(store, 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}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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
cp = _create_checkpoint(store, 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"]}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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.importers.sql_pack import apply_sql_pack_to_world
candidate = _FolderCandidateStore(store)
batches = copy.deepcopy(params.get("batches") or [])
sql_payload = copy.deepcopy(params.get("sqlPayload"))
kangni_payload = copy.deepcopy(params.get("kangniPayload"))
kangni_detected = params.get("kangniDetected") is True
if kangni_detected:
batches = []
sql_payload = None
sql_already = isinstance(sql_payload, dict) and bool(sql_payload)
applied = {"total": 0, "summary": {}}
applied_meta: dict[str, Any] = {}
resource_quality: dict[str, Any] = {}
try:
if kangni_detected:
from server.aps_domain.kangni_intake import apply_site_payload_to_world
applied_meta = apply_site_payload_to_world(
candidate.data,
kangni_payload,
clear_all=True,
)
for sales_order in candidate.data.get("salesOrders") or []:
sales_order["source"] = "SITE"
resource_quality = copy.deepcopy(
((kangni_payload or {}).get("meta") or {}).get("resourceQuality") or {}
)
applied = {
"total": int(applied_meta.get("orderCount") or 0),
"summary": {
"salesOrders": len(candidate.data.get("salesOrders") or []),
"flexOrders": len(candidate.data.get("flexOrders") or []),
"flexRoutings": len(candidate.data.get("flexRoutings") or []),
"flexEquipment": len(candidate.data.get("flexEquipment") or []),
"flexMolds": len(candidate.data.get("flexMolds") or []),
},
}
elif sql_already:
apply_sql_pack_to_world(candidate.data, sql_payload, replace=True)
if batches and not kangni_detected:
applied = apply_import_commit(candidate.data, candidate.next_id, batches)
from server.aps_domain.readiness import check_readiness, readiness_text
ready = check_readiness(candidate.data)
has_core = (
bool(candidate.data.get("flexOrders"))
and bool(candidate.data.get("flexRoutings"))
and any(
e.get("status") == "RUNNING"
for e in (candidate.data.get("flexEquipment") or [])
)
)
if ready["summary"].get("ready", 0) == 0 and not has_core:
raise ValueError("导入后仍不具备开排条件:" + readiness_text(ready))
from server.aps_domain.flex import run_flex_schedule
result = run_flex_schedule(
candidate,
sort_mode=params.get("sortMode") or "BOTTLENECK",
actor=actor,
trial=True,
)
if kangni_detected:
production_ready = bool(resource_quality.get("productionReady"))
result["trialOnly"] = True
result["productionReady"] = production_ready
result["resourceQuality"] = copy.deepcopy(resource_quality)
version_id = result.get("versionId")
for version in candidate.data.get("flexScheduleVersions") or []:
if version.get("id") == version_id:
version["trialOnly"] = True
version["productionReady"] = production_ready
version["resourceQuality"] = copy.deepcopy(resource_quality)
break
write_audit(
candidate.data,
candidate.next_id,
actor=actor,
category="WORLD_WRITE",
action="folder.schedule.import",
target={"type": "FOLDER_PACK", "id": params.get("projectId") or "project"},
power="P2",
rationale={
"confirmId": confirm_id,
"approver": actor,
"summary": applied.get("summary"),
"sqlApplied": sql_already,
"kangniDetected": kangni_detected,
"trialOnly": kangni_detected,
"productionReady": (
bool(resource_quality.get("productionReady"))
if kangni_detected else None
),
"sourceManifestDigest": params.get("sourceManifestDigest"),
"folderPayloadDigest": params.get("folderPayloadDigest"),
"frozenPayload": True,
},
before_snapshot=str(cp["pairId"]) if cp else None,
evidence_refs=evidence_refs,
)
except Exception as exc: # noqa: BLE001 - candidate world is discarded below
try:
from server.agent_core.plan_orchestration import decide_plan_node
decide_plan_node(
confirm_id=confirm_id,
approve=False,
note=f"folder execution denied: {exc}",
actor=actor,
)
except Exception:
pass
write_audit(
store.data,
store.next_id,
actor=actor,
category="GATE",
action="folder.schedule.execute.denied",
target={"type": "FOLDER_PACK", "id": params.get("projectId") or "project"},
power="P2",
rationale={"confirmId": confirm_id, "reason": str(exc)},
result="DENIED",
before_snapshot=str(cp["pairId"]) if cp else None,
evidence_refs=evidence_refs,
)
store.save()
return f"工程目录导入与试排失败,已回滚且未写入业务数据:{exc}"
previous_world = store.data
previous_counter_state = {
attr: copy.deepcopy(getattr(store, attr))
for attr in ("_counters", "_ids")
if hasattr(store, attr)
}
store.data = candidate.data
try:
reset_counters = getattr(store, "_reset_counters", None)
if callable(reset_counters):
reset_counters()
else:
committed_counters = copy.deepcopy(getattr(candidate, "_counters", {}))
for attr, previous in previous_counter_state.items():
synchronized = copy.deepcopy(previous)
for kind, value in committed_counters.items():
synchronized[kind] = max(int(synchronized.get(kind) or 0), int(value))
setattr(store, attr, synchronized)
store.save()
except Exception as exc: # noqa: BLE001 - restore old in-memory world after atomic write failure
store.data = previous_world
for attr, previous in previous_counter_state.items():
setattr(store, attr, previous)
try:
from server.agent_core.plan_orchestration import decide_plan_node
decide_plan_node(
confirm_id=confirm_id,
approve=False,
note=f"folder save denied: {exc}",
actor=actor,
)
except Exception:
pass
write_audit(
store.data,
store.next_id,
actor=actor,
category="GATE",
action="folder.schedule.save.denied",
target={"type": "FOLDER_PACK", "id": params.get("projectId") or "project"},
power="P2",
rationale={"confirmId": confirm_id, "reason": str(exc)},
result="DENIED",
before_snapshot=str(cp["pairId"]) if cp else None,
evidence_refs=evidence_refs,
)
try:
store.save()
except Exception: # noqa: BLE001 - original disk image remains authoritative
pass
return f"工程目录导入与试排落盘失败,已恢复原业务状态:{exc}"
try:
from server.agent_core.plan_orchestration import decide_plan_node
decide_plan_node(confirm_id=confirm_id, approve=True, note=note, actor=actor)
except Exception:
pass
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, trial=True)
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
cp = _create_checkpoint(store, 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}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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
cp = _create_checkpoint(store, 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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
return r.get("message") or "调程已执行 ✅"
if action == "schedule.adjust.commit":
from server.aps_domain.adjust import commit_fixed_adjust
cp = _create_checkpoint(store, 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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
return r.get("message") or "调程已执行 ✅"
if action == "sap.sync.inbound":
from server.aps_domain.sap_sync import apply_inbound
cp = _create_checkpoint(store, 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
# Pass the frozen decision fields verbatim; never create a final-stage snapshot.
try:
r = apply_outbound(
store,
actor=actor,
confirm_id=confirm_id,
approval_params=params,
execution_grant=pending.get("executionGrant"),
requester=pending.get("requester"),
approvals=pending.get("approvals"),
before_snapshot=pending.get("beforeSnapshot"),
checkpoint_store=get_checkpoints(),
)
except PermissionError as exc:
return f"SAP 出站证据校验未通过,未执行外部回写:{exc}"
return r.get("message") or "SAP 出站完成 ✅"
if action == "mes.dispatch":
from server.aps_domain.mes import apply_dispatch
checkpoints = get_checkpoints()
checkpoint = 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,
confirm_id=confirm_id,
execution_grant=str(pending.get("executionGrant") or ""),
version_id=int(params.get("versionId")),
before_snapshot=str(checkpoint["pairId"]),
evidence_refs=list(params.get("evidenceRefs") or []),
checkpoint_store=checkpoints,
)
return r.get("message") or "MES 下发完成 ✅"
if action == "mrp.release": # ---- 批准:MRP 建议单下达 ----
from server.aps_domain.mrp import apply_mrp_release
cp = _create_checkpoint(store, 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"]}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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
cp = _create_checkpoint(store, 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"))}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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
cp = _create_checkpoint(store, 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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
lw = applied["after"]["customerLevelWeights"]
return (f"排产参数已更新 ✅ 客户等级权重 VIP={lw['VIP']} / A={lw['A']} / B={lw['B']} / C={lw['C']}。"
"只影响后续新排产版本(变更前已自动建档)。")
if action == "param.experiment.promote": # ---- 批准:参数实验升级(矩阵 87 P2 门禁) ----
from server.agent_core.param_opt import DEFAULT_TOLERANCE, ParameterOptimizer
experiment_id = str(params.get("experimentId") or "")
existing = next((e for e in store.data.get("paramExperiments") or []
if e.get("id") == experiment_id), None)
if existing is None:
return f"未找到参数实验:{experiment_id},未做任何变更。"
if existing.get("status") != "GRAY":
return (f"参数实验 {experiment_id} 状态为 {existing.get('status')},"
"只能从 GRAY 升级,未做任何变更。")
cp = _create_checkpoint(store, label="参数实验升级前基线",
reason="auto:param.experiment.promote",
conversation_note="批准参数实验 GRAY→ACTIVE")
opt = ParameterOptimizer(
tolerance=float(existing.get("tolerance") or DEFAULT_TOLERANCE),
strategy=existing.get("strategy") or "COMPREHENSIVE")
rec = opt.finalize(store.data, experiment_id, promote_full=True)
v_replay = rec.get("validationReplay") or {}
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
action="param.promote",
target={"type": "PARAM_EXPERIMENT", "id": experiment_id},
power="P2",
rationale={"confirmId": confirm_id, "approver": actor,
"status": rec.get("status"),
"rollbackReason": rec.get("rollbackReason"),
"validationReplay": {
"degraded": v_replay.get("degraded"),
"baselineKpi": v_replay.get("baselineKpi"),
"candidateKpi": v_replay.get("candidateKpi"),
"deltas": v_replay.get("deltas"),
}},
before_snapshot=str(cp["pairId"]) if cp else None,
evidence_refs=evidence_refs)
store.save()
if rec.get("status") == "ROLLED_BACK":
return (f"参数实验 {experiment_id} 已批准升级,但验证集回放退化,"
f"已自动回滚并保持基线参数({rec.get('rollbackReason')})。")
status_cn = "全量生效(FULL)" if rec.get("status") == "FULL" else "ACTIVE"
return (f"参数实验 {experiment_id} 已升级为 {status_cn} ✅ "
"候选参数经验证集回放通过后生效(升级前已自动建档)。")
if action == "constraint.profile.save": # ---- 批准:约束剖面(SC-04) ----
from server.aps_domain.constraints import apply_profile_save
cp = _create_checkpoint(store, 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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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"]
cp = _create_checkpoint(store, 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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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)
# 尝试建向量索引(无嵌入后端则跳过)
# 复用模块级 get_knowledge(顶部已导入),避免局部 import 遮蔽导致 F823/NameError
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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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 "实测"
# 写前快照(统一证据链:P2 写世界必须有回滚锚点)
cp = _create_checkpoint(store, label="工时维护前基线",
reason="auto:flex.time.update",
conversation_note="批准更新工时")
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}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
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
cp = _create_checkpoint(store, 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")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
return (f"工艺路线已生成 ✅ {applied['productCode']} ← 模板「{applied['templateName']}」"
f"({applied['steps']} 步,工时标「模板」,出处 {applied.get('assetId') or '内置'})。"
f"可说「数据齐备度」复查后「柔性排产」。")
if action == "drawing.master.apply":
drawing_id = str(params.get("drawingId") or "")
selected = list(params.get("selectedCandidates") or [])
source_hash = str(params.get("sourceSha256") or "")
created = 0
linked = 0
materials = store.data.setdefault("materials", [])
links = store.data.setdefault("drawingLinks", [])
from server.aps_domain.drawing_understanding import build_drawing_link_evidence_records, utc_now # noqa: I001
for candidate in selected:
if candidate.get("candidateType") != "MATERIAL":
continue
code = str(candidate.get("code") or "").strip()
name = str(candidate.get("name") or "").strip()
if not code or not name:
continue
row = next((m for m in materials if str(m.get("code") or "") == code), None)
if row is None:
row = {"id": store.next_id("material"), "code": code, "name": name,
"spec": "", "type": "SEMI_FINISHED", "unit": "piece", "stock": 0,
"inTransit": 0, "safetyStock": 0, "procurementLeadTime": 0,
"productFamily": "DRAWING_PART", "status": "ACTIVE", "source": "DXF_P2_CONFIRMED"}
materials.append(row)
created += 1
link_key = (drawing_id, row.get("id"), source_hash)
if not any((x.get("drawingId"), x.get("materialId"), x.get("sourceSha256")) == link_key for x in links):
links.append({"id": store.next_id("drawingLink"), "drawingId": drawing_id,
"materialId": row.get("id"), "linkType": "MATERIAL",
"targetType": "material", "targetId": row.get("id"), "targetRef": code,
"sourceSha256": source_hash, "revision": candidate.get("revision"),
"status": "CONFIRMED", "masterCommitted": True,
"confidence": round(min(float(candidate.get("confidence") or 0.0), 1.0), 3),
"confirmedBy": actor, "confirmedAt": utc_now(),
"evidence": {"contractVersion": "drawing-link-evidence.v1",
"confirmId": confirm_id,
"candidateId": candidate.get("candidateId"),
"candidateType": candidate.get("candidateType"),
"code": code, "sourceSha256": source_hash,
"drawingId": drawing_id}})
linked += 1
evidence_records = build_drawing_link_evidence_records(
store.data,
drawing_id=drawing_id,
source_sha256=source_hash,
selected_candidates=selected,
actor=actor,
confirm_id=confirm_id,
next_id=store.next_id,
)
for link in evidence_records:
link_key = (drawing_id, link.get("linkType"), link.get("targetType"),
link.get("targetRef"), source_hash)
if not any((x.get("drawingId"), x.get("linkType"), x.get("targetType"),
x.get("targetRef"), x.get("sourceSha256")) == link_key for x in links):
links.append(link)
linked += 1
for change in store.data.get("drawingCandidates") or []:
if change.get("drawingAssetId") == drawing_id:
change["status"] = "PARTIALLY_COMMITTED" if created or linked else "REVIEW_CONFIRMED"
change["confirmedCandidateIds"] = [x.get("candidateId") for x in selected]
for asset in store.data.get("drawingAssets") or []:
if asset.get("id") == drawing_id:
asset["status"] = "COMMITTED" if created or linked else "REVIEW_REQUIRED"
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
action="drawing.master.apply", target={"type": "DRAWING", "id": drawing_id}, power="P2",
rationale={"confirmId": confirm_id, "sourceSha256": source_hash, "materialCreated": created,
"linksCreated": linked, "deferredIncompleteCandidates": sum(1 for x in selected if x.get("candidateType") != "MATERIAL")},
before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
return (f"Drawing review applied: materials={created}, links={linked}; "
"incomplete BOM/routing candidates remain pending and were not fabricated.")
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 _external_inputs_hash(world: dict, summary: dict) -> str | None:
"""EXTERNAL 引擎输入包指纹(可复算):柔性订单数 + 产品 + skillId 稳定投影。"""
import hashlib, json as _json
try:
payload = {
"flexOrders": len(world.get("flexOrders") or []),
"products": sorted({p.get("code") or p.get("name") or "" for p in (world.get("products") or [])}),
"skillId": summary.get("skillId") or "",
}
return hashlib.sha256(
_json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
except Exception:
return None
def _build_external_trace(store: WorldStore, summary: dict) -> dict:
"""构造 flex.schedule.external 可追溯链(尽力而为;失败返回空链摘要不影响排产)。
链哈希只含确定性信息:版本/run/EXTERNAL/输入包指纹/结果计数;
skill 上游墙钟等不确定量不进入链哈希。
"""
try:
from server.agent_core.evidence import EvidenceItem, trace_chain, evidence_ref
version_ref = evidence_ref("schedule-version", summary["versionId"])
run_ref = evidence_ref("run", summary.get("runId") or summary["versionId"])
items = [
EvidenceItem(kind="schedule-version", ref=str(summary["versionId"]),
version=str(summary["versionId"])),
EvidenceItem(kind="run", ref=run_ref, runId=str(summary.get("runId") or summary["versionId"]),
engine="EXTERNAL",
meta={"skillId": summary.get("skillId") or "",
"conflictCount": summary.get("conflictCount"),
"avgUtilization": summary.get("avgUtilization"),
"onTimeCount": summary.get("onTimeCount")}),
EvidenceItem(kind="algorithm", ref=summary.get("skillId") or "EXTERNAL",
version=summary.get("skillId") or "EXTERNAL",
inputsHash=_external_inputs_hash(store.data, summary)),
]
chain = trace_chain(items)
return {
"chainHash": chain["chainHash"],
"count": chain["count"],
"summary": [{"kind": it["kind"], "ref": it["ref"]} for it in chain["items"]],
"items": chain["items"],
"versionRef": version_ref,
}
except Exception:
return {"chainHash": None, "count": 0, "summary": [], "versionRef": None}
def _external_audit_evidence(summary: dict, trace: dict) -> list[str]:
"""合并 EXTERNAL 结果引用(skill/run)与 trace 的 schedule-version 引用(去重保序)。"""
refs = []
skill_id = summary.get("skillId")
run_id = summary.get("runId")
if skill_id:
refs.append(f"skill:{skill_id}")
if run_id:
refs.append(f"run:{run_id}")
version_ref = trace.get("versionRef")
if version_ref and version_ref not in refs:
refs.append(version_ref)
return refs
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))
ext_trace = _build_external_trace(store, result)
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"),
"traceChainHash": ext_trace["chainHash"],
"traceCount": ext_trace["count"],
"traceSummary": ext_trace["summary"],
"traceItems": ext_trace.get("items"),
"evidence": _external_audit_evidence(result, ext_trace)})
store.save()
else:
result = run_flex_schedule(store, sort_mode=mode, order_ids=order_ids or None,
actor=actor, window=window) # P1
mode_key = str(result.get("sortMode") or result.get("engineType") or mode or "CLOSED_LOOP")
mode_cn = _FLEX_MODE_CN.get(mode_key, mode_key)
solve_status = str(result.get("solveStatus") or "FEASIBLE").upper()
if result.get("sortMode") == "EXTERNAL" or use_ext:
mode_cn = f"外部算法({result.get('skillId') or 'skill'})"
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(float(result.get("avgUtilization") or 0) * 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 "可说「生成排产方案报告」下载。"
guide_block = None
if solve_status in {"BLOCKED", "REJECTED", "INFEASIBLE", "ERROR"}:
planning_summary = ((result.get("planning") or {}).get("summary") or {})
blocker_counts = planning_summary.get("blockerCounts") or {}
blocker_text = "、".join(
f"{code}x{count}"
for code, count in sorted(blocker_counts.items())[:5]
)
from server.aps_domain.guidance import scheduling_data_guide
data_guide = scheduling_data_guide(store.data)
if data_guide.get("steps"):
guide_block = UIBlock(blockId="guidance-data-missing", type="guidance", props=data_guide)
text = (
f"闭环排产被阻断 ⚠️ {focus_hint}版本 {result['versionNo']}:"
f"制造需求 {int(result.get('demandCount') or 0)} 项,"
f"准入 {int(result.get('admittedDemandCount') or 0)} 项,"
f"未排 {int(result.get('unscheduledDemandCount') or 0)} 项;"
f"未生成可发布生产工单或 MES 下发对象。"
+ (f"主要阻断:{blocker_text}。" if blocker_text else "")
+ dl_hint
+ ("\n\n别担心,我把还缺的东西列在下面,你按顺序补就行。" if guide_block else "")
)
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] + ([guide_block] if guide_block else []),
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)
table = matrix_from_flex_rows(
cmp["rows"], hint=cmp.get("hint"), data_quality=cmp.get("dataQuality"))
block = UIBlock(blockId="flex-compare", type="flex-compare",
props={"rows": cmp["rows"], "hint": cmp["hint"], "compressedDue": cmp["compressedDue"],
"dataQuality": cmp.get("dataQuality"),
"productionReady": table["productionReady"],
"recommendationStatus": table["recommendationStatus"],
"table": table})
recommendation_id = table.get("recommendationId")
best = next((row for row in cmp["rows"] if row.get("sortMode") == recommendation_id), None)
if best is None:
text = (
"五模式对比完成(已压缩交期制造压力)。当前没有可推荐策略。"
f"{table['recommendationReason']}已打开对比表视口。"
)
elif table["recommendationStatus"] == "TRIAL_ONLY":
text = (
"五模式对比完成(已压缩交期制造压力)。当前仅供试排参考,试排较优:"
f"【{best['label']}】延迟 {best['totalTardiness']}h、"
f"准时 {best['onTimeCount']}/{best['orderCount']}。"
f"{table['recommendationReason']};不能据此下达生产计划。已打开对比表视口。"
)
else:
text = (
"五模式对比完成(已压缩交期制造压力)。当前推荐:"
f"【{best['label']}】延迟 {best['totalTardiness']}h、"
f"准时 {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)
def _ref_failure_text(exc: RefResolutionError) -> str:
"""引用解析失败的显式中文说明(fail closed:不吞掉、不假装命中)。"""
if exc.kind == "knowledge":
if "not-wired" in (exc.reason or ""):
return ("「@知识」引用解析失败:知识库检索暂未接线,无法解析该引用,"
"未编造任何知识内容。请联系管理员确认知识库可用后再试。")
return (f"「@知识」引用解析失败:知识库未命中《{exc.target_id}》,未编造任何内容。"
"可回复「知识库」查看资产清单,或核对资产 ID/标题关键词后重试。")
label = {"session": "会话", "scenario": "方案",
"version": "版本", "report": "报告"}.get(exc.kind, exc.kind)
return (f"「@{label}」引用解析失败:目标《{exc.target_id}》不存在或无权访问"
f"({exc.reason}),本轮处理已停止,请核对引用对象。")
def _resolve_refs_context(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply | None:
"""对话链路入口的引用解析/注入(矩阵 63 行剩余项:@知识 + 接线到对话链路)。
- 无引用:返回 None,原意图处理不受影响;
- 命中:解析结果注入 intent.params['_refs'](本轮上下文),并写 SESSION
审计(evidenceRefs 带 session:/scenario:/version:/report:/knowledge: 引用,
复用 round-15 trace/evidence 体系);
- 失败(@知识 未命中/未接线、引用对象删除/无权访问):写 FAILED 审计并
返回显式中文说明(fail closed,绝不含混通过或假装命中)。
"""
from server.agent_core.session_ref import RefResolutionError, parse_refs, resolve_all
user_text = str((intent.params or {}).get("_rawUserText")
or (intent.params or {}).get("query") or "")
refs = parse_refs(user_text)
if not refs:
return None
kb = get_knowledge()
kb.ensure_seed_assets()
def _knowledge_lookup(target: str) -> dict[str, Any] | None:
q = target.strip()
return kb.get(q) or kb.find_by_title(q) # assetId 精确 → 标题关键词
try:
resolved, evidence_refs = resolve_all(store.data, refs,
knowledge_lookup=_knowledge_lookup)
except RefResolutionError as exc:
write_audit(store.data, store.next_id, actor=actor, category="SESSION",
action="session.ref.resolve",
target={"type": "REF", "id": f"{exc.kind}:{exc.target_id}"},
power="P0", rationale={"reason": exc.reason, "refCount": len(refs)},
result="FAILED")
store.save()
return AgentReply(text=_ref_failure_text(exc))
intent.params["_refs"] = {
"refs": [r.ref for r in resolved],
"evidenceRefs": evidence_refs,
"sources": [r.source for r in resolved],
}
write_audit(store.data, store.next_id, actor=actor, category="SESSION",
action="session.ref.resolve",
target={"type": "REF", "id": "|".join(evidence_refs)},
power="P0",
rationale={"refCount": len(resolved), "refs": [r.ref for r in resolved]},
evidence_refs=evidence_refs)
store.save()
return None
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 有委外吗」。")
# ---- 结构化引用(矩阵 63 行):@会话/@方案/@版本/@报告/@知识 ----
# 对话链路入口统一解析引用并注入证据链;未命中/未接线显式中文说明(fail closed)。
ref_fail = _resolve_refs_context(store, intent, actor)
if ref_fail is not None:
return ref_fail
# ---- 工程图纸:目录解析 / SVG 预览 / 主数据候选 P2 暂存 ----
if name == "data.analyze" and intent.params.get("drawingAction") in ("analyze", "preview", "stage"):
from server.aps_domain.folder_pack import analyze_work_dir
report = analyze_work_dir(store.data, session_id)
drawings = report.get("drawings") or []
requested = str(intent.params.get("path") or "").strip().lower()
drawing = next((row for row in drawings if not requested or
str(row.get("path") or "").lower() == requested or
str(row.get("name") or "").lower() in requested), None)
if not drawing:
return AgentReply(text=(report.get("error") or
"当前工程目录没有识别到 DXF 图纸。请把图纸放入项目工程目录后说“解析图纸”。"))
if drawing.get("status") != "ok":
return AgentReply(text=f"图纸 {drawing.get('name')} 解析失败:{drawing.get('issue')}。表格排产未受影响。")
drawing_id = str(drawing.get("id") or "")
asset = next((row for row in (store.data.get("drawingAssets") or []) if row.get("id") == drawing_id), {})
change_set = next((row for row in (store.data.get("drawingCandidates") or [])
if row.get("drawingAssetId") == drawing_id), {})
props = {
"drawingId": drawing_id, "asset": asset, "drawing": drawing.get("drawing") or {},
"candidateCounts": drawing.get("candidateCounts") or {}, "candidates": change_set,
"previewUrl": f"/api/drawings/{drawing_id}/preview",
"stageUrl": f"/api/drawings/{drawing_id}/stage",
"reviewRequired": True, "writesMasterData": False,
}
drawing_block = UIBlock(
blockId=f"drawing-{drawing_id}", type="drawing-analysis", props=props,
actions=[], evidenceRefs=[str(asset.get("sha256") or "")])
if intent.params.get("drawingAction") == "stage":
selected_candidates = [*(change_set.get("materials") or []), *(change_set.get("bomReferences") or []), *(change_set.get("routingOperations") or [])]
payload = {"drawingId": drawing_id, "changeSetId": change_set.get("changeSetId"),
"sourceSha256": (asset.get("parsed") or {}).get("asset", {}).get("sha256") or asset.get("sha256"),
"selectedCandidates": selected_candidates,
"candidateIds": [row.get("candidateId") for row in selected_candidates],
"executionSupported": True}
lines = [
f"图纸:{drawing.get('name')}",
f"物料候选 {len(payload.get('materials') or [])} 项",
f"BOM 引用 {len(payload.get('bomReferences') or [])} 项(数量未解析,不得正式写入)",
f"工艺候选 {len(payload.get('routingOperations') or [])} 项(工时与资源待补全)",
"当前只生成 P2 工程确认,不直接写正式主数据。",
]
confirm_block = harness.stage_confirmation(
session_id, "drawing.master.apply", payload,
title="确认图纸主数据候选", summary_lines=lines)
write_audit(store.data, store.next_id, actor=actor, category="GATE",
action="drawing.master.stage", target={"type": "DRAWING", "id": drawing_id},
power="P2", rationale={"confirmId": confirm_block.props.get("confirmId"),
"executionSupported": True})
store.save()
return AgentReply(
text="已把图纸解析结果整理为物料、BOM 引用和工艺候选,并生成 P2 工程确认。当前不会写正式主数据;批准执行器仍列为待开发。",
blocks=[drawing_block, confirm_block])
store.save()
verb = "预览" if name == "drawing.preview" else "解析"
return AgentReply(
text=f"图纸 {drawing.get('name')} 已完成{verb}。候选数据需工程审核,未写入正式主数据。",
blocks=[drawing_block])
# ---- 排产(P1) ----
if name == "schedule.run":
return _run_schedule(store, intent, actor) # 试排并回执
# ---- 发布(P2 → 确认卡) ----
if name == "schedule.publish":
return _stage_publish(store, session_id, actor, prefer_flex=True)
# ---- 重置数据(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()
text = format_impact_text(impact)
blocks = [impact_to_block(impact)]
lns = impact.get("lns")
if lns:
from server.aps_domain.lns import format_lns_text, lns_to_block
text = text + "\n\n" + format_lns_text(lns)
blocks.append(lns_to_block(lns))
return AgentReply(
text=text,
blocks=blocks,
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"),
"lns": _LAST_RUSH_EVAL.get("lns"),
}
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 '(直接采用)'}",
*([f"LNS:局部修复(移动 {p['lns']['disturbance']['movedOrderCount']} 单,窗口内最小扰动,入 DRAFT 草稿)"]
if p.get("lns") and p["lns"].get("status") == "LOCAL"
else [f"LNS:升级全量重排({';'.join(p['lns'].get('escalateReasons') or [])})"]
if p.get("lns") else []),
],
)
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 = _create_checkpoint(store, 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
from server.aps_domain.guidance import scheduling_data_guide
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]},
)
guide = scheduling_data_guide(store.data)
blocks = [block]
if guide.get("steps"):
blocks.append(UIBlock(blockId="guidance-data-missing", type="guidance", props=guide))
return AgentReply(text=readiness_text(report), blocks=blocks)
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 ""),
next_id=store.next_id,
),
)
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 {},
"owner": deep.get("owner"),
"knowledgeIngest": deep.get("knowledgeIngest") or {},
})
return AgentReply(text=deep.get("markdown") or "分析完成。", blocks=[block])
if name == "folder.analyze":
from server.aps_domain.project_analyze import analyze_project_deep
deep = harness.guard(
"folder.analyze", {},
lambda: analyze_project_deep(
store.data,
session_id,
apply_sql=True,
query=str(intent.params.get("query") or ""),
next_id=store.next_id,
),
)
store.save()
report = deep.get("folder") or {}
block = UIBlock(blockId="folder-pack", type="folder-pack", props={
"projectName": report.get("projectName") or deep.get("projectName"),
"workDir": report.get("workDir") or deep.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,
"skippedRows": report.get("skippedRows") or 0,
"error": report.get("error"),
})
project_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 {},
"owner": deep.get("owner"),
"knowledgeIngest": deep.get("knowledgeIngest") or {},
})
return AgentReply(
text=deep.get("markdown") or "工程目录分析与主数据入库完成。",
blocks=[block, project_block],
)
if name == "folder.schedule":
from server.aps_domain.folder_pack import (
confirmation_for_folder_schedule,
folder_schedule_payload_digest,
prepare_folder_schedule,
)
# P2 staging is read-only for the business world. Parsed rows and SQL payload
# are frozen below and bound to source/world fingerprints.
try:
report = prepare_folder_schedule(store.data, session_id)
except (OSError, PermissionError, ValueError) as exc:
return AgentReply(text=f"工程目录分析失败:{exc}")
kangni_trial = bool(report.get("kangniDetected") and report.get("trialReady"))
kangni_meta = report.get("kangniMeta") or {}
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": [] if kangni_trial else (report.get("missing") or []),
"genericMissing": report.get("missing") or [],
"softMissing": report.get("softMissing") or [],
"canSchedule": bool(report.get("canSchedule") or kangni_trial),
"totalOk": report.get("totalOk") or 0,
"totalErrors": report.get("totalErrors") or 0,
"skippedRows": report.get("skippedRows") or 0,
"error": report.get("error"),
"sqlApplied": bool(report.get("sqlApplied")),
"sqlStats": report.get("sqlStats") or {},
"kangniDetected": bool(report.get("kangniDetected")),
"trialReady": bool(report.get("trialReady")),
"productionReady": report.get("productionReady"),
"kangniMeta": kangni_meta,
})
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 not report.get("batches") and not report.get("sqlApplied") and not kangni_trial:
return AgentReply(
text=(report.get("markdown") or "")
+ "\n\n目录中没有可入库的有效记录,当前无法生成排产方案。"
"请补充订单、物料、工艺或设备数据。",
blocks=[analyze_block],
)
if report.get("missing") and not has_core and not kangni_trial:
return AgentReply(
text=(report.get("markdown") or "")
+ "\n\n**当前无法排产:**缺少 "
+ "、".join(report["missing"])
+ "。补齐后请重新执行目录分析。",
blocks=[analyze_block],
)
title, lines = confirmation_for_folder_schedule(report)
if kangni_trial:
quality = kangni_meta.get("resourceQuality") or {}
production_ready = bool(report.get("productionReady"))
if production_ready:
title = f"康尼真实数据生产落地试排 · {report.get('projectName') or ''}"
ready_line = "当前生产可用性:productionReady=true(设备/模具映射已齐,无共享占位)"
action_line = "确认后生成本地 trial 版本;正式 MES 仍需可信供应与独立 P3 确认"
else:
title = f"康尼真实数据本地试排 · {report.get('projectName') or ''}"
ready_line = "当前生产可用性:productionReady=false(共享占位和现场映射尚未确认)"
action_line = "确认后仅在候选世界生成 trial;不得生产发布或下发 MES"
lines = [
f"冻结工作簿:{len(report.get('sourceManifest') or [])} 份",
f"订单 {kangni_meta.get('orderCount', 0)} · 工艺记录 {kangni_meta.get('routingRecordCount', 0)}",
f"设备 {quality.get('equipmentCount', 0)} · 模具 {quality.get('moldCount', 0)}",
ready_line,
action_line,
]
slim = [] if kangni_trial else [
{"kind": b["kind"], "sheet": b.get("sheet"), "okRows": b.get("okRows") or []}
for b in report["batches"] if b.get("okRows")
]
params = {
"boundAction": "folder.schedule",
"workDir": report.get("workDir"),
"projectId": report.get("projectId"),
"targetWorldKey": report.get("projectId"),
"sessionId": session_id,
"batches": slim,
"sortMode": "BOTTLENECK",
"sqlApplied": bool(report.get("sqlApplied")),
"sqlPayload": report.get("sqlPayload"),
"sourceManifestVersion": report.get("sourceManifestVersion"),
"sourceManifest": report.get("sourceManifest") or [],
"sourceManifestDigest": report.get("sourceManifestDigest"),
"folderWorldFingerprint": report.get("folderWorldFingerprint"),
"kangniDetected": bool(report.get("kangniDetected")),
"trialReady": bool(report.get("trialReady")),
"productionReady": report.get("productionReady"),
"kangniPayload": report.get("kangniPayload"),
"kangniMeta": kangni_meta,
}
checkpoint = _create_checkpoint(
store,
label="工程目录审批前基线",
reason="stage:folder.schedule",
conversation_note="待确认导入工程目录并试排",
)
if not checkpoint:
return AgentReply(text="工程目录确认卡创建失败:无法建立审批前快照。")
params["beforeSnapshot"] = str(checkpoint["pairId"])
params["folderPayloadDigest"] = folder_schedule_payload_digest(params)
evidence_refs = [
f"folder-source:{params['sourceManifestDigest']}",
f"folder-world:{params['folderWorldFingerprint']}",
f"folder-payload:{params['folderPayloadDigest']}",
f"folder-snapshot:{params['beforeSnapshot']}",
]
block = harness.stage_confirmation(
session_id,
"folder.schedule",
params,
title=title,
summary_lines=lines,
evidence_refs=evidence_refs,
before_snapshot=str(checkpoint["pairId"]),
)
write_audit(store.data, store.next_id, actor=actor, category="GATE",
action="folder.schedule.stage",
target={"type": "FOLDER_PACK", "id": report.get("projectId") or "project"},
power="P2", rationale={"confirmId": block.props["confirmId"],
"totalOk": report.get("totalOk"),
"kangniDetected": kangni_trial,
"trialOnly": kangni_trial,
"sourceManifestDigest": report.get("sourceManifestDigest")})
store.save()
if kangni_trial:
production_ready = bool(report.get("productionReady"))
if production_ready:
tail = (
"资源映射已齐,productionReady=true。"
"下面请确认本地 trial:**可进入发布校验,现场 MES 仍需可信供应与 P3**(可回滚)。"
)
else:
tail = (
"当前使用共享占位资源,productionReady=false。"
"下面请确认本地 trial:**仅试排、不得生产发布或下发 MES**(可回滚)。"
)
return AgentReply(
text=(report.get("markdown") or "")
+ "\n\n康尼专用解析已冻结 9 份上传工作簿,并识别完整订单与工艺。"
+ tail,
blocks=[analyze_block, block],
)
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, scheduling_data_guide
data_guide = scheduling_data_guide(store.data)
if data_guide.get("steps"):
step_lines = [
f"{i}. {st['title']}:{st['detail']} {st['how']}"
for i, st in enumerate(data_guide["steps"], start=1)
]
return AgentReply(
text=(data_guide.get("hint") or "要开排还差一些数据:\n") + "\n".join(step_lines),
blocks=[UIBlock(blockId="guidance-data-missing", type="guidance", props=data_guide)])
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,
)
# ============================================================
# 自动化规则动作 → 真实业务动作 执行桥(round-40 方向 S · 矩阵 76)
# 只新增段落:commit → schedule.publish(发布)、reschedule → flex.reschedule(重排)。
# 门禁路径与 handle_intent 分支一致(_stage_publish / _flex_reschedule 出 P2 确认卡);
# auto=True(G4 受控自动,AutomationGate 升权凭据已过)时出卡后立即经
# execute_confirmed 批准执行——真实写入始终走既有 harness 门禁。
# ============================================================
def _confirm_id_of(reply: AgentReply) -> str | None:
"""从确认卡回复块中提取 confirmId;无确认卡返回 None。"""
for block in reply.blocks or []:
props = getattr(block, "props", None) or {}
if getattr(block, "type", None) == "confirm-card" and props.get("confirmId"):
return str(props["confirmId"])
return None
def run_automation_intent(store: WorldStore, session_id: str, intent: IntentResult,
actor: str = "automation", *, auto: bool = False) -> dict[str, Any]:
"""执行一条自动化规则动作绑定的真实业务意图(矩阵 76 方向 S)。
- auto=False(G2 沙盒 / G3 监督):与 handle_intent 相同分支出 P2 确认卡(STAGED);
- auto=True(G4 受控自动,AutomationGate 升权凭据已过):出卡后立即经
execute_confirmed 批准执行(EXECUTED)——真实写入始终过既有 harness 门禁。
未接线的意图抛 ValueError(fail closed,不产生任何写入)。
"""
name = intent.intent
if name == "schedule.publish":
reply = _stage_publish(store, session_id, actor) # handle_intent 同款分支
elif name == "flex.reschedule":
reply = _flex_reschedule(store, intent, session_id, actor) # handle_intent 同款分支
else:
raise ValueError(f"自动化规则未接线的真实业务意图:{name}")
confirm_id = _confirm_id_of(reply)
if auto and confirm_id:
text = execute_confirmed(store, confirm_id, True, actor=actor)
return {"status": "EXECUTED", "text": text, "confirmId": confirm_id}
if confirm_id:
return {"status": "STAGED", "text": reply.text, "confirmId": confirm_id}
return {"status": "PROPOSED", "text": reply.text}