commit fef930d1766e9f9e4fd8aa4ed5c626b334d540c9 Author: Robert Rapp Date: Sun Oct 11 14:00:36 2026 +0200 docs: Architekturplan, ADRs, Spezifikationen und Umsetzungsplan Co-Authored-By: Claude Opus 5.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2cc446 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Kopieren nach .env – niemals committen. +# Router-Service +KNASTER_API_TOKEN=change-me +KNASTER_DB_URL=postgresql+psycopg://knaster:change-me@postgres:5432/knaster +KNASTER_SITE_DIR=/app/sites/knaster +KNASTER_SKILLS_DIR=/app/skills +KNASTER_CONFIG_DIR=/app/config +KNASTER_DRY_RUN=true + +# LiteLLM-Gateway +LITELLM_BASE_URL=http://litellm:4000 +LITELLM_API_KEY=change-me +LITELLM_MASTER_KEY=change-me +OPENROUTER_API_KEY= +RERANK_URL=http://gpu-embed:8081 + +# Healer-Treiber +PORTAINER_HOST= +PORTAINER_KEY= +PROXMOX_URL=https://pve.example:8006 +PROXMOX_TOKEN_ID=knaster@pve!healer +PROXMOX_TOKEN_SECRET= +NOTIFY_WEBHOOK_URL= + +# n8n (nur für Import/Tests) +N8N_HOST= +N8N_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19cc47a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +*.env +!.env.example +!*.env.example +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +data/ +*.db +.terraform/ +*.tfstate* +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..99436d8 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# cluster-knaster + +Ein starkes LLM erarbeitet neue Aufgaben und destilliert sie zu **Skills**. Danach übernehmen kleinere, selbst gehostete Modelle diese Aufgaben. Embeddings erkennen wieder, routen und treffen einfache Entscheidungen. Betrieb auf einem Proxmox-Cluster mit gemischten GPUs, gesteuert über n8n, mit halbautomatischer Pflege (Human-in-the-Loop). + +``` +Anfrage → n8n → Router-Service ──▶ Stufe 3 Embedding-Classifier (CPU) + │ ├─▶ Stufe 2 Programm (CPU) + │ ├─▶ Stufe 1 schwaches LLM + Skill (+LoRA) über LiteLLM → vLLM + │ └─▶ Stufe 0 starkes LLM über LiteLLM → OpenRouter + └─ Verifier → bestanden: Antwort + Fall speichern + → nicht bestanden: Eskalation + Präferenzpaar +``` + +## Dokumentation + +| Dokument | Inhalt | +|---|---| +| [docs/PLAN.md](docs/PLAN.md) | Architektur und Betrieb: Proxmox, Pflege, Autohealing, Lifecycle, Finetuning, Problemerkennung, Portabilität | +| [docs/adr/](docs/adr/) | Architekturentscheidungen | +| [docs/spec/](docs/spec/) | Spezifikationen der Komponenten | +| [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) | Arbeitspakete, Teststrategie, Ablauf des Deployments | + +## Repo-Struktur + +``` +core/ Python-Paket `knaster` (Router-Service) + Tests +config/ Policy, Healer-Regeln, Grenzwerte (portabel) +catalog/ Modell- und Rollenkatalog mit Hardware-Anforderungen +skills/ SKILL.md-Bibliothek +deploy/ Docker Compose pro Rolle, LiteLLM, Prometheus, Alertmanager +n8n/workflows/ importierbare n8n-Workflows +platform/proxmox/ OpenTofu + Ansible +sites/knaster/ standortspezifisch: Inventar, Registry +``` + +## Entwicklung + +```bash +make dev # venv + Abhängigkeiten +make test # Unit- und Integrationstests +make lint # ruff +make validate # Compose-, Workflow- und Konfigurationsprüfung +``` + +Alle Aktionen gegen Infrastruktur laufen standardmäßig im **Dry-Run** (`KNASTER_DRY_RUN=true`). diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md new file mode 100644 index 0000000..aaac02f --- /dev/null +++ b/docs/IMPLEMENTATION.md @@ -0,0 +1,35 @@ +# Umsetzungsplan + +Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zum gemeinsamen Deployment wird nichts auf Produktivsysteme ausgerollt. Alle externen Aufrufe (LLMs, Proxmox, Portainer, n8n) sind hinter Schnittstellen gekapselt und in Tests durch Fakes ersetzt. + +## Arbeitspakete + +| # | Paket | Spec | Inhalt | Status | +|---|---|---|---|---| +| 1 | Grundlagen | SPEC-core §1–2 | Settings, Registry, Skill-Parser, Store (SQLAlchemy, SQLite/Postgres) | ✅ | +| 2 | Routing | SPEC-core §3–4 | Embedding-Client, Skill-Index, Reranker, Token-Budget, Router, Eskalation | ✅ | +| 3 | Ausführung | SPEC-core §5–7 | LLM-Client, Executor (Stufe 0–3), Signale, Verifier, Fälle/Präferenzpaare, Kalibrierung | ✅ | +| 4 | Pflege | SPEC-maintenance | Policy, Vorschläge, Service-Routine, Befehlsgrammatik, Intent-Mapper | ✅ | +| 5 | Healing | SPEC-healer | Regel-Engine, Treiber (Portainer, Proxmox, Notify), Dry-Run | ✅ | +| 6 | Lifecycle | SPEC-lifecycle | Kandidaten-Zustandsmaschine, Promote/Rollback, Graduierung | ✅ | +| 7 | Portabilität | SPEC-inventory | Inventar-Parser, Katalog, Placement, CLI | ✅ | +| 8 | API | SPEC-core §8 | FastAPI-App, Auth, Metriken | ✅ | +| 9 | Deployment-Artefakte | PLAN §1, §3 | Compose pro Rolle, LiteLLM, Prometheus-Regeln, Alertmanager, n8n-Workflows | ✅ | +| 10 | Plattform | ADR-0001/0002 | OpenTofu (bpg/proxmox), Ansible-Rollen (Treiber-Pinning, Docker) | ✅ (ungetestet gegen echten Cluster) | +| 11 | CI | – | Gitea Actions: Tests, Lint, Compose-Validierung | ✅ | + +## Teststrategie + +- **Unit-Tests** pro Modul (`core/tests`), deterministisch und ohne Netz. Für Embeddings kommt ein `HashingEmbedder` zum Einsatz (Bag-of-Words, normalisiert). Er liefert reproduzierbare Ähnlichkeiten. +- **Integrationstests** der API mit FastAPI-`TestClient`, Fake-LLM und SQLite im Speicher. Sie decken die komplette `/v1/run`-Schleife inkl. Eskalation und Präferenzpaar ab. +- **Artefakt-Tests:** Compose-Dateien, Prometheus-Regeln, LiteLLM-Konfiguration und n8n-Workflows werden strukturell geprüft (YAML/JSON gültig, referenzierte Knoten vorhanden, Slots konsistent zur Registry). +- **Nicht getestet bis zum gemeinsamen Deployment:** echte Modelle, Proxmox-API, Portainer-Aktionen, OpenTofu-Apply. + +## Gemeinsames Deployment (später) + +1. Inventarlauf auf allen Nodes → `sites/knaster/inventory.yaml` ergänzen → `knaster placement plan` +2. Proxmox: ZFS-Pools, Resource Mappings, Templates (`platform/proxmox`) +3. Infra-LXC: `deploy/compose/infra` mit `KNASTER_DRY_RUN=true` +4. GPU-VMs nacheinander, nach jeder Rolle Canary-Checks +5. n8n-Workflows importieren (zuerst inaktiv), Credentials setzen +6. Dry-Run eine Woche beobachten, dann Aktionen einzeln freischalten diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..fb29893 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,306 @@ +# embedd-everything – Architektur- und Betriebsplan + +Stand: 2026-10-11 (überarbeitet: Netzwerk 1 GbE, verteilte SSDs, halbautomatischer Pflege-Agent) + +Ziel: Ein starkes LLM (OpenRouter) erarbeitet neue Aufgaben einmalig und bricht sie in Skills herunter. Danach erledigen schwächere, selbst gehostete Modelle diese Aufgaben. Embedding-Modelle übernehmen Wiedererkennung, Routing und einfache Entscheidungen. Die Last verteilt sich auf CPU und GPU, RAM und VRAM. + +--- + +## 0. Leitprinzipien + +1. **Control Plane und Data Plane trennen.** n8n, Registry und Router *entscheiden*. vLLM, llama.cpp und die Embedding-Server *rechnen*. Fällt die Control Plane aus, läuft die Data Plane mit dem letzten Stand weiter. +2. **Slots statt Modellnamen.** n8n, Hermes und Skills sprechen nur abstrakte Slots an, etwa `tier1-general`, `verifier-small` oder `embed-default`. Welches Modell und welcher Adapter dahinterstehen, steht ausschließlich in der Registry. Ein Modellwechsel ist dann eine Datenänderung und kein Umbau. +3. **Daten sind das Asset, Gewichte sind Cache.** Verifizierte Fälle, Skills und Eval-Sets werden modellneutral gespeichert. Adapter und Quantisierungen lassen sich daraus jederzeit neu erzeugen. +4. **Alles deklarativ.** Gewünschter Zustand liegt in Git (IaC und Konfiguration), ein Reconcile-Prozess gleicht den Ist-Zustand ab. Das ist die Basis für Autohealing und Übertragbarkeit. +5. **Jede automatische Aktion ist begrenzt.** Autohealing hat Rate Limits und Eskalationsstufen und ruft ab Stufe X einen Menschen. + +--- + +## 1. Proxmox-Einbindung + +### 1.1 VM oder LXC? + +| | VM mit PCI-Passthrough | LXC mit GPU-Durchreichung | +|---|---|---| +| Overhead | gering: CPU ~2–5 %, GPU praktisch nativ | minimal | +| RAM | **fest reserviert** (Passthrough pinnt den gesamten VM-RAM, kein Ballooning) | flexibel geteilt | +| Treiber/CUDA | **eigener pro VM**, unabhängig vom Host | an den Host-Treiber gekoppelt | +| GPU-Teilung | 1 GPU → 1 VM | mehrere Container pro GPU möglich | +| Isolation | stark (eigener Kernel) | schwächer | +| Snapshots/Backup | vollständig | ja, mit Einschränkungen bei Bind-Mounts | + +**Empfehlung:** +- **GPU-Nodes als VM.** Grund: Die alten Karten (Maxwell M4000, Pascal P2000) brauchen fest gepinnte Treiber mit CUDA 12.x, die Ada-Karten profitieren von aktuellen Treibern. Mit VMs lebt beides sauber getrennt auf einem Cluster. Treiber-Updates werden per Snapshot rückrollbar. +- **CPU-Infrastruktur als LXC oder leichte VM** (n8n, Postgres, LiteLLM, Monitoring). Das spart RAM auf den i7-Nodes. +- Innerhalb der VMs bzw. LXCs läuft Docker Compose, ein Compose-Projekt pro Rolle. + +### 1.2 Was Proxmox zusätzlich ermöglicht + +- **Resource Mappings für PCI-Geräte.** GPUs werden clusterweit logisch benannt (z. B. `gpu-ada-1`). VM-Konfigurationen verweisen auf den Namen statt auf eine PCI-Adresse, wodurch sie zwischen Nodes mit gleicher Karte portabel werden. +- **Snapshots vor jedem riskanten Schritt** (Treiber-Update, Modellwechsel, vLLM-Upgrade). Schlägt die Eval fehl, folgt ein Rollback in Sekunden. +- **Templates und Cloud-Init.** Jede Rolle (`gpu-vllm`, `gpu-llamacpp`, `infra`) ist ein Template, damit neue Nodes reproduzierbar sind. +- **Staging durch Klonen.** Eine geklonte VM dient als Testumgebung für neue Modellversionen, ohne die Produktion anzufassen. +- **HA-Gruppen** für die zustandslosen und CPU-Dienste: automatischer Neustart auf einem anderen Node. GPU-VMs sind nicht live-migrierbar. Ein Neustart auf einem anderen Node geht nur, wenn dort eine gleich gemappte Karte steckt. +- **Proxmox Backup Server** für Postgres (Skills, Registry, Traces), Konfiguration und Datensätze. Modellgewichte nicht sichern, die lassen sich neu laden. +- **Energie.** Selten genutzte GPU-VMs (z. B. M4000) per API bedarfsgesteuert starten und stoppen. Für echte Stromersparnis ganze Hosts per Wake-on-LAN schlafen legen bzw. wecken. +- **API-Steuerung.** Alles oben Genannte ist über die Proxmox-API automatisierbar, aus n8n, Terraform/OpenTofu (Provider `bpg/proxmox`) oder dem Healer-Service. + +### 1.3 Rollenverteilung (aktuelle Hardware) + +| Rolle | Hardware | Engine | Aufgabe | +|---|---|---|---| +| `gpu-main` | RTX 4000 Ada #1 (20 GB) | vLLM, Multi-LoRA | Slot `tier1-general` plus Skill-Adapter | +| `gpu-flex` | RTX 4000 Ada #2 (20 GB) | vLLM / Unsloth | tagsüber zweiter Slot oder Reserve, nachts LoRA-Training | +| `gpu-embed` | 3× Quadro P2000 (5 GB) | llama.cpp (Embedding/Rerank) | `embed-default`, `rerank-default`, redundant | +| `gpu-small` | 4× Quadro M4000 (8 GB) | llama.cpp | `verifier-small`, `signature-extractor` (3–4B) – **auf Nutzen prüfen** | +| `infra` | i7 6./7. Gen, 32 GB | Docker | n8n, LiteLLM, Postgres+pgvector, Redis, Router, Healer, Langfuse, Prometheus/Grafana | +| `strong` | extern | OpenRouter | `tier0-strong`, Skill-Autor (Hermes) | + +Wichtig: Auf Maxwell und Pascal läuft kein vLLM (das braucht Compute Capability ≥ 7.0), und CUDA 13 unterstützt sie nicht mehr. Daher auf diesen Nodes Treiber und Images pinnen. + +### 1.4 Netzwerk und Storage (entschieden, siehe ADR-0002) + +Ausgangslage: **1 GbE** zwischen den Nodes, pro physischer Maschine **2–3 SSDs mit 128–256 GB**. + +- **Kein Ceph.** Ceph braucht für brauchbare Latenz und Rebuild-Zeiten mindestens 10 GbE und mehr Platz pro OSD. Mit 1 GbE und kleinen SSDs würde jeder Schreibvorgang über das Netz gebremst und ein Rebuild das Netz tagelang belegen. +- **ZFS lokal pro Node.** SSD 1 trägt das Proxmox-System. SSD 2 und 3 bilden, wo vorhanden, einen ZFS-Mirror `tank` für VM-Disks. Auf Nodes mit nur zwei SSDs ist `tank` eine Einzel-SSD, die über Replikation abgesichert wird. +- **ZFS-Replikation** (Proxmox-Job, alle 15 Min.) für die Infra-VMs auf einen zweiten Node. Zusammen mit Proxmox HA startet die VM nach einem Node-Ausfall dort mit maximal 15 Min. altem Stand. Für Postgres wird das zusätzlich durch PBS-Backups und WAL-Archivierung abgefedert. +- **Modelle liegen lokal** auf der GPU-VM, in einem eigenen Disk-Image (`models`, nicht repliziert, nicht gesichert). Quelle ist Hugging Face oder ein optionaler **Modell-Mirror** auf einem Infra-Node (HTTP/NFS, nur lesend). 10 GB über 1 GbE dauern etwa 90 s, das ist für Modellwechsel unkritisch. +- **Proxmox Backup Server** als VM oder LXC auf dem Node mit dem meisten freien SSD-Platz, oder besser auf einer externen Platte bzw. einem NAS. Gesichert werden Postgres, Konfiguration und Datensätze, keine Modellgewichte. +- **Netztrennung per VLAN** (Proxmox SDN): `mgmt` (Proxmox, PBS), `svc` (Dienste untereinander), `ext` (Zugriff von außen auf n8n und Router). Ohne Bandbreitengewinn, aber Replikation und Backups lassen sich zeitlich steuern, damit sie den Inferenz-Traffic nicht stören. + +**Speicherbudget pro Rolle (Richtwerte):** + +| Rolle | belegt | Inhalt | +|---|---|---| +| `gpu-main` / `gpu-flex` | 60–100 GB | aktives Basismodell (~9 GB bei 14B/AWQ), Kandidat (~9 GB), 4-Bit-Trainingsbasis (~9 GB), Adapter (je 50–200 MB), Checkpoints, Docker-Images (~15 GB) | +| `gpu-embed` / `gpu-small` | 15–25 GB | Embedding-, Rerank- und kleine GGUF-Modelle (je 0,5–3 GB), Images | +| `infra` | 40–80 GB | Postgres (Fälle, Traces), Prometheus (15 Tage Retention), Langfuse | + +Die genaue SSD-Verteilung wird beim Inventarlauf erfasst (`sites/knaster/inventory.yaml`). + +--- + +## 2. Betrieb und Pflege + +### 2.1 Was gepflegt werden muss + +| Objekt | Pflege | Automatisierbar? | +|---|---|---| +| Skill-Bibliothek | Duplikate zusammenführen, veraltete Skills stilllegen, Konflikte (zwei Skills fast gleich ähnlich) auflösen | großteils: Clustering plus Vorschlag, Freigabe durch Mensch | +| Eval-Sets (Golden Sets) | pro Skill wachsen lassen, Ausreißer prüfen, Leaks ins Training vermeiden | ja, mit Stichproben-Review | +| Registry | neue Modelle und Slots, Kalibrierungswerte | ja, über Kalibrierungs-Job (Abschnitt 7) | +| Adapter | Versionen, Rollback, Aufräumen alter Versionen | ja (Lifecycle-Pipeline) | +| Embedding-Index | Re-Embedding bei Wechsel des Embedding-Modells | ja, Blue/Green (Abschnitt 4.4) | +| Treiber, CUDA, Engines | Updates mit Snapshot, Eval, Rollback | halbautomatisch | +| OpenRouter-Kosten | Budget-Alarme, Anteil Stufe 0 beobachten | ja (LiteLLM-Budgets) | +| Backups | Restore-Test | ja, monatlich | + +### 2.2 Wartungsrhythmus + +- **Täglich, automatisch:** Health-Report (Pass-Rates pro Skill und Slot, Eskalationsquote, Kosten, Fehlerklassen). Er geht nur bei Auffälligkeiten an einen Menschen. +- **Nächtlich:** Graduierungs-Läufe, LoRA-Training für Cluster mit genug neuen Fällen, Re-Evals. +- **Wöchentlich, ca. 15–30 Min. Mensch:** Service-Routine mit dem Pflege-Agenten (Abschnitt 2.3): Freigaben, Merge-Vorschläge, Stichproben aus Eskalationen. +- **Monatlich:** Restore-Test, Kandidaten für Updates von Basismodell, Engine und Treiber prüfen. + +### 2.3 Pflege-Agent mit Human-in-the-Loop (Service-Routine) + +Die Pflege läuft **halbautomatisch**, geführt von einem Pflege-Agenten (siehe `docs/spec/SPEC-maintenance.md`): + +1. **Service-Routine.** Wöchentlich per Zeitplan, jederzeit auch auf Zuruf („starte die Service-Routine“). Der Agent sammelt Befunde: Skills mit sinkender Pass-Rate, ähnliche Skills (Merge-Kandidaten), offene Kandidaten der Lifecycle-Pipeline, Slots im Zustand `degraded`, Häufungen von Kontext-Ausweichern und den Kostenstand. +2. Daraus erzeugt er **Vorschläge** (`P-`), jeweils mit Begründung, Risiko und erwarteter Wirkung. +3. Der Mensch antwortet **per Prompt** im Chat (n8n Chat oder Messenger): „genehmige P-3“, „lehne P-4 ab, weil …“, „alle mit Risiko niedrig genehmigen“, oder neue Aufträge wie „wechsle tier1-general auf qwen3-14b“ und „trainiere einen Adapter für den Cluster rechnungen“. +4. Aufträge des Menschen werden ebenfalls zu Vorschlägen. Der Agent zeigt, was passieren würde, und führt erst nach Bestätigung aus. +5. Eine **Freigabe-Richtlinie** (`config/policy.yaml`) legt fest, was ohne Rückfrage laufen darf. Standard: Schritte in die sichere Richtung (Skill zurückstufen, Slot als `degraded` markieren, Container-Neustart) laufen automatisch. Alles, was Qualität oder Kosten nach oben verändert (Skill freischalten, Adapter oder Basismodell tauschen, Host-Neustart, Löschen), braucht eine Freigabe. + +Der Agent **entscheidet nicht über Infrastruktur**. Er schlägt vor, erklärt und führt Freigegebenes über dieselben deterministischen Aktionen aus wie der Healer. Freitext wird zuerst mit einer festen Befehlsgrammatik verstanden. Ein LLM wird nur verwendet, um unklare Formulierungen auf einen dieser Befehle abzubilden, und der Agent fragt dann zurück. + +### 2.4 Observability (Voraussetzung für alles Weitere) + +- **Langfuse:** Trace jeder Anfrage mit Slot, Skill, Adapter, Verifier-Ergebnis, Tokens, Latenz und Eskalation. Daraus entstehen Trainings- und Eval-Daten. +- **Prometheus + Grafana:** Metriken von vLLM (KV-Cache-Auslastung, wartende Requests, Preemptions), llama.cpp, LiteLLM, Router, `pve-exporter` (Proxmox) und `nvidia_gpu_exporter` (funktioniert auch auf alten Quadros). +- **Loki** für Logs, inkl. Kernel-Logs der GPU-VMs (NVIDIA-Xid-Fehler). + +--- + +## 3. Autohealing – in Schichten + +Jede Schicht fängt ab, was die darunterliegende nicht schafft. Jede Aktion hat einen Zähler, und nach N Versuchen wird an die nächste Schicht bzw. einen Menschen eskaliert. + +| Schicht | Erkennt | Reaktion | +|---|---|---| +| **L1 Container** | Prozess hängt, Healthcheck schlägt fehl | Docker-Healthcheck plus `autoheal`-Container (Compose startet Container im Zustand *unhealthy* nicht selbst neu) | +| **L2 Modell-Endpunkt** | Endpunkt antwortet, aber falsch oder zu langsam | **Canary-Prompt** mit erwarteter Antwort statt nur HTTP 200; LiteLLM-Fallbacks und Cooldowns; Circuit Breaker im Router | +| **L3 VM / GPU** | GPU fällt vom Bus (Xid 79), CUDA-Fehler, OOM-Schleifen, Überhitzung | Healer startet VM über die Proxmox-API neu. Bei Wiederholung wird der Slot in der Registry als `degraded` markiert, der Router meidet ihn, und auf Wunsch wird ein Ersatz-Node gestartet | +| **L4 Node** | Host weg | Proxmox HA startet Infra-VMs woanders neu. GPU-Slots fallen per Registry auf andere Nodes oder Stufe 0 zurück | +| **L5 Qualität** | Pass-Rate eines Skills oder Adapters sinkt (Drift) | Skill automatisch auf Stufe 0 zurückstufen, Re-Destillation einreihen, Adapter auf Vorversion zurückrollen | +| **L6 Konfiguration** | Ist-Zustand weicht von Git ab | Reconcile (Ansible/OpenTofu) stellt den Sollzustand wieder her | + +**Healer-Service:** Ein kleiner Dienst (oder n8n-Workflow mit Cron) wertet Prometheus-Alerts aus und führt die Aktionen nach einer festen Regeltabelle aus. Keine LLM-Entscheidung beim Heilen der Infrastruktur, das muss deterministisch sein. Später kann ein LLM *Diagnose-Berichte* schreiben, aber nicht handeln. + +**Hinweis zu Passthrough:** Manche älteren Karten lassen sich nach einem Absturz nicht sauber zurücksetzen (fehlendes FLR) und brauchen einen Host-Neustart. Das gehört in die Regeltabelle (Eskalation „Host-Reboot“, nur mit Freigabe oder in einem Wartungsfenster). + +--- + +## 4. Modellwechsel und Nachtrainieren = eine Pipeline + +Basismodell-Wechsel, neuer Adapter, neue Quantisierung, neues Embedding-Modell und Engine-Update sind alle dasselbe: **ein Kandidat für einen Slot.** Deshalb gibt es genau eine Lifecycle-Pipeline. + +### 4.1 Ablauf + +``` +Kandidat (Basis/Adapter/Quant/Engine) + → Build (Download+Quantisierung oder LoRA-Training aus Datensatz) + → Offline-Eval (Golden Sets aller betroffenen Skills + allgemeines Sanity-Set + + Kontextlängen-Kalibrierung, s. Abschnitt 6) + → Shadow (läuft parallel auf echtem Traffic mit, Antworten werden nur verglichen) + → Canary (x % Traffic, Verifier-Pass-Rate vs. aktueller Version) + → Promote (Registry-Eintrag des Slots umschalten) + → Vorversion bleibt N Tage als Rollback-Ziel erhalten +``` + +n8n orchestriert die Schritte und holt die Freigaben ein. Die Arbeit selbst machen Jobs auf `gpu-flex`. + +### 4.2 Basismodell-Wechsel + +- Adapter sind **an das Basismodell gebunden**. In der Registry heißt ihr Schlüssel deshalb `(base_model_hash, skill_cluster, version)`. +- Wechsel = neue Basis als Kandidat plus **Batch-Neutraining aller Adapter** aus den gespeicherten Datensätzen. Das ist ein einziger nächtlicher Job. +- Vorher prüfen: Schafft die neue Basis die Skills **ohne Adapter** (nur Skill-Text)? Adapter werden nur für Cluster trainiert, in denen sie messbar etwas bringen. Das reduziert die Pflege. + +### 4.3 Datensätze modellneutral halten + +- Speichern als Nachrichtenliste (`system`/`user`/`assistant` plus Metadaten), **ohne** modellspezifisches Chat-Template. Das Template kommt erst beim Training dazu. +- Jeder Fall trägt Herkunft (Stufe, Modell), Verifier-Ergebnis, Skill-ID und Zeitstempel. +- Train/Eval-Split ist fest pro Fall, damit Eval-Fälle nie ins Training geraten. + +### 4.4 Sonderfall Embedding-Modell + +Neues Embedding-Modell bedeutet: Alle Vektoren sind ungültig. Deshalb **Blue/Green-Index**: zweite Collection mit dem neuen Modell befüllen, Routing-Qualität vergleichen (Recall auf historisch korrekt gerouteten Fällen), dann den Alias umschalten. + +--- + +## 5. Finetuning-Strategie + +### 5.1 Stufen (in dieser Reihenfolge ausreizen) + +1. **Skill-Text und Beispiele im Kontext.** Kostenlos und sofort wirksam. +2. **LoRA pro Skill-Cluster.** Wird trainiert, sobald ein Cluster ≥ N verifizierte Fälle hat (Startwert ca. 200–500) *und* Stufe 1 dort unter dem Zielwert liegt. +3. **Präferenz-Training (DPO/ORPO).** Das System erzeugt automatisch Präferenzpaare: *Antwort des schwachen Modells (Verifier: falsch)* gegen *Korrektur des starken Modells (Verifier: richtig)* für dieselbe Eingabe. Das ist sehr wertvolles Trainingsmaterial, das hier nebenbei entsteht. +4. **Allgemeiner Domänen-Adapter** (mehrere Cluster zusammengeführt), wenn viele kleine Adapter unhandlich werden. + +### 5.2 Technik auf der vorhandenen Hardware + +- Training auf `gpu-flex` (RTX 4000 Ada, 20 GB) mit **QLoRA** über Unsloth oder Axolotl. 7–14B ist komfortabel, größere Modelle nur knapp oder gar nicht. +- Serving über vLLM mit `--enable-lora`. Begrenzungen (`max_loras`, `max_lora_rank`) setzen, sonst wird VRAM für den KV-Cache knapp. +- Während des Trainings ist `gpu-flex` in der Registry `unavailable`. Der Router weiß das, und ein Healer-Alarm wird dadurch nicht ausgelöst (Wartungsfenster). + +### 5.3 Risiken + +- **Overfitting auf selbst erzeugte Varianten.** Das Eval-Set muss echte Produktionsfälle enthalten. +- **Verifier als Flaschenhals.** Was der Verifier nicht prüft, lernt das Modell auch nicht sicher. Verifier-Qualität regelmäßig gegen das starke Modell stichprobenartig gegenprüfen. +- **Schleichende Selbstverstärkung.** Nur Fälle, die *unabhängig* verifiziert wurden, dürfen ins Training, nicht bloß solche, die das schwache Modell selbst für gut hält. + +--- + +## 6. Problemerkennung (z. B. zu kleine Kontextlänge) + +### 6.1 Vor dem Aufruf (präventiv, im Router) + +- **Token-Budget berechnen** mit dem Tokenizer des Zielmodells: Eingabe + Skill + Beispiele + erwartete Ausgabelänge. +- Liegt die Summe über `effective_context` des Slots (nicht über `max_context`, siehe unten), reagiert der Router in dieser Reihenfolge: + 1. kompaktere Skill-Variante bzw. weniger Beispiele laden + 2. Slot mit größerem effektivem Kontext wählen + 3. Aufgabe aufteilen (Map-Reduce, Chunking) + 4. auf Stufe 0 eskalieren +- Jede dieser Ausweichreaktionen wird als Ereignis geloggt. Häufen sie sich bei einem Skill, ist das ein Signal für Pflege. + +### 6.2 Effektiver vs. nomineller Kontext + +Modelle werden oft deutlich vor ihrem offiziellen Kontextlimit schlechter. Deshalb **kalibrieren**: die Verifier-Pass-Rate pro Slot nach Eingabelänge in Klassen gruppieren (z. B. <2k, 2–8k, 8–16k, …). Die Länge, ab der die Pass-Rate unter den Zielwert fällt, wird als `effective_context` in der Registry gespeichert und automatisch nachgeführt. + +### 6.3 Laufzeit-Signale und Fehlerklassen + +| Signal | Bedeutung | Automatische Reaktion | +|---|---|---| +| `finish_reason = length` | Ausgabe abgeschnitten | `max_tokens` erhöhen bzw. Aufgabe teilen; bei Häufung `effective_context` senken | +| Kontext-Overflow-Fehler der Engine | Budget falsch berechnet | Tokenizer-Konfiguration prüfen, Anfrage neu routen | +| JSON/Schema ungültig | Formatproblem | ein Retry mit Fehlerhinweis, danach Eskalation; bei Häufung Kandidat für Adapter | +| Wiederholungsschleifen in der Ausgabe | Degeneration (oft Quantisierung oder zu langer Kontext) | Abbruch, Eskalation, Längen-Bucket markieren | +| Hohe Ähnlichkeit, aber Verifier schlägt fehl | **Fehlrouting** | Schwellwert des Skills anheben, Fall als Negativbeispiel für den Router | +| Zwei Skills fast gleichauf | Skill-Konflikt | Reranker entscheiden lassen, Merge-Vorschlag | +| KV-Cache voll, Preemptions | Überlast | Drosselung, Ausweichen auf zweiten Slot | +| Latenz-Ausreißer | Überhitzung, Swapping, Nachbar-Last | Node-Metriken prüfen, ggf. Slot `degraded` | + +Alle Klassen landen mit Zählern in Grafana. Die Regeltabelle (Signal → Reaktion) ist dieselbe, die der Healer nutzt. + +--- + +## 7. Übertragbarkeit auf andere Cluster + +### 7.1 Hardware-Abstraktion + +1. **Inventar-Agent** auf jedem Node: liest GPU-Modell, VRAM, Compute Capability, Treiber, CPU-Kerne, RAM und Speicher aus (`nvidia-smi`, `lscpu`, …) und meldet ein **Capability-Profil**. +2. **Modell-Katalog** mit Anforderungen pro Modell und Quantisierung: Mindest-VRAM, Mindest-Compute-Capability, unterstützte Engines, Kontext. +3. **Placement-Regeln** (anfangs deterministisch, keine Optimierung): + - Compute Capability ≥ 7.x und genug VRAM → vLLM-Rolle + - Compute Capability 5.x–6.x → llama.cpp-Rolle (Embedding, Rerank, kleine Modelle) + - keine GPU → CPU-Rollen (Infra, CPU-Embedding als Fallback) +4. **Kalibrierungslauf** nach dem Deployment: Benchmark pro Slot (Tokens/s, Latenz, effektiver Kontext) → Registry wird automatisch befüllt. + +### 7.2 Konfigurationsschichten (Repo-Struktur) + +``` +core/ portabel: Python-Paket `knaster` (Router, Registry, Verifier, Healer, + Pflege-Agent, Lifecycle, Inventar/Placement) + Tests +config/ portable Standardwerte: Registry-Schema-Beispiel, Policy, Healer-Regeln +catalog/ Modell-Katalog mit Hardware-Anforderungen +skills/ SKILL.md-Bibliothek (Format kompatibel zu Hermes / agentskills) +deploy/ Docker-Compose pro Rolle, LiteLLM-, Prometheus-, Alertmanager-Konfiguration +n8n/ importierbare Workflows +platform/proxmox/ austauschbar: OpenTofu + Ansible; später ggf. platform/baremetal, platform/k8s +sites// standortspezifisch: Inventar, Registry, Placement-Overrides, Secret-Referenzen +``` + +### 7.3 Was wandert mit, was nicht? + +| Asset | Übertragbar? | +|---|---| +| Skills (Text), Eval-Sets, Trainingsdaten | **ja**, uneingeschränkt | +| Router-Schwellwerte | ja, aber nur wenn das Embedding-Modell gleich ist; sonst neu kalibrieren | +| LoRA-Adapter | nur bei identischem Basismodell; sonst neu trainieren (Pipeline aus Abschnitt 4) | +| Registry-Kalibrierung | nein, wird pro Cluster neu gemessen | + +Die Portabilität beruht also auf den **Daten und der Pipeline**, nicht auf Gewichten oder Hardware-Annahmen. + +--- + +## 8. Roadmap + +| Phase | Inhalt | Fertig, wenn … | +|---|---|---| +| **0 Fundament** | Proxmox-Templates, Resource Mappings, OpenTofu/Ansible, Monitoring-Stack | eine GPU-VM lässt sich per Code neu erzeugen, Grafana zeigt GPU- und Proxmox-Metriken | +| **1 Data Plane** | LiteLLM, vLLM (Ada), llama.cpp (P2000/M4000), Postgres+pgvector | alle Slots per LiteLLM erreichbar, Canary-Checks grün | +| **2 Kernpfad** | Router (`/route`, `/escalate`), Registry, n8n-Hauptworkflow, Verifier v1, Langfuse | Anfrage läuft Ende-zu-Ende inkl. Eskalation nach OpenRouter | +| **3 Skills** | Hermes als Skill-Autor, SKILL.md-Bibliothek, Graduierungs-Workflow | erster Skill autonom erstellt, getestet und auf Stufe 1 freigegeben | +| **4 Robustheit** | Healer, Regeltabelle, Token-Budget, Kontext-Kalibrierung, Fehlerklassen | provozierter GPU- bzw. Container-Ausfall heilt ohne Eingriff | +| **5 Lifecycle** | Kandidaten-Pipeline, QLoRA-Training, Shadow/Canary, Rollback | Adapter automatisch trainiert, evaluiert, promoted und einmal zurückgerollt | +| **6 Portabilität** | Inventar-Agent, Katalog, Placement, Kalibrierungslauf | zweiter (ggf. simulierter) Cluster aus `sites/` aufgesetzt | + +--- + +## 9. Entscheidungen + +### Getroffen +- GPU-Nodes als VM, Infrastruktur als LXC bzw. leichte VM (ADR-0001) +- Netzwerk 1 GbE → kein Ceph, ZFS lokal plus Replikation, Modelle lokal (ADR-0002) +- Slots statt Modellnamen, eine Lifecycle-Pipeline für alle Wechsel (ADR-0003) +- Halbautomatische Pflege über einen Pflege-Agenten mit Freigabe per Prompt (ADR-0004) +- Infrastruktur heilt deterministisch, ohne LLM-Entscheidungen (ADR-0005) + +### Offen +1. Erstes Basismodell für `tier1-general` (8–14B, gut quantisierbar, LoRA-freundlich, gutes Deutsch). Entscheidung per Kalibrierungslauf. +2. Bleiben die M4000 im Einsatz? Benchmark gegen P2000 bzw. CPU und Stromverbrauch +3. Verifier-Strategie pro Aufgabentyp: Code-Checks, Schema, kleines Judge-Modell, Stichproben durch das starke Modell +4. Schwellwerte: Graduierung (Start 90 %), Fallzahl fürs LoRA-Training (Start 300), Canary-Anteil (Start 10 %) +5. Kanal für den Pflege-Agenten: n8n Chat, Telegram oder Matrix +6. Standort des Proxmox Backup Servers (intern auf SSD oder extern) diff --git a/docs/adr/0001-gpu-vms-infra-lxc.md b/docs/adr/0001-gpu-vms-infra-lxc.md new file mode 100644 index 0000000..a9bc3b0 --- /dev/null +++ b/docs/adr/0001-gpu-vms-infra-lxc.md @@ -0,0 +1,17 @@ +# ADR-0001: GPU-Nodes als VM, Infrastruktur als LXC + +- Status: angenommen +- Datum: 2026-10-11 + +## Kontext +Im Cluster stecken GPUs aus drei Generationen: Maxwell (M4000), Pascal (P2000) und Ada (RTX 4000 Ada). Maxwell und Pascal werden von CUDA 13 nicht mehr unterstützt, vLLM verlangt Compute Capability ≥ 7.0. Ein LXC nutzt immer den Treiber des Hosts. + +## Entscheidung +- Jede GPU-Rolle läuft in einer **VM mit PCI-Passthrough** und eigenem, fest gepinntem Treiber. Ada bekommt einen aktuellen Treiber, Maxwell und Pascal einen Treiber der Reihe CUDA 12.x. +- GPUs werden über **Proxmox Resource Mappings** angesprochen (`gpu-ada-1`, `gpu-p2000-1`, …), nicht über PCI-Adressen. +- CPU-Dienste (n8n, LiteLLM, Router, Postgres, Monitoring) laufen in **LXC** bzw. einer kleinen VM, jeweils mit Docker Compose. + +## Folgen +- Der VM-RAM ist bei Passthrough vollständig reserviert. Er wird deshalb knapp bemessen: llama.cpp-VMs 6–8 GB, vLLM-VMs 24–32 GB. +- Keine Live-Migration für GPU-VMs. Bei einem Ausfall springt der Router auf einen anderen Slot oder auf Stufe 0. +- Treiber-Updates laufen pro VM, abgesichert durch einen Snapshot. diff --git a/docs/adr/0002-storage-zfs-kein-ceph.md b/docs/adr/0002-storage-zfs-kein-ceph.md new file mode 100644 index 0000000..473c29e --- /dev/null +++ b/docs/adr/0002-storage-zfs-kein-ceph.md @@ -0,0 +1,18 @@ +# ADR-0002: ZFS lokal + Replikation statt Ceph + +- Status: angenommen +- Datum: 2026-10-11 + +## Kontext +Die Nodes sind mit **1 GbE** verbunden. Jede physische Maschine hat 2–3 SSDs mit 128–256 GB. Ceph braucht für brauchbare Latenz und Rebuilds mindestens 10 GbE und genug Platz pro OSD. + +## Entscheidung +- Pro Node ZFS: SSD 1 für das System, SSD 2+3 als Mirror `tank` (bei nur 2 SSDs: Einzel-SSD `tank`). +- Proxmox-**ZFS-Replikation** alle 15 Min. für die Infra-VMs bzw. -LXCs auf einen Partner-Node, kombiniert mit Proxmox HA. +- **Modelle lokal** auf der GPU-VM (eigenes Disk-Image, weder repliziert noch gesichert). Quelle: Hugging Face oder ein optionaler Modell-Mirror (HTTP, nur lesend). +- **PBS** für Postgres, Konfiguration und Datensätze. + +## Folgen +- Bei einem Node-Ausfall gehen höchstens 15 Min. Daten der Infra-VMs verloren. Postgres sichert sich zusätzlich über WAL-Archivierung bzw. PBS. +- Modellwechsel verursachen Downloads (10 GB ≈ 90 s über 1 GbE). Das ist akzeptabel. +- Die Speicherbudgets pro Rolle stehen in PLAN.md, Abschnitt 1.4. diff --git a/docs/adr/0003-slots-und-eine-lifecycle-pipeline.md b/docs/adr/0003-slots-und-eine-lifecycle-pipeline.md new file mode 100644 index 0000000..fd85a49 --- /dev/null +++ b/docs/adr/0003-slots-und-eine-lifecycle-pipeline.md @@ -0,0 +1,14 @@ +# ADR-0003: Slots statt Modellnamen, eine Lifecycle-Pipeline + +- Status: angenommen +- Datum: 2026-10-11 + +## Entscheidung +- Clients (n8n, Hermes, Skills) sprechen **Slots** an (`tier1-general`, `verifier-small`, …). Was hinter einem Slot steht (Basismodell, Adapter, Engine, Node), legt nur die Registry fest. +- Jede Änderung hinter einem Slot ist ein **Kandidat**, egal ob Basismodell, Adapter, Quantisierung, Embedding-Modell oder Engine. Jeder Kandidat durchläuft dieselbe Pipeline: Build → Eval → Shadow → Canary → Promote, mit Rollback. +- Adapter sind über `(base_model, cluster, version)` adressiert. Ein Basiswechsel zieht das Neutraining der Adapter nach sich. +- Trainingsdaten werden **modellneutral** gespeichert (Nachrichtenlisten ohne Chat-Template). + +## Folgen +- Ein Modellwechsel ist eine Registry-Änderung und betrifft keinen Workflow. +- Die Pipeline muss pro Kandidatentyp wissen, welche Golden Sets sie prüft. diff --git a/docs/adr/0004-pflege-agent-hitl.md b/docs/adr/0004-pflege-agent-hitl.md new file mode 100644 index 0000000..a3d00ff --- /dev/null +++ b/docs/adr/0004-pflege-agent-hitl.md @@ -0,0 +1,17 @@ +# ADR-0004: Halbautomatische Pflege über einen Pflege-Agenten (HITL) + +- Status: angenommen +- Datum: 2026-10-11 + +## Kontext +Der Mensch soll Pflegeaufträge per Prompt erteilen können und regelmäßig durch eine Service-Routine geführt werden. Vollautomatik ist bei qualitäts- und kostenrelevanten Schritten nicht gewünscht. + +## Entscheidung +- Der Pflege-Agent sammelt Befunde, erzeugt **Vorschläge** und führt nur Freigegebenes aus. +- Aufträge des Menschen werden zu Vorschlägen und brauchen eine Bestätigung. +- Die **Freigabe-Richtlinie** (`config/policy.yaml`) entscheidet pro Aktionstyp über `auto` oder `approve`. Grundsatz: Schritte in die sichere Richtung laufen automatisch, Schritte, die Qualität oder Kosten ändern, brauchen eine Freigabe. +- Befehle werden zuerst über eine **feste Grammatik** verstanden. Ein LLM darf Freitext nur auf diese Befehle abbilden und löst niemals selbst eine Aktion aus. + +## Folgen +- Jede Aktion ist nachvollziehbar (Vorschlag, Freigabe, Ausführung, Ergebnis). +- Der Agent ist austauschbar: Der Kanal kann n8n Chat, Telegram oder Matrix sein, die Logik liegt im Router-Service. diff --git a/docs/adr/0005-deterministisches-healing.md b/docs/adr/0005-deterministisches-healing.md new file mode 100644 index 0000000..676af2b --- /dev/null +++ b/docs/adr/0005-deterministisches-healing.md @@ -0,0 +1,14 @@ +# ADR-0005: Deterministisches Autohealing, Kernlogik im Service statt in n8n + +- Status: angenommen +- Datum: 2026-10-11 + +## Entscheidung +- Der Healer arbeitet eine **Regeltabelle** ab (`config/healer-rules.yaml`). Für jedes Alert-Muster gibt es eine Eskalationsleiter von Aktionen, jeweils mit Versuchsbudget und Cooldown. Sind alle Versuche erschöpft, wird ein Mensch benachrichtigt. +- Alertmanager liefert **direkt** an den Router-Service, nicht über n8n. So funktioniert das Healing auch, wenn n8n ausfällt. +- Die Schleife aus Routing, Ausführung, Verifikation und Eskalation steckt testbar im Service (`/v1/run`). n8n übernimmt Trigger, Authentifizierung, Antwort, Benachrichtigung und die HITL-Kanäle. +- Alle Aktionen laufen standardmäßig im **Dry-Run**, bis sie pro Aktionstyp freigeschaltet werden. + +## Folgen +- Weniger Logik in n8n, dafür Unit-Tests für die gesamte Entscheidungslogik. +- Ein Ausfall von n8n betrifft nur die Eingangskanäle, nicht das Healing. diff --git a/docs/spec/SPEC-core.md b/docs/spec/SPEC-core.md new file mode 100644 index 0000000..40e293b --- /dev/null +++ b/docs/spec/SPEC-core.md @@ -0,0 +1,139 @@ +# 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 = adapter.name` gesetzt, sonst `model = slot.litellm_model`. + +`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 | + +Authentifizierung: Header `Authorization: Bearer ` auf allen `/v1/*`-Pfaden. diff --git a/docs/spec/SPEC-healer.md b/docs/spec/SPEC-healer.md new file mode 100644 index 0000000..066a2bb --- /dev/null +++ b/docs/spec/SPEC-healer.md @@ -0,0 +1,50 @@ +# SPEC-healer: Deterministisches Autohealing + +Paket: `knaster.healer`. + +## 1. Eingang + +Alertmanager-Webhook (Version 4) an `POST /v1/healer/alertmanager`. Pro Alert werden `status` (firing/resolved), `labels`, `annotations` und `fingerprint` verarbeitet. + +## 2. Regeln (`config/healer-rules.yaml`) + +```yaml +rules: + - id: slot-endpoint-down + match: {alertname: SlotEndpointDown} # alle Labels müssen gleich sein + ladder: + - {action: restart_container, params: {host: "{node}", container: "{container}"}, attempts: 2, cooldown: 5m} + - {action: restart_vm, params: {vmid: "{vmid}", pve_node: "{pve_node}"}, attempts: 1, cooldown: 15m} + - {action: mark_slot, params: {slot: "{slot}", status: degraded}, attempts: 1} + on_exhausted: notify + on_resolved: [{action: mark_slot, params: {slot: "{slot}", status: ok}, only_if_set_by_healer: true}] +``` + +`{label}` wird aus den Alert-Labels ersetzt. Fehlt ein Label, wird die Stufe übersprungen und im Ergebnis vermerkt. + +## 3. Ablauf pro Alert (firing) + +1. Erste passende Regel suchen (Reihenfolge der Datei). Passt keine, wird nur benachrichtigt (`unmatched`). +2. Zustand pro `(rule_id, fingerprint)`: Versuche pro Stufe und Zeitpunkt der letzten Aktion. +3. Ist der Cooldown der letzten Aktion noch nicht vorbei, passiert nichts (`cooldown`). +4. Sonst wird die erste Stufe genommen, deren Versuchsbudget noch nicht erschöpft ist. Vor der Ausführung entscheidet die **Policy** (SPEC-maintenance §2): Bei `auto` wird ausgeführt, bei `approve` entsteht ein Vorschlag (Quelle `healer`) und die Stufe gilt als verbraucht. +5. Sind alle Stufen erschöpft, wird einmalig benachrichtigt (`exhausted`). +6. Ein globaler Schutz begrenzt die Infrastruktur-Aktionen auf `max_actions_per_hour` (Standard 10). Darüber wird nur noch benachrichtigt. + +**resolved:** Die `on_resolved`-Aktionen laufen, und der Zustand des Fingerprints wird gelöscht. + +## 4. Treiber (`knaster.healer.drivers`) + +| Aktion | Treiber | API | +|---|---|---| +| `restart_container` | Portainer | `POST /api/endpoints/{endpoint}/docker/containers/{container}/restart` | +| `restart_vm` | Proxmox | `POST /api2/json/nodes/{pve_node}/qemu/{vmid}/status/reboot` | +| `reboot_host` | Proxmox | `POST /api2/json/nodes/{pve_node}/status` (`command=reboot`) | +| `mark_slot` | Registry | Laufzeitzustand | +| `notify` | Webhook (n8n) | `POST NOTIFY_WEBHOOK_URL` | + +Alle Treiber haben einen **Dry-Run**-Modus (Standard: an). Ein globales `KNASTER_DRY_RUN=false` schaltet die Ausführung frei, zusätzlich muss der Treiber pro Aktion in `config/healer-rules.yaml` unter `enabled_actions` aufgeführt sein. + +## 5. Mitgelieferte Alerts (`deploy/prometheus/alerts.yml`) + +`SlotEndpointDown`, `SlotCanaryFailing`, `GpuXidError`, `GpuTemperatureHigh`, `VllmKvCacheSaturated`, `NodeDown`, `DiskAlmostFull`. diff --git a/docs/spec/SPEC-inventory.md b/docs/spec/SPEC-inventory.md new file mode 100644 index 0000000..d89aef2 --- /dev/null +++ b/docs/spec/SPEC-inventory.md @@ -0,0 +1,34 @@ +# SPEC-inventory: Inventar, Modell-Katalog, Placement + +Paket: `knaster.inventory`. CLI: `knaster inventory collect`, `knaster placement plan`. + +## 1. Node-Profil + +```yaml +name: pve-ada +cpu: {model: "...", cores: 8, threads: 16} +memory_gb: 64 +disks: [{name: nvme0n1, size_gb: 256, rotational: false}] +gpus: + - {index: 0, name: "NVIDIA RTX 4000 Ada Generation", memory_gb: 20, compute_cap: 8.9, driver: "..."} +``` + +Datenquellen: `nvidia-smi --query-gpu=index,name,memory.total,compute_cap,driver_version --format=csv,noheader,nounits`, `/proc/meminfo`, `/proc/cpuinfo` bzw. `lscpu`, `lsblk -J -b -d -o NAME,SIZE,ROTA`. Die Parser sind reine Funktionen auf dem jeweiligen Text und testbar. `collect` ruft die Befehle lokal auf. + +## 2. Katalog (`catalog/models.yaml`, `catalog/roles.yaml`) + +Ein Modell hat `kind` (llm, embedding, reranker), `vram_gb` (Gewichte plus Mindest-KV-Cache), `min_cc`, `engines`, `context`, `lora`. +Eine Rolle hat `needs` (`engine`, `min_vram_gb`, `min_cc`, `max_cc?`, `gpu: true|false`), `prefer` (z. B. `smallest_fitting`), `count` und `slots`. + +## 3. Placement (`plan(nodes, roles, models) -> PlacementPlan`) + +Deterministisch: +1. Alle GPUs aller Nodes zu einer Liste `(node, gpu)` zusammenfassen. +2. Rollen nach `priority` (aufsteigend) abarbeiten. Für jede Rolle werden `count` GPUs gesucht, die `needs` erfüllen und noch frei sind. + - `prefer: largest` wählt die GPU mit dem meisten VRAM (für LLM-Rollen). + - `prefer: smallest_fitting` wählt die kleinste passende GPU (für Embedding und Rerank). So bleiben große Karten frei. +3. Rollen mit `gpu: false` bzw. `cpu_fallback: true` werden ohne passende GPU den CPU-Nodes zugewiesen, sortiert nach RAM. +4. Pro zugewiesener Rolle werden die Katalogmodelle gelistet, die passen (Engine, VRAM, CC). +5. Ergebnis: `assignments[{role, node, gpu_index?, models_fitting[]}]` und `unplaced[{role, reason}]`. + +Der Plan ist der Input für `platform/proxmox` (welche VM-Rolle auf welchem Host mit welchem Resource Mapping) und für die Registry (welche Slots existieren). diff --git a/docs/spec/SPEC-lifecycle.md b/docs/spec/SPEC-lifecycle.md new file mode 100644 index 0000000..6c0a9b7 --- /dev/null +++ b/docs/spec/SPEC-lifecycle.md @@ -0,0 +1,46 @@ +# SPEC-lifecycle: Kandidaten, Graduierung, Training + +Pakete: `knaster.lifecycle`, `knaster.graduation`. + +## 1. Kandidat + +``` +id, kind: base_model | adapter | quantization | embedding_model | engine +slot: str # betroffener Slot (bei adapter: Slot, dessen Basis passt) +target: {...} # z. B. {base_model: "qwen3-14b-awq"} oder {cluster: "rechnungen"} +baseline: {pass_rate?} # Messwert der aktuellen Version +state, metrics{}, history[{ts, from, to, note}] +``` + +## 2. Zustände + +``` +proposed ──(Freigabe)──▶ building ──report(build_ok)──▶ evaluating +evaluating ──report(eval: pass_rate ≥ baseline − tolerance)──▶ shadow (sonst ▶ rejected) +shadow ──report(shadow: agreement ≥ min_agreement, n ≥ min_n)──▶ canary (sonst ▶ rejected) +canary ──report(canary: pass_rate ≥ baseline − tolerance, n ≥ min_n)──▶ awaiting_promotion (sonst ▶ rejected) +awaiting_promotion ──(Freigabe)──▶ promoted +promoted ──rollback──▶ rolled_back +jeder Zustand ──report(error)──▶ failed +``` + +Grenzwerte stehen in `config/lifecycle.yaml`: `tolerance` 0,02, `min_agreement` 0,85, `min_n` 50, `canary_share` 0,1. + +Die Freigaben laufen über die Policy (`create_candidate`, `promote_candidate`). **Promote** schreibt in den Registry-Laufzeitzustand bzw. in die Adapterliste: +- `base_model`: Slot `base_model` wird umgestellt, alle Adapter der alten Basis werden `retired`, und für jeden Cluster mit Adapter entsteht ein Kandidat `adapter` (Neutraining). +- `adapter`: Der neue Adapter wird `active`, der bisher aktive für denselben Cluster und dieselbe Basis wird `retired` (bleibt als Rollback-Ziel erhalten). +- **Rollback** stellt den Zustand vor dem Promote wieder her (gespeichert in `history`). + +Die Jobs selbst (Download, Quantisierung, Training, Eval) laufen **extern**, als Job-Runner auf `gpu-flex`. Sie melden ihr Ergebnis per `POST /v1/lifecycle/candidates/{id}/report`. Der `JobRunner` im Service stößt den Job an: Im Dry-Run wird nur protokolliert, sonst geht ein Webhook an n8n bzw. den Runner. + +## 3. Graduierung (`knaster.graduation`) + +`graduate(skill, variants[], run_fn, verify_fn, threshold=0.9, min_variants=10) -> GraduationReport` +- Jede Variante wird mit `stage = 1` auf dem Skill-Slot ausgeführt (`run_fn`) und geprüft (`verify_fn`). +- `passed = pass_rate ≥ threshold` und `n ≥ min_variants`. +- Bei Erfolg entsteht ein Vorschlag `promote_skill{skill, stage: 1}` (Policy: approve), bei Misserfolg ein Bericht mit den fehlgeschlagenen Varianten. Diese gehen an das starke Modell, das den Skill überarbeitet. +- Die Varianten erzeugt das starke Modell (Hermes) und schickt sie an `POST /v1/skills/{name}/graduate`. + +## 4. Adapter-Training (extern, Vertrag) + +Eingang für den Trainingsjob: Export `cases` (split = train, passed = true, skill.cluster = X) plus optional `preference_pairs`. Ausgang: Adapter-Verzeichnis und `report(build_ok, adapter_name)`. Die Eval nutzt die Fälle mit split = eval desselben Clusters. diff --git a/docs/spec/SPEC-maintenance.md b/docs/spec/SPEC-maintenance.md new file mode 100644 index 0000000..ff6a48f --- /dev/null +++ b/docs/spec/SPEC-maintenance.md @@ -0,0 +1,77 @@ +# SPEC-maintenance: Pflege-Agent, Vorschläge, Freigabe-Richtlinie + +Paket: `knaster.maintenance`, `knaster.policy`. + +## 1. Vorschlag (`Proposal`) + +``` +id: "P-" +kind: promote_skill | demote_skill | merge_skills | retire_skill | mark_slot | restart_container | + restart_vm | reboot_host | create_candidate | promote_candidate | rollback_candidate | + train_adapter | recalibrate_context +params: {...} +summary: str # menschenlesbar +reason: str +risk: low | medium | high +state: open | approved | rejected | executed | failed | expired +source: routine | command | healer | lifecycle +created_at, decided_at, decided_by, result +``` + +## 2. Freigabe-Richtlinie (`config/policy.yaml`) + +```yaml +default: approve +actions: + demote_skill: {mode: auto} + mark_slot: {mode: auto} + restart_container: {mode: auto} + restart_vm: {mode: auto} + recalibrate_context:{mode: auto} + promote_skill: {mode: approve} + ... + reboot_host: {mode: approve, window: "02:00-05:00"} +``` + +`Policy.decide(kind, now) -> auto | approve | deny`. Liegt eine Aktion mit `window` außerhalb des Zeitfensters, wird sie zwar freigegeben, aber erst im nächsten Fenster ausgeführt (`deferred`). Ein Vorschlag mit Modus `auto` wird sofort ausgeführt und mit `decided_by = policy` protokolliert. + +## 3. Service-Routine (`MaintenanceAgent.run_routine`) + +Befunde werden aus diesen Quellen gesammelt (alle Grenzwerte in `config/maintenance.yaml`): + +| Befund | Bedingung | Vorschlag | +|---|---|---| +| Skill-Drift | Pass-Rate der letzten N Fälle auf Stufe ≥ 1 < `demote_below` (0,80), n ≥ `min_cases` (20) | `demote_skill` (auto) | +| Graduierungs-Kandidat | Skill auf Stufe 0, ≥ `min_cases` Fälle, Pass-Rate auf Stufe 0 ≥ 0,95 | `promote_skill` → Graduierungslauf (approve) | +| Ähnliche Skills | cos(description_a, description_b) ≥ `merge_similarity` (0,92) | `merge_skills` (approve) | +| Slot degradiert | Laufzeitzustand ≠ ok seit > 1 h | Hinweis + `mark_slot ok` nach Prüfung (approve) | +| Kontext-Ausweicher | Signal `context_exceeded` oder `output_truncated` ≥ `context_signal_limit` (10) pro Slot/Woche | `recalibrate_context` (auto) | +| Adapter-Bedarf | Cluster mit ≥ `adapter_min_cases` (300) verifizierten Train-Fällen und Pass-Rate auf Stufe 1 < 0,9 | `train_adapter` (approve) | +| Offene Kandidaten | Lifecycle-Kandidat wartet auf Freigabe | Verweis auf `promote_candidate` | + +Das Ergebnis ist ein `RoutineReport{findings[], proposals[], auto_executed[], text}`. `text` ist ein deutscher Bericht für den Chat. + +## 4. Befehle per Prompt (`MaintenanceAgent.handle_command`) + +Feste Grammatik (Groß-/Kleinschreibung egal, deutsch und kurze englische Synonyme): + +| Muster | Wirkung | +|---|---| +| `status` / `hilfe` | Überblick bzw. Befehlsliste | +| `routine` / `starte (die) service-routine` | Service-Routine | +| `vorschläge` / `zeige vorschläge` | offene Vorschläge | +| `genehmige P-3 [P-5 …]` / `approve P-3` | Freigabe und Ausführung | +| `genehmige alle (mit )?risiko niedrig` | alle offenen Vorschläge mit `risk == low` | +| `lehne P-3 ab[: Grund]` / `reject P-3` | Ablehnung | +| `wechsle auf ` | Vorschlag `create_candidate{kind: base_model}` | +| `trainiere adapter für ` | Vorschlag `train_adapter` | +| `rollback ` | Vorschlag `rollback_candidate` | +| `stufe zurück` / `degradiere ` | Vorschlag `demote_skill` | +| `markiere (als)? (ok\|degraded\|unavailable)` | Vorschlag `mark_slot` | + +Bei unbekanntem Text wird, falls konfiguriert, ein `IntentMapper` (LLM, Slot `tier1-general`) gefragt, der **nur** einen der obigen Befehle als String zurückgeben darf. Der Agent antwortet dann: „Meinst du: ``? Antworte mit ‚ja‘.“ Ein nachfolgendes `ja` in derselben Sitzung führt den Befehl aus, einen neu erzeugten Vorschlag aber ebenfalls nur bis zur Freigabestufe. +Aus Prompt-Aufträgen erzeugte Vorschläge werden **nie** sofort ausgeführt, auch wenn die Richtlinie `auto` sagt. Der Mensch bestätigt sie mit `genehmige`. + +## 5. Ausführung + +`ActionExecutor.execute(proposal) -> result`. Aktionen sind registrierte Handler. Infrastruktur-Aktionen (`restart_*`, `reboot_host`) gehen an dieselben Treiber wie beim Healer (SPEC-healer §4). Standard ist **Dry-Run**: Das Ergebnis beschreibt dann, was getan worden wäre.