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
|