Restructure: Move final 16 root files into packages (backend-lehrer)
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m41s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 38s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m41s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 38s
classroom/ (+2): state_engine_api, state_engine_models vocabulary/ (2): api, db worksheets/ (2): api, models services/ (+6): audio, email, translation, claude_vision, ai_processor, story_generator api/ (4): school, klausur_proxy, progress, user_language Only main.py + config.py remain at root. 16 shims added. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
6
backend-lehrer/api/__init__.py
Normal file
6
backend-lehrer/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# API Module — thin proxy routers and standalone API endpoints
|
||||
#
|
||||
# api/school.py — Proxy to Go school-service
|
||||
# api/klausur_proxy.py — Proxy to klausur-service
|
||||
# api/progress.py — Student learning progress tracking
|
||||
# api/user_language.py — User language preferences
|
||||
135
backend-lehrer/api/klausur_proxy.py
Normal file
135
backend-lehrer/api/klausur_proxy.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Klausur-Service API Proxy
|
||||
Routes API requests from /api/klausur/* to the klausur-service microservice
|
||||
"""
|
||||
|
||||
import os
|
||||
import jwt
|
||||
import datetime
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, HTTPException, Response
|
||||
|
||||
# Klausur Service URL
|
||||
KLAUSUR_SERVICE_URL = os.getenv("KLAUSUR_SERVICE_URL", "http://klausur-service:8086")
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
|
||||
|
||||
# Demo teacher UUID for development mode
|
||||
DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20"
|
||||
|
||||
router = APIRouter(prefix="/klausur", tags=["klausur"])
|
||||
|
||||
|
||||
def get_demo_token() -> str:
|
||||
"""Generate a demo JWT token for development mode"""
|
||||
payload = {
|
||||
"user_id": DEMO_TEACHER_ID,
|
||||
"email": "demo@breakpilot.app",
|
||||
"role": "admin",
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24),
|
||||
"iat": datetime.datetime.now(datetime.timezone.utc)
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
async def proxy_request(request: Request, path: str) -> Response:
|
||||
"""Forward a request to the klausur service"""
|
||||
url = f"{KLAUSUR_SERVICE_URL}/api/v1{path}"
|
||||
|
||||
# Forward headers, especially Authorization
|
||||
headers = {}
|
||||
if "authorization" in request.headers:
|
||||
headers["Authorization"] = request.headers["authorization"]
|
||||
elif ENVIRONMENT == "development":
|
||||
# In development mode, use demo token if no auth provided
|
||||
demo_token = get_demo_token()
|
||||
headers["Authorization"] = f"Bearer {demo_token}"
|
||||
if "content-type" in request.headers:
|
||||
headers["Content-Type"] = request.headers["content-type"]
|
||||
|
||||
# Get request body for POST/PUT/PATCH/DELETE
|
||||
body = None
|
||||
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
body = await request.body()
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
try:
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
params=request.query_params
|
||||
)
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type", "application/json")
|
||||
)
|
||||
except httpx.ConnectError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Klausur service unavailable"
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="Klausur service timeout"
|
||||
)
|
||||
|
||||
|
||||
# Health check
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Health check for klausur service connection"""
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
try:
|
||||
response = await client.get(f"{KLAUSUR_SERVICE_URL}/health")
|
||||
return {"klausur_service": "healthy", "connected": response.status_code == 200}
|
||||
except Exception:
|
||||
return {"klausur_service": "unhealthy", "connected": False}
|
||||
|
||||
|
||||
# Klausuren
|
||||
@router.api_route("/klausuren", methods=["GET", "POST"])
|
||||
async def klausuren(request: Request):
|
||||
return await proxy_request(request, "/klausuren")
|
||||
|
||||
|
||||
@router.api_route("/klausuren/{klausur_id}", methods=["GET", "PUT", "DELETE"])
|
||||
async def klausur_by_id(klausur_id: str, request: Request):
|
||||
return await proxy_request(request, f"/klausuren/{klausur_id}")
|
||||
|
||||
|
||||
# Students
|
||||
@router.api_route("/klausuren/{klausur_id}/students", methods=["GET", "POST"])
|
||||
async def klausur_students(klausur_id: str, request: Request):
|
||||
return await proxy_request(request, f"/klausuren/{klausur_id}/students")
|
||||
|
||||
|
||||
@router.api_route("/students/{student_id}", methods=["GET", "DELETE"])
|
||||
async def student_by_id(student_id: str, request: Request):
|
||||
return await proxy_request(request, f"/students/{student_id}")
|
||||
|
||||
|
||||
# Grading
|
||||
@router.api_route("/students/{student_id}/criteria", methods=["PUT"])
|
||||
async def update_criteria(student_id: str, request: Request):
|
||||
return await proxy_request(request, f"/students/{student_id}/criteria")
|
||||
|
||||
|
||||
@router.api_route("/students/{student_id}/gutachten", methods=["PUT"])
|
||||
async def update_gutachten(student_id: str, request: Request):
|
||||
return await proxy_request(request, f"/students/{student_id}/gutachten")
|
||||
|
||||
|
||||
@router.api_route("/students/{student_id}/finalize", methods=["POST"])
|
||||
async def finalize_student(student_id: str, request: Request):
|
||||
return await proxy_request(request, f"/students/{student_id}/finalize")
|
||||
|
||||
|
||||
# Grade info
|
||||
@router.get("/grade-info")
|
||||
async def grade_info(request: Request):
|
||||
return await proxy_request(request, "/grade-info")
|
||||
131
backend-lehrer/api/progress.py
Normal file
131
backend-lehrer/api/progress.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Progress API — Tracks student learning progress per unit.
|
||||
|
||||
Stores coins, crowns, streak data, and exercise completion stats.
|
||||
Uses JSON file storage (same pattern as learning_units.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/progress",
|
||||
tags=["progress"],
|
||||
)
|
||||
|
||||
PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress")
|
||||
|
||||
|
||||
def _ensure_dir():
|
||||
os.makedirs(PROGRESS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _progress_path(unit_id: str) -> Path:
|
||||
return Path(PROGRESS_DIR) / f"{unit_id}.json"
|
||||
|
||||
|
||||
def _load_progress(unit_id: str) -> Dict[str, Any]:
|
||||
path = _progress_path(unit_id)
|
||||
if path.exists():
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"coins": 0,
|
||||
"crowns": 0,
|
||||
"streak_days": 0,
|
||||
"last_activity": None,
|
||||
"exercises": {
|
||||
"flashcards": {"completed": 0, "correct": 0, "incorrect": 0},
|
||||
"quiz": {"completed": 0, "correct": 0, "incorrect": 0},
|
||||
"type": {"completed": 0, "correct": 0, "incorrect": 0},
|
||||
"story": {"generated": 0},
|
||||
},
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _save_progress(unit_id: str, data: Dict[str, Any]):
|
||||
_ensure_dir()
|
||||
path = _progress_path(unit_id)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
class RewardPayload(BaseModel):
|
||||
exercise_type: str # flashcards, quiz, type, story
|
||||
correct: bool = True
|
||||
first_try: bool = True
|
||||
|
||||
|
||||
@router.get("/{unit_id}")
|
||||
def get_progress(unit_id: str):
|
||||
"""Get learning progress for a unit."""
|
||||
return _load_progress(unit_id)
|
||||
|
||||
|
||||
@router.post("/{unit_id}/reward")
|
||||
def add_reward(unit_id: str, payload: RewardPayload):
|
||||
"""Record an exercise result and award coins."""
|
||||
progress = _load_progress(unit_id)
|
||||
|
||||
# Update exercise stats
|
||||
ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0})
|
||||
ex["completed"] = ex.get("completed", 0) + 1
|
||||
if payload.correct:
|
||||
ex["correct"] = ex.get("correct", 0) + 1
|
||||
else:
|
||||
ex["incorrect"] = ex.get("incorrect", 0) + 1
|
||||
progress["exercises"][payload.exercise_type] = ex
|
||||
|
||||
# Award coins
|
||||
if payload.correct:
|
||||
coins = 3 if payload.first_try else 1
|
||||
else:
|
||||
coins = 0
|
||||
progress["coins"] = progress.get("coins", 0) + coins
|
||||
|
||||
# Update streak
|
||||
today = date.today().isoformat()
|
||||
last = progress.get("last_activity")
|
||||
if last != today:
|
||||
if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None:
|
||||
progress["streak_days"] = progress.get("streak_days", 0) + 1
|
||||
elif last != today:
|
||||
progress["streak_days"] = 1
|
||||
progress["last_activity"] = today
|
||||
|
||||
# Award crowns for milestones
|
||||
total_correct = sum(
|
||||
e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict)
|
||||
)
|
||||
progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers
|
||||
|
||||
_save_progress(unit_id, progress)
|
||||
|
||||
return {
|
||||
"coins_awarded": coins,
|
||||
"total_coins": progress["coins"],
|
||||
"crowns": progress["crowns"],
|
||||
"streak_days": progress["streak_days"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_all_progress():
|
||||
"""List progress for all units."""
|
||||
_ensure_dir()
|
||||
results = []
|
||||
for f in Path(PROGRESS_DIR).glob("*.json"):
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
results.append(json.load(fh))
|
||||
return results
|
||||
250
backend-lehrer/api/school.py
Normal file
250
backend-lehrer/api/school.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
School Service API Proxy
|
||||
Routes API requests from /api/school/* to the Go school-service
|
||||
"""
|
||||
|
||||
import os
|
||||
import jwt
|
||||
import datetime
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, HTTPException, Response
|
||||
|
||||
# School Service URL
|
||||
SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084")
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
|
||||
|
||||
# Demo teacher UUID for development mode
|
||||
DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20"
|
||||
|
||||
router = APIRouter(prefix="/school", tags=["school"])
|
||||
|
||||
|
||||
def get_demo_token() -> str:
|
||||
"""Generate a demo JWT token for development mode"""
|
||||
payload = {
|
||||
"user_id": DEMO_TEACHER_ID,
|
||||
"email": "demo@breakpilot.app",
|
||||
"role": "admin",
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24),
|
||||
"iat": datetime.datetime.now(datetime.timezone.utc)
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
async def proxy_request(request: Request, path: str) -> Response:
|
||||
"""Forward a request to the school service"""
|
||||
url = f"{SCHOOL_SERVICE_URL}/api/v1/school{path}"
|
||||
|
||||
# Forward headers, especially Authorization
|
||||
headers = {}
|
||||
if "authorization" in request.headers:
|
||||
headers["Authorization"] = request.headers["authorization"]
|
||||
elif ENVIRONMENT == "development":
|
||||
# In development mode, use demo token if no auth provided
|
||||
demo_token = get_demo_token()
|
||||
headers["Authorization"] = f"Bearer {demo_token}"
|
||||
if "content-type" in request.headers:
|
||||
headers["Content-Type"] = request.headers["content-type"]
|
||||
|
||||
# Get request body for POST/PUT/PATCH
|
||||
body = None
|
||||
if request.method in ("POST", "PUT", "PATCH"):
|
||||
body = await request.body()
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
params=request.query_params
|
||||
)
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type", "application/json")
|
||||
)
|
||||
except httpx.ConnectError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="School service unavailable"
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="School service timeout"
|
||||
)
|
||||
|
||||
|
||||
# Health check
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Health check for school service connection"""
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
try:
|
||||
response = await client.get(f"{SCHOOL_SERVICE_URL}/health")
|
||||
return {"school_service": "healthy", "connected": response.status_code == 200}
|
||||
except Exception:
|
||||
return {"school_service": "unhealthy", "connected": False}
|
||||
|
||||
|
||||
# School Years
|
||||
@router.api_route("/years", methods=["GET", "POST"])
|
||||
async def years(request: Request):
|
||||
return await proxy_request(request, "/years")
|
||||
|
||||
|
||||
@router.api_route("/years/{year_id}", methods=["GET", "PUT", "DELETE"])
|
||||
async def year_by_id(request: Request, year_id: str):
|
||||
return await proxy_request(request, f"/years/{year_id}")
|
||||
|
||||
|
||||
# Classes
|
||||
@router.api_route("/classes", methods=["GET", "POST"])
|
||||
async def classes(request: Request):
|
||||
return await proxy_request(request, "/classes")
|
||||
|
||||
|
||||
@router.api_route("/classes/{class_id}", methods=["GET", "PUT", "DELETE"])
|
||||
async def class_by_id(request: Request, class_id: str):
|
||||
return await proxy_request(request, f"/classes/{class_id}")
|
||||
|
||||
|
||||
# Students
|
||||
@router.api_route("/classes/{class_id}/students", methods=["GET", "POST"])
|
||||
async def students(request: Request, class_id: str):
|
||||
return await proxy_request(request, f"/classes/{class_id}/students")
|
||||
|
||||
|
||||
@router.api_route("/classes/{class_id}/students/import", methods=["POST"])
|
||||
async def import_students(request: Request, class_id: str):
|
||||
return await proxy_request(request, f"/classes/{class_id}/students/import")
|
||||
|
||||
|
||||
@router.api_route("/classes/{class_id}/students/{student_id}", methods=["GET", "PUT", "DELETE"])
|
||||
async def student_by_id(request: Request, class_id: str, student_id: str):
|
||||
return await proxy_request(request, f"/classes/{class_id}/students/{student_id}")
|
||||
|
||||
|
||||
# Subjects
|
||||
@router.api_route("/subjects", methods=["GET", "POST"])
|
||||
async def subjects(request: Request):
|
||||
return await proxy_request(request, "/subjects")
|
||||
|
||||
|
||||
@router.api_route("/subjects/{subject_id}", methods=["GET", "PUT", "DELETE"])
|
||||
async def subject_by_id(request: Request, subject_id: str):
|
||||
return await proxy_request(request, f"/subjects/{subject_id}")
|
||||
|
||||
|
||||
# Exams
|
||||
@router.api_route("/exams", methods=["GET", "POST"])
|
||||
async def exams(request: Request):
|
||||
return await proxy_request(request, "/exams")
|
||||
|
||||
|
||||
@router.api_route("/exams/{exam_id}", methods=["GET", "PUT", "DELETE"])
|
||||
async def exam_by_id(request: Request, exam_id: str):
|
||||
return await proxy_request(request, f"/exams/{exam_id}")
|
||||
|
||||
|
||||
@router.api_route("/exams/{exam_id}/results", methods=["GET", "POST"])
|
||||
async def exam_results(request: Request, exam_id: str):
|
||||
return await proxy_request(request, f"/exams/{exam_id}/results")
|
||||
|
||||
|
||||
@router.api_route("/exams/{exam_id}/results/{student_id}", methods=["PUT"])
|
||||
async def exam_result_update(request: Request, exam_id: str, student_id: str):
|
||||
return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}")
|
||||
|
||||
|
||||
@router.api_route("/exams/{exam_id}/results/{student_id}/approve", methods=["PUT"])
|
||||
async def approve_result(request: Request, exam_id: str, student_id: str):
|
||||
return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}/approve")
|
||||
|
||||
|
||||
@router.api_route("/exams/{exam_id}/generate-variant", methods=["POST"])
|
||||
async def generate_variant(request: Request, exam_id: str):
|
||||
return await proxy_request(request, f"/exams/{exam_id}/generate-variant")
|
||||
|
||||
|
||||
# Grades
|
||||
@router.api_route("/grades/{class_id}", methods=["GET"])
|
||||
async def grades_by_class(request: Request, class_id: str):
|
||||
return await proxy_request(request, f"/grades/{class_id}")
|
||||
|
||||
|
||||
@router.api_route("/grades/student/{student_id}", methods=["GET"])
|
||||
async def grades_by_student(request: Request, student_id: str):
|
||||
return await proxy_request(request, f"/grades/student/{student_id}")
|
||||
|
||||
|
||||
@router.api_route("/grades/{student_id}/{subject_id}/oral", methods=["PUT"])
|
||||
async def update_oral_grade(request: Request, student_id: str, subject_id: str):
|
||||
return await proxy_request(request, f"/grades/{student_id}/{subject_id}/oral")
|
||||
|
||||
|
||||
@router.api_route("/grades/calculate", methods=["POST"])
|
||||
async def calculate_grades(request: Request):
|
||||
return await proxy_request(request, "/grades/calculate")
|
||||
|
||||
|
||||
# Attendance
|
||||
@router.api_route("/attendance/{class_id}", methods=["GET"])
|
||||
async def attendance_by_class(request: Request, class_id: str):
|
||||
return await proxy_request(request, f"/attendance/{class_id}")
|
||||
|
||||
|
||||
@router.api_route("/attendance", methods=["POST"])
|
||||
async def create_attendance(request: Request):
|
||||
return await proxy_request(request, "/attendance")
|
||||
|
||||
|
||||
@router.api_route("/attendance/bulk", methods=["POST"])
|
||||
async def bulk_attendance(request: Request):
|
||||
return await proxy_request(request, "/attendance/bulk")
|
||||
|
||||
|
||||
# Gradebook
|
||||
@router.api_route("/gradebook/{class_id}", methods=["GET"])
|
||||
async def gradebook_by_class(request: Request, class_id: str):
|
||||
return await proxy_request(request, f"/gradebook/{class_id}")
|
||||
|
||||
|
||||
@router.api_route("/gradebook", methods=["POST"])
|
||||
async def create_gradebook_entry(request: Request):
|
||||
return await proxy_request(request, "/gradebook")
|
||||
|
||||
|
||||
@router.api_route("/gradebook/{entry_id}", methods=["DELETE"])
|
||||
async def delete_gradebook_entry(request: Request, entry_id: str):
|
||||
return await proxy_request(request, f"/gradebook/{entry_id}")
|
||||
|
||||
|
||||
# Certificates
|
||||
@router.api_route("/certificates/templates", methods=["GET"])
|
||||
async def certificate_templates(request: Request):
|
||||
return await proxy_request(request, "/certificates/templates")
|
||||
|
||||
|
||||
@router.api_route("/certificates/generate", methods=["POST"])
|
||||
async def generate_certificate(request: Request):
|
||||
return await proxy_request(request, "/certificates/generate")
|
||||
|
||||
|
||||
@router.api_route("/certificates/{certificate_id}", methods=["GET"])
|
||||
async def certificate_by_id(request: Request, certificate_id: str):
|
||||
return await proxy_request(request, f"/certificates/{certificate_id}")
|
||||
|
||||
|
||||
@router.api_route("/certificates/{certificate_id}/pdf", methods=["GET"])
|
||||
async def certificate_pdf(request: Request, certificate_id: str):
|
||||
return await proxy_request(request, f"/certificates/{certificate_id}/pdf")
|
||||
|
||||
|
||||
@router.api_route("/certificates/{certificate_id}/finalize", methods=["PUT"])
|
||||
async def finalize_certificate(request: Request, certificate_id: str):
|
||||
return await proxy_request(request, f"/certificates/{certificate_id}/finalize")
|
||||
86
backend-lehrer/api/user_language.py
Normal file
86
backend-lehrer/api/user_language.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
User Language Preferences API — Stores native language + learning level.
|
||||
|
||||
Each user (student, parent, teacher) can set their native language.
|
||||
This drives: UI language, third-language display in flashcards,
|
||||
parent portal language, and translation generation.
|
||||
|
||||
Supported languages: de, en, tr, ar, uk, ru, pl
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/user", tags=["user-language"])
|
||||
|
||||
# Supported native languages with metadata
|
||||
SUPPORTED_LANGUAGES = {
|
||||
"de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False},
|
||||
"en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False},
|
||||
"tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False},
|
||||
"ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True},
|
||||
"uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False},
|
||||
"ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False},
|
||||
"pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False},
|
||||
}
|
||||
|
||||
# In-memory store (will be replaced with DB later)
|
||||
_preferences: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
class LanguagePreference(BaseModel):
|
||||
native_language: str # ISO 639-1 code
|
||||
role: str = "student" # student, parent, teacher
|
||||
learning_level: str = "A1" # A1, A2, B1, B2, C1
|
||||
|
||||
|
||||
@router.get("/languages")
|
||||
def get_supported_languages():
|
||||
"""List all supported native languages with metadata."""
|
||||
return {
|
||||
"languages": [
|
||||
{"code": code, **meta}
|
||||
for code, meta in SUPPORTED_LANGUAGES.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/language-preference")
|
||||
def get_language_preference(user_id: str = Query("default")):
|
||||
"""Get user's language preference."""
|
||||
pref = _preferences.get(user_id)
|
||||
if not pref:
|
||||
return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True}
|
||||
return {**pref, "is_default": False}
|
||||
|
||||
|
||||
@router.put("/language-preference")
|
||||
def set_language_preference(
|
||||
pref: LanguagePreference,
|
||||
user_id: str = Query("default"),
|
||||
):
|
||||
"""Set user's native language and learning level."""
|
||||
if pref.native_language not in SUPPORTED_LANGUAGES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. "
|
||||
f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}",
|
||||
)
|
||||
|
||||
_preferences[user_id] = {
|
||||
"user_id": user_id,
|
||||
"native_language": pref.native_language,
|
||||
"role": pref.role,
|
||||
"learning_level": pref.learning_level,
|
||||
}
|
||||
|
||||
lang_meta = SUPPORTED_LANGUAGES[pref.native_language]
|
||||
logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})")
|
||||
|
||||
return {**_preferences[user_id], "language_meta": lang_meta}
|
||||
@@ -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")
|
||||
|
||||
410
backend-lehrer/classroom/state_engine_api.py
Normal file
410
backend-lehrer/classroom/state_engine_api.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
State Engine API - REST API für Begleiter-Modus.
|
||||
|
||||
Endpoints:
|
||||
- GET /api/state/context - TeacherContext abrufen
|
||||
- GET /api/state/suggestions - Vorschläge abrufen
|
||||
- GET /api/state/dashboard - Dashboard-Daten
|
||||
- POST /api/state/milestone - Meilenstein abschließen
|
||||
- POST /api/state/transition - Phasen-Übergang
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from state_engine import (
|
||||
AnticipationEngine,
|
||||
PhaseService,
|
||||
SchoolYearPhase,
|
||||
ClassSummary,
|
||||
Event,
|
||||
get_phase_info,
|
||||
)
|
||||
from .state_engine_models import (
|
||||
MilestoneRequest,
|
||||
TransitionRequest,
|
||||
ContextResponse,
|
||||
SuggestionsResponse,
|
||||
DashboardResponse,
|
||||
_teacher_contexts,
|
||||
_milestones,
|
||||
get_or_create_context,
|
||||
update_context_from_services,
|
||||
get_phase_display_name,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/state",
|
||||
tags=["state-engine"],
|
||||
)
|
||||
|
||||
# Singleton instances
|
||||
_engine = AnticipationEngine()
|
||||
_phase_service = PhaseService()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/context", response_model=ContextResponse)
|
||||
async def get_teacher_context(teacher_id: str = Query("demo-teacher")):
|
||||
"""Gibt den aggregierten TeacherContext zurück."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
ctx = update_context_from_services(ctx)
|
||||
|
||||
phase_info = get_phase_info(ctx.current_phase)
|
||||
|
||||
return ContextResponse(
|
||||
context=ctx.to_dict(),
|
||||
phase_info={
|
||||
"phase": phase_info.phase.value,
|
||||
"display_name": phase_info.display_name,
|
||||
"description": phase_info.description,
|
||||
"typical_months": phase_info.typical_months,
|
||||
"required_actions": phase_info.required_actions,
|
||||
"optional_actions": phase_info.optional_actions,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/phase")
|
||||
async def get_current_phase(teacher_id: str = Query("demo-teacher")):
|
||||
"""Gibt die aktuelle Phase mit Details zurück."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
phase_info = get_phase_info(ctx.current_phase)
|
||||
|
||||
return {
|
||||
"current_phase": ctx.current_phase.value,
|
||||
"phase_info": {
|
||||
"display_name": phase_info.display_name,
|
||||
"description": phase_info.description,
|
||||
"expected_duration_weeks": phase_info.expected_duration_weeks,
|
||||
},
|
||||
"days_in_phase": ctx.days_in_phase,
|
||||
"progress": _phase_service.get_progress_percentage(ctx),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/phases")
|
||||
async def get_all_phases():
|
||||
"""Gibt alle Phasen mit Metadaten zurück."""
|
||||
return {
|
||||
"phases": _phase_service.get_all_phases()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/suggestions", response_model=SuggestionsResponse)
|
||||
async def get_suggestions(teacher_id: str = Query("demo-teacher")):
|
||||
"""Gibt Vorschläge basierend auf dem aktuellen Kontext zurück."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
ctx = update_context_from_services(ctx)
|
||||
|
||||
suggestions = _engine.get_suggestions(ctx)
|
||||
priority_counts = _engine.count_by_priority(ctx)
|
||||
|
||||
return SuggestionsResponse(
|
||||
suggestions=[s.to_dict() for s in suggestions],
|
||||
current_phase=ctx.current_phase.value,
|
||||
phase_display_name=get_phase_display_name(ctx.current_phase.value),
|
||||
priority_counts=priority_counts,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/suggestions/top")
|
||||
async def get_top_suggestion(teacher_id: str = Query("demo-teacher")):
|
||||
"""Gibt den wichtigsten einzelnen Vorschlag zurück."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
ctx = update_context_from_services(ctx)
|
||||
|
||||
suggestion = _engine.get_top_suggestion(ctx)
|
||||
|
||||
if not suggestion:
|
||||
return {
|
||||
"suggestion": None,
|
||||
"message": "Alles erledigt! Keine offenen Aufgaben."
|
||||
}
|
||||
|
||||
return {
|
||||
"suggestion": suggestion.to_dict()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardResponse)
|
||||
async def get_dashboard_data(teacher_id: str = Query("demo-teacher")):
|
||||
"""Gibt alle Daten für das Begleiter-Dashboard zurück."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
ctx = update_context_from_services(ctx)
|
||||
|
||||
suggestions = _engine.get_suggestions(ctx)
|
||||
phase_info = get_phase_info(ctx.current_phase)
|
||||
|
||||
required = set(phase_info.required_actions)
|
||||
completed = set(ctx.completed_milestones)
|
||||
completed_in_phase = len(required.intersection(completed))
|
||||
|
||||
all_phases = []
|
||||
phase_order = [
|
||||
SchoolYearPhase.ONBOARDING,
|
||||
SchoolYearPhase.SCHOOL_YEAR_START,
|
||||
SchoolYearPhase.TEACHING_SETUP,
|
||||
SchoolYearPhase.PERFORMANCE_1,
|
||||
SchoolYearPhase.SEMESTER_END,
|
||||
SchoolYearPhase.TEACHING_2,
|
||||
SchoolYearPhase.PERFORMANCE_2,
|
||||
SchoolYearPhase.YEAR_END,
|
||||
]
|
||||
|
||||
current_idx = phase_order.index(ctx.current_phase) if ctx.current_phase in phase_order else 0
|
||||
|
||||
for i, phase in enumerate(phase_order):
|
||||
info = get_phase_info(phase)
|
||||
all_phases.append({
|
||||
"phase": phase.value,
|
||||
"display_name": info.display_name,
|
||||
"short_name": info.display_name[:10],
|
||||
"is_current": phase == ctx.current_phase,
|
||||
"is_completed": i < current_idx,
|
||||
"is_future": i > current_idx,
|
||||
})
|
||||
|
||||
return DashboardResponse(
|
||||
context={
|
||||
"current_phase": ctx.current_phase.value,
|
||||
"phase_display_name": phase_info.display_name,
|
||||
"phase_description": phase_info.description,
|
||||
"weeks_since_start": ctx.weeks_since_start,
|
||||
"days_in_phase": ctx.days_in_phase,
|
||||
"federal_state": ctx.federal_state,
|
||||
"school_type": ctx.school_type,
|
||||
},
|
||||
suggestions=[s.to_dict() for s in suggestions],
|
||||
stats={
|
||||
"learning_units_created": ctx.stats.learning_units_created,
|
||||
"exams_scheduled": ctx.stats.exams_scheduled,
|
||||
"exams_graded": ctx.stats.exams_graded,
|
||||
"grades_entered": ctx.stats.grades_entered,
|
||||
"classes_count": len(ctx.classes),
|
||||
"students_count": ctx.total_students,
|
||||
},
|
||||
upcoming_events=[
|
||||
{
|
||||
"type": e.type,
|
||||
"title": e.title,
|
||||
"date": e.date.isoformat(),
|
||||
"in_days": e.in_days,
|
||||
"priority": e.priority,
|
||||
}
|
||||
for e in ctx.upcoming_events[:5]
|
||||
],
|
||||
progress={
|
||||
"completed": completed_in_phase,
|
||||
"total": len(required),
|
||||
"percentage": (completed_in_phase / len(required) * 100) if required else 100,
|
||||
"milestones_completed": list(completed.intersection(required)),
|
||||
"milestones_pending": list(required - completed),
|
||||
},
|
||||
phases=all_phases,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/milestone")
|
||||
async def complete_milestone(
|
||||
request: MilestoneRequest,
|
||||
teacher_id: str = Query("demo-teacher")
|
||||
):
|
||||
"""Markiert einen Meilenstein als erledigt."""
|
||||
milestone = request.milestone
|
||||
|
||||
if teacher_id not in _milestones:
|
||||
_milestones[teacher_id] = []
|
||||
|
||||
if milestone not in _milestones[teacher_id]:
|
||||
_milestones[teacher_id].append(milestone)
|
||||
logger.info(f"Milestone '{milestone}' completed for teacher {teacher_id}")
|
||||
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
ctx.completed_milestones = _milestones[teacher_id]
|
||||
_teacher_contexts[teacher_id] = ctx
|
||||
|
||||
new_phase = _phase_service.check_and_transition(ctx)
|
||||
|
||||
if new_phase:
|
||||
ctx.current_phase = new_phase
|
||||
ctx.phase_entered_at = datetime.now()
|
||||
ctx.days_in_phase = 0
|
||||
_teacher_contexts[teacher_id] = ctx
|
||||
logger.info(f"Auto-transitioned to {new_phase} for teacher {teacher_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"milestone": milestone,
|
||||
"new_phase": new_phase.value if new_phase else None,
|
||||
"current_phase": ctx.current_phase.value,
|
||||
"completed_milestones": ctx.completed_milestones,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/transition")
|
||||
async def transition_phase(
|
||||
request: TransitionRequest,
|
||||
teacher_id: str = Query("demo-teacher")
|
||||
):
|
||||
"""Führt einen manuellen Phasen-Übergang durch."""
|
||||
try:
|
||||
target_phase = SchoolYearPhase(request.target_phase)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Ungültige Phase: {request.target_phase}"
|
||||
)
|
||||
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
|
||||
if not _phase_service.can_transition_to(ctx, target_phase):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Übergang von {ctx.current_phase.value} zu {target_phase.value} nicht erlaubt"
|
||||
)
|
||||
|
||||
old_phase = ctx.current_phase
|
||||
ctx.current_phase = target_phase
|
||||
ctx.phase_entered_at = datetime.now()
|
||||
ctx.days_in_phase = 0
|
||||
_teacher_contexts[teacher_id] = ctx
|
||||
|
||||
logger.info(f"Manual transition from {old_phase} to {target_phase} for teacher {teacher_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"old_phase": old_phase.value,
|
||||
"new_phase": target_phase.value,
|
||||
"phase_info": get_phase_info(target_phase).__dict__,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/next-phase")
|
||||
async def get_next_phase(teacher_id: str = Query("demo-teacher")):
|
||||
"""Gibt die nächste Phase und Anforderungen zurück."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
next_phase = _phase_service.get_next_phase(ctx.current_phase)
|
||||
|
||||
if not next_phase:
|
||||
return {
|
||||
"next_phase": None,
|
||||
"message": "Letzte Phase erreicht"
|
||||
}
|
||||
|
||||
can_transition = _phase_service.can_transition_to(ctx, next_phase)
|
||||
next_info = get_phase_info(next_phase)
|
||||
current_info = get_phase_info(ctx.current_phase)
|
||||
|
||||
missing = [
|
||||
m for m in current_info.required_actions
|
||||
if m not in ctx.completed_milestones
|
||||
]
|
||||
|
||||
return {
|
||||
"current_phase": ctx.current_phase.value,
|
||||
"next_phase": next_phase.value,
|
||||
"next_phase_info": {
|
||||
"display_name": next_info.display_name,
|
||||
"description": next_info.description,
|
||||
},
|
||||
"can_transition": can_transition,
|
||||
"missing_requirements": missing,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Demo Data Endpoints (nur für Entwicklung)
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/demo/add-class")
|
||||
async def demo_add_class(
|
||||
name: str = Query(...),
|
||||
grade_level: int = Query(...),
|
||||
student_count: int = Query(25),
|
||||
teacher_id: str = Query("demo-teacher")
|
||||
):
|
||||
"""Demo: Fügt eine Klasse zum Kontext hinzu."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
|
||||
ctx.classes.append(ClassSummary(
|
||||
class_id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
grade_level=grade_level,
|
||||
student_count=student_count,
|
||||
subject="Deutsch"
|
||||
))
|
||||
ctx.total_students += student_count
|
||||
_teacher_contexts[teacher_id] = ctx
|
||||
|
||||
return {"success": True, "classes": len(ctx.classes)}
|
||||
|
||||
|
||||
@router.post("/demo/add-event")
|
||||
async def demo_add_event(
|
||||
event_type: str = Query(...),
|
||||
title: str = Query(...),
|
||||
in_days: int = Query(...),
|
||||
teacher_id: str = Query("demo-teacher")
|
||||
):
|
||||
"""Demo: Fügt ein Event zum Kontext hinzu."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
|
||||
ctx.upcoming_events.append(Event(
|
||||
type=event_type,
|
||||
title=title,
|
||||
date=datetime.now() + timedelta(days=in_days),
|
||||
in_days=in_days,
|
||||
priority="high" if in_days <= 3 else "medium"
|
||||
))
|
||||
_teacher_contexts[teacher_id] = ctx
|
||||
|
||||
return {"success": True, "events": len(ctx.upcoming_events)}
|
||||
|
||||
|
||||
@router.post("/demo/update-stats")
|
||||
async def demo_update_stats(
|
||||
learning_units: int = Query(0),
|
||||
exams_scheduled: int = Query(0),
|
||||
exams_graded: int = Query(0),
|
||||
grades_entered: int = Query(0),
|
||||
unanswered_messages: int = Query(0),
|
||||
teacher_id: str = Query("demo-teacher")
|
||||
):
|
||||
"""Demo: Aktualisiert Statistiken."""
|
||||
ctx = get_or_create_context(teacher_id)
|
||||
|
||||
if learning_units:
|
||||
ctx.stats.learning_units_created = learning_units
|
||||
if exams_scheduled:
|
||||
ctx.stats.exams_scheduled = exams_scheduled
|
||||
if exams_graded:
|
||||
ctx.stats.exams_graded = exams_graded
|
||||
if grades_entered:
|
||||
ctx.stats.grades_entered = grades_entered
|
||||
if unanswered_messages:
|
||||
ctx.stats.unanswered_messages = unanswered_messages
|
||||
|
||||
_teacher_contexts[teacher_id] = ctx
|
||||
|
||||
return {"success": True, "stats": ctx.stats.__dict__}
|
||||
|
||||
|
||||
@router.post("/demo/reset")
|
||||
async def demo_reset(teacher_id: str = Query("demo-teacher")):
|
||||
"""Demo: Setzt den Kontext zurück."""
|
||||
if teacher_id in _teacher_contexts:
|
||||
del _teacher_contexts[teacher_id]
|
||||
if teacher_id in _milestones:
|
||||
del _milestones[teacher_id]
|
||||
|
||||
return {"success": True, "message": "Kontext zurückgesetzt"}
|
||||
143
backend-lehrer/classroom/state_engine_models.py
Normal file
143
backend-lehrer/classroom/state_engine_models.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
State Engine API - Pydantic Models und Helper Functions.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from state_engine import (
|
||||
SchoolYearPhase,
|
||||
ClassSummary,
|
||||
Event,
|
||||
TeacherContext,
|
||||
TeacherStats,
|
||||
get_phase_info,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# In-Memory Storage (später durch DB ersetzen)
|
||||
# ============================================================================
|
||||
|
||||
_teacher_contexts: Dict[str, TeacherContext] = {}
|
||||
_milestones: Dict[str, List[str]] = {} # teacher_id -> milestones
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models
|
||||
# ============================================================================
|
||||
|
||||
class MilestoneRequest(BaseModel):
|
||||
"""Request zum Abschließen eines Meilensteins."""
|
||||
milestone: str = Field(..., description="Name des Meilensteins")
|
||||
|
||||
|
||||
class TransitionRequest(BaseModel):
|
||||
"""Request für Phasen-Übergang."""
|
||||
target_phase: str = Field(..., description="Zielphase")
|
||||
|
||||
|
||||
class ContextResponse(BaseModel):
|
||||
"""Response mit TeacherContext."""
|
||||
context: Dict[str, Any]
|
||||
phase_info: Dict[str, Any]
|
||||
|
||||
|
||||
class SuggestionsResponse(BaseModel):
|
||||
"""Response mit Vorschlägen."""
|
||||
suggestions: List[Dict[str, Any]]
|
||||
current_phase: str
|
||||
phase_display_name: str
|
||||
priority_counts: Dict[str, int]
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
"""Response mit Dashboard-Daten."""
|
||||
context: Dict[str, Any]
|
||||
suggestions: List[Dict[str, Any]]
|
||||
stats: Dict[str, Any]
|
||||
upcoming_events: List[Dict[str, Any]]
|
||||
progress: Dict[str, Any]
|
||||
phases: List[Dict[str, Any]]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def get_or_create_context(teacher_id: str) -> TeacherContext:
|
||||
"""
|
||||
Holt oder erstellt TeacherContext.
|
||||
|
||||
In Produktion würde dies aus der Datenbank geladen.
|
||||
"""
|
||||
if teacher_id not in _teacher_contexts:
|
||||
now = datetime.now()
|
||||
school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1)
|
||||
weeks_since_start = (now - school_year_start).days // 7
|
||||
|
||||
month = now.month
|
||||
if month in [8, 9]:
|
||||
phase = SchoolYearPhase.SCHOOL_YEAR_START
|
||||
elif month in [10, 11]:
|
||||
phase = SchoolYearPhase.TEACHING_SETUP
|
||||
elif month == 12:
|
||||
phase = SchoolYearPhase.PERFORMANCE_1
|
||||
elif month in [1, 2]:
|
||||
phase = SchoolYearPhase.SEMESTER_END
|
||||
elif month in [3, 4]:
|
||||
phase = SchoolYearPhase.TEACHING_2
|
||||
elif month in [5, 6]:
|
||||
phase = SchoolYearPhase.PERFORMANCE_2
|
||||
else:
|
||||
phase = SchoolYearPhase.YEAR_END
|
||||
|
||||
_teacher_contexts[teacher_id] = TeacherContext(
|
||||
teacher_id=teacher_id,
|
||||
school_id=str(uuid.uuid4()),
|
||||
school_year_id=str(uuid.uuid4()),
|
||||
federal_state="niedersachsen",
|
||||
school_type="gymnasium",
|
||||
school_year_start=school_year_start,
|
||||
current_phase=phase,
|
||||
phase_entered_at=now - timedelta(days=7),
|
||||
weeks_since_start=weeks_since_start,
|
||||
days_in_phase=7,
|
||||
classes=[],
|
||||
total_students=0,
|
||||
upcoming_events=[],
|
||||
completed_milestones=_milestones.get(teacher_id, []),
|
||||
pending_milestones=[],
|
||||
stats=TeacherStats(),
|
||||
)
|
||||
|
||||
return _teacher_contexts[teacher_id]
|
||||
|
||||
|
||||
def update_context_from_services(ctx: TeacherContext) -> TeacherContext:
|
||||
"""
|
||||
Aktualisiert Kontext mit Daten aus anderen Services.
|
||||
|
||||
In Produktion würde dies von school-service, gradebook etc. laden.
|
||||
"""
|
||||
ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days
|
||||
ctx.completed_milestones = _milestones.get(ctx.teacher_id, [])
|
||||
|
||||
phase_info = get_phase_info(ctx.current_phase)
|
||||
ctx.pending_milestones = [
|
||||
m for m in phase_info.required_actions
|
||||
if m not in ctx.completed_milestones
|
||||
]
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def get_phase_display_name(phase: str) -> str:
|
||||
"""Gibt Display-Name für Phase zurück."""
|
||||
try:
|
||||
return get_phase_info(SchoolYearPhase(phase)).display_name
|
||||
except (ValueError, KeyError):
|
||||
return phase
|
||||
@@ -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")
|
||||
|
||||
@@ -1,395 +1,4 @@
|
||||
"""
|
||||
BreakPilot Email Service
|
||||
|
||||
Ermoeglicht den Versand von Emails via SMTP.
|
||||
Verwendet Mailpit im Entwicklungsmodus.
|
||||
"""
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.utils import formataddr
|
||||
from typing import Optional, List
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SMTP Konfiguration aus Umgebungsvariablen
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "1025"))
|
||||
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "BreakPilot")
|
||||
SMTP_FROM_ADDR = os.getenv("SMTP_FROM_ADDR", "noreply@breakpilot.app")
|
||||
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "false").lower() == "true"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailResult:
|
||||
"""Ergebnis eines Email-Versands."""
|
||||
success: bool
|
||||
message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
recipient: Optional[str] = None
|
||||
sent_at: Optional[str] = None
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Service fuer den Email-Versand."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = SMTP_HOST,
|
||||
port: int = SMTP_PORT,
|
||||
username: str = SMTP_USERNAME,
|
||||
password: str = SMTP_PASSWORD,
|
||||
from_name: str = SMTP_FROM_NAME,
|
||||
from_addr: str = SMTP_FROM_ADDR,
|
||||
use_tls: bool = SMTP_USE_TLS
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.from_name = from_name
|
||||
self.from_addr = from_addr
|
||||
self.use_tls = use_tls
|
||||
|
||||
def _get_connection(self):
|
||||
"""Erstellt eine SMTP-Verbindung."""
|
||||
if self.use_tls:
|
||||
smtp = smtplib.SMTP_SSL(self.host, self.port)
|
||||
else:
|
||||
smtp = smtplib.SMTP(self.host, self.port)
|
||||
|
||||
if self.username and self.password:
|
||||
smtp.login(self.username, self.password)
|
||||
|
||||
return smtp
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
body_html: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
cc: Optional[List[str]] = None,
|
||||
bcc: Optional[List[str]] = None
|
||||
) -> EmailResult:
|
||||
"""
|
||||
Sendet eine Email.
|
||||
|
||||
Args:
|
||||
to_email: Empfaenger-Email
|
||||
subject: Betreff
|
||||
body_text: Plaintext-Inhalt
|
||||
body_html: Optional HTML-Inhalt
|
||||
reply_to: Optional Reply-To Adresse
|
||||
cc: Optional CC-Empfaenger
|
||||
bcc: Optional BCC-Empfaenger
|
||||
|
||||
Returns:
|
||||
EmailResult mit Erfolg/Fehler
|
||||
"""
|
||||
try:
|
||||
# Message erstellen
|
||||
if body_html:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||
else:
|
||||
msg = MIMEText(body_text, "plain", "utf-8")
|
||||
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr((self.from_name, self.from_addr))
|
||||
msg["To"] = to_email
|
||||
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
|
||||
if cc:
|
||||
msg["Cc"] = ", ".join(cc)
|
||||
|
||||
# Alle Empfaenger sammeln
|
||||
recipients = [to_email]
|
||||
if cc:
|
||||
recipients.extend(cc)
|
||||
if bcc:
|
||||
recipients.extend(bcc)
|
||||
|
||||
# Senden
|
||||
with self._get_connection() as smtp:
|
||||
smtp.sendmail(self.from_addr, recipients, msg.as_string())
|
||||
|
||||
logger.info(f"Email sent to {to_email}: {subject}")
|
||||
|
||||
return EmailResult(
|
||||
success=True,
|
||||
recipient=to_email,
|
||||
sent_at=datetime.utcnow().isoformat()
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP error sending to {to_email}: {e}")
|
||||
return EmailResult(
|
||||
success=False,
|
||||
error=f"SMTP Fehler: {str(e)}",
|
||||
recipient=to_email
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email to {to_email}: {e}")
|
||||
return EmailResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
recipient=to_email
|
||||
)
|
||||
|
||||
def send_messenger_notification(
|
||||
self,
|
||||
to_email: str,
|
||||
to_name: str,
|
||||
sender_name: str,
|
||||
message_content: str,
|
||||
reply_link: Optional[str] = None
|
||||
) -> EmailResult:
|
||||
"""
|
||||
Sendet eine Messenger-Benachrichtigung per Email.
|
||||
|
||||
Args:
|
||||
to_email: Empfaenger-Email
|
||||
to_name: Name des Empfaengers
|
||||
sender_name: Name des Absenders
|
||||
message_content: Nachrichteninhalt
|
||||
reply_link: Optional Link zum Antworten
|
||||
|
||||
Returns:
|
||||
EmailResult
|
||||
"""
|
||||
subject = f"Neue Nachricht von {sender_name} - BreakPilot"
|
||||
|
||||
# Plaintext Version
|
||||
body_text = f"""Hallo {to_name},
|
||||
|
||||
Sie haben eine neue Nachricht von {sender_name} erhalten:
|
||||
|
||||
---
|
||||
{message_content}
|
||||
---
|
||||
|
||||
"""
|
||||
if reply_link:
|
||||
body_text += f"Um zu antworten, klicken Sie hier: {reply_link}\n\n"
|
||||
|
||||
body_text += """Mit freundlichen Gruessen
|
||||
Ihr BreakPilot Team
|
||||
|
||||
---
|
||||
Diese E-Mail wurde automatisch versendet.
|
||||
Bitte antworten Sie nicht direkt auf diese E-Mail.
|
||||
"""
|
||||
|
||||
# HTML Version
|
||||
body_html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: #1a2b4e; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||
.content {{ background: #f8f9fa; padding: 20px; border: 1px solid #e0e0e0; }}
|
||||
.message-box {{ background: white; padding: 15px; border-radius: 8px; border-left: 4px solid #ffb800; margin: 15px 0; }}
|
||||
.button {{ display: inline-block; background: #ffb800; color: #1a2b4e; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; }}
|
||||
.footer {{ padding: 15px; font-size: 12px; color: #666; text-align: center; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2 style="margin: 0;">Neue Nachricht</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo <strong>{to_name}</strong>,</p>
|
||||
<p>Sie haben eine neue Nachricht von <strong>{sender_name}</strong> erhalten:</p>
|
||||
|
||||
<div class="message-box">
|
||||
{message_content.replace(chr(10), '<br>')}
|
||||
</div>
|
||||
|
||||
"""
|
||||
if reply_link:
|
||||
body_html += f'<p><a href="{reply_link}" class="button">Nachricht beantworten</a></p>'
|
||||
|
||||
body_html += """
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
|
||||
<p style="font-size: 11px; color: #999;">
|
||||
Diese E-Mail wurde automatisch versendet.<br>
|
||||
Bitte antworten Sie nicht direkt auf diese E-Mail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html
|
||||
)
|
||||
|
||||
def send_jitsi_invitation(
|
||||
self,
|
||||
to_email: str,
|
||||
to_name: str,
|
||||
organizer_name: str,
|
||||
meeting_title: str,
|
||||
meeting_date: str,
|
||||
meeting_time: str,
|
||||
jitsi_url: str,
|
||||
additional_info: Optional[str] = None
|
||||
) -> EmailResult:
|
||||
"""
|
||||
Sendet eine Jitsi-Meeting-Einladung per Email.
|
||||
|
||||
Args:
|
||||
to_email: Empfaenger-Email
|
||||
to_name: Name des Empfaengers
|
||||
organizer_name: Name des Organisators
|
||||
meeting_title: Titel des Meetings
|
||||
meeting_date: Datum des Meetings (z.B. "20. Dezember 2024")
|
||||
meeting_time: Uhrzeit des Meetings (z.B. "14:00 Uhr")
|
||||
jitsi_url: Der Jitsi-Meeting-Link
|
||||
additional_info: Optional zusaetzliche Informationen
|
||||
|
||||
Returns:
|
||||
EmailResult
|
||||
"""
|
||||
subject = f"Einladung: {meeting_title} - {meeting_date}"
|
||||
|
||||
# Plaintext Version
|
||||
body_text = f"""Hallo {to_name},
|
||||
|
||||
{organizer_name} laedt Sie zu einem Videogespraech ein.
|
||||
|
||||
TERMIN: {meeting_title}
|
||||
DATUM: {meeting_date}
|
||||
UHRZEIT: {meeting_time}
|
||||
|
||||
Treten Sie dem Meeting bei:
|
||||
{jitsi_url}
|
||||
|
||||
"""
|
||||
if additional_info:
|
||||
body_text += f"HINWEISE:\n{additional_info}\n\n"
|
||||
|
||||
body_text += """TECHNISCHE VORAUSSETZUNGEN:
|
||||
- Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
|
||||
- Keine Installation erforderlich
|
||||
- Optional: Kopfhoerer fuer bessere Audioqualitaet
|
||||
|
||||
Bei technischen Problemen wenden Sie sich bitte an den Organisator.
|
||||
|
||||
Mit freundlichen Gruessen
|
||||
Ihr BreakPilot Team
|
||||
"""
|
||||
|
||||
# HTML Version
|
||||
body_html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 30px; border-radius: 12px 12px 0 0; text-align: center; }}
|
||||
.header h2 {{ margin: 0 0 10px 0; font-size: 24px; }}
|
||||
.content {{ background: #f8f9fa; padding: 25px; border: 1px solid #e0e0e0; }}
|
||||
.meeting-info {{ background: white; padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||
.info-row {{ display: flex; padding: 10px 0; border-bottom: 1px solid #eee; }}
|
||||
.info-row:last-child {{ border-bottom: none; }}
|
||||
.info-label {{ font-weight: 600; color: #666; width: 100px; }}
|
||||
.info-value {{ color: #333; }}
|
||||
.join-button {{ display: block; background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 16px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 18px; text-align: center; margin: 25px 0; }}
|
||||
.join-button:hover {{ opacity: 0.9; }}
|
||||
.requirements {{ background: #e8f5e9; padding: 15px; border-radius: 8px; margin: 20px 0; }}
|
||||
.requirements h4 {{ margin: 0 0 10px 0; color: #2e7d32; }}
|
||||
.requirements ul {{ margin: 0; padding-left: 20px; }}
|
||||
.footer {{ padding: 20px; font-size: 12px; color: #666; text-align: center; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>Einladung zum Videogespraech</h2>
|
||||
<p style="margin: 0; opacity: 0.9;">{meeting_title}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo <strong>{to_name}</strong>,</p>
|
||||
<p><strong>{organizer_name}</strong> laedt Sie zu einem Videogespraech ein.</p>
|
||||
|
||||
<div class="meeting-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Termin:</span>
|
||||
<span class="info-value">{meeting_title}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Datum:</span>
|
||||
<span class="info-value">{meeting_date}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Uhrzeit:</span>
|
||||
<span class="info-value">{meeting_time}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{jitsi_url}" class="join-button">Meeting beitreten</a>
|
||||
|
||||
"""
|
||||
if additional_info:
|
||||
body_html += f"""
|
||||
<div style="background: #fff3e0; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #e65100;">Hinweise:</h4>
|
||||
<p style="margin: 0;">{additional_info}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
body_html += """
|
||||
<div class="requirements">
|
||||
<h4>Technische Voraussetzungen:</h4>
|
||||
<ul>
|
||||
<li>Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)</li>
|
||||
<li>Keine Installation erforderlich</li>
|
||||
<li>Optional: Kopfhoerer fuer bessere Audioqualitaet</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
Bei technischen Problemen wenden Sie sich bitte an den Organisator.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html
|
||||
)
|
||||
|
||||
|
||||
# Globale Instanz
|
||||
email_service = EmailService()
|
||||
# Backward-compat shim -- module moved to services/email.py
|
||||
import importlib as _importlib
|
||||
import sys as _sys
|
||||
_sys.modules[__name__] = _importlib.import_module("services.email")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
81
backend-lehrer/services/ai_processor.py
Normal file
81
backend-lehrer/services/ai_processor.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
AI Processor - Legacy Import Wrapper
|
||||
|
||||
This file provides backward compatibility for code that imports from ai_processor.
|
||||
All functionality has been moved to the ai_processor/ module.
|
||||
|
||||
Usage (new):
|
||||
from ai_processor import analyze_scan_structure_with_ai
|
||||
|
||||
Usage (legacy, still works):
|
||||
from ai_processor import analyze_scan_structure_with_ai
|
||||
"""
|
||||
|
||||
# Re-export everything from the new modular structure
|
||||
from ai_processor import (
|
||||
# Configuration
|
||||
BASE_DIR,
|
||||
EINGANG_DIR,
|
||||
BEREINIGT_DIR,
|
||||
VISION_API,
|
||||
# Utilities (with legacy aliases)
|
||||
encode_image_to_data_url as _encode_image_to_data_url,
|
||||
dummy_process_scan,
|
||||
# Vision - Scan Analysis
|
||||
analyze_scan_structure_with_ai,
|
||||
describe_scan_with_ai,
|
||||
remove_handwriting_from_scan,
|
||||
build_clean_html_from_analysis,
|
||||
# Generators - Multiple Choice
|
||||
generate_mc_from_analysis,
|
||||
# Generators - Cloze
|
||||
generate_cloze_from_analysis,
|
||||
# Generators - Q&A with Leitner
|
||||
generate_qa_from_analysis,
|
||||
update_leitner_progress,
|
||||
get_next_review_items,
|
||||
# Export - Print Versions
|
||||
generate_print_version_qa,
|
||||
generate_print_version_cloze,
|
||||
generate_print_version_mc,
|
||||
generate_print_version_worksheet,
|
||||
# Visualization - Mindmap
|
||||
generate_mindmap_data,
|
||||
generate_mindmap_html,
|
||||
save_mindmap_for_worksheet,
|
||||
)
|
||||
|
||||
# Legacy function alias
|
||||
from ai_processor import get_openai_api_key as _get_api_key
|
||||
|
||||
__all__ = [
|
||||
# Configuration
|
||||
"BASE_DIR",
|
||||
"EINGANG_DIR",
|
||||
"BEREINIGT_DIR",
|
||||
"VISION_API",
|
||||
# Legacy private functions
|
||||
"_get_api_key",
|
||||
"_encode_image_to_data_url",
|
||||
# Vision
|
||||
"analyze_scan_structure_with_ai",
|
||||
"describe_scan_with_ai",
|
||||
"remove_handwriting_from_scan",
|
||||
"build_clean_html_from_analysis",
|
||||
"dummy_process_scan",
|
||||
# Generators
|
||||
"generate_mc_from_analysis",
|
||||
"generate_cloze_from_analysis",
|
||||
"generate_qa_from_analysis",
|
||||
"update_leitner_progress",
|
||||
"get_next_review_items",
|
||||
# Export
|
||||
"generate_print_version_qa",
|
||||
"generate_print_version_cloze",
|
||||
"generate_print_version_mc",
|
||||
"generate_print_version_worksheet",
|
||||
# Visualization
|
||||
"generate_mindmap_data",
|
||||
"generate_mindmap_html",
|
||||
"save_mindmap_for_worksheet",
|
||||
]
|
||||
125
backend-lehrer/services/audio.py
Normal file
125
backend-lehrer/services/audio.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Audio Service — Generates TTS audio for vocabulary words.
|
||||
|
||||
Uses the Piper TTS service (compliance-tts-service, MIT license)
|
||||
for high-quality German (Thorsten) and English (Lessac) voices.
|
||||
Falls back to a placeholder response if TTS service is unavailable.
|
||||
|
||||
Audio files are cached — generated once, served forever.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Piper TTS service (runs in compliance stack)
|
||||
TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://bp-compliance-tts:8095")
|
||||
|
||||
# Local cache directory for generated audio
|
||||
AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache")
|
||||
|
||||
|
||||
def _ensure_cache_dir():
|
||||
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _cache_key(text: str, language: str) -> str:
|
||||
"""Generate a deterministic cache key for text + language."""
|
||||
h = hashlib.sha256(f"{language}:{text}".encode()).hexdigest()[:16]
|
||||
return f"{language}_{h}"
|
||||
|
||||
|
||||
def _cache_path(text: str, language: str) -> str:
|
||||
"""Full path to cached MP3 file."""
|
||||
_ensure_cache_dir()
|
||||
return os.path.join(AUDIO_CACHE_DIR, f"{_cache_key(text, language)}.mp3")
|
||||
|
||||
|
||||
async def synthesize_word(
|
||||
text: str,
|
||||
language: str = "de",
|
||||
word_id: str = "",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Generate TTS audio for a word or short phrase.
|
||||
|
||||
Returns the file path to the cached MP3, or None on error.
|
||||
Uses Piper TTS service (compliance-tts-service).
|
||||
"""
|
||||
# Check cache first
|
||||
cached = _cache_path(text, language)
|
||||
if os.path.exists(cached):
|
||||
return cached
|
||||
|
||||
# Call Piper TTS service
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TTS_SERVICE_URL}/synthesize",
|
||||
json={
|
||||
"text": text,
|
||||
"language": language,
|
||||
"voice": "thorsten-high" if language == "de" else "lessac-high",
|
||||
"module_id": "vocabulary",
|
||||
"content_id": word_id or _cache_key(text, language),
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"TTS service returned {resp.status_code} for '{text}'")
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
audio_url = data.get("audio_url") or data.get("presigned_url")
|
||||
|
||||
if audio_url:
|
||||
# Download the audio file
|
||||
audio_resp = await client.get(audio_url)
|
||||
if audio_resp.status_code == 200:
|
||||
with open(cached, "wb") as f:
|
||||
f.write(audio_resp.content)
|
||||
logger.info(f"TTS cached: '{text}' ({language}) → {cached}")
|
||||
return cached
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"TTS service unavailable: {e}")
|
||||
|
||||
# Fallback: try direct MP3 endpoint
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TTS_SERVICE_URL}/synthesize/mp3",
|
||||
json={
|
||||
"text": text,
|
||||
"language": language,
|
||||
"voice": "thorsten-high" if language == "de" else "lessac-high",
|
||||
"module_id": "vocabulary",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"):
|
||||
with open(cached, "wb") as f:
|
||||
f.write(resp.content)
|
||||
logger.info(f"TTS cached (direct): '{text}' ({language}) → {cached}")
|
||||
return cached
|
||||
except Exception as e:
|
||||
logger.debug(f"TTS direct fallback also failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_or_generate_audio(
|
||||
text: str, language: str = "de", word_id: str = "",
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Get audio bytes for a word. Returns MP3 bytes or None.
|
||||
Generates via TTS if not cached.
|
||||
"""
|
||||
path = await synthesize_word(text, language, word_id)
|
||||
if path and os.path.exists(path):
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
return None
|
||||
299
backend-lehrer/services/claude_vision.py
Normal file
299
backend-lehrer/services/claude_vision.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Claude Vision API Integration for Worksheet Analysis
|
||||
|
||||
Uses Anthropic's Claude 3.5 Sonnet for superior OCR and layout understanding.
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import Anthropic SDK
|
||||
try:
|
||||
from anthropic import Anthropic
|
||||
ANTHROPIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
ANTHROPIC_AVAILABLE = False
|
||||
logger.warning("Anthropic SDK not installed. Run: pip install anthropic")
|
||||
|
||||
|
||||
def _get_anthropic_api_key() -> str:
|
||||
"""Get Anthropic API key from environment variable"""
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"ANTHROPIC_API_KEY ist nicht gesetzt. "
|
||||
"Bitte API-Schlüssel als Umgebungsvariable setzen:\n"
|
||||
"export ANTHROPIC_API_KEY='sk-ant-api03-...'"
|
||||
)
|
||||
return api_key
|
||||
|
||||
|
||||
def _encode_image_to_base64(image_path: Path) -> tuple[str, str]:
|
||||
"""
|
||||
Encode image to base64 for Claude API.
|
||||
|
||||
Returns:
|
||||
(base64_string, media_type)
|
||||
"""
|
||||
image_bytes = image_path.read_bytes()
|
||||
image_b64 = base64.standard_b64encode(image_bytes).decode("utf-8")
|
||||
|
||||
# Determine media type from extension
|
||||
ext = image_path.suffix.lower()
|
||||
media_type_map = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
media_type = media_type_map.get(ext, 'image/jpeg')
|
||||
|
||||
return image_b64, media_type
|
||||
|
||||
|
||||
def analyze_worksheet_with_claude(
|
||||
image_path: Path,
|
||||
max_tokens: int = 2500,
|
||||
model: str = "claude-3-5-sonnet-20241022"
|
||||
) -> Dict:
|
||||
"""
|
||||
Analyze worksheet using Claude Vision API.
|
||||
|
||||
Args:
|
||||
image_path: Path to worksheet image
|
||||
max_tokens: Maximum tokens in response (default 2500)
|
||||
model: Claude model to use (default: Claude 3.5 Sonnet)
|
||||
|
||||
Returns:
|
||||
Analysis dict with same structure as OpenAI version
|
||||
|
||||
Raises:
|
||||
RuntimeError: If API key not set or SDK not installed
|
||||
Exception: If API call fails
|
||||
"""
|
||||
if not ANTHROPIC_AVAILABLE:
|
||||
raise RuntimeError("Anthropic SDK nicht installiert. Run: pip install anthropic")
|
||||
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"Image not found: {image_path}")
|
||||
|
||||
# Get API key
|
||||
api_key = _get_anthropic_api_key()
|
||||
|
||||
# Initialize Anthropic client
|
||||
client = Anthropic(api_key=api_key)
|
||||
|
||||
# Encode image
|
||||
image_b64, media_type = _encode_image_to_base64(image_path)
|
||||
|
||||
# System prompt (instructions)
|
||||
system_prompt = """Du bist ein Experte für die Analyse von Schul-Arbeitsblättern.
|
||||
|
||||
Deine Aufgabe ist es, das Arbeitsblatt detailliert zu analysieren und strukturierte Informationen zu extrahieren:
|
||||
|
||||
1. **Gedruckter Text**: Erkenne den VOLLSTÄNDIGEN gedruckten Text inklusive durchgestrichener Wörter
|
||||
2. **Handschrift**: Identifiziere alle handschriftlichen Eintragungen (Schülerantworten, Korrekturen, Notizen)
|
||||
3. **Layout**: Bestimme räumliche Positionen aller Elemente (Bounding Boxes in Pixeln)
|
||||
4. **Diagramme**: Erkenne gedruckte Illustrationen, Grafiken, Diagramme
|
||||
5. **Farben**: Klassifiziere Handschrift nach Farbe (blau/schwarz/rot/Bleistift)
|
||||
|
||||
WICHTIG: Gib deine Antwort als gültiges JSON zurück, nicht als Markdown Code Block!"""
|
||||
|
||||
# User prompt with JSON schema
|
||||
user_prompt = """Analysiere dieses Arbeitsblatt und gib ein JSON mit folgendem Aufbau zurück:
|
||||
|
||||
{
|
||||
"title": string | null,
|
||||
"subject": string | null,
|
||||
"grade_level": string | null,
|
||||
"instructions": string | null,
|
||||
"canonical_text": string | null,
|
||||
"printed_blocks": [
|
||||
{
|
||||
"id": string,
|
||||
"role": "title" | "instructions" | "body" | "other",
|
||||
"text": string
|
||||
}
|
||||
],
|
||||
"layout": {
|
||||
"page_structure": {
|
||||
"has_diagram": boolean,
|
||||
"orientation": "portrait" | "landscape"
|
||||
},
|
||||
"text_regions": [
|
||||
{
|
||||
"id": string,
|
||||
"type": "title" | "paragraph" | "list" | "instruction",
|
||||
"text": string,
|
||||
"bounding_box": {"x": int, "y": int, "width": int, "height": int},
|
||||
"font_characteristics": {
|
||||
"is_bold": boolean,
|
||||
"approximate_size": "large" | "medium" | "small"
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagram_elements": [
|
||||
{
|
||||
"id": string,
|
||||
"type": "illustration" | "chart" | "graph" | "shape",
|
||||
"description": string,
|
||||
"bounding_box": {"x": int, "y": int, "width": int, "height": int},
|
||||
"preserve": boolean
|
||||
}
|
||||
]
|
||||
},
|
||||
"handwriting_regions": [
|
||||
{
|
||||
"id": string,
|
||||
"text": string,
|
||||
"type": "student_answer" | "correction" | "note" | "drawing",
|
||||
"bounding_box": {"x": int, "y": int, "width": int, "height": int},
|
||||
"color_hint": "blue" | "black" | "red" | "pencil" | "unknown"
|
||||
}
|
||||
],
|
||||
"handwritten_annotations": [
|
||||
{
|
||||
"text": string,
|
||||
"approx_location": string
|
||||
}
|
||||
],
|
||||
"struck_through_words": [
|
||||
{
|
||||
"text": string,
|
||||
"context": string
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": string,
|
||||
"type": "cloze" | "mcq" | "short_answer" | "math" | "other",
|
||||
"description": string,
|
||||
"text_with_gaps": string | null,
|
||||
"gaps": [
|
||||
{
|
||||
"id": string,
|
||||
"solution": string,
|
||||
"position_hint": string
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
WICHTIGE HINWEISE:
|
||||
- "canonical_text" enthält den KORRIGIERTEN gedruckten Text OHNE Handschrift und OHNE durchgestrichene Wörter
|
||||
- "struck_through_words" enthält alle durchgestrichenen Wörter mit Kontext
|
||||
- Bounding Boxes sind ungefähre Pixel-Positionen (x, y von oben links, width/height in Pixeln)
|
||||
- "layout.text_regions" sollte alle gedruckten Textbereiche mit genauen Positionen enthalten
|
||||
- "handwriting_regions" sollte alle handschriftlichen Bereiche mit Farb-Hinweisen enthalten
|
||||
- Setze "preserve": true für Diagramm-Elemente die erhalten bleiben sollen
|
||||
- Durchgestrichene Wörter NUR in "struck_through_words", NICHT in "canonical_text"
|
||||
|
||||
Gib NUR das JSON zurück, ohne Code-Block-Marker!"""
|
||||
|
||||
try:
|
||||
logger.info(f"Calling Claude API for analysis of {image_path.name}")
|
||||
|
||||
# Call Claude API
|
||||
response = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": image_b64,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": user_prompt
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# Extract text from response
|
||||
if not response.content:
|
||||
raise RuntimeError("Empty response from Claude API")
|
||||
|
||||
# Get first text block
|
||||
text_content = None
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
text_content = block.text
|
||||
break
|
||||
|
||||
if not text_content:
|
||||
raise RuntimeError("No text content in Claude response")
|
||||
|
||||
logger.info(f"Received response from Claude ({len(text_content)} chars)")
|
||||
|
||||
# Parse JSON
|
||||
# Claude might wrap JSON in ```json ... ```, remove if present
|
||||
text_content = text_content.strip()
|
||||
if text_content.startswith("```json"):
|
||||
text_content = text_content[7:]
|
||||
if text_content.startswith("```"):
|
||||
text_content = text_content[3:]
|
||||
if text_content.endswith("```"):
|
||||
text_content = text_content[:-3]
|
||||
text_content = text_content.strip()
|
||||
|
||||
try:
|
||||
analysis_data = json.loads(text_content)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse Claude JSON response: {e}")
|
||||
logger.error(f"Response text: {text_content[:500]}...")
|
||||
raise RuntimeError(f"Invalid JSON from Claude: {e}\nContent: {text_content[:200]}...") from e
|
||||
|
||||
logger.info("Successfully parsed Claude analysis")
|
||||
return analysis_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Claude API call failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def test_claude_connection() -> bool:
|
||||
"""
|
||||
Test if Claude API is accessible with current credentials.
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
if not ANTHROPIC_AVAILABLE:
|
||||
logger.error("Anthropic SDK not installed")
|
||||
return False
|
||||
|
||||
try:
|
||||
api_key = _get_anthropic_api_key()
|
||||
client = Anthropic(api_key=api_key)
|
||||
|
||||
# Simple test call
|
||||
response = client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": "Test"}]
|
||||
)
|
||||
|
||||
logger.info("✅ Claude API connection successful")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Claude API connection failed: {e}")
|
||||
return False
|
||||
395
backend-lehrer/services/email.py
Normal file
395
backend-lehrer/services/email.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
BreakPilot Email Service
|
||||
|
||||
Ermoeglicht den Versand von Emails via SMTP.
|
||||
Verwendet Mailpit im Entwicklungsmodus.
|
||||
"""
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.utils import formataddr
|
||||
from typing import Optional, List
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SMTP Konfiguration aus Umgebungsvariablen
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "1025"))
|
||||
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "BreakPilot")
|
||||
SMTP_FROM_ADDR = os.getenv("SMTP_FROM_ADDR", "noreply@breakpilot.app")
|
||||
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "false").lower() == "true"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailResult:
|
||||
"""Ergebnis eines Email-Versands."""
|
||||
success: bool
|
||||
message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
recipient: Optional[str] = None
|
||||
sent_at: Optional[str] = None
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Service fuer den Email-Versand."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = SMTP_HOST,
|
||||
port: int = SMTP_PORT,
|
||||
username: str = SMTP_USERNAME,
|
||||
password: str = SMTP_PASSWORD,
|
||||
from_name: str = SMTP_FROM_NAME,
|
||||
from_addr: str = SMTP_FROM_ADDR,
|
||||
use_tls: bool = SMTP_USE_TLS
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.from_name = from_name
|
||||
self.from_addr = from_addr
|
||||
self.use_tls = use_tls
|
||||
|
||||
def _get_connection(self):
|
||||
"""Erstellt eine SMTP-Verbindung."""
|
||||
if self.use_tls:
|
||||
smtp = smtplib.SMTP_SSL(self.host, self.port)
|
||||
else:
|
||||
smtp = smtplib.SMTP(self.host, self.port)
|
||||
|
||||
if self.username and self.password:
|
||||
smtp.login(self.username, self.password)
|
||||
|
||||
return smtp
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
body_html: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
cc: Optional[List[str]] = None,
|
||||
bcc: Optional[List[str]] = None
|
||||
) -> EmailResult:
|
||||
"""
|
||||
Sendet eine Email.
|
||||
|
||||
Args:
|
||||
to_email: Empfaenger-Email
|
||||
subject: Betreff
|
||||
body_text: Plaintext-Inhalt
|
||||
body_html: Optional HTML-Inhalt
|
||||
reply_to: Optional Reply-To Adresse
|
||||
cc: Optional CC-Empfaenger
|
||||
bcc: Optional BCC-Empfaenger
|
||||
|
||||
Returns:
|
||||
EmailResult mit Erfolg/Fehler
|
||||
"""
|
||||
try:
|
||||
# Message erstellen
|
||||
if body_html:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||
else:
|
||||
msg = MIMEText(body_text, "plain", "utf-8")
|
||||
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr((self.from_name, self.from_addr))
|
||||
msg["To"] = to_email
|
||||
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
|
||||
if cc:
|
||||
msg["Cc"] = ", ".join(cc)
|
||||
|
||||
# Alle Empfaenger sammeln
|
||||
recipients = [to_email]
|
||||
if cc:
|
||||
recipients.extend(cc)
|
||||
if bcc:
|
||||
recipients.extend(bcc)
|
||||
|
||||
# Senden
|
||||
with self._get_connection() as smtp:
|
||||
smtp.sendmail(self.from_addr, recipients, msg.as_string())
|
||||
|
||||
logger.info(f"Email sent to {to_email}: {subject}")
|
||||
|
||||
return EmailResult(
|
||||
success=True,
|
||||
recipient=to_email,
|
||||
sent_at=datetime.utcnow().isoformat()
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP error sending to {to_email}: {e}")
|
||||
return EmailResult(
|
||||
success=False,
|
||||
error=f"SMTP Fehler: {str(e)}",
|
||||
recipient=to_email
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email to {to_email}: {e}")
|
||||
return EmailResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
recipient=to_email
|
||||
)
|
||||
|
||||
def send_messenger_notification(
|
||||
self,
|
||||
to_email: str,
|
||||
to_name: str,
|
||||
sender_name: str,
|
||||
message_content: str,
|
||||
reply_link: Optional[str] = None
|
||||
) -> EmailResult:
|
||||
"""
|
||||
Sendet eine Messenger-Benachrichtigung per Email.
|
||||
|
||||
Args:
|
||||
to_email: Empfaenger-Email
|
||||
to_name: Name des Empfaengers
|
||||
sender_name: Name des Absenders
|
||||
message_content: Nachrichteninhalt
|
||||
reply_link: Optional Link zum Antworten
|
||||
|
||||
Returns:
|
||||
EmailResult
|
||||
"""
|
||||
subject = f"Neue Nachricht von {sender_name} - BreakPilot"
|
||||
|
||||
# Plaintext Version
|
||||
body_text = f"""Hallo {to_name},
|
||||
|
||||
Sie haben eine neue Nachricht von {sender_name} erhalten:
|
||||
|
||||
---
|
||||
{message_content}
|
||||
---
|
||||
|
||||
"""
|
||||
if reply_link:
|
||||
body_text += f"Um zu antworten, klicken Sie hier: {reply_link}\n\n"
|
||||
|
||||
body_text += """Mit freundlichen Gruessen
|
||||
Ihr BreakPilot Team
|
||||
|
||||
---
|
||||
Diese E-Mail wurde automatisch versendet.
|
||||
Bitte antworten Sie nicht direkt auf diese E-Mail.
|
||||
"""
|
||||
|
||||
# HTML Version
|
||||
body_html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: #1a2b4e; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||
.content {{ background: #f8f9fa; padding: 20px; border: 1px solid #e0e0e0; }}
|
||||
.message-box {{ background: white; padding: 15px; border-radius: 8px; border-left: 4px solid #ffb800; margin: 15px 0; }}
|
||||
.button {{ display: inline-block; background: #ffb800; color: #1a2b4e; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; }}
|
||||
.footer {{ padding: 15px; font-size: 12px; color: #666; text-align: center; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2 style="margin: 0;">Neue Nachricht</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo <strong>{to_name}</strong>,</p>
|
||||
<p>Sie haben eine neue Nachricht von <strong>{sender_name}</strong> erhalten:</p>
|
||||
|
||||
<div class="message-box">
|
||||
{message_content.replace(chr(10), '<br>')}
|
||||
</div>
|
||||
|
||||
"""
|
||||
if reply_link:
|
||||
body_html += f'<p><a href="{reply_link}" class="button">Nachricht beantworten</a></p>'
|
||||
|
||||
body_html += """
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
|
||||
<p style="font-size: 11px; color: #999;">
|
||||
Diese E-Mail wurde automatisch versendet.<br>
|
||||
Bitte antworten Sie nicht direkt auf diese E-Mail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html
|
||||
)
|
||||
|
||||
def send_jitsi_invitation(
|
||||
self,
|
||||
to_email: str,
|
||||
to_name: str,
|
||||
organizer_name: str,
|
||||
meeting_title: str,
|
||||
meeting_date: str,
|
||||
meeting_time: str,
|
||||
jitsi_url: str,
|
||||
additional_info: Optional[str] = None
|
||||
) -> EmailResult:
|
||||
"""
|
||||
Sendet eine Jitsi-Meeting-Einladung per Email.
|
||||
|
||||
Args:
|
||||
to_email: Empfaenger-Email
|
||||
to_name: Name des Empfaengers
|
||||
organizer_name: Name des Organisators
|
||||
meeting_title: Titel des Meetings
|
||||
meeting_date: Datum des Meetings (z.B. "20. Dezember 2024")
|
||||
meeting_time: Uhrzeit des Meetings (z.B. "14:00 Uhr")
|
||||
jitsi_url: Der Jitsi-Meeting-Link
|
||||
additional_info: Optional zusaetzliche Informationen
|
||||
|
||||
Returns:
|
||||
EmailResult
|
||||
"""
|
||||
subject = f"Einladung: {meeting_title} - {meeting_date}"
|
||||
|
||||
# Plaintext Version
|
||||
body_text = f"""Hallo {to_name},
|
||||
|
||||
{organizer_name} laedt Sie zu einem Videogespraech ein.
|
||||
|
||||
TERMIN: {meeting_title}
|
||||
DATUM: {meeting_date}
|
||||
UHRZEIT: {meeting_time}
|
||||
|
||||
Treten Sie dem Meeting bei:
|
||||
{jitsi_url}
|
||||
|
||||
"""
|
||||
if additional_info:
|
||||
body_text += f"HINWEISE:\n{additional_info}\n\n"
|
||||
|
||||
body_text += """TECHNISCHE VORAUSSETZUNGEN:
|
||||
- Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
|
||||
- Keine Installation erforderlich
|
||||
- Optional: Kopfhoerer fuer bessere Audioqualitaet
|
||||
|
||||
Bei technischen Problemen wenden Sie sich bitte an den Organisator.
|
||||
|
||||
Mit freundlichen Gruessen
|
||||
Ihr BreakPilot Team
|
||||
"""
|
||||
|
||||
# HTML Version
|
||||
body_html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 30px; border-radius: 12px 12px 0 0; text-align: center; }}
|
||||
.header h2 {{ margin: 0 0 10px 0; font-size: 24px; }}
|
||||
.content {{ background: #f8f9fa; padding: 25px; border: 1px solid #e0e0e0; }}
|
||||
.meeting-info {{ background: white; padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||
.info-row {{ display: flex; padding: 10px 0; border-bottom: 1px solid #eee; }}
|
||||
.info-row:last-child {{ border-bottom: none; }}
|
||||
.info-label {{ font-weight: 600; color: #666; width: 100px; }}
|
||||
.info-value {{ color: #333; }}
|
||||
.join-button {{ display: block; background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 16px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 18px; text-align: center; margin: 25px 0; }}
|
||||
.join-button:hover {{ opacity: 0.9; }}
|
||||
.requirements {{ background: #e8f5e9; padding: 15px; border-radius: 8px; margin: 20px 0; }}
|
||||
.requirements h4 {{ margin: 0 0 10px 0; color: #2e7d32; }}
|
||||
.requirements ul {{ margin: 0; padding-left: 20px; }}
|
||||
.footer {{ padding: 20px; font-size: 12px; color: #666; text-align: center; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>Einladung zum Videogespraech</h2>
|
||||
<p style="margin: 0; opacity: 0.9;">{meeting_title}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo <strong>{to_name}</strong>,</p>
|
||||
<p><strong>{organizer_name}</strong> laedt Sie zu einem Videogespraech ein.</p>
|
||||
|
||||
<div class="meeting-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Termin:</span>
|
||||
<span class="info-value">{meeting_title}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Datum:</span>
|
||||
<span class="info-value">{meeting_date}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Uhrzeit:</span>
|
||||
<span class="info-value">{meeting_time}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{jitsi_url}" class="join-button">Meeting beitreten</a>
|
||||
|
||||
"""
|
||||
if additional_info:
|
||||
body_html += f"""
|
||||
<div style="background: #fff3e0; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #e65100;">Hinweise:</h4>
|
||||
<p style="margin: 0;">{additional_info}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
body_html += """
|
||||
<div class="requirements">
|
||||
<h4>Technische Voraussetzungen:</h4>
|
||||
<ul>
|
||||
<li>Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)</li>
|
||||
<li>Keine Installation erforderlich</li>
|
||||
<li>Optional: Kopfhoerer fuer bessere Audioqualitaet</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
Bei technischen Problemen wenden Sie sich bitte an den Organisator.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html
|
||||
)
|
||||
|
||||
|
||||
# Globale Instanz
|
||||
email_service = EmailService()
|
||||
108
backend-lehrer/services/story_generator.py
Normal file
108
backend-lehrer/services/story_generator.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Story Generator — Creates short stories using vocabulary words.
|
||||
|
||||
Generates age-appropriate mini-stories (3-5 sentences) that incorporate
|
||||
the given vocabulary words, marked with <mark> tags for highlighting.
|
||||
|
||||
Uses Ollama (local LLM) for generation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
|
||||
STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b")
|
||||
|
||||
|
||||
def generate_story(
|
||||
vocabulary: List[Dict[str, str]],
|
||||
language: str = "en",
|
||||
grade_level: str = "5-8",
|
||||
max_words: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a short story incorporating vocabulary words.
|
||||
|
||||
Args:
|
||||
vocabulary: List of dicts with 'english' and 'german' keys
|
||||
language: 'en' for English story, 'de' for German story
|
||||
grade_level: Target grade level
|
||||
max_words: Maximum vocab words to include (to keep story short)
|
||||
|
||||
Returns:
|
||||
Dict with 'story_html', 'story_text', 'vocab_used', 'language'
|
||||
"""
|
||||
# Select subset of vocabulary
|
||||
words = vocabulary[:max_words]
|
||||
word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words]
|
||||
word_list = [w for w in word_list if w.strip()]
|
||||
|
||||
if not word_list:
|
||||
return {"story_html": "", "story_text": "", "vocab_used": [], "language": language}
|
||||
|
||||
lang_name = "English" if language == "en" else "German"
|
||||
words_str = ", ".join(word_list)
|
||||
|
||||
prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student.
|
||||
The story MUST use these vocabulary words: {words_str}
|
||||
|
||||
Rules:
|
||||
1. The story should be fun and age-appropriate
|
||||
2. Each vocabulary word must appear at least once
|
||||
3. Keep sentences simple and clear
|
||||
4. The story should make sense and be engaging
|
||||
|
||||
Write ONLY the story, nothing else. No title, no introduction."""
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{OLLAMA_URL}/api/generate",
|
||||
json={
|
||||
"model": STORY_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.8, "num_predict": 300},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
story_text = resp.json().get("response", "").strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Story generation failed: {e}")
|
||||
# Fallback: simple template story
|
||||
story_text = _fallback_story(word_list, language)
|
||||
|
||||
# Mark vocabulary words in the story
|
||||
story_html = story_text
|
||||
vocab_found = []
|
||||
for word in word_list:
|
||||
if word.lower() in story_html.lower():
|
||||
# Case-insensitive replacement preserving original case
|
||||
import re
|
||||
pattern = re.compile(re.escape(word), re.IGNORECASE)
|
||||
story_html = pattern.sub(
|
||||
lambda m: f'<mark class="vocab-highlight">{m.group()}</mark>',
|
||||
story_html,
|
||||
count=1,
|
||||
)
|
||||
vocab_found.append(word)
|
||||
|
||||
return {
|
||||
"story_html": story_html,
|
||||
"story_text": story_text,
|
||||
"vocab_used": vocab_found,
|
||||
"vocab_total": len(word_list),
|
||||
"language": language,
|
||||
}
|
||||
|
||||
|
||||
def _fallback_story(words: List[str], language: str) -> str:
|
||||
"""Simple fallback when LLM is unavailable."""
|
||||
if language == "de":
|
||||
return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen."
|
||||
return f"Today I learned new words: {', '.join(words)}. It was a great day for learning."
|
||||
179
backend-lehrer/services/translation.py
Normal file
179
backend-lehrer/services/translation.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Translation Service — Batch-translates vocabulary words into target languages.
|
||||
|
||||
Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL.
|
||||
Translations are cached in vocabulary_words.translations JSONB field.
|
||||
|
||||
All processing happens locally — no external API calls, GDPR-compliant.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
|
||||
TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b")
|
||||
|
||||
LANGUAGE_NAMES = {
|
||||
"tr": "Turkish",
|
||||
"ar": "Arabic",
|
||||
"uk": "Ukrainian",
|
||||
"ru": "Russian",
|
||||
"pl": "Polish",
|
||||
"fr": "French",
|
||||
"es": "Spanish",
|
||||
}
|
||||
|
||||
|
||||
async def translate_words_batch(
|
||||
words: List[Dict[str, str]],
|
||||
target_language: str,
|
||||
batch_size: int = 30,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Translate a batch of EN/DE word pairs into a target language.
|
||||
|
||||
Args:
|
||||
words: List of dicts with 'english' and 'german' keys
|
||||
target_language: ISO 639-1 code (tr, ar, uk, ru, pl)
|
||||
batch_size: Words per LLM request
|
||||
|
||||
Returns:
|
||||
List of dicts with 'english', 'translation', 'example' keys
|
||||
"""
|
||||
lang_name = LANGUAGE_NAMES.get(target_language, target_language)
|
||||
all_translations = []
|
||||
|
||||
for i in range(0, len(words), batch_size):
|
||||
batch = words[i:i + batch_size]
|
||||
word_list = "\n".join(
|
||||
f"{j+1}. {w['english']} = {w.get('german', '')}"
|
||||
for j, w in enumerate(batch)
|
||||
)
|
||||
|
||||
prompt = f"""Translate these English/German word pairs into {lang_name}.
|
||||
For each word, provide the translation and a short example sentence in {lang_name}.
|
||||
|
||||
Words:
|
||||
{word_list}
|
||||
|
||||
Reply ONLY with a JSON array, no explanation:
|
||||
[
|
||||
{{"english": "word", "translation": "...", "example": "..."}},
|
||||
...
|
||||
]"""
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
f"{OLLAMA_BASE_URL}/api/generate",
|
||||
json={
|
||||
"model": TRANSLATION_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.2, "num_predict": 4096},
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
response_text = resp.json().get("response", "")
|
||||
|
||||
# Parse JSON from response
|
||||
import re
|
||||
match = re.search(r'\[[\s\S]*\]', response_text)
|
||||
if match:
|
||||
batch_translations = json.loads(match.group())
|
||||
all_translations.extend(batch_translations)
|
||||
logger.info(
|
||||
f"Translated batch {i//batch_size + 1}: "
|
||||
f"{len(batch_translations)} words → {lang_name}"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"No JSON array in LLM response for {lang_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Translation batch failed ({lang_name}): {e}")
|
||||
|
||||
return all_translations
|
||||
|
||||
|
||||
async def translate_and_store(
|
||||
word_ids: List[str],
|
||||
target_language: str,
|
||||
) -> int:
|
||||
"""
|
||||
Translate vocabulary words and store in the database.
|
||||
|
||||
Fetches words from DB, translates via LLM, stores in translations JSONB.
|
||||
Skips words that already have a translation for the target language.
|
||||
|
||||
Returns count of newly translated words.
|
||||
"""
|
||||
from vocabulary_db import get_pool
|
||||
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Fetch words that need translation
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, english, german, translations
|
||||
FROM vocabulary_words
|
||||
WHERE id = ANY($1::uuid[])
|
||||
""",
|
||||
[__import__('uuid').UUID(wid) for wid in word_ids],
|
||||
)
|
||||
|
||||
words_to_translate = []
|
||||
word_map = {}
|
||||
for row in rows:
|
||||
translations = row["translations"] or {}
|
||||
if isinstance(translations, str):
|
||||
translations = json.loads(translations)
|
||||
if target_language not in translations:
|
||||
words_to_translate.append({
|
||||
"english": row["english"],
|
||||
"german": row["german"],
|
||||
})
|
||||
word_map[row["english"].lower()] = str(row["id"])
|
||||
|
||||
if not words_to_translate:
|
||||
logger.info(f"All {len(rows)} words already translated to {target_language}")
|
||||
return 0
|
||||
|
||||
# Translate
|
||||
results = await translate_words_batch(words_to_translate, target_language)
|
||||
|
||||
# Store results
|
||||
updated = 0
|
||||
async with pool.acquire() as conn:
|
||||
for result in results:
|
||||
en = result.get("english", "").lower()
|
||||
word_id = word_map.get(en)
|
||||
if not word_id:
|
||||
continue
|
||||
|
||||
translation = result.get("translation", "")
|
||||
example = result.get("example", "")
|
||||
if not translation:
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE vocabulary_words
|
||||
SET translations = translations || $1::jsonb
|
||||
WHERE id = $2
|
||||
""",
|
||||
json.dumps({target_language: {
|
||||
"text": translation,
|
||||
"example": example,
|
||||
}}),
|
||||
__import__('uuid').UUID(word_id),
|
||||
)
|
||||
updated += 1
|
||||
|
||||
logger.info(f"Stored {updated} translations for {target_language}")
|
||||
return updated
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,108 +1,4 @@
|
||||
"""
|
||||
Story Generator — Creates short stories using vocabulary words.
|
||||
|
||||
Generates age-appropriate mini-stories (3-5 sentences) that incorporate
|
||||
the given vocabulary words, marked with <mark> tags for highlighting.
|
||||
|
||||
Uses Ollama (local LLM) for generation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
|
||||
STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b")
|
||||
|
||||
|
||||
def generate_story(
|
||||
vocabulary: List[Dict[str, str]],
|
||||
language: str = "en",
|
||||
grade_level: str = "5-8",
|
||||
max_words: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a short story incorporating vocabulary words.
|
||||
|
||||
Args:
|
||||
vocabulary: List of dicts with 'english' and 'german' keys
|
||||
language: 'en' for English story, 'de' for German story
|
||||
grade_level: Target grade level
|
||||
max_words: Maximum vocab words to include (to keep story short)
|
||||
|
||||
Returns:
|
||||
Dict with 'story_html', 'story_text', 'vocab_used', 'language'
|
||||
"""
|
||||
# Select subset of vocabulary
|
||||
words = vocabulary[:max_words]
|
||||
word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words]
|
||||
word_list = [w for w in word_list if w.strip()]
|
||||
|
||||
if not word_list:
|
||||
return {"story_html": "", "story_text": "", "vocab_used": [], "language": language}
|
||||
|
||||
lang_name = "English" if language == "en" else "German"
|
||||
words_str = ", ".join(word_list)
|
||||
|
||||
prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student.
|
||||
The story MUST use these vocabulary words: {words_str}
|
||||
|
||||
Rules:
|
||||
1. The story should be fun and age-appropriate
|
||||
2. Each vocabulary word must appear at least once
|
||||
3. Keep sentences simple and clear
|
||||
4. The story should make sense and be engaging
|
||||
|
||||
Write ONLY the story, nothing else. No title, no introduction."""
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{OLLAMA_URL}/api/generate",
|
||||
json={
|
||||
"model": STORY_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.8, "num_predict": 300},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
story_text = resp.json().get("response", "").strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Story generation failed: {e}")
|
||||
# Fallback: simple template story
|
||||
story_text = _fallback_story(word_list, language)
|
||||
|
||||
# Mark vocabulary words in the story
|
||||
story_html = story_text
|
||||
vocab_found = []
|
||||
for word in word_list:
|
||||
if word.lower() in story_html.lower():
|
||||
# Case-insensitive replacement preserving original case
|
||||
import re
|
||||
pattern = re.compile(re.escape(word), re.IGNORECASE)
|
||||
story_html = pattern.sub(
|
||||
lambda m: f'<mark class="vocab-highlight">{m.group()}</mark>',
|
||||
story_html,
|
||||
count=1,
|
||||
)
|
||||
vocab_found.append(word)
|
||||
|
||||
return {
|
||||
"story_html": story_html,
|
||||
"story_text": story_text,
|
||||
"vocab_used": vocab_found,
|
||||
"vocab_total": len(word_list),
|
||||
"language": language,
|
||||
}
|
||||
|
||||
|
||||
def _fallback_story(words: List[str], language: str) -> str:
|
||||
"""Simple fallback when LLM is unavailable."""
|
||||
if language == "de":
|
||||
return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen."
|
||||
return f"Today I learned new words: {', '.join(words)}. It was a great day for learning."
|
||||
# Backward-compat shim -- module moved to services/story_generator.py
|
||||
import importlib as _importlib
|
||||
import sys as _sys
|
||||
_sys.modules[__name__] = _importlib.import_module("services.story_generator")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
33
backend-lehrer/vocabulary/__init__.py
Normal file
33
backend-lehrer/vocabulary/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Vocabulary Module
|
||||
# vocabulary/api.py — API router (search, browse, import, translate)
|
||||
# vocabulary/db.py — PostgreSQL storage for vocabulary word catalog
|
||||
|
||||
from .api import router
|
||||
from .db import (
|
||||
VocabularyWord,
|
||||
get_pool,
|
||||
init_vocabulary_tables,
|
||||
search_words,
|
||||
get_word,
|
||||
browse_words,
|
||||
insert_word,
|
||||
insert_words_bulk,
|
||||
count_words,
|
||||
get_all_tags,
|
||||
get_all_pos,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"VocabularyWord",
|
||||
"get_pool",
|
||||
"init_vocabulary_tables",
|
||||
"search_words",
|
||||
"get_word",
|
||||
"browse_words",
|
||||
"insert_word",
|
||||
"insert_words_bulk",
|
||||
"count_words",
|
||||
"get_all_tags",
|
||||
"get_all_pos",
|
||||
]
|
||||
352
backend-lehrer/vocabulary/api.py
Normal file
352
backend-lehrer/vocabulary/api.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Vocabulary API — Search, browse, and build learning units from the word catalog.
|
||||
|
||||
Endpoints for teachers to find words and create learning units,
|
||||
and for students to access word details with audio/images/syllables.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .db import (
|
||||
search_words,
|
||||
get_word,
|
||||
browse_words,
|
||||
insert_word,
|
||||
count_words,
|
||||
get_all_tags,
|
||||
get_all_pos,
|
||||
VocabularyWord,
|
||||
)
|
||||
from learning_units import (
|
||||
LearningUnitCreate,
|
||||
create_learning_unit,
|
||||
get_learning_unit,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search & Browse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def api_search_words(
|
||||
q: str = Query("", description="Search query"),
|
||||
lang: str = Query("en", pattern="^(en|de)$"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""Full-text search for vocabulary words."""
|
||||
if not q.strip():
|
||||
return {"words": [], "query": q, "total": 0}
|
||||
|
||||
words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset)
|
||||
return {
|
||||
"words": [w.to_dict() for w in words],
|
||||
"query": q,
|
||||
"total": len(words),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
async def api_browse_words(
|
||||
pos: str = Query("", description="Part of speech filter"),
|
||||
difficulty: int = Query(0, ge=0, le=5, description="Difficulty 1-5, 0=all"),
|
||||
tag: str = Query("", description="Tag filter"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""Browse vocabulary words with filters."""
|
||||
words = await browse_words(
|
||||
pos=pos, difficulty=difficulty, tag=tag,
|
||||
limit=limit, offset=offset,
|
||||
)
|
||||
return {
|
||||
"words": [w.to_dict() for w in words],
|
||||
"filters": {"pos": pos, "difficulty": difficulty, "tag": tag},
|
||||
"total": len(words),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/word/{word_id}")
|
||||
async def api_get_word(word_id: str):
|
||||
"""Get a single word with all details."""
|
||||
word = await get_word(word_id)
|
||||
if not word:
|
||||
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
||||
return word.to_dict()
|
||||
|
||||
|
||||
@router.get("/filters")
|
||||
async def api_get_filters():
|
||||
"""Get available filter options (tags, parts of speech, word count)."""
|
||||
tags = await get_all_tags()
|
||||
pos_list = await get_all_pos()
|
||||
total = await count_words()
|
||||
return {
|
||||
"tags": tags,
|
||||
"parts_of_speech": pos_list,
|
||||
"total_words": total,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audio TTS for Words
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/word/{word_id}/audio/{lang}")
|
||||
async def api_get_word_audio(word_id: str, lang: str = "en"):
|
||||
"""Get or generate TTS audio for a vocabulary word.
|
||||
|
||||
Returns MP3 audio. Generated on first request, cached after.
|
||||
Uses Piper TTS (MIT license) with Thorsten (DE) and Lessac (EN) voices.
|
||||
"""
|
||||
from fastapi.responses import Response as FastAPIResponse
|
||||
|
||||
word = await get_word(word_id)
|
||||
if not word:
|
||||
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
||||
|
||||
text = word.english if lang == "en" else word.german
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'")
|
||||
|
||||
from audio_service import get_or_generate_audio
|
||||
audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id)
|
||||
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
|
||||
|
||||
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
||||
|
||||
|
||||
@router.get("/word/{word_id}/audio-syllables/{lang}")
|
||||
async def api_get_syllable_audio(word_id: str, lang: str = "en"):
|
||||
"""Get TTS audio with slow syllable pronunciation.
|
||||
|
||||
Generates audio like "ap ... ple" with pauses between syllables.
|
||||
"""
|
||||
from fastapi.responses import Response as FastAPIResponse
|
||||
|
||||
word = await get_word(word_id)
|
||||
if not word:
|
||||
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
|
||||
|
||||
syllables = word.syllables_en if lang == "en" else word.syllables_de
|
||||
if not syllables:
|
||||
# Fallback to full word
|
||||
text = word.english if lang == "en" else word.german
|
||||
syllables = [text]
|
||||
|
||||
# Join syllables with pauses (Piper handles "..." as pause)
|
||||
slow_text = " ... ".join(syllables)
|
||||
|
||||
from audio_service import get_or_generate_audio
|
||||
cache_key = f"{word_id}_syl_{lang}"
|
||||
audio_bytes = await get_or_generate_audio(slow_text, language=lang, word_id=cache_key)
|
||||
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
|
||||
|
||||
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Learning Unit Creation from Word Selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CreateUnitFromWordsPayload(BaseModel):
|
||||
title: str
|
||||
word_ids: List[str]
|
||||
grade: Optional[str] = None
|
||||
language: Optional[str] = "de"
|
||||
|
||||
|
||||
@router.post("/units")
|
||||
async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload):
|
||||
"""Create a learning unit from selected vocabulary word IDs.
|
||||
|
||||
Fetches full word details, creates a LearningUnit in the
|
||||
learning_units system, and stores the vocabulary data.
|
||||
"""
|
||||
if not payload.word_ids:
|
||||
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
|
||||
|
||||
# Fetch all selected words
|
||||
words = []
|
||||
for wid in payload.word_ids:
|
||||
word = await get_word(wid)
|
||||
if word:
|
||||
words.append(word)
|
||||
|
||||
if not words:
|
||||
raise HTTPException(status_code=404, detail="Keine der Woerter gefunden")
|
||||
|
||||
# Create learning unit
|
||||
lu = create_learning_unit(LearningUnitCreate(
|
||||
title=payload.title,
|
||||
topic="Vocabulary",
|
||||
grade_level=payload.grade or "5-8",
|
||||
language=payload.language or "de",
|
||||
status="raw",
|
||||
))
|
||||
|
||||
# Save vocabulary data as analysis JSON for generators
|
||||
import os
|
||||
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
|
||||
os.makedirs(analysis_dir, exist_ok=True)
|
||||
|
||||
vocab_data = [w.to_dict() for w in words]
|
||||
analysis_path = os.path.join(analysis_dir, f"{lu.id}_vocab.json")
|
||||
with open(analysis_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Also save as QA items for flashcards/type trainer
|
||||
qa_items = []
|
||||
for i, w in enumerate(words):
|
||||
qa_items.append({
|
||||
"id": f"qa_{i+1}",
|
||||
"question": w.english,
|
||||
"answer": w.german,
|
||||
"question_type": "knowledge",
|
||||
"key_terms": [w.english],
|
||||
"difficulty": w.difficulty,
|
||||
"source_hint": w.part_of_speech,
|
||||
"leitner_box": 0,
|
||||
"correct_count": 0,
|
||||
"incorrect_count": 0,
|
||||
"last_seen": None,
|
||||
"next_review": None,
|
||||
# Extra fields for enhanced flashcards
|
||||
"ipa_en": w.ipa_en,
|
||||
"ipa_de": w.ipa_de,
|
||||
"syllables_en": w.syllables_en,
|
||||
"syllables_de": w.syllables_de,
|
||||
"example_en": w.example_en,
|
||||
"example_de": w.example_de,
|
||||
"image_url": w.image_url,
|
||||
"audio_url_en": w.audio_url_en,
|
||||
"audio_url_de": w.audio_url_de,
|
||||
"part_of_speech": w.part_of_speech,
|
||||
"translations": w.translations,
|
||||
})
|
||||
|
||||
qa_path = os.path.join(analysis_dir, f"{lu.id}_qa.json")
|
||||
with open(qa_path, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"qa_items": qa_items,
|
||||
"metadata": {
|
||||
"subject": "English Vocabulary",
|
||||
"grade_level": payload.grade or "5-8",
|
||||
"source_title": payload.title,
|
||||
"total_questions": len(qa_items),
|
||||
},
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"Created vocab unit {lu.id} with {len(words)} words")
|
||||
|
||||
return {
|
||||
"unit_id": lu.id,
|
||||
"title": payload.title,
|
||||
"word_count": len(words),
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/units/{unit_id}")
|
||||
async def api_get_unit_words(unit_id: str):
|
||||
"""Get all words for a learning unit."""
|
||||
import os
|
||||
vocab_path = os.path.join(
|
||||
os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten"),
|
||||
f"{unit_id}_vocab.json",
|
||||
)
|
||||
if not os.path.exists(vocab_path):
|
||||
raise HTTPException(status_code=404, detail="Unit nicht gefunden")
|
||||
|
||||
with open(vocab_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"title": data.get("title", ""),
|
||||
"words": data.get("words", []),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bulk Import (for seeding the dictionary)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BulkImportPayload(BaseModel):
|
||||
words: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def api_bulk_import(payload: BulkImportPayload):
|
||||
"""Bulk import vocabulary words (for seeding the dictionary).
|
||||
|
||||
Each word dict should have at minimum: english, german.
|
||||
Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de,
|
||||
example_en, example_de, difficulty, tags, translations.
|
||||
"""
|
||||
from .db import insert_words_bulk
|
||||
|
||||
words = []
|
||||
for w in payload.words:
|
||||
words.append(VocabularyWord(
|
||||
english=w.get("english", ""),
|
||||
german=w.get("german", ""),
|
||||
ipa_en=w.get("ipa_en", ""),
|
||||
ipa_de=w.get("ipa_de", ""),
|
||||
part_of_speech=w.get("part_of_speech", ""),
|
||||
syllables_en=w.get("syllables_en", []),
|
||||
syllables_de=w.get("syllables_de", []),
|
||||
example_en=w.get("example_en", ""),
|
||||
example_de=w.get("example_de", ""),
|
||||
difficulty=w.get("difficulty", 1),
|
||||
tags=w.get("tags", []),
|
||||
translations=w.get("translations", {}),
|
||||
))
|
||||
|
||||
count = await insert_words_bulk(words)
|
||||
logger.info(f"Bulk imported {count} vocabulary words")
|
||||
return {"imported": count}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Translation Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TranslateRequest(BaseModel):
|
||||
word_ids: List[str]
|
||||
target_language: str
|
||||
|
||||
|
||||
@router.post("/translate")
|
||||
async def api_translate_words(payload: TranslateRequest):
|
||||
"""Generate translations for vocabulary words into a target language.
|
||||
|
||||
Uses local LLM (Ollama) for translation. Results are cached in the
|
||||
vocabulary_words.translations JSONB field.
|
||||
"""
|
||||
from translation_service import translate_and_store
|
||||
|
||||
if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}:
|
||||
raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt")
|
||||
|
||||
count = await translate_and_store(payload.word_ids, payload.target_language)
|
||||
return {"translated": count, "target_language": payload.target_language}
|
||||
296
backend-lehrer/vocabulary/db.py
Normal file
296
backend-lehrer/vocabulary/db.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Vocabulary Database — PostgreSQL storage for the vocabulary word catalog.
|
||||
|
||||
Stores 160k+ words with translations, IPA, syllables, examples, images, audio.
|
||||
Uses asyncpg for async PostgreSQL access (same pattern as game/database.py).
|
||||
|
||||
Schema: lehrer.vocabulary_words (search_path set in main.py)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RAW_DB_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://breakpilot:breakpilot@postgres:5432/breakpilot",
|
||||
)
|
||||
# Strip SQLAlchemy dialect prefix (asyncpg needs plain postgresql://)
|
||||
DATABASE_URL = _RAW_DB_URL.replace("postgresql+asyncpg://", "postgresql://")
|
||||
# Strip search_path options (set via SET after connect)
|
||||
if "options=" in DATABASE_URL:
|
||||
DATABASE_URL = DATABASE_URL.split("?")[0] if "options=" in DATABASE_URL.split("?")[-1] else DATABASE_URL
|
||||
|
||||
_pool = None
|
||||
|
||||
|
||||
async def get_pool():
|
||||
"""Get or create the asyncpg connection pool."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
import asyncpg
|
||||
_pool = await asyncpg.create_pool(
|
||||
DATABASE_URL, min_size=2, max_size=10,
|
||||
server_settings={"search_path": "lehrer,core,public"},
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
async def init_vocabulary_tables():
|
||||
"""Create vocabulary tables if they don't exist."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS vocabulary_words (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
english TEXT NOT NULL,
|
||||
german TEXT NOT NULL DEFAULT '',
|
||||
ipa_en TEXT NOT NULL DEFAULT '',
|
||||
ipa_de TEXT NOT NULL DEFAULT '',
|
||||
part_of_speech TEXT NOT NULL DEFAULT '',
|
||||
syllables_en TEXT[] NOT NULL DEFAULT '{}',
|
||||
syllables_de TEXT[] NOT NULL DEFAULT '{}',
|
||||
example_en TEXT NOT NULL DEFAULT '',
|
||||
example_de TEXT NOT NULL DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
audio_url_en TEXT NOT NULL DEFAULT '',
|
||||
audio_url_de TEXT NOT NULL DEFAULT '',
|
||||
difficulty INT NOT NULL DEFAULT 1,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
translations JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_english
|
||||
ON vocabulary_words (lower(english));
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_german
|
||||
ON vocabulary_words (lower(german));
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_pos
|
||||
ON vocabulary_words (part_of_speech);
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_difficulty
|
||||
ON vocabulary_words (difficulty);
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_tags
|
||||
ON vocabulary_words USING GIN (tags);
|
||||
""")
|
||||
# Enable trigram extension for fuzzy search (optional)
|
||||
try:
|
||||
await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_english_trgm
|
||||
ON vocabulary_words USING GIN (english gin_trgm_ops);
|
||||
""")
|
||||
except Exception:
|
||||
logger.info("pg_trgm not available — trigram search disabled, using LIKE fallback")
|
||||
|
||||
logger.info("vocabulary_words table initialized")
|
||||
|
||||
|
||||
@dataclass
|
||||
class VocabularyWord:
|
||||
"""A single vocabulary word with all metadata."""
|
||||
id: str = ""
|
||||
english: str = ""
|
||||
german: str = ""
|
||||
ipa_en: str = ""
|
||||
ipa_de: str = ""
|
||||
part_of_speech: str = ""
|
||||
syllables_en: List[str] = field(default_factory=list)
|
||||
syllables_de: List[str] = field(default_factory=list)
|
||||
example_en: str = ""
|
||||
example_de: str = ""
|
||||
image_url: str = ""
|
||||
audio_url_en: str = ""
|
||||
audio_url_de: str = ""
|
||||
difficulty: int = 1
|
||||
tags: List[str] = field(default_factory=list)
|
||||
translations: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _row_to_word(row) -> VocabularyWord:
|
||||
"""Convert an asyncpg Record to VocabularyWord."""
|
||||
import json
|
||||
translations = row["translations"]
|
||||
if isinstance(translations, str):
|
||||
translations = json.loads(translations)
|
||||
return VocabularyWord(
|
||||
id=str(row["id"]),
|
||||
english=row["english"],
|
||||
german=row["german"],
|
||||
ipa_en=row["ipa_en"],
|
||||
ipa_de=row["ipa_de"],
|
||||
part_of_speech=row["part_of_speech"],
|
||||
syllables_en=list(row["syllables_en"] or []),
|
||||
syllables_de=list(row["syllables_de"] or []),
|
||||
example_en=row["example_en"],
|
||||
example_de=row["example_de"],
|
||||
image_url=row["image_url"],
|
||||
audio_url_en=row["audio_url_en"],
|
||||
audio_url_de=row["audio_url_de"],
|
||||
difficulty=row["difficulty"],
|
||||
tags=list(row["tags"] or []),
|
||||
translations=translations or {},
|
||||
)
|
||||
|
||||
|
||||
async def search_words(
|
||||
query: str, lang: str = "en", limit: int = 20, offset: int = 0,
|
||||
) -> List[VocabularyWord]:
|
||||
"""Full-text search for words. Uses trigram similarity if available, else ILIKE."""
|
||||
pool = await get_pool()
|
||||
col = "english" if lang == "en" else "german"
|
||||
async with pool.acquire() as conn:
|
||||
# Try trigram search first, fall back to ILIKE
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT * FROM vocabulary_words
|
||||
WHERE lower({col}) LIKE $1 OR {col} % $2
|
||||
ORDER BY similarity({col}, $2) DESC, lower({col})
|
||||
LIMIT $3 OFFSET $4
|
||||
""",
|
||||
f"%{query.lower()}%", query, limit, offset,
|
||||
)
|
||||
except Exception:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT * FROM vocabulary_words
|
||||
WHERE lower({col}) LIKE $1
|
||||
ORDER BY lower({col})
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
f"%{query.lower()}%", limit, offset,
|
||||
)
|
||||
return [_row_to_word(r) for r in rows]
|
||||
|
||||
|
||||
async def get_word(word_id: str) -> Optional[VocabularyWord]:
|
||||
"""Get a single word by ID."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM vocabulary_words WHERE id = $1", uuid.UUID(word_id),
|
||||
)
|
||||
return _row_to_word(row) if row else None
|
||||
|
||||
|
||||
async def browse_words(
|
||||
pos: str = "", difficulty: int = 0, tag: str = "",
|
||||
limit: int = 50, offset: int = 0,
|
||||
) -> List[VocabularyWord]:
|
||||
"""Browse words with filters."""
|
||||
pool = await get_pool()
|
||||
conditions = []
|
||||
params: List[Any] = []
|
||||
idx = 1
|
||||
|
||||
if pos:
|
||||
conditions.append(f"part_of_speech = ${idx}")
|
||||
params.append(pos)
|
||||
idx += 1
|
||||
if difficulty > 0:
|
||||
conditions.append(f"difficulty = ${idx}")
|
||||
params.append(difficulty)
|
||||
idx += 1
|
||||
if tag:
|
||||
conditions.append(f"${idx} = ANY(tags)")
|
||||
params.append(tag)
|
||||
idx += 1
|
||||
|
||||
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
params.extend([limit, offset])
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"SELECT * FROM vocabulary_words {where} ORDER BY english LIMIT ${idx} OFFSET ${idx+1}",
|
||||
*params,
|
||||
)
|
||||
return [_row_to_word(r) for r in rows]
|
||||
|
||||
|
||||
async def insert_word(word: VocabularyWord) -> str:
|
||||
"""Insert a new word, returns the ID."""
|
||||
pool = await get_pool()
|
||||
import json
|
||||
word_id = word.id or str(uuid.uuid4())
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO vocabulary_words
|
||||
(id, english, german, ipa_en, ipa_de, part_of_speech,
|
||||
syllables_en, syllables_de, example_en, example_de,
|
||||
image_url, audio_url_en, audio_url_de, difficulty, tags, translations)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
uuid.UUID(word_id), word.english, word.german,
|
||||
word.ipa_en, word.ipa_de, word.part_of_speech,
|
||||
word.syllables_en, word.syllables_de,
|
||||
word.example_en, word.example_de,
|
||||
word.image_url, word.audio_url_en, word.audio_url_de,
|
||||
word.difficulty, word.tags, json.dumps(word.translations),
|
||||
)
|
||||
return word_id
|
||||
|
||||
|
||||
async def insert_words_bulk(words: List[VocabularyWord]) -> int:
|
||||
"""Bulk insert words. Returns count of inserted rows."""
|
||||
pool = await get_pool()
|
||||
import json
|
||||
records = []
|
||||
for w in words:
|
||||
wid = w.id or str(uuid.uuid4())
|
||||
records.append((
|
||||
uuid.UUID(wid), w.english, w.german,
|
||||
w.ipa_en, w.ipa_de, w.part_of_speech,
|
||||
w.syllables_en, w.syllables_de,
|
||||
w.example_en, w.example_de,
|
||||
w.image_url, w.audio_url_en, w.audio_url_de,
|
||||
w.difficulty, w.tags, json.dumps(w.translations),
|
||||
))
|
||||
async with pool.acquire() as conn:
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO vocabulary_words
|
||||
(id, english, german, ipa_en, ipa_de, part_of_speech,
|
||||
syllables_en, syllables_de, example_en, example_de,
|
||||
image_url, audio_url_en, audio_url_de, difficulty, tags, translations)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
records,
|
||||
)
|
||||
return len(records)
|
||||
|
||||
|
||||
async def count_words() -> int:
|
||||
"""Count total words in the database."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
return await conn.fetchval("SELECT COUNT(*) FROM vocabulary_words")
|
||||
|
||||
|
||||
async def get_all_tags() -> List[str]:
|
||||
"""Get all unique tags."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT DISTINCT unnest(tags) AS tag FROM vocabulary_words ORDER BY tag"
|
||||
)
|
||||
return [r["tag"] for r in rows]
|
||||
|
||||
|
||||
async def get_all_pos() -> List[str]:
|
||||
"""Get all unique parts of speech."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT DISTINCT part_of_speech FROM vocabulary_words WHERE part_of_speech != '' ORDER BY part_of_speech"
|
||||
)
|
||||
return [r["part_of_speech"] for r in rows]
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
37
backend-lehrer/worksheets/__init__.py
Normal file
37
backend-lehrer/worksheets/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Worksheets Module
|
||||
# worksheets/api.py — API router (generate MC, cloze, mindmap, quiz)
|
||||
# worksheets/models.py — Pydantic models and helpers
|
||||
|
||||
from .api import router
|
||||
from .models import (
|
||||
ContentType,
|
||||
GenerateRequest,
|
||||
MCGenerateRequest,
|
||||
ClozeGenerateRequest,
|
||||
MindmapGenerateRequest,
|
||||
QuizGenerateRequest,
|
||||
BatchGenerateRequest,
|
||||
WorksheetContent,
|
||||
GenerateResponse,
|
||||
BatchGenerateResponse,
|
||||
parse_difficulty,
|
||||
parse_cloze_type,
|
||||
parse_quiz_types,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"ContentType",
|
||||
"GenerateRequest",
|
||||
"MCGenerateRequest",
|
||||
"ClozeGenerateRequest",
|
||||
"MindmapGenerateRequest",
|
||||
"QuizGenerateRequest",
|
||||
"BatchGenerateRequest",
|
||||
"WorksheetContent",
|
||||
"GenerateResponse",
|
||||
"BatchGenerateResponse",
|
||||
"parse_difficulty",
|
||||
"parse_cloze_type",
|
||||
"parse_quiz_types",
|
||||
]
|
||||
439
backend-lehrer/worksheets/api.py
Normal file
439
backend-lehrer/worksheets/api.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Worksheets API - REST API für Arbeitsblatt-Generierung.
|
||||
|
||||
Integriert alle Content-Generatoren:
|
||||
- Multiple Choice Questions
|
||||
- Lückentexte (Cloze)
|
||||
- Mindmaps
|
||||
- Quizze (True/False, Matching, Sorting, Open)
|
||||
|
||||
Unterstützt:
|
||||
- H5P-Export für interaktive Inhalte
|
||||
- PDF-Export für Druckversionen
|
||||
- JSON-Export für Frontend-Integration
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from generators import (
|
||||
MultipleChoiceGenerator,
|
||||
ClozeGenerator,
|
||||
MindmapGenerator,
|
||||
QuizGenerator
|
||||
)
|
||||
|
||||
from .models import (
|
||||
ContentType,
|
||||
GenerateRequest,
|
||||
MCGenerateRequest,
|
||||
ClozeGenerateRequest,
|
||||
MindmapGenerateRequest,
|
||||
QuizGenerateRequest,
|
||||
BatchGenerateRequest,
|
||||
WorksheetContent,
|
||||
GenerateResponse,
|
||||
BatchGenerateResponse,
|
||||
parse_difficulty,
|
||||
parse_cloze_type,
|
||||
parse_quiz_types,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/worksheets",
|
||||
tags=["worksheets"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# In-Memory Storage (später durch DB ersetzen)
|
||||
# ============================================================================
|
||||
|
||||
_generated_content: Dict[str, WorksheetContent] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Generator Instances
|
||||
# ============================================================================
|
||||
|
||||
mc_generator = MultipleChoiceGenerator()
|
||||
cloze_generator = ClozeGenerator()
|
||||
mindmap_generator = MindmapGenerator()
|
||||
quiz_generator = QuizGenerator()
|
||||
|
||||
|
||||
def _store_content(content: WorksheetContent) -> None:
|
||||
"""Speichert generierten Content."""
|
||||
_generated_content[content.id] = content
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/generate/multiple-choice", response_model=GenerateResponse)
|
||||
async def generate_multiple_choice(request: MCGenerateRequest):
|
||||
"""Generiert Multiple-Choice-Fragen aus Quelltext."""
|
||||
try:
|
||||
difficulty = parse_difficulty(request.difficulty)
|
||||
|
||||
questions = mc_generator.generate(
|
||||
source_text=request.source_text,
|
||||
num_questions=request.num_questions,
|
||||
difficulty=difficulty,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level
|
||||
)
|
||||
|
||||
if not questions:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error="Keine Fragen generiert. Text möglicherweise zu kurz."
|
||||
)
|
||||
|
||||
questions_dict = mc_generator.to_dict(questions)
|
||||
h5p_format = mc_generator.to_h5p_format(questions)
|
||||
|
||||
content = WorksheetContent(
|
||||
id=str(uuid.uuid4()),
|
||||
content_type=ContentType.MULTIPLE_CHOICE.value,
|
||||
data={"questions": questions_dict},
|
||||
h5p_format=h5p_format,
|
||||
created_at=datetime.utcnow(),
|
||||
topic=request.topic,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
|
||||
_store_content(content)
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating MC questions: {e}")
|
||||
return GenerateResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/cloze", response_model=GenerateResponse)
|
||||
async def generate_cloze(request: ClozeGenerateRequest):
|
||||
"""Generiert Lückentext aus Quelltext."""
|
||||
try:
|
||||
cloze_type = parse_cloze_type(request.cloze_type)
|
||||
|
||||
cloze = cloze_generator.generate(
|
||||
source_text=request.source_text,
|
||||
num_gaps=request.num_gaps,
|
||||
difficulty=request.difficulty,
|
||||
cloze_type=cloze_type,
|
||||
topic=request.topic
|
||||
)
|
||||
|
||||
if not cloze.gaps:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error="Keine Lücken generiert. Text möglicherweise zu kurz."
|
||||
)
|
||||
|
||||
cloze_dict = cloze_generator.to_dict(cloze)
|
||||
h5p_format = cloze_generator.to_h5p_format(cloze)
|
||||
|
||||
content = WorksheetContent(
|
||||
id=str(uuid.uuid4()),
|
||||
content_type=ContentType.CLOZE.value,
|
||||
data=cloze_dict,
|
||||
h5p_format=h5p_format,
|
||||
created_at=datetime.utcnow(),
|
||||
topic=request.topic,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
|
||||
_store_content(content)
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating cloze: {e}")
|
||||
return GenerateResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/mindmap", response_model=GenerateResponse)
|
||||
async def generate_mindmap(request: MindmapGenerateRequest):
|
||||
"""Generiert Mindmap aus Quelltext."""
|
||||
try:
|
||||
mindmap = mindmap_generator.generate(
|
||||
source_text=request.source_text,
|
||||
title=request.topic,
|
||||
max_depth=request.max_depth,
|
||||
topic=request.topic
|
||||
)
|
||||
|
||||
if mindmap.total_nodes <= 1:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error="Mindmap konnte nicht generiert werden. Text möglicherweise zu kurz."
|
||||
)
|
||||
|
||||
mindmap_dict = mindmap_generator.to_dict(mindmap)
|
||||
mermaid = mindmap_generator.to_mermaid(mindmap)
|
||||
json_tree = mindmap_generator.to_json_tree(mindmap)
|
||||
|
||||
content = WorksheetContent(
|
||||
id=str(uuid.uuid4()),
|
||||
content_type=ContentType.MINDMAP.value,
|
||||
data={
|
||||
"mindmap": mindmap_dict,
|
||||
"mermaid": mermaid,
|
||||
"json_tree": json_tree
|
||||
},
|
||||
h5p_format=None,
|
||||
created_at=datetime.utcnow(),
|
||||
topic=request.topic,
|
||||
difficulty=None
|
||||
)
|
||||
|
||||
_store_content(content)
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating mindmap: {e}")
|
||||
return GenerateResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/quiz", response_model=GenerateResponse)
|
||||
async def generate_quiz(request: QuizGenerateRequest):
|
||||
"""Generiert Quiz mit verschiedenen Fragetypen."""
|
||||
try:
|
||||
quiz_types = parse_quiz_types(request.quiz_types)
|
||||
|
||||
all_questions = []
|
||||
quizzes = []
|
||||
|
||||
for quiz_type in quiz_types:
|
||||
quiz = quiz_generator.generate(
|
||||
source_text=request.source_text,
|
||||
quiz_type=quiz_type,
|
||||
num_questions=request.num_items,
|
||||
difficulty=request.difficulty,
|
||||
topic=request.topic
|
||||
)
|
||||
quizzes.append(quiz)
|
||||
all_questions.extend(quiz.questions)
|
||||
|
||||
if len(all_questions) == 0:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error="Quiz konnte nicht generiert werden. Text möglicherweise zu kurz."
|
||||
)
|
||||
|
||||
combined_quiz_dict = {
|
||||
"quiz_types": [qt.value for qt in quiz_types],
|
||||
"title": f"Combined Quiz - {request.topic or 'Various Topics'}",
|
||||
"topic": request.topic,
|
||||
"difficulty": request.difficulty,
|
||||
"questions": []
|
||||
}
|
||||
|
||||
for quiz in quizzes:
|
||||
quiz_dict = quiz_generator.to_dict(quiz)
|
||||
combined_quiz_dict["questions"].extend(quiz_dict.get("questions", []))
|
||||
|
||||
h5p_format = quiz_generator.to_h5p_format(quizzes[0]) if quizzes else {}
|
||||
|
||||
content = WorksheetContent(
|
||||
id=str(uuid.uuid4()),
|
||||
content_type=ContentType.QUIZ.value,
|
||||
data=combined_quiz_dict,
|
||||
h5p_format=h5p_format,
|
||||
created_at=datetime.utcnow(),
|
||||
topic=request.topic,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
|
||||
_store_content(content)
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating quiz: {e}")
|
||||
return GenerateResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/batch", response_model=BatchGenerateResponse)
|
||||
async def generate_batch(request: BatchGenerateRequest):
|
||||
"""Generiert mehrere Content-Typen aus einem Quelltext."""
|
||||
contents = []
|
||||
errors = []
|
||||
|
||||
for content_type in request.content_types:
|
||||
try:
|
||||
if content_type == "multiple_choice":
|
||||
mc_req = MCGenerateRequest(
|
||||
source_text=request.source_text,
|
||||
topic=request.topic,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
result = await generate_multiple_choice(mc_req)
|
||||
|
||||
elif content_type == "cloze":
|
||||
cloze_req = ClozeGenerateRequest(
|
||||
source_text=request.source_text,
|
||||
topic=request.topic,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
result = await generate_cloze(cloze_req)
|
||||
|
||||
elif content_type == "mindmap":
|
||||
mindmap_req = MindmapGenerateRequest(
|
||||
source_text=request.source_text,
|
||||
topic=request.topic,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level
|
||||
)
|
||||
result = await generate_mindmap(mindmap_req)
|
||||
|
||||
elif content_type == "quiz":
|
||||
quiz_req = QuizGenerateRequest(
|
||||
source_text=request.source_text,
|
||||
topic=request.topic,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
result = await generate_quiz(quiz_req)
|
||||
|
||||
else:
|
||||
errors.append(f"Unbekannter Content-Typ: {content_type}")
|
||||
continue
|
||||
|
||||
if result.success and result.content:
|
||||
contents.append(result.content)
|
||||
elif result.error:
|
||||
errors.append(f"{content_type}: {result.error}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{content_type}: {str(e)}")
|
||||
|
||||
return BatchGenerateResponse(
|
||||
success=len(contents) > 0,
|
||||
contents=contents,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.get("/content/{content_id}", response_model=GenerateResponse)
|
||||
async def get_content(content_id: str):
|
||||
"""Ruft gespeicherten Content ab."""
|
||||
content = _generated_content.get(content_id)
|
||||
|
||||
if not content:
|
||||
raise HTTPException(status_code=404, detail="Content nicht gefunden")
|
||||
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
|
||||
@router.get("/content/{content_id}/h5p")
|
||||
async def get_content_h5p(content_id: str):
|
||||
"""Gibt H5P-Format für Content zurück."""
|
||||
content = _generated_content.get(content_id)
|
||||
|
||||
if not content:
|
||||
raise HTTPException(status_code=404, detail="Content nicht gefunden")
|
||||
|
||||
if not content.h5p_format:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="H5P-Format für diesen Content-Typ nicht verfügbar"
|
||||
)
|
||||
|
||||
return content.h5p_format
|
||||
|
||||
|
||||
@router.delete("/content/{content_id}")
|
||||
async def delete_content(content_id: str):
|
||||
"""Löscht gespeicherten Content."""
|
||||
if content_id not in _generated_content:
|
||||
raise HTTPException(status_code=404, detail="Content nicht gefunden")
|
||||
|
||||
del _generated_content[content_id]
|
||||
return {"status": "deleted", "id": content_id}
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
async def list_content_types():
|
||||
"""Listet verfügbare Content-Typen und deren Optionen."""
|
||||
return {
|
||||
"content_types": [
|
||||
{
|
||||
"type": "multiple_choice",
|
||||
"name": "Multiple Choice",
|
||||
"description": "Fragen mit 4 Antwortmöglichkeiten",
|
||||
"options": {
|
||||
"num_questions": {"min": 1, "max": 20, "default": 5},
|
||||
"difficulty": ["easy", "medium", "hard"]
|
||||
},
|
||||
"h5p_supported": True
|
||||
},
|
||||
{
|
||||
"type": "cloze",
|
||||
"name": "Lückentext",
|
||||
"description": "Text mit ausgeblendeten Schlüsselwörtern",
|
||||
"options": {
|
||||
"num_gaps": {"min": 1, "max": 15, "default": 5},
|
||||
"difficulty": ["easy", "medium", "hard"],
|
||||
"cloze_type": ["fill_in", "drag_drop", "dropdown"]
|
||||
},
|
||||
"h5p_supported": True
|
||||
},
|
||||
{
|
||||
"type": "mindmap",
|
||||
"name": "Mindmap",
|
||||
"description": "Hierarchische Struktur aus Hauptthema und Unterthemen",
|
||||
"options": {
|
||||
"max_depth": {"min": 2, "max": 5, "default": 3}
|
||||
},
|
||||
"h5p_supported": False,
|
||||
"export_formats": ["mermaid", "json_tree"]
|
||||
},
|
||||
{
|
||||
"type": "quiz",
|
||||
"name": "Quiz",
|
||||
"description": "Verschiedene Fragetypen kombiniert",
|
||||
"options": {
|
||||
"quiz_types": ["true_false", "matching", "sorting", "open_ended"],
|
||||
"num_items": {"min": 1, "max": 10, "default": 5},
|
||||
"difficulty": ["easy", "medium", "hard"]
|
||||
},
|
||||
"h5p_supported": True
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_generation_history(limit: int = 10):
|
||||
"""Gibt die letzten generierten Contents zurück."""
|
||||
sorted_contents = sorted(
|
||||
_generated_content.values(),
|
||||
key=lambda x: x.created_at,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return {
|
||||
"total": len(_generated_content),
|
||||
"contents": [
|
||||
{
|
||||
"id": c.id,
|
||||
"content_type": c.content_type,
|
||||
"topic": c.topic,
|
||||
"difficulty": c.difficulty,
|
||||
"created_at": c.created_at.isoformat()
|
||||
}
|
||||
for c in sorted_contents[:limit]
|
||||
]
|
||||
}
|
||||
135
backend-lehrer/worksheets/models.py
Normal file
135
backend-lehrer/worksheets/models.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Worksheets API - Pydantic Models und Helpers.
|
||||
|
||||
Request-/Response-Models und Hilfsfunktionen fuer die
|
||||
Arbeitsblatt-Generierungs-API.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from generators.mc_generator import Difficulty
|
||||
from generators.cloze_generator import ClozeType
|
||||
from generators.quiz_generator import QuizType
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models
|
||||
# ============================================================================
|
||||
|
||||
class ContentType(str, Enum):
|
||||
"""Verfügbare Content-Typen."""
|
||||
MULTIPLE_CHOICE = "multiple_choice"
|
||||
CLOZE = "cloze"
|
||||
MINDMAP = "mindmap"
|
||||
QUIZ = "quiz"
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
"""Basis-Request für Generierung."""
|
||||
source_text: str = Field(..., min_length=50, description="Quelltext für Generierung")
|
||||
topic: Optional[str] = Field(None, description="Thema/Titel")
|
||||
subject: Optional[str] = Field(None, description="Fach")
|
||||
grade_level: Optional[str] = Field(None, description="Klassenstufe")
|
||||
|
||||
|
||||
class MCGenerateRequest(GenerateRequest):
|
||||
"""Request für Multiple-Choice-Generierung."""
|
||||
num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen")
|
||||
difficulty: str = Field("medium", description="easy, medium, hard")
|
||||
|
||||
|
||||
class ClozeGenerateRequest(GenerateRequest):
|
||||
"""Request für Lückentext-Generierung."""
|
||||
num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken")
|
||||
difficulty: str = Field("medium", description="easy, medium, hard")
|
||||
cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown")
|
||||
|
||||
|
||||
class MindmapGenerateRequest(GenerateRequest):
|
||||
"""Request für Mindmap-Generierung."""
|
||||
max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe")
|
||||
|
||||
|
||||
class QuizGenerateRequest(GenerateRequest):
|
||||
"""Request für Quiz-Generierung."""
|
||||
quiz_types: List[str] = Field(
|
||||
["true_false", "matching"],
|
||||
description="Typen: true_false, matching, sorting, open_ended"
|
||||
)
|
||||
num_items: int = Field(5, ge=1, le=10, description="Items pro Typ")
|
||||
difficulty: str = Field("medium", description="easy, medium, hard")
|
||||
|
||||
|
||||
class BatchGenerateRequest(BaseModel):
|
||||
"""Request für Batch-Generierung mehrerer Content-Typen."""
|
||||
source_text: str = Field(..., min_length=50)
|
||||
content_types: List[str] = Field(..., description="Liste von Content-Typen")
|
||||
topic: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
grade_level: Optional[str] = None
|
||||
difficulty: str = "medium"
|
||||
|
||||
|
||||
class WorksheetContent(BaseModel):
|
||||
"""Generierter Content."""
|
||||
id: str
|
||||
content_type: str
|
||||
data: Dict[str, Any]
|
||||
h5p_format: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
topic: Optional[str] = None
|
||||
difficulty: Optional[str] = None
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
"""Response mit generiertem Content."""
|
||||
success: bool
|
||||
content: Optional[WorksheetContent] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class BatchGenerateResponse(BaseModel):
|
||||
"""Response für Batch-Generierung."""
|
||||
success: bool
|
||||
contents: List[WorksheetContent] = []
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def parse_difficulty(difficulty_str: str) -> Difficulty:
|
||||
"""Konvertiert String zu Difficulty Enum."""
|
||||
mapping = {
|
||||
"easy": Difficulty.EASY,
|
||||
"medium": Difficulty.MEDIUM,
|
||||
"hard": Difficulty.HARD
|
||||
}
|
||||
return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM)
|
||||
|
||||
|
||||
def parse_cloze_type(type_str: str) -> ClozeType:
|
||||
"""Konvertiert String zu ClozeType Enum."""
|
||||
mapping = {
|
||||
"fill_in": ClozeType.FILL_IN,
|
||||
"drag_drop": ClozeType.DRAG_DROP,
|
||||
"dropdown": ClozeType.DROPDOWN
|
||||
}
|
||||
return mapping.get(type_str.lower(), ClozeType.FILL_IN)
|
||||
|
||||
|
||||
def parse_quiz_types(type_strs: List[str]) -> List[QuizType]:
|
||||
"""Konvertiert String-Liste zu QuizType Enums."""
|
||||
mapping = {
|
||||
"true_false": QuizType.TRUE_FALSE,
|
||||
"matching": QuizType.MATCHING,
|
||||
"sorting": QuizType.SORTING,
|
||||
"open_ended": QuizType.OPEN_ENDED
|
||||
}
|
||||
return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs]
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user