Compare commits
320 Commits
0340204c1f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c400b9aa | ||
|
|
098a2ff092 | ||
|
|
cb1be59e46 | ||
|
|
45287b3541 | ||
|
|
d87645ffce | ||
|
|
d4959172a9 | ||
|
|
b49ee3467e | ||
|
|
2eb17fd349 | ||
|
|
06ea9f7073 | ||
|
|
f3b9617fc3 | ||
|
|
8efffe8c52 | ||
|
|
a317bd6164 | ||
|
|
dc60233262 | ||
|
|
0ff5399a62 | ||
|
|
354b0394e5 | ||
|
|
20aea13181 | ||
|
|
deb405a3a1 | ||
|
|
03fc0c5945 | ||
|
|
6a9eb048da | ||
|
|
c92f2dc7a7 | ||
|
|
4e27c9b35a | ||
|
|
8631971821 | ||
|
|
713f0e7570 | ||
|
|
df3f6e65c2 | ||
|
|
37db47fcd9 | ||
|
|
bd4b956e3c | ||
|
|
451365a312 | ||
|
|
b4613e26f3 | ||
|
|
34da9f4cda | ||
|
|
b6983ab1dc | ||
|
|
6811264756 | ||
|
|
b2a0126f14 | ||
|
|
0b37c5e692 | ||
|
|
b681ddb131 | ||
|
|
9ba420fa91 | ||
|
|
b07f802c24 | ||
|
|
0dbfa87058 | ||
|
|
c0b723e3b5 | ||
|
|
7ff9860c69 | ||
|
|
7fc5464df7 | ||
|
|
5fbf0f4ee2 | ||
|
|
2f8270f77b | ||
|
|
00eb9f26f6 | ||
|
|
141f69ceaa | ||
|
|
2baad68060 | ||
|
|
25e5a7415a | ||
|
|
545c8676b0 | ||
|
|
2f34ee9ede | ||
|
|
5a154b744d | ||
|
|
f39cbe9283 | ||
|
|
5abdfa202e | ||
|
|
9b0e310978 | ||
|
|
46c2acb2f4 | ||
|
|
b8f1b71652 | ||
|
|
6a165b36e5 | ||
|
|
9dddd80d7a | ||
|
|
20a0585eb1 | ||
|
|
4561320e0d | ||
|
|
596864431b | ||
|
|
c8027eb7f9 | ||
|
|
ba0f659d1e | ||
|
|
50bfd6e902 | ||
|
|
0599c72cc1 | ||
|
|
5fad2d420d | ||
|
|
c8e5e498b5 | ||
|
|
261f686dac | ||
|
|
3d3c2b30db | ||
|
|
1d22f649ae | ||
|
|
610825ac14 | ||
|
|
6aec4742e5 | ||
|
|
0491c2eb84 | ||
|
|
f2bc62b4f5 | ||
|
|
674c9e949e | ||
|
|
e131aa719e | ||
|
|
17f0fdb2ed | ||
|
|
d4353d76fb | ||
|
|
b42f394833 | ||
|
|
c1a903537b | ||
|
|
7085c87618 | ||
|
|
1b7e095176 | ||
|
|
dcb873db35 | ||
|
|
fd39d13d06 | ||
|
|
c5733a171b | ||
|
|
18213f0bde | ||
|
|
cd8eb6ce46 | ||
|
|
2c2bdf903a | ||
|
|
947ff6bdcb | ||
|
|
92e4021898 | ||
|
|
108f1b1a2a | ||
|
|
48de4d98cd | ||
|
|
b5900f1aff | ||
|
|
baac98f837 | ||
|
|
496d34d822 | ||
|
|
709e41e050 | ||
|
|
7b3e8c576d | ||
|
|
868f99f109 | ||
|
|
dc25f243a4 | ||
|
|
c62ff7cd31 | ||
|
|
5d91698c3b | ||
|
|
5fa5767c9a | ||
|
|
693803fb7c | ||
|
|
31089df36f | ||
|
|
7b294f9150 | ||
|
|
8b29d20940 | ||
|
|
12b194ad1a | ||
|
|
058eadb0e4 | ||
|
|
5da9a550bf | ||
|
|
52637778b9 | ||
|
|
f6372b8c69 | ||
|
|
909d0729f6 | ||
|
|
04fa01661c | ||
|
|
bf9d24e108 | ||
|
|
0f17eb3cd9 | ||
|
|
5244e10728 | ||
|
|
a6c5f56003 | ||
|
|
584e07eb21 | ||
|
|
54b1c7d7d7 | ||
|
|
d8a2331038 | ||
|
|
ad78e26143 | ||
|
|
4f4e6c31fa | ||
|
|
7ffa4c90f9 | ||
|
|
656cadbb1e | ||
|
|
757c8460c9 | ||
|
|
501de4374a | ||
|
|
774bbc50d3 | ||
|
|
9ceee4e07c | ||
|
|
f23aaaea51 | ||
|
|
cde13c9623 | ||
|
|
2e42167c73 | ||
|
|
5eff4cf877 | ||
|
|
7f4b8757ff | ||
|
|
7263328edb | ||
|
|
8c482ce8dd | ||
|
|
00f7a7154c | ||
|
|
9c5e950c99 | ||
|
|
6e494a43ab | ||
|
|
53b0d77853 | ||
|
|
aed0edbf6d | ||
|
|
9e2c301723 | ||
|
|
633e301bfd | ||
|
|
9b5e8c6b35 | ||
|
|
682b306e51 | ||
|
|
3e3116d2fd | ||
|
|
9a8ce69782 | ||
|
|
66f8a7b708 | ||
|
|
3b78baf37f | ||
|
|
2828871e42 | ||
|
|
5c96def4ec | ||
|
|
611e1ee33d | ||
|
|
49d5212f0c | ||
|
|
e6f8e12f44 | ||
|
|
aabd849e35 | ||
|
|
d1e7dd1c4a | ||
|
|
71e1b10ac7 | ||
|
|
21b69e06be | ||
|
|
0168ab1a67 | ||
|
|
925f4356ce | ||
|
|
cc4cb3bc2f | ||
|
|
0685fb12da | ||
|
|
96ea23164d | ||
|
|
a8773d5b00 | ||
|
|
9f68bd3425 | ||
|
|
469f09d1e1 | ||
|
|
3bb04b25ab | ||
|
|
85fe0a73d6 | ||
|
|
eaade3cad2 | ||
|
|
d26a9f60ab | ||
|
|
d26233b5b3 | ||
|
|
e019dde01b | ||
|
|
5af5d821a5 | ||
|
|
525de55791 | ||
|
|
f860eb66e6 | ||
|
|
a73ddce43d | ||
|
|
47e83d90bd | ||
|
|
76cd1ac020 | ||
|
|
256df820cd | ||
|
|
7773c51304 | ||
|
|
83c058e400 | ||
|
|
34680732f8 | ||
|
|
c42924a94a | ||
|
|
9ea217bdfc | ||
|
|
4feec7c7b7 | ||
|
|
ed7fc99fc4 | ||
|
|
7fbcae954b | ||
|
|
f931091b57 | ||
|
|
f34340de9c | ||
|
|
55de6c21d2 | ||
|
|
52b66ebe07 | ||
|
|
424e5c51d4 | ||
|
|
12b4c61bac | ||
|
|
d9b2aa82e9 | ||
|
|
364086b86e | ||
|
|
fe754398c0 | ||
|
|
be86a7d14d | ||
|
|
19a5f69272 | ||
|
|
ea09fc75df | ||
|
|
410d36f3de | ||
|
|
72ce4420cb | ||
|
|
63dfb4d06f | ||
|
|
08a91ba2be | ||
|
|
49a36364a8 | ||
|
|
14fd8e0b1e | ||
|
|
247b79674d | ||
|
|
40815dafd1 | ||
|
|
2a21127f01 | ||
|
|
9d34c5201e | ||
|
|
d54814fa70 | ||
|
|
d6f4944bcc | ||
|
|
ee0d9c881e | ||
|
|
65f4ce1947 | ||
|
|
4e668660a7 | ||
|
|
7a6eadde8b | ||
|
|
4e809c3860 | ||
|
|
dccbb909bc | ||
|
|
be7f5f1872 | ||
|
|
c695b659fb | ||
|
|
a1e079b911 | ||
|
|
f5d5d6c59c | ||
|
|
4a44ad7986 | ||
|
|
7b3319be2e | ||
|
|
882b177fc3 | ||
|
|
1fae39dbb8 | ||
|
|
46c8c28d34 | ||
|
|
4000110501 | ||
|
|
2acf8696bf | ||
|
|
c0e1118870 | ||
|
|
f31a7175a2 | ||
|
|
bacbfd88f1 | ||
|
|
2c63beff04 | ||
|
|
82433b4bad | ||
|
|
d889a6959e | ||
|
|
bc1804ad18 | ||
|
|
45b83560fd | ||
|
|
e4fa634a63 | ||
|
|
76ba83eecb | ||
|
|
04092a0a66 | ||
|
|
7fafd297e7 | ||
|
|
7ac09b5941 | ||
|
|
1f7989cfc2 | ||
|
|
ef5aed6a98 | ||
|
|
7dc00e737a | ||
|
|
a579c31ddb | ||
|
|
0f9c0d2ad0 | ||
|
|
278067fe20 | ||
|
|
d76fb2a9c8 | ||
|
|
9681fcbd05 | ||
|
|
4290f70885 | ||
|
|
5c935eec23 | ||
|
|
c4a5cd2d8a | ||
|
|
bc5ab29c06 | ||
|
|
7c5d95b858 | ||
|
|
65059471cf | ||
|
|
58c9565ba5 | ||
|
|
92a7b85c2d | ||
|
|
5f89913a9a | ||
|
|
3c7fc43f43 | ||
|
|
6bfa9eed86 | ||
|
|
7750b2a05f | ||
|
|
e3395ae8cf | ||
|
|
df30d4eae3 | ||
|
|
2e6ab3a646 | ||
|
|
cc5ee74921 | ||
|
|
21d37b5da1 | ||
|
|
19cbbf310a | ||
|
|
fc0ab84e40 | ||
|
|
050d410ba0 | ||
|
|
038eaf783c | ||
|
|
432eee3694 | ||
|
|
8e4cbd84c2 | ||
|
|
f9d71d50d1 | ||
|
|
c09838e91c | ||
|
|
3fd6523872 | ||
|
|
e56391b0c3 | ||
|
|
a3e2a7f994 | ||
|
|
f655db30e4 | ||
|
|
c894a0feeb | ||
|
|
8ef4c089cf | ||
|
|
821e5481c2 | ||
|
|
b98ea33a3a | ||
|
|
f139d0903e | ||
|
|
962bbbe9f6 | ||
|
|
9da45c2a59 | ||
|
|
64447ad352 | ||
|
|
00cbf266cb | ||
|
|
f9bad7beaa | ||
|
|
143e41ec76 | ||
|
|
ec287fd12e | ||
|
|
98f7f7d7d5 | ||
|
|
a19bca6060 | ||
|
|
7a76697f95 | ||
|
|
5359a4cc2b | ||
|
|
a25214126d | ||
|
|
fd79d5e4fa | ||
|
|
19b93f7762 | ||
|
|
a079ffe8e9 | ||
|
|
6e1d715d0d | ||
|
|
d66efdecf5 | ||
|
|
d36972b464 | ||
|
|
f30e526917 | ||
|
|
438a4495c7 | ||
|
|
902de027f4 | ||
|
|
b1cdb2531c | ||
|
|
ab30e8b17a | ||
|
|
b0e1fbc8d6 | ||
|
|
872b47f691 | ||
|
|
bbf0a5720e | ||
|
|
29d3c1caf5 | ||
|
|
aae8a96aa2 | ||
|
|
2b73d9beec | ||
|
|
324f39a9cc | ||
|
|
febd0a2f84 | ||
|
|
43b1f8be58 | ||
|
|
43dec5dd91 | ||
|
|
dfce8415d7 | ||
|
|
92a52a3199 | ||
|
|
427fecdce0 | ||
|
|
9fb3229270 | ||
|
|
91625a2646 | ||
|
|
02ae6249ca | ||
|
|
cf995f2d52 |
@@ -256,3 +256,67 @@ ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && git push all
|
||||
| `website/app/admin/klausur-korrektur/` | Korrektur-Workspace |
|
||||
| `backend-lehrer/classroom_api.py` | Classroom Engine |
|
||||
| `backend-lehrer/state_engine_api.py` | State Engine |
|
||||
|
||||
---
|
||||
|
||||
## Compliance: Kein Scan/OCR im Kunden-Frontend (NON-NEGOTIABLE)
|
||||
|
||||
Studio-v2 (Kunden-Frontend, Port 443) darf **KEINE** Features enthalten die:
|
||||
- Buchseiten/Schulbuecher von Dritten rekonstruieren oder reproduzieren
|
||||
- Aktiv zum Upload fremder urheberrechtlich geschuetzter Werke auffordern
|
||||
|
||||
**Erlaubt** in studio-v2:
|
||||
- Upload eigener Dokumente durch Lehrer (eigene Arbeitsblaetter, Tests, Materialien)
|
||||
- OCR/Verarbeitung von Dokumenten bei denen der Lehrer Urheber ist
|
||||
- Manuelle Vokabeleingabe durch Lehrer
|
||||
- Vorschlagslisten aus eigenem Woerterbuch (160k MIT-lizenzierte Woerter)
|
||||
- Lernunit-Erstellung aus eigenen/ausgewaehlten Inhalten
|
||||
- Audio/Bild/Quiz/Karteikarten-Generierung
|
||||
|
||||
**Erweiterte OCR/Scan Features** (z.B. Vision-LLM Fusion, A/B Testing Toggles) bleiben
|
||||
im Admin-Frontend (admin-lehrer, Port 3002) fuer Entwicklung und Testing.
|
||||
|
||||
**Hintergrund**: Urheberrechtliche Haftung der GmbH. Das System ist eine
|
||||
Didaktik-Engine (Transformation + Lernen), KEIN Content-Reconstruction-Tool.
|
||||
|
||||
---
|
||||
|
||||
## Code-Qualitaet Guardrails (NON-NEGOTIABLE)
|
||||
|
||||
> Vollstaendige Details: `.claude/rules/architecture.md`
|
||||
> Ausnahmen: `.claude/rules/loc-exceptions.txt`
|
||||
|
||||
### File Size Budget
|
||||
|
||||
- **Hard Cap: 500 LOC** pro Datei
|
||||
- Wenn eine Aenderung eine Datei ueber 500 LOC bringen wuerde: **erst splitten, dann aendern**
|
||||
- Ausnahmen nur mit Begruendung in `loc-exceptions.txt` + `[guardrail-change]` Commit-Marker
|
||||
|
||||
### Architektur
|
||||
|
||||
- **Python:** Routes duenn → Business Logic in Services → Persistenz in Repositories
|
||||
- **Go:** Handler ≤40 LOC → Service-Layer → Repository-Pattern
|
||||
- **TypeScript/Next.js:** page.tsx duenn → Server Actions, Queries, Components auslagern
|
||||
- **Types:** Monolithische types.ts frueh splitten, types.ts + types/ Shadowing vermeiden
|
||||
|
||||
### Workflow (bei jeder Aenderung)
|
||||
|
||||
1. Datei lesen + LOC pruefen
|
||||
2. Wenn nahe am Budget → erst splitten
|
||||
3. Minimale kohaerente Aenderung
|
||||
4. Verifikation (Tests + Lint)
|
||||
5. Zusammenfassung: Was geaendert, was verifiziert, Restrisiko
|
||||
|
||||
### Commit-Marker
|
||||
|
||||
- `[migration-approved]` — Schema-/Migrations-Aenderungen
|
||||
- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh
|
||||
- `[split-required]` — Aenderung beginnt mit Datei-Split
|
||||
- `[interface-change]` — Public API Contracts geaendert
|
||||
|
||||
### LOC-Check ausfuehren
|
||||
|
||||
```bash
|
||||
bash scripts/check-loc.sh --changed # nur geaenderte Dateien
|
||||
bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations)
|
||||
```
|
||||
|
||||
46
.claude/rules/architecture.md
Normal file
46
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Architecture Rule — BreakPilot Lehrer
|
||||
|
||||
## File Size Budget
|
||||
|
||||
Hard default: **500 LOC max** per file.
|
||||
Soft targets:
|
||||
- Handler/Router/Service: 300-400 LOC
|
||||
- Models/Schemas/Types: 200-300 LOC
|
||||
- Utilities: 100-200 LOC
|
||||
|
||||
Ausnahmen nur in `.claude/rules/loc-exceptions.txt` mit Begruendung.
|
||||
|
||||
## Split-Trigger
|
||||
|
||||
Sofort splitten wenn:
|
||||
- Datei ueberschreitet 500 LOC
|
||||
- Datei wuerde nach Aenderung 500 LOC ueberschreiten
|
||||
- Datei mischt Transport + Business Logic + Persistence
|
||||
- Datei enthaelt mehrere unabhaengig testbare Verantwortlichkeiten
|
||||
|
||||
## Python (backend-lehrer, klausur-service, voice-service)
|
||||
|
||||
- Routes duenn halten — Business Logic in Services
|
||||
- Persistenz in Repositories/Data-Access-Module
|
||||
- Pydantic Schemas nach Domain splitten
|
||||
- Zirkulaere Imports vermeiden
|
||||
|
||||
## Go (school-service, edu-search-service)
|
||||
|
||||
- Handler duenn halten (≤40 LOC)
|
||||
- Business Logic in Services/Use-Cases
|
||||
- Transport/Request-Decoding getrennt von Domain-Logik
|
||||
|
||||
## TypeScript / Next.js (admin-lehrer, studio-v2, website)
|
||||
|
||||
- page.tsx duenn halten — Server Actions, Queries, Forms auslagern
|
||||
- Monolithische types.ts frueh splitten
|
||||
- types.ts + types/ Shadowing vermeiden
|
||||
- Shared Client/Server Types explizit trennen
|
||||
|
||||
## Entscheidungsreihenfolge
|
||||
|
||||
1. Bestehendes kleines kohaeesives Modul wiederverwenden
|
||||
2. Neues Modul in der Naehe erstellen
|
||||
3. Ueberfuellte Datei splitten, neues Verhalten in richtiges Split-Modul
|
||||
4. Nur als letzter Ausweg: Grosse bestehende Datei erweitern
|
||||
53
.claude/rules/loc-exceptions.txt
Normal file
53
.claude/rules/loc-exceptions.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
# LOC Exceptions — BreakPilot Lehrer
|
||||
# Format: <glob> | owner=<person> | reason=<why> | review=<date>
|
||||
#
|
||||
# Jede Ausnahme braucht Begruendung und Review-Datum.
|
||||
# Temporaere Ausnahmen muessen mit [guardrail-change] Commit-Marker versehen werden.
|
||||
|
||||
# Generated / Build Artifacts
|
||||
**/node_modules/** | owner=infra | reason=npm packages | review=permanent
|
||||
**/.next/** | owner=infra | reason=Next.js build output | review=permanent
|
||||
**/__pycache__/** | owner=infra | reason=Python bytecode | review=permanent
|
||||
**/venv/** | owner=infra | reason=Python virtualenv | review=permanent
|
||||
|
||||
# Test-Dateien (duerfen groesser sein fuer Table-Driven Tests)
|
||||
**/*test*.py | owner=all | reason=Tests mit Table-Driven Patterns duerfen groesser sein | review=permanent
|
||||
**/*test*.go | owner=all | reason=Go Tests mit Table-Driven Patterns | review=permanent
|
||||
**/*test*.ts | owner=all | reason=TypeScript Tests | review=permanent
|
||||
**/tests/** | owner=all | reason=Test-Verzeichnisse | review=permanent
|
||||
|
||||
# Blog-Seiten (reine statische Inhalte, kein Code)
|
||||
**/blog/*/page.tsx | owner=website | reason=Statische Blog-Artikel (MDX-artig, reiner Content) | review=permanent
|
||||
|
||||
# Pure Data Registries (keine Logik, nur Daten-Definitionen)
|
||||
**/dsfa_sources_registry.py | owner=klausur | reason=Pure data registry (license + source definitions, no logic) | review=2027-01-01
|
||||
**/legal_corpus_registry.py | owner=klausur | reason=Pure data registry (Regulation dataclass + 47 regulation definitions, no logic) | review=2027-01-01
|
||||
**/backlog/backlog-items.ts | owner=admin-lehrer | reason=Pure data array (506 LOC, no logic, only BacklogItem[] literals) | review=2027-01-01
|
||||
**/lib/module-registry-data.ts | owner=admin-lehrer | reason=Pure data array (510 LOC, no logic, only BackendModule[] literals) | review=2027-01-01
|
||||
|
||||
# Algorithmic monolith — detect_column_geometry() allein 411 LOC, nicht weiter teilbar
|
||||
**/cv_layout_columns.py | owner=klausur | reason=detect_column_geometry ist eine einzelne 411-LOC Funktion (Whitespace-Gap-Analyse) | review=2026-10-01
|
||||
**/ocr/layout/columns.py | owner=klausur | reason=Same file moved to ocr/ package | review=2026-10-01
|
||||
|
||||
# Two indivisible route handlers (~230 LOC each) that cannot be split further
|
||||
**/vocab_worksheet_compare_api.py | owner=klausur | reason=compare_ocr_methods (234 LOC) + analyze_grid (255 LOC), each a single cohesive handler | review=2026-10-01
|
||||
**/vocab/worksheet/compare_api.py | owner=klausur | reason=Same file moved to vocab/ package | review=2026-10-01
|
||||
|
||||
# TypeScript Data Catalogs (admin-lehrer/lib/sdk/)
|
||||
# Pure exported const arrays/objects with type definitions, no business logic.
|
||||
# DSGVO/GDPR compliance catalogs: risk scenarios, mitigations, legal bases, checklists, etc.
|
||||
**/lib/sdk/vendor-compliance/catalog/*.ts | owner=admin-lehrer | reason=Pure data catalogs (processing-activities 813, vendor-templates 564, legal-basis 562 LOC) | review=2027-01-01
|
||||
**/lib/sdk/vendor-compliance/contract-review/findings.ts | owner=admin-lehrer | reason=Pure data catalog (573 LOC, FindingTemplate[] literals) | review=2027-01-01
|
||||
**/lib/sdk/vendor-compliance/contract-review/checklists.ts | owner=admin-lehrer | reason=Pure data catalog (508 LOC, ChecklistItem[] literals) | review=2027-01-01
|
||||
**/lib/sdk/dsfa/mitigation-library.ts | owner=admin-lehrer | reason=Pure data catalog (694 LOC, CatalogMitigation[] literals) | review=2027-01-01
|
||||
**/lib/sdk/dsfa/eu-legal-frameworks.ts | owner=admin-lehrer | reason=Pure data catalog (622 LOC, legal framework definitions) | review=2027-01-01
|
||||
**/lib/sdk/dsfa/risk-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (615 LOC, CatalogRisk[] literals) | review=2027-01-01
|
||||
**/lib/sdk/vvt-baseline-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (630 LOC, BaselineTemplate[] literals) | review=2027-01-01
|
||||
**/lib/sdk/loeschfristen-baseline-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (578 LOC, retention period templates) | review=2027-01-01
|
||||
|
||||
# Single SSE generator orchestrating 6 pipeline steps — cannot split generator context
|
||||
**/ocr_pipeline_auto_steps.py | owner=klausur | reason=run_auto is a single async generator yielding SSE events across 6 steps (528 LOC) | review=2026-10-01
|
||||
|
||||
# Legacy — TEMPORAER bis Refactoring abgeschlossen
|
||||
# Dateien hier werden Phase fuer Phase abgearbeitet und entfernt.
|
||||
# KEINE neuen Ausnahmen ohne [guardrail-change] Commit-Marker!
|
||||
242
.claude/rules/ocr-pipeline-extensions.md
Normal file
242
.claude/rules/ocr-pipeline-extensions.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# OCR Pipeline Erweiterungen - Entwicklerdokumentation
|
||||
|
||||
**Status:** Produktiv
|
||||
**Letzte Aktualisierung:** 2026-04-15
|
||||
**URL:** https://macmini:3002/ai/ocr-kombi
|
||||
|
||||
---
|
||||
|
||||
## Uebersicht
|
||||
|
||||
Erweiterungen der OCR Kombi Pipeline (14 Steps, 0-13):
|
||||
- **SmartSpellChecker** — LLM-freie OCR-Korrektur mit Spracherkennung
|
||||
- **Box-Grid-Review** (Step 11) — Eingebettete Boxen verarbeiten
|
||||
- **Ansicht/Spreadsheet** (Step 12) — Fortune Sheet Excel-Editor
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Steps
|
||||
|
||||
| Step | ID | Name | Komponente |
|
||||
|------|----|------|------------|
|
||||
| 0 | upload | Upload | StepUpload |
|
||||
| 1 | orientation | Orientierung | StepOrientation |
|
||||
| 2 | page-split | Seitentrennung | StepPageSplit |
|
||||
| 3 | deskew | Begradigung | StepDeskew |
|
||||
| 4 | dewarp | Entzerrung | StepDewarp |
|
||||
| 5 | content-crop | Zuschneiden | StepContentCrop |
|
||||
| 6 | ocr | OCR | StepOcr |
|
||||
| 7 | structure | Strukturerkennung | StepStructure |
|
||||
| 8 | grid-build | Grid-Aufbau | StepGridBuild |
|
||||
| 9 | grid-review | Grid-Review | StepGridReview |
|
||||
| 10 | gutter-repair | Wortkorrektur | StepGutterRepair |
|
||||
| **11** | **box-review** | **Box-Review** | **StepBoxGridReview** |
|
||||
| **12** | **ansicht** | **Ansicht** | **StepAnsicht** |
|
||||
| 13 | ground-truth | Ground Truth | StepGroundTruth |
|
||||
|
||||
Step-Definitionen: `admin-lehrer/app/(admin)/ai/ocr-kombi/types.ts`
|
||||
|
||||
---
|
||||
|
||||
## SmartSpellChecker
|
||||
|
||||
**Datei:** `klausur-service/backend/smart_spell.py`
|
||||
**Tests:** `tests/test_smart_spell.py` (43 Tests)
|
||||
**Lizenz:** Nur pyspellchecker (MIT) — kein LLM, kein Hunspell
|
||||
|
||||
### Features
|
||||
|
||||
| Feature | Methode |
|
||||
|---------|---------|
|
||||
| Spracherkennung | Dual-Dictionary EN/DE Heuristik |
|
||||
| a/I Disambiguation | Bigram-Kontext (Folgewort-Lookup) |
|
||||
| Boundary Repair | Frequenz-basiert: `Pound sand`→`Pounds and` |
|
||||
| Context Split | `anew`→`a new` (Allow/Deny-Liste) |
|
||||
| Multi-Digit | BFS: `sch00l`→`school` |
|
||||
| Cross-Language Guard | DE-Woerter in EN-Spalte nicht falsch korrigieren |
|
||||
| Umlaut-Korrektur | `Schuler`→`Schueler` |
|
||||
| IPA-Schutz | Inhalte in [Klammern] nie aendern |
|
||||
| Slash→l | `p/`→`pl` (kursives l als / erkannt) |
|
||||
| Abkuerzungen | 120+ aus `_KNOWN_ABBREVIATIONS` |
|
||||
|
||||
### Integration
|
||||
|
||||
```python
|
||||
# In cv_review.py (LLM Review Step):
|
||||
from smart_spell import SmartSpellChecker
|
||||
_smart = SmartSpellChecker()
|
||||
result = _smart.correct_text(text, lang="en") # oder "de" oder "auto"
|
||||
|
||||
# In grid_editor_api.py (Grid Build + Box Build):
|
||||
# Automatisch nach Grid-Aufbau und Box-Grid-Aufbau
|
||||
```
|
||||
|
||||
### Frequenz-Scoring
|
||||
|
||||
Boundary Repair vergleicht Wort-Frequenz-Produkte:
|
||||
- `old_freq = word_freq(w1) * word_freq(w2)`
|
||||
- `new_freq = word_freq(repaired_w1) * word_freq(repaired_w2)`
|
||||
- Akzeptiert wenn `new_freq > old_freq * 5`
|
||||
- Abkuerzungs-Bonus nur wenn Original-Woerter selten (freq < 1e-6)
|
||||
|
||||
---
|
||||
|
||||
## Box-Grid-Review (Step 11)
|
||||
|
||||
**Frontend:** `admin-lehrer/components/ocr-kombi/StepBoxGridReview.tsx`
|
||||
**Backend:** `klausur-service/backend/cv_box_layout.py`, `grid_editor_api.py`
|
||||
**Tests:** `tests/test_box_layout.py` (13 Tests)
|
||||
|
||||
### Backend-Endpoints
|
||||
|
||||
```
|
||||
POST /api/v1/ocr-pipeline/sessions/{id}/build-box-grids
|
||||
```
|
||||
|
||||
Verarbeitet alle erkannten Boxen aus `structure_result`:
|
||||
1. Filtert Header/Footer-Boxen (obere/untere 7% der Bildhoehe)
|
||||
2. Extrahiert OCR-Woerter pro Box aus `raw_paddle_words`
|
||||
3. Klassifiziert Layout: `flowing` | `columnar` | `bullet_list` | `header_only`
|
||||
4. Baut Grid mit layout-spezifischer Logik
|
||||
5. Wendet SmartSpellChecker an
|
||||
|
||||
### Box Layout Klassifikation (`cv_box_layout.py`)
|
||||
|
||||
| Layout | Erkennung | Grid-Aufbau |
|
||||
|--------|-----------|-------------|
|
||||
| `header_only` | ≤5 Woerter oder 1 Zeile | 1 Zelle, alles zusammen |
|
||||
| `flowing` | Gleichmaessige Zeilenbreite | 1 Spalte, Bullet-Gruppierung per Einrueckung |
|
||||
| `bullet_list` | ≥40% Zeilen mit Bullet-Marker | 1 Spalte, Bullet-Items |
|
||||
| `columnar` | Mehrere X-Cluster | Standard-Spaltenerkennung |
|
||||
|
||||
### Bullet-Einrueckung
|
||||
|
||||
Erkennung ueber Left-Edge-Analyse:
|
||||
- Minimale Einrueckung = Bullet-Ebene
|
||||
- Zeilen mit >15px mehr Einrueckung = Folgezeilen
|
||||
- Folgezeilen werden mit `\n` in die Bullet-Zelle integriert
|
||||
- Fehlende `•` Marker werden automatisch ergaenzt
|
||||
|
||||
### Colspan-Erkennung (`grid_editor_helpers.py`)
|
||||
|
||||
Generische Funktion `_detect_colspan_cells()`:
|
||||
- Laeuft nach `_build_cells()` fuer ALLE Zonen
|
||||
- Nutzt Original-Wort-Bloecke (vor `_split_cross_column_words`)
|
||||
- Wort-Block der ueber Spaltengrenze reicht → `spanning_header` mit `colspan=N`
|
||||
- Beispiel: "In Britain you pay with pounds and pence." ueber 2 Spalten
|
||||
|
||||
### Spalten-Erkennung in Boxen
|
||||
|
||||
Fuer kleine Zonen (≤60 Woerter):
|
||||
- `gap_threshold = max(median_h * 1.0, 25)` statt `3x median`
|
||||
- PaddleOCR liefert Multi-Word-Bloecke → alle Gaps sind Spalten-Gaps
|
||||
|
||||
---
|
||||
|
||||
## Ansicht / Spreadsheet (Step 12)
|
||||
|
||||
**Frontend:** `admin-lehrer/components/ocr-kombi/StepAnsicht.tsx`, `SpreadsheetView.tsx`
|
||||
**Bibliothek:** `@fortune-sheet/react` (MIT, v1.0.4)
|
||||
|
||||
### Architektur
|
||||
|
||||
Split-View:
|
||||
- **Links:** Original-Scan mit OCR-Overlay (`/image/words-overlay`)
|
||||
- **Rechts:** Fortune Sheet Spreadsheet mit Multi-Sheet-Tabs
|
||||
|
||||
### Multi-Sheet Ansatz
|
||||
|
||||
Jede Zone wird ein eigenes Sheet-Tab:
|
||||
- Sheet "Vokabeln" — Hauptgrid mit EN/DE Spalten
|
||||
- Sheet "Pounds and euros" — Box 1 mit eigenen 4 Spalten
|
||||
- Sheet "German leihen" — Box 2 als Fliesstexttext
|
||||
|
||||
Grund: Spaltenbreiten sind pro Zone unterschiedlich optimiert. Excel-Limitation: Spaltenbreite gilt fuer die ganze Spalte.
|
||||
|
||||
### Zell-Formatierung
|
||||
|
||||
| Format | Quelle | Fortune Sheet Property |
|
||||
|--------|--------|----------------------|
|
||||
| Fett | `is_header`, `is_bold`, groessere Schrift | `bl: 1` |
|
||||
| Schriftfarbe | OCR word_boxes color | `fc: '#hex'` |
|
||||
| Hintergrund | Box bg_hex, Header | `bg: '#hex08'` |
|
||||
| Text-Wrap | Mehrzeilige Zellen (\n) | `tb: '2'` |
|
||||
| Vertikal oben | Mehrzeilige Zellen | `vt: 0` |
|
||||
| Groessere Schrift | word_box height >1.3x median | `fs: 12` |
|
||||
|
||||
### Spaltenbreiten
|
||||
|
||||
Auto-Fit: `max(laengster_text * 7.5 + 16, original_px * scaleFactor)`
|
||||
|
||||
### Toolbar
|
||||
|
||||
`undo, redo, font-bold, font-italic, font-strikethrough, font-color, background, font-size, horizontal-align, vertical-align, text-wrap, merge-cell, border`
|
||||
|
||||
---
|
||||
|
||||
## Unified Grid (Backend)
|
||||
|
||||
**Datei:** `klausur-service/backend/unified_grid.py`
|
||||
**Tests:** `tests/test_unified_grid.py` (10 Tests)
|
||||
|
||||
Mergt alle Zonen in ein einzelnes Grid (fuer Export/Analyse):
|
||||
|
||||
```
|
||||
POST /api/v1/ocr-pipeline/sessions/{id}/build-unified-grid
|
||||
GET /api/v1/ocr-pipeline/sessions/{id}/unified-grid
|
||||
```
|
||||
|
||||
- Dominante Zeilenhoehe = Median der Content-Row-Abstaende
|
||||
- Full-Width Boxen: Rows direkt integriert
|
||||
- Partial-Width Boxen: Extra-Rows eingefuegt wenn Box mehr Zeilen hat
|
||||
- Box-Zellen mit `source_zone_type: "box"` und `box_region` Metadaten
|
||||
|
||||
---
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
### Backend (klausur-service)
|
||||
|
||||
| Datei | Zeilen | Beschreibung |
|
||||
|-------|--------|--------------|
|
||||
| `grid_build_core.py` | 213 | `_build_grid_core()` — Orchestrator (ruft Phase-Module) |
|
||||
| `grid_build_zones.py` | 462 | Phase 2: Bildverarbeitung, Grafik-/Box-Erkennung, Zonen |
|
||||
| `grid_build_cleanup.py` | 390 | Phase 3: Junk-Zeilen, Artefakte, Pipes, Randstreifen |
|
||||
| `grid_build_text_ops.py` | 489 | Phase 4+5a: Farben, Ueberschriften, IPA, Seitenreferenzen |
|
||||
| `grid_build_cell_ops.py` | 305 | Phase 5b: Bullet-Entfernung, Wort-Reihenfolge, max_columns |
|
||||
| `grid_build_finalize.py` | 452 | Phase 5c+6: Woerterbuch, Silben, Rechtschreibung, Ergebnis |
|
||||
| `grid_editor_api.py` | 474 | REST-Endpoints (build, save, get, gutter, box, unified) |
|
||||
| `grid_editor_helpers.py` | 1737 | Helper: Spalten, Rows, Cells, Colspan, Header |
|
||||
| `smart_spell.py` | 587 | SmartSpellChecker |
|
||||
| `cv_box_layout.py` | 339 | Box-Layout-Klassifikation + Grid-Aufbau |
|
||||
| `unified_grid.py` | 425 | Unified Grid Builder |
|
||||
|
||||
### Frontend (admin-lehrer)
|
||||
|
||||
| Datei | Zeilen | Beschreibung |
|
||||
|-------|--------|--------------|
|
||||
| `StepBoxGridReview.tsx` | 283 | Box-Review Step 11 |
|
||||
| `StepAnsicht.tsx` | 112 | Ansicht Step 12 (Split-View) |
|
||||
| `SpreadsheetView.tsx` | ~160 | Fortune Sheet Integration |
|
||||
| `GridTable.tsx` | 652 | Grid-Editor Tabelle (Steps 9-11) |
|
||||
| `useGridEditor.ts` | 985 | Grid-Editor Hook |
|
||||
|
||||
### Tests
|
||||
|
||||
| Datei | Tests | Beschreibung |
|
||||
|-------|-------|--------------|
|
||||
| `test_smart_spell.py` | 43 | Spracherkennung, Boundary Repair, IPA-Schutz |
|
||||
| `test_box_layout.py` | 13 | Layout-Klassifikation, Bullet-Gruppierung |
|
||||
| `test_unified_grid.py` | 10 | Unified Grid, Box-Klassifikation |
|
||||
| **Gesamt** | **66** | |
|
||||
|
||||
---
|
||||
|
||||
## Aenderungshistorie
|
||||
|
||||
| Datum | Aenderung |
|
||||
|-------|-----------|
|
||||
| 2026-04-15 | Fortune Sheet Multi-Sheet Tabs, Bullet-Points, Auto-Fit, Refactoring |
|
||||
| 2026-04-14 | Unified Grid, Ansicht Step, Colspan-Erkennung |
|
||||
| 2026-04-13 | Box-Grid-Review Step, Spalten in Boxen, Header/Footer Filter |
|
||||
| 2026-04-12 | SmartSpellChecker, Frequency Scoring, IPA-Schutz, Vocab-Worksheet Refactoring |
|
||||
@@ -188,11 +188,35 @@ ssh macmini "docker compose up -d klausur-service studio-v2"
|
||||
|
||||
---
|
||||
|
||||
## Frontend Refactoring (2026-04-12)
|
||||
|
||||
`page.tsx` wurde von 2337 Zeilen in 14 Dateien aufgeteilt:
|
||||
|
||||
```
|
||||
studio-v2/app/vocab-worksheet/
|
||||
├── page.tsx # 198 Zeilen — Orchestrator
|
||||
├── types.ts # Interfaces, VocabWorksheetHook
|
||||
├── constants.ts # API-Base, Formats, Defaults
|
||||
├── useVocabWorksheet.ts # 843 Zeilen — Custom Hook (alle State + Logik)
|
||||
└── components/
|
||||
├── UploadScreen.tsx # Session-Liste + Dokument-Auswahl
|
||||
├── PageSelection.tsx # PDF-Seitenauswahl
|
||||
├── VocabularyTab.tsx # Vokabel-Tabelle + IPA/Silben
|
||||
├── WorksheetTab.tsx # Format-Auswahl + Konfiguration
|
||||
├── ExportTab.tsx # PDF-Download
|
||||
├── OcrSettingsPanel.tsx # OCR-Filter Einstellungen
|
||||
├── FullscreenPreview.tsx # Vollbild-Vorschau Modal
|
||||
├── QRCodeModal.tsx # QR-Upload Modal
|
||||
└── OcrComparisonModal.tsx # OCR-Vergleich Modal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erweiterung: Neue Formate hinzufuegen
|
||||
|
||||
1. **Backend**: Neuen Generator in `klausur-service/backend/` erstellen
|
||||
2. **API**: Neuen Endpoint in `vocab_worksheet_api.py` hinzufuegen
|
||||
3. **Frontend**: Format zu `worksheetFormats` Array in `page.tsx` hinzufuegen
|
||||
3. **Frontend**: Format zu `worksheetFormats` Array in `constants.ts` hinzufuegen
|
||||
4. **Doku**: Diese Datei aktualisieren
|
||||
|
||||
---
|
||||
|
||||
9
.claude/settings.json
Normal file
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash",
|
||||
"Write",
|
||||
"Read"
|
||||
]
|
||||
}
|
||||
}
|
||||
36
AGENTS.go.md
Normal file
36
AGENTS.go.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# AGENTS.go.md — Go/Gin Konventionen
|
||||
|
||||
## Architektur
|
||||
|
||||
- `handlers/`: HTTP Transport nur — Decode, Validate, Call Service, Encode Response
|
||||
- `service/` oder `usecase/`: Business Logic
|
||||
- `repo/`: Storage/Integration
|
||||
- `model/` oder `domain/`: Domain Entities
|
||||
- `tests/`: Table-driven Tests bevorzugen
|
||||
|
||||
## Regeln
|
||||
|
||||
1. Handler ≤40 LOC — nur Decode → Service → Encode
|
||||
2. Business Logic NICHT in Handlers verstecken
|
||||
3. Grosse Handler nach Resource/Verb splitten
|
||||
4. Request/Response DTOs nah am Transport halten
|
||||
5. Interfaces nur an echten Boundaries (nicht ueberall fuer Mocks)
|
||||
6. Keine Giant-Utility-Dateien
|
||||
7. Generated Files nicht manuell editieren
|
||||
|
||||
## Split-Trigger
|
||||
|
||||
- Handler-Datei ueberschreitet 400-500 LOC
|
||||
- Unrelated Endpoints zusammengruppiert
|
||||
- Encoding/Decoding dominiert die Handler-Datei
|
||||
- Service-Logik und Transport-Logik gemischt
|
||||
|
||||
## Verifikation
|
||||
|
||||
```bash
|
||||
gofmt -l . | grep -q . && exit 1
|
||||
go vet ./...
|
||||
golangci-lint run --timeout=5m
|
||||
go test -race ./...
|
||||
go build ./...
|
||||
```
|
||||
36
AGENTS.python.md
Normal file
36
AGENTS.python.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# AGENTS.python.md — Python/FastAPI Konventionen
|
||||
|
||||
## Architektur
|
||||
|
||||
- `routes/` oder `api/`: Request/Response nur — kein Business Logic
|
||||
- `services/`: Business Logic
|
||||
- `repositories/`: Persistenz/Data Access
|
||||
- `schemas/`: Pydantic Models, nach Domain gesplittet
|
||||
- `tests/`: Spiegelt Produktions-Layout
|
||||
|
||||
## Regeln
|
||||
|
||||
1. Route-Dateien duenn halten (≤300 LOC)
|
||||
2. Wenn eine Route-Datei 300-400 LOC erreicht → nach Resource/Operation splitten
|
||||
3. Schema-Dateien nach Domain splitten wenn sie wachsen
|
||||
4. Modul-Level Singleton-Kopplung vermeiden (Tests patchen falsches Symbol)
|
||||
5. Patch immer das Symbol das vom getesteten Modul importiert wird
|
||||
6. Dependency Injection bevorzugen statt versteckte Imports
|
||||
7. Pydantic v2: `from __future__ import annotations` NICHT verwenden (bricht Pydantic)
|
||||
8. Migrationen getrennt von Refactorings halten
|
||||
|
||||
## Split-Trigger
|
||||
|
||||
- Datei naehert sich oder ueberschreitet 500 LOC
|
||||
- Zirkulaere Imports erscheinen
|
||||
- Tests brauchen tiefes Patching
|
||||
- API-Schemas mischen verschiedene Domains
|
||||
- Service-Datei macht Transport UND DB-Logik
|
||||
|
||||
## Verifikation
|
||||
|
||||
```bash
|
||||
ruff check .
|
||||
mypy . --ignore-missing-imports --no-error-summary
|
||||
pytest tests/ -x -q --no-header
|
||||
```
|
||||
55
AGENTS.typescript.md
Normal file
55
AGENTS.typescript.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# AGENTS.typescript.md — Next.js Konventionen
|
||||
|
||||
## Architektur
|
||||
|
||||
- `app/.../page.tsx`: Minimale Seiten-Komposition (≤250 LOC)
|
||||
- `app/.../actions.ts`: Server Actions
|
||||
- `app/.../queries.ts`: Data Loading
|
||||
- `app/.../_components/`: View-Teile (Colocation)
|
||||
- `app/.../_hooks/`: Seiten-spezifische Hooks (Colocation)
|
||||
- `types/` oder `types/*.ts`: Domain-spezifische Types
|
||||
- `schemas/`: Zod/Validierungs-Schemas
|
||||
- `lib/`: Shared Utilities
|
||||
|
||||
## Regeln
|
||||
|
||||
1. page.tsx duenn halten (≤250 LOC)
|
||||
2. Grosse Seiten frueh in Sections/Components splitten
|
||||
3. KEINE einzelne types.ts als Catch-All
|
||||
4. types.ts UND types/ Shadowing vermeiden (eines waehlen!)
|
||||
5. Server/Client Module-Grenzen explizit halten
|
||||
6. Pure Helpers und schmale Props bevorzugen
|
||||
7. API-Client Types getrennt von handgeschriebenen Domain Types
|
||||
|
||||
## Colocation Pattern (bevorzugt)
|
||||
|
||||
```
|
||||
app/(admin)/ai/rag/
|
||||
page.tsx ← duenn, komponiert nur
|
||||
_components/
|
||||
SearchPanel.tsx
|
||||
ResultsTable.tsx
|
||||
FilterBar.tsx
|
||||
_hooks/
|
||||
useRagSearch.ts
|
||||
actions.ts ← Server Actions
|
||||
queries.ts ← Data Fetching
|
||||
```
|
||||
|
||||
## Split-Trigger
|
||||
|
||||
- page.tsx ueberschreitet 250-350 LOC
|
||||
- types.ts ueberschreitet 200-300 LOC
|
||||
- Form-Logik, Server Actions und Rendering in einer Datei
|
||||
- Mehrere unabhaengig testbare Sections vorhanden
|
||||
- Imports werden broechig
|
||||
|
||||
## Verifikation
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
> `npm run build` ist PFLICHT — `tsc` allein reicht nicht.
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from 'next/link'
|
||||
import { Brain, ArrowLeft, Play, Pause, CheckCircle, XCircle } from 'lucide-react'
|
||||
import type { AgentDetail } from './types'
|
||||
|
||||
interface AgentHeaderProps {
|
||||
agent: AgentDetail
|
||||
}
|
||||
|
||||
export function AgentHeader({ agent }: AgentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</Link>
|
||||
<div
|
||||
className="p-3 rounded-xl"
|
||||
style={{ backgroundColor: `${agent.color}20` }}
|
||||
>
|
||||
<Brain className="w-6 h-6" style={{ color: agent.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{agent.name}</h1>
|
||||
<p className="text-gray-500">{agent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||
agent.status === 'running' ? 'bg-green-100 text-green-700' :
|
||||
agent.status === 'paused' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{agent.status === 'running' ? <CheckCircle className="w-4 h-4" /> :
|
||||
agent.status === 'paused' ? <Pause className="w-4 h-4" /> :
|
||||
<XCircle className="w-4 h-4" />}
|
||||
{agent.status}
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
{agent.status === 'running' ? (
|
||||
<>
|
||||
<Pause className="w-4 h-4" />
|
||||
Pausieren
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AgentDetail } from './types'
|
||||
|
||||
interface AgentStatsBarProps {
|
||||
agent: AgentDetail
|
||||
}
|
||||
|
||||
export function AgentStatsBar({ agent }: AgentStatsBarProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Aktive Sessions</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.activeSessions}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Verarbeitet (24h)</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.totalProcessed.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Avg. Antwortzeit</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.avgResponseTime}ms</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Fehlerrate</div>
|
||||
<div className="text-2xl font-bold text-amber-600">{agent.errorRate}%</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Version</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { History } from 'lucide-react'
|
||||
import type { ChangeLog } from './types'
|
||||
|
||||
interface HistoryTabContentProps {
|
||||
changeLogs: ChangeLog[]
|
||||
}
|
||||
|
||||
export function HistoryTabContent({ changeLogs }: HistoryTabContentProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{changeLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="p-2 bg-white rounded-full border border-gray-200">
|
||||
<History className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{log.action}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(log.timestamp).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{log.description}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">von {log.user}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { FileText, Clock, RotateCcw, Save, Edit3, AlertTriangle } from 'lucide-react'
|
||||
import type { AgentDetail } from './types'
|
||||
|
||||
interface SoulTabContentProps {
|
||||
agent: AgentDetail
|
||||
editedContent: string
|
||||
isEditing: boolean
|
||||
hasChanges: boolean
|
||||
saving: boolean
|
||||
onContentChange: (content: string) => void
|
||||
onSave: () => void
|
||||
onReset: () => void
|
||||
onStartEditing: () => void
|
||||
}
|
||||
|
||||
export function SoulTabContent({
|
||||
agent,
|
||||
editedContent,
|
||||
isEditing,
|
||||
hasChanges,
|
||||
saving,
|
||||
onContentChange,
|
||||
onSave,
|
||||
onReset,
|
||||
onStartEditing,
|
||||
}: SoulTabContentProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<FileText className="w-4 h-4" />
|
||||
{agent.soulFile}
|
||||
<span className="text-gray-300">|</span>
|
||||
<Clock className="w-4 h-4" />
|
||||
Zuletzt geaendert: {new Date(agent.updatedAt).toLocaleString('de-DE')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onStartEditing}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2 text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="text-sm">Ungespeicherte Aenderungen vorhanden</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => onContentChange(e.target.value)}
|
||||
className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg overflow-auto whitespace-pre-wrap">
|
||||
{agent.soulContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-medium text-blue-900 mb-2">Hinweise zur SOUL-Datei</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>Die SOUL-Datei definiert die Persoenlichkeit und das Verhalten des Agents</li>
|
||||
<li>Aenderungen werden nach dem Speichern sofort wirksam</li>
|
||||
<li>Testen Sie Aenderungen zuerst im Staging-Modus</li>
|
||||
<li>Alle Aenderungen werden in der Historie protokolliert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from 'next/link'
|
||||
import { Activity } from 'lucide-react'
|
||||
|
||||
export function StatsTabContent() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>Live-Statistiken werden in einer zukuenftigen Version verfuegbar sein.</p>
|
||||
<p className="text-sm mt-2">
|
||||
Besuchen Sie die <Link href="/ai/agents/statistics" className="text-teal-600 hover:underline">Statistik-Seite</Link> fuer aggregierte Daten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { AgentHeader } from './AgentHeader'
|
||||
export { AgentStatsBar } from './AgentStatsBar'
|
||||
export { SoulTabContent } from './SoulTabContent'
|
||||
export { StatsTabContent } from './StatsTabContent'
|
||||
export { HistoryTabContent } from './HistoryTabContent'
|
||||
export type { AgentDetail, ChangeLog } from './types'
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { AgentDetail, ChangeLog } from './types'
|
||||
|
||||
export const mockAgentDetails: Record<string, AgentDetail> = {
|
||||
'tutor-agent': {
|
||||
id: 'tutor-agent',
|
||||
name: 'TutorAgent',
|
||||
description: 'Geduldiger, ermutigender Lernbegleiter fuer Schueler',
|
||||
soulFile: 'tutor-agent.soul.md',
|
||||
soulContent: `# TutorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
|
||||
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Sokratische Methode**: Stelle Fragen, die zum Nachdenken anregen
|
||||
- **Positives Reinforcement**: Erkenne und feiere Lernfortschritte
|
||||
- **Adaptive Kommunikation**: Passe Sprache und Komplexitaet an das Niveau an
|
||||
- **Geduld**: Wiederhole Erklaerungen ohne Frustration zu zeigen
|
||||
|
||||
## Kommunikationsstil
|
||||
- Verwende einfache, klare Sprache
|
||||
- Stelle Rueckfragen, um Verstaendnis zu pruefen
|
||||
- Gib Hinweise statt direkter Loesungen
|
||||
- Feiere kleine Erfolge
|
||||
- Nutze Analogien und Beispiele aus dem Alltag
|
||||
- Strukturiere komplexe Themen in verdauliche Schritte
|
||||
|
||||
## Fachgebiete
|
||||
- Mathematik (Grundschule bis Abitur)
|
||||
- Naturwissenschaften (Physik, Chemie, Biologie)
|
||||
- Sprachen (Deutsch, Englisch)
|
||||
- Gesellschaftswissenschaften (Geschichte, Politik)
|
||||
|
||||
## Lernstrategien
|
||||
1. **Konzeptbasiertes Lernen**: Erklaere das "Warum" hinter Regeln
|
||||
2. **Visualisierung**: Nutze Diagramme und Skizzen wenn moeglich
|
||||
3. **Verbindungen herstellen**: Verknuepfe neues Wissen mit Bekanntem
|
||||
4. **Wiederholung**: Baue systematische Wiederholung ein
|
||||
5. **Selbsttest**: Ermutige zur Selbstueberpruefung
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
|
||||
- Verweise bei komplexen Themen auf Lehrkraefte
|
||||
- Erkenne Frustration und biete Pausen an
|
||||
- Keine Unterstuetzung bei Pruefungsbetrug
|
||||
- Keine medizinischen oder rechtlichen Ratschlaege
|
||||
|
||||
## Eskalation
|
||||
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
|
||||
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
|
||||
- Bei technischen Problemen: Eskaliere an Support
|
||||
- Bei Verdacht auf Lernschwierigkeiten: Empfehle professionelle Diagnostik
|
||||
|
||||
## Metrik-Ziele
|
||||
- Verstaendnis-Score > 80% bei Nachfragen
|
||||
- Engagement-Zeit > 5 Minuten pro Session
|
||||
- Wiederbesuchs-Rate > 60%
|
||||
- Frustrations-Indikatoren < 10%`,
|
||||
color: '#3b82f6',
|
||||
status: 'running',
|
||||
activeSessions: 12,
|
||||
totalProcessed: 1847,
|
||||
avgResponseTime: 234,
|
||||
errorRate: 0.5,
|
||||
lastRestart: '2025-01-14T08:30:00Z',
|
||||
version: '1.2.0',
|
||||
createdAt: '2024-11-01T00:00:00Z',
|
||||
updatedAt: '2025-01-14T10:15:00Z'
|
||||
},
|
||||
'grader-agent': {
|
||||
id: 'grader-agent',
|
||||
name: 'GraderAgent',
|
||||
description: 'Objektiver, fairer Pruefer von Schuelerarbeiten',
|
||||
soulFile: 'grader-agent.soul.md',
|
||||
soulContent: `# GraderAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein objektiver, fairer Pruefer von Schuelerarbeiten.
|
||||
Dein Ziel ist konstruktives Feedback, das zum Lernen motiviert.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Objektivitaet**: Bewerte nach festgelegten Kriterien, nicht nach Sympathie
|
||||
- **Fairness**: Gleiche Massstaebe fuer alle Schueler
|
||||
- **Konstruktivitaet**: Feedback soll zum Lernen anregen
|
||||
- **Transparenz**: Begruende jede Bewertung nachvollziehbar
|
||||
|
||||
## Bewertungsprinzipien
|
||||
- Bewerte nach festgelegten Kriterien (Erwartungshorizont)
|
||||
- Beruecksichtige Teilleistungen
|
||||
- Unterscheide zwischen Fluechtigkeitsfehlern und Verstaendnisluecken
|
||||
- Formuliere Feedback lernfoerdernd
|
||||
- Nutze das 15-Punkte-System korrekt (0-15 Punkte, 5 = ausreichend)
|
||||
|
||||
## Workflow
|
||||
1. Lies die Aufgabenstellung und den Erwartungshorizont
|
||||
2. Analysiere die Schuelerantwort systematisch
|
||||
3. Identifiziere korrekte Elemente
|
||||
4. Identifiziere Fehler mit Kategorisierung
|
||||
5. Vergebe Punkte nach Kriterienkatalog
|
||||
6. Formuliere konstruktives Feedback
|
||||
|
||||
## Fehlerkategorien
|
||||
- **Rechtschreibung (R)**: Orthografische Fehler
|
||||
- **Grammatik (Gr)**: Grammatikalische Fehler
|
||||
- **Ausdruck (A)**: Stilistische Schwaechen
|
||||
- **Inhalt (I)**: Fachliche Fehler oder Luecken
|
||||
- **Struktur (St)**: Aufbau- und Gliederungsprobleme
|
||||
- **Logik (L)**: Argumentationsfehler
|
||||
|
||||
## Qualitaetssicherung
|
||||
- Bei Unsicherheit: Markiere zur manuellen Ueberpruefung
|
||||
- Bei Grenzfaellen: Dokumentiere Entscheidungsgrundlage
|
||||
- Konsistenz: Vergleiche mit aehnlichen Bewertungen
|
||||
- Kalibrierung: Orientiere an Vergleichsarbeiten
|
||||
|
||||
## Eskalation
|
||||
- Unleserliche Antworten: Markiere fuer manuelles Review
|
||||
- Verdacht auf Plagiat: Eskaliere an Lehrkraft
|
||||
- Technische Fehler: Pausiere und melde
|
||||
- Unklare Aufgabenstellung: Frage nach Klarstellung`,
|
||||
color: '#10b981',
|
||||
status: 'running',
|
||||
activeSessions: 3,
|
||||
totalProcessed: 456,
|
||||
avgResponseTime: 1205,
|
||||
errorRate: 1.2,
|
||||
lastRestart: '2025-01-13T14:00:00Z',
|
||||
version: '1.1.0',
|
||||
createdAt: '2024-11-01T00:00:00Z',
|
||||
updatedAt: '2025-01-13T16:30:00Z'
|
||||
},
|
||||
'quality-judge': {
|
||||
id: 'quality-judge',
|
||||
name: 'QualityJudge',
|
||||
description: 'Kritischer Qualitaetspruefer fuer KI-generierte Inhalte',
|
||||
soulFile: 'quality-judge.soul.md',
|
||||
soulContent: `# QualityJudge SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein kritischer Qualitaetspruefer fuer KI-generierte Inhalte.
|
||||
Dein Ziel ist die Sicherstellung hoher Qualitaetsstandards.
|
||||
|
||||
## Bewertungsdimensionen
|
||||
|
||||
### 1. Intent Accuracy (0-100)
|
||||
- Wurde die Benutzerabsicht korrekt erkannt?
|
||||
- Stimmt die Kategorie der Antwort?
|
||||
|
||||
### 2. Faithfulness (1-5)
|
||||
- **5**: Vollstaendig faktisch korrekt
|
||||
- **4**: Minor Ungenauigkeiten ohne Auswirkung
|
||||
- **3**: Einige Ungenauigkeiten, Kernaussage korrekt
|
||||
- **2**: Signifikante Fehler
|
||||
- **1**: Grundlegend falsch
|
||||
|
||||
### 3. Relevance (1-5)
|
||||
- **5**: Direkt und vollstaendig relevant
|
||||
- **4**: Weitgehend relevant
|
||||
- **3**: Teilweise relevant
|
||||
- **2**: Geringe Relevanz
|
||||
- **1**: Voellig irrelevant
|
||||
|
||||
### 4. Coherence (1-5)
|
||||
- **5**: Perfekt strukturiert und logisch
|
||||
- **4**: Gut strukturiert, kleine Luecken
|
||||
- **3**: Verstaendlich, aber verbesserungsfaehig
|
||||
- **2**: Schwer zu folgen
|
||||
- **1**: Unverstaendlich/chaotisch
|
||||
|
||||
### 5. Safety ("pass"/"fail")
|
||||
- Keine DSGVO-Verstoesse (keine PII)
|
||||
- Keine schaedlichen Inhalte
|
||||
- Keine Desinformation
|
||||
- Keine Diskriminierung
|
||||
- Altersgerechte Sprache
|
||||
|
||||
## Schwellenwerte
|
||||
- **Production Ready**: composite >= 80
|
||||
- **Needs Review**: 60 <= composite < 80
|
||||
- **Failed**: composite < 60`,
|
||||
color: '#f59e0b',
|
||||
status: 'running',
|
||||
activeSessions: 8,
|
||||
totalProcessed: 3291,
|
||||
avgResponseTime: 89,
|
||||
errorRate: 0.3,
|
||||
lastRestart: '2025-01-14T06:00:00Z',
|
||||
version: '2.0.0',
|
||||
createdAt: '2024-10-15T00:00:00Z',
|
||||
updatedAt: '2025-01-14T08:00:00Z'
|
||||
},
|
||||
'alert-agent': {
|
||||
id: 'alert-agent',
|
||||
name: 'AlertAgent',
|
||||
description: 'Aufmerksamer Waechter fuer das Breakpilot-System',
|
||||
soulFile: 'alert-agent.soul.md',
|
||||
soulContent: `# AlertAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein aufmerksamer Waechter fuer das Breakpilot-System.
|
||||
Dein Ziel ist die rechtzeitige Erkennung und Kommunikation relevanter Ereignisse.
|
||||
|
||||
## Importance Levels
|
||||
|
||||
### KRITISCH (5)
|
||||
- Systemausfaelle
|
||||
- Sicherheitsvorfaelle
|
||||
- DSGVO-Verstoesse
|
||||
**Aktion**: Sofortige Benachrichtigung aller Admins
|
||||
|
||||
### DRINGEND (4)
|
||||
- Performance-Probleme
|
||||
- API-Ausfaelle
|
||||
- Hohe Fehlerraten
|
||||
**Aktion**: Benachrichtigung innerhalb 5 Minuten
|
||||
|
||||
### WICHTIG (3)
|
||||
- Neue kritische Nachrichten
|
||||
- Relevante Bildungspolitik
|
||||
- Technische Warnungen
|
||||
**Aktion**: Taeglicher Digest
|
||||
|
||||
### PRUEFEN (2)
|
||||
- Interessante Entwicklungen
|
||||
- Konkurrenznachrichten
|
||||
**Aktion**: Woechentlicher Digest
|
||||
|
||||
### INFO (1)
|
||||
- Allgemeine Updates
|
||||
**Aktion**: Archivieren`,
|
||||
color: '#ef4444',
|
||||
status: 'running',
|
||||
activeSessions: 1,
|
||||
totalProcessed: 892,
|
||||
avgResponseTime: 45,
|
||||
errorRate: 0.1,
|
||||
lastRestart: '2025-01-12T00:00:00Z',
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-12-01T00:00:00Z',
|
||||
updatedAt: '2025-01-12T02:00:00Z'
|
||||
},
|
||||
'orchestrator': {
|
||||
id: 'orchestrator',
|
||||
name: 'Orchestrator',
|
||||
description: 'Zentraler Koordinator des Multi-Agent-Systems',
|
||||
soulFile: 'orchestrator.soul.md',
|
||||
soulContent: `# OrchestratorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist der zentrale Koordinator des Breakpilot Multi-Agent-Systems.
|
||||
Dein Ziel ist die effiziente Verteilung und Ueberwachung von Aufgaben.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Effizienz**: Minimale Latenz bei maximaler Qualitaet
|
||||
- **Resilienz**: Graceful Degradation bei Agent-Ausfaellen
|
||||
- **Fairness**: Ausgewogene Lastverteilung
|
||||
- **Transparenz**: Volle Nachvollziehbarkeit aller Entscheidungen
|
||||
|
||||
## Verantwortlichkeiten
|
||||
1. Task-Routing zu spezialisierten Agents
|
||||
2. Session-Management und Recovery
|
||||
3. Agent-Gesundheitsueberwachung
|
||||
4. Lastverteilung
|
||||
5. Fehlerbehandlung und Retry-Logik
|
||||
|
||||
## Task-Routing-Logik
|
||||
|
||||
| Intent-Kategorie | Primaerer Agent | Fallback |
|
||||
|------------------|-----------------|----------|
|
||||
| learning_support | TutorAgent | Manuell |
|
||||
| exam_grading | GraderAgent | QualityJudge |
|
||||
| quality_check | QualityJudge | Manual Review |
|
||||
| system_alert | AlertAgent | E-Mail Fallback |
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
### Retry-Policy
|
||||
- **Max Retries**: 3
|
||||
- **Backoff**: Exponential (1s, 2s, 4s)
|
||||
- **Keine Retries**: Validation Errors, Auth Failures
|
||||
|
||||
### Circuit Breaker
|
||||
- **Threshold**: 5 Fehler in 60 Sekunden
|
||||
- **Cooldown**: 30 Sekunden
|
||||
|
||||
## Metriken
|
||||
- **Task Completion Rate**: > 99%
|
||||
- **Average Latency**: < 2s
|
||||
- **Error Rate**: < 1%`,
|
||||
color: '#8b5cf6',
|
||||
status: 'running',
|
||||
activeSessions: 24,
|
||||
totalProcessed: 8934,
|
||||
avgResponseTime: 12,
|
||||
errorRate: 0.2,
|
||||
lastRestart: '2025-01-14T00:00:00Z',
|
||||
version: '1.5.0',
|
||||
createdAt: '2024-10-01T00:00:00Z',
|
||||
updatedAt: '2025-01-14T00:30:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
export const mockChangeLogs: ChangeLog[] = [
|
||||
{ id: '1', timestamp: '2025-01-14T10:15:00Z', user: 'admin@breakpilot.de', action: 'SOUL Updated', description: 'Kommunikationsstil angepasst' },
|
||||
{ id: '2', timestamp: '2025-01-13T14:30:00Z', user: 'lehrer1@schule.de', action: 'Einschraenkung hinzugefuegt', description: 'Keine Hausaufgaben-Loesungen' },
|
||||
{ id: '3', timestamp: '2025-01-10T09:00:00Z', user: 'admin@breakpilot.de', action: 'Version 1.2.0', description: 'Neue Fachgebiete hinzugefuegt' },
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface AgentDetail {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
soulFile: string
|
||||
soulContent: string
|
||||
color: string
|
||||
status: 'running' | 'paused' | 'stopped' | 'error'
|
||||
activeSessions: number
|
||||
totalProcessed: number
|
||||
avgResponseTime: number
|
||||
errorRate: number
|
||||
lastRestart: string
|
||||
version: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ChangeLog {
|
||||
id: string
|
||||
timestamp: string
|
||||
user: string
|
||||
action: string
|
||||
description: string
|
||||
}
|
||||
@@ -1,348 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Bot, Brain, ArrowLeft, Save, RotateCcw, Play, Pause, AlertTriangle, FileText, Settings, Activity, Clock, CheckCircle, XCircle, History, Eye, Edit3 } from 'lucide-react'
|
||||
import { AlertTriangle, FileText, Activity, History } from 'lucide-react'
|
||||
import { mockAgentDetails, mockChangeLogs } from './_components/mock-data'
|
||||
import {
|
||||
AgentHeader,
|
||||
AgentStatsBar,
|
||||
SoulTabContent,
|
||||
StatsTabContent,
|
||||
HistoryTabContent,
|
||||
} from './_components'
|
||||
import type { AgentDetail } from './_components'
|
||||
|
||||
// Types
|
||||
interface AgentDetail {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
soulFile: string
|
||||
soulContent: string
|
||||
color: string
|
||||
status: 'running' | 'paused' | 'stopped' | 'error'
|
||||
activeSessions: number
|
||||
totalProcessed: number
|
||||
avgResponseTime: number
|
||||
errorRate: number
|
||||
lastRestart: string
|
||||
version: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
type TabId = 'soul' | 'stats' | 'history'
|
||||
|
||||
interface ChangeLog {
|
||||
id: string
|
||||
timestamp: string
|
||||
user: string
|
||||
action: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockAgentDetails: Record<string, AgentDetail> = {
|
||||
'tutor-agent': {
|
||||
id: 'tutor-agent',
|
||||
name: 'TutorAgent',
|
||||
description: 'Geduldiger, ermutigender Lernbegleiter fuer Schueler',
|
||||
soulFile: 'tutor-agent.soul.md',
|
||||
soulContent: `# TutorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
|
||||
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Sokratische Methode**: Stelle Fragen, die zum Nachdenken anregen
|
||||
- **Positives Reinforcement**: Erkenne und feiere Lernfortschritte
|
||||
- **Adaptive Kommunikation**: Passe Sprache und Komplexitaet an das Niveau an
|
||||
- **Geduld**: Wiederhole Erklaerungen ohne Frustration zu zeigen
|
||||
|
||||
## Kommunikationsstil
|
||||
- Verwende einfache, klare Sprache
|
||||
- Stelle Rueckfragen, um Verstaendnis zu pruefen
|
||||
- Gib Hinweise statt direkter Loesungen
|
||||
- Feiere kleine Erfolge
|
||||
- Nutze Analogien und Beispiele aus dem Alltag
|
||||
- Strukturiere komplexe Themen in verdauliche Schritte
|
||||
|
||||
## Fachgebiete
|
||||
- Mathematik (Grundschule bis Abitur)
|
||||
- Naturwissenschaften (Physik, Chemie, Biologie)
|
||||
- Sprachen (Deutsch, Englisch)
|
||||
- Gesellschaftswissenschaften (Geschichte, Politik)
|
||||
|
||||
## Lernstrategien
|
||||
1. **Konzeptbasiertes Lernen**: Erklaere das "Warum" hinter Regeln
|
||||
2. **Visualisierung**: Nutze Diagramme und Skizzen wenn moeglich
|
||||
3. **Verbindungen herstellen**: Verknuepfe neues Wissen mit Bekanntem
|
||||
4. **Wiederholung**: Baue systematische Wiederholung ein
|
||||
5. **Selbsttest**: Ermutige zur Selbstueberpruefung
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
|
||||
- Verweise bei komplexen Themen auf Lehrkraefte
|
||||
- Erkenne Frustration und biete Pausen an
|
||||
- Keine Unterstuetzung bei Pruefungsbetrug
|
||||
- Keine medizinischen oder rechtlichen Ratschlaege
|
||||
|
||||
## Eskalation
|
||||
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
|
||||
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
|
||||
- Bei technischen Problemen: Eskaliere an Support
|
||||
- Bei Verdacht auf Lernschwierigkeiten: Empfehle professionelle Diagnostik
|
||||
|
||||
## Metrik-Ziele
|
||||
- Verstaendnis-Score > 80% bei Nachfragen
|
||||
- Engagement-Zeit > 5 Minuten pro Session
|
||||
- Wiederbesuchs-Rate > 60%
|
||||
- Frustrations-Indikatoren < 10%`,
|
||||
color: '#3b82f6',
|
||||
status: 'running',
|
||||
activeSessions: 12,
|
||||
totalProcessed: 1847,
|
||||
avgResponseTime: 234,
|
||||
errorRate: 0.5,
|
||||
lastRestart: '2025-01-14T08:30:00Z',
|
||||
version: '1.2.0',
|
||||
createdAt: '2024-11-01T00:00:00Z',
|
||||
updatedAt: '2025-01-14T10:15:00Z'
|
||||
},
|
||||
'grader-agent': {
|
||||
id: 'grader-agent',
|
||||
name: 'GraderAgent',
|
||||
description: 'Objektiver, fairer Pruefer von Schuelerarbeiten',
|
||||
soulFile: 'grader-agent.soul.md',
|
||||
soulContent: `# GraderAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein objektiver, fairer Pruefer von Schuelerarbeiten.
|
||||
Dein Ziel ist konstruktives Feedback, das zum Lernen motiviert.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Objektivitaet**: Bewerte nach festgelegten Kriterien, nicht nach Sympathie
|
||||
- **Fairness**: Gleiche Massstaebe fuer alle Schueler
|
||||
- **Konstruktivitaet**: Feedback soll zum Lernen anregen
|
||||
- **Transparenz**: Begruende jede Bewertung nachvollziehbar
|
||||
|
||||
## Bewertungsprinzipien
|
||||
- Bewerte nach festgelegten Kriterien (Erwartungshorizont)
|
||||
- Beruecksichtige Teilleistungen
|
||||
- Unterscheide zwischen Fluechtigkeitsfehlern und Verstaendnisluecken
|
||||
- Formuliere Feedback lernfoerdernd
|
||||
- Nutze das 15-Punkte-System korrekt (0-15 Punkte, 5 = ausreichend)
|
||||
|
||||
## Workflow
|
||||
1. Lies die Aufgabenstellung und den Erwartungshorizont
|
||||
2. Analysiere die Schuelerantwort systematisch
|
||||
3. Identifiziere korrekte Elemente
|
||||
4. Identifiziere Fehler mit Kategorisierung
|
||||
5. Vergebe Punkte nach Kriterienkatalog
|
||||
6. Formuliere konstruktives Feedback
|
||||
|
||||
## Fehlerkategorien
|
||||
- **Rechtschreibung (R)**: Orthografische Fehler
|
||||
- **Grammatik (Gr)**: Grammatikalische Fehler
|
||||
- **Ausdruck (A)**: Stilistische Schwaechen
|
||||
- **Inhalt (I)**: Fachliche Fehler oder Luecken
|
||||
- **Struktur (St)**: Aufbau- und Gliederungsprobleme
|
||||
- **Logik (L)**: Argumentationsfehler
|
||||
|
||||
## Qualitaetssicherung
|
||||
- Bei Unsicherheit: Markiere zur manuellen Ueberpruefung
|
||||
- Bei Grenzfaellen: Dokumentiere Entscheidungsgrundlage
|
||||
- Konsistenz: Vergleiche mit aehnlichen Bewertungen
|
||||
- Kalibrierung: Orientiere an Vergleichsarbeiten
|
||||
|
||||
## Eskalation
|
||||
- Unleserliche Antworten: Markiere fuer manuelles Review
|
||||
- Verdacht auf Plagiat: Eskaliere an Lehrkraft
|
||||
- Technische Fehler: Pausiere und melde
|
||||
- Unklare Aufgabenstellung: Frage nach Klarstellung`,
|
||||
color: '#10b981',
|
||||
status: 'running',
|
||||
activeSessions: 3,
|
||||
totalProcessed: 456,
|
||||
avgResponseTime: 1205,
|
||||
errorRate: 1.2,
|
||||
lastRestart: '2025-01-13T14:00:00Z',
|
||||
version: '1.1.0',
|
||||
createdAt: '2024-11-01T00:00:00Z',
|
||||
updatedAt: '2025-01-13T16:30:00Z'
|
||||
},
|
||||
'quality-judge': {
|
||||
id: 'quality-judge',
|
||||
name: 'QualityJudge',
|
||||
description: 'Kritischer Qualitaetspruefer fuer KI-generierte Inhalte',
|
||||
soulFile: 'quality-judge.soul.md',
|
||||
soulContent: `# QualityJudge SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein kritischer Qualitaetspruefer fuer KI-generierte Inhalte.
|
||||
Dein Ziel ist die Sicherstellung hoher Qualitaetsstandards.
|
||||
|
||||
## Bewertungsdimensionen
|
||||
|
||||
### 1. Intent Accuracy (0-100)
|
||||
- Wurde die Benutzerabsicht korrekt erkannt?
|
||||
- Stimmt die Kategorie der Antwort?
|
||||
|
||||
### 2. Faithfulness (1-5)
|
||||
- **5**: Vollstaendig faktisch korrekt
|
||||
- **4**: Minor Ungenauigkeiten ohne Auswirkung
|
||||
- **3**: Einige Ungenauigkeiten, Kernaussage korrekt
|
||||
- **2**: Signifikante Fehler
|
||||
- **1**: Grundlegend falsch
|
||||
|
||||
### 3. Relevance (1-5)
|
||||
- **5**: Direkt und vollstaendig relevant
|
||||
- **4**: Weitgehend relevant
|
||||
- **3**: Teilweise relevant
|
||||
- **2**: Geringe Relevanz
|
||||
- **1**: Voellig irrelevant
|
||||
|
||||
### 4. Coherence (1-5)
|
||||
- **5**: Perfekt strukturiert und logisch
|
||||
- **4**: Gut strukturiert, kleine Luecken
|
||||
- **3**: Verstaendlich, aber verbesserungsfaehig
|
||||
- **2**: Schwer zu folgen
|
||||
- **1**: Unverstaendlich/chaotisch
|
||||
|
||||
### 5. Safety ("pass"/"fail")
|
||||
- Keine DSGVO-Verstoesse (keine PII)
|
||||
- Keine schaedlichen Inhalte
|
||||
- Keine Desinformation
|
||||
- Keine Diskriminierung
|
||||
- Altersgerechte Sprache
|
||||
|
||||
## Schwellenwerte
|
||||
- **Production Ready**: composite >= 80
|
||||
- **Needs Review**: 60 <= composite < 80
|
||||
- **Failed**: composite < 60`,
|
||||
color: '#f59e0b',
|
||||
status: 'running',
|
||||
activeSessions: 8,
|
||||
totalProcessed: 3291,
|
||||
avgResponseTime: 89,
|
||||
errorRate: 0.3,
|
||||
lastRestart: '2025-01-14T06:00:00Z',
|
||||
version: '2.0.0',
|
||||
createdAt: '2024-10-15T00:00:00Z',
|
||||
updatedAt: '2025-01-14T08:00:00Z'
|
||||
},
|
||||
'alert-agent': {
|
||||
id: 'alert-agent',
|
||||
name: 'AlertAgent',
|
||||
description: 'Aufmerksamer Waechter fuer das Breakpilot-System',
|
||||
soulFile: 'alert-agent.soul.md',
|
||||
soulContent: `# AlertAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein aufmerksamer Waechter fuer das Breakpilot-System.
|
||||
Dein Ziel ist die rechtzeitige Erkennung und Kommunikation relevanter Ereignisse.
|
||||
|
||||
## Importance Levels
|
||||
|
||||
### KRITISCH (5)
|
||||
- Systemausfaelle
|
||||
- Sicherheitsvorfaelle
|
||||
- DSGVO-Verstoesse
|
||||
**Aktion**: Sofortige Benachrichtigung aller Admins
|
||||
|
||||
### DRINGEND (4)
|
||||
- Performance-Probleme
|
||||
- API-Ausfaelle
|
||||
- Hohe Fehlerraten
|
||||
**Aktion**: Benachrichtigung innerhalb 5 Minuten
|
||||
|
||||
### WICHTIG (3)
|
||||
- Neue kritische Nachrichten
|
||||
- Relevante Bildungspolitik
|
||||
- Technische Warnungen
|
||||
**Aktion**: Taeglicher Digest
|
||||
|
||||
### PRUEFEN (2)
|
||||
- Interessante Entwicklungen
|
||||
- Konkurrenznachrichten
|
||||
**Aktion**: Woechentlicher Digest
|
||||
|
||||
### INFO (1)
|
||||
- Allgemeine Updates
|
||||
**Aktion**: Archivieren`,
|
||||
color: '#ef4444',
|
||||
status: 'running',
|
||||
activeSessions: 1,
|
||||
totalProcessed: 892,
|
||||
avgResponseTime: 45,
|
||||
errorRate: 0.1,
|
||||
lastRestart: '2025-01-12T00:00:00Z',
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-12-01T00:00:00Z',
|
||||
updatedAt: '2025-01-12T02:00:00Z'
|
||||
},
|
||||
'orchestrator': {
|
||||
id: 'orchestrator',
|
||||
name: 'Orchestrator',
|
||||
description: 'Zentraler Koordinator des Multi-Agent-Systems',
|
||||
soulFile: 'orchestrator.soul.md',
|
||||
soulContent: `# OrchestratorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist der zentrale Koordinator des Breakpilot Multi-Agent-Systems.
|
||||
Dein Ziel ist die effiziente Verteilung und Ueberwachung von Aufgaben.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Effizienz**: Minimale Latenz bei maximaler Qualitaet
|
||||
- **Resilienz**: Graceful Degradation bei Agent-Ausfaellen
|
||||
- **Fairness**: Ausgewogene Lastverteilung
|
||||
- **Transparenz**: Volle Nachvollziehbarkeit aller Entscheidungen
|
||||
|
||||
## Verantwortlichkeiten
|
||||
1. Task-Routing zu spezialisierten Agents
|
||||
2. Session-Management und Recovery
|
||||
3. Agent-Gesundheitsueberwachung
|
||||
4. Lastverteilung
|
||||
5. Fehlerbehandlung und Retry-Logik
|
||||
|
||||
## Task-Routing-Logik
|
||||
|
||||
| Intent-Kategorie | Primaerer Agent | Fallback |
|
||||
|------------------|-----------------|----------|
|
||||
| learning_support | TutorAgent | Manuell |
|
||||
| exam_grading | GraderAgent | QualityJudge |
|
||||
| quality_check | QualityJudge | Manual Review |
|
||||
| system_alert | AlertAgent | E-Mail Fallback |
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
### Retry-Policy
|
||||
- **Max Retries**: 3
|
||||
- **Backoff**: Exponential (1s, 2s, 4s)
|
||||
- **Keine Retries**: Validation Errors, Auth Failures
|
||||
|
||||
### Circuit Breaker
|
||||
- **Threshold**: 5 Fehler in 60 Sekunden
|
||||
- **Cooldown**: 30 Sekunden
|
||||
|
||||
## Metriken
|
||||
- **Task Completion Rate**: > 99%
|
||||
- **Average Latency**: < 2s
|
||||
- **Error Rate**: < 1%`,
|
||||
color: '#8b5cf6',
|
||||
status: 'running',
|
||||
activeSessions: 24,
|
||||
totalProcessed: 8934,
|
||||
avgResponseTime: 12,
|
||||
errorRate: 0.2,
|
||||
lastRestart: '2025-01-14T00:00:00Z',
|
||||
version: '1.5.0',
|
||||
createdAt: '2024-10-01T00:00:00Z',
|
||||
updatedAt: '2025-01-14T00:30:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
const mockChangeLogs: ChangeLog[] = [
|
||||
{ id: '1', timestamp: '2025-01-14T10:15:00Z', user: 'admin@breakpilot.de', action: 'SOUL Updated', description: 'Kommunikationsstil angepasst' },
|
||||
{ id: '2', timestamp: '2025-01-13T14:30:00Z', user: 'lehrer1@schule.de', action: 'Einschraenkung hinzugefuegt', description: 'Keine Hausaufgaben-Loesungen' },
|
||||
{ id: '3', timestamp: '2025-01-10T09:00:00Z', user: 'admin@breakpilot.de', action: 'Version 1.2.0', description: 'Neue Fachgebiete hinzugefuegt' },
|
||||
const TABS: { id: TabId; label: string; icon: typeof FileText }[] = [
|
||||
{ id: 'soul', label: 'SOUL-File', icon: FileText },
|
||||
{ id: 'stats', label: 'Live-Statistiken', icon: Activity },
|
||||
{ id: 'history', label: 'Aenderungshistorie', icon: History },
|
||||
]
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const agentId = params.agentId as string
|
||||
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null)
|
||||
@@ -350,10 +31,9 @@ export default function AgentDetailPage() {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'soul' | 'stats' | 'history'>('soul')
|
||||
const [activeTab, setActiveTab] = useState<TabId>('soul')
|
||||
|
||||
useEffect(() => {
|
||||
// Load agent data
|
||||
const agentData = mockAgentDetails[agentId]
|
||||
if (agentData) {
|
||||
setAgent(agentData)
|
||||
@@ -363,10 +43,7 @@ export default function AgentDetailPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
// In production, save to API
|
||||
// await fetch(`/api/admin/agents/${agentId}/soul`, { method: 'PUT', body: editedContent })
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
if (agent) {
|
||||
setAgent({ ...agent, soulContent: editedContent, updatedAt: new Date().toISOString() })
|
||||
}
|
||||
@@ -393,7 +70,7 @@ export default function AgentDetailPage() {
|
||||
<div className="text-center py-12">
|
||||
<AlertTriangle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Agent nicht gefunden</h2>
|
||||
<p className="text-gray-500 mb-4">Der Agent "{agentId}" existiert nicht.</p>
|
||||
<p className="text-gray-500 mb-4">Der Agent "{agentId}" existiert nicht.</p>
|
||||
<Link href="/ai/agents" className="text-teal-600 hover:text-teal-700">
|
||||
← Zurueck zur Uebersicht
|
||||
</Link>
|
||||
@@ -404,231 +81,46 @@ export default function AgentDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</Link>
|
||||
<div
|
||||
className="p-3 rounded-xl"
|
||||
style={{ backgroundColor: `${agent.color}20` }}
|
||||
>
|
||||
<Brain className="w-6 h-6" style={{ color: agent.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{agent.name}</h1>
|
||||
<p className="text-gray-500">{agent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||
agent.status === 'running' ? 'bg-green-100 text-green-700' :
|
||||
agent.status === 'paused' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{agent.status === 'running' ? <CheckCircle className="w-4 h-4" /> :
|
||||
agent.status === 'paused' ? <Pause className="w-4 h-4" /> :
|
||||
<XCircle className="w-4 h-4" />}
|
||||
{agent.status}
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
{agent.status === 'running' ? (
|
||||
<>
|
||||
<Pause className="w-4 h-4" />
|
||||
Pausieren
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Aktive Sessions</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.activeSessions}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Verarbeitet (24h)</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.totalProcessed.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Avg. Antwortzeit</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.avgResponseTime}ms</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Fehlerrate</div>
|
||||
<div className="text-2xl font-bold text-amber-600">{agent.errorRate}%</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Version</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
<AgentHeader agent={agent} />
|
||||
<AgentStatsBar agent={agent} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('soul')}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'soul'
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
SOUL-File
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'stats'
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
Live-Statistiken
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
Aenderungshistorie
|
||||
</button>
|
||||
{TABS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === id
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'soul' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<FileText className="w-4 h-4" />
|
||||
{agent.soulFile}
|
||||
<span className="text-gray-300">|</span>
|
||||
<Clock className="w-4 h-4" />
|
||||
Zuletzt geaendert: {new Date(agent.updatedAt).toLocaleString('de-DE')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2 text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="text-sm">Ungespeicherte Aenderungen vorhanden</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg overflow-auto whitespace-pre-wrap">
|
||||
{agent.soulContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-medium text-blue-900 mb-2">Hinweise zur SOUL-Datei</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• Die SOUL-Datei definiert die Persoenlichkeit und das Verhalten des Agents</li>
|
||||
<li>• Aenderungen werden nach dem Speichern sofort wirksam</li>
|
||||
<li>• Testen Sie Aenderungen zuerst im Staging-Modus</li>
|
||||
<li>• Alle Aenderungen werden in der Historie protokolliert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>Live-Statistiken werden in einer zukuenftigen Version verfuegbar sein.</p>
|
||||
<p className="text-sm mt-2">
|
||||
Besuchen Sie die <Link href="/ai/agents/statistics" className="text-teal-600 hover:underline">Statistik-Seite</Link> fuer aggregierte Daten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{mockChangeLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="p-2 bg-white rounded-full border border-gray-200">
|
||||
<History className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{log.action}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(log.timestamp).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{log.description}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">von {log.user}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SoulTabContent
|
||||
agent={agent}
|
||||
editedContent={editedContent}
|
||||
isEditing={isEditing}
|
||||
hasChanges={hasChanges}
|
||||
saving={saving}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'stats' && <StatsTabContent />}
|
||||
{activeTab === 'history' && <HistoryTabContent changeLogs={mockChangeLogs} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Brain, CheckCircle, Shield, AlertTriangle, MessageSquare } from 'lucide-react'
|
||||
|
||||
interface AgentCardProps {
|
||||
icon: React.ReactNode
|
||||
bgColor: string
|
||||
hoverBorder: string
|
||||
name: string
|
||||
description: string
|
||||
tags: { label: string; colorClasses: string }[]
|
||||
soulInfo: string
|
||||
}
|
||||
|
||||
function AgentCard({ icon, bgColor, hoverBorder, name, description, tags, soulInfo }: AgentCardProps) {
|
||||
return (
|
||||
<div className={`border border-gray-200 rounded-xl p-4 ${hoverBorder} transition-colors`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 ${bgColor} rounded-lg`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">{name}</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">{description}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map(tag => (
|
||||
<span key={tag.label} className={`px-2 py-1 ${tag.colorClasses} text-xs rounded-full`}>
|
||||
{tag.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">{soulInfo}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AGENTS: AgentCardProps[] = [
|
||||
{
|
||||
icon: <Brain className="w-6 h-6 text-blue-600" />,
|
||||
bgColor: 'bg-blue-100',
|
||||
hoverBorder: 'hover:border-blue-300',
|
||||
name: 'TutorAgent',
|
||||
description: 'Lernbegleitung und Fragen beantworten',
|
||||
tags: [
|
||||
{ label: 'Geduldig', colorClasses: 'bg-blue-50 text-blue-700' },
|
||||
{ label: 'Ermutigend', colorClasses: 'bg-blue-50 text-blue-700' },
|
||||
{ label: 'Sokratisch', colorClasses: 'bg-blue-50 text-blue-700' },
|
||||
],
|
||||
soulInfo: 'SOUL: tutor-agent.soul.md | Routing: learning_*, help_*, question_*',
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="w-6 h-6 text-green-600" />,
|
||||
bgColor: 'bg-green-100',
|
||||
hoverBorder: 'hover:border-green-300',
|
||||
name: 'GraderAgent',
|
||||
description: 'Klausur-Korrektur und Bewertung',
|
||||
tags: [
|
||||
{ label: 'Objektiv', colorClasses: 'bg-green-50 text-green-700' },
|
||||
{ label: 'Fair', colorClasses: 'bg-green-50 text-green-700' },
|
||||
{ label: 'Konstruktiv', colorClasses: 'bg-green-50 text-green-700' },
|
||||
],
|
||||
soulInfo: 'SOUL: grader-agent.soul.md | Routing: grade_*, evaluate_*, correct_*',
|
||||
},
|
||||
{
|
||||
icon: <Shield className="w-6 h-6 text-amber-600" />,
|
||||
bgColor: 'bg-amber-100',
|
||||
hoverBorder: 'hover:border-amber-300',
|
||||
name: 'QualityJudge',
|
||||
description: 'BQAS Qualitaetspruefung',
|
||||
tags: [
|
||||
{ label: 'Kritisch', colorClasses: 'bg-amber-50 text-amber-700' },
|
||||
{ label: 'Praezise', colorClasses: 'bg-amber-50 text-amber-700' },
|
||||
{ label: 'Schnell', colorClasses: 'bg-amber-50 text-amber-700' },
|
||||
],
|
||||
soulInfo: 'SOUL: quality-judge.soul.md | Routing: quality_*, review_*, validate_*',
|
||||
},
|
||||
{
|
||||
icon: <AlertTriangle className="w-6 h-6 text-red-600" />,
|
||||
bgColor: 'bg-red-100',
|
||||
hoverBorder: 'hover:border-red-300',
|
||||
name: 'AlertAgent',
|
||||
description: 'Monitoring und Benachrichtigungen',
|
||||
tags: [
|
||||
{ label: 'Wachsam', colorClasses: 'bg-red-50 text-red-700' },
|
||||
{ label: 'Proaktiv', colorClasses: 'bg-red-50 text-red-700' },
|
||||
{ label: 'Priorisierend', colorClasses: 'bg-red-50 text-red-700' },
|
||||
],
|
||||
soulInfo: 'SOUL: alert-agent.soul.md | Routing: alert_*, monitor_*, notify_*',
|
||||
},
|
||||
{
|
||||
icon: <MessageSquare className="w-6 h-6 text-purple-600" />,
|
||||
bgColor: 'bg-purple-100',
|
||||
hoverBorder: 'hover:border-purple-300',
|
||||
name: 'Orchestrator',
|
||||
description: 'Task-Koordination und Routing',
|
||||
tags: [
|
||||
{ label: 'Koordinierend', colorClasses: 'bg-purple-50 text-purple-700' },
|
||||
{ label: 'Effizient', colorClasses: 'bg-purple-50 text-purple-700' },
|
||||
{ label: 'Zuverlaessig', colorClasses: 'bg-purple-50 text-purple-700' },
|
||||
],
|
||||
soulInfo: 'SOUL: orchestrator.soul.md | Routing: Fallback fuer alle unbekannten Intents',
|
||||
},
|
||||
]
|
||||
|
||||
export function AgentTypesSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Jeder Agent hat eine spezialisierte Rolle im System. Die Agents kommunizieren ueber den Message Bus
|
||||
und nutzen das Shared Brain fuer konsistente Entscheidungen.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{AGENTS.map(agent => (
|
||||
<AgentCard key={agent.name} {...agent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export function DatabaseSchemaSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Das Agent-System nutzt PostgreSQL fuer persistente Daten und Valkey (Redis) fuer Caching und Pub/Sub.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* agent_sessions */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_sessions</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Speichert Session-Daten mit Checkpoints</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
agent_type VARCHAR(50) NOT NULL,
|
||||
user_id UUID REFERENCES users(id),
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
context JSONB DEFAULT '{}',
|
||||
checkpoints JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_heartbeat TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* agent_memory */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_memory</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Langzeit-Gedaechtnis mit TTL</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_memory (
|
||||
id UUID PRIMARY KEY,
|
||||
namespace VARCHAR(100) NOT NULL,
|
||||
key VARCHAR(500) NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
agent_id VARCHAR(50) NOT NULL,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE(namespace, key)
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* agent_messages */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_messages</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Audit-Trail fuer Inter-Agent Kommunikation</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_messages (
|
||||
id UUID PRIMARY KEY,
|
||||
sender VARCHAR(50) NOT NULL,
|
||||
receiver VARCHAR(50) NOT NULL,
|
||||
message_type VARCHAR(50) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
priority INTEGER DEFAULT 1,
|
||||
correlation_id UUID,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export function MessageBusSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Der Message Bus ermoeglicht die asynchrone Kommunikation zwischen Agents via Redis Pub/Sub.
|
||||
Er unterstuetzt Prioritaeten, Request-Response-Pattern und Broadcast-Nachrichten.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
|
||||
<div className="text-gray-500 mb-2"># Nachrichtenfluss</div>
|
||||
<pre className="text-gray-700">{`
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Sender │ │ Receiver │
|
||||
│ (Agent) │ │ (Agent) │
|
||||
└──────┬───────┘ └──────▲───────┘
|
||||
│ │
|
||||
│ publish(AgentMessage) │ handle(message)
|
||||
│ │
|
||||
▼ │
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Message Bus │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Priority Q │ │ Routing │ │ Logging │ │
|
||||
│ │ HIGH/NORMAL │ │ Rules │ │ Audit │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ Redis Pub/Sub │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Nachrichtentypen</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 rounded-lg">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Typ</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_request</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Neue Aufgabe an Agent senden</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_response</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Antwort auf task_request</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">escalation</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-orange-100 text-orange-700 text-xs rounded">HIGH</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Eskalation an anderen Agent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">alert</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">CRITICAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Kritische Benachrichtigung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">heartbeat</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">LOW</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Liveness-Signal</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Server, Brain, GitBranch } from 'lucide-react'
|
||||
|
||||
export function OverviewSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-gray-600">
|
||||
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Es ermoeglicht
|
||||
die Koordination mehrerer spezialisierter KI-Agents, die gemeinsam komplexe Aufgaben loesen.
|
||||
</p>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-gray-700">{`
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Breakpilot Services │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │Voice Service│ │Klausur Svc │ │ Admin-v2 / AlertAgent │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┼──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Agent Core │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │
|
||||
│ │ │ Sessions │ │Shared Brain │ │ Orchestrator │ │ │
|
||||
│ │ │ - Manager │ │ - Memory │ │ - Message Bus │ │ │
|
||||
│ │ │ - Heartbeat │ │ - Context │ │ - Supervisor │ │ │
|
||||
│ │ │ - Checkpoint│ │ - Knowledge │ │ - Task Router │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └───────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Infrastructure │ │
|
||||
│ │ Valkey (Redis) PostgreSQL Qdrant │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-semibold text-blue-900">Session Management</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700">
|
||||
Verwaltet Agent-Lifecycles mit State Machine, Checkpoints und automatischer Recovery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Brain className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-semibold text-purple-900">Shared Brain</span>
|
||||
</div>
|
||||
<p className="text-sm text-purple-700">
|
||||
Gemeinsames Gedaechtnis fuer alle Agents mit TTL, Context-Verwaltung und Knowledge Graph.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitBranch className="w-5 h-5 text-green-600" />
|
||||
<span className="font-semibold text-green-900">Orchestrator</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">
|
||||
Message Bus, Supervisor und Task Router fuer die Agent-Koordination.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export function SessionLifecycleSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Sessions verwalten den Zustand von Agent-Interaktionen. Jede Session hat einen definierten
|
||||
Lebenszyklus mit Checkpoints fuer Recovery.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
|
||||
<div className="text-gray-500 mb-2"># Session State Machine</div>
|
||||
<pre className="text-gray-700">{`
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ ACTIVE │───▶│ PAUSED │───▶│ COMPLETED│ │ FAILED │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │ ▲
|
||||
│ │ │
|
||||
└───────────────┴───────────────────────────────┘
|
||||
(bei Fehler)
|
||||
|
||||
States:
|
||||
- ACTIVE: Session laeuft, Agent verarbeitet Tasks
|
||||
- PAUSED: Session pausiert, wartet auf Eingabe
|
||||
- COMPLETED: Session erfolgreich beendet
|
||||
- FAILED: Session mit Fehler beendet
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Heartbeat Monitoring</h4>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">30s</div>
|
||||
<div className="text-sm text-gray-500">Timeout</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">5s</div>
|
||||
<div className="text-sm text-gray-500">Check Interval</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">3</div>
|
||||
<div className="text-sm text-gray-500">Max Missed Beats</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-4 text-center">
|
||||
Nach 3 verpassten Heartbeats wird der Agent als ausgefallen markiert und die
|
||||
Restart-Policy greift (max. 3 Versuche).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Database, Activity, GitBranch } from 'lucide-react'
|
||||
|
||||
export function SharedBrainSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Das Shared Brain speichert Wissen und Kontext, auf den alle Agents zugreifen koennen.
|
||||
Es besteht aus drei Komponenten: Memory Store, Context Manager und Knowledge Graph.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
<h4 className="font-semibold text-gray-900">Memory Store</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Langzeit-Gedaechtnis fuer Fakten, Entscheidungen und Lernfortschritte.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- TTL-basierte Expiration (30 Tage default)</li>
|
||||
<li>- Access-Tracking (Haeufigkeit)</li>
|
||||
<li>- Pattern-basierte Suche</li>
|
||||
<li>- Hybrid: Redis + PostgreSQL</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="w-5 h-5 text-purple-600" />
|
||||
<h4 className="font-semibold text-gray-900">Context Manager</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Verwaltet Konversationskontext mit automatischer Komprimierung.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- Max 50 Messages pro Context</li>
|
||||
<li>- Automatische Zusammenfassung</li>
|
||||
<li>- System-Messages bleiben erhalten</li>
|
||||
<li>- Entity-Extraktion</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-5 h-5 text-green-600" />
|
||||
<h4 className="font-semibold text-gray-900">Knowledge Graph</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Graph-basierte Darstellung von Entitaeten und ihren Beziehungen.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- Entitaeten: Student, Lehrer, Fach</li>
|
||||
<li>- Beziehungen: lernt, unterrichtet</li>
|
||||
<li>- BFS-basierte Pfadsuche</li>
|
||||
<li>- Verwandte Entitaeten finden</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm mt-6">
|
||||
<div className="text-gray-500 mb-2"># Memory Store Beispiel</div>
|
||||
<pre className="text-gray-700">{`
|
||||
# Speichern
|
||||
await store.remember(
|
||||
key="student:123:progress",
|
||||
value={"level": 5, "score": 85, "topic": "algebra"},
|
||||
agent_id="tutor-agent",
|
||||
ttl_days=30
|
||||
)
|
||||
|
||||
# Abrufen
|
||||
progress = await store.recall("student:123:progress")
|
||||
# → {"level": 5, "score": 85, "topic": "algebra"}
|
||||
|
||||
# Suchen
|
||||
all_progress = await store.search("student:123:*")
|
||||
# → [Memory(...), Memory(...), ...]
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export function SoulFilesSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
SOUL-Dateien (Semantic Outline for Unified Learning) definieren die Persoenlichkeit und
|
||||
Verhaltensregeln jedes Agents. Sie bestimmen, wie ein Agent kommuniziert, entscheidet und eskaliert.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6 text-gray-100 font-mono text-sm overflow-x-auto">
|
||||
<div className="text-gray-400 mb-4"># Beispiel: tutor-agent.soul.md</div>
|
||||
<pre className="text-green-400">{`
|
||||
# TutorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
|
||||
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
|
||||
|
||||
## Kommunikationsstil
|
||||
- Verwende einfache, klare Sprache
|
||||
- Stelle Rueckfragen, um Verstaendnis zu pruefen
|
||||
- Gib Hinweise statt direkter Loesungen
|
||||
- Feiere kleine Erfolge
|
||||
|
||||
## Fachgebiete
|
||||
- Mathematik (Grundschule bis Abitur)
|
||||
- Naturwissenschaften (Physik, Chemie, Biologie)
|
||||
- Sprachen (Deutsch, Englisch)
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
|
||||
- Verweise bei komplexen Themen auf Lehrkraefte
|
||||
- Erkenne Frustration und biete Pausen an
|
||||
|
||||
## Eskalation
|
||||
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
|
||||
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
|
||||
- Bei technischen Problemen: Eskaliere an Support
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">SOUL-Struktur</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Identitaet</h5>
|
||||
<p className="text-sm text-gray-600">Wer ist der Agent? Welche Rolle nimmt er ein?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Kommunikationsstil</h5>
|
||||
<p className="text-sm text-gray-600">Wie kommuniziert der Agent mit Benutzern?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Fachgebiete</h5>
|
||||
<p className="text-sm text-gray-600">In welchen Bereichen ist der Agent kompetent?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Einschraenkungen</h5>
|
||||
<p className="text-sm text-gray-600">Was darf der Agent NICHT tun?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 md:col-span-2">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Eskalation</h5>
|
||||
<p className="text-sm text-gray-600">Wann und wie eskaliert der Agent an andere Agents oder Menschen?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export function TaskRoutingSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Der Task Router entscheidet, welcher Agent eine Anfrage bearbeitet. Er verwendet
|
||||
Intent-basierte Regeln mit Prioritaeten und Fallback-Ketten.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 rounded-lg">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Intent-Pattern</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Ziel-Agent</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Fallback</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-blue-700">learning_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-blue-700">help_*, question_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">8</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-green-700">grade_*, evaluate_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">GraderAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-amber-700">quality_*, review_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">QualityJudge</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">GraderAgent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-red-700">alert_*, monitor_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">AlertAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-500">* (alle anderen)</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">Orchestrator</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">0</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Routing-Strategien</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-2">
|
||||
<li><span className="font-mono text-blue-600">ROUND_ROBIN</span> - Gleichmaessige Verteilung</li>
|
||||
<li><span className="font-mono text-blue-600">LEAST_LOADED</span> - Agent mit wenigsten Tasks</li>
|
||||
<li><span className="font-mono text-blue-600">PRIORITY</span> - Hoechste Prioritaet zuerst</li>
|
||||
<li><span className="font-mono text-blue-600">RANDOM</span> - Zufaellige Auswahl</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Fallback-Verhalten</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-2">
|
||||
<li>1. Versuche Ziel-Agent zu erreichen</li>
|
||||
<li>2. Bei Timeout: Fallback-Agent nutzen</li>
|
||||
<li>3. Bei Fehler: Orchestrator uebernimmt</li>
|
||||
<li>4. Bei kritischen Fehlern: Alert an Admin</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { OverviewSection } from './OverviewSection'
|
||||
export { AgentTypesSection } from './AgentTypesSection'
|
||||
export { SoulFilesSection } from './SoulFilesSection'
|
||||
export { MessageBusSection } from './MessageBusSection'
|
||||
export { SharedBrainSection } from './SharedBrainSection'
|
||||
export { TaskRoutingSection } from './TaskRoutingSection'
|
||||
export { SessionLifecycleSection } from './SessionLifecycleSection'
|
||||
export { DatabaseSchemaSection } from './DatabaseSchemaSection'
|
||||
export type { Section } from './types'
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Section {
|
||||
id: string
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
content: React.ReactNode
|
||||
}
|
||||
@@ -2,17 +2,78 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Cpu, Brain, MessageSquare, Database, Activity, Shield, ChevronDown, ChevronRight, GitBranch, Layers, Server, FileText, AlertTriangle, CheckCircle, Zap, RefreshCw } from 'lucide-react'
|
||||
import {
|
||||
ArrowLeft, Cpu, Brain, MessageSquare, Database,
|
||||
Activity, ChevronDown, ChevronRight, GitBranch,
|
||||
Layers, FileText, Zap, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
OverviewSection,
|
||||
AgentTypesSection,
|
||||
SoulFilesSection,
|
||||
MessageBusSection,
|
||||
SharedBrainSection,
|
||||
TaskRoutingSection,
|
||||
SessionLifecycleSection,
|
||||
DatabaseSchemaSection,
|
||||
} from './_components'
|
||||
import type { Section } from './_components'
|
||||
|
||||
interface Section {
|
||||
id: string
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
content: React.ReactNode
|
||||
}
|
||||
const SECTIONS: Section[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
title: 'System-Uebersicht',
|
||||
icon: <Layers className="w-5 h-5" />,
|
||||
content: <OverviewSection />,
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Agent-Typen',
|
||||
icon: <Cpu className="w-5 h-5" />,
|
||||
content: <AgentTypesSection />,
|
||||
},
|
||||
{
|
||||
id: 'soul-files',
|
||||
title: 'SOUL-Files (Persoenlichkeiten)',
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
content: <SoulFilesSection />,
|
||||
},
|
||||
{
|
||||
id: 'message-bus',
|
||||
title: 'Message Bus & Kommunikation',
|
||||
icon: <MessageSquare className="w-5 h-5" />,
|
||||
content: <MessageBusSection />,
|
||||
},
|
||||
{
|
||||
id: 'shared-brain',
|
||||
title: 'Shared Brain (Gedaechtnis)',
|
||||
icon: <Brain className="w-5 h-5" />,
|
||||
content: <SharedBrainSection />,
|
||||
},
|
||||
{
|
||||
id: 'task-routing',
|
||||
title: 'Task Routing',
|
||||
icon: <Zap className="w-5 h-5" />,
|
||||
content: <TaskRoutingSection />,
|
||||
},
|
||||
{
|
||||
id: 'session-lifecycle',
|
||||
title: 'Session Lifecycle',
|
||||
icon: <RefreshCw className="w-5 h-5" />,
|
||||
content: <SessionLifecycleSection />,
|
||||
},
|
||||
{
|
||||
id: 'database',
|
||||
title: 'Datenbank-Schema',
|
||||
icon: <Database className="w-5 h-5" />,
|
||||
content: <DatabaseSchemaSection />,
|
||||
},
|
||||
]
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>(['overview', 'agents', 'soul-files'])
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>([
|
||||
'overview', 'agents', 'soul-files',
|
||||
])
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSections(prev =>
|
||||
@@ -22,654 +83,6 @@ export default function ArchitecturePage() {
|
||||
)
|
||||
}
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
title: 'System-Uebersicht',
|
||||
icon: <Layers className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
<p className="text-gray-600">
|
||||
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Es ermoeglicht
|
||||
die Koordination mehrerer spezialisierter KI-Agents, die gemeinsam komplexe Aufgaben loesen.
|
||||
</p>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-gray-700">{`
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Breakpilot Services │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │Voice Service│ │Klausur Svc │ │ Admin-v2 / AlertAgent │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┼──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Agent Core │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │
|
||||
│ │ │ Sessions │ │Shared Brain │ │ Orchestrator │ │ │
|
||||
│ │ │ - Manager │ │ - Memory │ │ - Message Bus │ │ │
|
||||
│ │ │ - Heartbeat │ │ - Context │ │ - Supervisor │ │ │
|
||||
│ │ │ - Checkpoint│ │ - Knowledge │ │ - Task Router │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └───────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Infrastructure │ │
|
||||
│ │ Valkey (Redis) PostgreSQL Qdrant │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-semibold text-blue-900">Session Management</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700">
|
||||
Verwaltet Agent-Lifecycles mit State Machine, Checkpoints und automatischer Recovery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Brain className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-semibold text-purple-900">Shared Brain</span>
|
||||
</div>
|
||||
<p className="text-sm text-purple-700">
|
||||
Gemeinsames Gedaechtnis fuer alle Agents mit TTL, Context-Verwaltung und Knowledge Graph.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitBranch className="w-5 h-5 text-green-600" />
|
||||
<span className="font-semibold text-green-900">Orchestrator</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">
|
||||
Message Bus, Supervisor und Task Router fuer die Agent-Koordination.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Agent-Typen',
|
||||
icon: <Cpu className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Jeder Agent hat eine spezialisierte Rolle im System. Die Agents kommunizieren ueber den Message Bus
|
||||
und nutzen das Shared Brain fuer konsistente Entscheidungen.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* TutorAgent */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-blue-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Brain className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">TutorAgent</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">Lernbegleitung und Fragen beantworten</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Geduldig</span>
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Ermutigend</span>
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Sokratisch</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: tutor-agent.soul.md | Routing: learning_*, help_*, question_*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GraderAgent */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-green-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">GraderAgent</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">Klausur-Korrektur und Bewertung</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Objektiv</span>
|
||||
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Fair</span>
|
||||
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Konstruktiv</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: grader-agent.soul.md | Routing: grade_*, evaluate_*, correct_*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QualityJudge */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-amber-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-100 rounded-lg">
|
||||
<Shield className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">QualityJudge</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">BQAS Qualitaetspruefung</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Kritisch</span>
|
||||
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Praezise</span>
|
||||
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Schnell</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: quality-judge.soul.md | Routing: quality_*, review_*, validate_*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AlertAgent */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-red-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-red-100 rounded-lg">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">AlertAgent</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">Monitoring und Benachrichtigungen</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Wachsam</span>
|
||||
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Proaktiv</span>
|
||||
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Priorisierend</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: alert-agent.soul.md | Routing: alert_*, monitor_*, notify_*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orchestrator */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<MessageSquare className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">Orchestrator</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">Task-Koordination und Routing</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Koordinierend</span>
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Effizient</span>
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Zuverlaessig</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: orchestrator.soul.md | Routing: Fallback fuer alle unbekannten Intents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'soul-files',
|
||||
title: 'SOUL-Files (Persoenlichkeiten)',
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
SOUL-Dateien (Semantic Outline for Unified Learning) definieren die Persoenlichkeit und
|
||||
Verhaltensregeln jedes Agents. Sie bestimmen, wie ein Agent kommuniziert, entscheidet und eskaliert.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6 text-gray-100 font-mono text-sm overflow-x-auto">
|
||||
<div className="text-gray-400 mb-4"># Beispiel: tutor-agent.soul.md</div>
|
||||
<pre className="text-green-400">{`
|
||||
# TutorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
|
||||
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
|
||||
|
||||
## Kommunikationsstil
|
||||
- Verwende einfache, klare Sprache
|
||||
- Stelle Rueckfragen, um Verstaendnis zu pruefen
|
||||
- Gib Hinweise statt direkter Loesungen
|
||||
- Feiere kleine Erfolge
|
||||
|
||||
## Fachgebiete
|
||||
- Mathematik (Grundschule bis Abitur)
|
||||
- Naturwissenschaften (Physik, Chemie, Biologie)
|
||||
- Sprachen (Deutsch, Englisch)
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
|
||||
- Verweise bei komplexen Themen auf Lehrkraefte
|
||||
- Erkenne Frustration und biete Pausen an
|
||||
|
||||
## Eskalation
|
||||
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
|
||||
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
|
||||
- Bei technischen Problemen: Eskaliere an Support
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">SOUL-Struktur</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Identitaet</h5>
|
||||
<p className="text-sm text-gray-600">Wer ist der Agent? Welche Rolle nimmt er ein?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Kommunikationsstil</h5>
|
||||
<p className="text-sm text-gray-600">Wie kommuniziert der Agent mit Benutzern?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Fachgebiete</h5>
|
||||
<p className="text-sm text-gray-600">In welchen Bereichen ist der Agent kompetent?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Einschraenkungen</h5>
|
||||
<p className="text-sm text-gray-600">Was darf der Agent NICHT tun?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 md:col-span-2">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Eskalation</h5>
|
||||
<p className="text-sm text-gray-600">Wann und wie eskaliert der Agent an andere Agents oder Menschen?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'message-bus',
|
||||
title: 'Message Bus & Kommunikation',
|
||||
icon: <MessageSquare className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Der Message Bus ermoeglicht die asynchrone Kommunikation zwischen Agents via Redis Pub/Sub.
|
||||
Er unterstuetzt Prioritaeten, Request-Response-Pattern und Broadcast-Nachrichten.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
|
||||
<div className="text-gray-500 mb-2"># Nachrichtenfluss</div>
|
||||
<pre className="text-gray-700">{`
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Sender │ │ Receiver │
|
||||
│ (Agent) │ │ (Agent) │
|
||||
└──────┬───────┘ └──────▲───────┘
|
||||
│ │
|
||||
│ publish(AgentMessage) │ handle(message)
|
||||
│ │
|
||||
▼ │
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Message Bus │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Priority Q │ │ Routing │ │ Logging │ │
|
||||
│ │ HIGH/NORMAL │ │ Rules │ │ Audit │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ Redis Pub/Sub │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Nachrichtentypen</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 rounded-lg">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Typ</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_request</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Neue Aufgabe an Agent senden</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_response</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Antwort auf task_request</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">escalation</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-orange-100 text-orange-700 text-xs rounded">HIGH</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Eskalation an anderen Agent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">alert</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">CRITICAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Kritische Benachrichtigung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">heartbeat</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">LOW</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Liveness-Signal</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'shared-brain',
|
||||
title: 'Shared Brain (Gedaechtnis)',
|
||||
icon: <Brain className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Das Shared Brain speichert Wissen und Kontext, auf den alle Agents zugreifen koennen.
|
||||
Es besteht aus drei Komponenten: Memory Store, Context Manager und Knowledge Graph.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
<h4 className="font-semibold text-gray-900">Memory Store</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Langzeit-Gedaechtnis fuer Fakten, Entscheidungen und Lernfortschritte.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- TTL-basierte Expiration (30 Tage default)</li>
|
||||
<li>- Access-Tracking (Haeufigkeit)</li>
|
||||
<li>- Pattern-basierte Suche</li>
|
||||
<li>- Hybrid: Redis + PostgreSQL</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="w-5 h-5 text-purple-600" />
|
||||
<h4 className="font-semibold text-gray-900">Context Manager</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Verwaltet Konversationskontext mit automatischer Komprimierung.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- Max 50 Messages pro Context</li>
|
||||
<li>- Automatische Zusammenfassung</li>
|
||||
<li>- System-Messages bleiben erhalten</li>
|
||||
<li>- Entity-Extraktion</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-5 h-5 text-green-600" />
|
||||
<h4 className="font-semibold text-gray-900">Knowledge Graph</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Graph-basierte Darstellung von Entitaeten und ihren Beziehungen.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- Entitaeten: Student, Lehrer, Fach</li>
|
||||
<li>- Beziehungen: lernt, unterrichtet</li>
|
||||
<li>- BFS-basierte Pfadsuche</li>
|
||||
<li>- Verwandte Entitaeten finden</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm mt-6">
|
||||
<div className="text-gray-500 mb-2"># Memory Store Beispiel</div>
|
||||
<pre className="text-gray-700">{`
|
||||
# Speichern
|
||||
await store.remember(
|
||||
key="student:123:progress",
|
||||
value={"level": 5, "score": 85, "topic": "algebra"},
|
||||
agent_id="tutor-agent",
|
||||
ttl_days=30
|
||||
)
|
||||
|
||||
# Abrufen
|
||||
progress = await store.recall("student:123:progress")
|
||||
# → {"level": 5, "score": 85, "topic": "algebra"}
|
||||
|
||||
# Suchen
|
||||
all_progress = await store.search("student:123:*")
|
||||
# → [Memory(...), Memory(...), ...]
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'task-routing',
|
||||
title: 'Task Routing',
|
||||
icon: <Zap className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Der Task Router entscheidet, welcher Agent eine Anfrage bearbeitet. Er verwendet
|
||||
Intent-basierte Regeln mit Prioritaeten und Fallback-Ketten.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 rounded-lg">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Intent-Pattern</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Ziel-Agent</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Fallback</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-blue-700">learning_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-blue-700">help_*, question_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">8</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-green-700">grade_*, evaluate_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">GraderAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-amber-700">quality_*, review_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">QualityJudge</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">GraderAgent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-red-700">alert_*, monitor_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">AlertAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-500">* (alle anderen)</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">Orchestrator</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">0</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Routing-Strategien</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-2">
|
||||
<li><span className="font-mono text-blue-600">ROUND_ROBIN</span> - Gleichmaessige Verteilung</li>
|
||||
<li><span className="font-mono text-blue-600">LEAST_LOADED</span> - Agent mit wenigsten Tasks</li>
|
||||
<li><span className="font-mono text-blue-600">PRIORITY</span> - Hoechste Prioritaet zuerst</li>
|
||||
<li><span className="font-mono text-blue-600">RANDOM</span> - Zufaellige Auswahl</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Fallback-Verhalten</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-2">
|
||||
<li>1. Versuche Ziel-Agent zu erreichen</li>
|
||||
<li>2. Bei Timeout: Fallback-Agent nutzen</li>
|
||||
<li>3. Bei Fehler: Orchestrator uebernimmt</li>
|
||||
<li>4. Bei kritischen Fehlern: Alert an Admin</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'session-lifecycle',
|
||||
title: 'Session Lifecycle',
|
||||
icon: <RefreshCw className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Sessions verwalten den Zustand von Agent-Interaktionen. Jede Session hat einen definierten
|
||||
Lebenszyklus mit Checkpoints fuer Recovery.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
|
||||
<div className="text-gray-500 mb-2"># Session State Machine</div>
|
||||
<pre className="text-gray-700">{`
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ ACTIVE │───▶│ PAUSED │───▶│ COMPLETED│ │ FAILED │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │ ▲
|
||||
│ │ │
|
||||
└───────────────┴───────────────────────────────┘
|
||||
(bei Fehler)
|
||||
|
||||
States:
|
||||
- ACTIVE: Session laeuft, Agent verarbeitet Tasks
|
||||
- PAUSED: Session pausiert, wartet auf Eingabe
|
||||
- COMPLETED: Session erfolgreich beendet
|
||||
- FAILED: Session mit Fehler beendet
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Heartbeat Monitoring</h4>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">30s</div>
|
||||
<div className="text-sm text-gray-500">Timeout</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">5s</div>
|
||||
<div className="text-sm text-gray-500">Check Interval</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">3</div>
|
||||
<div className="text-sm text-gray-500">Max Missed Beats</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-4 text-center">
|
||||
Nach 3 verpassten Heartbeats wird der Agent als ausgefallen markiert und die
|
||||
Restart-Policy greift (max. 3 Versuche).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'database',
|
||||
title: 'Datenbank-Schema',
|
||||
icon: <Database className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Das Agent-System nutzt PostgreSQL fuer persistente Daten und Valkey (Redis) fuer Caching und Pub/Sub.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* agent_sessions */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_sessions</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Speichert Session-Daten mit Checkpoints</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
agent_type VARCHAR(50) NOT NULL,
|
||||
user_id UUID REFERENCES users(id),
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
context JSONB DEFAULT '{}',
|
||||
checkpoints JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_heartbeat TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* agent_memory */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_memory</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Langzeit-Gedaechtnis mit TTL</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_memory (
|
||||
id UUID PRIMARY KEY,
|
||||
namespace VARCHAR(100) NOT NULL,
|
||||
key VARCHAR(500) NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
agent_id VARCHAR(50) NOT NULL,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE(namespace, key)
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* agent_messages */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_messages</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Audit-Trail fuer Inter-Agent Kommunikation</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_messages (
|
||||
id UUID PRIMARY KEY,
|
||||
sender VARCHAR(50) NOT NULL,
|
||||
receiver VARCHAR(50) NOT NULL,
|
||||
message_type VARCHAR(50) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
priority INTEGER DEFAULT 1,
|
||||
correlation_id UUID,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
@@ -696,7 +109,7 @@ CREATE TABLE agent_messages (
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-8">
|
||||
<h2 className="font-semibold text-gray-900 mb-3">Inhaltsverzeichnis</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{sections.map(section => (
|
||||
{SECTIONS.map(section => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => {
|
||||
@@ -716,7 +129,7 @@ CREATE TABLE agent_messages (
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-4">
|
||||
{sections.map(section => (
|
||||
{SECTIONS.map(section => (
|
||||
<div
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
@@ -749,7 +162,7 @@ CREATE TABLE agent_messages (
|
||||
|
||||
{/* Footer Links */}
|
||||
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-teal-900 mb-3">Weiterführende Ressourcen</h3>
|
||||
<h3 className="font-semibold text-teal-900 mb-3">Weiterfuehrende Ressourcen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GPU Infrastructure Admin Page
|
||||
*
|
||||
* vast.ai GPU Management for LLM Processing
|
||||
* Part of KI-Werkzeuge
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
||||
|
||||
interface VastStatus {
|
||||
instance_id: number | null
|
||||
status: string
|
||||
gpu_name: string | null
|
||||
dph_total: number | null
|
||||
endpoint_base_url: string | null
|
||||
last_activity: string | null
|
||||
auto_shutdown_in_minutes: number | null
|
||||
total_runtime_hours: number | null
|
||||
total_cost_usd: number | null
|
||||
account_credit: number | null
|
||||
account_total_spend: number | null
|
||||
session_runtime_minutes: number | null
|
||||
session_cost_usd: number | null
|
||||
message: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function GPUInfrastructurePage() {
|
||||
const [status, setStatus] = useState<VastStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const API_PROXY = '/api/admin/gpu'
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
setStatus({
|
||||
instance_id: null,
|
||||
status: 'error',
|
||||
gpu_name: null,
|
||||
dph_total: null,
|
||||
endpoint_base_url: null,
|
||||
last_activity: null,
|
||||
auto_shutdown_in_minutes: null,
|
||||
total_runtime_hours: null,
|
||||
total_cost_usd: null,
|
||||
account_credit: null,
|
||||
account_total_spend: null,
|
||||
session_runtime_minutes: null,
|
||||
session_cost_usd: null,
|
||||
message: 'Verbindung fehlgeschlagen'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
const powerOn = async () => {
|
||||
setActionLoading('on')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'on' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Start angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Starten')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const powerOff = async () => {
|
||||
setActionLoading('off')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'off' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Stop angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Stoppen')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (s: string) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase'
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return `${baseClasses} bg-green-100 text-green-800`
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return `${baseClasses} bg-red-100 text-red-800`
|
||||
case 'loading':
|
||||
case 'scheduling':
|
||||
case 'creating':
|
||||
case 'starting...':
|
||||
case 'stopping...':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditColor = (credit: number | null) => {
|
||||
if (credit === null) return 'text-slate-500'
|
||||
if (credit < 5) return 'text-red-600'
|
||||
if (credit < 15) return 'text-yellow-600'
|
||||
return 'text-green-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="GPU Infrastruktur"
|
||||
purpose="Verwalten Sie die vast.ai GPU-Instanzen fuer LLM-Verarbeitung und OCR. Starten/Stoppen Sie GPUs bei Bedarf und ueberwachen Sie Kosten in Echtzeit."
|
||||
audience={['DevOps', 'Entwickler', 'System-Admins']}
|
||||
architecture={{
|
||||
services: ['vast.ai API', 'Ollama', 'VLLM'],
|
||||
databases: ['PostgreSQL (Logs)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Tests' },
|
||||
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR Testing' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* KI-Werkzeuge Sidebar */}
|
||||
<AIToolsSidebarResponsive currentTool="gpu" />
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Status</div>
|
||||
{loading ? (
|
||||
<span className="px-3 py-1 rounded-full text-sm font-semibold bg-slate-100 text-slate-600">
|
||||
Laden...
|
||||
</span>
|
||||
) : (
|
||||
<span className={getStatusBadge(
|
||||
actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unknown'
|
||||
)}>
|
||||
{actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unbekannt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">GPU</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.gpu_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Kosten/h</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Auto-Stop</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.auto_shutdown_in_minutes !== null
|
||||
? `${status.auto_shutdown_in_minutes} min`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Budget</div>
|
||||
<div className={`font-bold text-lg ${getCreditColor(status?.account_credit ?? null)}`}>
|
||||
{status && status.account_credit !== null
|
||||
? `$${status.account_credit.toFixed(2)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Session</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.session_runtime_minutes !== null && status.session_cost_usd !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200">
|
||||
<button
|
||||
onClick={powerOn}
|
||||
disabled={actionLoading !== null || status?.status === 'running'}
|
||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
<button
|
||||
onClick={powerOff}
|
||||
disabled={actionLoading !== null || status?.status !== 'running'}
|
||||
className="px-6 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
|
||||
</button>
|
||||
|
||||
{message && (
|
||||
<span className="ml-4 text-sm text-green-600 font-medium">{message}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ml-4 text-sm text-red-600 font-medium">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extended Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Kosten-Uebersicht</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Laufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_runtime_minutes !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} Minuten`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Kosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_cost_usd !== null
|
||||
? `$${status.session_cost_usd.toFixed(4)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-600">Gesamtlaufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_runtime_hours !== null
|
||||
? `${status.total_runtime_hours.toFixed(1)} Stunden`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Gesamtkosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_cost_usd !== null
|
||||
? `$${status.total_cost_usd.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">vast.ai Ausgaben</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.account_total_spend !== null
|
||||
? `$${status.account_total_spend.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Instanz-Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Instanz ID</span>
|
||||
<span className="font-mono text-sm">
|
||||
{status?.instance_id || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">GPU</span>
|
||||
<span className="font-semibold">
|
||||
{status?.gpu_name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Stundensatz</span>
|
||||
<span className="font-semibold">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Letzte Aktivitaet</span>
|
||||
<span className="text-sm">
|
||||
{status?.last_activity
|
||||
? new Date(status.last_activity).toLocaleString('de-DE')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
{status?.endpoint_base_url && status.status === 'running' && (
|
||||
<div className="pt-4 border-t border-slate-100">
|
||||
<div className="text-slate-600 text-sm mb-1">Endpoint</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded block overflow-x-auto">
|
||||
{status.endpoint_base_url}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-violet-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-semibold text-violet-900">Auto-Shutdown</h4>
|
||||
<p className="text-sm text-violet-800 mt-1">
|
||||
Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist.
|
||||
Der Status wird alle 30 Sekunden automatisch aktualisiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
interface GlobalDragOverlayProps {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export function GlobalDragOverlay({ active }: GlobalDragOverlayProps) {
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-purple-900/80 backdrop-blur-sm flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="text-7xl mb-4 animate-bounce">📄</div>
|
||||
<div className="text-2xl font-bold text-white">Bild hier ablegen</div>
|
||||
<div className="text-purple-200 mt-2">PNG, JPG - Handgeschriebener Text</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsModal({ open, onClose }: KeyboardShortcutsModalProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 bg-black/50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-4">Tastenkuerzel</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Bild einfuegen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+V</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">OCR starten</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+Enter</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Tab wechseln</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Alt+1-6</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Bild entfernen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Escape</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Shortcuts anzeigen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">?</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
export function TabArchitecture() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Architecture Diagram */}
|
||||
<ArchitectureDiagram />
|
||||
|
||||
{/* Components */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<ComponentCard
|
||||
icon="🔍"
|
||||
title="TrOCR Service"
|
||||
description="Das TrOCR-Modell von Microsoft ist speziell fuer Handschrifterkennung trainiert. Es verwendet eine Vision-Transformer (ViT) Architektur fuer Bildverarbeitung und einen Text-Decoder fuer die Textgenerierung."
|
||||
specs={[
|
||||
{ label: 'Modell', value: 'microsoft/trocr-base-handwritten' },
|
||||
{ label: 'Groesse', value: '~350 MB' },
|
||||
{ label: 'Lizenz', value: 'MIT' },
|
||||
{ label: 'Framework', value: 'PyTorch / Transformers' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="🎯"
|
||||
title="LoRA Fine-Tuning"
|
||||
description="LoRA fuegt kleine, trainierbare Matrizen zu bestimmten Schichten hinzu, ohne das Basismodell zu veraendern. Dies ermoeglicht effizientes Fine-Tuning mit minimaler Speichernutzung."
|
||||
specs={[
|
||||
{ label: 'Methode', value: 'Low-Rank Adaptation' },
|
||||
{ label: 'Adapter-Groesse', value: '~10 MB' },
|
||||
{ label: 'Trainingszeit', value: '5-15 Min (CPU)' },
|
||||
{ label: 'Min. Beispiele', value: '10' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="🔒"
|
||||
title="Pseudonymisierung"
|
||||
description="Schuelernamen werden durch anonyme Tokens ersetzt, bevor Daten die lokale Umgebung verlassen. Das Mapping wird ausschliesslich lokal gespeichert."
|
||||
specs={[
|
||||
{ label: 'Methode', value: 'QR-Code Tokens' },
|
||||
{ label: 'Token-Format', value: 'UUID v4' },
|
||||
{ label: 'Mapping', value: 'Lokal beim Lehrer' },
|
||||
{ label: 'Cloud-Daten', value: 'Nur Tokens + Text' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="☁️"
|
||||
title="Cloud LLM"
|
||||
description="Die KI-Korrektur erfolgt auf deutschen Servern mit strikter Mandantentrennung. Es werden keine Klarnamen oder identifizierenden Informationen uebertragen."
|
||||
specs={[
|
||||
{ label: 'Provider', value: 'SysEleven (DE)' },
|
||||
{ label: 'Standort', value: 'Deutschland' },
|
||||
{ label: 'Isolation', value: 'Namespace pro Schule' },
|
||||
{ label: 'Datenverarbeitung', value: 'Nur pseudonymisiert' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data Flow */}
|
||||
<DataFlowCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ArchitectureDiagram() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-6">Systemarchitektur</h2>
|
||||
|
||||
<div className="bg-slate-900 rounded-lg p-6 font-mono text-xs overflow-x-auto">
|
||||
<pre className="text-slate-300">
|
||||
{`┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MAGIC HELP ARCHITEKTUR │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
||||
│ │ FRONTEND │ │ BACKEND │ │ STORAGE │ │
|
||||
│ │ (Next.js) │ │ (FastAPI) │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ ┌─────────┐ │ REST │ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Admin │──┼─────────┼──│ TrOCR │ │ │ │ Models │ │ │
|
||||
│ │ │ Panel │ │ │ │ Service │──┼─────────┼──│ (ONNX) │ │ │
|
||||
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ │ ┌─────────┐ │ WebSocket│ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Lehrer │──┼─────────┼──│ Klausur │ │ │ │ LoRA │ │ │
|
||||
│ │ │ Portal │ │ │ │ Processor │──┼─────────┼──│ Adapter │ │ │
|
||||
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ └───────────────┘ │ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Pseudo- │ │ │ │Training │ │ │
|
||||
│ │ │ nymizer │──┼─────────┼──│ Data │ │ │
|
||||
│ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────┘ └───────────────┘ │
|
||||
│ │ │
|
||||
│ │ (nur pseudonymisiert) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ CLOUD LLM │ │
|
||||
│ │ (SysEleven) │ │
|
||||
│ │ Namespace- │ │
|
||||
│ │ Isolation │ │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ComponentCardProps {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
specs: Array<{ label: string; value: string }>
|
||||
}
|
||||
|
||||
function ComponentCard({ icon, title, description, specs }: ComponentCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<span>{icon}</span> {title}
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{specs.map((spec) => (
|
||||
<div key={spec.label} className="flex justify-between">
|
||||
<span className="text-slate-500">{spec.label}</span>
|
||||
<span className="text-slate-900">{spec.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm mt-4">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DATA_FLOW_STEPS = [
|
||||
{
|
||||
num: 1,
|
||||
color: 'bg-blue-100 text-blue-600',
|
||||
title: 'Lokale Header-Extraktion',
|
||||
desc: 'TrOCR erkennt Schuelernamen, Klasse und Fach direkt im Browser/PWA (offline-faehig)',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
color: 'bg-purple-100 text-purple-600',
|
||||
title: 'Pseudonymisierung',
|
||||
desc: 'Namen werden durch QR-Code Tokens ersetzt, Mapping bleibt lokal',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
color: 'bg-green-100 text-green-600',
|
||||
title: 'Cloud-Korrektur',
|
||||
desc: 'Nur pseudonymisierte Dokument-Tokens werden an die KI gesendet',
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
color: 'bg-yellow-100 text-yellow-600',
|
||||
title: 'Re-Identifikation',
|
||||
desc: 'Ergebnisse werden lokal mit dem Mapping wieder den echten Namen zugeordnet',
|
||||
},
|
||||
]
|
||||
|
||||
function DataFlowCard() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Datenfluss</h2>
|
||||
<div className="space-y-4">
|
||||
{DATA_FLOW_STEPS.map((step) => (
|
||||
<div key={step.num} className="flex items-start gap-4 bg-slate-50 rounded-lg p-4">
|
||||
<div className={`w-8 h-8 rounded-full ${step.color} flex items-center justify-center font-bold`}>
|
||||
{step.num}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{step.title}</div>
|
||||
<div className="text-sm text-slate-500">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { BatchUploader } from '@/components/ai/BatchUploader'
|
||||
import { API_BASE } from '../types'
|
||||
|
||||
export function TabBatch() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Batch OCR Processing */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-2">Batch-Verarbeitung</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Verarbeite mehrere Bilder gleichzeitig mit Echtzeit-Fortschrittsanzeige.
|
||||
Die Ergebnisse werden per Server-Sent Events gestreamt.
|
||||
</p>
|
||||
|
||||
<BatchUploader
|
||||
apiBase={API_BASE}
|
||||
maxFiles={20}
|
||||
autoProcess={false}
|
||||
onComplete={(results) => {
|
||||
console.log('Batch complete:', results)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Batch Processing Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🚀</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Parallele Verarbeitung</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Mehrere Bilder werden parallel verarbeitet fuer maximale Geschwindigkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">💾</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Smart Caching</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Identische Bilder werden automatisch aus dem Cache geladen (unter 50ms).
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">📊</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Live-Fortschritt</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Echtzeit-Updates via Server-Sent Events zeigen den Verarbeitungsfortschritt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { SkeletonText } from '@/components/common/SkeletonText'
|
||||
import type { TrOCRStatus } from '../types'
|
||||
|
||||
interface TabOverviewProps {
|
||||
status: TrOCRStatus | null
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function TabOverview({ status, loading, onRefresh }: TabOverviewProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Systemstatus</h2>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-slate-50 rounded-lg p-4">
|
||||
<SkeletonText lines={1} className="mb-2" />
|
||||
<div className="h-3 w-16 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : status?.status === 'available' ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.model_name || 'trocr-base'}</div>
|
||||
<div className="text-xs text-slate-500">Modell</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.device || 'CPU'}</div>
|
||||
<div className="text-xs text-slate-500">Geraet</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.training_examples_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.has_lora_adapter ? 'Aktiv' : 'Keiner'}</div>
|
||||
<div className="text-xs text-slate-500">LoRA Adapter</div>
|
||||
</div>
|
||||
</div>
|
||||
) : status?.status === 'not_installed' ? (
|
||||
<div className="text-slate-600">
|
||||
<p className="mb-2">TrOCR ist nicht installiert. Fuehre aus:</p>
|
||||
<code className="bg-slate-100 px-3 py-2 rounded text-sm block font-mono">{status.install_command}</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-600">{status?.error || 'Unbekannter Fehler'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🎯</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Handschrifterkennung</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
TrOCR erkennt automatisch handgeschriebenen Text in Klausuren.
|
||||
Das Modell wurde speziell fuer deutsche Handschriften optimiert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🔒</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Privacy by Design</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Alle Daten werden lokal verarbeitet. Schuelernamen werden durch
|
||||
QR-Codes pseudonymisiert - DSGVO-konform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">📈</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Kontinuierliches Lernen</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Mit LoRA Fine-Tuning passt sich das Modell an individuelle
|
||||
Handschriften an - ohne das Basismodell zu veraendern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Overview */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Magic Onboarding Workflow</h2>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
{WORKFLOW_STEPS.map((step, i) => (
|
||||
<WorkflowStep key={step.title} step={step} showArrow={i < WORKFLOW_STEPS.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ icon: '📄', title: '1. Upload', desc: '25 Klausuren hochladen' },
|
||||
{ icon: '🔍', title: '2. Analyse', desc: 'Lokale OCR in 5-10 Sek' },
|
||||
{ icon: '✅', title: '3. Bestaetigung', desc: 'Klasse, Schueler, Fach' },
|
||||
{ icon: '🤖', title: '4. KI-Korrektur', desc: 'Cloud mit Pseudonymisierung' },
|
||||
{ icon: '📊', title: '5. Integration', desc: 'Notenbuch, Zeugnisse' },
|
||||
]
|
||||
|
||||
function WorkflowStep({ step, showArrow }: { step: typeof WORKFLOW_STEPS[number]; showArrow: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 bg-slate-50 rounded-lg px-4 py-3">
|
||||
<span className="text-2xl">{step.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{step.title}</div>
|
||||
<div className="text-slate-500">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showArrow && <div className="text-slate-400">→</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client'
|
||||
|
||||
import type { MagicSettings } from '../types'
|
||||
import { DEFAULT_SETTINGS } from '../types'
|
||||
|
||||
interface TabSettingsProps {
|
||||
settings: MagicSettings
|
||||
settingsSaved: boolean
|
||||
onUpdateSettings: (settings: MagicSettings) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function TabSettings({ settings, settingsSaved, onUpdateSettings, onSave }: TabSettingsProps) {
|
||||
const update = (partial: Partial<MagicSettings>) => {
|
||||
onUpdateSettings({ ...settings, ...partial })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* OCR Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CheckboxSetting
|
||||
label="Automatische Zeilenerkennung"
|
||||
description="Erkennt und verarbeitet einzelne Zeilen separat"
|
||||
checked={settings.autoDetectLines}
|
||||
onChange={(v) => update({ autoDetectLines: v })}
|
||||
/>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Live-Vorschau"
|
||||
description="OCR startet automatisch nach Bild-Upload"
|
||||
checked={settings.livePreview}
|
||||
onChange={(v) => update({ livePreview: v })}
|
||||
/>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Sound-Feedback"
|
||||
description="Akustisches Feedback bei erfolgreicher Erkennung"
|
||||
checked={settings.soundFeedback}
|
||||
onChange={(v) => update({ soundFeedback: v })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Konfidenz-Schwellwert</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={settings.confidenceThreshold}
|
||||
onChange={(e) => update({ confidenceThreshold: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-400 mt-1">
|
||||
<span>0%</span>
|
||||
<span className="text-slate-900">{(settings.confidenceThreshold * 100).toFixed(0)}%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Max. Bildgroesse (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxImageSize}
|
||||
onChange={(e) => update({ maxImageSize: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
<div className="text-xs text-slate-400 mt-1">Groessere Bilder werden skaliert</div>
|
||||
</div>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Ergebnis-Cache aktivieren"
|
||||
description="Speichert OCR-Ergebnisse fuer identische Bilder"
|
||||
checked={settings.enableCache}
|
||||
onChange={(v) => update({ enableCache: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Training Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">LoRA Rank</label>
|
||||
<select
|
||||
value={settings.loraRank}
|
||||
onChange={(e) => update({ loraRank: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="4">4 (Schnell, weniger Kapazitaet)</option>
|
||||
<option value="8">8 (Ausgewogen)</option>
|
||||
<option value="16">16 (Mehr Kapazitaet)</option>
|
||||
<option value="32">32 (Maximum)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">LoRA Alpha</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.loraAlpha}
|
||||
onChange={(e) => update({ loraAlpha: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
<div className="text-xs text-slate-400 mt-1">Empfohlen: 4 x LoRA Rank</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Epochen</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.epochs}
|
||||
onChange={(e) => update({ epochs: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Batch Size</label>
|
||||
<select
|
||||
value={settings.batchSize}
|
||||
onChange={(e) => update({ batchSize: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="1">1 (Wenig RAM)</option>
|
||||
<option value="2">2</option>
|
||||
<option value="4">4 (Standard)</option>
|
||||
<option value="8">8 (Viel RAM)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Learning Rate</label>
|
||||
<select
|
||||
value={settings.learningRate}
|
||||
onChange={(e) => update({ learningRate: parseFloat(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="0.0001">0.0001 (Schnell)</option>
|
||||
<option value="0.00005">0.00005 (Standard)</option>
|
||||
<option value="0.00001">0.00001 (Konservativ)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => onUpdateSettings(DEFAULT_SETTINGS)}
|
||||
className="px-6 py-2 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{settingsSaved ? '\u2713 Gespeichert!' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Technical Info */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Technische Informationen</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">API Endpoint:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">/api/klausur/trocr</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Model Path:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">~/.cache/huggingface</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">LoRA Path:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./models/lora</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Training Data:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./data/training</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CheckboxSetting({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-slate-100 border-slate-300"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-slate-900 font-medium">{label}</div>
|
||||
<div className="text-sm text-slate-500">{description}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
304
admin-lehrer/app/(admin)/ai/magic-help/_components/TabTest.tsx
Normal file
304
admin-lehrer/app/(admin)/ai/magic-help/_components/TabTest.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { SkeletonOCRResult, SkeletonDots } from '@/components/common/SkeletonText'
|
||||
import { ConfidenceHeatmap } from '@/components/ai/ConfidenceHeatmap'
|
||||
import type { OCRResult, MagicSettings } from '../types'
|
||||
|
||||
interface TabTestProps {
|
||||
ocrResult: OCRResult | null
|
||||
ocrLoading: boolean
|
||||
imagePreview: string | null
|
||||
uploadedImage: File | null
|
||||
settings: MagicSettings
|
||||
showHeatmap: boolean
|
||||
onToggleHeatmap: () => void
|
||||
onFileUpload: (file: File) => void
|
||||
onManualOCR: () => void
|
||||
onClearImage: () => void
|
||||
onSendToTraining: () => void
|
||||
}
|
||||
|
||||
function getConfidenceColor(confidence: number) {
|
||||
if (confidence >= 0.9) return 'bg-green-500'
|
||||
if (confidence >= 0.7) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
export function TabTest({
|
||||
ocrResult,
|
||||
ocrLoading,
|
||||
imagePreview,
|
||||
uploadedImage,
|
||||
settings,
|
||||
showHeatmap,
|
||||
onToggleHeatmap,
|
||||
onFileUpload,
|
||||
onManualOCR,
|
||||
onClearImage,
|
||||
onSendToTraining,
|
||||
}: TabTestProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* OCR Test */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Test</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Teste die Handschrifterkennung mit einem eigenen Bild. Das Ergebnis zeigt
|
||||
den erkannten Text, Konfidenz und Verarbeitungszeit.
|
||||
{settings.livePreview && (
|
||||
<span className="text-purple-600 ml-1">(Live-Vorschau aktiv)</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upload Area */}
|
||||
<UploadArea
|
||||
imagePreview={imagePreview}
|
||||
uploadedImage={uploadedImage}
|
||||
ocrLoading={ocrLoading}
|
||||
livePreview={settings.livePreview}
|
||||
onFileUpload={onFileUpload}
|
||||
onManualOCR={onManualOCR}
|
||||
onClearImage={onClearImage}
|
||||
/>
|
||||
|
||||
{/* Results Area */}
|
||||
<ResultsArea
|
||||
ocrResult={ocrResult}
|
||||
ocrLoading={ocrLoading}
|
||||
onSendToTraining={onSendToTraining}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confidence Heatmap */}
|
||||
{imagePreview && ocrResult && ocrResult.confidence > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Konfidenz-Visualisierung</h2>
|
||||
<button
|
||||
onClick={onToggleHeatmap}
|
||||
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
||||
showHeatmap
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{showHeatmap ? 'Heatmap verbergen' : 'Heatmap anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
{showHeatmap && (
|
||||
<ConfidenceHeatmap
|
||||
imageSrc={imagePreview}
|
||||
text={ocrResult.text}
|
||||
confidence={ocrResult.confidence}
|
||||
wordBoxes={ocrResult.word_boxes?.map(w => ({
|
||||
text: w.text,
|
||||
confidence: w.confidence,
|
||||
bbox: w.bbox as [number, number, number, number]
|
||||
})) || []}
|
||||
charConfidences={ocrResult.char_confidences || []}
|
||||
showLegend={true}
|
||||
toggleable={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence Interpretation */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Konfidenz-Interpretation</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-green-700 font-medium">90-100%</div>
|
||||
<div className="text-sm text-slate-600 mt-1">Sehr hohe Sicherheit - Text kann direkt uebernommen werden</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="text-yellow-700 font-medium">70-90%</div>
|
||||
<div className="text-sm text-slate-600 mt-1">Gute Sicherheit - manuelle Ueberpruefung empfohlen</div>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-red-700 font-medium">< 70%</div>
|
||||
<div className="text-sm text-slate-600 mt-1">Niedrige Sicherheit - manuelle Eingabe erforderlich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface UploadAreaProps {
|
||||
imagePreview: string | null
|
||||
uploadedImage: File | null
|
||||
ocrLoading: boolean
|
||||
livePreview: boolean
|
||||
onFileUpload: (file: File) => void
|
||||
onManualOCR: () => void
|
||||
onClearImage: () => void
|
||||
}
|
||||
|
||||
function UploadArea({ imagePreview, uploadedImage, ocrLoading, livePreview, onFileUpload, onManualOCR, onClearImage }: UploadAreaProps) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all ${
|
||||
imagePreview
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-slate-300 hover:border-purple-500'
|
||||
}`}
|
||||
onClick={() => document.getElementById('ocr-file-input')?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-purple-500', 'bg-purple-50') }}
|
||||
onDragLeave={(e) => { e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50') }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50')
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file?.type.startsWith('image/')) onFileUpload(file)
|
||||
}}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Hochgeladenes Bild"
|
||||
className="max-h-64 mx-auto rounded-lg shadow-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClearImage()
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
|
||||
title="Bild entfernen (Escape)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-4xl mb-2">📄</div>
|
||||
<div className="text-slate-700">Bild hierher ziehen oder klicken zum Hochladen</div>
|
||||
<div className="text-xs text-slate-400 mt-1">PNG, JPG - Handgeschriebener Text</div>
|
||||
<div className="text-xs text-purple-500 mt-2">
|
||||
oder <kbd className="px-1.5 py-0.5 bg-purple-100 rounded font-mono">Ctrl+V</kbd> zum Einfuegen
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="ocr-file-input"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileUpload(file)
|
||||
}}
|
||||
/>
|
||||
|
||||
{uploadedImage && !livePreview && (
|
||||
<button
|
||||
onClick={onManualOCR}
|
||||
disabled={ocrLoading}
|
||||
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{ocrLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<SkeletonDots />
|
||||
Analysiere...
|
||||
</span>
|
||||
) : (
|
||||
'OCR starten (Ctrl+Enter)'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ResultsAreaProps {
|
||||
ocrResult: OCRResult | null
|
||||
ocrLoading: boolean
|
||||
onSendToTraining: () => void
|
||||
}
|
||||
|
||||
function ResultsArea({ ocrResult, ocrLoading, onSendToTraining }: ResultsAreaProps) {
|
||||
if (ocrLoading) return <SkeletonOCRResult />
|
||||
|
||||
if (!ocrResult) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center text-slate-400">
|
||||
<div className="text-4xl mb-2">🔍</div>
|
||||
<div>Lade ein Bild hoch um die Erkennung zu testen</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-slate-700">Erkannter Text:</h3>
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
ocrResult.confidence >= 0.9 ? 'bg-green-100 text-green-700' :
|
||||
ocrResult.confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{(ocrResult.confidence * 100).toFixed(0)}% Konfidenz
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-white border p-3 rounded text-sm text-slate-900 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{ocrResult.text || '(Kein Text erkannt)'}
|
||||
</pre>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<div className="mt-3 mb-3">
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${getConfidenceColor(ocrResult.confidence)}`}
|
||||
style={{ width: `${ocrResult.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Konfidenz</div>
|
||||
<div className="text-slate-900 font-medium">{(ocrResult.confidence * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Verarbeitungszeit</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.processing_time_ms}ms</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Modell</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.model || 'TrOCR'}</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">LoRA Adapter</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.has_lora_adapter ? 'Ja' : 'Nein'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ocrResult.confidence < 0.9 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800 mb-2">
|
||||
Die Erkennung koennte verbessert werden! Moechtest du dieses Beispiel zum Training hinzufuegen?
|
||||
</p>
|
||||
<button
|
||||
onClick={onSendToTraining}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Als Trainingsbeispiel hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SkeletonDots } from '@/components/common/SkeletonText'
|
||||
import { TrainingMetrics } from '@/components/ai/TrainingMetrics'
|
||||
import type { TrOCRStatus, TrainingExample, MagicSettings } from '../types'
|
||||
import { API_BASE } from '../types'
|
||||
|
||||
interface TabTrainingProps {
|
||||
status: TrOCRStatus | null
|
||||
examples: TrainingExample[]
|
||||
trainingImage: File | null
|
||||
trainingText: string
|
||||
fineTuning: boolean
|
||||
settings: MagicSettings
|
||||
showTrainingDashboard: boolean
|
||||
onSetTrainingImage: (file: File | null) => void
|
||||
onSetTrainingText: (text: string) => void
|
||||
onAddExample: () => void
|
||||
onFineTune: () => void
|
||||
onToggleDashboard: () => void
|
||||
}
|
||||
|
||||
export function TabTraining({
|
||||
status,
|
||||
examples,
|
||||
trainingImage,
|
||||
trainingText,
|
||||
fineTuning,
|
||||
settings,
|
||||
showTrainingDashboard,
|
||||
onSetTrainingImage,
|
||||
onSetTrainingText,
|
||||
onAddExample,
|
||||
onFineTune,
|
||||
onToggleDashboard,
|
||||
}: TabTrainingProps) {
|
||||
const exampleCount = status?.training_examples_count || 0
|
||||
const progressPct = Math.min(100, (exampleCount / 10) * 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Training Overview */}
|
||||
<TrainingOverviewCard
|
||||
status={status}
|
||||
settings={settings}
|
||||
exampleCount={exampleCount}
|
||||
progressPct={progressPct}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Add Training Example */}
|
||||
<AddExampleCard
|
||||
trainingImage={trainingImage}
|
||||
trainingText={trainingText}
|
||||
onSetTrainingImage={onSetTrainingImage}
|
||||
onSetTrainingText={onSetTrainingText}
|
||||
onAddExample={onAddExample}
|
||||
/>
|
||||
|
||||
{/* Fine-Tuning */}
|
||||
<FineTuningCard
|
||||
settings={settings}
|
||||
fineTuning={fineTuning}
|
||||
exampleCount={exampleCount}
|
||||
hasLoraAdapter={status?.has_lora_adapter || false}
|
||||
onFineTune={onFineTune}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Training Examples List */}
|
||||
{examples.length > 0 && (
|
||||
<ExamplesListCard examples={examples} />
|
||||
)}
|
||||
|
||||
{/* Training Dashboard Demo */}
|
||||
<TrainingDashboardCard
|
||||
showDashboard={showTrainingDashboard}
|
||||
onToggle={onToggleDashboard}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function TrainingOverviewCard({
|
||||
status,
|
||||
settings,
|
||||
exampleCount,
|
||||
progressPct,
|
||||
}: {
|
||||
status: TrOCRStatus | null
|
||||
settings: MagicSettings
|
||||
exampleCount: number
|
||||
progressPct: number
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training mit LoRA</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
LoRA (Low-Rank Adaptation) ermoeglicht effizientes Fine-Tuning ohne das Basismodell zu veraendern.
|
||||
Das Training erfolgt lokal auf Ihrem System.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{exampleCount}</div>
|
||||
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">10</div>
|
||||
<div className="text-xs text-slate-500">Minimum benoetigt</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{settings.loraRank}</div>
|
||||
<div className="text-xs text-slate-500">LoRA Rank</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{status?.has_lora_adapter ? '\u2713' : '\u2717'}</div>
|
||||
<div className="text-xs text-slate-500">Adapter aktiv</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-500">Fortschritt zum Fine-Tuning</span>
|
||||
<span className="text-slate-500">{progressPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddExampleCard({
|
||||
trainingImage,
|
||||
trainingText,
|
||||
onSetTrainingImage,
|
||||
onSetTrainingText,
|
||||
onAddExample,
|
||||
}: {
|
||||
trainingImage: File | null
|
||||
trainingText: string
|
||||
onSetTrainingImage: (file: File | null) => void
|
||||
onSetTrainingText: (text: string) => void
|
||||
onAddExample: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiel hinzufuegen</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Lade ein Bild mit handgeschriebenem Text hoch und gib die korrekte Transkription ein.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-1">Bild</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm"
|
||||
onChange={(e) => onSetTrainingImage(e.target.files?.[0] || null)}
|
||||
/>
|
||||
{trainingImage && (
|
||||
<div className="mt-2 text-xs text-green-600">
|
||||
Bild ausgewaehlt: {trainingImage.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-1">Korrekter Text (Ground Truth)</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-900 resize-none"
|
||||
rows={3}
|
||||
placeholder="Gib hier den korrekten Text ein..."
|
||||
value={trainingText}
|
||||
onChange={(e) => onSetTrainingText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddExample}
|
||||
className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
+ Trainingsbeispiel hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FineTuningCard({
|
||||
settings,
|
||||
fineTuning,
|
||||
exampleCount,
|
||||
hasLoraAdapter,
|
||||
onFineTune,
|
||||
}: {
|
||||
settings: MagicSettings
|
||||
fineTuning: boolean
|
||||
exampleCount: number
|
||||
hasLoraAdapter: boolean
|
||||
onFineTune: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Fine-Tuning starten</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Trainiere das Modell mit den gesammelten Beispielen. Der Prozess dauert
|
||||
je nach Anzahl der Beispiele einige Minuten.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">Epochen:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.epochs}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Learning Rate:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.learningRate}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">LoRA Rank:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.loraRank}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Batch Size:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.batchSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onFineTune}
|
||||
disabled={fineTuning || exampleCount < 10}
|
||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{fineTuning ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<SkeletonDots />
|
||||
Fine-Tuning laeuft...
|
||||
</span>
|
||||
) : (
|
||||
'Fine-Tuning starten'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{exampleCount < 10 && (
|
||||
<p className="text-xs text-yellow-600 mt-2 text-center">
|
||||
Noch {10 - exampleCount} Beispiele benoetigt
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/ai/ocr-labeling?model=trocr-lora"
|
||||
className="w-full mt-4 px-4 py-2 bg-teal-100 text-teal-700 border border-teal-300 rounded-lg hover:bg-teal-200 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<span>🏷️</span>
|
||||
Ground Truth in OCR-Labeling sammeln
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExamplesListCard({ examples }: { examples: TrainingExample[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiele ({examples.length})</h2>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{examples.map((ex, i) => (
|
||||
<div key={i} className="flex items-center gap-4 bg-slate-50 rounded-lg p-3">
|
||||
<span className="text-slate-400 font-mono text-sm w-8">{i + 1}.</span>
|
||||
<span className="text-slate-900 text-sm flex-1 truncate">{ex.ground_truth}</span>
|
||||
<span className="text-slate-400 text-xs">{new Date(ex.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrainingDashboardCard({
|
||||
showDashboard,
|
||||
onToggle,
|
||||
}: {
|
||||
showDashboard: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Training Dashboard</h2>
|
||||
<p className="text-sm text-slate-500">Live-Metriken waehrend des Trainings</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
showDashboard
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-purple-600 hover:bg-purple-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{showDashboard ? 'Demo stoppen' : 'Demo starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDashboard ? (
|
||||
<TrainingMetrics
|
||||
apiBase={API_BASE}
|
||||
simulateMode={true}
|
||||
onComplete={onToggle}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center">
|
||||
<div className="text-4xl mb-3">📈</div>
|
||||
<div className="text-slate-600 mb-2">
|
||||
Das Training Dashboard zeigt Echtzeit-Metriken waehrend des Fine-Tunings
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
Klicke "Demo starten" um eine simulierte Training-Session zu sehen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { GlobalDragOverlay, KeyboardShortcutsModal } from './GlobalOverlays'
|
||||
export { TabOverview } from './TabOverview'
|
||||
export { TabTest } from './TabTest'
|
||||
export { TabBatch } from './TabBatch'
|
||||
export { TabTraining } from './TabTraining'
|
||||
export { TabArchitecture } from './TabArchitecture'
|
||||
export { TabSettings } from './TabSettings'
|
||||
File diff suppressed because it is too large
Load Diff
71
admin-lehrer/app/(admin)/ai/magic-help/types.ts
Normal file
71
admin-lehrer/app/(admin)/ai/magic-help/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type TabId = 'overview' | 'test' | 'batch' | 'training' | 'architecture' | 'settings'
|
||||
|
||||
export interface TrOCRStatus {
|
||||
status: 'available' | 'not_installed' | 'error'
|
||||
model_name?: string
|
||||
model_id?: string
|
||||
device?: string
|
||||
is_loaded?: boolean
|
||||
has_lora_adapter?: boolean
|
||||
training_examples_count?: number
|
||||
error?: string
|
||||
install_command?: string
|
||||
}
|
||||
|
||||
export interface OCRResult {
|
||||
text: string
|
||||
confidence: number
|
||||
processing_time_ms: number
|
||||
model: string
|
||||
has_lora_adapter: boolean
|
||||
char_confidences?: number[]
|
||||
word_boxes?: Array<{ text: string; confidence: number; bbox: number[] }>
|
||||
}
|
||||
|
||||
export interface TrainingExample {
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
teacher_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MagicSettings {
|
||||
autoDetectLines: boolean
|
||||
confidenceThreshold: number
|
||||
maxImageSize: number
|
||||
loraRank: number
|
||||
loraAlpha: number
|
||||
learningRate: number
|
||||
epochs: number
|
||||
batchSize: number
|
||||
enableCache: boolean
|
||||
cacheMaxAge: number
|
||||
livePreview: boolean
|
||||
soundFeedback: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MagicSettings = {
|
||||
autoDetectLines: true,
|
||||
confidenceThreshold: 0.7,
|
||||
maxImageSize: 4096,
|
||||
loraRank: 8,
|
||||
loraAlpha: 32,
|
||||
learningRate: 0.00005,
|
||||
epochs: 3,
|
||||
batchSize: 4,
|
||||
enableCache: true,
|
||||
cacheMaxAge: 3600,
|
||||
livePreview: true,
|
||||
soundFeedback: false,
|
||||
}
|
||||
|
||||
export const TABS = [
|
||||
{ id: 'overview' as TabId, label: 'Uebersicht', icon: '\u{1F4CA}', shortcut: 'Alt+1' },
|
||||
{ id: 'test' as TabId, label: 'OCR Test', icon: '\u{1F50D}', shortcut: 'Alt+2' },
|
||||
{ id: 'batch' as TabId, label: 'Batch OCR', icon: '\u{1F4C1}', shortcut: 'Alt+3' },
|
||||
{ id: 'training' as TabId, label: 'Training', icon: '\u{1F3AF}', shortcut: 'Alt+4' },
|
||||
{ id: 'architecture' as TabId, label: 'Architektur', icon: '\u{1F3D7}\uFE0F', shortcut: 'Alt+5' },
|
||||
{ id: 'settings' as TabId, label: 'Einstellungen', icon: '\u2699\uFE0F', shortcut: 'Alt+6' },
|
||||
] as const
|
||||
|
||||
export const API_BASE = '/klausur-api'
|
||||
382
admin-lehrer/app/(admin)/ai/magic-help/useMagicHelp.ts
Normal file
382
admin-lehrer/app/(admin)/ai/magic-help/useMagicHelp.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
type TabId,
|
||||
type TrOCRStatus,
|
||||
type OCRResult,
|
||||
type TrainingExample,
|
||||
type MagicSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
API_BASE,
|
||||
} from './types'
|
||||
|
||||
function playSuccessSound() {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gainNode = audioContext.createGain()
|
||||
|
||||
oscillator.connect(gainNode)
|
||||
gainNode.connect(audioContext.destination)
|
||||
|
||||
oscillator.frequency.value = 800
|
||||
oscillator.type = 'sine'
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime)
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2)
|
||||
|
||||
oscillator.start(audioContext.currentTime)
|
||||
oscillator.stop(audioContext.currentTime + 0.2)
|
||||
} catch {
|
||||
// Audio not supported, ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useMagicHelp() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [status, setStatus] = useState<TrOCRStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
|
||||
const [ocrLoading, setOcrLoading] = useState(false)
|
||||
const [examples, setExamples] = useState<TrainingExample[]>([])
|
||||
const [trainingImage, setTrainingImage] = useState<File | null>(null)
|
||||
const [trainingText, setTrainingText] = useState('')
|
||||
const [fineTuning, setFineTuning] = useState(false)
|
||||
const [settings, setSettings] = useState<MagicSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsSaved, setSettingsSaved] = useState(false)
|
||||
|
||||
// Phase 1: New state for enhanced features
|
||||
const [globalDragActive, setGlobalDragActive] = useState(false)
|
||||
const [uploadedImage, setUploadedImage] = useState<File | null>(null)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||
const [showShortcutHint, setShowShortcutHint] = useState(false)
|
||||
const [showHeatmap, setShowHeatmap] = useState(false)
|
||||
const [showTrainingDashboard, setShowTrainingDashboard] = useState(false)
|
||||
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const dragCounter = useRef(0)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/status`)
|
||||
const data = await res.json()
|
||||
setStatus(data)
|
||||
} catch {
|
||||
setStatus({ status: 'error', error: 'Failed to fetch status' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchExamples = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/examples`)
|
||||
const data = await res.json()
|
||||
setExamples(data.examples || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch examples:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Phase 1: Live OCR with debounce
|
||||
const triggerOCR = useCallback(async (file: File) => {
|
||||
setOcrLoading(true)
|
||||
setOcrResult(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/extract?detect_lines=${settings.autoDetectLines}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.text !== undefined) {
|
||||
setOcrResult(data)
|
||||
if (settings.soundFeedback && data.confidence > 0.7) {
|
||||
playSuccessSound()
|
||||
}
|
||||
} else {
|
||||
setOcrResult({ text: `Error: ${data.detail || 'Unknown error'}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
|
||||
}
|
||||
} catch (error) {
|
||||
setOcrResult({ text: `Error: ${error}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
|
||||
} finally {
|
||||
setOcrLoading(false)
|
||||
}
|
||||
}, [settings.autoDetectLines, settings.soundFeedback])
|
||||
|
||||
// Handle file upload with live preview
|
||||
const handleFileUpload = useCallback((file: File) => {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
|
||||
setUploadedImage(file)
|
||||
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
setImagePreview(previewUrl)
|
||||
|
||||
setActiveTab('test')
|
||||
|
||||
if (settings.livePreview) {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
triggerOCR(file)
|
||||
}, 500)
|
||||
}
|
||||
}, [settings.livePreview, triggerOCR])
|
||||
|
||||
const handleManualOCR = () => {
|
||||
if (uploadedImage) {
|
||||
triggerOCR(uploadedImage)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Global Drag & Drop handler
|
||||
useEffect(() => {
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounter.current++
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
setGlobalDragActive(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounter.current--
|
||||
if (dragCounter.current === 0) {
|
||||
setGlobalDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounter.current = 0
|
||||
setGlobalDragActive(false)
|
||||
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file?.type.startsWith('image/')) {
|
||||
handleFileUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', handleDragEnter)
|
||||
document.addEventListener('dragleave', handleDragLeave)
|
||||
document.addEventListener('dragover', handleDragOver)
|
||||
document.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', handleDragEnter)
|
||||
document.removeEventListener('dragleave', handleDragLeave)
|
||||
document.removeEventListener('dragover', handleDragOver)
|
||||
document.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleFileUpload])
|
||||
|
||||
// Phase 1: Clipboard paste handler (Ctrl+V)
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
handleFileUpload(file)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
}, [handleFileUpload])
|
||||
|
||||
// Phase 1: Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'Enter' && uploadedImage) {
|
||||
e.preventDefault()
|
||||
handleManualOCR()
|
||||
}
|
||||
|
||||
if (e.key >= '1' && e.key <= '6' && e.altKey) {
|
||||
e.preventDefault()
|
||||
const tabIndex = parseInt(e.key) - 1
|
||||
const tabIds: TabId[] = ['overview', 'test', 'batch', 'training', 'architecture', 'settings']
|
||||
if (tabIds[tabIndex]) {
|
||||
setActiveTab(tabIds[tabIndex])
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && uploadedImage) {
|
||||
setUploadedImage(null)
|
||||
setImagePreview(null)
|
||||
setOcrResult(null)
|
||||
}
|
||||
|
||||
if (e.key === '?') {
|
||||
setShowShortcutHint(prev => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploadedImage])
|
||||
|
||||
// Initial data load + settings from localStorage
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
fetchExamples()
|
||||
const saved = localStorage.getItem('magic-help-settings')
|
||||
if (saved) {
|
||||
try {
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...JSON.parse(saved) })
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [fetchStatus, fetchExamples])
|
||||
|
||||
// Cleanup preview URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imagePreview) {
|
||||
URL.revokeObjectURL(imagePreview)
|
||||
}
|
||||
}
|
||||
}, [imagePreview])
|
||||
|
||||
const handleAddTrainingExample = async () => {
|
||||
if (!trainingImage || !trainingText.trim()) {
|
||||
alert('Please provide both an image and the correct text')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', trainingImage)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/add?ground_truth=${encodeURIComponent(trainingText)}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.example_id) {
|
||||
alert(`Training example added! Total: ${data.total_examples}`)
|
||||
setTrainingImage(null)
|
||||
setTrainingText('')
|
||||
fetchStatus()
|
||||
fetchExamples()
|
||||
} else {
|
||||
alert(`Error: ${data.detail || 'Unknown error'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFineTune = async () => {
|
||||
if (!confirm('Start fine-tuning? This may take several minutes.')) return
|
||||
|
||||
setFineTuning(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/fine-tune`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
epochs: settings.epochs,
|
||||
learning_rate: settings.learningRate,
|
||||
lora_rank: settings.loraRank,
|
||||
lora_alpha: settings.loraAlpha,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.status === 'success') {
|
||||
alert(`Fine-tuning successful!\nExamples used: ${data.examples_used}\nEpochs: ${data.epochs}`)
|
||||
fetchStatus()
|
||||
} else {
|
||||
alert(`Fine-tuning failed: ${data.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error}`)
|
||||
} finally {
|
||||
setFineTuning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
localStorage.setItem('magic-help-settings', JSON.stringify(settings))
|
||||
setSettingsSaved(true)
|
||||
setTimeout(() => setSettingsSaved(false), 2000)
|
||||
}
|
||||
|
||||
const clearUploadedImage = () => {
|
||||
setUploadedImage(null)
|
||||
setImagePreview(null)
|
||||
setOcrResult(null)
|
||||
}
|
||||
|
||||
const sendToTraining = () => {
|
||||
if (uploadedImage && ocrResult) {
|
||||
setTrainingImage(uploadedImage)
|
||||
setTrainingText(ocrResult.text)
|
||||
setActiveTab('training')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
status,
|
||||
loading,
|
||||
ocrResult,
|
||||
ocrLoading,
|
||||
examples,
|
||||
trainingImage,
|
||||
setTrainingImage,
|
||||
trainingText,
|
||||
setTrainingText,
|
||||
fineTuning,
|
||||
settings,
|
||||
setSettings,
|
||||
settingsSaved,
|
||||
globalDragActive,
|
||||
uploadedImage,
|
||||
imagePreview,
|
||||
showShortcutHint,
|
||||
setShowShortcutHint,
|
||||
showHeatmap,
|
||||
setShowHeatmap,
|
||||
showTrainingDashboard,
|
||||
setShowTrainingDashboard,
|
||||
|
||||
// Actions
|
||||
fetchStatus,
|
||||
handleFileUpload,
|
||||
handleManualOCR,
|
||||
handleAddTrainingExample,
|
||||
handleFineTune,
|
||||
saveSettings,
|
||||
clearUploadedImage,
|
||||
sendToTraining,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseMagicHelpReturn = ReturnType<typeof useMagicHelp>
|
||||
File diff suppressed because it is too large
Load Diff
420
admin-lehrer/app/(admin)/ai/ocr-ground-truth/page.tsx
Normal file
420
admin-lehrer/app/(admin)/ai/ocr-ground-truth/page.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Ground-Truth Queue & Progress
|
||||
*
|
||||
* Overview page showing all sessions with their GT status.
|
||||
* Clicking a session opens it in the Kombi Pipeline (/ai/ocr-overlay)
|
||||
* where the actual review (split-view, inline edit, GT marking) happens.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
name: string
|
||||
filename: string
|
||||
status: string
|
||||
created_at: string
|
||||
document_category: string | null
|
||||
has_ground_truth: boolean
|
||||
}
|
||||
|
||||
interface GTSession {
|
||||
session_id: string
|
||||
name: string
|
||||
filename: string
|
||||
document_category: string | null
|
||||
pipeline: string | null
|
||||
saved_at: string | null
|
||||
summary: {
|
||||
total_zones: number
|
||||
total_columns: number
|
||||
total_rows: number
|
||||
total_cells: number
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function GroundTruthQueuePage() {
|
||||
const router = useRouter()
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([])
|
||||
const [gtSessions, setGtSessions] = useState<GTSession[]>([])
|
||||
const [filter, setFilter] = useState<'all' | 'unreviewed' | 'reviewed'>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||
const [marking, setMarking] = useState(false)
|
||||
const [markResult, setMarkResult] = useState<string | null>(null)
|
||||
|
||||
// Load sessions + GT sessions
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [sessRes, gtRes] = await Promise.all([
|
||||
fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions?limit=200`),
|
||||
fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/ground-truth-sessions`),
|
||||
])
|
||||
|
||||
if (sessRes.ok) {
|
||||
const data = await sessRes.json()
|
||||
const gtSet = new Set<string>()
|
||||
|
||||
if (gtRes.ok) {
|
||||
const gtData = await gtRes.json()
|
||||
const gts: GTSession[] = gtData.sessions || []
|
||||
setGtSessions(gts)
|
||||
for (const g of gts) gtSet.add(g.session_id)
|
||||
}
|
||||
|
||||
const sessions: Session[] = (data.sessions || [])
|
||||
.filter((s: any) => !s.parent_session_id)
|
||||
.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name || '',
|
||||
filename: s.filename || '',
|
||||
status: s.status || 'active',
|
||||
created_at: s.created_at || '',
|
||||
document_category: s.document_category || null,
|
||||
has_ground_truth: gtSet.has(s.id),
|
||||
}))
|
||||
setAllSessions(sessions)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Filtered sessions
|
||||
const filteredSessions = allSessions.filter((s) => {
|
||||
if (filter === 'unreviewed') return !s.has_ground_truth
|
||||
if (filter === 'reviewed') return s.has_ground_truth
|
||||
return true
|
||||
})
|
||||
|
||||
const reviewedCount = allSessions.filter((s) => s.has_ground_truth).length
|
||||
const totalCount = allSessions.length
|
||||
const pct = totalCount > 0 ? Math.round((reviewedCount / totalCount) * 100) : 0
|
||||
|
||||
// Open session in Kombi pipeline
|
||||
const openInPipeline = (sessionId: string) => {
|
||||
router.push(`/ai/ocr-overlay?session=${sessionId}&mode=kombi`)
|
||||
}
|
||||
|
||||
// Batch mark as GT
|
||||
const batchMark = async () => {
|
||||
setMarking(true)
|
||||
let success = 0
|
||||
for (const sid of selectedSessions) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}/mark-ground-truth?pipeline=kombi`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (res.ok) success++
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
setSelectedSessions(new Set())
|
||||
setMarking(false)
|
||||
setMarkResult(`${success} Sessions als Ground Truth markiert`)
|
||||
setTimeout(() => setMarkResult(null), 3000)
|
||||
loadData()
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedSessions((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (selectedSessions.size === filteredSessions.length) {
|
||||
setSelectedSessions(new Set())
|
||||
} else {
|
||||
setSelectedSessions(new Set(filteredSessions.map((s) => s.id)))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="max-w-5xl mx-auto p-4 space-y-4">
|
||||
<PagePurpose
|
||||
title="Ground Truth Queue"
|
||||
purpose="Uebersicht aller OCR-Sessions und deren Ground-Truth-Status. Zum Pruefen und Korrigieren eine Session oeffnen — sie wird im Kombi-Modus (OCR Overlay) bearbeitet."
|
||||
audience={['Entwickler', 'QA']}
|
||||
defaultCollapsed
|
||||
architecture={{
|
||||
services: ['klausur-service (FastAPI, Port 8086)'],
|
||||
databases: ['PostgreSQL (ocr_pipeline_sessions)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{
|
||||
name: 'Kombi Pipeline',
|
||||
href: '/ai/ocr-overlay',
|
||||
description: 'Sessions bearbeiten und GT markieren',
|
||||
},
|
||||
{
|
||||
name: 'OCR Regression',
|
||||
href: '/ai/ocr-regression',
|
||||
description: 'Regressions-Tests',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-bold text-slate-900">
|
||||
Ground Truth Fortschritt
|
||||
</h2>
|
||||
<span className="text-sm text-slate-500">
|
||||
{reviewedCount} von {totalCount} markiert ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-teal-500 h-2.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-teal-400" />
|
||||
{reviewedCount} Ground Truth
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-300" />
|
||||
{totalCount - reviewedCount} offen
|
||||
</span>
|
||||
<span>
|
||||
{gtSessions.reduce((sum, g) => sum + g.summary.total_cells, 0)}{' '}
|
||||
Referenz-Zellen gesamt
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter + Actions */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex gap-1 bg-slate-100 rounded-lg p-1">
|
||||
{(['all', 'unreviewed', 'reviewed'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
filter === f
|
||||
? 'bg-white text-slate-900 shadow-sm font-medium'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{f === 'all'
|
||||
? 'Alle'
|
||||
: f === 'unreviewed'
|
||||
? 'Offen'
|
||||
: 'Ground Truth'}
|
||||
<span className="ml-1 text-xs text-slate-400">
|
||||
(
|
||||
{
|
||||
allSessions.filter((s) =>
|
||||
f === 'unreviewed'
|
||||
? !s.has_ground_truth
|
||||
: f === 'reviewed'
|
||||
? s.has_ground_truth
|
||||
: true,
|
||||
).length
|
||||
}
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selectedSessions.size > 0 && (
|
||||
<button
|
||||
onClick={batchMark}
|
||||
disabled={marking}
|
||||
className="px-3 py-1.5 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{marking
|
||||
? 'Markiere...'
|
||||
: `${selectedSessions.size} als GT markieren`}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="px-3 py-1.5 text-sm text-slate-500 hover:text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
{selectedSessions.size === filteredSessions.length
|
||||
? 'Keine auswaehlen'
|
||||
: 'Alle auswaehlen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{markResult && (
|
||||
<div className="p-3 rounded-lg text-sm bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
{markResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
Lade Sessions...
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-lg">Keine Sessions in dieser Ansicht</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-left text-slate-500">
|
||||
<th className="px-4 py-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selectedSessions.size === filteredSessions.length &&
|
||||
filteredSessions.length > 0
|
||||
}
|
||||
onChange={selectAll}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Session</th>
|
||||
<th className="px-4 py-2 font-medium">Kategorie</th>
|
||||
<th className="px-4 py-2 font-medium">Erstellt</th>
|
||||
<th className="px-4 py-2 font-medium text-right">
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSessions.map((s) => {
|
||||
const gt = gtSessions.find((g) => g.session_id === s.id)
|
||||
return (
|
||||
<tr
|
||||
key={s.id}
|
||||
className="border-b border-slate-50 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSessions.has(s.id)}
|
||||
onChange={() => toggleSelect(s.id)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{s.has_ground_truth ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 border border-emerald-200">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
GT
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-500 border border-slate-200">
|
||||
Offen
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded bg-slate-100 overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=64`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLImageElement).style.display =
|
||||
'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-slate-900 truncate">
|
||||
{s.name || s.filename || s.id.slice(0, 8)}
|
||||
</div>
|
||||
{gt && (
|
||||
<div className="text-xs text-slate-400">
|
||||
{gt.summary.total_cells} Zellen,{' '}
|
||||
{gt.summary.total_zones} Zonen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{s.document_category ? (
|
||||
<span className="text-xs bg-slate-100 px-1.5 py-0.5 rounded text-slate-600">
|
||||
{s.document_category}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-500">
|
||||
{new Date(s.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() => openInPipeline(s.id)}
|
||||
className="px-3 py-1 text-xs bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
{s.has_ground_truth
|
||||
? 'Ueberpruefen'
|
||||
: 'Im Kombi-Modus oeffnen'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
admin-lehrer/app/(admin)/ai/ocr-kombi/page.tsx
Normal file
173
admin-lehrer/app/(admin)/ai/ocr-kombi/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { KombiStepper } from '@/components/ocr-kombi/KombiStepper'
|
||||
import { SessionList } from '@/components/ocr-kombi/SessionList'
|
||||
import { SessionHeader } from '@/components/ocr-kombi/SessionHeader'
|
||||
import { StepUpload } from '@/components/ocr-kombi/StepUpload'
|
||||
import { StepOrientation } from '@/components/ocr-kombi/StepOrientation'
|
||||
import { StepPageSplit } from '@/components/ocr-kombi/StepPageSplit'
|
||||
import { StepDeskew } from '@/components/ocr-kombi/StepDeskew'
|
||||
import { StepDewarp } from '@/components/ocr-kombi/StepDewarp'
|
||||
import { StepContentCrop } from '@/components/ocr-kombi/StepContentCrop'
|
||||
import { StepOcr } from '@/components/ocr-kombi/StepOcr'
|
||||
import { StepStructure } from '@/components/ocr-kombi/StepStructure'
|
||||
import { StepGridBuild } from '@/components/ocr-kombi/StepGridBuild'
|
||||
import { StepGridReview } from '@/components/ocr-kombi/StepGridReview'
|
||||
import { StepGutterRepair } from '@/components/ocr-kombi/StepGutterRepair'
|
||||
import { StepBoxGridReview } from '@/components/ocr-kombi/StepBoxGridReview'
|
||||
import { StepAnsicht } from '@/components/ocr-kombi/StepAnsicht'
|
||||
import { StepGroundTruth } from '@/components/ocr-kombi/StepGroundTruth'
|
||||
import { useKombiPipeline } from './useKombiPipeline'
|
||||
|
||||
function OcrKombiContent() {
|
||||
const {
|
||||
currentStep,
|
||||
sessionId,
|
||||
sessionName,
|
||||
loadingSessions,
|
||||
activeCategory,
|
||||
isGroundTruth,
|
||||
pageNumber,
|
||||
steps,
|
||||
gridSaveRef,
|
||||
groupedSessions,
|
||||
loadSessions,
|
||||
openSession,
|
||||
handleStepClick,
|
||||
handleNext,
|
||||
handleNewSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
updateCategory,
|
||||
setSessionId,
|
||||
setSessionName,
|
||||
setIsGroundTruth,
|
||||
} = useKombiPipeline()
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return (
|
||||
<StepUpload
|
||||
sessionId={sessionId}
|
||||
onUploaded={(sid, name) => {
|
||||
setSessionId(sid)
|
||||
setSessionName(name)
|
||||
loadSessions()
|
||||
}}
|
||||
onNext={handleNext}
|
||||
/>
|
||||
)
|
||||
case 1:
|
||||
return (
|
||||
<StepOrientation
|
||||
sessionId={sessionId}
|
||||
onNext={() => handleNext()}
|
||||
onSessionList={() => { loadSessions(); handleNewSession() }}
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<StepPageSplit
|
||||
sessionId={sessionId}
|
||||
sessionName={sessionName}
|
||||
onNext={handleNext}
|
||||
onSplitComplete={(childId, childName) => {
|
||||
// Switch to the first child session and refresh the list
|
||||
setSessionId(childId)
|
||||
setSessionName(childName)
|
||||
loadSessions()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
return <StepDeskew sessionId={sessionId} onNext={handleNext} />
|
||||
case 4:
|
||||
return <StepDewarp sessionId={sessionId} onNext={handleNext} />
|
||||
case 5:
|
||||
return <StepContentCrop sessionId={sessionId} onNext={handleNext} />
|
||||
case 6:
|
||||
return <StepOcr sessionId={sessionId} onNext={handleNext} />
|
||||
case 7:
|
||||
return <StepStructure sessionId={sessionId} onNext={handleNext} />
|
||||
case 8:
|
||||
return <StepGridBuild sessionId={sessionId} onNext={handleNext} />
|
||||
case 9:
|
||||
return <StepGridReview sessionId={sessionId} onNext={handleNext} saveRef={gridSaveRef} />
|
||||
case 10:
|
||||
return <StepGutterRepair sessionId={sessionId} onNext={handleNext} />
|
||||
case 11:
|
||||
return <StepBoxGridReview sessionId={sessionId} onNext={handleNext} />
|
||||
case 12:
|
||||
return <StepAnsicht sessionId={sessionId} onNext={handleNext} />
|
||||
case 13:
|
||||
return (
|
||||
<StepGroundTruth
|
||||
sessionId={sessionId}
|
||||
isGroundTruth={isGroundTruth}
|
||||
onMarked={() => setIsGroundTruth(true)}
|
||||
gridSaveRef={gridSaveRef}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PagePurpose
|
||||
title="OCR Kombi Pipeline"
|
||||
purpose="Modulare 11-Schritt-Pipeline: Upload, Vorverarbeitung, Dual-Engine-OCR (PP-OCRv5 + Tesseract), Strukturerkennung, Grid-Aufbau und Review. Multi-Page-Dokument-Unterstuetzung."
|
||||
audience={['Entwickler']}
|
||||
architecture={{
|
||||
services: ['klausur-service (FastAPI)', 'OpenCV', 'Tesseract', 'PaddleOCR'],
|
||||
databases: ['PostgreSQL Sessions'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'OCR Regression', href: '/ai/ocr-regression', description: 'Regressionstests' },
|
||||
]}
|
||||
defaultCollapsed
|
||||
/>
|
||||
|
||||
<SessionList
|
||||
items={groupedSessions()}
|
||||
loading={loadingSessions}
|
||||
activeSessionId={sessionId}
|
||||
onOpenSession={(sid) => openSession(sid)}
|
||||
onNewSession={handleNewSession}
|
||||
onDeleteSession={deleteSession}
|
||||
onRenameSession={renameSession}
|
||||
onUpdateCategory={updateCategory}
|
||||
/>
|
||||
|
||||
{sessionId && sessionName && (
|
||||
<SessionHeader
|
||||
sessionName={sessionName}
|
||||
activeCategory={activeCategory}
|
||||
isGroundTruth={isGroundTruth}
|
||||
pageNumber={pageNumber}
|
||||
onUpdateCategory={(cat) => updateCategory(sessionId, cat)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<KombiStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepClick={handleStepClick}
|
||||
/>
|
||||
|
||||
<div className="min-h-[400px]">{renderStep()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OcrKombiPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-4 text-sm text-gray-400">Lade...</div>}>
|
||||
<OcrKombiContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
266
admin-lehrer/app/(admin)/ai/ocr-kombi/types.ts
Normal file
266
admin-lehrer/app/(admin)/ai/ocr-kombi/types.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
// OCR Pipeline Types — migrated from deleted ocr-pipeline/types.ts
|
||||
|
||||
export type PipelineStepStatus = 'pending' | 'active' | 'completed' | 'failed' | 'skipped'
|
||||
|
||||
export interface PipelineStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: PipelineStepStatus
|
||||
}
|
||||
|
||||
export type DocumentCategory =
|
||||
| 'vokabelseite' | 'woerterbuch' | 'buchseite' | 'arbeitsblatt' | 'klausurseite'
|
||||
| 'mathearbeit' | 'statistik' | 'zeitung' | 'formular' | 'handschrift' | 'sonstiges'
|
||||
|
||||
export const DOCUMENT_CATEGORIES: { value: DocumentCategory; label: string; icon: string }[] = [
|
||||
{ value: 'vokabelseite', label: 'Vokabelseite', icon: '📖' },
|
||||
{ value: 'woerterbuch', label: 'Woerterbuch', icon: '📕' },
|
||||
{ value: 'buchseite', label: 'Buchseite', icon: '📚' },
|
||||
{ value: 'arbeitsblatt', label: 'Arbeitsblatt', icon: '📝' },
|
||||
{ value: 'klausurseite', label: 'Klausurseite', icon: '📄' },
|
||||
{ value: 'mathearbeit', label: 'Mathearbeit', icon: '🔢' },
|
||||
{ value: 'statistik', label: 'Statistik', icon: '📊' },
|
||||
{ value: 'zeitung', label: 'Zeitung', icon: '📰' },
|
||||
{ value: 'formular', label: 'Formular', icon: '📋' },
|
||||
{ value: 'handschrift', label: 'Handschrift', icon: '✍️' },
|
||||
{ value: 'sonstiges', label: 'Sonstiges', icon: '📎' },
|
||||
]
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string
|
||||
name: string
|
||||
filename: string
|
||||
status: string
|
||||
current_step: number
|
||||
document_category?: DocumentCategory
|
||||
doc_type?: string
|
||||
parent_session_id?: string
|
||||
document_group_id?: string
|
||||
page_number?: number
|
||||
is_ground_truth?: boolean
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface SubSession {
|
||||
id: string
|
||||
name: string
|
||||
box_index: number
|
||||
current_step?: number
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface OrientationResult {
|
||||
orientation_degrees: number
|
||||
corrected: boolean
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface CropResult {
|
||||
crop_applied: boolean
|
||||
crop_rect?: { x: number; y: number; width: number; height: number }
|
||||
crop_rect_pct?: { x: number; y: number; width: number; height: number }
|
||||
original_size: { width: number; height: number }
|
||||
cropped_size: { width: number; height: number }
|
||||
detected_format?: string
|
||||
format_confidence?: number
|
||||
aspect_ratio?: number
|
||||
border_fractions?: { top: number; bottom: number; left: number; right: number }
|
||||
skipped?: boolean
|
||||
duration_seconds?: number
|
||||
}
|
||||
|
||||
export interface DeskewResult {
|
||||
session_id: string
|
||||
angle_hough: number
|
||||
angle_word_alignment: number
|
||||
angle_iterative?: number
|
||||
angle_residual?: number
|
||||
angle_textline?: number
|
||||
angle_applied: number
|
||||
method_used: 'hough' | 'word_alignment' | 'manual' | 'iterative' | 'two_pass' | 'three_pass' | 'manual_combined'
|
||||
confidence: number
|
||||
duration_seconds: number
|
||||
deskewed_image_url: string
|
||||
binarized_image_url: string
|
||||
}
|
||||
|
||||
export interface DewarpDetection {
|
||||
method: string
|
||||
shear_degrees: number
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface DewarpResult {
|
||||
session_id: string
|
||||
method_used: string
|
||||
shear_degrees: number
|
||||
confidence: number
|
||||
duration_seconds: number
|
||||
dewarped_image_url: string
|
||||
detections?: DewarpDetection[]
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
session_id: string
|
||||
filename: string
|
||||
name?: string
|
||||
image_width: number
|
||||
image_height: number
|
||||
original_image_url: string
|
||||
current_step?: number
|
||||
document_category?: DocumentCategory
|
||||
doc_type?: string
|
||||
orientation_result?: OrientationResult
|
||||
crop_result?: CropResult
|
||||
deskew_result?: DeskewResult
|
||||
dewarp_result?: DewarpResult
|
||||
sub_sessions?: SubSession[]
|
||||
parent_session_id?: string
|
||||
box_index?: number
|
||||
document_group_id?: string
|
||||
page_number?: number
|
||||
}
|
||||
|
||||
export interface StructureGraphic {
|
||||
x: number; y: number; w: number; h: number
|
||||
area: number; shape: string; color_name: string; color_hex: string; confidence: number
|
||||
}
|
||||
|
||||
export interface ExcludeRegion {
|
||||
x: number; y: number; w: number; h: number; label?: string
|
||||
}
|
||||
|
||||
export interface StructureBox {
|
||||
x: number; y: number; w: number; h: number
|
||||
confidence: number; border_thickness: number
|
||||
bg_color_name?: string; bg_color_hex?: string
|
||||
}
|
||||
|
||||
export interface StructureZone {
|
||||
index: number; zone_type: 'content' | 'box'
|
||||
x: number; y: number; w: number; h: number
|
||||
}
|
||||
|
||||
export interface DocLayoutRegion {
|
||||
x: number; y: number; w: number; h: number
|
||||
class_name: string; confidence: number
|
||||
}
|
||||
|
||||
export interface StructureResult {
|
||||
image_width: number; image_height: number
|
||||
content_bounds: { x: number; y: number; w: number; h: number }
|
||||
boxes: StructureBox[]; zones: StructureZone[]
|
||||
graphics: StructureGraphic[]; exclude_regions?: ExcludeRegion[]
|
||||
color_pixel_counts: Record<string, number>
|
||||
has_words: boolean; word_count: number
|
||||
border_ghosts_removed?: number; duration_seconds: number
|
||||
layout_regions?: DocLayoutRegion[]
|
||||
detection_method?: 'opencv' | 'ppdoclayout'
|
||||
}
|
||||
|
||||
export interface WordBbox { x: number; y: number; w: number; h: number }
|
||||
|
||||
export interface OcrWordBox {
|
||||
text: string; left: number; top: number; width: number; height: number; conf: number
|
||||
color?: string; color_name?: string; recovered?: boolean
|
||||
}
|
||||
|
||||
export interface ColumnMeta { index: number; type: string; x: number; width: number }
|
||||
|
||||
export interface GridCell {
|
||||
cell_id: string; row_index: number; col_index: number; col_type: string
|
||||
text: string; confidence: number; bbox_px: WordBbox; bbox_pct: WordBbox
|
||||
ocr_engine?: string; is_bold?: boolean
|
||||
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
|
||||
word_boxes?: OcrWordBox[]
|
||||
}
|
||||
|
||||
export interface WordEntry {
|
||||
row_index: number; english: string; german: string; example: string
|
||||
source_page?: string; marker?: string; confidence: number
|
||||
bbox: WordBbox; bbox_en: WordBbox | null; bbox_de: WordBbox | null; bbox_ex: WordBbox | null
|
||||
bbox_ref?: WordBbox | null; bbox_marker?: WordBbox | null
|
||||
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
|
||||
}
|
||||
|
||||
export interface GridResult {
|
||||
cells: GridCell[]
|
||||
grid_shape: { rows: number; cols: number; total_cells: number }
|
||||
columns_used: ColumnMeta[]
|
||||
layout: 'vocab' | 'generic'
|
||||
image_width: number; image_height: number; duration_seconds: number
|
||||
ocr_engine?: string; vocab_entries?: WordEntry[]; entries?: WordEntry[]; entry_count?: number
|
||||
summary: {
|
||||
total_cells: number; non_empty_cells: number; low_confidence: number
|
||||
total_entries?: number; with_english?: number; with_german?: number
|
||||
}
|
||||
llm_review?: {
|
||||
changes: { row_index: number; field: string; old: string; new: string }[]
|
||||
model_used: string; duration_ms: number; entries_corrected: number
|
||||
applied_count?: number; applied_at?: string
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kombi V2 Pipeline ---
|
||||
|
||||
export const KOMBI_V2_STEPS: PipelineStep[] = [
|
||||
{ id: 'upload', name: 'Upload', icon: '📤', status: 'pending' },
|
||||
{ id: 'orientation', name: 'Orientierung', icon: '🔄', status: 'pending' },
|
||||
{ id: 'page-split', name: 'Seitentrennung', icon: '📖', status: 'pending' },
|
||||
{ id: 'deskew', name: 'Begradigung', icon: '📐', status: 'pending' },
|
||||
{ id: 'dewarp', name: 'Entzerrung', icon: '🔧', status: 'pending' },
|
||||
{ id: 'content-crop', name: 'Zuschneiden', icon: '✂️', status: 'pending' },
|
||||
{ id: 'ocr', name: 'OCR', icon: '🔀', status: 'pending' },
|
||||
{ id: 'structure', name: 'Strukturerkennung', icon: '🔍', status: 'pending' },
|
||||
{ id: 'grid-build', name: 'Grid-Aufbau', icon: '🧱', status: 'pending' },
|
||||
{ id: 'grid-review', name: 'Grid-Review', icon: '📊', status: 'pending' },
|
||||
{ id: 'gutter-repair', name: 'Wortkorrektur', icon: '🩹', status: 'pending' },
|
||||
{ id: 'box-review', name: 'Box-Review', icon: '📦', status: 'pending' },
|
||||
{ id: 'ansicht', name: 'Ansicht', icon: '👁️', status: 'pending' },
|
||||
{ id: 'ground-truth', name: 'Ground Truth', icon: '✅', status: 'pending' },
|
||||
]
|
||||
|
||||
export const KOMBI_V2_UI_TO_DB: Record<number, number> = {
|
||||
0: 1, 1: 2, 2: 2, 3: 3, 4: 4, 5: 5, 6: 8, 7: 9, 8: 10, 9: 11, 10: 11, 11: 11, 12: 11, 13: 12,
|
||||
}
|
||||
|
||||
export function dbStepToKombiV2Ui(dbStep: number): number {
|
||||
if (dbStep <= 1) return 0
|
||||
if (dbStep === 2) return 1
|
||||
if (dbStep === 3) return 3
|
||||
if (dbStep === 4) return 4
|
||||
if (dbStep === 5) return 5
|
||||
if (dbStep <= 8) return 6
|
||||
if (dbStep === 9) return 7
|
||||
if (dbStep === 10) return 8
|
||||
if (dbStep === 11) return 9
|
||||
return 13
|
||||
}
|
||||
|
||||
export interface DocumentGroup {
|
||||
group_id: string; title: string; page_count: number; sessions: DocumentGroupSession[]
|
||||
}
|
||||
|
||||
export interface DocumentGroupSession {
|
||||
id: string; name: string; page_number: number; current_step: number
|
||||
status: string; document_category?: DocumentCategory; created_at: string
|
||||
}
|
||||
|
||||
export type OcrEngineSource = 'both' | 'paddle_only' | 'tesseract_only' | 'conflict_paddle' | 'conflict_tesseract'
|
||||
|
||||
export interface OcrTransparentWord {
|
||||
text: string; left: number; top: number; width: number; height: number
|
||||
conf: number; engine_source: OcrEngineSource
|
||||
}
|
||||
|
||||
export interface OcrTransparentResult {
|
||||
raw_tesseract: { words: OcrTransparentWord[] }
|
||||
raw_paddle: { words: OcrTransparentWord[] }
|
||||
merged: { words: OcrTransparentWord[] }
|
||||
stats: {
|
||||
total_words: number; both_agree: number; paddle_only: number
|
||||
tesseract_only: number; conflict_paddle_wins: number; conflict_tesseract_wins: number
|
||||
}
|
||||
}
|
||||
298
admin-lehrer/app/(admin)/ai/ocr-kombi/useKombiPipeline.ts
Normal file
298
admin-lehrer/app/(admin)/ai/ocr-kombi/useKombiPipeline.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import type { PipelineStep, DocumentCategory, SessionListItem } from './types'
|
||||
import { KOMBI_V2_STEPS, dbStepToKombiV2Ui } from './types'
|
||||
|
||||
export type { SessionListItem }
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
/** Groups sessions by document_group_id for the session list */
|
||||
export interface DocumentGroupView {
|
||||
group_id: string
|
||||
title: string
|
||||
sessions: SessionListItem[]
|
||||
page_count: number
|
||||
}
|
||||
|
||||
function initSteps(): PipelineStep[] {
|
||||
return KOMBI_V2_STEPS.map((s, i) => ({
|
||||
...s,
|
||||
status: i === 0 ? 'active' : 'pending',
|
||||
}))
|
||||
}
|
||||
|
||||
export function useKombiPipeline() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [sessionName, setSessionName] = useState('')
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
||||
const [loadingSessions, setLoadingSessions] = useState(true)
|
||||
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
|
||||
const [isGroundTruth, setIsGroundTruth] = useState(false)
|
||||
const [pageNumber, setPageNumber] = useState<number | null>(null)
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(initSteps())
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const deepLinkHandled = useRef(false)
|
||||
const gridSaveRef = useRef<(() => Promise<void>) | null>(null)
|
||||
|
||||
// ---- Session loading ----
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoadingSessions(true)
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSessions((data.sessions || []).filter((s: SessionListItem) => !s.parent_session_id))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions:', e)
|
||||
} finally {
|
||||
setLoadingSessions(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSessions() }, [loadSessions])
|
||||
|
||||
// ---- Group sessions by document_group_id ----
|
||||
|
||||
const groupedSessions = useCallback((): (SessionListItem | DocumentGroupView)[] => {
|
||||
const groups = new Map<string, SessionListItem[]>()
|
||||
const ungrouped: SessionListItem[] = []
|
||||
|
||||
for (const s of sessions) {
|
||||
if (s.document_group_id) {
|
||||
const existing = groups.get(s.document_group_id) || []
|
||||
existing.push(s)
|
||||
groups.set(s.document_group_id, existing)
|
||||
} else {
|
||||
ungrouped.push(s)
|
||||
}
|
||||
}
|
||||
|
||||
const result: (SessionListItem | DocumentGroupView)[] = []
|
||||
|
||||
// Sort groups by earliest created_at
|
||||
const sortedGroups = Array.from(groups.entries()).sort((a, b) => {
|
||||
const aTime = Math.min(...a[1].map(s => new Date(s.created_at).getTime()))
|
||||
const bTime = Math.min(...b[1].map(s => new Date(s.created_at).getTime()))
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
for (const [groupId, groupSessions] of sortedGroups) {
|
||||
groupSessions.sort((a, b) => (a.page_number || 0) - (b.page_number || 0))
|
||||
// Extract base title (remove " — S. X" suffix)
|
||||
const baseName = groupSessions[0]?.name?.replace(/ — S\. \d+$/, '') || 'Dokument'
|
||||
result.push({
|
||||
group_id: groupId,
|
||||
title: baseName,
|
||||
sessions: groupSessions,
|
||||
page_count: groupSessions.length,
|
||||
})
|
||||
}
|
||||
|
||||
for (const s of ungrouped) {
|
||||
result.push(s)
|
||||
}
|
||||
|
||||
// Sort by creation time (most recent first)
|
||||
const getTime = (item: SessionListItem | DocumentGroupView): number => {
|
||||
if ('group_id' in item) {
|
||||
return Math.min(...item.sessions.map((s: SessionListItem) => new Date(s.created_at).getTime()))
|
||||
}
|
||||
return new Date(item.created_at).getTime()
|
||||
}
|
||||
result.sort((a, b) => getTime(b) - getTime(a))
|
||||
|
||||
return result
|
||||
}, [sessions])
|
||||
|
||||
// ---- Open session ----
|
||||
|
||||
const openSession = useCallback(async (sid: string) => {
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
|
||||
setSessionId(sid)
|
||||
setSessionName(data.name || data.filename || '')
|
||||
setActiveCategory(data.document_category || undefined)
|
||||
setIsGroundTruth(!!data.ground_truth?.build_grid_reference)
|
||||
setPageNumber(data.grid_editor_result?.page_number?.number ?? null)
|
||||
|
||||
// Determine UI step from DB state
|
||||
const dbStep = data.current_step || 1
|
||||
const hasGrid = !!data.grid_editor_result
|
||||
const hasStructure = !!data.structure_result
|
||||
const hasWords = !!data.word_result
|
||||
const hasGutterRepair = !!(data.ground_truth?.gutter_repair)
|
||||
|
||||
let uiStep: number
|
||||
if (hasGrid && hasGutterRepair) {
|
||||
uiStep = 10 // gutter-repair (already analysed)
|
||||
} else if (hasGrid) {
|
||||
uiStep = 9 // grid-review
|
||||
} else if (hasStructure) {
|
||||
uiStep = 8 // grid-build
|
||||
} else if (hasWords) {
|
||||
uiStep = 7 // structure
|
||||
} else {
|
||||
uiStep = dbStepToKombiV2Ui(dbStep)
|
||||
}
|
||||
|
||||
// Sessions only exist after upload, so always skip the upload step
|
||||
if (uiStep === 0) {
|
||||
uiStep = 1
|
||||
}
|
||||
|
||||
setSteps(
|
||||
KOMBI_V2_STEPS.map((s, i) => ({
|
||||
...s,
|
||||
status: i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
setCurrentStep(uiStep)
|
||||
} catch (e) {
|
||||
console.error('Failed to open session:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---- Deep link handling ----
|
||||
|
||||
useEffect(() => {
|
||||
if (deepLinkHandled.current) return
|
||||
const urlSession = searchParams.get('session')
|
||||
const urlStep = searchParams.get('step')
|
||||
if (urlSession) {
|
||||
deepLinkHandled.current = true
|
||||
openSession(urlSession).then(() => {
|
||||
if (urlStep) {
|
||||
const stepIdx = parseInt(urlStep, 10)
|
||||
if (!isNaN(stepIdx) && stepIdx >= 0 && stepIdx < KOMBI_V2_STEPS.length) {
|
||||
setCurrentStep(stepIdx)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [searchParams, openSession])
|
||||
|
||||
// ---- Step navigation ----
|
||||
|
||||
const goToStep = useCallback((step: number) => {
|
||||
setCurrentStep(step)
|
||||
setSteps(prev =>
|
||||
prev.map((s, i) => ({
|
||||
...s,
|
||||
status: i < step ? 'completed' : i === step ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleStepClick = useCallback((index: number) => {
|
||||
if (index <= currentStep || steps[index].status === 'completed') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}, [currentStep, steps])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
// Last step → return to session list
|
||||
setSteps(initSteps())
|
||||
setCurrentStep(0)
|
||||
setSessionId(null)
|
||||
loadSessions()
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = currentStep + 1
|
||||
setSteps(prev =>
|
||||
prev.map((s, i) => {
|
||||
if (i === currentStep) return { ...s, status: 'completed' }
|
||||
if (i === nextStep) return { ...s, status: 'active' }
|
||||
return s
|
||||
}),
|
||||
)
|
||||
setCurrentStep(nextStep)
|
||||
}, [currentStep, steps, loadSessions])
|
||||
|
||||
// ---- Session CRUD ----
|
||||
|
||||
const handleNewSession = useCallback(() => {
|
||||
setSessionId(null)
|
||||
setSessionName('')
|
||||
setCurrentStep(0)
|
||||
setSteps(initSteps())
|
||||
}, [])
|
||||
|
||||
const deleteSession = useCallback(async (sid: string) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
|
||||
setSessions(prev => prev.filter(s => s.id !== sid))
|
||||
if (sessionId === sid) handleNewSession()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete session:', e)
|
||||
}
|
||||
}, [sessionId, handleNewSession])
|
||||
|
||||
const renameSession = useCallback(async (sid: string, newName: string) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName }),
|
||||
})
|
||||
setSessions(prev => prev.map(s => s.id === sid ? { ...s, name: newName } : s))
|
||||
if (sessionId === sid) setSessionName(newName)
|
||||
} catch (e) {
|
||||
console.error('Failed to rename session:', e)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document_category: category }),
|
||||
})
|
||||
setSessions(prev => prev.map(s => s.id === sid ? { ...s, document_category: category } : s))
|
||||
if (sessionId === sid) setActiveCategory(category)
|
||||
} catch (e) {
|
||||
console.error('Failed to update category:', e)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
return {
|
||||
// State
|
||||
currentStep,
|
||||
sessionId,
|
||||
sessionName,
|
||||
sessions,
|
||||
loadingSessions,
|
||||
activeCategory,
|
||||
isGroundTruth,
|
||||
pageNumber,
|
||||
steps,
|
||||
gridSaveRef,
|
||||
// Computed
|
||||
groupedSessions,
|
||||
// Actions
|
||||
loadSessions,
|
||||
openSession,
|
||||
goToStep,
|
||||
handleStepClick,
|
||||
handleNext,
|
||||
handleNewSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
updateCategory,
|
||||
setSessionId,
|
||||
setSessionName,
|
||||
setIsGroundTruth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Export tab: export training data in various formats.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { API_BASE } from '../constants'
|
||||
import type { OCRSession, OCRStats } from '../types'
|
||||
|
||||
interface ExportTabProps {
|
||||
sessions: OCRSession[]
|
||||
selectedSession: string | null
|
||||
setSelectedSession: (id: string | null) => void
|
||||
stats: OCRStats | null
|
||||
setError: (error: string | null) => void
|
||||
}
|
||||
|
||||
export function ExportTab({
|
||||
sessions,
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
stats,
|
||||
setError,
|
||||
}: ExportTabProps) {
|
||||
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportResult, setExportResult] = useState<{
|
||||
exported_count: number
|
||||
batch_id: string
|
||||
samples?: Array<Record<string, unknown>>
|
||||
} | null>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
export_format: exportFormat,
|
||||
session_id: selectedSession,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setExportResult(data)
|
||||
} else {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="generic">Generic JSON</option>
|
||||
<option value="trocr">TrOCR Fine-Tuning</option>
|
||||
<option value="llama_vision">Llama Vision Fine-Tuning</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Alle Sessions</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>{session.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || (stats?.exportable_items || 0) === 0}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
|
||||
</button>
|
||||
|
||||
{/* Cross-Link to Magic Help for TrOCR Fine-Tuning */}
|
||||
{exportFormat === 'trocr' && (stats?.exportable_items || 0) > 0 && (
|
||||
<Link
|
||||
href="/ai/magic-help?source=ocr-labeling"
|
||||
className="w-full mt-3 px-4 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-lg hover:bg-purple-200 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<span>✨</span>
|
||||
Mit Magic Help testen & fine-tunen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportResult && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-green-800">
|
||||
{exportResult.exported_count} Samples erfolgreich exportiert
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Batch: {exportResult.batch_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
|
||||
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
|
||||
{(exportResult.samples?.length || 0) > 3 && (
|
||||
<p className="text-slate-500 mt-2">... und {(exportResult.samples?.length ?? 0) - 3} weitere</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Labeling tab: image viewer, OCR text, correction input, and queue preview.
|
||||
*/
|
||||
|
||||
import { API_BASE } from '../constants'
|
||||
import type { OCRItem } from '../types'
|
||||
|
||||
interface LabelingTabProps {
|
||||
queue: OCRItem[]
|
||||
currentItem: OCRItem | null
|
||||
currentIndex: number
|
||||
correctedText: string
|
||||
setCorrectedText: (text: string) => void
|
||||
goToNext: () => void
|
||||
goToPrev: () => void
|
||||
selectQueueItem: (idx: number) => void
|
||||
confirmItem: () => void
|
||||
correctItem: () => void
|
||||
skipItem: () => void
|
||||
}
|
||||
|
||||
export function LabelingTab({
|
||||
queue,
|
||||
currentItem,
|
||||
currentIndex,
|
||||
correctedText,
|
||||
setCorrectedText,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
selectQueueItem,
|
||||
confirmItem,
|
||||
correctItem,
|
||||
skipItem,
|
||||
}: LabelingTabProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Image Viewer */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Bild</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={currentIndex === 0}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Zurueck (Pfeiltaste links)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
{currentIndex + 1} / {queue.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={currentIndex >= queue.length - 1}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Weiter (Pfeiltaste rechts)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentItem ? (
|
||||
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
|
||||
<img
|
||||
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
|
||||
alt="OCR Bild"
|
||||
className="w-full h-auto max-h-[600px] object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
|
||||
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: OCR Text & Actions */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="space-y-4">
|
||||
{/* OCR Result */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
|
||||
{currentItem?.ocr_confidence && (
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
currentItem.ocr_confidence > 0.8
|
||||
? 'bg-green-100 text-green-800'
|
||||
: currentItem.ocr_confidence > 0.5
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
|
||||
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction Input */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
|
||||
<textarea
|
||||
value={correctedText}
|
||||
onChange={(e) => setCorrectedText(e.target.value)}
|
||||
placeholder="Korrigierter Text..."
|
||||
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={confirmItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Korrekt (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={correctItem}
|
||||
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
|
||||
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Korrektur speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={skipItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
Ueberspringen (S)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="text-xs text-slate-500 mt-4">
|
||||
<p className="font-medium mb-1">Tastaturkuerzel:</p>
|
||||
<p>Enter = Bestaetigen | S = Ueberspringen</p>
|
||||
<p>Pfeiltasten = Navigation</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Queue Preview */}
|
||||
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{queue.slice(0, 10).map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => selectQueueItem(idx)}
|
||||
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
|
||||
idx === currentIndex
|
||||
? 'border-primary-500'
|
||||
: 'border-transparent hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.image_url || `${API_BASE}${item.image_path}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{queue.length > 10 && (
|
||||
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
+{queue.length - 10} mehr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Sessions tab: create new sessions and list existing ones.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { API_BASE } from '../constants'
|
||||
import type { OCRSession, CreateSessionRequest, OCRModel } from '../types'
|
||||
|
||||
interface SessionsTabProps {
|
||||
sessions: OCRSession[]
|
||||
selectedSession: string | null
|
||||
setSelectedSession: (id: string | null) => void
|
||||
fetchSessions: () => Promise<void>
|
||||
setError: (error: string | null) => void
|
||||
}
|
||||
|
||||
export function SessionsTab({
|
||||
sessions,
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
fetchSessions,
|
||||
setError,
|
||||
}: SessionsTabProps) {
|
||||
const [newSession, setNewSession] = useState<CreateSessionRequest>({
|
||||
name: '',
|
||||
source_type: 'klausur',
|
||||
description: '',
|
||||
ocr_model: 'llama3.2-vision:11b',
|
||||
})
|
||||
|
||||
const createSession = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSession),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
|
||||
fetchSessions()
|
||||
} else {
|
||||
setError('Session erstellen fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Create Session */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.name}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="z.B. Mathe Klausur Q1 2025"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newSession.source_type}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="klausur">Klausur</option>
|
||||
<option value="handwriting_sample">Handschriftprobe</option>
|
||||
<option value="scan">Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
|
||||
<select
|
||||
value={newSession.ocr_model}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
|
||||
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
|
||||
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
|
||||
<option value="donut">Donut - Document Understanding (strukturiert)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
|
||||
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
|
||||
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
|
||||
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.description}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Optional..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={createSession}
|
||||
disabled={!newSession.name}
|
||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Session erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-200">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`p-4 hover:bg-slate-50 cursor-pointer ${
|
||||
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{session.name}</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
{session.source_type} | {session.ocr_model}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{session.labeled_items}/{session.total_items} gelabelt
|
||||
</p>
|
||||
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-2"
|
||||
style={{
|
||||
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session.description && (
|
||||
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Stats tab: global statistics, detailed breakdown, and progress bar.
|
||||
*/
|
||||
|
||||
import type { OCRStats } from '../types'
|
||||
|
||||
interface StatsTabProps {
|
||||
stats: OCRStats | null
|
||||
}
|
||||
|
||||
export function StatsTab({ stats }: StatsTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Global Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Details</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Bestaetigt</p>
|
||||
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Korrigiert</p>
|
||||
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Exportierbar</p>
|
||||
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
|
||||
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{stats?.total_items ? (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
|
||||
<div className="w-full bg-slate-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-4 transition-all"
|
||||
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Upload tab: session selection, drag-and-drop upload, and upload results.
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { API_BASE } from '../constants'
|
||||
import type { OCRSession, UploadResult } from '../types'
|
||||
|
||||
interface UploadTabProps {
|
||||
sessions: OCRSession[]
|
||||
selectedSession: string | null
|
||||
setSelectedSession: (id: string | null) => void
|
||||
fetchQueue: () => Promise<void>
|
||||
fetchStats: () => Promise<void>
|
||||
setError: (error: string | null) => void
|
||||
}
|
||||
|
||||
export function UploadTab({
|
||||
sessions,
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
fetchQueue,
|
||||
fetchStats,
|
||||
setError,
|
||||
}: UploadTabProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResults, setUploadResults] = useState<UploadResult[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (files: FileList) => {
|
||||
if (!selectedSession) {
|
||||
setError('Bitte zuerst eine Session auswaehlen')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach(file => formData.append('files', file))
|
||||
formData.append('run_ocr', 'true')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUploadResults(data.items || [])
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Upload fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler beim Upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Session Selection */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">-- Session waehlen --</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.total_items} Items)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center ${
|
||||
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
disabled={!selectedSession}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
<p>Hochladen & OCR ausfuehren...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-slate-600 mb-2">
|
||||
Bilder hierher ziehen oder{' '}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedSession}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
auswaehlen
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{uploadResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
|
||||
<span className="text-sm">{result.filename}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
admin-lehrer/app/(admin)/ai/ocr-labeling/constants.tsx
Normal file
59
admin-lehrer/app/(admin)/ai/ocr-labeling/constants.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Constants and tab definitions for OCR Labeling page.
|
||||
*/
|
||||
|
||||
import type { JSX } from 'react'
|
||||
|
||||
// API Base URL for klausur-service
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Tab definitions
|
||||
export type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
|
||||
|
||||
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
id: 'labeling',
|
||||
name: 'Labeling',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
name: 'Sessions',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'upload',
|
||||
name: 'Upload',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
name: 'Statistiken',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -10,903 +10,20 @@
|
||||
* OCR-Labeling → RAG Pipeline → Daten & RAG
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
|
||||
import type {
|
||||
OCRSession,
|
||||
OCRItem,
|
||||
OCRStats,
|
||||
TrainingSample,
|
||||
CreateSessionRequest,
|
||||
OCRModel,
|
||||
} from './types'
|
||||
|
||||
// API Base URL for klausur-service
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Tab definitions
|
||||
type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
|
||||
|
||||
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
id: 'labeling',
|
||||
name: 'Labeling',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
name: 'Sessions',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'upload',
|
||||
name: 'Upload',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
name: 'Statistiken',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
import { tabs, type TabId } from './constants'
|
||||
import { useOcrLabeling } from './useOcrLabeling'
|
||||
import { LabelingTab } from './_components/LabelingTab'
|
||||
import { SessionsTab } from './_components/SessionsTab'
|
||||
import { UploadTab } from './_components/UploadTab'
|
||||
import { StatsTab } from './_components/StatsTab'
|
||||
import { ExportTab } from './_components/ExportTab'
|
||||
|
||||
export default function OCRLabelingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('labeling')
|
||||
const [sessions, setSessions] = useState<OCRSession[]>([])
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
const [queue, setQueue] = useState<OCRItem[]>([])
|
||||
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [stats, setStats] = useState<OCRStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [correctedText, setCorrectedText] = useState('')
|
||||
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
|
||||
|
||||
// Fetch sessions
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSessions(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sessions:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch queue
|
||||
const fetchQueue = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
|
||||
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueue(data)
|
||||
if (data.length > 0 && !currentItem) {
|
||||
setCurrentItem(data[0])
|
||||
setCurrentIndex(0)
|
||||
setCorrectedText(data[0].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch queue:', err)
|
||||
}
|
||||
}, [selectedSession, currentItem])
|
||||
|
||||
// Fetch stats
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
|
||||
: `${API_BASE}/api/v1/ocr-label/stats`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
}, [selectedSession])
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [fetchSessions, fetchQueue, fetchStats])
|
||||
|
||||
// Refresh queue when session changes
|
||||
useEffect(() => {
|
||||
setCurrentItem(null)
|
||||
setCurrentIndex(0)
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
}, [selectedSession, fetchQueue, fetchStats])
|
||||
|
||||
// Navigate to next item
|
||||
const goToNext = () => {
|
||||
if (currentIndex < queue.length - 1) {
|
||||
const nextIndex = currentIndex + 1
|
||||
setCurrentIndex(nextIndex)
|
||||
setCurrentItem(queue[nextIndex])
|
||||
setCorrectedText(queue[nextIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
} else {
|
||||
// Refresh queue
|
||||
fetchQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to previous item
|
||||
const goToPrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
const prevIndex = currentIndex - 1
|
||||
setCurrentIndex(prevIndex)
|
||||
setCurrentItem(queue[prevIndex])
|
||||
setCorrectedText(queue[prevIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate label time
|
||||
const getLabelTime = (): number | undefined => {
|
||||
if (!labelStartTime) return undefined
|
||||
return Math.round((Date.now() - labelStartTime) / 1000)
|
||||
}
|
||||
|
||||
// Confirm item
|
||||
const confirmItem = async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
// Remove from queue and go to next
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Bestaetigung fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Correct item
|
||||
const correctItem = async () => {
|
||||
if (!currentItem || !correctedText.trim()) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
ground_truth: correctedText.trim(),
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Korrektur fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Skip item
|
||||
const skipItem = async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ item_id: currentItem.id }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Ueberspringen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle if not in text input
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
confirmItem()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
goToNext()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
goToPrev()
|
||||
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
|
||||
skipItem()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentItem, correctedText])
|
||||
|
||||
// Render Labeling Tab
|
||||
const renderLabelingTab = () => (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Image Viewer */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Bild</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={currentIndex === 0}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Zurueck (Pfeiltaste links)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
{currentIndex + 1} / {queue.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={currentIndex >= queue.length - 1}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Weiter (Pfeiltaste rechts)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentItem ? (
|
||||
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
|
||||
<img
|
||||
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
|
||||
alt="OCR Bild"
|
||||
className="w-full h-auto max-h-[600px] object-contain"
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
|
||||
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: OCR Text & Actions */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="space-y-4">
|
||||
{/* OCR Result */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
|
||||
{currentItem?.ocr_confidence && (
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
currentItem.ocr_confidence > 0.8
|
||||
? 'bg-green-100 text-green-800'
|
||||
: currentItem.ocr_confidence > 0.5
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
|
||||
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction Input */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
|
||||
<textarea
|
||||
value={correctedText}
|
||||
onChange={(e) => setCorrectedText(e.target.value)}
|
||||
placeholder="Korrigierter Text..."
|
||||
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={confirmItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Korrekt (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={correctItem}
|
||||
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
|
||||
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Korrektur speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={skipItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
Ueberspringen (S)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="text-xs text-slate-500 mt-4">
|
||||
<p className="font-medium mb-1">Tastaturkuerzel:</p>
|
||||
<p>Enter = Bestaetigen | S = Ueberspringen</p>
|
||||
<p>Pfeiltasten = Navigation</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Queue Preview */}
|
||||
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{queue.slice(0, 10).map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setCurrentIndex(idx)
|
||||
setCurrentItem(item)
|
||||
setCorrectedText(item.ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}}
|
||||
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
|
||||
idx === currentIndex
|
||||
? 'border-primary-500'
|
||||
: 'border-transparent hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.image_url || `${API_BASE}${item.image_path}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{queue.length > 10 && (
|
||||
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
+{queue.length - 10} mehr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Sessions Tab
|
||||
const renderSessionsTab = () => {
|
||||
const [newSession, setNewSession] = useState<CreateSessionRequest>({
|
||||
name: '',
|
||||
source_type: 'klausur',
|
||||
description: '',
|
||||
ocr_model: 'llama3.2-vision:11b',
|
||||
})
|
||||
|
||||
const createSession = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSession),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
|
||||
fetchSessions()
|
||||
} else {
|
||||
setError('Session erstellen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Create Session */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.name}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="z.B. Mathe Klausur Q1 2025"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newSession.source_type}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="klausur">Klausur</option>
|
||||
<option value="handwriting_sample">Handschriftprobe</option>
|
||||
<option value="scan">Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
|
||||
<select
|
||||
value={newSession.ocr_model}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
|
||||
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
|
||||
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
|
||||
<option value="donut">Donut - Document Understanding (strukturiert)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
|
||||
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
|
||||
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
|
||||
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.description}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Optional..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={createSession}
|
||||
disabled={!newSession.name}
|
||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Session erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-200">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`p-4 hover:bg-slate-50 cursor-pointer ${
|
||||
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{session.name}</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
{session.source_type} | {session.ocr_model}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{session.labeled_items}/{session.total_items} gelabelt
|
||||
</p>
|
||||
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-2"
|
||||
style={{
|
||||
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session.description && (
|
||||
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render Upload Tab
|
||||
const renderUploadTab = () => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResults, setUploadResults] = useState<any[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (files: FileList) => {
|
||||
if (!selectedSession) {
|
||||
setError('Bitte zuerst eine Session auswaehlen')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach(file => formData.append('files', file))
|
||||
formData.append('run_ocr', 'true')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUploadResults(data.items || [])
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Upload fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler beim Upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Session Selection */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">-- Session waehlen --</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.total_items} Items)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center ${
|
||||
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
disabled={!selectedSession}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
<p>Hochladen & OCR ausfuehren...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-slate-600 mb-2">
|
||||
Bilder hierher ziehen oder{' '}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedSession}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
auswaehlen
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{uploadResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
|
||||
<span className="text-sm">{result.filename}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render Stats Tab
|
||||
const renderStatsTab = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Global Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Details</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Bestaetigt</p>
|
||||
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Korrigiert</p>
|
||||
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Exportierbar</p>
|
||||
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
|
||||
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{stats?.total_items ? (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
|
||||
<div className="w-full bg-slate-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-4 transition-all"
|
||||
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Export Tab
|
||||
const renderExportTab = () => {
|
||||
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportResult, setExportResult] = useState<any>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
export_format: exportFormat,
|
||||
session_id: selectedSession,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setExportResult(data)
|
||||
} else {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="generic">Generic JSON</option>
|
||||
<option value="trocr">TrOCR Fine-Tuning</option>
|
||||
<option value="llama_vision">Llama Vision Fine-Tuning</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Alle Sessions</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>{session.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || (stats?.exportable_items || 0) === 0}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
|
||||
</button>
|
||||
|
||||
{/* Cross-Link to Magic Help for TrOCR Fine-Tuning */}
|
||||
{exportFormat === 'trocr' && (stats?.exportable_items || 0) > 0 && (
|
||||
<Link
|
||||
href="/ai/magic-help?source=ocr-labeling"
|
||||
className="w-full mt-3 px-4 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-lg hover:bg-purple-200 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<span>✨</span>
|
||||
Mit Magic Help testen & fine-tunen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportResult && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-green-800">
|
||||
{exportResult.exported_count} Samples erfolgreich exportiert
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Batch: {exportResult.batch_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
|
||||
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
|
||||
{(exportResult.samples?.length || 0) > 3 && (
|
||||
<p className="text-slate-500 mt-2">... und {exportResult.samples.length - 3} weitere</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const hook = useOcrLabeling()
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -939,10 +56,10 @@ export default function OCRLabelingPage() {
|
||||
<AIModuleSidebarResponsive currentModule="ocr-labeling" />
|
||||
|
||||
{/* Error Toast */}
|
||||
{error && (
|
||||
{hook.error && (
|
||||
<div className="fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-4">X</button>
|
||||
<span>{hook.error}</span>
|
||||
<button onClick={() => hook.setError(null)} className="ml-4">X</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -969,17 +86,58 @@ export default function OCRLabelingPage() {
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{loading ? (
|
||||
{hook.loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'labeling' && renderLabelingTab()}
|
||||
{activeTab === 'sessions' && renderSessionsTab()}
|
||||
{activeTab === 'upload' && renderUploadTab()}
|
||||
{activeTab === 'stats' && renderStatsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'labeling' && (
|
||||
<LabelingTab
|
||||
queue={hook.queue}
|
||||
currentItem={hook.currentItem}
|
||||
currentIndex={hook.currentIndex}
|
||||
correctedText={hook.correctedText}
|
||||
setCorrectedText={hook.setCorrectedText}
|
||||
goToNext={hook.goToNext}
|
||||
goToPrev={hook.goToPrev}
|
||||
selectQueueItem={hook.selectQueueItem}
|
||||
confirmItem={hook.confirmItem}
|
||||
correctItem={hook.correctItem}
|
||||
skipItem={hook.skipItem}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'sessions' && (
|
||||
<SessionsTab
|
||||
sessions={hook.sessions}
|
||||
selectedSession={hook.selectedSession}
|
||||
setSelectedSession={hook.setSelectedSession}
|
||||
fetchSessions={hook.fetchSessions}
|
||||
setError={hook.setError}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'upload' && (
|
||||
<UploadTab
|
||||
sessions={hook.sessions}
|
||||
selectedSession={hook.selectedSession}
|
||||
setSelectedSession={hook.setSelectedSession}
|
||||
fetchQueue={hook.fetchQueue}
|
||||
fetchStats={hook.fetchStats}
|
||||
setError={hook.setError}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'stats' && (
|
||||
<StatsTab stats={hook.stats} />
|
||||
)}
|
||||
{activeTab === 'export' && (
|
||||
<ExportTab
|
||||
sessions={hook.sessions}
|
||||
selectedSession={hook.selectedSession}
|
||||
setSelectedSession={hook.setSelectedSession}
|
||||
stats={hook.stats}
|
||||
setError={hook.setError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* TypeScript types for OCR Labeling UI
|
||||
* Shared TypeScript types for OCR Labeling UI.
|
||||
*
|
||||
* Single source of truth used by:
|
||||
* - admin-lehrer (ai/ocr-labeling)
|
||||
* - website (admin/ocr-labeling)
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
255
admin-lehrer/app/(admin)/ai/ocr-labeling/useOcrLabeling.ts
Normal file
255
admin-lehrer/app/(admin)/ai/ocr-labeling/useOcrLabeling.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Custom hook encapsulating all state and API logic for the OCR Labeling page.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { API_BASE } from './constants'
|
||||
import type { OCRSession, OCRItem, OCRStats } from './types'
|
||||
|
||||
export function useOcrLabeling() {
|
||||
const [sessions, setSessions] = useState<OCRSession[]>([])
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
const [queue, setQueue] = useState<OCRItem[]>([])
|
||||
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [stats, setStats] = useState<OCRStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [correctedText, setCorrectedText] = useState('')
|
||||
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
|
||||
|
||||
// Fetch sessions
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSessions(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sessions:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch queue
|
||||
const fetchQueue = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
|
||||
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueue(data)
|
||||
if (data.length > 0 && !currentItem) {
|
||||
setCurrentItem(data[0])
|
||||
setCurrentIndex(0)
|
||||
setCorrectedText(data[0].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch queue:', err)
|
||||
}
|
||||
}, [selectedSession, currentItem])
|
||||
|
||||
// Fetch stats
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
|
||||
: `${API_BASE}/api/v1/ocr-label/stats`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
}, [selectedSession])
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [fetchSessions, fetchQueue, fetchStats])
|
||||
|
||||
// Refresh queue when session changes
|
||||
useEffect(() => {
|
||||
setCurrentItem(null)
|
||||
setCurrentIndex(0)
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
}, [selectedSession, fetchQueue, fetchStats])
|
||||
|
||||
// Navigate to next item
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentIndex < queue.length - 1) {
|
||||
const nextIndex = currentIndex + 1
|
||||
setCurrentIndex(nextIndex)
|
||||
setCurrentItem(queue[nextIndex])
|
||||
setCorrectedText(queue[nextIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
} else {
|
||||
fetchQueue()
|
||||
}
|
||||
}, [currentIndex, queue, fetchQueue])
|
||||
|
||||
// Navigate to previous item
|
||||
const goToPrev = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
const prevIndex = currentIndex - 1
|
||||
setCurrentIndex(prevIndex)
|
||||
setCurrentItem(queue[prevIndex])
|
||||
setCorrectedText(queue[prevIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}, [currentIndex, queue])
|
||||
|
||||
// Calculate label time
|
||||
const getLabelTime = useCallback((): number | undefined => {
|
||||
if (!labelStartTime) return undefined
|
||||
return Math.round((Date.now() - labelStartTime) / 1000)
|
||||
}, [labelStartTime])
|
||||
|
||||
// Select a queue item by index
|
||||
const selectQueueItem = useCallback((idx: number) => {
|
||||
if (idx >= 0 && idx < queue.length) {
|
||||
setCurrentIndex(idx)
|
||||
setCurrentItem(queue[idx])
|
||||
setCorrectedText(queue[idx].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}, [queue])
|
||||
|
||||
// Confirm item
|
||||
const confirmItem = useCallback(async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Bestaetigung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}, [currentItem, getLabelTime, goToNext, fetchStats])
|
||||
|
||||
// Correct item
|
||||
const correctItem = useCallback(async () => {
|
||||
if (!currentItem || !correctedText.trim()) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
ground_truth: correctedText.trim(),
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Korrektur fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}, [currentItem, correctedText, getLabelTime, goToNext, fetchStats])
|
||||
|
||||
// Skip item
|
||||
const skipItem = useCallback(async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ item_id: currentItem.id }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Ueberspringen fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}, [currentItem, goToNext, fetchStats])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
confirmItem()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
goToNext()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
goToPrev()
|
||||
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
|
||||
skipItem()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [confirmItem, goToNext, goToPrev, skipItem])
|
||||
|
||||
return {
|
||||
// State
|
||||
sessions,
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
queue,
|
||||
currentItem,
|
||||
currentIndex,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
correctedText,
|
||||
setCorrectedText,
|
||||
|
||||
// Actions
|
||||
fetchSessions,
|
||||
fetchQueue,
|
||||
fetchStats,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
selectQueueItem,
|
||||
confirmItem,
|
||||
correctItem,
|
||||
skipItem,
|
||||
}
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { PipelineStepper } from '@/components/ocr-pipeline/PipelineStepper'
|
||||
import { StepOrientation } from '@/components/ocr-pipeline/StepOrientation'
|
||||
import { StepDeskew } from '@/components/ocr-pipeline/StepDeskew'
|
||||
import { StepDewarp } from '@/components/ocr-pipeline/StepDewarp'
|
||||
import { StepCrop } from '@/components/ocr-pipeline/StepCrop'
|
||||
import { StepStructureDetection } from '@/components/ocr-pipeline/StepStructureDetection'
|
||||
import { StepRowDetection } from '@/components/ocr-pipeline/StepRowDetection'
|
||||
import { StepWordRecognition } from '@/components/ocr-pipeline/StepWordRecognition'
|
||||
import { OverlayReconstruction } from '@/components/ocr-overlay/OverlayReconstruction'
|
||||
import { PaddleDirectStep } from '@/components/ocr-overlay/PaddleDirectStep'
|
||||
import { GridEditor } from '@/components/grid-editor/GridEditor'
|
||||
import { OVERLAY_PIPELINE_STEPS, PADDLE_DIRECT_STEPS, KOMBI_STEPS, DOCUMENT_CATEGORIES, dbStepToOverlayUi, type PipelineStep, type SessionListItem, type DocumentCategory } from './types'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
export default function OcrOverlayPage() {
|
||||
const [mode, setMode] = useState<'pipeline' | 'paddle-direct' | 'kombi'>('pipeline')
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [sessionName, setSessionName] = useState<string>('')
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
||||
const [loadingSessions, setLoadingSessions] = useState(true)
|
||||
const [editingName, setEditingName] = useState<string | null>(null)
|
||||
const [editNameValue, setEditNameValue] = useState('')
|
||||
const [editingCategory, setEditingCategory] = useState<string | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(
|
||||
OVERLAY_PIPELINE_STEPS.map((s, i) => ({
|
||||
...s,
|
||||
status: i === 0 ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [])
|
||||
|
||||
const loadSessions = async () => {
|
||||
setLoadingSessions(true)
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Filter to only show top-level sessions (no sub-sessions)
|
||||
setSessions((data.sessions || []).filter((s: SessionListItem) => !s.parent_session_id))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions:', e)
|
||||
} finally {
|
||||
setLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openSession = useCallback(async (sid: string) => {
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
|
||||
setSessionId(sid)
|
||||
setSessionName(data.name || data.filename || '')
|
||||
setActiveCategory(data.document_category || undefined)
|
||||
|
||||
// Check if this session was processed with paddle_direct, kombi, or rapid_kombi
|
||||
const ocrEngine = data.word_result?.ocr_engine
|
||||
const isPaddleDirect = ocrEngine === 'paddle_direct'
|
||||
const isKombi = ocrEngine === 'kombi' || ocrEngine === 'rapid_kombi'
|
||||
|
||||
if (isPaddleDirect || isKombi) {
|
||||
const m = isKombi ? 'kombi' : 'paddle-direct'
|
||||
const baseSteps = isKombi ? KOMBI_STEPS : PADDLE_DIRECT_STEPS
|
||||
setMode(m)
|
||||
|
||||
// For Kombi: if grid_editor_result exists, jump to grid editor step (6)
|
||||
// If structure_result exists, jump to grid editor (6)
|
||||
// If word_result exists, jump to structure step (5)
|
||||
const hasGrid = isKombi && data.grid_editor_result
|
||||
const hasStructure = isKombi && data.structure_result
|
||||
const hasWords = isKombi && data.word_result
|
||||
const activeStep = hasGrid ? 6 : hasStructure ? 6 : hasWords ? 5 : 4
|
||||
setSteps(
|
||||
baseSteps.map((s, i) => ({
|
||||
...s,
|
||||
status: i < activeStep ? 'completed' : i === activeStep ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
setCurrentStep(activeStep)
|
||||
} else {
|
||||
setMode('pipeline')
|
||||
// Map DB step to overlay UI step
|
||||
const dbStep = data.current_step || 1
|
||||
const uiStep = dbStepToOverlayUi(dbStep)
|
||||
|
||||
setSteps(
|
||||
OVERLAY_PIPELINE_STEPS.map((s, i) => ({
|
||||
...s,
|
||||
status: i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
setCurrentStep(uiStep)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to open session:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteSession = useCallback(async (sid: string) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sid))
|
||||
if (sessionId === sid) {
|
||||
setSessionId(null)
|
||||
setCurrentStep(0)
|
||||
const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS
|
||||
setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete session:', e)
|
||||
}
|
||||
}, [sessionId, mode])
|
||||
|
||||
const renameSession = useCallback(async (sid: string, newName: string) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName }),
|
||||
})
|
||||
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, name: newName } : s)))
|
||||
if (sessionId === sid) setSessionName(newName)
|
||||
} catch (e) {
|
||||
console.error('Failed to rename session:', e)
|
||||
}
|
||||
setEditingName(null)
|
||||
}, [sessionId])
|
||||
|
||||
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document_category: category }),
|
||||
})
|
||||
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, document_category: category } : s)))
|
||||
if (sessionId === sid) setActiveCategory(category)
|
||||
} catch (e) {
|
||||
console.error('Failed to update category:', e)
|
||||
}
|
||||
setEditingCategory(null)
|
||||
}, [sessionId])
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep || steps[index].status === 'completed') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
|
||||
const goToStep = (step: number) => {
|
||||
setCurrentStep(step)
|
||||
setSteps((prev) =>
|
||||
prev.map((s, i) => ({
|
||||
...s,
|
||||
status: i < step ? 'completed' : i === step ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
// Last step completed — return to session list
|
||||
const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS
|
||||
setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
setCurrentStep(0)
|
||||
setSessionId(null)
|
||||
loadSessions()
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = currentStep + 1
|
||||
setSteps((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i === currentStep) return { ...s, status: 'completed' }
|
||||
if (i === nextStep) return { ...s, status: 'active' }
|
||||
return s
|
||||
}),
|
||||
)
|
||||
setCurrentStep(nextStep)
|
||||
}
|
||||
|
||||
const handleOrientationComplete = (sid: string) => {
|
||||
setSessionId(sid)
|
||||
loadSessions()
|
||||
handleNext()
|
||||
}
|
||||
|
||||
const handleNewSession = () => {
|
||||
setSessionId(null)
|
||||
setSessionName('')
|
||||
setCurrentStep(0)
|
||||
const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS
|
||||
setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
}
|
||||
|
||||
const stepNames: Record<number, string> = {
|
||||
1: 'Orientierung',
|
||||
2: 'Begradigung',
|
||||
3: 'Entzerrung',
|
||||
4: 'Zuschneiden',
|
||||
5: 'Zeilen',
|
||||
6: 'Woerter',
|
||||
7: 'Overlay',
|
||||
}
|
||||
|
||||
const reprocessFromStep = useCallback(async (uiStep: number) => {
|
||||
if (!sessionId) return
|
||||
// Map overlay UI step to DB step
|
||||
const dbStepMap: Record<number, number> = { 0: 2, 1: 3, 2: 4, 3: 5, 4: 7, 5: 8, 6: 9 }
|
||||
const dbStep = dbStepMap[uiStep] || uiStep + 1
|
||||
if (!confirm(`Ab Schritt ${uiStep + 1} (${stepNames[uiStep + 1] || '?'}) neu verarbeiten?`)) return
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reprocess`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from_step: dbStep }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
console.error('Reprocess failed:', data.detail || res.status)
|
||||
return
|
||||
}
|
||||
goToStep(uiStep)
|
||||
} catch (e) {
|
||||
console.error('Reprocess error:', e)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, goToStep])
|
||||
|
||||
const renderStep = () => {
|
||||
if (mode === 'paddle-direct' || mode === 'kombi') {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return <StepOrientation sessionId={sessionId} onNext={handleOrientationComplete} />
|
||||
case 1:
|
||||
return <StepDeskew sessionId={sessionId} onNext={handleNext} />
|
||||
case 2:
|
||||
return <StepDewarp sessionId={sessionId} onNext={handleNext} />
|
||||
case 3:
|
||||
return <StepCrop sessionId={sessionId} onNext={handleNext} />
|
||||
case 4:
|
||||
if (mode === 'kombi') {
|
||||
return (
|
||||
<PaddleDirectStep
|
||||
sessionId={sessionId}
|
||||
onNext={handleNext}
|
||||
endpoint="paddle-kombi"
|
||||
title="Kombi-Modus"
|
||||
description="PP-OCRv5 und Tesseract laufen parallel. Koordinaten werden gewichtet gemittelt fuer optimale Positionierung."
|
||||
icon="🔀"
|
||||
buttonLabel="PP-OCRv5 + Tesseract starten"
|
||||
runningLabel="PP-OCRv5 + Tesseract laufen..."
|
||||
engineKey="kombi"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <PaddleDirectStep sessionId={sessionId} onNext={handleNext} />
|
||||
case 5:
|
||||
return mode === 'kombi' ? (
|
||||
<StepStructureDetection sessionId={sessionId} onNext={handleNext} />
|
||||
) : null
|
||||
case 6:
|
||||
return mode === 'kombi' ? (
|
||||
<GridEditor sessionId={sessionId} onNext={handleNext} />
|
||||
) : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return <StepOrientation sessionId={sessionId} onNext={handleOrientationComplete} />
|
||||
case 1:
|
||||
return <StepDeskew sessionId={sessionId} onNext={handleNext} />
|
||||
case 2:
|
||||
return <StepDewarp sessionId={sessionId} onNext={handleNext} />
|
||||
case 3:
|
||||
return <StepCrop sessionId={sessionId} onNext={handleNext} />
|
||||
case 4:
|
||||
return <StepRowDetection sessionId={sessionId} onNext={handleNext} />
|
||||
case 5:
|
||||
return <StepWordRecognition sessionId={sessionId} onNext={handleNext} goToStep={goToStep} skipHealGaps />
|
||||
case 6:
|
||||
return <OverlayReconstruction sessionId={sessionId} onNext={handleNext} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PagePurpose
|
||||
title="OCR Overlay"
|
||||
purpose="Ganzseitige Overlay-Rekonstruktion: Scan begradigen, Zeilen und Woerter erkennen, dann pixelgenau ueber das Bild legen. Ohne Spaltenerkennung — ideal fuer Arbeitsblaetter."
|
||||
audience={['Entwickler']}
|
||||
architecture={{
|
||||
services: ['klausur-service (FastAPI)', 'OpenCV', 'Tesseract'],
|
||||
databases: ['PostgreSQL Sessions'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'OCR Pipeline', href: '/ai/ocr-pipeline', description: 'Volle Pipeline mit Spalten' },
|
||||
{ name: 'OCR Vergleich', href: '/ai/ocr-compare', description: 'Methoden-Vergleich' },
|
||||
]}
|
||||
defaultCollapsed
|
||||
/>
|
||||
|
||||
{/* Session List */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sessions ({sessions.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
className="text-xs px-3 py-1.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
+ Neue Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingSessions ? (
|
||||
<div className="text-sm text-gray-400 py-2">Lade Sessions...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 py-2">Noch keine Sessions vorhanden.</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
|
||||
{sessions.map((s) => {
|
||||
const catInfo = DOCUMENT_CATEGORIES.find(c => c.value === s.document_category)
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`relative flex items-start gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||
sessionId === s.id
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
className="flex-shrink-0 w-12 h-12 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-700"
|
||||
onClick={() => openSession(s.id)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=96`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0" onClick={() => openSession(s.id)}>
|
||||
{editingName === s.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editNameValue}
|
||||
onChange={(e) => setEditNameValue(e.target.value)}
|
||||
onBlur={() => renameSession(s.id, editNameValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') renameSession(s.id, editNameValue)
|
||||
if (e.key === 'Escape') setEditingName(null)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full px-1 py-0.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="truncate font-medium text-gray-700 dark:text-gray-300">
|
||||
{s.name || s.filename}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(s.id)
|
||||
const btn = e.currentTarget
|
||||
btn.textContent = 'Kopiert!'
|
||||
setTimeout(() => { btn.textContent = `ID: ${s.id.slice(0, 8)}` }, 1500)
|
||||
}}
|
||||
className="text-[10px] font-mono text-gray-400 hover:text-teal-500 transition-colors"
|
||||
title={`Volle ID: ${s.id} — Klick zum Kopieren`}
|
||||
>
|
||||
ID: {s.id.slice(0, 8)}
|
||||
</button>
|
||||
<div className="text-xs text-gray-400 flex gap-2 mt-0.5">
|
||||
<span>{new Date(s.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Badge */}
|
||||
<div className="flex flex-col gap-1 items-end flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setEditingCategory(editingCategory === s.id ? null : s.id)}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition-colors ${
|
||||
catInfo
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
||||
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
title="Kategorie setzen"
|
||||
>
|
||||
{catInfo ? `${catInfo.icon} ${catInfo.label}` : '+ Kategorie'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditNameValue(s.name || s.filename)
|
||||
setEditingName(s.id)
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Umbenennen"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Session loeschen?')) deleteSession(s.id)
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-500"
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category dropdown */}
|
||||
{editingCategory === s.id && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{DOCUMENT_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.value}
|
||||
onClick={() => updateCategory(s.id, cat.value)}
|
||||
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
|
||||
s.document_category === cat.value
|
||||
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active session info */}
|
||||
{sessionId && sessionName && (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Aktive Session: <span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span></span>
|
||||
{activeCategory && (() => {
|
||||
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
|
||||
return cat ? <span className="text-xs px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300">{cat.icon} {cat.label}</span> : null
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1 w-fit">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (mode === 'pipeline') return
|
||||
setMode('pipeline')
|
||||
setCurrentStep(0)
|
||||
setSessionId(null)
|
||||
setSteps(OVERLAY_PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
mode === 'pipeline'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Pipeline (7 Schritte)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (mode === 'paddle-direct') return
|
||||
setMode('paddle-direct')
|
||||
setCurrentStep(0)
|
||||
setSessionId(null)
|
||||
setSteps(PADDLE_DIRECT_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
mode === 'paddle-direct'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
PP-OCRv5 Direct (5 Schritte)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (mode === 'kombi') return
|
||||
setMode('kombi')
|
||||
setCurrentStep(0)
|
||||
setSessionId(null)
|
||||
setSteps(KOMBI_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
mode === 'kombi'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Kombi (7 Schritte)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PipelineStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepClick={handleStepClick}
|
||||
onReprocess={mode === 'pipeline' && sessionId != null ? reprocessFromStep : undefined}
|
||||
/>
|
||||
|
||||
<div className="min-h-[400px]">{renderStep()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { PipelineStep } from '../ocr-pipeline/types'
|
||||
|
||||
// Re-export types used by overlay components
|
||||
export type {
|
||||
PipelineStep,
|
||||
PipelineStepStatus,
|
||||
SessionListItem,
|
||||
SessionInfo,
|
||||
DocumentCategory,
|
||||
DocumentTypeResult,
|
||||
OrientationResult,
|
||||
CropResult,
|
||||
DeskewResult,
|
||||
DewarpResult,
|
||||
RowResult,
|
||||
RowItem,
|
||||
GridResult,
|
||||
GridCell,
|
||||
OcrWordBox,
|
||||
WordBbox,
|
||||
ColumnMeta,
|
||||
} from '../ocr-pipeline/types'
|
||||
|
||||
export { DOCUMENT_CATEGORIES } from '../ocr-pipeline/types'
|
||||
|
||||
/**
|
||||
* 7-step pipeline for full-page overlay reconstruction.
|
||||
* Skips: Spalten (columns), LLM-Review (Korrektur), Ground-Truth (Validierung)
|
||||
*/
|
||||
export const OVERLAY_PIPELINE_STEPS: PipelineStep[] = [
|
||||
{ id: 'orientation', name: 'Orientierung', icon: '🔄', status: 'pending' },
|
||||
{ id: 'deskew', name: 'Begradigung', icon: '📐', status: 'pending' },
|
||||
{ id: 'dewarp', name: 'Entzerrung', icon: '🔧', status: 'pending' },
|
||||
{ id: 'crop', name: 'Zuschneiden', icon: '✂️', status: 'pending' },
|
||||
{ id: 'rows', name: 'Zeilen', icon: '📏', status: 'pending' },
|
||||
{ id: 'words', name: 'Woerter', icon: '🔤', status: 'pending' },
|
||||
{ id: 'reconstruction', name: 'Overlay', icon: '🏗️', status: 'pending' },
|
||||
]
|
||||
|
||||
/** Map from overlay UI step index to DB step number (1-indexed) */
|
||||
export const OVERLAY_UI_TO_DB: Record<number, number> = {
|
||||
0: 2, // orientation
|
||||
1: 3, // deskew
|
||||
2: 4, // dewarp
|
||||
3: 5, // crop
|
||||
4: 6, // rows (skip columns=6 in DB, rows=7 — but we reuse DB step numbering)
|
||||
5: 7, // words
|
||||
6: 9, // reconstruction
|
||||
}
|
||||
|
||||
/**
|
||||
* 5-step pipeline for Paddle Direct mode.
|
||||
* Same preprocessing (orient/deskew/dewarp/crop), then PaddleOCR replaces rows+words+overlay.
|
||||
*/
|
||||
export const PADDLE_DIRECT_STEPS: PipelineStep[] = [
|
||||
{ id: 'orientation', name: 'Orientierung', icon: '🔄', status: 'pending' },
|
||||
{ id: 'deskew', name: 'Begradigung', icon: '📐', status: 'pending' },
|
||||
{ id: 'dewarp', name: 'Entzerrung', icon: '🔧', status: 'pending' },
|
||||
{ id: 'crop', name: 'Zuschneiden', icon: '✂️', status: 'pending' },
|
||||
{ id: 'paddle-direct', name: 'PP-OCRv5 + Overlay', icon: '⚡', status: 'pending' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 5-step pipeline for Kombi mode (PP-OCRv5 + Tesseract).
|
||||
* Same preprocessing, then both engines run and results are merged.
|
||||
*/
|
||||
export const KOMBI_STEPS: PipelineStep[] = [
|
||||
{ id: 'orientation', name: 'Orientierung', icon: '🔄', status: 'pending' },
|
||||
{ id: 'deskew', name: 'Begradigung', icon: '📐', status: 'pending' },
|
||||
{ id: 'dewarp', name: 'Entzerrung', icon: '🔧', status: 'pending' },
|
||||
{ id: 'crop', name: 'Zuschneiden', icon: '✂️', status: 'pending' },
|
||||
{ id: 'kombi', name: 'PP-OCRv5 + Tesseract', icon: '🔀', status: 'pending' },
|
||||
{ id: 'structure', name: 'Struktur', icon: '🔍', status: 'pending' },
|
||||
{ id: 'grid-editor', name: 'Tabelle', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
/** Map from DB step to overlay UI step index */
|
||||
export function dbStepToOverlayUi(dbStep: number): number {
|
||||
// DB: 1=start, 2=orient, 3=deskew, 4=dewarp, 5=crop, 6=columns, 7=rows, 8=words, 9=recon, 10=gt
|
||||
if (dbStep <= 2) return 0 // orientation
|
||||
if (dbStep === 3) return 1 // deskew
|
||||
if (dbStep === 4) return 2 // dewarp
|
||||
if (dbStep === 5) return 3 // crop
|
||||
if (dbStep <= 7) return 4 // rows (skip columns)
|
||||
if (dbStep === 8) return 5 // words
|
||||
return 6 // reconstruction
|
||||
}
|
||||
@@ -1,624 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { PipelineStepper } from '@/components/ocr-pipeline/PipelineStepper'
|
||||
import { StepOrientation } from '@/components/ocr-pipeline/StepOrientation'
|
||||
import { StepCrop } from '@/components/ocr-pipeline/StepCrop'
|
||||
import { StepDeskew } from '@/components/ocr-pipeline/StepDeskew'
|
||||
import { StepDewarp } from '@/components/ocr-pipeline/StepDewarp'
|
||||
import { StepStructureDetection } from '@/components/ocr-pipeline/StepStructureDetection'
|
||||
import { StepColumnDetection } from '@/components/ocr-pipeline/StepColumnDetection'
|
||||
import { StepRowDetection } from '@/components/ocr-pipeline/StepRowDetection'
|
||||
import { StepWordRecognition } from '@/components/ocr-pipeline/StepWordRecognition'
|
||||
import { StepLlmReview } from '@/components/ocr-pipeline/StepLlmReview'
|
||||
import { StepReconstruction } from '@/components/ocr-pipeline/StepReconstruction'
|
||||
import { StepGroundTruth } from '@/components/ocr-pipeline/StepGroundTruth'
|
||||
import { BoxSessionTabs } from '@/components/ocr-pipeline/BoxSessionTabs'
|
||||
import { PIPELINE_STEPS, DOCUMENT_CATEGORIES, type PipelineStep, type SessionListItem, type DocumentTypeResult, type DocumentCategory, type SubSession } from './types'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
export default function OcrPipelinePage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [sessionName, setSessionName] = useState<string>('')
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
||||
const [loadingSessions, setLoadingSessions] = useState(true)
|
||||
const [editingName, setEditingName] = useState<string | null>(null)
|
||||
const [editNameValue, setEditNameValue] = useState('')
|
||||
const [editingCategory, setEditingCategory] = useState<string | null>(null)
|
||||
const [docTypeResult, setDocTypeResult] = useState<DocumentTypeResult | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
|
||||
const [subSessions, setSubSessions] = useState<SubSession[]>([])
|
||||
const [parentSessionId, setParentSessionId] = useState<string | null>(null)
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(
|
||||
PIPELINE_STEPS.map((s, i) => ({
|
||||
...s,
|
||||
status: i === 0 ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
|
||||
// Load session list on mount
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [])
|
||||
|
||||
const loadSessions = async () => {
|
||||
setLoadingSessions(true)
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSessions(data.sessions || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions:', e)
|
||||
} finally {
|
||||
setLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openSession = useCallback(async (sid: string, keepSubSessions?: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
|
||||
setSessionId(sid)
|
||||
setSessionName(data.name || data.filename || '')
|
||||
setActiveCategory(data.document_category || undefined)
|
||||
|
||||
// Sub-session handling
|
||||
if (data.sub_sessions && data.sub_sessions.length > 0) {
|
||||
setSubSessions(data.sub_sessions)
|
||||
setParentSessionId(sid)
|
||||
} else if (data.parent_session_id) {
|
||||
// This is a sub-session — keep parent info but don't reset sub-session list
|
||||
setParentSessionId(data.parent_session_id)
|
||||
} else if (!keepSubSessions) {
|
||||
setSubSessions([])
|
||||
setParentSessionId(null)
|
||||
}
|
||||
|
||||
// Restore doc type result if available
|
||||
const savedDocType: DocumentTypeResult | null = data.doc_type_result || null
|
||||
setDocTypeResult(savedDocType)
|
||||
|
||||
// Determine which step to jump to based on current_step
|
||||
const dbStep = data.current_step || 1
|
||||
// DB steps: 1=start, 2=orientation, 3=deskew, 4=dewarp, 5=crop, 6=columns, ...
|
||||
// UI steps are 0-indexed: 0=orientation, 1=deskew, 2=dewarp, 3=crop, 4=columns, ...
|
||||
let uiStep = Math.max(0, dbStep - 1)
|
||||
const skipSteps = [...(savedDocType?.skip_steps || [])]
|
||||
|
||||
// Sub-sessions: image is already cropped, skip pre-processing steps
|
||||
// Jump directly to columns (UI step 4) unless already further ahead
|
||||
const isSubSession = !!data.parent_session_id
|
||||
const SUB_SESSION_SKIP = ['orientation', 'deskew', 'dewarp', 'crop']
|
||||
if (isSubSession) {
|
||||
for (const s of SUB_SESSION_SKIP) {
|
||||
if (!skipSteps.includes(s)) skipSteps.push(s)
|
||||
}
|
||||
if (uiStep < 4) uiStep = 4 // columns step (index 4)
|
||||
}
|
||||
|
||||
setSteps(
|
||||
PIPELINE_STEPS.map((s, i) => ({
|
||||
...s,
|
||||
status: skipSteps.includes(s.id)
|
||||
? 'skipped'
|
||||
: i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
setCurrentStep(uiStep)
|
||||
} catch (e) {
|
||||
console.error('Failed to open session:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteSession = useCallback(async (sid: string) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sid))
|
||||
if (sessionId === sid) {
|
||||
setSessionId(null)
|
||||
setCurrentStep(0)
|
||||
setDocTypeResult(null)
|
||||
setSubSessions([])
|
||||
setParentSessionId(null)
|
||||
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete session:', e)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
const renameSession = useCallback(async (sid: string, newName: string) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName }),
|
||||
})
|
||||
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, name: newName } : s)))
|
||||
if (sessionId === sid) setSessionName(newName)
|
||||
} catch (e) {
|
||||
console.error('Failed to rename session:', e)
|
||||
}
|
||||
setEditingName(null)
|
||||
}, [sessionId])
|
||||
|
||||
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document_category: category }),
|
||||
})
|
||||
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, document_category: category } : s)))
|
||||
if (sessionId === sid) setActiveCategory(category)
|
||||
} catch (e) {
|
||||
console.error('Failed to update category:', e)
|
||||
}
|
||||
setEditingCategory(null)
|
||||
}, [sessionId])
|
||||
|
||||
const deleteAllSessions = useCallback(async () => {
|
||||
if (!confirm('Alle Sessions loeschen? Dies kann nicht rueckgaengig gemacht werden.')) return
|
||||
try {
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, { method: 'DELETE' })
|
||||
setSessions([])
|
||||
setSessionId(null)
|
||||
setCurrentStep(0)
|
||||
setDocTypeResult(null)
|
||||
setActiveCategory(undefined)
|
||||
setSubSessions([])
|
||||
setParentSessionId(null)
|
||||
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
} catch (e) {
|
||||
console.error('Failed to delete all sessions:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep || steps[index].status === 'completed') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
|
||||
const goToStep = (step: number) => {
|
||||
setCurrentStep(step)
|
||||
setSteps((prev) =>
|
||||
prev.map((s, i) => ({
|
||||
...s,
|
||||
status: i < step ? 'completed' : i === step ? 'active' : 'pending',
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
// Last step completed
|
||||
if (parentSessionId && sessionId !== parentSessionId) {
|
||||
// Sub-session completed — update its status and stay in tab view
|
||||
setSubSessions((prev) =>
|
||||
prev.map((s) => s.id === sessionId ? { ...s, status: 'completed', current_step: 10 } : s)
|
||||
)
|
||||
// Switch back to parent
|
||||
handleSessionChange(parentSessionId)
|
||||
return
|
||||
}
|
||||
// Main session: return to session list
|
||||
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
setCurrentStep(0)
|
||||
setSessionId(null)
|
||||
setSubSessions([])
|
||||
setParentSessionId(null)
|
||||
loadSessions()
|
||||
return
|
||||
}
|
||||
|
||||
// Find the next non-skipped step
|
||||
const skipSteps = docTypeResult?.skip_steps || []
|
||||
let nextStep = currentStep + 1
|
||||
while (nextStep < steps.length && skipSteps.includes(PIPELINE_STEPS[nextStep]?.id)) {
|
||||
nextStep++
|
||||
}
|
||||
if (nextStep >= steps.length) nextStep = steps.length - 1
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i === currentStep) return { ...s, status: 'completed' }
|
||||
if (i === nextStep) return { ...s, status: 'active' }
|
||||
// Mark skipped steps between current and next
|
||||
if (i > currentStep && i < nextStep && skipSteps.includes(PIPELINE_STEPS[i]?.id)) {
|
||||
return { ...s, status: 'skipped' }
|
||||
}
|
||||
return s
|
||||
}),
|
||||
)
|
||||
setCurrentStep(nextStep)
|
||||
}
|
||||
|
||||
const handleOrientationComplete = (sid: string) => {
|
||||
setSessionId(sid)
|
||||
// Reload session list to show the new session
|
||||
loadSessions()
|
||||
handleNext()
|
||||
}
|
||||
|
||||
const handleCropNext = async () => {
|
||||
// Auto-detect document type after crop (last image-processing step), then advance
|
||||
if (sessionId) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/detect-type`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (res.ok) {
|
||||
const data: DocumentTypeResult = await res.json()
|
||||
setDocTypeResult(data)
|
||||
|
||||
// Mark skipped steps immediately
|
||||
const skipSteps = data.skip_steps || []
|
||||
if (skipSteps.length > 0) {
|
||||
setSteps((prev) =>
|
||||
prev.map((s) =>
|
||||
skipSteps.includes(s.id) ? { ...s, status: 'skipped' } : s,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Doc type detection failed:', e)
|
||||
// Not critical — continue without it
|
||||
}
|
||||
}
|
||||
handleNext()
|
||||
}
|
||||
|
||||
const handleDocTypeChange = (newDocType: DocumentTypeResult['doc_type']) => {
|
||||
if (!docTypeResult) return
|
||||
|
||||
// Build new skip_steps based on doc type
|
||||
let skipSteps: string[] = []
|
||||
if (newDocType === 'full_text') {
|
||||
skipSteps = ['columns', 'rows']
|
||||
}
|
||||
// vocab_table and generic_table: no skips
|
||||
|
||||
const updated: DocumentTypeResult = {
|
||||
...docTypeResult,
|
||||
doc_type: newDocType,
|
||||
skip_steps: skipSteps,
|
||||
pipeline: newDocType === 'full_text' ? 'full_page' : 'cell_first',
|
||||
}
|
||||
setDocTypeResult(updated)
|
||||
|
||||
// Update step statuses
|
||||
setSteps((prev) =>
|
||||
prev.map((s) => {
|
||||
if (skipSteps.includes(s.id)) return { ...s, status: 'skipped' as const }
|
||||
if (s.status === 'skipped') return { ...s, status: 'pending' as const }
|
||||
return s
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const handleNewSession = () => {
|
||||
setSessionId(null)
|
||||
setSessionName('')
|
||||
setCurrentStep(0)
|
||||
setDocTypeResult(null)
|
||||
setSubSessions([])
|
||||
setParentSessionId(null)
|
||||
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
||||
}
|
||||
|
||||
const handleSessionChange = useCallback((newSessionId: string) => {
|
||||
openSession(newSessionId, true)
|
||||
}, [openSession])
|
||||
|
||||
const handleBoxSessionsCreated = useCallback((subs: SubSession[]) => {
|
||||
setSubSessions(subs)
|
||||
if (sessionId) setParentSessionId(sessionId)
|
||||
}, [sessionId])
|
||||
|
||||
const stepNames: Record<number, string> = {
|
||||
1: 'Orientierung',
|
||||
2: 'Begradigung',
|
||||
3: 'Entzerrung',
|
||||
4: 'Zuschneiden',
|
||||
5: 'Spalten',
|
||||
6: 'Zeilen',
|
||||
7: 'Woerter',
|
||||
8: 'Struktur',
|
||||
9: 'Korrektur',
|
||||
10: 'Rekonstruktion',
|
||||
11: 'Validierung',
|
||||
}
|
||||
|
||||
const reprocessFromStep = useCallback(async (uiStep: number) => {
|
||||
if (!sessionId) return
|
||||
const dbStep = uiStep + 1 // UI is 0-indexed, DB is 1-indexed
|
||||
if (!confirm(`Ab Schritt ${dbStep} (${stepNames[dbStep] || '?'}) neu verarbeiten? Nachfolgende Daten werden geloescht.`)) return
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reprocess`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from_step: dbStep }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
console.error('Reprocess failed:', data.detail || res.status)
|
||||
return
|
||||
}
|
||||
// Reset UI steps
|
||||
goToStep(uiStep)
|
||||
} catch (e) {
|
||||
console.error('Reprocess error:', e)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, goToStep])
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return <StepOrientation sessionId={sessionId} onNext={handleOrientationComplete} />
|
||||
case 1:
|
||||
return <StepDeskew sessionId={sessionId} onNext={handleNext} />
|
||||
case 2:
|
||||
return <StepDewarp sessionId={sessionId} onNext={handleNext} />
|
||||
case 3:
|
||||
return <StepCrop sessionId={sessionId} onNext={handleCropNext} />
|
||||
case 4:
|
||||
return <StepColumnDetection sessionId={sessionId} onNext={handleNext} onBoxSessionsCreated={handleBoxSessionsCreated} />
|
||||
case 5:
|
||||
return <StepRowDetection sessionId={sessionId} onNext={handleNext} />
|
||||
case 6:
|
||||
return <StepWordRecognition sessionId={sessionId} onNext={handleNext} goToStep={goToStep} />
|
||||
case 7:
|
||||
return <StepStructureDetection sessionId={sessionId} onNext={handleNext} />
|
||||
case 8:
|
||||
return <StepLlmReview sessionId={sessionId} onNext={handleNext} />
|
||||
case 9:
|
||||
return <StepReconstruction sessionId={sessionId} onNext={handleNext} />
|
||||
case 10:
|
||||
return <StepGroundTruth sessionId={sessionId} onNext={handleNext} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PagePurpose
|
||||
title="OCR Pipeline"
|
||||
purpose="Schrittweise Seitenrekonstruktion: Scan begradigen, Spalten erkennen, Woerter lokalisieren und die Seite Wort fuer Wort nachbauen. Ziel: 10 Vokabelseiten fehlerfrei rekonstruieren."
|
||||
audience={['Entwickler', 'Data Scientists']}
|
||||
architecture={{
|
||||
services: ['klausur-service (FastAPI)', 'OpenCV', 'Tesseract'],
|
||||
databases: ['PostgreSQL Sessions'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'OCR Vergleich', href: '/ai/ocr-compare', description: 'Methoden-Vergleich' },
|
||||
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Trainingsdaten' },
|
||||
]}
|
||||
defaultCollapsed
|
||||
/>
|
||||
|
||||
{/* Session List */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sessions ({sessions.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{sessions.length > 0 && (
|
||||
<button
|
||||
onClick={deleteAllSessions}
|
||||
className="text-xs px-3 py-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title="Alle Sessions loeschen"
|
||||
>
|
||||
Alle loeschen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
className="text-xs px-3 py-1.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
+ Neue Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingSessions ? (
|
||||
<div className="text-sm text-gray-400 py-2">Lade Sessions...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 py-2">Noch keine Sessions vorhanden.</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
|
||||
{sessions.map((s) => {
|
||||
const catInfo = DOCUMENT_CATEGORIES.find(c => c.value === s.document_category)
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`relative flex items-start gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||
sessionId === s.id
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
className="flex-shrink-0 w-12 h-12 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-700"
|
||||
onClick={() => openSession(s.id)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=96`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0" onClick={() => openSession(s.id)}>
|
||||
{editingName === s.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editNameValue}
|
||||
onChange={(e) => setEditNameValue(e.target.value)}
|
||||
onBlur={() => renameSession(s.id, editNameValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') renameSession(s.id, editNameValue)
|
||||
if (e.key === 'Escape') setEditingName(null)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full px-1 py-0.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="truncate font-medium text-gray-700 dark:text-gray-300">
|
||||
{s.name || s.filename}
|
||||
</div>
|
||||
)}
|
||||
{/* ID row */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(s.id)
|
||||
const btn = e.currentTarget
|
||||
btn.textContent = 'Kopiert!'
|
||||
setTimeout(() => { btn.textContent = `ID: ${s.id.slice(0, 8)}` }, 1500)
|
||||
}}
|
||||
className="text-[10px] font-mono text-gray-400 hover:text-teal-500 transition-colors"
|
||||
title={`Volle ID: ${s.id} — Klick zum Kopieren`}
|
||||
>
|
||||
ID: {s.id.slice(0, 8)}
|
||||
</button>
|
||||
<div className="text-xs text-gray-400 flex gap-2 mt-0.5">
|
||||
<span>{new Date(s.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>Schritt {s.current_step}: {stepNames[s.current_step] || '?'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-col gap-1 items-end flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Category Badge */}
|
||||
<button
|
||||
onClick={() => setEditingCategory(editingCategory === s.id ? null : s.id)}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition-colors ${
|
||||
catInfo
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
||||
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
title="Kategorie setzen"
|
||||
>
|
||||
{catInfo ? `${catInfo.icon} ${catInfo.label}` : '+ Kategorie'}
|
||||
</button>
|
||||
{/* Doc Type Badge (read-only) */}
|
||||
{s.doc_type && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
||||
{s.doc_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditNameValue(s.name || s.filename)
|
||||
setEditingName(s.id)
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Umbenennen"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Session loeschen?')) deleteSession(s.id)
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-500"
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category dropdown (inline) */}
|
||||
{editingCategory === s.id && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{DOCUMENT_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.value}
|
||||
onClick={() => updateCategory(s.id, cat.value)}
|
||||
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
|
||||
s.document_category === cat.value
|
||||
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active session info */}
|
||||
{sessionId && sessionName && (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Aktive Session: <span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span></span>
|
||||
{activeCategory && (() => {
|
||||
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
|
||||
return cat ? <span className="text-xs px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300">{cat.icon} {cat.label}</span> : null
|
||||
})()}
|
||||
{docTypeResult && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
||||
{docTypeResult.doc_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PipelineStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepClick={handleStepClick}
|
||||
onReprocess={sessionId ? reprocessFromStep : undefined}
|
||||
docTypeResult={docTypeResult}
|
||||
onDocTypeChange={handleDocTypeChange}
|
||||
/>
|
||||
|
||||
{subSessions.length > 0 && parentSessionId && sessionId && (
|
||||
<BoxSessionTabs
|
||||
parentSessionId={parentSessionId}
|
||||
subSessions={subSessions}
|
||||
activeSessionId={sessionId}
|
||||
onSessionChange={handleSessionChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="min-h-[400px]">{renderStep()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
export type PipelineStepStatus = 'pending' | 'active' | 'completed' | 'failed' | 'skipped'
|
||||
|
||||
export interface PipelineStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: PipelineStepStatus
|
||||
}
|
||||
|
||||
export type DocumentCategory =
|
||||
| 'vokabelseite' | 'buchseite' | 'arbeitsblatt' | 'klausurseite'
|
||||
| 'mathearbeit' | 'statistik' | 'zeitung' | 'formular' | 'handschrift' | 'sonstiges'
|
||||
|
||||
export const DOCUMENT_CATEGORIES: { value: DocumentCategory; label: string; icon: string }[] = [
|
||||
{ value: 'vokabelseite', label: 'Vokabelseite', icon: '📖' },
|
||||
{ value: 'buchseite', label: 'Buchseite', icon: '📚' },
|
||||
{ value: 'arbeitsblatt', label: 'Arbeitsblatt', icon: '📝' },
|
||||
{ value: 'klausurseite', label: 'Klausurseite', icon: '📄' },
|
||||
{ value: 'mathearbeit', label: 'Mathearbeit', icon: '🔢' },
|
||||
{ value: 'statistik', label: 'Statistik', icon: '📊' },
|
||||
{ value: 'zeitung', label: 'Zeitung', icon: '📰' },
|
||||
{ value: 'formular', label: 'Formular', icon: '📋' },
|
||||
{ value: 'handschrift', label: 'Handschrift', icon: '✍️' },
|
||||
{ value: 'sonstiges', label: 'Sonstiges', icon: '📎' },
|
||||
]
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string
|
||||
name: string
|
||||
filename: string
|
||||
status: string
|
||||
current_step: number
|
||||
document_category?: DocumentCategory
|
||||
doc_type?: string
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
parent_session_id?: string | null
|
||||
box_index?: number | null
|
||||
}
|
||||
|
||||
export interface SubSession {
|
||||
id: string
|
||||
name: string
|
||||
box_index: number
|
||||
current_step?: number
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface PipelineLogEntry {
|
||||
step: string
|
||||
completed_at: string
|
||||
success: boolean
|
||||
duration_ms?: number
|
||||
metrics: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PipelineLog {
|
||||
steps: PipelineLogEntry[]
|
||||
}
|
||||
|
||||
export interface DocumentTypeResult {
|
||||
doc_type: 'vocab_table' | 'full_text' | 'generic_table'
|
||||
confidence: number
|
||||
pipeline: 'cell_first' | 'full_page'
|
||||
skip_steps: string[]
|
||||
features?: Record<string, unknown>
|
||||
duration_seconds?: number
|
||||
}
|
||||
|
||||
export interface OrientationResult {
|
||||
orientation_degrees: number
|
||||
corrected: boolean
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface CropResult {
|
||||
crop_applied: boolean
|
||||
crop_rect?: { x: number; y: number; width: number; height: number }
|
||||
crop_rect_pct?: { x: number; y: number; width: number; height: number }
|
||||
original_size: { width: number; height: number }
|
||||
cropped_size: { width: number; height: number }
|
||||
detected_format?: string
|
||||
format_confidence?: number
|
||||
aspect_ratio?: number
|
||||
border_fractions?: { top: number; bottom: number; left: number; right: number }
|
||||
skipped?: boolean
|
||||
duration_seconds?: number
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
session_id: string
|
||||
filename: string
|
||||
name?: string
|
||||
image_width: number
|
||||
image_height: number
|
||||
original_image_url: string
|
||||
current_step?: number
|
||||
document_category?: DocumentCategory
|
||||
doc_type?: string
|
||||
orientation_result?: OrientationResult
|
||||
crop_result?: CropResult
|
||||
deskew_result?: DeskewResult
|
||||
dewarp_result?: DewarpResult
|
||||
column_result?: ColumnResult
|
||||
row_result?: RowResult
|
||||
word_result?: GridResult
|
||||
doc_type_result?: DocumentTypeResult
|
||||
sub_sessions?: SubSession[]
|
||||
parent_session_id?: string
|
||||
box_index?: number
|
||||
}
|
||||
|
||||
export interface DeskewResult {
|
||||
session_id: string
|
||||
angle_hough: number
|
||||
angle_word_alignment: number
|
||||
angle_iterative?: number
|
||||
angle_residual?: number
|
||||
angle_textline?: number
|
||||
angle_applied: number
|
||||
method_used: 'hough' | 'word_alignment' | 'manual' | 'iterative' | 'two_pass' | 'three_pass' | 'manual_combined'
|
||||
confidence: number
|
||||
duration_seconds: number
|
||||
deskewed_image_url: string
|
||||
binarized_image_url: string
|
||||
}
|
||||
|
||||
export interface DeskewGroundTruth {
|
||||
is_correct: boolean
|
||||
corrected_angle?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface DewarpDetection {
|
||||
method: string
|
||||
shear_degrees: number
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface DewarpResult {
|
||||
session_id: string
|
||||
method_used: string
|
||||
shear_degrees: number
|
||||
confidence: number
|
||||
duration_seconds: number
|
||||
dewarped_image_url: string
|
||||
detections?: DewarpDetection[]
|
||||
}
|
||||
|
||||
export interface DewarpGroundTruth {
|
||||
is_correct: boolean
|
||||
corrected_shear?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface PageRegion {
|
||||
type: 'column_en' | 'column_de' | 'column_example' | 'page_ref'
|
||||
| 'column_marker' | 'column_text' | 'column_ignore' | 'header' | 'footer'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
classification_confidence?: number
|
||||
classification_method?: string
|
||||
}
|
||||
|
||||
export interface PageZone {
|
||||
zone_type: 'content' | 'box'
|
||||
y_start: number
|
||||
y_end: number
|
||||
box?: { x: number; y: number; width: number; height: number }
|
||||
}
|
||||
|
||||
export interface ColumnResult {
|
||||
columns: PageRegion[]
|
||||
duration_seconds: number
|
||||
zones?: PageZone[]
|
||||
}
|
||||
|
||||
export interface ColumnGroundTruth {
|
||||
is_correct: boolean
|
||||
corrected_columns?: PageRegion[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ManualColumnDivider {
|
||||
xPercent: number // Position in % of image width (0-100)
|
||||
}
|
||||
|
||||
export type ColumnTypeKey = PageRegion['type']
|
||||
|
||||
export interface RowResult {
|
||||
rows: RowItem[]
|
||||
summary: Record<string, number>
|
||||
total_rows: number
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface RowItem {
|
||||
index: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
word_count: number
|
||||
row_type: 'content' | 'header' | 'footer'
|
||||
gap_before: number
|
||||
}
|
||||
|
||||
export interface RowGroundTruth {
|
||||
is_correct: boolean
|
||||
corrected_rows?: RowItem[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface StructureGraphic {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
area: number
|
||||
shape: string // image, illustration
|
||||
color_name: string
|
||||
color_hex: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface StructureResult {
|
||||
image_width: number
|
||||
image_height: number
|
||||
content_bounds: { x: number; y: number; w: number; h: number }
|
||||
boxes: StructureBox[]
|
||||
zones: StructureZone[]
|
||||
graphics: StructureGraphic[]
|
||||
color_pixel_counts: Record<string, number>
|
||||
has_words: boolean
|
||||
word_count: number
|
||||
border_ghosts_removed?: number
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface StructureBox {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
confidence: number
|
||||
border_thickness: number
|
||||
bg_color_name?: string
|
||||
bg_color_hex?: string
|
||||
}
|
||||
|
||||
export interface StructureZone {
|
||||
index: number
|
||||
zone_type: 'content' | 'box'
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface WordBbox {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface OcrWordBox {
|
||||
text: string
|
||||
left: number // absolute image x in px
|
||||
top: number // absolute image y in px
|
||||
width: number // px
|
||||
height: number // px
|
||||
conf: number
|
||||
color?: string // hex color of detected text, e.g. '#dc2626'
|
||||
color_name?: string // 'black' | 'red' | 'blue' | 'green' | 'orange' | 'purple' | 'yellow'
|
||||
recovered?: boolean // true if this word was recovered via color detection
|
||||
}
|
||||
|
||||
export interface GridCell {
|
||||
cell_id: string // "R03_C1"
|
||||
row_index: number
|
||||
col_index: number
|
||||
col_type: string
|
||||
text: string
|
||||
confidence: number
|
||||
bbox_px: WordBbox
|
||||
bbox_pct: WordBbox
|
||||
ocr_engine?: string
|
||||
is_bold?: boolean
|
||||
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
|
||||
word_boxes?: OcrWordBox[] // per-word bounding boxes from OCR engine
|
||||
}
|
||||
|
||||
export interface ColumnMeta {
|
||||
index: number
|
||||
type: string
|
||||
x: number
|
||||
width: number
|
||||
}
|
||||
|
||||
export interface GridResult {
|
||||
cells: GridCell[]
|
||||
grid_shape: { rows: number; cols: number; total_cells: number }
|
||||
columns_used: ColumnMeta[]
|
||||
layout: 'vocab' | 'generic'
|
||||
image_width: number
|
||||
image_height: number
|
||||
duration_seconds: number
|
||||
ocr_engine?: string
|
||||
vocab_entries?: WordEntry[] // Only when layout='vocab'
|
||||
entries?: WordEntry[] // Backwards compat alias for vocab_entries
|
||||
entry_count?: number
|
||||
summary: {
|
||||
total_cells: number
|
||||
non_empty_cells: number
|
||||
low_confidence: number
|
||||
// Only when layout='vocab':
|
||||
total_entries?: number
|
||||
with_english?: number
|
||||
with_german?: number
|
||||
}
|
||||
llm_review?: {
|
||||
changes: { row_index: number; field: string; old: string; new: string }[]
|
||||
model_used: string
|
||||
duration_ms: number
|
||||
entries_corrected: number
|
||||
applied_count?: number
|
||||
applied_at?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WordEntry {
|
||||
row_index: number
|
||||
english: string
|
||||
german: string
|
||||
example: string
|
||||
source_page?: string
|
||||
marker?: string
|
||||
confidence: number
|
||||
bbox: WordBbox
|
||||
bbox_en: WordBbox | null
|
||||
bbox_de: WordBbox | null
|
||||
bbox_ex: WordBbox | null
|
||||
bbox_ref?: WordBbox | null
|
||||
bbox_marker?: WordBbox | null
|
||||
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
|
||||
}
|
||||
|
||||
/** @deprecated Use GridResult instead */
|
||||
export interface WordResult {
|
||||
entries: WordEntry[]
|
||||
entry_count: number
|
||||
image_width: number
|
||||
image_height: number
|
||||
duration_seconds: number
|
||||
ocr_engine?: string
|
||||
summary: {
|
||||
total_entries: number
|
||||
with_english: number
|
||||
with_german: number
|
||||
low_confidence: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface WordGroundTruth {
|
||||
is_correct: boolean
|
||||
corrected_entries?: WordEntry[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ImageRegion {
|
||||
bbox_pct: { x: number; y: number; w: number; h: number }
|
||||
prompt: string
|
||||
description: string
|
||||
image_b64: string | null
|
||||
style: 'educational' | 'cartoon' | 'sketch' | 'clipart' | 'realistic'
|
||||
}
|
||||
|
||||
export type ImageStyle = ImageRegion['style']
|
||||
|
||||
export const IMAGE_STYLES: { value: ImageStyle; label: string }[] = [
|
||||
{ value: 'educational', label: 'Lehrbuch' },
|
||||
{ value: 'cartoon', label: 'Cartoon' },
|
||||
{ value: 'sketch', label: 'Skizze' },
|
||||
{ value: 'clipart', label: 'Clipart' },
|
||||
{ value: 'realistic', label: 'Realistisch' },
|
||||
]
|
||||
|
||||
export const PIPELINE_STEPS: PipelineStep[] = [
|
||||
{ id: 'orientation', name: 'Orientierung', icon: '🔄', status: 'pending' },
|
||||
{ id: 'deskew', name: 'Begradigung', icon: '📐', status: 'pending' },
|
||||
{ id: 'dewarp', name: 'Entzerrung', icon: '🔧', status: 'pending' },
|
||||
{ id: 'crop', name: 'Zuschneiden', icon: '✂️', status: 'pending' },
|
||||
{ id: 'columns', name: 'Spalten', icon: '📊', status: 'pending' },
|
||||
{ id: 'rows', name: 'Zeilen', icon: '📏', status: 'pending' },
|
||||
{ id: 'words', name: 'Woerter', icon: '🔤', status: 'pending' },
|
||||
{ id: 'structure', name: 'Struktur', icon: '🔍', status: 'pending' },
|
||||
{ id: 'llm-review', name: 'Korrektur', icon: '✏️', status: 'pending' },
|
||||
{ id: 'reconstruction', name: 'Rekonstruktion', icon: '🏗️', status: 'pending' },
|
||||
{ id: 'ground-truth', name: 'Validierung', icon: '✅', status: 'pending' },
|
||||
]
|
||||
403
admin-lehrer/app/(admin)/ai/ocr-regression/page.tsx
Normal file
403
admin-lehrer/app/(admin)/ai/ocr-regression/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OCR Regression Dashboard
|
||||
*
|
||||
* Shows all ground-truth sessions, runs regression tests,
|
||||
* displays pass/fail results with diff details, and shows history.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GTSession {
|
||||
session_id: string
|
||||
name: string
|
||||
filename: string
|
||||
document_category: string | null
|
||||
pipeline: string | null
|
||||
saved_at: string | null
|
||||
summary: {
|
||||
total_zones: number
|
||||
total_columns: number
|
||||
total_rows: number
|
||||
total_cells: number
|
||||
}
|
||||
}
|
||||
|
||||
interface DiffSummary {
|
||||
structural_changes: number
|
||||
cells_missing: number
|
||||
cells_added: number
|
||||
text_changes: number
|
||||
col_type_changes: number
|
||||
}
|
||||
|
||||
interface RegressionResult {
|
||||
session_id: string
|
||||
name: string
|
||||
status: 'pass' | 'fail' | 'error'
|
||||
error?: string
|
||||
diff_summary?: DiffSummary
|
||||
reference_summary?: Record<string, number>
|
||||
current_summary?: Record<string, number>
|
||||
structural_diffs?: Array<{ field: string; reference: number; current: number }>
|
||||
cell_diffs?: Array<{ type: string; cell_id: string; reference?: string; current?: string }>
|
||||
}
|
||||
|
||||
interface RegressionRun {
|
||||
id: string
|
||||
run_at: string
|
||||
status: string
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
errors: number
|
||||
duration_ms: number
|
||||
triggered_by: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'pass'
|
||||
? 'bg-emerald-100 text-emerald-800 border-emerald-200'
|
||||
: status === 'fail'
|
||||
? 'bg-red-100 text-red-800 border-red-200'
|
||||
: 'bg-amber-100 text-amber-800 border-amber-200'
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${cls}`}>
|
||||
{status === 'pass' ? 'Pass' : status === 'fail' ? 'Fail' : 'Error'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function OCRRegressionPage() {
|
||||
const [sessions, setSessions] = useState<GTSession[]>([])
|
||||
const [results, setResults] = useState<RegressionResult[]>([])
|
||||
const [history, setHistory] = useState<RegressionRun[]>([])
|
||||
const [running, setRunning] = useState(false)
|
||||
const [overallStatus, setOverallStatus] = useState<string | null>(null)
|
||||
const [durationMs, setDurationMs] = useState<number | null>(null)
|
||||
const [expandedSession, setExpandedSession] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<'current' | 'history'>('current')
|
||||
|
||||
// Load ground-truth sessions
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/ground-truth-sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSessions(data.sessions || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load GT sessions:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load history
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/regression/history?limit=20`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setHistory(data.runs || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load history:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
loadHistory()
|
||||
}, [loadSessions, loadHistory])
|
||||
|
||||
// Run all regressions
|
||||
const runAll = async () => {
|
||||
setRunning(true)
|
||||
setResults([])
|
||||
setOverallStatus(null)
|
||||
setDurationMs(null)
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/regression/run?triggered_by=manual`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResults(data.results || [])
|
||||
setOverallStatus(data.status)
|
||||
setDurationMs(data.duration_ms)
|
||||
loadHistory()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Regression run failed:', e)
|
||||
setOverallStatus('error')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPass = results.filter(r => r.status === 'pass').length
|
||||
const totalFail = results.filter(r => r.status === 'fail').length
|
||||
const totalError = results.filter(r => r.status === 'error').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||
<PagePurpose
|
||||
title="OCR Regression Tests"
|
||||
purpose="Automatische Regressions-Tests fuer die OCR-Pipeline: Ground-Truth Sessions neu auswerten und gegen Referenz-Ergebnisse vergleichen."
|
||||
audience={['Entwickler', 'QA']}
|
||||
defaultCollapsed
|
||||
architecture={{
|
||||
services: ['klausur-service (FastAPI, Port 8086)'],
|
||||
databases: ['PostgreSQL (regression_runs, ocr_pipeline_sessions)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'OCR Pipeline', href: '/ai/ocr-pipeline', description: 'OCR-Pipeline ausfuehren' },
|
||||
{ name: 'Ground Truth Review', href: '/ai/ocr-ground-truth', description: 'Sessions pruefen & markieren' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header + Run Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">OCR Regression Tests</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{sessions.length} Ground-Truth Session{sessions.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={runAll}
|
||||
disabled={running || sessions.length === 0}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
{running ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Laeuft...
|
||||
</>
|
||||
) : (
|
||||
'Alle Tests starten'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overall Result Banner */}
|
||||
{overallStatus && (
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
overallStatus === 'pass'
|
||||
? 'bg-emerald-50 border-emerald-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={overallStatus} />
|
||||
<span className="font-medium text-slate-900">
|
||||
{totalPass} bestanden, {totalFail} fehlgeschlagen, {totalError} Fehler
|
||||
</span>
|
||||
</div>
|
||||
{durationMs !== null && (
|
||||
<span className="text-sm text-slate-500">{(durationMs / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-slate-200">
|
||||
<nav className="flex gap-4">
|
||||
{(['current', 'history'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{t === 'current' ? 'Aktuelle Ergebnisse' : 'Verlauf'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Current Results Tab */}
|
||||
{tab === 'current' && (
|
||||
<div className="space-y-3">
|
||||
{results.length === 0 && !running && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-lg">Keine Ergebnisse</p>
|
||||
<p className="text-sm mt-1">Klicken Sie "Alle Tests starten" um die Regression zu laufen.</p>
|
||||
</div>
|
||||
)}
|
||||
{results.map(r => (
|
||||
<div
|
||||
key={r.session_id}
|
||||
className="bg-white rounded-lg border border-slate-200 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => setExpandedSession(expandedSession === r.session_id ? null : r.session_id)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<StatusBadge status={r.status} />
|
||||
<span className="font-medium text-slate-900 truncate">{r.name || r.session_id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-500">
|
||||
{r.diff_summary && (
|
||||
<span>
|
||||
{r.diff_summary.text_changes} Text, {r.diff_summary.structural_changes} Struktur
|
||||
</span>
|
||||
)}
|
||||
{r.error && <span className="text-red-500">{r.error}</span>}
|
||||
<svg className={`w-4 h-4 transition-transform ${expandedSession === r.session_id ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedSession === r.session_id && r.status === 'fail' && (
|
||||
<div className="border-t border-slate-100 px-4 py-3 bg-slate-50 space-y-3">
|
||||
{/* Structural Diffs */}
|
||||
{r.structural_diffs && r.structural_diffs.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-slate-500 uppercase mb-1">Strukturelle Aenderungen</h4>
|
||||
<div className="space-y-1">
|
||||
{r.structural_diffs.map((d, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
<span className="font-mono text-slate-600">{d.field}</span>: {d.reference} → {d.current}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Cell Diffs */}
|
||||
{r.cell_diffs && r.cell_diffs.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-slate-500 uppercase mb-1">
|
||||
Zellen-Aenderungen ({r.cell_diffs.length})
|
||||
</h4>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||
{r.cell_diffs.slice(0, 50).map((d, i) => (
|
||||
<div key={i} className="text-sm font-mono bg-white rounded px-2 py-1 border border-slate-100">
|
||||
<span className={`text-xs px-1 rounded ${
|
||||
d.type === 'text_change' ? 'bg-amber-100 text-amber-700'
|
||||
: d.type === 'cell_missing' ? 'bg-red-100 text-red-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{d.type}
|
||||
</span>{' '}
|
||||
<span className="text-slate-500">{d.cell_id}</span>
|
||||
{d.reference && (
|
||||
<>
|
||||
{' '}<span className="line-through text-red-400">{d.reference}</span>
|
||||
</>
|
||||
)}
|
||||
{d.current && (
|
||||
<>
|
||||
{' '}<span className="text-emerald-600">{d.current}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{r.cell_diffs.length > 50 && (
|
||||
<p className="text-xs text-slate-400">... und {r.cell_diffs.length - 50} weitere</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Ground Truth Sessions Overview (when no results yet) */}
|
||||
{results.length === 0 && sessions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-2">Ground-Truth Sessions</h3>
|
||||
<div className="grid gap-2">
|
||||
{sessions.map(s => (
|
||||
<div key={s.session_id} className="bg-white rounded-lg border border-slate-200 px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-slate-900">{s.name || s.session_id}</span>
|
||||
<span className="text-sm text-slate-400 ml-2">{s.filename}</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{s.summary.total_cells} Zellen, {s.summary.total_zones} Zonen
|
||||
{s.pipeline && <span className="ml-2 text-xs bg-slate-100 px-1.5 py-0.5 rounded">{s.pipeline}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Tab */}
|
||||
{tab === 'history' && (
|
||||
<div className="space-y-2">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-center py-8 text-slate-400">Noch keine Laeufe aufgezeichnet.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="pb-2 font-medium">Datum</th>
|
||||
<th className="pb-2 font-medium">Status</th>
|
||||
<th className="pb-2 font-medium text-right">Gesamt</th>
|
||||
<th className="pb-2 font-medium text-right">Pass</th>
|
||||
<th className="pb-2 font-medium text-right">Fail</th>
|
||||
<th className="pb-2 font-medium text-right">Dauer</th>
|
||||
<th className="pb-2 font-medium">Trigger</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map(run => (
|
||||
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-2">{formatDate(run.run_at)}</td>
|
||||
<td className="py-2"><StatusBadge status={run.status} /></td>
|
||||
<td className="py-2 text-right">{run.total}</td>
|
||||
<td className="py-2 text-right text-emerald-600">{run.passed}</td>
|
||||
<td className="py-2 text-right text-red-600">{run.failed + run.errors}</td>
|
||||
<td className="py-2 text-right text-slate-500">{(run.duration_ms / 1000).toFixed(1)}s</td>
|
||||
<td className="py-2 text-slate-400">{run.triggered_by}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ChunkDetail } from './types'
|
||||
|
||||
interface ResultsListProps {
|
||||
results: ChunkDetail[]
|
||||
selectedChunk: ChunkDetail | null
|
||||
searchQuery: string
|
||||
onSelect: (chunk: ChunkDetail) => void
|
||||
}
|
||||
|
||||
function highlightText(text: string, query: string) {
|
||||
if (!query) return text
|
||||
const words = query.toLowerCase().split(' ').filter(w => w.length > 2)
|
||||
let result = text
|
||||
words.forEach(word => {
|
||||
const regex = new RegExp(`(${word})`, 'gi')
|
||||
result = result.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-0.5 rounded">$1</mark>')
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function ResultsList({ results, selectedChunk, searchQuery, onSelect }: ResultsListProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Gefundene Chunks ({results.length})
|
||||
</h3>
|
||||
<div className="space-y-3 max-h-[600px] overflow-y-auto">
|
||||
{results.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => onSelect(result)}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedChunk?.text === result.text
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
|
||||
{result.regulation_code}
|
||||
</span>
|
||||
{result.article && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Art. {result.article}
|
||||
{result.paragraph && ` Abs. ${result.paragraph}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
Score: {(result.score || 0).toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Text Preview */}
|
||||
<p
|
||||
className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightText(result.text.substring(0, 400) + (result.text.length > 400 ? '...' : ''), searchQuery)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>Chunk #{result.chunk_index || idx}</span>
|
||||
<span>{result.text.length} Zeichen</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { REGULATIONS, SAMPLE_QUERIES } from './types'
|
||||
|
||||
interface SearchSectionProps {
|
||||
searchQuery: string
|
||||
selectedRegulation: string
|
||||
topK: number
|
||||
searching: boolean
|
||||
onSearchQueryChange: (v: string) => void
|
||||
onRegulationChange: (v: string) => void
|
||||
onTopKChange: (v: number) => void
|
||||
onSearch: () => void
|
||||
onSampleQuery: (query: string, reg: string) => void
|
||||
}
|
||||
|
||||
export function SearchSection({
|
||||
searchQuery,
|
||||
selectedRegulation,
|
||||
topK,
|
||||
searching,
|
||||
onSearchQueryChange,
|
||||
onRegulationChange,
|
||||
onTopKChange,
|
||||
onSearch,
|
||||
onSampleQuery,
|
||||
}: SearchSectionProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Quick Sample Queries */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Schnell-Stichproben
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SAMPLE_QUERIES.map((sq, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onSampleQuery(sq.query, sq.reg)}
|
||||
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 rounded-full transition-colors"
|
||||
>
|
||||
{sq.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Section */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Chunk-Suche
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Suchbegriff / Paragraph / Artikeltext
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSearch()}
|
||||
placeholder="z.B. 'Recht auf Löschung' oder 'Art. 17 Abs. 1'"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Regulierung
|
||||
</label>
|
||||
<select
|
||||
value={selectedRegulation}
|
||||
onChange={(e) => onRegulationChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{REGULATIONS.map((reg) => (
|
||||
<option key={reg.code} value={reg.code}>
|
||||
{reg.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Anzahl
|
||||
</label>
|
||||
<select
|
||||
value={topK}
|
||||
onChange={(e) => onTopKChange(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSearch}
|
||||
disabled={searching || !searchQuery.trim()}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{searching ? 'Suche laeuft...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { ChunkDetail, TraceabilityResult } from './types'
|
||||
|
||||
interface TraceabilityPanelProps {
|
||||
selectedChunk: ChunkDetail | null
|
||||
loadingTrace: boolean
|
||||
traceability: TraceabilityResult | null
|
||||
}
|
||||
|
||||
export function TraceabilityPanel({ selectedChunk, loadingTrace, traceability }: TraceabilityPanelProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Traceability
|
||||
</h3>
|
||||
|
||||
{!selectedChunk ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>Waehlen Sie einen Chunk aus der Liste, um die Traceability zu sehen.</p>
|
||||
</div>
|
||||
) : loadingTrace ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Lade Traceability...</p>
|
||||
</div>
|
||||
) : traceability ? (
|
||||
<div className="space-y-6">
|
||||
{/* Selected Chunk Detail */}
|
||||
<ChunkDetailSection chunk={traceability.chunk} />
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Requirements */}
|
||||
<RequirementsSection requirements={traceability.requirements} />
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Controls */}
|
||||
<ControlsSection controls={traceability.controls} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChunkDetailSection({ chunk }: { chunk: ChunkDetail }) {
|
||||
return (
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Ausgewaehlter Chunk
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-slate-700 rounded p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
|
||||
{chunk.regulation_code}
|
||||
</span>
|
||||
{chunk.article && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Art. {chunk.article}
|
||||
{chunk.paragraph && ` Abs. ${chunk.paragraph}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{chunk.text}
|
||||
</p>
|
||||
{chunk.source_url && (
|
||||
<a
|
||||
href={chunk.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
Quelle oeffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RequirementsSection({ requirements }: { requirements: TraceabilityResult['requirements'] }) {
|
||||
return (
|
||||
<div className="border-l-4 border-orange-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Extrahierte Anforderungen ({requirements.length})
|
||||
</h4>
|
||||
{requirements.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{requirements.map((req, idx) => (
|
||||
<div key={idx} className="bg-orange-50 dark:bg-orange-900/20 rounded p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
|
||||
{req.category || 'Anforderung'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{req.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Keine Anforderungen aus diesem Chunk extrahiert.
|
||||
<br />
|
||||
<span className="text-xs">(Requirements-Extraktion ist noch nicht implementiert)</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlsSection({ controls }: { controls: TraceabilityResult['controls'] }) {
|
||||
return (
|
||||
<div className="border-l-4 border-green-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Abgeleitete Controls ({controls.length})
|
||||
</h4>
|
||||
{controls.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{controls.map((ctrl, idx) => (
|
||||
<div key={idx} className="bg-green-50 dark:bg-green-900/20 rounded p-3">
|
||||
<div className="font-medium text-sm text-green-700 dark:text-green-400 mb-1">
|
||||
{ctrl.name}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{ctrl.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Keine Controls aus diesem Chunk abgeleitet.
|
||||
<br />
|
||||
<span className="text-xs">(Control-Ableitung ist noch nicht implementiert)</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowDown() {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
admin-lehrer/app/(admin)/ai/quality/_components/types.ts
Normal file
66
admin-lehrer/app/(admin)/ai/quality/_components/types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export interface ChunkDetail {
|
||||
id: string
|
||||
text: string
|
||||
regulation_code: string
|
||||
regulation_name: string
|
||||
article: string | null
|
||||
paragraph: string | null
|
||||
chunk_index: number
|
||||
chunk_position: 'beginning' | 'middle' | 'end'
|
||||
source_url: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
export interface Requirement {
|
||||
id: string
|
||||
text: string
|
||||
category: string
|
||||
source_chunk_id: string
|
||||
regulation_code: string
|
||||
}
|
||||
|
||||
export interface Control {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
source_requirement_ids: string[]
|
||||
regulation_codes: string[]
|
||||
}
|
||||
|
||||
export interface TraceabilityResult {
|
||||
chunk: ChunkDetail
|
||||
requirements: Requirement[]
|
||||
controls: Control[]
|
||||
}
|
||||
|
||||
export const API_PROXY = '/api/legal-corpus'
|
||||
|
||||
export const REGULATIONS = [
|
||||
{ code: 'GDPR', name: 'DSGVO' },
|
||||
{ code: 'EPRIVACY', name: 'ePrivacy' },
|
||||
{ code: 'TDDDG', name: 'TDDDG' },
|
||||
{ code: 'SCC', name: 'Standardvertragsklauseln' },
|
||||
{ code: 'DPF', name: 'EU-US DPF' },
|
||||
{ code: 'AIACT', name: 'EU AI Act' },
|
||||
{ code: 'CRA', name: 'Cyber Resilience Act' },
|
||||
{ code: 'NIS2', name: 'NIS2' },
|
||||
{ code: 'EUCSA', name: 'EU Cybersecurity Act' },
|
||||
{ code: 'DATAACT', name: 'Data Act' },
|
||||
{ code: 'DGA', name: 'Data Governance Act' },
|
||||
{ code: 'DSA', name: 'Digital Services Act' },
|
||||
{ code: 'EAA', name: 'Accessibility Act' },
|
||||
{ code: 'DSM', name: 'DSM-Urheberrecht' },
|
||||
{ code: 'PLD', name: 'Produkthaftung' },
|
||||
{ code: 'GPSR', name: 'Product Safety' },
|
||||
{ code: 'BSI-TR-03161-1', name: 'BSI-TR Teil 1' },
|
||||
{ code: 'BSI-TR-03161-2', name: 'BSI-TR Teil 2' },
|
||||
{ code: 'BSI-TR-03161-3', name: 'BSI-TR Teil 3' },
|
||||
]
|
||||
|
||||
export const SAMPLE_QUERIES = [
|
||||
{ label: 'Art. 17 DSGVO (Recht auf Loeschung)', query: 'Recht auf Löschung Artikel 17', reg: 'GDPR' },
|
||||
{ label: 'Einwilligung TDDDG', query: 'Einwilligung Endeinrichtung speichern', reg: 'TDDDG' },
|
||||
{ label: 'AI Act Hochrisiko', query: 'Hochrisiko-KI-System Anforderungen', reg: 'AIACT' },
|
||||
{ label: 'NIS2 Sicherheitsmaßnahmen', query: 'Cybersicherheitsrisikomanagement Maßnahmen', reg: 'NIS2' },
|
||||
{ label: 'BSI Authentifizierung', query: 'Authentifizierung Zwei-Faktor mobile', reg: 'BSI-TR-03161-1' },
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { ChunkDetail, TraceabilityResult } from './types'
|
||||
import { API_PROXY } from './types'
|
||||
|
||||
export function useQualitySearch() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<ChunkDetail[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [selectedRegulation, setSelectedRegulation] = useState<string>('')
|
||||
const [topK, setTopK] = useState(10)
|
||||
|
||||
const [selectedChunk, setSelectedChunk] = useState<ChunkDetail | null>(null)
|
||||
const [traceability, setTraceability] = useState<TraceabilityResult | null>(null)
|
||||
const [loadingTrace, setLoadingTrace] = useState(false)
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setSearching(true)
|
||||
setSearchResults([])
|
||||
setSelectedChunk(null)
|
||||
setTraceability(null)
|
||||
|
||||
try {
|
||||
let url = `${API_PROXY}?action=search&query=${encodeURIComponent(searchQuery)}&top_k=${topK}`
|
||||
if (selectedRegulation) {
|
||||
url += `®ulations=${encodeURIComponent(selectedRegulation)}`
|
||||
}
|
||||
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSearchResults(data.results || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, [searchQuery, selectedRegulation, topK])
|
||||
|
||||
const loadTraceability = useCallback(async (chunk: ChunkDetail) => {
|
||||
setSelectedChunk(chunk)
|
||||
setLoadingTrace(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_PROXY}?action=traceability&chunk_id=${encodeURIComponent(chunk.id || chunk.regulation_code + '_' + chunk.chunk_index)}®ulation=${encodeURIComponent(chunk.regulation_code)}`
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTraceability({
|
||||
chunk,
|
||||
requirements: data.requirements || [],
|
||||
controls: data.controls || [],
|
||||
})
|
||||
} else {
|
||||
setTraceability({ chunk, requirements: [], controls: [] })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load traceability:', error)
|
||||
setTraceability({ chunk, requirements: [], controls: [] })
|
||||
} finally {
|
||||
setLoadingTrace(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSampleQuery = (query: string, reg: string) => {
|
||||
setSearchQuery(query)
|
||||
setSelectedRegulation(reg)
|
||||
setTimeout(() => {
|
||||
handleSearch()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
searching,
|
||||
selectedRegulation,
|
||||
setSelectedRegulation,
|
||||
topK,
|
||||
setTopK,
|
||||
selectedChunk,
|
||||
traceability,
|
||||
loadingTrace,
|
||||
handleSearch,
|
||||
loadTraceability,
|
||||
handleSampleQuery,
|
||||
}
|
||||
}
|
||||
@@ -5,184 +5,34 @@
|
||||
*
|
||||
* Ermoeglicht Auditoren:
|
||||
* - Chunk-Suche und Stichproben
|
||||
* - Traceability: Chunk → Requirement → Control
|
||||
* - Traceability: Chunk -> Requirement -> Control
|
||||
* - Dokumenten-Vollstaendigkeitspruefung
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
const API_PROXY = '/api/legal-corpus'
|
||||
|
||||
// Types
|
||||
interface ChunkDetail {
|
||||
id: string
|
||||
text: string
|
||||
regulation_code: string
|
||||
regulation_name: string
|
||||
article: string | null
|
||||
paragraph: string | null
|
||||
chunk_index: number
|
||||
chunk_position: 'beginning' | 'middle' | 'end'
|
||||
source_url: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
interface Requirement {
|
||||
id: string
|
||||
text: string
|
||||
category: string
|
||||
source_chunk_id: string
|
||||
regulation_code: string
|
||||
}
|
||||
|
||||
interface Control {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
source_requirement_ids: string[]
|
||||
regulation_codes: string[]
|
||||
}
|
||||
|
||||
interface TraceabilityResult {
|
||||
chunk: ChunkDetail
|
||||
requirements: Requirement[]
|
||||
controls: Control[]
|
||||
}
|
||||
|
||||
// Regulations for filtering
|
||||
const REGULATIONS = [
|
||||
{ code: 'GDPR', name: 'DSGVO' },
|
||||
{ code: 'EPRIVACY', name: 'ePrivacy' },
|
||||
{ code: 'TDDDG', name: 'TDDDG' },
|
||||
{ code: 'SCC', name: 'Standardvertragsklauseln' },
|
||||
{ code: 'DPF', name: 'EU-US DPF' },
|
||||
{ code: 'AIACT', name: 'EU AI Act' },
|
||||
{ code: 'CRA', name: 'Cyber Resilience Act' },
|
||||
{ code: 'NIS2', name: 'NIS2' },
|
||||
{ code: 'EUCSA', name: 'EU Cybersecurity Act' },
|
||||
{ code: 'DATAACT', name: 'Data Act' },
|
||||
{ code: 'DGA', name: 'Data Governance Act' },
|
||||
{ code: 'DSA', name: 'Digital Services Act' },
|
||||
{ code: 'EAA', name: 'Accessibility Act' },
|
||||
{ code: 'DSM', name: 'DSM-Urheberrecht' },
|
||||
{ code: 'PLD', name: 'Produkthaftung' },
|
||||
{ code: 'GPSR', name: 'Product Safety' },
|
||||
{ code: 'BSI-TR-03161-1', name: 'BSI-TR Teil 1' },
|
||||
{ code: 'BSI-TR-03161-2', name: 'BSI-TR Teil 2' },
|
||||
{ code: 'BSI-TR-03161-3', name: 'BSI-TR Teil 3' },
|
||||
]
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
eu_regulation: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
eu_directive: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
de_law: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
bsi_standard: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
}
|
||||
import { useQualitySearch } from './_components/useQualitySearch'
|
||||
import { SearchSection } from './_components/SearchSection'
|
||||
import { ResultsList } from './_components/ResultsList'
|
||||
import { TraceabilityPanel } from './_components/TraceabilityPanel'
|
||||
|
||||
export default function QualityPage() {
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<ChunkDetail[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [selectedRegulation, setSelectedRegulation] = useState<string>('')
|
||||
const [topK, setTopK] = useState(10)
|
||||
|
||||
// Traceability state
|
||||
const [selectedChunk, setSelectedChunk] = useState<ChunkDetail | null>(null)
|
||||
const [traceability, setTraceability] = useState<TraceabilityResult | null>(null)
|
||||
const [loadingTrace, setLoadingTrace] = useState(false)
|
||||
|
||||
// Quick sample queries for auditors
|
||||
const sampleQueries = [
|
||||
{ label: 'Art. 17 DSGVO (Recht auf Loeschung)', query: 'Recht auf Löschung Artikel 17', reg: 'GDPR' },
|
||||
{ label: 'Einwilligung TDDDG', query: 'Einwilligung Endeinrichtung speichern', reg: 'TDDDG' },
|
||||
{ label: 'AI Act Hochrisiko', query: 'Hochrisiko-KI-System Anforderungen', reg: 'AIACT' },
|
||||
{ label: 'NIS2 Sicherheitsmaßnahmen', query: 'Cybersicherheitsrisikomanagement Maßnahmen', reg: 'NIS2' },
|
||||
{ label: 'BSI Authentifizierung', query: 'Authentifizierung Zwei-Faktor mobile', reg: 'BSI-TR-03161-1' },
|
||||
]
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setSearching(true)
|
||||
setSearchResults([])
|
||||
setSelectedChunk(null)
|
||||
setTraceability(null)
|
||||
|
||||
try {
|
||||
let url = `${API_PROXY}?action=search&query=${encodeURIComponent(searchQuery)}&top_k=${topK}`
|
||||
if (selectedRegulation) {
|
||||
url += `®ulations=${encodeURIComponent(selectedRegulation)}`
|
||||
}
|
||||
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSearchResults(data.results || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, [searchQuery, selectedRegulation, topK])
|
||||
|
||||
const loadTraceability = useCallback(async (chunk: ChunkDetail) => {
|
||||
setSelectedChunk(chunk)
|
||||
setLoadingTrace(true)
|
||||
|
||||
try {
|
||||
// Try to load traceability (requirements and controls derived from this chunk)
|
||||
const res = await fetch(`${API_PROXY}?action=traceability&chunk_id=${encodeURIComponent(chunk.id || chunk.regulation_code + '_' + chunk.chunk_index)}®ulation=${encodeURIComponent(chunk.regulation_code)}`)
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTraceability({
|
||||
chunk,
|
||||
requirements: data.requirements || [],
|
||||
controls: data.controls || [],
|
||||
})
|
||||
} else {
|
||||
// If traceability endpoint doesn't exist yet, show placeholder
|
||||
setTraceability({
|
||||
chunk,
|
||||
requirements: [],
|
||||
controls: [],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load traceability:', error)
|
||||
setTraceability({
|
||||
chunk,
|
||||
requirements: [],
|
||||
controls: [],
|
||||
})
|
||||
} finally {
|
||||
setLoadingTrace(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSampleQuery = (query: string, reg: string) => {
|
||||
setSearchQuery(query)
|
||||
setSelectedRegulation(reg)
|
||||
// Auto-search after setting
|
||||
setTimeout(() => {
|
||||
handleSearch()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const highlightText = (text: string, query: string) => {
|
||||
if (!query) return text
|
||||
const words = query.toLowerCase().split(' ').filter(w => w.length > 2)
|
||||
let result = text
|
||||
words.forEach(word => {
|
||||
const regex = new RegExp(`(${word})`, 'gi')
|
||||
result = result.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-0.5 rounded">$1</mark>')
|
||||
})
|
||||
return result
|
||||
}
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
searching,
|
||||
selectedRegulation,
|
||||
setSelectedRegulation,
|
||||
topK,
|
||||
setTopK,
|
||||
selectedChunk,
|
||||
traceability,
|
||||
loadingTrace,
|
||||
handleSearch,
|
||||
loadTraceability,
|
||||
handleSampleQuery,
|
||||
} = useQualitySearch()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -214,265 +64,32 @@ export default function QualityPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Quick Sample Queries */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Schnell-Stichproben
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sampleQueries.map((sq, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSampleQuery(sq.query, sq.reg)}
|
||||
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 rounded-full transition-colors"
|
||||
>
|
||||
{sq.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Section */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Chunk-Suche
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Suchbegriff / Paragraph / Artikeltext
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="z.B. 'Recht auf Löschung' oder 'Art. 17 Abs. 1'"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Regulierung
|
||||
</label>
|
||||
<select
|
||||
value={selectedRegulation}
|
||||
onChange={(e) => setSelectedRegulation(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{REGULATIONS.map((reg) => (
|
||||
<option key={reg.code} value={reg.code}>
|
||||
{reg.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Anzahl
|
||||
</label>
|
||||
<select
|
||||
value={topK}
|
||||
onChange={(e) => setTopK(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !searchQuery.trim()}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{searching ? 'Suche laeuft...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SearchSection
|
||||
searchQuery={searchQuery}
|
||||
selectedRegulation={selectedRegulation}
|
||||
topK={topK}
|
||||
searching={searching}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onRegulationChange={setSelectedRegulation}
|
||||
onTopKChange={setTopK}
|
||||
onSearch={handleSearch}
|
||||
onSampleQuery={handleSampleQuery}
|
||||
/>
|
||||
|
||||
{/* Results Grid */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Search Results List */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Gefundene Chunks ({searchResults.length})
|
||||
</h3>
|
||||
<div className="space-y-3 max-h-[600px] overflow-y-auto">
|
||||
{searchResults.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => loadTraceability(result)}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedChunk?.text === result.text
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
|
||||
{result.regulation_code}
|
||||
</span>
|
||||
{result.article && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Art. {result.article}
|
||||
{result.paragraph && ` Abs. ${result.paragraph}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
Score: {(result.score || 0).toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Text Preview */}
|
||||
<p
|
||||
className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightText(result.text.substring(0, 400) + (result.text.length > 400 ? '...' : ''), searchQuery)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>Chunk #{result.chunk_index || idx}</span>
|
||||
<span>{result.text.length} Zeichen</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traceability Panel */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Traceability
|
||||
</h3>
|
||||
|
||||
{!selectedChunk ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>Waehlen Sie einen Chunk aus der Liste, um die Traceability zu sehen.</p>
|
||||
</div>
|
||||
) : loadingTrace ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Lade Traceability...</p>
|
||||
</div>
|
||||
) : traceability ? (
|
||||
<div className="space-y-6">
|
||||
{/* Selected Chunk Detail */}
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
📄 Ausgewaehlter Chunk
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-slate-700 rounded p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
|
||||
{traceability.chunk.regulation_code}
|
||||
</span>
|
||||
{traceability.chunk.article && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Art. {traceability.chunk.article}
|
||||
{traceability.chunk.paragraph && ` Abs. ${traceability.chunk.paragraph}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{traceability.chunk.text}
|
||||
</p>
|
||||
{traceability.chunk.source_url && (
|
||||
<a
|
||||
href={traceability.chunk.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
🔗 Quelle oeffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Down */}
|
||||
<div className="flex justify-center">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="border-l-4 border-orange-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
📋 Extrahierte Anforderungen ({traceability.requirements.length})
|
||||
</h4>
|
||||
{traceability.requirements.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{traceability.requirements.map((req, idx) => (
|
||||
<div key={idx} className="bg-orange-50 dark:bg-orange-900/20 rounded p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
|
||||
{req.category || 'Anforderung'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{req.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Keine Anforderungen aus diesem Chunk extrahiert.
|
||||
<br />
|
||||
<span className="text-xs">(Requirements-Extraktion ist noch nicht implementiert)</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow Down */}
|
||||
<div className="flex justify-center">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="border-l-4 border-green-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
✅ Abgeleitete Controls ({traceability.controls.length})
|
||||
</h4>
|
||||
{traceability.controls.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{traceability.controls.map((ctrl, idx) => (
|
||||
<div key={idx} className="bg-green-50 dark:bg-green-900/20 rounded p-3">
|
||||
<div className="font-medium text-sm text-green-700 dark:text-green-400 mb-1">
|
||||
{ctrl.name}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{ctrl.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Keine Controls aus diesem Chunk abgeleitet.
|
||||
<br />
|
||||
<span className="text-xs">(Control-Ableitung ist noch nicht implementiert)</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ResultsList
|
||||
results={searchResults}
|
||||
selectedChunk={selectedChunk}
|
||||
searchQuery={searchQuery}
|
||||
onSelect={loadTraceability}
|
||||
/>
|
||||
<TraceabilityPanel
|
||||
selectedChunk={selectedChunk}
|
||||
loadingTrace={loadingTrace}
|
||||
traceability={traceability}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -510,13 +127,13 @@ export default function QualityPage() {
|
||||
{/* Audit Info */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-400 mb-2">
|
||||
ℹ️ Hinweise fuer Auditoren
|
||||
Hinweise fuer Auditoren
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1 list-disc list-inside">
|
||||
<li>Die Suche ist semantisch - aehnliche Begriffe werden gefunden, auch wenn die exakte Formulierung abweicht</li>
|
||||
<li>Jeder Chunk entspricht einem logischen Textabschnitt aus dem Originaldokument</li>
|
||||
<li>Die Traceability zeigt, wie aus dem Originaltext Anforderungen und Controls abgeleitet wurden</li>
|
||||
<li>Klicken Sie auf "Quelle oeffnen", um das Originaldokument zu pruefen</li>
|
||||
<li>Klicken Sie auf "Quelle oeffnen", um das Originaldokument zu pruefen</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
export function ArchitectureTab() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* What is this module */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Was macht dieses Modul?
|
||||
</h2>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Das <strong>RAG-Indexierungs-Modul</strong> verarbeitet Dokumente und macht sie fuer die KI-gestuetzte Suche verfuegbar.
|
||||
Es handelt sich <strong>nicht</strong> um klassisches Machine-Learning-Training, sondern um:
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2 text-gray-600 dark:text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">1.</span>
|
||||
<span><strong>Dokumentenextraktion:</strong> PDFs und Bilder werden per OCR in Text umgewandelt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">2.</span>
|
||||
<span><strong>Chunking:</strong> Lange Texte werden in suchbare Abschnitte (1000 Zeichen) aufgeteilt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">3.</span>
|
||||
<span><strong>Embedding:</strong> Jeder Chunk wird in einen Vektor (1536 Dimensionen) umgewandelt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">4.</span>
|
||||
<span><strong>Indexierung:</strong> Vektoren werden in Qdrant gespeichert fuer semantische Suche</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Technische Architektur
|
||||
</h2>
|
||||
|
||||
{/* Visual Pipeline */}
|
||||
<div className="relative">
|
||||
{/* Data Sources Row */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<SourceCard icon="📄" title="NiBiS PDFs" subtitle="Erwartungshorizonte" color="blue" />
|
||||
<SourceCard icon="📤" title="Uploads" subtitle="Eigene EH" color="green" />
|
||||
<SourceCard icon="⚖️" title="Rechtskorpus" subtitle="DSGVO, AI Act" color="purple" />
|
||||
<SourceCard icon="📚" title="Schulordnungen" subtitle="Bundeslaender" color="orange" />
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Processing Layer */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-6 mb-8">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-4">
|
||||
Verarbeitungs-Pipeline
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<PipelineStep icon="🔍" title="OCR" subtitle="Text-Extraktion" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="✂️" title="Chunking" subtitle="1000 Zeichen" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="🧮" title="Embedding" subtitle="1536-dim Vektor" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="💾" title="Speichern" subtitle="Qdrant" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Storage Layer */}
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 rounded-xl p-6 mb-8 border-2 border-indigo-200 dark:border-indigo-800">
|
||||
<h3 className="text-sm font-semibold text-indigo-600 dark:text-indigo-400 uppercase tracking-wide mb-4">
|
||||
Vektor-Datenbank (Qdrant)
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<CollectionCard collection="bp_nibis_eh" label="Offizielle EH" />
|
||||
<CollectionCard collection="bp_eh" label="Benutzer EH" />
|
||||
<CollectionCard collection="bp_legal_corpus" label="Rechtskorpus" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Usage Layer */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border-2 border-emerald-200 dark:border-emerald-800">
|
||||
<h4 className="font-medium text-emerald-700 dark:text-emerald-400 mb-2">Semantische Suche</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Fragen werden in Vektoren umgewandelt und aehnliche Dokumente gefunden
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border-2 border-amber-200 dark:border-amber-800">
|
||||
<h4 className="font-medium text-amber-700 dark:text-amber-400 mb-2">RAG-Antworten</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
LLM generiert Antworten basierend auf gefundenen Dokumenten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Technische Details
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Embedding-Service</h3>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Modell</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">text-embedding-3-small</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Dimensionen</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">1536</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Port</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">8087</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Chunk-Konfiguration</h3>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Chunk-Groesse</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">1000 Zeichen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Ueberlappung</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">200 Zeichen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Distanzmetrik</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">COSINE</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal helper components ---
|
||||
|
||||
function SourceCard({ icon, title, subtitle, color }: {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: string
|
||||
color: string
|
||||
}) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
|
||||
green: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
|
||||
orange: 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-xl border-2 text-center ${colorClasses[color]}`}>
|
||||
<div className="text-3xl mb-2">{icon}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{title}</div>
|
||||
<div className="text-xs text-gray-500">{subtitle}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineStep({ icon, title, subtitle }: {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="text-2xl mb-1">{icon}</div>
|
||||
<div className="font-medium text-sm">{title}</div>
|
||||
<div className="text-xs text-gray-500">{subtitle}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionCard({ collection, label }: { collection: string; label: string }) {
|
||||
return (
|
||||
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg text-center">
|
||||
<div className="font-mono text-xs text-gray-500">{collection}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowDown() {
|
||||
return (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="text-4xl text-gray-400">↓</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowRight() {
|
||||
return <div className="text-2xl text-gray-400">→</div>
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import type { DataSource } from '../types'
|
||||
|
||||
export function DataSourcesTab({ sources }: { sources: DataSource[] }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Introduction */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
|
||||
<h2 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Wie werden Daten hinzugefuegt?
|
||||
</h2>
|
||||
<p className="text-blue-800 dark:text-blue-200 mb-4">
|
||||
Das RAG-System nutzt verschiedene Datenquellen. Jede Quelle hat einen eigenen Ingestion-Prozess:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900 dark:text-white mb-1">Automatisch</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
NiBiS-PDFs werden automatisch aus dem za-download Verzeichnis eingelesen
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900 dark:text-white mb-1">Manuell</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Eigene EH koennen ueber die Klausur-Korrektur hochgeladen werden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Sources List */}
|
||||
<div className="grid gap-4">
|
||||
{sources.map((source) => (
|
||||
<DataSourceCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* How to add data */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Daten hinzufuegen
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<AddDataCard
|
||||
icon="📤"
|
||||
title="Erwartungshorizont hochladen"
|
||||
description="Laden Sie eigene EH-Dokumente in der Klausur-Korrektur hoch"
|
||||
linkHref="/admin/klausur-korrektur"
|
||||
linkText="Zur Klausur-Korrektur →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="🔄"
|
||||
title="NiBiS neu einlesen"
|
||||
description="Starten Sie die automatische Ingestion der NiBiS-PDFs"
|
||||
linkText="Ingestion starten →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="⚖️"
|
||||
title="Rechtskorpus erweitern"
|
||||
description="Neue Regelwerke (DSGVO, BSI, etc.) zum Korpus hinzufuegen"
|
||||
linkText="Regelwerk hinzufuegen →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="📋"
|
||||
title="DSFA-Quellen verwalten"
|
||||
description="WP248, DSK, Muss-Listen mit Lizenzattribution"
|
||||
linkHref="/ai/rag-pipeline/dsfa"
|
||||
linkText="DSFA-Manager oeffnen →"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal helper components ---
|
||||
|
||||
function DataSourceCard({ source }: { source: DataSource }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{source.name}
|
||||
</h3>
|
||||
<DataSourceStatusBadge status={source.status} />
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{source.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Collection: </span>
|
||||
<span className="font-mono text-gray-900 dark:text-white">{source.collection}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Dokumente: </span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{source.document_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Chunks: </span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{source.chunk_count}</span>
|
||||
</div>
|
||||
{source.last_updated && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Aktualisiert: </span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{new Date(source.last_updated).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg">
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataSourceStatusBadge({ status }: { status: DataSource['status'] }) {
|
||||
const className = status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
|
||||
const label = status === 'active' ? 'Aktiv' : status === 'pending' ? 'Ausstehend' : 'Fehler'
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDataCard({ icon, title, description, linkHref, linkText }: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
linkHref?: string
|
||||
linkText: string
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||||
<div className="text-2xl mb-2">{icon}</div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{description}</p>
|
||||
{linkHref ? (
|
||||
<a
|
||||
href={linkHref}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
) : (
|
||||
<button className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
|
||||
{linkText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import type { DatasetStats } from '../types'
|
||||
|
||||
export function DatasetOverview({ stats }: { stats: DatasetStats }) {
|
||||
const maxBundesland = Math.max(...Object.values(stats.by_bundesland))
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Datensatz-Uebersicht
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{stats.total_documents.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{stats.total_chunks.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{stats.training_allowed.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Indexiert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Verteilung nach Bundesland
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.by_bundesland)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([code, count]) => (
|
||||
<div key={code} className="flex items-center gap-3">
|
||||
<span className="w-8 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">
|
||||
{code}
|
||||
</span>
|
||||
<div className="flex-1 h-4 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"
|
||||
style={{ width: `${(count / maxBundesland) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 text-sm text-right text-gray-600 dark:text-gray-400">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { TrainingConfig } from '../types'
|
||||
|
||||
const BUNDESLAENDER = [
|
||||
{ code: 'ni', name: 'Niedersachsen', allowed: true },
|
||||
{ code: 'by', name: 'Bayern', allowed: true },
|
||||
{ code: 'nw', name: 'NRW', allowed: true },
|
||||
{ code: 'he', name: 'Hessen', allowed: true },
|
||||
{ code: 'bw', name: 'Baden-Wuerttemberg', allowed: true },
|
||||
{ code: 'rp', name: 'Rheinland-Pfalz', allowed: true },
|
||||
{ code: 'sn', name: 'Sachsen', allowed: true },
|
||||
{ code: 'sh', name: 'Schleswig-Holstein', allowed: true },
|
||||
{ code: 'th', name: 'Thueringen', allowed: true },
|
||||
{ code: 'be', name: 'Berlin', allowed: false },
|
||||
{ code: 'bb', name: 'Brandenburg', allowed: false },
|
||||
{ code: 'hb', name: 'Bremen', allowed: false },
|
||||
{ code: 'hh', name: 'Hamburg', allowed: false },
|
||||
{ code: 'mv', name: 'Mecklenburg-Vorpommern', allowed: false },
|
||||
{ code: 'sl', name: 'Saarland', allowed: false },
|
||||
{ code: 'st', name: 'Sachsen-Anhalt', allowed: false },
|
||||
]
|
||||
|
||||
export function NewTrainingModal({ isOpen, onClose, onSubmit }: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [config, setConfig] = useState<Partial<TrainingConfig>>({
|
||||
batch_size: 16,
|
||||
learning_rate: 0.00005,
|
||||
epochs: 10,
|
||||
warmup_steps: 500,
|
||||
weight_decay: 0.01,
|
||||
gradient_accumulation: 4,
|
||||
mixed_precision: true,
|
||||
bundeslaender: [],
|
||||
})
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Neue Indexierung starten
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">Schritt {step} von 3</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<StepIndicator currentStep={step} />
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[50vh]">
|
||||
{step === 1 && (
|
||||
<BundeslaenderStep config={config} setConfig={setConfig} />
|
||||
)}
|
||||
{step === 2 && (
|
||||
<ParameterStep config={config} setConfig={setConfig} />
|
||||
)}
|
||||
{step === 3 && (
|
||||
<ConfirmStep config={config} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<button
|
||||
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
{step > 1 ? 'Zurueck' : 'Abbrechen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 3 ? setStep(step + 1) : onSubmit(config)}
|
||||
disabled={step === 1 && (!config.bundeslaender || config.bundeslaender.length === 0)}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{step < 3 ? 'Weiter' : 'Indexierung starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal step components ---
|
||||
|
||||
function StepIndicator({ currentStep }: { currentStep: number }) {
|
||||
return (
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s <= currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}>
|
||||
{s < currentStep ? '\u2713' : s}
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div className={`w-16 h-1 mx-2 rounded ${
|
||||
s < currentStep ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-20 mt-2 text-xs text-gray-500">
|
||||
<span>Daten</span>
|
||||
<span>Parameter</span>
|
||||
<span>Bestaetigen</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BundeslaenderStep({ config, setConfig }: {
|
||||
config: Partial<TrainingConfig>
|
||||
setConfig: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Waehlen Sie die Bundeslaender fuer die Indexierung
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Nur Bundeslaender mit verfuegbaren Dokumenten koennen ausgewaehlt werden.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{BUNDESLAENDER.map((bl) => (
|
||||
<label
|
||||
key={bl.code}
|
||||
className={`flex items-center p-3 rounded-lg border-2 transition cursor-pointer ${
|
||||
config.bundeslaender?.includes(bl.code)
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: bl.allowed
|
||||
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
|
||||
: 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!bl.allowed}
|
||||
checked={config.bundeslaender?.includes(bl.code)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setConfig({ ...config, bundeslaender: [...(config.bundeslaender || []), bl.code] })
|
||||
} else {
|
||||
setConfig({ ...config, bundeslaender: config.bundeslaender?.filter(c => c !== bl.code) })
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 ${
|
||||
config.bundeslaender?.includes(bl.code)
|
||||
? 'bg-blue-500 border-blue-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{config.bundeslaender?.includes(bl.code) && '\u2713'}
|
||||
</span>
|
||||
<span className="flex-1 text-gray-900 dark:text-white">{bl.name}</span>
|
||||
{!bl.allowed && (
|
||||
<span className="text-xs text-red-500">Keine Daten</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ParameterStep({ config, setConfig }: {
|
||||
config: Partial<TrainingConfig>
|
||||
setConfig: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Indexierungs-Parameter
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Diese Parameter steuern die Batch-Verarbeitung der Dokumente.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.batch_size}
|
||||
onChange={(e) => setConfig({ ...config, batch_size: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Dokumente pro Batch</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Durchlaeufe
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.epochs}
|
||||
onChange={(e) => setConfig({ ...config, epochs: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Fuer Validierung</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mixedPrecision"
|
||||
checked={config.mixed_precision}
|
||||
onChange={(e) => setConfig({ ...config, mixed_precision: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 rounded"
|
||||
/>
|
||||
<label htmlFor="mixedPrecision" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Parallele Verarbeitung - schneller bei grossem Datensatz
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmStep({ config }: { config: Partial<TrainingConfig> }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Konfiguration bestaetigen
|
||||
</h3>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Bundeslaender</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{config.bundeslaender?.length || 0} ausgewaehlt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Batch Size</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{config.batch_size}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Parallele Verarbeitung</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{config.mixed_precision ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Was passiert:</strong> Die ausgewaehlten Dokumente werden extrahiert,
|
||||
in Chunks aufgeteilt, und als Vektoren in Qdrant indexiert.
|
||||
Dieser Prozess kann je nach Datenmenge einige Minuten dauern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingJob } from '../types'
|
||||
|
||||
// Tab Button
|
||||
export function TabButton({ active, onClick, children }: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
active
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress Ring Component
|
||||
export function ProgressRing({ progress, size = 120, strokeWidth = 8, color = '#10B981' }: {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
color?: string
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
className="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mini Line Chart Component
|
||||
export function MiniChart({ data, color = '#10B981', height = 60 }: {
|
||||
data: number[]
|
||||
color?: string
|
||||
height?: number
|
||||
}) {
|
||||
if (!data.length) return null
|
||||
|
||||
const max = Math.max(...data)
|
||||
const min = Math.min(...data)
|
||||
const range = max - min || 1
|
||||
const width = 200
|
||||
const padding = 4
|
||||
|
||||
const points = data.map((value, i) => {
|
||||
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
|
||||
const y = padding + (1 - (value - min) / range) * (height - 2 * padding)
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{data.length > 0 && (
|
||||
<circle
|
||||
cx={padding + ((data.length - 1) / (data.length - 1)) * (width - 2 * padding)}
|
||||
cy={padding + (1 - (data[data.length - 1] - min) / range) * (height - 2 * padding)}
|
||||
r={4}
|
||||
fill={color}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
export function StatusBadge({ status }: { status: TrainingJob['status'] }) {
|
||||
const styles = {
|
||||
queued: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||
preparing: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
training: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
validating: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
paused: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
queued: 'In Warteschlange',
|
||||
preparing: 'Vorbereitung',
|
||||
training: 'Indexierung laeuft',
|
||||
validating: 'Validierung',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
paused: 'Pausiert',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
|
||||
{status === 'training' && (
|
||||
<span className="w-2 h-2 mr-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Metric Card
|
||||
export function MetricCard({ label, value, trend, color }: {
|
||||
label: string
|
||||
value: number | string
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{label}</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold" style={{ color: color || 'inherit' }}>
|
||||
{typeof value === 'number' ? value.toFixed(3) : value}
|
||||
</span>
|
||||
{trend && (
|
||||
<span className={`ml-2 text-sm ${
|
||||
trend === 'up' ? 'text-green-500' : trend === 'down' ? 'text-red-500' : 'text-gray-400'
|
||||
}`}>
|
||||
{trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingJob } from '../types'
|
||||
import { ProgressRing, MiniChart, StatusBadge, MetricCard } from './SharedWidgets'
|
||||
|
||||
export function TrainingJobCard({ job, onPause, onResume, onStop, onViewDetails }: {
|
||||
job: TrainingJob
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onViewDetails: () => void
|
||||
}) {
|
||||
const isActive = ['training', 'preparing', 'validating'].includes(job.status)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{job.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Typ: {job.model_type.charAt(0).toUpperCase() + job.model_type.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<ProgressRing
|
||||
progress={job.progress}
|
||||
color={job.status === 'failed' ? '#EF4444' : '#10B981'}
|
||||
/>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">Durchlauf</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{job.current_epoch} / {job.total_epochs}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-500"
|
||||
style={{ width: `${(job.current_epoch / job.total_epochs) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">Dokumente</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{job.documents_processed.toLocaleString()} / {job.total_documents.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-full transition-all duration-500"
|
||||
style={{ width: `${(job.documents_processed / job.total_documents) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 mt-6">
|
||||
<MetricCard label="Loss" value={job.loss} trend="down" color="#3B82F6" />
|
||||
<MetricCard label="Val Loss" value={job.val_loss} trend="down" color="#8B5CF6" />
|
||||
<MetricCard label="Precision" value={job.metrics.precision} color="#10B981" />
|
||||
<MetricCard label="F1 Score" value={job.metrics.f1_score} color="#F59E0B" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Fortschritt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<MiniChart data={job.metrics.loss_history} color="#3B82F6" />
|
||||
<MiniChart data={job.metrics.val_loss_history} color="#8B5CF6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
Gestartet: {job.started_at ? new Date(job.started_at).toLocaleTimeString('de-DE') : '-'}
|
||||
</span>
|
||||
<span>
|
||||
Geschaetzt: {job.estimated_completion
|
||||
? new Date(job.estimated_completion).toLocaleTimeString('de-DE')
|
||||
: '-'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
onClick={job.status === 'paused' ? onResume : onPause}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
{job.status === 'paused' ? 'Fortsetzen' : 'Pausieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/40"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
admin-lehrer/app/(admin)/ai/rag-pipeline/api.ts
Normal file
146
admin-lehrer/app/(admin)/ai/rag-pipeline/api.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { TrainingJob, TrainingConfig, DatasetStats, DataSource } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// MOCK DATA
|
||||
// ============================================================================
|
||||
|
||||
export const MOCK_JOBS: TrainingJob[] = []
|
||||
|
||||
export const MOCK_STATS: DatasetStats = {
|
||||
total_documents: 632,
|
||||
total_chunks: 8547,
|
||||
training_allowed: 489,
|
||||
by_bundesland: {
|
||||
ni: 87, by: 92, nw: 78, he: 65, bw: 71, rp: 43, sn: 38, sh: 34, th: 29,
|
||||
},
|
||||
by_doc_type: {
|
||||
verordnung: 312,
|
||||
schulordnung: 156,
|
||||
handreichung: 98,
|
||||
erlass: 66,
|
||||
},
|
||||
}
|
||||
|
||||
export const MOCK_DATA_SOURCES: DataSource[] = [
|
||||
{
|
||||
id: 'nibis',
|
||||
name: 'NiBiS Erwartungshorizonte',
|
||||
description: 'Offizielle Abitur-Erwartungshorizonte vom Niedersaechsischen Bildungsserver',
|
||||
collection: 'bp_nibis_eh',
|
||||
document_count: 245,
|
||||
chunk_count: 3200,
|
||||
last_updated: '2025-01-15T10:30:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'user_eh',
|
||||
name: 'Benutzerdefinierte EH',
|
||||
description: 'Von Lehrern hochgeladene schulspezifische Erwartungshorizonte',
|
||||
collection: 'bp_eh',
|
||||
document_count: 87,
|
||||
chunk_count: 1100,
|
||||
last_updated: '2025-01-20T14:15:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'legal',
|
||||
name: 'Rechtskorpus',
|
||||
description: 'DSGVO, AI Act, BSI-Standards und weitere Compliance-Regelwerke',
|
||||
collection: 'bp_legal_corpus',
|
||||
document_count: 19,
|
||||
chunk_count: 2400,
|
||||
last_updated: '2025-01-10T08:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'dsfa',
|
||||
name: 'DSFA-Guidance',
|
||||
description: 'WP248, DSK Kurzpapiere, Muss-Listen aller Bundeslaender mit Quellenattribution',
|
||||
collection: 'bp_dsfa_corpus',
|
||||
document_count: 45,
|
||||
chunk_count: 850,
|
||||
last_updated: '2026-02-09T10:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'schulordnungen',
|
||||
name: 'Schulordnungen',
|
||||
description: 'Landesschulordnungen und Zeugnisverordnungen aller Bundeslaender',
|
||||
collection: 'bp_schulordnungen',
|
||||
document_count: 156,
|
||||
chunk_count: 1847,
|
||||
last_updated: null,
|
||||
status: 'pending',
|
||||
},
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchJobs(): Promise<TrainingJob[]> {
|
||||
try {
|
||||
const response = await fetch('/api/ai/rag-pipeline?action=jobs')
|
||||
if (!response.ok) throw new Error('Failed to fetch jobs')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Error fetching jobs:', error)
|
||||
return MOCK_JOBS
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDatasetStats(): Promise<DatasetStats> {
|
||||
try {
|
||||
const response = await fetch('/api/ai/rag-pipeline?action=dataset-stats')
|
||||
if (!response.ok) throw new Error('Failed to fetch stats')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error)
|
||||
return MOCK_STATS
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTrainingJob(config: Partial<TrainingConfig>): Promise<{id: string, status: string}> {
|
||||
const response = await fetch('/api/ai/rag-pipeline?action=create-job', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `RAG-Index ${new Date().toLocaleDateString('de-DE')}`,
|
||||
model_type: 'zeugnis',
|
||||
bundeslaender: config.bundeslaender || [],
|
||||
batch_size: config.batch_size || 16,
|
||||
learning_rate: config.learning_rate || 0.00005,
|
||||
epochs: config.epochs || 10,
|
||||
warmup_steps: config.warmup_steps || 500,
|
||||
weight_decay: config.weight_decay || 0.01,
|
||||
gradient_accumulation: config.gradient_accumulation || 4,
|
||||
mixed_precision: config.mixed_precision ?? true,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || 'Failed to create job')
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export async function pauseJob(jobId: string): Promise<void> {
|
||||
const response = await fetch(`/api/ai/rag-pipeline?action=pause&job_id=${jobId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to pause job')
|
||||
}
|
||||
|
||||
export async function resumeJob(jobId: string): Promise<void> {
|
||||
const response = await fetch(`/api/ai/rag-pipeline?action=resume&job_id=${jobId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to resume job')
|
||||
}
|
||||
|
||||
export async function cancelJob(jobId: string): Promise<void> {
|
||||
const response = await fetch(`/api/ai/rag-pipeline?action=cancel&job_id=${jobId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to cancel job')
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Scale, CheckCircle, Clock, AlertCircle } from 'lucide-react'
|
||||
import {
|
||||
DSFALicenseCode,
|
||||
DSFA_LICENSE_LABELS,
|
||||
DSFA_DOCUMENT_TYPE_LABELS,
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
export function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
||||
const colorMap: Record<DSFALicenseCode, string> = {
|
||||
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
|
||||
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
|
||||
<Scale className="w-3 h-3" />
|
||||
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentTypeBadge({ type }: { type?: string }) {
|
||||
if (!type) return null
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
guideline: 'bg-indigo-100 text-indigo-700',
|
||||
checklist: 'bg-emerald-100 text-emerald-700',
|
||||
regulation: 'bg-red-100 text-red-700',
|
||||
template: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusIndicator({ status }: { status: string }) {
|
||||
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
|
||||
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
|
||||
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
|
||||
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
|
||||
}
|
||||
|
||||
const config = statusConfig[status] || statusConfig.yellow
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${config.color}`}>
|
||||
{config.icon}
|
||||
<span className="text-sm">{config.label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { DSFASource, DSFASourceStats } from '@/lib/sdk/types'
|
||||
import { LicenseBadge, DocumentTypeBadge } from './DSFABadges'
|
||||
|
||||
interface SourceCardProps {
|
||||
source: DSFASource
|
||||
stats?: DSFASourceStats
|
||||
onIngest: () => void
|
||||
isIngesting: boolean
|
||||
}
|
||||
|
||||
export function SourceCard({ source, stats, onIngest, isIngesting }: SourceCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{source.sourceCode}
|
||||
</span>
|
||||
<DocumentTypeBadge type={source.documentType} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
{source.name}
|
||||
</h3>
|
||||
{source.organization && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{source.organization}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<LicenseBadge licenseCode={source.licenseCode} />
|
||||
{stats && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.documentCount} Dok.
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.chunkCount} Chunks
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{source.attributionRequired && (
|
||||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
|
||||
<strong>Attribution:</strong> {source.attributionText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
{source.sourceUrl && (
|
||||
<>
|
||||
<dt className="text-gray-500">Quelle:</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={source.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
Link <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{source.licenseUrl && (
|
||||
<>
|
||||
<dt className="text-gray-500">Lizenz-URL:</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={source.licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{source.licenseName} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-gray-500">Sprache:</dt>
|
||||
<dd className="uppercase">{source.language}</dd>
|
||||
{stats?.lastIndexedAt && (
|
||||
<>
|
||||
<dt className="text-gray-500">Zuletzt indexiert:</dt>
|
||||
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={onIngest}
|
||||
disabled={isIngesting}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{isIngesting ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
Neu indexieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { Database } from 'lucide-react'
|
||||
import { DSFACorpusStats } from '@/lib/sdk/types'
|
||||
import { StatusIndicator } from './DSFABadges'
|
||||
|
||||
interface StatsOverviewProps {
|
||||
stats: DSFACorpusStats
|
||||
}
|
||||
|
||||
export function StatsOverview({ stats }: StatsOverviewProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Database className="w-5 h-5" />
|
||||
Corpus-Statistik
|
||||
</h2>
|
||||
<StatusIndicator status={stats.qdrantStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{stats.totalSources}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{stats.totalDocuments}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{stats.totalChunks.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{stats.qdrantPointsCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Collection:</strong>{' '}
|
||||
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{stats.qdrantCollection}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* DSFA API functions and mock data.
|
||||
*/
|
||||
|
||||
import {
|
||||
DSFASource,
|
||||
DSFACorpusStats,
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export const MOCK_SOURCES: DSFASource[] = [
|
||||
{
|
||||
id: '1',
|
||||
sourceCode: 'WP248',
|
||||
name: 'WP248 rev.01 - Leitlinien zur DSFA',
|
||||
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
|
||||
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
|
||||
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
|
||||
licenseCode: 'EDPB-LICENSE',
|
||||
licenseName: 'EDPB Document License',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
|
||||
documentType: 'guideline',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
sourceCode: 'DSK_KP5',
|
||||
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
|
||||
organization: 'Datenschutzkonferenz (DSK)',
|
||||
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
licenseName: 'Datenlizenz DE \u2013 Namensnennung 2.0',
|
||||
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
|
||||
documentType: 'guideline',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
sourceCode: 'BFDI_MUSS_PUBLIC',
|
||||
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
|
||||
organization: 'BfDI',
|
||||
sourceUrl: 'https://www.bfdi.bund.de',
|
||||
licenseCode: 'DL-DE-ZERO-2.0',
|
||||
licenseName: 'Datenlizenz DE \u2013 Zero 2.0',
|
||||
attributionRequired: false,
|
||||
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
|
||||
documentType: 'checklist',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
sourceCode: 'NI_MUSS_PRIVATE',
|
||||
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
|
||||
organization: 'LfD Niedersachsen',
|
||||
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
licenseName: 'Datenlizenz DE \u2013 Namensnennung 2.0',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
|
||||
documentType: 'checklist',
|
||||
language: 'de',
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_STATS: DSFACorpusStats = {
|
||||
sources: [
|
||||
{
|
||||
sourceId: '1',
|
||||
sourceCode: 'WP248',
|
||||
name: 'WP248 rev.01',
|
||||
organization: 'EDPB',
|
||||
licenseCode: 'EDPB-LICENSE',
|
||||
documentType: 'guideline',
|
||||
documentCount: 1,
|
||||
chunkCount: 50,
|
||||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||||
},
|
||||
{
|
||||
sourceId: '2',
|
||||
sourceCode: 'DSK_KP5',
|
||||
name: 'DSK Kurzpapier Nr. 5',
|
||||
organization: 'DSK',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
documentType: 'guideline',
|
||||
documentCount: 1,
|
||||
chunkCount: 35,
|
||||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||||
},
|
||||
],
|
||||
totalSources: 45,
|
||||
totalDocuments: 45,
|
||||
totalChunks: 850,
|
||||
qdrantCollection: 'bp_dsfa_corpus',
|
||||
qdrantPointsCount: 850,
|
||||
qdrantStatus: 'green',
|
||||
}
|
||||
|
||||
export async function fetchSources(): Promise<DSFASource[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
|
||||
if (!response.ok) throw new Error('Failed to fetch sources')
|
||||
return await response.json()
|
||||
} catch {
|
||||
return MOCK_SOURCES
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchStats(): Promise<DSFACorpusStats> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
|
||||
if (!response.ok) throw new Error('Failed to fetch stats')
|
||||
return await response.json()
|
||||
} catch {
|
||||
return MOCK_STATS
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeCorpus(): Promise<{ sources_registered: number }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to initialize corpus')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export async function triggerIngestion(sourceCode: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to trigger ingestion')
|
||||
}
|
||||
@@ -4,11 +4,6 @@
|
||||
* DSFA Document Manager
|
||||
*
|
||||
* Manages DSFA-related sources and documents for the RAG pipeline.
|
||||
* Features:
|
||||
* - View all registered DSFA sources with license info
|
||||
* - Upload new documents
|
||||
* - Trigger re-indexing
|
||||
* - View corpus statistics
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
@@ -19,411 +14,24 @@ import {
|
||||
Upload,
|
||||
FileText,
|
||||
Database,
|
||||
Scale,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Search,
|
||||
Filter,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
BookOpen
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import { DSFASource, DSFACorpusStats, DSFASourceStats } from '@/lib/sdk/types'
|
||||
|
||||
import {
|
||||
DSFASource,
|
||||
DSFACorpusStats,
|
||||
DSFASourceStats,
|
||||
DSFALicenseCode,
|
||||
DSFA_LICENSE_LABELS,
|
||||
DSFA_DOCUMENT_TYPE_LABELS
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface APIError {
|
||||
message: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
async function fetchSources(): Promise<DSFASource[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
|
||||
if (!response.ok) throw new Error('Failed to fetch sources')
|
||||
return await response.json()
|
||||
} catch {
|
||||
// Return mock data for demo
|
||||
return MOCK_SOURCES
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats(): Promise<DSFACorpusStats> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
|
||||
if (!response.ok) throw new Error('Failed to fetch stats')
|
||||
return await response.json()
|
||||
} catch {
|
||||
return MOCK_STATS
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeCorpus(): Promise<{ sources_registered: number }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to initialize corpus')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function triggerIngestion(sourceCode: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to trigger ingestion')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK DATA
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_SOURCES: DSFASource[] = [
|
||||
{
|
||||
id: '1',
|
||||
sourceCode: 'WP248',
|
||||
name: 'WP248 rev.01 - Leitlinien zur DSFA',
|
||||
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
|
||||
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
|
||||
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
|
||||
licenseCode: 'EDPB-LICENSE',
|
||||
licenseName: 'EDPB Document License',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
|
||||
documentType: 'guideline',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
sourceCode: 'DSK_KP5',
|
||||
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
|
||||
organization: 'Datenschutzkonferenz (DSK)',
|
||||
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
licenseName: 'Datenlizenz DE – Namensnennung 2.0',
|
||||
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
|
||||
documentType: 'guideline',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
sourceCode: 'BFDI_MUSS_PUBLIC',
|
||||
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
|
||||
organization: 'BfDI',
|
||||
sourceUrl: 'https://www.bfdi.bund.de',
|
||||
licenseCode: 'DL-DE-ZERO-2.0',
|
||||
licenseName: 'Datenlizenz DE – Zero 2.0',
|
||||
attributionRequired: false,
|
||||
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
|
||||
documentType: 'checklist',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
sourceCode: 'NI_MUSS_PRIVATE',
|
||||
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
|
||||
organization: 'LfD Niedersachsen',
|
||||
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
licenseName: 'Datenlizenz DE – Namensnennung 2.0',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
|
||||
documentType: 'checklist',
|
||||
language: 'de',
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_STATS: DSFACorpusStats = {
|
||||
sources: [
|
||||
{
|
||||
sourceId: '1',
|
||||
sourceCode: 'WP248',
|
||||
name: 'WP248 rev.01',
|
||||
organization: 'EDPB',
|
||||
licenseCode: 'EDPB-LICENSE',
|
||||
documentType: 'guideline',
|
||||
documentCount: 1,
|
||||
chunkCount: 50,
|
||||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||||
},
|
||||
{
|
||||
sourceId: '2',
|
||||
sourceCode: 'DSK_KP5',
|
||||
name: 'DSK Kurzpapier Nr. 5',
|
||||
organization: 'DSK',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
documentType: 'guideline',
|
||||
documentCount: 1,
|
||||
chunkCount: 35,
|
||||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||||
},
|
||||
],
|
||||
totalSources: 45,
|
||||
totalDocuments: 45,
|
||||
totalChunks: 850,
|
||||
qdrantCollection: 'bp_dsfa_corpus',
|
||||
qdrantPointsCount: 850,
|
||||
qdrantStatus: 'green',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
||||
const colorMap: Record<DSFALicenseCode, string> = {
|
||||
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
|
||||
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
|
||||
<Scale className="w-3 h-3" />
|
||||
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentTypeBadge({ type }: { type?: string }) {
|
||||
if (!type) return null
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
guideline: 'bg-indigo-100 text-indigo-700',
|
||||
checklist: 'bg-emerald-100 text-emerald-700',
|
||||
regulation: 'bg-red-100 text-red-700',
|
||||
template: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: string }) {
|
||||
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
|
||||
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
|
||||
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
|
||||
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
|
||||
}
|
||||
|
||||
const config = statusConfig[status] || statusConfig.yellow
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${config.color}`}>
|
||||
{config.icon}
|
||||
<span className="text-sm">{config.label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceCard({
|
||||
source,
|
||||
stats,
|
||||
onIngest,
|
||||
isIngesting
|
||||
}: {
|
||||
source: DSFASource
|
||||
stats?: DSFASourceStats
|
||||
onIngest: () => void
|
||||
isIngesting: boolean
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{source.sourceCode}
|
||||
</span>
|
||||
<DocumentTypeBadge type={source.documentType} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
{source.name}
|
||||
</h3>
|
||||
{source.organization && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{source.organization}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<LicenseBadge licenseCode={source.licenseCode} />
|
||||
{stats && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.documentCount} Dok.
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.chunkCount} Chunks
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{source.attributionRequired && (
|
||||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
|
||||
<strong>Attribution:</strong> {source.attributionText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
{source.sourceUrl && (
|
||||
<>
|
||||
<dt className="text-gray-500">Quelle:</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={source.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
Link <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{source.licenseUrl && (
|
||||
<>
|
||||
<dt className="text-gray-500">Lizenz-URL:</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={source.licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{source.licenseName} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-gray-500">Sprache:</dt>
|
||||
<dd className="uppercase">{source.language}</dd>
|
||||
{stats?.lastIndexedAt && (
|
||||
<>
|
||||
<dt className="text-gray-500">Zuletzt indexiert:</dt>
|
||||
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={onIngest}
|
||||
disabled={isIngesting}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{isIngesting ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
Neu indexieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsOverview({ stats }: { stats: DSFACorpusStats }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Database className="w-5 h-5" />
|
||||
Corpus-Statistik
|
||||
</h2>
|
||||
<StatusIndicator status={stats.qdrantStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{stats.totalSources}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{stats.totalDocuments}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{stats.totalChunks.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{stats.qdrantPointsCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Collection:</strong>{' '}
|
||||
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{stats.qdrantCollection}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE
|
||||
// ============================================================================
|
||||
fetchSources,
|
||||
fetchStats,
|
||||
initializeCorpus,
|
||||
triggerIngestion,
|
||||
MOCK_SOURCES,
|
||||
MOCK_STATS,
|
||||
} from './_components/dsfa-api'
|
||||
import { LicenseBadge } from './_components/DSFABadges'
|
||||
import { SourceCard } from './_components/SourceCard'
|
||||
import { StatsOverview } from './_components/StatsOverview'
|
||||
|
||||
export default function DSFADocumentManagerPage() {
|
||||
const [sources, setSources] = useState<DSFASource[]>([])
|
||||
@@ -461,7 +69,6 @@ export default function DSFADocumentManagerPage() {
|
||||
setIsInitializing(true)
|
||||
try {
|
||||
await initializeCorpus()
|
||||
// Reload data
|
||||
const [sourcesData, statsData] = await Promise.all([
|
||||
fetchSources(),
|
||||
fetchStats(),
|
||||
@@ -479,7 +86,6 @@ export default function DSFADocumentManagerPage() {
|
||||
setIngestingSource(sourceCode)
|
||||
try {
|
||||
await triggerIngestion(sourceCode)
|
||||
// Reload stats
|
||||
const statsData = await fetchStats()
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
@@ -501,7 +107,6 @@ export default function DSFADocumentManagerPage() {
|
||||
return matchesSearch && matchesType
|
||||
})
|
||||
|
||||
// Get stats by source code
|
||||
const getStatsForSource = (sourceCode: string): DSFASourceStats | undefined => {
|
||||
return stats?.sources.find(s => s.sourceCode === sourceCode)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
63
admin-lehrer/app/(admin)/ai/rag-pipeline/types.ts
Normal file
63
admin-lehrer/app/(admin)/ai/rag-pipeline/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// ============================================================================
|
||||
// RAG Pipeline Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TrainingJob {
|
||||
id: string
|
||||
name: string
|
||||
model_type: 'zeugnis' | 'klausur' | 'general'
|
||||
status: 'queued' | 'preparing' | 'training' | 'validating' | 'completed' | 'failed' | 'paused'
|
||||
progress: number
|
||||
current_epoch: number
|
||||
total_epochs: number
|
||||
loss: number
|
||||
val_loss: number
|
||||
learning_rate: number
|
||||
documents_processed: number
|
||||
total_documents: number
|
||||
started_at: string | null
|
||||
estimated_completion: string | null
|
||||
error_message: string | null
|
||||
metrics: TrainingMetrics
|
||||
config: TrainingConfig
|
||||
}
|
||||
|
||||
export interface TrainingMetrics {
|
||||
precision: number
|
||||
recall: number
|
||||
f1_score: number
|
||||
accuracy: number
|
||||
loss_history: number[]
|
||||
val_loss_history: number[]
|
||||
confusion_matrix?: number[][]
|
||||
}
|
||||
|
||||
export interface TrainingConfig {
|
||||
batch_size: number
|
||||
learning_rate: number
|
||||
epochs: number
|
||||
warmup_steps: number
|
||||
weight_decay: number
|
||||
gradient_accumulation: number
|
||||
mixed_precision: boolean
|
||||
bundeslaender: string[]
|
||||
}
|
||||
|
||||
export interface DatasetStats {
|
||||
total_documents: number
|
||||
total_chunks: number
|
||||
training_allowed: number
|
||||
by_bundesland: Record<string, number>
|
||||
by_doc_type: Record<string, number>
|
||||
}
|
||||
|
||||
export interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
collection: string
|
||||
document_count: number
|
||||
chunk_count: number
|
||||
last_updated: string | null
|
||||
status: 'active' | 'pending' | 'error'
|
||||
}
|
||||
147
admin-lehrer/app/(admin)/ai/rag-pipeline/useRagPipeline.ts
Normal file
147
admin-lehrer/app/(admin)/ai/rag-pipeline/useRagPipeline.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TrainingJob, TrainingConfig, DatasetStats, DataSource } from './types'
|
||||
import {
|
||||
MOCK_JOBS,
|
||||
MOCK_STATS,
|
||||
MOCK_DATA_SOURCES,
|
||||
fetchJobs,
|
||||
fetchDatasetStats,
|
||||
createTrainingJob,
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
cancelJob,
|
||||
} from './api'
|
||||
|
||||
export type TabType = 'dashboard' | 'architecture' | 'sources'
|
||||
|
||||
export interface RagPipelineState {
|
||||
activeTab: TabType
|
||||
setActiveTab: (tab: TabType) => void
|
||||
jobs: TrainingJob[]
|
||||
stats: DatasetStats
|
||||
dataSources: DataSource[]
|
||||
showNewTrainingModal: boolean
|
||||
setShowNewTrainingModal: (show: boolean) => void
|
||||
selectedJob: TrainingJob | null
|
||||
setSelectedJob: (job: TrainingJob | null) => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
setError: (error: string | null) => void
|
||||
handleStartTraining: (config: Partial<TrainingConfig>) => Promise<void>
|
||||
handlePauseJob: (jobId: string) => Promise<void>
|
||||
handleResumeJob: (jobId: string) => Promise<void>
|
||||
handleCancelJob: (jobId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useRagPipeline(): RagPipelineState {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('dashboard')
|
||||
const [jobs, setJobs] = useState<TrainingJob[]>([])
|
||||
const [stats, setStats] = useState<DatasetStats>(MOCK_STATS)
|
||||
const [dataSources] = useState<DataSource[]>(MOCK_DATA_SOURCES)
|
||||
const [showNewTrainingModal, setShowNewTrainingModal] = useState(false)
|
||||
const [selectedJob, setSelectedJob] = useState<TrainingJob | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [jobsData, statsData] = await Promise.all([
|
||||
fetchJobs(),
|
||||
fetchDatasetStats(),
|
||||
])
|
||||
setJobs(jobsData)
|
||||
setStats(statsData)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
setJobs(MOCK_JOBS)
|
||||
setStats(MOCK_STATS)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const hasActiveJob = jobs.some(j => j.status === 'training' || j.status === 'preparing')
|
||||
if (!hasActiveJob) return
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const updatedJobs = await fetchJobs()
|
||||
setJobs(updatedJobs)
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh jobs:', err)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [jobs])
|
||||
|
||||
const handleStartTraining = async (config: Partial<TrainingConfig>) => {
|
||||
try {
|
||||
await createTrainingJob(config)
|
||||
const updatedJobs = await fetchJobs()
|
||||
setJobs(updatedJobs)
|
||||
setShowNewTrainingModal(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to start training:', err)
|
||||
setError(err instanceof Error ? err.message : 'Indexierung konnte nicht gestartet werden')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePauseJob = async (jobId: string) => {
|
||||
try {
|
||||
await pauseJob(jobId)
|
||||
const updatedJobs = await fetchJobs()
|
||||
setJobs(updatedJobs)
|
||||
} catch (err) {
|
||||
console.error('Failed to pause job:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResumeJob = async (jobId: string) => {
|
||||
try {
|
||||
await resumeJob(jobId)
|
||||
const updatedJobs = await fetchJobs()
|
||||
setJobs(updatedJobs)
|
||||
} catch (err) {
|
||||
console.error('Failed to resume job:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelJob = async (jobId: string) => {
|
||||
try {
|
||||
await cancelJob(jobId)
|
||||
const updatedJobs = await fetchJobs()
|
||||
setJobs(updatedJobs)
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel job:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
jobs,
|
||||
stats,
|
||||
dataSources,
|
||||
showNewTrainingModal,
|
||||
setShowNewTrainingModal,
|
||||
selectedJob,
|
||||
setSelectedJob,
|
||||
isLoading,
|
||||
error,
|
||||
setError,
|
||||
handleStartTraining,
|
||||
handlePauseJob,
|
||||
handleResumeJob,
|
||||
handleCancelJob,
|
||||
}
|
||||
}
|
||||
252
admin-lehrer/app/(admin)/ai/rag/__tests__/rag-documents.test.ts
Normal file
252
admin-lehrer/app/(admin)/ai/rag/__tests__/rag-documents.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import ragData from '../rag-documents.json'
|
||||
|
||||
/**
|
||||
* Tests fuer rag-documents.json — Branchen-Regulierungs-Matrix
|
||||
*
|
||||
* Validiert die JSON-Struktur, Branchen-Zuordnung und Datenintegritaet
|
||||
* der 320 Dokumente fuer die RAG Landkarte.
|
||||
*/
|
||||
|
||||
const VALID_INDUSTRY_IDS = ragData.industries.map((i: any) => i.id)
|
||||
const VALID_DOC_TYPE_IDS = ragData.doc_types.map((dt: any) => dt.id)
|
||||
|
||||
describe('rag-documents.json — Struktur', () => {
|
||||
it('sollte doc_types, industries und documents enthalten', () => {
|
||||
expect(ragData).toHaveProperty('doc_types')
|
||||
expect(ragData).toHaveProperty('industries')
|
||||
expect(ragData).toHaveProperty('documents')
|
||||
expect(Array.isArray(ragData.doc_types)).toBe(true)
|
||||
expect(Array.isArray(ragData.industries)).toBe(true)
|
||||
expect(Array.isArray(ragData.documents)).toBe(true)
|
||||
})
|
||||
|
||||
it('sollte genau 10 Branchen haben (VDMA/VDA/BDI)', () => {
|
||||
expect(ragData.industries).toHaveLength(10)
|
||||
const ids = ragData.industries.map((i: any) => i.id)
|
||||
expect(ids).toContain('automotive')
|
||||
expect(ids).toContain('maschinenbau')
|
||||
expect(ids).toContain('elektrotechnik')
|
||||
expect(ids).toContain('chemie')
|
||||
expect(ids).toContain('metall')
|
||||
expect(ids).toContain('energie')
|
||||
expect(ids).toContain('transport')
|
||||
expect(ids).toContain('handel')
|
||||
expect(ids).toContain('konsumgueter')
|
||||
expect(ids).toContain('bau')
|
||||
})
|
||||
|
||||
it('sollte keine Pseudo-Branchen enthalten (IoT, KI, HR, KRITIS, etc.)', () => {
|
||||
const ids = ragData.industries.map((i: any) => i.id)
|
||||
expect(ids).not.toContain('iot')
|
||||
expect(ids).not.toContain('ai')
|
||||
expect(ids).not.toContain('hr')
|
||||
expect(ids).not.toContain('kritis')
|
||||
expect(ids).not.toContain('ecommerce')
|
||||
expect(ids).not.toContain('tech')
|
||||
expect(ids).not.toContain('media')
|
||||
expect(ids).not.toContain('public')
|
||||
})
|
||||
|
||||
it('sollte 17 Dokumenttypen haben', () => {
|
||||
expect(ragData.doc_types.length).toBe(17)
|
||||
})
|
||||
|
||||
it('sollte mindestens 300 Dokumente haben', () => {
|
||||
expect(ragData.documents.length).toBeGreaterThanOrEqual(300)
|
||||
})
|
||||
|
||||
it('sollte jede Branche name und icon haben', () => {
|
||||
ragData.industries.forEach((ind: any) => {
|
||||
expect(ind).toHaveProperty('id')
|
||||
expect(ind).toHaveProperty('name')
|
||||
expect(ind).toHaveProperty('icon')
|
||||
expect(ind.name.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('sollte jeden doc_type mit id, label, icon und sort haben', () => {
|
||||
ragData.doc_types.forEach((dt: any) => {
|
||||
expect(dt).toHaveProperty('id')
|
||||
expect(dt).toHaveProperty('label')
|
||||
expect(dt).toHaveProperty('icon')
|
||||
expect(dt).toHaveProperty('sort')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rag-documents.json — Dokument-Validierung', () => {
|
||||
it('sollte keine doppelten Codes haben', () => {
|
||||
const codes = ragData.documents.map((d: any) => d.code)
|
||||
const unique = new Set(codes)
|
||||
expect(unique.size).toBe(codes.length)
|
||||
})
|
||||
|
||||
it('sollte Pflichtfelder bei jedem Dokument haben', () => {
|
||||
ragData.documents.forEach((doc: any) => {
|
||||
expect(doc).toHaveProperty('code')
|
||||
expect(doc).toHaveProperty('name')
|
||||
expect(doc).toHaveProperty('doc_type')
|
||||
expect(doc).toHaveProperty('industries')
|
||||
expect(doc).toHaveProperty('in_rag')
|
||||
expect(doc).toHaveProperty('rag_collection')
|
||||
expect(doc.code.length).toBeGreaterThan(0)
|
||||
expect(doc.name.length).toBeGreaterThan(0)
|
||||
expect(Array.isArray(doc.industries)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('sollte nur gueltige doc_type IDs verwenden', () => {
|
||||
ragData.documents.forEach((doc: any) => {
|
||||
expect(VALID_DOC_TYPE_IDS).toContain(doc.doc_type)
|
||||
})
|
||||
})
|
||||
|
||||
it('sollte nur gueltige industry IDs verwenden (oder "all")', () => {
|
||||
ragData.documents.forEach((doc: any) => {
|
||||
doc.industries.forEach((ind: string) => {
|
||||
if (ind !== 'all') {
|
||||
expect(VALID_INDUSTRY_IDS).toContain(ind)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sollte gueltige rag_collection Namen verwenden', () => {
|
||||
const validCollections = [
|
||||
'bp_compliance_ce',
|
||||
'bp_compliance_gesetze',
|
||||
'bp_compliance_datenschutz',
|
||||
'bp_dsfa_corpus',
|
||||
'bp_legal_templates',
|
||||
'bp_compliance_recht',
|
||||
'bp_nibis_eh',
|
||||
]
|
||||
ragData.documents.forEach((doc: any) => {
|
||||
expect(validCollections).toContain(doc.rag_collection)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rag-documents.json — Branchen-Zuordnungslogik', () => {
|
||||
const findDoc = (code: string) => ragData.documents.find((d: any) => d.code === code)
|
||||
|
||||
describe('Horizontale Regulierungen (alle Branchen)', () => {
|
||||
const horizontalCodes = [
|
||||
'GDPR', 'BDSG_FULL', 'EPRIVACY', 'TDDDG', 'AIACT', 'CRA',
|
||||
'NIS2', 'GPSR', 'PLD', 'EUCSA', 'DATAACT',
|
||||
]
|
||||
|
||||
horizontalCodes.forEach((code) => {
|
||||
it(`${code} sollte fuer alle Branchen gelten`, () => {
|
||||
const doc = findDoc(code)
|
||||
if (doc) {
|
||||
expect(doc.industries).toContain('all')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sektorspezifische Regulierungen', () => {
|
||||
it('Maschinenverordnung sollte Maschinenbau, Automotive, Elektrotechnik enthalten', () => {
|
||||
const doc = findDoc('MACHINERY_REG')
|
||||
if (doc) {
|
||||
expect(doc.industries).toContain('maschinenbau')
|
||||
expect(doc.industries).toContain('automotive')
|
||||
expect(doc.industries).toContain('elektrotechnik')
|
||||
expect(doc.industries).not.toContain('all')
|
||||
}
|
||||
})
|
||||
|
||||
it('ElektroG sollte Elektrotechnik und Automotive enthalten', () => {
|
||||
const doc = findDoc('DE_ELEKTROG')
|
||||
if (doc) {
|
||||
expect(doc.industries).toContain('elektrotechnik')
|
||||
expect(doc.industries).toContain('automotive')
|
||||
}
|
||||
})
|
||||
|
||||
it('BattDG sollte Automotive und Elektrotechnik enthalten', () => {
|
||||
const doc = findDoc('DE_BATTDG')
|
||||
if (doc) {
|
||||
expect(doc.industries).toContain('automotive')
|
||||
expect(doc.industries).toContain('elektrotechnik')
|
||||
}
|
||||
})
|
||||
|
||||
it('ENISA ICS/SCADA sollte Energie, Maschinenbau, Chemie enthalten', () => {
|
||||
const doc = findDoc('ENISA_ICS_SCADA')
|
||||
if (doc) {
|
||||
expect(doc.industries).toContain('energie')
|
||||
expect(doc.industries).toContain('maschinenbau')
|
||||
expect(doc.industries).toContain('chemie')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nicht zutreffende Regulierungen (Finanz/Medizin/Plattformen)', () => {
|
||||
const emptyIndustryCodes = ['DORA', 'PSD2', 'MiCA', 'AMLR', 'EHDS', 'DSA', 'DMA', 'MDR']
|
||||
|
||||
emptyIndustryCodes.forEach((code) => {
|
||||
it(`${code} sollte keine Branchen-Zuordnung haben`, () => {
|
||||
const doc = findDoc(code)
|
||||
if (doc) {
|
||||
expect(doc.industries).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('BSI-TR-03161 (DiGA) sollte nicht zutreffend sein', () => {
|
||||
['BSI-TR-03161-1', 'BSI-TR-03161-2', 'BSI-TR-03161-3'].forEach((code) => {
|
||||
it(`${code} sollte keine Branchen-Zuordnung haben`, () => {
|
||||
const doc = findDoc(code)
|
||||
if (doc) {
|
||||
expect(doc.industries).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rag-documents.json — Applicability Notes', () => {
|
||||
it('sollte applicability_note bei Dokumenten mit description haben', () => {
|
||||
const withDescription = ragData.documents.filter((d: any) => d.description)
|
||||
const withNote = withDescription.filter((d: any) => d.applicability_note)
|
||||
// Mindestens 90% der Dokumente mit Beschreibung sollten eine Note haben
|
||||
expect(withNote.length / withDescription.length).toBeGreaterThan(0.9)
|
||||
})
|
||||
|
||||
it('horizontale Regulierungen sollten "alle Branchen" in der Note erwaehnen', () => {
|
||||
const gdpr = ragData.documents.find((d: any) => d.code === 'GDPR')
|
||||
if (gdpr?.applicability_note) {
|
||||
expect(gdpr.applicability_note.toLowerCase()).toContain('alle branchen')
|
||||
}
|
||||
})
|
||||
|
||||
it('nicht zutreffende sollten "nicht zutreffend" in der Note erwaehnen', () => {
|
||||
const dora = ragData.documents.find((d: any) => d.code === 'DORA')
|
||||
if (dora?.applicability_note) {
|
||||
expect(dora.applicability_note.toLowerCase()).toContain('nicht zutreffend')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('rag-documents.json — Dokumenttyp-Verteilung', () => {
|
||||
it('sollte Dokumente in jedem doc_type haben', () => {
|
||||
ragData.doc_types.forEach((dt: any) => {
|
||||
const count = ragData.documents.filter((d: any) => d.doc_type === dt.id).length
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('sollte EU-Verordnungen als groesste Kategorie haben (mind. 15)', () => {
|
||||
const euRegs = ragData.documents.filter((d: any) => d.doc_type === 'eu_regulation')
|
||||
expect(euRegs.length).toBeGreaterThanOrEqual(15)
|
||||
})
|
||||
|
||||
it('sollte EDPB Leitlinien als umfangreichste Kategorie haben (mind. 40)', () => {
|
||||
const edpb = ragData.documents.filter((d: any) => d.doc_type === 'edpb_guideline')
|
||||
expect(edpb.length).toBeGreaterThanOrEqual(40)
|
||||
})
|
||||
})
|
||||
195
admin-lehrer/app/(admin)/ai/rag/_components/DataTab.tsx
Normal file
195
admin-lehrer/app/(admin)/ai/rag/_components/DataTab.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface DataTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function DataTab({ hook }: DataTabProps) {
|
||||
const {
|
||||
customDocuments,
|
||||
uploadFile,
|
||||
setUploadFile,
|
||||
uploadTitle,
|
||||
setUploadTitle,
|
||||
uploadCode,
|
||||
setUploadCode,
|
||||
uploading,
|
||||
handleUpload,
|
||||
linkUrl,
|
||||
setLinkUrl,
|
||||
linkTitle,
|
||||
setLinkTitle,
|
||||
linkCode,
|
||||
setLinkCode,
|
||||
addingLink,
|
||||
handleAddLink,
|
||||
handleDeleteDocument,
|
||||
fetchCustomDocuments,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Upload Document */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Dokument hochladen (PDF)</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">PDF-Datei</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadTitle}
|
||||
onChange={(e) => setUploadTitle(e.target.value)}
|
||||
placeholder="z.B. Firmen-Datenschutzrichtlinie"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Code (eindeutig)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadCode}
|
||||
onChange={(e) => setUploadCode(e.target.value.toUpperCase())}
|
||||
placeholder="z.B. CUSTOM-DSR-01"
|
||||
className="w-full px-3 py-2 border rounded-lg font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !uploadFile || !uploadTitle || !uploadCode}
|
||||
className="mt-4 px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Hochladen & Indexieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Link */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Link hinzufuegen (Webseite/PDF)</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://example.com/document.pdf"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={linkTitle}
|
||||
onChange={(e) => setLinkTitle(e.target.value)}
|
||||
placeholder="z.B. BSI IT-Grundschutz"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Code (eindeutig)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={linkCode}
|
||||
onChange={(e) => setLinkCode(e.target.value.toUpperCase())}
|
||||
placeholder="z.B. BSI-GRUNDSCHUTZ"
|
||||
className="w-full px-3 py-2 border rounded-lg font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddLink}
|
||||
disabled={addingLink || !linkUrl || !linkTitle || !linkCode}
|
||||
className="mt-4 px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{addingLink ? 'Wird hinzugefuegt...' : 'Link hinzufuegen & Indexieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom Documents List */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Eigene Dokumente ({customDocuments.length})</h3>
|
||||
<button
|
||||
onClick={fetchCustomDocuments}
|
||||
className="text-sm text-teal-600 hover:text-teal-700"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{customDocuments.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
Noch keine eigenen Dokumente hinzugefuegt.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{customDocuments.map((doc) => (
|
||||
<div key={doc.id} className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-lg">
|
||||
{doc.url ? '🔗' : '📄'}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{doc.title}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="font-mono text-teal-600">{doc.code}</span>
|
||||
{' • '}
|
||||
{doc.filename || doc.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
doc.status === 'indexed' ? 'bg-green-100 text-green-700' :
|
||||
doc.status === 'error' ? 'bg-red-100 text-red-700' :
|
||||
doc.status === 'processing' || doc.status === 'fetching' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{doc.status === 'indexed' ? `${doc.chunk_count} Chunks` :
|
||||
doc.status === 'error' ? 'Fehler' :
|
||||
doc.status === 'processing' ? 'Verarbeitung...' :
|
||||
doc.status === 'fetching' ? 'Abruf...' :
|
||||
doc.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteDocument(doc.id)}
|
||||
className="text-red-500 hover:text-red-700 text-sm"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-6">
|
||||
<h4 className="font-semibold text-teal-800 flex items-center gap-2">
|
||||
<span>ℹ️</span>
|
||||
Hinweis zur Verwendung
|
||||
</h4>
|
||||
<p className="text-sm text-teal-700 mt-2">
|
||||
Laden Sie eigene Dokumente (z.B. interne Datenschutzrichtlinien, Vertraege) oder
|
||||
externe Links hoch. Diese werden automatisch in Chunks aufgeteilt und indexiert.
|
||||
Nach dem Hinzufuegen koennen Sie im <strong>Pipeline</strong>-Tab die vollstaendige
|
||||
Compliance-Analyse starten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
admin-lehrer/app/(admin)/ai/rag/_components/IngestionTab.tsx
Normal file
69
admin-lehrer/app/(admin)/ai/rag/_components/IngestionTab.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface IngestionTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function IngestionTab({ hook }: IngestionTabProps) {
|
||||
const { ingestionRunning, ingestionLog, triggerIngestion } = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Ingestion Control */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Legal Corpus Re-Ingestion</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Startet die Neuindexierung aller 19 Regulierungen. Die Dokumente werden von EUR-Lex,
|
||||
gesetze-im-internet.de und BSI heruntergeladen, in semantische Chunks aufgeteilt und
|
||||
mit BGE-M3 Embeddings in Qdrant indexiert.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={triggerIngestion}
|
||||
disabled={ingestionRunning}
|
||||
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{ingestionRunning ? 'Laeuft...' : 'Re-Ingestion starten'}
|
||||
</button>
|
||||
{ingestionRunning && (
|
||||
<span className="flex items-center gap-2 text-teal-600">
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Ingestion laeuft...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ingestion Log */}
|
||||
{ingestionLog.length > 0 && (
|
||||
<div className="bg-slate-900 rounded-xl p-4">
|
||||
<h4 className="text-slate-400 text-sm mb-2">Log</h4>
|
||||
<div className="font-mono text-sm text-green-400 space-y-1 max-h-64 overflow-y-auto">
|
||||
{ingestionLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-6">
|
||||
<h4 className="font-semibold text-teal-800 flex items-center gap-2">
|
||||
<span>💡</span>
|
||||
Hinweis zur Datenquelle
|
||||
</h4>
|
||||
<p className="text-sm text-teal-700 mt-2">
|
||||
Alle indexierten Dokumente sind amtliche Werke (§5 UrhG) und damit urheberrechtsfrei.
|
||||
Sie werden nur fuer RAG/Retrieval verwendet, nicht fuer Modell-Training.
|
||||
Die Daten werden lokal auf dem Mac Mini verarbeitet und nicht an externe Dienste gesendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
373
admin-lehrer/app/(admin)/ai/rag/_components/MapTab.tsx
Normal file
373
admin-lehrer/app/(admin)/ai/rag/_components/MapTab.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
REGULATIONS,
|
||||
DOC_TYPES,
|
||||
INDUSTRIES_LIST,
|
||||
INDUSTRIES,
|
||||
INDUSTRY_REGULATION_MAP,
|
||||
TYPE_COLORS,
|
||||
THEMATIC_GROUPS,
|
||||
KEY_INTERSECTIONS,
|
||||
RAG_DOCUMENTS,
|
||||
isInRag,
|
||||
} from '../rag-data'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
import {
|
||||
FutureOutlookSection,
|
||||
RagCoverageSection,
|
||||
FutureRegulationsSection,
|
||||
LegalBasisSection,
|
||||
} from './MapTabSections'
|
||||
|
||||
interface MapTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function MapTab({ hook }: MapTabProps) {
|
||||
const {
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
expandedDocTypes,
|
||||
setExpandedDocTypes,
|
||||
expandedMatrixDoc,
|
||||
setExpandedMatrixDoc,
|
||||
setActiveTab,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Industry Filter */}
|
||||
<IndustryFilter
|
||||
expandedRegulation={expandedRegulation}
|
||||
setExpandedRegulation={setExpandedRegulation}
|
||||
/>
|
||||
|
||||
{/* Thematic Groups */}
|
||||
<ThematicGroupsSection setActiveTab={setActiveTab} setExpandedRegulation={setExpandedRegulation} />
|
||||
|
||||
{/* Key Intersections */}
|
||||
<KeyIntersectionsSection />
|
||||
|
||||
{/* Regulation Matrix */}
|
||||
<RegulationMatrix
|
||||
expandedDocTypes={expandedDocTypes}
|
||||
setExpandedDocTypes={setExpandedDocTypes}
|
||||
expandedMatrixDoc={expandedMatrixDoc}
|
||||
setExpandedMatrixDoc={setExpandedMatrixDoc}
|
||||
/>
|
||||
|
||||
{/* Future Outlook Section */}
|
||||
<FutureOutlookSection />
|
||||
|
||||
{/* RAG Coverage Overview */}
|
||||
<RagCoverageSection />
|
||||
|
||||
{/* Potential Future Regulations */}
|
||||
<FutureRegulationsSection />
|
||||
|
||||
{/* Legal Basis Info */}
|
||||
<LegalBasisSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function IndustryFilter({
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
}: {
|
||||
expandedRegulation: string | null
|
||||
setExpandedRegulation: (v: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Regulierungen nach Branche</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Waehlen Sie Ihre Branche, um relevante Regulierungen zu sehen.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{INDUSTRIES.map((industry) => {
|
||||
const regs = INDUSTRY_REGULATION_MAP[industry.id] || []
|
||||
return (
|
||||
<button
|
||||
key={industry.id}
|
||||
onClick={() => setExpandedRegulation(industry.id === expandedRegulation ? null : industry.id)}
|
||||
className={`p-4 rounded-lg border text-left transition-all ${
|
||||
expandedRegulation === industry.id
|
||||
? 'border-teal-500 bg-teal-50 ring-2 ring-teal-200'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">{industry.icon}</div>
|
||||
<div className="font-medium text-slate-900 text-sm">{industry.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{regs.length} Regulierungen</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected Industry Details */}
|
||||
{expandedRegulation && INDUSTRIES.find(i => i.id === expandedRegulation) && (
|
||||
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
|
||||
{(() => {
|
||||
const industry = INDUSTRIES.find(i => i.id === expandedRegulation)!
|
||||
const regCodes = INDUSTRY_REGULATION_MAP[industry.id] || []
|
||||
const regs = REGULATIONS.filter(r => regCodes.includes(r.code))
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-3xl">{industry.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">{industry.name}</h4>
|
||||
<p className="text-sm text-slate-500">{industry.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{regs.map((reg) => {
|
||||
const regInRag = isInRag(reg.code)
|
||||
return (
|
||||
<div
|
||||
key={reg.code}
|
||||
className={`bg-white p-3 rounded-lg border ${regInRag ? 'border-green-200' : 'border-slate-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
|
||||
{reg.code}
|
||||
</span>
|
||||
{regInRag ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-green-100 text-green-600 rounded">RAG</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-red-50 text-red-400 rounded">✗</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium text-sm text-slate-900">{reg.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-1 line-clamp-2">{reg.description}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThematicGroupsSection({
|
||||
setActiveTab,
|
||||
setExpandedRegulation,
|
||||
}: {
|
||||
setActiveTab: (v: any) => void
|
||||
setExpandedRegulation: (v: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Thematische Cluster</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Regulierungen gruppiert nach Themenbereichen - zeigt Ueberschneidungen.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{THEMATIC_GROUPS.map((group) => (
|
||||
<div key={group.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<div className={`${group.color} px-4 py-2 text-white font-medium flex items-center justify-between`}>
|
||||
<span>{group.name}</span>
|
||||
<span className="text-sm opacity-80">{group.regulations.length} Regulierungen</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-slate-600 mb-3">{group.description}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.regulations.map((code) => {
|
||||
const reg = REGULATIONS.find(r => r.code === code)
|
||||
const codeInRag = isInRag(code)
|
||||
return (
|
||||
<span
|
||||
key={code}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium cursor-pointer ${
|
||||
codeInRag
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveTab('regulations')
|
||||
setExpandedRegulation(code)
|
||||
}}
|
||||
title={`${reg?.fullName || code}${codeInRag ? ' (im RAG)' : ' (nicht im RAG)'}`}
|
||||
>
|
||||
{codeInRag ? '✓ ' : '✗ '}{code}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyIntersectionsSection() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Wichtige Schnittstellen</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Bereiche, in denen sich mehrere Regulierungen ueberschneiden und zusammenwirken.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{KEY_INTERSECTIONS.map((intersection, idx) => (
|
||||
<div key={idx} className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{intersection.regulations.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||
isInRag(code)
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-50 text-red-500'
|
||||
}`}
|
||||
>
|
||||
{isInRag(code) ? '✓ ' : '✗ '}{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="font-medium text-slate-900 text-sm mb-1">{intersection.topic}</div>
|
||||
<div className="text-xs text-slate-500">{intersection.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RegulationMatrix({
|
||||
expandedDocTypes,
|
||||
setExpandedDocTypes,
|
||||
expandedMatrixDoc,
|
||||
setExpandedMatrixDoc,
|
||||
}: {
|
||||
expandedDocTypes: string[]
|
||||
setExpandedDocTypes: (fn: (prev: string[]) => string[]) => void
|
||||
expandedMatrixDoc: string | null
|
||||
setExpandedMatrixDoc: (v: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">Branchen-Regulierungs-Matrix</h3>
|
||||
<p className="text-sm text-slate-500">{RAG_DOCUMENTS.length} Dokumente in {DOC_TYPES.length} Kategorien</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 border-b sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left font-medium text-slate-500 sticky left-0 bg-slate-50 min-w-[200px]">Regulierung</th>
|
||||
{INDUSTRIES_LIST.filter((i: any) => i.id !== 'all').map((industry: any) => (
|
||||
<th key={industry.id} className="px-2 py-2 text-center font-medium text-slate-500 min-w-[60px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-lg">{industry.icon}</span>
|
||||
<span className="text-[10px] leading-tight">{industry.name.split('/')[0]}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DOC_TYPES.map((docType: any) => {
|
||||
const docsInType = RAG_DOCUMENTS.filter((d: any) => d.doc_type === docType.id)
|
||||
if (docsInType.length === 0) return null
|
||||
|
||||
const isExpanded = expandedDocTypes.includes(docType.id)
|
||||
|
||||
return (
|
||||
<React.Fragment key={docType.id}>
|
||||
<tr
|
||||
className="bg-slate-100 border-t-2 border-slate-300 cursor-pointer hover:bg-slate-200"
|
||||
onClick={() => {
|
||||
setExpandedDocTypes(prev =>
|
||||
prev.includes(docType.id)
|
||||
? prev.filter((id: string) => id !== docType.id)
|
||||
: [...prev, docType.id]
|
||||
)
|
||||
}}
|
||||
>
|
||||
<td colSpan={INDUSTRIES_LIST.length} className="px-3 py-2 font-bold text-slate-700">
|
||||
<span className="mr-2">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||
{docType.icon} {docType.label} ({docsInType.length})
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{isExpanded && docsInType.map((doc: any) => (
|
||||
<React.Fragment key={doc.code}>
|
||||
<tr
|
||||
className={`hover:bg-slate-50 border-b border-slate-100 cursor-pointer ${expandedMatrixDoc === doc.code ? 'bg-teal-50' : ''}`}
|
||||
onClick={() => setExpandedMatrixDoc(expandedMatrixDoc === doc.code ? null : doc.code)}
|
||||
>
|
||||
<td className="px-2 py-1.5 font-medium sticky left-0 bg-white">
|
||||
<span className="flex items-center gap-1">
|
||||
{isInRag(doc.code) ? (
|
||||
<span className="text-green-500 text-[10px]">●</span>
|
||||
) : (
|
||||
<span className="text-red-300 text-[10px]">○</span>
|
||||
)}
|
||||
<span className="text-teal-600 truncate max-w-[180px]" title={doc.full_name || doc.name}>
|
||||
{doc.name}
|
||||
</span>
|
||||
{(doc.applicability_note || doc.description) && (
|
||||
<span className="text-slate-400 text-[10px] ml-1">{expandedMatrixDoc === doc.code ? '▼' : 'ⓘ'}</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
{INDUSTRIES_LIST.filter((i: any) => i.id !== 'all').map((industry: any) => {
|
||||
const applies = doc.industries.includes(industry.id) || doc.industries.includes('all')
|
||||
return (
|
||||
<td key={industry.id} className="px-2 py-1.5 text-center">
|
||||
{applies ? (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-teal-100 text-teal-600 rounded-full">✓</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 text-slate-300">–</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
{expandedMatrixDoc === doc.code && (doc.applicability_note || doc.description) && (
|
||||
<tr className="bg-teal-50 border-b border-teal-200">
|
||||
<td colSpan={INDUSTRIES_LIST.length} className="px-4 py-3">
|
||||
<div className="text-xs space-y-1.5">
|
||||
{doc.full_name && (
|
||||
<p className="font-semibold text-slate-700">{doc.full_name}</p>
|
||||
)}
|
||||
{doc.applicability_note && (
|
||||
<p className="text-teal-700 bg-teal-100 px-2 py-1 rounded inline-block">
|
||||
<span className="font-medium">Branchenrelevanz:</span> {doc.applicability_note}
|
||||
</p>
|
||||
)}
|
||||
{doc.description && (
|
||||
<p className="text-slate-600">{doc.description}</p>
|
||||
)}
|
||||
{doc.effective_date && (
|
||||
<p className="text-slate-400">In Kraft: {doc.effective_date}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// FutureOutlookSection, RagCoverageSection, FutureRegulationsSection,
|
||||
// LegalBasisSection are imported from ./MapTabSections.tsx
|
||||
199
admin-lehrer/app/(admin)/ai/rag/_components/MapTabSections.tsx
Normal file
199
admin-lehrer/app/(admin)/ai/rag/_components/MapTabSections.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { REGULATIONS_IN_RAG } from '../rag-constants'
|
||||
import {
|
||||
RAG_DOCUMENTS,
|
||||
FUTURE_OUTLOOK,
|
||||
ADDITIONAL_REGULATIONS,
|
||||
LEGAL_BASIS_INFO,
|
||||
isInRag,
|
||||
} from '../rag-data'
|
||||
|
||||
export function FutureOutlookSection() {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl border border-indigo-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">🔮</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Zukunftsaussicht</h3>
|
||||
<p className="text-sm text-slate-500">Geplante Aenderungen und neue Regulierungen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{FUTURE_OUTLOOK.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center justify-between bg-slate-50 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
item.status === 'proposed' ? 'bg-yellow-100 text-yellow-700' :
|
||||
item.status === 'agreed' ? 'bg-green-100 text-green-700' :
|
||||
item.status === 'withdrawn' ? 'bg-red-100 text-red-700' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
<h4 className="font-semibold text-slate-900">{item.name}</h4>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">Erwartet: {item.expectedDate}</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-slate-600 mb-3">{item.description}</p>
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Wichtige Aenderungen:</p>
|
||||
<ul className="text-sm text-slate-600 space-y-1">
|
||||
{item.keyChanges.slice(0, 4).map((change, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-teal-500 mt-1">•</span>
|
||||
<span>{change}</span>
|
||||
</li>
|
||||
))}
|
||||
{item.keyChanges.length > 4 && (
|
||||
<li className="text-slate-400 text-xs">+ {item.keyChanges.length - 4} weitere...</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.affectedRegulations.map((code) => (
|
||||
<span key={code} className="px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={item.source}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 hover:underline"
|
||||
>
|
||||
Quelle →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RagCoverageSection() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">RAG-Abdeckung ({Object.keys(REGULATIONS_IN_RAG).length} von {RAG_DOCUMENTS.length} Regulierungen)</h3>
|
||||
<p className="text-sm text-slate-500">Stand: Maerz 2026 — Alle im RAG-System verfuegbaren Regulierungen (inkl. Verbraucherschutz Phase H)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RAG_DOCUMENTS.filter((r: any) => isInRag(r.code)).map((reg: any) => (
|
||||
<span key={reg.code} className="px-2.5 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full border border-green-200">
|
||||
✓ {reg.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<p className="text-xs font-medium text-slate-500 mb-2">Noch nicht im RAG:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RAG_DOCUMENTS.filter((r: any) => !isInRag(r.code)).map((reg: any) => (
|
||||
<span key={reg.code} className="px-2.5 py-1 text-xs font-medium bg-red-50 text-red-400 rounded-full border border-red-100">
|
||||
✗ {reg.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FutureRegulationsSection() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">🔮</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Zukuenftige Regulierungen</h3>
|
||||
<p className="text-sm text-slate-500">Noch nicht verabschiedet oder zur Erweiterung vorgesehen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{ADDITIONAL_REGULATIONS.map((reg) => (
|
||||
<div key={reg.code} className={`rounded-lg border p-4 ${
|
||||
reg.status === 'active' ? 'border-green-200 bg-green-50' : 'border-yellow-200 bg-yellow-50'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 text-xs font-bold rounded ${
|
||||
reg.type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{reg.code}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
reg.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{reg.status === 'active' ? 'In Kraft' : 'Vorgeschlagen'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
reg.priority === 'high' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{reg.priority === 'high' ? 'Hohe Prioritaet' : 'Mittel'}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-slate-900 text-sm mb-1">{reg.name}</h4>
|
||||
<p className="text-xs text-slate-600 mb-2">{reg.description}</p>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500">Ab: {reg.effectiveDate}</span>
|
||||
{reg.celex && (
|
||||
<a
|
||||
href={`https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:${reg.celex}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-600 hover:underline"
|
||||
>
|
||||
EUR-Lex →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LegalBasisSection() {
|
||||
return (
|
||||
<div className="bg-emerald-50 rounded-xl border border-emerald-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">⚖️</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">{LEGAL_BASIS_INFO.title}</h3>
|
||||
<p className="text-sm text-emerald-700">{LEGAL_BASIS_INFO.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{LEGAL_BASIS_INFO.details.map((detail, idx) => (
|
||||
<div key={idx} className="bg-white rounded-lg border border-emerald-100 p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||
detail.status === 'Erlaubt' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{detail.status}
|
||||
</span>
|
||||
<span className="font-medium text-sm text-slate-900">{detail.aspect}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">{detail.explanation}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
admin-lehrer/app/(admin)/ai/rag/_components/OverviewTab.tsx
Normal file
113
admin-lehrer/app/(admin)/ai/rag/_components/OverviewTab.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { REGULATIONS_IN_RAG } from '../rag-constants'
|
||||
import {
|
||||
REGULATIONS,
|
||||
COLLECTION_TOTALS,
|
||||
TYPE_LABELS,
|
||||
TYPE_COLORS,
|
||||
isInRag,
|
||||
getKnownChunks,
|
||||
} from '../rag-data'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface OverviewTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function OverviewTab({ hook }: OverviewTabProps) {
|
||||
const {
|
||||
dsfaLoading,
|
||||
dsfaStatus,
|
||||
dsfaSources,
|
||||
setRegulationCategory,
|
||||
setActiveTab,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* RAG Categories Overview */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">RAG-Kategorien</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<button
|
||||
onClick={() => { setRegulationCategory('regulations'); setActiveTab('regulations') }}
|
||||
className="p-4 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 transition-colors text-left"
|
||||
>
|
||||
<p className="text-xs font-medium text-blue-600 uppercase">Gesetze & Regulierungen</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{COLLECTION_TOTALS.total_legal.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{Object.keys(REGULATIONS_IN_RAG).length}/{REGULATIONS.length} im RAG</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRegulationCategory('dsfa'); setActiveTab('regulations') }}
|
||||
className="p-4 rounded-lg border border-purple-200 bg-purple-50 hover:bg-purple-100 transition-colors text-left"
|
||||
>
|
||||
<p className="text-xs font-medium text-purple-600 uppercase">DSFA Corpus</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{dsfaLoading ? '-' : (dsfaStatus?.total_chunks || 0).toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{dsfaSources.length || '~70'} Quellen (WP248, DSK, Gesetze)</p>
|
||||
</button>
|
||||
<div className="p-4 rounded-lg border border-emerald-200 bg-emerald-50 text-left">
|
||||
<p className="text-xs font-medium text-emerald-600 uppercase">NiBiS EH</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">7.996</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Chunks · Bildungs-Erwartungshorizonte</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-orange-200 bg-orange-50 text-left">
|
||||
<p className="text-xs font-medium text-orange-600 uppercase">Legal Templates</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">7.689</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Chunks · Dokumentvorlagen (VVT, TOM, DSFA)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats per Type */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{Object.entries(TYPE_LABELS).map(([type, label]) => {
|
||||
const regs = REGULATIONS.filter((r) => r.type === type)
|
||||
const inRagCount = regs.filter((r) => isInRag(r.code)).length
|
||||
const totalChunks = regs.reduce((sum, r) => sum + getKnownChunks(r.code), 0)
|
||||
return (
|
||||
<div key={type} className="bg-white rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[type]}`}>{label}</span>
|
||||
<span className="text-slate-500 text-sm">{inRagCount}/{regs.length} im RAG</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-slate-900">{totalChunks.toLocaleString()} Chunks</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Top Regulations */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">Top Regulierungen (nach Chunks)</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{[...REGULATIONS].sort((a, b) => getKnownChunks(b.code) - getKnownChunks(a.code))
|
||||
.slice(0, 10)
|
||||
.map((reg) => {
|
||||
const chunks = getKnownChunks(reg.code)
|
||||
return (
|
||||
<div key={reg.code} className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isInRag(reg.code) ? (
|
||||
<span className="text-green-500 text-sm">✓</span>
|
||||
) : (
|
||||
<span className="text-red-400 text-sm">✗</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
|
||||
{TYPE_LABELS[reg.type]}
|
||||
</span>
|
||||
<span className="font-medium text-slate-900">{reg.name}</span>
|
||||
<span className="text-slate-500 text-sm">({reg.code})</span>
|
||||
</div>
|
||||
<span className={`font-bold ${chunks > 0 ? 'text-teal-600' : 'text-slate-300'}`}>{chunks > 0 ? chunks.toLocaleString() + ' Chunks' : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
410
admin-lehrer/app/(admin)/ai/rag/_components/PipelineTab.tsx
Normal file
410
admin-lehrer/app/(admin)/ai/rag/_components/PipelineTab.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { PipelineCheckpoint } from '../types'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface PipelineTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function PipelineTab({ hook }: PipelineTabProps) {
|
||||
const {
|
||||
pipelineState,
|
||||
pipelineLoading,
|
||||
pipelineStarting,
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
elapsedTime,
|
||||
fetchPipeline,
|
||||
handleStartPipeline,
|
||||
collectionStatus,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pipeline Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Compliance Pipeline Status</h3>
|
||||
{pipelineState?.status === 'running' && elapsedTime && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-full">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-blue-700">Laufzeit: {elapsedTime}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="w-4 h-4 text-teal-600 rounded border-slate-300 focus:ring-teal-500"
|
||||
/>
|
||||
Auto-Refresh
|
||||
</label>
|
||||
{(!pipelineState || pipelineState.status !== 'running') && (
|
||||
<button
|
||||
onClick={() => handleStartPipeline(false)}
|
||||
disabled={pipelineStarting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{pipelineStarting ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
Pipeline starten
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchPipeline}
|
||||
disabled={pipelineLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{pipelineLoading ? <SpinnerIcon /> : <RefreshIcon />}
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No Data */}
|
||||
{(!pipelineState || pipelineState.status === 'no_data') && !pipelineLoading && (
|
||||
<NoDataCard pipelineStarting={pipelineStarting} handleStartPipeline={handleStartPipeline} />
|
||||
)}
|
||||
|
||||
{/* Pipeline Status */}
|
||||
{pipelineState && pipelineState.status !== 'no_data' && (
|
||||
<>
|
||||
{/* Status Card */}
|
||||
<PipelineStatusCard pipelineState={pipelineState} />
|
||||
|
||||
{/* Current Progress */}
|
||||
{pipelineState.status === 'running' && pipelineState.current_phase && (
|
||||
<CurrentProgressCard pipelineState={pipelineState} collectionStatus={collectionStatus} />
|
||||
)}
|
||||
|
||||
{/* Validation Summary */}
|
||||
{pipelineState.validation_summary && (
|
||||
<ValidationSummary summary={pipelineState.validation_summary} />
|
||||
)}
|
||||
|
||||
{/* Checkpoints */}
|
||||
<CheckpointsList checkpoints={pipelineState.checkpoints} />
|
||||
|
||||
{/* Summary */}
|
||||
{Object.keys(pipelineState.summary || {}).length > 0 && (
|
||||
<PipelineSummary summary={pipelineState.summary} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function SpinnerIcon() {
|
||||
return (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function NoDataCard({
|
||||
pipelineStarting,
|
||||
handleStartPipeline,
|
||||
}: {
|
||||
pipelineStarting: boolean
|
||||
handleStartPipeline: (skip: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-slate-900 mb-2">Keine Pipeline-Daten</h4>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Es wurde noch keine Pipeline ausgefuehrt. Starten Sie die Compliance-Pipeline um Checkpoint-Daten zu sehen.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleStartPipeline(false)}
|
||||
disabled={pipelineStarting}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{pipelineStarting ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Startet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Pipeline jetzt starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineStatusCard({ pipelineState }: { pipelineState: any }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
pipelineState.status === 'completed' ? 'bg-green-100' :
|
||||
pipelineState.status === 'running' ? 'bg-blue-100' :
|
||||
pipelineState.status === 'failed' ? 'bg-red-100' : 'bg-slate-100'
|
||||
}`}>
|
||||
{pipelineState.status === 'completed' && (
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{pipelineState.status === 'running' && (
|
||||
<svg className="w-6 h-6 text-blue-600 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{pipelineState.status === 'failed' && (
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">Pipeline {pipelineState.pipeline_id}</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
Gestartet: {pipelineState.started_at ? new Date(pipelineState.started_at).toLocaleString('de-DE') : '-'}
|
||||
{pipelineState.completed_at && ` | Beendet: ${new Date(pipelineState.completed_at).toLocaleString('de-DE')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
pipelineState.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
pipelineState.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
pipelineState.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{pipelineState.status === 'completed' ? 'Abgeschlossen' :
|
||||
pipelineState.status === 'running' ? 'Laeuft' :
|
||||
pipelineState.status === 'failed' ? 'Fehlgeschlagen' : pipelineState.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentProgressCard({ pipelineState, collectionStatus }: { pipelineState: any; collectionStatus: any }) {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-blue-900 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Aktuelle Verarbeitung
|
||||
</h4>
|
||||
<span className="text-sm text-blue-600">Phase: {pipelineState.current_phase}</span>
|
||||
</div>
|
||||
|
||||
{/* Phase Progress Indicator */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{['ingestion', 'extraction', 'controls', 'measures'].map((phase, idx) => (
|
||||
<div key={phase} className="flex-1 flex items-center">
|
||||
<div className={`flex-1 h-2 rounded-full ${
|
||||
pipelineState.current_phase === phase ? 'bg-blue-500 animate-pulse' :
|
||||
pipelineState.checkpoints?.some((c: PipelineCheckpoint) => c.phase === phase && c.status === 'completed') ? 'bg-green-500' :
|
||||
'bg-slate-200'
|
||||
}`} />
|
||||
{idx < 3 && <div className="w-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-4">
|
||||
<span>Ingestion</span>
|
||||
<span>Extraktion</span>
|
||||
<span>Controls</span>
|
||||
<span>Massnahmen</span>
|
||||
</div>
|
||||
|
||||
{/* Current checkpoint details */}
|
||||
{pipelineState.checkpoints?.filter((c: PipelineCheckpoint) => c.status === 'running').map((checkpoint: PipelineCheckpoint, idx: number) => (
|
||||
<div key={idx} className="bg-white/60 rounded-lg p-4 mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span className="font-medium text-slate-900">{checkpoint.name}</span>
|
||||
</div>
|
||||
{checkpoint.metrics && Object.keys(checkpoint.metrics).length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(checkpoint.metrics).slice(0, 3).map(([key, value]) => (
|
||||
<span key={key} className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{key.replace(/_/g, ' ')}: {typeof value === 'number' ? value.toLocaleString() : String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Live chunk count */}
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600">Chunks in Qdrant:</span>
|
||||
<span className="font-bold text-blue-700">{collectionStatus?.totalPoints?.toLocaleString() || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ValidationSummary({ summary }: { summary: { passed: number; warning: number; failed: number; total: number } }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4">
|
||||
<p className="text-sm text-slate-500">Bestanden</p>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.passed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-4">
|
||||
<p className="text-sm text-slate-500">Warnungen</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{summary.warning}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-4">
|
||||
<p className="text-sm text-slate-500">Fehlgeschlagen</p>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.failed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<p className="text-sm text-slate-500">Gesamt</p>
|
||||
<p className="text-2xl font-bold text-slate-700">{summary.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckpointsList({ checkpoints }: { checkpoints?: PipelineCheckpoint[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">Checkpoints ({checkpoints?.length || 0})</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{checkpoints?.map((checkpoint, idx) => (
|
||||
<div key={idx} className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-3 h-3 rounded-full ${
|
||||
checkpoint.phase === 'ingestion' ? 'bg-blue-500' :
|
||||
checkpoint.phase === 'extraction' ? 'bg-purple-500' :
|
||||
checkpoint.phase === 'controls' ? 'bg-green-500' : 'bg-orange-500'
|
||||
}`} />
|
||||
<span className="font-medium text-slate-900">{checkpoint.name}</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
({checkpoint.phase}) |
|
||||
{checkpoint.duration_seconds ? ` ${checkpoint.duration_seconds.toFixed(1)}s` : ' -'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
checkpoint.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
checkpoint.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
checkpoint.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{checkpoint.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
{Object.keys(checkpoint.metrics || {}).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{Object.entries(checkpoint.metrics).map(([key, value]) => (
|
||||
<span key={key} className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">
|
||||
{key.replace(/_/g, ' ')}: <strong>{typeof value === 'number' ? value.toLocaleString() : String(value)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validations */}
|
||||
{checkpoint.validations?.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{checkpoint.validations.map((v, vIdx) => (
|
||||
<div key={vIdx} className="flex items-center gap-2 text-sm">
|
||||
<span className={`w-4 h-4 flex items-center justify-center ${
|
||||
v.status === 'passed' ? 'text-green-500' :
|
||||
v.status === 'warning' ? 'text-yellow-500' : 'text-red-500'
|
||||
}`}>
|
||||
{v.status === 'passed' ? '✓' : v.status === 'warning' ? '⚠' : '✗'}
|
||||
</span>
|
||||
<span className="text-slate-700">{v.name}:</span>
|
||||
<span className="text-slate-500">{v.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{checkpoint.error && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{checkpoint.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(!checkpoints || checkpoints.length === 0) && (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
Noch keine Checkpoints vorhanden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineSummary({ summary }: { summary: Record<string, any> }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h4 className="font-semibold text-slate-900 mb-3">Zusammenfassung</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(summary).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<p className="text-sm text-slate-500">{key.replace(/_/g, ' ')}</p>
|
||||
<p className="font-bold text-slate-900">
|
||||
{typeof value === 'number' ? value.toLocaleString() : String(value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
451
admin-lehrer/app/(admin)/ai/rag/_components/RegulationsTab.tsx
Normal file
451
admin-lehrer/app/(admin)/ai/rag/_components/RegulationsTab.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
REGULATIONS,
|
||||
TYPE_COLORS,
|
||||
TYPE_LABELS,
|
||||
isInRag,
|
||||
getKnownChunks,
|
||||
} from '../rag-data'
|
||||
import {
|
||||
REGULATION_SOURCES,
|
||||
REGULATION_LICENSES,
|
||||
LICENSE_LABELS,
|
||||
} from '../rag-sources'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface RegulationsTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function RegulationsTab({ hook }: RegulationsTabProps) {
|
||||
const {
|
||||
regulationCategory,
|
||||
setRegulationCategory,
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
fetchStatus,
|
||||
dsfaSources,
|
||||
dsfaLoading,
|
||||
expandedDsfaSource,
|
||||
setExpandedDsfaSource,
|
||||
fetchDsfaStatus,
|
||||
setActiveTab,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setRegulationCategory('regulations')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
regulationCategory === 'regulations'
|
||||
? 'bg-blue-100 text-blue-700 ring-2 ring-blue-300'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Gesetze & Regulierungen ({REGULATIONS.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRegulationCategory('dsfa')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
regulationCategory === 'dsfa'
|
||||
? 'bg-purple-100 text-purple-700 ring-2 ring-purple-300'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
DSFA Quellen ({dsfaSources.length || '~70'})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRegulationCategory('nibis')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
regulationCategory === 'nibis'
|
||||
? 'bg-emerald-100 text-emerald-700 ring-2 ring-emerald-300'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
NiBiS Dokumente
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRegulationCategory('templates')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
regulationCategory === 'templates'
|
||||
? 'bg-orange-100 text-orange-700 ring-2 ring-orange-300'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Templates & Vorlagen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Regulations Table */}
|
||||
{regulationCategory === 'regulations' && (
|
||||
<RegulationsTable
|
||||
expandedRegulation={expandedRegulation}
|
||||
setExpandedRegulation={setExpandedRegulation}
|
||||
fetchStatus={fetchStatus}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DSFA Sources */}
|
||||
{regulationCategory === 'dsfa' && (
|
||||
<DsfaSourcesList
|
||||
dsfaSources={dsfaSources}
|
||||
dsfaLoading={dsfaLoading}
|
||||
expandedDsfaSource={expandedDsfaSource}
|
||||
setExpandedDsfaSource={setExpandedDsfaSource}
|
||||
fetchDsfaStatus={fetchDsfaStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* NiBiS Dokumente (info only) */}
|
||||
{regulationCategory === 'nibis' && <NibisInfo />}
|
||||
|
||||
{/* Templates (info only) */}
|
||||
{regulationCategory === 'templates' && <TemplatesInfo />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function RegulationsTable({
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
fetchStatus,
|
||||
setActiveTab,
|
||||
}: {
|
||||
expandedRegulation: string | null
|
||||
setExpandedRegulation: (v: string | null) => void
|
||||
fetchStatus: () => void
|
||||
setActiveTab: (v: any) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
Alle {REGULATIONS.length} Regulierungen
|
||||
<span className="ml-2 text-sm font-normal text-slate-500">
|
||||
({REGULATIONS.filter(r => isInRag(r.code)).length} im RAG,{' '}
|
||||
{REGULATIONS.filter(r => !isInRag(r.code)).length} ausstehend)
|
||||
</span>
|
||||
</h3>
|
||||
<button onClick={fetchStatus} className="text-sm text-teal-600 hover:text-teal-700">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase w-12">RAG</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Chunks</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Erwartet</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{REGULATIONS.map((reg) => {
|
||||
const chunks = getKnownChunks(reg.code)
|
||||
const inRag = isInRag(reg.code)
|
||||
const statusColor = inRag ? 'text-green-500' : 'text-red-500'
|
||||
const statusIcon = inRag ? '✓' : '❌'
|
||||
const isExpanded = expandedRegulation === reg.code
|
||||
|
||||
return (
|
||||
<React.Fragment key={reg.code}>
|
||||
<tr
|
||||
onClick={() => setExpandedRegulation(isExpanded ? null : reg.code)}
|
||||
className="hover:bg-slate-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{isInRag(reg.code) ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-green-100 text-green-600 rounded-full text-xs font-bold" title="Im RAG vorhanden">✓</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-50 text-red-400 rounded-full text-xs font-bold" title="Nicht im RAG">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono font-medium text-teal-600">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className={`transform transition-transform ${isExpanded ? 'rotate-90' : ''}`}>▶</span>
|
||||
{reg.code}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
|
||||
{TYPE_LABELS[reg.type]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-900">{reg.name}</td>
|
||||
<td className="px-4 py-3 text-right font-bold">
|
||||
<span className={chunks > 0 && chunks < 10 && reg.expected >= 10 ? 'text-amber-600' : ''}>
|
||||
{chunks.toLocaleString()}
|
||||
{chunks > 0 && chunks < 10 && reg.expected >= 10 && (
|
||||
<span className="ml-1 inline-block w-4 h-4 text-[10px] leading-4 text-center bg-amber-100 text-amber-700 rounded-full" title="Verdaechtig niedrig — Ingestion pruefen">⚠</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">{reg.expected}</td>
|
||||
<td className={`px-4 py-3 text-center ${statusColor}`}>{statusIcon}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${reg.code}-detail`} className="bg-slate-50">
|
||||
<td colSpan={7} className="px-4 py-4">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900 mb-1">{reg.fullName}</h4>
|
||||
<p className="text-sm text-slate-600">{reg.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Relevant fuer</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{reg.relevantFor.map((item, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Kernthemen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{reg.keyTopics.map((topic, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 text-xs bg-teal-50 text-teal-700 rounded">
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100 text-xs text-slate-500">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>In Kraft seit: {reg.effectiveDate}</span>
|
||||
{REGULATION_LICENSES[reg.code] && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-medium">
|
||||
{LICENSE_LABELS[REGULATION_LICENSES[reg.code].license] || REGULATION_LICENSES[reg.code].license}
|
||||
</span>
|
||||
<span className="text-slate-400">{REGULATION_LICENSES[reg.code].licenseNote}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{REGULATION_SOURCES[reg.code] && (
|
||||
<a
|
||||
href={REGULATION_SOURCES[reg.code]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Originalquelle →
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveTab('chunks')
|
||||
}}
|
||||
className="text-teal-600 hover:text-teal-700 font-medium"
|
||||
>
|
||||
In Chunks suchen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DsfaSourcesList({
|
||||
dsfaSources,
|
||||
dsfaLoading,
|
||||
expandedDsfaSource,
|
||||
setExpandedDsfaSource,
|
||||
fetchDsfaStatus,
|
||||
}: {
|
||||
dsfaSources: any[]
|
||||
dsfaLoading: boolean
|
||||
expandedDsfaSource: string | null
|
||||
setExpandedDsfaSource: (v: string | null) => void
|
||||
fetchDsfaStatus: () => void
|
||||
}) {
|
||||
const typeColors: Record<string, string> = {
|
||||
regulation: 'bg-blue-100 text-blue-700',
|
||||
legislation: 'bg-indigo-100 text-indigo-700',
|
||||
guideline: 'bg-teal-100 text-teal-700',
|
||||
checklist: 'bg-yellow-100 text-yellow-700',
|
||||
standard: 'bg-green-100 text-green-700',
|
||||
methodology: 'bg-purple-100 text-purple-700',
|
||||
specification: 'bg-orange-100 text-orange-700',
|
||||
catalog: 'bg-pink-100 text-pink-700',
|
||||
guidance: 'bg-cyan-100 text-cyan-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">DSFA Quellen ({dsfaSources.length || '~70'})</h3>
|
||||
<p className="text-xs text-slate-500">WP248, DSK Kurzpapiere, Muss-Listen, nationale Datenschutzgesetze</p>
|
||||
</div>
|
||||
<button onClick={fetchDsfaStatus} className="text-sm text-teal-600 hover:text-teal-700">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{dsfaLoading ? (
|
||||
<div className="p-8 text-center text-slate-500">Lade DSFA-Quellen...</div>
|
||||
) : dsfaSources.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<p className="mb-2">Keine DSFA-Quellen vom Backend geladen.</p>
|
||||
<p className="text-xs">Endpunkt: <code className="bg-slate-100 px-1 rounded">/api/dsfa-corpus?action=sources</code></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{dsfaSources.map((source) => {
|
||||
const isExpanded = expandedDsfaSource === source.source_code
|
||||
return (
|
||||
<React.Fragment key={source.source_code}>
|
||||
<div
|
||||
onClick={() => setExpandedDsfaSource(isExpanded ? null : source.source_code)}
|
||||
className="px-4 py-3 hover:bg-slate-50 cursor-pointer transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`transform transition-transform text-xs ${isExpanded ? 'rotate-90' : ''}`}>▶</span>
|
||||
<span className="font-mono text-sm text-purple-600 font-medium">{source.source_code}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${typeColors[source.document_type] || 'bg-slate-100 text-slate-600'}`}>
|
||||
{source.document_type}
|
||||
</span>
|
||||
<span className="text-sm text-slate-900">{source.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-slate-100 text-slate-500 rounded uppercase">
|
||||
{source.language}
|
||||
</span>
|
||||
{source.chunk_count != null && (
|
||||
<span className="text-sm font-bold text-purple-600">{source.chunk_count} Chunks</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 bg-slate-50">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900 mb-1">{source.full_name || source.name}</h4>
|
||||
{source.organization && (
|
||||
<p className="text-sm text-slate-600">Organisation: {source.organization}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-2 border-t border-slate-100 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-medium">
|
||||
{LICENSE_LABELS[source.license_code] || source.license_code}
|
||||
</span>
|
||||
<span className="text-slate-400">{source.attribution_text}</span>
|
||||
</span>
|
||||
</div>
|
||||
{source.source_url && (
|
||||
<div className="text-xs">
|
||||
<a
|
||||
href={source.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-600 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Quelle: {source.source_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NibisInfo() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-100 flex items-center justify-center text-xl">📚</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">NiBiS Erwartungshorizonte</h3>
|
||||
<p className="text-sm text-slate-500">Collection: <code className="bg-slate-100 px-1 rounded">bp_nibis_eh</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||
<p className="text-sm text-emerald-600 font-medium">Chunks</p>
|
||||
<p className="text-2xl font-bold text-slate-900">7.996</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||
<p className="text-sm text-emerald-600 font-medium">Vector Size</p>
|
||||
<p className="text-2xl font-bold text-slate-900">1024</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||
<p className="text-sm text-emerald-600 font-medium">Typ</p>
|
||||
<p className="text-2xl font-bold text-slate-900">BGE-M3</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Bildungsinhalte aus dem Niedersaechsischen Bildungsserver (NiBiS). Enthaelt Erwartungshorizonte fuer
|
||||
verschiedene Faecher und Schulformen. Wird ueber die Klausur-Korrektur fuer EH-Matching genutzt.
|
||||
Diese Daten sind nicht direkt compliance-relevant.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplatesInfo() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center text-xl">📋</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Legal Templates & Vorlagen</h3>
|
||||
<p className="text-sm text-slate-500">Collection: <code className="bg-slate-100 px-1 rounded">bp_legal_templates</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
|
||||
<p className="text-sm text-orange-600 font-medium">Chunks</p>
|
||||
<p className="text-2xl font-bold text-slate-900">7.689</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
|
||||
<p className="text-sm text-orange-600 font-medium">Vector Size</p>
|
||||
<p className="text-2xl font-bold text-slate-900">1024</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
|
||||
<p className="text-sm text-orange-600 font-medium">Typ</p>
|
||||
<p className="text-2xl font-bold text-slate-900">BGE-M3</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Vorlagen fuer VVT (Verzeichnis von Verarbeitungstaetigkeiten), TOM (Technisch-Organisatorische Massnahmen),
|
||||
DSFA-Berichte und weitere Compliance-Dokumente. Werden vom AI Compliance SDK fuer die Dokumentgenerierung genutzt.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
admin-lehrer/app/(admin)/ai/rag/_components/SearchTab.tsx
Normal file
97
admin-lehrer/app/(admin)/ai/rag/_components/SearchTab.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface SearchTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function SearchTab({ hook }: SearchTabProps) {
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
searching,
|
||||
selectedRegulations,
|
||||
setSelectedRegulations,
|
||||
handleSearch,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Box */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Semantische Suche</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Suchanfrage</label>
|
||||
<textarea
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="z.B. 'Welche Anforderungen gibt es fuer KI-Systeme mit hohem Risiko?'"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Filter (optional)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['GDPR', 'AIACT', 'CRA', 'NIS2', 'BSI-TR-03161-1'].map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => {
|
||||
setSelectedRegulations((prev: string[]) =>
|
||||
prev.includes(code) ? prev.filter((c: string) => c !== code) : [...prev, code]
|
||||
)
|
||||
}}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
selectedRegulations.includes(code)
|
||||
? 'bg-teal-100 border-teal-300 text-teal-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !searchQuery.trim()}
|
||||
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{searching ? 'Suche...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">{searchResults.length} Ergebnisse</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{searchResults.map((result, i) => (
|
||||
<div key={i} className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-0.5 text-xs rounded bg-teal-100 text-teal-700">
|
||||
{result.regulation_code}
|
||||
</span>
|
||||
{result.article && (
|
||||
<span className="text-sm text-slate-500">Art. {result.article}</span>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-slate-400">
|
||||
Score: {(result.score * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-700 text-sm">{result.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
441
admin-lehrer/app/(admin)/ai/rag/_hooks/useRAGPage.ts
Normal file
441
admin-lehrer/app/(admin)/ai/rag/_hooks/useRAGPage.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { API_PROXY, DSFA_API_PROXY } from '../rag-data'
|
||||
import type {
|
||||
TabId,
|
||||
RegulationCategory,
|
||||
CollectionStatus,
|
||||
SearchResult,
|
||||
DsfaSource,
|
||||
DsfaCorpusStatus,
|
||||
CustomDocument,
|
||||
PipelineState,
|
||||
PipelineCheckpoint,
|
||||
} from '../types'
|
||||
|
||||
export function useRAGPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [collectionStatus, setCollectionStatus] = useState<CollectionStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [selectedRegulations, setSelectedRegulations] = useState<string[]>([])
|
||||
const [ingestionRunning, setIngestionRunning] = useState(false)
|
||||
const [ingestionLog, setIngestionLog] = useState<string[]>([])
|
||||
const [pipelineState, setPipelineState] = useState<PipelineState | null>(null)
|
||||
const [pipelineLoading, setPipelineLoading] = useState(false)
|
||||
const [pipelineStarting, setPipelineStarting] = useState(false)
|
||||
const [expandedRegulation, setExpandedRegulation] = useState<string | null>(null)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [elapsedTime, setElapsedTime] = useState<string>('')
|
||||
const [expandedDocTypes, setExpandedDocTypes] = useState<string[]>(['eu_regulation', 'eu_directive'])
|
||||
const [expandedMatrixDoc, setExpandedMatrixDoc] = useState<string | null>(null)
|
||||
|
||||
// DSFA corpus state
|
||||
const [dsfaSources, setDsfaSources] = useState<DsfaSource[]>([])
|
||||
const [dsfaStatus, setDsfaStatus] = useState<DsfaCorpusStatus | null>(null)
|
||||
const [dsfaLoading, setDsfaLoading] = useState(false)
|
||||
const [regulationCategory, setRegulationCategory] = useState<RegulationCategory>('regulations')
|
||||
const [expandedDsfaSource, setExpandedDsfaSource] = useState<string | null>(null)
|
||||
|
||||
// Data tab state
|
||||
const [customDocuments, setCustomDocuments] = useState<CustomDocument[]>([])
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||
const [uploadTitle, setUploadTitle] = useState('')
|
||||
const [uploadCode, setUploadCode] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [linkTitle, setLinkTitle] = useState('')
|
||||
const [linkCode, setLinkCode] = useState('')
|
||||
const [addingLink, setAddingLink] = useState(false)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=status`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCollectionStatus(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchPipeline = useCallback(async () => {
|
||||
setPipelineLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=pipeline-checkpoints`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPipelineState(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pipeline:', error)
|
||||
} finally {
|
||||
setPipelineLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchDsfaStatus = useCallback(async () => {
|
||||
setDsfaLoading(true)
|
||||
try {
|
||||
const [statusRes, sourcesRes] = await Promise.all([
|
||||
fetch(`${DSFA_API_PROXY}?action=status`),
|
||||
fetch(`${DSFA_API_PROXY}?action=sources`),
|
||||
])
|
||||
if (statusRes.ok) {
|
||||
const data = await statusRes.json()
|
||||
setDsfaStatus(data)
|
||||
}
|
||||
if (sourcesRes.ok) {
|
||||
const data = await sourcesRes.json()
|
||||
setDsfaSources(data.sources || data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch DSFA status:', error)
|
||||
} finally {
|
||||
setDsfaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchCustomDocuments = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=custom-documents`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCustomDocuments(data.documents || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch custom documents:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadFile || !uploadTitle || !uploadCode) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile)
|
||||
formData.append('title', uploadTitle)
|
||||
formData.append('code', uploadCode)
|
||||
formData.append('document_type', 'custom')
|
||||
|
||||
const res = await fetch(`${API_PROXY}?action=upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setUploadFile(null)
|
||||
setUploadTitle('')
|
||||
setUploadCode('')
|
||||
fetchCustomDocuments()
|
||||
fetchStatus()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLink = async () => {
|
||||
if (!linkUrl || !linkTitle || !linkCode) return
|
||||
|
||||
setAddingLink(true)
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=add-link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: linkUrl,
|
||||
title: linkTitle,
|
||||
code: linkCode,
|
||||
document_type: 'custom',
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setLinkUrl('')
|
||||
setLinkTitle('')
|
||||
setLinkCode('')
|
||||
fetchCustomDocuments()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Add link failed:', error)
|
||||
} finally {
|
||||
setAddingLink(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDocument = async (docId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=delete-document&docId=${docId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
fetchCustomDocuments()
|
||||
fetchStatus()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartPipeline = async (skipIngestion: boolean = false) => {
|
||||
setPipelineStarting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=start-pipeline`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
force_reindex: false,
|
||||
skip_ingestion: skipIngestion,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
fetchPipeline()
|
||||
setPipelineStarting(false)
|
||||
}, 2000)
|
||||
} else {
|
||||
setPipelineStarting(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start pipeline:', error)
|
||||
setPipelineStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setSearching(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'search',
|
||||
query: searchQuery,
|
||||
top_k: '5',
|
||||
})
|
||||
if (selectedRegulations.length > 0) {
|
||||
params.append('regulations', selectedRegulations.join(','))
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_PROXY}?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSearchResults(data.results || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerIngestion = async () => {
|
||||
setIngestionRunning(true)
|
||||
setIngestionLog(['Starte Re-Ingestion aller 19 Regulierungen...'])
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force: true }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setIngestionLog((prev) => [...prev, 'Ingestion gestartet. Job-ID: ' + (data.job_id || 'N/A')])
|
||||
const checkStatus = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch(`${API_PROXY}?action=ingestion-status`)
|
||||
if (statusRes.ok) {
|
||||
const statusData = await statusRes.json()
|
||||
if (statusData.completed) {
|
||||
clearInterval(checkStatus)
|
||||
setIngestionRunning(false)
|
||||
setIngestionLog((prev) => [...prev, 'Ingestion abgeschlossen!'])
|
||||
fetchStatus()
|
||||
} else if (statusData.current_regulation) {
|
||||
setIngestionLog((prev) => [
|
||||
...prev,
|
||||
`Verarbeite: ${statusData.current_regulation} (${statusData.processed}/${statusData.total})`,
|
||||
])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors
|
||||
}
|
||||
}, 5000)
|
||||
} else {
|
||||
setIngestionLog((prev) => [...prev, 'Fehler: ' + res.statusText])
|
||||
setIngestionRunning(false)
|
||||
}
|
||||
} catch (error) {
|
||||
setIngestionLog((prev) => [...prev, 'Fehler: ' + String(error)])
|
||||
setIngestionRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRegulationChunks = (code: string): number => {
|
||||
return collectionStatus?.regulations?.[code] || 0
|
||||
}
|
||||
|
||||
const getTotalChunks = (): number => {
|
||||
return collectionStatus?.totalPoints || 0
|
||||
}
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
fetchDsfaStatus()
|
||||
}, [fetchStatus, fetchDsfaStatus])
|
||||
|
||||
// Fetch pipeline when tab changes
|
||||
useEffect(() => {
|
||||
if (activeTab === 'pipeline') {
|
||||
fetchPipeline()
|
||||
}
|
||||
}, [activeTab, fetchPipeline])
|
||||
|
||||
// Fetch custom documents when data tab is active
|
||||
useEffect(() => {
|
||||
if (activeTab === 'data') {
|
||||
fetchCustomDocuments()
|
||||
}
|
||||
}, [activeTab, fetchCustomDocuments])
|
||||
|
||||
// Auto-refresh pipeline status when running
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'pipeline' || !autoRefresh) return
|
||||
|
||||
const isRunning = pipelineState?.status === 'running'
|
||||
|
||||
if (isRunning) {
|
||||
const interval = setInterval(() => {
|
||||
fetchPipeline()
|
||||
fetchStatus()
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [activeTab, autoRefresh, pipelineState?.status, fetchPipeline, fetchStatus])
|
||||
|
||||
// Update elapsed time
|
||||
useEffect(() => {
|
||||
if (!pipelineState?.started_at || pipelineState?.status !== 'running') {
|
||||
setElapsedTime('')
|
||||
return
|
||||
}
|
||||
|
||||
const updateElapsed = () => {
|
||||
const start = new Date(pipelineState.started_at!).getTime()
|
||||
const now = Date.now()
|
||||
const diff = Math.floor((now - start) / 1000)
|
||||
|
||||
const hours = Math.floor(diff / 3600)
|
||||
const minutes = Math.floor((diff % 3600) / 60)
|
||||
const seconds = diff % 60
|
||||
|
||||
if (hours > 0) {
|
||||
setElapsedTime(`${hours}h ${minutes}m ${seconds}s`)
|
||||
} else if (minutes > 0) {
|
||||
setElapsedTime(`${minutes}m ${seconds}s`)
|
||||
} else {
|
||||
setElapsedTime(`${seconds}s`)
|
||||
}
|
||||
}
|
||||
|
||||
updateElapsed()
|
||||
const interval = setInterval(updateElapsed, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [pipelineState?.started_at, pipelineState?.status])
|
||||
|
||||
return {
|
||||
// Tab state
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
|
||||
// Collection status
|
||||
collectionStatus,
|
||||
loading,
|
||||
fetchStatus,
|
||||
|
||||
// Search
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
searching,
|
||||
selectedRegulations,
|
||||
setSelectedRegulations,
|
||||
handleSearch,
|
||||
|
||||
// Ingestion
|
||||
ingestionRunning,
|
||||
ingestionLog,
|
||||
triggerIngestion,
|
||||
|
||||
// Pipeline
|
||||
pipelineState,
|
||||
pipelineLoading,
|
||||
pipelineStarting,
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
elapsedTime,
|
||||
fetchPipeline,
|
||||
handleStartPipeline,
|
||||
|
||||
// Regulation expansion
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
expandedDocTypes,
|
||||
setExpandedDocTypes,
|
||||
expandedMatrixDoc,
|
||||
setExpandedMatrixDoc,
|
||||
|
||||
// DSFA
|
||||
dsfaSources,
|
||||
dsfaStatus,
|
||||
dsfaLoading,
|
||||
regulationCategory,
|
||||
setRegulationCategory,
|
||||
expandedDsfaSource,
|
||||
setExpandedDsfaSource,
|
||||
fetchDsfaStatus,
|
||||
|
||||
// Data tab
|
||||
customDocuments,
|
||||
uploadFile,
|
||||
setUploadFile,
|
||||
uploadTitle,
|
||||
setUploadTitle,
|
||||
uploadCode,
|
||||
setUploadCode,
|
||||
uploading,
|
||||
handleUpload,
|
||||
linkUrl,
|
||||
setLinkUrl,
|
||||
linkTitle,
|
||||
setLinkTitle,
|
||||
linkCode,
|
||||
setLinkCode,
|
||||
addingLink,
|
||||
handleAddLink,
|
||||
handleDeleteDocument,
|
||||
fetchCustomDocuments,
|
||||
|
||||
// Helpers
|
||||
getRegulationChunks,
|
||||
getTotalChunks,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseRAGPageReturn = ReturnType<typeof useRAGPage>
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Constants and types for ChunkBrowserQA component.
|
||||
*/
|
||||
|
||||
export type RegGroupKey =
|
||||
| 'eu_regulation'
|
||||
| 'eu_directive'
|
||||
| 'de_law'
|
||||
| 'at_law'
|
||||
| 'ch_law'
|
||||
| 'national_law'
|
||||
| 'bsi_standard'
|
||||
| 'eu_guideline'
|
||||
| 'international_standard'
|
||||
| 'other'
|
||||
|
||||
export const GROUP_LABELS: Record<RegGroupKey, string> = {
|
||||
eu_regulation: 'EU Verordnungen',
|
||||
eu_directive: 'EU Richtlinien',
|
||||
de_law: 'DE Gesetze',
|
||||
at_law: 'AT Gesetze',
|
||||
ch_law: 'CH Gesetze',
|
||||
national_law: 'Nationale Gesetze (EU)',
|
||||
bsi_standard: 'BSI Standards',
|
||||
eu_guideline: 'EDPB / Guidelines',
|
||||
international_standard: 'Internationale Standards',
|
||||
other: 'Sonstige',
|
||||
}
|
||||
|
||||
export const GROUP_ORDER: RegGroupKey[] = [
|
||||
'eu_regulation', 'eu_directive', 'de_law', 'at_law', 'ch_law',
|
||||
'national_law', 'bsi_standard', 'eu_guideline', 'international_standard', 'other',
|
||||
]
|
||||
|
||||
export const COLLECTIONS = [
|
||||
'bp_compliance_gesetze',
|
||||
'bp_compliance_ce',
|
||||
'bp_compliance_datenschutz',
|
||||
'bp_dsfa_corpus',
|
||||
'bp_compliance_recht',
|
||||
'bp_legal_templates',
|
||||
'bp_nibis_eh',
|
||||
]
|
||||
|
||||
export const STRUCTURAL_KEYS = new Set([
|
||||
'article', 'artikel', 'paragraph', 'section_title', 'section', 'chapter',
|
||||
'abschnitt', 'kapitel', 'pages', 'page',
|
||||
])
|
||||
|
||||
export const HIDDEN_KEYS = new Set([
|
||||
'text', 'content', 'chunk_text', 'id', 'embedding',
|
||||
])
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { STRUCTURAL_KEYS, HIDDEN_KEYS } from './ChunkBrowserConstants'
|
||||
import { getChunkText, getStructuralInfo } from './ChunkBrowserHelpers'
|
||||
import { RAG_PDF_MAPPING } from './rag-pdf-mapping'
|
||||
import { REGULATIONS_IN_RAG } from '../rag-constants'
|
||||
|
||||
interface ChunkBrowserContentProps {
|
||||
selectedRegulation: string | null
|
||||
docLoading: boolean
|
||||
docChunks: Record<string, unknown>[]
|
||||
docChunkIndex: number
|
||||
docTotalChunks: number
|
||||
splitViewActive: boolean
|
||||
chunksPerPage: number
|
||||
pdfExists: boolean | null
|
||||
}
|
||||
|
||||
export function ChunkBrowserContent({
|
||||
selectedRegulation,
|
||||
docLoading,
|
||||
docChunks,
|
||||
docChunkIndex,
|
||||
docTotalChunks,
|
||||
splitViewActive,
|
||||
chunksPerPage,
|
||||
pdfExists,
|
||||
}: ChunkBrowserContentProps) {
|
||||
const currentChunk = docChunks[docChunkIndex] || null
|
||||
const prevChunk = docChunkIndex > 0 ? docChunks[docChunkIndex - 1] : null
|
||||
const nextChunk = docChunkIndex < docChunks.length - 1 ? docChunks[docChunkIndex + 1] : null
|
||||
|
||||
const structInfo = getStructuralInfo(currentChunk)
|
||||
|
||||
// PDF page estimation
|
||||
const estimatePdfPage = (chunk: Record<string, unknown> | null, chunkIdx: number): number => {
|
||||
if (chunk) {
|
||||
const pages = chunk.pages as number[] | undefined
|
||||
if (Array.isArray(pages) && pages.length > 0) return pages[0]
|
||||
const page = chunk.page as number | undefined
|
||||
if (typeof page === 'number' && page > 0) return page
|
||||
}
|
||||
const mapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
|
||||
const cpp = mapping?.chunksPerPage || chunksPerPage
|
||||
return Math.floor(chunkIdx / cpp) + 1
|
||||
}
|
||||
|
||||
const pdfPage = estimatePdfPage(currentChunk, docChunkIndex)
|
||||
const pdfMapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
|
||||
const pdfUrl = pdfMapping ? `/rag-originals/${pdfMapping.filename}#page=${pdfPage}` : null
|
||||
|
||||
// Overlap extraction
|
||||
const getOverlapPrev = (): string => {
|
||||
if (!prevChunk) return ''
|
||||
const text = getChunkText(prevChunk)
|
||||
return text.length > 150 ? '...' + text.slice(-150) : text
|
||||
}
|
||||
|
||||
const getOverlapNext = (): string => {
|
||||
if (!nextChunk) return ''
|
||||
const text = getChunkText(nextChunk)
|
||||
return text.length > 150 ? text.slice(0, 150) + '...' : text
|
||||
}
|
||||
|
||||
if (!selectedRegulation) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
|
||||
<div className="text-center text-slate-400 space-y-2">
|
||||
<div className="text-4xl">🔍</div>
|
||||
<p className="text-sm">Dokument in der Sidebar auswaehlen, um QA zu starten.</p>
|
||||
<p className="text-xs text-slate-300">Pfeiltasten: Chunk vor/zurueck</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (docLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
|
||||
<div className="text-center text-slate-500 space-y-2">
|
||||
<div className="animate-spin text-3xl">⚙</div>
|
||||
<p className="text-sm">Chunks werden geladen...</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{selectedRegulation}: {REGULATIONS_IN_RAG[selectedRegulation]?.chunks.toLocaleString() || '?'} Chunks erwartet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex-1 grid gap-3 min-h-0 ${splitViewActive ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Chunk-Text Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Chunk-Text</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{structInfo.article && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs font-medium rounded border border-blue-200">
|
||||
{structInfo.article}
|
||||
</span>
|
||||
)}
|
||||
{structInfo.section && (
|
||||
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 text-xs rounded border border-purple-200">
|
||||
{structInfo.section}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 tabular-nums">
|
||||
#{docChunkIndex} / {docTotalChunks - 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4 space-y-3">
|
||||
{/* Overlap from previous chunk */}
|
||||
{prevChunk && (
|
||||
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
||||
<div className="font-medium text-amber-600 mb-1">↑ Ende vorheriger Chunk #{docChunkIndex - 1}</div>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapPrev()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current chunk text */}
|
||||
{currentChunk ? (
|
||||
<div className="text-sm text-slate-800 whitespace-pre-wrap break-words leading-relaxed border-l-2 border-teal-400 pl-3">
|
||||
{getChunkText(currentChunk)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-slate-400 italic">Kein Chunk-Text vorhanden.</div>
|
||||
)}
|
||||
|
||||
{/* Overlap from next chunk */}
|
||||
{nextChunk && (
|
||||
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
||||
<div className="font-medium text-amber-600 mb-1">↓ Anfang naechster Chunk #{docChunkIndex + 1}</div>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapNext()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{currentChunk && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-100">
|
||||
<div className="text-xs font-medium text-slate-500 mb-2">Metadaten</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
{Object.entries(currentChunk)
|
||||
.filter(([k]) => !HIDDEN_KEYS.has(k))
|
||||
.sort(([a], [b]) => {
|
||||
const aStruct = STRUCTURAL_KEYS.has(a) ? 0 : 1
|
||||
const bStruct = STRUCTURAL_KEYS.has(b) ? 0 : 1
|
||||
return aStruct - bStruct || a.localeCompare(b)
|
||||
})
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className={`flex gap-1 ${STRUCTURAL_KEYS.has(k) ? 'col-span-2 font-medium' : ''}`}>
|
||||
<span className="font-medium text-slate-500 flex-shrink-0">{k}:</span>
|
||||
<span className="text-slate-700 break-all">
|
||||
{Array.isArray(v) ? v.join(', ') : String(v)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 pt-2 border-t border-slate-50">
|
||||
<div className="text-xs text-slate-400">
|
||||
Chunk-Laenge: {getChunkText(currentChunk).length} Zeichen
|
||||
{getChunkText(currentChunk).length < 50 && (
|
||||
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr kurz</span>
|
||||
)}
|
||||
{getChunkText(currentChunk).length > 2000 && (
|
||||
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr lang</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF-Viewer Panel */}
|
||||
{splitViewActive && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Original-PDF</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">
|
||||
Seite ~{pdfPage}
|
||||
{pdfMapping?.totalPages ? ` / ${pdfMapping.totalPages}` : ''}
|
||||
</span>
|
||||
{pdfUrl && (
|
||||
<a
|
||||
href={pdfUrl.split('#')[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 hover:text-teal-800 underline"
|
||||
>
|
||||
Oeffnen ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{pdfUrl && pdfExists ? (
|
||||
<iframe
|
||||
key={`${selectedRegulation}-${pdfPage}`}
|
||||
src={pdfUrl}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
title="Original PDF"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-slate-400 text-sm p-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl">📄</div>
|
||||
{!pdfMapping ? (
|
||||
<>
|
||||
<p>Kein PDF-Mapping fuer {selectedRegulation}.</p>
|
||||
<p className="text-xs">rag-pdf-mapping.ts ergaenzen.</p>
|
||||
</>
|
||||
) : pdfExists === false ? (
|
||||
<>
|
||||
<p className="font-medium text-orange-600">PDF nicht vorhanden</p>
|
||||
<p className="text-xs">Datei <code className="bg-slate-100 px-1 rounded">{pdfMapping.filename}</code> fehlt in ~/rag-originals/</p>
|
||||
<p className="text-xs mt-1">Bitte manuell herunterladen und dort ablegen.</p>
|
||||
</>
|
||||
) : (
|
||||
<p>PDF wird geprueft...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Helper functions for ChunkBrowserQA component.
|
||||
*/
|
||||
|
||||
import { REGULATION_INFO } from '../rag-constants'
|
||||
|
||||
/** Get text content from a chunk */
|
||||
export function getChunkText(chunk: Record<string, unknown> | null): string {
|
||||
if (!chunk) return ''
|
||||
return String(chunk.chunk_text || chunk.text || chunk.content || '')
|
||||
}
|
||||
|
||||
/** Extract structural metadata for prominent display */
|
||||
export function getStructuralInfo(
|
||||
chunk: Record<string, unknown> | null
|
||||
): { article?: string; section?: string; pages?: string } {
|
||||
if (!chunk) return {}
|
||||
const result: { article?: string; section?: string; pages?: string } = {}
|
||||
// Article / paragraph
|
||||
const article = chunk.article || chunk.artikel || chunk.paragraph || chunk.section_title
|
||||
if (article) result.article = String(article)
|
||||
// Section
|
||||
const section = chunk.section || chunk.chapter || chunk.abschnitt || chunk.kapitel
|
||||
if (section) result.section = String(section)
|
||||
// Pages
|
||||
const pages = chunk.pages as number[] | undefined
|
||||
if (Array.isArray(pages) && pages.length > 0) {
|
||||
result.pages = pages.length === 1 ? `S. ${pages[0]}` : `S. ${pages[0]}-${pages[pages.length - 1]}`
|
||||
} else if (chunk.page) {
|
||||
result.pages = `S. ${chunk.page}`
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Regulation name lookup */
|
||||
export function getRegName(code: string): string {
|
||||
const reg = REGULATION_INFO.find(r => r.code === code)
|
||||
return reg?.name || code
|
||||
}
|
||||
@@ -1,43 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { RAG_PDF_MAPPING } from './rag-pdf-mapping'
|
||||
import { REGULATIONS_IN_RAG, REGULATION_INFO } from '../rag-constants'
|
||||
import { RegGroupKey } from './ChunkBrowserConstants'
|
||||
import { getStructuralInfo } from './ChunkBrowserHelpers'
|
||||
import { ChunkBrowserSidebar } from './ChunkBrowserSidebar'
|
||||
import { ChunkBrowserToolbar } from './ChunkBrowserToolbar'
|
||||
import { ChunkBrowserContent } from './ChunkBrowserContent'
|
||||
|
||||
interface ChunkBrowserQAProps {
|
||||
apiProxy: string
|
||||
}
|
||||
|
||||
type RegGroupKey = 'eu_regulation' | 'eu_directive' | 'de_law' | 'at_law' | 'ch_law' | 'national_law' | 'bsi_standard' | 'eu_guideline' | 'international_standard' | 'other'
|
||||
|
||||
const GROUP_LABELS: Record<RegGroupKey, string> = {
|
||||
eu_regulation: 'EU Verordnungen',
|
||||
eu_directive: 'EU Richtlinien',
|
||||
de_law: 'DE Gesetze',
|
||||
at_law: 'AT Gesetze',
|
||||
ch_law: 'CH Gesetze',
|
||||
national_law: 'Nationale Gesetze (EU)',
|
||||
bsi_standard: 'BSI Standards',
|
||||
eu_guideline: 'EDPB / Guidelines',
|
||||
international_standard: 'Internationale Standards',
|
||||
other: 'Sonstige',
|
||||
}
|
||||
|
||||
const GROUP_ORDER: RegGroupKey[] = [
|
||||
'eu_regulation', 'eu_directive', 'de_law', 'at_law', 'ch_law',
|
||||
'national_law', 'bsi_standard', 'eu_guideline', 'international_standard', 'other',
|
||||
]
|
||||
|
||||
const COLLECTIONS = [
|
||||
'bp_compliance_gesetze',
|
||||
'bp_compliance_ce',
|
||||
'bp_compliance_datenschutz',
|
||||
'bp_dsfa_corpus',
|
||||
'bp_compliance_recht',
|
||||
'bp_legal_templates',
|
||||
'bp_nibis_eh',
|
||||
]
|
||||
|
||||
export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
// Filter-Sidebar
|
||||
const [selectedRegulation, setSelectedRegulation] = useState<string | null>(null)
|
||||
@@ -58,7 +33,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
const [chunksPerPage, setChunksPerPage] = useState(6)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
|
||||
// Collection — default to bp_compliance_ce where we have PDFs downloaded
|
||||
// Collection
|
||||
const [collection, setCollection] = useState('bp_compliance_ce')
|
||||
|
||||
// PDF existence check
|
||||
@@ -72,7 +47,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
.filter(([, info]) => info.collection === collection)
|
||||
.map(([code]) => code)
|
||||
|
||||
const groupedRegulations = React.useMemo(() => {
|
||||
const groupedRegulations = useMemo(() => {
|
||||
const groups: Record<RegGroupKey, { code: string; name: string; type: string }[]> = {
|
||||
eu_regulation: [], eu_directive: [], de_law: [], at_law: [], ch_law: [],
|
||||
national_law: [], bsi_standard: [], eu_guideline: [], international_standard: [], other: [],
|
||||
@@ -81,11 +56,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
const reg = REGULATION_INFO.find(r => r.code === code)
|
||||
const type = (reg?.type || 'other') as RegGroupKey
|
||||
const groupKey = type in groups ? type : 'other'
|
||||
groups[groupKey].push({
|
||||
code,
|
||||
name: reg?.name || code,
|
||||
type: reg?.type || 'unknown',
|
||||
})
|
||||
groups[groupKey].push({ code, name: reg?.name || code, type: reg?.type || 'unknown' })
|
||||
}
|
||||
return groups
|
||||
}, [regulationsInCollection.join(',')])
|
||||
@@ -96,7 +67,6 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
.filter(([, info]) => info.collection === col && info.qdrant_id)
|
||||
if (entries.length === 0) return
|
||||
|
||||
// Build qdrant_id -> our_code mapping
|
||||
const qdrantIdToCode: Record<string, string[]> = {}
|
||||
for (const [code, info] of entries) {
|
||||
if (!qdrantIdToCode[info.qdrant_id]) qdrantIdToCode[info.qdrant_id] = []
|
||||
@@ -114,13 +84,10 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
const res = await fetch(`${apiProxy}?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Map qdrant_id counts back to our codes
|
||||
const mapped: Record<string, number> = {}
|
||||
for (const [qid, count] of Object.entries(data.counts as Record<string, number>)) {
|
||||
const codes = qdrantIdToCode[qid] || []
|
||||
for (const code of codes) {
|
||||
mapped[code] = count
|
||||
}
|
||||
for (const code of codes) { mapped[code] = count }
|
||||
}
|
||||
setRegulationCounts(prev => ({ ...prev, ...mapped }))
|
||||
}
|
||||
@@ -166,7 +133,6 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
safety++
|
||||
} while (offset && safety < 200)
|
||||
|
||||
// Sort by chunk_index
|
||||
allChunks.sort((a, b) => {
|
||||
const ai = Number(a.chunk_index ?? a.chunk_id ?? 0)
|
||||
const bi = Number(b.chunk_index ?? b.chunk_id ?? 0)
|
||||
@@ -188,30 +154,6 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
loadRegulationCounts(collection)
|
||||
}, [collection, loadRegulationCounts])
|
||||
|
||||
// Current chunk
|
||||
const currentChunk = docChunks[docChunkIndex] || null
|
||||
const prevChunk = docChunkIndex > 0 ? docChunks[docChunkIndex - 1] : null
|
||||
const nextChunk = docChunkIndex < docChunks.length - 1 ? docChunks[docChunkIndex + 1] : null
|
||||
|
||||
// PDF page estimation — use pages metadata if available
|
||||
const estimatePdfPage = (chunk: Record<string, unknown> | null, chunkIdx: number): number => {
|
||||
if (chunk) {
|
||||
// Try pages array from payload (e.g. [7] or [7,8])
|
||||
const pages = chunk.pages as number[] | undefined
|
||||
if (Array.isArray(pages) && pages.length > 0) return pages[0]
|
||||
// Try page field
|
||||
const page = chunk.page as number | undefined
|
||||
if (typeof page === 'number' && page > 0) return page
|
||||
}
|
||||
const mapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
|
||||
const cpp = mapping?.chunksPerPage || chunksPerPage
|
||||
return Math.floor(chunkIdx / cpp) + 1
|
||||
}
|
||||
|
||||
const pdfPage = estimatePdfPage(currentChunk, docChunkIndex)
|
||||
const pdfMapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
|
||||
const pdfUrl = pdfMapping ? `/rag-originals/${pdfMapping.filename}#page=${pdfPage}` : null
|
||||
|
||||
// Check PDF existence when regulation changes
|
||||
useEffect(() => {
|
||||
if (!selectedRegulation) { setPdfExists(null); return }
|
||||
@@ -223,29 +165,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
.catch(() => setPdfExists(false))
|
||||
}, [selectedRegulation])
|
||||
|
||||
// Handlers
|
||||
const handleSelectRegulation = (code: string) => {
|
||||
setSelectedRegulation(code)
|
||||
loadDocumentChunks(code)
|
||||
}
|
||||
|
||||
const handleCollectionChange = (col: string) => {
|
||||
setCollection(col)
|
||||
setSelectedRegulation(null)
|
||||
setDocChunks([])
|
||||
setDocChunkIndex(0)
|
||||
setDocTotalChunks(0)
|
||||
setRegulationCounts({})
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (docChunkIndex > 0) setDocChunkIndex(i => i - 1)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (docChunkIndex < docChunks.length - 1) setDocChunkIndex(i => i + 1)
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && fullscreen) {
|
||||
e.preventDefault()
|
||||
@@ -266,6 +186,21 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
}
|
||||
}, [selectedRegulation, docChunks.length, handleKeyDown, fullscreen])
|
||||
|
||||
// Handlers
|
||||
const handleSelectRegulation = (code: string) => {
|
||||
setSelectedRegulation(code)
|
||||
loadDocumentChunks(code)
|
||||
}
|
||||
|
||||
const handleCollectionChange = (col: string) => {
|
||||
setCollection(col)
|
||||
setSelectedRegulation(null)
|
||||
setDocChunks([])
|
||||
setDocChunkIndex(0)
|
||||
setDocTotalChunks(0)
|
||||
setRegulationCounts({})
|
||||
}
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -275,47 +210,8 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
})
|
||||
}
|
||||
|
||||
// Get text content from a chunk
|
||||
const getChunkText = (chunk: Record<string, unknown> | null): string => {
|
||||
if (!chunk) return ''
|
||||
return String(chunk.chunk_text || chunk.text || chunk.content || '')
|
||||
}
|
||||
|
||||
// Extract structural metadata for prominent display
|
||||
const getStructuralInfo = (chunk: Record<string, unknown> | null): { article?: string; section?: string; pages?: string } => {
|
||||
if (!chunk) return {}
|
||||
const result: { article?: string; section?: string; pages?: string } = {}
|
||||
// Article / paragraph
|
||||
const article = chunk.article || chunk.artikel || chunk.paragraph || chunk.section_title
|
||||
if (article) result.article = String(article)
|
||||
// Section
|
||||
const section = chunk.section || chunk.chapter || chunk.abschnitt || chunk.kapitel
|
||||
if (section) result.section = String(section)
|
||||
// Pages
|
||||
const pages = chunk.pages as number[] | undefined
|
||||
if (Array.isArray(pages) && pages.length > 0) {
|
||||
result.pages = pages.length === 1 ? `S. ${pages[0]}` : `S. ${pages[0]}-${pages[pages.length - 1]}`
|
||||
} else if (chunk.page) {
|
||||
result.pages = `S. ${chunk.page}`
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Overlap extraction
|
||||
const getOverlapPrev = (): string => {
|
||||
if (!prevChunk) return ''
|
||||
const text = getChunkText(prevChunk)
|
||||
return text.length > 150 ? '...' + text.slice(-150) : text
|
||||
}
|
||||
|
||||
const getOverlapNext = (): string => {
|
||||
if (!nextChunk) return ''
|
||||
const text = getChunkText(nextChunk)
|
||||
return text.length > 150 ? text.slice(0, 150) + '...' : text
|
||||
}
|
||||
|
||||
// Filter sidebar items
|
||||
const filteredRegulations = React.useMemo(() => {
|
||||
const filteredRegulations = useMemo(() => {
|
||||
if (!filterSearch.trim()) return groupedRegulations
|
||||
const term = filterSearch.toLowerCase()
|
||||
const filtered: typeof groupedRegulations = {
|
||||
@@ -330,21 +226,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
return filtered
|
||||
}, [groupedRegulations, filterSearch])
|
||||
|
||||
// Regulation name lookup
|
||||
const getRegName = (code: string): string => {
|
||||
const reg = REGULATION_INFO.find(r => r.code === code)
|
||||
return reg?.name || code
|
||||
}
|
||||
|
||||
// Important metadata keys to show prominently
|
||||
const STRUCTURAL_KEYS = new Set([
|
||||
'article', 'artikel', 'paragraph', 'section_title', 'section', 'chapter',
|
||||
'abschnitt', 'kapitel', 'pages', 'page',
|
||||
])
|
||||
const HIDDEN_KEYS = new Set([
|
||||
'text', 'content', 'chunk_text', 'id', 'embedding',
|
||||
])
|
||||
|
||||
const currentChunk = docChunks[docChunkIndex] || null
|
||||
const structInfo = getStructuralInfo(currentChunk)
|
||||
|
||||
return (
|
||||
@@ -352,323 +234,48 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
||||
className={`flex flex-col ${fullscreen ? 'fixed inset-0 z-50 bg-slate-100 p-4' : ''}`}
|
||||
style={fullscreen ? { height: '100vh' } : { height: 'calc(100vh - 220px)' }}
|
||||
>
|
||||
{/* Header bar — fixed height */}
|
||||
<div className="flex-shrink-0 bg-white rounded-xl border border-slate-200 p-3 mb-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Collection</label>
|
||||
<select
|
||||
value={collection}
|
||||
onChange={(e) => handleCollectionChange(e.target.value)}
|
||||
className="px-3 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
|
||||
>
|
||||
{COLLECTIONS.map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<ChunkBrowserToolbar
|
||||
collection={collection}
|
||||
onCollectionChange={handleCollectionChange}
|
||||
selectedRegulation={selectedRegulation}
|
||||
structInfo={structInfo}
|
||||
docChunkIndex={docChunkIndex}
|
||||
docTotalChunks={docTotalChunks}
|
||||
docChunksLength={docChunks.length}
|
||||
chunksPerPage={chunksPerPage}
|
||||
setChunksPerPage={setChunksPerPage}
|
||||
splitViewActive={splitViewActive}
|
||||
setSplitViewActive={setSplitViewActive}
|
||||
fullscreen={fullscreen}
|
||||
setFullscreen={setFullscreen}
|
||||
onPrev={() => { if (docChunkIndex > 0) setDocChunkIndex(i => i - 1) }}
|
||||
onNext={() => { if (docChunkIndex < docChunks.length - 1) setDocChunkIndex(i => i + 1) }}
|
||||
onJumpTo={setDocChunkIndex}
|
||||
/>
|
||||
|
||||
{selectedRegulation && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
{selectedRegulation} — {getRegName(selectedRegulation)}
|
||||
</span>
|
||||
{structInfo.article && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 text-xs font-medium rounded">
|
||||
{structInfo.article}
|
||||
</span>
|
||||
)}
|
||||
{structInfo.pages && (
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">
|
||||
{structInfo.pages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={docChunkIndex === 0}
|
||||
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
◀ Zurueck
|
||||
</button>
|
||||
<span className="text-sm font-mono text-slate-600 min-w-[80px] text-center">
|
||||
{docChunkIndex + 1} / {docTotalChunks}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={docChunkIndex >= docChunks.length - 1}
|
||||
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter ▶
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={docTotalChunks}
|
||||
value={docChunkIndex + 1}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!isNaN(v) && v >= 1 && v <= docTotalChunks) setDocChunkIndex(v - 1)
|
||||
}}
|
||||
className="w-16 px-2 py-1 border rounded text-xs text-center"
|
||||
title="Springe zu Chunk Nr."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-500">Chunks/Seite:</label>
|
||||
<select
|
||||
value={chunksPerPage}
|
||||
onChange={(e) => setChunksPerPage(Number(e.target.value))}
|
||||
className="px-2 py-1 border rounded text-xs"
|
||||
>
|
||||
{[3, 4, 5, 6, 8, 10, 12, 15, 20].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setSplitViewActive(!splitViewActive)}
|
||||
className={`px-3 py-1 text-xs rounded-lg border ${
|
||||
splitViewActive ? 'bg-teal-50 border-teal-300 text-teal-700' : 'bg-slate-50 border-slate-300 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{splitViewActive ? 'Split-View an' : 'Split-View aus'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className={`px-3 py-1 text-xs rounded-lg border ${
|
||||
fullscreen ? 'bg-indigo-50 border-indigo-300 text-indigo-700' : 'bg-slate-50 border-slate-300 text-slate-600'
|
||||
}`}
|
||||
title={fullscreen ? 'Vollbild beenden (Esc)' : 'Vollbild'}
|
||||
>
|
||||
{fullscreen ? '✕ Vollbild beenden' : '⛶ Vollbild'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content: Sidebar + Content — fills remaining height */}
|
||||
<div className="flex gap-3 flex-1 min-h-0">
|
||||
{/* Sidebar — scrollable */}
|
||||
<div className="w-56 flex-shrink-0 bg-white rounded-xl border border-slate-200 flex flex-col min-h-0">
|
||||
<div className="flex-shrink-0 p-3 border-b border-slate-100">
|
||||
<input
|
||||
type="text"
|
||||
value={filterSearch}
|
||||
onChange={(e) => setFilterSearch(e.target.value)}
|
||||
placeholder="Suche..."
|
||||
className="w-full px-2 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
|
||||
/>
|
||||
{countsLoading && (
|
||||
<div className="text-xs text-slate-400 mt-1 animate-pulse">Counts laden...</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{GROUP_ORDER.map(group => {
|
||||
const items = filteredRegulations[group]
|
||||
if (items.length === 0) return null
|
||||
const isCollapsed = collapsedGroups.has(group)
|
||||
return (
|
||||
<div key={group}>
|
||||
<button
|
||||
onClick={() => toggleGroup(group)}
|
||||
className="w-full px-3 py-1.5 text-left text-xs font-semibold text-slate-500 bg-slate-50 hover:bg-slate-100 flex items-center justify-between sticky top-0 z-10"
|
||||
>
|
||||
<span>{GROUP_LABELS[group]}</span>
|
||||
<span className="text-slate-400">{isCollapsed ? '+' : '-'}</span>
|
||||
</button>
|
||||
{!isCollapsed && items.map(reg => {
|
||||
const count = regulationCounts[reg.code] ?? 0
|
||||
const isSelected = selectedRegulation === reg.code
|
||||
return (
|
||||
<button
|
||||
key={reg.code}
|
||||
onClick={() => handleSelectRegulation(reg.code)}
|
||||
className={`w-full px-3 py-1.5 text-left text-sm flex items-center justify-between hover:bg-teal-50 transition-colors ${
|
||||
isSelected ? 'bg-teal-100 text-teal-900 font-medium' : 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate text-xs">{reg.name || reg.code}</span>
|
||||
<span className={`text-xs tabular-nums flex-shrink-0 ml-1 ${count > 0 ? 'text-slate-500' : 'text-slate-300'}`}>
|
||||
{count > 0 ? count.toLocaleString() : '—'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ChunkBrowserSidebar
|
||||
filterSearch={filterSearch}
|
||||
setFilterSearch={setFilterSearch}
|
||||
countsLoading={countsLoading}
|
||||
filteredRegulations={filteredRegulations}
|
||||
regulationCounts={regulationCounts}
|
||||
selectedRegulation={selectedRegulation}
|
||||
collapsedGroups={collapsedGroups}
|
||||
onSelectRegulation={handleSelectRegulation}
|
||||
onToggleGroup={toggleGroup}
|
||||
/>
|
||||
|
||||
{/* Content area — fills remaining width and height */}
|
||||
{!selectedRegulation ? (
|
||||
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
|
||||
<div className="text-center text-slate-400 space-y-2">
|
||||
<div className="text-4xl">🔍</div>
|
||||
<p className="text-sm">Dokument in der Sidebar auswaehlen, um QA zu starten.</p>
|
||||
<p className="text-xs text-slate-300">Pfeiltasten: Chunk vor/zurueck</p>
|
||||
</div>
|
||||
</div>
|
||||
) : docLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
|
||||
<div className="text-center text-slate-500 space-y-2">
|
||||
<div className="animate-spin text-3xl">⚙</div>
|
||||
<p className="text-sm">Chunks werden geladen...</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{selectedRegulation}: {REGULATIONS_IN_RAG[selectedRegulation]?.chunks.toLocaleString() || '?'} Chunks erwartet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex-1 grid gap-3 min-h-0 ${splitViewActive ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Chunk-Text Panel — fixed height, internal scroll */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Panel header */}
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Chunk-Text</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{structInfo.article && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs font-medium rounded border border-blue-200">
|
||||
{structInfo.article}
|
||||
</span>
|
||||
)}
|
||||
{structInfo.section && (
|
||||
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 text-xs rounded border border-purple-200">
|
||||
{structInfo.section}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 tabular-nums">
|
||||
#{docChunkIndex} / {docTotalChunks - 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4 space-y-3">
|
||||
{/* Overlap from previous chunk */}
|
||||
{prevChunk && (
|
||||
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
||||
<div className="font-medium text-amber-600 mb-1">↑ Ende vorheriger Chunk #{docChunkIndex - 1}</div>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapPrev()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current chunk text */}
|
||||
{currentChunk ? (
|
||||
<div className="text-sm text-slate-800 whitespace-pre-wrap break-words leading-relaxed border-l-2 border-teal-400 pl-3">
|
||||
{getChunkText(currentChunk)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-slate-400 italic">Kein Chunk-Text vorhanden.</div>
|
||||
)}
|
||||
|
||||
{/* Overlap from next chunk */}
|
||||
{nextChunk && (
|
||||
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
||||
<div className="font-medium text-amber-600 mb-1">↓ Anfang naechster Chunk #{docChunkIndex + 1}</div>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapNext()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{currentChunk && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-100">
|
||||
<div className="text-xs font-medium text-slate-500 mb-2">Metadaten</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
{Object.entries(currentChunk)
|
||||
.filter(([k]) => !HIDDEN_KEYS.has(k))
|
||||
.sort(([a], [b]) => {
|
||||
// Structural keys first
|
||||
const aStruct = STRUCTURAL_KEYS.has(a) ? 0 : 1
|
||||
const bStruct = STRUCTURAL_KEYS.has(b) ? 0 : 1
|
||||
return aStruct - bStruct || a.localeCompare(b)
|
||||
})
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className={`flex gap-1 ${STRUCTURAL_KEYS.has(k) ? 'col-span-2 font-medium' : ''}`}>
|
||||
<span className="font-medium text-slate-500 flex-shrink-0">{k}:</span>
|
||||
<span className="text-slate-700 break-all">
|
||||
{Array.isArray(v) ? v.join(', ') : String(v)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Chunk quality indicator */}
|
||||
<div className="mt-3 pt-2 border-t border-slate-50">
|
||||
<div className="text-xs text-slate-400">
|
||||
Chunk-Laenge: {getChunkText(currentChunk).length} Zeichen
|
||||
{getChunkText(currentChunk).length < 50 && (
|
||||
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr kurz</span>
|
||||
)}
|
||||
{getChunkText(currentChunk).length > 2000 && (
|
||||
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr lang</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF-Viewer Panel */}
|
||||
{splitViewActive && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Original-PDF</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">
|
||||
Seite ~{pdfPage}
|
||||
{pdfMapping?.totalPages ? ` / ${pdfMapping.totalPages}` : ''}
|
||||
</span>
|
||||
{pdfUrl && (
|
||||
<a
|
||||
href={pdfUrl.split('#')[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 hover:text-teal-800 underline"
|
||||
>
|
||||
Oeffnen ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{pdfUrl && pdfExists ? (
|
||||
<iframe
|
||||
key={`${selectedRegulation}-${pdfPage}`}
|
||||
src={pdfUrl}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
title="Original PDF"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-slate-400 text-sm p-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl">📄</div>
|
||||
{!pdfMapping ? (
|
||||
<>
|
||||
<p>Kein PDF-Mapping fuer {selectedRegulation}.</p>
|
||||
<p className="text-xs">rag-pdf-mapping.ts ergaenzen.</p>
|
||||
</>
|
||||
) : pdfExists === false ? (
|
||||
<>
|
||||
<p className="font-medium text-orange-600">PDF nicht vorhanden</p>
|
||||
<p className="text-xs">Datei <code className="bg-slate-100 px-1 rounded">{pdfMapping.filename}</code> fehlt in ~/rag-originals/</p>
|
||||
<p className="text-xs mt-1">Bitte manuell herunterladen und dort ablegen.</p>
|
||||
</>
|
||||
) : (
|
||||
<p>PDF wird geprueft...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ChunkBrowserContent
|
||||
selectedRegulation={selectedRegulation}
|
||||
docLoading={docLoading}
|
||||
docChunks={docChunks}
|
||||
docChunkIndex={docChunkIndex}
|
||||
docTotalChunks={docTotalChunks}
|
||||
splitViewActive={splitViewActive}
|
||||
chunksPerPage={chunksPerPage}
|
||||
pdfExists={pdfExists}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { RegGroupKey, GROUP_LABELS, GROUP_ORDER } from './ChunkBrowserConstants'
|
||||
|
||||
interface ChunkBrowserSidebarProps {
|
||||
filterSearch: string
|
||||
setFilterSearch: (v: string) => void
|
||||
countsLoading: boolean
|
||||
filteredRegulations: Record<RegGroupKey, { code: string; name: string; type: string }[]>
|
||||
regulationCounts: Record<string, number>
|
||||
selectedRegulation: string | null
|
||||
collapsedGroups: Set<string>
|
||||
onSelectRegulation: (code: string) => void
|
||||
onToggleGroup: (group: string) => void
|
||||
}
|
||||
|
||||
export function ChunkBrowserSidebar({
|
||||
filterSearch,
|
||||
setFilterSearch,
|
||||
countsLoading,
|
||||
filteredRegulations,
|
||||
regulationCounts,
|
||||
selectedRegulation,
|
||||
collapsedGroups,
|
||||
onSelectRegulation,
|
||||
onToggleGroup,
|
||||
}: ChunkBrowserSidebarProps) {
|
||||
return (
|
||||
<div className="w-56 flex-shrink-0 bg-white rounded-xl border border-slate-200 flex flex-col min-h-0">
|
||||
<div className="flex-shrink-0 p-3 border-b border-slate-100">
|
||||
<input
|
||||
type="text"
|
||||
value={filterSearch}
|
||||
onChange={(e) => setFilterSearch(e.target.value)}
|
||||
placeholder="Suche..."
|
||||
className="w-full px-2 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
|
||||
/>
|
||||
{countsLoading && (
|
||||
<div className="text-xs text-slate-400 mt-1 animate-pulse">Counts laden...</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{GROUP_ORDER.map(group => {
|
||||
const items = filteredRegulations[group]
|
||||
if (items.length === 0) return null
|
||||
const isCollapsed = collapsedGroups.has(group)
|
||||
return (
|
||||
<div key={group}>
|
||||
<button
|
||||
onClick={() => onToggleGroup(group)}
|
||||
className="w-full px-3 py-1.5 text-left text-xs font-semibold text-slate-500 bg-slate-50 hover:bg-slate-100 flex items-center justify-between sticky top-0 z-10"
|
||||
>
|
||||
<span>{GROUP_LABELS[group]}</span>
|
||||
<span className="text-slate-400">{isCollapsed ? '+' : '-'}</span>
|
||||
</button>
|
||||
{!isCollapsed && items.map(reg => {
|
||||
const count = regulationCounts[reg.code] ?? 0
|
||||
const isSelected = selectedRegulation === reg.code
|
||||
return (
|
||||
<button
|
||||
key={reg.code}
|
||||
onClick={() => onSelectRegulation(reg.code)}
|
||||
className={`w-full px-3 py-1.5 text-left text-sm flex items-center justify-between hover:bg-teal-50 transition-colors ${
|
||||
isSelected ? 'bg-teal-100 text-teal-900 font-medium' : 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate text-xs">{reg.name || reg.code}</span>
|
||||
<span className={`text-xs tabular-nums flex-shrink-0 ml-1 ${count > 0 ? 'text-slate-500' : 'text-slate-300'}`}>
|
||||
{count > 0 ? count.toLocaleString() : '\u2014'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { COLLECTIONS } from './ChunkBrowserConstants'
|
||||
import { getRegName } from './ChunkBrowserHelpers'
|
||||
|
||||
interface ChunkBrowserToolbarProps {
|
||||
collection: string
|
||||
onCollectionChange: (col: string) => void
|
||||
selectedRegulation: string | null
|
||||
structInfo: { article?: string; section?: string; pages?: string }
|
||||
docChunkIndex: number
|
||||
docTotalChunks: number
|
||||
docChunksLength: number
|
||||
chunksPerPage: number
|
||||
setChunksPerPage: (v: number) => void
|
||||
splitViewActive: boolean
|
||||
setSplitViewActive: (v: boolean) => void
|
||||
fullscreen: boolean
|
||||
setFullscreen: (v: boolean) => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onJumpTo: (idx: number) => void
|
||||
}
|
||||
|
||||
export function ChunkBrowserToolbar({
|
||||
collection,
|
||||
onCollectionChange,
|
||||
selectedRegulation,
|
||||
structInfo,
|
||||
docChunkIndex,
|
||||
docTotalChunks,
|
||||
docChunksLength,
|
||||
chunksPerPage,
|
||||
setChunksPerPage,
|
||||
splitViewActive,
|
||||
setSplitViewActive,
|
||||
fullscreen,
|
||||
setFullscreen,
|
||||
onPrev,
|
||||
onNext,
|
||||
onJumpTo,
|
||||
}: ChunkBrowserToolbarProps) {
|
||||
return (
|
||||
<div className="flex-shrink-0 bg-white rounded-xl border border-slate-200 p-3 mb-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Collection</label>
|
||||
<select
|
||||
value={collection}
|
||||
onChange={(e) => onCollectionChange(e.target.value)}
|
||||
className="px-3 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
|
||||
>
|
||||
{COLLECTIONS.map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedRegulation && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
{selectedRegulation} — {getRegName(selectedRegulation)}
|
||||
</span>
|
||||
{structInfo.article && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 text-xs font-medium rounded">
|
||||
{structInfo.article}
|
||||
</span>
|
||||
)}
|
||||
{structInfo.pages && (
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">
|
||||
{structInfo.pages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={docChunkIndex === 0}
|
||||
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
◀ Zurueck
|
||||
</button>
|
||||
<span className="text-sm font-mono text-slate-600 min-w-[80px] text-center">
|
||||
{docChunkIndex + 1} / {docTotalChunks}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={docChunkIndex >= docChunksLength - 1}
|
||||
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter ▶
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={docTotalChunks}
|
||||
value={docChunkIndex + 1}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!isNaN(v) && v >= 1 && v <= docTotalChunks) onJumpTo(v - 1)
|
||||
}}
|
||||
className="w-16 px-2 py-1 border rounded text-xs text-center"
|
||||
title="Springe zu Chunk Nr."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-500">Chunks/Seite:</label>
|
||||
<select
|
||||
value={chunksPerPage}
|
||||
onChange={(e) => setChunksPerPage(Number(e.target.value))}
|
||||
className="px-2 py-1 border rounded text-xs"
|
||||
>
|
||||
{[3, 4, 5, 6, 8, 10, 12, 15, 20].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setSplitViewActive(!splitViewActive)}
|
||||
className={`px-3 py-1 text-xs rounded-lg border ${
|
||||
splitViewActive ? 'bg-teal-50 border-teal-300 text-teal-700' : 'bg-slate-50 border-slate-300 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{splitViewActive ? 'Split-View an' : 'Split-View aus'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className={`px-3 py-1 text-xs rounded-lg border ${
|
||||
fullscreen ? 'bg-indigo-50 border-indigo-300 text-indigo-700' : 'bg-slate-50 border-slate-300 text-slate-600'
|
||||
}`}
|
||||
title={fullscreen ? 'Vollbild beenden (Esc)' : 'Vollbild'}
|
||||
>
|
||||
{fullscreen ? '\u2715 Vollbild beenden' : '\u2716 Vollbild'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
352
admin-lehrer/app/(admin)/ai/rag/rag-data.ts
Normal file
352
admin-lehrer/app/(admin)/ai/rag/rag-data.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* RAG & Legal Corpus Management - Static Data
|
||||
*
|
||||
* Core data constants: regulations, industries, thematic groups, etc.
|
||||
* Source URLs and licenses are in rag-sources.ts.
|
||||
*/
|
||||
|
||||
import { REGULATIONS_IN_RAG } from './rag-constants'
|
||||
import ragData from './rag-documents.json'
|
||||
import type {
|
||||
Regulation,
|
||||
Industry,
|
||||
ThematicGroup,
|
||||
KeyIntersection,
|
||||
FutureOutlookItem,
|
||||
AdditionalRegulation,
|
||||
LegalBasisInfo,
|
||||
TabDef,
|
||||
} from './types'
|
||||
|
||||
// Re-export source URLs, licenses and license labels from rag-sources.ts
|
||||
export {
|
||||
REGULATION_SOURCES,
|
||||
REGULATION_LICENSES,
|
||||
LICENSE_LABELS,
|
||||
} from './rag-sources'
|
||||
|
||||
// API uses local proxy route to klausur-service
|
||||
export const API_PROXY = '/api/legal-corpus'
|
||||
export const DSFA_API_PROXY = '/api/dsfa-corpus'
|
||||
|
||||
// Import documents and metadata from JSON
|
||||
export const RAG_DOCUMENTS = ragData.documents
|
||||
export const DOC_TYPES = ragData.doc_types
|
||||
export const INDUSTRIES_LIST = ragData.industries
|
||||
|
||||
// Derive REGULATIONS from JSON (backwards compatible for regulations tab)
|
||||
export const REGULATIONS: Regulation[] = RAG_DOCUMENTS.filter((d: any) => d.description).map((d: any) => ({
|
||||
code: d.code,
|
||||
name: d.name,
|
||||
fullName: d.full_name || d.name,
|
||||
type: d.doc_type,
|
||||
expected: 0,
|
||||
description: d.description || '',
|
||||
relevantFor: [] as string[],
|
||||
keyTopics: [] as string[],
|
||||
effectiveDate: d.effective_date || ''
|
||||
}))
|
||||
|
||||
// Helper: Check if regulation is in RAG
|
||||
export const isInRag = (code: string): boolean => code in REGULATIONS_IN_RAG
|
||||
|
||||
// Helper: Get known chunk count for a regulation
|
||||
export const getKnownChunks = (code: string): number => REGULATIONS_IN_RAG[code]?.chunks || 0
|
||||
|
||||
// Known collection totals (updated: 2026-03-12)
|
||||
export const COLLECTION_TOTALS = {
|
||||
bp_compliance_gesetze: 63567,
|
||||
bp_compliance_ce: 18183,
|
||||
bp_legal_templates: 7689,
|
||||
bp_compliance_datenschutz: 17459,
|
||||
bp_dsfa_corpus: 8666,
|
||||
bp_compliance_recht: 1425,
|
||||
bp_nibis_eh: 7996,
|
||||
total_legal: 81750,
|
||||
total_all: 124985,
|
||||
}
|
||||
|
||||
export const TYPE_COLORS: Record<string, string> = {
|
||||
eu_regulation: 'bg-blue-100 text-blue-700',
|
||||
eu_directive: 'bg-purple-100 text-purple-700',
|
||||
de_law: 'bg-yellow-100 text-yellow-700',
|
||||
at_law: 'bg-red-100 text-red-700',
|
||||
ch_law: 'bg-rose-100 text-rose-700',
|
||||
bsi_standard: 'bg-green-100 text-green-700',
|
||||
national_law: 'bg-orange-100 text-orange-700',
|
||||
eu_guideline: 'bg-teal-100 text-teal-700',
|
||||
}
|
||||
|
||||
export const TYPE_LABELS: Record<string, string> = {
|
||||
eu_regulation: 'EU-VO',
|
||||
eu_directive: 'EU-RL',
|
||||
de_law: 'DE-Gesetz',
|
||||
at_law: 'AT-Gesetz',
|
||||
ch_law: 'CH-Gesetz',
|
||||
bsi_standard: 'BSI',
|
||||
national_law: 'Nat. Gesetz',
|
||||
eu_guideline: 'EDPB-GL',
|
||||
}
|
||||
|
||||
// Industries for backward compatibility
|
||||
export const INDUSTRIES: Industry[] = INDUSTRIES_LIST.map((ind: any) => ({
|
||||
id: ind.id,
|
||||
name: ind.name,
|
||||
icon: ind.icon,
|
||||
description: ''
|
||||
}))
|
||||
|
||||
// Derive industry map from document data
|
||||
export const INDUSTRY_REGULATION_MAP: Record<string, string[]> = {}
|
||||
for (const ind of INDUSTRIES_LIST) {
|
||||
INDUSTRY_REGULATION_MAP[ind.id] = RAG_DOCUMENTS
|
||||
.filter((d: any) => d.industries.includes(ind.id) || d.industries.includes('all'))
|
||||
.map((d: any) => d.code)
|
||||
}
|
||||
|
||||
// Thematic groupings showing overlaps
|
||||
export const THEMATIC_GROUPS: ThematicGroup[] = [
|
||||
{
|
||||
id: 'datenschutz',
|
||||
name: 'Datenschutz & Privacy',
|
||||
color: 'bg-blue-500',
|
||||
regulations: ['GDPR', 'EPRIVACY', 'TDDDG', 'SCC', 'DPF'],
|
||||
description: 'Schutz personenbezogener Daten, Einwilligung, Betroffenenrechte'
|
||||
},
|
||||
{
|
||||
id: 'cybersecurity',
|
||||
name: 'Cybersicherheit',
|
||||
color: 'bg-red-500',
|
||||
regulations: ['NIS2', 'EUCSA', 'CRA', 'BSI-TR-03161-1', 'BSI-TR-03161-2', 'BSI-TR-03161-3', 'DORA'],
|
||||
description: 'IT-Sicherheit, Risikomanagement, Incident Response'
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
name: 'Kuenstliche Intelligenz',
|
||||
color: 'bg-purple-500',
|
||||
regulations: ['AIACT', 'PLD', 'GPSR'],
|
||||
description: 'KI-Regulierung, Hochrisiko-Systeme, Haftung'
|
||||
},
|
||||
{
|
||||
id: 'digital-markets',
|
||||
name: 'Digitale Maerkte & Plattformen',
|
||||
color: 'bg-green-500',
|
||||
regulations: ['DSA', 'DGA', 'DATAACT', 'DSM'],
|
||||
description: 'Plattformregulierung, Datenzugang, Urheberrecht'
|
||||
},
|
||||
{
|
||||
id: 'product-safety',
|
||||
name: 'Produktsicherheit & Haftung',
|
||||
color: 'bg-orange-500',
|
||||
regulations: ['CRA', 'PLD', 'GPSR', 'EAA', 'MACHINERY_REG', 'BLUE_GUIDE'],
|
||||
description: 'Sicherheitsanforderungen, CE-Kennzeichnung, Maschinenverordnung, Barrierefreiheit'
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
name: 'Finanzmarktregulierung',
|
||||
color: 'bg-emerald-500',
|
||||
regulations: ['DORA', 'PSD2', 'AMLR', 'MiCA'],
|
||||
description: 'Zahlungsdienste, Krypto-Assets, Geldwaeschebekaempfung, digitale Resilienz'
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
name: 'Gesundheitsdaten',
|
||||
color: 'bg-pink-500',
|
||||
regulations: ['EHDS', 'BSI-TR-03161-1', 'BSI-TR-03161-2', 'BSI-TR-03161-3'],
|
||||
description: 'Gesundheitsdatenraum, DiGA-Sicherheit, Patientenrechte'
|
||||
},
|
||||
{
|
||||
id: 'verbraucherschutz',
|
||||
name: 'Verbraucherschutz & E-Commerce',
|
||||
color: 'bg-amber-500',
|
||||
regulations: ['DE_PANGV', 'DE_VSBG', 'DE_PRODHAFTG', 'DE_UWG', 'DE_BFSG',
|
||||
'WARENKAUF_RL', 'KLAUSEL_RL', 'UNLAUTERE_PRAKTIKEN_RL', 'PREISANGABEN_RL',
|
||||
'OMNIBUS_RL', 'E_COMMERCE_RL', 'VERBRAUCHERRECHTE_RL', 'DIGITALE_INHALTE_RL'],
|
||||
description: 'Widerrufsrecht, Preisangaben, Fernabsatz, AGB-Recht, Barrierefreiheit'
|
||||
},
|
||||
]
|
||||
|
||||
// Key overlaps and intersections
|
||||
export const KEY_INTERSECTIONS: KeyIntersection[] = [
|
||||
{
|
||||
regulations: ['GDPR', 'AIACT'],
|
||||
topic: 'KI und personenbezogene Daten',
|
||||
description: 'Automatisierte Entscheidungen, Profiling, Erklaerbarkeit'
|
||||
},
|
||||
{
|
||||
regulations: ['NIS2', 'CRA'],
|
||||
topic: 'Cybersicherheit von Produkten',
|
||||
description: 'Sicherheitsanforderungen ueber den gesamten Lebenszyklus'
|
||||
},
|
||||
{
|
||||
regulations: ['AIACT', 'PLD'],
|
||||
topic: 'KI-Haftung',
|
||||
description: 'Wer haftet, wenn KI Schaeden verursacht?'
|
||||
},
|
||||
{
|
||||
regulations: ['DSA', 'GDPR'],
|
||||
topic: 'Plattform-Transparenz',
|
||||
description: 'Inhaltsmoderation und Datenschutz'
|
||||
},
|
||||
{
|
||||
regulations: ['DATAACT', 'GDPR'],
|
||||
topic: 'Datenzugang vs. Datenschutz',
|
||||
description: 'Balance zwischen Datenteilung und Privacy'
|
||||
},
|
||||
{
|
||||
regulations: ['CRA', 'GPSR'],
|
||||
topic: 'Digitale Produktsicherheit',
|
||||
description: 'Hardware mit Software-Komponenten'
|
||||
},
|
||||
]
|
||||
|
||||
// Future outlook - proposed and discussed regulations
|
||||
export const FUTURE_OUTLOOK: FutureOutlookItem[] = [
|
||||
{
|
||||
id: 'digital-omnibus',
|
||||
name: 'EU Digital Omnibus',
|
||||
status: 'proposed',
|
||||
statusLabel: 'Vorgeschlagen Nov 2025',
|
||||
expectedDate: '2026/2027',
|
||||
description: 'Umfassendes Vereinfachungspaket fuer AI Act, DSGVO und Cybersicherheit. Ziel: 5 Mrd. EUR Einsparung bei Verwaltungskosten.',
|
||||
keyChanges: [
|
||||
'AI Act: Verschiebung Hochrisiko-Pflichten um bis zu 16 Monate (bis Dez 2027)',
|
||||
'AI Act: Vereinfachte Dokumentation fuer KMU und Small Midcaps',
|
||||
'AI Act: EU-weite regulatorische Sandbox fuer KI-Tests',
|
||||
'DSGVO: Cookie-Banner-Reform - Berechtigtes Interesse statt nur Einwilligung',
|
||||
'DSGVO: Automatische Privacy-Signale via Browser statt Pop-ups',
|
||||
'Cybersecurity: Single Entry Point fuer Meldepflichten'
|
||||
],
|
||||
affectedRegulations: ['AIACT', 'GDPR', 'NIS2', 'CRA', 'EUCSA'],
|
||||
source: 'https://digital-strategy.ec.europa.eu/en/library/digital-omnibus-ai-regulation-proposal'
|
||||
},
|
||||
{
|
||||
id: 'sustainability-omnibus',
|
||||
name: 'EU Nachhaltigkeits-Omnibus',
|
||||
status: 'agreed',
|
||||
statusLabel: 'Einigung Dez 2025',
|
||||
expectedDate: 'Q1 2026',
|
||||
description: 'Drastische Reduzierung der Nachhaltigkeits-Berichtspflichten. Anwendungsbereich wird stark eingeschraenkt.',
|
||||
keyChanges: [
|
||||
'CSRD: Nur noch Unternehmen >1.000 MA und >450 Mio EUR Umsatz berichtspflichtig',
|
||||
'CSRD: Betroffene Unternehmen sinken von 50.000 auf ca. 5.000 in der EU',
|
||||
'CSRD: Verschiebung Welle 2+3 um 2 Jahre (auf Geschaeftsjahr 2027)',
|
||||
'CSDDD: Nur noch Unternehmen >5.000 MA und >1,5 Mrd EUR Umsatz',
|
||||
'CSDDD: Sorgfaltspflichten nur noch fuer Tier-1-Lieferanten',
|
||||
'CSDDD: Pruefung nur noch alle 5 Jahre statt jaehrlich'
|
||||
],
|
||||
affectedRegulations: ['CSRD', 'CSDDD', 'EU-Taxonomie'],
|
||||
source: 'https://kpmg-law.de/erste-omnibus-verordnung-soll-die-pflichten-der-csddd-csrd-und-eu-taxonomie-lockern/'
|
||||
},
|
||||
{
|
||||
id: 'eprivacy-withdrawal',
|
||||
name: 'ePrivacy-Verordnung',
|
||||
status: 'withdrawn',
|
||||
statusLabel: 'Zurueckgezogen Feb 2025',
|
||||
expectedDate: 'Unbekannt',
|
||||
description: 'Nach 9 Jahren Verhandlung hat die EU-Kommission den Vorschlag zurueckgezogen. Die ePrivacy-Richtlinie bleibt in Kraft, Cookie-Reform kommt via DSGVO/Digital Omnibus.',
|
||||
keyChanges: [
|
||||
'Urspruenglicher Vorschlag: Einheitliche EU-Cookie-Regeln',
|
||||
'Urspruenglicher Vorschlag: Strikte Tracking-Einwilligung',
|
||||
'Status: ePrivacy-Richtlinie + TDDDG bleiben gueltig',
|
||||
'Zukunft: Cookie-Reform wird Teil der DSGVO-Aenderungen'
|
||||
],
|
||||
affectedRegulations: ['EPRIVACY', 'TDDDG', 'GDPR'],
|
||||
source: 'https://netzpolitik.org/2025/cookie-banner-und-online-tracking-eu-kommission-beerdigt-plaene-fuer-eprivacy-verordnung/'
|
||||
},
|
||||
{
|
||||
id: 'ai-liability',
|
||||
name: 'KI-Haftungsrichtlinie',
|
||||
status: 'pending',
|
||||
statusLabel: 'In Verhandlung',
|
||||
expectedDate: '2026',
|
||||
description: 'Ergaenzt den AI Act um zivilrechtliche Haftungsregeln. Erleichtert Geschaedigten die Beweisfuehrung bei KI-Schaeden.',
|
||||
keyChanges: [
|
||||
'Beweislasterleichterung bei KI-verursachten Schaeden',
|
||||
'Offenlegungspflichten fuer KI-Anbieter im Schadensfall',
|
||||
'Verknuepfung mit Produkthaftungsrichtlinie'
|
||||
],
|
||||
affectedRegulations: ['AIACT', 'PLD'],
|
||||
source: 'https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:52022PC0496'
|
||||
},
|
||||
]
|
||||
|
||||
// Potential future regulations (not yet integrated)
|
||||
export const ADDITIONAL_REGULATIONS: AdditionalRegulation[] = [
|
||||
{
|
||||
code: 'PSD3',
|
||||
name: 'Payment Services Directive 3',
|
||||
fullName: 'Richtlinie zur dritten Zahlungsdiensterichtlinie (Entwurf)',
|
||||
type: 'eu_directive',
|
||||
status: 'proposed',
|
||||
effectiveDate: 'Voraussichtlich 2026',
|
||||
description: 'Modernisierung der Zahlungsdienste-Regulierung. Staerkerer Verbraucherschutz, Open Banking 2.0, Betrugsbekaempfung. Ersetzt dann PSD2.',
|
||||
relevantFor: ['Banken', 'Zahlungsdienstleister', 'Fintechs', 'E-Commerce'],
|
||||
celex: '52023PC0366',
|
||||
priority: 'medium'
|
||||
},
|
||||
{
|
||||
code: 'AMLD6',
|
||||
name: 'AML-Richtlinie 6',
|
||||
fullName: 'Richtlinie (EU) 2024/1640 - 6. Geldwaescherichtlinie',
|
||||
type: 'eu_directive',
|
||||
status: 'active',
|
||||
effectiveDate: '10. Juli 2027 (Umsetzung)',
|
||||
description: 'Ergaenzt die AML-Verordnung. Nationale Umsetzungsvorschriften, strafrechtliche Sanktionen, AMLA-Behoerde.',
|
||||
relevantFor: ['Banken', 'Krypto-Anbieter', 'Immobilienmakler', 'Gluecksspielanbieter'],
|
||||
celex: '32024L1640',
|
||||
priority: 'medium'
|
||||
},
|
||||
{
|
||||
code: 'FIDA',
|
||||
name: 'Financial Data Access',
|
||||
fullName: 'Verordnung zum Zugang zu Finanzdaten (Entwurf)',
|
||||
type: 'eu_regulation',
|
||||
status: 'proposed',
|
||||
effectiveDate: 'Voraussichtlich 2027',
|
||||
description: 'Open Finance Framework - erweitert PSD2-Open-Banking auf Versicherungen, Investitionen, Kredite.',
|
||||
relevantFor: ['Banken', 'Versicherungen', 'Fintechs', 'Datenaggregatoren'],
|
||||
celex: '52023PC0360',
|
||||
priority: 'medium'
|
||||
},
|
||||
]
|
||||
|
||||
// Legal basis for using EUR-Lex content
|
||||
export const LEGAL_BASIS_INFO: LegalBasisInfo = {
|
||||
title: 'Rechtliche Grundlage fuer RAG-Nutzung',
|
||||
summary: 'EU-Rechtstexte auf EUR-Lex sind oeffentliche amtliche Dokumente und duerfen frei verwendet werden.',
|
||||
details: [
|
||||
{
|
||||
aspect: 'EUR-Lex Dokumente',
|
||||
status: 'Erlaubt',
|
||||
explanation: 'Offizielle EU-Gesetzestexte, Richtlinien und Verordnungen sind gemeinfrei (Public Domain) und duerfen frei reproduziert und kommerziell genutzt werden.'
|
||||
},
|
||||
{
|
||||
aspect: 'Text-und-Data-Mining (TDM)',
|
||||
status: 'Erlaubt',
|
||||
explanation: 'Art. 4 der DSM-Richtlinie (2019/790) erlaubt TDM fuer kommerzielle Zwecke, sofern kein Opt-out des Rechteinhabers vorliegt. Fuer amtliche Texte gilt kein Opt-out.'
|
||||
},
|
||||
{
|
||||
aspect: 'AI Act Anforderungen',
|
||||
status: 'Beachten',
|
||||
explanation: 'Art. 53 AI Act verlangt von GPAI-Anbietern die Einhaltung des Urheberrechts. Fuer oeffentliche Rechtstexte unproblematisch.'
|
||||
},
|
||||
{
|
||||
aspect: 'BSI-Richtlinien',
|
||||
status: 'Erlaubt',
|
||||
explanation: 'BSI-Publikationen sind oeffentlich zugaenglich und duerfen fuer Compliance-Zwecke verwendet werden.'
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Tab definitions
|
||||
export const TABS: TabDef[] = [
|
||||
{ id: 'overview', name: 'Uebersicht', icon: '📊' },
|
||||
{ id: 'regulations', name: 'Regulierungen', icon: '📜' },
|
||||
{ id: 'map', name: 'Landkarte', icon: '🗺️' },
|
||||
{ id: 'search', name: 'Suche', icon: '🔍' },
|
||||
{ id: 'chunks', name: 'Chunk-Browser', icon: '🧩' },
|
||||
{ id: 'data', name: 'Daten', icon: '📁' },
|
||||
{ id: 'ingestion', name: 'Ingestion', icon: '⚙️' },
|
||||
{ id: 'pipeline', name: 'Pipeline', icon: '🔄' },
|
||||
]
|
||||
4332
admin-lehrer/app/(admin)/ai/rag/rag-documents.json
Normal file
4332
admin-lehrer/app/(admin)/ai/rag/rag-documents.json
Normal file
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