Compare commits
101 Commits
f3b9617fc3
...
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 | |||
| dde45b29db | |||
| 165c493d1e | |||
| 0504d22b8e | |||
| 59c400b9aa | |||
| 098a2ff092 | |||
| cb1be59e46 | |||
| 45287b3541 | |||
| d87645ffce | |||
| d4959172a9 | |||
| b49ee3467e | |||
| 2eb17fd349 | |||
| 06ea9f7073 |
@@ -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 |
|
||||||
|
|||||||
@@ -27,9 +27,11 @@
|
|||||||
|
|
||||||
# Algorithmic monolith — detect_column_geometry() allein 411 LOC, nicht weiter teilbar
|
# Algorithmic monolith — detect_column_geometry() allein 411 LOC, nicht weiter teilbar
|
||||||
**/cv_layout_columns.py | owner=klausur | reason=detect_column_geometry ist eine einzelne 411-LOC Funktion (Whitespace-Gap-Analyse) | review=2026-10-01
|
**/cv_layout_columns.py | owner=klausur | reason=detect_column_geometry ist eine einzelne 411-LOC Funktion (Whitespace-Gap-Analyse) | review=2026-10-01
|
||||||
|
**/ocr/layout/columns.py | owner=klausur | reason=Same file moved to ocr/ package | review=2026-10-01
|
||||||
|
|
||||||
# Two indivisible route handlers (~230 LOC each) that cannot be split further
|
# Two indivisible route handlers (~230 LOC each) that cannot be split further
|
||||||
**/vocab_worksheet_compare_api.py | owner=klausur | reason=compare_ocr_methods (234 LOC) + analyze_grid (255 LOC), each a single cohesive handler | review=2026-10-01
|
**/vocab_worksheet_compare_api.py | owner=klausur | reason=compare_ocr_methods (234 LOC) + analyze_grid (255 LOC), each a single cohesive handler | review=2026-10-01
|
||||||
|
**/vocab/worksheet/compare_api.py | owner=klausur | reason=Same file moved to vocab/ package | review=2026-10-01
|
||||||
|
|
||||||
# TypeScript Data Catalogs (admin-lehrer/lib/sdk/)
|
# TypeScript Data Catalogs (admin-lehrer/lib/sdk/)
|
||||||
# Pure exported const arrays/objects with type definitions, no business logic.
|
# Pure exported const arrays/objects with type definitions, no business logic.
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
|
|
||||||
# Single SSE generator orchestrating 6 pipeline steps — cannot split generator context
|
# Single SSE generator orchestrating 6 pipeline steps — cannot split generator context
|
||||||
**/ocr_pipeline_auto_steps.py | owner=klausur | reason=run_auto is a single async generator yielding SSE events across 6 steps (528 LOC) | review=2026-10-01
|
**/ocr_pipeline_auto_steps.py | owner=klausur | reason=run_auto is a single async generator yielding SSE events across 6 steps (528 LOC) | review=2026-10-01
|
||||||
|
**/ocr/pipeline/auto_steps.py | owner=klausur | reason=Same file moved to ocr/ package | review=2026-10-01
|
||||||
|
|
||||||
# Legacy — TEMPORAER bis Refactoring abgeschlossen
|
# Legacy — TEMPORAER bis Refactoring abgeschlossen
|
||||||
# Dateien hier werden Phase fuer Phase abgearbeitet und entfernt.
|
# Dateien hier werden Phase fuer Phase abgearbeitet und entfernt.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,14 +4,13 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY admin-lehrer/package.json admin-lehrer/package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Copy source code + shared types
|
# Copy source code
|
||||||
COPY admin-lehrer/ .
|
COPY . .
|
||||||
COPY shared/ /shared/
|
|
||||||
|
|
||||||
# Build arguments for environment variables
|
# Build arguments for environment variables
|
||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
|
|||||||
@@ -1 +1,127 @@
|
|||||||
export * from '@shared/types/ocr-labeling'
|
/**
|
||||||
|
* Shared TypeScript types for OCR Labeling UI.
|
||||||
|
*
|
||||||
|
* Single source of truth used by:
|
||||||
|
* - admin-lehrer (ai/ocr-labeling)
|
||||||
|
* - website (admin/ocr-labeling)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available OCR Models
|
||||||
|
*
|
||||||
|
* - llama3.2-vision:11b: Vision LLM, beste Qualitaet bei Handschrift (Standard)
|
||||||
|
* - trocr: Microsoft TrOCR, schnell bei gedrucktem Text
|
||||||
|
* - paddleocr: PaddleOCR + LLM, 4x schneller durch Hybrid-Ansatz
|
||||||
|
* - donut: Document Understanding Transformer, strukturierte Dokumente
|
||||||
|
*/
|
||||||
|
export type OCRModel = 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut'
|
||||||
|
|
||||||
|
export const OCR_MODEL_INFO: Record<OCRModel, { label: string; description: string; speed: string }> = {
|
||||||
|
'llama3.2-vision:11b': {
|
||||||
|
label: 'Vision LLM',
|
||||||
|
description: 'Beste Qualitaet bei Handschrift',
|
||||||
|
speed: 'langsam',
|
||||||
|
},
|
||||||
|
trocr: {
|
||||||
|
label: 'Microsoft TrOCR',
|
||||||
|
description: 'Schnell bei gedrucktem Text',
|
||||||
|
speed: 'schnell',
|
||||||
|
},
|
||||||
|
paddleocr: {
|
||||||
|
label: 'PaddleOCR + LLM',
|
||||||
|
description: 'Hybrid-Ansatz: OCR + Strukturierung',
|
||||||
|
speed: 'sehr schnell',
|
||||||
|
},
|
||||||
|
donut: {
|
||||||
|
label: 'Donut',
|
||||||
|
description: 'Document Understanding fuer Tabellen/Formulare',
|
||||||
|
speed: 'mittel',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OCRSession {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||||
|
description?: string
|
||||||
|
ocr_model?: OCRModel
|
||||||
|
total_items: number
|
||||||
|
labeled_items: number
|
||||||
|
confirmed_items: number
|
||||||
|
corrected_items: number
|
||||||
|
skipped_items: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OCRItem {
|
||||||
|
id: string
|
||||||
|
session_id: string
|
||||||
|
session_name: string
|
||||||
|
image_path: string
|
||||||
|
image_url?: string
|
||||||
|
ocr_text?: string
|
||||||
|
ocr_confidence?: number
|
||||||
|
ground_truth?: string
|
||||||
|
status: 'pending' | 'confirmed' | 'corrected' | 'skipped'
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OCRStats {
|
||||||
|
total_sessions?: number
|
||||||
|
session_id?: string
|
||||||
|
name?: string
|
||||||
|
total_items: number
|
||||||
|
labeled_items: number
|
||||||
|
confirmed_items: number
|
||||||
|
corrected_items: number
|
||||||
|
skipped_items?: number
|
||||||
|
pending_items: number
|
||||||
|
exportable_items?: number
|
||||||
|
accuracy_rate: number
|
||||||
|
avg_label_time_seconds?: number
|
||||||
|
progress_percent?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainingSample {
|
||||||
|
id: string
|
||||||
|
image_path: string
|
||||||
|
ground_truth: string
|
||||||
|
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||||
|
training_batch: string
|
||||||
|
exported_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionRequest {
|
||||||
|
name: string
|
||||||
|
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||||
|
description?: string
|
||||||
|
ocr_model?: OCRModel
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmRequest {
|
||||||
|
item_id: string
|
||||||
|
label_time_seconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorrectRequest {
|
||||||
|
item_id: string
|
||||||
|
ground_truth: string
|
||||||
|
label_time_seconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportRequest {
|
||||||
|
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||||
|
session_id?: string
|
||||||
|
batch_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
image_path: string
|
||||||
|
image_hash: string
|
||||||
|
ocr_text?: string
|
||||||
|
ocr_confidence?: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -12,13 +12,13 @@ export type {
|
|||||||
ActiveTab,
|
ActiveTab,
|
||||||
GradeTotals,
|
GradeTotals,
|
||||||
CriteriaScores,
|
CriteriaScores,
|
||||||
} from '@shared/types/klausur'
|
} from '../../../../types'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
WORKFLOW_STATUS_LABELS,
|
WORKFLOW_STATUS_LABELS,
|
||||||
ROLE_LABELS,
|
ROLE_LABELS,
|
||||||
GRADE_LABELS,
|
GRADE_LABELS,
|
||||||
} from '@shared/types/klausur'
|
} from '../../../../types'
|
||||||
|
|
||||||
/** Same-origin proxy to avoid CORS issues */
|
/** Same-origin proxy to avoid CORS issues */
|
||||||
export const API_BASE = '/klausur-api'
|
export const API_BASE = '/klausur-api'
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ export type {
|
|||||||
VorabiturEHForm,
|
VorabiturEHForm,
|
||||||
EHTemplate,
|
EHTemplate,
|
||||||
DirektuploadForm,
|
DirektuploadForm,
|
||||||
} from '@shared/types/klausur'
|
} from '../../types'
|
||||||
|
|||||||
@@ -1 +1,432 @@
|
|||||||
export * from '@shared/types/klausur'
|
/**
|
||||||
|
* Shared Klausur-Korrektur types and constants.
|
||||||
|
*
|
||||||
|
* This is the single source of truth used by:
|
||||||
|
* - admin-lehrer (education/klausur-korrektur)
|
||||||
|
* - studio-v2 (korrektur)
|
||||||
|
* - website/admin (klausur-korrektur)
|
||||||
|
* - website/lehrer (klausur-korrektur)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core domain interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Klausur {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subject: string
|
||||||
|
year: number
|
||||||
|
semester: string
|
||||||
|
modus: KlausurModus
|
||||||
|
eh_id?: string
|
||||||
|
created_at: string
|
||||||
|
student_count?: number
|
||||||
|
completed_count?: number
|
||||||
|
status?: 'draft' | 'in_progress' | 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union of all modus values used across services */
|
||||||
|
export type KlausurModus = 'abitur' | 'vorabitur' | 'landes_abitur'
|
||||||
|
|
||||||
|
export interface StudentWork {
|
||||||
|
id: string
|
||||||
|
klausur_id: string
|
||||||
|
anonym_id: string
|
||||||
|
file_path: string
|
||||||
|
file_type: 'pdf' | 'image'
|
||||||
|
ocr_text: string
|
||||||
|
criteria_scores: CriteriaScores
|
||||||
|
gutachten: string
|
||||||
|
status: StudentStatus
|
||||||
|
raw_points: number
|
||||||
|
grade_points: number
|
||||||
|
grade_label?: string
|
||||||
|
created_at: string
|
||||||
|
examiner_id?: string
|
||||||
|
second_examiner_id?: string
|
||||||
|
second_examiner_grade?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudentStatus =
|
||||||
|
| 'UPLOADED'
|
||||||
|
| 'OCR_PROCESSING'
|
||||||
|
| 'OCR_COMPLETE'
|
||||||
|
| 'ANALYZING'
|
||||||
|
| 'FIRST_EXAMINER'
|
||||||
|
| 'SECOND_EXAMINER'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'ERROR'
|
||||||
|
|
||||||
|
export interface CriteriaScores {
|
||||||
|
rechtschreibung?: number
|
||||||
|
grammatik?: number
|
||||||
|
inhalt?: number
|
||||||
|
struktur?: number
|
||||||
|
stil?: number
|
||||||
|
[key: string]: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Criterion {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
weight: number
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradeInfo {
|
||||||
|
thresholds: Record<number, number>
|
||||||
|
labels: Record<number, string>
|
||||||
|
criteria: Record<string, Criterion>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Annotations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Annotation {
|
||||||
|
id: string
|
||||||
|
student_work_id: string
|
||||||
|
page: number
|
||||||
|
position: AnnotationPosition
|
||||||
|
type: AnnotationType
|
||||||
|
text: string
|
||||||
|
severity: 'minor' | 'major' | 'critical'
|
||||||
|
suggestion?: string
|
||||||
|
created_by: string
|
||||||
|
created_at: string
|
||||||
|
role: 'first_examiner' | 'second_examiner'
|
||||||
|
linked_criterion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationPosition {
|
||||||
|
x: number // Prozent (0-100)
|
||||||
|
y: number // Prozent (0-100)
|
||||||
|
width: number // Prozent (0-100)
|
||||||
|
height: number // Prozent (0-100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnnotationType =
|
||||||
|
| 'rechtschreibung'
|
||||||
|
| 'grammatik'
|
||||||
|
| 'inhalt'
|
||||||
|
| 'struktur'
|
||||||
|
| 'stil'
|
||||||
|
| 'comment'
|
||||||
|
| 'highlight'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fairness analysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface FairnessAnalysis {
|
||||||
|
klausur_id: string
|
||||||
|
student_count: number
|
||||||
|
average_grade: number
|
||||||
|
std_deviation: number
|
||||||
|
spread: number
|
||||||
|
outliers: OutlierInfo[]
|
||||||
|
criteria_analysis: Record<string, CriteriaStats>
|
||||||
|
fairness_score: number
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlierInfo {
|
||||||
|
student_id: string
|
||||||
|
anonym_id: string
|
||||||
|
grade_points: number
|
||||||
|
deviation: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriteriaStats {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
average: number
|
||||||
|
std_deviation: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EH suggestions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface EHSuggestion {
|
||||||
|
criterion: string
|
||||||
|
excerpt: string
|
||||||
|
relevance_score: number
|
||||||
|
source_chunk_id: string
|
||||||
|
// Attribution fields (CTRL-SRC-002)
|
||||||
|
source_document?: string
|
||||||
|
source_url?: string
|
||||||
|
license?: string
|
||||||
|
license_url?: string
|
||||||
|
publisher?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default Attribution for NiBiS documents (CTRL-SRC-002) */
|
||||||
|
export const NIBIS_ATTRIBUTION = {
|
||||||
|
publisher: 'Niedersaechsischer Bildungsserver (NiBiS)',
|
||||||
|
license: 'DL-DE-BY-2.0',
|
||||||
|
license_url: 'https://www.govdata.de/dl-de/by-2-0',
|
||||||
|
source_url: 'https://nibis.de',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gutachten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GutachtenSection {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
evidence_links?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Gutachten {
|
||||||
|
einleitung: string
|
||||||
|
hauptteil: string
|
||||||
|
fazit: string
|
||||||
|
staerken: string[]
|
||||||
|
schwaechen: string[]
|
||||||
|
generated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface KlausurenResponse {
|
||||||
|
klausuren: Klausur[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentsResponse {
|
||||||
|
students: StudentWork[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationsResponse {
|
||||||
|
annotations: Annotation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create / update types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CreateKlausurData {
|
||||||
|
title: string
|
||||||
|
subject?: string
|
||||||
|
year?: number
|
||||||
|
semester?: string
|
||||||
|
modus?: KlausurModus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants — annotation colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||||
|
rechtschreibung: '#dc2626', // Red
|
||||||
|
grammatik: '#2563eb', // Blue
|
||||||
|
inhalt: '#16a34a', // Green
|
||||||
|
struktur: '#9333ea', // Purple
|
||||||
|
stil: '#ea580c', // Orange
|
||||||
|
comment: '#6b7280', // Gray
|
||||||
|
highlight: '#eab308', // Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants — status colors & labels
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||||
|
UPLOADED: '#6b7280',
|
||||||
|
OCR_PROCESSING: '#eab308',
|
||||||
|
OCR_COMPLETE: '#3b82f6',
|
||||||
|
ANALYZING: '#8b5cf6',
|
||||||
|
FIRST_EXAMINER: '#f97316',
|
||||||
|
SECOND_EXAMINER: '#06b6d4',
|
||||||
|
COMPLETED: '#22c55e',
|
||||||
|
ERROR: '#ef4444',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||||
|
UPLOADED: 'Hochgeladen',
|
||||||
|
OCR_PROCESSING: 'OCR laeuft',
|
||||||
|
OCR_COMPLETE: 'OCR fertig',
|
||||||
|
ANALYZING: 'Analyse laeuft',
|
||||||
|
FIRST_EXAMINER: 'Erstkorrektur',
|
||||||
|
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||||
|
COMPLETED: 'Abgeschlossen',
|
||||||
|
ERROR: 'Fehler',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants — criteria & grades
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Default criteria with weights (Niedersachsen standard) */
|
||||||
|
export const DEFAULT_CRITERIA: Record<string, { name: string; weight: number }> = {
|
||||||
|
rechtschreibung: { name: 'Rechtschreibung', weight: 15 },
|
||||||
|
grammatik: { name: 'Grammatik', weight: 15 },
|
||||||
|
inhalt: { name: 'Inhalt', weight: 40 },
|
||||||
|
struktur: { name: 'Struktur', weight: 15 },
|
||||||
|
stil: { name: 'Stil', weight: 15 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grade thresholds (15-point system) */
|
||||||
|
export const GRADE_THRESHOLDS: Record<number, number> = {
|
||||||
|
15: 95, 14: 90, 13: 85, 12: 80, 11: 75,
|
||||||
|
10: 70, 9: 65, 8: 60, 7: 55, 6: 50,
|
||||||
|
5: 45, 4: 40, 3: 33, 2: 27, 1: 20, 0: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Calculate grade points from a percentage (0-100). */
|
||||||
|
export function calculateGrade(percentage: number): number {
|
||||||
|
for (const [grade, threshold] of Object.entries(GRADE_THRESHOLDS).sort(
|
||||||
|
(a, b) => Number(b[0]) - Number(a[0]),
|
||||||
|
)) {
|
||||||
|
if (percentage >= threshold) {
|
||||||
|
return Number(grade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable label for a 15-point grade value. */
|
||||||
|
export function getGradeLabel(points: number): string {
|
||||||
|
const labels: Record<number, string> = {
|
||||||
|
15: '1+', 14: '1', 13: '1-',
|
||||||
|
12: '2+', 11: '2', 10: '2-',
|
||||||
|
9: '3+', 8: '3', 7: '3-',
|
||||||
|
6: '4+', 5: '4', 4: '4-',
|
||||||
|
3: '5+', 2: '5', 1: '5-',
|
||||||
|
0: '6',
|
||||||
|
}
|
||||||
|
return labels[points] || String(points)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Examiner workflow types (workspace)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ExaminerInfo {
|
||||||
|
id: string
|
||||||
|
assigned_at: string
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExaminerResult {
|
||||||
|
grade_points: number
|
||||||
|
criteria_scores?: CriteriaScores
|
||||||
|
notes?: string
|
||||||
|
submitted_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExaminerWorkflow {
|
||||||
|
student_id: string
|
||||||
|
workflow_status: string
|
||||||
|
visibility_mode: string
|
||||||
|
user_role: 'ek' | 'zk' | 'dk' | 'viewer'
|
||||||
|
first_examiner?: ExaminerInfo
|
||||||
|
second_examiner?: ExaminerInfo
|
||||||
|
third_examiner?: ExaminerInfo
|
||||||
|
first_result?: ExaminerResult
|
||||||
|
first_result_visible?: boolean
|
||||||
|
second_result?: ExaminerResult
|
||||||
|
third_result?: ExaminerResult
|
||||||
|
grade_difference?: number
|
||||||
|
final_grade?: number
|
||||||
|
consensus_reached?: boolean
|
||||||
|
consensus_type?: string
|
||||||
|
einigung?: {
|
||||||
|
final_grade: number
|
||||||
|
notes: string
|
||||||
|
type: string
|
||||||
|
submitted_by: string
|
||||||
|
submitted_at: string
|
||||||
|
ek_grade: number
|
||||||
|
zk_grade: number
|
||||||
|
}
|
||||||
|
drittkorrektur_reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
|
||||||
|
|
||||||
|
export interface GradeTotals {
|
||||||
|
raw: number
|
||||||
|
weighted: number
|
||||||
|
gradePoints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants — workflow status & roles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const GRADE_LABELS: Record<number, string> = {
|
||||||
|
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
|
||||||
|
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
|
||||||
|
3: '5+', 2: '5', 1: '5-', 0: '6',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WORKFLOW_STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
not_started: { label: 'Nicht gestartet', color: 'bg-slate-100 text-slate-700' },
|
||||||
|
ek_in_progress: { label: 'EK in Arbeit', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
ek_completed: { label: 'EK abgeschlossen', color: 'bg-blue-200 text-blue-800' },
|
||||||
|
zk_assigned: { label: 'ZK zugewiesen', color: 'bg-amber-100 text-amber-700' },
|
||||||
|
zk_in_progress: { label: 'ZK in Arbeit', color: 'bg-amber-200 text-amber-800' },
|
||||||
|
zk_completed: { label: 'ZK abgeschlossen', color: 'bg-amber-300 text-amber-900' },
|
||||||
|
einigung_required: { label: 'Einigung erforderlich', color: 'bg-orange-100 text-orange-700' },
|
||||||
|
einigung_completed: { label: 'Einigung abgeschlossen', color: 'bg-green-100 text-green-700' },
|
||||||
|
drittkorrektur_required: { label: 'DK erforderlich', color: 'bg-red-100 text-red-700' },
|
||||||
|
drittkorrektur_assigned: { label: 'DK zugewiesen', color: 'bg-red-200 text-red-800' },
|
||||||
|
drittkorrektur_in_progress: { label: 'DK in Arbeit', color: 'bg-red-300 text-red-900' },
|
||||||
|
completed: { label: 'Abgeschlossen', color: 'bg-green-200 text-green-800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
ek: { label: 'Erstkorrektor', color: 'bg-blue-500' },
|
||||||
|
zk: { label: 'Zweitkorrektor', color: 'bg-amber-500' },
|
||||||
|
dk: { label: 'Drittkorrektor', color: 'bg-purple-500' },
|
||||||
|
viewer: { label: 'Betrachter', color: 'bg-slate-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Form types (create / upload)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CreateKlausurForm {
|
||||||
|
title: string
|
||||||
|
subject: string
|
||||||
|
year: number
|
||||||
|
semester: string
|
||||||
|
modus: 'abitur' | 'vorabitur'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VorabiturEHForm {
|
||||||
|
aufgabentyp: string
|
||||||
|
titel: string
|
||||||
|
text_titel: string
|
||||||
|
text_autor: string
|
||||||
|
aufgabenstellung: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EHTemplate {
|
||||||
|
aufgabentyp: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirektuploadForm {
|
||||||
|
files: File[]
|
||||||
|
ehFile: File | null
|
||||||
|
ehText: string
|
||||||
|
aufgabentyp: string
|
||||||
|
klausurTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabId = 'willkommen' | 'klausuren' | 'erstellen' | 'direktupload' | 'statistiken'
|
||||||
|
|||||||
@@ -1 +1,329 @@
|
|||||||
export * from '@shared/types/companion'
|
/**
|
||||||
|
* TypeScript Types for Companion Module
|
||||||
|
* Migration from Flask companion.py/companion_js.py
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Phase System
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type PhaseId = 'einstieg' | 'erarbeitung' | 'sicherung' | 'transfer' | 'reflexion'
|
||||||
|
|
||||||
|
export interface Phase {
|
||||||
|
id: PhaseId
|
||||||
|
shortName: string // E, A, S, T, R
|
||||||
|
displayName: string
|
||||||
|
duration: number // minutes
|
||||||
|
status: 'planned' | 'active' | 'completed'
|
||||||
|
actualTime?: number // seconds (actual time spent)
|
||||||
|
color: string // hex color
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhaseContext {
|
||||||
|
currentPhase: PhaseId
|
||||||
|
phaseDisplayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dashboard / Companion Mode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CompanionStats {
|
||||||
|
classesCount: number
|
||||||
|
studentsCount: number
|
||||||
|
learningUnitsCreated: number
|
||||||
|
gradesEntered: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Progress {
|
||||||
|
percentage: number
|
||||||
|
completed: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestionPriority = 'urgent' | 'high' | 'medium' | 'low'
|
||||||
|
|
||||||
|
export interface Suggestion {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
priority: SuggestionPriority
|
||||||
|
icon: string // lucide icon name
|
||||||
|
actionTarget: string // navigation path
|
||||||
|
estimatedTime: number // minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventType = 'exam' | 'parent_meeting' | 'deadline' | 'other'
|
||||||
|
|
||||||
|
export interface UpcomingEvent {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
date: string // ISO date string
|
||||||
|
type: EventType
|
||||||
|
inDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanionData {
|
||||||
|
context: PhaseContext
|
||||||
|
stats: CompanionStats
|
||||||
|
phases: Phase[]
|
||||||
|
progress: Progress
|
||||||
|
suggestions: Suggestion[]
|
||||||
|
upcomingEvents: UpcomingEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lesson Mode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type LessonStatus =
|
||||||
|
| 'not_started'
|
||||||
|
| 'in_progress'
|
||||||
|
| 'paused'
|
||||||
|
| 'completed'
|
||||||
|
| 'overtime'
|
||||||
|
|
||||||
|
export interface LessonPhase {
|
||||||
|
phase: PhaseId
|
||||||
|
duration: number // planned duration in minutes
|
||||||
|
status: 'planned' | 'active' | 'completed' | 'skipped'
|
||||||
|
actualTime: number // actual time spent in seconds
|
||||||
|
startedAt?: string // ISO timestamp
|
||||||
|
completedAt?: string // ISO timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Homework {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
dueDate: string // ISO date
|
||||||
|
attachments?: string[]
|
||||||
|
completed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Material {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: 'document' | 'video' | 'presentation' | 'link' | 'other'
|
||||||
|
url?: string
|
||||||
|
fileName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonReflection {
|
||||||
|
rating: number // 1-5 stars
|
||||||
|
notes: string
|
||||||
|
nextSteps: string
|
||||||
|
savedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonSession {
|
||||||
|
sessionId: string
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
subject: string
|
||||||
|
topic?: string
|
||||||
|
startTime: string // ISO timestamp
|
||||||
|
endTime?: string // ISO timestamp
|
||||||
|
phases: LessonPhase[]
|
||||||
|
totalPlannedDuration: number // minutes
|
||||||
|
currentPhaseIndex: number
|
||||||
|
elapsedTime: number // seconds
|
||||||
|
isPaused: boolean
|
||||||
|
pausedAt?: string
|
||||||
|
pauseDuration: number // total pause time in seconds
|
||||||
|
overtimeMinutes: number
|
||||||
|
status: LessonStatus
|
||||||
|
homeworkList: Homework[]
|
||||||
|
materials: Material[]
|
||||||
|
reflection?: LessonReflection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lesson Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PhaseDurations {
|
||||||
|
einstieg: number
|
||||||
|
erarbeitung: number
|
||||||
|
sicherung: number
|
||||||
|
transfer: number
|
||||||
|
reflexion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonTemplate {
|
||||||
|
templateId: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
subject?: string
|
||||||
|
durations: PhaseDurations
|
||||||
|
isSystemTemplate: boolean
|
||||||
|
createdBy?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Settings
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TeacherSettings {
|
||||||
|
defaultPhaseDurations: PhaseDurations
|
||||||
|
preferredLessonLength: number // minutes (default 45)
|
||||||
|
autoAdvancePhases: boolean
|
||||||
|
soundNotifications: boolean
|
||||||
|
showKeyboardShortcuts: boolean
|
||||||
|
highContrastMode: boolean
|
||||||
|
onboardingCompleted: boolean
|
||||||
|
selectedTemplateId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timer State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TimerColorStatus = 'plenty' | 'warning' | 'critical' | 'overtime'
|
||||||
|
|
||||||
|
export interface TimerState {
|
||||||
|
isRunning: boolean
|
||||||
|
isPaused: boolean
|
||||||
|
elapsedSeconds: number
|
||||||
|
remainingSeconds: number
|
||||||
|
totalSeconds: number
|
||||||
|
progress: number // 0-1
|
||||||
|
colorStatus: TimerColorStatus
|
||||||
|
currentPhase: LessonPhase | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Forms
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LessonStartFormData {
|
||||||
|
classId: string
|
||||||
|
subject: string
|
||||||
|
topic?: string
|
||||||
|
templateId?: string
|
||||||
|
customDurations?: PhaseDurations
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Class {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
grade: string
|
||||||
|
studentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feedback
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type FeedbackType = 'bug' | 'feature' | 'feedback'
|
||||||
|
|
||||||
|
export interface FeedbackSubmission {
|
||||||
|
type: FeedbackType
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
screenshot?: string // base64
|
||||||
|
sessionId?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Onboarding
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface OnboardingStep {
|
||||||
|
step: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingState {
|
||||||
|
currentStep: number
|
||||||
|
totalSteps: number
|
||||||
|
steps: OnboardingStep[]
|
||||||
|
selectedState?: string // Bundesland
|
||||||
|
selectedSchoolType?: string
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WebSocket Messages
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type WSMessageType =
|
||||||
|
| 'phase_update'
|
||||||
|
| 'timer_tick'
|
||||||
|
| 'overtime_warning'
|
||||||
|
| 'pause_toggle'
|
||||||
|
| 'session_end'
|
||||||
|
| 'sync_request'
|
||||||
|
|
||||||
|
export interface WSMessage {
|
||||||
|
type: WSMessageType
|
||||||
|
payload: {
|
||||||
|
sessionId: string
|
||||||
|
phase?: number
|
||||||
|
elapsed?: number
|
||||||
|
isPaused?: boolean
|
||||||
|
overtimeMinutes?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Responses
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface APIResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
error?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponse extends APIResponse<CompanionData> {}
|
||||||
|
|
||||||
|
export interface LessonResponse extends APIResponse<LessonSession> {}
|
||||||
|
|
||||||
|
export interface TemplatesResponse extends APIResponse<{ templates: LessonTemplate[] }> {}
|
||||||
|
|
||||||
|
export interface SettingsResponse extends APIResponse<TeacherSettings> {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Props
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type CompanionMode = 'companion' | 'lesson' | 'classic'
|
||||||
|
|
||||||
|
export interface ModeToggleProps {
|
||||||
|
currentMode: CompanionMode
|
||||||
|
onModeChange: (mode: CompanionMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhaseTimelineProps {
|
||||||
|
phases: Phase[]
|
||||||
|
currentPhaseIndex: number
|
||||||
|
onPhaseClick?: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisualPieTimerProps {
|
||||||
|
progress: number // 0-1
|
||||||
|
remainingSeconds: number
|
||||||
|
totalSeconds: number
|
||||||
|
colorStatus: TimerColorStatus
|
||||||
|
isPaused: boolean
|
||||||
|
currentPhaseName: string
|
||||||
|
phaseColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickActionsBarProps {
|
||||||
|
onExtend: (minutes: number) => void
|
||||||
|
onPause: () => void
|
||||||
|
onResume: () => void
|
||||||
|
onSkip: () => void
|
||||||
|
isPaused: boolean
|
||||||
|
isLastPhase: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,9 +24,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./*"
|
"./*"
|
||||||
],
|
|
||||||
"@shared/*": [
|
|
||||||
"../shared/*"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"target": "ES2017"
|
"target": "ES2017"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# abitur — Abitur document management (exam docs, recognition).
|
||||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from abitur_docs_models import (
|
from .models import (
|
||||||
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
|
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
|
||||||
DokumentCreate, DokumentUpdate, DokumentResponse, ImportResult,
|
DokumentCreate, DokumentUpdate, DokumentResponse, ImportResult,
|
||||||
RecognitionResult, AbiturDokument,
|
RecognitionResult, AbiturDokument,
|
||||||
@@ -32,7 +32,7 @@ from abitur_docs_models import (
|
|||||||
# Backwards-compatibility re-exports
|
# Backwards-compatibility re-exports
|
||||||
AbiturFach, Anforderungsniveau, DocumentMetadata, AbiturDokumentCompat,
|
AbiturFach, Anforderungsniveau, DocumentMetadata, AbiturDokumentCompat,
|
||||||
)
|
)
|
||||||
from abitur_docs_recognition import parse_nibis_filename, to_dokument_response
|
from .recognition import parse_nibis_filename, to_dokument_response
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import re
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from abitur_docs_models import (
|
from .models import (
|
||||||
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
|
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
|
||||||
RecognitionResult, AbiturDokument, DokumentResponse,
|
RecognitionResult, AbiturDokument, DokumentResponse,
|
||||||
FACH_NAME_MAPPING,
|
FACH_NAME_MAPPING,
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
User Language Preferences API — Stores native language + learning level.
|
||||||
|
|
||||||
|
Each user (student, parent, teacher) can set their native language.
|
||||||
|
This drives: UI language, third-language display in flashcards,
|
||||||
|
parent portal language, and translation generation.
|
||||||
|
|
||||||
|
Supported languages: de, en, tr, ar, uk, ru, pl
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/user", tags=["user-language"])
|
||||||
|
|
||||||
|
# Supported native languages with metadata
|
||||||
|
SUPPORTED_LANGUAGES = {
|
||||||
|
"de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False},
|
||||||
|
"en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False},
|
||||||
|
"tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False},
|
||||||
|
"ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True},
|
||||||
|
"uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False},
|
||||||
|
"ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False},
|
||||||
|
"pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
# In-memory store (will be replaced with DB later)
|
||||||
|
_preferences: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class LanguagePreference(BaseModel):
|
||||||
|
native_language: str # ISO 639-1 code
|
||||||
|
role: str = "student" # student, parent, teacher
|
||||||
|
learning_level: str = "A1" # A1, A2, B1, B2, C1
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/languages")
|
||||||
|
def get_supported_languages():
|
||||||
|
"""List all supported native languages with metadata."""
|
||||||
|
return {
|
||||||
|
"languages": [
|
||||||
|
{"code": code, **meta}
|
||||||
|
for code, meta in SUPPORTED_LANGUAGES.items()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/language-preference")
|
||||||
|
def get_language_preference(user_id: str = Query("default")):
|
||||||
|
"""Get user's language preference."""
|
||||||
|
pref = _preferences.get(user_id)
|
||||||
|
if not pref:
|
||||||
|
return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True}
|
||||||
|
return {**pref, "is_default": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/language-preference")
|
||||||
|
def set_language_preference(
|
||||||
|
pref: LanguagePreference,
|
||||||
|
user_id: str = Query("default"),
|
||||||
|
):
|
||||||
|
"""Set user's native language and learning level."""
|
||||||
|
if pref.native_language not in SUPPORTED_LANGUAGES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. "
|
||||||
|
f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}",
|
||||||
|
)
|
||||||
|
|
||||||
|
_preferences[user_id] = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"native_language": pref.native_language,
|
||||||
|
"role": pref.role,
|
||||||
|
"learning_level": pref.learning_level,
|
||||||
|
}
|
||||||
|
|
||||||
|
lang_meta = SUPPORTED_LANGUAGES[pref.native_language]
|
||||||
|
logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})")
|
||||||
|
|
||||||
|
return {**_preferences[user_id], "language_meta": lang_meta}
|
||||||
@@ -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,
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# correction — Klassenarbeits-Korrektur (grading, feedback, OCR).
|
||||||
@@ -4,8 +4,8 @@ Correction API - REST API fuer Klassenarbeits-Korrektur.
|
|||||||
Barrel re-export: router and all public symbols.
|
Barrel re-export: router and all public symbols.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from correction_endpoints import router # noqa: F401
|
from .endpoints import router # noqa: F401
|
||||||
from correction_models import ( # noqa: F401
|
from .models import ( # noqa: F401
|
||||||
CorrectionStatus,
|
CorrectionStatus,
|
||||||
AnswerEvaluation,
|
AnswerEvaluation,
|
||||||
CorrectionCreate,
|
CorrectionCreate,
|
||||||
@@ -15,7 +15,7 @@ from correction_models import ( # noqa: F401
|
|||||||
OCRResponse,
|
OCRResponse,
|
||||||
AnalysisResponse,
|
AnalysisResponse,
|
||||||
)
|
)
|
||||||
from correction_helpers import ( # noqa: F401
|
from .helpers import ( # noqa: F401
|
||||||
corrections_store,
|
corrections_store,
|
||||||
calculate_grade,
|
calculate_grade,
|
||||||
generate_ai_feedback,
|
generate_ai_feedback,
|
||||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks
|
||||||
|
|
||||||
from correction_models import (
|
from .models import (
|
||||||
CorrectionStatus,
|
CorrectionStatus,
|
||||||
AnswerEvaluation,
|
AnswerEvaluation,
|
||||||
CorrectionCreate,
|
CorrectionCreate,
|
||||||
@@ -28,7 +28,7 @@ from correction_models import (
|
|||||||
AnalysisResponse,
|
AnalysisResponse,
|
||||||
UPLOAD_DIR,
|
UPLOAD_DIR,
|
||||||
)
|
)
|
||||||
from correction_helpers import (
|
from .helpers import (
|
||||||
corrections_store,
|
corrections_store,
|
||||||
calculate_grade,
|
calculate_grade,
|
||||||
generate_ai_feedback,
|
generate_ai_feedback,
|
||||||
@@ -5,7 +5,7 @@ Correction API - Helper functions for grading, feedback, and OCR processing.
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
|
|
||||||
from correction_models import AnswerEvaluation, CorrectionStatus, Correction
|
from .models import AnswerEvaluation, CorrectionStatus, Correction
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# dashboard — Teacher dashboard, unit assignments, analytics.
|
||||||
+1
-1
@@ -7,7 +7,7 @@ from typing import List, Optional, Dict, Any
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from teacher_dashboard_models import (
|
from .models import (
|
||||||
UnitAssignmentStatus, TeacherControlSettings,
|
UnitAssignmentStatus, TeacherControlSettings,
|
||||||
UnitAssignment, StudentUnitProgress, ClassUnitProgress,
|
UnitAssignment, StudentUnitProgress, ClassUnitProgress,
|
||||||
MisconceptionReport, ClassAnalyticsSummary, ContentResource,
|
MisconceptionReport, ClassAnalyticsSummary, ContentResource,
|
||||||
@@ -14,14 +14,14 @@ from datetime import datetime, timedelta
|
|||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from teacher_dashboard_models import (
|
from .models import (
|
||||||
UnitAssignmentStatus, TeacherControlSettings, AssignUnitRequest,
|
UnitAssignmentStatus, TeacherControlSettings, AssignUnitRequest,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
get_current_teacher, get_teacher_database,
|
get_current_teacher, get_teacher_database,
|
||||||
get_classes_for_teacher,
|
get_classes_for_teacher,
|
||||||
REQUIRE_AUTH,
|
REQUIRE_AUTH,
|
||||||
)
|
)
|
||||||
from teacher_dashboard_analytics import (
|
from .analytics import (
|
||||||
router as analytics_router,
|
router as analytics_router,
|
||||||
set_assignments_store,
|
set_assignments_store,
|
||||||
)
|
)
|
||||||
@@ -7,16 +7,16 @@
|
|||||||
# - 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
|
||||||
|
|
||||||
from game_routes import router as _core_router
|
from .routes import router as _core_router
|
||||||
from game_session_routes import router as _session_router
|
from .session_routes import router as _session_router
|
||||||
from game_extended_routes import router as _extended_router
|
from .extended_routes import router as _extended_router
|
||||||
|
|
||||||
# Re-export models for any direct importers
|
# Re-export models for any direct importers
|
||||||
from game_models import ( # noqa: F401
|
from .game_models import ( # noqa: F401
|
||||||
LearningLevel,
|
LearningLevel,
|
||||||
GameDifficulty,
|
GameDifficulty,
|
||||||
QuizQuestion,
|
QuizQuestion,
|
||||||
@@ -28,7 +28,7 @@ from game_models import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Re-export helpers/state for any direct importers
|
# Re-export helpers/state for any direct importers
|
||||||
from game_routes import ( # noqa: F401
|
from .routes import ( # noqa: F401
|
||||||
get_optional_current_user,
|
get_optional_current_user,
|
||||||
get_user_id_from_auth,
|
get_user_id_from_auth,
|
||||||
get_game_database,
|
get_game_database,
|
||||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
|||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from game_routes import (
|
from .routes import (
|
||||||
get_optional_current_user,
|
get_optional_current_user,
|
||||||
get_user_id_from_auth,
|
get_user_id_from_auth,
|
||||||
get_game_database,
|
get_game_database,
|
||||||
@@ -13,7 +13,7 @@ import uuid
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from game_models import (
|
from .game_models import (
|
||||||
LearningLevel,
|
LearningLevel,
|
||||||
GameDifficulty,
|
GameDifficulty,
|
||||||
QuizQuestion,
|
QuizQuestion,
|
||||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
|||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from game_models import (
|
from .game_models import (
|
||||||
LearningLevel,
|
LearningLevel,
|
||||||
QuizQuestion,
|
QuizQuestion,
|
||||||
GameSession,
|
GameSession,
|
||||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Import shared state and helpers from game_routes
|
# Import shared state and helpers from game_routes
|
||||||
# (these are the canonical instances)
|
# (these are the canonical instances)
|
||||||
from game_routes import (
|
from .routes import (
|
||||||
get_optional_current_user,
|
get_optional_current_user,
|
||||||
get_user_id_from_auth,
|
get_user_id_from_auth,
|
||||||
get_game_database,
|
get_game_database,
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# letters — Elternbriefe and Zeugnisse (certificates).
|
||||||
@@ -30,7 +30,7 @@ except (ImportError, OSError):
|
|||||||
SchoolInfo = None # type: ignore
|
SchoolInfo = None # type: ignore
|
||||||
_pdf_available = False
|
_pdf_available = False
|
||||||
|
|
||||||
from letters_models import (
|
from .models import (
|
||||||
LetterType,
|
LetterType,
|
||||||
LetterTone,
|
LetterTone,
|
||||||
LetterStatus,
|
LetterStatus,
|
||||||
@@ -22,7 +22,7 @@ except (ImportError, OSError):
|
|||||||
SchoolInfo = None # type: ignore
|
SchoolInfo = None # type: ignore
|
||||||
_pdf_available = False
|
_pdf_available = False
|
||||||
|
|
||||||
from certificates_models import (
|
from .certificates_models import (
|
||||||
CertificateType,
|
CertificateType,
|
||||||
CertificateStatus,
|
CertificateStatus,
|
||||||
BehaviorGrade,
|
BehaviorGrade,
|
||||||
+26
-18
@@ -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,66 +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")
|
||||||
|
|
||||||
from unit_api import router as unit_router
|
# --- 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 ---
|
||||||
|
from api.user_language import router as user_language_router
|
||||||
|
app.include_router(user_language_router, prefix="/api")
|
||||||
|
|
||||||
|
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) ---
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# messenger — Kontakte, Konversationen, Nachrichten, Gruppen.
|
||||||
@@ -13,8 +13,8 @@ Split into:
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from messenger_contacts import router as _contacts_router
|
from .contacts import router as _contacts_router
|
||||||
from messenger_conversations import router as _conversations_router
|
from .conversations import router as _conversations_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/messenger", tags=["Messenger"])
|
router = APIRouter(prefix="/api/messenger", tags=["Messenger"])
|
||||||
router.include_router(_contacts_router)
|
router.include_router(_contacts_router)
|
||||||
@@ -13,13 +13,13 @@ from typing import List, Optional
|
|||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Query
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from messenger_models import (
|
from .models import (
|
||||||
Contact,
|
Contact,
|
||||||
ContactCreate,
|
ContactCreate,
|
||||||
ContactUpdate,
|
ContactUpdate,
|
||||||
CSVImportResult,
|
CSVImportResult,
|
||||||
)
|
)
|
||||||
from messenger_helpers import get_contacts, save_contacts
|
from .helpers import get_contacts, save_contacts
|
||||||
|
|
||||||
router = APIRouter(tags=["Messenger"])
|
router = APIRouter(tags=["Messenger"])
|
||||||
|
|
||||||
+3
-3
@@ -10,14 +10,14 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from messenger_models import (
|
from .models import (
|
||||||
Conversation,
|
Conversation,
|
||||||
Group,
|
Group,
|
||||||
GroupCreate,
|
GroupCreate,
|
||||||
Message,
|
Message,
|
||||||
MessageBase,
|
MessageBase,
|
||||||
)
|
)
|
||||||
from messenger_helpers import (
|
from .helpers import (
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
DEFAULT_TEMPLATES,
|
DEFAULT_TEMPLATES,
|
||||||
get_contacts,
|
get_contacts,
|
||||||
@@ -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"],
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# recording — Meeting recordings, transcription, minutes.
|
||||||
@@ -12,9 +12,9 @@ Split into:
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from recording_routes import router as _routes_router
|
from .routes import router as _routes_router
|
||||||
from recording_transcription import router as _transcription_router
|
from .transcription import router as _transcription_router
|
||||||
from recording_minutes import router as _minutes_router
|
from .minutes import router as _minutes_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/recordings", tags=["Recordings"])
|
router = APIRouter(prefix="/api/recordings", tags=["Recordings"])
|
||||||
router.include_router(_routes_router)
|
router.include_router(_routes_router)
|
||||||
@@ -10,7 +10,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||||
|
|
||||||
from recording_helpers import (
|
from .helpers import (
|
||||||
_recordings_store,
|
_recordings_store,
|
||||||
_transcriptions_store,
|
_transcriptions_store,
|
||||||
_minutes_store,
|
_minutes_store,
|
||||||
@@ -11,7 +11,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from recording_models import (
|
from .models import (
|
||||||
JibriWebhookPayload,
|
JibriWebhookPayload,
|
||||||
RecordingResponse,
|
RecordingResponse,
|
||||||
RecordingListResponse,
|
RecordingListResponse,
|
||||||
@@ -19,7 +19,7 @@ from recording_models import (
|
|||||||
MINIO_BUCKET,
|
MINIO_BUCKET,
|
||||||
DEFAULT_RETENTION_DAYS,
|
DEFAULT_RETENTION_DAYS,
|
||||||
)
|
)
|
||||||
from recording_helpers import (
|
from .helpers import (
|
||||||
_recordings_store,
|
_recordings_store,
|
||||||
_transcriptions_store,
|
_transcriptions_store,
|
||||||
_audit_log,
|
_audit_log,
|
||||||
+2
-2
@@ -11,11 +11,11 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
from recording_models import (
|
from .models import (
|
||||||
TranscriptionRequest,
|
TranscriptionRequest,
|
||||||
TranscriptionStatusResponse,
|
TranscriptionStatusResponse,
|
||||||
)
|
)
|
||||||
from recording_helpers import (
|
from .helpers import (
|
||||||
_recordings_store,
|
_recordings_store,
|
||||||
_transcriptions_store,
|
_transcriptions_store,
|
||||||
log_audit,
|
log_audit,
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Translation Service — Batch-translates vocabulary words into target languages.
|
||||||
|
|
||||||
|
Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL.
|
||||||
|
Translations are cached in vocabulary_words.translations JSONB field.
|
||||||
|
|
||||||
|
All processing happens locally — no external API calls, GDPR-compliant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
|
||||||
|
TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b")
|
||||||
|
|
||||||
|
LANGUAGE_NAMES = {
|
||||||
|
"tr": "Turkish",
|
||||||
|
"ar": "Arabic",
|
||||||
|
"uk": "Ukrainian",
|
||||||
|
"ru": "Russian",
|
||||||
|
"pl": "Polish",
|
||||||
|
"fr": "French",
|
||||||
|
"es": "Spanish",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def translate_words_batch(
|
||||||
|
words: List[Dict[str, str]],
|
||||||
|
target_language: str,
|
||||||
|
batch_size: int = 30,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Translate a batch of EN/DE word pairs into a target language.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
words: List of dicts with 'english' and 'german' keys
|
||||||
|
target_language: ISO 639-1 code (tr, ar, uk, ru, pl)
|
||||||
|
batch_size: Words per LLM request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with 'english', 'translation', 'example' keys
|
||||||
|
"""
|
||||||
|
lang_name = LANGUAGE_NAMES.get(target_language, target_language)
|
||||||
|
all_translations = []
|
||||||
|
|
||||||
|
for i in range(0, len(words), batch_size):
|
||||||
|
batch = words[i:i + batch_size]
|
||||||
|
word_list = "\n".join(
|
||||||
|
f"{j+1}. {w['english']} = {w.get('german', '')}"
|
||||||
|
for j, w in enumerate(batch)
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""Translate these English/German word pairs into {lang_name}.
|
||||||
|
For each word, provide the translation and a short example sentence in {lang_name}.
|
||||||
|
|
||||||
|
Words:
|
||||||
|
{word_list}
|
||||||
|
|
||||||
|
Reply ONLY with a JSON array, no explanation:
|
||||||
|
[
|
||||||
|
{{"english": "word", "translation": "...", "example": "..."}},
|
||||||
|
...
|
||||||
|
]"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{OLLAMA_BASE_URL}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": TRANSLATION_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.2, "num_predict": 4096},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
response_text = resp.json().get("response", "")
|
||||||
|
|
||||||
|
# Parse JSON from response
|
||||||
|
import re
|
||||||
|
match = re.search(r'\[[\s\S]*\]', response_text)
|
||||||
|
if match:
|
||||||
|
batch_translations = json.loads(match.group())
|
||||||
|
all_translations.extend(batch_translations)
|
||||||
|
logger.info(
|
||||||
|
f"Translated batch {i//batch_size + 1}: "
|
||||||
|
f"{len(batch_translations)} words → {lang_name}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"No JSON array in LLM response for {lang_name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Translation batch failed ({lang_name}): {e}")
|
||||||
|
|
||||||
|
return all_translations
|
||||||
|
|
||||||
|
|
||||||
|
async def translate_and_store(
|
||||||
|
word_ids: List[str],
|
||||||
|
target_language: str,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Translate vocabulary words and store in the database.
|
||||||
|
|
||||||
|
Fetches words from DB, translates via LLM, stores in translations JSONB.
|
||||||
|
Skips words that already have a translation for the target language.
|
||||||
|
|
||||||
|
Returns count of newly translated words.
|
||||||
|
"""
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Fetch words that need translation
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id, english, german, translations
|
||||||
|
FROM vocabulary_words
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
""",
|
||||||
|
[__import__('uuid').UUID(wid) for wid in word_ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
words_to_translate = []
|
||||||
|
word_map = {}
|
||||||
|
for row in rows:
|
||||||
|
translations = row["translations"] or {}
|
||||||
|
if isinstance(translations, str):
|
||||||
|
translations = json.loads(translations)
|
||||||
|
if target_language not in translations:
|
||||||
|
words_to_translate.append({
|
||||||
|
"english": row["english"],
|
||||||
|
"german": row["german"],
|
||||||
|
})
|
||||||
|
word_map[row["english"].lower()] = str(row["id"])
|
||||||
|
|
||||||
|
if not words_to_translate:
|
||||||
|
logger.info(f"All {len(rows)} words already translated to {target_language}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Translate
|
||||||
|
results = await translate_words_batch(words_to_translate, target_language)
|
||||||
|
|
||||||
|
# Store results
|
||||||
|
updated = 0
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
for result in results:
|
||||||
|
en = result.get("english", "").lower()
|
||||||
|
word_id = word_map.get(en)
|
||||||
|
if not word_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
translation = result.get("translation", "")
|
||||||
|
example = result.get("example", "")
|
||||||
|
if not translation:
|
||||||
|
continue
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE vocabulary_words
|
||||||
|
SET translations = translations || $1::jsonb
|
||||||
|
WHERE id = $2
|
||||||
|
""",
|
||||||
|
json.dumps({target_language: {
|
||||||
|
"text": translation,
|
||||||
|
"example": example,
|
||||||
|
}}),
|
||||||
|
__import__('uuid').UUID(word_id),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
logger.info(f"Stored {updated} translations for {target_language}")
|
||||||
|
return updated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# units — Learning units, analytics, definitions, content generation.
|
||||||
@@ -17,8 +17,8 @@ Split into:
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from unit_analytics_routes import router as _routes_router
|
from .analytics_routes import router as _routes_router
|
||||||
from unit_analytics_export import router as _export_router
|
from .analytics_export import router as _export_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/analytics", tags=["Unit Analytics"])
|
router = APIRouter(prefix="/api/analytics", tags=["Unit Analytics"])
|
||||||
router.include_router(_routes_router)
|
router.include_router(_routes_router)
|
||||||
@@ -11,8 +11,8 @@ from typing import Optional, Dict, Any
|
|||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from unit_analytics_models import TimeRange, ExportFormat
|
from .analytics_models import TimeRange, ExportFormat
|
||||||
from unit_analytics_helpers import get_analytics_database
|
from .analytics_helpers import get_analytics_database
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ async def export_misconceptions(
|
|||||||
Export misconception data for further analysis.
|
Export misconception data for further analysis.
|
||||||
"""
|
"""
|
||||||
# Import here to avoid circular dependency
|
# Import here to avoid circular dependency
|
||||||
from unit_analytics_routes import get_misconception_report
|
from .analytics_routes import get_misconception_report
|
||||||
|
|
||||||
report = await get_misconception_report(
|
report = await get_misconception_report(
|
||||||
class_id=class_id, unit_id=None,
|
class_id=class_id, unit_id=None,
|
||||||
@@ -12,7 +12,7 @@ from typing import Optional, Dict, Any, List
|
|||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from unit_analytics_models import (
|
from .analytics_models import (
|
||||||
TimeRange,
|
TimeRange,
|
||||||
LearningGainData,
|
LearningGainData,
|
||||||
LearningGainSummary,
|
LearningGainSummary,
|
||||||
@@ -23,7 +23,7 @@ from unit_analytics_models import (
|
|||||||
StudentProgressTimeline,
|
StudentProgressTimeline,
|
||||||
ClassComparisonData,
|
ClassComparisonData,
|
||||||
)
|
)
|
||||||
from unit_analytics_helpers import (
|
from .analytics_helpers import (
|
||||||
get_analytics_database,
|
get_analytics_database,
|
||||||
calculate_gain_distribution,
|
calculate_gain_distribution,
|
||||||
calculate_trend,
|
calculate_trend,
|
||||||
@@ -8,16 +8,16 @@
|
|||||||
# - 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
|
||||||
|
|
||||||
from unit_routes import router as _routes_router
|
from .routes import router as _routes_router
|
||||||
from unit_definition_routes import router as _definition_router
|
from .definition_routes import router as _definition_router
|
||||||
from unit_content_routes import router as _content_router
|
from .content_routes import router as _content_router
|
||||||
|
|
||||||
# Re-export models for any direct importers
|
# Re-export models for any direct importers
|
||||||
from unit_models import ( # noqa: F401
|
from .models import ( # noqa: F401
|
||||||
UnitDefinitionResponse,
|
UnitDefinitionResponse,
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
@@ -36,7 +36,7 @@ from unit_models import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Re-export helpers for any direct importers
|
# Re-export helpers for any direct importers
|
||||||
from unit_helpers import ( # noqa: F401
|
from .helpers import ( # noqa: F401
|
||||||
get_optional_current_user,
|
get_optional_current_user,
|
||||||
get_unit_database,
|
get_unit_database,
|
||||||
create_session_token,
|
create_session_token,
|
||||||
@@ -8,8 +8,8 @@ from fastapi import APIRouter, HTTPException, Query, Depends
|
|||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from unit_models import UnitDefinitionResponse
|
from .models import UnitDefinitionResponse
|
||||||
from unit_helpers import get_optional_current_user, get_unit_database
|
from .helpers import get_optional_current_user, get_unit_database
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
+2
-2
@@ -9,13 +9,13 @@ from typing import Optional, Dict, Any
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from unit_models import (
|
from .models import (
|
||||||
UnitDefinitionResponse,
|
UnitDefinitionResponse,
|
||||||
CreateUnitRequest,
|
CreateUnitRequest,
|
||||||
UpdateUnitRequest,
|
UpdateUnitRequest,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
)
|
)
|
||||||
from unit_helpers import (
|
from .helpers import (
|
||||||
get_optional_current_user,
|
get_optional_current_user,
|
||||||
get_unit_database,
|
get_unit_database,
|
||||||
validate_unit_definition,
|
validate_unit_definition,
|
||||||
@@ -11,7 +11,7 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
from unit_models import ValidationError, ValidationResult
|
from .models import ValidationError, ValidationResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import logging
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from learning_units import (
|
from .learning import (
|
||||||
LearningUnit,
|
LearningUnit,
|
||||||
LearningUnitCreate,
|
LearningUnitCreate,
|
||||||
LearningUnitUpdate,
|
LearningUnitUpdate,
|
||||||
@@ -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,
|
||||||
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta
|
|||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from unit_models import (
|
from .models import (
|
||||||
UnitDefinitionResponse,
|
UnitDefinitionResponse,
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
@@ -23,7 +23,7 @@ from unit_models import (
|
|||||||
UnitListItem,
|
UnitListItem,
|
||||||
RecommendedUnit,
|
RecommendedUnit,
|
||||||
)
|
)
|
||||||
from unit_helpers import (
|
from .helpers import (
|
||||||
get_optional_current_user,
|
get_optional_current_user,
|
||||||
get_unit_database,
|
get_unit_database,
|
||||||
create_session_token,
|
create_session_token,
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
"""
|
||||||
|
Vocabulary API — Search, browse, and build learning units from the word catalog.
|
||||||
|
|
||||||
|
Endpoints for teachers to find words and create learning units,
|
||||||
|
and for students to access word details with audio/images/syllables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .db import (
|
||||||
|
search_words,
|
||||||
|
get_word,
|
||||||
|
browse_words,
|
||||||
|
insert_word,
|
||||||
|
count_words,
|
||||||
|
get_all_tags,
|
||||||
|
get_all_pos,
|
||||||
|
VocabularyWord,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Search & Browse
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def api_search_words(
|
||||||
|
q: str = Query("", description="Search query"),
|
||||||
|
lang: str = Query("en"),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
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.
|
||||||
|
|
||||||
|
source=kaikki searches the 6.27M Kaikki/Wiktionary dictionary.
|
||||||
|
source=manual searches the manually curated vocabulary_words table.
|
||||||
|
"""
|
||||||
|
if not q.strip():
|
||||||
|
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)
|
||||||
|
return {
|
||||||
|
"words": [w.to_dict() for w in words],
|
||||||
|
"query": q,
|
||||||
|
"total": len(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")
|
||||||
|
async def api_browse_words(
|
||||||
|
pos: str = Query("", description="Part of speech filter"),
|
||||||
|
difficulty: int = Query(0, ge=0, le=5, description="Difficulty 1-5, 0=all"),
|
||||||
|
tag: str = Query("", description="Tag filter"),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
"""Browse vocabulary words with filters."""
|
||||||
|
words = await browse_words(
|
||||||
|
pos=pos, difficulty=difficulty, tag=tag,
|
||||||
|
limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"words": [w.to_dict() for w in words],
|
||||||
|
"filters": {"pos": pos, "difficulty": difficulty, "tag": tag},
|
||||||
|
"total": len(words),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/word/{word_id}")
|
||||||
|
async def api_get_word(word_id: str):
|
||||||
|
"""Get a single word with all details."""
|
||||||
|
word = await get_word(word_id)
|
||||||
|
if not word:
|
||||||
|
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
||||||
|
return word.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/filters")
|
||||||
|
async def api_get_filters():
|
||||||
|
"""Get available filter options (tags, parts of speech, word count)."""
|
||||||
|
tags = await get_all_tags()
|
||||||
|
pos_list = await get_all_pos()
|
||||||
|
total = await count_words()
|
||||||
|
# Kaikki stats (hardcoded to avoid slow COUNT on 6M rows)
|
||||||
|
return {
|
||||||
|
"tags": tags,
|
||||||
|
"parts_of_speech": pos_list,
|
||||||
|
"total_words": total,
|
||||||
|
"kaikki_total": 6271749,
|
||||||
|
"kaikki_languages": 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audio TTS for Words
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/word/{word_id}/audio/{lang}")
|
||||||
|
async def api_get_word_audio(word_id: str, lang: str = "en"):
|
||||||
|
"""Get or generate TTS audio for a vocabulary word.
|
||||||
|
|
||||||
|
Returns MP3 audio. Generated on first request, cached after.
|
||||||
|
Uses Piper TTS (MIT license) with Thorsten (DE) and Lessac (EN) voices.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import Response as FastAPIResponse
|
||||||
|
|
||||||
|
word = await get_word(word_id)
|
||||||
|
if not word:
|
||||||
|
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
||||||
|
|
||||||
|
text = word.english if lang == "en" else word.german
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'")
|
||||||
|
|
||||||
|
from services.audio import get_or_generate_audio
|
||||||
|
audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id)
|
||||||
|
|
||||||
|
if not audio_bytes:
|
||||||
|
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
|
||||||
|
|
||||||
|
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/word/{word_id}/audio-syllables/{lang}")
|
||||||
|
async def api_get_syllable_audio(word_id: str, lang: str = "en"):
|
||||||
|
"""Get TTS audio with slow syllable pronunciation.
|
||||||
|
|
||||||
|
Generates audio like "ap ... ple" with pauses between syllables.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import Response as FastAPIResponse
|
||||||
|
|
||||||
|
word = await get_word(word_id)
|
||||||
|
if not word:
|
||||||
|
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
||||||
|
|
||||||
|
syllables = word.syllables_en if lang == "en" else word.syllables_de
|
||||||
|
if not syllables:
|
||||||
|
# Fallback to full word
|
||||||
|
text = word.english if lang == "en" else word.german
|
||||||
|
syllables = [text]
|
||||||
|
|
||||||
|
# Join syllables with pauses (Piper handles "..." as pause)
|
||||||
|
slow_text = " ... ".join(syllables)
|
||||||
|
|
||||||
|
from services.audio import get_or_generate_audio
|
||||||
|
cache_key = f"{word_id}_syl_{lang}"
|
||||||
|
audio_bytes = await get_or_generate_audio(slow_text, language=lang, word_id=cache_key)
|
||||||
|
|
||||||
|
if not audio_bytes:
|
||||||
|
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
|
||||||
|
|
||||||
|
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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Unit creation and translation lookup moved to vocabulary/unit_api.py
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bulk Import (for seeding the dictionary)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BulkImportPayload(BaseModel):
|
||||||
|
words: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def api_bulk_import(payload: BulkImportPayload):
|
||||||
|
"""Bulk import vocabulary words (for seeding the dictionary).
|
||||||
|
|
||||||
|
Each word dict should have at minimum: english, german.
|
||||||
|
Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de,
|
||||||
|
example_en, example_de, difficulty, tags, translations.
|
||||||
|
"""
|
||||||
|
from .db import insert_words_bulk
|
||||||
|
|
||||||
|
words = []
|
||||||
|
for w in payload.words:
|
||||||
|
words.append(VocabularyWord(
|
||||||
|
english=w.get("english", ""),
|
||||||
|
german=w.get("german", ""),
|
||||||
|
ipa_en=w.get("ipa_en", ""),
|
||||||
|
ipa_de=w.get("ipa_de", ""),
|
||||||
|
part_of_speech=w.get("part_of_speech", ""),
|
||||||
|
syllables_en=w.get("syllables_en", []),
|
||||||
|
syllables_de=w.get("syllables_de", []),
|
||||||
|
example_en=w.get("example_en", ""),
|
||||||
|
example_de=w.get("example_de", ""),
|
||||||
|
difficulty=w.get("difficulty", 1),
|
||||||
|
tags=w.get("tags", []),
|
||||||
|
translations=w.get("translations", {}),
|
||||||
|
))
|
||||||
|
|
||||||
|
count = await insert_words_bulk(words)
|
||||||
|
logger.info(f"Bulk imported {count} vocabulary words")
|
||||||
|
return {"imported": count}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Translation Generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
||||||
|
word_ids: List[str]
|
||||||
|
target_language: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/translate")
|
||||||
|
async def api_translate_words(payload: TranslateRequest):
|
||||||
|
"""Generate translations for vocabulary words into a target language.
|
||||||
|
|
||||||
|
Uses local LLM (Ollama) for translation. Results are cached in the
|
||||||
|
vocabulary_words.translations JSONB field.
|
||||||
|
"""
|
||||||
|
from services.translation import translate_and_store
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
count = await translate_and_store(payload.word_ids, payload.target_language)
|
||||||
|
return {"translated": count, "target_language": payload.target_language}
|
||||||
@@ -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
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
"""
|
|
||||||
Vocabulary API — Search, browse, and build learning units from the word catalog.
|
|
||||||
|
|
||||||
Endpoints for teachers to find words and create learning units,
|
|
||||||
and for students to access word details with audio/images/syllables.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from vocabulary_db import (
|
|
||||||
search_words,
|
|
||||||
get_word,
|
|
||||||
browse_words,
|
|
||||||
insert_word,
|
|
||||||
count_words,
|
|
||||||
get_all_tags,
|
|
||||||
get_all_pos,
|
|
||||||
VocabularyWord,
|
|
||||||
)
|
|
||||||
from learning_units import (
|
|
||||||
LearningUnitCreate,
|
|
||||||
create_learning_unit,
|
|
||||||
get_learning_unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Search & Browse
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search")
|
|
||||||
async def api_search_words(
|
|
||||||
q: str = Query("", description="Search query"),
|
|
||||||
lang: str = Query("en", pattern="^(en|de)$"),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
):
|
|
||||||
"""Full-text search for vocabulary words."""
|
|
||||||
if not q.strip():
|
|
||||||
return {"words": [], "query": q, "total": 0}
|
|
||||||
|
|
||||||
words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset)
|
|
||||||
return {
|
|
||||||
"words": [w.to_dict() for w in words],
|
|
||||||
"query": q,
|
|
||||||
"total": len(words),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse")
|
|
||||||
async def api_browse_words(
|
|
||||||
pos: str = Query("", description="Part of speech filter"),
|
|
||||||
difficulty: int = Query(0, ge=0, le=5, description="Difficulty 1-5, 0=all"),
|
|
||||||
tag: str = Query("", description="Tag filter"),
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
):
|
|
||||||
"""Browse vocabulary words with filters."""
|
|
||||||
words = await browse_words(
|
|
||||||
pos=pos, difficulty=difficulty, tag=tag,
|
|
||||||
limit=limit, offset=offset,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"words": [w.to_dict() for w in words],
|
|
||||||
"filters": {"pos": pos, "difficulty": difficulty, "tag": tag},
|
|
||||||
"total": len(words),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/word/{word_id}")
|
|
||||||
async def api_get_word(word_id: str):
|
|
||||||
"""Get a single word with all details."""
|
|
||||||
word = await get_word(word_id)
|
|
||||||
if not word:
|
|
||||||
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
|
||||||
return word.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/filters")
|
|
||||||
async def api_get_filters():
|
|
||||||
"""Get available filter options (tags, parts of speech, word count)."""
|
|
||||||
tags = await get_all_tags()
|
|
||||||
pos_list = await get_all_pos()
|
|
||||||
total = await count_words()
|
|
||||||
return {
|
|
||||||
"tags": tags,
|
|
||||||
"parts_of_speech": pos_list,
|
|
||||||
"total_words": total,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Audio TTS for Words
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/word/{word_id}/audio/{lang}")
|
|
||||||
async def api_get_word_audio(word_id: str, lang: str = "en"):
|
|
||||||
"""Get or generate TTS audio for a vocabulary word.
|
|
||||||
|
|
||||||
Returns MP3 audio. Generated on first request, cached after.
|
|
||||||
Uses Piper TTS (MIT license) with Thorsten (DE) and Lessac (EN) voices.
|
|
||||||
"""
|
|
||||||
from fastapi.responses import Response as FastAPIResponse
|
|
||||||
|
|
||||||
word = await get_word(word_id)
|
|
||||||
if not word:
|
|
||||||
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
|
||||||
|
|
||||||
text = word.english if lang == "en" else word.german
|
|
||||||
if not text:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'")
|
|
||||||
|
|
||||||
from audio_service import get_or_generate_audio
|
|
||||||
audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id)
|
|
||||||
|
|
||||||
if not audio_bytes:
|
|
||||||
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
|
|
||||||
|
|
||||||
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/word/{word_id}/audio-syllables/{lang}")
|
|
||||||
async def api_get_syllable_audio(word_id: str, lang: str = "en"):
|
|
||||||
"""Get TTS audio with slow syllable pronunciation.
|
|
||||||
|
|
||||||
Generates audio like "ap ... ple" with pauses between syllables.
|
|
||||||
"""
|
|
||||||
from fastapi.responses import Response as FastAPIResponse
|
|
||||||
|
|
||||||
word = await get_word(word_id)
|
|
||||||
if not word:
|
|
||||||
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
|
||||||
|
|
||||||
syllables = word.syllables_en if lang == "en" else word.syllables_de
|
|
||||||
if not syllables:
|
|
||||||
# Fallback to full word
|
|
||||||
text = word.english if lang == "en" else word.german
|
|
||||||
syllables = [text]
|
|
||||||
|
|
||||||
# Join syllables with pauses (Piper handles "..." as pause)
|
|
||||||
slow_text = " ... ".join(syllables)
|
|
||||||
|
|
||||||
from audio_service import get_or_generate_audio
|
|
||||||
cache_key = f"{word_id}_syl_{lang}"
|
|
||||||
audio_bytes = await get_or_generate_audio(slow_text, language=lang, word_id=cache_key)
|
|
||||||
|
|
||||||
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
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class CreateUnitFromWordsPayload(BaseModel):
|
|
||||||
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", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Bulk Import (for seeding the dictionary)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class BulkImportPayload(BaseModel):
|
|
||||||
words: List[Dict[str, Any]]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import")
|
|
||||||
async def api_bulk_import(payload: BulkImportPayload):
|
|
||||||
"""Bulk import vocabulary words (for seeding the dictionary).
|
|
||||||
|
|
||||||
Each word dict should have at minimum: english, german.
|
|
||||||
Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de,
|
|
||||||
example_en, example_de, difficulty, tags, translations.
|
|
||||||
"""
|
|
||||||
from vocabulary_db import insert_words_bulk
|
|
||||||
|
|
||||||
words = []
|
|
||||||
for w in payload.words:
|
|
||||||
words.append(VocabularyWord(
|
|
||||||
english=w.get("english", ""),
|
|
||||||
german=w.get("german", ""),
|
|
||||||
ipa_en=w.get("ipa_en", ""),
|
|
||||||
ipa_de=w.get("ipa_de", ""),
|
|
||||||
part_of_speech=w.get("part_of_speech", ""),
|
|
||||||
syllables_en=w.get("syllables_en", []),
|
|
||||||
syllables_de=w.get("syllables_de", []),
|
|
||||||
example_en=w.get("example_en", ""),
|
|
||||||
example_de=w.get("example_de", ""),
|
|
||||||
difficulty=w.get("difficulty", 1),
|
|
||||||
tags=w.get("tags", []),
|
|
||||||
translations=w.get("translations", {}),
|
|
||||||
))
|
|
||||||
|
|
||||||
count = await insert_words_bulk(words)
|
|
||||||
logger.info(f"Bulk imported {count} vocabulary words")
|
|
||||||
return {"imported": count}
|
|
||||||
@@ -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,
|
||||||
+30
-6
@@ -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:
|
||||||
@@ -60,8 +61,8 @@ services:
|
|||||||
# =========================================================
|
# =========================================================
|
||||||
admin-lehrer:
|
admin-lehrer:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./admin-lehrer
|
||||||
dockerfile: admin-lehrer/Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://macmini:8001}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://macmini:8001}
|
||||||
NEXT_PUBLIC_OLD_ADMIN_URL: ${NEXT_PUBLIC_OLD_ADMIN_URL:-http://macmini:3000/admin}
|
NEXT_PUBLIC_OLD_ADMIN_URL: ${NEXT_PUBLIC_OLD_ADMIN_URL:-http://macmini:3000/admin}
|
||||||
@@ -95,8 +96,8 @@ services:
|
|||||||
|
|
||||||
studio-v2:
|
studio-v2:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./studio-v2
|
||||||
dockerfile: studio-v2/Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_VOICE_SERVICE_URL: ${NEXT_PUBLIC_VOICE_SERVICE_URL:-wss://macmini:8091}
|
NEXT_PUBLIC_VOICE_SERVICE_URL: ${NEXT_PUBLIC_VOICE_SERVICE_URL:-wss://macmini:8091}
|
||||||
NEXT_PUBLIC_KLAUSUR_SERVICE_URL: ${NEXT_PUBLIC_KLAUSUR_SERVICE_URL:-https://macmini:8086}
|
NEXT_PUBLIC_KLAUSUR_SERVICE_URL: ${NEXT_PUBLIC_KLAUSUR_SERVICE_URL:-https://macmini:8086}
|
||||||
@@ -108,16 +109,18 @@ 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
|
||||||
|
|
||||||
website:
|
website:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./website
|
||||||
dockerfile: website/Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_BILLING_API_URL: ${NEXT_PUBLIC_BILLING_API_URL:-https://macmini:8083}
|
NEXT_PUBLIC_BILLING_API_URL: ${NEXT_PUBLIC_BILLING_API_URL:-https://macmini:8083}
|
||||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://macmini}
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://macmini}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
admin package — admin APIs for NiBiS, RAG, templates.
|
||||||
|
|
||||||
|
Backward-compatible re-exports: consumers can still use
|
||||||
|
``from admin.api import ...`` etc. via the shim files in backend/.
|
||||||
|
"""
|
||||||
@@ -7,23 +7,23 @@ 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
|
||||||
|
|
||||||
from admin_nibis import router as _nibis_router
|
from .nibis import router as _nibis_router
|
||||||
from admin_rag import router as _rag_router
|
from .rag import router as _rag_router
|
||||||
from admin_templates import router as _templates_router
|
from .templates import router as _templates_router
|
||||||
|
|
||||||
# Re-export internal state for test importers
|
# Re-export internal state for test importers
|
||||||
from admin_nibis import ( # noqa: F401
|
from .nibis import ( # noqa: F401
|
||||||
_ingestion_status,
|
_ingestion_status,
|
||||||
NiBiSSearchRequest,
|
NiBiSSearchRequest,
|
||||||
search_nibis,
|
search_nibis,
|
||||||
)
|
)
|
||||||
from admin_rag import _upload_history # noqa: F401
|
from .rag import _upload_history # noqa: F401
|
||||||
from admin_templates import _templates_ingestion_status # noqa: F401
|
from .templates import _templates_ingestion_status # noqa: F401
|
||||||
|
|
||||||
# Assemble the combined router.
|
# Assemble the combined router.
|
||||||
# All sub-routers use prefix="/api/v1/admin", so include without extra prefix.
|
# All sub-routers use prefix="/api/v1/admin", so include without extra prefix.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user