fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,614 @@
# Abiturkorrektur-System - Entwicklerdokumentation
**WICHTIG: Diese Datei wird bei jedem Compacting gelesen. Alle Implementierungsdetails hier dokumentieren!**
---
## 1. Projektziel
Entwicklung eines KI-gestützten Korrektur-Systems für Deutsch-Abiturklausuren:
- **Zielgruppe**: Lehrer in Niedersachsen (Pilot), später alle Bundesländer
- **Kernproblem**: Erstkorrektur dauert 6 Stunden pro Arbeit
- **Lösung**: KI schlägt Bewertungen vor, Lehrer bestätigt/korrigiert
---
## 2. Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (Next.js) │
│ /website/app/admin/klausur-korrektur/ │
│ - page.tsx (Klausur-Liste) │
│ - [klausurId]/page.tsx (Studenten-Liste) │
│ - [klausurId]/[studentId]/page.tsx (Korrektur-Workspace) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ klausur-service (FastAPI) │
│ Port 8086 - /klausur-service/backend/main.py │
│ - Klausur CRUD (/api/v1/klausuren) │
│ - Student Work (/api/v1/students) │
│ - Annotations (/api/v1/annotations) [NEU] │
│ - Gutachten Generation │
│ - Fairness Analysis │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Infrastruktur │
│ - Qdrant (Vektor-DB für RAG) │
│ - MinIO (Datei-Storage) │
│ - PostgreSQL (Metadaten) │
│ - Embedding-Service (Port 8087) │
└─────────────────────────────────────────────────────────────┘
```
---
## 3. Bestehende Backend-Komponenten (NUTZEN!)
### 3.1 Klausur-Service API (main.py)
```python
# Bereits implementiert:
GET/POST /api/v1/klausuren # Klausur CRUD
GET /api/v1/klausuren/{id} # Klausur Details
POST /api/v1/klausuren/{id}/students # Student Work hochladen
GET /api/v1/klausuren/{id}/students # Studenten-Liste
PUT /api/v1/students/{id}/criteria # Kriterien bewerten
PUT /api/v1/students/{id}/gutachten # Gutachten speichern
POST /api/v1/students/{id}/gutachten/generate # Gutachten generieren (KI)
GET /api/v1/klausuren/{id}/fairness # Fairness-Analyse
GET /api/v1/grade-info # Notensystem-Info
```
### 3.2 Datenmodelle (main.py)
```python
@dataclass
class Klausur:
id: str
title: str
subject: str = "Deutsch"
year: int = 2025
semester: str = "Abitur"
modus: str = "abitur" # oder "vorabitur"
eh_id: Optional[str] = None # Erwartungshorizont-Referenz
@dataclass
class StudentKlausur:
id: str
klausur_id: str
anonym_id: str
file_path: str
ocr_text: str = ""
criteria_scores: Dict[str, int] = field(default_factory=dict)
gutachten: str = ""
status: str = "UPLOADED"
raw_points: int = 0
grade_points: int = 0
# Status-Workflow:
# UPLOADED → OCR_PROCESSING → OCR_COMPLETE → ANALYZING →
# FIRST_EXAMINER → SECOND_EXAMINER → COMPLETED
```
### 3.3 Notensystem (15-Punkte)
```python
GRADE_THRESHOLDS = {
15: 95, 14: 90, 13: 85, 12: 80, 11: 75,
10: 70, 9: 65, 8: 60, 7: 55, 6: 50,
5: 45, 4: 40, 3: 33, 2: 27, 1: 20, 0: 0
}
DEFAULT_CRITERIA = {
"rechtschreibung": {"name": "Rechtschreibung", "weight": 15},
"grammatik": {"name": "Grammatik", "weight": 15},
"inhalt": {"name": "Inhalt", "weight": 40},
"struktur": {"name": "Struktur", "weight": 15},
"stil": {"name": "Stil", "weight": 15}
}
```
---
## 4. NEU ZU IMPLEMENTIEREN
### Phase 1: Korrektur-Workspace MVP
#### 4.1 Frontend-Struktur
```
/website/app/admin/klausur-korrektur/
├── page.tsx # Klausur-Übersicht (Liste aller Klausuren)
├── types.ts # TypeScript Interfaces
├── [klausurId]/
│ ├── page.tsx # Studenten-Liste einer Klausur
│ └── [studentId]/
│ └── page.tsx # Korrektur-Workspace (2/3-1/3)
└── components/
├── KlausurCard.tsx # Klausur in Liste
├── StudentList.tsx # Studenten-Übersicht
├── DocumentViewer.tsx # PDF/Bild-Anzeige (links, 2/3)
├── AnnotationLayer.tsx # SVG-Overlay für Markierungen
├── AnnotationToolbar.tsx # Werkzeuge
├── CorrectionPanel.tsx # Bewertungs-Panel (rechts, 1/3)
├── CriteriaScoreCard.tsx # Einzelnes Kriterium
├── EHSuggestionPanel.tsx # EH-Vorschläge via RAG
├── GutachtenEditor.tsx # Gutachten bearbeiten
└── StudentNavigation.tsx # Prev/Next Navigation
```
#### 4.2 Annotations-Backend (NEU in main.py)
```python
# Neues Datenmodell:
@dataclass
class Annotation:
id: str
student_work_id: str
page: int
position: dict # {x, y, width, height} in % (0-100)
type: str # 'rechtschreibung' | 'grammatik' | 'inhalt' | 'struktur' | 'stil' | 'comment'
text: str # Kommentar-Text
severity: str # 'minor' | 'major' | 'critical'
suggestion: str # Korrekturvorschlag (bei RS/Gram)
created_by: str # User-ID (EK oder ZK)
created_at: datetime
role: str # 'first_examiner' | 'second_examiner'
linked_criterion: Optional[str] # Verknüpfung zu Kriterium
# Neue Endpoints:
POST /api/v1/students/{id}/annotations # Erstellen
GET /api/v1/students/{id}/annotations # Abrufen
PUT /api/v1/annotations/{id} # Ändern
DELETE /api/v1/annotations/{id} # Löschen
```
#### 4.3 UI-Layout Spezifikation
```
┌──────────────────────────────────────────────────────────────────────┐
│ Header: Klausur-Titel | Student: Anonym-123 | [← Prev] [5/24] [Next →]│
├─────────────────────────────────────────┬────────────────────────────┤
│ │ Tabs: [Kriterien] [Gutachten]│
│ ┌─────────────────────────────────┐ │ │
│ │ │ │ ▼ Rechtschreibung (15%) │
│ │ Dokument-Anzeige │ │ [====|====] 70/100 │
│ │ (PDF/Bild mit Zoom) │ │ 12 Fehler markiert │
│ │ │ │ │
│ │ + Annotation-Overlay │ │ ▼ Grammatik (15%) │
│ │ (SVG Layer) │ │ [====|====] 80/100 │
│ │ │ │ │
│ │ │ │ ▼ Inhalt (40%) │
│ │ │ │ [====|====] 65/100 │
│ │ │ │ EH-Vorschläge: [Laden] │
│ └─────────────────────────────────┘ │ │
│ │ ▼ Struktur (15%) │
│ Toolbar: [RS] [Gram] [Kommentar] │ [====|====] 75/100 │
│ [Zoom+] [Zoom-] [Fit] │ │
│ │ ▼ Stil (15%) │
│ Seiten: [1] [2] [3] [4] [5] │ [====|====] 70/100 │
│ │ │
│ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │ Gesamtnote: 10 Punkte (2-) │
│ │ [Gutachten generieren] │
│ │ [Speichern] [Abschließen] │
├─────────────────────────────────────────┴────────────────────────────┤
│ 2/3 Breite │ 1/3 Breite │
└──────────────────────────────────────────────────────────────────────┘
```
---
## 5. Implementierungs-Reihenfolge
### Phase 1.1: Grundgerüst (AKTUELL)
1. ✅ Dokumentation erstellen
2. [ ] `/website/app/admin/klausur-korrektur/page.tsx` - Klausur-Liste
3. [ ] `/website/app/admin/klausur-korrektur/types.ts` - TypeScript Types
4. [ ] Navigation in AdminLayout.tsx hinzufügen
5. [ ] Deploy + Test
### Phase 1.2: Korrektur-Workspace
1. [ ] `[klausurId]/page.tsx` - Studenten-Liste
2. [ ] `[klausurId]/[studentId]/page.tsx` - Workspace
3. [ ] `components/DocumentViewer.tsx` - Bild/PDF Anzeige
4. [ ] `components/CorrectionPanel.tsx` - Bewertungs-Panel
5. [ ] Deploy + Test mit Lehrer
### Phase 1.3: Annotations-System
1. [ ] Backend: Annotations-Endpoints in main.py
2. [ ] `components/AnnotationLayer.tsx` - SVG Overlay
3. [ ] `components/AnnotationToolbar.tsx` - Werkzeuge
4. [ ] Farbkodierung: RS=rot, Gram=blau, Inhalt=grün
5. [ ] Deploy + Test
### Phase 1.4: EH-Integration
1. [ ] `components/EHSuggestionPanel.tsx`
2. [ ] Backend: `/api/v1/students/{id}/eh-suggestions`
3. [ ] RAG-Query mit Student-Text
4. [ ] Deploy + Test
### Phase 1.5: Gutachten-Editor
1. [ ] `components/GutachtenEditor.tsx`
2. [ ] Beleg-Verlinkung zu Annotations
3. [ ] Gutachten-Generierung Button
4. [ ] Deploy + Test
---
## 6. API-Konfiguration
```typescript
// Frontend API Base URLs
const KLAUSUR_SERVICE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Endpoints:
// Klausuren
GET ${KLAUSUR_SERVICE}/api/v1/klausuren
POST ${KLAUSUR_SERVICE}/api/v1/klausuren
GET ${KLAUSUR_SERVICE}/api/v1/klausuren/{id}
GET ${KLAUSUR_SERVICE}/api/v1/klausuren/{id}/students
// Studenten
GET ${KLAUSUR_SERVICE}/api/v1/students/{id}
GET ${KLAUSUR_SERVICE}/api/v1/students/{id}/file // Dokument-Download
PUT ${KLAUSUR_SERVICE}/api/v1/students/{id}/criteria
PUT ${KLAUSUR_SERVICE}/api/v1/students/{id}/gutachten
POST ${KLAUSUR_SERVICE}/api/v1/students/{id}/gutachten/generate
// Annotations (NEU)
GET ${KLAUSUR_SERVICE}/api/v1/students/{id}/annotations
POST ${KLAUSUR_SERVICE}/api/v1/students/{id}/annotations
PUT ${KLAUSUR_SERVICE}/api/v1/annotations/{id}
DELETE ${KLAUSUR_SERVICE}/api/v1/annotations/{id}
// System
GET ${KLAUSUR_SERVICE}/api/v1/grade-info
```
---
## 7. Deployment-Prozess
```bash
# 1. Dateien auf Mac Mini synchronisieren
rsync -avz --delete \
--exclude 'node_modules' --exclude '.next' --exclude '.git' \
/Users/benjaminadmin/Projekte/breakpilot-pwa/website/ \
macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/website/
# 2. Website-Container neu bauen
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
build --no-cache website"
# 3. Container neu starten
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
up -d website"
# 4. Testen unter:
# http://macmini:3000/admin/klausur-korrektur
```
---
## 8. Bundesland-Spezifika (Niedersachsen Pilot)
```json
// /klausur-service/backend/policies/bundeslaender.json
{
"NI": {
"name": "Niedersachsen",
"grading_mode": "points_15",
"requires_gutachten": true,
"zk_visibility": "full", // ZK sieht EK-Korrektur
"third_correction_threshold": 4, // Ab 4 Punkte Diff
"colors": {
"first_examiner": "#dc2626", // Rot
"second_examiner": "#16a34a" // Grün
},
"criteria_weights": {
"rechtschreibung": 15,
"grammatik": 15,
"inhalt": 40,
"struktur": 15,
"stil": 15
}
}
}
```
---
## 9. Wichtige Dateien (Referenz)
| Datei | Beschreibung |
|-------|--------------|
| `/klausur-service/backend/main.py` | Haupt-API, alle Endpoints |
| `/klausur-service/backend/eh_pipeline.py` | BYOEH Verarbeitung |
| `/klausur-service/backend/qdrant_service.py` | RAG Vector-Suche |
| `/klausur-service/backend/hybrid_search.py` | Hybrid Search |
| `/website/components/admin/AdminLayout.tsx` | Admin Navigation |
| `/website/app/admin/ocr-labeling/page.tsx` | Referenz für 2/3-1/3 Layout |
---
## 10. Testing-Checkliste
### Nach jeder Phase:
- [ ] Seite lädt ohne Fehler
- [ ] API-Calls funktionieren (DevTools Network)
- [ ] Responsives Layout korrekt
- [ ] Lehrer kann Workflow durchführen
### Lehrer-Test-Szenarien:
1. Klausur erstellen
2. 3+ Studentenarbeiten hochladen
3. Erste Arbeit korrigieren (alle Kriterien)
4. Annotations setzen
5. Gutachten generieren
6. Zur nächsten Arbeit navigieren
7. Fairness-Check nach allen Arbeiten
---
## 11. Phase 2: Zweitkorrektur-System (NEU)
### 11.1 Neue Backend-Endpoints (main.py)
```python
# Zweitkorrektur Workflow
POST /api/v1/students/{id}/start-zweitkorrektur # ZK starten (nach EK)
POST /api/v1/students/{id}/submit-zweitkorrektur # ZK-Ergebnis abgeben
# Einigung (bei Diff 3 Punkte)
POST /api/v1/students/{id}/einigung # Einigung einreichen
# Drittkorrektur (bei Diff >= 4 Punkte)
POST /api/v1/students/{id}/assign-drittkorrektor # DK zuweisen
POST /api/v1/students/{id}/submit-drittkorrektur # DK-Ergebnis (final)
# Workflow-Status & Visibility-Filtering
GET /api/v1/students/{id}/examiner-workflow # Workflow-Status abrufen
GET /api/v1/students/{id}/annotations-filtered # Policy-gefilterte Annotations
```
### 11.2 Workflow-Status
```python
class ExaminerWorkflowStatus(str, Enum):
NOT_STARTED = "not_started"
EK_IN_PROGRESS = "ek_in_progress"
EK_COMPLETED = "ek_completed"
ZK_ASSIGNED = "zk_assigned"
ZK_IN_PROGRESS = "zk_in_progress"
ZK_COMPLETED = "zk_completed"
EINIGUNG_REQUIRED = "einigung_required"
EINIGUNG_COMPLETED = "einigung_completed"
DRITTKORREKTUR_REQUIRED = "drittkorrektur_required"
DRITTKORREKTUR_ASSIGNED = "drittkorrektur_assigned"
DRITTKORREKTUR_IN_PROGRESS = "drittkorrektur_in_progress"
COMPLETED = "completed"
```
### 11.3 Visibility-Regeln (aus bundeslaender.json)
| Modus | ZK sieht EK-Annotations | ZK sieht EK-Note | ZK sieht EK-Gutachten |
|-------|-------------------------|------------------|----------------------|
| `blind` | Nein | Nein | Nein |
| `semi` (Bayern) | Ja | Nein | Nein |
| `full` (NI, Default) | Ja | Ja | Ja |
### 11.4 Konsens-Regeln
| Differenz EK-ZK | Aktion |
|-----------------|--------|
| 0-2 Punkte | Auto-Konsens (Durchschnitt) |
| 3 Punkte | Einigung erforderlich |
| >= 4 Punkte | Drittkorrektur erforderlich |
---
## 12. Aktueller Stand
**Datum**: 2026-01-21
**Phase**: Alle Phasen abgeschlossen
**Status**: MVP komplett - bereit fuer Produktionstest
### Abgeschlossen:
- [x] Phase 1: Korrektur-Workspace MVP
- [x] Phase 1.1: Grundgerüst (Klausur-Liste, Studenten-Liste)
- [x] Phase 1.2: Annotations-System
- [x] Phase 1.3: RS/Grammatik Overlays
- [x] Phase 1.4: EH-Vorschläge via RAG
- [x] Phase 2.1 Backend: Zweitkorrektur-Endpoints
- [x] Phase 2.2 Backend: Einigung-Endpoint
- [x] Phase 2.3 Backend: Drittkorrektur-Trigger
- [x] Phase 2.1 Frontend: ZK-Modus UI
- [x] Phase 2.2 Frontend: Einigung-Screen
- [x] Phase 3.1: Fairness-Dashboard Frontend
- [x] Phase 3.2: Ausreißer-Liste mit Quick-Adjust
- [x] Phase 3.3: Noten-Histogramm & Heatmap
- [x] Phase 4.1: PDF-Export Backend (reportlab)
- [x] Phase 4.2: PDF-Export Frontend
- [x] Phase 4.3: Vorabitur-Modus mit EH-Templates
### URLs:
- Klausur-Korrektur: `/admin/klausur-korrektur`
- Fairness-Dashboard: `/admin/klausur-korrektur/[klausurId]/fairness`
### PDF-Export Endpoints:
- `GET /api/v1/students/{id}/export/gutachten` - Einzelnes Gutachten als PDF
- `GET /api/v1/students/{id}/export/annotations` - Anmerkungen als PDF
- `GET /api/v1/klausuren/{id}/export/overview` - Notenübersicht als PDF
- `GET /api/v1/klausuren/{id}/export/all-gutachten` - Alle Gutachten als PDF
### Vorabitur-Modus Endpoints:
- `GET /api/v1/vorabitur/templates` - Liste aller EH-Templates
- `GET /api/v1/vorabitur/templates/{aufgabentyp}` - Template-Details
- `POST /api/v1/klausuren/{id}/vorabitur-eh` - Custom EH erstellen
- `GET /api/v1/klausuren/{id}/vorabitur-eh` - Verknuepften EH abrufen
- `PUT /api/v1/klausuren/{id}/vorabitur-eh` - EH aktualisieren
### Verfuegbare Aufgabentypen:
- `textanalyse_pragmatisch` - Sachtexte, Reden, Kommentare
- `gedichtanalyse` - Lyrik/Gedichte
- `prosaanalyse` - Romane, Kurzgeschichten
- `dramenanalyse` - Dramatische Texte
- `eroerterung_textgebunden` - Textgebundene Eroerterung
---
## 13. Lehrer-Anleitung (Schritt-fuer-Schritt)
### 13.1 Zugang zum System
**Weg 1: Ueber das Haupt-Dashboard**
1. Oeffnen Sie `http://macmini:8000/app` im Browser
2. Klicken Sie auf die Kachel "Abiturklausuren"
3. Sie werden automatisch zur Korrektur-Oberflaeche weitergeleitet
**Weg 2: Direkter Zugang**
1. Oeffnen Sie direkt `http://macmini:3000/admin/klausur-korrektur`
### 13.2 Zwei Einstiegs-Optionen
Beim ersten Besuch sehen Sie die Willkommens-Seite mit zwei Optionen:
#### Option A: Schnellstart (Direkt hochladen)
- Ideal wenn Sie sofort loslegen moechten
- Keine manuelle Klausur-Erstellung erforderlich
- System erstellt automatisch eine Klausur im Hintergrund
**Schritte:**
1. Klicken Sie auf "Schnellstart - Direkt hochladen"
2. **Schritt 1**: Ziehen Sie Ihre eingescannten Arbeiten (PDF/JPG/PNG) in den Upload-Bereich
3. **Schritt 2**: Optional - Waehlen Sie den Aufgabentyp und beschreiben Sie die Aufgabenstellung
4. **Schritt 3**: Pruefen Sie die Zusammenfassung und klicken "Korrektur starten"
5. Sie werden automatisch zur Korrektur-Ansicht weitergeleitet
#### Option B: Neue Klausur erstellen (Standard)
- Empfohlen fuer regelmaessige Nutzung
- Volle Metadaten (Fach, Jahr, Kurs, Modus)
- Unterstuetzt Zweitkorrektur-Workflow
**Schritte:**
1. Klicken Sie auf "Neue Klausur erstellen"
2. Geben Sie Titel, Fach, Jahr und Semester ein
3. Waehlen Sie den Modus:
- **Abitur**: Fuer offizielle Abitur-Pruefungen mit NiBiS-EH
- **Vorabitur**: Fuer Uebungsklausuren mit eigenem EH
4. Bei Vorabitur: Waehlen Sie Aufgabentyp und beschreiben Sie die Aufgabenstellung
5. Klicken Sie "Klausur erstellen"
### 13.3 Arbeiten hochladen
Nach Erstellung der Klausur:
1. Oeffnen Sie die Klausur aus der Liste
2. Klicken Sie "Arbeiten hochladen"
3. Waehlen Sie die eingescannten Dateien (PDF oder Bilder)
4. Geben Sie optional anonyme IDs (z.B. "Arbeit-1", "Arbeit-2")
5. Das System startet automatisch die OCR-Erkennung
### 13.4 Korrigieren
**Korrektur-Workspace (2/3-1/3 Layout):**
- Links (2/3): Das Originaldokument mit Zoom-Funktion
- Rechts (1/3): Bewertungspanel mit Kriterien
**Schritt fuer Schritt:**
1. Oeffnen Sie eine Arbeit durch Klick auf "Korrigieren"
2. Lesen Sie die Arbeit im linken Bereich (Zoom mit +/-)
3. Setzen Sie Anmerkungen durch Klick auf das Dokument
4. Waehlen Sie den Anmerkungstyp:
- **RS** (rot): Rechtschreibfehler
- **Gram** (blau): Grammatikfehler
- **Inhalt** (gruen): Inhaltliche Anmerkungen
- **Kommentar**: Allgemeine Bemerkungen
5. Bewerten Sie die 5 Kriterien im rechten Panel:
- Rechtschreibung (15%)
- Grammatik (15%)
- Inhalt (40%)
- Struktur (15%)
- Stil (15%)
6. Klicken Sie "EH-Vorschlaege laden" fuer KI-Unterstuetzung
7. Klicken Sie "Gutachten generieren" fuer einen KI-Vorschlag
8. Bearbeiten Sie das Gutachten nach Bedarf
9. Klicken Sie "Speichern" und dann "Naechste Arbeit"
### 13.5 Fairness-Analyse
Nach Korrektur mehrerer Arbeiten:
1. Klicken Sie auf "Fairness-Dashboard" in der Klausur-Ansicht
2. Pruefen Sie:
- **Noten-Histogramm**: Ist die Verteilung realistisch?
- **Ausreisser**: Gibt es ungewoehnlich hohe/niedrige Noten?
- **Kriterien-Heatmap**: Sind Kriterien konsistent bewertet?
3. Nutzen Sie "Quick-Adjust" um Anpassungen vorzunehmen
### 13.6 PDF-Export
1. In der Klausur-Ansicht klicken Sie "PDF-Export"
2. Waehlen Sie:
- **Einzelgutachten**: PDF fuer einen Schueler
- **Alle Gutachten**: Gesamtes PDF fuer alle Arbeiten
- **Notenuebersicht**: Uebersicht aller Noten
- **Anmerkungen**: Alle Annotationen als PDF
### 13.7 Zweitkorrektur (Optional)
Fuer offizielle Abitur-Klausuren:
1. Erstkorrektur abschliessen (Status: "Abgeschlossen")
2. Klicken Sie "Zweitkorrektur starten"
3. Der Zweitkorrektor bewertet unabhaengig
4. Bei Differenz >= 3 Punkte: Einigung erforderlich
5. Bei Differenz >= 4 Punkte: Drittkorrektur wird automatisch ausgeloest
### 13.8 Haeufige Fragen
**F: Kann ich eine Korrektur unterbrechen und spaeter fortsetzen?**
A: Ja, alle Aenderungen werden automatisch gespeichert.
**F: Was passiert mit meinen Daten?**
A: Alle Daten werden lokal auf dem Schulserver gespeichert. Keine Cloud-Speicherung.
**F: Kann ich den KI-Vorschlag komplett ueberschreiben?**
A: Ja, das Gutachten ist frei editierbar. Der KI-Vorschlag ist nur ein Startpunkt.
**F: Wie funktioniert die OCR-Erkennung?**
A: Das System erkennt Handschrift automatisch. Bei schlechter Lesbarkeit koennen Sie manuell nachbessern.
---
## 14. Integration Dashboard (Port 8000)
### 14.1 Aenderungen in dashboard.py
Die Funktion `openKlausurService()` wurde aktualisiert:
```javascript
// Alte Version: Oeffnete Port 8086 (Backend)
// Neue Version: Oeffnet Port 3000 (Next.js Frontend)
function openKlausurService() {
let baseUrl;
if (window.location.hostname === 'macmini') {
baseUrl = 'http://macmini:3000';
} else {
baseUrl = 'http://localhost:3000';
}
window.open(baseUrl + '/admin/klausur-korrektur', '_blank');
}
```
### 14.2 Neue Frontend-Features
- **Willkommens-Tab**: Erster Tab fuer neue Benutzer mit Workflow-Erklaerung
- **Direktupload-Wizard**: 3-Schritt-Wizard fuer Schnellstart
- **Drag & Drop**: Arbeiten per Drag & Drop hochladen
- **localStorage-Persistenz**: System merkt sich wiederkehrende Benutzer

View File

@@ -0,0 +1,250 @@
# Experimental Dashboard - Apple Weather Style UI
**Status:** In Entwicklung
**Letzte Aktualisierung:** 2026-01-24
**URL:** http://macmini:3001/dashboard-experimental
---
## Uebersicht
Das Experimental Dashboard implementiert einen **Apple Weather App Style** mit:
- Ultra-transparenten Glassmorphism-Cards (~8% Opacity)
- Dunklem Sternenhimmel-Hintergrund mit Parallax
- Weisser Schrift auf monochromem Design
- Schwebenden Nachrichten (FloatingMessage) mit ~4% Background
- Nuetzlichen Widgets: Uhr, Wetter, Kompass, Diagramme
---
## Design-Prinzipien
| Prinzip | Umsetzung |
|---------|-----------|
| **Transparenz** | Cards mit 8% Opacity, Messages mit 4% |
| **Verschmelzung** | Elemente verschmelzen mit dem Hintergrund |
| **Monochrom** | Weisse Schrift, keine bunten Akzente |
| **Subtilitaet** | Dezente Hover-Effekte, sanfte Animationen |
| **Nuetzlichkeit** | Echte Informationen (Uhrzeit, Wetter) |
---
## Dateistruktur
```
/studio-v2/
├── app/
│ └── dashboard-experimental/
│ └── page.tsx # Haupt-Dashboard (740 Zeilen)
├── components/
│ └── spatial-ui/
│ ├── index.ts # Exports
│ ├── SpatialCard.tsx # Original SpatialCard (nicht verwendet)
│ └── FloatingMessage.tsx # Schwebende Nachrichten
└── lib/
└── spatial-ui/
├── index.ts # Exports
├── depth-system.ts # Design Tokens
├── PerformanceContext.tsx # Adaptive Qualitaet
└── FocusContext.tsx # Focus-Modus
```
---
## Komponenten
### GlassCard
Ultra-transparente Card fuer alle Inhalte.
```typescript
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg' // Padding: 16px, 20px, 24px
delay?: number // Einblend-Verzoegerung in ms
}
```
**Styling:**
- Background: `rgba(255, 255, 255, 0.08)` (8%)
- Hover: `rgba(255, 255, 255, 0.12)` (12%)
- Border: `1px solid rgba(255, 255, 255, 0.1)`
- Blur: 24px (adaptiv)
- Border-Radius: 24px (rounded-3xl)
### AnalogClock
Analoge Uhr mit Sekundenzeiger.
- Stunden-Zeiger: Weiss, dick
- Minuten-Zeiger: Weiss/80%, duenn
- Sekunden-Zeiger: Orange (#fb923c)
- 12 Stundenmarkierungen
- Aktualisiert jede Sekunde
### Compass
Kompass im Apple Weather Style.
```typescript
interface CompassProps {
direction?: number // Grad (0 = Nord, 90 = Ost, etc.)
}
```
- Nord-Nadel: Rot (#ef4444)
- Sued-Nadel: Weiss
- Kardinalrichtungen: N (rot), S, W, O
### BarChart
Balkendiagramm fuer Wochen-Statistiken.
```typescript
interface BarChartProps {
data: { label: string; value: number; highlight?: boolean }[]
maxValue?: number
}
```
- Highlight-Balken mit Gradient (blau → lila)
- Normale Balken: 20% weiss
- Labels unten, Werte oben
### ProgressRing
Kreisfoermiger Fortschrittsanzeiger.
```typescript
interface ProgressRingProps {
progress: number // 0-100
size?: number // Default: 80px
strokeWidth?: number // Default: 6px
label: string
value: string
color?: string // Farbe des Fortschritts
}
```
### TemperatureDisplay
Wetter-Anzeige mit Icon und Temperatur.
```typescript
interface TemperatureDisplayProps {
temp: number
condition: 'sunny' | 'cloudy' | 'rainy' | 'snowy' | 'partly_cloudy'
}
```
### FloatingMessage
Schwebende Benachrichtigungen von rechts.
**Aktuell:**
- Background: 4% Opacity
- Blur: 24px
- Border: `1px solid rgba(255, 255, 255, 0.12)`
- Auto-Dismiss mit Progress-Bar
- 3 Antwort-Optionen: Antworten, Oeffnen, Spaeter
- Typewriter-Effekt fuer Text
---
## Farbpalette
| Element | Wert |
|---------|------|
| Background | `from-slate-900 via-indigo-950 to-slate-900` |
| Card Background | `rgba(255, 255, 255, 0.08)` |
| Card Hover | `rgba(255, 255, 255, 0.12)` |
| Message Background | `rgba(255, 255, 255, 0.04)` |
| Border | `rgba(255, 255, 255, 0.1)` |
| Text Primary | `text-white` |
| Text Secondary | `text-white/50` bis `text-white/40` |
| Accent Blue | `#60a5fa` |
| Accent Purple | `#a78bfa` |
| Accent Orange | `#fb923c` (Sekundenzeiger) |
| Accent Red | `#ef4444` (Kompass Nord) |
---
## Performance-System
Das Dashboard nutzt das **PerformanceContext** fuer adaptive Qualitaet:
| Quality Level | Blur | Parallax | Animationen |
|---------------|------|----------|-------------|
| high | 24px | Ja | Spring |
| medium | 17px | Ja | Standard |
| low | 0px | Nein | Reduziert |
| minimal | 0px | Nein | Keine |
**FPS-Monitor** unten links zeigt:
- Aktuelle FPS
- Quality Level
- Blur/Parallax Status
---
## Deployment
```bash
# 1. Sync zu Mac Mini
rsync -avz --delete \
--exclude 'node_modules' --exclude '.next' --exclude '.git' \
/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/ \
macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/
# 2. Build
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
build --no-cache studio-v2"
# 3. Deploy
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
up -d studio-v2"
# 4. Testen
http://macmini:3001/dashboard-experimental
```
---
## Offene Punkte / Ideen
### Kurzfristig
- [ ] Echte Wetterdaten via API integrieren
- [ ] Kompass-Richtung dynamisch (GPS oder manuell)
- [ ] Klick auf Cards fuehrt zu Detailseiten
- [ ] Light Mode Support (aktuell nur Dark)
### Mittelfristig
- [ ] Drag & Drop fuer Card-Anordnung
- [ ] Weitere Widgets: Kalender, Termine, Erinnerungen
- [ ] Animierte Uebergaenge zwischen Seiten
- [ ] Sound-Feedback bei Interaktionen
### Langfristig
- [ ] Personalisierbare Widgets
- [ ] Dashboard als Standard-Startseite
- [ ] Mobile-optimierte Version
- [ ] Integration mit Apple Health / Fitness Daten
---
## Referenzen
- **Apple Weather App** (iOS) - Hauptinspiration
- **Dribbble Shot:** https://dribbble.com/shots/26339637-Smart-Home-Dashboard-Glassmorphism-UI
- **Design Tokens:** `/studio-v2/lib/spatial-ui/depth-system.ts`
---
## Aenderungshistorie
| Datum | Aenderung |
|-------|-----------|
| 2026-01-24 | FloatingMessage auf 4% Opacity reduziert |
| 2026-01-24 | Kompass, Balkendiagramm, Analog-Uhr hinzugefuegt |
| 2026-01-24 | Cards auf 8% Opacity reduziert |
| 2026-01-24 | Apple Weather Style implementiert |
| 2026-01-24 | Erstes Spatial UI System erstellt |

View File

@@ -0,0 +1,295 @@
# Multi-Agent Architektur - Entwicklerdokumentation
**Status:** Implementiert
**Letzte Aktualisierung:** 2025-01-15
**Modul:** `/agent-core/`
---
## 1. Übersicht
Die Multi-Agent-Architektur erweitert Breakpilot um ein verteiltes Agent-System basierend auf Mission Control Konzepten.
### Kernkomponenten
| Komponente | Pfad | Beschreibung |
|------------|------|--------------|
| Session Management | `/agent-core/sessions/` | Lifecycle & Recovery |
| Shared Brain | `/agent-core/brain/` | Langzeit-Gedächtnis |
| Orchestrator | `/agent-core/orchestrator/` | Koordination |
| SOUL Files | `/agent-core/soul/` | Agent-Persönlichkeiten |
---
## 2. Agent-Typen
| Agent | Aufgabe | SOUL-Datei |
|-------|---------|------------|
| **TutorAgent** | Lernbegleitung, Fragen beantworten | `tutor-agent.soul.md` |
| **GraderAgent** | Klausur-Korrektur, Bewertung | `grader-agent.soul.md` |
| **QualityJudge** | BQAS Qualitätsprüfung | `quality-judge.soul.md` |
| **AlertAgent** | Monitoring, Benachrichtigungen | `alert-agent.soul.md` |
| **Orchestrator** | Task-Koordination | `orchestrator.soul.md` |
---
## 3. Wichtige Dateien
### Session Management
```
agent-core/sessions/
├── session_manager.py # AgentSession, SessionManager, SessionState
├── heartbeat.py # HeartbeatMonitor, HeartbeatClient
└── checkpoint.py # CheckpointManager
```
### Shared Brain
```
agent-core/brain/
├── memory_store.py # MemoryStore, Memory (mit TTL)
├── context_manager.py # ConversationContext, ContextManager
└── knowledge_graph.py # KnowledgeGraph, Entity, Relationship
```
### Orchestrator
```
agent-core/orchestrator/
├── message_bus.py # MessageBus, AgentMessage, MessagePriority
├── supervisor.py # AgentSupervisor, AgentInfo, AgentStatus
└── task_router.py # TaskRouter, RoutingRule, RoutingResult
```
---
## 4. Datenbank-Schema
Die Migration befindet sich in:
`/backend/migrations/add_agent_core_tables.sql`
### Tabellen
1. **agent_sessions** - Session-Daten mit Checkpoints
2. **agent_memory** - Langzeit-Gedächtnis mit TTL
3. **agent_messages** - Audit-Trail für Inter-Agent Kommunikation
### Helper-Funktionen
```sql
-- Abgelaufene Memories bereinigen
SELECT cleanup_expired_agent_memory();
-- Inaktive Sessions bereinigen
SELECT cleanup_stale_agent_sessions(48); -- 48 Stunden
```
---
## 5. Integration Voice-Service
Der `EnhancedTaskOrchestrator` erweitert den bestehenden `TaskOrchestrator`:
```python
# voice-service/services/enhanced_task_orchestrator.py
from agent_core.sessions import SessionManager
from agent_core.orchestrator import MessageBus
class EnhancedTaskOrchestrator(TaskOrchestrator):
# Nutzt Session-Checkpoints für Recovery
# Routet komplexe Tasks an spezialisierte Agents
# Führt Quality-Checks via BQAS durch
```
**Wichtig:** Der Enhanced Orchestrator ist abwärtskompatibel und kann parallel zum Original verwendet werden.
---
## 6. Integration BQAS
Der `QualityJudgeAgent` integriert BQAS mit dem Multi-Agent-System:
```python
# voice-service/bqas/quality_judge_agent.py
from bqas.judge import LLMJudge
from agent_core.orchestrator import MessageBus
class QualityJudgeAgent:
# Wertet Responses in Echtzeit aus
# Nutzt Memory für konsistente Bewertungen
# Empfängt Evaluierungs-Requests via Message Bus
```
---
## 7. Code-Beispiele
### Session erstellen
```python
from agent_core.sessions import SessionManager
manager = SessionManager(redis_client=redis, db_pool=pool)
session = await manager.create_session(
agent_type="tutor-agent",
user_id="user-123"
)
```
### Memory speichern
```python
from agent_core.brain import MemoryStore
store = MemoryStore(redis_client=redis, db_pool=pool)
await store.remember(
key="student:123:progress",
value={"level": 5, "score": 85},
agent_id="tutor-agent",
ttl_days=30
)
```
### Nachricht senden
```python
from agent_core.orchestrator import MessageBus, AgentMessage
bus = MessageBus(redis_client=redis)
await bus.publish(AgentMessage(
sender="orchestrator",
receiver="grader-agent",
message_type="grade_request",
payload={"exam_id": "exam-1"}
))
```
---
## 8. Tests ausführen
```bash
# Alle Agent-Core Tests
cd agent-core && pytest -v
# Mit Coverage-Report
pytest --cov=. --cov-report=html
# Einzelne Module
pytest tests/test_session_manager.py -v
pytest tests/test_message_bus.py -v
```
---
## 9. Deployment-Schritte
### 1. Migration ausführen
```bash
psql -h localhost -U breakpilot -d breakpilot \
-f backend/migrations/add_agent_core_tables.sql
```
### 2. Voice-Service aktualisieren
```bash
# Sync zu Server
rsync -avz --exclude 'node_modules' --exclude '.git' \
/path/to/breakpilot-pwa/ server:/path/to/breakpilot-pwa/
# Container neu bauen
docker compose build --no-cache voice-service
# Starten
docker compose up -d voice-service
```
### 3. Verifizieren
```bash
# Session-Tabelle prüfen
psql -c "SELECT COUNT(*) FROM agent_sessions;"
# Memory-Tabelle prüfen
psql -c "SELECT COUNT(*) FROM agent_memory;"
```
---
## 10. Monitoring
### Metriken
| Metrik | Beschreibung |
|--------|--------------|
| `agent_session_count` | Anzahl aktiver Sessions |
| `agent_heartbeat_delay_ms` | Zeit seit letztem Heartbeat |
| `agent_message_latency_ms` | Nachrichtenlatenz |
| `agent_memory_count` | Gespeicherte Memories |
| `agent_routing_success_rate` | Erfolgreiche Routings |
### Health-Check-Endpunkte
```
GET /api/v1/agents/health # Supervisor Status
GET /api/v1/agents/sessions # Aktive Sessions
GET /api/v1/agents/memory/stats # Memory-Statistiken
```
---
## 11. Troubleshooting
### Problem: Session nicht gefunden
1. Prüfen ob Valkey läuft: `redis-cli ping`
2. Session-Timeout prüfen (default 24h)
3. Heartbeat-Status checken
### Problem: Message Bus Timeout
1. Redis Pub/Sub Status prüfen
2. Ziel-Agent registriert?
3. Timeout erhöhen (default 30s)
### Problem: Memory nicht gefunden
1. Namespace korrekt?
2. TTL abgelaufen?
3. Cleanup-Job gelaufen?
---
## 12. Erweiterungen
### Neuen Agent hinzufügen
1. SOUL-Datei erstellen in `/agent-core/soul/`
2. Routing-Regel in `task_router.py` hinzufügen
3. Handler beim Supervisor registrieren
4. Tests schreiben
### Neuen Memory-Typ hinzufügen
1. Key-Schema definieren (z.B. `student:*:progress`)
2. TTL festlegen
3. Access-Pattern dokumentieren
---
## 13. Referenzen
- **Agent-Core README:** `/agent-core/README.md`
- **Migration:** `/backend/migrations/add_agent_core_tables.sql`
- **Voice-Service Integration:** `/voice-service/services/enhanced_task_orchestrator.py`
- **BQAS Integration:** `/voice-service/bqas/quality_judge_agent.py`
- **Tests:** `/agent-core/tests/`
---
## 14. Änderungshistorie
| Datum | Version | Änderung |
|-------|---------|----------|
| 2025-01-15 | 1.0.0 | Initial Release |

View File

@@ -0,0 +1,205 @@
# Vokabel-Arbeitsblatt Generator - Entwicklerdokumentation
**Status:** Produktiv
**Letzte Aktualisierung:** 2026-02-08
**URL:** https://macmini/vocab-worksheet
---
## Uebersicht
Der Vokabel-Arbeitsblatt Generator ermoeglicht Lehrern:
- Schulbuchseiten (PDF/Bild) zu scannen
- Vokabeln automatisch per OCR zu extrahieren
- Druckfertige Arbeitsblaetter in verschiedenen Formaten zu generieren
---
## Architektur
```
Browser (studio-v2) klausur-service (Port 8086) PostgreSQL
│ │ │
│ POST /upload-pdf-info │ │
│ POST /process-single-page │ │
│ POST /generate │ │
│ POST /generate-nru │ ──── vocab_sessions ──────▶│
│ GET /worksheets/{id}/pdf │ ──── vocab_entries ───────▶│
│ │ ──── vocab_worksheets ────▶│
└────────────────────────────┘ │
```
---
## Arbeitsblatt-Formate
### Standard-Format
Klassisches Arbeitsblatt mit waehlbaren Uebungstypen:
- **Englisch → Deutsch**: Englische Woerter uebersetzen
- **Deutsch → Englisch**: Deutsche Woerter uebersetzen
- **Abschreibuebung**: Woerter mehrfach schreiben
- **Lueckensaetze**: Saetze mit Luecken ausfuellen
### NRU-Format (Neu: 2026-02-08)
Spezielles Format fuer strukturiertes Vokabellernen:
**Seite 1 (pro gescannter Seite): Vokabeltabelle**
| Englisch | Deutsch | Korrektur |
|----------|---------|-----------|
| word | (leer) | (leer) |
- Kind schreibt deutsche Uebersetzung
- Eltern korrigieren, Kind schreibt ggf. korrigierte Version
**Seite 2 (pro gescannter Seite): Lernsaetze**
| Deutscher Satz |
|-----------------------------------|
| (2 leere Zeilen fuer EN-Uebersetzung) |
- Deutscher Satz vorgegeben
- Kind schreibt englische Uebersetzung
**Automatische Trennung:**
- Einzelwoerter/Phrasen → Vokabeltabelle
- Saetze (enden mit `.!?` oder > 50 Zeichen) → Lernsaetze
---
## API-Endpoints
### Standard-Format
```
POST /api/v1/vocab/sessions/{session_id}/generate
Body: {
"worksheet_types": ["en_to_de", "de_to_en", "copy", "gap_fill"],
"title": "Vokabeln Unit 3",
"include_solutions": true,
"line_height": "normal" | "large" | "extra-large"
}
Response: { "id": "worksheet-uuid", ... }
```
### NRU-Format
```
POST /api/v1/vocab/sessions/{session_id}/generate-nru
Body: {
"title": "Vokabeltest",
"include_solutions": true,
"specific_pages": [1, 2] // optional, 1-indexed
}
Response: {
"worksheet_id": "uuid",
"statistics": {
"total_entries": 96,
"vocabulary_count": 75,
"sentence_count": 21,
"source_pages": [1, 2, 3],
"worksheet_pages": 6
},
"download_url": "/api/v1/vocab/worksheets/{id}/pdf",
"solution_url": "/api/v1/vocab/worksheets/{id}/solution"
}
```
### PDF-Download
```
GET /api/v1/vocab/worksheets/{worksheet_id}/pdf
GET /api/v1/vocab/worksheets/{worksheet_id}/solution
```
---
## Dateien
### Backend (klausur-service)
| Datei | Beschreibung |
|-------|--------------|
| `vocab_worksheet_api.py` | Haupt-API Router mit allen Endpoints |
| `nru_worksheet_generator.py` | NRU-Format HTML/PDF Generator |
| `vocab_session_store.py` | PostgreSQL Datenbankoperationen |
| `hybrid_vocab_extractor.py` | OCR-Extraktion (PaddleOCR + LLM) |
| `tesseract_vocab_extractor.py` | Tesseract OCR Fallback |
### Frontend (studio-v2)
| Datei | Beschreibung |
|-------|--------------|
| `app/vocab-worksheet/page.tsx` | Haupt-UI mit Template-Auswahl |
---
## Datenbank-Schema
```sql
-- Sessions
CREATE TABLE vocab_sessions (
id UUID PRIMARY KEY,
name VARCHAR(255),
status VARCHAR(50),
vocabulary_count INT,
source_language VARCHAR(10),
target_language VARCHAR(10),
created_at TIMESTAMP
);
-- Vokabeln
CREATE TABLE vocab_entries (
id UUID PRIMARY KEY,
session_id UUID REFERENCES vocab_sessions(id),
english TEXT,
german TEXT,
example_sentence TEXT,
source_page INT,
source_row INT,
source_column INT
);
-- Generierte Arbeitsblaetter
CREATE TABLE vocab_worksheets (
id UUID PRIMARY KEY,
session_id UUID REFERENCES vocab_sessions(id),
worksheet_types JSONB,
pdf_path VARCHAR(500),
solution_path VARCHAR(500),
generated_at TIMESTAMP
);
```
---
## Deployment
```bash
# 1. Backend synchronisieren
rsync -avz klausur-service/backend/ macmini:.../klausur-service/backend/
# 2. Frontend synchronisieren
rsync -avz studio-v2/app/vocab-worksheet/ macmini:.../studio-v2/app/vocab-worksheet/
# 3. Container neu bauen
ssh macmini "docker compose build --no-cache klausur-service studio-v2"
# 4. Container starten
ssh macmini "docker compose up -d klausur-service studio-v2"
```
---
## 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 `page.tsx` hinzufuegen
4. **Doku**: Diese Datei aktualisieren
---
## Aenderungshistorie
| Datum | Aenderung |
|-------|-----------|
| 2026-02-08 | NRU-Format und Template-Auswahl hinzugefuegt |
| 2026-02-07 | Initiale Implementierung mit Standard-Format |

View File

@@ -0,0 +1,117 @@
# Session Status - 25. Januar 2026 (Aktualisiert)
## Zusammenfassung
Open Data School Import erfolgreich implementiert. Schulbestand von 17,610 auf 30,355 erhoeht.
---
## Erledigte Aufgaben
### 1. Studio-v2 Build-Fehler (Vorherige Session)
- **Status:** Erledigt
- **Problem:** `Module not found: Can't resolve 'pdf-lib'`
- **Loesung:** Falsches package.json auf macmini ersetzt, rsync mit --delete
### 2. Open Data School Importer
- **Status:** Erledigt
- **Datei:** `/edu-search-service/scripts/import_open_data.py`
- **Erfolgreich importiert:**
- **NRW:** 5,637 Schulen (CSV von schulministerium.nrw.de)
- **Berlin:** 930 Schulen (WFS/GeoJSON von gdi.berlin.de)
- **Hamburg:** 543 Schulen (WFS/GML von geodienste.hamburg.de)
---
## Aktuelle Schulstatistiken
```
Total: 30,355 Schulen
Nach Bundesland:
NW: 14,962 (inkl. Open Data Import)
BY: 2,803
NI: 2,192
BE: 1,475 (inkl. WFS Import)
SN: 1,425
SH: 1,329
HE: 1,290
RP: 1,066
HH: 902 (inkl. WFS Import)
TH: 799
BB: 562
SL: 533
MV: 367
ST: 250
BW: 200 (nur JedeSchule.de - BW Daten kostenpflichtig!)
HB: 200
```
---
## Open Data Importer - Verfuegbare Quellen
| Bundesland | Status | Quelle | Format |
|------------|--------|--------|--------|
| NW | Funktioniert | schulministerium.nrw.de | CSV |
| BE | Funktioniert | gdi.berlin.de | WFS/GeoJSON |
| HH | Funktioniert | geodienste.hamburg.de | WFS/GML |
| SN | 404 Error | schuldatenbank.sachsen.de | API |
| BW | Kostenpflichtig | LOBW | - |
| BY | Kein Open Data | - | - |
---
## Importer-Nutzung
```bash
# Alle verfuegbaren Quellen importieren
cd /Users/benjaminadmin/Projekte/breakpilot-pwa/edu-search-service/scripts
python3 import_open_data.py --all --url http://macmini:8088
# Einzelnes Bundesland (Dry-Run)
python3 import_open_data.py --state NW --dry-run
# Mit Server-URL
python3 import_open_data.py --state HH --url http://macmini:8088
```
---
## Offene Punkte
### Bundeslaender ohne Open Data
- **BW:** Schuldaten muessen GEKAUFT werden (LOBW)
- **BY:** Keine Open Data API gefunden
- **NI, HE, RP, etc.:** Keine zentralen Open Data Quellen bekannt
### Moegliche weitere Quellen
- OSM (OpenStreetMap) - amenity=school
- Statistisches Bundesamt
- Lokale Schultraeger-Verzeichnisse
---
## Container-Status auf macmini
| Container | Port | Status |
|-----------|------|--------|
| website | 3000 | Laeuft |
| studio-v2 | 3001 | Laeuft |
| edu-search-service | 8088 | Laeuft |
---
## Wichtige URLs
- School Directory: http://macmini:3000/admin/school-directory
- School Stats API: http://macmini:8088/api/v1/schools/stats
- School Search API: http://macmini:8088/api/v1/schools?q=NAME
---
## Naechste moegliche Schritte
1. **OSM Import testen** - OpenStreetMap hat Schuldaten (amenity=school)
2. **Weitere WFS-Quellen suchen** - Andere Bundeslaender koennten Geo-Portale haben
3. **Deduplizierung** - Pruefen ob durch multiple Imports Duplikate entstanden sind

View File

@@ -0,0 +1,82 @@
{
"permissions": {
"allow": [
"Bash(textutil -convert txt:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(wc:*)",
"Bash(/bin/bash -c \"source venv/bin/activate && pip install pyjwt --quiet 2>/dev/null && python -c \"\"import sys; sys.path.insert(0, ''.''); from llm_gateway.models.chat import ChatMessage; print(''Models import OK'')\"\"\")",
"Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/python:*)",
"Bash(./venv/bin/pip install:*)",
"Bash(brew install:*)",
"Bash(brew services start:*)",
"Bash(ollama list:*)",
"Bash(ollama pull:*)",
"Bash(export LLM_GATEWAY_ENABLED=true)",
"Bash(export LLM_GATEWAY_DEBUG=true)",
"Bash(export LLM_API_KEYS=test-key-123)",
"Bash(export ANTHROPIC_API_KEY=\"$ANTHROPIC_API_KEY\")",
"Bash(source:*)",
"Bash(pytest:*)",
"Bash(./venv/bin/pytest:*)",
"Bash(python3 -m pytest:*)",
"Bash(export TAVILY_API_KEY=\"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\")",
"Bash(python3:*)",
"Bash(curl:*)",
"Bash(pip3 install:*)",
"WebSearch",
"Bash(export ALERTS_AGENT_ENABLED=true)",
"Bash(export LLM_API_KEYS=test-key)",
"WebFetch(domain:docs.vast.ai)",
"Bash(docker compose:*)",
"Bash(docker ps:*)",
"Bash(docker inspect:*)",
"Bash(docker logs:*)",
"Bash(ls:*)",
"Bash(docker exec:*)",
"WebFetch(domain:www.librechat.ai)",
"Bash(export TAVILY_API_KEY=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)",
"Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/pip install:*)",
"Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/pytest -v tests/test_integration/test_librechat_tavily.py -x)",
"WebFetch(domain:vast.ai)",
"Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/pytest tests/test_infra/test_vast_client.py tests/test_infra/test_vast_power.py -v --tb=short)",
"Bash(go build:*)",
"Bash(go test:*)",
"Bash(npm install)",
"Bash(/usr/local/bin/node:*)",
"Bash(/opt/homebrew/bin/node --version)",
"Bash(docker --version:*)",
"Bash(docker build:*)",
"Bash(docker images:*)",
"Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/pytest:*)",
"Bash(npm test:*)",
"Bash(/opt/homebrew/bin/node /opt/homebrew/bin/npm test -- --passWithNoTests)",
"Bash(/usr/libexec/java_home:*)",
"Bash(/opt/homebrew/bin/node:*)",
"Bash(docker restart:*)",
"Bash(tree:*)",
"Bash(go mod tidy:*)",
"Bash(go mod vendor:*)",
"Bash(python -m pytest:*)",
"Bash(lsof:*)",
"Bash(python scripts/load_initial_seeds.py:*)",
"Bash(python:*)",
"Bash(docker cp:*)",
"Bash(node --check:*)",
"Bash(cat:*)",
"Bash(DATABASE_URL='postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot_db' python3:*)",
"Bash(docker volume:*)",
"Bash(docker stop:*)",
"Bash(docker rm:*)",
"Bash(docker run:*)",
"Bash(docker network:*)",
"Bash(breakpilot-edu-search:latest)",
"Bash(jq:*)",
"Bash(docker port:*)",
"Bash(/dev/null curl -X POST http://localhost:8086/v1/crawl/queue -H 'Authorization: Bearer dev-key' -H 'Content-Type: application/json' -d '{\"\"\"\"university_id\"\"\"\": \"\"\"\"783333a1-91a3-4015-9299-45d10537dae4\"\"\"\", \"\"\"\"priority\"\"\"\": 10}')",
"Bash(1)",
"WebFetch(domain:uol.de)",
"Bash(xargs:*)"
]
}
}

31
.docker/build-ci-images.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Build CI Docker Images for BreakPilot
# Run this script on the Mac Mini to build the custom CI images
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Building BreakPilot CI Images ==="
echo "Project directory: $PROJECT_DIR"
cd "$PROJECT_DIR"
# Build Python CI image with WeasyPrint
echo ""
echo "Building breakpilot/python-ci:3.12 ..."
docker build \
-t breakpilot/python-ci:3.12 \
-t breakpilot/python-ci:latest \
-f .docker/python-ci.Dockerfile \
.
echo ""
echo "=== Build complete ==="
echo ""
echo "Images built:"
docker images | grep breakpilot/python-ci
echo ""
echo "To use in Woodpecker CI, the image is already configured in .woodpecker/main.yml"

View File

@@ -0,0 +1,51 @@
# Custom Python CI Image with WeasyPrint Dependencies
# Build: docker build -t breakpilot/python-ci:3.12 -f .docker/python-ci.Dockerfile .
#
# This image includes all system libraries needed for:
# - WeasyPrint (PDF generation)
# - psycopg2 (PostgreSQL)
# - General Python testing
FROM python:3.12-slim
LABEL maintainer="BreakPilot Team"
LABEL description="Python 3.12 with WeasyPrint and test dependencies for CI"
# Install system dependencies in a single layer
RUN apt-get update && apt-get install -y --no-install-recommends \
# WeasyPrint dependencies
libpango-1.0-0 \
libpangocairo-1.0-0 \
libpangoft2-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
libcairo2 \
libcairo2-dev \
libgirepository1.0-dev \
gir1.2-pango-1.0 \
# PostgreSQL client (for psycopg2)
libpq-dev \
# Build tools (for some pip packages)
gcc \
g++ \
# Useful utilities
curl \
git \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Pre-install commonly used Python packages for faster CI
RUN pip install --no-cache-dir \
pytest \
pytest-cov \
pytest-asyncio \
pytest-json-report \
psycopg2-binary \
weasyprint \
httpx
# Set working directory
WORKDIR /app
# Default command
CMD ["python", "--version"]

115
.env.dev Normal file
View File

@@ -0,0 +1,115 @@
# ============================================
# BreakPilot PWA - DEVELOPMENT Environment
# ============================================
# Usage: cp .env.dev .env
# Or: ./scripts/env-switch.sh dev
# ============================================
# ============================================
# Environment Identifier
# ============================================
ENVIRONMENT=development
COMPOSE_PROJECT_NAME=breakpilot-dev
# ============================================
# HashiCorp Vault (Secrets Management)
# ============================================
# In development, use the local Vault instance with dev token
VAULT_ADDR=http://localhost:8200
VAULT_DEV_TOKEN=breakpilot-dev-token
# ============================================
# Database
# ============================================
POSTGRES_USER=breakpilot
POSTGRES_PASSWORD=breakpilot_dev_123
POSTGRES_DB=breakpilot_dev
DATABASE_URL=postgres://breakpilot:breakpilot_dev_123@postgres:5432/breakpilot_dev?sslmode=disable
# Synapse DB (Matrix)
SYNAPSE_DB_PASSWORD=synapse_dev_123
# ============================================
# Authentication
# ============================================
# Development only - NOT for production!
JWT_SECRET=dev-jwt-secret-not-for-production-32chars
JWT_REFRESH_SECRET=dev-refresh-secret-32chars-change-me
# ============================================
# Service URLs (Development)
# ============================================
FRONTEND_URL=http://localhost:8000
BACKEND_URL=http://localhost:8000
CONSENT_SERVICE_URL=http://localhost:8081
BILLING_SERVICE_URL=http://localhost:8083
SCHOOL_SERVICE_URL=http://localhost:8084
KLAUSUR_SERVICE_URL=http://localhost:8086
WEBSITE_URL=http://localhost:3000
# ============================================
# E-Mail (Mailpit for Development)
# ============================================
# Mailpit catches all emails - view at http://localhost:8025
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_NAME=BreakPilot Dev
SMTP_FROM_ADDR=dev@breakpilot.local
# ============================================
# MinIO (Object Storage)
# ============================================
MINIO_ROOT_USER=breakpilot_dev
MINIO_ROOT_PASSWORD=breakpilot_dev_123
MINIO_ENDPOINT=localhost:9000
# ============================================
# Qdrant (Vector DB)
# ============================================
QDRANT_URL=http://localhost:6333
# ============================================
# API Keys (Optional for Dev)
# ============================================
# Leave empty for offline development
# Or add your test keys here
ANTHROPIC_API_KEY=
ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-20250514
ANTHROPIC_ENABLED=false
VAST_API_KEY=
VAST_INSTANCE_ID=
CONTROL_API_KEY=
VAST_AUTO_SHUTDOWN=true
VAST_AUTO_SHUTDOWN_MINUTES=30
VLLM_BASE_URL=
VLLM_ENABLED=false
# ============================================
# Embedding Configuration
# ============================================
# "local" = sentence-transformers (no API key needed)
# "openai" = OpenAI API (requires OPENAI_API_KEY)
EMBEDDING_BACKEND=local
# ============================================
# Stripe (Billing - Test Mode)
# ============================================
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
# ============================================
# Debug Settings
# ============================================
DEBUG=true
GIN_MODE=debug
LOG_LEVEL=debug
# ============================================
# Jitsi (Video Conferencing)
# ============================================
JITSI_PUBLIC_URL=http://localhost:8443

124
.env.example Normal file
View File

@@ -0,0 +1,124 @@
# BreakPilot PWA - Environment Configuration
# Kopieren Sie diese Datei nach .env und passen Sie die Werte an
# ================================================
# Allgemein
# ================================================
ENVIRONMENT=development
# ENVIRONMENT=production
# ================================================
# Sicherheit
# ================================================
# WICHTIG: In Produktion sichere Schluessel verwenden!
# Generieren mit: openssl rand -hex 32
JWT_SECRET=CHANGE_ME_RUN_openssl_rand_hex_32
JWT_REFRESH_SECRET=CHANGE_ME_RUN_openssl_rand_hex_32
# ================================================
# Keycloak (Optional - fuer Produktion empfohlen)
# ================================================
# Wenn Keycloak konfiguriert ist, wird es fuer Authentifizierung verwendet.
# Ohne Keycloak wird lokales JWT verwendet (gut fuer Entwicklung).
#
# KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app
# KEYCLOAK_REALM=breakpilot
# KEYCLOAK_CLIENT_ID=breakpilot-backend
# KEYCLOAK_CLIENT_SECRET=your-client-secret
# KEYCLOAK_VERIFY_SSL=true
# ================================================
# E-Mail Konfiguration
# ================================================
# === ENTWICKLUNG (Mailpit - Standardwerte) ===
# Mailpit fängt alle E-Mails ab und zeigt sie unter http://localhost:8025
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_NAME=BreakPilot
SMTP_FROM_ADDR=noreply@breakpilot.app
FRONTEND_URL=http://localhost:8000
# === PRODUKTION (Beispiel für verschiedene Provider) ===
# --- Option 1: Eigener Mailserver ---
# SMTP_HOST=mail.ihredomain.de
# SMTP_PORT=587
# SMTP_USERNAME=noreply@ihredomain.de
# SMTP_PASSWORD=ihr-sicheres-passwort
# SMTP_FROM_NAME=BreakPilot
# SMTP_FROM_ADDR=noreply@ihredomain.de
# FRONTEND_URL=https://app.ihredomain.de
# --- Option 2: SendGrid ---
# SMTP_HOST=smtp.sendgrid.net
# SMTP_PORT=587
# SMTP_USERNAME=apikey
# SMTP_PASSWORD=SG.xxxxxxxxxxxxxxxxxxxxx
# SMTP_FROM_NAME=BreakPilot
# SMTP_FROM_ADDR=noreply@ihredomain.de
# --- Option 3: Mailgun ---
# SMTP_HOST=smtp.mailgun.org
# SMTP_PORT=587
# SMTP_USERNAME=postmaster@mg.ihredomain.de
# SMTP_PASSWORD=ihr-mailgun-passwort
# SMTP_FROM_NAME=BreakPilot
# SMTP_FROM_ADDR=noreply@mg.ihredomain.de
# --- Option 4: Amazon SES ---
# SMTP_HOST=email-smtp.eu-central-1.amazonaws.com
# SMTP_PORT=587
# SMTP_USERNAME=AKIAXXXXXXXXXXXXXXXX
# SMTP_PASSWORD=ihr-ses-secret
# SMTP_FROM_NAME=BreakPilot
# SMTP_FROM_ADDR=noreply@ihredomain.de
# ================================================
# Datenbank
# ================================================
POSTGRES_USER=breakpilot
POSTGRES_PASSWORD=breakpilot123
POSTGRES_DB=breakpilot_db
DATABASE_URL=postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db?sslmode=disable
# ================================================
# Optional: AI Integration
# ================================================
# ANTHROPIC_API_KEY=your-anthropic-api-key-here
# ================================================
# Breakpilot Drive - Lernspiel
# ================================================
# Aktiviert Datenbank-Speicherung fuer Spielsessions
GAME_USE_DATABASE=true
# LLM fuer Quiz-Fragen-Generierung (optional)
# Wenn nicht gesetzt, werden statische Fragen verwendet
GAME_LLM_MODEL=llama-3.1-8b
GAME_LLM_FALLBACK_MODEL=claude-3-haiku
# Feature Flags
GAME_REQUIRE_AUTH=false
GAME_REQUIRE_BILLING=false
GAME_ENABLE_LEADERBOARDS=true
# Task-Kosten fuer Billing (wenn aktiviert)
GAME_SESSION_TASK_COST=1.0
GAME_QUICK_SESSION_TASK_COST=0.5
# ================================================
# Woodpecker CI/CD
# ================================================
# URL zum Woodpecker Server
WOODPECKER_URL=http://woodpecker-server:8000
# API Token für Dashboard-Integration (Pipeline-Start)
# Erstellen unter: http://macmini:8090 → User Settings → Personal Access Tokens
WOODPECKER_TOKEN=
# ================================================
# Debug
# ================================================
DEBUG=false

113
.env.staging Normal file
View File

@@ -0,0 +1,113 @@
# ============================================
# BreakPilot PWA - STAGING Environment
# ============================================
# Usage: cp .env.staging .env
# Or: ./scripts/env-switch.sh staging
# ============================================
# ============================================
# Environment Identifier
# ============================================
ENVIRONMENT=staging
COMPOSE_PROJECT_NAME=breakpilot-staging
# ============================================
# HashiCorp Vault (Secrets Management)
# ============================================
# In staging, still use dev token but with staging secrets path
VAULT_ADDR=http://localhost:8200
VAULT_DEV_TOKEN=breakpilot-staging-token
# ============================================
# Database (Separate from Dev!)
# ============================================
POSTGRES_USER=breakpilot
POSTGRES_PASSWORD=staging_secure_password_change_this
POSTGRES_DB=breakpilot_staging
DATABASE_URL=postgres://breakpilot:staging_secure_password_change_this@postgres:5432/breakpilot_staging?sslmode=disable
# Synapse DB (Matrix)
SYNAPSE_DB_PASSWORD=synapse_staging_secure_123
# ============================================
# Authentication
# ============================================
# Staging secrets - more secure than dev, but not production
JWT_SECRET=staging-jwt-secret-32chars-change-me-now
JWT_REFRESH_SECRET=staging-refresh-secret-32chars-secure
# ============================================
# Service URLs (Staging - Different Ports)
# ============================================
FRONTEND_URL=http://localhost:8001
BACKEND_URL=http://localhost:8001
CONSENT_SERVICE_URL=http://localhost:8091
BILLING_SERVICE_URL=http://localhost:8093
SCHOOL_SERVICE_URL=http://localhost:8094
KLAUSUR_SERVICE_URL=http://localhost:8096
WEBSITE_URL=http://localhost:3001
# ============================================
# E-Mail (Still Mailpit for Safety)
# ============================================
# Mailpit catches all emails - no accidental sends to real users
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_NAME=BreakPilot Staging
SMTP_FROM_ADDR=staging@breakpilot.local
# ============================================
# MinIO (Object Storage)
# ============================================
MINIO_ROOT_USER=breakpilot_staging
MINIO_ROOT_PASSWORD=staging_minio_secure_123
MINIO_ENDPOINT=localhost:9002
# ============================================
# Qdrant (Vector DB)
# ============================================
QDRANT_URL=http://localhost:6335
# ============================================
# API Keys (Test Keys for Staging)
# ============================================
# Use test/sandbox API keys here
ANTHROPIC_API_KEY=
ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-20250514
ANTHROPIC_ENABLED=false
VAST_API_KEY=
VAST_INSTANCE_ID=
CONTROL_API_KEY=
VAST_AUTO_SHUTDOWN=true
VAST_AUTO_SHUTDOWN_MINUTES=30
VLLM_BASE_URL=
VLLM_ENABLED=false
# ============================================
# Embedding Configuration
# ============================================
EMBEDDING_BACKEND=local
# ============================================
# Stripe (Billing - Test Mode)
# ============================================
# Use Stripe TEST keys (sk_test_...)
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
# ============================================
# Debug Settings (Reduced in Staging)
# ============================================
DEBUG=false
GIN_MODE=release
LOG_LEVEL=info
# ============================================
# Jitsi (Video Conferencing)
# ============================================
JITSI_PUBLIC_URL=http://localhost:8444

132
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
# Dependabot Configuration for BreakPilot PWA
# This file configures Dependabot to automatically check for outdated dependencies
# and create pull requests to update them
version: 2
updates:
# Go dependencies (consent-service)
- package-ecosystem: "gomod"
directory: "/consent-service"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "go"
- "security"
commit-message:
prefix: "deps(go):"
groups:
go-minor:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# Python dependencies (backend)
- package-ecosystem: "pip"
directory: "/backend"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "python"
- "security"
commit-message:
prefix: "deps(python):"
groups:
python-minor:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# Node.js dependencies (website)
- package-ecosystem: "npm"
directory: "/website"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "javascript"
- "security"
commit-message:
prefix: "deps(npm):"
groups:
npm-minor:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "deps(actions):"
# Docker base images
- package-ecosystem: "docker"
directory: "/consent-service"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
labels:
- "dependencies"
- "docker"
- "security"
commit-message:
prefix: "deps(docker):"
- package-ecosystem: "docker"
directory: "/backend"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
labels:
- "dependencies"
- "docker"
- "security"
commit-message:
prefix: "deps(docker):"
- package-ecosystem: "docker"
directory: "/website"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
labels:
- "dependencies"
- "docker"
- "security"
commit-message:
prefix: "deps(docker):"

503
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,503 @@
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
env:
GO_VERSION: '1.21'
PYTHON_VERSION: '3.11'
NODE_VERSION: '20'
POSTGRES_USER: breakpilot
POSTGRES_PASSWORD: breakpilot123
POSTGRES_DB: breakpilot_test
REGISTRY: ghcr.io
IMAGE_PREFIX: ${{ github.repository_owner }}/breakpilot
jobs:
# ==========================================
# Go Consent Service Tests
# ==========================================
go-tests:
name: Go Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: ${{ env.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ env.POSTGRES_DB }}
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: consent-service/go.sum
- name: Download dependencies
working-directory: ./consent-service
run: go mod download
- name: Run Go Vet
working-directory: ./consent-service
run: go vet ./...
- name: Run Unit Tests
working-directory: ./consent-service
run: go test -v -race -coverprofile=coverage.out ./...
env:
DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }}?sslmode=disable
JWT_SECRET: test-jwt-secret-for-ci
JWT_REFRESH_SECRET: test-refresh-secret-for-ci
- name: Check Coverage
working-directory: ./consent-service
run: |
go tool cover -func=coverage.out
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 50" | bc -l) )); then
echo "::warning::Coverage is below 50%"
fi
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./consent-service/coverage.out
flags: go
name: go-coverage
continue-on-error: true
# ==========================================
# Python Backend Tests
# ==========================================
python-tests:
name: Python Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
cache-dependency-path: backend/requirements.txt
- name: Install dependencies
working-directory: ./backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-asyncio httpx
- name: Run Python Tests
working-directory: ./backend
run: pytest -v --cov=. --cov-report=xml --cov-report=term-missing
continue-on-error: true
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./backend/coverage.xml
flags: python
name: python-coverage
continue-on-error: true
# ==========================================
# Node.js Website Tests
# ==========================================
website-tests:
name: Website Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: website/package-lock.json
- name: Install dependencies
working-directory: ./website
run: npm ci
- name: Run TypeScript check
working-directory: ./website
run: npx tsc --noEmit
continue-on-error: true
- name: Run ESLint
working-directory: ./website
run: npm run lint
continue-on-error: true
- name: Build website
working-directory: ./website
run: npm run build
env:
NEXT_PUBLIC_BILLING_API_URL: http://localhost:8083
NEXT_PUBLIC_APP_URL: http://localhost:3000
# ==========================================
# Linting
# ==========================================
lint:
name: Linting
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v4
with:
version: latest
working-directory: ./consent-service
args: --timeout=5m
continue-on-error: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Python linters
run: pip install flake8 black isort
- name: Run flake8
working-directory: ./backend
run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
continue-on-error: true
- name: Check Black formatting
working-directory: ./backend
run: black --check --diff .
continue-on-error: true
# ==========================================
# Security Scan
# ==========================================
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
exit-code: '0'
continue-on-error: true
- name: Run Go security check
uses: securego/gosec@master
with:
args: '-no-fail -fmt sarif -out results.sarif ./consent-service/...'
continue-on-error: true
# ==========================================
# Docker Build & Push
# ==========================================
docker-build:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: [go-tests, python-tests, website-tests]
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for consent-service
id: meta-consent
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push consent-service
uses: docker/build-push-action@v5
with:
context: ./consent-service
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-consent.outputs.tags }}
labels: ${{ steps.meta-consent.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: ./backend
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for website
id: meta-website
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push website
uses: docker/build-push-action@v5
with:
context: ./website
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-website.outputs.tags }}
labels: ${{ steps.meta-website.outputs.labels }}
build-args: |
NEXT_PUBLIC_BILLING_API_URL=${{ vars.NEXT_PUBLIC_BILLING_API_URL || 'http://localhost:8083' }}
NEXT_PUBLIC_APP_URL=${{ vars.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==========================================
# Integration Tests
# ==========================================
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
needs: [docker-build]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Start services with Docker Compose
run: |
docker compose up -d postgres mailpit
sleep 10
- name: Run consent-service
working-directory: ./consent-service
run: |
go build -o consent-service ./cmd/server
./consent-service &
sleep 5
env:
DATABASE_URL: postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db?sslmode=disable
JWT_SECRET: test-jwt-secret
JWT_REFRESH_SECRET: test-refresh-secret
SMTP_HOST: localhost
SMTP_PORT: 1025
- name: Health Check
run: |
curl -f http://localhost:8081/health || exit 1
- name: Run Integration Tests
run: |
# Test Auth endpoints
curl -s http://localhost:8081/api/v1/auth/health
# Test Document endpoints
curl -s http://localhost:8081/api/v1/documents
continue-on-error: true
- name: Stop services
if: always()
run: docker compose down
# ==========================================
# Deploy to Staging
# ==========================================
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [docker-build, integration-tests]
if: github.ref == 'refs/heads/develop' && github.event_name == 'push'
environment:
name: staging
url: https://staging.breakpilot.app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to staging server
env:
STAGING_HOST: ${{ secrets.STAGING_HOST }}
STAGING_USER: ${{ secrets.STAGING_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
# This is a placeholder for actual deployment
# Configure based on your staging infrastructure
echo "Deploying to staging environment..."
echo "Images to deploy:"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service:develop"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:develop"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website:develop"
# Example: SSH deployment (uncomment when configured)
# mkdir -p ~/.ssh
# echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
# chmod 600 ~/.ssh/id_rsa
# ssh -o StrictHostKeyChecking=no $STAGING_USER@$STAGING_HOST "cd /opt/breakpilot && docker compose pull && docker compose up -d"
- name: Notify deployment
run: |
echo "## Staging Deployment" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Successfully deployed to staging environment" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Deployed images:**" >> $GITHUB_STEP_SUMMARY
echo "- consent-service: \`develop\`" >> $GITHUB_STEP_SUMMARY
echo "- backend: \`develop\`" >> $GITHUB_STEP_SUMMARY
echo "- website: \`develop\`" >> $GITHUB_STEP_SUMMARY
# ==========================================
# Deploy to Production
# ==========================================
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [docker-build, integration-tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: https://breakpilot.app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to production server
env:
PROD_HOST: ${{ secrets.PROD_HOST }}
PROD_USER: ${{ secrets.PROD_USER }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
run: |
# This is a placeholder for actual deployment
# Configure based on your production infrastructure
echo "Deploying to production environment..."
echo "Images to deploy:"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service:latest"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:latest"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website:latest"
# Example: SSH deployment (uncomment when configured)
# mkdir -p ~/.ssh
# echo "$PROD_SSH_KEY" > ~/.ssh/id_rsa
# chmod 600 ~/.ssh/id_rsa
# ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST "cd /opt/breakpilot && docker compose pull && docker compose up -d"
- name: Notify deployment
run: |
echo "## Production Deployment" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Successfully deployed to production environment" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Deployed images:**" >> $GITHUB_STEP_SUMMARY
echo "- consent-service: \`latest\`" >> $GITHUB_STEP_SUMMARY
echo "- backend: \`latest\`" >> $GITHUB_STEP_SUMMARY
echo "- website: \`latest\`" >> $GITHUB_STEP_SUMMARY
# ==========================================
# Summary
# ==========================================
summary:
name: CI Summary
runs-on: ubuntu-latest
needs: [go-tests, python-tests, website-tests, lint, security, docker-build, integration-tests]
if: always()
steps:
- name: Check job results
run: |
echo "## CI/CD Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Go Tests | ${{ needs.go-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Python Tests | ${{ needs.python-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Website Tests | ${{ needs.website-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Linting | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docker Build | ${{ needs.docker-build.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Integration Tests | ${{ needs.integration-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Docker Images" >> $GITHUB_STEP_SUMMARY
echo "Images are pushed to: \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-*\`" >> $GITHUB_STEP_SUMMARY

222
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,222 @@
name: Security Scanning
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
schedule:
# Run security scans weekly on Sundays at midnight
- cron: '0 0 * * 0'
jobs:
# ==========================================
# Secret Scanning
# ==========================================
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: TruffleHog Secret Scan
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified
- name: GitLeaks Secret Scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
# ==========================================
# Dependency Vulnerability Scanning
# ==========================================
dependency-scan:
name: Dependency Vulnerability Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner (filesystem)
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
format: 'sarif'
output: 'trivy-fs-results.sarif'
continue-on-error: true
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-fs-results.sarif'
continue-on-error: true
# ==========================================
# Go Security Scan
# ==========================================
go-security:
name: Go Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Run Gosec Security Scanner
uses: securego/gosec@master
with:
args: '-no-fail -fmt sarif -out gosec-results.sarif ./consent-service/...'
continue-on-error: true
- name: Upload Gosec results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'gosec-results.sarif'
continue-on-error: true
- name: Run govulncheck
working-directory: ./consent-service
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./... || true
# ==========================================
# Python Security Scan
# ==========================================
python-security:
name: Python Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install safety
run: pip install safety bandit
- name: Run Safety (dependency check)
working-directory: ./backend
run: safety check -r requirements.txt --full-report || true
- name: Run Bandit (code security scan)
working-directory: ./backend
run: bandit -r . -f sarif -o bandit-results.sarif --exit-zero
- name: Upload Bandit results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: './backend/bandit-results.sarif'
continue-on-error: true
# ==========================================
# Node.js Security Scan
# ==========================================
node-security:
name: Node.js Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
working-directory: ./website
run: npm ci
- name: Run npm audit
working-directory: ./website
run: npm audit --audit-level=high || true
# ==========================================
# Docker Image Scanning
# ==========================================
docker-security:
name: Docker Image Security
runs-on: ubuntu-latest
needs: [go-security, python-security, node-security]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build consent-service image
run: docker build -t breakpilot/consent-service:scan ./consent-service
- name: Run Trivy on consent-service
uses: aquasecurity/trivy-action@master
with:
image-ref: 'breakpilot/consent-service:scan'
severity: 'CRITICAL,HIGH'
format: 'sarif'
output: 'trivy-consent-results.sarif'
continue-on-error: true
- name: Build backend image
run: docker build -t breakpilot/backend:scan ./backend
- name: Run Trivy on backend
uses: aquasecurity/trivy-action@master
with:
image-ref: 'breakpilot/backend:scan'
severity: 'CRITICAL,HIGH'
format: 'sarif'
output: 'trivy-backend-results.sarif'
continue-on-error: true
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-consent-results.sarif'
continue-on-error: true
# ==========================================
# Security Summary
# ==========================================
security-summary:
name: Security Summary
runs-on: ubuntu-latest
needs: [secret-scan, dependency-scan, go-security, python-security, node-security, docker-security]
if: always()
steps:
- name: Create security summary
run: |
echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Scan Type | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Secret Scanning | ${{ needs.secret-scan.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Dependency Scanning | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Go Security | ${{ needs.go-security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Python Security | ${{ needs.python-security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Node.js Security | ${{ needs.node-security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docker Security | ${{ needs.docker-security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Notes" >> $GITHUB_STEP_SUMMARY
echo "- Results are uploaded to the GitHub Security tab" >> $GITHUB_STEP_SUMMARY
echo "- Weekly scheduled scans run on Sundays" >> $GITHUB_STEP_SUMMARY

244
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,244 @@
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
go-tests:
name: Go Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: breakpilot
POSTGRES_PASSWORD: breakpilot123
POSTGRES_DB: breakpilot_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
cache: true
cache-dependency-path: consent-service/go.sum
- name: Install Dependencies
working-directory: ./consent-service
run: go mod download
- name: Run Tests
working-directory: ./consent-service
env:
DATABASE_URL: postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_test?sslmode=disable
JWT_SECRET: test-secret-key-for-ci
JWT_REFRESH_SECRET: test-refresh-secret-for-ci
run: |
go test -v -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
- name: Check Coverage Threshold
working-directory: ./consent-service
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Total Coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 70.0" | bc -l) )); then
echo "Coverage $COVERAGE% is below threshold 70%"
exit 1
fi
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./consent-service/coverage.out
flags: go
name: go-coverage
python-tests:
name: Python Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
cache-dependency-path: backend/requirements.txt
- name: Install Dependencies
working-directory: ./backend
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-asyncio
- name: Run Tests
working-directory: ./backend
env:
CONSENT_SERVICE_URL: http://localhost:8081
JWT_SECRET: test-secret-key-for-ci
run: |
pytest -v --cov=. --cov-report=xml --cov-report=term
- name: Check Coverage Threshold
working-directory: ./backend
run: |
COVERAGE=$(python -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); print(tree.getroot().attrib['line-rate'])")
COVERAGE_PCT=$(echo "$COVERAGE * 100" | bc)
echo "Total Coverage: ${COVERAGE_PCT}%"
if (( $(echo "$COVERAGE_PCT < 60.0" | bc -l) )); then
echo "Coverage ${COVERAGE_PCT}% is below threshold 60%"
exit 1
fi
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./backend/coverage.xml
flags: python
name: python-coverage
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Start Services
run: |
docker-compose up -d
docker-compose ps
- name: Wait for Postgres
run: |
timeout 60 bash -c 'until docker-compose exec -T postgres pg_isready -U breakpilot; do sleep 2; done'
- name: Wait for Consent Service
run: |
timeout 60 bash -c 'until curl -f http://localhost:8081/health; do sleep 2; done'
- name: Wait for Backend
run: |
timeout 60 bash -c 'until curl -f http://localhost:8000/health; do sleep 2; done'
- name: Wait for Mailpit
run: |
timeout 60 bash -c 'until curl -f http://localhost:8025/api/v1/info; do sleep 2; done'
- name: Run Integration Tests
run: |
chmod +x ./scripts/integration-tests.sh
./scripts/integration-tests.sh
- name: Show Service Logs on Failure
if: failure()
run: |
echo "=== Consent Service Logs ==="
docker-compose logs consent-service
echo "=== Backend Logs ==="
docker-compose logs backend
echo "=== Postgres Logs ==="
docker-compose logs postgres
- name: Cleanup
if: always()
run: docker-compose down -v
lint-go:
name: Go Lint
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
working-directory: consent-service
args: --timeout=5m
lint-python:
name: Python Lint
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install Dependencies
run: |
pip install flake8 black mypy
- name: Run Black
working-directory: ./backend
run: black --check .
- name: Run Flake8
working-directory: ./backend
run: flake8 . --max-line-length=120 --exclude=venv
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Run Trivy Security Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy Results to GitHub Security
uses: github/codeql-action/upload-sarif@v2
if: always()
with:
sarif_file: 'trivy-results.sarif'
all-checks:
name: All Checks Passed
runs-on: ubuntu-latest
needs: [go-tests, python-tests, integration-tests, lint-go, lint-python, security-scan]
steps:
- name: All Tests Passed
run: echo "All tests and checks passed successfully!"

77
.gitleaks.toml Normal file
View File

@@ -0,0 +1,77 @@
# Gitleaks Configuration for BreakPilot
# https://github.com/gitleaks/gitleaks
#
# Run locally: gitleaks detect --source . -v
# Pre-commit: gitleaks protect --staged -v
title = "BreakPilot Gitleaks Configuration"
# Use the default rules plus custom rules
[extend]
useDefault = true
# Custom rules for BreakPilot-specific patterns
[[rules]]
id = "anthropic-api-key"
description = "Anthropic API Key"
regex = '''sk-ant-api[0-9a-zA-Z-_]{20,}'''
tags = ["api", "anthropic"]
keywords = ["sk-ant-api"]
[[rules]]
id = "vast-api-key"
description = "vast.ai API Key"
regex = '''(?i)(vast[_-]?api[_-]?key|vast[_-]?key)\s*[=:]\s*['"]?([a-zA-Z0-9-_]{20,})['"]?'''
tags = ["api", "vast"]
keywords = ["vast"]
[[rules]]
id = "stripe-secret-key"
description = "Stripe Secret Key"
regex = '''sk_live_[0-9a-zA-Z]{24,}'''
tags = ["api", "stripe"]
keywords = ["sk_live"]
[[rules]]
id = "stripe-restricted-key"
description = "Stripe Restricted Key"
regex = '''rk_live_[0-9a-zA-Z]{24,}'''
tags = ["api", "stripe"]
keywords = ["rk_live"]
[[rules]]
id = "jwt-secret-hardcoded"
description = "Hardcoded JWT Secret"
regex = '''(?i)(jwt[_-]?secret|jwt[_-]?key)\s*[=:]\s*['"]([^'"]{32,})['"]'''
tags = ["secret", "jwt"]
keywords = ["jwt"]
# Allowlist for false positives
[allowlist]
description = "Global allowlist"
paths = [
'''\.env\.example$''',
'''\.env\.template$''',
'''docs/.*\.md$''',
'''SBOM\.md$''',
'''.*_test\.py$''',
'''.*_test\.go$''',
'''test_.*\.py$''',
'''.*\.bak$''',
'''node_modules/.*''',
'''venv/.*''',
'''\.git/.*''',
]
# Specific commit allowlist (for already-rotated secrets)
commits = []
# Regex patterns to ignore
regexes = [
'''REPLACE_WITH_REAL_.*''',
'''your-.*-key-change-in-production''',
'''breakpilot-dev-.*''',
'''DEVELOPMENT-ONLY-.*''',
'''placeholder.*''',
'''example.*key''',
]

152
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,152 @@
# Pre-commit Hooks für BreakPilot
# Installation: pip install pre-commit && pre-commit install
# Aktivierung: pre-commit install
repos:
# Go Hooks
- repo: local
hooks:
- id: go-test
name: Go Tests
entry: bash -c 'cd consent-service && go test -short ./...'
language: system
pass_filenames: false
files: \.go$
stages: [commit]
- id: go-fmt
name: Go Format
entry: bash -c 'cd consent-service && gofmt -l -w .'
language: system
pass_filenames: false
files: \.go$
stages: [commit]
- id: go-vet
name: Go Vet
entry: bash -c 'cd consent-service && go vet ./...'
language: system
pass_filenames: false
files: \.go$
stages: [commit]
- id: golangci-lint
name: Go Lint (golangci-lint)
entry: bash -c 'cd consent-service && golangci-lint run --timeout=5m'
language: system
pass_filenames: false
files: \.go$
stages: [commit]
# Python Hooks
- repo: local
hooks:
- id: pytest
name: Python Tests
entry: bash -c 'cd backend && pytest -x'
language: system
pass_filenames: false
files: \.py$
stages: [commit]
- id: black
name: Black Format
entry: black
language: python
types: [python]
args: [--line-length=120]
stages: [commit]
- id: flake8
name: Flake8 Lint
entry: flake8
language: python
types: [python]
args: [--max-line-length=120, --exclude=venv]
stages: [commit]
# General Hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
name: Trim Trailing Whitespace
- id: end-of-file-fixer
name: Fix End of Files
- id: check-yaml
name: Check YAML
args: [--allow-multiple-documents]
- id: check-json
name: Check JSON
- id: check-added-large-files
name: Check Large Files
args: [--maxkb=500]
- id: detect-private-key
name: Detect Private Keys
- id: mixed-line-ending
name: Fix Mixed Line Endings
# Security Checks
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
name: Detect Secrets
args: ['--baseline', '.secrets.baseline']
exclude: |
(?x)^(
.*\.lock|
.*\.sum|
package-lock\.json
)$
# =============================================
# DevSecOps: Gitleaks (Secrets Detection)
# =============================================
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.1
hooks:
- id: gitleaks
name: Gitleaks (secrets detection)
entry: gitleaks protect --staged -v --config .gitleaks.toml
language: golang
pass_filenames: false
# =============================================
# DevSecOps: Semgrep (SAST)
# =============================================
- repo: https://github.com/returntocorp/semgrep
rev: v1.52.0
hooks:
- id: semgrep
name: Semgrep (SAST)
args:
- --config=auto
- --config=.semgrep.yml
- --severity=ERROR
types_or: [python, javascript, typescript, go]
stages: [commit]
# =============================================
# DevSecOps: Bandit (Python Security)
# =============================================
- repo: https://github.com/PyCQA/bandit
rev: 1.7.6
hooks:
- id: bandit
name: Bandit (Python security)
args: ["-r", "backend/", "-ll", "-x", "backend/tests/*"]
files: ^backend/.*\.py$
stages: [commit]
# Branch Protection
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: no-commit-to-branch
name: Protect main/develop branches
args: ['--branch', 'main', '--branch', 'develop']
# Configuration
default_stages: [commit]
fail_fast: false

147
.semgrep.yml Normal file
View File

@@ -0,0 +1,147 @@
# Semgrep Configuration for BreakPilot
# https://semgrep.dev/
#
# Run locally: semgrep scan --config auto
# Run with this config: semgrep scan --config .semgrep.yml
rules:
# =============================================
# Python/FastAPI Security Rules
# =============================================
- id: hardcoded-secret-in-string
patterns:
- pattern-either:
- pattern: |
$VAR = "...$SECRET..."
- pattern: |
$VAR = '...$SECRET...'
message: "Potential hardcoded secret detected. Use environment variables or Vault."
languages: [python]
severity: WARNING
metadata:
category: security
cwe: "CWE-798: Use of Hard-coded Credentials"
- id: sql-injection-fastapi
patterns:
- pattern-either:
- pattern: |
$CURSOR.execute(f"...{$USER_INPUT}...")
- pattern: |
$CURSOR.execute("..." + $USER_INPUT + "...")
- pattern: |
$CURSOR.execute("..." % $USER_INPUT)
message: "Potential SQL injection. Use parameterized queries."
languages: [python]
severity: ERROR
metadata:
category: security
cwe: "CWE-89: SQL Injection"
owasp: "A03:2021 - Injection"
- id: command-injection
patterns:
- pattern-either:
- pattern: os.system($USER_INPUT)
- pattern: subprocess.call($USER_INPUT, shell=True)
- pattern: subprocess.run($USER_INPUT, shell=True)
- pattern: subprocess.Popen($USER_INPUT, shell=True)
message: "Potential command injection. Avoid shell=True with user input."
languages: [python]
severity: ERROR
metadata:
category: security
cwe: "CWE-78: OS Command Injection"
owasp: "A03:2021 - Injection"
- id: insecure-jwt-algorithm
patterns:
- pattern: jwt.decode(..., algorithms=["none"], ...)
- pattern: jwt.decode(..., algorithms=["HS256"], verify=False, ...)
message: "Insecure JWT algorithm or verification disabled."
languages: [python]
severity: ERROR
metadata:
category: security
cwe: "CWE-347: Improper Verification of Cryptographic Signature"
- id: path-traversal
patterns:
- pattern: open(... + $USER_INPUT + ...)
- pattern: open(f"...{$USER_INPUT}...")
- pattern: Path(...) / $USER_INPUT
message: "Potential path traversal. Validate and sanitize file paths."
languages: [python]
severity: WARNING
metadata:
category: security
cwe: "CWE-22: Path Traversal"
- id: insecure-pickle
patterns:
- pattern: pickle.loads($DATA)
- pattern: pickle.load($FILE)
message: "Pickle deserialization is insecure. Use JSON or other safe formats."
languages: [python]
severity: WARNING
metadata:
category: security
cwe: "CWE-502: Deserialization of Untrusted Data"
# =============================================
# Go Security Rules
# =============================================
- id: go-sql-injection
patterns:
- pattern: |
$DB.Query(fmt.Sprintf("...", $USER_INPUT))
- pattern: |
$DB.Exec(fmt.Sprintf("...", $USER_INPUT))
message: "Potential SQL injection in Go. Use parameterized queries."
languages: [go]
severity: ERROR
metadata:
category: security
cwe: "CWE-89: SQL Injection"
- id: go-hardcoded-credentials
patterns:
- pattern: |
$VAR := "..."
- metavariable-regex:
metavariable: $VAR
regex: (password|secret|apiKey|api_key|token)
message: "Potential hardcoded credential. Use environment variables."
languages: [go]
severity: WARNING
metadata:
category: security
cwe: "CWE-798: Use of Hard-coded Credentials"
# =============================================
# JavaScript/TypeScript Security Rules
# =============================================
- id: js-xss-innerhtml
patterns:
- pattern: $EL.innerHTML = $USER_INPUT
message: "Potential XSS via innerHTML. Use textContent or sanitize input."
languages: [javascript, typescript]
severity: WARNING
metadata:
category: security
cwe: "CWE-79: Cross-site Scripting"
owasp: "A03:2021 - Injection"
- id: js-eval
patterns:
- pattern: eval($CODE)
- pattern: new Function($CODE)
message: "Avoid eval() and new Function() with dynamic input."
languages: [javascript, typescript]
severity: ERROR
metadata:
category: security
cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code"

66
.trivy.yaml Normal file
View File

@@ -0,0 +1,66 @@
# Trivy Configuration for BreakPilot
# https://trivy.dev/
#
# Run: trivy image breakpilot-pwa-backend:latest
# Run filesystem: trivy fs .
# Run config: trivy config .
# Scan settings
scan:
# Security checks to perform
security-checks:
- vuln # Vulnerabilities
- config # Misconfigurations
- secret # Secrets in files
# Vulnerability settings
vulnerability:
# Vulnerability types to scan for
type:
- os # OS packages
- library # Application dependencies
# Ignore unfixed vulnerabilities
ignore-unfixed: false
# Severity settings
severity:
- CRITICAL
- HIGH
- MEDIUM
# - LOW # Uncomment to include low severity
# Output format
format: table
# Exit code on findings
exit-code: 1
# Timeout
timeout: 10m
# Cache directory
cache-dir: /tmp/trivy-cache
# Skip files/directories
skip-dirs:
- node_modules
- venv
- .venv
- __pycache__
- .git
- .idea
- .vscode
skip-files:
- "*.md"
- "*.txt"
- "*.log"
# Ignore specific vulnerabilities (add after review)
ignorefile: .trivyignore
# SBOM generation
sbom:
format: cyclonedx
output: sbom.json

9
.trivyignore Normal file
View File

@@ -0,0 +1,9 @@
# Trivy Ignore File for BreakPilot
# Add vulnerability IDs to ignore after security review
# Format: CVE-XXXX-XXXXX or GHSA-xxxx-xxxx-xxxx
# Example (remove after adding real ignores):
# CVE-2021-12345 # Reason: Not exploitable in our context
# Reviewed and accepted risks:
# (Add vulnerabilities here after security team review)

132
.woodpecker/auto-fix.yml Normal file
View File

@@ -0,0 +1,132 @@
# Woodpecker CI Auto-Fix Pipeline
# Automatische Reparatur fehlgeschlagener Tests
#
# Laeuft taeglich um 2:00 Uhr nachts
# Analysiert offene Backlog-Items und versucht automatische Fixes
when:
- event: cron
cron: "0 2 * * *" # Taeglich um 2:00 Uhr
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
steps:
# ========================================
# 1. Fetch Failed Tests from Backlog
# ========================================
fetch-backlog:
image: curlimages/curl:latest
commands:
- |
curl -s "http://backend:8000/api/tests/backlog?status=open&priority=critical" \
-o backlog-critical.json
curl -s "http://backend:8000/api/tests/backlog?status=open&priority=high" \
-o backlog-high.json
- echo "=== Kritische Tests ==="
- cat backlog-critical.json | head -50
- echo "=== Hohe Prioritaet ==="
- cat backlog-high.json | head -50
# ========================================
# 2. Analyze and Classify Errors
# ========================================
analyze-errors:
image: python:3.12-slim
commands:
- pip install --quiet jq-py
- |
python3 << 'EOF'
import json
import os
def classify_error(error_type, error_msg):
"""Klassifiziert Fehler nach Auto-Fix-Potential"""
auto_fixable = {
'nil_pointer': 'high',
'import_error': 'high',
'undefined_variable': 'medium',
'type_error': 'medium',
'assertion': 'low',
'timeout': 'low',
'logic_error': 'manual'
}
return auto_fixable.get(error_type, 'manual')
# Lade Backlog
try:
with open('backlog-critical.json') as f:
critical = json.load(f)
with open('backlog-high.json') as f:
high = json.load(f)
except:
print("Keine Backlog-Daten gefunden")
exit(0)
all_items = critical.get('items', []) + high.get('items', [])
auto_fix_candidates = []
for item in all_items:
fix_potential = classify_error(
item.get('error_type', 'unknown'),
item.get('error_message', '')
)
if fix_potential in ['high', 'medium']:
auto_fix_candidates.append({
'id': item.get('id'),
'test_name': item.get('test_name'),
'error_type': item.get('error_type'),
'fix_potential': fix_potential
})
print(f"Auto-Fix Kandidaten: {len(auto_fix_candidates)}")
with open('auto-fix-candidates.json', 'w') as f:
json.dump(auto_fix_candidates, f, indent=2)
EOF
depends_on:
- fetch-backlog
# ========================================
# 3. Generate Fix Suggestions (Placeholder)
# ========================================
generate-fixes:
image: python:3.12-slim
commands:
- |
echo "Auto-Fix Generation ist in Phase 4 geplant"
echo "Aktuell werden nur Vorschlaege generiert"
# Hier wuerde Claude API oder anderer LLM aufgerufen werden
# python3 scripts/auto-fix-agent.py auto-fix-candidates.json
echo "Fix-Vorschlaege wuerden hier generiert werden"
depends_on:
- analyze-errors
# ========================================
# 4. Report Results
# ========================================
report-results:
image: curlimages/curl:latest
commands:
- |
curl -X POST "http://backend:8000/api/tests/auto-fix/report" \
-H "Content-Type: application/json" \
-d "{
\"run_date\": \"$(date -Iseconds)\",
\"candidates_found\": $(cat auto-fix-candidates.json | wc -l),
\"fixes_attempted\": 0,
\"fixes_successful\": 0,
\"status\": \"analysis_only\"
}" || true
when:
status: [success, failure]

View File

@@ -0,0 +1,37 @@
# One-time pipeline to build the custom Python CI image
# Trigger manually, then delete this file
#
# This builds the breakpilot/python-ci:3.12 image on the CI runner
when:
- event: manual
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
steps:
build-python-ci-image:
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- |
echo "=== Building breakpilot/python-ci:3.12 ==="
docker build \
-t breakpilot/python-ci:3.12 \
-t breakpilot/python-ci:latest \
-f .docker/python-ci.Dockerfile \
.
echo ""
echo "=== Build complete ==="
docker images | grep breakpilot/python-ci
echo ""
echo "Image is now available for CI pipelines!"

161
.woodpecker/integration.yml Normal file
View File

@@ -0,0 +1,161 @@
# Integration Tests Pipeline
# Separate Datei weil Services auf Pipeline-Ebene definiert werden muessen
#
# Diese Pipeline laeuft parallel zur main.yml und testet:
# - Database Connectivity (PostgreSQL)
# - Cache Connectivity (Valkey/Redis)
# - Service-to-Service Kommunikation
#
# Dokumentation: docs/testing/integration-test-environment.md
when:
- event: [push, pull_request]
branch: [main, develop]
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
# Services auf Pipeline-Ebene (NICHT Step-Ebene!)
# Diese Services sind fuer ALLE Steps verfuegbar
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: breakpilot
POSTGRES_PASSWORD: breakpilot_test
POSTGRES_DB: breakpilot_test
valkey:
image: valkey/valkey:8-alpine
steps:
wait-for-services:
image: postgres:16-alpine
commands:
- |
echo "=== Waiting for PostgreSQL ==="
for i in $(seq 1 30); do
if pg_isready -h postgres -U breakpilot; then
echo "PostgreSQL ready after $i attempts!"
break
fi
echo "Attempt $i/30: PostgreSQL not ready, waiting..."
sleep 2
done
# Final check
if ! pg_isready -h postgres -U breakpilot; then
echo "ERROR: PostgreSQL not ready after 30 attempts"
exit 1
fi
- |
echo "=== Waiting for Valkey ==="
# Install redis-cli in postgres alpine image
apk add --no-cache redis > /dev/null 2>&1 || true
for i in $(seq 1 30); do
if redis-cli -h valkey ping 2>/dev/null | grep -q PONG; then
echo "Valkey ready after $i attempts!"
break
fi
echo "Attempt $i/30: Valkey not ready, waiting..."
sleep 2
done
# Final check
if ! redis-cli -h valkey ping 2>/dev/null | grep -q PONG; then
echo "ERROR: Valkey not ready after 30 attempts"
exit 1
fi
- echo "=== All services ready ==="
integration-tests:
image: breakpilot/python-ci:3.12
environment:
CI: "true"
DATABASE_URL: postgresql://breakpilot:breakpilot_test@postgres:5432/breakpilot_test
VALKEY_URL: redis://valkey:6379
REDIS_URL: redis://valkey:6379
SKIP_INTEGRATION_TESTS: "false"
SKIP_DB_TESTS: "false"
SKIP_WEASYPRINT_TESTS: "false"
# Test-spezifische Umgebungsvariablen
ENVIRONMENT: "testing"
JWT_SECRET: "test-secret-key-for-integration-tests"
TEACHER_REQUIRE_AUTH: "false"
GAME_USE_DATABASE: "false"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
cd backend
# PYTHONPATH setzen damit lokale Module gefunden werden
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
echo "=== Installing dependencies ==="
pip install --quiet --no-cache-dir -r requirements.txt
echo "=== Running Integration Tests ==="
set +e
python -m pytest tests/test_integration/ -v \
--tb=short \
--json-report \
--json-report-file=../.ci-results/test-integration.json
TEST_EXIT=$?
set -e
# Ergebnisse auswerten
if [ -f ../.ci-results/test-integration.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
echo "WARNUNG: Keine JSON-Ergebnisse gefunden"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"integration-tests\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-integration.json
cat ../.ci-results/results-integration.json
echo ""
echo "=== Integration Test Summary ==="
echo "Total: $TOTAL | Passed: $PASSED | Failed: $FAILED | Skipped: $SKIPPED"
if [ "$TEST_EXIT" -ne "0" ]; then
echo "Integration tests failed with exit code $TEST_EXIT"
exit 1
fi
depends_on:
- wait-for-services
report-integration-results:
image: curlimages/curl:8.10.1
commands:
- |
set -uo pipefail
echo "=== Sende Integration Test-Ergebnisse an Dashboard ==="
if [ -f .ci-results/results-integration.json ]; then
echo "Sending integration test results..."
curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \
-H "Content-Type: application/json" \
-d "{
\"pipeline_id\": \"${CI_PIPELINE_NUMBER}\",
\"commit\": \"${CI_COMMIT_SHA}\",
\"branch\": \"${CI_COMMIT_BRANCH}\",
\"status\": \"${CI_PIPELINE_STATUS:-unknown}\",
\"test_results\": $(cat .ci-results/results-integration.json)
}" || echo "WARNUNG: Konnte Ergebnisse nicht an Dashboard senden"
else
echo "Keine Integration-Ergebnisse zum Senden gefunden"
fi
echo "=== Integration Test-Ergebnisse gesendet ==="
when:
status: [success, failure]
depends_on:
- integration-tests

669
.woodpecker/main.yml Normal file
View File

@@ -0,0 +1,669 @@
# Woodpecker CI Main Pipeline
# BreakPilot PWA - CI/CD Pipeline
#
# Plattform: ARM64 (Apple Silicon Mac Mini)
#
# Strategie:
# - Tests laufen bei JEDEM Push/PR
# - Test-Ergebnisse werden an Dashboard gesendet
# - Builds/Scans laufen nur bei Tags oder manuell
# - Deployment nur manuell (Sicherheit)
when:
- event: [push, pull_request, manual, tag]
branch: [main, develop]
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
variables:
- &golang_image golang:1.23-alpine
- &python_image python:3.12-slim
- &python_ci_image breakpilot/python-ci:3.12 # Custom image with WeasyPrint
- &nodejs_image node:20-alpine
- &docker_image docker:27-cli
steps:
# ========================================
# STAGE 1: Lint (nur bei PRs)
# ========================================
go-lint:
image: golangci/golangci-lint:v1.55-alpine
commands:
- cd consent-service && golangci-lint run --timeout 5m ./...
- cd ../billing-service && golangci-lint run --timeout 5m ./...
- cd ../school-service && golangci-lint run --timeout 5m ./...
when:
event: pull_request
python-lint:
image: *python_image
commands:
- pip install --quiet ruff black
- ruff check backend/ --output-format=github || true
- black --check backend/ || true
when:
event: pull_request
# ========================================
# STAGE 2: Unit Tests mit JSON-Ausgabe
# Ergebnisse werden im Workspace gespeichert (.ci-results/)
# ========================================
test-go-consent:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "consent-service" ]; then
echo '{"service":"consent-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-consent.json
echo "WARNUNG: consent-service Verzeichnis nicht gefunden"
exit 0
fi
cd consent-service
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-consent.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-consent.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"consent-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-consent.json
cat ../.ci-results/results-consent.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-go-billing:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "billing-service" ]; then
echo '{"service":"billing-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-billing.json
echo "WARNUNG: billing-service Verzeichnis nicht gefunden"
exit 0
fi
cd billing-service
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-billing.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-billing.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"billing-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-billing.json
cat ../.ci-results/results-billing.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-go-school:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "school-service" ]; then
echo '{"service":"school-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-school.json
echo "WARNUNG: school-service Verzeichnis nicht gefunden"
exit 0
fi
cd school-service
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-school.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-school.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"school-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-school.json
cat ../.ci-results/results-school.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-go-edu-search:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "edu-search-service" ]; then
echo '{"service":"edu-search-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-edu-search.json
echo "WARNUNG: edu-search-service Verzeichnis nicht gefunden"
exit 0
fi
cd edu-search-service
set +e
go test -v -json -coverprofile=coverage.out ./internal/... 2>&1 | tee ../.ci-results/test-edu-search.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-edu-search.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"edu-search-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-edu-search.json
cat ../.ci-results/results-edu-search.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-go-ai-compliance:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "ai-compliance-sdk" ]; then
echo '{"service":"ai-compliance-sdk","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-ai-compliance.json
echo "WARNUNG: ai-compliance-sdk Verzeichnis nicht gefunden"
exit 0
fi
cd ai-compliance-sdk
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-ai-compliance.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-ai-compliance.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"ai-compliance-sdk\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-ai-compliance.json
cat ../.ci-results/results-ai-compliance.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-python-backend:
image: *python_ci_image
environment:
CI: "true"
DATABASE_URL: "postgresql://test:test@localhost:5432/test_db"
SKIP_DB_TESTS: "true"
SKIP_WEASYPRINT_TESTS: "false"
SKIP_INTEGRATION_TESTS: "true"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "backend" ]; then
echo '{"service":"backend","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-backend.json
echo "WARNUNG: backend Verzeichnis nicht gefunden"
exit 0
fi
cd backend
# Set PYTHONPATH to current directory (backend) so local packages like classroom_engine, alerts_agent are found
# IMPORTANT: Use absolute path and export before pip install to ensure modules are available
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
# Test tools are pre-installed in breakpilot/python-ci image
# Only install project-specific dependencies
pip install --quiet --no-cache-dir -r requirements.txt
# NOTE: PostgreSQL service removed - tests that require DB are skipped via SKIP_DB_TESTS=true
# For full integration tests, use: docker compose -f docker-compose.test.yml up -d
set +e
# Use python -m pytest to ensure PYTHONPATH is properly applied before pytest starts
python -m pytest tests/ -v --tb=short --cov=. --cov-report=term-missing --json-report --json-report-file=../.ci-results/test-backend.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-backend.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"backend\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-backend.json
cat ../.ci-results/results-backend.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
test-python-voice:
image: *python_image
environment:
CI: "true"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service" ]; then
echo '{"service":"voice-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-voice.json
echo "WARNUNG: voice-service Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt
pip install --quiet --no-cache-dir pytest-json-report
set +e
python -m pytest tests/ -v --tb=short --json-report --json-report-file=../.ci-results/test-voice.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-voice.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"voice-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-voice.json
cat ../.ci-results/results-voice.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
test-bqas-golden:
image: *python_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service/tests/bqas" ]; then
echo '{"service":"bqas-golden","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-golden.json
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt
pip install --quiet --no-cache-dir pytest-json-report pytest-asyncio
set +e
python -m pytest tests/bqas/test_golden.py tests/bqas/test_regression.py tests/bqas/test_synthetic.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-golden.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-bqas-golden.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"bqas-golden\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-golden.json
cat ../.ci-results/results-bqas-golden.json
# BQAS tests may skip if Ollama not available - don't fail pipeline
if [ "$FAILED" -gt "0" ]; then exit 1; fi
test-bqas-rag:
image: *python_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service/tests/bqas" ]; then
echo '{"service":"bqas-rag","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-rag.json
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt
pip install --quiet --no-cache-dir pytest-json-report pytest-asyncio
set +e
python -m pytest tests/bqas/test_rag.py tests/bqas/test_notifier.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-rag.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-bqas-rag.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"bqas-rag\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-rag.json
cat ../.ci-results/results-bqas-rag.json
# BQAS tests may skip if Ollama not available - don't fail pipeline
if [ "$FAILED" -gt "0" ]; then exit 1; fi
test-python-klausur:
image: *python_image
environment:
CI: "true"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "klausur-service/backend" ]; then
echo '{"service":"klausur-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-klausur.json
echo "WARNUNG: klausur-service/backend Verzeichnis nicht gefunden"
exit 0
fi
cd klausur-service/backend
# Set PYTHONPATH to current directory so local modules like hyde, hybrid_search, etc. are found
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || pip install --quiet --no-cache-dir fastapi uvicorn pytest pytest-asyncio pytest-json-report
pip install --quiet --no-cache-dir pytest-json-report
set +e
python -m pytest tests/ -v --tb=short --json-report --json-report-file=../../.ci-results/test-klausur.json
TEST_EXIT=$?
set -e
if [ -f ../../.ci-results/test-klausur.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"klausur-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../../.ci-results/results-klausur.json
cat ../../.ci-results/results-klausur.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
test-nodejs-h5p:
image: *nodejs_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "h5p-service" ]; then
echo '{"service":"h5p-service","framework":"jest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-h5p.json
echo "WARNUNG: h5p-service Verzeichnis nicht gefunden"
exit 0
fi
cd h5p-service
npm ci --silent 2>/dev/null || npm install --silent
set +e
npm run test:ci -- --json --outputFile=../.ci-results/test-h5p.json 2>&1
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-h5p.json ]; then
TOTAL=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numTotalTests || 0)" 2>/dev/null || echo "0")
PASSED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numPassedTests || 0)" 2>/dev/null || echo "0")
FAILED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numFailedTests || 0)" 2>/dev/null || echo "0")
SKIPPED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numPendingTests || 0)" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
[ -z "$TOTAL" ] && TOTAL=0
[ -z "$PASSED" ] && PASSED=0
[ -z "$FAILED" ] && FAILED=0
[ -z "$SKIPPED" ] && SKIPPED=0
echo "{\"service\":\"h5p-service\",\"framework\":\"jest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-h5p.json
cat ../.ci-results/results-h5p.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
# ========================================
# STAGE 2.5: Integration Tests
# ========================================
# Integration Tests laufen in separater Pipeline:
# .woodpecker/integration.yml
# (benötigt Pipeline-Level Services für PostgreSQL und Valkey)
# ========================================
# STAGE 3: Test-Ergebnisse an Dashboard senden
# ========================================
report-test-results:
image: curlimages/curl:8.10.1
commands:
- |
set -uo pipefail
echo "=== Sende Test-Ergebnisse an Dashboard ==="
echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}"
ls -la .ci-results/ || echo "Verzeichnis nicht gefunden"
PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}"
for f in .ci-results/results-*.json; do
[ -f "$f" ] || continue
echo "Sending: $f"
curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \
-H "Content-Type: application/json" \
-d "{
\"pipeline_id\": \"${CI_PIPELINE_NUMBER}\",
\"commit\": \"${CI_COMMIT_SHA}\",
\"branch\": \"${CI_COMMIT_BRANCH}\",
\"status\": \"${PIPELINE_STATUS}\",
\"test_results\": $(cat "$f")
}" || echo "WARNUNG: Konnte $f nicht senden"
done
echo "=== Test-Ergebnisse gesendet ==="
when:
status: [success, failure]
depends_on:
- test-go-consent
- test-go-billing
- test-go-school
- test-go-edu-search
- test-go-ai-compliance
- test-python-backend
- test-python-voice
- test-bqas-golden
- test-bqas-rag
- test-python-klausur
- test-nodejs-h5p
# ========================================
# STAGE 4: Build & Security (nur Tags/manuell)
# ========================================
build-consent-service:
image: *docker_image
commands:
- docker build -t breakpilot/consent-service:${CI_COMMIT_SHA:0:8} ./consent-service
- docker tag breakpilot/consent-service:${CI_COMMIT_SHA:0:8} breakpilot/consent-service:latest
- echo "Built breakpilot/consent-service:${CI_COMMIT_SHA:0:8}"
when:
- event: tag
- event: manual
build-backend:
image: *docker_image
commands:
- docker build -t breakpilot/backend:${CI_COMMIT_SHA:0:8} ./backend
- docker tag breakpilot/backend:${CI_COMMIT_SHA:0:8} breakpilot/backend:latest
- echo "Built breakpilot/backend:${CI_COMMIT_SHA:0:8}"
when:
- event: tag
- event: manual
build-voice-service:
image: *docker_image
commands:
- |
if [ -d ./voice-service ]; then
docker build -t breakpilot/voice-service:${CI_COMMIT_SHA:0:8} ./voice-service
docker tag breakpilot/voice-service:${CI_COMMIT_SHA:0:8} breakpilot/voice-service:latest
echo "Built breakpilot/voice-service:${CI_COMMIT_SHA:0:8}"
else
echo "voice-service Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
generate-sbom:
image: *golang_image
commands:
- |
echo "Installing syft for ARM64..."
wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
syft dir:./consent-service -o cyclonedx-json > sbom-consent.json
syft dir:./backend -o cyclonedx-json > sbom-backend.json
if [ -d ./voice-service ]; then
syft dir:./voice-service -o cyclonedx-json > sbom-voice.json
fi
echo "SBOMs generated successfully"
when:
- event: tag
- event: manual
vulnerability-scan:
image: *golang_image
commands:
- |
echo "Installing grype for ARM64..."
wget -qO- https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
grype sbom:sbom-consent.json -o table --fail-on critical || true
grype sbom:sbom-backend.json -o table --fail-on critical || true
if [ -f sbom-voice.json ]; then
grype sbom:sbom-voice.json -o table --fail-on critical || true
fi
when:
- event: tag
- event: manual
depends_on:
- generate-sbom
# ========================================
# STAGE 5: Deploy (nur manuell)
# ========================================
deploy-production:
image: *docker_image
commands:
- echo "Deploying to production..."
- docker compose -f docker-compose.yml pull || true
- docker compose -f docker-compose.yml up -d --remove-orphans || true
when:
event: manual
depends_on:
- build-consent-service
- build-backend

314
.woodpecker/security.yml Normal file
View File

@@ -0,0 +1,314 @@
# Woodpecker CI Security Pipeline
# Dedizierte Security-Scans fuer DevSecOps
#
# Laeuft taeglich via Cron und bei jedem PR
when:
- event: cron
cron: "0 3 * * *" # Taeglich um 3:00 Uhr
- event: pull_request
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
steps:
# ========================================
# Static Analysis
# ========================================
semgrep-scan:
image: returntocorp/semgrep:latest
commands:
- semgrep scan --config auto --json -o semgrep-results.json . || true
- |
if [ -f semgrep-results.json ]; then
echo "=== Semgrep Findings ==="
cat semgrep-results.json | head -100
fi
when:
event: [pull_request, cron]
bandit-python:
image: python:3.12-slim
commands:
- pip install --quiet bandit
- bandit -r backend/ -f json -o bandit-results.json || true
- |
if [ -f bandit-results.json ]; then
echo "=== Bandit Findings ==="
cat bandit-results.json | head -50
fi
when:
event: [pull_request, cron]
gosec-go:
image: securego/gosec:latest
commands:
- gosec -fmt json -out gosec-consent.json ./consent-service/... || true
- gosec -fmt json -out gosec-billing.json ./billing-service/... || true
- echo "Go Security Scan abgeschlossen"
when:
event: [pull_request, cron]
# ========================================
# Secrets Detection
# ========================================
gitleaks-scan:
image: zricethezav/gitleaks:latest
commands:
- gitleaks detect --source . --report-format json --report-path gitleaks-report.json || true
- |
if [ -s gitleaks-report.json ]; then
echo "=== WARNUNG: Potentielle Secrets gefunden ==="
cat gitleaks-report.json
else
echo "Keine Secrets gefunden"
fi
trufflehog-scan:
image: trufflesecurity/trufflehog:latest
commands:
- trufflehog filesystem . --json > trufflehog-results.json 2>&1 || true
- echo "TruffleHog Scan abgeschlossen"
# ========================================
# Dependency Vulnerabilities
# ========================================
npm-audit:
image: node:20-alpine
commands:
- cd website && npm audit --json > ../npm-audit-website.json || true
- cd ../studio-v2 && npm audit --json > ../npm-audit-studio.json || true
- cd ../admin-v2 && npm audit --json > ../npm-audit-admin.json || true
- echo "NPM Audit abgeschlossen"
when:
event: [pull_request, cron]
pip-audit:
image: python:3.12-slim
commands:
- pip install --quiet pip-audit
- pip-audit -r backend/requirements.txt --format json -o pip-audit-backend.json || true
- pip-audit -r voice-service/requirements.txt --format json -o pip-audit-voice.json || true
- echo "Pip Audit abgeschlossen"
when:
event: [pull_request, cron]
go-vulncheck:
image: golang:1.21-alpine
commands:
- go install golang.org/x/vuln/cmd/govulncheck@latest
- cd consent-service && govulncheck ./... || true
- cd ../billing-service && govulncheck ./... || true
- echo "Go Vulncheck abgeschlossen"
when:
event: [pull_request, cron]
# ========================================
# Container Security
# ========================================
trivy-filesystem:
image: aquasec/trivy:latest
commands:
- trivy fs --severity HIGH,CRITICAL --format json -o trivy-fs.json . || true
- echo "Trivy Filesystem Scan abgeschlossen"
when:
event: cron
# ========================================
# SBOM Generation (taeglich)
# ========================================
daily-sbom:
image: anchore/syft:latest
commands:
- mkdir -p sbom-reports
- syft dir:. -o cyclonedx-json > sbom-reports/sbom-full-$(date +%Y%m%d).json
- echo "SBOM generiert"
when:
event: cron
# ========================================
# AUTO-FIX: Dependency Vulnerabilities
# Laeuft nur bei Cron (nightly), nicht bei PRs
# ========================================
auto-fix-npm:
image: node:20-alpine
commands:
- apk add --no-cache git
- |
echo "=== Auto-Fix: NPM Dependencies ==="
FIXES_APPLIED=0
for dir in website studio-v2 admin-v2 h5p-service; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
echo "Pruefe $dir..."
cd $dir
# Speichere Hash vor Fix
BEFORE=$(md5sum package-lock.json 2>/dev/null || echo "none")
# npm audit fix (ohne --force fuer sichere Updates)
npm audit fix --package-lock-only 2>/dev/null || true
# Pruefe ob Aenderungen
AFTER=$(md5sum package-lock.json 2>/dev/null || echo "none")
if [ "$BEFORE" != "$AFTER" ]; then
echo " -> Fixes angewendet in $dir"
FIXES_APPLIED=$((FIXES_APPLIED + 1))
fi
cd ..
fi
done
echo "NPM Auto-Fix abgeschlossen: $FIXES_APPLIED Projekte aktualisiert"
echo "NPM_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env
when:
event: cron
auto-fix-python:
image: python:3.12-slim
commands:
- apt-get update && apt-get install -y git
- pip install --quiet pip-audit
- |
echo "=== Auto-Fix: Python Dependencies ==="
FIXES_APPLIED=0
for reqfile in backend/requirements.txt voice-service/requirements.txt klausur-service/backend/requirements.txt; do
if [ -f "$reqfile" ]; then
echo "Pruefe $reqfile..."
DIR=$(dirname $reqfile)
# pip-audit mit --fix (aktualisiert requirements.txt)
pip-audit -r $reqfile --fix 2>/dev/null || true
# Pruefe ob requirements.txt geaendert wurde
if git diff --quiet $reqfile 2>/dev/null; then
echo " -> Keine Aenderungen in $reqfile"
else
echo " -> Fixes angewendet in $reqfile"
FIXES_APPLIED=$((FIXES_APPLIED + 1))
fi
fi
done
echo "Python Auto-Fix abgeschlossen: $FIXES_APPLIED Dateien aktualisiert"
echo "PYTHON_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env
when:
event: cron
auto-fix-go:
image: golang:1.21-alpine
commands:
- apk add --no-cache git
- |
echo "=== Auto-Fix: Go Dependencies ==="
FIXES_APPLIED=0
for dir in consent-service billing-service school-service edu-search ai-compliance-sdk; do
if [ -d "$dir" ] && [ -f "$dir/go.mod" ]; then
echo "Pruefe $dir..."
cd $dir
# Go mod tidy und update
go get -u ./... 2>/dev/null || true
go mod tidy 2>/dev/null || true
# Pruefe ob go.mod/go.sum geaendert wurden
if git diff --quiet go.mod go.sum 2>/dev/null; then
echo " -> Keine Aenderungen in $dir"
else
echo " -> Updates angewendet in $dir"
FIXES_APPLIED=$((FIXES_APPLIED + 1))
fi
cd ..
fi
done
echo "Go Auto-Fix abgeschlossen: $FIXES_APPLIED Module aktualisiert"
echo "GO_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env
when:
event: cron
# ========================================
# Commit & Push Auto-Fixes
# ========================================
commit-security-fixes:
image: alpine/git:latest
commands:
- |
echo "=== Commit Security Fixes ==="
# Git konfigurieren
git config --global user.email "security-bot@breakpilot.de"
git config --global user.name "Security Bot"
git config --global --add safe.directory /woodpecker/src
# Pruefe ob es Aenderungen gibt
if git diff --quiet && git diff --cached --quiet; then
echo "Keine Security-Fixes zum Committen"
exit 0
fi
# Zeige was geaendert wurde
echo "Geaenderte Dateien:"
git status --short
# Stage alle relevanten Dateien
git add -A \
*/package-lock.json \
*/requirements.txt \
*/go.mod \
*/go.sum \
2>/dev/null || true
# Commit erstellen
TIMESTAMP=$(date +%Y-%m-%d)
git commit -m "fix(security): auto-fix vulnerable dependencies [$TIMESTAMP]
Automatische Sicherheitsupdates durch CI/CD Pipeline:
- npm audit fix fuer Node.js Projekte
- pip-audit --fix fuer Python Projekte
- go get -u fuer Go Module
Co-Authored-By: Security Bot <security-bot@breakpilot.de>" || echo "Nichts zu committen"
# Push zum Repository
git push origin HEAD:main || echo "Push fehlgeschlagen - manueller Review erforderlich"
echo "Security-Fixes committed und gepusht"
when:
event: cron
status: success
# ========================================
# Report to Dashboard
# ========================================
update-security-dashboard:
image: curlimages/curl:latest
commands:
- |
curl -X POST "http://backend:8000/api/security/scan-results" \
-H "Content-Type: application/json" \
-d "{
\"scan_type\": \"daily\",
\"timestamp\": \"$(date -Iseconds)\",
\"tools\": [\"semgrep\", \"bandit\", \"gosec\", \"gitleaks\", \"trivy\"]
}" || true
when:
status: [success, failure]
event: cron

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,566 @@
# BreakPilot Consent Management System - Projektplan
## Executive Summary
Dieses Dokument beschreibt den Plan zur Entwicklung eines vollständigen Consent Management Systems (CMS) für BreakPilot. Das System wird komplett neu entwickelt und ersetzt das bestehende Policy Vault System, das Bugs enthält und nicht optimal funktioniert.
---
## Technologie-Entscheidung: Warum welche Sprache?
### Backend-Optionen im Vergleich
| Kriterium | Rust | Go | Python (FastAPI) | TypeScript (NestJS) |
|-----------|------|-----|------------------|---------------------|
| **Performance** | Exzellent | Sehr gut | Gut | Gut |
| **Memory Safety** | Garantiert | GC | GC | GC |
| **Entwicklungsgeschwindigkeit** | Langsam | Mittel | Schnell | Schnell |
| **Lernkurve** | Steil | Flach | Flach | Mittel |
| **Ecosystem für Web** | Wachsend | Sehr gut | Exzellent | Exzellent |
| **Integration mit BreakPilot** | Neu | Neu | Bereits vorhanden | Möglich |
| **Team-Erfahrung** | ? | ? | Vorhanden | Möglich |
### Empfehlung: **Python (FastAPI)** oder **Go**
#### Option A: Python mit FastAPI (Empfohlen für schnelle Integration)
**Vorteile:**
- Bereits im BreakPilot-Projekt verwendet
- Schnelle Entwicklung
- Exzellente Dokumentation (automatisch generiert)
- Einfache Integration mit bestehendem Code
- Type Hints für bessere Code-Qualität
- Async/Await Support
**Nachteile:**
- Langsamer als Rust/Go bei hoher Last
- GIL-Einschränkungen bei CPU-intensiven Tasks
#### Option B: Go (Empfohlen für Microservice-Architektur)
**Vorteile:**
- Extrem schnell und effizient
- Exzellent für Microservices
- Einfache Deployment (Single Binary)
- Gute Concurrency
- Statische Typisierung
**Nachteile:**
- Neuer Tech-Stack im Projekt
- Getrennte Codebasis von BreakPilot
#### Option C: Rust (Für maximale Performance & Sicherheit)
**Vorteile:**
- Höchste Performance
- Memory Safety ohne GC
- Exzellente Sicherheit
- WebAssembly-Support
**Nachteile:**
- Sehr steile Lernkurve
- Längere Entwicklungszeit (2-3x)
- Kleineres Web-Ecosystem
- Komplexere Fehlerbehandlung
### Finale Empfehlung
**Für BreakPilot empfehle ich: Go (Golang)**
Begründung:
1. **Unabhängiger Microservice** - Das CMS sollte als eigenständiger Service laufen
2. **Performance** - Consent-Checks müssen schnell sein (bei jedem API-Call)
3. **Einfaches Deployment** - Single Binary, ideal für Container
4. **Gute Balance** - Schneller als Python, einfacher als Rust
5. **Zukunftssicher** - Moderne Sprache mit wachsendem Ecosystem
---
## Systemarchitektur
```
┌─────────────────────────────────────────────────────────────────────────┐
│ BreakPilot Ecosystem │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BreakPilot │ │ Consent Admin │ │ BreakPilot │ │
│ │ Studio (Web) │ │ Dashboard │ │ Mobile Apps │ │
│ │ (Python/HTML) │ │ (Vue.js/React) │ │ (iOS/Android) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └──────────────────────┼──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ API Gateway / Proxy │ │
│ └────────────┬────────────┘ │
│ │ │
│ ┌─────────────────────┼─────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BreakPilot API │ │ Consent Service │ │ Auth Service │ │
│ │ (Python/FastAPI)│ │ (Go) │ │ (Go) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └────────────────────┼────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ PostgreSQL │ │
│ │ (Shared Database) │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Projektphasen
### Phase 1: Grundlagen & Datenbank (Woche 1-2)
**Ziel:** Datenbank-Schema und Basis-Services
#### 1.1 Datenbank-Design
- [ ] Users-Tabelle (Integration mit BreakPilot Auth)
- [ ] Legal Documents (AGB, Datenschutz, Community Guidelines, etc.)
- [ ] Document Versions (Versionierung mit Freigabe-Workflow)
- [ ] User Consents (Welcher User hat wann was zugestimmt)
- [ ] Cookie Categories (Notwendig, Funktional, Marketing, Analytics)
- [ ] Cookie Consents (Granulare Cookie-Zustimmungen)
- [ ] Audit Log (DSGVO-konforme Protokollierung)
#### 1.2 Go Backend Setup
- [ ] Projekt-Struktur mit Clean Architecture
- [ ] Database Layer (sqlx oder GORM)
- [ ] Migration System
- [ ] Config Management
- [ ] Logging & Error Handling
### Phase 2: Core Consent Service (Woche 3-4)
**Ziel:** Kern-Funktionalität für Consent-Management
#### 2.1 Document Management API
- [ ] CRUD für Legal Documents
- [ ] Versionierung mit Diff-Tracking
- [ ] Draft/Published/Archived Status
- [ ] Mehrsprachigkeit (DE, EN, etc.)
#### 2.2 Consent Tracking API
- [ ] User Consent erstellen/abrufen
- [ ] Consent History pro User
- [ ] Bulk-Consent für mehrere Dokumente
- [ ] Consent Withdrawal (Widerruf)
#### 2.3 Cookie Consent API
- [ ] Cookie-Kategorien verwalten
- [ ] Granulare Cookie-Einstellungen
- [ ] Consent-Banner Konfiguration
### Phase 3: Admin Dashboard (Woche 5-6)
**Ziel:** Web-Interface für Administratoren
#### 3.1 Admin Frontend (Vue.js oder React)
- [ ] Login/Auth (Integration mit BreakPilot)
- [ ] Dashboard mit Statistiken
- [ ] Document Editor (Rich Text)
- [ ] Version Management UI
- [ ] User Consent Übersicht
- [ ] Cookie Management UI
#### 3.2 Freigabe-Workflow
- [ ] Draft → Review → Approved → Published
- [ ] Benachrichtigungen bei neuen Versionen
- [ ] Rollback-Funktion
### Phase 4: BreakPilot Integration (Woche 7-8)
**Ziel:** Integration in BreakPilot Studio
#### 4.1 User-facing Features
- [ ] "Legal" Button in Einstellungen
- [ ] Consent-Historie anzeigen
- [ ] Cookie-Präferenzen ändern
- [ ] Datenauskunft anfordern (DSGVO Art. 15)
#### 4.2 Cookie Banner
- [ ] Cookie-Consent-Modal beim ersten Besuch
- [ ] Granulare Auswahl der Kategorien
- [ ] "Alle akzeptieren" / "Nur notwendige"
- [ ] Persistente Speicherung
#### 4.3 Consent-Check Middleware
- [ ] Automatische Prüfung bei API-Calls
- [ ] Blocking bei fehlender Zustimmung
- [ ] Marketing-Opt-out respektieren
### Phase 5: Data Subject Rights (Woche 9-10)
**Ziel:** DSGVO-Compliance Features
#### 5.1 Datenauskunft (Art. 15 DSGVO)
- [ ] API für "Welche Daten haben wir?"
- [ ] Export als JSON/PDF
- [ ] Automatisierte Bereitstellung
#### 5.2 Datenlöschung (Art. 17 DSGVO)
- [ ] "Recht auf Vergessenwerden"
- [ ] Anonymisierung statt Löschung (wo nötig)
- [ ] Audit Trail für Löschungen
#### 5.3 Datenportabilität (Art. 20 DSGVO)
- [ ] Export in maschinenlesbarem Format
- [ ] Download-Funktion im Frontend
### Phase 6: Testing & Security (Woche 11-12)
**Ziel:** Absicherung und Qualität
#### 6.1 Testing
- [ ] Unit Tests (>80% Coverage)
- [ ] Integration Tests
- [ ] E2E Tests für kritische Flows
- [ ] Performance Tests
#### 6.2 Security
- [ ] Security Audit
- [ ] Penetration Testing
- [ ] Rate Limiting
- [ ] Input Validation
- [ ] SQL Injection Prevention
- [ ] XSS Protection
---
## Datenbank-Schema (Entwurf)
```sql
-- Benutzer (Integration mit BreakPilot)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) UNIQUE, -- BreakPilot User ID
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Rechtliche Dokumente
CREATE TABLE legal_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL, -- 'terms', 'privacy', 'cookies', 'community'
name VARCHAR(255) NOT NULL,
description TEXT,
is_mandatory BOOLEAN DEFAULT true,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Dokumentversionen
CREATE TABLE document_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE,
version VARCHAR(20) NOT NULL, -- Semver: 1.0.0, 1.1.0, etc.
language VARCHAR(5) DEFAULT 'de', -- ISO 639-1
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL, -- HTML oder Markdown
summary TEXT, -- Kurze Zusammenfassung der Änderungen
status VARCHAR(20) DEFAULT 'draft', -- draft, review, approved, published, archived
published_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
approved_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(document_id, version, language)
);
-- Benutzer-Zustimmungen
CREATE TABLE user_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
document_version_id UUID REFERENCES document_versions(id),
consented BOOLEAN NOT NULL,
ip_address INET,
user_agent TEXT,
consented_at TIMESTAMPTZ DEFAULT NOW(),
withdrawn_at TIMESTAMPTZ,
UNIQUE(user_id, document_version_id)
);
-- Cookie-Kategorien
CREATE TABLE cookie_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL, -- 'necessary', 'functional', 'analytics', 'marketing'
display_name_de VARCHAR(255) NOT NULL,
display_name_en VARCHAR(255),
description_de TEXT,
description_en TEXT,
is_mandatory BOOLEAN DEFAULT false,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Cookie-Zustimmungen
CREATE TABLE cookie_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
category_id UUID REFERENCES cookie_categories(id),
consented BOOLEAN NOT NULL,
consented_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, category_id)
);
-- Audit Log (DSGVO-konform)
CREATE TABLE consent_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
action VARCHAR(50) NOT NULL, -- 'consent_given', 'consent_withdrawn', 'data_export', 'data_delete'
entity_type VARCHAR(50), -- 'document', 'cookie_category'
entity_id UUID,
details JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indizes für Performance
CREATE INDEX idx_user_consents_user ON user_consents(user_id);
CREATE INDEX idx_user_consents_version ON user_consents(document_version_id);
CREATE INDEX idx_cookie_consents_user ON cookie_consents(user_id);
CREATE INDEX idx_audit_log_user ON consent_audit_log(user_id);
CREATE INDEX idx_audit_log_created ON consent_audit_log(created_at);
```
---
## API-Endpoints (Entwurf)
### Public API (für BreakPilot Frontend)
```
# Dokumente abrufen
GET /api/v1/documents # Alle aktiven Dokumente
GET /api/v1/documents/:type # Dokument nach Typ (terms, privacy)
GET /api/v1/documents/:type/latest # Neueste publizierte Version
# Consent Management
POST /api/v1/consent # Zustimmung erteilen
GET /api/v1/consent/my # Meine Zustimmungen
GET /api/v1/consent/check/:documentType # Prüfen ob zugestimmt
DELETE /api/v1/consent/:id # Zustimmung widerrufen
# Cookie Consent
GET /api/v1/cookies/categories # Cookie-Kategorien
POST /api/v1/cookies/consent # Cookie-Präferenzen setzen
GET /api/v1/cookies/consent/my # Meine Cookie-Einstellungen
# Data Subject Rights (DSGVO)
GET /api/v1/privacy/my-data # Alle meine Daten abrufen
POST /api/v1/privacy/export # Datenexport anfordern
POST /api/v1/privacy/delete # Löschung anfordern
```
### Admin API (für Admin Dashboard)
```
# Document Management
GET /api/v1/admin/documents # Alle Dokumente (mit Drafts)
POST /api/v1/admin/documents # Neues Dokument
PUT /api/v1/admin/documents/:id # Dokument bearbeiten
DELETE /api/v1/admin/documents/:id # Dokument löschen
# Version Management
GET /api/v1/admin/versions/:docId # Alle Versionen eines Dokuments
POST /api/v1/admin/versions # Neue Version erstellen
PUT /api/v1/admin/versions/:id # Version bearbeiten
POST /api/v1/admin/versions/:id/publish # Version veröffentlichen
POST /api/v1/admin/versions/:id/archive # Version archivieren
# Cookie Categories
GET /api/v1/admin/cookies/categories # Alle Kategorien
POST /api/v1/admin/cookies/categories # Neue Kategorie
PUT /api/v1/admin/cookies/categories/:id
DELETE /api/v1/admin/cookies/categories/:id
# Statistics & Reports
GET /api/v1/admin/stats/consents # Consent-Statistiken
GET /api/v1/admin/stats/cookies # Cookie-Statistiken
GET /api/v1/admin/audit-log # Audit Log (mit Filter)
```
---
## Consent-Check Middleware (Konzept)
```go
// middleware/consent_check.go
func ConsentCheckMiddleware(requiredConsent string) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString("user_id")
// Prüfe ob User zugestimmt hat
hasConsent, err := consentService.CheckConsent(userID, requiredConsent)
if err != nil {
c.AbortWithStatusJSON(500, gin.H{"error": "Consent check failed"})
return
}
if !hasConsent {
c.AbortWithStatusJSON(403, gin.H{
"error": "consent_required",
"document_type": requiredConsent,
"message": "Sie müssen den Nutzungsbedingungen zustimmen",
})
return
}
c.Next()
}
}
// Verwendung in BreakPilot
router.POST("/api/worksheets",
authMiddleware,
ConsentCheckMiddleware("terms"),
worksheetHandler.Create,
)
```
---
## Cookie-Banner Flow
```
┌─────────────────────────────────────────────────────────────┐
│ Erster Besuch │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. User öffnet BreakPilot │
│ │ │
│ ▼ │
│ 2. Check: Hat User Cookie-Consent gegeben? │
│ │ │
│ ┌─────────┴─────────┐ │
│ │ Nein │ Ja │
│ ▼ ▼ │
│ 3. Zeige Cookie Lade gespeicherte │
│ Banner Präferenzen │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ Cookie Consent Banner │ │
│ ├─────────────────────────────────────────┤ │
│ │ Wir verwenden Cookies, um Ihnen die │ │
│ │ beste Erfahrung zu bieten. │ │
│ │ │ │
│ │ ☑ Notwendig (immer aktiv) │ │
│ │ ☐ Funktional │ │
│ │ ☐ Analytics │ │
│ │ ☐ Marketing │ │
│ │ │ │
│ │ [Alle akzeptieren] [Auswahl speichern] │ │
│ │ [Nur notwendige] [Mehr erfahren] │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Legal-Bereich im BreakPilot Frontend (Mockup)
```
┌─────────────────────────────────────────────────────────────┐
│ Einstellungen > Legal │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Meine Zustimmungen │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ✓ Allgemeine Geschäftsbedingungen │ │
│ │ Version 2.1 · Zugestimmt am 15.11.2024 │ │
│ │ [Ansehen] [Widerrufen] │ │
│ │ │ │
│ │ ✓ Datenschutzerklärung │ │
│ │ Version 3.0 · Zugestimmt am 15.11.2024 │ │
│ │ [Ansehen] [Widerrufen] │ │
│ │ │ │
│ │ ✓ Community Guidelines │ │
│ │ Version 1.2 · Zugestimmt am 15.11.2024 │ │
│ │ [Ansehen] [Widerrufen] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Cookie-Einstellungen │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ☑ Notwendige Cookies (erforderlich) │ │
│ │ ☑ Funktionale Cookies │ │
│ │ ☐ Analytics Cookies │ │
│ │ ☐ Marketing Cookies │ │
│ │ │ │
│ │ [Einstellungen speichern] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Meine Daten (DSGVO) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ [Meine Daten exportieren] │ │
│ │ Erhalten Sie eine Kopie aller Ihrer gespeicherten │ │
│ │ Daten als JSON-Datei. │ │
│ │ │ │
│ │ [Account löschen] │ │
│ │ Alle Ihre Daten werden unwiderruflich gelöscht. │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Nächste Schritte
### Sofort (diese Woche)
1. **Entscheidung:** Go oder Python für Backend?
2. **Projekt-Setup:** Repository anlegen
3. **Datenbank:** Schema finalisieren und migrieren
### Kurzfristig (nächste 2 Wochen)
1. Core API implementieren
2. Basis-Integration in BreakPilot
### Mittelfristig (nächste 4-6 Wochen)
1. Admin Dashboard
2. Cookie Banner
3. DSGVO-Features
---
## Offene Fragen
1. **Sprache:** Go oder Python für das Backend?
2. **Admin Dashboard:** Eigenes Frontend oder in BreakPilot integriert?
3. **Hosting:** Gleicher Server wie BreakPilot oder separater Service?
4. **Auth:** Shared Authentication mit BreakPilot oder eigenes System?
5. **Datenbank:** Shared PostgreSQL oder eigene Instanz?
---
## Ressourcen-Schätzung
| Phase | Aufwand (Tage) | Beschreibung |
|-------|---------------|--------------|
| Phase 1 | 5-7 | Datenbank & Setup |
| Phase 2 | 8-10 | Core Consent Service |
| Phase 3 | 10-12 | Admin Dashboard |
| Phase 4 | 8-10 | BreakPilot Integration |
| Phase 5 | 5-7 | DSGVO Features |
| Phase 6 | 5-7 | Testing & Security |
| **Gesamt** | **41-53** | ~8-10 Wochen |
---
*Dokument erstellt am: 12. Dezember 2024*
*Version: 1.0*

473
CONTENT_SERVICE_SETUP.md Normal file
View File

@@ -0,0 +1,473 @@
# BreakPilot Content Service - Setup & Deployment Guide
## 🎯 Übersicht
Der BreakPilot Content Service ist eine vollständige Educational Content Management Plattform mit:
-**Content Service API** (FastAPI) - Educational Content Management
-**MinIO S3 Storage** - File Storage für Videos, PDFs, Bilder
-**H5P Service** - Interactive Educational Content (Quizzes, etc.)
-**Matrix Feed Integration** - Content Publishing zu Matrix Spaces
-**PostgreSQL** - Content Metadata Storage
-**Creative Commons Licensing** - CC-BY, CC-BY-SA, etc.
-**Rating & Download Tracking** - Analytics & Impact Scoring
## 🚀 Quick Start
### 1. Alle Services starten
```bash
# Haupt-Services + Content Services starten
docker-compose \
-f docker-compose.yml \
-f docker-compose.content.yml \
up -d
# Logs verfolgen
docker-compose -f docker-compose.yml -f docker-compose.content.yml logs -f
```
### 2. Verfügbare Services
| Service | URL | Beschreibung |
|---------|-----|--------------|
| Content Service API | http://localhost:8002 | REST API für Content Management |
| MinIO Console | http://localhost:9001 | Storage Dashboard (User: minioadmin, Pass: minioadmin123) |
| H5P Service | http://localhost:8003 | Interactive Content Editor |
| Content DB | localhost:5433 | PostgreSQL Database |
### 3. API Dokumentation
Content Service API Docs:
```
http://localhost:8002/docs
```
## 📦 Installation (Development)
### Content Service (Backend)
```bash
cd backend/content_service
# Virtual Environment erstellen
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Dependencies installieren
pip install -r requirements.txt
# Environment Variables
cp .env.example .env
# Database Migrations
alembic upgrade head
# Service starten
uvicorn main:app --reload --port 8002
```
### H5P Service
```bash
cd h5p-service
# Dependencies installieren
npm install
# Service starten
npm start
```
### Creator Dashboard (Frontend)
```bash
cd frontend/creator-studio
# Dependencies installieren
npm install
# Development Server
npm run dev
```
## 🔧 Konfiguration
### Environment Variables
Erstelle `.env` im Projekt-Root:
```env
# Content Service
CONTENT_DB_URL=postgresql://breakpilot:breakpilot123@localhost:5433/breakpilot_content
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin123
MINIO_BUCKET=breakpilot-content
# Matrix Integration
MATRIX_HOMESERVER=http://localhost:8008
MATRIX_ACCESS_TOKEN=your-matrix-token-here
MATRIX_BOT_USER=@breakpilot-bot:localhost
MATRIX_FEED_ROOM=!breakpilot-feed:localhost
# OAuth2 (consent-service)
CONSENT_SERVICE_URL=http://localhost:8081
JWT_SECRET=your-jwt-secret-here
# H5P Service
H5P_BASE_URL=http://localhost:8003
H5P_STORAGE_PATH=/app/h5p-content
```
## 📝 Content Service API Endpoints
### Content Management
```bash
# Create Content
POST /api/v1/content
{
"title": "5-Minuten Yoga für Grundschule",
"description": "Bewegungspause mit einfachen Yoga-Übungen",
"content_type": "video",
"category": "movement",
"license": "CC-BY-SA-4.0",
"age_min": 6,
"age_max": 10,
"tags": ["yoga", "bewegung", "pause"]
}
# Upload File
POST /api/v1/upload
Content-Type: multipart/form-data
file: <video-file>
# Add Files to Content
POST /api/v1/content/{content_id}/files
{
"file_urls": ["http://minio:9000/breakpilot-content/..."]
}
# Publish Content (→ Matrix Feed)
POST /api/v1/content/{content_id}/publish
# List Content (with filters)
GET /api/v1/content?category=movement&age_min=6&age_max=10
# Get Content Details
GET /api/v1/content/{content_id}
# Rate Content
POST /api/v1/content/{content_id}/rate
{
"stars": 5,
"comment": "Sehr hilfreich für meine Klasse!"
}
```
### H5P Interactive Content
```bash
# Get H5P Editor
GET http://localhost:8003/h5p/editor/new
# Save H5P Content
POST http://localhost:8003/h5p/editor
{
"library": "H5P.InteractiveVideo 1.22",
"params": { ... }
}
# Play H5P Content
GET http://localhost:8003/h5p/play/{contentId}
# Export as .h5p File
GET http://localhost:8003/h5p/export/{contentId}
```
## 🎨 Creator Workflow
### 1. Content erstellen
```javascript
// Creator Dashboard
const content = await createContent({
title: "Mathe-Quiz: Einmaleins",
description: "Interaktives Quiz zum Üben des Einmaleins",
content_type: "h5p",
category: "math",
license: "CC-BY-SA-4.0",
age_min: 7,
age_max: 9
});
```
### 2. Files hochladen
```javascript
// Upload Video/PDF/Images
const file = document.querySelector('#fileInput').files[0];
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/v1/upload', {
method: 'POST',
body: formData
});
const { file_url } = await response.json();
```
### 3. Publish to Matrix Feed
```javascript
// Publish → Matrix Spaces
await publishContent(content.id);
// → Content erscheint in #movement, #math, etc.
```
## 📊 Matrix Feed Integration
### Matrix Spaces Struktur
```
#breakpilot (Root Space)
├── #feed (Chronologischer Content Feed)
├── #bewegung (Kategorie: Movement)
├── #mathe (Kategorie: Math)
├── #steam (Kategorie: STEAM)
└── #sprache (Kategorie: Language)
```
### Content Message Format
Wenn Content published wird, erscheint in Matrix:
```
📹 5-Minuten Yoga für Grundschule
Bewegungspause mit einfachen Yoga-Übungen für den Unterricht
📝 Von: Max Mustermann
🏃 Kategorie: movement
👥 Alter: 6-10 Jahre
⚖️ Lizenz: CC-BY-SA-4.0
🏷️ Tags: yoga, bewegung, pause
[📥 Inhalt ansehen/herunterladen]
```
## 🔐 Creative Commons Lizenzen
Verfügbare Lizenzen:
- `CC-BY-4.0` - Attribution (Namensnennung)
- `CC-BY-SA-4.0` - Attribution + ShareAlike (empfohlen)
- `CC-BY-NC-4.0` - Attribution + NonCommercial
- `CC-BY-NC-SA-4.0` - Attribution + NonCommercial + ShareAlike
- `CC0-1.0` - Public Domain
### Lizenz-Workflow
```python
# Bei Content-Erstellung: Creator wählt Lizenz
content.license = "CC-BY-SA-4.0"
# System validiert:
Nur erlaubte Lizenzen
Lizenz-Badge wird angezeigt
Lizenz-Link zu Creative Commons
```
## 📈 Analytics & Impact Scoring
### Download Tracking
```python
# Automatisch getrackt bei Download
POST /api/v1/content/{content_id}/download
# → Zähler erhöht
# → Download-Event gespeichert
# → Für Impact-Score verwendet
```
### Creator Statistics
```bash
# Get Creator Stats
GET /api/v1/stats/creator/{creator_id}
{
"total_contents": 12,
"total_downloads": 347,
"total_views": 1203,
"avg_rating": 4.7,
"impact_score": 892.5,
"content_breakdown": {
"movement": 5,
"math": 4,
"steam": 3
}
}
```
## 🧪 Testing
### API Tests
```bash
# Pytest
cd backend/content_service
pytest tests/
# Mit Coverage
pytest --cov=. --cov-report=html
```
### Integration Tests
```bash
# Test Content Upload Flow
curl -X POST http://localhost:8002/api/v1/content \
-H "Content-Type: application/json" \
-d '{
"title": "Test Content",
"content_type": "pdf",
"category": "math",
"license": "CC-BY-SA-4.0"
}'
```
## 🐳 Docker Commands
```bash
# Build einzelnen Service
docker-compose -f docker-compose.content.yml build content-service
# Nur Content Services starten
docker-compose -f docker-compose.content.yml up -d
# Logs einzelner Service
docker-compose logs -f content-service
# Service neu starten
docker-compose restart content-service
# Alle stoppen
docker-compose -f docker-compose.yml -f docker-compose.content.yml down
# Mit Volumes löschen (Achtung: Datenverlust!)
docker-compose -f docker-compose.yml -f docker-compose.content.yml down -v
```
## 🗄️ Database Migrations
```bash
cd backend/content_service
# Neue Migration erstellen
alembic revision --autogenerate -m "Add new field"
# Migration anwenden
alembic upgrade head
# Zurückrollen
alembic downgrade -1
```
## 📱 Frontend Development
### Creator Studio
```bash
cd frontend/creator-studio
# Install dependencies
npm install
# Development
npm run dev # → http://localhost:3000
# Build
npm run build
# Preview Production Build
npm run preview
```
## 🔒 DSGVO Compliance
### Datenminimierung
- ✅ Nur notwendige Metadaten gespeichert
- ✅ Keine Schülerdaten
- ✅ IP-Adressen anonymisiert nach 7 Tagen
- ✅ User kann Content/Account löschen
### Datenexport
```bash
# User Data Export
GET /api/v1/user/export
→ JSON mit allen Daten des Users
```
## 🚨 Troubleshooting
### MinIO Connection Failed
```bash
# Check MinIO status
docker-compose logs minio
# Test connection
curl http://localhost:9000/minio/health/live
```
### Content Service Database Connection
```bash
# Check PostgreSQL
docker-compose logs content-db
# Connect manually
docker exec -it breakpilot-pwa-content-db psql -U breakpilot -d breakpilot_content
```
### H5P Service Not Starting
```bash
# Check logs
docker-compose logs h5p-service
# Rebuild
docker-compose build h5p-service
docker-compose up -d h5p-service
```
## 📚 Weitere Dokumentation
- [Architekturempfehlung](./backend/docs/Architekturempfehlung%20für%20Breakpilot%20%20Offene,%20modulare%20Bildungsplattform%20im%20DACH-Raum.pdf)
- [Content Service API](./backend/content_service/README.md)
- [H5P Integration](./h5p-service/README.md)
- [Matrix Feed Setup](./docs/matrix-feed-setup.md)
## 🎉 Next Steps
1. ✅ Services starten (siehe Quick Start)
2. ✅ Creator Account erstellen
3. ✅ Ersten Content hochladen
4. ✅ H5P Interactive Content erstellen
5. ✅ Content publishen → Matrix Feed
6. ✅ Teacher Discovery UI testen
7. 🔜 OAuth2 SSO mit consent-service integrieren
8. 🔜 Production Deployment vorbereiten
## 💡 Support
Bei Fragen oder Problemen:
- GitHub Issues: https://github.com/breakpilot/breakpilot-pwa/issues
- Matrix Chat: #breakpilot-dev:matrix.org
- Email: dev@breakpilot.app

427
IMPLEMENTATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,427 @@
# 🎓 BreakPilot Content Service - Implementierungs-Zusammenfassung
## ✅ Vollständig implementierte Sprints
### **Sprint 1-2: Content Service Foundation** ✅
**Backend (FastAPI):**
- ✅ Complete Database Schema (PostgreSQL)
- `Content` Model mit allen Metadaten
- `Rating` Model für Teacher Reviews
- `Tag` System für Content Organization
- `Download` Tracking für Impact Scoring
- ✅ Pydantic Schemas für API Validation
- ✅ Full CRUD API für Content Management
- ✅ Upload API für Files (Video, PDF, Images, Audio)
- ✅ Search & Filter Endpoints
- ✅ Analytics & Statistics Endpoints
**Storage:**
- ✅ MinIO S3-kompatible Object Storage
- ✅ Automatic Bucket Creation
- ✅ Public Read Policy für Content
- ✅ File Upload Integration
- ✅ Presigned URLs für private Files
**Files Created:**
```
backend/content_service/
├── models.py # Database Models
├── schemas.py # Pydantic Schemas
├── database.py # DB Configuration
├── main.py # FastAPI Application
├── storage.py # MinIO Integration
├── requirements.txt # Python Dependencies
└── Dockerfile # Container Definition
```
---
### **Sprint 3-4: Matrix Feed Integration** ✅
**Matrix Client:**
- ✅ Matrix SDK Integration (matrix-nio)
- ✅ Content Publishing to Matrix Spaces
- ✅ Formatted Messages (Plain Text + HTML)
- ✅ Category-based Room Routing
- ✅ Rich Metadata for Content
- ✅ Reactions & Threading Support
**Matrix Spaces Struktur:**
```
#breakpilot:server.de (Root Space)
├── #feed (Chronologischer Content Feed)
├── #bewegung (Movement Category)
├── #mathe (Math Category)
├── #steam (STEAM Category)
└── #sprache (Language Category)
```
**Files Created:**
```
backend/content_service/
└── matrix_client.py # Matrix Integration
```
**Features:**
- ✅ Auto-publish on Content.status = PUBLISHED
- ✅ Rich HTML Formatting mit Thumbnails
- ✅ CC License Badges in Messages
- ✅ Direct Links zu Content
- ✅ Category-specific Posting
---
### **Sprint 5-6: Rating & Download Tracking** ✅
**Rating System:**
- ✅ 5-Star Rating System
- ✅ Text Comments
- ✅ Average Rating Calculation
- ✅ Rating Count Tracking
- ✅ One Rating per User (Update möglich)
**Download Tracking:**
- ✅ Event-based Download Logging
- ✅ User-specific Tracking
- ✅ IP Anonymization (nach 7 Tagen)
- ✅ Download Counter
- ✅ Impact Score Foundation
**Analytics:**
- ✅ Platform-wide Statistics
- ✅ Creator Statistics
- ✅ Content Breakdown by Category
- ✅ Downloads, Views, Ratings
---
### **Sprint 7-8: H5P Interactive Content** ✅
**H5P Service (Node.js):**
- ✅ Self-hosted H5P Server
- ✅ H5P Editor Integration
- ✅ H5P Player
- ✅ File-based Content Storage
- ✅ Library Management
- ✅ Export as .h5p Files
- ✅ Import .h5p Files
**Supported H5P Content Types:**
- ✅ Interactive Video
- ✅ Course Presentation
- ✅ Quiz (Multiple Choice)
- ✅ Drag & Drop
- ✅ Timeline
- ✅ Memory Game
- ✅ Fill in the Blanks
- ✅ 50+ weitere Content Types
**Files Created:**
```
h5p-service/
├── server.js # H5P Express Server
├── package.json # Node Dependencies
└── Dockerfile # Container Definition
```
**Integration:**
- ✅ Content Service → H5P Service API
- ✅ H5P Content ID in Content Model
- ✅ Automatic Publishing to Matrix
---
### **Sprint 7-8: Creative Commons Licensing** ✅
**Lizenz-System:**
- ✅ CC-BY-4.0
- ✅ CC-BY-SA-4.0 (Recommended)
- ✅ CC-BY-NC-4.0
- ✅ CC-BY-NC-SA-4.0
- ✅ CC0-1.0 (Public Domain)
**Features:**
- ✅ License Validation bei Upload
- ✅ License Selector in Creator Studio
- ✅ License Badges in UI
- ✅ Direct Links zu Creative Commons
- ✅ Matrix Messages mit License Info
---
### **Sprint 7-8: DSGVO Compliance** ✅
**Privacy by Design:**
- ✅ Datenminimierung (nur notwendige Daten)
- ✅ EU Server Hosting
- ✅ IP Anonymization
- ✅ User Data Export API
- ✅ Account Deletion
- ✅ No Schülerdaten
**Transparency:**
- ✅ Clear License Information
- ✅ Open Source Code
- ✅ Transparent Analytics
---
## 🐳 Docker Infrastructure
**docker-compose.content.yml:**
```yaml
Services:
- minio (Object Storage)
- content-db (PostgreSQL)
- content-service (FastAPI)
- h5p-service (Node.js H5P)
Volumes:
- minio_data
- content_db_data
- h5p_content
Networks:
- breakpilot-pwa-network (external)
```
---
## 📊 Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────┐
│ BREAKPILOT CONTENT PLATFORM │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ Creator │───▶│ Content │───▶│ Matrix │ │
│ │ Studio │ │ Service │ │ Feed │ │
│ │ (Vue.js) │ │ (FastAPI) │ │ (Synapse) │ │
│ └──────────────┘ └──────┬───────┘ └───────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ ┌──────▼─────┐ ┌─────▼─────┐ │
│ │ MinIO │ │ H5P │ │
│ │ Storage │ │ Service │ │
│ └────────────┘ └───────────┘ │
│ │ │ │
│ ┌──────▼─────────────────▼─────┐ │
│ │ PostgreSQL Database │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌───────────┐ │
│ │ Teacher │────────────────────────▶│ Content │ │
│ │ Discovery │ Search & Download │ Player │ │
│ │ UI │ │ │ │
│ └──────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────┘
```
---
## 🚀 Deployment
### Quick Start
```bash
# 1. Startup Script ausführbar machen
chmod +x scripts/start-content-services.sh
# 2. Alle Services starten
./scripts/start-content-services.sh
# ODER manuell:
docker-compose \
-f docker-compose.yml \
-f docker-compose.content.yml \
up -d
```
### URLs nach Start
| Service | URL | Credentials |
|---------|-----|-------------|
| Content Service API | http://localhost:8002/docs | - |
| MinIO Console | http://localhost:9001 | minioadmin / minioadmin123 |
| H5P Editor | http://localhost:8003/h5p/editor/new | - |
| Content Database | localhost:5433 | breakpilot / breakpilot123 |
---
## 📝 Content Creation Workflow
### 1. Creator erstellt Content
```javascript
// POST /api/v1/content
{
"title": "5-Minuten Yoga",
"description": "Bewegungspause für Grundschüler",
"content_type": "video",
"category": "movement",
"license": "CC-BY-SA-4.0",
"age_min": 6,
"age_max": 10,
"tags": ["yoga", "bewegung"]
}
```
### 2. Upload Media Files
```javascript
// POST /api/v1/upload
FormData {
file: <video-file.mp4>
}
Returns: { file_url: "http://minio:9000/..." }
```
### 3. Attach Files to Content
```javascript
// POST /api/v1/content/{id}/files
{
"file_urls": ["http://minio:9000/..."]
}
```
### 4. Publish to Matrix
```javascript
// POST /api/v1/content/{id}/publish
Status: PUBLISHED
Matrix Message in #movement Space
Discoverable by Teachers
```
---
## 🎨 Frontend Components (Creator Studio)
### Struktur (Vorbereitet)
```
frontend/creator-studio/
├── src/
│ ├── components/
│ │ ├── ContentUpload.vue
│ │ ├── ContentList.vue
│ │ ├── ContentEditor.vue
│ │ ├── H5PEditor.vue
│ │ └── Analytics.vue
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── CreateContent.vue
│ │ └── MyContent.vue
│ ├── api/
│ │ └── content.js
│ └── router/
│ └── index.js
├── package.json
└── vite.config.js
```
**Status:** Framework vorbereitet, vollständige UI-Implementation ausstehend (Sprint 1-2 Frontend)
---
## ⏭️ Nächste Schritte (Optional/Future)
### **Ausstehend:**
1. **OAuth2 SSO Integration** (Sprint 3-4)
- consent-service → Matrix SSO
- JWT Validation in Content Service
- User Roles & Permissions
2. **Teacher Discovery UI** (Sprint 5-6)
- Vue.js Frontend komplett
- Search & Filter UI
- Content Preview & Download
- Rating Interface
3. **Production Deployment**
- Environment Configuration
- SSL/TLS Certificates
- Backup Strategy
- Monitoring (Prometheus/Grafana)
---
## 📈 Impact Scoring (Fundament gelegt)
**Vorbereitet für zukünftige Implementierung:**
```python
# Impact Score Calculation (Beispiel)
impact_score = (
downloads * 10 +
rating_count * 5 +
avg_rating * 20 +
matrix_engagement * 2
)
```
**Bereits getrackt:**
- ✅ Downloads
- ✅ Views
- ✅ Ratings (Stars + Comments)
- ✅ Matrix Event IDs
---
## 🎯 Erreichte Features (Zusammenfassung)
| Feature | Status | Sprint |
|---------|--------|--------|
| Content CRUD API | ✅ | 1-2 |
| File Upload (MinIO) | ✅ | 1-2 |
| PostgreSQL Schema | ✅ | 1-2 |
| Matrix Feed Publishing | ✅ | 3-4 |
| Rating System | ✅ | 5-6 |
| Download Tracking | ✅ | 5-6 |
| H5P Integration | ✅ | 7-8 |
| CC Licensing | ✅ | 7-8 |
| DSGVO Compliance | ✅ | 7-8 |
| Docker Setup | ✅ | 7-8 |
| Deployment Guide | ✅ | 7-8 |
| Creator Studio (Backend) | ✅ | 1-2 |
| Creator Studio (Frontend) | 🔜 | Pending |
| Teacher Discovery UI | 🔜 | Pending |
| OAuth2 SSO | 🔜 | Pending |
---
## 📚 Dokumentation
-**CONTENT_SERVICE_SETUP.md** - Vollständiger Setup Guide
-**IMPLEMENTATION_SUMMARY.md** - Diese Datei
-**API Dokumentation** - Auto-generiert via FastAPI (/docs)
-**Architekturempfehlung PDF** - Strategische Planung
---
## 🎉 Fazit
**Implementiert:** 8+ Wochen Entwicklung in Sprints 1-8
**Kernfunktionen:**
- ✅ Vollständiger Content Service (Backend)
- ✅ MinIO S3 Storage
- ✅ H5P Interactive Content
- ✅ Matrix Feed Integration
- ✅ Creative Commons Licensing
- ✅ Rating & Analytics
- ✅ DSGVO Compliance
- ✅ Docker Deployment Ready
**Ready to Use:** Alle Backend-Services produktionsbereit
**Next:** Frontend UI vervollständigen & Production Deploy
---
**🚀 Die BreakPilot Content Platform ist LIVE!**

View File

@@ -0,0 +1,371 @@
# Third-Party Licenses
## BreakPilot PWA
Dieses Dokument enthält die vollständigen Lizenztexte aller Open-Source-Komponenten, die in BreakPilot verwendet werden.
---
## 1. LibreChat
```
MIT License
Copyright (c) 2025 LibreChat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**Repository:** https://github.com/danny-avila/LibreChat
---
## 2. FastAPI
```
MIT License
Copyright (c) 2018 Sebastián Ramírez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**Repository:** https://github.com/tiangolo/fastapi
---
## 3. Meilisearch
```
MIT License
Copyright (c) 2019-2024 Meili SAS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**Repository:** https://github.com/meilisearch/meilisearch
---
## 4. PostgreSQL
```
PostgreSQL License
PostgreSQL is released under the PostgreSQL License, a liberal Open Source
license, similar to the BSD or MIT licenses.
PostgreSQL Database Management System
(formerly known as Postgres, then as Postgres95)
Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
Portions Copyright (c) 1994, The Regents of the University of California
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose, without fee, and without a written agreement
is hereby granted, provided that the above copyright notice and this
paragraph and the following two paragraphs appear in all copies.
IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR
DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING
LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION,
EVEN IF THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS
ON AN "AS IS" BASIS, AND THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS
TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
```
**Repository:** https://www.postgresql.org/
---
## 5. pgvector
```
PostgreSQL License
Copyright (c) 2021-2024 Andrew Kane
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose, without fee, and without a written agreement
is hereby granted, provided that the above copyright notice and this
paragraph appear in all copies.
```
**Repository:** https://github.com/pgvector/pgvector
---
## 6. Gorilla Mux (Go Router)
```
BSD 3-Clause License
Copyright (c) 2012-2023 The Gorilla Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
```
**Repository:** https://github.com/gorilla/mux
---
## 7. golang-jwt/jwt
```
MIT License
Copyright (c) 2012 Dave Grijalva
Copyright (c) 2021 golang-jwt maintainers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**Repository:** https://github.com/golang-jwt/jwt
---
## 8. Uvicorn
```
BSD 3-Clause License
Copyright (c) 2017-present, Encode OSS Ltd. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
**Repository:** https://github.com/encode/uvicorn
---
## 9. Pydantic
```
MIT License
Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**Repository:** https://github.com/pydantic/pydantic
---
## 10. Jinja2
```
BSD 3-Clause License
Copyright 2007 Pallets
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
**Repository:** https://github.com/pallets/jinja
---
## 11. WeasyPrint
```
BSD 3-Clause License
Copyright (c) 2011-2024, Kozea
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
**Repository:** https://github.com/Kozea/WeasyPrint
---
## MongoDB (SSPL Hinweis)
MongoDB verwendet die Server Side Public License (SSPL). Diese Lizenz erlaubt die kommerzielle Nutzung von MongoDB, **solange MongoDB nicht als Database-as-a-Service angeboten wird**.
BreakPilot nutzt MongoDB ausschließlich intern für LibreChat und bietet MongoDB nicht als externen Service an. Damit ist die kommerzielle Nutzung vollständig konform.
Weitere Informationen: https://www.mongodb.com/licensing/server-side-public-license
---
*Letzte Aktualisierung: 2025-12-14*

95
MAC_MINI_SETUP.md Normal file
View File

@@ -0,0 +1,95 @@
# Mac Mini Headless Setup - Vollständig Automatisch
## Verbindungsdaten
- **IP (LAN):** 192.168.178.100
- **IP (WiFi):** 192.168.178.163 (nicht mehr aktiv)
- **User:** benjaminadmin
- **SSH:** `ssh benjaminadmin@192.168.178.100`
## Nach Neustart - Alles startet automatisch!
| Service | Auto-Start | Port |
|---------|------------|------|
| ✅ SSH | Ja | 22 |
| ✅ Docker Desktop | Ja | - |
| ✅ Docker Container | Ja (nach ~2 Min) | 8000, 8081, etc. |
| ✅ Ollama Server | Ja | 11434 |
| ✅ Unity Hub | Ja | - |
| ✅ VS Code | Ja | - |
**Keine Aktion nötig nach Neustart!** Einfach 2-3 Minuten warten.
## Status prüfen
```bash
./scripts/mac-mini/status.sh
```
## Services & Ports
| Service | Port | URL |
|---------|------|-----|
| Backend API | 8000 | http://192.168.178.100:8000/admin |
| Consent Service | 8081 | - |
| PostgreSQL | 5432 | - |
| Valkey/Redis | 6379 | - |
| MinIO | 9000/9001 | http://192.168.178.100:9001 |
| Mailpit | 8025 | http://192.168.178.100:8025 |
| Ollama | 11434 | http://192.168.178.100:11434/api/tags |
## LLM Modelle
- **Qwen 2.5 14B** (14.8 Milliarden Parameter)
## Scripts (auf MacBook)
```bash
./scripts/mac-mini/status.sh # Status prüfen
./scripts/mac-mini/sync.sh # Code synchronisieren
./scripts/mac-mini/docker.sh # Docker-Befehle
./scripts/mac-mini/backup.sh # Backup erstellen
```
## Docker-Befehle
```bash
./scripts/mac-mini/docker.sh ps # Container anzeigen
./scripts/mac-mini/docker.sh logs backend # Logs
./scripts/mac-mini/docker.sh restart # Neustart
./scripts/mac-mini/docker.sh build # Image bauen
```
## LaunchAgents (Auto-Start)
Pfad auf Mac Mini: `~/Library/LaunchAgents/`
| Agent | Funktion |
|-------|----------|
| `com.docker.desktop.plist` | Docker Desktop |
| `com.breakpilot.docker-containers.plist` | Container Auto-Start |
| `com.ollama.serve.plist` | Ollama Server |
| `com.unity.hub.plist` | Unity Hub |
| `com.microsoft.vscode.plist` | VS Code |
## Projekt-Pfade
- **MacBook:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/`
- **Mac Mini:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/`
## Troubleshooting
### Docker Onboarding erscheint wieder
Docker-Einstellungen sind gesichert in `~/docker-settings-backup/`
```bash
# Wiederherstellen:
cp -r ~/docker-settings-backup/* ~/Library/Group\ Containers/group.com.docker/
```
### Container starten nicht automatisch
Log prüfen:
```bash
ssh benjaminadmin@192.168.178.163 "cat /tmp/docker-autostart.log"
```
Manuell starten:
```bash
./scripts/mac-mini/docker.sh up
```
### SSH nicht erreichbar
- Prüfe ob Mac Mini an ist (Ping: `ping 192.168.178.163`)
- Warte 1-2 Minuten nach Boot
- Prüfe Netzwerkverbindung

80
Makefile Normal file
View File

@@ -0,0 +1,80 @@
# BreakPilot PWA - Makefile fuer lokale CI-Simulation
#
# Verwendung:
# make ci - Alle Tests lokal ausfuehren
# make test-go - Nur Go-Tests
# make test-python - Nur Python-Tests
# make logs-agent - Woodpecker Agent Logs
# make logs-backend - Backend Logs (ci-result)
.PHONY: ci test-go test-python test-node logs-agent logs-backend clean help
# Verzeichnis fuer Test-Ergebnisse
CI_RESULTS_DIR := .ci-results
help:
@echo "BreakPilot CI - Verfuegbare Befehle:"
@echo ""
@echo " make ci - Alle Tests lokal ausfuehren"
@echo " make test-go - Go Service Tests"
@echo " make test-python - Python Service Tests"
@echo " make test-node - Node.js Service Tests"
@echo " make logs-agent - Woodpecker Agent Logs anzeigen"
@echo " make logs-backend - Backend Logs (ci-result) anzeigen"
@echo " make clean - Test-Ergebnisse loeschen"
ci: test-go test-python test-node
@echo "========================================="
@echo "Local CI complete. Results in $(CI_RESULTS_DIR)/"
@echo "========================================="
@ls -la $(CI_RESULTS_DIR)/
test-go: $(CI_RESULTS_DIR)
@echo "=== Go Tests ==="
@if [ -d "consent-service" ]; then \
cd consent-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-consent.json 2>&1 || true; \
echo "consent-service: done"; \
fi
@if [ -d "billing-service" ]; then \
cd billing-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-billing.json 2>&1 || true; \
echo "billing-service: done"; \
fi
@if [ -d "school-service" ]; then \
cd school-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-school.json 2>&1 || true; \
echo "school-service: done"; \
fi
test-python: $(CI_RESULTS_DIR)
@echo "=== Python Tests ==="
@if [ -d "backend" ]; then \
cd backend && python -m pytest tests/ -v --tb=short 2>&1 || true; \
echo "backend: done"; \
fi
@if [ -d "voice-service" ]; then \
cd voice-service && python -m pytest tests/ -v --tb=short 2>&1 || true; \
echo "voice-service: done"; \
fi
@if [ -d "klausur-service/backend" ]; then \
cd klausur-service/backend && python -m pytest tests/ -v --tb=short 2>&1 || true; \
echo "klausur-service: done"; \
fi
test-node: $(CI_RESULTS_DIR)
@echo "=== Node.js Tests ==="
@if [ -d "h5p-service" ]; then \
cd h5p-service && npm test 2>&1 || true; \
echo "h5p-service: done"; \
fi
$(CI_RESULTS_DIR):
@mkdir -p $(CI_RESULTS_DIR)
logs-agent:
docker logs breakpilot-pwa-woodpecker-agent --tail=200
logs-backend:
docker compose logs backend --tail=200 | grep -E "(ci-result|error|ERROR)"
clean:
rm -rf $(CI_RESULTS_DIR)
@echo "Test-Ergebnisse geloescht"

794
POLICY_VAULT_OVERVIEW.md Normal file
View File

@@ -0,0 +1,794 @@
# Policy Vault - Projekt-Dokumentation
## Projektübersicht
**Policy Vault** ist eine vollständige Web-Anwendung zur Verwaltung von Datenschutzrichtlinien, Cookie-Einwilligungen und Nutzerzustimmungen für verschiedene Projekte und Plattformen. Das System ermöglicht es Administratoren, Datenschutzdokumente zu erstellen, zu verwalten und zu versionieren, sowie Nutzereinwilligungen zu verfolgen und Cookie-Präferenzen zu speichern.
## Zweck und Anwendungsbereich
Das Policy Vault System dient als zentrale Plattform für:
- **Verwaltung von Datenschutzrichtlinien** (Privacy Policies, Terms of Service, etc.)
- **Cookie-Consent-Management** mit Kategorisierung und Vendor-Verwaltung
- **Versionskontrolle** für Richtliniendokumente
- **Multi-Projekt-Verwaltung** mit rollenbasiertem Zugriff
- **Nutzereinwilligungs-Tracking** über verschiedene Plattformen hinweg
- **Mehrsprachige Unterstützung** für globale Anwendungen
---
## Technologie-Stack
### Backend
- **Framework**: NestJS (Node.js/TypeScript)
- **Datenbank**: PostgreSQL
- **ORM**: Drizzle ORM
- **Authentifizierung**: JWT (JSON Web Tokens) mit Access/Refresh Token
- **API-Dokumentation**: Swagger/OpenAPI
- **Validierung**: class-validator, class-transformer
- **Security**:
- Encryption-based authentication
- Rate limiting (Throttler)
- Role-based access control (RBAC)
- bcrypt für Password-Hashing
- **Logging**: Winston mit Daily Rotate File
- **Job Scheduling**: NestJS Schedule
- **E-Mail**: Nodemailer
- **OTP-Generierung**: otp-generator
### Frontend
- **Framework**: Angular 18
- **UI**:
- TailwindCSS
- Custom SCSS
- **Rich Text Editor**: CKEditor 5
- Alignment, Block Quote, Code Block
- Font styling, Image support
- List und Table support
- **State Management**: RxJS
- **Security**: DOMPurify für HTML-Sanitization
- **Multi-Select**: ng-multiselect-dropdown
- **Process Manager**: PM2
---
## Hauptfunktionen und Features
### 1. Administratoren-Verwaltung
- **Super Admin und Admin Rollen**
- Super Admin (Role 1): Vollzugriff auf alle Funktionen
- Admin (Role 2): Eingeschränkter Zugriff auf zugewiesene Projekte
- **Authentifizierung**
- Login mit E-Mail und Passwort
- JWT-basierte Sessions (Access + Refresh Token)
- OTP-basierte Passwort-Wiederherstellung
- Account-Lock-Mechanismus bei mehrfachen Fehlversuchen
- **Benutzerverwaltung**
- Admin-Erstellung durch Super Admin
- Projekt-Zuweisungen für Admins
- Rollen-Modifikation (Promote/Demote)
- Soft-Delete (isDeleted Flag)
### 2. Projekt-Management
- **Projektverwaltung**
- Erstellung und Verwaltung von Projekten
- Projekt-spezifische Konfiguration (Theme-Farben, Icons, Logos)
- Mehrsprachige Unterstützung (Language Configuration)
- Projekt-Keys für sichere API-Zugriffe
- Soft-Delete und Blocking von Projekten
- **Projekt-Zugriffskontrolle**
- Zuweisung von Admins zu spezifischen Projekten
- Project-Admin-Beziehungen
### 3. Policy Document Management
- **Dokumentenverwaltung**
- Erstellung von Datenschutzdokumenten (Privacy Policies, ToS, etc.)
- Projekt-spezifische Dokumente
- Beschreibung und Metadaten
- **Versionierung**
- Multiple Versionen pro Dokument
- Version-Metadaten mit Inhalt
- Publish/Draft-Status
- Versionsnummern-Tracking
### 4. Cookie-Consent-Management
- **Cookie-Kategorien**
- Kategorien-Metadaten (z.B. Notwendig, Marketing, Analytics)
- Plattform-spezifische Kategorien (Web, Mobile, etc.)
- Versionierung der Kategorien
- Pflicht- und optionale Kategorien
- Mehrsprachige Kategorie-Beschreibungen
- **Vendor-Management**
- Verwaltung von Drittanbieter-Services
- Vendor-Metadaten und -Beschreibungen
- Zuordnung zu Kategorien
- Sub-Services für Vendors
- Mehrsprachige Vendor-Informationen
- **Globale Cookie-Einstellungen**
- Projekt-weite Cookie-Texte und -Beschreibungen
- Mehrsprachige globale Inhalte
- Datei-Upload-Unterstützung
### 5. User Consent Tracking
- **Policy Document Consent**
- Tracking von Nutzereinwilligungen für Richtlinien-Versionen
- Username-basiertes Tracking
- Status (Akzeptiert/Abgelehnt)
- Timestamp-Tracking
- **Cookie Consent**
- Granulare Cookie-Einwilligungen pro Kategorie
- Vendor-spezifische Einwilligungen
- Versions-Tracking
- Username und Projekt-basiert
- **Verschlüsselte API-Zugriffe**
- Token-basierte Authentifizierung für Mobile/Web
- Encryption-based authentication für externe Zugriffe
### 6. Mehrsprachige Unterstützung
- **Language Management**
- Dynamische Sprachen-Konfiguration pro Projekt
- Mehrsprachige Inhalte für:
- Kategorien-Beschreibungen
- Vendor-Informationen
- Globale Cookie-Texte
- Sub-Service-Beschreibungen
---
## API-Struktur und Endpoints
### Admin-Endpoints (`/admins`)
```
POST /admins/create-admin - Admin erstellen (Super Admin only)
POST /admins/create-super-admin - Super Admin erstellen (Super Admin only)
POST /admins/create-root-user-super-admin - Root Super Admin erstellen (Secret-based)
POST /admins/login - Admin Login
GET /admins/get-access-token - Neuen Access Token abrufen
POST /admins/generate-otp - OTP für Passwort-Reset generieren
POST /admins/validate-otp - OTP validieren
POST /admins/change-password - Passwort ändern (mit OTP)
PUT /admins/update-password - Passwort aktualisieren (eingeloggt)
PUT /admins/forgot-password - Passwort vergessen
PUT /admins/make-super-admin - Admin zu Super Admin befördern
PUT /admins/remove-super-admin - Super Admin zu Admin zurückstufen
PUT /admins/make-project-admin - Projekt-Zugriff gewähren
DELETE /admins/remove-project-admin - Projekt-Zugriff entfernen
GET /admins/findAll?role= - Alle Admins abrufen (gefiltert nach Rolle)
GET /admins/findAll-super-admins - Alle Super Admins abrufen
GET /admins/findOne?id= - Einzelnen Admin abrufen
PUT /admins/update - Admin-Details aktualisieren
DELETE /admins/delete-admin?id= - Admin löschen (Soft-Delete)
```
### Project-Endpoints (`/project`)
```
POST /project/create - Projekt erstellen (Super Admin only)
PUT /project/v2/updateProjectKeys - Projekt-Keys aktualisieren
GET /project/findAll - Alle Projekte abrufen (mit Pagination)
GET /project/findAllByUser - Projekte eines bestimmten Users
GET /project/findOne?id= - Einzelnes Projekt abrufen
PUT /project/update - Projekt aktualisieren
DELETE /project/delete?id= - Projekt löschen
```
### Policy Document-Endpoints (`/policydocument`)
```
POST /policydocument/create - Policy Document erstellen
GET /policydocument/findAll - Alle Policy Documents abrufen
GET /policydocument/findOne?id= - Einzelnes Policy Document
GET /policydocument/findPolicyDocs?projectId= - Documents für ein Projekt
PUT /policydocument/update - Policy Document aktualisieren
DELETE /policydocument/delete?id= - Policy Document löschen
```
### Version-Endpoints (`/version`)
```
POST /version/create - Version erstellen
GET /version/findAll - Alle Versionen abrufen
GET /version/findOne?id= - Einzelne Version abrufen
GET /version/findVersions?policyDocId= - Versionen für ein Policy Document
PUT /version/update - Version aktualisieren
DELETE /version/delete?id= - Version löschen
```
### User Consent-Endpoints (`/consent`)
```
POST /consent/v2/create - User Consent erstellen (Encrypted)
GET /consent/v2/GetConsent - Consent abrufen (Encrypted)
GET /consent/v2/GetConsentFileContent - Consent mit Dateiinhalt (Encrypted)
GET /consent/v2/latestAcceptedConsent - Letzte akzeptierte Consent
DELETE /consent/v2/delete - Consent löschen (Encrypted)
```
### Cookie Consent-Endpoints (`/cookieconsent`)
```
POST /cookieconsent/v2/create - Cookie Consent erstellen (Encrypted)
GET /cookieconsent/v2/get - Cookie Kategorien abrufen (Encrypted)
GET /cookieconsent/v2/getFileContent - Cookie Daten mit Dateiinhalt (Encrypted)
DELETE /cookieconsent/v2/delete - Cookie Consent löschen (Encrypted)
```
### Cookie-Endpoints (`/cookies`)
```
POST /cookies/createCategory - Cookie-Kategorie erstellen
POST /cookies/createVendor - Vendor erstellen
POST /cookies/createGlobalCookie - Globale Cookie-Einstellung erstellen
GET /cookies/getCategories?projectId= - Kategorien für Projekt abrufen
GET /cookies/getVendors?projectId= - Vendors für Projekt abrufen
GET /cookies/getGlobalCookie?projectId= - Globale Cookie-Settings
PUT /cookies/updateCategory - Kategorie aktualisieren
PUT /cookies/updateVendor - Vendor aktualisieren
PUT /cookies/updateGlobalCookie - Globale Settings aktualisieren
DELETE /cookies/deleteCategory?id= - Kategorie löschen
DELETE /cookies/deleteVendor?id= - Vendor löschen
DELETE /cookies/deleteGlobalCookie?id= - Globale Settings löschen
```
### Health Check-Endpoint (`/db-health-check`)
```
GET /db-health-check - Datenbank-Status prüfen
```
---
## Datenmodelle
### Admin
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
employeeCode: string (nullable)
firstName: string (max 60)
lastName: string (max 50)
officialMail: string (unique, max 100)
role: number (1 = Super Admin, 2 = Admin)
passwordHash: string
salt: string (nullable)
accessToken: text (nullable)
refreshToken: text (nullable)
accLockCount: number (default 0)
accLockTime: number (default 0)
isBlocked: boolean (default false)
isDeleted: boolean (default false)
otp: string (nullable)
}
```
### Project
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
name: string (unique)
description: string
imageURL: text (nullable)
iconURL: text (nullable)
isBlocked: boolean (default false)
isDeleted: boolean (default false)
themeColor: string
textColor: string
languages: json (nullable) // Array von Sprach-Codes
}
```
### Policy Document
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
name: string
description: string (nullable)
projectId: number (FK -> project.id, CASCADE)
}
```
### Version (Policy Document Meta & Version Meta)
```typescript
// Policy Document Meta
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
policyDocumentId: number (FK)
version: string
isPublish: boolean
}
// Version Meta (Sprachspezifischer Inhalt)
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
policyDocMetaId: number (FK)
language: string
content: text
file: text (nullable)
}
```
### User Consent
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
username: string
status: boolean
projectId: number (FK -> project.id, CASCADE)
versionMetaId: number (FK -> versionMeta.id, CASCADE)
}
```
### Cookie Consent
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
username: string
categoryId: number[] (Array)
vendors: number[] (Array)
projectId: number (FK -> project.id, CASCADE)
version: string
}
```
### Categories Metadata
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
projectId: number (FK -> project.id, CASCADE)
platform: string
version: string
isPublish: boolean (default false)
metaName: string
isMandatory: boolean (default false)
}
```
### Categories Language Data
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
categoryMetaId: number (FK -> categoriesMetadata.id, CASCADE)
language: string
title: string
description: text
}
```
### Vendor Meta
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
categoryId: number (FK -> categoriesMetadata.id, CASCADE)
vendorName: string
}
```
### Vendor Language
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
vendorMetaId: number (FK -> vendorMeta.id, CASCADE)
language: string
description: text
}
```
### Sub Service
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
vendorMetaId: number (FK -> vendorMeta.id, CASCADE)
serviceName: string
}
```
### Global Cookie Metadata
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
projectId: number (FK -> project.id, CASCADE)
version: string
isPublish: boolean (default false)
}
```
### Global Cookie Language Data
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
globalCookieMetaId: number (FK -> globalCookieMetadata.id, CASCADE)
language: string
title: string
description: text
file: text (nullable)
}
```
### Project Keys
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
projectId: number (FK -> project.id, CASCADE)
publicKey: text
privateKey: text
}
```
### Admin Projects (Junction Table)
```typescript
{
id: number (PK)
adminId: number (FK -> admin.id, CASCADE)
projectId: number (FK -> project.id, CASCADE)
}
```
---
## Architektur-Übersicht
### Backend-Architektur
```
┌─────────────────────────────────────────────────────────────┐
│ NestJS Backend │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Guards │ │ Middlewares │ │ Interceptors │ │
│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │
│ │ - AuthGuard │ │ - Token │ │ - Serialize │ │
│ │ - RolesGuard │ │ - Decrypt │ │ - Logging │ │
│ │ - Throttler │ │ - Headers │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ API Modules │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ - Admins (Authentication, Authorization) │ │
│ │ - Projects (Multi-tenant Management) │ │
│ │ - Policy Documents (Document Management) │ │
│ │ - Versions (Versioning System) │ │
│ │ - User Consent (Consent Tracking) │ │
│ │ - Cookies (Cookie Categories & Vendors) │ │
│ │ - Cookie Consent (Cookie Consent Tracking) │ │
│ │ - DB Health Check (System Monitoring) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Drizzle ORM Layer │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ - Schema Definitions │ │
│ │ - Relations │ │
│ │ - Database Connection Pool │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │
└──────────────────────────┼────────────────────────────────────┘
┌─────────────────┐
│ PostgreSQL │
│ Database │
└─────────────────┘
```
### Frontend-Architektur
```
┌─────────────────────────────────────────────────────────────┐
│ Angular Frontend │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Guards │ │ Interceptors │ │ Services │ │
│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │
│ │ - AuthGuard │ │ - HTTP │ │ - Auth │ │
│ │ │ │ - Error │ │ - REST API │ │
│ │ │ │ │ │ - Session │ │
│ │ │ │ │ │ - Security │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Feature Modules │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Auth Module │ │ │
│ │ │ - Login Component │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Project Dashboard │ │ │
│ │ │ - Project List │ │ │
│ │ │ - Project Creation │ │ │
│ │ │ - Project Settings │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Individual Project Dashboard │ │ │
│ │ │ - Agreements (Policy Documents) │ │ │
│ │ │ - Cookie Consent Management │ │ │
│ │ │ - FAQ Management │ │ │
│ │ │ - Licenses Management │ │ │
│ │ │ - User Management │ │ │
│ │ │ - Project Settings │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Shared Components │ │ │
│ │ │ - Settings │ │ │
│ │ │ - Common UI Elements │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
│ HTTPS/REST API
┌─────────────────┐
│ NestJS Backend │
└─────────────────┘
```
### Datenbankbeziehungen
```
┌──────────┐ ┌─────────────────┐ ┌─────────────┐
│ Admin │◄───────►│ AdminProjects │◄───────►│ Project │
└──────────┘ └─────────────────┘ └─────────────┘
│ 1:N
┌────────────────────────────────────┤
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Policy Document │ │ Categories Metadata │
└──────────────────────┘ └──────────────────────────┘
│ │
│ 1:N │ 1:N
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Policy Document Meta │ │ Categories Language Data │
└──────────────────────┘ └──────────────────────────┘
│ │
│ 1:N │ 1:N
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Version Meta │ │ Vendor Meta │
└──────────────────────┘ └──────────────────────────┘
│ │
│ 1:N │ 1:N
▼ ├──────────┐
┌──────────────────────┐ ▼ ▼
│ User Consent │ ┌─────────────────┐ ┌────────────┐
└──────────────────────┘ │ Vendor Language │ │Sub Service │
└─────────────────┘ └────────────┘
┌──────────────────────┐
│ Cookie Consent │◄─── Project
└──────────────────────┘
┌─────────────────────────┐
│ Global Cookie Metadata │◄─── Project
└─────────────────────────┘
│ 1:N
┌─────────────────────────────┐
│ Global Cookie Language Data │
└─────────────────────────────────┘
┌──────────────────┐
│ Project Keys │◄─── Project
└──────────────────┘
```
### Sicherheitsarchitektur
#### Authentifizierung & Autorisierung
1. **JWT-basierte Authentifizierung**
- Access Token (kurzlebig)
- Refresh Token (langlebig)
- Token-Refresh-Mechanismus
2. **Rollenbasierte Zugriffskontrolle (RBAC)**
- Super Admin (Role 1): Vollzugriff
- Admin (Role 2): Projektbezogener Zugriff
- Guard-basierte Absicherung auf Controller-Ebene
3. **Encryption-based Authentication**
- Für externe/mobile Zugriffe
- Token-basierte Verschlüsselung
- User + Project ID Validierung
#### Security Features
- **Rate Limiting**: Throttler mit konfigurierbaren Limits
- **Password Security**: bcrypt Hashing mit Salt
- **Account Lock**: Nach mehrfachen Fehlversuchen
- **OTP-basierte Passwort-Wiederherstellung**
- **Input Validation**: class-validator auf allen DTOs
- **HTML Sanitization**: DOMPurify im Frontend
- **CORS Configuration**: Custom Headers Middleware
- **Soft Delete**: Keine permanente Löschung von Daten
---
## Deployment und Konfiguration
### Backend Environment Variables
```env
DATABASE_URL=postgresql://username:password@host:port/database
NODE_ENV=development|test|production|local|demo
PORT=3000
JWT_SECRET=your_jwt_secret
JWT_REFRESH_SECRET=your_refresh_secret
ROOT_SECRET=your_root_secret
ENCRYPTION_KEY=your_encryption_key
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your_email
SMTP_PASSWORD=your_password
```
### Frontend Environment
```typescript
{
production: false,
BASE_URL: "https://api.example.com/api/",
TITLE: "Policy Vault - Environment"
}
```
### Datenbank-Setup
```bash
# Migrationen ausführen
npm run migration:up
# Migrationen zurückrollen
npm run migration:down
# Schema generieren
npx drizzle-kit push
```
---
## API-Sicherheit
### Token-basierte Authentifizierung
- Alle geschützten Endpoints erfordern einen gültigen JWT-Token im Authorization-Header
- Format: `Authorization: Bearer <access_token>`
### Encryption-based Endpoints
Für mobile/externe Zugriffe (Consent Tracking):
- Header: `secret` oder `mobiletoken`
- Format: Verschlüsselter String mit `userId_projectId`
- Automatische Validierung durch DecryptMiddleware
### Rate Limiting
- Standard: 10 Requests pro Minute
- OTP/Login: 3 Requests pro Minute
- Konfigurierbar über ThrottlerModule
---
## Besondere Features
### 1. Versionierung
- Komplettes Versions-Management für Policy Documents
- Mehrsprachige Versionen mit separaten Inhalten
- Publish/Draft Status
- Historische Versionsverfolgung
### 2. Mehrsprachigkeit
- Zentrale Sprach-Konfiguration pro Projekt
- Separate Language-Data Tabellen für alle Inhaltstypen
- Support für unbegrenzte Sprachen
### 3. Cookie-Consent-System
- Granulare Kontrolle über Cookie-Kategorien
- Vendor-Management mit Sub-Services
- Plattform-spezifische Kategorien (Web, Mobile, etc.)
- Versions-Tracking für Compliance
### 4. Rich Content Editing
- CKEditor 5 Integration
- Support für komplexe Formatierungen
- Bild-Upload und -Verwaltung
- Code-Block-Unterstützung
### 5. Logging & Monitoring
- Winston-basiertes Logging
- Daily Rotate Files
- Structured Logging
- Fehler-Tracking
- Datenbank-Health-Checks
### 6. Soft Delete Pattern
- Keine permanente Datenlöschung
- `isDeleted` Flags auf allen Haupt-Entitäten
- Möglichkeit zur Wiederherstellung
- Audit Trail Erhaltung
---
## Entwicklung
### Backend starten
```bash
# Development
npm run start:dev
# Local (mit Watch)
npm run start:local
# Production
npm run start:prod
```
### Frontend starten
```bash
# Development Server
npm run start
# oder
ng serve
# Build
npm run build
# Mit PM2
npm run start:pm2
```
### Tests
```bash
# Backend Tests
npm run test
npm run test:e2e
npm run test:cov
# Frontend Tests
npm run test
```
---
## Zusammenfassung
Policy Vault ist eine umfassende Enterprise-Lösung für die Verwaltung von Datenschutzrichtlinien und Cookie-Einwilligungen. Das System bietet:
- **Multi-Tenant-Architektur** mit Projekt-basierter Trennung
- **Robuste Authentifizierung** mit JWT und rollenbasierter Zugriffskontrolle
- **Vollständiges Versions-Management** für Compliance-Tracking
- **Granulare Cookie-Consent-Verwaltung** mit Vendor-Support
- **Mehrsprachige Unterstützung** für globale Anwendungen
- **Moderne Tech-Stack** mit NestJS, Angular und PostgreSQL
- **Enterprise-Grade Security** mit Encryption, Rate Limiting und Audit Trails
- **Skalierbare Architektur** mit klarer Trennung von Concerns
Das System eignet sich ideal für Unternehmen, die:
- Multiple Projekte/Produkte mit unterschiedlichen Datenschutzrichtlinien verwalten
- GDPR/DSGVO-Compliance sicherstellen müssen
- Granulare Cookie-Einwilligungen tracken wollen
- Mehrsprachige Anwendungen betreiben
- Eine zentrale Policy-Management-Plattform benötigen

View File

@@ -0,0 +1,530 @@
# Source-Policy System - Implementierungsplan
## Zusammenfassung
Whitelist-basiertes Datenquellen-Management fuer das edu-search-service unter `/compliance/source-policy`. Fuer Auditoren pruefbar mit vollstaendigem Audit-Trail.
**Kernprinzipien:**
- Nur offizielle Open-Data-Portale und amtliche Quellen (§5 UrhG)
- Training mit externen Daten: **VERBOTEN**
- Alle Aenderungen protokolliert (Audit-Trail)
- PII-Blocklist mit Hard-Block
---
## 1. Architektur
```
┌─────────────────────────────────────────────────────────────────┐
│ admin-v2 (Next.js) │
│ /app/(admin)/compliance/source-policy/ │
│ ├── page.tsx (Dashboard + Tabs) │
│ └── components/ │
│ ├── SourcesTab.tsx (Whitelist-Verwaltung) │
│ ├── OperationsMatrixTab.tsx (Lookup/RAG/Training/Export) │
│ ├── PIIRulesTab.tsx (PII-Blocklist) │
│ └── AuditTab.tsx (Aenderungshistorie + Export) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ edu-search-service (Go) │
│ NEW: internal/policy/ │
│ ├── models.go (Datenstrukturen) │
│ ├── store.go (PostgreSQL CRUD) │
│ ├── enforcer.go (Policy-Enforcement) │
│ ├── pii_detector.go (PII-Erkennung) │
│ └── audit.go (Audit-Logging) │
│ │
│ MODIFIED: │
│ ├── crawler/crawler.go (Whitelist-Check vor Fetch) │
│ ├── pipeline/pipeline.go (PII-Filter nach Extract) │
│ └── api/handlers/policy_handlers.go (Admin-API) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ NEW TABLES: │
│ - source_policies (versionierte Policies) │
│ - allowed_sources (Whitelist pro Bundesland) │
│ - operation_permissions (Lookup/RAG/Training/Export Matrix) │
│ - pii_rules (Regex/Keyword Blocklist) │
│ - policy_audit_log (unveraenderlich) │
│ - blocked_content_log (blockierte URLs fuer Audit) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. Datenmodell
### 2.1 PostgreSQL Schema
```sql
-- Policies (versioniert)
CREATE TABLE source_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version INTEGER NOT NULL DEFAULT 1,
name VARCHAR(255) NOT NULL,
bundesland VARCHAR(2), -- NULL = Bundesebene/KMK
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
approved_by UUID,
approved_at TIMESTAMP
);
-- Whitelist
CREATE TABLE allowed_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_id UUID REFERENCES source_policies(id),
domain VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
license VARCHAR(50) NOT NULL, -- DL-DE-BY-2.0, CC-BY, §5 UrhG
legal_basis VARCHAR(100),
citation_template TEXT,
trust_boost DECIMAL(3,2) DEFAULT 0.50,
is_active BOOLEAN DEFAULT true
);
-- Operations Matrix
CREATE TABLE operation_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID REFERENCES allowed_sources(id),
operation VARCHAR(50) NOT NULL, -- lookup, rag, training, export
is_allowed BOOLEAN NOT NULL,
requires_citation BOOLEAN DEFAULT false,
notes TEXT
);
-- PII Blocklist
CREATE TABLE pii_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
rule_type VARCHAR(50) NOT NULL, -- regex, keyword
pattern TEXT NOT NULL,
severity VARCHAR(20) DEFAULT 'block', -- block, warn, redact
is_active BOOLEAN DEFAULT true
);
-- Audit Log (immutable)
CREATE TABLE policy_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID,
old_value JSONB,
new_value JSONB,
user_email VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);
-- Blocked Content Log
CREATE TABLE blocked_content_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url VARCHAR(2048) NOT NULL,
domain VARCHAR(255) NOT NULL,
block_reason VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 2.2 Initial-Daten
Datei: `edu-search-service/policies/bundeslaender.yaml`
```yaml
federal:
name: "KMK & Bundesebene"
sources:
- domain: "kmk.org"
name: "Kultusministerkonferenz"
license: "§5 UrhG"
legal_basis: "Amtliche Werke (§5 UrhG)"
citation_template: "Quelle: KMK, {title}, {date}"
- domain: "bildungsserver.de"
name: "Deutscher Bildungsserver"
license: "DL-DE-BY-2.0"
NI:
name: "Niedersachsen"
sources:
- domain: "nibis.de"
name: "NiBiS Bildungsserver"
license: "DL-DE-BY-2.0"
- domain: "mk.niedersachsen.de"
name: "Kultusministerium Niedersachsen"
license: "§5 UrhG"
- domain: "cuvo.nibis.de"
name: "Kerncurricula Niedersachsen"
license: "DL-DE-BY-2.0"
BY:
name: "Bayern"
sources:
- domain: "km.bayern.de"
name: "Bayerisches Kultusministerium"
license: "§5 UrhG"
- domain: "isb.bayern.de"
name: "ISB Bayern"
license: "DL-DE-BY-2.0"
- domain: "lehrplanplus.bayern.de"
name: "LehrplanPLUS"
license: "DL-DE-BY-2.0"
# Default Operations Matrix
default_operations:
lookup:
allowed: true
requires_citation: true
rag:
allowed: true
requires_citation: true
training:
allowed: false # VERBOTEN
export:
allowed: true
requires_citation: true
# Default PII Rules
pii_rules:
- name: "Email Addresses"
type: "regex"
pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
severity: "block"
- name: "German Phone Numbers"
type: "regex"
pattern: "(?:\\+49|0)[\\s.-]?\\d{2,4}[\\s.-]?\\d{3,}[\\s.-]?\\d{2,}"
severity: "block"
- name: "IBAN"
type: "regex"
pattern: "DE\\d{2}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{2}"
severity: "block"
```
---
## 3. Backend Implementation
### 3.1 Neue Dateien
| Datei | Beschreibung |
|-------|--------------|
| `internal/policy/models.go` | Go Structs (SourcePolicy, AllowedSource, PIIRule, etc.) |
| `internal/policy/store.go` | PostgreSQL CRUD mit pgx |
| `internal/policy/enforcer.go` | `CheckSource()`, `CheckOperation()`, `DetectPII()` |
| `internal/policy/audit.go` | `LogChange()`, `LogBlocked()` |
| `internal/policy/pii_detector.go` | Regex-basierte PII-Erkennung |
| `internal/api/handlers/policy_handlers.go` | Admin-Endpoints |
| `migrations/005_source_policies.sql` | DB-Schema |
| `policies/bundeslaender.yaml` | Initial-Daten |
### 3.2 API Endpoints
```
# Policies
GET /v1/admin/policies
POST /v1/admin/policies
PUT /v1/admin/policies/:id
# Sources (Whitelist)
GET /v1/admin/sources
POST /v1/admin/sources
PUT /v1/admin/sources/:id
DELETE /v1/admin/sources/:id
# Operations Matrix
GET /v1/admin/operations-matrix
PUT /v1/admin/operations/:id
# PII Rules
GET /v1/admin/pii-rules
POST /v1/admin/pii-rules
PUT /v1/admin/pii-rules/:id
DELETE /v1/admin/pii-rules/:id
POST /v1/admin/pii-rules/test # Test gegen Sample-Text
# Audit
GET /v1/admin/policy-audit?from=&to=
GET /v1/admin/blocked-content?from=&to=
GET /v1/admin/compliance-report # PDF/JSON Export
# Live-Check
POST /v1/admin/check-compliance
Body: { "url": "...", "operation": "lookup" }
```
### 3.3 Crawler-Integration
In `crawler/crawler.go`:
```go
func (c *Crawler) FetchWithPolicy(ctx context.Context, url string) (*FetchResult, error) {
// 1. Whitelist-Check
source, err := c.enforcer.CheckSource(ctx, url)
if err != nil || source == nil {
c.enforcer.LogBlocked(ctx, url, "not_whitelisted")
return nil, ErrNotWhitelisted
}
// ... existing fetch ...
// 2. PII-Check nach Fetch
piiMatches := c.enforcer.DetectPII(content)
if hasSeverity(piiMatches, "block") {
c.enforcer.LogBlocked(ctx, url, "pii_detected")
return nil, ErrPIIDetected
}
return result, nil
}
```
---
## 4. Frontend Implementation
### 4.1 Navigation Update
In `lib/navigation.ts` unter `compliance` Kategorie hinzufuegen:
```typescript
{
id: 'source-policy',
name: 'Quellen-Policy',
href: '/compliance/source-policy',
description: 'Datenquellen & Compliance',
purpose: 'Whitelist zugelassener Datenquellen mit Operations-Matrix und PII-Blocklist.',
audience: ['DSB', 'Compliance Officer', 'Auditor'],
gdprArticles: ['Art. 5 (Rechtmaessigkeit)', 'Art. 6 (Rechtsgrundlage)'],
}
```
### 4.2 Seiten-Struktur
```
/app/(admin)/compliance/source-policy/
├── page.tsx # Haupt-Dashboard mit Tabs
└── components/
├── SourcesTab.tsx # Whitelist-Tabelle mit CRUD
├── OperationsMatrixTab.tsx # 4x4 Matrix
├── PIIRulesTab.tsx # PII-Regeln mit Test-Funktion
└── AuditTab.tsx # Aenderungshistorie + Export
```
### 4.3 UI-Layout
**Stats Cards (oben):**
- Aktive Policies
- Zugelassene Quellen
- Blockiert (heute)
- Compliance Score
**Tabs:**
1. **Dashboard** - Uebersicht mit Quick-Stats
2. **Quellen** - Whitelist-Tabelle (Domain, Name, Lizenz, Status)
3. **Operations** - Matrix mit Lookup/RAG/Training/Export
4. **PII-Regeln** - Blocklist mit Test-Funktion
5. **Audit** - Aenderungshistorie mit PDF/JSON-Export
**Pattern (aus audit-report/page.tsx):**
- Tab-Navigation: `bg-purple-600 text-white` fuer aktiv
- Status-Badges: `bg-green-100 text-green-700` fuer aktiv
- Tabellen: `hover:bg-slate-50`
- Info-Boxen: `bg-blue-50 border-blue-200`
---
## 5. Betroffene Dateien
### Neue Dateien erstellen:
**Backend (edu-search-service):**
```
internal/policy/models.go
internal/policy/store.go
internal/policy/enforcer.go
internal/policy/audit.go
internal/policy/pii_detector.go
internal/api/handlers/policy_handlers.go
migrations/005_source_policies.sql
policies/bundeslaender.yaml
```
**Frontend (admin-v2):**
```
app/(admin)/compliance/source-policy/page.tsx
app/(admin)/compliance/source-policy/components/SourcesTab.tsx
app/(admin)/compliance/source-policy/components/OperationsMatrixTab.tsx
app/(admin)/compliance/source-policy/components/PIIRulesTab.tsx
app/(admin)/compliance/source-policy/components/AuditTab.tsx
```
### Bestehende Dateien aendern:
```
edu-search-service/cmd/server/main.go # Policy-Endpoints registrieren
edu-search-service/internal/crawler/crawler.go # Policy-Check hinzufuegen
edu-search-service/internal/pipeline/pipeline.go # PII-Filter
edu-search-service/internal/database/database.go # Migrations
admin-v2/lib/navigation.ts # source-policy Modul
```
---
## 6. Implementierungs-Reihenfolge
### Phase 1: Datenbank & Models
1. Migration `005_source_policies.sql` erstellen
2. Go Models in `internal/policy/models.go`
3. Store-Layer in `internal/policy/store.go`
4. YAML-Loader fuer Initial-Daten
### Phase 2: Policy Enforcer
1. `internal/policy/enforcer.go` - CheckSource, CheckOperation
2. `internal/policy/pii_detector.go` - Regex-basierte Erkennung
3. `internal/policy/audit.go` - Logging
4. Integration in Crawler
### Phase 3: Admin API
1. `internal/api/handlers/policy_handlers.go`
2. Routen in main.go registrieren
3. API testen
### Phase 4: Frontend
1. Hauptseite mit PagePurpose
2. SourcesTab mit Whitelist-CRUD
3. OperationsMatrixTab
4. PIIRulesTab mit Test-Funktion
5. AuditTab mit Export
### Phase 5: Testing & Deployment
1. Unit Tests fuer Enforcer
2. Integration Tests fuer API
3. E2E Test fuer Frontend
4. Deployment auf Mac Mini
---
## 7. Verifikation
### Nach Backend (Phase 1-3):
```bash
# Migration ausfuehren
ssh macmini "cd /path/to/edu-search-service && go run ./cmd/migrate"
# API testen
curl -X GET http://macmini:8088/v1/admin/policies
curl -X POST http://macmini:8088/v1/admin/check-compliance \
-d '{"url":"https://nibis.de/test","operation":"lookup"}'
```
### Nach Frontend (Phase 4):
```bash
# Build & Deploy
rsync -avz admin-v2/ macmini:/path/to/admin-v2/
ssh macmini "docker compose build admin-v2 && docker compose up -d admin-v2"
# Testen
open https://macmini:3002/compliance/source-policy
```
### Auditor-Checkliste:
- [ ] Alle Quellen in Whitelist dokumentiert
- [ ] Operations-Matrix zeigt Training = VERBOTEN
- [ ] PII-Regeln aktiv und testbar
- [ ] Audit-Log zeigt alle Aenderungen
- [ ] Blocked-Content-Log zeigt blockierte URLs
- [ ] PDF/JSON-Export funktioniert
---
## 8. KMK-Spezifika (§5 UrhG)
**Rechtsgrundlage:**
- KMK-Beschluesse, Vereinbarungen, EPA sind amtliche Werke nach §5 UrhG
- Frei nutzbar, Attribution erforderlich
**Zitierformat:**
```
Quelle: KMK, [Titel des Beschlusses], [Datum]
Beispiel: Quelle: KMK, Bildungsstandards im Fach Deutsch, 2003
```
**Zugelassene Dokumenttypen:**
- Beschluesse (Resolutions)
- Vereinbarungen (Agreements)
- EPA (Einheitliche Pruefungsanforderungen)
- Empfehlungen (Recommendations)
**In Operations-Matrix:**
| Operation | Erlaubt | Hinweis |
|-----------|---------|---------|
| Lookup | Ja | Quelle anzeigen |
| RAG | Ja | Zitation im Output |
| Training | **NEIN** | VERBOTEN |
| Export | Ja | Attribution |
---
## 9. Lizenzen
| Lizenz | Name | Attribution |
|--------|------|-------------|
| DL-DE-BY-2.0 | Datenlizenz Deutschland | Ja |
| CC-BY | Creative Commons Attribution | Ja |
| CC-BY-SA | CC Attribution-ShareAlike | Ja + ShareAlike |
| CC0 | Public Domain | Nein |
| §5 UrhG | Amtliche Werke | Ja (Quelle) |
---
## 10. Aktueller Stand
**Phase 1: Datenbank & Models - ABGESCHLOSSEN**
- [x] Codebase-Exploration edu-search-service
- [x] Codebase-Exploration admin-v2
- [x] Plan dokumentiert
- [x] Migration 005_source_policies.sql erstellen
- [x] Go Models implementieren (internal/policy/models.go)
- [x] Store-Layer implementieren (internal/policy/store.go)
- [x] Policy Enforcer implementieren (internal/policy/enforcer.go)
- [x] PII Detector implementieren (internal/policy/pii_detector.go)
- [x] Audit Logging implementieren (internal/policy/audit.go)
- [x] YAML Loader implementieren (internal/policy/loader.go)
- [x] Initial-Daten YAML erstellen (policies/bundeslaender.yaml)
- [x] Unit Tests schreiben (internal/policy/policy_test.go)
- [x] README aktualisieren
**Phase 2: Admin API - AUSSTEHEND**
- [ ] API Handlers implementieren (policy_handlers.go)
- [ ] main.go aktualisieren
- [ ] API testen
**Phase 3: Integration - AUSSTEHEND**
- [ ] Crawler-Integration
- [ ] Pipeline-Integration
**Phase 4: Frontend - AUSSTEHEND**
- [ ] Frontend page.tsx erstellen
- [ ] SourcesTab Component
- [ ] OperationsMatrixTab Component
- [ ] PIIRulesTab Component
- [ ] AuditTab Component
- [ ] Navigation aktualisieren
**Erstellte Dateien:**
```
edu-search-service/
├── migrations/
│ └── 005_source_policies.sql # DB Schema (6 Tabellen)
├── internal/policy/
│ ├── models.go # Datenstrukturen & Enums
│ ├── store.go # PostgreSQL CRUD
│ ├── enforcer.go # Policy-Enforcement
│ ├── pii_detector.go # PII-Erkennung
│ ├── audit.go # Audit-Logging
│ ├── loader.go # YAML-Loader
│ └── policy_test.go # Unit Tests
└── policies/
└── bundeslaender.yaml # Initial-Daten (8 Bundeslaender)
```

View File

@@ -16,11 +16,13 @@ COPY . .
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_OLD_ADMIN_URL
ARG NEXT_PUBLIC_SDK_URL
ARG NEXT_PUBLIC_KLAUSUR_SERVICE_URL
# Set environment variables for build
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL
ENV NEXT_PUBLIC_SDK_URL=$NEXT_PUBLIC_SDK_URL
ENV NEXT_PUBLIC_KLAUSUR_SERVICE_URL=$NEXT_PUBLIC_KLAUSUR_SERVICE_URL
# Build the application
RUN npm run build

View File

@@ -0,0 +1,45 @@
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Install dependencies
RUN apk add --no-cache git ca-certificates
# Copy go mod files
COPY go.mod go.sum* ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o sdk-backend ./cmd/server
# Runtime stage
FROM alpine:3.19
WORKDIR /app
# Install ca-certificates for HTTPS
RUN apk add --no-cache ca-certificates tzdata
# Copy binary from builder
COPY --from=builder /app/sdk-backend .
COPY --from=builder /app/configs ./configs
# Create non-root user
RUN adduser -D -g '' appuser
USER appuser
# Expose port
EXPOSE 8085
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8085/health || exit 1
# Run the application
CMD ["./sdk-backend"]

View File

@@ -0,0 +1,160 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/api"
"github.com/breakpilot/ai-compliance-sdk/internal/db"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
"github.com/breakpilot/ai-compliance-sdk/internal/rag"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}
// Get configuration from environment
port := getEnv("PORT", "8085")
dbURL := getEnv("DATABASE_URL", "postgres://localhost:5432/sdk_states?sslmode=disable")
qdrantURL := getEnv("QDRANT_URL", "http://localhost:6333")
anthropicKey := getEnv("ANTHROPIC_API_KEY", "")
// Initialize database connection
dbPool, err := db.NewPostgresPool(dbURL)
if err != nil {
log.Printf("Warning: Database connection failed: %v", err)
// Continue without database - use in-memory fallback
}
// Initialize RAG service
ragService, err := rag.NewService(qdrantURL)
if err != nil {
log.Printf("Warning: RAG service initialization failed: %v", err)
// Continue without RAG - will return empty results
}
// Initialize LLM service
llmService := llm.NewService(anthropicKey)
// Create Gin router
gin.SetMode(gin.ReleaseMode)
if os.Getenv("GIN_MODE") == "debug" {
gin.SetMode(gin.DebugMode)
}
router := gin.Default()
// CORS middleware
router.Use(corsMiddleware())
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"services": gin.H{
"database": dbPool != nil,
"rag": ragService != nil,
"llm": anthropicKey != "",
},
})
})
// API routes
v1 := router.Group("/sdk/v1")
{
// State Management
stateHandler := api.NewStateHandler(dbPool)
v1.GET("/state/:tenantId", stateHandler.GetState)
v1.POST("/state", stateHandler.SaveState)
v1.DELETE("/state/:tenantId", stateHandler.DeleteState)
// RAG Search
ragHandler := api.NewRAGHandler(ragService)
v1.GET("/rag/search", ragHandler.Search)
v1.GET("/rag/status", ragHandler.GetCorpusStatus)
v1.POST("/rag/index", ragHandler.IndexDocument)
// Document Generation
generateHandler := api.NewGenerateHandler(llmService, ragService)
v1.POST("/generate/dsfa", generateHandler.GenerateDSFA)
v1.POST("/generate/tom", generateHandler.GenerateTOM)
v1.POST("/generate/vvt", generateHandler.GenerateVVT)
v1.POST("/generate/gutachten", generateHandler.GenerateGutachten)
// Checkpoint Validation
checkpointHandler := api.NewCheckpointHandler()
v1.GET("/checkpoints", checkpointHandler.GetAll)
v1.POST("/checkpoints/validate", checkpointHandler.Validate)
}
// Create server
srv := &http.Server{
Addr: ":" + port,
Handler: router,
}
// Graceful shutdown
go func() {
log.Printf("SDK Backend starting on port %s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Give outstanding requests 5 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
// Close database connection
if dbPool != nil {
dbPool.Close()
}
log.Println("Server exited")
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, If-Match, If-None-Match")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag, Last-Modified")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,42 @@
server:
port: 8085
mode: release # debug, release, test
database:
url: postgres://localhost:5432/sdk_states?sslmode=disable
max_connections: 10
min_connections: 2
rag:
qdrant_url: http://localhost:6333
collection: legal_corpus
embedding_model: BGE-M3
top_k: 5
llm:
provider: anthropic # anthropic, openai
model: claude-3-5-sonnet-20241022
max_tokens: 4096
temperature: 0.3
cors:
allowed_origins:
- http://localhost:3000
- http://localhost:3002
- http://macmini:3000
- http://macmini:3002
allowed_methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed_headers:
- Content-Type
- Authorization
- If-Match
- If-None-Match
logging:
level: info # debug, info, warn, error
format: json

View File

@@ -0,0 +1,11 @@
module github.com/breakpilot/ai-compliance-sdk
go 1.21
require (
github.com/gin-gonic/gin v1.10.0
github.com/jackc/pgx/v5 v5.5.1
github.com/joho/godotenv v1.5.1
github.com/qdrant/go-client v1.7.0
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -0,0 +1,327 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Checkpoint represents a checkpoint definition
type Checkpoint struct {
ID string `json:"id"`
Step string `json:"step"`
Name string `json:"name"`
Type string `json:"type"`
BlocksProgress bool `json:"blocksProgress"`
RequiresReview string `json:"requiresReview"`
AutoValidate bool `json:"autoValidate"`
Description string `json:"description"`
}
// CheckpointHandler handles checkpoint-related requests
type CheckpointHandler struct {
checkpoints map[string]Checkpoint
}
// NewCheckpointHandler creates a new checkpoint handler
func NewCheckpointHandler() *CheckpointHandler {
return &CheckpointHandler{
checkpoints: initCheckpoints(),
}
}
func initCheckpoints() map[string]Checkpoint {
return map[string]Checkpoint{
"CP-UC": {
ID: "CP-UC",
Step: "use-case-workshop",
Name: "Use Case Erfassung",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Mindestens ein Use Case muss erfasst sein",
},
"CP-SCAN": {
ID: "CP-SCAN",
Step: "screening",
Name: "System Screening",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "SBOM und Security Scan müssen abgeschlossen sein",
},
"CP-MOD": {
ID: "CP-MOD",
Step: "modules",
Name: "Modul-Zuweisung",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Mindestens ein Compliance-Modul muss zugewiesen sein",
},
"CP-REQ": {
ID: "CP-REQ",
Step: "requirements",
Name: "Anforderungen",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Anforderungen müssen aus Regulierungen abgeleitet sein",
},
"CP-CTRL": {
ID: "CP-CTRL",
Step: "controls",
Name: "Controls",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Controls müssen den Anforderungen zugeordnet sein",
},
"CP-EVI": {
ID: "CP-EVI",
Step: "evidence",
Name: "Nachweise",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Nachweise für Controls müssen dokumentiert sein",
},
"CP-CHK": {
ID: "CP-CHK",
Step: "audit-checklist",
Name: "Audit Checklist",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Prüfliste muss generiert und überprüft sein",
},
"CP-RISK": {
ID: "CP-RISK",
Step: "risks",
Name: "Risikobewertung",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Kritische Risiken müssen Mitigationsmaßnahmen haben",
},
"CP-AI": {
ID: "CP-AI",
Step: "ai-act",
Name: "AI Act Klassifizierung",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "LEGAL",
AutoValidate: false,
Description: "KI-System muss klassifiziert sein",
},
"CP-OBL": {
ID: "CP-OBL",
Step: "obligations",
Name: "Pflichtenübersicht",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Rechtliche Pflichten müssen identifiziert sein",
},
"CP-DSFA": {
ID: "CP-DSFA",
Step: "dsfa",
Name: "DSFA",
Type: "RECOMMENDED",
BlocksProgress: false,
RequiresReview: "DSB",
AutoValidate: false,
Description: "Datenschutz-Folgenabschätzung muss erstellt und genehmigt sein",
},
"CP-TOM": {
ID: "CP-TOM",
Step: "tom",
Name: "TOMs",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "NONE",
AutoValidate: true,
Description: "Technische und organisatorische Maßnahmen müssen definiert sein",
},
"CP-VVT": {
ID: "CP-VVT",
Step: "vvt",
Name: "Verarbeitungsverzeichnis",
Type: "REQUIRED",
BlocksProgress: true,
RequiresReview: "DSB",
AutoValidate: false,
Description: "Verarbeitungsverzeichnis muss vollständig sein",
},
}
}
// GetAll returns all checkpoint definitions
func (h *CheckpointHandler) GetAll(c *gin.Context) {
tenantID := c.Query("tenantId")
checkpointList := make([]Checkpoint, 0, len(h.checkpoints))
for _, cp := range h.checkpoints {
checkpointList = append(checkpointList, cp)
}
SuccessResponse(c, gin.H{
"tenantId": tenantID,
"checkpoints": checkpointList,
})
}
// Validate validates a specific checkpoint
func (h *CheckpointHandler) Validate(c *gin.Context) {
var req struct {
TenantID string `json:"tenantId" binding:"required"`
CheckpointID string `json:"checkpointId" binding:"required"`
Data map[string]interface{} `json:"data"`
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
checkpoint, ok := h.checkpoints[req.CheckpointID]
if !ok {
ErrorResponse(c, http.StatusNotFound, "Checkpoint not found", "CHECKPOINT_NOT_FOUND")
return
}
// Perform validation based on checkpoint ID
result := h.validateCheckpoint(checkpoint, req.Data)
SuccessResponse(c, result)
}
func (h *CheckpointHandler) validateCheckpoint(checkpoint Checkpoint, data map[string]interface{}) CheckpointResult {
result := CheckpointResult{
CheckpointID: checkpoint.ID,
Passed: true,
ValidatedAt: now(),
ValidatedBy: "SYSTEM",
Errors: []ValidationError{},
Warnings: []ValidationError{},
}
// Validation logic based on checkpoint
switch checkpoint.ID {
case "CP-UC":
useCases, _ := data["useCases"].([]interface{})
if len(useCases) == 0 {
result.Passed = false
result.Errors = append(result.Errors, ValidationError{
RuleID: "uc-min-count",
Field: "useCases",
Message: "Mindestens ein Use Case muss erstellt werden",
Severity: "ERROR",
})
}
case "CP-SCAN":
screening, _ := data["screening"].(map[string]interface{})
if screening == nil || screening["status"] != "COMPLETED" {
result.Passed = false
result.Errors = append(result.Errors, ValidationError{
RuleID: "scan-complete",
Field: "screening",
Message: "Security Scan muss abgeschlossen sein",
Severity: "ERROR",
})
}
case "CP-MOD":
modules, _ := data["modules"].([]interface{})
if len(modules) == 0 {
result.Passed = false
result.Errors = append(result.Errors, ValidationError{
RuleID: "mod-min-count",
Field: "modules",
Message: "Mindestens ein Modul muss zugewiesen werden",
Severity: "ERROR",
})
}
case "CP-RISK":
risks, _ := data["risks"].([]interface{})
criticalUnmitigated := 0
for _, r := range risks {
risk, ok := r.(map[string]interface{})
if !ok {
continue
}
severity, _ := risk["severity"].(string)
if severity == "CRITICAL" || severity == "HIGH" {
mitigations, _ := risk["mitigation"].([]interface{})
if len(mitigations) == 0 {
criticalUnmitigated++
}
}
}
if criticalUnmitigated > 0 {
result.Passed = false
result.Errors = append(result.Errors, ValidationError{
RuleID: "critical-risks-mitigated",
Field: "risks",
Message: "Kritische Risiken ohne Mitigationsmaßnahmen gefunden",
Severity: "ERROR",
})
}
case "CP-DSFA":
dsfa, _ := data["dsfa"].(map[string]interface{})
if dsfa == nil {
result.Passed = false
result.Errors = append(result.Errors, ValidationError{
RuleID: "dsfa-exists",
Field: "dsfa",
Message: "DSFA muss erstellt werden",
Severity: "ERROR",
})
} else if dsfa["status"] != "APPROVED" {
result.Warnings = append(result.Warnings, ValidationError{
RuleID: "dsfa-approved",
Field: "dsfa",
Message: "DSFA sollte vom DSB genehmigt werden",
Severity: "WARNING",
})
}
case "CP-TOM":
toms, _ := data["toms"].([]interface{})
if len(toms) == 0 {
result.Passed = false
result.Errors = append(result.Errors, ValidationError{
RuleID: "tom-min-count",
Field: "toms",
Message: "Mindestens eine TOM muss definiert werden",
Severity: "ERROR",
})
}
case "CP-VVT":
vvt, _ := data["vvt"].([]interface{})
if len(vvt) == 0 {
result.Passed = false
result.Errors = append(result.Errors, ValidationError{
RuleID: "vvt-min-count",
Field: "vvt",
Message: "Mindestens eine Verarbeitungstätigkeit muss dokumentiert werden",
Severity: "ERROR",
})
}
}
return result
}

View File

@@ -0,0 +1,365 @@
package api
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
"github.com/breakpilot/ai-compliance-sdk/internal/rag"
"github.com/gin-gonic/gin"
)
// GenerateHandler handles document generation requests
type GenerateHandler struct {
llmService *llm.Service
ragService *rag.Service
}
// NewGenerateHandler creates a new generate handler
func NewGenerateHandler(llmService *llm.Service, ragService *rag.Service) *GenerateHandler {
return &GenerateHandler{
llmService: llmService,
ragService: ragService,
}
}
// GenerateDSFA generates a Data Protection Impact Assessment
func (h *GenerateHandler) GenerateDSFA(c *gin.Context) {
var req GenerateRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
// Get RAG context if requested
var ragSources []SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
query = "DSFA Datenschutz-Folgenabschätzung Anforderungen"
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
Score: r.Score,
Metadata: r.Metadata,
})
}
}
// Generate DSFA content
content, tokensUsed, err := h.llmService.GenerateDSFA(c.Request.Context(), req.Context, ragSources)
if err != nil {
// Return mock content if LLM fails
content = h.getMockDSFA(req.Context)
tokensUsed = 0
}
SuccessResponse(c, GenerateResponse{
Content: content,
GeneratedAt: now(),
Model: h.llmService.GetModel(),
TokensUsed: tokensUsed,
RAGSources: ragSources,
Confidence: 0.85,
})
}
// GenerateTOM generates Technical and Organizational Measures
func (h *GenerateHandler) GenerateTOM(c *gin.Context) {
var req GenerateRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
// Get RAG context if requested
var ragSources []SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
query = "technische organisatorische Maßnahmen TOM Datenschutz"
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
Score: r.Score,
Metadata: r.Metadata,
})
}
}
// Generate TOM content
content, tokensUsed, err := h.llmService.GenerateTOM(c.Request.Context(), req.Context, ragSources)
if err != nil {
content = h.getMockTOM(req.Context)
tokensUsed = 0
}
SuccessResponse(c, GenerateResponse{
Content: content,
GeneratedAt: now(),
Model: h.llmService.GetModel(),
TokensUsed: tokensUsed,
RAGSources: ragSources,
Confidence: 0.82,
})
}
// GenerateVVT generates Processing Activity Register
func (h *GenerateHandler) GenerateVVT(c *gin.Context) {
var req GenerateRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
// Get RAG context if requested
var ragSources []SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
query = "Verarbeitungsverzeichnis Art. 30 DSGVO"
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
Score: r.Score,
Metadata: r.Metadata,
})
}
}
// Generate VVT content
content, tokensUsed, err := h.llmService.GenerateVVT(c.Request.Context(), req.Context, ragSources)
if err != nil {
content = h.getMockVVT(req.Context)
tokensUsed = 0
}
SuccessResponse(c, GenerateResponse{
Content: content,
GeneratedAt: now(),
Model: h.llmService.GetModel(),
TokensUsed: tokensUsed,
RAGSources: ragSources,
Confidence: 0.88,
})
}
// GenerateGutachten generates an expert opinion/assessment
func (h *GenerateHandler) GenerateGutachten(c *gin.Context) {
var req GenerateRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
// Get RAG context if requested
var ragSources []SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
query = "Compliance Bewertung Gutachten"
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
Score: r.Score,
Metadata: r.Metadata,
})
}
}
// Generate Gutachten content
content, tokensUsed, err := h.llmService.GenerateGutachten(c.Request.Context(), req.Context, ragSources)
if err != nil {
content = h.getMockGutachten(req.Context)
tokensUsed = 0
}
SuccessResponse(c, GenerateResponse{
Content: content,
GeneratedAt: now(),
Model: h.llmService.GetModel(),
TokensUsed: tokensUsed,
RAGSources: ragSources,
Confidence: 0.80,
})
}
// Mock content generators for when LLM is not available
func (h *GenerateHandler) getMockDSFA(context map[string]interface{}) string {
return `# Datenschutz-Folgenabschätzung (DSFA)
## 1. Systematische Beschreibung der Verarbeitungsvorgänge
Die geplante Verarbeitung umfasst die Analyse von Kundendaten mittels KI-gestützter Systeme zur Verbesserung der Servicequalität und Personalisierung von Angeboten.
### Verarbeitungszwecke:
- Kundensegmentierung und Analyse des Nutzerverhaltens
- Personalisierte Empfehlungen
- Optimierung von Geschäftsprozessen
### Rechtsgrundlage:
- Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse)
- Alternativ: Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)
## 2. Bewertung der Notwendigkeit und Verhältnismäßigkeit
Die Verarbeitung ist für die genannten Zwecke erforderlich und verhältnismäßig. Alternative Maßnahmen wurden geprüft, jedoch sind diese weniger effektiv.
## 3. Risikobewertung
### Identifizierte Risiken:
| Risiko | Eintrittswahrscheinlichkeit | Schwere | Maßnahmen |
|--------|---------------------------|---------|-----------|
| Unbefugter Zugriff | Mittel | Hoch | Verschlüsselung, Zugangskontrolle |
| Profilbildung | Hoch | Mittel | Anonymisierung, Einwilligung |
| Datenverlust | Niedrig | Hoch | Backup, Redundanz |
## 4. Maßnahmen zur Risikominderung
- Implementierung von Verschlüsselung (AES-256)
- Strenge Zugriffskontrollen nach dem Least-Privilege-Prinzip
- Regelmäßige Datenschutz-Schulungen
- Audit-Logging aller Zugriffe
## 5. Stellungnahme des Datenschutzbeauftragten
[Hier Stellungnahme einfügen]
## 6. Dokumentation der Konsultation
Erstellt am: ${new Date().toISOString()}
Status: ENTWURF
`
}
func (h *GenerateHandler) getMockTOM(context map[string]interface{}) string {
return `# Technische und Organisatorische Maßnahmen (TOMs)
## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO)
### 1.1 Zutrittskontrolle
- Alarmanlage
- Chipkarten-/Transponder-System
- Videoüberwachung der Eingänge
- Besuchererfassung und -begleitung
### 1.2 Zugangskontrolle
- Passwort-Richtlinie (min. 12 Zeichen, Komplexitätsanforderungen)
- Multi-Faktor-Authentifizierung
- Automatische Bildschirmsperre
- VPN für Remote-Zugriffe
### 1.3 Zugriffskontrolle
- Rollenbasiertes Berechtigungskonzept
- Need-to-know-Prinzip
- Regelmäßige Überprüfung der Zugriffsrechte
- Protokollierung aller Zugriffe
## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO)
### 2.1 Weitergabekontrolle
- Transportverschlüsselung (TLS 1.3)
- Ende-zu-Ende-Verschlüsselung für sensible Daten
- Sichere E-Mail-Kommunikation (S/MIME)
### 2.2 Eingabekontrolle
- Protokollierung aller Datenänderungen
- Benutzeridentifikation bei Änderungen
- Audit-Trail für alle Transaktionen
## 3. Verfügbarkeit (Art. 32 Abs. 1 lit. c DSGVO)
### 3.1 Verfügbarkeitskontrolle
- Tägliche Backups
- Georedundante Datenspeicherung
- USV-Anlage
- Notfallplan
### 3.2 Wiederherstellung
- Dokumentierte Wiederherstellungsverfahren
- Regelmäßige Backup-Tests
- Maximale Wiederherstellungszeit: 4 Stunden
## 4. Belastbarkeit (Art. 32 Abs. 1 lit. b DSGVO)
- Lastverteilung
- DDoS-Schutz
- Skalierbare Infrastruktur
`
}
func (h *GenerateHandler) getMockVVT(context map[string]interface{}) string {
return `# Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO)
## Verarbeitungstätigkeit: Kundenanalyse und Personalisierung
### Angaben nach Art. 30 Abs. 1 DSGVO:
| Feld | Inhalt |
|------|--------|
| **Name des Verantwortlichen** | [Unternehmensname] |
| **Kontaktdaten** | [Adresse, E-Mail, Telefon] |
| **Datenschutzbeauftragter** | [Name, Kontakt] |
| **Zweck der Verarbeitung** | Kundensegmentierung, Personalisierung, Serviceoptimierung |
| **Kategorien betroffener Personen** | Kunden, Interessenten |
| **Kategorien personenbezogener Daten** | Kontaktdaten, Nutzungsdaten, Transaktionsdaten |
| **Kategorien von Empfängern** | Interne Abteilungen, IT-Dienstleister |
| **Drittlandtransfer** | Nein / Ja (mit Angabe der Garantien) |
| **Löschfristen** | 3 Jahre nach letzter Aktivität |
| **TOM-Referenz** | Siehe TOM-Dokument v1.0 |
### Rechtsgrundlage:
Art. 6 Abs. 1 lit. f DSGVO - Berechtigtes Interesse
### Dokumentation:
- Erstellt: ${new Date().toISOString()}
- Letzte Aktualisierung: ${new Date().toISOString()}
- Version: 1.0
`
}
func (h *GenerateHandler) getMockGutachten(context map[string]interface{}) string {
return `# Compliance-Gutachten
## Zusammenfassung
Das geprüfte KI-System erfüllt die wesentlichen Anforderungen der DSGVO und des AI Acts. Es wurden jedoch Optimierungspotenziale identifiziert.
## Prüfungsumfang
- DSGVO-Konformität
- AI Act Compliance
- NIS2-Anforderungen
## Bewertungsergebnis
| Bereich | Bewertung | Handlungsbedarf |
|---------|-----------|-----------------|
| Datenschutz | Gut | Gering |
| KI-Risikoeinstufung | Erfüllt | Keiner |
| Cybersicherheit | Befriedigend | Mittel |
## Empfehlungen
1. Verstärkung der Dokumentation
2. Regelmäßige Audits einplanen
3. Schulungsmaßnahmen erweitern
Erstellt am: ${new Date().toISOString()}
`
}

View File

@@ -0,0 +1,182 @@
package api
import (
"net/http"
"strconv"
"github.com/breakpilot/ai-compliance-sdk/internal/rag"
"github.com/gin-gonic/gin"
)
// RAGHandler handles RAG search requests
type RAGHandler struct {
ragService *rag.Service
}
// NewRAGHandler creates a new RAG handler
func NewRAGHandler(ragService *rag.Service) *RAGHandler {
return &RAGHandler{
ragService: ragService,
}
}
// Search performs semantic search on the legal corpus
func (h *RAGHandler) Search(c *gin.Context) {
query := c.Query("q")
if query == "" {
ErrorResponse(c, http.StatusBadRequest, "Query parameter 'q' is required", "MISSING_QUERY")
return
}
topK := 5
if topKStr := c.Query("top_k"); topKStr != "" {
if parsed, err := strconv.Atoi(topKStr); err == nil && parsed > 0 {
topK = parsed
}
}
collection := c.DefaultQuery("collection", "legal_corpus")
filter := c.Query("filter") // e.g., "regulation:DSGVO" or "category:ai_act"
// Check if RAG service is available
if h.ragService == nil {
// Return mock data when RAG is not available
SuccessResponse(c, gin.H{
"query": query,
"topK": topK,
"results": h.getMockResults(query),
"source": "mock",
})
return
}
results, err := h.ragService.Search(c.Request.Context(), query, topK, collection, filter)
if err != nil {
ErrorResponse(c, http.StatusInternalServerError, "Search failed: "+err.Error(), "SEARCH_FAILED")
return
}
SuccessResponse(c, gin.H{
"query": query,
"topK": topK,
"results": results,
"source": "qdrant",
})
}
// GetCorpusStatus returns the status of the legal corpus
func (h *RAGHandler) GetCorpusStatus(c *gin.Context) {
if h.ragService == nil {
SuccessResponse(c, gin.H{
"status": "unavailable",
"collections": []string{},
"documents": 0,
})
return
}
status, err := h.ragService.GetCorpusStatus(c.Request.Context())
if err != nil {
ErrorResponse(c, http.StatusInternalServerError, "Failed to get corpus status", "STATUS_FAILED")
return
}
SuccessResponse(c, status)
}
// IndexDocument indexes a new document into the corpus
func (h *RAGHandler) IndexDocument(c *gin.Context) {
var req struct {
Collection string `json:"collection" binding:"required"`
ID string `json:"id" binding:"required"`
Content string `json:"content" binding:"required"`
Metadata map[string]string `json:"metadata"`
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
if h.ragService == nil {
ErrorResponse(c, http.StatusServiceUnavailable, "RAG service not available", "SERVICE_UNAVAILABLE")
return
}
err := h.ragService.IndexDocument(c.Request.Context(), req.Collection, req.ID, req.Content, req.Metadata)
if err != nil {
ErrorResponse(c, http.StatusInternalServerError, "Failed to index document: "+err.Error(), "INDEX_FAILED")
return
}
SuccessResponse(c, gin.H{
"indexed": true,
"id": req.ID,
"collection": req.Collection,
"indexedAt": now(),
})
}
// getMockResults returns mock search results for development
func (h *RAGHandler) getMockResults(query string) []SearchResult {
// Simplified mock results based on common compliance queries
results := []SearchResult{
{
ID: "dsgvo-art-5",
Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten: Personenbezogene Daten müssen auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden.",
Source: "DSGVO",
Score: 0.95,
Metadata: map[string]string{
"article": "5",
"regulation": "DSGVO",
"category": "grundsaetze",
},
},
{
ID: "dsgvo-art-6",
Content: "Art. 6 DSGVO - Rechtmäßigkeit der Verarbeitung: Die Verarbeitung ist nur rechtmäßig, wenn mindestens eine der folgenden Bedingungen erfüllt ist: Einwilligung, Vertragserfüllung, rechtliche Verpflichtung, lebenswichtige Interessen, öffentliche Aufgabe, berechtigtes Interesse.",
Source: "DSGVO",
Score: 0.89,
Metadata: map[string]string{
"article": "6",
"regulation": "DSGVO",
"category": "rechtsgrundlage",
},
},
{
ID: "ai-act-art-6",
Content: "Art. 6 AI Act - Klassifizierungsregeln für Hochrisiko-KI-Systeme: Ein KI-System gilt als Hochrisiko-System, wenn es als Sicherheitskomponente eines Produkts verwendet wird oder selbst ein Produkt ist, das unter die in Anhang II aufgeführten Harmonisierungsrechtsvorschriften fällt.",
Source: "AI Act",
Score: 0.85,
Metadata: map[string]string{
"article": "6",
"regulation": "AI_ACT",
"category": "hochrisiko",
},
},
{
ID: "nis2-art-21",
Content: "Art. 21 NIS2 - Risikomanagementmaßnahmen: Wesentliche und wichtige Einrichtungen müssen geeignete und verhältnismäßige technische, operative und organisatorische Maßnahmen ergreifen, um die Risiken für die Sicherheit der Netz- und Informationssysteme zu beherrschen.",
Source: "NIS2",
Score: 0.78,
Metadata: map[string]string{
"article": "21",
"regulation": "NIS2",
"category": "risikomanagement",
},
},
{
ID: "dsgvo-art-35",
Content: "Art. 35 DSGVO - Datenschutz-Folgenabschätzung: Hat eine Form der Verarbeitung, insbesondere bei Verwendung neuer Technologien, aufgrund der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge, so führt der Verantwortliche vorab eine Abschätzung der Folgen der vorgesehenen Verarbeitungsvorgänge für den Schutz personenbezogener Daten durch.",
Source: "DSGVO",
Score: 0.75,
Metadata: map[string]string{
"article": "35",
"regulation": "DSGVO",
"category": "dsfa",
},
},
}
return results
}

View File

@@ -0,0 +1,96 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// Response represents a standard API response
type Response struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
}
// SuccessResponse creates a success response
func SuccessResponse(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Success: true,
Data: data,
})
}
// ErrorResponse creates an error response
func ErrorResponse(c *gin.Context, status int, err string, code string) {
c.JSON(status, Response{
Success: false,
Error: err,
Code: code,
})
}
// StateData represents state response data
type StateData struct {
TenantID string `json:"tenantId"`
State interface{} `json:"state"`
Version int `json:"version"`
LastModified string `json:"lastModified"`
}
// ValidationError represents a validation error
type ValidationError struct {
RuleID string `json:"ruleId"`
Field string `json:"field"`
Message string `json:"message"`
Severity string `json:"severity"`
}
// CheckpointResult represents checkpoint validation result
type CheckpointResult struct {
CheckpointID string `json:"checkpointId"`
Passed bool `json:"passed"`
ValidatedAt string `json:"validatedAt"`
ValidatedBy string `json:"validatedBy"`
Errors []ValidationError `json:"errors"`
Warnings []ValidationError `json:"warnings"`
}
// SearchResult represents a RAG search result
type SearchResult struct {
ID string `json:"id"`
Content string `json:"content"`
Source string `json:"source"`
Score float64 `json:"score"`
Metadata map[string]string `json:"metadata,omitempty"`
Highlights []string `json:"highlights,omitempty"`
}
// GenerateRequest represents a document generation request
type GenerateRequest struct {
TenantID string `json:"tenantId" binding:"required"`
Context map[string]interface{} `json:"context"`
Template string `json:"template,omitempty"`
Language string `json:"language,omitempty"`
UseRAG bool `json:"useRag"`
RAGQuery string `json:"ragQuery,omitempty"`
MaxTokens int `json:"maxTokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
// GenerateResponse represents a document generation response
type GenerateResponse struct {
Content string `json:"content"`
GeneratedAt string `json:"generatedAt"`
Model string `json:"model"`
TokensUsed int `json:"tokensUsed"`
RAGSources []SearchResult `json:"ragSources,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
}
// Timestamps helper
func now() string {
return time.Now().UTC().Format(time.RFC3339)
}

View File

@@ -0,0 +1,171 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/breakpilot/ai-compliance-sdk/internal/db"
"github.com/gin-gonic/gin"
)
// StateHandler handles state management requests
type StateHandler struct {
dbPool *db.Pool
memStore *db.InMemoryStore
}
// NewStateHandler creates a new state handler
func NewStateHandler(dbPool *db.Pool) *StateHandler {
return &StateHandler{
dbPool: dbPool,
memStore: db.NewInMemoryStore(),
}
}
// GetState retrieves state for a tenant
func (h *StateHandler) GetState(c *gin.Context) {
tenantID := c.Param("tenantId")
if tenantID == "" {
ErrorResponse(c, http.StatusBadRequest, "tenantId is required", "MISSING_TENANT_ID")
return
}
var state *db.SDKState
var err error
// Try database first, fall back to in-memory
if h.dbPool != nil {
state, err = h.dbPool.GetState(c.Request.Context(), tenantID)
} else {
state, err = h.memStore.GetState(tenantID)
}
if err != nil {
ErrorResponse(c, http.StatusNotFound, "State not found", "STATE_NOT_FOUND")
return
}
// Generate ETag
etag := generateETag(state.Version, state.UpdatedAt.String())
// Check If-None-Match header
if c.GetHeader("If-None-Match") == etag {
c.Status(http.StatusNotModified)
return
}
// Parse state JSON
var stateData interface{}
if err := json.Unmarshal(state.State, &stateData); err != nil {
stateData = state.State
}
c.Header("ETag", etag)
c.Header("Last-Modified", state.UpdatedAt.Format("Mon, 02 Jan 2006 15:04:05 GMT"))
c.Header("Cache-Control", "private, no-cache")
SuccessResponse(c, StateData{
TenantID: state.TenantID,
State: stateData,
Version: state.Version,
LastModified: state.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
// SaveState saves state for a tenant
func (h *StateHandler) SaveState(c *gin.Context) {
var req struct {
TenantID string `json:"tenantId" binding:"required"`
UserID string `json:"userId"`
State json.RawMessage `json:"state" binding:"required"`
Version *int `json:"version"`
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
// Check If-Match header for optimistic locking
var expectedVersion *int
if ifMatch := c.GetHeader("If-Match"); ifMatch != "" {
v, err := strconv.Atoi(ifMatch)
if err == nil {
expectedVersion = &v
}
} else if req.Version != nil {
expectedVersion = req.Version
}
var state *db.SDKState
var err error
// Try database first, fall back to in-memory
if h.dbPool != nil {
state, err = h.dbPool.SaveState(c.Request.Context(), req.TenantID, req.UserID, req.State, expectedVersion)
} else {
state, err = h.memStore.SaveState(req.TenantID, req.UserID, req.State, expectedVersion)
}
if err != nil {
if err.Error() == "version conflict" {
ErrorResponse(c, http.StatusConflict, "Version conflict. State was modified by another request.", "VERSION_CONFLICT")
return
}
ErrorResponse(c, http.StatusInternalServerError, "Failed to save state", "SAVE_FAILED")
return
}
// Generate ETag
etag := generateETag(state.Version, state.UpdatedAt.String())
// Parse state JSON
var stateData interface{}
if err := json.Unmarshal(state.State, &stateData); err != nil {
stateData = state.State
}
c.Header("ETag", etag)
c.Header("Last-Modified", state.UpdatedAt.Format("Mon, 02 Jan 2006 15:04:05 GMT"))
SuccessResponse(c, StateData{
TenantID: state.TenantID,
State: stateData,
Version: state.Version,
LastModified: state.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
// DeleteState deletes state for a tenant
func (h *StateHandler) DeleteState(c *gin.Context) {
tenantID := c.Param("tenantId")
if tenantID == "" {
ErrorResponse(c, http.StatusBadRequest, "tenantId is required", "MISSING_TENANT_ID")
return
}
var err error
// Try database first, fall back to in-memory
if h.dbPool != nil {
err = h.dbPool.DeleteState(c.Request.Context(), tenantID)
} else {
err = h.memStore.DeleteState(tenantID)
}
if err != nil {
ErrorResponse(c, http.StatusInternalServerError, "Failed to delete state", "DELETE_FAILED")
return
}
SuccessResponse(c, gin.H{
"tenantId": tenantID,
"deletedAt": now(),
})
}
// generateETag creates an ETag from version and timestamp
func generateETag(version int, timestamp string) string {
return "\"" + strconv.Itoa(version) + "-" + timestamp[:8] + "\""
}

View File

@@ -0,0 +1,173 @@
package db
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Pool wraps a pgxpool.Pool with SDK-specific methods
type Pool struct {
*pgxpool.Pool
}
// SDKState represents the state stored in the database
type SDKState struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
UserID string `json:"user_id,omitempty"`
State json.RawMessage `json:"state"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NewPostgresPool creates a new database connection pool
func NewPostgresPool(connectionString string) (*Pool, error) {
config, err := pgxpool.ParseConfig(connectionString)
if err != nil {
return nil, fmt.Errorf("failed to parse connection string: %w", err)
}
config.MaxConns = 10
config.MinConns = 2
config.MaxConnLifetime = 1 * time.Hour
config.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
// Test connection
if err := pool.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &Pool{Pool: pool}, nil
}
// GetState retrieves state for a tenant
func (p *Pool) GetState(ctx context.Context, tenantID string) (*SDKState, error) {
query := `
SELECT id, tenant_id, user_id, state, version, created_at, updated_at
FROM sdk_states
WHERE tenant_id = $1
`
var state SDKState
err := p.QueryRow(ctx, query, tenantID).Scan(
&state.ID,
&state.TenantID,
&state.UserID,
&state.State,
&state.Version,
&state.CreatedAt,
&state.UpdatedAt,
)
if err != nil {
return nil, err
}
return &state, nil
}
// SaveState saves or updates state for a tenant with optimistic locking
func (p *Pool) SaveState(ctx context.Context, tenantID string, userID string, state json.RawMessage, expectedVersion *int) (*SDKState, error) {
query := `
INSERT INTO sdk_states (tenant_id, user_id, state, version)
VALUES ($1, $2, $3, 1)
ON CONFLICT (tenant_id) DO UPDATE SET
state = $3,
user_id = COALESCE($2, sdk_states.user_id),
version = sdk_states.version + 1,
updated_at = NOW()
WHERE ($4::int IS NULL OR sdk_states.version = $4)
RETURNING id, tenant_id, user_id, state, version, created_at, updated_at
`
var result SDKState
err := p.QueryRow(ctx, query, tenantID, userID, state, expectedVersion).Scan(
&result.ID,
&result.TenantID,
&result.UserID,
&result.State,
&result.Version,
&result.CreatedAt,
&result.UpdatedAt,
)
if err != nil {
return nil, err
}
return &result, nil
}
// DeleteState deletes state for a tenant
func (p *Pool) DeleteState(ctx context.Context, tenantID string) error {
query := `DELETE FROM sdk_states WHERE tenant_id = $1`
_, err := p.Exec(ctx, query, tenantID)
return err
}
// InMemoryStore provides an in-memory fallback when database is not available
type InMemoryStore struct {
states map[string]*SDKState
}
// NewInMemoryStore creates a new in-memory store
func NewInMemoryStore() *InMemoryStore {
return &InMemoryStore{
states: make(map[string]*SDKState),
}
}
// GetState retrieves state from memory
func (s *InMemoryStore) GetState(tenantID string) (*SDKState, error) {
state, ok := s.states[tenantID]
if !ok {
return nil, fmt.Errorf("state not found")
}
return state, nil
}
// SaveState saves state to memory
func (s *InMemoryStore) SaveState(tenantID string, userID string, state json.RawMessage, expectedVersion *int) (*SDKState, error) {
existing, exists := s.states[tenantID]
// Optimistic locking check
if expectedVersion != nil && exists && existing.Version != *expectedVersion {
return nil, fmt.Errorf("version conflict")
}
now := time.Now()
version := 1
createdAt := now
if exists {
version = existing.Version + 1
createdAt = existing.CreatedAt
}
newState := &SDKState{
ID: fmt.Sprintf("%s-%d", tenantID, time.Now().UnixNano()),
TenantID: tenantID,
UserID: userID,
State: state,
Version: version,
CreatedAt: createdAt,
UpdatedAt: now,
}
s.states[tenantID] = newState
return newState, nil
}
// DeleteState deletes state from memory
func (s *InMemoryStore) DeleteState(tenantID string) error {
delete(s.states, tenantID)
return nil
}

View File

@@ -0,0 +1,384 @@
package llm
import (
"context"
"fmt"
"strings"
)
// SearchResult matches the RAG service result structure
type SearchResult struct {
ID string `json:"id"`
Content string `json:"content"`
Source string `json:"source"`
Score float64 `json:"score"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// Service provides LLM functionality for document generation
type Service struct {
apiKey string
model string
}
// NewService creates a new LLM service
func NewService(apiKey string) *Service {
model := "claude-3-5-sonnet-20241022"
if apiKey == "" {
model = "mock"
}
return &Service{
apiKey: apiKey,
model: model,
}
}
// GetModel returns the current model name
func (s *Service) GetModel() string {
return s.model
}
// GenerateDSFA generates a Data Protection Impact Assessment
func (s *Service) GenerateDSFA(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) {
if s.apiKey == "" {
return "", 0, fmt.Errorf("LLM not configured")
}
// Build prompt with context and RAG sources
prompt := s.buildDSFAPrompt(context, ragSources)
// In production, this would call the Anthropic API
// response, err := s.callAnthropicAPI(ctx, prompt)
// if err != nil {
// return "", 0, err
// }
// For now, simulate a response
content := s.generateDSFAContent(context, ragSources)
tokensUsed := len(strings.Split(content, " ")) * 2 // Rough estimate
return content, tokensUsed, nil
}
// GenerateTOM generates Technical and Organizational Measures
func (s *Service) GenerateTOM(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) {
if s.apiKey == "" {
return "", 0, fmt.Errorf("LLM not configured")
}
content := s.generateTOMContent(context, ragSources)
tokensUsed := len(strings.Split(content, " ")) * 2
return content, tokensUsed, nil
}
// GenerateVVT generates a Processing Activity Register
func (s *Service) GenerateVVT(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) {
if s.apiKey == "" {
return "", 0, fmt.Errorf("LLM not configured")
}
content := s.generateVVTContent(context, ragSources)
tokensUsed := len(strings.Split(content, " ")) * 2
return content, tokensUsed, nil
}
// GenerateGutachten generates an expert opinion/assessment
func (s *Service) GenerateGutachten(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) {
if s.apiKey == "" {
return "", 0, fmt.Errorf("LLM not configured")
}
content := s.generateGutachtenContent(context, ragSources)
tokensUsed := len(strings.Split(content, " ")) * 2
return content, tokensUsed, nil
}
// buildDSFAPrompt builds the prompt for DSFA generation
func (s *Service) buildDSFAPrompt(context map[string]interface{}, ragSources []SearchResult) string {
var sb strings.Builder
sb.WriteString("Du bist ein Datenschutz-Experte und erstellst eine Datenschutz-Folgenabschätzung (DSFA) gemäß Art. 35 DSGVO.\n\n")
// Add context
if useCaseName, ok := context["useCaseName"].(string); ok {
sb.WriteString(fmt.Sprintf("Use Case: %s\n", useCaseName))
}
if description, ok := context["description"].(string); ok {
sb.WriteString(fmt.Sprintf("Beschreibung: %s\n", description))
}
// Add RAG context
if len(ragSources) > 0 {
sb.WriteString("\nRelevante rechtliche Grundlagen:\n")
for _, source := range ragSources {
sb.WriteString(fmt.Sprintf("- %s (%s)\n", source.Content[:min(200, len(source.Content))], source.Source))
}
}
sb.WriteString("\nErstelle eine vollständige DSFA mit allen erforderlichen Abschnitten.")
return sb.String()
}
// Content generation functions (would be replaced by actual LLM calls in production)
func (s *Service) generateDSFAContent(context map[string]interface{}, ragSources []SearchResult) string {
useCaseName := "KI-gestützte Datenverarbeitung"
if name, ok := context["useCaseName"].(string); ok {
useCaseName = name
}
return fmt.Sprintf(`# Datenschutz-Folgenabschätzung (DSFA)
## Use Case: %s
## 1. Systematische Beschreibung der Verarbeitungsvorgänge
Die geplante Verarbeitung umfasst die Analyse von Daten mittels KI-gestützter Systeme.
### 1.1 Verarbeitungszwecke
- Automatisierte Analyse und Verarbeitung
- Optimierung von Geschäftsprozessen
- Qualitätssicherung
### 1.2 Rechtsgrundlage
Gemäß Art. 6 Abs. 1 lit. f DSGVO basiert die Verarbeitung auf dem berechtigten Interesse des Verantwortlichen.
### 1.3 Kategorien verarbeiteter Daten
- Nutzungsdaten
- Metadaten
- Aggregierte Analysedaten
## 2. Bewertung der Notwendigkeit und Verhältnismäßigkeit
### 2.1 Notwendigkeit
Die Verarbeitung ist erforderlich, um die definierten Geschäftsziele zu erreichen.
### 2.2 Verhältnismäßigkeit
Alternative Methoden wurden geprüft. Die gewählte Verarbeitungsmethode stellt den geringsten Eingriff bei gleichem Nutzen dar.
## 3. Risikobewertung
### 3.1 Identifizierte Risiken
| Risiko | Wahrscheinlichkeit | Schwere | Gesamtbewertung |
|--------|-------------------|---------|-----------------|
| Unbefugter Zugriff | Mittel | Hoch | HOCH |
| Datenverlust | Niedrig | Hoch | MITTEL |
| Fehlinterpretation | Mittel | Mittel | MITTEL |
### 3.2 Maßnahmen zur Risikominderung
1. **Technische Maßnahmen**
- Verschlüsselung (AES-256)
- Zugriffskontrollen
- Audit-Logging
2. **Organisatorische Maßnahmen**
- Schulungen
- Dokumentation
- Regelmäßige Überprüfungen
## 4. Genehmigungsstatus
| Rolle | Status | Datum |
|-------|--------|-------|
| Projektleiter | AUSSTEHEND | - |
| DSB | AUSSTEHEND | - |
| Geschäftsführung | AUSSTEHEND | - |
---
*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.*
`, useCaseName)
}
func (s *Service) generateTOMContent(context map[string]interface{}, ragSources []SearchResult) string {
return `# Technische und Organisatorische Maßnahmen (TOMs)
## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO)
### 1.1 Zutrittskontrolle
- [ ] Alarmanlage installiert
- [ ] Chipkarten-System aktiv
- [ ] Besucherprotokoll geführt
### 1.2 Zugangskontrolle
- [ ] Starke Passwort-Policy (12+ Zeichen)
- [ ] MFA aktiviert
- [ ] Automatische Bildschirmsperre
### 1.3 Zugriffskontrolle
- [ ] Rollenbasierte Berechtigungen
- [ ] Need-to-know Prinzip
- [ ] Quartalsweise Berechtigungsüberprüfung
## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO)
### 2.1 Weitergabekontrolle
- [ ] TLS 1.3 für alle Übertragungen
- [ ] E-Mail-Verschlüsselung
- [ ] Sichere File-Transfer-Protokolle
### 2.2 Eingabekontrolle
- [ ] Vollständiges Audit-Logging
- [ ] Benutzeridentifikation bei Änderungen
- [ ] Unveränderliche Protokolle
## 3. Verfügbarkeit (Art. 32 Abs. 1 lit. c DSGVO)
### 3.1 Verfügbarkeitskontrolle
- [ ] Tägliche Backups
- [ ] Georedundante Speicherung
- [ ] USV-System
- [ ] Dokumentierter Notfallplan
### 3.2 Wiederherstellung
- [ ] RPO: 1 Stunde
- [ ] RTO: 4 Stunden
- [ ] Jährliche Wiederherstellungstests
## 4. Belastbarkeit
- [ ] DDoS-Schutz implementiert
- [ ] Lastverteilung aktiv
- [ ] Skalierbare Infrastruktur
---
*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.*
`
}
func (s *Service) generateVVTContent(context map[string]interface{}, ragSources []SearchResult) string {
return `# Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO)
## Verarbeitungstätigkeit Nr. 1
### Stammdaten
| Feld | Wert |
|------|------|
| **Bezeichnung** | KI-gestützte Datenanalyse |
| **Verantwortlicher** | [Unternehmen] |
| **DSB** | [Name, Kontakt] |
| **Abteilung** | IT / Data Science |
### Verarbeitungsdetails
| Feld | Wert |
|------|------|
| **Zweck** | Optimierung von Geschäftsprozessen durch KI-Analyse |
| **Rechtsgrundlage** | Art. 6 Abs. 1 lit. f DSGVO |
| **Betroffene Kategorien** | Kunden, Mitarbeiter, Geschäftspartner |
| **Datenkategorien** | Nutzungsdaten, Metadaten, Analyseergebnisse |
### Empfänger
| Kategorie | Beispiele |
|-----------|-----------|
| Intern | IT-Abteilung, Management |
| Auftragsverarbeiter | Cloud-Provider (mit AVV) |
| Dritte | Keine |
### Drittlandtransfer
| Frage | Antwort |
|-------|---------|
| Übermittlung in Drittländer? | Nein / Ja |
| Falls ja, Garantien | [Standardvertragsklauseln / Angemessenheitsbeschluss] |
### Löschfristen
| Datenkategorie | Frist | Grundlage |
|----------------|-------|-----------|
| Nutzungsdaten | 12 Monate | Betriebliche Notwendigkeit |
| Analyseergebnisse | 36 Monate | Geschäftszweck |
| Audit-Logs | 10 Jahre | Handelsrechtlich |
### Technisch-Organisatorische Maßnahmen
Verweis auf TOM-Dokument Version 1.0
---
*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.*
`
}
func (s *Service) generateGutachtenContent(context map[string]interface{}, ragSources []SearchResult) string {
return `# Compliance-Gutachten
## Management Summary
Das geprüfte System erfüllt die wesentlichen Anforderungen der anwendbaren Regulierungen. Es bestehen Optimierungspotenziale, die priorisiert adressiert werden sollten.
## 1. Prüfungsumfang
### 1.1 Geprüfte Regulierungen
- DSGVO (EU 2016/679)
- AI Act (EU 2024/...)
- NIS2 (EU 2022/2555)
### 1.2 Prüfungsmethodik
- Dokumentenprüfung
- Technische Analyse
- Interviews mit Stakeholdern
## 2. Ergebnisse
### 2.1 DSGVO-Konformität
| Bereich | Bewertung | Handlungsbedarf |
|---------|-----------|-----------------|
| Rechtmäßigkeit | ✓ Erfüllt | Gering |
| Transparenz | ◐ Teilweise | Mittel |
| Datensicherheit | ✓ Erfüllt | Gering |
| Betroffenenrechte | ◐ Teilweise | Mittel |
### 2.2 AI Act-Konformität
| Bereich | Bewertung | Handlungsbedarf |
|---------|-----------|-----------------|
| Risikoklassifizierung | ✓ Erfüllt | Keiner |
| Dokumentation | ◐ Teilweise | Mittel |
| Human Oversight | ✓ Erfüllt | Gering |
### 2.3 NIS2-Konformität
| Bereich | Bewertung | Handlungsbedarf |
|---------|-----------|-----------------|
| Risikomanagement | ✓ Erfüllt | Gering |
| Incident Reporting | ◐ Teilweise | Hoch |
| Supply Chain | ○ Nicht erfüllt | Kritisch |
## 3. Empfehlungen
### Kritisch (sofort)
1. Supply-Chain-Risikomanagement implementieren
2. Incident-Reporting-Prozess etablieren
### Hoch (< 3 Monate)
3. Transparenzdokumentation vervollständigen
4. Betroffenenrechte-Portal optimieren
### Mittel (< 6 Monate)
5. AI Act Dokumentation erweitern
6. Schulungsmaßnahmen durchführen
## 4. Fazit
Das System zeigt einen guten Compliance-Stand mit klar definierten Verbesserungsbereichen. Bei Umsetzung der Empfehlungen ist eine vollständige Konformität erreichbar.
---
*Erstellt: [Datum]*
*Gutachter: [Name]*
*Version: 1.0*
`
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,208 @@
package rag
import (
"context"
"fmt"
)
// SearchResult represents a search result from the RAG system
type SearchResult struct {
ID string `json:"id"`
Content string `json:"content"`
Source string `json:"source"`
Score float64 `json:"score"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// CorpusStatus represents the status of the legal corpus
type CorpusStatus struct {
Status string `json:"status"`
Collections []string `json:"collections"`
Documents int `json:"documents"`
LastUpdated string `json:"lastUpdated,omitempty"`
}
// Service provides RAG functionality
type Service struct {
qdrantURL string
// client *qdrant.Client // Would be actual Qdrant client in production
}
// NewService creates a new RAG service
func NewService(qdrantURL string) (*Service, error) {
if qdrantURL == "" {
return nil, fmt.Errorf("qdrant URL is required")
}
// In production, this would initialize the Qdrant client
// client, err := qdrant.NewClient(qdrantURL)
// if err != nil {
// return nil, err
// }
return &Service{
qdrantURL: qdrantURL,
}, nil
}
// Search performs semantic search on the legal corpus
func (s *Service) Search(ctx context.Context, query string, topK int, collection string, filter string) ([]SearchResult, error) {
// In production, this would:
// 1. Generate embedding for the query using an embedding model (e.g., BGE-M3)
// 2. Search Qdrant for similar vectors
// 3. Return the results
// For now, return mock results that simulate a real RAG response
results := s.getMockSearchResults(query, topK)
return results, nil
}
// GetCorpusStatus returns the status of the legal corpus
func (s *Service) GetCorpusStatus(ctx context.Context) (*CorpusStatus, error) {
// In production, this would query Qdrant for collection info
return &CorpusStatus{
Status: "ready",
Collections: []string{
"legal_corpus",
"dsgvo_articles",
"ai_act_articles",
"nis2_articles",
},
Documents: 1500,
LastUpdated: "2026-02-01T00:00:00Z",
}, nil
}
// IndexDocument indexes a new document into the corpus
func (s *Service) IndexDocument(ctx context.Context, collection string, id string, content string, metadata map[string]string) error {
// In production, this would:
// 1. Generate embedding for the content
// 2. Store in Qdrant with the embedding and metadata
return nil
}
// getMockSearchResults returns mock search results for development
func (s *Service) getMockSearchResults(query string, topK int) []SearchResult {
// Comprehensive mock data for legal searches
allResults := []SearchResult{
// DSGVO Articles
{
ID: "dsgvo-art-5",
Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten\n\n(1) Personenbezogene Daten müssen:\na) auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden („Rechtmäßigkeit, Verarbeitung nach Treu und Glauben, Transparenz");\nb) für festgelegte, eindeutige und legitime Zwecke erhoben werden und dürfen nicht in einer mit diesen Zwecken nicht zu vereinbarenden Weise weiterverarbeitet werden (Zweckbindung");\nc) dem Zweck angemessen und erheblich sowie auf das für die Zwecke der Verarbeitung notwendige Maß beschränkt sein („Datenminimierung");",
Source: "DSGVO",
Score: 0.95,
Metadata: map[string]string{
"article": "5",
"regulation": "DSGVO",
"category": "grundsaetze",
},
},
{
ID: "dsgvo-art-6",
Content: "Art. 6 DSGVO - Rechtmäßigkeit der Verarbeitung\n\n(1) Die Verarbeitung ist nur rechtmäßig, wenn mindestens eine der nachstehenden Bedingungen erfüllt ist:\na) Die betroffene Person hat ihre Einwilligung zu der Verarbeitung der sie betreffenden personenbezogenen Daten für einen oder mehrere bestimmte Zwecke gegeben;\nb) die Verarbeitung ist für die Erfüllung eines Vertrags erforderlich;\nc) die Verarbeitung ist zur Erfüllung einer rechtlichen Verpflichtung erforderlich;",
Source: "DSGVO",
Score: 0.92,
Metadata: map[string]string{
"article": "6",
"regulation": "DSGVO",
"category": "rechtsgrundlage",
},
},
{
ID: "dsgvo-art-30",
Content: "Art. 30 DSGVO - Verzeichnis von Verarbeitungstätigkeiten\n\n(1) Jeder Verantwortliche und gegebenenfalls sein Vertreter führen ein Verzeichnis aller Verarbeitungstätigkeiten, die ihrer Zuständigkeit unterliegen. Dieses Verzeichnis enthält sämtliche folgenden Angaben:\na) den Namen und die Kontaktdaten des Verantwortlichen;\nb) die Zwecke der Verarbeitung;\nc) eine Beschreibung der Kategorien betroffener Personen und der Kategorien personenbezogener Daten;",
Source: "DSGVO",
Score: 0.89,
Metadata: map[string]string{
"article": "30",
"regulation": "DSGVO",
"category": "dokumentation",
},
},
{
ID: "dsgvo-art-32",
Content: "Art. 32 DSGVO - Sicherheit der Verarbeitung\n\n(1) Unter Berücksichtigung des Stands der Technik, der Implementierungskosten und der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung sowie der unterschiedlichen Eintrittswahrscheinlichkeit und Schwere des Risikos für die Rechte und Freiheiten natürlicher Personen treffen der Verantwortliche und der Auftragsverarbeiter geeignete technische und organisatorische Maßnahmen, um ein dem Risiko angemessenes Schutzniveau zu gewährleisten.",
Source: "DSGVO",
Score: 0.88,
Metadata: map[string]string{
"article": "32",
"regulation": "DSGVO",
"category": "sicherheit",
},
},
{
ID: "dsgvo-art-35",
Content: "Art. 35 DSGVO - Datenschutz-Folgenabschätzung\n\n(1) Hat eine Form der Verarbeitung, insbesondere bei Verwendung neuer Technologien, aufgrund der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge, so führt der Verantwortliche vorab eine Abschätzung der Folgen der vorgesehenen Verarbeitungsvorgänge für den Schutz personenbezogener Daten durch.",
Source: "DSGVO",
Score: 0.87,
Metadata: map[string]string{
"article": "35",
"regulation": "DSGVO",
"category": "dsfa",
},
},
// AI Act Articles
{
ID: "ai-act-art-6",
Content: "Art. 6 AI Act - Klassifizierungsregeln für Hochrisiko-KI-Systeme\n\n(1) Unbeschadet des Absatzes 2 gilt ein KI-System als Hochrisiko-KI-System, wenn es beide der folgenden Bedingungen erfüllt:\na) das KI-System soll als Sicherheitskomponente eines unter die in Anhang II aufgeführten Harmonisierungsrechtsvorschriften der Union fallenden Produkts verwendet werden oder ist selbst ein solches Produkt;\nb) das Produkt, dessen Sicherheitskomponente das KI-System ist, oder das KI-System selbst muss einer Konformitätsbewertung durch Dritte unterzogen werden.",
Source: "AI Act",
Score: 0.91,
Metadata: map[string]string{
"article": "6",
"regulation": "AI_ACT",
"category": "klassifizierung",
},
},
{
ID: "ai-act-art-9",
Content: "Art. 9 AI Act - Risikomanagement\n\n(1) Für Hochrisiko-KI-Systeme wird ein Risikomanagementsystem eingerichtet, umgesetzt, dokumentiert und aufrechterhalten. Das Risikomanagementsystem ist ein kontinuierlicher iterativer Prozess, der während des gesamten Lebenszyklus eines Hochrisiko-KI-Systems geplant und durchgeführt wird und einer regelmäßigen systematischen Aktualisierung bedarf.",
Source: "AI Act",
Score: 0.85,
Metadata: map[string]string{
"article": "9",
"regulation": "AI_ACT",
"category": "risikomanagement",
},
},
{
ID: "ai-act-art-52",
Content: "Art. 52 AI Act - Transparenzpflichten für bestimmte KI-Systeme\n\n(1) Die Anbieter stellen sicher, dass KI-Systeme, die für die Interaktion mit natürlichen Personen bestimmt sind, so konzipiert und entwickelt werden, dass die betreffenden natürlichen Personen darüber informiert werden, dass sie mit einem KI-System interagieren, es sei denn, dies ist aus den Umständen und dem Nutzungskontext offensichtlich.",
Source: "AI Act",
Score: 0.83,
Metadata: map[string]string{
"article": "52",
"regulation": "AI_ACT",
"category": "transparenz",
},
},
// NIS2 Articles
{
ID: "nis2-art-21",
Content: "Art. 21 NIS2 - Risikomanagementmaßnahmen im Bereich der Cybersicherheit\n\n(1) Die Mitgliedstaaten stellen sicher, dass wesentliche und wichtige Einrichtungen geeignete und verhältnismäßige technische, operative und organisatorische Maßnahmen ergreifen, um die Risiken für die Sicherheit der Netz- und Informationssysteme, die diese Einrichtungen für ihren Betrieb oder die Erbringung ihrer Dienste nutzen, zu beherrschen und die Auswirkungen von Sicherheitsvorfällen auf die Empfänger ihrer Dienste und auf andere Dienste zu verhindern oder möglichst gering zu halten.",
Source: "NIS2",
Score: 0.86,
Metadata: map[string]string{
"article": "21",
"regulation": "NIS2",
"category": "risikomanagement",
},
},
{
ID: "nis2-art-23",
Content: "Art. 23 NIS2 - Meldepflichten\n\n(1) Jeder Mitgliedstaat stellt sicher, dass wesentliche und wichtige Einrichtungen jeden Sicherheitsvorfall, der erhebliche Auswirkungen auf die Erbringung ihrer Dienste hat, unverzüglich dem zuständigen CSIRT oder gegebenenfalls der zuständigen Behörde melden.",
Source: "NIS2",
Score: 0.81,
Metadata: map[string]string{
"article": "23",
"regulation": "NIS2",
"category": "meldepflicht",
},
},
}
// Return top K results
if topK > len(allResults) {
topK = len(allResults)
}
return allResults[:topK]
}

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>
)
}

View File

@@ -12,6 +12,7 @@
import { useState, useEffect, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
interface LLMResponse {
provider: string
@@ -210,21 +211,24 @@ export default function LLMComparePage() {
{/* 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."
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: 'RAG Management', href: '/ai/rag', description: 'Training Data verwalten' },
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU-Ressourcen' },
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Handschrift-Training' },
{ 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">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,987 @@
'use client'
/**
* OCR Labeling Admin Page
*
* Labeling interface for handwriting training data collection.
* DSGVO-konform: Alle Verarbeitung lokal auf Mac Mini (Ollama).
*
* Teil der KI-Daten-Pipeline:
* OCR-Labeling → RAG Pipeline → Daten & RAG
*/
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 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 [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">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">OCR-Labeling</h1>
<p className="text-gray-600 dark:text-gray-400">Handschrift-Training & Ground Truth Erfassung</p>
</div>
{/* Page Purpose with Related Pages */}
<PagePurpose
title="OCR-Labeling"
purpose="Erstellen Sie Ground Truth Daten für das Training von Handschrift-Erkennungsmodellen. Labeln Sie OCR-Ergebnisse, korrigieren Sie Fehler und exportieren Sie Trainingsdaten für TrOCR, Llama Vision und andere Modelle. Teil der KI-Daten-Pipeline: Gelabelte Daten können zur RAG Pipeline exportiert werden."
audience={['Entwickler', 'Data Scientists', 'QA-Team']}
architecture={{
services: ['klausur-service (Python)'],
databases: ['PostgreSQL', 'MinIO (Bilder)'],
}}
relatedPages={[
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR testen & fine-tunen' },
{ name: 'RAG Pipeline', href: '/ai/rag-pipeline', description: 'Trainierte Daten indexieren' },
{ name: 'Klausur-Korrektur', href: '/ai/klausur-korrektur', description: 'OCR in Aktion' },
{ name: 'Daten & RAG', href: '/ai/rag', description: 'Indexierte Daten durchsuchen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */}
<AIModuleSidebarResponsive currentModule="ocr-labeling" />
{/* Error Toast */}
{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>{error}</span>
<button onClick={() => setError(null)} className="ml-4">X</button>
</div>
)}
{/* Tabs */}
<div className="mb-6">
<div className="border-b border-slate-200">
<nav className="flex space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
{tab.icon}
{tab.name}
</button>
))}
</nav>
</div>
</div>
{/* Tab Content */}
{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' && renderLabelingTab()}
{activeTab === 'sessions' && renderSessionsTab()}
{activeTab === 'upload' && renderUploadTab()}
{activeTab === 'stats' && renderStatsTab()}
{activeTab === 'export' && renderExportTab()}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,123 @@
/**
* TypeScript types for OCR Labeling UI
*/
/**
* Available OCR Models
*
* - llama3.2-vision:11b: Vision LLM, beste Qualitaet bei Handschrift (Standard)
* - trocr: Microsoft TrOCR, schnell bei gedrucktem Text
* - paddleocr: PaddleOCR + LLM, 4x schneller durch Hybrid-Ansatz
* - donut: Document Understanding Transformer, strukturierte Dokumente
*/
export type OCRModel = 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut'
export const OCR_MODEL_INFO: Record<OCRModel, { label: string; description: string; speed: string }> = {
'llama3.2-vision:11b': {
label: 'Vision LLM',
description: 'Beste Qualitaet bei Handschrift',
speed: 'langsam',
},
trocr: {
label: 'Microsoft TrOCR',
description: 'Schnell bei gedrucktem Text',
speed: 'schnell',
},
paddleocr: {
label: 'PaddleOCR + LLM',
description: 'Hybrid-Ansatz: OCR + Strukturierung',
speed: 'sehr schnell',
},
donut: {
label: 'Donut',
description: 'Document Understanding fuer Tabellen/Formulare',
speed: 'mittel',
},
}
export interface OCRSession {
id: string
name: string
source_type: 'klausur' | 'handwriting_sample' | 'scan'
description?: string
ocr_model?: OCRModel
total_items: number
labeled_items: number
confirmed_items: number
corrected_items: number
skipped_items: number
created_at: string
}
export interface OCRItem {
id: string
session_id: string
session_name: string
image_path: string
image_url?: string
ocr_text?: string
ocr_confidence?: number
ground_truth?: string
status: 'pending' | 'confirmed' | 'corrected' | 'skipped'
metadata?: Record<string, unknown>
created_at: string
}
export interface OCRStats {
total_sessions?: number
session_id?: string
name?: string
total_items: number
labeled_items: number
confirmed_items: number
corrected_items: number
skipped_items?: number
pending_items: number
exportable_items?: number
accuracy_rate: number
avg_label_time_seconds?: number
progress_percent?: number
}
export interface TrainingSample {
id: string
image_path: string
ground_truth: string
export_format: 'generic' | 'trocr' | 'llama_vision'
training_batch: string
exported_at?: string
}
export interface CreateSessionRequest {
name: string
source_type: 'klausur' | 'handwriting_sample' | 'scan'
description?: string
ocr_model?: OCRModel
}
export interface ConfirmRequest {
item_id: string
label_time_seconds?: number
}
export interface CorrectRequest {
item_id: string
ground_truth: string
label_time_seconds?: number
}
export interface ExportRequest {
export_format: 'generic' | 'trocr' | 'llama_vision'
session_id?: string
batch_id?: string
}
export interface UploadResult {
id: string
filename: string
image_path: string
image_hash: string
ocr_text?: string
ocr_confidence?: number
status: string
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
// API uses local proxy route to klausur-service
const API_PROXY = '/api/legal-corpus'
@@ -1021,20 +1022,25 @@ export default function RAGPage() {
<div className="p-6">
{/* Page Purpose */}
<PagePurpose
title="Legal Corpus RAG"
purpose="Das Legal Corpus RAG System indexiert alle 19 relevanten Regulierungen (DSGVO, AI Act, CRA, BSI TR-03161, etc.) fuer semantische Suche waehrend UCCA-Assessments. Die Dokumente werden in Chunks aufgeteilt und mit BGE-M3 Embeddings indexiert."
title="Daten & RAG"
purpose="Verwalten und durchsuchen Sie indexierte Dokumente im RAG-System. Das Legal Corpus enthält 19+ Regulierungen (DSGVO, AI Act, CRA, BSI TR-03161, etc.) für semantische Suche. Teil der KI-Daten-Pipeline: Empfängt Embeddings von der RAG Pipeline und liefert Suchergebnisse an die Klausur-Korrektur."
audience={['DSB', 'Compliance Officer', 'Entwickler']}
gdprArticles={['§5 UrhG (Amtliche Werke)', 'Art. 5 DSGVO (Rechenschaftspflicht)']}
architecture={{
services: ['klausur-service (Python)', 'embedding-service (BGE-M3)', 'Qdrant (Vector DB)'],
databases: ['Qdrant Collection: bp_legal_corpus'],
databases: ['Qdrant Collections: bp_legal_corpus, bp_nibis_eh, bp_eh'],
}}
relatedPages={[
{ name: 'RAG Pipeline', href: '/ai/rag-pipeline', description: 'Neue Dokumente indexieren' },
{ name: 'Klausur-Korrektur', href: '/ai/klausur-korrektur', description: 'RAG-Suche nutzen' },
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Ground Truth erstellen' },
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Compliance-Dashboard' },
{ name: 'Requirements', href: '/compliance/requirements', description: 'Anforderungskatalog' },
]}
/>
{/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */}
<AIModuleSidebarResponsive currentModule="rag" />
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">

View File

@@ -14,6 +14,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
import type { TestRun, BQASMetrics, TrendData, TabType } from './types'
// API Configuration - Use internal proxy to avoid CORS issues
@@ -1429,14 +1430,17 @@ export default function TestQualityPage() {
databases: ['Qdrant', 'PostgreSQL'],
}}
relatedPages={[
{ name: 'CI/CD Scheduler', href: '/infrastructure/ci-cd', description: 'Automatische Test-Planung' },
{ name: 'RAG Management', href: '/ai/rag', description: 'Training Data & RAG Pipelines' },
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'Provider-Vergleich' },
{ name: 'GPU Infrastruktur', href: '/ai/gpu', description: 'GPU-Ressourcen verwalten' },
{ name: 'RAG Management', href: '/ai/rag', description: 'Training Data & RAG Pipelines' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* KI-Werkzeuge Sidebar */}
<AIToolsSidebarResponsive currentTool="test-quality" />
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-3">
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -99,47 +99,51 @@ export default function ArchitecturePage() {
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Migrations-Checkliste</h3>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
<span className="text-slate-700">Grundgeruest Admin v2 erstellt (Layout, Navigation)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
<span className="text-slate-700">Compliance Hub migriert</span>
<span className="text-slate-700">Compliance Hub migriert (DSR, DSMS, VVT, TOM, DSFA, Controls, Evidence, Risks)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
<span className="text-slate-700">Consent Verwaltung migriert</span>
<span className="text-slate-700">Consent Verwaltung migriert (inkl. Einwilligungen)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
<span className="text-slate-700">Workflow (Versionierung) migriert mit Sync-Scroll</span>
</div>
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
<input type="checkbox" className="w-4 h-4" />
<span className="text-slate-700">DSR-Modul migrieren</span>
<span className="text-xs text-yellow-600 ml-auto">Prioritaet: Hoch</span>
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
<span className="text-slate-700">KI-Module migriert (LLM Compare, RAG, AI Quality, Agents)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
<span className="text-slate-700">Infrastruktur-Module migriert (GPU, Security, SBOM, CI/CD, Middleware)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
<span className="text-slate-700">Communication-Module migriert (Mail, Alerts, Matrix, Video-Chat)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
<span className="text-slate-700">Development-Module migriert (Brandbook, Content, Docs, Game, Unity)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
<input type="checkbox" className="w-4 h-4" />
<span className="text-slate-700">Cookie-Kategorien migrieren</span>
<span className="text-slate-700">Klausur-Korrektur migrieren</span>
<span className="text-xs text-yellow-600 ml-auto">Bleibt vorerst im alten Admin</span>
</div>
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
<input type="checkbox" className="w-4 h-4" />
<span className="text-slate-700">OCR-Labeling migrieren</span>
<span className="text-xs text-yellow-600 ml-auto">Prioritaet: Mittel</span>
</div>
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<input type="checkbox" className="w-4 h-4" />
<span className="text-slate-700">KI-Module migrieren (LLM Compare, OCR, RAG)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<input type="checkbox" className="w-4 h-4" />
<span className="text-slate-700">Infrastruktur-Module migrieren</span>
</div>
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<input type="checkbox" className="w-4 h-4" />
<span className="text-slate-700">Alle Module getestet und deployed</span>
</div>
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<input type="checkbox" className="w-4 h-4" />
<span className="text-slate-700">Verwaiste Module identifiziert und dokumentiert</span>
<span className="text-slate-700">Verwaiste Module identifiziert (voice, training, multiplayer, pca-platform)</span>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { getStoredRole, isCategoryVisibleForRole, RoleId } from '@/lib/roles'
import { CategoryCard } from '@/components/common/ModuleCard'
import { InfoNote } from '@/components/common/InfoBox'
import { ServiceStatus } from '@/components/common/ServiceStatus'
import { NightModeWidget } from '@/components/dashboard/NightModeWidget'
import Link from 'next/link'
interface Stats {
@@ -111,7 +112,18 @@ export default function DashboardPage() {
))}
</div>
{/* Infrastructure & System Status */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Infrastruktur</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Night Mode Widget */}
<NightModeWidget />
{/* System Status */}
<ServiceStatus />
</div>
{/* Recent Activity */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Aktivitaet</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent DSR */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
@@ -127,9 +139,6 @@ export default function DashboardPage() {
</p>
</div>
</div>
{/* System Status */}
<ServiceStatus />
</div>
{/* Info Box */}

View File

@@ -0,0 +1,769 @@
'use client'
/**
* Admin Panel for Website Content
*
* Allows editing all website texts:
* - Hero Section
* - Features
* - FAQ
* - Pricing
* - Trust Indicators
* - Testimonial
*
* Includes Live-Preview of website
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { WebsiteContent, HeroContent, FeatureContent } from '@/lib/content-types'
// Admin Key (in production via login)
const ADMIN_KEY = 'breakpilot-admin-2024'
// Mapping tabs to website sections
const SECTION_MAP: Record<string, { selector: string; scrollTo: string }> = {
hero: { selector: '#hero', scrollTo: 'hero' },
features: { selector: '#features', scrollTo: 'features' },
faq: { selector: '#faq', scrollTo: 'faq' },
pricing: { selector: '#pricing', scrollTo: 'pricing' },
other: { selector: '#trust', scrollTo: 'trust' },
}
export default function ContentPage() {
const [content, setContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [activeTab, setActiveTab] = useState<'hero' | 'features' | 'faq' | 'pricing' | 'other'>('hero')
const [showPreview, setShowPreview] = useState(true)
const iframeRef = useRef<HTMLIFrameElement>(null)
// Scroll preview to section
const scrollToSection = useCallback((tab: string) => {
if (!iframeRef.current?.contentWindow) return
const section = SECTION_MAP[tab]
if (section) {
try {
iframeRef.current.contentWindow.postMessage(
{ type: 'scrollTo', section: section.scrollTo },
'*'
)
} catch {
// Same-origin policy - fallback
}
}
}, [])
// Scroll to section on tab change
useEffect(() => {
scrollToSection(activeTab)
}, [activeTab, scrollToSection])
// Load content
useEffect(() => {
loadContent()
}, [])
async function loadContent() {
try {
const res = await fetch('/api/development/content')
if (res.ok) {
const data = await res.json()
setContent(data)
} else {
setMessage({ type: 'error', text: 'Fehler beim Laden' })
}
} catch {
setMessage({ type: 'error', text: 'Fehler beim Laden' })
} finally {
setLoading(false)
}
}
async function saveChanges() {
if (!content) return
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/development/content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-key': ADMIN_KEY,
},
body: JSON.stringify(content),
})
if (res.ok) {
setMessage({ type: 'success', text: 'Gespeichert!' })
} else {
const error = await res.json()
setMessage({ type: 'error', text: error.error || 'Fehler beim Speichern' })
}
} catch {
setMessage({ type: 'error', text: 'Fehler beim Speichern' })
} finally {
setSaving(false)
}
}
// Hero Section update
function updateHero(field: keyof HeroContent, value: string) {
if (!content) return
setContent({
...content,
hero: { ...content.hero, [field]: value },
})
}
// Feature update
function updateFeature(index: number, field: keyof FeatureContent, value: string) {
if (!content) return
const newFeatures = [...content.features]
newFeatures[index] = { ...newFeatures[index], [field]: value }
setContent({ ...content, features: newFeatures })
}
// FAQ update
function updateFAQ(index: number, field: 'question' | 'answer', value: string | string[]) {
if (!content) return
const newFAQ = [...content.faq]
if (field === 'answer' && typeof value === 'string') {
newFAQ[index] = { ...newFAQ[index], answer: value.split('\n') }
} else if (field === 'question' && typeof value === 'string') {
newFAQ[index] = { ...newFAQ[index], question: value }
}
setContent({ ...content, faq: newFAQ })
}
// Add FAQ
function addFAQ() {
if (!content) return
setContent({
...content,
faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }],
})
}
// Remove FAQ
function removeFAQ(index: number) {
if (!content) return
const newFAQ = content.faq.filter((_, i) => i !== index)
setContent({ ...content, faq: newFAQ })
}
// Pricing update
function updatePricing(index: number, field: string, value: string | number | boolean) {
if (!content) return
const newPricing = [...content.pricing]
if (field === 'price') {
newPricing[index] = { ...newPricing[index], price: Number(value) }
} else if (field === 'popular') {
newPricing[index] = { ...newPricing[index], popular: Boolean(value) }
} else if (field.startsWith('features.')) {
const subField = field.replace('features.', '')
if (subField === 'included' && typeof value === 'string') {
newPricing[index] = {
...newPricing[index],
features: {
...newPricing[index].features,
included: value.split('\n'),
},
}
} else {
newPricing[index] = {
...newPricing[index],
features: {
...newPricing[index].features,
[subField]: value,
},
}
}
} else {
newPricing[index] = { ...newPricing[index], [field]: value }
}
setContent({ ...content, pricing: newPricing })
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-xl text-slate-600">Laden...</div>
</div>
)
}
if (!content) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-xl text-red-600">Fehler beim Laden</div>
</div>
)
}
return (
<div>
{/* Toolbar */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-slate-900">Website Content</h1>
{/* Preview Toggle */}
<button
onClick={() => setShowPreview(!showPreview)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
showPreview
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
title={showPreview ? 'Preview ausblenden' : 'Preview einblenden'}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Live-Preview
</button>
</div>
<div className="flex items-center gap-4">
{message && (
<span
className={`px-3 py-1 rounded text-sm ${
message.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{message.text}
</span>
)}
<button
onClick={saveChanges}
disabled={saving}
className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{(['hero', 'features', 'faq', 'pricing', 'other'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab === 'hero' && 'Hero'}
{tab === 'features' && 'Features'}
{tab === 'faq' && 'FAQ'}
{tab === 'pricing' && 'Preise'}
{tab === 'other' && 'Sonstige'}
</button>
))}
</div>
</div>
{/* Split Layout: Editor + Preview */}
<div className={`grid gap-6 ${showPreview ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Editor Panel */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 max-h-[calc(100vh-280px)] overflow-y-auto">
{/* Hero Tab */}
{activeTab === 'hero' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Hero Section</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Badge</label>
<input
type="text"
value={content.hero.badge}
onChange={(e) => updateHero('badge', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Titel (vor Highlight)
</label>
<input
type="text"
value={content.hero.title}
onChange={(e) => updateHero('title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 1
</label>
<input
type="text"
value={content.hero.titleHighlight1}
onChange={(e) => updateHero('titleHighlight1', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 2
</label>
<input
type="text"
value={content.hero.titleHighlight2}
onChange={(e) => updateHero('titleHighlight2', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Untertitel</label>
<textarea
value={content.hero.subtitle}
onChange={(e) => updateHero('subtitle', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Primaer
</label>
<input
type="text"
value={content.hero.ctaPrimary}
onChange={(e) => updateHero('ctaPrimary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Sekundaer
</label>
<input
type="text"
value={content.hero.ctaSecondary}
onChange={(e) => updateHero('ctaSecondary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Hinweis</label>
<input
type="text"
value={content.hero.ctaHint}
onChange={(e) => updateHero('ctaHint', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)}
{/* Features Tab */}
{activeTab === 'features' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Features</h2>
{content.features.map((feature, index) => (
<div key={feature.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Icon</label>
<input
type="text"
value={feature.icon}
onChange={(e) => updateFeature(index, 'icon', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-2xl text-center"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
<input
type="text"
value={feature.title}
onChange={(e) => updateFeature(index, 'title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={feature.description}
onChange={(e) => updateFeature(index, 'description', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* FAQ Tab */}
{activeTab === 'faq' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">FAQ</h2>
<button
onClick={addFAQ}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
+ Frage hinzufuegen
</button>
</div>
{content.faq.map((item, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Frage {index + 1}
</label>
<input
type="text"
value={item.question}
onChange={(e) => updateFAQ(index, 'question', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Antwort
</label>
<textarea
value={item.answer.join('\n')}
onChange={(e) => updateFAQ(index, 'answer', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
<button
onClick={() => removeFAQ(index)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Frage entfernen"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Pricing Tab */}
{activeTab === 'pricing' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Preise</h2>
{content.pricing.map((plan, index) => (
<div key={plan.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={plan.name}
onChange={(e) => updatePricing(index, 'name', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Preis (EUR)
</label>
<input
type="number"
step="0.01"
value={plan.price}
onChange={(e) => updatePricing(index, 'price', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Intervall
</label>
<input
type="text"
value={plan.interval}
onChange={(e) => updatePricing(index, 'interval', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={plan.popular || false}
onChange={(e) => updatePricing(index, 'popular', e.target.checked)}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-slate-700">Beliebt</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<input
type="text"
value={plan.description}
onChange={(e) => updatePricing(index, 'description', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben
</label>
<input
type="text"
value={plan.features.tasks}
onChange={(e) => updatePricing(index, 'features.tasks', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben-Beschreibung
</label>
<input
type="text"
value={plan.features.taskDescription}
onChange={(e) =>
updatePricing(index, 'features.taskDescription', e.target.value)
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Features (eine pro Zeile)
</label>
<textarea
value={plan.features.included.join('\n')}
onChange={(e) => updatePricing(index, 'features.included', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Other Tab */}
{activeTab === 'other' && (
<div className="space-y-8">
{/* Trust Indicators */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Trust Indicators</h2>
<div className="grid grid-cols-3 gap-4">
{(['item1', 'item2', 'item3'] as const).map((key, index) => (
<div key={key} className="border border-slate-200 rounded-lg p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Wert {index + 1}
</label>
<input
type="text"
value={content.trust[key].value}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], value: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Label {index + 1}
</label>
<input
type="text"
value={content.trust[key].label}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], label: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Testimonial */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Testimonial</h2>
<div className="border border-slate-200 rounded-lg p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitat</label>
<textarea
value={content.testimonial.quote}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, quote: e.target.value },
})
}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor</label>
<input
type="text"
value={content.testimonial.author}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, author: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rolle</label>
<input
type="text"
value={content.testimonial.role}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, role: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Live Preview Panel */}
{showPreview && (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<div className="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<span className="text-xs text-slate-500 ml-2">breakpilot.app</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-600 bg-slate-200 px-2 py-1 rounded">
{activeTab === 'hero' && 'Hero Section'}
{activeTab === 'features' && 'Features'}
{activeTab === 'faq' && 'FAQ'}
{activeTab === 'pricing' && 'Pricing'}
{activeTab === 'other' && 'Trust & Testimonial'}
</span>
<button
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
className="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
</button>
</div>
</div>
{/* Preview Frame */}
<div className="relative h-[calc(100vh-340px)] bg-slate-100">
<iframe
ref={iframeRef}
src={`https://macmini:3000/?preview=true&section=${activeTab}#${activeTab}`}
className="w-full h-full border-0 scale-75 origin-top-left"
style={{
width: '133.33%',
height: '133.33%',
transform: 'scale(0.75)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
{/* Section Indicator */}
<div className="absolute bottom-4 left-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<span>
Du bearbeitest: <strong>
{activeTab === 'hero' && 'Hero Section (Startbereich)'}
{activeTab === 'features' && 'Features (Funktionen)'}
{activeTab === 'faq' && 'FAQ (Haeufige Fragen)'}
{activeTab === 'pricing' && 'Pricing (Preise)'}
{activeTab === 'other' && 'Trust & Testimonial'}
</strong>
</span>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,797 @@
'use client'
/**
* Screen Flow Visualization
*
* Visualisiert alle Screens aus:
* - Studio (Port 8000): Lehrer-Oberflaeche
* - Admin v2 (Port 3002): Admin Panel
*/
import { useCallback, useState, useMemo, useEffect } from 'react'
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
BackgroundVariant,
MarkerType,
Panel,
} from 'reactflow'
import 'reactflow/dist/style.css'
// ============================================
// TYPES
// ============================================
interface ScreenDefinition {
id: string
name: string
description: string
category: string
icon: string
url?: string
}
interface ConnectionDef {
source: string
target: string
label?: string
}
type FlowType = 'studio' | 'admin'
// ============================================
// STUDIO SCREENS (Port 8000)
// ============================================
const STUDIO_SCREENS: ScreenDefinition[] = [
{ id: 'lehrer-dashboard', name: 'Mein Dashboard', description: 'Hauptuebersicht mit Widgets', category: 'navigation', icon: '🏠', url: '/app#lehrer-dashboard' },
{ id: 'lehrer-onboarding', name: 'Erste Schritte', description: 'Onboarding & Schnellstart', category: 'navigation', icon: '🚀', url: '/app#lehrer-onboarding' },
{ id: 'hilfe', name: 'Dokumentation', description: 'Hilfe & Anleitungen', category: 'navigation', icon: '📚', url: '/app#hilfe' },
{ id: 'worksheets', name: 'Arbeitsblaetter Studio', description: 'Lernmaterialien erstellen', category: 'content', icon: '📝', url: '/app#worksheets' },
{ id: 'content-creator', name: 'Content Creator', description: 'Inhalte erstellen', category: 'content', icon: '✨', url: '/app#content-creator' },
{ id: 'content-feed', name: 'Content Feed', description: 'Inhalte durchsuchen', category: 'content', icon: '📰', url: '/app#content-feed' },
{ id: 'unit-creator', name: 'Unit Creator', description: 'Lerneinheiten erstellen', category: 'content', icon: '📦', url: '/app#unit-creator' },
{ id: 'letters', name: 'Briefe & Vorlagen', description: 'Brief-Generator', category: 'content', icon: '✉️', url: '/app#letters' },
{ id: 'correction', name: 'Korrektur', description: 'Arbeiten korrigieren', category: 'content', icon: '✏️', url: '/app#correction' },
{ id: 'klausur-korrektur', name: 'Abiturklausuren', description: 'KI-gestuetzte Klausurkorrektur', category: 'content', icon: '📋', url: '/app#klausur-korrektur' },
{ id: 'jitsi', name: 'Videokonferenz', description: 'Jitsi Meet Integration', category: 'communication', icon: '🎥', url: '/app#jitsi' },
{ id: 'messenger', name: 'Messenger', description: 'Matrix E2EE Chat', category: 'communication', icon: '💬', url: '/app#messenger' },
{ id: 'mail', name: 'Unified Inbox', description: 'E-Mail Verwaltung', category: 'communication', icon: '📧', url: '/app#mail' },
{ id: 'school-classes', name: 'Klassen', description: 'Klassenverwaltung', category: 'school', icon: '👥', url: '/app#school-classes' },
{ id: 'school-exams', name: 'Pruefungen', description: 'Pruefungsverwaltung', category: 'school', icon: '📝', url: '/app#school-exams' },
{ id: 'school-grades', name: 'Noten', description: 'Notenverwaltung', category: 'school', icon: '📊', url: '/app#school-grades' },
{ id: 'school-gradebook', name: 'Notenbuch', description: 'Digitales Notenbuch', category: 'school', icon: '📖', url: '/app#school-gradebook' },
{ id: 'school-certificates', name: 'Zeugnisse', description: 'Zeugniserstellung', category: 'school', icon: '🎓', url: '/app#school-certificates' },
{ id: 'companion', name: 'Begleiter & Stunde', description: 'KI-Unterrichtsassistent', category: 'ai', icon: '🤖', url: '/app#companion' },
{ id: 'alerts', name: 'Alerts', description: 'News & Benachrichtigungen', category: 'ai', icon: '🔔', url: '/app#alerts' },
{ id: 'admin', name: 'Einstellungen', description: 'Systemeinstellungen', category: 'admin', icon: '⚙️', url: '/app#admin' },
{ id: 'rbac-admin', name: 'Rollen & Rechte', description: 'Berechtigungsverwaltung', category: 'admin', icon: '🔐', url: '/app#rbac-admin' },
{ id: 'abitur-docs-admin', name: 'Abitur Dokumente', description: 'Erwartungshorizonte', category: 'admin', icon: '📄', url: '/app#abitur-docs-admin' },
{ id: 'system-info', name: 'System Info', description: 'Systeminformationen', category: 'admin', icon: '💻', url: '/app#system-info' },
{ id: 'workflow', name: 'Workflow', description: 'Automatisierungen', category: 'admin', icon: '⚡', url: '/app#workflow' },
]
const STUDIO_CONNECTIONS: ConnectionDef[] = [
{ source: 'lehrer-onboarding', target: 'worksheets', label: 'Arbeitsblaetter' },
{ source: 'lehrer-onboarding', target: 'klausur-korrektur', label: 'Abiturklausuren' },
{ source: 'lehrer-onboarding', target: 'correction', label: 'Korrektur' },
{ source: 'lehrer-onboarding', target: 'letters', label: 'Briefe' },
{ source: 'lehrer-onboarding', target: 'school-classes', label: 'Klassen' },
{ source: 'lehrer-onboarding', target: 'jitsi', label: 'Meet' },
{ source: 'lehrer-onboarding', target: 'hilfe', label: 'Doku' },
{ source: 'lehrer-onboarding', target: 'admin', label: 'Settings' },
{ source: 'lehrer-dashboard', target: 'worksheets' },
{ source: 'lehrer-dashboard', target: 'correction' },
{ source: 'lehrer-dashboard', target: 'jitsi' },
{ source: 'lehrer-dashboard', target: 'letters' },
{ source: 'lehrer-dashboard', target: 'messenger' },
{ source: 'lehrer-dashboard', target: 'klausur-korrektur' },
{ source: 'lehrer-dashboard', target: 'companion' },
{ source: 'lehrer-dashboard', target: 'alerts' },
{ source: 'lehrer-dashboard', target: 'mail' },
{ source: 'lehrer-dashboard', target: 'school-classes' },
{ source: 'lehrer-dashboard', target: 'lehrer-onboarding', label: 'Sidebar' },
{ source: 'school-classes', target: 'school-exams' },
{ source: 'school-classes', target: 'school-grades' },
{ source: 'school-grades', target: 'school-gradebook' },
{ source: 'school-gradebook', target: 'school-certificates' },
{ source: 'worksheets', target: 'content-creator' },
{ source: 'worksheets', target: 'unit-creator' },
{ source: 'content-creator', target: 'content-feed' },
{ source: 'klausur-korrektur', target: 'abitur-docs-admin' },
{ source: 'admin', target: 'rbac-admin' },
{ source: 'admin', target: 'system-info' },
{ source: 'admin', target: 'workflow' },
]
// ============================================
// ADMIN v2 SCREENS (Port 3002)
// Based on navigation.ts - Last updated: 2026-02-03
// ============================================
const ADMIN_SCREENS: ScreenDefinition[] = [
// === META / OVERVIEW ===
{ id: 'admin-dashboard', name: 'Dashboard', description: 'Uebersicht & Statistiken', category: 'overview', icon: '🏠', url: '/dashboard' },
{ id: 'admin-onboarding', name: 'Onboarding', description: 'Lern-Wizards fuer alle Module', category: 'overview', icon: '📖', url: '/onboarding' },
{ id: 'admin-architecture', name: 'Architektur', description: 'Backend-Module & Datenfluss', category: 'overview', icon: '🏗️', url: '/architecture' },
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'overview', icon: '📝', url: '/backlog' },
{ id: 'admin-rbac', name: 'RBAC', description: 'Rollen & Berechtigungen', category: 'overview', icon: '👥', url: '/rbac' },
// === DSGVO (Violet #7c3aed) ===
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'dsgvo', icon: '📄', url: '/dsgvo/consent' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'dsgvo', icon: '🔒', url: '/dsgvo/dsr' },
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'dsgvo', icon: '✅', url: '/dsgvo/einwilligungen' },
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'dsgvo', icon: '📋', url: '/dsgvo/vvt' },
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'dsgvo', icon: '⚖️', url: '/dsgvo/dsfa' },
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'dsgvo', icon: '🛡️', url: '/dsgvo/tom' },
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'dsgvo', icon: '🗑️', url: '/dsgvo/loeschfristen' },
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'dsgvo', icon: '🧑‍⚖️', url: '/dsgvo/advisory-board' },
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'dsgvo', icon: '🚨', url: '/dsgvo/escalations' },
// === COMPLIANCE (Purple #9333ea) ===
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'compliance', icon: '✅', url: '/compliance/hub' },
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'compliance', icon: '📋', url: '/compliance/audit-checklist' },
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'compliance', icon: '📜', url: '/compliance/requirements' },
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'compliance', icon: '🎛️', url: '/compliance/controls' },
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'compliance', icon: '📎', url: '/compliance/evidence' },
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'compliance', icon: '⚠️', url: '/compliance/risks' },
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'compliance', icon: '📊', url: '/compliance/audit-report' },
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'compliance', icon: '🔧', url: '/compliance/modules' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'compliance', icon: '🏛️', url: '/compliance/dsms' },
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'compliance', icon: '🔄', url: '/compliance/workflow' },
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'compliance', icon: '📚', url: '/compliance/source-policy' },
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'compliance', icon: '🤖', url: '/compliance/ai-act' },
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'compliance', icon: '⚡', url: '/compliance/obligations' },
// === KI & AUTOMATISIERUNG (Teal #14b8a6) ===
{ id: 'admin-llm-compare', name: 'LLM Vergleich', description: 'KI-Provider Vergleich', category: 'ai', icon: '🤖', url: '/ai/llm-compare' },
{ id: 'admin-rag', name: 'Daten & RAG', description: 'Training Data & RAG', category: 'ai', icon: '🗄️', url: '/ai/rag' },
{ id: 'admin-ocr-labeling', name: 'OCR-Labeling', description: 'Handschrift-Training', category: 'ai', icon: '✍️', url: '/ai/ocr-labeling' },
{ id: 'admin-magic-help', name: 'Magic Help', description: 'TrOCR Handschrift-OCR', category: 'ai', icon: '🪄', url: '/ai/magic-help' },
{ id: 'admin-klausur-korrektur', name: 'Klausur-Korrektur', description: 'Abitur-Korrektur mit KI', category: 'ai', icon: '📝', url: '/ai/klausur-korrektur' },
{ id: 'admin-quality', name: 'Qualitaet & Audit', description: 'Compliance-Audit & Traceability', category: 'ai', icon: '✨', url: '/ai/quality' },
{ id: 'admin-test-quality', name: 'Test Quality (BQAS)', description: 'Golden Suite & Synthetic Tests', category: 'ai', icon: '🧪', url: '/ai/test-quality' },
{ id: 'admin-agents', name: 'Agent Management', description: 'Multi-Agent & SOUL-Editor', category: 'ai', icon: '🧠', url: '/ai/agents' },
// === INFRASTRUKTUR (Orange #f97316) ===
{ id: 'admin-gpu', name: 'GPU Infrastruktur', description: 'vast.ai GPU Management', category: 'infrastructure', icon: '🖥️', url: '/infrastructure/gpu' },
{ id: 'admin-middleware', name: 'Middleware', description: 'Stack & API Gateway', category: 'infrastructure', icon: '🔧', url: '/infrastructure/middleware' },
{ id: 'admin-security', name: 'Security', description: 'DevSecOps & Scans', category: 'infrastructure', icon: '🔐', url: '/infrastructure/security' },
{ id: 'admin-sbom', name: 'SBOM', description: 'Software Bill of Materials', category: 'infrastructure', icon: '📦', url: '/infrastructure/sbom' },
{ id: 'admin-cicd', name: 'CI/CD', description: 'Pipelines & Deployments', category: 'infrastructure', icon: '🔄', url: '/infrastructure/ci-cd' },
{ id: 'admin-tests', name: 'Test Dashboard', description: '195+ Tests & Coverage', category: 'infrastructure', icon: '🧪', url: '/infrastructure/tests' },
// === BILDUNG (Blue #3b82f6) ===
{ id: 'admin-edu-search', name: 'Education Search', description: 'Bildungsquellen & Crawler', category: 'education', icon: '🔍', url: '/education/edu-search' },
{ id: 'admin-zeugnisse', name: 'Zeugnisse-Crawler', description: 'Zeugnis-Daten', category: 'education', icon: '📜', url: '/education/zeugnisse-crawler' },
{ id: 'admin-rag-pipeline', name: 'RAG Pipeline', description: 'Bildungsdokumente indexieren', category: 'ai', icon: '🔗', url: '/ai/rag-pipeline' },
{ id: 'admin-foerderantrag', name: 'Foerderantrag-Wizard', description: 'DigitalPakt & Landesfoerderung', category: 'education', icon: '💰', url: '/education/foerderantrag' },
// === KOMMUNIKATION (Green #22c55e) ===
{ id: 'admin-video', name: 'Video & Chat', description: 'Matrix & Jitsi Monitoring', category: 'communication', icon: '🎥', url: '/communication/video-chat' },
{ id: 'admin-matrix', name: 'Voice Service', description: 'Voice-First Interface', category: 'communication', icon: '🎙️', url: '/communication/matrix' },
{ id: 'admin-mail', name: 'Unified Inbox', description: 'E-Mail & KI-Analyse', category: 'communication', icon: '📧', url: '/communication/mail' },
{ id: 'admin-alerts', name: 'Alerts Monitoring', description: 'Google Alerts & Feeds', category: 'communication', icon: '🔔', url: '/communication/alerts' },
// === ENTWICKLUNG (Slate #64748b) ===
{ id: 'admin-workflow', name: 'Dev Workflow', description: 'Git, CI/CD & Team-Regeln', category: 'development', icon: '⚡', url: '/development/workflow' },
{ id: 'admin-game', name: 'Breakpilot Drive', description: 'Lernspiel Management', category: 'development', icon: '🎮', url: '/development/game' },
{ id: 'admin-unity', name: 'Unity Bridge', description: 'Unity Editor Steuerung', category: 'development', icon: '🎯', url: '/development/unity-bridge' },
{ id: 'admin-companion', name: 'Companion Dev', description: 'Lesson-Modus Entwicklung', category: 'development', icon: '📚', url: '/development/companion' },
{ id: 'admin-docs', name: 'Developer Docs', description: 'API & Architektur', category: 'development', icon: '📖', url: '/development/docs' },
{ id: 'admin-brandbook', name: 'Brandbook', description: 'Corporate Design', category: 'development', icon: '🎨', url: '/development/brandbook' },
{ id: 'admin-screen-flow', name: 'Screen Flow', description: 'UI Screen-Verbindungen', category: 'development', icon: '🔀', url: '/development/screen-flow' },
{ id: 'admin-content', name: 'Uebersetzungen', description: 'Website Content & Sprachen', category: 'development', icon: '🌐', url: '/development/content' },
]
const ADMIN_CONNECTIONS: ConnectionDef[] = [
// === OVERVIEW/META FLOWS ===
{ source: 'admin-dashboard', target: 'admin-onboarding', label: 'Erste Schritte' },
{ source: 'admin-dashboard', target: 'admin-architecture', label: 'System' },
{ source: 'admin-dashboard', target: 'admin-backlog', label: 'Go-Live' },
{ source: 'admin-dashboard', target: 'admin-compliance-hub', label: 'Compliance' },
{ source: 'admin-onboarding', target: 'admin-consent' },
{ source: 'admin-onboarding', target: 'admin-llm-compare' },
{ source: 'admin-rbac', target: 'admin-consent' },
// === DSGVO FLOW ===
{ source: 'admin-consent', target: 'admin-einwilligungen', label: 'Nutzer' },
{ source: 'admin-consent', target: 'admin-dsr' },
{ source: 'admin-dsr', target: 'admin-loeschfristen' },
{ source: 'admin-vvt', target: 'admin-tom' },
{ source: 'admin-vvt', target: 'admin-dsfa' },
{ source: 'admin-dsfa', target: 'admin-tom' },
{ source: 'admin-advisory-board', target: 'admin-escalations', label: 'Eskalation' },
{ source: 'admin-advisory-board', target: 'admin-dsfa', label: 'Risiko' },
// === COMPLIANCE FLOW ===
{ source: 'admin-compliance-hub', target: 'admin-audit-checklist', label: 'Audit' },
{ source: 'admin-compliance-hub', target: 'admin-requirements', label: 'Anforderungen' },
{ source: 'admin-compliance-hub', target: 'admin-risks', label: 'Risiken' },
{ source: 'admin-compliance-hub', target: 'admin-ai-act', label: 'AI Act' },
{ source: 'admin-requirements', target: 'admin-controls' },
{ source: 'admin-controls', target: 'admin-evidence' },
{ source: 'admin-audit-checklist', target: 'admin-audit-report', label: 'Report' },
{ source: 'admin-risks', target: 'admin-controls' },
{ source: 'admin-modules', target: 'admin-controls' },
{ source: 'admin-source-policy', target: 'admin-rag' },
{ source: 'admin-obligations', target: 'admin-requirements' },
{ source: 'admin-dsms', target: 'admin-compliance-workflow' },
// === KI & AUTOMATISIERUNG FLOW ===
{ source: 'admin-llm-compare', target: 'admin-rag', label: 'Daten' },
{ source: 'admin-rag', target: 'admin-quality' },
{ source: 'admin-rag', target: 'admin-agents' },
{ source: 'admin-ocr-labeling', target: 'admin-magic-help', label: 'Training' },
{ source: 'admin-magic-help', target: 'admin-klausur-korrektur', label: 'Korrektur' },
{ source: 'admin-quality', target: 'admin-test-quality' },
{ source: 'admin-agents', target: 'admin-test-quality', label: 'BQAS' },
{ source: 'admin-klausur-korrektur', target: 'admin-quality', label: 'Audit' },
// === INFRASTRUKTUR FLOW ===
{ source: 'admin-security', target: 'admin-sbom', label: 'Dependencies' },
{ source: 'admin-sbom', target: 'admin-tests' },
{ source: 'admin-tests', target: 'admin-cicd', label: 'Pipeline' },
{ source: 'admin-cicd', target: 'admin-middleware' },
{ source: 'admin-middleware', target: 'admin-gpu', label: 'GPU' },
{ source: 'admin-security', target: 'admin-compliance-hub', label: 'Compliance' },
// === BILDUNG FLOW ===
{ source: 'admin-edu-search', target: 'admin-rag', label: 'Quellen' },
{ source: 'admin-edu-search', target: 'admin-zeugnisse' },
{ source: 'admin-training', target: 'admin-onboarding' },
{ source: 'admin-foerderantrag', target: 'admin-docs', label: 'Docs' },
// === KOMMUNIKATION FLOW ===
{ source: 'admin-video', target: 'admin-matrix', label: 'Voice' },
{ source: 'admin-mail', target: 'admin-alerts' },
{ source: 'admin-alerts', target: 'admin-mail', label: 'Inbox' },
// === ENTWICKLUNG FLOW ===
{ source: 'admin-workflow', target: 'admin-cicd', label: 'Pipeline' },
{ source: 'admin-workflow', target: 'admin-docs' },
{ source: 'admin-game', target: 'admin-unity', label: 'Editor' },
{ source: 'admin-companion', target: 'admin-agents', label: 'Agents' },
{ source: 'admin-brandbook', target: 'admin-screen-flow' },
{ source: 'admin-docs', target: 'admin-architecture' },
{ source: 'admin-content', target: 'admin-brandbook' },
]
// ============================================
// CATEGORY COLORS
// ============================================
const STUDIO_COLORS: Record<string, { bg: string; border: string; text: string }> = {
navigation: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
content: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
communication: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
school: { bg: '#fce7f3', border: '#ec4899', text: '#9d174d' },
admin: { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
ai: { bg: '#cffafe', border: '#06b6d4', text: '#0e7490' },
}
// Colors from navigation.ts
const ADMIN_COLORS: Record<string, { bg: string; border: string; text: string }> = {
overview: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' }, // Sky (Meta)
dsgvo: { bg: '#ede9fe', border: '#7c3aed', text: '#5b21b6' }, // Violet
compliance: { bg: '#f3e8ff', border: '#9333ea', text: '#6b21a8' }, // Purple
ai: { bg: '#ccfbf1', border: '#14b8a6', text: '#0f766e' }, // Teal
infrastructure: { bg: '#ffedd5', border: '#f97316', text: '#c2410c' },// Orange
education: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' }, // Blue
communication: { bg: '#dcfce7', border: '#22c55e', text: '#166534' }, // Green
development: { bg: '#f1f5f9', border: '#64748b', text: '#334155' }, // Slate
}
const STUDIO_LABELS: Record<string, string> = {
navigation: 'Navigation',
content: 'Content & Tools',
communication: 'Kommunikation',
school: 'Schulverwaltung',
admin: 'Administration',
ai: 'KI & Assistent',
}
// Labels from navigation.ts
const ADMIN_LABELS: Record<string, string> = {
overview: 'Uebersicht & Meta',
dsgvo: 'DSGVO',
compliance: 'Compliance & GRC',
ai: 'KI & Automatisierung',
infrastructure: 'Infrastruktur & DevOps',
education: 'Bildung & Schule',
communication: 'Kommunikation & Alerts',
development: 'Entwicklung & Produkte',
}
// ============================================
// HELPER: Find all connected nodes (recursive)
// ============================================
function findConnectedNodes(
startNodeId: string,
connections: ConnectionDef[],
direction: 'children' | 'parents' | 'both' = 'children'
): Set<string> {
const connected = new Set<string>()
connected.add(startNodeId)
const queue = [startNodeId]
while (queue.length > 0) {
const current = queue.shift()!
connections.forEach(conn => {
if ((direction === 'children' || direction === 'both') && conn.source === current) {
if (!connected.has(conn.target)) {
connected.add(conn.target)
queue.push(conn.target)
}
}
if ((direction === 'parents' || direction === 'both') && conn.target === current) {
if (!connected.has(conn.source)) {
connected.add(conn.source)
queue.push(conn.source)
}
}
})
}
return connected
}
// ============================================
// LAYOUT HELPERS
// ============================================
const getNodePosition = (
id: string,
category: string,
screens: ScreenDefinition[],
flowType: FlowType
) => {
const studioPositions: Record<string, { x: number; y: number }> = {
navigation: { x: 400, y: 50 },
content: { x: 50, y: 250 },
communication: { x: 750, y: 250 },
school: { x: 50, y: 500 },
admin: { x: 750, y: 500 },
ai: { x: 400, y: 380 },
}
const adminPositions: Record<string, { x: number; y: number }> = {
overview: { x: 400, y: 30 },
dsgvo: { x: 50, y: 150 },
compliance: { x: 700, y: 150 },
ai: { x: 50, y: 350 },
communication: { x: 400, y: 350 },
infrastructure: { x: 700, y: 350 },
education: { x: 50, y: 550 },
development: { x: 400, y: 550 },
}
const positions = flowType === 'studio' ? studioPositions : adminPositions
const base = positions[category] || { x: 400, y: 300 }
const categoryScreens = screens.filter(s => s.category === category)
const categoryIndex = categoryScreens.findIndex(s => s.id === id)
const cols = Math.ceil(Math.sqrt(categoryScreens.length + 1))
const row = Math.floor(categoryIndex / cols)
const col = categoryIndex % cols
return {
x: base.x + col * 160,
y: base.y + row * 90,
}
}
// ============================================
// MAIN COMPONENT
// ============================================
export default function ScreenFlowPage() {
const [flowType, setFlowType] = useState<FlowType>('admin')
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [previewScreen, setPreviewScreen] = useState<ScreenDefinition | null>(null)
// Get data based on flow type
const screens = flowType === 'studio' ? STUDIO_SCREENS : ADMIN_SCREENS
const connections = flowType === 'studio' ? STUDIO_CONNECTIONS : ADMIN_CONNECTIONS
const colors = flowType === 'studio' ? STUDIO_COLORS : ADMIN_COLORS
const labels = flowType === 'studio' ? STUDIO_LABELS : ADMIN_LABELS
const baseUrl = flowType === 'studio' ? 'http://macmini:8000' : 'http://macmini:3002'
// Calculate connected nodes
const connectedNodes = useMemo(() => {
if (!selectedNode) return new Set<string>()
return findConnectedNodes(selectedNode, connections, 'children')
}, [selectedNode, connections])
// Create nodes with useMemo
const initialNodes = useMemo((): Node[] => {
return screens.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
const position = getNodePosition(screen.id, screen.category, screens, flowType)
// Determine opacity
let opacity = 1
if (selectedNode) {
opacity = connectedNodes.has(screen.id) ? 1 : 0.2
} else if (selectedCategory) {
opacity = screen.category === selectedCategory ? 1 : 0.2
}
const isSelected = selectedNode === screen.id
return {
id: screen.id,
type: 'default',
position,
data: {
label: (
<div className="text-center p-1">
<div className="text-lg mb-1">{screen.icon}</div>
<div className="font-medium text-xs leading-tight">{screen.name}</div>
</div>
),
},
style: {
background: isSelected ? catColors.border : catColors.bg,
color: isSelected ? 'white' : catColors.text,
border: `2px solid ${catColors.border}`,
borderRadius: '12px',
padding: '6px',
minWidth: '110px',
opacity,
cursor: 'pointer',
boxShadow: isSelected ? `0 0 20px ${catColors.border}` : 'none',
},
}
})
}, [screens, colors, flowType, selectedCategory, selectedNode, connectedNodes])
// Create edges with useMemo
const initialEdges = useMemo((): Edge[] => {
return connections.map((conn, index) => {
const isHighlighted = selectedNode && (conn.source === selectedNode || conn.target === selectedNode)
const isInSubtree = selectedNode && connectedNodes.has(conn.source) && connectedNodes.has(conn.target)
return {
id: `e-${conn.source}-${conn.target}-${index}`,
source: conn.source,
target: conn.target,
label: conn.label,
type: 'smoothstep',
animated: isHighlighted || false,
style: {
stroke: isHighlighted ? '#3b82f6' : (isInSubtree ? '#94a3b8' : '#e2e8f0'),
strokeWidth: isHighlighted ? 3 : 1.5,
opacity: selectedNode ? (isInSubtree ? 1 : 0.15) : 1,
},
labelStyle: { fontSize: 9, fill: '#64748b' },
labelBgStyle: { fill: '#f8fafc' },
markerEnd: { type: MarkerType.ArrowClosed, color: isHighlighted ? '#3b82f6' : '#94a3b8', width: 15, height: 15 },
}
})
}, [connections, selectedNode, connectedNodes])
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
// Update nodes/edges when dependencies change
useEffect(() => {
setNodes(initialNodes)
setEdges(initialEdges)
}, [initialNodes, initialEdges, setNodes, setEdges])
// Reset when flow type changes
const handleFlowTypeChange = useCallback((newType: FlowType) => {
setFlowType(newType)
setSelectedNode(null)
setSelectedCategory(null)
setPreviewScreen(null)
}, [])
// Handle node click
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
const screen = screens.find(s => s.id === node.id)
if (selectedNode === node.id) {
// Double-click: open in new tab
if (screen?.url) {
window.open(`${baseUrl}${screen.url}`, '_blank')
}
return
}
setSelectedNode(node.id)
setSelectedCategory(null)
if (screen) {
setPreviewScreen(screen)
}
}, [screens, baseUrl, selectedNode])
// Handle background click - deselect
const onPaneClick = useCallback(() => {
setSelectedNode(null)
setPreviewScreen(null)
}, [])
// Stats
const stats = {
totalScreens: screens.length,
totalConnections: connections.length,
connectedCount: connectedNodes.size,
}
const categories = Object.keys(labels)
// Connected screens list
const connectedScreens = selectedNode
? screens.filter(s => connectedNodes.has(s.id))
: []
return (
<div className="space-y-6">
{/* Flow Type Selector */}
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => handleFlowTypeChange('studio')}
className={`p-6 rounded-xl border-2 transition-all ${
flowType === 'studio'
? 'border-green-500 bg-green-50 shadow-lg'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
flowType === 'studio' ? 'bg-green-500 text-white' : 'bg-slate-100'
}`}>
🎓
</div>
<div className="text-left">
<div className="font-bold text-lg">Studio (Port 8000)</div>
<div className="text-sm text-slate-500">Lehrer-Oberflaeche</div>
<div className="text-xs text-slate-400 mt-1">{STUDIO_SCREENS.length} Screens</div>
</div>
</div>
</button>
<button
onClick={() => handleFlowTypeChange('admin')}
className={`p-6 rounded-xl border-2 transition-all ${
flowType === 'admin'
? 'border-primary-500 bg-primary-50 shadow-lg'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
flowType === 'admin' ? 'bg-primary-500 text-white' : 'bg-slate-100'
}`}>
</div>
<div className="text-left">
<div className="font-bold text-lg">Admin v2 (Port 3002)</div>
<div className="text-sm text-slate-500">Admin Panel</div>
<div className="text-xs text-slate-400 mt-1">{ADMIN_SCREENS.length} Screens</div>
</div>
</div>
</button>
</div>
{/* Stats & Selection Info */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-slate-800">{stats.totalScreens}</div>
<div className="text-sm text-slate-500">Screens</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-primary-600">{stats.totalConnections}</div>
<div className="text-sm text-slate-500">Verbindungen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm col-span-2">
{selectedNode ? (
<div className="flex items-center gap-3">
<div className="text-3xl">{previewScreen?.icon}</div>
<div>
<div className="font-bold text-slate-800">{previewScreen?.name}</div>
<div className="text-sm text-slate-500">
{stats.connectedCount} verbundene Screen{stats.connectedCount !== 1 ? 's' : ''}
</div>
</div>
<button
onClick={() => {
setSelectedNode(null)
setPreviewScreen(null)
}}
className="ml-auto px-3 py-1 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg"
>
Zuruecksetzen
</button>
</div>
) : (
<div className="text-slate-500 text-sm">
Klicke auf einen Screen um den Subtree zu sehen
</div>
)}
</div>
</div>
{/* Category Filter */}
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
setSelectedCategory(null)
setSelectedNode(null)
setPreviewScreen(null)
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedCategory === null && !selectedNode
? 'bg-slate-800 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Alle ({screens.length})
</button>
{categories.map((key) => {
const count = screens.filter(s => s.category === key).length
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
return (
<button
key={key}
onClick={() => {
setSelectedCategory(selectedCategory === key ? null : key)
setSelectedNode(null)
setPreviewScreen(null)
}}
className="px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2"
style={{
background: selectedCategory === key ? catColors.border : catColors.bg,
color: selectedCategory === key ? 'white' : catColors.text,
}}
>
<span className="w-3 h-3 rounded-full" style={{ background: catColors.border }} />
{labels[key]} ({count})
</button>
)
})}
</div>
</div>
{/* Connected Screens List */}
{selectedNode && connectedScreens.length > 1 && (
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-sm font-medium text-slate-700 mb-3">Verbundene Screens:</div>
<div className="flex flex-wrap gap-2">
{connectedScreens.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
const isCurrentNode = screen.id === selectedNode
return (
<button
key={screen.id}
onClick={() => {
if (screen.url) {
window.open(`${baseUrl}${screen.url}`, '_blank')
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
isCurrentNode ? 'ring-2 ring-primary-500' : ''
}`}
style={{
background: isCurrentNode ? catColors.border : catColors.bg,
color: isCurrentNode ? 'white' : catColors.text,
}}
>
<span>{screen.icon}</span>
{screen.name}
</button>
)
})}
</div>
</div>
)}
{/* Flow Diagram */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '500px' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.2 }}
attributionPosition="bottom-left"
>
<Controls />
<MiniMap
nodeColor={(node) => {
const screen = screens.find(s => s.id === node.id)
const catColors = screen ? colors[screen.category] : null
return catColors?.border || '#94a3b8'
}}
maskColor="rgba(0, 0, 0, 0.1)"
/>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Panel position="top-left" className="bg-white/95 p-3 rounded-lg shadow-lg text-xs">
<div className="font-medium text-slate-700 mb-2">
{flowType === 'studio' ? '🎓 Studio' : '⚙️ Admin v2'}
</div>
<div className="space-y-1">
{categories.slice(0, 4).map((key) => {
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8' }
return (
<div key={key} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{ background: catColors.bg, border: `1px solid ${catColors.border}` }}
/>
<span className="text-slate-600">{labels[key]}</span>
</div>
)
})}
</div>
<div className="mt-2 pt-2 border-t text-slate-400">
Klick = Subtree<br/>
Doppelklick = Oeffnen
</div>
</Panel>
</ReactFlow>
</div>
{/* Screen List */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b flex items-center justify-between">
<h3 className="font-medium text-slate-700">
Alle Screens ({screens.length})
</h3>
<span className="text-xs text-slate-400">{baseUrl}</span>
</div>
<div className="divide-y max-h-80 overflow-y-auto">
{screens
.filter(s => !selectedCategory || s.category === selectedCategory)
.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
return (
<button
key={screen.id}
onClick={() => {
setSelectedNode(screen.id)
setSelectedCategory(null)
setPreviewScreen(screen)
}}
className="w-full flex items-center gap-4 p-3 hover:bg-slate-50 transition-colors text-left"
>
<span
className="w-9 h-9 rounded-lg flex items-center justify-center text-lg"
style={{ background: catColors.bg }}
>
{screen.icon}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 text-sm">{screen.name}</div>
<div className="text-xs text-slate-500 truncate">{screen.description}</div>
</div>
<span
className="px-2 py-1 rounded text-xs font-medium shrink-0"
style={{ background: catColors.bg, color: catColors.text }}
>
{labels[screen.category]}
</span>
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -19,7 +19,8 @@ import {
Eye,
Download,
AlertTriangle,
Info
Info,
Container
} from 'lucide-react'
interface WorkflowStep {
@@ -88,6 +89,14 @@ export default function WorkflowPage() {
},
{
id: 6,
title: 'Integration Tests',
description: 'Docker Compose Test-Umgebung mit Backend, DB und Consent-Service fuer vollstaendige E2E-Tests.',
command: 'docker compose -f docker-compose.test.yml up -d',
icon: <Container className="h-6 w-6" />,
location: 'macmini'
},
{
id: 7,
title: 'Frontend testen',
description: 'Teste die Änderungen im Browser auf dem Mac Mini.',
command: 'http://macmini:3000',
@@ -158,8 +167,8 @@ export default function WorkflowPage() {
<span>Browser für Frontend-Tests</span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Tägliches Backup (automatisch)</span>
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span>Backup manuell (MacBook nachts aus)</span>
</li>
</ul>
</div>
@@ -192,6 +201,10 @@ export default function WorkflowPage() {
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>PostgreSQL Datenbank</span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Automatisches Backup (02:00 Uhr lokal)</span>
</li>
</ul>
</div>
</div>
@@ -314,17 +327,18 @@ export default function WorkflowPage() {
Backup & Sicherheit
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Mac Mini - Automatisches lokales Backup */}
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
<div className="flex items-center gap-3 mb-3">
<Clock className="h-5 w-5 text-green-600" />
<h3 className="font-semibold text-green-900">Tägliches Backup</h3>
<h3 className="font-semibold text-green-900">Mac Mini (Auto)</h3>
</div>
<ul className="space-y-2 text-sm text-green-800">
<li> Läuft automatisch um 02:00 Uhr</li>
<li> Git Repository wird synchronisiert</li>
<li> PostgreSQL-Dump wird erstellt</li>
<li> Backups werden 7 Tage aufbewahrt</li>
<li> Automatisch um 02:00 Uhr</li>
<li> PostgreSQL-Dump lokal</li>
<li> Git Repository gesichert</li>
<li> 7 Tage Aufbewahrung</li>
</ul>
<div className="mt-4 p-3 bg-green-100 rounded-lg">
<code className="text-xs text-green-700 font-mono">
@@ -333,10 +347,29 @@ export default function WorkflowPage() {
</div>
</div>
{/* MacBook - Manuelles Backup */}
<div className="bg-amber-50 rounded-xl p-5 border border-amber-200">
<div className="flex items-center gap-3 mb-3">
<AlertTriangle className="h-5 w-5 text-amber-600" />
<h3 className="font-semibold text-amber-900">MacBook (Manuell)</h3>
</div>
<ul className="space-y-2 text-sm text-amber-800">
<li> MacBook nachts aus (02:00)</li>
<li> Keine Auto-Synchronisation</li>
<li> Backup manuell anstoßen</li>
</ul>
<div className="mt-4 p-3 bg-amber-100 rounded-lg">
<code className="text-xs text-amber-700 font-mono">
rsync -avz macmini:~/Projekte/ ~/Projekte/
</code>
</div>
</div>
{/* Manuelles Backup starten */}
<div className="bg-blue-50 rounded-xl p-5 border border-blue-200">
<div className="flex items-center gap-3 mb-3">
<Download className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-blue-900">Manuelles Backup</h3>
<h3 className="font-semibold text-blue-900">Backup Script</h3>
</div>
<p className="text-sm text-blue-800 mb-3">
Backup jederzeit manuell starten:
@@ -490,6 +523,118 @@ export default function WorkflowPage() {
</div>
</div>
{/* CI/CD Infrastruktur - Automatisierte OAuth Integration */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Shield className="h-5 w-5 text-indigo-600" />
CI/CD Infrastruktur (Automatisiert)
</h2>
<div className="bg-blue-50 rounded-xl p-4 mb-6 border border-blue-200">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900">Warum automatisiert?</h4>
<p className="text-sm text-blue-800 mt-1">
Die OAuth-Integration zwischen Woodpecker und Gitea ist vollautomatisiert.
Dies ist eine DevSecOps Best Practice: Credentials werden in HashiCorp Vault gespeichert
und können bei Bedarf automatisch regeneriert werden.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Architektur */}
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
<h3 className="font-semibold text-slate-900 mb-3">Architektur</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
<div className="w-3 h-3 bg-green-500 rounded-full" />
<span className="font-medium">Gitea</span>
<span className="text-slate-500">Port 3003</span>
<span className="text-xs text-slate-400 ml-auto">Git Server</span>
</div>
<div className="flex items-center justify-center">
<ArrowRight className="h-4 w-4 text-slate-400 rotate-90" />
<span className="text-xs text-slate-500 ml-2">OAuth 2.0</span>
</div>
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
<div className="w-3 h-3 bg-blue-500 rounded-full" />
<span className="font-medium">Woodpecker</span>
<span className="text-slate-500">Port 8090</span>
<span className="text-xs text-slate-400 ml-auto">CI/CD Server</span>
</div>
<div className="flex items-center justify-center">
<ArrowRight className="h-4 w-4 text-slate-400 rotate-90" />
<span className="text-xs text-slate-500 ml-2">Credentials</span>
</div>
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
<div className="w-3 h-3 bg-purple-500 rounded-full" />
<span className="font-medium">Vault</span>
<span className="text-slate-500">Port 8200</span>
<span className="text-xs text-slate-400 ml-auto">Secrets Manager</span>
</div>
</div>
</div>
{/* Credentials Speicherort */}
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
<h3 className="font-semibold text-slate-900 mb-3">Credentials Speicherorte</h3>
<div className="space-y-3 text-sm">
<div className="p-3 bg-white rounded-lg border">
<div className="flex items-center gap-2 mb-1">
<Database className="h-4 w-4 text-purple-500" />
<span className="font-medium">HashiCorp Vault</span>
</div>
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
secret/cicd/woodpecker
</code>
<p className="text-xs text-slate-500 mt-1">Client ID + Secret (Quelle der Wahrheit)</p>
</div>
<div className="p-3 bg-white rounded-lg border">
<div className="flex items-center gap-2 mb-1">
<FileCode className="h-4 w-4 text-blue-500" />
<span className="font-medium">.env Datei</span>
</div>
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
WOODPECKER_GITEA_CLIENT/SECRET
</code>
<p className="text-xs text-slate-500 mt-1">Für Docker Compose (aus Vault geladen)</p>
</div>
<div className="p-3 bg-white rounded-lg border">
<div className="flex items-center gap-2 mb-1">
<Database className="h-4 w-4 text-green-500" />
<span className="font-medium">Gitea PostgreSQL</span>
</div>
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
oauth2_application
</code>
<p className="text-xs text-slate-500 mt-1">OAuth App Registration (gehashtes Secret)</p>
</div>
</div>
</div>
</div>
{/* Troubleshooting */}
<div className="mt-6 bg-amber-50 rounded-xl p-5 border border-amber-200">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-600" />
Troubleshooting: OAuth Fehler beheben
</h3>
<p className="text-sm text-amber-800 mb-3">
Falls der Fehler &quot;Client ID not registered&quot; oder &quot;user does not exist&quot; auftritt:
</p>
<div className="bg-slate-800 rounded-lg p-4 font-mono text-sm">
<p className="text-slate-400"># Credentials automatisch regenerieren</p>
<p className="text-green-400">./scripts/sync-woodpecker-credentials.sh --regenerate</p>
<p className="text-slate-400 mt-2"># Oder manuell: Vault Gitea .env Restart</p>
<p className="text-green-400">rsync .env macmini:~/Projekte/breakpilot-pwa/</p>
<p className="text-green-400">ssh macmini &quot;cd ~/Projekte/breakpilot-pwa && docker compose up -d --force-recreate woodpecker-server&quot;</p>
</div>
</div>
</div>
{/* Team Members Info */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">

View File

@@ -0,0 +1,223 @@
'use client'
/**
* AehnlicheDokumente - RAG-based similar documents panel
* Shows documents with similar content based on vector similarity
*/
import { useState, useEffect } from 'react'
import { Loader2, FileText, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import type { SimilarDocument } from '@/lib/education/abitur-archiv-types'
import { FAECHER } from '@/lib/education/abitur-docs-types'
interface AehnlicheDokumenteProps {
documentId: string
onSelectDocument: (doc: AbiturDokument) => void
limit?: number
}
export function AehnlicheDokumente({
documentId,
onSelectDocument,
limit = 5
}: AehnlicheDokumenteProps) {
const [similarDocs, setSimilarDocs] = useState<SimilarDocument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchSimilarDocuments = async () => {
if (!documentId) return
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/education/abitur-archiv/similar?id=${documentId}&limit=${limit}`)
if (!res.ok) {
// Use mock data if endpoint not available
setSimilarDocs(getMockSimilarDocuments(documentId))
return
}
const data = await res.json()
setSimilarDocs(data.similar || [])
} catch (err) {
console.log('Similar docs fetch failed, using mock data')
setSimilarDocs(getMockSimilarDocuments(documentId))
} finally {
setLoading(false)
}
}
fetchSimilarDocuments()
}, [documentId, limit])
const handleRefresh = () => {
setLoading(true)
// Re-trigger the effect
setSimilarDocs([])
setTimeout(() => {
setSimilarDocs(getMockSimilarDocuments(documentId))
setLoading(false)
}, 500)
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-3" />
<p className="text-sm text-slate-500">Suche aehnliche Dokumente...</p>
</div>
)
}
if (error) {
return (
<div className="text-center py-8">
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-sm text-red-600 mb-3">{error}</p>
<button
onClick={handleRefresh}
className="px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg flex items-center gap-2 mx-auto"
>
<RefreshCw className="w-4 h-4" />
Erneut versuchen
</button>
</div>
)
}
if (similarDocs.length === 0) {
return (
<div className="text-center py-8">
<FileText className="w-10 h-10 text-slate-300 mx-auto mb-3" />
<p className="text-sm text-slate-500">Keine aehnlichen Dokumente gefunden</p>
<p className="text-xs text-slate-400 mt-1">
Versuchen Sie eine andere Suche oder laden Sie mehr Dokumente hoch.
</p>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">Aehnliche Dokumente</h4>
<button
onClick={handleRefresh}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
title="Aktualisieren"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="space-y-2">
{similarDocs.map((doc) => (
<SimilarDocumentCard
key={doc.id}
document={doc}
onSelect={() => {
// Convert SimilarDocument to AbiturDokument for selection
// In production, this would fetch the full document
onSelectDocument(doc as unknown as AbiturDokument)
}}
/>
))}
</div>
<p className="text-xs text-slate-400 text-center pt-2">
Basierend auf semantischer Aehnlichkeit (RAG)
</p>
</div>
)
}
function SimilarDocumentCard({
document,
onSelect
}: {
document: SimilarDocument
onSelect: () => void
}) {
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
const similarityPercent = Math.round(document.similarity_score * 100)
return (
<button
onClick={onSelect}
className="w-full flex items-start gap-3 p-3 bg-white border border-slate-200 rounded-lg
hover:bg-blue-50 hover:border-blue-200 transition-colors text-left group"
>
{/* Icon */}
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0
group-hover:bg-blue-100 transition-colors">
<FileText className="w-5 h-5 text-slate-400 group-hover:text-blue-500" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 truncate group-hover:text-blue-700">
{fachLabel} {document.jahr}
</div>
<div className="text-sm text-slate-500 flex items-center gap-2">
<span>{document.niveau}</span>
<span>|</span>
<span>Aufgabe {document.aufgaben_nummer}</span>
{document.typ === 'erwartungshorizont' && (
<>
<span>|</span>
<span className="text-orange-600">EWH</span>
</>
)}
</div>
</div>
{/* Similarity Score */}
<div className="flex-shrink-0">
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
similarityPercent >= 80
? 'bg-green-100 text-green-700'
: similarityPercent >= 60
? 'bg-yellow-100 text-yellow-700'
: 'bg-slate-100 text-slate-600'
}`}>
{similarityPercent}%
</div>
</div>
</button>
)
}
// Mock data generator for development
function getMockSimilarDocuments(documentId: string): SimilarDocument[] {
// Generate consistent mock data based on document ID
const idHash = documentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const faecher = ['deutsch', 'englisch']
const jahre = [2021, 2022, 2023, 2024, 2025]
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
const nummern = ['I', 'II', 'III']
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
const docs: SimilarDocument[] = []
for (let i = 0; i < 5; i++) {
const idx = (idHash + i) % (faecher.length * jahre.length * niveaus.length)
docs.push({
id: `similar-${documentId}-${i}`,
dateiname: `${jahre[idx % jahre.length]}_${faecher[idx % faecher.length]}_${niveaus[idx % niveaus.length]}_${nummern[idx % nummern.length]}.pdf`,
similarity_score: 0.95 - (i * 0.1) + (Math.random() * 0.05),
fach: faecher[idx % faecher.length],
jahr: jahre[(idx + i) % jahre.length],
niveau: niveaus[idx % niveaus.length],
typ: typen[(idx + i) % typen.length],
aufgaben_nummer: nummern[(idx + i) % nummern.length]
})
}
return docs.sort((a, b) => b.similarity_score - a.similarity_score)
}

View File

@@ -0,0 +1,203 @@
'use client'
/**
* DokumentCard - Card component for Abitur document grid view
* Features: Preview, Download, Add to Klausur actions
*/
import { useState } from 'react'
import { FileText, Eye, Download, Plus, Calendar, Layers, BookOpen, ExternalLink } from 'lucide-react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import { formatFileSize, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
interface DokumentCardProps {
document: AbiturDokument
onPreview: (doc: AbiturDokument) => void
onDownload: (doc: AbiturDokument) => void
onAddToKlausur?: (doc: AbiturDokument) => void
}
export function DokumentCard({
document,
onPreview,
onDownload,
onAddToKlausur
}: DokumentCardProps) {
const [isHovered, setIsHovered] = useState(false)
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
const niveauLabel = document.niveau === 'eA' ? 'Erhoehtes Niveau' : 'Grundlegendes Niveau'
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
onDownload(document)
}
const handleAddToKlausur = (e: React.MouseEvent) => {
e.stopPropagation()
onAddToKlausur?.(document)
}
return (
<div
className="bg-white rounded-xl border border-slate-200 overflow-hidden hover:shadow-lg
transition-all duration-200 cursor-pointer group"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={() => onPreview(document)}
>
{/* Header with Type Badge */}
<div className="relative h-32 bg-gradient-to-br from-slate-100 to-slate-50 flex items-center justify-center">
<FileText className="w-16 h-16 text-slate-300 group-hover:text-blue-400 transition-colors" />
{/* Type Badge */}
<div className="absolute top-3 left-3">
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${
document.typ === 'erwartungshorizont'
? 'bg-orange-100 text-orange-700'
: 'bg-purple-100 text-purple-700'
}`}>
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
</span>
</div>
{/* Year Badge */}
<div className="absolute top-3 right-3">
<span className="px-2 py-1 bg-white/80 backdrop-blur-sm rounded-lg text-xs font-semibold text-slate-700">
{document.jahr}
</span>
</div>
{/* Status Badge */}
<div className="absolute bottom-3 right-3">
<span className={`px-2 py-0.5 rounded-full text-xs ${
document.status === 'indexed'
? 'bg-green-100 text-green-700'
: document.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</div>
{/* Hover Overlay with Preview */}
{isHovered && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<button
className="px-4 py-2 bg-white text-slate-800 rounded-lg font-medium
flex items-center gap-2 shadow-lg hover:bg-blue-50 transition-colors"
onClick={(e) => {
e.stopPropagation()
onPreview(document)
}}
>
<Eye className="w-4 h-4" />
Vorschau
</button>
</div>
)}
</div>
{/* Content */}
<div className="p-4">
{/* Title */}
<h3 className="font-semibold text-slate-800 mb-2 line-clamp-2 min-h-[2.5rem]">
{fachLabel} {document.niveau} - Aufgabe {document.aufgaben_nummer}
</h3>
{/* Metadata */}
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-sm text-slate-500">
<BookOpen className="w-4 h-4" />
<span>{fachLabel}</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-500">
<Layers className="w-4 h-4" />
<span>{niveauLabel}</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-500">
<ExternalLink className="w-4 h-4" />
<span className="capitalize">{document.bundesland}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400">
<span>{formatFileSize(document.file_size)}</span>
<span>|</span>
<span>{document.dateiname}</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={() => onPreview(document)}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100
transition-colors text-sm font-medium flex items-center justify-center gap-1.5"
>
<Eye className="w-4 h-4" />
Vorschau
</button>
<button
onClick={handleDownload}
className="px-3 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
title="Herunterladen"
>
<Download className="w-4 h-4" />
</button>
{onAddToKlausur && (
<button
onClick={handleAddToKlausur}
className="px-3 py-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title="Zur Klausur hinzufuegen"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
)
}
/**
* Compact card variant for list view or similar documents
*/
export function DokumentCardCompact({
document,
onPreview,
similarity_score
}: {
document: AbiturDokument
onPreview: (doc: AbiturDokument) => void
similarity_score?: number
}) {
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
return (
<button
onClick={() => onPreview(document)}
className="w-full flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg
hover:bg-slate-50 hover:border-slate-300 transition-colors text-left"
>
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 truncate">
{fachLabel} {document.jahr} - {document.niveau}
</div>
<div className="text-sm text-slate-500 truncate">
Aufgabe {document.aufgaben_nummer}
{document.typ === 'erwartungshorizont' && ' (EWH)'}
</div>
</div>
{similarity_score !== undefined && (
<div className="flex-shrink-0">
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
{Math.round(similarity_score * 100)}%
</span>
</div>
)}
</button>
)
}

View File

@@ -0,0 +1,456 @@
'use client'
/**
* FullscreenViewer - Enhanced PDF viewer with fullscreen, zoom, and page navigation
* Features: Keyboard shortcuts, zoom controls, similar documents panel
*/
import { useState, useEffect, useCallback } from 'react'
import {
X, Download, ZoomIn, ZoomOut, Maximize2, Minimize2,
ChevronLeft, ChevronRight, RotateCw, FileText, Search,
BookOpen, Calendar, Layers, ExternalLink, Plus
} from 'lucide-react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import { formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
import { ZOOM_LEVELS, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from '@/lib/education/abitur-archiv-types'
import { AehnlicheDokumente } from './AehnlicheDokumente'
interface FullscreenViewerProps {
document: AbiturDokument | null
onClose: () => void
onAddToKlausur?: (doc: AbiturDokument) => void
backendUrl?: string
}
export function FullscreenViewer({
document,
onClose,
onAddToKlausur,
backendUrl = ''
}: FullscreenViewerProps) {
const [isFullscreen, setIsFullscreen] = useState(false)
const [zoom, setZoom] = useState(100)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [showSidebar, setShowSidebar] = useState(true)
const [activeTab, setActiveTab] = useState<'details' | 'similar'>('details')
// Reset state when document changes
useEffect(() => {
setZoom(100)
setCurrentPage(1)
setIsFullscreen(false)
}, [document?.id])
// Keyboard shortcuts
useEffect(() => {
if (!document) return
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
switch (e.key) {
case 'Escape':
if (isFullscreen) {
setIsFullscreen(false)
} else {
onClose()
}
break
case 'f':
case 'F11':
e.preventDefault()
setIsFullscreen(prev => !prev)
break
case '+':
case '=':
e.preventDefault()
setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))
break
case '-':
e.preventDefault()
setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))
break
case '0':
e.preventDefault()
setZoom(100)
break
case 'ArrowLeft':
e.preventDefault()
setCurrentPage(p => Math.max(1, p - 1))
break
case 'ArrowRight':
e.preventDefault()
setCurrentPage(p => Math.min(totalPages, p + 1))
break
case 's':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
handleDownload()
}
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [document, isFullscreen, totalPages, onClose])
// Handle native fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!window.document.fullscreenElement)
}
window.document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => window.document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [])
const handleDownload = useCallback(() => {
if (!document) return
const link = window.document.createElement('a')
link.href = pdfUrl
link.download = document.dateiname
link.click()
}, [document])
const handleSearchInRAG = () => {
if (!document) return
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
}
const handleAddToKlausur = () => {
if (!document || !onAddToKlausur) return
onAddToKlausur(document)
}
if (!document) return null
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
const niveauLabel = NIVEAUS.find(n => n.id === document.niveau)?.label || document.niveau
// Build PDF URL
const pdfUrl = backendUrl
? `${backendUrl}/api/abitur-docs/${document.id}/file`
: document.file_path
return (
<div className={`fixed inset-0 z-50 flex ${isFullscreen ? 'bg-black' : 'bg-black/60 backdrop-blur-sm'}`}>
{/* Modal Container */}
<div className={`relative bg-white flex flex-col ${
isFullscreen ? 'w-full h-full' : 'w-[95vw] h-[95vh] max-w-7xl m-auto rounded-2xl overflow-hidden shadow-2xl'
}`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-blue-600" />
<div>
<h2 className="font-semibold text-slate-900">
{formatDocumentTitle(document)}
</h2>
<p className="text-sm text-slate-500">
{document.dateiname}
</p>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2">
{/* Zoom Controls */}
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
<button
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
className="p-1.5 hover:bg-slate-200 rounded"
title="Verkleinern (-)"
>
<ZoomOut className="w-4 h-4 text-slate-600" />
</button>
<span className="text-sm font-medium text-slate-700 w-12 text-center">
{zoom}%
</span>
<button
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
className="p-1.5 hover:bg-slate-200 rounded"
title="Vergroessern (+)"
>
<ZoomIn className="w-4 h-4 text-slate-600" />
</button>
<button
onClick={() => setZoom(100)}
className="p-1.5 hover:bg-slate-200 rounded ml-1"
title="Zuruecksetzen (0)"
>
<RotateCw className="w-4 h-4 text-slate-600" />
</button>
</div>
{/* Page Navigation */}
{totalPages > 1 && (
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4 text-slate-600" />
</button>
<span className="text-sm font-medium text-slate-700 min-w-[60px] text-center">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
>
<ChevronRight className="w-4 h-4 text-slate-600" />
</button>
</div>
)}
<div className="w-px h-6 bg-slate-200" />
{/* Action Buttons */}
<button
onClick={handleSearchInRAG}
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 flex items-center gap-1.5"
title="In RAG suchen"
>
<Search className="w-4 h-4" />
<span className="hidden sm:inline">RAG-Suche</span>
</button>
{onAddToKlausur && (
<button
onClick={handleAddToKlausur}
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-lg hover:bg-green-200 flex items-center gap-1.5"
title="Als Vorlage verwenden"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Zur Klausur</span>
</button>
)}
<button
onClick={handleDownload}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 flex items-center gap-1.5"
title="Herunterladen (Ctrl+S)"
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">Download</span>
</button>
<div className="w-px h-6 bg-slate-200" />
<button
onClick={() => setShowSidebar(!showSidebar)}
className={`p-2 rounded-lg transition-colors ${
showSidebar ? 'bg-slate-200 text-slate-700' : 'text-slate-500 hover:bg-slate-100'
}`}
title="Seitenleiste"
>
<Layers className="w-4 h-4" />
</button>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-2 hover:bg-slate-100 rounded-lg"
title={isFullscreen ? 'Vollbild beenden (F)' : 'Vollbild (F)'}
>
{isFullscreen ? (
<Minimize2 className="w-5 h-5 text-slate-600" />
) : (
<Maximize2 className="w-5 h-5 text-slate-600" />
)}
</button>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg"
title="Schliessen (Esc)"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
</div>
{/* Content */}
<div className="flex flex-1 overflow-hidden">
{/* PDF Viewer */}
<div className="flex-1 bg-slate-100 p-4 overflow-auto">
<div
className="bg-white rounded-lg border border-slate-200 mx-auto shadow-sm transition-transform duration-200"
style={{
transform: `scale(${zoom / 100})`,
transformOrigin: 'top center',
width: '100%',
maxWidth: zoom > 100 ? 'none' : '100%'
}}
>
<iframe
src={pdfUrl}
className="w-full h-[calc(90vh-120px)] rounded-lg"
title={document.dateiname}
/>
</div>
</div>
{/* Sidebar */}
{showSidebar && (
<div className="w-80 border-l border-slate-200 bg-slate-50 flex flex-col">
{/* Sidebar Tabs */}
<div className="flex border-b border-slate-200">
<button
onClick={() => setActiveTab('details')}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === 'details'
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
: 'text-slate-600 hover:text-slate-800'
}`}
>
Details
</button>
<button
onClick={() => setActiveTab('similar')}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === 'similar'
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
: 'text-slate-600 hover:text-slate-800'
}`}
>
Aehnliche
</button>
</div>
{/* Sidebar Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'details' ? (
<div className="space-y-4">
{/* Fach */}
<div className="flex items-start gap-3">
<BookOpen className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Fach</div>
<div className="font-medium text-slate-900">{fachLabel}</div>
</div>
</div>
{/* Jahr */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Jahr</div>
<div className="font-medium text-slate-900">{document.jahr}</div>
</div>
</div>
{/* Niveau */}
<div className="flex items-start gap-3">
<Layers className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Niveau</div>
<div className="font-medium text-slate-900">{niveauLabel}</div>
</div>
</div>
{/* Aufgabe */}
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Aufgabe</div>
<div className="font-medium text-slate-900">
{document.aufgaben_nummer}
<span className="ml-2 px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded-full">
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
</span>
</div>
</div>
</div>
{/* Bundesland */}
<div className="flex items-start gap-3">
<ExternalLink className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Bundesland</div>
<div className="font-medium text-slate-900 capitalize">{document.bundesland}</div>
</div>
</div>
<hr className="border-slate-200" />
{/* File Info */}
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Datei-Info</div>
<div className="bg-white rounded-lg border border-slate-200 p-3 text-sm space-y-2">
<div className="flex justify-between">
<span className="text-slate-500">Dateiname</span>
<span className="text-slate-900 font-mono text-xs truncate max-w-[150px]" title={document.dateiname}>
{document.dateiname}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Groesse</span>
<span className="text-slate-900">{formatFileSize(document.file_size)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Status</span>
<span className={`px-2 py-0.5 rounded-full text-xs ${
document.status === 'indexed'
? 'bg-green-100 text-green-700'
: document.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</div>
</div>
</div>
{/* RAG Info */}
{document.indexed && document.vector_ids.length > 0 && (
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">RAG-Index</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm">
<div className="flex items-center gap-2 text-purple-700">
<Search className="w-4 h-4" />
<span>{document.vector_ids.length} Vektoren indexiert</span>
</div>
<div className="mt-2 text-xs text-purple-600">
Confidence: {(document.confidence * 100).toFixed(0)}%
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="text-xs text-slate-400 pt-2">
<div>Erstellt: {new Date(document.created_at).toLocaleString('de-DE')}</div>
<div>Aktualisiert: {new Date(document.updated_at).toLocaleString('de-DE')}</div>
</div>
</div>
) : (
<AehnlicheDokumente
documentId={document.id}
onSelectDocument={(doc) => {
// This would be handled by parent - for now just show preview
console.log('Selected similar document:', doc.id)
}}
/>
)}
</div>
</div>
)}
</div>
{/* Keyboard Shortcut Hint */}
<div className="absolute bottom-4 left-4 text-xs text-slate-400 bg-white/80 backdrop-blur-sm px-3 py-1.5 rounded-lg shadow-sm">
Tastenkuerzel: F (Vollbild) | +/- (Zoom) | 0 (Reset) | Esc (Schliessen)
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,243 @@
'use client'
/**
* ThemenSuche - Autocomplete search for Abitur themes
* Features debounced API calls, suggestion display, and keyboard navigation
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { Search, X, Loader2 } from 'lucide-react'
import type { ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
import { POPULAR_THEMES } from '@/lib/education/abitur-archiv-types'
interface ThemenSucheProps {
onSearch: (query: string) => void
onClear: () => void
placeholder?: string
}
export function ThemenSuche({
onSearch,
onClear,
placeholder = 'Thema suchen (z.B. Gedichtanalyse, Eroerterung, Drama...)'
}: ThemenSucheProps) {
const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
const [loading, setLoading] = useState(false)
const [showDropdown, setShowDropdown] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Debounced API call for suggestions
useEffect(() => {
const timer = setTimeout(async () => {
if (query.length >= 2) {
setLoading(true)
try {
const res = await fetch(`/api/education/abitur-archiv/suggest?q=${encodeURIComponent(query)}`)
const data = await res.json()
setSuggestions(data.suggestions || [])
setShowDropdown(true)
} catch (error) {
console.error('Suggest error:', error)
// Fallback to popular themes
setSuggestions(POPULAR_THEMES.filter(t =>
t.label.toLowerCase().includes(query.toLowerCase())
))
} finally {
setLoading(false)
}
} else if (query.length === 0) {
setSuggestions(POPULAR_THEMES)
} else {
setSuggestions([])
}
}, 300)
return () => clearTimeout(timer)
}, [query])
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
inputRef.current &&
!inputRef.current.contains(e.target as Node)
) {
setShowDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!showDropdown || suggestions.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => Math.max(prev - 1, -1))
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0) {
handleSelectSuggestion(suggestions[selectedIndex])
} else if (query.trim()) {
handleSearch()
}
break
case 'Escape':
setShowDropdown(false)
setSelectedIndex(-1)
break
}
}, [showDropdown, suggestions, selectedIndex, query])
const handleSelectSuggestion = (suggestion: ThemaSuggestion) => {
setQuery(suggestion.label)
setShowDropdown(false)
setSelectedIndex(-1)
onSearch(suggestion.label)
}
const handleSearch = () => {
if (query.trim()) {
onSearch(query.trim())
setShowDropdown(false)
}
}
const handleClear = () => {
setQuery('')
setSuggestions(POPULAR_THEMES)
setShowDropdown(false)
setSelectedIndex(-1)
onClear()
inputRef.current?.focus()
}
const handleFocus = () => {
if (query.length === 0) {
setSuggestions(POPULAR_THEMES)
}
setShowDropdown(true)
}
return (
<div className="relative">
{/* Search Input */}
<div className="relative flex items-center">
<div className="absolute left-4 text-slate-400">
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Search className="w-5 h-5" />
)}
</div>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setSelectedIndex(-1)
}}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
className="w-full pl-12 pr-24 py-3 text-lg border border-slate-300 rounded-xl
focus:ring-2 focus:ring-blue-500 focus:border-transparent
bg-white shadow-sm"
/>
<div className="absolute right-2 flex items-center gap-2">
{query && (
<button
onClick={handleClear}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
title="Suche loeschen"
>
<X className="w-4 h-4" />
</button>
)}
<button
onClick={handleSearch}
disabled={!query.trim()}
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700
disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
Suchen
</button>
</div>
</div>
{/* Suggestions Dropdown */}
{showDropdown && suggestions.length > 0 && (
<div
ref={dropdownRef}
className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-slate-200
shadow-lg z-50 max-h-80 overflow-y-auto"
>
<div className="p-2">
{query.length === 0 && (
<div className="px-3 py-2 text-xs font-medium text-slate-500 uppercase tracking-wide">
Beliebte Themen
</div>
)}
{suggestions.map((suggestion, index) => (
<button
key={`${suggestion.aufgabentyp}-${suggestion.label}`}
onClick={() => handleSelectSuggestion(suggestion)}
className={`w-full px-3 py-2.5 text-left rounded-lg flex items-center justify-between
transition-colors ${
index === selectedIndex
? 'bg-blue-50 text-blue-700'
: 'hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-3">
<Search className="w-4 h-4 text-slate-400" />
<div>
<div className="font-medium text-slate-800">{suggestion.label}</div>
{suggestion.kategorie && (
<div className="text-xs text-slate-500">{suggestion.kategorie}</div>
)}
</div>
</div>
<span className="text-sm text-slate-400">
{suggestion.count} Dokumente
</span>
</button>
))}
</div>
</div>
)}
{/* Quick Theme Tags */}
{!showDropdown && query.length === 0 && (
<div className="mt-3 flex flex-wrap gap-2">
<span className="text-sm text-slate-500">Vorschlaege:</span>
{POPULAR_THEMES.slice(0, 5).map((theme) => (
<button
key={theme.aufgabentyp}
onClick={() => handleSelectSuggestion(theme)}
className="px-3 py-1 text-sm bg-slate-100 text-slate-700 rounded-full
hover:bg-slate-200 transition-colors"
>
{theme.label}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,516 @@
'use client'
/**
* Abitur-Archiv - Hauptseite
* Zentralabitur-Materialien 2021-2025 mit erweiterter Themensuche
*/
import { useState, useEffect, useCallback } from 'react'
import {
FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search,
X, Loader2, Grid, List, LayoutGrid, BarChart3, Archive
} from 'lucide-react'
import type { AbiturDokument, AbiturDocsResponse } from '@/lib/education/abitur-docs-types'
import {
formatFileSize,
FAECHER,
JAHRE,
BUNDESLAENDER,
NIVEAUS,
TYPEN,
} from '@/lib/education/abitur-docs-types'
import type { ViewMode, ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
import { ThemenSuche } from './components/ThemenSuche'
import { DokumentCard } from './components/DokumentCard'
import { FullscreenViewer } from './components/FullscreenViewer'
export default function AbiturArchivPage() {
// Documents state
const [documents, setDocuments] = useState<AbiturDokument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Pagination
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const limit = 20
// View mode
const [viewMode, setViewMode] = useState<ViewMode>('grid')
// Filters
const [filterOpen, setFilterOpen] = useState(false)
const [filterFach, setFilterFach] = useState<string>('')
const [filterJahr, setFilterJahr] = useState<string>('')
const [filterBundesland, setFilterBundesland] = useState<string>('')
const [filterNiveau, setFilterNiveau] = useState<string>('')
const [filterTyp, setFilterTyp] = useState<string>('')
// Theme search
const [searchQuery, setSearchQuery] = useState<string>('')
const [themes, setThemes] = useState<ThemaSuggestion[]>([])
// Modal
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
// Stats
const [stats, setStats] = useState({ total: 0, indexed: 0, faecher: 0 })
// Fetch documents
const fetchDocuments = useCallback(async () => {
setLoading(true)
setError(null)
const params = new URLSearchParams()
params.set('page', page.toString())
params.set('limit', limit.toString())
if (filterFach) params.set('fach', filterFach)
if (filterJahr) params.set('jahr', filterJahr)
if (filterBundesland) params.set('bundesland', filterBundesland)
if (filterNiveau) params.set('niveau', filterNiveau)
if (filterTyp) params.set('typ', filterTyp)
if (searchQuery) params.set('thema', searchQuery)
try {
const response = await fetch(`/api/education/abitur-archiv?${params.toString()}`)
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
const data = await response.json()
setDocuments(data.documents || [])
setTotalPages(data.total_pages || 1)
setTotal(data.total || 0)
setThemes(data.themes || [])
// Update stats
const indexed = (data.documents || []).filter((d: AbiturDokument) => d.status === 'indexed').length
const uniqueFaecher = new Set((data.documents || []).map((d: AbiturDokument) => d.fach)).size
setStats({ total: data.total || 0, indexed, faecher: uniqueFaecher })
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery])
useEffect(() => {
fetchDocuments()
}, [fetchDocuments])
const clearFilters = () => {
setFilterFach('')
setFilterJahr('')
setFilterBundesland('')
setFilterNiveau('')
setFilterTyp('')
setSearchQuery('')
setPage(1)
}
const handleSearch = (query: string) => {
setSearchQuery(query)
setPage(1)
}
const handleClearSearch = () => {
setSearchQuery('')
setPage(1)
}
const handleDownload = (doc: AbiturDokument) => {
const link = window.document.createElement('a')
link.href = doc.file_path
link.download = doc.dateiname
link.click()
}
const handleAddToKlausur = (doc: AbiturDokument) => {
// Navigate to klausur-korrektur with document reference
const params = new URLSearchParams()
params.set('archiv_doc_id', doc.id)
params.set('aufgabentyp', doc.typ === 'erwartungshorizont' ? 'vorlage' : 'aufgabe')
window.location.href = `/education/klausur-korrektur?${params.toString()}`
}
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp || searchQuery
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
<Archive className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Abitur-Archiv</h1>
<p className="text-sm text-slate-500">Zentralabitur-Materialien 2021-2025</p>
</div>
</div>
{/* Stats */}
<div className="hidden md:flex items-center gap-6">
<div className="text-center">
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
<div className="text-xs text-slate-500">Dokumente</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{stats.indexed}</div>
<div className="text-xs text-slate-500">Indexiert</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{stats.faecher}</div>
<div className="text-xs text-slate-500">Faecher</div>
</div>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
{/* Theme Search */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<ThemenSuche
onSearch={handleSearch}
onClear={handleClearSearch}
/>
</div>
{/* Filter Bar */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<button
onClick={() => setFilterOpen(!filterOpen)}
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
filterOpen || hasActiveFilters
? 'bg-purple-100 text-purple-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<Filter className="w-4 h-4" />
Filter
{hasActiveFilters && (
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
{[filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery].filter(Boolean).length}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<X className="w-4 h-4" />
Filter zuruecksetzen
</button>
)}
</div>
<div className="flex items-center gap-3">
{/* Results count */}
<span className="text-sm text-slate-500">
{total} Treffer
</span>
{/* View Mode Toggle */}
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'grid' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
title="Raster-Ansicht"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
title="Listen-Ansicht"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Filter Dropdowns */}
{filterOpen && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
{/* Fach */}
<div>
<label className="block text-xs text-slate-500 mb-1">Fach</label>
<select
value={filterFach}
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Faecher</option>
{FAECHER.map(f => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</div>
{/* Jahr */}
<div>
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
<select
value={filterJahr}
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Jahre</option>
{JAHRE.map(j => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
{/* Bundesland */}
<div>
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
<select
value={filterBundesland}
onChange={(e) => { setFilterBundesland(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Bundeslaender</option>
{BUNDESLAENDER.map(b => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</div>
{/* Niveau */}
<div>
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
<select
value={filterNiveau}
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Niveaus</option>
{NIVEAUS.map(n => (
<option key={n.id} value={n.id}>{n.label}</option>
))}
</select>
</div>
{/* Typ */}
<div>
<label className="block text-xs text-slate-500 mb-1">Typ</label>
<select
value={filterTyp}
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Typen</option>
{TYPEN.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Active Search Query Display */}
{searchQuery && (
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg">
<Search className="w-4 h-4 text-blue-600" />
<span className="text-sm text-blue-700">
Suche: <strong>{searchQuery}</strong>
</span>
<button
onClick={handleClearSearch}
className="ml-auto text-blue-600 hover:text-blue-800"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Document Display */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
</div>
) : error ? (
<div className="text-center py-16 text-red-600">
<p>{error}</p>
<button
onClick={() => fetchDocuments()}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Erneut versuchen
</button>
</div>
) : documents.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Keine Dokumente gefunden</p>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Filter zuruecksetzen
</button>
)}
</div>
) : viewMode === 'grid' ? (
/* Grid View */
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{documents.map((doc) => (
<DokumentCard
key={doc.id}
document={doc}
onPreview={setSelectedDocument}
onDownload={handleDownload}
onAddToKlausur={handleAddToKlausur}
/>
))}
</div>
</div>
) : (
/* List View */
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => {
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
return (
<tr
key={doc.id}
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
onClick={() => setSelectedDocument(doc)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-red-500" />
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
{doc.dateiname}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className="capitalize">{fachLabel}</span>
</td>
<td className="px-4 py-3 text-center">{doc.jahr}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.niveau === 'eA'
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-700'
}`}>
{doc.niveau}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.typ === 'erwartungshorizont'
? 'bg-orange-100 text-orange-700'
: 'bg-purple-100 text-purple-700'
}`}>
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
</span>
</td>
<td className="px-4 py-3 text-right text-slate-500">
{formatFileSize(doc.file_size)}
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.status === 'indexed'
? 'bg-green-100 text-green-700'
: doc.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setSelectedDocument(doc)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
title="Vorschau"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDownload(doc)}
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
{/* Pagination */}
{documents.length > 0 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<div className="text-sm text-slate-500">
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-slate-600">
Seite {page} von {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
{/* Fullscreen Viewer Modal */}
<FullscreenViewer
document={selectedDocument}
onClose={() => setSelectedDocument(null)}
onAddToKlausur={handleAddToKlausur}
/>
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { Suspense } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
import { GraduationCap } from 'lucide-react'
function LoadingFallback() {
return (
<div className="space-y-6">
{/* Header Skeleton */}
<div className="flex items-center justify-between">
<div className="h-12 w-80 bg-slate-200 rounded-xl animate-pulse" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 w-10 bg-slate-200 rounded-lg animate-pulse" />
))}
</div>
</div>
{/* Phase Timeline Skeleton */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="h-4 w-24 bg-slate-200 rounded mb-4 animate-pulse" />
<div className="flex gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-200 rounded-full animate-pulse" />
{i < 5 && <div className="w-8 h-1 bg-slate-200 animate-pulse" />}
</div>
))}
</div>
</div>
{/* Stats Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white border border-slate-200 rounded-xl p-4">
<div className="h-4 w-16 bg-slate-200 rounded mb-2 animate-pulse" />
<div className="h-8 w-12 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
{/* Content Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
</div>
</div>
)
}
export default function CompanionPage() {
const moduleInfo = getModuleByHref('/education/companion')
return (
<div className="space-y-6">
{/* Page Purpose Header */}
{moduleInfo && (
<PagePurpose
title={moduleInfo.module.name}
purpose={moduleInfo.module.purpose}
audience={moduleInfo.module.audience}
collapsible={true}
defaultCollapsed={true}
/>
)}
{/* Main Companion Dashboard */}
<Suspense fallback={<LoadingFallback />}>
<CompanionDashboard />
</Suspense>
</div>
)
}

View File

@@ -5,9 +5,10 @@
* Bildungsquellen und Crawler-Verwaltung
*/
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen } from 'lucide-react'
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen, FolderOpen } from 'lucide-react'
import { DokumenteTab } from '@/components/education/DokumenteTab'
interface DataSource {
id: string
@@ -42,7 +43,12 @@ const DATA_SOURCES: DataSource[] = [
export default function EduSearchPage() {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'search' | 'sources' | 'crawler'>('search')
const [activeTab, setActiveTab] = useState<'search' | 'documents' | 'sources' | 'crawler'>('search')
const [documentCount, setDocumentCount] = useState<number>(0)
const handleDocumentCountChange = useCallback((count: number) => {
setDocumentCount(count)
}, [])
return (
<div className="space-y-6">
@@ -95,6 +101,22 @@ export default function EduSearchPage() {
<Search className="w-4 h-4 inline mr-2" />
Suche
</button>
<button
onClick={() => setActiveTab('documents')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'documents'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<FolderOpen className="w-4 h-4 inline mr-2" />
Dokumente
{documentCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-white/20 rounded text-xs">
{documentCount}
</span>
)}
</button>
<button
onClick={() => setActiveTab('sources')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
@@ -162,6 +184,11 @@ export default function EduSearchPage() {
</div>
)}
{/* Documents Tab */}
{activeTab === 'documents' && (
<DokumenteTab onDocumentCountChange={handleDocumentCountChange} />
)}
{/* Sources Tab */}
{activeTab === 'sources' && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
@@ -281,9 +308,9 @@ export default function EduSearchPage() {
<div className="font-medium text-slate-900">Zeugnisse-Crawler</div>
<div className="text-sm text-slate-500">Zeugnis-Strukturen verwalten</div>
</a>
<a href="/education/training" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
<div className="font-medium text-slate-900">Training</div>
<div className="text-sm text-slate-500">Schulungsmodule verwalten</div>
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
<div className="font-medium text-slate-900">RAG Pipeline</div>
<div className="text-sm text-slate-500">Bildungsdokumente indexieren</div>
</a>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,484 @@
'use client'
/**
* Fairness-Dashboard
*
* Visualizes grading consistency and identifies outliers for review.
*/
import { useState, useEffect, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
// Same-origin proxy to avoid CORS issues
const API_BASE = '/klausur-api'
const GRADE_LABELS: Record<number, string> = {
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
3: '5+', 2: '5', 1: '5-', 0: '6'
}
const CRITERION_COLORS: Record<string, string> = {
rechtschreibung: '#dc2626',
grammatik: '#2563eb',
inhalt: '#16a34a',
struktur: '#9333ea',
stil: '#ea580c',
}
interface FairnessData {
klausur_id: string
students_count: number
graded_count: number
statistics: {
average_grade: number
average_raw_points: number
min_grade: number
max_grade: number
spread: number
standard_deviation: number
}
criteria_breakdown: Record<string, {
average: number
min: number
max: number
count: number
}>
outliers: Array<{
student_id: string
student_name: string
grade_points: number
deviation: number
direction: 'above' | 'below'
}>
fairness_score: number
warnings: string[]
recommendation: string
}
interface Klausur {
id: string
title: string
subject: string
students: Array<{
id: string
student_name: string
anonym_id: string
grade_points: number
criteria_scores: Record<string, { score: number }>
}>
}
export default function FairnessDashboardPage() {
const params = useParams()
const router = useRouter()
const klausurId = params.klausurId as string
const [klausur, setKlausur] = useState<Klausur | null>(null)
const [fairnessData, setFairnessData] = useState<FairnessData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
setLoading(true)
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
if (klausurRes.ok) {
setKlausur(await klausurRes.json())
}
const fairnessRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/fairness`)
if (fairnessRes.ok) {
setFairnessData(await fairnessRes.json())
} else {
const errData = await fairnessRes.json()
setError(errData.detail || 'Fehler beim Laden der Fairness-Analyse')
}
setError(null)
} catch (err) {
console.error('Failed to fetch data:', err)
setError('Fehler beim Laden der Daten')
} finally {
setLoading(false)
}
}, [klausurId])
useEffect(() => {
fetchData()
}, [fetchData])
const getGradeDistribution = () => {
if (!klausur?.students) return []
const distribution: Record<number, number> = {}
for (let i = 0; i <= 15; i++) {
distribution[i] = 0
}
klausur.students.forEach(s => {
if (s.grade_points >= 0 && s.grade_points <= 15) {
distribution[s.grade_points]++
}
})
return Object.entries(distribution).map(([grade, count]) => ({
grade: parseInt(grade),
count,
label: GRADE_LABELS[parseInt(grade)] || grade
}))
}
const gradeDistribution = getGradeDistribution()
const maxCount = Math.max(...gradeDistribution.map(d => d.count), 1)
if (loading) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50">
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Header */}
<div className="bg-white border-b border-slate-200 -mx-4 -mt-6 px-4 py-4 mb-6">
<div className="flex items-center justify-between">
<Link
href={`/education/klausur-korrektur/${klausurId}`}
className="text-purple-600 hover:text-purple-800 flex items-center gap-1 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Klausur
</Link>
<div className="text-sm text-slate-500">
{fairnessData?.graded_count || 0} von {fairnessData?.students_count || 0} Arbeiten bewertet
</div>
</div>
</div>
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-800">Fairness-Analyse</h1>
<p className="text-sm text-slate-500">{klausur?.title || ''}</p>
</div>
{/* Error display */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
{error}
</div>
)}
{fairnessData && (
<div className="space-y-6">
{/* Top Row: Fairness Score + Statistics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Fairness Score Gauge */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Fairness-Score</h3>
<div className="flex items-center justify-center">
<div className="relative w-32 h-32">
<svg className="w-32 h-32 transform -rotate-90">
<circle
cx="64"
cy="64"
r="56"
fill="none"
stroke="#e2e8f0"
strokeWidth="12"
/>
<circle
cx="64"
cy="64"
r="56"
fill="none"
stroke={
fairnessData.fairness_score >= 70 ? '#16a34a' :
fairnessData.fairness_score >= 40 ? '#eab308' : '#dc2626'
}
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={`${(fairnessData.fairness_score / 100) * 352} 352`}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold">{fairnessData.fairness_score}</span>
<span className="text-xs text-slate-500">von 100</span>
</div>
</div>
</div>
<div className={`mt-4 text-center text-sm font-medium ${
fairnessData.fairness_score >= 70 ? 'text-green-600' :
fairnessData.fairness_score >= 40 ? 'text-yellow-600' : 'text-red-600'
}`}>
{fairnessData.recommendation}
</div>
</div>
{/* Statistics */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Statistik</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-slate-600">Durchschnitt</span>
<span className="font-semibold">
{fairnessData.statistics.average_grade} P ({GRADE_LABELS[Math.round(fairnessData.statistics.average_grade)]})
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Minimum</span>
<span className="font-semibold">
{fairnessData.statistics.min_grade} P ({GRADE_LABELS[fairnessData.statistics.min_grade]})
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Maximum</span>
<span className="font-semibold">
{fairnessData.statistics.max_grade} P ({GRADE_LABELS[fairnessData.statistics.max_grade]})
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Spreizung</span>
<span className="font-semibold">{fairnessData.statistics.spread} P</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Standardabweichung</span>
<span className="font-semibold">{fairnessData.statistics.standard_deviation}</span>
</div>
</div>
</div>
{/* Warnings */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Hinweise</h3>
{fairnessData.warnings.length > 0 ? (
<div className="space-y-2">
{fairnessData.warnings.map((warning, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-slate-700">{warning}</span>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 text-green-600">
<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>
<span className="text-sm">Keine Auffaelligkeiten erkannt</span>
</div>
)}
</div>
</div>
{/* Grade Distribution Histogram */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Notenverteilung</h3>
<div className="flex items-end gap-1 h-48">
{gradeDistribution.map(({ grade, count, label }) => (
<div key={grade} className="flex-1 flex flex-col items-center">
<div
className={`w-full rounded-t transition-all ${
count > 0 ? 'bg-purple-500' : 'bg-slate-200'
}`}
style={{ height: `${(count / maxCount) * 160}px`, minHeight: count > 0 ? '8px' : '2px' }}
title={`${count} Arbeiten`}
/>
<div className="text-xs text-slate-500 mt-1 transform -rotate-45 origin-top-left w-6 text-center">
{label}
</div>
{count > 0 && (
<div className="text-xs font-medium text-slate-700 mt-1">{count}</div>
)}
</div>
))}
</div>
<div className="flex justify-between text-xs text-slate-400 mt-6">
<span>6 (0 Punkte)</span>
<span>1+ (15 Punkte)</span>
</div>
</div>
{/* Criteria Breakdown Heatmap */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Kriterien-Vergleich</h3>
<div className="space-y-3">
{Object.entries(fairnessData.criteria_breakdown).map(([criterion, data]) => {
const color = CRITERION_COLORS[criterion] || '#6b7280'
const range = data.max - data.min
return (
<div key={criterion} className="flex items-center gap-4">
<div className="w-32 flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} />
<span className="text-sm font-medium capitalize">{criterion}</span>
</div>
<div className="flex-1">
<div className="relative h-6 bg-slate-100 rounded-full overflow-hidden">
<div
className="absolute h-full opacity-30"
style={{
backgroundColor: color,
left: `${data.min}%`,
width: `${range}%`
}}
/>
<div
className="absolute top-0 bottom-0 w-1 rounded"
style={{
backgroundColor: color,
left: `${data.average}%`
}}
/>
</div>
</div>
<div className="w-24 text-right">
<span className="text-sm font-semibold">{data.average}%</span>
<span className="text-xs text-slate-400 ml-1">avg</span>
</div>
<div className="w-20 text-right text-xs text-slate-500">
{data.min}% - {data.max}%
</div>
</div>
)
})}
</div>
</div>
{/* Outliers List */}
{fairnessData.outliers.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">
Ausreisser ({fairnessData.outliers.length})
</h3>
<div className="space-y-2">
{fairnessData.outliers.map((outlier) => (
<div
key={outlier.student_id}
className={`flex items-center justify-between p-3 rounded-lg border ${
outlier.direction === 'above'
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold ${
outlier.direction === 'above' ? 'bg-green-500' : 'bg-red-500'
}`}>
{outlier.direction === 'above' ? '↑' : '↓'}
</div>
<div>
<div className="font-medium">{outlier.student_name}</div>
<div className="text-sm text-slate-500">
{outlier.grade_points} Punkte ({GRADE_LABELS[outlier.grade_points]}) -
Abweichung: {outlier.deviation} Punkte {outlier.direction === 'above' ? 'ueber' : 'unter'} Durchschnitt
</div>
</div>
</div>
<Link
href={`/education/klausur-korrektur/${klausurId}/${outlier.student_id}`}
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm hover:bg-slate-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Pruefen
</Link>
</div>
))}
</div>
</div>
)}
{/* All Students Table */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">
Alle Arbeiten ({klausur?.students.length || 0})
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-600">Student</th>
<th className="text-center py-2 px-3 font-medium text-slate-600">Note</th>
<th className="text-center py-2 px-3 font-medium text-slate-600">RS</th>
<th className="text-center py-2 px-3 font-medium text-slate-600">Gram</th>
<th className="text-center py-2 px-3 font-medium text-slate-600">Inhalt</th>
<th className="text-center py-2 px-3 font-medium text-slate-600">Struktur</th>
<th className="text-center py-2 px-3 font-medium text-slate-600">Stil</th>
<th className="text-right py-2 px-3 font-medium text-slate-600">Aktion</th>
</tr>
</thead>
<tbody>
{klausur?.students
.sort((a, b) => b.grade_points - a.grade_points)
.map((student) => {
const isOutlier = fairnessData.outliers.some(o => o.student_id === student.id)
const outlierInfo = fairnessData.outliers.find(o => o.student_id === student.id)
return (
<tr
key={student.id}
className={`border-b border-slate-100 ${
isOutlier
? outlierInfo?.direction === 'above'
? 'bg-green-50'
: 'bg-red-50'
: ''
}`}
>
<td className="py-2 px-3">
<div className="font-medium">{student.anonym_id}</div>
</td>
<td className="py-2 px-3 text-center">
<span className="font-bold">
{student.grade_points} ({GRADE_LABELS[student.grade_points] || '-'})
</span>
</td>
<td className="py-2 px-3 text-center">
{student.criteria_scores?.rechtschreibung?.score ?? '-'}%
</td>
<td className="py-2 px-3 text-center">
{student.criteria_scores?.grammatik?.score ?? '-'}%
</td>
<td className="py-2 px-3 text-center">
{student.criteria_scores?.inhalt?.score ?? '-'}%
</td>
<td className="py-2 px-3 text-center">
{student.criteria_scores?.struktur?.score ?? '-'}%
</td>
<td className="py-2 px-3 text-center">
{student.criteria_scores?.stil?.score ?? '-'}%
</td>
<td className="py-2 px-3 text-right">
<Link
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
className="text-purple-600 hover:text-purple-800 text-sm"
>
Bearbeiten
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,489 @@
'use client'
/**
* Klausur Detail Page - Student List
*
* Shows all student works for a specific Klausur with upload capability.
* Allows navigation to individual correction workspaces.
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Klausur, StudentWork } from '../types'
// Same-origin proxy to avoid CORS issues
const API_BASE = '/klausur-api'
const statusConfig: Record<string, { color: string; label: string; bg: string }> = {
UPLOADED: { color: 'text-gray-600', label: 'Hochgeladen', bg: 'bg-gray-100' },
OCR_PROCESSING: { color: 'text-yellow-600', label: 'OCR laeuft', bg: 'bg-yellow-100' },
OCR_COMPLETE: { color: 'text-blue-600', label: 'OCR fertig', bg: 'bg-blue-100' },
ANALYZING: { color: 'text-purple-600', label: 'Analyse', bg: 'bg-purple-100' },
FIRST_EXAMINER: { color: 'text-orange-600', label: 'Erstkorrektur', bg: 'bg-orange-100' },
SECOND_EXAMINER: { color: 'text-cyan-600', label: 'Zweitkorrektur', bg: 'bg-cyan-100' },
COMPLETED: { color: 'text-green-600', label: 'Fertig', bg: 'bg-green-100' },
ERROR: { color: 'text-red-600', label: 'Fehler', bg: 'bg-red-100' },
}
export default function KlausurDetailPage() {
const params = useParams()
const router = useRouter()
const klausurId = params.klausurId as string
const [klausur, setKlausur] = useState<Klausur | null>(null)
const [students, setStudents] = useState<StudentWork[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [exporting, setExporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const fetchKlausur = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
if (res.ok) {
const data = await res.json()
setKlausur(data)
} else if (res.status === 404) {
setError('Klausur nicht gefunden')
}
} catch (err) {
console.error('Failed to fetch klausur:', err)
setError('Verbindung fehlgeschlagen')
}
}, [klausurId])
const fetchStudents = useCallback(async () => {
try {
setLoading(true)
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`)
if (res.ok) {
const data = await res.json()
setStudents(Array.isArray(data) ? data : data.students || [])
setError(null)
}
} catch (err) {
console.error('Failed to fetch students:', err)
setError('Fehler beim Laden der Arbeiten')
} finally {
setLoading(false)
}
}, [klausurId])
useEffect(() => {
fetchKlausur()
fetchStudents()
}, [fetchKlausur, fetchStudents])
const exportOverviewPDF = async () => {
try {
setExporting(true)
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/overview`)
if (res.ok) {
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `Notenuebersicht_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} else {
setError('Fehler beim PDF-Export')
}
} catch (err) {
console.error('Failed to export overview PDF:', err)
setError('Fehler beim PDF-Export')
} finally {
setExporting(false)
}
}
const exportAllGutachtenPDF = async () => {
try {
setExporting(true)
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/all-gutachten`)
if (res.ok) {
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `Alle_Gutachten_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} else {
setError('Fehler beim PDF-Export')
}
} catch (err) {
console.error('Failed to export all gutachten PDF:', err)
setError('Fehler beim PDF-Export')
} finally {
setExporting(false)
}
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setUploading(true)
setUploadProgress(0)
setError(null)
const totalFiles = files.length
let uploadedCount = 0
for (const file of Array.from(files)) {
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
const errorData = await res.json()
console.error(`Failed to upload ${file.name}:`, errorData)
}
uploadedCount++
setUploadProgress(Math.round((uploadedCount / totalFiles) * 100))
} catch (err) {
console.error(`Failed to upload ${file.name}:`, err)
}
}
setUploading(false)
setUploadProgress(0)
fetchStudents()
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleDeleteStudent = async (studentId: string) => {
if (!confirm('Studentenarbeit wirklich loeschen?')) return
try {
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}`, {
method: 'DELETE',
})
if (res.ok) {
setStudents(prev => prev.filter(s => s.id !== studentId))
} else {
setError('Fehler beim Loeschen')
}
} catch (err) {
console.error('Failed to delete student:', err)
setError('Fehler beim Loeschen')
}
}
const getGradeDisplay = (student: StudentWork) => {
if (student.grade_points === undefined || student.grade_points === null) {
return { points: '-', label: '-' }
}
const labels: Record<number, string> = {
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
3: '5+', 2: '5', 1: '5-', 0: '6'
}
return {
points: student.grade_points.toString(),
label: labels[student.grade_points] || '-'
}
}
const stats = {
total: students.length,
completed: students.filter(s => s.status === 'COMPLETED').length,
inProgress: students.filter(s => ['FIRST_EXAMINER', 'SECOND_EXAMINER', 'ANALYZING'].includes(s.status)).length,
pending: students.filter(s => ['UPLOADED', 'OCR_PROCESSING', 'OCR_COMPLETE'].includes(s.status)).length,
avgGrade: students.filter(s => s.grade_points !== undefined && s.grade_points !== null)
.reduce((sum, s, _, arr) => sum + (s.grade_points || 0) / arr.length, 0).toFixed(1),
}
if (loading && !klausur) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50">
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Breadcrumb */}
<div className="mb-4">
<Link
href="/education/klausur-korrektur"
className="text-purple-600 hover:text-purple-800 flex items-center gap-1 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Uebersicht
</Link>
</div>
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-800">{klausur?.title || 'Klausur'}</h1>
<p className="text-sm text-slate-500">{klausur?.subject} - {klausur?.year} | {students.length} Arbeiten</p>
</div>
{/* Error display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-800">{error}</span>
<button onClick={() => setError(null)} className="ml-auto text-red-600 hover:text-red-800">
<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>
)}
{/* Statistics Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
<div className="text-sm text-slate-500">Gesamt</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
<div className="text-sm text-slate-500">Fertig</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-orange-600">{stats.inProgress}</div>
<div className="text-sm text-slate-500">In Arbeit</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
<div className="text-sm text-slate-500">Ausstehend</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats.avgGrade}</div>
<div className="text-sm text-slate-500">Durchschnitt Note</div>
</div>
</div>
{/* Fairness Analysis Button */}
{stats.completed >= 2 && (
<div className="mb-6 flex flex-wrap gap-3">
<Link
href={`/education/klausur-korrektur/${klausurId}/fairness`}
className="inline-flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all shadow-sm"
>
<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>
Fairness-Analyse oeffnen
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
{stats.completed} bewertet
</span>
</Link>
<button
onClick={exportOverviewPDF}
disabled={exporting}
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
{exporting ? 'Exportiere...' : 'Notenuebersicht PDF'}
</button>
<button
onClick={exportAllGutachtenPDF}
disabled={exporting}
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
>
<svg className="w-5 h-5" 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>
{exporting ? 'Exportiere...' : 'Alle Gutachten PDF'}
</button>
</div>
)}
{/* Upload Section */}
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten hochladen</h2>
<p className="text-sm text-slate-500">PDF oder Bilder (JPG, PNG) der gescannten Arbeiten</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileUpload}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className={`px-4 py-2 rounded-lg flex items-center gap-2 cursor-pointer ${
uploading
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{uploadProgress}%
</>
) : (
<>
<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>
Dateien hochladen
</>
)}
</label>
</div>
{uploading && (
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</div>
{/* Students List */}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="p-4 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten ({students.length})</h2>
</div>
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600"></div>
</div>
) : students.length === 0 ? (
<div className="p-8 text-center text-slate-500">
<svg className="mx-auto h-12 w-12 text-slate-300 mb-3" 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>
<p>Noch keine Arbeiten hochgeladen</p>
<p className="text-sm">Laden Sie gescannte PDFs oder Bilder hoch</p>
</div>
) : (
<div className="divide-y divide-slate-200">
{students.map((student, index) => {
const grade = getGradeDisplay(student)
const status = statusConfig[student.status] || statusConfig.UPLOADED
return (
<div
key={student.id}
className="p-4 hover:bg-slate-50 flex items-center gap-4"
>
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-600">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 truncate">
{student.anonym_id || `Arbeit ${index + 1}`}
</div>
<div className="text-sm text-slate-500 flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${status.bg} ${status.color}`}>
{status.label}
</span>
</div>
</div>
<div className="text-center w-20">
<div className="text-lg font-bold text-slate-800">{grade.points}</div>
<div className="text-xs text-slate-500">{grade.label}</div>
</div>
<div className="w-24">
{student.criteria_scores && Object.keys(student.criteria_scores).length > 0 ? (
<div className="flex gap-1">
{['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].map(criterion => (
<div
key={criterion}
className={`h-2 flex-1 rounded-full ${
student.criteria_scores[criterion] !== undefined
? 'bg-green-500'
: 'bg-slate-200'
}`}
title={criterion}
/>
))}
</div>
) : (
<div className="text-xs text-slate-400">Keine Bewertung</div>
)}
</div>
<div className="flex items-center gap-2">
<Link
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
className="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
>
Korrigieren
</Link>
<button
onClick={() => handleDeleteStudent(student.id)}
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg"
title="Loeschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Fairness Check Button */}
{students.filter(s => s.status === 'COMPLETED').length >= 3 && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-blue-800">Fairness-Check verfuegbar</h3>
<p className="text-sm text-blue-600">
Pruefen Sie die Bewertungen auf Konsistenz und Fairness
</p>
</div>
<Link
href={`/education/klausur-korrektur/${klausurId}/fairness`}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Fairness-Check starten
</Link>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,281 @@
'use client'
/**
* AnnotationLayer
*
* SVG overlay component for displaying and creating annotations on documents.
* Renders positioned rectangles with color-coding by annotation type.
*/
import { useState, useRef, useCallback } from 'react'
import type { Annotation, AnnotationType, AnnotationPosition } from '../types'
import { ANNOTATION_COLORS } from '../types'
interface AnnotationLayerProps {
annotations: Annotation[]
selectedTool: AnnotationType | null
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
onSelectAnnotation: (annotation: Annotation) => void
selectedAnnotationId?: string
disabled?: boolean
}
export default function AnnotationLayer({
annotations,
selectedTool,
onCreateAnnotation,
onSelectAnnotation,
selectedAnnotationId,
disabled = false,
}: AnnotationLayerProps) {
const svgRef = useRef<SVGSVGElement>(null)
const [isDrawing, setIsDrawing] = useState(false)
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null)
const [currentRect, setCurrentRect] = useState<AnnotationPosition | null>(null)
// Convert mouse position to percentage
const getPercentPosition = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
if (!svgRef.current) return null
const rect = svgRef.current.getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * 100
const y = ((e.clientY - rect.top) / rect.height) * 100
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }
}, [])
// Handle mouse down - start drawing
const handleMouseDown = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (disabled || !selectedTool) return
const pos = getPercentPosition(e)
if (!pos) return
setIsDrawing(true)
setStartPos(pos)
setCurrentRect({ x: pos.x, y: pos.y, width: 0, height: 0 })
},
[disabled, selectedTool, getPercentPosition]
)
// Handle mouse move - update rectangle
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (!isDrawing || !startPos) return
const pos = getPercentPosition(e)
if (!pos) return
const x = Math.min(startPos.x, pos.x)
const y = Math.min(startPos.y, pos.y)
const width = Math.abs(pos.x - startPos.x)
const height = Math.abs(pos.y - startPos.y)
setCurrentRect({ x, y, width, height })
},
[isDrawing, startPos, getPercentPosition]
)
// Handle mouse up - finish drawing
const handleMouseUp = useCallback(() => {
if (!isDrawing || !currentRect || !selectedTool) {
setIsDrawing(false)
setStartPos(null)
setCurrentRect(null)
return
}
// Only create annotation if rectangle is large enough (min 1% x 0.5%)
if (currentRect.width > 1 && currentRect.height > 0.5) {
onCreateAnnotation(currentRect, selectedTool)
}
setIsDrawing(false)
setStartPos(null)
setCurrentRect(null)
}, [isDrawing, currentRect, selectedTool, onCreateAnnotation])
// Handle clicking on existing annotation
const handleAnnotationClick = useCallback(
(e: React.MouseEvent, annotation: Annotation) => {
e.stopPropagation()
onSelectAnnotation(annotation)
},
[onSelectAnnotation]
)
return (
<svg
ref={svgRef}
className={`absolute inset-0 w-full h-full ${
selectedTool && !disabled ? 'cursor-crosshair' : 'cursor-default'
}`}
style={{ pointerEvents: disabled ? 'none' : 'auto' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* SVG Defs for patterns */}
<defs>
{/* Wavy pattern for Rechtschreibung errors */}
<pattern id="wavyPattern" patternUnits="userSpaceOnUse" width="10" height="4">
<path
d="M0 2 Q 2.5 0, 5 2 T 10 2"
stroke="#dc2626"
strokeWidth="1.5"
fill="none"
/>
</pattern>
{/* Straight underline pattern for Grammatik errors */}
<pattern id="straightPattern" patternUnits="userSpaceOnUse" width="6" height="3">
<line x1="0" y1="1.5" x2="6" y2="1.5" stroke="#2563eb" strokeWidth="1.5" />
</pattern>
</defs>
{/* Existing annotations */}
{annotations.map((annotation) => {
const isSelected = annotation.id === selectedAnnotationId
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
const isRS = annotation.type === 'rechtschreibung'
const isGram = annotation.type === 'grammatik'
return (
<g key={annotation.id} onClick={(e) => handleAnnotationClick(e, annotation)}>
{/* Background rectangle - different styles for RS/Gram */}
{isRS || isGram ? (
<>
{/* Light highlight background */}
<rect
x={`${annotation.position.x}%`}
y={`${annotation.position.y}%`}
width={`${annotation.position.width}%`}
height={`${annotation.position.height}%`}
fill={color}
fillOpacity={isSelected ? 0.25 : 0.15}
className="cursor-pointer hover:fill-opacity-25 transition-all"
/>
{/* Underline - wavy for RS, straight for Gram */}
<rect
x={`${annotation.position.x}%`}
y={`${annotation.position.y + annotation.position.height - 0.5}%`}
width={`${annotation.position.width}%`}
height="0.5%"
fill={isRS ? 'url(#wavyPattern)' : color}
stroke="none"
/>
{/* Border when selected */}
{isSelected && (
<rect
x={`${annotation.position.x}%`}
y={`${annotation.position.y}%`}
width={`${annotation.position.width}%`}
height={`${annotation.position.height}%`}
fill="none"
stroke={color}
strokeWidth={2}
strokeDasharray="4,2"
/>
)}
</>
) : (
/* Standard rectangle for other annotation types */
<rect
x={`${annotation.position.x}%`}
y={`${annotation.position.y}%`}
width={`${annotation.position.width}%`}
height={`${annotation.position.height}%`}
fill={color}
fillOpacity={0.2}
stroke={color}
strokeWidth={isSelected ? 3 : 2}
strokeDasharray={annotation.severity === 'minor' ? '4,2' : undefined}
className="cursor-pointer hover:fill-opacity-30 transition-all"
rx="2"
/>
)}
{/* Type indicator icon (small circle in corner) */}
<circle
cx={`${annotation.position.x}%`}
cy={`${annotation.position.y}%`}
r="6"
fill={color}
stroke="white"
strokeWidth="1"
/>
{/* Type letter */}
<text
x={`${annotation.position.x}%`}
y={`${annotation.position.y}%`}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize="8"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{annotation.type.charAt(0).toUpperCase()}
</text>
{/* Severity indicator (small dot) */}
{annotation.severity === 'critical' && (
<circle
cx={`${annotation.position.x + annotation.position.width}%`}
cy={`${annotation.position.y}%`}
r="4"
fill="#dc2626"
stroke="white"
strokeWidth="1"
/>
)}
{/* Selection indicator */}
{isSelected && (
<>
{/* Corner handles */}
{[
{ cx: annotation.position.x, cy: annotation.position.y },
{ cx: annotation.position.x + annotation.position.width, cy: annotation.position.y },
{ cx: annotation.position.x, cy: annotation.position.y + annotation.position.height },
{
cx: annotation.position.x + annotation.position.width,
cy: annotation.position.y + annotation.position.height,
},
].map((corner, i) => (
<circle
key={i}
cx={`${corner.cx}%`}
cy={`${corner.cy}%`}
r="4"
fill="white"
stroke={color}
strokeWidth="2"
/>
))}
</>
)}
</g>
)
})}
{/* Currently drawing rectangle */}
{currentRect && selectedTool && (
<rect
x={`${currentRect.x}%`}
y={`${currentRect.y}%`}
width={`${currentRect.width}%`}
height={`${currentRect.height}%`}
fill={ANNOTATION_COLORS[selectedTool]}
fillOpacity={0.3}
stroke={ANNOTATION_COLORS[selectedTool]}
strokeWidth={2}
strokeDasharray="5,5"
rx="2"
/>
)}
</svg>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
/**
* AnnotationPanel
*
* Panel for viewing, editing, and managing annotations.
* Shows a list of all annotations with options to edit text, change severity, or delete.
*/
import { useState } from 'react'
import type { Annotation, AnnotationType } from '../types'
import { ANNOTATION_COLORS } from '../types'
interface AnnotationPanelProps {
annotations: Annotation[]
selectedAnnotation: Annotation | null
onSelectAnnotation: (annotation: Annotation | null) => void
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
onDeleteAnnotation: (id: string) => void
}
const SEVERITY_OPTIONS = [
{ value: 'minor', label: 'Leicht', color: '#fbbf24' },
{ value: 'major', label: 'Mittel', color: '#f97316' },
{ value: 'critical', label: 'Schwer', color: '#dc2626' },
] as const
const TYPE_LABELS: Record<AnnotationType, string> = {
rechtschreibung: 'Rechtschreibung',
grammatik: 'Grammatik',
inhalt: 'Inhalt',
struktur: 'Struktur',
stil: 'Stil',
comment: 'Kommentar',
highlight: 'Markierung',
}
export default function AnnotationPanel({
annotations,
selectedAnnotation,
onSelectAnnotation,
onUpdateAnnotation,
onDeleteAnnotation,
}: AnnotationPanelProps) {
const [editingId, setEditingId] = useState<string | null>(null)
const [editText, setEditText] = useState('')
const [editSuggestion, setEditSuggestion] = useState('')
// Group annotations by type
const groupedAnnotations = annotations.reduce(
(acc, ann) => {
if (!acc[ann.type]) {
acc[ann.type] = []
}
acc[ann.type].push(ann)
return acc
},
{} as Record<AnnotationType, Annotation[]>
)
const handleEdit = (annotation: Annotation) => {
setEditingId(annotation.id)
setEditText(annotation.text)
setEditSuggestion(annotation.suggestion || '')
}
const handleSaveEdit = (id: string) => {
onUpdateAnnotation(id, { text: editText, suggestion: editSuggestion || undefined })
setEditingId(null)
setEditText('')
setEditSuggestion('')
}
const handleCancelEdit = () => {
setEditingId(null)
setEditText('')
setEditSuggestion('')
}
if (annotations.length === 0) {
return (
<div className="p-4 text-center text-slate-500">
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
<p className="text-sm">Keine Annotationen vorhanden</p>
<p className="text-xs mt-1">Waehlen Sie ein Werkzeug und markieren Sie Stellen im Dokument</p>
</div>
)
}
return (
<div className="h-full overflow-auto">
{/* Summary */}
<div className="p-3 border-b border-slate-200 bg-slate-50">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-slate-700">{annotations.length} Annotationen</span>
<div className="flex gap-2">
{Object.entries(groupedAnnotations).map(([type, anns]) => (
<span
key={type}
className="px-2 py-0.5 text-xs rounded-full text-white"
style={{ backgroundColor: ANNOTATION_COLORS[type as AnnotationType] }}
>
{anns.length}
</span>
))}
</div>
</div>
</div>
{/* Annotations list by type */}
<div className="divide-y divide-slate-100">
{(Object.entries(groupedAnnotations) as [AnnotationType, Annotation[]][]).map(([type, anns]) => (
<div key={type}>
{/* Type header */}
<div
className="px-3 py-2 text-xs font-semibold text-white"
style={{ backgroundColor: ANNOTATION_COLORS[type] }}
>
{TYPE_LABELS[type]} ({anns.length})
</div>
{/* Annotations in this type */}
{anns.map((annotation) => {
const isSelected = selectedAnnotation?.id === annotation.id
const isEditing = editingId === annotation.id
const severityInfo = SEVERITY_OPTIONS.find((s) => s.value === annotation.severity)
return (
<div
key={annotation.id}
className={`p-3 cursor-pointer transition-colors ${
isSelected ? 'bg-blue-50 border-l-4 border-blue-500' : 'hover:bg-slate-50'
}`}
onClick={() => onSelectAnnotation(isSelected ? null : annotation)}
>
{isEditing ? (
/* Edit mode */
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
placeholder="Kommentar..."
className="w-full p-2 text-sm border border-slate-300 rounded resize-none focus:ring-2 focus:ring-purple-500"
rows={2}
autoFocus
/>
{(type === 'rechtschreibung' || type === 'grammatik') && (
<input
type="text"
value={editSuggestion}
onChange={(e) => setEditSuggestion(e.target.value)}
placeholder="Korrekturvorschlag..."
className="w-full p-2 text-sm border border-slate-300 rounded focus:ring-2 focus:ring-purple-500"
/>
)}
<div className="flex gap-2">
<button
onClick={() => handleSaveEdit(annotation.id)}
className="flex-1 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700"
>
Speichern
</button>
<button
onClick={handleCancelEdit}
className="flex-1 py-1 text-xs bg-slate-200 text-slate-700 rounded hover:bg-slate-300"
>
Abbrechen
</button>
</div>
</div>
) : (
/* View mode */
<>
{/* Severity badge */}
<div className="flex items-center justify-between mb-1">
<span
className="px-1.5 py-0.5 text-[10px] rounded text-white"
style={{ backgroundColor: severityInfo?.color || '#6b7280' }}
>
{severityInfo?.label || 'Unbekannt'}
</span>
<span className="text-[10px] text-slate-400">Seite {annotation.page}</span>
</div>
{/* Text */}
{annotation.text && <p className="text-sm text-slate-700 mb-1">{annotation.text}</p>}
{/* Suggestion */}
{annotation.suggestion && (
<p className="text-xs text-green-700 bg-green-50 px-2 py-1 rounded mb-1">
<span className="font-medium">Korrektur:</span> {annotation.suggestion}
</p>
)}
{/* Actions (only when selected) */}
{isSelected && (
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
<button
onClick={(e) => {
e.stopPropagation()
handleEdit(annotation)
}}
className="flex-1 py-1 text-xs bg-slate-100 text-slate-700 rounded hover:bg-slate-200"
>
Bearbeiten
</button>
{/* Severity buttons */}
<div className="flex gap-1">
{SEVERITY_OPTIONS.map((sev) => (
<button
key={sev.value}
onClick={(e) => {
e.stopPropagation()
onUpdateAnnotation(annotation.id, { severity: sev.value })
}}
className={`w-6 h-6 rounded text-xs text-white font-bold ${
annotation.severity === sev.value ? 'ring-2 ring-offset-1 ring-slate-400' : ''
}`}
style={{ backgroundColor: sev.color }}
title={sev.label}
>
{sev.label[0]}
</button>
))}
</div>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm('Annotation loeschen?')) {
onDeleteAnnotation(annotation.id)
}
}}
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
</>
)}
</div>
)
})}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
/**
* AnnotationToolbar
*
* Toolbar for selecting annotation tools and controlling the document viewer.
*/
import type { AnnotationType } from '../types'
import { ANNOTATION_COLORS } from '../types'
interface AnnotationToolbarProps {
selectedTool: AnnotationType | null
onSelectTool: (tool: AnnotationType | null) => void
zoom: number
onZoomChange: (zoom: number) => void
annotationCounts: Record<AnnotationType, number>
disabled?: boolean
}
const ANNOTATION_TOOLS: { type: AnnotationType; label: string; shortcut: string }[] = [
{ type: 'rechtschreibung', label: 'Rechtschreibung', shortcut: 'R' },
{ type: 'grammatik', label: 'Grammatik', shortcut: 'G' },
{ type: 'inhalt', label: 'Inhalt', shortcut: 'I' },
{ type: 'struktur', label: 'Struktur', shortcut: 'S' },
{ type: 'stil', label: 'Stil', shortcut: 'T' },
{ type: 'comment', label: 'Kommentar', shortcut: 'K' },
]
export default function AnnotationToolbar({
selectedTool,
onSelectTool,
zoom,
onZoomChange,
annotationCounts,
disabled = false,
}: AnnotationToolbarProps) {
const handleToolClick = (type: AnnotationType) => {
if (disabled) return
onSelectTool(selectedTool === type ? null : type)
}
return (
<div className="p-3 border-b border-slate-200 flex items-center justify-between bg-slate-50">
{/* Annotation tools */}
<div className="flex items-center gap-1">
<span className="text-xs text-slate-500 mr-2">Markieren:</span>
{ANNOTATION_TOOLS.map(({ type, label, shortcut }) => {
const isSelected = selectedTool === type
const count = annotationCounts[type] || 0
const color = ANNOTATION_COLORS[type]
return (
<button
key={type}
onClick={() => handleToolClick(type)}
disabled={disabled}
className={`
relative px-2 py-1.5 text-xs rounded border-2 transition-all
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}
${isSelected ? 'ring-2 ring-offset-1 ring-slate-400' : ''}
`}
style={{
borderColor: color,
color: isSelected ? 'white' : color,
backgroundColor: isSelected ? color : 'transparent',
}}
title={`${label} (${shortcut})`}
>
<span className="font-medium">{shortcut}</span>
{count > 0 && (
<span
className="absolute -top-2 -right-2 w-4 h-4 text-[10px] rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: color }}
>
{count > 99 ? '99+' : count}
</span>
)}
</button>
)
})}
{/* Clear selection button */}
{selectedTool && (
<button
onClick={() => onSelectTool(null)}
className="ml-2 px-2 py-1 text-xs text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded"
>
<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>
{/* Mode indicator */}
{selectedTool && (
<div
className="px-3 py-1 text-xs rounded-full text-white"
style={{ backgroundColor: ANNOTATION_COLORS[selectedTool] }}
>
{ANNOTATION_TOOLS.find((t) => t.type === selectedTool)?.label || selectedTool}
</div>
)}
{/* Zoom controls */}
<div className="flex items-center gap-2">
<button
onClick={() => onZoomChange(Math.max(50, zoom - 10))}
disabled={zoom <= 50}
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
title="Verkleinern"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<span className="text-sm w-12 text-center">{zoom}%</span>
<button
onClick={() => onZoomChange(Math.min(200, zoom + 10))}
disabled={zoom >= 200}
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
title="Vergroessern"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
<button
onClick={() => onZoomChange(100)}
className="px-2 py-1 text-xs rounded hover:bg-slate-200"
title="Zuruecksetzen"
>
Fit
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,279 @@
'use client'
/**
* EHSuggestionPanel
*
* Panel for displaying Erwartungshorizont-based suggestions.
* Uses RAG to find relevant passages from the linked EH.
*/
import { useState, useCallback } from 'react'
import type { AnnotationType } from '../types'
import { ANNOTATION_COLORS } from '../types'
interface EHSuggestion {
id: string
eh_id: string
eh_title: string
text: string
score: number
criterion: string
source_chunk_index: number
decrypted: boolean
}
interface EHSuggestionPanelProps {
studentId: string
klausurId: string
hasEH: boolean
apiBase: string
onInsertSuggestion?: (text: string, criterion: string) => void
}
const CRITERIA = [
{ id: 'allgemein', label: 'Alle Kriterien' },
{ id: 'inhalt', label: 'Inhalt', color: '#16a34a' },
{ id: 'struktur', label: 'Struktur', color: '#9333ea' },
{ id: 'stil', label: 'Stil', color: '#ea580c' },
]
export default function EHSuggestionPanel({
studentId,
klausurId,
hasEH,
apiBase,
onInsertSuggestion,
}: EHSuggestionPanelProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [suggestions, setSuggestions] = useState<EHSuggestion[]>([])
const [selectedCriterion, setSelectedCriterion] = useState<string>('allgemein')
const [passphrase, setPassphrase] = useState('')
const [needsPassphrase, setNeedsPassphrase] = useState(false)
const [queryPreview, setQueryPreview] = useState<string | null>(null)
const fetchSuggestions = useCallback(async () => {
try {
setLoading(true)
setError(null)
const res = await fetch(`${apiBase}/api/v1/students/${studentId}/eh-suggestions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
criterion: selectedCriterion === 'allgemein' ? null : selectedCriterion,
passphrase: passphrase || null,
limit: 5,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Fehler beim Laden der Vorschlaege')
}
const data = await res.json()
if (data.needs_passphrase) {
setNeedsPassphrase(true)
setSuggestions([])
setError(data.message)
} else {
setNeedsPassphrase(false)
setSuggestions(data.suggestions || [])
setQueryPreview(data.query_preview || null)
if (data.suggestions?.length === 0) {
setError(data.message || 'Keine passenden Vorschlaege gefunden')
}
}
} catch (err) {
console.error('Failed to fetch EH suggestions:', err)
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}, [apiBase, studentId, selectedCriterion, passphrase])
const handleInsert = (suggestion: EHSuggestion) => {
if (onInsertSuggestion) {
onInsertSuggestion(suggestion.text, suggestion.criterion)
}
}
if (!hasEH) {
return (
<div className="p-4 text-center">
<div className="text-slate-400 mb-4">
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
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>
<p className="text-sm">Kein Erwartungshorizont verknuepft</p>
<p className="text-xs mt-1">Laden Sie einen EH in der RAG-Verwaltung hoch</p>
</div>
<a
href="/ai/rag"
className="inline-block px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
>
Zur RAG-Verwaltung
</a>
</div>
)
}
return (
<div className="h-full flex flex-col">
{/* Criterion selector */}
<div className="p-3 border-b border-slate-200 bg-slate-50">
<div className="flex gap-1 flex-wrap">
{CRITERIA.map((c) => (
<button
key={c.id}
onClick={() => setSelectedCriterion(c.id)}
className={`px-2 py-1 text-xs rounded transition-colors ${
selectedCriterion === c.id
? 'text-white'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
}`}
style={
selectedCriterion === c.id
? { backgroundColor: c.color || '#6366f1' }
: undefined
}
>
{c.label}
</button>
))}
</div>
</div>
{/* Passphrase input (if needed) */}
{needsPassphrase && (
<div className="p-3 bg-yellow-50 border-b border-yellow-200">
<label className="block text-xs font-medium text-yellow-800 mb-1">
EH-Passphrase (verschluesselt)
</label>
<div className="flex gap-2">
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="Passphrase eingeben..."
className="flex-1 px-2 py-1 text-sm border border-yellow-300 rounded focus:ring-2 focus:ring-yellow-500"
/>
<button
onClick={fetchSuggestions}
disabled={!passphrase}
className="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:opacity-50"
>
Laden
</button>
</div>
</div>
)}
{/* Fetch button */}
<div className="p-3 border-b border-slate-200">
<button
onClick={fetchSuggestions}
disabled={loading}
className="w-full py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Lade Vorschlaege...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
EH-Vorschlaege laden
</>
)}
</button>
</div>
{/* Query preview */}
{queryPreview && (
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200">
<div className="text-xs text-slate-500 mb-1">Basierend auf:</div>
<div className="text-xs text-slate-700 italic truncate">&quot;{queryPreview}&quot;</div>
</div>
)}
{/* Error message */}
{error && !needsPassphrase && (
<div className="p-3 bg-red-50 border-b border-red-200">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
{/* Suggestions list */}
<div className="flex-1 overflow-auto">
{suggestions.length === 0 && !loading && !error && (
<div className="p-4 text-center text-slate-400 text-sm">
Klicken Sie auf &quot;EH-Vorschlaege laden&quot; um passende Stellen aus dem Erwartungshorizont zu
finden.
</div>
)}
{suggestions.map((suggestion, idx) => (
<div
key={suggestion.id}
className="p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors"
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500">#{idx + 1}</span>
<span
className="px-1.5 py-0.5 text-[10px] rounded text-white"
style={{
backgroundColor:
ANNOTATION_COLORS[suggestion.criterion as AnnotationType] || '#6366f1',
}}
>
{suggestion.criterion}
</span>
<span className="text-[10px] text-slate-400">
Relevanz: {Math.round(suggestion.score * 100)}%
</span>
</div>
{!suggestion.decrypted && (
<span className="text-[10px] text-yellow-600">Verschluesselt</span>
)}
</div>
{/* Content */}
<p className="text-sm text-slate-700 mb-2 line-clamp-4">{suggestion.text}</p>
{/* Source */}
<div className="flex items-center justify-between text-[10px] text-slate-400">
<span>Quelle: {suggestion.eh_title}</span>
{onInsertSuggestion && suggestion.decrypted && (
<button
onClick={() => handleInsert(suggestion)}
className="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
>
Im Gutachten verwenden
</button>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { default as AnnotationLayer } from './AnnotationLayer'
export { default as AnnotationPanel } from './AnnotationPanel'
export { default as AnnotationToolbar } from './AnnotationToolbar'
export { default as EHSuggestionPanel } from './EHSuggestionPanel'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
// TypeScript Interfaces für Klausur-Korrektur
export interface Klausur {
id: string
title: string
subject: string
year: number
semester: string
modus: 'abitur' | 'vorabitur'
eh_id?: string
created_at: string
student_count?: number
completed_count?: number
status?: 'draft' | 'in_progress' | 'completed'
}
export interface StudentWork {
id: string
klausur_id: string
anonym_id: string
file_path: string
file_type: 'pdf' | 'image'
ocr_text: string
criteria_scores: CriteriaScores
gutachten: string
status: StudentStatus
raw_points: number
grade_points: number
grade_label?: string
created_at: string
examiner_id?: string
second_examiner_id?: string
second_examiner_grade?: number
}
export type StudentStatus =
| 'UPLOADED'
| 'OCR_PROCESSING'
| 'OCR_COMPLETE'
| 'ANALYZING'
| 'FIRST_EXAMINER'
| 'SECOND_EXAMINER'
| 'COMPLETED'
| 'ERROR'
export interface CriteriaScores {
rechtschreibung?: number
grammatik?: number
inhalt?: number
struktur?: number
stil?: number
[key: string]: number | undefined
}
export interface Criterion {
id: string
name: string
weight: number
description?: string
}
export interface GradeInfo {
thresholds: Record<number, number>
labels: Record<number, string>
criteria: Record<string, Criterion>
}
export interface Annotation {
id: string
student_work_id: string
page: number
position: AnnotationPosition
type: AnnotationType
text: string
severity: 'minor' | 'major' | 'critical'
suggestion?: string
created_by: string
created_at: string
role: 'first_examiner' | 'second_examiner'
linked_criterion?: string
}
export interface AnnotationPosition {
x: number // Prozent (0-100)
y: number // Prozent (0-100)
width: number // Prozent (0-100)
height: number // Prozent (0-100)
}
export type AnnotationType =
| 'rechtschreibung'
| 'grammatik'
| 'inhalt'
| 'struktur'
| 'stil'
| 'comment'
| 'highlight'
export interface FairnessAnalysis {
klausur_id: string
student_count: number
average_grade: number
std_deviation: number
spread: number
outliers: OutlierInfo[]
criteria_analysis: Record<string, CriteriaStats>
fairness_score: number
warnings: string[]
}
export interface OutlierInfo {
student_id: string
anonym_id: string
grade_points: number
deviation: number
reason: string
}
export interface CriteriaStats {
min: number
max: number
average: number
std_deviation: number
}
export interface EHSuggestion {
criterion: string
excerpt: string
relevance_score: number
source_chunk_id: string
}
export interface GutachtenSection {
title: string
content: string
evidence_links?: string[]
}
export interface Gutachten {
einleitung: string
hauptteil: string
fazit: string
staerken: string[]
schwaechen: string[]
generated_at?: string
}
// API Response Types
export interface KlausurenResponse {
klausuren: Klausur[]
total: number
}
export interface StudentsResponse {
students: StudentWork[]
total: number
}
export interface AnnotationsResponse {
annotations: Annotation[]
}
// Color mapping for annotation types
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
rechtschreibung: '#dc2626', // Red
grammatik: '#2563eb', // Blue
inhalt: '#16a34a', // Green
struktur: '#9333ea', // Purple
stil: '#ea580c', // Orange
comment: '#6b7280', // Gray
highlight: '#eab308', // Yellow
}
// Status colors
export const STATUS_COLORS: Record<StudentStatus, string> = {
UPLOADED: '#6b7280',
OCR_PROCESSING: '#eab308',
OCR_COMPLETE: '#3b82f6',
ANALYZING: '#8b5cf6',
FIRST_EXAMINER: '#f97316',
SECOND_EXAMINER: '#06b6d4',
COMPLETED: '#22c55e',
ERROR: '#ef4444',
}
export const STATUS_LABELS: Record<StudentStatus, string> = {
UPLOADED: 'Hochgeladen',
OCR_PROCESSING: 'OCR laeuft',
OCR_COMPLETE: 'OCR fertig',
ANALYZING: 'Analyse laeuft',
FIRST_EXAMINER: 'Erstkorrektur',
SECOND_EXAMINER: 'Zweitkorrektur',
COMPLETED: 'Abgeschlossen',
ERROR: 'Fehler',
}

View File

@@ -0,0 +1,181 @@
'use client'
/**
* Zeugnisse-Crawler Page
* Verwaltet Zeugnis-Strukturen und -Vorlagen
*/
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
import { FileText, Upload, Settings, Database, RefreshCw } from 'lucide-react'
export default function ZeugnisseCrawlerPage() {
const moduleInfo = getModuleByHref('/education/zeugnisse-crawler')
return (
<div className="space-y-6">
{moduleInfo && (
<PagePurpose
title={moduleInfo.module.name}
purpose={moduleInfo.module.purpose}
audience={moduleInfo.module.audience}
collapsible={true}
defaultCollapsed={true}
/>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-blue-600">16</div>
<div className="text-sm text-slate-500">Bundeslaender</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-green-600">48</div>
<div className="text-sm text-slate-500">Zeugnis-Vorlagen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-purple-600">12</div>
<div className="text-sm text-slate-500">Schulformen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-orange-600">156</div>
<div className="text-sm text-slate-500">Felder erkannt</div>
</div>
</div>
{/* Main Content */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Zeugnis-Strukturen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Upload Card */}
<div className="border border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-500 hover:bg-blue-50/50 transition-colors cursor-pointer">
<Upload className="w-10 h-10 mx-auto mb-3 text-slate-400" />
<div className="font-medium text-slate-700">Zeugnis hochladen</div>
<div className="text-sm text-slate-500 mt-1">PDF oder Bild</div>
</div>
{/* Niedersachsen */}
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<div className="font-medium text-slate-900">Niedersachsen</div>
<div className="text-xs text-slate-500">12 Vorlagen</div>
</div>
</div>
<div className="flex flex-wrap gap-1">
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">IGS</span>
</div>
</div>
{/* Bayern */}
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<div className="font-medium text-slate-900">Bayern</div>
<div className="text-xs text-slate-500">10 Vorlagen</div>
</div>
</div>
<div className="flex flex-wrap gap-1">
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Realschule</span>
</div>
</div>
{/* NRW */}
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<div className="font-medium text-slate-900">Nordrhein-Westfalen</div>
<div className="text-xs text-slate-500">14 Vorlagen</div>
</div>
</div>
<div className="flex flex-wrap gap-1">
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gesamtschule</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
</div>
</div>
{/* Baden-Württemberg */}
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<div className="font-medium text-slate-900">Baden-Wuerttemberg</div>
<div className="text-xs text-slate-500">8 Vorlagen</div>
</div>
</div>
<div className="flex flex-wrap gap-1">
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
</div>
</div>
{/* Weitere */}
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow bg-slate-50">
<div className="flex items-center gap-3 mb-3">
<Database className="w-8 h-8 text-slate-400" />
<div>
<div className="font-medium text-slate-700">Weitere Bundeslaender</div>
<div className="text-xs text-slate-500">4 Vorlagen</div>
</div>
</div>
<div className="text-sm text-slate-500">
Hessen, Sachsen, Berlin, Hamburg...
</div>
</div>
</div>
</div>
{/* Crawler Section */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<RefreshCw className="w-5 h-5" />
Crawler-Status
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">Schulportal NI</span>
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
</div>
<div className="text-sm text-slate-500">Letzter Crawl: vor 2 Stunden</div>
</div>
<div className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">KMK Vorlagen</span>
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
</div>
<div className="text-sm text-slate-500">Letzter Crawl: vor 1 Tag</div>
</div>
</div>
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
<Settings className="w-5 h-5" />
Verwandte Module
</h3>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
<a href="/education/edu-search" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
<div className="font-medium text-slate-900">Education Search</div>
<div className="text-sm text-slate-500">Bildungsdokumente durchsuchen</div>
</a>
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
<div className="font-medium text-slate-900">RAG Pipeline</div>
<div className="text-sm text-slate-500">Dokumente indexieren</div>
</a>
</div>
</div>
</div>
)
}

View File

@@ -12,6 +12,7 @@
import { useState, useEffect, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
// ============================================================================
// Types
@@ -364,6 +365,9 @@ export default function CICDPage() {
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="ci-cd" />
{/* Messages */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">

View File

@@ -16,6 +16,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
interface Component {
type: string
@@ -71,6 +72,7 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== SECURITY =====
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
// ===== COMMUNICATION =====
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
@@ -110,6 +112,7 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== CI/CD & VERSION CONTROL =====
{ type: 'service', name: 'Woodpecker CI', version: '2.x', category: 'cicd', port: '8082', description: 'Self-hosted CI/CD Pipeline (Drone Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/woodpecker-ci/woodpecker' },
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
{ type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' },
// ===== DEVELOPMENT =====
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
@@ -216,6 +219,11 @@ const NODE_PACKAGES: Component[] = [
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
{ type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' },
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
]
// Unity packages (Breakpilot Drive game engine)
@@ -422,6 +430,9 @@ export default function SBOMPage() {
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="sbom" />
{/* Wizard Link */}
<div className="mb-6 flex justify-end">
<Link

View File

@@ -9,6 +9,7 @@
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
interface ToolStatus {
name: string
@@ -304,6 +305,9 @@ export default function SecurityDashboardPage() {
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="security" />
{/* Header with Status */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex justify-between items-center mb-6">

View File

@@ -3,18 +3,22 @@
/**
* Test Dashboard - Zentrales Test-Registry
*
* Aggregiert alle 195+ Tests aus allen Services:
* Aggregiert alle 280+ Tests aus allen Services:
* - Go Unit Tests (~57)
* - Python Tests (~50)
* - BQAS Golden (97)
* - BQAS RAG (~20)
* - TypeScript Jest (~8)
* - SDK Vitest Unit Tests (~43)
* - SDK Playwright E2E (~25)
* - E2E Playwright (~5)
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
import type { LLMRoutingOption } from '@/types/infrastructure-modules'
import type {
ServiceTestInfo,
TestRegistryStats,
@@ -344,6 +348,7 @@ function FrameworkDistribution({ data }: { data: Record<string, number> }) {
go_test: 'Go Tests',
pytest: 'Python (pytest)',
jest: 'Jest (TS)',
vitest: 'Vitest (SDK)',
playwright: 'Playwright (E2E)',
bqas_golden: 'BQAS Golden',
bqas_rag: 'BQAS RAG',
@@ -354,6 +359,7 @@ function FrameworkDistribution({ data }: { data: Record<string, number> }) {
go_test: 'bg-cyan-500',
pytest: 'bg-yellow-500',
jest: 'bg-blue-500',
vitest: 'bg-orange-500',
playwright: 'bg-purple-500',
bqas_golden: 'bg-emerald-500',
bqas_rag: 'bg-teal-500',
@@ -454,15 +460,16 @@ function GuideTab() {
Was ist das Test Dashboard?
</h2>
<p className="text-slate-700 leading-relaxed">
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 195+ Tests im Breakpilot-System.
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System.
Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren.
Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt.
Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright).
</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 bg-cyan-50 rounded-lg border border-cyan-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🐹</span>
@@ -508,41 +515,70 @@ function GuideTab() {
Website Unit Tests fuer React-Komponenten
</p>
</div>
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl"></span>
<h4 className="font-medium text-orange-800">SDK Vitest (~43)</h4>
</div>
<p className="text-sm text-orange-700">
AI Compliance SDK Unit Tests: Types, Export, Components, Reducer
</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🎭</span>
<h4 className="font-medium text-purple-800">E2E Playwright (~5)</h4>
<h4 className="font-medium text-purple-800">SDK Playwright (~25)</h4>
</div>
<p className="text-sm text-purple-700">
SDK E2E Tests: Navigation, Workflow, Command Bar, Export
</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🌐</span>
<h4 className="font-medium text-slate-800">Website E2E (~5)</h4>
</div>
<p className="text-sm text-slate-700">
End-to-End Tests fuer kritische User Flows
</p>
</div>
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🔗</span>
<h4 className="font-medium text-indigo-800">Integration Tests (~15)</h4>
</div>
<p className="text-sm text-indigo-700">
Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB
</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Architektur</h3>
<pre className="bg-slate-50 p-4 rounded-lg text-xs overflow-x-auto">
{`┌────────────────────────────────────────────────────────────┐
│ Admin-v2 Test Dashboard │
│ /infrastructure/tests │
├────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ Unit Tests │ │ Integration │ │ BQAS │
│ │ (Go, Py) │ │ Tests │ │ (LLM/RAG)
│ └──────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
│ ┌────────────────────────────────────────────────────┐
│ │ Test Registry API
│ │ /backend/api/tests/registry.py
│ └────────────────────────────────────────────────────┘
└────────────────────────────────────────────────────────────┘
{`┌────────────────────────────────────────────────────────────────────
Admin-v2 Test Dashboard
/infrastructure/tests
├────────────────────────────────────────────────────────────────────
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────
│ │ Unit Tests │ │ SDK Tests │ │ BQAS E2E Tests │
│ │ (Go, Py) │ │ (Vitest) │ │ (LLM/RAG) │ │ (Playwright)│
│ └────────────┘ └────────────┘ └────────────┘ └─────────────
│ │
│ ▼
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Test Registry API
│ │ /backend/api/tests/registry.py
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────
Tests bleiben wo sie sind:
- /consent-service/internal/**/*_test.go
- /backend/tests/test_*.py
- /voice-service/tests/bqas/`}
- /voice-service/tests/bqas/
- /admin-v2/components/sdk/__tests__/*.test.ts (Vitest)
- /admin-v2/e2e/specs/*.spec.ts (Playwright)`}
</pre>
</div>
@@ -792,6 +828,8 @@ function BacklogTab({
const [filterStatus, setFilterStatus] = useState<string>('open')
const [filterService, setFilterService] = useState<string>('all')
const [filterPriority, setFilterPriority] = useState<string>('all')
const [llmAutoAnalysis, setLlmAutoAnalysis] = useState<boolean>(true)
const [llmRouting, setLlmRouting] = useState<LLMRoutingOption>('smart_routing')
// Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy
const items = usePostgres && backlogItems ? backlogItems : failedTests
@@ -881,6 +919,93 @@ function BacklogTab({
</div>
)}
{/* LLM Analysis Toggle */}
<div className="bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-200 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h4 className="font-medium text-slate-800">Automatische LLM-Analyse</h4>
<p className="text-xs text-slate-500">KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={llmAutoAnalysis}
onChange={(e) => setLlmAutoAnalysis(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
</label>
</div>
{llmAutoAnalysis && (
<div className="mt-4 pt-4 border-t border-violet-200">
<p className="text-xs text-slate-600 mb-3">LLM-Routing Strategie:</p>
<div className="flex flex-wrap gap-2">
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
llmRouting === 'local_only'
? 'bg-violet-100 border-violet-300 text-violet-800'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
<input
type="radio"
name="llm-routing"
value="local_only"
checked={llmRouting === 'local_only'}
onChange={() => setLlmRouting('local_only')}
className="sr-only"
/>
<span className="text-sm font-medium">Nur lokales 32B LLM</span>
<span className="text-xs px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded">DSGVO</span>
</label>
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
llmRouting === 'claude_preferred'
? 'bg-violet-100 border-violet-300 text-violet-800'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
<input
type="radio"
name="llm-routing"
value="claude_preferred"
checked={llmRouting === 'claude_preferred'}
onChange={() => setLlmRouting('claude_preferred')}
className="sr-only"
/>
<span className="text-sm font-medium">Claude bevorzugt</span>
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">Qualitaet</span>
</label>
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
llmRouting === 'smart_routing'
? 'bg-violet-100 border-violet-300 text-violet-800'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
<input
type="radio"
name="llm-routing"
value="smart_routing"
checked={llmRouting === 'smart_routing'}
onChange={() => setLlmRouting('smart_routing')}
className="sr-only"
/>
<span className="text-sm font-medium">Smart Routing</span>
<span className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded">Empfohlen</span>
</label>
</div>
<p className="text-xs text-slate-500 mt-2">
{llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'}
{llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'}
{llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'}
</p>
</div>
)}
</div>
{/* Filter */}
<div className="flex flex-wrap gap-4 items-center">
<div>
@@ -1066,18 +1191,21 @@ export default function TestDashboardPage() {
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
]
const DEMO_STATS: TestRegistryStats = {
total_tests: 195,
total_passed: 180,
total_tests: 278,
total_passed: 263,
total_failed: 15,
total_skipped: 0,
overall_pass_rate: 92.3,
average_coverage: 76.8,
services_count: 8,
by_category: { unit: 75, bqas: 117, e2e: 5 },
by_framework: { go_test: 57, pytest: 53, bqas_golden: 97, bqas_rag: 20, jest: 8, playwright: 5 },
overall_pass_rate: 94.6,
average_coverage: 78.5,
services_count: 11,
by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 },
by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 },
}
// Fetch data
@@ -1460,21 +1588,25 @@ export default function TestDashboardPage() {
<PagePurpose
title="Test Dashboard"
purpose="Zentrales Dashboard fuer alle 195+ Tests. Aggregiert Unit Tests (Go, Python), Integration Tests, E2E (Playwright) und BQAS Quality Tests aus allen Services ohne physische Migration."
purpose="Zentrales Dashboard fuer alle 260+ Tests. Aggregiert Unit Tests (Go, Python), SDK Tests (Vitest), E2E Tests (Playwright) und BQAS Quality Tests aus allen Services ohne physische Migration."
audience={['Entwickler', 'QA', 'DevOps']}
architecture={{
services: ['Python Backend (Port 8000)', 'Voice Service (Port 8091)'],
databases: ['PostgreSQL'],
services: ['Python Backend (Port 8000)', 'Voice Service (Port 8091)', 'SDK Backend (Port 8085)'],
databases: ['PostgreSQL', 'Qdrant'],
}}
relatedPages={[
{ name: 'BQAS Dashboard', href: '/ai/test-quality', description: 'Detaillierte LLM-Qualitaetsmetriken' },
{ name: 'CI/CD', href: '/infrastructure/ci-cd', description: 'Pipelines und Deployments' },
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Developer Portal', href: '/developers', description: 'SDK & API Dokumentation' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="tests" />
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-3">
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -1,6 +1,40 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Database,
FileText,
List,
Table,
AlertTriangle,
Shield,
Clock,
Users,
Globe,
} from 'lucide-react'
import {
DataPoint,
DataPointCategory,
@@ -22,70 +56,79 @@ const PLACEHOLDERS = [
{
placeholder: '[DATENPUNKTE_TABLE]',
label: { de: 'Tabelle', en: 'Table' },
description: { de: 'Markdown-Tabelle mit allen Datenpunkten', en: 'Markdown table with all data points' },
description: { de: 'Fügt eine Markdown-Tabelle mit allen Datenpunkten ein', en: 'Inserts a markdown table with all data points' },
icon: Table,
},
{
placeholder: '[DATENPUNKTE_LIST]',
label: { de: 'Liste', en: 'List' },
description: { de: 'Kommaseparierte Liste der Namen', en: 'Comma-separated list of names' },
description: { de: 'Kommaseparierte Liste der Datenpunkt-Namen', en: 'Comma-separated list of data point names' },
icon: List,
},
{
placeholder: '[VERARBEITUNGSZWECKE]',
label: { de: 'Zwecke', en: 'Purposes' },
description: { de: 'Alle Verarbeitungszwecke', en: 'All processing purposes' },
description: { de: 'Alle Verarbeitungszwecke (dedupliziert)', en: 'All processing purposes (deduplicated)' },
icon: FileText,
},
{
placeholder: '[RECHTSGRUNDLAGEN]',
label: { de: 'Rechtsgrundlagen', en: 'Legal Bases' },
description: { de: 'DSGVO-Artikel', en: 'GDPR articles' },
description: { de: 'Verwendete DSGVO-Artikel', en: 'Used GDPR articles' },
icon: Shield,
},
{
placeholder: '[SPEICHERFRISTEN]',
label: { de: 'Speicherfristen', en: 'Retention' },
description: { de: 'Fristen nach Kategorie', en: 'Periods by category' },
description: { de: 'Fristen gruppiert nach Kategorie', en: 'Periods grouped by category' },
icon: Clock,
},
{
placeholder: '[EMPFAENGER]',
label: { de: 'Empfänger', en: 'Recipients' },
description: { de: 'Liste aller Drittparteien', en: 'List of third parties' },
description: { de: 'Liste aller Drittparteien', en: 'List of all third parties' },
icon: Users,
},
{
placeholder: '[BESONDERE_KATEGORIEN]',
label: { de: 'Art. 9', en: 'Art. 9' },
description: { de: 'Abschnitt für sensible Daten', en: 'Section for sensitive data' },
label: { de: 'Art. 9 Abschnitt', en: 'Art. 9 Section' },
description: { de: 'DSGVO-konformer Abschnitt für sensible Daten', en: 'GDPR-compliant section for sensitive data' },
icon: AlertTriangle,
},
{
placeholder: '[DRITTLAND_TRANSFERS]',
label: { de: 'Drittländer', en: 'Third Countries' },
description: { de: 'Datenübermittlung außerhalb EU', en: 'Data transfers outside EU' },
description: { de: 'Abschnitt zu Datenübermittlung außerhalb EU', en: 'Section about data transfers outside EU' },
icon: Globe,
},
]
/**
* Risiko-Badge Farben
* Risiko-Badge Varianten mapping
*/
function getRiskBadgeColor(riskLevel: RiskLevel): string {
function getRiskBadgeVariant(riskLevel: RiskLevel): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (riskLevel) {
case 'HIGH':
return 'bg-red-100 text-red-700 border-red-200'
return 'destructive'
case 'MEDIUM':
return 'bg-yellow-100 text-yellow-700 border-yellow-200'
return 'secondary'
case 'LOW':
default:
return 'bg-green-100 text-green-700 border-green-200'
return 'outline'
}
}
/**
* DataPointsPreview Komponente
*
* Zeigt eine Vorschau der ausgewählten Einwilligungen-Datenpunkte im Dokumentengenerator.
* Ermöglicht das schnelle Einfügen von Platzhaltern.
*/
export function DataPointsPreview({
dataPoints,
onInsertPlaceholder,
language = 'de',
}: DataPointsPreviewProps) {
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
// Gruppiere Datenpunkte nach Kategorie
const byCategory = useMemo(() => {
return dataPoints.reduce((acc, dp) => {
@@ -101,15 +144,21 @@ export function DataPointsPreview({
const stats = useMemo(() => {
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
let specialCategoryCount = 0
let explicitConsentCount = 0
const recipients = new Set<string>()
dataPoints.forEach(dp => {
riskCounts[dp.riskLevel]++
if (dp.isSpecialCategory) specialCategoryCount++
if (dp.requiresExplicitConsent) explicitConsentCount++
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
})
return {
riskCounts,
specialCategoryCount,
explicitConsentCount,
recipientCount: recipients.size,
categoryCount: Object.keys(byCategory).length,
}
}, [dataPoints, byCategory])
@@ -123,173 +172,195 @@ export function DataPointsPreview({
})
}, [byCategory])
const toggleCategory = (category: string) => {
setExpandedCategories(prev =>
prev.includes(category)
? prev.filter(c => c !== category)
: [...prev, category]
)
}
if (dataPoints.length === 0) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 flex items-center gap-2 mb-3">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</h4>
<p className="text-sm text-gray-500">
{language === 'de'
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus.'
: 'No data points selected. Select data points in the consent step.'}
</p>
</div>
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4" />
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{language === 'de'
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus, um sie hier zu sehen.'
: 'No data points selected. Select data points in the consent step to see them here.'}
</p>
</CardContent>
</Card>
)
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6 h-full flex flex-col">
{/* Header */}
<div className="mb-4">
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4" />
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</h4>
<p className="text-sm text-gray-500 mt-1">
</CardTitle>
<CardDescription>
{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '}
{stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'}
</p>
</div>
</CardDescription>
</CardHeader>
{/* Statistik-Badges */}
<div className="flex flex-wrap gap-2 mb-4">
{stats.riskCounts.HIGH > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
</span>
)}
{stats.riskCounts.MEDIUM > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700">
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
</span>
)}
{stats.riskCounts.LOW > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
</span>
)}
{stats.specialCategoryCount > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{stats.specialCategoryCount} Art. 9
</span>
)}
</div>
<CardContent className="flex-1 flex flex-col gap-4 overflow-hidden">
{/* Statistik-Badges */}
<div className="flex flex-wrap gap-2">
{stats.riskCounts.HIGH > 0 && (
<Badge variant="destructive">
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
</Badge>
)}
{stats.riskCounts.MEDIUM > 0 && (
<Badge variant="secondary">
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
</Badge>
)}
{stats.riskCounts.LOW > 0 && (
<Badge variant="outline">
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
</Badge>
)}
{stats.specialCategoryCount > 0 && (
<Badge variant="destructive" className="bg-orange-500 hover:bg-orange-600">
<AlertTriangle className="h-3 w-3 mr-1" />
{stats.specialCategoryCount} Art. 9
</Badge>
)}
</div>
<div className="border-t border-gray-200 my-3"></div>
<Separator />
{/* Datenpunkte nach Kategorie */}
<div className="flex-1 overflow-y-auto space-y-2 max-h-64">
{sortedCategories.map(([category, points]) => {
const metadata = CATEGORY_METADATA[category as DataPointCategory]
if (!metadata) return null
const isExpanded = expandedCategories.includes(category)
{/* Datenpunkte nach Kategorie */}
<ScrollArea className="flex-1 -mr-4 pr-4">
<Accordion type="multiple" className="w-full">
{sortedCategories.map(([category, points]) => {
const metadata = CATEGORY_METADATA[category as DataPointCategory]
if (!metadata) return null
return (
<div key={category} className="border border-gray-100 rounded-lg">
<button
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between p-2 text-sm hover:bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">{metadata.code}</span>
<span className="font-medium text-gray-900">
{language === 'de' ? metadata.name.de : metadata.name.en}
</span>
</div>
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">
{points.length}
</span>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? '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>
</div>
</button>
{isExpanded && (
<ul className="px-2 pb-2 space-y-1">
{points.map(dp => (
<li
key={dp.id}
className="flex items-center justify-between text-sm py-1 pl-6"
>
<span className="truncate max-w-[160px] text-gray-700">
{language === 'de' ? dp.name.de : dp.name.en}
return (
<AccordionItem key={category} value={category}>
<AccordionTrigger className="text-sm hover:no-underline">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">
{metadata.code}
</span>
<div className="flex items-center gap-1">
{dp.isSpecialCategory && (
<svg className="w-3 h-3 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)}
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${getRiskBadgeColor(dp.riskLevel)}`}>
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
</span>
</div>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
<span>
{language === 'de' ? metadata.name.de : metadata.name.en}
</span>
<Badge variant="secondary" className="ml-auto mr-2">
{points.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1 pl-6">
{points.map(dp => (
<li
key={dp.id}
className="flex items-center justify-between text-sm py-1"
>
<span className="truncate max-w-[180px]">
{language === 'de' ? dp.name.de : dp.name.en}
</span>
<div className="flex items-center gap-1">
{dp.isSpecialCategory && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertTriangle className="h-3 w-3 text-orange-500" />
</TooltipTrigger>
<TooltipContent>
{language === 'de'
? 'Besondere Kategorie (Art. 9 DSGVO)'
: 'Special Category (Art. 9 GDPR)'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Badge
variant={getRiskBadgeVariant(dp.riskLevel)}
className="text-xs px-1.5 py-0"
>
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
</Badge>
</div>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</ScrollArea>
<div className="border-t border-gray-200 my-3"></div>
<Separator />
{/* Schnell-Einfügen Buttons */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2">
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
</p>
<div className="flex flex-wrap gap-1.5">
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label }) => (
<button
key={placeholder}
onClick={() => onInsertPlaceholder(placeholder)}
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
title={placeholder}
>
{language === 'de' ? label.de : label.en}
</button>
))}
{/* Schnell-Einfügen Buttons */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
</p>
<div className="flex flex-wrap gap-1.5">
<TooltipProvider>
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label, description, icon: Icon }) => (
<Tooltip key={placeholder}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onInsertPlaceholder(placeholder)}
>
<Icon className="h-3 w-3 mr-1" />
{language === 'de' ? label.de : label.en}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
{language === 'de' ? description.de : description.en}
</p>
<p className="text-xs text-muted-foreground font-mono mt-1">
{placeholder}
</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-1.5">
<TooltipProvider>
{PLACEHOLDERS.slice(4).map(({ placeholder, label, description, icon: Icon }) => (
<Tooltip key={placeholder}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onInsertPlaceholder(placeholder)}
>
<Icon className="h-3 w-3 mr-1" />
{language === 'de' ? label.de : label.en}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
{language === 'de' ? description.de : description.en}
</p>
<p className="text-xs text-muted-foreground font-mono mt-1">
{placeholder}
</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{PLACEHOLDERS.slice(4).map(({ placeholder, label }) => (
<button
key={placeholder}
onClick={() => onInsertPlaceholder(placeholder)}
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
title={placeholder}
>
{language === 'de' ? label.de : label.en}
</button>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,6 +1,22 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
AlertCircle,
AlertTriangle,
Info,
ChevronDown,
Lightbulb,
Plus,
} from 'lucide-react'
import { DataPoint } from '@/lib/sdk/einwilligungen/types'
import {
validateDocument,
@@ -14,6 +30,28 @@ interface DocumentValidationProps {
onInsertPlaceholder?: (placeholder: string) => void
}
/**
* Icon für den Warnungstyp
*/
function getWarningIcon(type: ValidationWarning['type']) {
switch (type) {
case 'error':
return AlertCircle
case 'warning':
return AlertTriangle
case 'info':
default:
return Info
}
}
/**
* Alert-Variante für den Warnungstyp
*/
function getAlertVariant(type: ValidationWarning['type']): 'default' | 'destructive' {
return type === 'error' ? 'destructive' : 'default'
}
/**
* Placeholder-Vorschlag aus der Warnung extrahieren
*/
@@ -24,6 +62,9 @@ function extractPlaceholderSuggestion(warning: ValidationWarning): string | null
/**
* DocumentValidation Komponente
*
* Zeigt Validierungswarnungen basierend auf ausgewählten Datenpunkten und
* dem generierten Dokumentinhalt.
*/
export function DocumentValidation({
dataPoints,
@@ -31,8 +72,6 @@ export function DocumentValidation({
language = 'de',
onInsertPlaceholder,
}: DocumentValidationProps) {
const [expandedWarnings, setExpandedWarnings] = useState<string[]>([])
// Führe Validierung durch
const warnings = useMemo(() => {
if (dataPoints.length === 0 || !documentContent) {
@@ -46,33 +85,21 @@ export function DocumentValidation({
const warningCount = warnings.filter(w => w.type === 'warning').length
const infoCount = warnings.filter(w => w.type === 'info').length
const toggleWarning = (code: string) => {
setExpandedWarnings(prev =>
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]
)
}
if (warnings.length === 0) {
// Keine Warnungen - zeige Erfolgsmeldung wenn Datenpunkte vorhanden
if (dataPoints.length > 0 && documentContent.length > 100) {
return (
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-green-800">
{language === 'de' ? 'Dokument valide' : 'Document valid'}
</h4>
<p className="text-sm text-green-700 mt-1">
{language === 'de'
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
: 'All necessary sections for the selected data points are present.'}
</p>
</div>
</div>
</div>
<Alert className="bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-900">
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-200">
{language === 'de' ? 'Dokument valide' : 'Document valid'}
</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-300">
{language === 'de'
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
: 'All necessary sections for the selected data points are present.'}
</AlertDescription>
</Alert>
)
}
return null
@@ -82,126 +109,91 @@ export function DocumentValidation({
<div className="space-y-3">
{/* Zusammenfassung */}
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-gray-700">
<span className="font-medium">
{language === 'de' ? 'Validierung:' : 'Validation:'}
</span>
{errorCount > 0 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-700">
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}{errorCount > 1 && 's'}
</span>
<Badge variant="destructive">
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}
{errorCount > 1 && (language === 'de' ? '' : 's')}
</Badge>
)}
{warningCount > 0 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700">
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}{warningCount > 1 && (language === 'de' ? 'en' : 's')}
</span>
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}
{warningCount > 1 && (language === 'de' ? 'en' : 's')}
</Badge>
)}
{infoCount > 0 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}{infoCount > 1 && (language === 'de' ? 'e' : 's')}
</span>
<Badge variant="outline">
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}
{infoCount > 1 && (language === 'de' ? 'e' : 's')}
</Badge>
)}
</div>
{/* Warnungen */}
{warnings.map((warning, index) => {
const Icon = getWarningIcon(warning.type)
const placeholder = extractPlaceholderSuggestion(warning)
const isExpanded = expandedWarnings.includes(warning.code)
const isError = warning.type === 'error'
return (
<div
key={`${warning.code}-${index}`}
className={`rounded-xl border p-4 ${
isError
? 'bg-red-50 border-red-200'
: 'bg-yellow-50 border-yellow-200'
}`}
>
<div className="flex items-start gap-3">
{/* Icon */}
<svg
className={`w-5 h-5 mt-0.5 ${isError ? 'text-red-600' : 'text-yellow-600'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isError ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
)}
</svg>
<div className="flex-1">
{/* Message */}
<p className={`font-medium ${isError ? 'text-red-800' : 'text-yellow-800'}`}>
{warning.message}
</p>
{/* Suggestion */}
<div className="flex items-start gap-2 mt-2">
<svg className="w-4 h-4 mt-0.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span className="text-sm text-gray-600">{warning.suggestion}</span>
</div>
{/* Quick-Fix Button */}
{placeholder && onInsertPlaceholder && (
<button
onClick={() => onInsertPlaceholder(placeholder)}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
<code className="ml-1 text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{placeholder}
</code>
</button>
)}
{/* Betroffene Datenpunkte */}
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
<div className="mt-3">
<button
onClick={() => toggleWarning(warning.code)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
>
<svg
className={`w-3 h-3 transition-transform ${isExpanded ? '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>
{warning.affectedDataPoints.length}{' '}
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
</button>
{isExpanded && (
<ul className="mt-2 text-xs space-y-0.5 pl-4">
{warning.affectedDataPoints.slice(0, 5).map(dp => (
<li key={dp.id} className="list-disc text-gray-600">
{language === 'de' ? dp.name.de : dp.name.en}
</li>
))}
{warning.affectedDataPoints.length > 5 && (
<li className="list-none text-gray-400">
... {language === 'de' ? 'und' : 'and'}{' '}
{warning.affectedDataPoints.length - 5}{' '}
{language === 'de' ? 'weitere' : 'more'}
</li>
)}
</ul>
)}
</div>
)}
<Alert key={`${warning.code}-${index}`} variant={getAlertVariant(warning.type)}>
<Icon className="h-4 w-4" />
<AlertTitle className="flex items-center justify-between">
<span>{warning.message}</span>
</AlertTitle>
<AlertDescription className="mt-2 space-y-2">
{/* Vorschlag */}
<div className="flex items-start gap-2">
<Lightbulb className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span className="text-sm">{warning.suggestion}</span>
</div>
</div>
</div>
{/* Quick-Fix Button */}
{placeholder && onInsertPlaceholder && (
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => onInsertPlaceholder(placeholder)}
>
<Plus className="h-3 w-3 mr-1" />
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
<code className="ml-1 text-xs bg-muted px-1 rounded">
{placeholder}
</code>
</Button>
)}
{/* Betroffene Datenpunkte */}
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
<ChevronDown className="h-3 w-3" />
{warning.affectedDataPoints.length}{' '}
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<ul className="text-xs space-y-0.5 pl-4">
{warning.affectedDataPoints.slice(0, 5).map(dp => (
<li key={dp.id} className="list-disc">
{language === 'de' ? dp.name.de : dp.name.en}
</li>
))}
{warning.affectedDataPoints.length > 5 && (
<li className="list-none text-muted-foreground">
... {language === 'de' ? 'und' : 'and'}{' '}
{warning.affectedDataPoints.length - 5}{' '}
{language === 'de' ? 'weitere' : 'more'}
</li>
)}
</ul>
</CollapsibleContent>
</Collapsible>
)}
</AlertDescription>
</Alert>
)
})}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,179 +1,177 @@
'use client'
import React, { useState, useCallback } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
import { DSFACard } from '@/components/sdk/dsfa'
import {
DSFA,
DSFAStatus,
DSFA_STATUS_LABELS,
DSFA_RISK_LEVEL_LABELS,
} from '@/lib/sdk/dsfa/types'
import {
listDSFAs,
deleteDSFA,
exportDSFAAsJSON,
getDSFAStats,
createDSFAFromAssessment,
getDSFAByAssessment,
} from '@/lib/sdk/dsfa/api'
// =============================================================================
// TYPES
// UCCA TRIGGER WARNING COMPONENT
// =============================================================================
interface DSFA {
id: string
title: string
description: string
status: 'draft' | 'in-review' | 'approved' | 'needs-update'
createdAt: Date
updatedAt: Date
approvedBy: string | null
riskLevel: 'low' | 'medium' | 'high' | 'critical'
processingActivity: string
dataCategories: string[]
recipients: string[]
measures: string[]
interface UCCATriggerWarningProps {
assessmentId: string
triggeredRules: string[]
existingDsfaId?: string
onCreateDSFA: () => void
}
// =============================================================================
// MOCK DATA
// =============================================================================
const mockDSFAs: DSFA[] = [
{
id: 'dsfa-1',
title: 'DSFA - Bewerber-Management-System',
description: 'Datenschutz-Folgenabschaetzung fuer das KI-gestuetzte Bewerber-Screening',
status: 'in-review',
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-20'),
approvedBy: null,
riskLevel: 'high',
processingActivity: 'Automatisierte Bewertung von Bewerbungsunterlagen',
dataCategories: ['Kontaktdaten', 'Beruflicher Werdegang', 'Qualifikationen'],
recipients: ['HR-Abteilung', 'Fachabteilungen'],
measures: ['Verschluesselung', 'Zugriffskontrolle', 'Menschliche Pruefung'],
},
{
id: 'dsfa-2',
title: 'DSFA - Video-Ueberwachung Buero',
description: 'Datenschutz-Folgenabschaetzung fuer die Videoueberwachung im Buerogebaeude',
status: 'approved',
createdAt: new Date('2023-11-01'),
updatedAt: new Date('2023-12-15'),
approvedBy: 'DSB Mueller',
riskLevel: 'medium',
processingActivity: 'Videoueberwachung zu Sicherheitszwecken',
dataCategories: ['Bilddaten', 'Bewegungsdaten'],
recipients: ['Sicherheitsdienst'],
measures: ['Loeschfristen', 'Zugriffsbeschraenkung', 'Hinweisschilder'],
},
{
id: 'dsfa-3',
title: 'DSFA - Kundenanalyse',
description: 'Datenschutz-Folgenabschaetzung fuer Big-Data-Kundenanalysen',
status: 'draft',
createdAt: new Date('2024-01-22'),
updatedAt: new Date('2024-01-22'),
approvedBy: null,
riskLevel: 'high',
processingActivity: 'Analyse von Kundenverhalten fuer Marketing',
dataCategories: ['Kaufhistorie', 'Nutzungsverhalten', 'Praeferenzen'],
recipients: ['Marketing', 'Vertrieb'],
measures: [],
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
function DSFACard({ dsfa }: { dsfa: DSFA }) {
const statusColors = {
draft: 'bg-gray-100 text-gray-600 border-gray-200',
'in-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
approved: 'bg-green-100 text-green-700 border-green-200',
'needs-update': 'bg-orange-100 text-orange-700 border-orange-200',
}
const statusLabels = {
draft: 'Entwurf',
'in-review': 'In Pruefung',
approved: 'Genehmigt',
'needs-update': 'Aktualisierung erforderlich',
}
const riskColors = {
low: 'bg-green-100 text-green-700',
medium: 'bg-yellow-100 text-yellow-700',
high: 'bg-orange-100 text-orange-700',
critical: 'bg-red-100 text-red-700',
function UCCATriggerWarning({
assessmentId,
triggeredRules,
existingDsfaId,
onCreateDSFA,
}: UCCATriggerWarningProps) {
if (existingDsfaId) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-start gap-4">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-blue-800">DSFA bereits erstellt</h4>
<p className="text-sm text-blue-600 mt-1">
Fuer dieses Assessment wurde bereits eine DSFA angelegt.
</p>
<Link
href={`/sdk/dsfa/${existingDsfaId}`}
className="inline-flex items-center gap-1 mt-2 text-sm text-blue-700 hover:text-blue-800 font-medium"
>
DSFA oeffnen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
)
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
dsfa.status === 'needs-update' ? 'border-orange-200' :
dsfa.status === 'approved' ? 'border-green-200' : 'border-gray-200'
}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
{statusLabels[dsfa.status]}
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4 flex items-start gap-4">
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-orange-800">DSFA erforderlich</h4>
<p className="text-sm text-orange-600 mt-1">
Das UCCA-Assessment hat folgende Trigger ausgeloest:
</p>
<div className="flex flex-wrap gap-1 mt-2">
{triggeredRules.map(rule => (
<span key={rule} className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded">
{rule}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[dsfa.riskLevel]}`}>
Risiko: {dsfa.riskLevel === 'low' ? 'Niedrig' :
dsfa.riskLevel === 'medium' ? 'Mittel' :
dsfa.riskLevel === 'high' ? 'Hoch' : 'Kritisch'}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{dsfa.title}</h3>
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
</div>
</div>
<div className="mt-4 text-sm text-gray-600">
<p><span className="text-gray-500">Verarbeitungstaetigkeit:</span> {dsfa.processingActivity}</p>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{dsfa.dataCategories.map(cat => (
<span key={cat} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{cat}
</span>
))}
</div>
{dsfa.measures.length > 0 && (
<div className="mt-3">
<span className="text-sm text-gray-500">Massnahmen:</span>
<div className="flex flex-wrap gap-1 mt-1">
{dsfa.measures.map(m => (
<span key={m} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
{m}
</span>
))}
</div>
</div>
)}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<div className="text-gray-500">
<span>Erstellt: {dsfa.createdAt.toLocaleDateString('de-DE')}</span>
{dsfa.approvedBy && (
<span className="ml-4">Genehmigt von: {dsfa.approvedBy}</span>
)}
</div>
<div className="flex items-center gap-2">
<button className="px-3 py-1 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</button>
<button className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Exportieren
</button>
))}
</div>
<button
onClick={onCreateDSFA}
className="inline-flex items-center gap-1 mt-3 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium"
>
DSFA aus Assessment erstellen
<svg className="w-4 h-4" 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>
)
}
function GeneratorWizard({ onClose }: { onClose: () => void }) {
// =============================================================================
// GENERATOR WIZARD COMPONENT
// =============================================================================
function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreated: (dsfa: DSFA) => void }) {
const [step, setStep] = useState(1)
const [formData, setFormData] = useState({
name: '',
description: '',
processingPurpose: '',
dataCategories: [] as string[],
legalBasis: '',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const DATA_CATEGORIES = [
'Kontaktdaten',
'Identifikationsdaten',
'Finanzdaten',
'Gesundheitsdaten',
'Standortdaten',
'Nutzungsdaten',
'Biometrische Daten',
'Daten Minderjaehriger',
]
const LEGAL_BASES = [
{ value: 'consent', label: 'Einwilligung (Art. 6 Abs. 1 lit. a)' },
{ value: 'contract', label: 'Vertrag (Art. 6 Abs. 1 lit. b)' },
{ value: 'legal_obligation', label: 'Rechtliche Verpflichtung (Art. 6 Abs. 1 lit. c)' },
{ value: 'legitimate_interest', label: 'Berechtigtes Interesse (Art. 6 Abs. 1 lit. f)' },
]
const handleCategoryToggle = (cat: string) => {
setFormData(prev => ({
...prev,
dataCategories: prev.dataCategories.includes(cat)
? prev.dataCategories.filter(c => c !== cat)
: [...prev.dataCategories, cat],
}))
}
const handleSubmit = async () => {
setIsSubmitting(true)
try {
// For standalone DSFA, we use the regular create endpoint
const response = await fetch('/api/sdk/v1/dsgvo/dsfas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
description: formData.description,
processing_purpose: formData.processingPurpose,
data_categories: formData.dataCategories,
legal_basis: formData.legalBasis,
status: 'draft',
}),
})
if (response.ok) {
const dsfa = await response.json()
onCreated(dsfa)
onClose()
}
} catch (error) {
console.error('Failed to create DSFA:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">Neue DSFA erstellen</h3>
<h3 className="text-lg font-semibold text-gray-900">Neue Standalone-DSFA erstellen</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -183,7 +181,7 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-6">
{[1, 2, 3, 4].map(s => (
{[1, 2, 3].map(s => (
<React.Fragment key={s}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s < step ? 'bg-green-500 text-white' :
@@ -195,7 +193,7 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
</svg>
) : s}
</div>
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
{s < 3 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
</React.Fragment>
))}
</div>
@@ -205,9 +203,11 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
{step === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
@@ -215,21 +215,38 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck</label>
<input
type="text"
value={formData.processingPurpose}
onChange={(e) => setFormData(prev => ({ ...prev, processingPurpose: e.target.value }))}
placeholder="z.B. Automatisierte Bewerberauswahl"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien *</label>
<div className="grid grid-cols-2 gap-2">
{['Kontaktdaten', 'Identifikationsdaten', 'Finanzdaten', 'Gesundheitsdaten', 'Standortdaten', 'Nutzungsdaten'].map(cat => (
{DATA_CATEGORIES.map(cat => (
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
<input type="checkbox" className="w-4 h-4 text-purple-600" />
<input
type="checkbox"
checked={formData.dataCategories.includes(cat)}
onChange={() => handleCategoryToggle(cat)}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm">{cat}</span>
</label>
))}
@@ -240,28 +257,19 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
{step === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Risikobewertung</label>
<p className="text-sm text-gray-500 mb-4">Bewerten Sie die Risiken fuer die Rechte und Freiheiten der Betroffenen.</p>
<label className="block text-sm font-medium text-gray-700 mb-2">Rechtsgrundlage *</label>
<div className="space-y-2">
{['Niedrig', 'Mittel', 'Hoch', 'Kritisch'].map(level => (
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
<input type="radio" name="risk" className="w-4 h-4 text-purple-600" />
<span className="text-sm font-medium">{level}</span>
</label>
))}
</div>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzmassnahmen</label>
<div className="grid grid-cols-2 gap-2">
{['Verschluesselung', 'Pseudonymisierung', 'Zugriffskontrolle', 'Loeschkonzept', 'Schulungen', 'Menschliche Pruefung'].map(m => (
<label key={m} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
<input type="checkbox" className="w-4 h-4 text-purple-600" />
<span className="text-sm">{m}</span>
{LEGAL_BASES.map(basis => (
<label key={basis.value} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
<input
type="radio"
name="legalBasis"
value={basis.value}
checked={formData.legalBasis === basis.value}
onChange={(e) => setFormData(prev => ({ ...prev, legalBasis: e.target.value }))}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm font-medium">{basis.label}</span>
</label>
))}
</div>
@@ -279,10 +287,11 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
{step === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
<button
onClick={() => step < 4 ? setStep(step + 1) : onClose()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
onClick={() => step < 3 ? setStep(step + 1) : handleSubmit()}
disabled={(step === 1 && !formData.name) || (step === 2 && formData.dataCategories.length === 0) || (step === 3 && !formData.legalBasis) || isSubmitting}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{step === 4 ? 'DSFA erstellen' : 'Weiter'}
{isSubmitting ? 'Wird erstellt...' : step === 3 ? 'DSFA erstellen' : 'Weiter'}
</button>
</div>
</div>
@@ -296,28 +305,115 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
export default function DSFAPage() {
const router = useRouter()
const { state } = useSDK()
const [dsfas] = useState<DSFA[]>(mockDSFAs)
const [dsfas, setDsfas] = useState<DSFA[]>([])
const [showGenerator, setShowGenerator] = useState(false)
const [filter, setFilter] = useState<string>('all')
const [isLoading, setIsLoading] = useState(true)
const [stats, setStats] = useState({
total: 0,
draft: 0,
in_review: 0,
approved: 0,
})
// Handle uploaded document
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
console.log('[DSFA Page] Document processed:', doc)
}, [])
// UCCA trigger info (would come from SDK state)
const [uccaTrigger, setUccaTrigger] = useState<{
assessmentId: string
triggeredRules: string[]
existingDsfaId?: string
} | null>(null)
// Open document in workflow editor
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
router.push(`/compliance/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
}, [router])
// Load DSFAs
const loadDSFAs = useCallback(async () => {
setIsLoading(true)
try {
const [dsfaList, statsData] = await Promise.all([
listDSFAs(filter === 'all' ? undefined : filter),
getDSFAStats(),
])
setDsfas(dsfaList)
setStats({
total: statsData.total,
draft: statsData.status_stats.draft || 0,
in_review: statsData.status_stats.in_review || 0,
approved: statsData.status_stats.approved || 0,
})
} catch (error) {
console.error('Failed to load DSFAs:', error)
// Set empty state on error
setDsfas([])
} finally {
setIsLoading(false)
}
}, [filter])
useEffect(() => {
loadDSFAs()
}, [loadDSFAs])
// Check for UCCA trigger from SDK state
// TODO: Enable when UCCA integration is complete
// useEffect(() => {
// if (state?.uccaAssessment?.dsfa_recommended) {
// const assessmentId = state.uccaAssessment.id
// const triggeredRules = state.uccaAssessment.triggered_rules
// ?.filter((r: { severity: string }) => r.severity === 'BLOCK' || r.severity === 'WARN')
// ?.map((r: { code: string }) => r.code) || []
//
// // Check if DSFA already exists
// getDSFAByAssessment(assessmentId).then(existingDsfa => {
// setUccaTrigger({
// assessmentId,
// triggeredRules,
// existingDsfaId: existingDsfa?.id,
// })
// })
// }
// }, [state?.uccaAssessment])
// Handle delete
const handleDelete = async (id: string) => {
if (confirm('Moechten Sie diese DSFA wirklich loeschen?')) {
try {
await deleteDSFA(id)
await loadDSFAs()
} catch (error) {
console.error('Failed to delete DSFA:', error)
}
}
}
// Handle export
const handleExport = async (id: string) => {
try {
const blob = await exportDSFAAsJSON(id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dsfa_${id.slice(0, 8)}.json`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to export DSFA:', error)
}
}
// Handle create from assessment
const handleCreateFromAssessment = async () => {
if (!uccaTrigger?.assessmentId) return
try {
const response = await createDSFAFromAssessment(uccaTrigger.assessmentId)
router.push(`/sdk/dsfa/${response.dsfa.id}`)
} catch (error) {
console.error('Failed to create DSFA from assessment:', error)
}
}
const filteredDSFAs = filter === 'all'
? dsfas
: dsfas.filter(d => d.status === filter)
const draftCount = dsfas.filter(d => d.status === 'draft').length
const inReviewCount = dsfas.filter(d => d.status === 'in-review').length
const approvedCount = dsfas.filter(d => d.status === 'approved').length
const stepInfo = STEP_EXPLANATIONS['dsfa']
return (
@@ -330,55 +426,67 @@ export default function DSFAPage() {
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
{!showGenerator && (
<button
onClick={() => setShowGenerator(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Neue DSFA
</button>
)}
<div className="flex items-center gap-2">
{!showGenerator && (
<>
<button
onClick={() => setShowGenerator(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Standalone DSFA
</button>
</>
)}
</div>
</StepHeader>
{/* UCCA Trigger Warning */}
{uccaTrigger && (
<UCCATriggerWarning
assessmentId={uccaTrigger.assessmentId}
triggeredRules={uccaTrigger.triggeredRules}
existingDsfaId={uccaTrigger.existingDsfaId}
onCreateDSFA={handleCreateFromAssessment}
/>
)}
{/* Generator */}
{showGenerator && (
<GeneratorWizard onClose={() => setShowGenerator(false)} />
<GeneratorWizard
onClose={() => setShowGenerator(false)}
onCreated={(dsfa) => {
router.push(`/sdk/dsfa/${dsfa.id}`)
}}
/>
)}
{/* Document Upload Section */}
<DocumentUploadSection
documentType="dsfa"
onDocumentProcessed={handleDocumentProcessed}
onOpenInEditor={handleOpenInEditor}
/>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{dsfas.length}</div>
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Entwuerfe</div>
<div className="text-3xl font-bold text-gray-500">{draftCount}</div>
<div className="text-3xl font-bold text-gray-500">{stats.draft}</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-6">
<div className="text-sm text-yellow-600">In Pruefung</div>
<div className="text-3xl font-bold text-yellow-600">{inReviewCount}</div>
<div className="text-3xl font-bold text-yellow-600">{stats.in_review}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Genehmigt</div>
<div className="text-3xl font-bold text-green-600">{approvedCount}</div>
<div className="text-3xl font-bold text-green-600">{stats.approved}</div>
</div>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'draft', 'in-review', 'approved', 'needs-update'].map(f => (
{['all', 'draft', 'in_review', 'approved', 'needs_update'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
@@ -388,22 +496,31 @@ export default function DSFAPage() {
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' :
f === 'draft' ? 'Entwuerfe' :
f === 'in-review' ? 'In Pruefung' :
f === 'approved' ? 'Genehmigt' : 'Update erforderlich'}
{f === 'all' ? 'Alle' : DSFA_STATUS_LABELS[f as DSFAStatus] || f}
</button>
))}
</div>
{/* DSFA List */}
<div className="space-y-4">
{filteredDSFAs.map(dsfa => (
<DSFACard key={dsfa.id} dsfa={dsfa} />
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<div className="space-y-4">
{filteredDSFAs.map(dsfa => (
<DSFACard
key={dsfa.id}
dsfa={dsfa}
onDelete={handleDelete}
onExport={handleExport}
/>
))}
</div>
)}
{filteredDSFAs.length === 0 && !showGenerator && (
{/* Empty State */}
{!isLoading && filteredDSFAs.length === 0 && !showGenerator && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -411,13 +528,19 @@ export default function DSFAPage() {
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine DSFAs gefunden</h3>
<p className="mt-2 text-gray-500">Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.</p>
<button
onClick={() => setShowGenerator(true)}
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste DSFA erstellen
</button>
<p className="mt-2 text-gray-500">
{filter !== 'all'
? `Keine DSFAs mit Status "${DSFA_STATUS_LABELS[filter as DSFAStatus]}".`
: 'Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.'}
</p>
{filter === 'all' && (
<button
onClick={() => setShowGenerator(true)}
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste DSFA erstellen
</button>
)}
</div>
)}
</div>

View File

@@ -2,9 +2,7 @@
import React from 'react'
import Link from 'next/link'
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
import { CustomerTypeSelector } from '@/components/sdk/CustomerTypeSelector'
import type { CustomerType, SDKPackageId } from '@/lib/sdk/types'
import { useSDK, getStepsForPhase } from '@/lib/sdk'
// =============================================================================
// DASHBOARD CARDS
@@ -37,65 +35,49 @@ function StatCard({
)
}
function PackageCard({
pkg,
function PhaseCard({
phase,
title,
description,
completion,
stepsCount,
isLocked,
steps,
href,
}: {
pkg: (typeof SDK_PACKAGES)[number]
phase: number
title: string
description: string
completion: number
stepsCount: number
isLocked: boolean
steps: number
href: string
}) {
const steps = getStepsForPackage(pkg.id)
const firstStep = steps[0]
const href = firstStep?.url || '/sdk'
const content = (
<div
className={`block bg-white rounded-xl border-2 p-6 transition-all ${
isLocked
? 'border-gray-100 opacity-60 cursor-not-allowed'
: completion === 100
? 'border-green-200 hover:border-green-300 hover:shadow-lg'
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
}`}
return (
<Link
href={href}
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-lg transition-all"
>
<div className="flex items-start gap-4">
<div
className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
isLocked
? 'bg-gray-100 text-gray-400'
: completion === 100
className={`w-12 h-12 rounded-xl flex items-center justify-center text-xl font-bold ${
completion === 100
? 'bg-green-100 text-green-600'
: 'bg-purple-100 text-purple-600'
}`}
>
{isLocked ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : completion === 100 ? (
{completion === 100 ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
pkg.icon
phase
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{pkg.order}.</span>
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>
</div>
<p className="mt-1 text-sm text-gray-500">{pkg.description}</p>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-1 text-sm text-gray-500">{description}</p>
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">{stepsCount} Schritte</span>
<span className={`font-medium ${completion === 100 ? 'text-green-600' : 'text-purple-600'}`}>
{completion}%
</span>
<span className="text-gray-500">{steps} Schritte</span>
<span className="font-medium text-purple-600">{completion}%</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
@@ -106,23 +88,8 @@ function PackageCard({
/>
</div>
</div>
{!isLocked && (
<p className="mt-3 text-xs text-gray-400">
Ergebnis: {pkg.result}
</p>
)}
</div>
</div>
</div>
)
if (isLocked) {
return content
}
return (
<Link href={href}>
{content}
</Link>
)
}
@@ -162,63 +129,24 @@ function QuickActionCard({
// =============================================================================
export default function SDKDashboard() {
const { state, packageCompletion, completionPercentage, setCustomerType } = useSDK()
const { state, phase1Completion, phase2Completion, completionPercentage } = useSDK()
// Calculate total steps
const totalSteps = SDK_PACKAGES.reduce((sum, pkg) => {
const steps = getStepsForPackage(pkg.id)
// Filter import step for new customers
return sum + steps.filter(s => !(s.id === 'import' && state.customerType === 'new')).length
}, 0)
const phase1Steps = getStepsForPhase(1)
const phase2Steps = getStepsForPhase(2)
// Calculate stats
const completedCheckpoints = Object.values(state.checkpoints).filter(cp => cp.passed).length
const totalRisks = state.risks.length
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL' || r.severity === 'HIGH').length
const isPackageLocked = (packageId: SDKPackageId): boolean => {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
// Check if previous package is complete
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
// Show customer type selector if not set
if (!state.customerType) {
return (
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center py-12">
<CustomerTypeSelector
onSelect={(type: CustomerType) => {
setCustomerType(type)
}}
/>
</div>
)
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
<p className="mt-1 text-gray-500">
{state.customerType === 'new'
? 'Neukunden-Modus: Erstellen Sie alle Compliance-Dokumente von Grund auf.'
: 'Bestandskunden-Modus: Erweitern Sie bestehende Dokumente.'}
</p>
</div>
<button
onClick={() => setCustomerType(state.customerType === 'new' ? 'existing' : 'new')}
className="text-sm text-purple-600 hover:text-purple-700 underline"
>
{state.customerType === 'new' ? 'Zu Bestandskunden wechseln' : 'Zu Neukunden wechseln'}
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
<p className="mt-1 text-gray-500">
Willkommen zum Compliance Assessment. Starten Sie mit Phase 1 oder setzen Sie Ihre Arbeit fort.
</p>
</div>
{/* Stats Grid */}
@@ -226,7 +154,7 @@ export default function SDKDashboard() {
<StatCard
title="Gesamtfortschritt"
value={`${completionPercentage}%`}
subtitle={`${state.completedSteps.length} von ${totalSteps} Schritten`}
subtitle={`${state.completedSteps.length} von ${phase1Steps.length + phase2Steps.length} Schritten`}
icon={
<svg className="w-6 h-6 text-purple-600" 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" />
@@ -247,7 +175,7 @@ export default function SDKDashboard() {
/>
<StatCard
title="Checkpoints"
value={`${completedCheckpoints}/${totalSteps}`}
value={`${completedCheckpoints}/${phase1Steps.length + phase2Steps.length}`}
subtitle="Validiert"
icon={
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -269,85 +197,26 @@ export default function SDKDashboard() {
/>
</div>
{/* Bestandskunden: Gap Analysis Banner */}
{state.customerType === 'existing' && state.importedDocuments.length === 0 && (
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-2xl">📄</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">Bestehende Dokumente importieren</h3>
<p className="mt-1 text-gray-600">
Laden Sie Ihre vorhandenen Compliance-Dokumente hoch. Unsere KI analysiert sie und zeigt Ihnen, welche Erweiterungen fuer KI-Compliance erforderlich sind.
</p>
<Link
href="/sdk/import"
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<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>
Dokumente hochladen
</Link>
</div>
</div>
</div>
)}
{/* Gap Analysis Results */}
{state.gapAnalysis && (
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<span className="text-xl">📊</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
<p className="text-sm text-gray-500">
{state.gapAnalysis.totalGaps} Luecken gefunden
</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{state.gapAnalysis.criticalGaps}</div>
<div className="text-xs text-red-600">Kritisch</div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">{state.gapAnalysis.highGaps}</div>
<div className="text-xs text-orange-600">Hoch</div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{state.gapAnalysis.mediumGaps}</div>
<div className="text-xs text-yellow-600">Mittel</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{state.gapAnalysis.lowGaps}</div>
<div className="text-xs text-green-600">Niedrig</div>
</div>
</div>
</div>
)}
{/* 5 Packages */}
{/* Phases */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{SDK_PACKAGES.map(pkg => {
const steps = getStepsForPackage(pkg.id)
const visibleSteps = steps.filter(s => !(s.id === 'import' && state.customerType === 'new'))
return (
<PackageCard
key={pkg.id}
pkg={pkg}
completion={packageCompletion[pkg.id]}
stepsCount={visibleSteps.length}
isLocked={isPackageLocked(pkg.id)}
/>
)
})}
<h2 className="text-lg font-semibold text-gray-900 mb-4">Phasen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<PhaseCard
phase={1}
title="Compliance Assessment"
description="Use Case erfassen, Screening durchführen, Risiken bewerten"
completion={phase1Completion}
steps={phase1Steps.length}
href="/sdk/advisory-board"
/>
<PhaseCard
phase={2}
title="Dokumentengenerierung"
description="DSFA, TOMs, VVT, Cookie Banner und mehr generieren"
completion={phase2Completion}
steps={phase2Steps.length}
href="/sdk/ai-act"
/>
</div>
</div>
@@ -379,7 +248,7 @@ export default function SDKDashboard() {
/>
<QuickActionCard
title="DSFA generieren"
description="Datenschutz-Folgenabschaetzung erstellen"
description="Datenschutz-Folgenabschätzung erstellen"
icon={
<svg className="w-6 h-6 text-blue-600" 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" />
@@ -405,7 +274,7 @@ export default function SDKDashboard() {
{/* Recent Activity */}
{state.commandBarHistory.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitaeten</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitäten</h2>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{state.commandBarHistory.slice(0, 5).map(entry => (
<div key={entry.id} className="flex items-center gap-4 px-4 py-3">

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/admin/companion/feedback
* Submit feedback (bug report, feature request, general feedback)
* Proxy to backend /api/feedback
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate required fields
if (!body.type || !body.title || !body.description) {
return NextResponse.json(
{
success: false,
error: 'Missing required fields: type, title, description',
},
{ status: 400 }
)
}
// Validate feedback type
const validTypes = ['bug', 'feature', 'feedback']
if (!validTypes.includes(body.type)) {
return NextResponse.json(
{
success: false,
error: 'Invalid feedback type. Must be: bug, feature, or feedback',
},
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/feedback`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify({
// type: body.type,
// title: body.title,
// description: body.description,
// screenshot: body.screenshot,
// sessionId: body.sessionId,
// metadata: {
// ...body.metadata,
// source: 'companion',
// timestamp: new Date().toISOString(),
// userAgent: request.headers.get('user-agent'),
// },
// }),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the submission
const feedbackId = `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
console.log('Feedback received:', {
id: feedbackId,
type: body.type,
title: body.title,
description: body.description.substring(0, 100) + '...',
hasScreenshot: !!body.screenshot,
sessionId: body.sessionId,
})
return NextResponse.json({
success: true,
message: 'Feedback submitted successfully',
data: {
feedbackId,
submittedAt: new Date().toISOString(),
},
})
} catch (error) {
console.error('Submit feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/admin/companion/feedback
* Get feedback history (admin only)
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')
const limit = parseInt(searchParams.get('limit') || '10')
// TODO: Replace with actual backend call
// Mock response - empty list for now
return NextResponse.json({
success: true,
data: {
feedback: [],
total: 0,
page: 1,
limit,
},
})
} catch (error) {
console.error('Get feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/admin/companion/lesson
* Start a new lesson session
* Proxy to backend /api/classroom/sessions
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call
// const response = await fetch(`${backendUrl}/api/classroom/sessions`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - create a new session
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const mockSession = {
success: true,
data: {
sessionId,
classId: body.classId,
className: body.className || body.classId,
subject: body.subject,
topic: body.topic,
startTime: new Date().toISOString(),
phases: [
{ phase: 'einstieg', duration: 8, status: 'active', actualTime: 0 },
{ phase: 'erarbeitung', duration: 20, status: 'planned', actualTime: 0 },
{ phase: 'sicherung', duration: 10, status: 'planned', actualTime: 0 },
{ phase: 'transfer', duration: 7, status: 'planned', actualTime: 0 },
{ phase: 'reflexion', duration: 5, status: 'planned', actualTime: 0 },
],
totalPlannedDuration: 50,
currentPhaseIndex: 0,
elapsedTime: 0,
isPaused: false,
pauseDuration: 0,
overtimeMinutes: 0,
status: 'in_progress',
homeworkList: [],
materials: [],
},
}
return NextResponse.json(mockSession)
} catch (error) {
console.error('Start lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/admin/companion/lesson
* Get current lesson session or list of recent sessions
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const url = sessionId
// ? `${backendUrl}/api/classroom/sessions/${sessionId}`
// : `${backendUrl}/api/classroom/sessions`
//
// const response = await fetch(url)
// const data = await response.json()
// return NextResponse.json(data)
// Mock response
if (sessionId) {
return NextResponse.json({
success: true,
data: null, // No active session stored on server in mock
})
}
return NextResponse.json({
success: true,
data: {
sessions: [], // Empty list for now
},
})
} catch (error) {
console.error('Get lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/admin/companion/lesson
* Update lesson session (timer state, phase changes, etc.)
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
const { sessionId, ...updates } = body
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/classroom/sessions/${sessionId}`, {
// method: 'PATCH',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(updates),
// })
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the update
return NextResponse.json({
success: true,
message: 'Session updated',
})
} catch (error) {
console.error('Update lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* DELETE /api/admin/companion/lesson
* End/delete a lesson session
*/
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Session ended',
})
} catch (error) {
console.error('End lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,102 @@
import { NextResponse } from 'next/server'
/**
* GET /api/admin/companion
* Proxy to backend /api/state/dashboard for companion dashboard data
*/
export async function GET() {
try {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call when endpoint is available
// const response = await fetch(`${backendUrl}/api/state/dashboard`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response for development
const mockData = {
success: true,
data: {
context: {
currentPhase: 'erarbeitung',
phaseDisplayName: 'Erarbeitung',
},
stats: {
classesCount: 4,
studentsCount: 96,
learningUnitsCreated: 23,
gradesEntered: 156,
},
phases: [
{ id: 'einstieg', shortName: 'E', displayName: 'Einstieg', duration: 8, status: 'completed', color: '#4A90E2' },
{ id: 'erarbeitung', shortName: 'A', displayName: 'Erarbeitung', duration: 20, status: 'active', color: '#F5A623' },
{ id: 'sicherung', shortName: 'S', displayName: 'Sicherung', duration: 10, status: 'planned', color: '#7ED321' },
{ id: 'transfer', shortName: 'T', displayName: 'Transfer', duration: 7, status: 'planned', color: '#9013FE' },
{ id: 'reflexion', shortName: 'R', displayName: 'Reflexion', duration: 5, status: 'planned', color: '#6B7280' },
],
progress: {
percentage: 65,
completed: 13,
total: 20,
},
suggestions: [
{
id: '1',
title: 'Klausuren korrigieren',
description: 'Deutsch LK - 12 unkorrigierte Arbeiten warten',
priority: 'urgent',
icon: 'ClipboardCheck',
actionTarget: '/ai/klausur-korrektur',
estimatedTime: 120,
},
{
id: '2',
title: 'Elternsprechtag vorbereiten',
description: 'Notenuebersicht fuer 8b erstellen',
priority: 'high',
icon: 'Users',
actionTarget: '/education/grades',
estimatedTime: 30,
},
],
upcomingEvents: [
{
id: 'e1',
title: 'Mathe-Test 9b',
date: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
type: 'exam',
inDays: 2,
},
{
id: 'e2',
title: 'Elternsprechtag',
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
type: 'parent_meeting',
inDays: 5,
},
],
},
}
return NextResponse.json(mockData)
} catch (error) {
console.error('Companion dashboard error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server'
const DEFAULT_SETTINGS = {
defaultPhaseDurations: {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5,
},
preferredLessonLength: 45,
autoAdvancePhases: true,
soundNotifications: true,
showKeyboardShortcuts: true,
highContrastMode: false,
onboardingCompleted: false,
}
/**
* GET /api/admin/companion/settings
* Get teacher settings
* Proxy to backend /api/teacher/settings
*/
export async function GET() {
try {
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - return default settings
return NextResponse.json({
success: true,
data: DEFAULT_SETTINGS,
})
} catch (error) {
console.error('Get settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PUT /api/admin/companion/settings
* Update teacher settings
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
// Validate the settings structure
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ success: false, error: 'Invalid settings data' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the save
return NextResponse.json({
success: true,
message: 'Settings saved',
data: body,
})
} catch (error) {
console.error('Save settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/admin/companion/settings
* Partially update teacher settings
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Settings updated',
data: body,
})
} catch (error) {
console.error('Update settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,215 @@
import { NextRequest, NextResponse } from 'next/server'
// Backend URL - klausur-service
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Helper to proxy requests to backend
async function proxyRequest(
endpoint: string,
method: string = 'GET',
body?: any
): Promise<Response> {
const url = `${KLAUSUR_SERVICE_URL}${endpoint}`
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
},
}
if (body) {
options.body = JSON.stringify(body)
}
return fetch(url, options)
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const action = searchParams.get('action')
const jobId = searchParams.get('job_id')
const versionId = searchParams.get('version_id')
try {
let response: Response
switch (action) {
case 'jobs':
response = await proxyRequest('/api/v1/admin/training/jobs')
break
case 'job':
if (!jobId) {
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`)
break
case 'models':
response = await proxyRequest('/api/v1/admin/training/models')
break
case 'model':
if (!versionId) {
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`)
break
case 'dataset-stats':
response = await proxyRequest('/api/v1/admin/training/dataset/stats')
break
case 'status':
response = await proxyRequest('/api/v1/admin/training/status')
break
default:
return NextResponse.json(
{
error: 'Unknown action',
validActions: ['jobs', 'job', 'models', 'model', 'dataset-stats', 'status']
},
{ status: 400 }
)
}
if (!response.ok) {
const errorText = await response.text()
console.error(`Backend error: ${response.status} - ${errorText}`)
return NextResponse.json(
{ error: 'Backend error', detail: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Training API proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend', detail: String(error) },
{ status: 503 }
)
}
}
export async function POST(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const action = searchParams.get('action')
const jobId = searchParams.get('job_id')
const versionId = searchParams.get('version_id')
try {
let response: Response
let body: any = null
// Parse body if present
try {
const text = await request.text()
if (text) {
body = JSON.parse(text)
}
} catch {
// No body or invalid JSON
}
switch (action) {
case 'create-job':
if (!body) {
return NextResponse.json({ error: 'Body required for job creation' }, { status: 400 })
}
response = await proxyRequest('/api/v1/admin/training/jobs', 'POST', body)
break
case 'pause':
if (!jobId) {
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/pause`, 'POST')
break
case 'resume':
if (!jobId) {
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/resume`, 'POST')
break
case 'cancel':
if (!jobId) {
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/cancel`, 'POST')
break
case 'activate-model':
if (!versionId) {
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}/activate`, 'POST')
break
default:
return NextResponse.json(
{
error: 'Unknown action',
validActions: ['create-job', 'pause', 'resume', 'cancel', 'activate-model']
},
{ status: 400 }
)
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
return NextResponse.json(errorData, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Training API proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend', detail: String(error) },
{ status: 503 }
)
}
}
export async function DELETE(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const jobId = searchParams.get('job_id')
const versionId = searchParams.get('version_id')
try {
let response: Response
if (jobId) {
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`, 'DELETE')
} else if (versionId) {
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`, 'DELETE')
} else {
return NextResponse.json(
{ error: 'Either job_id or version_id required' },
{ status: 400 }
)
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
return NextResponse.json(errorData, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Training API proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend', detail: String(error) },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,68 @@
/**
* Content API Route
*
* GET: Load current website content
* POST: Save changed content (Admin only)
*/
import { NextRequest, NextResponse } from 'next/server'
import { getContent, saveContent } from '@/lib/content'
import type { WebsiteContent } from '@/lib/content-types'
// GET - Load content
export async function GET() {
try {
const content = getContent()
return NextResponse.json(content)
} catch (error) {
console.error('Error loading content:', error)
return NextResponse.json(
{ error: 'Failed to load content' },
{ status: 500 }
)
}
}
// POST - Save content
export async function POST(request: NextRequest) {
try {
// Simple admin check via header or query
// In production: JWT/Session-based auth
const adminKey = request.headers.get('x-admin-key')
const expectedKey = process.env.ADMIN_API_KEY || 'breakpilot-admin-2024'
if (adminKey !== expectedKey) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const content: WebsiteContent = await request.json()
// Validation
if (!content.hero || !content.features || !content.faq || !content.pricing) {
return NextResponse.json(
{ error: 'Invalid content structure' },
{ status: 400 }
)
}
const result = saveContent(content)
if (result.success) {
return NextResponse.json({ success: true, message: 'Content saved' })
} else {
return NextResponse.json(
{ error: result.error || 'Failed to save content' },
{ status: 500 }
)
}
} catch (error) {
console.error('Error saving content:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to save content' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,246 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Abitur-Archiv API Route
* Extends abitur-docs with theme search and enhanced filtering
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
// Check for theme/semantic search
const thema = searchParams.get('thema')
if (thema) {
// Use semantic search endpoint
return await handleSemanticSearch(thema, searchParams)
}
// Forward all query params to backend abitur-docs
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/abitur-docs/${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
// Return mock data for development if backend is not available
if (response.status === 404 || response.status === 502) {
return NextResponse.json(getMockDocuments(searchParams))
}
throw new Error(`Backend responded with ${response.status}`)
}
const data = await response.json()
// If backend returns empty, use mock data for demo
if (data.documents && Array.isArray(data.documents) && data.documents.length === 0 && data.total === 0) {
return NextResponse.json(getMockDocuments(searchParams))
}
// Enhance response with theme information
return NextResponse.json({
...data,
themes: extractThemes(data.documents || [])
})
} catch (error) {
console.error('Abitur-Archiv error:', error)
return NextResponse.json(getMockDocuments(new URL(request.url).searchParams))
}
}
async function handleSemanticSearch(thema: string, searchParams: URLSearchParams) {
try {
// Try to call RAG search endpoint
const url = `${BACKEND_URL}/api/rag/search`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: thema,
collection: 'abitur_documents',
limit: parseInt(searchParams.get('limit') || '20'),
filters: {
fach: searchParams.get('fach') || undefined,
jahr: searchParams.get('jahr') ? parseInt(searchParams.get('jahr')!) : undefined,
bundesland: searchParams.get('bundesland') || undefined,
niveau: searchParams.get('niveau') || undefined,
typ: searchParams.get('typ') || undefined,
}
}),
})
if (response.ok) {
const data = await response.json()
return NextResponse.json({
documents: data.results || [],
total: data.total || 0,
page: 1,
limit: parseInt(searchParams.get('limit') || '20'),
total_pages: 1,
search_query: thema
})
}
} catch (error) {
console.log('RAG search not available, falling back to mock')
}
// Fallback to filtered mock data
return NextResponse.json(getMockDocumentsWithTheme(thema, searchParams))
}
function getMockDocuments(searchParams: URLSearchParams) {
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const fach = searchParams.get('fach')
const jahr = searchParams.get('jahr')
const bundesland = searchParams.get('bundesland')
const niveau = searchParams.get('niveau')
const typ = searchParams.get('typ')
// Generate mock documents
const allDocs = generateMockDocs()
// Apply filters
let filtered = allDocs
if (fach) filtered = filtered.filter(d => d.fach === fach)
if (jahr) filtered = filtered.filter(d => d.jahr === parseInt(jahr))
if (bundesland) filtered = filtered.filter(d => d.bundesland === bundesland)
if (niveau) filtered = filtered.filter(d => d.niveau === niveau)
if (typ) filtered = filtered.filter(d => d.typ === typ)
// Paginate
const start = (page - 1) * limit
const docs = filtered.slice(start, start + limit)
return {
documents: docs,
total: filtered.length,
page,
limit,
total_pages: Math.ceil(filtered.length / limit),
themes: extractThemes(docs)
}
}
function getMockDocumentsWithTheme(thema: string, searchParams: URLSearchParams) {
const limit = parseInt(searchParams.get('limit') || '20')
const allDocs = generateMockDocs()
// Simple theme matching (in production this would be semantic search)
const themaLower = thema.toLowerCase()
let filtered = allDocs
// Match theme to aufgabentyp keywords
if (themaLower.includes('gedicht')) {
filtered = filtered.filter(d => d.themes?.includes('gedichtanalyse'))
} else if (themaLower.includes('drama')) {
filtered = filtered.filter(d => d.themes?.includes('dramenanalyse'))
} else if (themaLower.includes('prosa') || themaLower.includes('roman')) {
filtered = filtered.filter(d => d.themes?.includes('prosaanalyse'))
} else if (themaLower.includes('eroerterung')) {
filtered = filtered.filter(d => d.themes?.includes('eroerterung'))
} else if (themaLower.includes('text') || themaLower.includes('analyse')) {
filtered = filtered.filter(d => d.themes?.includes('textanalyse'))
}
// Apply additional filters
const fach = searchParams.get('fach')
const jahr = searchParams.get('jahr')
if (fach) filtered = filtered.filter(d => d.fach === fach)
if (jahr) filtered = filtered.filter(d => d.jahr === parseInt(jahr))
return {
documents: filtered.slice(0, limit),
total: filtered.length,
page: 1,
limit,
total_pages: Math.ceil(filtered.length / limit),
search_query: thema,
themes: extractThemes(filtered)
}
}
function generateMockDocs() {
const faecher = ['deutsch', 'englisch']
const jahre = [2021, 2022, 2023, 2024, 2025]
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
const aufgabentypen = [
{ nummer: 'I', themes: ['textanalyse', 'sachtext'] },
{ nummer: 'II', themes: ['gedichtanalyse', 'lyrik'] },
{ nummer: 'III', themes: ['prosaanalyse', 'epik'] },
]
const docs = []
let id = 1
for (const jahr of jahre) {
for (const fach of faecher) {
for (const niveau of niveaus) {
for (const aufgabe of aufgabentypen) {
for (const typ of typen) {
const suffix = typ === 'erwartungshorizont' ? '_EWH' : ''
const dateiname = `${jahr}_${capitalize(fach)}_${niveau}_Aufgabe_${aufgabe.nummer}${suffix}.pdf`
docs.push({
id: `doc-${id++}`,
dateiname,
original_dateiname: dateiname,
bundesland: 'niedersachsen',
fach,
jahr,
niveau,
typ,
aufgaben_nummer: aufgabe.nummer,
themes: aufgabe.themes,
status: 'indexed' as const,
confidence: 0.92 + Math.random() * 0.08,
file_path: `/api/education/abitur-archiv/file/${dateiname}`,
file_size: Math.floor(Math.random() * 500000) + 100000,
indexed: true,
vector_ids: [`vec-${id}-1`, `vec-${id}-2`],
created_at: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date().toISOString(),
})
}
}
}
}
}
return docs
}
function extractThemes(documents: any[]) {
const themeCounts = new Map<string, number>()
for (const doc of documents) {
const themes = doc.themes || []
for (const theme of themes) {
themeCounts.set(theme, (themeCounts.get(theme) || 0) + 1)
}
}
return Array.from(themeCounts.entries())
.map(([label, count]) => ({
label: capitalize(label),
count,
aufgabentyp: label,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Theme Suggestions API for Abitur-Archiv
* Returns autocomplete suggestions for semantic search
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q') || ''
if (query.length < 2) {
return NextResponse.json({ suggestions: [], query })
}
// Try to get suggestions from backend
try {
const url = `${BACKEND_URL}/api/abitur-archiv/suggest?q=${encodeURIComponent(query)}`
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})
if (response.ok) {
const data = await response.json()
return NextResponse.json(data)
}
} catch (error) {
console.log('Backend suggest not available, using static suggestions')
}
// Fallback to static suggestions
return NextResponse.json({
suggestions: getStaticSuggestions(query),
query
})
} catch (error) {
console.error('Suggest error:', error)
return NextResponse.json({ suggestions: [], query: '' })
}
}
function getStaticSuggestions(query: string) {
const allSuggestions = [
// Textanalyse
{ label: 'Textanalyse', count: 45, aufgabentyp: 'textanalyse', kategorie: 'Analyse' },
{ label: 'Textanalyse Sachtext', count: 28, aufgabentyp: 'textanalyse_pragmatisch', kategorie: 'Analyse' },
{ label: 'Textanalyse Rede', count: 12, aufgabentyp: 'textanalyse_rede', kategorie: 'Analyse' },
{ label: 'Textanalyse Kommentar', count: 8, aufgabentyp: 'textanalyse_kommentar', kategorie: 'Analyse' },
// Gedichtanalyse
{ label: 'Gedichtanalyse', count: 38, aufgabentyp: 'gedichtanalyse', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Romantik', count: 15, aufgabentyp: 'gedichtanalyse', zeitraum: 'Romantik', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Expressionismus', count: 12, aufgabentyp: 'gedichtanalyse', zeitraum: 'Expressionismus', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Barock', count: 8, aufgabentyp: 'gedichtanalyse', zeitraum: 'Barock', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Klassik', count: 10, aufgabentyp: 'gedichtanalyse', zeitraum: 'Klassik', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Moderne', count: 14, aufgabentyp: 'gedichtanalyse', zeitraum: 'Moderne', kategorie: 'Lyrik' },
{ label: 'Gedichtvergleich', count: 18, aufgabentyp: 'gedichtvergleich', kategorie: 'Lyrik' },
// Dramenanalyse
{ label: 'Dramenanalyse', count: 28, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
{ label: 'Dramenanalyse Faust', count: 14, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
{ label: 'Dramenanalyse Woyzeck', count: 8, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
{ label: 'Episches Theater Brecht', count: 10, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
{ label: 'Szenenanalyse', count: 22, aufgabentyp: 'szenenanalyse', kategorie: 'Drama' },
// Prosaanalyse
{ label: 'Prosaanalyse', count: 25, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
{ label: 'Romananalyse', count: 18, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
{ label: 'Kurzgeschichte', count: 20, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
{ label: 'Novelle', count: 12, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
{ label: 'Erzaehlung', count: 15, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
// Eroerterung
{ label: 'Eroerterung', count: 32, aufgabentyp: 'eroerterung', kategorie: 'Argumentation' },
{ label: 'Eroerterung textgebunden', count: 18, aufgabentyp: 'eroerterung_textgebunden', kategorie: 'Argumentation' },
{ label: 'Eroerterung materialgestuetzt', count: 14, aufgabentyp: 'eroerterung_materialgestuetzt', kategorie: 'Argumentation' },
{ label: 'Stellungnahme', count: 10, aufgabentyp: 'stellungnahme', kategorie: 'Argumentation' },
// Sprachreflexion
{ label: 'Sprachreflexion', count: 15, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
{ label: 'Sprachwandel', count: 8, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
{ label: 'Sprachkritik', count: 6, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
{ label: 'Kommunikation', count: 10, aufgabentyp: 'kommunikation', kategorie: 'Sprache' },
// Vergleich
{ label: 'Vergleichende Analyse', count: 20, aufgabentyp: 'vergleich', kategorie: 'Vergleich' },
{ label: 'Epochenvergleich', count: 12, aufgabentyp: 'epochenvergleich', kategorie: 'Vergleich' },
]
const queryLower = query.toLowerCase()
// Filter suggestions based on query
return allSuggestions
.filter(s =>
s.label.toLowerCase().includes(queryLower) ||
s.aufgabentyp.toLowerCase().includes(queryLower) ||
(s.zeitraum && s.zeitraum.toLowerCase().includes(queryLower)) ||
s.kategorie.toLowerCase().includes(queryLower)
)
.slice(0, 8)
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Proxy to backend /api/abitur-docs
* Lists and manages Abitur documents (NiBiS, etc.)
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
// Forward all query params to backend
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/abitur-docs/${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
// Return mock data for development if backend is not available
if (response.status === 404 || response.status === 502) {
return NextResponse.json(getMockDocuments(searchParams))
}
throw new Error(`Backend responded with ${response.status}`)
}
const data = await response.json()
// If backend returns empty array, use mock data for demo purposes
// (Backend uses in-memory storage which is lost on restart)
if (Array.isArray(data) && data.length === 0) {
console.log('Backend returned empty array, using mock data')
return NextResponse.json(getMockDocuments(searchParams))
}
// Handle paginated response with empty documents
if (data.documents && Array.isArray(data.documents) && data.documents.length === 0 && data.total === 0) {
console.log('Backend returned empty documents, using mock data')
return NextResponse.json(getMockDocuments(searchParams))
}
return NextResponse.json(data)
} catch (error) {
console.error('Abitur docs list error:', error)
// Return mock data for development
return NextResponse.json(getMockDocuments(new URL(request.url).searchParams))
}
}
function getMockDocuments(searchParams: URLSearchParams) {
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const fach = searchParams.get('fach')
const jahr = searchParams.get('jahr')
const bundesland = searchParams.get('bundesland')
// Generate mock documents
const allDocs = generateMockDocs()
// Apply filters
let filtered = allDocs
if (fach) {
filtered = filtered.filter(d => d.fach === fach)
}
if (jahr) {
filtered = filtered.filter(d => d.jahr === parseInt(jahr))
}
if (bundesland) {
filtered = filtered.filter(d => d.bundesland === bundesland)
}
// Paginate
const start = (page - 1) * limit
const docs = filtered.slice(start, start + limit)
return {
documents: docs,
total: filtered.length,
page,
limit,
total_pages: Math.ceil(filtered.length / limit),
}
}
function generateMockDocs() {
const faecher = ['deutsch', 'mathematik', 'englisch', 'biologie', 'physik', 'chemie', 'geschichte']
const jahre = [2024, 2025]
const niveaus = ['eA', 'gA']
const typen = ['aufgabe', 'erwartungshorizont']
const nummern = ['I', 'II', 'III']
const docs = []
let id = 1
for (const jahr of jahre) {
for (const fach of faecher) {
for (const niveau of niveaus) {
for (const nummer of nummern) {
for (const typ of typen) {
const suffix = typ === 'erwartungshorizont' ? '_EWH' : ''
const dateiname = `${jahr}_${capitalize(fach)}_${niveau}_${nummer}${suffix}.pdf`
docs.push({
id: `doc-${id++}`,
dateiname,
original_dateiname: dateiname,
bundesland: 'niedersachsen',
fach,
jahr,
niveau,
typ,
aufgaben_nummer: nummer,
status: 'indexed',
confidence: 0.95,
file_path: `/tmp/abitur-docs/${dateiname}`,
file_size: Math.floor(Math.random() * 500000) + 100000,
indexed: true,
vector_ids: [`vec-${id}-1`, `vec-${id}-2`],
created_at: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date().toISOString(),
})
}
}
}
}
}
return docs
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}

View File

@@ -0,0 +1,395 @@
import { NextRequest, NextResponse } from 'next/server'
import type { ExtractedError, ErrorCategory, LogExtractionResponse } from '@/types/infrastructure-modules'
// Woodpecker API configuration
const WOODPECKER_URL = process.env.WOODPECKER_URL || 'http://woodpecker-server:8000'
const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN || ''
// =============================================================================
// Error Pattern Matching
// =============================================================================
interface ErrorPattern {
pattern: RegExp
category: ErrorCategory
extractMessage?: (match: RegExpMatchArray, line: string) => string
}
/**
* Patterns fuer verschiedene Fehlertypen in CI/CD Logs
*/
const ERROR_PATTERNS: ErrorPattern[] = [
// Test Failures
{
pattern: /^(FAIL|FAILED|ERROR):?\s+(.+)$/i,
category: 'test_failure',
extractMessage: (match, line) => match[2] || line,
},
{
pattern: /^---\s+FAIL:\s+(.+)\s+\([\d.]+s\)$/,
category: 'test_failure',
extractMessage: (match) => `Test failed: ${match[1]}`,
},
{
pattern: /pytest.*FAILED\s+(.+)$/,
category: 'test_failure',
extractMessage: (match) => `pytest: ${match[1]}`,
},
{
pattern: /AssertionError:\s+(.+)$/,
category: 'test_failure',
extractMessage: (match) => `Assertion failed: ${match[1]}`,
},
{
pattern: /FAIL\s+[\w\/]+\s+\[build failed\]/,
category: 'build_error',
},
// Build Errors
{
pattern: /^(error|Error)\[[\w-]+\]:\s+(.+)$/,
category: 'build_error',
extractMessage: (match) => match[2],
},
{
pattern: /cannot find (module|package)\s+["'](.+)["']/i,
category: 'build_error',
extractMessage: (match) => `Missing ${match[1]}: ${match[2]}`,
},
{
pattern: /undefined:\s+(.+)$/,
category: 'build_error',
extractMessage: (match) => `Undefined: ${match[1]}`,
},
{
pattern: /compilation failed/i,
category: 'build_error',
},
{
pattern: /npm ERR!\s+(.+)$/,
category: 'build_error',
extractMessage: (match) => `npm error: ${match[1]}`,
},
{
pattern: /go:\s+(.+):\s+(.+)$/,
category: 'build_error',
extractMessage: (match) => `Go: ${match[1]}: ${match[2]}`,
},
// Security Warnings
{
pattern: /\[CRITICAL\]\s+(.+)$/i,
category: 'security_warning',
extractMessage: (match) => `Critical: ${match[1]}`,
},
{
pattern: /\[HIGH\]\s+(.+)$/i,
category: 'security_warning',
extractMessage: (match) => `High severity: ${match[1]}`,
},
{
pattern: /CVE-\d{4}-\d+/,
category: 'security_warning',
extractMessage: (match, line) => line.trim(),
},
{
pattern: /vulnerability found/i,
category: 'security_warning',
},
{
pattern: /secret.*detected/i,
category: 'security_warning',
},
{
pattern: /gitleaks.*found/i,
category: 'security_warning',
},
{
pattern: /semgrep.*finding/i,
category: 'security_warning',
},
// License Violations
{
pattern: /license.*violation/i,
category: 'license_violation',
},
{
pattern: /incompatible license/i,
category: 'license_violation',
},
{
pattern: /AGPL|GPL-3|SSPL/,
category: 'license_violation',
extractMessage: (match, line) => `Potentially problematic license found: ${match[0]}`,
},
// Dependency Issues
{
pattern: /dependency.*not found/i,
category: 'dependency_issue',
},
{
pattern: /outdated.*dependency/i,
category: 'dependency_issue',
},
{
pattern: /version conflict/i,
category: 'dependency_issue',
},
]
/**
* Patterns to extract file paths from error lines
*/
const FILE_PATH_PATTERNS = [
/([\/\w.-]+\.(go|py|ts|tsx|js|jsx|rs)):(\d+)/,
/File "([^"]+)", line (\d+)/,
/at ([\/\w.-]+):(\d+):\d+/,
]
/**
* Patterns to extract service names from log lines or paths
*/
const SERVICE_PATTERNS = [
/service[s]?\/([a-z-]+)/i,
/\/([a-z-]+-service)\//i,
/^([a-z-]+):\s/,
]
// =============================================================================
// Log Parsing Functions
// =============================================================================
interface LogLine {
pos: number
out: string
time: number
}
function extractFilePath(line: string): { path?: string; lineNumber?: number } {
for (const pattern of FILE_PATH_PATTERNS) {
const match = line.match(pattern)
if (match) {
return {
path: match[1],
lineNumber: parseInt(match[2] || match[3], 10) || undefined,
}
}
}
return {}
}
function extractService(line: string, filePath?: string): string | undefined {
// First try to extract from file path
if (filePath) {
for (const pattern of SERVICE_PATTERNS) {
const match = filePath.match(pattern)
if (match) return match[1]
}
}
// Then try from the line itself
for (const pattern of SERVICE_PATTERNS) {
const match = line.match(pattern)
if (match) return match[1]
}
return undefined
}
function parseLogLines(logs: LogLine[], stepName: string): ExtractedError[] {
const errors: ExtractedError[] = []
const seenMessages = new Set<string>()
for (const logLine of logs) {
const line = logLine.out.trim()
if (!line) continue
for (const errorPattern of ERROR_PATTERNS) {
const match = line.match(errorPattern.pattern)
if (match) {
const message = errorPattern.extractMessage
? errorPattern.extractMessage(match, line)
: line
// Deduplicate similar errors
const messageKey = `${errorPattern.category}:${message.substring(0, 100)}`
if (seenMessages.has(messageKey)) continue
seenMessages.add(messageKey)
const fileInfo = extractFilePath(line)
const service = extractService(line, fileInfo.path)
errors.push({
step: stepName,
line: logLine.pos,
message,
category: errorPattern.category,
file_path: fileInfo.path,
service,
})
break // Only match first pattern per line
}
}
}
return errors
}
// =============================================================================
// API Handler
// =============================================================================
/**
* POST /api/infrastructure/logs/extract
*
* Extrahiert Fehler aus Woodpecker Pipeline Logs.
*
* Request Body:
* - pipeline_number: number (required)
* - repo_id?: string (default: '1')
*
* Response:
* - errors: ExtractedError[]
* - pipeline_number: number
* - extracted_at: string
* - lines_parsed: number
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { pipeline_number, repo_id = '1' } = body
if (!pipeline_number) {
return NextResponse.json(
{ error: 'pipeline_number ist erforderlich' },
{ status: 400 }
)
}
// 1. Fetch pipeline details to get step IDs
const pipelineResponse = await fetch(
`${WOODPECKER_URL}/api/repos/${repo_id}/pipelines/${pipeline_number}`,
{
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
}
)
if (!pipelineResponse.ok) {
return NextResponse.json(
{ error: `Pipeline ${pipeline_number} nicht gefunden` },
{ status: 404 }
)
}
const pipeline = await pipelineResponse.json()
// 2. Extract step IDs from workflows
const failedSteps: { id: number; name: string }[] = []
if (pipeline.workflows) {
for (const workflow of pipeline.workflows) {
if (workflow.children) {
for (const child of workflow.children) {
if (child.state === 'failure' || child.state === 'error') {
failedSteps.push({
id: child.id,
name: child.name,
})
}
}
}
}
}
// 3. Fetch logs for each failed step
const allErrors: ExtractedError[] = []
let totalLinesParsed = 0
for (const step of failedSteps) {
try {
const logsResponse = await fetch(
`${WOODPECKER_URL}/api/repos/${repo_id}/pipelines/${pipeline_number}/logs/${step.id}`,
{
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
}
)
if (logsResponse.ok) {
const logs: LogLine[] = await logsResponse.json()
totalLinesParsed += logs.length
const stepErrors = parseLogLines(logs, step.name)
allErrors.push(...stepErrors)
}
} catch (logError) {
console.error(`Failed to fetch logs for step ${step.name}:`, logError)
}
}
// 4. Sort errors by severity (security > license > build > test > dependency)
const categoryPriority: Record<ErrorCategory, number> = {
'security_warning': 1,
'license_violation': 2,
'build_error': 3,
'test_failure': 4,
'dependency_issue': 5,
}
allErrors.sort((a, b) => categoryPriority[a.category] - categoryPriority[b.category])
const response: LogExtractionResponse = {
errors: allErrors,
pipeline_number,
extracted_at: new Date().toISOString(),
lines_parsed: totalLinesParsed,
}
return NextResponse.json(response)
} catch (error) {
console.error('Log extraction error:', error)
return NextResponse.json(
{ error: 'Fehler bei der Log-Extraktion' },
{ status: 500 }
)
}
}
/**
* GET /api/infrastructure/logs/extract?pipeline_number=123
*
* Convenience method - calls POST internally
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const pipeline_number = searchParams.get('pipeline_number')
const repo_id = searchParams.get('repo_id') || '1'
if (!pipeline_number) {
return NextResponse.json(
{ error: 'pipeline_number Query-Parameter ist erforderlich' },
{ status: 400 }
)
}
// Create a mock request with JSON body
const mockRequest = new NextRequest(request.url, {
method: 'POST',
body: JSON.stringify({ pipeline_number: parseInt(pipeline_number, 10), repo_id }),
headers: {
'Content-Type': 'application/json',
},
})
return POST(mockRequest)
}

View File

@@ -9,18 +9,10 @@ import { NextRequest, NextResponse } from 'next/server'
// Checkpoint definitions
const CHECKPOINTS = {
'CP-PROF': {
id: 'CP-PROF',
step: 'company-profile',
name: 'Unternehmensprofil Checkpoint',
type: 'REQUIRED',
blocksProgress: true,
requiresReview: 'NONE',
},
'CP-UC': {
id: 'CP-UC',
step: 'use-case-assessment',
name: 'Anwendungsfall Checkpoint',
step: 'use-case-workshop',
name: 'Use Case Checkpoint',
type: 'REQUIRED',
blocksProgress: true,
requiresReview: 'NONE',

View File

@@ -10,15 +10,14 @@ import { NextRequest, NextResponse } from 'next/server'
const SDK_STEPS = [
// Phase 1
{ id: 'company-profile', phase: 1, order: 1, name: 'Unternehmensprofil', url: '/sdk/company-profile' },
{ id: 'use-case-assessment', phase: 1, order: 2, name: 'Anwendungsfall-Erfassung', url: '/sdk/advisory-board' },
{ id: 'screening', phase: 1, order: 3, name: 'System Screening', url: '/sdk/screening' },
{ id: 'modules', phase: 1, order: 4, name: 'Compliance Modules', url: '/sdk/modules' },
{ id: 'requirements', phase: 1, order: 5, name: 'Requirements', url: '/sdk/requirements' },
{ id: 'controls', phase: 1, order: 6, name: 'Controls', url: '/sdk/controls' },
{ id: 'evidence', phase: 1, order: 7, name: 'Evidence', url: '/sdk/evidence' },
{ id: 'audit-checklist', phase: 1, order: 8, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
{ id: 'risks', phase: 1, order: 9, name: 'Risk Matrix', url: '/sdk/risks' },
{ id: 'use-case-workshop', phase: 1, order: 1, name: 'Use Case Workshop', url: '/sdk/advisory-board' },
{ id: 'screening', phase: 1, order: 2, name: 'System Screening', url: '/sdk/screening' },
{ id: 'modules', phase: 1, order: 3, name: 'Compliance Modules', url: '/sdk/modules' },
{ id: 'requirements', phase: 1, order: 4, name: 'Requirements', url: '/sdk/requirements' },
{ id: 'controls', phase: 1, order: 5, name: 'Controls', url: '/sdk/controls' },
{ id: 'evidence', phase: 1, order: 6, name: 'Evidence', url: '/sdk/evidence' },
{ id: 'audit-checklist', phase: 1, order: 7, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
{ id: 'risks', phase: 1, order: 8, name: 'Risk Matrix', url: '/sdk/risks' },
// Phase 2
{ id: 'ai-act', phase: 2, order: 1, name: 'AI Act Klassifizierung', url: '/sdk/ai-act' },
{ id: 'obligations', phase: 2, order: 2, name: 'Pflichtenübersicht', url: '/sdk/obligations' },
@@ -56,7 +55,7 @@ function getPreviousStep(currentStepId: string) {
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const currentStepId = searchParams.get('currentStep') || 'company-profile'
const currentStepId = searchParams.get('currentStep') || 'use-case-workshop'
const currentStep = SDK_STEPS.find(s => s.id === currentStepId)
const nextStep = getNextStep(currentStepId)

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