diff --git a/backend-lehrer/ai_processor.py b/backend-lehrer/ai_processor.py
index ba893a4..a222b64 100644
--- a/backend-lehrer/ai_processor.py
+++ b/backend-lehrer/ai_processor.py
@@ -1,81 +1,4 @@
-"""
-AI Processor - Legacy Import Wrapper
-
-This file provides backward compatibility for code that imports from ai_processor.
-All functionality has been moved to the ai_processor/ module.
-
-Usage (new):
- from ai_processor import analyze_scan_structure_with_ai
-
-Usage (legacy, still works):
- from ai_processor import analyze_scan_structure_with_ai
-"""
-
-# Re-export everything from the new modular structure
-from ai_processor import (
- # Configuration
- BASE_DIR,
- EINGANG_DIR,
- BEREINIGT_DIR,
- VISION_API,
- # Utilities (with legacy aliases)
- encode_image_to_data_url as _encode_image_to_data_url,
- dummy_process_scan,
- # Vision - Scan Analysis
- analyze_scan_structure_with_ai,
- describe_scan_with_ai,
- remove_handwriting_from_scan,
- build_clean_html_from_analysis,
- # Generators - Multiple Choice
- generate_mc_from_analysis,
- # Generators - Cloze
- generate_cloze_from_analysis,
- # Generators - Q&A with Leitner
- generate_qa_from_analysis,
- update_leitner_progress,
- get_next_review_items,
- # Export - Print Versions
- generate_print_version_qa,
- generate_print_version_cloze,
- generate_print_version_mc,
- generate_print_version_worksheet,
- # Visualization - Mindmap
- generate_mindmap_data,
- generate_mindmap_html,
- save_mindmap_for_worksheet,
-)
-
-# Legacy function alias
-from ai_processor import get_openai_api_key as _get_api_key
-
-__all__ = [
- # Configuration
- "BASE_DIR",
- "EINGANG_DIR",
- "BEREINIGT_DIR",
- "VISION_API",
- # Legacy private functions
- "_get_api_key",
- "_encode_image_to_data_url",
- # Vision
- "analyze_scan_structure_with_ai",
- "describe_scan_with_ai",
- "remove_handwriting_from_scan",
- "build_clean_html_from_analysis",
- "dummy_process_scan",
- # Generators
- "generate_mc_from_analysis",
- "generate_cloze_from_analysis",
- "generate_qa_from_analysis",
- "update_leitner_progress",
- "get_next_review_items",
- # Export
- "generate_print_version_qa",
- "generate_print_version_cloze",
- "generate_print_version_mc",
- "generate_print_version_worksheet",
- # Visualization
- "generate_mindmap_data",
- "generate_mindmap_html",
- "save_mindmap_for_worksheet",
-]
+# Backward-compat shim -- module moved to services/ai_processor.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("services.ai_processor")
diff --git a/backend-lehrer/api/__init__.py b/backend-lehrer/api/__init__.py
new file mode 100644
index 0000000..03214f9
--- /dev/null
+++ b/backend-lehrer/api/__init__.py
@@ -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
diff --git a/backend-lehrer/api/klausur_proxy.py b/backend-lehrer/api/klausur_proxy.py
new file mode 100644
index 0000000..0c7b841
--- /dev/null
+++ b/backend-lehrer/api/klausur_proxy.py
@@ -0,0 +1,135 @@
+"""
+Klausur-Service API Proxy
+Routes API requests from /api/klausur/* to the klausur-service microservice
+"""
+
+import os
+import jwt
+import datetime
+import httpx
+from fastapi import APIRouter, Request, HTTPException, Response
+
+# Klausur Service URL
+KLAUSUR_SERVICE_URL = os.getenv("KLAUSUR_SERVICE_URL", "http://klausur-service:8086")
+JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
+ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
+
+# Demo teacher UUID for development mode
+DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20"
+
+router = APIRouter(prefix="/klausur", tags=["klausur"])
+
+
+def get_demo_token() -> str:
+ """Generate a demo JWT token for development mode"""
+ payload = {
+ "user_id": DEMO_TEACHER_ID,
+ "email": "demo@breakpilot.app",
+ "role": "admin",
+ "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24),
+ "iat": datetime.datetime.now(datetime.timezone.utc)
+ }
+ return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
+
+
+async def proxy_request(request: Request, path: str) -> Response:
+ """Forward a request to the klausur service"""
+ url = f"{KLAUSUR_SERVICE_URL}/api/v1{path}"
+
+ # Forward headers, especially Authorization
+ headers = {}
+ if "authorization" in request.headers:
+ headers["Authorization"] = request.headers["authorization"]
+ elif ENVIRONMENT == "development":
+ # In development mode, use demo token if no auth provided
+ demo_token = get_demo_token()
+ headers["Authorization"] = f"Bearer {demo_token}"
+ if "content-type" in request.headers:
+ headers["Content-Type"] = request.headers["content-type"]
+
+ # Get request body for POST/PUT/PATCH/DELETE
+ body = None
+ if request.method in ("POST", "PUT", "PATCH", "DELETE"):
+ body = await request.body()
+
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ try:
+ response = await client.request(
+ method=request.method,
+ url=url,
+ headers=headers,
+ content=body,
+ params=request.query_params
+ )
+ return Response(
+ content=response.content,
+ status_code=response.status_code,
+ headers=dict(response.headers),
+ media_type=response.headers.get("content-type", "application/json")
+ )
+ except httpx.ConnectError:
+ raise HTTPException(
+ status_code=503,
+ detail="Klausur service unavailable"
+ )
+ except httpx.TimeoutException:
+ raise HTTPException(
+ status_code=504,
+ detail="Klausur service timeout"
+ )
+
+
+# Health check
+@router.get("/health")
+async def health():
+ """Health check for klausur service connection"""
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ try:
+ response = await client.get(f"{KLAUSUR_SERVICE_URL}/health")
+ return {"klausur_service": "healthy", "connected": response.status_code == 200}
+ except Exception:
+ return {"klausur_service": "unhealthy", "connected": False}
+
+
+# Klausuren
+@router.api_route("/klausuren", methods=["GET", "POST"])
+async def klausuren(request: Request):
+ return await proxy_request(request, "/klausuren")
+
+
+@router.api_route("/klausuren/{klausur_id}", methods=["GET", "PUT", "DELETE"])
+async def klausur_by_id(klausur_id: str, request: Request):
+ return await proxy_request(request, f"/klausuren/{klausur_id}")
+
+
+# Students
+@router.api_route("/klausuren/{klausur_id}/students", methods=["GET", "POST"])
+async def klausur_students(klausur_id: str, request: Request):
+ return await proxy_request(request, f"/klausuren/{klausur_id}/students")
+
+
+@router.api_route("/students/{student_id}", methods=["GET", "DELETE"])
+async def student_by_id(student_id: str, request: Request):
+ return await proxy_request(request, f"/students/{student_id}")
+
+
+# Grading
+@router.api_route("/students/{student_id}/criteria", methods=["PUT"])
+async def update_criteria(student_id: str, request: Request):
+ return await proxy_request(request, f"/students/{student_id}/criteria")
+
+
+@router.api_route("/students/{student_id}/gutachten", methods=["PUT"])
+async def update_gutachten(student_id: str, request: Request):
+ return await proxy_request(request, f"/students/{student_id}/gutachten")
+
+
+@router.api_route("/students/{student_id}/finalize", methods=["POST"])
+async def finalize_student(student_id: str, request: Request):
+ return await proxy_request(request, f"/students/{student_id}/finalize")
+
+
+# Grade info
+@router.get("/grade-info")
+async def grade_info(request: Request):
+ return await proxy_request(request, "/grade-info")
diff --git a/backend-lehrer/api/progress.py b/backend-lehrer/api/progress.py
new file mode 100644
index 0000000..9b1c7d8
--- /dev/null
+++ b/backend-lehrer/api/progress.py
@@ -0,0 +1,131 @@
+"""
+Progress API — Tracks student learning progress per unit.
+
+Stores coins, crowns, streak data, and exercise completion stats.
+Uses JSON file storage (same pattern as learning_units.py).
+"""
+
+import os
+import json
+import logging
+from datetime import datetime, date
+from typing import Dict, Any, Optional, List
+from pathlib import Path
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(
+ prefix="/progress",
+ tags=["progress"],
+)
+
+PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress")
+
+
+def _ensure_dir():
+ os.makedirs(PROGRESS_DIR, exist_ok=True)
+
+
+def _progress_path(unit_id: str) -> Path:
+ return Path(PROGRESS_DIR) / f"{unit_id}.json"
+
+
+def _load_progress(unit_id: str) -> Dict[str, Any]:
+ path = _progress_path(unit_id)
+ if path.exists():
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ return {
+ "unit_id": unit_id,
+ "coins": 0,
+ "crowns": 0,
+ "streak_days": 0,
+ "last_activity": None,
+ "exercises": {
+ "flashcards": {"completed": 0, "correct": 0, "incorrect": 0},
+ "quiz": {"completed": 0, "correct": 0, "incorrect": 0},
+ "type": {"completed": 0, "correct": 0, "incorrect": 0},
+ "story": {"generated": 0},
+ },
+ "created_at": datetime.now().isoformat(),
+ }
+
+
+def _save_progress(unit_id: str, data: Dict[str, Any]):
+ _ensure_dir()
+ path = _progress_path(unit_id)
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+
+class RewardPayload(BaseModel):
+ exercise_type: str # flashcards, quiz, type, story
+ correct: bool = True
+ first_try: bool = True
+
+
+@router.get("/{unit_id}")
+def get_progress(unit_id: str):
+ """Get learning progress for a unit."""
+ return _load_progress(unit_id)
+
+
+@router.post("/{unit_id}/reward")
+def add_reward(unit_id: str, payload: RewardPayload):
+ """Record an exercise result and award coins."""
+ progress = _load_progress(unit_id)
+
+ # Update exercise stats
+ ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0})
+ ex["completed"] = ex.get("completed", 0) + 1
+ if payload.correct:
+ ex["correct"] = ex.get("correct", 0) + 1
+ else:
+ ex["incorrect"] = ex.get("incorrect", 0) + 1
+ progress["exercises"][payload.exercise_type] = ex
+
+ # Award coins
+ if payload.correct:
+ coins = 3 if payload.first_try else 1
+ else:
+ coins = 0
+ progress["coins"] = progress.get("coins", 0) + coins
+
+ # Update streak
+ today = date.today().isoformat()
+ last = progress.get("last_activity")
+ if last != today:
+ if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None:
+ progress["streak_days"] = progress.get("streak_days", 0) + 1
+ elif last != today:
+ progress["streak_days"] = 1
+ progress["last_activity"] = today
+
+ # Award crowns for milestones
+ total_correct = sum(
+ e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict)
+ )
+ progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers
+
+ _save_progress(unit_id, progress)
+
+ return {
+ "coins_awarded": coins,
+ "total_coins": progress["coins"],
+ "crowns": progress["crowns"],
+ "streak_days": progress["streak_days"],
+ }
+
+
+@router.get("/")
+def list_all_progress():
+ """List progress for all units."""
+ _ensure_dir()
+ results = []
+ for f in Path(PROGRESS_DIR).glob("*.json"):
+ with open(f, "r", encoding="utf-8") as fh:
+ results.append(json.load(fh))
+ return results
diff --git a/backend-lehrer/api/school.py b/backend-lehrer/api/school.py
new file mode 100644
index 0000000..f49c5be
--- /dev/null
+++ b/backend-lehrer/api/school.py
@@ -0,0 +1,250 @@
+"""
+School Service API Proxy
+Routes API requests from /api/school/* to the Go school-service
+"""
+
+import os
+import jwt
+import datetime
+import httpx
+from fastapi import APIRouter, Request, HTTPException, Response
+
+# School Service URL
+SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084")
+JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
+ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
+
+# Demo teacher UUID for development mode
+DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20"
+
+router = APIRouter(prefix="/school", tags=["school"])
+
+
+def get_demo_token() -> str:
+ """Generate a demo JWT token for development mode"""
+ payload = {
+ "user_id": DEMO_TEACHER_ID,
+ "email": "demo@breakpilot.app",
+ "role": "admin",
+ "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24),
+ "iat": datetime.datetime.now(datetime.timezone.utc)
+ }
+ return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
+
+
+async def proxy_request(request: Request, path: str) -> Response:
+ """Forward a request to the school service"""
+ url = f"{SCHOOL_SERVICE_URL}/api/v1/school{path}"
+
+ # Forward headers, especially Authorization
+ headers = {}
+ if "authorization" in request.headers:
+ headers["Authorization"] = request.headers["authorization"]
+ elif ENVIRONMENT == "development":
+ # In development mode, use demo token if no auth provided
+ demo_token = get_demo_token()
+ headers["Authorization"] = f"Bearer {demo_token}"
+ if "content-type" in request.headers:
+ headers["Content-Type"] = request.headers["content-type"]
+
+ # Get request body for POST/PUT/PATCH
+ body = None
+ if request.method in ("POST", "PUT", "PATCH"):
+ body = await request.body()
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ try:
+ response = await client.request(
+ method=request.method,
+ url=url,
+ headers=headers,
+ content=body,
+ params=request.query_params
+ )
+ return Response(
+ content=response.content,
+ status_code=response.status_code,
+ headers=dict(response.headers),
+ media_type=response.headers.get("content-type", "application/json")
+ )
+ except httpx.ConnectError:
+ raise HTTPException(
+ status_code=503,
+ detail="School service unavailable"
+ )
+ except httpx.TimeoutException:
+ raise HTTPException(
+ status_code=504,
+ detail="School service timeout"
+ )
+
+
+# Health check
+@router.get("/health")
+async def health():
+ """Health check for school service connection"""
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ try:
+ response = await client.get(f"{SCHOOL_SERVICE_URL}/health")
+ return {"school_service": "healthy", "connected": response.status_code == 200}
+ except Exception:
+ return {"school_service": "unhealthy", "connected": False}
+
+
+# School Years
+@router.api_route("/years", methods=["GET", "POST"])
+async def years(request: Request):
+ return await proxy_request(request, "/years")
+
+
+@router.api_route("/years/{year_id}", methods=["GET", "PUT", "DELETE"])
+async def year_by_id(request: Request, year_id: str):
+ return await proxy_request(request, f"/years/{year_id}")
+
+
+# Classes
+@router.api_route("/classes", methods=["GET", "POST"])
+async def classes(request: Request):
+ return await proxy_request(request, "/classes")
+
+
+@router.api_route("/classes/{class_id}", methods=["GET", "PUT", "DELETE"])
+async def class_by_id(request: Request, class_id: str):
+ return await proxy_request(request, f"/classes/{class_id}")
+
+
+# Students
+@router.api_route("/classes/{class_id}/students", methods=["GET", "POST"])
+async def students(request: Request, class_id: str):
+ return await proxy_request(request, f"/classes/{class_id}/students")
+
+
+@router.api_route("/classes/{class_id}/students/import", methods=["POST"])
+async def import_students(request: Request, class_id: str):
+ return await proxy_request(request, f"/classes/{class_id}/students/import")
+
+
+@router.api_route("/classes/{class_id}/students/{student_id}", methods=["GET", "PUT", "DELETE"])
+async def student_by_id(request: Request, class_id: str, student_id: str):
+ return await proxy_request(request, f"/classes/{class_id}/students/{student_id}")
+
+
+# Subjects
+@router.api_route("/subjects", methods=["GET", "POST"])
+async def subjects(request: Request):
+ return await proxy_request(request, "/subjects")
+
+
+@router.api_route("/subjects/{subject_id}", methods=["GET", "PUT", "DELETE"])
+async def subject_by_id(request: Request, subject_id: str):
+ return await proxy_request(request, f"/subjects/{subject_id}")
+
+
+# Exams
+@router.api_route("/exams", methods=["GET", "POST"])
+async def exams(request: Request):
+ return await proxy_request(request, "/exams")
+
+
+@router.api_route("/exams/{exam_id}", methods=["GET", "PUT", "DELETE"])
+async def exam_by_id(request: Request, exam_id: str):
+ return await proxy_request(request, f"/exams/{exam_id}")
+
+
+@router.api_route("/exams/{exam_id}/results", methods=["GET", "POST"])
+async def exam_results(request: Request, exam_id: str):
+ return await proxy_request(request, f"/exams/{exam_id}/results")
+
+
+@router.api_route("/exams/{exam_id}/results/{student_id}", methods=["PUT"])
+async def exam_result_update(request: Request, exam_id: str, student_id: str):
+ return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}")
+
+
+@router.api_route("/exams/{exam_id}/results/{student_id}/approve", methods=["PUT"])
+async def approve_result(request: Request, exam_id: str, student_id: str):
+ return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}/approve")
+
+
+@router.api_route("/exams/{exam_id}/generate-variant", methods=["POST"])
+async def generate_variant(request: Request, exam_id: str):
+ return await proxy_request(request, f"/exams/{exam_id}/generate-variant")
+
+
+# Grades
+@router.api_route("/grades/{class_id}", methods=["GET"])
+async def grades_by_class(request: Request, class_id: str):
+ return await proxy_request(request, f"/grades/{class_id}")
+
+
+@router.api_route("/grades/student/{student_id}", methods=["GET"])
+async def grades_by_student(request: Request, student_id: str):
+ return await proxy_request(request, f"/grades/student/{student_id}")
+
+
+@router.api_route("/grades/{student_id}/{subject_id}/oral", methods=["PUT"])
+async def update_oral_grade(request: Request, student_id: str, subject_id: str):
+ return await proxy_request(request, f"/grades/{student_id}/{subject_id}/oral")
+
+
+@router.api_route("/grades/calculate", methods=["POST"])
+async def calculate_grades(request: Request):
+ return await proxy_request(request, "/grades/calculate")
+
+
+# Attendance
+@router.api_route("/attendance/{class_id}", methods=["GET"])
+async def attendance_by_class(request: Request, class_id: str):
+ return await proxy_request(request, f"/attendance/{class_id}")
+
+
+@router.api_route("/attendance", methods=["POST"])
+async def create_attendance(request: Request):
+ return await proxy_request(request, "/attendance")
+
+
+@router.api_route("/attendance/bulk", methods=["POST"])
+async def bulk_attendance(request: Request):
+ return await proxy_request(request, "/attendance/bulk")
+
+
+# Gradebook
+@router.api_route("/gradebook/{class_id}", methods=["GET"])
+async def gradebook_by_class(request: Request, class_id: str):
+ return await proxy_request(request, f"/gradebook/{class_id}")
+
+
+@router.api_route("/gradebook", methods=["POST"])
+async def create_gradebook_entry(request: Request):
+ return await proxy_request(request, "/gradebook")
+
+
+@router.api_route("/gradebook/{entry_id}", methods=["DELETE"])
+async def delete_gradebook_entry(request: Request, entry_id: str):
+ return await proxy_request(request, f"/gradebook/{entry_id}")
+
+
+# Certificates
+@router.api_route("/certificates/templates", methods=["GET"])
+async def certificate_templates(request: Request):
+ return await proxy_request(request, "/certificates/templates")
+
+
+@router.api_route("/certificates/generate", methods=["POST"])
+async def generate_certificate(request: Request):
+ return await proxy_request(request, "/certificates/generate")
+
+
+@router.api_route("/certificates/{certificate_id}", methods=["GET"])
+async def certificate_by_id(request: Request, certificate_id: str):
+ return await proxy_request(request, f"/certificates/{certificate_id}")
+
+
+@router.api_route("/certificates/{certificate_id}/pdf", methods=["GET"])
+async def certificate_pdf(request: Request, certificate_id: str):
+ return await proxy_request(request, f"/certificates/{certificate_id}/pdf")
+
+
+@router.api_route("/certificates/{certificate_id}/finalize", methods=["PUT"])
+async def finalize_certificate(request: Request, certificate_id: str):
+ return await proxy_request(request, f"/certificates/{certificate_id}/finalize")
diff --git a/backend-lehrer/api/user_language.py b/backend-lehrer/api/user_language.py
new file mode 100644
index 0000000..b498aa6
--- /dev/null
+++ b/backend-lehrer/api/user_language.py
@@ -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}
diff --git a/backend-lehrer/audio_service.py b/backend-lehrer/audio_service.py
index d1ee9ca..310c9ac 100644
--- a/backend-lehrer/audio_service.py
+++ b/backend-lehrer/audio_service.py
@@ -1,125 +1,4 @@
-"""
-Audio Service — Generates TTS audio for vocabulary words.
-
-Uses the Piper TTS service (compliance-tts-service, MIT license)
-for high-quality German (Thorsten) and English (Lessac) voices.
-Falls back to a placeholder response if TTS service is unavailable.
-
-Audio files are cached — generated once, served forever.
-"""
-
-import hashlib
-import logging
-import os
-from typing import Optional
-
-import httpx
-
-logger = logging.getLogger(__name__)
-
-# Piper TTS service (runs in compliance stack)
-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")
-
-
-def _ensure_cache_dir():
- os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
-
-
-def _cache_key(text: str, language: str) -> str:
- """Generate a deterministic cache key for text + language."""
- h = hashlib.sha256(f"{language}:{text}".encode()).hexdigest()[:16]
- return f"{language}_{h}"
-
-
-def _cache_path(text: str, language: str) -> str:
- """Full path to cached MP3 file."""
- _ensure_cache_dir()
- return os.path.join(AUDIO_CACHE_DIR, f"{_cache_key(text, language)}.mp3")
-
-
-async def synthesize_word(
- text: str,
- language: str = "de",
- word_id: str = "",
-) -> Optional[str]:
- """
- Generate TTS audio for a word or short phrase.
-
- Returns the file path to the cached MP3, or None on error.
- Uses Piper TTS service (compliance-tts-service).
- """
- # Check cache first
- cached = _cache_path(text, language)
- if os.path.exists(cached):
- return cached
-
- # Call Piper TTS service
- try:
- async with httpx.AsyncClient(timeout=30.0) as client:
- resp = await client.post(
- f"{TTS_SERVICE_URL}/synthesize",
- json={
- "text": 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"):
- with open(cached, "wb") as f:
- f.write(resp.content)
- logger.info(f"TTS cached (direct): '{text}' ({language}) → {cached}")
- return cached
- except Exception as e:
- logger.debug(f"TTS direct fallback also failed: {e}")
-
- return None
-
-
-async def get_or_generate_audio(
- text: str, language: str = "de", word_id: str = "",
-) -> Optional[bytes]:
- """
- Get audio bytes for a word. Returns MP3 bytes or None.
- Generates via TTS if not cached.
- """
- path = await synthesize_word(text, language, word_id)
- if path and os.path.exists(path):
- with open(path, "rb") as f:
- return f.read()
- return None
+# Backward-compat shim -- module moved to services/audio.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("services.audio")
diff --git a/backend-lehrer/classroom/state_engine_api.py b/backend-lehrer/classroom/state_engine_api.py
new file mode 100644
index 0000000..9d44126
--- /dev/null
+++ b/backend-lehrer/classroom/state_engine_api.py
@@ -0,0 +1,410 @@
+"""
+State Engine API - REST API für Begleiter-Modus.
+
+Endpoints:
+- GET /api/state/context - TeacherContext abrufen
+- GET /api/state/suggestions - Vorschläge abrufen
+- GET /api/state/dashboard - Dashboard-Daten
+- POST /api/state/milestone - Meilenstein abschließen
+- POST /api/state/transition - Phasen-Übergang
+"""
+
+import logging
+import uuid
+from datetime import datetime, timedelta
+from typing import Dict, Any, List
+
+from fastapi import APIRouter, HTTPException, Query
+
+from state_engine import (
+ AnticipationEngine,
+ PhaseService,
+ SchoolYearPhase,
+ ClassSummary,
+ Event,
+ get_phase_info,
+)
+from .state_engine_models import (
+ MilestoneRequest,
+ TransitionRequest,
+ ContextResponse,
+ SuggestionsResponse,
+ DashboardResponse,
+ _teacher_contexts,
+ _milestones,
+ get_or_create_context,
+ update_context_from_services,
+ get_phase_display_name,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(
+ prefix="/state",
+ tags=["state-engine"],
+)
+
+# Singleton instances
+_engine = AnticipationEngine()
+_phase_service = PhaseService()
+
+
+# ============================================================================
+# API Endpoints
+# ============================================================================
+
+@router.get("/context", response_model=ContextResponse)
+async def get_teacher_context(teacher_id: str = Query("demo-teacher")):
+ """Gibt den aggregierten TeacherContext zurück."""
+ ctx = get_or_create_context(teacher_id)
+ ctx = update_context_from_services(ctx)
+
+ phase_info = get_phase_info(ctx.current_phase)
+
+ return ContextResponse(
+ context=ctx.to_dict(),
+ phase_info={
+ "phase": phase_info.phase.value,
+ "display_name": phase_info.display_name,
+ "description": phase_info.description,
+ "typical_months": phase_info.typical_months,
+ "required_actions": phase_info.required_actions,
+ "optional_actions": phase_info.optional_actions,
+ }
+ )
+
+
+@router.get("/phase")
+async def get_current_phase(teacher_id: str = Query("demo-teacher")):
+ """Gibt die aktuelle Phase mit Details zurück."""
+ ctx = get_or_create_context(teacher_id)
+ phase_info = get_phase_info(ctx.current_phase)
+
+ return {
+ "current_phase": ctx.current_phase.value,
+ "phase_info": {
+ "display_name": phase_info.display_name,
+ "description": phase_info.description,
+ "expected_duration_weeks": phase_info.expected_duration_weeks,
+ },
+ "days_in_phase": ctx.days_in_phase,
+ "progress": _phase_service.get_progress_percentage(ctx),
+ }
+
+
+@router.get("/phases")
+async def get_all_phases():
+ """Gibt alle Phasen mit Metadaten zurück."""
+ return {
+ "phases": _phase_service.get_all_phases()
+ }
+
+
+@router.get("/suggestions", response_model=SuggestionsResponse)
+async def get_suggestions(teacher_id: str = Query("demo-teacher")):
+ """Gibt Vorschläge basierend auf dem aktuellen Kontext zurück."""
+ ctx = get_or_create_context(teacher_id)
+ ctx = update_context_from_services(ctx)
+
+ suggestions = _engine.get_suggestions(ctx)
+ priority_counts = _engine.count_by_priority(ctx)
+
+ return SuggestionsResponse(
+ suggestions=[s.to_dict() for s in suggestions],
+ current_phase=ctx.current_phase.value,
+ phase_display_name=get_phase_display_name(ctx.current_phase.value),
+ priority_counts=priority_counts,
+ )
+
+
+@router.get("/suggestions/top")
+async def get_top_suggestion(teacher_id: str = Query("demo-teacher")):
+ """Gibt den wichtigsten einzelnen Vorschlag zurück."""
+ ctx = get_or_create_context(teacher_id)
+ ctx = update_context_from_services(ctx)
+
+ suggestion = _engine.get_top_suggestion(ctx)
+
+ if not suggestion:
+ return {
+ "suggestion": None,
+ "message": "Alles erledigt! Keine offenen Aufgaben."
+ }
+
+ return {
+ "suggestion": suggestion.to_dict()
+ }
+
+
+@router.get("/dashboard", response_model=DashboardResponse)
+async def get_dashboard_data(teacher_id: str = Query("demo-teacher")):
+ """Gibt alle Daten für das Begleiter-Dashboard zurück."""
+ ctx = get_or_create_context(teacher_id)
+ ctx = update_context_from_services(ctx)
+
+ suggestions = _engine.get_suggestions(ctx)
+ phase_info = get_phase_info(ctx.current_phase)
+
+ required = set(phase_info.required_actions)
+ completed = set(ctx.completed_milestones)
+ completed_in_phase = len(required.intersection(completed))
+
+ all_phases = []
+ phase_order = [
+ SchoolYearPhase.ONBOARDING,
+ SchoolYearPhase.SCHOOL_YEAR_START,
+ SchoolYearPhase.TEACHING_SETUP,
+ SchoolYearPhase.PERFORMANCE_1,
+ SchoolYearPhase.SEMESTER_END,
+ SchoolYearPhase.TEACHING_2,
+ SchoolYearPhase.PERFORMANCE_2,
+ SchoolYearPhase.YEAR_END,
+ ]
+
+ current_idx = phase_order.index(ctx.current_phase) if ctx.current_phase in phase_order else 0
+
+ for i, phase in enumerate(phase_order):
+ info = get_phase_info(phase)
+ all_phases.append({
+ "phase": phase.value,
+ "display_name": info.display_name,
+ "short_name": info.display_name[:10],
+ "is_current": phase == ctx.current_phase,
+ "is_completed": i < current_idx,
+ "is_future": i > current_idx,
+ })
+
+ return DashboardResponse(
+ context={
+ "current_phase": ctx.current_phase.value,
+ "phase_display_name": phase_info.display_name,
+ "phase_description": phase_info.description,
+ "weeks_since_start": ctx.weeks_since_start,
+ "days_in_phase": ctx.days_in_phase,
+ "federal_state": ctx.federal_state,
+ "school_type": ctx.school_type,
+ },
+ suggestions=[s.to_dict() for s in suggestions],
+ stats={
+ "learning_units_created": ctx.stats.learning_units_created,
+ "exams_scheduled": ctx.stats.exams_scheduled,
+ "exams_graded": ctx.stats.exams_graded,
+ "grades_entered": ctx.stats.grades_entered,
+ "classes_count": len(ctx.classes),
+ "students_count": ctx.total_students,
+ },
+ upcoming_events=[
+ {
+ "type": e.type,
+ "title": e.title,
+ "date": e.date.isoformat(),
+ "in_days": e.in_days,
+ "priority": e.priority,
+ }
+ for e in ctx.upcoming_events[:5]
+ ],
+ progress={
+ "completed": completed_in_phase,
+ "total": len(required),
+ "percentage": (completed_in_phase / len(required) * 100) if required else 100,
+ "milestones_completed": list(completed.intersection(required)),
+ "milestones_pending": list(required - completed),
+ },
+ phases=all_phases,
+ )
+
+
+@router.post("/milestone")
+async def complete_milestone(
+ request: MilestoneRequest,
+ teacher_id: str = Query("demo-teacher")
+):
+ """Markiert einen Meilenstein als erledigt."""
+ milestone = request.milestone
+
+ if teacher_id not in _milestones:
+ _milestones[teacher_id] = []
+
+ if milestone not in _milestones[teacher_id]:
+ _milestones[teacher_id].append(milestone)
+ logger.info(f"Milestone '{milestone}' completed for teacher {teacher_id}")
+
+ ctx = get_or_create_context(teacher_id)
+ ctx.completed_milestones = _milestones[teacher_id]
+ _teacher_contexts[teacher_id] = ctx
+
+ new_phase = _phase_service.check_and_transition(ctx)
+
+ if new_phase:
+ ctx.current_phase = new_phase
+ ctx.phase_entered_at = datetime.now()
+ ctx.days_in_phase = 0
+ _teacher_contexts[teacher_id] = ctx
+ logger.info(f"Auto-transitioned to {new_phase} for teacher {teacher_id}")
+
+ return {
+ "success": True,
+ "milestone": milestone,
+ "new_phase": new_phase.value if new_phase else None,
+ "current_phase": ctx.current_phase.value,
+ "completed_milestones": ctx.completed_milestones,
+ }
+
+
+@router.post("/transition")
+async def transition_phase(
+ request: TransitionRequest,
+ teacher_id: str = Query("demo-teacher")
+):
+ """Führt einen manuellen Phasen-Übergang durch."""
+ try:
+ target_phase = SchoolYearPhase(request.target_phase)
+ except ValueError:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Ungültige Phase: {request.target_phase}"
+ )
+
+ ctx = get_or_create_context(teacher_id)
+
+ if not _phase_service.can_transition_to(ctx, target_phase):
+ raise HTTPException(
+ status_code=400,
+ detail=f"Übergang von {ctx.current_phase.value} zu {target_phase.value} nicht erlaubt"
+ )
+
+ old_phase = ctx.current_phase
+ ctx.current_phase = target_phase
+ ctx.phase_entered_at = datetime.now()
+ ctx.days_in_phase = 0
+ _teacher_contexts[teacher_id] = ctx
+
+ logger.info(f"Manual transition from {old_phase} to {target_phase} for teacher {teacher_id}")
+
+ return {
+ "success": True,
+ "old_phase": old_phase.value,
+ "new_phase": target_phase.value,
+ "phase_info": get_phase_info(target_phase).__dict__,
+ }
+
+
+@router.get("/next-phase")
+async def get_next_phase(teacher_id: str = Query("demo-teacher")):
+ """Gibt die nächste Phase und Anforderungen zurück."""
+ ctx = get_or_create_context(teacher_id)
+ next_phase = _phase_service.get_next_phase(ctx.current_phase)
+
+ if not next_phase:
+ return {
+ "next_phase": None,
+ "message": "Letzte Phase erreicht"
+ }
+
+ can_transition = _phase_service.can_transition_to(ctx, next_phase)
+ next_info = get_phase_info(next_phase)
+ current_info = get_phase_info(ctx.current_phase)
+
+ missing = [
+ m for m in current_info.required_actions
+ if m not in ctx.completed_milestones
+ ]
+
+ return {
+ "current_phase": ctx.current_phase.value,
+ "next_phase": next_phase.value,
+ "next_phase_info": {
+ "display_name": next_info.display_name,
+ "description": next_info.description,
+ },
+ "can_transition": can_transition,
+ "missing_requirements": missing,
+ }
+
+
+# ============================================================================
+# Demo Data Endpoints (nur für Entwicklung)
+# ============================================================================
+
+@router.post("/demo/add-class")
+async def demo_add_class(
+ name: str = Query(...),
+ grade_level: int = Query(...),
+ student_count: int = Query(25),
+ teacher_id: str = Query("demo-teacher")
+):
+ """Demo: Fügt eine Klasse zum Kontext hinzu."""
+ ctx = get_or_create_context(teacher_id)
+
+ ctx.classes.append(ClassSummary(
+ class_id=str(uuid.uuid4()),
+ name=name,
+ grade_level=grade_level,
+ student_count=student_count,
+ subject="Deutsch"
+ ))
+ ctx.total_students += student_count
+ _teacher_contexts[teacher_id] = ctx
+
+ return {"success": True, "classes": len(ctx.classes)}
+
+
+@router.post("/demo/add-event")
+async def demo_add_event(
+ event_type: str = Query(...),
+ title: str = Query(...),
+ in_days: int = Query(...),
+ teacher_id: str = Query("demo-teacher")
+):
+ """Demo: Fügt ein Event zum Kontext hinzu."""
+ ctx = get_or_create_context(teacher_id)
+
+ ctx.upcoming_events.append(Event(
+ type=event_type,
+ title=title,
+ date=datetime.now() + timedelta(days=in_days),
+ in_days=in_days,
+ priority="high" if in_days <= 3 else "medium"
+ ))
+ _teacher_contexts[teacher_id] = ctx
+
+ return {"success": True, "events": len(ctx.upcoming_events)}
+
+
+@router.post("/demo/update-stats")
+async def demo_update_stats(
+ learning_units: int = Query(0),
+ exams_scheduled: int = Query(0),
+ exams_graded: int = Query(0),
+ grades_entered: int = Query(0),
+ unanswered_messages: int = Query(0),
+ teacher_id: str = Query("demo-teacher")
+):
+ """Demo: Aktualisiert Statistiken."""
+ ctx = get_or_create_context(teacher_id)
+
+ if learning_units:
+ ctx.stats.learning_units_created = learning_units
+ if exams_scheduled:
+ ctx.stats.exams_scheduled = exams_scheduled
+ if exams_graded:
+ ctx.stats.exams_graded = exams_graded
+ if grades_entered:
+ ctx.stats.grades_entered = grades_entered
+ if unanswered_messages:
+ ctx.stats.unanswered_messages = unanswered_messages
+
+ _teacher_contexts[teacher_id] = ctx
+
+ return {"success": True, "stats": ctx.stats.__dict__}
+
+
+@router.post("/demo/reset")
+async def demo_reset(teacher_id: str = Query("demo-teacher")):
+ """Demo: Setzt den Kontext zurück."""
+ if teacher_id in _teacher_contexts:
+ del _teacher_contexts[teacher_id]
+ if teacher_id in _milestones:
+ del _milestones[teacher_id]
+
+ return {"success": True, "message": "Kontext zurückgesetzt"}
diff --git a/backend-lehrer/classroom/state_engine_models.py b/backend-lehrer/classroom/state_engine_models.py
new file mode 100644
index 0000000..778b2f7
--- /dev/null
+++ b/backend-lehrer/classroom/state_engine_models.py
@@ -0,0 +1,143 @@
+"""
+State Engine API - Pydantic Models und Helper Functions.
+"""
+
+import uuid
+from datetime import datetime, timedelta
+from typing import Dict, Any, List, Optional
+
+from pydantic import BaseModel, Field
+
+from state_engine import (
+ SchoolYearPhase,
+ ClassSummary,
+ Event,
+ TeacherContext,
+ TeacherStats,
+ get_phase_info,
+)
+
+
+# ============================================================================
+# In-Memory Storage (später durch DB ersetzen)
+# ============================================================================
+
+_teacher_contexts: Dict[str, TeacherContext] = {}
+_milestones: Dict[str, List[str]] = {} # teacher_id -> milestones
+
+
+# ============================================================================
+# Pydantic Models
+# ============================================================================
+
+class MilestoneRequest(BaseModel):
+ """Request zum Abschließen eines Meilensteins."""
+ milestone: str = Field(..., description="Name des Meilensteins")
+
+
+class TransitionRequest(BaseModel):
+ """Request für Phasen-Übergang."""
+ target_phase: str = Field(..., description="Zielphase")
+
+
+class ContextResponse(BaseModel):
+ """Response mit TeacherContext."""
+ context: Dict[str, Any]
+ phase_info: Dict[str, Any]
+
+
+class SuggestionsResponse(BaseModel):
+ """Response mit Vorschlägen."""
+ suggestions: List[Dict[str, Any]]
+ current_phase: str
+ phase_display_name: str
+ priority_counts: Dict[str, int]
+
+
+class DashboardResponse(BaseModel):
+ """Response mit Dashboard-Daten."""
+ context: Dict[str, Any]
+ suggestions: List[Dict[str, Any]]
+ stats: Dict[str, Any]
+ upcoming_events: List[Dict[str, Any]]
+ progress: Dict[str, Any]
+ phases: List[Dict[str, Any]]
+
+
+# ============================================================================
+# Helper Functions
+# ============================================================================
+
+def get_or_create_context(teacher_id: str) -> TeacherContext:
+ """
+ Holt oder erstellt TeacherContext.
+
+ In Produktion würde dies aus der Datenbank geladen.
+ """
+ if teacher_id not in _teacher_contexts:
+ now = datetime.now()
+ school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1)
+ weeks_since_start = (now - school_year_start).days // 7
+
+ month = now.month
+ if month in [8, 9]:
+ phase = SchoolYearPhase.SCHOOL_YEAR_START
+ elif month in [10, 11]:
+ phase = SchoolYearPhase.TEACHING_SETUP
+ elif month == 12:
+ phase = SchoolYearPhase.PERFORMANCE_1
+ elif month in [1, 2]:
+ phase = SchoolYearPhase.SEMESTER_END
+ elif month in [3, 4]:
+ phase = SchoolYearPhase.TEACHING_2
+ elif month in [5, 6]:
+ phase = SchoolYearPhase.PERFORMANCE_2
+ else:
+ phase = SchoolYearPhase.YEAR_END
+
+ _teacher_contexts[teacher_id] = TeacherContext(
+ teacher_id=teacher_id,
+ school_id=str(uuid.uuid4()),
+ school_year_id=str(uuid.uuid4()),
+ federal_state="niedersachsen",
+ school_type="gymnasium",
+ school_year_start=school_year_start,
+ current_phase=phase,
+ phase_entered_at=now - timedelta(days=7),
+ weeks_since_start=weeks_since_start,
+ days_in_phase=7,
+ classes=[],
+ total_students=0,
+ upcoming_events=[],
+ completed_milestones=_milestones.get(teacher_id, []),
+ pending_milestones=[],
+ stats=TeacherStats(),
+ )
+
+ return _teacher_contexts[teacher_id]
+
+
+def update_context_from_services(ctx: TeacherContext) -> TeacherContext:
+ """
+ Aktualisiert Kontext mit Daten aus anderen Services.
+
+ In Produktion würde dies von school-service, gradebook etc. laden.
+ """
+ ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days
+ ctx.completed_milestones = _milestones.get(ctx.teacher_id, [])
+
+ phase_info = get_phase_info(ctx.current_phase)
+ ctx.pending_milestones = [
+ m for m in phase_info.required_actions
+ if m not in ctx.completed_milestones
+ ]
+
+ return ctx
+
+
+def get_phase_display_name(phase: str) -> str:
+ """Gibt Display-Name für Phase zurück."""
+ try:
+ return get_phase_info(SchoolYearPhase(phase)).display_name
+ except (ValueError, KeyError):
+ return phase
diff --git a/backend-lehrer/claude_vision.py b/backend-lehrer/claude_vision.py
index c95cb6a..cb900a6 100644
--- a/backend-lehrer/claude_vision.py
+++ b/backend-lehrer/claude_vision.py
@@ -1,299 +1,4 @@
-"""
-Claude Vision API Integration for Worksheet Analysis
-
-Uses Anthropic's Claude 3.5 Sonnet for superior OCR and layout understanding.
-"""
-
-import os
-import base64
-import json
-from pathlib import Path
-from typing import Dict, Optional
-import logging
-
-logger = logging.getLogger(__name__)
-
-# Try to import Anthropic SDK
-try:
- from anthropic import Anthropic
- ANTHROPIC_AVAILABLE = True
-except ImportError:
- ANTHROPIC_AVAILABLE = False
- logger.warning("Anthropic SDK not installed. Run: pip install anthropic")
-
-
-def _get_anthropic_api_key() -> str:
- """Get Anthropic API key from environment variable"""
- api_key = os.getenv("ANTHROPIC_API_KEY")
- if not api_key:
- raise RuntimeError(
- "ANTHROPIC_API_KEY ist nicht gesetzt. "
- "Bitte API-Schlüssel als Umgebungsvariable setzen:\n"
- "export ANTHROPIC_API_KEY='sk-ant-api03-...'"
- )
- return api_key
-
-
-def _encode_image_to_base64(image_path: Path) -> tuple[str, str]:
- """
- Encode image to base64 for Claude API.
-
- Returns:
- (base64_string, media_type)
- """
- image_bytes = image_path.read_bytes()
- image_b64 = base64.standard_b64encode(image_bytes).decode("utf-8")
-
- # Determine media type from extension
- ext = image_path.suffix.lower()
- media_type_map = {
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.png': 'image/png',
- '.gif': 'image/gif',
- '.webp': 'image/webp'
- }
- media_type = media_type_map.get(ext, 'image/jpeg')
-
- return image_b64, media_type
-
-
-def analyze_worksheet_with_claude(
- image_path: Path,
- max_tokens: int = 2500,
- model: str = "claude-3-5-sonnet-20241022"
-) -> Dict:
- """
- Analyze worksheet using Claude Vision API.
-
- Args:
- image_path: Path to worksheet image
- max_tokens: Maximum tokens in response (default 2500)
- model: Claude model to use (default: Claude 3.5 Sonnet)
-
- Returns:
- Analysis dict with same structure as OpenAI version
-
- Raises:
- RuntimeError: If API key not set or SDK not installed
- Exception: If API call fails
- """
- if not ANTHROPIC_AVAILABLE:
- raise RuntimeError("Anthropic SDK nicht installiert. Run: pip install anthropic")
-
- if not image_path.exists():
- raise FileNotFoundError(f"Image not found: {image_path}")
-
- # Get API key
- api_key = _get_anthropic_api_key()
-
- # Initialize Anthropic client
- client = Anthropic(api_key=api_key)
-
- # Encode image
- image_b64, media_type = _encode_image_to_base64(image_path)
-
- # System prompt (instructions)
- system_prompt = """Du bist ein Experte für die Analyse von Schul-Arbeitsblättern.
-
-Deine Aufgabe ist es, das Arbeitsblatt detailliert zu analysieren und strukturierte Informationen zu extrahieren:
-
-1. **Gedruckter Text**: Erkenne den VOLLSTÄNDIGEN gedruckten Text inklusive durchgestrichener Wörter
-2. **Handschrift**: Identifiziere alle handschriftlichen Eintragungen (Schülerantworten, Korrekturen, Notizen)
-3. **Layout**: Bestimme räumliche Positionen aller Elemente (Bounding Boxes in Pixeln)
-4. **Diagramme**: Erkenne gedruckte Illustrationen, Grafiken, Diagramme
-5. **Farben**: Klassifiziere Handschrift nach Farbe (blau/schwarz/rot/Bleistift)
-
-WICHTIG: Gib deine Antwort als gültiges JSON zurück, nicht als Markdown Code Block!"""
-
- # User prompt with JSON schema
- user_prompt = """Analysiere dieses Arbeitsblatt und gib ein JSON mit folgendem Aufbau zurück:
-
-{
- "title": string | null,
- "subject": string | null,
- "grade_level": string | null,
- "instructions": string | null,
- "canonical_text": string | null,
- "printed_blocks": [
- {
- "id": string,
- "role": "title" | "instructions" | "body" | "other",
- "text": string
- }
- ],
- "layout": {
- "page_structure": {
- "has_diagram": boolean,
- "orientation": "portrait" | "landscape"
- },
- "text_regions": [
- {
- "id": string,
- "type": "title" | "paragraph" | "list" | "instruction",
- "text": string,
- "bounding_box": {"x": int, "y": int, "width": int, "height": int},
- "font_characteristics": {
- "is_bold": boolean,
- "approximate_size": "large" | "medium" | "small"
- }
- }
- ],
- "diagram_elements": [
- {
- "id": string,
- "type": "illustration" | "chart" | "graph" | "shape",
- "description": string,
- "bounding_box": {"x": int, "y": int, "width": int, "height": int},
- "preserve": boolean
- }
- ]
- },
- "handwriting_regions": [
- {
- "id": string,
- "text": string,
- "type": "student_answer" | "correction" | "note" | "drawing",
- "bounding_box": {"x": int, "y": int, "width": int, "height": int},
- "color_hint": "blue" | "black" | "red" | "pencil" | "unknown"
- }
- ],
- "handwritten_annotations": [
- {
- "text": string,
- "approx_location": string
- }
- ],
- "struck_through_words": [
- {
- "text": string,
- "context": string
- }
- ],
- "tasks": [
- {
- "id": string,
- "type": "cloze" | "mcq" | "short_answer" | "math" | "other",
- "description": string,
- "text_with_gaps": string | null,
- "gaps": [
- {
- "id": string,
- "solution": string,
- "position_hint": string
- }
- ]
- }
- ]
-}
-
-WICHTIGE HINWEISE:
-- "canonical_text" enthält den KORRIGIERTEN gedruckten Text OHNE Handschrift und OHNE durchgestrichene Wörter
-- "struck_through_words" enthält alle durchgestrichenen Wörter mit Kontext
-- Bounding Boxes sind ungefähre Pixel-Positionen (x, y von oben links, width/height in Pixeln)
-- "layout.text_regions" sollte alle gedruckten Textbereiche mit genauen Positionen enthalten
-- "handwriting_regions" sollte alle handschriftlichen Bereiche mit Farb-Hinweisen enthalten
-- Setze "preserve": true für Diagramm-Elemente die erhalten bleiben sollen
-- Durchgestrichene Wörter NUR in "struck_through_words", NICHT in "canonical_text"
-
-Gib NUR das JSON zurück, ohne Code-Block-Marker!"""
-
- try:
- logger.info(f"Calling Claude API for analysis of {image_path.name}")
-
- # Call Claude API
- response = client.messages.create(
- model=model,
- max_tokens=max_tokens,
- system=system_prompt,
- messages=[
- {
- "role": "user",
- "content": [
- {
- "type": "image",
- "source": {
- "type": "base64",
- "media_type": media_type,
- "data": image_b64,
- },
- },
- {
- "type": "text",
- "text": user_prompt
- }
- ],
- }
- ],
- )
-
- # Extract text from response
- if not response.content:
- raise RuntimeError("Empty response from Claude API")
-
- # Get first text block
- text_content = None
- for block in response.content:
- if block.type == "text":
- text_content = block.text
- break
-
- if not text_content:
- raise RuntimeError("No text content in Claude response")
-
- logger.info(f"Received response from Claude ({len(text_content)} chars)")
-
- # Parse JSON
- # Claude might wrap JSON in ```json ... ```, remove if present
- text_content = text_content.strip()
- if text_content.startswith("```json"):
- text_content = text_content[7:]
- if text_content.startswith("```"):
- text_content = text_content[3:]
- if text_content.endswith("```"):
- text_content = text_content[:-3]
- text_content = text_content.strip()
-
- try:
- analysis_data = json.loads(text_content)
- except json.JSONDecodeError as e:
- logger.error(f"Failed to parse Claude JSON response: {e}")
- logger.error(f"Response text: {text_content[:500]}...")
- raise RuntimeError(f"Invalid JSON from Claude: {e}\nContent: {text_content[:200]}...") from e
-
- logger.info("Successfully parsed Claude analysis")
- return analysis_data
-
- except Exception as e:
- logger.error(f"Claude API call failed: {e}")
- raise
-
-
-def test_claude_connection() -> bool:
- """
- Test if Claude API is accessible with current credentials.
-
- Returns:
- True if connection successful, False otherwise
- """
- if not ANTHROPIC_AVAILABLE:
- logger.error("Anthropic SDK not installed")
- return False
-
- try:
- api_key = _get_anthropic_api_key()
- client = Anthropic(api_key=api_key)
-
- # Simple test call
- response = client.messages.create(
- model="claude-3-5-sonnet-20241022",
- max_tokens=10,
- messages=[{"role": "user", "content": "Test"}]
- )
-
- logger.info("✅ Claude API connection successful")
- return True
-
- except Exception as e:
- logger.error(f"❌ Claude API connection failed: {e}")
- return False
+# Backward-compat shim -- module moved to services/claude_vision.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("services.claude_vision")
diff --git a/backend-lehrer/email_service.py b/backend-lehrer/email_service.py
index f7defbe..1228f28 100644
--- a/backend-lehrer/email_service.py
+++ b/backend-lehrer/email_service.py
@@ -1,395 +1,4 @@
-"""
-BreakPilot Email Service
-
-Ermoeglicht den Versand von Emails via SMTP.
-Verwendet Mailpit im Entwicklungsmodus.
-"""
-
-import os
-import smtplib
-import logging
-from email.mime.text import MIMEText
-from email.mime.multipart import MIMEMultipart
-from email.utils import formataddr
-from typing import Optional, List
-from dataclasses import dataclass
-from datetime import datetime
-
-logger = logging.getLogger(__name__)
-
-# SMTP Konfiguration aus Umgebungsvariablen
-SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
-SMTP_PORT = int(os.getenv("SMTP_PORT", "1025"))
-SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
-SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
-SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "BreakPilot")
-SMTP_FROM_ADDR = os.getenv("SMTP_FROM_ADDR", "noreply@breakpilot.app")
-SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "false").lower() == "true"
-
-
-@dataclass
-class EmailResult:
- """Ergebnis eines Email-Versands."""
- success: bool
- message_id: Optional[str] = None
- error: Optional[str] = None
- recipient: Optional[str] = None
- sent_at: Optional[str] = None
-
-
-class EmailService:
- """Service fuer den Email-Versand."""
-
- def __init__(
- self,
- host: str = SMTP_HOST,
- port: int = SMTP_PORT,
- username: str = SMTP_USERNAME,
- password: str = SMTP_PASSWORD,
- from_name: str = SMTP_FROM_NAME,
- from_addr: str = SMTP_FROM_ADDR,
- use_tls: bool = SMTP_USE_TLS
- ):
- self.host = host
- self.port = port
- self.username = username
- self.password = password
- self.from_name = from_name
- self.from_addr = from_addr
- self.use_tls = use_tls
-
- def _get_connection(self):
- """Erstellt eine SMTP-Verbindung."""
- if self.use_tls:
- smtp = smtplib.SMTP_SSL(self.host, self.port)
- else:
- smtp = smtplib.SMTP(self.host, self.port)
-
- if self.username and self.password:
- smtp.login(self.username, self.password)
-
- return smtp
-
- def send_email(
- self,
- to_email: str,
- subject: str,
- body_text: str,
- body_html: Optional[str] = None,
- reply_to: Optional[str] = None,
- cc: Optional[List[str]] = None,
- bcc: Optional[List[str]] = None
- ) -> EmailResult:
- """
- Sendet eine Email.
-
- Args:
- to_email: Empfaenger-Email
- subject: Betreff
- body_text: Plaintext-Inhalt
- body_html: Optional HTML-Inhalt
- reply_to: Optional Reply-To Adresse
- cc: Optional CC-Empfaenger
- bcc: Optional BCC-Empfaenger
-
- Returns:
- EmailResult mit Erfolg/Fehler
- """
- try:
- # Message erstellen
- if body_html:
- msg = MIMEMultipart("alternative")
- msg.attach(MIMEText(body_text, "plain", "utf-8"))
- msg.attach(MIMEText(body_html, "html", "utf-8"))
- else:
- msg = MIMEText(body_text, "plain", "utf-8")
-
- msg["Subject"] = subject
- msg["From"] = formataddr((self.from_name, self.from_addr))
- msg["To"] = to_email
-
- if reply_to:
- msg["Reply-To"] = reply_to
-
- if cc:
- msg["Cc"] = ", ".join(cc)
-
- # Alle Empfaenger sammeln
- recipients = [to_email]
- if cc:
- recipients.extend(cc)
- if bcc:
- recipients.extend(bcc)
-
- # Senden
- with self._get_connection() as smtp:
- smtp.sendmail(self.from_addr, recipients, msg.as_string())
-
- logger.info(f"Email sent to {to_email}: {subject}")
-
- return EmailResult(
- success=True,
- recipient=to_email,
- sent_at=datetime.utcnow().isoformat()
- )
-
- except smtplib.SMTPException as e:
- logger.error(f"SMTP error sending to {to_email}: {e}")
- return EmailResult(
- success=False,
- error=f"SMTP Fehler: {str(e)}",
- recipient=to_email
- )
- except Exception as e:
- logger.error(f"Error sending email to {to_email}: {e}")
- return EmailResult(
- success=False,
- error=str(e),
- recipient=to_email
- )
-
- def send_messenger_notification(
- self,
- to_email: str,
- to_name: str,
- sender_name: str,
- message_content: str,
- reply_link: Optional[str] = None
- ) -> EmailResult:
- """
- Sendet eine Messenger-Benachrichtigung per Email.
-
- Args:
- to_email: Empfaenger-Email
- to_name: Name des Empfaengers
- sender_name: Name des Absenders
- message_content: Nachrichteninhalt
- reply_link: Optional Link zum Antworten
-
- Returns:
- EmailResult
- """
- subject = f"Neue Nachricht von {sender_name} - BreakPilot"
-
- # Plaintext Version
- body_text = f"""Hallo {to_name},
-
-Sie haben eine neue Nachricht von {sender_name} erhalten:
-
----
-{message_content}
----
-
-"""
- if reply_link:
- body_text += f"Um zu antworten, klicken Sie hier: {reply_link}\n\n"
-
- body_text += """Mit freundlichen Gruessen
-Ihr BreakPilot Team
-
----
-Diese E-Mail wurde automatisch versendet.
-Bitte antworten Sie nicht direkt auf diese E-Mail.
-"""
-
- # HTML Version
- body_html = f"""
-
-
-
-
-
-
-
-
-
-
Hallo {to_name},
-
Sie haben eine neue Nachricht von {sender_name} erhalten:
-
-
- {message_content.replace(chr(10), '
')}
-
-
-"""
- if reply_link:
- body_html += f'
Nachricht beantworten
'
-
- body_html += """
-
-
-
-
-
-"""
-
- return self.send_email(
- to_email=to_email,
- subject=subject,
- body_text=body_text,
- body_html=body_html
- )
-
- def send_jitsi_invitation(
- self,
- to_email: str,
- to_name: str,
- organizer_name: str,
- meeting_title: str,
- meeting_date: str,
- meeting_time: str,
- jitsi_url: str,
- additional_info: Optional[str] = None
- ) -> EmailResult:
- """
- Sendet eine Jitsi-Meeting-Einladung per Email.
-
- Args:
- to_email: Empfaenger-Email
- to_name: Name des Empfaengers
- organizer_name: Name des Organisators
- meeting_title: Titel des Meetings
- meeting_date: Datum des Meetings (z.B. "20. Dezember 2024")
- meeting_time: Uhrzeit des Meetings (z.B. "14:00 Uhr")
- jitsi_url: Der Jitsi-Meeting-Link
- additional_info: Optional zusaetzliche Informationen
-
- Returns:
- EmailResult
- """
- subject = f"Einladung: {meeting_title} - {meeting_date}"
-
- # Plaintext Version
- body_text = f"""Hallo {to_name},
-
-{organizer_name} laedt Sie zu einem Videogespraech ein.
-
-TERMIN: {meeting_title}
-DATUM: {meeting_date}
-UHRZEIT: {meeting_time}
-
-Treten Sie dem Meeting bei:
-{jitsi_url}
-
-"""
- if additional_info:
- body_text += f"HINWEISE:\n{additional_info}\n\n"
-
- body_text += """TECHNISCHE VORAUSSETZUNGEN:
-- Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
-- Keine Installation erforderlich
-- Optional: Kopfhoerer fuer bessere Audioqualitaet
-
-Bei technischen Problemen wenden Sie sich bitte an den Organisator.
-
-Mit freundlichen Gruessen
-Ihr BreakPilot Team
-"""
-
- # HTML Version
- body_html = f"""
-
-
-
-
-
-
-
-
-
-
Hallo {to_name},
-
{organizer_name} laedt Sie zu einem Videogespraech ein.
-
-
-
- Termin:
- {meeting_title}
-
-
- Datum:
- {meeting_date}
-
-
- Uhrzeit:
- {meeting_time}
-
-
-
-
Meeting beitreten
-
-"""
- if additional_info:
- body_html += f"""
-
-
Hinweise:
-
{additional_info}
-
-"""
-
- body_html += """
-
-
Technische Voraussetzungen:
-
- - Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
- - Keine Installation erforderlich
- - Optional: Kopfhoerer fuer bessere Audioqualitaet
-
-
-
-
- Bei technischen Problemen wenden Sie sich bitte an den Organisator.
-
-
-
-
-
-
-"""
-
- return self.send_email(
- to_email=to_email,
- subject=subject,
- body_text=body_text,
- body_html=body_html
- )
-
-
-# Globale Instanz
-email_service = EmailService()
+# Backward-compat shim -- module moved to services/email.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("services.email")
diff --git a/backend-lehrer/klausur_service_proxy.py b/backend-lehrer/klausur_service_proxy.py
index 0c7b841..90d81cb 100644
--- a/backend-lehrer/klausur_service_proxy.py
+++ b/backend-lehrer/klausur_service_proxy.py
@@ -1,135 +1,4 @@
-"""
-Klausur-Service API Proxy
-Routes API requests from /api/klausur/* to the klausur-service microservice
-"""
-
-import os
-import jwt
-import datetime
-import httpx
-from fastapi import APIRouter, Request, HTTPException, Response
-
-# Klausur Service URL
-KLAUSUR_SERVICE_URL = os.getenv("KLAUSUR_SERVICE_URL", "http://klausur-service:8086")
-JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
-ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
-
-# Demo teacher UUID for development mode
-DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20"
-
-router = APIRouter(prefix="/klausur", tags=["klausur"])
-
-
-def get_demo_token() -> str:
- """Generate a demo JWT token for development mode"""
- payload = {
- "user_id": DEMO_TEACHER_ID,
- "email": "demo@breakpilot.app",
- "role": "admin",
- "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24),
- "iat": datetime.datetime.now(datetime.timezone.utc)
- }
- return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
-
-
-async def proxy_request(request: Request, path: str) -> Response:
- """Forward a request to the klausur service"""
- url = f"{KLAUSUR_SERVICE_URL}/api/v1{path}"
-
- # Forward headers, especially Authorization
- headers = {}
- if "authorization" in request.headers:
- headers["Authorization"] = request.headers["authorization"]
- elif ENVIRONMENT == "development":
- # In development mode, use demo token if no auth provided
- demo_token = get_demo_token()
- headers["Authorization"] = f"Bearer {demo_token}"
- if "content-type" in request.headers:
- headers["Content-Type"] = request.headers["content-type"]
-
- # Get request body for POST/PUT/PATCH/DELETE
- body = None
- if request.method in ("POST", "PUT", "PATCH", "DELETE"):
- body = await request.body()
-
- async with httpx.AsyncClient(timeout=60.0) as client:
- try:
- response = await client.request(
- method=request.method,
- url=url,
- headers=headers,
- content=body,
- params=request.query_params
- )
- return Response(
- content=response.content,
- status_code=response.status_code,
- headers=dict(response.headers),
- media_type=response.headers.get("content-type", "application/json")
- )
- except httpx.ConnectError:
- raise HTTPException(
- status_code=503,
- detail="Klausur service unavailable"
- )
- except httpx.TimeoutException:
- raise HTTPException(
- status_code=504,
- detail="Klausur service timeout"
- )
-
-
-# Health check
-@router.get("/health")
-async def health():
- """Health check for klausur service connection"""
- async with httpx.AsyncClient(timeout=5.0) as client:
- try:
- response = await client.get(f"{KLAUSUR_SERVICE_URL}/health")
- return {"klausur_service": "healthy", "connected": response.status_code == 200}
- except Exception:
- return {"klausur_service": "unhealthy", "connected": False}
-
-
-# Klausuren
-@router.api_route("/klausuren", methods=["GET", "POST"])
-async def klausuren(request: Request):
- return await proxy_request(request, "/klausuren")
-
-
-@router.api_route("/klausuren/{klausur_id}", methods=["GET", "PUT", "DELETE"])
-async def klausur_by_id(klausur_id: str, request: Request):
- return await proxy_request(request, f"/klausuren/{klausur_id}")
-
-
-# Students
-@router.api_route("/klausuren/{klausur_id}/students", methods=["GET", "POST"])
-async def klausur_students(klausur_id: str, request: Request):
- return await proxy_request(request, f"/klausuren/{klausur_id}/students")
-
-
-@router.api_route("/students/{student_id}", methods=["GET", "DELETE"])
-async def student_by_id(student_id: str, request: Request):
- return await proxy_request(request, f"/students/{student_id}")
-
-
-# Grading
-@router.api_route("/students/{student_id}/criteria", methods=["PUT"])
-async def update_criteria(student_id: str, request: Request):
- return await proxy_request(request, f"/students/{student_id}/criteria")
-
-
-@router.api_route("/students/{student_id}/gutachten", methods=["PUT"])
-async def update_gutachten(student_id: str, request: Request):
- return await proxy_request(request, f"/students/{student_id}/gutachten")
-
-
-@router.api_route("/students/{student_id}/finalize", methods=["POST"])
-async def finalize_student(student_id: str, request: Request):
- return await proxy_request(request, f"/students/{student_id}/finalize")
-
-
-# Grade info
-@router.get("/grade-info")
-async def grade_info(request: Request):
- return await proxy_request(request, "/grade-info")
+# Backward-compat shim -- module moved to api/klausur_proxy.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("api.klausur_proxy")
diff --git a/backend-lehrer/progress_api.py b/backend-lehrer/progress_api.py
index 9b1c7d8..4b5a5d1 100644
--- a/backend-lehrer/progress_api.py
+++ b/backend-lehrer/progress_api.py
@@ -1,131 +1,4 @@
-"""
-Progress API — Tracks student learning progress per unit.
-
-Stores coins, crowns, streak data, and exercise completion stats.
-Uses JSON file storage (same pattern as learning_units.py).
-"""
-
-import os
-import json
-import logging
-from datetime import datetime, date
-from typing import Dict, Any, Optional, List
-from pathlib import Path
-
-from fastapi import APIRouter, HTTPException
-from pydantic import BaseModel
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(
- prefix="/progress",
- tags=["progress"],
-)
-
-PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress")
-
-
-def _ensure_dir():
- os.makedirs(PROGRESS_DIR, exist_ok=True)
-
-
-def _progress_path(unit_id: str) -> Path:
- return Path(PROGRESS_DIR) / f"{unit_id}.json"
-
-
-def _load_progress(unit_id: str) -> Dict[str, Any]:
- path = _progress_path(unit_id)
- if path.exists():
- with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
- return {
- "unit_id": unit_id,
- "coins": 0,
- "crowns": 0,
- "streak_days": 0,
- "last_activity": None,
- "exercises": {
- "flashcards": {"completed": 0, "correct": 0, "incorrect": 0},
- "quiz": {"completed": 0, "correct": 0, "incorrect": 0},
- "type": {"completed": 0, "correct": 0, "incorrect": 0},
- "story": {"generated": 0},
- },
- "created_at": datetime.now().isoformat(),
- }
-
-
-def _save_progress(unit_id: str, data: Dict[str, Any]):
- _ensure_dir()
- path = _progress_path(unit_id)
- with open(path, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
-
-
-class RewardPayload(BaseModel):
- exercise_type: str # flashcards, quiz, type, story
- correct: bool = True
- first_try: bool = True
-
-
-@router.get("/{unit_id}")
-def get_progress(unit_id: str):
- """Get learning progress for a unit."""
- return _load_progress(unit_id)
-
-
-@router.post("/{unit_id}/reward")
-def add_reward(unit_id: str, payload: RewardPayload):
- """Record an exercise result and award coins."""
- progress = _load_progress(unit_id)
-
- # Update exercise stats
- ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0})
- ex["completed"] = ex.get("completed", 0) + 1
- if payload.correct:
- ex["correct"] = ex.get("correct", 0) + 1
- else:
- ex["incorrect"] = ex.get("incorrect", 0) + 1
- progress["exercises"][payload.exercise_type] = ex
-
- # Award coins
- if payload.correct:
- coins = 3 if payload.first_try else 1
- else:
- coins = 0
- progress["coins"] = progress.get("coins", 0) + coins
-
- # Update streak
- today = date.today().isoformat()
- last = progress.get("last_activity")
- if last != today:
- if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None:
- progress["streak_days"] = progress.get("streak_days", 0) + 1
- elif last != today:
- progress["streak_days"] = 1
- progress["last_activity"] = today
-
- # Award crowns for milestones
- total_correct = sum(
- e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict)
- )
- progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers
-
- _save_progress(unit_id, progress)
-
- return {
- "coins_awarded": coins,
- "total_coins": progress["coins"],
- "crowns": progress["crowns"],
- "streak_days": progress["streak_days"],
- }
-
-
-@router.get("/")
-def list_all_progress():
- """List progress for all units."""
- _ensure_dir()
- results = []
- for f in Path(PROGRESS_DIR).glob("*.json"):
- with open(f, "r", encoding="utf-8") as fh:
- results.append(json.load(fh))
- return results
+# Backward-compat shim -- module moved to api/progress.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("api.progress")
diff --git a/backend-lehrer/school_api.py b/backend-lehrer/school_api.py
index f49c5be..89c1426 100644
--- a/backend-lehrer/school_api.py
+++ b/backend-lehrer/school_api.py
@@ -1,250 +1,4 @@
-"""
-School Service API Proxy
-Routes API requests from /api/school/* to the Go school-service
-"""
-
-import os
-import jwt
-import datetime
-import httpx
-from fastapi import APIRouter, Request, HTTPException, Response
-
-# School Service URL
-SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084")
-JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
-ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
-
-# Demo teacher UUID for development mode
-DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20"
-
-router = APIRouter(prefix="/school", tags=["school"])
-
-
-def get_demo_token() -> str:
- """Generate a demo JWT token for development mode"""
- payload = {
- "user_id": DEMO_TEACHER_ID,
- "email": "demo@breakpilot.app",
- "role": "admin",
- "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24),
- "iat": datetime.datetime.now(datetime.timezone.utc)
- }
- return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
-
-
-async def proxy_request(request: Request, path: str) -> Response:
- """Forward a request to the school service"""
- url = f"{SCHOOL_SERVICE_URL}/api/v1/school{path}"
-
- # Forward headers, especially Authorization
- headers = {}
- if "authorization" in request.headers:
- headers["Authorization"] = request.headers["authorization"]
- elif ENVIRONMENT == "development":
- # In development mode, use demo token if no auth provided
- demo_token = get_demo_token()
- headers["Authorization"] = f"Bearer {demo_token}"
- if "content-type" in request.headers:
- headers["Content-Type"] = request.headers["content-type"]
-
- # Get request body for POST/PUT/PATCH
- body = None
- if request.method in ("POST", "PUT", "PATCH"):
- body = await request.body()
-
- async with httpx.AsyncClient(timeout=30.0) as client:
- try:
- response = await client.request(
- method=request.method,
- url=url,
- headers=headers,
- content=body,
- params=request.query_params
- )
- return Response(
- content=response.content,
- status_code=response.status_code,
- headers=dict(response.headers),
- media_type=response.headers.get("content-type", "application/json")
- )
- except httpx.ConnectError:
- raise HTTPException(
- status_code=503,
- detail="School service unavailable"
- )
- except httpx.TimeoutException:
- raise HTTPException(
- status_code=504,
- detail="School service timeout"
- )
-
-
-# Health check
-@router.get("/health")
-async def health():
- """Health check for school service connection"""
- async with httpx.AsyncClient(timeout=5.0) as client:
- try:
- response = await client.get(f"{SCHOOL_SERVICE_URL}/health")
- return {"school_service": "healthy", "connected": response.status_code == 200}
- except Exception:
- return {"school_service": "unhealthy", "connected": False}
-
-
-# School Years
-@router.api_route("/years", methods=["GET", "POST"])
-async def years(request: Request):
- return await proxy_request(request, "/years")
-
-
-@router.api_route("/years/{year_id}", methods=["GET", "PUT", "DELETE"])
-async def year_by_id(request: Request, year_id: str):
- return await proxy_request(request, f"/years/{year_id}")
-
-
-# Classes
-@router.api_route("/classes", methods=["GET", "POST"])
-async def classes(request: Request):
- return await proxy_request(request, "/classes")
-
-
-@router.api_route("/classes/{class_id}", methods=["GET", "PUT", "DELETE"])
-async def class_by_id(request: Request, class_id: str):
- return await proxy_request(request, f"/classes/{class_id}")
-
-
-# Students
-@router.api_route("/classes/{class_id}/students", methods=["GET", "POST"])
-async def students(request: Request, class_id: str):
- return await proxy_request(request, f"/classes/{class_id}/students")
-
-
-@router.api_route("/classes/{class_id}/students/import", methods=["POST"])
-async def import_students(request: Request, class_id: str):
- return await proxy_request(request, f"/classes/{class_id}/students/import")
-
-
-@router.api_route("/classes/{class_id}/students/{student_id}", methods=["GET", "PUT", "DELETE"])
-async def student_by_id(request: Request, class_id: str, student_id: str):
- return await proxy_request(request, f"/classes/{class_id}/students/{student_id}")
-
-
-# Subjects
-@router.api_route("/subjects", methods=["GET", "POST"])
-async def subjects(request: Request):
- return await proxy_request(request, "/subjects")
-
-
-@router.api_route("/subjects/{subject_id}", methods=["GET", "PUT", "DELETE"])
-async def subject_by_id(request: Request, subject_id: str):
- return await proxy_request(request, f"/subjects/{subject_id}")
-
-
-# Exams
-@router.api_route("/exams", methods=["GET", "POST"])
-async def exams(request: Request):
- return await proxy_request(request, "/exams")
-
-
-@router.api_route("/exams/{exam_id}", methods=["GET", "PUT", "DELETE"])
-async def exam_by_id(request: Request, exam_id: str):
- return await proxy_request(request, f"/exams/{exam_id}")
-
-
-@router.api_route("/exams/{exam_id}/results", methods=["GET", "POST"])
-async def exam_results(request: Request, exam_id: str):
- return await proxy_request(request, f"/exams/{exam_id}/results")
-
-
-@router.api_route("/exams/{exam_id}/results/{student_id}", methods=["PUT"])
-async def exam_result_update(request: Request, exam_id: str, student_id: str):
- return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}")
-
-
-@router.api_route("/exams/{exam_id}/results/{student_id}/approve", methods=["PUT"])
-async def approve_result(request: Request, exam_id: str, student_id: str):
- return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}/approve")
-
-
-@router.api_route("/exams/{exam_id}/generate-variant", methods=["POST"])
-async def generate_variant(request: Request, exam_id: str):
- return await proxy_request(request, f"/exams/{exam_id}/generate-variant")
-
-
-# Grades
-@router.api_route("/grades/{class_id}", methods=["GET"])
-async def grades_by_class(request: Request, class_id: str):
- return await proxy_request(request, f"/grades/{class_id}")
-
-
-@router.api_route("/grades/student/{student_id}", methods=["GET"])
-async def grades_by_student(request: Request, student_id: str):
- return await proxy_request(request, f"/grades/student/{student_id}")
-
-
-@router.api_route("/grades/{student_id}/{subject_id}/oral", methods=["PUT"])
-async def update_oral_grade(request: Request, student_id: str, subject_id: str):
- return await proxy_request(request, f"/grades/{student_id}/{subject_id}/oral")
-
-
-@router.api_route("/grades/calculate", methods=["POST"])
-async def calculate_grades(request: Request):
- return await proxy_request(request, "/grades/calculate")
-
-
-# Attendance
-@router.api_route("/attendance/{class_id}", methods=["GET"])
-async def attendance_by_class(request: Request, class_id: str):
- return await proxy_request(request, f"/attendance/{class_id}")
-
-
-@router.api_route("/attendance", methods=["POST"])
-async def create_attendance(request: Request):
- return await proxy_request(request, "/attendance")
-
-
-@router.api_route("/attendance/bulk", methods=["POST"])
-async def bulk_attendance(request: Request):
- return await proxy_request(request, "/attendance/bulk")
-
-
-# Gradebook
-@router.api_route("/gradebook/{class_id}", methods=["GET"])
-async def gradebook_by_class(request: Request, class_id: str):
- return await proxy_request(request, f"/gradebook/{class_id}")
-
-
-@router.api_route("/gradebook", methods=["POST"])
-async def create_gradebook_entry(request: Request):
- return await proxy_request(request, "/gradebook")
-
-
-@router.api_route("/gradebook/{entry_id}", methods=["DELETE"])
-async def delete_gradebook_entry(request: Request, entry_id: str):
- return await proxy_request(request, f"/gradebook/{entry_id}")
-
-
-# Certificates
-@router.api_route("/certificates/templates", methods=["GET"])
-async def certificate_templates(request: Request):
- return await proxy_request(request, "/certificates/templates")
-
-
-@router.api_route("/certificates/generate", methods=["POST"])
-async def generate_certificate(request: Request):
- return await proxy_request(request, "/certificates/generate")
-
-
-@router.api_route("/certificates/{certificate_id}", methods=["GET"])
-async def certificate_by_id(request: Request, certificate_id: str):
- return await proxy_request(request, f"/certificates/{certificate_id}")
-
-
-@router.api_route("/certificates/{certificate_id}/pdf", methods=["GET"])
-async def certificate_pdf(request: Request, certificate_id: str):
- return await proxy_request(request, f"/certificates/{certificate_id}/pdf")
-
-
-@router.api_route("/certificates/{certificate_id}/finalize", methods=["PUT"])
-async def finalize_certificate(request: Request, certificate_id: str):
- return await proxy_request(request, f"/certificates/{certificate_id}/finalize")
+# Backward-compat shim -- module moved to api/school.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("api.school")
diff --git a/backend-lehrer/services/__init__.py b/backend-lehrer/services/__init__.py
index 8d932cc..aed3197 100644
--- a/backend-lehrer/services/__init__.py
+++ b/backend-lehrer/services/__init__.py
@@ -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"]
diff --git a/backend-lehrer/services/ai_processor.py b/backend-lehrer/services/ai_processor.py
new file mode 100644
index 0000000..ba893a4
--- /dev/null
+++ b/backend-lehrer/services/ai_processor.py
@@ -0,0 +1,81 @@
+"""
+AI Processor - Legacy Import Wrapper
+
+This file provides backward compatibility for code that imports from ai_processor.
+All functionality has been moved to the ai_processor/ module.
+
+Usage (new):
+ from ai_processor import analyze_scan_structure_with_ai
+
+Usage (legacy, still works):
+ from ai_processor import analyze_scan_structure_with_ai
+"""
+
+# Re-export everything from the new modular structure
+from ai_processor import (
+ # Configuration
+ BASE_DIR,
+ EINGANG_DIR,
+ BEREINIGT_DIR,
+ VISION_API,
+ # Utilities (with legacy aliases)
+ encode_image_to_data_url as _encode_image_to_data_url,
+ dummy_process_scan,
+ # Vision - Scan Analysis
+ analyze_scan_structure_with_ai,
+ describe_scan_with_ai,
+ remove_handwriting_from_scan,
+ build_clean_html_from_analysis,
+ # Generators - Multiple Choice
+ generate_mc_from_analysis,
+ # Generators - Cloze
+ generate_cloze_from_analysis,
+ # Generators - Q&A with Leitner
+ generate_qa_from_analysis,
+ update_leitner_progress,
+ get_next_review_items,
+ # Export - Print Versions
+ generate_print_version_qa,
+ generate_print_version_cloze,
+ generate_print_version_mc,
+ generate_print_version_worksheet,
+ # Visualization - Mindmap
+ generate_mindmap_data,
+ generate_mindmap_html,
+ save_mindmap_for_worksheet,
+)
+
+# Legacy function alias
+from ai_processor import get_openai_api_key as _get_api_key
+
+__all__ = [
+ # Configuration
+ "BASE_DIR",
+ "EINGANG_DIR",
+ "BEREINIGT_DIR",
+ "VISION_API",
+ # Legacy private functions
+ "_get_api_key",
+ "_encode_image_to_data_url",
+ # Vision
+ "analyze_scan_structure_with_ai",
+ "describe_scan_with_ai",
+ "remove_handwriting_from_scan",
+ "build_clean_html_from_analysis",
+ "dummy_process_scan",
+ # Generators
+ "generate_mc_from_analysis",
+ "generate_cloze_from_analysis",
+ "generate_qa_from_analysis",
+ "update_leitner_progress",
+ "get_next_review_items",
+ # Export
+ "generate_print_version_qa",
+ "generate_print_version_cloze",
+ "generate_print_version_mc",
+ "generate_print_version_worksheet",
+ # Visualization
+ "generate_mindmap_data",
+ "generate_mindmap_html",
+ "save_mindmap_for_worksheet",
+]
diff --git a/backend-lehrer/services/audio.py b/backend-lehrer/services/audio.py
new file mode 100644
index 0000000..d1ee9ca
--- /dev/null
+++ b/backend-lehrer/services/audio.py
@@ -0,0 +1,125 @@
+"""
+Audio Service — Generates TTS audio for vocabulary words.
+
+Uses the Piper TTS service (compliance-tts-service, MIT license)
+for high-quality German (Thorsten) and English (Lessac) voices.
+Falls back to a placeholder response if TTS service is unavailable.
+
+Audio files are cached — generated once, served forever.
+"""
+
+import hashlib
+import logging
+import os
+from typing import Optional
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# Piper TTS service (runs in compliance stack)
+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")
+
+
+def _ensure_cache_dir():
+ os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
+
+
+def _cache_key(text: str, language: str) -> str:
+ """Generate a deterministic cache key for text + language."""
+ h = hashlib.sha256(f"{language}:{text}".encode()).hexdigest()[:16]
+ return f"{language}_{h}"
+
+
+def _cache_path(text: str, language: str) -> str:
+ """Full path to cached MP3 file."""
+ _ensure_cache_dir()
+ return os.path.join(AUDIO_CACHE_DIR, f"{_cache_key(text, language)}.mp3")
+
+
+async def synthesize_word(
+ text: str,
+ language: str = "de",
+ word_id: str = "",
+) -> Optional[str]:
+ """
+ Generate TTS audio for a word or short phrase.
+
+ Returns the file path to the cached MP3, or None on error.
+ Uses Piper TTS service (compliance-tts-service).
+ """
+ # Check cache first
+ cached = _cache_path(text, language)
+ if os.path.exists(cached):
+ return cached
+
+ # Call Piper TTS service
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.post(
+ f"{TTS_SERVICE_URL}/synthesize",
+ json={
+ "text": 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"):
+ with open(cached, "wb") as f:
+ f.write(resp.content)
+ logger.info(f"TTS cached (direct): '{text}' ({language}) → {cached}")
+ return cached
+ except Exception as e:
+ logger.debug(f"TTS direct fallback also failed: {e}")
+
+ return None
+
+
+async def get_or_generate_audio(
+ text: str, language: str = "de", word_id: str = "",
+) -> Optional[bytes]:
+ """
+ Get audio bytes for a word. Returns MP3 bytes or None.
+ Generates via TTS if not cached.
+ """
+ path = await synthesize_word(text, language, word_id)
+ if path and os.path.exists(path):
+ with open(path, "rb") as f:
+ return f.read()
+ return None
diff --git a/backend-lehrer/services/claude_vision.py b/backend-lehrer/services/claude_vision.py
new file mode 100644
index 0000000..c95cb6a
--- /dev/null
+++ b/backend-lehrer/services/claude_vision.py
@@ -0,0 +1,299 @@
+"""
+Claude Vision API Integration for Worksheet Analysis
+
+Uses Anthropic's Claude 3.5 Sonnet for superior OCR and layout understanding.
+"""
+
+import os
+import base64
+import json
+from pathlib import Path
+from typing import Dict, Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+# Try to import Anthropic SDK
+try:
+ from anthropic import Anthropic
+ ANTHROPIC_AVAILABLE = True
+except ImportError:
+ ANTHROPIC_AVAILABLE = False
+ logger.warning("Anthropic SDK not installed. Run: pip install anthropic")
+
+
+def _get_anthropic_api_key() -> str:
+ """Get Anthropic API key from environment variable"""
+ api_key = os.getenv("ANTHROPIC_API_KEY")
+ if not api_key:
+ raise RuntimeError(
+ "ANTHROPIC_API_KEY ist nicht gesetzt. "
+ "Bitte API-Schlüssel als Umgebungsvariable setzen:\n"
+ "export ANTHROPIC_API_KEY='sk-ant-api03-...'"
+ )
+ return api_key
+
+
+def _encode_image_to_base64(image_path: Path) -> tuple[str, str]:
+ """
+ Encode image to base64 for Claude API.
+
+ Returns:
+ (base64_string, media_type)
+ """
+ image_bytes = image_path.read_bytes()
+ image_b64 = base64.standard_b64encode(image_bytes).decode("utf-8")
+
+ # Determine media type from extension
+ ext = image_path.suffix.lower()
+ media_type_map = {
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp'
+ }
+ media_type = media_type_map.get(ext, 'image/jpeg')
+
+ return image_b64, media_type
+
+
+def analyze_worksheet_with_claude(
+ image_path: Path,
+ max_tokens: int = 2500,
+ model: str = "claude-3-5-sonnet-20241022"
+) -> Dict:
+ """
+ Analyze worksheet using Claude Vision API.
+
+ Args:
+ image_path: Path to worksheet image
+ max_tokens: Maximum tokens in response (default 2500)
+ model: Claude model to use (default: Claude 3.5 Sonnet)
+
+ Returns:
+ Analysis dict with same structure as OpenAI version
+
+ Raises:
+ RuntimeError: If API key not set or SDK not installed
+ Exception: If API call fails
+ """
+ if not ANTHROPIC_AVAILABLE:
+ raise RuntimeError("Anthropic SDK nicht installiert. Run: pip install anthropic")
+
+ if not image_path.exists():
+ raise FileNotFoundError(f"Image not found: {image_path}")
+
+ # Get API key
+ api_key = _get_anthropic_api_key()
+
+ # Initialize Anthropic client
+ client = Anthropic(api_key=api_key)
+
+ # Encode image
+ image_b64, media_type = _encode_image_to_base64(image_path)
+
+ # System prompt (instructions)
+ system_prompt = """Du bist ein Experte für die Analyse von Schul-Arbeitsblättern.
+
+Deine Aufgabe ist es, das Arbeitsblatt detailliert zu analysieren und strukturierte Informationen zu extrahieren:
+
+1. **Gedruckter Text**: Erkenne den VOLLSTÄNDIGEN gedruckten Text inklusive durchgestrichener Wörter
+2. **Handschrift**: Identifiziere alle handschriftlichen Eintragungen (Schülerantworten, Korrekturen, Notizen)
+3. **Layout**: Bestimme räumliche Positionen aller Elemente (Bounding Boxes in Pixeln)
+4. **Diagramme**: Erkenne gedruckte Illustrationen, Grafiken, Diagramme
+5. **Farben**: Klassifiziere Handschrift nach Farbe (blau/schwarz/rot/Bleistift)
+
+WICHTIG: Gib deine Antwort als gültiges JSON zurück, nicht als Markdown Code Block!"""
+
+ # User prompt with JSON schema
+ user_prompt = """Analysiere dieses Arbeitsblatt und gib ein JSON mit folgendem Aufbau zurück:
+
+{
+ "title": string | null,
+ "subject": string | null,
+ "grade_level": string | null,
+ "instructions": string | null,
+ "canonical_text": string | null,
+ "printed_blocks": [
+ {
+ "id": string,
+ "role": "title" | "instructions" | "body" | "other",
+ "text": string
+ }
+ ],
+ "layout": {
+ "page_structure": {
+ "has_diagram": boolean,
+ "orientation": "portrait" | "landscape"
+ },
+ "text_regions": [
+ {
+ "id": string,
+ "type": "title" | "paragraph" | "list" | "instruction",
+ "text": string,
+ "bounding_box": {"x": int, "y": int, "width": int, "height": int},
+ "font_characteristics": {
+ "is_bold": boolean,
+ "approximate_size": "large" | "medium" | "small"
+ }
+ }
+ ],
+ "diagram_elements": [
+ {
+ "id": string,
+ "type": "illustration" | "chart" | "graph" | "shape",
+ "description": string,
+ "bounding_box": {"x": int, "y": int, "width": int, "height": int},
+ "preserve": boolean
+ }
+ ]
+ },
+ "handwriting_regions": [
+ {
+ "id": string,
+ "text": string,
+ "type": "student_answer" | "correction" | "note" | "drawing",
+ "bounding_box": {"x": int, "y": int, "width": int, "height": int},
+ "color_hint": "blue" | "black" | "red" | "pencil" | "unknown"
+ }
+ ],
+ "handwritten_annotations": [
+ {
+ "text": string,
+ "approx_location": string
+ }
+ ],
+ "struck_through_words": [
+ {
+ "text": string,
+ "context": string
+ }
+ ],
+ "tasks": [
+ {
+ "id": string,
+ "type": "cloze" | "mcq" | "short_answer" | "math" | "other",
+ "description": string,
+ "text_with_gaps": string | null,
+ "gaps": [
+ {
+ "id": string,
+ "solution": string,
+ "position_hint": string
+ }
+ ]
+ }
+ ]
+}
+
+WICHTIGE HINWEISE:
+- "canonical_text" enthält den KORRIGIERTEN gedruckten Text OHNE Handschrift und OHNE durchgestrichene Wörter
+- "struck_through_words" enthält alle durchgestrichenen Wörter mit Kontext
+- Bounding Boxes sind ungefähre Pixel-Positionen (x, y von oben links, width/height in Pixeln)
+- "layout.text_regions" sollte alle gedruckten Textbereiche mit genauen Positionen enthalten
+- "handwriting_regions" sollte alle handschriftlichen Bereiche mit Farb-Hinweisen enthalten
+- Setze "preserve": true für Diagramm-Elemente die erhalten bleiben sollen
+- Durchgestrichene Wörter NUR in "struck_through_words", NICHT in "canonical_text"
+
+Gib NUR das JSON zurück, ohne Code-Block-Marker!"""
+
+ try:
+ logger.info(f"Calling Claude API for analysis of {image_path.name}")
+
+ # Call Claude API
+ response = client.messages.create(
+ model=model,
+ max_tokens=max_tokens,
+ system=system_prompt,
+ messages=[
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "media_type": media_type,
+ "data": image_b64,
+ },
+ },
+ {
+ "type": "text",
+ "text": user_prompt
+ }
+ ],
+ }
+ ],
+ )
+
+ # Extract text from response
+ if not response.content:
+ raise RuntimeError("Empty response from Claude API")
+
+ # Get first text block
+ text_content = None
+ for block in response.content:
+ if block.type == "text":
+ text_content = block.text
+ break
+
+ if not text_content:
+ raise RuntimeError("No text content in Claude response")
+
+ logger.info(f"Received response from Claude ({len(text_content)} chars)")
+
+ # Parse JSON
+ # Claude might wrap JSON in ```json ... ```, remove if present
+ text_content = text_content.strip()
+ if text_content.startswith("```json"):
+ text_content = text_content[7:]
+ if text_content.startswith("```"):
+ text_content = text_content[3:]
+ if text_content.endswith("```"):
+ text_content = text_content[:-3]
+ text_content = text_content.strip()
+
+ try:
+ analysis_data = json.loads(text_content)
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to parse Claude JSON response: {e}")
+ logger.error(f"Response text: {text_content[:500]}...")
+ raise RuntimeError(f"Invalid JSON from Claude: {e}\nContent: {text_content[:200]}...") from e
+
+ logger.info("Successfully parsed Claude analysis")
+ return analysis_data
+
+ except Exception as e:
+ logger.error(f"Claude API call failed: {e}")
+ raise
+
+
+def test_claude_connection() -> bool:
+ """
+ Test if Claude API is accessible with current credentials.
+
+ Returns:
+ True if connection successful, False otherwise
+ """
+ if not ANTHROPIC_AVAILABLE:
+ logger.error("Anthropic SDK not installed")
+ return False
+
+ try:
+ api_key = _get_anthropic_api_key()
+ client = Anthropic(api_key=api_key)
+
+ # Simple test call
+ response = client.messages.create(
+ model="claude-3-5-sonnet-20241022",
+ max_tokens=10,
+ messages=[{"role": "user", "content": "Test"}]
+ )
+
+ logger.info("✅ Claude API connection successful")
+ return True
+
+ except Exception as e:
+ logger.error(f"❌ Claude API connection failed: {e}")
+ return False
diff --git a/backend-lehrer/services/email.py b/backend-lehrer/services/email.py
new file mode 100644
index 0000000..f7defbe
--- /dev/null
+++ b/backend-lehrer/services/email.py
@@ -0,0 +1,395 @@
+"""
+BreakPilot Email Service
+
+Ermoeglicht den Versand von Emails via SMTP.
+Verwendet Mailpit im Entwicklungsmodus.
+"""
+
+import os
+import smtplib
+import logging
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from email.utils import formataddr
+from typing import Optional, List
+from dataclasses import dataclass
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+# SMTP Konfiguration aus Umgebungsvariablen
+SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
+SMTP_PORT = int(os.getenv("SMTP_PORT", "1025"))
+SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
+SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
+SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "BreakPilot")
+SMTP_FROM_ADDR = os.getenv("SMTP_FROM_ADDR", "noreply@breakpilot.app")
+SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "false").lower() == "true"
+
+
+@dataclass
+class EmailResult:
+ """Ergebnis eines Email-Versands."""
+ success: bool
+ message_id: Optional[str] = None
+ error: Optional[str] = None
+ recipient: Optional[str] = None
+ sent_at: Optional[str] = None
+
+
+class EmailService:
+ """Service fuer den Email-Versand."""
+
+ def __init__(
+ self,
+ host: str = SMTP_HOST,
+ port: int = SMTP_PORT,
+ username: str = SMTP_USERNAME,
+ password: str = SMTP_PASSWORD,
+ from_name: str = SMTP_FROM_NAME,
+ from_addr: str = SMTP_FROM_ADDR,
+ use_tls: bool = SMTP_USE_TLS
+ ):
+ self.host = host
+ self.port = port
+ self.username = username
+ self.password = password
+ self.from_name = from_name
+ self.from_addr = from_addr
+ self.use_tls = use_tls
+
+ def _get_connection(self):
+ """Erstellt eine SMTP-Verbindung."""
+ if self.use_tls:
+ smtp = smtplib.SMTP_SSL(self.host, self.port)
+ else:
+ smtp = smtplib.SMTP(self.host, self.port)
+
+ if self.username and self.password:
+ smtp.login(self.username, self.password)
+
+ return smtp
+
+ def send_email(
+ self,
+ to_email: str,
+ subject: str,
+ body_text: str,
+ body_html: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ cc: Optional[List[str]] = None,
+ bcc: Optional[List[str]] = None
+ ) -> EmailResult:
+ """
+ Sendet eine Email.
+
+ Args:
+ to_email: Empfaenger-Email
+ subject: Betreff
+ body_text: Plaintext-Inhalt
+ body_html: Optional HTML-Inhalt
+ reply_to: Optional Reply-To Adresse
+ cc: Optional CC-Empfaenger
+ bcc: Optional BCC-Empfaenger
+
+ Returns:
+ EmailResult mit Erfolg/Fehler
+ """
+ try:
+ # Message erstellen
+ if body_html:
+ msg = MIMEMultipart("alternative")
+ msg.attach(MIMEText(body_text, "plain", "utf-8"))
+ msg.attach(MIMEText(body_html, "html", "utf-8"))
+ else:
+ msg = MIMEText(body_text, "plain", "utf-8")
+
+ msg["Subject"] = subject
+ msg["From"] = formataddr((self.from_name, self.from_addr))
+ msg["To"] = to_email
+
+ if reply_to:
+ msg["Reply-To"] = reply_to
+
+ if cc:
+ msg["Cc"] = ", ".join(cc)
+
+ # Alle Empfaenger sammeln
+ recipients = [to_email]
+ if cc:
+ recipients.extend(cc)
+ if bcc:
+ recipients.extend(bcc)
+
+ # Senden
+ with self._get_connection() as smtp:
+ smtp.sendmail(self.from_addr, recipients, msg.as_string())
+
+ logger.info(f"Email sent to {to_email}: {subject}")
+
+ return EmailResult(
+ success=True,
+ recipient=to_email,
+ sent_at=datetime.utcnow().isoformat()
+ )
+
+ except smtplib.SMTPException as e:
+ logger.error(f"SMTP error sending to {to_email}: {e}")
+ return EmailResult(
+ success=False,
+ error=f"SMTP Fehler: {str(e)}",
+ recipient=to_email
+ )
+ except Exception as e:
+ logger.error(f"Error sending email to {to_email}: {e}")
+ return EmailResult(
+ success=False,
+ error=str(e),
+ recipient=to_email
+ )
+
+ def send_messenger_notification(
+ self,
+ to_email: str,
+ to_name: str,
+ sender_name: str,
+ message_content: str,
+ reply_link: Optional[str] = None
+ ) -> EmailResult:
+ """
+ Sendet eine Messenger-Benachrichtigung per Email.
+
+ Args:
+ to_email: Empfaenger-Email
+ to_name: Name des Empfaengers
+ sender_name: Name des Absenders
+ message_content: Nachrichteninhalt
+ reply_link: Optional Link zum Antworten
+
+ Returns:
+ EmailResult
+ """
+ subject = f"Neue Nachricht von {sender_name} - BreakPilot"
+
+ # Plaintext Version
+ body_text = f"""Hallo {to_name},
+
+Sie haben eine neue Nachricht von {sender_name} erhalten:
+
+---
+{message_content}
+---
+
+"""
+ if reply_link:
+ body_text += f"Um zu antworten, klicken Sie hier: {reply_link}\n\n"
+
+ body_text += """Mit freundlichen Gruessen
+Ihr BreakPilot Team
+
+---
+Diese E-Mail wurde automatisch versendet.
+Bitte antworten Sie nicht direkt auf diese E-Mail.
+"""
+
+ # HTML Version
+ body_html = f"""
+
+
+
+
+
+
+
+
+
+
Hallo {to_name},
+
Sie haben eine neue Nachricht von {sender_name} erhalten:
+
+
+ {message_content.replace(chr(10), '
')}
+
+
+"""
+ if reply_link:
+ body_html += f'
Nachricht beantworten
'
+
+ body_html += """
+
+
+
+
+
+"""
+
+ return self.send_email(
+ to_email=to_email,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html
+ )
+
+ def send_jitsi_invitation(
+ self,
+ to_email: str,
+ to_name: str,
+ organizer_name: str,
+ meeting_title: str,
+ meeting_date: str,
+ meeting_time: str,
+ jitsi_url: str,
+ additional_info: Optional[str] = None
+ ) -> EmailResult:
+ """
+ Sendet eine Jitsi-Meeting-Einladung per Email.
+
+ Args:
+ to_email: Empfaenger-Email
+ to_name: Name des Empfaengers
+ organizer_name: Name des Organisators
+ meeting_title: Titel des Meetings
+ meeting_date: Datum des Meetings (z.B. "20. Dezember 2024")
+ meeting_time: Uhrzeit des Meetings (z.B. "14:00 Uhr")
+ jitsi_url: Der Jitsi-Meeting-Link
+ additional_info: Optional zusaetzliche Informationen
+
+ Returns:
+ EmailResult
+ """
+ subject = f"Einladung: {meeting_title} - {meeting_date}"
+
+ # Plaintext Version
+ body_text = f"""Hallo {to_name},
+
+{organizer_name} laedt Sie zu einem Videogespraech ein.
+
+TERMIN: {meeting_title}
+DATUM: {meeting_date}
+UHRZEIT: {meeting_time}
+
+Treten Sie dem Meeting bei:
+{jitsi_url}
+
+"""
+ if additional_info:
+ body_text += f"HINWEISE:\n{additional_info}\n\n"
+
+ body_text += """TECHNISCHE VORAUSSETZUNGEN:
+- Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
+- Keine Installation erforderlich
+- Optional: Kopfhoerer fuer bessere Audioqualitaet
+
+Bei technischen Problemen wenden Sie sich bitte an den Organisator.
+
+Mit freundlichen Gruessen
+Ihr BreakPilot Team
+"""
+
+ # HTML Version
+ body_html = f"""
+
+
+
+
+
+
+
+
+
+
Hallo {to_name},
+
{organizer_name} laedt Sie zu einem Videogespraech ein.
+
+
+
+ Termin:
+ {meeting_title}
+
+
+ Datum:
+ {meeting_date}
+
+
+ Uhrzeit:
+ {meeting_time}
+
+
+
+
Meeting beitreten
+
+"""
+ if additional_info:
+ body_html += f"""
+
+
Hinweise:
+
{additional_info}
+
+"""
+
+ body_html += """
+
+
Technische Voraussetzungen:
+
+ - Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
+ - Keine Installation erforderlich
+ - Optional: Kopfhoerer fuer bessere Audioqualitaet
+
+
+
+
+ Bei technischen Problemen wenden Sie sich bitte an den Organisator.
+
+
+
+
+
+
+"""
+
+ return self.send_email(
+ to_email=to_email,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html
+ )
+
+
+# Globale Instanz
+email_service = EmailService()
diff --git a/backend-lehrer/services/story_generator.py b/backend-lehrer/services/story_generator.py
new file mode 100644
index 0000000..9b7cf36
--- /dev/null
+++ b/backend-lehrer/services/story_generator.py
@@ -0,0 +1,108 @@
+"""
+Story Generator — Creates short stories using vocabulary words.
+
+Generates age-appropriate mini-stories (3-5 sentences) that incorporate
+the given vocabulary words, marked with tags for highlighting.
+
+Uses Ollama (local LLM) for generation.
+"""
+
+import os
+import json
+import logging
+import requests
+from typing import List, Dict, Any, Optional
+
+logger = logging.getLogger(__name__)
+
+OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
+STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b")
+
+
+def generate_story(
+ vocabulary: List[Dict[str, str]],
+ language: str = "en",
+ grade_level: str = "5-8",
+ max_words: int = 5,
+) -> Dict[str, Any]:
+ """
+ Generate a short story incorporating vocabulary words.
+
+ Args:
+ vocabulary: List of dicts with 'english' and 'german' keys
+ language: 'en' for English story, 'de' for German story
+ grade_level: Target grade level
+ max_words: Maximum vocab words to include (to keep story short)
+
+ Returns:
+ Dict with 'story_html', 'story_text', 'vocab_used', 'language'
+ """
+ # Select subset of vocabulary
+ words = vocabulary[:max_words]
+ word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words]
+ word_list = [w for w in word_list if w.strip()]
+
+ if not word_list:
+ return {"story_html": "", "story_text": "", "vocab_used": [], "language": language}
+
+ lang_name = "English" if language == "en" else "German"
+ words_str = ", ".join(word_list)
+
+ prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student.
+The story MUST use these vocabulary words: {words_str}
+
+Rules:
+1. The story should be fun and age-appropriate
+2. Each vocabulary word must appear at least once
+3. Keep sentences simple and clear
+4. The story should make sense and be engaging
+
+Write ONLY the story, nothing else. No title, no introduction."""
+
+ try:
+ resp = requests.post(
+ f"{OLLAMA_URL}/api/generate",
+ json={
+ "model": STORY_MODEL,
+ "prompt": prompt,
+ "stream": False,
+ "options": {"temperature": 0.8, "num_predict": 300},
+ },
+ timeout=30,
+ )
+ resp.raise_for_status()
+ story_text = resp.json().get("response", "").strip()
+ except Exception as e:
+ logger.error(f"Story generation failed: {e}")
+ # Fallback: simple template story
+ story_text = _fallback_story(word_list, language)
+
+ # Mark vocabulary words in the story
+ story_html = story_text
+ vocab_found = []
+ for word in word_list:
+ if word.lower() in story_html.lower():
+ # Case-insensitive replacement preserving original case
+ import re
+ pattern = re.compile(re.escape(word), re.IGNORECASE)
+ story_html = pattern.sub(
+ lambda m: f'{m.group()}',
+ story_html,
+ count=1,
+ )
+ vocab_found.append(word)
+
+ return {
+ "story_html": story_html,
+ "story_text": story_text,
+ "vocab_used": vocab_found,
+ "vocab_total": len(word_list),
+ "language": language,
+ }
+
+
+def _fallback_story(words: List[str], language: str) -> str:
+ """Simple fallback when LLM is unavailable."""
+ if language == "de":
+ return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen."
+ return f"Today I learned new words: {', '.join(words)}. It was a great day for learning."
diff --git a/backend-lehrer/services/translation.py b/backend-lehrer/services/translation.py
new file mode 100644
index 0000000..a10836c
--- /dev/null
+++ b/backend-lehrer/services/translation.py
@@ -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
diff --git a/backend-lehrer/state_engine_api.py b/backend-lehrer/state_engine_api.py
index 3f43ca8..8ab678b 100644
--- a/backend-lehrer/state_engine_api.py
+++ b/backend-lehrer/state_engine_api.py
@@ -1,410 +1,4 @@
-"""
-State Engine API - REST API für Begleiter-Modus.
-
-Endpoints:
-- GET /api/state/context - TeacherContext abrufen
-- GET /api/state/suggestions - Vorschläge abrufen
-- GET /api/state/dashboard - Dashboard-Daten
-- POST /api/state/milestone - Meilenstein abschließen
-- POST /api/state/transition - Phasen-Übergang
-"""
-
-import logging
-import uuid
-from datetime import datetime, timedelta
-from typing import Dict, Any, List
-
-from fastapi import APIRouter, HTTPException, Query
-
-from state_engine import (
- AnticipationEngine,
- PhaseService,
- SchoolYearPhase,
- ClassSummary,
- Event,
- get_phase_info,
-)
-from state_engine_models import (
- MilestoneRequest,
- TransitionRequest,
- ContextResponse,
- SuggestionsResponse,
- DashboardResponse,
- _teacher_contexts,
- _milestones,
- get_or_create_context,
- update_context_from_services,
- get_phase_display_name,
-)
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(
- prefix="/state",
- tags=["state-engine"],
-)
-
-# Singleton instances
-_engine = AnticipationEngine()
-_phase_service = PhaseService()
-
-
-# ============================================================================
-# API Endpoints
-# ============================================================================
-
-@router.get("/context", response_model=ContextResponse)
-async def get_teacher_context(teacher_id: str = Query("demo-teacher")):
- """Gibt den aggregierten TeacherContext zurück."""
- ctx = get_or_create_context(teacher_id)
- ctx = update_context_from_services(ctx)
-
- phase_info = get_phase_info(ctx.current_phase)
-
- return ContextResponse(
- context=ctx.to_dict(),
- phase_info={
- "phase": phase_info.phase.value,
- "display_name": phase_info.display_name,
- "description": phase_info.description,
- "typical_months": phase_info.typical_months,
- "required_actions": phase_info.required_actions,
- "optional_actions": phase_info.optional_actions,
- }
- )
-
-
-@router.get("/phase")
-async def get_current_phase(teacher_id: str = Query("demo-teacher")):
- """Gibt die aktuelle Phase mit Details zurück."""
- ctx = get_or_create_context(teacher_id)
- phase_info = get_phase_info(ctx.current_phase)
-
- return {
- "current_phase": ctx.current_phase.value,
- "phase_info": {
- "display_name": phase_info.display_name,
- "description": phase_info.description,
- "expected_duration_weeks": phase_info.expected_duration_weeks,
- },
- "days_in_phase": ctx.days_in_phase,
- "progress": _phase_service.get_progress_percentage(ctx),
- }
-
-
-@router.get("/phases")
-async def get_all_phases():
- """Gibt alle Phasen mit Metadaten zurück."""
- return {
- "phases": _phase_service.get_all_phases()
- }
-
-
-@router.get("/suggestions", response_model=SuggestionsResponse)
-async def get_suggestions(teacher_id: str = Query("demo-teacher")):
- """Gibt Vorschläge basierend auf dem aktuellen Kontext zurück."""
- ctx = get_or_create_context(teacher_id)
- ctx = update_context_from_services(ctx)
-
- suggestions = _engine.get_suggestions(ctx)
- priority_counts = _engine.count_by_priority(ctx)
-
- return SuggestionsResponse(
- suggestions=[s.to_dict() for s in suggestions],
- current_phase=ctx.current_phase.value,
- phase_display_name=get_phase_display_name(ctx.current_phase.value),
- priority_counts=priority_counts,
- )
-
-
-@router.get("/suggestions/top")
-async def get_top_suggestion(teacher_id: str = Query("demo-teacher")):
- """Gibt den wichtigsten einzelnen Vorschlag zurück."""
- ctx = get_or_create_context(teacher_id)
- ctx = update_context_from_services(ctx)
-
- suggestion = _engine.get_top_suggestion(ctx)
-
- if not suggestion:
- return {
- "suggestion": None,
- "message": "Alles erledigt! Keine offenen Aufgaben."
- }
-
- return {
- "suggestion": suggestion.to_dict()
- }
-
-
-@router.get("/dashboard", response_model=DashboardResponse)
-async def get_dashboard_data(teacher_id: str = Query("demo-teacher")):
- """Gibt alle Daten für das Begleiter-Dashboard zurück."""
- ctx = get_or_create_context(teacher_id)
- ctx = update_context_from_services(ctx)
-
- suggestions = _engine.get_suggestions(ctx)
- phase_info = get_phase_info(ctx.current_phase)
-
- required = set(phase_info.required_actions)
- completed = set(ctx.completed_milestones)
- completed_in_phase = len(required.intersection(completed))
-
- all_phases = []
- phase_order = [
- SchoolYearPhase.ONBOARDING,
- SchoolYearPhase.SCHOOL_YEAR_START,
- SchoolYearPhase.TEACHING_SETUP,
- SchoolYearPhase.PERFORMANCE_1,
- SchoolYearPhase.SEMESTER_END,
- SchoolYearPhase.TEACHING_2,
- SchoolYearPhase.PERFORMANCE_2,
- SchoolYearPhase.YEAR_END,
- ]
-
- current_idx = phase_order.index(ctx.current_phase) if ctx.current_phase in phase_order else 0
-
- for i, phase in enumerate(phase_order):
- info = get_phase_info(phase)
- all_phases.append({
- "phase": phase.value,
- "display_name": info.display_name,
- "short_name": info.display_name[:10],
- "is_current": phase == ctx.current_phase,
- "is_completed": i < current_idx,
- "is_future": i > current_idx,
- })
-
- return DashboardResponse(
- context={
- "current_phase": ctx.current_phase.value,
- "phase_display_name": phase_info.display_name,
- "phase_description": phase_info.description,
- "weeks_since_start": ctx.weeks_since_start,
- "days_in_phase": ctx.days_in_phase,
- "federal_state": ctx.federal_state,
- "school_type": ctx.school_type,
- },
- suggestions=[s.to_dict() for s in suggestions],
- stats={
- "learning_units_created": ctx.stats.learning_units_created,
- "exams_scheduled": ctx.stats.exams_scheduled,
- "exams_graded": ctx.stats.exams_graded,
- "grades_entered": ctx.stats.grades_entered,
- "classes_count": len(ctx.classes),
- "students_count": ctx.total_students,
- },
- upcoming_events=[
- {
- "type": e.type,
- "title": e.title,
- "date": e.date.isoformat(),
- "in_days": e.in_days,
- "priority": e.priority,
- }
- for e in ctx.upcoming_events[:5]
- ],
- progress={
- "completed": completed_in_phase,
- "total": len(required),
- "percentage": (completed_in_phase / len(required) * 100) if required else 100,
- "milestones_completed": list(completed.intersection(required)),
- "milestones_pending": list(required - completed),
- },
- phases=all_phases,
- )
-
-
-@router.post("/milestone")
-async def complete_milestone(
- request: MilestoneRequest,
- teacher_id: str = Query("demo-teacher")
-):
- """Markiert einen Meilenstein als erledigt."""
- milestone = request.milestone
-
- if teacher_id not in _milestones:
- _milestones[teacher_id] = []
-
- if milestone not in _milestones[teacher_id]:
- _milestones[teacher_id].append(milestone)
- logger.info(f"Milestone '{milestone}' completed for teacher {teacher_id}")
-
- ctx = get_or_create_context(teacher_id)
- ctx.completed_milestones = _milestones[teacher_id]
- _teacher_contexts[teacher_id] = ctx
-
- new_phase = _phase_service.check_and_transition(ctx)
-
- if new_phase:
- ctx.current_phase = new_phase
- ctx.phase_entered_at = datetime.now()
- ctx.days_in_phase = 0
- _teacher_contexts[teacher_id] = ctx
- logger.info(f"Auto-transitioned to {new_phase} for teacher {teacher_id}")
-
- return {
- "success": True,
- "milestone": milestone,
- "new_phase": new_phase.value if new_phase else None,
- "current_phase": ctx.current_phase.value,
- "completed_milestones": ctx.completed_milestones,
- }
-
-
-@router.post("/transition")
-async def transition_phase(
- request: TransitionRequest,
- teacher_id: str = Query("demo-teacher")
-):
- """Führt einen manuellen Phasen-Übergang durch."""
- try:
- target_phase = SchoolYearPhase(request.target_phase)
- except ValueError:
- raise HTTPException(
- status_code=400,
- detail=f"Ungültige Phase: {request.target_phase}"
- )
-
- ctx = get_or_create_context(teacher_id)
-
- if not _phase_service.can_transition_to(ctx, target_phase):
- raise HTTPException(
- status_code=400,
- detail=f"Übergang von {ctx.current_phase.value} zu {target_phase.value} nicht erlaubt"
- )
-
- old_phase = ctx.current_phase
- ctx.current_phase = target_phase
- ctx.phase_entered_at = datetime.now()
- ctx.days_in_phase = 0
- _teacher_contexts[teacher_id] = ctx
-
- logger.info(f"Manual transition from {old_phase} to {target_phase} for teacher {teacher_id}")
-
- return {
- "success": True,
- "old_phase": old_phase.value,
- "new_phase": target_phase.value,
- "phase_info": get_phase_info(target_phase).__dict__,
- }
-
-
-@router.get("/next-phase")
-async def get_next_phase(teacher_id: str = Query("demo-teacher")):
- """Gibt die nächste Phase und Anforderungen zurück."""
- ctx = get_or_create_context(teacher_id)
- next_phase = _phase_service.get_next_phase(ctx.current_phase)
-
- if not next_phase:
- return {
- "next_phase": None,
- "message": "Letzte Phase erreicht"
- }
-
- can_transition = _phase_service.can_transition_to(ctx, next_phase)
- next_info = get_phase_info(next_phase)
- current_info = get_phase_info(ctx.current_phase)
-
- missing = [
- m for m in current_info.required_actions
- if m not in ctx.completed_milestones
- ]
-
- return {
- "current_phase": ctx.current_phase.value,
- "next_phase": next_phase.value,
- "next_phase_info": {
- "display_name": next_info.display_name,
- "description": next_info.description,
- },
- "can_transition": can_transition,
- "missing_requirements": missing,
- }
-
-
-# ============================================================================
-# Demo Data Endpoints (nur für Entwicklung)
-# ============================================================================
-
-@router.post("/demo/add-class")
-async def demo_add_class(
- name: str = Query(...),
- grade_level: int = Query(...),
- student_count: int = Query(25),
- teacher_id: str = Query("demo-teacher")
-):
- """Demo: Fügt eine Klasse zum Kontext hinzu."""
- ctx = get_or_create_context(teacher_id)
-
- ctx.classes.append(ClassSummary(
- class_id=str(uuid.uuid4()),
- name=name,
- grade_level=grade_level,
- student_count=student_count,
- subject="Deutsch"
- ))
- ctx.total_students += student_count
- _teacher_contexts[teacher_id] = ctx
-
- return {"success": True, "classes": len(ctx.classes)}
-
-
-@router.post("/demo/add-event")
-async def demo_add_event(
- event_type: str = Query(...),
- title: str = Query(...),
- in_days: int = Query(...),
- teacher_id: str = Query("demo-teacher")
-):
- """Demo: Fügt ein Event zum Kontext hinzu."""
- ctx = get_or_create_context(teacher_id)
-
- ctx.upcoming_events.append(Event(
- type=event_type,
- title=title,
- date=datetime.now() + timedelta(days=in_days),
- in_days=in_days,
- priority="high" if in_days <= 3 else "medium"
- ))
- _teacher_contexts[teacher_id] = ctx
-
- return {"success": True, "events": len(ctx.upcoming_events)}
-
-
-@router.post("/demo/update-stats")
-async def demo_update_stats(
- learning_units: int = Query(0),
- exams_scheduled: int = Query(0),
- exams_graded: int = Query(0),
- grades_entered: int = Query(0),
- unanswered_messages: int = Query(0),
- teacher_id: str = Query("demo-teacher")
-):
- """Demo: Aktualisiert Statistiken."""
- ctx = get_or_create_context(teacher_id)
-
- if learning_units:
- ctx.stats.learning_units_created = learning_units
- if exams_scheduled:
- ctx.stats.exams_scheduled = exams_scheduled
- if exams_graded:
- ctx.stats.exams_graded = exams_graded
- if grades_entered:
- ctx.stats.grades_entered = grades_entered
- if unanswered_messages:
- ctx.stats.unanswered_messages = unanswered_messages
-
- _teacher_contexts[teacher_id] = ctx
-
- return {"success": True, "stats": ctx.stats.__dict__}
-
-
-@router.post("/demo/reset")
-async def demo_reset(teacher_id: str = Query("demo-teacher")):
- """Demo: Setzt den Kontext zurück."""
- if teacher_id in _teacher_contexts:
- del _teacher_contexts[teacher_id]
- if teacher_id in _milestones:
- del _milestones[teacher_id]
-
- return {"success": True, "message": "Kontext zurückgesetzt"}
+# Backward-compat shim -- module moved to classroom/state_engine_api.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("classroom.state_engine_api")
diff --git a/backend-lehrer/state_engine_models.py b/backend-lehrer/state_engine_models.py
index 778b2f7..6d395a2 100644
--- a/backend-lehrer/state_engine_models.py
+++ b/backend-lehrer/state_engine_models.py
@@ -1,143 +1,4 @@
-"""
-State Engine API - Pydantic Models und Helper Functions.
-"""
-
-import uuid
-from datetime import datetime, timedelta
-from typing import Dict, Any, List, Optional
-
-from pydantic import BaseModel, Field
-
-from state_engine import (
- SchoolYearPhase,
- ClassSummary,
- Event,
- TeacherContext,
- TeacherStats,
- get_phase_info,
-)
-
-
-# ============================================================================
-# In-Memory Storage (später durch DB ersetzen)
-# ============================================================================
-
-_teacher_contexts: Dict[str, TeacherContext] = {}
-_milestones: Dict[str, List[str]] = {} # teacher_id -> milestones
-
-
-# ============================================================================
-# Pydantic Models
-# ============================================================================
-
-class MilestoneRequest(BaseModel):
- """Request zum Abschließen eines Meilensteins."""
- milestone: str = Field(..., description="Name des Meilensteins")
-
-
-class TransitionRequest(BaseModel):
- """Request für Phasen-Übergang."""
- target_phase: str = Field(..., description="Zielphase")
-
-
-class ContextResponse(BaseModel):
- """Response mit TeacherContext."""
- context: Dict[str, Any]
- phase_info: Dict[str, Any]
-
-
-class SuggestionsResponse(BaseModel):
- """Response mit Vorschlägen."""
- suggestions: List[Dict[str, Any]]
- current_phase: str
- phase_display_name: str
- priority_counts: Dict[str, int]
-
-
-class DashboardResponse(BaseModel):
- """Response mit Dashboard-Daten."""
- context: Dict[str, Any]
- suggestions: List[Dict[str, Any]]
- stats: Dict[str, Any]
- upcoming_events: List[Dict[str, Any]]
- progress: Dict[str, Any]
- phases: List[Dict[str, Any]]
-
-
-# ============================================================================
-# Helper Functions
-# ============================================================================
-
-def get_or_create_context(teacher_id: str) -> TeacherContext:
- """
- Holt oder erstellt TeacherContext.
-
- In Produktion würde dies aus der Datenbank geladen.
- """
- if teacher_id not in _teacher_contexts:
- now = datetime.now()
- school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1)
- weeks_since_start = (now - school_year_start).days // 7
-
- month = now.month
- if month in [8, 9]:
- phase = SchoolYearPhase.SCHOOL_YEAR_START
- elif month in [10, 11]:
- phase = SchoolYearPhase.TEACHING_SETUP
- elif month == 12:
- phase = SchoolYearPhase.PERFORMANCE_1
- elif month in [1, 2]:
- phase = SchoolYearPhase.SEMESTER_END
- elif month in [3, 4]:
- phase = SchoolYearPhase.TEACHING_2
- elif month in [5, 6]:
- phase = SchoolYearPhase.PERFORMANCE_2
- else:
- phase = SchoolYearPhase.YEAR_END
-
- _teacher_contexts[teacher_id] = TeacherContext(
- teacher_id=teacher_id,
- school_id=str(uuid.uuid4()),
- school_year_id=str(uuid.uuid4()),
- federal_state="niedersachsen",
- school_type="gymnasium",
- school_year_start=school_year_start,
- current_phase=phase,
- phase_entered_at=now - timedelta(days=7),
- weeks_since_start=weeks_since_start,
- days_in_phase=7,
- classes=[],
- total_students=0,
- upcoming_events=[],
- completed_milestones=_milestones.get(teacher_id, []),
- pending_milestones=[],
- stats=TeacherStats(),
- )
-
- return _teacher_contexts[teacher_id]
-
-
-def update_context_from_services(ctx: TeacherContext) -> TeacherContext:
- """
- Aktualisiert Kontext mit Daten aus anderen Services.
-
- In Produktion würde dies von school-service, gradebook etc. laden.
- """
- ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days
- ctx.completed_milestones = _milestones.get(ctx.teacher_id, [])
-
- phase_info = get_phase_info(ctx.current_phase)
- ctx.pending_milestones = [
- m for m in phase_info.required_actions
- if m not in ctx.completed_milestones
- ]
-
- return ctx
-
-
-def get_phase_display_name(phase: str) -> str:
- """Gibt Display-Name für Phase zurück."""
- try:
- return get_phase_info(SchoolYearPhase(phase)).display_name
- except (ValueError, KeyError):
- return phase
+# Backward-compat shim -- module moved to classroom/state_engine_models.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("classroom.state_engine_models")
diff --git a/backend-lehrer/story_generator.py b/backend-lehrer/story_generator.py
index 9b7cf36..f0e5d10 100644
--- a/backend-lehrer/story_generator.py
+++ b/backend-lehrer/story_generator.py
@@ -1,108 +1,4 @@
-"""
-Story Generator — Creates short stories using vocabulary words.
-
-Generates age-appropriate mini-stories (3-5 sentences) that incorporate
-the given vocabulary words, marked with tags for highlighting.
-
-Uses Ollama (local LLM) for generation.
-"""
-
-import os
-import json
-import logging
-import requests
-from typing import List, Dict, Any, Optional
-
-logger = logging.getLogger(__name__)
-
-OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
-STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b")
-
-
-def generate_story(
- vocabulary: List[Dict[str, str]],
- language: str = "en",
- grade_level: str = "5-8",
- max_words: int = 5,
-) -> Dict[str, Any]:
- """
- Generate a short story incorporating vocabulary words.
-
- Args:
- vocabulary: List of dicts with 'english' and 'german' keys
- language: 'en' for English story, 'de' for German story
- grade_level: Target grade level
- max_words: Maximum vocab words to include (to keep story short)
-
- Returns:
- Dict with 'story_html', 'story_text', 'vocab_used', 'language'
- """
- # Select subset of vocabulary
- words = vocabulary[:max_words]
- word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words]
- word_list = [w for w in word_list if w.strip()]
-
- if not word_list:
- return {"story_html": "", "story_text": "", "vocab_used": [], "language": language}
-
- lang_name = "English" if language == "en" else "German"
- words_str = ", ".join(word_list)
-
- prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student.
-The story MUST use these vocabulary words: {words_str}
-
-Rules:
-1. The story should be fun and age-appropriate
-2. Each vocabulary word must appear at least once
-3. Keep sentences simple and clear
-4. The story should make sense and be engaging
-
-Write ONLY the story, nothing else. No title, no introduction."""
-
- try:
- resp = requests.post(
- f"{OLLAMA_URL}/api/generate",
- json={
- "model": STORY_MODEL,
- "prompt": prompt,
- "stream": False,
- "options": {"temperature": 0.8, "num_predict": 300},
- },
- timeout=30,
- )
- resp.raise_for_status()
- story_text = resp.json().get("response", "").strip()
- except Exception as e:
- logger.error(f"Story generation failed: {e}")
- # Fallback: simple template story
- story_text = _fallback_story(word_list, language)
-
- # Mark vocabulary words in the story
- story_html = story_text
- vocab_found = []
- for word in word_list:
- if word.lower() in story_html.lower():
- # Case-insensitive replacement preserving original case
- import re
- pattern = re.compile(re.escape(word), re.IGNORECASE)
- story_html = pattern.sub(
- lambda m: f'{m.group()}',
- story_html,
- count=1,
- )
- vocab_found.append(word)
-
- return {
- "story_html": story_html,
- "story_text": story_text,
- "vocab_used": vocab_found,
- "vocab_total": len(word_list),
- "language": language,
- }
-
-
-def _fallback_story(words: List[str], language: str) -> str:
- """Simple fallback when LLM is unavailable."""
- if language == "de":
- return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen."
- return f"Today I learned new words: {', '.join(words)}. It was a great day for learning."
+# Backward-compat shim -- module moved to services/story_generator.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("services.story_generator")
diff --git a/backend-lehrer/translation_service.py b/backend-lehrer/translation_service.py
index a10836c..a34073c 100644
--- a/backend-lehrer/translation_service.py
+++ b/backend-lehrer/translation_service.py
@@ -1,179 +1,4 @@
-"""
-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
+# Backward-compat shim -- module moved to services/translation.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("services.translation")
diff --git a/backend-lehrer/user_language_api.py b/backend-lehrer/user_language_api.py
index b498aa6..91cdcce 100644
--- a/backend-lehrer/user_language_api.py
+++ b/backend-lehrer/user_language_api.py
@@ -1,86 +1,4 @@
-"""
-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}
+# Backward-compat shim -- module moved to api/user_language.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("api.user_language")
diff --git a/backend-lehrer/vocabulary/__init__.py b/backend-lehrer/vocabulary/__init__.py
new file mode 100644
index 0000000..aaac9e6
--- /dev/null
+++ b/backend-lehrer/vocabulary/__init__.py
@@ -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",
+]
diff --git a/backend-lehrer/vocabulary/api.py b/backend-lehrer/vocabulary/api.py
new file mode 100644
index 0000000..703f054
--- /dev/null
+++ b/backend-lehrer/vocabulary/api.py
@@ -0,0 +1,352 @@
+"""
+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,
+)
+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 .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
+# ---------------------------------------------------------------------------
+
+
+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 translation_service 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}
diff --git a/backend-lehrer/vocabulary/db.py b/backend-lehrer/vocabulary/db.py
new file mode 100644
index 0000000..82b15cc
--- /dev/null
+++ b/backend-lehrer/vocabulary/db.py
@@ -0,0 +1,296 @@
+"""
+Vocabulary Database — PostgreSQL storage for the vocabulary word catalog.
+
+Stores 160k+ words with translations, IPA, syllables, examples, images, audio.
+Uses asyncpg for async PostgreSQL access (same pattern as game/database.py).
+
+Schema: lehrer.vocabulary_words (search_path set in main.py)
+"""
+
+import logging
+import os
+import uuid
+from dataclasses import dataclass, field, asdict
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+_RAW_DB_URL = os.getenv(
+ "DATABASE_URL",
+ "postgresql://breakpilot:breakpilot@postgres:5432/breakpilot",
+)
+# Strip SQLAlchemy dialect prefix (asyncpg needs plain postgresql://)
+DATABASE_URL = _RAW_DB_URL.replace("postgresql+asyncpg://", "postgresql://")
+# Strip search_path options (set via SET after connect)
+if "options=" in DATABASE_URL:
+ DATABASE_URL = DATABASE_URL.split("?")[0] if "options=" in DATABASE_URL.split("?")[-1] else DATABASE_URL
+
+_pool = None
+
+
+async def get_pool():
+ """Get or create the asyncpg connection pool."""
+ global _pool
+ if _pool is None:
+ import asyncpg
+ _pool = await asyncpg.create_pool(
+ DATABASE_URL, min_size=2, max_size=10,
+ server_settings={"search_path": "lehrer,core,public"},
+ )
+ return _pool
+
+
+async def init_vocabulary_tables():
+ """Create vocabulary tables if they don't exist."""
+ pool = await get_pool()
+ async with pool.acquire() as conn:
+ await conn.execute("""
+ CREATE TABLE IF NOT EXISTS vocabulary_words (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ english TEXT NOT NULL,
+ german TEXT NOT NULL DEFAULT '',
+ ipa_en TEXT NOT NULL DEFAULT '',
+ ipa_de TEXT NOT NULL DEFAULT '',
+ part_of_speech TEXT NOT NULL DEFAULT '',
+ syllables_en TEXT[] NOT NULL DEFAULT '{}',
+ syllables_de TEXT[] NOT NULL DEFAULT '{}',
+ example_en TEXT NOT NULL DEFAULT '',
+ example_de TEXT NOT NULL DEFAULT '',
+ image_url TEXT NOT NULL DEFAULT '',
+ audio_url_en TEXT NOT NULL DEFAULT '',
+ audio_url_de TEXT NOT NULL DEFAULT '',
+ difficulty INT NOT NULL DEFAULT 1,
+ tags TEXT[] NOT NULL DEFAULT '{}',
+ translations JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_vocab_english
+ ON vocabulary_words (lower(english));
+ CREATE INDEX IF NOT EXISTS idx_vocab_german
+ ON vocabulary_words (lower(german));
+ CREATE INDEX IF NOT EXISTS idx_vocab_pos
+ ON vocabulary_words (part_of_speech);
+ CREATE INDEX IF NOT EXISTS idx_vocab_difficulty
+ ON vocabulary_words (difficulty);
+ CREATE INDEX IF NOT EXISTS idx_vocab_tags
+ ON vocabulary_words USING GIN (tags);
+ """)
+ # Enable trigram extension for fuzzy search (optional)
+ try:
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
+ await conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_vocab_english_trgm
+ ON vocabulary_words USING GIN (english gin_trgm_ops);
+ """)
+ except Exception:
+ logger.info("pg_trgm not available — trigram search disabled, using LIKE fallback")
+
+ logger.info("vocabulary_words table initialized")
+
+
+@dataclass
+class VocabularyWord:
+ """A single vocabulary word with all metadata."""
+ id: str = ""
+ english: str = ""
+ german: str = ""
+ ipa_en: str = ""
+ ipa_de: str = ""
+ part_of_speech: str = ""
+ syllables_en: List[str] = field(default_factory=list)
+ syllables_de: List[str] = field(default_factory=list)
+ example_en: str = ""
+ example_de: str = ""
+ image_url: str = ""
+ audio_url_en: str = ""
+ audio_url_de: str = ""
+ difficulty: int = 1
+ tags: List[str] = field(default_factory=list)
+ translations: Dict[str, str] = field(default_factory=dict)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return asdict(self)
+
+
+def _row_to_word(row) -> VocabularyWord:
+ """Convert an asyncpg Record to VocabularyWord."""
+ import json
+ translations = row["translations"]
+ if isinstance(translations, str):
+ translations = json.loads(translations)
+ return VocabularyWord(
+ id=str(row["id"]),
+ english=row["english"],
+ german=row["german"],
+ ipa_en=row["ipa_en"],
+ ipa_de=row["ipa_de"],
+ part_of_speech=row["part_of_speech"],
+ syllables_en=list(row["syllables_en"] or []),
+ syllables_de=list(row["syllables_de"] or []),
+ example_en=row["example_en"],
+ example_de=row["example_de"],
+ image_url=row["image_url"],
+ audio_url_en=row["audio_url_en"],
+ audio_url_de=row["audio_url_de"],
+ difficulty=row["difficulty"],
+ tags=list(row["tags"] or []),
+ translations=translations or {},
+ )
+
+
+async def search_words(
+ query: str, lang: str = "en", limit: int = 20, offset: int = 0,
+) -> List[VocabularyWord]:
+ """Full-text search for words. Uses trigram similarity if available, else ILIKE."""
+ pool = await get_pool()
+ col = "english" if lang == "en" else "german"
+ async with pool.acquire() as conn:
+ # Try trigram search first, fall back to ILIKE
+ try:
+ rows = await conn.fetch(
+ f"""
+ SELECT * FROM vocabulary_words
+ WHERE lower({col}) LIKE $1 OR {col} % $2
+ ORDER BY similarity({col}, $2) DESC, lower({col})
+ LIMIT $3 OFFSET $4
+ """,
+ f"%{query.lower()}%", query, limit, offset,
+ )
+ except Exception:
+ rows = await conn.fetch(
+ f"""
+ SELECT * FROM vocabulary_words
+ WHERE lower({col}) LIKE $1
+ ORDER BY lower({col})
+ LIMIT $2 OFFSET $3
+ """,
+ f"%{query.lower()}%", limit, offset,
+ )
+ return [_row_to_word(r) for r in rows]
+
+
+async def get_word(word_id: str) -> Optional[VocabularyWord]:
+ """Get a single word by ID."""
+ pool = await get_pool()
+ async with pool.acquire() as conn:
+ row = await conn.fetchrow(
+ "SELECT * FROM vocabulary_words WHERE id = $1", uuid.UUID(word_id),
+ )
+ return _row_to_word(row) if row else None
+
+
+async def browse_words(
+ pos: str = "", difficulty: int = 0, tag: str = "",
+ limit: int = 50, offset: int = 0,
+) -> List[VocabularyWord]:
+ """Browse words with filters."""
+ pool = await get_pool()
+ conditions = []
+ params: List[Any] = []
+ idx = 1
+
+ if pos:
+ conditions.append(f"part_of_speech = ${idx}")
+ params.append(pos)
+ idx += 1
+ if difficulty > 0:
+ conditions.append(f"difficulty = ${idx}")
+ params.append(difficulty)
+ idx += 1
+ if tag:
+ conditions.append(f"${idx} = ANY(tags)")
+ params.append(tag)
+ idx += 1
+
+ where = "WHERE " + " AND ".join(conditions) if conditions else ""
+ params.extend([limit, offset])
+
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ f"SELECT * FROM vocabulary_words {where} ORDER BY english LIMIT ${idx} OFFSET ${idx+1}",
+ *params,
+ )
+ return [_row_to_word(r) for r in rows]
+
+
+async def insert_word(word: VocabularyWord) -> str:
+ """Insert a new word, returns the ID."""
+ pool = await get_pool()
+ import json
+ word_id = word.id or str(uuid.uuid4())
+ async with pool.acquire() as conn:
+ await conn.execute(
+ """
+ INSERT INTO vocabulary_words
+ (id, english, german, ipa_en, ipa_de, part_of_speech,
+ syllables_en, syllables_de, example_en, example_de,
+ image_url, audio_url_en, audio_url_de, difficulty, tags, translations)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
+ ON CONFLICT (id) DO NOTHING
+ """,
+ uuid.UUID(word_id), word.english, word.german,
+ word.ipa_en, word.ipa_de, word.part_of_speech,
+ word.syllables_en, word.syllables_de,
+ word.example_en, word.example_de,
+ word.image_url, word.audio_url_en, word.audio_url_de,
+ word.difficulty, word.tags, json.dumps(word.translations),
+ )
+ return word_id
+
+
+async def insert_words_bulk(words: List[VocabularyWord]) -> int:
+ """Bulk insert words. Returns count of inserted rows."""
+ pool = await get_pool()
+ import json
+ records = []
+ for w in words:
+ wid = w.id or str(uuid.uuid4())
+ records.append((
+ uuid.UUID(wid), w.english, w.german,
+ w.ipa_en, w.ipa_de, w.part_of_speech,
+ w.syllables_en, w.syllables_de,
+ w.example_en, w.example_de,
+ w.image_url, w.audio_url_en, w.audio_url_de,
+ w.difficulty, w.tags, json.dumps(w.translations),
+ ))
+ async with pool.acquire() as conn:
+ await conn.executemany(
+ """
+ INSERT INTO vocabulary_words
+ (id, english, german, ipa_en, ipa_de, part_of_speech,
+ syllables_en, syllables_de, example_en, example_de,
+ image_url, audio_url_en, audio_url_de, difficulty, tags, translations)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
+ ON CONFLICT (id) DO NOTHING
+ """,
+ records,
+ )
+ return len(records)
+
+
+async def count_words() -> int:
+ """Count total words in the database."""
+ pool = await get_pool()
+ async with pool.acquire() as conn:
+ return await conn.fetchval("SELECT COUNT(*) FROM vocabulary_words")
+
+
+async def get_all_tags() -> List[str]:
+ """Get all unique tags."""
+ pool = await get_pool()
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ "SELECT DISTINCT unnest(tags) AS tag FROM vocabulary_words ORDER BY tag"
+ )
+ return [r["tag"] for r in rows]
+
+
+async def get_all_pos() -> List[str]:
+ """Get all unique parts of speech."""
+ pool = await get_pool()
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ "SELECT DISTINCT part_of_speech FROM vocabulary_words WHERE part_of_speech != '' ORDER BY part_of_speech"
+ )
+ return [r["part_of_speech"] for r in rows]
diff --git a/backend-lehrer/vocabulary_api.py b/backend-lehrer/vocabulary_api.py
index f83da53..7965f61 100644
--- a/backend-lehrer/vocabulary_api.py
+++ b/backend-lehrer/vocabulary_api.py
@@ -1,352 +1,4 @@
-"""
-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}
-
-
-# ---------------------------------------------------------------------------
-# Translation Generation
-# ---------------------------------------------------------------------------
-
-
-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 translation_service 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}
+# Backward-compat shim -- module moved to vocabulary/api.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("vocabulary.api")
diff --git a/backend-lehrer/vocabulary_db.py b/backend-lehrer/vocabulary_db.py
index 82b15cc..7e6172f 100644
--- a/backend-lehrer/vocabulary_db.py
+++ b/backend-lehrer/vocabulary_db.py
@@ -1,296 +1,4 @@
-"""
-Vocabulary Database — PostgreSQL storage for the vocabulary word catalog.
-
-Stores 160k+ words with translations, IPA, syllables, examples, images, audio.
-Uses asyncpg for async PostgreSQL access (same pattern as game/database.py).
-
-Schema: lehrer.vocabulary_words (search_path set in main.py)
-"""
-
-import logging
-import os
-import uuid
-from dataclasses import dataclass, field, asdict
-from typing import Any, Dict, List, Optional
-
-logger = logging.getLogger(__name__)
-
-_RAW_DB_URL = os.getenv(
- "DATABASE_URL",
- "postgresql://breakpilot:breakpilot@postgres:5432/breakpilot",
-)
-# Strip SQLAlchemy dialect prefix (asyncpg needs plain postgresql://)
-DATABASE_URL = _RAW_DB_URL.replace("postgresql+asyncpg://", "postgresql://")
-# Strip search_path options (set via SET after connect)
-if "options=" in DATABASE_URL:
- DATABASE_URL = DATABASE_URL.split("?")[0] if "options=" in DATABASE_URL.split("?")[-1] else DATABASE_URL
-
-_pool = None
-
-
-async def get_pool():
- """Get or create the asyncpg connection pool."""
- global _pool
- if _pool is None:
- import asyncpg
- _pool = await asyncpg.create_pool(
- DATABASE_URL, min_size=2, max_size=10,
- server_settings={"search_path": "lehrer,core,public"},
- )
- return _pool
-
-
-async def init_vocabulary_tables():
- """Create vocabulary tables if they don't exist."""
- pool = await get_pool()
- async with pool.acquire() as conn:
- await conn.execute("""
- CREATE TABLE IF NOT EXISTS vocabulary_words (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- english TEXT NOT NULL,
- german TEXT NOT NULL DEFAULT '',
- ipa_en TEXT NOT NULL DEFAULT '',
- ipa_de TEXT NOT NULL DEFAULT '',
- part_of_speech TEXT NOT NULL DEFAULT '',
- syllables_en TEXT[] NOT NULL DEFAULT '{}',
- syllables_de TEXT[] NOT NULL DEFAULT '{}',
- example_en TEXT NOT NULL DEFAULT '',
- example_de TEXT NOT NULL DEFAULT '',
- image_url TEXT NOT NULL DEFAULT '',
- audio_url_en TEXT NOT NULL DEFAULT '',
- audio_url_de TEXT NOT NULL DEFAULT '',
- difficulty INT NOT NULL DEFAULT 1,
- tags TEXT[] NOT NULL DEFAULT '{}',
- translations JSONB NOT NULL DEFAULT '{}',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
- );
-
- CREATE INDEX IF NOT EXISTS idx_vocab_english
- ON vocabulary_words (lower(english));
- CREATE INDEX IF NOT EXISTS idx_vocab_german
- ON vocabulary_words (lower(german));
- CREATE INDEX IF NOT EXISTS idx_vocab_pos
- ON vocabulary_words (part_of_speech);
- CREATE INDEX IF NOT EXISTS idx_vocab_difficulty
- ON vocabulary_words (difficulty);
- CREATE INDEX IF NOT EXISTS idx_vocab_tags
- ON vocabulary_words USING GIN (tags);
- """)
- # Enable trigram extension for fuzzy search (optional)
- try:
- await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
- await conn.execute("""
- CREATE INDEX IF NOT EXISTS idx_vocab_english_trgm
- ON vocabulary_words USING GIN (english gin_trgm_ops);
- """)
- except Exception:
- logger.info("pg_trgm not available — trigram search disabled, using LIKE fallback")
-
- logger.info("vocabulary_words table initialized")
-
-
-@dataclass
-class VocabularyWord:
- """A single vocabulary word with all metadata."""
- id: str = ""
- english: str = ""
- german: str = ""
- ipa_en: str = ""
- ipa_de: str = ""
- part_of_speech: str = ""
- syllables_en: List[str] = field(default_factory=list)
- syllables_de: List[str] = field(default_factory=list)
- example_en: str = ""
- example_de: str = ""
- image_url: str = ""
- audio_url_en: str = ""
- audio_url_de: str = ""
- difficulty: int = 1
- tags: List[str] = field(default_factory=list)
- translations: Dict[str, str] = field(default_factory=dict)
-
- def to_dict(self) -> Dict[str, Any]:
- return asdict(self)
-
-
-def _row_to_word(row) -> VocabularyWord:
- """Convert an asyncpg Record to VocabularyWord."""
- import json
- translations = row["translations"]
- if isinstance(translations, str):
- translations = json.loads(translations)
- return VocabularyWord(
- id=str(row["id"]),
- english=row["english"],
- german=row["german"],
- ipa_en=row["ipa_en"],
- ipa_de=row["ipa_de"],
- part_of_speech=row["part_of_speech"],
- syllables_en=list(row["syllables_en"] or []),
- syllables_de=list(row["syllables_de"] or []),
- example_en=row["example_en"],
- example_de=row["example_de"],
- image_url=row["image_url"],
- audio_url_en=row["audio_url_en"],
- audio_url_de=row["audio_url_de"],
- difficulty=row["difficulty"],
- tags=list(row["tags"] or []),
- translations=translations or {},
- )
-
-
-async def search_words(
- query: str, lang: str = "en", limit: int = 20, offset: int = 0,
-) -> List[VocabularyWord]:
- """Full-text search for words. Uses trigram similarity if available, else ILIKE."""
- pool = await get_pool()
- col = "english" if lang == "en" else "german"
- async with pool.acquire() as conn:
- # Try trigram search first, fall back to ILIKE
- try:
- rows = await conn.fetch(
- f"""
- SELECT * FROM vocabulary_words
- WHERE lower({col}) LIKE $1 OR {col} % $2
- ORDER BY similarity({col}, $2) DESC, lower({col})
- LIMIT $3 OFFSET $4
- """,
- f"%{query.lower()}%", query, limit, offset,
- )
- except Exception:
- rows = await conn.fetch(
- f"""
- SELECT * FROM vocabulary_words
- WHERE lower({col}) LIKE $1
- ORDER BY lower({col})
- LIMIT $2 OFFSET $3
- """,
- f"%{query.lower()}%", limit, offset,
- )
- return [_row_to_word(r) for r in rows]
-
-
-async def get_word(word_id: str) -> Optional[VocabularyWord]:
- """Get a single word by ID."""
- pool = await get_pool()
- async with pool.acquire() as conn:
- row = await conn.fetchrow(
- "SELECT * FROM vocabulary_words WHERE id = $1", uuid.UUID(word_id),
- )
- return _row_to_word(row) if row else None
-
-
-async def browse_words(
- pos: str = "", difficulty: int = 0, tag: str = "",
- limit: int = 50, offset: int = 0,
-) -> List[VocabularyWord]:
- """Browse words with filters."""
- pool = await get_pool()
- conditions = []
- params: List[Any] = []
- idx = 1
-
- if pos:
- conditions.append(f"part_of_speech = ${idx}")
- params.append(pos)
- idx += 1
- if difficulty > 0:
- conditions.append(f"difficulty = ${idx}")
- params.append(difficulty)
- idx += 1
- if tag:
- conditions.append(f"${idx} = ANY(tags)")
- params.append(tag)
- idx += 1
-
- where = "WHERE " + " AND ".join(conditions) if conditions else ""
- params.extend([limit, offset])
-
- async with pool.acquire() as conn:
- rows = await conn.fetch(
- f"SELECT * FROM vocabulary_words {where} ORDER BY english LIMIT ${idx} OFFSET ${idx+1}",
- *params,
- )
- return [_row_to_word(r) for r in rows]
-
-
-async def insert_word(word: VocabularyWord) -> str:
- """Insert a new word, returns the ID."""
- pool = await get_pool()
- import json
- word_id = word.id or str(uuid.uuid4())
- async with pool.acquire() as conn:
- await conn.execute(
- """
- INSERT INTO vocabulary_words
- (id, english, german, ipa_en, ipa_de, part_of_speech,
- syllables_en, syllables_de, example_en, example_de,
- image_url, audio_url_en, audio_url_de, difficulty, tags, translations)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
- ON CONFLICT (id) DO NOTHING
- """,
- uuid.UUID(word_id), word.english, word.german,
- word.ipa_en, word.ipa_de, word.part_of_speech,
- word.syllables_en, word.syllables_de,
- word.example_en, word.example_de,
- word.image_url, word.audio_url_en, word.audio_url_de,
- word.difficulty, word.tags, json.dumps(word.translations),
- )
- return word_id
-
-
-async def insert_words_bulk(words: List[VocabularyWord]) -> int:
- """Bulk insert words. Returns count of inserted rows."""
- pool = await get_pool()
- import json
- records = []
- for w in words:
- wid = w.id or str(uuid.uuid4())
- records.append((
- uuid.UUID(wid), w.english, w.german,
- w.ipa_en, w.ipa_de, w.part_of_speech,
- w.syllables_en, w.syllables_de,
- w.example_en, w.example_de,
- w.image_url, w.audio_url_en, w.audio_url_de,
- w.difficulty, w.tags, json.dumps(w.translations),
- ))
- async with pool.acquire() as conn:
- await conn.executemany(
- """
- INSERT INTO vocabulary_words
- (id, english, german, ipa_en, ipa_de, part_of_speech,
- syllables_en, syllables_de, example_en, example_de,
- image_url, audio_url_en, audio_url_de, difficulty, tags, translations)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
- ON CONFLICT (id) DO NOTHING
- """,
- records,
- )
- return len(records)
-
-
-async def count_words() -> int:
- """Count total words in the database."""
- pool = await get_pool()
- async with pool.acquire() as conn:
- return await conn.fetchval("SELECT COUNT(*) FROM vocabulary_words")
-
-
-async def get_all_tags() -> List[str]:
- """Get all unique tags."""
- pool = await get_pool()
- async with pool.acquire() as conn:
- rows = await conn.fetch(
- "SELECT DISTINCT unnest(tags) AS tag FROM vocabulary_words ORDER BY tag"
- )
- return [r["tag"] for r in rows]
-
-
-async def get_all_pos() -> List[str]:
- """Get all unique parts of speech."""
- pool = await get_pool()
- async with pool.acquire() as conn:
- rows = await conn.fetch(
- "SELECT DISTINCT part_of_speech FROM vocabulary_words WHERE part_of_speech != '' ORDER BY part_of_speech"
- )
- return [r["part_of_speech"] for r in rows]
+# Backward-compat shim -- module moved to vocabulary/db.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("vocabulary.db")
diff --git a/backend-lehrer/worksheets/__init__.py b/backend-lehrer/worksheets/__init__.py
new file mode 100644
index 0000000..56a4370
--- /dev/null
+++ b/backend-lehrer/worksheets/__init__.py
@@ -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",
+]
diff --git a/backend-lehrer/worksheets/api.py b/backend-lehrer/worksheets/api.py
new file mode 100644
index 0000000..4229865
--- /dev/null
+++ b/backend-lehrer/worksheets/api.py
@@ -0,0 +1,439 @@
+"""
+Worksheets API - REST API für Arbeitsblatt-Generierung.
+
+Integriert alle Content-Generatoren:
+- Multiple Choice Questions
+- Lückentexte (Cloze)
+- Mindmaps
+- Quizze (True/False, Matching, Sorting, Open)
+
+Unterstützt:
+- H5P-Export für interaktive Inhalte
+- PDF-Export für Druckversionen
+- JSON-Export für Frontend-Integration
+"""
+
+import logging
+import uuid
+from datetime import datetime
+from typing import Dict
+
+from fastapi import APIRouter, HTTPException
+
+from generators import (
+ MultipleChoiceGenerator,
+ ClozeGenerator,
+ MindmapGenerator,
+ QuizGenerator
+)
+
+from .models import (
+ ContentType,
+ GenerateRequest,
+ MCGenerateRequest,
+ ClozeGenerateRequest,
+ MindmapGenerateRequest,
+ QuizGenerateRequest,
+ BatchGenerateRequest,
+ WorksheetContent,
+ GenerateResponse,
+ BatchGenerateResponse,
+ parse_difficulty,
+ parse_cloze_type,
+ parse_quiz_types,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(
+ prefix="/worksheets",
+ tags=["worksheets"],
+)
+
+
+# ============================================================================
+# In-Memory Storage (später durch DB ersetzen)
+# ============================================================================
+
+_generated_content: Dict[str, WorksheetContent] = {}
+
+
+# ============================================================================
+# Generator Instances
+# ============================================================================
+
+mc_generator = MultipleChoiceGenerator()
+cloze_generator = ClozeGenerator()
+mindmap_generator = MindmapGenerator()
+quiz_generator = QuizGenerator()
+
+
+def _store_content(content: WorksheetContent) -> None:
+ """Speichert generierten Content."""
+ _generated_content[content.id] = content
+
+
+# ============================================================================
+# API Endpoints
+# ============================================================================
+
+@router.post("/generate/multiple-choice", response_model=GenerateResponse)
+async def generate_multiple_choice(request: MCGenerateRequest):
+ """Generiert Multiple-Choice-Fragen aus Quelltext."""
+ try:
+ difficulty = parse_difficulty(request.difficulty)
+
+ questions = mc_generator.generate(
+ source_text=request.source_text,
+ num_questions=request.num_questions,
+ difficulty=difficulty,
+ subject=request.subject,
+ grade_level=request.grade_level
+ )
+
+ if not questions:
+ return GenerateResponse(
+ success=False,
+ error="Keine Fragen generiert. Text möglicherweise zu kurz."
+ )
+
+ questions_dict = mc_generator.to_dict(questions)
+ h5p_format = mc_generator.to_h5p_format(questions)
+
+ content = WorksheetContent(
+ id=str(uuid.uuid4()),
+ content_type=ContentType.MULTIPLE_CHOICE.value,
+ data={"questions": questions_dict},
+ h5p_format=h5p_format,
+ created_at=datetime.utcnow(),
+ topic=request.topic,
+ difficulty=request.difficulty
+ )
+
+ _store_content(content)
+ return GenerateResponse(success=True, content=content)
+
+ except Exception as e:
+ logger.error(f"Error generating MC questions: {e}")
+ return GenerateResponse(success=False, error=str(e))
+
+
+@router.post("/generate/cloze", response_model=GenerateResponse)
+async def generate_cloze(request: ClozeGenerateRequest):
+ """Generiert Lückentext aus Quelltext."""
+ try:
+ cloze_type = parse_cloze_type(request.cloze_type)
+
+ cloze = cloze_generator.generate(
+ source_text=request.source_text,
+ num_gaps=request.num_gaps,
+ difficulty=request.difficulty,
+ cloze_type=cloze_type,
+ topic=request.topic
+ )
+
+ if not cloze.gaps:
+ return GenerateResponse(
+ success=False,
+ error="Keine Lücken generiert. Text möglicherweise zu kurz."
+ )
+
+ cloze_dict = cloze_generator.to_dict(cloze)
+ h5p_format = cloze_generator.to_h5p_format(cloze)
+
+ content = WorksheetContent(
+ id=str(uuid.uuid4()),
+ content_type=ContentType.CLOZE.value,
+ data=cloze_dict,
+ h5p_format=h5p_format,
+ created_at=datetime.utcnow(),
+ topic=request.topic,
+ difficulty=request.difficulty
+ )
+
+ _store_content(content)
+ return GenerateResponse(success=True, content=content)
+
+ except Exception as e:
+ logger.error(f"Error generating cloze: {e}")
+ return GenerateResponse(success=False, error=str(e))
+
+
+@router.post("/generate/mindmap", response_model=GenerateResponse)
+async def generate_mindmap(request: MindmapGenerateRequest):
+ """Generiert Mindmap aus Quelltext."""
+ try:
+ mindmap = mindmap_generator.generate(
+ source_text=request.source_text,
+ title=request.topic,
+ max_depth=request.max_depth,
+ topic=request.topic
+ )
+
+ if mindmap.total_nodes <= 1:
+ return GenerateResponse(
+ success=False,
+ error="Mindmap konnte nicht generiert werden. Text möglicherweise zu kurz."
+ )
+
+ mindmap_dict = mindmap_generator.to_dict(mindmap)
+ mermaid = mindmap_generator.to_mermaid(mindmap)
+ json_tree = mindmap_generator.to_json_tree(mindmap)
+
+ content = WorksheetContent(
+ id=str(uuid.uuid4()),
+ content_type=ContentType.MINDMAP.value,
+ data={
+ "mindmap": mindmap_dict,
+ "mermaid": mermaid,
+ "json_tree": json_tree
+ },
+ h5p_format=None,
+ created_at=datetime.utcnow(),
+ topic=request.topic,
+ difficulty=None
+ )
+
+ _store_content(content)
+ return GenerateResponse(success=True, content=content)
+
+ except Exception as e:
+ logger.error(f"Error generating mindmap: {e}")
+ return GenerateResponse(success=False, error=str(e))
+
+
+@router.post("/generate/quiz", response_model=GenerateResponse)
+async def generate_quiz(request: QuizGenerateRequest):
+ """Generiert Quiz mit verschiedenen Fragetypen."""
+ try:
+ quiz_types = parse_quiz_types(request.quiz_types)
+
+ all_questions = []
+ quizzes = []
+
+ for quiz_type in quiz_types:
+ quiz = quiz_generator.generate(
+ source_text=request.source_text,
+ quiz_type=quiz_type,
+ num_questions=request.num_items,
+ difficulty=request.difficulty,
+ topic=request.topic
+ )
+ quizzes.append(quiz)
+ all_questions.extend(quiz.questions)
+
+ if len(all_questions) == 0:
+ return GenerateResponse(
+ success=False,
+ error="Quiz konnte nicht generiert werden. Text möglicherweise zu kurz."
+ )
+
+ combined_quiz_dict = {
+ "quiz_types": [qt.value for qt in quiz_types],
+ "title": f"Combined Quiz - {request.topic or 'Various Topics'}",
+ "topic": request.topic,
+ "difficulty": request.difficulty,
+ "questions": []
+ }
+
+ for quiz in quizzes:
+ quiz_dict = quiz_generator.to_dict(quiz)
+ combined_quiz_dict["questions"].extend(quiz_dict.get("questions", []))
+
+ h5p_format = quiz_generator.to_h5p_format(quizzes[0]) if quizzes else {}
+
+ content = WorksheetContent(
+ id=str(uuid.uuid4()),
+ content_type=ContentType.QUIZ.value,
+ data=combined_quiz_dict,
+ h5p_format=h5p_format,
+ created_at=datetime.utcnow(),
+ topic=request.topic,
+ difficulty=request.difficulty
+ )
+
+ _store_content(content)
+ return GenerateResponse(success=True, content=content)
+
+ except Exception as e:
+ logger.error(f"Error generating quiz: {e}")
+ return GenerateResponse(success=False, error=str(e))
+
+
+@router.post("/generate/batch", response_model=BatchGenerateResponse)
+async def generate_batch(request: BatchGenerateRequest):
+ """Generiert mehrere Content-Typen aus einem Quelltext."""
+ contents = []
+ errors = []
+
+ for content_type in request.content_types:
+ try:
+ if content_type == "multiple_choice":
+ mc_req = MCGenerateRequest(
+ source_text=request.source_text,
+ topic=request.topic,
+ subject=request.subject,
+ grade_level=request.grade_level,
+ difficulty=request.difficulty
+ )
+ result = await generate_multiple_choice(mc_req)
+
+ elif content_type == "cloze":
+ cloze_req = ClozeGenerateRequest(
+ source_text=request.source_text,
+ topic=request.topic,
+ subject=request.subject,
+ grade_level=request.grade_level,
+ difficulty=request.difficulty
+ )
+ result = await generate_cloze(cloze_req)
+
+ elif content_type == "mindmap":
+ mindmap_req = MindmapGenerateRequest(
+ source_text=request.source_text,
+ topic=request.topic,
+ subject=request.subject,
+ grade_level=request.grade_level
+ )
+ result = await generate_mindmap(mindmap_req)
+
+ elif content_type == "quiz":
+ quiz_req = QuizGenerateRequest(
+ source_text=request.source_text,
+ topic=request.topic,
+ subject=request.subject,
+ grade_level=request.grade_level,
+ difficulty=request.difficulty
+ )
+ result = await generate_quiz(quiz_req)
+
+ else:
+ errors.append(f"Unbekannter Content-Typ: {content_type}")
+ continue
+
+ if result.success and result.content:
+ contents.append(result.content)
+ elif result.error:
+ errors.append(f"{content_type}: {result.error}")
+
+ except Exception as e:
+ errors.append(f"{content_type}: {str(e)}")
+
+ return BatchGenerateResponse(
+ success=len(contents) > 0,
+ contents=contents,
+ errors=errors
+ )
+
+
+@router.get("/content/{content_id}", response_model=GenerateResponse)
+async def get_content(content_id: str):
+ """Ruft gespeicherten Content ab."""
+ content = _generated_content.get(content_id)
+
+ if not content:
+ raise HTTPException(status_code=404, detail="Content nicht gefunden")
+
+ return GenerateResponse(success=True, content=content)
+
+
+@router.get("/content/{content_id}/h5p")
+async def get_content_h5p(content_id: str):
+ """Gibt H5P-Format für Content zurück."""
+ content = _generated_content.get(content_id)
+
+ if not content:
+ raise HTTPException(status_code=404, detail="Content nicht gefunden")
+
+ if not content.h5p_format:
+ raise HTTPException(
+ status_code=400,
+ detail="H5P-Format für diesen Content-Typ nicht verfügbar"
+ )
+
+ return content.h5p_format
+
+
+@router.delete("/content/{content_id}")
+async def delete_content(content_id: str):
+ """Löscht gespeicherten Content."""
+ if content_id not in _generated_content:
+ raise HTTPException(status_code=404, detail="Content nicht gefunden")
+
+ del _generated_content[content_id]
+ return {"status": "deleted", "id": content_id}
+
+
+@router.get("/types")
+async def list_content_types():
+ """Listet verfügbare Content-Typen und deren Optionen."""
+ return {
+ "content_types": [
+ {
+ "type": "multiple_choice",
+ "name": "Multiple Choice",
+ "description": "Fragen mit 4 Antwortmöglichkeiten",
+ "options": {
+ "num_questions": {"min": 1, "max": 20, "default": 5},
+ "difficulty": ["easy", "medium", "hard"]
+ },
+ "h5p_supported": True
+ },
+ {
+ "type": "cloze",
+ "name": "Lückentext",
+ "description": "Text mit ausgeblendeten Schlüsselwörtern",
+ "options": {
+ "num_gaps": {"min": 1, "max": 15, "default": 5},
+ "difficulty": ["easy", "medium", "hard"],
+ "cloze_type": ["fill_in", "drag_drop", "dropdown"]
+ },
+ "h5p_supported": True
+ },
+ {
+ "type": "mindmap",
+ "name": "Mindmap",
+ "description": "Hierarchische Struktur aus Hauptthema und Unterthemen",
+ "options": {
+ "max_depth": {"min": 2, "max": 5, "default": 3}
+ },
+ "h5p_supported": False,
+ "export_formats": ["mermaid", "json_tree"]
+ },
+ {
+ "type": "quiz",
+ "name": "Quiz",
+ "description": "Verschiedene Fragetypen kombiniert",
+ "options": {
+ "quiz_types": ["true_false", "matching", "sorting", "open_ended"],
+ "num_items": {"min": 1, "max": 10, "default": 5},
+ "difficulty": ["easy", "medium", "hard"]
+ },
+ "h5p_supported": True
+ }
+ ]
+ }
+
+
+@router.get("/history")
+async def get_generation_history(limit: int = 10):
+ """Gibt die letzten generierten Contents zurück."""
+ sorted_contents = sorted(
+ _generated_content.values(),
+ key=lambda x: x.created_at,
+ reverse=True
+ )
+
+ return {
+ "total": len(_generated_content),
+ "contents": [
+ {
+ "id": c.id,
+ "content_type": c.content_type,
+ "topic": c.topic,
+ "difficulty": c.difficulty,
+ "created_at": c.created_at.isoformat()
+ }
+ for c in sorted_contents[:limit]
+ ]
+ }
diff --git a/backend-lehrer/worksheets/models.py b/backend-lehrer/worksheets/models.py
new file mode 100644
index 0000000..87c6b25
--- /dev/null
+++ b/backend-lehrer/worksheets/models.py
@@ -0,0 +1,135 @@
+"""
+Worksheets API - Pydantic Models und Helpers.
+
+Request-/Response-Models und Hilfsfunktionen fuer die
+Arbeitsblatt-Generierungs-API.
+"""
+
+import uuid
+from datetime import datetime
+from typing import List, Dict, Any, Optional
+from enum import Enum
+
+from pydantic import BaseModel, Field
+
+from generators.mc_generator import Difficulty
+from generators.cloze_generator import ClozeType
+from generators.quiz_generator import QuizType
+
+
+# ============================================================================
+# Pydantic Models
+# ============================================================================
+
+class ContentType(str, Enum):
+ """Verfügbare Content-Typen."""
+ MULTIPLE_CHOICE = "multiple_choice"
+ CLOZE = "cloze"
+ MINDMAP = "mindmap"
+ QUIZ = "quiz"
+
+
+class GenerateRequest(BaseModel):
+ """Basis-Request für Generierung."""
+ source_text: str = Field(..., min_length=50, description="Quelltext für Generierung")
+ topic: Optional[str] = Field(None, description="Thema/Titel")
+ subject: Optional[str] = Field(None, description="Fach")
+ grade_level: Optional[str] = Field(None, description="Klassenstufe")
+
+
+class MCGenerateRequest(GenerateRequest):
+ """Request für Multiple-Choice-Generierung."""
+ num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen")
+ difficulty: str = Field("medium", description="easy, medium, hard")
+
+
+class ClozeGenerateRequest(GenerateRequest):
+ """Request für Lückentext-Generierung."""
+ num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken")
+ difficulty: str = Field("medium", description="easy, medium, hard")
+ cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown")
+
+
+class MindmapGenerateRequest(GenerateRequest):
+ """Request für Mindmap-Generierung."""
+ max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe")
+
+
+class QuizGenerateRequest(GenerateRequest):
+ """Request für Quiz-Generierung."""
+ quiz_types: List[str] = Field(
+ ["true_false", "matching"],
+ description="Typen: true_false, matching, sorting, open_ended"
+ )
+ num_items: int = Field(5, ge=1, le=10, description="Items pro Typ")
+ difficulty: str = Field("medium", description="easy, medium, hard")
+
+
+class BatchGenerateRequest(BaseModel):
+ """Request für Batch-Generierung mehrerer Content-Typen."""
+ source_text: str = Field(..., min_length=50)
+ content_types: List[str] = Field(..., description="Liste von Content-Typen")
+ topic: Optional[str] = None
+ subject: Optional[str] = None
+ grade_level: Optional[str] = None
+ difficulty: str = "medium"
+
+
+class WorksheetContent(BaseModel):
+ """Generierter Content."""
+ id: str
+ content_type: str
+ data: Dict[str, Any]
+ h5p_format: Optional[Dict[str, Any]] = None
+ created_at: datetime
+ topic: Optional[str] = None
+ difficulty: Optional[str] = None
+
+
+class GenerateResponse(BaseModel):
+ """Response mit generiertem Content."""
+ success: bool
+ content: Optional[WorksheetContent] = None
+ error: Optional[str] = None
+
+
+class BatchGenerateResponse(BaseModel):
+ """Response für Batch-Generierung."""
+ success: bool
+ contents: List[WorksheetContent] = []
+ errors: List[str] = []
+
+
+# ============================================================================
+# Helper Functions
+# ============================================================================
+
+def parse_difficulty(difficulty_str: str) -> Difficulty:
+ """Konvertiert String zu Difficulty Enum."""
+ mapping = {
+ "easy": Difficulty.EASY,
+ "medium": Difficulty.MEDIUM,
+ "hard": Difficulty.HARD
+ }
+ return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM)
+
+
+def parse_cloze_type(type_str: str) -> ClozeType:
+ """Konvertiert String zu ClozeType Enum."""
+ mapping = {
+ "fill_in": ClozeType.FILL_IN,
+ "drag_drop": ClozeType.DRAG_DROP,
+ "dropdown": ClozeType.DROPDOWN
+ }
+ return mapping.get(type_str.lower(), ClozeType.FILL_IN)
+
+
+def parse_quiz_types(type_strs: List[str]) -> List[QuizType]:
+ """Konvertiert String-Liste zu QuizType Enums."""
+ mapping = {
+ "true_false": QuizType.TRUE_FALSE,
+ "matching": QuizType.MATCHING,
+ "sorting": QuizType.SORTING,
+ "open_ended": QuizType.OPEN_ENDED
+ }
+ return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs]
diff --git a/backend-lehrer/worksheets_api.py b/backend-lehrer/worksheets_api.py
index d6f9f8d..a13de4e 100644
--- a/backend-lehrer/worksheets_api.py
+++ b/backend-lehrer/worksheets_api.py
@@ -1,439 +1,4 @@
-"""
-Worksheets API - REST API für Arbeitsblatt-Generierung.
-
-Integriert alle Content-Generatoren:
-- Multiple Choice Questions
-- Lückentexte (Cloze)
-- Mindmaps
-- Quizze (True/False, Matching, Sorting, Open)
-
-Unterstützt:
-- H5P-Export für interaktive Inhalte
-- PDF-Export für Druckversionen
-- JSON-Export für Frontend-Integration
-"""
-
-import logging
-import uuid
-from datetime import datetime
-from typing import Dict
-
-from fastapi import APIRouter, HTTPException
-
-from generators import (
- MultipleChoiceGenerator,
- ClozeGenerator,
- MindmapGenerator,
- QuizGenerator
-)
-
-from worksheets_models import (
- ContentType,
- GenerateRequest,
- MCGenerateRequest,
- ClozeGenerateRequest,
- MindmapGenerateRequest,
- QuizGenerateRequest,
- BatchGenerateRequest,
- WorksheetContent,
- GenerateResponse,
- BatchGenerateResponse,
- parse_difficulty,
- parse_cloze_type,
- parse_quiz_types,
-)
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(
- prefix="/worksheets",
- tags=["worksheets"],
-)
-
-
-# ============================================================================
-# In-Memory Storage (später durch DB ersetzen)
-# ============================================================================
-
-_generated_content: Dict[str, WorksheetContent] = {}
-
-
-# ============================================================================
-# Generator Instances
-# ============================================================================
-
-mc_generator = MultipleChoiceGenerator()
-cloze_generator = ClozeGenerator()
-mindmap_generator = MindmapGenerator()
-quiz_generator = QuizGenerator()
-
-
-def _store_content(content: WorksheetContent) -> None:
- """Speichert generierten Content."""
- _generated_content[content.id] = content
-
-
-# ============================================================================
-# API Endpoints
-# ============================================================================
-
-@router.post("/generate/multiple-choice", response_model=GenerateResponse)
-async def generate_multiple_choice(request: MCGenerateRequest):
- """Generiert Multiple-Choice-Fragen aus Quelltext."""
- try:
- difficulty = parse_difficulty(request.difficulty)
-
- questions = mc_generator.generate(
- source_text=request.source_text,
- num_questions=request.num_questions,
- difficulty=difficulty,
- subject=request.subject,
- grade_level=request.grade_level
- )
-
- if not questions:
- return GenerateResponse(
- success=False,
- error="Keine Fragen generiert. Text möglicherweise zu kurz."
- )
-
- questions_dict = mc_generator.to_dict(questions)
- h5p_format = mc_generator.to_h5p_format(questions)
-
- content = WorksheetContent(
- id=str(uuid.uuid4()),
- content_type=ContentType.MULTIPLE_CHOICE.value,
- data={"questions": questions_dict},
- h5p_format=h5p_format,
- created_at=datetime.utcnow(),
- topic=request.topic,
- difficulty=request.difficulty
- )
-
- _store_content(content)
- return GenerateResponse(success=True, content=content)
-
- except Exception as e:
- logger.error(f"Error generating MC questions: {e}")
- return GenerateResponse(success=False, error=str(e))
-
-
-@router.post("/generate/cloze", response_model=GenerateResponse)
-async def generate_cloze(request: ClozeGenerateRequest):
- """Generiert Lückentext aus Quelltext."""
- try:
- cloze_type = parse_cloze_type(request.cloze_type)
-
- cloze = cloze_generator.generate(
- source_text=request.source_text,
- num_gaps=request.num_gaps,
- difficulty=request.difficulty,
- cloze_type=cloze_type,
- topic=request.topic
- )
-
- if not cloze.gaps:
- return GenerateResponse(
- success=False,
- error="Keine Lücken generiert. Text möglicherweise zu kurz."
- )
-
- cloze_dict = cloze_generator.to_dict(cloze)
- h5p_format = cloze_generator.to_h5p_format(cloze)
-
- content = WorksheetContent(
- id=str(uuid.uuid4()),
- content_type=ContentType.CLOZE.value,
- data=cloze_dict,
- h5p_format=h5p_format,
- created_at=datetime.utcnow(),
- topic=request.topic,
- difficulty=request.difficulty
- )
-
- _store_content(content)
- return GenerateResponse(success=True, content=content)
-
- except Exception as e:
- logger.error(f"Error generating cloze: {e}")
- return GenerateResponse(success=False, error=str(e))
-
-
-@router.post("/generate/mindmap", response_model=GenerateResponse)
-async def generate_mindmap(request: MindmapGenerateRequest):
- """Generiert Mindmap aus Quelltext."""
- try:
- mindmap = mindmap_generator.generate(
- source_text=request.source_text,
- title=request.topic,
- max_depth=request.max_depth,
- topic=request.topic
- )
-
- if mindmap.total_nodes <= 1:
- return GenerateResponse(
- success=False,
- error="Mindmap konnte nicht generiert werden. Text möglicherweise zu kurz."
- )
-
- mindmap_dict = mindmap_generator.to_dict(mindmap)
- mermaid = mindmap_generator.to_mermaid(mindmap)
- json_tree = mindmap_generator.to_json_tree(mindmap)
-
- content = WorksheetContent(
- id=str(uuid.uuid4()),
- content_type=ContentType.MINDMAP.value,
- data={
- "mindmap": mindmap_dict,
- "mermaid": mermaid,
- "json_tree": json_tree
- },
- h5p_format=None,
- created_at=datetime.utcnow(),
- topic=request.topic,
- difficulty=None
- )
-
- _store_content(content)
- return GenerateResponse(success=True, content=content)
-
- except Exception as e:
- logger.error(f"Error generating mindmap: {e}")
- return GenerateResponse(success=False, error=str(e))
-
-
-@router.post("/generate/quiz", response_model=GenerateResponse)
-async def generate_quiz(request: QuizGenerateRequest):
- """Generiert Quiz mit verschiedenen Fragetypen."""
- try:
- quiz_types = parse_quiz_types(request.quiz_types)
-
- all_questions = []
- quizzes = []
-
- for quiz_type in quiz_types:
- quiz = quiz_generator.generate(
- source_text=request.source_text,
- quiz_type=quiz_type,
- num_questions=request.num_items,
- difficulty=request.difficulty,
- topic=request.topic
- )
- quizzes.append(quiz)
- all_questions.extend(quiz.questions)
-
- if len(all_questions) == 0:
- return GenerateResponse(
- success=False,
- error="Quiz konnte nicht generiert werden. Text möglicherweise zu kurz."
- )
-
- combined_quiz_dict = {
- "quiz_types": [qt.value for qt in quiz_types],
- "title": f"Combined Quiz - {request.topic or 'Various Topics'}",
- "topic": request.topic,
- "difficulty": request.difficulty,
- "questions": []
- }
-
- for quiz in quizzes:
- quiz_dict = quiz_generator.to_dict(quiz)
- combined_quiz_dict["questions"].extend(quiz_dict.get("questions", []))
-
- h5p_format = quiz_generator.to_h5p_format(quizzes[0]) if quizzes else {}
-
- content = WorksheetContent(
- id=str(uuid.uuid4()),
- content_type=ContentType.QUIZ.value,
- data=combined_quiz_dict,
- h5p_format=h5p_format,
- created_at=datetime.utcnow(),
- topic=request.topic,
- difficulty=request.difficulty
- )
-
- _store_content(content)
- return GenerateResponse(success=True, content=content)
-
- except Exception as e:
- logger.error(f"Error generating quiz: {e}")
- return GenerateResponse(success=False, error=str(e))
-
-
-@router.post("/generate/batch", response_model=BatchGenerateResponse)
-async def generate_batch(request: BatchGenerateRequest):
- """Generiert mehrere Content-Typen aus einem Quelltext."""
- contents = []
- errors = []
-
- for content_type in request.content_types:
- try:
- if content_type == "multiple_choice":
- mc_req = MCGenerateRequest(
- source_text=request.source_text,
- topic=request.topic,
- subject=request.subject,
- grade_level=request.grade_level,
- difficulty=request.difficulty
- )
- result = await generate_multiple_choice(mc_req)
-
- elif content_type == "cloze":
- cloze_req = ClozeGenerateRequest(
- source_text=request.source_text,
- topic=request.topic,
- subject=request.subject,
- grade_level=request.grade_level,
- difficulty=request.difficulty
- )
- result = await generate_cloze(cloze_req)
-
- elif content_type == "mindmap":
- mindmap_req = MindmapGenerateRequest(
- source_text=request.source_text,
- topic=request.topic,
- subject=request.subject,
- grade_level=request.grade_level
- )
- result = await generate_mindmap(mindmap_req)
-
- elif content_type == "quiz":
- quiz_req = QuizGenerateRequest(
- source_text=request.source_text,
- topic=request.topic,
- subject=request.subject,
- grade_level=request.grade_level,
- difficulty=request.difficulty
- )
- result = await generate_quiz(quiz_req)
-
- else:
- errors.append(f"Unbekannter Content-Typ: {content_type}")
- continue
-
- if result.success and result.content:
- contents.append(result.content)
- elif result.error:
- errors.append(f"{content_type}: {result.error}")
-
- except Exception as e:
- errors.append(f"{content_type}: {str(e)}")
-
- return BatchGenerateResponse(
- success=len(contents) > 0,
- contents=contents,
- errors=errors
- )
-
-
-@router.get("/content/{content_id}", response_model=GenerateResponse)
-async def get_content(content_id: str):
- """Ruft gespeicherten Content ab."""
- content = _generated_content.get(content_id)
-
- if not content:
- raise HTTPException(status_code=404, detail="Content nicht gefunden")
-
- return GenerateResponse(success=True, content=content)
-
-
-@router.get("/content/{content_id}/h5p")
-async def get_content_h5p(content_id: str):
- """Gibt H5P-Format für Content zurück."""
- content = _generated_content.get(content_id)
-
- if not content:
- raise HTTPException(status_code=404, detail="Content nicht gefunden")
-
- if not content.h5p_format:
- raise HTTPException(
- status_code=400,
- detail="H5P-Format für diesen Content-Typ nicht verfügbar"
- )
-
- return content.h5p_format
-
-
-@router.delete("/content/{content_id}")
-async def delete_content(content_id: str):
- """Löscht gespeicherten Content."""
- if content_id not in _generated_content:
- raise HTTPException(status_code=404, detail="Content nicht gefunden")
-
- del _generated_content[content_id]
- return {"status": "deleted", "id": content_id}
-
-
-@router.get("/types")
-async def list_content_types():
- """Listet verfügbare Content-Typen und deren Optionen."""
- return {
- "content_types": [
- {
- "type": "multiple_choice",
- "name": "Multiple Choice",
- "description": "Fragen mit 4 Antwortmöglichkeiten",
- "options": {
- "num_questions": {"min": 1, "max": 20, "default": 5},
- "difficulty": ["easy", "medium", "hard"]
- },
- "h5p_supported": True
- },
- {
- "type": "cloze",
- "name": "Lückentext",
- "description": "Text mit ausgeblendeten Schlüsselwörtern",
- "options": {
- "num_gaps": {"min": 1, "max": 15, "default": 5},
- "difficulty": ["easy", "medium", "hard"],
- "cloze_type": ["fill_in", "drag_drop", "dropdown"]
- },
- "h5p_supported": True
- },
- {
- "type": "mindmap",
- "name": "Mindmap",
- "description": "Hierarchische Struktur aus Hauptthema und Unterthemen",
- "options": {
- "max_depth": {"min": 2, "max": 5, "default": 3}
- },
- "h5p_supported": False,
- "export_formats": ["mermaid", "json_tree"]
- },
- {
- "type": "quiz",
- "name": "Quiz",
- "description": "Verschiedene Fragetypen kombiniert",
- "options": {
- "quiz_types": ["true_false", "matching", "sorting", "open_ended"],
- "num_items": {"min": 1, "max": 10, "default": 5},
- "difficulty": ["easy", "medium", "hard"]
- },
- "h5p_supported": True
- }
- ]
- }
-
-
-@router.get("/history")
-async def get_generation_history(limit: int = 10):
- """Gibt die letzten generierten Contents zurück."""
- sorted_contents = sorted(
- _generated_content.values(),
- key=lambda x: x.created_at,
- reverse=True
- )
-
- return {
- "total": len(_generated_content),
- "contents": [
- {
- "id": c.id,
- "content_type": c.content_type,
- "topic": c.topic,
- "difficulty": c.difficulty,
- "created_at": c.created_at.isoformat()
- }
- for c in sorted_contents[:limit]
- ]
- }
+# Backward-compat shim -- module moved to worksheets/api.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("worksheets.api")
diff --git a/backend-lehrer/worksheets_models.py b/backend-lehrer/worksheets_models.py
index 87c6b25..00ed0b8 100644
--- a/backend-lehrer/worksheets_models.py
+++ b/backend-lehrer/worksheets_models.py
@@ -1,135 +1,4 @@
-"""
-Worksheets API - Pydantic Models und Helpers.
-
-Request-/Response-Models und Hilfsfunktionen fuer die
-Arbeitsblatt-Generierungs-API.
-"""
-
-import uuid
-from datetime import datetime
-from typing import List, Dict, Any, Optional
-from enum import Enum
-
-from pydantic import BaseModel, Field
-
-from generators.mc_generator import Difficulty
-from generators.cloze_generator import ClozeType
-from generators.quiz_generator import QuizType
-
-
-# ============================================================================
-# Pydantic Models
-# ============================================================================
-
-class ContentType(str, Enum):
- """Verfügbare Content-Typen."""
- MULTIPLE_CHOICE = "multiple_choice"
- CLOZE = "cloze"
- MINDMAP = "mindmap"
- QUIZ = "quiz"
-
-
-class GenerateRequest(BaseModel):
- """Basis-Request für Generierung."""
- source_text: str = Field(..., min_length=50, description="Quelltext für Generierung")
- topic: Optional[str] = Field(None, description="Thema/Titel")
- subject: Optional[str] = Field(None, description="Fach")
- grade_level: Optional[str] = Field(None, description="Klassenstufe")
-
-
-class MCGenerateRequest(GenerateRequest):
- """Request für Multiple-Choice-Generierung."""
- num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen")
- difficulty: str = Field("medium", description="easy, medium, hard")
-
-
-class ClozeGenerateRequest(GenerateRequest):
- """Request für Lückentext-Generierung."""
- num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken")
- difficulty: str = Field("medium", description="easy, medium, hard")
- cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown")
-
-
-class MindmapGenerateRequest(GenerateRequest):
- """Request für Mindmap-Generierung."""
- max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe")
-
-
-class QuizGenerateRequest(GenerateRequest):
- """Request für Quiz-Generierung."""
- quiz_types: List[str] = Field(
- ["true_false", "matching"],
- description="Typen: true_false, matching, sorting, open_ended"
- )
- num_items: int = Field(5, ge=1, le=10, description="Items pro Typ")
- difficulty: str = Field("medium", description="easy, medium, hard")
-
-
-class BatchGenerateRequest(BaseModel):
- """Request für Batch-Generierung mehrerer Content-Typen."""
- source_text: str = Field(..., min_length=50)
- content_types: List[str] = Field(..., description="Liste von Content-Typen")
- topic: Optional[str] = None
- subject: Optional[str] = None
- grade_level: Optional[str] = None
- difficulty: str = "medium"
-
-
-class WorksheetContent(BaseModel):
- """Generierter Content."""
- id: str
- content_type: str
- data: Dict[str, Any]
- h5p_format: Optional[Dict[str, Any]] = None
- created_at: datetime
- topic: Optional[str] = None
- difficulty: Optional[str] = None
-
-
-class GenerateResponse(BaseModel):
- """Response mit generiertem Content."""
- success: bool
- content: Optional[WorksheetContent] = None
- error: Optional[str] = None
-
-
-class BatchGenerateResponse(BaseModel):
- """Response für Batch-Generierung."""
- success: bool
- contents: List[WorksheetContent] = []
- errors: List[str] = []
-
-
-# ============================================================================
-# Helper Functions
-# ============================================================================
-
-def parse_difficulty(difficulty_str: str) -> Difficulty:
- """Konvertiert String zu Difficulty Enum."""
- mapping = {
- "easy": Difficulty.EASY,
- "medium": Difficulty.MEDIUM,
- "hard": Difficulty.HARD
- }
- return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM)
-
-
-def parse_cloze_type(type_str: str) -> ClozeType:
- """Konvertiert String zu ClozeType Enum."""
- mapping = {
- "fill_in": ClozeType.FILL_IN,
- "drag_drop": ClozeType.DRAG_DROP,
- "dropdown": ClozeType.DROPDOWN
- }
- return mapping.get(type_str.lower(), ClozeType.FILL_IN)
-
-
-def parse_quiz_types(type_strs: List[str]) -> List[QuizType]:
- """Konvertiert String-Liste zu QuizType Enums."""
- mapping = {
- "true_false": QuizType.TRUE_FALSE,
- "matching": QuizType.MATCHING,
- "sorting": QuizType.SORTING,
- "open_ended": QuizType.OPEN_ENDED
- }
- return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs]
+# Backward-compat shim -- module moved to worksheets/models.py
+import importlib as _importlib
+import sys as _sys
+_sys.modules[__name__] = _importlib.import_module("worksheets.models")