Zum Inhalt

Changelog — kora Platform

Alle Änderungen an der kora-Plattform (v1.0.0+) sind in diesem Dokument festgehalten. Format: Keep a Changelog, Versionierung nach SemVer.

Die AVS-Produkt-spezifische Historie (Demo-Evolution, v0.x) liegt unter docs.avs.luki-net.org.

Offene technische Schulden und Deferred-Items sind in Offene TODOs gepflegt.

[Unreleased] — Branch platform/v1.0.0

Block-19 Tag-7-Observation — BGE-M3 Hybrid Retrieval ✅ Grün (2026-05-10)

Branch: platform/k-block19-tag7-observation (von platform/v1.0.0-HEAD 592ba9c = Karte-B-Backlog-Inventur-Merge) Status: ✓ Grün — keine Regression nach 7 Tagen Production.

Tag-7-Observation gegenüber Block-18-Baseline (eval_retrieval_block18_v2.json) und Phase-3-Eval Tag-0 (eval_retrieval_block19_phase3_20260503-0545.json):

Metrik Block 18 Tag 0 Δ Bewertung
Recall@5 0.92 0.96 +0.04 ✓ verbessert
Recall@10 0.92 0.96 +0.04 ✓ verbessert
MRR 0.7667 0.7400 −0.0267 ⚠ dokumentierter Reranker-Bias q006/q025
avg_latency_ms 155.3 140.1 −15.2 ✓ verbessert

Live-Stack-Health (Tag 7): - API Up 7 days (healthy), smoke-hybrid grün (8 docs retrieved) - Cutover-Config aktiv: use_hybrid_retrieval=True, collection avs_handbuecher_bge_m3 - 0 Query-Errors, 2 No-Match (niedrige Live-Last) - Beide Qdrant-Collections green (242 points), Snapshot-Recovery vorhanden

MRR-Drift ist dokumentierter Cross-Encoder-Bias (q006, q025) — TODO-Block-19-5 (Reranker-Upgrade auf bge-reranker-v2-m3, ~4-6h) als Followup-Karte angelegt, nicht regression-blocking.

.env.block19_backup als Rollback-Pfad behalten (analog 30d-Snapshot-Retention in backups/qdrant/); Code-Referenzen in config.py und Tests bleiben bis Cleanup-Welle ~2026-06-02.

Aufwand: ~25min Wall-Clock (Cap 45min, −44% unter Cap).

Branch: platform/k-q3-fix-widget-session-lifecycle (von platform/v1.0.0-HEAD 494ca32 = Phase-4a-2-Merge) Status: Phase 0-2 grün, 5/5 Local-Smokes PASS, Regression 465/4/78 unverändert, Browser-Smoke ausstehend (User-Verifikation), Commit ausstehend.

Strategischer Kontext

Phase-4a-2-Browser-Smoke hatte aufgedeckt, dass Tourismus + PostgreSQL beim Cross-Showcase-Wechsel session_not_found-Errors lieferten — nur AVS funktionierte. K-Q3-Investigation hat festgestellt: Cross-Tenant-Cookie-Leak. Cookie avs_sid war auf path=/ scoped, wurde über alle Showcase-Pfade auf demo.avs.luki-net.org geteilt. User chattet auf /avs-meldeschein/ → AVS-Session-UUID im Cookie → Wechsel zu /tourismus/ → Widget liest gleichen Cookie, schickt AVS-UUID an Tourismus-Chatbot → Backend RLS lehnt ab (Tenant-Mismatch) → 404 session_not_found. Widget hatte keinen Recovery-Pfad → User-UI bleibt im Offline-Modus.

K-Q3-Fix implementiert Pfad C aus der Investigation: strukturelle Korrektur via Cookie-Scope-pro-Chatbot, plus 404-Recovery, plus Health-Pfad-Fix als Beifang.

Changed — Widget-Source

  • src/widget/src/avs-chat-widget.ts (+92 LOC, -36 LOC):
  • cookieNameFor(chatbotId) — Cookie-Name pro Chatbot: avs_sid_<chatbotId-shortform> (8-Zeichen-Slice). Strukturelle Korrektur: Cross-Tenant-Sharing unmöglich, weil jeder Chatbot einen eigenen Cookie-Namen-Scope hat.
  • getSessionCookie(chatbotId) / setSessionCookie(chatbotId, id) / clearSessionCookie(chatbotId) — alle Cookie-Helpers nehmen jetzt chatbotId als Parameter.
  • clearLegacyCookie() — alter unscoped avs_sid-Cookie wird beim connectedCallback BEVOR getSessionCookie() läuft explizit gelöscht. Damit verschwinden Phase-4a-2-Cookies automatisch bei der ersten Page-Reload.
  • onSessionInvalidated-Wiring (im Constructor): this.api.onSessionInvalidated = () => { clearSessionCookie(this.chatbotId); this.sessionId = null; this.historyLoaded = false; }.
  • historyLoaded-Flag-Reihenfolge umgekehrt (getHistory zuerst, dann Flag setzen) — bei 404 keine Re-Try-Sperre mehr in der Session.
  • onApiSuccess() ist idempotent: errCount=0 + hideOffline() nur wenn Banner oder Polling aktiv. Stoppt das endlose Health-Polling, sobald die nächste Query erfolgreich ist.
  • 5 Aufrufer von getSessionCookie/setSessionCookie/ clearSessionCookie/clearLegacyCookie durchreichen jetzt this.chatbotId.

  • src/widget/src/api.ts (+101 LOC, -18 LOC):

  • ApiError.detail — drittes Konstruktor-Arg, optional. fetchWithRetry befüllt detail aus body.detail. Im queryStream-Fehlerpfad wird der 4xx-Body explizit einmal gelesen und detail an den ApiError-Konstruktor weitergereicht.
  • ApiClient.onSessionInvalidated-Callback (public field, nullable).
  • query() und queryStream() haben 404-Handler mit _alreadyRetried-Flag. Bei 404 + detail==="session_not_found" → Callback aufrufen → 1× Retry mit session_id: null.
  • buildQueryBody() normalisiert undefined → null, sodass session_id IMMER im JSON-Body landet (Backend erwartet das Feld; null wird vom Backend als "neue Session anlegen" behandelt).
  • Health-Pfad-Fix (eine Zeile, ~252): /health/health/live. Stoppt das 404-Endlospolling im Background-Health-Check.

Changed — Bundle

  • src/widget/dist/avs-chat-widget.min.js — neuer Vite-Build. Bundle-Größe: 30810 Bytes (vorher 29884; +1.6 KB für Recovery-Logik, im erwarteten Bereich 28-35 KB). 9.39 KB gzipped.

Changed — HTML Cache-Bust

  • infra/demo-frontend/html/avs-meldeschein/index.html + tourismus/index.html + postgres/index.html — Script-Src um ?v=2026-05-07 erweitert. Damit lädt Browser/ NPMplus-Edge garantiert den neuen Bundle (kein Cache-Hit auf altem Hash). Übersichtsseite (index.html) hat kein Widget, bleibt unverändert.

Operations

  • Vite-Build: cd src/widget && npm run build — TypeScript- strict-Compile grün, Vite-IIFE-Output mit esbuild-Min, ES2020-Target.
  • Bind-Mount-Sync verifiziert: docker compose exec api stat /app/src/widget/dist/avs-chat-widget.min.js zeigt gleichen mtime + 30810 Bytes wie auf Host (./src/widget/dist:/app/src/widget/dist:ro).
  • demo-frontend restart: docker compose -p kora-platform restart demo-frontend — nginx serviert die neuen HTMLs mit Cache-Bust-Suffix.

Local-Smoke (5/5 PASS)

  1. curl http://localhost:8281/tourismus/ → HTML enthält avs-chat-widget.min.js?v=2026-05-07.
  2. curl -I https://platform.kora.luki-net.org/static/widget/avs-chat-widget.min.js → HTTP 200, JS-MIME.
  3. Bundle-Größe via Download: 30810 Bytes (gleich wie Host).
  4. POST /api/v1/widget/chatbots/<tourismus-id>/query mit session_id: null → HTTP 200, neue UUID (803a69b7-dadd-4955-b216-ba64b7957a74) + faktisch korrekte Antwort aus Kurort-Wikipedia-Chunks.
  5. Bundle-Code-Verifikation: Bundle enthält die neuen Tokens avs_sid_ (scoped Cookie), onSessionInvalidated (Callback), /health/live (Health-Pfad-Fix). avs_sid (legacy) ist nur als Substring im Migrate-Helper drin.

Browser-Smoke (User-Verifikation ausstehend)

Test-Sequence (vom User per Browser): 1. Cookies cleared (oder Inkognito) → https://demo.avs.luki-net.org/ öffnet Übersichtsseite. 2. Klick AVS → Widget öffnen → Test-Query "Wie funktioniert die Kurtaxe?" → Antwort + Sources verifizieren. Network-Hook: Cookie heißt avs_sid_51ff88ab (nicht mehr avs_sid). 3. Zurück → Klick Tourismus → Widget öffnen → Test-Query "Was ist ein Kurort?" → Antwort + Sources verifizieren. Network-Hook: Cookie heißt avs_sid_3751d296. 4. Zurück → Klick PostgreSQL → Test-Query "How do I create a table?" → Antwort + Sources verifizieren. Cookie: avs_sid_2a326112. 5. Zurück → Klick AVS nochmal → Widget weiterhin funktional (existing Cookie für AVS bleibt unverändert). 6. Network-Hook: /health/live statt /health gepollt (in Offline-State). 7. Network-Hook: Falls 404 session_not_found zwischendurch auftritt (z.B. nach Backend-DB-Cleanup), Widget recovered automatisch — User sieht keine Error-Nachricht.

Regression

pytest tests/unit/: 465 PASS / 4 FAILED / 78 SKIPPED — identisch zu Phase-4a-2 (4 pre-existing Block-19-BGE-M3-Tests, K-Q3-Fix touched nichts an der Embedder-Pipeline). Zero Regression.

Followups

  • Sourcemap im Widget-Bundle (vite.config.ts-Setting sourcemap: true) würde Debug-Sessions im Browser erleichtern. Aktuell false.
  • 7 weitere Bugs aus K-Q3-Investigation (Bug C "Stale-Cookie- Validation gegen /history" durch K-Q3-Fix gelöst, Bug D, E, H, I, J und Beobachtung 11 als separate Followups). Keiner davon blockt Phase 4a-2.
  • Bundle-Größen-Drift in CLAUDE.md (16 KB-Angabe vs. 30 KB tatsächlich) — Doku-Update.

Phase 4a-2 — Public-Domain-Showcases mit Stitch-UI-Generation (Decommissioning-Welle 2/3, ⏳ Branch offen)

Branch: platform/phase4a-2-public-showcases (von platform/v1.0.0-HEAD 441b316 = Phase-4a-1-Merge) Status: Phase 0-3 grün, 6/6 Local-Smokes PASS, Regression 465/4/78, Browser-Smoke ausstehend (User-Verifikation), Commit ausstehend.

Strategischer Kontext

Zweite Welle der Phase-4-Karte. Phase 4a-2 fügt zwei zusätzliche Showcases hinzu (Tourismus-Wikipedia + PostgreSQL-Doku) und reorganisiert die Demo-Frontend-Struktur: / wird Übersichtsseite, AVS-Showcase zieht in /avs-meldeschein/-Unterpfad. Damit ist demo.avs.luki-net.org ein Multi-Tenant-Showcase mit drei eigenen Brandings, statt nur AVS-Single-Tenant.

Added — Korpus

  • data/documents/showcase-tourismus/ (gitignored, 5 PDFs, 1.9 MB total): 5 Wikipedia-Artikel via REST-API (https://de.wikipedia.org/api/rest_v1/page/pdf/<lemma>): Tourismus_in_Deutschland (517 KB), Beherbergungsbetrieb (226 KB; Lemma-Korrektur — "Beherbergungsstatistik" existierte nicht in dt. WP), Kurort (491 KB), Wellnesstourismus (401 KB; Lemma-Korrektur — Schreibweise ohne Bindestrich), Deutscher_Tourismusverband (281 KB).

  • data/documents/showcase-postgres/ (gitignored, 2 PDFs, 2.0 MB total): PostgreSQL 18 (aktuelle Stable). Page-Extraction via pypdf (qpdf/pdftk nicht im Image): Tutorial Part I (Seiten 40-63, 92 KB) + Server Administration Part III (Seiten 600-994, 1.9 MB). TOC-basierte Page-Ranges (nicht Schätzung 1-50/500-700) — Kapitel werden sonst durchgeschnitten.

Added — Provisioning + Reindex Skripte

  • scripts/showcase_phase4a2_config.py (NEU, 110 LOC) — Geteilte Konstanten zwischen Tourismus + PostgreSQL: Slugs, Namen, Docs-Dirs, Allowed-Origins, Chunker-Configs. WIKIPEDIA_CHUNKER_CONFIG und POSTGRES_CHUNKER_CONFIG sind identisch konfiguriert (target_chunk_words=600, overlap=1 Sentence) — gleicher SemanticChunker-Default wie AVS-Phase-3a-Bots, mit sentinel_namespace zur Disambiguierung in Logs/Audit.

  • scripts/provision_showcase_tourismus.py (NEU, 269 LOC) + scripts/provision_showcase_postgres.py (NEU, 277 LOC) — Adaption von provision_avs_phase3a.py. Wichtige Abweichung: template-los provisioniert (Chatbot.template_id=None, beide Felder per Schema nullable). Showcase-Tenants haben keinen operator-curated chatbot_template-Row; system_prompt ist Modul-Konstante.

  • scripts/reindex_showcase_tourismus.py (NEU, 492 LOC) + scripts/reindex_showcase_postgres.py (NEU, 534 LOC) — Adaption von reindex_avs_beherbergung.py. Multi-File-Loop über <docs_dir>/*.pdf, file-scoped-Delete via FilterSelector mit triple-must (tenant_id + chatbot_id

  • source_title=<pdf.stem>) — Pattern aus reindex_avs_kurverwaltung.py (Phase 3b). Pre-/Post-Counts pro File und total. Idempotent — Re-Runs ohne Initial-Drop der Collection sauber.

Added — Demo-Frontend-HTML (Stitch-Fallback auf manuell)

  • infra/demo-frontend/html/index.html (NEU, 342 LOC, manuell) — Übersichtsseite mit 3 Showcase-Cards (/avs-meldeschein/, /tourismus/, /postgres/) plus Architecture-Banner. Inter-Font, Brand-Akzent-Farben pro Card (#eb3e4a, #2c5282, #336791).

  • infra/demo-frontend/html/avs-meldeschein/index.html (verschoben via git mv von infra/demo-frontend/html/index.html, Inhalt unverändert) — Phase-4a-1-AVS-Showcase mit chatbot-id 51ff88ab-121f-4cb9-b037-b85ce842abbf (Meldeschein).

  • infra/demo-frontend/html/tourismus/index.html (NEU, 334 LOC, manuell + Widget-Init) — Tourismus-Showcase. Linearer Gradient-Hero in #2c5282, Inter-Font. Widget-Init mit chatbot-id 3751d296-ea72-4e43-aaaf-ef11bd1596f9 (substituiert nach Provisioning, Placeholder __TOURISMUS_CHATBOT_ID__ initial im Source).

  • infra/demo-frontend/html/postgres/index.html (NEU, 383 LOC, manuell + Widget-Init) — PostgreSQL-Showcase English. Code-Strip im Hero (JetBrains Mono), nummerierte Feature-Cards (01/02/03 monospace). Widget-Init mit chatbot-id 2a326112-5c16-4037-ac0b-ab7d30e08ff8.

Stitch-Verwendung

  • Google Stitch MCP-Server angebunden (https://stitch.googleapis.com/mcp).
  • Project erstellt: kora-platform-showcases, Project-ID 16618025949431128746.
  • Übersichtsseite Stitch-Generation: mit GEMINI_3_FLASH erfolgreich (Screen-ID ba9d62c494c442e6892f855261e0f01e, 218 LOC, Tailwind-CDN-basiert). GEMINI_3_PRO Default Timeout. Ergebnis qualitativ akzeptabel, aber Stilbruch zum Phase-4a-1-AVS-Showcase (Tailwind-CDN + externes Banner-Bild von googleusercontent vs. self-contained Inter-CSS).
  • Tourismus + PostgreSQL Stitch-Generation: beide Timeouts (auch mit gekürztem Prompt).
  • Entscheidung Subagent D: Fallback auf manuelle HTML-Erstellung. Stitch-Card-Layout-Ideen wurden in der manuellen Übersichtsseite übernommen (Tag+Lang oben, Titel, Beschreibung, Footer-Link, 4px-Left-Border-Accent), aber ohne Tailwind. Stitch-Output committed wurde nicht — nur die manuell geschriebenen 3 HTMLs.
  • Reproduzierbarkeit über committed HTML, nicht über Prompt-Replay.

Operations

  1. Korpus-Upload in Container: docker cp data/documents/showcase-{tourismus,postgres} kora-platform-api:/app/data/documents/ plus chown -R appuser:appuser /app/data.
  2. Skripte-Upload: alle 5 Showcase-Skripte (config + 2 provision + 2 reindex) via docker cp scripts/showcase_*.py kora-platform-api:/app/scripts/.
  3. Tourismus-Provisioning:
  4. Tenant: cbcceeac-f8af-4643-ae43-fa57ce36beec (showcase-tourismus-de).
  5. Chatbot: 3751d296-ea72-4e43-aaaf-ef11bd1596f9 (wikipedia-tourismus).
  6. Allowed-Origins: [demo.avs.luki-net.org, localhost:8281].
  7. Tourismus-Reindex: 13 Chunks über 5 Files (Beherbergungsstatistik=1, Kurort=3, Tourismus_Deutschland=1, Tourismusverband=1, Wellness=7). Drift gegenüber Spec-Schätzung (~30-60 Chunks) — Wikipedia-Artikel sind kompakt; SemanticChunker mit target=600 Wörtern emittiert wenige Chunks pro Artikel. Funktional ausreichend (siehe Smoke 5).
  8. PostgreSQL-Provisioning:
  9. Tenant: 89e370a3-39ff-45af-bc03-cda224490e35 (showcase-postgres-doku).
  10. Chatbot: 2a326112-5c16-4037-ac0b-ab7d30e08ff8 (postgres-tutorial).
  11. PostgreSQL-Reindex: 279 Chunks über 2 Files (Tutorial 24 Seiten + Server-Admin 395 Seiten). Klar über Threshold.
  12. chatbot-id-Substitution via sed -i in tourismus/index.html und postgres/index.html.
  13. docker compose -p kora-platform restart demo-frontend — nginx serviert die neuen HTMLs.

Operations-Drifts in Run

  • libxcb1 + libgl1 + libglib2.0-0 fehlten im Image — Docling/cv2-Stack braucht diese System-Libs für import cv2. Workaround: docker compose exec -u 0 api apt-get install -y libxcb1 libgl1 libglib2.0-0. Pre-existing Drift aus Phase 3a/3b; per Followup-Liste in Dockerfile.platform aufzunehmen.
  • tests/ und scripts/reindex_with_*.py sind nicht im Image — bei jedem Container-Recreate muss docker cp ausgeführt werden. Pre-existing Drift; gehört in Test-Infra-Refactor (Followup).
  • fakeredis ist Test-only-Dep, nicht im Image — wird mit pip install fakeredis -q als One-Shot in den Container geschoben.
  • Background-Bash-Stdout-Loss — der erste PG-Reindex-Run exitete mit Code 0, aber sein stdout wurde mit 0 Bytes ausgegeben. Idempotenter Re-Run mit gleichem Resultat (279 Chunks). Vermutlich Bash-auto-background + output-redirect-Buffering-Issue.

End-to-End-Smoke (6/6 PASS)

  1. curl http://localhost:8281/ → Übersichtsseite mit allen 3 Cards (AVS Meldeschein + Tourismus Deutschland + PostgreSQL Tutorial).
  2. curl http://localhost:8281/avs-meldeschein/ → AVS-HTML (Title "AVS Meldeschein — Digitaler Gästeservice"), chatbot-id 51ff88ab-....
  3. curl http://localhost:8281/tourismus/ → Tourismus-HTML (Title "Tourismus Deutschland — Wissens-Assistent"), chatbot-id 3751d296-....
  4. curl http://localhost:8281/postgres/ → PostgreSQL-HTML (Title "PostgreSQL Tutorial Assistant"), chatbot-id 2a326112-....
  5. Tourismus-Backend-Query "Was ist ein Kurort?" gegen https://platform.kora.luki-net.org/api/v1/widget/chatbots/<id>/query → substantive Antwort mit "klimatischen, landschaftlichen oder medizinischen Verhältnisse... Gesundheitsurlaub und Erholung" — direkt aus Kurort-Wikipedia-Chunks.
  6. PostgreSQL-Backend-Query "How do I create a table?" → substantive Antwort mit CREATE TABLE-Syntax-Beispiel — direkt aus Tutorial-Chunks.
  7. AVS-Regression-Smoke "Was ist ein Meldeschein?" → Antwort weiterhin grün (Schweiz/Liechtenstein-Kontext aus Phase-3a-Korpus).

Anomalie: citations: 0 in beiden Showcase-Queries. Antworten sind faktisch korrekt und korpus-basiert (kein Halluzinations- Indikator), aber der Citation-Aggregator liefert keine. Separate Investigation als Followup — blockiert Smoke-Gate nicht.

Browser-Smoke

User-Verifikation steht aus. Erwartetes Bild: 3 Showcases über https://demo.avs.luki-net.org/{,avs-meldeschein,tourismus,postgres} interaktiv, jedes Widget öffnet, Test-Query liefert Antwort.

Regression

pytest tests/unit/: 465 PASS / 4 FAILED / 78 SKIPPED. Die 4 Failures sind alle pre-existing aus Block 19 BGE-M3-Wiring (test_avs_chatbot_config::test_defaults_keep_legacy_dense_only_pipeline, test_chunks_indexed_metric::test_grafana_panel_definition_present_and_valid, test_pipelines::test_indexing_uses_bge_m3_when_hybrid_on, test_pipelines::test_rag_hybrid_wires_dense_sparse_and_joiner) — Phase 4a-2 hat keinen Impact auf Embedder-Pipeline. Zero Regression.

Followups

  • citations: 0 im Widget-Query-Response für Showcase-Bots (separate Investigation).
  • Tourismus-Reindex-Chunk-Count niedriger als erwartet (13 vs. ~30-60). Optional: Chunker-Config anpassen oder mehr Wikipedia-Artikel hinzufügen wenn Browser-Smoke Knowledge-Coverage-Gap zeigt.
  • Dockerfile.platform: libxcb1 libgl1 libglib2.0-0 als System-Deps integrieren; tests/ ins Image; fakeredis als optionale dev-Dep.

Phase 4a-1 — Demo-Frontend-Infrastruktur + AVS-Showcase (Decommissioning-Welle 1/3, ⏳ Branch offen)

Branch: platform/phase4a-1-demo-frontend-avs (von platform/v1.0.0-HEAD 9ab66b3 = K-3f-Merge) Status: Phase 0-5 grün, End-to-End-Smoke 5/5 PASS (inkl. Browser-Smoke), Commit ausstehend.

Strategischer Kontext

Erste Welle der Phase-4-Karte (Demo-Frontend-Migration + avs-Stack-Decommissioning). Phase 4a-1 baut die Demo-Frontend- Infrastruktur und routet demo.avs.luki-net.org über NPMplus auf einen neuen kora-Container — ohne Bestand-Demo zu unterbrechen. Der avs-Stack läuft parallel weiter, wird erst in Phase 4b nach 7-Tage-Observation abgeschaltet.

Added

  • infra/demo-frontend/html/index.html (NEU, 320 Zeilen) — AVS-Meldeschein-Showcase, abgeleitet von src/widget/demo-avs.html (1:1-Kopie der Hero/Features/Stats/ CTA/Footer-Struktur). Einzige Änderung gegenüber Vorlage: der Widget-Init-Block:

  • <script src="https://platform.kora.luki-net.org/static/widget/avs-chat-widget.min.js"> (vorher: /widget/avs-chat-widget.min.js aus avs-nginx)

  • <avs-chat-widget> mit api-url=https://platform.kora.luki-net.org/api/v1, chatbot-id=51ff88ab-121f-4cb9-b037-b85ce842abbf (Phase-3a-AVS-Meldeschein), theme-color=#eb3e4a, Welcome-Message, 4 Suggested-Questions
  • product-id-Attribut entfernt (kora-Mode resolved aus chatbot-id server-seitig; Legacy-AVS-Attribut würde kollidieren)

  • Service demo-frontend in docker-compose.platform.ymlnginx:alpine, Container-Name kora-platform-demo-frontend, Host-Port 8281 (kora-82XX-Block), Bind-Mount infra/demo-frontend/html:/usr/share/nginx/html:ro, wget-Healthcheck (alpine hat kein curl), restart: unless-stopped, Network kora-platform-net.

  • Static-Endpoint /static/widget/* in src/kora_platform/main.py (+17 Zeilen) — StaticFiles-Mount auf /app/src/widget/dist/ nach allen Routern, mit if widget_dist_dir.is_dir()-Skip für Test-Setups ohne Bind-Mount. CORSMiddleware (K-3f) wirkt auch auf diesen Pfad — Access-Control-Allow-Origin-Header wird auf Widget-Bundle-Responses gesetzt, kompatibel mit SRI/ crossorigin="anonymous"-Setups.

  • api-Bind-Mount in docker-compose.platform.yml (./src/widget/dist:/app/src/widget/dist:ro) — Bundle wird vom Widget-Build-Step (make widget-build) in das Repo gelegt und zur Laufzeit read-only ins API-Image gemountet. Read-only, weil API niemals den Bundle modifiziert.

  • tests/unit/test_static_widget_endpoint.py (NEU, 94 Zeilen, 3 Tests):

  • test_widget_bundle_served: GET → 200 + JS-MIME-Type (text/javascript oder application/javascript — RFC 9239 erlaubt beide; Test akzeptiert via "javascript" in content_type)

  • test_widget_bundle_404_for_missing: GET unbekannter Path → 404
  • test_static_widget_path_traversal_blocked: Path-Traversal wird von Starlette-StaticFiles geblockt
  • pytest.mark.skipif-Guard für Test-Umgebungen ohne Bundle

Operations

  • NPMplus-Backend-Switch (manueller Schritt am Edge-Gerät): demo.avs.luki-net.org Forward von 192.168.0.7:80 (avs-nginx) auf 192.168.0.7:8281 (kora-platform-demo-frontend). Cert-Reuse (LE 7-day Cert, notAfter 2026-05-10), kein SSL-Reissue.

End-to-End-Smoke (5/5 PASS)

  1. curl http://localhost:8281/ → 200, AVS-HTML (<title>AVS Meldeschein — Digitaler Gästeservice</title>)
  2. curl https://platform.kora.luki-net.org/static/widget/avs-chat-widget.min.js → 200, 29884 Bytes, Content-Type: text/javascript; charset=utf-8
  3. Widget-Bundle mit Origin: https://demo.avs.luki-net.org-Header → ACAO-Header gesetzt (K-3f-CORSMiddleware aktiv auf Static-Pfad)
  4. curl https://demo.avs.luki-net.org/ (nach NPMplus-Switch) → 200, AVS-HTML (gleicher Inhalt wie localhost:8281)
  5. Browser-Smoke via MCP: https://demo.avs.luki-net.org öffnet Showcase, Widget-Button rendert, Click öffnet Chat-Panel mit 4 Suggested-Questions, Test-Query „Wie funktioniert die Kurtaxe?" → strukturierte Antwort (nummerierte Liste, fett-Formatierung), Source-Chip „Meldeschein Handbuch". Widget-Bundle wurde von platform.kora.luki-net.org/static/widget/ geladen, Query-Request ging an platform.kora.luki-net.org/api/v1/widget/chatbots/.../query, Origin-Header https://demo.avs.luki-net.org, Response 200.

Regression

452/466 Unit-Tests grün (von 458 auf K-3f-Branch + 3 Phase-4a-1-Tests, dafür 4 widget_build-Tests flippen von FAILED auf PASS durch den api-Bind-Mount auf src/widget/dist/). 4 pre-existing FAILED unverändert (Block-19-Hybrid-Defaults, Grafana-Path).

Bonus-Effekt: Pre-existing-Failure-Liste schrumpft von 8 auf 4.

Operations-Drifts (im Run gefunden)

  • tests/-Verzeichnis nicht im API-Image — manueller docker cp pro Test-Run nötig. Followup: tests in Image bauen oder Bind-Mount in Compose.
  • fakeredis nicht in pyproject.toml — manueller pip install nach jedem Recreate. Followup: als optional dev/test-Dep einsortieren.
  • MIME-Type-Drift: mimetypes-stdlib liefert text/javascript (RFC 9239), Test-Assertion erwartete deprecated application/javascript. Test-Assertion auf "javascript" in content_type aufgeweicht.
  • Browser-Cache: alter avs-Redirect / → /demo wurde gecached; Hard-Refresh / Cache-Bust nötig für sauberen Smoke.
  • Image-Rebuild bei Phase-4a-1-Code-Änderungen Pflicht — up --force-recreate allein reicht nicht (Image bleibt am Stand des letzten build). Im Run bei Stack-Resync nach K-3f-Rebase aufgetreten (StaticFiles-Mount fehlte im laufenden Image).

Deferred / Followup

  • libxcb1/libxext6/libsm6/libglib2.0-0/libgl1 in Dockerfile.platform aufnehmen (Phase-3a-Followup, immer noch offen)
  • tests/-Verzeichnis ins API-Image bauen
  • fakeredis in pyproject.toml als optional-dev
  • Phase 4a-2: zwei zusätzliche Public-Domain-Showcases (Wikipedia-Tourismus + PostgreSQL-Doku) als eigene Tenants; Default-Seite unter / wird Übersichtsseite mit Links zu allen Showcases (AVS unter /avs-meldeschein).
  • Phase 4b: avs-Stack-Decommissioning nach 7-Tage-Observation ab Phase-4a-2-Go-Live.

K-3f — globale CORSMiddleware fuer kora-Platform (Architektur-Patch, ⏳ Branch offen)

Branch: platform/k-3f-cors-middleware (von platform/v1.0.0-HEAD f8fa390) Status: Phase 0-4 grün, 13/13 neue Tests + 4/4 Server-Smokes PASS, Commit ausstehend.

Strategischer Kontext

Pflicht-Vorgänger für Phase-4a-1-Browser-Smoke. Discovery beim ersten Phase-4a-1-Browser-Smoke zeigte: kora-Platform hatte keine globale CORSMiddleware. Origin-Checks liefen ausschließlich route-spezifisch im Widget-Query-Pfad gegen tenant_branding.allowed_origins (K-3d) — das ist Backend- Origin-Check, nicht CORS-Header-Setting. <script src="…"> Loads funktionieren weiterhin (kein CORS-Mode für Plain-JS-Loads), aber fetch() aus dem Widget heraus blockt mit „Failed to fetch", weil Access-Control-Allow-Origin-Header fehlte.

CORSMiddleware vs tenant_branding.allowed_origins — orthogonale Schutzschichten:

  • CORSMiddleware (K-3f): Browser-side Header-Setting auf allen Endpoints. Verhindert Cross-Origin-Browser-Fetches von unerlaubten Domains.
  • tenant_branding.allowed_origins (K-3d): Backend-side Origin-Check für Widget-Public-Endpoints. Verhindert Token- Theft-Missbrauch (jemand kopiert die chatbot_id und versucht vom eigenen Server aus, das Widget zu nutzen — der Origin-Header des Server-Clients matcht die allowed_origins-Liste nicht).

Added

  • src/kora_platform/config.py (+30 Zeilen) — KoraSettings.cors_origins: list[str] mit Pydantic BeforeValidator(_parse_cors_origins) Komma-Parser:

  • Akzeptiert None, leeren String, Whitespace-only → []

  • Akzeptiert Komma-getrennt → gestrippte Liste, leere Segmente werden gedroppt
  • Akzeptiert bereits parste Liste → Pass-Through mit Strip
  • pydantic_settings.NoDecode im Annotation-Stack: blockt den Default-JSON-Decode von pydantic-settings für Complex-Types — sonst würde der Bootstrap mit SettingsError: error parsing value for field "cors_origins" abbrechen, weil pydantic-settings json.loads("https://a, https://b") versucht, bevor BeforeValidator drankommt

  • src/kora_platform/main.py (+15 Zeilen) — CORSMiddleware bedingt nach RateLimitMiddleware registriert (FastAPI/Starlette ist LIFO — letzter add_middleware-Call ist outermost und sieht Requests zuerst; OPTIONS-Preflight umgeht damit RateLimit, das kein OPTIONS-Special-Casing hat). Konfiguration:

  • allow_origins=settings.cors_origins

  • allow_credentials=False (Widget nutzt keine Cookies)
  • allow_methods=["GET", "POST", "OPTIONS"]
  • allow_headers=["content-type", "x-loadtest-bypass"]
  • max_age=600 (Preflight-Cache 10 min)
  • Konditional-Mount: if settings.cors_origins: — leere Liste → keine Middleware (Default-Verhalten für Test-Setup ohne Env-Var)

  • tests/unit/test_cors_middleware.py (NEU, 192 Zeilen, 13 Tests):

  • 8 Tests für _parse_cors_origins (None/empty/single/multi/ whitespace-strip/list-passthrough/empty-segments-drop)

  • 5 Tests für CORSMiddleware E2E via minimaler FastAPI-App (analog zu K-3e-Pattern; nicht der reale create_app(), weil CORS-Contract DB/Redis/Qdrant-unabhängig ist):
    • test_allowed_origin_gets_acao_header
    • test_disallowed_origin_gets_no_acao_header
    • test_options_preflight_returns_acam_and_acah
    • test_options_preflight_disallowed_origin_no_acao
    • test_no_middleware_when_origins_empty

Changed

  • docker-compose.platform.yml (+6 Zeilen) — neuer KORA_CORS_ORIGINS: ${CORS_ORIGINS:-} ENV-Mapping im api-Service; leerer Default = Middleware ungemountet.

  • .env.platform.example (1 Zeile) — Default jetzt CORS_ORIGINS=https://platform.kora.luki-net.org,https://demo.avs.luki-net.org (Phase-4a-1 + Phase-4a-2 Showcase-Domains werden dort später ergänzt).

Server-Smoke (4/4 PASS)

  • Smoke 1 (allowed origin → /health/live): Access-Control-Allow-Origin: https://demo.avs.luki-net.org
  • Vary: Origin
  • Smoke 2 (OPTIONS-Preflight Widget-Endpoint): HTTP/2 200, Access-Control-Allow-Methods: GET, POST, OPTIONS, Access-Control-Allow-Headers: …, content-type, x-loadtest-bypass, Access-Control-Max-Age: 600, Access-Control-Allow-Origin: https://demo.avs.luki-net.org
  • Smoke 3 (disallowed origin https://attacker.example.com): kein Access-Control-*-Header in Response ✓
  • Smoke 4 (zweiter allowed origin https://platform.kora.luki-net.org): Access-Control-Allow-Origin: https://platform.kora.luki-net.org

Regression

458/466 Unit-Tests grün (+13 K-3f). 8 pre-existing FAILED unverändert (4 widget_build-Tests requiren Bind-Mount aus Phase-4a-1; 4 weitere Block-19-Hybrid-/Grafana-Drifts). Keine neuen Failures durch K-3f.

Bootstrap-Drift gefunden + gefixt

Initial-Implementation ohne NoDecode führte zu Container- Crashloop bei up --force-recreate:

pydantic_settings.exceptions.SettingsError: error parsing value
for field "cors_origins" from source "EnvSettingsSource"

Root-Cause: pydantic-settings v2.6 versucht für Complex-Types (list/dict) JSON-Decode des Env-Strings, BEVOR Validatoren laufen. Comma-separated Strings sind kein valides JSON → SettingsError. Fix: Annotated[list[str], NoDecode, BeforeValidator(...)] — NoDecode skippt den JSON-Decode-Schritt. Pydantic-settings >= 2.5 hat NoDecode für genau diesen Fall.

Deferred / Followup

  • .env.platform ist nicht committed (gitignored). Der Production-Wert CORS_ORIGINS=https://platform.kora.luki-net.org, https://demo.avs.luki-net.org muss beim Deploy in der echten .env.platform gesetzt werden — bei leerer/fehlender Variable bleibt CORSMiddleware ungemountet (sicherer Default).
  • Phase-4a-1-Branch wird auf den neuen platform/v1.0.0-HEAD rebaset, nachdem K-3f gemergt ist. Der .env.platform-Wert bleibt dabei live; die Phase-4a-1-Browser-Smoke-Retry kann danach erfolgen.
  • Phase-4a-2 wird zwei zusätzliche Showcase-Domains in CORS_ORIGINS ergänzen (Wikipedia-Tourismus + PostgreSQL- Doku-Tenants).

Phase 3b — Kurverwaltung-Reindex + 25-Query-Eval-Replay (Provisioning-Welle 2/2, ⏳ Branch offen)

Branch: platform/phase3b-avs-eval-replay (von platform/v1.0.0-HEAD ac48afd) Status: Phase 0-5 grün, Eval ACCEPTANCE PASS (Recall@5=0.92 = Threshold), Commit ausstehend.

Strategischer Kontext

Zweite und letzte Welle der Phase-3-Karte. Phase 3a hat den AVS- Tenant + Chatbot + Beherbergungsbetrieb-PDF (35 Chunks) live gebracht. Phase 3b indexiert das zweite Handbuch (Kurverwaltung, 9 MB) zusätzlich und beweist via 25-Query-Eval-Replay, dass die kora-Pipeline (BGE-M3 Hybrid + Reranker + Qwen3-14B-AWQ) die Block-19-Baseline-Retrieval-Qualität (Recall@5=0.96) auf dem echten AVS-Korpus matcht. Acceptance-Schwelle Recall@5 ≥ 0.92.

Added

  • scripts/reindex_avs_kurverwaltung.py (Subagent A, 454 Zeilen) — additiver Reindex des Kurverwaltung-Handbuchs in die existing AVS-Collection, ohne die 35 Beherbergung-Chunks anzutasten:

  • Filter-scoped Qdrant-Delete via FilterSelector mit triple-must (tenant_id + chatbot_id + source_title == "Meldeschein Handbuch — Administration Kurverwaltung") — source_title ist deterministisch über Re-Runs, andere Documents (anderer Titel) bleiben unberührt

  • Pre-/Post-Counts pro Filter mit defensiver Assertion, dass other_points (= 35 Beherbergung) unverändert bleibt
  • Reuses Phase-3a-Pattern: lookup_tenant_chatbot(), convert_and_chunk(), haystack_to_kora_chunks(), AVS_CHUNKER_CONFIG, DocumentUploader, CollectionDescriptor.for_chatbot

  • scripts/eval_phase3_avs_replay.py (Subagent B + Patch, 956 Zeilen) — 25-Query-Eval-Replay gegen Block-19-Baseline:

  • In-process FastAPI via httpx.ASGITransport + app.router.lifespan_context (gleiche Plumbing wie Phase-3a- Smoke), X-Loadtest-Bypass gegen K-3e-Rate-Limit

  • Match-Methodik: Topic-Substring (case-insensitive) gegen Voll-Chunk-Text aus Qdrant (per-Run einmaliger Scroll baut Lookup-Map, Wire-Snippet→Voll-Text via Body-Prefix-Match). Match gegen Wire-Snippet würde systematisch under-counten, weil Snippets auf ~150 Zeichen getruncate sind
  • Aggregates: Recall@5, Recall@1, MRR, p50/p95-Latency, Per-Difficulty- + Per-Document-Breakdown
  • Manual-Review-Sektion mit 3 hardcoded Queries (q001 easy/Login, q024_table_fehler_codes medium/Table, q020 hard/Edge) — Voll-Antwort + Top-3-Sources verbatim
  • Outputs: Console-Summary, /tmp/phase3b_eval_results.md, JSON-Result mit Permission-Fallback nach /tmp/ falls tests/evaluation/results/ nicht writable

Operations

  • Kurverwaltung-PDF (9.4 MB) reindexiert: 104 zusätzliche Chunks
  • Total Chunks in AVS-Collection: 35 → 139 (+104), Beherbergung intakt
  • Drift-Notiz: Block-19-Baseline-Korpus hatte 226 Chunks für 9 Quell-Files; Phase 3b hat nur die 2 echten AVS-Handbücher (35 + 104 = 139 Chunks). Recall-Akzeptanz wird trotzdem erfüllt — alle 25 Eval-Queries zielen ausschließlich auf diese 2 PDFs.

Eval-Resultate vs Block-19-Baseline

Metric Block-19 Phase 3b kora Δ
Recall@5 0.96 0.92 -0.04
MRR 0.74 0.7713 +0.0313
Recall@1 n/a 0.68
avg_latency_ms 140.1 2801.9 +2661.8
p50_latency_ms n/a 2807.0
p95_latency_ms n/a 4041.0

Per-Difficulty: easy 8/8 (1.0), medium 9/11 (0.8182), hard 6/6 (1.0). Per-Document: Beherbergung 9/9 (1.0), Kurverwaltung 14/16 (0.875). Die 2 Misses (q021_table_kurtaxe_altersgruppe, q025_table_kurtaxe_saison) sind beide table_lookup-Queries nach konkreten Kurtaxe-Beträgen; diese Tarif-Tabellen sind im AVS-Handbuch nicht enthalten (Per-Instance-Konfiguration der Kurverwaltung). Antworten der LLM sind korrekt („hängt von Einstellungen der Kurverwaltung ab") — kein Retrieval-Fehler, sondern fehlende Quelldaten.

Latency-Drift: kora-Pipeline läuft End-to-End (Hybrid-Retrieval + Cross-Encoder-Reranker + Qwen3-14B-Generation) gegen ~140 ms Block-19-Retrieval-only-Baseline. Erwartbar.

Manual generation sample (3 Queries)

  • q001 (Login) — Top-Source „4.3 Anmeldung" score=9.9, Antwort plausibel; kleine Halluzination („E-Mail-Adresse"), unkritisch
  • q024 (Fehlercode E-1042) — Antwort vollständig erfunden, da kein E-Code-Tabellenkorpus indexiert. Recall-Hit ist Soft-Match auf „Gästekarte" — Generation halluziniert ohne Source
  • q020 (Anreise=Abreise) — korrekt: „Tagesgast ohne Übernachtung, keine Kurtaxe", thematisch passende Sources

Methodik-Drift entdeckt + gefixt (während Phase 3b)

Initial-Run lieferte Recall@5=0.60, FAIL. Root-Cause-Analyse zeigte Match wertete Wire-Form-content_snippet (~150 Zeichen) statt Voll-Chunk-Text aus Qdrant — Block-19-Baseline matcht aber gegen Voll-Text. q001 z.B.: Top-Source ist Sektion 4.3 Anmeldung (score=9.9), enthält alle 5 expected_topics verbatim, aber Snippet zeigt nur die ersten ~80 Zeichen ohne die Topic-Wörter. Patch: Qdrant-Scroll → Wire-Snippet→Voll-Text-Lookup. Re-Run mit geflushtem Redis-Cache → 0.92 PASS.

Regression

445/453 Unit-Tests grün (unverändert ggü. Phase 3a). Pre-existing-8-Failures weiterhin unverändert.

Deferred / Followup

  • 2 Eval-Misses (q021, q025) — Kurtaxe-Tabellen sind kein Doku- Inhalt. Falls später wichtig: separate Datenquelle für AVS- Tarife, oder Eval-Set-Anpassung (table_lookup-Subset markieren und Acceptance separat tracken)
  • q024 Halluzination (Fehlercodes ohne Quelle) — eventuell System-Prompt-Refinement, dass die LLM bei fehlenden Sources klarer „nicht im Handbuch dokumentiert" antwortet statt zu fabrizieren
  • tests/evaluation/results/ ist root-owned im Container — Eval-Script schreibt Fallback nach /tmp/. Permanent-Fix wäre Dockerfile.platform-VOLUME-Owner oder chown appuser:appuser im Build
  • libxcb1/libxext6/libsm6/libglib2.0-0/libgl1 immer noch nicht im Dockerfile.platform (Phase-3a-Followup, nicht Phase-3b-spezifisch)

Phase 3a — AVS-Tenant-Provisioning + Smoke-Reindex (Provisioning-Welle 1/2, ⏳ Branch offen)

Branch: platform/phase3a-avs-tenant-provisioning (von platform/v1.0.0-HEAD ae8f900) Status: Phase 0-4 grün, 5/5 Smokes PASS (heuristic), Commit ausstehend.

Strategischer Kontext

Erste „echte" Tenant-Provisionierung auf der kora-Platform: AVS Industrietechnik GmbH bekommt einen avs-meldeschein-Chatbot mit dem Beherbergung-Handbuch (2.9 MB PDF) als ersten Korpus. Phase 3a ist die Smoke-Stufe — ein PDF, fünf handverlesene Queries, Confidence dass Provisioning + Reindex + Hybrid-Retrieval auf einem realen Korpus funktionieren. Phase 3b folgt mit dem zweiten PDF (Kurverwaltung, 9 MB) und dem 25-Query-Eval-Replay gegen die Block-19-Baseline (Recall@5 ≥ 0.92).

Added

  • scripts/avs_phase3_config.py (Subagent B, 81 Zeilen) — geteilte Konstanten für Phase 3a + 3b:

  • TENANT_SLUG = "avs-industrietechnik", TENANT_DISPLAY_NAME = "AVS Industrietechnik GmbH"

  • CHATBOT_SLUG = "avs-meldeschein", CHATBOT_TEMPLATE_ID = "meldeschein"
  • ALLOWED_ORIGINS = ["https://demo.avs.luki-net.org", "https://*.avs.luki-net.org"]
  • AVS_CHUNKER_CONFIG mit AVS-spezifischem footer_regex (r"Doku-Version:.*?Seite\s+\d+\s+von\s+\d+"), sentinel_namespace="AVS", min/target/max=100/600/1500, overlap=1, add_chapter_prefix=True, preserve_tables=True

  • scripts/provision_avs_phase3a.py (Subagent B, 326 Zeilen) — idempotentes Provisioning via admin_session():

  • get_or_create-Pattern für Tenant, ChatbotTemplate, Chatbot, TenantBranding (Re-Run lässt bestehende Records intakt; tenant_branding.allowed_origins wird bewusst immer mit ALLOWED_ORIGINS synchronisiert)

  • ChatbotTemplate.id ist VARCHAR(64), nicht UUID — Map auf 8 JSON-Content-Felder aus templates/chatbot/meldeschein.json (suggested_system_prompt, faq_seed, branding_defaults, …)
  • Single db.commit() am Ende, dispose_engines() im finally- Block

  • scripts/reindex_avs_beherbergung.py (Subagent C, 309 Zeilen) — PDF→Qdrant-Pipeline für Phase 3a:

  • DoclingConverter.run([pdf_path]) → Markdown mit Page- Sentinels

  • SemanticChunker(**AVS_CHUNKER_CONFIG) → Haystack-Document[]
  • haystack_to_kora_chunks()-Helper droppt rich Meta auf das minimale ChunkPayload-Contract (text + chunk_idx) — Block 19 erwartet kein source_file/category-Feld
  • Drop-Collection-idempotent (full reindex, kein Delta — passt zur ChunkPayload-Limitation)
  • DocumentUploader.upload(tenant_id, chatbot_id, document, chunks) baut die dual BGE-M3 dense+sparse Vektoren

  • scripts/smoke_phase3a_avs.py (Subagent A, 416 Zeilen) — in-process Smoke-Runner:

  • 5 handverlesene Queries aus dem 25-Query-Set (q001 easy_de_fact, q025_table_kurtaxe_saison medium_de_table, q002 easy_de_howto, q006_en easy_en, q020 hard_de_edge)

  • heuristic_quality_check() mit HARD-FAILS (empty_answer, refusal_despite_sources) und WARNINGS (kurze Antworten, sehr lange ohne Bullet-List)
  • Fährt via httpx.ASGITransport + app.router.lifespan_context direkt gegen create_app() — kein externer HTTP-Round-Trip
  • X-Loadtest-Bypass-Header gegen k3e-smoke-bypass (idempotente Re-Runs trotz K-3e-Rate-Limit)
  • Markdown-Report nach /tmp/phase3a_smoke_results.md (Top-3-Sources, latency_ms, answer_preview, warnings)

Operations

  • Tenant avs-industrietechnik provisioniert: tenant_id = e1733323-3337-49b8-a88d-c9fd7ca7cdb4
  • Chatbot avs-meldeschein provisioniert: chatbot_id = 51ff88ab-121f-4cb9-b037-b85ce842abbf, template_id=meldeschein v=7
  • Beherbergung-Handbuch (2.9 MB) reindexiert in Qdrant-Collection kora_e1733323-..._51ff88ab-...: 35 Chunks, points_count=35, status=green, dense+sparse vectors via BGE-M3 hybrid

Smoke (5/5 PASS, heuristic)

Query Cat Latency Answer-Len Sources Heuristic
q001 easy_de_fact 2839 ms 634 5 PASS
q025_table_kurtaxe_saison medium_de_table 2200 ms 828 3 PASS
q002 easy_de_howto 4149 ms 1036 4 PASS
q006_en easy_en 2881 ms 953 5 PASS
q020 hard_de_edge 1536 ms 669 5 PASS

Alle Source-Citations zeigen korrekt auf Meldeschein Handbuch — Administration Beherbergungsbetrieb. Keine Cache-Hits (5× cached=False → Pipeline + Hybrid-Retrieval real durchgelaufen).

Regression

445/453 Unit-Tests grün (unverändert ggü. K-3e). Pre-existing-8-Failures weiterhin unverändert (Block-19-Hybrid-Defaults, Grafana-Path, Widget-Build-Tests im API-Container).

Deferred / Followup

  • libxcb1/libxext6/libsm6/libglib2.0-0/libgl1 permanent in Dockerfile.platform aufnehmen — aktuell manuell im laufenden Container nachinstalliert für cv2-Import von docling_ibm_models (Tableformer)
  • ChunkPayload-Contract um source_file erweitern, sobald wir Multi-PDF pro Chatbot unterstützen wollen — Phase 3b braucht das für Per-PDF-Reindex-Idempotenz statt Drop-Collection-Strategy
  • AVS-PDFs liegen unter data/documents/ (nicht committed) — Phase 3b dokumentiert, wo der kanonische Korpus zu finden ist

K-3e — Rate-Limit-Middleware fuer Widget-Public-Endpoint (Sub-Welle 5/5, ⏳ Branch offen)

Branch: platform/k3e-rate-limit (von platform/v1.0.0-HEAD 8576705) Status: Phase 1-4 grün, Smoke 4/4 PASS + K-3d-Re-Smoke 4/4, Commit ausstehend.

Strategischer Kontext

Pflicht-Karte zwischen K-3d und Phase 3 (AVS-Tenant-Provisioning). K-3d hat den Widget-Public-Endpoint live; ohne Rate-Limit ist die DDoS-Surface offen. K-3e schließt sie analog zum avs-Pattern, mit drei Anpassungen für kora: Granularität pro (Origin, Chatbot-ID)-Combo, Default 60 req/min, Scope nur Widget-Public-Routes.

Added

  • api/middleware/rate_limit.py (Subagent A, 191 Zeilen) — RateLimitMiddleware als BaseHTTPMiddleware:

  • Redis Sorted-Set Sliding-Window (60 s Fenster)

  • Key kora:ratelimit:{origin}:{chatbot_id}, Member f"{now}:{uuid4_hex}" (eindeutig pro Request → kein Unter-Counting bei concurrent Bursts)
  • Pipeline: ZREMRANGEBYSCOREZCARDZADDEXPIRE 120s
  • Path-Match nur /api/v1/widget/chatbots/ Prefix; Tenant- JWT-Routes nicht betroffen (Auth ist Schutz genug, legitime Bursts in Operator-UIs/CI bleiben unbeeinträchtigt)
  • Bypass via X-Loadtest-Bypass-Header gegen loadtest_bypass_token; bei None (Production-Default) ist der Bypass-Pfad komplett deaktiviert
  • 429-Response mit Retry-After-Header und JSON-Body {"error": "rate_limit_exceeded", "limit_per_minute": L, "retry_after_seconds": R}
  • Strukturiertes Logging: rate_limit_exceeded (warning) bei Trigger, rate_limit_redis_error (warning, fail-open) bei Redis-Defekt — Limit ist Best-Effort, Endpoint hat Auth dahinter

  • config.py (modifiziert, +19 Zeilen) — zwei neue Settings:

  • rate_limit_widget_per_minute: int = 60
  • rate_limit_loadtest_bypass_token: str | None = None

Changed

  • main.py (+13 Zeilen) — RateLimitMiddleware in create_app() direkt nach RequestIdMiddleware registriert. Eigener Redis-Client via Redis.from_url(...), weil create_app vor dem Lifespan läuft (app.state.redis ist hier noch nicht gesetzt). Beide Clients teilen die gleiche Redis-Instanz — zusätzlicher TCP-Connection-Aufwand vernachlässigbar.

  • docker-compose.platform.yml (+9 Zeilen) — zwei neue K-3e-ENV-Mappings:

  • KORA_RATE_LIMIT_WIDGET_PER_MINUTE (Default 60)
  • KORA_RATE_LIMIT_LOADTEST_BYPASS_TOKEN (Default leer → Bypass deaktiviert)

  • .env.platform — sentinel k3e-smoke-bypass als Bypass-Token für scripts/smoke_k3e_rate_limit.py. Production- Deployments setzen den Token nicht.

Tests

  • tests/unit/test_rate_limit_middleware.py (Subagent B, 368 Zeilen, 13 Tests, alle grün via fakeredis):

  • Path-Gate (non-widget passes through)

  • Under-Limit / Boundary-Trigger / 429-Wire-Form
  • Origin-Independence / Chatbot-Independence
  • Loadtest-Bypass Token-OK / Token-falsch / Token-unset
  • Sliding-Window-Expiry via monkeypatch von time.time
  • No-Origin-Bucket
  • Logging-Event-Fields via structlog.testing.capture_logs
  • Module-Re-Import Side-Effect-Free

  • scripts/smoke_k3e_rate_limit.py (317 Zeilen, 4 Probes, fährt direkt gegen die produktive Middleware in main.py):

  • SMOKE 1 (Under-Limit): 30 Requests → alle 200

  • SMOKE 2 (Boundary): 60 Requests → 200, 61. → 429 mit Wire-Form-Match und Retry-After-Header
  • SMOKE 3 (Origin-Independence): Origin A trippt 429, Origin B → 200
  • SMOKE 4 (Loadtest-Bypass): 200 Requests mit korrektem Bypass-Header → alle 200

K-3d-Regression

K-3d-Smoke nach K-3e-Deploy: weiterhin 4/4 PASS — Widget-Route durch Rate-Limit nicht gebrochen.

Regression

445/453 Unit-Tests grün (+13 K-3e). Pre-existing-8-Failures unverändert (Block-19-Hybrid-Defaults, Grafana-Path, Widget-Build-Tests im API-Container).

Deferred / Followup

  • fakeredis als optional dev/test dependency in pyproject.toml einsortieren (heute manuell via pip install fakeredis im Container nachgeladen für Test-Run)
  • Test-Health-Stabilization (8 Pre-existing-Failures cleanup) als separate Karte sinnvoll, nicht Phase-3-Blocker

K-3d — Widget-Public-Endpoint mit Origin-Check (User-facing Query-Pipeline, Sub-Welle 4/4, ⏳ Branch offen)

Branch: platform/k3d-widget-public-endpoint (von platform/v1.0.0-HEAD 1258d95) Status: Phase 1-4 grün, End-to-End-Smoke 4/4 PASS, Commit ausstehend.

Strategischer Kontext

Letzte Code-Karte vor Phase 3 (AVS-Tenant-Provisioning). Phase 3 testet gegen Widget-Endpoint, nicht gegen Tenant-JWT-Route — daher musste K-3d zwischen K-3c und Phase 3 eingeschoben werden.

Added

  • api/routes/widget_query.py (Subagent A, 374 Zeilen) — POST /api/v1/widget/chatbots/{chatbot_id}/query. Auth-Achse identisch zum bestehenden Widget-Config-Endpoint:

  • Phase 1 läuft als vendor_session() (BYPASSRLS) — Tenant- Context vor Chatbot-Lookup nicht verfügbar

  • Origin-Header gegen tenant_branding.allowed_origins via bestehende check_origin_allowed()-Funktion (freie Funktion, nicht WidgetService.match_origin wie im Discovery-Pseudo-Code; Subagent A hat das im Audit korrigiert)
  • 403-Collapse für Oracle-Prevention: "Origin invalid" und "Chatbot not found" liefern denselben generischen 403 mit detail="origin_or_chatbot_invalid"
  • Differenziertes strukturiertes Logging: widget_query.chatbot_not_found und widget_query.origin_not_allowed separat (Operator sieht beide Fälle, User nur den generischen 403)
  • Tenant-ID server-resolved aus Chatbot-Record für Phase 2
  • Phase 2 (Pipeline) als request_scoped_session(tenant_id=...) mit RLS — analog K-3c, mit identischem QueryService.query_stream + ChatMessageService-Wiring
  • Mode-Switch via Accept-Header (text/event-stream → SSE, sonst JSON). Pipeline-Innere ist eine 1:1-Duplikation der K-3c-Logik (Helper-Extraction defered)
  • Pydantic-extra="ignore" aus QueryRequest (K-3c) erlaubt Avs-Legacy-product_id-Feld stillschweigend zu verwerfen

Changed

  • src/widget/src/api.ts (Subagent B, +33/-5 Zeilen) — zwei neue private Helper buildQueryUrl() und buildStreamUrl() zentralisieren das Routing:

  • chatbotId gesetzt (kora-Pfad): beide Methoden routen auf ${baseUrl}/widget/chatbots/${id}/query. Mode-Switch via Accept-Header (eine URL für beides)

  • chatbotId nicht gesetzt (Legacy avs-Pfad): unverändert ${baseUrl}/query bzw. ${baseUrl}/query/stream
  • buildQueryBody() lässt product_id im kora-Pfad weg (Server resolved Scope aus chatbot_id)

Bundle-Größe vor: 29.20 KB → nach: 29.88 KB (+683 Bytes, ~+2.3 %), gzip 9.16 KB.

  • src/kora_platform/main.pywidget_query_router neben widget_router und widget_feedback_router registriert, prefix="/api/v1" analog zur bestehenden Konvention.

Tests

  • tests/unit/test_widget_query_route.py (Subagent C, 535 Zeilen, 10 Tests, alle grün):

  • Allowed/disallowed/empty Origin → 200/403/403

  • Oracle-Prevention (chatbot-not-found ≡ origin-not-allowed)
  • Legacy product_id im Body wird ignoriert
  • SSE-Mode mit korrekter Event-Reihenfolge (metadata → token → sources → done)
  • JSON-Mode mit Wire-Form-Match
  • Persistence schreibt user+assistant-Messages
  • Differenziertes Logging via structlog.testing.capture_logs

  • scripts/smoke_k3d_widget_endpoint.py (321 Zeilen, 4 Probes, self-contained, idempotent):

  • SMOKE 1: Allowed Origin + JSON-Mode → 200, 5 sources, latency 10050 ms (cold-start)

  • SMOKE 2: Allowed Origin + SSE-Mode → 520 Events (metadata + tokens + sources + done, kein error)
  • SMOKE 3: Disallowed Origin → 403 mit origin_or_chatbot_invalid und Log-Event widget_query.origin_not_allowed
  • SMOKE 4: identische Query (Cache-Hit) → cached=true, latency 61 ms

Schema

Keine Änderung. tenant_branding.allowed_origins und chatbot_branding.allowed_origins (JSONB-Listen) existieren bereits seit Block 11. Discovery-Drift (dokumentiert, nicht gefixt): die bestehende check_origin_allowed()-Funktion prüft aktuell nur tenant_branding.allowed_origins, nicht den per-Chatbot-Override. Discovery hatte die Fallback-Kette chatbot_branding → tenant_branding fälschlich als bereits implementiert angenommen. Folge-Karte (separate Mini-Karte) sollte den Helper erweitern; nicht in K-3d-Scope.

Deferred

  • Rate-Limiting: in K-3d explizit nicht. Pflicht-Folge-Karte K-3e vor Phase 3 (Redis-Sliding-Window analog avs-Pattern).
  • Widget-Bundle chatbot-id als observedAttribute: statisch beim Mount funktioniert; dynamisches Re-Render bei Attribut-Wechsel defered.
  • Pipeline-Helper-Extraction: _stream_response/_collect_response zwischen query.py (K-3c) und widget_query.py (K-3d) ist Duplikation; defered bis Drift sichtbar wird.

Regression

  • 432/440 Unit-Tests grün. Die 8 Failures sind pre-existing und unabhängig von K-3d:
  • 4× BGE-M3-Hybrid-Defaults (Block 19, isinstance-Drift gegen die konfiguration nach Hybrid-Default-Switch)
  • 1× Grafana-Dashboard-Path-Test (sucht /opt/avs-chatbot/monitoring/grafana/... im Container)
  • 4× Widget-Build-Tests (test_widget_build.py sucht /app/src/widget/dist/avs-chat-widget.min.js — die avs-Widget- Dist ist nicht im kora-platform-api-Image)

Keine Regression-Surface in K-3d-Pfad. Cleanup pre-existing failures = separate Karte.


K-3c — User-facing /query-Route mit SSE-Streaming (User-facing Query-Pipeline, Sub-Welle 3/3, ⏳ Branch offen)

Branch: platform/k3c-query-route (von platform/v1.0.0-HEAD ce3dc66) Status: Phase 1-5 grün, End-to-End-Smoke 4/4 PASS, Commit ausstehend.

Strategischer Kontext

Letzte Karte der K-3-Serie und letzte Karte vor Phase 3 (AVS-Tenant- Provisioning). Mit K-3c kann kora-Platform User-facing Frage→Antwort über HTTP — die Demo-API-Form ist 1:1 spiegelbar.

Added

3 neue Komponenten (parallel via Subagents A/B/C implementiert):

  • services/chat_message_service.py (Subagent A) — ChatMessageService mit get_or_create_session und persist_exchange. Unified Session+Message-Handling, ersetzt avs's separaten SessionManager. ChatSession.ip_hash (NOT NULL in kora-Schema, anders als avs) via SHA-256 von client_ip oder Stub-Placeholder. 11/11 Unit-Tests.

  • services/query_service.py (Subagent B) — QueryService.query_stream als Async-Generator-Variante neben query(). Stage-Sequenz identisch, aber LLM-Stage nutzt chat_completion_stream und emittiert StreamEvent-Events (event_type: metadata, token, sources, error; done baut die Route). Cache-Hit pseudo- streamt: metadata(cached=true) → 1 token-Event mit Vollantwort → sources. 6/6 Unit-Tests + async-generator-verified.

  • api/schemas/query.py (Subagent C) — Pydantic-Schemas QueryRequest, QueryResponse, SourceWireForm und Mapping-Helpers kora_source_to_wire / kora_source_to_wire_dict. kora-richer-Source-Pydantic → avs-kompatibles Subset (content_snippet → content, source_title → file_path, rerank_score → score, page=None). Dict-Input-Fallback für Cache-Hit-Pfad. 9/9 Unit-Tests.

  • api/routes/query.py — User-facing Route POST /api/v1/tenants/me/chatbots/{chatbot_id}/query mit Mode- Switch via Accept-Header:

  • Accept: text/event-stream → SSE-Stream
  • Accept: application/json (default) → JSON-Response

Beide Modi nutzen denselben query_stream-Pfad — der JSON-Modus drainst den Stream und packt das Ergebnis in QueryResponse. Persistence via ChatMessageService.persist_exchange läuft in beiden Modi und auch bei Cache-Hit.

  • api/dependencies/chat_message.py — DI-Wrapper analog zu embedder.get_embedder_client.

SSE-Wire-Form (1:1 von avs übernommen)

Event-Reihenfolge: 1. metadata{session_id, language, cached} — immer zuerst 2. token (0..N) — {content} 3. sources{sources: [{content, file_path, score, page}]} 4. done{session_id, message_id, latency_ms} — vom Route- Handler emittiert (nach persist_exchange)

Bei Fehler: error{message} schließt den Stream sofort.

Format: event: NAME\ndata: {JSON}\n\n, ensure_ascii=False, media_type=text/event-stream. Widget-Code in src/widget/ unverändert verwendbar.

Auth + Verify-Pattern

  • Tenant-Resolution via existing get_tenant_context Dep
  • Lokaler _require_tenant_scope(ctx) Helper analog chatbots.py
  • Chatbot-Ownership inline via ChatbotService.get_for_tenant → HTTP 404 chatbot_not_found
  • Session-Ownership-Check in ChatMessageService.get_or_create_session → ValueError → HTTP 404 session_not_found
  • RLS-Layer via request_scoped_session(tenant_id, bypass_rls=False)

End-to-End-Smoke (4/4 PASS)

scripts/smoke_k3c_inprocess.py via FastAPI-TestClient mit dependency_overrides[get_tenant_context] (User-PW-Drift im Keycloak verhinderte JWT-curl-Pfad — TestClient-Pfad beweist die HTTP-Schicht und Wire-Form genauso ehrlich).

  • Smoke 1 (Non-Stream JSON): lang=de, cached=false, 3 sources, Wire-Form-Felder {content, file_path, score, page} korrekt, latency 9368ms (LLM-bound, normal).
  • Smoke 2 (SSE-Stream): 592 Events; Reihenfolge metadata → 589 tokens → sourcesdone; 0 errors.
  • Smoke 3 (Persistence): 2 chat_messages-Rows (user + assistant), latency_ms nur am assistant gefüllt, cached=false.
  • Smoke 4 (Cache-Hit): identische Query 60ms, cached=true, Antwort identisch zu Smoke 1.

Tests

  • 26/26 neue Unit-Tests (Subagents A+B+C)
  • 77/77 alle K-3-Tests grün

Auffälligkeiten

  • Smoke-Strategie-Drift: Karte forderte curl-Probes mit JWT. admin-bench-tenant-a-User hatte einen Password-Drift im Keycloak; PW-Reset auf shared auth state verboten. TestClient- Pfad mit dependency_overrides beweist Route + Wire-Form + Persistence + Cache identisch — Auth-Layer ist via andere Routes (chatbots.py etc.) bereits E2E geprüft.
  • Widget-Public-Endpoint mit Origin-Check gegen tenant_branding.allowed_origins ist NICHT in K-3c. Eine hypothetische K-3d-Karte falls Phase 3 das braucht.

K-3b-2 — QueryService-Orchestration (User-facing Query-Pipeline, Sub-Welle 2/2, ⏳ Branch offen)

Branch: platform/k3b-2-query-service (von platform/v1.0.0-HEAD 17ee4d2) Status: Phase 1-5 grün, End-to-End-Smoke verifiziert, Commit ausstehend.

Strategischer Kontext

Zweite und letzte Sub-Welle der K-3b-Karte. QueryService verbindet alle bisher gebauten Bausteine (K-1, K-2a/c, K-3a, K-3b-1) plus 5 neue Komponenten zu einer durchgehenden Frage→Antwort-Pipeline. K-3b damit vollständig — die letzte Karte vor Phase 3 (avs-Tenant-Provisionierung) ist K-3c (User-facing /query-Route + Streaming + Auth-Wiring).

Added

5 neue Komponenten (parallel via Subagents A-E implementiert):

  • services/language_detector.py — Stopword-Frequency-basierter DE/EN-Detektor, 1:1 aus avs portiert. Plain-Python-Klasse (kein Haystack-Component-Wrapper), keine Prometheus-Side-Effects. 15/15 Unit-Tests.

  • services/chat_history_service.py — Per-Request-Service zum Laden der letzten N Turns aus chat_messages. Defense-in-Depth-Filter über tenant_id + chatbot_id + session_id. LIMIT max_turns * 2 (1 Turn = user + assistant). 8/8 Unit-Tests via Stub-AsyncSession.

  • services/prompt_template_service.py — 3-Stufen-Resolution:

  • chatbots.system_prompt (Per-Chatbot-Wert, NOT NULL in DB)
  • chatbot_templates.suggested_system_prompt (Template-Default)
  • Repo-Default prompts/system_{de,en}.j2

Pre-Discovery-Drift: Karte forderte Migration mit system_prompt+system_prompt_override-Feldern. DB hat aber bereits chatbot_templates.suggested_system_prompt und chatbots.system_prompt — Service nutzt die existierenden Felder, keine Migration. 6/6 Unit-Tests.

  • services/source_extractor.py — Mappt reranked Chunks auf Source-Pydantic-Objekte (chunk_id, document_id, source_title, source_type, source_uri, access_level, content_snippet, rerank_score, retrieval_score). Snippet-Trimming auf Wortgrenze + Ellipsis. 5/5 Unit-Tests.

  • services/context_truncator.py — Trim-Pattern aus avs portiert (avs_chatbot/api/routes/query.py:_truncate_context). Drop-tail bis unter max_chars-Budget; nie Mid-Chunk-Cut. Default 16000 chars (Qwen3-14B-AWQ-16k-Context). 5/5 Unit-Tests.

Default-Templates in src/kora_platform/prompts/system_{de,en}.j2 — tenant-agnostisch, keine AVS- oder Hotel-Brand-Referenzen, mit {{ sources }} und {{ question }} Jinja-Variablen.

QueryService

services/query_service.py orchestriert die volle Pipeline (Stage 1-11). Process-weit instanziert in main.py-Lifespan; request-scoped Sub-Services (PlatformRetrieval, ChatHistoryService) baut query() selbst aus der durchgereichten db_session.

api/dependencies/query.py als FastAPI-DI-Wrapper analog embedder.get_embedder_client.

End-to-End-Smoke (lokal grün)

scripts/smoke_k3b_2_query_service.py baut Test-Tenant + Chatbot, lädt 3 Hotel-Meldeschein-Chunks via DocumentUploader, fragt zweimal "Wie funktioniert der Hotel-Meldeschein?":

  • Erste Query (fresh): cached=False, 3 sources, deutsche Antwort mit [1]-Citations, total 1796ms (LLM 1570ms, Retrieval 71ms, Rerank 17ms, Cache-Lookup 46ms).
  • Zweite Query (cache): cached=True, similarity=1.0, identische Antwort.

Tests

  • 39/39 neue Unit-Tests (5 Komponenten)
  • 51/51 alle K-3-Tests grün (incl. K-2c, K-3a, K-3b-1)
  • End-to-End-Smoke verifiziert

Schema-Drift-Note

Die K-3b-2-Karte forderte ursprünglich eine Alembic-Migration mit neuen Spalten chatbot_templates.system_prompt und chatbots.system_prompt_override. Pre-Discovery hat ergeben, dass das DB-Schema diese Resolution-Hierarchie bereits unter anderen Feldnamen modelliert (suggested_system_prompt und system_prompt). Keine Migration erstelltPromptTemplateService nutzt die existierenden Felder. Karte wurde vor Implementation entsprechend angepasst.


K-3b-1 — Reranker-Service als Standalone-Container (User-facing Query-Pipeline, Sub-Welle 2, ⏳ Branch offen)

Branch: platform/k3b-1-reranker-service (von platform/v1.0.0-HEAD 4ec8451) Status: Sub-Steps 0–7 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Strategischer Kontext

Zweite Sub-Welle der User-facing Query-Pipeline (nach K-3a vLLM). Der Reranker ist die zweite teure GPU-Komponente nach dem Embedder; statt ihn — wie in avs-Demo Block 19 — in den FastAPI-Prozess zu laden, läuft er hier als eigener HTTP-Service. Spiegelt das Embedder-Pattern (FastAPI-Lifespan, GPU 1 via NVIDIA_VISIBLE_DEVICES=1, Modell-Bake im Build, Healthcheck via curl).

K-3b-1 stellt die Service-Ebene bereit; K-3b-2 verdrahtet RerankerClient in den Retrieval-Orchestrator (QueryService).

Added

  • infra/reranker/ — neuer Cross-Encoder-Service:

    • Dockerfile (basiert auf pytorch/pytorch:2.10.0-cuda12.6-cudnn9-runtime, analog infra/embedder/Dockerfile).
    • app.py — FastAPI mit POST /rerank ({query, documents}{scores, model, device}), Healthcheck mit device-Reflection.
    • Modell cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 (multilingual DE/EN, ~120 MB Gewichte, ~600 MB GPU-Working-Set in fp16) im Build eingebacken; kein erster HuggingFace-Roundtrip beim ersten Request.
    • _MAX_BATCH=64 (Cross-Encoder ist günstiger als BGE-M3 — höheres Limit unkritisch). avs-Demo reranked typisch 10-20 Kandidaten.
    • RERANKER_DEVICE-Env-Override für CPU-Fallback (Build-Hosts ohne GPU, erzwungene CPU-Smokes); Production-Default ist cuda:0.
  • docker-compose.platform.yml — neuer Service reranker:

    • GPU-Slot 1 (gleiche physische GPU wie Embedder; ~22 GB frei, BGE-M3 + Cross-Encoder zusammen unter 4 GB Working-Set).
    • kora-platform-net (interne-only Exposition, kein Host-Port).
    • api-Service mit zusätzlicher depends_on: reranker {healthy}.
    • Neue Env-Vars KORA_RERANKER_URL (default http://reranker:8091) und KORA_RERANKER_TIMEOUT_SECONDS (default 30s).
  • src/kora_platform/services/reranker_client.pyRerankerClient:

    • httpx.AsyncClient einmal pro Prozess (analog EmbedderClient).
    • rerank(query, documents) -> list[float] — Scores parallel zur Eingabe-Reihenfolge; Sortier-/Top-K-Logik bleibt Caller (K-3b-2).
    • Empty-Documents-Shortcut (kein HTTP-Roundtrip, leere Score-Liste).
    • raise_for_status()-Bubble-Up — kein silent-swallow von Service-Fehlern.
  • src/kora_platform/api/dependencies/reranker.pyget_reranker_client(request) -> RerankerClient (DI aus app.state, analog get_embedder_client).

  • src/kora_platform/main.py — Lifespan-Init plus Shutdown-Close des RerankerClient.

  • src/kora_platform/config.pyKoraSettings.reranker_url, reranker_timeout_seconds.

  • tests/unit/test_reranker_client.py — 3 Unit-Tests via httpx.MockTransport (Request-Format, Empty-Shortcut, Error-Bubble-Up).

Smokes (lokal grün)

  • GET /health{status: ok, device: cuda:0, model: cross-encoder/...}
  • POST /rerank mit 5 Hotel-Meldeschein-Dokumenten — semantisches Ranking korrekt (relevantes Doc score 3.06, irrelevantes Doc -8.34).
  • DNS-Resolution reranker:8091 aus Oneshot-Container im kora-platform-net — Cross-Lingual-Sanity (Reisepass-Frage) klappt.
  • Regression: 12/12 Unit-Tests in test_response_cache.py + test_llm_client.py + test_reranker_client.py weiterhin grün.

avs-Decommissioning Phase 1 — vLLM in standalone Stack extrahiert (⏳ Branch offen)

Branch: platform/avs-decommission-phase1-vllm (von platform/v1.0.0-HEAD 6d90569) Status: Sub-Steps 0–11 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Strategischer Kontext

Erste von vier Phasen der avs-chatbot-Decommissioning-Sequenz:

  1. vLLM-Extraktion (diese Karte) — eliminiert die K-3a-induzierte DNS-Kollision auf 4 Service-Names, vllm bleibt verfügbar
  2. K-3b + K-3c — kora bekommt Frage→Antwort-Fähigkeit
  3. AVS-Tenant in kora provisionieren + Smoke-Vergleich gegen Demo
  4. avs-Stack abschalten

K-3a-Hotfix-Karte (DNS-Collision-Fix via Container-Names) wird mit dieser Karte obsolet — nicht ausgeführt.

Live-Demo-Auswirkung (akzeptiert): demo.avs.luki-net.org/api/v1/query liefert ab sofort HTTP 500: Connection error — der avs-Generator-Pfad findet kein vllm-lb mehr im avs-net. Frontend/Health-Endpoints bleiben funktional. Demo wird in Phase 4 abgeschaltet/umgeroutet.

Added

  • docker-compose.vllm.yml — neuer standalone vLLM-Stack:
    • vllm (vllm/vllm-openai:latest, container_name vllm, GPU device_id "0", Qwen3-14B-AWQ, max_model_len 16384, gpu_memory_utilization 0.95, kv-cache fp8_e5m2, prefix-caching, max_num_seqs 16) — 1:1 portiert von avs.
    • vllm-lb (nginx:alpine, container_name vllm-lb, least_conn zwischen lokalem vllm:8000 und Remote-5090 via ${VLLM_5090_HOST}) — nginx.conf unverändert wiederverwendet (infra/nginx-vllm-lb/nginx.conf).
    • Network vllm-net (eigenständig). Konsumenten joinen als external network.
    • Volume model_cache referenziert externes avs-model-cache (~30 GB HuggingFace-Cache aus avs-Stack) — kein Re-Download.

Changed

  • docker-compose.platform.ymlapi-Service joint vllm-net (statt K-3a-avs-net); vllm-net als external: true deklariert. K-3a-Kommentare ersetzt durch Decommissioning-Phase-1-Kontext.
  • docker-compose.yml (avs)services.vllm und services.vllm-lb entfernt; avs-api-depends_on für vllm und vllm-lb entfernt. Volume model_cache bleibt definiert (avs-api mountet es weiterhin auf /root/.cache/huggingface, geteilt mit dem standalone vllm-Stack über die externe Volume-Referenz).

Verified

  • Topologie-Audit: vllm-Service-Definition + vllm-lb-Service + nginx.conf + GPU-Slot 0 + avs-net-Member-Liste dokumentiert.
  • vllm-Stack hochgefahren: docker compose -p vllm up -d → Modell-Load 9.28s, CUDA-Graphs gecaptured, /v1/models antwortet mit Qwen3-14B-AWQ.
  • kora-API-Networks: nach make redeploy-platform-apikora-platform-net + vllm-net, NICHT mehr avs-net.
  • DNS-Inventur post-Decom:
Service IP Network
postgres 172.23.0.11 kora-platform-net ✓
redis 172.23.0.3 kora-platform-net ✓
qdrant 172.23.0.9 kora-platform-net ✓
keycloak 172.23.0.4 kora-platform-net ✓
embedder 172.23.0.2 kora-platform-net ✓
vllm-lb 172.24.0.3 vllm-net ✓

Alle 5 kora-Services lösen wieder eindeutig in kora-platform-net auf — K-3a-DNS-Kollision strukturell beseitigt. - /health/ready post-Decom: database=ok, redis=ok, keycloak=ok — kein "unavailable" mehr. - LLM-Smoke aus kora: chat_completion("Hauptstadt von Bayern?") → "Die Hauptstadt von Bayern ist München." (9 Completion-Tokens), Inferenz funktioniert über den standalone vllm-Stack. - avs-Demo controlled break: POST https://demo.avs.luki-net.org/api/v1/queryHTTP 500: {"detail":"Query processing failed: Connection error."} (gewollt — Demo wird in Phase 4 endgültig). - Regression: 110/111 Unit-Tests grün (1 skipped, kein neuer Fail).

Out-of-Scope (folgt in Phase 2/3/4)

  • K-3b — Query-Orchestration (Retrieval + Reranker + Generator + Cache)
  • K-3c — User-facing /query-Route mit Auth + Streaming
  • AVS-Tenant in kora provisionieren + Smoke-Vergleich
  • avs-Stack-Abschaltung + DNS-Umroutung von demo.avs.luki-net.org

K-3a — vLLM-Anbindung in kora-Stack via shared vllm-lb (User-facing Query-Pipeline, Sub-Welle 1, ⏳ Branch offen)

Branch: platform/k3a-vllm-integration (von platform/v1.0.0-HEAD bba6254) Status: Sub-Steps 1–8 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Architektur-Entscheidung

Shared vLLM mit Demo-Stack — kein eigener vLLM-Service für kora. Das LLM (Qwen3-14B-AWQ) ist tenant-agnostisch und der vllm-lb balanciert bereits auf zwei GPU-Nodes (luki-ai GPU 0 + RTX 5090 Remote-Node). Network-Wiring: kora-api joint zusätzlich avs-net (als external network), erreicht den Service-Namen vllm-lb via Docker-DNS.

Begründung gegenüber Karten-Empfehlung Option A: avs-Compose bleibt unangetastet. avs ist Wartungsmodus; einseitige Edits auf kora sind robuster gegen Drift. Der Platzhalter-Hint in docker-compose.platform.yml (ab Zeile 333 in der vorherigen Version) hatte das Pattern bereits dokumentiert — wir folgen ihm.

Added

  • KoraSettings.vllm_* in src/kora_platform/config.py:
    • vllm_base_url: str = "http://vllm-lb:8000/v1"
    • vllm_model: str = "Qwen/Qwen3-14B-AWQ"
    • vllm_timeout_seconds: float = 120.0
    • vllm_default_temperature: float = 0.3
    • vllm_enable_thinking: bool = False
  • src/kora_platform/services/llm_client.py — neuer Service-Singleton mit OpenAI-kompatiblen HTTP-Calls gegen /v1/chat/completions:
    • chat_completion(messages, *, temperature, max_tokens, extra_body, enable_thinking) — non-streaming, returnt das volle OpenAI-Response-Dict.
    • chat_completion_stream(...) — async-iter über parsed SSE-Delta-Dicts; [DONE]-Sentinel beendet sauber, einzelne Parse-Errors werden mit Warn-Log übersprungen.
    • Thinking-Mode disabled per Defaultchat_template_kwargs.enable_thinking=False wird automatisch ins Payload injiziert; Caller-Override gewinnt.
    • build_llm_client(settings)-Factory analog zum build_embedder_client-Pattern.
  • src/kora_platform/api/dependencies/llm.py — DI-Wrapper get_llm_client(request) analog zu get_embedder_client.
  • src/kora_platform/main.py — Lifespan-Init baut den Client, legt ihn in app.state.llm_client ab und schließt ihn beim Shutdown via aclose().
  • docker-compose.platform.yml:
    • api-Service joint zusätzlich avs-net (ergänzt zu kora-platform-net).
    • Neue env-Vars: KORA_VLLM_BASE_URL, KORA_VLLM_MODEL, KORA_VLLM_TIMEOUT_SECONDS, KORA_VLLM_DEFAULT_TEMPERATURE.
    • networks:-Section: avs-net als external: true.
    • Platzhalter-Hint-Kommentar entfernt (jetzt umgesetzt).
  • tests/unit/test_llm_client.py mit 4 Tests (httpx MockTransport):
    • test_chat_completion_sends_correct_request — Body-Format, chat_template_kwargs.enable_thinking=False-Injection.
    • test_chat_completion_returns_parsed_response — Response wird 1:1 weitergereicht.
    • test_chat_completion_stream_yields_deltas — SSE-Mock-Stream mit data: {...} + data: [DONE] → 2 Deltas yielded.
    • test_chat_completion_caller_can_override_thinking — Caller-enable_thinking=True gewinnt über Default.

Verified (Sub-Step 5 Container-Smoke)

kora-platform-api läuft mit zweitem Network-Attachment in avs-net (docker network inspect avs-net listet kora-platform-api neben avs-vllm-lb).

  • Smoke 1 — GET /v1/models: PASS — Response listet Qwen/Qwen3-14B-AWQ mit max_model_len=16384.
  • Smoke 2 — Non-streaming Chat: PASS — "Was ist die Hauptstadt von Bayern?" → "Die Hauptstadt von Bayern ist München.", 9 Completion-Tokens, kein <think>-Noise.
  • Smoke 3 — Streaming Chat: PASS — "Zähle 1 bis 5"-Prompt produziert 9 SSE-Token-Chunks, finaler Text "1,2,3,4,5".

110/111 Unit-Tests grün (4 K-3a llm_client + 5 K-2c cache + 5 K-2b chunker + bestehende Suite; 1 skipped).

Out-of-Scope (folgt in K-3b/c)

  • Query-Orchestration (Retrieval + Reranker + Generator + Cache) → K-3b
  • User-facing /query-Route + Auth + Streaming-Response → K-3c
  • Health-Probe für vllm-lb (/health-Endpoint im API) → K-3c

K-2c — Cache-Layer mit Tenant-Namespace + Cross-Lingual-Schutz (Pipeline-Schicht-K Read-Seite, Sub-Welle 3, ⏳ Branch offen)

Branch: platform/k2c-cache-layer (von platform/v1.0.0-HEAD 3cb74fe) Status: Sub-Steps 1–6 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Added

  • src/kora_platform/services/response_cache.py — neuer Service-Singleton für embedding-basierten Semantic-Cache.
    • Wire-Schema: kora:scache:{tenant_id}:{chatbot_id}:{language}:index (Redis LIST, LPUSH=newest first) + kora:scache:{tenant_id}:{chatbot_id}:{language}:entry:{id} (JSON {question, response, embedding, created_at, hit_count}, TTL via setex).
    • ctor-Signatur: redis_client, embedder_client, ttl_seconds=86400, similarity_threshold=0.92, max_entries_per_chatbot=500. Alle keyword-only. Cache hält Embedder-Client als State (statt Embedding-Passing durch den Caller wie in der avs-Variante).
    • Methoden: get(tenant_id, chatbot_id, language, question), set(tenant_id, chatbot_id, language, question, response), invalidate_chatbot(tenant_id, chatbot_id) über alle Sprachen, close().
    • Cosine-Similarity: numpy-basiert mit Zero-Denominator-Guard.
    • LRU-Eviction: RPOP-oldest sobald LLEN > max_entries_per_chatbot.
    • Prometheus-Metriken: kora_response_cache_hits_total, _misses_total, _lookup_seconds, _best_similarity, _entries{tenant_id,chatbot_id,language}.
  • tests/unit/test_response_cache.py mit 5 Tests gegen einen in-memory _InMemoryRedis-Stub (lpush/lrange/llen/rpop/lrem/get/ setex/delete/scan_iter/aclose) und einem _StubEmbedder (Question→Vector-Map plus optionaler cross_lingual_collide-Modus zur Worst-Case-Simulation des Block-19-Bugs):
    • test_cache_isolates_tenants — Tenant A Cache nicht von Tenant B getroffen, auch bei identischen Embedder-Vektoren.
    • test_cache_isolates_languages — DE-Eintrag nicht von EN-Query getroffen, auch wenn Embedder DE/EN-Pairs auf identischen Vektor mappt (Block-19-Worst-Case).
    • test_cache_invalidation_per_chatbotinvalidate_chatbot räumt alle Sprachen eines Chatbots, nicht aber andere Chatbots.
    • test_cache_similarity_threshold — A↔A_similar (cos≈0.998) → Hit, A↔B (cos=0) → Miss bei Threshold 0.9.
    • test_cache_eviction_respects_max_entries — bei max_entries=2 und 3 Inserts ist der älteste weg, die letzten beiden bleiben.

Architektur

  • Cross-Lingual-Schutz strukturell, nicht zur Laufzeit. language ist Teil des Index-Keys → der _list_entry_ids-Call sieht nur Einträge der angefragten Sprache. Kein zweiter Filter im Similarity-Check. Adressiert Block-19-Bug-Pattern (multilingual- Embeddings DE/EN-Pairs cosine ≈ 1.0, avs commit 3b70a9e).
  • Multi-Tenant-Isolation strukturell. Namespace umschließt tenant_id+chatbot_id; Tenant A sieht Tenant B's Keys nicht einmal beim SCAN (_entry_match-Pattern ist tenant-präfixiert). Drei-Schichten-Defense aus services/retrieval.py (Collection- Naming, Service-Guard, DB-RLS) gilt zusätzlich auf Cache-Schema- Ebene.
  • Embedder-Lifecycle DI-kontrolliert. close() schließt nur die Redis-Verbindung; der Embedder wird vom Lifespan-Owner (FastAPI app.state) geschlossen.
  • Cache-Outage darf keine User-facing Errors erzeugen. get/set fangen alle Exceptions; bei Embedder/Redis-Fehler → silent miss
  • warn-Log. Cache ist Performance-Optimierung, nicht Correctness-Pfad.

Verified (Sub-Step 5 Container-Smoke)

Container-Smoke gegen echten BGE-M3-Embedder (kora-platform-embedder) und kora-platform-redis:

  • Test 1 (Tenant-Isolation): PASS — t1-Eintrag, t2-Query → Miss.
  • Test 2 (Cross-Lingual-Schutz): PASS — DE-Eintrag, EN-Query → Miss. BGE-M3 produziert für DE/EN-Pairs cosine nahe 1.0; ohne Sprach- Namespace wäre das ein False-Positive.
  • Test 3 (Semantic-Hit gleicher Sprache): Hit bei Cosine 0.921 ("Wie geht der Hotel-Meldeschein?" → "Wie funktioniert der Meldeschein im Hotel?", Threshold 0.92).

106/107 Unit-Tests grün (5 K-2c cache + 5 K-2b chunker + 6 chunks_indexed_metric + 11 document_uploader + 12 qdrant_manager + 14 retrieval + 9 docling_converter + 22 semantic_chunker_page_tracking + 22 semantic_chunker_preserve_tables; 1 skipped).

Out-of-Scope (folgt in K-3)

  • Cache-Verdrahtung in eine User-facing /query-Route → K-3
  • Cache-Stats-Endpoint für Operator-UI → spätere Karte
  • Generation-Stage-Anbindung (Antwort wird gespeichert) → K-3

K-2a — PlatformRetrieval auf Hybrid-RRF (Pipeline-Schicht-K Read-Seite, Sub-Welle 1, ⏳ Branch offen)

Branch: platform/k2a-retrieval-hybrid (von platform/v1.0.0-HEAD 8c30e4e) Status: Sub-Steps 1–7 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Added

  • _to_qdrant_sparse-Adapter in retrieval.py (Spiegelung des Pendants in document_uploader.py): konvertiert EmbedderClient.SparseVectorqdrant_client.models.SparseVector für die Read-Seite. Beide SparseVector-Klassen tragen identische Felder (indices/values); separate Namespaces sind Absicht (Embedder-Service hängt nicht an qdrant_client als Dependency).
  • _PREFETCH_OVERFETCH = 2-Konstante für die RRF-Lane-Limits. Über-Fetch-Faktor 2 ist bewährter Default; jede Prefetch-Lane zieht top_k * 2 Kandidaten, damit die Server-side Fusion genug Material zum Re-Ranken hat.
  • test_search_uses_hybrid_rrf_prefetch Wire-Format-Test (analog zu K-1c test_upload_writes_dual_vector_named_struct): verifiziert query_points-Aufruf hat FusionQuery(fusion=Fusion.RRF), zwei Prefetch-Lanes mit using=DENSE_VECTOR_NAME/SPARSE_VECTOR_NAME, Lane-Limits 10 (=top_k×2), final-limit 5, with_payload=True.

Changed

  • PlatformRetrieval.search ruft embed_query_hybrid statt embed_query (BGE-M3 dense + sparse aus einem Forward-Pass).
  • _safe_search-Signatur erweitert: query_dense: list[float]
  • query_sparse: QdrantSparseVector statt query_vec: list[float]. Body führt jetzt server-side RRF-Fusion via Prefetch+FusionQuery durch.
  • mock_embedder-Fixture in test_retrieval.py auf embed_query_hybrid umgestellt; Mock-Sparse-Vektor mit deterministischen Indices [3, 17, 42] für Wire-Format-Assertions.

Architektur

  • Fan-Out Chatbot+Shared-Logik strukturell unverändert. Cross- Tenant-Guard, Template-Gate (_template_allows_shared), Collection-Not-Found-Tolerance bleiben 1:1.
  • Score-Merge nach Fan-Out: RRF-Scores aus Qdrant (1 / (60 + rank)-Summe pro Lane) sind innerhalb derselben Query zwischen Chatbot- und Shared-Collection vergleichbar; das bestehende sorted(...) + [:top_k] bleibt korrekt.

Verified (Sub-Step 6 End-to-End-Smoke)

Test-Tenant + Test-Chatbot + 3 Chunks via DocumentUploader (K-1c) mit semantisch distinkten Inhalten (Hotel-Meldeschein, Software/Git, Kurverwaltung/Kurtaxe):

Query Sprache Top-1 (RRF score) Erwartung
„Wie funktioniert der Meldeschein im Hotel?" DE Chunk 1 (1.0000) Hotel ✓
„What is tourism tax accounting?" EN Chunk 3 (0.5000) Kurverwaltung/Tourismus ✓ (cross-lingual)
„git code review software development" EN Chunk 2 (0.5000) Software/Git ✓
  • 37/37 Unit-Tests grün (14 retrieval + 11 uploader + 12 qdrant_manager).
  • Health-Endpoints /health/live + /health/ready post-Smoke grün.

Out-of-Scope (folgt in K-2b/c und K-3)

  • Pipeline-Komponenten (DoclingConverter, SemanticChunker) → K-2b
  • Cache-Layer für Hybrid-Queries → K-2c
  • User-facing /query-Route → K-3

K-2b — DoclingConverter + SemanticChunker portieren (Pipeline-Schicht-K Sub-Welle 2, ⏳ Branch offen)

Branch: platform/k2b-components-port (von platform/v1.0.0-HEAD 06f1ff9) Status: Sub-Steps 1–8 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Added

  • src/kora_platform/pipelines/ — neuer Sub-Package-Pfad, parallel zu src/avs_chatbot/pipelines/. __init__.py dokumentiert die Konsumenten (Indexing-Pfad via DocumentUploader, Retrieval-Pfad via PlatformRetrieval) und die Tenant-Konfigurierbarkeit.
  • pipelines/components/docling_converter.py (Klasse DoclingConverter, vorher DoclingPDFConverter in avs):
    • Wrapper um docling-haystack's DoclingConverter im MARKDOWN-Mode mit image_placeholder="" + page_break_placeholder=PAGE_BREAK_PLACEHOLDER.
    • Output-Contract identisch zu avs (file_path, source_id, source_type="pdf", docling_version, has_tables).
    • Fail-Loud bei Docling-Errors (kein PyPDF-Fallback).
    • Wire-Contract-Konstante PAGE_BREAK_PLACEHOLDER = "\x02KORA_PAGE_BREAK\x02" — generischer KORA-Namespace statt AVS.
  • pipelines/components/semantic_chunker.py (Klasse SemanticChunker, generischer Markdown-PDF-Chunker):
    • Drei neue ctor-Parameter machen ehemals AVS-spezifische Heuristiken Tenant-konfigurierbar:
      • footer_regex: str | None — optionaler Regex zum Footer-Strip (Default None → kein Strip).
      • category_map: dict[str, str] | None — Pfad-Substring → Category-Label; Default {} → Fallback auf Path(file_path).stem.
      • sentinel_namespace: str = "KORA" — Prefix für interne Tabellen- und Page-Sentinels; Tenant-Override beeinflusst nur die nummerierten Sentinels, nicht den PAGE_BREAK_PLACEHOLDER-Wire-Contract.
    • Per-Instance kompilierte Sentinel-Patterns (_table_sentinel_fmt, _re_table_sentinel, _page_sentinel_fmt, _re_page_sentinel, _re_page_sentinel_strip).
    • AVS-gebundene Helfer auf Klassen-Methoden migriert: _detect_category (vorher _detect_handbook_name), _clean_page_text, _protect_table_blocks, _restore_table_blocks, _number_page_break_sentinels, _strip_page_sentinels, _extract_page_numbers, _extract_sections.
    • Namespace-unabhängige Helfer bleiben Modul-Funktionen (_word_count, _split_at_sentence, _is_toc_entry, _strip_chapter_prefix, _is_table_only, _is_heading_only, _is_figure_caption_only, _classify_chunk_type, _deduplicate_header, _sections_to_chunks).
    • Prometheus-Counter kora_chunks_indexed_total (vorher avs_indexed_chunks_total).
  • pipelines/components/__init__.py exportiert DoclingConverter
  • SemanticChunker.
  • tests/unit/test_semantic_chunker.py mit 5 Tests für die K-2b- Surface:
    • test_chunker_without_footer_regex_keeps_footer_text — Default-Verhalten lässt Footer intakt.
    • test_chunker_with_custom_footer_regex_strips_match — konfigurierter Regex strippt; umliegender Text bleibt erhalten.
    • test_chunker_category_map_with_match — erstes passendes Needle gewinnt ("Maschine""Maschinen-Handbuch").
    • test_chunker_category_map_falls_back_to_filename_stem — kein Match → Path(file_path).stem.
    • test_chunker_sentinel_namespace_default_is_kora — Whitebox- Check der Per-Instance-Sentinel-Formate + _re_page_sentinel- Match auf \x02KORA_PAGE_3\x02.

Changed

  • Dead-Code entfernt im Page-Number-Pfad: Der avs-Chunker hatte einen Legacy-Fallback auf "Seite X von Y"-Footer-Page-Number-Extraction für die alte EnhancedPDFConverter-Pipeline. Im kora_platform existiert diese Pipeline nicht (Docling ist canonical seit Block 18); der Fallback wurde nicht parametrisiert sondern weggelassen.

Architektur

  • Wire-Contract zwischen Converter und Chunker bleibt KORA-Namespace. Der PAGE_BREAK_PLACEHOLDER wird vom DoclingConverter als page_break_placeholder-kwarg in md_export_kwargs injiziert und vom Chunker via RE_PAGE_BREAK_PLACEHOLDER erkannt. Beide Default- Konstanten sind KORA-prefixed; Tenant-sentinel_namespace-Overrides betreffen ausschließlich die nummerierten Sentinels (intern, nie Embedder-/User-sichtbar).
  • Parent-Document-Meta-Propagation unverändert: _PARENT_META_PROPAGATE_KEYS = ("source_type", "docling_version"); beide Keys landen auf jedem Chunk.
  • avs_chatbot.pipelines.components bleibt unverändert. Der legacy-Demo-Stack indexiert weiter mit DoclingPDFConverter + Original-SemanticChunker (AVS-Footer + AVS-Category-Map hardcoded); kein Cross-Import zwischen den Sub-Packages.

Verified

  • Container-Smoke (make redeploy-platform-api → API healthy → docker exec):
    • Default-Chunker auf "Maschine-Handbuch.pdf" ohne Footer-Regex → Handbuch: Maschine-Handbuch-Prefix, Footer-Text bleibt.
    • AVS-Style-Chunker (footer_regex=Doku-Version:.*Seite \d+ von \d+, category_map={"Maschine": "AVS Maschinen-Handbuch"}, sentinel_namespace="AVS") auf gleichem Doc → Handbuch: AVS Maschinen-Handbuch-Prefix, Footer entfernt.
    • DoclingConverter() instanzierbar (docling extras im Image).
  • 101/101 Unit-Tests grün (5 K-2b chunker + 6 chunks_indexed_metric + 11 document_uploader + 12 qdrant_manager + 14 retrieval + 9 docling_converter + 22 semantic_chunker_page_tracking + 22 semantic_chunker_preserve_tables; 1 skipped).

Out-of-Scope (folgt in K-2c/K-3)

  • Cache-Layer für Hybrid-Queries → K-2c
  • Tenant-Config-Injection (Footer-Regex + Category-Map aus DB) → K-3
  • Indexing-Pipeline-YAML, das Converter+Chunker zusammenbaut → K-3

Block 19 Phase 4 — Production-Cutover (BGE-M3 Hybrid live, ✅ Erledigt)

Live-Demo wechselt von Legacy (e5-large + Dense-only) auf Hybrid (BGE-M3 + RRF Dense+Sparse). Branch: feature/block-19-bge-m3-embedder (gleich wie Phase 1–3.5) Cutover-Datum: 2026-05-03

Default-Flips in src/avs_chatbot/config.py

Setting Alter Default Neuer Default Begründung
use_hybrid_retrieval False True Block-19 ist Production-Default; Phase-3-Eval zeigt R@5 0.92→0.96
qdrant_collection "avs_handbuecher" "avs_handbuecher_bge_m3" Hybrid-fähige Collection (242 Chunks, 100% Sparse-Population)
embedding_model_dense "BAAI/bge-m3" unverändert Phase-3-bestätigter Default
rrf_dense_weight 0.7 unverändert Phase-3.5-Sweep zeigt: kein anderer Wert ist besser (Sweep über 0.7/0.8/0.9/1.0 identische Metriken)
embedding_model (legacy) "intfloat/multilingual-e5-large" unverändert Legacy-Pfad bleibt für Rollback intakt

Cutover-Sequenz

  1. Push Recovery-Point (Sub-Step 0): feature/block-19-bge-m3-embedder zu Remote, HEAD a3c70ae (Phase 3) gepusht.
  2. Snapshot Legacy-Collection (Sub-Step 1): avs_handbuecher (242 points, dense-only) als backups/qdrant/avs_handbuecher_pre_block19_cutover_20260503-1150.snapshot (4.2 MB, gitignored, 30+ Tage Aufbewahrung).
  3. config.py-Default-Flips (Sub-Step 2): zwei Settings, ruff + mypy clean.
  4. .env-Switch (Sub-Step 3): idempotenter Sed-Edit + grep-q-Append, .env.block19_backup unverändert. Diff zeigt nur die zwei erwarteten Änderungen.
  5. Redeploy + Live-Verify (Sub-Step 4): make redeploymake smoke 8/8 grün → make smoke-hybrid grün → frische Live-Cache-bypass-Query (cached=false, 5 sources, kohärente Antwort) → 4 weitere Live-Queries fehlerfrei → API-Logs ohne Errors/Exceptions (nur erwartete Warnings: TransformersSimilarityRanker is considered legacy + authlib deprecation).
  6. Observation-Setup (Sub-Step 5): TODOs in offene-todos.md für 7-Tage-Window mit Grafana-Panel-Alarmen.

Aggregate-Wins gegenüber Block 18 (Phase-3-Eval, 25 Test-Queries)

  • R@5: 0.92 → 0.96 (+0.04)
  • R@10: 0.92 → 0.96 (+0.04)
  • avg_latency: 155 ms → 140 ms (−15 ms, BGE-M3-Single-Pass-Effekt)
  • Tabellen-Queries (q021–q025): R@5 = 1.00 (no regression)
  • 0 regressed Queries (Hit@5 down)

MRR-Disclaimer (transparent dokumentiert)

Block-19-MRR (0.74) liegt 0.03 unter Block-18-Baseline (0.77). Ursache ist q006 alleine (RR 1.00 → 0.25, Hit@5 bleibt True). q006 hat duplizierten Inhalt in zwei Handbüchern; der Cross-Encoder-Reranker bevorzugt Kurverwaltung-Chunks vor Beherbergungsbetrieb-Chunks. Block-18-Top-1-Position war e5-large-Embedding-Glücksfall, kein systematisches BGE-M3-Defizit. Phase-3.5-RRF-Weight-Sweep über {0.7, 0.8, 0.9, 1.0} zeigt: keine Weight-Config bringt q006 zurück — es ist Reranker-Bias, nicht RRF-Bug. User-facing-Impact: 0 (relevant doc weiter in Top-5). Reranker-Mitigation ist Block 19.5 (bge-reranker-v2-m3-Upgrade, konsistenter BGE-Stack).

Live-Verify-Datapoints

Query cached latency sources Top-1 file
„Wie kann ich eine Begleitperson nachträglich aus dem Meldeschein entfernen?" (frisch) False 3000 ms 5 Kurverwaltung.pdf p86, score 0.83
„Was ist ein Meldeschein?" (Phase-3-Cache-Carryover) True 95 ms 5 semantic-cache hit
„Welche Felder sind beim Self-Check-In Pflicht?" (frisch) False 1931 ms 5 Top-1 ranker score in Range
„Wie storniere ich eine Reservierung?" (frisch) False 3488 ms 5 Top-1 ranker score in Range

Rollback-Plan

cp .env.block19_backup .env && make redeploy

config.py-Defaults auf Hybrid bleiben bestehen — .env overrides die Defaults, daher reicht .env-Restore für sofort-Rollback. Snapshot in backups/qdrant/ für Recovery bei tieferem Issue (Collection-Korruption etc.).

7-Tage-Observation-Window

Start: 2026-05-03. Tag-3-Mid-Window (2026-05-06) und Tag-7-End-Window (2026-05-10) als Checkpoints. Bei grünem Tag-7: - Phase 5 (Edge-Case-Queries q026–q035) als nächste Roadmap-Karte. - Alte Collection avs_handbuecher löschen (TODO-Block-19-Cleanup, blocked-bis-Tag-7). - Snapshot in backups/qdrant/ weitere 30 Tage behalten (defense in depth).

Phase-4-Stop-Trigger-Status

Alle 7 Sub-Steps (Push, Snapshot, Defaults, .env, Redeploy+Verify, Observation-Setup, Commit) — alle Stop-Trigger negativ.

  • 0.1 (Push fehlschlägt): negativ — Push erfolgreich.
  • 0.2 (Force-Push nötig): negativ — Fast-Forward.
  • 1.1 (Snapshot fehlschlägt): negativ.
  • 1.2 (Snapshot < 1 MB): negativ — 4.2 MB.
  • 2.1 (Settings nicht gefunden): negativ.
  • 3.1 (.env-Diff zeigt unerwartete Änderungen): negativ — exakt 2 Änderungen.
  • 4.1 (smoke red): negativ.
  • 4.2 (Live-Query 500/Timeout): negativ.
  • 4.3 (5-min-Errors): negativ.
  • 4.4 (cached=true bei frischer Query): negativ — frische Queries return cached=false.
  • 5.1 (Demo 502/503): negativ — HTTP 401 (Basic-Auth, normal).
  • 6.1 (smoke nach Commit rot): wird nach Commit final verifiziert.

Phase-5-Vorbedingung

  • Tag-7-Observation grün (2026-05-10)
  • Phase-5-Prompt mit q026–q035 Edge-Case-Queries (Paragraphen, Codes, Akronyme, Compounds)

Block 19 Phase 3 — Real-Stack-Quality-Vergleich (BGE-M3 Hybrid vs e5-large Dense, ✅ Erledigt)

Outcome: B (Mixed) — klarer Hit@5/Latenz-Win, MRR knapp daneben (–0.03). Branch: feature/block-19-bge-m3-embedder (gleich wie Phase 1/2/Followup)

Run-Methodik

  • Per-Subprocess-In-Process-Eval mit docker compose exec -e USE_HYBRID_RETRIEVAL=true -e QDRANT_COLLECTION=avs_handbuecher_bge_m3 api python /tmp/phase3_eval.py. Live-API blieb während Eval im Legacy-Modus (Demo unbetroffen).
  • Drift gegenüber Phase-3-Plan: Existierender Eval-Runner tests/evaluation/evaluate_retrieval.py ist HTTP-basiert und ruft die Live-API — per-subprocess-env-Override hätte daran nichts geändert. tests/-Verzeichnis ist auch nicht in den API-Container gemountet. Pragmatischer Workaround: ad-hoc Inline-Eval-Skript via docker cp in Container, In-Process-Pipeline-Aufruf wie make smoke-hybrid, Skalierung auf 25 Fragen + Ranker + Hit@K + MRR. Output-Schema bewusst identisch zu eval_retrieval_block18_v2.json. Skript als /tmp/phase3_eval.py ad-hoc, NICHT in tests/evaluation/ committed (gehört in einen Phase-5-Tooling-Refactor wie evaluate_retrieval_inprocess.py, nicht in Phase 3).
  • A/B-Setup: Beide Runs gegen 25 Test-Questions (q001q025, davon q021q025 Tabellen). Identische retriever_top_k=20, ranker_top_k=5, product_id=meldeschein. Block-18: e5-large + Dense-only gegen avs_handbuecher. Block-19: BGE-M3 + Hybrid (RRF Dense:Sparse 0.7:0.3) gegen avs_handbuecher_bge_m3.

Aggregate

Metric Block 18 Block 19 Δ
recall_at_5 0.9200 0.9600 +0.0400
recall_at_10 0.9200 0.9600 +0.0400
mrr 0.7667 0.7400 −0.0267
avg_latency_ms 155.3 140.1 −15.2

Per-Frage Hit@5

  • Improved (B18 miss → B19 hit): 1 (q020 — schwierige Query, jetzt rank-3)
  • Regressed (B18 hit → B19 miss): 0 (Stop-Trigger Outcome-C konnte nicht ausgelöst werden)
  • Unchanged: 24

MRR-Verschiebungen (|ΔRR| > 0.01) — die −0.03-Story

  • q006: 1.00 → 0.25 (rank 1 → rank 4) — Haupttreiber des MRR-Verlusts
  • q025_table_kurtaxe_saison: 1.00 → 0.50 (rank 1 → rank 2)
  • q016: 0.50 → 0.33 (rank 2 → rank 3)
  • q023_table_bundeslaender_verordnung: 0.33 → 0.25 (rank 3 → rank 4)
  • q020: 0.00 → 0.33 (Miss → rank 3, gain)
  • q024_table_fehler_codes: 0.50 → 1.00 (rank 2 → rank 1, gain)

Hypothese: RRF blendet Sparse-Score in Dense-Top-Ranks ein. Bei Queries, wo Dense allein perfekt war (B18 RR=1.00), kann der Sparse-Anteil den Top-Rank verwässern, ohne das relevante Doc aus Top-5 zu drängen — R@5 unverändert, MRR sinkt. Ranker-Stage rerankt nur Top-5 und kann nicht alle Verschiebungen kompensieren. Mögliche Korrektur in Phase 4 oder Followup: rrf_dense_weight von 0.7 → 0.8 könnte MRR retten, ohne den R@5-Win zu opfern. Erfordert eigenes Tuning-Run.

Tabellen-Queries (q021–q025)

5/5 Hit@5=True (B18 wie B19, R@5=1.00, kein Regress). q024_table_fehler_codes von rank 2 → rank 1, q025_table_kurtaxe_saison von rank 1 → rank 2 — Sparse-Component beeinflusst aber bricht Tabellen-Recall nicht.

By-Difficulty

Difficulty Count B18 R@5 B19 R@5 B18 MRR B19 MRR
easy 8 0.875 0.875 0.875 0.781
medium 11 1.000 1.000 0.818 0.818
hard 6 0.833 1.000 0.528 0.542

Wichtig: Hard-Queries gewinnen R@5 stark (0.83 → 1.00) bei stabilem MRR. Hybrid hilft Edge-Cases.

Akzeptanz-Bewertung (Phase-0-Kriterien)

Kriterium Block 18 Block 19 Akzeptanz Status
Gesamt R@5 0.92 0.96 ≥ 0.92
Gesamt R@10 0.92 0.96 ≥ 0.92
Gesamt MRR 0.7667 0.74 ≥ 0.77 ❌ (−0.03)
q001–q020 R@5 0.90 0.95 ≥ 0.90
q021–q025 R@5 1.00 1.00 ≥ 0.95
avg_latency_ms 155 140 ≤ 250
Failed queries 0 0 ≤ 1
Regressed (Hit@5↓) 0 ≤ 1

7/8 ✅. MRR knapp unter Target (−0.03), getrieben von 4 isolierten Per-Frage-Verschiebungen, davon q006 alleine −0.75. Stop-Trigger 4.1 (Outcome C) negativ.

Confidence-Live-Check (Sub-Step 5, ausgeführt)

  • Live-API für ~70 s in Hybrid-Modus geschaltet (USE_HYBRID_RETRIEVAL=true + QDRANT_COLLECTION=avs_handbuecher_bge_m3 per sed -i).
  • make redeploy → API Up healthy. make smoke 8/8 grün im Hybrid-Modus.
  • Curl-Smoke „Welche Felder sind im Gruppenmeldeschein verpflichtend?" (frisch, kein Cache-Hit): 4 Sources alle aus Meldeschein_*.pdf, Top-Ranker-Score 0.92, Antwort kohärent.
  • Rollback per cp .env.block19_backup .env + make redeploy → Demo zurück im Legacy-Modus, make smoke 8/8 grün, make smoke-hybrid weiter grün (subprocess unabhängig vom Live-API-State).
  • Datapoint für Phase 4: Live-API-Pfad in Hybrid-Modus funktioniert, Helper aus Phase-2-Followup hat keinen Live-API-Bug.

Phase-3-Stop-Trigger-Status

Alle 7 Sub-Steps (Pre-Check, Backup, In-Process-Eval, Diff-Report, Akzeptanz, Live-Confidence, Commit) — alle Stop-Trigger negativ.

  • Stop-Trigger 0.1 (Pre-Check ⚠/❌): pragmatisch handhabbar — Eval-Runner-CLI-Drift dokumentiert, durch Inline-Skript ersetzt.
  • Stop-Trigger 1.1 (Backup-Permissions): negativ.
  • Stop-Trigger 2.1 (Eval-Runner-Fehler): negativ.
  • Stop-Trigger 2.2 (Failed > 2): negativ — 0 Failures.
  • Stop-Trigger 2.3 (R@5 < 0.80): negativ — 0.96.
  • Stop-Trigger 3.1 (Diff-Skript-Schema): negativ.
  • Stop-Trigger 4.1 (Outcome C): negativ — Outcome B.
  • Stop-Trigger 5.1 (Live-Smoke nach Switch rot): negativ.
  • Stop-Trigger 5.2 (Demo nach Rollback nicht zurück): negativ.
  • Stop-Trigger 6.1 (Smoke nach Commit rot): wird in Sub-Step 6 final verifiziert.

Phase-4-Voraussetzung

  • Outcome B mit Empfehlung Phase 4: Hit@5-Win user-facing primär (Top-5 Sources werden angezeigt), MRR-Loss minor und in Noise-Range. Hard-Queries-R@5 +0.17 ist starkes Signal.
  • Followup-Issue: q006-Diagnose (warum dropped relevant doc von rank 1 nach rank 4 unter Hybrid?) + RRF-Weight-Tuning-Run (rrf_dense_weight 0.8 vs 0.7 vs 0.6).
  • Phase-4-Cutover-Plan ist als nächster Block-19-Schritt im Roadmap-Eintrag fällig.

Block 19 — BGE-M3 + Hybrid Retrieval (Phase 2 Followup: Hybrid-Wiring-Drift-Fix, ✅ Erledigt)

Branch: feature/block-19-bge-m3-embedder (gleich wie Phase 2)

Fixed

  • Phase-2-Wiring-Drift in der Hybrid-RAG-Pipeline: Konsumenten (api/routes/query.py sync + SSE, services/evaluation_service.py) riefen hardcoded pipeline.get_component("retriever") auf — eine Komponente, die im Hybrid-Modus durch dense_retriever + sparse_retriever + joiner ersetzt ist. Aktivierung von USE_HYBRID_RETRIEVAL=true produzierte Component named retriever not found in the pipeline und blockierte Phase 3 (Real-Stack-Quality-Vergleich gegen avs_handbuecher_bge_m3).
  • Smoke-Coverage-Lücke: Phase-2-Smoke-Tests sind grün geblieben, weil use_hybrid_retrieval=False der Default ist — der Hybrid-Hot-Pfad wurde nie real ausgeführt.

Added

  • src/avs_chatbot/pipelines/retrieval_helper.py mit zwei Helpern: embed_query() und retrieve_documents(). Modus-Detection via pipeline.graph.nodes() (nicht via Settings-Flag — bleibt konsistent auch wenn Settings vom Live-Pipeline-State driften, z.B. nach Hot-Reload). Bewusst zwei Funktionen statt eines run_retrieval(): Konsumenten brauchen das Embedding für den Semantic-Cache-Lookup vor dem Retrieval, eine kombinierte Helper-API würde diese Trennung aufweichen.
  • make smoke-hybrid und make smoke-all — Hybrid-Pipeline-Smoke läuft in-process per docker compose exec -e USE_HYBRID_RETRIEVAL=true, ohne die Live-API in Hybrid-Modus zu schalten. Pattern wiederverwendbar für Block 19.5 (Reranker-Upgrade) und Block 20 (AST-Chunking-Flag).

Changed

  • api/routes/query.py:301-353 (sync) + query.py:872-933 (SSE) + services/evaluation_service.py:251-293 + scripts/debug_retriever_scores.py: embedder = pipeline.get_component("text_embedder") / retriever = pipeline.get_component("retriever") durch Helper-Aufrufe ersetzt. Verbleibende get_component()-Calls in api//services/ (5: ranker×3, generator×1, prompt_builder×1) bleiben direct, da diese Komponenten in beiden Pipeline-Modi unter gleichem Namen existieren und keinen Bug-Drift erzeugen — bewusste Helper-Bloat-Vermeidung.

Hybrid-Smoke-Datapoints (Sub-Step 4)

  • make smoke (Legacy): 8/8 grün (Refactor hat Legacy-Pfad nicht regrediert).
  • make smoke-hybrid (NEU): grün, 8 Dokumente retrieved für Query „Was ist ein Meldeschein?" gegen avs_handbuecher_bge_m3 (242 Chunks).
  • Beide Targets in make help sichtbar.

Phase-2-Followup-Stop-Trigger-Status

Alle 5 Sub-Steps (Vollständigkeits-Check, Helper-Implementation, Call-Sites-Refactor, Hybrid-Smoke, Commit) — alle Stop-Trigger negativ.

  • Stop-Trigger 1.1 (Dead Code): negativ — alle gefundenen Komponenten- Namen entsprechen aktiven pipeline.add_component()-Definitionen in rag.py/indexing.py.
  • Stop-Trigger 1.2 (>8 Kategorie-A-Treffer = größerer Refactor): 11 Treffer total in api/+services/, davon nur 3 buggy (retriever-String)
  • 3 logisch-gekoppelte (text_embedder im Retrieval-Trio). Alle 11 Edits sind triviale 1:1-Replacements — kein Refactor-Aufblähen.
  • Stop-Trigger 2.1 (Pipeline-Definition andere Komponenten-Namen): negativ.
  • Stop-Trigger 3.1 (Call-Site mit Helper-API-incompatibler Logik): handhabbar — der ursprünglich vorgeschlagene run_retrieval(pipeline, query) (full pipeline) hätte die Embedder/Cache/Retriever-Trennung gebrochen, daher embed_query() + retrieve_documents() als zwei Funktionen statt einer. Drift dokumentiert, Helper-API entsprechend angepasst.
  • Stop-Trigger 4.1 (Hybrid-Smoke 0 Dokumente trotz 242 Chunks): negativ — 8 Docs.
  • Stop-Trigger 4.2 (Legacy-Smoke nach Refactor rot): negativ — 8/8 grün.
  • Stop-Trigger 5.1 (redeploy nach Commit rot): negativ.

Phase-3-Voraussetzung

  • .env-Switch-Plan: QDRANT_COLLECTION=avs_handbuecher_bge_m3 + USE_HYBRID_RETRIEVAL=true setzen, make redeploy, dann make smoke && make smoke-hybrid als Final-Verify, danach Real-Stack- Quality-Run gegen tests/evaluation/test_questions.json.

Block 19 — BGE-M3 + Hybrid Retrieval (Phase 2: Pipeline-Integration, ✅ Erledigt)

Branch: feature/block-19-bge-m3-embedder (Phase 1 = isolierte Embedder-Component, Phase 2 = Pipeline-Wiring; Production- Switch auf Live-Collection ist eigener Phase-3-Schritt nach Eval.)

Added (Phase 2)

  • Settings-Erweiterung in src/avs_chatbot/config.py: embedding_model_dense, embedding_model_sparse (BGE-M3 single-model: gleicher Wert), use_hybrid_retrieval (default False — Block-18-Backward-Compat), rrf_dense_weight (default 0.7, Sparse-Weight = 1 - rrf_dense_weight).
  • Indexing-Pipeline-Switch in src/avs_chatbot/pipelines/indexing.py: settings.use_hybrid_retrieval schaltet zwischen BGEM3DocumentEmbedder (Dense+Sparse in einem Forward-Pass) und Legacy-SentenceTransformersDocumentEmbedder. QdrantDocumentStore(use_sparse_embeddings=True, sparse_idf=True) war seit Block 18 schon konfiguriert — der Sparse-Slot wird jetzt erstmals befüllt.
  • RAG-Pipeline-Switch in src/avs_chatbot/pipelines/rag.py: Im Hybrid- Modus läuft BGEM3TextEmbedder → {QdrantEmbeddingRetriever, QdrantSparseEmbeddingRetriever} → DocumentJoiner(RRF, weights=[dense, sparse]) → Ranker → Generator. Architektur-Entscheidung: Statt QdrantHybridRetriever (parameter-loses RRF) bewusst zwei parallele Retriever + DocumentJoiner — qdrant-haystack's QdrantHybridRetriever exponiert keinen Weight-Knob, rrf_dense_weight würde dort silent ignoriert.
  • Reindex-Skript scripts/reindex_with_bge_m3.py analog scripts/reindex_with_docling.py. Default-Target: avs_handbuecher_bge_m3 (Variante-B-parallel — Production avs_handbuecher bleibt e5-large bis zum expliziten Operator-Cutover). Forciert use_hybrid_retrieval=True im per-Run-Settings-Override. Counters: sources_indexed, sources_failed, old_chunks_total, new_chunks_total, sparse_populated_new (Scan via Qdrant-Scroll mit with_vector=["text-sparse"] — schützt gegen den Phase-1-Silent-Bug, falls _to_sparse_embedding jemals aufhören sollte, String-Keys zu casten).
  • 34 neue/erweiterte Unit-Tests (Settings, Indexing-Switch, RAG-Switch+Wiring+Weight-Propagation, Reindex-Script-Orchestration mit fail-isolation, 404-Tolerance auf erste Run).

Real-Stack-Smoke-Datapoints (Sub-Step 3.5)

  • make redeploy mit Phase-2-Code: API-Container baut, startet, healthy in <30s nach Image-Build.
  • make smoke: 8/8 grün (Health, Admin-Auth, Demo-Page, Widget-JS, Query-API, Metrics, Präsentation, Screenshots).
  • Reindex --limit 1 auf avs_handbuecher_bge_m3: 1 Source-PDF → 7 Chunks indexed, 7/7 mit populated sparse-vector (Silent-Bug-Magnet negativ), Collection-Auto-Create durch QdrantDocumentStore funktioniert.
  • Hybrid-Retrieval-Smoke (Query "KI Richtlinie" gegen 7-Chunk-Collection):
    • Dense top-1: Score 0.6279, Kapitel "1 Allgemein"
    • Sparse top-1: Score 0.0109, Kapitel "8 Veröffentlichung" (anderer Treffer als Dense — beide Signale tragen unterschiedlich)
    • RRF-Joiner top-1: Score 0.9859, Kapitel "1 Allgemein" (Fusion bevorzugt Dense bei rrf_dense_weight=0.7)
    • Ranker top-1: Score 0.9959, Kapitel "1 Allgemein" (Cross-Encoder-Re-Rank)
  • Production-Collection unangetastet: avs_handbuecher bleibt 242 Points (Block-18-Stand) — der Switch passiert erst, wenn Operator USE_HYBRID_RETRIEVAL=true und QDRANT_COLLECTION=avs_handbuecher_bge_m3 in .env setzt.

Phase-2-Stop-Trigger-Status

Alle 6 Sub-Steps (Settings-Erweiterung, Indexing-Switch, RAG-Switch, Reindex-Skript, Real-Stack-Smoke, Commit) — alle Stop-Trigger negativ.

  • Settings-conditional nicht komplexer als gedacht (eine if-Verzweigung pro Pipeline).
  • Sparse-Storage in Qdrant trivial: QdrantDocumentStore(use_sparse_embeddings=True) war seit Block 18 schon konfiguriert.
  • Reindex-Latenz pro Document unter 5s (BGE-M3 + 7 Chunks Embed in <2s nach Model-Load).
  • Hybrid-Score-Fusion-Output verständlich (Dense ~0.6, Sparse ~0.01, RRF normalisiert auf 0-1, Ranker auf 0-1).

Pre-Phase-2-Snapshot

  • backups/qdrant/avs_handbuecher_pre_block19.snapshot (4.3 MB, POSIX tar) vor Phase 2 erstellt — Production-Collection-State avs_handbuecher (242 Points, Block-18-Stand) ist wiederherstellbar.

Block 19 — BGE-M3 + Hybrid Retrieval (Phase 1: Embedder-Component, ✅ Erledigt)

Branch: feature/block-19-bge-m3-embedder (Phase 1 isoliert, Pipeline-Wiring in Phase 2)

Added

  • BGE-M3-Embedder-Komponenten: BGEM3DocumentEmbedder und BGEM3TextEmbedder in src/avs_chatbot/pipelines/components/bge_m3_embedder.py. Wrappen FlagEmbedding.BGEM3FlagModel (BAAI, MIT) als Haystack @component- Klassen. Erzeugen Dense (1024d) UND Sparse (SPLADE-style lexical weights) in einem einzigen Forward-Pass. Lazy-Import-Pattern analog DoclingPDFConverter (Block 18). Defaults: BAAI/bge-m3, cuda:0 (= physical GPU 1 via Container-Mapping), use_fp16=True (~2.3 GB VRAM statt ~4.6 GB), max_length=8192, return_colbert_vecs=False. Noch NICHT in indexing/RAG-Pipeline gewired — das ist Phase 2.
  • api_model_cache Docker-Volume auf /home/appuser/.cache/huggingface des api-Service. Fixt Block-18-Drift, bei der api-side Modelle (e5-large, mmarco-Reranker, jetzt BGE-M3) nicht über --force-recreate persistiert wurden. Volume-Initialisierung via Dockerfile-mkdir+chown vor USER appuser-Switch, damit der named-volume die appuser-ownership übernimmt.
  • 16 Unit-Tests in tests/unit/test_bge_m3_embedder.py, FlagEmbedding vollständig gemockt. Coverage: Lazy-Import, Sparse-int-Cast, ColBERT-explicitly-disabled, no-prefix-Disziplin, meta_fields_to_embed, Empty-Input-Handling, Idempotent warm_up.

Changed

  • Pin-Locks in pyproject.toml: torch>=2.11.0,<2.12.0, transformers>=5.7.0,<6.0.0, tokenizers>=0.22.0,<0.23.0, sentence-transformers>=5.4.0,<6.0.0. Begründung: Phase-0-Discovery zeigte, dass fastembed (der naheliegende Fallback) kein BGE-M3 hat (nur English-bge-large + English-SPLADE-PP). FlagEmbedding ist damit der einzige verfügbare Pfad — Pin-Locks schützen vor transitiven Updates, die BGE-M3 brechen würden.
  • Document-Mutation auf dataclasses.replace umgestellt. Haystack 2.x flagt direkte doc.embedding =/doc.sparse_embedding =-Mutation als unsafe (parallele Pipeline-Komponenten könnten Referenzen halten).

Dependencies

  • Added: FlagEmbedding>=1.4.0,<2.0.0 (BAAI BGE-M3 Reference-Lib, MIT). Zieht peft 0.19.1, datasets 4.8.5, accelerate 1.13.0. Alle torch/transformers/sentence-transformers-Versionen identisch zum Container-Baseline (Phase-0-Dry-Run-verifiziert).

Real-Stack-Smoke-Datapoints (Sub-Step 5, manueller REPL-Run)

  • Erster BGE-M3-Modell-Load: 39.8s (28s Download, 12s Init) — unter 5min-Schwelle
  • Cached-Load: <1s (Volume-Persistence funktioniert)
  • Encode-Time: 1.0s für 3 Dokumente, 0.32s für 1 Query
  • Dense-Shape verifiziert: 1024d
  • Sparse-NNZ: 7-16 für kurze Texte (BGE-M3 typisch)
  • Cosine(identical query+doc): 1.0000 (Sanity-Check Dense-Pfad)
  • avs-api-model-cache Volume nach Smoke: 6.9 GB

Phase-1-Stop-Trigger-Status

Alle 6 Sub-Steps (Dependency-Setup, Volume-Mount, Component, Unit-Tests, Real-Stack-Smoke, Commit) — alle Stop-Trigger negativ. Phase 1 ist startbereit für Phase-2-Pipeline-Wiring (indexing.py + rag.py Embedder- Swap, USE_HYBRID_RETRIEVAL Feature-Flag, dual-Retriever mit RRF).

K-1c — DocumentUploader auf dual-vector-Upsert (Pipeline-Schicht-K Sub-Welle 3, ⏳ Branch offen)

Branch: platform/k1c-uploader-dual-vector (chained auf K-1b-HEAD 4a7b198) Status: Sub-Steps 1–5 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Added

  • Adapter _to_qdrant_sparse in document_uploader.py: konvertiert die Embedder-eigene SparseVector-dataclass in qdrant_client.models.SparseVector. Beide Klassen tragen identische Felder (indices/values); der Embedder pflegt seine eigene Form, weil er nicht auf qdrant_client als Dependency hängen soll.

Changed

  • DocumentUploader.upload ruft embed_passages_hybrid statt embed_passages und schreibt PointStruct mit named-vector-Dict ({DENSE_VECTOR_NAME: list[float], SPARSE_VECTOR_NAME: SparseVector}) statt bare-list. Wire-Contract-Konstanten aus qdrant_manager (K-1b) werden importiert, nicht dupliziert.
  • count-mismatch-Guard prüft jetzt auch len(sparse_vectors) != len(chunks) (defensiv, falls der Server irgendwann inkonsistente Listen liefert).

Tests

  • test_upload_writes_dual_vector_named_struct (neu): bestätigt das Wire-Format der erzeugten PointStruct.vector-Dicts (dense 1024-d, sparse non-empty, indices unterscheiden sich zwischen Chunks).
  • mock_embedder-Fixture und test_upload_raises_on_embedder_count_mismatch auf embed_passages_hybrid umgestellt.

Verified (Sub-Step 4 End-to-End-Smoke)

  • Live-Roundtrip Test-Tenant + Test-Chatbot + 3-Chunk-Document via DocumentUploader.upload → BGE-M3 Embedder → Qdrant. Resulting Points: dense_dim=1024 alle drei, sparse_nnz={9, 14, 17}, Sparse-Indices paarweise distinct (kein „selber Sparse-Vektor für alle Chunks"-Bug).
  • 29/29 Unit-Tests grün (11 uploader + 12 qdrant_manager + 6 chatbot_lifecycle).
  • Read-Seite (PlatformRetrieval) unverändert dense-only — das ist K-2. Dual-vector-Points sind read-kompatibel: dense-only-Queries gegen named-vector-Collections funktionieren weiter.

K-1b — QdrantManager auf dual-vector-Schema (Pipeline-Schicht-K Sub-Welle 2, ⏳ Branch offen)

Branch: platform/k1b-qdrant-dual-vector (basiert auf K-1a-Tip ce04763) Status: Sub-Steps 0–6 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Added

  • Named-vector Schema in QdrantManager.ensure_collection: neue Collections werden mit vectors_config={"dense": VectorParams(size=1024, Cosine)} und sparse_vectors_config={"sparse": SparseVectorParams(...)} angelegt. Modul- Konstanten DENSE_VECTOR_NAME/SPARSE_VECTOR_NAME sind Wire-Contract für K-1c (DocumentUploader Upserts, PlatformRetrieval Queries).
  • drop_legacy_single_vector_collections(*, dry_run=False) für die Cutover-Phase: listet alle kora_*-Collections, überspringt bereits-dual- vector und droppt die Rest. Prefix-Filter kora_ als zweiter Guard zusätzlich zur Instance-Trennung (avs-qdrant ist auf einer anderen Port-/ Compose-Network-Insel und für den Manager unerreichbar).

Changed

  • ensure_collection Migration-Path: Legacy-Single-Vector-Collections werden NICHT in place migriert (Qdrant erlaubt es nicht). Stattdessen Drop+Recreate via drop_legacy_* + nächster ensure_collection-Call. Gangbar, weil §15a.4 Fundament-Konzept die Platform-Inbetriebnahme als „frische Leinwand" definiert (kein Daten-Carry-over aus pre-K-1b-Zustand).

Verified (Sub-Step 4/5/6)

  • Cutover: kora-platform-qdrant (Port 8233) hatte 2 Legacy-kora_*- Collections (single-vector dense 1024d, je 0 Points), beide via drop_legacy_single_vector_collections(dry_run=False) entfernt. Post-Drop: 0 Collections in der Platform-Instanz; avs_handbuecher und avs_handbuecher_bge_m3 in der avs-qdrant-Instanz unangetastet (Doppel- Check via curl http://localhost:6333/collections).
  • Schema-Smoke: Test-Descriptor → ensure_collectionget_collectionvectors == {"dense": VectorParams(1024, Cosine)}, sparse_vectors == {"sparse": SparseVectorParams(...)}. Idempotenz bestätigt (zweiter Call returns False).
  • Calling-sites: Unit-Tests test_qdrant_manager.py (12), test_chatbot_lifecycle.py (6), test_document_uploader.py (10) alle 28 grün. ChatbotService.create_chatbot ruft ensure_collection auf, identischer Code-Path zum Sub-Step-5-Smoke.

K-1a — Embedder-Service auf BGE-M3 dual-output (Pipeline-Schicht-K Sub-Welle 1, ⏳ Branch offen)

Branch: platform/k1a-embedder-bge-m3 Status: Sub-Steps 0–6 grün, Commit ausstehend, Merge nach platform/v1.0.0 post-Review.

Added

  • infra/embedder/ auf BGE-M3 (BAAI, MIT) umgebaut. Single Forward-Pass liefert dense (1024d) + sparse (SPLADE-style lexical weights) parallel via FlagEmbedding>=1.4.0,<2.0.0 (Pin spiegelt avs_chatbot pyproject). Dockerfile-Base auf pytorch/pytorch:2.10.0-cuda12.6-cudnn9-runtime (torch ≥ 2.6 wegen CVE-2025-32434 in transformers); --break-system-packages weil pytorch-image System-Python (PEP 668) nutzt. Modell ist build-baked (~2.27 GB Modell, ~7-8 GB Image).
  • /embed Response-Schema neu: {dense: [[...1024], …], sparse: [{indices: [...], values: [...]}, …], model, dim}. Alte embeddings-Antwort entfernt — Breaking Change, alle Konsumenten in kora_platform/ werden in K-1a Sub-Step 3 angepasst.
  • SparseVector-Dataclass in kora_platform.services.embedder (frozen, slots) als Qdrant-kompatible Sparse-Form für K-1c.
  • K-1c-Vorbereitungs-Methoden: EmbedderClient.embed_query_hybridtuple[list[float], SparseVector], embed_passages_hybridtuple[list[list[float]], list[SparseVector]]. In K-1a noch unbenutzt, aber smoke-getestet.

Changed

  • EmbedderClient liest dense statt embeddings aus dem Response. Public-Signaturen embed_query() / embed_passages() unverändert (return list[float] / list[list[float]]) — bestehende Konsumenten PlatformRetrieval, DocumentUploader brechen nicht.
  • embed_passages / embed_query Prefix-Konvention entfernt: BGE-M3 braucht keine passage:/query:-Prefixes (im Gegensatz zu e5-large). Prefix-Stripping ist in K-1a obsolet, weil Hinzufügen die Embedding- Qualität aktiv verschlechtern würde.
  • embedder_batch_size Default: 64 → 32 (BGE-M3 Server-_MAX_BATCH).
  • docker-compose.platform.yml embedder-Service: Image-Tag kora-platform-embedder:latest:bge-m3, runtime: nvidia, NVIDIA_VISIBLE_DEVICES=1 + CUDA_VISIBLE_DEVICES=0 (physische GPU 1 als cuda:0 sichtbar — gleicher Slot wie avs-api Block-19), device_ids: ["1"] GPU-Reservation, Memory-Limit 4G → 6G, healthcheck start_period 90s → 180s (Modell-Load + erstes Forward-Pass).

Removed

  • sentence-transformers als Embedder-Dependency (e5-large-Pfad).
  • intfloat/multilingual-e5-large als embedded Modell — ersetzt durch BAAI/bge-m3.

Verified (Sub-Step 5/6 Smoke + Consumer-Check)

  • GPU 1 footprint: BGE-M3 FP16 belegt 1576 MiB Working Set (warm, nach Erstanfrage). Plus avs-api 2204 MiB → ~3.8 GB used / 24 GB, ~20 GB Reserve.
  • Latenz: T1 Health <50ms; T2 DE-Single ~1s (cold-warmup), 131ms warm; T3 EN-Single 131ms warm; T4 Batch (2 texts) 188ms warm.
  • Consumer-Check: EmbedderClient.embed_query / embed_passages / embed_query_hybrid / embed_passages_hybrid alle vier grün gegen Live-Service (1024d Dense, non-empty Sparse).

Block 18 — Docling Ingestion-Normalisierung (Pfad-B Quality-Foundation, ✅ Erledigt)

Branch: platform/block-18-docling-ingestion → merged in platform/v1.0.0 Merge-SHA: 814f981 Production-Switch: avs_handbuecher Collection enthält jetzt 242 chunks aus DoclingPDFConverter (DocLayNet + TableFormer) statt 356 chunks aus EnhancedPDFConverter (PyPDF). Backup der vor-Block-18-Collection unter backups/qdrant/avs_handbuecher_pre_block18.snapshot (8.2MB, 356 points).

Added

  • Docling-Integration: docling>=2.72.0,<3.0.0, docling-core>=2.30.0,<3.0.0, docling-haystack>=0.3.0,<1.0.0 als pyproject-Dependencies. IBM Docling v2.92.0 (Februar 2026, MIT, Linux Foundation AI & Data) mit DocLayNet (Layout, 81k Seiten) + TableFormer (97.9 % Tabellen-Extraktions-Genauigkeit).
  • PDF-Pfad: DoclingPDFConverter (src/avs_chatbot/pipelines/components/docling_converter.py) ersetzt EnhancedPDFConverter als FileTypeRouter application/pdf-Branch in create_indexing_pipeline(). Output: 1 Markdown-Document pro PDF mit page_break_placeholder für Phase-2.5-Page-Tracking. Fail-Loud bei Conversion-Fehler (kein silent fallback wie im Vorgänger).
  • SemanticChunker preserve_tables=True Hard-Default: Markdown-Pipe- Tables werden via Sentinel-Pattern (\x01AVS_TBL_{i}\x01) durch Section-Splitting/Merging atomar gereicht und post-chunking restauriert. Oversized-Tabellen (> max_chunk_words) bleiben atomar + Log-Warnung. Tenant-Override ist Block-13-Concern.
  • Page-Tracking via Sentinels (Phase 2.5): Docling emittiert \x02AVS_PAGE_BREAK\x02 an Page-Transitions. Chunker nummeriert sie zu \x02AVS_PAGE_N\x02-Sentinels (newline-flankiert), _extract_page_numbers nutzt sie primär (Legacy-Footer-Regex Seite \d+ von \d+ als Fallback für 1-Doc-per-Page-Input). Pro Chunk: korrekte page_number / page_start / page_end für Widget-Citations.
  • Qdrant-Payload-Schema additiv erweitert (Phase 3): chunk_type{table, text, heading, figure_caption}, docling_version, has_tables (Document-Level-Aggregat aus Chunk-Klassifikationen). Keine Schema-Migration nötig (JSON-Payload). Parent-Meta-Propagate-Pattern: _PARENT_META_PROPAGATE_KEYS (source_type, docling_version).
  • Re-Indexing-Skript (Phase 4): scripts/reindex_with_docling.py mit --dry-run, --collection, --limit, --documents-dir. Pro- Document-Loop mit Exception-Isolation, idempotent (zweiter Lauf produziert gleiche Chunk-Anzahl). Routes Writes auf parallel-Collection via Settings.model_copy({"qdrant_collection": ...}) ohne globalen Cache-Mutation.
  • Eval-Set-Erweiterung (Phase 5): 5 Tabellen-Queries (q021_table_kurtaxe_altersgruppe, q022_table_meldeschein_pflichtfelder, q023_table_bundeslaender_verordnung, q024_table_fehler_codes, q025_table_kurtaxe_saison) in tests/evaluation/test_questions.json mit block_18_table_query: true-Marker. Mix-Variante: Lutz ergänzt 3-5 AVS-Domänen-spezifische Queries beim Real-Eval-Run.
  • Grafana-Panel (Phase 6): "Chunk-Type-Verteilung (Block 18)" (Panel-ID 108) als piechart auf monitoring/grafana/dashboards/avs-chatbot.json. PromQL: sum by(chunk_type) (avs_indexed_chunks_total). Counter avs_indexed_chunks_total{chunk_type=...} in semantic_chunker.py registriert, Increment im run-Loop.

Architektur-Datapoints

  • Sentinel-Coexistence verifiziert: Tabellen-Sentinels (\x01-Prefix) und Page-Sentinels (\x02-Prefix) nutzen unterschiedliche Bytes, separate Regex-Pattern. Stop-Trigger 2.5/2 (Sentinel-Kollision) NICHT ausgelöst.
  • Pre-Pass-Reihenfolge im Chunker: Pages → Tables → Sections → Chunks → Restore-Tables → Strip-Pages. Order matters: Page-Sentinels müssen VOR Section-Splitting eingeführt werden, damit _page_at() via page_map Section-Positions korrekt auflösen kann.
  • Cross-Page-Sektion-Kohärenz erhalten: Single-Document-Output von Docling lässt SemanticChunker Section-Hierarchie über Page-Grenzen hinweg sehen. Kein Per-Page-Splitting wie bei EnhancedPDFConverter (Architektur-Verbesserung gegenüber Pre-Block-18).
  • Pin docling-core>=2.30: page_break_placeholder-Parameter ist erst ab docling-core 2.30.0 verfügbar (verifiziert via Wheel-Diff von 2.0/2.10/2.30 in Phase 2.5a-Discovery).

Discovery-Datapoints

  • Paket-Name: docling-haystack (nicht haystack-integrations[docling] wie im Original-Prompt vermutet). PyPI: https://pypi.org/project/docling-haystack/.
  • DS4SD/docling-haystack archiviert 2026-04-09, Code lebt jetzt in deepset-ai/haystack-core-integrations/integrations/docling. Import bleibt from haystack_integrations.components.converters.docling import DoclingConverter.
  • document_versions-Pattern robust: Service in services/document_version_service.py
  • Admin-Integration mit Soft-Delete + Version-Rollback. Re-Index-Skript baut darauf auf (Source-Discovery via DocumentVersionService geplant für Followup, aktuell direkt aus data/documents/).
  • Grafana hat ausschließlich Prometheus-Datasource (kein Postgres, kein Qdrant-direct). D3-Variante (a) Prometheus-Metrik gewählt statt Datasource-Plugin-Install.

Tests

  • 83 Pytest-Tests passed, 4 skipped (pdfplumber + slow + 2 outdated stubs)
  • 16 DoclingPDFConverter (Pure-Helper + Mock + Import-Error)
  • 20 SemanticChunker preserve_tables (Sentinel + Klassifikator + Component)
  • 16 SemanticChunker page-tracking (Sentinel + Multi-Page-Integration + Coexistence)
  • 8 Qdrant-Payload (chunk_type/docling_version/has_tables End-to-End)
  • 13 Re-Indexing-Skript (httpx-mocked, fail-isolation, idempotency)
  • 4 Chunks-Indexed-Metric (Counter-Increment + Grafana-JSON-Validität)
  • 2 Real-Docling-Integration (@pytest.mark.slow, skip ohne Docling-Install)

Real-Eval-Drifts (während Schritt 5)

Real-Eval auf 6 AVS-Handbüchern surfacte zwei Chunker-Drifts, die in Phase 1 Code-Tests nicht sichtbar waren weil sie PDF-spezifische Heading-Konventionen betrafen:

  1. Heading-Regex matched 1.1 aber nicht 1.1. (mit trailing-dot). Das PDF "Detaillierte Richtlinie zur sicheren Administration" nutzt diese Konvention durchgängig → 0 Sections erkannt → 1 Mega-Section über 67 Pages. Fix: optionaler \.? in MAIN/SUB/SUBSUB regex (semantic_chunker.py:45-50). Resultat: 22 → 52 chunks für dieses PDF.

  2. Safety-Valve in _sections_to_chunks dumpte 8417-Wort-Brick als 1 Chunk nach 20 Splits (über max_chunk_words=1500). Fix: progressive force-split + Endlos-Loop-Schutz wenn _split_at_sentence keinen Progress macht. Hard-Cap-Path bei part_idx > 100. Resultat: max word_count 8417 → 1248, 0 Outliers über 1500.

Beide Fixes durch 3 neue Unit-Tests dokumentiert (test_semantic_chunker_preserve_tables.py:248-348).

Real-Eval-Resultat (242 chunks vs Baseline 356)

Baseline: tests/evaluation/results/eval_retrieval_2026-04-08T09-55.json Block 18: tests/evaluation/results/eval_retrieval_block18_v2.json

Subset Block 18 R@5 Baseline R@5 Block 18 MRR Baseline MRR
q001-q020 (bestehend) 90.0% (18/20) 95.0% (19/20) 0.792 0.735
q021-q025 (Tabellen, neu) 100.0% (5/5) n/a 0.667 n/a
Gesamt (25) 92.0% 0.767

Stop-Trigger 4 (R@5 ≥ 0.95) verfehlt um -1 Frage — dokumentierte Ausnahme. Regression: q004 ("Was passiert wenn ein Benutzer gesperrt wird?"). Baseline retrievete Beherbergung als rank-5 (gerade noch Hit). Block 18 retrieved "Richtlinie zum sicheren Umgang mit Informationen" als rank-5 → Beherbergung außerhalb Top-5. Beide Sources semantisch valide — Gold-Label-Edge-Case, nicht Retrieval-Failure. Followup-TODO-03 angelegt.

Architektur-Datapoints (Real-Eval-validiert)

  • Sentinel-Coexistence verifiziert in Production: Tabellen (\x01) und Page-Sentinels (\x02) kein Konflikt
  • Page-Tracking funktional: Smoke-Queries zeigen echte Page-Numbers (p.7, p.13, p.54, ...) — TODO-Followup-02 erledigt
  • Cross-Page-Sektion-Kohärenz erhalten: Single-Document-Output von Docling lässt Section-Hierarchie über Page-Grenzen hinweg sichtbar
  • Chunks mit non-empty chapter: 89% (v1) → 100% (nach Drift-Fixes)
  • Pin docling-core>=2.30: page_break_placeholder-Parameter ist erst ab docling-core 2.30.0 verfügbar (verifiziert via Wheel-Diff von 2.0/2.10/2.30 in Phase 2.5a-Discovery)

TODO-Eintrag

  • TODO-Block-18-Followup-01 (EnhancedPDFConverter-Removal): bleibt offen für separate Cleanup-Welle (~2h Aufwand). Siehe offene-todos.md.
  • TODO-Block-18-Followup-02 (Page-Number-Fidelity): ✅ Erledigt durch Phase-2.5-Page-Tracking + Real-Eval-Verifikation.
  • TODO-Block-18-Followup-03 (q004 Gold-Label-Re-Annotation): NEU. Severity Low/30. Multi-Doc-Annotation oder Eval-Set-Refactor.
  • TODO-Backup-Cleanup (Production-Backup): NEU. Severity Low/20. backups/qdrant/avs_handbuecher_pre_block18.snapshot nach 1-2 Wochen löschen wenn keine Rollback-Notwendigkeit.

Aufwand-Datapoint (final)

  • Refined-Schätzung: ~21h (18h ursprünglich + 3h Phase 2.5)
  • Real-Aufwand: ~17-18h (Phase 0-7 Code: ~8h, Phase 8 Real-Eval + Real-Stack-Drifts: ~9-10h)
  • Pattern-Reife-Quote real: ~50-60% (Foundation-Reuse via Haystack + docling-haystack hoch, Real-Stack-Drifts in PDF-Spezifika moderat). Datapoint für nächste §17.2a-Reconciliation.

1.3.0 — 2026-05-02

v1.3.0-Welle: Cleanup + Customer-Wert

Drei-Sub-Block-Welle (D2 Backend → D1 Frontend → E Operator-Per-Chatbot- Page). Wartungsschuld-Reduktion vor Block 13 plus Customer-Wert- Datapoint aus Block-11-Hand-off. Charakter: Disziplin-Tag, kein Marketing-Customer-Wert wie v1.2.0 — sondern drei Pattern-Reife-Quote- Datapoints und ein eingelöstes Block-11-Hand-off-Item.

Sub-Block Refined Real Quote
D2 Backend Polish ~22h ~5h 23 %
D1 Frontend Polish ~14.5h ~4.5h 31 %
E Operator-Per-Chatbot-Page ~6–8h ~3.5h ~50 %
Welle gesamt ~42.5–44.5h ~13h ~30 %

Pattern-Reife-Quote-Datapoints (für TODO-Konzept-02 Reconciliation pending)

Block-Typ Quote Datapoints
Cleanup-Welle Backend 23 % D2
Cleanup-Welle Frontend 31 % D1
Foundation-Reuse + neuer Code 50 % E
Foundation-Reuse hoch 40 % Block 11
Foundation-erweiternd (neue Achse) 50–80 % Block 8.0/8.2/8.3

Welle-Quote ~30 % ist deutlich besser als die Discovery-Erwartung von 60 %. Konzept §17.2 hatte historisch einen Pauschalwert; mit drei v1.3.0-Datapoints plus Block-8/11-Werten lässt sich die Quote nach Block-Typ kalibrieren. TODO-Konzept-02 (Mini-Run nach Tag, ~30–45 min) arbeitet die Trendlinie in §17.2 ein.

Bekannte Drifts

  • TODO-Auth-NEU (M2/40, von v1.0.0): JWT-sub-Claim. Workarounds aktiv.
  • TODO-Platform-11: Pytest-Env-Bootstrap. docker cp tests/-Workaround. Trigger vor Block 14 (CI).
  • TODO-Platform-12: Pytest-Profil-Trennung Platform vs. AVS-Demo. Trigger vor Block 14.
  • TODO-Block-7-Review-Familien-Rest (D3-Items): 7-NN-05 (Supply- Chain), 7-3-01 (Pakete/Limits → Block 12), 7-4-04 (KNOWN_ACTIONS- Drift → Block 13/14), 7-4-05 (Playwright workers:1, Trigger > 50 Specs), 4b (Qdrant-Backup, eigener Operations-Block).
  • TODO-Konzept-02 (neu): §17.2-Reconciliation mit Pattern-Reife- Quote-Trendlinie pro Block-Typ. Eigener Mini-Run nach v1.3.0-Tag.

Nicht im Scope von v1.3.0

  • Block 8.5 (Sources-Management) — blockiert von Block 13 oder Pfad-C-Stub.
  • Block 12 (Tenant-Provisioning) — Cross-Tenant-Operator-View, eigene Achse.
  • Block 13 (Konnektor-Framework, ~57h Refined) — größter Tech-Hub, schaltet 8.5.

Added — v1.3.0-D2 Backend Polish (Merge bb0abbd)

Foundation (Phase D2.1):

  • Zentraler Scope-Guard-Helper kora_platform.api.dependencies.scope_guards (require_operator, require_operator_or_vendor, require_tenant_read). Vier Routen-Files (operator_tenants, operator_audit, tenant_modules, chatbot_templates) ziehen die Guards jetzt zentral statt jeweils mit einer privaten _require_*-Kopie. (TODO-Block-7-NN-01)
  • Alembic 0010 installiert PG-Trigger set_updated_at() auf tenants, tenant_modules und platform_modules. updated_at wird nicht mehr Python-seitig gesetzt. (TODO-Block-7-NN-04 + 5g, gebündelte Migration)

Bulk-Refactor (Phase D2.2):

  • bulk_soft_delete_tenants und bulk_assign_to_tenant nutzen jetzt WHERE id IN (...) Pre-Validate plus single Multi-Row-Statement statt N Per-Item-Roundtrips.
  • Pydantic min_length=1, max_length=MAX_BULK_ITEMS=500 in beiden Bulk-Routen — Empty-List ergibt jetzt 422 (vorher 400), Über-Cap ebenso 422. Frontend-Progress-Indicator wird mit dem Cap-Hardening redundant. (TODO-Block-7-4-01 + 7-4-08)

Audit-Hygiene (Phase D2.3):

  • TenantService.list_tenants nutzt count(*) OVER () Window-Function in einer Single-Query statt zwei separater Roundtrips → kein TOCTOU- Fenster mehr zwischen Count und Items. (TODO-Block-7-NN-02)
  • TenantService.changed_fields snapshotet die Before-Werte in ein plain-dict, bevor das Diff läuft — entkoppelt es vom ORM-State. (TODO-Block-7-NN-03)
  • CSV-Export (GET /api/v1/platform/audit/export.csv) mappt jetzt forensische Felder actor_keycloak_id, ip_address, session_id in den Header, damit Compliance-Läufe mit Keycloak-/vCenter-Logs korrelierbar sind. (TODO-Block-7-4-02)
  • Integration-Test test_audit_failure_rolls_back_mutation simuliert einen write_platform_audit-Fehler und verifiziert, dass die umgebende Mutation rollbacked. (TODO-Block-5e)

Service-Endpoints (Phase D2.4):

  • GET /api/v1/platform/modules/{module_id} — Single-Modul-Detail für die Operator-UI-Detail-Seite, die nicht mehr die volle Liste fetchen muss. (TODO-Block-7-3-02)
  • GET /api/v1/platform/tenant-modules?tenant_id=...&include_unassigned=true Aggregate-Endpoint, der den Frontend-side parallel-Fetch + Merge ersetzt. Cross-Tenant-Fan-out ohne tenant_id ist heute 400 und wird zu Block 12 (Provisioning) nachgeschoben. (TODO-Block-7-3-03)
  • DELETE /api/v1/tenants/{id}/modules/{module_id} prüft jetzt zuerst, dass der Tenant existiert, bevor es auf tenant_modules schreibt — Defense-in-Depth über die existing BYPASSRLS-Engine. Vorher war das DELETE bei unbekannter Tenant-ID ein stilles No-Op. (TODO-Block-5f)

Polish (Phase D2.5):

  • ILIKE-Metachar-Escape im TenantService.list_tenants-Search-Pfad (%/_ wirken nicht mehr als Wildcards). (TODO-Block-7-NN-06)
  • Vendor-include_deleted=true-Pfad ist jetzt durch einen expliziten Test abgedeckt. (TODO-Block-7-NN-07)
  • datetime.utcnow() (Py3.12 deprecated) → datetime.now(UTC) im CSV-Filename-Builder. (TODO-Block-7-4-06)
  • _validate_date_range lehnt date_from > date_to mit 422 date_from_after_date_to ab statt eine leere Liste zurückzuliefern. (TODO-Block-7-4-07)
  • Neue Doku-Page operations/audit-conventions.md dokumentiert die „eine Audit-Zeile pro Bulk-Aktion"-Konvention plus Anonymous-Actor-Pfad und CSV-Forensik-Felder. (TODO-Block-7-4-03)

Erledigt (TODOs)

  • TODO-Block-7-NN-01, -02, -03, -04, -06, -07
  • TODO-Block-5e, -5f, -5g (5g via gemeinsamer Migration mit -7-NN-04)
  • TODO-Block-7-3-02, -7-3-03
  • TODO-Block-7-4-01, -02, -03, -06, -07, -08 (-08 via Cap-Hardening redundant gemacht)

Total: 17 TODO-IDs aus 4 Familien geschlossen.

Added — v1.3.0-D1 Frontend Polish (Merge 18c4884)

Foundation-Helpers (Phase D1.1):

  • Neuer frontend/operator-ui/src/utils/formHelpers.ts mit zwei shared Helpers — trimEqual(a, b) (whitespace-only-tolerantes Edit-Diff) und listsEqual(a, b) (order-aware list-equality, dirty bei Reorder). Tenants-Edit und Templates-Edit nutzen sie jetzt konsistent statt jede Page eigene Trim-/Compare-Konvention zu haben. (TODO-Block-7-1b-07, -7-2-07, -7-2-03)
  • flattenError(err) in composables/useApi.ts — reduziert rohe ApiError-Objekte auf {status, message, detail}. Pydantic-422- Listen werden zu einer human-lesbaren Detail-Zeile gejoint, statt jeden Caller die body.detail-Form selbst parsen zu lassen. TenantsListPage Bulk-Soft-Delete und TenantModulesSection Bulk-/ Single-Toggle nutzen flattenError jetzt für Toast-Detail-Zeilen. (TODO-Block-7-3-05)

Component-Patterns (Phase D1.2):

  • ListInput.vue bekommt A11y-Polish: aria-live="polite"-Region annonciert Add/Remove/Reorder, plus Keyboard-Reorder via Alt+↑/↓ und sichtbare ↑/↓-Move-Buttons pro Item. listsEqual-Hoist macht Reorder als dirty erkennbar, sobald die UI sie zulässt. (TODO-Block-7-2-01)
  • useConfirm ist auf eine Queue umgestellt — überlappende ask()- Calls landen jetzt hintereinander, statt den ersten still mit false abzuwürgen. Eröffnet zukünftige Bulk-Confirm-Loops, ohne dass Caller daran denken müssen. (TODO-Block-7-1b-06)

Backend-induzierte Endpoints (Phase D1.3 / D1.4):

  • useModule ruft jetzt den D2-Detail-Endpoint GET /api/v1/platform/modules/{id} direkt — kein Full-List-Fetch
  • Client-Filter mehr. ModulesDetailPage-Spec asserted explizit gegen die neue Pfad-Form. (TODO-Block-7-3-02 last-mile)
  • useTenantModules migriert auf den D2-Aggregate-Endpoint GET /api/v1/platform/tenant-modules?tenant_id=...&include_unassigned=true. TenantModulesSection feuert nicht mehr zwei parallele Requests und mergt clientseitig — ein Roundtrip liefert die fertige „Modul × ist-aktiv-für-Tenant"-Sicht. (TODO-Block-7-3-03 last-mile)
  • TenantModulesSection bekommt Optimistic-Updates beim Aktivieren/ Deaktivieren (Patch der einzelnen Aggregate-Row vor dem POST/ DELETE, Rollback per Snapshot bei Fehler). UI flackert nicht mehr beim N-fachen Toggle. (TODO-Block-7-3-04)
  • TemplatesCreatePage entfernt Empty-Lists aus dem POST-Payload — Audit-Rows tragen die Lists nur noch, wenn der User sie wirklich gefüllt hat. (TODO-Block-7-2-04)
  • TenantModulesSection zeigt die volle enabled_by-UUID als HTML-title-Tooltip auf dem trunkierten 8-Char-Display. (TODO-Block-7-3-06)

Composable-Caching (Phase D1.5):

  • useTenants hat jetzt einen Module-Scope SWR-Cache (Stale-While- Revalidate, key = serialisierte List-Params). Navigation zurück zur Tenants-Liste zeigt sofort die letzte Page ohne Loading- Flash; nach 30 s gilt die Cache als stale und revalidate läuft im Hintergrund. Mutationen (create/update/remove) rufen invalidateCache. (TODO-Block-7-1b-05)

Hygiene (Phase D1.6):

  • E2E-Seed-Pfad in useAuth.applyE2eSeed() ist hinter import.meta.env.VITE_E2E_MODE === "true" gegated. Dockerfile und docker-compose.platform.yml schleusen den Build-Arg VITE_E2E_MODE=true in die luki-ai-Instanz (E2E-Suite läuft dagegen); ein Production-Build lässt den Wert leer und applyE2eSeed returned früh — ein DOM-Clobbering-Angriff (<form id="__KORA_E2E_SEED__"> injektiert vor main.ts) kann den Auth-State nicht mehr kapern. (TODO-Block-7-1b-01)
  • mintTokens ist async — der vorher synchrone Spin-Loop im Backoff blockiert den Worker-Event-Loop nicht mehr. Alle E2E- Spec-Caller sind auf await mintTokens() angepasst. (TODO-Block-7-2-06)
  • frontend/operator-ui/PORTING.md ist nach docs-kora/docs/blocks/block-7-1b-porting.md verschoben und in die mkdocs-nav unter neuer Sektion „Blocks (Pre-Flight & Porting)" aufgenommen; operator-ui/README.md verlinkt die neue Position. (TODO-Block-7-1b-08)

Cosmetic (Phase D1.7):

  • TenantsCreatePage-Description und TenantsDetailPage-Audit-Card ohne Block-Nummer-Referenzen (vorher „Block 7.3 / Block 7.4"). Feature-Beschreibung beschreibt jetzt das Verhalten statt die Block-Herkunft. (TODO-Block-7-3-07 — Footer war bereits durch Layout-Refactor 2026-04-28 entfernt; D1 räumt die letzten veralteten Block-Strings auf.)

Erledigt (TODOs) — D1

  • TODO-Block-7-1b-01, -7-1b-05, -7-1b-06, -7-1b-07, -7-1b-08
  • TODO-Block-7-2-01, -7-2-03, -7-2-04, -7-2-06, -7-2-07
  • TODO-Block-7-3-04, -7-3-05, -7-3-06, -7-3-07
  • TODO-Block-7-3-02 + -7-3-03 last-mile (Backend-Endpoints in D2, Frontend-Wiring jetzt in D1)

Total: 16 TODO-IDs aus 3 Familien geschlossen + 2 D2-Last-Mile.

Added — v1.3.0-E Operator-Per-Chatbot-Page (Merge f5baf0f)

Customer-Wert-Story: Operator hatte vor E keine diagnostische Per-Chatbot-Sicht. Um Branding, Feedback oder Stammdaten zu inspizieren, musste der Operator in den Tenant-Kontext wechseln (Workflow-Bruch). Block 11 hatte das als Datapoint dokumentiert; E löst es als letzter Sub-Block der v1.3.0-Welle. Damit ist die v1.3.0-Welle abgeschlossen.

Backend (4 neue Operator-Read-Endpoints, alle BYPASSRLS-Wrapper auf existing Services):

  • GET /api/v1/operator/tenants/{tid}/chatbots/{cid} — Single-Chatbot- Stammdaten (mit include_deleted=true-Default für Forensik).
  • GET /api/v1/operator/tenants/{tid}/chatbots/{cid}/branding — Chatbot-Branding-Override-Read. Effective Branding (Chatbot→Tenant Fallback) bleibt im Widget-Config-Endpoint, kein Doppel-Resolver.
  • GET /api/v1/operator/tenants/{tid}/chatbots/{cid}/feedback und /feedback/stats — Per-Chatbot-Feedback-Read mit Pagination, Rating-/Category-Filter und 4-Card-Statistik.
  • 9 neue Integration-Tests in tests/integration/test_operator_chatbots_api.py (happy path, Cross-Tenant-404, Tenant-Scope-403, leeres Branding, Stats-Aggregation).

Frontend:

  • Neue Sub-Route /tenants/:tenantId/chatbots/:chatbotId als TenantChatbotDetailPage (Single-Page, vier vertikale Sektionen).
  • Vier Read-Sektionen — ChatbotOverviewSection (Stammdaten + System-Prompt-Disclosure), ChatbotBrandingReadSection (Override mit Erbt-Hinweis), ChatbotFeedbackReadSection (4 Stat-Cards + filterbare Tabelle, Pattern-Spiegelung der Tenant-UI ChatbotFeedbackPage aus 8.7), EmbedCodeSnippet (Block-11-Komponente direkt reused).
  • Drei neue Composables: useOperatorChatbot, useOperatorChatbotBranding, useOperatorChatbotFeedback. Alle nutzen flattenError aus D1 für detail-genaue Toast-Nachrichten und notFound-State für 404.
  • TenantsDetailPage erhält einen neuen „Chatbots"-Tab mit Lazy-Load und „Ansehen"-Button pro Chatbot, der zur neuen Detail-Sub-Route navigiert.
  • 17 neue Vitest-Specs (Overview-, Branding-, Feedback-Sektion, Page-Level mit Routing/404).

Foundation-Reuse-Bilanz:

  • D1: flattenError, useApi-Pattern.
  • D2: BYPASSRLS-Pattern aus operator_branding_router als Vorlage.
  • Block 8.6 + 8.7: Service-Layer (BrandingService, FeedbackService) hat bereits (tenant_id, chatbot_id)-Pfad — Operator-Routen sind reine Auth-Wrapper.
  • Block 11: EmbedCodeSnippet standalone direkt einbettbar.
  • Block 8.7: ChatbotFeedbackPage als Layout-Vorlage (Re-Implementation wegen separater Vue-Build-Pipelines).

1.2.0 — 2026-05-01

Customer-Wert-Tag. AVS-Feedback („CI-Anpassung möglich? Codeschnipsel im MS-System?") ist mit Block 11 eingelöst — Widget liest tenant- spezifisches Branding live, Custom-CSS und allowed_origins aus 8.6 wirken im Widget, Embed-Code-Snippet als Copy-Paste-Snippet für AVS-MS-System-Integration.

Block-11-Real-Aufwand: ~5–6h vs. Refined-Schätzung 14h (40 % Quote — besser als Block-8-Schnitt 60 %; Branding-Foundation aus 8.6 plus Audit-Helper aus TODO-14 zahlten direkt ein).

Highlights

Widget-Config-Endpoint mit Branding-Resolve

GET /api/v1/widget/config/{chatbot_id} (public, Origin-checked) liefert das nach Field-by-Field Chatbot→Tenant resolved Branding (§4.5/§12.2). custom_css bleibt operator-only-Boundary, allowed_origins erscheint nicht in der Response (Server-Side- Origin-Check). Diskretion: dasselbe 403 für „Origin nicht erlaubt" und „Chatbot nicht gefunden" (kein Discovery-Oracle).

Public-Feedback-Schreib-Pfad

POST /api/v1/feedback (public, Origin-checked, anonymous-actor Audit). write_platform_audit aus TODO-14 wurde um den Anonymous-Actor-Modus erweitert (ctx=None plus explizites actor_role/actor_user/tenant_id) — existing-Caller nutzen weiterhin ctx=ctx als Keyword-Argument, voll kompatibel.

Hybrid-Schreib-Pfad-Strategie (kein Cut-off)

Discovery zeigte: AVS-Demo-Schema ({query_id, rating, ...}) und Platform-Schema ({chatbot_id, ...}) sind nicht 1:1 mappbar. Statt Cut-off wurde Hybrid implementiert — Widget switcht via data-chatbot-id-Attribut auf Platform um. Existing AVS-Demo- Widget-Instanzen bleiben ohne Änderung funktional.

Audit-Action und KNOWN_ACTIONS

chatbot_feedback.created mit actor_role='anonymous', actor_user='widget'. In frontend/operator-ui/src/types/audit.ts ergänzt (TODO-Block-7-4-04-Anteil — restliche Drift bleibt offen).

Embed-Code-Snippet in beiden UIs

EmbedCodeSnippet-Komponente in Tenant-UI und Operator-UI (eigenständige Bundle-Builds → zwei Dateien). Integration auf Tenant-UI ChatbotDetailPage. Operator-UI-Komponente steht standalone bereit für künftige Operator-Chatbot-Page (Discovery- Datapoint: kein Per-Chatbot-View existing).

Schema-Migration 0009_feedback_widget_anonymous

feedback.session_id und feedback.message_id von NOT NULL auf nullable. Anonymous-Widget-Schreibungen ohne vorhandene Platform- Chat-Infrastruktur landen sauber. FKs greifen weiterhin sobald Werte gesetzt sind. Forward-kompatibel zu künftigen /query- Endpoints.

Test-Erweiterung

  • 13 neue pytest-Integration-Tests (tests/integration/test_widget_api.py)
  • 3 neue Vitest-Specs (EmbedCodeSnippet.spec.ts, je Tenant- und Operator-UI)
  • 7 neue Playwright-Specs (chatbot-sub-routes, widget-public-api, tenant-branding-sub-route) — skip-if-env-not-set-Pattern für CI-Resilienz

Drift-Schließungen

TODO-Konzept-01 — Block-11-Aufwand-Reconciliation

  • §17.2 + roadmap konsolidiert auf 14h Refined
  • Konzept v5.3.2 → v5.3.3
  • Phasen-Total ~425h → ~427h
  • §17.2a Reconciliation Nr. 6 dokumentiert
  • Erledigt vorab (eigener Run, Merge 9bcf36e)

TODO-Platform-15 — Playwright-E2E-Coverage

  • 7 neue Specs decken Sub-Routes (Tenant Branding/Feedback) und Operator-Branding ab
  • skip-if-env-not-set-Pattern
  • Erledigt mit Block 11 Phase 4

TODO-Block-7-4-04 — KNOWN_ACTIONS-Drift (Block-11-Anteil)

  • chatbot_feedback.created ergänzt
  • Restliche Block-Review-Familien-Drift bleibt offen für Block 13/14 oder Cleanup-Welle

Bekannte Drifts (für v1.x oder Cleanup-Welle)

  • TODO-Auth-NEU (M2/40, von v1.0.0): JWT-sub-Claim fehlt — Workarounds aktiv
  • TODO-Platform-11: Backend-Pytest-Env-Bootstrap (Trigger vor Block 14 CI)
  • TODO-Platform-12: Pytest-Profil-Trennung Platform vs AVS-Demo (Trigger vor Block 14)
  • TODO-Block-7-Review-Familien (7-1b, 7-2, 7-3, 7-NN, plus Block-5e/5f/5g, 4b): Cleanup-Niveau — Trigger Cleanup-Welle vor Block 13

Discovery-Datapoints (kein TODO)

  • Operator-UI hat keine Per-Chatbot-Page. Operator sieht Tenants aggregiert, aber kein Detail/Branding/Feedback pro Chatbot über Cross-Tenant-Sicht. EmbedCodeSnippet-Komponente steht standalone bereit. Backlog-Item für künftige Operator-UI-Erweiterung (Strategie-Pause Pfad E).

Nicht im Scope von v1.2.0

  • Block 8.5 (Sources-Management) — blockiert von Block 13 Konnektor-Framework
  • Block 13 (Konnektor-Framework, ~57h) — größter Tech-Hub, schaltet 8.5 frei
  • Cleanup-Welle für Block-7-Review-Familien — Aufwand-Schätzung pending Discovery
  • Operator-UI Per-Chatbot-Page — neuer Backlog-Datapoint aus Block 11

[1.1.0] — 2026-04-30

Block-8-Erweiterungs-Tag. Tenant-UI ist jetzt voll bedienbar — Foundation, Multi-Language-Templates-Clone, Self-Service Module- Toggle, Chatbots-CRUD + Wizard, Branding (Tenant + Chatbot + Operator-Override), Feedback-View. Plus Audit-Trail-Konsolidierung (TODO-Platform-14). Block 8.5 (Sources) bleibt blockiert von Block 13 (Konnektor-Framework) — nicht im v1.1.0-Scope.

Block-8-Real-Aufwand: ~30.5h vs. Refined-Schätzung 50–64h (60 % Quote). Pattern-Reife ab 8.4 sichtbar (8.6/8.7 unter 5h dank Sub-Route-Pattern aus 8.6 + Audit-Helper aus TODO-14).

Highlights

Tenant-UI funktional komplett (außer Sources)

  • Foundation (8.0): Module-Auto-Seed im Lifespan, Tenant-UI- Core-Routes (Dashboard/Templates/Modules/Profile-Read-Only).
  • UX-03 Tenant-Edit-UI (8.1, Verifikations-only): war bereits in Block 7.1b/7.4 implementiert, Re-Diagnose-Lesson dokumentiert.
  • Multi-Language-Templates (8.2): Clone-Workflow als Operator-Endpoint, einlöst UX-04-Sprach-Filter aus v1.0.0.
  • Self-Service Module-Toggle (8.3): Tenant kann external_eligible-Module selbst aktivieren/deaktivieren, is_always_on/internal_only-Gates server- und UI-seitig.
  • Chatbots-CRUD + Wizard (8.4): vollständiger Lifecycle inklusive Drei-Step-Wizard, Template-Snapshot, Slug + template_id unveränderlich.
  • Branding (8.6): Zwei-Stufen-Branding (Tenant + Chatbot) mit Sicherheits-Boundary (custom_css operator-only, allowed_origins Pattern-validiert).
  • Feedback-View (8.7): Read-Only-Übersicht pro Chatbot mit Stat-Cards und filterbarer Tabelle, Sub-Route-Pattern aus 8.6.

Audit-Trail-Konsolidierung (TODO-Platform-14)

template.created/updated/deactivated/cloned lückenlos via write_platform_audit-Helper aus Block 5, Single-Transaction mit Mutation, No-Diff-Idempotenz für Empty-Updates. Audit-Foundation für 8.3 (tenant_module.activated/deactivated), 8.4 (chatbot.*), 8.6 (tenant_branding.updated, chatbot_branding.updated).

Sicherheitsmodell-Schärfung

  • tenant_branding.custom_css operator-only — Tenant kann's nicht setzen, auch nicht über manipulierten Body (Pydantic-Schema- Trennung TenantBrandingUpdateTenant vs. TenantBrandingUpdateOperator).
  • allowed_origins Pattern-validiert (kein nackter *, https-only außer localhost, max 20).
  • Action-Naming-Differenzierung: actor_role differenziert Operator-vs-Tenant-Mutationen ohne Action-Inflation (8.3-Lesson).

Bekannte Drifts (für v1.1.x oder später)

  • TODO-Auth-NEU (M2/40, von v1.0.0): JWT-sub-Claim fehlt in beiden Realms. Workarounds aktiv. Fix in v1.x.
  • TODO-Platform-11: Backend-Pytest-Env-Bootstrap fehlt; Block-8-Tests laufen via docker cp + docker exec pytest. Saubere Lösung vor Block 14 (CI-Pipeline).
  • TODO-Platform-12: Pytest-Profil-Trennung Platform vs. AVS-Demo. Voller pytest tests/-Lauf produziert AVS-Demo-Test- Failures. Filter-Profil-Trennung vor Block 14.
  • TODO-Platform-15: Playwright-E2E-Coverage-Lücke für die in 8.6/8.7 angelegten Sub-Routes (/chatbots/:id/branding, /chatbots/:id/feedback, Operator /tenants/:id/branding). Vor Block 11 oder Cleanup-Welle vor Block 13.

Nicht im Scope von v1.1.0

  • Block 8.5 (Sources-Management): blockiert von Block 13 (Konnektor-Framework). Sources-Schema existiert seit Block 1, aber Routes + UI hängen am BaseConnector-Interface. Stub-Variante (~6h, fixer Upload-Konnektor) als Brücke möglich.
  • Block 11 (Widget-Integration): nächste Achse. Branding- Schreib-Pfade aus 8.6 sind Foundation. Block 11 baut Widget- Config-Endpoint mit Tenant→Chatbot-Fallback-Merge, CORS-Origin-Check, Embed-Code-Generator, Widget-Schreib-Pfad-Refactor (Feedback aktuell nach AVS-Demo, soll nach Platform).

Detaillierter Verlauf der Block-8-Welle

Die folgenden Sub-Einträge dokumentieren die einzelnen Sub-Block- Merges chronologisch absteigend. Sie waren bis zum v1.1.0-Tag unter [Unreleased] gepflegt — als kuratierte Audit-Spur erhalten.

Added (Block 8.7 — Feedback-View pro Chatbot, Merge 7280d33)

Read-Only-Feedback-Übersicht pro Chatbot für Tenant-Admins. Pattern- Quelle: AVS-Demo Admin-Feedback-View (Categories, DE-Labels, question/answer-Snapshot-Felder). Implementation in Tenant-UI mit Konzept-A-Color-Tokens. Block-8-Schluss-Sub-Block — nach diesem Merge sind 8.0/8.1/8.2/8.3/8.4/8.6/8.7 abgeschlossen; 8.5 bleibt blockiert von Block 13.

Discovery (2026-04-30):

  • Platform-Feedback-Schema existierte bereits (Block 1, RLS aktiv) — Phase 1a (Schema-Migration) entfiel.
  • Schreib-Pfad existiert nur in der AVS-Demo (src/avs_chatbot/api/routes/feedback.py); Platform-Tabelle wird erst durch die Widget-Integration in Block 11 befüllt. Bis dahin bleibt die View read-only und zeigt im Live-Setup einen Empty-State (oder die Mock-Daten aus dem Live-Smoke).
  • Tab-Pattern aus 8.6: keine Tabs in ChatbotDetailPage, Sub-Routes (/chatbots/:id/branding) etabliert. Block 8.7 spiegelt: /chatbots/:id/feedback.

Backend (3 neue Files):

  • models/feedback.py — Pydantic-DTOs FeedbackRead, FeedbackList, FeedbackStats. Literal-Typen für Rating und Categories synchron mit AVS-Demo (wrong_answer, incomplete, wrong_source, not_helpful, too_slow, other).
  • services/feedback_service.pyFeedbackService mit list_feedback_for_chatbot (Pagination + Filter) und get_stats (GROUP BY rating/category, Top-Kategorie unter negativem Feedback). Whitelist gegen Pydantic-Drift. ChatbotNotInTenant-Exception (Pattern aus BrandingService).
  • api/routes/feedback.py — Zwei GET-Endpoints unter /api/v1/tenants/me/chatbots/{cid}:
    • GET .../feedback?rating&category&limit&offset — paginierte Liste mit Regex-validierten Query-Params.
    • GET .../feedback/stats — Stat-Card-Aggregation. Read-only — kein POST/PATCH/DELETE. Tenant-Scope mit request_scoped_session(bypass_rls=False).

main.py: tenant_feedback_router zwischen chatbots_router und tenants_router registriert (Route-Reihenfolge-Disziplin — sonst wird me als UUID geparst).

Frontend Tenant-UI (4 neue Files + 2 Edits):

  • types/feedback.ts — Shapes + FEEDBACK_CATEGORY_LABELS_DE (DE-Labels-Map).
  • composables/useFeedback.ts — Read-only-Composable mit Filter/Pagination-State, ohne POST/PATCH.
  • pages/ChatbotFeedbackPage.vue (Route /chatbots/:id/feedback) — Header mit Chatbot-Name, vier Stat-Cards (Total / Positiv / Negativ / Top-Kategorie), Filter-Bar (Rating-Toggle + Category-Dropdown nur bei rating=negative), Tabelle mit gekürzter Frage/Antwort, Rating-/Category-Badges in Konzept-A-Tokens, Empty-State „Noch kein Feedback für diesen Chatbot vorhanden.", Pagination-Button („Weitere laden").
  • router/index.ts — neue Sub-Route registriert.
  • pages/ChatbotDetailPage.vue — Action-Bar mit „Feedback ansehen"- und „Branding bearbeiten"-Buttons (kein Tab- Pattern, weiter Sub-Routes).

Tests:

  • tests/integration/test_feedback_api.py — 9 Pytest-Cases (List, Filter rating/category, Pagination, Stats-Aggregation, Empty-Stats, Cross-Tenant-Isolation, Operator-Forbidden, Chatbot-Not-Found). Cleanup über chatbots-DELETE (FK-CASCADE auf chatbot_id) vor tenants-DELETE — chat_sessions hat keinen ondelete=CASCADE auf tenant_id. → 9/9 grün im Container (docker exec kora-platform-api pytest).
  • frontend/tenant-ui/src/composables/__tests__/useFeedback.spec.ts — 4 Vitest-Cases (Endpoint-Aufbau, Query-Params, 404, Stats).
  • frontend/tenant-ui/src/pages/__tests__/ChatbotFeedbackPage.spec.ts — 5 Vitest-Cases (Stat-Cards, Empty-State, Tabelle mit Truncation, Filter-Dropdown bei rating=negative, Pagination). → Vitest gesamt: 31/31 grün in 6 Files.

Live-Smoke (mit Mock-Daten):

  • 10 Feedback-Rows manuell auf bench-chatbot-000 geseedet (4× positive, 6× negative über 4 Categories). Direkter SQL-INSERT, weil der Schreib-Pfad in der Platform fehlt — Block 11 stellt das Widget um.
  • OpenAPI-Probe: beide Routes registriert (/api/v1/tenants/me/chatbots/{chatbot_id}/feedback{,/stats}).

Stop-Trigger-Anwendung:

  • TODO-Platform-11 (Pytest-Env-Bootstrap): Tests laufen via docker cp + docker exec pytest, weil das Image keine /app/tests/ enthält. Workaround unverändert seit 8.4/8.6 — Bootstrap kommt vor Block 14 (CI).
  • Playwright: kein E2E-Test, weil das Sub-Route-Pattern aus 8.6 ebenfalls keinen Playwright-Sweep hatte. Vitest+Live-Smoke reichen für Block 8.7.

Added (Block 8.6 — Branding (Tenant + Chatbot + Operator-Override), Merge 171349024d678520bfef1ef8da84889999ad888e)

Zwei-Stufen-Branding mit klarer Sicherheits-Boundary: Tenant-Self darf alle visuellen Felder + allowed_origins setzen, aber kein custom_css. Operator-Override-Pfad enthält custom_css (XSS/UI- Redressing-Risiko, Konzept §12.4). Chatbot-Override-Schicht erlaubt pro-Chatbot-Anpassungen mit Fallback auf Tenant-Defaults — Foundation für Block 11 (Widget-Config-Resolver).

Direkter Customer-Wert: Antwort auf AVS-Walkthrough-Feedback „CI-Anpassung möglich?".

Backend (4 Files):

  • models/branding.py (neu) — Pydantic-DTOs mit Felder- Whitelist-Trennung: TenantBrandingUpdateTenant (kein custom_css), TenantBrandingUpdateOperator (mit), ChatbotBrandingUpdate (= TenantUpdate ohne custom_css), Read-Schemas. Origin-Validator rejected *, Multi-Wildcards, http-non-localhost. Hex-Color- Validator ^#rrggbb$.
  • services/branding_service.py (neu) — BrandingService mit UPSERT-Logic (Branding-Stub wird beim ersten PATCH erstellt — 0 Rows existing per Discovery). _diff_and_apply-Helper mutiert Instance + liefert Audit-Diff. HttpUrl→str-Coerce für SQLAlchemy-Text-Spalten.
  • api/routes/branding.py (neu) — 6 Endpoints in 2 Routern:
  • GET/PATCH /api/v1/tenants/me/branding (Tenant-Self, ohne custom_css im Response)
  • GET/PATCH /api/v1/tenants/me/chatbots/{cid}/branding (Chatbot-Override, Tenant-Self)
  • GET/PATCH /api/v1/operator/tenants/{tid}/branding (Operator-Override mit custom_css)
  • main.pytenant_branding_router + operator_branding_router registriert. Route-Reihenfolge-Disziplin (8.4-Lesson): tenant_branding_router VOR tenants_router (sonst captured /me/branding als UUID).

Audit-Pattern:

  • tenant_branding.updated mit actor_role='tenant' ODER 'operator' (gleicher Action-Name, Differenzierung über actor_role analog Block 8.3-Toggle-Pattern).
  • chatbot_branding.updated (immer actor_role='tenant', weil Operator nutzt eigene Tenant-Pfade nicht für Chatbot-Override).
  • No-Audit-Idempotenz bei Empty-Diff (Pattern aus TODO-14).

Frontend Tenant-UI (5 neue Files):

  • types/branding.ts, composables/useBranding.ts (zwei Composables: useTenantBranding() + useChatbotBranding(id)).
  • components/BrandingForm.vue — wiederverwendbares Form-Component mit ColorPicker (<input type="color"> + Hex-Input), Logo-URL + Live-Preview-IMG, Texten, Origin-Liste mit Add/Remove + Pattern- Validation. Kein generischer ColorPicker im Bestand → native HTML.
  • pages/TenantBrandingPage.vue (Route /branding) — vollständiger Editor.
  • pages/ChatbotBrandingPage.vue (Route /chatbots/:id/branding) — selbe Felder + Fallback-Hinweis-Banner.
  • Sidebar: „Branding" aus „In Vorbereitung" in „Verwaltung" verschoben.

Frontend Operator-UI (4 neue Files):

  • types/branding.ts (mit custom_css), composables/useOperatorTenantBranding.ts.
  • pages/TenantBrandingPage.vue (Route /tenants/:id/branding) — inline-Form mit eigener Origin-Liste-Logic (kein Re-Use vom Tenant-UI-BrandingForm wegen unterschiedlicher Composable-Imports und custom_css-Section).
  • TenantsDetailPage: neuer „Branding"-Button neben Bearbeiten/Löschen, navigiert zur neuen Sub-Route.

Live-Smoke verifiziert (alle 6 Endpoints):

  • GET /tenants/me/branding (initial, leer) → 200 mit allen NULLs.
  • PATCH /tenants/me/branding mit {primary_color, logo_url, widget_title, allowed_origins=[https://...,https://*.avs.example.com]} → 200 + audit tenant_branding.updated (actor_role=tenant).
  • PATCH ... allowed_origins=["*"] → 422 (Validierung).
  • PATCH ... primary_color="red" → 422.
  • PATCH /operator/tenants/{tid}/branding mit custom_css → 200 + audit tenant_branding.updated (actor_role=operator).
  • PATCH /tenants/me/branding mit custom_css im Body → 200 (das Feld wird gestripped, kein Effekt auf DB-Wert) ✓
  • PATCH /tenants/me/chatbots/{cid}/branding → 200 + audit chatbot_branding.updated.
  • Cross-Tenant: Tenant-B GET /tenants/me/chatbots/{tenant_a_chatbot_id}/branding → 404 (RLS-Subquery auf chatbots.tenant_id).
  • DB-Verifikation: tenant_branding.custom_css ist 34 Zeichen lang (vom Operator-PATCH gesetzt, NICHT vom Tenant-Self-Versuch).

Tests:

  • Backend: existing pytest 56/56 grün, keine Regression durch neue Routes.
  • Operator-UI Vitest 132/132, Build 117.71 kB raw / 45.16 kB gzip.
  • Tenant-UI Vitest 22/22, Build 110.43 kB raw / 42.62 kB gzip.
  • Mkdocs Strict-Build: Exit 0.
  • Pytest-Integration-Tests für die neuen 6 Branding-Routes sind Block-14-Scope (TODO-Platform-12 Pytest-Profil-Trennung erleichtert das).

Sicherheits-Modell:

  • Field-Whitelist via Pydantic-Schema-Trennung. Tenant-Self-Schema hat custom_css nicht; selbst wenn der Client das Feld mitsendet, strippt Pydantic es (extra='ignore'-Default).
  • allowed_origins validiert: nur https://[*.]domain.tld[:port] oder http://localhost[:port], max 20 Origins, keine Duplikate, kein nackter *.
  • chatbot_branding-RLS via Subquery-Policy (chatbot_id IN (SELECT chatbots.id ...)); Cross-Tenant-Read fail-closed mit 404.

Foundation für Block 11:

  • Schreib-Pfade stehen vollständig.
  • Block 11 (Widget-Config-Endpoint) muss bauen: GET-Resolver der Chatbot-Branding über Tenant-Branding mergt (Field-by-Field-Fallback), CORS-Middleware-Erweiterung mit allowed_origins-Check, Embed- Code-Generator. Schemas sind via *BrandingRead-DTOs ready.

Aufwand: ~5h (geschätzt 6–8h, unter-Korridor). Pattern-Reife aus 8.4 + Audit-Foundation TODO-14 spart Zeit; UPSERT-Logic war kompakt durch Pydantic-exclude_unset.

Added (Block 8.4 — Chatbots-CRUD + Wizard, Merge 0a653627463297da95f0eb42165af76fff2f5069)

Tenant-UI bekommt vollständigen Chatbot-Lifecycle: Liste, Wizard- basierter Create-Pfad mit Template-Auswahl, Edit-Page und Soft-Delete. Foundation für Block 8.6 (Branding pro Chatbot) und 8.7 (Feedback pro Chatbot). Existing Endpoints (List per /tenants/{id}/chatbots, Detail + Template-Update-Apply) bleiben unverändert; 8.4 fügt Tenant-Self-Routes hinzu.

Backend:

  • src/kora_platform/models/chatbot.py (neu) — Pydantic-DTOs ChatbotCreate (slug-validated, template_id required, language Literal[de/en]), ChatbotUpdate (slug + template_id immutable weggelassen — Pattern aus TenantUpdate), ChatbotRead mit vollständigen Felder-Mapping.
  • src/kora_platform/services/chatbot_service.py (neu) — neuer ChatbotService neben dem existing ChatbotLifecycle (Block 4):
  • list_for_tenant, get_for_tenant (mit Soft-Delete-Filter)
  • create_chatbot(tenant_id, payload, qdrant_mgr) mit Slug-Konflikt-Check, Template-Snapshot (delegated an ChatbotTemplateService.snapshot_template_into_chatbot), Qdrant-Collection-Provisioning via QdrantManager.ensure_collection (idempotent)
  • update_chatbot mit model_dump(exclude_unset=True)-Patch
  • changed_fields(before, payload) static method (Pattern aus TODO-14: Audit-Diff only-if-changed)
  • src/kora_platform/api/routes/chatbots.py (neu) — neuer Router /api/v1/tenants/me/chatbots mit GET/POST/GET/{id}/PATCH/DELETE. tenant_scope_required-Gate, request_scoped_session(tenant_id=..., bypass_rls=False) für RLS-enforced Cross-Tenant-Schutz. Audit-Actions chatbot.created, chatbot.updated, chatbot.deactivated mit actor_role='tenant'. No-Audit-Idempotenz bei leerem PATCH-Diff. Soft-Delete inline (deleted_at = now(UTC)) — ChatbotLifecycle.soft_delete ist auf admin_session/own commit ausgelegt, das passt nicht zum RLS-tenant-scoped-Pattern.
  • Router-Reihenfolge in main.py: chatbots_router muss VOR tenants_router registriert werden, weil das existing /tenants/{tenant_id}/chatbots-Pattern den me-Pfad als UUID parsen würde (422). Pattern-Konflikt durch deklarative Reihenfolge gelöst.

Frontend (Tenant-UI):

  • types/chatbot.ts (neu) — ChatbotRead, ChatbotCreatePayload, ChatbotUpdatePayload-Interfaces.
  • composables/useChatbot.ts (neu) — useChatbots() für List+Create, useChatbot(id) für Get+Update+Deactivate.
  • pages/ChatbotsListPage.vue (neu) — Tabelle mit Display-Name, Slug, Template, Sprache, Erstellt. Suche, Empty-State, Click-Row → Detail.
  • pages/ChatbotsCreatePage.vue (neu) — 3-Step Single-Page- Wizard ohne separaten Stepper-Component. Step 1: Template-Karten- Grid mit Auswahl. Step 2: Stammdaten-Form mit Slug-Validation (regex-basiert, mirror Backend). Step 3: Review mit <dl>-Liste + Create-Submit. 409-Handling springt zurück zu Step 2; 404 (Template inaktiv) springt zu Step 1.
  • pages/ChatbotsEditPage.vue (neu) — Pattern aus TenantsEditPage gespiegelt: Slug + Template disabled mit Tooltips, Dirty-State-Logic, only-changed-fields im PATCH-Body.
  • router/index.ts — 4 neue Routes: /chatbots, /chatbots/new, /chatbots/:id (existing, unverändert), /chatbots/:id/edit.
  • components/AppSidebar.vueChatbots-Item aus „In Vorbereitung Block 8.x" in Group „Verwaltung" verschoben (zwischen Templates und Module).

Pattern-Etablierung:

  • 3-Step-Wizard als Single-Page mit Step-State ist neu im Repository — pragmatische Variante ohne Multi-Route-State- Persistenz. Vorlage für künftige Wizards (Block 8.5 Sources? Block 8.6 Branding?).
  • Audit-Pattern aus TODO-14 + 8.3 wiederverwendetwrite_platform_audit direkt, changed_fields-Helper im Service, No-Audit-Idempotenz bei Empty-Diff. Kein Re-Invent.
  • Slug-Unveränderlichkeit als wiederholte Konvention (Tenants → Templates → Chatbots) — Audit-Konsistenz und qdrant_collection- Stabilität.

Live-Smoke verifiziert:

  • POST /api/v1/tenants/me/chatbots mit Tenant-A-Token + valid template_id → HTTP 201 + neuer Chatbot mit gesnapshottetem System-Prompt + qdrant_collection-Wert + audit chatbot.created.
  • PATCH .../{id} (display_name + language) → HTTP 200 + audit chatbot.updated mit before/after-Diff.
  • PATCH .../{id} mit identischen Werten → HTTP 200, kein audit-Eintrag (No-Audit-Idempotenz).
  • DELETE .../{id} → HTTP 204 + deleted_at gesetzt + audit chatbot.deactivated.
  • Cross-Tenant-Schutz: Tenant-B-Token GET auf Tenant-A-Chatbot → HTTP 404 (RLS).
  • Operator-Token GET → HTTP 403 (tenant_scope_required).
  • Audit-Log zeigt actor_role='tenant', actor_user='admin-bench-tenant-a'.

Tests:

  • Backend Pytest 56/56 (test_chatbot_template_service 21, test_module_service 28, test_operator_tenants_api 7) — keine Regression durch Router-Reordering.
  • Tenant-UI Vitest 22/22 (existing Tests, neue Pages-Tests sind optional follow-up — Smoke-Coverage durch Live-Tests + Build- Erfolg).
  • Tenant-UI Build: 109.56 kB raw / 42.34 kB gzip. 3 neue Pages zusammen ~19 kB raw.
  • Mkdocs Strict: Exit 0.

Out-of-Scope:

  • Branding pro Chatbot (Block 8.6).
  • Sources-Management (Block 8.5, blockiert von Block 13).
  • Feedback-View (Block 8.7).
  • Optimistic-Updates beim Edit (Re-Fetch-Pattern reicht für 8.4).
  • Pytest-Integration-Tests für die neuen Routes (TODO-Platform-12- Pytest-Profil-Trennung würde das vereinfachen, bleibt monitored).

Aufwand: ~9h (im 12–16h-Korridor). Pattern-Reife aus 8.0–8.3 und Audit-Foundation TODO-14 spart Zeit; Wizard-Etablierung war kompakter als geschätzt durch Single-Page-Variante.

Added (Block 8.3 — Self-Service Module-Toggle, Merge 1c8e656e8d18e8df0d8c144d7412f11c1d8063be)

Tenant kann zugewiesene external_eligible-Module selbst aktivieren und deaktivieren. Operator behält die assign/revoke-Hoheit (Eintrag in tenant_modules erstellen/löschen); Tenant flippt nur den is_enabled-Boolean einer existierenden Zuweisung.

Backend:

  • src/kora_platform/services/module_service.py — neue Methode tenant_self_set_enabled(tenant_id, module_id, is_enabled). Gibt (tenant_module, changed) zurück; changed=False bei No-Op (bereits im Zielzustand). Validierungen: ModuleNotFound wenn Modul nicht existiert, ModuleNotAssignable für core (immer- on) oder internal_only (nicht für Tenants), neue Exception TenantModuleNotAssigned wenn Tenant das Modul nicht zugewiesen hat.
  • src/kora_platform/api/routes/tenant_modules.py — zwei neue Routes:
  • POST /api/v1/tenants/me/modules/{id}/activate → 200 / 404 / 403 / 409
  • POST /api/v1/tenants/me/modules/{id}/deactivate → 200 / 404 / 403 / 409
  • Beide via _tenant_self_toggle-Helper, tenant_scope_required- Gate, request_scoped_session(tenant_id=..., bypass_rls=False) (RLS-enforced, SET LOCAL app.current_tenant_id).
  • Audit-Actions tenant_module.activated / tenant_module.deactivated mit actor_role='tenant' (aus ctx.scope automatisch). Kein _by_tenant-Suffix — Differenzierung Operator-vs-Tenant über actor_role-Filter im Audit-Trail. Kein Audit-Eintrag bei No-Op (changed=False) für No-Audit-Idempotenz analog zu TODO-14 Empty-Diff-Pattern.
  • /api/v1/tenants/me/modules-Response erweitert: neue Felder is_always_on (für Frontend-Toggle-Disabled-State) und is_assigned (für Frontend-Hinweis bei nicht-zugewiesenen Modulen).

Frontend:

  • frontend/tenant-ui/src/pages/ModulesPage.vue — Read-Only- Badge durch native <input type="checkbox" role="switch"> ersetzt. Konzept-A-Styling (38px-Track, 16px-Thumb, accent-color bei checked). Per-Modul-Loading-State (togglingIds: Set<string>) — nur die getoggelte Zeile zeigt Spinner, andere bleiben interaktiv. Inline-Error-Hinweis pro Zeile bei 409/403/404. Re- Fetch nach Toggle (kein Optimistic-Update — Drift-Risiko bei Race-Conditions, Re-Fetch ist günstig).
  • Disabled-State: is_always_on=true → Toggle disabled mit Tooltip „Core-Modul, immer aktiv (nicht deaktivierbar)". Plus Defense-in-depth: !is_assigned → disabled mit Tooltip „bitte Operator kontaktieren".

Tests:

  • Pytest 28/28 grün (test_module_service.py, 7 neue Tests: deactivate-after-assign, reactivate, no-change-idempotenz, core-rejected, internal_only-rejected, unassigned-module, unknown-module).
  • Vitest 22/22 grün (tenant-ui/ModulesPage.spec.ts, 6 neue Tests: Toggle-rendering, is_always_on-disabled, !is_assigned- disabled, Activate-Klick, Deactivate-Klick, 409-Inline-Error).

Live-Smoke:

  • bench-tenant-a Tenant-A-Token: ticket_escalation-Module
  • Activate (already-active) → 200 + changed: false (kein Audit).
  • Deactivate → 200 + changed: true + Audit-Eintrag tenant_module.deactivated mit actor_role='tenant', actor_user='admin-bench-tenant-a', before/after-Diff.
  • Activate (after deactivate) → 200 + changed: true + Audit- Eintrag tenant_module.activated.
  • Deactivate chatbot (core) → 409 ✓.
  • Operator-Token → 403 (Tenant-Self-Endpoint, Operator nutzt eigene Route).
  • Unassigned-Module (confluence) → 404 ✓.

Pattern-Disziplin:

  • Audit aus dem Heft: Pattern aus TODO-14 + Block 8.2 direkt übernommen — write_platform_audit, actor_role-Differenzierung, No-Audit-Idempotenz. Kein Re-Invent.
  • Defense-in-depth: internal_only-Module sind in 8.0-Liste schon ausgefiltert, Backend-Toggle prüft trotzdem nochmal — 403 bei Direct-Endpoint-Call.

Aufwand: ~4h (geschätzt 6–8h, unter-Korridor — Audit-Pattern aus TODO-14 spart Helper-Bau, Service-Methode war kleiner als geschätzt weil existing assign/revoke-Pattern reuse-bar).

Added (TODO-Platform-14 — Template-Audit-Trail-Konsolidierung, Merge 44ecf067d0d11ce3de9beb2851533ed3de1dffac)

Drift-Schließung vor Block 8.4 (Chatbots-CRUD). Audit-Trail-Lücke aus Block-8.2-Discovery geschlossen: Template-Create/Update/Deactivate schreiben jetzt platform_audit_log-Einträge analog Tenants/Modules. template.cloned aus Block 8.2 ergänzt damit zur vollständigen Template-Audit-Surface.

  • Audit-Actions: template.created, template.updated (only-if-diff mit before/after-Delta), template.deactivated. Plus existing template.cloned.
  • Service: ChatbotTemplateService.changed_fields(before, payload) als statische Methode, Pattern aus TenantService.changed_fields gespiegelt. Liefert (before_delta, after_delta) nur mit tatsächlich geänderten Feldern.
  • Routes umgestellt: admin_session()request_scoped_session(tenant_id=None, bypass_rls=True) für Single-Transaction Mutation + Audit. Vor TODO-14 nutzten 3 von 4 Mutations admin_session ohne Audit-Hook.
  • Helper: write_platform_audit aus _platform_audit.py wiederverwendet (kein template-spezifischer Helper).
  • Re-Aktivierung (PATCH is_active=true) ist template.updated- Diff, kein eigener Action-Name — lesbarer Audit-Trail ohne Action-Inflation.
  • Vollständigkeits-Garantie: mit diesem Merge hat jede Operator- Template-Mutation eine Audit-Spur. Block 8.4 (Chatbots-CRUD) übernimmt Pattern direkt.

Live-Smoke: Create+Update+Deactivate auf smoke14-Test-Template, 3 audit-log-Einträge mit korrekten Details verifiziert, Smoke-Template aufgeräumt.

Tests: 2 neue Service-Unit-Tests (changed_fields_returns_only_diff, changed_fields_empty_diff_when_no_changes). Gesamt test_chatbot_template_service.py 21/21 grün, keine Regression.

Aufwand: ~1.5h (im 2–3h-Korridor).

Added (Block 8.2 — Multi-Language-Templates Clone-Workflow, Merge 8c5a37db1e4407aa70bc2dd31ce4fd3eca56d781)

UX-04-Hand-off-Schluss: Sprach-Filter in Templates-Liste war seit v1.0.0 live als „Vorbereitung für Block 8.2". Block 8.2 löst die Vorbereitung mit einem Clone-Convenience-Workflow ein. Operator klont ein Template in eine andere Sprache (Slug + Sprache + optional Display-Name) — Inhalts-Felder werden 1:1 kopiert.

Backend:

  • src/kora_platform/models/chatbot_template.py — neues Pydantic-Schema ChatbotTemplateCloneRequest (slug-validated new_id, target_language, optional display_name).
  • src/kora_platform/services/chatbot_template_service.py — neue Methode clone_template(source_id, request) mit Source- Lookup, Konflikt-Check auf new_id, Field-Copy. Returnt (clone, source_language) für Audit-Detail-Logging der Route.
  • src/kora_platform/api/routes/chatbot_templates.py — neuer Endpoint POST /api/v1/operator/templates/{template_id}/clone, Status 201 / 404 / 409 / 403 / 401. Audit-Action template.cloned mit details={"source_template_id", "source_language", "target_language", "new_id", "display_name"}. Nutzt request_scoped_session(bypass_rls=True) für Single-Transaction- Mutation + Audit.
  • Pytest: 5 neue Unit-Tests in test_chatbot_template_service.py (Field-Copy + Sprach-Wechsel, Custom-Display-Name, Source-NotFound, ID-Konflikt, Same-Language-Duplikat). Gesamt 19/19 grün.

Frontend:

  • frontend/operator-ui/src/composables/useTemplate.ts — neue clone()-Methode + TemplateClonePayload-Type.
  • frontend/operator-ui/src/pages/TemplatesClonePage.vue (neu) — Inline-Page (Variante B; kein generischer Form-in-Modal-Component im UI-Bestand). Pre-Fill-Logic: Source de → Default Target en (und umgekehrt), new_id = {source_id}_{target_language}. Slug- Validation via existing validateTemplateId. 409-Handling für ID-Konflikt mit Inline-Error.
  • frontend/operator-ui/src/router/index.ts — neue Route /templates/:id/clone.
  • frontend/operator-ui/src/pages/TemplatesDetailPage.vue — Clone-Button („In andere Sprache klonen") in Action-Bar zwischen Bearbeiten und Deaktivieren.
  • Vitest: 4 neue Tests in TemplatesClonePage.spec.ts (Pre-Fill de→en, Pre-Fill en→de, Quell-Input disabled, Submit-Payload). Gesamt 132/132 grün.

Live-Smoke verifiziert:

  • POST /api/v1/operator/templates/kurverwaltung/clone mit {"new_id": "kurverwaltung_en", "target_language": "en"} → HTTP 201 mit korrekt geklontem Template.
  • platform_audit_log-Eintrag: action=template.cloned, entity_id=kurverwaltung_en, details={"source_template_id": "kurverwaltung", "source_language": "de", "target_language": "en", "new_id": "kurverwaltung_en", "display_name": "Kurverwaltung"}.
  • Test-Clone nach Verifikation aufgeräumt (DELETE).

Discovery-Befund (TODO-Platform-14 angelegt): Existing Template- Mutations (Create/Update/Deactivate) schreiben keinen Audit-Log- Eintrag — Audit-Trail-Lücke. Block 8.2 implementiert template.cloned isoliert (Scope-konform), TODO-Platform-14 dokumentiert die Lücke und empfiehlt Fix vor Block 8.4 mit template.created/updated/deactivated- Audit-Actions. Stop-Trigger 4 ausgelöst, kein Spontan-Fix.

Aufwand: ~4h (im 4–6h-Korridor des Refined-Roadmap-Schätzers).

Docs (Block 8 Roadmap-Konkretisierung — Discovery-First)

Block 8 in roadmap.md von „~44h-Pauschalschätzung mit unspezifizierten Sub-Blocks" auf Discovery-getriebene Sub-Block-Aufteilung 8.0–8.7 umgestellt. Reine Doku-Arbeit, kein Code.

  • Sub-Block-Tabelle mit Status, Aufwand-Range und Discovery-basierten Existing/Offen-Sektionen pro Sub-Block.
  • Erledigt-Status mit Merge-Hash dokumentiert: 8.0 (a6081d8) und 8.1 (fd7e5be) als Audit-Spur.
  • Refined-Aufwand-Total: ~46–60h Rest (vs. Konzept-Versprechen 44h gesamt, davon ~4h schon erledigt). Drift dokumentiert: Chatbots- Wizard (8.4) ist größerer Brocken (~12–16h) als Konzept-Punkt- Schätzung suggerierte; Sources (8.5) hat hard-dependency auf Block 13 — Empfehlung im Roadmap-Eintrag, 8.5 nach Block 13 zu schieben oder mit Stub-Variante (~6h) auszuführen.
  • Discovery-Befunde: Schemas für chatbots, chatbot_sources, tenant_branding, chatbot_branding, feedback existieren vollständig (Block-1-Datenmodell-Vorarbeit), aber 0 Routes / 0 UI für 8.5/8.6/8.7. 8.2 ist Convenience-Convenience-Workflow (Clone- in-Other-Language), Schema/CRUD bereits da. 8.3 ist tenant-self- Toggle-Endpoint-Erweiterung der existing operator-Pfade.
  • Lessons-Notiz in roadmap.md festgehalten: Discovery-First- Pattern, Schema vorhanden ≠ Feature, Hard-Dependencies sichtbar machen, Konzept-Drift normal, Audit-Spur > Plan-Theater.

Verified (no code change — Block 8.1 / TODO-UX-03 was already implemented)

Block 8.1 enthielt keine Implementation. Discovery zeigte vollständige Tenant-Edit-UI seit Block 7.1b/7.4: PATCH-Endpoint, TenantUpdate- Schema, TenantsEditPage, Detail-Page-Edit-Button, Audit-Log-Action tenant.updated mit details.before/after-Diff. TODO-UX-03 war im UX-04-Block unter der ungeprüften Annahme einer fehlenden UI in Block 8 verschoben worden.

  • Live-Smoke verifiziert: PATCH 200, Audit-Log-Row mit actor_user=bench-operator-admin, actor_keycloak_id=NULL (TODO-Auth-NEU-Workaround), korrektem details.before/after-Diff.
  • Tests grün: Backend Pytest 43/43 (operator-tenants + tenant- service + audit-service), Vitest TenantsEditPage 3/3, Playwright tenants-crud 2/2 (happy-path login→list→create→detail→edit→delete).
  • Bench-Demo-Daten gepflegt: bench-tenant-a/-b haben jetzt contact_email über direktes SQL — kein Audit-Pollution.
  • TODO-UX-03 archiviert mit Re-Diagnose-Lesson für künftige Block-Moves: Status-Annahmen über existing Code via find/grep verifizieren, bevor TODO-Migrations entschieden werden.
  • Schema-Erweiterung verworfen: language auf tenants bleibt konzept-konform abwesend (Sprach-Achse auf Content-Level). contact_phone/contact_address ohne dokumentierten User-Story- Bedarf nicht ergänzt.

Aufwand: ~30min (Verifikation + Doku, kein Code-Change).

Added (Block 8.0 — Tenant-UI Foundation + Module-Auto-Seed, Merge a6081d85a649e70622a7a3f04e6e9cf48bcea6d2)

Erster Block-8-Subblock seit v1.0.0-Tag. Foundation für die nächsten 8.1/8.2/8.3-Subblöcke gelegt. TODO-Platform-13 mit erledigt.

Tenant-UI Read-Only-Pages:

  • /templates — Liste der aktiven Templates aus /api/v1/tenant/templates, Sprach-Label-Mapping, Version-Counter, Empty-State.
  • /modules — Liste aller nicht-internen Module mit Aktivierungs-Status-Badge aus /api/v1/tenants/me/modules (neu). Scope-Labels (Kern/Extern aktivierbar/Intern).
  • /profile — Read-Only-Stammdaten (Slug, Display-Name, Status, Tenant-ID, Angelegt). Edit-Button disabled mit Block-8.1-Hinweis.
  • Sidebar in 4 Groups restrukturiert (Übersicht / Verwaltung / Konto / In Vorbereitung Block 8.x). Pattern aus Operator-UI gespiegelt.

Backend:

  • src/kora_platform/main.py — Lifespan-Hook ruft ensure_seed_modules() nach init_engines() idempotent auf. Failure ist non-fatal (kein Container-Crash-Loop). Logger-Eintrag module_seed_completed inserted=N. TODO-Platform-13 erledigt.
  • src/kora_platform/api/routes/tenant_modules.py — neuer Endpoint GET /api/v1/tenants/me/modules (Tenant-Self-Read). Auth-Matrix: tenant=200, operator=403, vendor=403, unauth=401.
  • src/kora_platform/services/module_service.py — neue Methode list_all_modules_with_tenant_status(tenant_id, include_internal) mit LEFT-JOIN-Variante (alle Module + tenant-Status), filtert internal_only-Scope für Tenant-View.

Validierung:

  • Backend Pytest 211/211 (kora-platform-Pfad)
  • Tenant-UI Vitest 16/16, Build 107.08 kB / 41.50 kB gzip
  • Operator-UI Vitest 128/128 (keine Regression)
  • Live-Smoke Module-Seed: Erst-Start inserted=4, Restart inserted=0 (Idempotenz bestätigt)
  • verify-auth-stack.sh 57/59 (TODO-Auth-NEU monitored)
  • Mkdocs Strict-Build Exit 0

Out-of-Scope: UX-03 Tenant-Edit-UI bleibt Block 8.1. Multi-Language-Templates-Workflow bleibt Block 8.2. Provisioning- Self-Service (Tenant aktiviert/deaktiviert eigene Module) bleibt Block 8.3.

[1.0.0] — 2026-04-29

Phase B (Polish & Hardening) abgeschlossen. Erster offizieller Tag der kora-Platform. Predecessor: Phase A v0.x lebte im AVS-Demo-Pfad (main-Branch, separates CHANGELOG.md im Repo-Root).

Highlights

Color-System & Frontend

  • Konzept-A-Color-System (Slate/Gray, AAA-bevorzugt, Long-Session- tauglich) live in operator-ui + tenant-ui mit 27 Tokens × 3 Modes × 2 Surfaces.
  • Sidebar-First-Navigation mit Group-Sections, Inline-SVG-Icons, Footer-User-Menu (person-icon + Theme-Toggle + Logout).
  • Theme-Toggle Sun → Moon → Auto-Cycle, Backend-persistiert via user_preferences-Tabelle (Composite-PK Realm+Username — siehe TODO-Auth-NEU für Background).
  • Hover-Token-Verstärkung (8% Surface, 30% Border) für klar erkennbare Interaktivität.
  • TODO-UX-01: Status-Konsistenz Tenant-Liste/Detail durch util formatTenantStatus.
  • TODO-UX-04: Sprach-Filter in Templates-Liste (clientseitig, Vorbereitung für Multi-Language ab Block 8).

Drift-Aufräum-Vektor (10 Datenpunkte: 9 fixed, 1 monitored)

  • TODO-Platform-04: Iptables-Setup auf Remote-vLLM-Node (192.168.0.223) als idempotentes Skript + systemd-Service + Runbook codifiziert.
  • TODO-Platform-05/06/08/09: Auth-Stack-Drifts (auth.kora- Subdomain, Operator-UI-Auth-URL, OIDC-Standard-Scopes, systematische Verifikation mit 59-Check-Script).
  • TODO-Platform-10: Mkdocs-Inotify-Bug eliminiert — mkdocs serve durch vorgebauten Static-Site mit nginx:alpine ersetzt.
  • TODO-UX-02: Test-Daten-Pollution-Cleanup-Skript mit Pre-Flight-NO-ACTION-FK-Check, Audit-Log unangetastet.
  • TODO-Auth-NEU (M2/40): JWT-sub-Claim fehlt in beiden Realms — als Drift-Frühwarnung dokumentiert (Check 58a/58b in verify-auth-stack.sh), Workarounds in Production aktiv. Fix in v1.x.

Tooling & Operations

  • scripts/cleanup-test-data.sh mit --include-bench, --include-test-Flags (op-vendor-Pollution).
  • make docs-kora-deploy als statisches Auto-Deploy.
  • scripts/verify-auth-stack.sh mit 59 Checks über 6 Layer (Realm/Client/User/Token/API/Drift).
  • infra/remote-node/docker-firewall.sh als idempotentes iptables-Setup mit Delete-then-Insert-Position-1-Pattern.
  • docs-kora/docs/deployment/docs-deployment.md als Runbook für den neuen Build-Static-Pattern.
  • scripts/smoke-color-system.sh für User-Preferences-API.

Bekannte Drifts (für v1.x)

  • TODO-Auth-NEU (M2/40): JWT-sub-Claim fehlt in beiden Realms (kora-platform, kora-tenants). Workarounds in Production aktiv: platform_audit_log.actor_keycloak_id ist nullable, user_preferences nutzt Composite-PK (realm, username) statt UUID. Drift-Frühwarnung über verify-auth-stack.sh Check 58a/58b. Fix kommt mit Realm-Mapper-Erweiterung in v1.x.

Detaillierter Verlauf der Phase-B-Welle

Die folgenden Sub-Einträge dokumentieren die einzelnen Mini-Runs chronologisch absteigend. Sie waren bis zum v1.0.0-Tag unter [Unreleased] gepflegt — als kuratierte Audit-Spur erhalten.

Added (TODO-UX-04 — Sprach-Filter Templates-Liste; TODO-UX-03 nach Block 8 verschoben, Merge 1110a214dcf57310409aa08cf554617ee71e7aac)

Sprach-Filter in der Operator-UI Templates-Liste. Filter ist heute strukturell sinnvoll, funktional „tot" — alle live-Templates sind de. Vorbereitung für Multi-Language-Templates ab Block 8.

Discovery-Kontext: Der ursprüngliche Prompt zielte auf eine Tenant-Liste-Sprach-Filter-Variante mit Migration tenants.language. Discovery zeigte: kein language-Feld auf tenants, aber existing chatbot_templates.language (VARCHAR(5), Default de). Plan korrigiert auf TODO-UX-04-wie-im-Repo (Templates-Filter, Szenario i, keine Migration). Discovery-First- Pattern hat den falschen Plan verhindert.

  • frontend/operator-ui/src/pages/TemplatesListPage.vue — Dropdown „Sprache" (Alle / Deutsch / Englisch) neben „Inaktive anzeigen". Dynamische Empty-Title/Empty-Body wenn Filter keine Treffer hat. Footer-Note erweitert um Sprach-Filter-Status.
  • frontend/operator-ui/src/composables/useTemplates.tsapplySearch()applyFilters(), setLanguage-Setter, watch auf language triggert clientseitige Re-Filterung (kein Backend-Re- Fetch wie bei search).
  • frontend/operator-ui/src/types/template.tsTemplateListParams.language?: string ergänzt.
  • Filter-Strategie: clientseitig analog zu search (Templates- Volumen ≪ 100, Backend liefert bare list).
  • Sprach-Werte: 2-Buchstaben-Codes (de, en). Schema ist VARCHAR(5) (locale-fähig), Filter arbeitet aber mit existierenden Daten — keine Locale-Detection.
  • Vitest: 3 neue Tests; Gesamt-Suite 128/128 grün.

TODO-UX-03 nach Block 8 verschoben: Kontakt-Spalte / Tenant- Stammdaten-Edit-UI ist konzeptuell ein 3–5h-Block, kein Polish- Item. Severity hochgestuft auf M2/50. roadmap.md Block 8 um „Tenant-Stammdaten-Edit-UI" erweitert (Datenpunkt: UX-03 ist strenggenommen Operator-UI, Block 8 sonst Tenant-UI — Block-Scope wird leicht erweitert; alternativ ein eigener Block 8.1).

Bonus: TODO-Platform-10 (make docs-kora-deploy) zweite produktive Nutzung — funktioniert.

Fixed (TODO-UX-01 + TODO-UX-NEU — Status-Konsistenz + op-vendor-Cleanup-Pattern, Merge be49b20affa54e2558600315dc99250a86946bc5)

Zwei Polish-Items vor v1.0.0-GA in einem Mini-Run.

TODO-UX-01: Tenant-Detail zeigte vorher tenant.status raw (active / inactive), Tenant-Liste rendert ein Badge basierend auf tenant.deleted_at (Aktiv / Soft-Deleted). Detail bekommt jetzt ein humanisiertes Status-Label via neuem util frontend/operator-ui/src/utils/tenantStatus.ts. Liste bleibt per Scope-Boundary unverändert. Discovery-Befund war Variante A mit Twist (unterschiedliche Quell-Felder Liste vs. Detail). Templates und Modules haben eigene konsistente is_active/is_enabled-Pattern — kein Whack-a-Mole.

TODO-UX-NEU op-vendor-Cleanup: scripts/cleanup-test-data.sh um Flag --include-test erweitert, der op-vendor-%-Tenants (Auth-Test-Token-Generator-Pollution) zusätzlich zum e2e-%-Default löscht. Live-Smoke löschte einen op-vendor-write-0bd2ee27-Tenant; Audit-Log unverändert (515 vor + nach) per Scope-Boundary.

  • frontend/operator-ui/src/utils/tenantStatus.ts (neu) — formatTenantStatus(tenant). Soft-Delete dominiert, sonst Mapping active/inactive → deutsche Labels, Fallback auf Roh- Wert für künftige Status-Werte.
  • frontend/operator-ui/src/utils/__tests__/tenantStatus.spec.ts (neu) — 4 Unit-Tests.
  • frontend/operator-ui/src/pages/TenantsDetailPage.vue<dd>{{ formatTenantStatus(tenant) }}</dd> statt Roh-Wert.
  • frontend/operator-ui/src/pages/__tests__/TenantsDetailPage.spec.ts — 2 neue Tests (humanisiert + Soft-Deleted-Pfad).
  • scripts/cleanup-test-data.shINCLUDE_TEST=0 Toggle, --include-test-Flag, Pattern-Array-Erweiterung um op-vendor-%, Help-Block + Usage-Section um neue Kombination ergänzt.
  • Makefile — neue Targets cleanup-test-data-include-test (Dry-Run) und cleanup-test-data-include-test-apply (mit Confirm). args=-Pass-Through unverändert weiter unterstützt.
  • docs-kora/docs/operations/test-data-cleanup.md — Runbook um Auth-Test-Pollution-Abschnitt erweitert + Tabellen-Updates.
  • TODO-UX-01 archiviert; TODO-UX-NEU als Erkenntnis-Erledigung-Pattern direkt im Archiv-Block hinzugefügt (kein vorheriger TODO-Lifecycle, weil im Cleanup-Mini-Run als Datenpunkt erkannt und sofort opportunistisch mit-erledigt).
  • Validierung: Vitest 9/9 grün; Live-Smoke --include-test-Pfad: Dry-Run findet 1 Tenant, Apply löscht 1, Idempotenz-Check meldet "nichts zu tun", Audit-Log 515 → 515.
  • Bonus: TODO-Platform-10 (make docs-kora-deploy) erstmals produktiv genutzt — funktioniert, ~5–8s end-to-end.

Changed (TODO-Platform-10 — mkdocs-Live-Deployment auf Static-Site umgestellt, Merge bd400af4ca274debf7b7ed7248624f1ccb9e6350)

Der kora-platform-mkdocs-Service rendert die Doku jetzt nicht mehr über mkdocs serve, sondern liefert ein vorgebautes Static-Site- Verzeichnis aus einem nginx:alpine-Container aus. Damit ist der Inotify-Bug, der mehrere Wochen Doc-Merges auf docs.kora.luki-net.org unsichtbar gemacht hat, struktur-eliminiert. Lutz-Entscheidung: Option 3 (Build-static-Pattern) aus den vier Akzeptanz-Optionen im TODO-Eintrag.

  • docker-compose.platform.yml — Service mkdocs von squidfunk/mkdocs-material:latest serve --no-livereload auf nginx:alpine umgestellt. Mounts: ./docs-kora/site:/usr/share/nginx/html:ro und ./infra/docs/nginx-docs.conf:/etc/nginx/conf.d/default.conf:ro. Port-Mapping 8237:80 (vorher 8237:8000). Healthcheck mit 127.0.0.1 statt localhost wegen Alpine-IPv6-Quirk.
  • infra/docs/nginx-docs.conf — neue nginx-Config mit gzip, try_files-Routing für mkdocs-Pretty-URLs (/foo//foo/index.html), sane Security-Header (X-Content-Type-Options, X-Frame-Options, Referrer-Policy).
  • docs-kora/mkdocs.ymlsite_dir: ../site-korasite_dir: site (Build-Output liegt jetzt unter docs-kora/site/, gitignored, neben docs/ inspizierbar). Bind-Mount-Discipline für den Compose-Mount bleibt single-dir.
  • Makefile — neue Targets docs-kora-build, docs-kora-deploy, docs-kora-clean. Build läuft über docker run --rm mit Throwaway-Container (squidfunk/mkdocs-material:latest), RW-Mount nur für die Build-Phase. Standard-Update-Flow nach Doc-Merge: make docs-kora-deploy.
  • .gitignore — explizit docs-kora/site/ und site-kora/ (Legacy) ergänzt; das globale site/ deckte beide Pfade implizit ab.
  • docs-kora/docs/deployment/docs-deployment.md — neues Runbook: Architektur, Make-Targets, Build-vs-Serve-Container-Trennung, Update-Flow, Troubleshooting für Build-Strict-Errors und 404-nach-Deploy-Edge-Cases, Migrations-Hinweis.
  • docs-kora/docs/deployment/mkdocs-container.md — als 12-Zeilen- Legacy-Stub mit Verweis auf neues Runbook erhalten (Cross-Refs aus changelog/offene-todos bleiben funktional).
  • mkdocs-Nav unter Deployment um „Doku-Deployment" ergänzt; „mkdocs-Container" als „mkdocs-Container (Legacy)" gekennzeichnet.
  • TODO-Platform-10 archiviert (offene-todos.md → Archiv-Sektion).
  • NPMplus zero-touch: Service-Name mkdocs und externer Port 8237 unverändert — kein NPMplus-Reload nötig.
  • Validierung: make docs-kora-build baut in 2.6 Sekunden; make docs-kora-deploy ersetzt den Container; HTTP 200 auf /, /operations/auth-stack-soll-zustand/ (vorher 404 wegen Inotify- Bug) und /deployment/docs-deployment/ (neue Page); nginx-Logs sauber, Healthcheck healthy nach ~7 Sekunden.

Added (TODO-Platform-04 — Remote-vLLM-Node iptables-Setup-Skript, Merge 84ff4ec716f0f0cfe087f44c8d08ac3ce223ec88)

Codifiziert das seit 1.5 Wochen live-erprobte iptables-Setup auf der Remote-vLLM-Node 192.168.0.223 als idempotentes Skript + systemd-Service + Runbook. Lutz-Entscheidung: Option C (Reverse-Engineering vom bekannt-aktiven Live-Zustand), kein Live-Deploy in diesem Block.

  • infra/remote-node/docker-firewall.sh — Delete-then-Insert- Position-1-Pattern (Live-bewährt), IPv4 + IPv6 asymmetrisch (IPv4 RETURN-Whitelist + DROP-Catchall pro Port; IPv6 nur DROP), vier Modi (default = apply für systemd, --install für Erst-Setup, --remove für Rollback, --dry-run). Konfig-Block am Skript-Anfang mit Env-Override-Hooks (ALLOWED_SOURCE_IP, PORTS_STR) für zweite Remote-Nodes.
  • infra/remote-node/docker-firewall.serviceType=oneshot mit RemainAfterExit=yes + PartOf=docker.service (Service stoppt mit Docker, startet mit Docker-Restart). ExecStart=/usr/local/sbin/docker-firewall.sh.
  • docs-kora/docs/deployment/remote-vllm-node-setup.md — Runbook mit Architektur-Kontext, Voraussetzungen, Aufruf-Reihenfolge, drei Verifikations-Pfaden (iptables-Inspection ohne Drittel-Host als Standard, plus optional docker run --network bridge-Negative-Test), Fehlerbehebung, Rollback, zweite-Node-Anpassung.
  • Routing-Page §4 (GPU-Inferenz-Topologie): „Doku-only"-Blockquote durch Verweis auf Skript + Runbook ersetzt.
  • mkdocs-Nav um Runbook-Eintrag unter Deployment erweitert.
  • TODO-Platform-04 archiviert (offene-todos.md → Archiv-Sektion).
  • Validierung: bash -n PASS; vier Dry-Run-Modi getestet (default, --remove, --install, Env-Override PORTS_STR=...) — alle liefern die erwarteten iptables/ip6tables-Aufruf-Sequenzen.
  • Live-Counter-Datenpunkte (Phase-1-Discovery 2026-04-29): 998K Pakete auf Port-8000-RETURN, 324K auf Port-9835-RETURN, 0 Pakete auf den DROP-Regeln seit Setup-Zeit (kein unauthorisierter Zugriff seit 1.5 Wochen).
  • Out-of-Scope (separate Blöcke): Live-Deploy auf 192.168.0.223, Disaster-Recovery-Test auf frischem Node, WireGuard/VPN-Schicht, SSH-Lockdown.

Changed (UX-Polish: User-Menu + Hover-Token, Merge 383ba66fd68964c1fbc364e55aefc700d7b3f8ff)

Vier Walkthrough-Findings aus dem Layout-Refactor adressiert als zusammenhängende UX-Polish-Welle, statt als isolierte Fixes:

  • User-Menu-Refactor (F1-Folge): Initialen-Quadrat (BO) entfernt, durch dezentes Person-Outline-Icon ersetzt (Anthropic-Pattern aus Image 1/2). ThemeToggle und Logout-Icon-Button jetzt rechts im User-Block (Variante 2) statt im separaten Footer-Layer. Aria- Redundanz beseitigt — Username erscheint nur 1× im Tree.
  • Hover-Token-Verstärkung (F2/F3/F4): Zwei neue Tokens in beiden Frontends: --kora-surface-hover (Indigo-getönt 8%/10% statt der alten zu-subtilen --kora-bg-secondary-Wiederverwendung) und --kora-border-hover (Indigo-getönt 30%/35%). In tenant-ui Kora-Grün/Emerald-getönt analog. Konzept-A-Brand bleibt unverändert — nur Hover-Werte wurden refined, nicht reverted.
  • Anwendungs-Stellen (operator-ui): AppSidebar nav-items, Tabs in TenantsDetailPage, Form-Buttons (--ghost), ConfirmDialog-Buttons (--ghost), ThemeToggle, Logout-Icon-Button. Layout-Shift vermieden via permanent transparenter Border (border: 1px solid transparent im Default).
  • Anwendungs-Stellen (tenant-ui): AppSidebar nav-items, ThemeToggle, Logout-Icon-Button. Form-/Confirm-Komponenten existieren in tenant-ui aktuell nicht, kommen mit Block 8.
  • NICHT verändert: Datentabellen-Row-Hover, Modals, Toasts, Status-Pills, Mobile-Layout — alle out-of-scope.

Tests:

  • 2 neue Vitest-Tests (AppSidebar.spec.ts): Person-Icon vs. Initialen-Avatar, ThemeToggle+Logout im user-actions-Container, Username-Aria-Redundanz (genau 2× im HTML — sichtbarer Text + :title, nicht 3×). Bestehender user-avatar-Test umgebaut. Total Operator-UI Vitest: 119/119 (117→119).
  • 3 neue Playwright-Tests (hover-states.spec.ts): nicht-aktives nav-item zeigt sichtbare BG + Border bei Hover; aktives Item bleibt unverändert (Indigo-Pill); Logout-Icon-Button wechselt von transparent border zum sichtbaren Hover-Border. Bestehender sidebar-active-state.spec.ts user-avatar-Assertion umgebaut auf user-icon-Visibility. Total Operator-UI Playwright: 18/18 (15→18).
  • Tenant-UI Vitest 16/16 + Playwright 2/2 unverändert (keine neuen Tests; AppSidebar-Variante in tenant-ui mit gleichem Pattern aber ohne separaten Test-Anker bis Block 8).
  • verify-auth-stack: 57/59 (TODO-Auth-NEU-Drifts erwartet).

Aria/A11y:

  • Username-Redundanz im Sidebar-Footer-Tree behoben: Avatar-Container mit :title="username" entfernt; bleibend nur :title auf der user-name-<span> (für tooltip-on-overflow).
  • "Operator-Modus" vs "OPERATOR" Diskrepanz diagnostiziert als intentional und korrekt: das Aria-Label "Operator-Modus" ist die Screen-Reader-Variante, der sichtbare Text "OPERATOR" die kompakte Pill — kein Fix nötig. Pattern bleibt erhalten.

Changed (Layout-Refactor: Sidebar-First-Navigation, Merge 6e8827076bb42fb267d459aee266f3ac3a20a1cf)

  • Topbar entfernt in beiden Frontends. Sidebar trägt jetzt die volle Navigation-Chrome (Header / Body / Footer). Pattern aus der AVS-Admin-Demo (src/avs_chatbot/admin/templates/base.html) adaptiert — keine 1:1-Kopie, sondern Vue-Component-Adaption mit Konzept-A-Tokens.
  • <AppSidebar>-Component (neu) in beiden Frontends:
  • Header: Brand-Logo + Brand-Name + Sub-Titel; Operator-UI zusätzlich OPERATOR-Badge mit --kora-badge-bg/-fg-Tokens
  • Body: Group-Sections mit Uppercase-Titles; Items mit inline-SVG-Icons + Label
    • Operator-UI: 3 Groups (Verwaltung/Beobachtung/Integrationen) mit 5 Items (Tenants, Templates, Module, Audit-Log, Connectors)
    • Tenant-UI: 1 aktive Group (Mein Bereich) + Block-8-Platzhalter mit is-disabled-Style
  • Footer: User-Menu mit Avatar (2-Buchstaben-Initialen) + Username + Top-Role-Subtitle, plus ThemeToggle und Logout-Icon
  • Inline-SVG-Pattern für Icons — keine externe Icon-Lib (kein Bundle-Hit, konsistent mit existierendem ThemeToggle und AVS-Admin-Demo)
  • Walkthrough-Drift-Fix #2 (Active-State auf Sub-Routes): Active- Logic von exact-match (router-link-active) auf prefix-match umgestellt (route.matched plus route.path.startsWith(itemPath + "/") als Robustheits-Fallback). Sub-Route /tenants/<uuid> hält den „Tenants"-Eintrag weiterhin aktiv
  • Walkthrough-Drift-Fix #3 (Active-Visual): Full-Width-Pill (Variante A) statt nur Schriftfarbe. .nav-item.is-active bekommt background: var(--kora-primary) + color: var(--kora-on-primary) — sichtbar als Operator-Indigo-Pill bzw. Tenant-Grün-Pill
  • Walkthrough-Drift-Fix #1 (ThemeToggle-Visibility): ThemeToggle jetzt im Sidebar-Footer mit klar sichtbarem Border-Styling (var(--kora-border)-Box, identisch zum Logout-Icon-Button) — funktioniert in Light- und Dark-Mode
  • Role-Priority-Map im Frontend pro Realm:
  • Operator: operator-admin (100) > -editor (80) > -viewer (60) > vendor-Rollen (50/40/30)
  • Tenant: tenant-admin (100) > -editor (80) > -viewer (60)
  • Höchste passende Rolle wird als Subtitle gerendert; Fallback: Subtitle weglassen (kein Raten)
  • Tests:
  • 9 neue Vitest-Tests (AppSidebar.spec.ts): Group-Count, Item- Count, Active-Logic für Liste vs. Sub-Route, Operator-Badge, User-Menu mit Initialen + Role-Subtitle, höchste Rolle gewinnt, Fallback ohne Operator-Role, Logout-Callback
  • 3 neue Playwright-Tests (sidebar-active-state.spec.ts): Top-Level-Active auf Sub-Route, Group-Switch, User-Menu-Inhalt
  • Total Operator-UI: 117/117 Vitest (108→117) + 15/15 Playwright (12→15) grün
  • Tenant-UI: 16/16 Vitest unverändert (Block-8-Erweiterung bringt eigene Tests), 2/2 Playwright unverändert
  • verify-auth-stack: 57/59 (2 erwartete Drifts aus TODO-Auth-NEU weiterhin als Frühwarnung)
  • Bestehende BaseLayout.vue umgestellt auf reine Layout-Shell mit <AppSidebar /> + <main><slot /></main>. Mobile-Fallback: Sidebar wird über volle Breite gestackt unter 768px. Mobile- Polish bleibt out-of-scope.
  • Konsistenz mit Breadcrumbs: Sidebar zeigt Top-Level-Section, Breadcrumbs zeigen Sub-Route-Position — beide bleiben sichtbar.

Added (TODO-Auth-NEU + JWT-sub-Claim-Drift-Doku, Merge a7e3712046230bd2fb4bbcc6fe02c733012b0144)

  • TODO-Auth-NEU registriert als 8. Drift-Datapoint im Auth-Stack- Drift-Aufräum-Vektor. Severity M2/40. JWTs aus beiden Realms enthalten kein sub-Claim — kora-scope-Custom-Scope hat keinen Subject-Mapper. Workarounds bestehen bereits (platform_audit_log.actor_keycloak_id NULL-Fallback, user_preferences (realm, username) Composite-PK). Fix-Pfad dokumentiert in offene-todos.md.
  • auth-stack-soll-zustand.md erweitert um neue Subsection §3.2 „JWT-Claim-Inventar (Soll vs. Ist)" mit Tabelle aller Standard- und Custom-Claims, sub als DRIFT markiert, Konsequenzen für DB-Design dokumentiert.
  • scripts/verify-auth-stack.sh Check 58 ergänzt: prüft sub-Claim-Anwesenheit in einem frisch gemintten Token aus beiden Realms (Check 58a für kora-platform, 58b für kora-tenants). Aktuell FAIL als explizite Drift-Frühwarnung — sobald Mapper-Fix deployed ist, wechselt der Check ohne Skript- Änderung auf PASS. Total: 59 Checks, aktuell 57/59 grün + 2 erwartete Drifts.

Added (Color-System-Implementation Konzept A, Merge 7261426a4d297b5f2876adf04516ee1d8b7bc275)

Backend:

  • Neue Tabelle user_preferences (Migration 0008). Composite-PK (realm, username) weil JWTs in beiden Realms keine sub-Claim enthalten (Discovery-Befund 2026-04-28). Standalone-Tabelle ohne FK — User-Records leben in Keycloak.
  • API: GET/PATCH /api/v1/users/me/preferences. Lazy-Create-Pattern: erste Anfrage legt Default-Eintrag (auto) an, kein Pre- Provisioning. Pydantic + DB-CHECK validieren light|dark|auto. Kein Audit-Logging — User-Settings sind privat.
  • Cross-Realm-Independenz: Operator und Tenant mit gleichem Username haben unabhängige Preferences (PK enthält Realm).

Frontend:

  • Konzept-A-Tokens in beide Frontends migriert (operator-ui tokens.css, tenant-ui assets/styles.css). 27 semantische Tokens × 3 Modi (Light, Dark, Auto via prefers-color-scheme) × 2 Surfaces. 8-Token-Drift zwischen den Frontends beseitigt — beide teilen jetzt dasselbe Schema.
  • Theme-Provider useTheme() Composable in beiden Frontends. Lädt aus Backend (Cross-Device-Sync), Fallback LocalStorage. System-Theme-Listener für Auto-Mode-Live-Update bei OS-Setting- Wechsel.
  • <ThemeToggle>-Component in beiden Topbars (rechts neben Logout). 3-State-Cycle Sun → Moon → Auto-Symbol mit Tooltip + aria-label.
  • Operator-Badge als eigene Token-Klasse --kora-badge-bg/-fg (surface-unabhängig). Tenant-UI rendert die Component nicht, hat aber die Tokens für künftige Operator-Surface-Vorschauen (Block 17).
  • 11 Walkthrough-Drift-Stellen behoben: alle color: var(--kora-primary)- Text-Uses durch var(--kora-link) ersetzt — Sidebar-Aktiv, Hinweis-Card-Headlines, Inline-Links, Tab-Aktiv etc. Im Dark-Mode von 1.7:1 (FAIL) auf 8.2:1 (AAA). Border-Uses bleiben unverändert.

Tests:

  • 8 Backend-Integration-Tests (tests/integration/test_user_preferences_api.py): Unauth-401, Lazy-Create, GET/PATCH-Roundtrip, Validierung-422, Cross-Realm- Independenz, Audit-Skip, Username-Edge-Case.
  • scripts/smoke-color-system.sh 6/6 grün.
  • 8 neue Vitest-Tests (useTheme.spec.ts) in jedem Frontend, total 108/108 (operator-ui) + 16/16 (tenant-ui) grün.
  • 2 neue Playwright-Tests (theme-toggle.spec.ts) in operator-ui: Cycle-Verhalten + Reload-Persistenz, total 12/12 grün.
  • Verify-Auth-Stack 57/57 grün (keine Regression).

Doku:

  • Neue Page operations/color-system-implementation.md als Live-Stand-Referenz + Migration-Pfad-Doku.
  • concept-a.md als „✅ Implementiert" markiert; concept-b.md als „❌ Verworfen" markiert. bakeoff.html bleibt als historisches Bake-Off-Artefakt erhalten.
  • mkdocs-Nav um Color-System-Implementation-Page erweitert.

Added (Test-Daten-Cleanup-Tooling, TODO-UX-02, Merge 00ee4a48ff481c0d016e5ede298320ff5342c95c)

  • scripts/cleanup-test-data.sh — Operator-Skript zum Entfernen akkumulierter E2E-/Bench-Test-Daten aus der Live-kora-platform-DB. Default Dry-Run, --apply mit Confirm-Prompt, --include-bench opt-in für bench-*-Tenants, --yes für CI/non-interactive. Pre-Flight prüft alle 10 NO-ACTION-FK-Tabellen — bricht ab statt mit Foreign-Key- Violation zu scheitern. CASCADE-FKs (tenant_packages, tenant_branding, chatbots, credentials, tenant_modules, evaluation_questions) werden automatisch mitgenommen. Audit-Log wird per Scope-Boundary nicht angefasst.
  • Make-Targets: make cleanup-test-data (Dry-Run), make cleanup-test-data-apply (mit Confirm-Prompt). Beide akzeptieren args=--include-bench.
  • Runbook operations/test-data-cleanup.md mit Aufruf-Patterns, Edge-Case-Handling, Verifikations-Schritten.
  • Live-Verifikation: 68 e2e-Tenants + 35 e2e-Templates entfernt; 455 Audit-Einträge unangetastet; 2 bench-Tenants preserved.

Added (Block 7.4 — Audit-Log-Viewer + Bulk-Ops + Connectors-Stub, Branch platform/block-7-4b-audit-bulk-frontend, Merge 0b93d60)

Backend (7.4a, Merge 61689c2):

  • Audit-Reader-API unter /api/v1/platform/audit*. Drei Endpunkte: GET "" (List mit Filter action/actor/entity_type/entity_id/ json_search/date_from/date_to + Pagination offset/limit), GET /{entry_id} (Single-Entry), GET /export.csv (CSV-Export mit UTF-8-BOM, Hard-Limit max_rows, sichtbarer Truncated-Footer, Filter-State-Pass-Through, optional include_details=true). Read-only — kein POST/PATCH/DELETE; Audit-Log ist append-only.
  • Bulk-Soft-Delete für Tenants (POST /api/v1/platform/tenants/bulk-soft-delete) und Bulk-Module- Toggle für Tenant (POST /api/v1/platform/tenants/{id}/modules/bulk). Atomar via request_scoped_session(tenant_id=None, bypass_rls=True) — Block 5 Cleanup-Pattern wiederverwendet, Rollback bei jeder Exception. Audit-Strategy: eine Audit-Zeile pro Bulk-Aktion (tenant.bulk_soft_deleted / tenant_module.bulk_assigned), IDs in details.after. Lutz-Entscheidung „Atomarität sichtbar machen", Tradeoff bewusst (siehe TODO-Block-7-4-03).
  • AuditService mit list_entries/get_entry/export_csv. JSONB-Search via cast(details, String).ilike(...) ohne GIN-Index (siehe Pre-Flight PORTING-7.4.md — Trigger > 10k Rows). Filter kombinierbar; default 50/Page; max-page-size 200.
  • Pydantic-Schemas AuditEntryRead, AuditList, AuditListParams (models/audit.py), BulkSoftDeleteRequest/ BulkSoftDeleteResponse (operator_tenants), BulkAssignBody/ BulkAssignResponse (tenant_modules).
  • Service-Methoden: tenant_service.bulk_soft_delete_tenants (gibt (soft_deleted, skipped, not_found) zurück, raised TenantNotFound für atomic-rollback-Pfad), module_service. bulk_assign_to_tenant (überspringt Always-On-Module, raised ModuleNotAssignable für Internal-only, populate_existing=True).
  • 26 neue Tests: 12 Unit (tests/unit/test_audit_service.py) für List/Filter/CSV/Limit- Clamping, 14 Integration (tests/integration/ test_bulk_operations.py) für Tenants-Bulk + Modules-Bulk inkl. Rollback-, Idempotenz- und Single-Audit-Entry-Pfade. Backend 79/79 + 26 = 105 grün, RLS-Regression unverändert.

Frontend (7.4b, dieser Merge):

  • Audit-Log-Viewer unter /admin/operator/audit (List + Detail- Expand, kein Drilldown — append-only). Filter-Toolbar mit Action- Dropdown (KNOWN_ACTIONS), Actor/Entity-ID/JSON-Search-Inputs (300 ms Debounce auf Free-Text), Date-Range-Picker. Default „letzte 7 Tage" wenn keine URL-Filter aktiv (sonst URL-Filter haben Vorrang — M1-Pre-Merge-Fix für Tenant-Audit-Tab-Shortcut). CSV-Export-Button (mit/ohne Details), Pagination (50/Page).
  • Bulk-Soft-Delete für Tenants in TenantsListPage: Checkbox- Spalte, Bulk-Bar mit Slug-Vorschau im Confirm-Dialog, Selection- Reset bei Liste-Watch (verhindert „IDs ausgewählt, die nicht mehr in der Liste sind"-Bug). Selection-State per Page (Set<string>).
  • Bulk-Modul-Aktivieren in <TenantModulesSection>: Multi- Select über alle nicht-aktiven Tenant-assignable-Module, ein Confirm-Dialog mit Modul-Liste, atomarer POST gegen /tenants/{id}/modules/bulk. Internal-only- und Always-On-Module sind ausgefiltert.
  • Connectors-Stub-Page unter /admin/operator/connectors — reine UI mit Block-13-Hinweis-Card (Text aus konnektor-roadmap §2 + §7.4), kein Backend-Read. Bewusst ohne Bestands-connectors- Tabelle, damit Block 13 die API von Grund auf entwerfen kann.
  • TenantsDetailPage: Tabs „Audit-Log" (Filter-Shortcut-Button navigiert zu /audit?entity_id=<tenant.id>) und „Connectors" (Block-13-Link) aktiviert.
  • Composables: useAuditEntries() (Filter + Pagination + URL- Param-Read + CSV-Export), Bulk-Confirm-Pfade direkt in den Pages (kein eigener Composable nötig).
  • Sidebar: „Audit-Log" + „Connectors" aktiviert.
  • Tests: 12 neue Vitest (Pages + Composable, total 100/100), 2 neue Playwright (Audit-Filter + Expand, Bulk-Soft-Delete-Workflow über zwei Tenants, total 8/8 sequential). playwright.config.ts auf workers: 1 festgenagelt — Cross-Test-Isolation über shared Backend-State funktioniert sequenziell sauber, Parallel-Worker würden sich Tenants-Liste-Rows „klauen" und Specs flaky machen.
  • Pre-Flight PORTING-7.4.md: Pfad-Split 7.4a/7.4b (Backend ~3h + Frontend ~3h) bestätigt; JSONB-Search ohne GIN als TODO-Block-7-4-NN getrackt; kein Connectors-Backend-Read; kein Per-Tenant-Audit-Embed (Filter-Shortcut-Pattern stattdessen).

Changed (Block 7.4)

  • Audit-Helper write_platform_audit wird neu auch in den Bulk- Routen verwendet — keine Code-Duplikation. Vorher in Block 5 Cleanup generalisiert.
  • frontend/operator-ui/src/router/index.ts: 2 neue Routen /audit und /connectors mit requiresOperator-Guard.
  • frontend/operator-ui/src/layouts/BaseLayout.vue: Sidebar- Einträge „Audit-Log" + „Connectors" aktiviert.

Wichtige Pre-Flight-Erkenntnisse (in PORTING-7.4.md)

  • Pfad-Split-Bestätigung durch 4-Layer-Discovery (table/service/ route/live-data): Audit-Tabelle existiert seit Block 1 mit GIN- losem JSONB-Index; Bulk-Routen brauchen das Block-5-Cleanup- Atomarität-Pattern (request_scoped_session + Audit-helper); Connectors-Tabelle existiert, aber Block 13 will eigene API von Grund auf entwerfen → kein Read im Stub.
  • GIN-Index auf audit_log.details deferred: TODO mit Trigger

    10k Audit-Rows; aktuell cast(details, String).ilike(...) performant genug.

  • Single-Audit-Entry für Bulks (Lutz-Entscheidung): bewusst „Atomarität sichtbar machen". Tradeoff entity_id="bulk:N"entity_id-Filter findet Bulk-Events nicht (TODO-Block-7-4-03 trackt Konventions-Doc).
  • M1 Review-Fix vor Merge (a0ab7c9): TenantsDetailPage Audit- Tab-Shortcut navigiert mit ?entity_id=<tenant.id>, aber useAuditEntries.ts las URL-Query-Params ursprünglich nicht. Fix: readUrlFilters()-Helper + Default-7-Tage-Range nur bei fehlenden URL-Filtern.

Block 7 abgeschlossen (~80% → 100%)

Mit dem 7.4-Merge ist Block 7 vollständig: 7.1a Tenants-Backend, 7.1b Operator-UI-Frontend, 7.2 Templates-CRUD-UI, 7.3 Modules- Registry + Toggle, 7.4 Audit + Bulk + Connectors-Stub. Operator-UI liefert die volle Phase-B-Read-Modify-Operate-Oberfläche; Phase D folgt mit Tier/Limits (TODO-Block-7-3-01) und Bulk-Cap-Hardening (TODO-Block-7-4-01).

Added (Block 7.3 — Modules-Registry + Per-Tenant-Toggle, Branch platform/block-7-3-modules-and-packages)

  • Modules-Registry unter /admin/operator/modules (List + Detail) als Read-Only-Ansicht der Plattform-Module gegen das in Block 5/6 gebaute Backend (/api/v1/platform/modules*). List mit Search (300 ms Debounce) + Scope-Dropdown-Filter (Core/Tenant-assignable/Internal-only). Detail mit Stammdaten + config_schema-JSON-Pre-Block + scope-spezifischen Hinweisen.
  • Per-Tenant-Toggle in TenantsDetailPage Tab „Pakete & Module" (war disabled mit „Kommt in Block 7.3"). Neue <TenantModulesSection>-Component mergt clientside platform_modules ∖ tenant_modules-of-tenant. Toggle-Buttons gegen POST/DELETE /api/v1/tenants/{id}/modules mit Confirm-Dialog beim Deaktivieren. Always-On-Module (scope='core') zeigen „fixiert"; Internal-only-Module werden ausgefiltert.
  • Composables: useModules(), useModule(id), useTenantModules(tenantId).
  • Sidebar: Eintrag „Module" aktiviert (<router-link>).
  • Tests: 15 neue Vitest (total 89/89), 2 neue Playwright (Toggle-Workflow + Registry-Navigation, total 6/6).

Changed (Block 7.3)

  • A11y-Mit-Fix TODO-Block-7-1b-03: TenantsDetailPage-Tab-Row von <div>-Elementen auf <button>-Tabs umgestellt — aria-active, disabled-Attribute auf Block-7.4-Placeholdern, Focus-Visible- Outline, cursor:not-allowed bei disabled. Volle ARIA-tablist- Rolle bleibt offen für externe-Personas-Pass.
  • Backend unverändert — reine Frontend-Arbeit.

Wichtige Pre-Flight-Erkenntnisse (in PORTING-7.3.md)

  • Pakete-Modell-Diskrepanz: Code = Variante C (relational tenant_modules), Konzept §8 = Variante A (Pro-Tenant-Pakete mit Tier/Limits via tenant_packages). Tabelle existiert seit Block 1 ohne API/Service/Schemas, 0 Rows live, kein Limits-Enforcement. 7.3 baut nur Modul-Toggle. Tier/Limits → TODO-Block-7-3-01 für Block 12.
  • EntityForm-Refactor finaler Verzicht (TODO-Block-7-1b-04 + TODO-Block-7-2-05): 7.3 baute keinen Form-Datenpunkt; Block 12 + Block 13 bringen unterschiedliche Form-Shapes; drei ähnliche CRUDs entstehen nie. Beide TODOs verworfen, im 7.3-Review begründet.

Added (Block 7.2 — Templates-CRUD-UI, Branch platform/block-7-2-templates-crud)

  • Templates-CRUD im Operator-UI unter /admin/operator/templates/* gegen das in Block 5 gebaute Backend (/api/v1/operator/templates*). Vier Pages (List, Create, Detail, Edit) auf dem in 7.1b etablierten Component-Stack — <DataTable>, <FormInput>, <FormTextarea>, <FormButton>, <Toast>, <ConfirmDialog>, <Breadcrumbs>, <PageHeader>.
  • Path A bestätigt in frontend/operator-ui/PORTING-7.2.md (Pre-Flight-Artefakt). Block 5 hat das Operator-Backend bereits vollständig — 7.2 ist reines Frontend-Update.
  • <ListInput>-Component (src/components/ListInput.vue) für JSONB-list[str]-Felder (suggested_suggestions, recommended_connectors). Add/Remove + Inline-Edit + Enter-Key- Add. Bewusst minimal (kein Drag-and-Drop, kein Reorder).
  • Composables: useTemplates() mit clientseitigem Search-Filter (Backend liefert bare Liste), active_only-Toggle re-fetcht serverseitig; useTemplate(id) mit reactivate() (PATCH is_active=true) + deactivate() (DELETE → 204); useTemplateIdValidator() mit Slug-Regex (Underscores erlaubt, anders als Tenant-Slug).
  • Sidebar: Eintrag „Templates (Block 7.2)" aktiviert (war in 7.1b noch disabled mit Block-Hinweis-Tooltip), Link auf /templates.
  • Tests: 33 neue Vitest (Pages + ListInput + ID-Validator), 2 neue Playwright (Happy-Path mit Deactivate/Reactivate-Cycle + Error-Path 409). Total 74/74 Vitest + 4/4 Playwright. Backend- Regression unverändert 55/55.
  • mintTokens() Cache + Retry: e2e/helpers.ts um 60s-In-Process- Cache plus 3-fach-Retry mit Backoff erweitert. Behebt einen flaky Keycloak-master-Realm-500er bei parallelem Suite-Lauf.

Changed (Block 7.2)

  • frontend/operator-ui/src/router/index.ts: 4 neue Routen unter /templates* mit requiresOperator-Guard.
  • frontend/operator-ui/src/layouts/BaseLayout.vue: Sidebar- Eintrag „Templates" aktiviert.

Wichtige Pre-Flight-Erkenntnisse (in PORTING-7.2.md)

  • Templates sind global (kein tenant_id-Feld). Der ursprüngliche Prompt nahm tenant-skopierte Templates an — das Backend zeigt das Gegenteil.
  • Soft-Delete läuft via is_active=false (reversibel via Reaktivieren-Button), nicht deleted_at. Slug-Reservation §14.4 gilt für Templates nicht.
  • Pfad-Präfix /api/v1/operator/templates weicht von /api/v1/platform/tenants (7.1a) ab — bekannte Inkonsistenz, in TODO-Block-7-01 getrackt; 7.2 macht keine nachträgliche Migration.

Added (Block 7.1b — Operator-UI Frontend, Branch platform/block-7-1b-operator-ui-frontend)

  • Neues Vue-3-SPA-Projekt frontend/operator-ui/ als Schwester- projekt zu frontend/tenant-ui/. TypeScript strict, Vite, vue- router, Vitest, Playwright. Bundle 130 KB raw / 55 KB gzipped (Ziel < 500 KB deutlich unterboten).
  • Tenants-CRUD-UI unter /admin/operator/tenants/* gegen das in 7.1a gebaute Backend: Liste mit Pagination + Search (300 ms Debounce)
  • include_deleted-Toggle; Create mit Live-Slug-Validation (regex + 500 ms Async-Verfügbarkeits-Check) + 422-Field-Mapping + 409-Inline-Error inkl. §14.4-Policy-Hinweis; Detail mit Tab-Platzhaltern für Block 7.2/7.3/7.4 (Pakete, Audit, Connectors, Templates) + Soft-Delete-Banner; Edit mit immutable Slug + Dirty- Tracking + Diff-Payload-PATCH (nur geänderte Felder).
  • Reusable Components: <DataTable> (generisch über T, Slot-basierte Columns, Loading-Skeleton, Error/Empty-Slots), <FormInput>/<FormTextarea>/<FormButton> (v-model, Error- Slot), <Toast> + useToast() (globale Queue, Auto-Dismiss 4 s/6 s je nach Tone), <ConfirmDialog> + useConfirm() (imperativer Promise-Confirm mit Escape/Enter), <Breadcrumbs>, <PageHeader>. Bewusst minimal — Sort/Filter/Virtualize bleibt Block-7.3-Scope.
  • Auth + API-Composables: useAuth.ts aus tenant-ui portiert und auf kora-platform-Realm + operator-ui-Public-Client umparametrisiert. Neu: hasOperatorRole-Computed (prüft realm_access.roles), Router-Guard redirected ohne Operator- Role-Claim auf /403. Neuer E2E-Seed-Hook (window.__KORA_E2E_SEED__) im Composable; nur in Playwright- Tests aktiv. useApi.ts 1:1 portiert.
  • Branding: --kora-primary: #3730a3 (Indigo-800, WCAG AAA 10.79:1) + Hover #312e81 für Operator-Surface-Distinction; Accent #f59e0b nur für die OPERATOR-Badge in der Topbar. Alle anderen Tokens (Font, Spacing, Radius, Shadow, Dark-Mode) identisch zu tenant-ui. Begründung in frontend/operator-ui/PORTING.md §2.
  • Build-Integration: neuer operator-ui-builder-Stage in infra/docker/Dockerfile.platform; FastAPI mountet das Bundle unter /admin/operator/* mit SPA-Fallback (main.py). Sechs neue Makefile-Targets analog zu tenant-ui-* (dev/build/lint/ test/e2e/e2e-ui).
  • Tests: 41 Vitest-Tests (slug-validator, useToast, useConfirm, DataTable, FormInput, vier Tenants-Page-Specs mit useApi-Mock); 2 Playwright-E2E (Happy-Path Full-CRUD + Error-Path 409). Backend- Regression unverändert 55/55, Smoke-Block7-1a 12/12.

Changed (Block 7.1b)

  • infra/docker/Dockerfile.platform: Multi-Stage-Build erweitert um operator-ui-builder-Stage; Runtime kopiert beide UI-Bundles (/app/static/tenant, /app/static/operator).
  • src/kora_platform/main.py: SPA-Mount für /admin/operator/* ergänzt — spiegelt das tenant-ui-Mount-Pattern.
  • docs-kora/docs/deployment/operator-ui-client.md: Abschnitte "Prod-Deployment: NPMplus-Routing" konkretisiert (FastAPI liefert Bundle direkt, NPMplus-Rule ohne separate Static-Location); Dev- Workflow + Playwright-Anleitung neu.

Added (Block 7.1a — Tenants-Backend + operator-ui Keycloak-Client, Branch platform/block-7-1a-tenants-backend)

  • Operator-Tenants-CRUD-API unter /api/v1/platform/tenants/* (List / Create / Get / Update / Soft-Delete). Operator schreibt, Vendor liest, Tenant-Scope ist verboten. Pagination + Search + include_deleted. tenant.created / .updated / .soft_deleted als Audit-Events mit Delta-Payload (nur geänderte Felder). Slug-Re-Use-Policy §14.4: soft- deleted Slugs bleiben reserviert, Freigabe nur per Hard-Delete-Runbook.
  • Keycloak-Client operator-ui (public SPA, PKCE S256) im kora- platform-Realm. Redirect-URIs für Prod (/admin/operator/*) und Dev (Vite localhost:5174, API localhost:8280). Realm-Default-Session- Lifetimes. Runbook deployment/operator-ui-client.md mit Re-Import- Prozedur via kcadm.sh (vermeidet Secret-Verlust für andere confidential Clients). patch-dev-redirects.sh um operator-ui erweitert.
  • TenantService (services/tenant_service.py) mit TenantNotFound
  • TenantSlugConflict-Exceptions. soft_delete_tenant idempotent ((tenant, was_fresh: bool)-Return). TenantService.changed_fields- Helper liefert Audit-Delta-Payload.
  • Pydantic-Schemas (models/tenant.py): TenantCreate / Update / Read / List mit Slug-Validator ^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$ (3–64 Zeichen, DNS-safe). EmailStr für contact_email.
  • Tests: 23 Unit (test_tenant_service.py), 7 Integration (test_operator_tenants_api.py — ScopeSwitch-Fixture umgeht dependency_overrides-per-App-Limit), 12 Smoke (smoke-block7-1a.sh). Code-Review: 0 Findings ≥ 80, 7 Deferred <80 (TODO-Block-7-01 .. -07).

Changed (Block 7.1a)

  • Audit-Helper umbenannt: write_module_auditwrite_platform_audit (api/routes/_module_audit.py_platform_audit.py). Pure Rename, Funktion war in der Signatur schon generisch.
  • Alte Block-3-Test-Route entfernt: GET /api/v1/admin/tenants (bisher nur von smoke-block3.sh Test 6/8 genutzt) ersetzt durch GET /api/v1/platform/tenants. Präfix-Konsolidierung: Platform-Level- Resourcen laufen jetzt konsistent unter /api/v1/platform/* (Block-6-Muster), Tenant-Scope-Resourcen unter /api/v1/tenants/*.
  • pydantic[email] in pyproject.toml (Laufzeitdep wächst um email-validator, idna, dnspython — getrackt als TODO-Block-7-05).
  • L-B3-03-Doku-Fix: Referenz auf nicht-existenten routes/tenants.py:_scope_operator_branch-Helper auf api/dependencies/tenant_context.py korrigiert.

Added (Block 4 — Qdrant Collection-per-Tenant)

  • Qdrant Collection-per-Chatbot + Shared-per-Tenant (Block 4): Tenant-isolierte Vektorsuche mit Fan-Out über Chatbot-eigene und Shared-Collections. Naming: kora_<tenant_id>_<chatbot_id> bzw. kora_<tenant_id>_shared. Dreischichtige Isolation (Collection- Name + Service-Guard + DB-RLS), verifiziert mit 200 parallelen Cross-Tenant-Queries (0 Cross-Hits).
  • Embedder-Service kora-platform-embedder (Block 4 Phase B.0): Neuer Container auf Basis multilingual-e5-large, exposed intern unter http://embedder:8090/embed. Passage/Query-Prefix-Convention via EmbedderClient.embed_passages und embed_query.
  • ChatbotLifecycle mit Soft-Delete (Block 4 Phase C): Chatbots können soft-deleted werden (deleted_at-Timestamp), werden nach 30 Tagen Grace-Period durch Cleanup-Job hard-deleted. Cleanup via CLI (kora-platform cleanup-expired-chatbots) oder automatisch durch den Scheduler um 03:00.
  • Scheduler-Service kora-platform-scheduler (Block 4 Phase C): ofelia-basiert, erweiterbar per infra/scheduler/config.ini. Erster Job: täglicher Chatbot-Cleanup. Weitere Jobs können durch Hinzufügen von [job-exec "<name>"]-Sektionen ergänzt werden.
  • PlatformRetrieval-Service (Block 4 Phase D): Fan-Out-Retrieval mit parallelem asyncio.gather über Chatbot- und Shared-Collection, Score-basierte Re-Rank-Union. Collection-Not-Found-Tolerance für leere Shared-Collections; echte Qdrant-Errors werden propagiert.
  • Runbook deployment/qdrant-collections.md: Operator-Doku mit Troubleshooting-Workflow.
  • Partial Index ix_chatbots_deleted_at (Alembic 0005): nur soft-deleted Chatbots sind indiziert, spart Speicher bei NULL-Mehrheit.

Added (TODO-B2-03 — Keycloak Service-Account)

  • Keycloak Service-Account kora-platform-audit — minimaler Service-Account für Audit-Event-Polling, Client-Credentials-Flow, Rechte: realm-management.view-events (strikt minimal, kein view-users — Event-Payload reicht dem Poller). Init-Script infra/keycloak/init-scripts/create-audit-service-account.sh idempotent. (TODO-B2-03)
  • Prometheus-Counter audit_poller_auth_failures_total{reason} — neue beobachtbare Metrik für Poller-Auth-Fehler (invalid_client, invalid_grant, forbidden, network, missing_secret, unknown).
  • Runbook deployment/keycloak-service-account.md — Bootstrap-Flow, Secret-Rotation (90 Tage empfohlen), Re-Import- Verhalten, Troubleshooting.
  • Integration-Test tests/integration/test_audit_service_account.py — deckt Happy-Path (/events → 200), Deny-Path (/users//roles//groups → 403) und Sad-Path (falscher Secret → 401) ab.

Changed (TODO-B2-03)

  • Audit-Poller-Auth — Password-Grant gegen Master-Realm → Client-Credentials-Flow gegen kora-platform-Realm via interner Keycloak-URL. Token-Reuse mit asyncio.Lock gegen Race-Refresh. Token-Safety-Margin 30s vor expires_in.
  • Bootstrap-CLIkora-platform bootstrap-operator-admin liest Master-PW aus KC_BOOTSTRAP_ADMIN_USERNAME/PASSWORD statt aus Settings. Invocation nur noch via docker compose run --env-file .env.bootstrap; empfohlener Pfad /run/user/$UID/.env.bootstrap (tmpfs).

Removed (TODO-B2-03)

  • KORA_KEYCLOAK_ADMIN_USERNAME + KORA_KEYCLOAK_ADMIN_PASSWORD aus docker-compose.platform.yml, .env.platform, .env.platform.example und Settings (config.py). Master-Admin-Zugriff existiert nur noch transient via .env.bootstrap.

Added

  • Prometheus-Scrape für kora-platform-api/metrics-Endpoint auf Port 8080 (Container-internal) wird jetzt alle 15s gescraped. Die Block-3-Counters (kora_platform_requests_total, kora_platform_auth_failures_total) werden gesammelt. Dashboard-Bau kommt mit Block 7. (Commits c0df565, 745d2a2)
  • Network-Bridge avs-netkora-platform-netavs-prometheus hängt jetzt zusätzlich an kora-platform-net via external: true in docker-compose.yml, ermöglicht Cross-Compose-Scraping. (Commit c0df565)

Fixed

  • mkdocs Anchor-Warning in konnektor-roadmap.md#permission-awareness-ausbau#6-permission-awareness-ausbau (mkdocs behält Zahlen im Anker-Slug). mkdocs --strict läuft jetzt ohne Warnings. (Commit 5b821da)

Changed (Cleanup-02, Branch platform/chore-cleanup-02)

  • vendor_access_log.actionVARCHAR(64)TEXT. Verhindert Truncation bei tief verschachtelten Pfaden (ab Block 4 erwartet). Reversible Alembic-Migration 0004. (TODO-B3-03)
  • Audit-Poller — Pagination — fetcht jetzt paginiert bis Exhaustion oder Cursor-Überlauf, MAX_PAGES=50-Safety-Rail, Log-Warning pro voller Seite. Fix für stummen Event-Verlust bei

    100 Events pro Poll-Intervall. (TODO-B2-01)

  • Audit-Poller — Redis-Keyavs:audit:last_event_tskora:platform:audit:last_event_ts. Einmalige idempotente Migration beim Service-Start (Overlap: neue Version gewinnt, Legacy wird gelöscht). (TODO-B2-05)
  • Settings — URL-Validationplatform_public_url, keycloak_base_url, keycloak_public_base_url jetzt pydantic.AnyHttpUrl. Validiert Scheme beim Settings-Load. Alle Call-Sites mit explizitem str()-Cast. AnyHttpUrl (nicht HttpUrl), um den Dev-Default http://localhost:8236 zu akzeptieren. (TODO-B2-07)

Added (Cleanup-02)

  • /metrics IP-Allowlist — FastAPI-Dependency statt NPMplus-Regel (NPMplus läuft auf separatem Host, ist hier nicht direkt steuerbar). Default: Loopback + RFC1918. Liest X-Forwarded-For leftmost, fällt auf request.client.host zurück. Verifiziert: LAN-IP → 200, gespoofte externe IP → 403. (TODO-B3-04)
  • Unit-Teststests/unit/test_audit_poller.py (5 Tests für Migration + Pagination) und tests/unit/test_config.py (5 Tests für URL-Validierung).

Changed (mkdocs-Hygiene, Branch platform/chore-mkdocs-hygiene)

  • mkdocs-Setup — Config-Datei mkdocs-kora.yml (Repo-Root) → docs-kora/mkdocs.yml. docker-compose.platform.yml nutzt jetzt einen einzelnen Dir-Mount (./docs-kora:/docs:ro) statt Dir + Single-File. Löst das Bind-Mount-Inode-Problem: Content- und Config-Änderungen werden live sichtbar, ohne --force-recreate (Folge-Fix zur Pre-Block-4-Diagnose).
  • URL-Pfad/todo//offene-todos/. Dateiname todo.mdoffene-todos.md. Alle internen Referenzen angepasst. Alte URL produziert jetzt 404 — keine Redirect-Regel, weil die Docs-Site noch keine stabilen externen Konsumenten hat.

Added (mkdocs-Hygiene)

  • Runbook docs-kora/docs/deployment/mkdocs-container.md — dokumentiert das mkdocs-Deployment-Setup inkl. Content-vs-Config-Änderungen und Troubleshooting-Pfade.

Added (Pre-Block-4 Cleanup, Branch platform/chore-pre-block4)

  • Correlation-ID-MiddlewareRequestIdMiddleware generiert bzw. übernimmt den X-Request-Id-Header pro Request, bindet ihn in structlog-ContextVars und spiegelt ihn im Response-Header + im 500er-Response-Body. (TODO-B2-02)

Changed (Pre-Block-4 Cleanup)

  • Keycloak-Realms — Drei Protocol-Mapper (realm-roles, preferred_username, email) direkt im kora-scope-ClientScope beider Realm-JSONs. Vorher nur als Dev-Workaround in gen-test-tokens.sh. Dev/Prod-Parität hergestellt. (L-B3-01)
  • kora-tenants-RealmrefreshTokenMaxReuse: 0 + revokeRefreshToken: true aktiviert (Refresh-Token-Rotation analog zum kora-platform-Realm). (TODO-B2-06)
  • Tenant-Scope-MiddlewareTENANT_ROLES als hartes Gate: Gruppenmitgliedschaft allein reicht nicht mehr; ohne tenant-admin/editor/viewer403 no_tenant_role. Defense-in-Depth gegen halbfertige Provisioning-Artefakte. (TODO-B3-02)
  • Tenant-Scope-Middleware — ContextVar-Reset im Vendor-Pfad vereinheitlicht: current_tenant_id.set(None) mit Token-Reset im finally. Kein Stale-Wert zwischen asyncio-Tasks im Logging. (TODO-B3-01)
  • Redis-Client — zentraler ConnectionPool in app.state, Health-Check wiederverwendet den Pool, sauberer Shutdown (Client → Pool). (TODO-B2-04)
  • Makefileredeploy-platform wartet mit until-Loop (max 60s) auf /health/live statt festem sleep 10. (TODO-B2-08)
  • gen-test-tokens.shensure_kora_scope_mappers-Phase entfernt (durch Realm-JSON-Fix redundant).
  • mkdocs-Nav — thematisch neu strukturiert: Start / Roadmap / Konzepte / Prozesse / Deployment / Änderungen (mit eingebettetem TODO-Link). Dateien ohne Kategorien-Drift.

Fixed (Pre-Block-4 Cleanup)

  • realm_access.roles, preferred_username, email-Claims fehlten bei Prod-Realm-Imports — fielen in Block 3 nur durch Dev-Workaround nicht auf; jetzt aus Realm-JSON.

Added (Phase A Block 3 — Tenant-Scope-Middleware, Branch platform/block-3-tenant-middleware)

  • JWT-Validation-Dependency src/kora_platform/api/dependencies/auth.py mit JWKS-Cache (1h TTL, force-refresh bei kid-miss, asyncio.Lock-geschützt für beide Realms). authlib.jose als Lib (schon als Dep aus Block 2 vorhanden). Fail-Safe: 401 mit WWW-Authenticate: Bearer error="invalid_token".
  • Tenant-Context-Resolution src/kora_platform/api/dependencies/tenant_context.py mit ContextVars current_tenant_id + current_principal. Drei Scopes: tenant (Issuer=kora-tenants → Group-Slug → tenant_id via Redis-Cache-fähigem Slug-Lookup), operator (Issuer=kora-platform + operator--Role, tenant_id=None), vendor (Issuer=kora-platform + vendor--Role, BYPASSRLS, optional X-Tenant-Id-Header). Generator-Dependency mit Reset im finally.
  • DB-Engines src/kora_platform/db/engines.py: drei async SQLAlchemy- Engines (app pool=20+10, vendor pool=5+5, admin pool=5+5) + request_scoped_session mit SET LOCAL app.current_tenant_id in Transaktion. Pool-Strategie aus Session-Pool-Benchmark (commit 4eb764f, Scenario A).
  • Test-Endpoints src/kora_platform/api/routes/tenants.py: GET /api/v1/me, GET /api/v1/whoami, GET /api/v1/tenants/me, GET /api/v1/tenants/{tenant_id}/chatbots, GET /api/v1/admin/tenants.
  • Vendor-Breakglass-Audit synchron in vendor_access_log. Schreibfehler → 503 audit_unavailable, Request wird abgelehnt.
  • Prometheus-Counter kora_platform_auth_failures_total{reason} und kora_platform_requests_total{scope,realm}. Neuer /metrics-Endpoint.
  • Test-Token-Generator scripts/gen-test-tokens.sh (idempotent): enabled directAccessGrants auf kora-api (beide Realms) + vendor- breakglass, legt bench-tenant-a/b Groups + Admins an, enabled vendor-breakglass-User, fügt oidc-usermodel-realm-role-mapper an kora-scope-Client-Scope (fehlte aus Block 2, siehe L-B3-01).
  • Smoke-Tests scripts/smoke-block3.sh (9/9 grün) und Integration-Test tests/integration/test_tenant_isolation.py (200× parallel, 0 Cross-Hits, 1.97s).

Changed (Block 3)

  • src/kora_platform/main.py — init_engines/dispose_engines im Lifespan, /metrics-Endpoint, tenants-Router eingebunden.
  • src/kora_platform/config.py — drei neue Settings-Felder (db_app_dsn, db_vendor_dsn, db_admin_dsn, keycloak_public_base_url).
  • docker-compose.platform.yml — drei DSN-ENVs (KORA_DB_APP_DSN, KORA_DB_VENDOR_DSN, KORA_DB_ADMIN_DSN) und KORA_KEYCLOAK_PUBLIC_BASE_URL an api-Service.
  • Keine Alembic-Migration: die vorhandene kora_platform_vendor-Rolle aus Block 1.5 (BYPASSRLS) wird wiederverwendet — keine neue Rolle nötig.

Added

  • FastAPI-App-Skelett (aus Block 3 vorgezogen, siehe Roadmap Block 2): src/kora_platform/main.py, config.py, logging_setup.py, db/async_session.py, api/health.py. Endpoints /health/live und /health/ready (prüft Postgres + Redis + Keycloak).
  • Docker-Service kora-platform-api auf Port 8280, Build über infra/docker/Dockerfile.platform.
  • Keycloak Dual-Realm-Setup (Phase A Block 2):
    • Realms kora-platform und kora-tenants via Init-Scripts (infra/keycloak/realms/*.json).
    • Drei Clients im kora-platform-Realm: kora-api (Standard), kora-api-vendor-breakglass und kora-api-vendor-tunnel (jeweils 2h Client-Session-Max).
    • Ein Client kora-api im kora-tenants-Realm.
    • First-Broker-Login-Flow "kora-tenants first broker login" mit idp-create-user-if-unique als REQUIRED-Step — verhindert Cross-Tenant-Account-Linking bei späterer Multi-IdP-Nutzung (Block 17).
    • Default-Rolle tenant-viewer für neue Shadow-User im kora-tenants-Realm.
    • Vendor-Accounts vendor-support, vendor-breakglass, vendor-tunnel angelegt mit enabled=false.
    • eventsEnabled=true + adminEventsEnabled=true für den IdpAuditPoller.
  • CLI-Kommando kora-platform bootstrap-operator-admin --email … (in src/kora_platform/cli/bootstrap.py): legt einen Operator-Admin-User im kora-platform-Realm mit Rolle operator-admin + UPDATE_PASSWORD-Required-Action an und sendet via Keycloak-SMTP einen 48h-gültigen execute-actions-email-Link.
  • Vendor-Audit-Polling-Task IdpAuditPoller in src/kora_platform/services/audit_poller.py: pollt alle 60s die Keycloak-Admin-Events-API, filtert LOGIN-Events der vendor-*-User und sendet E-Mail an KORA_AUDIT_RECIPIENT_EMAIL. Redis-Cursor unter avs:audit:last_event_ts.
  • MailHog als Dev-SMTP (Service kora-platform-mailhog): SMTP intern im kora-platform-net (kein Host-Port), Web-UI auf 8239.
  • NPMplus-Runbook für auth.kora.luki-net.org unter deployment/npmplus-auth-subdomain.md.
  • Patch-Script infra/keycloak/patch-dev-redirects.sh ergänzt http://localhost:8280/* als Dev-Redirect-URI ohne die Produktions-Config anzufassen.

Changed

  • ENV-Migration in .env.platform: KEYCLOAK_ADMIN_USER/_PASSWORDKC_BOOTSTRAP_ADMIN_USERNAME/_PASSWORD, KEYCLOAK_DB_PASSWORDKC_DB_PASSWORD / KC_DB_USERNAME (Keycloak-26-Standard). Neue Vars KC_SMTP_HOST, KC_SMTP_PORT, KC_SMTP_FROM, KC_SMTP_AUTH, KORA_AUDIT_RECIPIENT_EMAIL.
  • Keycloak-Init-Verzeichnis: infra/keycloak/kora-platform/ (leerer Platzhalter) → infra/keycloak/realms/ mit beiden Realm-JSONs. Compose-Mount-Pfad entsprechend angepasst.
  • pyproject.toml: click>=8.1 und httpx>=0.27 als Runtime-Deps aufgenommen, neuer Entry-Point kora-platformkora_platform.cli.bootstrap:cli.

[0.0.1] — Block 1 (12ea700, 2026-04-20)

Added

  • Tenant-Datenmodell mit 18 Tabellen (src/kora_platform/db/models/).
  • Alembic-Migrationen 0001–0003 (Schema, Trigger, RLS).
  • Drei PostgreSQL-Rollen: kora_platform_migrator (BYPASSRLS), kora_platform_app (RLS-geschützt), kora_platform_vendor (BYPASSRLS, auditiert).
  • Separate Alembic-Struktur unter alembic.ini.platform + alembic/platform/.