264 lines
9.6 KiB
Python
264 lines
9.6 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 外部算法排产 Skill 注册表(moduleId: core-skills, 可重生 ✅)
|
|||
|
|
# 桌面端:~/.aps/skills/<skill_id>/manifest.json(对标 Codex skills 目录)
|
|||
|
|
# Web:server/data/skills/…;APS_SKILLS_PATH 仍可指向单文件 JSON(测试兼容)
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import shutil
|
|||
|
|
import tempfile
|
|||
|
|
import threading
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
from pydantic import BaseModel, Field
|
|||
|
|
|
|||
|
|
_PATH_ENV = "APS_SKILLS_PATH"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SkillManifest(BaseModel):
|
|||
|
|
skill_id: str
|
|||
|
|
name: str
|
|||
|
|
endpoint: str = "local://stub"
|
|||
|
|
auth: str = ""
|
|||
|
|
max_power: str = "P1"
|
|||
|
|
track: str = "flex"
|
|||
|
|
enabled: bool = True
|
|||
|
|
timeout_sec: float = 60.0
|
|||
|
|
description: str = ""
|
|||
|
|
golden_tests: list[str] = Field(default_factory=list)
|
|||
|
|
version: str = "1.0"
|
|||
|
|
capabilities: list[str] = Field(
|
|||
|
|
default_factory=lambda: ["flex_schedule"])
|
|||
|
|
ragScopes: list[str] = Field(default_factory=list)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _default_skills() -> list[dict[str, Any]]:
|
|||
|
|
return [
|
|||
|
|
SkillManifest(
|
|||
|
|
skill_id="algo.stub",
|
|||
|
|
name="内置算法桩(演示)",
|
|||
|
|
endpoint="local://stub",
|
|||
|
|
track="flex",
|
|||
|
|
enabled=True,
|
|||
|
|
description="本地桩:把工序顺序串到能力池首台设备,供联调与黄金测试",
|
|||
|
|
golden_tests=["test_external_engine"],
|
|||
|
|
capabilities=["flex_schedule"],
|
|||
|
|
ragScopes=["process", "sop"],
|
|||
|
|
).model_dump()
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _skills_root() -> Path:
|
|||
|
|
"""Skill 根目录(目录化布局)。"""
|
|||
|
|
from server.aps_home import skills_dir
|
|||
|
|
return skills_dir()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SkillRegistry:
|
|||
|
|
"""外部算法 skill 清单:目录扫描 + 启停/健康检查。"""
|
|||
|
|
|
|||
|
|
def __init__(self, path: str | None = None) -> None:
|
|||
|
|
# path:单文件 JSON(测试/旧配置);None → ~/.aps/skills 目录布局
|
|||
|
|
self.legacy_file = path or os.environ.get(_PATH_ENV)
|
|||
|
|
self._lock = threading.Lock()
|
|||
|
|
self.health_history: dict[str, list[dict[str, Any]]] = {}
|
|||
|
|
self.skills: list[dict[str, Any]] = self._load()
|
|||
|
|
if not self.skills:
|
|||
|
|
self.skills = _default_skills()
|
|||
|
|
self._write_all()
|
|||
|
|
else:
|
|||
|
|
upgraded = False
|
|||
|
|
for s in self.skills:
|
|||
|
|
for key, default in (("version", "1.0"),
|
|||
|
|
("capabilities", ["flex_schedule"]),
|
|||
|
|
("ragScopes", [])):
|
|||
|
|
if key not in s:
|
|||
|
|
s[key] = default
|
|||
|
|
upgraded = True
|
|||
|
|
if upgraded:
|
|||
|
|
self._write_all()
|
|||
|
|
|
|||
|
|
def _load(self) -> list[dict[str, Any]]:
|
|||
|
|
if self.legacy_file:
|
|||
|
|
return self._load_legacy_file(Path(self.legacy_file))
|
|||
|
|
root = _skills_root()
|
|||
|
|
root.mkdir(parents=True, exist_ok=True)
|
|||
|
|
# 迁移:若仍有旧 skills.json,导入后可保留文件
|
|||
|
|
legacy = root.parent / "skills.json"
|
|||
|
|
if not any(root.iterdir()) and legacy.exists():
|
|||
|
|
items = self._load_legacy_file(legacy)
|
|||
|
|
for s in items:
|
|||
|
|
self._write_one(root, s)
|
|||
|
|
return items
|
|||
|
|
# 同层 server/data/skills.json(web 默认 data 根)
|
|||
|
|
flat = root.parent / "skills.json" if root.name == "skills" else None
|
|||
|
|
out: list[dict[str, Any]] = []
|
|||
|
|
for child in sorted(root.iterdir()):
|
|||
|
|
if not child.is_dir() or child.name.startswith("."):
|
|||
|
|
continue
|
|||
|
|
manifest = child / "manifest.json"
|
|||
|
|
if not manifest.exists():
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|||
|
|
if isinstance(data, dict) and data.get("skill_id"):
|
|||
|
|
out.append(data)
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
continue
|
|||
|
|
if not out and flat and flat.exists():
|
|||
|
|
return self._load_legacy_file(flat)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _load_legacy_file(path: Path) -> list[dict[str, Any]]:
|
|||
|
|
try:
|
|||
|
|
with open(path, "r", encoding="utf-8") as f:
|
|||
|
|
return json.load(f).get("skills", [])
|
|||
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _write_one(root: Path, skill: dict[str, Any]) -> None:
|
|||
|
|
sid = skill.get("skill_id") or "unknown"
|
|||
|
|
folder = root / sid
|
|||
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|||
|
|
path = folder / "manifest.json"
|
|||
|
|
fd, tmp = tempfile.mkstemp(dir=str(folder), suffix=".tmp")
|
|||
|
|
try:
|
|||
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(skill, f, ensure_ascii=False, indent=2)
|
|||
|
|
os.replace(tmp, path)
|
|||
|
|
except BaseException:
|
|||
|
|
if os.path.exists(tmp):
|
|||
|
|
os.unlink(tmp)
|
|||
|
|
raise
|
|||
|
|
# 可选说明文件,便于人在资源管理器里阅读
|
|||
|
|
readme = folder / "README.md"
|
|||
|
|
if not readme.exists():
|
|||
|
|
readme.write_text(
|
|||
|
|
f"# {skill.get('name') or sid}\n\n"
|
|||
|
|
f"- id: `{sid}`\n"
|
|||
|
|
f"- endpoint: `{skill.get('endpoint')}`\n"
|
|||
|
|
f"- {skill.get('description') or ''}\n",
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def _write_all(self) -> None:
|
|||
|
|
if self.legacy_file:
|
|||
|
|
path = Path(self.legacy_file)
|
|||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|||
|
|
try:
|
|||
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump({"skills": self.skills}, f, ensure_ascii=False, indent=1)
|
|||
|
|
os.replace(tmp, path)
|
|||
|
|
except BaseException:
|
|||
|
|
if os.path.exists(tmp):
|
|||
|
|
os.unlink(tmp)
|
|||
|
|
raise
|
|||
|
|
return
|
|||
|
|
root = _skills_root()
|
|||
|
|
root.mkdir(parents=True, exist_ok=True)
|
|||
|
|
keep = {s["skill_id"] for s in self.skills}
|
|||
|
|
for s in self.skills:
|
|||
|
|
self._write_one(root, s)
|
|||
|
|
# 清理已删除的 skill 目录
|
|||
|
|
for child in root.iterdir():
|
|||
|
|
if child.is_dir() and child.name not in keep and not child.name.startswith("."):
|
|||
|
|
shutil.rmtree(child, ignore_errors=True)
|
|||
|
|
|
|||
|
|
def list(self) -> list[dict[str, Any]]:
|
|||
|
|
return list(self.skills)
|
|||
|
|
|
|||
|
|
def get(self, skill_id: str) -> dict[str, Any] | None:
|
|||
|
|
return next((s for s in self.skills if s["skill_id"] == skill_id), None)
|
|||
|
|
|
|||
|
|
def get_enabled(self, skill_id: str | None = None, track: str | None = None) -> dict[str, Any] | None:
|
|||
|
|
if skill_id:
|
|||
|
|
s = self.get(skill_id)
|
|||
|
|
return s if s and s.get("enabled") else None
|
|||
|
|
for s in self.skills:
|
|||
|
|
if not s.get("enabled"):
|
|||
|
|
continue
|
|||
|
|
if track and s.get("track") != track:
|
|||
|
|
continue
|
|||
|
|
return s
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def upsert(self, manifest: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
m = SkillManifest(**manifest).model_dump()
|
|||
|
|
with self._lock:
|
|||
|
|
for i, s in enumerate(self.skills):
|
|||
|
|
if s["skill_id"] == m["skill_id"]:
|
|||
|
|
self.skills[i] = m
|
|||
|
|
self._write_all()
|
|||
|
|
return m
|
|||
|
|
self.skills.append(m)
|
|||
|
|
self._write_all()
|
|||
|
|
return m
|
|||
|
|
|
|||
|
|
def set_enabled(self, skill_id: str, enabled: bool) -> dict[str, Any]:
|
|||
|
|
with self._lock:
|
|||
|
|
s = self.get(skill_id)
|
|||
|
|
if not s:
|
|||
|
|
raise ValueError(f"未找到 skill:{skill_id}")
|
|||
|
|
s["enabled"] = bool(enabled)
|
|||
|
|
self._write_all()
|
|||
|
|
return dict(s)
|
|||
|
|
|
|||
|
|
def health(self, skill_id: str | None = None) -> list[dict[str, Any]]:
|
|||
|
|
from datetime import datetime
|
|||
|
|
targets = [self.get(skill_id)] if skill_id else self.skills
|
|||
|
|
out = []
|
|||
|
|
for s in targets:
|
|||
|
|
if not s:
|
|||
|
|
continue
|
|||
|
|
status = self._probe(s)
|
|||
|
|
record = {"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), **status}
|
|||
|
|
hist = self.health_history.setdefault(s["skill_id"], [])
|
|||
|
|
hist.append(record)
|
|||
|
|
del hist[:-20]
|
|||
|
|
out.append({"skill_id": s["skill_id"], "name": s["name"],
|
|||
|
|
"enabled": s.get("enabled"), "endpoint": s.get("endpoint"),
|
|||
|
|
**status})
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
def history(self, skill_id: str) -> list[dict[str, Any]]:
|
|||
|
|
return list(self.health_history.get(skill_id) or [])
|
|||
|
|
|
|||
|
|
def _probe(self, s: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
ep = s.get("endpoint") or ""
|
|||
|
|
if ep.startswith("local://"):
|
|||
|
|
return {"ok": True, "latencyMs": 0, "detail": "local stub"}
|
|||
|
|
health_url = ep.rstrip("/") + "/health"
|
|||
|
|
try:
|
|||
|
|
headers = {}
|
|||
|
|
if s.get("auth"):
|
|||
|
|
headers["Authorization"] = f"Bearer {s['auth']}"
|
|||
|
|
with httpx.Client(timeout=5.0) as client:
|
|||
|
|
r = client.get(health_url, headers=headers)
|
|||
|
|
return {"ok": r.status_code < 400, "latencyMs": int(r.elapsed.total_seconds() * 1000),
|
|||
|
|
"detail": f"HTTP {r.status_code}"}
|
|||
|
|
except Exception as exc:
|
|||
|
|
return {"ok": False, "latencyMs": None, "detail": str(exc)}
|
|||
|
|
|
|||
|
|
|
|||
|
|
_registry: SkillRegistry | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_skills() -> SkillRegistry:
|
|||
|
|
global _registry
|
|||
|
|
if _registry is None:
|
|||
|
|
_registry = SkillRegistry()
|
|||
|
|
return _registry
|
|||
|
|
|
|||
|
|
|
|||
|
|
def reset_skills_registry() -> None:
|
|||
|
|
"""测试用:丢弃单例,下次按当前环境变量重建。"""
|
|||
|
|
global _registry
|
|||
|
|
_registry = None
|