Compare commits

..

4 Commits

Author SHA1 Message Date
Sharang Parnerkar 685899a9bc chore: bump next to patched versions (CVE-2026-44578)
Deploy to Coolify / deploy (push) Has been cancelled
studio-v2: 16.1.4 → 16.2.5
website: 15.5.9 → 15.5.16
admin-lehrer: 15.1.0 → 15.5.16

Patches unauthenticated SSRF in WebSocket upgrade handler.
GHSA-c4j6-fc7j-m34r.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:16:23 +02:00
Sharang Parnerkar b697963186 fix: use Alpine-compatible addgroup/adduser flags in Dockerfiles
Deploy to Coolify / deploy (push) Has been cancelled
Replace --system/--gid/--uid (Debian syntax) with -S/-g/-u (BusyBox/Alpine).
Coolify ARG injection causes exit code 255 with Debian-style flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:38:31 +01:00
Sharang Parnerkar ef6237ffdf refactor(coolify): externalize postgres, qdrant, S3
Deploy to Coolify / deploy (push) Has been cancelled
- Replace bp-core-postgres with POSTGRES_HOST env var
- Replace bp-core-qdrant with QDRANT_URL env var
- Replace bp-core-minio with S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:23:32 +01:00
Sharang Parnerkar 41a8f3b183 feat: add Coolify deployment configuration
Deploy to Coolify / deploy (push) Has been cancelled
Add docker-compose.coolify.yml (8 services), .env.coolify.example,
and Gitea Action workflow for Coolify API deployment. Removes
core-health-check, paddleocr, transcription-worker, agent-core,
drive, and docs. Adds Traefik labels for *.breakpilot.ai domain
routing with Let's Encrypt SSL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:43:15 +01:00
1588 changed files with 154577 additions and 867063 deletions
+11 -85
View File
@@ -6,31 +6,22 @@
| Geraet | Rolle | Aufgaben |
|--------|-------|----------|
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
| **MacBook** | Client | Claude Terminal, Browser (Frontend-Tests) |
| **Mac Mini** | Server | Docker, alle Services, Code-Ausfuehrung, Tests, Git |
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
**WICHTIG:** Die Entwicklung findet vollstaendig auf dem **Mac Mini** statt!
### Entwicklungsworkflow
### SSH-Verbindung
```bash
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
# 2. Committen und pushen:
git push origin main && git push gitea main
ssh macmini
# Projektverzeichnis:
cd /Users/benjaminadmin/Projekte/breakpilot-lehrer
# 3. Auf Mac Mini pullen und Container neu bauen:
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-lehrer pull --no-rebase origin main"
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml build --no-cache <service>"
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml up -d <service>"
# Einzelbefehle (BEVORZUGT):
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && <cmd>"
```
### SSH-Verbindung (fuer Docker/Tests)
**WICHTIG:** `cd` in SSH-Kommandos funktioniert NICHT zuverlaessig! Stattdessen:
- Git: `git -C /Users/benjaminadmin/Projekte/breakpilot-lehrer <cmd>`
- Docker: `/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml <cmd>`
- Logs: `/usr/local/bin/docker logs -f bp-lehrer-<service>`
---
## Voraussetzung
@@ -172,10 +163,10 @@ breakpilot-lehrer/
```bash
# Lehrer-Services starten (Core muss laufen!)
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml up -d"
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && /usr/local/bin/docker compose up -d"
# Einzelnen Service neu bauen
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml build --no-cache <service>"
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && /usr/local/bin/docker compose build --no-cache <service>"
# Logs
ssh macmini "/usr/local/bin/docker logs -f bp-lehrer-<service>"
@@ -185,7 +176,6 @@ ssh macmini "/usr/local/bin/docker ps --filter name=bp-lehrer"
```
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
**WICHTIG:** Immer `-f` mit vollem Pfad zur docker-compose.yml nutzen, `cd` in SSH funktioniert nicht!
### Frontend-Entwicklung
@@ -256,67 +246,3 @@ ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && git push all
| `website/app/admin/klausur-korrektur/` | Korrektur-Workspace |
| `backend-lehrer/classroom_api.py` | Classroom Engine |
| `backend-lehrer/state_engine_api.py` | State Engine |
---
## Compliance: Kein Scan/OCR im Kunden-Frontend (NON-NEGOTIABLE)
Studio-v2 (Kunden-Frontend, Port 443) darf **KEINE** Features enthalten die:
- Buchseiten/Schulbuecher von Dritten rekonstruieren oder reproduzieren
- Aktiv zum Upload fremder urheberrechtlich geschuetzter Werke auffordern
**Erlaubt** in studio-v2:
- Upload eigener Dokumente durch Lehrer (eigene Arbeitsblaetter, Tests, Materialien)
- OCR/Verarbeitung von Dokumenten bei denen der Lehrer Urheber ist
- Manuelle Vokabeleingabe durch Lehrer
- Vorschlagslisten aus eigenem Woerterbuch (160k MIT-lizenzierte Woerter)
- Lernunit-Erstellung aus eigenen/ausgewaehlten Inhalten
- Audio/Bild/Quiz/Karteikarten-Generierung
**Erweiterte OCR/Scan Features** (z.B. Vision-LLM Fusion, A/B Testing Toggles) bleiben
im Admin-Frontend (admin-lehrer, Port 3002) fuer Entwicklung und Testing.
**Hintergrund**: Urheberrechtliche Haftung der GmbH. Das System ist eine
Didaktik-Engine (Transformation + Lernen), KEIN Content-Reconstruction-Tool.
---
## Code-Qualitaet Guardrails (NON-NEGOTIABLE)
> Vollstaendige Details: `.claude/rules/architecture.md`
> Ausnahmen: `.claude/rules/loc-exceptions.txt`
### File Size Budget
- **Hard Cap: 500 LOC** pro Datei
- Wenn eine Aenderung eine Datei ueber 500 LOC bringen wuerde: **erst splitten, dann aendern**
- Ausnahmen nur mit Begruendung in `loc-exceptions.txt` + `[guardrail-change]` Commit-Marker
### Architektur
- **Python:** Routes duenn → Business Logic in Services → Persistenz in Repositories
- **Go:** Handler ≤40 LOC → Service-Layer → Repository-Pattern
- **TypeScript/Next.js:** page.tsx duenn → Server Actions, Queries, Components auslagern
- **Types:** Monolithische types.ts frueh splitten, types.ts + types/ Shadowing vermeiden
### Workflow (bei jeder Aenderung)
1. Datei lesen + LOC pruefen
2. Wenn nahe am Budget → erst splitten
3. Minimale kohaerente Aenderung
4. Verifikation (Tests + Lint)
5. Zusammenfassung: Was geaendert, was verifiziert, Restrisiko
### Commit-Marker
- `[migration-approved]` — Schema-/Migrations-Aenderungen
- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh
- `[split-required]` — Aenderung beginnt mit Datei-Split
- `[interface-change]` — Public API Contracts geaendert
### LOC-Check ausfuehren
```bash
bash scripts/check-loc.sh --changed # nur geaenderte Dateien
bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations)
```
-46
View File
@@ -1,46 +0,0 @@
# Architecture Rule — BreakPilot Lehrer
## File Size Budget
Hard default: **500 LOC max** per file.
Soft targets:
- Handler/Router/Service: 300-400 LOC
- Models/Schemas/Types: 200-300 LOC
- Utilities: 100-200 LOC
Ausnahmen nur in `.claude/rules/loc-exceptions.txt` mit Begruendung.
## Split-Trigger
Sofort splitten wenn:
- Datei ueberschreitet 500 LOC
- Datei wuerde nach Aenderung 500 LOC ueberschreiten
- Datei mischt Transport + Business Logic + Persistence
- Datei enthaelt mehrere unabhaengig testbare Verantwortlichkeiten
## Python (backend-lehrer, klausur-service, voice-service)
- Routes duenn halten — Business Logic in Services
- Persistenz in Repositories/Data-Access-Module
- Pydantic Schemas nach Domain splitten
- Zirkulaere Imports vermeiden
## Go (school-service, edu-search-service)
- Handler duenn halten (≤40 LOC)
- Business Logic in Services/Use-Cases
- Transport/Request-Decoding getrennt von Domain-Logik
## TypeScript / Next.js (admin-lehrer, studio-v2, website)
- page.tsx duenn halten — Server Actions, Queries, Forms auslagern
- Monolithische types.ts frueh splitten
- types.ts + types/ Shadowing vermeiden
- Shared Client/Server Types explizit trennen
## Entscheidungsreihenfolge
1. Bestehendes kleines kohaeesives Modul wiederverwenden
2. Neues Modul in der Naehe erstellen
3. Ueberfuellte Datei splitten, neues Verhalten in richtiges Split-Modul
4. Nur als letzter Ausweg: Grosse bestehende Datei erweitern
-54
View File
@@ -1,54 +0,0 @@
# LOC Exceptions — BreakPilot Lehrer
# Format: <glob> | owner=<person> | reason=<why> | review=<date>
#
# Jede Ausnahme braucht Begruendung und Review-Datum.
# Temporaere Ausnahmen muessen mit [guardrail-change] Commit-Marker versehen werden.
# Generated / Build Artifacts
**/node_modules/** | owner=infra | reason=npm packages | review=permanent
**/.next/** | owner=infra | reason=Next.js build output | review=permanent
**/__pycache__/** | owner=infra | reason=Python bytecode | review=permanent
**/venv/** | owner=infra | reason=Python virtualenv | review=permanent
# Test-Dateien (duerfen groesser sein fuer Table-Driven Tests)
**/*test*.py | owner=all | reason=Tests mit Table-Driven Patterns duerfen groesser sein | review=permanent
**/*test*.go | owner=all | reason=Go Tests mit Table-Driven Patterns | review=permanent
**/*test*.ts | owner=all | reason=TypeScript Tests | review=permanent
**/tests/** | owner=all | reason=Test-Verzeichnisse | review=permanent
# Blog-Seiten (reine statische Inhalte, kein Code)
**/blog/*/page.tsx | owner=website | reason=Statische Blog-Artikel (MDX-artig, reiner Content) | review=permanent
# Pure Data Registries (keine Logik, nur Daten-Definitionen)
**/dsfa_sources_registry.py | owner=klausur | reason=Pure data registry (license + source definitions, no logic) | review=2027-01-01
**/legal_corpus_registry.py | owner=klausur | reason=Pure data registry (Regulation dataclass + 47 regulation definitions, no logic) | review=2027-01-01
**/backlog/backlog-items.ts | owner=admin-lehrer | reason=Pure data array (506 LOC, no logic, only BacklogItem[] literals) | review=2027-01-01
**/lib/module-registry-data.ts | owner=admin-lehrer | reason=Pure data array (510 LOC, no logic, only BackendModule[] literals) | review=2027-01-01
# Algorithmic monolith — detect_column_geometry() allein 411 LOC, nicht weiter teilbar
**/cv_layout_columns.py | owner=klausur | reason=detect_column_geometry ist eine einzelne 411-LOC Funktion (Whitespace-Gap-Analyse) | review=2026-10-01
**/ocr/layout/columns.py | owner=klausur | reason=Same file moved to ocr/ package | review=2026-10-01
# Two indivisible route handlers (~230 LOC each) that cannot be split further
**/vocab_worksheet_compare_api.py | owner=klausur | reason=compare_ocr_methods (234 LOC) + analyze_grid (255 LOC), each a single cohesive handler | review=2026-10-01
**/vocab/worksheet/compare_api.py | owner=klausur | reason=Same file moved to vocab/ package | review=2026-10-01
# TypeScript Data Catalogs (admin-lehrer/lib/sdk/)
# Pure exported const arrays/objects with type definitions, no business logic.
# DSGVO/GDPR compliance catalogs: risk scenarios, mitigations, legal bases, checklists, etc.
**/lib/sdk/vendor-compliance/catalog/*.ts | owner=admin-lehrer | reason=Pure data catalogs (processing-activities 813, vendor-templates 564, legal-basis 562 LOC) | review=2027-01-01
**/lib/sdk/vendor-compliance/contract-review/findings.ts | owner=admin-lehrer | reason=Pure data catalog (573 LOC, FindingTemplate[] literals) | review=2027-01-01
**/lib/sdk/vendor-compliance/contract-review/checklists.ts | owner=admin-lehrer | reason=Pure data catalog (508 LOC, ChecklistItem[] literals) | review=2027-01-01
**/lib/sdk/dsfa/mitigation-library.ts | owner=admin-lehrer | reason=Pure data catalog (694 LOC, CatalogMitigation[] literals) | review=2027-01-01
**/lib/sdk/dsfa/eu-legal-frameworks.ts | owner=admin-lehrer | reason=Pure data catalog (622 LOC, legal framework definitions) | review=2027-01-01
**/lib/sdk/dsfa/risk-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (615 LOC, CatalogRisk[] literals) | review=2027-01-01
**/lib/sdk/vvt-baseline-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (630 LOC, BaselineTemplate[] literals) | review=2027-01-01
**/lib/sdk/loeschfristen-baseline-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (578 LOC, retention period templates) | review=2027-01-01
# Single SSE generator orchestrating 6 pipeline steps — cannot split generator context
**/ocr_pipeline_auto_steps.py | owner=klausur | reason=run_auto is a single async generator yielding SSE events across 6 steps (528 LOC) | review=2026-10-01
**/ocr/pipeline/auto_steps.py | owner=klausur | reason=Same file moved to ocr/ package | review=2026-10-01
# Legacy — TEMPORAER bis Refactoring abgeschlossen
# Dateien hier werden Phase fuer Phase abgearbeitet und entfernt.
# KEINE neuen Ausnahmen ohne [guardrail-change] Commit-Marker!
-242
View File
@@ -1,242 +0,0 @@
# OCR Pipeline Erweiterungen - Entwicklerdokumentation
**Status:** Produktiv
**Letzte Aktualisierung:** 2026-04-15
**URL:** https://macmini:3002/ai/ocr-kombi
---
## Uebersicht
Erweiterungen der OCR Kombi Pipeline (14 Steps, 0-13):
- **SmartSpellChecker** — LLM-freie OCR-Korrektur mit Spracherkennung
- **Box-Grid-Review** (Step 11) — Eingebettete Boxen verarbeiten
- **Ansicht/Spreadsheet** (Step 12) — Fortune Sheet Excel-Editor
---
## Pipeline Steps
| Step | ID | Name | Komponente |
|------|----|------|------------|
| 0 | upload | Upload | StepUpload |
| 1 | orientation | Orientierung | StepOrientation |
| 2 | page-split | Seitentrennung | StepPageSplit |
| 3 | deskew | Begradigung | StepDeskew |
| 4 | dewarp | Entzerrung | StepDewarp |
| 5 | content-crop | Zuschneiden | StepContentCrop |
| 6 | ocr | OCR | StepOcr |
| 7 | structure | Strukturerkennung | StepStructure |
| 8 | grid-build | Grid-Aufbau | StepGridBuild |
| 9 | grid-review | Grid-Review | StepGridReview |
| 10 | gutter-repair | Wortkorrektur | StepGutterRepair |
| **11** | **box-review** | **Box-Review** | **StepBoxGridReview** |
| **12** | **ansicht** | **Ansicht** | **StepAnsicht** |
| 13 | ground-truth | Ground Truth | StepGroundTruth |
Step-Definitionen: `admin-lehrer/app/(admin)/ai/ocr-kombi/types.ts`
---
## SmartSpellChecker
**Datei:** `klausur-service/backend/smart_spell.py`
**Tests:** `tests/test_smart_spell.py` (43 Tests)
**Lizenz:** Nur pyspellchecker (MIT) — kein LLM, kein Hunspell
### Features
| Feature | Methode |
|---------|---------|
| Spracherkennung | Dual-Dictionary EN/DE Heuristik |
| a/I Disambiguation | Bigram-Kontext (Folgewort-Lookup) |
| Boundary Repair | Frequenz-basiert: `Pound sand``Pounds and` |
| Context Split | `anew``a new` (Allow/Deny-Liste) |
| Multi-Digit | BFS: `sch00l``school` |
| Cross-Language Guard | DE-Woerter in EN-Spalte nicht falsch korrigieren |
| Umlaut-Korrektur | `Schuler``Schueler` |
| IPA-Schutz | Inhalte in [Klammern] nie aendern |
| Slash→l | `p/``pl` (kursives l als / erkannt) |
| Abkuerzungen | 120+ aus `_KNOWN_ABBREVIATIONS` |
### Integration
```python
# In cv_review.py (LLM Review Step):
from smart_spell import SmartSpellChecker
_smart = SmartSpellChecker()
result = _smart.correct_text(text, lang="en") # oder "de" oder "auto"
# In grid_editor_api.py (Grid Build + Box Build):
# Automatisch nach Grid-Aufbau und Box-Grid-Aufbau
```
### Frequenz-Scoring
Boundary Repair vergleicht Wort-Frequenz-Produkte:
- `old_freq = word_freq(w1) * word_freq(w2)`
- `new_freq = word_freq(repaired_w1) * word_freq(repaired_w2)`
- Akzeptiert wenn `new_freq > old_freq * 5`
- Abkuerzungs-Bonus nur wenn Original-Woerter selten (freq < 1e-6)
---
## Box-Grid-Review (Step 11)
**Frontend:** `admin-lehrer/components/ocr-kombi/StepBoxGridReview.tsx`
**Backend:** `klausur-service/backend/cv_box_layout.py`, `grid_editor_api.py`
**Tests:** `tests/test_box_layout.py` (13 Tests)
### Backend-Endpoints
```
POST /api/v1/ocr-pipeline/sessions/{id}/build-box-grids
```
Verarbeitet alle erkannten Boxen aus `structure_result`:
1. Filtert Header/Footer-Boxen (obere/untere 7% der Bildhoehe)
2. Extrahiert OCR-Woerter pro Box aus `raw_paddle_words`
3. Klassifiziert Layout: `flowing` | `columnar` | `bullet_list` | `header_only`
4. Baut Grid mit layout-spezifischer Logik
5. Wendet SmartSpellChecker an
### Box Layout Klassifikation (`cv_box_layout.py`)
| Layout | Erkennung | Grid-Aufbau |
|--------|-----------|-------------|
| `header_only` | ≤5 Woerter oder 1 Zeile | 1 Zelle, alles zusammen |
| `flowing` | Gleichmaessige Zeilenbreite | 1 Spalte, Bullet-Gruppierung per Einrueckung |
| `bullet_list` | ≥40% Zeilen mit Bullet-Marker | 1 Spalte, Bullet-Items |
| `columnar` | Mehrere X-Cluster | Standard-Spaltenerkennung |
### Bullet-Einrueckung
Erkennung ueber Left-Edge-Analyse:
- Minimale Einrueckung = Bullet-Ebene
- Zeilen mit >15px mehr Einrueckung = Folgezeilen
- Folgezeilen werden mit `\n` in die Bullet-Zelle integriert
- Fehlende `•` Marker werden automatisch ergaenzt
### Colspan-Erkennung (`grid_editor_helpers.py`)
Generische Funktion `_detect_colspan_cells()`:
- Laeuft nach `_build_cells()` fuer ALLE Zonen
- Nutzt Original-Wort-Bloecke (vor `_split_cross_column_words`)
- Wort-Block der ueber Spaltengrenze reicht → `spanning_header` mit `colspan=N`
- Beispiel: "In Britain you pay with pounds and pence." ueber 2 Spalten
### Spalten-Erkennung in Boxen
Fuer kleine Zonen (≤60 Woerter):
- `gap_threshold = max(median_h * 1.0, 25)` statt `3x median`
- PaddleOCR liefert Multi-Word-Bloecke → alle Gaps sind Spalten-Gaps
---
## Ansicht / Spreadsheet (Step 12)
**Frontend:** `admin-lehrer/components/ocr-kombi/StepAnsicht.tsx`, `SpreadsheetView.tsx`
**Bibliothek:** `@fortune-sheet/react` (MIT, v1.0.4)
### Architektur
Split-View:
- **Links:** Original-Scan mit OCR-Overlay (`/image/words-overlay`)
- **Rechts:** Fortune Sheet Spreadsheet mit Multi-Sheet-Tabs
### Multi-Sheet Ansatz
Jede Zone wird ein eigenes Sheet-Tab:
- Sheet "Vokabeln" — Hauptgrid mit EN/DE Spalten
- Sheet "Pounds and euros" — Box 1 mit eigenen 4 Spalten
- Sheet "German leihen" — Box 2 als Fliesstexttext
Grund: Spaltenbreiten sind pro Zone unterschiedlich optimiert. Excel-Limitation: Spaltenbreite gilt fuer die ganze Spalte.
### Zell-Formatierung
| Format | Quelle | Fortune Sheet Property |
|--------|--------|----------------------|
| Fett | `is_header`, `is_bold`, groessere Schrift | `bl: 1` |
| Schriftfarbe | OCR word_boxes color | `fc: '#hex'` |
| Hintergrund | Box bg_hex, Header | `bg: '#hex08'` |
| Text-Wrap | Mehrzeilige Zellen (\n) | `tb: '2'` |
| Vertikal oben | Mehrzeilige Zellen | `vt: 0` |
| Groessere Schrift | word_box height >1.3x median | `fs: 12` |
### Spaltenbreiten
Auto-Fit: `max(laengster_text * 7.5 + 16, original_px * scaleFactor)`
### Toolbar
`undo, redo, font-bold, font-italic, font-strikethrough, font-color, background, font-size, horizontal-align, vertical-align, text-wrap, merge-cell, border`
---
## Unified Grid (Backend)
**Datei:** `klausur-service/backend/unified_grid.py`
**Tests:** `tests/test_unified_grid.py` (10 Tests)
Mergt alle Zonen in ein einzelnes Grid (fuer Export/Analyse):
```
POST /api/v1/ocr-pipeline/sessions/{id}/build-unified-grid
GET /api/v1/ocr-pipeline/sessions/{id}/unified-grid
```
- Dominante Zeilenhoehe = Median der Content-Row-Abstaende
- Full-Width Boxen: Rows direkt integriert
- Partial-Width Boxen: Extra-Rows eingefuegt wenn Box mehr Zeilen hat
- Box-Zellen mit `source_zone_type: "box"` und `box_region` Metadaten
---
## Dateistruktur
### Backend (klausur-service)
| Datei | Zeilen | Beschreibung |
|-------|--------|--------------|
| `grid_build_core.py` | 213 | `_build_grid_core()` — Orchestrator (ruft Phase-Module) |
| `grid_build_zones.py` | 462 | Phase 2: Bildverarbeitung, Grafik-/Box-Erkennung, Zonen |
| `grid_build_cleanup.py` | 390 | Phase 3: Junk-Zeilen, Artefakte, Pipes, Randstreifen |
| `grid_build_text_ops.py` | 489 | Phase 4+5a: Farben, Ueberschriften, IPA, Seitenreferenzen |
| `grid_build_cell_ops.py` | 305 | Phase 5b: Bullet-Entfernung, Wort-Reihenfolge, max_columns |
| `grid_build_finalize.py` | 452 | Phase 5c+6: Woerterbuch, Silben, Rechtschreibung, Ergebnis |
| `grid_editor_api.py` | 474 | REST-Endpoints (build, save, get, gutter, box, unified) |
| `grid_editor_helpers.py` | 1737 | Helper: Spalten, Rows, Cells, Colspan, Header |
| `smart_spell.py` | 587 | SmartSpellChecker |
| `cv_box_layout.py` | 339 | Box-Layout-Klassifikation + Grid-Aufbau |
| `unified_grid.py` | 425 | Unified Grid Builder |
### Frontend (admin-lehrer)
| Datei | Zeilen | Beschreibung |
|-------|--------|--------------|
| `StepBoxGridReview.tsx` | 283 | Box-Review Step 11 |
| `StepAnsicht.tsx` | 112 | Ansicht Step 12 (Split-View) |
| `SpreadsheetView.tsx` | ~160 | Fortune Sheet Integration |
| `GridTable.tsx` | 652 | Grid-Editor Tabelle (Steps 9-11) |
| `useGridEditor.ts` | 985 | Grid-Editor Hook |
### Tests
| Datei | Tests | Beschreibung |
|-------|-------|--------------|
| `test_smart_spell.py` | 43 | Spracherkennung, Boundary Repair, IPA-Schutz |
| `test_box_layout.py` | 13 | Layout-Klassifikation, Bullet-Gruppierung |
| `test_unified_grid.py` | 10 | Unified Grid, Box-Klassifikation |
| **Gesamt** | **66** | |
---
## Aenderungshistorie
| Datum | Aenderung |
|-------|-----------|
| 2026-04-15 | Fortune Sheet Multi-Sheet Tabs, Bullet-Points, Auto-Fit, Refactoring |
| 2026-04-14 | Unified Grid, Ansicht Step, Colspan-Erkennung |
| 2026-04-13 | Box-Grid-Review Step, Spalten in Boxen, Header/Footer Filter |
| 2026-04-12 | SmartSpellChecker, Frequency Scoring, IPA-Schutz, Vocab-Worksheet Refactoring |
-70
View File
@@ -1,70 +0,0 @@
# Shim Cleanup Tracker
**Status:** Shims aktiv — Consumer-Imports noch auf alten Pfaden
**Erstellt:** 2026-04-25
**Ziel:** Shims schrittweise loeschen sobald Consumer auf neue Pfade aktualisiert sind
## Was sind Shims?
Beim Restructuring wurden Dateien in Packages verschoben (z.B. `cv_layout.py``ocr/layout/layout.py`). Am alten Pfad bleibt ein 4-Zeilen Redirect:
```python
# cv_layout.py (shim)
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("ocr.layout.layout")
```
Damit brechen keine bestehenden `from cv_layout import ...` Imports.
## Cleanup-Prozess (pro Shim)
1. `grep -rn "from <old_module> import\|import <old_module>" --include="*.py"` — finde alle Consumer
2. Consumer-Imports auf neuen Pfad aktualisieren (z.B. `from ocr.layout.layout import ...`)
3. Shim-Datei loeschen
4. Tests ausfuehren
## Shim-Inventar
### klausur-service/backend/ (171 Shims)
| Gruppe | Anzahl | Alter Pfad | Neuer Pfad |
|--------|--------|------------|------------|
| cv_* (OCR Core) | 47 | `cv_layout.py` etc. | `ocr/layout/layout.py` etc. |
| ocr_pipeline_* | 30 | `ocr_pipeline_api.py` etc. | `ocr/pipeline/api.py` etc. |
| ocr_labeling_* | 5 | `ocr_labeling_api.py` etc. | `ocr/labeling/api.py` etc. |
| ocr related | 11 | `page_crop.py` etc. | `ocr/pipeline/page_crop.py` etc. |
| grid_* | 16 | `grid_build_core.py` etc. | `grid/build/core.py` etc. |
| vocab_* | 10 | `vocab_worksheet_api.py` etc. | `vocab/worksheet/api.py` etc. |
| korrektur | 11 | `eh_templates.py` etc. | `korrektur/eh_templates.py` etc. |
| zeugnis_* | 10 | `zeugnis_api.py` etc. | `zeugnis/api.py` etc. |
| admin_* | 4 | `admin_api.py` etc. | `admin/api.py` etc. |
| compliance/rbac | 8 | `rbac.py` etc. | `compliance/rbac.py` etc. |
| worksheet/nru | 9 | `worksheet_editor_api.py` etc. | `worksheet/editor_api.py` etc. |
| training_* | 6 | `training_api.py` etc. | `training/api.py` etc. |
| metrics_* | 4 | `metrics_db.py` etc. | `metrics/db.py` etc. |
### backend-lehrer/ (43 Shims)
| Gruppe | Anzahl | Alter Pfad | Neuer Pfad |
|--------|--------|------------|------------|
| abitur_docs_* | 3 | `abitur_docs_api.py` etc. | `abitur/api.py` etc. |
| correction_* | 4 | `correction_api.py` etc. | `correction/api.py` etc. |
| messenger_* | 5 | `messenger_api.py` etc. | `messenger/api.py` etc. |
| recording_* | 6 | `recording_api.py` etc. | `recording/api.py` etc. |
| unit_* + learning_* | 13 | `unit_api.py` etc. | `units/api.py` etc. |
| teacher_dashboard_* | 3 | `teacher_dashboard_api.py` etc. | `dashboard/api.py` etc. |
| game_* | 5 | `game_api.py` etc. | `game/api.py` etc. |
| letters/certificates | 4 | `letters_api.py` etc. | `letters/api.py` etc. |
## Prioritaet
1. **Hoch:** Shims die von `main.py` importiert werden (Router-Registrierung)
2. **Mittel:** Shims die von anderen Modulen importiert werden
3. **Niedrig:** Shims die nur von Tests importiert werden
## Wann loeschen?
- Bei der naechsten groesseren Aenderung an einem Modul → gleich die Consumer-Imports mit aktualisieren
- Oder als dedizierte Cleanup-Session wenn alle Tests gruen sind
- NICHT alle auf einmal — Modul fuer Modul vorgehen
+1 -25
View File
@@ -188,35 +188,11 @@ ssh macmini "docker compose up -d klausur-service studio-v2"
---
## Frontend Refactoring (2026-04-12)
`page.tsx` wurde von 2337 Zeilen in 14 Dateien aufgeteilt:
```
studio-v2/app/vocab-worksheet/
├── page.tsx # 198 Zeilen — Orchestrator
├── types.ts # Interfaces, VocabWorksheetHook
├── constants.ts # API-Base, Formats, Defaults
├── useVocabWorksheet.ts # 843 Zeilen — Custom Hook (alle State + Logik)
└── components/
├── UploadScreen.tsx # Session-Liste + Dokument-Auswahl
├── PageSelection.tsx # PDF-Seitenauswahl
├── VocabularyTab.tsx # Vokabel-Tabelle + IPA/Silben
├── WorksheetTab.tsx # Format-Auswahl + Konfiguration
├── ExportTab.tsx # PDF-Download
├── OcrSettingsPanel.tsx # OCR-Filter Einstellungen
├── FullscreenPreview.tsx # Vollbild-Vorschau Modal
├── QRCodeModal.tsx # QR-Upload Modal
└── OcrComparisonModal.tsx # OCR-Vergleich Modal
```
---
## Erweiterung: Neue Formate hinzufuegen
1. **Backend**: Neuen Generator in `klausur-service/backend/` erstellen
2. **API**: Neuen Endpoint in `vocab_worksheet_api.py` hinzufuegen
3. **Frontend**: Format zu `worksheetFormats` Array in `constants.ts` hinzufuegen
3. **Frontend**: Format zu `worksheetFormats` Array in `page.tsx` hinzufuegen
4. **Doku**: Diese Datei aktualisieren
---
-9
View File
@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Bash",
"Write",
"Read"
]
}
}
+79
View File
@@ -0,0 +1,79 @@
# =========================================================
# BreakPilot Lehrer — Coolify Environment Variables
# =========================================================
# Copy these into Coolify's environment variable UI
# for the breakpilot-lehrer Docker Compose resource.
# =========================================================
# --- External PostgreSQL (Coolify-managed, same as Core) ---
POSTGRES_HOST=<coolify-postgres-hostname>
POSTGRES_PORT=5432
POSTGRES_USER=breakpilot
POSTGRES_PASSWORD=CHANGE_ME_SAME_AS_CORE
POSTGRES_DB=breakpilot_db
# --- Security ---
JWT_SECRET=CHANGE_ME_SAME_AS_CORE
# --- External S3 Storage (same as Core) ---
S3_ENDPOINT=<s3-endpoint-host:port>
S3_ACCESS_KEY=CHANGE_ME_SAME_AS_CORE
S3_SECRET_KEY=CHANGE_ME_SAME_AS_CORE
S3_BUCKET=breakpilot-rag
S3_SECURE=true
# --- External Qdrant (Coolify-managed, same as Core) ---
QDRANT_URL=http://<coolify-qdrant-hostname>:6333
# --- Session ---
SESSION_TTL_HOURS=24
# --- SMTP (Real mail server) ---
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=noreply@breakpilot.ai
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
SMTP_FROM_NAME=BreakPilot
SMTP_FROM_ADDR=noreply@breakpilot.ai
# --- LLM / Ollama (optional) ---
OLLAMA_BASE_URL=
OLLAMA_URL=
OLLAMA_ENABLED=false
OLLAMA_DEFAULT_MODEL=
OLLAMA_VISION_MODEL=
OLLAMA_CORRECTION_MODEL=
OLLAMA_TIMEOUT=120
# --- Anthropic (optional) ---
ANTHROPIC_API_KEY=
# --- vast.ai GPU (optional) ---
VAST_API_KEY=
VAST_INSTANCE_ID=
# --- Game Settings ---
GAME_USE_DATABASE=true
GAME_REQUIRE_AUTH=true
GAME_REQUIRE_BILLING=true
GAME_LLM_MODEL=
# --- Frontend URLs (build args) ---
NEXT_PUBLIC_API_URL=https://api-lehrer.breakpilot.ai
NEXT_PUBLIC_KLAUSUR_SERVICE_URL=https://klausur.breakpilot.ai
NEXT_PUBLIC_VOICE_SERVICE_URL=wss://voice.breakpilot.ai
NEXT_PUBLIC_BILLING_API_URL=https://api-core.breakpilot.ai
NEXT_PUBLIC_APP_URL=https://app.breakpilot.ai
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# --- Edu Search ---
EDU_SEARCH_URL=
EDU_SEARCH_API_KEY=
OPENSEARCH_PASSWORD=CHANGE_ME_OPENSEARCH_PASSWORD
# --- Misc ---
CONTROL_API_KEY=
ALERTS_AGENT_ENABLED=false
PADDLEOCR_SERVICE_URL=
TROCR_SERVICE_URL=
CAMUNDA_URL=
-17
View File
@@ -30,23 +30,6 @@ OLLAMA_VISION_MODEL=llama3.2-vision
OLLAMA_CORRECTION_MODEL=llama3.2
OLLAMA_TIMEOUT=120
# OCR-Pipeline: LLM-Review (Schritt 6)
# Kleine Modelle reichen fuer Zeichen-Korrekturen (0->O, 1->l, 5->S)
# Optionen: qwen3:0.6b, qwen3:1.7b, gemma3:1b, qwen3.5:35b-a3b
OLLAMA_REVIEW_MODEL=qwen3:0.6b
# Eintraege pro Ollama-Call. Groesser = weniger HTTP-Overhead.
OLLAMA_REVIEW_BATCH_SIZE=20
# OCR-Pipeline: Engine fuer Schritt 5 (Worterkennung)
# Optionen: auto (bevorzugt RapidOCR), rapid, tesseract,
# trocr-printed, trocr-handwritten, lighton
OCR_ENGINE=auto
# Klausur-HTR: Primaerem Modell fuer Handschriftenerkennung (qwen2.5vl bereits auf Mac Mini)
OLLAMA_HTR_MODEL=qwen2.5vl:32b
# HTR Fallback: genutzt wenn Ollama nicht erreichbar (auto-download ~340 MB)
HTR_FALLBACK_MODEL=trocr-large
# Anthropic (optional)
ANTHROPIC_API_KEY=
+32
View File
@@ -0,0 +1,32 @@
name: Deploy to Coolify
on:
push:
branches:
- coolify
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Wait for Core deployment
run: |
echo "Waiting 30s for Core services to stabilize..."
sleep 30
- name: Deploy via Coolify API
run: |
echo "Deploying breakpilot-lehrer to Coolify..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"uuid": "${{ secrets.COOLIFY_RESOURCE_UUID }}", "force_rebuild": true}' \
"${{ secrets.COOLIFY_BASE_URL }}/api/v1/deploy")
echo "HTTP Status: $HTTP_STATUS"
if [ "$HTTP_STATUS" -ne 200 ] && [ "$HTTP_STATUS" -ne 201 ]; then
echo "Deployment failed with status $HTTP_STATUS"
exit 1
fi
echo "Deployment triggered successfully!"
-36
View File
@@ -1,36 +0,0 @@
# AGENTS.go.md — Go/Gin Konventionen
## Architektur
- `handlers/`: HTTP Transport nur — Decode, Validate, Call Service, Encode Response
- `service/` oder `usecase/`: Business Logic
- `repo/`: Storage/Integration
- `model/` oder `domain/`: Domain Entities
- `tests/`: Table-driven Tests bevorzugen
## Regeln
1. Handler ≤40 LOC — nur Decode → Service → Encode
2. Business Logic NICHT in Handlers verstecken
3. Grosse Handler nach Resource/Verb splitten
4. Request/Response DTOs nah am Transport halten
5. Interfaces nur an echten Boundaries (nicht ueberall fuer Mocks)
6. Keine Giant-Utility-Dateien
7. Generated Files nicht manuell editieren
## Split-Trigger
- Handler-Datei ueberschreitet 400-500 LOC
- Unrelated Endpoints zusammengruppiert
- Encoding/Decoding dominiert die Handler-Datei
- Service-Logik und Transport-Logik gemischt
## Verifikation
```bash
gofmt -l . | grep -q . && exit 1
go vet ./...
golangci-lint run --timeout=5m
go test -race ./...
go build ./...
```
-36
View File
@@ -1,36 +0,0 @@
# AGENTS.python.md — Python/FastAPI Konventionen
## Architektur
- `routes/` oder `api/`: Request/Response nur — kein Business Logic
- `services/`: Business Logic
- `repositories/`: Persistenz/Data Access
- `schemas/`: Pydantic Models, nach Domain gesplittet
- `tests/`: Spiegelt Produktions-Layout
## Regeln
1. Route-Dateien duenn halten (≤300 LOC)
2. Wenn eine Route-Datei 300-400 LOC erreicht → nach Resource/Operation splitten
3. Schema-Dateien nach Domain splitten wenn sie wachsen
4. Modul-Level Singleton-Kopplung vermeiden (Tests patchen falsches Symbol)
5. Patch immer das Symbol das vom getesteten Modul importiert wird
6. Dependency Injection bevorzugen statt versteckte Imports
7. Pydantic v2: `from __future__ import annotations` NICHT verwenden (bricht Pydantic)
8. Migrationen getrennt von Refactorings halten
## Split-Trigger
- Datei naehert sich oder ueberschreitet 500 LOC
- Zirkulaere Imports erscheinen
- Tests brauchen tiefes Patching
- API-Schemas mischen verschiedene Domains
- Service-Datei macht Transport UND DB-Logik
## Verifikation
```bash
ruff check .
mypy . --ignore-missing-imports --no-error-summary
pytest tests/ -x -q --no-header
```
-55
View File
@@ -1,55 +0,0 @@
# AGENTS.typescript.md — Next.js Konventionen
## Architektur
- `app/.../page.tsx`: Minimale Seiten-Komposition (≤250 LOC)
- `app/.../actions.ts`: Server Actions
- `app/.../queries.ts`: Data Loading
- `app/.../_components/`: View-Teile (Colocation)
- `app/.../_hooks/`: Seiten-spezifische Hooks (Colocation)
- `types/` oder `types/*.ts`: Domain-spezifische Types
- `schemas/`: Zod/Validierungs-Schemas
- `lib/`: Shared Utilities
## Regeln
1. page.tsx duenn halten (≤250 LOC)
2. Grosse Seiten frueh in Sections/Components splitten
3. KEINE einzelne types.ts als Catch-All
4. types.ts UND types/ Shadowing vermeiden (eines waehlen!)
5. Server/Client Module-Grenzen explizit halten
6. Pure Helpers und schmale Props bevorzugen
7. API-Client Types getrennt von handgeschriebenen Domain Types
## Colocation Pattern (bevorzugt)
```
app/(admin)/ai/rag/
page.tsx ← duenn, komponiert nur
_components/
SearchPanel.tsx
ResultsTable.tsx
FilterBar.tsx
_hooks/
useRagSearch.ts
actions.ts ← Server Actions
queries.ts ← Data Fetching
```
## Split-Trigger
- page.tsx ueberschreitet 250-350 LOC
- types.ts ueberschreitet 200-300 LOC
- Form-Logik, Server Actions und Rendering in einer Datei
- Mehrere unabhaengig testbare Sections vorhanden
- Imports werden broechig
## Verifikation
```bash
npx tsc --noEmit
npm run lint
npm run build
```
> `npm run build` ist PFLICHT — `tsc` allein reicht nicht.
+2 -2
View File
@@ -34,8 +34,8 @@ WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN addgroup -S -g 1001 nodejs
RUN adduser -S -u 1001 -G nodejs nextjs
# Copy built assets
COPY --from=builder /app/public ./public
@@ -1,58 +0,0 @@
import Link from 'next/link'
import { Brain, ArrowLeft, Play, Pause, CheckCircle, XCircle } from 'lucide-react'
import type { AgentDetail } from './types'
interface AgentHeaderProps {
agent: AgentDetail
}
export function AgentHeader({ agent }: AgentHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href="/ai/agents"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
</Link>
<div
className="p-3 rounded-xl"
style={{ backgroundColor: `${agent.color}20` }}
>
<Brain className="w-6 h-6" style={{ color: agent.color }} />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{agent.name}</h1>
<p className="text-gray-500">{agent.description}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
agent.status === 'running' ? 'bg-green-100 text-green-700' :
agent.status === 'paused' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{agent.status === 'running' ? <CheckCircle className="w-4 h-4" /> :
agent.status === 'paused' ? <Pause className="w-4 h-4" /> :
<XCircle className="w-4 h-4" />}
{agent.status}
</div>
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
{agent.status === 'running' ? (
<>
<Pause className="w-4 h-4" />
Pausieren
</>
) : (
<>
<Play className="w-4 h-4" />
Starten
</>
)}
</button>
</div>
</div>
)
}
@@ -1,32 +0,0 @@
import type { AgentDetail } from './types'
interface AgentStatsBarProps {
agent: AgentDetail
}
export function AgentStatsBar({ agent }: AgentStatsBarProps) {
return (
<div className="grid grid-cols-5 gap-4 mb-6">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Aktive Sessions</div>
<div className="text-2xl font-bold text-gray-900">{agent.activeSessions}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Verarbeitet (24h)</div>
<div className="text-2xl font-bold text-gray-900">{agent.totalProcessed.toLocaleString()}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Avg. Antwortzeit</div>
<div className="text-2xl font-bold text-gray-900">{agent.avgResponseTime}ms</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Fehlerrate</div>
<div className="text-2xl font-bold text-amber-600">{agent.errorRate}%</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Version</div>
<div className="text-2xl font-bold text-gray-900">{agent.version}</div>
</div>
</div>
)
}
@@ -1,32 +0,0 @@
import { History } from 'lucide-react'
import type { ChangeLog } from './types'
interface HistoryTabContentProps {
changeLogs: ChangeLog[]
}
export function HistoryTabContent({ changeLogs }: HistoryTabContentProps) {
return (
<div>
<div className="space-y-4">
{changeLogs.map((log) => (
<div key={log.id} className="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
<div className="p-2 bg-white rounded-full border border-gray-200">
<History className="w-4 h-4 text-gray-500" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{log.action}</span>
<span className="text-sm text-gray-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{log.description}</p>
<p className="text-xs text-gray-400 mt-1">von {log.user}</p>
</div>
</div>
))}
</div>
</div>
)
}
@@ -1,102 +0,0 @@
import { FileText, Clock, RotateCcw, Save, Edit3, AlertTriangle } from 'lucide-react'
import type { AgentDetail } from './types'
interface SoulTabContentProps {
agent: AgentDetail
editedContent: string
isEditing: boolean
hasChanges: boolean
saving: boolean
onContentChange: (content: string) => void
onSave: () => void
onReset: () => void
onStartEditing: () => void
}
export function SoulTabContent({
agent,
editedContent,
isEditing,
hasChanges,
saving,
onContentChange,
onSave,
onReset,
onStartEditing,
}: SoulTabContentProps) {
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<FileText className="w-4 h-4" />
{agent.soulFile}
<span className="text-gray-300">|</span>
<Clock className="w-4 h-4" />
Zuletzt geaendert: {new Date(agent.updatedAt).toLocaleString('de-DE')}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button
onClick={onReset}
disabled={!hasChanges}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<button
onClick={onSave}
disabled={!hasChanges || saving}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Speichert...' : 'Speichern'}
</button>
</>
) : (
<button
onClick={onStartEditing}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
>
<Edit3 className="w-4 h-4" />
Bearbeiten
</button>
)}
</div>
</div>
{hasChanges && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2 text-amber-700">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">Ungespeicherte Aenderungen vorhanden</span>
</div>
)}
<div className="relative">
{isEditing ? (
<textarea
value={editedContent}
onChange={(e) => onContentChange(e.target.value)}
className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
spellCheck={false}
/>
) : (
<div className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg overflow-auto whitespace-pre-wrap">
{agent.soulContent}
</div>
)}
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Hinweise zur SOUL-Datei</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li>Die SOUL-Datei definiert die Persoenlichkeit und das Verhalten des Agents</li>
<li>Aenderungen werden nach dem Speichern sofort wirksam</li>
<li>Testen Sie Aenderungen zuerst im Staging-Modus</li>
<li>Alle Aenderungen werden in der Historie protokolliert</li>
</ul>
</div>
</div>
)
}
@@ -1,16 +0,0 @@
import Link from 'next/link'
import { Activity } from 'lucide-react'
export function StatsTabContent() {
return (
<div className="space-y-6">
<div className="text-center py-12 text-gray-500">
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>Live-Statistiken werden in einer zukuenftigen Version verfuegbar sein.</p>
<p className="text-sm mt-2">
Besuchen Sie die <Link href="/ai/agents/statistics" className="text-teal-600 hover:underline">Statistik-Seite</Link> fuer aggregierte Daten.
</p>
</div>
</div>
)
}
@@ -1,6 +0,0 @@
export { AgentHeader } from './AgentHeader'
export { AgentStatsBar } from './AgentStatsBar'
export { SoulTabContent } from './SoulTabContent'
export { StatsTabContent } from './StatsTabContent'
export { HistoryTabContent } from './HistoryTabContent'
export type { AgentDetail, ChangeLog } from './types'
@@ -1,308 +0,0 @@
import type { AgentDetail, ChangeLog } from './types'
export const mockAgentDetails: Record<string, AgentDetail> = {
'tutor-agent': {
id: 'tutor-agent',
name: 'TutorAgent',
description: 'Geduldiger, ermutigender Lernbegleiter fuer Schueler',
soulFile: 'tutor-agent.soul.md',
soulContent: `# TutorAgent SOUL
## Identitaet
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
## Kernprinzipien
- **Sokratische Methode**: Stelle Fragen, die zum Nachdenken anregen
- **Positives Reinforcement**: Erkenne und feiere Lernfortschritte
- **Adaptive Kommunikation**: Passe Sprache und Komplexitaet an das Niveau an
- **Geduld**: Wiederhole Erklaerungen ohne Frustration zu zeigen
## Kommunikationsstil
- Verwende einfache, klare Sprache
- Stelle Rueckfragen, um Verstaendnis zu pruefen
- Gib Hinweise statt direkter Loesungen
- Feiere kleine Erfolge
- Nutze Analogien und Beispiele aus dem Alltag
- Strukturiere komplexe Themen in verdauliche Schritte
## Fachgebiete
- Mathematik (Grundschule bis Abitur)
- Naturwissenschaften (Physik, Chemie, Biologie)
- Sprachen (Deutsch, Englisch)
- Gesellschaftswissenschaften (Geschichte, Politik)
## Lernstrategien
1. **Konzeptbasiertes Lernen**: Erklaere das "Warum" hinter Regeln
2. **Visualisierung**: Nutze Diagramme und Skizzen wenn moeglich
3. **Verbindungen herstellen**: Verknuepfe neues Wissen mit Bekanntem
4. **Wiederholung**: Baue systematische Wiederholung ein
5. **Selbsttest**: Ermutige zur Selbstueberpruefung
## Einschraenkungen
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
- Verweise bei komplexen Themen auf Lehrkraefte
- Erkenne Frustration und biete Pausen an
- Keine Unterstuetzung bei Pruefungsbetrug
- Keine medizinischen oder rechtlichen Ratschlaege
## Eskalation
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
- Bei technischen Problemen: Eskaliere an Support
- Bei Verdacht auf Lernschwierigkeiten: Empfehle professionelle Diagnostik
## Metrik-Ziele
- Verstaendnis-Score > 80% bei Nachfragen
- Engagement-Zeit > 5 Minuten pro Session
- Wiederbesuchs-Rate > 60%
- Frustrations-Indikatoren < 10%`,
color: '#3b82f6',
status: 'running',
activeSessions: 12,
totalProcessed: 1847,
avgResponseTime: 234,
errorRate: 0.5,
lastRestart: '2025-01-14T08:30:00Z',
version: '1.2.0',
createdAt: '2024-11-01T00:00:00Z',
updatedAt: '2025-01-14T10:15:00Z'
},
'grader-agent': {
id: 'grader-agent',
name: 'GraderAgent',
description: 'Objektiver, fairer Pruefer von Schuelerarbeiten',
soulFile: 'grader-agent.soul.md',
soulContent: `# GraderAgent SOUL
## Identitaet
Du bist ein objektiver, fairer Pruefer von Schuelerarbeiten.
Dein Ziel ist konstruktives Feedback, das zum Lernen motiviert.
## Kernprinzipien
- **Objektivitaet**: Bewerte nach festgelegten Kriterien, nicht nach Sympathie
- **Fairness**: Gleiche Massstaebe fuer alle Schueler
- **Konstruktivitaet**: Feedback soll zum Lernen anregen
- **Transparenz**: Begruende jede Bewertung nachvollziehbar
## Bewertungsprinzipien
- Bewerte nach festgelegten Kriterien (Erwartungshorizont)
- Beruecksichtige Teilleistungen
- Unterscheide zwischen Fluechtigkeitsfehlern und Verstaendnisluecken
- Formuliere Feedback lernfoerdernd
- Nutze das 15-Punkte-System korrekt (0-15 Punkte, 5 = ausreichend)
## Workflow
1. Lies die Aufgabenstellung und den Erwartungshorizont
2. Analysiere die Schuelerantwort systematisch
3. Identifiziere korrekte Elemente
4. Identifiziere Fehler mit Kategorisierung
5. Vergebe Punkte nach Kriterienkatalog
6. Formuliere konstruktives Feedback
## Fehlerkategorien
- **Rechtschreibung (R)**: Orthografische Fehler
- **Grammatik (Gr)**: Grammatikalische Fehler
- **Ausdruck (A)**: Stilistische Schwaechen
- **Inhalt (I)**: Fachliche Fehler oder Luecken
- **Struktur (St)**: Aufbau- und Gliederungsprobleme
- **Logik (L)**: Argumentationsfehler
## Qualitaetssicherung
- Bei Unsicherheit: Markiere zur manuellen Ueberpruefung
- Bei Grenzfaellen: Dokumentiere Entscheidungsgrundlage
- Konsistenz: Vergleiche mit aehnlichen Bewertungen
- Kalibrierung: Orientiere an Vergleichsarbeiten
## Eskalation
- Unleserliche Antworten: Markiere fuer manuelles Review
- Verdacht auf Plagiat: Eskaliere an Lehrkraft
- Technische Fehler: Pausiere und melde
- Unklare Aufgabenstellung: Frage nach Klarstellung`,
color: '#10b981',
status: 'running',
activeSessions: 3,
totalProcessed: 456,
avgResponseTime: 1205,
errorRate: 1.2,
lastRestart: '2025-01-13T14:00:00Z',
version: '1.1.0',
createdAt: '2024-11-01T00:00:00Z',
updatedAt: '2025-01-13T16:30:00Z'
},
'quality-judge': {
id: 'quality-judge',
name: 'QualityJudge',
description: 'Kritischer Qualitaetspruefer fuer KI-generierte Inhalte',
soulFile: 'quality-judge.soul.md',
soulContent: `# QualityJudge SOUL
## Identitaet
Du bist ein kritischer Qualitaetspruefer fuer KI-generierte Inhalte.
Dein Ziel ist die Sicherstellung hoher Qualitaetsstandards.
## Bewertungsdimensionen
### 1. Intent Accuracy (0-100)
- Wurde die Benutzerabsicht korrekt erkannt?
- Stimmt die Kategorie der Antwort?
### 2. Faithfulness (1-5)
- **5**: Vollstaendig faktisch korrekt
- **4**: Minor Ungenauigkeiten ohne Auswirkung
- **3**: Einige Ungenauigkeiten, Kernaussage korrekt
- **2**: Signifikante Fehler
- **1**: Grundlegend falsch
### 3. Relevance (1-5)
- **5**: Direkt und vollstaendig relevant
- **4**: Weitgehend relevant
- **3**: Teilweise relevant
- **2**: Geringe Relevanz
- **1**: Voellig irrelevant
### 4. Coherence (1-5)
- **5**: Perfekt strukturiert und logisch
- **4**: Gut strukturiert, kleine Luecken
- **3**: Verstaendlich, aber verbesserungsfaehig
- **2**: Schwer zu folgen
- **1**: Unverstaendlich/chaotisch
### 5. Safety ("pass"/"fail")
- Keine DSGVO-Verstoesse (keine PII)
- Keine schaedlichen Inhalte
- Keine Desinformation
- Keine Diskriminierung
- Altersgerechte Sprache
## Schwellenwerte
- **Production Ready**: composite >= 80
- **Needs Review**: 60 <= composite < 80
- **Failed**: composite < 60`,
color: '#f59e0b',
status: 'running',
activeSessions: 8,
totalProcessed: 3291,
avgResponseTime: 89,
errorRate: 0.3,
lastRestart: '2025-01-14T06:00:00Z',
version: '2.0.0',
createdAt: '2024-10-15T00:00:00Z',
updatedAt: '2025-01-14T08:00:00Z'
},
'alert-agent': {
id: 'alert-agent',
name: 'AlertAgent',
description: 'Aufmerksamer Waechter fuer das Breakpilot-System',
soulFile: 'alert-agent.soul.md',
soulContent: `# AlertAgent SOUL
## Identitaet
Du bist ein aufmerksamer Waechter fuer das Breakpilot-System.
Dein Ziel ist die rechtzeitige Erkennung und Kommunikation relevanter Ereignisse.
## Importance Levels
### KRITISCH (5)
- Systemausfaelle
- Sicherheitsvorfaelle
- DSGVO-Verstoesse
**Aktion**: Sofortige Benachrichtigung aller Admins
### DRINGEND (4)
- Performance-Probleme
- API-Ausfaelle
- Hohe Fehlerraten
**Aktion**: Benachrichtigung innerhalb 5 Minuten
### WICHTIG (3)
- Neue kritische Nachrichten
- Relevante Bildungspolitik
- Technische Warnungen
**Aktion**: Taeglicher Digest
### PRUEFEN (2)
- Interessante Entwicklungen
- Konkurrenznachrichten
**Aktion**: Woechentlicher Digest
### INFO (1)
- Allgemeine Updates
**Aktion**: Archivieren`,
color: '#ef4444',
status: 'running',
activeSessions: 1,
totalProcessed: 892,
avgResponseTime: 45,
errorRate: 0.1,
lastRestart: '2025-01-12T00:00:00Z',
version: '1.0.0',
createdAt: '2024-12-01T00:00:00Z',
updatedAt: '2025-01-12T02:00:00Z'
},
'orchestrator': {
id: 'orchestrator',
name: 'Orchestrator',
description: 'Zentraler Koordinator des Multi-Agent-Systems',
soulFile: 'orchestrator.soul.md',
soulContent: `# OrchestratorAgent SOUL
## Identitaet
Du bist der zentrale Koordinator des Breakpilot Multi-Agent-Systems.
Dein Ziel ist die effiziente Verteilung und Ueberwachung von Aufgaben.
## Kernprinzipien
- **Effizienz**: Minimale Latenz bei maximaler Qualitaet
- **Resilienz**: Graceful Degradation bei Agent-Ausfaellen
- **Fairness**: Ausgewogene Lastverteilung
- **Transparenz**: Volle Nachvollziehbarkeit aller Entscheidungen
## Verantwortlichkeiten
1. Task-Routing zu spezialisierten Agents
2. Session-Management und Recovery
3. Agent-Gesundheitsueberwachung
4. Lastverteilung
5. Fehlerbehandlung und Retry-Logik
## Task-Routing-Logik
| Intent-Kategorie | Primaerer Agent | Fallback |
|------------------|-----------------|----------|
| learning_support | TutorAgent | Manuell |
| exam_grading | GraderAgent | QualityJudge |
| quality_check | QualityJudge | Manual Review |
| system_alert | AlertAgent | E-Mail Fallback |
## Fehlerbehandlung
### Retry-Policy
- **Max Retries**: 3
- **Backoff**: Exponential (1s, 2s, 4s)
- **Keine Retries**: Validation Errors, Auth Failures
### Circuit Breaker
- **Threshold**: 5 Fehler in 60 Sekunden
- **Cooldown**: 30 Sekunden
## Metriken
- **Task Completion Rate**: > 99%
- **Average Latency**: < 2s
- **Error Rate**: < 1%`,
color: '#8b5cf6',
status: 'running',
activeSessions: 24,
totalProcessed: 8934,
avgResponseTime: 12,
errorRate: 0.2,
lastRestart: '2025-01-14T00:00:00Z',
version: '1.5.0',
createdAt: '2024-10-01T00:00:00Z',
updatedAt: '2025-01-14T00:30:00Z'
}
}
export const mockChangeLogs: ChangeLog[] = [
{ id: '1', timestamp: '2025-01-14T10:15:00Z', user: 'admin@breakpilot.de', action: 'SOUL Updated', description: 'Kommunikationsstil angepasst' },
{ id: '2', timestamp: '2025-01-13T14:30:00Z', user: 'lehrer1@schule.de', action: 'Einschraenkung hinzugefuegt', description: 'Keine Hausaufgaben-Loesungen' },
{ id: '3', timestamp: '2025-01-10T09:00:00Z', user: 'admin@breakpilot.de', action: 'Version 1.2.0', description: 'Neue Fachgebiete hinzugefuegt' },
]
@@ -1,25 +0,0 @@
export interface AgentDetail {
id: string
name: string
description: string
soulFile: string
soulContent: string
color: string
status: 'running' | 'paused' | 'stopped' | 'error'
activeSessions: number
totalProcessed: number
avgResponseTime: number
errorRate: number
lastRestart: string
version: string
createdAt: string
updatedAt: string
}
export interface ChangeLog {
id: string
timestamp: string
user: string
action: string
description: string
}
@@ -1,29 +1,394 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { AlertTriangle, FileText, Activity, History } from 'lucide-react'
import { mockAgentDetails, mockChangeLogs } from './_components/mock-data'
import {
AgentHeader,
AgentStatsBar,
SoulTabContent,
StatsTabContent,
HistoryTabContent,
} from './_components'
import type { AgentDetail } from './_components'
import { Bot, Brain, ArrowLeft, Save, RotateCcw, Play, Pause, AlertTriangle, FileText, Settings, Activity, Clock, CheckCircle, XCircle, History, Eye, Edit3 } from 'lucide-react'
type TabId = 'soul' | 'stats' | 'history'
// Types
interface AgentDetail {
id: string
name: string
description: string
soulFile: string
soulContent: string
color: string
status: 'running' | 'paused' | 'stopped' | 'error'
activeSessions: number
totalProcessed: number
avgResponseTime: number
errorRate: number
lastRestart: string
version: string
createdAt: string
updatedAt: string
}
const TABS: { id: TabId; label: string; icon: typeof FileText }[] = [
{ id: 'soul', label: 'SOUL-File', icon: FileText },
{ id: 'stats', label: 'Live-Statistiken', icon: Activity },
{ id: 'history', label: 'Aenderungshistorie', icon: History },
interface ChangeLog {
id: string
timestamp: string
user: string
action: string
description: string
}
// Mock data
const mockAgentDetails: Record<string, AgentDetail> = {
'tutor-agent': {
id: 'tutor-agent',
name: 'TutorAgent',
description: 'Geduldiger, ermutigender Lernbegleiter fuer Schueler',
soulFile: 'tutor-agent.soul.md',
soulContent: `# TutorAgent SOUL
## Identitaet
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
## Kernprinzipien
- **Sokratische Methode**: Stelle Fragen, die zum Nachdenken anregen
- **Positives Reinforcement**: Erkenne und feiere Lernfortschritte
- **Adaptive Kommunikation**: Passe Sprache und Komplexitaet an das Niveau an
- **Geduld**: Wiederhole Erklaerungen ohne Frustration zu zeigen
## Kommunikationsstil
- Verwende einfache, klare Sprache
- Stelle Rueckfragen, um Verstaendnis zu pruefen
- Gib Hinweise statt direkter Loesungen
- Feiere kleine Erfolge
- Nutze Analogien und Beispiele aus dem Alltag
- Strukturiere komplexe Themen in verdauliche Schritte
## Fachgebiete
- Mathematik (Grundschule bis Abitur)
- Naturwissenschaften (Physik, Chemie, Biologie)
- Sprachen (Deutsch, Englisch)
- Gesellschaftswissenschaften (Geschichte, Politik)
## Lernstrategien
1. **Konzeptbasiertes Lernen**: Erklaere das "Warum" hinter Regeln
2. **Visualisierung**: Nutze Diagramme und Skizzen wenn moeglich
3. **Verbindungen herstellen**: Verknuepfe neues Wissen mit Bekanntem
4. **Wiederholung**: Baue systematische Wiederholung ein
5. **Selbsttest**: Ermutige zur Selbstueberpruefung
## Einschraenkungen
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
- Verweise bei komplexen Themen auf Lehrkraefte
- Erkenne Frustration und biete Pausen an
- Keine Unterstuetzung bei Pruefungsbetrug
- Keine medizinischen oder rechtlichen Ratschlaege
## Eskalation
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
- Bei technischen Problemen: Eskaliere an Support
- Bei Verdacht auf Lernschwierigkeiten: Empfehle professionelle Diagnostik
## Metrik-Ziele
- Verstaendnis-Score > 80% bei Nachfragen
- Engagement-Zeit > 5 Minuten pro Session
- Wiederbesuchs-Rate > 60%
- Frustrations-Indikatoren < 10%`,
color: '#3b82f6',
status: 'running',
activeSessions: 12,
totalProcessed: 1847,
avgResponseTime: 234,
errorRate: 0.5,
lastRestart: '2025-01-14T08:30:00Z',
version: '1.2.0',
createdAt: '2024-11-01T00:00:00Z',
updatedAt: '2025-01-14T10:15:00Z'
},
'grader-agent': {
id: 'grader-agent',
name: 'GraderAgent',
description: 'Objektiver, fairer Pruefer von Schuelerarbeiten',
soulFile: 'grader-agent.soul.md',
soulContent: `# GraderAgent SOUL
## Identitaet
Du bist ein objektiver, fairer Pruefer von Schuelerarbeiten.
Dein Ziel ist konstruktives Feedback, das zum Lernen motiviert.
## Kernprinzipien
- **Objektivitaet**: Bewerte nach festgelegten Kriterien, nicht nach Sympathie
- **Fairness**: Gleiche Massstaebe fuer alle Schueler
- **Konstruktivitaet**: Feedback soll zum Lernen anregen
- **Transparenz**: Begruende jede Bewertung nachvollziehbar
## Bewertungsprinzipien
- Bewerte nach festgelegten Kriterien (Erwartungshorizont)
- Beruecksichtige Teilleistungen
- Unterscheide zwischen Fluechtigkeitsfehlern und Verstaendnisluecken
- Formuliere Feedback lernfoerdernd
- Nutze das 15-Punkte-System korrekt (0-15 Punkte, 5 = ausreichend)
## Workflow
1. Lies die Aufgabenstellung und den Erwartungshorizont
2. Analysiere die Schuelerantwort systematisch
3. Identifiziere korrekte Elemente
4. Identifiziere Fehler mit Kategorisierung
5. Vergebe Punkte nach Kriterienkatalog
6. Formuliere konstruktives Feedback
## Fehlerkategorien
- **Rechtschreibung (R)**: Orthografische Fehler
- **Grammatik (Gr)**: Grammatikalische Fehler
- **Ausdruck (A)**: Stilistische Schwaechen
- **Inhalt (I)**: Fachliche Fehler oder Luecken
- **Struktur (St)**: Aufbau- und Gliederungsprobleme
- **Logik (L)**: Argumentationsfehler
## Qualitaetssicherung
- Bei Unsicherheit: Markiere zur manuellen Ueberpruefung
- Bei Grenzfaellen: Dokumentiere Entscheidungsgrundlage
- Konsistenz: Vergleiche mit aehnlichen Bewertungen
- Kalibrierung: Orientiere an Vergleichsarbeiten
## Eskalation
- Unleserliche Antworten: Markiere fuer manuelles Review
- Verdacht auf Plagiat: Eskaliere an Lehrkraft
- Technische Fehler: Pausiere und melde
- Unklare Aufgabenstellung: Frage nach Klarstellung`,
color: '#10b981',
status: 'running',
activeSessions: 3,
totalProcessed: 456,
avgResponseTime: 1205,
errorRate: 1.2,
lastRestart: '2025-01-13T14:00:00Z',
version: '1.1.0',
createdAt: '2024-11-01T00:00:00Z',
updatedAt: '2025-01-13T16:30:00Z'
},
'quality-judge': {
id: 'quality-judge',
name: 'QualityJudge',
description: 'Kritischer Qualitaetspruefer fuer KI-generierte Inhalte',
soulFile: 'quality-judge.soul.md',
soulContent: `# QualityJudge SOUL
## Identitaet
Du bist ein kritischer Qualitaetspruefer fuer KI-generierte Inhalte.
Dein Ziel ist die Sicherstellung hoher Qualitaetsstandards.
## Bewertungsdimensionen
### 1. Intent Accuracy (0-100)
- Wurde die Benutzerabsicht korrekt erkannt?
- Stimmt die Kategorie der Antwort?
### 2. Faithfulness (1-5)
- **5**: Vollstaendig faktisch korrekt
- **4**: Minor Ungenauigkeiten ohne Auswirkung
- **3**: Einige Ungenauigkeiten, Kernaussage korrekt
- **2**: Signifikante Fehler
- **1**: Grundlegend falsch
### 3. Relevance (1-5)
- **5**: Direkt und vollstaendig relevant
- **4**: Weitgehend relevant
- **3**: Teilweise relevant
- **2**: Geringe Relevanz
- **1**: Voellig irrelevant
### 4. Coherence (1-5)
- **5**: Perfekt strukturiert und logisch
- **4**: Gut strukturiert, kleine Luecken
- **3**: Verstaendlich, aber verbesserungsfaehig
- **2**: Schwer zu folgen
- **1**: Unverstaendlich/chaotisch
### 5. Safety ("pass"/"fail")
- Keine DSGVO-Verstoesse (keine PII)
- Keine schaedlichen Inhalte
- Keine Desinformation
- Keine Diskriminierung
- Altersgerechte Sprache
## Schwellenwerte
- **Production Ready**: composite >= 80
- **Needs Review**: 60 <= composite < 80
- **Failed**: composite < 60`,
color: '#f59e0b',
status: 'running',
activeSessions: 8,
totalProcessed: 3291,
avgResponseTime: 89,
errorRate: 0.3,
lastRestart: '2025-01-14T06:00:00Z',
version: '2.0.0',
createdAt: '2024-10-15T00:00:00Z',
updatedAt: '2025-01-14T08:00:00Z'
},
'alert-agent': {
id: 'alert-agent',
name: 'AlertAgent',
description: 'Aufmerksamer Waechter fuer das Breakpilot-System',
soulFile: 'alert-agent.soul.md',
soulContent: `# AlertAgent SOUL
## Identitaet
Du bist ein aufmerksamer Waechter fuer das Breakpilot-System.
Dein Ziel ist die rechtzeitige Erkennung und Kommunikation relevanter Ereignisse.
## Importance Levels
### KRITISCH (5)
- Systemausfaelle
- Sicherheitsvorfaelle
- DSGVO-Verstoesse
**Aktion**: Sofortige Benachrichtigung aller Admins
### DRINGEND (4)
- Performance-Probleme
- API-Ausfaelle
- Hohe Fehlerraten
**Aktion**: Benachrichtigung innerhalb 5 Minuten
### WICHTIG (3)
- Neue kritische Nachrichten
- Relevante Bildungspolitik
- Technische Warnungen
**Aktion**: Taeglicher Digest
### PRUEFEN (2)
- Interessante Entwicklungen
- Konkurrenznachrichten
**Aktion**: Woechentlicher Digest
### INFO (1)
- Allgemeine Updates
**Aktion**: Archivieren`,
color: '#ef4444',
status: 'running',
activeSessions: 1,
totalProcessed: 892,
avgResponseTime: 45,
errorRate: 0.1,
lastRestart: '2025-01-12T00:00:00Z',
version: '1.0.0',
createdAt: '2024-12-01T00:00:00Z',
updatedAt: '2025-01-12T02:00:00Z'
},
'compliance-advisor': {
id: 'compliance-advisor',
name: 'Compliance Advisor',
description: 'DSGVO/Compliance-Berater fuer SDK-Nutzer',
soulFile: 'compliance-advisor.soul.md',
soulContent: `# Compliance Advisor Agent
## Identitaet
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
offiziellen Quellen und gibst praxisnahe Hinweise.
## Kernprinzipien
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen AUSSER NIBIS-Dokumenten
## Kompetenzbereich
- DSGVO Art. 1-99 + Erwaegsgruende
- BDSG (Bundesdatenschutzgesetz)
- AI Act (EU KI-Verordnung)
- TTDSG, ePrivacy-Richtlinie
- DSK-Kurzpapiere (Nr. 1-20)
- SDM V3.0, BSI-Grundschutz, BSI-TR-03161
- EDPB Guidelines, Bundes-/Laender-Muss-Listen
- ISO 27001/27701 (Ueberblick)
## Kommunikationsstil
- Sachlich, aber verstaendlich
- Deutsch als Hauptsprache
- Strukturierte Antworten mit Quellenangabe
- Praxisbeispiele wo hilfreich`,
color: '#6366f1',
status: 'running',
activeSessions: 0,
totalProcessed: 0,
avgResponseTime: 0,
errorRate: 0,
lastRestart: new Date().toISOString(),
version: '1.0.0',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
'orchestrator': {
id: 'orchestrator',
name: 'Orchestrator',
description: 'Zentraler Koordinator des Multi-Agent-Systems',
soulFile: 'orchestrator.soul.md',
soulContent: `# OrchestratorAgent SOUL
## Identitaet
Du bist der zentrale Koordinator des Breakpilot Multi-Agent-Systems.
Dein Ziel ist die effiziente Verteilung und Ueberwachung von Aufgaben.
## Kernprinzipien
- **Effizienz**: Minimale Latenz bei maximaler Qualitaet
- **Resilienz**: Graceful Degradation bei Agent-Ausfaellen
- **Fairness**: Ausgewogene Lastverteilung
- **Transparenz**: Volle Nachvollziehbarkeit aller Entscheidungen
## Verantwortlichkeiten
1. Task-Routing zu spezialisierten Agents
2. Session-Management und Recovery
3. Agent-Gesundheitsueberwachung
4. Lastverteilung
5. Fehlerbehandlung und Retry-Logik
## Task-Routing-Logik
| Intent-Kategorie | Primaerer Agent | Fallback |
|------------------|-----------------|----------|
| learning_support | TutorAgent | Manuell |
| exam_grading | GraderAgent | QualityJudge |
| quality_check | QualityJudge | Manual Review |
| system_alert | AlertAgent | E-Mail Fallback |
## Fehlerbehandlung
### Retry-Policy
- **Max Retries**: 3
- **Backoff**: Exponential (1s, 2s, 4s)
- **Keine Retries**: Validation Errors, Auth Failures
### Circuit Breaker
- **Threshold**: 5 Fehler in 60 Sekunden
- **Cooldown**: 30 Sekunden
## Metriken
- **Task Completion Rate**: > 99%
- **Average Latency**: < 2s
- **Error Rate**: < 1%`,
color: '#8b5cf6',
status: 'running',
activeSessions: 24,
totalProcessed: 8934,
avgResponseTime: 12,
errorRate: 0.2,
lastRestart: '2025-01-14T00:00:00Z',
version: '1.5.0',
createdAt: '2024-10-01T00:00:00Z',
updatedAt: '2025-01-14T00:30:00Z'
}
}
const mockChangeLogs: ChangeLog[] = [
{ id: '1', timestamp: '2025-01-14T10:15:00Z', user: 'admin@breakpilot.de', action: 'SOUL Updated', description: 'Kommunikationsstil angepasst' },
{ id: '2', timestamp: '2025-01-13T14:30:00Z', user: 'lehrer1@schule.de', action: 'Einschraenkung hinzugefuegt', description: 'Keine Hausaufgaben-Loesungen' },
{ id: '3', timestamp: '2025-01-10T09:00:00Z', user: 'admin@breakpilot.de', action: 'Version 1.2.0', description: 'Neue Fachgebiete hinzugefuegt' },
]
export default function AgentDetailPage() {
const params = useParams()
const router = useRouter()
const agentId = params.agentId as string
const [agent, setAgent] = useState<AgentDetail | null>(null)
@@ -31,9 +396,10 @@ export default function AgentDetailPage() {
const [isEditing, setIsEditing] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<TabId>('soul')
const [activeTab, setActiveTab] = useState<'soul' | 'stats' | 'history'>('soul')
useEffect(() => {
// Load agent data
const agentData = mockAgentDetails[agentId]
if (agentData) {
setAgent(agentData)
@@ -43,7 +409,10 @@ export default function AgentDetailPage() {
const handleSave = async () => {
setSaving(true)
// In production, save to API
// await fetch(`/api/admin/agents/${agentId}/soul`, { method: 'PUT', body: editedContent })
await new Promise(resolve => setTimeout(resolve, 1000))
if (agent) {
setAgent({ ...agent, soulContent: editedContent, updatedAt: new Date().toISOString() })
}
@@ -70,7 +439,7 @@ export default function AgentDetailPage() {
<div className="text-center py-12">
<AlertTriangle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Agent nicht gefunden</h2>
<p className="text-gray-500 mb-4">Der Agent &quot;{agentId}&quot; existiert nicht.</p>
<p className="text-gray-500 mb-4">Der Agent "{agentId}" existiert nicht.</p>
<Link href="/ai/agents" className="text-teal-600 hover:text-teal-700">
&larr; Zurueck zur Uebersicht
</Link>
@@ -81,46 +450,231 @@ export default function AgentDetailPage() {
return (
<div className="p-6 max-w-7xl mx-auto">
<AgentHeader agent={agent} />
<AgentStatsBar agent={agent} />
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href="/ai/agents"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
</Link>
<div
className="p-3 rounded-xl"
style={{ backgroundColor: `${agent.color}20` }}
>
<Brain className="w-6 h-6" style={{ color: agent.color }} />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{agent.name}</h1>
<p className="text-gray-500">{agent.description}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
agent.status === 'running' ? 'bg-green-100 text-green-700' :
agent.status === 'paused' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{agent.status === 'running' ? <CheckCircle className="w-4 h-4" /> :
agent.status === 'paused' ? <Pause className="w-4 h-4" /> :
<XCircle className="w-4 h-4" />}
{agent.status}
</div>
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
{agent.status === 'running' ? (
<>
<Pause className="w-4 h-4" />
Pausieren
</>
) : (
<>
<Play className="w-4 h-4" />
Starten
</>
)}
</button>
</div>
</div>
{/* Stats Bar */}
<div className="grid grid-cols-5 gap-4 mb-6">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Aktive Sessions</div>
<div className="text-2xl font-bold text-gray-900">{agent.activeSessions}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Verarbeitet (24h)</div>
<div className="text-2xl font-bold text-gray-900">{agent.totalProcessed.toLocaleString()}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Avg. Antwortzeit</div>
<div className="text-2xl font-bold text-gray-900">{agent.avgResponseTime}ms</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Fehlerrate</div>
<div className="text-2xl font-bold text-amber-600">{agent.errorRate}%</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Version</div>
<div className="text-2xl font-bold text-gray-900">{agent.version}</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="border-b border-gray-200">
<div className="flex">
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === id
? 'border-teal-500 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
<button
onClick={() => setActiveTab('soul')}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'soul'
? 'border-teal-500 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<FileText className="w-4 h-4" />
SOUL-File
</button>
<button
onClick={() => setActiveTab('stats')}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'stats'
? 'border-teal-500 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Activity className="w-4 h-4" />
Live-Statistiken
</button>
<button
onClick={() => setActiveTab('history')}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'history'
? 'border-teal-500 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<History className="w-4 h-4" />
Aenderungshistorie
</button>
</div>
</div>
{/* Tab Content */}
<div className="p-6">
{activeTab === 'soul' && (
<SoulTabContent
agent={agent}
editedContent={editedContent}
isEditing={isEditing}
hasChanges={hasChanges}
saving={saving}
onContentChange={handleContentChange}
onSave={handleSave}
onReset={handleReset}
onStartEditing={() => setIsEditing(true)}
/>
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<FileText className="w-4 h-4" />
{agent.soulFile}
<span className="text-gray-300">|</span>
<Clock className="w-4 h-4" />
Zuletzt geaendert: {new Date(agent.updatedAt).toLocaleString('de-DE')}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button
onClick={handleReset}
disabled={!hasChanges}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<button
onClick={handleSave}
disabled={!hasChanges || saving}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Speichert...' : 'Speichern'}
</button>
</>
) : (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
>
<Edit3 className="w-4 h-4" />
Bearbeiten
</button>
)}
</div>
</div>
{hasChanges && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2 text-amber-700">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">Ungespeicherte Aenderungen vorhanden</span>
</div>
)}
<div className="relative">
{isEditing ? (
<textarea
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
spellCheck={false}
/>
) : (
<div className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg overflow-auto whitespace-pre-wrap">
{agent.soulContent}
</div>
)}
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Hinweise zur SOUL-Datei</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li> Die SOUL-Datei definiert die Persoenlichkeit und das Verhalten des Agents</li>
<li> Aenderungen werden nach dem Speichern sofort wirksam</li>
<li> Testen Sie Aenderungen zuerst im Staging-Modus</li>
<li> Alle Aenderungen werden in der Historie protokolliert</li>
</ul>
</div>
</div>
)}
{activeTab === 'stats' && (
<div className="space-y-6">
<div className="text-center py-12 text-gray-500">
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>Live-Statistiken werden in einer zukuenftigen Version verfuegbar sein.</p>
<p className="text-sm mt-2">
Besuchen Sie die <Link href="/ai/agents/statistics" className="text-teal-600 hover:underline">Statistik-Seite</Link> fuer aggregierte Daten.
</p>
</div>
</div>
)}
{activeTab === 'history' && (
<div>
<div className="space-y-4">
{mockChangeLogs.map((log) => (
<div key={log.id} className="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
<div className="p-2 bg-white rounded-full border border-gray-200">
<History className="w-4 h-4 text-gray-500" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{log.action}</span>
<span className="text-sm text-gray-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{log.description}</p>
<p className="text-xs text-gray-400 mt-1">von {log.user}</p>
</div>
</div>
))}
</div>
</div>
)}
{activeTab === 'stats' && <StatsTabContent />}
{activeTab === 'history' && <HistoryTabContent changeLogs={mockChangeLogs} />}
</div>
</div>
</div>
@@ -1,120 +0,0 @@
import { Brain, CheckCircle, Shield, AlertTriangle, MessageSquare } from 'lucide-react'
interface AgentCardProps {
icon: React.ReactNode
bgColor: string
hoverBorder: string
name: string
description: string
tags: { label: string; colorClasses: string }[]
soulInfo: string
}
function AgentCard({ icon, bgColor, hoverBorder, name, description, tags, soulInfo }: AgentCardProps) {
return (
<div className={`border border-gray-200 rounded-xl p-4 ${hoverBorder} transition-colors`}>
<div className="flex items-start gap-4">
<div className={`p-3 ${bgColor} rounded-lg`}>
{icon}
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">{name}</h4>
<p className="text-sm text-gray-600 mb-2">{description}</p>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<span key={tag.label} className={`px-2 py-1 ${tag.colorClasses} text-xs rounded-full`}>
{tag.label}
</span>
))}
</div>
<div className="mt-2 text-xs text-gray-500">{soulInfo}</div>
</div>
</div>
</div>
)
}
const AGENTS: AgentCardProps[] = [
{
icon: <Brain className="w-6 h-6 text-blue-600" />,
bgColor: 'bg-blue-100',
hoverBorder: 'hover:border-blue-300',
name: 'TutorAgent',
description: 'Lernbegleitung und Fragen beantworten',
tags: [
{ label: 'Geduldig', colorClasses: 'bg-blue-50 text-blue-700' },
{ label: 'Ermutigend', colorClasses: 'bg-blue-50 text-blue-700' },
{ label: 'Sokratisch', colorClasses: 'bg-blue-50 text-blue-700' },
],
soulInfo: 'SOUL: tutor-agent.soul.md | Routing: learning_*, help_*, question_*',
},
{
icon: <CheckCircle className="w-6 h-6 text-green-600" />,
bgColor: 'bg-green-100',
hoverBorder: 'hover:border-green-300',
name: 'GraderAgent',
description: 'Klausur-Korrektur und Bewertung',
tags: [
{ label: 'Objektiv', colorClasses: 'bg-green-50 text-green-700' },
{ label: 'Fair', colorClasses: 'bg-green-50 text-green-700' },
{ label: 'Konstruktiv', colorClasses: 'bg-green-50 text-green-700' },
],
soulInfo: 'SOUL: grader-agent.soul.md | Routing: grade_*, evaluate_*, correct_*',
},
{
icon: <Shield className="w-6 h-6 text-amber-600" />,
bgColor: 'bg-amber-100',
hoverBorder: 'hover:border-amber-300',
name: 'QualityJudge',
description: 'BQAS Qualitaetspruefung',
tags: [
{ label: 'Kritisch', colorClasses: 'bg-amber-50 text-amber-700' },
{ label: 'Praezise', colorClasses: 'bg-amber-50 text-amber-700' },
{ label: 'Schnell', colorClasses: 'bg-amber-50 text-amber-700' },
],
soulInfo: 'SOUL: quality-judge.soul.md | Routing: quality_*, review_*, validate_*',
},
{
icon: <AlertTriangle className="w-6 h-6 text-red-600" />,
bgColor: 'bg-red-100',
hoverBorder: 'hover:border-red-300',
name: 'AlertAgent',
description: 'Monitoring und Benachrichtigungen',
tags: [
{ label: 'Wachsam', colorClasses: 'bg-red-50 text-red-700' },
{ label: 'Proaktiv', colorClasses: 'bg-red-50 text-red-700' },
{ label: 'Priorisierend', colorClasses: 'bg-red-50 text-red-700' },
],
soulInfo: 'SOUL: alert-agent.soul.md | Routing: alert_*, monitor_*, notify_*',
},
{
icon: <MessageSquare className="w-6 h-6 text-purple-600" />,
bgColor: 'bg-purple-100',
hoverBorder: 'hover:border-purple-300',
name: 'Orchestrator',
description: 'Task-Koordination und Routing',
tags: [
{ label: 'Koordinierend', colorClasses: 'bg-purple-50 text-purple-700' },
{ label: 'Effizient', colorClasses: 'bg-purple-50 text-purple-700' },
{ label: 'Zuverlaessig', colorClasses: 'bg-purple-50 text-purple-700' },
],
soulInfo: 'SOUL: orchestrator.soul.md | Routing: Fallback fuer alle unbekannten Intents',
},
]
export function AgentTypesSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Jeder Agent hat eine spezialisierte Rolle im System. Die Agents kommunizieren ueber den Message Bus
und nutzen das Shared Brain fuer konsistente Entscheidungen.
</p>
<div className="grid gap-4">
{AGENTS.map(agent => (
<AgentCard key={agent.name} {...agent} />
))}
</div>
</div>
)
}
@@ -1,73 +0,0 @@
export function DatabaseSchemaSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Das Agent-System nutzt PostgreSQL fuer persistente Daten und Valkey (Redis) fuer Caching und Pub/Sub.
</p>
<div className="space-y-4">
{/* agent_sessions */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_sessions</h4>
<p className="text-sm text-gray-600 mb-3">Speichert Session-Daten mit Checkpoints</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_sessions (
id UUID PRIMARY KEY,
agent_type VARCHAR(50) NOT NULL,
user_id UUID REFERENCES users(id),
state VARCHAR(20) NOT NULL DEFAULT 'active',
context JSONB DEFAULT '{}',
checkpoints JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_heartbeat TIMESTAMPTZ DEFAULT NOW()
);
`}</pre>
</div>
</div>
{/* agent_memory */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_memory</h4>
<p className="text-sm text-gray-600 mb-3">Langzeit-Gedaechtnis mit TTL</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_memory (
id UUID PRIMARY KEY,
namespace VARCHAR(100) NOT NULL,
key VARCHAR(500) NOT NULL,
value JSONB NOT NULL,
agent_id VARCHAR(50) NOT NULL,
access_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
UNIQUE(namespace, key)
);
`}</pre>
</div>
</div>
{/* agent_messages */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_messages</h4>
<p className="text-sm text-gray-600 mb-3">Audit-Trail fuer Inter-Agent Kommunikation</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_messages (
id UUID PRIMARY KEY,
sender VARCHAR(50) NOT NULL,
receiver VARCHAR(50) NOT NULL,
message_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
priority INTEGER DEFAULT 1,
correlation_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
`}</pre>
</div>
</div>
</div>
</div>
)
}
@@ -1,75 +0,0 @@
export function MessageBusSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Der Message Bus ermoeglicht die asynchrone Kommunikation zwischen Agents via Redis Pub/Sub.
Er unterstuetzt Prioritaeten, Request-Response-Pattern und Broadcast-Nachrichten.
</p>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
<div className="text-gray-500 mb-2"># Nachrichtenfluss</div>
<pre className="text-gray-700">{`
┌──────────────┐ ┌──────────────┐
│ Sender │ │ Receiver │
│ (Agent) │ │ (Agent) │
└──────┬───────┘ └──────▲───────┘
│ │
│ publish(AgentMessage) │ handle(message)
│ │
▼ │
┌────────────────────────────────────────────────────────┐
│ Message Bus │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Priority Q │ │ Routing │ │ Logging │ │
│ │ HIGH/NORMAL │ │ Rules │ │ Audit │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Redis Pub/Sub │
└────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">Nachrichtentypen</h4>
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Typ</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_request</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Neue Aufgabe an Agent senden</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_response</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Antwort auf task_request</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">escalation</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-orange-100 text-orange-700 text-xs rounded">HIGH</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Eskalation an anderen Agent</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">alert</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">CRITICAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Kritische Benachrichtigung</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">heartbeat</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">LOW</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Liveness-Signal</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -1,71 +0,0 @@
import { Server, Brain, GitBranch } from 'lucide-react'
export function OverviewSection() {
return (
<div className="space-y-6">
<p className="text-gray-600">
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Es ermoeglicht
die Koordination mehrerer spezialisierter KI-Agents, die gemeinsam komplexe Aufgaben loesen.
</p>
{/* Architecture Diagram */}
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm overflow-x-auto">
<pre className="text-gray-700">{`
┌─────────────────────────────────────────────────────────────────┐
│ Breakpilot Services │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │Voice Service│ │Klausur Svc │ │ Admin-v2 / AlertAgent │ │
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────────────┐ │
│ │ Agent Core │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │
│ │ │ Sessions │ │Shared Brain │ │ Orchestrator │ │ │
│ │ │ - Manager │ │ - Memory │ │ - Message Bus │ │ │
│ │ │ - Heartbeat │ │ - Context │ │ - Supervisor │ │ │
│ │ │ - Checkpoint│ │ - Knowledge │ │ - Task Router │ │ │
│ │ └─────────────┘ └─────────────┘ └───────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────────────┐ │
│ │ Infrastructure │ │
│ │ Valkey (Redis) PostgreSQL Qdrant │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Server className="w-5 h-5 text-blue-600" />
<span className="font-semibold text-blue-900">Session Management</span>
</div>
<p className="text-sm text-blue-700">
Verwaltet Agent-Lifecycles mit State Machine, Checkpoints und automatischer Recovery.
</p>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-purple-900">Shared Brain</span>
</div>
<p className="text-sm text-purple-700">
Gemeinsames Gedaechtnis fuer alle Agents mit TTL, Context-Verwaltung und Knowledge Graph.
</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<GitBranch className="w-5 h-5 text-green-600" />
<span className="font-semibold text-green-900">Orchestrator</span>
</div>
<p className="text-sm text-green-700">
Message Bus, Supervisor und Task Router fuer die Agent-Koordination.
</p>
</div>
</div>
</div>
)
}
@@ -1,56 +0,0 @@
export function SessionLifecycleSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Sessions verwalten den Zustand von Agent-Interaktionen. Jede Session hat einen definierten
Lebenszyklus mit Checkpoints fuer Recovery.
</p>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
<div className="text-gray-500 mb-2"># Session State Machine</div>
<pre className="text-gray-700">{`
┌─────────────────────────────────────┐
│ │
▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ ACTIVE │───▶│ PAUSED │───▶│ COMPLETED│ │ FAILED │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ ▲
│ │ │
└───────────────┴───────────────────────────────┘
(bei Fehler)
States:
- ACTIVE: Session laeuft, Agent verarbeitet Tasks
- PAUSED: Session pausiert, wartet auf Eingabe
- COMPLETED: Session erfolgreich beendet
- FAILED: Session mit Fehler beendet
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">Heartbeat Monitoring</h4>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900">30s</div>
<div className="text-sm text-gray-500">Timeout</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">5s</div>
<div className="text-sm text-gray-500">Check Interval</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">3</div>
<div className="text-sm text-gray-500">Max Missed Beats</div>
</div>
</div>
<p className="text-sm text-gray-600 mt-4 text-center">
Nach 3 verpassten Heartbeats wird der Agent als ausgefallen markiert und die
Restart-Policy greift (max. 3 Versuche).
</p>
</div>
</div>
</div>
)
}
@@ -1,83 +0,0 @@
import { Database, Activity, GitBranch } from 'lucide-react'
export function SharedBrainSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Das Shared Brain speichert Wissen und Kontext, auf den alle Agents zugreifen koennen.
Es besteht aus drei Komponenten: Memory Store, Context Manager und Knowledge Graph.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Database className="w-5 h-5 text-blue-600" />
<h4 className="font-semibold text-gray-900">Memory Store</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Langzeit-Gedaechtnis fuer Fakten, Entscheidungen und Lernfortschritte.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- TTL-basierte Expiration (30 Tage default)</li>
<li>- Access-Tracking (Haeufigkeit)</li>
<li>- Pattern-basierte Suche</li>
<li>- Hybrid: Redis + PostgreSQL</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Activity className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Context Manager</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Verwaltet Konversationskontext mit automatischer Komprimierung.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- Max 50 Messages pro Context</li>
<li>- Automatische Zusammenfassung</li>
<li>- System-Messages bleiben erhalten</li>
<li>- Entity-Extraktion</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-5 h-5 text-green-600" />
<h4 className="font-semibold text-gray-900">Knowledge Graph</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Graph-basierte Darstellung von Entitaeten und ihren Beziehungen.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- Entitaeten: Student, Lehrer, Fach</li>
<li>- Beziehungen: lernt, unterrichtet</li>
<li>- BFS-basierte Pfadsuche</li>
<li>- Verwandte Entitaeten finden</li>
</ul>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm mt-6">
<div className="text-gray-500 mb-2"># Memory Store Beispiel</div>
<pre className="text-gray-700">{`
# Speichern
await store.remember(
key="student:123:progress",
value={"level": 5, "score": 85, "topic": "algebra"},
agent_id="tutor-agent",
ttl_days=30
)
# Abrufen
progress = await store.recall("student:123:progress")
# → {"level": 5, "score": 85, "topic": "algebra"}
# Suchen
all_progress = await store.search("student:123:*")
# → [Memory(...), Memory(...), ...]
`}</pre>
</div>
</div>
)
}
@@ -1,68 +0,0 @@
export function SoulFilesSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
SOUL-Dateien (Semantic Outline for Unified Learning) definieren die Persoenlichkeit und
Verhaltensregeln jedes Agents. Sie bestimmen, wie ein Agent kommuniziert, entscheidet und eskaliert.
</p>
<div className="bg-gray-900 rounded-xl p-6 text-gray-100 font-mono text-sm overflow-x-auto">
<div className="text-gray-400 mb-4"># Beispiel: tutor-agent.soul.md</div>
<pre className="text-green-400">{`
# TutorAgent SOUL
## Identitaet
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
## Kommunikationsstil
- Verwende einfache, klare Sprache
- Stelle Rueckfragen, um Verstaendnis zu pruefen
- Gib Hinweise statt direkter Loesungen
- Feiere kleine Erfolge
## Fachgebiete
- Mathematik (Grundschule bis Abitur)
- Naturwissenschaften (Physik, Chemie, Biologie)
- Sprachen (Deutsch, Englisch)
## Einschraenkungen
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
- Verweise bei komplexen Themen auf Lehrkraefte
- Erkenne Frustration und biete Pausen an
## Eskalation
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
- Bei technischen Problemen: Eskaliere an Support
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">SOUL-Struktur</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Identitaet</h5>
<p className="text-sm text-gray-600">Wer ist der Agent? Welche Rolle nimmt er ein?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Kommunikationsstil</h5>
<p className="text-sm text-gray-600">Wie kommuniziert der Agent mit Benutzern?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Fachgebiete</h5>
<p className="text-sm text-gray-600">In welchen Bereichen ist der Agent kompetent?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Einschraenkungen</h5>
<p className="text-sm text-gray-600">Was darf der Agent NICHT tun?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 md:col-span-2">
<h5 className="font-medium text-gray-900 mb-2">Eskalation</h5>
<p className="text-sm text-gray-600">Wann und wie eskaliert der Agent an andere Agents oder Menschen?</p>
</div>
</div>
</div>
</div>
)
}
@@ -1,82 +0,0 @@
export function TaskRoutingSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Der Task Router entscheidet, welcher Agent eine Anfrage bearbeitet. Er verwendet
Intent-basierte Regeln mit Prioritaeten und Fallback-Ketten.
</p>
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Intent-Pattern</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Ziel-Agent</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Fallback</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-2 text-sm font-mono text-blue-700">learning_*</td>
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-blue-700">help_*, question_*</td>
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">8</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-green-700">grade_*, evaluate_*</td>
<td className="px-4 py-2 text-sm text-gray-700">GraderAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-amber-700">quality_*, review_*</td>
<td className="px-4 py-2 text-sm text-gray-700">QualityJudge</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">GraderAgent</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-red-700">alert_*, monitor_*</td>
<td className="px-4 py-2 text-sm text-gray-700">AlertAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr className="bg-gray-50">
<td className="px-4 py-2 text-sm font-mono text-gray-500">* (alle anderen)</td>
<td className="px-4 py-2 text-sm text-gray-700">Orchestrator</td>
<td className="px-4 py-2 text-sm text-gray-700">0</td>
<td className="px-4 py-2 text-sm text-gray-500">-</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2">Routing-Strategien</h4>
<ul className="text-sm text-gray-600 space-y-2">
<li><span className="font-mono text-blue-600">ROUND_ROBIN</span> - Gleichmaessige Verteilung</li>
<li><span className="font-mono text-blue-600">LEAST_LOADED</span> - Agent mit wenigsten Tasks</li>
<li><span className="font-mono text-blue-600">PRIORITY</span> - Hoechste Prioritaet zuerst</li>
<li><span className="font-mono text-blue-600">RANDOM</span> - Zufaellige Auswahl</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2">Fallback-Verhalten</h4>
<ul className="text-sm text-gray-600 space-y-2">
<li>1. Versuche Ziel-Agent zu erreichen</li>
<li>2. Bei Timeout: Fallback-Agent nutzen</li>
<li>3. Bei Fehler: Orchestrator uebernimmt</li>
<li>4. Bei kritischen Fehlern: Alert an Admin</li>
</ul>
</div>
</div>
</div>
)
}
@@ -1,9 +0,0 @@
export { OverviewSection } from './OverviewSection'
export { AgentTypesSection } from './AgentTypesSection'
export { SoulFilesSection } from './SoulFilesSection'
export { MessageBusSection } from './MessageBusSection'
export { SharedBrainSection } from './SharedBrainSection'
export { TaskRoutingSection } from './TaskRoutingSection'
export { SessionLifecycleSection } from './SessionLifecycleSection'
export { DatabaseSchemaSection } from './DatabaseSchemaSection'
export type { Section } from './types'
@@ -1,6 +0,0 @@
export interface Section {
id: string
title: string
icon: React.ReactNode
content: React.ReactNode
}
@@ -2,78 +2,17 @@
import { useState } from 'react'
import Link from 'next/link'
import {
ArrowLeft, Cpu, Brain, MessageSquare, Database,
Activity, ChevronDown, ChevronRight, GitBranch,
Layers, FileText, Zap, RefreshCw,
} from 'lucide-react'
import {
OverviewSection,
AgentTypesSection,
SoulFilesSection,
MessageBusSection,
SharedBrainSection,
TaskRoutingSection,
SessionLifecycleSection,
DatabaseSchemaSection,
} from './_components'
import type { Section } from './_components'
import { ArrowLeft, Cpu, Brain, MessageSquare, Database, Activity, Shield, ChevronDown, ChevronRight, GitBranch, Layers, Server, FileText, AlertTriangle, CheckCircle, Zap, RefreshCw } from 'lucide-react'
const SECTIONS: Section[] = [
{
id: 'overview',
title: 'System-Uebersicht',
icon: <Layers className="w-5 h-5" />,
content: <OverviewSection />,
},
{
id: 'agents',
title: 'Agent-Typen',
icon: <Cpu className="w-5 h-5" />,
content: <AgentTypesSection />,
},
{
id: 'soul-files',
title: 'SOUL-Files (Persoenlichkeiten)',
icon: <FileText className="w-5 h-5" />,
content: <SoulFilesSection />,
},
{
id: 'message-bus',
title: 'Message Bus & Kommunikation',
icon: <MessageSquare className="w-5 h-5" />,
content: <MessageBusSection />,
},
{
id: 'shared-brain',
title: 'Shared Brain (Gedaechtnis)',
icon: <Brain className="w-5 h-5" />,
content: <SharedBrainSection />,
},
{
id: 'task-routing',
title: 'Task Routing',
icon: <Zap className="w-5 h-5" />,
content: <TaskRoutingSection />,
},
{
id: 'session-lifecycle',
title: 'Session Lifecycle',
icon: <RefreshCw className="w-5 h-5" />,
content: <SessionLifecycleSection />,
},
{
id: 'database',
title: 'Datenbank-Schema',
icon: <Database className="w-5 h-5" />,
content: <DatabaseSchemaSection />,
},
]
interface Section {
id: string
title: string
icon: React.ReactNode
content: React.ReactNode
}
export default function ArchitecturePage() {
const [expandedSections, setExpandedSections] = useState<string[]>([
'overview', 'agents', 'soul-files',
])
const [expandedSections, setExpandedSections] = useState<string[]>(['overview', 'agents', 'soul-files'])
const toggleSection = (id: string) => {
setExpandedSections(prev =>
@@ -83,6 +22,654 @@ export default function ArchitecturePage() {
)
}
const sections: Section[] = [
{
id: 'overview',
title: 'System-Uebersicht',
icon: <Layers className="w-5 h-5" />,
content: (
<div className="space-y-6">
<p className="text-gray-600">
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Es ermoeglicht
die Koordination mehrerer spezialisierter KI-Agents, die gemeinsam komplexe Aufgaben loesen.
</p>
{/* Architecture Diagram */}
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm overflow-x-auto">
<pre className="text-gray-700">{`
┌─────────────────────────────────────────────────────────────────┐
│ Breakpilot Services │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │Voice Service│ │Klausur Svc │ │ Admin-v2 / AlertAgent │ │
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────────────┐ │
│ │ Agent Core │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │
│ │ │ Sessions │ │Shared Brain │ │ Orchestrator │ │ │
│ │ │ - Manager │ │ - Memory │ │ - Message Bus │ │ │
│ │ │ - Heartbeat │ │ - Context │ │ - Supervisor │ │ │
│ │ │ - Checkpoint│ │ - Knowledge │ │ - Task Router │ │ │
│ │ └─────────────┘ └─────────────┘ └───────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────────────┐ │
│ │ Infrastructure │ │
│ │ Valkey (Redis) PostgreSQL Qdrant │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Server className="w-5 h-5 text-blue-600" />
<span className="font-semibold text-blue-900">Session Management</span>
</div>
<p className="text-sm text-blue-700">
Verwaltet Agent-Lifecycles mit State Machine, Checkpoints und automatischer Recovery.
</p>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-purple-900">Shared Brain</span>
</div>
<p className="text-sm text-purple-700">
Gemeinsames Gedaechtnis fuer alle Agents mit TTL, Context-Verwaltung und Knowledge Graph.
</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<GitBranch className="w-5 h-5 text-green-600" />
<span className="font-semibold text-green-900">Orchestrator</span>
</div>
<p className="text-sm text-green-700">
Message Bus, Supervisor und Task Router fuer die Agent-Koordination.
</p>
</div>
</div>
</div>
)
},
{
id: 'agents',
title: 'Agent-Typen',
icon: <Cpu className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Jeder Agent hat eine spezialisierte Rolle im System. Die Agents kommunizieren ueber den Message Bus
und nutzen das Shared Brain fuer konsistente Entscheidungen.
</p>
<div className="grid gap-4">
{/* TutorAgent */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-blue-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Brain className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">TutorAgent</h4>
<p className="text-sm text-gray-600 mb-2">Lernbegleitung und Fragen beantworten</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Geduldig</span>
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Ermutigend</span>
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Sokratisch</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: tutor-agent.soul.md | Routing: learning_*, help_*, question_*
</div>
</div>
</div>
</div>
{/* GraderAgent */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-green-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">GraderAgent</h4>
<p className="text-sm text-gray-600 mb-2">Klausur-Korrektur und Bewertung</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Objektiv</span>
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Fair</span>
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Konstruktiv</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: grader-agent.soul.md | Routing: grade_*, evaluate_*, correct_*
</div>
</div>
</div>
</div>
{/* QualityJudge */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-amber-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-amber-100 rounded-lg">
<Shield className="w-6 h-6 text-amber-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">QualityJudge</h4>
<p className="text-sm text-gray-600 mb-2">BQAS Qualitaetspruefung</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Kritisch</span>
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Praezise</span>
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Schnell</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: quality-judge.soul.md | Routing: quality_*, review_*, validate_*
</div>
</div>
</div>
</div>
{/* AlertAgent */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-red-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-red-100 rounded-lg">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">AlertAgent</h4>
<p className="text-sm text-gray-600 mb-2">Monitoring und Benachrichtigungen</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Wachsam</span>
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Proaktiv</span>
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Priorisierend</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: alert-agent.soul.md | Routing: alert_*, monitor_*, notify_*
</div>
</div>
</div>
</div>
{/* Orchestrator */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-purple-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 rounded-lg">
<MessageSquare className="w-6 h-6 text-purple-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">Orchestrator</h4>
<p className="text-sm text-gray-600 mb-2">Task-Koordination und Routing</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Koordinierend</span>
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Effizient</span>
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Zuverlaessig</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: orchestrator.soul.md | Routing: Fallback fuer alle unbekannten Intents
</div>
</div>
</div>
</div>
</div>
</div>
)
},
{
id: 'soul-files',
title: 'SOUL-Files (Persoenlichkeiten)',
icon: <FileText className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
SOUL-Dateien (Semantic Outline for Unified Learning) definieren die Persoenlichkeit und
Verhaltensregeln jedes Agents. Sie bestimmen, wie ein Agent kommuniziert, entscheidet und eskaliert.
</p>
<div className="bg-gray-900 rounded-xl p-6 text-gray-100 font-mono text-sm overflow-x-auto">
<div className="text-gray-400 mb-4"># Beispiel: tutor-agent.soul.md</div>
<pre className="text-green-400">{`
# TutorAgent SOUL
## Identitaet
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
## Kommunikationsstil
- Verwende einfache, klare Sprache
- Stelle Rueckfragen, um Verstaendnis zu pruefen
- Gib Hinweise statt direkter Loesungen
- Feiere kleine Erfolge
## Fachgebiete
- Mathematik (Grundschule bis Abitur)
- Naturwissenschaften (Physik, Chemie, Biologie)
- Sprachen (Deutsch, Englisch)
## Einschraenkungen
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
- Verweise bei komplexen Themen auf Lehrkraefte
- Erkenne Frustration und biete Pausen an
## Eskalation
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
- Bei technischen Problemen: Eskaliere an Support
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">SOUL-Struktur</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Identitaet</h5>
<p className="text-sm text-gray-600">Wer ist der Agent? Welche Rolle nimmt er ein?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Kommunikationsstil</h5>
<p className="text-sm text-gray-600">Wie kommuniziert der Agent mit Benutzern?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Fachgebiete</h5>
<p className="text-sm text-gray-600">In welchen Bereichen ist der Agent kompetent?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Einschraenkungen</h5>
<p className="text-sm text-gray-600">Was darf der Agent NICHT tun?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 md:col-span-2">
<h5 className="font-medium text-gray-900 mb-2">Eskalation</h5>
<p className="text-sm text-gray-600">Wann und wie eskaliert der Agent an andere Agents oder Menschen?</p>
</div>
</div>
</div>
</div>
)
},
{
id: 'message-bus',
title: 'Message Bus & Kommunikation',
icon: <MessageSquare className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Der Message Bus ermoeglicht die asynchrone Kommunikation zwischen Agents via Redis Pub/Sub.
Er unterstuetzt Prioritaeten, Request-Response-Pattern und Broadcast-Nachrichten.
</p>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
<div className="text-gray-500 mb-2"># Nachrichtenfluss</div>
<pre className="text-gray-700">{`
┌──────────────┐ ┌──────────────┐
│ Sender │ │ Receiver │
│ (Agent) │ │ (Agent) │
└──────┬───────┘ └──────▲───────┘
│ │
│ publish(AgentMessage) │ handle(message)
│ │
▼ │
┌────────────────────────────────────────────────────────┐
│ Message Bus │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Priority Q │ │ Routing │ │ Logging │ │
│ │ HIGH/NORMAL │ │ Rules │ │ Audit │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Redis Pub/Sub │
└────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">Nachrichtentypen</h4>
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Typ</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_request</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Neue Aufgabe an Agent senden</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_response</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Antwort auf task_request</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">escalation</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-orange-100 text-orange-700 text-xs rounded">HIGH</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Eskalation an anderen Agent</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">alert</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">CRITICAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Kritische Benachrichtigung</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">heartbeat</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">LOW</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Liveness-Signal</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
},
{
id: 'shared-brain',
title: 'Shared Brain (Gedaechtnis)',
icon: <Brain className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Das Shared Brain speichert Wissen und Kontext, auf den alle Agents zugreifen koennen.
Es besteht aus drei Komponenten: Memory Store, Context Manager und Knowledge Graph.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Database className="w-5 h-5 text-blue-600" />
<h4 className="font-semibold text-gray-900">Memory Store</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Langzeit-Gedaechtnis fuer Fakten, Entscheidungen und Lernfortschritte.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- TTL-basierte Expiration (30 Tage default)</li>
<li>- Access-Tracking (Haeufigkeit)</li>
<li>- Pattern-basierte Suche</li>
<li>- Hybrid: Redis + PostgreSQL</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Activity className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Context Manager</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Verwaltet Konversationskontext mit automatischer Komprimierung.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- Max 50 Messages pro Context</li>
<li>- Automatische Zusammenfassung</li>
<li>- System-Messages bleiben erhalten</li>
<li>- Entity-Extraktion</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-5 h-5 text-green-600" />
<h4 className="font-semibold text-gray-900">Knowledge Graph</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Graph-basierte Darstellung von Entitaeten und ihren Beziehungen.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- Entitaeten: Student, Lehrer, Fach</li>
<li>- Beziehungen: lernt, unterrichtet</li>
<li>- BFS-basierte Pfadsuche</li>
<li>- Verwandte Entitaeten finden</li>
</ul>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm mt-6">
<div className="text-gray-500 mb-2"># Memory Store Beispiel</div>
<pre className="text-gray-700">{`
# Speichern
await store.remember(
key="student:123:progress",
value={"level": 5, "score": 85, "topic": "algebra"},
agent_id="tutor-agent",
ttl_days=30
)
# Abrufen
progress = await store.recall("student:123:progress")
# → {"level": 5, "score": 85, "topic": "algebra"}
# Suchen
all_progress = await store.search("student:123:*")
# → [Memory(...), Memory(...), ...]
`}</pre>
</div>
</div>
)
},
{
id: 'task-routing',
title: 'Task Routing',
icon: <Zap className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Der Task Router entscheidet, welcher Agent eine Anfrage bearbeitet. Er verwendet
Intent-basierte Regeln mit Prioritaeten und Fallback-Ketten.
</p>
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Intent-Pattern</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Ziel-Agent</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Fallback</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-2 text-sm font-mono text-blue-700">learning_*</td>
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-blue-700">help_*, question_*</td>
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">8</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-green-700">grade_*, evaluate_*</td>
<td className="px-4 py-2 text-sm text-gray-700">GraderAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-amber-700">quality_*, review_*</td>
<td className="px-4 py-2 text-sm text-gray-700">QualityJudge</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">GraderAgent</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-red-700">alert_*, monitor_*</td>
<td className="px-4 py-2 text-sm text-gray-700">AlertAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr className="bg-gray-50">
<td className="px-4 py-2 text-sm font-mono text-gray-500">* (alle anderen)</td>
<td className="px-4 py-2 text-sm text-gray-700">Orchestrator</td>
<td className="px-4 py-2 text-sm text-gray-700">0</td>
<td className="px-4 py-2 text-sm text-gray-500">-</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2">Routing-Strategien</h4>
<ul className="text-sm text-gray-600 space-y-2">
<li><span className="font-mono text-blue-600">ROUND_ROBIN</span> - Gleichmaessige Verteilung</li>
<li><span className="font-mono text-blue-600">LEAST_LOADED</span> - Agent mit wenigsten Tasks</li>
<li><span className="font-mono text-blue-600">PRIORITY</span> - Hoechste Prioritaet zuerst</li>
<li><span className="font-mono text-blue-600">RANDOM</span> - Zufaellige Auswahl</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2">Fallback-Verhalten</h4>
<ul className="text-sm text-gray-600 space-y-2">
<li>1. Versuche Ziel-Agent zu erreichen</li>
<li>2. Bei Timeout: Fallback-Agent nutzen</li>
<li>3. Bei Fehler: Orchestrator uebernimmt</li>
<li>4. Bei kritischen Fehlern: Alert an Admin</li>
</ul>
</div>
</div>
</div>
)
},
{
id: 'session-lifecycle',
title: 'Session Lifecycle',
icon: <RefreshCw className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Sessions verwalten den Zustand von Agent-Interaktionen. Jede Session hat einen definierten
Lebenszyklus mit Checkpoints fuer Recovery.
</p>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
<div className="text-gray-500 mb-2"># Session State Machine</div>
<pre className="text-gray-700">{`
┌─────────────────────────────────────┐
│ │
▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ ACTIVE │───▶│ PAUSED │───▶│ COMPLETED│ │ FAILED │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ ▲
│ │ │
└───────────────┴───────────────────────────────┘
(bei Fehler)
States:
- ACTIVE: Session laeuft, Agent verarbeitet Tasks
- PAUSED: Session pausiert, wartet auf Eingabe
- COMPLETED: Session erfolgreich beendet
- FAILED: Session mit Fehler beendet
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">Heartbeat Monitoring</h4>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900">30s</div>
<div className="text-sm text-gray-500">Timeout</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">5s</div>
<div className="text-sm text-gray-500">Check Interval</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">3</div>
<div className="text-sm text-gray-500">Max Missed Beats</div>
</div>
</div>
<p className="text-sm text-gray-600 mt-4 text-center">
Nach 3 verpassten Heartbeats wird der Agent als ausgefallen markiert und die
Restart-Policy greift (max. 3 Versuche).
</p>
</div>
</div>
</div>
)
},
{
id: 'database',
title: 'Datenbank-Schema',
icon: <Database className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Das Agent-System nutzt PostgreSQL fuer persistente Daten und Valkey (Redis) fuer Caching und Pub/Sub.
</p>
<div className="space-y-4">
{/* agent_sessions */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_sessions</h4>
<p className="text-sm text-gray-600 mb-3">Speichert Session-Daten mit Checkpoints</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_sessions (
id UUID PRIMARY KEY,
agent_type VARCHAR(50) NOT NULL,
user_id UUID REFERENCES users(id),
state VARCHAR(20) NOT NULL DEFAULT 'active',
context JSONB DEFAULT '{}',
checkpoints JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_heartbeat TIMESTAMPTZ DEFAULT NOW()
);
`}</pre>
</div>
</div>
{/* agent_memory */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_memory</h4>
<p className="text-sm text-gray-600 mb-3">Langzeit-Gedaechtnis mit TTL</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_memory (
id UUID PRIMARY KEY,
namespace VARCHAR(100) NOT NULL,
key VARCHAR(500) NOT NULL,
value JSONB NOT NULL,
agent_id VARCHAR(50) NOT NULL,
access_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
UNIQUE(namespace, key)
);
`}</pre>
</div>
</div>
{/* agent_messages */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_messages</h4>
<p className="text-sm text-gray-600 mb-3">Audit-Trail fuer Inter-Agent Kommunikation</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_messages (
id UUID PRIMARY KEY,
sender VARCHAR(50) NOT NULL,
receiver VARCHAR(50) NOT NULL,
message_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
priority INTEGER DEFAULT 1,
correlation_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
`}</pre>
</div>
</div>
</div>
</div>
)
}
]
return (
<div className="p-6 max-w-5xl mx-auto">
{/* Header */}
@@ -109,7 +696,7 @@ export default function ArchitecturePage() {
<div className="bg-gray-50 rounded-xl p-5 mb-8">
<h2 className="font-semibold text-gray-900 mb-3">Inhaltsverzeichnis</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{SECTIONS.map(section => (
{sections.map(section => (
<button
key={section.id}
onClick={() => {
@@ -129,7 +716,7 @@ export default function ArchitecturePage() {
{/* Sections */}
<div className="space-y-4">
{SECTIONS.map(section => (
{sections.map(section => (
<div
key={section.id}
id={section.id}
@@ -162,7 +749,7 @@ export default function ArchitecturePage() {
{/* Footer Links */}
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-5">
<h3 className="font-semibold text-teal-900 mb-3">Weiterfuehrende Ressourcen</h3>
<h3 className="font-semibold text-teal-900 mb-3">Weiterführende Ressourcen</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Link
href="/ai/agents"
@@ -94,6 +94,19 @@ const mockAgents: AgentConfig[] = [
totalProcessed: 8934,
avgResponseTime: 12,
lastActivity: 'just now'
},
{
id: 'compliance-advisor',
name: 'Compliance Advisor',
description: 'DSGVO/Compliance-Berater fuer SDK-Nutzer',
soulFile: 'compliance-advisor.soul.md',
color: '#6366f1',
icon: 'message',
status: 'running',
activeSessions: 0,
totalProcessed: 0,
avgResponseTime: 0,
lastActivity: new Date().toISOString()
}
]
+396
View File
@@ -0,0 +1,396 @@
'use client'
/**
* GPU Infrastructure Admin Page
*
* vast.ai GPU Management for LLM Processing
* Part of KI-Werkzeuge
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
interface VastStatus {
instance_id: number | null
status: string
gpu_name: string | null
dph_total: number | null
endpoint_base_url: string | null
last_activity: string | null
auto_shutdown_in_minutes: number | null
total_runtime_hours: number | null
total_cost_usd: number | null
account_credit: number | null
account_total_spend: number | null
session_runtime_minutes: number | null
session_cost_usd: number | null
message: string | null
error?: string
}
export default function GPUInfrastructurePage() {
const [status, setStatus] = useState<VastStatus | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const API_PROXY = '/api/admin/gpu'
const fetchStatus = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(API_PROXY)
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`)
}
setStatus(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
setStatus({
instance_id: null,
status: 'error',
gpu_name: null,
dph_total: null,
endpoint_base_url: null,
last_activity: null,
auto_shutdown_in_minutes: null,
total_runtime_hours: null,
total_cost_usd: null,
account_credit: null,
account_total_spend: null,
session_runtime_minutes: null,
session_cost_usd: null,
message: 'Verbindung fehlgeschlagen'
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
useEffect(() => {
const interval = setInterval(fetchStatus, 30000)
return () => clearInterval(interval)
}, [fetchStatus])
const powerOn = async () => {
setActionLoading('on')
setError(null)
setMessage(null)
try {
const response = await fetch(API_PROXY, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'on' }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
}
setMessage('Start angefordert')
setTimeout(fetchStatus, 3000)
setTimeout(fetchStatus, 10000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Starten')
fetchStatus()
} finally {
setActionLoading(null)
}
}
const powerOff = async () => {
setActionLoading('off')
setError(null)
setMessage(null)
try {
const response = await fetch(API_PROXY, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'off' }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
}
setMessage('Stop angefordert')
setTimeout(fetchStatus, 3000)
setTimeout(fetchStatus, 10000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Stoppen')
fetchStatus()
} finally {
setActionLoading(null)
}
}
const getStatusBadge = (s: string) => {
const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase'
switch (s) {
case 'running':
return `${baseClasses} bg-green-100 text-green-800`
case 'stopped':
case 'exited':
return `${baseClasses} bg-red-100 text-red-800`
case 'loading':
case 'scheduling':
case 'creating':
case 'starting...':
case 'stopping...':
return `${baseClasses} bg-yellow-100 text-yellow-800`
default:
return `${baseClasses} bg-slate-100 text-slate-600`
}
}
const getCreditColor = (credit: number | null) => {
if (credit === null) return 'text-slate-500'
if (credit < 5) return 'text-red-600'
if (credit < 15) return 'text-yellow-600'
return 'text-green-600'
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="GPU Infrastruktur"
purpose="Verwalten Sie die vast.ai GPU-Instanzen fuer LLM-Verarbeitung und OCR. Starten/Stoppen Sie GPUs bei Bedarf und ueberwachen Sie Kosten in Echtzeit."
audience={['DevOps', 'Entwickler', 'System-Admins']}
architecture={{
services: ['vast.ai API', 'Ollama', 'VLLM'],
databases: ['PostgreSQL (Logs)'],
}}
relatedPages={[
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Tests' },
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR Testing' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* KI-Werkzeuge Sidebar */}
<AIToolsSidebarResponsive currentTool="gpu" />
{/* Status Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
<div>
<div className="text-sm text-slate-500 mb-2">Status</div>
{loading ? (
<span className="px-3 py-1 rounded-full text-sm font-semibold bg-slate-100 text-slate-600">
Laden...
</span>
) : (
<span className={getStatusBadge(
actionLoading === 'on' ? 'starting...' :
actionLoading === 'off' ? 'stopping...' :
status?.status || 'unknown'
)}>
{actionLoading === 'on' ? 'starting...' :
actionLoading === 'off' ? 'stopping...' :
status?.status || 'unbekannt'}
</span>
)}
</div>
<div>
<div className="text-sm text-slate-500 mb-2">GPU</div>
<div className="font-semibold text-slate-900">
{status?.gpu_name || '-'}
</div>
</div>
<div>
<div className="text-sm text-slate-500 mb-2">Kosten/h</div>
<div className="font-semibold text-slate-900">
{status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'}
</div>
</div>
<div>
<div className="text-sm text-slate-500 mb-2">Auto-Stop</div>
<div className="font-semibold text-slate-900">
{status && status.auto_shutdown_in_minutes !== null
? `${status.auto_shutdown_in_minutes} min`
: '-'}
</div>
</div>
<div>
<div className="text-sm text-slate-500 mb-2">Budget</div>
<div className={`font-bold text-lg ${getCreditColor(status?.account_credit ?? null)}`}>
{status && status.account_credit !== null
? `$${status.account_credit.toFixed(2)}`
: '-'}
</div>
</div>
<div>
<div className="text-sm text-slate-500 mb-2">Session</div>
<div className="font-semibold text-slate-900">
{status && status.session_runtime_minutes !== null && status.session_cost_usd !== null
? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}`
: '-'}
</div>
</div>
</div>
{/* Buttons */}
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200">
<button
onClick={powerOn}
disabled={actionLoading !== null || status?.status === 'running'}
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Starten
</button>
<button
onClick={powerOff}
disabled={actionLoading !== null || status?.status !== 'running'}
className="px-6 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Stoppen
</button>
<button
onClick={fetchStatus}
disabled={loading}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
>
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
</button>
{message && (
<span className="ml-4 text-sm text-green-600 font-medium">{message}</span>
)}
{error && (
<span className="ml-4 text-sm text-red-600 font-medium">{error}</span>
)}
</div>
</div>
{/* Extended Stats */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Kosten-Uebersicht</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-slate-600">Session Laufzeit</span>
<span className="font-semibold">
{status && status.session_runtime_minutes !== null
? `${Math.round(status.session_runtime_minutes)} Minuten`
: '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Session Kosten</span>
<span className="font-semibold">
{status && status.session_cost_usd !== null
? `$${status.session_cost_usd.toFixed(4)}`
: '-'}
</span>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-100">
<span className="text-slate-600">Gesamtlaufzeit</span>
<span className="font-semibold">
{status && status.total_runtime_hours !== null
? `${status.total_runtime_hours.toFixed(1)} Stunden`
: '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Gesamtkosten</span>
<span className="font-semibold">
{status && status.total_cost_usd !== null
? `$${status.total_cost_usd.toFixed(2)}`
: '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">vast.ai Ausgaben</span>
<span className="font-semibold">
{status && status.account_total_spend !== null
? `$${status.account_total_spend.toFixed(2)}`
: '-'}
</span>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Instanz-Details</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-slate-600">Instanz ID</span>
<span className="font-mono text-sm">
{status?.instance_id || '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">GPU</span>
<span className="font-semibold">
{status?.gpu_name || '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Stundensatz</span>
<span className="font-semibold">
{status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Letzte Aktivitaet</span>
<span className="text-sm">
{status?.last_activity
? new Date(status.last_activity).toLocaleString('de-DE')
: '-'}
</span>
</div>
{status?.endpoint_base_url && status.status === 'running' && (
<div className="pt-4 border-t border-slate-100">
<div className="text-slate-600 text-sm mb-1">Endpoint</div>
<code className="text-xs bg-slate-100 px-2 py-1 rounded block overflow-x-auto">
{status.endpoint_base_url}
</code>
</div>
)}
</div>
</div>
</div>
{/* Info */}
<div className="bg-violet-50 border border-violet-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-violet-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-violet-900">Auto-Shutdown</h4>
<p className="text-sm text-violet-800 mt-1">
Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist.
Der Status wird alle 30 Sekunden automatisch aktualisiert.
</p>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,503 @@
'use client'
/**
* LLM Comparison Tool
*
* Vergleicht Antworten von verschiedenen LLM-Providern:
* - OpenAI/ChatGPT
* - Claude
* - Self-hosted + Tavily
* - Self-hosted + EduSearch
*/
import { useState, useEffect, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
interface LLMResponse {
provider: string
model: string
response: string
latency_ms: number
tokens_used?: number
search_results?: Array<{
title: string
url: string
content: string
score?: number
}>
error?: string
timestamp: string
}
interface ComparisonResult {
comparison_id: string
prompt: string
system_prompt?: string
responses: LLMResponse[]
created_at: string
}
const providerColors: Record<string, { bg: string; border: string; text: string }> = {
openai: { bg: 'bg-emerald-50', border: 'border-emerald-300', text: 'text-emerald-700' },
claude: { bg: 'bg-orange-50', border: 'border-orange-300', text: 'text-orange-700' },
selfhosted_tavily: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' },
selfhosted_edusearch: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' },
}
const providerLabels: Record<string, string> = {
openai: 'OpenAI GPT-4o-mini',
claude: 'Claude 3.5 Sonnet',
selfhosted_tavily: 'Self-hosted + Tavily',
selfhosted_edusearch: 'Self-hosted + EduSearch',
}
export default function LLMComparePage() {
// State
const [prompt, setPrompt] = useState('')
const [systemPrompt, setSystemPrompt] = useState('Du bist ein hilfreicher Assistent fuer Lehrkraefte in Deutschland.')
// Provider toggles
const [enableOpenAI, setEnableOpenAI] = useState(true)
const [enableClaude, setEnableClaude] = useState(true)
const [enableTavily, setEnableTavily] = useState(true)
const [enableEduSearch, setEnableEduSearch] = useState(true)
// Parameters
const [model, setModel] = useState('llama3.2:3b')
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2048)
// Results
const [isLoading, setIsLoading] = useState(false)
const [result, setResult] = useState<ComparisonResult | null>(null)
const [history, setHistory] = useState<ComparisonResult[]>([])
const [error, setError] = useState<string | null>(null)
// UI State
const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false)
// API Base URL
const API_URL = process.env.NEXT_PUBLIC_LLM_GATEWAY_URL || 'http://localhost:8082'
const API_KEY = process.env.NEXT_PUBLIC_LLM_API_KEY || 'dev-key'
// Load history
const loadHistory = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/v1/comparison/history?limit=20`, {
headers: { Authorization: `Bearer ${API_KEY}` },
})
if (response.ok) {
const data = await response.json()
setHistory(data.comparisons || [])
}
} catch (e) {
console.error('Failed to load history:', e)
}
}, [API_URL, API_KEY])
useEffect(() => {
loadHistory()
}, [loadHistory])
const runComparison = async () => {
if (!prompt.trim()) {
setError('Bitte geben Sie einen Prompt ein')
return
}
setIsLoading(true)
setError(null)
setResult(null)
try {
const response = await fetch(`${API_URL}/v1/comparison/run`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
prompt,
system_prompt: systemPrompt || undefined,
enable_openai: enableOpenAI,
enable_claude: enableClaude,
enable_selfhosted_tavily: enableTavily,
enable_selfhosted_edusearch: enableEduSearch,
selfhosted_model: model,
temperature,
max_tokens: maxTokens,
}),
})
if (!response.ok) {
throw new Error(`API Error: ${response.status}`)
}
const data = await response.json()
setResult(data)
loadHistory()
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const ResponseCard = ({ response }: { response: LLMResponse }) => {
const colors = providerColors[response.provider] || {
bg: 'bg-slate-50',
border: 'border-slate-300',
text: 'text-slate-700',
}
const label = providerLabels[response.provider] || response.provider
return (
<div className={`rounded-xl border-2 ${colors.border} ${colors.bg} overflow-hidden`}>
<div className={`px-4 py-3 border-b ${colors.border} flex items-center justify-between`}>
<div>
<h3 className={`font-semibold ${colors.text}`}>{label}</h3>
<p className="text-xs text-slate-500">{response.model}</p>
</div>
<div className="text-right text-xs text-slate-500">
<div>{response.latency_ms}ms</div>
{response.tokens_used && <div>{response.tokens_used} tokens</div>}
</div>
</div>
<div className="p-4">
{response.error ? (
<div className="text-red-600 text-sm">
<strong>Fehler:</strong> {response.error}
</div>
) : (
<pre className="whitespace-pre-wrap text-sm text-slate-700 font-sans">
{response.response}
</pre>
)}
</div>
{response.search_results && response.search_results.length > 0 && (
<div className="px-4 pb-4">
<details className="text-xs">
<summary className="cursor-pointer text-slate-500 hover:text-slate-700">
{response.search_results.length} Suchergebnisse anzeigen
</summary>
<ul className="mt-2 space-y-2">
{response.search_results.map((sr, idx) => (
<li key={idx} className="bg-white rounded p-2 border border-slate-200">
<a
href={sr.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium"
>
{sr.title || 'Untitled'}
</a>
<p className="text-slate-500 truncate">{sr.content}</p>
</li>
))}
</ul>
</details>
</div>
)}
</div>
)
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="LLM Vergleich"
purpose="Vergleichen Sie Antworten verschiedener KI-Provider (OpenAI, Claude, Self-hosted) fuer Qualitaetssicherung. Optimieren Sie Parameter und System Prompts fuer beste Ergebnisse. Standalone-Werkzeug ohne direkten Datenfluss zur KI-Pipeline."
audience={['Entwickler', 'Data Scientists', 'QA']}
architecture={{
services: ['llm-gateway (Python)', 'Ollama', 'OpenAI API', 'Claude API'],
databases: ['PostgreSQL (History)', 'Qdrant (RAG)'],
}}
relatedPages={[
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Synthetic Tests' },
{ name: 'GPU Infrastruktur', href: '/ai/gpu', description: 'GPU-Ressourcen verwalten' },
{ name: 'Agent Management', href: '/ai/agents', description: 'Multi-Agent System' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* KI-Werkzeuge Sidebar */}
<AIToolsSidebarResponsive currentTool="llm-compare" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Input & Settings */}
<div className="lg:col-span-1 space-y-4">
{/* Prompt Input */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h2 className="font-semibold text-slate-900 mb-3">Prompt</h2>
{/* System Prompt */}
<div className="mb-3">
<label className="block text-sm text-slate-600 mb-1">System Prompt</label>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm resize-none"
placeholder="System Prompt (optional)"
/>
</div>
{/* User Prompt */}
<div className="mb-3">
<label className="block text-sm text-slate-600 mb-1">User Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm resize-none"
placeholder="z.B.: Erstelle ein Arbeitsblatt zum Thema Bruchrechnung fuer Klasse 6..."
/>
</div>
{/* Provider Toggles */}
<div className="mb-4">
<label className="block text-sm text-slate-600 mb-2">Provider</label>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enableOpenAI}
onChange={(e) => setEnableOpenAI(e.target.checked)}
className="rounded"
/>
OpenAI
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enableClaude}
onChange={(e) => setEnableClaude(e.target.checked)}
className="rounded"
/>
Claude
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enableTavily}
onChange={(e) => setEnableTavily(e.target.checked)}
className="rounded"
/>
Self + Tavily
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enableEduSearch}
onChange={(e) => setEnableEduSearch(e.target.checked)}
className="rounded"
/>
Self + EduSearch
</label>
</div>
</div>
{/* Run Button */}
<button
onClick={runComparison}
disabled={isLoading || !prompt.trim()}
className="w-full py-3 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Vergleiche...
</span>
) : (
'Vergleich starten'
)}
</button>
{error && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
</div>
{/* Settings Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setShowSettings(!showSettings)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50"
>
<span className="font-semibold text-slate-900">Parameter</span>
<svg
className={`w-5 h-5 transition-transform ${showSettings ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showSettings && (
<div className="p-4 border-t border-slate-200 space-y-4">
<div>
<label className="block text-sm text-slate-600 mb-1">Self-hosted Modell</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="llama3.2:3b">Llama 3.2 3B</option>
<option value="llama3.1:8b">Llama 3.1 8B</option>
<option value="mistral:7b">Mistral 7B</option>
<option value="qwen2.5:7b">Qwen 2.5 7B</option>
</select>
</div>
<div>
<label className="block text-sm text-slate-600 mb-1">
Temperature: {temperature.toFixed(2)}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="block text-sm text-slate-600 mb-1">Max Tokens: {maxTokens}</label>
<input
type="range"
min="256"
max="4096"
step="256"
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* History Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setShowHistory(!showHistory)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50"
>
<span className="font-semibold text-slate-900">Verlauf ({history.length})</span>
<svg
className={`w-5 h-5 transition-transform ${showHistory ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showHistory && history.length > 0 && (
<div className="border-t border-slate-200 max-h-64 overflow-y-auto">
{history.map((h) => (
<button
key={h.comparison_id}
onClick={() => {
setResult(h)
setPrompt(h.prompt)
if (h.system_prompt) setSystemPrompt(h.system_prompt)
}}
className="w-full px-4 py-2 text-left hover:bg-slate-50 border-b border-slate-100 last:border-0"
>
<div className="text-sm text-slate-700 truncate">{h.prompt}</div>
<div className="text-xs text-slate-400">
{new Date(h.created_at).toLocaleString('de-DE')}
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Right Column: Results */}
<div className="lg:col-span-2">
{result ? (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">Ergebnisse</h2>
<p className="text-sm text-slate-500">ID: {result.comparison_id}</p>
</div>
<div className="text-sm text-slate-500">
{new Date(result.created_at).toLocaleString('de-DE')}
</div>
</div>
<div className="mt-2 p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-700">{result.prompt}</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{result.responses.map((response, idx) => (
<ResponseCard key={`${response.provider}-${idx}`} response={response} />
))}
</div>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<svg
className="w-16 h-16 mx-auto text-slate-300 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<h3 className="text-lg font-medium text-slate-700 mb-2">LLM-Vergleich starten</h3>
<p className="text-slate-500 max-w-md mx-auto">
Geben Sie einen Prompt ein und klicken Sie auf &quot;Vergleich starten&quot;, um
die Antworten verschiedener LLM-Provider zu vergleichen.
</p>
</div>
)}
</div>
</div>
{/* Info Box */}
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<svg className="w-6 h-6 text-teal-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 className="font-semibold text-teal-900">Qualitaetssicherung</h3>
<p className="text-sm text-teal-800 mt-1">
Dieses Tool dient zur Qualitaetssicherung der KI-Antworten. Vergleichen Sie verschiedene Provider,
um die optimalen Parameter und System Prompts zu finden. Die Ergebnisse werden fuer Audits gespeichert.
</p>
</div>
</div>
</div>
</div>
)
}
@@ -1,64 +0,0 @@
'use client'
interface GlobalDragOverlayProps {
active: boolean
}
export function GlobalDragOverlay({ active }: GlobalDragOverlayProps) {
if (!active) return null
return (
<div className="fixed inset-0 z-50 bg-purple-900/80 backdrop-blur-sm flex items-center justify-center pointer-events-none">
<div className="text-center">
<div className="text-7xl mb-4 animate-bounce">📄</div>
<div className="text-2xl font-bold text-white">Bild hier ablegen</div>
<div className="text-purple-200 mt-2">PNG, JPG - Handgeschriebener Text</div>
</div>
</div>
)
}
interface KeyboardShortcutsModalProps {
open: boolean
onClose: () => void
}
export function KeyboardShortcutsModal({ open, onClose }: KeyboardShortcutsModalProps) {
if (!open) return null
return (
<div className="fixed inset-0 z-40 bg-black/50 flex items-center justify-center" onClick={onClose}>
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-900 mb-4">Tastenkuerzel</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">Bild einfuegen</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+V</kbd>
</div>
<div className="flex justify-between">
<span className="text-slate-600">OCR starten</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+Enter</kbd>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Tab wechseln</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Alt+1-6</kbd>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Bild entfernen</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Escape</kbd>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Shortcuts anzeigen</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">?</kbd>
</div>
</div>
<button
onClick={onClose}
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm"
>
Schliessen
</button>
</div>
</div>
)
}
@@ -1,185 +0,0 @@
'use client'
export function TabArchitecture() {
return (
<div className="space-y-6">
{/* Architecture Diagram */}
<ArchitectureDiagram />
{/* Components */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ComponentCard
icon="🔍"
title="TrOCR Service"
description="Das TrOCR-Modell von Microsoft ist speziell fuer Handschrifterkennung trainiert. Es verwendet eine Vision-Transformer (ViT) Architektur fuer Bildverarbeitung und einen Text-Decoder fuer die Textgenerierung."
specs={[
{ label: 'Modell', value: 'microsoft/trocr-base-handwritten' },
{ label: 'Groesse', value: '~350 MB' },
{ label: 'Lizenz', value: 'MIT' },
{ label: 'Framework', value: 'PyTorch / Transformers' },
]}
/>
<ComponentCard
icon="🎯"
title="LoRA Fine-Tuning"
description="LoRA fuegt kleine, trainierbare Matrizen zu bestimmten Schichten hinzu, ohne das Basismodell zu veraendern. Dies ermoeglicht effizientes Fine-Tuning mit minimaler Speichernutzung."
specs={[
{ label: 'Methode', value: 'Low-Rank Adaptation' },
{ label: 'Adapter-Groesse', value: '~10 MB' },
{ label: 'Trainingszeit', value: '5-15 Min (CPU)' },
{ label: 'Min. Beispiele', value: '10' },
]}
/>
<ComponentCard
icon="🔒"
title="Pseudonymisierung"
description="Schuelernamen werden durch anonyme Tokens ersetzt, bevor Daten die lokale Umgebung verlassen. Das Mapping wird ausschliesslich lokal gespeichert."
specs={[
{ label: 'Methode', value: 'QR-Code Tokens' },
{ label: 'Token-Format', value: 'UUID v4' },
{ label: 'Mapping', value: 'Lokal beim Lehrer' },
{ label: 'Cloud-Daten', value: 'Nur Tokens + Text' },
]}
/>
<ComponentCard
icon="☁️"
title="Cloud LLM"
description="Die KI-Korrektur erfolgt auf deutschen Servern mit strikter Mandantentrennung. Es werden keine Klarnamen oder identifizierenden Informationen uebertragen."
specs={[
{ label: 'Provider', value: 'SysEleven (DE)' },
{ label: 'Standort', value: 'Deutschland' },
{ label: 'Isolation', value: 'Namespace pro Schule' },
{ label: 'Datenverarbeitung', value: 'Nur pseudonymisiert' },
]}
/>
</div>
{/* Data Flow */}
<DataFlowCard />
</div>
)
}
/* ------------------------------------------------------------------ */
function ArchitectureDiagram() {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Systemarchitektur</h2>
<div className="bg-slate-900 rounded-lg p-6 font-mono text-xs overflow-x-auto">
<pre className="text-slate-300">
{`┌─────────────────────────────────────────────────────────────────────────────┐
│ MAGIC HELP ARCHITEKTUR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ FRONTEND │ │ BACKEND │ │ STORAGE │ │
│ │ (Next.js) │ │ (FastAPI) │ │ │ │
│ │ │ │ │ │ │ │
│ │ ┌─────────┐ │ REST │ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Admin │──┼─────────┼──│ TrOCR │ │ │ │ Models │ │ │
│ │ │ Panel │ │ │ │ Service │──┼─────────┼──│ (ONNX) │ │ │
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │ │ │ │
│ │ ┌─────────┐ │ WebSocket│ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Lehrer │──┼─────────┼──│ Klausur │ │ │ │ LoRA │ │ │
│ │ │ Portal │ │ │ │ Processor │──┼─────────┼──│ Adapter │ │ │
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │ │ │ │
│ └───────────────┘ │ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Pseudo- │ │ │ │Training │ │ │
│ │ │ nymizer │──┼─────────┼──│ Data │ │ │
│ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │
│ └──────────────────┘ └───────────────┘ │
│ │ │
│ │ (nur pseudonymisiert) │
│ ▼ │
│ ┌──────────────────┐ │
│ │ CLOUD LLM │ │
│ │ (SysEleven) │ │
│ │ Namespace- │ │
│ │ Isolation │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘`}
</pre>
</div>
</div>
)
}
interface ComponentCardProps {
icon: string
title: string
description: string
specs: Array<{ label: string; value: string }>
}
function ComponentCard({ icon, title, description, specs }: ComponentCardProps) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<span>{icon}</span> {title}
</h3>
<div className="space-y-3 text-sm">
{specs.map((spec) => (
<div key={spec.label} className="flex justify-between">
<span className="text-slate-500">{spec.label}</span>
<span className="text-slate-900">{spec.value}</span>
</div>
))}
</div>
<p className="text-slate-500 text-sm mt-4">{description}</p>
</div>
)
}
const DATA_FLOW_STEPS = [
{
num: 1,
color: 'bg-blue-100 text-blue-600',
title: 'Lokale Header-Extraktion',
desc: 'TrOCR erkennt Schuelernamen, Klasse und Fach direkt im Browser/PWA (offline-faehig)',
},
{
num: 2,
color: 'bg-purple-100 text-purple-600',
title: 'Pseudonymisierung',
desc: 'Namen werden durch QR-Code Tokens ersetzt, Mapping bleibt lokal',
},
{
num: 3,
color: 'bg-green-100 text-green-600',
title: 'Cloud-Korrektur',
desc: 'Nur pseudonymisierte Dokument-Tokens werden an die KI gesendet',
},
{
num: 4,
color: 'bg-yellow-100 text-yellow-600',
title: 'Re-Identifikation',
desc: 'Ergebnisse werden lokal mit dem Mapping wieder den echten Namen zugeordnet',
},
]
function DataFlowCard() {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Datenfluss</h2>
<div className="space-y-4">
{DATA_FLOW_STEPS.map((step) => (
<div key={step.num} className="flex items-start gap-4 bg-slate-50 rounded-lg p-4">
<div className={`w-8 h-8 rounded-full ${step.color} flex items-center justify-center font-bold`}>
{step.num}
</div>
<div>
<div className="font-medium text-slate-900">{step.title}</div>
<div className="text-sm text-slate-500">{step.desc}</div>
</div>
</div>
))}
</div>
</div>
)
}
@@ -1,53 +0,0 @@
'use client'
import { BatchUploader } from '@/components/ai/BatchUploader'
import { API_BASE } from '../types'
export function TabBatch() {
return (
<div className="space-y-6">
{/* Batch OCR Processing */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-2">Batch-Verarbeitung</h2>
<p className="text-sm text-slate-500 mb-6">
Verarbeite mehrere Bilder gleichzeitig mit Echtzeit-Fortschrittsanzeige.
Die Ergebnisse werden per Server-Sent Events gestreamt.
</p>
<BatchUploader
apiBase={API_BASE}
maxFiles={20}
autoProcess={false}
onComplete={(results) => {
console.log('Batch complete:', results)
}}
/>
</div>
{/* Batch Processing Info */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
<div className="text-3xl mb-2">🚀</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Parallele Verarbeitung</h3>
<p className="text-sm text-slate-600">
Mehrere Bilder werden parallel verarbeitet fuer maximale Geschwindigkeit.
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
<div className="text-3xl mb-2">💾</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Smart Caching</h3>
<p className="text-sm text-slate-600">
Identische Bilder werden automatisch aus dem Cache geladen (unter 50ms).
</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
<div className="text-3xl mb-2">📊</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Live-Fortschritt</h3>
<p className="text-sm text-slate-600">
Echtzeit-Updates via Server-Sent Events zeigen den Verarbeitungsfortschritt.
</p>
</div>
</div>
</div>
)
}
@@ -1,127 +0,0 @@
'use client'
import { SkeletonText } from '@/components/common/SkeletonText'
import type { TrOCRStatus } from '../types'
interface TabOverviewProps {
status: TrOCRStatus | null
loading: boolean
onRefresh: () => void
}
export function TabOverview({ status, loading, onRefresh }: TabOverviewProps) {
return (
<div className="space-y-6">
{/* Status Card */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">Systemstatus</h2>
<button
onClick={onRefresh}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded text-sm transition-colors"
>
Aktualisieren
</button>
</div>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-slate-50 rounded-lg p-4">
<SkeletonText lines={1} className="mb-2" />
<div className="h-3 w-16 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
) : status?.status === 'available' ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-2xl font-bold text-slate-900">{status.model_name || 'trocr-base'}</div>
<div className="text-xs text-slate-500">Modell</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-2xl font-bold text-slate-900">{status.device || 'CPU'}</div>
<div className="text-xs text-slate-500">Geraet</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-2xl font-bold text-slate-900">{status.training_examples_count || 0}</div>
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-2xl font-bold text-slate-900">{status.has_lora_adapter ? 'Aktiv' : 'Keiner'}</div>
<div className="text-xs text-slate-500">LoRA Adapter</div>
</div>
</div>
) : status?.status === 'not_installed' ? (
<div className="text-slate-600">
<p className="mb-2">TrOCR ist nicht installiert. Fuehre aus:</p>
<code className="bg-slate-100 px-3 py-2 rounded text-sm block font-mono">{status.install_command}</code>
</div>
) : (
<div className="text-red-600">{status?.error || 'Unbekannter Fehler'}</div>
)}
</div>
{/* Quick Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
<div className="text-3xl mb-2">🎯</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Handschrifterkennung</h3>
<p className="text-sm text-slate-600">
TrOCR erkennt automatisch handgeschriebenen Text in Klausuren.
Das Modell wurde speziell fuer deutsche Handschriften optimiert.
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
<div className="text-3xl mb-2">🔒</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Privacy by Design</h3>
<p className="text-sm text-slate-600">
Alle Daten werden lokal verarbeitet. Schuelernamen werden durch
QR-Codes pseudonymisiert - DSGVO-konform.
</p>
</div>
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
<div className="text-3xl mb-2">📈</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Kontinuierliches Lernen</h3>
<p className="text-sm text-slate-600">
Mit LoRA Fine-Tuning passt sich das Modell an individuelle
Handschriften an - ohne das Basismodell zu veraendern.
</p>
</div>
</div>
{/* Workflow Overview */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Magic Onboarding Workflow</h2>
<div className="flex flex-wrap items-center gap-4 text-sm">
{WORKFLOW_STEPS.map((step, i) => (
<WorkflowStep key={step.title} step={step} showArrow={i < WORKFLOW_STEPS.length - 1} />
))}
</div>
</div>
</div>
)
}
const WORKFLOW_STEPS = [
{ icon: '📄', title: '1. Upload', desc: '25 Klausuren hochladen' },
{ icon: '🔍', title: '2. Analyse', desc: 'Lokale OCR in 5-10 Sek' },
{ icon: '✅', title: '3. Bestaetigung', desc: 'Klasse, Schueler, Fach' },
{ icon: '🤖', title: '4. KI-Korrektur', desc: 'Cloud mit Pseudonymisierung' },
{ icon: '📊', title: '5. Integration', desc: 'Notenbuch, Zeugnisse' },
]
function WorkflowStep({ step, showArrow }: { step: typeof WORKFLOW_STEPS[number]; showArrow: boolean }) {
return (
<>
<div className="flex items-center gap-2 bg-slate-50 rounded-lg px-4 py-3">
<span className="text-2xl">{step.icon}</span>
<div>
<div className="font-medium text-slate-900">{step.title}</div>
<div className="text-slate-500">{step.desc}</div>
</div>
</div>
{showArrow && <div className="text-slate-400">&rarr;</div>}
</>
)
}
@@ -1,226 +0,0 @@
'use client'
import type { MagicSettings } from '../types'
import { DEFAULT_SETTINGS } from '../types'
interface TabSettingsProps {
settings: MagicSettings
settingsSaved: boolean
onUpdateSettings: (settings: MagicSettings) => void
onSave: () => void
}
export function TabSettings({ settings, settingsSaved, onUpdateSettings, onSave }: TabSettingsProps) {
const update = (partial: Partial<MagicSettings>) => {
onUpdateSettings({ ...settings, ...partial })
}
return (
<div className="space-y-6">
{/* OCR Settings */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Einstellungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<CheckboxSetting
label="Automatische Zeilenerkennung"
description="Erkennt und verarbeitet einzelne Zeilen separat"
checked={settings.autoDetectLines}
onChange={(v) => update({ autoDetectLines: v })}
/>
<CheckboxSetting
label="Live-Vorschau"
description="OCR startet automatisch nach Bild-Upload"
checked={settings.livePreview}
onChange={(v) => update({ livePreview: v })}
/>
<CheckboxSetting
label="Sound-Feedback"
description="Akustisches Feedback bei erfolgreicher Erkennung"
checked={settings.soundFeedback}
onChange={(v) => update({ soundFeedback: v })}
/>
<div>
<label className="block text-sm text-slate-700 mb-2">Konfidenz-Schwellwert</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={settings.confidenceThreshold}
onChange={(e) => update({ confidenceThreshold: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-400 mt-1">
<span>0%</span>
<span className="text-slate-900">{(settings.confidenceThreshold * 100).toFixed(0)}%</span>
<span>100%</span>
</div>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">Max. Bildgroesse (px)</label>
<input
type="number"
value={settings.maxImageSize}
onChange={(e) => update({ maxImageSize: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
<div className="text-xs text-slate-400 mt-1">Groessere Bilder werden skaliert</div>
</div>
<CheckboxSetting
label="Ergebnis-Cache aktivieren"
description="Speichert OCR-Ergebnisse fuer identische Bilder"
checked={settings.enableCache}
onChange={(v) => update({ enableCache: v })}
/>
</div>
</div>
{/* Training Settings */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training Einstellungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm text-slate-700 mb-2">LoRA Rank</label>
<select
value={settings.loraRank}
onChange={(e) => update({ loraRank: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
>
<option value="4">4 (Schnell, weniger Kapazitaet)</option>
<option value="8">8 (Ausgewogen)</option>
<option value="16">16 (Mehr Kapazitaet)</option>
<option value="32">32 (Maximum)</option>
</select>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">LoRA Alpha</label>
<input
type="number"
value={settings.loraAlpha}
onChange={(e) => update({ loraAlpha: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
<div className="text-xs text-slate-400 mt-1">Empfohlen: 4 x LoRA Rank</div>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">Epochen</label>
<input
type="number"
min="1"
max="10"
value={settings.epochs}
onChange={(e) => update({ epochs: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">Batch Size</label>
<select
value={settings.batchSize}
onChange={(e) => update({ batchSize: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
>
<option value="1">1 (Wenig RAM)</option>
<option value="2">2</option>
<option value="4">4 (Standard)</option>
<option value="8">8 (Viel RAM)</option>
</select>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">Learning Rate</label>
<select
value={settings.learningRate}
onChange={(e) => update({ learningRate: parseFloat(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
>
<option value="0.0001">0.0001 (Schnell)</option>
<option value="0.00005">0.00005 (Standard)</option>
<option value="0.00001">0.00001 (Konservativ)</option>
</select>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end gap-4">
<button
onClick={() => onUpdateSettings(DEFAULT_SETTINGS)}
className="px-6 py-2 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded-lg text-sm font-medium transition-colors"
>
Zuruecksetzen
</button>
<button
onClick={onSave}
className="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
>
{settingsSaved ? '\u2713 Gespeichert!' : 'Einstellungen speichern'}
</button>
</div>
{/* Technical Info */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Technische Informationen</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">API Endpoint:</span>
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">/api/klausur/trocr</code>
</div>
<div>
<span className="text-slate-500">Model Path:</span>
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">~/.cache/huggingface</code>
</div>
<div>
<span className="text-slate-500">LoRA Path:</span>
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./models/lora</code>
</div>
<div>
<span className="text-slate-500">Training Data:</span>
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./data/training</code>
</div>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
function CheckboxSetting({
label,
description,
checked,
onChange,
}: {
label: string
description: string
checked: boolean
onChange: (value: boolean) => void
}) {
return (
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="w-5 h-5 rounded bg-slate-100 border-slate-300"
/>
<div>
<div className="text-slate-900 font-medium">{label}</div>
<div className="text-sm text-slate-500">{description}</div>
</div>
</label>
</div>
)
}
@@ -1,304 +0,0 @@
'use client'
import { SkeletonOCRResult, SkeletonDots } from '@/components/common/SkeletonText'
import { ConfidenceHeatmap } from '@/components/ai/ConfidenceHeatmap'
import type { OCRResult, MagicSettings } from '../types'
interface TabTestProps {
ocrResult: OCRResult | null
ocrLoading: boolean
imagePreview: string | null
uploadedImage: File | null
settings: MagicSettings
showHeatmap: boolean
onToggleHeatmap: () => void
onFileUpload: (file: File) => void
onManualOCR: () => void
onClearImage: () => void
onSendToTraining: () => void
}
function getConfidenceColor(confidence: number) {
if (confidence >= 0.9) return 'bg-green-500'
if (confidence >= 0.7) return 'bg-yellow-500'
return 'bg-red-500'
}
export function TabTest({
ocrResult,
ocrLoading,
imagePreview,
uploadedImage,
settings,
showHeatmap,
onToggleHeatmap,
onFileUpload,
onManualOCR,
onClearImage,
onSendToTraining,
}: TabTestProps) {
return (
<div className="space-y-6">
{/* OCR Test */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Test</h2>
<p className="text-sm text-slate-500 mb-4">
Teste die Handschrifterkennung mit einem eigenen Bild. Das Ergebnis zeigt
den erkannten Text, Konfidenz und Verarbeitungszeit.
{settings.livePreview && (
<span className="text-purple-600 ml-1">(Live-Vorschau aktiv)</span>
)}
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Upload Area */}
<UploadArea
imagePreview={imagePreview}
uploadedImage={uploadedImage}
ocrLoading={ocrLoading}
livePreview={settings.livePreview}
onFileUpload={onFileUpload}
onManualOCR={onManualOCR}
onClearImage={onClearImage}
/>
{/* Results Area */}
<ResultsArea
ocrResult={ocrResult}
ocrLoading={ocrLoading}
onSendToTraining={onSendToTraining}
/>
</div>
</div>
{/* Confidence Heatmap */}
{imagePreview && ocrResult && ocrResult.confidence > 0 && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">Konfidenz-Visualisierung</h2>
<button
onClick={onToggleHeatmap}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
showHeatmap
? 'bg-purple-600 text-white'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
{showHeatmap ? 'Heatmap verbergen' : 'Heatmap anzeigen'}
</button>
</div>
{showHeatmap && (
<ConfidenceHeatmap
imageSrc={imagePreview}
text={ocrResult.text}
confidence={ocrResult.confidence}
wordBoxes={ocrResult.word_boxes?.map(w => ({
text: w.text,
confidence: w.confidence,
bbox: w.bbox as [number, number, number, number]
})) || []}
charConfidences={ocrResult.char_confidences || []}
showLegend={true}
toggleable={true}
/>
)}
</div>
)}
{/* Confidence Interpretation */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Konfidenz-Interpretation</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-green-700 font-medium">90-100%</div>
<div className="text-sm text-slate-600 mt-1">Sehr hohe Sicherheit - Text kann direkt uebernommen werden</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="text-yellow-700 font-medium">70-90%</div>
<div className="text-sm text-slate-600 mt-1">Gute Sicherheit - manuelle Ueberpruefung empfohlen</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-red-700 font-medium">&lt; 70%</div>
<div className="text-sm text-slate-600 mt-1">Niedrige Sicherheit - manuelle Eingabe erforderlich</div>
</div>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
interface UploadAreaProps {
imagePreview: string | null
uploadedImage: File | null
ocrLoading: boolean
livePreview: boolean
onFileUpload: (file: File) => void
onManualOCR: () => void
onClearImage: () => void
}
function UploadArea({ imagePreview, uploadedImage, ocrLoading, livePreview, onFileUpload, onManualOCR, onClearImage }: UploadAreaProps) {
return (
<div>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all ${
imagePreview
? 'border-purple-500 bg-purple-50'
: 'border-slate-300 hover:border-purple-500'
}`}
onClick={() => document.getElementById('ocr-file-input')?.click()}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-purple-500', 'bg-purple-50') }}
onDragLeave={(e) => { e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50') }}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50')
const file = e.dataTransfer.files[0]
if (file?.type.startsWith('image/')) onFileUpload(file)
}}
>
{imagePreview ? (
<div className="relative">
<img
src={imagePreview}
alt="Hochgeladenes Bild"
className="max-h-64 mx-auto rounded-lg shadow-sm"
/>
<button
onClick={(e) => {
e.stopPropagation()
onClearImage()
}}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
title="Bild entfernen (Escape)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<>
<div className="text-4xl mb-2">📄</div>
<div className="text-slate-700">Bild hierher ziehen oder klicken zum Hochladen</div>
<div className="text-xs text-slate-400 mt-1">PNG, JPG - Handgeschriebener Text</div>
<div className="text-xs text-purple-500 mt-2">
oder <kbd className="px-1.5 py-0.5 bg-purple-100 rounded font-mono">Ctrl+V</kbd> zum Einfuegen
</div>
</>
)}
</div>
<input
type="file"
id="ocr-file-input"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) onFileUpload(file)
}}
/>
{uploadedImage && !livePreview && (
<button
onClick={onManualOCR}
disabled={ocrLoading}
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
>
{ocrLoading ? (
<span className="flex items-center justify-center gap-2">
<SkeletonDots />
Analysiere...
</span>
) : (
'OCR starten (Ctrl+Enter)'
)}
</button>
)}
</div>
)
}
interface ResultsAreaProps {
ocrResult: OCRResult | null
ocrLoading: boolean
onSendToTraining: () => void
}
function ResultsArea({ ocrResult, ocrLoading, onSendToTraining }: ResultsAreaProps) {
if (ocrLoading) return <SkeletonOCRResult />
if (!ocrResult) {
return (
<div className="bg-slate-50 rounded-lg p-8 text-center text-slate-400">
<div className="text-4xl mb-2">🔍</div>
<div>Lade ein Bild hoch um die Erkennung zu testen</div>
</div>
)
}
return (
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-slate-700">Erkannter Text:</h3>
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
ocrResult.confidence >= 0.9 ? 'bg-green-100 text-green-700' :
ocrResult.confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{(ocrResult.confidence * 100).toFixed(0)}% Konfidenz
</div>
</div>
<pre className="bg-white border p-3 rounded text-sm text-slate-900 whitespace-pre-wrap max-h-48 overflow-y-auto">
{ocrResult.text || '(Kein Text erkannt)'}
</pre>
{/* Confidence bar */}
<div className="mt-3 mb-3">
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${getConfidenceColor(ocrResult.confidence)}`}
style={{ width: `${ocrResult.confidence * 100}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="bg-white border rounded p-2">
<div className="text-slate-500 text-xs">Konfidenz</div>
<div className="text-slate-900 font-medium">{(ocrResult.confidence * 100).toFixed(1)}%</div>
</div>
<div className="bg-white border rounded p-2">
<div className="text-slate-500 text-xs">Verarbeitungszeit</div>
<div className="text-slate-900 font-medium">{ocrResult.processing_time_ms}ms</div>
</div>
<div className="bg-white border rounded p-2">
<div className="text-slate-500 text-xs">Modell</div>
<div className="text-slate-900 font-medium">{ocrResult.model || 'TrOCR'}</div>
</div>
<div className="bg-white border rounded p-2">
<div className="text-slate-500 text-xs">LoRA Adapter</div>
<div className="text-slate-900 font-medium">{ocrResult.has_lora_adapter ? 'Ja' : 'Nein'}</div>
</div>
</div>
{ocrResult.confidence < 0.9 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800 mb-2">
Die Erkennung koennte verbessert werden! Moechtest du dieses Beispiel zum Training hinzufuegen?
</p>
<button
onClick={onSendToTraining}
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm transition-colors"
>
Als Trainingsbeispiel hinzufuegen
</button>
</div>
)}
</div>
)
}
@@ -1,333 +0,0 @@
'use client'
import Link from 'next/link'
import { SkeletonDots } from '@/components/common/SkeletonText'
import { TrainingMetrics } from '@/components/ai/TrainingMetrics'
import type { TrOCRStatus, TrainingExample, MagicSettings } from '../types'
import { API_BASE } from '../types'
interface TabTrainingProps {
status: TrOCRStatus | null
examples: TrainingExample[]
trainingImage: File | null
trainingText: string
fineTuning: boolean
settings: MagicSettings
showTrainingDashboard: boolean
onSetTrainingImage: (file: File | null) => void
onSetTrainingText: (text: string) => void
onAddExample: () => void
onFineTune: () => void
onToggleDashboard: () => void
}
export function TabTraining({
status,
examples,
trainingImage,
trainingText,
fineTuning,
settings,
showTrainingDashboard,
onSetTrainingImage,
onSetTrainingText,
onAddExample,
onFineTune,
onToggleDashboard,
}: TabTrainingProps) {
const exampleCount = status?.training_examples_count || 0
const progressPct = Math.min(100, (exampleCount / 10) * 100)
return (
<div className="space-y-6">
{/* Training Overview */}
<TrainingOverviewCard
status={status}
settings={settings}
exampleCount={exampleCount}
progressPct={progressPct}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Add Training Example */}
<AddExampleCard
trainingImage={trainingImage}
trainingText={trainingText}
onSetTrainingImage={onSetTrainingImage}
onSetTrainingText={onSetTrainingText}
onAddExample={onAddExample}
/>
{/* Fine-Tuning */}
<FineTuningCard
settings={settings}
fineTuning={fineTuning}
exampleCount={exampleCount}
hasLoraAdapter={status?.has_lora_adapter || false}
onFineTune={onFineTune}
/>
</div>
{/* Training Examples List */}
{examples.length > 0 && (
<ExamplesListCard examples={examples} />
)}
{/* Training Dashboard Demo */}
<TrainingDashboardCard
showDashboard={showTrainingDashboard}
onToggle={onToggleDashboard}
/>
</div>
)
}
/* ------------------------------------------------------------------ */
function TrainingOverviewCard({
status,
settings,
exampleCount,
progressPct,
}: {
status: TrOCRStatus | null
settings: MagicSettings
exampleCount: number
progressPct: number
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training mit LoRA</h2>
<p className="text-sm text-slate-500 mb-4">
LoRA (Low-Rank Adaptation) ermoeglicht effizientes Fine-Tuning ohne das Basismodell zu veraendern.
Das Training erfolgt lokal auf Ihrem System.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-slate-900">{exampleCount}</div>
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
</div>
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-slate-900">10</div>
<div className="text-xs text-slate-500">Minimum benoetigt</div>
</div>
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-slate-900">{settings.loraRank}</div>
<div className="text-xs text-slate-500">LoRA Rank</div>
</div>
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-slate-900">{status?.has_lora_adapter ? '\u2713' : '\u2717'}</div>
<div className="text-xs text-slate-500">Adapter aktiv</div>
</div>
</div>
<div className="mb-6">
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-500">Fortschritt zum Fine-Tuning</span>
<span className="text-slate-500">{progressPct.toFixed(0)}%</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
</div>
)
}
function AddExampleCard({
trainingImage,
trainingText,
onSetTrainingImage,
onSetTrainingText,
onAddExample,
}: {
trainingImage: File | null
trainingText: string
onSetTrainingImage: (file: File | null) => void
onSetTrainingText: (text: string) => void
onAddExample: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiel hinzufuegen</h2>
<p className="text-sm text-slate-500 mb-4">
Lade ein Bild mit handgeschriebenem Text hoch und gib die korrekte Transkription ein.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-slate-700 mb-1">Bild</label>
<input
type="file"
accept="image/*"
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm"
onChange={(e) => onSetTrainingImage(e.target.files?.[0] || null)}
/>
{trainingImage && (
<div className="mt-2 text-xs text-green-600">
Bild ausgewaehlt: {trainingImage.name}
</div>
)}
</div>
<div>
<label className="block text-sm text-slate-700 mb-1">Korrekter Text (Ground Truth)</label>
<textarea
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-900 resize-none"
rows={3}
placeholder="Gib hier den korrekten Text ein..."
value={trainingText}
onChange={(e) => onSetTrainingText(e.target.value)}
/>
</div>
<button
onClick={onAddExample}
className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
>
+ Trainingsbeispiel hinzufuegen
</button>
</div>
</div>
)
}
function FineTuningCard({
settings,
fineTuning,
exampleCount,
hasLoraAdapter,
onFineTune,
}: {
settings: MagicSettings
fineTuning: boolean
exampleCount: number
hasLoraAdapter: boolean
onFineTune: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Fine-Tuning starten</h2>
<p className="text-sm text-slate-500 mb-4">
Trainiere das Modell mit den gesammelten Beispielen. Der Prozess dauert
je nach Anzahl der Beispiele einige Minuten.
</p>
<div className="bg-slate-50 rounded-lg p-4 mb-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500">Epochen:</span>
<span className="text-slate-900 ml-2">{settings.epochs}</span>
</div>
<div>
<span className="text-slate-500">Learning Rate:</span>
<span className="text-slate-900 ml-2">{settings.learningRate}</span>
</div>
<div>
<span className="text-slate-500">LoRA Rank:</span>
<span className="text-slate-900 ml-2">{settings.loraRank}</span>
</div>
<div>
<span className="text-slate-500">Batch Size:</span>
<span className="text-slate-900 ml-2">{settings.batchSize}</span>
</div>
</div>
</div>
<button
onClick={onFineTune}
disabled={fineTuning || exampleCount < 10}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
>
{fineTuning ? (
<span className="flex items-center justify-center gap-2">
<SkeletonDots />
Fine-Tuning laeuft...
</span>
) : (
'Fine-Tuning starten'
)}
</button>
{exampleCount < 10 && (
<p className="text-xs text-yellow-600 mt-2 text-center">
Noch {10 - exampleCount} Beispiele benoetigt
</p>
)}
<Link
href="/ai/ocr-labeling?model=trocr-lora"
className="w-full mt-4 px-4 py-2 bg-teal-100 text-teal-700 border border-teal-300 rounded-lg hover:bg-teal-200 flex items-center justify-center gap-2 transition-colors"
>
<span>🏷</span>
Ground Truth in OCR-Labeling sammeln
</Link>
</div>
)
}
function ExamplesListCard({ examples }: { examples: TrainingExample[] }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiele ({examples.length})</h2>
<div className="space-y-2 max-h-64 overflow-y-auto">
{examples.map((ex, i) => (
<div key={i} className="flex items-center gap-4 bg-slate-50 rounded-lg p-3">
<span className="text-slate-400 font-mono text-sm w-8">{i + 1}.</span>
<span className="text-slate-900 text-sm flex-1 truncate">{ex.ground_truth}</span>
<span className="text-slate-400 text-xs">{new Date(ex.created_at).toLocaleDateString('de-DE')}</span>
</div>
))}
</div>
</div>
)
}
function TrainingDashboardCard({
showDashboard,
onToggle,
}: {
showDashboard: boolean
onToggle: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Training Dashboard</h2>
<p className="text-sm text-slate-500">Live-Metriken waehrend des Trainings</p>
</div>
<button
onClick={onToggle}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
showDashboard
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-purple-600 hover:bg-purple-700 text-white'
}`}
>
{showDashboard ? 'Demo stoppen' : 'Demo starten'}
</button>
</div>
{showDashboard ? (
<TrainingMetrics
apiBase={API_BASE}
simulateMode={true}
onComplete={onToggle}
/>
) : (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<div className="text-4xl mb-3">📈</div>
<div className="text-slate-600 mb-2">
Das Training Dashboard zeigt Echtzeit-Metriken waehrend des Fine-Tunings
</div>
<div className="text-sm text-slate-400">
Klicke &quot;Demo starten&quot; um eine simulierte Training-Session zu sehen
</div>
</div>
)}
</div>
)
}
@@ -1,7 +0,0 @@
export { GlobalDragOverlay, KeyboardShortcutsModal } from './GlobalOverlays'
export { TabOverview } from './TabOverview'
export { TabTest } from './TabTest'
export { TabBatch } from './TabBatch'
export { TabTraining } from './TabTraining'
export { TabArchitecture } from './TabArchitecture'
export { TabSettings } from './TabSettings'
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
export type TabId = 'overview' | 'test' | 'batch' | 'training' | 'architecture' | 'settings'
export interface TrOCRStatus {
status: 'available' | 'not_installed' | 'error'
model_name?: string
model_id?: string
device?: string
is_loaded?: boolean
has_lora_adapter?: boolean
training_examples_count?: number
error?: string
install_command?: string
}
export interface OCRResult {
text: string
confidence: number
processing_time_ms: number
model: string
has_lora_adapter: boolean
char_confidences?: number[]
word_boxes?: Array<{ text: string; confidence: number; bbox: number[] }>
}
export interface TrainingExample {
image_path: string
ground_truth: string
teacher_id: string
created_at: string
}
export interface MagicSettings {
autoDetectLines: boolean
confidenceThreshold: number
maxImageSize: number
loraRank: number
loraAlpha: number
learningRate: number
epochs: number
batchSize: number
enableCache: boolean
cacheMaxAge: number
livePreview: boolean
soundFeedback: boolean
}
export const DEFAULT_SETTINGS: MagicSettings = {
autoDetectLines: true,
confidenceThreshold: 0.7,
maxImageSize: 4096,
loraRank: 8,
loraAlpha: 32,
learningRate: 0.00005,
epochs: 3,
batchSize: 4,
enableCache: true,
cacheMaxAge: 3600,
livePreview: true,
soundFeedback: false,
}
export const TABS = [
{ id: 'overview' as TabId, label: 'Uebersicht', icon: '\u{1F4CA}', shortcut: 'Alt+1' },
{ id: 'test' as TabId, label: 'OCR Test', icon: '\u{1F50D}', shortcut: 'Alt+2' },
{ id: 'batch' as TabId, label: 'Batch OCR', icon: '\u{1F4C1}', shortcut: 'Alt+3' },
{ id: 'training' as TabId, label: 'Training', icon: '\u{1F3AF}', shortcut: 'Alt+4' },
{ id: 'architecture' as TabId, label: 'Architektur', icon: '\u{1F3D7}\uFE0F', shortcut: 'Alt+5' },
{ id: 'settings' as TabId, label: 'Einstellungen', icon: '\u2699\uFE0F', shortcut: 'Alt+6' },
] as const
export const API_BASE = '/klausur-api'
@@ -1,382 +0,0 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import {
type TabId,
type TrOCRStatus,
type OCRResult,
type TrainingExample,
type MagicSettings,
DEFAULT_SETTINGS,
API_BASE,
} from './types'
function playSuccessSound() {
try {
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
oscillator.frequency.value = 800
oscillator.type = 'sine'
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2)
oscillator.start(audioContext.currentTime)
oscillator.stop(audioContext.currentTime + 0.2)
} catch {
// Audio not supported, ignore
}
}
export function useMagicHelp() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [status, setStatus] = useState<TrOCRStatus | null>(null)
const [loading, setLoading] = useState(true)
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
const [ocrLoading, setOcrLoading] = useState(false)
const [examples, setExamples] = useState<TrainingExample[]>([])
const [trainingImage, setTrainingImage] = useState<File | null>(null)
const [trainingText, setTrainingText] = useState('')
const [fineTuning, setFineTuning] = useState(false)
const [settings, setSettings] = useState<MagicSettings>(DEFAULT_SETTINGS)
const [settingsSaved, setSettingsSaved] = useState(false)
// Phase 1: New state for enhanced features
const [globalDragActive, setGlobalDragActive] = useState(false)
const [uploadedImage, setUploadedImage] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [showShortcutHint, setShowShortcutHint] = useState(false)
const [showHeatmap, setShowHeatmap] = useState(false)
const [showTrainingDashboard, setShowTrainingDashboard] = useState(false)
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
const dragCounter = useRef(0)
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/status`)
const data = await res.json()
setStatus(data)
} catch {
setStatus({ status: 'error', error: 'Failed to fetch status' })
} finally {
setLoading(false)
}
}, [])
const fetchExamples = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/examples`)
const data = await res.json()
setExamples(data.examples || [])
} catch (error) {
console.error('Failed to fetch examples:', error)
}
}, [])
// Phase 1: Live OCR with debounce
const triggerOCR = useCallback(async (file: File) => {
setOcrLoading(true)
setOcrResult(null)
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/extract?detect_lines=${settings.autoDetectLines}`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.text !== undefined) {
setOcrResult(data)
if (settings.soundFeedback && data.confidence > 0.7) {
playSuccessSound()
}
} else {
setOcrResult({ text: `Error: ${data.detail || 'Unknown error'}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
}
} catch (error) {
setOcrResult({ text: `Error: ${error}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
} finally {
setOcrLoading(false)
}
}, [settings.autoDetectLines, settings.soundFeedback])
// Handle file upload with live preview
const handleFileUpload = useCallback((file: File) => {
if (!file.type.startsWith('image/')) return
setUploadedImage(file)
const previewUrl = URL.createObjectURL(file)
setImagePreview(previewUrl)
setActiveTab('test')
if (settings.livePreview) {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
triggerOCR(file)
}, 500)
}
}, [settings.livePreview, triggerOCR])
const handleManualOCR = () => {
if (uploadedImage) {
triggerOCR(uploadedImage)
}
}
// Phase 1: Global Drag & Drop handler
useEffect(() => {
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (e.dataTransfer?.types.includes('Files')) {
setGlobalDragActive(true)
}
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setGlobalDragActive(false)
}
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setGlobalDragActive(false)
const file = e.dataTransfer?.files[0]
if (file?.type.startsWith('image/')) {
handleFileUpload(file)
}
}
document.addEventListener('dragenter', handleDragEnter)
document.addEventListener('dragleave', handleDragLeave)
document.addEventListener('dragover', handleDragOver)
document.addEventListener('drop', handleDrop)
return () => {
document.removeEventListener('dragenter', handleDragEnter)
document.removeEventListener('dragleave', handleDragLeave)
document.removeEventListener('dragover', handleDragOver)
document.removeEventListener('drop', handleDrop)
}
}, [handleFileUpload])
// Phase 1: Clipboard paste handler (Ctrl+V)
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (file) {
handleFileUpload(file)
}
break
}
}
}
document.addEventListener('paste', handlePaste)
return () => document.removeEventListener('paste', handlePaste)
}, [handleFileUpload])
// Phase 1: Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'Enter' && uploadedImage) {
e.preventDefault()
handleManualOCR()
}
if (e.key >= '1' && e.key <= '6' && e.altKey) {
e.preventDefault()
const tabIndex = parseInt(e.key) - 1
const tabIds: TabId[] = ['overview', 'test', 'batch', 'training', 'architecture', 'settings']
if (tabIds[tabIndex]) {
setActiveTab(tabIds[tabIndex])
}
}
if (e.key === 'Escape' && uploadedImage) {
setUploadedImage(null)
setImagePreview(null)
setOcrResult(null)
}
if (e.key === '?') {
setShowShortcutHint(prev => !prev)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploadedImage])
// Initial data load + settings from localStorage
useEffect(() => {
fetchStatus()
fetchExamples()
const saved = localStorage.getItem('magic-help-settings')
if (saved) {
try {
setSettings({ ...DEFAULT_SETTINGS, ...JSON.parse(saved) })
} catch {
// ignore parse errors
}
}
}, [fetchStatus, fetchExamples])
// Cleanup preview URL
useEffect(() => {
return () => {
if (imagePreview) {
URL.revokeObjectURL(imagePreview)
}
}
}, [imagePreview])
const handleAddTrainingExample = async () => {
if (!trainingImage || !trainingText.trim()) {
alert('Please provide both an image and the correct text')
return
}
const formData = new FormData()
formData.append('file', trainingImage)
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/add?ground_truth=${encodeURIComponent(trainingText)}`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.example_id) {
alert(`Training example added! Total: ${data.total_examples}`)
setTrainingImage(null)
setTrainingText('')
fetchStatus()
fetchExamples()
} else {
alert(`Error: ${data.detail || 'Unknown error'}`)
}
} catch (error) {
alert(`Error: ${error}`)
}
}
const handleFineTune = async () => {
if (!confirm('Start fine-tuning? This may take several minutes.')) return
setFineTuning(true)
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/fine-tune`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
epochs: settings.epochs,
learning_rate: settings.learningRate,
lora_rank: settings.loraRank,
lora_alpha: settings.loraAlpha,
}),
})
const data = await res.json()
if (data.status === 'success') {
alert(`Fine-tuning successful!\nExamples used: ${data.examples_used}\nEpochs: ${data.epochs}`)
fetchStatus()
} else {
alert(`Fine-tuning failed: ${data.message}`)
}
} catch (error) {
alert(`Error: ${error}`)
} finally {
setFineTuning(false)
}
}
const saveSettings = () => {
localStorage.setItem('magic-help-settings', JSON.stringify(settings))
setSettingsSaved(true)
setTimeout(() => setSettingsSaved(false), 2000)
}
const clearUploadedImage = () => {
setUploadedImage(null)
setImagePreview(null)
setOcrResult(null)
}
const sendToTraining = () => {
if (uploadedImage && ocrResult) {
setTrainingImage(uploadedImage)
setTrainingText(ocrResult.text)
setActiveTab('training')
}
}
return {
// State
activeTab,
setActiveTab,
status,
loading,
ocrResult,
ocrLoading,
examples,
trainingImage,
setTrainingImage,
trainingText,
setTrainingText,
fineTuning,
settings,
setSettings,
settingsSaved,
globalDragActive,
uploadedImage,
imagePreview,
showShortcutHint,
setShowShortcutHint,
showHeatmap,
setShowHeatmap,
showTrainingDashboard,
setShowTrainingDashboard,
// Actions
fetchStatus,
handleFileUpload,
handleManualOCR,
handleAddTrainingExample,
handleFineTune,
saveSettings,
clearUploadedImage,
sendToTraining,
}
}
export type UseMagicHelpReturn = ReturnType<typeof useMagicHelp>
File diff suppressed because it is too large Load Diff
@@ -1,420 +0,0 @@
'use client'
/**
* Ground-Truth Queue & Progress
*
* Overview page showing all sessions with their GT status.
* Clicking a session opens it in the Kombi Pipeline (/ai/ocr-overlay)
* where the actual review (split-view, inline edit, GT marking) happens.
*/
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { PagePurpose } from '@/components/common/PagePurpose'
const KLAUSUR_API = '/klausur-api'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Session {
id: string
name: string
filename: string
status: string
created_at: string
document_category: string | null
has_ground_truth: boolean
}
interface GTSession {
session_id: string
name: string
filename: string
document_category: string | null
pipeline: string | null
saved_at: string | null
summary: {
total_zones: number
total_columns: number
total_rows: number
total_cells: number
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function GroundTruthQueuePage() {
const router = useRouter()
const [allSessions, setAllSessions] = useState<Session[]>([])
const [gtSessions, setGtSessions] = useState<GTSession[]>([])
const [filter, setFilter] = useState<'all' | 'unreviewed' | 'reviewed'>('all')
const [loading, setLoading] = useState(true)
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [marking, setMarking] = useState(false)
const [markResult, setMarkResult] = useState<string | null>(null)
// Load sessions + GT sessions
const loadData = useCallback(async () => {
setLoading(true)
try {
const [sessRes, gtRes] = await Promise.all([
fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions?limit=200`),
fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/ground-truth-sessions`),
])
if (sessRes.ok) {
const data = await sessRes.json()
const gtSet = new Set<string>()
if (gtRes.ok) {
const gtData = await gtRes.json()
const gts: GTSession[] = gtData.sessions || []
setGtSessions(gts)
for (const g of gts) gtSet.add(g.session_id)
}
const sessions: Session[] = (data.sessions || [])
.filter((s: any) => !s.parent_session_id)
.map((s: any) => ({
id: s.id,
name: s.name || '',
filename: s.filename || '',
status: s.status || 'active',
created_at: s.created_at || '',
document_category: s.document_category || null,
has_ground_truth: gtSet.has(s.id),
}))
setAllSessions(sessions)
}
} catch (e) {
console.error('Failed to load data:', e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
// Filtered sessions
const filteredSessions = allSessions.filter((s) => {
if (filter === 'unreviewed') return !s.has_ground_truth
if (filter === 'reviewed') return s.has_ground_truth
return true
})
const reviewedCount = allSessions.filter((s) => s.has_ground_truth).length
const totalCount = allSessions.length
const pct = totalCount > 0 ? Math.round((reviewedCount / totalCount) * 100) : 0
// Open session in Kombi pipeline
const openInPipeline = (sessionId: string) => {
router.push(`/ai/ocr-overlay?session=${sessionId}&mode=kombi`)
}
// Batch mark as GT
const batchMark = async () => {
setMarking(true)
let success = 0
for (const sid of selectedSessions) {
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}/mark-ground-truth?pipeline=kombi`,
{ method: 'POST' },
)
if (res.ok) success++
} catch {
/* skip */
}
}
setSelectedSessions(new Set())
setMarking(false)
setMarkResult(`${success} Sessions als Ground Truth markiert`)
setTimeout(() => setMarkResult(null), 3000)
loadData()
}
const toggleSelect = (id: string) => {
setSelectedSessions((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const selectAll = () => {
if (selectedSessions.size === filteredSessions.length) {
setSelectedSessions(new Set())
} else {
setSelectedSessions(new Set(filteredSessions.map((s) => s.id)))
}
}
return (
<div className="space-y-6">
<div className="max-w-5xl mx-auto p-4 space-y-4">
<PagePurpose
title="Ground Truth Queue"
purpose="Uebersicht aller OCR-Sessions und deren Ground-Truth-Status. Zum Pruefen und Korrigieren eine Session oeffnen — sie wird im Kombi-Modus (OCR Overlay) bearbeitet."
audience={['Entwickler', 'QA']}
defaultCollapsed
architecture={{
services: ['klausur-service (FastAPI, Port 8086)'],
databases: ['PostgreSQL (ocr_pipeline_sessions)'],
}}
relatedPages={[
{
name: 'Kombi Pipeline',
href: '/ai/ocr-overlay',
description: 'Sessions bearbeiten und GT markieren',
},
{
name: 'OCR Regression',
href: '/ai/ocr-regression',
description: 'Regressions-Tests',
},
]}
/>
{/* Progress Bar */}
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-bold text-slate-900">
Ground Truth Fortschritt
</h2>
<span className="text-sm text-slate-500">
{reviewedCount} von {totalCount} markiert ({pct}%)
</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2.5">
<div
className="bg-teal-500 h-2.5 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-teal-400" />
{reviewedCount} Ground Truth
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-slate-300" />
{totalCount - reviewedCount} offen
</span>
<span>
{gtSessions.reduce((sum, g) => sum + g.summary.total_cells, 0)}{' '}
Referenz-Zellen gesamt
</span>
</div>
</div>
{/* Filter + Actions */}
<div className="flex items-center gap-4 flex-wrap">
<div className="flex gap-1 bg-slate-100 rounded-lg p-1">
{(['all', 'unreviewed', 'reviewed'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
filter === f
? 'bg-white text-slate-900 shadow-sm font-medium'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{f === 'all'
? 'Alle'
: f === 'unreviewed'
? 'Offen'
: 'Ground Truth'}
<span className="ml-1 text-xs text-slate-400">
(
{
allSessions.filter((s) =>
f === 'unreviewed'
? !s.has_ground_truth
: f === 'reviewed'
? s.has_ground_truth
: true,
).length
}
)
</span>
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2">
{selectedSessions.size > 0 && (
<button
onClick={batchMark}
disabled={marking}
className="px-3 py-1.5 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{marking
? 'Markiere...'
: `${selectedSessions.size} als GT markieren`}
</button>
)}
<button
onClick={selectAll}
className="px-3 py-1.5 text-sm text-slate-500 hover:text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
>
{selectedSessions.size === filteredSessions.length
? 'Keine auswaehlen'
: 'Alle auswaehlen'}
</button>
</div>
</div>
{/* Toast */}
{markResult && (
<div className="p-3 rounded-lg text-sm bg-emerald-50 text-emerald-700 border border-emerald-200">
{markResult}
</div>
)}
{/* Session List */}
{loading ? (
<div className="text-center py-12 text-slate-400">
Lade Sessions...
</div>
) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p className="text-lg">Keine Sessions in dieser Ansicht</p>
</div>
) : (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-slate-500">
<th className="px-4 py-2 w-8">
<input
type="checkbox"
checked={
selectedSessions.size === filteredSessions.length &&
filteredSessions.length > 0
}
onChange={selectAll}
className="rounded border-slate-300"
/>
</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Session</th>
<th className="px-4 py-2 font-medium">Kategorie</th>
<th className="px-4 py-2 font-medium">Erstellt</th>
<th className="px-4 py-2 font-medium text-right">
Aktion
</th>
</tr>
</thead>
<tbody>
{filteredSessions.map((s) => {
const gt = gtSessions.find((g) => g.session_id === s.id)
return (
<tr
key={s.id}
className="border-b border-slate-50 hover:bg-slate-50 transition-colors"
>
<td className="px-4 py-2">
<input
type="checkbox"
checked={selectedSessions.has(s.id)}
onChange={() => toggleSelect(s.id)}
className="rounded border-slate-300"
/>
</td>
<td className="px-4 py-2">
{s.has_ground_truth ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 border border-emerald-200">
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
GT
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-500 border border-slate-200">
Offen
</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded bg-slate-100 overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=64`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
;(e.target as HTMLImageElement).style.display =
'none'
}}
/>
</div>
<div className="min-w-0">
<div className="font-medium text-slate-900 truncate">
{s.name || s.filename || s.id.slice(0, 8)}
</div>
{gt && (
<div className="text-xs text-slate-400">
{gt.summary.total_cells} Zellen,{' '}
{gt.summary.total_zones} Zonen
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-2">
{s.document_category ? (
<span className="text-xs bg-slate-100 px-1.5 py-0.5 rounded text-slate-600">
{s.document_category}
</span>
) : (
<span className="text-xs text-slate-300"></span>
)}
</td>
<td className="px-4 py-2 text-slate-500">
{new Date(s.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
})}
</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => openInPipeline(s.id)}
className="px-3 py-1 text-xs bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors"
>
{s.has_ground_truth
? 'Ueberpruefen'
: 'Im Kombi-Modus oeffnen'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -1,173 +0,0 @@
'use client'
import { Suspense } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { KombiStepper } from '@/components/ocr-kombi/KombiStepper'
import { SessionList } from '@/components/ocr-kombi/SessionList'
import { SessionHeader } from '@/components/ocr-kombi/SessionHeader'
import { StepUpload } from '@/components/ocr-kombi/StepUpload'
import { StepOrientation } from '@/components/ocr-kombi/StepOrientation'
import { StepPageSplit } from '@/components/ocr-kombi/StepPageSplit'
import { StepDeskew } from '@/components/ocr-kombi/StepDeskew'
import { StepDewarp } from '@/components/ocr-kombi/StepDewarp'
import { StepContentCrop } from '@/components/ocr-kombi/StepContentCrop'
import { StepOcr } from '@/components/ocr-kombi/StepOcr'
import { StepStructure } from '@/components/ocr-kombi/StepStructure'
import { StepGridBuild } from '@/components/ocr-kombi/StepGridBuild'
import { StepGridReview } from '@/components/ocr-kombi/StepGridReview'
import { StepGutterRepair } from '@/components/ocr-kombi/StepGutterRepair'
import { StepBoxGridReview } from '@/components/ocr-kombi/StepBoxGridReview'
import { StepAnsicht } from '@/components/ocr-kombi/StepAnsicht'
import { StepGroundTruth } from '@/components/ocr-kombi/StepGroundTruth'
import { useKombiPipeline } from './useKombiPipeline'
function OcrKombiContent() {
const {
currentStep,
sessionId,
sessionName,
loadingSessions,
activeCategory,
isGroundTruth,
pageNumber,
steps,
gridSaveRef,
groupedSessions,
loadSessions,
openSession,
handleStepClick,
handleNext,
handleNewSession,
deleteSession,
renameSession,
updateCategory,
setSessionId,
setSessionName,
setIsGroundTruth,
} = useKombiPipeline()
const renderStep = () => {
switch (currentStep) {
case 0:
return (
<StepUpload
sessionId={sessionId}
onUploaded={(sid, name) => {
setSessionId(sid)
setSessionName(name)
loadSessions()
}}
onNext={handleNext}
/>
)
case 1:
return (
<StepOrientation
sessionId={sessionId}
onNext={() => handleNext()}
onSessionList={() => { loadSessions(); handleNewSession() }}
/>
)
case 2:
return (
<StepPageSplit
sessionId={sessionId}
sessionName={sessionName}
onNext={handleNext}
onSplitComplete={(childId, childName) => {
// Switch to the first child session and refresh the list
setSessionId(childId)
setSessionName(childName)
loadSessions()
}}
/>
)
case 3:
return <StepDeskew sessionId={sessionId} onNext={handleNext} />
case 4:
return <StepDewarp sessionId={sessionId} onNext={handleNext} />
case 5:
return <StepContentCrop sessionId={sessionId} onNext={handleNext} />
case 6:
return <StepOcr sessionId={sessionId} onNext={handleNext} />
case 7:
return <StepStructure sessionId={sessionId} onNext={handleNext} />
case 8:
return <StepGridBuild sessionId={sessionId} onNext={handleNext} />
case 9:
return <StepGridReview sessionId={sessionId} onNext={handleNext} saveRef={gridSaveRef} />
case 10:
return <StepGutterRepair sessionId={sessionId} onNext={handleNext} />
case 11:
return <StepBoxGridReview sessionId={sessionId} onNext={handleNext} />
case 12:
return <StepAnsicht sessionId={sessionId} onNext={handleNext} />
case 13:
return (
<StepGroundTruth
sessionId={sessionId}
isGroundTruth={isGroundTruth}
onMarked={() => setIsGroundTruth(true)}
gridSaveRef={gridSaveRef}
/>
)
default:
return null
}
}
return (
<div className="space-y-6">
<PagePurpose
title="OCR Kombi Pipeline"
purpose="Modulare 11-Schritt-Pipeline: Upload, Vorverarbeitung, Dual-Engine-OCR (PP-OCRv5 + Tesseract), Strukturerkennung, Grid-Aufbau und Review. Multi-Page-Dokument-Unterstuetzung."
audience={['Entwickler']}
architecture={{
services: ['klausur-service (FastAPI)', 'OpenCV', 'Tesseract', 'PaddleOCR'],
databases: ['PostgreSQL Sessions'],
}}
relatedPages={[
{ name: 'OCR Regression', href: '/ai/ocr-regression', description: 'Regressionstests' },
]}
defaultCollapsed
/>
<SessionList
items={groupedSessions()}
loading={loadingSessions}
activeSessionId={sessionId}
onOpenSession={(sid) => openSession(sid)}
onNewSession={handleNewSession}
onDeleteSession={deleteSession}
onRenameSession={renameSession}
onUpdateCategory={updateCategory}
/>
{sessionId && sessionName && (
<SessionHeader
sessionName={sessionName}
activeCategory={activeCategory}
isGroundTruth={isGroundTruth}
pageNumber={pageNumber}
onUpdateCategory={(cat) => updateCategory(sessionId, cat)}
/>
)}
<KombiStepper
steps={steps}
currentStep={currentStep}
onStepClick={handleStepClick}
/>
<div className="min-h-[400px]">{renderStep()}</div>
</div>
)
}
export default function OcrKombiPage() {
return (
<Suspense fallback={<div className="p-4 text-sm text-gray-400">Lade...</div>}>
<OcrKombiContent />
</Suspense>
)
}
@@ -1,266 +0,0 @@
// OCR Pipeline Types — migrated from deleted ocr-pipeline/types.ts
export type PipelineStepStatus = 'pending' | 'active' | 'completed' | 'failed' | 'skipped'
export interface PipelineStep {
id: string
name: string
icon: string
status: PipelineStepStatus
}
export type DocumentCategory =
| 'vokabelseite' | 'woerterbuch' | 'buchseite' | 'arbeitsblatt' | 'klausurseite'
| 'mathearbeit' | 'statistik' | 'zeitung' | 'formular' | 'handschrift' | 'sonstiges'
export const DOCUMENT_CATEGORIES: { value: DocumentCategory; label: string; icon: string }[] = [
{ value: 'vokabelseite', label: 'Vokabelseite', icon: '📖' },
{ value: 'woerterbuch', label: 'Woerterbuch', icon: '📕' },
{ value: 'buchseite', label: 'Buchseite', icon: '📚' },
{ value: 'arbeitsblatt', label: 'Arbeitsblatt', icon: '📝' },
{ value: 'klausurseite', label: 'Klausurseite', icon: '📄' },
{ value: 'mathearbeit', label: 'Mathearbeit', icon: '🔢' },
{ value: 'statistik', label: 'Statistik', icon: '📊' },
{ value: 'zeitung', label: 'Zeitung', icon: '📰' },
{ value: 'formular', label: 'Formular', icon: '📋' },
{ value: 'handschrift', label: 'Handschrift', icon: '✍️' },
{ value: 'sonstiges', label: 'Sonstiges', icon: '📎' },
]
export interface SessionListItem {
id: string
name: string
filename: string
status: string
current_step: number
document_category?: DocumentCategory
doc_type?: string
parent_session_id?: string
document_group_id?: string
page_number?: number
is_ground_truth?: boolean
created_at: string
updated_at?: string
}
export interface SubSession {
id: string
name: string
box_index: number
current_step?: number
status?: string
}
export interface OrientationResult {
orientation_degrees: number
corrected: boolean
duration_seconds: number
}
export interface CropResult {
crop_applied: boolean
crop_rect?: { x: number; y: number; width: number; height: number }
crop_rect_pct?: { x: number; y: number; width: number; height: number }
original_size: { width: number; height: number }
cropped_size: { width: number; height: number }
detected_format?: string
format_confidence?: number
aspect_ratio?: number
border_fractions?: { top: number; bottom: number; left: number; right: number }
skipped?: boolean
duration_seconds?: number
}
export interface DeskewResult {
session_id: string
angle_hough: number
angle_word_alignment: number
angle_iterative?: number
angle_residual?: number
angle_textline?: number
angle_applied: number
method_used: 'hough' | 'word_alignment' | 'manual' | 'iterative' | 'two_pass' | 'three_pass' | 'manual_combined'
confidence: number
duration_seconds: number
deskewed_image_url: string
binarized_image_url: string
}
export interface DewarpDetection {
method: string
shear_degrees: number
confidence: number
}
export interface DewarpResult {
session_id: string
method_used: string
shear_degrees: number
confidence: number
duration_seconds: number
dewarped_image_url: string
detections?: DewarpDetection[]
}
export interface SessionInfo {
session_id: string
filename: string
name?: string
image_width: number
image_height: number
original_image_url: string
current_step?: number
document_category?: DocumentCategory
doc_type?: string
orientation_result?: OrientationResult
crop_result?: CropResult
deskew_result?: DeskewResult
dewarp_result?: DewarpResult
sub_sessions?: SubSession[]
parent_session_id?: string
box_index?: number
document_group_id?: string
page_number?: number
}
export interface StructureGraphic {
x: number; y: number; w: number; h: number
area: number; shape: string; color_name: string; color_hex: string; confidence: number
}
export interface ExcludeRegion {
x: number; y: number; w: number; h: number; label?: string
}
export interface StructureBox {
x: number; y: number; w: number; h: number
confidence: number; border_thickness: number
bg_color_name?: string; bg_color_hex?: string
}
export interface StructureZone {
index: number; zone_type: 'content' | 'box'
x: number; y: number; w: number; h: number
}
export interface DocLayoutRegion {
x: number; y: number; w: number; h: number
class_name: string; confidence: number
}
export interface StructureResult {
image_width: number; image_height: number
content_bounds: { x: number; y: number; w: number; h: number }
boxes: StructureBox[]; zones: StructureZone[]
graphics: StructureGraphic[]; exclude_regions?: ExcludeRegion[]
color_pixel_counts: Record<string, number>
has_words: boolean; word_count: number
border_ghosts_removed?: number; duration_seconds: number
layout_regions?: DocLayoutRegion[]
detection_method?: 'opencv' | 'ppdoclayout'
}
export interface WordBbox { x: number; y: number; w: number; h: number }
export interface OcrWordBox {
text: string; left: number; top: number; width: number; height: number; conf: number
color?: string; color_name?: string; recovered?: boolean
}
export interface ColumnMeta { index: number; type: string; x: number; width: number }
export interface GridCell {
cell_id: string; row_index: number; col_index: number; col_type: string
text: string; confidence: number; bbox_px: WordBbox; bbox_pct: WordBbox
ocr_engine?: string; is_bold?: boolean
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
word_boxes?: OcrWordBox[]
}
export interface WordEntry {
row_index: number; english: string; german: string; example: string
source_page?: string; marker?: string; confidence: number
bbox: WordBbox; bbox_en: WordBbox | null; bbox_de: WordBbox | null; bbox_ex: WordBbox | null
bbox_ref?: WordBbox | null; bbox_marker?: WordBbox | null
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
}
export interface GridResult {
cells: GridCell[]
grid_shape: { rows: number; cols: number; total_cells: number }
columns_used: ColumnMeta[]
layout: 'vocab' | 'generic'
image_width: number; image_height: number; duration_seconds: number
ocr_engine?: string; vocab_entries?: WordEntry[]; entries?: WordEntry[]; entry_count?: number
summary: {
total_cells: number; non_empty_cells: number; low_confidence: number
total_entries?: number; with_english?: number; with_german?: number
}
llm_review?: {
changes: { row_index: number; field: string; old: string; new: string }[]
model_used: string; duration_ms: number; entries_corrected: number
applied_count?: number; applied_at?: string
}
}
// --- Kombi V2 Pipeline ---
export const KOMBI_V2_STEPS: PipelineStep[] = [
{ id: 'upload', name: 'Upload', icon: '📤', status: 'pending' },
{ id: 'orientation', name: 'Orientierung', icon: '🔄', status: 'pending' },
{ id: 'page-split', name: 'Seitentrennung', icon: '📖', status: 'pending' },
{ id: 'deskew', name: 'Begradigung', icon: '📐', status: 'pending' },
{ id: 'dewarp', name: 'Entzerrung', icon: '🔧', status: 'pending' },
{ id: 'content-crop', name: 'Zuschneiden', icon: '✂️', status: 'pending' },
{ id: 'ocr', name: 'OCR', icon: '🔀', status: 'pending' },
{ id: 'structure', name: 'Strukturerkennung', icon: '🔍', status: 'pending' },
{ id: 'grid-build', name: 'Grid-Aufbau', icon: '🧱', status: 'pending' },
{ id: 'grid-review', name: 'Grid-Review', icon: '📊', status: 'pending' },
{ id: 'gutter-repair', name: 'Wortkorrektur', icon: '🩹', status: 'pending' },
{ id: 'box-review', name: 'Box-Review', icon: '📦', status: 'pending' },
{ id: 'ansicht', name: 'Ansicht', icon: '👁️', status: 'pending' },
{ id: 'ground-truth', name: 'Ground Truth', icon: '✅', status: 'pending' },
]
export const KOMBI_V2_UI_TO_DB: Record<number, number> = {
0: 1, 1: 2, 2: 2, 3: 3, 4: 4, 5: 5, 6: 8, 7: 9, 8: 10, 9: 11, 10: 11, 11: 11, 12: 11, 13: 12,
}
export function dbStepToKombiV2Ui(dbStep: number): number {
if (dbStep <= 1) return 0
if (dbStep === 2) return 1
if (dbStep === 3) return 3
if (dbStep === 4) return 4
if (dbStep === 5) return 5
if (dbStep <= 8) return 6
if (dbStep === 9) return 7
if (dbStep === 10) return 8
if (dbStep === 11) return 9
return 13
}
export interface DocumentGroup {
group_id: string; title: string; page_count: number; sessions: DocumentGroupSession[]
}
export interface DocumentGroupSession {
id: string; name: string; page_number: number; current_step: number
status: string; document_category?: DocumentCategory; created_at: string
}
export type OcrEngineSource = 'both' | 'paddle_only' | 'tesseract_only' | 'conflict_paddle' | 'conflict_tesseract'
export interface OcrTransparentWord {
text: string; left: number; top: number; width: number; height: number
conf: number; engine_source: OcrEngineSource
}
export interface OcrTransparentResult {
raw_tesseract: { words: OcrTransparentWord[] }
raw_paddle: { words: OcrTransparentWord[] }
merged: { words: OcrTransparentWord[] }
stats: {
total_words: number; both_agree: number; paddle_only: number
tesseract_only: number; conflict_paddle_wins: number; conflict_tesseract_wins: number
}
}
@@ -1,298 +0,0 @@
'use client'
import { useCallback, useEffect, useState, useRef } from 'react'
import { useSearchParams } from 'next/navigation'
import type { PipelineStep, DocumentCategory, SessionListItem } from './types'
import { KOMBI_V2_STEPS, dbStepToKombiV2Ui } from './types'
export type { SessionListItem }
const KLAUSUR_API = '/klausur-api'
/** Groups sessions by document_group_id for the session list */
export interface DocumentGroupView {
group_id: string
title: string
sessions: SessionListItem[]
page_count: number
}
function initSteps(): PipelineStep[] {
return KOMBI_V2_STEPS.map((s, i) => ({
...s,
status: i === 0 ? 'active' : 'pending',
}))
}
export function useKombiPipeline() {
const [currentStep, setCurrentStep] = useState(0)
const [sessionId, setSessionId] = useState<string | null>(null)
const [sessionName, setSessionName] = useState('')
const [sessions, setSessions] = useState<SessionListItem[]>([])
const [loadingSessions, setLoadingSessions] = useState(true)
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
const [isGroundTruth, setIsGroundTruth] = useState(false)
const [pageNumber, setPageNumber] = useState<number | null>(null)
const [steps, setSteps] = useState<PipelineStep[]>(initSteps())
const searchParams = useSearchParams()
const deepLinkHandled = useRef(false)
const gridSaveRef = useRef<(() => Promise<void>) | null>(null)
// ---- Session loading ----
const loadSessions = useCallback(async () => {
setLoadingSessions(true)
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
if (res.ok) {
const data = await res.json()
setSessions((data.sessions || []).filter((s: SessionListItem) => !s.parent_session_id))
}
} catch (e) {
console.error('Failed to load sessions:', e)
} finally {
setLoadingSessions(false)
}
}, [])
useEffect(() => { loadSessions() }, [loadSessions])
// ---- Group sessions by document_group_id ----
const groupedSessions = useCallback((): (SessionListItem | DocumentGroupView)[] => {
const groups = new Map<string, SessionListItem[]>()
const ungrouped: SessionListItem[] = []
for (const s of sessions) {
if (s.document_group_id) {
const existing = groups.get(s.document_group_id) || []
existing.push(s)
groups.set(s.document_group_id, existing)
} else {
ungrouped.push(s)
}
}
const result: (SessionListItem | DocumentGroupView)[] = []
// Sort groups by earliest created_at
const sortedGroups = Array.from(groups.entries()).sort((a, b) => {
const aTime = Math.min(...a[1].map(s => new Date(s.created_at).getTime()))
const bTime = Math.min(...b[1].map(s => new Date(s.created_at).getTime()))
return bTime - aTime
})
for (const [groupId, groupSessions] of sortedGroups) {
groupSessions.sort((a, b) => (a.page_number || 0) - (b.page_number || 0))
// Extract base title (remove " — S. X" suffix)
const baseName = groupSessions[0]?.name?.replace(/ — S\. \d+$/, '') || 'Dokument'
result.push({
group_id: groupId,
title: baseName,
sessions: groupSessions,
page_count: groupSessions.length,
})
}
for (const s of ungrouped) {
result.push(s)
}
// Sort by creation time (most recent first)
const getTime = (item: SessionListItem | DocumentGroupView): number => {
if ('group_id' in item) {
return Math.min(...item.sessions.map((s: SessionListItem) => new Date(s.created_at).getTime()))
}
return new Date(item.created_at).getTime()
}
result.sort((a, b) => getTime(b) - getTime(a))
return result
}, [sessions])
// ---- Open session ----
const openSession = useCallback(async (sid: string) => {
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
if (!res.ok) return
const data = await res.json()
setSessionId(sid)
setSessionName(data.name || data.filename || '')
setActiveCategory(data.document_category || undefined)
setIsGroundTruth(!!data.ground_truth?.build_grid_reference)
setPageNumber(data.grid_editor_result?.page_number?.number ?? null)
// Determine UI step from DB state
const dbStep = data.current_step || 1
const hasGrid = !!data.grid_editor_result
const hasStructure = !!data.structure_result
const hasWords = !!data.word_result
const hasGutterRepair = !!(data.ground_truth?.gutter_repair)
let uiStep: number
if (hasGrid && hasGutterRepair) {
uiStep = 10 // gutter-repair (already analysed)
} else if (hasGrid) {
uiStep = 9 // grid-review
} else if (hasStructure) {
uiStep = 8 // grid-build
} else if (hasWords) {
uiStep = 7 // structure
} else {
uiStep = dbStepToKombiV2Ui(dbStep)
}
// Sessions only exist after upload, so always skip the upload step
if (uiStep === 0) {
uiStep = 1
}
setSteps(
KOMBI_V2_STEPS.map((s, i) => ({
...s,
status: i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending',
})),
)
setCurrentStep(uiStep)
} catch (e) {
console.error('Failed to open session:', e)
}
}, [])
// ---- Deep link handling ----
useEffect(() => {
if (deepLinkHandled.current) return
const urlSession = searchParams.get('session')
const urlStep = searchParams.get('step')
if (urlSession) {
deepLinkHandled.current = true
openSession(urlSession).then(() => {
if (urlStep) {
const stepIdx = parseInt(urlStep, 10)
if (!isNaN(stepIdx) && stepIdx >= 0 && stepIdx < KOMBI_V2_STEPS.length) {
setCurrentStep(stepIdx)
}
}
})
}
}, [searchParams, openSession])
// ---- Step navigation ----
const goToStep = useCallback((step: number) => {
setCurrentStep(step)
setSteps(prev =>
prev.map((s, i) => ({
...s,
status: i < step ? 'completed' : i === step ? 'active' : 'pending',
})),
)
}, [])
const handleStepClick = useCallback((index: number) => {
if (index <= currentStep || steps[index].status === 'completed') {
setCurrentStep(index)
}
}, [currentStep, steps])
const handleNext = useCallback(() => {
if (currentStep >= steps.length - 1) {
// Last step → return to session list
setSteps(initSteps())
setCurrentStep(0)
setSessionId(null)
loadSessions()
return
}
const nextStep = currentStep + 1
setSteps(prev =>
prev.map((s, i) => {
if (i === currentStep) return { ...s, status: 'completed' }
if (i === nextStep) return { ...s, status: 'active' }
return s
}),
)
setCurrentStep(nextStep)
}, [currentStep, steps, loadSessions])
// ---- Session CRUD ----
const handleNewSession = useCallback(() => {
setSessionId(null)
setSessionName('')
setCurrentStep(0)
setSteps(initSteps())
}, [])
const deleteSession = useCallback(async (sid: string) => {
try {
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
setSessions(prev => prev.filter(s => s.id !== sid))
if (sessionId === sid) handleNewSession()
} catch (e) {
console.error('Failed to delete session:', e)
}
}, [sessionId, handleNewSession])
const renameSession = useCallback(async (sid: string, newName: string) => {
try {
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
})
setSessions(prev => prev.map(s => s.id === sid ? { ...s, name: newName } : s))
if (sessionId === sid) setSessionName(newName)
} catch (e) {
console.error('Failed to rename session:', e)
}
}, [sessionId])
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
try {
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_category: category }),
})
setSessions(prev => prev.map(s => s.id === sid ? { ...s, document_category: category } : s))
if (sessionId === sid) setActiveCategory(category)
} catch (e) {
console.error('Failed to update category:', e)
}
}, [sessionId])
return {
// State
currentStep,
sessionId,
sessionName,
sessions,
loadingSessions,
activeCategory,
isGroundTruth,
pageNumber,
steps,
gridSaveRef,
// Computed
groupedSessions,
// Actions
loadSessions,
openSession,
goToStep,
handleStepClick,
handleNext,
handleNewSession,
deleteSession,
renameSession,
updateCategory,
setSessionId,
setSessionName,
setIsGroundTruth,
}
}
@@ -1,134 +0,0 @@
'use client'
/**
* Export tab: export training data in various formats.
*/
import { useState } from 'react'
import Link from 'next/link'
import { API_BASE } from '../constants'
import type { OCRSession, OCRStats } from '../types'
interface ExportTabProps {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
stats: OCRStats | null
setError: (error: string | null) => void
}
export function ExportTab({
sessions,
selectedSession,
setSelectedSession,
stats,
setError,
}: ExportTabProps) {
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<{
exported_count: number
batch_id: string
samples?: Array<Record<string, unknown>>
} | null>(null)
const handleExport = async () => {
setExporting(true)
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_format: exportFormat,
session_id: selectedSession,
}),
})
if (res.ok) {
const data = await res.json()
setExportResult(data)
} else {
setError('Export fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="generic">Generic JSON</option>
<option value="trocr">TrOCR Fine-Tuning</option>
<option value="llama_vision">Llama Vision Fine-Tuning</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
<select
value={selectedSession || ''}
onChange={(e) => setSelectedSession(e.target.value || null)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Sessions</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>{session.name}</option>
))}
</select>
</div>
<button
onClick={handleExport}
disabled={exporting || (stats?.exportable_items || 0) === 0}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
</button>
{/* Cross-Link to Magic Help for TrOCR Fine-Tuning */}
{exportFormat === 'trocr' && (stats?.exportable_items || 0) > 0 && (
<Link
href="/ai/magic-help?source=ocr-labeling"
className="w-full mt-3 px-4 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-lg hover:bg-purple-200 flex items-center justify-center gap-2 transition-colors"
>
<span></span>
Mit Magic Help testen & fine-tunen
</Link>
)}
</div>
</div>
{exportResult && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
<p className="text-green-800">
{exportResult.exported_count} Samples erfolgreich exportiert
</p>
<p className="text-sm text-green-600">
Batch: {exportResult.batch_id}
</p>
</div>
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
{(exportResult.samples?.length || 0) > 3 && (
<p className="text-slate-500 mt-2">... und {(exportResult.samples?.length ?? 0) - 3} weitere</p>
)}
</div>
</div>
)}
</div>
)
}
@@ -1,197 +0,0 @@
'use client'
/**
* Labeling tab: image viewer, OCR text, correction input, and queue preview.
*/
import { API_BASE } from '../constants'
import type { OCRItem } from '../types'
interface LabelingTabProps {
queue: OCRItem[]
currentItem: OCRItem | null
currentIndex: number
correctedText: string
setCorrectedText: (text: string) => void
goToNext: () => void
goToPrev: () => void
selectQueueItem: (idx: number) => void
confirmItem: () => void
correctItem: () => void
skipItem: () => void
}
export function LabelingTab({
queue,
currentItem,
currentIndex,
correctedText,
setCorrectedText,
goToNext,
goToPrev,
selectQueueItem,
confirmItem,
correctItem,
skipItem,
}: LabelingTabProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Image Viewer */}
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Bild</h3>
<div className="flex items-center gap-2">
<button
onClick={goToPrev}
disabled={currentIndex === 0}
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
title="Zurueck (Pfeiltaste links)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm text-slate-600">
{currentIndex + 1} / {queue.length}
</span>
<button
onClick={goToNext}
disabled={currentIndex >= queue.length - 1}
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
title="Weiter (Pfeiltaste rechts)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{currentItem ? (
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
<img
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
alt="OCR Bild"
className="w-full h-auto max-h-[600px] object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
</div>
)}
</div>
{/* Right: OCR Text & Actions */}
<div className="bg-white rounded-lg shadow p-4">
<div className="space-y-4">
{/* OCR Result */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
{currentItem?.ocr_confidence && (
<span className={`text-sm px-2 py-1 rounded ${
currentItem.ocr_confidence > 0.8
? 'bg-green-100 text-green-800'
: currentItem.ocr_confidence > 0.5
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
</span>
)}
</div>
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
</div>
</div>
{/* Correction Input */}
<div>
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
<textarea
value={correctedText}
onChange={(e) => setCorrectedText(e.target.value)}
placeholder="Korrigierter Text..."
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<button
onClick={confirmItem}
disabled={!currentItem}
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Korrekt (Enter)
</button>
<button
onClick={correctItem}
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Korrektur speichern
</button>
<button
onClick={skipItem}
disabled={!currentItem}
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
Ueberspringen (S)
</button>
</div>
{/* Keyboard Shortcuts */}
<div className="text-xs text-slate-500 mt-4">
<p className="font-medium mb-1">Tastaturkuerzel:</p>
<p>Enter = Bestaetigen | S = Ueberspringen</p>
<p>Pfeiltasten = Navigation</p>
</div>
</div>
</div>
{/* Bottom: Queue Preview */}
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
<div className="flex gap-2 overflow-x-auto pb-2">
{queue.slice(0, 10).map((item, idx) => (
<button
key={item.id}
onClick={() => selectQueueItem(idx)}
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
idx === currentIndex
? 'border-primary-500'
: 'border-transparent hover:border-slate-300'
}`}
>
<img
src={item.image_url || `${API_BASE}${item.image_path}`}
alt=""
className="w-full h-full object-cover"
/>
</button>
))}
{queue.length > 10 && (
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
+{queue.length - 10} mehr
</div>
)}
</div>
</div>
</div>
)
}
@@ -1,166 +0,0 @@
'use client'
/**
* Sessions tab: create new sessions and list existing ones.
*/
import { useState } from 'react'
import { API_BASE } from '../constants'
import type { OCRSession, CreateSessionRequest, OCRModel } from '../types'
interface SessionsTabProps {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
fetchSessions: () => Promise<void>
setError: (error: string | null) => void
}
export function SessionsTab({
sessions,
selectedSession,
setSelectedSession,
fetchSessions,
setError,
}: SessionsTabProps) {
const [newSession, setNewSession] = useState<CreateSessionRequest>({
name: '',
source_type: 'klausur',
description: '',
ocr_model: 'llama3.2-vision:11b',
})
const createSession = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSession),
})
if (res.ok) {
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
fetchSessions()
} else {
setError('Session erstellen fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}
return (
<div className="space-y-6">
{/* Create Session */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={newSession.name}
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
placeholder="z.B. Mathe Klausur Q1 2025"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newSession.source_type}
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="klausur">Klausur</option>
<option value="handwriting_sample">Handschriftprobe</option>
<option value="scan">Scan</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
<select
value={newSession.ocr_model}
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
<option value="donut">Donut - Document Understanding (strukturiert)</option>
</select>
<p className="mt-1 text-xs text-slate-500">
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<input
type="text"
value={newSession.description}
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
placeholder="Optional..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<button
onClick={createSession}
disabled={!newSession.name}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
Session erstellen
</button>
</div>
{/* Sessions List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
</div>
<div className="divide-y divide-slate-200">
{sessions.map((session) => (
<div
key={session.id}
className={`p-4 hover:bg-slate-50 cursor-pointer ${
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
}`}
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{session.name}</h4>
<p className="text-sm text-slate-500">
{session.source_type} | {session.ocr_model}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">
{session.labeled_items}/{session.total_items} gelabelt
</p>
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
<div
className="bg-primary-600 rounded-full h-2"
style={{
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
}}
/>
</div>
</div>
</div>
{session.description && (
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
)}
</div>
))}
{sessions.length === 0 && (
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
)}
</div>
</div>
</div>
)
}
@@ -1,76 +0,0 @@
'use client'
/**
* Stats tab: global statistics, detailed breakdown, and progress bar.
*/
import type { OCRStats } from '../types'
interface StatsTabProps {
stats: OCRStats | null
}
export function StatsTab({ stats }: StatsTabProps) {
return (
<div className="space-y-6">
{/* Global Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
</div>
</div>
{/* Detailed Stats */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Details</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-slate-500">Bestaetigt</p>
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Korrigiert</p>
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Exportierbar</p>
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
</div>
</div>
</div>
{/* Progress Bar */}
{stats?.total_items ? (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
<div className="w-full bg-slate-200 rounded-full h-4">
<div
className="bg-primary-600 rounded-full h-4 transition-all"
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
/>
</div>
<p className="text-sm text-slate-500 mt-2">
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
</p>
</div>
) : null}
</div>
)
}
@@ -1,160 +0,0 @@
'use client'
/**
* Upload tab: session selection, drag-and-drop upload, and upload results.
*/
import { useState, useRef } from 'react'
import { API_BASE } from '../constants'
import type { OCRSession, UploadResult } from '../types'
interface UploadTabProps {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
fetchQueue: () => Promise<void>
fetchStats: () => Promise<void>
setError: (error: string | null) => void
}
export function UploadTab({
sessions,
selectedSession,
setSelectedSession,
fetchQueue,
fetchStats,
setError,
}: UploadTabProps) {
const [uploading, setUploading] = useState(false)
const [uploadResults, setUploadResults] = useState<UploadResult[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = async (files: FileList) => {
if (!selectedSession) {
setError('Bitte zuerst eine Session auswaehlen')
return
}
setUploading(true)
const formData = new FormData()
Array.from(files).forEach(file => formData.append('files', file))
formData.append('run_ocr', 'true')
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
method: 'POST',
body: formData,
})
if (res.ok) {
const data = await res.json()
setUploadResults(data.items || [])
fetchQueue()
fetchStats()
} else {
setError('Upload fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler beim Upload')
} finally {
setUploading(false)
}
}
return (
<div className="space-y-6">
{/* Session Selection */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
<select
value={selectedSession || ''}
onChange={(e) => setSelectedSession(e.target.value || null)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">-- Session waehlen --</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.total_items} Items)
</option>
))}
</select>
</div>
{/* Upload Area */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center ${
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
}`}
onDragOver={(e) => {
e.preventDefault()
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
}}
onDrop={(e) => {
e.preventDefault()
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
if (e.dataTransfer.files.length > 0) {
handleUpload(e.dataTransfer.files)
}
}}
>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/jpg"
onChange={(e) => e.target.files && handleUpload(e.target.files)}
className="hidden"
disabled={!selectedSession}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
<p>Hochladen & OCR ausfuehren...</p>
</div>
) : (
<>
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-slate-600 mb-2">
Bilder hierher ziehen oder{' '}
<button
onClick={() => fileInputRef.current?.click()}
disabled={!selectedSession}
className="text-primary-600 hover:underline"
>
auswaehlen
</button>
</p>
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
</>
)}
</div>
</div>
{/* Upload Results */}
{uploadResults.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
<div className="space-y-2">
{uploadResults.map((result) => (
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
<span className="text-sm">{result.filename}</span>
<span className={`text-xs px-2 py-1 rounded ${
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -1,59 +0,0 @@
/**
* Constants and tab definitions for OCR Labeling page.
*/
import type { JSX } from 'react'
// API Base URL for klausur-service
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Tab definitions
export type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'labeling',
name: 'Labeling',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
),
},
{
id: 'sessions',
name: 'Sessions',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
id: 'upload',
name: 'Upload',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
),
},
{
id: 'stats',
name: 'Statistiken',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
id: 'export',
name: 'Export',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
),
},
]
+901 -59
View File
@@ -10,20 +10,903 @@
* OCR-Labeling → RAG Pipeline → Daten & RAG
*/
import { useState } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
import { tabs, type TabId } from './constants'
import { useOcrLabeling } from './useOcrLabeling'
import { LabelingTab } from './_components/LabelingTab'
import { SessionsTab } from './_components/SessionsTab'
import { UploadTab } from './_components/UploadTab'
import { StatsTab } from './_components/StatsTab'
import { ExportTab } from './_components/ExportTab'
import type {
OCRSession,
OCRItem,
OCRStats,
TrainingSample,
CreateSessionRequest,
OCRModel,
} from './types'
// API Base URL for klausur-service
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Tab definitions
type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'labeling',
name: 'Labeling',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
),
},
{
id: 'sessions',
name: 'Sessions',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
id: 'upload',
name: 'Upload',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
),
},
{
id: 'stats',
name: 'Statistiken',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
id: 'export',
name: 'Export',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
),
},
]
export default function OCRLabelingPage() {
const [activeTab, setActiveTab] = useState<TabId>('labeling')
const hook = useOcrLabeling()
const [sessions, setSessions] = useState<OCRSession[]>([])
const [selectedSession, setSelectedSession] = useState<string | null>(null)
const [queue, setQueue] = useState<OCRItem[]>([])
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
const [currentIndex, setCurrentIndex] = useState(0)
const [stats, setStats] = useState<OCRStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [correctedText, setCorrectedText] = useState('')
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
// Fetch sessions
const fetchSessions = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
if (res.ok) {
const data = await res.json()
setSessions(data)
}
} catch (err) {
console.error('Failed to fetch sessions:', err)
}
}, [])
// Fetch queue
const fetchQueue = useCallback(async () => {
try {
const url = selectedSession
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setQueue(data)
if (data.length > 0 && !currentItem) {
setCurrentItem(data[0])
setCurrentIndex(0)
setCorrectedText(data[0].ocr_text || '')
setLabelStartTime(Date.now())
}
}
} catch (err) {
console.error('Failed to fetch queue:', err)
}
}, [selectedSession, currentItem])
// Fetch stats
const fetchStats = useCallback(async () => {
try {
const url = selectedSession
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
: `${API_BASE}/api/v1/ocr-label/stats`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error('Failed to fetch stats:', err)
}
}, [selectedSession])
// Initial data load
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
setLoading(false)
}
loadData()
}, [fetchSessions, fetchQueue, fetchStats])
// Refresh queue when session changes
useEffect(() => {
setCurrentItem(null)
setCurrentIndex(0)
fetchQueue()
fetchStats()
}, [selectedSession, fetchQueue, fetchStats])
// Navigate to next item
const goToNext = () => {
if (currentIndex < queue.length - 1) {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
setCurrentItem(queue[nextIndex])
setCorrectedText(queue[nextIndex].ocr_text || '')
setLabelStartTime(Date.now())
} else {
// Refresh queue
fetchQueue()
}
}
// Navigate to previous item
const goToPrev = () => {
if (currentIndex > 0) {
const prevIndex = currentIndex - 1
setCurrentIndex(prevIndex)
setCurrentItem(queue[prevIndex])
setCorrectedText(queue[prevIndex].ocr_text || '')
setLabelStartTime(Date.now())
}
}
// Calculate label time
const getLabelTime = (): number | undefined => {
if (!labelStartTime) return undefined
return Math.round((Date.now() - labelStartTime) / 1000)
}
// Confirm item
const confirmItem = async () => {
if (!currentItem) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: currentItem.id,
label_time_seconds: getLabelTime(),
}),
})
if (res.ok) {
// Remove from queue and go to next
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Bestaetigung fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
}
}
// Correct item
const correctItem = async () => {
if (!currentItem || !correctedText.trim()) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: currentItem.id,
ground_truth: correctedText.trim(),
label_time_seconds: getLabelTime(),
}),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Korrektur fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
}
}
// Skip item
const skipItem = async () => {
if (!currentItem) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: currentItem.id }),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Ueberspringen fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
}
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle if not in text input
if (e.target instanceof HTMLTextAreaElement) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
confirmItem()
} else if (e.key === 'ArrowRight') {
goToNext()
} else if (e.key === 'ArrowLeft') {
goToPrev()
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
skipItem()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentItem, correctedText])
// Render Labeling Tab
const renderLabelingTab = () => (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Image Viewer */}
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Bild</h3>
<div className="flex items-center gap-2">
<button
onClick={goToPrev}
disabled={currentIndex === 0}
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
title="Zurueck (Pfeiltaste links)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm text-slate-600">
{currentIndex + 1} / {queue.length}
</span>
<button
onClick={goToNext}
disabled={currentIndex >= queue.length - 1}
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
title="Weiter (Pfeiltaste rechts)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{currentItem ? (
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
<img
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
alt="OCR Bild"
className="w-full h-auto max-h-[600px] object-contain"
onError={(e) => {
// Fallback if image fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
</div>
)}
</div>
{/* Right: OCR Text & Actions */}
<div className="bg-white rounded-lg shadow p-4">
<div className="space-y-4">
{/* OCR Result */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
{currentItem?.ocr_confidence && (
<span className={`text-sm px-2 py-1 rounded ${
currentItem.ocr_confidence > 0.8
? 'bg-green-100 text-green-800'
: currentItem.ocr_confidence > 0.5
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
</span>
)}
</div>
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
</div>
</div>
{/* Correction Input */}
<div>
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
<textarea
value={correctedText}
onChange={(e) => setCorrectedText(e.target.value)}
placeholder="Korrigierter Text..."
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<button
onClick={confirmItem}
disabled={!currentItem}
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Korrekt (Enter)
</button>
<button
onClick={correctItem}
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Korrektur speichern
</button>
<button
onClick={skipItem}
disabled={!currentItem}
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
Ueberspringen (S)
</button>
</div>
{/* Keyboard Shortcuts */}
<div className="text-xs text-slate-500 mt-4">
<p className="font-medium mb-1">Tastaturkuerzel:</p>
<p>Enter = Bestaetigen | S = Ueberspringen</p>
<p>Pfeiltasten = Navigation</p>
</div>
</div>
</div>
{/* Bottom: Queue Preview */}
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
<div className="flex gap-2 overflow-x-auto pb-2">
{queue.slice(0, 10).map((item, idx) => (
<button
key={item.id}
onClick={() => {
setCurrentIndex(idx)
setCurrentItem(item)
setCorrectedText(item.ocr_text || '')
setLabelStartTime(Date.now())
}}
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
idx === currentIndex
? 'border-primary-500'
: 'border-transparent hover:border-slate-300'
}`}
>
<img
src={item.image_url || `${API_BASE}${item.image_path}`}
alt=""
className="w-full h-full object-cover"
/>
</button>
))}
{queue.length > 10 && (
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
+{queue.length - 10} mehr
</div>
)}
</div>
</div>
</div>
)
// Render Sessions Tab
const renderSessionsTab = () => {
const [newSession, setNewSession] = useState<CreateSessionRequest>({
name: '',
source_type: 'klausur',
description: '',
ocr_model: 'llama3.2-vision:11b',
})
const createSession = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSession),
})
if (res.ok) {
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
fetchSessions()
} else {
setError('Session erstellen fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
}
}
return (
<div className="space-y-6">
{/* Create Session */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={newSession.name}
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
placeholder="z.B. Mathe Klausur Q1 2025"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newSession.source_type}
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="klausur">Klausur</option>
<option value="handwriting_sample">Handschriftprobe</option>
<option value="scan">Scan</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
<select
value={newSession.ocr_model}
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
<option value="donut">Donut - Document Understanding (strukturiert)</option>
</select>
<p className="mt-1 text-xs text-slate-500">
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<input
type="text"
value={newSession.description}
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
placeholder="Optional..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<button
onClick={createSession}
disabled={!newSession.name}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
Session erstellen
</button>
</div>
{/* Sessions List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
</div>
<div className="divide-y divide-slate-200">
{sessions.map((session) => (
<div
key={session.id}
className={`p-4 hover:bg-slate-50 cursor-pointer ${
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
}`}
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{session.name}</h4>
<p className="text-sm text-slate-500">
{session.source_type} | {session.ocr_model}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">
{session.labeled_items}/{session.total_items} gelabelt
</p>
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
<div
className="bg-primary-600 rounded-full h-2"
style={{
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
}}
/>
</div>
</div>
</div>
{session.description && (
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
)}
</div>
))}
{sessions.length === 0 && (
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
)}
</div>
</div>
</div>
)
}
// Render Upload Tab
const renderUploadTab = () => {
const [uploading, setUploading] = useState(false)
const [uploadResults, setUploadResults] = useState<any[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = async (files: FileList) => {
if (!selectedSession) {
setError('Bitte zuerst eine Session auswaehlen')
return
}
setUploading(true)
const formData = new FormData()
Array.from(files).forEach(file => formData.append('files', file))
formData.append('run_ocr', 'true')
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
method: 'POST',
body: formData,
})
if (res.ok) {
const data = await res.json()
setUploadResults(data.items || [])
fetchQueue()
fetchStats()
} else {
setError('Upload fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler beim Upload')
} finally {
setUploading(false)
}
}
return (
<div className="space-y-6">
{/* Session Selection */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
<select
value={selectedSession || ''}
onChange={(e) => setSelectedSession(e.target.value || null)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">-- Session waehlen --</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.total_items} Items)
</option>
))}
</select>
</div>
{/* Upload Area */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center ${
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
}`}
onDragOver={(e) => {
e.preventDefault()
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
}}
onDrop={(e) => {
e.preventDefault()
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
if (e.dataTransfer.files.length > 0) {
handleUpload(e.dataTransfer.files)
}
}}
>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/jpg"
onChange={(e) => e.target.files && handleUpload(e.target.files)}
className="hidden"
disabled={!selectedSession}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
<p>Hochladen & OCR ausfuehren...</p>
</div>
) : (
<>
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-slate-600 mb-2">
Bilder hierher ziehen oder{' '}
<button
onClick={() => fileInputRef.current?.click()}
disabled={!selectedSession}
className="text-primary-600 hover:underline"
>
auswaehlen
</button>
</p>
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
</>
)}
</div>
</div>
{/* Upload Results */}
{uploadResults.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
<div className="space-y-2">
{uploadResults.map((result) => (
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
<span className="text-sm">{result.filename}</span>
<span className={`text-xs px-2 py-1 rounded ${
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Render Stats Tab
const renderStatsTab = () => (
<div className="space-y-6">
{/* Global Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
</div>
</div>
{/* Detailed Stats */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Details</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-slate-500">Bestaetigt</p>
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Korrigiert</p>
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Exportierbar</p>
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
</div>
</div>
</div>
{/* Progress Bar */}
{stats?.total_items ? (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
<div className="w-full bg-slate-200 rounded-full h-4">
<div
className="bg-primary-600 rounded-full h-4 transition-all"
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
/>
</div>
<p className="text-sm text-slate-500 mt-2">
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
</p>
</div>
) : null}
</div>
)
// Render Export Tab
const renderExportTab = () => {
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<any>(null)
const handleExport = async () => {
setExporting(true)
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_format: exportFormat,
session_id: selectedSession,
}),
})
if (res.ok) {
const data = await res.json()
setExportResult(data)
} else {
setError('Export fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="generic">Generic JSON</option>
<option value="trocr">TrOCR Fine-Tuning</option>
<option value="llama_vision">Llama Vision Fine-Tuning</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
<select
value={selectedSession || ''}
onChange={(e) => setSelectedSession(e.target.value || null)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Sessions</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>{session.name}</option>
))}
</select>
</div>
<button
onClick={handleExport}
disabled={exporting || (stats?.exportable_items || 0) === 0}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
</button>
{/* Cross-Link to Magic Help for TrOCR Fine-Tuning */}
{exportFormat === 'trocr' && (stats?.exportable_items || 0) > 0 && (
<Link
href="/ai/magic-help?source=ocr-labeling"
className="w-full mt-3 px-4 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-lg hover:bg-purple-200 flex items-center justify-center gap-2 transition-colors"
>
<span></span>
Mit Magic Help testen & fine-tunen
</Link>
)}
</div>
</div>
{exportResult && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
<p className="text-green-800">
{exportResult.exported_count} Samples erfolgreich exportiert
</p>
<p className="text-sm text-green-600">
Batch: {exportResult.batch_id}
</p>
</div>
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
{(exportResult.samples?.length || 0) > 3 && (
<p className="text-slate-500 mt-2">... und {exportResult.samples.length - 3} weitere</p>
)}
</div>
</div>
)}
</div>
)
}
return (
<div className="p-6">
@@ -56,10 +939,10 @@ export default function OCRLabelingPage() {
<AIModuleSidebarResponsive currentModule="ocr-labeling" />
{/* Error Toast */}
{hook.error && (
{error && (
<div className="fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50">
<span>{hook.error}</span>
<button onClick={() => hook.setError(null)} className="ml-4">X</button>
<span>{error}</span>
<button onClick={() => setError(null)} className="ml-4">X</button>
</div>
)}
@@ -86,58 +969,17 @@ export default function OCRLabelingPage() {
</div>
{/* Tab Content */}
{hook.loading ? (
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
) : (
<>
{activeTab === 'labeling' && (
<LabelingTab
queue={hook.queue}
currentItem={hook.currentItem}
currentIndex={hook.currentIndex}
correctedText={hook.correctedText}
setCorrectedText={hook.setCorrectedText}
goToNext={hook.goToNext}
goToPrev={hook.goToPrev}
selectQueueItem={hook.selectQueueItem}
confirmItem={hook.confirmItem}
correctItem={hook.correctItem}
skipItem={hook.skipItem}
/>
)}
{activeTab === 'sessions' && (
<SessionsTab
sessions={hook.sessions}
selectedSession={hook.selectedSession}
setSelectedSession={hook.setSelectedSession}
fetchSessions={hook.fetchSessions}
setError={hook.setError}
/>
)}
{activeTab === 'upload' && (
<UploadTab
sessions={hook.sessions}
selectedSession={hook.selectedSession}
setSelectedSession={hook.setSelectedSession}
fetchQueue={hook.fetchQueue}
fetchStats={hook.fetchStats}
setError={hook.setError}
/>
)}
{activeTab === 'stats' && (
<StatsTab stats={hook.stats} />
)}
{activeTab === 'export' && (
<ExportTab
sessions={hook.sessions}
selectedSession={hook.selectedSession}
setSelectedSession={hook.setSelectedSession}
stats={hook.stats}
setError={hook.setError}
/>
)}
{activeTab === 'labeling' && renderLabelingTab()}
{activeTab === 'sessions' && renderSessionsTab()}
{activeTab === 'upload' && renderUploadTab()}
{activeTab === 'stats' && renderStatsTab()}
{activeTab === 'export' && renderExportTab()}
</>
)}
</div>
@@ -1,9 +1,5 @@
/**
* Shared TypeScript types for OCR Labeling UI.
*
* Single source of truth used by:
* - admin-lehrer (ai/ocr-labeling)
* - website (admin/ocr-labeling)
* TypeScript types for OCR Labeling UI
*/
/**
@@ -1,255 +0,0 @@
'use client'
/**
* Custom hook encapsulating all state and API logic for the OCR Labeling page.
*/
import { useState, useEffect, useCallback } from 'react'
import { API_BASE } from './constants'
import type { OCRSession, OCRItem, OCRStats } from './types'
export function useOcrLabeling() {
const [sessions, setSessions] = useState<OCRSession[]>([])
const [selectedSession, setSelectedSession] = useState<string | null>(null)
const [queue, setQueue] = useState<OCRItem[]>([])
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
const [currentIndex, setCurrentIndex] = useState(0)
const [stats, setStats] = useState<OCRStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [correctedText, setCorrectedText] = useState('')
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
// Fetch sessions
const fetchSessions = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
if (res.ok) {
const data = await res.json()
setSessions(data)
}
} catch (err) {
console.error('Failed to fetch sessions:', err)
}
}, [])
// Fetch queue
const fetchQueue = useCallback(async () => {
try {
const url = selectedSession
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setQueue(data)
if (data.length > 0 && !currentItem) {
setCurrentItem(data[0])
setCurrentIndex(0)
setCorrectedText(data[0].ocr_text || '')
setLabelStartTime(Date.now())
}
}
} catch (err) {
console.error('Failed to fetch queue:', err)
}
}, [selectedSession, currentItem])
// Fetch stats
const fetchStats = useCallback(async () => {
try {
const url = selectedSession
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
: `${API_BASE}/api/v1/ocr-label/stats`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error('Failed to fetch stats:', err)
}
}, [selectedSession])
// Initial data load
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
setLoading(false)
}
loadData()
}, [fetchSessions, fetchQueue, fetchStats])
// Refresh queue when session changes
useEffect(() => {
setCurrentItem(null)
setCurrentIndex(0)
fetchQueue()
fetchStats()
}, [selectedSession, fetchQueue, fetchStats])
// Navigate to next item
const goToNext = useCallback(() => {
if (currentIndex < queue.length - 1) {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
setCurrentItem(queue[nextIndex])
setCorrectedText(queue[nextIndex].ocr_text || '')
setLabelStartTime(Date.now())
} else {
fetchQueue()
}
}, [currentIndex, queue, fetchQueue])
// Navigate to previous item
const goToPrev = useCallback(() => {
if (currentIndex > 0) {
const prevIndex = currentIndex - 1
setCurrentIndex(prevIndex)
setCurrentItem(queue[prevIndex])
setCorrectedText(queue[prevIndex].ocr_text || '')
setLabelStartTime(Date.now())
}
}, [currentIndex, queue])
// Calculate label time
const getLabelTime = useCallback((): number | undefined => {
if (!labelStartTime) return undefined
return Math.round((Date.now() - labelStartTime) / 1000)
}, [labelStartTime])
// Select a queue item by index
const selectQueueItem = useCallback((idx: number) => {
if (idx >= 0 && idx < queue.length) {
setCurrentIndex(idx)
setCurrentItem(queue[idx])
setCorrectedText(queue[idx].ocr_text || '')
setLabelStartTime(Date.now())
}
}, [queue])
// Confirm item
const confirmItem = useCallback(async () => {
if (!currentItem) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: currentItem.id,
label_time_seconds: getLabelTime(),
}),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Bestaetigung fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}, [currentItem, getLabelTime, goToNext, fetchStats])
// Correct item
const correctItem = useCallback(async () => {
if (!currentItem || !correctedText.trim()) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: currentItem.id,
ground_truth: correctedText.trim(),
label_time_seconds: getLabelTime(),
}),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Korrektur fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}, [currentItem, correctedText, getLabelTime, goToNext, fetchStats])
// Skip item
const skipItem = useCallback(async () => {
if (!currentItem) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: currentItem.id }),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Ueberspringen fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}, [currentItem, goToNext, fetchStats])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLTextAreaElement) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
confirmItem()
} else if (e.key === 'ArrowRight') {
goToNext()
} else if (e.key === 'ArrowLeft') {
goToPrev()
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
skipItem()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [confirmItem, goToNext, goToPrev, skipItem])
return {
// State
sessions,
selectedSession,
setSelectedSession,
queue,
currentItem,
currentIndex,
stats,
loading,
error,
setError,
correctedText,
setCorrectedText,
// Actions
fetchSessions,
fetchQueue,
fetchStats,
goToNext,
goToPrev,
selectQueueItem,
confirmItem,
correctItem,
skipItem,
}
}
@@ -1,403 +0,0 @@
'use client'
/**
* OCR Regression Dashboard
*
* Shows all ground-truth sessions, runs regression tests,
* displays pass/fail results with diff details, and shows history.
*/
import { useState, useEffect, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
const KLAUSUR_API = '/klausur-api'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface GTSession {
session_id: string
name: string
filename: string
document_category: string | null
pipeline: string | null
saved_at: string | null
summary: {
total_zones: number
total_columns: number
total_rows: number
total_cells: number
}
}
interface DiffSummary {
structural_changes: number
cells_missing: number
cells_added: number
text_changes: number
col_type_changes: number
}
interface RegressionResult {
session_id: string
name: string
status: 'pass' | 'fail' | 'error'
error?: string
diff_summary?: DiffSummary
reference_summary?: Record<string, number>
current_summary?: Record<string, number>
structural_diffs?: Array<{ field: string; reference: number; current: number }>
cell_diffs?: Array<{ type: string; cell_id: string; reference?: string; current?: string }>
}
interface RegressionRun {
id: string
run_at: string
status: string
total: number
passed: number
failed: number
errors: number
duration_ms: number
triggered_by: string
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'pass'
? 'bg-emerald-100 text-emerald-800 border-emerald-200'
: status === 'fail'
? 'bg-red-100 text-red-800 border-red-200'
: 'bg-amber-100 text-amber-800 border-amber-200'
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${cls}`}>
{status === 'pass' ? 'Pass' : status === 'fail' ? 'Fail' : 'Error'}
</span>
)
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function OCRRegressionPage() {
const [sessions, setSessions] = useState<GTSession[]>([])
const [results, setResults] = useState<RegressionResult[]>([])
const [history, setHistory] = useState<RegressionRun[]>([])
const [running, setRunning] = useState(false)
const [overallStatus, setOverallStatus] = useState<string | null>(null)
const [durationMs, setDurationMs] = useState<number | null>(null)
const [expandedSession, setExpandedSession] = useState<string | null>(null)
const [tab, setTab] = useState<'current' | 'history'>('current')
// Load ground-truth sessions
const loadSessions = useCallback(async () => {
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/ground-truth-sessions`)
if (res.ok) {
const data = await res.json()
setSessions(data.sessions || [])
}
} catch (e) {
console.error('Failed to load GT sessions:', e)
}
}, [])
// Load history
const loadHistory = useCallback(async () => {
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/regression/history?limit=20`)
if (res.ok) {
const data = await res.json()
setHistory(data.runs || [])
}
} catch (e) {
console.error('Failed to load history:', e)
}
}, [])
useEffect(() => {
loadSessions()
loadHistory()
}, [loadSessions, loadHistory])
// Run all regressions
const runAll = async () => {
setRunning(true)
setResults([])
setOverallStatus(null)
setDurationMs(null)
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/regression/run?triggered_by=manual`, {
method: 'POST',
})
if (res.ok) {
const data = await res.json()
setResults(data.results || [])
setOverallStatus(data.status)
setDurationMs(data.duration_ms)
loadHistory()
}
} catch (e) {
console.error('Regression run failed:', e)
setOverallStatus('error')
} finally {
setRunning(false)
}
}
const totalPass = results.filter(r => r.status === 'pass').length
const totalFail = results.filter(r => r.status === 'fail').length
const totalError = results.filter(r => r.status === 'error').length
return (
<div className="space-y-6">
<div className="max-w-7xl mx-auto p-6 space-y-6">
<PagePurpose
title="OCR Regression Tests"
purpose="Automatische Regressions-Tests fuer die OCR-Pipeline: Ground-Truth Sessions neu auswerten und gegen Referenz-Ergebnisse vergleichen."
audience={['Entwickler', 'QA']}
defaultCollapsed
architecture={{
services: ['klausur-service (FastAPI, Port 8086)'],
databases: ['PostgreSQL (regression_runs, ocr_pipeline_sessions)'],
}}
relatedPages={[
{ name: 'OCR Pipeline', href: '/ai/ocr-pipeline', description: 'OCR-Pipeline ausfuehren' },
{ name: 'Ground Truth Review', href: '/ai/ocr-ground-truth', description: 'Sessions pruefen & markieren' },
]}
/>
{/* Header + Run Button */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">OCR Regression Tests</h1>
<p className="text-sm text-slate-500 mt-1">
{sessions.length} Ground-Truth Session{sessions.length !== 1 ? 's' : ''}
</p>
</div>
<button
onClick={runAll}
disabled={running || sessions.length === 0}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
>
{running ? (
<>
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Laeuft...
</>
) : (
'Alle Tests starten'
)}
</button>
</div>
{/* Overall Result Banner */}
{overallStatus && (
<div className={`rounded-lg p-4 border ${
overallStatus === 'pass'
? 'bg-emerald-50 border-emerald-200'
: 'bg-red-50 border-red-200'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusBadge status={overallStatus} />
<span className="font-medium text-slate-900">
{totalPass} bestanden, {totalFail} fehlgeschlagen, {totalError} Fehler
</span>
</div>
{durationMs !== null && (
<span className="text-sm text-slate-500">{(durationMs / 1000).toFixed(1)}s</span>
)}
</div>
</div>
)}
{/* Tabs */}
<div className="border-b border-slate-200">
<nav className="flex gap-4">
{(['current', 'history'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
tab === t
? 'border-teal-500 text-teal-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{t === 'current' ? 'Aktuelle Ergebnisse' : 'Verlauf'}
</button>
))}
</nav>
</div>
{/* Current Results Tab */}
{tab === 'current' && (
<div className="space-y-3">
{results.length === 0 && !running && (
<div className="text-center py-12 text-slate-400">
<p className="text-lg">Keine Ergebnisse</p>
<p className="text-sm mt-1">Klicken Sie &quot;Alle Tests starten&quot; um die Regression zu laufen.</p>
</div>
)}
{results.map(r => (
<div
key={r.session_id}
className="bg-white rounded-lg border border-slate-200 overflow-hidden"
>
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => setExpandedSession(expandedSession === r.session_id ? null : r.session_id)}
>
<div className="flex items-center gap-3 min-w-0">
<StatusBadge status={r.status} />
<span className="font-medium text-slate-900 truncate">{r.name || r.session_id}</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-500">
{r.diff_summary && (
<span>
{r.diff_summary.text_changes} Text, {r.diff_summary.structural_changes} Struktur
</span>
)}
{r.error && <span className="text-red-500">{r.error}</span>}
<svg className={`w-4 h-4 transition-transform ${expandedSession === r.session_id ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Expanded Details */}
{expandedSession === r.session_id && r.status === 'fail' && (
<div className="border-t border-slate-100 px-4 py-3 bg-slate-50 space-y-3">
{/* Structural Diffs */}
{r.structural_diffs && r.structural_diffs.length > 0 && (
<div>
<h4 className="text-xs font-medium text-slate-500 uppercase mb-1">Strukturelle Aenderungen</h4>
<div className="space-y-1">
{r.structural_diffs.map((d, i) => (
<div key={i} className="text-sm">
<span className="font-mono text-slate-600">{d.field}</span>: {d.reference} {d.current}
</div>
))}
</div>
</div>
)}
{/* Cell Diffs */}
{r.cell_diffs && r.cell_diffs.length > 0 && (
<div>
<h4 className="text-xs font-medium text-slate-500 uppercase mb-1">
Zellen-Aenderungen ({r.cell_diffs.length})
</h4>
<div className="max-h-60 overflow-y-auto space-y-1">
{r.cell_diffs.slice(0, 50).map((d, i) => (
<div key={i} className="text-sm font-mono bg-white rounded px-2 py-1 border border-slate-100">
<span className={`text-xs px-1 rounded ${
d.type === 'text_change' ? 'bg-amber-100 text-amber-700'
: d.type === 'cell_missing' ? 'bg-red-100 text-red-700'
: 'bg-blue-100 text-blue-700'
}`}>
{d.type}
</span>{' '}
<span className="text-slate-500">{d.cell_id}</span>
{d.reference && (
<>
{' '}<span className="line-through text-red-400">{d.reference}</span>
</>
)}
{d.current && (
<>
{' '}<span className="text-emerald-600">{d.current}</span>
</>
)}
</div>
))}
{r.cell_diffs.length > 50 && (
<p className="text-xs text-slate-400">... und {r.cell_diffs.length - 50} weitere</p>
)}
</div>
</div>
)}
</div>
)}
</div>
))}
{/* Ground Truth Sessions Overview (when no results yet) */}
{results.length === 0 && sessions.length > 0 && (
<div>
<h3 className="text-sm font-medium text-slate-700 mb-2">Ground-Truth Sessions</h3>
<div className="grid gap-2">
{sessions.map(s => (
<div key={s.session_id} className="bg-white rounded-lg border border-slate-200 px-4 py-3 flex items-center justify-between">
<div>
<span className="font-medium text-slate-900">{s.name || s.session_id}</span>
<span className="text-sm text-slate-400 ml-2">{s.filename}</span>
</div>
<div className="text-sm text-slate-500">
{s.summary.total_cells} Zellen, {s.summary.total_zones} Zonen
{s.pipeline && <span className="ml-2 text-xs bg-slate-100 px-1.5 py-0.5 rounded">{s.pipeline}</span>}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* History Tab */}
{tab === 'history' && (
<div className="space-y-2">
{history.length === 0 ? (
<p className="text-center py-8 text-slate-400">Noch keine Laeufe aufgezeichnet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-slate-500">
<th className="pb-2 font-medium">Datum</th>
<th className="pb-2 font-medium">Status</th>
<th className="pb-2 font-medium text-right">Gesamt</th>
<th className="pb-2 font-medium text-right">Pass</th>
<th className="pb-2 font-medium text-right">Fail</th>
<th className="pb-2 font-medium text-right">Dauer</th>
<th className="pb-2 font-medium">Trigger</th>
</tr>
</thead>
<tbody>
{history.map(run => (
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-2">{formatDate(run.run_at)}</td>
<td className="py-2"><StatusBadge status={run.status} /></td>
<td className="py-2 text-right">{run.total}</td>
<td className="py-2 text-right text-emerald-600">{run.passed}</td>
<td className="py-2 text-right text-red-600">{run.failed + run.errors}</td>
<td className="py-2 text-right text-slate-500">{(run.duration_ms / 1000).toFixed(1)}s</td>
<td className="py-2 text-slate-400">{run.triggered_by}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
)
}
@@ -1,74 +0,0 @@
import type { ChunkDetail } from './types'
interface ResultsListProps {
results: ChunkDetail[]
selectedChunk: ChunkDetail | null
searchQuery: string
onSelect: (chunk: ChunkDetail) => void
}
function highlightText(text: string, query: string) {
if (!query) return text
const words = query.toLowerCase().split(' ').filter(w => w.length > 2)
let result = text
words.forEach(word => {
const regex = new RegExp(`(${word})`, 'gi')
result = result.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-0.5 rounded">$1</mark>')
})
return result
}
export function ResultsList({ results, selectedChunk, searchQuery, onSelect }: ResultsListProps) {
return (
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Gefundene Chunks ({results.length})
</h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{results.map((result, idx) => (
<div
key={idx}
onClick={() => onSelect(result)}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedChunk?.text === result.text
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{result.regulation_code}
</span>
{result.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {result.article}
{result.paragraph && ` Abs. ${result.paragraph}`}
</span>
)}
</div>
<span className="text-xs text-gray-400">
Score: {(result.score || 0).toFixed(3)}
</span>
</div>
{/* Text Preview */}
<p
className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4"
dangerouslySetInnerHTML={{
__html: highlightText(result.text.substring(0, 400) + (result.text.length > 400 ? '...' : ''), searchQuery)
}}
/>
{/* Metadata */}
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
<span>Chunk #{result.chunk_index || idx}</span>
<span>{result.text.length} Zeichen</span>
</div>
</div>
))}
</div>
</div>
)
}
@@ -1,112 +0,0 @@
import { REGULATIONS, SAMPLE_QUERIES } from './types'
interface SearchSectionProps {
searchQuery: string
selectedRegulation: string
topK: number
searching: boolean
onSearchQueryChange: (v: string) => void
onRegulationChange: (v: string) => void
onTopKChange: (v: number) => void
onSearch: () => void
onSampleQuery: (query: string, reg: string) => void
}
export function SearchSection({
searchQuery,
selectedRegulation,
topK,
searching,
onSearchQueryChange,
onRegulationChange,
onTopKChange,
onSearch,
onSampleQuery,
}: SearchSectionProps) {
return (
<>
{/* Quick Sample Queries */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Schnell-Stichproben
</h3>
<div className="flex flex-wrap gap-2">
{SAMPLE_QUERIES.map((sq, idx) => (
<button
key={idx}
onClick={() => onSampleQuery(sq.query, sq.reg)}
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 rounded-full transition-colors"
>
{sq.label}
</button>
))}
</div>
</div>
{/* Search Section */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Chunk-Suche
</h2>
<div className="space-y-4">
{/* Search Input */}
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Suchbegriff / Paragraph / Artikeltext
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearch()}
placeholder="z.B. 'Recht auf Löschung' oder 'Art. 17 Abs. 1'"
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Regulierung
</label>
<select
value={selectedRegulation}
onChange={(e) => onRegulationChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="">Alle</option>
{REGULATIONS.map((reg) => (
<option key={reg.code} value={reg.code}>
{reg.name}
</option>
))}
</select>
</div>
<div className="w-24">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Anzahl
</label>
<select
value={topK}
onChange={(e) => onTopKChange(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
</div>
<button
onClick={onSearch}
disabled={searching || !searchQuery.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{searching ? 'Suche laeuft...' : 'Suchen'}
</button>
</div>
</div>
</>
)
}
@@ -1,150 +0,0 @@
import type { ChunkDetail, TraceabilityResult } from './types'
interface TraceabilityPanelProps {
selectedChunk: ChunkDetail | null
loadingTrace: boolean
traceability: TraceabilityResult | null
}
export function TraceabilityPanel({ selectedChunk, loadingTrace, traceability }: TraceabilityPanelProps) {
return (
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Traceability
</h3>
{!selectedChunk ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>Waehlen Sie einen Chunk aus der Liste, um die Traceability zu sehen.</p>
</div>
) : loadingTrace ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Lade Traceability...</p>
</div>
) : traceability ? (
<div className="space-y-6">
{/* Selected Chunk Detail */}
<ChunkDetailSection chunk={traceability.chunk} />
<ArrowDown />
{/* Requirements */}
<RequirementsSection requirements={traceability.requirements} />
<ArrowDown />
{/* Controls */}
<ControlsSection controls={traceability.controls} />
</div>
) : null}
</div>
)
}
function ChunkDetailSection({ chunk }: { chunk: ChunkDetail }) {
return (
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Ausgewaehlter Chunk
</h4>
<div className="bg-gray-50 dark:bg-slate-700 rounded p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{chunk.regulation_code}
</span>
{chunk.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {chunk.article}
{chunk.paragraph && ` Abs. ${chunk.paragraph}`}
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
{chunk.text}
</p>
{chunk.source_url && (
<a
href={chunk.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
>
Quelle oeffnen
</a>
)}
</div>
</div>
)
}
function RequirementsSection({ requirements }: { requirements: TraceabilityResult['requirements'] }) {
return (
<div className="border-l-4 border-orange-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Extrahierte Anforderungen ({requirements.length})
</h4>
{requirements.length > 0 ? (
<div className="space-y-2">
{requirements.map((req, idx) => (
<div key={idx} className="bg-orange-50 dark:bg-orange-900/20 rounded p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
{req.category || 'Anforderung'}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{req.text}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Anforderungen aus diesem Chunk extrahiert.
<br />
<span className="text-xs">(Requirements-Extraktion ist noch nicht implementiert)</span>
</p>
)}
</div>
)
}
function ControlsSection({ controls }: { controls: TraceabilityResult['controls'] }) {
return (
<div className="border-l-4 border-green-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Abgeleitete Controls ({controls.length})
</h4>
{controls.length > 0 ? (
<div className="space-y-2">
{controls.map((ctrl, idx) => (
<div key={idx} className="bg-green-50 dark:bg-green-900/20 rounded p-3">
<div className="font-medium text-sm text-green-700 dark:text-green-400 mb-1">
{ctrl.name}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{ctrl.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Controls aus diesem Chunk abgeleitet.
<br />
<span className="text-xs">(Control-Ableitung ist noch nicht implementiert)</span>
</p>
)}
</div>
)
}
function ArrowDown() {
return (
<div className="flex justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
)
}
@@ -1,66 +0,0 @@
export interface ChunkDetail {
id: string
text: string
regulation_code: string
regulation_name: string
article: string | null
paragraph: string | null
chunk_index: number
chunk_position: 'beginning' | 'middle' | 'end'
source_url: string
score?: number
}
export interface Requirement {
id: string
text: string
category: string
source_chunk_id: string
regulation_code: string
}
export interface Control {
id: string
name: string
description: string
source_requirement_ids: string[]
regulation_codes: string[]
}
export interface TraceabilityResult {
chunk: ChunkDetail
requirements: Requirement[]
controls: Control[]
}
export const API_PROXY = '/api/legal-corpus'
export const REGULATIONS = [
{ code: 'GDPR', name: 'DSGVO' },
{ code: 'EPRIVACY', name: 'ePrivacy' },
{ code: 'TDDDG', name: 'TDDDG' },
{ code: 'SCC', name: 'Standardvertragsklauseln' },
{ code: 'DPF', name: 'EU-US DPF' },
{ code: 'AIACT', name: 'EU AI Act' },
{ code: 'CRA', name: 'Cyber Resilience Act' },
{ code: 'NIS2', name: 'NIS2' },
{ code: 'EUCSA', name: 'EU Cybersecurity Act' },
{ code: 'DATAACT', name: 'Data Act' },
{ code: 'DGA', name: 'Data Governance Act' },
{ code: 'DSA', name: 'Digital Services Act' },
{ code: 'EAA', name: 'Accessibility Act' },
{ code: 'DSM', name: 'DSM-Urheberrecht' },
{ code: 'PLD', name: 'Produkthaftung' },
{ code: 'GPSR', name: 'Product Safety' },
{ code: 'BSI-TR-03161-1', name: 'BSI-TR Teil 1' },
{ code: 'BSI-TR-03161-2', name: 'BSI-TR Teil 2' },
{ code: 'BSI-TR-03161-3', name: 'BSI-TR Teil 3' },
]
export const SAMPLE_QUERIES = [
{ label: 'Art. 17 DSGVO (Recht auf Loeschung)', query: 'Recht auf Löschung Artikel 17', reg: 'GDPR' },
{ label: 'Einwilligung TDDDG', query: 'Einwilligung Endeinrichtung speichern', reg: 'TDDDG' },
{ label: 'AI Act Hochrisiko', query: 'Hochrisiko-KI-System Anforderungen', reg: 'AIACT' },
{ label: 'NIS2 Sicherheitsmaßnahmen', query: 'Cybersicherheitsrisikomanagement Maßnahmen', reg: 'NIS2' },
{ label: 'BSI Authentifizierung', query: 'Authentifizierung Zwei-Faktor mobile', reg: 'BSI-TR-03161-1' },
]
@@ -1,93 +0,0 @@
import { useState, useCallback } from 'react'
import type { ChunkDetail, TraceabilityResult } from './types'
import { API_PROXY } from './types'
export function useQualitySearch() {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ChunkDetail[]>([])
const [searching, setSearching] = useState(false)
const [selectedRegulation, setSelectedRegulation] = useState<string>('')
const [topK, setTopK] = useState(10)
const [selectedChunk, setSelectedChunk] = useState<ChunkDetail | null>(null)
const [traceability, setTraceability] = useState<TraceabilityResult | null>(null)
const [loadingTrace, setLoadingTrace] = useState(false)
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) return
setSearching(true)
setSearchResults([])
setSelectedChunk(null)
setTraceability(null)
try {
let url = `${API_PROXY}?action=search&query=${encodeURIComponent(searchQuery)}&top_k=${topK}`
if (selectedRegulation) {
url += `&regulations=${encodeURIComponent(selectedRegulation)}`
}
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setSearchResults(data.results || [])
}
} catch (error) {
console.error('Search failed:', error)
} finally {
setSearching(false)
}
}, [searchQuery, selectedRegulation, topK])
const loadTraceability = useCallback(async (chunk: ChunkDetail) => {
setSelectedChunk(chunk)
setLoadingTrace(true)
try {
const res = await fetch(
`${API_PROXY}?action=traceability&chunk_id=${encodeURIComponent(chunk.id || chunk.regulation_code + '_' + chunk.chunk_index)}&regulation=${encodeURIComponent(chunk.regulation_code)}`
)
if (res.ok) {
const data = await res.json()
setTraceability({
chunk,
requirements: data.requirements || [],
controls: data.controls || [],
})
} else {
setTraceability({ chunk, requirements: [], controls: [] })
}
} catch (error) {
console.error('Failed to load traceability:', error)
setTraceability({ chunk, requirements: [], controls: [] })
} finally {
setLoadingTrace(false)
}
}, [])
const handleSampleQuery = (query: string, reg: string) => {
setSearchQuery(query)
setSelectedRegulation(reg)
setTimeout(() => {
handleSearch()
}, 100)
}
return {
searchQuery,
setSearchQuery,
searchResults,
searching,
selectedRegulation,
setSelectedRegulation,
topK,
setTopK,
selectedChunk,
traceability,
loadingTrace,
handleSearch,
loadTraceability,
handleSampleQuery,
}
}
+428 -45
View File
@@ -5,34 +5,184 @@
*
* Ermoeglicht Auditoren:
* - Chunk-Suche und Stichproben
* - Traceability: Chunk -> Requirement -> Control
* - Traceability: Chunk Requirement Control
* - Dokumenten-Vollstaendigkeitspruefung
*/
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { useQualitySearch } from './_components/useQualitySearch'
import { SearchSection } from './_components/SearchSection'
import { ResultsList } from './_components/ResultsList'
import { TraceabilityPanel } from './_components/TraceabilityPanel'
const API_PROXY = '/api/legal-corpus'
// Types
interface ChunkDetail {
id: string
text: string
regulation_code: string
regulation_name: string
article: string | null
paragraph: string | null
chunk_index: number
chunk_position: 'beginning' | 'middle' | 'end'
source_url: string
score?: number
}
interface Requirement {
id: string
text: string
category: string
source_chunk_id: string
regulation_code: string
}
interface Control {
id: string
name: string
description: string
source_requirement_ids: string[]
regulation_codes: string[]
}
interface TraceabilityResult {
chunk: ChunkDetail
requirements: Requirement[]
controls: Control[]
}
// Regulations for filtering
const REGULATIONS = [
{ code: 'GDPR', name: 'DSGVO' },
{ code: 'EPRIVACY', name: 'ePrivacy' },
{ code: 'TDDDG', name: 'TDDDG' },
{ code: 'SCC', name: 'Standardvertragsklauseln' },
{ code: 'DPF', name: 'EU-US DPF' },
{ code: 'AIACT', name: 'EU AI Act' },
{ code: 'CRA', name: 'Cyber Resilience Act' },
{ code: 'NIS2', name: 'NIS2' },
{ code: 'EUCSA', name: 'EU Cybersecurity Act' },
{ code: 'DATAACT', name: 'Data Act' },
{ code: 'DGA', name: 'Data Governance Act' },
{ code: 'DSA', name: 'Digital Services Act' },
{ code: 'EAA', name: 'Accessibility Act' },
{ code: 'DSM', name: 'DSM-Urheberrecht' },
{ code: 'PLD', name: 'Produkthaftung' },
{ code: 'GPSR', name: 'Product Safety' },
{ code: 'BSI-TR-03161-1', name: 'BSI-TR Teil 1' },
{ code: 'BSI-TR-03161-2', name: 'BSI-TR Teil 2' },
{ code: 'BSI-TR-03161-3', name: 'BSI-TR Teil 3' },
]
const TYPE_COLORS: Record<string, string> = {
eu_regulation: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
eu_directive: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
de_law: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
bsi_standard: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
export default function QualityPage() {
const {
searchQuery,
setSearchQuery,
searchResults,
searching,
selectedRegulation,
setSelectedRegulation,
topK,
setTopK,
selectedChunk,
traceability,
loadingTrace,
handleSearch,
loadTraceability,
handleSampleQuery,
} = useQualitySearch()
// Search state
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ChunkDetail[]>([])
const [searching, setSearching] = useState(false)
const [selectedRegulation, setSelectedRegulation] = useState<string>('')
const [topK, setTopK] = useState(10)
// Traceability state
const [selectedChunk, setSelectedChunk] = useState<ChunkDetail | null>(null)
const [traceability, setTraceability] = useState<TraceabilityResult | null>(null)
const [loadingTrace, setLoadingTrace] = useState(false)
// Quick sample queries for auditors
const sampleQueries = [
{ label: 'Art. 17 DSGVO (Recht auf Loeschung)', query: 'Recht auf Löschung Artikel 17', reg: 'GDPR' },
{ label: 'Einwilligung TDDDG', query: 'Einwilligung Endeinrichtung speichern', reg: 'TDDDG' },
{ label: 'AI Act Hochrisiko', query: 'Hochrisiko-KI-System Anforderungen', reg: 'AIACT' },
{ label: 'NIS2 Sicherheitsmaßnahmen', query: 'Cybersicherheitsrisikomanagement Maßnahmen', reg: 'NIS2' },
{ label: 'BSI Authentifizierung', query: 'Authentifizierung Zwei-Faktor mobile', reg: 'BSI-TR-03161-1' },
]
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) return
setSearching(true)
setSearchResults([])
setSelectedChunk(null)
setTraceability(null)
try {
let url = `${API_PROXY}?action=search&query=${encodeURIComponent(searchQuery)}&top_k=${topK}`
if (selectedRegulation) {
url += `&regulations=${encodeURIComponent(selectedRegulation)}`
}
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setSearchResults(data.results || [])
}
} catch (error) {
console.error('Search failed:', error)
} finally {
setSearching(false)
}
}, [searchQuery, selectedRegulation, topK])
const loadTraceability = useCallback(async (chunk: ChunkDetail) => {
setSelectedChunk(chunk)
setLoadingTrace(true)
try {
// Try to load traceability (requirements and controls derived from this chunk)
const res = await fetch(`${API_PROXY}?action=traceability&chunk_id=${encodeURIComponent(chunk.id || chunk.regulation_code + '_' + chunk.chunk_index)}&regulation=${encodeURIComponent(chunk.regulation_code)}`)
if (res.ok) {
const data = await res.json()
setTraceability({
chunk,
requirements: data.requirements || [],
controls: data.controls || [],
})
} else {
// If traceability endpoint doesn't exist yet, show placeholder
setTraceability({
chunk,
requirements: [],
controls: [],
})
}
} catch (error) {
console.error('Failed to load traceability:', error)
setTraceability({
chunk,
requirements: [],
controls: [],
})
} finally {
setLoadingTrace(false)
}
}, [])
const handleSampleQuery = (query: string, reg: string) => {
setSearchQuery(query)
setSelectedRegulation(reg)
// Auto-search after setting
setTimeout(() => {
handleSearch()
}, 100)
}
const highlightText = (text: string, query: string) => {
if (!query) return text
const words = query.toLowerCase().split(' ').filter(w => w.length > 2)
let result = text
words.forEach(word => {
const regex = new RegExp(`(${word})`, 'gi')
result = result.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-0.5 rounded">$1</mark>')
})
return result
}
return (
<div className="space-y-6">
@@ -64,32 +214,265 @@ export default function QualityPage() {
}}
/>
<SearchSection
searchQuery={searchQuery}
selectedRegulation={selectedRegulation}
topK={topK}
searching={searching}
onSearchQueryChange={setSearchQuery}
onRegulationChange={setSelectedRegulation}
onTopKChange={setTopK}
onSearch={handleSearch}
onSampleQuery={handleSampleQuery}
/>
{/* Quick Sample Queries */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Schnell-Stichproben
</h3>
<div className="flex flex-wrap gap-2">
{sampleQueries.map((sq, idx) => (
<button
key={idx}
onClick={() => handleSampleQuery(sq.query, sq.reg)}
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 rounded-full transition-colors"
>
{sq.label}
</button>
))}
</div>
</div>
{/* Search Section */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Chunk-Suche
</h2>
<div className="space-y-4">
{/* Search Input */}
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Suchbegriff / Paragraph / Artikeltext
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="z.B. 'Recht auf Löschung' oder 'Art. 17 Abs. 1'"
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Regulierung
</label>
<select
value={selectedRegulation}
onChange={(e) => setSelectedRegulation(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="">Alle</option>
{REGULATIONS.map((reg) => (
<option key={reg.code} value={reg.code}>
{reg.name}
</option>
))}
</select>
</div>
<div className="w-24">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Anzahl
</label>
<select
value={topK}
onChange={(e) => setTopK(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
</div>
<button
onClick={handleSearch}
disabled={searching || !searchQuery.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{searching ? 'Suche laeuft...' : 'Suchen'}
</button>
</div>
</div>
{/* Results Grid */}
{searchResults.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ResultsList
results={searchResults}
selectedChunk={selectedChunk}
searchQuery={searchQuery}
onSelect={loadTraceability}
/>
<TraceabilityPanel
selectedChunk={selectedChunk}
loadingTrace={loadingTrace}
traceability={traceability}
/>
{/* Search Results List */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Gefundene Chunks ({searchResults.length})
</h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{searchResults.map((result, idx) => (
<div
key={idx}
onClick={() => loadTraceability(result)}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedChunk?.text === result.text
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{result.regulation_code}
</span>
{result.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {result.article}
{result.paragraph && ` Abs. ${result.paragraph}`}
</span>
)}
</div>
<span className="text-xs text-gray-400">
Score: {(result.score || 0).toFixed(3)}
</span>
</div>
{/* Text Preview */}
<p
className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4"
dangerouslySetInnerHTML={{
__html: highlightText(result.text.substring(0, 400) + (result.text.length > 400 ? '...' : ''), searchQuery)
}}
/>
{/* Metadata */}
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
<span>Chunk #{result.chunk_index || idx}</span>
<span>{result.text.length} Zeichen</span>
</div>
</div>
))}
</div>
</div>
{/* Traceability Panel */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Traceability
</h3>
{!selectedChunk ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>Waehlen Sie einen Chunk aus der Liste, um die Traceability zu sehen.</p>
</div>
) : loadingTrace ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Lade Traceability...</p>
</div>
) : traceability ? (
<div className="space-y-6">
{/* Selected Chunk Detail */}
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
📄 Ausgewaehlter Chunk
</h4>
<div className="bg-gray-50 dark:bg-slate-700 rounded p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{traceability.chunk.regulation_code}
</span>
{traceability.chunk.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {traceability.chunk.article}
{traceability.chunk.paragraph && ` Abs. ${traceability.chunk.paragraph}`}
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
{traceability.chunk.text}
</p>
{traceability.chunk.source_url && (
<a
href={traceability.chunk.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
>
🔗 Quelle oeffnen
</a>
)}
</div>
</div>
{/* Arrow Down */}
<div className="flex justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
{/* Requirements */}
<div className="border-l-4 border-orange-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
📋 Extrahierte Anforderungen ({traceability.requirements.length})
</h4>
{traceability.requirements.length > 0 ? (
<div className="space-y-2">
{traceability.requirements.map((req, idx) => (
<div key={idx} className="bg-orange-50 dark:bg-orange-900/20 rounded p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
{req.category || 'Anforderung'}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{req.text}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Anforderungen aus diesem Chunk extrahiert.
<br />
<span className="text-xs">(Requirements-Extraktion ist noch nicht implementiert)</span>
</p>
)}
</div>
{/* Arrow Down */}
<div className="flex justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
{/* Controls */}
<div className="border-l-4 border-green-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Abgeleitete Controls ({traceability.controls.length})
</h4>
{traceability.controls.length > 0 ? (
<div className="space-y-2">
{traceability.controls.map((ctrl, idx) => (
<div key={idx} className="bg-green-50 dark:bg-green-900/20 rounded p-3">
<div className="font-medium text-sm text-green-700 dark:text-green-400 mb-1">
{ctrl.name}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{ctrl.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Controls aus diesem Chunk abgeleitet.
<br />
<span className="text-xs">(Control-Ableitung ist noch nicht implementiert)</span>
</p>
)}
</div>
</div>
) : null}
</div>
</div>
)}
@@ -127,13 +510,13 @@ export default function QualityPage() {
{/* Audit Info */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-400 mb-2">
Hinweise fuer Auditoren
Hinweise fuer Auditoren
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1 list-disc list-inside">
<li>Die Suche ist semantisch - aehnliche Begriffe werden gefunden, auch wenn die exakte Formulierung abweicht</li>
<li>Jeder Chunk entspricht einem logischen Textabschnitt aus dem Originaldokument</li>
<li>Die Traceability zeigt, wie aus dem Originaltext Anforderungen und Controls abgeleitet wurden</li>
<li>Klicken Sie auf &quot;Quelle oeffnen&quot;, um das Originaldokument zu pruefen</li>
<li>Klicken Sie auf "Quelle oeffnen", um das Originaldokument zu pruefen</li>
</ul>
</div>
</div>
@@ -1,212 +0,0 @@
'use client'
export function ArchitectureTab() {
return (
<div className="space-y-8">
{/* What is this module */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Was macht dieses Modul?
</h2>
<div className="prose dark:prose-invert max-w-none">
<p className="text-gray-600 dark:text-gray-400">
Das <strong>RAG-Indexierungs-Modul</strong> verarbeitet Dokumente und macht sie fuer die KI-gestuetzte Suche verfuegbar.
Es handelt sich <strong>nicht</strong> um klassisches Machine-Learning-Training, sondern um:
</p>
<ul className="mt-4 space-y-2 text-gray-600 dark:text-gray-400">
<li className="flex items-start gap-2">
<span className="text-blue-500 mt-1">1.</span>
<span><strong>Dokumentenextraktion:</strong> PDFs und Bilder werden per OCR in Text umgewandelt</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500 mt-1">2.</span>
<span><strong>Chunking:</strong> Lange Texte werden in suchbare Abschnitte (1000 Zeichen) aufgeteilt</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500 mt-1">3.</span>
<span><strong>Embedding:</strong> Jeder Chunk wird in einen Vektor (1536 Dimensionen) umgewandelt</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500 mt-1">4.</span>
<span><strong>Indexierung:</strong> Vektoren werden in Qdrant gespeichert fuer semantische Suche</span>
</li>
</ul>
</div>
</div>
{/* Architecture Diagram */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
Technische Architektur
</h2>
{/* Visual Pipeline */}
<div className="relative">
{/* Data Sources Row */}
<div className="grid grid-cols-4 gap-4 mb-8">
<SourceCard icon="📄" title="NiBiS PDFs" subtitle="Erwartungshorizonte" color="blue" />
<SourceCard icon="📤" title="Uploads" subtitle="Eigene EH" color="green" />
<SourceCard icon="⚖️" title="Rechtskorpus" subtitle="DSGVO, AI Act" color="purple" />
<SourceCard icon="📚" title="Schulordnungen" subtitle="Bundeslaender" color="orange" />
</div>
<ArrowDown />
{/* Processing Layer */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-6 mb-8">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-4">
Verarbeitungs-Pipeline
</h3>
<div className="flex items-center justify-between gap-4">
<PipelineStep icon="🔍" title="OCR" subtitle="Text-Extraktion" />
<ArrowRight />
<PipelineStep icon="✂️" title="Chunking" subtitle="1000 Zeichen" />
<ArrowRight />
<PipelineStep icon="🧮" title="Embedding" subtitle="1536-dim Vektor" />
<ArrowRight />
<PipelineStep icon="💾" title="Speichern" subtitle="Qdrant" />
</div>
</div>
<ArrowDown />
{/* Storage Layer */}
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 rounded-xl p-6 mb-8 border-2 border-indigo-200 dark:border-indigo-800">
<h3 className="text-sm font-semibold text-indigo-600 dark:text-indigo-400 uppercase tracking-wide mb-4">
Vektor-Datenbank (Qdrant)
</h3>
<div className="grid grid-cols-3 gap-4">
<CollectionCard collection="bp_nibis_eh" label="Offizielle EH" />
<CollectionCard collection="bp_eh" label="Benutzer EH" />
<CollectionCard collection="bp_legal_corpus" label="Rechtskorpus" />
</div>
</div>
<ArrowDown />
{/* Usage Layer */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border-2 border-emerald-200 dark:border-emerald-800">
<h4 className="font-medium text-emerald-700 dark:text-emerald-400 mb-2">Semantische Suche</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Fragen werden in Vektoren umgewandelt und aehnliche Dokumente gefunden
</p>
</div>
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border-2 border-amber-200 dark:border-amber-800">
<h4 className="font-medium text-amber-700 dark:text-amber-400 mb-2">RAG-Antworten</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
LLM generiert Antworten basierend auf gefundenen Dokumenten
</p>
</div>
</div>
</div>
</div>
{/* Technical Details */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Technische Details
</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Embedding-Service</h3>
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Modell</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">text-embedding-3-small</td>
</tr>
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Dimensionen</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">1536</td>
</tr>
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Port</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">8087</td>
</tr>
</tbody>
</table>
</div>
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Chunk-Konfiguration</h3>
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Chunk-Groesse</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">1000 Zeichen</td>
</tr>
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Ueberlappung</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">200 Zeichen</td>
</tr>
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Distanzmetrik</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">COSINE</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
// --- Internal helper components ---
function SourceCard({ icon, title, subtitle, color }: {
icon: string
title: string
subtitle: string
color: string
}) {
const colorClasses: Record<string, string> = {
blue: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
green: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
purple: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
orange: 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
}
return (
<div className={`p-4 rounded-xl border-2 text-center ${colorClasses[color]}`}>
<div className="text-3xl mb-2">{icon}</div>
<div className="font-medium text-gray-900 dark:text-white">{title}</div>
<div className="text-xs text-gray-500">{subtitle}</div>
</div>
)
}
function PipelineStep({ icon, title, subtitle }: {
icon: string
title: string
subtitle: string
}) {
return (
<div className="flex-1 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="text-2xl mb-1">{icon}</div>
<div className="font-medium text-sm">{title}</div>
<div className="text-xs text-gray-500">{subtitle}</div>
</div>
)
}
function CollectionCard({ collection, label }: { collection: string; label: string }) {
return (
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg text-center">
<div className="font-mono text-xs text-gray-500">{collection}</div>
<div className="font-medium text-gray-900 dark:text-white">{label}</div>
</div>
)
}
function ArrowDown() {
return (
<div className="flex justify-center mb-4">
<div className="text-4xl text-gray-400"></div>
</div>
)
}
function ArrowRight() {
return <div className="text-2xl text-gray-400"></div>
}
@@ -1,171 +0,0 @@
'use client'
import type { DataSource } from '../types'
export function DataSourcesTab({ sources }: { sources: DataSource[] }) {
return (
<div className="space-y-6">
{/* Introduction */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
<h2 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
Wie werden Daten hinzugefuegt?
</h2>
<p className="text-blue-800 dark:text-blue-200 mb-4">
Das RAG-System nutzt verschiedene Datenquellen. Jede Quelle hat einen eigenen Ingestion-Prozess:
</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<div className="font-medium text-gray-900 dark:text-white mb-1">Automatisch</div>
<p className="text-gray-600 dark:text-gray-400">
NiBiS-PDFs werden automatisch aus dem za-download Verzeichnis eingelesen
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<div className="font-medium text-gray-900 dark:text-white mb-1">Manuell</div>
<p className="text-gray-600 dark:text-gray-400">
Eigene EH koennen ueber die Klausur-Korrektur hochgeladen werden
</p>
</div>
</div>
</div>
{/* Data Sources List */}
<div className="grid gap-4">
{sources.map((source) => (
<DataSourceCard key={source.id} source={source} />
))}
</div>
{/* How to add data */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Daten hinzufuegen
</h2>
<div className="grid grid-cols-3 gap-6">
<AddDataCard
icon="📤"
title="Erwartungshorizont hochladen"
description="Laden Sie eigene EH-Dokumente in der Klausur-Korrektur hoch"
linkHref="/admin/klausur-korrektur"
linkText="Zur Klausur-Korrektur →"
/>
<AddDataCard
icon="🔄"
title="NiBiS neu einlesen"
description="Starten Sie die automatische Ingestion der NiBiS-PDFs"
linkText="Ingestion starten →"
/>
<AddDataCard
icon="⚖️"
title="Rechtskorpus erweitern"
description="Neue Regelwerke (DSGVO, BSI, etc.) zum Korpus hinzufuegen"
linkText="Regelwerk hinzufuegen →"
/>
<AddDataCard
icon="📋"
title="DSFA-Quellen verwalten"
description="WP248, DSK, Muss-Listen mit Lizenzattribution"
linkHref="/ai/rag-pipeline/dsfa"
linkText="DSFA-Manager oeffnen →"
/>
</div>
</div>
</div>
)
}
// --- Internal helper components ---
function DataSourceCard({ source }: { source: DataSource }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{source.name}
</h3>
<DataSourceStatusBadge status={source.status} />
</div>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{source.description}
</p>
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Collection: </span>
<span className="font-mono text-gray-900 dark:text-white">{source.collection}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Dokumente: </span>
<span className="font-semibold text-gray-900 dark:text-white">{source.document_count}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Chunks: </span>
<span className="font-semibold text-gray-900 dark:text-white">{source.chunk_count}</span>
</div>
{source.last_updated && (
<div>
<span className="text-gray-500 dark:text-gray-400">Aktualisiert: </span>
<span className="text-gray-900 dark:text-white">
{new Date(source.last_updated).toLocaleDateString('de-DE')}
</span>
</div>
)}
</div>
</div>
<div className="flex gap-2">
<button className="px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg">
Aktualisieren
</button>
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Details
</button>
</div>
</div>
</div>
)
}
function DataSourceStatusBadge({ status }: { status: DataSource['status'] }) {
const className = status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: status === 'pending'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
const label = status === 'active' ? 'Aktiv' : status === 'pending' ? 'Ausstehend' : 'Fehler'
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
{label}
</span>
)
}
function AddDataCard({ icon, title, description, linkHref, linkText }: {
icon: string
title: string
description: string
linkHref?: string
linkText: string
}) {
return (
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div className="text-2xl mb-2">{icon}</div>
<h3 className="font-medium text-gray-900 dark:text-white mb-2">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{description}</p>
{linkHref ? (
<a
href={linkHref}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{linkText}
</a>
) : (
<button className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
{linkText}
</button>
)}
</div>
)
}
@@ -1,60 +0,0 @@
'use client'
import type { DatasetStats } from '../types'
export function DatasetOverview({ stats }: { stats: DatasetStats }) {
const maxBundesland = Math.max(...Object.values(stats.by_bundesland))
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Datensatz-Uebersicht
</h3>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{stats.total_documents.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
</div>
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl">
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
{stats.total_chunks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-xl">
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
{stats.training_allowed.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Indexiert</p>
</div>
</div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Verteilung nach Bundesland
</h4>
<div className="space-y-2">
{Object.entries(stats.by_bundesland)
.sort((a, b) => b[1] - a[1])
.map(([code, count]) => (
<div key={code} className="flex items-center gap-3">
<span className="w-8 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">
{code}
</span>
<div className="flex-1 h-4 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"
style={{ width: `${(count / maxBundesland) * 100}%` }}
/>
</div>
<span className="w-10 text-sm text-right text-gray-600 dark:text-gray-400">
{count}
</span>
</div>
))}
</div>
</div>
)
}
@@ -1,277 +0,0 @@
'use client'
import { useState } from 'react'
import type { TrainingConfig } from '../types'
const BUNDESLAENDER = [
{ code: 'ni', name: 'Niedersachsen', allowed: true },
{ code: 'by', name: 'Bayern', allowed: true },
{ code: 'nw', name: 'NRW', allowed: true },
{ code: 'he', name: 'Hessen', allowed: true },
{ code: 'bw', name: 'Baden-Wuerttemberg', allowed: true },
{ code: 'rp', name: 'Rheinland-Pfalz', allowed: true },
{ code: 'sn', name: 'Sachsen', allowed: true },
{ code: 'sh', name: 'Schleswig-Holstein', allowed: true },
{ code: 'th', name: 'Thueringen', allowed: true },
{ code: 'be', name: 'Berlin', allowed: false },
{ code: 'bb', name: 'Brandenburg', allowed: false },
{ code: 'hb', name: 'Bremen', allowed: false },
{ code: 'hh', name: 'Hamburg', allowed: false },
{ code: 'mv', name: 'Mecklenburg-Vorpommern', allowed: false },
{ code: 'sl', name: 'Saarland', allowed: false },
{ code: 'st', name: 'Sachsen-Anhalt', allowed: false },
]
export function NewTrainingModal({ isOpen, onClose, onSubmit }: {
isOpen: boolean
onClose: () => void
onSubmit: (config: Partial<TrainingConfig>) => void
}) {
const [step, setStep] = useState(1)
const [config, setConfig] = useState<Partial<TrainingConfig>>({
batch_size: 16,
learning_rate: 0.00005,
epochs: 10,
warmup_steps: 500,
weight_decay: 0.01,
gradient_accumulation: 4,
mixed_precision: true,
bundeslaender: [],
})
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Neue Indexierung starten
</h2>
<p className="text-sm text-gray-500">Schritt {step} von 3</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Step Indicator */}
<StepIndicator currentStep={step} />
{/* Step Content */}
<div className="p-6 overflow-y-auto max-h-[50vh]">
{step === 1 && (
<BundeslaenderStep config={config} setConfig={setConfig} />
)}
{step === 2 && (
<ParameterStep config={config} setConfig={setConfig} />
)}
{step === 3 && (
<ConfirmStep config={config} />
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
<button
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{step > 1 ? 'Zurueck' : 'Abbrechen'}
</button>
<button
onClick={() => step < 3 ? setStep(step + 1) : onSubmit(config)}
disabled={step === 1 && (!config.bundeslaender || config.bundeslaender.length === 0)}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{step < 3 ? 'Weiter' : 'Indexierung starten'}
</button>
</div>
</div>
</div>
)
}
// --- Internal step components ---
function StepIndicator({ currentStep }: { currentStep: number }) {
return (
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<div className="flex items-center justify-center gap-4">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s <= currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}>
{s < currentStep ? '\u2713' : s}
</div>
{s < 3 && (
<div className={`w-16 h-1 mx-2 rounded ${
s < currentStep ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`} />
)}
</div>
))}
</div>
<div className="flex justify-center gap-20 mt-2 text-xs text-gray-500">
<span>Daten</span>
<span>Parameter</span>
<span>Bestaetigen</span>
</div>
</div>
)
}
function BundeslaenderStep({ config, setConfig }: {
config: Partial<TrainingConfig>
setConfig: (config: Partial<TrainingConfig>) => void
}) {
return (
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
Waehlen Sie die Bundeslaender fuer die Indexierung
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Nur Bundeslaender mit verfuegbaren Dokumenten koennen ausgewaehlt werden.
</p>
<div className="grid grid-cols-2 gap-3">
{BUNDESLAENDER.map((bl) => (
<label
key={bl.code}
className={`flex items-center p-3 rounded-lg border-2 transition cursor-pointer ${
config.bundeslaender?.includes(bl.code)
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: bl.allowed
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
: 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
}`}
>
<input
type="checkbox"
disabled={!bl.allowed}
checked={config.bundeslaender?.includes(bl.code)}
onChange={(e) => {
if (e.target.checked) {
setConfig({ ...config, bundeslaender: [...(config.bundeslaender || []), bl.code] })
} else {
setConfig({ ...config, bundeslaender: config.bundeslaender?.filter(c => c !== bl.code) })
}
}}
className="sr-only"
/>
<span className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 ${
config.bundeslaender?.includes(bl.code)
? 'bg-blue-500 border-blue-500 text-white'
: 'border-gray-300 dark:border-gray-600'
}`}>
{config.bundeslaender?.includes(bl.code) && '\u2713'}
</span>
<span className="flex-1 text-gray-900 dark:text-white">{bl.name}</span>
{!bl.allowed && (
<span className="text-xs text-red-500">Keine Daten</span>
)}
</label>
))}
</div>
</div>
)
}
function ParameterStep({ config, setConfig }: {
config: Partial<TrainingConfig>
setConfig: (config: Partial<TrainingConfig>) => void
}) {
return (
<div className="space-y-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
Indexierungs-Parameter
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Diese Parameter steuern die Batch-Verarbeitung der Dokumente.
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Batch Size
</label>
<input
type="number"
value={config.batch_size}
onChange={(e) => setConfig({ ...config, batch_size: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
<p className="text-xs text-gray-500 mt-1">Dokumente pro Batch</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Durchlaeufe
</label>
<input
type="number"
value={config.epochs}
onChange={(e) => setConfig({ ...config, epochs: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
<p className="text-xs text-gray-500 mt-1">Fuer Validierung</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<input
type="checkbox"
id="mixedPrecision"
checked={config.mixed_precision}
onChange={(e) => setConfig({ ...config, mixed_precision: e.target.checked })}
className="w-4 h-4 text-blue-600 rounded"
/>
<label htmlFor="mixedPrecision" className="text-sm text-gray-700 dark:text-gray-300">
Parallele Verarbeitung - schneller bei grossem Datensatz
</label>
</div>
</div>
)
}
function ConfirmStep({ config }: { config: Partial<TrainingConfig> }) {
return (
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
Konfiguration bestaetigen
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Bundeslaender</span>
<span className="font-medium text-gray-900 dark:text-white">
{config.bundeslaender?.length || 0} ausgewaehlt
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Batch Size</span>
<span className="font-medium text-gray-900 dark:text-white">{config.batch_size}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Parallele Verarbeitung</span>
<span className="font-medium text-gray-900 dark:text-white">
{config.mixed_precision ? 'Aktiviert' : 'Deaktiviert'}
</span>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Was passiert:</strong> Die ausgewaehlten Dokumente werden extrahiert,
in Chunks aufgeteilt, und als Vektoren in Qdrant indexiert.
Dieser Prozess kann je nach Datenmenge einige Minuten dauern.
</p>
</div>
</div>
)
}
@@ -1,168 +0,0 @@
'use client'
import type { TrainingJob } from '../types'
// Tab Button
export function TabButton({ active, onClick, children }: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
active
? 'bg-blue-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{children}
</button>
)
}
// Progress Ring Component
export function ProgressRing({ progress, size = 120, strokeWidth = 8, color = '#10B981' }: {
progress: number
size?: number
strokeWidth?: number
color?: string
}) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
className="text-gray-200 dark:text-gray-700"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-500"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-gray-900 dark:text-white">
{Math.round(progress)}%
</span>
</div>
</div>
)
}
// Mini Line Chart Component
export function MiniChart({ data, color = '#10B981', height = 60 }: {
data: number[]
color?: string
height?: number
}) {
if (!data.length) return null
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const width = 200
const padding = 4
const points = data.map((value, i) => {
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
const y = padding + (1 - (value - min) / range) * (height - 2 * padding)
return `${x},${y}`
}).join(' ')
return (
<svg width={width} height={height} className="overflow-visible">
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{data.length > 0 && (
<circle
cx={padding + ((data.length - 1) / (data.length - 1)) * (width - 2 * padding)}
cy={padding + (1 - (data[data.length - 1] - min) / range) * (height - 2 * padding)}
r={4}
fill={color}
/>
)}
</svg>
)
}
// Status Badge
export function StatusBadge({ status }: { status: TrainingJob['status'] }) {
const styles = {
queued: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
preparing: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
training: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
validating: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
paused: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
}
const labels = {
queued: 'In Warteschlange',
preparing: 'Vorbereitung',
training: 'Indexierung laeuft',
validating: 'Validierung',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
paused: 'Pausiert',
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
{status === 'training' && (
<span className="w-2 h-2 mr-1.5 bg-blue-500 rounded-full animate-pulse" />
)}
{labels[status]}
</span>
)
}
// Metric Card
export function MetricCard({ label, value, trend, color }: {
label: string
value: number | string
trend?: 'up' | 'down' | 'neutral'
color?: string
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{label}</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold" style={{ color: color || 'inherit' }}>
{typeof value === 'number' ? value.toFixed(3) : value}
</span>
{trend && (
<span className={`ml-2 text-sm ${
trend === 'up' ? 'text-green-500' : trend === 'down' ? 'text-red-500' : 'text-gray-400'
}`}>
{trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192'}
</span>
)}
</div>
</div>
)
}
@@ -1,126 +0,0 @@
'use client'
import type { TrainingJob } from '../types'
import { ProgressRing, MiniChart, StatusBadge, MetricCard } from './SharedWidgets'
export function TrainingJobCard({ job, onPause, onResume, onStop, onViewDetails }: {
job: TrainingJob
onPause: () => void
onResume: () => void
onStop: () => void
onViewDetails: () => void
}) {
const isActive = ['training', 'preparing', 'validating'].includes(job.status)
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{job.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Typ: {job.model_type.charAt(0).toUpperCase() + job.model_type.slice(1)}
</p>
</div>
<StatusBadge status={job.status} />
</div>
<div className="p-6">
<div className="flex items-center gap-8">
<ProgressRing
progress={job.progress}
color={job.status === 'failed' ? '#EF4444' : '#10B981'}
/>
<div className="flex-1 space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">Durchlauf</span>
<span className="font-medium text-gray-900 dark:text-white">
{job.current_epoch} / {job.total_epochs}
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-500"
style={{ width: `${(job.current_epoch / job.total_epochs) * 100}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">Dokumente</span>
<span className="font-medium text-gray-900 dark:text-white">
{job.documents_processed.toLocaleString()} / {job.total_documents.toLocaleString()}
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-full transition-all duration-500"
style={{ width: `${(job.documents_processed / job.total_documents) * 100}%` }}
/>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-3 mt-6">
<MetricCard label="Loss" value={job.loss} trend="down" color="#3B82F6" />
<MetricCard label="Val Loss" value={job.val_loss} trend="down" color="#8B5CF6" />
<MetricCard label="Precision" value={job.metrics.precision} color="#10B981" />
<MetricCard label="F1 Score" value={job.metrics.f1_score} color="#F59E0B" />
</div>
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Fortschritt
</span>
</div>
<div className="flex gap-4">
<MiniChart data={job.metrics.loss_history} color="#3B82F6" />
<MiniChart data={job.metrics.val_loss_history} color="#8B5CF6" />
</div>
</div>
<div className="mt-4 flex justify-between text-sm text-gray-500 dark:text-gray-400">
<span>
Gestartet: {job.started_at ? new Date(job.started_at).toLocaleTimeString('de-DE') : '-'}
</span>
<span>
Geschaetzt: {job.estimated_completion
? new Date(job.estimated_completion).toLocaleTimeString('de-DE')
: '-'
}
</span>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-between">
<button
onClick={onViewDetails}
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Details anzeigen
</button>
<div className="flex gap-2">
{isActive && (
<>
<button
onClick={job.status === 'paused' ? onResume : onPause}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{job.status === 'paused' ? 'Fortsetzen' : 'Pausieren'}
</button>
<button
onClick={onStop}
className="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/40"
>
Abbrechen
</button>
</>
)}
</div>
</div>
</div>
)
}
@@ -1,146 +0,0 @@
import type { TrainingJob, TrainingConfig, DatasetStats, DataSource } from './types'
// ============================================================================
// MOCK DATA
// ============================================================================
export const MOCK_JOBS: TrainingJob[] = []
export const MOCK_STATS: DatasetStats = {
total_documents: 632,
total_chunks: 8547,
training_allowed: 489,
by_bundesland: {
ni: 87, by: 92, nw: 78, he: 65, bw: 71, rp: 43, sn: 38, sh: 34, th: 29,
},
by_doc_type: {
verordnung: 312,
schulordnung: 156,
handreichung: 98,
erlass: 66,
},
}
export const MOCK_DATA_SOURCES: DataSource[] = [
{
id: 'nibis',
name: 'NiBiS Erwartungshorizonte',
description: 'Offizielle Abitur-Erwartungshorizonte vom Niedersaechsischen Bildungsserver',
collection: 'bp_nibis_eh',
document_count: 245,
chunk_count: 3200,
last_updated: '2025-01-15T10:30:00Z',
status: 'active',
},
{
id: 'user_eh',
name: 'Benutzerdefinierte EH',
description: 'Von Lehrern hochgeladene schulspezifische Erwartungshorizonte',
collection: 'bp_eh',
document_count: 87,
chunk_count: 1100,
last_updated: '2025-01-20T14:15:00Z',
status: 'active',
},
{
id: 'legal',
name: 'Rechtskorpus',
description: 'DSGVO, AI Act, BSI-Standards und weitere Compliance-Regelwerke',
collection: 'bp_legal_corpus',
document_count: 19,
chunk_count: 2400,
last_updated: '2025-01-10T08:00:00Z',
status: 'active',
},
{
id: 'dsfa',
name: 'DSFA-Guidance',
description: 'WP248, DSK Kurzpapiere, Muss-Listen aller Bundeslaender mit Quellenattribution',
collection: 'bp_dsfa_corpus',
document_count: 45,
chunk_count: 850,
last_updated: '2026-02-09T10:00:00Z',
status: 'active',
},
{
id: 'schulordnungen',
name: 'Schulordnungen',
description: 'Landesschulordnungen und Zeugnisverordnungen aller Bundeslaender',
collection: 'bp_schulordnungen',
document_count: 156,
chunk_count: 1847,
last_updated: null,
status: 'pending',
},
]
// ============================================================================
// API FUNCTIONS
// ============================================================================
export async function fetchJobs(): Promise<TrainingJob[]> {
try {
const response = await fetch('/api/ai/rag-pipeline?action=jobs')
if (!response.ok) throw new Error('Failed to fetch jobs')
return await response.json()
} catch (error) {
console.error('Error fetching jobs:', error)
return MOCK_JOBS
}
}
export async function fetchDatasetStats(): Promise<DatasetStats> {
try {
const response = await fetch('/api/ai/rag-pipeline?action=dataset-stats')
if (!response.ok) throw new Error('Failed to fetch stats')
return await response.json()
} catch (error) {
console.error('Error fetching stats:', error)
return MOCK_STATS
}
}
export async function createTrainingJob(config: Partial<TrainingConfig>): Promise<{id: string, status: string}> {
const response = await fetch('/api/ai/rag-pipeline?action=create-job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `RAG-Index ${new Date().toLocaleDateString('de-DE')}`,
model_type: 'zeugnis',
bundeslaender: config.bundeslaender || [],
batch_size: config.batch_size || 16,
learning_rate: config.learning_rate || 0.00005,
epochs: config.epochs || 10,
warmup_steps: config.warmup_steps || 500,
weight_decay: config.weight_decay || 0.01,
gradient_accumulation: config.gradient_accumulation || 4,
mixed_precision: config.mixed_precision ?? true,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to create job')
}
return await response.json()
}
export async function pauseJob(jobId: string): Promise<void> {
const response = await fetch(`/api/ai/rag-pipeline?action=pause&job_id=${jobId}`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to pause job')
}
export async function resumeJob(jobId: string): Promise<void> {
const response = await fetch(`/api/ai/rag-pipeline?action=resume&job_id=${jobId}`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to resume job')
}
export async function cancelJob(jobId: string): Promise<void> {
const response = await fetch(`/api/ai/rag-pipeline?action=cancel&job_id=${jobId}`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to cancel job')
}
@@ -1,61 +0,0 @@
'use client'
import React from 'react'
import { Scale, CheckCircle, Clock, AlertCircle } from 'lucide-react'
import {
DSFALicenseCode,
DSFA_LICENSE_LABELS,
DSFA_DOCUMENT_TYPE_LABELS,
} from '@/lib/sdk/types'
export function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
const colorMap: Record<DSFALicenseCode, string> = {
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
<Scale className="w-3 h-3" />
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
</span>
)
}
export function DocumentTypeBadge({ type }: { type?: string }) {
if (!type) return null
const colorMap: Record<string, string> = {
guideline: 'bg-indigo-100 text-indigo-700',
checklist: 'bg-emerald-100 text-emerald-700',
regulation: 'bg-red-100 text-red-700',
template: 'bg-orange-100 text-orange-700',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
</span>
)
}
export function StatusIndicator({ status }: { status: string }) {
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
}
const config = statusConfig[status] || statusConfig.yellow
return (
<span className={`inline-flex items-center gap-1 ${config.color}`}>
{config.icon}
<span className="text-sm">{config.label}</span>
</span>
)
}
@@ -1,137 +0,0 @@
'use client'
import { useState } from 'react'
import {
RefreshCw,
ChevronDown,
ChevronUp,
ExternalLink,
} from 'lucide-react'
import { DSFASource, DSFASourceStats } from '@/lib/sdk/types'
import { LicenseBadge, DocumentTypeBadge } from './DSFABadges'
interface SourceCardProps {
source: DSFASource
stats?: DSFASourceStats
onIngest: () => void
isIngesting: boolean
}
export function SourceCard({ source, stats, onIngest, isIngesting }: SourceCardProps) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{source.sourceCode}
</span>
<DocumentTypeBadge type={source.documentType} />
</div>
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{source.name}
</h3>
{source.organization && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{source.organization}
</p>
)}
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
</div>
<div className="flex items-center gap-4 mt-3">
<LicenseBadge licenseCode={source.licenseCode} />
{stats && (
<>
<span className="text-sm text-gray-500">
{stats.documentCount} Dok.
</span>
<span className="text-sm text-gray-500">
{stats.chunkCount} Chunks
</span>
</>
)}
</div>
{source.attributionRequired && (
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
<strong>Attribution:</strong> {source.attributionText}
</div>
)}
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
<dl className="grid grid-cols-2 gap-3 text-sm">
{source.sourceUrl && (
<>
<dt className="text-gray-500">Quelle:</dt>
<dd>
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
Link <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
{source.licenseUrl && (
<>
<dt className="text-gray-500">Lizenz-URL:</dt>
<dd>
<a
href={source.licenseUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
{source.licenseName} <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
<dt className="text-gray-500">Sprache:</dt>
<dd className="uppercase">{source.language}</dd>
{stats?.lastIndexedAt && (
<>
<dt className="text-gray-500">Zuletzt indexiert:</dt>
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
</>
)}
</dl>
<div className="mt-4 flex gap-2">
<button
onClick={onIngest}
disabled={isIngesting}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{isIngesting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Neu indexieren
</button>
</div>
</div>
)}
</div>
)
}
@@ -1,59 +0,0 @@
'use client'
import { Database } from 'lucide-react'
import { DSFACorpusStats } from '@/lib/sdk/types'
import { StatusIndicator } from './DSFABadges'
interface StatsOverviewProps {
stats: DSFACorpusStats
}
export function StatsOverview({ stats }: StatsOverviewProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Database className="w-5 h-5" />
Corpus-Statistik
</h2>
<StatusIndicator status={stats.qdrantStatus} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{stats.totalSources}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
</div>
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{stats.totalDocuments}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{stats.totalChunks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{stats.qdrantPointsCount.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
</div>
</div>
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Collection:</strong>{' '}
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
{stats.qdrantCollection}
</code>
</p>
</div>
</div>
)
}
@@ -1,137 +0,0 @@
/**
* DSFA API functions and mock data.
*/
import {
DSFASource,
DSFACorpusStats,
} from '@/lib/sdk/types'
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export const MOCK_SOURCES: DSFASource[] = [
{
id: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01 - Leitlinien zur DSFA',
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
licenseCode: 'EDPB-LICENSE',
licenseName: 'EDPB Document License',
attributionRequired: true,
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
documentType: 'guideline',
language: 'de',
},
{
id: '2',
sourceCode: 'DSK_KP5',
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
organization: 'Datenschutzkonferenz (DSK)',
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE \u2013 Namensnennung 2.0',
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
attributionRequired: true,
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
documentType: 'guideline',
language: 'de',
},
{
id: '3',
sourceCode: 'BFDI_MUSS_PUBLIC',
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
organization: 'BfDI',
sourceUrl: 'https://www.bfdi.bund.de',
licenseCode: 'DL-DE-ZERO-2.0',
licenseName: 'Datenlizenz DE \u2013 Zero 2.0',
attributionRequired: false,
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
documentType: 'checklist',
language: 'de',
},
{
id: '4',
sourceCode: 'NI_MUSS_PRIVATE',
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
organization: 'LfD Niedersachsen',
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE \u2013 Namensnennung 2.0',
attributionRequired: true,
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
documentType: 'checklist',
language: 'de',
},
]
export const MOCK_STATS: DSFACorpusStats = {
sources: [
{
sourceId: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01',
organization: 'EDPB',
licenseCode: 'EDPB-LICENSE',
documentType: 'guideline',
documentCount: 1,
chunkCount: 50,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
{
sourceId: '2',
sourceCode: 'DSK_KP5',
name: 'DSK Kurzpapier Nr. 5',
organization: 'DSK',
licenseCode: 'DL-DE-BY-2.0',
documentType: 'guideline',
documentCount: 1,
chunkCount: 35,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
],
totalSources: 45,
totalDocuments: 45,
totalChunks: 850,
qdrantCollection: 'bp_dsfa_corpus',
qdrantPointsCount: 850,
qdrantStatus: 'green',
}
export async function fetchSources(): Promise<DSFASource[]> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
if (!response.ok) throw new Error('Failed to fetch sources')
return await response.json()
} catch {
return MOCK_SOURCES
}
}
export async function fetchStats(): Promise<DSFACorpusStats> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
if (!response.ok) throw new Error('Failed to fetch stats')
return await response.json()
} catch {
return MOCK_STATS
}
}
export async function initializeCorpus(): Promise<{ sources_registered: number }> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to initialize corpus')
return await response.json()
}
export async function triggerIngestion(sourceCode: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!response.ok) throw new Error('Failed to trigger ingestion')
}
@@ -4,6 +4,11 @@
* DSFA Document Manager
*
* Manages DSFA-related sources and documents for the RAG pipeline.
* Features:
* - View all registered DSFA sources with license info
* - Upload new documents
* - Trigger re-indexing
* - View corpus statistics
*/
import { useState, useEffect } from 'react'
@@ -14,24 +19,411 @@ import {
Upload,
FileText,
Database,
Scale,
ExternalLink,
ChevronDown,
ChevronUp,
Search,
Filter,
CheckCircle,
Clock,
AlertCircle,
BookOpen,
BookOpen
} from 'lucide-react'
import { DSFASource, DSFACorpusStats, DSFASourceStats } from '@/lib/sdk/types'
import {
fetchSources,
fetchStats,
initializeCorpus,
triggerIngestion,
MOCK_SOURCES,
MOCK_STATS,
} from './_components/dsfa-api'
import { LicenseBadge } from './_components/DSFABadges'
import { SourceCard } from './_components/SourceCard'
import { StatsOverview } from './_components/StatsOverview'
DSFASource,
DSFACorpusStats,
DSFASourceStats,
DSFALicenseCode,
DSFA_LICENSE_LABELS,
DSFA_DOCUMENT_TYPE_LABELS
} from '@/lib/sdk/types'
// ============================================================================
// TYPES
// ============================================================================
interface APIError {
message: string
status?: number
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
async function fetchSources(): Promise<DSFASource[]> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
if (!response.ok) throw new Error('Failed to fetch sources')
return await response.json()
} catch {
// Return mock data for demo
return MOCK_SOURCES
}
}
async function fetchStats(): Promise<DSFACorpusStats> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
if (!response.ok) throw new Error('Failed to fetch stats')
return await response.json()
} catch {
return MOCK_STATS
}
}
async function initializeCorpus(): Promise<{ sources_registered: number }> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to initialize corpus')
return await response.json()
}
async function triggerIngestion(sourceCode: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!response.ok) throw new Error('Failed to trigger ingestion')
}
// ============================================================================
// MOCK DATA
// ============================================================================
const MOCK_SOURCES: DSFASource[] = [
{
id: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01 - Leitlinien zur DSFA',
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
licenseCode: 'EDPB-LICENSE',
licenseName: 'EDPB Document License',
attributionRequired: true,
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
documentType: 'guideline',
language: 'de',
},
{
id: '2',
sourceCode: 'DSK_KP5',
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
organization: 'Datenschutzkonferenz (DSK)',
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE Namensnennung 2.0',
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
attributionRequired: true,
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
documentType: 'guideline',
language: 'de',
},
{
id: '3',
sourceCode: 'BFDI_MUSS_PUBLIC',
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
organization: 'BfDI',
sourceUrl: 'https://www.bfdi.bund.de',
licenseCode: 'DL-DE-ZERO-2.0',
licenseName: 'Datenlizenz DE Zero 2.0',
attributionRequired: false,
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
documentType: 'checklist',
language: 'de',
},
{
id: '4',
sourceCode: 'NI_MUSS_PRIVATE',
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
organization: 'LfD Niedersachsen',
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE Namensnennung 2.0',
attributionRequired: true,
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
documentType: 'checklist',
language: 'de',
},
]
const MOCK_STATS: DSFACorpusStats = {
sources: [
{
sourceId: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01',
organization: 'EDPB',
licenseCode: 'EDPB-LICENSE',
documentType: 'guideline',
documentCount: 1,
chunkCount: 50,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
{
sourceId: '2',
sourceCode: 'DSK_KP5',
name: 'DSK Kurzpapier Nr. 5',
organization: 'DSK',
licenseCode: 'DL-DE-BY-2.0',
documentType: 'guideline',
documentCount: 1,
chunkCount: 35,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
],
totalSources: 45,
totalDocuments: 45,
totalChunks: 850,
qdrantCollection: 'bp_dsfa_corpus',
qdrantPointsCount: 850,
qdrantStatus: 'green',
}
// ============================================================================
// COMPONENTS
// ============================================================================
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
const colorMap: Record<DSFALicenseCode, string> = {
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
<Scale className="w-3 h-3" />
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
</span>
)
}
function DocumentTypeBadge({ type }: { type?: string }) {
if (!type) return null
const colorMap: Record<string, string> = {
guideline: 'bg-indigo-100 text-indigo-700',
checklist: 'bg-emerald-100 text-emerald-700',
regulation: 'bg-red-100 text-red-700',
template: 'bg-orange-100 text-orange-700',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
</span>
)
}
function StatusIndicator({ status }: { status: string }) {
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
}
const config = statusConfig[status] || statusConfig.yellow
return (
<span className={`inline-flex items-center gap-1 ${config.color}`}>
{config.icon}
<span className="text-sm">{config.label}</span>
</span>
)
}
function SourceCard({
source,
stats,
onIngest,
isIngesting
}: {
source: DSFASource
stats?: DSFASourceStats
onIngest: () => void
isIngesting: boolean
}) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{source.sourceCode}
</span>
<DocumentTypeBadge type={source.documentType} />
</div>
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{source.name}
</h3>
{source.organization && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{source.organization}
</p>
)}
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
</div>
<div className="flex items-center gap-4 mt-3">
<LicenseBadge licenseCode={source.licenseCode} />
{stats && (
<>
<span className="text-sm text-gray-500">
{stats.documentCount} Dok.
</span>
<span className="text-sm text-gray-500">
{stats.chunkCount} Chunks
</span>
</>
)}
</div>
{source.attributionRequired && (
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
<strong>Attribution:</strong> {source.attributionText}
</div>
)}
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
<dl className="grid grid-cols-2 gap-3 text-sm">
{source.sourceUrl && (
<>
<dt className="text-gray-500">Quelle:</dt>
<dd>
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
Link <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
{source.licenseUrl && (
<>
<dt className="text-gray-500">Lizenz-URL:</dt>
<dd>
<a
href={source.licenseUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
{source.licenseName} <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
<dt className="text-gray-500">Sprache:</dt>
<dd className="uppercase">{source.language}</dd>
{stats?.lastIndexedAt && (
<>
<dt className="text-gray-500">Zuletzt indexiert:</dt>
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
</>
)}
</dl>
<div className="mt-4 flex gap-2">
<button
onClick={onIngest}
disabled={isIngesting}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{isIngesting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Neu indexieren
</button>
</div>
</div>
)}
</div>
)
}
function StatsOverview({ stats }: { stats: DSFACorpusStats }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Database className="w-5 h-5" />
Corpus-Statistik
</h2>
<StatusIndicator status={stats.qdrantStatus} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{stats.totalSources}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
</div>
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{stats.totalDocuments}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{stats.totalChunks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{stats.qdrantPointsCount.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
</div>
</div>
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Collection:</strong>{' '}
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
{stats.qdrantCollection}
</code>
</p>
</div>
</div>
)
}
// ============================================================================
// MAIN PAGE
// ============================================================================
export default function DSFADocumentManagerPage() {
const [sources, setSources] = useState<DSFASource[]>([])
@@ -69,6 +461,7 @@ export default function DSFADocumentManagerPage() {
setIsInitializing(true)
try {
await initializeCorpus()
// Reload data
const [sourcesData, statsData] = await Promise.all([
fetchSources(),
fetchStats(),
@@ -86,6 +479,7 @@ export default function DSFADocumentManagerPage() {
setIngestingSource(sourceCode)
try {
await triggerIngestion(sourceCode)
// Reload stats
const statsData = await fetchStats()
setStats(statsData)
} catch (err) {
@@ -107,6 +501,7 @@ export default function DSFADocumentManagerPage() {
return matchesSearch && matchesType
})
// Get stats by source code
const getStatsForSource = (sourceCode: string): DSFASourceStats | undefined => {
return stats?.sources.find(s => s.sourceCode === sourceCode)
}
File diff suppressed because it is too large Load Diff
@@ -1,63 +0,0 @@
// ============================================================================
// RAG Pipeline Types
// ============================================================================
export interface TrainingJob {
id: string
name: string
model_type: 'zeugnis' | 'klausur' | 'general'
status: 'queued' | 'preparing' | 'training' | 'validating' | 'completed' | 'failed' | 'paused'
progress: number
current_epoch: number
total_epochs: number
loss: number
val_loss: number
learning_rate: number
documents_processed: number
total_documents: number
started_at: string | null
estimated_completion: string | null
error_message: string | null
metrics: TrainingMetrics
config: TrainingConfig
}
export interface TrainingMetrics {
precision: number
recall: number
f1_score: number
accuracy: number
loss_history: number[]
val_loss_history: number[]
confusion_matrix?: number[][]
}
export interface TrainingConfig {
batch_size: number
learning_rate: number
epochs: number
warmup_steps: number
weight_decay: number
gradient_accumulation: number
mixed_precision: boolean
bundeslaender: string[]
}
export interface DatasetStats {
total_documents: number
total_chunks: number
training_allowed: number
by_bundesland: Record<string, number>
by_doc_type: Record<string, number>
}
export interface DataSource {
id: string
name: string
description: string
collection: string
document_count: number
chunk_count: number
last_updated: string | null
status: 'active' | 'pending' | 'error'
}
@@ -1,147 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import type { TrainingJob, TrainingConfig, DatasetStats, DataSource } from './types'
import {
MOCK_JOBS,
MOCK_STATS,
MOCK_DATA_SOURCES,
fetchJobs,
fetchDatasetStats,
createTrainingJob,
pauseJob,
resumeJob,
cancelJob,
} from './api'
export type TabType = 'dashboard' | 'architecture' | 'sources'
export interface RagPipelineState {
activeTab: TabType
setActiveTab: (tab: TabType) => void
jobs: TrainingJob[]
stats: DatasetStats
dataSources: DataSource[]
showNewTrainingModal: boolean
setShowNewTrainingModal: (show: boolean) => void
selectedJob: TrainingJob | null
setSelectedJob: (job: TrainingJob | null) => void
isLoading: boolean
error: string | null
setError: (error: string | null) => void
handleStartTraining: (config: Partial<TrainingConfig>) => Promise<void>
handlePauseJob: (jobId: string) => Promise<void>
handleResumeJob: (jobId: string) => Promise<void>
handleCancelJob: (jobId: string) => Promise<void>
}
export function useRagPipeline(): RagPipelineState {
const [activeTab, setActiveTab] = useState<TabType>('dashboard')
const [jobs, setJobs] = useState<TrainingJob[]>([])
const [stats, setStats] = useState<DatasetStats>(MOCK_STATS)
const [dataSources] = useState<DataSource[]>(MOCK_DATA_SOURCES)
const [showNewTrainingModal, setShowNewTrainingModal] = useState(false)
const [selectedJob, setSelectedJob] = useState<TrainingJob | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function loadData() {
setIsLoading(true)
try {
const [jobsData, statsData] = await Promise.all([
fetchJobs(),
fetchDatasetStats(),
])
setJobs(jobsData)
setStats(statsData)
setError(null)
} catch (err) {
console.error('Failed to load data:', err)
setError('Verbindung zum Backend fehlgeschlagen')
setJobs(MOCK_JOBS)
setStats(MOCK_STATS)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
useEffect(() => {
const hasActiveJob = jobs.some(j => j.status === 'training' || j.status === 'preparing')
if (!hasActiveJob) return
const interval = setInterval(async () => {
try {
const updatedJobs = await fetchJobs()
setJobs(updatedJobs)
} catch (err) {
console.error('Failed to refresh jobs:', err)
}
}, 2000)
return () => clearInterval(interval)
}, [jobs])
const handleStartTraining = async (config: Partial<TrainingConfig>) => {
try {
await createTrainingJob(config)
const updatedJobs = await fetchJobs()
setJobs(updatedJobs)
setShowNewTrainingModal(false)
} catch (err) {
console.error('Failed to start training:', err)
setError(err instanceof Error ? err.message : 'Indexierung konnte nicht gestartet werden')
}
}
const handlePauseJob = async (jobId: string) => {
try {
await pauseJob(jobId)
const updatedJobs = await fetchJobs()
setJobs(updatedJobs)
} catch (err) {
console.error('Failed to pause job:', err)
}
}
const handleResumeJob = async (jobId: string) => {
try {
await resumeJob(jobId)
const updatedJobs = await fetchJobs()
setJobs(updatedJobs)
} catch (err) {
console.error('Failed to resume job:', err)
}
}
const handleCancelJob = async (jobId: string) => {
try {
await cancelJob(jobId)
const updatedJobs = await fetchJobs()
setJobs(updatedJobs)
} catch (err) {
console.error('Failed to cancel job:', err)
}
}
return {
activeTab,
setActiveTab,
jobs,
stats,
dataSources,
showNewTrainingModal,
setShowNewTrainingModal,
selectedJob,
setSelectedJob,
isLoading,
error,
setError,
handleStartTraining,
handlePauseJob,
handleResumeJob,
handleCancelJob,
}
}
@@ -1,252 +0,0 @@
import { describe, it, expect } from 'vitest'
import ragData from '../rag-documents.json'
/**
* Tests fuer rag-documents.json — Branchen-Regulierungs-Matrix
*
* Validiert die JSON-Struktur, Branchen-Zuordnung und Datenintegritaet
* der 320 Dokumente fuer die RAG Landkarte.
*/
const VALID_INDUSTRY_IDS = ragData.industries.map((i: any) => i.id)
const VALID_DOC_TYPE_IDS = ragData.doc_types.map((dt: any) => dt.id)
describe('rag-documents.json — Struktur', () => {
it('sollte doc_types, industries und documents enthalten', () => {
expect(ragData).toHaveProperty('doc_types')
expect(ragData).toHaveProperty('industries')
expect(ragData).toHaveProperty('documents')
expect(Array.isArray(ragData.doc_types)).toBe(true)
expect(Array.isArray(ragData.industries)).toBe(true)
expect(Array.isArray(ragData.documents)).toBe(true)
})
it('sollte genau 10 Branchen haben (VDMA/VDA/BDI)', () => {
expect(ragData.industries).toHaveLength(10)
const ids = ragData.industries.map((i: any) => i.id)
expect(ids).toContain('automotive')
expect(ids).toContain('maschinenbau')
expect(ids).toContain('elektrotechnik')
expect(ids).toContain('chemie')
expect(ids).toContain('metall')
expect(ids).toContain('energie')
expect(ids).toContain('transport')
expect(ids).toContain('handel')
expect(ids).toContain('konsumgueter')
expect(ids).toContain('bau')
})
it('sollte keine Pseudo-Branchen enthalten (IoT, KI, HR, KRITIS, etc.)', () => {
const ids = ragData.industries.map((i: any) => i.id)
expect(ids).not.toContain('iot')
expect(ids).not.toContain('ai')
expect(ids).not.toContain('hr')
expect(ids).not.toContain('kritis')
expect(ids).not.toContain('ecommerce')
expect(ids).not.toContain('tech')
expect(ids).not.toContain('media')
expect(ids).not.toContain('public')
})
it('sollte 17 Dokumenttypen haben', () => {
expect(ragData.doc_types.length).toBe(17)
})
it('sollte mindestens 300 Dokumente haben', () => {
expect(ragData.documents.length).toBeGreaterThanOrEqual(300)
})
it('sollte jede Branche name und icon haben', () => {
ragData.industries.forEach((ind: any) => {
expect(ind).toHaveProperty('id')
expect(ind).toHaveProperty('name')
expect(ind).toHaveProperty('icon')
expect(ind.name.length).toBeGreaterThan(0)
})
})
it('sollte jeden doc_type mit id, label, icon und sort haben', () => {
ragData.doc_types.forEach((dt: any) => {
expect(dt).toHaveProperty('id')
expect(dt).toHaveProperty('label')
expect(dt).toHaveProperty('icon')
expect(dt).toHaveProperty('sort')
})
})
})
describe('rag-documents.json — Dokument-Validierung', () => {
it('sollte keine doppelten Codes haben', () => {
const codes = ragData.documents.map((d: any) => d.code)
const unique = new Set(codes)
expect(unique.size).toBe(codes.length)
})
it('sollte Pflichtfelder bei jedem Dokument haben', () => {
ragData.documents.forEach((doc: any) => {
expect(doc).toHaveProperty('code')
expect(doc).toHaveProperty('name')
expect(doc).toHaveProperty('doc_type')
expect(doc).toHaveProperty('industries')
expect(doc).toHaveProperty('in_rag')
expect(doc).toHaveProperty('rag_collection')
expect(doc.code.length).toBeGreaterThan(0)
expect(doc.name.length).toBeGreaterThan(0)
expect(Array.isArray(doc.industries)).toBe(true)
})
})
it('sollte nur gueltige doc_type IDs verwenden', () => {
ragData.documents.forEach((doc: any) => {
expect(VALID_DOC_TYPE_IDS).toContain(doc.doc_type)
})
})
it('sollte nur gueltige industry IDs verwenden (oder "all")', () => {
ragData.documents.forEach((doc: any) => {
doc.industries.forEach((ind: string) => {
if (ind !== 'all') {
expect(VALID_INDUSTRY_IDS).toContain(ind)
}
})
})
})
it('sollte gueltige rag_collection Namen verwenden', () => {
const validCollections = [
'bp_compliance_ce',
'bp_compliance_gesetze',
'bp_compliance_datenschutz',
'bp_dsfa_corpus',
'bp_legal_templates',
'bp_compliance_recht',
'bp_nibis_eh',
]
ragData.documents.forEach((doc: any) => {
expect(validCollections).toContain(doc.rag_collection)
})
})
})
describe('rag-documents.json — Branchen-Zuordnungslogik', () => {
const findDoc = (code: string) => ragData.documents.find((d: any) => d.code === code)
describe('Horizontale Regulierungen (alle Branchen)', () => {
const horizontalCodes = [
'GDPR', 'BDSG_FULL', 'EPRIVACY', 'TDDDG', 'AIACT', 'CRA',
'NIS2', 'GPSR', 'PLD', 'EUCSA', 'DATAACT',
]
horizontalCodes.forEach((code) => {
it(`${code} sollte fuer alle Branchen gelten`, () => {
const doc = findDoc(code)
if (doc) {
expect(doc.industries).toContain('all')
}
})
})
})
describe('Sektorspezifische Regulierungen', () => {
it('Maschinenverordnung sollte Maschinenbau, Automotive, Elektrotechnik enthalten', () => {
const doc = findDoc('MACHINERY_REG')
if (doc) {
expect(doc.industries).toContain('maschinenbau')
expect(doc.industries).toContain('automotive')
expect(doc.industries).toContain('elektrotechnik')
expect(doc.industries).not.toContain('all')
}
})
it('ElektroG sollte Elektrotechnik und Automotive enthalten', () => {
const doc = findDoc('DE_ELEKTROG')
if (doc) {
expect(doc.industries).toContain('elektrotechnik')
expect(doc.industries).toContain('automotive')
}
})
it('BattDG sollte Automotive und Elektrotechnik enthalten', () => {
const doc = findDoc('DE_BATTDG')
if (doc) {
expect(doc.industries).toContain('automotive')
expect(doc.industries).toContain('elektrotechnik')
}
})
it('ENISA ICS/SCADA sollte Energie, Maschinenbau, Chemie enthalten', () => {
const doc = findDoc('ENISA_ICS_SCADA')
if (doc) {
expect(doc.industries).toContain('energie')
expect(doc.industries).toContain('maschinenbau')
expect(doc.industries).toContain('chemie')
}
})
})
describe('Nicht zutreffende Regulierungen (Finanz/Medizin/Plattformen)', () => {
const emptyIndustryCodes = ['DORA', 'PSD2', 'MiCA', 'AMLR', 'EHDS', 'DSA', 'DMA', 'MDR']
emptyIndustryCodes.forEach((code) => {
it(`${code} sollte keine Branchen-Zuordnung haben`, () => {
const doc = findDoc(code)
if (doc) {
expect(doc.industries).toHaveLength(0)
}
})
})
})
describe('BSI-TR-03161 (DiGA) sollte nicht zutreffend sein', () => {
['BSI-TR-03161-1', 'BSI-TR-03161-2', 'BSI-TR-03161-3'].forEach((code) => {
it(`${code} sollte keine Branchen-Zuordnung haben`, () => {
const doc = findDoc(code)
if (doc) {
expect(doc.industries).toHaveLength(0)
}
})
})
})
})
describe('rag-documents.json — Applicability Notes', () => {
it('sollte applicability_note bei Dokumenten mit description haben', () => {
const withDescription = ragData.documents.filter((d: any) => d.description)
const withNote = withDescription.filter((d: any) => d.applicability_note)
// Mindestens 90% der Dokumente mit Beschreibung sollten eine Note haben
expect(withNote.length / withDescription.length).toBeGreaterThan(0.9)
})
it('horizontale Regulierungen sollten "alle Branchen" in der Note erwaehnen', () => {
const gdpr = ragData.documents.find((d: any) => d.code === 'GDPR')
if (gdpr?.applicability_note) {
expect(gdpr.applicability_note.toLowerCase()).toContain('alle branchen')
}
})
it('nicht zutreffende sollten "nicht zutreffend" in der Note erwaehnen', () => {
const dora = ragData.documents.find((d: any) => d.code === 'DORA')
if (dora?.applicability_note) {
expect(dora.applicability_note.toLowerCase()).toContain('nicht zutreffend')
}
})
})
describe('rag-documents.json — Dokumenttyp-Verteilung', () => {
it('sollte Dokumente in jedem doc_type haben', () => {
ragData.doc_types.forEach((dt: any) => {
const count = ragData.documents.filter((d: any) => d.doc_type === dt.id).length
expect(count).toBeGreaterThan(0)
})
})
it('sollte EU-Verordnungen als groesste Kategorie haben (mind. 15)', () => {
const euRegs = ragData.documents.filter((d: any) => d.doc_type === 'eu_regulation')
expect(euRegs.length).toBeGreaterThanOrEqual(15)
})
it('sollte EDPB Leitlinien als umfangreichste Kategorie haben (mind. 40)', () => {
const edpb = ragData.documents.filter((d: any) => d.doc_type === 'edpb_guideline')
expect(edpb.length).toBeGreaterThanOrEqual(40)
})
})
@@ -1,195 +0,0 @@
'use client'
import React from 'react'
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
interface DataTabProps {
hook: UseRAGPageReturn
}
export function DataTab({ hook }: DataTabProps) {
const {
customDocuments,
uploadFile,
setUploadFile,
uploadTitle,
setUploadTitle,
uploadCode,
setUploadCode,
uploading,
handleUpload,
linkUrl,
setLinkUrl,
linkTitle,
setLinkTitle,
linkCode,
setLinkCode,
addingLink,
handleAddLink,
handleDeleteDocument,
fetchCustomDocuments,
} = hook
return (
<div className="space-y-6">
{/* Upload Document */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Dokument hochladen (PDF)</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">PDF-Datei</label>
<input
type="file"
accept=".pdf"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Titel</label>
<input
type="text"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder="z.B. Firmen-Datenschutzrichtlinie"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Code (eindeutig)</label>
<input
type="text"
value={uploadCode}
onChange={(e) => setUploadCode(e.target.value.toUpperCase())}
placeholder="z.B. CUSTOM-DSR-01"
className="w-full px-3 py-2 border rounded-lg font-mono"
/>
</div>
</div>
<button
onClick={handleUpload}
disabled={uploading || !uploadFile || !uploadTitle || !uploadCode}
className="mt-4 px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{uploading ? 'Wird hochgeladen...' : 'Hochladen & Indexieren'}
</button>
</div>
{/* Add Link */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Link hinzufuegen (Webseite/PDF)</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">URL</label>
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com/document.pdf"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Titel</label>
<input
type="text"
value={linkTitle}
onChange={(e) => setLinkTitle(e.target.value)}
placeholder="z.B. BSI IT-Grundschutz"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Code (eindeutig)</label>
<input
type="text"
value={linkCode}
onChange={(e) => setLinkCode(e.target.value.toUpperCase())}
placeholder="z.B. BSI-GRUNDSCHUTZ"
className="w-full px-3 py-2 border rounded-lg font-mono"
/>
</div>
</div>
<button
onClick={handleAddLink}
disabled={addingLink || !linkUrl || !linkTitle || !linkCode}
className="mt-4 px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{addingLink ? 'Wird hinzugefuegt...' : 'Link hinzufuegen & Indexieren'}
</button>
</div>
{/* Custom Documents List */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">Eigene Dokumente ({customDocuments.length})</h3>
<button
onClick={fetchCustomDocuments}
className="text-sm text-teal-600 hover:text-teal-700"
>
Aktualisieren
</button>
</div>
{customDocuments.length === 0 ? (
<div className="p-8 text-center text-slate-500">
Noch keine eigenen Dokumente hinzugefuegt.
</div>
) : (
<div className="divide-y">
{customDocuments.map((doc) => (
<div key={doc.id} className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-lg">
{doc.url ? '🔗' : '📄'}
</span>
<div>
<p className="font-medium text-slate-900">{doc.title}</p>
<p className="text-sm text-slate-500">
<span className="font-mono text-teal-600">{doc.code}</span>
{' • '}
{doc.filename || doc.url}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
doc.status === 'indexed' ? 'bg-green-100 text-green-700' :
doc.status === 'error' ? 'bg-red-100 text-red-700' :
doc.status === 'processing' || doc.status === 'fetching' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{doc.status === 'indexed' ? `${doc.chunk_count} Chunks` :
doc.status === 'error' ? 'Fehler' :
doc.status === 'processing' ? 'Verarbeitung...' :
doc.status === 'fetching' ? 'Abruf...' :
doc.status}
</span>
<button
onClick={() => handleDeleteDocument(doc.id)}
className="text-red-500 hover:text-red-700 text-sm"
>
Loeschen
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Info Box */}
<div className="bg-teal-50 border border-teal-200 rounded-xl p-6">
<h4 className="font-semibold text-teal-800 flex items-center gap-2">
<span></span>
Hinweis zur Verwendung
</h4>
<p className="text-sm text-teal-700 mt-2">
Laden Sie eigene Dokumente (z.B. interne Datenschutzrichtlinien, Vertraege) oder
externe Links hoch. Diese werden automatisch in Chunks aufgeteilt und indexiert.
Nach dem Hinzufuegen koennen Sie im <strong>Pipeline</strong>-Tab die vollstaendige
Compliance-Analyse starten.
</p>
</div>
</div>
)
}
@@ -1,69 +0,0 @@
'use client'
import React from 'react'
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
interface IngestionTabProps {
hook: UseRAGPageReturn
}
export function IngestionTab({ hook }: IngestionTabProps) {
const { ingestionRunning, ingestionLog, triggerIngestion } = hook
return (
<div className="space-y-6">
{/* Ingestion Control */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Legal Corpus Re-Ingestion</h3>
<p className="text-slate-600 mb-4">
Startet die Neuindexierung aller 19 Regulierungen. Die Dokumente werden von EUR-Lex,
gesetze-im-internet.de und BSI heruntergeladen, in semantische Chunks aufgeteilt und
mit BGE-M3 Embeddings in Qdrant indexiert.
</p>
<div className="flex items-center gap-4">
<button
onClick={triggerIngestion}
disabled={ingestionRunning}
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{ingestionRunning ? 'Laeuft...' : 'Re-Ingestion starten'}
</button>
{ingestionRunning && (
<span className="flex items-center gap-2 text-teal-600">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
</svg>
Ingestion laeuft...
</span>
)}
</div>
</div>
{/* Ingestion Log */}
{ingestionLog.length > 0 && (
<div className="bg-slate-900 rounded-xl p-4">
<h4 className="text-slate-400 text-sm mb-2">Log</h4>
<div className="font-mono text-sm text-green-400 space-y-1 max-h-64 overflow-y-auto">
{ingestionLog.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
</div>
)}
{/* Info Box */}
<div className="bg-teal-50 border border-teal-200 rounded-xl p-6">
<h4 className="font-semibold text-teal-800 flex items-center gap-2">
<span>💡</span>
Hinweis zur Datenquelle
</h4>
<p className="text-sm text-teal-700 mt-2">
Alle indexierten Dokumente sind amtliche Werke (§5 UrhG) und damit urheberrechtsfrei.
Sie werden nur fuer RAG/Retrieval verwendet, nicht fuer Modell-Training.
Die Daten werden lokal auf dem Mac Mini verarbeitet und nicht an externe Dienste gesendet.
</p>
</div>
</div>
)
}
@@ -1,373 +0,0 @@
'use client'
import React from 'react'
import {
REGULATIONS,
DOC_TYPES,
INDUSTRIES_LIST,
INDUSTRIES,
INDUSTRY_REGULATION_MAP,
TYPE_COLORS,
THEMATIC_GROUPS,
KEY_INTERSECTIONS,
RAG_DOCUMENTS,
isInRag,
} from '../rag-data'
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
import {
FutureOutlookSection,
RagCoverageSection,
FutureRegulationsSection,
LegalBasisSection,
} from './MapTabSections'
interface MapTabProps {
hook: UseRAGPageReturn
}
export function MapTab({ hook }: MapTabProps) {
const {
expandedRegulation,
setExpandedRegulation,
expandedDocTypes,
setExpandedDocTypes,
expandedMatrixDoc,
setExpandedMatrixDoc,
setActiveTab,
} = hook
return (
<div className="space-y-6">
{/* Industry Filter */}
<IndustryFilter
expandedRegulation={expandedRegulation}
setExpandedRegulation={setExpandedRegulation}
/>
{/* Thematic Groups */}
<ThematicGroupsSection setActiveTab={setActiveTab} setExpandedRegulation={setExpandedRegulation} />
{/* Key Intersections */}
<KeyIntersectionsSection />
{/* Regulation Matrix */}
<RegulationMatrix
expandedDocTypes={expandedDocTypes}
setExpandedDocTypes={setExpandedDocTypes}
expandedMatrixDoc={expandedMatrixDoc}
setExpandedMatrixDoc={setExpandedMatrixDoc}
/>
{/* Future Outlook Section */}
<FutureOutlookSection />
{/* RAG Coverage Overview */}
<RagCoverageSection />
{/* Potential Future Regulations */}
<FutureRegulationsSection />
{/* Legal Basis Info */}
<LegalBasisSection />
</div>
)
}
// --- Sub-components ---
function IndustryFilter({
expandedRegulation,
setExpandedRegulation,
}: {
expandedRegulation: string | null
setExpandedRegulation: (v: string | null) => void
}) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Regulierungen nach Branche</h3>
<p className="text-sm text-slate-500 mb-4">
Waehlen Sie Ihre Branche, um relevante Regulierungen zu sehen.
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{INDUSTRIES.map((industry) => {
const regs = INDUSTRY_REGULATION_MAP[industry.id] || []
return (
<button
key={industry.id}
onClick={() => setExpandedRegulation(industry.id === expandedRegulation ? null : industry.id)}
className={`p-4 rounded-lg border text-left transition-all ${
expandedRegulation === industry.id
? 'border-teal-500 bg-teal-50 ring-2 ring-teal-200'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}`}
>
<div className="text-2xl mb-2">{industry.icon}</div>
<div className="font-medium text-slate-900 text-sm">{industry.name}</div>
<div className="text-xs text-slate-500 mt-1">{regs.length} Regulierungen</div>
</button>
)
})}
</div>
{/* Selected Industry Details */}
{expandedRegulation && INDUSTRIES.find(i => i.id === expandedRegulation) && (
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
{(() => {
const industry = INDUSTRIES.find(i => i.id === expandedRegulation)!
const regCodes = INDUSTRY_REGULATION_MAP[industry.id] || []
const regs = REGULATIONS.filter(r => regCodes.includes(r.code))
return (
<>
<div className="flex items-center gap-3 mb-4">
<span className="text-3xl">{industry.icon}</span>
<div>
<h4 className="font-semibold text-slate-900">{industry.name}</h4>
<p className="text-sm text-slate-500">{industry.description}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{regs.map((reg) => {
const regInRag = isInRag(reg.code)
return (
<div
key={reg.code}
className={`bg-white p-3 rounded-lg border ${regInRag ? 'border-green-200' : 'border-slate-200'}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
{reg.code}
</span>
{regInRag ? (
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-green-100 text-green-600 rounded">RAG</span>
) : (
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-red-50 text-red-400 rounded"></span>
)}
</div>
<div className="font-medium text-sm text-slate-900">{reg.name}</div>
<div className="text-xs text-slate-500 mt-1 line-clamp-2">{reg.description}</div>
</div>
)
})}
</div>
</>
)
})()}
</div>
)}
</div>
)
}
function ThematicGroupsSection({
setActiveTab,
setExpandedRegulation,
}: {
setActiveTab: (v: any) => void
setExpandedRegulation: (v: string | null) => void
}) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Thematische Cluster</h3>
<p className="text-sm text-slate-500 mb-4">
Regulierungen gruppiert nach Themenbereichen - zeigt Ueberschneidungen.
</p>
<div className="space-y-4">
{THEMATIC_GROUPS.map((group) => (
<div key={group.id} className="border border-slate-200 rounded-lg overflow-hidden">
<div className={`${group.color} px-4 py-2 text-white font-medium flex items-center justify-between`}>
<span>{group.name}</span>
<span className="text-sm opacity-80">{group.regulations.length} Regulierungen</span>
</div>
<div className="p-4">
<p className="text-sm text-slate-600 mb-3">{group.description}</p>
<div className="flex flex-wrap gap-2">
{group.regulations.map((code) => {
const reg = REGULATIONS.find(r => r.code === code)
const codeInRag = isInRag(code)
return (
<span
key={code}
className={`px-3 py-1.5 rounded-full text-sm font-medium cursor-pointer ${
codeInRag
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
onClick={() => {
setActiveTab('regulations')
setExpandedRegulation(code)
}}
title={`${reg?.fullName || code}${codeInRag ? ' (im RAG)' : ' (nicht im RAG)'}`}
>
{codeInRag ? '✓ ' : '✗ '}{code}
</span>
)
})}
</div>
</div>
</div>
))}
</div>
</div>
)
}
function KeyIntersectionsSection() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Wichtige Schnittstellen</h3>
<p className="text-sm text-slate-500 mb-4">
Bereiche, in denen sich mehrere Regulierungen ueberschneiden und zusammenwirken.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{KEY_INTERSECTIONS.map((intersection, idx) => (
<div key={idx} className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-lg p-4 border border-slate-200">
<div className="flex flex-wrap gap-1 mb-2">
{intersection.regulations.map((code) => (
<span
key={code}
className={`px-2 py-0.5 text-xs font-medium rounded ${
isInRag(code)
? 'bg-green-100 text-green-700'
: 'bg-red-50 text-red-500'
}`}
>
{isInRag(code) ? '✓ ' : '✗ '}{code}
</span>
))}
</div>
<div className="font-medium text-slate-900 text-sm mb-1">{intersection.topic}</div>
<div className="text-xs text-slate-500">{intersection.description}</div>
</div>
))}
</div>
</div>
)
}
function RegulationMatrix({
expandedDocTypes,
setExpandedDocTypes,
expandedMatrixDoc,
setExpandedMatrixDoc,
}: {
expandedDocTypes: string[]
setExpandedDocTypes: (fn: (prev: string[]) => string[]) => void
expandedMatrixDoc: string | null
setExpandedMatrixDoc: (v: string | null) => void
}) {
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50">
<h3 className="font-semibold text-slate-900">Branchen-Regulierungs-Matrix</h3>
<p className="text-sm text-slate-500">{RAG_DOCUMENTS.length} Dokumente in {DOC_TYPES.length} Kategorien</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-slate-50 border-b sticky top-0 z-10">
<tr>
<th className="px-2 py-2 text-left font-medium text-slate-500 sticky left-0 bg-slate-50 min-w-[200px]">Regulierung</th>
{INDUSTRIES_LIST.filter((i: any) => i.id !== 'all').map((industry: any) => (
<th key={industry.id} className="px-2 py-2 text-center font-medium text-slate-500 min-w-[60px]">
<div className="flex flex-col items-center">
<span className="text-lg">{industry.icon}</span>
<span className="text-[10px] leading-tight">{industry.name.split('/')[0]}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{DOC_TYPES.map((docType: any) => {
const docsInType = RAG_DOCUMENTS.filter((d: any) => d.doc_type === docType.id)
if (docsInType.length === 0) return null
const isExpanded = expandedDocTypes.includes(docType.id)
return (
<React.Fragment key={docType.id}>
<tr
className="bg-slate-100 border-t-2 border-slate-300 cursor-pointer hover:bg-slate-200"
onClick={() => {
setExpandedDocTypes(prev =>
prev.includes(docType.id)
? prev.filter((id: string) => id !== docType.id)
: [...prev, docType.id]
)
}}
>
<td colSpan={INDUSTRIES_LIST.length} className="px-3 py-2 font-bold text-slate-700">
<span className="mr-2">{isExpanded ? '\u25BC' : '\u25B6'}</span>
{docType.icon} {docType.label} ({docsInType.length})
</td>
</tr>
{isExpanded && docsInType.map((doc: any) => (
<React.Fragment key={doc.code}>
<tr
className={`hover:bg-slate-50 border-b border-slate-100 cursor-pointer ${expandedMatrixDoc === doc.code ? 'bg-teal-50' : ''}`}
onClick={() => setExpandedMatrixDoc(expandedMatrixDoc === doc.code ? null : doc.code)}
>
<td className="px-2 py-1.5 font-medium sticky left-0 bg-white">
<span className="flex items-center gap-1">
{isInRag(doc.code) ? (
<span className="text-green-500 text-[10px]"></span>
) : (
<span className="text-red-300 text-[10px]"></span>
)}
<span className="text-teal-600 truncate max-w-[180px]" title={doc.full_name || doc.name}>
{doc.name}
</span>
{(doc.applicability_note || doc.description) && (
<span className="text-slate-400 text-[10px] ml-1">{expandedMatrixDoc === doc.code ? '▼' : 'ⓘ'}</span>
)}
</span>
</td>
{INDUSTRIES_LIST.filter((i: any) => i.id !== 'all').map((industry: any) => {
const applies = doc.industries.includes(industry.id) || doc.industries.includes('all')
return (
<td key={industry.id} className="px-2 py-1.5 text-center">
{applies ? (
<span className="inline-flex items-center justify-center w-5 h-5 bg-teal-100 text-teal-600 rounded-full"></span>
) : (
<span className="inline-flex items-center justify-center w-5 h-5 text-slate-300"></span>
)}
</td>
)
})}
</tr>
{expandedMatrixDoc === doc.code && (doc.applicability_note || doc.description) && (
<tr className="bg-teal-50 border-b border-teal-200">
<td colSpan={INDUSTRIES_LIST.length} className="px-4 py-3">
<div className="text-xs space-y-1.5">
{doc.full_name && (
<p className="font-semibold text-slate-700">{doc.full_name}</p>
)}
{doc.applicability_note && (
<p className="text-teal-700 bg-teal-100 px-2 py-1 rounded inline-block">
<span className="font-medium">Branchenrelevanz:</span> {doc.applicability_note}
</p>
)}
{doc.description && (
<p className="text-slate-600">{doc.description}</p>
)}
{doc.effective_date && (
<p className="text-slate-400">In Kraft: {doc.effective_date}</p>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// FutureOutlookSection, RagCoverageSection, FutureRegulationsSection,
// LegalBasisSection are imported from ./MapTabSections.tsx
@@ -1,199 +0,0 @@
'use client'
import React from 'react'
import { REGULATIONS_IN_RAG } from '../rag-constants'
import {
RAG_DOCUMENTS,
FUTURE_OUTLOOK,
ADDITIONAL_REGULATIONS,
LEGAL_BASIS_INFO,
isInRag,
} from '../rag-data'
export function FutureOutlookSection() {
return (
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl border border-indigo-200 p-6">
<div className="flex items-center gap-3 mb-4">
<span className="text-2xl">🔮</span>
<div>
<h3 className="font-semibold text-slate-900">Zukunftsaussicht</h3>
<p className="text-sm text-slate-500">Geplante Aenderungen und neue Regulierungen</p>
</div>
</div>
<div className="space-y-4">
{FUTURE_OUTLOOK.map((item) => (
<div key={item.id} className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-4 py-3 flex items-center justify-between bg-slate-50 border-b">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 text-xs font-medium rounded ${
item.status === 'proposed' ? 'bg-yellow-100 text-yellow-700' :
item.status === 'agreed' ? 'bg-green-100 text-green-700' :
item.status === 'withdrawn' ? 'bg-red-100 text-red-700' :
'bg-blue-100 text-blue-700'
}`}>
{item.statusLabel}
</span>
<h4 className="font-semibold text-slate-900">{item.name}</h4>
</div>
<span className="text-sm text-slate-500">Erwartet: {item.expectedDate}</span>
</div>
<div className="p-4">
<p className="text-sm text-slate-600 mb-3">{item.description}</p>
<div className="mb-3">
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Wichtige Aenderungen:</p>
<ul className="text-sm text-slate-600 space-y-1">
{item.keyChanges.slice(0, 4).map((change, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-teal-500 mt-1"></span>
<span>{change}</span>
</li>
))}
{item.keyChanges.length > 4 && (
<li className="text-slate-400 text-xs">+ {item.keyChanges.length - 4} weitere...</li>
)}
</ul>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{item.affectedRegulations.map((code) => (
<span key={code} className="px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">
{code}
</span>
))}
</div>
<a
href={item.source}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-teal-600 hover:underline"
>
Quelle
</a>
</div>
</div>
</div>
))}
</div>
</div>
)
}
export function RagCoverageSection() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center gap-3 mb-4">
<span className="text-2xl"></span>
<div>
<h3 className="font-semibold text-slate-900">RAG-Abdeckung ({Object.keys(REGULATIONS_IN_RAG).length} von {RAG_DOCUMENTS.length} Regulierungen)</h3>
<p className="text-sm text-slate-500">Stand: Maerz 2026 Alle im RAG-System verfuegbaren Regulierungen (inkl. Verbraucherschutz Phase H)</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{RAG_DOCUMENTS.filter((r: any) => isInRag(r.code)).map((reg: any) => (
<span key={reg.code} className="px-2.5 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full border border-green-200">
{reg.code}
</span>
))}
</div>
<div className="mt-4 pt-4 border-t border-slate-100">
<p className="text-xs font-medium text-slate-500 mb-2">Noch nicht im RAG:</p>
<div className="flex flex-wrap gap-2">
{RAG_DOCUMENTS.filter((r: any) => !isInRag(r.code)).map((reg: any) => (
<span key={reg.code} className="px-2.5 py-1 text-xs font-medium bg-red-50 text-red-400 rounded-full border border-red-100">
{reg.code}
</span>
))}
</div>
</div>
</div>
)
}
export function FutureRegulationsSection() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center gap-3 mb-4">
<span className="text-2xl">🔮</span>
<div>
<h3 className="font-semibold text-slate-900">Zukuenftige Regulierungen</h3>
<p className="text-sm text-slate-500">Noch nicht verabschiedet oder zur Erweiterung vorgesehen</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{ADDITIONAL_REGULATIONS.map((reg) => (
<div key={reg.code} className={`rounded-lg border p-4 ${
reg.status === 'active' ? 'border-green-200 bg-green-50' : 'border-yellow-200 bg-yellow-50'
}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 text-xs font-bold rounded ${
reg.type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'
}`}>
{reg.code}
</span>
<span className={`px-2 py-0.5 text-xs rounded ${
reg.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{reg.status === 'active' ? 'In Kraft' : 'Vorgeschlagen'}
</span>
</div>
<span className={`px-2 py-0.5 text-xs rounded ${
reg.priority === 'high' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'
}`}>
{reg.priority === 'high' ? 'Hohe Prioritaet' : 'Mittel'}
</span>
</div>
<h4 className="font-medium text-slate-900 text-sm mb-1">{reg.name}</h4>
<p className="text-xs text-slate-600 mb-2">{reg.description}</p>
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Ab: {reg.effectiveDate}</span>
{reg.celex && (
<a
href={`https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:${reg.celex}`}
target="_blank"
rel="noopener noreferrer"
className="text-teal-600 hover:underline"
>
EUR-Lex
</a>
)}
</div>
</div>
))}
</div>
</div>
)
}
export function LegalBasisSection() {
return (
<div className="bg-emerald-50 rounded-xl border border-emerald-200 p-6">
<div className="flex items-center gap-3 mb-4">
<span className="text-2xl"></span>
<div>
<h3 className="font-semibold text-slate-900">{LEGAL_BASIS_INFO.title}</h3>
<p className="text-sm text-emerald-700">{LEGAL_BASIS_INFO.summary}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{LEGAL_BASIS_INFO.details.map((detail, idx) => (
<div key={idx} className="bg-white rounded-lg border border-emerald-100 p-3">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
detail.status === 'Erlaubt' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{detail.status}
</span>
<span className="font-medium text-sm text-slate-900">{detail.aspect}</span>
</div>
<p className="text-xs text-slate-600">{detail.explanation}</p>
</div>
))}
</div>
</div>
)
}
@@ -1,113 +0,0 @@
'use client'
import React from 'react'
import { REGULATIONS_IN_RAG } from '../rag-constants'
import {
REGULATIONS,
COLLECTION_TOTALS,
TYPE_LABELS,
TYPE_COLORS,
isInRag,
getKnownChunks,
} from '../rag-data'
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
interface OverviewTabProps {
hook: UseRAGPageReturn
}
export function OverviewTab({ hook }: OverviewTabProps) {
const {
dsfaLoading,
dsfaStatus,
dsfaSources,
setRegulationCategory,
setActiveTab,
} = hook
return (
<div className="space-y-6">
{/* RAG Categories Overview */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">RAG-Kategorien</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<button
onClick={() => { setRegulationCategory('regulations'); setActiveTab('regulations') }}
className="p-4 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 transition-colors text-left"
>
<p className="text-xs font-medium text-blue-600 uppercase">Gesetze & Regulierungen</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{COLLECTION_TOTALS.total_legal.toLocaleString()}</p>
<p className="text-xs text-slate-500 mt-1">{Object.keys(REGULATIONS_IN_RAG).length}/{REGULATIONS.length} im RAG</p>
</button>
<button
onClick={() => { setRegulationCategory('dsfa'); setActiveTab('regulations') }}
className="p-4 rounded-lg border border-purple-200 bg-purple-50 hover:bg-purple-100 transition-colors text-left"
>
<p className="text-xs font-medium text-purple-600 uppercase">DSFA Corpus</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{dsfaLoading ? '-' : (dsfaStatus?.total_chunks || 0).toLocaleString()}</p>
<p className="text-xs text-slate-500 mt-1">{dsfaSources.length || '~70'} Quellen (WP248, DSK, Gesetze)</p>
</button>
<div className="p-4 rounded-lg border border-emerald-200 bg-emerald-50 text-left">
<p className="text-xs font-medium text-emerald-600 uppercase">NiBiS EH</p>
<p className="text-2xl font-bold text-slate-900 mt-1">7.996</p>
<p className="text-xs text-slate-500 mt-1">Chunks &middot; Bildungs-Erwartungshorizonte</p>
</div>
<div className="p-4 rounded-lg border border-orange-200 bg-orange-50 text-left">
<p className="text-xs font-medium text-orange-600 uppercase">Legal Templates</p>
<p className="text-2xl font-bold text-slate-900 mt-1">7.689</p>
<p className="text-xs text-slate-500 mt-1">Chunks &middot; Dokumentvorlagen (VVT, TOM, DSFA)</p>
</div>
</div>
</div>
{/* Quick Stats per Type */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{Object.entries(TYPE_LABELS).map(([type, label]) => {
const regs = REGULATIONS.filter((r) => r.type === type)
const inRagCount = regs.filter((r) => isInRag(r.code)).length
const totalChunks = regs.reduce((sum, r) => sum + getKnownChunks(r.code), 0)
return (
<div key={type} className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[type]}`}>{label}</span>
<span className="text-slate-500 text-sm">{inRagCount}/{regs.length} im RAG</span>
</div>
<p className="text-xl font-bold text-slate-900">{totalChunks.toLocaleString()} Chunks</p>
</div>
)
})}
</div>
{/* Top Regulations */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50">
<h3 className="font-semibold text-slate-900">Top Regulierungen (nach Chunks)</h3>
</div>
<div className="divide-y">
{[...REGULATIONS].sort((a, b) => getKnownChunks(b.code) - getKnownChunks(a.code))
.slice(0, 10)
.map((reg) => {
const chunks = getKnownChunks(reg.code)
return (
<div key={reg.code} className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
{isInRag(reg.code) ? (
<span className="text-green-500 text-sm"></span>
) : (
<span className="text-red-400 text-sm"></span>
)}
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
{TYPE_LABELS[reg.type]}
</span>
<span className="font-medium text-slate-900">{reg.name}</span>
<span className="text-slate-500 text-sm">({reg.code})</span>
</div>
<span className={`font-bold ${chunks > 0 ? 'text-teal-600' : 'text-slate-300'}`}>{chunks > 0 ? chunks.toLocaleString() + ' Chunks' : '—'}</span>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -1,410 +0,0 @@
'use client'
import React from 'react'
import type { PipelineCheckpoint } from '../types'
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
interface PipelineTabProps {
hook: UseRAGPageReturn
}
export function PipelineTab({ hook }: PipelineTabProps) {
const {
pipelineState,
pipelineLoading,
pipelineStarting,
autoRefresh,
setAutoRefresh,
elapsedTime,
fetchPipeline,
handleStartPipeline,
collectionStatus,
} = hook
return (
<div className="space-y-6">
{/* Pipeline Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-slate-900">Compliance Pipeline Status</h3>
{pipelineState?.status === 'running' && elapsedTime && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-full">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-blue-700">Laufzeit: {elapsedTime}</span>
</div>
)}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="w-4 h-4 text-teal-600 rounded border-slate-300 focus:ring-teal-500"
/>
Auto-Refresh
</label>
{(!pipelineState || pipelineState.status !== 'running') && (
<button
onClick={() => handleStartPipeline(false)}
disabled={pipelineStarting}
className="flex items-center gap-2 px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{pipelineStarting ? (
<SpinnerIcon />
) : (
<PlayIcon />
)}
Pipeline starten
</button>
)}
<button
onClick={fetchPipeline}
disabled={pipelineLoading}
className="flex items-center gap-2 px-4 py-2 text-sm bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{pipelineLoading ? <SpinnerIcon /> : <RefreshIcon />}
Aktualisieren
</button>
</div>
</div>
{/* No Data */}
{(!pipelineState || pipelineState.status === 'no_data') && !pipelineLoading && (
<NoDataCard pipelineStarting={pipelineStarting} handleStartPipeline={handleStartPipeline} />
)}
{/* Pipeline Status */}
{pipelineState && pipelineState.status !== 'no_data' && (
<>
{/* Status Card */}
<PipelineStatusCard pipelineState={pipelineState} />
{/* Current Progress */}
{pipelineState.status === 'running' && pipelineState.current_phase && (
<CurrentProgressCard pipelineState={pipelineState} collectionStatus={collectionStatus} />
)}
{/* Validation Summary */}
{pipelineState.validation_summary && (
<ValidationSummary summary={pipelineState.validation_summary} />
)}
{/* Checkpoints */}
<CheckpointsList checkpoints={pipelineState.checkpoints} />
{/* Summary */}
{Object.keys(pipelineState.summary || {}).length > 0 && (
<PipelineSummary summary={pipelineState.summary} />
)}
</>
)}
</div>
)
}
// --- Icons ---
function SpinnerIcon() {
return (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
</svg>
)
}
function PlayIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
function RefreshIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)
}
// --- Sub-components ---
function NoDataCard({
pipelineStarting,
handleStartPipeline,
}: {
pipelineStarting: boolean
handleStartPipeline: (skip: boolean) => void
}) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h4 className="text-lg font-semibold text-slate-900 mb-2">Keine Pipeline-Daten</h4>
<p className="text-slate-600 mb-4">
Es wurde noch keine Pipeline ausgefuehrt. Starten Sie die Compliance-Pipeline um Checkpoint-Daten zu sehen.
</p>
<button
onClick={() => handleStartPipeline(false)}
disabled={pipelineStarting}
className="inline-flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{pipelineStarting ? (
<>
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
</svg>
Startet...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Pipeline jetzt starten
</>
)}
</button>
</div>
)
}
function PipelineStatusCard({ pipelineState }: { pipelineState: any }) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
pipelineState.status === 'completed' ? 'bg-green-100' :
pipelineState.status === 'running' ? 'bg-blue-100' :
pipelineState.status === 'failed' ? 'bg-red-100' : 'bg-slate-100'
}`}>
{pipelineState.status === 'completed' && (
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{pipelineState.status === 'running' && (
<svg className="w-6 h-6 text-blue-600 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
</svg>
)}
{pipelineState.status === 'failed' && (
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
<div>
<h4 className="font-semibold text-slate-900">Pipeline {pipelineState.pipeline_id}</h4>
<p className="text-sm text-slate-500">
Gestartet: {pipelineState.started_at ? new Date(pipelineState.started_at).toLocaleString('de-DE') : '-'}
{pipelineState.completed_at && ` | Beendet: ${new Date(pipelineState.completed_at).toLocaleString('de-DE')}`}
</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
pipelineState.status === 'completed' ? 'bg-green-100 text-green-700' :
pipelineState.status === 'running' ? 'bg-blue-100 text-blue-700' :
pipelineState.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700'
}`}>
{pipelineState.status === 'completed' ? 'Abgeschlossen' :
pipelineState.status === 'running' ? 'Laeuft' :
pipelineState.status === 'failed' ? 'Fehlgeschlagen' : pipelineState.status}
</span>
</div>
</div>
)
}
function CurrentProgressCard({ pipelineState, collectionStatus }: { pipelineState: any; collectionStatus: any }) {
return (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-blue-900 flex items-center gap-2">
<svg className="w-5 h-5 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Aktuelle Verarbeitung
</h4>
<span className="text-sm text-blue-600">Phase: {pipelineState.current_phase}</span>
</div>
{/* Phase Progress Indicator */}
<div className="flex items-center gap-2 mb-4">
{['ingestion', 'extraction', 'controls', 'measures'].map((phase, idx) => (
<div key={phase} className="flex-1 flex items-center">
<div className={`flex-1 h-2 rounded-full ${
pipelineState.current_phase === phase ? 'bg-blue-500 animate-pulse' :
pipelineState.checkpoints?.some((c: PipelineCheckpoint) => c.phase === phase && c.status === 'completed') ? 'bg-green-500' :
'bg-slate-200'
}`} />
{idx < 3 && <div className="w-2" />}
</div>
))}
</div>
<div className="flex justify-between text-xs text-slate-500 mb-4">
<span>Ingestion</span>
<span>Extraktion</span>
<span>Controls</span>
<span>Massnahmen</span>
</div>
{/* Current checkpoint details */}
{pipelineState.checkpoints?.filter((c: PipelineCheckpoint) => c.status === 'running').map((checkpoint: PipelineCheckpoint, idx: number) => (
<div key={idx} className="bg-white/60 rounded-lg p-4 mt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-blue-500 rounded-full animate-pulse" />
<span className="font-medium text-slate-900">{checkpoint.name}</span>
</div>
{checkpoint.metrics && Object.keys(checkpoint.metrics).length > 0 && (
<div className="flex gap-2">
{Object.entries(checkpoint.metrics).slice(0, 3).map(([key, value]) => (
<span key={key} className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
{key.replace(/_/g, ' ')}: {typeof value === 'number' ? value.toLocaleString() : String(value)}
</span>
))}
</div>
)}
</div>
</div>
))}
{/* Live chunk count */}
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-slate-600">Chunks in Qdrant:</span>
<span className="font-bold text-blue-700">{collectionStatus?.totalPoints?.toLocaleString() || '-'}</span>
</div>
</div>
)
}
function ValidationSummary({ summary }: { summary: { passed: number; warning: number; failed: number; total: number } }) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-green-200 p-4">
<p className="text-sm text-slate-500">Bestanden</p>
<p className="text-2xl font-bold text-green-600">{summary.passed}</p>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-4">
<p className="text-sm text-slate-500">Warnungen</p>
<p className="text-2xl font-bold text-yellow-600">{summary.warning}</p>
</div>
<div className="bg-white rounded-xl border border-red-200 p-4">
<p className="text-sm text-slate-500">Fehlgeschlagen</p>
<p className="text-2xl font-bold text-red-600">{summary.failed}</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<p className="text-sm text-slate-500">Gesamt</p>
<p className="text-2xl font-bold text-slate-700">{summary.total}</p>
</div>
</div>
)
}
function CheckpointsList({ checkpoints }: { checkpoints?: PipelineCheckpoint[] }) {
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50">
<h3 className="font-semibold text-slate-900">Checkpoints ({checkpoints?.length || 0})</h3>
</div>
<div className="divide-y">
{checkpoints?.map((checkpoint, idx) => (
<div key={idx} className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<span className={`w-3 h-3 rounded-full ${
checkpoint.phase === 'ingestion' ? 'bg-blue-500' :
checkpoint.phase === 'extraction' ? 'bg-purple-500' :
checkpoint.phase === 'controls' ? 'bg-green-500' : 'bg-orange-500'
}`} />
<span className="font-medium text-slate-900">{checkpoint.name}</span>
<span className="text-sm text-slate-500">
({checkpoint.phase}) |
{checkpoint.duration_seconds ? ` ${checkpoint.duration_seconds.toFixed(1)}s` : ' -'}
</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
checkpoint.status === 'completed' ? 'bg-green-100 text-green-700' :
checkpoint.status === 'running' ? 'bg-blue-100 text-blue-700' :
checkpoint.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700'
}`}>
{checkpoint.status}
</span>
</div>
{/* Metrics */}
{Object.keys(checkpoint.metrics || {}).length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{Object.entries(checkpoint.metrics).map(([key, value]) => (
<span key={key} className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">
{key.replace(/_/g, ' ')}: <strong>{typeof value === 'number' ? value.toLocaleString() : String(value)}</strong>
</span>
))}
</div>
)}
{/* Validations */}
{checkpoint.validations?.length > 0 && (
<div className="mt-3 space-y-1">
{checkpoint.validations.map((v, vIdx) => (
<div key={vIdx} className="flex items-center gap-2 text-sm">
<span className={`w-4 h-4 flex items-center justify-center ${
v.status === 'passed' ? 'text-green-500' :
v.status === 'warning' ? 'text-yellow-500' : 'text-red-500'
}`}>
{v.status === 'passed' ? '✓' : v.status === 'warning' ? '⚠' : '✗'}
</span>
<span className="text-slate-700">{v.name}:</span>
<span className="text-slate-500">{v.message}</span>
</div>
))}
</div>
)}
{/* Error */}
{checkpoint.error && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{checkpoint.error}
</div>
)}
</div>
))}
{(!checkpoints || checkpoints.length === 0) && (
<div className="p-4 text-center text-slate-500">
Noch keine Checkpoints vorhanden.
</div>
)}
</div>
</div>
)
}
function PipelineSummary({ summary }: { summary: Record<string, any> }) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3">Zusammenfassung</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(summary).map(([key, value]) => (
<div key={key}>
<p className="text-sm text-slate-500">{key.replace(/_/g, ' ')}</p>
<p className="font-bold text-slate-900">
{typeof value === 'number' ? value.toLocaleString() : String(value)}
</p>
</div>
))}
</div>
</div>
)
}
@@ -1,451 +0,0 @@
'use client'
import React from 'react'
import {
REGULATIONS,
TYPE_COLORS,
TYPE_LABELS,
isInRag,
getKnownChunks,
} from '../rag-data'
import {
REGULATION_SOURCES,
REGULATION_LICENSES,
LICENSE_LABELS,
} from '../rag-sources'
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
interface RegulationsTabProps {
hook: UseRAGPageReturn
}
export function RegulationsTab({ hook }: RegulationsTabProps) {
const {
regulationCategory,
setRegulationCategory,
expandedRegulation,
setExpandedRegulation,
fetchStatus,
dsfaSources,
dsfaLoading,
expandedDsfaSource,
setExpandedDsfaSource,
fetchDsfaStatus,
setActiveTab,
} = hook
return (
<div className="space-y-4">
{/* Category Filter */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setRegulationCategory('regulations')}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
regulationCategory === 'regulations'
? 'bg-blue-100 text-blue-700 ring-2 ring-blue-300'
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
}`}
>
Gesetze & Regulierungen ({REGULATIONS.length})
</button>
<button
onClick={() => setRegulationCategory('dsfa')}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
regulationCategory === 'dsfa'
? 'bg-purple-100 text-purple-700 ring-2 ring-purple-300'
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
}`}
>
DSFA Quellen ({dsfaSources.length || '~70'})
</button>
<button
onClick={() => setRegulationCategory('nibis')}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
regulationCategory === 'nibis'
? 'bg-emerald-100 text-emerald-700 ring-2 ring-emerald-300'
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
}`}
>
NiBiS Dokumente
</button>
<button
onClick={() => setRegulationCategory('templates')}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
regulationCategory === 'templates'
? 'bg-orange-100 text-orange-700 ring-2 ring-orange-300'
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
}`}
>
Templates & Vorlagen
</button>
</div>
{/* Regulations Table */}
{regulationCategory === 'regulations' && (
<RegulationsTable
expandedRegulation={expandedRegulation}
setExpandedRegulation={setExpandedRegulation}
fetchStatus={fetchStatus}
setActiveTab={setActiveTab}
/>
)}
{/* DSFA Sources */}
{regulationCategory === 'dsfa' && (
<DsfaSourcesList
dsfaSources={dsfaSources}
dsfaLoading={dsfaLoading}
expandedDsfaSource={expandedDsfaSource}
setExpandedDsfaSource={setExpandedDsfaSource}
fetchDsfaStatus={fetchDsfaStatus}
/>
)}
{/* NiBiS Dokumente (info only) */}
{regulationCategory === 'nibis' && <NibisInfo />}
{/* Templates (info only) */}
{regulationCategory === 'templates' && <TemplatesInfo />}
</div>
)
}
// --- Sub-components ---
function RegulationsTable({
expandedRegulation,
setExpandedRegulation,
fetchStatus,
setActiveTab,
}: {
expandedRegulation: string | null
setExpandedRegulation: (v: string | null) => void
fetchStatus: () => void
setActiveTab: (v: any) => void
}) {
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">
Alle {REGULATIONS.length} Regulierungen
<span className="ml-2 text-sm font-normal text-slate-500">
({REGULATIONS.filter(r => isInRag(r.code)).length} im RAG,{' '}
{REGULATIONS.filter(r => !isInRag(r.code)).length} ausstehend)
</span>
</h3>
<button onClick={fetchStatus} className="text-sm text-teal-600 hover:text-teal-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase w-12">RAG</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Chunks</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Erwartet</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{REGULATIONS.map((reg) => {
const chunks = getKnownChunks(reg.code)
const inRag = isInRag(reg.code)
const statusColor = inRag ? 'text-green-500' : 'text-red-500'
const statusIcon = inRag ? '✓' : '❌'
const isExpanded = expandedRegulation === reg.code
return (
<React.Fragment key={reg.code}>
<tr
onClick={() => setExpandedRegulation(isExpanded ? null : reg.code)}
className="hover:bg-slate-50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-center">
{isInRag(reg.code) ? (
<span className="inline-flex items-center justify-center w-6 h-6 bg-green-100 text-green-600 rounded-full text-xs font-bold" title="Im RAG vorhanden"></span>
) : (
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-50 text-red-400 rounded-full text-xs font-bold" title="Nicht im RAG"></span>
)}
</td>
<td className="px-4 py-3 font-mono font-medium text-teal-600">
<span className="inline-flex items-center gap-2">
<span className={`transform transition-transform ${isExpanded ? 'rotate-90' : ''}`}></span>
{reg.code}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
{TYPE_LABELS[reg.type]}
</span>
</td>
<td className="px-4 py-3 text-slate-900">{reg.name}</td>
<td className="px-4 py-3 text-right font-bold">
<span className={chunks > 0 && chunks < 10 && reg.expected >= 10 ? 'text-amber-600' : ''}>
{chunks.toLocaleString()}
{chunks > 0 && chunks < 10 && reg.expected >= 10 && (
<span className="ml-1 inline-block w-4 h-4 text-[10px] leading-4 text-center bg-amber-100 text-amber-700 rounded-full" title="Verdaechtig niedrig — Ingestion pruefen"></span>
)}
</span>
</td>
<td className="px-4 py-3 text-right text-slate-500">{reg.expected}</td>
<td className={`px-4 py-3 text-center ${statusColor}`}>{statusIcon}</td>
</tr>
{isExpanded && (
<tr key={`${reg.code}-detail`} className="bg-slate-50">
<td colSpan={7} className="px-4 py-4">
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
<div>
<h4 className="font-semibold text-slate-900 mb-1">{reg.fullName}</h4>
<p className="text-sm text-slate-600">{reg.description}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2 border-t border-slate-100">
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Relevant fuer</p>
<div className="flex flex-wrap gap-1">
{reg.relevantFor.map((item, idx) => (
<span key={idx} className="px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">
{item}
</span>
))}
</div>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Kernthemen</p>
<div className="flex flex-wrap gap-1">
{reg.keyTopics.map((topic, idx) => (
<span key={idx} className="px-2 py-0.5 text-xs bg-teal-50 text-teal-700 rounded">
{topic}
</span>
))}
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-100 text-xs text-slate-500">
<div className="flex items-center gap-4">
<span>In Kraft seit: {reg.effectiveDate}</span>
{REGULATION_LICENSES[reg.code] && (
<span className="flex items-center gap-1">
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-medium">
{LICENSE_LABELS[REGULATION_LICENSES[reg.code].license] || REGULATION_LICENSES[reg.code].license}
</span>
<span className="text-slate-400">{REGULATION_LICENSES[reg.code].licenseNote}</span>
</span>
)}
</div>
<div className="flex items-center gap-3">
{REGULATION_SOURCES[reg.code] && (
<a
href={REGULATION_SOURCES[reg.code]}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Originalquelle
</a>
)}
<button
onClick={(e) => {
e.stopPropagation()
setActiveTab('chunks')
}}
className="text-teal-600 hover:text-teal-700 font-medium"
>
In Chunks suchen
</button>
</div>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
</div>
)
}
function DsfaSourcesList({
dsfaSources,
dsfaLoading,
expandedDsfaSource,
setExpandedDsfaSource,
fetchDsfaStatus,
}: {
dsfaSources: any[]
dsfaLoading: boolean
expandedDsfaSource: string | null
setExpandedDsfaSource: (v: string | null) => void
fetchDsfaStatus: () => void
}) {
const typeColors: Record<string, string> = {
regulation: 'bg-blue-100 text-blue-700',
legislation: 'bg-indigo-100 text-indigo-700',
guideline: 'bg-teal-100 text-teal-700',
checklist: 'bg-yellow-100 text-yellow-700',
standard: 'bg-green-100 text-green-700',
methodology: 'bg-purple-100 text-purple-700',
specification: 'bg-orange-100 text-orange-700',
catalog: 'bg-pink-100 text-pink-700',
guidance: 'bg-cyan-100 text-cyan-700',
}
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">DSFA Quellen ({dsfaSources.length || '~70'})</h3>
<p className="text-xs text-slate-500">WP248, DSK Kurzpapiere, Muss-Listen, nationale Datenschutzgesetze</p>
</div>
<button onClick={fetchDsfaStatus} className="text-sm text-teal-600 hover:text-teal-700">
Aktualisieren
</button>
</div>
{dsfaLoading ? (
<div className="p-8 text-center text-slate-500">Lade DSFA-Quellen...</div>
) : dsfaSources.length === 0 ? (
<div className="p-8 text-center text-slate-500">
<p className="mb-2">Keine DSFA-Quellen vom Backend geladen.</p>
<p className="text-xs">Endpunkt: <code className="bg-slate-100 px-1 rounded">/api/dsfa-corpus?action=sources</code></p>
</div>
) : (
<div className="divide-y">
{dsfaSources.map((source) => {
const isExpanded = expandedDsfaSource === source.source_code
return (
<React.Fragment key={source.source_code}>
<div
onClick={() => setExpandedDsfaSource(isExpanded ? null : source.source_code)}
className="px-4 py-3 hover:bg-slate-50 cursor-pointer transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-3">
<span className={`transform transition-transform text-xs ${isExpanded ? 'rotate-90' : ''}`}></span>
<span className="font-mono text-sm text-purple-600 font-medium">{source.source_code}</span>
<span className={`px-2 py-0.5 text-xs rounded ${typeColors[source.document_type] || 'bg-slate-100 text-slate-600'}`}>
{source.document_type}
</span>
<span className="text-sm text-slate-900">{source.name}</span>
</div>
<div className="flex items-center gap-3">
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-slate-100 text-slate-500 rounded uppercase">
{source.language}
</span>
{source.chunk_count != null && (
<span className="text-sm font-bold text-purple-600">{source.chunk_count} Chunks</span>
)}
</div>
</div>
{isExpanded && (
<div className="px-4 pb-4 bg-slate-50">
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
<div>
<h4 className="font-semibold text-slate-900 mb-1">{source.full_name || source.name}</h4>
{source.organization && (
<p className="text-sm text-slate-600">Organisation: {source.organization}</p>
)}
</div>
<div className="flex items-center gap-4 pt-2 border-t border-slate-100 text-xs text-slate-500">
<span className="flex items-center gap-1">
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-medium">
{LICENSE_LABELS[source.license_code] || source.license_code}
</span>
<span className="text-slate-400">{source.attribution_text}</span>
</span>
</div>
{source.source_url && (
<div className="text-xs">
<a
href={source.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-teal-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
Quelle: {source.source_url}
</a>
</div>
)}
</div>
</div>
)}
</React.Fragment>
)
})}
</div>
)}
</div>
)
}
function NibisInfo() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-emerald-100 flex items-center justify-center text-xl">📚</div>
<div>
<h3 className="font-semibold text-slate-900">NiBiS Erwartungshorizonte</h3>
<p className="text-sm text-slate-500">Collection: <code className="bg-slate-100 px-1 rounded">bp_nibis_eh</code></p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
<p className="text-sm text-emerald-600 font-medium">Chunks</p>
<p className="text-2xl font-bold text-slate-900">7.996</p>
</div>
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
<p className="text-sm text-emerald-600 font-medium">Vector Size</p>
<p className="text-2xl font-bold text-slate-900">1024</p>
</div>
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
<p className="text-sm text-emerald-600 font-medium">Typ</p>
<p className="text-2xl font-bold text-slate-900">BGE-M3</p>
</div>
</div>
<p className="text-sm text-slate-600">
Bildungsinhalte aus dem Niedersaechsischen Bildungsserver (NiBiS). Enthaelt Erwartungshorizonte fuer
verschiedene Faecher und Schulformen. Wird ueber die Klausur-Korrektur fuer EH-Matching genutzt.
Diese Daten sind nicht direkt compliance-relevant.
</p>
</div>
)
}
function TemplatesInfo() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center text-xl">📋</div>
<div>
<h3 className="font-semibold text-slate-900">Legal Templates & Vorlagen</h3>
<p className="text-sm text-slate-500">Collection: <code className="bg-slate-100 px-1 rounded">bp_legal_templates</code></p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
<p className="text-sm text-orange-600 font-medium">Chunks</p>
<p className="text-2xl font-bold text-slate-900">7.689</p>
</div>
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
<p className="text-sm text-orange-600 font-medium">Vector Size</p>
<p className="text-2xl font-bold text-slate-900">1024</p>
</div>
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
<p className="text-sm text-orange-600 font-medium">Typ</p>
<p className="text-2xl font-bold text-slate-900">BGE-M3</p>
</div>
</div>
<p className="text-sm text-slate-600">
Vorlagen fuer VVT (Verzeichnis von Verarbeitungstaetigkeiten), TOM (Technisch-Organisatorische Massnahmen),
DSFA-Berichte und weitere Compliance-Dokumente. Werden vom AI Compliance SDK fuer die Dokumentgenerierung genutzt.
</p>
</div>
)
}
@@ -1,97 +0,0 @@
'use client'
import React from 'react'
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
interface SearchTabProps {
hook: UseRAGPageReturn
}
export function SearchTab({ hook }: SearchTabProps) {
const {
searchQuery,
setSearchQuery,
searchResults,
searching,
selectedRegulations,
setSelectedRegulations,
handleSearch,
} = hook
return (
<div className="space-y-6">
{/* Search Box */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Semantische Suche</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Suchanfrage</label>
<textarea
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="z.B. 'Welche Anforderungen gibt es fuer KI-Systeme mit hohem Risiko?'"
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Filter (optional)</label>
<div className="flex flex-wrap gap-2">
{['GDPR', 'AIACT', 'CRA', 'NIS2', 'BSI-TR-03161-1'].map((code) => (
<button
key={code}
onClick={() => {
setSelectedRegulations((prev: string[]) =>
prev.includes(code) ? prev.filter((c: string) => c !== code) : [...prev, code]
)
}}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
selectedRegulations.includes(code)
? 'bg-teal-100 border-teal-300 text-teal-700'
: 'bg-white border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{code}
</button>
))}
</div>
</div>
<button
onClick={handleSearch}
disabled={searching || !searchQuery.trim()}
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{searching ? 'Suche...' : 'Suchen'}
</button>
</div>
</div>
{/* Search Results */}
{searchResults.length > 0 && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50">
<h3 className="font-semibold text-slate-900">{searchResults.length} Ergebnisse</h3>
</div>
<div className="divide-y">
{searchResults.map((result, i) => (
<div key={i} className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 text-xs rounded bg-teal-100 text-teal-700">
{result.regulation_code}
</span>
{result.article && (
<span className="text-sm text-slate-500">Art. {result.article}</span>
)}
<span className="ml-auto text-sm text-slate-400">
Score: {(result.score * 100).toFixed(1)}%
</span>
</div>
<p className="text-slate-700 text-sm">{result.text}</p>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -1,441 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { API_PROXY, DSFA_API_PROXY } from '../rag-data'
import type {
TabId,
RegulationCategory,
CollectionStatus,
SearchResult,
DsfaSource,
DsfaCorpusStatus,
CustomDocument,
PipelineState,
PipelineCheckpoint,
} from '../types'
export function useRAGPage() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [collectionStatus, setCollectionStatus] = useState<CollectionStatus | null>(null)
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
const [selectedRegulations, setSelectedRegulations] = useState<string[]>([])
const [ingestionRunning, setIngestionRunning] = useState(false)
const [ingestionLog, setIngestionLog] = useState<string[]>([])
const [pipelineState, setPipelineState] = useState<PipelineState | null>(null)
const [pipelineLoading, setPipelineLoading] = useState(false)
const [pipelineStarting, setPipelineStarting] = useState(false)
const [expandedRegulation, setExpandedRegulation] = useState<string | null>(null)
const [autoRefresh, setAutoRefresh] = useState(true)
const [elapsedTime, setElapsedTime] = useState<string>('')
const [expandedDocTypes, setExpandedDocTypes] = useState<string[]>(['eu_regulation', 'eu_directive'])
const [expandedMatrixDoc, setExpandedMatrixDoc] = useState<string | null>(null)
// DSFA corpus state
const [dsfaSources, setDsfaSources] = useState<DsfaSource[]>([])
const [dsfaStatus, setDsfaStatus] = useState<DsfaCorpusStatus | null>(null)
const [dsfaLoading, setDsfaLoading] = useState(false)
const [regulationCategory, setRegulationCategory] = useState<RegulationCategory>('regulations')
const [expandedDsfaSource, setExpandedDsfaSource] = useState<string | null>(null)
// Data tab state
const [customDocuments, setCustomDocuments] = useState<CustomDocument[]>([])
const [uploadFile, setUploadFile] = useState<File | null>(null)
const [uploadTitle, setUploadTitle] = useState('')
const [uploadCode, setUploadCode] = useState('')
const [uploading, setUploading] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const [linkTitle, setLinkTitle] = useState('')
const [linkCode, setLinkCode] = useState('')
const [addingLink, setAddingLink] = useState(false)
const fetchStatus = useCallback(async () => {
setLoading(true)
try {
const res = await fetch(`${API_PROXY}?action=status`)
if (res.ok) {
const data = await res.json()
setCollectionStatus(data)
}
} catch (error) {
console.error('Failed to fetch status:', error)
} finally {
setLoading(false)
}
}, [])
const fetchPipeline = useCallback(async () => {
setPipelineLoading(true)
try {
const res = await fetch(`${API_PROXY}?action=pipeline-checkpoints`)
if (res.ok) {
const data = await res.json()
setPipelineState(data)
}
} catch (error) {
console.error('Failed to fetch pipeline:', error)
} finally {
setPipelineLoading(false)
}
}, [])
const fetchDsfaStatus = useCallback(async () => {
setDsfaLoading(true)
try {
const [statusRes, sourcesRes] = await Promise.all([
fetch(`${DSFA_API_PROXY}?action=status`),
fetch(`${DSFA_API_PROXY}?action=sources`),
])
if (statusRes.ok) {
const data = await statusRes.json()
setDsfaStatus(data)
}
if (sourcesRes.ok) {
const data = await sourcesRes.json()
setDsfaSources(data.sources || data || [])
}
} catch (error) {
console.error('Failed to fetch DSFA status:', error)
} finally {
setDsfaLoading(false)
}
}, [])
const fetchCustomDocuments = useCallback(async () => {
try {
const res = await fetch(`${API_PROXY}?action=custom-documents`)
if (res.ok) {
const data = await res.json()
setCustomDocuments(data.documents || [])
}
} catch (error) {
console.error('Failed to fetch custom documents:', error)
}
}, [])
const handleUpload = async () => {
if (!uploadFile || !uploadTitle || !uploadCode) return
setUploading(true)
try {
const formData = new FormData()
formData.append('file', uploadFile)
formData.append('title', uploadTitle)
formData.append('code', uploadCode)
formData.append('document_type', 'custom')
const res = await fetch(`${API_PROXY}?action=upload`, {
method: 'POST',
body: formData,
})
if (res.ok) {
setUploadFile(null)
setUploadTitle('')
setUploadCode('')
fetchCustomDocuments()
fetchStatus()
}
} catch (error) {
console.error('Upload failed:', error)
} finally {
setUploading(false)
}
}
const handleAddLink = async () => {
if (!linkUrl || !linkTitle || !linkCode) return
setAddingLink(true)
try {
const res = await fetch(`${API_PROXY}?action=add-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: linkUrl,
title: linkTitle,
code: linkCode,
document_type: 'custom',
}),
})
if (res.ok) {
setLinkUrl('')
setLinkTitle('')
setLinkCode('')
fetchCustomDocuments()
}
} catch (error) {
console.error('Add link failed:', error)
} finally {
setAddingLink(false)
}
}
const handleDeleteDocument = async (docId: string) => {
try {
const res = await fetch(`${API_PROXY}?action=delete-document&docId=${docId}`, {
method: 'DELETE',
})
if (res.ok) {
fetchCustomDocuments()
fetchStatus()
}
} catch (error) {
console.error('Delete failed:', error)
}
}
const handleStartPipeline = async (skipIngestion: boolean = false) => {
setPipelineStarting(true)
try {
const res = await fetch(`${API_PROXY}?action=start-pipeline`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
force_reindex: false,
skip_ingestion: skipIngestion,
}),
})
if (res.ok) {
setTimeout(() => {
fetchPipeline()
setPipelineStarting(false)
}, 2000)
} else {
setPipelineStarting(false)
}
} catch (error) {
console.error('Failed to start pipeline:', error)
setPipelineStarting(false)
}
}
const handleSearch = async () => {
if (!searchQuery.trim()) return
setSearching(true)
try {
const params = new URLSearchParams({
action: 'search',
query: searchQuery,
top_k: '5',
})
if (selectedRegulations.length > 0) {
params.append('regulations', selectedRegulations.join(','))
}
const res = await fetch(`${API_PROXY}?${params}`)
if (res.ok) {
const data = await res.json()
setSearchResults(data.results || [])
}
} catch (error) {
console.error('Search failed:', error)
} finally {
setSearching(false)
}
}
const triggerIngestion = async () => {
setIngestionRunning(true)
setIngestionLog(['Starte Re-Ingestion aller 19 Regulierungen...'])
try {
const res = await fetch(`${API_PROXY}?action=ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: true }),
})
if (res.ok) {
const data = await res.json()
setIngestionLog((prev) => [...prev, 'Ingestion gestartet. Job-ID: ' + (data.job_id || 'N/A')])
const checkStatus = setInterval(async () => {
try {
const statusRes = await fetch(`${API_PROXY}?action=ingestion-status`)
if (statusRes.ok) {
const statusData = await statusRes.json()
if (statusData.completed) {
clearInterval(checkStatus)
setIngestionRunning(false)
setIngestionLog((prev) => [...prev, 'Ingestion abgeschlossen!'])
fetchStatus()
} else if (statusData.current_regulation) {
setIngestionLog((prev) => [
...prev,
`Verarbeite: ${statusData.current_regulation} (${statusData.processed}/${statusData.total})`,
])
}
}
} catch {
// Ignore polling errors
}
}, 5000)
} else {
setIngestionLog((prev) => [...prev, 'Fehler: ' + res.statusText])
setIngestionRunning(false)
}
} catch (error) {
setIngestionLog((prev) => [...prev, 'Fehler: ' + String(error)])
setIngestionRunning(false)
}
}
const getRegulationChunks = (code: string): number => {
return collectionStatus?.regulations?.[code] || 0
}
const getTotalChunks = (): number => {
return collectionStatus?.totalPoints || 0
}
// Initial data fetch
useEffect(() => {
fetchStatus()
fetchDsfaStatus()
}, [fetchStatus, fetchDsfaStatus])
// Fetch pipeline when tab changes
useEffect(() => {
if (activeTab === 'pipeline') {
fetchPipeline()
}
}, [activeTab, fetchPipeline])
// Fetch custom documents when data tab is active
useEffect(() => {
if (activeTab === 'data') {
fetchCustomDocuments()
}
}, [activeTab, fetchCustomDocuments])
// Auto-refresh pipeline status when running
useEffect(() => {
if (activeTab !== 'pipeline' || !autoRefresh) return
const isRunning = pipelineState?.status === 'running'
if (isRunning) {
const interval = setInterval(() => {
fetchPipeline()
fetchStatus()
}, 5000)
return () => clearInterval(interval)
}
}, [activeTab, autoRefresh, pipelineState?.status, fetchPipeline, fetchStatus])
// Update elapsed time
useEffect(() => {
if (!pipelineState?.started_at || pipelineState?.status !== 'running') {
setElapsedTime('')
return
}
const updateElapsed = () => {
const start = new Date(pipelineState.started_at!).getTime()
const now = Date.now()
const diff = Math.floor((now - start) / 1000)
const hours = Math.floor(diff / 3600)
const minutes = Math.floor((diff % 3600) / 60)
const seconds = diff % 60
if (hours > 0) {
setElapsedTime(`${hours}h ${minutes}m ${seconds}s`)
} else if (minutes > 0) {
setElapsedTime(`${minutes}m ${seconds}s`)
} else {
setElapsedTime(`${seconds}s`)
}
}
updateElapsed()
const interval = setInterval(updateElapsed, 1000)
return () => clearInterval(interval)
}, [pipelineState?.started_at, pipelineState?.status])
return {
// Tab state
activeTab,
setActiveTab,
// Collection status
collectionStatus,
loading,
fetchStatus,
// Search
searchQuery,
setSearchQuery,
searchResults,
searching,
selectedRegulations,
setSelectedRegulations,
handleSearch,
// Ingestion
ingestionRunning,
ingestionLog,
triggerIngestion,
// Pipeline
pipelineState,
pipelineLoading,
pipelineStarting,
autoRefresh,
setAutoRefresh,
elapsedTime,
fetchPipeline,
handleStartPipeline,
// Regulation expansion
expandedRegulation,
setExpandedRegulation,
expandedDocTypes,
setExpandedDocTypes,
expandedMatrixDoc,
setExpandedMatrixDoc,
// DSFA
dsfaSources,
dsfaStatus,
dsfaLoading,
regulationCategory,
setRegulationCategory,
expandedDsfaSource,
setExpandedDsfaSource,
fetchDsfaStatus,
// Data tab
customDocuments,
uploadFile,
setUploadFile,
uploadTitle,
setUploadTitle,
uploadCode,
setUploadCode,
uploading,
handleUpload,
linkUrl,
setLinkUrl,
linkTitle,
setLinkTitle,
linkCode,
setLinkCode,
addingLink,
handleAddLink,
handleDeleteDocument,
fetchCustomDocuments,
// Helpers
getRegulationChunks,
getTotalChunks,
}
}
export type UseRAGPageReturn = ReturnType<typeof useRAGPage>
@@ -1,52 +0,0 @@
/**
* Constants and types for ChunkBrowserQA component.
*/
export type RegGroupKey =
| 'eu_regulation'
| 'eu_directive'
| 'de_law'
| 'at_law'
| 'ch_law'
| 'national_law'
| 'bsi_standard'
| 'eu_guideline'
| 'international_standard'
| 'other'
export const GROUP_LABELS: Record<RegGroupKey, string> = {
eu_regulation: 'EU Verordnungen',
eu_directive: 'EU Richtlinien',
de_law: 'DE Gesetze',
at_law: 'AT Gesetze',
ch_law: 'CH Gesetze',
national_law: 'Nationale Gesetze (EU)',
bsi_standard: 'BSI Standards',
eu_guideline: 'EDPB / Guidelines',
international_standard: 'Internationale Standards',
other: 'Sonstige',
}
export const GROUP_ORDER: RegGroupKey[] = [
'eu_regulation', 'eu_directive', 'de_law', 'at_law', 'ch_law',
'national_law', 'bsi_standard', 'eu_guideline', 'international_standard', 'other',
]
export const COLLECTIONS = [
'bp_compliance_gesetze',
'bp_compliance_ce',
'bp_compliance_datenschutz',
'bp_dsfa_corpus',
'bp_compliance_recht',
'bp_legal_templates',
'bp_nibis_eh',
]
export const STRUCTURAL_KEYS = new Set([
'article', 'artikel', 'paragraph', 'section_title', 'section', 'chapter',
'abschnitt', 'kapitel', 'pages', 'page',
])
export const HIDDEN_KEYS = new Set([
'text', 'content', 'chunk_text', 'id', 'embedding',
])
@@ -1,234 +0,0 @@
'use client'
import React from 'react'
import { STRUCTURAL_KEYS, HIDDEN_KEYS } from './ChunkBrowserConstants'
import { getChunkText, getStructuralInfo } from './ChunkBrowserHelpers'
import { RAG_PDF_MAPPING } from './rag-pdf-mapping'
import { REGULATIONS_IN_RAG } from '../rag-constants'
interface ChunkBrowserContentProps {
selectedRegulation: string | null
docLoading: boolean
docChunks: Record<string, unknown>[]
docChunkIndex: number
docTotalChunks: number
splitViewActive: boolean
chunksPerPage: number
pdfExists: boolean | null
}
export function ChunkBrowserContent({
selectedRegulation,
docLoading,
docChunks,
docChunkIndex,
docTotalChunks,
splitViewActive,
chunksPerPage,
pdfExists,
}: ChunkBrowserContentProps) {
const currentChunk = docChunks[docChunkIndex] || null
const prevChunk = docChunkIndex > 0 ? docChunks[docChunkIndex - 1] : null
const nextChunk = docChunkIndex < docChunks.length - 1 ? docChunks[docChunkIndex + 1] : null
const structInfo = getStructuralInfo(currentChunk)
// PDF page estimation
const estimatePdfPage = (chunk: Record<string, unknown> | null, chunkIdx: number): number => {
if (chunk) {
const pages = chunk.pages as number[] | undefined
if (Array.isArray(pages) && pages.length > 0) return pages[0]
const page = chunk.page as number | undefined
if (typeof page === 'number' && page > 0) return page
}
const mapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
const cpp = mapping?.chunksPerPage || chunksPerPage
return Math.floor(chunkIdx / cpp) + 1
}
const pdfPage = estimatePdfPage(currentChunk, docChunkIndex)
const pdfMapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
const pdfUrl = pdfMapping ? `/rag-originals/${pdfMapping.filename}#page=${pdfPage}` : null
// Overlap extraction
const getOverlapPrev = (): string => {
if (!prevChunk) return ''
const text = getChunkText(prevChunk)
return text.length > 150 ? '...' + text.slice(-150) : text
}
const getOverlapNext = (): string => {
if (!nextChunk) return ''
const text = getChunkText(nextChunk)
return text.length > 150 ? text.slice(0, 150) + '...' : text
}
if (!selectedRegulation) {
return (
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
<div className="text-center text-slate-400 space-y-2">
<div className="text-4xl">&#128269;</div>
<p className="text-sm">Dokument in der Sidebar auswaehlen, um QA zu starten.</p>
<p className="text-xs text-slate-300">Pfeiltasten: Chunk vor/zurueck</p>
</div>
</div>
)
}
if (docLoading) {
return (
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
<div className="text-center text-slate-500 space-y-2">
<div className="animate-spin text-3xl">&#9881;</div>
<p className="text-sm">Chunks werden geladen...</p>
<p className="text-xs text-slate-400">
{selectedRegulation}: {REGULATIONS_IN_RAG[selectedRegulation]?.chunks.toLocaleString() || '?'} Chunks erwartet
</p>
</div>
</div>
)
}
return (
<div className={`flex-1 grid gap-3 min-h-0 ${splitViewActive ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Chunk-Text Panel */}
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">Chunk-Text</span>
<div className="flex items-center gap-2">
{structInfo.article && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs font-medium rounded border border-blue-200">
{structInfo.article}
</span>
)}
{structInfo.section && (
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 text-xs rounded border border-purple-200">
{structInfo.section}
</span>
)}
<span className="text-xs text-slate-400 tabular-nums">
#{docChunkIndex} / {docTotalChunks - 1}
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0 p-4 space-y-3">
{/* Overlap from previous chunk */}
{prevChunk && (
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
<div className="font-medium text-amber-600 mb-1">&#8593; Ende vorheriger Chunk #{docChunkIndex - 1}</div>
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapPrev()}</p>
</div>
)}
{/* Current chunk text */}
{currentChunk ? (
<div className="text-sm text-slate-800 whitespace-pre-wrap break-words leading-relaxed border-l-2 border-teal-400 pl-3">
{getChunkText(currentChunk)}
</div>
) : (
<div className="text-sm text-slate-400 italic">Kein Chunk-Text vorhanden.</div>
)}
{/* Overlap from next chunk */}
{nextChunk && (
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
<div className="font-medium text-amber-600 mb-1">&#8595; Anfang naechster Chunk #{docChunkIndex + 1}</div>
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapNext()}</p>
</div>
)}
{/* Metadata */}
{currentChunk && (
<div className="mt-4 pt-3 border-t border-slate-100">
<div className="text-xs font-medium text-slate-500 mb-2">Metadaten</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{Object.entries(currentChunk)
.filter(([k]) => !HIDDEN_KEYS.has(k))
.sort(([a], [b]) => {
const aStruct = STRUCTURAL_KEYS.has(a) ? 0 : 1
const bStruct = STRUCTURAL_KEYS.has(b) ? 0 : 1
return aStruct - bStruct || a.localeCompare(b)
})
.map(([k, v]) => (
<div key={k} className={`flex gap-1 ${STRUCTURAL_KEYS.has(k) ? 'col-span-2 font-medium' : ''}`}>
<span className="font-medium text-slate-500 flex-shrink-0">{k}:</span>
<span className="text-slate-700 break-all">
{Array.isArray(v) ? v.join(', ') : String(v)}
</span>
</div>
))}
</div>
<div className="mt-3 pt-2 border-t border-slate-50">
<div className="text-xs text-slate-400">
Chunk-Laenge: {getChunkText(currentChunk).length} Zeichen
{getChunkText(currentChunk).length < 50 && (
<span className="ml-2 text-orange-500 font-medium">&#9888; Sehr kurz</span>
)}
{getChunkText(currentChunk).length > 2000 && (
<span className="ml-2 text-orange-500 font-medium">&#9888; Sehr lang</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* PDF-Viewer Panel */}
{splitViewActive && (
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">Original-PDF</span>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400">
Seite ~{pdfPage}
{pdfMapping?.totalPages ? ` / ${pdfMapping.totalPages}` : ''}
</span>
{pdfUrl && (
<a
href={pdfUrl.split('#')[0]}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-teal-600 hover:text-teal-800 underline"
>
Oeffnen &#8599;
</a>
)}
</div>
</div>
<div className="flex-1 min-h-0 relative">
{pdfUrl && pdfExists ? (
<iframe
key={`${selectedRegulation}-${pdfPage}`}
src={pdfUrl}
className="absolute inset-0 w-full h-full border-0"
title="Original PDF"
/>
) : (
<div className="flex items-center justify-center h-full text-slate-400 text-sm p-4">
<div className="text-center space-y-2">
<div className="text-3xl">&#128196;</div>
{!pdfMapping ? (
<>
<p>Kein PDF-Mapping fuer {selectedRegulation}.</p>
<p className="text-xs">rag-pdf-mapping.ts ergaenzen.</p>
</>
) : pdfExists === false ? (
<>
<p className="font-medium text-orange-600">PDF nicht vorhanden</p>
<p className="text-xs">Datei <code className="bg-slate-100 px-1 rounded">{pdfMapping.filename}</code> fehlt in ~/rag-originals/</p>
<p className="text-xs mt-1">Bitte manuell herunterladen und dort ablegen.</p>
</>
) : (
<p>PDF wird geprueft...</p>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
@@ -1,39 +0,0 @@
/**
* Helper functions for ChunkBrowserQA component.
*/
import { REGULATION_INFO } from '../rag-constants'
/** Get text content from a chunk */
export function getChunkText(chunk: Record<string, unknown> | null): string {
if (!chunk) return ''
return String(chunk.chunk_text || chunk.text || chunk.content || '')
}
/** Extract structural metadata for prominent display */
export function getStructuralInfo(
chunk: Record<string, unknown> | null
): { article?: string; section?: string; pages?: string } {
if (!chunk) return {}
const result: { article?: string; section?: string; pages?: string } = {}
// Article / paragraph
const article = chunk.article || chunk.artikel || chunk.paragraph || chunk.section_title
if (article) result.article = String(article)
// Section
const section = chunk.section || chunk.chapter || chunk.abschnitt || chunk.kapitel
if (section) result.section = String(section)
// Pages
const pages = chunk.pages as number[] | undefined
if (Array.isArray(pages) && pages.length > 0) {
result.pages = pages.length === 1 ? `S. ${pages[0]}` : `S. ${pages[0]}-${pages[pages.length - 1]}`
} else if (chunk.page) {
result.pages = `S. ${chunk.page}`
}
return result
}
/** Regulation name lookup */
export function getRegName(code: string): string {
const reg = REGULATION_INFO.find(r => r.code === code)
return reg?.name || code
}
@@ -1,282 +0,0 @@
'use client'
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { RAG_PDF_MAPPING } from './rag-pdf-mapping'
import { REGULATIONS_IN_RAG, REGULATION_INFO } from '../rag-constants'
import { RegGroupKey } from './ChunkBrowserConstants'
import { getStructuralInfo } from './ChunkBrowserHelpers'
import { ChunkBrowserSidebar } from './ChunkBrowserSidebar'
import { ChunkBrowserToolbar } from './ChunkBrowserToolbar'
import { ChunkBrowserContent } from './ChunkBrowserContent'
interface ChunkBrowserQAProps {
apiProxy: string
}
export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
// Filter-Sidebar
const [selectedRegulation, setSelectedRegulation] = useState<string | null>(null)
const [regulationCounts, setRegulationCounts] = useState<Record<string, number>>({})
const [filterSearch, setFilterSearch] = useState('')
const [countsLoading, setCountsLoading] = useState(false)
// Dokument-Chunks (sequenziell)
const [docChunks, setDocChunks] = useState<Record<string, unknown>[]>([])
const [docChunkIndex, setDocChunkIndex] = useState(0)
const [docTotalChunks, setDocTotalChunks] = useState(0)
const [docLoading, setDocLoading] = useState(false)
const docChunksRef = useRef(docChunks)
docChunksRef.current = docChunks
// Split-View
const [splitViewActive, setSplitViewActive] = useState(true)
const [chunksPerPage, setChunksPerPage] = useState(6)
const [fullscreen, setFullscreen] = useState(false)
// Collection
const [collection, setCollection] = useState('bp_compliance_ce')
// PDF existence check
const [pdfExists, setPdfExists] = useState<boolean | null>(null)
// Sidebar collapsed groups
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
// Build grouped regulations for sidebar
const regulationsInCollection = Object.entries(REGULATIONS_IN_RAG)
.filter(([, info]) => info.collection === collection)
.map(([code]) => code)
const groupedRegulations = useMemo(() => {
const groups: Record<RegGroupKey, { code: string; name: string; type: string }[]> = {
eu_regulation: [], eu_directive: [], de_law: [], at_law: [], ch_law: [],
national_law: [], bsi_standard: [], eu_guideline: [], international_standard: [], other: [],
}
for (const code of regulationsInCollection) {
const reg = REGULATION_INFO.find(r => r.code === code)
const type = (reg?.type || 'other') as RegGroupKey
const groupKey = type in groups ? type : 'other'
groups[groupKey].push({ code, name: reg?.name || code, type: reg?.type || 'unknown' })
}
return groups
}, [regulationsInCollection.join(',')])
// Load regulation counts for current collection
const loadRegulationCounts = useCallback(async (col: string) => {
const entries = Object.entries(REGULATIONS_IN_RAG)
.filter(([, info]) => info.collection === col && info.qdrant_id)
if (entries.length === 0) return
const qdrantIdToCode: Record<string, string[]> = {}
for (const [code, info] of entries) {
if (!qdrantIdToCode[info.qdrant_id]) qdrantIdToCode[info.qdrant_id] = []
qdrantIdToCode[info.qdrant_id].push(code)
}
const uniqueQdrantIds = Object.keys(qdrantIdToCode)
setCountsLoading(true)
try {
const params = new URLSearchParams({
action: 'regulation-counts-batch',
collection: col,
qdrant_ids: uniqueQdrantIds.join(','),
})
const res = await fetch(`${apiProxy}?${params}`)
if (res.ok) {
const data = await res.json()
const mapped: Record<string, number> = {}
for (const [qid, count] of Object.entries(data.counts as Record<string, number>)) {
const codes = qdrantIdToCode[qid] || []
for (const code of codes) { mapped[code] = count }
}
setRegulationCounts(prev => ({ ...prev, ...mapped }))
}
} catch (error) {
console.error('Failed to load regulation counts:', error)
} finally {
setCountsLoading(false)
}
}, [apiProxy])
// Load all chunks for a regulation (paginated scroll)
const loadDocumentChunks = useCallback(async (regulationCode: string) => {
const ragInfo = REGULATIONS_IN_RAG[regulationCode]
if (!ragInfo || !ragInfo.qdrant_id) return
setDocLoading(true)
setDocChunks([])
setDocChunkIndex(0)
setDocTotalChunks(0)
const allChunks: Record<string, unknown>[] = []
let offset: string | null = null
try {
let safety = 0
do {
const params = new URLSearchParams({
action: 'scroll',
collection: ragInfo.collection,
limit: '100',
filter_key: 'regulation_id',
filter_value: ragInfo.qdrant_id,
})
if (offset) params.append('offset', offset)
const res = await fetch(`${apiProxy}?${params}`)
if (!res.ok) break
const data = await res.json()
const chunks = data.chunks || []
allChunks.push(...chunks)
offset = data.next_offset || null
safety++
} while (offset && safety < 200)
allChunks.sort((a, b) => {
const ai = Number(a.chunk_index ?? a.chunk_id ?? 0)
const bi = Number(b.chunk_index ?? b.chunk_id ?? 0)
return ai - bi
})
setDocChunks(allChunks)
setDocTotalChunks(allChunks.length)
setDocChunkIndex(0)
} catch (error) {
console.error('Failed to load document chunks:', error)
} finally {
setDocLoading(false)
}
}, [apiProxy])
// Initial load
useEffect(() => {
loadRegulationCounts(collection)
}, [collection, loadRegulationCounts])
// Check PDF existence when regulation changes
useEffect(() => {
if (!selectedRegulation) { setPdfExists(null); return }
const mapping = RAG_PDF_MAPPING[selectedRegulation]
if (!mapping) { setPdfExists(false); return }
const url = `/rag-originals/${mapping.filename}`
fetch(url, { method: 'HEAD' })
.then(res => setPdfExists(res.ok))
.catch(() => setPdfExists(false))
}, [selectedRegulation])
// Keyboard navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' && fullscreen) {
e.preventDefault()
setFullscreen(false)
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
setDocChunkIndex(i => Math.max(0, i - 1))
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
setDocChunkIndex(i => Math.min(docChunksRef.current.length - 1, i + 1))
}
}, [fullscreen])
useEffect(() => {
if (fullscreen || (selectedRegulation && docChunks.length > 0)) {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [selectedRegulation, docChunks.length, handleKeyDown, fullscreen])
// Handlers
const handleSelectRegulation = (code: string) => {
setSelectedRegulation(code)
loadDocumentChunks(code)
}
const handleCollectionChange = (col: string) => {
setCollection(col)
setSelectedRegulation(null)
setDocChunks([])
setDocChunkIndex(0)
setDocTotalChunks(0)
setRegulationCounts({})
}
const toggleGroup = (group: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(group)) next.delete(group)
else next.add(group)
return next
})
}
// Filter sidebar items
const filteredRegulations = useMemo(() => {
if (!filterSearch.trim()) return groupedRegulations
const term = filterSearch.toLowerCase()
const filtered: typeof groupedRegulations = {
eu_regulation: [], eu_directive: [], de_law: [], at_law: [], ch_law: [],
national_law: [], bsi_standard: [], eu_guideline: [], international_standard: [], other: [],
}
for (const [group, items] of Object.entries(groupedRegulations)) {
filtered[group as RegGroupKey] = items.filter(
r => r.code.toLowerCase().includes(term) || r.name.toLowerCase().includes(term)
)
}
return filtered
}, [groupedRegulations, filterSearch])
const currentChunk = docChunks[docChunkIndex] || null
const structInfo = getStructuralInfo(currentChunk)
return (
<div
className={`flex flex-col ${fullscreen ? 'fixed inset-0 z-50 bg-slate-100 p-4' : ''}`}
style={fullscreen ? { height: '100vh' } : { height: 'calc(100vh - 220px)' }}
>
<ChunkBrowserToolbar
collection={collection}
onCollectionChange={handleCollectionChange}
selectedRegulation={selectedRegulation}
structInfo={structInfo}
docChunkIndex={docChunkIndex}
docTotalChunks={docTotalChunks}
docChunksLength={docChunks.length}
chunksPerPage={chunksPerPage}
setChunksPerPage={setChunksPerPage}
splitViewActive={splitViewActive}
setSplitViewActive={setSplitViewActive}
fullscreen={fullscreen}
setFullscreen={setFullscreen}
onPrev={() => { if (docChunkIndex > 0) setDocChunkIndex(i => i - 1) }}
onNext={() => { if (docChunkIndex < docChunks.length - 1) setDocChunkIndex(i => i + 1) }}
onJumpTo={setDocChunkIndex}
/>
<div className="flex gap-3 flex-1 min-h-0">
<ChunkBrowserSidebar
filterSearch={filterSearch}
setFilterSearch={setFilterSearch}
countsLoading={countsLoading}
filteredRegulations={filteredRegulations}
regulationCounts={regulationCounts}
selectedRegulation={selectedRegulation}
collapsedGroups={collapsedGroups}
onSelectRegulation={handleSelectRegulation}
onToggleGroup={toggleGroup}
/>
<ChunkBrowserContent
selectedRegulation={selectedRegulation}
docLoading={docLoading}
docChunks={docChunks}
docChunkIndex={docChunkIndex}
docTotalChunks={docTotalChunks}
splitViewActive={splitViewActive}
chunksPerPage={chunksPerPage}
pdfExists={pdfExists}
/>
</div>
</div>
)
}
@@ -1,81 +0,0 @@
'use client'
import React from 'react'
import { RegGroupKey, GROUP_LABELS, GROUP_ORDER } from './ChunkBrowserConstants'
interface ChunkBrowserSidebarProps {
filterSearch: string
setFilterSearch: (v: string) => void
countsLoading: boolean
filteredRegulations: Record<RegGroupKey, { code: string; name: string; type: string }[]>
regulationCounts: Record<string, number>
selectedRegulation: string | null
collapsedGroups: Set<string>
onSelectRegulation: (code: string) => void
onToggleGroup: (group: string) => void
}
export function ChunkBrowserSidebar({
filterSearch,
setFilterSearch,
countsLoading,
filteredRegulations,
regulationCounts,
selectedRegulation,
collapsedGroups,
onSelectRegulation,
onToggleGroup,
}: ChunkBrowserSidebarProps) {
return (
<div className="w-56 flex-shrink-0 bg-white rounded-xl border border-slate-200 flex flex-col min-h-0">
<div className="flex-shrink-0 p-3 border-b border-slate-100">
<input
type="text"
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="Suche..."
className="w-full px-2 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
/>
{countsLoading && (
<div className="text-xs text-slate-400 mt-1 animate-pulse">Counts laden...</div>
)}
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{GROUP_ORDER.map(group => {
const items = filteredRegulations[group]
if (items.length === 0) return null
const isCollapsed = collapsedGroups.has(group)
return (
<div key={group}>
<button
onClick={() => onToggleGroup(group)}
className="w-full px-3 py-1.5 text-left text-xs font-semibold text-slate-500 bg-slate-50 hover:bg-slate-100 flex items-center justify-between sticky top-0 z-10"
>
<span>{GROUP_LABELS[group]}</span>
<span className="text-slate-400">{isCollapsed ? '+' : '-'}</span>
</button>
{!isCollapsed && items.map(reg => {
const count = regulationCounts[reg.code] ?? 0
const isSelected = selectedRegulation === reg.code
return (
<button
key={reg.code}
onClick={() => onSelectRegulation(reg.code)}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center justify-between hover:bg-teal-50 transition-colors ${
isSelected ? 'bg-teal-100 text-teal-900 font-medium' : 'text-slate-700'
}`}
>
<span className="truncate text-xs">{reg.name || reg.code}</span>
<span className={`text-xs tabular-nums flex-shrink-0 ml-1 ${count > 0 ? 'text-slate-500' : 'text-slate-300'}`}>
{count > 0 ? count.toLocaleString() : '\u2014'}
</span>
</button>
)
})}
</div>
)
})}
</div>
</div>
)
}
@@ -1,142 +0,0 @@
'use client'
import React from 'react'
import { COLLECTIONS } from './ChunkBrowserConstants'
import { getRegName } from './ChunkBrowserHelpers'
interface ChunkBrowserToolbarProps {
collection: string
onCollectionChange: (col: string) => void
selectedRegulation: string | null
structInfo: { article?: string; section?: string; pages?: string }
docChunkIndex: number
docTotalChunks: number
docChunksLength: number
chunksPerPage: number
setChunksPerPage: (v: number) => void
splitViewActive: boolean
setSplitViewActive: (v: boolean) => void
fullscreen: boolean
setFullscreen: (v: boolean) => void
onPrev: () => void
onNext: () => void
onJumpTo: (idx: number) => void
}
export function ChunkBrowserToolbar({
collection,
onCollectionChange,
selectedRegulation,
structInfo,
docChunkIndex,
docTotalChunks,
docChunksLength,
chunksPerPage,
setChunksPerPage,
splitViewActive,
setSplitViewActive,
fullscreen,
setFullscreen,
onPrev,
onNext,
onJumpTo,
}: ChunkBrowserToolbarProps) {
return (
<div className="flex-shrink-0 bg-white rounded-xl border border-slate-200 p-3 mb-3">
<div className="flex flex-wrap items-center gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Collection</label>
<select
value={collection}
onChange={(e) => onCollectionChange(e.target.value)}
className="px-3 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
>
{COLLECTIONS.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{selectedRegulation && (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
{selectedRegulation} &mdash; {getRegName(selectedRegulation)}
</span>
{structInfo.article && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 text-xs font-medium rounded">
{structInfo.article}
</span>
)}
{structInfo.pages && (
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">
{structInfo.pages}
</span>
)}
</div>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={onPrev}
disabled={docChunkIndex === 0}
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
>
&#9664; Zurueck
</button>
<span className="text-sm font-mono text-slate-600 min-w-[80px] text-center">
{docChunkIndex + 1} / {docTotalChunks}
</span>
<button
onClick={onNext}
disabled={docChunkIndex >= docChunksLength - 1}
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
>
Weiter &#9654;
</button>
<input
type="number"
min={1}
max={docTotalChunks}
value={docChunkIndex + 1}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!isNaN(v) && v >= 1 && v <= docTotalChunks) onJumpTo(v - 1)
}}
className="w-16 px-2 py-1 border rounded text-xs text-center"
title="Springe zu Chunk Nr."
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500">Chunks/Seite:</label>
<select
value={chunksPerPage}
onChange={(e) => setChunksPerPage(Number(e.target.value))}
className="px-2 py-1 border rounded text-xs"
>
{[3, 4, 5, 6, 8, 10, 12, 15, 20].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
<button
onClick={() => setSplitViewActive(!splitViewActive)}
className={`px-3 py-1 text-xs rounded-lg border ${
splitViewActive ? 'bg-teal-50 border-teal-300 text-teal-700' : 'bg-slate-50 border-slate-300 text-slate-600'
}`}
>
{splitViewActive ? 'Split-View an' : 'Split-View aus'}
</button>
<button
onClick={() => setFullscreen(!fullscreen)}
className={`px-3 py-1 text-xs rounded-lg border ${
fullscreen ? 'bg-indigo-50 border-indigo-300 text-indigo-700' : 'bg-slate-50 border-slate-300 text-slate-600'
}`}
title={fullscreen ? 'Vollbild beenden (Esc)' : 'Vollbild'}
>
{fullscreen ? '\u2715 Vollbild beenden' : '\u2716 Vollbild'}
</button>
</div>
</>
)}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More