core: Inventar/Placement, Konfig-Validierung, CLI, HTTP-API, LiteLLM-Konfiguration, Beispiel-Skills

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 00:47:13 +02:00
co-authored by Claude Opus 5.5
parent f2da893f71
commit 4cbe313c62
20 changed files with 1085 additions and 19 deletions
+204
View File
@@ -0,0 +1,204 @@
"""HTTP-API des Router-Service (SPEC-core §8)."""
from __future__ import annotations
from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Response
from pydantic import BaseModel, Field
from .graduation import graduate
from .lifecycle import LifecycleError
from .settings import Settings
from .system import System, build_system
class RouteIn(BaseModel):
input: str
skill: str | None = None
class SlotStateIn(BaseModel):
status: str = Field(pattern="^(ok|degraded|unavailable)$")
reason: str = ""
set_by: str = "human"
class CommandIn(BaseModel):
text: str
user: str = "human"
session: str = "default"
class DecisionIn(BaseModel):
user: str = "human"
reason: str = ""
class CandidateIn(BaseModel):
kind: str = Field(pattern="^(base_model|adapter|quantization|embedding_model|engine)$")
slot: str
target: dict[str, Any]
requested_by: str = "api"
class ReportIn(BaseModel):
step: str
payload: dict[str, Any] = Field(default_factory=dict)
class GraduateIn(BaseModel):
variants: list[str]
threshold: float = 0.9
min_variants: int = 10
def create_app(system: System | None = None) -> FastAPI:
system = system or build_system(Settings.from_env())
app = FastAPI(title="knaster", version="0.1.0")
app.state.system = system
def auth(authorization: str | None = Header(default=None)) -> None:
token = system.settings.api_token
if token and authorization != f"Bearer {token}":
raise HTTPException(status_code=401, detail="unauthorized")
v1 = [Depends(auth)]
@app.get("/health")
def health() -> dict[str, Any]:
return {"status": "ok", "skills": len(system.library.all()), "dry_run": system.settings.dry_run}
@app.get("/metrics")
def metrics() -> Response:
for slot in system.registry.slots():
system.metrics.slot_status.labels(slot.name).set(1 if slot.available else 0)
system.metrics.open_proposals.set(len(system.maintenance.proposals("open")))
return Response(system.metrics.render(), media_type="text/plain; version=0.0.4")
# --- Routing ----------------------------------------------------------------------------
@app.post("/v1/route", dependencies=v1)
def route(body: RouteIn) -> dict[str, Any]:
try:
return system.router.route(body.input, hint_skill=body.skill).model_dump(mode="json")
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
@app.post("/v1/run", dependencies=v1)
def run(body: RouteIn) -> dict[str, Any]:
try:
result = system.runner.run(body.input, hint_skill=body.skill)
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
data = result.model_dump(mode="json")
data["escalations"] = result.escalations
return data
# --- Registry ---------------------------------------------------------------------------
@app.get("/v1/registry", dependencies=v1)
def registry() -> dict[str, Any]:
return {
"slots": [{**s.spec.model_dump(), "status": s.status, "reason": s.reason, "set_by": s.set_by,
"effective_context": s.effective_context, "base_model": s.base_model,
"canary_model": s.canary_model, "canary_share": s.canary_share}
for s in system.registry.slots()],
"adapters": [a.model_dump() for a in system.registry.adapters()],
"skills": [{"name": s.name, "status": s.meta.status, "stage": s.meta.stage, "slot": s.meta.slot,
"cluster": s.cluster, "version": s.meta.version} for s in system.library.all()],
}
@app.post("/v1/registry/slots/{slot}/state", dependencies=v1)
def slot_state(slot: str, body: SlotStateIn) -> dict[str, Any]:
if not system.registry.has_slot(slot):
raise HTTPException(404, f"Slot {slot} unbekannt")
if body.status == "ok" and body.set_by != "human":
# Wieder-Freischalten nur über Vorschlag/Freigabe, außer ein Mensch setzt es direkt
p = system.maintenance.propose("mark_slot", {"slot": slot, "status": "ok", "reason": body.reason},
f"Slot {slot} wieder auf ok setzen", source="api")
return {"proposal": p.id}
system.registry.set_status(slot, body.status, reason=body.reason, set_by=body.set_by) # type: ignore
return {"slot": slot, "status": body.status}
# --- Pflege -----------------------------------------------------------------------------
@app.post("/v1/maintenance/routine", dependencies=v1)
def routine() -> dict[str, Any]:
report = system.maintenance.run_routine()
return {**report.model_dump(mode="json"), "proposal_ids": [p.id for p in report.proposals]}
@app.post("/v1/maintenance/command", dependencies=v1)
def command(body: CommandIn) -> dict[str, Any]:
reply = system.maintenance.handle_command(body.text, user=body.user, session=body.session)
return {"text": reply.text, "proposals": [{**p.model_dump(mode="json"), "id": p.id}
for p in reply.proposals]}
@app.get("/v1/maintenance/proposals", dependencies=v1)
def proposals(state: str | None = "open") -> list[dict[str, Any]]:
return [{**p.model_dump(mode="json"), "id": p.id} for p in system.maintenance.proposals(state)]
@app.post("/v1/maintenance/proposals/{seq}/{decision}", dependencies=v1)
def decide(seq: int, decision: str, body: DecisionIn) -> dict[str, Any]:
try:
if decision == "approve":
p = system.maintenance.approve(seq, by=body.user)
elif decision == "reject":
p = system.maintenance.reject(seq, by=body.user, reason=body.reason)
else:
raise HTTPException(404, "approve oder reject")
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
return {**p.model_dump(mode="json"), "id": p.id}
# --- Healer -----------------------------------------------------------------------------
@app.post("/v1/healer/alertmanager", dependencies=v1)
def alertmanager(payload: dict[str, Any]) -> dict[str, Any]:
outcomes = system.healer.handle_alertmanager(payload)
return {"outcomes": [o.model_dump() for o in outcomes]}
# --- Lifecycle --------------------------------------------------------------------------
@app.get("/v1/lifecycle/candidates", dependencies=v1)
def candidates(state: str | None = None) -> list[dict[str, Any]]:
return system.store.list_candidates(state=state)
@app.post("/v1/lifecycle/candidates", dependencies=v1)
def new_candidate(body: CandidateIn) -> dict[str, Any]:
if not system.registry.has_slot(body.slot):
raise HTTPException(404, f"Slot {body.slot} unbekannt")
p = system.maintenance.propose("create_candidate",
{"kind": body.kind, "slot": body.slot, "target": body.target},
f"Kandidat {body.kind} für {body.slot}: {body.target}",
reason=f"angefragt von {body.requested_by}", source="api")
return {**p.model_dump(mode="json"), "id": p.id}
@app.post("/v1/lifecycle/candidates/{cid}/report", dependencies=v1)
def report(cid: str, body: ReportIn) -> dict[str, Any]:
try:
return system.lifecycle.report(cid, body.step, body.payload)
except LifecycleError as exc:
raise HTTPException(409, str(exc)) from exc
# --- Skills -----------------------------------------------------------------------------
@app.post("/v1/skills/{name}/graduate", dependencies=v1)
def graduate_skill(name: str, body: GraduateIn) -> dict[str, Any]:
if not system.library.has(name):
raise HTTPException(404, f"Skill {name} unbekannt")
rep = graduate(name, body.variants, router=system.router, executor=system.executor,
verifier=system.verifier, library=system.library, store=system.store,
threshold=body.threshold, min_variants=body.min_variants)
out: dict[str, Any] = {"report": rep.model_dump()}
if rep.graduated:
p = system.maintenance.propose("promote_skill", {"skill": name, "stage": 1},
f"Skill {name} für Stufe 1 freigeben",
reason=f"Graduierung {rep.passed_n}/{rep.n} ({rep.pass_rate:.0%})",
source="graduation")
out["proposal"] = p.id
return out
@app.post("/v1/skills/reload", dependencies=v1)
def reload_skills() -> dict[str, Any]:
system.library.reload(system.settings.skills_dir)
system.router.refresh()
return {"skills": len(system.library.all())}
return app
+82
View File
@@ -0,0 +1,82 @@
"""Kommandozeile: serve, validate, inventory, placement, registry export."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import yaml
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="knaster")
sub = parser.add_subparsers(dest="cmd", required=True)
p_serve = sub.add_parser("serve", help="Router-Service starten")
p_serve.add_argument("--host", default="0.0.0.0")
p_serve.add_argument("--port", type=int, default=8080)
p_val = sub.add_parser("validate", help="Konfiguration prüfen")
p_val.add_argument("--root", default=".")
p_val.add_argument("--site", default="knaster")
p_inv = sub.add_parser("inventory", help="Inventar")
inv_sub = p_inv.add_subparsers(dest="inv_cmd", required=True)
p_col = inv_sub.add_parser("collect", help="lokales System auslesen")
p_col.add_argument("--name")
p_plan = sub.add_parser("placement", help="Placement-Plan")
p_plan.add_argument("--inventory", required=True)
p_plan.add_argument("--catalog", default="catalog")
p_reg = sub.add_parser("registry", help="Registry")
reg_sub = p_reg.add_subparsers(dest="reg_cmd", required=True)
reg_sub.add_parser("export", help="effektiven Stand (inkl. Laufzeitzustand) als YAML ausgeben")
args = parser.parse_args(argv)
if args.cmd == "serve":
import uvicorn
uvicorn.run("knaster.api:create_app", factory=True, host=args.host, port=args.port)
return 0
if args.cmd == "validate":
from .validate import validate_repo
issues = validate_repo(Path(args.root), args.site)
for level, msg in issues:
print(f"{level.upper():8} {msg}")
errors = [i for i in issues if i[0] == "error"]
print(f"{len(errors)} Fehler, {len(issues) - len(errors)} Warnungen")
return 1 if errors else 0
if args.cmd == "inventory":
from .inventory.profile import collect
print(yaml.safe_dump({"nodes": [collect(args.name).model_dump()]}, sort_keys=False, allow_unicode=True))
return 0
if args.cmd == "placement":
from .inventory.placement import load_catalog, load_nodes, plan
roles, models = load_catalog(args.catalog)
result = plan(load_nodes(args.inventory), roles, models)
print(yaml.safe_dump(result.model_dump(), sort_keys=False, allow_unicode=True))
return 1 if any(not u.optional for u in result.unplaced) else 0
if args.cmd == "registry":
from .registry import Registry
from .settings import Settings
from .store import Store
settings = Settings.from_env()
registry = Registry.from_file(settings.site_dir / "registry.yaml", Store(settings.db_url))
print(yaml.safe_dump(registry.export(), sort_keys=False, allow_unicode=True))
return 0
return 2
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
"""Inventar, Modell-Katalog und Placement (Übertragbarkeit auf andere Cluster)."""
+153
View File
@@ -0,0 +1,153 @@
"""Deterministisches Placement: Rollen → GPUs/Nodes anhand von Katalog-Anforderungen."""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field
from ..util import load_yaml
from .profile import GPU, NodeProfile
class ModelSpec(BaseModel):
name: str
kind: Literal["llm", "embedding", "reranker"]
vram_gb: float
min_cc: float = 5.0
engines: list[str]
context: int = 8192
lora: bool = False
cpu_ok: bool = False
note: str = ""
class Needs(BaseModel):
gpu: bool = True
engine: str | None = None
min_vram_gb: float = 0
min_cc: float = 0
max_cc: float | None = None
min_ram_gb: float = 0
class RoleSpec(BaseModel):
name: str
priority: int = 100
count: int = 1
needs: Needs = Field(default_factory=Needs)
prefer: Literal["largest", "smallest_fitting"] = "largest"
kinds: list[str] = Field(default_factory=list)
cpu_fallback: bool = False
optional: bool = False
slots: list[str] = Field(default_factory=list)
class Assignment(BaseModel):
role: str
node: str
gpu_index: int | None = None
gpu_name: str | None = None
vram_gb: float | None = None
mode: Literal["gpu", "cpu", "cpu_fallback"]
models_fitting: list[str] = Field(default_factory=list)
slots: list[str] = Field(default_factory=list)
class Unplaced(BaseModel):
role: str
reason: str
optional: bool = False
class PlacementPlan(BaseModel):
assignments: list[Assignment] = Field(default_factory=list)
unplaced: list[Unplaced] = Field(default_factory=list)
free_gpus: list[str] = Field(default_factory=list)
def roles_on(self, node: str) -> list[str]:
return [a.role for a in self.assignments if a.node == node]
def load_catalog(directory: str | Path) -> tuple[list[RoleSpec], list[ModelSpec]]:
d = Path(directory)
roles = [RoleSpec(name=k, **v) for k, v in (load_yaml(d / "roles.yaml").get("roles") or {}).items()]
models = [ModelSpec(name=k, **v) for k, v in (load_yaml(d / "models.yaml").get("models") or {}).items()]
return roles, models
def load_nodes(path: str | Path) -> list[NodeProfile]:
return [NodeProfile(**n) for n in load_yaml(path).get("nodes") or []]
def _gpu_ok(gpu: GPU, needs: Needs) -> bool:
if gpu.memory_gb < needs.min_vram_gb or gpu.compute_cap < needs.min_cc:
return False
return needs.max_cc is None or gpu.compute_cap <= needs.max_cc
def _fitting_models(role: RoleSpec, models: list[ModelSpec], gpu: GPU | None) -> list[str]:
out = []
for m in models:
if m.kind not in role.kinds:
continue
if gpu is None:
if m.cpu_ok:
out.append(m.name)
continue
if role.needs.engine and role.needs.engine not in m.engines:
continue
if m.vram_gb <= gpu.memory_gb and m.min_cc <= gpu.compute_cap:
out.append(m.name)
return out
def plan(nodes: list[NodeProfile], roles: list[RoleSpec], models: list[ModelSpec]) -> PlacementPlan:
result = PlacementPlan()
free: list[tuple[NodeProfile, GPU]] = [(n, g) for n in nodes for g in n.gpus]
cpu_load: dict[str, int] = {n.name: 0 for n in nodes}
for role in sorted(roles, key=lambda r: (r.priority, r.name)):
placed = 0
if role.needs.gpu:
used_nodes: set[str] = set()
for _ in range(role.count):
options = [(n, g) for n, g in free if _gpu_ok(g, role.needs)
and _fitting_models(role, models, g)]
if not options:
break
# Anti-Affinität: Replikate einer Rolle möglichst auf verschiedene Hosts
if role.prefer == "largest":
options.sort(key=lambda x: (x[0].name in used_nodes, -x[1].memory_gb, -x[1].compute_cap,
x[0].name, x[1].index))
else:
options.sort(key=lambda x: (x[0].name in used_nodes, x[1].memory_gb, -x[1].compute_cap,
x[0].name, x[1].index))
node, gpu = options[0]
free.remove((node, gpu))
used_nodes.add(node.name)
result.assignments.append(Assignment(
role=role.name, node=node.name, gpu_index=gpu.index, gpu_name=gpu.name,
vram_gb=gpu.memory_gb, mode="gpu", models_fitting=_fitting_models(role, models, gpu),
slots=role.slots))
placed += 1
missing = role.count - placed
if missing and (not role.needs.gpu or role.cpu_fallback):
mode: Literal["cpu", "cpu_fallback"] = "cpu" if not role.needs.gpu else "cpu_fallback"
candidates = [n for n in nodes if n.memory_gb >= role.needs.min_ram_gb]
# bevorzugt Nodes ohne GPU-Rollen, dann wenig belastete, dann viel RAM
gpu_roles = {a.node for a in result.assignments if a.mode == "gpu"}
candidates.sort(key=lambda n: (n.name in gpu_roles, cpu_load[n.name], -n.memory_gb, n.name))
for node in candidates[:missing]:
cpu_load[node.name] += 1
result.assignments.append(Assignment(role=role.name, node=node.name, mode=mode,
models_fitting=_fitting_models(role, models, None),
slots=role.slots))
missing -= 1
if missing:
reason = "keine passende GPU frei" if role.needs.gpu else "kein Node mit genug RAM"
result.unplaced.append(Unplaced(role=role.name, reason=f"{reason} ({missing} fehlen)",
optional=role.optional))
result.free_gpus = [f"{n.name}/gpu{g.index} {g.name} ({g.memory_gb} GB)" for n, g in free]
return result
+108
View File
@@ -0,0 +1,108 @@
"""Node-Profile und Parser für Systemausgaben (reine Funktionen, testbar)."""
from __future__ import annotations
import csv
import io
import json
import re
import shutil
import socket
import subprocess
from pathlib import Path
from pydantic import BaseModel, Field
class GPU(BaseModel):
index: int
name: str
memory_gb: float
compute_cap: float
driver: str | None = None
class Disk(BaseModel):
name: str
size_gb: float
rotational: bool = False
class CPU(BaseModel):
model: str = ""
cores: int = 0
threads: int = 0
class NodeProfile(BaseModel):
name: str
cpu: CPU = Field(default_factory=CPU)
memory_gb: float = 0
disks: list[Disk] = Field(default_factory=list)
gpus: list[GPU] = Field(default_factory=list)
labels: dict[str, str] = Field(default_factory=dict)
NVIDIA_SMI_QUERY = ["nvidia-smi", "--query-gpu=index,name,memory.total,compute_cap,driver_version",
"--format=csv,noheader,nounits"]
def parse_nvidia_smi(text: str) -> list[GPU]:
gpus = []
for row in csv.reader(io.StringIO(text.strip())):
if not row or not row[0].strip():
continue
idx, name, mem_mib, cc, driver = (c.strip() for c in row[:5])
gpus.append(GPU(index=int(idx), name=name, memory_gb=round(float(mem_mib) / 1024, 1),
compute_cap=float(cc), driver=driver))
return gpus
def parse_meminfo(text: str) -> float:
m = re.search(r"^MemTotal:\s+(\d+)\s+kB", text, re.MULTILINE)
return round(int(m.group(1)) / 1024 / 1024, 1) if m else 0.0
def parse_lscpu(text: str) -> CPU:
fields = {}
for line in text.splitlines():
if ":" in line:
k, v = line.split(":", 1)
fields[k.strip()] = v.strip()
threads = int(fields.get("CPU(s)", "0") or 0)
cores = int(fields.get("Core(s) per socket", "0") or 0) * int(fields.get("Socket(s)", "1") or 1)
return CPU(model=fields.get("Model name", ""), cores=cores, threads=threads)
def parse_lsblk(text: str) -> list[Disk]:
data = json.loads(text)
disks = []
for dev in data.get("blockdevices", []):
rota = dev.get("rota")
disks.append(Disk(name=dev["name"], size_gb=round(int(dev["size"]) / 1e9, 1),
rotational=rota in (True, "1", 1)))
return disks
def _run(cmd: list[str]) -> str | None:
if shutil.which(cmd[0]) is None:
return None
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=20, check=True).stdout
except (subprocess.SubprocessError, OSError):
return None
def collect(name: str | None = None) -> NodeProfile:
"""Liest das lokale System aus (Linux). Fehlende Werkzeuge ergeben leere Felder."""
smi = _run(NVIDIA_SMI_QUERY)
lscpu = _run(["lscpu"])
lsblk = _run(["lsblk", "-J", "-b", "-d", "-o", "NAME,SIZE,ROTA"])
meminfo = Path("/proc/meminfo").read_text() if Path("/proc/meminfo").exists() else ""
return NodeProfile(
name=name or socket.gethostname(),
cpu=parse_lscpu(lscpu) if lscpu else CPU(),
memory_gb=parse_meminfo(meminfo),
disks=parse_lsblk(lsblk) if lsblk else [],
gpus=parse_nvidia_smi(smi) if smi else [],
)
+2 -1
View File
@@ -176,7 +176,8 @@ class Lifecycle:
cand = self.get(cid)
model = cand["target"].get("model")
since = next((h["ts"] for h in cand.get("history") or [] if h["to"] == "canary"), None)
rows = [r for r in self.store.list_cases(stage=1, since=since) if r["model"] == model]
column = "adapter" if cand["kind"] == "adapter" else "model"
rows = [r for r in self.store.list_cases(stage=1, since=since) if r[column] == model]
return {"pass_rate": pass_rate(rows), "n": len([r for r in rows if r["passed"] is not None])}
# --- Promote / Rollback -----------------------------------------------------------------
+10 -4
View File
@@ -46,6 +46,11 @@ class RouterConfig:
default_max_output_tokens: int = 1024
def adapter_model(slot_alias: str, adapter: str) -> str:
"""LiteLLM-Name eines Adapters: '<slot>/<adapter>' (Wildcard-Route auf den vLLM des Slots)."""
return f"{slot_alias}/{adapter}"
def system_prompt(skill: Skill | None, variant: PromptVariant) -> str:
parts: list[str] = []
if skill is not None and variant != "none":
@@ -211,15 +216,16 @@ class Router:
if slot.spec.supports_lora and skill.meta.adapter == "auto":
found = self.registry.active_adapter(slot.base_model, skill.cluster)
if found:
model, adapter = found.name, found.name
model, adapter = adapter_model(slot.spec.litellm_model, found.name), found.name
reasons.append(f"Adapter {found.name}")
canary = False
if (slot.canary_model and slot.canary_cluster in (None, skill.cluster)
and self.rng.random() < slot.canary_share):
canary = True
model = slot.canary_model
if slot.canary_cluster:
adapter = slot.canary_model
if slot.canary_cluster: # Adapter-Kandidat
model, adapter = adapter_model(slot.spec.litellm_model, slot.canary_model), slot.canary_model
else: # Basis-/Engine-Kandidat unter eigenem LiteLLM-Alias
model = slot.canary_model
reasons.append(f"Canary {slot.canary_model}")
return self._decision(stage=1, slot=slot.name, model=model, adapter=adapter, skill=skill, canary=canary,
prompt_variant=variant, similarity=similarity,
+4
View File
@@ -116,6 +116,10 @@ class SkillLibrary:
skills.append(parse_skill(file.read_text(encoding="utf-8"), str(file)))
return cls(skills, store)
def reload(self, directory: str | Path) -> None:
"""Lädt die Dateien neu; das Objekt bleibt, damit alle Referenzen (Router, Agent) gültig bleiben."""
self._skills = SkillLibrary.from_dir(directory)._skills
def _states(self) -> dict[str, dict[str, Any]]:
if not self.store:
return {}
+96
View File
@@ -0,0 +1,96 @@
"""Konsistenzprüfung der Konfiguration (CI und `knaster validate`)."""
from __future__ import annotations
from pathlib import Path
from pydantic import ValidationError
from . import programs
from .healer.engine import HealerConfig
from .inventory.placement import load_catalog
from .maintenance.actions import ActionExecutor
from .policy import Policy
from .registry import Registry
from .skills import SkillLibrary
from .store import Store
from .util import load_yaml
def _known_actions() -> set[str]:
return {name.removeprefix("_do_") for name in dir(ActionExecutor) if name.startswith("_do_")}
def validate_repo(root: str | Path, site: str = "knaster") -> list[tuple[str, str]]:
root = Path(root)
issues: list[tuple[str, str]] = []
def err(msg: str) -> None:
issues.append(("error", msg))
def warn(msg: str) -> None:
issues.append(("warning", msg))
store = Store("sqlite://")
try:
registry = Registry.from_file(root / "sites" / site / "registry.yaml", store)
except (ValidationError, ValueError, OSError) as exc:
return [("error", f"Registry: {exc}")]
slots = {s.name: s for s in registry.slots()}
if not any(s.spec.tier == 0 and s.spec.kind == "chat" for s in slots.values()):
err("Registry: kein Stufe-0-Slot (tier 0, kind chat)")
try:
library = SkillLibrary.from_dir(root / "skills")
except (ValidationError, ValueError) as exc:
return issues + [("error", f"Skills: {exc}")]
for skill in library.all():
m = skill.meta
if m.slot not in slots:
err(f"Skill {skill.name}: Slot {m.slot} unbekannt")
elif slots[m.slot].spec.kind != "chat":
err(f"Skill {skill.name}: Slot {m.slot} ist kein Chat-Slot")
for check in m.verifier:
if check.get("type") == "judge" and check.get("slot", "verifier-small") not in slots:
err(f"Skill {skill.name}: Judge-Slot {check.get('slot', 'verifier-small')} unbekannt")
if m.execution == "program" and m.program and m.program not in programs.names():
(err if m.stage >= 2 else warn)(f"Skill {skill.name}: Programm {m.program} nicht registriert")
if m.execution == "classifier" and m.classifier:
path = root / "config" / "classifiers" / f"{m.classifier}.json"
if not path.exists():
(err if m.stage >= 3 else warn)(f"Skill {skill.name}: Classifier {path.name} fehlt")
if m.output.format == "json" and not m.output.schema_:
warn(f"Skill {skill.name}: JSON-Ausgabe ohne Schema")
litellm = root / "deploy" / "litellm" / "config.yaml"
if litellm.exists():
names = {m["model_name"] for m in load_yaml(litellm).get("model_list") or []}
for s in slots.values():
if s.spec.litellm_model not in names:
err(f"LiteLLM: Alias {s.spec.litellm_model} (Slot {s.name}) fehlt in {litellm.name}")
else:
warn("LiteLLM-Konfiguration fehlt")
known = _known_actions()
cfg = root / "config"
if (cfg / "healer-rules.yaml").exists():
healer = HealerConfig.from_file(cfg / "healer-rules.yaml")
for rule in healer.rules:
for step in [*rule.ladder, *rule.on_resolved]:
if step.action not in known:
err(f"Healer-Regel {rule.id}: unbekannte Aktion {step.action}")
for a in healer.enabled_actions:
if a not in {"restart_container", "restart_vm", "reboot_host"}:
err(f"Healer: enabled_actions enthält {a} (nur Infrastruktur-Aktionen erlaubt)")
if (cfg / "policy.yaml").exists():
for kind in Policy.from_file(cfg / "policy.yaml").actions:
if kind not in known:
err(f"Policy: unbekannte Aktion {kind}")
if (root / "catalog").exists():
roles, _ = load_catalog(root / "catalog")
for role in roles:
for slot in role.slots:
if slot not in slots:
err(f"Rolle {role.name}: Slot {slot} fehlt in der Registry")
return issues