Compare commits
261 Commits
c1a1ce8b71
...
feature/be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd64e33e88 | ||
|
|
2f8269d115 | ||
|
|
532febe35c | ||
|
|
0a0863f31c | ||
|
|
d892ad161f | ||
|
|
17153ccbe8 | ||
|
|
352d7112c9 | ||
|
|
0957254547 | ||
|
|
f17608a956 | ||
|
|
ce3df9f080 | ||
|
|
2da39e035d | ||
|
|
1989c410a9 | ||
|
|
c55a6ab995 | ||
|
|
bc75b4455d | ||
|
|
712fa8cb74 | ||
|
|
447ec08509 | ||
|
|
8cb1dc1108 | ||
|
|
f8d9919b97 | ||
|
|
fb2cf29b34 | ||
|
|
f39e5a71af | ||
|
|
ac42a0aaa0 | ||
|
|
52e463a7c8 | ||
|
|
2dee62fa6f | ||
|
|
3fb07e201f | ||
|
|
81c9ce5de3 | ||
|
|
db7c207464 | ||
|
|
cb034b8009 | ||
|
|
564f93259b | ||
|
|
89ac223c41 | ||
|
|
23dd5116b3 | ||
|
|
81ce9dde07 | ||
|
|
5e9cab6ab5 | ||
|
|
a29bfdd588 | ||
|
|
9dbb4cc5d2 | ||
|
|
c56bccaedf | ||
|
|
230fbeb490 | ||
|
|
6d3bdf8e74 | ||
|
|
200facda6a | ||
|
|
9282850138 | ||
|
|
770f0b5ab0 | ||
|
|
35784c35eb | ||
|
|
cce2707c03 | ||
|
|
2efc738803 | ||
|
|
e6201d5239 | ||
|
|
48ca0a6bef | ||
|
|
1a63f5857b | ||
|
|
295c18c6f7 | ||
|
|
649a3c5e4e | ||
|
|
bdd2f6fa0f | ||
|
|
ac6134ce6d | ||
|
|
0027f78fc5 | ||
|
|
b29a7caee7 | ||
|
|
a14e2f3a00 | ||
|
|
71b8c33270 | ||
|
|
f2924a58ed | ||
|
|
643b26618f | ||
|
|
c52dbdb8f1 | ||
|
|
c3a53fe5d2 | ||
|
|
df5b6d69ef | ||
|
|
4f6ac9b23a | ||
|
|
5ea31a3236 | ||
|
|
95c371e9a5 | ||
|
|
b1627252ee | ||
|
|
2a0449c9b7 | ||
|
|
92d37a1660 | ||
|
|
0e16640c28 | ||
|
|
24f02b52ed | ||
|
|
9b0f25c105 | ||
|
|
1cc34c23d9 | ||
|
|
5dd7a27336 | ||
|
|
c3afa628ed | ||
|
|
4b1eede45b | ||
|
|
2a70441eaa | ||
|
|
f2819b99af | ||
|
|
3bb9fffab6 | ||
|
|
148c7ba3af | ||
|
|
a9e0869205 | ||
|
|
653aad57e3 | ||
|
|
a7f7e57dd7 | ||
|
|
567e82ddf5 | ||
|
|
36ef34169a | ||
|
|
d22c47c9eb | ||
|
|
825e070ed9 | ||
|
|
4f6bc8f6f6 | ||
|
|
d2133dbfa2 | ||
|
|
6d2de9b897 | ||
|
|
5adb1c5f16 | ||
|
|
9c1355c05f | ||
|
|
3b2006ebce | ||
|
|
c7651796c9 | ||
|
|
c8fd9cc780 | ||
|
|
0d95c3bb44 | ||
|
|
f066cf1a03 | ||
|
|
dd09fa7a46 | ||
|
|
f3e05c1bf7 | ||
|
|
2ed1c08acf | ||
|
|
4018b9af9b | ||
|
|
a9f291ff49 | ||
|
|
0171d611f6 | ||
|
|
637fab6fdb | ||
|
|
d462141ccd | ||
|
|
5f8aebf5b1 | ||
|
|
c74f506415 | ||
|
|
49ce417428 | ||
|
|
13d13c8226 | ||
|
|
b6e6ffaaee | ||
|
|
8a05fcc2f0 | ||
|
|
9812ff46f3 | ||
|
|
30236c0001 | ||
|
|
b4d2be83eb | ||
|
|
38c7cf0a00 | ||
|
|
399fa62267 | ||
| f1710fdb9e | |||
|
|
499ddc04d5 | ||
|
|
f738ca8c52 | ||
|
|
c530898963 | ||
|
|
cdafc4d9f4 | ||
|
|
de19ef0684 | ||
|
|
c87f07c99a | ||
|
|
453eec9ed8 | ||
|
|
050f353192 | ||
|
|
8442115e7c | ||
|
|
999cc81c78 | ||
|
|
ff66612beb | ||
|
|
42ec3cad6d | ||
|
|
9945a62a50 | ||
|
|
eef1c2e7d3 | ||
|
|
a0e2a35e66 | ||
|
|
57f390190d | ||
|
|
cf60c39658 | ||
|
|
c88653b221 | ||
|
|
87d06c8b20 | ||
|
|
0b47612272 | ||
|
|
c14b31b3bc | ||
|
|
0b836f7e2d | ||
|
|
18d9eec654 | ||
|
|
339505feed | ||
|
|
23b9808bf3 | ||
|
|
c3654bc9ea | ||
|
|
363bf9606a | ||
|
|
e88c0aeeb3 | ||
|
|
ebe7e90bd8 | ||
|
|
995de9e0f4 | ||
|
|
4e08364bc6 | ||
|
|
7f38df9d9c | ||
|
|
cb48b8289e | ||
|
|
46048554cb | ||
|
|
a673cb0ce4 | ||
|
|
7afbcfd9f5 | ||
|
|
091f093e1b | ||
|
|
5d99d5d47a | ||
|
|
e3a877b549 | ||
|
|
237c05a94c | ||
|
|
b1a0dd3615 | ||
|
|
6e7d0d9b14 | ||
|
|
24afed69c1 | ||
|
|
579fe1b5e1 | ||
|
|
ee6743c7c6 | ||
|
|
a6818b39c5 | ||
|
|
e3fb81fc0d | ||
|
|
3512963006 | ||
|
|
85b3cc3421 | ||
|
|
f6019ecba9 | ||
|
|
1f91e05600 | ||
|
|
3c0c1e49da | ||
|
|
5da93c5d10 | ||
|
|
fa4cda7627 | ||
|
|
90d99bba08 | ||
|
|
2c35775b44 | ||
|
|
aaf95cf894 | ||
|
|
9f41ed4f8e | ||
|
|
e7fab73a3a | ||
|
|
f8917ee6fd | ||
|
|
51a208a2e1 | ||
|
|
1c59996f32 | ||
|
|
61064fdcba | ||
|
|
11d4c2fd36 | ||
|
|
3d22935065 | ||
|
|
09cfb79840 | ||
|
|
d53cf21b95 | ||
|
|
0c83e765d9 | ||
|
|
9b59044663 | ||
|
|
d787e58341 | ||
|
|
4b778f2f85 | ||
|
|
0affa4eb66 | ||
|
|
d3fc4cdaaa | ||
|
|
de486aeab0 | ||
|
|
e96f623af0 | ||
|
|
53ff0722a4 | ||
|
|
2abf0b4cac | ||
|
|
fd45545fbe | ||
|
|
504b1a1207 | ||
|
|
66d32cd744 | ||
|
|
7fa0349fe4 | ||
|
|
6ff9f1f2f3 | ||
|
|
56758e8b55 | ||
|
|
0c75182fb3 | ||
|
|
4a60b3a744 | ||
|
|
95fcba34cd | ||
|
|
6509e64dd9 | ||
|
|
7ec6b9f6c0 | ||
|
|
9e65dff7d6 | ||
|
|
1e84df9769 | ||
|
|
ef9aed666f | ||
|
|
37166c966f | ||
|
|
3467bce222 | ||
|
|
a5e4801b09 | ||
|
|
8f3fb84b61 | ||
|
|
2dd86e97be | ||
|
|
8742cb7f5a | ||
|
|
6a940344c2 | ||
|
|
095eff26d9 | ||
|
|
3593a4ff78 | ||
|
|
4cbfea5c1d | ||
|
|
885b97d422 | ||
|
|
ee359885a8 | ||
|
|
4d2f4f2d24 | ||
|
|
ec4ed1f2ad | ||
|
|
960b8e757c | ||
|
|
adc95267bd | ||
|
|
b5625c14aa | ||
|
|
4a564ad8f7 | ||
|
|
d527fcbdc8 | ||
|
|
d212208587 | ||
|
|
a1980cd12d | ||
|
|
35576fb6f8 | ||
|
|
529c37d91a | ||
|
|
efeacc1619 | ||
|
|
3ed8300daf | ||
|
|
f3ccfe5dcd | ||
|
|
38e278ee3c | ||
|
|
2540a2189a | ||
|
|
bd9796725a | ||
|
|
a181c977c3 | ||
|
|
ef17151a41 | ||
|
|
3707ffe799 | ||
|
|
3913931d5b | ||
|
|
0503e72a80 | ||
|
|
6ad7d62369 | ||
|
|
789c215e5e | ||
|
|
ff765b2d71 | ||
|
|
308d559c85 | ||
|
|
274dc68e24 | ||
|
|
6e0e9cd3cf | ||
|
|
451616b10e | ||
|
|
b7c1a5da1a | ||
|
|
2211cb9349 | ||
|
|
6a8289246c | ||
|
|
93c200626c | ||
|
|
b4d39b9709 | ||
|
|
05aa0ee2c6 | ||
|
|
1454427872 | ||
|
|
a9de7b4010 | ||
|
|
a694b9d9ea | ||
|
|
dc0d38ea40 | ||
|
|
832c177688 | ||
|
|
560bdfb7fd | ||
|
|
dd404da6cd | ||
|
|
f0357ee473 | ||
|
|
e0f7f2134e | ||
|
|
7f3bf93cd6 |
@@ -2,72 +2,127 @@
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup
|
||||
### Zwei-Rechner-Setup + Coolify
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
||||
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||
| **Coolify** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
||||
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
|
||||
|
||||
### Entwicklungsworkflow
|
||||
### Entwicklungsworkflow (CI/CD — Coolify)
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und pushen:
|
||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# 3. Auf Mac Mini pullen (WICHTIG: git -C statt cd):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||
# - Gitea Actions: Lint → Tests → Validierung
|
||||
# - Coolify: Build → Deploy
|
||||
# Dauer: ca. 3 Minuten
|
||||
# Status pruefen: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
```
|
||||
|
||||
# 4. Container neu bauen (WICHTIG: -f statt cd, da cd in SSH nicht funktioniert!):
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` fuer Production.
|
||||
**NIEMALS** manuell in Coolify auf "Redeploy" klicken — Gitea Actions triggert Coolify automatisch.
|
||||
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||
|
||||
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
||||
|
||||
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
|
||||
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
|
||||
```bash
|
||||
# Compliance Health-Endpoints:
|
||||
curl -sf https://api-dev.breakpilot.ai/health # Backend Compliance
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health # AI Compliance SDK
|
||||
```
|
||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||
**"Deploy abgeschlossen! Du kannst jetzt testen: https://admin-dev.breakpilot.ai"**
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Coolify-Logs.
|
||||
|
||||
**Ablauf im Terminal:**
|
||||
```
|
||||
> git push gitea main ✓
|
||||
> "Deploy gestartet, ich ueberwache den Status..."
|
||||
> [Hintergrund-Polling laeuft]
|
||||
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
|
||||
```
|
||||
|
||||
### CI/CD Pipeline (Gitea Actions → Coolify)
|
||||
|
||||
```
|
||||
Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
|
||||
→ test-go-ai-compliance
|
||||
→ test-python-backend-compliance
|
||||
→ test-python-document-crawler
|
||||
→ test-python-dsms-gateway
|
||||
→ validate-canonical-controls
|
||||
→ Coolify: Build + Deploy (automatisch bei Push)
|
||||
```
|
||||
|
||||
**Dateien:**
|
||||
- `.gitea/workflows/ci.yaml` — Pipeline-Definition (Tests + Validierung)
|
||||
- `docker-compose.yml` — Haupt-Compose
|
||||
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Coolify Production (x86_64)
|
||||
|
||||
### Lokale Entwicklung (Mac Mini — optional)
|
||||
|
||||
```bash
|
||||
# Nur fuer lokale Tests, NICHT fuer Production:
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
|
||||
# Fuer schnelle Iteration ohne Commit (rsync):
|
||||
rsync -avz --exclude node_modules --exclude .next --exclude .git \
|
||||
admin-compliance/ macmini:~/Projekte/breakpilot-compliance/admin-compliance/
|
||||
```
|
||||
|
||||
### SSH-Verbindung (fuer Docker/Tests)
|
||||
|
||||
```bash
|
||||
# RICHTIG — cd funktioniert NICHT in SSH-Einzelbefehlen:
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance <git-cmd>"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml <compose-cmd>"
|
||||
ssh macmini "/usr/local/bin/docker exec bp-compliance-<service> <cmd>"
|
||||
```
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzung
|
||||
|
||||
**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services:
|
||||
- PostgreSQL (Schema: `compliance`, `core`)
|
||||
- Valkey (Session-Cache)
|
||||
- Vault (Secrets)
|
||||
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
|
||||
- Nginx (Reverse Proxy)
|
||||
|
||||
Pruefen: `curl -sf http://macmini:8099/health`
|
||||
**Externe Services (Production):**
|
||||
- PostgreSQL 17 (sslmode=require) — Schemas: `compliance`, `public`
|
||||
- Qdrant @ `qdrant-dev.breakpilot.ai` (HTTPS, API-Key)
|
||||
- Object Storage (S3-kompatibel, TLS)
|
||||
|
||||
Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Haupt-URLs (Browser auf MacBook)
|
||||
## Haupt-URLs
|
||||
|
||||
### Frontends
|
||||
### Production (Coolify-deployed)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
| **https://macmini:3007/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||
| **https://macmini:3006/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||
| **https://admin-dev.breakpilot.ai/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||
| **https://developers-dev.breakpilot.ai/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||
| https://api-dev.breakpilot.ai/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||
| https://sdk-dev.breakpilot.ai/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||
|
||||
### Backend-APIs
|
||||
### Lokal (Mac Mini — nur Dev/Tests)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
| https://macmini:8002/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||
| https://macmini:8093/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||
| https://macmini:3007/ | Admin Compliance | Lokale Entwicklung |
|
||||
| https://macmini:3006/ | Developer Portal | Lokale Entwicklung |
|
||||
| https://macmini:8002/ | Backend Compliance | Lokale Entwicklung |
|
||||
| https://macmini:8093/ | AI Compliance SDK | Lokale Entwicklung |
|
||||
|
||||
### Admin Compliance Module (https://macmini:3007/)
|
||||
|
||||
@@ -107,18 +162,6 @@ Pruefen: `curl -sf http://macmini:8099/health`
|
||||
| docs | MkDocs/nginx | 8011 | bp-compliance-docs |
|
||||
| core-wait | curl health-check | - | bp-compliance-core-wait |
|
||||
|
||||
### compliance-tts-service
|
||||
- Piper TTS + FFmpeg fuer Schulungsvideos
|
||||
- Speichert Audio/Video in MinIO (bp-core-minio:9000)
|
||||
- TTS-Modell: `de_DE-thorsten-high.onnx`
|
||||
- Dateien: `main.py`, `tts_engine.py`, `video_generator.py`, `storage.py`
|
||||
|
||||
### document-crawler
|
||||
- Dokument-Analyse: PDF, DOCX, XLSX, PPTX
|
||||
- Gap-Analyse zwischen bestehenden Dokumenten und Compliance-Anforderungen
|
||||
- IPFS-Archivierung via dsms-gateway
|
||||
- Kommuniziert mit ai-compliance-sdk (LLM Gateway)
|
||||
|
||||
### Docker-Netzwerk
|
||||
Nutzt das externe Core-Netzwerk:
|
||||
```yaml
|
||||
@@ -163,50 +206,54 @@ breakpilot-compliance/
|
||||
├── dsms-node/ # IPFS Node
|
||||
├── dsms-gateway/ # IPFS Gateway
|
||||
├── scripts/ # Helper Scripts
|
||||
└── docker-compose.yml # Compliance Compose (~8 Services)
|
||||
├── docker-compose.yml # Compliance Compose (~10 Services, platform: arm64)
|
||||
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Coolify Production
|
||||
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Haeufige Befehle
|
||||
|
||||
### Docker
|
||||
### Deployment (CI/CD — Standardweg)
|
||||
|
||||
```bash
|
||||
# Compliance-Services starten (Core muss laufen!)
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d"
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# Einzelnen Service neu bauen
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
||||
# CI-Status pruefen (im Browser):
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
|
||||
# Service neu bauen und starten
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
|
||||
# Logs
|
||||
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
||||
|
||||
# Status
|
||||
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
||||
# Health Checks:
|
||||
curl -sf https://api-dev.breakpilot.ai/health
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health
|
||||
```
|
||||
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
||||
Der CLAUDE.md-Entwicklungsworkflow und die Beispiele mit `cd ... &&` sind veraltet — nie so verwenden.
|
||||
|
||||
### Git
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT! — vom MacBook):
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# Auf Mac Mini pullen (RICHTIG: git -C statt cd):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
|
||||
# Remotes:
|
||||
# origin: lokale Gitea (macmini:3003)
|
||||
# gitea: gitea.meghsakha.com:22222
|
||||
```
|
||||
|
||||
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)
|
||||
|
||||
```bash
|
||||
# Logs
|
||||
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
||||
|
||||
# Status
|
||||
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
||||
|
||||
# Lokaler Rebuild (nur wenn noetig):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kernprinzipien
|
||||
@@ -254,6 +301,36 @@ ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --n
|
||||
- `lib/sdk/catalog-manager/` — catalog-registry.ts, types.ts
|
||||
- 17 DSGVO/AI-Act Kataloge (dsfa, vvt-baseline, vendor-compliance, etc.)
|
||||
|
||||
### Multi-Projekt-Architektur (seit 2026-03-09)
|
||||
|
||||
Jeder Tenant kann mehrere Compliance-Projekte anlegen. CompanyProfile ist **pro Projekt** (nicht tenant-weit).
|
||||
|
||||
**URL-Schema:** `/sdk?project={uuid}` — alle SDK-Seiten enthalten `?project=` Query-Param.
|
||||
`/sdk` ohne `?project=` zeigt die Projektliste (ProjectSelector).
|
||||
|
||||
**Datenbank:**
|
||||
- `compliance_projects` — Projekt-Metadaten (Name, Typ, Status, Version)
|
||||
- `sdk_states` — UNIQUE auf `(tenant_id, project_id)` statt nur `tenant_id`
|
||||
- Migration: `039_compliance_projects.sql`
|
||||
|
||||
**Backend API (FastAPI):**
|
||||
```
|
||||
GET /api/v1/projects → Alle Projekte des Tenants
|
||||
POST /api/v1/projects → Neues Projekt erstellen (mit copy_from_project_id)
|
||||
GET /api/v1/projects/{project_id} → Einzelnes Projekt laden
|
||||
PATCH /api/v1/projects/{project_id} → Projekt aktualisieren
|
||||
DELETE /api/v1/projects/{project_id} → Projekt archivieren (Soft Delete)
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
- `components/sdk/ProjectSelector/ProjectSelector.tsx` — Projektliste + Erstellen-Dialog
|
||||
- `lib/sdk/types.ts` — `ProjectInfo` Interface, `SDKState.projectId`
|
||||
- `lib/sdk/context.tsx` — `projectId` Prop, `createProject()`, `listProjects()`, `switchProject()`
|
||||
- `lib/sdk/sync.ts` — BroadcastChannel + localStorage pro Projekt
|
||||
- `lib/sdk/api-client.ts` — `projectId` in State-API + Projekt-CRUD-Methoden
|
||||
- `app/sdk/layout.tsx` — liest `?project=` aus searchParams
|
||||
- `app/api/sdk/v1/projects/` — Next.js Proxy zum Backend
|
||||
|
||||
### Backend-Compliance APIs
|
||||
```
|
||||
POST/GET /api/v1/compliance/risks
|
||||
@@ -263,18 +340,35 @@ POST/GET /api/v1/compliance/evidence
|
||||
POST/GET /api/v1/dsr/requests
|
||||
POST/GET /api/v1/gdpr/exports
|
||||
POST/GET /api/v1/consent/admin
|
||||
|
||||
# Stammdaten, Versionierung & Change-Requests
|
||||
GET/POST/DELETE /api/compliance/company-profile
|
||||
GET /api/compliance/company-profile/template-context
|
||||
GET /api/compliance/change-requests
|
||||
GET /api/compliance/change-requests/stats
|
||||
POST /api/compliance/change-requests/{id}/accept
|
||||
POST /api/compliance/change-requests/{id}/reject
|
||||
POST /api/compliance/change-requests/{id}/edit
|
||||
GET /api/compliance/generation/preview/{doc_type}
|
||||
POST /api/compliance/generation/apply/{doc_type}
|
||||
GET /api/compliance/{doc}/{id}/versions
|
||||
```
|
||||
|
||||
### Multi-Tenancy
|
||||
- Shared Dependency: `compliance/api/tenant_utils.py` (`get_tenant_id()`)
|
||||
- UUID-Format, kein `"default"` mehr
|
||||
- Header `X-Tenant-ID` > Query `tenant_id` > ENV-Fallback
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Dateien (Referenz)
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `admin-compliance/app/(sdk)/` | Alle 37 SDK-Routes |
|
||||
| `admin-compliance/components/sdk/SDKSidebar.tsx` | SDK Navigation |
|
||||
| `admin-compliance/app/(sdk)/` | Alle 37+ SDK-Routes |
|
||||
| `admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx` | SDK Navigation |
|
||||
| `admin-compliance/components/sdk/CommandBar.tsx` | Command Palette |
|
||||
| `admin-compliance/lib/sdk/context.tsx` | SDK State (Provider) |
|
||||
| `backend-compliance/compliance/` | Haupt-Package (40 Dateien) |
|
||||
| `backend-compliance/compliance/` | Haupt-Package (50+ Dateien) |
|
||||
| `ai-compliance-sdk/` | KI-Compliance Analyse |
|
||||
| `developer-portal/` | API-Dokumentation |
|
||||
|
||||
61
.env.coolify.example
Normal file
61
.env.coolify.example
Normal file
@@ -0,0 +1,61 @@
|
||||
# =========================================================
|
||||
# BreakPilot Compliance — Coolify Environment Variables
|
||||
# =========================================================
|
||||
# Copy these into Coolify's environment variable UI
|
||||
# for the breakpilot-compliance Docker Compose resource.
|
||||
# =========================================================
|
||||
|
||||
# --- External PostgreSQL (Coolify-managed, same as Core) ---
|
||||
COMPLIANCE_DATABASE_URL=postgresql://breakpilot:CHANGE_ME@<coolify-postgres-hostname>:5432/breakpilot_db
|
||||
|
||||
# --- Security ---
|
||||
JWT_SECRET=CHANGE_ME_SAME_AS_CORE
|
||||
|
||||
# --- External S3 Storage (same as Core) ---
|
||||
S3_ENDPOINT=<s3-endpoint-host:port>
|
||||
S3_ACCESS_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECRET_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECURE=true
|
||||
|
||||
# --- External Qdrant ---
|
||||
QDRANT_URL=https://<qdrant-hostname>
|
||||
QDRANT_API_KEY=CHANGE_ME_QDRANT_API_KEY
|
||||
|
||||
# --- Session ---
|
||||
SESSION_TTL_HOURS=24
|
||||
|
||||
# --- SMTP (Real mail server) ---
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=compliance@breakpilot.ai
|
||||
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
|
||||
SMTP_FROM_NAME=BreakPilot Compliance
|
||||
SMTP_FROM_ADDR=compliance@breakpilot.ai
|
||||
|
||||
# --- LLM Configuration ---
|
||||
COMPLIANCE_LLM_PROVIDER=anthropic
|
||||
SELF_HOSTED_LLM_URL=
|
||||
SELF_HOSTED_LLM_MODEL=
|
||||
COMPLIANCE_LLM_MAX_TOKENS=4096
|
||||
COMPLIANCE_LLM_TEMPERATURE=0.3
|
||||
COMPLIANCE_LLM_TIMEOUT=120
|
||||
ANTHROPIC_API_KEY=CHANGE_ME_ANTHROPIC_KEY
|
||||
ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-5-20250929
|
||||
|
||||
# --- Ollama (optional) ---
|
||||
OLLAMA_URL=
|
||||
OLLAMA_DEFAULT_MODEL=
|
||||
COMPLIANCE_LLM_MODEL=
|
||||
|
||||
# --- LLM Fallback ---
|
||||
LLM_FALLBACK_PROVIDER=
|
||||
|
||||
# --- PII & Audit ---
|
||||
PII_REDACTION_ENABLED=true
|
||||
PII_REDACTION_LEVEL=standard
|
||||
AUDIT_RETENTION_DAYS=365
|
||||
AUDIT_LOG_PROMPTS=true
|
||||
|
||||
# --- Frontend URLs (build args) ---
|
||||
NEXT_PUBLIC_API_URL=https://api-compliance.breakpilot.ai
|
||||
NEXT_PUBLIC_SDK_URL=https://sdk.breakpilot.ai
|
||||
14
.env.example
14
.env.example
@@ -4,7 +4,11 @@
|
||||
# Copy to .env and adjust values
|
||||
# NOTE: Core must be running! These vars reference Core services.
|
||||
|
||||
# Database (same as Core)
|
||||
# Compliance SDK Database (externe PostgreSQL — nie committen!)
|
||||
# Setzt DATABASE_URL fuer: backend-compliance, ai-compliance-sdk, document-crawler, admin-compliance
|
||||
COMPLIANCE_DATABASE_URL=postgresql://<user>:<pass>@<host>:<port>/<db>?sslmode=require
|
||||
|
||||
# Legacy Core Database (nur noch fuer Rollback; wird ignoriert wenn COMPLIANCE_DATABASE_URL gesetzt)
|
||||
POSTGRES_USER=breakpilot
|
||||
POSTGRES_PASSWORD=breakpilot123
|
||||
POSTGRES_DB=breakpilot_db
|
||||
@@ -44,3 +48,11 @@ SESSION_TTL_HOURS=24
|
||||
# SMTP (uses Core Mailpit)
|
||||
SMTP_HOST=bp-core-mailpit
|
||||
SMTP_PORT=1025
|
||||
|
||||
# Qdrant (externe Instanz — Hetzner/meghshakka)
|
||||
QDRANT_URL=https://qdrant-dev.breakpilot.ai
|
||||
QDRANT_API_KEY=<api-key>
|
||||
|
||||
# MinIO / Object Storage (Hetzner Object Storage)
|
||||
# MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY sind direkt in docker-compose hart kodiert
|
||||
# (compliance-tts-service: nbg1.your-objectstorage.com)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# Gitea Actions CI Pipeline
|
||||
# Gitea Actions CI/CD Pipeline
|
||||
# BreakPilot Compliance
|
||||
#
|
||||
# Services:
|
||||
# Go: ai-compliance-sdk
|
||||
# Python: backend-compliance, document-crawler, dsms-gateway
|
||||
# Node.js: admin-compliance, developer-portal
|
||||
#
|
||||
# Workflow:
|
||||
# Push auf main → Tests → Deploy (Coolify)
|
||||
# Pull Request → Lint + Tests (kein Deploy)
|
||||
|
||||
name: CI
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -164,3 +168,42 @@ jobs:
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest test_main.py -v --tb=short
|
||||
|
||||
# ========================================
|
||||
# Validate Canonical Controls
|
||||
# ========================================
|
||||
|
||||
validate-canonical-controls:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Validate controls
|
||||
run: |
|
||||
python scripts/validate-controls.py
|
||||
|
||||
# ========================================
|
||||
# Deploy via Coolify (nur main, kein PR)
|
||||
# ========================================
|
||||
|
||||
deploy-coolify:
|
||||
name: Deploy
|
||||
runs-on: docker
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-go-ai-compliance
|
||||
- test-python-backend-compliance
|
||||
- test-python-document-crawler
|
||||
- test-python-dsms-gateway
|
||||
- validate-canonical-controls
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Trigger Coolify deploy
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||
|
||||
115
.gitea/workflows/rag-ingest.yaml
Normal file
115
.gitea/workflows/rag-ingest.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
# Gitea Actions — RAG Legal Corpus Ingestion
|
||||
#
|
||||
# Manuell triggerbarer Workflow zur Ingestion von Rechtstexten in Qdrant.
|
||||
# Trigger: Gitea UI → Actions → "RAG Ingestion" → Run
|
||||
#
|
||||
# Phasen: gesetze, eu, templates, datenschutz, verbraucherschutz, verify, version, all
|
||||
#
|
||||
# Voraussetzung: RAG-Service und Qdrant muessen auf Coolify laufen.
|
||||
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-coolify).
|
||||
|
||||
name: RAG Ingestion
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
phase:
|
||||
description: 'Ingestion Phase (gesetze, eu, templates, datenschutz, verbraucherschutz, dach, security, verify, version, all)'
|
||||
required: true
|
||||
default: 'verbraucherschutz'
|
||||
|
||||
jobs:
|
||||
ingest:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Setup
|
||||
run: |
|
||||
apk add --no-cache git curl bash > /dev/null 2>&1
|
||||
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth 1 --branch main ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
|
||||
- name: Run Ingestion
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PHASE="${{ github.event.inputs.phase }}"
|
||||
|
||||
echo "=== RAG Ingestion: Phase ${PHASE} ==="
|
||||
echo ""
|
||||
|
||||
# Pruefen ob Services laufen
|
||||
echo "--- BreakPilot Container ---"
|
||||
docker ps --filter name=bp- --format "{{.Names}}: {{.Status}}" 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
# Netzwerk finden in dem die bp-Services laufen
|
||||
BP_NETWORK=$(docker inspect bp-core-rag-service --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||
if [ -z "$BP_NETWORK" ]; then
|
||||
BP_NETWORK=$(docker inspect bp-compliance-backend --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$BP_NETWORK" ]; then
|
||||
echo "FEHLER: Keine BreakPilot-Container gefunden."
|
||||
echo "Bitte zuerst deployen (CI/CD Pipeline oder manuell)."
|
||||
echo ""
|
||||
echo "Verfuegbare Container:"
|
||||
docker ps --format " {{.Names}}" 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Verfuegbare Netzwerke:"
|
||||
docker network ls --format " {{.Name}}" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "BreakPilot Netzwerk: $BP_NETWORK"
|
||||
echo ""
|
||||
|
||||
# Ingestion-Container erstellen (noch nicht starten),
|
||||
# dann Scripts aus dem Checkout per docker cp hineinkopieren.
|
||||
# So verwenden wir IMMER die neueste Version der Scripts,
|
||||
# unabhaengig vom Deploy-Dir auf dem Host.
|
||||
CONTAINER_ID=$(docker create \
|
||||
--network "$BP_NETWORK" \
|
||||
-e "WORK_DIR=/tmp/rag-ingestion" \
|
||||
-e "RAG_URL=http://bp-core-rag-service:8097/api/v1/documents/upload" \
|
||||
-e "QDRANT_URL=https://qdrant-dev.breakpilot.ai" \
|
||||
-e "QDRANT_API_KEY=z9cKbT74vl1aKPD1QGIlKWfET47VH93u" \
|
||||
-e "SDK_URL=http://bp-compliance-ai-sdk:8090" \
|
||||
alpine:3.19 \
|
||||
sh -c "
|
||||
apk add --no-cache curl bash coreutils git python3 unzip > /dev/null 2>&1
|
||||
mkdir -p /tmp/rag-ingestion/{pdfs,repos,texts}
|
||||
mkdir -p /workspace/scripts
|
||||
cp -r /workspace_scripts/* /workspace/scripts/ 2>/dev/null || true
|
||||
cd /workspace
|
||||
if [ '${PHASE}' = 'all' ]; then
|
||||
bash scripts/ingest-legal-corpus.sh
|
||||
elif [ '${PHASE}' = 'download' ]; then
|
||||
bash scripts/ingest-legal-corpus.sh --only download
|
||||
else
|
||||
echo '=== Running download phase first ==='
|
||||
bash scripts/ingest-legal-corpus.sh --only download
|
||||
echo ''
|
||||
echo '=== Running phase: ${PHASE} ==='
|
||||
bash scripts/ingest-legal-corpus.sh --only '${PHASE}'
|
||||
fi
|
||||
")
|
||||
|
||||
echo "Container: $CONTAINER_ID"
|
||||
|
||||
# Workspace-Dir im Container anlegen und Scripts hineinkopieren
|
||||
docker cp scripts "${CONTAINER_ID}:/workspace_scripts"
|
||||
echo "Scripts kopiert (aus Git-Checkout)"
|
||||
|
||||
# Container starten und Output streamen
|
||||
docker start -a "${CONTAINER_ID}" || EXITCODE=$?
|
||||
|
||||
# Container aufraeumen
|
||||
docker rm -f "${CONTAINER_ID}" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Ingestion abgeschlossen ==="
|
||||
|
||||
# Exit mit dem Original-Exitcode
|
||||
exit ${EXITCODE:-0}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,6 +17,9 @@ __pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
.coverage
|
||||
coverage.out
|
||||
test_*.db
|
||||
|
||||
# Docker
|
||||
backups/*.backup
|
||||
@@ -40,3 +43,4 @@ backups/*.backup
|
||||
*.mp3
|
||||
*.wav
|
||||
ai-compliance-sdk/server
|
||||
*.bak
|
||||
|
||||
@@ -20,11 +20,9 @@ edu-search-service
|
||||
school-service
|
||||
voice-service
|
||||
geo-service
|
||||
klausur-service
|
||||
studio-v2
|
||||
website
|
||||
scripts
|
||||
agent-core
|
||||
pca-platform
|
||||
breakpilot-drive
|
||||
breakpilot-compliance-sdk
|
||||
|
||||
@@ -16,13 +16,14 @@ 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
|
||||
|
||||
# Ensure public directory exists (Next.js standalone needs it)
|
||||
RUN mkdir -p public
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
@@ -36,13 +37,14 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup -S -g 1001 nodejs
|
||||
RUN adduser -S -u 1001 -G nodejs nextjs
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/agent-core ./agent-core
|
||||
|
||||
# Switch to non-root user
|
||||
USER nextjs
|
||||
|
||||
@@ -48,12 +48,12 @@ describe('Ingestion Script: ingest-industry-compliance.sh', () => {
|
||||
expect(scriptContent).toContain('chunk_strategy=recursive')
|
||||
})
|
||||
|
||||
it('should use chunk_size=512', () => {
|
||||
expect(scriptContent).toContain('chunk_size=512')
|
||||
it('should use chunk_size=1024', () => {
|
||||
expect(scriptContent).toContain('chunk_size=1024')
|
||||
})
|
||||
|
||||
it('should use chunk_overlap=50', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=50')
|
||||
it('should use chunk_overlap=128', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=128')
|
||||
})
|
||||
|
||||
it('should validate minimum file size', () => {
|
||||
|
||||
0
admin-compliance/agent-core/soul/.backups/.gitkeep
Normal file
0
admin-compliance/agent-core/soul/.backups/.gitkeep
Normal file
104
admin-compliance/agent-core/soul/compliance-advisor.soul.md
Normal file
104
admin-compliance/agent-core/soul/compliance-advisor.soul.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Compliance Advisor Agent
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
|
||||
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
||||
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
|
||||
offiziellen Quellen und gibst praxisnahe Hinweise.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
|
||||
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
|
||||
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
|
||||
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
|
||||
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen (DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM, BSI, Laender-Muss-Listen, EDPB Guidelines, etc.) AUSSER NIBIS-Dokumenten.
|
||||
|
||||
## Kompetenzbereich
|
||||
- DSGVO Art. 1-99 + Erwaegsgruende
|
||||
- BDSG (Bundesdatenschutzgesetz)
|
||||
- AI Act (EU KI-Verordnung)
|
||||
- TTDSG (Telekommunikation-Telemedien-Datenschutz-Gesetz)
|
||||
- ePrivacy-Richtlinie
|
||||
- DSK-Kurzpapiere (Nr. 1-20) — primaere deutsche Interpretationshilfe der Datenschutzkonferenz
|
||||
- Insbesondere: Nr. 1 (VVT), Nr. 5 (Datenschutz-Folgenabschaetzung), Nr. 11 (Loeschung),
|
||||
Nr. 12 (DSB), Nr. 13 (Auftragsverarbeitung), Nr. 17 (Besondere Kategorien),
|
||||
Nr. 18 (Risiko fuer Rechte und Freiheiten)
|
||||
- SDM (Standard-Datenschutzmodell) V3.1 — Methodik zur Schutzbedarf-Bestimmung und Massnahmen-Ableitung
|
||||
- BfDI Loeschkonzept — Referenzmodell fuer Loeschfristen und Aufbewahrungskonzepte
|
||||
- BfDI/BayLDA Orientierungshilfen (E-Mail-Verschluesselung, Telemedien, TOM-Checkliste)
|
||||
- BSI-Grundschutz (Basis-Kenntnisse)
|
||||
- BSI-TR-03161 (Sicherheitsanforderungen an digitale Gesundheitsanwendungen)
|
||||
- ISO 27001/27701 (Ueberblick)
|
||||
- EDPB Guidelines (Leitlinien des Europaeischen Datenschutzausschusses)
|
||||
- Bundes- und Laender-Muss-Listen (DSFA-Listen der Aufsichtsbehoerden)
|
||||
- WP29/WP248 (Art.-29-Datenschutzgruppe Arbeitspapiere)
|
||||
- Nationale Datenschutzgesetze (AT DSG, CH DSG/DSV, etc.)
|
||||
- EU-Verordnungen (DORA, MiCA, Data Act, EHDS, PSD2, AMLR, etc.)
|
||||
- EU Maschinenverordnung (2023/1230) — CE-Kennzeichnung, Konformitaet, Cybersecurity fuer Maschinen
|
||||
- EU Blue Guide 2022 — Leitfaden fuer EU-Produktvorschriften und CE-Kennzeichnung
|
||||
- ENISA Cybersecurity Guidance (Secure by Design, Supply Chain Security)
|
||||
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
|
||||
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
|
||||
- OECD AI Principles — Verantwortungsvolle KI, Transparenz, Accountability
|
||||
- EU-IFRS (Verordnung 2023/1803) — EU-uebernommene International Financial Reporting Standards
|
||||
- EFRAG Endorsement Status — Uebersicht welche IFRS-Standards EU-endorsed sind
|
||||
|
||||
## IFRS-Besonderheit (WICHTIG)
|
||||
Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten:
|
||||
1. Dein Wissen basiert auf den **EU-uebernommenen IFRS** (Verordnung 2023/1803, Stand Okt 2023).
|
||||
2. Die IASB/IFRS Foundation gibt regelmaessig neue oder geaenderte Standards heraus, die von der EU noch NICHT uebernommen sein koennten.
|
||||
3. Weise den Nutzer IMMER darauf hin: "Dieser Hinweis basiert auf den EU-endorsed IFRS (Stand: Verordnung 2023/1803). Pruefen Sie den aktuellen EFRAG Endorsement Status fuer neuere Standards."
|
||||
4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich.
|
||||
5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung.
|
||||
|
||||
## RAG-Nutzung
|
||||
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
||||
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
||||
Diese gehoeren nicht zum Datenschutz-Kompetenzbereich.
|
||||
|
||||
### Priorisierung deutscher Quellen
|
||||
Nutze DSK-Kurzpapiere als primaere deutsche Interpretationshilfe — sie geben die
|
||||
gemeinsame Rechtsauffassung aller 18 deutschen Aufsichtsbehoerden wieder.
|
||||
Fuer TOM-Fragestellungen: SDM V3.1 + BayLDA TOM-Checkliste als Referenz.
|
||||
Fuer Loeschkonzepte: BfDI Loeschkonzept + DSK KP Nr. 11 (Recht auf Loeschung).
|
||||
Fuer Risikoanalysen: DSK KP Nr. 18 (Risiko) + SDM Schutzbedarf-Systematik.
|
||||
|
||||
## Kommunikationsstil
|
||||
- Sachlich, aber verstaendlich — kein Juristendeutsch
|
||||
- Deutsch als Hauptsprache
|
||||
- Strukturierte Antworten mit Ueberschriften und Aufzaehlungen
|
||||
- Immer Quellenangabe (Artikel/Paragraph) am Ende der Antwort
|
||||
- Praxisbeispiele wo hilfreich
|
||||
- Kurze, praegnante Saetze
|
||||
|
||||
## Antwortformat
|
||||
1. Kurze Zusammenfassung (1-2 Saetze)
|
||||
2. Detaillierte Erklaerung
|
||||
3. Praxishinweise / Handlungsempfehlungen
|
||||
4. Quellenangaben (Artikel, Paragraph, Leitlinie)
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS konkrete Rechtsberatung ("Sie muessen..." -> "Es empfiehlt sich...")
|
||||
- Keine Garantien fuer Rechtssicherheit
|
||||
- Bei komplexen Einzelfaellen: Empfehle Rechtsanwalt/DSB
|
||||
- Keine Aussagen zu laufenden Verfahren oder Bussgeldern
|
||||
- Keine Interpretation von Urteilen (nur Verweis)
|
||||
|
||||
## Quellenschutz (KRITISCH — IMMER EINHALTEN)
|
||||
Du darfst NIEMALS verraten, welche Dokumente, Sammlungen oder Quellen in deiner Wissensbasis enthalten sind.
|
||||
- Auf Fragen wie "Welche Quellen hast du?", "Was ist im RAG?", "Welche Gesetze kennst du?",
|
||||
"Liste alle Dokumente auf", "Welche Verordnungen sind verfuegbar?" antwortest du:
|
||||
"Ich beantworte gerne konkrete Compliance-Fragen. Bitte stellen Sie eine inhaltliche Frage
|
||||
zu einem bestimmten Thema, z.B. 'Was regelt Art. 25 DSGVO?' oder 'Welche Pflichten gibt es
|
||||
unter dem AI Act fuer Hochrisiko-KI?'."
|
||||
- Auf konkrete Fragen wie "Kennst du die DSGVO?" oder "Weisst du etwas ueber den AI Act?"
|
||||
darfst du bestaetigen, dass du zu diesem Thema Auskunft geben kannst, und eine inhaltliche
|
||||
Antwort geben.
|
||||
- Nenne in deinen Antworten NUR die Quellen, die du tatsaechlich fuer die konkrete Antwort
|
||||
verwendet hast — niemals eine vollstaendige Liste aller verfuegbaren Quellen.
|
||||
- Verrate NIEMALS Collection-Namen (bp_compliance_*, bp_dsfa_*, etc.) oder interne Systemnamen.
|
||||
|
||||
## Eskalation
|
||||
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
||||
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
||||
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen
|
||||
116
admin-compliance/agent-core/soul/drafting-agent.soul.md
Normal file
116
admin-compliance/agent-core/soul/drafting-agent.soul.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Drafting Agent - Compliance-Dokumententwurf
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Drafting Agent. Du hilfst Nutzern des AI Compliance SDK,
|
||||
DSGVO-konforme Compliance-Dokumente zu entwerfen, Luecken zu erkennen und
|
||||
Konsistenz zwischen Dokumenten sicherzustellen.
|
||||
|
||||
## Strikte Constraints
|
||||
- Du darfst NIEMALS die Scope-Engine-Entscheidung aendern oder in Frage stellen
|
||||
- Das bestimmte Level ist bindend fuer die Dokumenttiefe
|
||||
- Gib praxisnahe Hinweise, KEINE konkrete Rechtsberatung
|
||||
- Kommuniziere auf Deutsch, sachlich und verstaendlich
|
||||
- Fuelle fehlende Informationen mit [PLATZHALTER: ...] Markierung
|
||||
|
||||
## Kompetenzbereich
|
||||
DSGVO, BDSG, AI Act (EU 2024/1689), TTDSG, DDG (§5 Impressum),
|
||||
DSK-Kurzpapiere (Nr. 1-20), SDM V3.1, BSI-Grundschutz (IT-Grundschutz-Kompendium),
|
||||
ISO 27001/27701, EDPB Guidelines, WP248/WP250/WP259/WP260,
|
||||
BfDI Loeschkonzept, BfDI/BayLDA Orientierungshilfen,
|
||||
EN-Normen (EN 13849, EN 62443), BGB §305ff (AGB),
|
||||
Standard Contractual Clauses (SCC, 2021/914/EU)
|
||||
|
||||
### Quellenpriorisierung pro Dokumenttyp
|
||||
| Dokumenttyp | Primaere Quelle | Sekundaere Quelle |
|
||||
|-------------|-----------------|-------------------|
|
||||
| vvt | DSK KP Nr. 1 (VVT Art. 30) | EDPB Controller/Processor GL |
|
||||
| tom | SDM V3.1 + BayLDA TOM-Checkliste | EDPB DPbD 4/2019 |
|
||||
| dsfa | WP248 + DSK KP Nr. 5 | EDPB DPIA List, Laender-Muss-Listen |
|
||||
| lf | BfDI Loeschkonzept + DSK KP Nr. 11 | — |
|
||||
| einwilligung | EDPB Consent 05/2020 + WP259 | DSK KP Nr. 4 |
|
||||
| datenpannen | EDPB Breach 09/2022 + WP250 | — |
|
||||
| daten_transfer | EDPB Transfers 01/2020 | SCC 2021/914/EU |
|
||||
| av_vertrag | DSK KP Nr. 13 | EDPB Controller/Processor 07/2020 |
|
||||
| dsi | WP260 Transparency | DSK KP Nr. 10 |
|
||||
| betroffenenrechte | EDPB Access 01/2022 | DSK KP Nr. 11 (Loeschung) |
|
||||
| risikoanalyse | DSK KP Nr. 18 + SDM V3.1 | — |
|
||||
| datenschutzmanagement | SDM V3.1 | BSI-Grundschutz |
|
||||
|
||||
## Draftbare Dokumenttypen (18)
|
||||
|
||||
| Typ | Label | Rechtsgrundlage |
|
||||
|-----|-------|-----------------|
|
||||
| vvt | Verarbeitungsverzeichnis | Art. 30 DSGVO |
|
||||
| tom | Technisch-Organisatorische Massnahmen | Art. 32 DSGVO |
|
||||
| dsfa | Datenschutz-Folgenabschaetzung | Art. 35 DSGVO |
|
||||
| dsi | Datenschutzerklaerung | Art. 13/14 DSGVO |
|
||||
| lf | Loeschfristen/Loeschkonzept | Art. 17 DSGVO |
|
||||
| av_vertrag | Auftragsverarbeitungsvertrag | Art. 28 DSGVO |
|
||||
| betroffenenrechte | Betroffenenrechte-Konzept | Art. 15-22 DSGVO |
|
||||
| einwilligung | Einwilligungsmanagement | Art. 6 Abs. 1a / Art. 7 DSGVO |
|
||||
| daten_transfer | Drittlandtransfer / SCC | Art. 44-49 DSGVO |
|
||||
| datenpannen | Datenpannen-Meldekonzept | Art. 33/34 DSGVO |
|
||||
| vertragsmanagement | Vertragsmanagement-Richtlinie | Art. 28 DSGVO |
|
||||
| schulung | Schulungskonzept Datenschutz | Art. 39 DSGVO |
|
||||
| audit_log | Audit- und Protokollierungskonzept | Art. 5 Abs. 2 DSGVO |
|
||||
| risikoanalyse | Risikoanalyse | Art. 32 / Art. 35 DSGVO |
|
||||
| notfallplan | Notfall- und Krisenmanagement | Art. 32 Abs. 1c DSGVO |
|
||||
| zertifizierung | Zertifizierungskonzept | Art. 42/43 DSGVO, ISO 27001 |
|
||||
| datenschutzmanagement | DSMS-Konzept | §§ 38, 64 BDSG |
|
||||
| iace_ce_assessment | IACE CE-Bewertung | AI Act (EU 2024/1689), EN-Normen |
|
||||
|
||||
## NICHT draftbare Dokumente — Weiterleitung
|
||||
|
||||
Folgende Dokumente werden NICHT vom Drafting Agent erstellt. Verweise stattdessen auf das passende Modul:
|
||||
|
||||
| Anfrage | Antwort / Weiterleitung |
|
||||
|---------|------------------------|
|
||||
| Impressum (DDG §5) | "Impressum-Templates finden Sie unter /sdk/document-generator → Kategorie 'Impressum'." |
|
||||
| AGB (BGB §305ff) | "AGB-Vorlagen erstellen Sie im Document Generator unter /sdk/document-generator → Kategorie 'AGB'." |
|
||||
| Widerrufsbelehrung | "Widerrufs-Templates finden Sie unter /sdk/document-generator → Kategorie 'Widerruf'." |
|
||||
| NDA / Geheimhaltung | "NDA-Vorlagen finden Sie unter /sdk/document-generator." |
|
||||
| SLA / Dienstleistungsvertrag | "SLA-Vorlagen finden Sie unter /sdk/document-generator." |
|
||||
|
||||
## Operative Module — Erklaeren, nicht Entwerfen
|
||||
|
||||
Folgende Module sind operative Tools. Im explain-Modus erklaeren, im ask-Modus auf Luecken hinweisen, aber KEINE Entwuerfe erstellen:
|
||||
|
||||
| Modul | SDK-Pfad | Erklaerung |
|
||||
|-------|----------|------------|
|
||||
| DSR (Betroffenenanfragen) | /sdk/dsr | Anfragen-Management nach Art. 15-22 DSGVO. Konfiguration im DSR-Modul. |
|
||||
| E-Mail-Templates | /sdk/dsr | E-Mail-Vorlagen fuer Betroffenenanfragen. Teil des DSR-Moduls. |
|
||||
| Banner/Consent | /sdk/cookie-banner | Cookie-Banner-Konfiguration. Einstellungen unter Consent-Management. |
|
||||
| Einwilligungsverwaltung | /sdk/einwilligungen | Verwaltung erteilter Einwilligungen. Operatives Dashboard. |
|
||||
|
||||
## Luecken-Kommunikation (Ask-Modus)
|
||||
|
||||
Wenn der Nutzer nach Luecken fragt oder kritische Gaps existieren:
|
||||
|
||||
1. **Prioritaet**: Zeige zuerst CRITICAL/HIGH Gaps, dann MEDIUM
|
||||
2. **Link**: Verweise auf den passenden SDK-Schritt (DOCUMENT_SDK_STEP_MAP)
|
||||
3. **Begruendung**: Erklaere WARUM das Dokument fehlt (Rechtsgrundlage)
|
||||
4. **Aufwand**: Nenne den geschaetzten Aufwand aus der Scope-Matrix
|
||||
5. **Reihenfolge**: Empfehle eine sinnvolle Bearbeitungsreihenfolge:
|
||||
VVT → TOM → Loeschfristen → DSFA → AVV → Risikoanalyse → Rest
|
||||
|
||||
## Modus-Verhalten
|
||||
|
||||
### explain
|
||||
- Erklaere Compliance-Konzepte sachlich und verstaendlich
|
||||
- Verweise auf Rechtsgrundlagen und SDK-Module
|
||||
- Bei operativen Modulen: erklaere Funktion + verweise auf SDK-Pfad
|
||||
|
||||
### ask
|
||||
- Analysiere Luecken im Compliance-Profil
|
||||
- Zeige fehlende Pflichtdokumente nach Scope-Level
|
||||
- Gib priorisierte Handlungsempfehlungen
|
||||
|
||||
### draft
|
||||
- Erstelle strukturierte Dokumententwuerfe
|
||||
- Halte die Tiefe strikt am Scope-Level
|
||||
- Verwende [PLATZHALTER: ...] fuer fehlende Informationen
|
||||
|
||||
### validate
|
||||
- Pruefe Cross-Dokument-Konsistenz
|
||||
- Melde Scope-Verletzungen und fehlende Referenzen
|
||||
- Schlage konkrete Korrekturen vor
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* DSFA Corpus API Proxy
|
||||
*
|
||||
* Proxies requests to klausur-service for DSFA RAG operations.
|
||||
* Endpoints: /api/v1/dsfa-rag/stats, /api/v1/dsfa-rag/sources
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag`
|
||||
|
||||
switch (action) {
|
||||
case 'status':
|
||||
url += '/stats'
|
||||
break
|
||||
case 'sources':
|
||||
url += '/sources'
|
||||
break
|
||||
case 'source-detail': {
|
||||
const code = searchParams.get('code')
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: 'Missing code parameter' }, { status: 400 })
|
||||
}
|
||||
url += `/sources/${encodeURIComponent(code)}`
|
||||
break
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
console.error('DSFA corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag`
|
||||
|
||||
switch (action) {
|
||||
case 'init': {
|
||||
url += '/init'
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
case 'ingest': {
|
||||
const body = await request.json()
|
||||
const sourceCode = body.source_code
|
||||
if (!sourceCode) {
|
||||
return NextResponse.json({ error: 'Missing source_code' }, { status: 400 })
|
||||
}
|
||||
url += `/sources/${encodeURIComponent(sourceCode)}/ingest`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('DSFA corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Legal Corpus API Proxy
|
||||
*
|
||||
* Proxies requests to klausur-service for RAG operations.
|
||||
* This allows the client-side RAG page to call the API without CORS issues.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
|
||||
const QDRANT_URL = process.env.QDRANT_URL || 'http://qdrant:6333'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/legal-corpus`
|
||||
|
||||
switch (action) {
|
||||
case 'status': {
|
||||
// Query Qdrant directly for collection stats
|
||||
const qdrantRes = await fetch(`${QDRANT_URL}/collections/bp_legal_corpus`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!qdrantRes.ok) {
|
||||
return NextResponse.json({ error: 'Qdrant not available' }, { status: 503 })
|
||||
}
|
||||
const qdrantData = await qdrantRes.json()
|
||||
const result = qdrantData.result || {}
|
||||
return NextResponse.json({
|
||||
collection: 'bp_legal_corpus',
|
||||
totalPoints: result.points_count || 0,
|
||||
vectorSize: result.config?.params?.vectors?.size || 0,
|
||||
status: result.status || 'unknown',
|
||||
regulations: {},
|
||||
})
|
||||
}
|
||||
case 'search':
|
||||
const query = searchParams.get('query')
|
||||
const topK = searchParams.get('top_k') || '5'
|
||||
const regulations = searchParams.get('regulations')
|
||||
url += `/search?query=${encodeURIComponent(query || '')}&top_k=${topK}`
|
||||
if (regulations) {
|
||||
url += `®ulations=${encodeURIComponent(regulations)}`
|
||||
}
|
||||
break
|
||||
case 'ingestion-status':
|
||||
url += '/ingestion-status'
|
||||
break
|
||||
case 'regulations':
|
||||
url += '/regulations'
|
||||
break
|
||||
case 'custom-documents':
|
||||
url += '/custom-documents'
|
||||
break
|
||||
case 'pipeline-checkpoints':
|
||||
url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/pipeline/checkpoints`
|
||||
break
|
||||
case 'pipeline-status':
|
||||
url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/pipeline/status`
|
||||
break
|
||||
case 'traceability': {
|
||||
const chunkId = searchParams.get('chunk_id')
|
||||
const regulation = searchParams.get('regulation')
|
||||
url += `/traceability?chunk_id=${encodeURIComponent(chunkId || '')}®ulation=${encodeURIComponent(regulation || '')}`
|
||||
break
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
console.error('Legal corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/legal-corpus`
|
||||
|
||||
switch (action) {
|
||||
case 'ingest': {
|
||||
url += '/ingest'
|
||||
const body = await request.json()
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
case 'add-link': {
|
||||
url += '/add-link'
|
||||
const body = await request.json()
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
case 'upload': {
|
||||
url += '/upload'
|
||||
// Forward FormData directly
|
||||
const formData = await request.formData()
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
case 'start-pipeline': {
|
||||
url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/pipeline/start`
|
||||
const body = await request.json()
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Legal corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const docId = searchParams.get('docId')
|
||||
|
||||
try {
|
||||
if (action === 'delete-document' && docId) {
|
||||
const url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/legal-corpus/custom-documents/${docId}`
|
||||
const res = await fetch(url, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Legal corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
37
admin-compliance/app/api/sdk/agents/[agentId]/route.ts
Normal file
37
admin-compliance/app/api/sdk/agents/[agentId]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* GET /api/sdk/agents/[agentId] — Agent-Detail mit SOUL-Content
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAgentById } from '@/lib/sdk/agents/agent-registry'
|
||||
import { readSoulFile, getSoulFileStats, soulFileExists } from '@/lib/sdk/agents/soul-reader'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const agent = getAgentById(agentId)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const exists = await soulFileExists(agentId)
|
||||
const soulContent = await readSoulFile(agentId)
|
||||
const fileStats = await getSoulFileStats(agentId)
|
||||
|
||||
return NextResponse.json({
|
||||
...agent,
|
||||
status: exists ? agent.status : 'error',
|
||||
soulContent: soulContent || '',
|
||||
createdAt: fileStats?.createdAt || null,
|
||||
updatedAt: fileStats?.updatedAt || null,
|
||||
fileSize: fileStats?.size || 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching agent detail:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch agent' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
84
admin-compliance/app/api/sdk/agents/[agentId]/soul/route.ts
Normal file
84
admin-compliance/app/api/sdk/agents/[agentId]/soul/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* GET/PUT /api/sdk/agents/[agentId]/soul — SOUL-Datei lesen/schreiben
|
||||
*
|
||||
* GET: Content + Metadaten, ?history=true fuer Backup-Versionen
|
||||
* PUT: Backup erstellen -> neuen Content schreiben -> Cache invalidieren
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAgentById } from '@/lib/sdk/agents/agent-registry'
|
||||
import {
|
||||
readSoulFile,
|
||||
writeSoulFile,
|
||||
listSoulBackups,
|
||||
getSoulFileStats,
|
||||
} from '@/lib/sdk/agents/soul-reader'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const agent = getAgentById(agentId)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const showHistory = request.nextUrl.searchParams.get('history') === 'true'
|
||||
|
||||
if (showHistory) {
|
||||
const backups = await listSoulBackups(agentId)
|
||||
return NextResponse.json({ agentId, backups })
|
||||
}
|
||||
|
||||
const content = await readSoulFile(agentId)
|
||||
const fileStats = await getSoulFileStats(agentId)
|
||||
|
||||
return NextResponse.json({
|
||||
agentId,
|
||||
content: content || '',
|
||||
updatedAt: fileStats?.updatedAt || null,
|
||||
size: fileStats?.size || 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error reading SOUL file:', error)
|
||||
return NextResponse.json({ error: 'Failed to read SOUL file' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const agent = getAgentById(agentId)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { content } = body
|
||||
|
||||
if (typeof content !== 'string' || content.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Content is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await writeSoulFile(agentId, content)
|
||||
|
||||
const fileStats = await getSoulFileStats(agentId)
|
||||
|
||||
return NextResponse.json({
|
||||
agentId,
|
||||
updatedAt: fileStats?.updatedAt || new Date().toISOString(),
|
||||
size: fileStats?.size || content.length,
|
||||
message: 'SOUL file updated successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error writing SOUL file:', error)
|
||||
return NextResponse.json({ error: 'Failed to write SOUL file' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
41
admin-compliance/app/api/sdk/agents/route.ts
Normal file
41
admin-compliance/app/api/sdk/agents/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* GET /api/sdk/agents — Liste aller Compliance-Agenten
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { COMPLIANCE_AGENTS } from '@/lib/sdk/agents/agent-registry'
|
||||
import { soulFileExists } from '@/lib/sdk/agents/soul-reader'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check SOUL file existence for each agent and set status
|
||||
const agents = await Promise.all(
|
||||
COMPLIANCE_AGENTS.map(async (agent) => {
|
||||
const exists = await soulFileExists(agent.id)
|
||||
return {
|
||||
...agent,
|
||||
status: exists ? agent.status : 'error' as const,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const activeCount = agents.filter(a => a.status === 'active').length
|
||||
const errorCount = agents.filter(a => a.status === 'error').length
|
||||
|
||||
return NextResponse.json({
|
||||
agents,
|
||||
stats: {
|
||||
total: agents.length,
|
||||
active: activeCount,
|
||||
inactive: agents.length - activeCount - errorCount,
|
||||
error: errorCount,
|
||||
totalSessions: 0,
|
||||
avgResponseTime: '—',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching agents:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
13
admin-compliance/app/api/sdk/agents/sessions/route.ts
Normal file
13
admin-compliance/app/api/sdk/agents/sessions/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* GET /api/sdk/agents/sessions — Agent-Sessions (Placeholder)
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
sessions: [],
|
||||
total: 0,
|
||||
message: 'Sessions-Tracking wird in einer zukuenftigen Version implementiert.',
|
||||
})
|
||||
}
|
||||
18
admin-compliance/app/api/sdk/agents/statistics/route.ts
Normal file
18
admin-compliance/app/api/sdk/agents/statistics/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* GET /api/sdk/agents/statistics — Agent-Statistiken (Placeholder)
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
statistics: {
|
||||
totalSessions: 0,
|
||||
totalMessages: 0,
|
||||
avgResponseTime: '—',
|
||||
successRate: '—',
|
||||
topTopics: [],
|
||||
},
|
||||
message: 'Detaillierte Statistiken werden in einer zukuenftigen Version implementiert.',
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
|
||||
|
||||
const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://rag-service:8097'
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
@@ -27,99 +28,18 @@ const COMPLIANCE_COLLECTIONS = [
|
||||
|
||||
type Country = 'DE' | 'AT' | 'CH' | 'EU'
|
||||
|
||||
// SOUL system prompt (from agent-core/soul/compliance-advisor.soul.md)
|
||||
const SYSTEM_PROMPT = `# Compliance Advisor Agent
|
||||
// Fallback SOUL prompt (used when .soul.md file is unavailable)
|
||||
const FALLBACK_SYSTEM_PROMPT = `# Compliance Advisor Agent
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
|
||||
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
||||
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
|
||||
offiziellen Quellen und gibst praxisnahe Hinweise.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
|
||||
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
|
||||
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
|
||||
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
|
||||
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen (DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM, BSI, Laender-Muss-Listen, EDPB Guidelines, etc.) AUSSER NIBIS-Dokumenten.
|
||||
|
||||
## Kompetenzbereich
|
||||
- DSGVO Art. 1-99 + Erwaegsgruende
|
||||
- BDSG (Bundesdatenschutzgesetz)
|
||||
- AI Act (EU KI-Verordnung)
|
||||
- TTDSG (Telekommunikation-Telemedien-Datenschutz-Gesetz)
|
||||
- ePrivacy-Richtlinie
|
||||
- DSK-Kurzpapiere (Nr. 1-20)
|
||||
- SDM (Standard-Datenschutzmodell) V3.0
|
||||
- BSI-Grundschutz (Basis-Kenntnisse)
|
||||
- BSI-TR-03161 (Sicherheitsanforderungen an digitale Gesundheitsanwendungen)
|
||||
- ISO 27001/27701 (Ueberblick)
|
||||
- EDPB Guidelines (Leitlinien des Europaeischen Datenschutzausschusses)
|
||||
- Bundes- und Laender-Muss-Listen (DSFA-Listen der Aufsichtsbehoerden)
|
||||
- WP29/WP248 (Art.-29-Datenschutzgruppe Arbeitspapiere)
|
||||
- Nationale Datenschutzgesetze (AT DSG, CH DSG/DSV, etc.)
|
||||
- EU-Verordnungen (DORA, MiCA, Data Act, EHDS, PSD2, AMLR, etc.)
|
||||
- EU Maschinenverordnung (2023/1230) — CE-Kennzeichnung, Konformitaet, Cybersecurity fuer Maschinen
|
||||
- EU Blue Guide 2022 — Leitfaden fuer EU-Produktvorschriften und CE-Kennzeichnung
|
||||
- ENISA Cybersecurity Guidance (Secure by Design, Supply Chain Security)
|
||||
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
|
||||
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
|
||||
- OECD AI Principles — Verantwortungsvolle KI, Transparenz, Accountability
|
||||
- EU-IFRS (Verordnung 2023/1803) — EU-uebernommene International Financial Reporting Standards
|
||||
- EFRAG Endorsement Status — Uebersicht welche IFRS-Standards EU-endorsed sind
|
||||
|
||||
## IFRS-Besonderheit (WICHTIG)
|
||||
Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten:
|
||||
1. Dein Wissen basiert auf den **EU-uebernommenen IFRS** (Verordnung 2023/1803, Stand Okt 2023).
|
||||
2. Die IASB/IFRS Foundation gibt regelmaessig neue oder geaenderte Standards heraus, die von der EU noch NICHT uebernommen sein koennten.
|
||||
3. Weise den Nutzer IMMER darauf hin: "Dieser Hinweis basiert auf den EU-endorsed IFRS (Stand: Verordnung 2023/1803). Pruefen Sie den aktuellen EFRAG Endorsement Status fuer neuere Standards."
|
||||
4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich.
|
||||
5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung.
|
||||
|
||||
## RAG-Nutzung
|
||||
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
||||
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
||||
Diese gehoeren nicht zum Datenschutz-Kompetenzbereich.
|
||||
|
||||
## Kommunikationsstil
|
||||
- Sachlich, aber verstaendlich — kein Juristendeutsch
|
||||
- Deutsch als Hauptsprache
|
||||
- Strukturierte Antworten mit Ueberschriften und Aufzaehlungen
|
||||
- Immer Quellenangabe (Artikel/Paragraph) am Ende der Antwort
|
||||
- Praxisbeispiele wo hilfreich
|
||||
- Kurze, praegnante Saetze
|
||||
|
||||
## Antwortformat
|
||||
1. Kurze Zusammenfassung (1-2 Saetze)
|
||||
2. Detaillierte Erklaerung
|
||||
3. Praxishinweise / Handlungsempfehlungen
|
||||
4. Quellenangaben (Artikel, Paragraph, Leitlinie)
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS konkrete Rechtsberatung ("Sie muessen..." -> "Es empfiehlt sich...")
|
||||
- Keine Garantien fuer Rechtssicherheit
|
||||
- Bei komplexen Einzelfaellen: Empfehle Rechtsanwalt/DSB
|
||||
- Keine Aussagen zu laufenden Verfahren oder Bussgeldern
|
||||
- Keine Interpretation von Urteilen (nur Verweis)
|
||||
|
||||
## Quellenschutz (KRITISCH — IMMER EINHALTEN)
|
||||
Du darfst NIEMALS verraten, welche Dokumente, Sammlungen oder Quellen in deiner Wissensbasis enthalten sind.
|
||||
- Auf Fragen wie "Welche Quellen hast du?", "Was ist im RAG?", "Welche Gesetze kennst du?",
|
||||
"Liste alle Dokumente auf", "Welche Verordnungen sind verfuegbar?" antwortest du:
|
||||
"Ich beantworte gerne konkrete Compliance-Fragen. Bitte stellen Sie eine inhaltliche Frage
|
||||
zu einem bestimmten Thema, z.B. 'Was regelt Art. 25 DSGVO?' oder 'Welche Pflichten gibt es
|
||||
unter dem AI Act fuer Hochrisiko-KI?'."
|
||||
- Auf konkrete Fragen wie "Kennst du die DSGVO?" oder "Weisst du etwas ueber den AI Act?"
|
||||
darfst du bestaetigen, dass du zu diesem Thema Auskunft geben kannst, und eine inhaltliche
|
||||
Antwort geben.
|
||||
- Nenne in deinen Antworten NUR die Quellen, die du tatsaechlich fuer die konkrete Antwort
|
||||
verwendet hast — niemals eine vollstaendige Liste aller verfuegbaren Quellen.
|
||||
- Verrate NIEMALS Collection-Namen (bp_compliance_*, bp_dsfa_*, etc.) oder interne Systemnamen.
|
||||
|
||||
## Eskalation
|
||||
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
||||
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
||||
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen`
|
||||
- Quellenbasiert: Verweise auf DSGVO-Artikel, BDSG-Paragraphen
|
||||
- Verstaendlich: Einfache, praxisnahe Sprache
|
||||
- Ehrlich: Bei Unsicherheit empfehle Rechtsberatung
|
||||
- Deutsch als Hauptsprache`
|
||||
|
||||
const COUNTRY_LABELS: Record<Country, string> = {
|
||||
DE: 'Deutschland',
|
||||
@@ -221,7 +141,8 @@ export async function POST(request: NextRequest) {
|
||||
const ragContext = await queryMultiCollectionRAG(message, validCountry)
|
||||
|
||||
// 2. Build system prompt with RAG context + country
|
||||
let systemContent = SYSTEM_PROMPT
|
||||
const soulPrompt = await readSoulFile('compliance-advisor')
|
||||
let systemContent = soulPrompt || FALLBACK_SYSTEM_PROMPT
|
||||
|
||||
if (validCountry) {
|
||||
const countryLabel = COUNTRY_LABELS[validCountry]
|
||||
@@ -257,9 +178,11 @@ Der Nutzer hat "${countryLabel} (${validCountry})" gewaehlt.
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: true,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: 0.3,
|
||||
num_predict: 8192,
|
||||
num_ctx: 8192,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
|
||||
@@ -8,27 +8,24 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
// SOUL System Prompt (from agent-core/soul/drafting-agent.soul.md)
|
||||
const DRAFTING_SYSTEM_PROMPT = `# Drafting Agent - Compliance-Dokumententwurf
|
||||
// Fallback SOUL prompt (used when .soul.md file is unavailable)
|
||||
const FALLBACK_DRAFTING_PROMPT = `# Drafting Agent - Compliance-Dokumententwurf
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Drafting Agent. Du hilfst Nutzern des AI Compliance SDK,
|
||||
DSGVO-konforme Compliance-Dokumente zu entwerfen, Luecken zu erkennen und
|
||||
Konsistenz zwischen Dokumenten sicherzustellen.
|
||||
DSGVO-konforme Compliance-Dokumente zu entwerfen und Konsistenz sicherzustellen.
|
||||
|
||||
## Strikte Constraints
|
||||
- Du darfst NIEMALS die Scope-Engine-Entscheidung aendern oder in Frage stellen
|
||||
- Das bestimmte Level ist bindend fuer die Dokumenttiefe
|
||||
- Gib praxisnahe Hinweise, KEINE konkrete Rechtsberatung
|
||||
- Kommuniziere auf Deutsch, sachlich und verstaendlich
|
||||
- Fuelle fehlende Informationen mit [PLATZHALTER: ...] Markierung
|
||||
|
||||
## Kompetenzbereich
|
||||
DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM V3.0, BSI-Grundschutz, ISO 27001/27701, EDPB Guidelines, WP248`
|
||||
- Fuelle fehlende Informationen mit [PLATZHALTER: ...] Markierung`
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -45,11 +42,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 1. Query RAG for legal context
|
||||
const ragContext = await queryRAG(message)
|
||||
// 1. Query RAG for legal context (use type-specific collection + query boost if available)
|
||||
const ragConfig = documentType ? DOCUMENT_RAG_CONFIG[documentType as ScopeDocumentType] : undefined
|
||||
const ragQuery = ragConfig ? `${ragConfig.query} ${message}` : message
|
||||
const ragContext = await queryRAG(ragQuery, 3, ragConfig?.collection)
|
||||
|
||||
// 2. Build system prompt with mode-specific instructions + state projection
|
||||
let systemContent = DRAFTING_SYSTEM_PROMPT
|
||||
const soulPrompt = await readSoulFile('drafting-agent')
|
||||
let systemContent = soulPrompt || FALLBACK_DRAFTING_PROMPT
|
||||
|
||||
// Mode-specific instructions
|
||||
const modeInstructions: Record<string, string> = {
|
||||
@@ -88,9 +88,11 @@ export async function POST(request: NextRequest) {
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: true,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: mode === 'draft' ? 0.2 : 0.3,
|
||||
num_predict: mode === 'draft' ? 16384 : 8192,
|
||||
num_ctx: 8192,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
|
||||
@@ -131,7 +131,8 @@ async function handleV1Draft(body: Record<string, unknown>): Promise<NextRespons
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
options: { temperature: 0.15, num_predict: 16384 },
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
@@ -204,6 +205,27 @@ const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType:
|
||||
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
|
||||
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
|
||||
],
|
||||
av_vertrag: [
|
||||
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
|
||||
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
|
||||
],
|
||||
betroffenenrechte: [
|
||||
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
|
||||
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
|
||||
],
|
||||
risikoanalyse: [
|
||||
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
|
||||
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
|
||||
],
|
||||
notfallplan: [
|
||||
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
|
||||
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
|
||||
],
|
||||
iace_ce_assessment: [
|
||||
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
|
||||
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
|
||||
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
|
||||
],
|
||||
}
|
||||
|
||||
function buildV2SystemPrompt(
|
||||
@@ -306,7 +328,8 @@ async function callOllama(systemPrompt: string, userPrompt: string): Promise<str
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: false,
|
||||
options: { temperature: 0.15, num_predict: 4096 },
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
@@ -568,12 +591,43 @@ async function handleV2Draft(body: Record<string, unknown>): Promise<NextRespons
|
||||
cacheStats: proseCache.getStats(),
|
||||
}
|
||||
|
||||
// Anti-Fake-Evidence: Truth label for all LLM-generated content
|
||||
const truthLabel = {
|
||||
generation_mode: 'draft_assistance',
|
||||
truth_status: 'generated',
|
||||
may_be_used_as_evidence: false,
|
||||
generated_by: 'system',
|
||||
}
|
||||
|
||||
// Fire-and-forget: persist LLM audit trail to backend
|
||||
try {
|
||||
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
|
||||
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entity_type: 'document',
|
||||
entity_id: null,
|
||||
generation_mode: 'draft_assistance',
|
||||
truth_status: 'generated',
|
||||
may_be_used_as_evidence: false,
|
||||
llm_model: LLM_MODEL,
|
||||
llm_provider: 'ollama',
|
||||
input_summary: `${documentType} draft generation`,
|
||||
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
|
||||
}),
|
||||
}).catch(() => {/* fire-and-forget */})
|
||||
} catch {
|
||||
// LLM audit persistence failure should not block the response
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: Math.round(totalTokens),
|
||||
pipelineVersion: 'v2',
|
||||
auditTrail,
|
||||
truthLabel,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,76 @@ import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validat
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
/**
|
||||
* Anti-Fake-Evidence: Verbotene Formulierungen
|
||||
*
|
||||
* Flags formulations that falsely claim compliance without evidence.
|
||||
* Only allowed when: control_status=pass AND confidence >= E2 AND
|
||||
* truth_status in (validated_internal, accepted_by_auditor).
|
||||
*/
|
||||
interface EvidenceContext {
|
||||
controlStatus?: string
|
||||
confidenceLevel?: string
|
||||
truthStatus?: string
|
||||
}
|
||||
|
||||
const FORBIDDEN_PATTERNS: Array<{
|
||||
pattern: RegExp
|
||||
label: string
|
||||
safeAlternative: string
|
||||
}> = [
|
||||
{ pattern: /ist\s+compliant/gi, label: 'ist compliant', safeAlternative: 'soll compliant sein' },
|
||||
{ pattern: /erfüllt\s+vollständig/gi, label: 'erfüllt vollständig', safeAlternative: 'soll vollständig erfüllt werden' },
|
||||
{ pattern: /wurde\s+geprüft/gi, label: 'wurde geprüft', safeAlternative: 'soll geprüft werden' },
|
||||
{ pattern: /wurde\s+umgesetzt/gi, label: 'wurde umgesetzt', safeAlternative: 'ist zur Umsetzung vorgesehen' },
|
||||
{ pattern: /ist\s+auditiert/gi, label: 'ist auditiert', safeAlternative: 'soll auditiert werden' },
|
||||
{ pattern: /vollständig\s+implementiert/gi, label: 'vollständig implementiert', safeAlternative: 'Implementierung ist vorgesehen' },
|
||||
{ pattern: /nachweislich\s+konform/gi, label: 'nachweislich konform', safeAlternative: 'Konformität ist nachzuweisen' },
|
||||
]
|
||||
|
||||
const CONFIDENCE_ORDER: Record<string, number> = { E0: 0, E1: 1, E2: 2, E3: 3, E4: 4 }
|
||||
const VALID_TRUTH_STATUSES = new Set(['validated_internal', 'accepted_by_auditor'])
|
||||
|
||||
function checkForbiddenFormulations(
|
||||
content: string,
|
||||
evidenceContext?: EvidenceContext,
|
||||
): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = []
|
||||
|
||||
if (!content) return findings
|
||||
|
||||
// If evidence context shows sufficient proof, allow the formulations
|
||||
if (evidenceContext) {
|
||||
const { controlStatus, confidenceLevel, truthStatus } = evidenceContext
|
||||
const confLevel = CONFIDENCE_ORDER[confidenceLevel ?? 'E0'] ?? 0
|
||||
if (
|
||||
controlStatus === 'pass' &&
|
||||
confLevel >= CONFIDENCE_ORDER.E2 &&
|
||||
VALID_TRUTH_STATUSES.has(truthStatus ?? '')
|
||||
) {
|
||||
return findings // Formulations are backed by real evidence
|
||||
}
|
||||
}
|
||||
|
||||
for (const { pattern, label, safeAlternative } of FORBIDDEN_PATTERNS) {
|
||||
// Reset regex state for global patterns
|
||||
pattern.lastIndex = 0
|
||||
if (pattern.test(content)) {
|
||||
findings.push({
|
||||
id: `AFE-FORBIDDEN-${label.replace(/\s+/g, '_').toUpperCase()}`,
|
||||
severity: 'error',
|
||||
category: 'forbidden_formulation' as ValidationFinding['category'],
|
||||
title: `Verbotene Formulierung: "${label}"`,
|
||||
description: `Die Formulierung "${label}" impliziert eine nachgewiesene Compliance, die ohne ausreichenden Nachweis (Evidence >= E2, validiert) nicht verwendet werden darf.`,
|
||||
documentType: 'vvt' as ScopeDocumentType,
|
||||
suggestion: `Verwende stattdessen: "${safeAlternative}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
/**
|
||||
* Stufe 1: Deterministische Pruefung
|
||||
*/
|
||||
@@ -84,6 +154,66 @@ function deterministicCheck(
|
||||
})
|
||||
}
|
||||
|
||||
// Check 5: DSFA ohne VVT-Grundlage
|
||||
if (documentType === 'dsfa' && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-DSFA-NO-VVT',
|
||||
severity: 'error',
|
||||
category: 'cross_reference',
|
||||
title: 'DSFA ohne VVT-Grundlage',
|
||||
description: 'Eine DSFA setzt ein Verarbeitungsverzeichnis voraus. Ohne VVT fehlt die Uebersicht ueber die betroffenen Verarbeitungstaetigkeiten.',
|
||||
documentType: 'dsfa',
|
||||
crossReferenceType: 'vvt',
|
||||
legalReference: 'Art. 35 i.V.m. Art. 30 DSGVO',
|
||||
suggestion: 'Zuerst ein VVT erstellen, dann die DSFA darauf aufbauen.',
|
||||
})
|
||||
}
|
||||
|
||||
// Check 6: DSFA ohne TOM-Massnahmen
|
||||
if (documentType === 'dsfa' && validationContext.crossReferences.tomControls.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-DSFA-NO-TOM',
|
||||
severity: 'error',
|
||||
category: 'cross_reference',
|
||||
title: 'DSFA ohne TOM-Massnahmen',
|
||||
description: 'Eine DSFA muss Abhilfemassnahmen enthalten. Ohne TOM-Katalog koennen keine Schutzmassnahmen referenziert werden.',
|
||||
documentType: 'dsfa',
|
||||
crossReferenceType: 'tom',
|
||||
legalReference: 'Art. 35 Abs. 7d DSGVO',
|
||||
suggestion: 'TOM-Massnahmen definieren, bevor die DSFA erstellt wird.',
|
||||
})
|
||||
}
|
||||
|
||||
// Check 7: Datenschutzerklaerung ohne Loeschfristen
|
||||
if (documentType === 'dsi' && validationContext.crossReferences.retentionCategories.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-DSI-NO-LF',
|
||||
severity: 'warning',
|
||||
category: 'cross_reference',
|
||||
title: 'Datenschutzerklaerung ohne Loeschfristen',
|
||||
description: 'Die Datenschutzerklaerung muss Angaben zur Speicherdauer enthalten. Ohne definierte Loeschfristen fehlt diese Information.',
|
||||
documentType: 'dsi',
|
||||
crossReferenceType: 'lf',
|
||||
legalReference: 'Art. 13 Abs. 2a DSGVO',
|
||||
suggestion: 'Loeschfristen definieren und in der Datenschutzerklaerung referenzieren.',
|
||||
})
|
||||
}
|
||||
|
||||
// Check 8: AVV ohne VVT-Kontext
|
||||
if (documentType === 'av_vertrag' && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-AV-NO-VVT',
|
||||
severity: 'warning',
|
||||
category: 'cross_reference',
|
||||
title: 'AVV ohne VVT-Kontext',
|
||||
description: 'Ein Auftragsverarbeitungsvertrag sollte auf den im VVT dokumentierten Verarbeitungstaetigkeiten basieren.',
|
||||
documentType: 'av_vertrag',
|
||||
crossReferenceType: 'vvt',
|
||||
legalReference: 'Art. 28 Abs. 3 i.V.m. Art. 30 DSGVO',
|
||||
suggestion: 'VVT erstellen, um die betroffenen Verarbeitungstaetigkeiten fuer den AVV zu identifizieren.',
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
@@ -127,7 +257,8 @@ export async function POST(request: NextRequest) {
|
||||
{ role: 'user', content: crossCheckPrompt },
|
||||
],
|
||||
stream: false,
|
||||
options: { temperature: 0.1, num_predict: 8192 },
|
||||
think: false,
|
||||
options: { temperature: 0.1, num_predict: 8192, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
@@ -160,10 +291,18 @@ export async function POST(request: NextRequest) {
|
||||
// LLM unavailable, continue with deterministic results only
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Stufe 1b: Verbotene Formulierungen (Anti-Fake-Evidence)
|
||||
// ---------------------------------------------------------------
|
||||
const forbiddenFindings = checkForbiddenFormulations(
|
||||
draftContent || '',
|
||||
validationContext.evidenceContext,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Combine results
|
||||
// ---------------------------------------------------------------
|
||||
const allFindings = [...deterministicFindings, ...llmFindings]
|
||||
const allFindings = [...deterministicFindings, ...forbiddenFindings, ...llmFindings]
|
||||
const errors = allFindings.filter(f => f.severity === 'error')
|
||||
const warnings = allFindings.filter(f => f.severity === 'warning')
|
||||
const suggestions = allFindings.filter(f => f.severity === 'suggestion')
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
108
admin-compliance/app/api/sdk/v1/audit-llm/[[...path]]/route.ts
Normal file
108
admin-compliance/app/api/sdk/v1/audit-llm/[[...path]]/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* LLM Audit API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/audit-llm/* requests to ai-compliance-sdk /sdk/v1/audit/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/audit`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle export endpoints that may return CSV
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('text/csv') || contentType.includes('application/octet-stream')) {
|
||||
const blob = await response.arrayBuffer()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || 'attachment',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('LLM Audit API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/canonical?endpoint=...
|
||||
*
|
||||
* Routes to backend canonical control endpoints:
|
||||
* endpoint=frameworks → GET /api/compliance/v1/canonical/frameworks
|
||||
* endpoint=controls → GET /api/compliance/v1/canonical/controls(?severity=...&domain=...)
|
||||
* endpoint=control&id= → GET /api/compliance/v1/canonical/controls/{id}
|
||||
* endpoint=sources → GET /api/compliance/v1/canonical/sources
|
||||
* endpoint=licenses → GET /api/compliance/v1/canonical/licenses
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'frameworks'
|
||||
|
||||
let backendPath: string
|
||||
|
||||
switch (endpoint) {
|
||||
case 'frameworks':
|
||||
backendPath = '/api/compliance/v1/canonical/frameworks'
|
||||
break
|
||||
|
||||
case 'controls': {
|
||||
const controlParams = new URLSearchParams()
|
||||
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset']
|
||||
for (const key of passthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) controlParams.set(key, val)
|
||||
}
|
||||
const qs = controlParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls${qs ? `?${qs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'controls-count': {
|
||||
const countParams = new URLSearchParams()
|
||||
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||
for (const key of countPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) countParams.set(key, val)
|
||||
}
|
||||
const countQs = countParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-count${countQs ? `?${countQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'controls-meta': {
|
||||
const metaParams = new URLSearchParams()
|
||||
const metaPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||
for (const key of metaPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) metaParams.set(key, val)
|
||||
}
|
||||
const metaQs = metaParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-meta${metaQs ? `?${metaQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'control': {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'sources':
|
||||
backendPath = '/api/compliance/v1/canonical/sources'
|
||||
break
|
||||
|
||||
case 'licenses':
|
||||
backendPath = '/api/compliance/v1/canonical/licenses'
|
||||
break
|
||||
|
||||
// Generator endpoints
|
||||
case 'generate-jobs':
|
||||
backendPath = '/api/compliance/v1/canonical/generate/jobs'
|
||||
break
|
||||
|
||||
case 'generate-status': {
|
||||
const jobId = searchParams.get('jobId')
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/status/${encodeURIComponent(jobId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'review-queue': {
|
||||
const state = searchParams.get('release_state') || 'needs_review'
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review-queue?release_state=${encodeURIComponent(state)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'processed-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/generate/processed-stats'
|
||||
break
|
||||
|
||||
case 'categories':
|
||||
backendPath = '/api/compliance/v1/canonical/categories'
|
||||
break
|
||||
|
||||
case 'traceability': {
|
||||
const traceId = searchParams.get('id')
|
||||
if (!traceId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(traceId)}/traceability`
|
||||
break
|
||||
}
|
||||
|
||||
case 'provenance': {
|
||||
const provId = searchParams.get('id')
|
||||
if (!provId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(provId)}/provenance`
|
||||
break
|
||||
}
|
||||
|
||||
case 'atomic-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/controls/atomic-stats'
|
||||
break
|
||||
|
||||
case 'similar': {
|
||||
const simControlId = searchParams.get('id')
|
||||
if (!simControlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
const simThreshold = searchParams.get('threshold') || '0.85'
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(simControlId)}/similar?threshold=${simThreshold}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'blocked-sources':
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources'
|
||||
break
|
||||
|
||||
case 'v1-matches': {
|
||||
const matchId = searchParams.get('id')
|
||||
if (!matchId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(matchId)}/v1-matches`
|
||||
break
|
||||
}
|
||||
|
||||
case 'v1-enrichment-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/controls/v1-enrichment-stats'
|
||||
break
|
||||
|
||||
case 'obligation-dedup-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/obligations/dedup-stats'
|
||||
break
|
||||
|
||||
case 'controls-customer': {
|
||||
const custSeverity = searchParams.get('severity')
|
||||
const custDomain = searchParams.get('domain')
|
||||
const custParams = new URLSearchParams()
|
||||
if (custSeverity) custParams.set('severity', custSeverity)
|
||||
if (custDomain) custParams.set('domain', custDomain)
|
||||
const custQs = custParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-customer${custQs ? `?${custQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown endpoint: ${endpoint}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(null, { status: 404 })
|
||||
}
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Canonical control proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/canonical?endpoint=...
|
||||
*
|
||||
* endpoint=create-control → POST /api/compliance/v1/canonical/controls
|
||||
* endpoint=similarity-check&id= → POST /api/compliance/v1/canonical/controls/{id}/similarity-check
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
const body = await request.json()
|
||||
|
||||
let backendPath: string
|
||||
|
||||
if (endpoint === 'create-control') {
|
||||
backendPath = '/api/compliance/v1/canonical/controls'
|
||||
} else if (endpoint === 'generate') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate'
|
||||
} else if (endpoint === 'review') {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review/${encodeURIComponent(controlId)}`
|
||||
} else if (endpoint === 'bulk-review') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
||||
} else if (endpoint === 'blocked-sources-cleanup') {
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
||||
} else if (endpoint === 'enrich-v1-matches') {
|
||||
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||
const batchSize = searchParams.get('batch_size') ?? '100'
|
||||
const enrichOffset = searchParams.get('offset') ?? '0'
|
||||
backendPath = `/api/compliance/v1/canonical/controls/enrich-v1-matches?dry_run=${dryRun}&batch_size=${batchSize}&offset=${enrichOffset}`
|
||||
} else if (endpoint === 'obligation-dedup') {
|
||||
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||
const batchSize = searchParams.get('batch_size') ?? '0'
|
||||
const dedupOffset = searchParams.get('offset') ?? '0'
|
||||
backendPath = `/api/compliance/v1/canonical/obligations/dedup?dry_run=${dryRun}&batch_size=${batchSize}&offset=${dedupOffset}`
|
||||
} else if (endpoint === 'similarity-check') {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}/similarity-check`
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unknown POST endpoint: ${endpoint}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json(), { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Canonical control POST proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PUT /api/sdk/v1/canonical?endpoint=update-control&id=AUTH-001
|
||||
*
|
||||
* Routes to: PUT /api/compliance/v1/canonical/controls/{id}
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Canonical control PUT proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/canonical?id=AUTH-001
|
||||
*
|
||||
* Routes to: DELETE /api/compliance/v1/canonical/controls/{id}
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
console.error('Canonical control DELETE proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function getIds(request: NextRequest, body?: Record<string, unknown>) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||
const projectId = searchParams.get('project_id') || (body?.project_id as string) || ''
|
||||
const qs = projectId
|
||||
? `tenant_id=${encodeURIComponent(tenantId)}&project_id=${encodeURIComponent(projectId)}`
|
||||
: `tenant_id=${encodeURIComponent(tenantId)}`
|
||||
return { tenantId, projectId, qs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/company-profile → Backend GET /api/v1/company-profile
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||
const { tenantId, qs } = getIds(request)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
@@ -47,10 +56,10 @@ export async function GET(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || 'default'
|
||||
const { tenantId, qs } = getIds(request, body)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -80,6 +89,42 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/company-profile → Backend DELETE /api/v1/company-profile
|
||||
* DSGVO Art. 17 Recht auf Löschung
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { tenantId, qs } = getIds(request)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to delete company profile:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PATCH /api/sdk/v1/company-profile → Backend PATCH /api/v1/company-profile
|
||||
* Partial updates for individual fields
|
||||
@@ -87,10 +132,10 @@ export async function POST(request: NextRequest) {
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || 'default'
|
||||
const { tenantId, qs } = getIds(request, body)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/compliance-scope → Backend GET /api/v1/compliance-scope
|
||||
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
|
||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/compliance/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,39 +1,52 @@
|
||||
/**
|
||||
* DSGVO API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/dsgvo/* requests to ai-compliance-sdk backend
|
||||
* Evidence Checks API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/compliance/evidence-checks/* requests to backend-compliance
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[],
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments.join('/')
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const url = `${SDK_BACKEND_URL}/sdk/v1/dsgvo/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
const basePath = `${BACKEND_URL}/api/compliance/evidence-checks`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': 'admin',
|
||||
}
|
||||
|
||||
// Forward auth headers if present
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
|
||||
const userIdHeader = request.headers.get('x-user-id')
|
||||
if (userIdHeader) {
|
||||
headers['X-User-Id'] = userIdHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
|
||||
// Add body for POST/PUT/PATCH methods
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
@@ -43,27 +56,13 @@ async function proxyRequest(
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body - continue without
|
||||
// Empty or invalid body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle non-JSON responses (e.g., PDF export)
|
||||
const responseContentType = response.headers.get('content-type')
|
||||
if (responseContentType?.includes('application/pdf') ||
|
||||
responseContentType?.includes('application/octet-stream')) {
|
||||
const blob = await response.blob()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
@@ -81,9 +80,9 @@ async function proxyRequest(
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('DSGVO API proxy error:', error)
|
||||
console.error('Evidence Checks API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
@@ -91,7 +90,7 @@ async function proxyRequest(
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
@@ -99,7 +98,7 @@ export async function GET(
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
@@ -107,7 +106,7 @@ export async function POST(
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
@@ -115,7 +114,7 @@ export async function PUT(
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
@@ -123,7 +122,7 @@ export async function PATCH(
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Process Tasks API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/compliance/process-tasks/* requests to backend-compliance
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/process-tasks`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': 'admin',
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
|
||||
const userIdHeader = request.headers.get('x-user-id')
|
||||
if (userIdHeader) {
|
||||
headers['X-User-Id'] = userIdHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text && text.trim()) {
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Process Tasks API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
123
admin-compliance/app/api/sdk/v1/dsfa/[[...path]]/route.ts
Normal file
123
admin-compliance/app/api/sdk/v1/dsfa/[[...path]]/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* DSFA API Proxy — Datenschutz-Folgenabschaetzung (Art. 35 DSGVO)
|
||||
* Proxies /api/sdk/v1/dsfa/* → backend-compliance:8002/api/v1/dsfa/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/dsfa`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId))
|
||||
? clientUserId
|
||||
: '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId))
|
||||
? clientTenantId
|
||||
: (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('DSFA API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -22,6 +22,8 @@ async function proxyRequest(
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': 'admin',
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
@@ -34,6 +36,11 @@ async function proxyRequest(
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
|
||||
const userIdHeader = request.headers.get('x-user-id')
|
||||
if (userIdHeader) {
|
||||
headers['X-User-Id'] = userIdHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/import/:id → Backend DELETE /api/v1/import/:id
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/import/analyze → Backend POST /api/v1/import/analyze
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/import → Backend GET /api/v1/import
|
||||
@@ -40,3 +40,52 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/import → Backend POST /api/v1/import/analyze
|
||||
* Uploads a document for gap analysis. Forwards multipart/form-data.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
const url = `${BACKEND_URL}/api/v1/import/analyze`
|
||||
|
||||
let body: BodyInit
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
body = await request.arrayBuffer()
|
||||
headers['Content-Type'] = contentType
|
||||
} else {
|
||||
body = await request.text()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (request.headers.get('X-Tenant-ID')) {
|
||||
headers['X-Tenant-ID'] = request.headers.get('X-Tenant-ID') as string
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to upload document for import analysis:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Incidents/Breach Management API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/incidents/* requests to ai-compliance-sdk backend
|
||||
* Proxies all /api/sdk/v1/incidents/* requests to backend-compliance (Python)
|
||||
* Python backend is Source of Truth (migrated from Go ai-compliance-sdk)
|
||||
* Supports PDF generation for authority notification forms
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
@@ -15,7 +18,7 @@ async function proxyRequest(
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/incidents`
|
||||
const basePath = `${BACKEND_URL}/api/compliance/incidents`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
@@ -30,10 +33,8 @@ async function proxyRequest(
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
headers['X-Tenant-Id'] = request.headers.get('x-tenant-id') || DEFAULT_TENANT_ID
|
||||
headers['X-User-Id'] = request.headers.get('x-user-id') || DEFAULT_USER_ID
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
|
||||
111
admin-compliance/app/api/sdk/v1/isms/[[...path]]/route.ts
Normal file
111
admin-compliance/app/api/sdk/v1/isms/[[...path]]/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ISMS (ISO 27001) API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/isms/* requests to backend-compliance /api/isms/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/isms`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('ISMS API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@@ -9,7 +9,7 @@ export async function POST(
|
||||
try {
|
||||
const { moduleId } = await params
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/activate`,
|
||||
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}/activate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@@ -9,7 +9,7 @@ export async function POST(
|
||||
try {
|
||||
const { moduleId } = await params
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/deactivate`,
|
||||
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}/deactivate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/modules/:moduleId → Backend GET /api/modules/:moduleId
|
||||
@@ -13,7 +13,7 @@ export async function GET(
|
||||
const { moduleId } = await params
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}`,
|
||||
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy to backend-compliance /api/modules endpoint.
|
||||
@@ -23,7 +23,7 @@ export async function GET(request: NextRequest) {
|
||||
if (aiComponents) params.set('ai_components', aiComponents)
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `${BACKEND_URL}/api/modules${queryString ? `?${queryString}` : ''}`
|
||||
const url = `${BACKEND_URL}/api/compliance/modules${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
@@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/modules`, {
|
||||
const response = await fetch(`${BACKEND_URL}/api/compliance/modules`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
119
admin-compliance/app/api/sdk/v1/portfolio/[[...path]]/route.ts
Normal file
119
admin-compliance/app/api/sdk/v1/portfolio/[[...path]]/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Portfolio API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/portfolio/* requests to ai-compliance-sdk backend
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/portfolios`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Portfolio API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/projects/{projectId}/permanent → Backend (hard delete)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}/permanent?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to permanently delete project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/projects/{projectId}/restore → Backend
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}/restore?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to restore project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
120
admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts
Normal file
120
admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/projects/{projectId} → Backend
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to get project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PATCH /api/sdk/v1/projects/{projectId} → Backend
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || request.headers.get('X-Tenant-ID') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to update project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/projects/{projectId} → Backend (soft delete)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to archive project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
75
admin-compliance/app/api/sdk/v1/projects/route.ts
Normal file
75
admin-compliance/app/api/sdk/v1/projects/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/projects → Backend GET /api/compliance/v1/projects
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenant_id') || request.headers.get('X-Tenant-ID') || ''
|
||||
const includeArchived = searchParams.get('include_archived') || 'false'
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects?tenant_id=${encodeURIComponent(tenantId)}&include_archived=${includeArchived}`,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to list projects:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/projects → Backend POST /api/compliance/v1/projects
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || request.headers.get('X-Tenant-ID') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json(), { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
125
admin-compliance/app/api/sdk/v1/rbac/[[...path]]/route.ts
Normal file
125
admin-compliance/app/api/sdk/v1/rbac/[[...path]]/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* RBAC Admin API Proxy - Catch-all route
|
||||
* Proxies /api/sdk/v1/rbac/<resource>/... to ai-compliance-sdk /sdk/v1/<resource>/...
|
||||
*
|
||||
* Mapping: /rbac/tenants/... → /sdk/v1/tenants/...
|
||||
* /rbac/namespaces/... → /sdk/v1/namespaces/...
|
||||
* /rbac/roles/... → /sdk/v1/roles/...
|
||||
* /rbac/user-roles/... → /sdk/v1/user-roles/...
|
||||
* /rbac/permissions/... → /sdk/v1/permissions/...
|
||||
* /rbac/llm/policies/... → /sdk/v1/llm/policies/...
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
// Path segments come as the full sub-path after /rbac/
|
||||
// e.g. /rbac/tenants/123 → pathSegments = ['tenants', '123']
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const url = `${SDK_BACKEND_URL}/sdk/v1/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('RBAC API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Roadmap Items API Proxy - Catch-all route
|
||||
* Proxies /api/sdk/v1/roadmap-items/* to ai-compliance-sdk /sdk/v1/roadmap-items/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/roadmap-items`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Roadmap Items API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Vendor Compliance API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/vendors/* requests to ai-compliance-sdk backend
|
||||
* Roadmap API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/roadmap/* requests to ai-compliance-sdk backend
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
@@ -14,7 +14,7 @@ async function proxyRequest(
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/vendors`
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/roadmaps`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
@@ -24,8 +24,7 @@ async function proxyRequest(
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Forward all relevant headers
|
||||
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
@@ -33,42 +32,27 @@ async function proxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text && text.trim()) {
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body - continue without
|
||||
}
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle non-JSON responses (e.g., PDF exports)
|
||||
const responseContentType = response.headers.get('content-type')
|
||||
if (responseContentType?.includes('application/pdf') ||
|
||||
responseContentType?.includes('application/octet-stream')) {
|
||||
const blob = await response.blob()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
@@ -83,10 +67,22 @@ async function proxyRequest(
|
||||
)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/octet-stream') || contentType.includes('text/csv')) {
|
||||
const blob = await response.blob()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Vendor Compliance API proxy error:', error)
|
||||
console.error('Roadmap API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/screening → Backend GET /api/v1/screening
|
||||
@@ -40,3 +40,52 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/screening → Backend POST /api/v1/screening/scan
|
||||
* Uploads a dependency file (package-lock.json, requirements.txt, etc.) for SBOM + vulnerability scan.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
const url = `${BACKEND_URL}/api/v1/screening/scan`
|
||||
|
||||
let body: BodyInit
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
body = await request.arrayBuffer()
|
||||
headers['Content-Type'] = contentType
|
||||
} else {
|
||||
body = await request.text()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (request.headers.get('X-Tenant-ID')) {
|
||||
headers['X-Tenant-ID'] = request.headers.get('X-Tenant-ID') as string
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file for screening scan:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/screening/scan → Backend POST /api/v1/screening/scan
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/admin/operations/${encodeURIComponent(id)}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch operation:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
@@ -32,3 +60,32 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/admin/operations/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to delete operation:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/admin/pii-rules/${encodeURIComponent(id)}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch PII rule:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -2,17 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Pool } from 'pg'
|
||||
|
||||
/**
|
||||
* SDK State Management API
|
||||
* SDK State Management API (Multi-Project)
|
||||
*
|
||||
* GET /api/sdk/v1/state?tenantId=xxx - Load state for a tenant
|
||||
* POST /api/sdk/v1/state - Save state for a tenant
|
||||
* DELETE /api/sdk/v1/state?tenantId=xxx - Clear state for a tenant
|
||||
* GET /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Load state for a tenant+project
|
||||
* POST /api/sdk/v1/state - Save state for a tenant+project
|
||||
* DELETE /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Clear state
|
||||
*
|
||||
* Features:
|
||||
* - Versioning for optimistic locking
|
||||
* - Last-Modified headers
|
||||
* - ETag support for caching
|
||||
* - PostgreSQL persistence (with InMemory fallback)
|
||||
* - projectId support for multi-project architecture
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
@@ -32,25 +33,31 @@ interface StoredState {
|
||||
// =============================================================================
|
||||
|
||||
interface StateStore {
|
||||
get(tenantId: string): Promise<StoredState | null>
|
||||
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState>
|
||||
delete(tenantId: string): Promise<boolean>
|
||||
get(tenantId: string, projectId?: string): Promise<StoredState | null>
|
||||
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise<StoredState>
|
||||
delete(tenantId: string, projectId?: string): Promise<boolean>
|
||||
}
|
||||
|
||||
class InMemoryStateStore implements StateStore {
|
||||
private store: Map<string, StoredState> = new Map()
|
||||
|
||||
async get(tenantId: string): Promise<StoredState | null> {
|
||||
return this.store.get(tenantId) || null
|
||||
private key(tenantId: string, projectId?: string): string {
|
||||
return projectId ? `${tenantId}:${projectId}` : tenantId
|
||||
}
|
||||
|
||||
async get(tenantId: string, projectId?: string): Promise<StoredState | null> {
|
||||
return this.store.get(this.key(tenantId, projectId)) || null
|
||||
}
|
||||
|
||||
async save(
|
||||
tenantId: string,
|
||||
state: unknown,
|
||||
userId?: string,
|
||||
expectedVersion?: number
|
||||
expectedVersion?: number,
|
||||
projectId?: string
|
||||
): Promise<StoredState> {
|
||||
const existing = this.store.get(tenantId)
|
||||
const k = this.key(tenantId, projectId)
|
||||
const existing = this.store.get(k)
|
||||
|
||||
if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) {
|
||||
const error = new Error('Version conflict') as Error & { status: number }
|
||||
@@ -72,12 +79,12 @@ class InMemoryStateStore implements StateStore {
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
this.store.set(tenantId, stored)
|
||||
this.store.set(k, stored)
|
||||
return stored
|
||||
}
|
||||
|
||||
async delete(tenantId: string): Promise<boolean> {
|
||||
return this.store.delete(tenantId)
|
||||
async delete(tenantId: string, projectId?: string): Promise<boolean> {
|
||||
return this.store.delete(this.key(tenantId, projectId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,11 +100,26 @@ class PostgreSQLStateStore implements StateStore {
|
||||
})
|
||||
}
|
||||
|
||||
async get(tenantId: string): Promise<StoredState | null> {
|
||||
const result = await this.pool.query(
|
||||
'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1',
|
||||
[tenantId]
|
||||
)
|
||||
async get(tenantId: string, projectId?: string): Promise<StoredState | null> {
|
||||
let result
|
||||
if (projectId) {
|
||||
result = await this.pool.query(
|
||||
'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1 AND project_id = $2',
|
||||
[tenantId, projectId]
|
||||
)
|
||||
} else {
|
||||
// Backwards compatibility: find the single active project for this tenant
|
||||
result = await this.pool.query(
|
||||
`SELECT s.state, s.version, s.user_id, s.created_at, s.updated_at
|
||||
FROM sdk_states s
|
||||
LEFT JOIN compliance_projects p ON s.project_id = p.id
|
||||
WHERE s.tenant_id = $1
|
||||
AND (p.status = 'active' OR p.id IS NULL)
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT 1`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
if (result.rows.length === 0) return null
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
@@ -109,25 +131,71 @@ class PostgreSQLStateStore implements StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState> {
|
||||
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise<StoredState> {
|
||||
const now = new Date().toISOString()
|
||||
const stateWithTimestamp = {
|
||||
...(state as object),
|
||||
lastModified: now,
|
||||
}
|
||||
|
||||
// Use UPSERT with version check
|
||||
const result = await this.pool.query(`
|
||||
INSERT INTO sdk_states (tenant_id, user_id, state, version, created_at, updated_at)
|
||||
VALUES ($1, $2, $3::jsonb, 1, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
state = $3::jsonb,
|
||||
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 version, user_id, created_at, updated_at
|
||||
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null])
|
||||
let result
|
||||
|
||||
if (projectId) {
|
||||
// Multi-project: UPSERT on (tenant_id, project_id)
|
||||
result = await this.pool.query(`
|
||||
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
|
||||
state = $3::jsonb,
|
||||
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 version, user_id, created_at, updated_at
|
||||
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, projectId])
|
||||
} else {
|
||||
// Backwards compatibility: find the single project for this tenant
|
||||
// First try to find an existing project
|
||||
const projectResult = await this.pool.query(
|
||||
`SELECT id FROM compliance_projects WHERE tenant_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT 1`,
|
||||
[tenantId]
|
||||
)
|
||||
|
||||
if (projectResult.rows.length > 0) {
|
||||
const foundProjectId = projectResult.rows[0].id
|
||||
result = await this.pool.query(`
|
||||
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
|
||||
state = $3::jsonb,
|
||||
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 version, user_id, created_at, updated_at
|
||||
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, foundProjectId])
|
||||
} else {
|
||||
// No project exists — create a default one
|
||||
const newProject = await this.pool.query(
|
||||
`INSERT INTO compliance_projects (tenant_id, name, customer_type, status)
|
||||
VALUES ($1, 'Projekt 1', 'new', 'active')
|
||||
RETURNING id`,
|
||||
[tenantId]
|
||||
)
|
||||
const newProjectId = newProject.rows[0].id
|
||||
result = await this.pool.query(`
|
||||
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
|
||||
state = $3::jsonb,
|
||||
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 version, user_id, created_at, updated_at
|
||||
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, newProjectId])
|
||||
}
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
const error = new Error('Version conflict') as Error & { status: number }
|
||||
@@ -145,11 +213,19 @@ class PostgreSQLStateStore implements StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
async delete(tenantId: string): Promise<boolean> {
|
||||
const result = await this.pool.query(
|
||||
'DELETE FROM sdk_states WHERE tenant_id = $1',
|
||||
[tenantId]
|
||||
)
|
||||
async delete(tenantId: string, projectId?: string): Promise<boolean> {
|
||||
let result
|
||||
if (projectId) {
|
||||
result = await this.pool.query(
|
||||
'DELETE FROM sdk_states WHERE tenant_id = $1 AND project_id = $2',
|
||||
[tenantId, projectId]
|
||||
)
|
||||
} else {
|
||||
result = await this.pool.query(
|
||||
'DELETE FROM sdk_states WHERE tenant_id = $1',
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
}
|
||||
@@ -186,6 +262,7 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId')
|
||||
const projectId = searchParams.get('projectId') || undefined
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
@@ -194,7 +271,7 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const stored = await stateStore.get(tenantId)
|
||||
const stored = await stateStore.get(tenantId, projectId)
|
||||
|
||||
if (!stored) {
|
||||
return NextResponse.json(
|
||||
@@ -216,6 +293,7 @@ export async function GET(request: NextRequest) {
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
projectId: projectId || null,
|
||||
state: stored.state,
|
||||
version: stored.version,
|
||||
lastModified: stored.updatedAt,
|
||||
@@ -241,7 +319,7 @@ export async function GET(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { tenantId, state, version } = body
|
||||
const { tenantId, state, version, projectId } = body
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
@@ -261,7 +339,7 @@ export async function POST(request: NextRequest) {
|
||||
const ifMatch = request.headers.get('If-Match')
|
||||
const expectedVersion = ifMatch ? parseInt(ifMatch, 10) : version
|
||||
|
||||
const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion)
|
||||
const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion, projectId || undefined)
|
||||
|
||||
const etag = generateETag(stored.version, stored.updatedAt)
|
||||
|
||||
@@ -270,6 +348,7 @@ export async function POST(request: NextRequest) {
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
projectId: projectId || null,
|
||||
state: stored.state,
|
||||
version: stored.version,
|
||||
lastModified: stored.updatedAt,
|
||||
@@ -309,6 +388,7 @@ export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId')
|
||||
const projectId = searchParams.get('projectId') || undefined
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
@@ -317,7 +397,7 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const deleted = await stateStore.delete(tenantId)
|
||||
const deleted = await stateStore.delete(tenantId, projectId)
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,119 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
TOMGeneratorState,
|
||||
createEmptyTOMGeneratorState,
|
||||
} from '@/lib/sdk/tom-generator/types'
|
||||
|
||||
/**
|
||||
* TOM Generator State API
|
||||
* TOM Generator State API — Proxy to backend-compliance (Python/FastAPI)
|
||||
*
|
||||
* GET /api/sdk/v1/tom-generator/state?tenantId=xxx - Load TOM generator state
|
||||
* POST /api/sdk/v1/tom-generator/state - Save TOM generator state
|
||||
* DELETE /api/sdk/v1/tom-generator/state?tenantId=xxx - Clear state
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// STORAGE (In-Memory for development)
|
||||
// =============================================================================
|
||||
|
||||
interface StoredTOMState {
|
||||
state: TOMGeneratorState
|
||||
version: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
class InMemoryTOMStateStore {
|
||||
private store: Map<string, StoredTOMState> = new Map()
|
||||
|
||||
async get(tenantId: string): Promise<StoredTOMState | null> {
|
||||
return this.store.get(tenantId) || null
|
||||
}
|
||||
|
||||
async save(tenantId: string, state: TOMGeneratorState, expectedVersion?: number): Promise<StoredTOMState> {
|
||||
const existing = this.store.get(tenantId)
|
||||
|
||||
if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) {
|
||||
const error = new Error('Version conflict') as Error & { status: number }
|
||||
error.status = 409
|
||||
throw error
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const newVersion = existing ? existing.version + 1 : 1
|
||||
|
||||
const stored: StoredTOMState = {
|
||||
state: {
|
||||
...state,
|
||||
updatedAt: new Date(now),
|
||||
},
|
||||
version: newVersion,
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
this.store.set(tenantId, stored)
|
||||
return stored
|
||||
}
|
||||
|
||||
async delete(tenantId: string): Promise<boolean> {
|
||||
return this.store.delete(tenantId)
|
||||
}
|
||||
|
||||
async list(): Promise<{ tenantId: string; updatedAt: string }[]> {
|
||||
const result: { tenantId: string; updatedAt: string }[] = []
|
||||
this.store.forEach((value, key) => {
|
||||
result.push({ tenantId: key, updatedAt: value.updatedAt })
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
const stateStore = new InMemoryTOMStateStore()
|
||||
|
||||
// =============================================================================
|
||||
// HANDLERS
|
||||
// =============================================================================
|
||||
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId')
|
||||
|
||||
// List all states if no tenantId provided
|
||||
if (!tenantId) {
|
||||
const states = await stateStore.list()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: states,
|
||||
})
|
||||
}
|
||||
const url = tenantId
|
||||
? `${BACKEND_URL}/api/compliance/tom/state?tenant_id=${encodeURIComponent(tenantId)}`
|
||||
: `${BACKEND_URL}/api/compliance/tom/state`
|
||||
|
||||
const stored = await stateStore.get(tenantId)
|
||||
|
||||
if (!stored) {
|
||||
// Return empty state for new tenants
|
||||
const emptyState = createEmptyTOMGeneratorState(tenantId)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
state: emptyState,
|
||||
version: 0,
|
||||
isNew: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
state: stored.state,
|
||||
version: stored.version,
|
||||
lastModified: stored.updatedAt,
|
||||
},
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to load TOM generator state:', error)
|
||||
return NextResponse.json(
|
||||
@@ -142,65 +53,19 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Deserialize dates
|
||||
const parsedState: TOMGeneratorState = {
|
||||
...state,
|
||||
createdAt: new Date(state.createdAt),
|
||||
updatedAt: new Date(state.updatedAt),
|
||||
steps: state.steps.map((step: { id: string; completed: boolean; data: unknown; validatedAt: string | null }) => ({
|
||||
...step,
|
||||
validatedAt: step.validatedAt ? new Date(step.validatedAt) : null,
|
||||
})),
|
||||
documents: state.documents?.map((doc: { uploadedAt: string; validFrom?: string; validUntil?: string; aiAnalysis?: { analyzedAt: string } }) => ({
|
||||
...doc,
|
||||
uploadedAt: new Date(doc.uploadedAt),
|
||||
validFrom: doc.validFrom ? new Date(doc.validFrom) : null,
|
||||
validUntil: doc.validUntil ? new Date(doc.validUntil) : null,
|
||||
aiAnalysis: doc.aiAnalysis ? {
|
||||
...doc.aiAnalysis,
|
||||
analyzedAt: new Date(doc.aiAnalysis.analyzedAt),
|
||||
} : null,
|
||||
})) || [],
|
||||
derivedTOMs: state.derivedTOMs?.map((tom: { implementationDate?: string; reviewDate?: string }) => ({
|
||||
...tom,
|
||||
implementationDate: tom.implementationDate ? new Date(tom.implementationDate) : null,
|
||||
reviewDate: tom.reviewDate ? new Date(tom.reviewDate) : null,
|
||||
})) || [],
|
||||
gapAnalysis: state.gapAnalysis ? {
|
||||
...state.gapAnalysis,
|
||||
generatedAt: new Date(state.gapAnalysis.generatedAt),
|
||||
} : null,
|
||||
exports: state.exports?.map((exp: { generatedAt: string }) => ({
|
||||
...exp,
|
||||
generatedAt: new Date(exp.generatedAt),
|
||||
})) || [],
|
||||
}
|
||||
|
||||
const stored = await stateStore.save(tenantId, parsedState, version)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
state: stored.state,
|
||||
version: stored.version,
|
||||
lastModified: stored.updatedAt,
|
||||
},
|
||||
const res = await fetch(`${BACKEND_URL}/api/compliance/tom/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
state,
|
||||
version,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
const err = error as Error & { status?: number }
|
||||
|
||||
if (err.status === 409 || err.message === 'Version conflict') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Version conflict. State was modified by another request.',
|
||||
code: 'VERSION_CONFLICT',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
console.error('Failed to save TOM generator state:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to save state' },
|
||||
@@ -221,14 +86,16 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const deleted = await stateStore.delete(tenantId)
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/tom/state?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tenantId,
|
||||
deleted,
|
||||
deletedAt: new Date().toISOString(),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete TOM generator state:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -53,7 +53,18 @@ async function proxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// Handle redirects (e.g. media stream presigned URL)
|
||||
if (response.status === 307 || response.status === 302) {
|
||||
const location = response.headers.get('location')
|
||||
if (location) {
|
||||
return NextResponse.redirect(location)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -69,6 +80,19 @@ async function proxyRequest(
|
||||
)
|
||||
}
|
||||
|
||||
// Handle binary responses (PDF, octet-stream)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) {
|
||||
const buffer = await response.arrayBuffer()
|
||||
return new NextResponse(buffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||
*/
|
||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
const subPath = path ? path.join('/') : ''
|
||||
const search = request.nextUrl.search || ''
|
||||
const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}`
|
||||
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Tenant-ID': tenantID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') {
|
||||
const body = await request.json()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxyRequest
|
||||
export const POST = proxyRequest
|
||||
export const DELETE = proxyRequest
|
||||
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
|
||||
* Returns the decision tree definition (questions, structure)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
|
||||
headers: { 'X-Tenant-ID': tenantID },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Decision tree GET error:', errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
async function proxyRequest(request: NextRequest, method: string) {
|
||||
try {
|
||||
const pathSegments = request.nextUrl.pathname.replace('/api/sdk/v1/ucca/obligations/', '')
|
||||
const targetUrl = `${SDK_BASE_URL}/sdk/v1/ucca/obligations/${pathSegments}${request.nextUrl.search}`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = { method, headers }
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
fetchOptions.body = await request.text()
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'SDK backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('UCCA obligations proxy error:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to SDK backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) { return proxyRequest(request, 'GET') }
|
||||
export async function POST(request: NextRequest) { return proxyRequest(request, 'POST') }
|
||||
@@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Forward the request to the SDK backend
|
||||
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/assess`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Forward tenant ID if present
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
@@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Forward the request to the SDK backend
|
||||
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/export/direct`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Forward tenant ID if present
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Vendor Compliance API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/vendor-compliance/* requests to backend-compliance
|
||||
*
|
||||
* Backend routes: vendors, contracts, findings, control-instances, controls, export
|
||||
* All under /api/compliance/vendor-compliance/ prefix on backend-compliance:8002
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/vendor-compliance`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Vendor Compliance API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ContractDocument } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
// In-memory storage for demo purposes
|
||||
const contracts: Map<string, ContractDocument> = new Map()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const contractList = Array.from(contracts.values())
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: contractList,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching contracts:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch contracts' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Handle multipart form data for file upload
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const vendorId = formData.get('vendorId') as string
|
||||
const metadataStr = formData.get('metadata') as string
|
||||
|
||||
if (!file || !vendorId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'File and vendorId are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const metadata = metadataStr ? JSON.parse(metadataStr) : {}
|
||||
const id = uuidv4()
|
||||
|
||||
// In production, upload file to storage (MinIO, S3, etc.)
|
||||
const storagePath = `contracts/${id}/${file.name}`
|
||||
|
||||
const contract: ContractDocument = {
|
||||
id,
|
||||
tenantId: 'default',
|
||||
vendorId,
|
||||
fileName: `${id}-${file.name}`,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
fileSize: file.size,
|
||||
storagePath,
|
||||
documentType: metadata.documentType || 'OTHER',
|
||||
version: metadata.version || '1.0',
|
||||
previousVersionId: metadata.previousVersionId,
|
||||
parties: metadata.parties,
|
||||
effectiveDate: metadata.effectiveDate ? new Date(metadata.effectiveDate) : undefined,
|
||||
expirationDate: metadata.expirationDate ? new Date(metadata.expirationDate) : undefined,
|
||||
autoRenewal: metadata.autoRenewal,
|
||||
renewalNoticePeriod: metadata.renewalNoticePeriod,
|
||||
terminationNoticePeriod: metadata.terminationNoticePeriod,
|
||||
reviewStatus: 'PENDING',
|
||||
status: 'DRAFT',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
contracts.set(id, contract)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: contract,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error uploading contract:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to upload contract' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { CONTROLS_LIBRARY } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const domain = searchParams.get('domain')
|
||||
|
||||
let controls = [...CONTROLS_LIBRARY]
|
||||
|
||||
// Filter by domain if provided
|
||||
if (domain) {
|
||||
controls = controls.filter((c) => c.domain === domain)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: controls,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching controls:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch controls' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/vendor-compliance/export/[reportId]/download
|
||||
*
|
||||
* Download a generated report file.
|
||||
* In production, this would redirect to a signed MinIO/S3 URL or stream the file.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ reportId: string }> }
|
||||
) {
|
||||
const { reportId } = await params
|
||||
|
||||
// TODO: Implement actual file download
|
||||
// This would typically:
|
||||
// 1. Verify report exists and user has access
|
||||
// 2. Generate signed URL for MinIO/S3
|
||||
// 3. Redirect to signed URL or stream file
|
||||
|
||||
// For now, return a placeholder PDF
|
||||
const placeholderContent = `
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 200 >>
|
||||
stream
|
||||
BT
|
||||
/F1 24 Tf
|
||||
100 700 Td
|
||||
(Vendor Compliance Report) Tj
|
||||
/F1 12 Tf
|
||||
100 650 Td
|
||||
(Report ID: ${reportId}) Tj
|
||||
100 620 Td
|
||||
(Generated: ${new Date().toISOString()}) Tj
|
||||
100 580 Td
|
||||
(This is a placeholder. Implement actual report generation.) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000266 00000 n
|
||||
0000000519 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
598
|
||||
%%EOF
|
||||
`.trim()
|
||||
|
||||
// Return as PDF
|
||||
return new NextResponse(placeholderContent, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="Report_${reportId.slice(0, 8)}.pdf"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/vendor-compliance/export/[reportId]
|
||||
*
|
||||
* Get report metadata by ID.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ reportId: string }> }
|
||||
) {
|
||||
const { reportId } = await params
|
||||
|
||||
// TODO: Fetch report metadata from database
|
||||
// For now, return mock data
|
||||
|
||||
return NextResponse.json({
|
||||
id: reportId,
|
||||
status: 'completed',
|
||||
filename: `Report_${reportId.slice(0, 8)}.pdf`,
|
||||
generatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24h
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/sdk/v1/vendor-compliance/export/[reportId]
|
||||
*
|
||||
* Delete a generated report.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ reportId: string }> }
|
||||
) {
|
||||
const { reportId } = await params
|
||||
|
||||
// TODO: Delete report from storage and database
|
||||
console.log('Deleting report:', reportId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedId: reportId,
|
||||
})
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
/**
|
||||
* POST /api/sdk/v1/vendor-compliance/export
|
||||
*
|
||||
* Generate and export reports in various formats.
|
||||
* Currently returns mock data - integrate with actual report generation service.
|
||||
*/
|
||||
|
||||
interface ExportConfig {
|
||||
reportType: 'VVT_EXPORT' | 'VENDOR_AUDIT' | 'ROPA' | 'MANAGEMENT_SUMMARY' | 'DPIA_INPUT'
|
||||
format: 'PDF' | 'DOCX' | 'XLSX' | 'JSON'
|
||||
scope: {
|
||||
vendorIds: string[]
|
||||
processingActivityIds: string[]
|
||||
includeFindings: boolean
|
||||
includeControls: boolean
|
||||
includeRiskAssessment: boolean
|
||||
dateRange?: {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REPORT_TYPE_NAMES: Record<ExportConfig['reportType'], string> = {
|
||||
VVT_EXPORT: 'Verarbeitungsverzeichnis',
|
||||
VENDOR_AUDIT: 'Vendor-Audit-Pack',
|
||||
ROPA: 'RoPA',
|
||||
MANAGEMENT_SUMMARY: 'Management-Summary',
|
||||
DPIA_INPUT: 'DSFA-Input',
|
||||
}
|
||||
|
||||
const FORMAT_EXTENSIONS: Record<ExportConfig['format'], string> = {
|
||||
PDF: 'pdf',
|
||||
DOCX: 'docx',
|
||||
XLSX: 'xlsx',
|
||||
JSON: 'json',
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config = (await request.json()) as ExportConfig
|
||||
|
||||
// Validate request
|
||||
if (!config.reportType || !config.format) {
|
||||
return NextResponse.json(
|
||||
{ error: 'reportType and format are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate report ID and filename
|
||||
const reportId = uuidv4()
|
||||
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||
const filename = `${REPORT_TYPE_NAMES[config.reportType]}_${timestamp}.${FORMAT_EXTENSIONS[config.format]}`
|
||||
|
||||
// TODO: Implement actual report generation
|
||||
// This would typically:
|
||||
// 1. Fetch data from database based on scope
|
||||
// 2. Generate report using template engine (e.g., docx-templates, pdfkit)
|
||||
// 3. Store in MinIO/S3
|
||||
// 4. Return download URL
|
||||
|
||||
// Mock implementation - simulate processing time
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// In production, this would be a signed URL to MinIO/S3
|
||||
const downloadUrl = `/api/sdk/v1/vendor-compliance/export/${reportId}/download`
|
||||
|
||||
// Log export for audit trail
|
||||
console.log('Export generated:', {
|
||||
reportId,
|
||||
reportType: config.reportType,
|
||||
format: config.format,
|
||||
scope: config.scope,
|
||||
filename,
|
||||
generatedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
id: reportId,
|
||||
reportType: config.reportType,
|
||||
format: config.format,
|
||||
filename,
|
||||
downloadUrl,
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: {
|
||||
vendorCount: config.scope.vendorIds?.length || 0,
|
||||
activityCount: config.scope.processingActivityIds?.length || 0,
|
||||
includesFindings: config.scope.includeFindings,
|
||||
includesControls: config.scope.includeControls,
|
||||
includesRiskAssessment: config.scope.includeRiskAssessment,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate export' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/vendor-compliance/export
|
||||
*
|
||||
* List recent exports for the current tenant.
|
||||
*/
|
||||
export async function GET() {
|
||||
// TODO: Implement fetching recent exports from database
|
||||
// For now, return empty list
|
||||
return NextResponse.json({
|
||||
exports: [],
|
||||
totalCount: 0,
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Finding } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
// In-memory storage for demo purposes
|
||||
const findings: Map<string, Finding> = new Map()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const vendorId = searchParams.get('vendorId')
|
||||
const contractId = searchParams.get('contractId')
|
||||
const status = searchParams.get('status')
|
||||
|
||||
let findingsList = Array.from(findings.values())
|
||||
|
||||
// Filter by vendor
|
||||
if (vendorId) {
|
||||
findingsList = findingsList.filter((f) => f.vendorId === vendorId)
|
||||
}
|
||||
|
||||
// Filter by contract
|
||||
if (contractId) {
|
||||
findingsList = findingsList.filter((f) => f.contractId === contractId)
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (status) {
|
||||
findingsList = findingsList.filter((f) => f.status === status)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: findingsList,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching findings:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch findings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// This would reference the same storage as the main route
|
||||
// In production, this would be database calls
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// In production, fetch from database
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null, // Would return the activity
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching processing activity:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch processing activity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
// In production, update in database
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { id, ...body, updatedAt: new Date() },
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating processing activity:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update processing activity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// In production, delete from database
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting processing activity:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete processing activity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ProcessingActivity, generateVVTId } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
// In-memory storage for demo purposes
|
||||
// In production, this would be replaced with database calls
|
||||
const processingActivities: Map<string, ProcessingActivity> = new Map()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const activities = Array.from(processingActivities.values())
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: activities,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching processing activities:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch processing activities' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Generate IDs
|
||||
const id = uuidv4()
|
||||
const existingIds = Array.from(processingActivities.values()).map((a) => a.vvtId)
|
||||
const vvtId = body.vvtId || generateVVTId(existingIds)
|
||||
|
||||
const activity: ProcessingActivity = {
|
||||
id,
|
||||
tenantId: 'default', // Would come from auth context
|
||||
vvtId,
|
||||
name: body.name,
|
||||
responsible: body.responsible,
|
||||
dpoContact: body.dpoContact,
|
||||
purposes: body.purposes || [],
|
||||
dataSubjectCategories: body.dataSubjectCategories || [],
|
||||
personalDataCategories: body.personalDataCategories || [],
|
||||
recipientCategories: body.recipientCategories || [],
|
||||
thirdCountryTransfers: body.thirdCountryTransfers || [],
|
||||
retentionPeriod: body.retentionPeriod || { description: { de: '', en: '' } },
|
||||
technicalMeasures: body.technicalMeasures || [],
|
||||
legalBasis: body.legalBasis || [],
|
||||
dataSources: body.dataSources || [],
|
||||
systems: body.systems || [],
|
||||
dataFlows: body.dataFlows || [],
|
||||
protectionLevel: body.protectionLevel || 'MEDIUM',
|
||||
dpiaRequired: body.dpiaRequired || false,
|
||||
dpiaJustification: body.dpiaJustification,
|
||||
subProcessors: body.subProcessors || [],
|
||||
legalRetentionBasis: body.legalRetentionBasis,
|
||||
status: body.status || 'DRAFT',
|
||||
owner: body.owner || '',
|
||||
lastReviewDate: body.lastReviewDate,
|
||||
nextReviewDate: body.nextReviewDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
processingActivities.set(id, activity)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: activity,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error creating processing activity:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create processing activity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Vendor } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
// In-memory storage for demo purposes
|
||||
const vendors: Map<string, Vendor> = new Map()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const vendorList = Array.from(vendors.values())
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: vendorList,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching vendors:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch vendors' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const id = uuidv4()
|
||||
|
||||
const vendor: Vendor = {
|
||||
id,
|
||||
tenantId: 'default',
|
||||
name: body.name,
|
||||
legalForm: body.legalForm,
|
||||
country: body.country,
|
||||
address: body.address,
|
||||
website: body.website,
|
||||
role: body.role,
|
||||
serviceDescription: body.serviceDescription,
|
||||
serviceCategory: body.serviceCategory,
|
||||
dataAccessLevel: body.dataAccessLevel || 'NONE',
|
||||
processingLocations: body.processingLocations || [],
|
||||
transferMechanisms: body.transferMechanisms || [],
|
||||
certifications: body.certifications || [],
|
||||
primaryContact: body.primaryContact,
|
||||
dpoContact: body.dpoContact,
|
||||
securityContact: body.securityContact,
|
||||
contractTypes: body.contractTypes || [],
|
||||
contracts: body.contracts || [],
|
||||
inherentRiskScore: body.inherentRiskScore || 50,
|
||||
residualRiskScore: body.residualRiskScore || 50,
|
||||
manualRiskAdjustment: body.manualRiskAdjustment,
|
||||
riskJustification: body.riskJustification,
|
||||
reviewFrequency: body.reviewFrequency || 'ANNUAL',
|
||||
lastReviewDate: body.lastReviewDate,
|
||||
nextReviewDate: body.nextReviewDate,
|
||||
status: body.status || 'ACTIVE',
|
||||
processingActivityIds: body.processingActivityIds || [],
|
||||
notes: body.notes,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
vendors.set(id, vendor)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: vendor,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error creating vendor:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create vendor' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/wiki?endpoint=...
|
||||
*
|
||||
* Routes to backend wiki endpoints:
|
||||
* endpoint=categories → GET /api/compliance/v1/wiki/categories
|
||||
* endpoint=articles → GET /api/compliance/v1/wiki/articles(?category_id=...)
|
||||
* endpoint=search → GET /api/compliance/v1/wiki/search?q=...
|
||||
* endpoint=article&id= → GET /api/compliance/v1/wiki/articles/{id}
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'categories'
|
||||
|
||||
let backendPath: string
|
||||
|
||||
switch (endpoint) {
|
||||
case 'categories':
|
||||
backendPath = '/api/compliance/v1/wiki/categories'
|
||||
break
|
||||
|
||||
case 'articles': {
|
||||
const categoryId = searchParams.get('category_id')
|
||||
backendPath = '/api/compliance/v1/wiki/articles'
|
||||
if (categoryId) {
|
||||
backendPath += `?category_id=${encodeURIComponent(categoryId)}`
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'article': {
|
||||
const articleId = searchParams.get('id')
|
||||
if (!articleId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing article id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
backendPath = `/api/compliance/v1/wiki/articles/${encodeURIComponent(articleId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'search': {
|
||||
const query = searchParams.get('q')
|
||||
if (!query) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing search query' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
backendPath = `/api/compliance/v1/wiki/search?q=${encodeURIComponent(query)}`
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown endpoint: ${endpoint}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(null, { status: 404 })
|
||||
}
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Wiki proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
119
admin-compliance/app/api/sdk/v1/workshops/[[...path]]/route.ts
Normal file
119
admin-compliance/app/api/sdk/v1/workshops/[[...path]]/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Workshop API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/workshops/* requests to ai-compliance-sdk backend
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/workshops`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Workshop API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const LOG_EXTRACT_URL = process.env.NEXT_PUBLIC_APP_URL
|
||||
: 'http://localhost:3002/api/infrastructure/log-extract/extract'
|
||||
|
||||
// Test service API URL for backlog insertion
|
||||
const TEST_SERVICE_URL = process.env.TEST_SERVICE_URL || 'http://localhost:8086'
|
||||
const TEST_SERVICE_URL = process.env.TEST_SERVICE_URL || 'http://localhost:8002'
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
333
admin-compliance/app/sdk/agents/[agentId]/page.tsx
Normal file
333
admin-compliance/app/sdk/agents/[agentId]/page.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface AgentDetail {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
icon: string
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
version: string
|
||||
soulContent: string
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
fileSize: number
|
||||
stats: {
|
||||
sessionsToday: number
|
||||
avgResponseTime: string
|
||||
successRate: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BackupEntry {
|
||||
filename: string
|
||||
timestamp: number
|
||||
size: number
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
inactive: 'bg-gray-100 text-gray-600',
|
||||
error: 'bg-red-100 text-red-700',
|
||||
}
|
||||
const labels = { active: 'Aktiv', inactive: 'Inaktiv', error: 'Fehler' }
|
||||
return (
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${colors[status as keyof typeof colors] || colors.inactive}`}>
|
||||
{labels[status as keyof typeof labels] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
const params = useParams()
|
||||
const agentId = params.agentId as string
|
||||
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'soul' | 'stats' | 'history'>('soul')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState('')
|
||||
const [backups, setBackups] = useState<BackupEntry[]>([])
|
||||
|
||||
const loadAgent = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/agents/${agentId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAgent(data)
|
||||
if (!editing) setEditContent(data.soulContent)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load agent:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [agentId, editing])
|
||||
|
||||
const loadBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/agents/${agentId}/soul?history=true`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setBackups(data.backups || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load backups:', err)
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
loadAgent()
|
||||
}, [loadAgent])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'history') loadBackups()
|
||||
}, [activeTab, loadBackups])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaveMessage('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/agents/${agentId}/soul`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: editContent }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setSaveMessage('SOUL-Datei gespeichert')
|
||||
setEditing(false)
|
||||
await loadAgent()
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setSaveMessage(`Fehler: ${err.error}`)
|
||||
}
|
||||
} catch {
|
||||
setSaveMessage('Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setTimeout(() => setSaveMessage(''), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (agent) {
|
||||
setEditContent(agent.soulContent)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-48" />
|
||||
<div className="h-64 bg-gray-200 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<p className="text-red-600">Agent nicht gefunden.</p>
|
||||
<Link href="/sdk/agents" className="text-purple-600 hover:underline mt-2 inline-block">
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
>
|
||||
{agent.icon === 'shield' ? (
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{agent.name}</h1>
|
||||
<StatusBadge status={agent.status} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{agent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.stats.sessionsToday}</div>
|
||||
<div className="text-xs text-gray-500">Sessions heute</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.stats.avgResponseTime}</div>
|
||||
<div className="text-xs text-gray-500">Avg. Antwortzeit</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.stats.successRate}</div>
|
||||
<div className="text-xs text-gray-500">Erfolgsrate</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-lg font-semibold text-gray-900">v{agent.version}</div>
|
||||
<div className="text-xs text-gray-500">Version</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.fileSize ? `${(agent.fileSize / 1024).toFixed(1)}k` : '—'}</div>
|
||||
<div className="text-xs text-gray-500">SOUL-Groesse</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 mb-6">
|
||||
{[
|
||||
{ id: 'soul' as const, label: 'SOUL-File' },
|
||||
{ id: 'stats' as const, label: 'Live-Statistiken' },
|
||||
{ id: 'history' as const, label: 'Aenderungshistorie' },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-700'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'soul' && (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
{agent.updatedAt && `Zuletzt geaendert: ${new Date(agent.updatedAt).toLocaleString('de-DE')}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saveMessage && (
|
||||
<span className={`text-sm ${saveMessage.startsWith('Fehler') ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{saveMessage}
|
||||
</span>
|
||||
)}
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="px-3 py-1.5 text-sm text-purple-600 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
readOnly={!editing}
|
||||
className={`w-full h-[600px] font-mono text-sm p-4 border rounded-xl resize-none focus:outline-none ${
|
||||
editing
|
||||
? 'border-purple-300 bg-white focus:ring-2 focus:ring-purple-200'
|
||||
: 'border-gray-200 bg-gray-50 cursor-default'
|
||||
}`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-8 text-center">
|
||||
<svg className="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
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>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Live-Statistiken</h3>
|
||||
<p className="text-gray-500">
|
||||
Detaillierte Echtzeit-Statistiken werden in einer zukuenftigen Version implementiert.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
{backups.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-8 text-center">
|
||||
<svg className="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Keine Backups vorhanden</h3>
|
||||
<p className="text-gray-500">
|
||||
Backups werden automatisch beim Speichern von SOUL-Dateien erstellt.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{backups.map((backup) => (
|
||||
<div key={backup.filename} className="bg-white border border-gray-200 rounded-xl px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm text-gray-900">
|
||||
{new Date(backup.timestamp).toLocaleString('de-DE')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{(backup.size / 1024).toFixed(1)} KB — {backup.filename}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-purple-200" />
|
||||
<span className="text-xs text-gray-400">Backup</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
admin-compliance/app/sdk/agents/architecture/page.tsx
Normal file
221
admin-compliance/app/sdk/agents/architecture/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AgentArchitecturePage() {
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Architektur</h1>
|
||||
<p className="text-gray-500 mt-1">2-Agenten-System mit RAG, SOUL-Files und LLM-Backend</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Architecture Overview */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">System-Uebersicht</h2>
|
||||
<div className="bg-gray-900 text-green-400 font-mono text-sm rounded-xl p-6 overflow-x-auto">
|
||||
<pre>{`
|
||||
+---------------------+ +---------------------+
|
||||
| Compliance Advisor | | Drafting Agent |
|
||||
| (RAG-Chat) | | (4-Modi) |
|
||||
| - DSGVO | | - Explain |
|
||||
| - AI Act | | - Ask |
|
||||
| - 6 Sammlungen | | - Draft |
|
||||
+--------+------------+ | - Validate |
|
||||
| +--------+------------+
|
||||
| |
|
||||
+----------+ +--------------+
|
||||
| |
|
||||
v v
|
||||
+--------+--+--------+
|
||||
| SOUL-File System |
|
||||
| agent-core/soul/ |
|
||||
| - .soul.md Dateien |
|
||||
| - .backups/ |
|
||||
| - 30s TTL Cache |
|
||||
+--------+-----------+
|
||||
|
|
||||
v
|
||||
+--------+-----------+
|
||||
| RAG-Service :8097 |
|
||||
| 6 Sammlungen: |
|
||||
| - gesetze |
|
||||
| - ce |
|
||||
| - datenschutz |
|
||||
| - dsfa_corpus |
|
||||
| - recht |
|
||||
| - legal_templates |
|
||||
+--------+-----------+
|
||||
|
|
||||
v
|
||||
+--------+-----------+
|
||||
| Ollama LLM |
|
||||
| qwen2.5vl:32b |
|
||||
| Temp: 0.2-0.3 |
|
||||
+--------------------+
|
||||
`.trim()}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Agent Cards */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Agenten im Detail</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Compliance Advisor */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-600 flex items-center justify-center text-white">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Compliance Advisor</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-600 mt-0.5">•</span>
|
||||
Multi-Collection RAG (6 Sammlungen parallel)
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-600 mt-0.5">•</span>
|
||||
Laender-Filter: DE, AT, CH, EU
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-600 mt-0.5">•</span>
|
||||
Streaming-Antworten (Ollama)
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-600 mt-0.5">•</span>
|
||||
Quellenschutz: Keine Collection-Namen preisgeben
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-600 mt-0.5">•</span>
|
||||
IFRS-Besonderheit: Nur EU-endorsed Standards
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||
<code className="text-xs text-gray-500">API: POST /api/sdk/compliance-advisor/chat</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drafting Agent */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Drafting Agent</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<strong>Explain</strong>: Fragen verstaendlich beantworten
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<strong>Ask</strong>: Luecken analysieren, gezielte Fragen
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<strong>Draft</strong>: Dokument-Sections entwerfen (JSON)
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<strong>Validate</strong>: Cross-Dokument-Konsistenz pruefen
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
SDK-State-Projection fuer token-effizienten Kontext
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||
<code className="text-xs text-gray-500">API: POST /api/sdk/drafting-engine/chat</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SOUL System */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">SOUL-File System</h2>
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Jeder Agent hat eine <code className="bg-gray-100 px-1.5 py-0.5 rounded text-purple-700">.soul.md</code> Datei,
|
||||
die seinen System-Prompt definiert. Diese Datei kann ueber die Agent-Detail-Seite live bearbeitet werden.
|
||||
Aenderungen werden nach 30 Sekunden (TTL-Cache) wirksam.
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-xl p-4 font-mono text-sm text-gray-700">
|
||||
<div>agent-core/</div>
|
||||
<div className="ml-4">soul/</div>
|
||||
<div className="ml-8 text-purple-700">compliance-advisor.soul.md</div>
|
||||
<div className="ml-8 text-blue-700">drafting-agent.soul.md</div>
|
||||
<div className="ml-8 text-gray-400">.backups/</div>
|
||||
<div className="ml-12 text-gray-400">compliance-advisor-1709567890123.soul.md</div>
|
||||
<div className="ml-12 text-gray-400">...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* RAG Collections */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">RAG-Sammlungen</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ name: 'bp_compliance_gesetze', desc: 'DSGVO, BDSG, AI Act, TTDSG, nationale Gesetze', icon: '⚖' },
|
||||
{ name: 'bp_compliance_ce', desc: 'EU Maschinenverordnung, Blue Guide, CE-Kennzeichnung', icon: '⚙' },
|
||||
{ name: 'bp_compliance_datenschutz', desc: 'DSK-Kurzpapiere, SDM, EDPB Guidelines', icon: '🔒' },
|
||||
{ name: 'bp_dsfa_corpus', desc: 'DSFA-Listen, Muss-Listen der Aufsichtsbehoerden', icon: '📋' },
|
||||
{ name: 'bp_compliance_recht', desc: 'WP248, EU-Verordnungen (DORA, MiCA, etc.)', icon: '⚖' },
|
||||
{ name: 'bp_legal_templates', desc: 'Vorlagen fuer Datenschutz-Dokumente', icon: '📄' },
|
||||
].map(col => (
|
||||
<div key={col.name} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span dangerouslySetInnerHTML={{ __html: col.icon }} />
|
||||
<code className="text-xs font-medium text-gray-700">{col.name}</code>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{col.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* LLM Config */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">LLM-Konfiguration</h2>
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Modell</div>
|
||||
<div className="font-medium text-gray-900">qwen2.5vl:32b</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Backend</div>
|
||||
<div className="font-medium text-gray-900">Ollama</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Temperatur</div>
|
||||
<div className="font-medium text-gray-900">0.2 - 0.3</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Max Tokens</div>
|
||||
<div className="font-medium text-gray-900">8.192 - 16.384</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
admin-compliance/app/sdk/agents/page.tsx
Normal file
224
admin-compliance/app/sdk/agents/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface AgentData {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
icon: string
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
version: string
|
||||
stats: {
|
||||
sessionsToday: number
|
||||
avgResponseTime: string
|
||||
successRate: string
|
||||
}
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
agents: AgentData[]
|
||||
stats: {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
error: number
|
||||
totalSessions: number
|
||||
avgResponseTime: string
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const ShieldIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PencilIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
function getAgentIcon(icon: string) {
|
||||
switch (icon) {
|
||||
case 'shield': return <ShieldIcon />
|
||||
case 'pencil': return <PencilIcon />
|
||||
default: return <ShieldIcon />
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
inactive: 'bg-gray-100 text-gray-600',
|
||||
error: 'bg-red-100 text-red-700',
|
||||
}
|
||||
const labels = { active: 'Aktiv', inactive: 'Inaktiv', error: 'Fehler' }
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colors[status as keyof typeof colors] || colors.inactive}`}>
|
||||
{labels[status as keyof typeof labels] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AgentsDashboardPage() {
|
||||
const [data, setData] = useState<AgentsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/agents')
|
||||
if (res.ok) {
|
||||
setData(await res.json())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load agents:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents()
|
||||
const interval = setInterval(loadAgents, 30_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadAgents])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-64" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => <div key={i} className="h-24 bg-gray-200 rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stats = data?.stats
|
||||
const agents = data?.agents || []
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Compliance-Agenten</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Verwaltung und Konfiguration der KI-Agenten im Compliance SDK
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||
<Link href="/sdk/agents/architecture" className="flex items-center gap-2 px-4 py-3 bg-white border border-gray-200 rounded-xl hover:border-purple-300 hover:bg-purple-50 transition-colors text-sm">
|
||||
<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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
Architektur
|
||||
</Link>
|
||||
<Link href="/sdk/agents/sessions" className="flex items-center gap-2 px-4 py-3 bg-white border border-gray-200 rounded-xl hover:border-purple-300 hover:bg-purple-50 transition-colors text-sm">
|
||||
<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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
Sessions
|
||||
</Link>
|
||||
<Link href="/sdk/agents/statistics" className="flex items-center gap-2 px-4 py-3 bg-white border border-gray-200 rounded-xl hover:border-purple-300 hover:bg-purple-50 transition-colors text-sm">
|
||||
<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="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>
|
||||
Statistiken
|
||||
</Link>
|
||||
<Link href="/sdk/document-generator" className="flex items-center gap-2 px-4 py-3 bg-white border border-gray-200 rounded-xl hover:border-purple-300 hover:bg-purple-50 transition-colors text-sm">
|
||||
<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="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>
|
||||
Dokument-Generator
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats?.total || 0}</div>
|
||||
<div className="text-xs text-gray-500">Agenten gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-2xl font-bold text-green-600">{stats?.active || 0}</div>
|
||||
<div className="text-xs text-gray-500">Aktiv</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-2xl font-bold text-red-600">{stats?.error || 0}</div>
|
||||
<div className="text-xs text-gray-500">Fehler</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats?.totalSessions || 0}</div>
|
||||
<div className="text-xs text-gray-500">Sessions heute</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats?.avgResponseTime || '—'}</div>
|
||||
<div className="text-xs text-gray-500">Avg. Antwortzeit</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3">
|
||||
<div className="text-2xl font-bold text-gray-900">6</div>
|
||||
<div className="text-xs text-gray-500">RAG-Sammlungen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{agents.map((agent) => (
|
||||
<Link
|
||||
key={agent.id}
|
||||
href={`/sdk/agents/${agent.id}`}
|
||||
className="bg-white border border-gray-200 rounded-2xl p-6 hover:border-purple-300 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
>
|
||||
{getAgentIcon(agent.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 group-hover:text-purple-700 transition-colors">
|
||||
{agent.name}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-400">v{agent.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={agent.status} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
{agent.description}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 pt-3 border-t border-gray-100">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.stats.sessionsToday}</div>
|
||||
<div className="text-xs text-gray-500">Sessions</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.stats.avgResponseTime}</div>
|
||||
<div className="text-xs text-gray-500">Avg. Zeit</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.stats.successRate}</div>
|
||||
<div className="text-xs text-gray-500">Erfolgsrate</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
admin-compliance/app/sdk/agents/sessions/page.tsx
Normal file
34
admin-compliance/app/sdk/agents/sessions/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AgentSessionsPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Sessions</h1>
|
||||
<p className="text-gray-500 mt-1">Chat-Verlaeufe und Session-Management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||
<svg className="w-20 h-20 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">Sessions-Tracking</h2>
|
||||
<p className="text-gray-500 max-w-md mx-auto">
|
||||
Das Session-Tracking fuer Compliance-Agenten wird in einer zukuenftigen Version implementiert.
|
||||
Hier werden Chat-Verlaeufe, Antwortqualitaet und Nutzer-Feedback angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
admin-compliance/app/sdk/agents/statistics/page.tsx
Normal file
34
admin-compliance/app/sdk/agents/statistics/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AgentStatisticsPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Statistiken</h1>
|
||||
<p className="text-gray-500 mt-1">Performance-Metriken und Nutzungsanalysen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||
<svg className="w-20 h-20 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
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>
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">Agent-Statistiken</h2>
|
||||
<p className="text-gray-500 max-w-md mx-auto">
|
||||
Detaillierte Statistiken wie Antwortzeiten, Erfolgsraten, haeufigste Themen und
|
||||
RAG-Trefferquoten werden in einer zukuenftigen Version implementiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -21,6 +22,8 @@ interface AISystem {
|
||||
assessmentResult: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SKELETON
|
||||
// =============================================================================
|
||||
@@ -306,12 +309,178 @@ function AISystemCard({
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SAVED RESULTS TAB
|
||||
// =============================================================================
|
||||
|
||||
interface SavedResult {
|
||||
id: string
|
||||
system_name: string
|
||||
system_description?: string
|
||||
high_risk_result: string
|
||||
gpai_result: { gpai_category: string; is_systemic_risk: boolean }
|
||||
combined_obligations: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function SavedResultsTab() {
|
||||
const [results, setResults] = useState<SavedResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/results')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResults(data.results || [])
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Ergebnis wirklich löschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/ucca/decision-tree/results/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setResults(prev => prev.filter(r => r.id !== id))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
unacceptable: 'Unzulässig',
|
||||
high_risk: 'Hochrisiko',
|
||||
limited_risk: 'Begrenztes Risiko',
|
||||
minimal_risk: 'Minimales Risiko',
|
||||
not_applicable: 'Nicht anwendbar',
|
||||
}
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
unacceptable: 'bg-red-100 text-red-700',
|
||||
high_risk: 'bg-orange-100 text-orange-700',
|
||||
limited_risk: 'bg-yellow-100 text-yellow-700',
|
||||
minimal_risk: 'bg-green-100 text-green-700',
|
||||
not_applicable: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const gpaiLabels: Record<string, string> = {
|
||||
none: 'Kein GPAI',
|
||||
standard: 'GPAI Standard',
|
||||
systemic: 'GPAI Systemisch',
|
||||
}
|
||||
|
||||
const gpaiColors: Record<string, string> = {
|
||||
none: 'bg-gray-100 text-gray-500',
|
||||
standard: 'bg-blue-100 text-blue-700',
|
||||
systemic: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Ergebnisse vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500">Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{results.map(r => (
|
||||
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{r.system_name}</h4>
|
||||
{r.system_description && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">{r.system_description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[r.high_risk_result] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{riskLabels[r.high_risk_result] || r.high_risk_result}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${gpaiColors[r.gpai_result?.gpai_category] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'}
|
||||
</span>
|
||||
{r.gpai_result?.is_systemic_risk && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Systemisch</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{r.combined_obligations?.length || 0} Pflichten · {new Date(r.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TABS
|
||||
// =============================================================================
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Übersicht',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decision-tree',
|
||||
label: 'Entscheidungsbaum',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
label: 'Ergebnisse',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AIActPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [systems, setSystems] = useState<AISystem[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -354,7 +523,6 @@ export default function AIActPage() {
|
||||
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||
setError(null)
|
||||
if (editingSystem) {
|
||||
// Edit existing system via PUT
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
|
||||
method: 'PUT',
|
||||
@@ -380,14 +548,12 @@ export default function AIActPage() {
|
||||
setError('Speichern fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: update locally
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id ? { ...s, ...data } : s
|
||||
))
|
||||
}
|
||||
setEditingSystem(null)
|
||||
} else {
|
||||
// Create new system via POST
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
|
||||
method: 'POST',
|
||||
@@ -415,7 +581,6 @@ export default function AIActPage() {
|
||||
setError('Registrierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: add locally
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: `ai-${Date.now()}`,
|
||||
@@ -503,17 +668,37 @@ export default function AIActPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowAddForm(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>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
{activeTab === 'overview' && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(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>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
@@ -522,90 +707,105 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<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">
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<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">
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
{/* Tab: Decision Tree */}
|
||||
{activeTab === 'decision-tree' && (
|
||||
<DecisionTreeWizard />
|
||||
)}
|
||||
|
||||
{/* Tab: Results */}
|
||||
{activeTab === 'results' && (
|
||||
<SavedResultsTab />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface Registration {
|
||||
id: string
|
||||
system_name: string
|
||||
system_version: string
|
||||
risk_classification: string
|
||||
gpai_classification: string
|
||||
registration_status: string
|
||||
eu_database_id: string
|
||||
provider_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||
}
|
||||
|
||||
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
system_name: '',
|
||||
system_version: '1.0',
|
||||
system_description: '',
|
||||
intended_purpose: '',
|
||||
provider_name: '',
|
||||
provider_legal_form: '',
|
||||
provider_address: '',
|
||||
provider_country: 'DE',
|
||||
eu_representative_name: '',
|
||||
eu_representative_contact: '',
|
||||
risk_classification: 'not_classified',
|
||||
annex_iii_category: '',
|
||||
gpai_classification: 'none',
|
||||
conformity_assessment_type: 'internal',
|
||||
notified_body_name: '',
|
||||
notified_body_id: '',
|
||||
ce_marking: false,
|
||||
training_data_summary: '',
|
||||
}
|
||||
|
||||
export default function AIRegistrationPage() {
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
const [wizardStep, setWizardStep] = useState(1)
|
||||
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { loadRegistrations() }, [])
|
||||
|
||||
async function loadRegistrations() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setRegistrations(data.registrations || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowWizard(false)
|
||||
setForm({ ...INITIAL_FORM })
|
||||
setWizardStep(1)
|
||||
loadRegistrations()
|
||||
} else {
|
||||
const data = await resp.json()
|
||||
setError(data.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||
if (resp.ok) {
|
||||
const reg = await resp.json()
|
||||
// Build export JSON client-side
|
||||
const exportData = {
|
||||
schema_version: '1.0',
|
||||
submission_type: 'ai_system_registration',
|
||||
regulation: 'EU AI Act (EU) 2024/1689',
|
||||
article: 'Art. 49',
|
||||
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
loadRegistrations()
|
||||
} catch {
|
||||
setError('Status-Aenderung fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
+ Neue Registrierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||
const count = registrations.filter(r => r.registration_status === status).length
|
||||
const style = STATUS_STYLES[status]
|
||||
return (
|
||||
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||
<div className="text-sm text-gray-600">{style.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Registrations List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{registrations.map(reg => {
|
||||
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||
return (
|
||||
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||
{reg.gpai_classification !== 'none' && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
JSON Export
|
||||
</button>
|
||||
{reg.registration_status === 'draft' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Bereit markieren
|
||||
</button>
|
||||
)}
|
||||
{reg.registration_status === 'ready' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
Als eingereicht markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wizard Modal */}
|
||||
{showWizard && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||
</div>
|
||||
{/* Step Indicator */}
|
||||
<div className="flex gap-1">
|
||||
{STEPS.map(step => (
|
||||
<button key={step.id} onClick={() => setWizardStep(step.id)}
|
||||
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
|
||||
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
|
||||
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
|
||||
}`}>
|
||||
{wizardStep > step.id ? '✓ ' : ''}{step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Provider */}
|
||||
{wizardStep === 1 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
|
||||
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
|
||||
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Oesterreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
<option value="OTHER">Anderes Land</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
|
||||
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: System */}
|
||||
{wizardStep === 2 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
|
||||
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
|
||||
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
|
||||
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 3: Classification */}
|
||||
{wizardStep === 3 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
|
||||
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
|
||||
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_classified">Noch nicht klassifiziert</option>
|
||||
<option value="minimal_risk">Minimal Risk</option>
|
||||
<option value="limited_risk">Limited Risk</option>
|
||||
<option value="high_risk">High Risk</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.risk_classification === 'high_risk' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
|
||||
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Bitte waehlen...</option>
|
||||
<option value="biometric">1. Biometrische Identifizierung</option>
|
||||
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
|
||||
<option value="education">3. Bildung und Berufsausbildung</option>
|
||||
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
|
||||
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
|
||||
<option value="law_enforcement">6. Strafverfolgung</option>
|
||||
<option value="migration">7. Migration und Grenzkontrolle</option>
|
||||
<option value="justice">8. Rechtspflege und Demokratie</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
|
||||
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="none">Kein GPAI</option>
|
||||
<option value="standard">GPAI (Standard)</option>
|
||||
<option value="systemic">GPAI mit systemischem Risiko</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 4: Conformity */}
|
||||
{wizardStep === 4 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
|
||||
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_required">Nicht erforderlich</option>
|
||||
<option value="internal">Interne Konformitaetsbewertung</option>
|
||||
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.conformity_assessment_type === 'third_party' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
|
||||
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body ID</label>
|
||||
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 5: Training Data */}
|
||||
{wizardStep === 5 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
|
||||
<p className="text-sm text-gray-500">Art. 10 KI-VO — Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
|
||||
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Beschreibe die verwendeten Datenquellen: - Oeffentliche Daten (z.B. Wikipedia, Common Crawl) - Lizenzierte Daten (z.B. Fachpublikationen) - Synthetische Daten - Unternehmensinterne Daten" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 6: Review */}
|
||||
{wizardStep === 6 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
|
||||
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
|
||||
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
|
||||
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
|
||||
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
|
||||
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
|
||||
</div>
|
||||
{form.intended_purpose && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="p-6 border-t flex justify-between">
|
||||
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
|
||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
|
||||
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
{wizardStep < 6 ? (
|
||||
<button onClick={() => setWizardStep(wizardStep + 1)}
|
||||
disabled={wizardStep === 2 && !form.system_name}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
404
admin-compliance/app/sdk/api-docs/page.tsx
Normal file
404
admin-compliance/app/sdk/api-docs/page.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { apiModules } from '@/lib/sdk/api-docs/endpoints'
|
||||
import type { HttpMethod, BackendService, ApiExposure } from '@/lib/sdk/api-docs/types'
|
||||
|
||||
const METHOD_COLORS: Record<HttpMethod, string> = {
|
||||
GET: 'bg-green-100 text-green-800',
|
||||
POST: 'bg-blue-100 text-blue-800',
|
||||
PUT: 'bg-yellow-100 text-yellow-800',
|
||||
DELETE: 'bg-red-100 text-red-800',
|
||||
PATCH: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
const EXPOSURE_CONFIG: Record<ApiExposure, { label: string; color: string; description: string }> = {
|
||||
public: { label: 'Oeffentlich', color: 'bg-green-100 text-green-800 border-green-200', description: 'Internet-exponiert' },
|
||||
partner: { label: 'Integration', color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'API-Key/OAuth' },
|
||||
internal: { label: 'Intern', color: 'bg-gray-100 text-gray-700 border-gray-200', description: 'Nur Admin' },
|
||||
admin: { label: 'Wartung', color: 'bg-orange-100 text-orange-800 border-orange-200', description: 'Nur Setup' },
|
||||
}
|
||||
|
||||
type ServiceFilter = 'all' | BackendService
|
||||
type ExposureFilter = 'all' | ApiExposure
|
||||
|
||||
function ExposureBadge({ exposure }: { exposure: ApiExposure }) {
|
||||
const config = EXPOSURE_CONFIG[exposure]
|
||||
return (
|
||||
<span className={`inline-block text-[10px] font-medium px-1.5 py-0.5 rounded border ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [serviceFilter, setServiceFilter] = useState<ServiceFilter>('all')
|
||||
const [methodFilter, setMethodFilter] = useState<HttpMethod | 'all'>('all')
|
||||
const [exposureFilter, setExposureFilter] = useState<ExposureFilter>('all')
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set())
|
||||
const moduleRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
return apiModules
|
||||
.filter((m) => serviceFilter === 'all' || m.service === serviceFilter)
|
||||
.filter((m) => {
|
||||
if (exposureFilter === 'all') return true
|
||||
if (m.exposure === exposureFilter) return true
|
||||
return m.endpoints.some((e) => (e.exposure || m.exposure) === exposureFilter)
|
||||
})
|
||||
.map((m) => {
|
||||
const eps = m.endpoints.filter((e) => {
|
||||
if (methodFilter !== 'all' && e.method !== methodFilter) return false
|
||||
if (exposureFilter !== 'all' && (e.exposure || m.exposure) !== exposureFilter) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
e.path.toLowerCase().includes(q) ||
|
||||
e.description.toLowerCase().includes(q) ||
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.id.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
return { ...m, endpoints: eps }
|
||||
})
|
||||
.filter((m) => m.endpoints.length > 0)
|
||||
}, [search, serviceFilter, methodFilter, exposureFilter])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = apiModules.reduce((s, m) => s + m.endpoints.length, 0)
|
||||
const python = apiModules.filter((m) => m.service === 'python').reduce((s, m) => s + m.endpoints.length, 0)
|
||||
const go = apiModules.filter((m) => m.service === 'go').reduce((s, m) => s + m.endpoints.length, 0)
|
||||
|
||||
const exposureCounts = { public: 0, partner: 0, internal: 0, admin: 0 }
|
||||
apiModules.forEach((m) => {
|
||||
m.endpoints.forEach((e) => {
|
||||
const exp = e.exposure || m.exposure
|
||||
exposureCounts[exp]++
|
||||
})
|
||||
})
|
||||
|
||||
return { total, python, go, modules: apiModules.length, exposure: exposureCounts }
|
||||
}, [])
|
||||
|
||||
const filteredTotal = filteredModules.reduce((s, m) => s + m.endpoints.length, 0)
|
||||
|
||||
const toggleModule = (id: string) => {
|
||||
setExpandedModules((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const expandAll = () => setExpandedModules(new Set(filteredModules.map((m) => m.id)))
|
||||
const collapseAll = () => setExpandedModules(new Set())
|
||||
|
||||
const scrollToModule = (id: string) => {
|
||||
setExpandedModules((prev) => new Set([...prev, id]))
|
||||
setTimeout(() => {
|
||||
moduleRefs.current[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-20">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">API-Referenz</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{stats.total} Endpoints in {stats.modules} Modulen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Alle aufklappen
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Alle zuklappen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exposure Stats */}
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-xs">
|
||||
<span className="text-gray-500">Exposure:</span>
|
||||
<span className="px-2 py-0.5 rounded bg-green-50 text-green-700 font-medium">
|
||||
{stats.exposure.public} oeffentlich
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-700 font-medium">
|
||||
{stats.exposure.partner} Integration
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">
|
||||
{stats.exposure.internal} intern
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-orange-50 text-orange-700 font-medium">
|
||||
{stats.exposure.admin} Wartung
|
||||
</span>
|
||||
<span className="text-gray-400 ml-1">
|
||||
({Math.round((stats.exposure.public + stats.exposure.partner) / stats.total * 100)}% exponiert)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search + Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Endpoint, Beschreibung oder Modul suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Service Filter */}
|
||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden">
|
||||
{([['all', 'Alle'], ['python', 'Python/FastAPI'], ['go', 'Go/Gin']] as const).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setServiceFilter(val)}
|
||||
className={`px-3 py-2 text-xs font-medium transition-colors ${
|
||||
serviceFilter === val
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Exposure Filter */}
|
||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden">
|
||||
{([
|
||||
['all', 'Alle'],
|
||||
['public', 'Oeffentlich'],
|
||||
['partner', 'Integration'],
|
||||
['internal', 'Intern'],
|
||||
['admin', 'Wartung'],
|
||||
] as const).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setExposureFilter(val)}
|
||||
className={`px-3 py-2 text-xs font-medium transition-colors ${
|
||||
exposureFilter === val
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Method Filter */}
|
||||
<div className="flex gap-1.5">
|
||||
{(['all', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMethodFilter(m)}
|
||||
className={`px-2.5 py-1.5 text-xs font-mono font-bold rounded-md transition-colors ${
|
||||
methodFilter === m
|
||||
? m === 'all'
|
||||
? 'bg-gray-900 text-white'
|
||||
: METHOD_COLORS[m] + ' ring-2 ring-offset-1 ring-gray-400'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{m === 'all' ? 'ALLE' : m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'Endpoints gesamt', value: stats.total, color: 'text-gray-900' },
|
||||
{ label: 'Python / FastAPI', value: stats.python, color: 'text-blue-700' },
|
||||
{ label: 'Go / Gin', value: stats.go, color: 'text-emerald-700' },
|
||||
{ label: 'Module', value: stats.modules, color: 'text-purple-700' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Module Index (Sidebar) */}
|
||||
<div className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 sticky top-[170px] max-h-[calc(100vh-210px)] overflow-y-auto">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
Modul-Index ({filteredModules.length})
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{filteredModules.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => scrollToModule(m.id)}
|
||||
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-gray-100 transition-colors group flex items-center justify-between gap-1"
|
||||
>
|
||||
<span className="truncate text-gray-700 group-hover:text-gray-900">
|
||||
{m.id}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<ExposureBadge exposure={m.exposure} />
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||
m.service === 'python' ? 'bg-blue-50 text-blue-600' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
{m.endpoints.length}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{search && (
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{filteredTotal} Treffer in {filteredModules.length} Modulen
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredModules.map((m) => {
|
||||
const isExpanded = expandedModules.has(m.id)
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(el) => { moduleRefs.current[m.id] = el }}
|
||||
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{/* Module Header */}
|
||||
<button
|
||||
onClick={() => toggleModule(m.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded ${
|
||||
m.service === 'python' ? 'bg-blue-50 text-blue-700' : 'bg-emerald-50 text-emerald-700'
|
||||
}`}>
|
||||
{m.service === 'python' ? 'PY' : 'GO'}
|
||||
</span>
|
||||
<ExposureBadge exposure={m.exposure} />
|
||||
<span className="text-sm font-medium text-gray-900 truncate">{m.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
<span className="text-xs text-gray-400 font-mono">{m.basePath}</span>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{m.endpoints.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Endpoints Table */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-xs text-gray-500">
|
||||
<th className="text-left px-4 py-2 w-20">Methode</th>
|
||||
<th className="text-left px-4 py-2">Pfad</th>
|
||||
<th className="text-left px-4 py-2">Beschreibung</th>
|
||||
<th className="text-left px-4 py-2 w-24">Exposure</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.endpoints.map((e, i) => {
|
||||
const endpointExposure = e.exposure || m.exposure
|
||||
const hasOverride = e.exposure && e.exposure !== m.exposure
|
||||
return (
|
||||
<tr
|
||||
key={`${e.method}-${e.path}-${i}`}
|
||||
className="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-block text-[11px] font-mono font-bold px-2 py-0.5 rounded ${METHOD_COLORS[e.method]}`}>
|
||||
{e.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-800">
|
||||
{e.path}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-gray-600">
|
||||
{e.description}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{hasOverride ? (
|
||||
<ExposureBadge exposure={endpointExposure} />
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredModules.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p className="text-sm">Keine Endpoints gefunden</p>
|
||||
<p className="text-xs mt-1">Suchbegriff oder Filter anpassen</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -160,6 +160,13 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
'security_backlog', 'quality_entries',
|
||||
'notfallplan_incidents', 'notfallplan_templates',
|
||||
'data_processing_agreement',
|
||||
'vendor_vendors', 'vendor_contracts', 'vendor_findings',
|
||||
'vendor_control_instances', 'compliance_templates',
|
||||
'compliance_isms_scope', 'compliance_isms_context', 'compliance_isms_policy',
|
||||
'compliance_security_objectives', 'compliance_soa',
|
||||
'compliance_audit_findings', 'compliance_corrective_actions',
|
||||
'compliance_management_reviews', 'compliance_internal_audits',
|
||||
'compliance_audit_trail', 'compliance_isms_readiness_checks',
|
||||
],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [
|
||||
@@ -173,6 +180,20 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
'CRUD /api/compliance/vvt',
|
||||
'CRUD /api/compliance/loeschfristen',
|
||||
'CRUD /api/compliance/obligations',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/vendors',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/contracts',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/findings',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/control-instances',
|
||||
'CRUD /api/isms/scope',
|
||||
'CRUD /api/isms/policies',
|
||||
'CRUD /api/isms/objectives',
|
||||
'CRUD /api/isms/soa',
|
||||
'CRUD /api/isms/findings',
|
||||
'CRUD /api/isms/capa',
|
||||
'CRUD /api/isms/management-reviews',
|
||||
'CRUD /api/isms/internal-audits',
|
||||
'GET /api/isms/overview',
|
||||
'POST /api/isms/readiness-check',
|
||||
'CRUD /api/compliance/legal-documents',
|
||||
'CRUD /api/compliance/legal-templates',
|
||||
],
|
||||
@@ -188,7 +209,7 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
url: 'https://macmini:8093',
|
||||
container: 'bp-compliance-ai-sdk',
|
||||
description: 'KI-konforme Compliance-Analyse: UCCA, Training, RAG-Suche, IACE, Portfolio, Roadmap, Workshop.',
|
||||
descriptionLong: 'Der AI Compliance SDK Service ist in Go geschrieben und bietet KI-gestuetzte Compliance-Analysen. Er fuehrt UCCA-Bewertungen (Use Case Compliance Assessments) durch, verwaltet Schulungsmodule mit Fortschrittstracking und durchsucht Rechtstexte per RAG (Retrieval Augmented Generation) ueber Qdrant. Als LLM wird primaer Ollama (qwen3:30b-a3b) lokal genutzt, mit Fallback auf Claude Sonnet ueber die Anthropic API.',
|
||||
descriptionLong: 'Der AI Compliance SDK Service ist in Go geschrieben und bietet KI-gestuetzte Compliance-Analysen. Er fuehrt UCCA-Bewertungen (Use Case Compliance Assessments) durch, verwaltet Schulungsmodule mit Fortschrittstracking und durchsucht Rechtstexte per RAG (Retrieval Augmented Generation) ueber Qdrant. Als LLM wird primaer Ollama (qwen3.5:35b-a3b) lokal genutzt, mit Fallback auf Claude Sonnet ueber die Anthropic API.',
|
||||
dbTables: [
|
||||
'ai_assessments', 'ai_training_modules', 'ai_training_progress',
|
||||
],
|
||||
@@ -252,12 +273,12 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
name: 'PostgreSQL',
|
||||
nameShort: 'PostgreSQL',
|
||||
layer: 'infrastructure',
|
||||
tech: 'PostgreSQL 16',
|
||||
port: 5432,
|
||||
tech: 'PostgreSQL 17',
|
||||
port: 54321,
|
||||
url: null,
|
||||
container: 'bp-core-postgres',
|
||||
description: 'Zentrale Datenbank. Schemas: compliance, core, public. Shared mit breakpilot-core.',
|
||||
descriptionLong: 'PostgreSQL ist die gemeinsame Datenbank fuer das gesamte BreakPilot-Oekosystem. Das compliance-Schema enthaelt alle Compliance-spezifischen Tabellen, waehrend core und public von breakpilot-core bereitgestellt werden. Der search_path ist auf compliance,core,public konfiguriert, sodass Services transparent auf Schema-uebergreifende Daten zugreifen koennen.',
|
||||
container: '46.225.100.82:54321 (extern)',
|
||||
description: 'Externe PostgreSQL-Instanz (Hetzner/meghshakka). Schemas: compliance (51 Tabellen) + public (33 compliance_*-Tabellen). Migriert 2026-03-06.',
|
||||
descriptionLong: 'Die Compliance-Daten liegen seit 2026-03-06 auf einer dedizierten externen PostgreSQL 17-Instanz bei Hetzner (meghshakka, 46.225.100.82:54321). Das compliance-Schema umfasst 51 Tabellen fuer Risiken, Kontrollen, VVT, DSFA, Einwilligungen, Loeschfristen u.v.m. Weitere 33 compliance_*-Tabellen befinden sich im public-Schema (Tenants, Namespaces, LLM-Policies, Legal Documents etc.). Die Verbindung ist TLS-verschluesselt (sslmode=require). Die lokale bp-core-postgres auf dem Mac Mini wird exklusiv von breakpilot-lehrer (NIBIS-Daten) genutzt.',
|
||||
dbTables: [],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [],
|
||||
@@ -284,12 +305,12 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
name: 'Qdrant',
|
||||
nameShort: 'Qdrant',
|
||||
layer: 'infrastructure',
|
||||
tech: 'Qdrant',
|
||||
port: 6333,
|
||||
url: null,
|
||||
container: 'bp-core-qdrant',
|
||||
description: 'Vektor-Datenbank fuer RAG-Compliance-Suche. Collections: DSGVO, AI Act, BDSG, TTDSG.',
|
||||
descriptionLong: 'Qdrant speichert Vektorembeddings von Rechtstexten und Compliance-Dokumenten in thematischen Collections (DSGVO, AI Act, BDSG, TTDSG, Templates). Der AI Compliance SDK nutzt diese fuer semantische Suchen — Nutzer koennen so in natuerlicher Sprache nach relevanten Gesetzespassagen und Compliance-Anforderungen suchen, ohne exakte Suchbegriffe kennen zu muessen.',
|
||||
tech: 'Qdrant Cloud',
|
||||
port: 443,
|
||||
url: 'https://qdrant-dev.breakpilot.ai',
|
||||
container: 'qdrant-dev.breakpilot.ai (extern)',
|
||||
description: 'Externe Vektor-Datenbank (Hetzner/meghshakka). RAG-Compliance-Suche. Collections: DSGVO, AI Act, BDSG, TTDSG. Migriert 2026-03-06.',
|
||||
descriptionLong: 'Qdrant laeuft seit 2026-03-06 als externe Instanz auf qdrant-dev.breakpilot.ai (Hetzner/meghshakka). Es speichert Vektorembeddings von Rechtstexten und Compliance-Dokumenten in thematischen Collections. Der AI Compliance SDK verbindet sich per HTTPS mit API-Key-Authentifizierung. Durch die externe Instanz skaliert der Vektorspeicher unabhaengig vom Mac Mini.',
|
||||
dbTables: [],
|
||||
ragCollections: [
|
||||
'bp_dsgvo', 'bp_ai_act', 'bp_bdsg', 'bp_ttdsg',
|
||||
@@ -300,15 +321,15 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
},
|
||||
{
|
||||
id: 'minio',
|
||||
name: 'MinIO',
|
||||
nameShort: 'MinIO',
|
||||
name: 'Hetzner Object Storage',
|
||||
nameShort: 'Object Storage',
|
||||
layer: 'infrastructure',
|
||||
tech: 'MinIO',
|
||||
port: 9000,
|
||||
url: null,
|
||||
container: 'bp-core-minio',
|
||||
description: 'Object Storage fuer TTS-Audio, generierte Videos und Dokument-Uploads.',
|
||||
descriptionLong: 'MinIO stellt S3-kompatiblen Object Storage bereit und wird von breakpilot-core verwaltet. Der Compliance-Stack nutzt es primaer fuer generierte Schulungsvideos und Audio-Dateien aus dem TTS-Service. Durch die S3-API-Kompatibilitaet koennen Standard-AWS-SDKs fuer den Zugriff verwendet werden.',
|
||||
tech: 'S3-kompatibel',
|
||||
port: 443,
|
||||
url: 'https://nbg1.your-objectstorage.com',
|
||||
container: 'nbg1.your-objectstorage.com (extern)',
|
||||
description: 'Externer S3-kompatibler Object Storage (Hetzner Nuernberg). TTS-Audio, generierte Videos. Migriert 2026-03-06.',
|
||||
descriptionLong: 'Der Object Storage laeuft seit 2026-03-06 auf dem Hetzner-Standort Nuernberg (nbg1.your-objectstorage.com) und ist vollstaendig S3-kompatibel. Der Compliance TTS-Service speichert dort generierte Schulungsvideos und Audio-Dateien. Die Verbindung ist TLS-verschluesselt (MINIO_SECURE=true). Die lokale bp-core-minio-Instanz auf dem Mac Mini wird nicht mehr vom Compliance-Stack genutzt.',
|
||||
dbTables: [],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [],
|
||||
@@ -323,8 +344,8 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
port: 11434,
|
||||
url: null,
|
||||
container: 'bp-core-ollama',
|
||||
description: 'Lokaler LLM-Server. Modell: qwen3:30b-a3b. Fallback: Claude Sonnet via Anthropic API.',
|
||||
descriptionLong: 'Ollama hostet ein lokales Large Language Model (qwen3:30b-a3b) fuer Compliance-Analysen, Textgenerierung und UCCA-Bewertungen. Durch die lokale Ausfuehrung bleiben alle Daten im eigenen Netzwerk — ein zentraler Vorteil fuer DSGVO-Konformitaet. Ist das lokale Modell nicht verfuegbar oder die Aufgabe zu komplex, wird automatisch auf Claude Sonnet ueber die Anthropic API zurueckgegriffen.',
|
||||
description: 'Lokaler LLM-Server. Modell: qwen3.5:35b-a3b. Fallback: Claude Sonnet via Anthropic API.',
|
||||
descriptionLong: 'Ollama hostet ein lokales Large Language Model (qwen3.5:35b-a3b) fuer Compliance-Analysen, Textgenerierung und UCCA-Bewertungen. Durch die lokale Ausfuehrung bleiben alle Daten im eigenen Netzwerk — ein zentraler Vorteil fuer DSGVO-Konformitaet. Ist das lokale Modell nicht verfuegbar oder die Aufgabe zu komplex, wird automatisch auf Claude Sonnet ueber die Anthropic API zurueckgegriffen.',
|
||||
dbTables: [],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [],
|
||||
|
||||
468
admin-compliance/app/sdk/assertions/page.tsx
Normal file
468
admin-compliance/app/sdk/assertions/page.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Assertion {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
sentence_text: string
|
||||
sentence_index: number
|
||||
assertion_type: string // 'assertion' | 'fact' | 'rationale'
|
||||
evidence_ids: string[]
|
||||
confidence: number
|
||||
normative_tier: string | null // 'pflicht' | 'empfehlung' | 'kann'
|
||||
verified_by: string | null
|
||||
verified_at: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface AssertionSummary {
|
||||
total_assertions: number
|
||||
total_facts: number
|
||||
total_rationale: number
|
||||
unverified_count: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const TIER_COLORS: Record<string, string> = {
|
||||
pflicht: 'bg-red-100 text-red-700',
|
||||
empfehlung: 'bg-yellow-100 text-yellow-700',
|
||||
kann: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
pflicht: 'Pflicht',
|
||||
empfehlung: 'Empfehlung',
|
||||
kann: 'Kann',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
assertion: 'bg-orange-100 text-orange-700',
|
||||
fact: 'bg-green-100 text-green-700',
|
||||
rationale: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
assertion: 'Behauptung',
|
||||
fact: 'Fakt',
|
||||
rationale: 'Begruendung',
|
||||
}
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance'
|
||||
|
||||
type TabKey = 'overview' | 'list' | 'extract'
|
||||
|
||||
// =============================================================================
|
||||
// ASSERTION CARD
|
||||
// =============================================================================
|
||||
|
||||
function AssertionCard({
|
||||
assertion,
|
||||
onVerify,
|
||||
}: {
|
||||
assertion: Assertion
|
||||
onVerify: (id: string) => void
|
||||
}) {
|
||||
const tierColor = assertion.normative_tier ? TIER_COLORS[assertion.normative_tier] || 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
|
||||
const tierLabel = assertion.normative_tier ? TIER_LABELS[assertion.normative_tier] || assertion.normative_tier : '—'
|
||||
const typeColor = TYPE_COLORS[assertion.assertion_type] || 'bg-gray-100 text-gray-600'
|
||||
const typeLabel = TYPE_LABELS[assertion.assertion_type] || assertion.assertion_type
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded font-medium ${tierColor}`}>
|
||||
{tierLabel}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${typeColor}`}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
{assertion.entity_type && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">
|
||||
{assertion.entity_type}: {assertion.entity_id?.slice(0, 8) || '—'}
|
||||
</span>
|
||||
)}
|
||||
{assertion.confidence > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Konfidenz: {(assertion.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-900 leading-relaxed">
|
||||
“{assertion.sentence_text}”
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400">
|
||||
{assertion.verified_by && (
|
||||
<span className="text-green-600">
|
||||
Verifiziert von {assertion.verified_by} am {assertion.verified_at ? new Date(assertion.verified_at).toLocaleDateString('de-DE') : '—'}
|
||||
</span>
|
||||
)}
|
||||
{assertion.evidence_ids.length > 0 && (
|
||||
<span>
|
||||
{assertion.evidence_ids.length} Evidence verknuepft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{assertion.assertion_type !== 'fact' && (
|
||||
<button
|
||||
onClick={() => onVerify(assertion.id)}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Als Fakt pruefen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AssertionsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview')
|
||||
const [summary, setSummary] = useState<AssertionSummary | null>(null)
|
||||
const [assertions, setAssertions] = useState<Assertion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [filterEntityType, setFilterEntityType] = useState('')
|
||||
const [filterAssertionType, setFilterAssertionType] = useState('')
|
||||
|
||||
// Extract tab
|
||||
const [extractText, setExtractText] = useState('')
|
||||
const [extractEntityType, setExtractEntityType] = useState('control')
|
||||
const [extractEntityId, setExtractEntityId] = useState('')
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [extractedAssertions, setExtractedAssertions] = useState<Assertion[]>([])
|
||||
|
||||
// Verify dialog
|
||||
const [verifyingId, setVerifyingId] = useState<string | null>(null)
|
||||
const [verifyEmail, setVerifyEmail] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'list') loadAssertions()
|
||||
}, [activeTab, filterEntityType, filterAssertionType]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/summary`)
|
||||
if (res.ok) setSummary(await res.json())
|
||||
} catch { /* silent */ }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const loadAssertions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterEntityType) params.set('entity_type', filterEntityType)
|
||||
if (filterAssertionType) params.set('assertion_type', filterAssertionType)
|
||||
params.set('limit', '200')
|
||||
|
||||
const res = await fetch(`${API_BASE}/assertions?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAssertions(data.assertions || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Assertions konnten nicht geladen werden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!extractText.trim()) { setError('Bitte Text eingeben'); return }
|
||||
setExtracting(true)
|
||||
setError(null)
|
||||
setExtractedAssertions([])
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: extractText,
|
||||
entity_type: extractEntityType || 'control',
|
||||
entity_id: extractEntityId || undefined,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Extraktion fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
const data = await res.json()
|
||||
setExtractedAssertions(data.assertions || [])
|
||||
// Refresh summary
|
||||
loadSummary()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraktion fehlgeschlagen')
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async (assertionId: string) => {
|
||||
setVerifyingId(assertionId)
|
||||
}
|
||||
|
||||
const submitVerify = async () => {
|
||||
if (!verifyingId || !verifyEmail.trim()) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/${verifyingId}/verify?verified_by=${encodeURIComponent(verifyEmail)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
setVerifyingId(null)
|
||||
setVerifyEmail('')
|
||||
loadAssertions()
|
||||
loadSummary()
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ detail: 'Verifizierung fehlgeschlagen' }))
|
||||
setError(typeof err.detail === 'string' ? err.detail : 'Verifizierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'overview', label: 'Uebersicht' },
|
||||
{ key: 'list', label: 'Assertion-Liste' },
|
||||
{ key: 'extract', label: 'Extraktion' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Assertions</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Behauptungen vs. Fakten in Compliance-Texten trennen und verifizieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-sm border">
|
||||
<div className="flex border-b">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-purple-600 border-b-2 border-purple-600'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Uebersicht */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : summary ? (
|
||||
<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 Assertions</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{summary.total_assertions}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Verifizierte Fakten</div>
|
||||
<div className="text-3xl font-bold text-green-600">{summary.total_facts}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
||||
<div className="text-sm text-purple-600">Begruendungen</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{summary.total_rationale}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Unverifizizt</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{summary.unverified_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Keine Assertions vorhanden. Nutzen Sie die Extraktion, um Behauptungen aus Texten zu identifizieren.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Assertion-Liste */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Entity-Typ</label>
|
||||
<select value={filterEntityType} onChange={e => setFilterEntityType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Alle</option>
|
||||
<option value="control">Control</option>
|
||||
<option value="evidence">Evidence</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Assertion-Typ</label>
|
||||
<select value={filterAssertionType} onChange={e => setFilterAssertionType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Alle</option>
|
||||
<option value="assertion">Behauptung</option>
|
||||
<option value="fact">Fakt</option>
|
||||
<option value="rationale">Begruendung</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : assertions.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Keine Assertions gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-500">{assertions.length} Assertions</p>
|
||||
{assertions.map(a => (
|
||||
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Extraktion */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'extract' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Assertions aus Text extrahieren</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Geben Sie einen Compliance-Text ein. Das System identifiziert automatisch Behauptungen, Fakten und Begruendungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-Typ</label>
|
||||
<select value={extractEntityType} onChange={e => setExtractEntityType(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="control">Control</option>
|
||||
<option value="evidence">Evidence</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
<option value="policy">Policy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-ID (optional)</label>
|
||||
<input type="text" value={extractEntityId} onChange={e => setExtractEntityId(e.target.value)}
|
||||
placeholder="z.B. GOV-001 oder UUID"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
|
||||
<textarea
|
||||
value={extractText}
|
||||
onChange={e => setExtractText(e.target.value)}
|
||||
placeholder="Die Organisation muss ein ISMS gemaess ISO 27001 implementieren. Es sollte regelmaessig ein internes Audit durchgefuehrt werden. Optional kann ein externer Auditor hinzugezogen werden."
|
||||
rows={6}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
disabled={extracting || !extractText.trim()}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition-colors ${
|
||||
extracting || !extractText.trim()
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{extracting ? 'Extrahiere...' : 'Extrahieren'}
|
||||
</button>
|
||||
|
||||
{/* Extracted results */}
|
||||
{extractedAssertions.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">{extractedAssertions.length} Assertions extrahiert:</h4>
|
||||
<div className="space-y-3">
|
||||
{extractedAssertions.map(a => (
|
||||
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verify Dialog */}
|
||||
{verifyingId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setVerifyingId(null)}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-4">Als Fakt verifizieren</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verifiziert von (E-Mail)</label>
|
||||
<input type="email" value={verifyEmail} onChange={e => setVerifyEmail(e.target.value)}
|
||||
placeholder="auditor@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setVerifyingId(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={submitVerify} disabled={!verifyEmail.trim()}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
|
||||
Verifizieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Atom, Search, ChevronRight, ChevronLeft, Filter,
|
||||
BarChart3, ChevronsLeft, ChevronsRight, ArrowUpDown,
|
||||
Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, CategoryBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge, ObligationTypeBadge, RegulationCountBadge,
|
||||
CATEGORY_OPTIONS,
|
||||
} from '../control-library/components/helpers'
|
||||
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface AtomicStats {
|
||||
total_active: number
|
||||
total_duplicate: number
|
||||
by_domain: Array<{ domain: string; count: number }>
|
||||
by_regulation: Array<{ regulation: string; count: number }>
|
||||
avg_regulation_coverage: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ATOMIC CONTROLS PAGE
|
||||
// =============================================================================
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function AtomicControlsPage() {
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [stats, setStats] = useState<AtomicStats | null>(null)
|
||||
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest'>('id')
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// Mode
|
||||
const [mode, setMode] = useState<'list' | 'detail'>('list')
|
||||
|
||||
// Debounce search
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [searchQuery])
|
||||
|
||||
// Build query params
|
||||
const buildParams = useCallback((extra?: Record<string, string>) => {
|
||||
const p = new URLSearchParams()
|
||||
p.set('control_type', 'atomic')
|
||||
// Exclude duplicates — show only active masters
|
||||
if (!extra?.release_state) {
|
||||
// Don't filter by state for count queries that already have it
|
||||
}
|
||||
if (severityFilter) p.set('severity', severityFilter)
|
||||
if (domainFilter) p.set('domain', domainFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||
return p.toString()
|
||||
}, [severityFilter, domainFilter, categoryFilter, debouncedSearch])
|
||||
|
||||
// Load stats
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=atomic-stats`)
|
||||
if (res.ok) setStats(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Load controls page
|
||||
const loadControls = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const sortField = sortBy === 'id' ? 'control_id' : 'created_at'
|
||||
const sortOrder = sortBy === 'newest' ? 'desc' : 'asc'
|
||||
const offset = (currentPage - 1) * PAGE_SIZE
|
||||
|
||||
const qs = buildParams({
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(offset),
|
||||
})
|
||||
|
||||
const countQs = buildParams()
|
||||
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
||||
])
|
||||
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
if (countRes.ok) {
|
||||
const data = await countRes.json()
|
||||
setTotalCount(data.total || 0)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [buildParams, sortBy, currentPage])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, categoryFilter, debouncedSearch, sortBy])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
// Loading
|
||||
if (loading && controls.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-violet-600 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DETAIL MODE
|
||||
if (mode === 'detail' && selectedControl) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ControlDetail
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null) }}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onReview={() => {}}
|
||||
onNavigateToControl={async (controlId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSelectedControl(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LIST VIEW
|
||||
// =========================================================================
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Atom className="w-6 h-6 text-violet-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Atomare Controls</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
Deduplizierte atomare Controls mit Herkunftsnachweis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { loadControls(); loadStats() }}
|
||||
className="p-2 text-gray-400 hover:text-violet-600"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-violet-700">{stats.total_active.toLocaleString('de-DE')}</div>
|
||||
<div className="text-xs text-violet-500">Master Controls</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.total_duplicate.toLocaleString('de-DE')}</div>
|
||||
<div className="text-xs text-gray-500">Duplikate (entfernt)</div>
|
||||
</div>
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-indigo-700">{stats.by_regulation.length}</div>
|
||||
<div className="text-xs text-indigo-500">Regulierungen</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-emerald-700">{stats.avg_regulation_coverage}</div>
|
||||
<div className="text-xs text-emerald-500">Avg. Regulierungen / Control</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Atomare Controls durchsuchen (ID, Titel, Objective)..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Domain</option>
|
||||
{stats?.by_domain.map(d => (
|
||||
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Kategorie</option>
|
||||
{CATEGORY_OPTIONS.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest')}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="id">Sortierung: ID</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Aelteste zuerst</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{stats && totalCount !== stats.total_active && ` (von ${stats.total_active.toLocaleString('de-DE')} Master Controls)`}
|
||||
{loading && <span className="ml-2 text-violet-500">Lade...</span>}
|
||||
</span>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{controls.map((ctrl) => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-violet-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{ctrl.source_citation?.source && (
|
||||
<>
|
||||
<span className="text-xs text-blue-600">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
</>
|
||||
)}
|
||||
{ctrl.parent_control_id && (
|
||||
<>
|
||||
<span className="text-xs text-violet-500">via {ctrl.parent_control_id}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
</>
|
||||
)}
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
||||
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-violet-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{controls.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
Keine atomaren Controls gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||
acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === 'dots' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p as number)}
|
||||
className={`w-8 h-8 text-sm rounded-lg ${
|
||||
currentPage === p
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'text-gray-600 hover:bg-violet-50 hover:text-violet-600'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
561
admin-compliance/app/sdk/audit-llm/page.tsx
Normal file
561
admin-compliance/app/sdk/audit-llm/page.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface LLMLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
namespace: string
|
||||
model: string
|
||||
provider: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
pii_detected: boolean
|
||||
pii_categories: string[]
|
||||
redacted: boolean
|
||||
duration_ms: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_prompt_tokens: number
|
||||
total_completion_tokens: number
|
||||
models_used: Record<string, number>
|
||||
providers_used: Record<string, number>
|
||||
avg_duration_ms: number
|
||||
pii_detection_rate: number
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
interface ComplianceReport {
|
||||
total_requests: number
|
||||
pii_incidents: number
|
||||
pii_rate: number
|
||||
redaction_rate: number
|
||||
policy_violations: number
|
||||
top_pii_categories: Record<string, number>
|
||||
namespace_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
user_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
type TabId = 'llm-log' | 'usage' | 'compliance'
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = '/api/sdk/v1/audit-llm'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function getDateRange(period: string): { from: string; to: string } {
|
||||
const now = new Date()
|
||||
const to = now.toISOString().slice(0, 10)
|
||||
const from = new Date(now)
|
||||
switch (period) {
|
||||
case '7d': from.setDate(from.getDate() - 7); break
|
||||
case '30d': from.setDate(from.getDate() - 30); break
|
||||
case '90d': from.setDate(from.getDate() - 90); break
|
||||
default: from.setDate(from.getDate() - 7)
|
||||
}
|
||||
return { from: from.toISOString().slice(0, 10), to }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AuditLLMPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('llm-log')
|
||||
const [period, setPeriod] = useState('7d')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// LLM Log state
|
||||
const [logEntries, setLogEntries] = useState<LLMLogEntry[]>([])
|
||||
const [logFilter, setLogFilter] = useState({ model: '', pii: '' })
|
||||
|
||||
// Usage state
|
||||
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||
|
||||
// Compliance state
|
||||
const [complianceReport, setComplianceReport] = useState<ComplianceReport | null>(null)
|
||||
|
||||
// ─── Load Data ───────────────────────────────────────────────────────
|
||||
|
||||
const loadLLMLog = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const params = new URLSearchParams({ from, to, limit: '100' })
|
||||
if (logFilter.model) params.set('model', logFilter.model)
|
||||
if (logFilter.pii === 'true') params.set('pii_detected', 'true')
|
||||
if (logFilter.pii === 'false') params.set('pii_detected', 'false')
|
||||
|
||||
const res = await fetch(`${API_BASE}/llm?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setLogEntries(Array.isArray(data) ? data : data.entries || data.logs || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period, logFilter])
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/usage?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setUsageStats(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const loadCompliance = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/compliance-report?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setComplianceReport(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'llm-log') loadLLMLog()
|
||||
else if (activeTab === 'usage') loadUsage()
|
||||
else if (activeTab === 'compliance') loadCompliance()
|
||||
}, [activeTab, loadLLMLog, loadUsage, loadCompliance])
|
||||
|
||||
// ─── Export ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleExport = async (type: 'llm' | 'general' | 'compliance', format: 'json' | 'csv') => {
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/export/${type}?from=${from}&to=${to}&format=${format}`)
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${type}-${from}-${to}.${format}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tabs ────────────────────────────────────────────────────────────
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'llm-log', label: 'LLM-Log' },
|
||||
{ id: 'usage', label: 'Nutzung' },
|
||||
{ id: 'compliance', label: 'Compliance' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">LLM Audit Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">Monitoring und Compliance-Analyse der LLM-Operationen</p>
|
||||
</div>
|
||||
|
||||
{/* Period + Tabs */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={period}
|
||||
onChange={e => setPeriod(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="7d">Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
<option value="90d">Letzte 90 Tage</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTab === 'llm-log') handleExport('llm', 'csv')
|
||||
else if (activeTab === 'compliance') handleExport('compliance', 'json')
|
||||
else handleExport('general', 'csv')
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-purple-200 border-t-purple-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── LLM-Log Tab ── */}
|
||||
{!loading && activeTab === 'llm-log' && (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Model filtern..."
|
||||
value={logFilter.model}
|
||||
onChange={e => setLogFilter(f => ({ ...f, model: e.target.value }))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
|
||||
/>
|
||||
<select
|
||||
value={logFilter.pii}
|
||||
onChange={e => setLogFilter(f => ({ ...f, pii: e.target.value }))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle PII-Status</option>
|
||||
<option value="true">PII erkannt</option>
|
||||
<option value="false">Kein PII</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Zeitpunkt</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Model</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Tokens</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">PII</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Dauer</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logEntries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
Keine Log-Eintraege im gewaehlten Zeitraum
|
||||
</td>
|
||||
</tr>
|
||||
) : logEntries.map(entry => (
|
||||
<tr key={entry.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(entry.created_at)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{entry.user_id?.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
|
||||
{entry.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{formatNumber(entry.total_tokens)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{entry.pii_detected ? (
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-medium">
|
||||
{entry.redacted ? 'Redacted' : 'Erkannt'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatDuration(entry.duration_ms)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
entry.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">{logEntries.length} Eintraege</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Nutzung Tab ── */}
|
||||
{!loading && activeTab === 'usage' && usageStats && (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests gesamt" value={formatNumber(usageStats.total_requests)} />
|
||||
<StatCard label="Tokens gesamt" value={formatNumber(usageStats.total_tokens)} />
|
||||
<StatCard label="Avg. Dauer" value={formatDuration(usageStats.avg_duration_ms)} />
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(usageStats.pii_detection_rate * 100).toFixed(1)}%`}
|
||||
highlight={usageStats.pii_detection_rate > 0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Model-Nutzung</h3>
|
||||
{Object.entries(usageStats.models_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.models_used).sort((a, b) => b[1] - a[1]).map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">{model}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${(count / usageStats.total_requests) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-gray-500 w-16 text-right">{formatNumber(count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Provider-Verteilung</h3>
|
||||
{Object.entries(usageStats.providers_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.providers_used).sort((a, b) => b[1] - a[1]).map(([provider, count]) => (
|
||||
<div key={provider} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 capitalize">{provider}</span>
|
||||
<span className="text-sm font-mono text-gray-500">{formatNumber(count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Details */}
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Token-Aufschluesselung</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_prompt_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Prompt Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_completion_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Completion Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{formatNumber(usageStats.total_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Compliance Tab ── */}
|
||||
{!loading && activeTab === 'compliance' && complianceReport && (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests" value={formatNumber(complianceReport.total_requests)} />
|
||||
<StatCard
|
||||
label="PII-Vorfaelle"
|
||||
value={formatNumber(complianceReport.pii_incidents)}
|
||||
highlight={complianceReport.pii_incidents > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(complianceReport.pii_rate * 100).toFixed(1)}%`}
|
||||
highlight={complianceReport.pii_rate > 0.05}
|
||||
/>
|
||||
<StatCard label="Redaction-Rate" value={`${(complianceReport.redaction_rate * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
|
||||
{complianceReport.policy_violations > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-red-700 font-semibold">
|
||||
<svg className="w-5 h-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>
|
||||
{complianceReport.policy_violations} Policy-Verletzungen im Zeitraum
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* PII Categories */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">PII-Kategorien</h3>
|
||||
{Object.entries(complianceReport.top_pii_categories || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine PII erkannt</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(complianceReport.top_pii_categories).sort((a, b) => b[1] - a[1]).map(([cat, count]) => (
|
||||
<div key={cat} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-gray-700">{cat}</span>
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-mono">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace Breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Namespace-Analyse</h3>
|
||||
{Object.entries(complianceReport.namespace_breakdown || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Namespace-Daten</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">Namespace</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.namespace_breakdown).map(([ns, data]) => (
|
||||
<tr key={ns} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{ns}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Breakdown */}
|
||||
{Object.entries(complianceReport.user_breakdown || {}).length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Top-Nutzer</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">User-ID</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII-Vorfaelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.user_breakdown)
|
||||
.sort((a, b) => b[1].requests - a[1].requests)
|
||||
.slice(0, 10)
|
||||
.map(([userId, data]) => (
|
||||
<tr key={userId} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{userId}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state for usage/compliance when no data */}
|
||||
{!loading && activeTab === 'usage' && !usageStats && !error && (
|
||||
<div className="text-center py-12 text-gray-400">Keine Nutzungsdaten verfuegbar</div>
|
||||
)}
|
||||
{!loading && activeTab === 'compliance' && !complianceReport && !error && (
|
||||
<div className="text-center py-12 text-gray-400">Kein Compliance-Report verfuegbar</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${highlight ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="text-sm text-gray-500">{label}</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${highlight ? 'text-red-700' : 'text-gray-900'}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import CatalogManagerContent from '@/components/catalog-manager/CatalogManagerContent'
|
||||
import { CatalogManagerContent } from '@/components/catalog-manager/CatalogManagerContent'
|
||||
|
||||
export default function CatalogManagerPage() {
|
||||
return <CatalogManagerContent />
|
||||
|
||||
345
admin-compliance/app/sdk/change-requests/page.tsx
Normal file
345
admin-compliance/app/sdk/change-requests/page.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface ChangeRequest {
|
||||
id: string
|
||||
triggerType: string
|
||||
targetDocumentType: string
|
||||
targetDocumentId: string | null
|
||||
targetSection: string | null
|
||||
proposalTitle: string
|
||||
proposalBody: string | null
|
||||
proposedChanges: Record<string, unknown>
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'edited_and_accepted'
|
||||
priority: 'low' | 'normal' | 'high' | 'critical'
|
||||
decidedBy: string | null
|
||||
decidedAt: string | null
|
||||
rejectionReason: string | null
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_pending: number
|
||||
critical_count: number
|
||||
total_accepted: number
|
||||
total_rejected: number
|
||||
by_document_type: Record<string, number>
|
||||
}
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance/change-requests'
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
dsfa: 'DSFA',
|
||||
vvt: 'VVT',
|
||||
tom: 'TOM',
|
||||
loeschfristen: 'Löschfristen',
|
||||
obligation: 'Pflichten',
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
normal: 'bg-blue-100 text-blue-800',
|
||||
low: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
accepted: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
edited_and_accepted: 'bg-emerald-100 text-emerald-800',
|
||||
}
|
||||
|
||||
function snakeToCamel(obj: Record<string, unknown>): ChangeRequest {
|
||||
return {
|
||||
id: obj.id as string,
|
||||
triggerType: obj.trigger_type as string,
|
||||
targetDocumentType: obj.target_document_type as string,
|
||||
targetDocumentId: obj.target_document_id as string | null,
|
||||
targetSection: obj.target_section as string | null,
|
||||
proposalTitle: obj.proposal_title as string,
|
||||
proposalBody: obj.proposal_body as string | null,
|
||||
proposedChanges: (obj.proposed_changes || {}) as Record<string, unknown>,
|
||||
status: obj.status as ChangeRequest['status'],
|
||||
priority: obj.priority as ChangeRequest['priority'],
|
||||
decidedBy: obj.decided_by as string | null,
|
||||
decidedAt: obj.decided_at as string | null,
|
||||
rejectionReason: obj.rejection_reason as string | null,
|
||||
createdBy: obj.created_by as string,
|
||||
createdAt: obj.created_at as string,
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChangeRequestsPage() {
|
||||
const [requests, setRequests] = useState<ChangeRequest[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionModal, setActionModal] = useState<{ type: 'accept' | 'reject' | 'edit'; cr: ChangeRequest } | null>(null)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [editBody, setEditBody] = useState('')
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const statsRes = await fetch(`${API_BASE}/stats`)
|
||||
if (statsRes.ok) setStats(await statsRes.json())
|
||||
|
||||
let url = `${API_BASE}?status=${statusFilter}`
|
||||
if (filter !== 'all') url += `&target_document_type=${filter}`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRequests((Array.isArray(data) ? data : []).map(snakeToCamel))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load change requests:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filter, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const interval = setInterval(loadData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadData])
|
||||
|
||||
const handleAccept = async (cr: ChangeRequest) => {
|
||||
const res = await fetch(`${API_BASE}/${cr.id}/accept`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
setActionModal(null)
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (cr: ChangeRequest) => {
|
||||
const res = await fetch(`${API_BASE}/${cr.id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rejection_reason: rejectReason }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setActionModal(null)
|
||||
setRejectReason('')
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (cr: ChangeRequest) => {
|
||||
const res = await fetch(`${API_BASE}/${cr.id}/edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ proposal_body: editBody }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setActionModal(null)
|
||||
setEditBody('')
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Änderungsanfrage wirklich löschen?')) return
|
||||
const res = await fetch(`${API_BASE}/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) loadData()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Änderungsanfragen</h1>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-yellow-700">{stats.total_pending}</div>
|
||||
<div className="text-sm text-yellow-600">Offen</div>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-red-700">{stats.critical_count}</div>
|
||||
<div className="text-sm text-red-600">Kritisch</div>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-green-700">{stats.total_accepted}</div>
|
||||
<div className="text-sm text-green-600">Angenommen</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-gray-700">{stats.total_rejected}</div>
|
||||
<div className="text-sm text-gray-600">Abgelehnt</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{['pending', 'accepted', 'rejected'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
statusFilter === s ? 'bg-white shadow text-purple-700' : 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{s === 'pending' ? 'Offen' : s === 'accepted' ? 'Angenommen' : 'Abgelehnt'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{['all', 'dsfa', 'vvt', 'tom', 'loeschfristen', 'obligation'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setFilter(t)}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
filter === t ? 'bg-white shadow text-purple-700' : 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{t === 'all' ? 'Alle' : DOC_TYPE_LABELS[t] || t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Keine Änderungsanfragen {statusFilter === 'pending' ? 'offen' : 'gefunden'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{requests.map(cr => (
|
||||
<div key={cr.id} className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[cr.priority]}`}>
|
||||
{cr.priority}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[cr.status]}`}>
|
||||
{cr.status}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
{DOC_TYPE_LABELS[cr.targetDocumentType] || cr.targetDocumentType}
|
||||
</span>
|
||||
{cr.triggerType !== 'manual' && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600">
|
||||
{cr.triggerType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900">{cr.proposalTitle}</h3>
|
||||
{cr.proposalBody && (
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{cr.proposalBody}</p>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{new Date(cr.createdAt).toLocaleDateString('de-DE')} — {cr.createdBy}
|
||||
{cr.rejectionReason && (
|
||||
<span className="text-red-500 ml-2">Grund: {cr.rejectionReason}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cr.status === 'pending' && (
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => { setActionModal({ type: 'accept', cr }) }}
|
||||
className="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
Annehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditBody(cr.proposalBody || ''); setActionModal({ type: 'edit', cr }) }}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRejectReason(''); setActionModal({ type: 'reject', cr }) }}
|
||||
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cr.id)}
|
||||
className="px-3 py-1 text-red-600 rounded text-sm hover:bg-red-50"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Modal */}
|
||||
{actionModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setActionModal(null)}>
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||
{actionModal.type === 'accept' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold mb-4">Änderung annehmen?</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{actionModal.cr.proposalTitle}</p>
|
||||
{Object.keys(actionModal.cr.proposedChanges).length > 0 && (
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs mb-4 max-h-48 overflow-auto">
|
||||
{JSON.stringify(actionModal.cr.proposedChanges, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
||||
<button onClick={() => handleAccept(actionModal.cr)} className="px-4 py-2 bg-green-600 text-white rounded-lg">Annehmen</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{actionModal.type === 'reject' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold mb-4">Änderung ablehnen</h3>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={e => setRejectReason(e.target.value)}
|
||||
placeholder="Begründung..."
|
||||
className="w-full border rounded-lg p-3 h-24 mb-4"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
||||
<button
|
||||
onClick={() => handleReject(actionModal.cr)}
|
||||
disabled={!rejectReason.trim()}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{actionModal.type === 'edit' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold mb-4">Vorschlag bearbeiten & annehmen</h3>
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={e => setEditBody(e.target.value)}
|
||||
className="w-full border rounded-lg p-3 h-32 mb-4"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
||||
<button
|
||||
onClick={() => handleEdit(actionModal.cr)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Speichern & Annehmen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user