# SPEC-core: Registry, Skills, Router, Ausführung, Verifier Paket: `core/src/knaster`. Alle Schnittstellen zu Modellen laufen über das OpenAI-kompatible API von LiteLLM. ## 1. Registry (`knaster.registry`) Deklarativer Teil (Git): `sites//registry.yaml`. Laufzeitzustand (DB): `slot_state`. ```yaml slots: tier1-general: tier: 1 # 0 = stark/extern, 1 = schwaches LLM, (Stufe 2/3 brauchen keinen Slot) device: gpu # gpu | cpu | external engine: vllm # vllm | llamacpp | openrouter | ... node: gpu-main # Rolle/Node, für Healer und Placement litellm_model: tier1-general base_model: qwen3-14b-awq max_context: 32768 effective_context: 24000 # Startwert; Kalibrierung überschreibt im Laufzeitzustand supports_lora: true fallbacks: [tier1-flex] # nur bei Infrastruktur-Fehlern, nicht bei Qualitätsfehlern adapters: - name: rechnungen-v3 # = Modellname, unter dem vLLM den Adapter bereitstellt base_model: qwen3-14b-awq cluster: rechnungen version: 3 status: active # active | candidate | retired ``` Laufzeitzustand pro Slot: `status ∈ {ok, degraded, unavailable}`, `effective_context` (kalibriert, optional), `reason`, `updated_at`. Für den Router gilt: Ein Slot ist **verfügbar**, wenn `status == ok`. ## 2. Skills (`knaster.skills`) Dateiformat ist `SKILL.md` mit YAML-Frontmatter, kompatibel zu agentskills/Hermes (`name`, `description`). Eigene Felder stehen unter `metadata.knaster`: | Feld | Typ | Default | Bedeutung | |---|---|---|---| | `version` | int | 1 | | | `status` | draft/active/demoted/retired | draft | nur `active` und `demoted` werden geroutet | | `stage` | 0–3 | 0 | freigegebene Ausführungsstufe | | `execution` | llm/program/classifier | llm | Art der Ausführung ab Stufe ≥ 1 | | `slot` | str | tier1-general | Ziel-Slot für Stufe 1 | | `cluster` | str | = name | Adapter-Cluster | | `adapter` | auto/none | auto | Adapter verwenden, falls einer aktiv ist | | `threshold` | float | 0.80 | minimale Ähnlichkeit | | `triggers` | list[str] | [] | zusätzliche Beispielformulierungen fürs Embedding | | `output.format` | text/json | text | | | `output.schema` | JSON-Schema | – | für den Verifier | | `verifier` | list | [] | Prüfungen, siehe §5 | | `max_output_tokens` | int | 1024 | | | `program` | str | – | Name des registrierten Programms (Stufe 2) | | `classifier` | str | – | Name des Classifiers (Stufe 3) | Body: Markdown-Anleitung. Abschnitte, deren Überschrift mit `## Beispiele` beginnt, gelten als **optional**. Die **kompakte Variante** des Skills enthält sie nicht. `demoted` bedeutet: Der Skill wird erkannt, aber auf Stufe 0 ausgeführt, mit dem Skill als Hinweis. ## 3. Router (`knaster.router`) Eingabe ist `RouteRequest{input: str, hints?: {skill?: str}}`. Ausgabe ist `RouteDecision`: ``` stage, slot?, model?, skill?, skill_version?, adapter?, prompt_variant (full|compact|none), similarity?, estimated_tokens, reasons[], signals[], attempt ``` Ablauf: 1. Embedding der Eingabe über `EmbeddingClient` (LiteLLM `/v1/embeddings`, Slot `embed-default`). 2. Kandidaten: Score pro Skill = max(cos(q, description), cos(q, trigger_i)). Davon die Top-k (k = 5). 3. Optionales Reranking der Top-k (`Reranker`, llama.cpp `/v1/rerank`). Die Reihenfolge folgt dann dem Rerank-Score, der Schwellwert prüft weiterhin die Cosine-Ähnlichkeit. 4. Gibt es keinen Kandidaten oder liegt die Ähnlichkeit unter `threshold`, wird mit `no_skill` auf Stufe 0 geroutet. 5. **Konflikt:** Liegt der Zweitplatzierte innerhalb von `conflict_margin` (0,02) und ist kein Reranker aktiv, wird mit `skill_conflict` auf Stufe 0 geroutet. Ist ein Reranker aktiv, wird nur das Signal gesetzt. 6. Bei `status == demoted` oder `stage == 0` geht es auf Stufe 0, mit Skill als Hinweis. 7. **Stufe 3/2:** Classifier bzw. Programm, ohne Slot. 8. **Stufe 1:** Slot des Skills, falls verfügbar, sonst dessen `fallbacks`. Ist keiner verfügbar, wird mit `slot_unavailable` auf Stufe 0 geroutet. 9. **Token-Budget** (§4): erst `full` versuchen, dann `compact`, dann die Fallback-Slots mit größerem Kontext. Passt nichts, wird mit `context_exceeded` auf Stufe 0 geroutet. Passt es auch auf Stufe 0 nicht, ist das Ergebnis ein Fehler `too_large` mit dem Signal `needs_split`. 10. **Adapter:** Wenn `slot.supports_lora`, `skill.adapter == auto` und ein aktiver Adapter für `(slot.base_model, skill.cluster)` existiert, wird `model = "/"` gesetzt (LiteLLM-Wildcard-Route auf den vLLM des Slots), sonst `model = slot.litellm_model`. 11. **Canary:** Hat der Slot im Laufzeitzustand ein `canary_model` (vom Lifecycle gesetzt), geht der Anteil `canary_share` der passenden Anfragen auf den Kandidaten. Bei Adapter-Kandidaten gilt das nur für den Cluster des Kandidaten. Die Entscheidung trägt dann `canary: true`. `escalate(decision, reason)`: - Bei Infrastruktur-Fehlern (`endpoint_error`, `context_overflow`) wird auf derselben Stufe der nächste Fallback-Slot genommen, sonst wird eine Stufe höher gegangen. - Qualitätsfehler (`verifier_failed`, `invalid_format`, `degenerate_output`, `output_truncated`, `classifier_low_confidence`, `program_error`) eskalieren: 3/2 → 1 (falls der Skill `execution: llm` als Rückfall erlaubt, sonst → 0) und 1 → 0. - Maximal 3 Eskalationen. Auf Stufe 0 ist danach Schluss, das Ergebnis lautet `failed`. ## 4. Token-Budget (`knaster.budget`) `estimate_tokens(text)` ist ein konservativer Schätzer (ceil(len/3)) und ersetzbar durch einen echten Tokenizer pro Slot. Bedarf = Systemprompt + Eingabe + `max_output_tokens` + Overhead (64). Ein Prompt passt, wenn Bedarf ≤ `effective_context` des Slots (Laufzeitwert vor Registry-Wert). ## 5. Ausführung (`knaster.executor`) und Verifier (`knaster.verifier`) Executor: - Stufe 1/0: Nachrichten bauen (System = Skill-Body in der gewählten Variante plus Formatanweisung, User = Eingabe), dann `POST /v1/chat/completions`. - Stufe 2: registriertes Programm `fn(input:str) -> str`. - Stufe 3: `EmbeddingClassifier.predict(vector) -> (label, confidence)`. Liegt die Konfidenz unter dem Minimum, wird `classifier_low_confidence` gemeldet. Erkannte Signale (`knaster.problems.Signal`): | Signal | Erkennung | |---|---| | `output_truncated` | `finish_reason == "length"` | | `degenerate_output` | Anteil wiederholter 4-Gramme > 0,5 bei ≥ 40 Wörtern | | `invalid_format` | Format `json`, aber nicht parsebar | | `context_overflow` | HTTP 400 mit „context length“ / „maximum context“ / „too many tokens“ im Text | | `endpoint_error` | Timeout, Verbindungsfehler, HTTP 5xx/429 | Verifier-Prüfungen (`metadata.knaster.verifier`): `json_schema` (gegen `output.schema`), `regex {pattern}`, `contains {value}`, `not_contains {value}`, `max_chars {value}`, `judge {slot, rubric}` (das LLM antwortet `PASS` oder `FAIL: Grund`). Ergebnis ist `VerifyResult{passed, checks[{type, passed, detail}]}`. Bei Format `json` wird `json_schema` immer geprüft, falls ein Schema vorhanden ist. ## 6. Fälle und Datensätze (`knaster.cases`) - Tabelle `cases`: id, ts, input, skill, skill_version, stage, slot, model, adapter, messages (JSON, modellneutral), output, passed, signals, split (`train`/`eval`, deterministisch über sha256(input) mod 10 < 2 → eval), origin (`route`/`graduation`/`shadow`). - Tabelle `preference_pairs`: Entsteht, wenn in einem `/v1/run` eine Stufe ≥ 1 durch den Verifier fällt und eine höhere Stufe besteht. Inhalt: prompt messages, `rejected`, `chosen`, skill, split. - Export als JSONL im Format `{"messages":[...], "meta":{...}}` bzw. `{"prompt":[...],"chosen":"...","rejected":"..."}`. ## 7. Kalibrierung (`knaster.calibration`) `effective_context(observations, target=0.9, min_n=20, buckets=[2k,4k,8k,16k,32k,64k,128k])`: Die Beobachtungen (Prompt-Tokens, bestanden) werden in Buckets gruppiert. Ergebnis ist die Obergrenze des größten Buckets, für den gilt: Dieser und alle kleineren Buckets mit n ≥ min_n haben eine Pass-Rate ≥ target. Ohne ausreichende Daten ist das Ergebnis `None`, und der Registry-Wert bleibt. ## 8. HTTP-API (`knaster.api`) | Methode | Pfad | Zweck | |---|---|---| | GET | `/health` | Liveness | | GET | `/metrics` | Prometheus | | POST | `/v1/route` | RouteDecision | | POST | `/v1/run` | komplette Schleife: Route → Ausführung → Verifier → Eskalation, dazu Fall speichern | | GET | `/v1/registry` | Slots plus Laufzeitzustand | | POST | `/v1/registry/slots/{slot}/state` | Zustand setzen (Healer/Mensch) | | POST | `/v1/maintenance/routine` | Service-Routine starten (SPEC-maintenance) | | POST | `/v1/maintenance/command` | Prompt-Befehl | | GET | `/v1/maintenance/proposals` | offene Vorschläge | | POST | `/v1/healer/alertmanager` | Alertmanager-Webhook (SPEC-healer) | | GET/POST | `/v1/lifecycle/candidates` | Kandidaten (SPEC-lifecycle) | | POST | `/v1/lifecycle/candidates/{id}/report` | Ergebnis eines Jobs melden | | POST | `/v1/skills/{name}/graduate` | Graduierungslauf mit mitgelieferten Varianten | | POST | `/v1/skills/reload` | Skill-Dateien neu laden (nach Git-Pull) | | POST | `/v1/maintenance/proposals/{seq}/approve` bzw. `/reject` | Freigabe per Knopf (n8n/UI) statt per Prompt | ## 9. Canary-Prober (`knaster.canary`) Läuft im Service-Prozess (`KNASTER_CANARY_INTERVAL`, Standard 60 s, 0 = aus). Er prüft jeden Slot außer Stufe 0 und Rerank mit einer Mini-Anfrage („Antworte nur mit OK“) bzw. einem Embedding. Daraus entstehen die Metriken `knaster_slot_endpoint_up`, `knaster_slot_canary_ok` und `knaster_slot_canary_latency_seconds`, jeweils mit den Labels `slot, node, docker_host, container, pve_node, vmid` aus `ops` der Registry. Daraus leiten sich die Alerts `SlotEndpointDown` und `SlotCanaryFailing` für den Healer ab. Authentifizierung: Header `Authorization: Bearer ` auf allen `/v1/*`-Pfaden.