4655 lines
258 KiB
Python
4655 lines
258 KiB
Python
# ============================================================
|
||
# 排产工作流编排(moduleId: domain-workflow, 可重生 ✅)
|
||
# 职责:意图 → 动作 → AgentReply(文本 + 视口命令 + UI 块)
|
||
# 权力路由:P0/P1 直接执行;P2(发布/重置)经门禁出确认卡(§3.3)
|
||
# ============================================================
|
||
from __future__ import annotations # 前向类型引用
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
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 _publication_safety_block(store: WorldStore, version: dict[str, Any]) -> str | None:
|
||
"""Return a fail-closed reason when a version must not become an execution baseline."""
|
||
|
||
snapshot = version.get("inputSnapshot")
|
||
snapshot_context = snapshot.get("planningContext") if isinstance(snapshot, dict) else None
|
||
current_context = store.data.get("planningContext")
|
||
trial_only = (
|
||
version.get("trialOnly") is True
|
||
or (isinstance(snapshot_context, dict) and snapshot_context.get("trialOnly") is True)
|
||
or (isinstance(current_context, dict) and current_context.get("trialOnly") is True)
|
||
)
|
||
reasons: list[str] = []
|
||
if trial_only:
|
||
reasons.append("版本标记为 trialOnly=true(试排或历史资料)")
|
||
if version.get("productionReady") is False:
|
||
reasons.append("版本标记为 productionReady=false")
|
||
return ";".join(reasons) if reasons else None
|
||
|
||
|
||
_PUBLISH_REASON_TITLES: dict[str, str] = {
|
||
"TRIAL_ONLY": "当前版本为试排草稿",
|
||
"PRODUCTION_NOT_READY": "生产条件未确认",
|
||
"VERSION_NOT_DRAFT": "版本状态不允许发布",
|
||
"VL_COUNT_MISMATCH": "产线数量不一致",
|
||
"WO_COUNT_MISMATCH": "工单数量不一致",
|
||
"ORDER_COVERAGE_MISMATCH": "订单覆盖不完整",
|
||
"UNSCHEDULED_REQUIREMENTS": "仍有未排订单",
|
||
"HARD_CONFLICT": "存在未解决硬约束冲突",
|
||
"HARD_BLOCKER": "存在发布阻断项",
|
||
"HARD_VALIDATION_VIOLATION": "硬约束校验未通过",
|
||
"NO_WORK_ORDERS": "没有可执行工单",
|
||
"NO_ROUTING": "缺少工艺路线",
|
||
"INCOMPLETE_ROUTING": "工艺路线不完整",
|
||
"INCOMPLETE_WORK_ORDER": "工单字段不完整",
|
||
"ORPHAN_WORK_ORDER": "工单缺少产线归属",
|
||
"ORPHAN_VIRTUAL_LINE": "产线没有工单",
|
||
"VERSION_MARKED_NOT_PUBLISHABLE": "版本被标记为不可发布",
|
||
"DUPLICATE_OR_MISSING_VL_ID": "产线编号重复或缺失",
|
||
"DUPLICATE_OR_MISSING_WO_ID": "工单编号重复或缺失",
|
||
"DUPLICATE_WORK_ORDER_ACTIVITY_ID": "工序编号重复",
|
||
"V2_EVIDENCE_REQUIRED": "缺少排产过程证据",
|
||
"INCOMPLETE_V2_EVIDENCE": "排产过程证据字段不完整",
|
||
"INVALID_V2_EVIDENCE": "排产过程证据无效",
|
||
"V2_SOLUTION_NOT_FEASIBLE": "求解结果不是可行解",
|
||
"V2_VALIDATION_NOT_VALID": "求解结果未通过校验",
|
||
"V2_VALIDATION_HAS_HARD_VIOLATIONS": "求解结果仍违反硬约束",
|
||
"V2_PROBLEM_NOT_FOUND": "找不到该版本的排产问题记录",
|
||
"V2_ACTIVITY_COVERAGE_MISMATCH": "工序未全覆盖",
|
||
"V2_PROBLEM_ID_DRIFT": "排产问题已变化",
|
||
"V2_PROBLEM_REVISION_DRIFT": "排产问题版本已变化",
|
||
"V2_PROBLEM_SOURCE_DRIFT": "排产问题来源数据已变化",
|
||
"V2_SOLUTION_PROBLEM_DRIFT": "求解结果对应的排产问题已变化",
|
||
"V2_SOLUTION_SOURCE_DRIFT": "求解结果来源数据已变化",
|
||
"V2_SOLUTION_STATUS_DRIFT": "求解状态已变化",
|
||
"V2_VALIDATION_IDENTITY_DRIFT": "校验证据标识已变化",
|
||
"V2_VALIDATION_PROBLEM_DRIFT": "校验证据对应的排产问题已变化",
|
||
"CURRENT_RESOURCE_CONSTRAINT_DRIFT": "资源约束已变化",
|
||
"WORK_ORDER_ACTIVITY_NOT_IN_PROBLEM": "工序不在排产问题中",
|
||
"WORK_ORDER_ACTIVITY_NOT_IN_SOLUTION": "工序不在求解结果中",
|
||
"WORK_ORDER_ACTIVITY_IDENTITY_DRIFT": "工序标识已变化",
|
||
"WORK_ORDER_START_DRIFT": "工序开始时间已变化",
|
||
"WORK_ORDER_END_DRIFT": "工序结束时间已变化",
|
||
"WORK_ORDER_OPERATION_DRIFT": "工序内容已变化",
|
||
"WORK_ORDER_REQUIREMENT_ID_DRIFT": "需求编号已变化",
|
||
"WORK_ORDER_RESOURCE_ALLOCATION_DRIFT": "资源分配已变化",
|
||
"CP_SOLVE_NOT_MATERIALIZABLE": "CP 求解结果无法落成工序计划",
|
||
"CP_C3_MATERIALIZATION_INVALID": "日历约束落细校验未通过",
|
||
"CP_C7_MATERIALIZATION_INVALID": "产能约束落细校验未通过",
|
||
"CP_TIMING_NOT_VALIDATED": "CP 时序未通过校验",
|
||
"CP_TIMING_NOT_DIRECTLY_MATERIALIZED": "CP 时序未直接落细",
|
||
}
|
||
|
||
|
||
def _publication_gate_summary(
|
||
store: WorldStore,
|
||
version: dict[str, Any],
|
||
*,
|
||
track: str,
|
||
validation: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Build a human-readable publication gate for chat and result cards."""
|
||
|
||
snapshot = version.get("inputSnapshot")
|
||
snapshot_context = snapshot.get("planningContext") if isinstance(snapshot, dict) else None
|
||
current_context = store.data.get("planningContext")
|
||
trial_only = (
|
||
version.get("trialOnly") is True
|
||
or (isinstance(snapshot_context, dict) and snapshot_context.get("trialOnly") is True)
|
||
or (isinstance(current_context, dict) and current_context.get("trialOnly") is True)
|
||
)
|
||
status = str(version.get("status") or "").upper()
|
||
reasons: list[dict[str, str]] = []
|
||
checks: list[str] = []
|
||
|
||
if trial_only:
|
||
reasons.append({
|
||
"code": "TRIAL_ONLY",
|
||
"title": _PUBLISH_REASON_TITLES["TRIAL_ONLY"],
|
||
"detail": "版本标记 trialOnly=true;结果来自历史资料或试排假设,不能作为正式执行基准。",
|
||
})
|
||
else:
|
||
checks.append("试排限制:未启用")
|
||
|
||
if version.get("productionReady") is False:
|
||
reasons.append({
|
||
"code": "PRODUCTION_NOT_READY",
|
||
"title": _PUBLISH_REASON_TITLES["PRODUCTION_NOT_READY"],
|
||
"detail": "版本标记 productionReady=false;人员技能、在制状态或供料条件尚未全部确认。",
|
||
})
|
||
elif version.get("productionReady") is True:
|
||
checks.append("生产就绪:已确认")
|
||
else:
|
||
checks.append("生产就绪:未标记为阻断")
|
||
|
||
if status == "DRAFT":
|
||
checks.append("版本状态:草稿")
|
||
else:
|
||
reasons.append({
|
||
"code": "VERSION_NOT_DRAFT",
|
||
"title": _PUBLISH_REASON_TITLES["VERSION_NOT_DRAFT"],
|
||
"detail": f"当前版本状态为 {status or 'UNKNOWN'};只有草稿版本可以进入发布审批。",
|
||
})
|
||
|
||
if validation:
|
||
for row in validation.get("publishBlockingReasons") or []:
|
||
code = str(row.get("code") or "PUBLICATION_CHECK_FAILED")
|
||
if code == "VERSION_NOT_DRAFT":
|
||
continue
|
||
reasons.append({
|
||
"code": code,
|
||
"title": _PUBLISH_REASON_TITLES.get(code, "发布校验未通过"),
|
||
"detail": str(row.get("message") or code),
|
||
})
|
||
if validation.get("structuralValid"):
|
||
checks.append("版本结构:校验通过")
|
||
if validation.get("publishReady") is True:
|
||
checks.append("发布校验:通过")
|
||
|
||
deduped: list[dict[str, str]] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
for reason in reasons:
|
||
key = (reason["code"], reason["detail"])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
deduped.append(reason)
|
||
|
||
if validation and validation.get("publishReady") is not True and not deduped:
|
||
deduped.append({
|
||
"code": "PUBLICATION_CHECK_FAILED",
|
||
"title": "发布校验未通过",
|
||
"detail": "发布校验未返回可识别原因,请检查版本结构与生产就绪状态。",
|
||
})
|
||
|
||
can_publish = not deduped
|
||
source_label = "历史资料试排" if trial_only else ("柔性排产版本" if track == "flex" else "固定排产版本")
|
||
if can_publish:
|
||
next_steps = [
|
||
"确认发布范围与版本号",
|
||
"完成 P2 审批,建立执行基准",
|
||
"如需下发车间,另走 P3 MES 双人确认",
|
||
]
|
||
elif trial_only:
|
||
next_steps = [
|
||
"核对并补齐试排假设涉及的人员技能、在制状态与供料信息",
|
||
"处理硬约束冲突与交期超期",
|
||
"使用确认后的数据重新生成正式排产版本",
|
||
"重新提交 P2 发布审批",
|
||
]
|
||
else:
|
||
next_steps = [
|
||
"按未满足条件修正版本数据或冲突",
|
||
"重新生成并校验排产版本",
|
||
"再次提交 P2 发布审批",
|
||
]
|
||
|
||
return {
|
||
"canPublish": can_publish,
|
||
"statusLabel": "可进入发布审批" if can_publish else "不可发布为执行基准",
|
||
"summary": (
|
||
"该版本已通过当前发布校验,可以进入 P2 人工审批。"
|
||
if can_publish else
|
||
f"当前为{source_label},仍有发布条件未满足。"
|
||
),
|
||
"sourceLabel": source_label,
|
||
"reasons": deduped,
|
||
"checks": checks,
|
||
"nextSteps": next_steps,
|
||
"boundary": "发布只建立执行基准,不会自动下发 MES;MES 下发仍需独立 P3 双人确认。",
|
||
}
|
||
|
||
|
||
def _publication_gate_block(
|
||
version: dict[str, Any], *, track: str, gate: dict[str, Any],
|
||
) -> UIBlock:
|
||
"""Render a publication gate as the existing structured text block."""
|
||
|
||
version_no = version.get("versionNo") or version.get("id")
|
||
return UIBlock(
|
||
blockId=f"publication-gate-{track}-{version.get('id')}",
|
||
type="text",
|
||
props={
|
||
"title": f"发布条件 · {version_no}",
|
||
"publicationGate": gate,
|
||
},
|
||
)
|
||
|
||
|
||
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],
|
||
)
|
||
safety_block = _publication_safety_block(store, version)
|
||
if safety_block:
|
||
gate = _publication_gate_summary(store, version, track=normalized_track)
|
||
return AgentReply(
|
||
text=(
|
||
f"版本 {version.get('versionNo') or version['id']} 不可发布:{safety_block}。"
|
||
"试排或未达到生产条件的版本只能保留为草稿,禁止建立执行基准或进入 MES 下发。"
|
||
),
|
||
blocks=[_publication_gate_block(version, track=normalized_track, gate=gate)],
|
||
)
|
||
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":
|
||
gate = _publication_gate_summary(store, version, track=normalized_track)
|
||
return AgentReply(
|
||
text=f"版本 {version.get('versionNo') or version['id']} 状态为 {status or 'UNKNOWN'},不可发布。",
|
||
blocks=[_publication_gate_block(version, track=normalized_track, gate=gate)],
|
||
)
|
||
|
||
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"]),
|
||
)
|
||
gate = _publication_gate_summary(
|
||
store, version, track=normalized_track, validation=validation,
|
||
)
|
||
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 '结构不完整'}"
|
||
),
|
||
blocks=[_publication_gate_block(version, track=normalized_track, gate=gate)],
|
||
)
|
||
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=[_publication_gate_block(version, track=normalized_track, gate=gate), 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FB-03:受控配置文件写(policy.update / ops.config.apply 共用原语)
|
||
# tmp + os.replace 原子写(失败不留下半个文件)+ 旧文件 .bak 备份(保留最近 5 份)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _atomic_write_json(path, doc: Any) -> None:
|
||
"""原子写 JSON 文件:先写同目录 tmp,再 os.replace 提升(无中间态)。"""
|
||
import json as _json
|
||
import uuid as _uuid
|
||
from pathlib import Path as _Path
|
||
|
||
path = _Path(path)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_name(f"{path.name}.tmp-{_uuid.uuid4().hex[:8]}")
|
||
tmp.write_text(_json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def _backup_config_file(path, *, keep: int = 5) -> str | None:
|
||
"""旧文件备份为 <name>.bak-<ts>-<rand>,保留最近 keep 份(供人工回滚)。"""
|
||
import shutil as _shutil
|
||
import time as _time
|
||
import uuid as _uuid
|
||
from pathlib import Path as _Path
|
||
|
||
path = _Path(path)
|
||
if not path.is_file():
|
||
return None
|
||
backup = path.with_name(
|
||
f"{path.name}.bak-{_time.strftime('%Y%m%d-%H%M%S')}-{_uuid.uuid4().hex[:6]}")
|
||
_shutil.copy2(path, backup)
|
||
backups = sorted(path.parent.glob(f"{path.name}.bak-*"),
|
||
key=lambda p: p.stat().st_mtime, reverse=True)
|
||
for old in backups[keep:]:
|
||
try:
|
||
old.unlink()
|
||
except OSError: # 清理失败不阻断主流程
|
||
pass
|
||
return str(backup)
|
||
|
||
|
||
def _sql_payload_row_count(payload: dict[str, Any] | None) -> int:
|
||
"""SQL 冻结载荷的业务行数:按真实列表长度计,不信任 stats(两者可能不一致)。"""
|
||
total = 0
|
||
for key in ("orders", "materials", "equipment", "routing", "bom"):
|
||
value = (payload or {}).get(key)
|
||
if isinstance(value, list):
|
||
total += len(value)
|
||
return total
|
||
|
||
|
||
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 == "agent.fallback.execute": # ---- 批准:智能兜底计划执行(FB-02)----
|
||
# 计划锁 + checkpoint 成对快照 + diff 验证(P2-DESIGN §3):
|
||
# 分支体全异常归并显式失败(§9.2 降级方案——checkpoint 前置保证最坏情况
|
||
# 退化为「一次显式失败且已回滚的确认」,绝无半写入不声明)。
|
||
from server.agent_core import fallback_lane
|
||
try:
|
||
fb_result = fallback_lane.execute_plan(store, pending, actor=actor,
|
||
evidence_refs=evidence_refs)
|
||
except Exception as exc: # noqa: BLE001 - 热路径归并显式失败(绝不抛出)
|
||
fb_result = fallback_lane.ExecuteResult(
|
||
run_id=str(params.get("runId") or ""), ok=False, status="failed",
|
||
plan_fingerprint=str(params.get("planFingerprint") or ""),
|
||
error_message=f"{type(exc).__name__}: {exc}",
|
||
message=f"兜底执行出现编排器内部错误({type(exc).__name__}: {exc}),"
|
||
"未执行任何变更。")
|
||
write_audit( # 成败都写(失败总账在 restore 之后补写)
|
||
store.data,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="WORLD_WRITE",
|
||
action="agent.fallback.execute",
|
||
target={"type": "FALLBACK_RUN", "id": fb_result.run_id},
|
||
power="P2",
|
||
rationale={
|
||
"confirmId": confirm_id,
|
||
"approver": actor,
|
||
"runId": fb_result.run_id,
|
||
"planFingerprint": fb_result.plan_fingerprint,
|
||
"stepsExecuted": fb_result.steps_executed,
|
||
"status": fb_result.status,
|
||
**({"deviation": fb_result.deviation} if fb_result.deviation else {}),
|
||
**({"reason": fb_result.error_message}
|
||
if fb_result.status in ("denied", "failed") else {}),
|
||
"rolledBack": fb_result.rolled_back,
|
||
"rollbackVerified": fb_result.rollback_verified,
|
||
"cpAfter": fb_result.cp_after or None,
|
||
"verifyReport": fb_result.report_path,
|
||
"executionLog": fb_result.execution_log,
|
||
},
|
||
result=("SUCCESS" if fb_result.ok
|
||
else "DENIED" if fb_result.status == "denied" else "FAILED"),
|
||
before_snapshot=fb_result.cp_before or None,
|
||
evidence_refs=evidence_refs)
|
||
store.save() # 落盘
|
||
return fb_result.message
|
||
if action == "agent.fallback.policy.update": # ---- 批准:P3 白名单治理(FB-03)----
|
||
# 原子写白名单文件(P3-DESIGN §2.4):beforeSha256 漂移比对 → 冻结文档
|
||
# 复验 → tmp+replace 原子替换 + .bak 备份 → WORLD_WRITE 审计。
|
||
# 分支体全异常归并显式失败(热路径纪律,绝不抛出)。
|
||
from server.agent_core import fallback_highrisk
|
||
|
||
wl_path = ""
|
||
before_sha = after_sha = None
|
||
result_status = "FAILED"
|
||
try:
|
||
wl_path = str(fallback_highrisk.default_whitelist_path())
|
||
before_sha = fallback_highrisk.file_sha256(wl_path)
|
||
if before_sha != params.get("beforeSha256"):
|
||
result_status = "DENIED"
|
||
message = ("白名单治理被拒绝:出卡后白名单文件已变化(漂移比对不符),"
|
||
"未执行任何变更,请重新发起。")
|
||
else:
|
||
new_doc = params.get("document")
|
||
doc_err = fallback_highrisk.validate_whitelist_doc(new_doc)
|
||
if doc_err or fallback_highrisk.canonical_sha256(new_doc) \
|
||
!= str(params.get("documentSha256") or ""):
|
||
result_status = "DENIED"
|
||
message = (f"白名单治理被拒绝:冻结文档复验失败"
|
||
f"({doc_err or '文档指纹与出卡值不符'}),未执行任何变更。")
|
||
else:
|
||
backup = _backup_config_file(wl_path)
|
||
_atomic_write_json(wl_path, new_doc)
|
||
readback_err = fallback_highrisk.validate_whitelist_doc(
|
||
json.loads(Path(wl_path).read_text(encoding="utf-8")))
|
||
if readback_err:
|
||
raise RuntimeError(f"回读校验失败: {readback_err}")
|
||
after_sha = fallback_highrisk.file_sha256(wl_path)
|
||
result_status = "SUCCESS"
|
||
message = (f"P3 白名单已更新 ✅(原子替换完成"
|
||
f"{f',旧文件已备份 {backup}' if backup else ''})。"
|
||
"下一次裁决即生效。")
|
||
except Exception as exc: # noqa: BLE001 - 热路径归并显式失败(原子写保证无中间态)
|
||
message = (f"白名单治理执行失败({type(exc).__name__}: {exc}),"
|
||
"未留下半个文件(原子写语义)。")
|
||
write_audit(
|
||
store.data,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="WORLD_WRITE",
|
||
action="agent.fallback.policy.update",
|
||
target={"type": "FALLBACK_POLICY", "id": wl_path},
|
||
power="P2",
|
||
rationale={"confirmId": confirm_id, "approver": actor,
|
||
"beforeSha256": before_sha, "afterSha256": after_sha,
|
||
"diff": params.get("diff")},
|
||
result=result_status,
|
||
before_snapshot=str(staged_snapshot) if staged_snapshot else None,
|
||
evidence_refs=evidence_refs)
|
||
store.save()
|
||
return message
|
||
if action == "agent.fallback.ops.config.apply": # ---- 批准:S7 受控配置变更(FB-03)----
|
||
# P3 双人链终点(P3-DESIGN §7.3):消费一次性 executionGrant →
|
||
# beforeSha256 漂移比对 → tmp+replace 原子替换 + .bak 备份 → WORLD_WRITE 审计。
|
||
from server.agent_core import fallback_highrisk
|
||
from server.agent_core.feature_flags import default_features_path
|
||
|
||
target_path = ""
|
||
before_sha = after_sha = None
|
||
grant_id = str(pending.get("executionGrant") or "")
|
||
result_status = "FAILED"
|
||
try:
|
||
if not harness.consume_execution_grant(
|
||
grant_id, confirm_id=confirm_id, action=action, params=params):
|
||
result_status = "DENIED"
|
||
message = "配置变更被拒绝:执行授权无效或已被消费,未执行任何变更。"
|
||
else:
|
||
target_path = str(default_features_path())
|
||
param_err = fallback_highrisk.validate_config_apply_params(params)
|
||
before_sha = fallback_highrisk.file_sha256(target_path)
|
||
if param_err:
|
||
result_status = "DENIED"
|
||
message = f"配置变更被拒绝:{param_err},未执行任何变更。"
|
||
elif before_sha != params.get("beforeSha256"):
|
||
result_status = "DENIED"
|
||
message = ("配置变更被拒绝:出卡后配置文件已变化(漂移比对不符),"
|
||
"未执行任何变更,请重新发起。")
|
||
else:
|
||
backup = _backup_config_file(target_path)
|
||
_atomic_write_json(target_path, params["content"])
|
||
readback_err = fallback_highrisk.validate_features_config_doc(
|
||
json.loads(Path(target_path).read_text(encoding="utf-8")))
|
||
if readback_err:
|
||
raise RuntimeError(f"回读校验失败: {readback_err}")
|
||
after_sha = fallback_highrisk.file_sha256(target_path)
|
||
result_status = "SUCCESS"
|
||
message = (f"配置已原子替换 ✅({params.get('file')}"
|
||
f"{f',旧文件已备份 {backup}' if backup else ''})。"
|
||
"features.json 每次现读,替换即生效。")
|
||
except Exception as exc: # noqa: BLE001 - 热路径归并显式失败(原子写保证无中间态)
|
||
message = (f"配置变更执行失败({type(exc).__name__}: {exc}),"
|
||
"未留下半个文件(原子写语义)。")
|
||
write_audit(
|
||
store.data,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="WORLD_WRITE",
|
||
action="agent.fallback.ops.config.apply",
|
||
target={"type": "FALLBACK_OPS_CONFIG", "id": params.get("file")},
|
||
power="P3",
|
||
rationale={"confirmId": confirm_id, "approver": actor,
|
||
"grantId": grant_id,
|
||
"approvals": pending.get("approvals"),
|
||
"beforeSha256": before_sha, "afterSha256": after_sha},
|
||
result=result_status,
|
||
before_snapshot=str(staged_snapshot) if staged_snapshot else None,
|
||
evidence_refs=evidence_refs)
|
||
store.save()
|
||
return message
|
||
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 "发布被拒绝:确认卡绑定的排产版本已不存在。"
|
||
safety_block = _publication_safety_block(store, version)
|
||
if safety_block:
|
||
return f"发布被拒绝:{safety_block}。版本保持草稿状态,未建立执行基准,也未下发 MES。"
|
||
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')}")
|
||
batches = params.get("batches") or []
|
||
sql_payload = params.get("sqlPayload")
|
||
mom_path = str(params.get("momPath") or "").strip()
|
||
if (isinstance(sql_payload, dict) and sql_payload) or mom_path:
|
||
# SQL/MOM 采用是整表替换:必须绑定出卡时的目标主数据快照,
|
||
# 审批窗口内目标世界有任何业务改动都拒绝写入(与 folder.schedule 同口径)。
|
||
from server.aps_domain.folder_pack import folder_schedule_world_fingerprint
|
||
expected_world = str(params.get("targetWorldFingerprint") or "").strip()
|
||
if not expected_world or folder_schedule_world_fingerprint(store.data) != expected_world:
|
||
raise PermissionError("采用目标主数据已变化,本次未写入;请重新分析后再采用")
|
||
try:
|
||
if isinstance(sql_payload, dict) and sql_payload:
|
||
# 只读分析冻结的 SQL 载荷:采用即整包写入(不再重读来源文件)。
|
||
from server.importers.sql_pack import apply_sql_pack_to_world
|
||
frozen_sql = copy.deepcopy(sql_payload)
|
||
if _sql_payload_row_count(frozen_sql) <= 0:
|
||
raise PermissionError(
|
||
"SQL 数据包不含可采用的业务行,本次未写入;请重新分析后再采用")
|
||
counts = apply_sql_pack_to_world(store.data, frozen_sql, replace=True)
|
||
applied = {"summary": counts, "total": sum(
|
||
int(value) for value in counts.values()
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool))}
|
||
elif mom_path:
|
||
# MOM 主数据表按文件采用:先按字节校验指纹,再用同一份字节解析
|
||
# (避免 hash→重开之间的替换竞态);空表/无业务行一律拒绝写入。
|
||
expected = str(params.get("momSha256") or "").strip()
|
||
try:
|
||
raw = Path(mom_path).read_bytes()
|
||
except OSError as exc:
|
||
raise PermissionError(
|
||
"来源文件不可读,本次未写入;请重新分析后再采用") from exc
|
||
if not expected or hashlib.sha256(raw).hexdigest() != expected:
|
||
raise PermissionError("来源文件已变化,本次未写入;请重新分析后再采用")
|
||
from server.importers.mom_pack import (
|
||
apply_mom_pack_to_world,
|
||
mom_pack_is_adoptable,
|
||
parse_mom_workbook,
|
||
)
|
||
with tempfile.TemporaryDirectory(prefix="aps-mom-adopt-") as frozen_dir:
|
||
frozen_path = Path(str(frozen_dir)) / "frozen-source.xlsx"
|
||
frozen_path.write_bytes(raw)
|
||
pack = parse_mom_workbook(str(frozen_path))
|
||
if not mom_pack_is_adoptable(pack):
|
||
raise PermissionError(
|
||
"来源文件不足以作为 MOM 主数据采用(模型表或主干行不足),"
|
||
"本次未写入;请重新分析后再采用")
|
||
counts = apply_mom_pack_to_world(store.data, pack, replace=True)
|
||
applied = {"summary": counts, "total": sum(counts.values())}
|
||
elif params.get("intakeRecovery"):
|
||
from server.aps_domain.intake_recovery import apply_reconciled_intake
|
||
applied = apply_reconciled_intake(store.data, store.next_id, batches, params["intakeRecovery"])
|
||
else:
|
||
applied = apply_import_commit(store.data, store.next_id, batches)
|
||
except (ValueError, PermissionError) as exc:
|
||
if not (params.get("sourceProfile") or any(batch.get("sourceProfile") for batch in batches)):
|
||
raise
|
||
from server.aps_domain.planning_intake import PlanningIntakeRejected
|
||
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="import.commit.denied", target={"type": "IMPORT", "id": params.get("filename")},
|
||
power="P2", rationale={"confirmId": confirm_id, "reason": str(exc)},
|
||
result="DENIED", evidence_refs=evidence_refs)
|
||
store.save()
|
||
raise PlanningIntakeRejected(str(exc)) from exc
|
||
# 采用即归属:只补首次归属,后续审批人不会改写已登记的数据owner。
|
||
try:
|
||
from server.knowledge.ingest import bind_world_to_identity
|
||
|
||
if not (store.data.get("meta") or {}).get("ownerUserId"):
|
||
bind_world_to_identity(
|
||
store.data, sources=[str(params.get("filename") or "")])
|
||
except Exception: # noqa: BLE001 - 归属登记失败不阻断已批准的采用
|
||
pass
|
||
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()
|
||
ingest_meta = params.get("analysisIngest")
|
||
if isinstance(ingest_meta, dict):
|
||
# 分析报告入租户知识库:只在采用获批后发生,并带上已落库的完整数据集。
|
||
try:
|
||
from server.knowledge.ingest import ingest_project_analyze_report
|
||
|
||
project = str(ingest_meta.get("projectName") or params.get("filename") or "当前项目")
|
||
counts = applied.get("summary") or {}
|
||
detail = "、".join(f"{k} {v}" for k, v in counts.items()) or "无新增行"
|
||
ingest_project_analyze_report({
|
||
"projectName": project,
|
||
"summary": counts,
|
||
"sources": ingest_meta.get("sources") or [params.get("filename")],
|
||
"sourcePaths": ingest_meta.get("sourcePaths") or [],
|
||
"plan": [],
|
||
"markdown": (f"# {project} 资料采用\n\n"
|
||
f"- 来源:{'、'.join(str(s) for s in (ingest_meta.get('sources') or [])) or params.get('filename')}\n"
|
||
f"- 采用结果:{detail}\n"
|
||
f"- 采用人:{actor}\n"),
|
||
}, world=store.data)
|
||
except Exception: # noqa: BLE001 - 知识入库失败不回滚已批准的采用
|
||
pass
|
||
from server.importers.workbook_profiles import has_adoption_flow
|
||
# 采用流:注册格式的采用卡,或由只读分析(analysisIngest)升级出的采用卡;
|
||
# 两者都走人工采用的措辞,避免把“采用资料”说成普通文件导入。
|
||
if has_adoption_flow(params.get("sourceProfile")) or isinstance(ingest_meta, dict):
|
||
recovered = applied.get("recoveredOrderNos") or []
|
||
note = f"已将 {'、'.join(recovered)} 恢复为待评估插单,原记录已归档。" if recovered else ""
|
||
adopted = "、".join(f"{k}×{v}" for k, v in (applied.get("summary") or {}).items()) or "无新增行"
|
||
return (note + f"排产资料已采用({adopted}),尚未生成方案。你可以在主数据中核对和修改,"
|
||
"再说「根据这些数据排产」。原表的来源说明与待确认信息已保留。")
|
||
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,
|
||
)
|
||
production_ready = bool(kangni_detected and resource_quality.get("productionReady"))
|
||
result["trialOnly"] = True
|
||
result["productionReady"] = production_ready
|
||
if kangni_detected:
|
||
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
|
||
if kangni_detected:
|
||
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": True,
|
||
"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
|
||
|
||
# The validated candidate already contains the schedule we just committed.
|
||
# Running again here creates a second version outside the atomic transaction.
|
||
head = "工程目录已导入。"
|
||
ver = (result or {}).get("versionNo") or ""
|
||
return (head + f"\n已按目录数据生成试排方案:**{ver}**"
|
||
f"(生产分组 {(result or {}).get('vlCount', 0)} 个 · "
|
||
f"待处理问题 {(result or {}).get('conflictCount', 0)} 项)。"
|
||
"\n这一步尚未下发到车间。请查看右侧排产结果,核对订单、设备和交期;"
|
||
"也可以查看冲突或导出排产表。")
|
||
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_asset
|
||
pack = params.get("pack")
|
||
if not isinstance(pack, dict):
|
||
asset_id = str(params.get("assetId") or params.get("asset_id") or "").strip()
|
||
if not asset_id:
|
||
return "应用 SOP 被拒绝:确认卡缺少 assetId,且没有预编译规则包。"
|
||
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
|
||
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 = params.get("enabled")
|
||
if not isinstance(enabled, bool):
|
||
return "确认卡缺少布尔 enabled,未执行算法 Skill 启停。"
|
||
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.importers.workbook_profiles import has_adoption_flow
|
||
from server.timeutil import parse_dt
|
||
planning_context = store.data.get("planningContext") or {}
|
||
# Adopted workbooks are explicitly trial-only. Real blockers must stay
|
||
# visible, but they must not prevent the draft trial that produces that
|
||
# blocker list. Formal publication remains a separate P2 gate.
|
||
if has_adoption_flow(planning_context.get("sourceProfile")) and not planning_context.get("trialOnly"):
|
||
from server.aps_domain.readiness import check_readiness
|
||
|
||
readiness = check_readiness(store.data)
|
||
if int((readiness.get("summary") or {}).get("ready") or 0) <= 0:
|
||
return AgentReply(
|
||
text="当前资料未通过排产检查,未生成方案。请先补齐订单、工艺、工时、设备或班次,再开始排产。"
|
||
)
|
||
mode = (intent.params.get("sortMode")
|
||
or planning_context.get("defaultSortMode")
|
||
or (store.data.get("flexParams") or {}).get("sortMode"))
|
||
if not mode:
|
||
return AgentReply(text="请先选择本次排产目标,再生成方案。")
|
||
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"]]
|
||
|
||
if has_adoption_flow((store.data.get("planningContext") or {}).get("sourceProfile")):
|
||
total_orders = int(result.get("orderCount") or 0)
|
||
scheduled_orders = len(vls)
|
||
blocked_orders = max(0, total_orders - scheduled_orders)
|
||
trial_block = UIBlock(blockId=f"flex-sched-{result['versionId']}", type="flex-schedule", props={
|
||
"versionNo": result["versionNo"], "modeCn": "历史资料试排",
|
||
"trialOnly": True, "productionReady": False, "solveStatus": solve_status,
|
||
"assumptionCount": int(result.get("assumptionCount") or 0),
|
||
"stats": {"vlCount": scheduled_orders, "woCount": result["woCount"],
|
||
"makespan": result.get("makespan"), "onTimeCount": result.get("onTimeCount", 0),
|
||
"conflictCount": result["conflictCount"], "utilization": round(float(result.get("avgUtilization") or 0) * 100),
|
||
"orderCount": total_orders, "blockedOrderCount": blocked_orders},
|
||
"bottleneck": [], "lines": lines, "conflicts": conflicts,
|
||
"downloadUrl": None, "filename": None,
|
||
})
|
||
status_text = "试排暂未生成可执行方案" if not scheduled_orders else "已生成试排安排"
|
||
assumption_count = int(result.get("assumptionCount") or 0)
|
||
assumption_note = (f"其中 {assumption_count} 项按「试排假设」处理"
|
||
"(未登记人员技能、未确认在制事实已逐条列出),"
|
||
if assumption_count else "")
|
||
return AgentReply(
|
||
text=f"{status_text}:本次 {total_orders} 张订单,已安排 {scheduled_orders} 张,"
|
||
f"待补资料 {blocked_orders} 张,生成 {result['woCount']} 个工单。"
|
||
+ assumption_note +
|
||
"请核对下面的人员、在制和供料问题;已采用的主数据修改会保留。"
|
||
"这是带来源标记的试排结果,尚未下发到车间。",
|
||
blocks=[trial_block], commands=(
|
||
[ViewportCommand(cmd="viewport.mode", params={"mode": "flex"}, issuedBy="LLM")] if scheduled_orders else []
|
||
) + [ViewportCommand(cmd="world.refresh", issuedBy="SYSTEM")],
|
||
)
|
||
|
||
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=False) # 按真实交期试排
|
||
table = matrix_from_flex_rows(
|
||
cmp["rows"], hint=cmp.get("hint"), data_quality=cmp.get("dataQuality"),
|
||
due_basis=cmp.get("dueBasis"))
|
||
block = UIBlock(blockId="flex-compare", type="flex-compare",
|
||
props={"rows": cmp["rows"], "hint": cmp["hint"], "compressedDue": cmp["compressedDue"],
|
||
"dueBasis": cmp.get("dueBasis"),
|
||
"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
|
||
|
||
|
||
def _stage_pending_adoption(store: WorldStore, session_id: str, deep: dict[str, Any],
|
||
actor: str) -> list[UIBlock]:
|
||
"""把只读分析出的可采用资料转成 P2 采用确认卡(未经批准不写世界)。
|
||
|
||
分析(P0)只出预览:这里把 Pi 结构化后的批次 / SQL 载荷 / MOM 来源文件原样
|
||
冻结进确认卡,人工批准后才由 execute_confirmed(action="import.commit") 写入;
|
||
三种来源(Excel/CSV 批次、SQL 数据包、MOM 主数据表)都没有可提交内容时不出卡。
|
||
整表替换类来源(SQL/MOM)额外冻结目标主数据指纹:审批窗口内世界漂移即拒绝执行。
|
||
"""
|
||
from server.agent_core import harness
|
||
from server.aps_domain.folder_pack import folder_schedule_world_fingerprint
|
||
from server.aps_domain.importers import confirmation_for_import_commit
|
||
|
||
pending = deep.get("pendingAdoption") or {}
|
||
mode = str(pending.get("mode") or "")
|
||
batches = [
|
||
batch for batch in (pending.get("batches") or [])
|
||
if isinstance(batch, dict) and batch.get("okRows")
|
||
]
|
||
raw_payload = pending.get("sqlPayload")
|
||
sql_payload = copy.deepcopy(raw_payload) if isinstance(raw_payload, dict) and raw_payload else None
|
||
mom_path = str(pending.get("applyPath") or "").strip()
|
||
sql_stats = (sql_payload or {}).get("stats") or {}
|
||
sql_row_count = _sql_payload_row_count(sql_payload)
|
||
mom_business_rows = int(pending.get("businessRows") or 0)
|
||
mom_counts = {
|
||
str(key): int(value)
|
||
for key, value in (pending.get("counts") or {}).items()
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool) and int(value) > 0
|
||
}
|
||
filename = str(pending.get("filename") or "").strip() or "本次分析的资料"
|
||
if batches:
|
||
preview_batches = [{
|
||
"kind": batch.get("kind"),
|
||
"sheet": batch.get("sheet") or batch.get("sourceFile") or filename,
|
||
"okCount": len(batch.get("okRows") or []),
|
||
"errorCount": int(batch.get("errorCount") or 0),
|
||
} for batch in batches]
|
||
total_ok = sum(item["okCount"] for item in preview_batches)
|
||
total_errors = sum(item["errorCount"] for item in preview_batches)
|
||
title, lines = confirmation_for_import_commit({
|
||
"filename": filename, "batches": preview_batches,
|
||
"totalOk": total_ok, "totalErrors": total_errors,
|
||
})
|
||
params: dict[str, Any] = {"filename": filename, "batches": batches}
|
||
elif sql_payload is not None and sql_row_count > 0:
|
||
stats = sql_stats
|
||
title = f"采用分析资料 · {filename}"
|
||
lines = [
|
||
"SQL 数据包只读解析完成:批准后整包写入项目主干(P2)",
|
||
"订单 {} · 物料 {} · 工艺 {} · 设备 {} · BOM {}".format(
|
||
stats.get("orders", 0), stats.get("materials", 0),
|
||
stats.get("routing", 0), stats.get("equipment", 0), stats.get("bom", 0)),
|
||
"批准后写入主干(P2);执行前自动建档可回滚",
|
||
]
|
||
params = {"filename": filename, "sqlPayload": sql_payload,
|
||
"targetWorldFingerprint": folder_schedule_world_fingerprint(store.data)}
|
||
elif mom_path and pending.get("adoptable") is True and mom_business_rows > 0 and mom_counts:
|
||
detail = "、".join(f"{key} {value}" for key, value in mom_counts.items())
|
||
title = f"采用分析资料 · {filename}"
|
||
lines = [
|
||
f"MOM 主数据表只读解析完成:{detail}",
|
||
"批准后按这份文件替换项目资料(P2)",
|
||
"批准后写入主干(P2);执行前自动建档可回滚",
|
||
]
|
||
params = {"filename": filename, "momPath": mom_path,
|
||
"momSha256": pending.get("applySha256"),
|
||
"targetWorldFingerprint": folder_schedule_world_fingerprint(store.data)}
|
||
else:
|
||
return []
|
||
# 采用获批后由 execute_confirmed 把这次分析写进租户知识库:
|
||
# P0 分析只出预览,知识入库跟随 P2 采用一起发生。
|
||
params["analysisIngest"] = {
|
||
"projectName": deep.get("projectName"),
|
||
"summary": deep.get("summary") or {},
|
||
"sources": deep.get("sources") or [],
|
||
"sourcePaths": (pending.get("sourcePaths") or [])[:8],
|
||
"mode": pending.get("mode"),
|
||
}
|
||
block = harness.stage_confirmation(
|
||
session_id, "import.commit", params,
|
||
title=title, summary_lines=lines,
|
||
evidence_refs=[
|
||
f"analysis-source:{path}" for path in (pending.get("sourcePaths") or [])[:8]
|
||
],
|
||
)
|
||
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||
action="import.commit.stage", target={"type": "IMPORT", "id": filename},
|
||
power="P2",
|
||
rationale={"confirmId": block.props["confirmId"], "mode": mode,
|
||
"batchCount": len(batches),
|
||
"sqlPayload": sql_payload is not None,
|
||
"momFrozen": bool(mom_path)})
|
||
store.save()
|
||
return [block]
|
||
|
||
|
||
def _analysis_summary_props(
|
||
deep: dict[str, Any],
|
||
*,
|
||
has_adoption: bool,
|
||
can_schedule: bool,
|
||
) -> dict[str, Any]:
|
||
"""Pi 侧展示摘要:首屏只给结论、关键数和下一步,详细内容留给折叠层。"""
|
||
summary = deep.get("summary") or {}
|
||
folder = deep.get("folder") or {}
|
||
pending = deep.get("pendingAdoption") or {}
|
||
issues: list[str] = []
|
||
seen: set[str] = set()
|
||
|
||
def add_issue(message: Any) -> None:
|
||
text = str(message or "").strip()
|
||
if text and text not in seen:
|
||
seen.add(text)
|
||
issues.append(text)
|
||
|
||
for item in folder.get("diagnostics") or []:
|
||
if isinstance(item, dict) and item.get("severity") in ("blocking", "warning"):
|
||
add_issue(item.get("message"))
|
||
for line in deep.get("plan") or []:
|
||
add_issue(line)
|
||
|
||
metrics = [
|
||
{"key": "orders", "label": "订单", "value": int(summary.get("orders") or 0)},
|
||
{"key": "materials", "label": "产品和物料", "value": int(summary.get("materials") or 0)},
|
||
{"key": "routing", "label": "加工步骤", "value": int(summary.get("routings") or 0)},
|
||
{"key": "equipment", "label": "设备", "value": int(summary.get("equipment") or 0)},
|
||
]
|
||
mode = str(pending.get("mode") or "none")
|
||
if has_adoption and mode != "none":
|
||
status, status_label = "review", "待确认采用"
|
||
headline = "资料已读取,确认采用后才会写入项目主数据。"
|
||
next_step = None
|
||
elif can_schedule:
|
||
status, status_label = "ready", "可以排产"
|
||
headline = "资料已通过检查,可以生成排产方案。"
|
||
next_step = {"label": "开始排产", "command": "立即排产"}
|
||
else:
|
||
status, status_label = "blocked", "需要补充"
|
||
headline = f"资料已读取,但有 {len(issues)} 项需要核对或补充。"
|
||
next_step = {"label": "查看缺少资料", "command": "排产还缺什么"}
|
||
|
||
return {
|
||
"headline": headline,
|
||
"status": status,
|
||
"statusLabel": status_label,
|
||
"summaryLine": (
|
||
f"订单 {metrics[0]['value']} · 产品和物料 {metrics[1]['value']} · "
|
||
f"加工步骤 {metrics[2]['value']} · 设备 {metrics[3]['value']}"
|
||
),
|
||
"metrics": metrics,
|
||
"issues": issues[:3],
|
||
"issueOverflow": max(0, len(issues) - 3),
|
||
"nextStep": next_step,
|
||
}
|
||
|
||
|
||
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
|
||
# Pi 已经结构化;只接受显式 kind + rows,不再解析任何自然语言文本。
|
||
parsed = parse_import(
|
||
intent.params.get("kind"),
|
||
store.data,
|
||
rows=intent.params.get("rows"),
|
||
)
|
||
if not parsed["rows"]:
|
||
err = ";".join(parsed.get("errors", [])[:3])
|
||
return AgentReply(
|
||
text=("导入参数不完整或无效:" + (err or "请提供 kind 与非空 rows"))
|
||
+ "\n请提供结构化参数 kind=orders|materials 与 rows 数组;"
|
||
"本工具不再从自然语言文本解析导入行。"
|
||
)
|
||
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")
|
||
# 「查这个订单」这类指代由 Pi 显式传 orderRef=last,这里只做确定性解析。
|
||
if str(intent.params.get("orderRef") or "").strip().lower() in ("last", "this", "focus"):
|
||
from server.agent_core.session_focus import get_last_order_no
|
||
|
||
code = get_last_order_no(session_id) or ""
|
||
if not code:
|
||
return AgentReply(text="本次会话还没有查到过订单,请先说出订单号或先查询订单。")
|
||
result = run_master_query(
|
||
store.data,
|
||
entity=intent.params.get("entity"),
|
||
code=code,
|
||
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":
|
||
mode = str(intent.params.get("mode") or "").strip().lower()
|
||
if mode not in {"catalog", "search"}:
|
||
return AgentReply(
|
||
text="知识查询需要结构化参数 mode=catalog 或 mode=search;"
|
||
"本工具不再从 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 "").strip()
|
||
title_hint = str(intent.params.get("assetTitle") or "").strip()
|
||
if mode == "catalog":
|
||
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": mode, "count": len(metas)})
|
||
store.save()
|
||
return AgentReply(text="\n".join(lines))
|
||
if not q and not title_hint:
|
||
return AgentReply(
|
||
text="知识检索需要结构化参数 query(关键词)或 assetTitle(资产标题);"
|
||
"本次未执行检索。"
|
||
)
|
||
if title_hint:
|
||
asset = kb.find_by_title(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": q, "assetTitle": title_hint or None, "mode": mode,
|
||
"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))
|
||
# Pi owns the follow-up conversation; this tool only returns current facts.
|
||
if name == "schedule.wizard":
|
||
name = "readiness.query"
|
||
# ---- 数据齐备度 / 工时维护(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 in ("data.analyze", "folder.analyze", "folder.schedule"):
|
||
from server.aps_domain.planning_intake import profile_intake_reply, scheduling_entry_actions
|
||
|
||
source_index = intent.params.get("sourceIndex")
|
||
source_file = intent.params.get("sourceFile") or intent.params.get("filename")
|
||
source_hint = (
|
||
f"{int(source_index)}."
|
||
if isinstance(source_index, int) and not isinstance(source_index, bool)
|
||
else Path(str(source_file)).name if source_file else ""
|
||
)
|
||
try:
|
||
profile_reply = profile_intake_reply(
|
||
store, session_id, schedule_requested=name == "folder.schedule",
|
||
query=source_hint,
|
||
schedule_current=lambda: _run_flex(store, IntentResult(
|
||
intent="flex.schedule", params={},
|
||
confidence=1.0, source="LLM"), actor),
|
||
)
|
||
except (OSError, PermissionError, ValueError) as exc:
|
||
return AgentReply(text=f"资料检查未完成:{exc}。请检查文件后重试。")
|
||
if profile_reply is not None:
|
||
return profile_reply
|
||
if name == "data.analyze":
|
||
from server.aps_domain.project_analyze import analyze_project_deep
|
||
from server.aps_domain.guidance import scheduling_data_guide
|
||
|
||
deep = harness.guard(
|
||
"data.analyze", {},
|
||
lambda: analyze_project_deep(
|
||
store.data, session_id,
|
||
apply_sql=False,
|
||
query="",
|
||
input_path=str(intent.params.get("path") or "") or None,
|
||
next_id=store.next_id,
|
||
apply=False,
|
||
),
|
||
)
|
||
store.save()
|
||
can_schedule = bool(deep.get("canSchedule"))
|
||
data_guide = scheduling_data_guide(store.data)
|
||
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": can_schedule,
|
||
"nextActions": scheduling_entry_actions(
|
||
can_schedule=can_schedule,
|
||
reason="请先补齐订单、工艺、工时、设备或班次等排产资料。",
|
||
),
|
||
"applied": deep.get("applied") or {},
|
||
"owner": deep.get("owner"),
|
||
"knowledgeIngest": deep.get("knowledgeIngest") or {},
|
||
})
|
||
adoption_cards = _stage_pending_adoption(store, session_id, deep, actor)
|
||
block.props["analysisSummary"] = _analysis_summary_props(
|
||
deep, has_adoption=bool(adoption_cards), can_schedule=can_schedule,
|
||
)
|
||
blocks = [block, *adoption_cards]
|
||
if not can_schedule and data_guide.get("steps"):
|
||
blocks.append(UIBlock(blockId="guidance-data-missing", type="guidance", props=data_guide))
|
||
if adoption_cards:
|
||
text = ("已读取并整理本项目的数据。请先确认采用本次资料,采用后才会写入项目主数据,"
|
||
"再继续排产。")
|
||
if not can_schedule:
|
||
text += "当前资料还未通过排产检查,补齐缺项前不会开始排产。"
|
||
elif can_schedule:
|
||
text = "已读取并整理本项目的数据。当前资料已通过排产检查,但尚未生成方案。"
|
||
else:
|
||
text = "当前资料未通过排产检查,未生成方案。下面已按顺序列出需要补充的数据;补齐前不会开始排产。"
|
||
return AgentReply(text=text, blocks=blocks)
|
||
if name == "folder.analyze":
|
||
from server.aps_domain.project_analyze import analyze_project_deep
|
||
from server.aps_domain.guidance import scheduling_data_guide
|
||
|
||
deep = harness.guard(
|
||
"folder.analyze", {},
|
||
lambda: analyze_project_deep(
|
||
store.data,
|
||
session_id,
|
||
apply_sql=False,
|
||
query="",
|
||
input_path=str(intent.params.get("path") or "") or None,
|
||
next_id=store.next_id,
|
||
apply=False,
|
||
),
|
||
)
|
||
store.save()
|
||
report = deep.get("folder") or {}
|
||
can_schedule = bool(deep.get("canSchedule"))
|
||
data_guide = scheduling_data_guide(store.data)
|
||
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 [],
|
||
"summary": deep.get("summary") or {},
|
||
"missing": report.get("missing") or [],
|
||
"softMissing": report.get("softMissing") or [],
|
||
"canSchedule": bool(report.get("canSchedule")),
|
||
"nextActions": scheduling_entry_actions(
|
||
can_schedule=bool(report.get("canSchedule")),
|
||
reason="请先补齐订单、物料、工艺、工时或设备等排产资料。",
|
||
),
|
||
"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": can_schedule,
|
||
"nextActions": scheduling_entry_actions(
|
||
can_schedule=can_schedule,
|
||
reason="请先补齐订单、工艺、工时、设备或班次等排产资料。",
|
||
),
|
||
"applied": deep.get("applied") or {},
|
||
"owner": deep.get("owner"),
|
||
"knowledgeIngest": deep.get("knowledgeIngest") or {},
|
||
})
|
||
adoption_cards = _stage_pending_adoption(store, session_id, deep, actor)
|
||
block.props["analysisSummary"] = _analysis_summary_props(
|
||
deep, has_adoption=bool(adoption_cards), can_schedule=can_schedule,
|
||
)
|
||
blocks = [block, project_block, *adoption_cards]
|
||
if not can_schedule and data_guide.get("steps"):
|
||
blocks.append(UIBlock(blockId="guidance-data-missing", type="guidance", props=data_guide))
|
||
return AgentReply(
|
||
text=("已检查文件并整理项目数据。请先确认采用本次资料,采用后才会写入项目主数据,"
|
||
"再继续排产。"
|
||
if adoption_cards else
|
||
("已检查文件并整理项目数据。当前资料已通过排产检查,但尚未生成方案。"
|
||
if can_schedule else
|
||
"当前资料未通过排产检查,未生成方案。下面已按顺序列出需要补充的数据;补齐前不会开始排产。")),
|
||
blocks=blocks,
|
||
)
|
||
if name == "folder.schedule":
|
||
from server.aps_domain.guidance import scheduling_data_guide
|
||
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),
|
||
"nextActions": scheduling_entry_actions(
|
||
can_schedule=bool(report.get("canSchedule") or kangni_trial),
|
||
reason="请先补齐订单、物料、工艺、工时、设备或班次等排产资料。",
|
||
),
|
||
"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,
|
||
})
|
||
data_guide = scheduling_data_guide(store.data)
|
||
missing_blocks = [analyze_block]
|
||
if data_guide.get("steps"):
|
||
missing_blocks.append(UIBlock(blockId="guidance-data-missing", type="guidance", props=data_guide))
|
||
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="文件中没有可导入的有效记录,当前无法生成排产方案。"
|
||
"下面已按顺序列出需要补充的数据;补齐前不会开始排产。",
|
||
blocks=missing_blocks,
|
||
)
|
||
if report.get("missing") and not has_core and not kangni_trial:
|
||
return AgentReply(
|
||
text="**当前无法排产:**缺少 "
|
||
+ "、".join(report["missing"])
|
||
+ "。下面已按顺序列出补充方法;补齐前不会开始排产。",
|
||
blocks=missing_blocks,
|
||
)
|
||
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 = "设备和模具的实际对应关系还未确认,目前只能试排"
|
||
action_line = "确认后生成试排方案,不会发布生产计划或下发到车间"
|
||
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": True,
|
||
"sourceManifestDigest": report.get("sourceManifestDigest")})
|
||
store.save()
|
||
if kangni_trial:
|
||
production_ready = bool(report.get("productionReady"))
|
||
if production_ready:
|
||
tail = (
|
||
"设备和模具对应关系已齐。"
|
||
"确认后生成试排方案;正式下发前仍需核对供料并单独审批。"
|
||
)
|
||
else:
|
||
tail = (
|
||
"设备和模具对应关系仍需核对。"
|
||
"确认后仅生成试排方案,不会下发到车间。"
|
||
)
|
||
return AgentReply(
|
||
text="数据检查已完成,正在等待你确认生成方案。"
|
||
+ tail,
|
||
blocks=[block],
|
||
)
|
||
return AgentReply(
|
||
text="数据检查已完成,尚未生成方案。请确认下方内容,再保存数据并生成一版试排方案。",
|
||
blocks=[block],
|
||
)
|
||
if name in ("assistant.reply", "unknown"):
|
||
# Pi 是唯一自然语言入口:这里只投递结构化请求,不再做本地意图/话术兜底。
|
||
from server.agent_core import fallback_lane
|
||
|
||
params = dict(intent.params or {})
|
||
params["_piPrimary"] = True
|
||
pi_intent = intent.model_copy(update={"params": params})
|
||
reply = await fallback_lane.propose_reply(
|
||
store, session_id, pi_intent, actor=actor)
|
||
if reply is not None:
|
||
return reply
|
||
return AgentReply(text="智能助手服务暂不可用,本次未执行任何操作。请稍后重试。")
|
||
if name == "flex.time.update":
|
||
pc = str(intent.params.get("productCode") or "")
|
||
op_code = str(intent.params.get("operationCode") or "")
|
||
raw_std_min = intent.params.get("stdMin")
|
||
if not op_code or raw_std_min is None or isinstance(raw_std_min, bool):
|
||
return AgentReply(
|
||
text="工时更新需要结构化参数 operationCode(工序编码)和 stdMin(正数分钟);"
|
||
"本次不从 query 文本猜测工序或工时。"
|
||
)
|
||
try:
|
||
std_min = float(raw_std_min)
|
||
except (TypeError, ValueError):
|
||
return AgentReply(
|
||
text="stdMin 必须是正数(分钟),本次未生成工时更新确认卡。"
|
||
)
|
||
from math import isfinite
|
||
|
||
if not isfinite(std_min) or std_min <= 0:
|
||
return AgentReply(
|
||
text="stdMin 必须是有限正数(分钟),本次未生成工时更新确认卡。"
|
||
)
|
||
# 允许用工序名匹配(「部装」→ 所有名称含部装的工序编码)
|
||
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")
|
||
enabled = intent.params.get("enabled")
|
||
if not sid or not isinstance(enabled, bool):
|
||
return AgentReply(
|
||
text="启停算法 Skill 需要结构化参数 skillId(字符串)和 enabled(布尔值 true/false);"
|
||
"不再从 query 文本推断启停动作。"
|
||
)
|
||
sid = str(sid)
|
||
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_asset, list_compilable_sops, sop_as_block
|
||
asset_id = str((intent.params or {}).get("assetId")
|
||
or (intent.params or {}).get("asset_id") or "").strip()
|
||
if not asset_id:
|
||
return AgentReply(
|
||
text="SOP 编译需要结构化参数 assetId;本次未从 query 文本猜测 SOP。"
|
||
)
|
||
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
|
||
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={"assetId": asset_id, "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_asset, confirmation_for_sop_apply,
|
||
)
|
||
raw = dict(intent.params or {})
|
||
asset_id = str(raw.get("assetId") or raw.get("asset_id") or "").strip()
|
||
pack = raw.get("pack") if isinstance(raw.get("pack"), dict) else None
|
||
if pack is None:
|
||
if not asset_id:
|
||
return AgentReply(
|
||
text="应用 SOP 需要结构化参数 pack,或 assetId 精确指定 SOP;本次未生成确认卡。"
|
||
)
|
||
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
|
||
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, "assetId": asset_id or None},
|
||
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)
|
||
# 未登记工具:Pi 只能调用封闭工具目录;这里显式拒绝,不做自然语言兜底。
|
||
return AgentReply(text="这项操作不在当前可执行工具目录中,本次未执行任何操作。")
|
||
|
||
|
||
# ============================================================
|
||
# 自动化规则动作 → 真实业务动作 执行桥(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}
|