Files
cluster-knaster/docs/spec/SPEC-core.md
T

147 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<cluster>/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 = "<slot.litellm_model>/<adapter.name>"` 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 <KNASTER_API_TOKEN>` auf allen `/v1/*`-Pfaden.