Restructure: Move final 16 root files into packages (backend-lehrer)
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m41s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 38s

classroom/ (+2): state_engine_api, state_engine_models
vocabulary/ (2): api, db
worksheets/ (2): api, models
services/  (+6): audio, email, translation, claude_vision, ai_processor, story_generator
api/        (4): school, klausur_proxy, progress, user_language

Only main.py + config.py remain at root. 16 shims added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 22:50:37 +02:00
parent 6be555fb7c
commit cba877c65a
36 changed files with 3712 additions and 3564 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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")

View File

@@ -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}

View File

@@ -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")

View File

@@ -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"}

View File

@@ -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

View File

@@ -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")

View File

@@ -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"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #1a2b4e; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background: #f8f9fa; padding: 20px; border: 1px solid #e0e0e0; }}
.message-box {{ background: white; padding: 15px; border-radius: 8px; border-left: 4px solid #ffb800; margin: 15px 0; }}
.button {{ display: inline-block; background: #ffb800; color: #1a2b4e; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; }}
.footer {{ padding: 15px; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="margin: 0;">Neue Nachricht</h2>
</div>
<div class="content">
<p>Hallo <strong>{to_name}</strong>,</p>
<p>Sie haben eine neue Nachricht von <strong>{sender_name}</strong> erhalten:</p>
<div class="message-box">
{message_content.replace(chr(10), '<br>')}
</div>
"""
if reply_link:
body_html += f'<p><a href="{reply_link}" class="button">Nachricht beantworten</a></p>'
body_html += """
</div>
<div class="footer">
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
<p style="font-size: 11px; color: #999;">
Diese E-Mail wurde automatisch versendet.<br>
Bitte antworten Sie nicht direkt auf diese E-Mail.
</p>
</div>
</div>
</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"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 30px; border-radius: 12px 12px 0 0; text-align: center; }}
.header h2 {{ margin: 0 0 10px 0; font-size: 24px; }}
.content {{ background: #f8f9fa; padding: 25px; border: 1px solid #e0e0e0; }}
.meeting-info {{ background: white; padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.info-row {{ display: flex; padding: 10px 0; border-bottom: 1px solid #eee; }}
.info-row:last-child {{ border-bottom: none; }}
.info-label {{ font-weight: 600; color: #666; width: 100px; }}
.info-value {{ color: #333; }}
.join-button {{ display: block; background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 16px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 18px; text-align: center; margin: 25px 0; }}
.join-button:hover {{ opacity: 0.9; }}
.requirements {{ background: #e8f5e9; padding: 15px; border-radius: 8px; margin: 20px 0; }}
.requirements h4 {{ margin: 0 0 10px 0; color: #2e7d32; }}
.requirements ul {{ margin: 0; padding-left: 20px; }}
.footer {{ padding: 20px; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Einladung zum Videogespraech</h2>
<p style="margin: 0; opacity: 0.9;">{meeting_title}</p>
</div>
<div class="content">
<p>Hallo <strong>{to_name}</strong>,</p>
<p><strong>{organizer_name}</strong> laedt Sie zu einem Videogespraech ein.</p>
<div class="meeting-info">
<div class="info-row">
<span class="info-label">Termin:</span>
<span class="info-value">{meeting_title}</span>
</div>
<div class="info-row">
<span class="info-label">Datum:</span>
<span class="info-value">{meeting_date}</span>
</div>
<div class="info-row">
<span class="info-label">Uhrzeit:</span>
<span class="info-value">{meeting_time}</span>
</div>
</div>
<a href="{jitsi_url}" class="join-button">Meeting beitreten</a>
"""
if additional_info:
body_html += f"""
<div style="background: #fff3e0; padding: 15px; border-radius: 8px; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #e65100;">Hinweise:</h4>
<p style="margin: 0;">{additional_info}</p>
</div>
"""
body_html += """
<div class="requirements">
<h4>Technische Voraussetzungen:</h4>
<ul>
<li>Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)</li>
<li>Keine Installation erforderlich</li>
<li>Optional: Kopfhoerer fuer bessere Audioqualitaet</li>
</ul>
</div>
<p style="font-size: 14px; color: #666;">
Bei technischen Problemen wenden Sie sich bitte an den Organisator.
</p>
</div>
<div class="footer">
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
</div>
</div>
</body>
</html>
"""
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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #1a2b4e; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background: #f8f9fa; padding: 20px; border: 1px solid #e0e0e0; }}
.message-box {{ background: white; padding: 15px; border-radius: 8px; border-left: 4px solid #ffb800; margin: 15px 0; }}
.button {{ display: inline-block; background: #ffb800; color: #1a2b4e; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; }}
.footer {{ padding: 15px; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="margin: 0;">Neue Nachricht</h2>
</div>
<div class="content">
<p>Hallo <strong>{to_name}</strong>,</p>
<p>Sie haben eine neue Nachricht von <strong>{sender_name}</strong> erhalten:</p>
<div class="message-box">
{message_content.replace(chr(10), '<br>')}
</div>
"""
if reply_link:
body_html += f'<p><a href="{reply_link}" class="button">Nachricht beantworten</a></p>'
body_html += """
</div>
<div class="footer">
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
<p style="font-size: 11px; color: #999;">
Diese E-Mail wurde automatisch versendet.<br>
Bitte antworten Sie nicht direkt auf diese E-Mail.
</p>
</div>
</div>
</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"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 30px; border-radius: 12px 12px 0 0; text-align: center; }}
.header h2 {{ margin: 0 0 10px 0; font-size: 24px; }}
.content {{ background: #f8f9fa; padding: 25px; border: 1px solid #e0e0e0; }}
.meeting-info {{ background: white; padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.info-row {{ display: flex; padding: 10px 0; border-bottom: 1px solid #eee; }}
.info-row:last-child {{ border-bottom: none; }}
.info-label {{ font-weight: 600; color: #666; width: 100px; }}
.info-value {{ color: #333; }}
.join-button {{ display: block; background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 16px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 18px; text-align: center; margin: 25px 0; }}
.join-button:hover {{ opacity: 0.9; }}
.requirements {{ background: #e8f5e9; padding: 15px; border-radius: 8px; margin: 20px 0; }}
.requirements h4 {{ margin: 0 0 10px 0; color: #2e7d32; }}
.requirements ul {{ margin: 0; padding-left: 20px; }}
.footer {{ padding: 20px; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Einladung zum Videogespraech</h2>
<p style="margin: 0; opacity: 0.9;">{meeting_title}</p>
</div>
<div class="content">
<p>Hallo <strong>{to_name}</strong>,</p>
<p><strong>{organizer_name}</strong> laedt Sie zu einem Videogespraech ein.</p>
<div class="meeting-info">
<div class="info-row">
<span class="info-label">Termin:</span>
<span class="info-value">{meeting_title}</span>
</div>
<div class="info-row">
<span class="info-label">Datum:</span>
<span class="info-value">{meeting_date}</span>
</div>
<div class="info-row">
<span class="info-label">Uhrzeit:</span>
<span class="info-value">{meeting_time}</span>
</div>
</div>
<a href="{jitsi_url}" class="join-button">Meeting beitreten</a>
"""
if additional_info:
body_html += f"""
<div style="background: #fff3e0; padding: 15px; border-radius: 8px; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #e65100;">Hinweise:</h4>
<p style="margin: 0;">{additional_info}</p>
</div>
"""
body_html += """
<div class="requirements">
<h4>Technische Voraussetzungen:</h4>
<ul>
<li>Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)</li>
<li>Keine Installation erforderlich</li>
<li>Optional: Kopfhoerer fuer bessere Audioqualitaet</li>
</ul>
</div>
<p style="font-size: 14px; color: #666;">
Bei technischen Problemen wenden Sie sich bitte an den Organisator.
</p>
</div>
<div class="footer">
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(
to_email=to_email,
subject=subject,
body_text=body_text,
body_html=body_html
)
# Globale Instanz
email_service = EmailService()

View File

@@ -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 <mark> 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'<mark class="vocab-highlight">{m.group()}</mark>',
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."

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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 <mark> 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'<mark class="vocab-highlight">{m.group()}</mark>',
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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -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}

View File

@@ -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]

View File

@@ -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")

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -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]
]
}

View File

@@ -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]

View File

@@ -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")

View File

@@ -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")