# ============================================================ # 外部算法排产 Skill 注册表(moduleId: core-skills, 可重生 ✅) # 桌面端:~/.aps/skills//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