aps-agent/server/engines/external_engine.py

333 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# ============================================================
# 外部算法引擎适配器(moduleId: engines-external, 可重生 ✅)
# ISchedulingEngine:组中性 DTO → HTTP/local skill → 写回 world
# ============================================================
from __future__ import annotations
from copy import deepcopy
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from server.aps_domain.scheduling_dto import (
SchedulingSolution, apply_flex_solution, world_to_flex_problem,
)
from server.contracts import ScheduleResult
from server.engines.base import EngineParams, ISchedulingEngine
class ExternalEngine(ISchedulingEngine):
"""外部算法排产:通过 SkillRegistry 选定 skill,HTTP 或 local://stub。"""
name = "EXTERNAL"
supports_anytime = False
def __init__(self, skill_id: str | None = None) -> None:
self.skill_id = skill_id
def solve(self, world: dict[str, Any], params: EngineParams, next_id) -> ScheduleResult:
from server.agent_core.skills import get_skills
skill_id = self.skill_id or (
params.meta_skill_id if hasattr(params, "meta_skill_id") else None
)
skill_id = skill_id or world.pop("_externalSkillId", None)
registry = get_skills()
skill = (
registry.get_enabled(skill_id, track="flex")
or registry.get_enabled(skill_id)
or registry.get_enabled()
)
if not skill:
raise ValueError("No enabled external scheduling skill is available")
order_ids = list(params.orderIds or []) or None
start_time = (
params.startDate + " 08:00"
if params.startDate and " " not in params.startDate
else params.startDate
)
if _is_explicit_demo_world(world):
problem = world_to_flex_problem(
world,
order_ids=order_ids,
start_time=start_time,
)
solution = self._call_skill(skill, problem.model_dump())
summary = apply_flex_solution(
world,
solution,
next_id,
skill_id=skill["skill_id"],
)
summary["executionMode"] = "EXPLICIT_DEMO_LEGACY"
else:
summary, solution, problem = _run_closed_loop_external_candidate(
world,
next_id,
skill,
order_ids=order_ids,
start_time=start_time,
)
evidence_refs = [f"skill:{skill['skill_id']}", f"run:{solution.runId}"]
if summary.get("planningProblemId"):
evidence_refs.append(f"planning-problem:{summary['planningProblemId']}")
return ScheduleResult(
versionId=summary["versionId"],
versionNo=summary["versionNo"],
engineType="EXTERNAL",
strategy=f"EXTERNAL:{skill['skill_id']}",
status="DRAFT",
orderCount=len(problem.orders),
poCount=summary["vlCount"],
woCount=summary["woCount"],
conflictCount=summary["conflictCount"],
totalTardiness=float((solution.kpi or {}).get("totalTardiness") or 0),
avgUtilization=float(summary.get("avgUtilization") or 0),
evidenceRefs=evidence_refs,
solveStatus=str(summary.get("solveStatus") or solution.status),
solveTimeSec=float((solution.solverMeta or {}).get("timeSec") or 0) or None,
)
def _call_skill(self, skill: dict[str, Any], problem: dict) -> SchedulingSolution:
ep = skill.get("endpoint") or ""
if ep.startswith("local://"):
from server.integrations.algo_skill_stub import solve_problem
return solve_problem(problem)
url = ep.rstrip("/") + "/schedule"
headers = {"Content-Type": "application/json"}
if skill.get("auth"):
headers["Authorization"] = f"Bearer {skill['auth']}"
timeout = float(skill.get("timeout_sec") or 60)
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, json=problem, headers=headers)
resp.raise_for_status()
return SchedulingSolution(**resp.json())
except Exception as exc:
raise ValueError(f"外部算法 skill「{skill.get('name')}」调用失败:{exc}") from exc
def _is_explicit_demo_world(world: dict[str, Any]) -> bool:
return any(
str(factory.get("name") or "") == "\u6f14\u793a\u5de5\u5382"
for factory in world.get("factories") or []
)
def _run_closed_loop_external_candidate(
world: dict[str, Any],
next_id,
skill: dict[str, Any],
*,
order_ids: list[int] | None,
start_time: str | None,
) -> tuple[dict[str, Any], SchedulingSolution, Any]:
"""Validate a legacy Skill result against V2 before materializing it."""
from server.aps_domain.closed_loop_problem import build_closed_loop_problem
from server.aps_domain.closed_loop_runtime import (
closed_loop_to_problem_v2,
flex_version_to_solution_v2,
persist_closed_loop_projection,
project_admitted_demands_to_flex_orders,
)
from server.aps_domain.scheduling_problem_v2 import SolveStatus
from server.aps_domain.scheduling_validator import validate_solution
selected_order_nos = None
if order_ids:
selected = set(order_ids)
selected_order_nos = sorted({
str(row.get("salesOrderNo") or row.get("orderNo"))
for row in world.get("flexOrders") or []
if row.get("id") in selected and (row.get("salesOrderNo") or row.get("orderNo"))
})
business_date = str(
world.get("businessDate")
or datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
)[:10]
closed_loop = build_closed_loop_problem(
world,
business_date=business_date,
order_nos=selected_order_nos,
strict=True,
)
problem_v2 = closed_loop_to_problem_v2(world, closed_loop)
persist_closed_loop_projection(world, closed_loop, problem_v2)
admitted = [
row
for row in closed_loop.manufacturing_demands
if row.release_status in {"READY", "READY_FOR_SCHEDULING"}
and row.routing_status == "READY"
and row.resource_status == "READY"
]
if not admitted:
blocker_counts = closed_loop.stats.get("blockerCounts") or {}
details = ", ".join(f"{code}={count}" for code, count in sorted(blocker_counts.items())[:5])
raise ValueError(f"External Skill scheduling admission blocked: {details or 'no admitted MAKE demand'}")
candidate = deepcopy(world)
projected = project_admitted_demands_to_flex_orders(candidate, closed_loop)
legacy_problem = world_to_flex_problem(
candidate,
order_ids=projected["orderIds"],
start_time=start_time,
)
engine = ExternalEngine(skill_id=skill["skill_id"])
legacy_solution = engine._call_skill(skill, legacy_problem.model_dump())
summary = apply_flex_solution(
candidate,
legacy_solution,
next_id,
skill_id=skill["skill_id"],
)
version_id = int(summary["versionId"])
version = next(
row for row in candidate["flexScheduleVersions"] if row.get("id") == version_id
)
business_version_no = f"EXT{business_date.replace('-', '')}-{version_id:03d}"
version.update({
"versionNo": business_version_no,
"createdAt": f"{business_date} 00:00",
"orderCount": len(projected["orderIds"]),
"demandCount": len(closed_loop.manufacturing_demands),
"admittedDemandCount": len(admitted),
"unscheduledDemandCount": max(0, len(admitted) - int(summary.get("vlCount") or 0)),
"planningProblemId": closed_loop.problem_id,
"planningSourceHash": closed_loop.source_revision,
})
solution_v2 = flex_version_to_solution_v2(
candidate,
closed_loop,
problem_v2,
version_id,
)
report = validate_solution(problem_v2, solution_v2, world=candidate)
if not report.valid or solution_v2.solveStatus != SolveStatus.FEASIBLE:
details = "; ".join(
f"{issue.code}:{issue.message}"
for issue in report.hardViolations[:5]
)
raise ValueError(
f"External Skill solution rejected by SchedulingProblemV2 validator: {details or solution_v2.solveStatus.value}"
)
version["schedulingSolutionV2"] = solution_v2.model_dump(mode="json")
version["validationReport"] = report.model_dump(mode="json")
version["solveStatus"] = solution_v2.solveStatus.value
version["engineType"] = "EXTERNAL"
for key in (
"flexOrders",
"flexRoutings",
"flexScheduleVersions",
"flexVirtualLines",
"flexWorkOrders",
"flexConflicts",
):
world[key] = candidate[key]
summary.update({
"versionNo": business_version_no,
"executionMode": "CLOSED_LOOP_V2_VALIDATED",
"solveStatus": solution_v2.solveStatus.value,
"planningProblemId": closed_loop.problem_id,
"planningSourceHash": closed_loop.source_revision,
"validation": version["validationReport"],
"projectedFlexOrders": projected,
})
return summary, legacy_solution, legacy_problem
def run_external_flex(store, *, skill_id: str | None = None,
order_ids: list[int] | None = None, actor: str = "planner") -> dict[str, Any]:
"""Run external flexible scheduling through the closed-loop V2 validator."""
from datetime import timedelta
from server.agent_core.audit import write_audit
from server.agent_core.skills import get_skills
from server.aps_domain.mrp import decompose_orders
from server.aps_domain.orders import sync_flex_orders_to_sales
from server.aps_domain.sourcing import annotate_world_sourcing
from server.timeutil import add_minutes, fmt_dt, today0
registry = get_skills()
skill = (
registry.get_enabled(skill_id, track="flex")
or registry.get_enabled(skill_id)
or registry.get_enabled()
)
if not skill:
raise ValueError("No enabled external scheduling skill is available")
if _is_explicit_demo_world(store.data):
problem = world_to_flex_problem(
store.data,
order_ids=order_ids,
start_time=fmt_dt(add_minutes(today0(), 24 * 60)),
)
engine = ExternalEngine(skill_id=skill["skill_id"])
solution = engine._call_skill(skill, problem.model_dump())
summary = apply_flex_solution(
store.data,
solution,
store.next_id,
skill_id=skill["skill_id"],
)
summary["executionMode"] = "EXPLICIT_DEMO_LEGACY"
else:
synced = sync_flex_orders_to_sales(store.data)
annotate_world_sourcing(store.data)
decomposition = decompose_orders(store.data, store.next_id)
business_date = str(
store.data.get("businessDate")
or datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
)[:10]
start_day = datetime.fromisoformat(business_date).date() + timedelta(days=1)
summary, solution, problem = _run_closed_loop_external_candidate(
store.data,
store.next_id,
skill,
order_ids=order_ids,
start_time=f"{start_day.isoformat()} 08:00",
)
summary["salesOrdersSynced"] = synced
summary["decompose"] = {
"orders": len(decomposition.get("orders") or []),
"make": len(decomposition.get("make") or []),
"purchase": len(decomposition.get("purchase") or []),
"outsource": len(decomposition.get("outsource") or []),
}
write_audit(
store.data,
store.next_id,
actor=actor,
category="ALGO_RUN",
action="external.flex.schedule",
target={"type": "FLEX_VERSION", "id": summary["versionId"]},
power="P1",
rationale={
"skillId": skill["skill_id"],
"runId": solution.runId,
"executionMode": summary.get("executionMode"),
"solveStatus": summary.get("solveStatus") or solution.status,
"planningProblemId": summary.get("planningProblemId"),
"planningSourceHash": summary.get("planningSourceHash"),
"validationValid": (summary.get("validation") or {}).get("valid"),
},
evidence_refs=[
f"skill:{skill['skill_id']}",
f"run:{solution.runId}",
*(
[f"planning-problem:{summary['planningProblemId']}"]
if summary.get("planningProblemId")
else []
),
],
)
store.save()
return summary