Compare commits
104 Commits
dc60233262
..
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 | |||
| f3b9617fc3 | |||
| 8efffe8c52 | |||
| a317bd6164 |
@@ -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)
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|
||||
@@ -27,9 +27,11 @@
|
||||
|
||||
# Algorithmic monolith — detect_column_geometry() allein 411 LOC, nicht weiter teilbar
|
||||
**/cv_layout_columns.py | owner=klausur | reason=detect_column_geometry ist eine einzelne 411-LOC Funktion (Whitespace-Gap-Analyse) | review=2026-10-01
|
||||
**/ocr/layout/columns.py | owner=klausur | reason=Same file moved to ocr/ package | review=2026-10-01
|
||||
|
||||
# Two indivisible route handlers (~230 LOC each) that cannot be split further
|
||||
**/vocab_worksheet_compare_api.py | owner=klausur | reason=compare_ocr_methods (234 LOC) + analyze_grid (255 LOC), each a single cohesive handler | review=2026-10-01
|
||||
**/vocab/worksheet/compare_api.py | owner=klausur | reason=Same file moved to vocab/ package | review=2026-10-01
|
||||
|
||||
# TypeScript Data Catalogs (admin-lehrer/lib/sdk/)
|
||||
# Pure exported const arrays/objects with type definitions, no business logic.
|
||||
@@ -45,6 +47,7 @@
|
||||
|
||||
# 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=Same file moved to ocr/ package | review=2026-10-01
|
||||
|
||||
# Legacy — TEMPORAER bis Refactoring abgeschlossen
|
||||
# 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
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* TypeScript types for OCR Labeling UI
|
||||
* Shared TypeScript types for OCR Labeling UI.
|
||||
*
|
||||
* Single source of truth used by:
|
||||
* - admin-lehrer (ai/ocr-labeling)
|
||||
* - website (admin/ocr-labeling)
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+16
-85
@@ -1,93 +1,24 @@
|
||||
/**
|
||||
* Types and constants for the Korrektur-Workspace page.
|
||||
*
|
||||
* Domain types are re-exported from the shared module.
|
||||
* Only the API_BASE constant remains local (uses Next.js rewrite proxy).
|
||||
*/
|
||||
|
||||
import type { CriteriaScores } from '../../../types'
|
||||
export type {
|
||||
ExaminerInfo,
|
||||
ExaminerResult,
|
||||
ExaminerWorkflow,
|
||||
ActiveTab,
|
||||
GradeTotals,
|
||||
CriteriaScores,
|
||||
} from '../../../../types'
|
||||
|
||||
// ---- Examiner workflow types ----
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---- Active tab ----
|
||||
|
||||
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
|
||||
|
||||
// ---- Totals from grade calculation ----
|
||||
|
||||
export interface GradeTotals {
|
||||
raw: number
|
||||
weighted: number
|
||||
gradePoints: number
|
||||
}
|
||||
|
||||
// ---- Constants ----
|
||||
export {
|
||||
WORKFLOW_STATUS_LABELS,
|
||||
ROLE_LABELS,
|
||||
GRADE_LABELS,
|
||||
} from '../../../../types'
|
||||
|
||||
/** Same-origin proxy to avoid CORS issues */
|
||||
export const API_BASE = '/klausur-api'
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
/**
|
||||
* Local form types for Klausur-Korrektur page
|
||||
* Local form types for Klausur-Korrektur page.
|
||||
* Re-exported from the shared module.
|
||||
*/
|
||||
|
||||
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 {
|
||||
CreateKlausurForm,
|
||||
VorabiturEHForm,
|
||||
EHTemplate,
|
||||
DirektuploadForm,
|
||||
} from '../../types'
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
// TypeScript Interfaces für Klausur-Korrektur
|
||||
/**
|
||||
* 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
|
||||
@@ -6,7 +18,7 @@ export interface Klausur {
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
modus: KlausurModus
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
@@ -14,6 +26,9 @@ export interface Klausur {
|
||||
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
|
||||
@@ -65,6 +80,10 @@ export interface GradeInfo {
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Annotations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
@@ -96,6 +115,10 @@ export type AnnotationType =
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fairness analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
@@ -123,13 +146,35 @@ export interface CriteriaStats {
|
||||
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
|
||||
@@ -145,7 +190,10 @@ export interface Gutachten {
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
@@ -160,7 +208,22 @@ export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
@@ -171,7 +234,10 @@ export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants — status colors & labels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
@@ -193,3 +259,174 @@ export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
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'
|
||||
|
||||
@@ -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.responses import FileResponse
|
||||
|
||||
from abitur_docs_models import (
|
||||
from .models import (
|
||||
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
|
||||
DokumentCreate, DokumentUpdate, DokumentResponse, ImportResult,
|
||||
RecognitionResult, AbiturDokument,
|
||||
@@ -32,7 +32,7 @@ from abitur_docs_models import (
|
||||
# Backwards-compatibility re-exports
|
||||
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__)
|
||||
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from abitur_docs_models import (
|
||||
from .models import (
|
||||
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
|
||||
RecognitionResult, AbiturDokument, DokumentResponse,
|
||||
FACH_NAME_MAPPING,
|
||||
@@ -158,7 +158,7 @@ def _analyze_with_openai(input_path: Path) -> Path:
|
||||
|
||||
def _analyze_with_claude(input_path: Path) -> Path:
|
||||
"""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():
|
||||
raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}")
|
||||
|
||||
@@ -8,7 +8,7 @@ A modular AI-powered worksheet processing system for:
|
||||
- Mindmap visualization
|
||||
|
||||
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
|
||||
|
||||
@@ -157,7 +157,7 @@ def _analyze_with_claude(input_path: Path) -> Path:
|
||||
|
||||
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():
|
||||
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,
|
||||
get_phase_info,
|
||||
)
|
||||
from state_engine_models import (
|
||||
from .state_engine_models import (
|
||||
MilestoneRequest,
|
||||
TransitionRequest,
|
||||
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.
|
||||
"""
|
||||
|
||||
from correction_endpoints import router # noqa: F401
|
||||
from correction_models import ( # noqa: F401
|
||||
from .endpoints import router # noqa: F401
|
||||
from .models import ( # noqa: F401
|
||||
CorrectionStatus,
|
||||
AnswerEvaluation,
|
||||
CorrectionCreate,
|
||||
@@ -15,7 +15,7 @@ from correction_models import ( # noqa: F401
|
||||
OCRResponse,
|
||||
AnalysisResponse,
|
||||
)
|
||||
from correction_helpers import ( # noqa: F401
|
||||
from .helpers import ( # noqa: F401
|
||||
corrections_store,
|
||||
calculate_grade,
|
||||
generate_ai_feedback,
|
||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks
|
||||
|
||||
from correction_models import (
|
||||
from .models import (
|
||||
CorrectionStatus,
|
||||
AnswerEvaluation,
|
||||
CorrectionCreate,
|
||||
@@ -28,7 +28,7 @@ from correction_models import (
|
||||
AnalysisResponse,
|
||||
UPLOAD_DIR,
|
||||
)
|
||||
from correction_helpers import (
|
||||
from .helpers import (
|
||||
corrections_store,
|
||||
calculate_grade,
|
||||
generate_ai_feedback,
|
||||
@@ -5,7 +5,7 @@ Correction API - Helper functions for grading, feedback, and OCR processing.
|
||||
import logging
|
||||
from typing import List, Dict
|
||||
|
||||
from correction_models import AnswerEvaluation, CorrectionStatus, Correction
|
||||
from .models import AnswerEvaluation, CorrectionStatus, Correction
|
||||
|
||||
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
|
||||
import logging
|
||||
|
||||
from teacher_dashboard_models import (
|
||||
from .models import (
|
||||
UnitAssignmentStatus, TeacherControlSettings,
|
||||
UnitAssignment, StudentUnitProgress, ClassUnitProgress,
|
||||
MisconceptionReport, ClassAnalyticsSummary, ContentResource,
|
||||
@@ -14,14 +14,14 @@ from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from teacher_dashboard_models import (
|
||||
from .models import (
|
||||
UnitAssignmentStatus, TeacherControlSettings, AssignUnitRequest,
|
||||
UnitAssignment,
|
||||
get_current_teacher, get_teacher_database,
|
||||
get_classes_for_teacher,
|
||||
REQUIRE_AUTH,
|
||||
)
|
||||
from teacher_dashboard_analytics import (
|
||||
from .analytics import (
|
||||
router as analytics_router,
|
||||
set_assignments_store,
|
||||
)
|
||||
@@ -7,16 +7,16 @@
|
||||
# - game_extended_routes.py (Phase 5: achievements, progress, parent, class)
|
||||
#
|
||||
# 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 game_routes import router as _core_router
|
||||
from game_session_routes import router as _session_router
|
||||
from game_extended_routes import router as _extended_router
|
||||
from .routes import router as _core_router
|
||||
from .session_routes import router as _session_router
|
||||
from .extended_routes import router as _extended_router
|
||||
|
||||
# Re-export models for any direct importers
|
||||
from game_models import ( # noqa: F401
|
||||
from .game_models import ( # noqa: F401
|
||||
LearningLevel,
|
||||
GameDifficulty,
|
||||
QuizQuestion,
|
||||
@@ -28,7 +28,7 @@ from game_models import ( # noqa: F401
|
||||
)
|
||||
|
||||
# Re-export helpers/state for any direct importers
|
||||
from game_routes import ( # noqa: F401
|
||||
from .routes import ( # noqa: F401
|
||||
get_optional_current_user,
|
||||
get_user_id_from_auth,
|
||||
get_game_database,
|
||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
from game_routes import (
|
||||
from .routes import (
|
||||
get_optional_current_user,
|
||||
get_user_id_from_auth,
|
||||
get_game_database,
|
||||
@@ -13,7 +13,7 @@ import uuid
|
||||
import os
|
||||
import logging
|
||||
|
||||
from game_models import (
|
||||
from .game_models import (
|
||||
LearningLevel,
|
||||
GameDifficulty,
|
||||
QuizQuestion,
|
||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from game_models import (
|
||||
from .game_models import (
|
||||
LearningLevel,
|
||||
QuizQuestion,
|
||||
GameSession,
|
||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Import shared state and helpers from game_routes
|
||||
# (these are the canonical instances)
|
||||
from game_routes import (
|
||||
from .routes import (
|
||||
get_optional_current_user,
|
||||
get_user_id_from_auth,
|
||||
get_game_database,
|
||||
@@ -0,0 +1 @@
|
||||
# letters — Elternbriefe and Zeugnisse (certificates).
|
||||
@@ -30,7 +30,7 @@ except (ImportError, OSError):
|
||||
SchoolInfo = None # type: ignore
|
||||
_pdf_available = False
|
||||
|
||||
from letters_models import (
|
||||
from .models import (
|
||||
LetterType,
|
||||
LetterTone,
|
||||
LetterStatus,
|
||||
@@ -22,7 +22,7 @@ except (ImportError, OSError):
|
||||
SchoolInfo = None # type: ignore
|
||||
_pdf_available = False
|
||||
|
||||
from certificates_models import (
|
||||
from .certificates_models import (
|
||||
CertificateType,
|
||||
CertificateStatus,
|
||||
BehaviorGrade,
|
||||
+26
-18
@@ -50,7 +50,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Backend-Lehrer starting up (DB search_path=lehrer,core,public)")
|
||||
# Initialize vocabulary tables
|
||||
try:
|
||||
from vocabulary_db import init_vocabulary_tables
|
||||
from vocabulary.db import init_vocabulary_tables
|
||||
await init_vocabulary_tables()
|
||||
except Exception as 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")
|
||||
|
||||
# --- 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")
|
||||
|
||||
# --- 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")
|
||||
|
||||
from correction_api import router as correction_router
|
||||
from correction.api import router as correction_router
|
||||
app.include_router(correction_router, prefix="/api")
|
||||
|
||||
# --- 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")
|
||||
|
||||
# --- 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")
|
||||
|
||||
# --- 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")
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- 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")
|
||||
|
||||
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")
|
||||
|
||||
# --- 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")
|
||||
|
||||
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
|
||||
|
||||
# --- 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")
|
||||
|
||||
from letters_api import router as letters_router
|
||||
from letters.api import router as letters_router
|
||||
app.include_router(letters_router, prefix="/api")
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- 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 messenger_contacts import router as _contacts_router
|
||||
from messenger_conversations import router as _conversations_router
|
||||
from .contacts import router as _contacts_router
|
||||
from .conversations import router as _conversations_router
|
||||
|
||||
router = APIRouter(prefix="/api/messenger", tags=["Messenger"])
|
||||
router.include_router(_contacts_router)
|
||||
@@ -13,13 +13,13 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from messenger_models import (
|
||||
from .models import (
|
||||
Contact,
|
||||
ContactCreate,
|
||||
ContactUpdate,
|
||||
CSVImportResult,
|
||||
)
|
||||
from messenger_helpers import get_contacts, save_contacts
|
||||
from .helpers import get_contacts, save_contacts
|
||||
|
||||
router = APIRouter(tags=["Messenger"])
|
||||
|
||||
+3
-3
@@ -10,14 +10,14 @@ from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from messenger_models import (
|
||||
from .models import (
|
||||
Conversation,
|
||||
Group,
|
||||
GroupCreate,
|
||||
Message,
|
||||
MessageBase,
|
||||
)
|
||||
from messenger_helpers import (
|
||||
from .helpers import (
|
||||
DATA_DIR,
|
||||
DEFAULT_TEMPLATES,
|
||||
get_contacts,
|
||||
@@ -266,7 +266,7 @@ async def send_message(conversation_id: str, message: MessageBase):
|
||||
|
||||
if contact and contact.get("email"):
|
||||
try:
|
||||
from email_service import email_service
|
||||
from services.email import email_service
|
||||
|
||||
result = email_service.send_messenger_notification(
|
||||
to_email=contact["email"],
|
||||
@@ -0,0 +1 @@
|
||||
# recording — Meeting recordings, transcription, minutes.
|
||||
@@ -12,9 +12,9 @@ Split into:
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from recording_routes import router as _routes_router
|
||||
from recording_transcription import router as _transcription_router
|
||||
from recording_minutes import router as _minutes_router
|
||||
from .routes import router as _routes_router
|
||||
from .transcription import router as _transcription_router
|
||||
from .minutes import router as _minutes_router
|
||||
|
||||
router = APIRouter(prefix="/api/recordings", tags=["Recordings"])
|
||||
router.include_router(_routes_router)
|
||||
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||
|
||||
from recording_helpers import (
|
||||
from .helpers import (
|
||||
_recordings_store,
|
||||
_transcriptions_store,
|
||||
_minutes_store,
|
||||
@@ -11,7 +11,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from recording_models import (
|
||||
from .models import (
|
||||
JibriWebhookPayload,
|
||||
RecordingResponse,
|
||||
RecordingListResponse,
|
||||
@@ -19,7 +19,7 @@ from recording_models import (
|
||||
MINIO_BUCKET,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
)
|
||||
from recording_helpers import (
|
||||
from .helpers import (
|
||||
_recordings_store,
|
||||
_transcriptions_store,
|
||||
_audit_log,
|
||||
+2
-2
@@ -11,11 +11,11 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from recording_models import (
|
||||
from .models import (
|
||||
TranscriptionRequest,
|
||||
TranscriptionStatusResponse,
|
||||
)
|
||||
from recording_helpers import (
|
||||
from .helpers import (
|
||||
_recordings_store,
|
||||
_transcriptions_store,
|
||||
log_audit,
|
||||
@@ -19,4 +19,12 @@ except (ImportError, OSError) as e:
|
||||
FileProcessor = None # type: ignore
|
||||
_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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
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):
|
||||
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
|
||||
from ai_processor import (
|
||||
from services.ai_processor import (
|
||||
# Configuration
|
||||
BASE_DIR,
|
||||
EINGANG_DIR,
|
||||
@@ -46,7 +46,7 @@ from ai_processor import (
|
||||
)
|
||||
|
||||
# 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__ = [
|
||||
# Configuration
|
||||
@@ -23,6 +23,53 @@ TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://bp-compliance-tts:8095")
|
||||
# Local cache directory for generated audio
|
||||
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():
|
||||
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
||||
@@ -56,48 +103,17 @@ async def synthesize_word(
|
||||
if os.path.exists(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:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TTS_SERVICE_URL}/synthesize",
|
||||
f"{TTS_SERVICE_URL}/synthesize-direct",
|
||||
json={
|
||||
"text": text,
|
||||
"text": speak_text,
|
||||
"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"):
|
||||
@@ -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 unit_analytics_routes import router as _routes_router
|
||||
from unit_analytics_export import router as _export_router
|
||||
from .analytics_routes import router as _routes_router
|
||||
from .analytics_export import router as _export_router
|
||||
|
||||
router = APIRouter(prefix="/api/analytics", tags=["Unit Analytics"])
|
||||
router.include_router(_routes_router)
|
||||
@@ -11,8 +11,8 @@ from typing import Optional, Dict, Any
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from unit_analytics_models import TimeRange, ExportFormat
|
||||
from unit_analytics_helpers import get_analytics_database
|
||||
from .analytics_models import TimeRange, ExportFormat
|
||||
from .analytics_helpers import get_analytics_database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,7 +76,7 @@ async def export_misconceptions(
|
||||
Export misconception data for further analysis.
|
||||
"""
|
||||
# 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(
|
||||
class_id=class_id, unit_id=None,
|
||||
@@ -12,7 +12,7 @@ from typing import Optional, Dict, Any, List
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from unit_analytics_models import (
|
||||
from .analytics_models import (
|
||||
TimeRange,
|
||||
LearningGainData,
|
||||
LearningGainSummary,
|
||||
@@ -23,7 +23,7 @@ from unit_analytics_models import (
|
||||
StudentProgressTimeline,
|
||||
ClassComparisonData,
|
||||
)
|
||||
from unit_analytics_helpers import (
|
||||
from .analytics_helpers import (
|
||||
get_analytics_database,
|
||||
calculate_gain_distribution,
|
||||
calculate_trend,
|
||||
@@ -8,16 +8,16 @@
|
||||
# - unit_content_routes.py (H5P, worksheet, PDF routes)
|
||||
#
|
||||
# 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 unit_routes import router as _routes_router
|
||||
from unit_definition_routes import router as _definition_router
|
||||
from unit_content_routes import router as _content_router
|
||||
from .routes import router as _routes_router
|
||||
from .definition_routes import router as _definition_router
|
||||
from .content_routes import router as _content_router
|
||||
|
||||
# Re-export models for any direct importers
|
||||
from unit_models import ( # noqa: F401
|
||||
from .models import ( # noqa: F401
|
||||
UnitDefinitionResponse,
|
||||
CreateSessionRequest,
|
||||
SessionResponse,
|
||||
@@ -36,7 +36,7 @@ from unit_models import ( # noqa: F401
|
||||
)
|
||||
|
||||
# Re-export helpers for any direct importers
|
||||
from unit_helpers import ( # noqa: F401
|
||||
from .helpers import ( # noqa: F401
|
||||
get_optional_current_user,
|
||||
get_unit_database,
|
||||
create_session_token,
|
||||
@@ -8,8 +8,8 @@ from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
from unit_models import UnitDefinitionResponse
|
||||
from unit_helpers import get_optional_current_user, get_unit_database
|
||||
from .models import UnitDefinitionResponse
|
||||
from .helpers import get_optional_current_user, get_unit_database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
+2
-2
@@ -9,13 +9,13 @@ from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from unit_models import (
|
||||
from .models import (
|
||||
UnitDefinitionResponse,
|
||||
CreateUnitRequest,
|
||||
UpdateUnitRequest,
|
||||
ValidationResult,
|
||||
)
|
||||
from unit_helpers import (
|
||||
from .helpers import (
|
||||
get_optional_current_user,
|
||||
get_unit_database,
|
||||
validate_unit_definition,
|
||||
@@ -11,7 +11,7 @@ import os
|
||||
import logging
|
||||
import jwt
|
||||
|
||||
from unit_models import ValidationError, ValidationResult
|
||||
from .models import ValidationError, ValidationResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from learning_units import (
|
||||
from .learning import (
|
||||
LearningUnit,
|
||||
LearningUnitCreate,
|
||||
LearningUnitUpdate,
|
||||
@@ -363,7 +363,7 @@ def api_generate_story(unit_id: str, payload: StoryGeneratePayload):
|
||||
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
|
||||
|
||||
try:
|
||||
from story_generator import generate_story
|
||||
from services.story_generator import generate_story
|
||||
result = generate_story(
|
||||
vocabulary=payload.vocabulary,
|
||||
language=payload.language,
|
||||
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from unit_models import (
|
||||
from .models import (
|
||||
UnitDefinitionResponse,
|
||||
CreateSessionRequest,
|
||||
SessionResponse,
|
||||
@@ -23,7 +23,7 @@ from unit_models import (
|
||||
UnitListItem,
|
||||
RecommendedUnit,
|
||||
)
|
||||
from unit_helpers import (
|
||||
from .helpers import (
|
||||
get_optional_current_user,
|
||||
get_unit_database,
|
||||
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
|
||||
)
|
||||
|
||||
from worksheets_models import (
|
||||
from .models import (
|
||||
ContentType,
|
||||
GenerateRequest,
|
||||
MCGenerateRequest,
|
||||
@@ -20,6 +20,7 @@ volumes:
|
||||
transcription_models:
|
||||
transcription_temp:
|
||||
lehrer_backend_data:
|
||||
lehrer_arbeitsblaetter:
|
||||
opensearch_data:
|
||||
# Communication (Jitsi + Matrix)
|
||||
synapse_data:
|
||||
@@ -108,8 +109,10 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
BACKEND_URL: http://backend-lehrer:8001
|
||||
SCHOOL_SERVICE_URL: http://school-service:8084
|
||||
depends_on:
|
||||
- backend-lehrer
|
||||
- school-service
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- breakpilot-network
|
||||
@@ -159,6 +162,7 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- lehrer_backend_data:/app/data
|
||||
- lehrer_arbeitsblaetter:/root/Arbeitsblaetter
|
||||
environment:
|
||||
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
|
||||
@@ -285,6 +289,26 @@ services:
|
||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
ALLOWED_ORIGINS: "*"
|
||||
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:
|
||||
core-health-check:
|
||||
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)
|
||||
|
||||
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 admin_nibis import router as _nibis_router
|
||||
from admin_rag import router as _rag_router
|
||||
from admin_templates import router as _templates_router
|
||||
from .nibis import router as _nibis_router
|
||||
from .rag import router as _rag_router
|
||||
from .templates import router as _templates_router
|
||||
|
||||
# Re-export internal state for test importers
|
||||
from admin_nibis import ( # noqa: F401
|
||||
from .nibis import ( # noqa: F401
|
||||
_ingestion_status,
|
||||
NiBiSSearchRequest,
|
||||
search_nibis,
|
||||
)
|
||||
from admin_rag import _upload_history # noqa: F401
|
||||
from admin_templates import _templates_ingestion_status # noqa: F401
|
||||
from .rag import _upload_history # noqa: F401
|
||||
from .templates import _templates_ingestion_status # noqa: F401
|
||||
|
||||
# Assemble the combined router.
|
||||
# All sub-routers use prefix="/api/v1/admin", so include without extra prefix.
|
||||
@@ -17,7 +17,7 @@ from nibis_ingestion import (
|
||||
DOCS_BASE_PATH,
|
||||
)
|
||||
from qdrant_service import QdrantService, search_nibis_eh, get_qdrant_client
|
||||
from eh_pipeline import generate_single_embedding
|
||||
from korrektur.eh_pipeline import generate_single_embedding
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
|
||||
|
||||
@@ -18,7 +18,7 @@ import os
|
||||
from nibis_ingestion import run_ingestion, DOCS_BASE_PATH
|
||||
|
||||
# Import ingestion status from nibis module for auto-ingest
|
||||
from admin_nibis import _ingestion_status
|
||||
from .nibis import _ingestion_status
|
||||
|
||||
# Optional: MinIO and PostgreSQL integrations
|
||||
try:
|
||||
@@ -28,7 +28,7 @@ except ImportError:
|
||||
MINIO_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from metrics_db import (
|
||||
from metrics.db import (
|
||||
init_metrics_tables, store_feedback, log_search, log_upload,
|
||||
calculate_metrics, get_recent_feedback, get_upload_history
|
||||
)
|
||||
+1
-1
@@ -11,7 +11,7 @@ from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from eh_pipeline import generate_single_embedding
|
||||
from korrektur.eh_pipeline import generate_single_embedding
|
||||
|
||||
# Import legal templates modules
|
||||
try:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user