Compare commits
89 Commits
dde45b29db
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77c720e2df | |||
| 89011d64f7 | |||
| 8311b33fb3 | |||
| 85957ed5db | |||
| d9858084dd | |||
| 33409352ee | |||
| 3b8df0d294 | |||
| 09f6f5a5e1 | |||
| 97e37837ee | |||
| 65e7ed94f6 | |||
| 306886a42b | |||
| bf5ea860cc | |||
| 612ecec6d9 | |||
| 0744769d88 | |||
| d3f311a32e | |||
| 77650e8092 | |||
| 22f08a232d | |||
| 1f2f304724 | |||
| 53cfe9238f | |||
| f042f2896b | |||
| 082a5bb68c | |||
| a315db0388 | |||
| 7c96d89927 | |||
| c2c09e1cd9 | |||
| 4657589b89 | |||
| 73636f76a2 | |||
| f21ecf293b | |||
| 64e7176267 | |||
| e958f88a2d | |||
| a1488b2fec | |||
| 8d53b1f6b9 | |||
| 399ab88f5f | |||
| d52eb43a32 | |||
| bde0d57b5a | |||
| fc49d87928 | |||
| 0018076ed5 | |||
| a30f10a467 | |||
| a44d360cbc | |||
| 52a15b24fe | |||
| 855cc4caf4 | |||
| c09fc6c7bc | |||
| 387219682d | |||
| 6f43224fda | |||
| 9b96998654 | |||
| 91e8b92bdc | |||
| c2efb9934c | |||
| 0d2e79da66 | |||
| cb4ea8e49a | |||
| d14826b199 | |||
| 693989c1a6 | |||
| bd24fa6ba6 | |||
| ef821831a4 | |||
| 93f7ef88e3 | |||
| 6ea20fa1a3 | |||
| bf2f7daaeb | |||
| fc2fe98bd9 | |||
| 1a272371f4 | |||
| fdde5d43b3 | |||
| f6caa3091f | |||
| 91d6918e2c | |||
| 82f5b4fbba | |||
| afe7a983d1 | |||
| 6d54ee8178 | |||
| a1664ab12c | |||
| 9f21bd070a | |||
| 5012699aaf | |||
| d8771bb509 | |||
| 7f8743d1e3 | |||
| 9de26701dd | |||
| c252556528 | |||
| 68d1679294 | |||
| 9e63b09cb7 | |||
| bd3ca854ef | |||
| b495e63e6f | |||
| 198a0b2a0d | |||
| 6b3bff48f0 | |||
| 0f0bbc3dc0 | |||
| 3cdab5a967 | |||
| f2300219d7 | |||
| aaa52a8901 | |||
| 1fb6702bf4 | |||
| 6210ceb05e | |||
| 3619ddfdad | |||
| f2346b88cd | |||
| eecb5472dd | |||
| 5f2ed44654 | |||
| d093a4d388 | |||
| cba877c65a | |||
| 6be555fb7c |
@@ -243,6 +243,35 @@ ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && git push all
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Stundenplan + Schulkalender (Mai 2026, alle Phasen deployed)
|
||||||
|
|
||||||
|
Zwei groesse Feature-Strange, vollstaendig live auf Mac Mini:
|
||||||
|
|
||||||
|
| Pfad | Beschreibung |
|
||||||
|
|------|--------------|
|
||||||
|
| `/stundenplan` (studio-v2) | Lehrer-UI mit 9 Tabs (Plan + 7 Stammdaten + Regeln), 15 Constraint-Editoren, Pin/Unpin im Wochengrid |
|
||||||
|
| `/schulkalender` (studio-v2) | Bundesland-Wizard, Monatsansicht mit Ferien (16 BL × 3 Jahre), Schul-Events, Schuljahres-Rollover, Eltern-Manager |
|
||||||
|
| `/eltern` (studio-v2) | Eltern-Sicht: Wochengrid des eigenen Kindes in Eltern-Sprache, Magic-Link-Login |
|
||||||
|
| `school-service` (Go, :8084) | Beide Backends — 30+ Tabellen, JWT-Auth (Dev-Bypass aktiv), Cron fuer Notifications |
|
||||||
|
| `timetable-solver-service` (Python+JVM, :8095) | Timefold-basierter Solver, 14 Constraints implementiert |
|
||||||
|
|
||||||
|
**Wichtigste Memo-Dateien fuer Wiedereinstieg:**
|
||||||
|
- `~/.claude/projects/-Users-benjaminadmin-Projekte-breakpilot-lehrer/memory/session_summary_2026_05_22.md` — vollstaendiges Inventar
|
||||||
|
- `~/.claude/projects/-Users-benjaminadmin/memory/project_timetable_scheduler.md` — Stundenplan-Status
|
||||||
|
- `~/.claude/projects/-Users-benjaminadmin-Projekte-breakpilot-lehrer/memory/project_schulkalender.md` — Schulkalender-Status
|
||||||
|
|
||||||
|
**Pitfalls (vermeidet diese):**
|
||||||
|
- Timefold Python-Package heisst `timefold` (NICHT `timefold-solver`), v1.24.0b0
|
||||||
|
- Production-Auth + Matrix/Email-Services baut Kollege — Frontend-Hooks nutzen, kein eigener Service-Code
|
||||||
|
- JSX-Attribute mit deutschen Quotes `„X"` brechen, Loesung: `description={"..."}` Expression-Form
|
||||||
|
- LOC-Budget 500 pro File — bei specs mit shared Helpers arbeiten (`e2e/_helpers.ts`)
|
||||||
|
|
||||||
|
**Test-Status (Stand 2026-05-22):** 89 Go + 21 Playwright im Schulkalender + 42 Playwright im Stundenplan = **152 grun**
|
||||||
|
|
||||||
|
**Offen:** Seed-Daten fuer Demo-Schule, Vollschuljahr-ICS mit RRULE+EXDATE, Untis-Import (Phase 4 geparkt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Wichtige Dateien (Referenz)
|
## Wichtige Dateien (Referenz)
|
||||||
|
|
||||||
| Datei | Beschreibung |
|
| Datei | Beschreibung |
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Shim Cleanup Tracker
|
||||||
|
|
||||||
|
**Status:** Shims aktiv — Consumer-Imports noch auf alten Pfaden
|
||||||
|
**Erstellt:** 2026-04-25
|
||||||
|
**Ziel:** Shims schrittweise loeschen sobald Consumer auf neue Pfade aktualisiert sind
|
||||||
|
|
||||||
|
## Was sind Shims?
|
||||||
|
|
||||||
|
Beim Restructuring wurden Dateien in Packages verschoben (z.B. `cv_layout.py` → `ocr/layout/layout.py`). Am alten Pfad bleibt ein 4-Zeilen Redirect:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# cv_layout.py (shim)
|
||||||
|
import importlib as _importlib
|
||||||
|
import sys as _sys
|
||||||
|
_sys.modules[__name__] = _importlib.import_module("ocr.layout.layout")
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit brechen keine bestehenden `from cv_layout import ...` Imports.
|
||||||
|
|
||||||
|
## Cleanup-Prozess (pro Shim)
|
||||||
|
|
||||||
|
1. `grep -rn "from <old_module> import\|import <old_module>" --include="*.py"` — finde alle Consumer
|
||||||
|
2. Consumer-Imports auf neuen Pfad aktualisieren (z.B. `from ocr.layout.layout import ...`)
|
||||||
|
3. Shim-Datei loeschen
|
||||||
|
4. Tests ausfuehren
|
||||||
|
|
||||||
|
## Shim-Inventar
|
||||||
|
|
||||||
|
### klausur-service/backend/ (171 Shims)
|
||||||
|
|
||||||
|
| Gruppe | Anzahl | Alter Pfad | Neuer Pfad |
|
||||||
|
|--------|--------|------------|------------|
|
||||||
|
| cv_* (OCR Core) | 47 | `cv_layout.py` etc. | `ocr/layout/layout.py` etc. |
|
||||||
|
| ocr_pipeline_* | 30 | `ocr_pipeline_api.py` etc. | `ocr/pipeline/api.py` etc. |
|
||||||
|
| ocr_labeling_* | 5 | `ocr_labeling_api.py` etc. | `ocr/labeling/api.py` etc. |
|
||||||
|
| ocr related | 11 | `page_crop.py` etc. | `ocr/pipeline/page_crop.py` etc. |
|
||||||
|
| grid_* | 16 | `grid_build_core.py` etc. | `grid/build/core.py` etc. |
|
||||||
|
| vocab_* | 10 | `vocab_worksheet_api.py` etc. | `vocab/worksheet/api.py` etc. |
|
||||||
|
| korrektur | 11 | `eh_templates.py` etc. | `korrektur/eh_templates.py` etc. |
|
||||||
|
| zeugnis_* | 10 | `zeugnis_api.py` etc. | `zeugnis/api.py` etc. |
|
||||||
|
| admin_* | 4 | `admin_api.py` etc. | `admin/api.py` etc. |
|
||||||
|
| compliance/rbac | 8 | `rbac.py` etc. | `compliance/rbac.py` etc. |
|
||||||
|
| worksheet/nru | 9 | `worksheet_editor_api.py` etc. | `worksheet/editor_api.py` etc. |
|
||||||
|
| training_* | 6 | `training_api.py` etc. | `training/api.py` etc. |
|
||||||
|
| metrics_* | 4 | `metrics_db.py` etc. | `metrics/db.py` etc. |
|
||||||
|
|
||||||
|
### backend-lehrer/ (43 Shims)
|
||||||
|
|
||||||
|
| Gruppe | Anzahl | Alter Pfad | Neuer Pfad |
|
||||||
|
|--------|--------|------------|------------|
|
||||||
|
| abitur_docs_* | 3 | `abitur_docs_api.py` etc. | `abitur/api.py` etc. |
|
||||||
|
| correction_* | 4 | `correction_api.py` etc. | `correction/api.py` etc. |
|
||||||
|
| messenger_* | 5 | `messenger_api.py` etc. | `messenger/api.py` etc. |
|
||||||
|
| recording_* | 6 | `recording_api.py` etc. | `recording/api.py` etc. |
|
||||||
|
| unit_* + learning_* | 13 | `unit_api.py` etc. | `units/api.py` etc. |
|
||||||
|
| teacher_dashboard_* | 3 | `teacher_dashboard_api.py` etc. | `dashboard/api.py` etc. |
|
||||||
|
| game_* | 5 | `game_api.py` etc. | `game/api.py` etc. |
|
||||||
|
| letters/certificates | 4 | `letters_api.py` etc. | `letters/api.py` etc. |
|
||||||
|
|
||||||
|
## Prioritaet
|
||||||
|
|
||||||
|
1. **Hoch:** Shims die von `main.py` importiert werden (Router-Registrierung)
|
||||||
|
2. **Mittel:** Shims die von anderen Modulen importiert werden
|
||||||
|
3. **Niedrig:** Shims die nur von Tests importiert werden
|
||||||
|
|
||||||
|
## Wann loeschen?
|
||||||
|
|
||||||
|
- Bei der naechsten groesseren Aenderung an einem Modul → gleich die Consumer-Imports mit aktualisieren
|
||||||
|
- Oder als dedizierte Cleanup-Session wenn alle Tests gruen sind
|
||||||
|
- NICHT alle auf einmal — Modul fuer Modul vorgehen
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to abitur/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("abitur.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to abitur/models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("abitur.models")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to abitur/recognition.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("abitur.recognition")
|
|
||||||
@@ -158,7 +158,7 @@ def _analyze_with_openai(input_path: Path) -> Path:
|
|||||||
|
|
||||||
def _analyze_with_claude(input_path: Path) -> Path:
|
def _analyze_with_claude(input_path: Path) -> Path:
|
||||||
"""Strukturierte JSON-Analyse mit Claude Vision API."""
|
"""Strukturierte JSON-Analyse mit Claude Vision API."""
|
||||||
from claude_vision import analyze_worksheet_with_claude
|
from services.claude_vision import analyze_worksheet_with_claude
|
||||||
|
|
||||||
if not input_path.exists():
|
if not input_path.exists():
|
||||||
raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}")
|
raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ A modular AI-powered worksheet processing system for:
|
|||||||
- Mindmap visualization
|
- Mindmap visualization
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from ai_processor import analyze_scan_structure_with_ai, generate_mc_from_analysis
|
from services.ai_processor import analyze_scan_structure_with_ai, generate_mc_from_analysis
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ def _analyze_with_claude(input_path: Path) -> Path:
|
|||||||
|
|
||||||
Uses Claude 3.5 Sonnet for better OCR and layout detection.
|
Uses Claude 3.5 Sonnet for better OCR and layout detection.
|
||||||
"""
|
"""
|
||||||
from claude_vision import analyze_worksheet_with_claude
|
from services.claude_vision import analyze_worksheet_with_claude
|
||||||
|
|
||||||
if not input_path.exists():
|
if not input_path.exists():
|
||||||
raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}")
|
raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}")
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# API Module — thin proxy routers and standalone API endpoints
|
||||||
|
#
|
||||||
|
# api/school.py — Proxy to Go school-service
|
||||||
|
# api/klausur_proxy.py — Proxy to klausur-service
|
||||||
|
# api/progress.py — Student learning progress tracking
|
||||||
|
# api/user_language.py — User language preferences
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to letters/certificates_api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("letters.certificates_api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to letters/certificates_models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("letters.certificates_models")
|
|
||||||
@@ -24,7 +24,7 @@ from state_engine import (
|
|||||||
Event,
|
Event,
|
||||||
get_phase_info,
|
get_phase_info,
|
||||||
)
|
)
|
||||||
from state_engine_models import (
|
from .state_engine_models import (
|
||||||
MilestoneRequest,
|
MilestoneRequest,
|
||||||
TransitionRequest,
|
TransitionRequest,
|
||||||
ContextResponse,
|
ContextResponse,
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to correction/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("correction.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to correction/endpoints.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("correction.endpoints")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to correction/helpers.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("correction.helpers")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to correction/models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("correction.models")
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
# - game_extended_routes.py (Phase 5: achievements, progress, parent, class)
|
# - game_extended_routes.py (Phase 5: achievements, progress, parent, class)
|
||||||
#
|
#
|
||||||
# The `router` object is assembled here by including all sub-routers.
|
# The `router` object is assembled here by including all sub-routers.
|
||||||
# Importers that did `from game_api import router` continue to work.
|
# Importers that did `from game.api import router` continue to work.
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to game/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("game.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to game/extended_routes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("game.extended_routes")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to game/game_models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("game.game_models")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to game/routes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("game.routes")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to game/session_routes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("game.session_routes")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/learning.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.learning")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/learning_api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.learning_api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to letters/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("letters.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to letters/models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("letters.models")
|
|
||||||
+23
-19
@@ -50,7 +50,7 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Backend-Lehrer starting up (DB search_path=lehrer,core,public)")
|
logger.info("Backend-Lehrer starting up (DB search_path=lehrer,core,public)")
|
||||||
# Initialize vocabulary tables
|
# Initialize vocabulary tables
|
||||||
try:
|
try:
|
||||||
from vocabulary_db import init_vocabulary_tables
|
from vocabulary.db import init_vocabulary_tables
|
||||||
await init_vocabulary_tables()
|
await init_vocabulary_tables()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Vocabulary tables init failed (non-critical): {e}")
|
logger.warning(f"Vocabulary tables init failed (non-critical): {e}")
|
||||||
@@ -97,70 +97,74 @@ from classroom_api import router as classroom_router
|
|||||||
app.include_router(classroom_router, prefix="/api/classroom")
|
app.include_router(classroom_router, prefix="/api/classroom")
|
||||||
|
|
||||||
# --- 2. State Engine (Begleiter-Modus mit Phasen und Antizipation) ---
|
# --- 2. State Engine (Begleiter-Modus mit Phasen und Antizipation) ---
|
||||||
from state_engine_api import router as state_engine_router
|
from classroom.state_engine_api import router as state_engine_router
|
||||||
app.include_router(state_engine_router, prefix="/api")
|
app.include_router(state_engine_router, prefix="/api")
|
||||||
|
|
||||||
# --- 3. Worksheets & Corrections ---
|
# --- 3. Worksheets & Corrections ---
|
||||||
from worksheets_api import router as worksheets_router
|
from worksheets.api import router as worksheets_router
|
||||||
app.include_router(worksheets_router, prefix="/api")
|
app.include_router(worksheets_router, prefix="/api")
|
||||||
|
|
||||||
from correction_api import router as correction_router
|
from correction.api import router as correction_router
|
||||||
app.include_router(correction_router, prefix="/api")
|
app.include_router(correction_router, prefix="/api")
|
||||||
|
|
||||||
# --- 4. Learning Units ---
|
# --- 4. Learning Units ---
|
||||||
from learning_units_api import router as learning_units_router
|
from units.learning_api import router as learning_units_router
|
||||||
app.include_router(learning_units_router, prefix="/api")
|
app.include_router(learning_units_router, prefix="/api")
|
||||||
|
|
||||||
# --- 4b. Learning Progress ---
|
# --- 4b. Learning Progress ---
|
||||||
from progress_api import router as progress_router
|
from api.progress import router as progress_router
|
||||||
app.include_router(progress_router, prefix="/api")
|
app.include_router(progress_router, prefix="/api")
|
||||||
|
|
||||||
# --- 4c. Vocabulary Catalog ---
|
# --- 4c. Vocabulary Catalog ---
|
||||||
from vocabulary_api import router as vocabulary_router
|
from vocabulary.api import router as vocabulary_router
|
||||||
app.include_router(vocabulary_router, prefix="/api")
|
app.include_router(vocabulary_router, prefix="/api")
|
||||||
|
|
||||||
|
# --- 4c2. Vocabulary Unit Creation + Translation ---
|
||||||
|
from vocabulary.unit_api import router as vocab_unit_router
|
||||||
|
app.include_router(vocab_unit_router, prefix="/api")
|
||||||
|
|
||||||
# --- 4d. User Language Preferences ---
|
# --- 4d. User Language Preferences ---
|
||||||
from user_language_api import router as user_language_router
|
from api.user_language import router as user_language_router
|
||||||
app.include_router(user_language_router, prefix="/api")
|
app.include_router(user_language_router, prefix="/api")
|
||||||
|
|
||||||
from unit_api import router as unit_router
|
from units.api import router as unit_router
|
||||||
app.include_router(unit_router) # Already has /api/units prefix
|
app.include_router(unit_router) # Already has /api/units prefix
|
||||||
|
|
||||||
from unit_analytics_api import router as unit_analytics_router
|
from units.analytics_api import router as unit_analytics_router
|
||||||
app.include_router(unit_analytics_router) # Already has /api/analytics prefix
|
app.include_router(unit_analytics_router) # Already has /api/analytics prefix
|
||||||
|
|
||||||
|
|
||||||
from recording_api import router as recording_api_router
|
from recording.api import router as recording_api_router
|
||||||
app.include_router(recording_api_router) # Already has /api/recordings prefix
|
app.include_router(recording_api_router) # Already has /api/recordings prefix
|
||||||
|
|
||||||
|
|
||||||
# --- 6. Messenger ---
|
# --- 6. Messenger ---
|
||||||
from messenger_api import router as messenger_router
|
from messenger.api import router as messenger_router
|
||||||
app.include_router(messenger_router) # Already has /api/messenger prefix
|
app.include_router(messenger_router) # Already has /api/messenger prefix
|
||||||
|
|
||||||
# --- 7. Klausur & School Proxies ---
|
# --- 7. Klausur & School Proxies ---
|
||||||
from klausur_service_proxy import router as klausur_service_router
|
from api.klausur_proxy import router as klausur_service_router
|
||||||
app.include_router(klausur_service_router, prefix="/api")
|
app.include_router(klausur_service_router, prefix="/api")
|
||||||
|
|
||||||
from school_api import router as school_api_router
|
from api.school import router as school_api_router
|
||||||
app.include_router(school_api_router, prefix="/api")
|
app.include_router(school_api_router, prefix="/api")
|
||||||
|
|
||||||
# --- 8. Teacher Dashboard & Abitur Docs ---
|
# --- 8. Teacher Dashboard & Abitur Docs ---
|
||||||
from abitur_docs_api import router as abitur_docs_router
|
from abitur.api import router as abitur_docs_router
|
||||||
app.include_router(abitur_docs_router, prefix="/api")
|
app.include_router(abitur_docs_router, prefix="/api")
|
||||||
|
|
||||||
from teacher_dashboard_api import router as teacher_dashboard_router
|
from dashboard.api import router as teacher_dashboard_router
|
||||||
app.include_router(teacher_dashboard_router) # Already has /api/teacher prefix
|
app.include_router(teacher_dashboard_router) # Already has /api/teacher prefix
|
||||||
|
|
||||||
# --- 9. Certificates & Letters ---
|
# --- 9. Certificates & Letters ---
|
||||||
from certificates_api import router as certificates_router
|
from letters.certificates_api import router as certificates_router
|
||||||
app.include_router(certificates_router, prefix="/api")
|
app.include_router(certificates_router, prefix="/api")
|
||||||
|
|
||||||
from letters_api import router as letters_router
|
from letters.api import router as letters_router
|
||||||
app.include_router(letters_router, prefix="/api")
|
app.include_router(letters_router, prefix="/api")
|
||||||
|
|
||||||
# --- 10. Game System ---
|
# --- 10. Game System ---
|
||||||
from game_api import router as game_router
|
from game.api import router as game_router
|
||||||
app.include_router(game_router) # Already has /api/game prefix
|
app.include_router(game_router) # Already has /api/game prefix
|
||||||
|
|
||||||
# --- 11. AI Processor (OCR + Content generation) ---
|
# --- 11. AI Processor (OCR + Content generation) ---
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ async def send_message(conversation_id: str, message: MessageBase):
|
|||||||
|
|
||||||
if contact and contact.get("email"):
|
if contact and contact.get("email"):
|
||||||
try:
|
try:
|
||||||
from email_service import email_service
|
from services.email import email_service
|
||||||
|
|
||||||
result = email_service.send_messenger_notification(
|
result = email_service.send_messenger_notification(
|
||||||
to_email=contact["email"],
|
to_email=contact["email"],
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to messenger/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("messenger.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to messenger/contacts.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("messenger.contacts")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to messenger/conversations.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("messenger.conversations")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to messenger/helpers.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("messenger.helpers")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to messenger/models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("messenger.models")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to recording/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("recording.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to recording/helpers.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("recording.helpers")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to recording/minutes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("recording.minutes")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to recording/models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("recording.models")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to recording/routes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("recording.routes")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to recording/transcription.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("recording.transcription")
|
|
||||||
@@ -19,4 +19,12 @@ except (ImportError, OSError) as e:
|
|||||||
FileProcessor = None # type: ignore
|
FileProcessor = None # type: ignore
|
||||||
_file_processor_available = False
|
_file_processor_available = False
|
||||||
|
|
||||||
|
# Lazy-loaded service modules (imported on demand to avoid heavy deps at startup):
|
||||||
|
# .audio — TTS audio generation for vocabulary words
|
||||||
|
# .email — Email/SMTP service
|
||||||
|
# .translation — Batch vocabulary translation via Ollama
|
||||||
|
# .claude_vision — Claude Vision API for worksheet analysis
|
||||||
|
# .ai_processor — Legacy shim for ai_processor/ package
|
||||||
|
# .story_generator — Story generation from vocabulary words
|
||||||
|
|
||||||
__all__ = ["PDFService", "FileProcessor"]
|
__all__ = ["PDFService", "FileProcessor"]
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ This file provides backward compatibility for code that imports from ai_processo
|
|||||||
All functionality has been moved to the ai_processor/ module.
|
All functionality has been moved to the ai_processor/ module.
|
||||||
|
|
||||||
Usage (new):
|
Usage (new):
|
||||||
from ai_processor import analyze_scan_structure_with_ai
|
from services.ai_processor import analyze_scan_structure_with_ai
|
||||||
|
|
||||||
Usage (legacy, still works):
|
Usage (legacy, still works):
|
||||||
from ai_processor import analyze_scan_structure_with_ai
|
from services.ai_processor import analyze_scan_structure_with_ai
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Re-export everything from the new modular structure
|
# Re-export everything from the new modular structure
|
||||||
from ai_processor import (
|
from services.ai_processor import (
|
||||||
# Configuration
|
# Configuration
|
||||||
BASE_DIR,
|
BASE_DIR,
|
||||||
EINGANG_DIR,
|
EINGANG_DIR,
|
||||||
@@ -46,7 +46,7 @@ from ai_processor import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Legacy function alias
|
# Legacy function alias
|
||||||
from ai_processor import get_openai_api_key as _get_api_key
|
from services.ai_processor import get_openai_api_key as _get_api_key
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Configuration
|
# Configuration
|
||||||
@@ -23,6 +23,53 @@ TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://bp-compliance-tts:8095")
|
|||||||
# Local cache directory for generated audio
|
# Local cache directory for generated audio
|
||||||
AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache")
|
AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache")
|
||||||
|
|
||||||
|
# Abbreviations expanded before TTS (so the speaker says the full word)
|
||||||
|
_TTS_EXPANSIONS = {
|
||||||
|
"sth.": "something",
|
||||||
|
"sth": "something",
|
||||||
|
"sb.": "somebody",
|
||||||
|
"sb": "somebody",
|
||||||
|
"smth.": "something",
|
||||||
|
"smb.": "somebody",
|
||||||
|
"sbd.": "somebody",
|
||||||
|
"etc.": "etcetera",
|
||||||
|
"e.g.": "for example",
|
||||||
|
"i.e.": "that is",
|
||||||
|
"esp.": "especially",
|
||||||
|
"approx.": "approximately",
|
||||||
|
"vs.": "versus",
|
||||||
|
"nr.": "number",
|
||||||
|
"no.": "number",
|
||||||
|
"p.": "page",
|
||||||
|
"adj.": "adjective",
|
||||||
|
"adv.": "adverb",
|
||||||
|
"prep.": "preposition",
|
||||||
|
"pron.": "pronoun",
|
||||||
|
"pl.": "plural",
|
||||||
|
"sg.": "singular",
|
||||||
|
"syn.": "synonym",
|
||||||
|
"ant.": "antonym",
|
||||||
|
# DE
|
||||||
|
"usw.": "und so weiter",
|
||||||
|
"bzw.": "beziehungsweise",
|
||||||
|
"z.B.": "zum Beispiel",
|
||||||
|
"d.h.": "das heisst",
|
||||||
|
"vgl.": "vergleiche",
|
||||||
|
"ca.": "circa",
|
||||||
|
"evtl.": "eventuell",
|
||||||
|
"ggf.": "gegebenenfalls",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_abbreviations(text: str) -> str:
|
||||||
|
"""Expand abbreviations so TTS speaks the full word."""
|
||||||
|
import re
|
||||||
|
for abbr, full in _TTS_EXPANSIONS.items():
|
||||||
|
# Word-boundary aware replacement (case-insensitive)
|
||||||
|
pattern = re.escape(abbr)
|
||||||
|
text = re.sub(rf'\b{pattern}', full, text, flags=re.IGNORECASE)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _ensure_cache_dir():
|
def _ensure_cache_dir():
|
||||||
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
||||||
@@ -56,48 +103,17 @@ async def synthesize_word(
|
|||||||
if os.path.exists(cached):
|
if os.path.exists(cached):
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Call Piper TTS service
|
# Expand abbreviations before speaking
|
||||||
|
speak_text = _expand_abbreviations(text)
|
||||||
|
|
||||||
|
# Call Piper TTS service via /synthesize-direct (returns MP3, selects language correctly)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{TTS_SERVICE_URL}/synthesize",
|
f"{TTS_SERVICE_URL}/synthesize-direct",
|
||||||
json={
|
json={
|
||||||
"text": text,
|
"text": speak_text,
|
||||||
"language": language,
|
"language": language,
|
||||||
"voice": "thorsten-high" if language == "de" else "lessac-high",
|
|
||||||
"module_id": "vocabulary",
|
|
||||||
"content_id": word_id or _cache_key(text, language),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning(f"TTS service returned {resp.status_code} for '{text}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
audio_url = data.get("audio_url") or data.get("presigned_url")
|
|
||||||
|
|
||||||
if audio_url:
|
|
||||||
# Download the audio file
|
|
||||||
audio_resp = await client.get(audio_url)
|
|
||||||
if audio_resp.status_code == 200:
|
|
||||||
with open(cached, "wb") as f:
|
|
||||||
f.write(audio_resp.content)
|
|
||||||
logger.info(f"TTS cached: '{text}' ({language}) → {cached}")
|
|
||||||
return cached
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"TTS service unavailable: {e}")
|
|
||||||
|
|
||||||
# Fallback: try direct MP3 endpoint
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{TTS_SERVICE_URL}/synthesize/mp3",
|
|
||||||
json={
|
|
||||||
"text": text,
|
|
||||||
"language": language,
|
|
||||||
"voice": "thorsten-high" if language == "de" else "lessac-high",
|
|
||||||
"module_id": "vocabulary",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"):
|
if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"):
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Image Service — Fetches vocabulary images from Wikipedia + Emoji fallback.
|
||||||
|
|
||||||
|
On-demand: Images are fetched when a learning unit is created,
|
||||||
|
then cached in the vocabulary_words.image_url field.
|
||||||
|
|
||||||
|
Sources (in priority order):
|
||||||
|
1. Wikipedia REST API (free, no account needed, CC license)
|
||||||
|
2. Emoji fallback for abstract words
|
||||||
|
|
||||||
|
Later: Unsplash API (needs account), Stable Diffusion (local batch)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Emoji map for common abstract words that don't have good photos
|
||||||
|
EMOJI_FALLBACK: dict[str, str] = {
|
||||||
|
"strong": "💪", "weak": "😩", "hard-working": "📚", "skinny": "🦴",
|
||||||
|
"female": "👩", "male": "👨", "definite": "✅", "definitely": "✅",
|
||||||
|
"even": "⚖️", "violent": "⚡", "opinion": "💭", "message": "💬",
|
||||||
|
"beginning": "🏁", "mention": "🗣️", "summarize": "📋", "mark": "✏️",
|
||||||
|
"throw": "🤾", "take": "🤲", "sum": "➕", "on the one hand": "👐",
|
||||||
|
"apple": "🍎", "gym": "🏋️", "medal": "🏅", "sportswoman": "🏃♀️",
|
||||||
|
"role model": "⭐", "tourist office": "🏨", "the olympics": "🏅",
|
||||||
|
"box": "🥊", "football": "⚽", "footballer": "⚽",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_wikipedia_image(word: str) -> Optional[str]:
|
||||||
|
"""Fetch thumbnail image URL from Wikipedia for a word."""
|
||||||
|
# Clean word for Wikipedia lookup
|
||||||
|
query = word.split(",")[0].strip() # "throw, threw, thrown" → "throw"
|
||||||
|
query = query.replace("sth.", "").replace("sb.", "").strip()
|
||||||
|
if query.startswith("the "):
|
||||||
|
query = query[4:]
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"https://en.wikipedia.org/api/rest_v1/page/summary/{query}",
|
||||||
|
headers={"User-Agent": "BreakPilot/1.0 (https://breakpilot.com; education platform; contact@breakpilot.com)"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
thumb = data.get("thumbnail", {})
|
||||||
|
url = thumb.get("source")
|
||||||
|
if url:
|
||||||
|
logger.info(f"Wikipedia image for '{word}': {url}")
|
||||||
|
return url
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Wikipedia image lookup failed for '{word}': {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_emoji_for_word(word: str) -> str:
|
||||||
|
"""Get an emoji representation for a word."""
|
||||||
|
lower = word.lower()
|
||||||
|
for key, emoji in EMOJI_FALLBACK.items():
|
||||||
|
if key in lower:
|
||||||
|
return emoji
|
||||||
|
# Generic fallback by part of speech could be added here
|
||||||
|
return "📝"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_image_for_word(word: str) -> str:
|
||||||
|
"""Get the best available image for a vocabulary word.
|
||||||
|
|
||||||
|
Returns a URL (Wikipedia) or emoji string.
|
||||||
|
Result should be stored in vocabulary_words.image_url.
|
||||||
|
"""
|
||||||
|
# Try Wikipedia first
|
||||||
|
url = await fetch_wikipedia_image(word)
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
|
||||||
|
# Fallback to emoji
|
||||||
|
return get_emoji_for_word(word)
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_words_with_images(word_ids: list[str]) -> int:
|
||||||
|
"""Fetch and store images for vocabulary words that don't have one yet."""
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
updated = 0
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT id, english, image_url FROM vocabulary_words WHERE id = ANY($1::uuid[])",
|
||||||
|
[uuid.UUID(wid) for wid in word_ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row["image_url"]:
|
||||||
|
continue # Already has an image
|
||||||
|
|
||||||
|
image = await get_image_for_word(row["english"])
|
||||||
|
if image:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE vocabulary_words SET image_url = $1 WHERE id = $2",
|
||||||
|
image, row["id"],
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
logger.info(f"Image for '{row['english']}': {image[:60]}...")
|
||||||
|
|
||||||
|
logger.info(f"Enriched {updated} words with images")
|
||||||
|
return updated
|
||||||
@@ -113,7 +113,7 @@ async def translate_and_store(
|
|||||||
|
|
||||||
Returns count of newly translated words.
|
Returns count of newly translated words.
|
||||||
"""
|
"""
|
||||||
from vocabulary_db import get_pool
|
from vocabulary.db import get_pool
|
||||||
|
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to dashboard/analytics.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("dashboard.analytics")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to dashboard/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("dashboard.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to dashboard/models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("dashboard.models")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/analytics_api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.analytics_api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/analytics_export.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.analytics_export")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/analytics_helpers.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.analytics_helpers")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/analytics_models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.analytics_models")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/analytics_routes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.analytics_routes")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/content_routes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.content_routes")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/definition_routes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.definition_routes")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/helpers.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.helpers")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.models")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to units/routes.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("units.routes")
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
# - unit_content_routes.py (H5P, worksheet, PDF routes)
|
# - unit_content_routes.py (H5P, worksheet, PDF routes)
|
||||||
#
|
#
|
||||||
# The `router` object is assembled here by including all sub-routers.
|
# The `router` object is assembled here by including all sub-routers.
|
||||||
# Importers that did `from unit_api import router` continue to work.
|
# Importers that did `from units.api import router` continue to work.
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ def api_generate_story(unit_id: str, payload: StoryGeneratePayload):
|
|||||||
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
|
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from story_generator import generate_story
|
from services.story_generator import generate_story
|
||||||
result = generate_story(
|
result = generate_story(
|
||||||
vocabulary=payload.vocabulary,
|
vocabulary=payload.vocabulary,
|
||||||
language=payload.language,
|
language=payload.language,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Vocabulary Module
|
||||||
|
# vocabulary/api.py — API router (search, browse, import, translate)
|
||||||
|
# vocabulary/db.py — PostgreSQL storage for vocabulary word catalog
|
||||||
|
|
||||||
|
from .api import router
|
||||||
|
from .db import (
|
||||||
|
VocabularyWord,
|
||||||
|
get_pool,
|
||||||
|
init_vocabulary_tables,
|
||||||
|
search_words,
|
||||||
|
get_word,
|
||||||
|
browse_words,
|
||||||
|
insert_word,
|
||||||
|
insert_words_bulk,
|
||||||
|
count_words,
|
||||||
|
get_all_tags,
|
||||||
|
get_all_pos,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"router",
|
||||||
|
"VocabularyWord",
|
||||||
|
"get_pool",
|
||||||
|
"init_vocabulary_tables",
|
||||||
|
"search_words",
|
||||||
|
"get_word",
|
||||||
|
"browse_words",
|
||||||
|
"insert_word",
|
||||||
|
"insert_words_bulk",
|
||||||
|
"count_words",
|
||||||
|
"get_all_tags",
|
||||||
|
"get_all_pos",
|
||||||
|
]
|
||||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from vocabulary_db import (
|
from .db import (
|
||||||
search_words,
|
search_words,
|
||||||
get_word,
|
get_word,
|
||||||
browse_words,
|
browse_words,
|
||||||
@@ -22,11 +22,6 @@ from vocabulary_db import (
|
|||||||
get_all_pos,
|
get_all_pos,
|
||||||
VocabularyWord,
|
VocabularyWord,
|
||||||
)
|
)
|
||||||
from learning_units import (
|
|
||||||
LearningUnitCreate,
|
|
||||||
create_learning_unit,
|
|
||||||
get_learning_unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,14 +36,22 @@ router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
|
|||||||
@router.get("/search")
|
@router.get("/search")
|
||||||
async def api_search_words(
|
async def api_search_words(
|
||||||
q: str = Query("", description="Search query"),
|
q: str = Query("", description="Search query"),
|
||||||
lang: str = Query("en", pattern="^(en|de)$"),
|
lang: str = Query("en"),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
|
source: str = Query("kaikki", description="Source: kaikki (6M words) or manual (27 words)"),
|
||||||
):
|
):
|
||||||
"""Full-text search for vocabulary words."""
|
"""Full-text search for vocabulary words.
|
||||||
|
|
||||||
|
source=kaikki searches the 6.27M Kaikki/Wiktionary dictionary.
|
||||||
|
source=manual searches the manually curated vocabulary_words table.
|
||||||
|
"""
|
||||||
if not q.strip():
|
if not q.strip():
|
||||||
return {"words": [], "query": q, "total": 0}
|
return {"words": [], "query": q, "total": 0}
|
||||||
|
|
||||||
|
if source == "kaikki":
|
||||||
|
return await _search_kaikki(q.strip(), lang, limit, offset)
|
||||||
|
|
||||||
words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset)
|
words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset)
|
||||||
return {
|
return {
|
||||||
"words": [w.to_dict() for w in words],
|
"words": [w.to_dict() for w in words],
|
||||||
@@ -57,6 +60,77 @@ async def api_search_words(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_kaikki(q: str, lang: str, limit: int, offset: int):
|
||||||
|
"""Search the vocabulary_kaikki table (6.27M Wiktionary entries)."""
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id, word, lang, pos, ipa, translations, example
|
||||||
|
FROM vocabulary_kaikki
|
||||||
|
WHERE lang = $1 AND lower(word) LIKE $2
|
||||||
|
ORDER BY length(word), lower(word)
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
""",
|
||||||
|
lang, f"{q.lower()}%", limit, offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
words = []
|
||||||
|
for r in rows:
|
||||||
|
tr = r["translations"]
|
||||||
|
if isinstance(tr, str):
|
||||||
|
import json as _json
|
||||||
|
tr = _json.loads(tr)
|
||||||
|
|
||||||
|
en_word = ""
|
||||||
|
en_ipa = ""
|
||||||
|
|
||||||
|
if r["lang"] == "en":
|
||||||
|
en_word = r["word"]
|
||||||
|
en_ipa = r["ipa"] or ""
|
||||||
|
else:
|
||||||
|
# Non-EN entries have empty translations — enrich from EN via reverse lookup
|
||||||
|
if not tr or len(tr) < 3:
|
||||||
|
async with pool.acquire() as conn2:
|
||||||
|
en_row = await conn2.fetchrow(
|
||||||
|
"""SELECT word, ipa, translations FROM vocabulary_kaikki
|
||||||
|
WHERE lang = 'en' AND translations->'%s'->>'text' ILIKE $1
|
||||||
|
ORDER BY length(word) LIMIT 1""" % lang,
|
||||||
|
r["word"],
|
||||||
|
)
|
||||||
|
if en_row:
|
||||||
|
en_word = en_row["word"]
|
||||||
|
en_ipa = en_row["ipa"] or ""
|
||||||
|
en_tr = en_row["translations"]
|
||||||
|
if isinstance(en_tr, str):
|
||||||
|
en_tr = _json.loads(en_tr)
|
||||||
|
tr = en_tr
|
||||||
|
|
||||||
|
words.append({
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"english": en_word if r["lang"] != "en" else r["word"],
|
||||||
|
"german": tr.get("de", {}).get("text", "") if r["lang"] != "de" else r["word"],
|
||||||
|
"word": r["word"],
|
||||||
|
"lang": r["lang"],
|
||||||
|
"ipa_en": en_ipa if r["lang"] != "en" else (r["ipa"] or ""),
|
||||||
|
"ipa_de": r["ipa"] if r["lang"] == "de" else "",
|
||||||
|
"part_of_speech": r["pos"],
|
||||||
|
"syllables_en": [],
|
||||||
|
"syllables_de": [],
|
||||||
|
"example_en": r["example"] if r["lang"] == "en" else "",
|
||||||
|
"example_de": r["example"] if r["lang"] == "de" else "",
|
||||||
|
"image_url": "",
|
||||||
|
"audio_url_en": "",
|
||||||
|
"audio_url_de": "",
|
||||||
|
"difficulty": 0,
|
||||||
|
"tags": [],
|
||||||
|
"translations": tr,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"words": words, "query": q, "total": len(words), "source": "kaikki"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse")
|
@router.get("/browse")
|
||||||
async def api_browse_words(
|
async def api_browse_words(
|
||||||
pos: str = Query("", description="Part of speech filter"),
|
pos: str = Query("", description="Part of speech filter"),
|
||||||
@@ -92,10 +166,13 @@ async def api_get_filters():
|
|||||||
tags = await get_all_tags()
|
tags = await get_all_tags()
|
||||||
pos_list = await get_all_pos()
|
pos_list = await get_all_pos()
|
||||||
total = await count_words()
|
total = await count_words()
|
||||||
|
# Kaikki stats (hardcoded to avoid slow COUNT on 6M rows)
|
||||||
return {
|
return {
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"parts_of_speech": pos_list,
|
"parts_of_speech": pos_list,
|
||||||
"total_words": total,
|
"total_words": total,
|
||||||
|
"kaikki_total": 6271749,
|
||||||
|
"kaikki_languages": 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -121,7 +198,7 @@ async def api_get_word_audio(word_id: str, lang: str = "en"):
|
|||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'")
|
raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'")
|
||||||
|
|
||||||
from audio_service import get_or_generate_audio
|
from services.audio import get_or_generate_audio
|
||||||
audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id)
|
audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id)
|
||||||
|
|
||||||
if not audio_bytes:
|
if not audio_bytes:
|
||||||
@@ -151,7 +228,7 @@ async def api_get_syllable_audio(word_id: str, lang: str = "en"):
|
|||||||
# Join syllables with pauses (Piper handles "..." as pause)
|
# Join syllables with pauses (Piper handles "..." as pause)
|
||||||
slow_text = " ... ".join(syllables)
|
slow_text = " ... ".join(syllables)
|
||||||
|
|
||||||
from audio_service import get_or_generate_audio
|
from services.audio import get_or_generate_audio
|
||||||
cache_key = f"{word_id}_syl_{lang}"
|
cache_key = f"{word_id}_syl_{lang}"
|
||||||
audio_bytes = await get_or_generate_audio(slow_text, language=lang, word_id=cache_key)
|
audio_bytes = await get_or_generate_audio(slow_text, language=lang, word_id=cache_key)
|
||||||
|
|
||||||
@@ -161,128 +238,28 @@ async def api_get_syllable_audio(word_id: str, lang: str = "en"):
|
|||||||
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tts")
|
||||||
|
async def api_tts(text: str = Query("", min_length=1), lang: str = Query("de")):
|
||||||
|
"""Text-to-Speech endpoint. Returns MP3 audio for any text.
|
||||||
|
|
||||||
|
Uses Piper TTS (Thorsten DE / Lessac EN). Cached by text+lang.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import Response as FastAPIResponse
|
||||||
|
from services.audio import get_or_generate_audio
|
||||||
|
|
||||||
|
audio_bytes = await get_or_generate_audio(text, language=lang)
|
||||||
|
if not audio_bytes:
|
||||||
|
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
|
||||||
|
|
||||||
|
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Learning Unit Creation from Word Selection
|
# Learning Unit Creation from Word Selection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class CreateUnitFromWordsPayload(BaseModel):
|
# Unit creation and translation lookup moved to vocabulary/unit_api.py
|
||||||
title: str
|
|
||||||
word_ids: List[str]
|
|
||||||
grade: Optional[str] = None
|
|
||||||
language: Optional[str] = "de"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/units")
|
|
||||||
async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload):
|
|
||||||
"""Create a learning unit from selected vocabulary word IDs.
|
|
||||||
|
|
||||||
Fetches full word details, creates a LearningUnit in the
|
|
||||||
learning_units system, and stores the vocabulary data.
|
|
||||||
"""
|
|
||||||
if not payload.word_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
|
|
||||||
|
|
||||||
# Fetch all selected words
|
|
||||||
words = []
|
|
||||||
for wid in payload.word_ids:
|
|
||||||
word = await get_word(wid)
|
|
||||||
if word:
|
|
||||||
words.append(word)
|
|
||||||
|
|
||||||
if not words:
|
|
||||||
raise HTTPException(status_code=404, detail="Keine der Woerter gefunden")
|
|
||||||
|
|
||||||
# Create learning unit
|
|
||||||
lu = create_learning_unit(LearningUnitCreate(
|
|
||||||
title=payload.title,
|
|
||||||
topic="Vocabulary",
|
|
||||||
grade_level=payload.grade or "5-8",
|
|
||||||
language=payload.language or "de",
|
|
||||||
status="raw",
|
|
||||||
))
|
|
||||||
|
|
||||||
# Save vocabulary data as analysis JSON for generators
|
|
||||||
import os
|
|
||||||
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
|
|
||||||
os.makedirs(analysis_dir, exist_ok=True)
|
|
||||||
|
|
||||||
vocab_data = [w.to_dict() for w in words]
|
|
||||||
analysis_path = os.path.join(analysis_dir, f"{lu.id}_vocab.json")
|
|
||||||
with open(analysis_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
# Also save as QA items for flashcards/type trainer
|
|
||||||
qa_items = []
|
|
||||||
for i, w in enumerate(words):
|
|
||||||
qa_items.append({
|
|
||||||
"id": f"qa_{i+1}",
|
|
||||||
"question": w.english,
|
|
||||||
"answer": w.german,
|
|
||||||
"question_type": "knowledge",
|
|
||||||
"key_terms": [w.english],
|
|
||||||
"difficulty": w.difficulty,
|
|
||||||
"source_hint": w.part_of_speech,
|
|
||||||
"leitner_box": 0,
|
|
||||||
"correct_count": 0,
|
|
||||||
"incorrect_count": 0,
|
|
||||||
"last_seen": None,
|
|
||||||
"next_review": None,
|
|
||||||
# Extra fields for enhanced flashcards
|
|
||||||
"ipa_en": w.ipa_en,
|
|
||||||
"ipa_de": w.ipa_de,
|
|
||||||
"syllables_en": w.syllables_en,
|
|
||||||
"syllables_de": w.syllables_de,
|
|
||||||
"example_en": w.example_en,
|
|
||||||
"example_de": w.example_de,
|
|
||||||
"image_url": w.image_url,
|
|
||||||
"audio_url_en": w.audio_url_en,
|
|
||||||
"audio_url_de": w.audio_url_de,
|
|
||||||
"part_of_speech": w.part_of_speech,
|
|
||||||
"translations": w.translations,
|
|
||||||
})
|
|
||||||
|
|
||||||
qa_path = os.path.join(analysis_dir, f"{lu.id}_qa.json")
|
|
||||||
with open(qa_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump({
|
|
||||||
"qa_items": qa_items,
|
|
||||||
"metadata": {
|
|
||||||
"subject": "English Vocabulary",
|
|
||||||
"grade_level": payload.grade or "5-8",
|
|
||||||
"source_title": payload.title,
|
|
||||||
"total_questions": len(qa_items),
|
|
||||||
},
|
|
||||||
}, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
logger.info(f"Created vocab unit {lu.id} with {len(words)} words")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"unit_id": lu.id,
|
|
||||||
"title": payload.title,
|
|
||||||
"word_count": len(words),
|
|
||||||
"status": "created",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/units/{unit_id}")
|
|
||||||
async def api_get_unit_words(unit_id: str):
|
|
||||||
"""Get all words for a learning unit."""
|
|
||||||
import os
|
|
||||||
vocab_path = os.path.join(
|
|
||||||
os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten"),
|
|
||||||
f"{unit_id}_vocab.json",
|
|
||||||
)
|
|
||||||
if not os.path.exists(vocab_path):
|
|
||||||
raise HTTPException(status_code=404, detail="Unit nicht gefunden")
|
|
||||||
|
|
||||||
with open(vocab_path, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"title": data.get("title", ""),
|
|
||||||
"words": data.get("words", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -302,7 +279,7 @@ async def api_bulk_import(payload: BulkImportPayload):
|
|||||||
Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de,
|
Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de,
|
||||||
example_en, example_de, difficulty, tags, translations.
|
example_en, example_de, difficulty, tags, translations.
|
||||||
"""
|
"""
|
||||||
from vocabulary_db import insert_words_bulk
|
from .db import insert_words_bulk
|
||||||
|
|
||||||
words = []
|
words = []
|
||||||
for w in payload.words:
|
for w in payload.words:
|
||||||
@@ -331,6 +308,83 @@ async def api_bulk_import(payload: BulkImportPayload):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrich-images")
|
||||||
|
async def api_enrich_images(word_ids: List[str] = None):
|
||||||
|
"""Fetch and store images for vocabulary words (Wikipedia + emoji fallback)."""
|
||||||
|
from services.image_service import enrich_words_with_images
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
if not word_ids:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT id FROM vocabulary_words WHERE image_url = '' OR image_url IS NULL")
|
||||||
|
word_ids = [str(r["id"]) for r in rows]
|
||||||
|
|
||||||
|
if not word_ids:
|
||||||
|
return {"enriched": 0, "message": "All words already have images"}
|
||||||
|
|
||||||
|
count = await enrich_words_with_images(word_ids)
|
||||||
|
return {"enriched": count, "total": len(word_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/topics")
|
||||||
|
async def api_get_topics(
|
||||||
|
q: str = Query("", description="Search topic or word"),
|
||||||
|
lang: str = Query("en", description="Display language for word labels"),
|
||||||
|
):
|
||||||
|
"""Find topics matching a search word. Returns related word lists.
|
||||||
|
|
||||||
|
If q matches a topic name → returns that topic.
|
||||||
|
If q matches a word in any topic → returns all topics containing that word.
|
||||||
|
Words are returned with translations if lang != en.
|
||||||
|
"""
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if not q.strip():
|
||||||
|
rows = await conn.fetch("SELECT topic, words, word_count FROM vocabulary_topics ORDER BY topic LIMIT 50")
|
||||||
|
else:
|
||||||
|
q_lower = q.strip().lower()
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT topic, words, word_count FROM vocabulary_topics
|
||||||
|
WHERE lower(topic) LIKE $1 OR $2 = ANY(words)
|
||||||
|
ORDER BY word_count DESC
|
||||||
|
""", f"%{q_lower}%", q_lower)
|
||||||
|
|
||||||
|
# Translate word labels if not English
|
||||||
|
topics = []
|
||||||
|
for r in rows:
|
||||||
|
en_words = list(r["words"])
|
||||||
|
display_words = en_words
|
||||||
|
if lang != "en":
|
||||||
|
# Batch-lookup translations from Kaikki
|
||||||
|
translated = []
|
||||||
|
for w in en_words[:20]: # Limit to 20 for speed
|
||||||
|
tr_row = await conn.fetchrow(
|
||||||
|
"SELECT translations FROM vocabulary_kaikki WHERE lang = 'en' AND lower(word) = $1 LIMIT 1",
|
||||||
|
w.lower(),
|
||||||
|
)
|
||||||
|
if tr_row and tr_row["translations"]:
|
||||||
|
import json as _json
|
||||||
|
tr = tr_row["translations"]
|
||||||
|
if isinstance(tr, str):
|
||||||
|
tr = _json.loads(tr)
|
||||||
|
tr_text = tr.get(lang, {}).get("text", "")
|
||||||
|
translated.append(tr_text if tr_text else w)
|
||||||
|
else:
|
||||||
|
translated.append(w)
|
||||||
|
display_words = translated + en_words[20:]
|
||||||
|
topics.append({
|
||||||
|
"topic": r["topic"],
|
||||||
|
"words": en_words,
|
||||||
|
"display_words": display_words,
|
||||||
|
"word_count": r["word_count"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"topics": topics, "query": q, "lang": lang}
|
||||||
|
|
||||||
|
|
||||||
class TranslateRequest(BaseModel):
|
class TranslateRequest(BaseModel):
|
||||||
word_ids: List[str]
|
word_ids: List[str]
|
||||||
target_language: str
|
target_language: str
|
||||||
@@ -343,7 +397,7 @@ async def api_translate_words(payload: TranslateRequest):
|
|||||||
Uses local LLM (Ollama) for translation. Results are cached in the
|
Uses local LLM (Ollama) for translation. Results are cached in the
|
||||||
vocabulary_words.translations JSONB field.
|
vocabulary_words.translations JSONB field.
|
||||||
"""
|
"""
|
||||||
from translation_service import translate_and_store
|
from services.translation import translate_and_store
|
||||||
|
|
||||||
if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}:
|
if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}:
|
||||||
raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt")
|
raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt")
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""
|
||||||
|
Vocabulary Unit API — Create learning units, translate words, manage language pairs.
|
||||||
|
|
||||||
|
Endpoints for teachers to build vocabulary learning units with custom words,
|
||||||
|
auto-translation via Kaikki dictionary, and flexible language pair support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .db import get_word, VocabularyWord, get_pool
|
||||||
|
from units.learning import LearningUnitCreate, create_learning_unit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
|
||||||
|
|
||||||
|
# All supported language codes
|
||||||
|
SUPPORTED_LANGS = {
|
||||||
|
"en", "de", "fr", "es", "it", "pt", "nl", "tr", "ru", "ar",
|
||||||
|
"uk", "pl", "sv", "fi", "da", "ro", "el", "hu", "cs", "bg",
|
||||||
|
"lv", "lt", "sk", "et", "sl", "hr",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Translation Lookup (auto-suggest)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lookup-translation")
|
||||||
|
async def api_lookup_translation(
|
||||||
|
word: str = Query("", min_length=1, description="Word to translate"),
|
||||||
|
source: str = Query("en", description="Source language code"),
|
||||||
|
target: str = Query("de", description="Target language code"),
|
||||||
|
limit: int = Query(5, ge=1, le=20),
|
||||||
|
):
|
||||||
|
"""Look up translations between any two languages via Kaikki dictionary.
|
||||||
|
|
||||||
|
Uses EN entries as a hub: all EN words have translations to 24 languages.
|
||||||
|
- EN → X: direct lookup (word in EN, translation from JSONB)
|
||||||
|
- X → EN: reverse lookup (search EN entries where translations.X matches)
|
||||||
|
- X → Y: bridge via EN (find EN word via X, then get Y translation)
|
||||||
|
"""
|
||||||
|
if source not in SUPPORTED_LANGS or target not in SUPPORTED_LANGS:
|
||||||
|
raise HTTPException(status_code=400, detail="Sprache nicht unterstuetzt")
|
||||||
|
if source == target:
|
||||||
|
return {"results": [], "word": word, "source": source, "target": target}
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
q = word.strip()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if source == "en":
|
||||||
|
# Direct: search EN word, return target translation
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT word, pos, ipa, translations
|
||||||
|
FROM vocabulary_kaikki
|
||||||
|
WHERE lang = 'en' AND lower(word) LIKE $1
|
||||||
|
ORDER BY length(word), lower(word)
|
||||||
|
LIMIT $2""",
|
||||||
|
f"{q.lower()}%", limit,
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
tr = _parse_translations(r["translations"])
|
||||||
|
target_text = tr.get(target, {}).get("text", "")
|
||||||
|
if target_text:
|
||||||
|
results.append({
|
||||||
|
"source_text": r["word"],
|
||||||
|
"target_text": target_text,
|
||||||
|
"pos": r["pos"],
|
||||||
|
"ipa": r["ipa"] or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
elif target == "en":
|
||||||
|
# Reverse: search EN entries where translations.source matches
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT word, pos, ipa, translations->'%s'->>'text' as src_text
|
||||||
|
FROM vocabulary_kaikki
|
||||||
|
WHERE lang = 'en'
|
||||||
|
AND translations->'%s'->>'text' ILIKE $1
|
||||||
|
ORDER BY length(word)
|
||||||
|
LIMIT $2""" % (source, source),
|
||||||
|
f"{q}%", limit,
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
results.append({
|
||||||
|
"source_text": r["src_text"],
|
||||||
|
"target_text": r["word"],
|
||||||
|
"pos": r["pos"],
|
||||||
|
"ipa": r["ipa"] or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Bridge via EN: find EN word via source, then get target translation
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT word, pos, ipa, translations
|
||||||
|
FROM vocabulary_kaikki
|
||||||
|
WHERE lang = 'en'
|
||||||
|
AND translations->'%s'->>'text' ILIKE $1
|
||||||
|
ORDER BY length(word)
|
||||||
|
LIMIT $2""" % source,
|
||||||
|
f"{q}%", limit,
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
tr = _parse_translations(r["translations"])
|
||||||
|
src_text = tr.get(source, {}).get("text", "")
|
||||||
|
target_text = tr.get(target, {}).get("text", "")
|
||||||
|
if src_text and target_text:
|
||||||
|
results.append({
|
||||||
|
"source_text": src_text,
|
||||||
|
"target_text": target_text,
|
||||||
|
"pos": r["pos"],
|
||||||
|
"ipa": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"results": results, "word": q, "source": source, "target": target}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_translations(tr) -> dict:
|
||||||
|
"""Parse translations field (may be JSONB dict or JSON string)."""
|
||||||
|
if isinstance(tr, str):
|
||||||
|
return json.loads(tr)
|
||||||
|
return tr or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit Creation (with custom words + language pair)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CustomWord(BaseModel):
|
||||||
|
source_text: str
|
||||||
|
target_text: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUnitPayload(BaseModel):
|
||||||
|
title: str
|
||||||
|
word_ids: List[str] = []
|
||||||
|
custom_words: List[CustomWord] = []
|
||||||
|
source_lang: str = "en"
|
||||||
|
target_lang: str = "de"
|
||||||
|
grade: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/units")
|
||||||
|
async def api_create_unit_from_words(payload: CreateUnitPayload):
|
||||||
|
"""Create a learning unit from dictionary words and/or custom word pairs.
|
||||||
|
|
||||||
|
Supports any language pair. Words can come from:
|
||||||
|
1. word_ids — looked up in Kaikki dictionary
|
||||||
|
2. custom_words — manually entered source/target pairs
|
||||||
|
"""
|
||||||
|
if not payload.word_ids and not payload.custom_words:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
|
||||||
|
|
||||||
|
qa_items = []
|
||||||
|
vocab_data = []
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
# 1. Process dictionary words
|
||||||
|
for wid in payload.word_ids:
|
||||||
|
word = await get_word(wid)
|
||||||
|
if not word:
|
||||||
|
# Try Kaikki lookup
|
||||||
|
kaikki_word = await _get_kaikki_word(wid, payload.source_lang, payload.target_lang)
|
||||||
|
if kaikki_word:
|
||||||
|
qa_items.append(_make_qa_item(idx, kaikki_word, payload.source_lang, payload.target_lang))
|
||||||
|
vocab_data.append(kaikki_word)
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
# Manual vocabulary_words entry
|
||||||
|
source_text, target_text = _get_word_pair(word, payload.source_lang, payload.target_lang)
|
||||||
|
qa_items.append({
|
||||||
|
"id": f"qa_{idx+1}",
|
||||||
|
"question": source_text,
|
||||||
|
"answer": target_text,
|
||||||
|
"question_type": "knowledge",
|
||||||
|
"key_terms": [source_text],
|
||||||
|
"difficulty": word.difficulty,
|
||||||
|
"source_hint": word.part_of_speech,
|
||||||
|
"leitner_box": 0,
|
||||||
|
"correct_count": 0,
|
||||||
|
"incorrect_count": 0,
|
||||||
|
"last_seen": None,
|
||||||
|
"next_review": None,
|
||||||
|
"ipa_en": word.ipa_en,
|
||||||
|
"ipa_de": word.ipa_de,
|
||||||
|
"syllables_en": word.syllables_en,
|
||||||
|
"syllables_de": word.syllables_de,
|
||||||
|
"example_en": word.example_en,
|
||||||
|
"example_de": word.example_de,
|
||||||
|
"image_url": word.image_url,
|
||||||
|
"audio_url_en": word.audio_url_en,
|
||||||
|
"audio_url_de": word.audio_url_de,
|
||||||
|
"part_of_speech": word.part_of_speech,
|
||||||
|
"translations": word.translations,
|
||||||
|
})
|
||||||
|
vocab_data.append(word.to_dict())
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
# 2. Process custom words (manually entered by teacher)
|
||||||
|
for cw in payload.custom_words:
|
||||||
|
qa_items.append({
|
||||||
|
"id": f"qa_{idx+1}",
|
||||||
|
"question": cw.source_text,
|
||||||
|
"answer": cw.target_text,
|
||||||
|
"question_type": "knowledge",
|
||||||
|
"key_terms": [cw.source_text],
|
||||||
|
"difficulty": 1,
|
||||||
|
"source_hint": "",
|
||||||
|
"leitner_box": 0,
|
||||||
|
"correct_count": 0,
|
||||||
|
"incorrect_count": 0,
|
||||||
|
"last_seen": None,
|
||||||
|
"next_review": None,
|
||||||
|
"part_of_speech": "",
|
||||||
|
"translations": {},
|
||||||
|
})
|
||||||
|
vocab_data.append({
|
||||||
|
"english": cw.source_text if payload.source_lang == "en" else cw.target_text if payload.target_lang == "en" else "",
|
||||||
|
"german": cw.source_text if payload.source_lang == "de" else cw.target_text if payload.target_lang == "de" else "",
|
||||||
|
"word": cw.source_text,
|
||||||
|
"translation": cw.target_text,
|
||||||
|
"source_lang": payload.source_lang,
|
||||||
|
"target_lang": payload.target_lang,
|
||||||
|
})
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if not qa_items:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine gültigen Woerter")
|
||||||
|
|
||||||
|
# Create learning unit
|
||||||
|
lang_label = f"{payload.source_lang.upper()}→{payload.target_lang.upper()}"
|
||||||
|
lu = create_learning_unit(LearningUnitCreate(
|
||||||
|
title=payload.title,
|
||||||
|
topic="Vocabulary",
|
||||||
|
grade_level=payload.grade or "5-8",
|
||||||
|
language=payload.target_lang,
|
||||||
|
status="raw",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Save files
|
||||||
|
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
|
||||||
|
os.makedirs(analysis_dir, exist_ok=True)
|
||||||
|
|
||||||
|
with open(os.path.join(analysis_dir, f"{lu.id}_vocab.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
with open(os.path.join(analysis_dir, f"{lu.id}_qa.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({
|
||||||
|
"qa_items": qa_items,
|
||||||
|
"metadata": {
|
||||||
|
"subject": f"Vocabulary {lang_label}",
|
||||||
|
"grade_level": payload.grade or "5-8",
|
||||||
|
"source_title": payload.title,
|
||||||
|
"total_questions": len(qa_items),
|
||||||
|
"source_lang": payload.source_lang,
|
||||||
|
"target_lang": payload.target_lang,
|
||||||
|
},
|
||||||
|
}, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Auto-enrich images for dictionary words
|
||||||
|
dict_ids = [wid for wid in payload.word_ids]
|
||||||
|
if dict_ids:
|
||||||
|
try:
|
||||||
|
from services.image_service import enrich_words_with_images
|
||||||
|
await enrich_words_with_images(dict_ids)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Image enrichment failed (non-critical): {e}")
|
||||||
|
|
||||||
|
logger.info(f"Created vocab unit {lu.id} ({lang_label}) with {len(qa_items)} words")
|
||||||
|
return {
|
||||||
|
"unit_id": lu.id,
|
||||||
|
"title": payload.title,
|
||||||
|
"word_count": len(qa_items),
|
||||||
|
"source_lang": payload.source_lang,
|
||||||
|
"target_lang": payload.target_lang,
|
||||||
|
"status": "created",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_word_pair(word: VocabularyWord, source_lang: str, target_lang: str):
|
||||||
|
"""Extract source/target text from a VocabularyWord for the given language pair."""
|
||||||
|
lang_map = {"en": word.english, "de": word.german}
|
||||||
|
# Check translations for other languages
|
||||||
|
if source_lang not in lang_map:
|
||||||
|
tr = word.translations or {}
|
||||||
|
lang_map[source_lang] = tr.get(source_lang, {}).get("text", word.english)
|
||||||
|
if target_lang not in lang_map:
|
||||||
|
tr = word.translations or {}
|
||||||
|
lang_map[target_lang] = tr.get(target_lang, {}).get("text", word.german)
|
||||||
|
return lang_map.get(source_lang, word.english), lang_map.get(target_lang, word.german)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_kaikki_word(word_id: str, source_lang: str, target_lang: str) -> Optional[dict]:
|
||||||
|
"""Look up a word by ID in the Kaikki table and return a vocab dict."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT id, word, lang, pos, ipa, translations, example FROM vocabulary_kaikki WHERE id = $1",
|
||||||
|
_to_uuid(word_id),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
tr = _parse_translations(row["translations"])
|
||||||
|
src = row["word"] if row["lang"] == source_lang else tr.get(source_lang, {}).get("text", "")
|
||||||
|
tgt = tr.get(target_lang, {}).get("text", "") if row["lang"] != target_lang else row["word"]
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"word": row["word"],
|
||||||
|
"lang": row["lang"],
|
||||||
|
"source_text": src or row["word"],
|
||||||
|
"target_text": tgt,
|
||||||
|
"pos": row["pos"],
|
||||||
|
"ipa": row["ipa"] or "",
|
||||||
|
"example": row["example"] or "",
|
||||||
|
"translations": tr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_qa_item(idx: int, kw: dict, source_lang: str, target_lang: str) -> dict:
|
||||||
|
"""Create a QA item from a Kaikki word dict."""
|
||||||
|
return {
|
||||||
|
"id": f"qa_{idx+1}",
|
||||||
|
"question": kw.get("source_text", kw.get("word", "")),
|
||||||
|
"answer": kw.get("target_text", ""),
|
||||||
|
"question_type": "knowledge",
|
||||||
|
"key_terms": [kw.get("source_text", kw.get("word", ""))],
|
||||||
|
"difficulty": 0,
|
||||||
|
"source_hint": kw.get("pos", ""),
|
||||||
|
"leitner_box": 0,
|
||||||
|
"correct_count": 0,
|
||||||
|
"incorrect_count": 0,
|
||||||
|
"last_seen": None,
|
||||||
|
"next_review": None,
|
||||||
|
"ipa_en": kw.get("ipa", "") if source_lang == "en" else "",
|
||||||
|
"ipa_de": kw.get("ipa", "") if source_lang == "de" else "",
|
||||||
|
"part_of_speech": kw.get("pos", ""),
|
||||||
|
"translations": kw.get("translations", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_uuid(s: str):
|
||||||
|
"""Convert string to UUID, return as-is if already valid."""
|
||||||
|
import uuid
|
||||||
|
try:
|
||||||
|
return uuid.UUID(s)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return s
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Worksheets Module
|
||||||
|
# worksheets/api.py — API router (generate MC, cloze, mindmap, quiz)
|
||||||
|
# worksheets/models.py — Pydantic models and helpers
|
||||||
|
|
||||||
|
from .api import router
|
||||||
|
from .models import (
|
||||||
|
ContentType,
|
||||||
|
GenerateRequest,
|
||||||
|
MCGenerateRequest,
|
||||||
|
ClozeGenerateRequest,
|
||||||
|
MindmapGenerateRequest,
|
||||||
|
QuizGenerateRequest,
|
||||||
|
BatchGenerateRequest,
|
||||||
|
WorksheetContent,
|
||||||
|
GenerateResponse,
|
||||||
|
BatchGenerateResponse,
|
||||||
|
parse_difficulty,
|
||||||
|
parse_cloze_type,
|
||||||
|
parse_quiz_types,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"router",
|
||||||
|
"ContentType",
|
||||||
|
"GenerateRequest",
|
||||||
|
"MCGenerateRequest",
|
||||||
|
"ClozeGenerateRequest",
|
||||||
|
"MindmapGenerateRequest",
|
||||||
|
"QuizGenerateRequest",
|
||||||
|
"BatchGenerateRequest",
|
||||||
|
"WorksheetContent",
|
||||||
|
"GenerateResponse",
|
||||||
|
"BatchGenerateResponse",
|
||||||
|
"parse_difficulty",
|
||||||
|
"parse_cloze_type",
|
||||||
|
"parse_quiz_types",
|
||||||
|
]
|
||||||
@@ -27,7 +27,7 @@ from generators import (
|
|||||||
QuizGenerator
|
QuizGenerator
|
||||||
)
|
)
|
||||||
|
|
||||||
from worksheets_models import (
|
from .models import (
|
||||||
ContentType,
|
ContentType,
|
||||||
GenerateRequest,
|
GenerateRequest,
|
||||||
MCGenerateRequest,
|
MCGenerateRequest,
|
||||||
@@ -20,6 +20,7 @@ volumes:
|
|||||||
transcription_models:
|
transcription_models:
|
||||||
transcription_temp:
|
transcription_temp:
|
||||||
lehrer_backend_data:
|
lehrer_backend_data:
|
||||||
|
lehrer_arbeitsblaetter:
|
||||||
opensearch_data:
|
opensearch_data:
|
||||||
# Communication (Jitsi + Matrix)
|
# Communication (Jitsi + Matrix)
|
||||||
synapse_data:
|
synapse_data:
|
||||||
@@ -108,8 +109,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
BACKEND_URL: http://backend-lehrer:8001
|
BACKEND_URL: http://backend-lehrer:8001
|
||||||
|
SCHOOL_SERVICE_URL: http://school-service:8084
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-lehrer
|
- backend-lehrer
|
||||||
|
- school-service
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- breakpilot-network
|
- breakpilot-network
|
||||||
@@ -159,6 +162,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- lehrer_backend_data:/app/data
|
- lehrer_backend_data:/app/data
|
||||||
|
- lehrer_arbeitsblaetter:/root/Arbeitsblaetter
|
||||||
environment:
|
environment:
|
||||||
PORT: 8001
|
PORT: 8001
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dlehrer,core,public
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dlehrer,core,public
|
||||||
@@ -285,6 +289,26 @@ services:
|
|||||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||||
ALLOWED_ORIGINS: "*"
|
ALLOWED_ORIGINS: "*"
|
||||||
LLM_GATEWAY_URL: http://backend-lehrer:8001/llm
|
LLM_GATEWAY_URL: http://backend-lehrer:8001/llm
|
||||||
|
SOLVER_SERVICE_URL: http://timetable-solver-service:8095
|
||||||
|
depends_on:
|
||||||
|
core-health-check:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- breakpilot-network
|
||||||
|
|
||||||
|
timetable-solver-service:
|
||||||
|
build:
|
||||||
|
context: ./timetable-solver-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: bp-lehrer-timetable-solver
|
||||||
|
platform: linux/arm64
|
||||||
|
ports:
|
||||||
|
- "8095:8095"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}
|
||||||
|
SOLVER_SECONDS_LIMIT: ${SOLVER_SECONDS_LIMIT:-60}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
depends_on:
|
depends_on:
|
||||||
core-health-check:
|
core-health-check:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Architektur
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
### Phase 9a — Kalender-Stammdaten
|
||||||
|
|
||||||
|
| Tabelle | Inhalt | Owner |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| `cal_public_event` | Ferien + Feiertage (region, type, name, start, end) | global (alle Bundeslaender) |
|
||||||
|
| `cal_school_config` | Bundesland-Auswahl + Schuljahr-Daten | 1 row per user_id |
|
||||||
|
|
||||||
|
### Phase 9b — Schul-Events
|
||||||
|
|
||||||
|
| Tabelle | Inhalt | Owner |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| `cal_school_event` | Titel + Typ + Datum/Zeit + affected_class_ids + Notification-Flags | created_by_user_id |
|
||||||
|
|
||||||
|
Event-Typen (CHECK constraint): `fortbildung`, `schulfeier`, `klassenfahrt`, `projekttag`, `eltern_info`, `andere`.
|
||||||
|
|
||||||
|
### Phase 9c — Parent-Accounts
|
||||||
|
|
||||||
|
| Tabelle | Inhalt |
|
||||||
|
|---------|--------|
|
||||||
|
| `parent_account` | Email + preferred_language, UNIQUE pro (Lehrer, Email) |
|
||||||
|
| `parent_child` | Vorname/Nachname + FK auf tt_class |
|
||||||
|
| `parent_magic_link` | Einmal-Token (SHA-256 in DB), expires_at 7 Tage |
|
||||||
|
| `parent_session` | Browser-Session-Token (SHA-256 in DB), expires_at 30 Tage |
|
||||||
|
|
||||||
|
### Phase 9d — Notifications
|
||||||
|
|
||||||
|
| Tabelle | Inhalt |
|
||||||
|
|---------|--------|
|
||||||
|
| `notification_log` | Idempotenz: UNIQUE(event_id, lead_days, audience, channel) |
|
||||||
|
|
||||||
|
## Auth-Modell
|
||||||
|
|
||||||
|
**Zwei voneinander unabhaengige Auth-Wege:**
|
||||||
|
|
||||||
|
1. **Lehrer:** JWT in Authorization-Header (oder Dev-Bypass mit Default-User wenn `ENVIRONMENT != "production"`). Routen unter `/api/v1/school/...`.
|
||||||
|
2. **Eltern:** Session-Cookie `bp_parent_session` (HttpOnly, SameSite=Lax), gesetzt vom `/api/v1/parent/auth/redeem` Endpoint. ParentSessionMiddleware resolved Cookie → parent_account.
|
||||||
|
|
||||||
|
Eltern sehen **nie** Daten anderer Eltern. Privacy-Check via `ChildBelongsToParent` in jedem GET, Plus Filterung der Lessons gegen tt_solution des einladenden Lehrers.
|
||||||
|
|
||||||
|
## Bundesland-Wizard
|
||||||
|
|
||||||
|
Erster Aufruf von `/schulkalender` → kein `cal_school_config` → `BundeslandWizard` UI → POST `/calendar/config` mit `{bundesland: "DE-NI"}` → MonthView lädt für die naechsten ~6 Wochen.
|
||||||
|
|
||||||
|
## Schuljahres-Rollover
|
||||||
|
|
||||||
|
POST `/calendar/school-year-rollover` (optional `{new_year_start, new_year_end}`):
|
||||||
|
|
||||||
|
1. `DELETE FROM tt_class WHERE grade_level >= 13` (Abschlusskohorte)
|
||||||
|
2. `UPDATE tt_class SET grade_level = grade_level + 1`
|
||||||
|
3. `UPDATE cal_school_config SET school_year_start/end = ...`
|
||||||
|
|
||||||
|
Alles in einer Transaction. Stundenplan-Lehrer-Faecher-Raum-Bestand bleibt unangetastet.
|
||||||
|
|
||||||
|
## Auth + Messaging outsourced
|
||||||
|
|
||||||
|
Production-Auth, Matrix-Bridge und Email-Gateway werden vom Kollegen gepflegt — siehe globale Memory `stundenplan_auth_and_messaging.md`. Wir definieren nur:
|
||||||
|
|
||||||
|
- Dispatch-Payload-Struct (siehe [notifications.md](notifications.md))
|
||||||
|
- Env-Vars `MATRIX_SERVICE_URL`, `EMAIL_SERVICE_URL` (leer = Stub-Mode)
|
||||||
|
- Endpoint-Vertrag (POST mit JSON-Body, HTTP 2xx = sent)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Ferien + Feiertage
|
||||||
|
|
||||||
|
## Quelle
|
||||||
|
|
||||||
|
[openholidaysapi.org](https://openholidaysapi.org) — EU-Initiative, MIT-Lizenz
|
||||||
|
fuer den API-Code, ODbL fuer die Daten. Liefert sowohl `PublicHolidays` als
|
||||||
|
auch `SchoolHolidays` je Bundesland mit ISO-Codes `DE-BW`, `DE-BY`, ...
|
||||||
|
|
||||||
|
## Build-Time-Snapshot
|
||||||
|
|
||||||
|
Statt zur Laufzeit zu pollen wird ein JSON-Snapshot committed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/calendar-snapshot.sh 2026 2030
|
||||||
|
```
|
||||||
|
|
||||||
|
Schreibt nach `school-service/internal/seed/calendar_holidays.json`. Das
|
||||||
|
Dockerfile kopiert die Datei ins Image; bei jedem Container-Start importiert
|
||||||
|
`CalendarService.SeedFromSnapshot()` die Eintraege idempotent (UNIQUE auf
|
||||||
|
region, event_type, name_de, start_date).
|
||||||
|
|
||||||
|
**Stand 2026-05-22:** 854 Events fuer alle 16 Bundeslaender × 3 Schuljahre.
|
||||||
|
|
||||||
|
## Aktualisierungs-Workflow
|
||||||
|
|
||||||
|
1. Jaehrlich (z.B. im Mai vor neuem Schuljahr):
|
||||||
|
```bash
|
||||||
|
bash scripts/calendar-snapshot.sh 2027 2031
|
||||||
|
```
|
||||||
|
2. Diff im Git pruefen — sollte nur neue Eintraege haben, nicht alte ueberschreiben.
|
||||||
|
3. Commit + push + Container-Rebuild.
|
||||||
|
4. Beim ersten Boot werden neue Eintraege in `cal_public_event` eingefuegt; bestehende bleiben.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/school/calendar/holidays?region=DE-NI&from=2026-08-01&to=2027-07-31
|
||||||
|
```
|
||||||
|
|
||||||
|
Liefert Array sortiert nach `start_date`. Beispiel-Antwort:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"id":"…","region":"DE-NI","event_type":"school_holiday","name_de":"Sommerferien","start_date":"2026-07-02","end_date":"2026-08-12"},
|
||||||
|
{"id":"…","region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit","start_date":"2026-10-03","end_date":"2026-10-03"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format-Mapping (Snapshot-Script)
|
||||||
|
|
||||||
|
OpenHolidaysAPI gibt:
|
||||||
|
```json
|
||||||
|
{"id":"...","startDate":"2026-10-03","endDate":"2026-10-03","type":"Public",
|
||||||
|
"name":[{"language":"DE","text":"Tag der Deutschen Einheit"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/calendar-snapshot.sh` normalisiert via jq:
|
||||||
|
```json
|
||||||
|
{"region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit",
|
||||||
|
"name_en":null,"start_date":"2026-10-03","end_date":"2026-10-03"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lizenz-Compliance
|
||||||
|
|
||||||
|
- API-Code: MIT
|
||||||
|
- Daten: ODbL (Open Database License)
|
||||||
|
|
||||||
|
Beides ist fuer kommerzielle Nutzung erlaubt. Die Quelle muss in einer
|
||||||
|
Lizenz-Aufstellung (SBOM) genannt werden — bereits in
|
||||||
|
`sbom/stundenplan/README.md` dokumentiert.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Schulkalender
|
||||||
|
|
||||||
|
Bundeslandweit kalibrierter Schulkalender mit Ferien, Feiertagen, Schul-
|
||||||
|
Events, Eltern-Sicht und mehrsprachigen Benachrichtigungen.
|
||||||
|
|
||||||
|
## Auf einen Blick
|
||||||
|
|
||||||
|
```
|
||||||
|
studio-v2 /schulkalender → Lehrer-Sicht (CRUD Events, Eltern einladen, Rollover)
|
||||||
|
studio-v2 /eltern → Eltern-Sicht (Wochengrid des Kindes in eigener Sprache)
|
||||||
|
│
|
||||||
|
│ HTTP /api/school/* und /api/parent/* (zwei separate Auth-Gruppen)
|
||||||
|
▼
|
||||||
|
school-service (Go, :8084)
|
||||||
|
├── cal_public_event — Ferien/Feiertage-Snapshot (OpenHolidaysAPI)
|
||||||
|
├── cal_school_config — Bundesland pro Rektor
|
||||||
|
├── cal_school_event — Schulfeier, Fortbildung, Klassenfahrt etc.
|
||||||
|
├── parent_account/_child/_magic_link/_session — Eltern-Auth
|
||||||
|
└── notification_log — Idempotenter Versand-Log
|
||||||
|
│
|
||||||
|
▼ POST DispatchPayload
|
||||||
|
Matrix-Bridge + Email-Gateway (vom Kollegen gepflegt, nicht in diesem Repo)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module
|
||||||
|
|
||||||
|
| Bereich | Doku |
|
||||||
|
|---------|------|
|
||||||
|
| [Architektur](architecture.md) | DB-Modell, Auth-Ablauf, Phase-Reihenfolge |
|
||||||
|
| [Ferien-Snapshot](holidays.md) | OpenHolidaysAPI-Pipeline, jaehrliche Aktualisierung |
|
||||||
|
| [Eltern-Workflow](parent-flow.md) | Magic-Link, Cookie-Session, i18n-Fachnamen |
|
||||||
|
| [Notifications](notifications.md) | Cron, Templates, Dispatcher-Vertrag |
|
||||||
|
|
||||||
|
## Phasen-Stand
|
||||||
|
|
||||||
|
**Alle vier Phasen abgeschlossen (2026-05-22):**
|
||||||
|
|
||||||
|
- 9a — Bundesland-Wizard + Monatsansicht
|
||||||
|
- 9b — Schul-Events + Schuljahres-Rollover
|
||||||
|
- 9c — Parent-Accounts + Magic-Link + Wochengrid in 8 Sprachen
|
||||||
|
- 9d — Notification-Cron + Templates + Status-Badges
|
||||||
|
|
||||||
|
**Offen:** Vollschuljahr-ICS, Seed-Daten fuer Demo-Schule.
|
||||||
|
|
||||||
|
## Test-Status
|
||||||
|
|
||||||
|
| Suite | Tests |
|
||||||
|
|------|-------|
|
||||||
|
| Go (services + notifications) | 89 / 89 |
|
||||||
|
| Playwright Schulkalender | 16 / 16 |
|
||||||
|
| Playwright Eltern | 7 / 7 |
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Notifications
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
06:00 Uhr (Berlin-Zeit, Container TZ=Europe/Berlin)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
NotificationService.RunForDate(today)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
dueEvents() findet cal_school_event mit
|
||||||
|
(start_date - today) ∈ notification_lead_days
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Pro Event: fuer jede Audience (parents/students) und jeden Channel
|
||||||
|
(matrix für alle, email zusaetzlich nur fuer parents):
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
dispatchOne()
|
||||||
|
1. Idempotenz-Check (UNIQUE notification_log)
|
||||||
|
2. recipientsFor() — JOIN parent_account+parent_child fuer
|
||||||
|
betroffene Klassen, gibt Email-Liste + bevorzugte Sprache zurueck
|
||||||
|
3. Render-Template (templates.go, 8 Sprachen)
|
||||||
|
4. POST {MATRIX,EMAIL}_SERVICE_URL mit DispatchPayload
|
||||||
|
5. notification_log writeLog (sent/failed/skipped)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cron-Mechanik
|
||||||
|
|
||||||
|
`main.go` startet einen Goroutine-Ticker mit 1h-Intervall. Sobald `time.Now().Hour() == 6` wird `RunForDate` aufgerufen. Idempotent — die UNIQUE auf notification_log filtert Doppel-Calls am selben Tag.
|
||||||
|
|
||||||
|
Bei Container-Restart vor 06:00 läuft trotzdem alles korrekt: der naechste 06-Tick fired bis spaetestens 06:59:59. Bei Restart nach 06:00: erste Notification erst am Folgetag (acceptable trade-off gegen einen 1-Min-Ticker).
|
||||||
|
|
||||||
|
## Manueller Trigger
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Heute jetzt scannen
|
||||||
|
curl -X POST http://localhost:8084/api/v1/school/calendar/notifications/run-now
|
||||||
|
|
||||||
|
# Backfill (z.B. nach langem Container-Down)
|
||||||
|
curl -X POST 'http://localhost:8084/api/v1/school/calendar/notifications/run-now?date=2026-05-20'
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort: `{"date":"2026-05-22","sent":N,"failed":N,"skipped":N,"already_logged":N}`.
|
||||||
|
|
||||||
|
## Template-Engine
|
||||||
|
|
||||||
|
Datei: `school-service/internal/notifications/templates.go`. Schema:
|
||||||
|
|
||||||
|
```
|
||||||
|
templates[lang][event_type][audience][bucket] → {Subject, Body}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `lang` ∈ de/en/tr/ar/uk/ru/pl/fr (Fallback `de`)
|
||||||
|
- `event_type` ∈ fortbildung/schulfeier/klassenfahrt/projekttag/eltern_info/andere (Fallback `andere`)
|
||||||
|
- `audience` ∈ parents/students (Fallback `parents`)
|
||||||
|
- `bucket` ∈ today/tomorrow/days (Fallback `days`)
|
||||||
|
|
||||||
|
Placeholders: `{{title}}`, `{{date}}`, `{{date_pretty}}`, `{{class_name}}`, `{{class_suffix}}`, `{{teacher_name}}`, `{{lead}}`.
|
||||||
|
|
||||||
|
Beispiel-Render (TR / schulfeier / parents / 1-Tag-Vorlauf):
|
||||||
|
```
|
||||||
|
Subject: Yarın: Sommerfest (5a)
|
||||||
|
Body: Sayın veliler, yarın (15.06.2026) Sommerfest gerçekleşiyor (5a).
|
||||||
|
```
|
||||||
|
|
||||||
|
## DispatchPayload (Endpoint-Vertrag mit Matrix/Email Service)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel": "matrix",
|
||||||
|
"recipient": "mama@example.de",
|
||||||
|
"language": "tr",
|
||||||
|
"subject": "Yarın: Sommerfest",
|
||||||
|
"body": "Sayın veliler, ...",
|
||||||
|
"event_id": "uuid-…",
|
||||||
|
"lead_days": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartete Antwort vom Upstream: HTTP 2xx = sent. 4xx/5xx = failed. Wir leiten **keine** Empfaenger-Identifier-Aufloesung weiter ans Upstream — die Matrix-Bridge mapt Email → Matrix-Handle in der eigenen Logik.
|
||||||
|
|
||||||
|
Bei `MATRIX_SERVICE_URL` oder `EMAIL_SERVICE_URL` leer: status='skipped', kein Versandversuch. Erlaubt lokales Testen ohne Upstream.
|
||||||
|
|
||||||
|
## Status-Anzeige im Lehrer-UI
|
||||||
|
|
||||||
|
`DayDetail` mountet `NotificationStatus` fuer jedes Event mit `notify_parents` oder `notify_students`. Lädt `GET /api/v1/school/calendar/events/:id/notifications` und zeigt Badges:
|
||||||
|
|
||||||
|
- ✓ gruen = sent
|
||||||
|
- ✗ rot = failed (Hover zeigt error_message)
|
||||||
|
- ⏱ amber = skipped (Upstream noch nicht konfiguriert)
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
`notification_log` ist nur über JOIN cal_school_event sichtbar — Lehrer sieht nur Logs seiner eigenen Events. Eltern haben gar keine UI fuer Logs.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Eltern-Workflow
|
||||||
|
|
||||||
|
## Einladung (Lehrer)
|
||||||
|
|
||||||
|
1. Lehrer offnet `/schulkalender`, scrollt zu `ParentManager`.
|
||||||
|
2. Klick "+ Eltern einladen" → Form mit Email, Vorname/Nachname Kind, Klasse, Sprache.
|
||||||
|
3. `POST /api/v1/school/calendar/parents/invite` legt parent_account (upsert), parent_child + parent_magic_link an, gibt Klartext-Token + voll qualifizierten Link zurueck.
|
||||||
|
4. Lehrer kopiert Link aus der UI und schickt ihn ueber Matrix oder Email (Versand-Automation kommt mit Phase 9d Notification-Pipeline).
|
||||||
|
|
||||||
|
## Login (Eltern)
|
||||||
|
|
||||||
|
1. Eltern klicken den Link `https://app/eltern/login?token=…`.
|
||||||
|
2. Browser laedt die Login-Page, sendet `POST /api/v1/parent/auth/redeem {token}`.
|
||||||
|
3. school-service validiert Token (Hash-Lookup + expires_at + used_at), markiert used_at, mintet Session-Token (32-Byte URL-safe Base64), setzt HttpOnly Cookie `bp_parent_session`.
|
||||||
|
4. Redirect auf `/eltern`. Folgende API-Calls senden Cookie automatisch.
|
||||||
|
|
||||||
|
## Wochengrid
|
||||||
|
|
||||||
|
`/eltern` ruft:
|
||||||
|
|
||||||
|
- `GET /api/v1/parent/me` → Account + Kinder-Liste (Name, Klasse via JOIN tt_class)
|
||||||
|
- `GET /api/v1/parent/me/timetable?class_id=…` → letzte completed tt_solution der Klasse
|
||||||
|
|
||||||
|
Filter laeuft strikt: ParentService prueft `ChildBelongsToParent(parent_id, class_id)` vor jeder Timetable-Query.
|
||||||
|
|
||||||
|
## Fach-Uebersetzung
|
||||||
|
|
||||||
|
`lib/calendar/subject-i18n.ts` hat 22 Standardfaecher in 8 Sprachen:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
mathematik: { de: 'Mathematik', en: 'Mathematics', tr: 'Matematik',
|
||||||
|
ar: 'الرياضيات', uk: 'Математика', ru: 'Математика',
|
||||||
|
pl: 'Matematyka', fr: 'Mathématiques' }
|
||||||
|
```
|
||||||
|
|
||||||
|
`translateSubject(germanName, lang)`:
|
||||||
|
|
||||||
|
1. Lowercase + trim → `key`
|
||||||
|
2. `SUBJECTS[key]` lookup
|
||||||
|
3. Wenn key nicht in Map: Original-Deutsch zurueck (z.B. "Imkern AG")
|
||||||
|
4. Wenn lang nicht in Sprachen: `de`-Fallback
|
||||||
|
|
||||||
|
## Logout
|
||||||
|
|
||||||
|
`POST /api/v1/parent/auth/logout` setzt Cookie auf max-age=-1. Session-Row bleibt in DB (laeuft selber ab nach 30 Tagen) — vereinfacht Tracking.
|
||||||
|
|
||||||
|
## Was die Eltern NICHT sehen
|
||||||
|
|
||||||
|
- Andere Eltern oder Kinder
|
||||||
|
- Stundenplan-Versionen die nicht "completed" sind
|
||||||
|
- Schul-Events mit `visible_to_parents=false`
|
||||||
|
- Lehrer-internes wie Stundentafel oder Lehrauftrag-Konfiguration
|
||||||
|
|
||||||
|
Privacy-Garantien sind auf SQL-Ebene durchgesetzt (JOIN-Pfade + WHERE-Klauseln), nicht nur im Application-Layer.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Architektur + Datenmodell
|
||||||
|
|
||||||
|
## Verantwortung pro Service
|
||||||
|
|
||||||
|
### school-service (Go/Gin)
|
||||||
|
|
||||||
|
- Persistenz fuer alle Stammdaten + Constraints + Solutions
|
||||||
|
- API-Gateway fuer studio-v2: validiert, ownership-checked, faked Auth im
|
||||||
|
Dev-Mode
|
||||||
|
- Trigger-Aufruf an solver-service nach POST /solutions
|
||||||
|
|
||||||
|
### timetable-solver-service (Python/FastAPI + Timefold)
|
||||||
|
|
||||||
|
- Liest Problem aus PG via asyncpg
|
||||||
|
- Baut Timefold-Domain (Lessons, Timeslots, Rooms, Rules)
|
||||||
|
- Loest im ThreadPoolExecutor (Solver ist CPU-gebunden)
|
||||||
|
- Schreibt Loesung direkt nach tt_lesson, updated tt_solution.status
|
||||||
|
|
||||||
|
### studio-v2 (Next.js)
|
||||||
|
|
||||||
|
- `/stundenplan` Tab-Page mit 9 Tabs
|
||||||
|
- Next.js API-Route `/api/school/*` proxied zu school-service
|
||||||
|
- Solution-Polling alle 4 s wenn Solve laeuft
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
### Stammdaten (7 Tabellen)
|
||||||
|
|
||||||
|
| Tabelle | Inhalt | Owner |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| tt_class | Klassen (Name, Klassenstufe) | created_by_user_id |
|
||||||
|
| tt_period | Zeitraster (Mo-So × Stunde × Start/Ende) | created_by_user_id |
|
||||||
|
| tt_room | Raeume (Name, Typ, Kapazitaet, Aufzug) | created_by_user_id |
|
||||||
|
| tt_subject | Faecher (Name, Kuerzel, Farbe, RoomType) | created_by_user_id |
|
||||||
|
| tt_teacher | Lehrer als planbare Ressource | created_by_user_id |
|
||||||
|
| tt_curriculum | Klasse × Fach → Wochenstunden | indirect via tt_class |
|
||||||
|
| tt_assignment | Lehrer × Klasse × Fach | indirect via tt_teacher |
|
||||||
|
|
||||||
|
### Constraints (15 Tabellen)
|
||||||
|
|
||||||
|
Pro Tabelle die Felder: `is_hard` (bool), `weight` (0-100), `active` (bool),
|
||||||
|
`note` (TEXT), `created_by_user_id`. Aufgeteilt nach Parent-Entitaet:
|
||||||
|
|
||||||
|
- Lehrer: `unavailable_day`, `unavailable_window`, `max_hours_day`,
|
||||||
|
`max_hours_week`, `excluded_subject`, `excluded_room`
|
||||||
|
- Fach: `min_day_gap`, `max_consecutive`, `contiguous_when_repeated`,
|
||||||
|
`preferred_period`, `double_lesson`
|
||||||
|
- Klasse: `max_hours_day`, `no_gaps`
|
||||||
|
- Raum: `requires_type`, `unavailable`
|
||||||
|
|
||||||
|
### Solutions (2 Tabellen, Phase 5+7)
|
||||||
|
|
||||||
|
| Tabelle | Inhalt |
|
||||||
|
|---------|--------|
|
||||||
|
| tt_solution | Solve-Run: Status, hard/soft Score, parent_solution_id, seconds_limit |
|
||||||
|
| tt_lesson | Eine Stunde im Plan (class, subject, teacher, room, day, period, pinned) |
|
||||||
|
|
||||||
|
`tt_lesson` hat drei `UNIQUE`-Constraints, die der DB-Layer selbst Konflikt-
|
||||||
|
Lessons ablehnen laesst:
|
||||||
|
|
||||||
|
- `(solution_id, class_id, day, period)` — Klasse nicht doppelt
|
||||||
|
- `(solution_id, teacher_id, day, period)` — Lehrer nicht doppelt
|
||||||
|
- `(solution_id, room_id, day, period)` — Raum nicht doppelt
|
||||||
|
|
||||||
|
Damit kann ein fehlerhafter Solver-Output nicht in Daten landen, die das UI
|
||||||
|
inkonsistent darstellt.
|
||||||
|
|
||||||
|
## Ownership-Modell
|
||||||
|
|
||||||
|
Alles ist single-tenant pro `created_by_user_id`. CRUD-Endpoints filtern via
|
||||||
|
`WHERE EXISTS (SELECT 1 FROM tt_<parent> WHERE id = $X AND created_by_user_id
|
||||||
|
= $user)`. Cross-Tenant-Zugriff ist auf SQL-Ebene ausgeschlossen.
|
||||||
|
|
||||||
|
Im Dev-Mode injiziert `AuthMiddleware` einen festen UUID, damit Tests ohne
|
||||||
|
JWT laufen koennen. Production-Build (`ENVIRONMENT=production`) deaktiviert
|
||||||
|
den Bypass — JWT wird Pflicht.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Constraint-Referenz
|
||||||
|
|
||||||
|
Jeder Constraint-Eintrag im UI legt eine Row in der korrespondierenden
|
||||||
|
`tt_constraint_*` Tabelle an. Der Solver liest sie als Problem-Facts und
|
||||||
|
joined sie gegen die Lessons.
|
||||||
|
|
||||||
|
## Gemeinsame Felder
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
|------|-----|-----------|
|
||||||
|
| `is_hard` | bool | true = Solver muss einhalten (HardScore -1 pro Verstoss). false = Soft-Penalty (SoftScore -weight pro Verstoss) |
|
||||||
|
| `weight` | int 0-100 | Multiplikator fuer Soft-Penalties; bei Hard ignoriert |
|
||||||
|
| `active` | bool | inaktive Rows werden vom Solver ignoriert |
|
||||||
|
| `note` | TEXT | Freier Begruendungstext fuer den Rektor |
|
||||||
|
|
||||||
|
## Constraint-Typen
|
||||||
|
|
||||||
|
### Universal (immer aktiv, nicht abschaltbar)
|
||||||
|
|
||||||
|
| Constraint | Bedeutung |
|
||||||
|
|------------|-----------|
|
||||||
|
| `class_conflict` | Eine Klasse hat nur eine Lesson pro Timeslot |
|
||||||
|
| `teacher_conflict` | Ein Lehrer haelt nur eine Lesson pro Timeslot |
|
||||||
|
| `room_conflict` | Ein Raum hostet nur eine Lesson pro Timeslot |
|
||||||
|
|
||||||
|
### DB-Driven (vom Rektor konfigurierbar)
|
||||||
|
|
||||||
|
Jeder Typ existiert als `_hard` und `_soft` Constraint im Provider:
|
||||||
|
|
||||||
|
| Typ | Tabelle | Beispiel |
|
||||||
|
|-----|---------|----------|
|
||||||
|
| Lehrer Tag nicht verfuegbar | `tt_constraint_teacher_unavailable_day` | „Anna nie Montags" |
|
||||||
|
| Lehrer Zeitfenster nicht verfuegbar | `tt_constraint_teacher_unavailable_window` | „Bob Dienstag 13–17 Uhr nicht" |
|
||||||
|
| Lehrer Max h/Tag | `tt_constraint_teacher_max_hours_day` | Anti-Burnout |
|
||||||
|
| Lehrer Max h/Woche | `tt_constraint_teacher_max_hours_week` | Teilzeit-Cap |
|
||||||
|
| Lehrer Fach ausgeschlossen | `tt_constraint_teacher_excluded_subject` | Qualifikationsluecke |
|
||||||
|
| Lehrer Raum ausgeschlossen | `tt_constraint_teacher_excluded_room` | Rollstuhl, kein Fahrstuhl |
|
||||||
|
| Fach Mindest-Tagesabstand | `tt_constraint_subject_min_day_gap` | Mathe nicht 2 Tage hintereinander |
|
||||||
|
| Fach Max Stunden am Stueck | `tt_constraint_subject_max_consecutive` | Keine Dreifachstunde |
|
||||||
|
| Fach Mehrfach=zusammen | `tt_constraint_subject_contiguous_when_repeated` | Wenn 2× am Tag, dann benachbart |
|
||||||
|
| Fach Bevorzugte Stunden | `tt_constraint_subject_preferred_period` | Hauptfaecher morgens |
|
||||||
|
| Fach Doppelstunde bevorzugt | `tt_constraint_subject_double_lesson` | Sport als 90-min-Block |
|
||||||
|
| Klasse Max h/Tag | `tt_constraint_class_max_hours_day` | Jugendgerecht |
|
||||||
|
| Klasse Keine Freistunden | `tt_constraint_class_no_gaps` | Soft, minimiert Loecher |
|
||||||
|
| Raumtyp erforderlich | `tt_constraint_room_requires_type` | Sport → Sporthalle |
|
||||||
|
| Raum nicht verfuegbar | `tt_constraint_room_unavailable` | Wartung, Renovierung |
|
||||||
|
|
||||||
|
## Hard vs. Soft — Faustregel
|
||||||
|
|
||||||
|
- **Hard** wenn die Schule den Plan rechtlich oder physisch nicht
|
||||||
|
ausfuehren kann (Lehrervertrag, Behinderung, Raum existiert nicht).
|
||||||
|
- **Soft** wenn es nur eine Praeferenz ist („Mathe lieber morgens",
|
||||||
|
„keine Freistunden").
|
||||||
|
|
||||||
|
Score-Bewertung im UI:
|
||||||
|
- `hard_score = 0` → Plan ist gueltig
|
||||||
|
- `hard_score < 0` → mindestens eine harte Regel ist verletzt (Solver
|
||||||
|
meldet das als `infeasible`)
|
||||||
|
- `soft_score` → wird in den UI angezeigt; je naeher an 0, desto besser
|
||||||
|
|
||||||
|
## Erweitern um einen 16. Constraint-Typ
|
||||||
|
|
||||||
|
1. Neue Tabelle in `school-service/internal/database/timetable_constraints_migrations.go`
|
||||||
|
2. Model + DTO in `models/timetable_constraints.go`
|
||||||
|
3. Service + Handler im gleichen Paket-Pattern wie die existierenden 15
|
||||||
|
4. Route in `cmd/server/main.go`
|
||||||
|
5. Rule-Dataclass in `timetable-solver-service/app/rules.py`
|
||||||
|
6. ProblemFactCollection in `domain.py`
|
||||||
|
7. ConstraintProvider-Funktion in `constraints.py` (Hard + Soft Variante)
|
||||||
|
8. Frontend: Editor-Komponente in `_components/regeln/`, dann in
|
||||||
|
`RegelnHub.tsx` registrieren
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Export
|
||||||
|
|
||||||
|
Drei Export-Formate fuer fertige Solutions, alle als GET-Endpoints im
|
||||||
|
school-service.
|
||||||
|
|
||||||
|
## CSV
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/school/timetable/solutions/{id}/export.csv
|
||||||
|
Content-Type: text/csv; charset=utf-8
|
||||||
|
```
|
||||||
|
|
||||||
|
Spalten: `day_of_week,period_index,start_time,end_time,class,subject,
|
||||||
|
subject_code,teacher,room,pinned`.
|
||||||
|
|
||||||
|
Komma in Feldwerten (z.B. „Schmidt, Anna") wird automatisch escaped.
|
||||||
|
Sortierung: by `(day_of_week, period_index, class_name)`.
|
||||||
|
|
||||||
|
Anwendungsfaelle:
|
||||||
|
- Import in Excel oder Google Sheets fuer Reports
|
||||||
|
- Datenuebergabe an externes Schulverwaltungs-System
|
||||||
|
- Datenarchivierung pro Schuljahr
|
||||||
|
|
||||||
|
## ICS (iCalendar, RFC 5545)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/school/timetable/solutions/{id}/export.ics
|
||||||
|
GET /api/v1/school/timetable/solutions/{id}/export.ics?start=2026-08-24
|
||||||
|
Content-Type: text/calendar; charset=utf-8
|
||||||
|
```
|
||||||
|
|
||||||
|
Emittiert ein VEVENT pro Lesson, anchored auf die naechste Montag-Woche
|
||||||
|
(oder via `?start=YYYY-MM-DD` ueberschreibbar). Lehrer kann die Datei
|
||||||
|
direkt im Apple Calendar, Google Calendar oder Outlook importieren.
|
||||||
|
|
||||||
|
Strukturbeispiel:
|
||||||
|
```
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//BreakPilot//Timetable//DE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:lesson-0-d1p1-20260824@breakpilot
|
||||||
|
DTSTAMP:20260522T144800Z
|
||||||
|
DTSTART:20260824T080000
|
||||||
|
DTEND:20260824T084500
|
||||||
|
SUMMARY:Mathe (5a)
|
||||||
|
LOCATION:A101
|
||||||
|
DESCRIPTION:Lehrer: Schmidt, Anna\nSchuljahr 26/27
|
||||||
|
END:VEVENT
|
||||||
|
...
|
||||||
|
END:VCALENDAR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aktuell nur eine Kalender-Woche** — fuer ganzes Schuljahr braeuchte es
|
||||||
|
RRULE + Ferien-Exceptions, ist als Phase 9 vorgemerkt.
|
||||||
|
|
||||||
|
## Drucken (HTML print view)
|
||||||
|
|
||||||
|
Im PlanView gibt es einen „Drucken"-Button. Der Druck-Dialog des Browsers
|
||||||
|
oeffnet sich; eigene `@media print`-Stylesheet in `globals.css` blendet
|
||||||
|
Sidebar, Tabs, Help-Panel und Token-Banner aus und zwingt das Wochengrid auf
|
||||||
|
weisses A4-Format.
|
||||||
|
|
||||||
|
Vorteil ueber serverseitiges PDF: kein zusaetzliches Backend-Tool, keine
|
||||||
|
Headless-Browser-Container, der User waehlt selbst Drucker/PDF/Format.
|
||||||
|
|
||||||
|
## Aufruf vom Frontend
|
||||||
|
|
||||||
|
`lib/stundenplan/api.ts:downloadSolutionExport(solutionId, 'csv' | 'ics')`
|
||||||
|
laedt das Blob ueber den Next.js-Proxy, sodass der JWT (falls gesetzt) im
|
||||||
|
Authorization-Header weitergegeben wird. Im Dev-Mode ohne Token funktioniert
|
||||||
|
es ebenfalls.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Stundenplaner
|
||||||
|
|
||||||
|
Schulweiter Stundenplan-Generator fuer den Rektor. Erfasst Klassen, Lehrer,
|
||||||
|
Faecher, Raeume + Constraints und ruft einen Timefold-basierten Solver auf,
|
||||||
|
um einen konfliktfreien Wochenplan zu produzieren.
|
||||||
|
|
||||||
|
## Architektur auf einen Blick
|
||||||
|
|
||||||
|
```
|
||||||
|
studio-v2 /stundenplan (Next.js)
|
||||||
|
│ HTTP über Next.js Proxy /api/school/*
|
||||||
|
▼
|
||||||
|
school-service (Go/Gin, :8084)
|
||||||
|
│ ─ CRUD Stammdaten + Constraints + Solutions in PostgreSQL
|
||||||
|
│ ─ Fire-and-forget Trigger an Solver
|
||||||
|
▼
|
||||||
|
timetable-solver-service (Python/FastAPI + Timefold, :8095)
|
||||||
|
│ ─ Liest Problem aus PG, rechnet im Worker-Thread
|
||||||
|
│ ─ Schreibt Lessons direkt nach tt_lesson
|
||||||
|
▼
|
||||||
|
PostgreSQL (Schema `public` in `breakpilot_db`)
|
||||||
|
24 Tabellen: 7 Stammdaten + 15 Constraints + tt_solution + tt_lesson
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module
|
||||||
|
|
||||||
|
| Bereich | Doku |
|
||||||
|
|---------|------|
|
||||||
|
| [Architektur + Datenmodell](architecture.md) | DB-Schema, Ownership-Modell |
|
||||||
|
| [Constraints](constraints.md) | 15 Constraint-Typen, hard/soft Semantik |
|
||||||
|
| [Solver-Tuning](solver-tuning.md) | Timefold-Konfiguration, Zeit-Budgets |
|
||||||
|
| [Export](export.md) | CSV, ICS, Drucken |
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Phasen 1-3 + 5-8 fertig** (Stand 2026-05-22, Phase 4 Untis übersprungen).
|
||||||
|
|
||||||
|
- 24 DB-Tabellen, alle 22 CRUD-Endpoints + Solve + Export-Endpoints live
|
||||||
|
- Frontend: 9 Tabs (Plan + 7 Stammdaten + Regeln), 15 Constraint-Editoren,
|
||||||
|
Wochengrid mit Pin-Funktion, 3 Perspektiven (Klasse/Lehrer/Raum)
|
||||||
|
- Tests: 73 Go + 36 Playwright + 4 Export-Unit-Tests
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
|
||||||
|
- Phase 4 (Untis-Import) — verschoben, kein Kunde fordert es aktuell
|
||||||
|
- Seed-Daten fuer Demo-Schule
|
||||||
|
- Echte Auth-Integration ablöst Dev-Bypass
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Solver-Tuning
|
||||||
|
|
||||||
|
## Timefold-Konfiguration
|
||||||
|
|
||||||
|
Der `SolverFactory` wird in `runner.py` pro Solve gebaut so dass jedes
|
||||||
|
Job-Spent-Limit aus `tt_solution.seconds_limit` einzeln zur Geltung kommt.
|
||||||
|
|
||||||
|
```python
|
||||||
|
SolverConfig(
|
||||||
|
solution_class=Timetable,
|
||||||
|
entity_class_list=[Lesson],
|
||||||
|
score_director_factory_config=ScoreDirectorFactoryConfig(
|
||||||
|
constraint_provider_function=define_constraints,
|
||||||
|
),
|
||||||
|
termination_config=TerminationConfig(
|
||||||
|
spent_limit=Duration(seconds=seconds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Default Timeout: 60 s. Per Solve ueberschreibbar im UI (5–600 s).
|
||||||
|
|
||||||
|
## Score-Modell
|
||||||
|
|
||||||
|
`HardSoftScore` — Hard-Komponente ist die wichtige:
|
||||||
|
- `hard_score < 0` → Solution ist `infeasible`, UI markiert in Amber.
|
||||||
|
- `hard_score == 0` → Solution gueltig, `soft_score` minimiert Praeferenz-
|
||||||
|
Verletzungen.
|
||||||
|
|
||||||
|
## Pinning fuer iterative Verbesserung
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
1. Initial-Solve laeuft → Plan A.
|
||||||
|
2. Rektor pinnt 5–10 Cells im UI, die ihm gefallen.
|
||||||
|
3. Neuer Solve mit `parent_solution_id = Plan A`. Der Solver nimmt die
|
||||||
|
gepinnten Cells als Fixpunkte (`@PlanningPin`) und rechnet die restlichen
|
||||||
|
Lessons neu.
|
||||||
|
4. Optional Sekunden-Limit erhoehen (z.B. 180 s) wenn die Solution-Qualitaet
|
||||||
|
wichtiger ist als die Wartezeit.
|
||||||
|
|
||||||
|
Implementierung in `repository._inherit_pinned_from_parent()`:
|
||||||
|
- Greedy First-Fit-Matching by `(class_id, subject_id)`
|
||||||
|
- Surplus pinned Rows aus dem Parent (z.B. weil curriculum-Stunden gekuerzt)
|
||||||
|
werden silently uebersprungen
|
||||||
|
- Mismatch wird in Logs ausgegeben, fuehrt aber nicht zu failed Status
|
||||||
|
|
||||||
|
## Was tun wenn der Solver `infeasible` meldet
|
||||||
|
|
||||||
|
Reihenfolge der Diagnose:
|
||||||
|
|
||||||
|
1. **Lessons-Count vs. Slots-Count**: Wenn die Summe der Wochenstunden ueber
|
||||||
|
alle Klassen > Anzahl Slots pro Woche × Anzahl Raeume ist, kann es
|
||||||
|
physisch keine Loesung geben. Stundentafel kuerzen oder mehr Raeume.
|
||||||
|
2. **Lehrer-Auslastung**: Wenn ein Lehrer mit 28 h Cap in der Stundentafel
|
||||||
|
30 h zugewiesen bekommt, ist es unloesbar. Lehrauftraege anpassen.
|
||||||
|
3. **Harte Constraints widerspruechlich**: Mathe muss morgens UND ist
|
||||||
|
`excluded_room` fuer alle Vormittags-Raeume → Konflikt. Constraints von
|
||||||
|
Hard auf Soft umstellen wo moeglich.
|
||||||
|
4. **Sekunden-Limit zu kurz**: Bei sehr restriktiven Modellen braucht der
|
||||||
|
Solver laenger zum ersten Fit-finden. 300 s probieren.
|
||||||
|
|
||||||
|
## Performance-Charakteristik
|
||||||
|
|
||||||
|
- Kleine Schule (3 Klassen, 8 Lehrer, 6 Faecher, ~80 Lessons): meist <5 s
|
||||||
|
- Mittlere Schule (15 Klassen, 30 Lehrer, ~400 Lessons): 30–60 s fuer
|
||||||
|
hard_score=0, weitere Minuten fuer soft-Optimierung
|
||||||
|
- Sehr grosse Schule (>800 Lessons): Solver kommt mit 60 s Default nicht
|
||||||
|
konvergent, hoeheres Limit oder Multi-Threading evaluieren (Timefold
|
||||||
|
Enterprise)
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
admin package — admin APIs for NiBiS, RAG, templates.
|
admin package — admin APIs for NiBiS, RAG, templates.
|
||||||
|
|
||||||
Backward-compatible re-exports: consumers can still use
|
Backward-compatible re-exports: consumers can still use
|
||||||
``from admin_api import ...`` etc. via the shim files in backend/.
|
``from admin.api import ...`` etc. via the shim files in backend/.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ This module was split into:
|
|||||||
- admin_templates.py (Legal templates ingestion, search)
|
- admin_templates.py (Legal templates ingestion, search)
|
||||||
|
|
||||||
The `router` object is assembled here by including all sub-routers.
|
The `router` object is assembled here by including all sub-routers.
|
||||||
Importers that did `from admin_api import router` continue to work.
|
Importers that did `from admin.api import router` continue to work.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from nibis_ingestion import (
|
|||||||
DOCS_BASE_PATH,
|
DOCS_BASE_PATH,
|
||||||
)
|
)
|
||||||
from qdrant_service import QdrantService, search_nibis_eh, get_qdrant_client
|
from qdrant_service import QdrantService, search_nibis_eh, get_qdrant_client
|
||||||
from eh_pipeline import generate_single_embedding
|
from korrektur.eh_pipeline import generate_single_embedding
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
|
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ except ImportError:
|
|||||||
MINIO_AVAILABLE = False
|
MINIO_AVAILABLE = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from metrics_db import (
|
from metrics.db import (
|
||||||
init_metrics_tables, store_feedback, log_search, log_upload,
|
init_metrics_tables, store_feedback, log_search, log_upload,
|
||||||
calculate_metrics, get_recent_feedback, get_upload_history
|
calculate_metrics, get_recent_feedback, get_upload_history
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from eh_pipeline import generate_single_embedding
|
from korrektur.eh_pipeline import generate_single_embedding
|
||||||
|
|
||||||
# Import legal templates modules
|
# Import legal templates modules
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to admin/api.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("admin.api")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to admin/nibis.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("admin.nibis")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to admin/rag.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("admin.rag")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to admin/templates.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("admin.templates")
|
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
compliance package — compliance pipeline, RBAC/ABAC policy engine.
|
compliance package — compliance pipeline, RBAC/ABAC policy engine.
|
||||||
|
|
||||||
Backward-compatible re-exports: consumers can still use
|
Backward-compatible re-exports: consumers can still use
|
||||||
``from compliance_models import ...`` etc. via the shim files in backend/.
|
``from compliance.models import ...`` etc. via the shim files in backend/.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to compliance/extraction.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("compliance.extraction")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to compliance/models.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("compliance.models")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Backward-compat shim -- module moved to compliance/pipeline.py
|
|
||||||
import importlib as _importlib
|
|
||||||
import sys as _sys
|
|
||||||
_sys.modules[__name__] = _importlib.import_module("compliance.pipeline")
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Crawler package — GitHub repository crawler for legal templates.
|
||||||
|
|
||||||
|
Moved from backend/ flat modules (github_crawler*.py).
|
||||||
|
Backward-compatible shim files remain at the old locations.
|
||||||
|
"""
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user