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
+110
View File
@@ -0,0 +1,110 @@
import json
import pytest
from fastapi.testclient import TestClient
from knaster.api import create_app
from .test_router import RECHNUNG
GOOD = json.dumps({"nummer": "4711", "betrag": 99})
H = {"Authorization": "Bearer secret"}
@pytest.fixture
def client(make_system):
sysm = make_system()
sysm.chat.handlers.update({"tier1-general/rechnungen-v1": lambda m: '{"nummer": "1"}',
"tier0-strong": lambda m: GOOD})
return TestClient(create_app(sysm))
def test_auth(client):
assert client.post("/v1/route", json={"input": "x"}).status_code == 401
assert client.get("/health").status_code == 200
def test_route_and_run(client):
r = client.post("/v1/route", json={"input": RECHNUNG}, headers=H).json()
assert r["stage"] == 1 and r["adapter"] == "rechnungen-v1"
r = client.post("/v1/run", json={"input": RECHNUNG}, headers=H).json()
assert r["status"] == "ok" and r["stage"] == 0 and r["escalations"] == 1
assert r["attempts"][0]["verification"]["passed"] is False
assert client.post("/v1/route", json={"input": "x", "skill": "gibtsnicht"}, headers=H).status_code == 404
def test_metrics(client):
client.post("/v1/run", json={"input": RECHNUNG}, headers=H)
text = client.get("/metrics").text
assert 'knaster_runs_total{stage="0",status="ok"} 1.0' in text
assert 'knaster_escalations_total{signal="verifier_failed"} 1.0' in text
assert 'knaster_slot_available{slot="tier1-general"} 1.0' in text
def test_registry_and_slot_state(client):
reg = client.get("/v1/registry", headers=H).json()
assert {s["name"] for s in reg["slots"]} >= {"tier1-general", "tier0-strong"}
r = client.post("/v1/registry/slots/tier1-general/state", json={"status": "degraded", "reason": "x"},
headers=H)
assert r.json()["status"] == "degraded"
r = client.post("/v1/registry/slots/tier1-general/state", json={"status": "ok", "set_by": "n8n"}, headers=H)
assert "proposal" in r.json()
assert client.post("/v1/registry/slots/nope/state", json={"status": "ok"}, headers=H).status_code == 404
def test_maintenance_flow(client):
r = client.post("/v1/maintenance/command", json={"text": "markiere tier1-flex als degraded", "user": "robert"},
headers=H).json()
pid = r["proposals"][0]["id"]
seq = r["proposals"][0]["seq"]
open_ = client.get("/v1/maintenance/proposals", headers=H).json()
assert [p["id"] for p in open_] == [pid]
r = client.post(f"/v1/maintenance/proposals/{seq}/approve", json={"user": "robert"}, headers=H).json()
assert r["state"] == "executed"
assert client.post(f"/v1/maintenance/proposals/{seq}/approve", json={}, headers=H).status_code == 409
rep = client.post("/v1/maintenance/routine", headers=H).json()
assert "Service-Routine" in rep["text"]
def test_healer_endpoint(client):
payload = {"version": "4", "status": "firing", "alerts": [
{"status": "firing", "labels": {"alertname": "GpuTemperatureHigh", "slot": "tier1-flex"},
"fingerprint": "abc"}]}
r = client.post("/v1/healer/alertmanager", json=payload, headers=H).json()
assert r["outcomes"][0]["status"] == "executed"
reg = client.get("/v1/registry", headers=H).json()
assert next(s for s in reg["slots"] if s["name"] == "tier1-flex")["status"] == "degraded"
def test_candidate_via_api_needs_approval_then_reports(client):
r = client.post("/v1/lifecycle/candidates", json={"kind": "adapter", "slot": "tier1-general",
"target": {"cluster": "rechnungen"}}, headers=H).json()
assert r["state"] == "open"
client.post(f"/v1/maintenance/proposals/{r['seq']}/approve", json={}, headers=H)
cands = client.get("/v1/lifecycle/candidates", headers=H).json()
cid = cands[0]["id"]
assert cands[0]["state"] == "building"
r = client.post(f"/v1/lifecycle/candidates/{cid}/report", json={"step": "build", "payload": {"model": "rv2"}},
headers=H).json()
assert r["state"] == "evaluating"
assert client.post(f"/v1/lifecycle/candidates/{cid}/report", json={"step": "canary"},
headers=H).status_code == 409
def test_graduation_endpoint(client):
app_sys = client.app.state.system
app_sys.chat.handlers["tier1-general/rechnungen-v1"] = lambda m: GOOD
variants = [f"{RECHNUNG} Variante {i}" for i in range(10)]
r = client.post("/v1/skills/rechnung-extrahieren/graduate", json={"variants": variants}, headers=H).json()
assert r["report"]["graduated"] and "proposal" in r
cases = app_sys.store.list_cases()
assert len(cases) == 10 and all(c["origin"] == "graduation" for c in cases)
app_sys.chat.handlers["tier1-general/rechnungen-v1"] = lambda m: "kaputt"
r = client.post("/v1/skills/rechnung-extrahieren/graduate", json={"variants": variants}, headers=H).json()
assert not r["report"]["graduated"] and r["report"]["failures"]
assert client.post("/v1/skills/nope/graduate", json={"variants": []}, headers=H).status_code == 404
def test_reload_skills(client):
assert client.post("/v1/skills/reload", headers=H).json()["skills"] == 5
+10 -9
View File
@@ -41,7 +41,7 @@ def make_runner(registry, library, embedder, store, fake_chat, classifiers):
def test_weak_success_records_case(make_runner, store):
runner = make_runner(**{"rechnungen-v1": lambda m: GOOD})
runner = make_runner(**{"tier1-general/rechnungen-v1": lambda m: GOOD})
res = runner.run(RECHNUNG)
assert res.status == "ok" and res.stage == 1 and res.escalations == 0
cases = store.list_cases()
@@ -51,7 +51,7 @@ def test_weak_success_records_case(make_runner, store):
def test_schema_failure_escalates_and_creates_pair(make_runner, store):
runner = make_runner(**{
"rechnungen-v1": lambda m: json.dumps({"nummer": "4711"}), # betrag fehlt
"tier1-general/rechnungen-v1": lambda m: json.dumps({"nummer": "4711"}), # betrag fehlt
"tier0-strong": lambda m: GOOD,
})
res = runner.run(RECHNUNG)
@@ -63,7 +63,7 @@ def test_schema_failure_escalates_and_creates_pair(make_runner, store):
def test_invalid_json_signal(make_runner, store):
runner = make_runner(**{"rechnungen-v1": lambda m: "kein json", "tier0-strong": lambda m: GOOD})
runner = make_runner(**{"tier1-general/rechnungen-v1": lambda m: "kein json", "tier0-strong": lambda m: GOOD})
res = runner.run(RECHNUNG)
assert Signal.INVALID_FORMAT in res.attempts[0].execution.signals
assert res.stage == 0
@@ -74,7 +74,7 @@ def test_endpoint_error_uses_fallback_slot_without_case(make_runner, store):
def boom(m):
raise EndpointError("down")
runner = make_runner(**{"rechnungen-v1": boom})
runner = make_runner(**{"tier1-general/rechnungen-v1": boom})
# Fallback-Slot tier1-flex hat dieselbe Basis → gleicher Adapter-Name; daher Fehler nur einmal
calls = {"n": 0}
@@ -84,7 +84,8 @@ def test_endpoint_error_uses_fallback_slot_without_case(make_runner, store):
raise EndpointError("down")
return GOOD
runner.executor.chat.handlers["rechnungen-v1"] = flaky
runner.executor.chat.handlers["tier1-general/rechnungen-v1"] = flaky
runner.executor.chat.handlers["tier1-flex/rechnungen-v1"] = lambda m: GOOD
res = runner.run(RECHNUNG)
assert res.status == "ok"
assert [a.decision.slot for a in res.attempts] == ["tier1-general", "tier1-flex"]
@@ -96,7 +97,7 @@ def test_context_overflow_detected(make_runner):
def overflow(m):
raise ContextOverflowError("maximum context length")
runner = make_runner(**{"rechnungen-v1": overflow, "tier0-strong": lambda m: GOOD})
runner = make_runner(**{"tier1-general/rechnungen-v1": overflow, "tier0-strong": lambda m: GOOD})
res = runner.run(RECHNUNG)
assert res.attempts[0].execution.signals == [Signal.CONTEXT_OVERFLOW]
assert res.status == "ok"
@@ -105,7 +106,7 @@ def test_context_overflow_detected(make_runner):
def test_truncated_and_degenerate(make_runner):
loop = " ".join(["immer wieder das gleiche"] * 30)
runner = make_runner(**{
"rechnungen-v1": lambda m: ChatResult(content=loop, finish_reason="length"),
"tier1-general/rechnungen-v1": lambda m: ChatResult(content=loop, finish_reason="length"),
"tier0-strong": lambda m: GOOD,
})
res = runner.run(RECHNUNG)
@@ -114,7 +115,7 @@ def test_truncated_and_degenerate(make_runner):
def test_total_failure(make_runner):
runner = make_runner(**{"rechnungen-v1": lambda m: "x", "tier0-strong": lambda m: "y"})
runner = make_runner(**{"tier1-general/rechnungen-v1": lambda m: "x", "tier0-strong": lambda m: "y"})
res = runner.run(RECHNUNG)
assert res.status == "failed"
@@ -193,7 +194,7 @@ def test_split_is_deterministic():
def test_exports(make_runner, store):
runner = make_runner(**{"rechnungen-v1": lambda m: '{"nummer": "1"}', "tier0-strong": lambda m: GOOD})
runner = make_runner(**{"tier1-general/rechnungen-v1": lambda m: '{"nummer": "1"}', "tier0-strong": lambda m: GOOD})
for i in range(30):
runner.run(f"{RECHNUNG} Nr {i}")
train = list(export_sft(store, cluster="rechnungen", split="train"))
+90
View File
@@ -0,0 +1,90 @@
import json
from knaster.cli import main
from knaster.inventory.placement import load_catalog, load_nodes, plan
from knaster.inventory.profile import NodeProfile, parse_lsblk, parse_lscpu, parse_meminfo, parse_nvidia_smi
from knaster.settings import REPO_ROOT
from knaster.validate import validate_repo
SMI = """0, NVIDIA RTX 4000 Ada Generation, 20475, 8.9, 570.86
1, Quadro P2000, 5120, 6.1, 535.183
"""
LSCPU = """Architecture: x86_64
CPU(s): 8
Model name: Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz
Core(s) per socket: 4
Socket(s): 1
"""
def test_parsers():
gpus = parse_nvidia_smi(SMI)
assert [(g.name, g.memory_gb, g.compute_cap) for g in gpus] == [
("NVIDIA RTX 4000 Ada Generation", 20.0, 8.9), ("Quadro P2000", 5.0, 6.1)]
assert parse_meminfo("MemTotal: 32768000 kB\nMemFree: 1 kB") == 31.2
cpu = parse_lscpu(LSCPU)
assert (cpu.cores, cpu.threads) == (4, 8) and "i7-7700" in cpu.model
disks = parse_lsblk(json.dumps({"blockdevices": [{"name": "sda", "size": 256060514304, "rota": False}]}))
assert disks[0].size_gb == 256.1 and not disks[0].rotational
def test_placement_knaster():
roles, models = load_catalog(REPO_ROOT / "catalog")
p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models)
by_role = {}
for a in p.assignments:
by_role.setdefault(a.role, []).append(a)
assert by_role["gpu-main"][0].gpu_name.startswith("NVIDIA RTX 4000 Ada")
assert "qwen3-14b-awq" in by_role["gpu-main"][0].models_fitting
embed_nodes = {a.node for a in by_role["gpu-embed"]}
assert len(embed_nodes) == 2 # Anti-Affinität
assert all("P2000" in a.gpu_name for a in by_role["gpu-embed"] + by_role["gpu-small"])
assert by_role["infra"][0].mode == "cpu"
assert not p.unplaced
assert len(p.free_gpus) == 4 and all("M4000" in g for g in p.free_gpus)
def test_placement_other_cluster():
roles, models = load_catalog(REPO_ROOT / "catalog")
p = plan(load_nodes(REPO_ROOT / "sites/example-single-gpu/inventory.yaml"), roles, models)
modes = {(a.role, a.mode) for a in p.assignments}
assert ("gpu-main", "gpu") in modes and ("gpu-embed", "cpu_fallback") in modes
assert [u.role for u in p.unplaced] == ["gpu-flex"] and p.unplaced[0].optional
def test_placement_maxwell_never_gets_vllm():
roles, models = load_catalog(REPO_ROOT / "catalog")
nodes = [NodeProfile(name="old", memory_gb=32, gpus=[
{"index": 0, "name": "Quadro M6000", "memory_gb": 24, "compute_cap": 5.2}])]
p = plan(nodes, roles, models)
assert "gpu-main" in [u.role for u in p.unplaced]
small = next(a for a in p.assignments if a.role == "gpu-embed" and a.mode == "gpu")
assert small.node == "old"
def test_repo_validates_without_errors():
issues = validate_repo(REPO_ROOT)
assert [i for i in issues if i[0] == "error"] == [], issues
def test_validate_detects_problems(tmp_path):
import shutil
root = tmp_path / "repo"
for d in ("sites", "skills", "config", "catalog", "deploy"):
shutil.copytree(REPO_ROOT / d, root / d)
skill = root / "skills" / "zusammenfassung" / "SKILL.md"
skill.write_text(skill.read_text().replace("slot: tier1-general", "slot: gibts-nicht"))
lite = root / "deploy" / "litellm" / "config.yaml"
lite.write_text(lite.read_text().replace("model_name: verifier-small", "model_name: verifier-x"))
errors = [m for lvl, m in validate_repo(root) if lvl == "error"]
assert any("gibts-nicht" in e for e in errors)
assert any("verifier-small" in e and "LiteLLM" in e for e in errors)
def test_cli(capsys):
assert main(["validate", "--root", str(REPO_ROOT)]) == 0
assert main(["placement", "--inventory", str(REPO_ROOT / "sites/knaster/inventory.yaml"),
"--catalog", str(REPO_ROOT / "catalog")]) == 0
out = capsys.readouterr().out
assert "gpu-main" in out
+4 -3
View File
@@ -96,9 +96,10 @@ def test_canary_metrics_from_cases(life, store):
life.report(cid, "eval", {"pass_rate": 0.95, "n": 40})
life.report(cid, "shadow", {"agreement": 0.9, "n": 20})
for i in range(4):
store.add_case(input=f"x{i}", stage=1, slot="tier1-general", model="rv2", passed=i < 3, split="train",
store.add_case(input=f"x{i}", stage=1, slot="tier1-general", adapter="rv2",
model="tier1-general/rv2", passed=i < 3, split="train",
origin="route", ts=life.get(cid)["updated_at"] + 1)
store.add_case(input="y", stage=1, slot="tier1-general", model="other", passed=False, split="train",
store.add_case(input="y", stage=1, slot="tier1-general", adapter="other", passed=False, split="train",
origin="route")
assert life.canary_metrics(cid) == {"pass_rate": 0.75, "n": 4}
@@ -113,7 +114,7 @@ def test_router_uses_canary_model(registry, library, embedder):
registry.set_canary("tier1-general", "rechnungen-v2", 1.0, "rechnungen")
r = Router(registry, library, embedder, rng=random.Random(1))
d = r.route(RECHNUNG)
assert d.canary and d.model == d.adapter == "rechnungen-v2"
assert d.canary and d.model == "tier1-general/rechnungen-v2" and d.adapter == "rechnungen-v2"
registry.set_canary("tier1-general", "rechnungen-v2", 0.0, "rechnungen")
assert not r.route(RECHNUNG).canary
registry.set_adapter(AdapterSpec(name="x", base_model="base-14b", cluster="andere"))
+2 -1
View File
@@ -190,7 +190,8 @@ def test_routine_evaluates_canary_and_proposes_promotion(make_system):
life.report(cid, "eval", {"pass_rate": 0.95, "n": 40})
life.report(cid, "shadow", {"agreement": 0.9, "n": 20})
for i in range(6):
sysm.store.add_case(input=f"c{i}", stage=1, slot="tier1-general", model="rv2", passed=True,
sysm.store.add_case(input=f"c{i}", stage=1, slot="tier1-general", adapter="rv2",
model="tier1-general/rv2", passed=True,
split="train", origin="route")
report = sysm.maintenance.run_routine()
assert any("Canary" in a for a in report.auto_executed)
+1 -1
View File
@@ -15,7 +15,7 @@ def test_routes_to_weak_slot_with_adapter(router):
d = router.route(RECHNUNG)
assert d.status == "routed"
assert (d.stage, d.slot, d.skill) == (1, "tier1-general", "rechnung-extrahieren")
assert d.model == d.adapter == "rechnungen-v1"
assert d.model == "tier1-general/rechnungen-v1" and d.adapter == "rechnungen-v1"
assert d.prompt_variant == "full"