From cba877c65a33d0006113099946b99b1196aa7c48 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sat, 25 Apr 2026 22:50:37 +0200 Subject: [PATCH] Restructure: Move final 16 root files into packages (backend-lehrer) 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) --- backend-lehrer/ai_processor.py | 85 +--- backend-lehrer/api/__init__.py | 6 + backend-lehrer/api/klausur_proxy.py | 135 ++++++ backend-lehrer/api/progress.py | 131 ++++++ backend-lehrer/api/school.py | 250 ++++++++++ backend-lehrer/api/user_language.py | 86 ++++ backend-lehrer/audio_service.py | 129 +---- backend-lehrer/classroom/state_engine_api.py | 410 ++++++++++++++++ .../classroom/state_engine_models.py | 143 ++++++ backend-lehrer/claude_vision.py | 303 +----------- backend-lehrer/email_service.py | 399 +--------------- backend-lehrer/klausur_service_proxy.py | 139 +----- backend-lehrer/progress_api.py | 135 +----- backend-lehrer/school_api.py | 254 +--------- backend-lehrer/services/__init__.py | 8 + backend-lehrer/services/ai_processor.py | 81 ++++ backend-lehrer/services/audio.py | 125 +++++ backend-lehrer/services/claude_vision.py | 299 ++++++++++++ backend-lehrer/services/email.py | 395 ++++++++++++++++ backend-lehrer/services/story_generator.py | 108 +++++ backend-lehrer/services/translation.py | 179 +++++++ backend-lehrer/state_engine_api.py | 414 +--------------- backend-lehrer/state_engine_models.py | 147 +----- backend-lehrer/story_generator.py | 112 +---- backend-lehrer/translation_service.py | 183 +------- backend-lehrer/user_language_api.py | 90 +--- backend-lehrer/vocabulary/__init__.py | 33 ++ backend-lehrer/vocabulary/api.py | 352 ++++++++++++++ backend-lehrer/vocabulary/db.py | 296 ++++++++++++ backend-lehrer/vocabulary_api.py | 356 +------------- backend-lehrer/vocabulary_db.py | 300 +----------- backend-lehrer/worksheets/__init__.py | 37 ++ backend-lehrer/worksheets/api.py | 439 +++++++++++++++++ backend-lehrer/worksheets/models.py | 135 ++++++ backend-lehrer/worksheets_api.py | 443 +----------------- backend-lehrer/worksheets_models.py | 139 +----- 36 files changed, 3712 insertions(+), 3564 deletions(-) create mode 100644 backend-lehrer/api/__init__.py create mode 100644 backend-lehrer/api/klausur_proxy.py create mode 100644 backend-lehrer/api/progress.py create mode 100644 backend-lehrer/api/school.py create mode 100644 backend-lehrer/api/user_language.py create mode 100644 backend-lehrer/classroom/state_engine_api.py create mode 100644 backend-lehrer/classroom/state_engine_models.py create mode 100644 backend-lehrer/services/ai_processor.py create mode 100644 backend-lehrer/services/audio.py create mode 100644 backend-lehrer/services/claude_vision.py create mode 100644 backend-lehrer/services/email.py create mode 100644 backend-lehrer/services/story_generator.py create mode 100644 backend-lehrer/services/translation.py create mode 100644 backend-lehrer/vocabulary/__init__.py create mode 100644 backend-lehrer/vocabulary/api.py create mode 100644 backend-lehrer/vocabulary/db.py create mode 100644 backend-lehrer/worksheets/__init__.py create mode 100644 backend-lehrer/worksheets/api.py create mode 100644 backend-lehrer/worksheets/models.py diff --git a/backend-lehrer/ai_processor.py b/backend-lehrer/ai_processor.py index ba893a4..a222b64 100644 --- a/backend-lehrer/ai_processor.py +++ b/backend-lehrer/ai_processor.py @@ -1,81 +1,4 @@ -""" -AI Processor - Legacy Import Wrapper - -This file provides backward compatibility for code that imports from ai_processor. -All functionality has been moved to the ai_processor/ module. - -Usage (new): - from ai_processor import analyze_scan_structure_with_ai - -Usage (legacy, still works): - from ai_processor import analyze_scan_structure_with_ai -""" - -# Re-export everything from the new modular structure -from ai_processor import ( - # Configuration - BASE_DIR, - EINGANG_DIR, - BEREINIGT_DIR, - VISION_API, - # Utilities (with legacy aliases) - encode_image_to_data_url as _encode_image_to_data_url, - dummy_process_scan, - # Vision - Scan Analysis - analyze_scan_structure_with_ai, - describe_scan_with_ai, - remove_handwriting_from_scan, - build_clean_html_from_analysis, - # Generators - Multiple Choice - generate_mc_from_analysis, - # Generators - Cloze - generate_cloze_from_analysis, - # Generators - Q&A with Leitner - generate_qa_from_analysis, - update_leitner_progress, - get_next_review_items, - # Export - Print Versions - generate_print_version_qa, - generate_print_version_cloze, - generate_print_version_mc, - generate_print_version_worksheet, - # Visualization - Mindmap - generate_mindmap_data, - generate_mindmap_html, - save_mindmap_for_worksheet, -) - -# Legacy function alias -from ai_processor import get_openai_api_key as _get_api_key - -__all__ = [ - # Configuration - "BASE_DIR", - "EINGANG_DIR", - "BEREINIGT_DIR", - "VISION_API", - # Legacy private functions - "_get_api_key", - "_encode_image_to_data_url", - # Vision - "analyze_scan_structure_with_ai", - "describe_scan_with_ai", - "remove_handwriting_from_scan", - "build_clean_html_from_analysis", - "dummy_process_scan", - # Generators - "generate_mc_from_analysis", - "generate_cloze_from_analysis", - "generate_qa_from_analysis", - "update_leitner_progress", - "get_next_review_items", - # Export - "generate_print_version_qa", - "generate_print_version_cloze", - "generate_print_version_mc", - "generate_print_version_worksheet", - # Visualization - "generate_mindmap_data", - "generate_mindmap_html", - "save_mindmap_for_worksheet", -] +# Backward-compat shim -- module moved to services/ai_processor.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("services.ai_processor") diff --git a/backend-lehrer/api/__init__.py b/backend-lehrer/api/__init__.py new file mode 100644 index 0000000..03214f9 --- /dev/null +++ b/backend-lehrer/api/__init__.py @@ -0,0 +1,6 @@ +# API Module — thin proxy routers and standalone API endpoints +# +# api/school.py — Proxy to Go school-service +# api/klausur_proxy.py — Proxy to klausur-service +# api/progress.py — Student learning progress tracking +# api/user_language.py — User language preferences diff --git a/backend-lehrer/api/klausur_proxy.py b/backend-lehrer/api/klausur_proxy.py new file mode 100644 index 0000000..0c7b841 --- /dev/null +++ b/backend-lehrer/api/klausur_proxy.py @@ -0,0 +1,135 @@ +""" +Klausur-Service API Proxy +Routes API requests from /api/klausur/* to the klausur-service microservice +""" + +import os +import jwt +import datetime +import httpx +from fastapi import APIRouter, Request, HTTPException, Response + +# Klausur Service URL +KLAUSUR_SERVICE_URL = os.getenv("KLAUSUR_SERVICE_URL", "http://klausur-service:8086") +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") + +# Demo teacher UUID for development mode +DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20" + +router = APIRouter(prefix="/klausur", tags=["klausur"]) + + +def get_demo_token() -> str: + """Generate a demo JWT token for development mode""" + payload = { + "user_id": DEMO_TEACHER_ID, + "email": "demo@breakpilot.app", + "role": "admin", + "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24), + "iat": datetime.datetime.now(datetime.timezone.utc) + } + return jwt.encode(payload, JWT_SECRET, algorithm="HS256") + + +async def proxy_request(request: Request, path: str) -> Response: + """Forward a request to the klausur service""" + url = f"{KLAUSUR_SERVICE_URL}/api/v1{path}" + + # Forward headers, especially Authorization + headers = {} + if "authorization" in request.headers: + headers["Authorization"] = request.headers["authorization"] + elif ENVIRONMENT == "development": + # In development mode, use demo token if no auth provided + demo_token = get_demo_token() + headers["Authorization"] = f"Bearer {demo_token}" + if "content-type" in request.headers: + headers["Content-Type"] = request.headers["content-type"] + + # Get request body for POST/PUT/PATCH/DELETE + body = None + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + body = await request.body() + + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.request( + method=request.method, + url=url, + headers=headers, + content=body, + params=request.query_params + ) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type", "application/json") + ) + except httpx.ConnectError: + raise HTTPException( + status_code=503, + detail="Klausur service unavailable" + ) + except httpx.TimeoutException: + raise HTTPException( + status_code=504, + detail="Klausur service timeout" + ) + + +# Health check +@router.get("/health") +async def health(): + """Health check for klausur service connection""" + async with httpx.AsyncClient(timeout=5.0) as client: + try: + response = await client.get(f"{KLAUSUR_SERVICE_URL}/health") + return {"klausur_service": "healthy", "connected": response.status_code == 200} + except Exception: + return {"klausur_service": "unhealthy", "connected": False} + + +# Klausuren +@router.api_route("/klausuren", methods=["GET", "POST"]) +async def klausuren(request: Request): + return await proxy_request(request, "/klausuren") + + +@router.api_route("/klausuren/{klausur_id}", methods=["GET", "PUT", "DELETE"]) +async def klausur_by_id(klausur_id: str, request: Request): + return await proxy_request(request, f"/klausuren/{klausur_id}") + + +# Students +@router.api_route("/klausuren/{klausur_id}/students", methods=["GET", "POST"]) +async def klausur_students(klausur_id: str, request: Request): + return await proxy_request(request, f"/klausuren/{klausur_id}/students") + + +@router.api_route("/students/{student_id}", methods=["GET", "DELETE"]) +async def student_by_id(student_id: str, request: Request): + return await proxy_request(request, f"/students/{student_id}") + + +# Grading +@router.api_route("/students/{student_id}/criteria", methods=["PUT"]) +async def update_criteria(student_id: str, request: Request): + return await proxy_request(request, f"/students/{student_id}/criteria") + + +@router.api_route("/students/{student_id}/gutachten", methods=["PUT"]) +async def update_gutachten(student_id: str, request: Request): + return await proxy_request(request, f"/students/{student_id}/gutachten") + + +@router.api_route("/students/{student_id}/finalize", methods=["POST"]) +async def finalize_student(student_id: str, request: Request): + return await proxy_request(request, f"/students/{student_id}/finalize") + + +# Grade info +@router.get("/grade-info") +async def grade_info(request: Request): + return await proxy_request(request, "/grade-info") diff --git a/backend-lehrer/api/progress.py b/backend-lehrer/api/progress.py new file mode 100644 index 0000000..9b1c7d8 --- /dev/null +++ b/backend-lehrer/api/progress.py @@ -0,0 +1,131 @@ +""" +Progress API — Tracks student learning progress per unit. + +Stores coins, crowns, streak data, and exercise completion stats. +Uses JSON file storage (same pattern as learning_units.py). +""" + +import os +import json +import logging +from datetime import datetime, date +from typing import Dict, Any, Optional, List +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/progress", + tags=["progress"], +) + +PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress") + + +def _ensure_dir(): + os.makedirs(PROGRESS_DIR, exist_ok=True) + + +def _progress_path(unit_id: str) -> Path: + return Path(PROGRESS_DIR) / f"{unit_id}.json" + + +def _load_progress(unit_id: str) -> Dict[str, Any]: + path = _progress_path(unit_id) + if path.exists(): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + return { + "unit_id": unit_id, + "coins": 0, + "crowns": 0, + "streak_days": 0, + "last_activity": None, + "exercises": { + "flashcards": {"completed": 0, "correct": 0, "incorrect": 0}, + "quiz": {"completed": 0, "correct": 0, "incorrect": 0}, + "type": {"completed": 0, "correct": 0, "incorrect": 0}, + "story": {"generated": 0}, + }, + "created_at": datetime.now().isoformat(), + } + + +def _save_progress(unit_id: str, data: Dict[str, Any]): + _ensure_dir() + path = _progress_path(unit_id) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +class RewardPayload(BaseModel): + exercise_type: str # flashcards, quiz, type, story + correct: bool = True + first_try: bool = True + + +@router.get("/{unit_id}") +def get_progress(unit_id: str): + """Get learning progress for a unit.""" + return _load_progress(unit_id) + + +@router.post("/{unit_id}/reward") +def add_reward(unit_id: str, payload: RewardPayload): + """Record an exercise result and award coins.""" + progress = _load_progress(unit_id) + + # Update exercise stats + ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0}) + ex["completed"] = ex.get("completed", 0) + 1 + if payload.correct: + ex["correct"] = ex.get("correct", 0) + 1 + else: + ex["incorrect"] = ex.get("incorrect", 0) + 1 + progress["exercises"][payload.exercise_type] = ex + + # Award coins + if payload.correct: + coins = 3 if payload.first_try else 1 + else: + coins = 0 + progress["coins"] = progress.get("coins", 0) + coins + + # Update streak + today = date.today().isoformat() + last = progress.get("last_activity") + if last != today: + if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None: + progress["streak_days"] = progress.get("streak_days", 0) + 1 + elif last != today: + progress["streak_days"] = 1 + progress["last_activity"] = today + + # Award crowns for milestones + total_correct = sum( + e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict) + ) + progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers + + _save_progress(unit_id, progress) + + return { + "coins_awarded": coins, + "total_coins": progress["coins"], + "crowns": progress["crowns"], + "streak_days": progress["streak_days"], + } + + +@router.get("/") +def list_all_progress(): + """List progress for all units.""" + _ensure_dir() + results = [] + for f in Path(PROGRESS_DIR).glob("*.json"): + with open(f, "r", encoding="utf-8") as fh: + results.append(json.load(fh)) + return results diff --git a/backend-lehrer/api/school.py b/backend-lehrer/api/school.py new file mode 100644 index 0000000..f49c5be --- /dev/null +++ b/backend-lehrer/api/school.py @@ -0,0 +1,250 @@ +""" +School Service API Proxy +Routes API requests from /api/school/* to the Go school-service +""" + +import os +import jwt +import datetime +import httpx +from fastapi import APIRouter, Request, HTTPException, Response + +# School Service URL +SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084") +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") + +# Demo teacher UUID for development mode +DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20" + +router = APIRouter(prefix="/school", tags=["school"]) + + +def get_demo_token() -> str: + """Generate a demo JWT token for development mode""" + payload = { + "user_id": DEMO_TEACHER_ID, + "email": "demo@breakpilot.app", + "role": "admin", + "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24), + "iat": datetime.datetime.now(datetime.timezone.utc) + } + return jwt.encode(payload, JWT_SECRET, algorithm="HS256") + + +async def proxy_request(request: Request, path: str) -> Response: + """Forward a request to the school service""" + url = f"{SCHOOL_SERVICE_URL}/api/v1/school{path}" + + # Forward headers, especially Authorization + headers = {} + if "authorization" in request.headers: + headers["Authorization"] = request.headers["authorization"] + elif ENVIRONMENT == "development": + # In development mode, use demo token if no auth provided + demo_token = get_demo_token() + headers["Authorization"] = f"Bearer {demo_token}" + if "content-type" in request.headers: + headers["Content-Type"] = request.headers["content-type"] + + # Get request body for POST/PUT/PATCH + body = None + if request.method in ("POST", "PUT", "PATCH"): + body = await request.body() + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.request( + method=request.method, + url=url, + headers=headers, + content=body, + params=request.query_params + ) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type", "application/json") + ) + except httpx.ConnectError: + raise HTTPException( + status_code=503, + detail="School service unavailable" + ) + except httpx.TimeoutException: + raise HTTPException( + status_code=504, + detail="School service timeout" + ) + + +# Health check +@router.get("/health") +async def health(): + """Health check for school service connection""" + async with httpx.AsyncClient(timeout=5.0) as client: + try: + response = await client.get(f"{SCHOOL_SERVICE_URL}/health") + return {"school_service": "healthy", "connected": response.status_code == 200} + except Exception: + return {"school_service": "unhealthy", "connected": False} + + +# School Years +@router.api_route("/years", methods=["GET", "POST"]) +async def years(request: Request): + return await proxy_request(request, "/years") + + +@router.api_route("/years/{year_id}", methods=["GET", "PUT", "DELETE"]) +async def year_by_id(request: Request, year_id: str): + return await proxy_request(request, f"/years/{year_id}") + + +# Classes +@router.api_route("/classes", methods=["GET", "POST"]) +async def classes(request: Request): + return await proxy_request(request, "/classes") + + +@router.api_route("/classes/{class_id}", methods=["GET", "PUT", "DELETE"]) +async def class_by_id(request: Request, class_id: str): + return await proxy_request(request, f"/classes/{class_id}") + + +# Students +@router.api_route("/classes/{class_id}/students", methods=["GET", "POST"]) +async def students(request: Request, class_id: str): + return await proxy_request(request, f"/classes/{class_id}/students") + + +@router.api_route("/classes/{class_id}/students/import", methods=["POST"]) +async def import_students(request: Request, class_id: str): + return await proxy_request(request, f"/classes/{class_id}/students/import") + + +@router.api_route("/classes/{class_id}/students/{student_id}", methods=["GET", "PUT", "DELETE"]) +async def student_by_id(request: Request, class_id: str, student_id: str): + return await proxy_request(request, f"/classes/{class_id}/students/{student_id}") + + +# Subjects +@router.api_route("/subjects", methods=["GET", "POST"]) +async def subjects(request: Request): + return await proxy_request(request, "/subjects") + + +@router.api_route("/subjects/{subject_id}", methods=["GET", "PUT", "DELETE"]) +async def subject_by_id(request: Request, subject_id: str): + return await proxy_request(request, f"/subjects/{subject_id}") + + +# Exams +@router.api_route("/exams", methods=["GET", "POST"]) +async def exams(request: Request): + return await proxy_request(request, "/exams") + + +@router.api_route("/exams/{exam_id}", methods=["GET", "PUT", "DELETE"]) +async def exam_by_id(request: Request, exam_id: str): + return await proxy_request(request, f"/exams/{exam_id}") + + +@router.api_route("/exams/{exam_id}/results", methods=["GET", "POST"]) +async def exam_results(request: Request, exam_id: str): + return await proxy_request(request, f"/exams/{exam_id}/results") + + +@router.api_route("/exams/{exam_id}/results/{student_id}", methods=["PUT"]) +async def exam_result_update(request: Request, exam_id: str, student_id: str): + return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}") + + +@router.api_route("/exams/{exam_id}/results/{student_id}/approve", methods=["PUT"]) +async def approve_result(request: Request, exam_id: str, student_id: str): + return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}/approve") + + +@router.api_route("/exams/{exam_id}/generate-variant", methods=["POST"]) +async def generate_variant(request: Request, exam_id: str): + return await proxy_request(request, f"/exams/{exam_id}/generate-variant") + + +# Grades +@router.api_route("/grades/{class_id}", methods=["GET"]) +async def grades_by_class(request: Request, class_id: str): + return await proxy_request(request, f"/grades/{class_id}") + + +@router.api_route("/grades/student/{student_id}", methods=["GET"]) +async def grades_by_student(request: Request, student_id: str): + return await proxy_request(request, f"/grades/student/{student_id}") + + +@router.api_route("/grades/{student_id}/{subject_id}/oral", methods=["PUT"]) +async def update_oral_grade(request: Request, student_id: str, subject_id: str): + return await proxy_request(request, f"/grades/{student_id}/{subject_id}/oral") + + +@router.api_route("/grades/calculate", methods=["POST"]) +async def calculate_grades(request: Request): + return await proxy_request(request, "/grades/calculate") + + +# Attendance +@router.api_route("/attendance/{class_id}", methods=["GET"]) +async def attendance_by_class(request: Request, class_id: str): + return await proxy_request(request, f"/attendance/{class_id}") + + +@router.api_route("/attendance", methods=["POST"]) +async def create_attendance(request: Request): + return await proxy_request(request, "/attendance") + + +@router.api_route("/attendance/bulk", methods=["POST"]) +async def bulk_attendance(request: Request): + return await proxy_request(request, "/attendance/bulk") + + +# Gradebook +@router.api_route("/gradebook/{class_id}", methods=["GET"]) +async def gradebook_by_class(request: Request, class_id: str): + return await proxy_request(request, f"/gradebook/{class_id}") + + +@router.api_route("/gradebook", methods=["POST"]) +async def create_gradebook_entry(request: Request): + return await proxy_request(request, "/gradebook") + + +@router.api_route("/gradebook/{entry_id}", methods=["DELETE"]) +async def delete_gradebook_entry(request: Request, entry_id: str): + return await proxy_request(request, f"/gradebook/{entry_id}") + + +# Certificates +@router.api_route("/certificates/templates", methods=["GET"]) +async def certificate_templates(request: Request): + return await proxy_request(request, "/certificates/templates") + + +@router.api_route("/certificates/generate", methods=["POST"]) +async def generate_certificate(request: Request): + return await proxy_request(request, "/certificates/generate") + + +@router.api_route("/certificates/{certificate_id}", methods=["GET"]) +async def certificate_by_id(request: Request, certificate_id: str): + return await proxy_request(request, f"/certificates/{certificate_id}") + + +@router.api_route("/certificates/{certificate_id}/pdf", methods=["GET"]) +async def certificate_pdf(request: Request, certificate_id: str): + return await proxy_request(request, f"/certificates/{certificate_id}/pdf") + + +@router.api_route("/certificates/{certificate_id}/finalize", methods=["PUT"]) +async def finalize_certificate(request: Request, certificate_id: str): + return await proxy_request(request, f"/certificates/{certificate_id}/finalize") diff --git a/backend-lehrer/api/user_language.py b/backend-lehrer/api/user_language.py new file mode 100644 index 0000000..b498aa6 --- /dev/null +++ b/backend-lehrer/api/user_language.py @@ -0,0 +1,86 @@ +""" +User Language Preferences API — Stores native language + learning level. + +Each user (student, parent, teacher) can set their native language. +This drives: UI language, third-language display in flashcards, +parent portal language, and translation generation. + +Supported languages: de, en, tr, ar, uk, ru, pl +""" + +import logging +import os +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/user", tags=["user-language"]) + +# Supported native languages with metadata +SUPPORTED_LANGUAGES = { + "de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False}, + "en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False}, + "tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False}, + "ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True}, + "uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False}, + "ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False}, + "pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False}, +} + +# In-memory store (will be replaced with DB later) +_preferences: Dict[str, Dict[str, Any]] = {} + + +class LanguagePreference(BaseModel): + native_language: str # ISO 639-1 code + role: str = "student" # student, parent, teacher + learning_level: str = "A1" # A1, A2, B1, B2, C1 + + +@router.get("/languages") +def get_supported_languages(): + """List all supported native languages with metadata.""" + return { + "languages": [ + {"code": code, **meta} + for code, meta in SUPPORTED_LANGUAGES.items() + ] + } + + +@router.get("/language-preference") +def get_language_preference(user_id: str = Query("default")): + """Get user's language preference.""" + pref = _preferences.get(user_id) + if not pref: + return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True} + return {**pref, "is_default": False} + + +@router.put("/language-preference") +def set_language_preference( + pref: LanguagePreference, + user_id: str = Query("default"), +): + """Set user's native language and learning level.""" + if pref.native_language not in SUPPORTED_LANGUAGES: + raise HTTPException( + status_code=400, + detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. " + f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}", + ) + + _preferences[user_id] = { + "user_id": user_id, + "native_language": pref.native_language, + "role": pref.role, + "learning_level": pref.learning_level, + } + + lang_meta = SUPPORTED_LANGUAGES[pref.native_language] + logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})") + + return {**_preferences[user_id], "language_meta": lang_meta} diff --git a/backend-lehrer/audio_service.py b/backend-lehrer/audio_service.py index d1ee9ca..310c9ac 100644 --- a/backend-lehrer/audio_service.py +++ b/backend-lehrer/audio_service.py @@ -1,125 +1,4 @@ -""" -Audio Service — Generates TTS audio for vocabulary words. - -Uses the Piper TTS service (compliance-tts-service, MIT license) -for high-quality German (Thorsten) and English (Lessac) voices. -Falls back to a placeholder response if TTS service is unavailable. - -Audio files are cached — generated once, served forever. -""" - -import hashlib -import logging -import os -from typing import Optional - -import httpx - -logger = logging.getLogger(__name__) - -# Piper TTS service (runs in compliance stack) -TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://bp-compliance-tts:8095") - -# Local cache directory for generated audio -AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache") - - -def _ensure_cache_dir(): - os.makedirs(AUDIO_CACHE_DIR, exist_ok=True) - - -def _cache_key(text: str, language: str) -> str: - """Generate a deterministic cache key for text + language.""" - h = hashlib.sha256(f"{language}:{text}".encode()).hexdigest()[:16] - return f"{language}_{h}" - - -def _cache_path(text: str, language: str) -> str: - """Full path to cached MP3 file.""" - _ensure_cache_dir() - return os.path.join(AUDIO_CACHE_DIR, f"{_cache_key(text, language)}.mp3") - - -async def synthesize_word( - text: str, - language: str = "de", - word_id: str = "", -) -> Optional[str]: - """ - Generate TTS audio for a word or short phrase. - - Returns the file path to the cached MP3, or None on error. - Uses Piper TTS service (compliance-tts-service). - """ - # Check cache first - cached = _cache_path(text, language) - if os.path.exists(cached): - return cached - - # Call Piper TTS service - try: - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.post( - f"{TTS_SERVICE_URL}/synthesize", - json={ - "text": text, - "language": language, - "voice": "thorsten-high" if language == "de" else "lessac-high", - "module_id": "vocabulary", - "content_id": word_id or _cache_key(text, language), - }, - ) - if resp.status_code != 200: - logger.warning(f"TTS service returned {resp.status_code} for '{text}'") - return None - - data = resp.json() - audio_url = data.get("audio_url") or data.get("presigned_url") - - if audio_url: - # Download the audio file - audio_resp = await client.get(audio_url) - if audio_resp.status_code == 200: - with open(cached, "wb") as f: - f.write(audio_resp.content) - logger.info(f"TTS cached: '{text}' ({language}) → {cached}") - return cached - - except Exception as e: - logger.warning(f"TTS service unavailable: {e}") - - # Fallback: try direct MP3 endpoint - try: - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.post( - f"{TTS_SERVICE_URL}/synthesize/mp3", - json={ - "text": text, - "language": language, - "voice": "thorsten-high" if language == "de" else "lessac-high", - "module_id": "vocabulary", - }, - ) - if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"): - with open(cached, "wb") as f: - f.write(resp.content) - logger.info(f"TTS cached (direct): '{text}' ({language}) → {cached}") - return cached - except Exception as e: - logger.debug(f"TTS direct fallback also failed: {e}") - - return None - - -async def get_or_generate_audio( - text: str, language: str = "de", word_id: str = "", -) -> Optional[bytes]: - """ - Get audio bytes for a word. Returns MP3 bytes or None. - Generates via TTS if not cached. - """ - path = await synthesize_word(text, language, word_id) - if path and os.path.exists(path): - with open(path, "rb") as f: - return f.read() - return None +# Backward-compat shim -- module moved to services/audio.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("services.audio") diff --git a/backend-lehrer/classroom/state_engine_api.py b/backend-lehrer/classroom/state_engine_api.py new file mode 100644 index 0000000..9d44126 --- /dev/null +++ b/backend-lehrer/classroom/state_engine_api.py @@ -0,0 +1,410 @@ +""" +State Engine API - REST API für Begleiter-Modus. + +Endpoints: +- GET /api/state/context - TeacherContext abrufen +- GET /api/state/suggestions - Vorschläge abrufen +- GET /api/state/dashboard - Dashboard-Daten +- POST /api/state/milestone - Meilenstein abschließen +- POST /api/state/transition - Phasen-Übergang +""" + +import logging +import uuid +from datetime import datetime, timedelta +from typing import Dict, Any, List + +from fastapi import APIRouter, HTTPException, Query + +from state_engine import ( + AnticipationEngine, + PhaseService, + SchoolYearPhase, + ClassSummary, + Event, + get_phase_info, +) +from .state_engine_models import ( + MilestoneRequest, + TransitionRequest, + ContextResponse, + SuggestionsResponse, + DashboardResponse, + _teacher_contexts, + _milestones, + get_or_create_context, + update_context_from_services, + get_phase_display_name, +) + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/state", + tags=["state-engine"], +) + +# Singleton instances +_engine = AnticipationEngine() +_phase_service = PhaseService() + + +# ============================================================================ +# API Endpoints +# ============================================================================ + +@router.get("/context", response_model=ContextResponse) +async def get_teacher_context(teacher_id: str = Query("demo-teacher")): + """Gibt den aggregierten TeacherContext zurück.""" + ctx = get_or_create_context(teacher_id) + ctx = update_context_from_services(ctx) + + phase_info = get_phase_info(ctx.current_phase) + + return ContextResponse( + context=ctx.to_dict(), + phase_info={ + "phase": phase_info.phase.value, + "display_name": phase_info.display_name, + "description": phase_info.description, + "typical_months": phase_info.typical_months, + "required_actions": phase_info.required_actions, + "optional_actions": phase_info.optional_actions, + } + ) + + +@router.get("/phase") +async def get_current_phase(teacher_id: str = Query("demo-teacher")): + """Gibt die aktuelle Phase mit Details zurück.""" + ctx = get_or_create_context(teacher_id) + phase_info = get_phase_info(ctx.current_phase) + + return { + "current_phase": ctx.current_phase.value, + "phase_info": { + "display_name": phase_info.display_name, + "description": phase_info.description, + "expected_duration_weeks": phase_info.expected_duration_weeks, + }, + "days_in_phase": ctx.days_in_phase, + "progress": _phase_service.get_progress_percentage(ctx), + } + + +@router.get("/phases") +async def get_all_phases(): + """Gibt alle Phasen mit Metadaten zurück.""" + return { + "phases": _phase_service.get_all_phases() + } + + +@router.get("/suggestions", response_model=SuggestionsResponse) +async def get_suggestions(teacher_id: str = Query("demo-teacher")): + """Gibt Vorschläge basierend auf dem aktuellen Kontext zurück.""" + ctx = get_or_create_context(teacher_id) + ctx = update_context_from_services(ctx) + + suggestions = _engine.get_suggestions(ctx) + priority_counts = _engine.count_by_priority(ctx) + + return SuggestionsResponse( + suggestions=[s.to_dict() for s in suggestions], + current_phase=ctx.current_phase.value, + phase_display_name=get_phase_display_name(ctx.current_phase.value), + priority_counts=priority_counts, + ) + + +@router.get("/suggestions/top") +async def get_top_suggestion(teacher_id: str = Query("demo-teacher")): + """Gibt den wichtigsten einzelnen Vorschlag zurück.""" + ctx = get_or_create_context(teacher_id) + ctx = update_context_from_services(ctx) + + suggestion = _engine.get_top_suggestion(ctx) + + if not suggestion: + return { + "suggestion": None, + "message": "Alles erledigt! Keine offenen Aufgaben." + } + + return { + "suggestion": suggestion.to_dict() + } + + +@router.get("/dashboard", response_model=DashboardResponse) +async def get_dashboard_data(teacher_id: str = Query("demo-teacher")): + """Gibt alle Daten für das Begleiter-Dashboard zurück.""" + ctx = get_or_create_context(teacher_id) + ctx = update_context_from_services(ctx) + + suggestions = _engine.get_suggestions(ctx) + phase_info = get_phase_info(ctx.current_phase) + + required = set(phase_info.required_actions) + completed = set(ctx.completed_milestones) + completed_in_phase = len(required.intersection(completed)) + + all_phases = [] + phase_order = [ + SchoolYearPhase.ONBOARDING, + SchoolYearPhase.SCHOOL_YEAR_START, + SchoolYearPhase.TEACHING_SETUP, + SchoolYearPhase.PERFORMANCE_1, + SchoolYearPhase.SEMESTER_END, + SchoolYearPhase.TEACHING_2, + SchoolYearPhase.PERFORMANCE_2, + SchoolYearPhase.YEAR_END, + ] + + current_idx = phase_order.index(ctx.current_phase) if ctx.current_phase in phase_order else 0 + + for i, phase in enumerate(phase_order): + info = get_phase_info(phase) + all_phases.append({ + "phase": phase.value, + "display_name": info.display_name, + "short_name": info.display_name[:10], + "is_current": phase == ctx.current_phase, + "is_completed": i < current_idx, + "is_future": i > current_idx, + }) + + return DashboardResponse( + context={ + "current_phase": ctx.current_phase.value, + "phase_display_name": phase_info.display_name, + "phase_description": phase_info.description, + "weeks_since_start": ctx.weeks_since_start, + "days_in_phase": ctx.days_in_phase, + "federal_state": ctx.federal_state, + "school_type": ctx.school_type, + }, + suggestions=[s.to_dict() for s in suggestions], + stats={ + "learning_units_created": ctx.stats.learning_units_created, + "exams_scheduled": ctx.stats.exams_scheduled, + "exams_graded": ctx.stats.exams_graded, + "grades_entered": ctx.stats.grades_entered, + "classes_count": len(ctx.classes), + "students_count": ctx.total_students, + }, + upcoming_events=[ + { + "type": e.type, + "title": e.title, + "date": e.date.isoformat(), + "in_days": e.in_days, + "priority": e.priority, + } + for e in ctx.upcoming_events[:5] + ], + progress={ + "completed": completed_in_phase, + "total": len(required), + "percentage": (completed_in_phase / len(required) * 100) if required else 100, + "milestones_completed": list(completed.intersection(required)), + "milestones_pending": list(required - completed), + }, + phases=all_phases, + ) + + +@router.post("/milestone") +async def complete_milestone( + request: MilestoneRequest, + teacher_id: str = Query("demo-teacher") +): + """Markiert einen Meilenstein als erledigt.""" + milestone = request.milestone + + if teacher_id not in _milestones: + _milestones[teacher_id] = [] + + if milestone not in _milestones[teacher_id]: + _milestones[teacher_id].append(milestone) + logger.info(f"Milestone '{milestone}' completed for teacher {teacher_id}") + + ctx = get_or_create_context(teacher_id) + ctx.completed_milestones = _milestones[teacher_id] + _teacher_contexts[teacher_id] = ctx + + new_phase = _phase_service.check_and_transition(ctx) + + if new_phase: + ctx.current_phase = new_phase + ctx.phase_entered_at = datetime.now() + ctx.days_in_phase = 0 + _teacher_contexts[teacher_id] = ctx + logger.info(f"Auto-transitioned to {new_phase} for teacher {teacher_id}") + + return { + "success": True, + "milestone": milestone, + "new_phase": new_phase.value if new_phase else None, + "current_phase": ctx.current_phase.value, + "completed_milestones": ctx.completed_milestones, + } + + +@router.post("/transition") +async def transition_phase( + request: TransitionRequest, + teacher_id: str = Query("demo-teacher") +): + """Führt einen manuellen Phasen-Übergang durch.""" + try: + target_phase = SchoolYearPhase(request.target_phase) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Ungültige Phase: {request.target_phase}" + ) + + ctx = get_or_create_context(teacher_id) + + if not _phase_service.can_transition_to(ctx, target_phase): + raise HTTPException( + status_code=400, + detail=f"Übergang von {ctx.current_phase.value} zu {target_phase.value} nicht erlaubt" + ) + + old_phase = ctx.current_phase + ctx.current_phase = target_phase + ctx.phase_entered_at = datetime.now() + ctx.days_in_phase = 0 + _teacher_contexts[teacher_id] = ctx + + logger.info(f"Manual transition from {old_phase} to {target_phase} for teacher {teacher_id}") + + return { + "success": True, + "old_phase": old_phase.value, + "new_phase": target_phase.value, + "phase_info": get_phase_info(target_phase).__dict__, + } + + +@router.get("/next-phase") +async def get_next_phase(teacher_id: str = Query("demo-teacher")): + """Gibt die nächste Phase und Anforderungen zurück.""" + ctx = get_or_create_context(teacher_id) + next_phase = _phase_service.get_next_phase(ctx.current_phase) + + if not next_phase: + return { + "next_phase": None, + "message": "Letzte Phase erreicht" + } + + can_transition = _phase_service.can_transition_to(ctx, next_phase) + next_info = get_phase_info(next_phase) + current_info = get_phase_info(ctx.current_phase) + + missing = [ + m for m in current_info.required_actions + if m not in ctx.completed_milestones + ] + + return { + "current_phase": ctx.current_phase.value, + "next_phase": next_phase.value, + "next_phase_info": { + "display_name": next_info.display_name, + "description": next_info.description, + }, + "can_transition": can_transition, + "missing_requirements": missing, + } + + +# ============================================================================ +# Demo Data Endpoints (nur für Entwicklung) +# ============================================================================ + +@router.post("/demo/add-class") +async def demo_add_class( + name: str = Query(...), + grade_level: int = Query(...), + student_count: int = Query(25), + teacher_id: str = Query("demo-teacher") +): + """Demo: Fügt eine Klasse zum Kontext hinzu.""" + ctx = get_or_create_context(teacher_id) + + ctx.classes.append(ClassSummary( + class_id=str(uuid.uuid4()), + name=name, + grade_level=grade_level, + student_count=student_count, + subject="Deutsch" + )) + ctx.total_students += student_count + _teacher_contexts[teacher_id] = ctx + + return {"success": True, "classes": len(ctx.classes)} + + +@router.post("/demo/add-event") +async def demo_add_event( + event_type: str = Query(...), + title: str = Query(...), + in_days: int = Query(...), + teacher_id: str = Query("demo-teacher") +): + """Demo: Fügt ein Event zum Kontext hinzu.""" + ctx = get_or_create_context(teacher_id) + + ctx.upcoming_events.append(Event( + type=event_type, + title=title, + date=datetime.now() + timedelta(days=in_days), + in_days=in_days, + priority="high" if in_days <= 3 else "medium" + )) + _teacher_contexts[teacher_id] = ctx + + return {"success": True, "events": len(ctx.upcoming_events)} + + +@router.post("/demo/update-stats") +async def demo_update_stats( + learning_units: int = Query(0), + exams_scheduled: int = Query(0), + exams_graded: int = Query(0), + grades_entered: int = Query(0), + unanswered_messages: int = Query(0), + teacher_id: str = Query("demo-teacher") +): + """Demo: Aktualisiert Statistiken.""" + ctx = get_or_create_context(teacher_id) + + if learning_units: + ctx.stats.learning_units_created = learning_units + if exams_scheduled: + ctx.stats.exams_scheduled = exams_scheduled + if exams_graded: + ctx.stats.exams_graded = exams_graded + if grades_entered: + ctx.stats.grades_entered = grades_entered + if unanswered_messages: + ctx.stats.unanswered_messages = unanswered_messages + + _teacher_contexts[teacher_id] = ctx + + return {"success": True, "stats": ctx.stats.__dict__} + + +@router.post("/demo/reset") +async def demo_reset(teacher_id: str = Query("demo-teacher")): + """Demo: Setzt den Kontext zurück.""" + if teacher_id in _teacher_contexts: + del _teacher_contexts[teacher_id] + if teacher_id in _milestones: + del _milestones[teacher_id] + + return {"success": True, "message": "Kontext zurückgesetzt"} diff --git a/backend-lehrer/classroom/state_engine_models.py b/backend-lehrer/classroom/state_engine_models.py new file mode 100644 index 0000000..778b2f7 --- /dev/null +++ b/backend-lehrer/classroom/state_engine_models.py @@ -0,0 +1,143 @@ +""" +State Engine API - Pydantic Models und Helper Functions. +""" + +import uuid +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional + +from pydantic import BaseModel, Field + +from state_engine import ( + SchoolYearPhase, + ClassSummary, + Event, + TeacherContext, + TeacherStats, + get_phase_info, +) + + +# ============================================================================ +# In-Memory Storage (später durch DB ersetzen) +# ============================================================================ + +_teacher_contexts: Dict[str, TeacherContext] = {} +_milestones: Dict[str, List[str]] = {} # teacher_id -> milestones + + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +class MilestoneRequest(BaseModel): + """Request zum Abschließen eines Meilensteins.""" + milestone: str = Field(..., description="Name des Meilensteins") + + +class TransitionRequest(BaseModel): + """Request für Phasen-Übergang.""" + target_phase: str = Field(..., description="Zielphase") + + +class ContextResponse(BaseModel): + """Response mit TeacherContext.""" + context: Dict[str, Any] + phase_info: Dict[str, Any] + + +class SuggestionsResponse(BaseModel): + """Response mit Vorschlägen.""" + suggestions: List[Dict[str, Any]] + current_phase: str + phase_display_name: str + priority_counts: Dict[str, int] + + +class DashboardResponse(BaseModel): + """Response mit Dashboard-Daten.""" + context: Dict[str, Any] + suggestions: List[Dict[str, Any]] + stats: Dict[str, Any] + upcoming_events: List[Dict[str, Any]] + progress: Dict[str, Any] + phases: List[Dict[str, Any]] + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def get_or_create_context(teacher_id: str) -> TeacherContext: + """ + Holt oder erstellt TeacherContext. + + In Produktion würde dies aus der Datenbank geladen. + """ + if teacher_id not in _teacher_contexts: + now = datetime.now() + school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1) + weeks_since_start = (now - school_year_start).days // 7 + + month = now.month + if month in [8, 9]: + phase = SchoolYearPhase.SCHOOL_YEAR_START + elif month in [10, 11]: + phase = SchoolYearPhase.TEACHING_SETUP + elif month == 12: + phase = SchoolYearPhase.PERFORMANCE_1 + elif month in [1, 2]: + phase = SchoolYearPhase.SEMESTER_END + elif month in [3, 4]: + phase = SchoolYearPhase.TEACHING_2 + elif month in [5, 6]: + phase = SchoolYearPhase.PERFORMANCE_2 + else: + phase = SchoolYearPhase.YEAR_END + + _teacher_contexts[teacher_id] = TeacherContext( + teacher_id=teacher_id, + school_id=str(uuid.uuid4()), + school_year_id=str(uuid.uuid4()), + federal_state="niedersachsen", + school_type="gymnasium", + school_year_start=school_year_start, + current_phase=phase, + phase_entered_at=now - timedelta(days=7), + weeks_since_start=weeks_since_start, + days_in_phase=7, + classes=[], + total_students=0, + upcoming_events=[], + completed_milestones=_milestones.get(teacher_id, []), + pending_milestones=[], + stats=TeacherStats(), + ) + + return _teacher_contexts[teacher_id] + + +def update_context_from_services(ctx: TeacherContext) -> TeacherContext: + """ + Aktualisiert Kontext mit Daten aus anderen Services. + + In Produktion würde dies von school-service, gradebook etc. laden. + """ + ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days + ctx.completed_milestones = _milestones.get(ctx.teacher_id, []) + + phase_info = get_phase_info(ctx.current_phase) + ctx.pending_milestones = [ + m for m in phase_info.required_actions + if m not in ctx.completed_milestones + ] + + return ctx + + +def get_phase_display_name(phase: str) -> str: + """Gibt Display-Name für Phase zurück.""" + try: + return get_phase_info(SchoolYearPhase(phase)).display_name + except (ValueError, KeyError): + return phase diff --git a/backend-lehrer/claude_vision.py b/backend-lehrer/claude_vision.py index c95cb6a..cb900a6 100644 --- a/backend-lehrer/claude_vision.py +++ b/backend-lehrer/claude_vision.py @@ -1,299 +1,4 @@ -""" -Claude Vision API Integration for Worksheet Analysis - -Uses Anthropic's Claude 3.5 Sonnet for superior OCR and layout understanding. -""" - -import os -import base64 -import json -from pathlib import Path -from typing import Dict, Optional -import logging - -logger = logging.getLogger(__name__) - -# Try to import Anthropic SDK -try: - from anthropic import Anthropic - ANTHROPIC_AVAILABLE = True -except ImportError: - ANTHROPIC_AVAILABLE = False - logger.warning("Anthropic SDK not installed. Run: pip install anthropic") - - -def _get_anthropic_api_key() -> str: - """Get Anthropic API key from environment variable""" - api_key = os.getenv("ANTHROPIC_API_KEY") - if not api_key: - raise RuntimeError( - "ANTHROPIC_API_KEY ist nicht gesetzt. " - "Bitte API-Schlüssel als Umgebungsvariable setzen:\n" - "export ANTHROPIC_API_KEY='sk-ant-api03-...'" - ) - return api_key - - -def _encode_image_to_base64(image_path: Path) -> tuple[str, str]: - """ - Encode image to base64 for Claude API. - - Returns: - (base64_string, media_type) - """ - image_bytes = image_path.read_bytes() - image_b64 = base64.standard_b64encode(image_bytes).decode("utf-8") - - # Determine media type from extension - ext = image_path.suffix.lower() - media_type_map = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp' - } - media_type = media_type_map.get(ext, 'image/jpeg') - - return image_b64, media_type - - -def analyze_worksheet_with_claude( - image_path: Path, - max_tokens: int = 2500, - model: str = "claude-3-5-sonnet-20241022" -) -> Dict: - """ - Analyze worksheet using Claude Vision API. - - Args: - image_path: Path to worksheet image - max_tokens: Maximum tokens in response (default 2500) - model: Claude model to use (default: Claude 3.5 Sonnet) - - Returns: - Analysis dict with same structure as OpenAI version - - Raises: - RuntimeError: If API key not set or SDK not installed - Exception: If API call fails - """ - if not ANTHROPIC_AVAILABLE: - raise RuntimeError("Anthropic SDK nicht installiert. Run: pip install anthropic") - - if not image_path.exists(): - raise FileNotFoundError(f"Image not found: {image_path}") - - # Get API key - api_key = _get_anthropic_api_key() - - # Initialize Anthropic client - client = Anthropic(api_key=api_key) - - # Encode image - image_b64, media_type = _encode_image_to_base64(image_path) - - # System prompt (instructions) - system_prompt = """Du bist ein Experte für die Analyse von Schul-Arbeitsblättern. - -Deine Aufgabe ist es, das Arbeitsblatt detailliert zu analysieren und strukturierte Informationen zu extrahieren: - -1. **Gedruckter Text**: Erkenne den VOLLSTÄNDIGEN gedruckten Text inklusive durchgestrichener Wörter -2. **Handschrift**: Identifiziere alle handschriftlichen Eintragungen (Schülerantworten, Korrekturen, Notizen) -3. **Layout**: Bestimme räumliche Positionen aller Elemente (Bounding Boxes in Pixeln) -4. **Diagramme**: Erkenne gedruckte Illustrationen, Grafiken, Diagramme -5. **Farben**: Klassifiziere Handschrift nach Farbe (blau/schwarz/rot/Bleistift) - -WICHTIG: Gib deine Antwort als gültiges JSON zurück, nicht als Markdown Code Block!""" - - # User prompt with JSON schema - user_prompt = """Analysiere dieses Arbeitsblatt und gib ein JSON mit folgendem Aufbau zurück: - -{ - "title": string | null, - "subject": string | null, - "grade_level": string | null, - "instructions": string | null, - "canonical_text": string | null, - "printed_blocks": [ - { - "id": string, - "role": "title" | "instructions" | "body" | "other", - "text": string - } - ], - "layout": { - "page_structure": { - "has_diagram": boolean, - "orientation": "portrait" | "landscape" - }, - "text_regions": [ - { - "id": string, - "type": "title" | "paragraph" | "list" | "instruction", - "text": string, - "bounding_box": {"x": int, "y": int, "width": int, "height": int}, - "font_characteristics": { - "is_bold": boolean, - "approximate_size": "large" | "medium" | "small" - } - } - ], - "diagram_elements": [ - { - "id": string, - "type": "illustration" | "chart" | "graph" | "shape", - "description": string, - "bounding_box": {"x": int, "y": int, "width": int, "height": int}, - "preserve": boolean - } - ] - }, - "handwriting_regions": [ - { - "id": string, - "text": string, - "type": "student_answer" | "correction" | "note" | "drawing", - "bounding_box": {"x": int, "y": int, "width": int, "height": int}, - "color_hint": "blue" | "black" | "red" | "pencil" | "unknown" - } - ], - "handwritten_annotations": [ - { - "text": string, - "approx_location": string - } - ], - "struck_through_words": [ - { - "text": string, - "context": string - } - ], - "tasks": [ - { - "id": string, - "type": "cloze" | "mcq" | "short_answer" | "math" | "other", - "description": string, - "text_with_gaps": string | null, - "gaps": [ - { - "id": string, - "solution": string, - "position_hint": string - } - ] - } - ] -} - -WICHTIGE HINWEISE: -- "canonical_text" enthält den KORRIGIERTEN gedruckten Text OHNE Handschrift und OHNE durchgestrichene Wörter -- "struck_through_words" enthält alle durchgestrichenen Wörter mit Kontext -- Bounding Boxes sind ungefähre Pixel-Positionen (x, y von oben links, width/height in Pixeln) -- "layout.text_regions" sollte alle gedruckten Textbereiche mit genauen Positionen enthalten -- "handwriting_regions" sollte alle handschriftlichen Bereiche mit Farb-Hinweisen enthalten -- Setze "preserve": true für Diagramm-Elemente die erhalten bleiben sollen -- Durchgestrichene Wörter NUR in "struck_through_words", NICHT in "canonical_text" - -Gib NUR das JSON zurück, ohne Code-Block-Marker!""" - - try: - logger.info(f"Calling Claude API for analysis of {image_path.name}") - - # Call Claude API - response = client.messages.create( - model=model, - max_tokens=max_tokens, - system=system_prompt, - messages=[ - { - "role": "user", - "content": [ - { - "type": "image", - "source": { - "type": "base64", - "media_type": media_type, - "data": image_b64, - }, - }, - { - "type": "text", - "text": user_prompt - } - ], - } - ], - ) - - # Extract text from response - if not response.content: - raise RuntimeError("Empty response from Claude API") - - # Get first text block - text_content = None - for block in response.content: - if block.type == "text": - text_content = block.text - break - - if not text_content: - raise RuntimeError("No text content in Claude response") - - logger.info(f"Received response from Claude ({len(text_content)} chars)") - - # Parse JSON - # Claude might wrap JSON in ```json ... ```, remove if present - text_content = text_content.strip() - if text_content.startswith("```json"): - text_content = text_content[7:] - if text_content.startswith("```"): - text_content = text_content[3:] - if text_content.endswith("```"): - text_content = text_content[:-3] - text_content = text_content.strip() - - try: - analysis_data = json.loads(text_content) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse Claude JSON response: {e}") - logger.error(f"Response text: {text_content[:500]}...") - raise RuntimeError(f"Invalid JSON from Claude: {e}\nContent: {text_content[:200]}...") from e - - logger.info("Successfully parsed Claude analysis") - return analysis_data - - except Exception as e: - logger.error(f"Claude API call failed: {e}") - raise - - -def test_claude_connection() -> bool: - """ - Test if Claude API is accessible with current credentials. - - Returns: - True if connection successful, False otherwise - """ - if not ANTHROPIC_AVAILABLE: - logger.error("Anthropic SDK not installed") - return False - - try: - api_key = _get_anthropic_api_key() - client = Anthropic(api_key=api_key) - - # Simple test call - response = client.messages.create( - model="claude-3-5-sonnet-20241022", - max_tokens=10, - messages=[{"role": "user", "content": "Test"}] - ) - - logger.info("✅ Claude API connection successful") - return True - - except Exception as e: - logger.error(f"❌ Claude API connection failed: {e}") - return False +# Backward-compat shim -- module moved to services/claude_vision.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("services.claude_vision") diff --git a/backend-lehrer/email_service.py b/backend-lehrer/email_service.py index f7defbe..1228f28 100644 --- a/backend-lehrer/email_service.py +++ b/backend-lehrer/email_service.py @@ -1,395 +1,4 @@ -""" -BreakPilot Email Service - -Ermoeglicht den Versand von Emails via SMTP. -Verwendet Mailpit im Entwicklungsmodus. -""" - -import os -import smtplib -import logging -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from email.utils import formataddr -from typing import Optional, List -from dataclasses import dataclass -from datetime import datetime - -logger = logging.getLogger(__name__) - -# SMTP Konfiguration aus Umgebungsvariablen -SMTP_HOST = os.getenv("SMTP_HOST", "localhost") -SMTP_PORT = int(os.getenv("SMTP_PORT", "1025")) -SMTP_USERNAME = os.getenv("SMTP_USERNAME", "") -SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") -SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "BreakPilot") -SMTP_FROM_ADDR = os.getenv("SMTP_FROM_ADDR", "noreply@breakpilot.app") -SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "false").lower() == "true" - - -@dataclass -class EmailResult: - """Ergebnis eines Email-Versands.""" - success: bool - message_id: Optional[str] = None - error: Optional[str] = None - recipient: Optional[str] = None - sent_at: Optional[str] = None - - -class EmailService: - """Service fuer den Email-Versand.""" - - def __init__( - self, - host: str = SMTP_HOST, - port: int = SMTP_PORT, - username: str = SMTP_USERNAME, - password: str = SMTP_PASSWORD, - from_name: str = SMTP_FROM_NAME, - from_addr: str = SMTP_FROM_ADDR, - use_tls: bool = SMTP_USE_TLS - ): - self.host = host - self.port = port - self.username = username - self.password = password - self.from_name = from_name - self.from_addr = from_addr - self.use_tls = use_tls - - def _get_connection(self): - """Erstellt eine SMTP-Verbindung.""" - if self.use_tls: - smtp = smtplib.SMTP_SSL(self.host, self.port) - else: - smtp = smtplib.SMTP(self.host, self.port) - - if self.username and self.password: - smtp.login(self.username, self.password) - - return smtp - - def send_email( - self, - to_email: str, - subject: str, - body_text: str, - body_html: Optional[str] = None, - reply_to: Optional[str] = None, - cc: Optional[List[str]] = None, - bcc: Optional[List[str]] = None - ) -> EmailResult: - """ - Sendet eine Email. - - Args: - to_email: Empfaenger-Email - subject: Betreff - body_text: Plaintext-Inhalt - body_html: Optional HTML-Inhalt - reply_to: Optional Reply-To Adresse - cc: Optional CC-Empfaenger - bcc: Optional BCC-Empfaenger - - Returns: - EmailResult mit Erfolg/Fehler - """ - try: - # Message erstellen - if body_html: - msg = MIMEMultipart("alternative") - msg.attach(MIMEText(body_text, "plain", "utf-8")) - msg.attach(MIMEText(body_html, "html", "utf-8")) - else: - msg = MIMEText(body_text, "plain", "utf-8") - - msg["Subject"] = subject - msg["From"] = formataddr((self.from_name, self.from_addr)) - msg["To"] = to_email - - if reply_to: - msg["Reply-To"] = reply_to - - if cc: - msg["Cc"] = ", ".join(cc) - - # Alle Empfaenger sammeln - recipients = [to_email] - if cc: - recipients.extend(cc) - if bcc: - recipients.extend(bcc) - - # Senden - with self._get_connection() as smtp: - smtp.sendmail(self.from_addr, recipients, msg.as_string()) - - logger.info(f"Email sent to {to_email}: {subject}") - - return EmailResult( - success=True, - recipient=to_email, - sent_at=datetime.utcnow().isoformat() - ) - - except smtplib.SMTPException as e: - logger.error(f"SMTP error sending to {to_email}: {e}") - return EmailResult( - success=False, - error=f"SMTP Fehler: {str(e)}", - recipient=to_email - ) - except Exception as e: - logger.error(f"Error sending email to {to_email}: {e}") - return EmailResult( - success=False, - error=str(e), - recipient=to_email - ) - - def send_messenger_notification( - self, - to_email: str, - to_name: str, - sender_name: str, - message_content: str, - reply_link: Optional[str] = None - ) -> EmailResult: - """ - Sendet eine Messenger-Benachrichtigung per Email. - - Args: - to_email: Empfaenger-Email - to_name: Name des Empfaengers - sender_name: Name des Absenders - message_content: Nachrichteninhalt - reply_link: Optional Link zum Antworten - - Returns: - EmailResult - """ - subject = f"Neue Nachricht von {sender_name} - BreakPilot" - - # Plaintext Version - body_text = f"""Hallo {to_name}, - -Sie haben eine neue Nachricht von {sender_name} erhalten: - ---- -{message_content} ---- - -""" - if reply_link: - body_text += f"Um zu antworten, klicken Sie hier: {reply_link}\n\n" - - body_text += """Mit freundlichen Gruessen -Ihr BreakPilot Team - ---- -Diese E-Mail wurde automatisch versendet. -Bitte antworten Sie nicht direkt auf diese E-Mail. -""" - - # HTML Version - body_html = f""" - - - - - - -
-
-

Neue Nachricht

-
-
-

Hallo {to_name},

-

Sie haben eine neue Nachricht von {sender_name} erhalten:

- -
- {message_content.replace(chr(10), '
')} -
- -""" - if reply_link: - body_html += f'

Nachricht beantworten

' - - body_html += """ -
- -
- - -""" - - return self.send_email( - to_email=to_email, - subject=subject, - body_text=body_text, - body_html=body_html - ) - - def send_jitsi_invitation( - self, - to_email: str, - to_name: str, - organizer_name: str, - meeting_title: str, - meeting_date: str, - meeting_time: str, - jitsi_url: str, - additional_info: Optional[str] = None - ) -> EmailResult: - """ - Sendet eine Jitsi-Meeting-Einladung per Email. - - Args: - to_email: Empfaenger-Email - to_name: Name des Empfaengers - organizer_name: Name des Organisators - meeting_title: Titel des Meetings - meeting_date: Datum des Meetings (z.B. "20. Dezember 2024") - meeting_time: Uhrzeit des Meetings (z.B. "14:00 Uhr") - jitsi_url: Der Jitsi-Meeting-Link - additional_info: Optional zusaetzliche Informationen - - Returns: - EmailResult - """ - subject = f"Einladung: {meeting_title} - {meeting_date}" - - # Plaintext Version - body_text = f"""Hallo {to_name}, - -{organizer_name} laedt Sie zu einem Videogespraech ein. - -TERMIN: {meeting_title} -DATUM: {meeting_date} -UHRZEIT: {meeting_time} - -Treten Sie dem Meeting bei: -{jitsi_url} - -""" - if additional_info: - body_text += f"HINWEISE:\n{additional_info}\n\n" - - body_text += """TECHNISCHE VORAUSSETZUNGEN: -- Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge) -- Keine Installation erforderlich -- Optional: Kopfhoerer fuer bessere Audioqualitaet - -Bei technischen Problemen wenden Sie sich bitte an den Organisator. - -Mit freundlichen Gruessen -Ihr BreakPilot Team -""" - - # HTML Version - body_html = f""" - - - - - - -
-
-

Einladung zum Videogespraech

-

{meeting_title}

-
-
-

Hallo {to_name},

-

{organizer_name} laedt Sie zu einem Videogespraech ein.

- -
-
- Termin: - {meeting_title} -
-
- Datum: - {meeting_date} -
-
- Uhrzeit: - {meeting_time} -
-
- - Meeting beitreten - -""" - if additional_info: - body_html += f""" -
-

Hinweise:

-

{additional_info}

-
-""" - - body_html += """ -
-

Technische Voraussetzungen:

-
    -
  • Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
  • -
  • Keine Installation erforderlich
  • -
  • Optional: Kopfhoerer fuer bessere Audioqualitaet
  • -
-
- -

- Bei technischen Problemen wenden Sie sich bitte an den Organisator. -

-
- -
- - -""" - - return self.send_email( - to_email=to_email, - subject=subject, - body_text=body_text, - body_html=body_html - ) - - -# Globale Instanz -email_service = EmailService() +# Backward-compat shim -- module moved to services/email.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("services.email") diff --git a/backend-lehrer/klausur_service_proxy.py b/backend-lehrer/klausur_service_proxy.py index 0c7b841..90d81cb 100644 --- a/backend-lehrer/klausur_service_proxy.py +++ b/backend-lehrer/klausur_service_proxy.py @@ -1,135 +1,4 @@ -""" -Klausur-Service API Proxy -Routes API requests from /api/klausur/* to the klausur-service microservice -""" - -import os -import jwt -import datetime -import httpx -from fastapi import APIRouter, Request, HTTPException, Response - -# Klausur Service URL -KLAUSUR_SERVICE_URL = os.getenv("KLAUSUR_SERVICE_URL", "http://klausur-service:8086") -JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") -ENVIRONMENT = os.getenv("ENVIRONMENT", "development") - -# Demo teacher UUID for development mode -DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20" - -router = APIRouter(prefix="/klausur", tags=["klausur"]) - - -def get_demo_token() -> str: - """Generate a demo JWT token for development mode""" - payload = { - "user_id": DEMO_TEACHER_ID, - "email": "demo@breakpilot.app", - "role": "admin", - "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24), - "iat": datetime.datetime.now(datetime.timezone.utc) - } - return jwt.encode(payload, JWT_SECRET, algorithm="HS256") - - -async def proxy_request(request: Request, path: str) -> Response: - """Forward a request to the klausur service""" - url = f"{KLAUSUR_SERVICE_URL}/api/v1{path}" - - # Forward headers, especially Authorization - headers = {} - if "authorization" in request.headers: - headers["Authorization"] = request.headers["authorization"] - elif ENVIRONMENT == "development": - # In development mode, use demo token if no auth provided - demo_token = get_demo_token() - headers["Authorization"] = f"Bearer {demo_token}" - if "content-type" in request.headers: - headers["Content-Type"] = request.headers["content-type"] - - # Get request body for POST/PUT/PATCH/DELETE - body = None - if request.method in ("POST", "PUT", "PATCH", "DELETE"): - body = await request.body() - - async with httpx.AsyncClient(timeout=60.0) as client: - try: - response = await client.request( - method=request.method, - url=url, - headers=headers, - content=body, - params=request.query_params - ) - return Response( - content=response.content, - status_code=response.status_code, - headers=dict(response.headers), - media_type=response.headers.get("content-type", "application/json") - ) - except httpx.ConnectError: - raise HTTPException( - status_code=503, - detail="Klausur service unavailable" - ) - except httpx.TimeoutException: - raise HTTPException( - status_code=504, - detail="Klausur service timeout" - ) - - -# Health check -@router.get("/health") -async def health(): - """Health check for klausur service connection""" - async with httpx.AsyncClient(timeout=5.0) as client: - try: - response = await client.get(f"{KLAUSUR_SERVICE_URL}/health") - return {"klausur_service": "healthy", "connected": response.status_code == 200} - except Exception: - return {"klausur_service": "unhealthy", "connected": False} - - -# Klausuren -@router.api_route("/klausuren", methods=["GET", "POST"]) -async def klausuren(request: Request): - return await proxy_request(request, "/klausuren") - - -@router.api_route("/klausuren/{klausur_id}", methods=["GET", "PUT", "DELETE"]) -async def klausur_by_id(klausur_id: str, request: Request): - return await proxy_request(request, f"/klausuren/{klausur_id}") - - -# Students -@router.api_route("/klausuren/{klausur_id}/students", methods=["GET", "POST"]) -async def klausur_students(klausur_id: str, request: Request): - return await proxy_request(request, f"/klausuren/{klausur_id}/students") - - -@router.api_route("/students/{student_id}", methods=["GET", "DELETE"]) -async def student_by_id(student_id: str, request: Request): - return await proxy_request(request, f"/students/{student_id}") - - -# Grading -@router.api_route("/students/{student_id}/criteria", methods=["PUT"]) -async def update_criteria(student_id: str, request: Request): - return await proxy_request(request, f"/students/{student_id}/criteria") - - -@router.api_route("/students/{student_id}/gutachten", methods=["PUT"]) -async def update_gutachten(student_id: str, request: Request): - return await proxy_request(request, f"/students/{student_id}/gutachten") - - -@router.api_route("/students/{student_id}/finalize", methods=["POST"]) -async def finalize_student(student_id: str, request: Request): - return await proxy_request(request, f"/students/{student_id}/finalize") - - -# Grade info -@router.get("/grade-info") -async def grade_info(request: Request): - return await proxy_request(request, "/grade-info") +# Backward-compat shim -- module moved to api/klausur_proxy.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("api.klausur_proxy") diff --git a/backend-lehrer/progress_api.py b/backend-lehrer/progress_api.py index 9b1c7d8..4b5a5d1 100644 --- a/backend-lehrer/progress_api.py +++ b/backend-lehrer/progress_api.py @@ -1,131 +1,4 @@ -""" -Progress API — Tracks student learning progress per unit. - -Stores coins, crowns, streak data, and exercise completion stats. -Uses JSON file storage (same pattern as learning_units.py). -""" - -import os -import json -import logging -from datetime import datetime, date -from typing import Dict, Any, Optional, List -from pathlib import Path - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/progress", - tags=["progress"], -) - -PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress") - - -def _ensure_dir(): - os.makedirs(PROGRESS_DIR, exist_ok=True) - - -def _progress_path(unit_id: str) -> Path: - return Path(PROGRESS_DIR) / f"{unit_id}.json" - - -def _load_progress(unit_id: str) -> Dict[str, Any]: - path = _progress_path(unit_id) - if path.exists(): - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - return { - "unit_id": unit_id, - "coins": 0, - "crowns": 0, - "streak_days": 0, - "last_activity": None, - "exercises": { - "flashcards": {"completed": 0, "correct": 0, "incorrect": 0}, - "quiz": {"completed": 0, "correct": 0, "incorrect": 0}, - "type": {"completed": 0, "correct": 0, "incorrect": 0}, - "story": {"generated": 0}, - }, - "created_at": datetime.now().isoformat(), - } - - -def _save_progress(unit_id: str, data: Dict[str, Any]): - _ensure_dir() - path = _progress_path(unit_id) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -class RewardPayload(BaseModel): - exercise_type: str # flashcards, quiz, type, story - correct: bool = True - first_try: bool = True - - -@router.get("/{unit_id}") -def get_progress(unit_id: str): - """Get learning progress for a unit.""" - return _load_progress(unit_id) - - -@router.post("/{unit_id}/reward") -def add_reward(unit_id: str, payload: RewardPayload): - """Record an exercise result and award coins.""" - progress = _load_progress(unit_id) - - # Update exercise stats - ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0}) - ex["completed"] = ex.get("completed", 0) + 1 - if payload.correct: - ex["correct"] = ex.get("correct", 0) + 1 - else: - ex["incorrect"] = ex.get("incorrect", 0) + 1 - progress["exercises"][payload.exercise_type] = ex - - # Award coins - if payload.correct: - coins = 3 if payload.first_try else 1 - else: - coins = 0 - progress["coins"] = progress.get("coins", 0) + coins - - # Update streak - today = date.today().isoformat() - last = progress.get("last_activity") - if last != today: - if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None: - progress["streak_days"] = progress.get("streak_days", 0) + 1 - elif last != today: - progress["streak_days"] = 1 - progress["last_activity"] = today - - # Award crowns for milestones - total_correct = sum( - e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict) - ) - progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers - - _save_progress(unit_id, progress) - - return { - "coins_awarded": coins, - "total_coins": progress["coins"], - "crowns": progress["crowns"], - "streak_days": progress["streak_days"], - } - - -@router.get("/") -def list_all_progress(): - """List progress for all units.""" - _ensure_dir() - results = [] - for f in Path(PROGRESS_DIR).glob("*.json"): - with open(f, "r", encoding="utf-8") as fh: - results.append(json.load(fh)) - return results +# Backward-compat shim -- module moved to api/progress.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("api.progress") diff --git a/backend-lehrer/school_api.py b/backend-lehrer/school_api.py index f49c5be..89c1426 100644 --- a/backend-lehrer/school_api.py +++ b/backend-lehrer/school_api.py @@ -1,250 +1,4 @@ -""" -School Service API Proxy -Routes API requests from /api/school/* to the Go school-service -""" - -import os -import jwt -import datetime -import httpx -from fastapi import APIRouter, Request, HTTPException, Response - -# School Service URL -SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084") -JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") -ENVIRONMENT = os.getenv("ENVIRONMENT", "development") - -# Demo teacher UUID for development mode -DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20" - -router = APIRouter(prefix="/school", tags=["school"]) - - -def get_demo_token() -> str: - """Generate a demo JWT token for development mode""" - payload = { - "user_id": DEMO_TEACHER_ID, - "email": "demo@breakpilot.app", - "role": "admin", - "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24), - "iat": datetime.datetime.now(datetime.timezone.utc) - } - return jwt.encode(payload, JWT_SECRET, algorithm="HS256") - - -async def proxy_request(request: Request, path: str) -> Response: - """Forward a request to the school service""" - url = f"{SCHOOL_SERVICE_URL}/api/v1/school{path}" - - # Forward headers, especially Authorization - headers = {} - if "authorization" in request.headers: - headers["Authorization"] = request.headers["authorization"] - elif ENVIRONMENT == "development": - # In development mode, use demo token if no auth provided - demo_token = get_demo_token() - headers["Authorization"] = f"Bearer {demo_token}" - if "content-type" in request.headers: - headers["Content-Type"] = request.headers["content-type"] - - # Get request body for POST/PUT/PATCH - body = None - if request.method in ("POST", "PUT", "PATCH"): - body = await request.body() - - async with httpx.AsyncClient(timeout=30.0) as client: - try: - response = await client.request( - method=request.method, - url=url, - headers=headers, - content=body, - params=request.query_params - ) - return Response( - content=response.content, - status_code=response.status_code, - headers=dict(response.headers), - media_type=response.headers.get("content-type", "application/json") - ) - except httpx.ConnectError: - raise HTTPException( - status_code=503, - detail="School service unavailable" - ) - except httpx.TimeoutException: - raise HTTPException( - status_code=504, - detail="School service timeout" - ) - - -# Health check -@router.get("/health") -async def health(): - """Health check for school service connection""" - async with httpx.AsyncClient(timeout=5.0) as client: - try: - response = await client.get(f"{SCHOOL_SERVICE_URL}/health") - return {"school_service": "healthy", "connected": response.status_code == 200} - except Exception: - return {"school_service": "unhealthy", "connected": False} - - -# School Years -@router.api_route("/years", methods=["GET", "POST"]) -async def years(request: Request): - return await proxy_request(request, "/years") - - -@router.api_route("/years/{year_id}", methods=["GET", "PUT", "DELETE"]) -async def year_by_id(request: Request, year_id: str): - return await proxy_request(request, f"/years/{year_id}") - - -# Classes -@router.api_route("/classes", methods=["GET", "POST"]) -async def classes(request: Request): - return await proxy_request(request, "/classes") - - -@router.api_route("/classes/{class_id}", methods=["GET", "PUT", "DELETE"]) -async def class_by_id(request: Request, class_id: str): - return await proxy_request(request, f"/classes/{class_id}") - - -# Students -@router.api_route("/classes/{class_id}/students", methods=["GET", "POST"]) -async def students(request: Request, class_id: str): - return await proxy_request(request, f"/classes/{class_id}/students") - - -@router.api_route("/classes/{class_id}/students/import", methods=["POST"]) -async def import_students(request: Request, class_id: str): - return await proxy_request(request, f"/classes/{class_id}/students/import") - - -@router.api_route("/classes/{class_id}/students/{student_id}", methods=["GET", "PUT", "DELETE"]) -async def student_by_id(request: Request, class_id: str, student_id: str): - return await proxy_request(request, f"/classes/{class_id}/students/{student_id}") - - -# Subjects -@router.api_route("/subjects", methods=["GET", "POST"]) -async def subjects(request: Request): - return await proxy_request(request, "/subjects") - - -@router.api_route("/subjects/{subject_id}", methods=["GET", "PUT", "DELETE"]) -async def subject_by_id(request: Request, subject_id: str): - return await proxy_request(request, f"/subjects/{subject_id}") - - -# Exams -@router.api_route("/exams", methods=["GET", "POST"]) -async def exams(request: Request): - return await proxy_request(request, "/exams") - - -@router.api_route("/exams/{exam_id}", methods=["GET", "PUT", "DELETE"]) -async def exam_by_id(request: Request, exam_id: str): - return await proxy_request(request, f"/exams/{exam_id}") - - -@router.api_route("/exams/{exam_id}/results", methods=["GET", "POST"]) -async def exam_results(request: Request, exam_id: str): - return await proxy_request(request, f"/exams/{exam_id}/results") - - -@router.api_route("/exams/{exam_id}/results/{student_id}", methods=["PUT"]) -async def exam_result_update(request: Request, exam_id: str, student_id: str): - return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}") - - -@router.api_route("/exams/{exam_id}/results/{student_id}/approve", methods=["PUT"]) -async def approve_result(request: Request, exam_id: str, student_id: str): - return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}/approve") - - -@router.api_route("/exams/{exam_id}/generate-variant", methods=["POST"]) -async def generate_variant(request: Request, exam_id: str): - return await proxy_request(request, f"/exams/{exam_id}/generate-variant") - - -# Grades -@router.api_route("/grades/{class_id}", methods=["GET"]) -async def grades_by_class(request: Request, class_id: str): - return await proxy_request(request, f"/grades/{class_id}") - - -@router.api_route("/grades/student/{student_id}", methods=["GET"]) -async def grades_by_student(request: Request, student_id: str): - return await proxy_request(request, f"/grades/student/{student_id}") - - -@router.api_route("/grades/{student_id}/{subject_id}/oral", methods=["PUT"]) -async def update_oral_grade(request: Request, student_id: str, subject_id: str): - return await proxy_request(request, f"/grades/{student_id}/{subject_id}/oral") - - -@router.api_route("/grades/calculate", methods=["POST"]) -async def calculate_grades(request: Request): - return await proxy_request(request, "/grades/calculate") - - -# Attendance -@router.api_route("/attendance/{class_id}", methods=["GET"]) -async def attendance_by_class(request: Request, class_id: str): - return await proxy_request(request, f"/attendance/{class_id}") - - -@router.api_route("/attendance", methods=["POST"]) -async def create_attendance(request: Request): - return await proxy_request(request, "/attendance") - - -@router.api_route("/attendance/bulk", methods=["POST"]) -async def bulk_attendance(request: Request): - return await proxy_request(request, "/attendance/bulk") - - -# Gradebook -@router.api_route("/gradebook/{class_id}", methods=["GET"]) -async def gradebook_by_class(request: Request, class_id: str): - return await proxy_request(request, f"/gradebook/{class_id}") - - -@router.api_route("/gradebook", methods=["POST"]) -async def create_gradebook_entry(request: Request): - return await proxy_request(request, "/gradebook") - - -@router.api_route("/gradebook/{entry_id}", methods=["DELETE"]) -async def delete_gradebook_entry(request: Request, entry_id: str): - return await proxy_request(request, f"/gradebook/{entry_id}") - - -# Certificates -@router.api_route("/certificates/templates", methods=["GET"]) -async def certificate_templates(request: Request): - return await proxy_request(request, "/certificates/templates") - - -@router.api_route("/certificates/generate", methods=["POST"]) -async def generate_certificate(request: Request): - return await proxy_request(request, "/certificates/generate") - - -@router.api_route("/certificates/{certificate_id}", methods=["GET"]) -async def certificate_by_id(request: Request, certificate_id: str): - return await proxy_request(request, f"/certificates/{certificate_id}") - - -@router.api_route("/certificates/{certificate_id}/pdf", methods=["GET"]) -async def certificate_pdf(request: Request, certificate_id: str): - return await proxy_request(request, f"/certificates/{certificate_id}/pdf") - - -@router.api_route("/certificates/{certificate_id}/finalize", methods=["PUT"]) -async def finalize_certificate(request: Request, certificate_id: str): - return await proxy_request(request, f"/certificates/{certificate_id}/finalize") +# Backward-compat shim -- module moved to api/school.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("api.school") diff --git a/backend-lehrer/services/__init__.py b/backend-lehrer/services/__init__.py index 8d932cc..aed3197 100644 --- a/backend-lehrer/services/__init__.py +++ b/backend-lehrer/services/__init__.py @@ -19,4 +19,12 @@ except (ImportError, OSError) as e: FileProcessor = None # type: ignore _file_processor_available = False +# Lazy-loaded service modules (imported on demand to avoid heavy deps at startup): +# .audio — TTS audio generation for vocabulary words +# .email — Email/SMTP service +# .translation — Batch vocabulary translation via Ollama +# .claude_vision — Claude Vision API for worksheet analysis +# .ai_processor — Legacy shim for ai_processor/ package +# .story_generator — Story generation from vocabulary words + __all__ = ["PDFService", "FileProcessor"] diff --git a/backend-lehrer/services/ai_processor.py b/backend-lehrer/services/ai_processor.py new file mode 100644 index 0000000..ba893a4 --- /dev/null +++ b/backend-lehrer/services/ai_processor.py @@ -0,0 +1,81 @@ +""" +AI Processor - Legacy Import Wrapper + +This file provides backward compatibility for code that imports from ai_processor. +All functionality has been moved to the ai_processor/ module. + +Usage (new): + from ai_processor import analyze_scan_structure_with_ai + +Usage (legacy, still works): + from ai_processor import analyze_scan_structure_with_ai +""" + +# Re-export everything from the new modular structure +from ai_processor import ( + # Configuration + BASE_DIR, + EINGANG_DIR, + BEREINIGT_DIR, + VISION_API, + # Utilities (with legacy aliases) + encode_image_to_data_url as _encode_image_to_data_url, + dummy_process_scan, + # Vision - Scan Analysis + analyze_scan_structure_with_ai, + describe_scan_with_ai, + remove_handwriting_from_scan, + build_clean_html_from_analysis, + # Generators - Multiple Choice + generate_mc_from_analysis, + # Generators - Cloze + generate_cloze_from_analysis, + # Generators - Q&A with Leitner + generate_qa_from_analysis, + update_leitner_progress, + get_next_review_items, + # Export - Print Versions + generate_print_version_qa, + generate_print_version_cloze, + generate_print_version_mc, + generate_print_version_worksheet, + # Visualization - Mindmap + generate_mindmap_data, + generate_mindmap_html, + save_mindmap_for_worksheet, +) + +# Legacy function alias +from ai_processor import get_openai_api_key as _get_api_key + +__all__ = [ + # Configuration + "BASE_DIR", + "EINGANG_DIR", + "BEREINIGT_DIR", + "VISION_API", + # Legacy private functions + "_get_api_key", + "_encode_image_to_data_url", + # Vision + "analyze_scan_structure_with_ai", + "describe_scan_with_ai", + "remove_handwriting_from_scan", + "build_clean_html_from_analysis", + "dummy_process_scan", + # Generators + "generate_mc_from_analysis", + "generate_cloze_from_analysis", + "generate_qa_from_analysis", + "update_leitner_progress", + "get_next_review_items", + # Export + "generate_print_version_qa", + "generate_print_version_cloze", + "generate_print_version_mc", + "generate_print_version_worksheet", + # Visualization + "generate_mindmap_data", + "generate_mindmap_html", + "save_mindmap_for_worksheet", +] diff --git a/backend-lehrer/services/audio.py b/backend-lehrer/services/audio.py new file mode 100644 index 0000000..d1ee9ca --- /dev/null +++ b/backend-lehrer/services/audio.py @@ -0,0 +1,125 @@ +""" +Audio Service — Generates TTS audio for vocabulary words. + +Uses the Piper TTS service (compliance-tts-service, MIT license) +for high-quality German (Thorsten) and English (Lessac) voices. +Falls back to a placeholder response if TTS service is unavailable. + +Audio files are cached — generated once, served forever. +""" + +import hashlib +import logging +import os +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +# Piper TTS service (runs in compliance stack) +TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://bp-compliance-tts:8095") + +# Local cache directory for generated audio +AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache") + + +def _ensure_cache_dir(): + os.makedirs(AUDIO_CACHE_DIR, exist_ok=True) + + +def _cache_key(text: str, language: str) -> str: + """Generate a deterministic cache key for text + language.""" + h = hashlib.sha256(f"{language}:{text}".encode()).hexdigest()[:16] + return f"{language}_{h}" + + +def _cache_path(text: str, language: str) -> str: + """Full path to cached MP3 file.""" + _ensure_cache_dir() + return os.path.join(AUDIO_CACHE_DIR, f"{_cache_key(text, language)}.mp3") + + +async def synthesize_word( + text: str, + language: str = "de", + word_id: str = "", +) -> Optional[str]: + """ + Generate TTS audio for a word or short phrase. + + Returns the file path to the cached MP3, or None on error. + Uses Piper TTS service (compliance-tts-service). + """ + # Check cache first + cached = _cache_path(text, language) + if os.path.exists(cached): + return cached + + # Call Piper TTS service + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{TTS_SERVICE_URL}/synthesize", + json={ + "text": text, + "language": language, + "voice": "thorsten-high" if language == "de" else "lessac-high", + "module_id": "vocabulary", + "content_id": word_id or _cache_key(text, language), + }, + ) + if resp.status_code != 200: + logger.warning(f"TTS service returned {resp.status_code} for '{text}'") + return None + + data = resp.json() + audio_url = data.get("audio_url") or data.get("presigned_url") + + if audio_url: + # Download the audio file + audio_resp = await client.get(audio_url) + if audio_resp.status_code == 200: + with open(cached, "wb") as f: + f.write(audio_resp.content) + logger.info(f"TTS cached: '{text}' ({language}) → {cached}") + return cached + + except Exception as e: + logger.warning(f"TTS service unavailable: {e}") + + # Fallback: try direct MP3 endpoint + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{TTS_SERVICE_URL}/synthesize/mp3", + json={ + "text": text, + "language": language, + "voice": "thorsten-high" if language == "de" else "lessac-high", + "module_id": "vocabulary", + }, + ) + if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"): + with open(cached, "wb") as f: + f.write(resp.content) + logger.info(f"TTS cached (direct): '{text}' ({language}) → {cached}") + return cached + except Exception as e: + logger.debug(f"TTS direct fallback also failed: {e}") + + return None + + +async def get_or_generate_audio( + text: str, language: str = "de", word_id: str = "", +) -> Optional[bytes]: + """ + Get audio bytes for a word. Returns MP3 bytes or None. + Generates via TTS if not cached. + """ + path = await synthesize_word(text, language, word_id) + if path and os.path.exists(path): + with open(path, "rb") as f: + return f.read() + return None diff --git a/backend-lehrer/services/claude_vision.py b/backend-lehrer/services/claude_vision.py new file mode 100644 index 0000000..c95cb6a --- /dev/null +++ b/backend-lehrer/services/claude_vision.py @@ -0,0 +1,299 @@ +""" +Claude Vision API Integration for Worksheet Analysis + +Uses Anthropic's Claude 3.5 Sonnet for superior OCR and layout understanding. +""" + +import os +import base64 +import json +from pathlib import Path +from typing import Dict, Optional +import logging + +logger = logging.getLogger(__name__) + +# Try to import Anthropic SDK +try: + from anthropic import Anthropic + ANTHROPIC_AVAILABLE = True +except ImportError: + ANTHROPIC_AVAILABLE = False + logger.warning("Anthropic SDK not installed. Run: pip install anthropic") + + +def _get_anthropic_api_key() -> str: + """Get Anthropic API key from environment variable""" + api_key = os.getenv("ANTHROPIC_API_KEY") + if not api_key: + raise RuntimeError( + "ANTHROPIC_API_KEY ist nicht gesetzt. " + "Bitte API-Schlüssel als Umgebungsvariable setzen:\n" + "export ANTHROPIC_API_KEY='sk-ant-api03-...'" + ) + return api_key + + +def _encode_image_to_base64(image_path: Path) -> tuple[str, str]: + """ + Encode image to base64 for Claude API. + + Returns: + (base64_string, media_type) + """ + image_bytes = image_path.read_bytes() + image_b64 = base64.standard_b64encode(image_bytes).decode("utf-8") + + # Determine media type from extension + ext = image_path.suffix.lower() + media_type_map = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp' + } + media_type = media_type_map.get(ext, 'image/jpeg') + + return image_b64, media_type + + +def analyze_worksheet_with_claude( + image_path: Path, + max_tokens: int = 2500, + model: str = "claude-3-5-sonnet-20241022" +) -> Dict: + """ + Analyze worksheet using Claude Vision API. + + Args: + image_path: Path to worksheet image + max_tokens: Maximum tokens in response (default 2500) + model: Claude model to use (default: Claude 3.5 Sonnet) + + Returns: + Analysis dict with same structure as OpenAI version + + Raises: + RuntimeError: If API key not set or SDK not installed + Exception: If API call fails + """ + if not ANTHROPIC_AVAILABLE: + raise RuntimeError("Anthropic SDK nicht installiert. Run: pip install anthropic") + + if not image_path.exists(): + raise FileNotFoundError(f"Image not found: {image_path}") + + # Get API key + api_key = _get_anthropic_api_key() + + # Initialize Anthropic client + client = Anthropic(api_key=api_key) + + # Encode image + image_b64, media_type = _encode_image_to_base64(image_path) + + # System prompt (instructions) + system_prompt = """Du bist ein Experte für die Analyse von Schul-Arbeitsblättern. + +Deine Aufgabe ist es, das Arbeitsblatt detailliert zu analysieren und strukturierte Informationen zu extrahieren: + +1. **Gedruckter Text**: Erkenne den VOLLSTÄNDIGEN gedruckten Text inklusive durchgestrichener Wörter +2. **Handschrift**: Identifiziere alle handschriftlichen Eintragungen (Schülerantworten, Korrekturen, Notizen) +3. **Layout**: Bestimme räumliche Positionen aller Elemente (Bounding Boxes in Pixeln) +4. **Diagramme**: Erkenne gedruckte Illustrationen, Grafiken, Diagramme +5. **Farben**: Klassifiziere Handschrift nach Farbe (blau/schwarz/rot/Bleistift) + +WICHTIG: Gib deine Antwort als gültiges JSON zurück, nicht als Markdown Code Block!""" + + # User prompt with JSON schema + user_prompt = """Analysiere dieses Arbeitsblatt und gib ein JSON mit folgendem Aufbau zurück: + +{ + "title": string | null, + "subject": string | null, + "grade_level": string | null, + "instructions": string | null, + "canonical_text": string | null, + "printed_blocks": [ + { + "id": string, + "role": "title" | "instructions" | "body" | "other", + "text": string + } + ], + "layout": { + "page_structure": { + "has_diagram": boolean, + "orientation": "portrait" | "landscape" + }, + "text_regions": [ + { + "id": string, + "type": "title" | "paragraph" | "list" | "instruction", + "text": string, + "bounding_box": {"x": int, "y": int, "width": int, "height": int}, + "font_characteristics": { + "is_bold": boolean, + "approximate_size": "large" | "medium" | "small" + } + } + ], + "diagram_elements": [ + { + "id": string, + "type": "illustration" | "chart" | "graph" | "shape", + "description": string, + "bounding_box": {"x": int, "y": int, "width": int, "height": int}, + "preserve": boolean + } + ] + }, + "handwriting_regions": [ + { + "id": string, + "text": string, + "type": "student_answer" | "correction" | "note" | "drawing", + "bounding_box": {"x": int, "y": int, "width": int, "height": int}, + "color_hint": "blue" | "black" | "red" | "pencil" | "unknown" + } + ], + "handwritten_annotations": [ + { + "text": string, + "approx_location": string + } + ], + "struck_through_words": [ + { + "text": string, + "context": string + } + ], + "tasks": [ + { + "id": string, + "type": "cloze" | "mcq" | "short_answer" | "math" | "other", + "description": string, + "text_with_gaps": string | null, + "gaps": [ + { + "id": string, + "solution": string, + "position_hint": string + } + ] + } + ] +} + +WICHTIGE HINWEISE: +- "canonical_text" enthält den KORRIGIERTEN gedruckten Text OHNE Handschrift und OHNE durchgestrichene Wörter +- "struck_through_words" enthält alle durchgestrichenen Wörter mit Kontext +- Bounding Boxes sind ungefähre Pixel-Positionen (x, y von oben links, width/height in Pixeln) +- "layout.text_regions" sollte alle gedruckten Textbereiche mit genauen Positionen enthalten +- "handwriting_regions" sollte alle handschriftlichen Bereiche mit Farb-Hinweisen enthalten +- Setze "preserve": true für Diagramm-Elemente die erhalten bleiben sollen +- Durchgestrichene Wörter NUR in "struck_through_words", NICHT in "canonical_text" + +Gib NUR das JSON zurück, ohne Code-Block-Marker!""" + + try: + logger.info(f"Calling Claude API for analysis of {image_path.name}") + + # Call Claude API + response = client.messages.create( + model=model, + max_tokens=max_tokens, + system=system_prompt, + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": image_b64, + }, + }, + { + "type": "text", + "text": user_prompt + } + ], + } + ], + ) + + # Extract text from response + if not response.content: + raise RuntimeError("Empty response from Claude API") + + # Get first text block + text_content = None + for block in response.content: + if block.type == "text": + text_content = block.text + break + + if not text_content: + raise RuntimeError("No text content in Claude response") + + logger.info(f"Received response from Claude ({len(text_content)} chars)") + + # Parse JSON + # Claude might wrap JSON in ```json ... ```, remove if present + text_content = text_content.strip() + if text_content.startswith("```json"): + text_content = text_content[7:] + if text_content.startswith("```"): + text_content = text_content[3:] + if text_content.endswith("```"): + text_content = text_content[:-3] + text_content = text_content.strip() + + try: + analysis_data = json.loads(text_content) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse Claude JSON response: {e}") + logger.error(f"Response text: {text_content[:500]}...") + raise RuntimeError(f"Invalid JSON from Claude: {e}\nContent: {text_content[:200]}...") from e + + logger.info("Successfully parsed Claude analysis") + return analysis_data + + except Exception as e: + logger.error(f"Claude API call failed: {e}") + raise + + +def test_claude_connection() -> bool: + """ + Test if Claude API is accessible with current credentials. + + Returns: + True if connection successful, False otherwise + """ + if not ANTHROPIC_AVAILABLE: + logger.error("Anthropic SDK not installed") + return False + + try: + api_key = _get_anthropic_api_key() + client = Anthropic(api_key=api_key) + + # Simple test call + response = client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=10, + messages=[{"role": "user", "content": "Test"}] + ) + + logger.info("✅ Claude API connection successful") + return True + + except Exception as e: + logger.error(f"❌ Claude API connection failed: {e}") + return False diff --git a/backend-lehrer/services/email.py b/backend-lehrer/services/email.py new file mode 100644 index 0000000..f7defbe --- /dev/null +++ b/backend-lehrer/services/email.py @@ -0,0 +1,395 @@ +""" +BreakPilot Email Service + +Ermoeglicht den Versand von Emails via SMTP. +Verwendet Mailpit im Entwicklungsmodus. +""" + +import os +import smtplib +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr +from typing import Optional, List +from dataclasses import dataclass +from datetime import datetime + +logger = logging.getLogger(__name__) + +# SMTP Konfiguration aus Umgebungsvariablen +SMTP_HOST = os.getenv("SMTP_HOST", "localhost") +SMTP_PORT = int(os.getenv("SMTP_PORT", "1025")) +SMTP_USERNAME = os.getenv("SMTP_USERNAME", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "BreakPilot") +SMTP_FROM_ADDR = os.getenv("SMTP_FROM_ADDR", "noreply@breakpilot.app") +SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "false").lower() == "true" + + +@dataclass +class EmailResult: + """Ergebnis eines Email-Versands.""" + success: bool + message_id: Optional[str] = None + error: Optional[str] = None + recipient: Optional[str] = None + sent_at: Optional[str] = None + + +class EmailService: + """Service fuer den Email-Versand.""" + + def __init__( + self, + host: str = SMTP_HOST, + port: int = SMTP_PORT, + username: str = SMTP_USERNAME, + password: str = SMTP_PASSWORD, + from_name: str = SMTP_FROM_NAME, + from_addr: str = SMTP_FROM_ADDR, + use_tls: bool = SMTP_USE_TLS + ): + self.host = host + self.port = port + self.username = username + self.password = password + self.from_name = from_name + self.from_addr = from_addr + self.use_tls = use_tls + + def _get_connection(self): + """Erstellt eine SMTP-Verbindung.""" + if self.use_tls: + smtp = smtplib.SMTP_SSL(self.host, self.port) + else: + smtp = smtplib.SMTP(self.host, self.port) + + if self.username and self.password: + smtp.login(self.username, self.password) + + return smtp + + def send_email( + self, + to_email: str, + subject: str, + body_text: str, + body_html: Optional[str] = None, + reply_to: Optional[str] = None, + cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None + ) -> EmailResult: + """ + Sendet eine Email. + + Args: + to_email: Empfaenger-Email + subject: Betreff + body_text: Plaintext-Inhalt + body_html: Optional HTML-Inhalt + reply_to: Optional Reply-To Adresse + cc: Optional CC-Empfaenger + bcc: Optional BCC-Empfaenger + + Returns: + EmailResult mit Erfolg/Fehler + """ + try: + # Message erstellen + if body_html: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(body_text, "plain", "utf-8")) + msg.attach(MIMEText(body_html, "html", "utf-8")) + else: + msg = MIMEText(body_text, "plain", "utf-8") + + msg["Subject"] = subject + msg["From"] = formataddr((self.from_name, self.from_addr)) + msg["To"] = to_email + + if reply_to: + msg["Reply-To"] = reply_to + + if cc: + msg["Cc"] = ", ".join(cc) + + # Alle Empfaenger sammeln + recipients = [to_email] + if cc: + recipients.extend(cc) + if bcc: + recipients.extend(bcc) + + # Senden + with self._get_connection() as smtp: + smtp.sendmail(self.from_addr, recipients, msg.as_string()) + + logger.info(f"Email sent to {to_email}: {subject}") + + return EmailResult( + success=True, + recipient=to_email, + sent_at=datetime.utcnow().isoformat() + ) + + except smtplib.SMTPException as e: + logger.error(f"SMTP error sending to {to_email}: {e}") + return EmailResult( + success=False, + error=f"SMTP Fehler: {str(e)}", + recipient=to_email + ) + except Exception as e: + logger.error(f"Error sending email to {to_email}: {e}") + return EmailResult( + success=False, + error=str(e), + recipient=to_email + ) + + def send_messenger_notification( + self, + to_email: str, + to_name: str, + sender_name: str, + message_content: str, + reply_link: Optional[str] = None + ) -> EmailResult: + """ + Sendet eine Messenger-Benachrichtigung per Email. + + Args: + to_email: Empfaenger-Email + to_name: Name des Empfaengers + sender_name: Name des Absenders + message_content: Nachrichteninhalt + reply_link: Optional Link zum Antworten + + Returns: + EmailResult + """ + subject = f"Neue Nachricht von {sender_name} - BreakPilot" + + # Plaintext Version + body_text = f"""Hallo {to_name}, + +Sie haben eine neue Nachricht von {sender_name} erhalten: + +--- +{message_content} +--- + +""" + if reply_link: + body_text += f"Um zu antworten, klicken Sie hier: {reply_link}\n\n" + + body_text += """Mit freundlichen Gruessen +Ihr BreakPilot Team + +--- +Diese E-Mail wurde automatisch versendet. +Bitte antworten Sie nicht direkt auf diese E-Mail. +""" + + # HTML Version + body_html = f""" + + + + + + +
+
+

Neue Nachricht

+
+
+

Hallo {to_name},

+

Sie haben eine neue Nachricht von {sender_name} erhalten:

+ +
+ {message_content.replace(chr(10), '
')} +
+ +""" + if reply_link: + body_html += f'

Nachricht beantworten

' + + body_html += """ +
+ +
+ + +""" + + return self.send_email( + to_email=to_email, + subject=subject, + body_text=body_text, + body_html=body_html + ) + + def send_jitsi_invitation( + self, + to_email: str, + to_name: str, + organizer_name: str, + meeting_title: str, + meeting_date: str, + meeting_time: str, + jitsi_url: str, + additional_info: Optional[str] = None + ) -> EmailResult: + """ + Sendet eine Jitsi-Meeting-Einladung per Email. + + Args: + to_email: Empfaenger-Email + to_name: Name des Empfaengers + organizer_name: Name des Organisators + meeting_title: Titel des Meetings + meeting_date: Datum des Meetings (z.B. "20. Dezember 2024") + meeting_time: Uhrzeit des Meetings (z.B. "14:00 Uhr") + jitsi_url: Der Jitsi-Meeting-Link + additional_info: Optional zusaetzliche Informationen + + Returns: + EmailResult + """ + subject = f"Einladung: {meeting_title} - {meeting_date}" + + # Plaintext Version + body_text = f"""Hallo {to_name}, + +{organizer_name} laedt Sie zu einem Videogespraech ein. + +TERMIN: {meeting_title} +DATUM: {meeting_date} +UHRZEIT: {meeting_time} + +Treten Sie dem Meeting bei: +{jitsi_url} + +""" + if additional_info: + body_text += f"HINWEISE:\n{additional_info}\n\n" + + body_text += """TECHNISCHE VORAUSSETZUNGEN: +- Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge) +- Keine Installation erforderlich +- Optional: Kopfhoerer fuer bessere Audioqualitaet + +Bei technischen Problemen wenden Sie sich bitte an den Organisator. + +Mit freundlichen Gruessen +Ihr BreakPilot Team +""" + + # HTML Version + body_html = f""" + + + + + + +
+
+

Einladung zum Videogespraech

+

{meeting_title}

+
+
+

Hallo {to_name},

+

{organizer_name} laedt Sie zu einem Videogespraech ein.

+ +
+
+ Termin: + {meeting_title} +
+
+ Datum: + {meeting_date} +
+
+ Uhrzeit: + {meeting_time} +
+
+ + Meeting beitreten + +""" + if additional_info: + body_html += f""" +
+

Hinweise:

+

{additional_info}

+
+""" + + body_html += """ +
+

Technische Voraussetzungen:

+
    +
  • Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
  • +
  • Keine Installation erforderlich
  • +
  • Optional: Kopfhoerer fuer bessere Audioqualitaet
  • +
+
+ +

+ Bei technischen Problemen wenden Sie sich bitte an den Organisator. +

+
+ +
+ + +""" + + return self.send_email( + to_email=to_email, + subject=subject, + body_text=body_text, + body_html=body_html + ) + + +# Globale Instanz +email_service = EmailService() diff --git a/backend-lehrer/services/story_generator.py b/backend-lehrer/services/story_generator.py new file mode 100644 index 0000000..9b7cf36 --- /dev/null +++ b/backend-lehrer/services/story_generator.py @@ -0,0 +1,108 @@ +""" +Story Generator — Creates short stories using vocabulary words. + +Generates age-appropriate mini-stories (3-5 sentences) that incorporate +the given vocabulary words, marked with tags for highlighting. + +Uses Ollama (local LLM) for generation. +""" + +import os +import json +import logging +import requests +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + +OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") +STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b") + + +def generate_story( + vocabulary: List[Dict[str, str]], + language: str = "en", + grade_level: str = "5-8", + max_words: int = 5, +) -> Dict[str, Any]: + """ + Generate a short story incorporating vocabulary words. + + Args: + vocabulary: List of dicts with 'english' and 'german' keys + language: 'en' for English story, 'de' for German story + grade_level: Target grade level + max_words: Maximum vocab words to include (to keep story short) + + Returns: + Dict with 'story_html', 'story_text', 'vocab_used', 'language' + """ + # Select subset of vocabulary + words = vocabulary[:max_words] + word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words] + word_list = [w for w in word_list if w.strip()] + + if not word_list: + return {"story_html": "", "story_text": "", "vocab_used": [], "language": language} + + lang_name = "English" if language == "en" else "German" + words_str = ", ".join(word_list) + + prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student. +The story MUST use these vocabulary words: {words_str} + +Rules: +1. The story should be fun and age-appropriate +2. Each vocabulary word must appear at least once +3. Keep sentences simple and clear +4. The story should make sense and be engaging + +Write ONLY the story, nothing else. No title, no introduction.""" + + try: + resp = requests.post( + f"{OLLAMA_URL}/api/generate", + json={ + "model": STORY_MODEL, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.8, "num_predict": 300}, + }, + timeout=30, + ) + resp.raise_for_status() + story_text = resp.json().get("response", "").strip() + except Exception as e: + logger.error(f"Story generation failed: {e}") + # Fallback: simple template story + story_text = _fallback_story(word_list, language) + + # Mark vocabulary words in the story + story_html = story_text + vocab_found = [] + for word in word_list: + if word.lower() in story_html.lower(): + # Case-insensitive replacement preserving original case + import re + pattern = re.compile(re.escape(word), re.IGNORECASE) + story_html = pattern.sub( + lambda m: f'{m.group()}', + story_html, + count=1, + ) + vocab_found.append(word) + + return { + "story_html": story_html, + "story_text": story_text, + "vocab_used": vocab_found, + "vocab_total": len(word_list), + "language": language, + } + + +def _fallback_story(words: List[str], language: str) -> str: + """Simple fallback when LLM is unavailable.""" + if language == "de": + return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen." + return f"Today I learned new words: {', '.join(words)}. It was a great day for learning." diff --git a/backend-lehrer/services/translation.py b/backend-lehrer/services/translation.py new file mode 100644 index 0000000..a10836c --- /dev/null +++ b/backend-lehrer/services/translation.py @@ -0,0 +1,179 @@ +""" +Translation Service — Batch-translates vocabulary words into target languages. + +Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL. +Translations are cached in vocabulary_words.translations JSONB field. + +All processing happens locally — no external API calls, GDPR-compliant. +""" + +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +logger = logging.getLogger(__name__) + +OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") +TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b") + +LANGUAGE_NAMES = { + "tr": "Turkish", + "ar": "Arabic", + "uk": "Ukrainian", + "ru": "Russian", + "pl": "Polish", + "fr": "French", + "es": "Spanish", +} + + +async def translate_words_batch( + words: List[Dict[str, str]], + target_language: str, + batch_size: int = 30, +) -> List[Dict[str, str]]: + """ + Translate a batch of EN/DE word pairs into a target language. + + Args: + words: List of dicts with 'english' and 'german' keys + target_language: ISO 639-1 code (tr, ar, uk, ru, pl) + batch_size: Words per LLM request + + Returns: + List of dicts with 'english', 'translation', 'example' keys + """ + lang_name = LANGUAGE_NAMES.get(target_language, target_language) + all_translations = [] + + for i in range(0, len(words), batch_size): + batch = words[i:i + batch_size] + word_list = "\n".join( + f"{j+1}. {w['english']} = {w.get('german', '')}" + for j, w in enumerate(batch) + ) + + prompt = f"""Translate these English/German word pairs into {lang_name}. +For each word, provide the translation and a short example sentence in {lang_name}. + +Words: +{word_list} + +Reply ONLY with a JSON array, no explanation: +[ + {{"english": "word", "translation": "...", "example": "..."}}, + ... +]""" + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post( + f"{OLLAMA_BASE_URL}/api/generate", + json={ + "model": TRANSLATION_MODEL, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.2, "num_predict": 4096}, + }, + ) + resp.raise_for_status() + response_text = resp.json().get("response", "") + + # Parse JSON from response + import re + match = re.search(r'\[[\s\S]*\]', response_text) + if match: + batch_translations = json.loads(match.group()) + all_translations.extend(batch_translations) + logger.info( + f"Translated batch {i//batch_size + 1}: " + f"{len(batch_translations)} words → {lang_name}" + ) + else: + logger.warning(f"No JSON array in LLM response for {lang_name}") + + except Exception as e: + logger.error(f"Translation batch failed ({lang_name}): {e}") + + return all_translations + + +async def translate_and_store( + word_ids: List[str], + target_language: str, +) -> int: + """ + Translate vocabulary words and store in the database. + + Fetches words from DB, translates via LLM, stores in translations JSONB. + Skips words that already have a translation for the target language. + + Returns count of newly translated words. + """ + from vocabulary_db import get_pool + + pool = await get_pool() + async with pool.acquire() as conn: + # Fetch words that need translation + rows = await conn.fetch( + """ + SELECT id, english, german, translations + FROM vocabulary_words + WHERE id = ANY($1::uuid[]) + """, + [__import__('uuid').UUID(wid) for wid in word_ids], + ) + + words_to_translate = [] + word_map = {} + for row in rows: + translations = row["translations"] or {} + if isinstance(translations, str): + translations = json.loads(translations) + if target_language not in translations: + words_to_translate.append({ + "english": row["english"], + "german": row["german"], + }) + word_map[row["english"].lower()] = str(row["id"]) + + if not words_to_translate: + logger.info(f"All {len(rows)} words already translated to {target_language}") + return 0 + + # Translate + results = await translate_words_batch(words_to_translate, target_language) + + # Store results + updated = 0 + async with pool.acquire() as conn: + for result in results: + en = result.get("english", "").lower() + word_id = word_map.get(en) + if not word_id: + continue + + translation = result.get("translation", "") + example = result.get("example", "") + if not translation: + continue + + await conn.execute( + """ + UPDATE vocabulary_words + SET translations = translations || $1::jsonb + WHERE id = $2 + """, + json.dumps({target_language: { + "text": translation, + "example": example, + }}), + __import__('uuid').UUID(word_id), + ) + updated += 1 + + logger.info(f"Stored {updated} translations for {target_language}") + return updated diff --git a/backend-lehrer/state_engine_api.py b/backend-lehrer/state_engine_api.py index 3f43ca8..8ab678b 100644 --- a/backend-lehrer/state_engine_api.py +++ b/backend-lehrer/state_engine_api.py @@ -1,410 +1,4 @@ -""" -State Engine API - REST API für Begleiter-Modus. - -Endpoints: -- GET /api/state/context - TeacherContext abrufen -- GET /api/state/suggestions - Vorschläge abrufen -- GET /api/state/dashboard - Dashboard-Daten -- POST /api/state/milestone - Meilenstein abschließen -- POST /api/state/transition - Phasen-Übergang -""" - -import logging -import uuid -from datetime import datetime, timedelta -from typing import Dict, Any, List - -from fastapi import APIRouter, HTTPException, Query - -from state_engine import ( - AnticipationEngine, - PhaseService, - SchoolYearPhase, - ClassSummary, - Event, - get_phase_info, -) -from state_engine_models import ( - MilestoneRequest, - TransitionRequest, - ContextResponse, - SuggestionsResponse, - DashboardResponse, - _teacher_contexts, - _milestones, - get_or_create_context, - update_context_from_services, - get_phase_display_name, -) - -logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/state", - tags=["state-engine"], -) - -# Singleton instances -_engine = AnticipationEngine() -_phase_service = PhaseService() - - -# ============================================================================ -# API Endpoints -# ============================================================================ - -@router.get("/context", response_model=ContextResponse) -async def get_teacher_context(teacher_id: str = Query("demo-teacher")): - """Gibt den aggregierten TeacherContext zurück.""" - ctx = get_or_create_context(teacher_id) - ctx = update_context_from_services(ctx) - - phase_info = get_phase_info(ctx.current_phase) - - return ContextResponse( - context=ctx.to_dict(), - phase_info={ - "phase": phase_info.phase.value, - "display_name": phase_info.display_name, - "description": phase_info.description, - "typical_months": phase_info.typical_months, - "required_actions": phase_info.required_actions, - "optional_actions": phase_info.optional_actions, - } - ) - - -@router.get("/phase") -async def get_current_phase(teacher_id: str = Query("demo-teacher")): - """Gibt die aktuelle Phase mit Details zurück.""" - ctx = get_or_create_context(teacher_id) - phase_info = get_phase_info(ctx.current_phase) - - return { - "current_phase": ctx.current_phase.value, - "phase_info": { - "display_name": phase_info.display_name, - "description": phase_info.description, - "expected_duration_weeks": phase_info.expected_duration_weeks, - }, - "days_in_phase": ctx.days_in_phase, - "progress": _phase_service.get_progress_percentage(ctx), - } - - -@router.get("/phases") -async def get_all_phases(): - """Gibt alle Phasen mit Metadaten zurück.""" - return { - "phases": _phase_service.get_all_phases() - } - - -@router.get("/suggestions", response_model=SuggestionsResponse) -async def get_suggestions(teacher_id: str = Query("demo-teacher")): - """Gibt Vorschläge basierend auf dem aktuellen Kontext zurück.""" - ctx = get_or_create_context(teacher_id) - ctx = update_context_from_services(ctx) - - suggestions = _engine.get_suggestions(ctx) - priority_counts = _engine.count_by_priority(ctx) - - return SuggestionsResponse( - suggestions=[s.to_dict() for s in suggestions], - current_phase=ctx.current_phase.value, - phase_display_name=get_phase_display_name(ctx.current_phase.value), - priority_counts=priority_counts, - ) - - -@router.get("/suggestions/top") -async def get_top_suggestion(teacher_id: str = Query("demo-teacher")): - """Gibt den wichtigsten einzelnen Vorschlag zurück.""" - ctx = get_or_create_context(teacher_id) - ctx = update_context_from_services(ctx) - - suggestion = _engine.get_top_suggestion(ctx) - - if not suggestion: - return { - "suggestion": None, - "message": "Alles erledigt! Keine offenen Aufgaben." - } - - return { - "suggestion": suggestion.to_dict() - } - - -@router.get("/dashboard", response_model=DashboardResponse) -async def get_dashboard_data(teacher_id: str = Query("demo-teacher")): - """Gibt alle Daten für das Begleiter-Dashboard zurück.""" - ctx = get_or_create_context(teacher_id) - ctx = update_context_from_services(ctx) - - suggestions = _engine.get_suggestions(ctx) - phase_info = get_phase_info(ctx.current_phase) - - required = set(phase_info.required_actions) - completed = set(ctx.completed_milestones) - completed_in_phase = len(required.intersection(completed)) - - all_phases = [] - phase_order = [ - SchoolYearPhase.ONBOARDING, - SchoolYearPhase.SCHOOL_YEAR_START, - SchoolYearPhase.TEACHING_SETUP, - SchoolYearPhase.PERFORMANCE_1, - SchoolYearPhase.SEMESTER_END, - SchoolYearPhase.TEACHING_2, - SchoolYearPhase.PERFORMANCE_2, - SchoolYearPhase.YEAR_END, - ] - - current_idx = phase_order.index(ctx.current_phase) if ctx.current_phase in phase_order else 0 - - for i, phase in enumerate(phase_order): - info = get_phase_info(phase) - all_phases.append({ - "phase": phase.value, - "display_name": info.display_name, - "short_name": info.display_name[:10], - "is_current": phase == ctx.current_phase, - "is_completed": i < current_idx, - "is_future": i > current_idx, - }) - - return DashboardResponse( - context={ - "current_phase": ctx.current_phase.value, - "phase_display_name": phase_info.display_name, - "phase_description": phase_info.description, - "weeks_since_start": ctx.weeks_since_start, - "days_in_phase": ctx.days_in_phase, - "federal_state": ctx.federal_state, - "school_type": ctx.school_type, - }, - suggestions=[s.to_dict() for s in suggestions], - stats={ - "learning_units_created": ctx.stats.learning_units_created, - "exams_scheduled": ctx.stats.exams_scheduled, - "exams_graded": ctx.stats.exams_graded, - "grades_entered": ctx.stats.grades_entered, - "classes_count": len(ctx.classes), - "students_count": ctx.total_students, - }, - upcoming_events=[ - { - "type": e.type, - "title": e.title, - "date": e.date.isoformat(), - "in_days": e.in_days, - "priority": e.priority, - } - for e in ctx.upcoming_events[:5] - ], - progress={ - "completed": completed_in_phase, - "total": len(required), - "percentage": (completed_in_phase / len(required) * 100) if required else 100, - "milestones_completed": list(completed.intersection(required)), - "milestones_pending": list(required - completed), - }, - phases=all_phases, - ) - - -@router.post("/milestone") -async def complete_milestone( - request: MilestoneRequest, - teacher_id: str = Query("demo-teacher") -): - """Markiert einen Meilenstein als erledigt.""" - milestone = request.milestone - - if teacher_id not in _milestones: - _milestones[teacher_id] = [] - - if milestone not in _milestones[teacher_id]: - _milestones[teacher_id].append(milestone) - logger.info(f"Milestone '{milestone}' completed for teacher {teacher_id}") - - ctx = get_or_create_context(teacher_id) - ctx.completed_milestones = _milestones[teacher_id] - _teacher_contexts[teacher_id] = ctx - - new_phase = _phase_service.check_and_transition(ctx) - - if new_phase: - ctx.current_phase = new_phase - ctx.phase_entered_at = datetime.now() - ctx.days_in_phase = 0 - _teacher_contexts[teacher_id] = ctx - logger.info(f"Auto-transitioned to {new_phase} for teacher {teacher_id}") - - return { - "success": True, - "milestone": milestone, - "new_phase": new_phase.value if new_phase else None, - "current_phase": ctx.current_phase.value, - "completed_milestones": ctx.completed_milestones, - } - - -@router.post("/transition") -async def transition_phase( - request: TransitionRequest, - teacher_id: str = Query("demo-teacher") -): - """Führt einen manuellen Phasen-Übergang durch.""" - try: - target_phase = SchoolYearPhase(request.target_phase) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Ungültige Phase: {request.target_phase}" - ) - - ctx = get_or_create_context(teacher_id) - - if not _phase_service.can_transition_to(ctx, target_phase): - raise HTTPException( - status_code=400, - detail=f"Übergang von {ctx.current_phase.value} zu {target_phase.value} nicht erlaubt" - ) - - old_phase = ctx.current_phase - ctx.current_phase = target_phase - ctx.phase_entered_at = datetime.now() - ctx.days_in_phase = 0 - _teacher_contexts[teacher_id] = ctx - - logger.info(f"Manual transition from {old_phase} to {target_phase} for teacher {teacher_id}") - - return { - "success": True, - "old_phase": old_phase.value, - "new_phase": target_phase.value, - "phase_info": get_phase_info(target_phase).__dict__, - } - - -@router.get("/next-phase") -async def get_next_phase(teacher_id: str = Query("demo-teacher")): - """Gibt die nächste Phase und Anforderungen zurück.""" - ctx = get_or_create_context(teacher_id) - next_phase = _phase_service.get_next_phase(ctx.current_phase) - - if not next_phase: - return { - "next_phase": None, - "message": "Letzte Phase erreicht" - } - - can_transition = _phase_service.can_transition_to(ctx, next_phase) - next_info = get_phase_info(next_phase) - current_info = get_phase_info(ctx.current_phase) - - missing = [ - m for m in current_info.required_actions - if m not in ctx.completed_milestones - ] - - return { - "current_phase": ctx.current_phase.value, - "next_phase": next_phase.value, - "next_phase_info": { - "display_name": next_info.display_name, - "description": next_info.description, - }, - "can_transition": can_transition, - "missing_requirements": missing, - } - - -# ============================================================================ -# Demo Data Endpoints (nur für Entwicklung) -# ============================================================================ - -@router.post("/demo/add-class") -async def demo_add_class( - name: str = Query(...), - grade_level: int = Query(...), - student_count: int = Query(25), - teacher_id: str = Query("demo-teacher") -): - """Demo: Fügt eine Klasse zum Kontext hinzu.""" - ctx = get_or_create_context(teacher_id) - - ctx.classes.append(ClassSummary( - class_id=str(uuid.uuid4()), - name=name, - grade_level=grade_level, - student_count=student_count, - subject="Deutsch" - )) - ctx.total_students += student_count - _teacher_contexts[teacher_id] = ctx - - return {"success": True, "classes": len(ctx.classes)} - - -@router.post("/demo/add-event") -async def demo_add_event( - event_type: str = Query(...), - title: str = Query(...), - in_days: int = Query(...), - teacher_id: str = Query("demo-teacher") -): - """Demo: Fügt ein Event zum Kontext hinzu.""" - ctx = get_or_create_context(teacher_id) - - ctx.upcoming_events.append(Event( - type=event_type, - title=title, - date=datetime.now() + timedelta(days=in_days), - in_days=in_days, - priority="high" if in_days <= 3 else "medium" - )) - _teacher_contexts[teacher_id] = ctx - - return {"success": True, "events": len(ctx.upcoming_events)} - - -@router.post("/demo/update-stats") -async def demo_update_stats( - learning_units: int = Query(0), - exams_scheduled: int = Query(0), - exams_graded: int = Query(0), - grades_entered: int = Query(0), - unanswered_messages: int = Query(0), - teacher_id: str = Query("demo-teacher") -): - """Demo: Aktualisiert Statistiken.""" - ctx = get_or_create_context(teacher_id) - - if learning_units: - ctx.stats.learning_units_created = learning_units - if exams_scheduled: - ctx.stats.exams_scheduled = exams_scheduled - if exams_graded: - ctx.stats.exams_graded = exams_graded - if grades_entered: - ctx.stats.grades_entered = grades_entered - if unanswered_messages: - ctx.stats.unanswered_messages = unanswered_messages - - _teacher_contexts[teacher_id] = ctx - - return {"success": True, "stats": ctx.stats.__dict__} - - -@router.post("/demo/reset") -async def demo_reset(teacher_id: str = Query("demo-teacher")): - """Demo: Setzt den Kontext zurück.""" - if teacher_id in _teacher_contexts: - del _teacher_contexts[teacher_id] - if teacher_id in _milestones: - del _milestones[teacher_id] - - return {"success": True, "message": "Kontext zurückgesetzt"} +# Backward-compat shim -- module moved to classroom/state_engine_api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("classroom.state_engine_api") diff --git a/backend-lehrer/state_engine_models.py b/backend-lehrer/state_engine_models.py index 778b2f7..6d395a2 100644 --- a/backend-lehrer/state_engine_models.py +++ b/backend-lehrer/state_engine_models.py @@ -1,143 +1,4 @@ -""" -State Engine API - Pydantic Models und Helper Functions. -""" - -import uuid -from datetime import datetime, timedelta -from typing import Dict, Any, List, Optional - -from pydantic import BaseModel, Field - -from state_engine import ( - SchoolYearPhase, - ClassSummary, - Event, - TeacherContext, - TeacherStats, - get_phase_info, -) - - -# ============================================================================ -# In-Memory Storage (später durch DB ersetzen) -# ============================================================================ - -_teacher_contexts: Dict[str, TeacherContext] = {} -_milestones: Dict[str, List[str]] = {} # teacher_id -> milestones - - -# ============================================================================ -# Pydantic Models -# ============================================================================ - -class MilestoneRequest(BaseModel): - """Request zum Abschließen eines Meilensteins.""" - milestone: str = Field(..., description="Name des Meilensteins") - - -class TransitionRequest(BaseModel): - """Request für Phasen-Übergang.""" - target_phase: str = Field(..., description="Zielphase") - - -class ContextResponse(BaseModel): - """Response mit TeacherContext.""" - context: Dict[str, Any] - phase_info: Dict[str, Any] - - -class SuggestionsResponse(BaseModel): - """Response mit Vorschlägen.""" - suggestions: List[Dict[str, Any]] - current_phase: str - phase_display_name: str - priority_counts: Dict[str, int] - - -class DashboardResponse(BaseModel): - """Response mit Dashboard-Daten.""" - context: Dict[str, Any] - suggestions: List[Dict[str, Any]] - stats: Dict[str, Any] - upcoming_events: List[Dict[str, Any]] - progress: Dict[str, Any] - phases: List[Dict[str, Any]] - - -# ============================================================================ -# Helper Functions -# ============================================================================ - -def get_or_create_context(teacher_id: str) -> TeacherContext: - """ - Holt oder erstellt TeacherContext. - - In Produktion würde dies aus der Datenbank geladen. - """ - if teacher_id not in _teacher_contexts: - now = datetime.now() - school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1) - weeks_since_start = (now - school_year_start).days // 7 - - month = now.month - if month in [8, 9]: - phase = SchoolYearPhase.SCHOOL_YEAR_START - elif month in [10, 11]: - phase = SchoolYearPhase.TEACHING_SETUP - elif month == 12: - phase = SchoolYearPhase.PERFORMANCE_1 - elif month in [1, 2]: - phase = SchoolYearPhase.SEMESTER_END - elif month in [3, 4]: - phase = SchoolYearPhase.TEACHING_2 - elif month in [5, 6]: - phase = SchoolYearPhase.PERFORMANCE_2 - else: - phase = SchoolYearPhase.YEAR_END - - _teacher_contexts[teacher_id] = TeacherContext( - teacher_id=teacher_id, - school_id=str(uuid.uuid4()), - school_year_id=str(uuid.uuid4()), - federal_state="niedersachsen", - school_type="gymnasium", - school_year_start=school_year_start, - current_phase=phase, - phase_entered_at=now - timedelta(days=7), - weeks_since_start=weeks_since_start, - days_in_phase=7, - classes=[], - total_students=0, - upcoming_events=[], - completed_milestones=_milestones.get(teacher_id, []), - pending_milestones=[], - stats=TeacherStats(), - ) - - return _teacher_contexts[teacher_id] - - -def update_context_from_services(ctx: TeacherContext) -> TeacherContext: - """ - Aktualisiert Kontext mit Daten aus anderen Services. - - In Produktion würde dies von school-service, gradebook etc. laden. - """ - ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days - ctx.completed_milestones = _milestones.get(ctx.teacher_id, []) - - phase_info = get_phase_info(ctx.current_phase) - ctx.pending_milestones = [ - m for m in phase_info.required_actions - if m not in ctx.completed_milestones - ] - - return ctx - - -def get_phase_display_name(phase: str) -> str: - """Gibt Display-Name für Phase zurück.""" - try: - return get_phase_info(SchoolYearPhase(phase)).display_name - except (ValueError, KeyError): - return phase +# Backward-compat shim -- module moved to classroom/state_engine_models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("classroom.state_engine_models") diff --git a/backend-lehrer/story_generator.py b/backend-lehrer/story_generator.py index 9b7cf36..f0e5d10 100644 --- a/backend-lehrer/story_generator.py +++ b/backend-lehrer/story_generator.py @@ -1,108 +1,4 @@ -""" -Story Generator — Creates short stories using vocabulary words. - -Generates age-appropriate mini-stories (3-5 sentences) that incorporate -the given vocabulary words, marked with tags for highlighting. - -Uses Ollama (local LLM) for generation. -""" - -import os -import json -import logging -import requests -from typing import List, Dict, Any, Optional - -logger = logging.getLogger(__name__) - -OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") -STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b") - - -def generate_story( - vocabulary: List[Dict[str, str]], - language: str = "en", - grade_level: str = "5-8", - max_words: int = 5, -) -> Dict[str, Any]: - """ - Generate a short story incorporating vocabulary words. - - Args: - vocabulary: List of dicts with 'english' and 'german' keys - language: 'en' for English story, 'de' for German story - grade_level: Target grade level - max_words: Maximum vocab words to include (to keep story short) - - Returns: - Dict with 'story_html', 'story_text', 'vocab_used', 'language' - """ - # Select subset of vocabulary - words = vocabulary[:max_words] - word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words] - word_list = [w for w in word_list if w.strip()] - - if not word_list: - return {"story_html": "", "story_text": "", "vocab_used": [], "language": language} - - lang_name = "English" if language == "en" else "German" - words_str = ", ".join(word_list) - - prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student. -The story MUST use these vocabulary words: {words_str} - -Rules: -1. The story should be fun and age-appropriate -2. Each vocabulary word must appear at least once -3. Keep sentences simple and clear -4. The story should make sense and be engaging - -Write ONLY the story, nothing else. No title, no introduction.""" - - try: - resp = requests.post( - f"{OLLAMA_URL}/api/generate", - json={ - "model": STORY_MODEL, - "prompt": prompt, - "stream": False, - "options": {"temperature": 0.8, "num_predict": 300}, - }, - timeout=30, - ) - resp.raise_for_status() - story_text = resp.json().get("response", "").strip() - except Exception as e: - logger.error(f"Story generation failed: {e}") - # Fallback: simple template story - story_text = _fallback_story(word_list, language) - - # Mark vocabulary words in the story - story_html = story_text - vocab_found = [] - for word in word_list: - if word.lower() in story_html.lower(): - # Case-insensitive replacement preserving original case - import re - pattern = re.compile(re.escape(word), re.IGNORECASE) - story_html = pattern.sub( - lambda m: f'{m.group()}', - story_html, - count=1, - ) - vocab_found.append(word) - - return { - "story_html": story_html, - "story_text": story_text, - "vocab_used": vocab_found, - "vocab_total": len(word_list), - "language": language, - } - - -def _fallback_story(words: List[str], language: str) -> str: - """Simple fallback when LLM is unavailable.""" - if language == "de": - return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen." - return f"Today I learned new words: {', '.join(words)}. It was a great day for learning." +# Backward-compat shim -- module moved to services/story_generator.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("services.story_generator") diff --git a/backend-lehrer/translation_service.py b/backend-lehrer/translation_service.py index a10836c..a34073c 100644 --- a/backend-lehrer/translation_service.py +++ b/backend-lehrer/translation_service.py @@ -1,179 +1,4 @@ -""" -Translation Service — Batch-translates vocabulary words into target languages. - -Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL. -Translations are cached in vocabulary_words.translations JSONB field. - -All processing happens locally — no external API calls, GDPR-compliant. -""" - -import json -import logging -import os -from typing import Any, Dict, List - -import httpx - -logger = logging.getLogger(__name__) - -OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") -TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b") - -LANGUAGE_NAMES = { - "tr": "Turkish", - "ar": "Arabic", - "uk": "Ukrainian", - "ru": "Russian", - "pl": "Polish", - "fr": "French", - "es": "Spanish", -} - - -async def translate_words_batch( - words: List[Dict[str, str]], - target_language: str, - batch_size: int = 30, -) -> List[Dict[str, str]]: - """ - Translate a batch of EN/DE word pairs into a target language. - - Args: - words: List of dicts with 'english' and 'german' keys - target_language: ISO 639-1 code (tr, ar, uk, ru, pl) - batch_size: Words per LLM request - - Returns: - List of dicts with 'english', 'translation', 'example' keys - """ - lang_name = LANGUAGE_NAMES.get(target_language, target_language) - all_translations = [] - - for i in range(0, len(words), batch_size): - batch = words[i:i + batch_size] - word_list = "\n".join( - f"{j+1}. {w['english']} = {w.get('german', '')}" - for j, w in enumerate(batch) - ) - - prompt = f"""Translate these English/German word pairs into {lang_name}. -For each word, provide the translation and a short example sentence in {lang_name}. - -Words: -{word_list} - -Reply ONLY with a JSON array, no explanation: -[ - {{"english": "word", "translation": "...", "example": "..."}}, - ... -]""" - - try: - async with httpx.AsyncClient(timeout=120.0) as client: - resp = await client.post( - f"{OLLAMA_BASE_URL}/api/generate", - json={ - "model": TRANSLATION_MODEL, - "prompt": prompt, - "stream": False, - "options": {"temperature": 0.2, "num_predict": 4096}, - }, - ) - resp.raise_for_status() - response_text = resp.json().get("response", "") - - # Parse JSON from response - import re - match = re.search(r'\[[\s\S]*\]', response_text) - if match: - batch_translations = json.loads(match.group()) - all_translations.extend(batch_translations) - logger.info( - f"Translated batch {i//batch_size + 1}: " - f"{len(batch_translations)} words → {lang_name}" - ) - else: - logger.warning(f"No JSON array in LLM response for {lang_name}") - - except Exception as e: - logger.error(f"Translation batch failed ({lang_name}): {e}") - - return all_translations - - -async def translate_and_store( - word_ids: List[str], - target_language: str, -) -> int: - """ - Translate vocabulary words and store in the database. - - Fetches words from DB, translates via LLM, stores in translations JSONB. - Skips words that already have a translation for the target language. - - Returns count of newly translated words. - """ - from vocabulary_db import get_pool - - pool = await get_pool() - async with pool.acquire() as conn: - # Fetch words that need translation - rows = await conn.fetch( - """ - SELECT id, english, german, translations - FROM vocabulary_words - WHERE id = ANY($1::uuid[]) - """, - [__import__('uuid').UUID(wid) for wid in word_ids], - ) - - words_to_translate = [] - word_map = {} - for row in rows: - translations = row["translations"] or {} - if isinstance(translations, str): - translations = json.loads(translations) - if target_language not in translations: - words_to_translate.append({ - "english": row["english"], - "german": row["german"], - }) - word_map[row["english"].lower()] = str(row["id"]) - - if not words_to_translate: - logger.info(f"All {len(rows)} words already translated to {target_language}") - return 0 - - # Translate - results = await translate_words_batch(words_to_translate, target_language) - - # Store results - updated = 0 - async with pool.acquire() as conn: - for result in results: - en = result.get("english", "").lower() - word_id = word_map.get(en) - if not word_id: - continue - - translation = result.get("translation", "") - example = result.get("example", "") - if not translation: - continue - - await conn.execute( - """ - UPDATE vocabulary_words - SET translations = translations || $1::jsonb - WHERE id = $2 - """, - json.dumps({target_language: { - "text": translation, - "example": example, - }}), - __import__('uuid').UUID(word_id), - ) - updated += 1 - - logger.info(f"Stored {updated} translations for {target_language}") - return updated +# Backward-compat shim -- module moved to services/translation.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("services.translation") diff --git a/backend-lehrer/user_language_api.py b/backend-lehrer/user_language_api.py index b498aa6..91cdcce 100644 --- a/backend-lehrer/user_language_api.py +++ b/backend-lehrer/user_language_api.py @@ -1,86 +1,4 @@ -""" -User Language Preferences API — Stores native language + learning level. - -Each user (student, parent, teacher) can set their native language. -This drives: UI language, third-language display in flashcards, -parent portal language, and translation generation. - -Supported languages: de, en, tr, ar, uk, ru, pl -""" - -import logging -import os -from typing import Any, Dict, Optional - -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/user", tags=["user-language"]) - -# Supported native languages with metadata -SUPPORTED_LANGUAGES = { - "de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False}, - "en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False}, - "tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False}, - "ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True}, - "uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False}, - "ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False}, - "pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False}, -} - -# In-memory store (will be replaced with DB later) -_preferences: Dict[str, Dict[str, Any]] = {} - - -class LanguagePreference(BaseModel): - native_language: str # ISO 639-1 code - role: str = "student" # student, parent, teacher - learning_level: str = "A1" # A1, A2, B1, B2, C1 - - -@router.get("/languages") -def get_supported_languages(): - """List all supported native languages with metadata.""" - return { - "languages": [ - {"code": code, **meta} - for code, meta in SUPPORTED_LANGUAGES.items() - ] - } - - -@router.get("/language-preference") -def get_language_preference(user_id: str = Query("default")): - """Get user's language preference.""" - pref = _preferences.get(user_id) - if not pref: - return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True} - return {**pref, "is_default": False} - - -@router.put("/language-preference") -def set_language_preference( - pref: LanguagePreference, - user_id: str = Query("default"), -): - """Set user's native language and learning level.""" - if pref.native_language not in SUPPORTED_LANGUAGES: - raise HTTPException( - status_code=400, - detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. " - f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}", - ) - - _preferences[user_id] = { - "user_id": user_id, - "native_language": pref.native_language, - "role": pref.role, - "learning_level": pref.learning_level, - } - - lang_meta = SUPPORTED_LANGUAGES[pref.native_language] - logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})") - - return {**_preferences[user_id], "language_meta": lang_meta} +# Backward-compat shim -- module moved to api/user_language.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("api.user_language") diff --git a/backend-lehrer/vocabulary/__init__.py b/backend-lehrer/vocabulary/__init__.py new file mode 100644 index 0000000..aaac9e6 --- /dev/null +++ b/backend-lehrer/vocabulary/__init__.py @@ -0,0 +1,33 @@ +# Vocabulary Module +# vocabulary/api.py — API router (search, browse, import, translate) +# vocabulary/db.py — PostgreSQL storage for vocabulary word catalog + +from .api import router +from .db import ( + VocabularyWord, + get_pool, + init_vocabulary_tables, + search_words, + get_word, + browse_words, + insert_word, + insert_words_bulk, + count_words, + get_all_tags, + get_all_pos, +) + +__all__ = [ + "router", + "VocabularyWord", + "get_pool", + "init_vocabulary_tables", + "search_words", + "get_word", + "browse_words", + "insert_word", + "insert_words_bulk", + "count_words", + "get_all_tags", + "get_all_pos", +] diff --git a/backend-lehrer/vocabulary/api.py b/backend-lehrer/vocabulary/api.py new file mode 100644 index 0000000..703f054 --- /dev/null +++ b/backend-lehrer/vocabulary/api.py @@ -0,0 +1,352 @@ +""" +Vocabulary API — Search, browse, and build learning units from the word catalog. + +Endpoints for teachers to find words and create learning units, +and for students to access word details with audio/images/syllables. +""" + +import logging +import json +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from .db import ( + search_words, + get_word, + browse_words, + insert_word, + count_words, + get_all_tags, + get_all_pos, + VocabularyWord, +) +from learning_units import ( + LearningUnitCreate, + create_learning_unit, + get_learning_unit, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/vocabulary", tags=["vocabulary"]) + + +# --------------------------------------------------------------------------- +# Search & Browse +# --------------------------------------------------------------------------- + + +@router.get("/search") +async def api_search_words( + q: str = Query("", description="Search query"), + lang: str = Query("en", pattern="^(en|de)$"), + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), +): + """Full-text search for vocabulary words.""" + if not q.strip(): + return {"words": [], "query": q, "total": 0} + + words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset) + return { + "words": [w.to_dict() for w in words], + "query": q, + "total": len(words), + } + + +@router.get("/browse") +async def api_browse_words( + pos: str = Query("", description="Part of speech filter"), + difficulty: int = Query(0, ge=0, le=5, description="Difficulty 1-5, 0=all"), + tag: str = Query("", description="Tag filter"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +): + """Browse vocabulary words with filters.""" + words = await browse_words( + pos=pos, difficulty=difficulty, tag=tag, + limit=limit, offset=offset, + ) + return { + "words": [w.to_dict() for w in words], + "filters": {"pos": pos, "difficulty": difficulty, "tag": tag}, + "total": len(words), + } + + +@router.get("/word/{word_id}") +async def api_get_word(word_id: str): + """Get a single word with all details.""" + word = await get_word(word_id) + if not word: + raise HTTPException(status_code=404, detail="Wort nicht gefunden") + return word.to_dict() + + +@router.get("/filters") +async def api_get_filters(): + """Get available filter options (tags, parts of speech, word count).""" + tags = await get_all_tags() + pos_list = await get_all_pos() + total = await count_words() + return { + "tags": tags, + "parts_of_speech": pos_list, + "total_words": total, + } + + +# --------------------------------------------------------------------------- +# Audio TTS for Words +# --------------------------------------------------------------------------- + + +@router.get("/word/{word_id}/audio/{lang}") +async def api_get_word_audio(word_id: str, lang: str = "en"): + """Get or generate TTS audio for a vocabulary word. + + Returns MP3 audio. Generated on first request, cached after. + Uses Piper TTS (MIT license) with Thorsten (DE) and Lessac (EN) voices. + """ + from fastapi.responses import Response as FastAPIResponse + + word = await get_word(word_id) + if not word: + raise HTTPException(status_code=404, detail="Wort nicht gefunden") + + text = word.english if lang == "en" else word.german + if not text: + raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'") + + from audio_service import get_or_generate_audio + audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id) + + if not audio_bytes: + raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar") + + return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg") + + +@router.get("/word/{word_id}/audio-syllables/{lang}") +async def api_get_syllable_audio(word_id: str, lang: str = "en"): + """Get TTS audio with slow syllable pronunciation. + + Generates audio like "ap ... ple" with pauses between syllables. + """ + from fastapi.responses import Response as FastAPIResponse + + word = await get_word(word_id) + if not word: + raise HTTPException(status_code=404, detail="Wort nicht gefunden") + + syllables = word.syllables_en if lang == "en" else word.syllables_de + if not syllables: + # Fallback to full word + text = word.english if lang == "en" else word.german + syllables = [text] + + # Join syllables with pauses (Piper handles "..." as pause) + slow_text = " ... ".join(syllables) + + from audio_service import get_or_generate_audio + cache_key = f"{word_id}_syl_{lang}" + audio_bytes = await get_or_generate_audio(slow_text, language=lang, word_id=cache_key) + + if not audio_bytes: + raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar") + + return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg") + + +# --------------------------------------------------------------------------- +# Learning Unit Creation from Word Selection +# --------------------------------------------------------------------------- + + +class CreateUnitFromWordsPayload(BaseModel): + title: str + word_ids: List[str] + grade: Optional[str] = None + language: Optional[str] = "de" + + +@router.post("/units") +async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload): + """Create a learning unit from selected vocabulary word IDs. + + Fetches full word details, creates a LearningUnit in the + learning_units system, and stores the vocabulary data. + """ + if not payload.word_ids: + raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt") + + # Fetch all selected words + words = [] + for wid in payload.word_ids: + word = await get_word(wid) + if word: + words.append(word) + + if not words: + raise HTTPException(status_code=404, detail="Keine der Woerter gefunden") + + # Create learning unit + lu = create_learning_unit(LearningUnitCreate( + title=payload.title, + topic="Vocabulary", + grade_level=payload.grade or "5-8", + language=payload.language or "de", + status="raw", + )) + + # Save vocabulary data as analysis JSON for generators + import os + analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten") + os.makedirs(analysis_dir, exist_ok=True) + + vocab_data = [w.to_dict() for w in words] + analysis_path = os.path.join(analysis_dir, f"{lu.id}_vocab.json") + with open(analysis_path, "w", encoding="utf-8") as f: + json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2) + + # Also save as QA items for flashcards/type trainer + qa_items = [] + for i, w in enumerate(words): + qa_items.append({ + "id": f"qa_{i+1}", + "question": w.english, + "answer": w.german, + "question_type": "knowledge", + "key_terms": [w.english], + "difficulty": w.difficulty, + "source_hint": w.part_of_speech, + "leitner_box": 0, + "correct_count": 0, + "incorrect_count": 0, + "last_seen": None, + "next_review": None, + # Extra fields for enhanced flashcards + "ipa_en": w.ipa_en, + "ipa_de": w.ipa_de, + "syllables_en": w.syllables_en, + "syllables_de": w.syllables_de, + "example_en": w.example_en, + "example_de": w.example_de, + "image_url": w.image_url, + "audio_url_en": w.audio_url_en, + "audio_url_de": w.audio_url_de, + "part_of_speech": w.part_of_speech, + "translations": w.translations, + }) + + qa_path = os.path.join(analysis_dir, f"{lu.id}_qa.json") + with open(qa_path, "w", encoding="utf-8") as f: + json.dump({ + "qa_items": qa_items, + "metadata": { + "subject": "English Vocabulary", + "grade_level": payload.grade or "5-8", + "source_title": payload.title, + "total_questions": len(qa_items), + }, + }, f, ensure_ascii=False, indent=2) + + logger.info(f"Created vocab unit {lu.id} with {len(words)} words") + + return { + "unit_id": lu.id, + "title": payload.title, + "word_count": len(words), + "status": "created", + } + + +@router.get("/units/{unit_id}") +async def api_get_unit_words(unit_id: str): + """Get all words for a learning unit.""" + import os + vocab_path = os.path.join( + os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten"), + f"{unit_id}_vocab.json", + ) + if not os.path.exists(vocab_path): + raise HTTPException(status_code=404, detail="Unit nicht gefunden") + + with open(vocab_path, "r", encoding="utf-8") as f: + data = json.load(f) + + return { + "unit_id": unit_id, + "title": data.get("title", ""), + "words": data.get("words", []), + } + + +# --------------------------------------------------------------------------- +# Bulk Import (for seeding the dictionary) +# --------------------------------------------------------------------------- + + +class BulkImportPayload(BaseModel): + words: List[Dict[str, Any]] + + +@router.post("/import") +async def api_bulk_import(payload: BulkImportPayload): + """Bulk import vocabulary words (for seeding the dictionary). + + Each word dict should have at minimum: english, german. + Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de, + example_en, example_de, difficulty, tags, translations. + """ + from .db import insert_words_bulk + + words = [] + for w in payload.words: + words.append(VocabularyWord( + english=w.get("english", ""), + german=w.get("german", ""), + ipa_en=w.get("ipa_en", ""), + ipa_de=w.get("ipa_de", ""), + part_of_speech=w.get("part_of_speech", ""), + syllables_en=w.get("syllables_en", []), + syllables_de=w.get("syllables_de", []), + example_en=w.get("example_en", ""), + example_de=w.get("example_de", ""), + difficulty=w.get("difficulty", 1), + tags=w.get("tags", []), + translations=w.get("translations", {}), + )) + + count = await insert_words_bulk(words) + logger.info(f"Bulk imported {count} vocabulary words") + return {"imported": count} + + +# --------------------------------------------------------------------------- +# Translation Generation +# --------------------------------------------------------------------------- + + +class TranslateRequest(BaseModel): + word_ids: List[str] + target_language: str + + +@router.post("/translate") +async def api_translate_words(payload: TranslateRequest): + """Generate translations for vocabulary words into a target language. + + Uses local LLM (Ollama) for translation. Results are cached in the + vocabulary_words.translations JSONB field. + """ + from translation_service import translate_and_store + + if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}: + raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt") + + count = await translate_and_store(payload.word_ids, payload.target_language) + return {"translated": count, "target_language": payload.target_language} diff --git a/backend-lehrer/vocabulary/db.py b/backend-lehrer/vocabulary/db.py new file mode 100644 index 0000000..82b15cc --- /dev/null +++ b/backend-lehrer/vocabulary/db.py @@ -0,0 +1,296 @@ +""" +Vocabulary Database — PostgreSQL storage for the vocabulary word catalog. + +Stores 160k+ words with translations, IPA, syllables, examples, images, audio. +Uses asyncpg for async PostgreSQL access (same pattern as game/database.py). + +Schema: lehrer.vocabulary_words (search_path set in main.py) +""" + +import logging +import os +import uuid +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +_RAW_DB_URL = os.getenv( + "DATABASE_URL", + "postgresql://breakpilot:breakpilot@postgres:5432/breakpilot", +) +# Strip SQLAlchemy dialect prefix (asyncpg needs plain postgresql://) +DATABASE_URL = _RAW_DB_URL.replace("postgresql+asyncpg://", "postgresql://") +# Strip search_path options (set via SET after connect) +if "options=" in DATABASE_URL: + DATABASE_URL = DATABASE_URL.split("?")[0] if "options=" in DATABASE_URL.split("?")[-1] else DATABASE_URL + +_pool = None + + +async def get_pool(): + """Get or create the asyncpg connection pool.""" + global _pool + if _pool is None: + import asyncpg + _pool = await asyncpg.create_pool( + DATABASE_URL, min_size=2, max_size=10, + server_settings={"search_path": "lehrer,core,public"}, + ) + return _pool + + +async def init_vocabulary_tables(): + """Create vocabulary tables if they don't exist.""" + pool = await get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS vocabulary_words ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + english TEXT NOT NULL, + german TEXT NOT NULL DEFAULT '', + ipa_en TEXT NOT NULL DEFAULT '', + ipa_de TEXT NOT NULL DEFAULT '', + part_of_speech TEXT NOT NULL DEFAULT '', + syllables_en TEXT[] NOT NULL DEFAULT '{}', + syllables_de TEXT[] NOT NULL DEFAULT '{}', + example_en TEXT NOT NULL DEFAULT '', + example_de TEXT NOT NULL DEFAULT '', + image_url TEXT NOT NULL DEFAULT '', + audio_url_en TEXT NOT NULL DEFAULT '', + audio_url_de TEXT NOT NULL DEFAULT '', + difficulty INT NOT NULL DEFAULT 1, + tags TEXT[] NOT NULL DEFAULT '{}', + translations JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_vocab_english + ON vocabulary_words (lower(english)); + CREATE INDEX IF NOT EXISTS idx_vocab_german + ON vocabulary_words (lower(german)); + CREATE INDEX IF NOT EXISTS idx_vocab_pos + ON vocabulary_words (part_of_speech); + CREATE INDEX IF NOT EXISTS idx_vocab_difficulty + ON vocabulary_words (difficulty); + CREATE INDEX IF NOT EXISTS idx_vocab_tags + ON vocabulary_words USING GIN (tags); + """) + # Enable trigram extension for fuzzy search (optional) + try: + await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;") + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_vocab_english_trgm + ON vocabulary_words USING GIN (english gin_trgm_ops); + """) + except Exception: + logger.info("pg_trgm not available — trigram search disabled, using LIKE fallback") + + logger.info("vocabulary_words table initialized") + + +@dataclass +class VocabularyWord: + """A single vocabulary word with all metadata.""" + id: str = "" + english: str = "" + german: str = "" + ipa_en: str = "" + ipa_de: str = "" + part_of_speech: str = "" + syllables_en: List[str] = field(default_factory=list) + syllables_de: List[str] = field(default_factory=list) + example_en: str = "" + example_de: str = "" + image_url: str = "" + audio_url_en: str = "" + audio_url_de: str = "" + difficulty: int = 1 + tags: List[str] = field(default_factory=list) + translations: Dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +def _row_to_word(row) -> VocabularyWord: + """Convert an asyncpg Record to VocabularyWord.""" + import json + translations = row["translations"] + if isinstance(translations, str): + translations = json.loads(translations) + return VocabularyWord( + id=str(row["id"]), + english=row["english"], + german=row["german"], + ipa_en=row["ipa_en"], + ipa_de=row["ipa_de"], + part_of_speech=row["part_of_speech"], + syllables_en=list(row["syllables_en"] or []), + syllables_de=list(row["syllables_de"] or []), + example_en=row["example_en"], + example_de=row["example_de"], + image_url=row["image_url"], + audio_url_en=row["audio_url_en"], + audio_url_de=row["audio_url_de"], + difficulty=row["difficulty"], + tags=list(row["tags"] or []), + translations=translations or {}, + ) + + +async def search_words( + query: str, lang: str = "en", limit: int = 20, offset: int = 0, +) -> List[VocabularyWord]: + """Full-text search for words. Uses trigram similarity if available, else ILIKE.""" + pool = await get_pool() + col = "english" if lang == "en" else "german" + async with pool.acquire() as conn: + # Try trigram search first, fall back to ILIKE + try: + rows = await conn.fetch( + f""" + SELECT * FROM vocabulary_words + WHERE lower({col}) LIKE $1 OR {col} % $2 + ORDER BY similarity({col}, $2) DESC, lower({col}) + LIMIT $3 OFFSET $4 + """, + f"%{query.lower()}%", query, limit, offset, + ) + except Exception: + rows = await conn.fetch( + f""" + SELECT * FROM vocabulary_words + WHERE lower({col}) LIKE $1 + ORDER BY lower({col}) + LIMIT $2 OFFSET $3 + """, + f"%{query.lower()}%", limit, offset, + ) + return [_row_to_word(r) for r in rows] + + +async def get_word(word_id: str) -> Optional[VocabularyWord]: + """Get a single word by ID.""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM vocabulary_words WHERE id = $1", uuid.UUID(word_id), + ) + return _row_to_word(row) if row else None + + +async def browse_words( + pos: str = "", difficulty: int = 0, tag: str = "", + limit: int = 50, offset: int = 0, +) -> List[VocabularyWord]: + """Browse words with filters.""" + pool = await get_pool() + conditions = [] + params: List[Any] = [] + idx = 1 + + if pos: + conditions.append(f"part_of_speech = ${idx}") + params.append(pos) + idx += 1 + if difficulty > 0: + conditions.append(f"difficulty = ${idx}") + params.append(difficulty) + idx += 1 + if tag: + conditions.append(f"${idx} = ANY(tags)") + params.append(tag) + idx += 1 + + where = "WHERE " + " AND ".join(conditions) if conditions else "" + params.extend([limit, offset]) + + async with pool.acquire() as conn: + rows = await conn.fetch( + f"SELECT * FROM vocabulary_words {where} ORDER BY english LIMIT ${idx} OFFSET ${idx+1}", + *params, + ) + return [_row_to_word(r) for r in rows] + + +async def insert_word(word: VocabularyWord) -> str: + """Insert a new word, returns the ID.""" + pool = await get_pool() + import json + word_id = word.id or str(uuid.uuid4()) + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO vocabulary_words + (id, english, german, ipa_en, ipa_de, part_of_speech, + syllables_en, syllables_de, example_en, example_de, + image_url, audio_url_en, audio_url_de, difficulty, tags, translations) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + ON CONFLICT (id) DO NOTHING + """, + uuid.UUID(word_id), word.english, word.german, + word.ipa_en, word.ipa_de, word.part_of_speech, + word.syllables_en, word.syllables_de, + word.example_en, word.example_de, + word.image_url, word.audio_url_en, word.audio_url_de, + word.difficulty, word.tags, json.dumps(word.translations), + ) + return word_id + + +async def insert_words_bulk(words: List[VocabularyWord]) -> int: + """Bulk insert words. Returns count of inserted rows.""" + pool = await get_pool() + import json + records = [] + for w in words: + wid = w.id or str(uuid.uuid4()) + records.append(( + uuid.UUID(wid), w.english, w.german, + w.ipa_en, w.ipa_de, w.part_of_speech, + w.syllables_en, w.syllables_de, + w.example_en, w.example_de, + w.image_url, w.audio_url_en, w.audio_url_de, + w.difficulty, w.tags, json.dumps(w.translations), + )) + async with pool.acquire() as conn: + await conn.executemany( + """ + INSERT INTO vocabulary_words + (id, english, german, ipa_en, ipa_de, part_of_speech, + syllables_en, syllables_de, example_en, example_de, + image_url, audio_url_en, audio_url_de, difficulty, tags, translations) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + ON CONFLICT (id) DO NOTHING + """, + records, + ) + return len(records) + + +async def count_words() -> int: + """Count total words in the database.""" + pool = await get_pool() + async with pool.acquire() as conn: + return await conn.fetchval("SELECT COUNT(*) FROM vocabulary_words") + + +async def get_all_tags() -> List[str]: + """Get all unique tags.""" + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT DISTINCT unnest(tags) AS tag FROM vocabulary_words ORDER BY tag" + ) + return [r["tag"] for r in rows] + + +async def get_all_pos() -> List[str]: + """Get all unique parts of speech.""" + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT DISTINCT part_of_speech FROM vocabulary_words WHERE part_of_speech != '' ORDER BY part_of_speech" + ) + return [r["part_of_speech"] for r in rows] diff --git a/backend-lehrer/vocabulary_api.py b/backend-lehrer/vocabulary_api.py index f83da53..7965f61 100644 --- a/backend-lehrer/vocabulary_api.py +++ b/backend-lehrer/vocabulary_api.py @@ -1,352 +1,4 @@ -""" -Vocabulary API — Search, browse, and build learning units from the word catalog. - -Endpoints for teachers to find words and create learning units, -and for students to access word details with audio/images/syllables. -""" - -import logging -import json -from typing import Any, Dict, List, Optional - -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -from vocabulary_db import ( - search_words, - get_word, - browse_words, - insert_word, - count_words, - get_all_tags, - get_all_pos, - VocabularyWord, -) -from learning_units import ( - LearningUnitCreate, - create_learning_unit, - get_learning_unit, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/vocabulary", tags=["vocabulary"]) - - -# --------------------------------------------------------------------------- -# Search & Browse -# --------------------------------------------------------------------------- - - -@router.get("/search") -async def api_search_words( - q: str = Query("", description="Search query"), - lang: str = Query("en", pattern="^(en|de)$"), - limit: int = Query(20, ge=1, le=100), - offset: int = Query(0, ge=0), -): - """Full-text search for vocabulary words.""" - if not q.strip(): - return {"words": [], "query": q, "total": 0} - - words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset) - return { - "words": [w.to_dict() for w in words], - "query": q, - "total": len(words), - } - - -@router.get("/browse") -async def api_browse_words( - pos: str = Query("", description="Part of speech filter"), - difficulty: int = Query(0, ge=0, le=5, description="Difficulty 1-5, 0=all"), - tag: str = Query("", description="Tag filter"), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), -): - """Browse vocabulary words with filters.""" - words = await browse_words( - pos=pos, difficulty=difficulty, tag=tag, - limit=limit, offset=offset, - ) - return { - "words": [w.to_dict() for w in words], - "filters": {"pos": pos, "difficulty": difficulty, "tag": tag}, - "total": len(words), - } - - -@router.get("/word/{word_id}") -async def api_get_word(word_id: str): - """Get a single word with all details.""" - word = await get_word(word_id) - if not word: - raise HTTPException(status_code=404, detail="Wort nicht gefunden") - return word.to_dict() - - -@router.get("/filters") -async def api_get_filters(): - """Get available filter options (tags, parts of speech, word count).""" - tags = await get_all_tags() - pos_list = await get_all_pos() - total = await count_words() - return { - "tags": tags, - "parts_of_speech": pos_list, - "total_words": total, - } - - -# --------------------------------------------------------------------------- -# Audio TTS for Words -# --------------------------------------------------------------------------- - - -@router.get("/word/{word_id}/audio/{lang}") -async def api_get_word_audio(word_id: str, lang: str = "en"): - """Get or generate TTS audio for a vocabulary word. - - Returns MP3 audio. Generated on first request, cached after. - Uses Piper TTS (MIT license) with Thorsten (DE) and Lessac (EN) voices. - """ - from fastapi.responses import Response as FastAPIResponse - - word = await get_word(word_id) - if not word: - raise HTTPException(status_code=404, detail="Wort nicht gefunden") - - text = word.english if lang == "en" else word.german - if not text: - raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'") - - from audio_service import get_or_generate_audio - audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id) - - if not audio_bytes: - raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar") - - return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg") - - -@router.get("/word/{word_id}/audio-syllables/{lang}") -async def api_get_syllable_audio(word_id: str, lang: str = "en"): - """Get TTS audio with slow syllable pronunciation. - - Generates audio like "ap ... ple" with pauses between syllables. - """ - from fastapi.responses import Response as FastAPIResponse - - word = await get_word(word_id) - if not word: - raise HTTPException(status_code=404, detail="Wort nicht gefunden") - - syllables = word.syllables_en if lang == "en" else word.syllables_de - if not syllables: - # Fallback to full word - text = word.english if lang == "en" else word.german - syllables = [text] - - # Join syllables with pauses (Piper handles "..." as pause) - slow_text = " ... ".join(syllables) - - from audio_service import get_or_generate_audio - cache_key = f"{word_id}_syl_{lang}" - audio_bytes = await get_or_generate_audio(slow_text, language=lang, word_id=cache_key) - - if not audio_bytes: - raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar") - - return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg") - - -# --------------------------------------------------------------------------- -# Learning Unit Creation from Word Selection -# --------------------------------------------------------------------------- - - -class CreateUnitFromWordsPayload(BaseModel): - title: str - word_ids: List[str] - grade: Optional[str] = None - language: Optional[str] = "de" - - -@router.post("/units") -async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload): - """Create a learning unit from selected vocabulary word IDs. - - Fetches full word details, creates a LearningUnit in the - learning_units system, and stores the vocabulary data. - """ - if not payload.word_ids: - raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt") - - # Fetch all selected words - words = [] - for wid in payload.word_ids: - word = await get_word(wid) - if word: - words.append(word) - - if not words: - raise HTTPException(status_code=404, detail="Keine der Woerter gefunden") - - # Create learning unit - lu = create_learning_unit(LearningUnitCreate( - title=payload.title, - topic="Vocabulary", - grade_level=payload.grade or "5-8", - language=payload.language or "de", - status="raw", - )) - - # Save vocabulary data as analysis JSON for generators - import os - analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten") - os.makedirs(analysis_dir, exist_ok=True) - - vocab_data = [w.to_dict() for w in words] - analysis_path = os.path.join(analysis_dir, f"{lu.id}_vocab.json") - with open(analysis_path, "w", encoding="utf-8") as f: - json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2) - - # Also save as QA items for flashcards/type trainer - qa_items = [] - for i, w in enumerate(words): - qa_items.append({ - "id": f"qa_{i+1}", - "question": w.english, - "answer": w.german, - "question_type": "knowledge", - "key_terms": [w.english], - "difficulty": w.difficulty, - "source_hint": w.part_of_speech, - "leitner_box": 0, - "correct_count": 0, - "incorrect_count": 0, - "last_seen": None, - "next_review": None, - # Extra fields for enhanced flashcards - "ipa_en": w.ipa_en, - "ipa_de": w.ipa_de, - "syllables_en": w.syllables_en, - "syllables_de": w.syllables_de, - "example_en": w.example_en, - "example_de": w.example_de, - "image_url": w.image_url, - "audio_url_en": w.audio_url_en, - "audio_url_de": w.audio_url_de, - "part_of_speech": w.part_of_speech, - "translations": w.translations, - }) - - qa_path = os.path.join(analysis_dir, f"{lu.id}_qa.json") - with open(qa_path, "w", encoding="utf-8") as f: - json.dump({ - "qa_items": qa_items, - "metadata": { - "subject": "English Vocabulary", - "grade_level": payload.grade or "5-8", - "source_title": payload.title, - "total_questions": len(qa_items), - }, - }, f, ensure_ascii=False, indent=2) - - logger.info(f"Created vocab unit {lu.id} with {len(words)} words") - - return { - "unit_id": lu.id, - "title": payload.title, - "word_count": len(words), - "status": "created", - } - - -@router.get("/units/{unit_id}") -async def api_get_unit_words(unit_id: str): - """Get all words for a learning unit.""" - import os - vocab_path = os.path.join( - os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten"), - f"{unit_id}_vocab.json", - ) - if not os.path.exists(vocab_path): - raise HTTPException(status_code=404, detail="Unit nicht gefunden") - - with open(vocab_path, "r", encoding="utf-8") as f: - data = json.load(f) - - return { - "unit_id": unit_id, - "title": data.get("title", ""), - "words": data.get("words", []), - } - - -# --------------------------------------------------------------------------- -# Bulk Import (for seeding the dictionary) -# --------------------------------------------------------------------------- - - -class BulkImportPayload(BaseModel): - words: List[Dict[str, Any]] - - -@router.post("/import") -async def api_bulk_import(payload: BulkImportPayload): - """Bulk import vocabulary words (for seeding the dictionary). - - Each word dict should have at minimum: english, german. - Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de, - example_en, example_de, difficulty, tags, translations. - """ - from vocabulary_db import insert_words_bulk - - words = [] - for w in payload.words: - words.append(VocabularyWord( - english=w.get("english", ""), - german=w.get("german", ""), - ipa_en=w.get("ipa_en", ""), - ipa_de=w.get("ipa_de", ""), - part_of_speech=w.get("part_of_speech", ""), - syllables_en=w.get("syllables_en", []), - syllables_de=w.get("syllables_de", []), - example_en=w.get("example_en", ""), - example_de=w.get("example_de", ""), - difficulty=w.get("difficulty", 1), - tags=w.get("tags", []), - translations=w.get("translations", {}), - )) - - count = await insert_words_bulk(words) - logger.info(f"Bulk imported {count} vocabulary words") - return {"imported": count} - - -# --------------------------------------------------------------------------- -# Translation Generation -# --------------------------------------------------------------------------- - - -class TranslateRequest(BaseModel): - word_ids: List[str] - target_language: str - - -@router.post("/translate") -async def api_translate_words(payload: TranslateRequest): - """Generate translations for vocabulary words into a target language. - - Uses local LLM (Ollama) for translation. Results are cached in the - vocabulary_words.translations JSONB field. - """ - from translation_service import translate_and_store - - if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}: - raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt") - - count = await translate_and_store(payload.word_ids, payload.target_language) - return {"translated": count, "target_language": payload.target_language} +# Backward-compat shim -- module moved to vocabulary/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("vocabulary.api") diff --git a/backend-lehrer/vocabulary_db.py b/backend-lehrer/vocabulary_db.py index 82b15cc..7e6172f 100644 --- a/backend-lehrer/vocabulary_db.py +++ b/backend-lehrer/vocabulary_db.py @@ -1,296 +1,4 @@ -""" -Vocabulary Database — PostgreSQL storage for the vocabulary word catalog. - -Stores 160k+ words with translations, IPA, syllables, examples, images, audio. -Uses asyncpg for async PostgreSQL access (same pattern as game/database.py). - -Schema: lehrer.vocabulary_words (search_path set in main.py) -""" - -import logging -import os -import uuid -from dataclasses import dataclass, field, asdict -from typing import Any, Dict, List, Optional - -logger = logging.getLogger(__name__) - -_RAW_DB_URL = os.getenv( - "DATABASE_URL", - "postgresql://breakpilot:breakpilot@postgres:5432/breakpilot", -) -# Strip SQLAlchemy dialect prefix (asyncpg needs plain postgresql://) -DATABASE_URL = _RAW_DB_URL.replace("postgresql+asyncpg://", "postgresql://") -# Strip search_path options (set via SET after connect) -if "options=" in DATABASE_URL: - DATABASE_URL = DATABASE_URL.split("?")[0] if "options=" in DATABASE_URL.split("?")[-1] else DATABASE_URL - -_pool = None - - -async def get_pool(): - """Get or create the asyncpg connection pool.""" - global _pool - if _pool is None: - import asyncpg - _pool = await asyncpg.create_pool( - DATABASE_URL, min_size=2, max_size=10, - server_settings={"search_path": "lehrer,core,public"}, - ) - return _pool - - -async def init_vocabulary_tables(): - """Create vocabulary tables if they don't exist.""" - pool = await get_pool() - async with pool.acquire() as conn: - await conn.execute(""" - CREATE TABLE IF NOT EXISTS vocabulary_words ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - english TEXT NOT NULL, - german TEXT NOT NULL DEFAULT '', - ipa_en TEXT NOT NULL DEFAULT '', - ipa_de TEXT NOT NULL DEFAULT '', - part_of_speech TEXT NOT NULL DEFAULT '', - syllables_en TEXT[] NOT NULL DEFAULT '{}', - syllables_de TEXT[] NOT NULL DEFAULT '{}', - example_en TEXT NOT NULL DEFAULT '', - example_de TEXT NOT NULL DEFAULT '', - image_url TEXT NOT NULL DEFAULT '', - audio_url_en TEXT NOT NULL DEFAULT '', - audio_url_de TEXT NOT NULL DEFAULT '', - difficulty INT NOT NULL DEFAULT 1, - tags TEXT[] NOT NULL DEFAULT '{}', - translations JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - - CREATE INDEX IF NOT EXISTS idx_vocab_english - ON vocabulary_words (lower(english)); - CREATE INDEX IF NOT EXISTS idx_vocab_german - ON vocabulary_words (lower(german)); - CREATE INDEX IF NOT EXISTS idx_vocab_pos - ON vocabulary_words (part_of_speech); - CREATE INDEX IF NOT EXISTS idx_vocab_difficulty - ON vocabulary_words (difficulty); - CREATE INDEX IF NOT EXISTS idx_vocab_tags - ON vocabulary_words USING GIN (tags); - """) - # Enable trigram extension for fuzzy search (optional) - try: - await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;") - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_vocab_english_trgm - ON vocabulary_words USING GIN (english gin_trgm_ops); - """) - except Exception: - logger.info("pg_trgm not available — trigram search disabled, using LIKE fallback") - - logger.info("vocabulary_words table initialized") - - -@dataclass -class VocabularyWord: - """A single vocabulary word with all metadata.""" - id: str = "" - english: str = "" - german: str = "" - ipa_en: str = "" - ipa_de: str = "" - part_of_speech: str = "" - syllables_en: List[str] = field(default_factory=list) - syllables_de: List[str] = field(default_factory=list) - example_en: str = "" - example_de: str = "" - image_url: str = "" - audio_url_en: str = "" - audio_url_de: str = "" - difficulty: int = 1 - tags: List[str] = field(default_factory=list) - translations: Dict[str, str] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - return asdict(self) - - -def _row_to_word(row) -> VocabularyWord: - """Convert an asyncpg Record to VocabularyWord.""" - import json - translations = row["translations"] - if isinstance(translations, str): - translations = json.loads(translations) - return VocabularyWord( - id=str(row["id"]), - english=row["english"], - german=row["german"], - ipa_en=row["ipa_en"], - ipa_de=row["ipa_de"], - part_of_speech=row["part_of_speech"], - syllables_en=list(row["syllables_en"] or []), - syllables_de=list(row["syllables_de"] or []), - example_en=row["example_en"], - example_de=row["example_de"], - image_url=row["image_url"], - audio_url_en=row["audio_url_en"], - audio_url_de=row["audio_url_de"], - difficulty=row["difficulty"], - tags=list(row["tags"] or []), - translations=translations or {}, - ) - - -async def search_words( - query: str, lang: str = "en", limit: int = 20, offset: int = 0, -) -> List[VocabularyWord]: - """Full-text search for words. Uses trigram similarity if available, else ILIKE.""" - pool = await get_pool() - col = "english" if lang == "en" else "german" - async with pool.acquire() as conn: - # Try trigram search first, fall back to ILIKE - try: - rows = await conn.fetch( - f""" - SELECT * FROM vocabulary_words - WHERE lower({col}) LIKE $1 OR {col} % $2 - ORDER BY similarity({col}, $2) DESC, lower({col}) - LIMIT $3 OFFSET $4 - """, - f"%{query.lower()}%", query, limit, offset, - ) - except Exception: - rows = await conn.fetch( - f""" - SELECT * FROM vocabulary_words - WHERE lower({col}) LIKE $1 - ORDER BY lower({col}) - LIMIT $2 OFFSET $3 - """, - f"%{query.lower()}%", limit, offset, - ) - return [_row_to_word(r) for r in rows] - - -async def get_word(word_id: str) -> Optional[VocabularyWord]: - """Get a single word by ID.""" - pool = await get_pool() - async with pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT * FROM vocabulary_words WHERE id = $1", uuid.UUID(word_id), - ) - return _row_to_word(row) if row else None - - -async def browse_words( - pos: str = "", difficulty: int = 0, tag: str = "", - limit: int = 50, offset: int = 0, -) -> List[VocabularyWord]: - """Browse words with filters.""" - pool = await get_pool() - conditions = [] - params: List[Any] = [] - idx = 1 - - if pos: - conditions.append(f"part_of_speech = ${idx}") - params.append(pos) - idx += 1 - if difficulty > 0: - conditions.append(f"difficulty = ${idx}") - params.append(difficulty) - idx += 1 - if tag: - conditions.append(f"${idx} = ANY(tags)") - params.append(tag) - idx += 1 - - where = "WHERE " + " AND ".join(conditions) if conditions else "" - params.extend([limit, offset]) - - async with pool.acquire() as conn: - rows = await conn.fetch( - f"SELECT * FROM vocabulary_words {where} ORDER BY english LIMIT ${idx} OFFSET ${idx+1}", - *params, - ) - return [_row_to_word(r) for r in rows] - - -async def insert_word(word: VocabularyWord) -> str: - """Insert a new word, returns the ID.""" - pool = await get_pool() - import json - word_id = word.id or str(uuid.uuid4()) - async with pool.acquire() as conn: - await conn.execute( - """ - INSERT INTO vocabulary_words - (id, english, german, ipa_en, ipa_de, part_of_speech, - syllables_en, syllables_de, example_en, example_de, - image_url, audio_url_en, audio_url_de, difficulty, tags, translations) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) - ON CONFLICT (id) DO NOTHING - """, - uuid.UUID(word_id), word.english, word.german, - word.ipa_en, word.ipa_de, word.part_of_speech, - word.syllables_en, word.syllables_de, - word.example_en, word.example_de, - word.image_url, word.audio_url_en, word.audio_url_de, - word.difficulty, word.tags, json.dumps(word.translations), - ) - return word_id - - -async def insert_words_bulk(words: List[VocabularyWord]) -> int: - """Bulk insert words. Returns count of inserted rows.""" - pool = await get_pool() - import json - records = [] - for w in words: - wid = w.id or str(uuid.uuid4()) - records.append(( - uuid.UUID(wid), w.english, w.german, - w.ipa_en, w.ipa_de, w.part_of_speech, - w.syllables_en, w.syllables_de, - w.example_en, w.example_de, - w.image_url, w.audio_url_en, w.audio_url_de, - w.difficulty, w.tags, json.dumps(w.translations), - )) - async with pool.acquire() as conn: - await conn.executemany( - """ - INSERT INTO vocabulary_words - (id, english, german, ipa_en, ipa_de, part_of_speech, - syllables_en, syllables_de, example_en, example_de, - image_url, audio_url_en, audio_url_de, difficulty, tags, translations) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) - ON CONFLICT (id) DO NOTHING - """, - records, - ) - return len(records) - - -async def count_words() -> int: - """Count total words in the database.""" - pool = await get_pool() - async with pool.acquire() as conn: - return await conn.fetchval("SELECT COUNT(*) FROM vocabulary_words") - - -async def get_all_tags() -> List[str]: - """Get all unique tags.""" - pool = await get_pool() - async with pool.acquire() as conn: - rows = await conn.fetch( - "SELECT DISTINCT unnest(tags) AS tag FROM vocabulary_words ORDER BY tag" - ) - return [r["tag"] for r in rows] - - -async def get_all_pos() -> List[str]: - """Get all unique parts of speech.""" - pool = await get_pool() - async with pool.acquire() as conn: - rows = await conn.fetch( - "SELECT DISTINCT part_of_speech FROM vocabulary_words WHERE part_of_speech != '' ORDER BY part_of_speech" - ) - return [r["part_of_speech"] for r in rows] +# Backward-compat shim -- module moved to vocabulary/db.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("vocabulary.db") diff --git a/backend-lehrer/worksheets/__init__.py b/backend-lehrer/worksheets/__init__.py new file mode 100644 index 0000000..56a4370 --- /dev/null +++ b/backend-lehrer/worksheets/__init__.py @@ -0,0 +1,37 @@ +# Worksheets Module +# worksheets/api.py — API router (generate MC, cloze, mindmap, quiz) +# worksheets/models.py — Pydantic models and helpers + +from .api import router +from .models import ( + ContentType, + GenerateRequest, + MCGenerateRequest, + ClozeGenerateRequest, + MindmapGenerateRequest, + QuizGenerateRequest, + BatchGenerateRequest, + WorksheetContent, + GenerateResponse, + BatchGenerateResponse, + parse_difficulty, + parse_cloze_type, + parse_quiz_types, +) + +__all__ = [ + "router", + "ContentType", + "GenerateRequest", + "MCGenerateRequest", + "ClozeGenerateRequest", + "MindmapGenerateRequest", + "QuizGenerateRequest", + "BatchGenerateRequest", + "WorksheetContent", + "GenerateResponse", + "BatchGenerateResponse", + "parse_difficulty", + "parse_cloze_type", + "parse_quiz_types", +] diff --git a/backend-lehrer/worksheets/api.py b/backend-lehrer/worksheets/api.py new file mode 100644 index 0000000..4229865 --- /dev/null +++ b/backend-lehrer/worksheets/api.py @@ -0,0 +1,439 @@ +""" +Worksheets API - REST API für Arbeitsblatt-Generierung. + +Integriert alle Content-Generatoren: +- Multiple Choice Questions +- Lückentexte (Cloze) +- Mindmaps +- Quizze (True/False, Matching, Sorting, Open) + +Unterstützt: +- H5P-Export für interaktive Inhalte +- PDF-Export für Druckversionen +- JSON-Export für Frontend-Integration +""" + +import logging +import uuid +from datetime import datetime +from typing import Dict + +from fastapi import APIRouter, HTTPException + +from generators import ( + MultipleChoiceGenerator, + ClozeGenerator, + MindmapGenerator, + QuizGenerator +) + +from .models import ( + ContentType, + GenerateRequest, + MCGenerateRequest, + ClozeGenerateRequest, + MindmapGenerateRequest, + QuizGenerateRequest, + BatchGenerateRequest, + WorksheetContent, + GenerateResponse, + BatchGenerateResponse, + parse_difficulty, + parse_cloze_type, + parse_quiz_types, +) + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/worksheets", + tags=["worksheets"], +) + + +# ============================================================================ +# In-Memory Storage (später durch DB ersetzen) +# ============================================================================ + +_generated_content: Dict[str, WorksheetContent] = {} + + +# ============================================================================ +# Generator Instances +# ============================================================================ + +mc_generator = MultipleChoiceGenerator() +cloze_generator = ClozeGenerator() +mindmap_generator = MindmapGenerator() +quiz_generator = QuizGenerator() + + +def _store_content(content: WorksheetContent) -> None: + """Speichert generierten Content.""" + _generated_content[content.id] = content + + +# ============================================================================ +# API Endpoints +# ============================================================================ + +@router.post("/generate/multiple-choice", response_model=GenerateResponse) +async def generate_multiple_choice(request: MCGenerateRequest): + """Generiert Multiple-Choice-Fragen aus Quelltext.""" + try: + difficulty = parse_difficulty(request.difficulty) + + questions = mc_generator.generate( + source_text=request.source_text, + num_questions=request.num_questions, + difficulty=difficulty, + subject=request.subject, + grade_level=request.grade_level + ) + + if not questions: + return GenerateResponse( + success=False, + error="Keine Fragen generiert. Text möglicherweise zu kurz." + ) + + questions_dict = mc_generator.to_dict(questions) + h5p_format = mc_generator.to_h5p_format(questions) + + content = WorksheetContent( + id=str(uuid.uuid4()), + content_type=ContentType.MULTIPLE_CHOICE.value, + data={"questions": questions_dict}, + h5p_format=h5p_format, + created_at=datetime.utcnow(), + topic=request.topic, + difficulty=request.difficulty + ) + + _store_content(content) + return GenerateResponse(success=True, content=content) + + except Exception as e: + logger.error(f"Error generating MC questions: {e}") + return GenerateResponse(success=False, error=str(e)) + + +@router.post("/generate/cloze", response_model=GenerateResponse) +async def generate_cloze(request: ClozeGenerateRequest): + """Generiert Lückentext aus Quelltext.""" + try: + cloze_type = parse_cloze_type(request.cloze_type) + + cloze = cloze_generator.generate( + source_text=request.source_text, + num_gaps=request.num_gaps, + difficulty=request.difficulty, + cloze_type=cloze_type, + topic=request.topic + ) + + if not cloze.gaps: + return GenerateResponse( + success=False, + error="Keine Lücken generiert. Text möglicherweise zu kurz." + ) + + cloze_dict = cloze_generator.to_dict(cloze) + h5p_format = cloze_generator.to_h5p_format(cloze) + + content = WorksheetContent( + id=str(uuid.uuid4()), + content_type=ContentType.CLOZE.value, + data=cloze_dict, + h5p_format=h5p_format, + created_at=datetime.utcnow(), + topic=request.topic, + difficulty=request.difficulty + ) + + _store_content(content) + return GenerateResponse(success=True, content=content) + + except Exception as e: + logger.error(f"Error generating cloze: {e}") + return GenerateResponse(success=False, error=str(e)) + + +@router.post("/generate/mindmap", response_model=GenerateResponse) +async def generate_mindmap(request: MindmapGenerateRequest): + """Generiert Mindmap aus Quelltext.""" + try: + mindmap = mindmap_generator.generate( + source_text=request.source_text, + title=request.topic, + max_depth=request.max_depth, + topic=request.topic + ) + + if mindmap.total_nodes <= 1: + return GenerateResponse( + success=False, + error="Mindmap konnte nicht generiert werden. Text möglicherweise zu kurz." + ) + + mindmap_dict = mindmap_generator.to_dict(mindmap) + mermaid = mindmap_generator.to_mermaid(mindmap) + json_tree = mindmap_generator.to_json_tree(mindmap) + + content = WorksheetContent( + id=str(uuid.uuid4()), + content_type=ContentType.MINDMAP.value, + data={ + "mindmap": mindmap_dict, + "mermaid": mermaid, + "json_tree": json_tree + }, + h5p_format=None, + created_at=datetime.utcnow(), + topic=request.topic, + difficulty=None + ) + + _store_content(content) + return GenerateResponse(success=True, content=content) + + except Exception as e: + logger.error(f"Error generating mindmap: {e}") + return GenerateResponse(success=False, error=str(e)) + + +@router.post("/generate/quiz", response_model=GenerateResponse) +async def generate_quiz(request: QuizGenerateRequest): + """Generiert Quiz mit verschiedenen Fragetypen.""" + try: + quiz_types = parse_quiz_types(request.quiz_types) + + all_questions = [] + quizzes = [] + + for quiz_type in quiz_types: + quiz = quiz_generator.generate( + source_text=request.source_text, + quiz_type=quiz_type, + num_questions=request.num_items, + difficulty=request.difficulty, + topic=request.topic + ) + quizzes.append(quiz) + all_questions.extend(quiz.questions) + + if len(all_questions) == 0: + return GenerateResponse( + success=False, + error="Quiz konnte nicht generiert werden. Text möglicherweise zu kurz." + ) + + combined_quiz_dict = { + "quiz_types": [qt.value for qt in quiz_types], + "title": f"Combined Quiz - {request.topic or 'Various Topics'}", + "topic": request.topic, + "difficulty": request.difficulty, + "questions": [] + } + + for quiz in quizzes: + quiz_dict = quiz_generator.to_dict(quiz) + combined_quiz_dict["questions"].extend(quiz_dict.get("questions", [])) + + h5p_format = quiz_generator.to_h5p_format(quizzes[0]) if quizzes else {} + + content = WorksheetContent( + id=str(uuid.uuid4()), + content_type=ContentType.QUIZ.value, + data=combined_quiz_dict, + h5p_format=h5p_format, + created_at=datetime.utcnow(), + topic=request.topic, + difficulty=request.difficulty + ) + + _store_content(content) + return GenerateResponse(success=True, content=content) + + except Exception as e: + logger.error(f"Error generating quiz: {e}") + return GenerateResponse(success=False, error=str(e)) + + +@router.post("/generate/batch", response_model=BatchGenerateResponse) +async def generate_batch(request: BatchGenerateRequest): + """Generiert mehrere Content-Typen aus einem Quelltext.""" + contents = [] + errors = [] + + for content_type in request.content_types: + try: + if content_type == "multiple_choice": + mc_req = MCGenerateRequest( + source_text=request.source_text, + topic=request.topic, + subject=request.subject, + grade_level=request.grade_level, + difficulty=request.difficulty + ) + result = await generate_multiple_choice(mc_req) + + elif content_type == "cloze": + cloze_req = ClozeGenerateRequest( + source_text=request.source_text, + topic=request.topic, + subject=request.subject, + grade_level=request.grade_level, + difficulty=request.difficulty + ) + result = await generate_cloze(cloze_req) + + elif content_type == "mindmap": + mindmap_req = MindmapGenerateRequest( + source_text=request.source_text, + topic=request.topic, + subject=request.subject, + grade_level=request.grade_level + ) + result = await generate_mindmap(mindmap_req) + + elif content_type == "quiz": + quiz_req = QuizGenerateRequest( + source_text=request.source_text, + topic=request.topic, + subject=request.subject, + grade_level=request.grade_level, + difficulty=request.difficulty + ) + result = await generate_quiz(quiz_req) + + else: + errors.append(f"Unbekannter Content-Typ: {content_type}") + continue + + if result.success and result.content: + contents.append(result.content) + elif result.error: + errors.append(f"{content_type}: {result.error}") + + except Exception as e: + errors.append(f"{content_type}: {str(e)}") + + return BatchGenerateResponse( + success=len(contents) > 0, + contents=contents, + errors=errors + ) + + +@router.get("/content/{content_id}", response_model=GenerateResponse) +async def get_content(content_id: str): + """Ruft gespeicherten Content ab.""" + content = _generated_content.get(content_id) + + if not content: + raise HTTPException(status_code=404, detail="Content nicht gefunden") + + return GenerateResponse(success=True, content=content) + + +@router.get("/content/{content_id}/h5p") +async def get_content_h5p(content_id: str): + """Gibt H5P-Format für Content zurück.""" + content = _generated_content.get(content_id) + + if not content: + raise HTTPException(status_code=404, detail="Content nicht gefunden") + + if not content.h5p_format: + raise HTTPException( + status_code=400, + detail="H5P-Format für diesen Content-Typ nicht verfügbar" + ) + + return content.h5p_format + + +@router.delete("/content/{content_id}") +async def delete_content(content_id: str): + """Löscht gespeicherten Content.""" + if content_id not in _generated_content: + raise HTTPException(status_code=404, detail="Content nicht gefunden") + + del _generated_content[content_id] + return {"status": "deleted", "id": content_id} + + +@router.get("/types") +async def list_content_types(): + """Listet verfügbare Content-Typen und deren Optionen.""" + return { + "content_types": [ + { + "type": "multiple_choice", + "name": "Multiple Choice", + "description": "Fragen mit 4 Antwortmöglichkeiten", + "options": { + "num_questions": {"min": 1, "max": 20, "default": 5}, + "difficulty": ["easy", "medium", "hard"] + }, + "h5p_supported": True + }, + { + "type": "cloze", + "name": "Lückentext", + "description": "Text mit ausgeblendeten Schlüsselwörtern", + "options": { + "num_gaps": {"min": 1, "max": 15, "default": 5}, + "difficulty": ["easy", "medium", "hard"], + "cloze_type": ["fill_in", "drag_drop", "dropdown"] + }, + "h5p_supported": True + }, + { + "type": "mindmap", + "name": "Mindmap", + "description": "Hierarchische Struktur aus Hauptthema und Unterthemen", + "options": { + "max_depth": {"min": 2, "max": 5, "default": 3} + }, + "h5p_supported": False, + "export_formats": ["mermaid", "json_tree"] + }, + { + "type": "quiz", + "name": "Quiz", + "description": "Verschiedene Fragetypen kombiniert", + "options": { + "quiz_types": ["true_false", "matching", "sorting", "open_ended"], + "num_items": {"min": 1, "max": 10, "default": 5}, + "difficulty": ["easy", "medium", "hard"] + }, + "h5p_supported": True + } + ] + } + + +@router.get("/history") +async def get_generation_history(limit: int = 10): + """Gibt die letzten generierten Contents zurück.""" + sorted_contents = sorted( + _generated_content.values(), + key=lambda x: x.created_at, + reverse=True + ) + + return { + "total": len(_generated_content), + "contents": [ + { + "id": c.id, + "content_type": c.content_type, + "topic": c.topic, + "difficulty": c.difficulty, + "created_at": c.created_at.isoformat() + } + for c in sorted_contents[:limit] + ] + } diff --git a/backend-lehrer/worksheets/models.py b/backend-lehrer/worksheets/models.py new file mode 100644 index 0000000..87c6b25 --- /dev/null +++ b/backend-lehrer/worksheets/models.py @@ -0,0 +1,135 @@ +""" +Worksheets API - Pydantic Models und Helpers. + +Request-/Response-Models und Hilfsfunktionen fuer die +Arbeitsblatt-Generierungs-API. +""" + +import uuid +from datetime import datetime +from typing import List, Dict, Any, Optional +from enum import Enum + +from pydantic import BaseModel, Field + +from generators.mc_generator import Difficulty +from generators.cloze_generator import ClozeType +from generators.quiz_generator import QuizType + + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +class ContentType(str, Enum): + """Verfügbare Content-Typen.""" + MULTIPLE_CHOICE = "multiple_choice" + CLOZE = "cloze" + MINDMAP = "mindmap" + QUIZ = "quiz" + + +class GenerateRequest(BaseModel): + """Basis-Request für Generierung.""" + source_text: str = Field(..., min_length=50, description="Quelltext für Generierung") + topic: Optional[str] = Field(None, description="Thema/Titel") + subject: Optional[str] = Field(None, description="Fach") + grade_level: Optional[str] = Field(None, description="Klassenstufe") + + +class MCGenerateRequest(GenerateRequest): + """Request für Multiple-Choice-Generierung.""" + num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen") + difficulty: str = Field("medium", description="easy, medium, hard") + + +class ClozeGenerateRequest(GenerateRequest): + """Request für Lückentext-Generierung.""" + num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken") + difficulty: str = Field("medium", description="easy, medium, hard") + cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown") + + +class MindmapGenerateRequest(GenerateRequest): + """Request für Mindmap-Generierung.""" + max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe") + + +class QuizGenerateRequest(GenerateRequest): + """Request für Quiz-Generierung.""" + quiz_types: List[str] = Field( + ["true_false", "matching"], + description="Typen: true_false, matching, sorting, open_ended" + ) + num_items: int = Field(5, ge=1, le=10, description="Items pro Typ") + difficulty: str = Field("medium", description="easy, medium, hard") + + +class BatchGenerateRequest(BaseModel): + """Request für Batch-Generierung mehrerer Content-Typen.""" + source_text: str = Field(..., min_length=50) + content_types: List[str] = Field(..., description="Liste von Content-Typen") + topic: Optional[str] = None + subject: Optional[str] = None + grade_level: Optional[str] = None + difficulty: str = "medium" + + +class WorksheetContent(BaseModel): + """Generierter Content.""" + id: str + content_type: str + data: Dict[str, Any] + h5p_format: Optional[Dict[str, Any]] = None + created_at: datetime + topic: Optional[str] = None + difficulty: Optional[str] = None + + +class GenerateResponse(BaseModel): + """Response mit generiertem Content.""" + success: bool + content: Optional[WorksheetContent] = None + error: Optional[str] = None + + +class BatchGenerateResponse(BaseModel): + """Response für Batch-Generierung.""" + success: bool + contents: List[WorksheetContent] = [] + errors: List[str] = [] + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def parse_difficulty(difficulty_str: str) -> Difficulty: + """Konvertiert String zu Difficulty Enum.""" + mapping = { + "easy": Difficulty.EASY, + "medium": Difficulty.MEDIUM, + "hard": Difficulty.HARD + } + return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM) + + +def parse_cloze_type(type_str: str) -> ClozeType: + """Konvertiert String zu ClozeType Enum.""" + mapping = { + "fill_in": ClozeType.FILL_IN, + "drag_drop": ClozeType.DRAG_DROP, + "dropdown": ClozeType.DROPDOWN + } + return mapping.get(type_str.lower(), ClozeType.FILL_IN) + + +def parse_quiz_types(type_strs: List[str]) -> List[QuizType]: + """Konvertiert String-Liste zu QuizType Enums.""" + mapping = { + "true_false": QuizType.TRUE_FALSE, + "matching": QuizType.MATCHING, + "sorting": QuizType.SORTING, + "open_ended": QuizType.OPEN_ENDED + } + return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs] diff --git a/backend-lehrer/worksheets_api.py b/backend-lehrer/worksheets_api.py index d6f9f8d..a13de4e 100644 --- a/backend-lehrer/worksheets_api.py +++ b/backend-lehrer/worksheets_api.py @@ -1,439 +1,4 @@ -""" -Worksheets API - REST API für Arbeitsblatt-Generierung. - -Integriert alle Content-Generatoren: -- Multiple Choice Questions -- Lückentexte (Cloze) -- Mindmaps -- Quizze (True/False, Matching, Sorting, Open) - -Unterstützt: -- H5P-Export für interaktive Inhalte -- PDF-Export für Druckversionen -- JSON-Export für Frontend-Integration -""" - -import logging -import uuid -from datetime import datetime -from typing import Dict - -from fastapi import APIRouter, HTTPException - -from generators import ( - MultipleChoiceGenerator, - ClozeGenerator, - MindmapGenerator, - QuizGenerator -) - -from worksheets_models import ( - ContentType, - GenerateRequest, - MCGenerateRequest, - ClozeGenerateRequest, - MindmapGenerateRequest, - QuizGenerateRequest, - BatchGenerateRequest, - WorksheetContent, - GenerateResponse, - BatchGenerateResponse, - parse_difficulty, - parse_cloze_type, - parse_quiz_types, -) - -logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/worksheets", - tags=["worksheets"], -) - - -# ============================================================================ -# In-Memory Storage (später durch DB ersetzen) -# ============================================================================ - -_generated_content: Dict[str, WorksheetContent] = {} - - -# ============================================================================ -# Generator Instances -# ============================================================================ - -mc_generator = MultipleChoiceGenerator() -cloze_generator = ClozeGenerator() -mindmap_generator = MindmapGenerator() -quiz_generator = QuizGenerator() - - -def _store_content(content: WorksheetContent) -> None: - """Speichert generierten Content.""" - _generated_content[content.id] = content - - -# ============================================================================ -# API Endpoints -# ============================================================================ - -@router.post("/generate/multiple-choice", response_model=GenerateResponse) -async def generate_multiple_choice(request: MCGenerateRequest): - """Generiert Multiple-Choice-Fragen aus Quelltext.""" - try: - difficulty = parse_difficulty(request.difficulty) - - questions = mc_generator.generate( - source_text=request.source_text, - num_questions=request.num_questions, - difficulty=difficulty, - subject=request.subject, - grade_level=request.grade_level - ) - - if not questions: - return GenerateResponse( - success=False, - error="Keine Fragen generiert. Text möglicherweise zu kurz." - ) - - questions_dict = mc_generator.to_dict(questions) - h5p_format = mc_generator.to_h5p_format(questions) - - content = WorksheetContent( - id=str(uuid.uuid4()), - content_type=ContentType.MULTIPLE_CHOICE.value, - data={"questions": questions_dict}, - h5p_format=h5p_format, - created_at=datetime.utcnow(), - topic=request.topic, - difficulty=request.difficulty - ) - - _store_content(content) - return GenerateResponse(success=True, content=content) - - except Exception as e: - logger.error(f"Error generating MC questions: {e}") - return GenerateResponse(success=False, error=str(e)) - - -@router.post("/generate/cloze", response_model=GenerateResponse) -async def generate_cloze(request: ClozeGenerateRequest): - """Generiert Lückentext aus Quelltext.""" - try: - cloze_type = parse_cloze_type(request.cloze_type) - - cloze = cloze_generator.generate( - source_text=request.source_text, - num_gaps=request.num_gaps, - difficulty=request.difficulty, - cloze_type=cloze_type, - topic=request.topic - ) - - if not cloze.gaps: - return GenerateResponse( - success=False, - error="Keine Lücken generiert. Text möglicherweise zu kurz." - ) - - cloze_dict = cloze_generator.to_dict(cloze) - h5p_format = cloze_generator.to_h5p_format(cloze) - - content = WorksheetContent( - id=str(uuid.uuid4()), - content_type=ContentType.CLOZE.value, - data=cloze_dict, - h5p_format=h5p_format, - created_at=datetime.utcnow(), - topic=request.topic, - difficulty=request.difficulty - ) - - _store_content(content) - return GenerateResponse(success=True, content=content) - - except Exception as e: - logger.error(f"Error generating cloze: {e}") - return GenerateResponse(success=False, error=str(e)) - - -@router.post("/generate/mindmap", response_model=GenerateResponse) -async def generate_mindmap(request: MindmapGenerateRequest): - """Generiert Mindmap aus Quelltext.""" - try: - mindmap = mindmap_generator.generate( - source_text=request.source_text, - title=request.topic, - max_depth=request.max_depth, - topic=request.topic - ) - - if mindmap.total_nodes <= 1: - return GenerateResponse( - success=False, - error="Mindmap konnte nicht generiert werden. Text möglicherweise zu kurz." - ) - - mindmap_dict = mindmap_generator.to_dict(mindmap) - mermaid = mindmap_generator.to_mermaid(mindmap) - json_tree = mindmap_generator.to_json_tree(mindmap) - - content = WorksheetContent( - id=str(uuid.uuid4()), - content_type=ContentType.MINDMAP.value, - data={ - "mindmap": mindmap_dict, - "mermaid": mermaid, - "json_tree": json_tree - }, - h5p_format=None, - created_at=datetime.utcnow(), - topic=request.topic, - difficulty=None - ) - - _store_content(content) - return GenerateResponse(success=True, content=content) - - except Exception as e: - logger.error(f"Error generating mindmap: {e}") - return GenerateResponse(success=False, error=str(e)) - - -@router.post("/generate/quiz", response_model=GenerateResponse) -async def generate_quiz(request: QuizGenerateRequest): - """Generiert Quiz mit verschiedenen Fragetypen.""" - try: - quiz_types = parse_quiz_types(request.quiz_types) - - all_questions = [] - quizzes = [] - - for quiz_type in quiz_types: - quiz = quiz_generator.generate( - source_text=request.source_text, - quiz_type=quiz_type, - num_questions=request.num_items, - difficulty=request.difficulty, - topic=request.topic - ) - quizzes.append(quiz) - all_questions.extend(quiz.questions) - - if len(all_questions) == 0: - return GenerateResponse( - success=False, - error="Quiz konnte nicht generiert werden. Text möglicherweise zu kurz." - ) - - combined_quiz_dict = { - "quiz_types": [qt.value for qt in quiz_types], - "title": f"Combined Quiz - {request.topic or 'Various Topics'}", - "topic": request.topic, - "difficulty": request.difficulty, - "questions": [] - } - - for quiz in quizzes: - quiz_dict = quiz_generator.to_dict(quiz) - combined_quiz_dict["questions"].extend(quiz_dict.get("questions", [])) - - h5p_format = quiz_generator.to_h5p_format(quizzes[0]) if quizzes else {} - - content = WorksheetContent( - id=str(uuid.uuid4()), - content_type=ContentType.QUIZ.value, - data=combined_quiz_dict, - h5p_format=h5p_format, - created_at=datetime.utcnow(), - topic=request.topic, - difficulty=request.difficulty - ) - - _store_content(content) - return GenerateResponse(success=True, content=content) - - except Exception as e: - logger.error(f"Error generating quiz: {e}") - return GenerateResponse(success=False, error=str(e)) - - -@router.post("/generate/batch", response_model=BatchGenerateResponse) -async def generate_batch(request: BatchGenerateRequest): - """Generiert mehrere Content-Typen aus einem Quelltext.""" - contents = [] - errors = [] - - for content_type in request.content_types: - try: - if content_type == "multiple_choice": - mc_req = MCGenerateRequest( - source_text=request.source_text, - topic=request.topic, - subject=request.subject, - grade_level=request.grade_level, - difficulty=request.difficulty - ) - result = await generate_multiple_choice(mc_req) - - elif content_type == "cloze": - cloze_req = ClozeGenerateRequest( - source_text=request.source_text, - topic=request.topic, - subject=request.subject, - grade_level=request.grade_level, - difficulty=request.difficulty - ) - result = await generate_cloze(cloze_req) - - elif content_type == "mindmap": - mindmap_req = MindmapGenerateRequest( - source_text=request.source_text, - topic=request.topic, - subject=request.subject, - grade_level=request.grade_level - ) - result = await generate_mindmap(mindmap_req) - - elif content_type == "quiz": - quiz_req = QuizGenerateRequest( - source_text=request.source_text, - topic=request.topic, - subject=request.subject, - grade_level=request.grade_level, - difficulty=request.difficulty - ) - result = await generate_quiz(quiz_req) - - else: - errors.append(f"Unbekannter Content-Typ: {content_type}") - continue - - if result.success and result.content: - contents.append(result.content) - elif result.error: - errors.append(f"{content_type}: {result.error}") - - except Exception as e: - errors.append(f"{content_type}: {str(e)}") - - return BatchGenerateResponse( - success=len(contents) > 0, - contents=contents, - errors=errors - ) - - -@router.get("/content/{content_id}", response_model=GenerateResponse) -async def get_content(content_id: str): - """Ruft gespeicherten Content ab.""" - content = _generated_content.get(content_id) - - if not content: - raise HTTPException(status_code=404, detail="Content nicht gefunden") - - return GenerateResponse(success=True, content=content) - - -@router.get("/content/{content_id}/h5p") -async def get_content_h5p(content_id: str): - """Gibt H5P-Format für Content zurück.""" - content = _generated_content.get(content_id) - - if not content: - raise HTTPException(status_code=404, detail="Content nicht gefunden") - - if not content.h5p_format: - raise HTTPException( - status_code=400, - detail="H5P-Format für diesen Content-Typ nicht verfügbar" - ) - - return content.h5p_format - - -@router.delete("/content/{content_id}") -async def delete_content(content_id: str): - """Löscht gespeicherten Content.""" - if content_id not in _generated_content: - raise HTTPException(status_code=404, detail="Content nicht gefunden") - - del _generated_content[content_id] - return {"status": "deleted", "id": content_id} - - -@router.get("/types") -async def list_content_types(): - """Listet verfügbare Content-Typen und deren Optionen.""" - return { - "content_types": [ - { - "type": "multiple_choice", - "name": "Multiple Choice", - "description": "Fragen mit 4 Antwortmöglichkeiten", - "options": { - "num_questions": {"min": 1, "max": 20, "default": 5}, - "difficulty": ["easy", "medium", "hard"] - }, - "h5p_supported": True - }, - { - "type": "cloze", - "name": "Lückentext", - "description": "Text mit ausgeblendeten Schlüsselwörtern", - "options": { - "num_gaps": {"min": 1, "max": 15, "default": 5}, - "difficulty": ["easy", "medium", "hard"], - "cloze_type": ["fill_in", "drag_drop", "dropdown"] - }, - "h5p_supported": True - }, - { - "type": "mindmap", - "name": "Mindmap", - "description": "Hierarchische Struktur aus Hauptthema und Unterthemen", - "options": { - "max_depth": {"min": 2, "max": 5, "default": 3} - }, - "h5p_supported": False, - "export_formats": ["mermaid", "json_tree"] - }, - { - "type": "quiz", - "name": "Quiz", - "description": "Verschiedene Fragetypen kombiniert", - "options": { - "quiz_types": ["true_false", "matching", "sorting", "open_ended"], - "num_items": {"min": 1, "max": 10, "default": 5}, - "difficulty": ["easy", "medium", "hard"] - }, - "h5p_supported": True - } - ] - } - - -@router.get("/history") -async def get_generation_history(limit: int = 10): - """Gibt die letzten generierten Contents zurück.""" - sorted_contents = sorted( - _generated_content.values(), - key=lambda x: x.created_at, - reverse=True - ) - - return { - "total": len(_generated_content), - "contents": [ - { - "id": c.id, - "content_type": c.content_type, - "topic": c.topic, - "difficulty": c.difficulty, - "created_at": c.created_at.isoformat() - } - for c in sorted_contents[:limit] - ] - } +# Backward-compat shim -- module moved to worksheets/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("worksheets.api") diff --git a/backend-lehrer/worksheets_models.py b/backend-lehrer/worksheets_models.py index 87c6b25..00ed0b8 100644 --- a/backend-lehrer/worksheets_models.py +++ b/backend-lehrer/worksheets_models.py @@ -1,135 +1,4 @@ -""" -Worksheets API - Pydantic Models und Helpers. - -Request-/Response-Models und Hilfsfunktionen fuer die -Arbeitsblatt-Generierungs-API. -""" - -import uuid -from datetime import datetime -from typing import List, Dict, Any, Optional -from enum import Enum - -from pydantic import BaseModel, Field - -from generators.mc_generator import Difficulty -from generators.cloze_generator import ClozeType -from generators.quiz_generator import QuizType - - -# ============================================================================ -# Pydantic Models -# ============================================================================ - -class ContentType(str, Enum): - """Verfügbare Content-Typen.""" - MULTIPLE_CHOICE = "multiple_choice" - CLOZE = "cloze" - MINDMAP = "mindmap" - QUIZ = "quiz" - - -class GenerateRequest(BaseModel): - """Basis-Request für Generierung.""" - source_text: str = Field(..., min_length=50, description="Quelltext für Generierung") - topic: Optional[str] = Field(None, description="Thema/Titel") - subject: Optional[str] = Field(None, description="Fach") - grade_level: Optional[str] = Field(None, description="Klassenstufe") - - -class MCGenerateRequest(GenerateRequest): - """Request für Multiple-Choice-Generierung.""" - num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen") - difficulty: str = Field("medium", description="easy, medium, hard") - - -class ClozeGenerateRequest(GenerateRequest): - """Request für Lückentext-Generierung.""" - num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken") - difficulty: str = Field("medium", description="easy, medium, hard") - cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown") - - -class MindmapGenerateRequest(GenerateRequest): - """Request für Mindmap-Generierung.""" - max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe") - - -class QuizGenerateRequest(GenerateRequest): - """Request für Quiz-Generierung.""" - quiz_types: List[str] = Field( - ["true_false", "matching"], - description="Typen: true_false, matching, sorting, open_ended" - ) - num_items: int = Field(5, ge=1, le=10, description="Items pro Typ") - difficulty: str = Field("medium", description="easy, medium, hard") - - -class BatchGenerateRequest(BaseModel): - """Request für Batch-Generierung mehrerer Content-Typen.""" - source_text: str = Field(..., min_length=50) - content_types: List[str] = Field(..., description="Liste von Content-Typen") - topic: Optional[str] = None - subject: Optional[str] = None - grade_level: Optional[str] = None - difficulty: str = "medium" - - -class WorksheetContent(BaseModel): - """Generierter Content.""" - id: str - content_type: str - data: Dict[str, Any] - h5p_format: Optional[Dict[str, Any]] = None - created_at: datetime - topic: Optional[str] = None - difficulty: Optional[str] = None - - -class GenerateResponse(BaseModel): - """Response mit generiertem Content.""" - success: bool - content: Optional[WorksheetContent] = None - error: Optional[str] = None - - -class BatchGenerateResponse(BaseModel): - """Response für Batch-Generierung.""" - success: bool - contents: List[WorksheetContent] = [] - errors: List[str] = [] - - -# ============================================================================ -# Helper Functions -# ============================================================================ - -def parse_difficulty(difficulty_str: str) -> Difficulty: - """Konvertiert String zu Difficulty Enum.""" - mapping = { - "easy": Difficulty.EASY, - "medium": Difficulty.MEDIUM, - "hard": Difficulty.HARD - } - return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM) - - -def parse_cloze_type(type_str: str) -> ClozeType: - """Konvertiert String zu ClozeType Enum.""" - mapping = { - "fill_in": ClozeType.FILL_IN, - "drag_drop": ClozeType.DRAG_DROP, - "dropdown": ClozeType.DROPDOWN - } - return mapping.get(type_str.lower(), ClozeType.FILL_IN) - - -def parse_quiz_types(type_strs: List[str]) -> List[QuizType]: - """Konvertiert String-Liste zu QuizType Enums.""" - mapping = { - "true_false": QuizType.TRUE_FALSE, - "matching": QuizType.MATCHING, - "sorting": QuizType.SORTING, - "open_ended": QuizType.OPEN_ENDED - } - return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs] +# Backward-compat shim -- module moved to worksheets/models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("worksheets.models")