[split-required] Split final batch of monoliths >1000 LOC

Python (6 files in klausur-service):
- rbac.py (1,132 → 4), admin_api.py (1,012 → 4)
- routes/eh.py (1,111 → 4), ocr_pipeline_geometry.py (1,105 → 5)

Python (2 files in backend-lehrer):
- unit_api.py (1,226 → 6), game_api.py (1,129 → 5)

Website (6 page files):
- 4x klausur-korrektur pages (1,249-1,328 LOC each) → shared components
  in website/components/klausur-korrektur/ (17 shared files)
- companion (1,057 → 10), magic-help (1,017 → 8)

All re-export barrels preserve backward compatibility.
Zero import errors verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 23:17:30 +02:00
parent b2a0126f14
commit 6811264756
67 changed files with 12270 additions and 13651 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
# ==============================================
# Breakpilot Drive - Game Extended Routes
# ==============================================
# Phase 5 features: achievements, progress, parent dashboard,
# class leaderboard, and display leaderboard.
# Extracted from game_api.py for file-size compliance.
from fastapi import APIRouter, HTTPException, Query, Depends, Request
from typing import List, Optional, Dict, Any
import logging
from game_routes import (
get_optional_current_user,
get_user_id_from_auth,
get_game_database,
REQUIRE_AUTH,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"])
# ==============================================
# Phase 5: Erweiterte Features
# ==============================================
@router.get("/achievements/{user_id}")
async def get_achievements(
user_id: str,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> dict:
"""
Gibt Achievements mit Fortschritt fuer einen Benutzer zurueck.
Achievements werden basierend auf Spielstatistiken berechnet.
"""
# Verify access rights
user_id = get_user_id_from_auth(user, user_id)
db = await get_game_database()
if not db:
return {"achievements": [], "message": "Database not available"}
try:
achievements = await db.get_student_achievements(user_id)
unlocked = [a for a in achievements if a.unlocked]
locked = [a for a in achievements if not a.unlocked]
return {
"user_id": user_id,
"total": len(achievements),
"unlocked_count": len(unlocked),
"achievements": [
{
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"category": a.category,
"threshold": a.threshold,
"progress": a.progress,
"unlocked": a.unlocked,
}
for a in achievements
]
}
except Exception as e:
logger.error(f"Failed to get achievements: {e}")
return {"achievements": [], "message": str(e)}
@router.get("/progress/{user_id}")
async def get_progress(
user_id: str,
days: int = Query(30, ge=7, le=90, description="Anzahl Tage zurueck"),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> dict:
"""
Gibt Lernfortschritt ueber Zeit zurueck (fuer Charts).
- Taegliche Statistiken
- Fuer Eltern-Dashboard und Fortschrittsanzeige
"""
# Verify access rights
user_id = get_user_id_from_auth(user, user_id)
db = await get_game_database()
if not db:
return {"progress": [], "message": "Database not available"}
try:
progress = await db.get_progress_over_time(user_id, days)
return {
"user_id": user_id,
"days": days,
"data_points": len(progress),
"progress": progress,
}
except Exception as e:
logger.error(f"Failed to get progress: {e}")
return {"progress": [], "message": str(e)}
@router.get("/parent/children")
async def get_children_dashboard(
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> dict:
"""
Eltern-Dashboard: Statistiken fuer alle Kinder.
Erfordert Auth mit Eltern-Rolle und children_ids Claim.
"""
if not REQUIRE_AUTH or user is None:
return {
"message": "Auth required for parent dashboard",
"children": []
}
# Get children IDs from token
children_ids = user.get("raw_claims", {}).get("children_ids", [])
if not children_ids:
return {
"message": "No children associated with this account",
"children": []
}
db = await get_game_database()
if not db:
return {"children": [], "message": "Database not available"}
try:
children_stats = await db.get_children_stats(children_ids)
return {
"parent_id": user.get("user_id"),
"children_count": len(children_ids),
"children": children_stats,
}
except Exception as e:
logger.error(f"Failed to get children stats: {e}")
return {"children": [], "message": str(e)}
@router.get("/leaderboard/class/{class_id}")
async def get_class_leaderboard(
class_id: str,
timeframe: str = Query("week", description="day, week, month, all"),
limit: int = Query(10, ge=1, le=50),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> List[dict]:
"""
Klassenspezifische Rangliste.
Nur fuer Lehrer oder Schueler der Klasse sichtbar.
"""
db = await get_game_database()
if not db:
return []
try:
leaderboard = await db.get_class_leaderboard(class_id, timeframe, limit)
return leaderboard
except Exception as e:
logger.error(f"Failed to get class leaderboard: {e}")
return []
@router.get("/leaderboard/display")
async def get_display_leaderboard(
timeframe: str = Query("day", description="day, week, month, all"),
limit: int = Query(10, ge=1, le=100),
anonymize: bool = Query(True, description="Namen anonymisieren")
) -> List[dict]:
"""
Oeffentliche Rangliste mit Anzeigenamen.
Standardmaessig anonymisiert fuer Datenschutz.
"""
db = await get_game_database()
if not db:
return []
try:
return await db.get_leaderboard_with_names(timeframe, limit, anonymize)
except Exception as e:
logger.error(f"Failed to get display leaderboard: {e}")
return []

View File

@@ -0,0 +1,322 @@
# ==============================================
# Breakpilot Drive - Game API Models & Data
# ==============================================
# Pydantic models, difficulty mappings, and sample questions.
# Extracted from game_api.py for file-size compliance.
from pydantic import BaseModel
from typing import List, Optional, Literal, Dict, Any
from datetime import datetime
# ==============================================
# Pydantic Models
# ==============================================
class LearningLevel(BaseModel):
"""Lernniveau eines Benutzers aus dem Breakpilot-System"""
user_id: str
overall_level: int # 1-5 (1=Anfaenger/Klasse 2, 5=Fortgeschritten/Klasse 6)
math_level: float
german_level: float
english_level: float
last_updated: datetime
class GameDifficulty(BaseModel):
"""Spielschwierigkeit basierend auf Lernniveau"""
lane_speed: float # Geschwindigkeit in m/s
obstacle_frequency: float # Hindernisse pro Sekunde
power_up_chance: float # Wahrscheinlichkeit fuer Power-Ups (0-1)
question_complexity: int # 1-5
answer_time: int # Sekunden zum Antworten
hints_enabled: bool
speech_speed: float # Sprechgeschwindigkeit fuer Audio-Version
class QuizQuestion(BaseModel):
"""Quiz-Frage fuer das Spiel"""
id: str
question_text: str
audio_url: Optional[str] = None
options: List[str] # 2-4 Antwortmoeglichkeiten
correct_index: int # 0-3
difficulty: int # 1-5
subject: Literal["math", "german", "english", "general"]
grade_level: Optional[int] = None # 2-6
# NEU: Quiz-Modus
quiz_mode: Literal["quick", "pause"] = "quick" # quick=waehrend Fahrt, pause=Spiel haelt an
visual_trigger: Optional[str] = None # z.B. "bridge", "house", "tree" - loest Frage aus
time_limit_seconds: Optional[float] = None # Zeit bis Antwort noetig (bei quick)
class QuizAnswer(BaseModel):
"""Antwort auf eine Quiz-Frage"""
question_id: str
selected_index: int
answer_time_ms: int # Zeit bis zur Antwort in ms
was_correct: bool
class GameSession(BaseModel):
"""Spielsession-Daten fuer Analytics"""
user_id: str
game_mode: Literal["video", "audio"]
duration_seconds: int
distance_traveled: float
score: int
questions_answered: int
questions_correct: int
difficulty_level: int
quiz_answers: Optional[List[QuizAnswer]] = None
class SessionResponse(BaseModel):
"""Antwort nach Session-Speicherung"""
session_id: str
status: str
new_level: Optional[int] = None # Falls Lernniveau angepasst wurde
# ==============================================
# Schwierigkeits-Mapping
# ==============================================
DIFFICULTY_MAPPING = {
1: GameDifficulty(
lane_speed=3.0,
obstacle_frequency=0.3,
power_up_chance=0.4,
question_complexity=1,
answer_time=15,
hints_enabled=True,
speech_speed=0.8
),
2: GameDifficulty(
lane_speed=4.0,
obstacle_frequency=0.4,
power_up_chance=0.35,
question_complexity=2,
answer_time=12,
hints_enabled=True,
speech_speed=0.9
),
3: GameDifficulty(
lane_speed=5.0,
obstacle_frequency=0.5,
power_up_chance=0.3,
question_complexity=3,
answer_time=10,
hints_enabled=True,
speech_speed=1.0
),
4: GameDifficulty(
lane_speed=6.0,
obstacle_frequency=0.6,
power_up_chance=0.25,
question_complexity=4,
answer_time=8,
hints_enabled=False,
speech_speed=1.1
),
5: GameDifficulty(
lane_speed=7.0,
obstacle_frequency=0.7,
power_up_chance=0.2,
question_complexity=5,
answer_time=6,
hints_enabled=False,
speech_speed=1.2
),
}
# ==============================================
# Beispiel Quiz-Fragen (spaeter aus DB laden)
# ==============================================
SAMPLE_QUESTIONS = [
# ==============================================
# QUICK QUESTIONS (waehrend der Fahrt, visuell getriggert)
# ==============================================
# Englisch Vokabeln - Objekte im Spiel (QUICK MODE)
QuizQuestion(
id="vq-bridge", question_text="What is this?",
options=["Bridge", "House"], correct_index=0,
difficulty=1, subject="english", grade_level=3,
quiz_mode="quick", visual_trigger="bridge", time_limit_seconds=3.0
),
QuizQuestion(
id="vq-tree", question_text="What is this?",
options=["Tree", "Flower"], correct_index=0,
difficulty=1, subject="english", grade_level=3,
quiz_mode="quick", visual_trigger="tree", time_limit_seconds=3.0
),
QuizQuestion(
id="vq-house", question_text="What is this?",
options=["House", "Car"], correct_index=0,
difficulty=1, subject="english", grade_level=3,
quiz_mode="quick", visual_trigger="house", time_limit_seconds=3.0
),
QuizQuestion(
id="vq-car", question_text="What is this?",
options=["Car", "Bus"], correct_index=0,
difficulty=1, subject="english", grade_level=3,
quiz_mode="quick", visual_trigger="car", time_limit_seconds=2.5
),
QuizQuestion(
id="vq-mountain", question_text="What is this?",
options=["Hill", "Mountain", "Valley"], correct_index=1,
difficulty=2, subject="english", grade_level=4,
quiz_mode="quick", visual_trigger="mountain", time_limit_seconds=3.5
),
QuizQuestion(
id="vq-river", question_text="What is this?",
options=["Lake", "River", "Sea"], correct_index=1,
difficulty=2, subject="english", grade_level=4,
quiz_mode="quick", visual_trigger="river", time_limit_seconds=3.5
),
# Schnelle Rechenaufgaben (QUICK MODE)
QuizQuestion(
id="mq-1", question_text="3 + 4 = ?",
options=["6", "7"], correct_index=1,
difficulty=1, subject="math", grade_level=2,
quiz_mode="quick", time_limit_seconds=4.0
),
QuizQuestion(
id="mq-2", question_text="5 x 2 = ?",
options=["10", "12"], correct_index=0,
difficulty=1, subject="math", grade_level=2,
quiz_mode="quick", time_limit_seconds=4.0
),
QuizQuestion(
id="mq-3", question_text="8 - 3 = ?",
options=["4", "5"], correct_index=1,
difficulty=1, subject="math", grade_level=2,
quiz_mode="quick", time_limit_seconds=3.5
),
QuizQuestion(
id="mq-4", question_text="6 x 7 = ?",
options=["42", "48"], correct_index=0,
difficulty=2, subject="math", grade_level=3,
quiz_mode="quick", time_limit_seconds=5.0
),
QuizQuestion(
id="mq-5", question_text="9 x 8 = ?",
options=["72", "64"], correct_index=0,
difficulty=3, subject="math", grade_level=4,
quiz_mode="quick", time_limit_seconds=5.0
),
# ==============================================
# PAUSE QUESTIONS (Spiel haelt an, mehr Zeit)
# ==============================================
# Mathe Level 1-2 (Klasse 2-3) - PAUSE MODE
QuizQuestion(
id="mp1-1", question_text="Anna hat 5 Aepfel. Sie bekommt 3 dazu. Wie viele hat sie jetzt?",
options=["6", "7", "8", "9"], correct_index=2,
difficulty=1, subject="math", grade_level=2,
quiz_mode="pause"
),
QuizQuestion(
id="mp2-1", question_text="Ein Bus hat 24 Sitze. 18 sind besetzt. Wie viele sind frei?",
options=["4", "5", "6", "7"], correct_index=2,
difficulty=2, subject="math", grade_level=3,
quiz_mode="pause"
),
QuizQuestion(
id="mp2-2", question_text="Was ist 45 + 27?",
options=["72", "62", "82", "70"], correct_index=0,
difficulty=2, subject="math", grade_level=3,
quiz_mode="pause"
),
# Mathe Level 3-4 (Klasse 4-5) - PAUSE MODE
QuizQuestion(
id="mp3-1", question_text="Was ist 7 x 8?",
options=["54", "56", "58", "48"], correct_index=1,
difficulty=3, subject="math", grade_level=4,
quiz_mode="pause"
),
QuizQuestion(
id="mp3-2", question_text="Ein Rechteck ist 8m lang und 5m breit. Wie gross ist die Flaeche?",
options=["35 m2", "40 m2", "45 m2", "26 m2"], correct_index=1,
difficulty=3, subject="math", grade_level=4,
quiz_mode="pause"
),
QuizQuestion(
id="mp4-1", question_text="Was ist 15% von 80?",
options=["10", "12", "8", "15"], correct_index=1,
difficulty=4, subject="math", grade_level=5,
quiz_mode="pause"
),
QuizQuestion(
id="mp4-2", question_text="Was ist 3/4 + 1/2?",
options=["5/4", "4/6", "1", "5/6"], correct_index=0,
difficulty=4, subject="math", grade_level=5,
quiz_mode="pause"
),
# Mathe Level 5 (Klasse 6) - PAUSE MODE
QuizQuestion(
id="mp5-1", question_text="Was ist (-5) x (-3)?",
options=["-15", "15", "-8", "8"], correct_index=1,
difficulty=5, subject="math", grade_level=6,
quiz_mode="pause"
),
QuizQuestion(
id="mp5-2", question_text="Loesung von 2x + 5 = 11?",
options=["2", "3", "4", "6"], correct_index=1,
difficulty=5, subject="math", grade_level=6,
quiz_mode="pause"
),
# Deutsch - PAUSE MODE (brauchen Lesezeit)
QuizQuestion(
id="dp1-1", question_text="Welches Wort ist ein Nomen?",
options=["laufen", "schnell", "Hund", "und"], correct_index=2,
difficulty=1, subject="german", grade_level=2,
quiz_mode="pause"
),
QuizQuestion(
id="dp2-1", question_text="Was ist die Mehrzahl von 'Haus'?",
options=["Haeuse", "Haeuser", "Hausern", "Haus"], correct_index=1,
difficulty=2, subject="german", grade_level=3,
quiz_mode="pause"
),
QuizQuestion(
id="dp3-1", question_text="Welches Verb steht im Praeteritum?",
options=["geht", "ging", "gegangen", "gehen"], correct_index=1,
difficulty=3, subject="german", grade_level=4,
quiz_mode="pause"
),
QuizQuestion(
id="dp3-2", question_text="Finde den Rechtschreibfehler: 'Der Hund leuft schnell.'",
options=["Hund", "leuft", "schnell", "Der"], correct_index=1,
difficulty=3, subject="german", grade_level=4,
quiz_mode="pause"
),
# Englisch Saetze - PAUSE MODE
QuizQuestion(
id="ep3-1", question_text="How do you say 'Schmetterling'?",
options=["bird", "bee", "butterfly", "beetle"], correct_index=2,
difficulty=3, subject="english", grade_level=4,
quiz_mode="pause"
),
QuizQuestion(
id="ep4-1", question_text="Choose the correct form: She ___ to school.",
options=["go", "goes", "going", "gone"], correct_index=1,
difficulty=4, subject="english", grade_level=5,
quiz_mode="pause"
),
QuizQuestion(
id="ep4-2", question_text="What is the past tense of 'run'?",
options=["runned", "ran", "runed", "running"], correct_index=1,
difficulty=4, subject="english", grade_level=5,
quiz_mode="pause"
),
]

View File

@@ -0,0 +1,296 @@
# ==============================================
# Breakpilot Drive - Game API Core Routes
# ==============================================
# Core game endpoints: learning level, difficulty, quiz questions.
# Session/stats/leaderboard routes are in game_session_routes.py.
# Extracted from game_api.py for file-size compliance.
from fastapi import APIRouter, HTTPException, Query, Depends, Request
from typing import List, Optional, Dict, Any
from datetime import datetime
import random
import uuid
import os
import logging
from game_models import (
LearningLevel,
GameDifficulty,
QuizQuestion,
QuizAnswer,
GameSession,
SessionResponse,
DIFFICULTY_MAPPING,
SAMPLE_QUESTIONS,
)
logger = logging.getLogger(__name__)
# Feature flags
USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true"
REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true"
router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"])
# ==============================================
# Auth Dependency (Optional)
# ==============================================
async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]:
"""
Optional auth dependency for Game API.
If GAME_REQUIRE_AUTH=true: Requires valid JWT token
If GAME_REQUIRE_AUTH=false: Returns None (anonymous access)
In development mode without auth, returns demo user.
"""
if not REQUIRE_AUTH:
return None
try:
from auth import get_current_user
return await get_current_user(request)
except ImportError:
logger.warning("Auth module not available")
return None
except HTTPException:
raise # Re-raise auth errors
except Exception as e:
logger.error(f"Auth error: {e}")
raise HTTPException(status_code=401, detail="Authentication failed")
def get_user_id_from_auth(
user: Optional[Dict[str, Any]],
requested_user_id: str
) -> str:
"""
Get the effective user ID, respecting auth when enabled.
If auth is enabled and user is authenticated:
- Returns user's own ID if requested_user_id matches
- For parents: allows access to child IDs from token
- For teachers: allows access to student IDs (future)
If auth is disabled: Returns requested_user_id as-is
"""
if not REQUIRE_AUTH or user is None:
return requested_user_id
user_id = user.get("user_id", "")
# Same user - always allowed
if requested_user_id == user_id:
return user_id
# Check for parent accessing child data
children_ids = user.get("raw_claims", {}).get("children_ids", [])
if requested_user_id in children_ids:
return requested_user_id
# Check for teacher accessing student data (future)
realm_roles = user.get("realm_roles", [])
if "lehrer" in realm_roles or "teacher" in realm_roles:
# Teachers can access any student in their class (implement class check later)
return requested_user_id
# Admin bypass
if "admin" in realm_roles:
return requested_user_id
# Not authorized
raise HTTPException(
status_code=403,
detail="Not authorized to access this user's data"
)
# In-Memory Session Storage (Fallback wenn DB nicht verfuegbar)
_sessions: dict[str, GameSession] = {}
_user_levels: dict[str, LearningLevel] = {}
# Database integration
_game_db = None
async def get_game_database():
"""Get game database instance with lazy initialization."""
global _game_db
if not USE_DATABASE:
return None
if _game_db is None:
try:
from game.database import get_game_db
_game_db = await get_game_db()
logger.info("Game database initialized")
except Exception as e:
logger.warning(f"Game database not available, using in-memory: {e}")
return _game_db
# ==============================================
# API Endpunkte
# ==============================================
@router.get("/learning-level/{user_id}", response_model=LearningLevel)
async def get_learning_level(
user_id: str,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> LearningLevel:
"""
Holt das aktuelle Lernniveau eines Benutzers aus Breakpilot.
- Wird beim Spielstart aufgerufen um Schwierigkeit anzupassen
- Gibt Level 1-5 zurueck (1=Anfaenger, 5=Fortgeschritten)
- Cached Werte fuer schnellen Zugriff
- Speichert in PostgreSQL wenn verfuegbar
- Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten
"""
# Verify access rights
user_id = get_user_id_from_auth(user, user_id)
# Try database first
db = await get_game_database()
if db:
state = await db.get_learning_state(user_id)
if state:
return LearningLevel(
user_id=user_id,
overall_level=state.overall_level,
math_level=state.math_level,
german_level=state.german_level,
english_level=state.english_level,
last_updated=state.updated_at or datetime.now()
)
# Create new state in database
new_state = await db.create_or_update_learning_state(
student_id=user_id,
overall_level=3,
math_level=3.0,
german_level=3.0,
english_level=3.0
)
if new_state:
return LearningLevel(
user_id=user_id,
overall_level=new_state.overall_level,
math_level=new_state.math_level,
german_level=new_state.german_level,
english_level=new_state.english_level,
last_updated=new_state.updated_at or datetime.now()
)
# Fallback to in-memory
if user_id in _user_levels:
return _user_levels[user_id]
# Standard-Level fuer neue Benutzer
default_level = LearningLevel(
user_id=user_id,
overall_level=3, # Mittleres Level als Default
math_level=3.0,
german_level=3.0,
english_level=3.0,
last_updated=datetime.now()
)
_user_levels[user_id] = default_level
return default_level
@router.get("/difficulty/{level}", response_model=GameDifficulty)
async def get_game_difficulty(level: int) -> GameDifficulty:
"""
Gibt Spielparameter basierend auf Lernniveau zurueck.
Level 1-5 werden auf Spielgeschwindigkeit, Hindernisfrequenz,
Fragen-Schwierigkeit etc. gemappt.
"""
if level < 1 or level > 5:
raise HTTPException(status_code=400, detail="Level muss zwischen 1 und 5 sein")
return DIFFICULTY_MAPPING[level]
@router.get("/quiz/questions", response_model=List[QuizQuestion])
async def get_quiz_questions(
difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"),
count: int = Query(10, ge=1, le=50, description="Anzahl der Fragen"),
subject: Optional[str] = Query(None, description="Fach: math, german, english, oder None fuer gemischt"),
mode: Optional[str] = Query(None, description="Quiz-Modus: quick (waehrend Fahrt), pause (Spiel pausiert), oder None fuer beide")
) -> List[QuizQuestion]:
"""
Holt Quiz-Fragen fuer das Spiel.
- Filtert nach Schwierigkeitsgrad (+/- 1 Level)
- Optional nach Fach filterbar
- Optional nach Modus: "quick" (visuelle Fragen waehrend Fahrt) oder "pause" (Denkaufgaben)
- Gibt zufaellige Auswahl zurueck
"""
# Fragen nach Schwierigkeit filtern (+/- 1 Level Toleranz)
filtered = [
q for q in SAMPLE_QUESTIONS
if abs(q.difficulty - difficulty) <= 1
and (subject is None or q.subject == subject)
and (mode is None or q.quiz_mode == mode)
]
if not filtered:
# Fallback: Alle Fragen wenn keine passenden gefunden
filtered = [q for q in SAMPLE_QUESTIONS if mode is None or q.quiz_mode == mode]
# Zufaellige Auswahl
selected = random.sample(filtered, min(count, len(filtered)))
return selected
@router.get("/quiz/visual-triggers")
async def get_visual_triggers() -> List[dict]:
"""
Gibt alle verfuegbaren visuellen Trigger zurueck.
Unity verwendet diese Liste um zu wissen, welche Objekte
im Spiel Quiz-Fragen ausloesen koennen.
"""
triggers = {}
for q in SAMPLE_QUESTIONS:
if q.visual_trigger and q.quiz_mode == "quick":
if q.visual_trigger not in triggers:
triggers[q.visual_trigger] = {
"trigger": q.visual_trigger,
"question_count": 0,
"difficulties": set(),
"subjects": set()
}
triggers[q.visual_trigger]["question_count"] += 1
triggers[q.visual_trigger]["difficulties"].add(q.difficulty)
triggers[q.visual_trigger]["subjects"].add(q.subject)
# Sets zu Listen konvertieren fuer JSON
return [
{
"trigger": t["trigger"],
"question_count": t["question_count"],
"difficulties": list(t["difficulties"]),
"subjects": list(t["subjects"])
}
for t in triggers.values()
]
@router.post("/quiz/answer")
async def submit_quiz_answer(answer: QuizAnswer) -> dict:
"""
Verarbeitet eine Quiz-Antwort (fuer Echtzeit-Feedback).
In der finalen Version: Speichert in Session, updated Analytics.
"""
return {
"question_id": answer.question_id,
"was_correct": answer.was_correct,
"points": 500 if answer.was_correct else -100,
"message": "Richtig! Weiter so!" if answer.was_correct else "Nicht ganz, versuch es nochmal!"
}

View File

@@ -0,0 +1,395 @@
# ==============================================
# Breakpilot Drive - Game Session & Stats Routes
# ==============================================
# Session saving, leaderboard, stats, suggestions,
# quiz generation, and health check.
# Extracted from game_routes.py for file-size compliance.
from fastapi import APIRouter, HTTPException, Query, Depends, Request
from typing import List, Optional, Dict, Any
from datetime import datetime
import uuid
import logging
from game_models import (
LearningLevel,
QuizQuestion,
GameSession,
SessionResponse,
SAMPLE_QUESTIONS,
)
logger = logging.getLogger(__name__)
# Import shared state and helpers from game_routes
# (these are the canonical instances)
from game_routes import (
get_optional_current_user,
get_user_id_from_auth,
get_game_database,
get_quiz_questions,
_sessions,
_user_levels,
REQUIRE_AUTH,
)
router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"])
@router.post("/session", response_model=SessionResponse)
async def save_game_session(
session: GameSession,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> SessionResponse:
"""
Speichert eine komplette Spielsession.
- Protokolliert Score, Distanz, Fragen-Performance
- Aktualisiert Lernniveau bei genuegend Daten
- Wird am Ende jedes Spiels aufgerufen
- Speichert in PostgreSQL wenn verfuegbar
- Bei GAME_REQUIRE_AUTH=true: User-ID aus Token
"""
# If auth is enabled, use user_id from token (ignore session.user_id)
effective_user_id = session.user_id
if REQUIRE_AUTH and user:
effective_user_id = user.get("user_id", session.user_id)
session_id = str(uuid.uuid4())
# Lernniveau-Anpassung basierend auf Performance
new_level = None
old_level = 3 # Default
# Try to get current level first
db = await get_game_database()
if db:
state = await db.get_learning_state(effective_user_id)
if state:
old_level = state.overall_level
else:
# Create initial state if not exists
await db.create_or_update_learning_state(effective_user_id)
old_level = 3
elif effective_user_id in _user_levels:
old_level = _user_levels[effective_user_id].overall_level
# Calculate level adjustment
if session.questions_answered >= 5:
accuracy = session.questions_correct / session.questions_answered
# Anpassung: Wenn >80% korrekt und max nicht erreicht -> Level up
if accuracy >= 0.8 and old_level < 5:
new_level = old_level + 1
# Wenn <40% korrekt und min nicht erreicht -> Level down
elif accuracy < 0.4 and old_level > 1:
new_level = old_level - 1
# Save to database
if db:
# Save session
db_session_id = await db.save_game_session(
student_id=effective_user_id,
game_mode=session.game_mode,
duration_seconds=session.duration_seconds,
distance_traveled=session.distance_traveled,
score=session.score,
questions_answered=session.questions_answered,
questions_correct=session.questions_correct,
difficulty_level=session.difficulty_level,
)
if db_session_id:
session_id = db_session_id
# Save individual quiz answers if provided
if session.quiz_answers:
for answer in session.quiz_answers:
await db.save_quiz_answer(
session_id=session_id,
question_id=answer.question_id,
subject="general", # Could be enhanced to track actual subject
difficulty=session.difficulty_level,
is_correct=answer.was_correct,
answer_time_ms=answer.answer_time_ms,
)
# Update learning stats
duration_minutes = session.duration_seconds // 60
await db.update_learning_stats(
student_id=effective_user_id,
duration_minutes=duration_minutes,
questions_answered=session.questions_answered,
questions_correct=session.questions_correct,
new_level=new_level,
)
else:
# Fallback to in-memory
_sessions[session_id] = session
if new_level:
_user_levels[effective_user_id] = LearningLevel(
user_id=effective_user_id,
overall_level=new_level,
math_level=float(new_level),
german_level=float(new_level),
english_level=float(new_level),
last_updated=datetime.now()
)
return SessionResponse(
session_id=session_id,
status="saved",
new_level=new_level
)
@router.get("/sessions/{user_id}")
async def get_user_sessions(
user_id: str,
limit: int = Query(10, ge=1, le=100),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> List[dict]:
"""
Holt die letzten Spielsessions eines Benutzers.
Fuer Statistiken und Fortschrittsanzeige.
Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten.
"""
# Verify access rights
user_id = get_user_id_from_auth(user, user_id)
# Try database first
db = await get_game_database()
if db:
sessions = await db.get_user_sessions(user_id, limit)
if sessions:
return sessions
# Fallback to in-memory
user_sessions = [
{"session_id": sid, **s.model_dump()}
for sid, s in _sessions.items()
if s.user_id == user_id
]
return user_sessions[:limit]
@router.get("/leaderboard")
async def get_leaderboard(
timeframe: str = Query("day", description="day, week, month, all"),
limit: int = Query(10, ge=1, le=100)
) -> List[dict]:
"""
Gibt Highscore-Liste zurueck.
- Sortiert nach Punktzahl
- Optional nach Zeitraum filterbar
"""
# Try database first
db = await get_game_database()
if db:
leaderboard = await db.get_leaderboard(timeframe, limit)
if leaderboard:
return leaderboard
# Fallback to in-memory
# Aggregiere Scores pro User
user_scores: dict[str, int] = {}
for session in _sessions.values():
if session.user_id not in user_scores:
user_scores[session.user_id] = 0
user_scores[session.user_id] += session.score
# Sortieren und limitieren
leaderboard = [
{"rank": i + 1, "user_id": uid, "total_score": score}
for i, (uid, score) in enumerate(
sorted(user_scores.items(), key=lambda x: x[1], reverse=True)[:limit]
)
]
return leaderboard
@router.get("/stats/{user_id}")
async def get_user_stats(
user_id: str,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> dict:
"""
Gibt detaillierte Statistiken fuer einen Benutzer zurueck.
- Gesamtstatistiken
- Fach-spezifische Statistiken
- Lernniveau-Verlauf
- Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten
"""
# Verify access rights
user_id = get_user_id_from_auth(user, user_id)
db = await get_game_database()
if db:
state = await db.get_learning_state(user_id)
subject_stats = await db.get_subject_stats(user_id)
if state:
return {
"user_id": user_id,
"overall_level": state.overall_level,
"math_level": state.math_level,
"german_level": state.german_level,
"english_level": state.english_level,
"total_play_time_minutes": state.total_play_time_minutes,
"total_sessions": state.total_sessions,
"questions_answered": state.questions_answered,
"questions_correct": state.questions_correct,
"accuracy": state.accuracy,
"subjects": subject_stats,
}
# Fallback - return defaults
return {
"user_id": user_id,
"overall_level": 3,
"math_level": 3.0,
"german_level": 3.0,
"english_level": 3.0,
"total_play_time_minutes": 0,
"total_sessions": 0,
"questions_answered": 0,
"questions_correct": 0,
"accuracy": 0.0,
"subjects": {},
}
@router.get("/suggestions/{user_id}")
async def get_learning_suggestions(
user_id: str,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> dict:
"""
Gibt adaptive Lernvorschlaege fuer einen Benutzer zurueck.
Basierend auf aktueller Performance und Lernhistorie.
Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten.
"""
# Verify access rights
user_id = get_user_id_from_auth(user, user_id)
db = await get_game_database()
if not db:
return {"suggestions": [], "message": "Database not available"}
state = await db.get_learning_state(user_id)
if not state:
return {"suggestions": [], "message": "No learning state found"}
try:
from game.learning_rules import (
LearningContext,
get_rule_engine,
)
# Create context from state
context = LearningContext.from_learning_state(state)
# Get suggestions from rule engine
engine = get_rule_engine()
suggestions = engine.evaluate(context)
return {
"user_id": user_id,
"overall_level": state.overall_level,
"suggestions": [
{
"title": s.title,
"description": s.description,
"action": s.action.value,
"priority": s.priority.name.lower(),
"metadata": s.metadata or {},
}
for s in suggestions[:3] # Top 3 suggestions
]
}
except ImportError:
return {"suggestions": [], "message": "Learning rules not available"}
except Exception as e:
logger.warning(f"Failed to get suggestions: {e}")
return {"suggestions": [], "message": str(e)}
@router.get("/quiz/generate")
async def generate_quiz_questions(
difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"),
count: int = Query(5, ge=1, le=20, description="Anzahl der Fragen"),
subject: Optional[str] = Query(None, description="Fach: math, german, english"),
mode: str = Query("quick", description="Quiz-Modus: quick oder pause"),
visual_trigger: Optional[str] = Query(None, description="Visueller Trigger: bridge, tree, house, etc.")
) -> List[dict]:
"""
Generiert Quiz-Fragen dynamisch via LLM.
Fallback auf statische Fragen wenn LLM nicht verfuegbar.
"""
try:
from game.quiz_generator import get_quiz_generator
generator = await get_quiz_generator()
questions = await generator.get_questions(
difficulty=difficulty,
subject=subject or "general",
mode=mode,
count=count,
visual_trigger=visual_trigger
)
if questions:
return [
{
"id": f"gen-{i}",
"question_text": q.question_text,
"options": q.options,
"correct_index": q.correct_index,
"difficulty": q.difficulty,
"subject": q.subject,
"grade_level": q.grade_level,
"quiz_mode": q.quiz_mode,
"visual_trigger": q.visual_trigger,
"time_limit_seconds": q.time_limit_seconds,
}
for i, q in enumerate(questions)
]
except ImportError:
logger.info("Quiz generator not available, using static questions")
except Exception as e:
logger.warning(f"Quiz generation failed: {e}")
# Fallback to static questions
return await get_quiz_questions(difficulty, count, subject, mode)
@router.get("/health")
async def health_check() -> dict:
"""Health-Check fuer das Spiel-Backend."""
db = await get_game_database()
db_status = "connected" if db and db._connected else "disconnected"
# Check LLM availability
llm_status = "disabled"
try:
from game.quiz_generator import get_quiz_generator
generator = await get_quiz_generator()
llm_status = "connected" if generator._llm_available else "disconnected"
except:
pass
return {
"status": "healthy",
"service": "breakpilot-drive",
"database": db_status,
"llm_generator": llm_status,
"auth_required": REQUIRE_AUTH,
"questions_available": len(SAMPLE_QUESTIONS),
"active_sessions": len(_sessions)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
# ==============================================
# Breakpilot Drive - Unit Content Generation Routes
# ==============================================
# API endpoints for H5P content, worksheets, and PDF generation.
# Extracted from unit_api.py for file-size compliance.
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import Optional, Dict, Any
import logging
from unit_models import UnitDefinitionResponse
from unit_helpers import get_optional_current_user, get_unit_database
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"])
@router.get("/content/{unit_id}/h5p")
async def generate_h5p_content(
unit_id: str,
locale: str = Query("de-DE", description="Target locale"),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> Dict[str, Any]:
"""
Generate H5P content items for a unit.
Returns H5P-compatible content structures for:
- Drag and Drop (vocabulary matching)
- Fill in the Blanks (concept texts)
- Multiple Choice (misconception targeting)
"""
from content_generators import generate_h5p_for_unit, H5PGenerator, generate_h5p_manifest
# Get unit definition
db = await get_unit_database()
unit_def = None
if db:
try:
unit = await db.get_unit_definition(unit_id)
if unit:
unit_def = unit.get("definition", {})
except Exception as e:
logger.error(f"Failed to get unit for H5P generation: {e}")
if not unit_def:
raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}")
try:
generator = H5PGenerator(locale=locale)
contents = generator.generate_from_unit(unit_def)
manifest = generate_h5p_manifest(contents, unit_id)
return {
"unit_id": unit_id,
"locale": locale,
"generated_count": len(contents),
"manifest": manifest,
"contents": [c.to_h5p_structure() for c in contents]
}
except Exception as e:
logger.error(f"H5P generation failed: {e}")
raise HTTPException(status_code=500, detail=f"H5P generation failed: {str(e)}")
@router.get("/content/{unit_id}/worksheet")
async def generate_worksheet_html(
unit_id: str,
locale: str = Query("de-DE", description="Target locale"),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> Dict[str, Any]:
"""
Generate worksheet HTML for a unit.
Returns HTML that can be:
- Displayed in browser
- Converted to PDF using weasyprint
- Printed directly
"""
from content_generators import PDFGenerator
# Get unit definition
db = await get_unit_database()
unit_def = None
if db:
try:
unit = await db.get_unit_definition(unit_id)
if unit:
unit_def = unit.get("definition", {})
except Exception as e:
logger.error(f"Failed to get unit for worksheet generation: {e}")
if not unit_def:
raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}")
try:
generator = PDFGenerator(locale=locale)
worksheet = generator.generate_from_unit(unit_def)
return {
"unit_id": unit_id,
"locale": locale,
"title": worksheet.title,
"sections": len(worksheet.sections),
"html": worksheet.to_html()
}
except Exception as e:
logger.error(f"Worksheet generation failed: {e}")
raise HTTPException(status_code=500, detail=f"Worksheet generation failed: {str(e)}")
@router.get("/content/{unit_id}/worksheet.pdf")
async def download_worksheet_pdf(
unit_id: str,
locale: str = Query("de-DE", description="Target locale"),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
):
"""
Generate and download worksheet as PDF.
Requires weasyprint to be installed on the server.
"""
from fastapi.responses import Response
# Get unit definition
db = await get_unit_database()
unit_def = None
if db:
try:
unit = await db.get_unit_definition(unit_id)
if unit:
unit_def = unit.get("definition", {})
except Exception as e:
logger.error(f"Failed to get unit for PDF generation: {e}")
if not unit_def:
raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}")
try:
from content_generators import generate_worksheet_pdf
pdf_bytes = generate_worksheet_pdf(unit_def, locale)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{unit_id}_worksheet.pdf"'
}
)
except ImportError:
raise HTTPException(
status_code=501,
detail="PDF generation not available. Install weasyprint: pip install weasyprint"
)
except Exception as e:
logger.error(f"PDF generation failed: {e}")
raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}")

View File

@@ -0,0 +1,301 @@
# ==============================================
# Breakpilot Drive - Unit Definition CRUD Routes
# ==============================================
# Endpoints for creating, updating, deleting, and validating
# unit definitions. Extracted from unit_routes.py for file-size compliance.
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import Optional, Dict, Any
from datetime import datetime
import logging
from unit_models import (
UnitDefinitionResponse,
CreateUnitRequest,
UpdateUnitRequest,
ValidationResult,
)
from unit_helpers import (
get_optional_current_user,
get_unit_database,
validate_unit_definition,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"])
@router.post("/definitions", response_model=UnitDefinitionResponse)
async def create_unit_definition(
request_data: CreateUnitRequest,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> UnitDefinitionResponse:
"""
Create a new unit definition.
- Validates unit structure
- Saves to database or JSON file
- Returns created unit
"""
import json
from pathlib import Path
# Build full definition
definition = {
"unit_id": request_data.unit_id,
"template": request_data.template,
"version": request_data.version,
"locale": request_data.locale,
"grade_band": request_data.grade_band,
"duration_minutes": request_data.duration_minutes,
"difficulty": request_data.difficulty,
"subject": request_data.subject,
"topic": request_data.topic,
"learning_objectives": request_data.learning_objectives,
"stops": request_data.stops,
"precheck": request_data.precheck or {
"question_set_id": f"{request_data.unit_id}_precheck",
"required": True,
"time_limit_seconds": 120
},
"postcheck": request_data.postcheck or {
"question_set_id": f"{request_data.unit_id}_postcheck",
"required": True,
"time_limit_seconds": 180
},
"teacher_controls": request_data.teacher_controls or {
"allow_skip": True,
"allow_replay": True,
"max_time_per_stop_sec": 90,
"show_hints": True,
"require_precheck": True,
"require_postcheck": True
},
"assets": request_data.assets or {},
"metadata": request_data.metadata or {
"author": user.get("email", "Unknown") if user else "Unknown",
"created": datetime.utcnow().isoformat(),
"curriculum_reference": ""
}
}
# Validate
validation = validate_unit_definition(definition)
if not validation.valid:
error_msgs = [f"{e.field}: {e.message}" for e in validation.errors]
raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}")
# Check if unit_id already exists
db = await get_unit_database()
if db:
try:
existing = await db.get_unit_definition(request_data.unit_id)
if existing:
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
# Save to database
await db.create_unit_definition(
unit_id=request_data.unit_id,
template=request_data.template,
version=request_data.version,
locale=request_data.locale,
grade_band=request_data.grade_band,
duration_minutes=request_data.duration_minutes,
difficulty=request_data.difficulty,
definition=definition,
status=request_data.status
)
logger.info(f"Unit created in database: {request_data.unit_id}")
except HTTPException:
raise
except Exception as e:
logger.warning(f"Database save failed, using JSON fallback: {e}")
# Fallback to JSON
units_dir = Path(__file__).parent / "data" / "units"
units_dir.mkdir(parents=True, exist_ok=True)
json_path = units_dir / f"{request_data.unit_id}.json"
if json_path.exists():
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(definition, f, ensure_ascii=False, indent=2)
logger.info(f"Unit created as JSON: {json_path}")
else:
# JSON only mode
units_dir = Path(__file__).parent / "data" / "units"
units_dir.mkdir(parents=True, exist_ok=True)
json_path = units_dir / f"{request_data.unit_id}.json"
if json_path.exists():
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(definition, f, ensure_ascii=False, indent=2)
logger.info(f"Unit created as JSON: {json_path}")
return UnitDefinitionResponse(
unit_id=request_data.unit_id,
template=request_data.template,
version=request_data.version,
locale=request_data.locale,
grade_band=request_data.grade_band,
duration_minutes=request_data.duration_minutes,
difficulty=request_data.difficulty,
definition=definition
)
@router.put("/definitions/{unit_id}", response_model=UnitDefinitionResponse)
async def update_unit_definition(
unit_id: str,
request_data: UpdateUnitRequest,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> UnitDefinitionResponse:
"""
Update an existing unit definition.
- Merges updates with existing definition
- Re-validates
- Saves updated version
"""
import json
from pathlib import Path
# Get existing unit
db = await get_unit_database()
existing = None
if db:
try:
existing = await db.get_unit_definition(unit_id)
except Exception as e:
logger.warning(f"Database read failed: {e}")
if not existing:
# Try JSON file
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
if json_path.exists():
with open(json_path, "r", encoding="utf-8") as f:
file_data = json.load(f)
existing = {
"unit_id": file_data.get("unit_id"),
"template": file_data.get("template"),
"version": file_data.get("version", "1.0.0"),
"locale": file_data.get("locale", ["de-DE"]),
"grade_band": file_data.get("grade_band", []),
"duration_minutes": file_data.get("duration_minutes", 8),
"difficulty": file_data.get("difficulty", "base"),
"definition": file_data
}
if not existing:
raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}")
# Merge updates into existing definition
definition = existing.get("definition", {})
update_dict = request_data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if value is not None:
definition[key] = value
# Validate updated definition
validation = validate_unit_definition(definition)
if not validation.valid:
error_msgs = [f"{e.field}: {e.message}" for e in validation.errors]
raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}")
# Save
if db:
try:
await db.update_unit_definition(
unit_id=unit_id,
version=definition.get("version"),
locale=definition.get("locale"),
grade_band=definition.get("grade_band"),
duration_minutes=definition.get("duration_minutes"),
difficulty=definition.get("difficulty"),
definition=definition,
status=update_dict.get("status")
)
logger.info(f"Unit updated in database: {unit_id}")
except Exception as e:
logger.warning(f"Database update failed, using JSON: {e}")
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(definition, f, ensure_ascii=False, indent=2)
else:
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(definition, f, ensure_ascii=False, indent=2)
logger.info(f"Unit updated as JSON: {json_path}")
return UnitDefinitionResponse(
unit_id=unit_id,
template=definition.get("template", existing.get("template")),
version=definition.get("version", existing.get("version", "1.0.0")),
locale=definition.get("locale", existing.get("locale", ["de-DE"])),
grade_band=definition.get("grade_band", existing.get("grade_band", [])),
duration_minutes=definition.get("duration_minutes", existing.get("duration_minutes", 8)),
difficulty=definition.get("difficulty", existing.get("difficulty", "base")),
definition=definition
)
@router.delete("/definitions/{unit_id}")
async def delete_unit_definition(
unit_id: str,
force: bool = Query(False, description="Force delete even if published"),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> Dict[str, Any]:
"""
Delete a unit definition.
- By default, only drafts can be deleted
- Use force=true to delete published units
"""
from pathlib import Path
db = await get_unit_database()
deleted = False
if db:
try:
existing = await db.get_unit_definition(unit_id)
if existing:
status = existing.get("status", "draft")
if status == "published" and not force:
raise HTTPException(
status_code=400,
detail="Veroeffentlichte Units koennen nicht geloescht werden. Verwende force=true."
)
await db.delete_unit_definition(unit_id)
deleted = True
logger.info(f"Unit deleted from database: {unit_id}")
except HTTPException:
raise
except Exception as e:
logger.warning(f"Database delete failed: {e}")
# Also check JSON file
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
if json_path.exists():
json_path.unlink()
deleted = True
logger.info(f"Unit JSON deleted: {json_path}")
if not deleted:
raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}")
return {"success": True, "unit_id": unit_id, "message": "Unit geloescht"}
@router.post("/definitions/validate", response_model=ValidationResult)
async def validate_unit(
unit_data: Dict[str, Any],
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> ValidationResult:
"""
Validate a unit definition without saving.
Returns validation result with errors and warnings.
"""
return validate_unit_definition(unit_data)

View File

@@ -0,0 +1,204 @@
# ==============================================
# Breakpilot Drive - Unit API Helpers
# ==============================================
# Auth, database, token, and validation helpers for the Unit API.
# Extracted from unit_api.py for file-size compliance.
from fastapi import HTTPException, Request
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
import os
import logging
import jwt
from unit_models import ValidationError, ValidationResult
logger = logging.getLogger(__name__)
# Feature flags
USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true"
REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true"
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
# ==============================================
# Auth Dependency (reuse from game_api)
# ==============================================
async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]:
"""Optional auth dependency for Unit API."""
if not REQUIRE_AUTH:
return None
try:
from auth import get_current_user
return await get_current_user(request)
except ImportError:
logger.warning("Auth module not available")
return None
except HTTPException:
raise
except Exception as e:
logger.error(f"Auth error: {e}")
raise HTTPException(status_code=401, detail="Authentication failed")
# ==============================================
# Database Integration
# ==============================================
_unit_db = None
async def get_unit_database():
"""Get unit database instance with lazy initialization."""
global _unit_db
if not USE_DATABASE:
return None
if _unit_db is None:
try:
from unit.database import get_unit_db
_unit_db = await get_unit_db()
logger.info("Unit database initialized")
except ImportError:
logger.warning("Unit database module not available")
except Exception as e:
logger.warning(f"Unit database not available: {e}")
return _unit_db
# ==============================================
# Token Helpers
# ==============================================
def create_session_token(session_id: str, student_id: str, expires_hours: int = 4) -> str:
"""Create a JWT session token for telemetry authentication."""
payload = {
"session_id": session_id,
"student_id": student_id,
"exp": datetime.utcnow() + timedelta(hours=expires_hours),
"iat": datetime.utcnow(),
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
def verify_session_token(token: str) -> Optional[Dict[str, Any]]:
"""Verify a session token and return payload."""
try:
return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
async def get_session_from_token(request: Request) -> Optional[Dict[str, Any]]:
"""Extract and verify session from Authorization header."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
return verify_session_token(token)
# ==============================================
# Validation
# ==============================================
def validate_unit_definition(unit_data: Dict[str, Any]) -> ValidationResult:
"""
Validate a unit definition structure.
Returns validation result with errors and warnings.
"""
errors: List[ValidationError] = []
warnings: List[ValidationError] = []
# Required fields
if not unit_data.get("unit_id"):
errors.append(ValidationError(field="unit_id", message="unit_id ist erforderlich"))
if not unit_data.get("template"):
errors.append(ValidationError(field="template", message="template ist erforderlich"))
elif unit_data["template"] not in ["flight_path", "station_loop"]:
errors.append(ValidationError(
field="template",
message="template muss 'flight_path' oder 'station_loop' sein"
))
# Validate stops
stops = unit_data.get("stops", [])
if not stops:
errors.append(ValidationError(field="stops", message="Mindestens 1 Stop erforderlich"))
else:
# Check minimum stops for flight_path
if unit_data.get("template") == "flight_path" and len(stops) < 3:
warnings.append(ValidationError(
field="stops",
message="FlightPath sollte mindestens 3 Stops haben",
severity="warning"
))
# Validate each stop
stop_ids = set()
for i, stop in enumerate(stops):
if not stop.get("stop_id"):
errors.append(ValidationError(
field=f"stops[{i}].stop_id",
message=f"Stop {i}: stop_id fehlt"
))
else:
if stop["stop_id"] in stop_ids:
errors.append(ValidationError(
field=f"stops[{i}].stop_id",
message=f"Stop {i}: Doppelte stop_id '{stop['stop_id']}'"
))
stop_ids.add(stop["stop_id"])
# Check interaction type
interaction = stop.get("interaction", {})
if not interaction.get("type"):
errors.append(ValidationError(
field=f"stops[{i}].interaction.type",
message=f"Stop {stop.get('stop_id', i)}: Interaktionstyp fehlt"
))
elif interaction["type"] not in [
"aim_and_pass", "slider_adjust", "slider_equivalence",
"sequence_arrange", "toggle_switch", "drag_match",
"error_find", "transfer_apply"
]:
warnings.append(ValidationError(
field=f"stops[{i}].interaction.type",
message=f"Stop {stop.get('stop_id', i)}: Unbekannter Interaktionstyp '{interaction['type']}'",
severity="warning"
))
# Check for label
if not stop.get("label"):
warnings.append(ValidationError(
field=f"stops[{i}].label",
message=f"Stop {stop.get('stop_id', i)}: Label fehlt",
severity="warning"
))
# Validate duration
duration = unit_data.get("duration_minutes", 0)
if duration < 3 or duration > 20:
warnings.append(ValidationError(
field="duration_minutes",
message="Dauer sollte zwischen 3 und 20 Minuten liegen",
severity="warning"
))
# Validate difficulty
if unit_data.get("difficulty") and unit_data["difficulty"] not in ["base", "advanced"]:
warnings.append(ValidationError(
field="difficulty",
message="difficulty sollte 'base' oder 'advanced' sein",
severity="warning"
))
return ValidationResult(
valid=len(errors) == 0,
errors=errors,
warnings=warnings
)

View File

@@ -0,0 +1,149 @@
# ==============================================
# Breakpilot Drive - Unit API Models
# ==============================================
# Pydantic models for the Unit API.
# Extracted from unit_api.py for file-size compliance.
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
class UnitDefinitionResponse(BaseModel):
"""Unit definition response"""
unit_id: str
template: str
version: str
locale: List[str]
grade_band: List[str]
duration_minutes: int
difficulty: str
definition: Dict[str, Any]
class CreateSessionRequest(BaseModel):
"""Request to create a unit session"""
unit_id: str
student_id: str
locale: str = "de-DE"
difficulty: str = "base"
class SessionResponse(BaseModel):
"""Response after creating a session"""
session_id: str
unit_definition_url: str
session_token: str
telemetry_endpoint: str
expires_at: datetime
class TelemetryEvent(BaseModel):
"""Single telemetry event"""
ts: Optional[str] = None
type: str = Field(..., alias="type")
stop_id: Optional[str] = None
metrics: Optional[Dict[str, Any]] = None
class Config:
populate_by_name = True
class TelemetryPayload(BaseModel):
"""Batch telemetry payload"""
session_id: str
events: List[TelemetryEvent]
class TelemetryResponse(BaseModel):
"""Response after receiving telemetry"""
accepted: int
class PostcheckAnswer(BaseModel):
"""Single postcheck answer"""
question_id: str
answer: str
class CompleteSessionRequest(BaseModel):
"""Request to complete a session"""
postcheck_answers: Optional[List[PostcheckAnswer]] = None
class SessionSummaryResponse(BaseModel):
"""Response with session summary"""
summary: Dict[str, Any]
next_recommendations: Dict[str, Any]
class UnitListItem(BaseModel):
"""Unit list item"""
unit_id: str
template: str
difficulty: str
duration_minutes: int
locale: List[str]
grade_band: List[str]
class RecommendedUnit(BaseModel):
"""Recommended unit with reason"""
unit_id: str
template: str
difficulty: str
reason: str
class CreateUnitRequest(BaseModel):
"""Request to create a new unit definition"""
unit_id: str = Field(..., description="Unique unit identifier")
template: str = Field(..., description="Template type: flight_path or station_loop")
version: str = Field(default="1.0.0", description="Version string")
locale: List[str] = Field(default=["de-DE"], description="Supported locales")
grade_band: List[str] = Field(default=["5", "6", "7"], description="Target grade levels")
duration_minutes: int = Field(default=8, ge=3, le=20, description="Expected duration")
difficulty: str = Field(default="base", description="Difficulty level: base or advanced")
subject: Optional[str] = Field(default=None, description="Subject area")
topic: Optional[str] = Field(default=None, description="Topic within subject")
learning_objectives: List[str] = Field(default=[], description="Learning objectives")
stops: List[Dict[str, Any]] = Field(default=[], description="Unit stops/stations")
precheck: Optional[Dict[str, Any]] = Field(default=None, description="Pre-check configuration")
postcheck: Optional[Dict[str, Any]] = Field(default=None, description="Post-check configuration")
teacher_controls: Optional[Dict[str, Any]] = Field(default=None, description="Teacher control settings")
assets: Optional[Dict[str, Any]] = Field(default=None, description="Asset configuration")
metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata")
status: str = Field(default="draft", description="Publication status: draft or published")
class UpdateUnitRequest(BaseModel):
"""Request to update an existing unit definition"""
version: Optional[str] = None
locale: Optional[List[str]] = None
grade_band: Optional[List[str]] = None
duration_minutes: Optional[int] = Field(default=None, ge=3, le=20)
difficulty: Optional[str] = None
subject: Optional[str] = None
topic: Optional[str] = None
learning_objectives: Optional[List[str]] = None
stops: Optional[List[Dict[str, Any]]] = None
precheck: Optional[Dict[str, Any]] = None
postcheck: Optional[Dict[str, Any]] = None
teacher_controls: Optional[Dict[str, Any]] = None
assets: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None
status: Optional[str] = None
class ValidationError(BaseModel):
"""Single validation error"""
field: str
message: str
severity: str = "error" # error or warning
class ValidationResult(BaseModel):
"""Result of unit validation"""
valid: bool
errors: List[ValidationError] = []
warnings: List[ValidationError] = []

View File

@@ -0,0 +1,494 @@
# ==============================================
# Breakpilot Drive - Unit API Routes
# ==============================================
# Endpoints for listing/getting definitions, sessions, telemetry,
# recommendations, and analytics.
# CRUD definition routes are in unit_definition_routes.py.
# Extracted from unit_api.py for file-size compliance.
from fastapi import APIRouter, HTTPException, Query, Depends, Request
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
import uuid
import logging
from unit_models import (
UnitDefinitionResponse,
CreateSessionRequest,
SessionResponse,
TelemetryPayload,
TelemetryResponse,
CompleteSessionRequest,
SessionSummaryResponse,
UnitListItem,
RecommendedUnit,
)
from unit_helpers import (
get_optional_current_user,
get_unit_database,
create_session_token,
get_session_from_token,
REQUIRE_AUTH,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"])
# ==============================================
# Definition List/Get Endpoints
# ==============================================
@router.get("/definitions", response_model=List[UnitListItem])
async def list_unit_definitions(
template: Optional[str] = Query(None, description="Filter by template: flight_path, station_loop"),
grade: Optional[str] = Query(None, description="Filter by grade level"),
locale: str = Query("de-DE", description="Filter by locale"),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> List[UnitListItem]:
"""
List available unit definitions.
Returns published units matching the filter criteria.
"""
db = await get_unit_database()
if db:
try:
units = await db.list_units(
template=template,
grade=grade,
locale=locale,
published_only=True
)
return [
UnitListItem(
unit_id=u["unit_id"],
template=u["template"],
difficulty=u["difficulty"],
duration_minutes=u["duration_minutes"],
locale=u["locale"],
grade_band=u["grade_band"],
)
for u in units
]
except Exception as e:
logger.error(f"Failed to list units: {e}")
# Fallback: return demo unit
return [
UnitListItem(
unit_id="demo_unit_v1",
template="flight_path",
difficulty="base",
duration_minutes=5,
locale=["de-DE"],
grade_band=["5", "6", "7"],
)
]
@router.get("/definitions/{unit_id}", response_model=UnitDefinitionResponse)
async def get_unit_definition(
unit_id: str,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> UnitDefinitionResponse:
"""
Get a specific unit definition.
Returns the full unit configuration including stops, interactions, etc.
"""
db = await get_unit_database()
if db:
try:
unit = await db.get_unit_definition(unit_id)
if unit:
return UnitDefinitionResponse(
unit_id=unit["unit_id"],
template=unit["template"],
version=unit["version"],
locale=unit["locale"],
grade_band=unit["grade_band"],
duration_minutes=unit["duration_minutes"],
difficulty=unit["difficulty"],
definition=unit["definition"],
)
except Exception as e:
logger.error(f"Failed to get unit definition: {e}")
# Demo unit fallback
if unit_id == "demo_unit_v1":
return UnitDefinitionResponse(
unit_id="demo_unit_v1",
template="flight_path",
version="1.0.0",
locale=["de-DE"],
grade_band=["5", "6", "7"],
duration_minutes=5,
difficulty="base",
definition={
"unit_id": "demo_unit_v1",
"template": "flight_path",
"version": "1.0.0",
"learning_objectives": ["Demo: Grundfunktion testen"],
"stops": [
{"stop_id": "stop_1", "label": {"de-DE": "Start"}, "interaction": {"type": "aim_and_pass"}},
{"stop_id": "stop_2", "label": {"de-DE": "Mitte"}, "interaction": {"type": "aim_and_pass"}},
{"stop_id": "stop_3", "label": {"de-DE": "Ende"}, "interaction": {"type": "aim_and_pass"}},
],
"teacher_controls": {"allow_skip": True, "allow_replay": True},
},
)
raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}")
# ==============================================
# Session Endpoints
# ==============================================
@router.post("/sessions", response_model=SessionResponse)
async def create_unit_session(
request_data: CreateSessionRequest,
request: Request,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> SessionResponse:
"""
Create a new unit session.
- Validates unit exists
- Creates session record
- Returns session token for telemetry
"""
session_id = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=4)
# Validate unit exists
db = await get_unit_database()
if db:
try:
unit = await db.get_unit_definition(request_data.unit_id)
if not unit:
raise HTTPException(status_code=404, detail=f"Unit not found: {request_data.unit_id}")
# Create session in database
total_stops = len(unit.get("definition", {}).get("stops", []))
await db.create_session(
session_id=session_id,
unit_id=request_data.unit_id,
student_id=request_data.student_id,
locale=request_data.locale,
difficulty=request_data.difficulty,
total_stops=total_stops,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create session: {e}")
# Continue with in-memory fallback
# Create session token
session_token = create_session_token(session_id, request_data.student_id)
# Build definition URL
base_url = str(request.base_url).rstrip("/")
definition_url = f"{base_url}/api/units/definitions/{request_data.unit_id}"
return SessionResponse(
session_id=session_id,
unit_definition_url=definition_url,
session_token=session_token,
telemetry_endpoint="/api/units/telemetry",
expires_at=expires_at,
)
@router.post("/telemetry", response_model=TelemetryResponse)
async def receive_telemetry(
payload: TelemetryPayload,
request: Request,
) -> TelemetryResponse:
"""
Receive batched telemetry events from Unity client.
- Validates session token
- Stores events in database
- Returns count of accepted events
"""
# Verify session token
session_data = await get_session_from_token(request)
if session_data is None:
# Allow without auth in dev mode
if REQUIRE_AUTH:
raise HTTPException(status_code=401, detail="Invalid or expired session token")
logger.warning("Telemetry received without valid token (dev mode)")
# Verify session_id matches
if session_data and session_data.get("session_id") != payload.session_id:
raise HTTPException(status_code=403, detail="Session ID mismatch")
accepted = 0
db = await get_unit_database()
for event in payload.events:
try:
# Set timestamp if not provided
timestamp = event.ts or datetime.utcnow().isoformat()
if db:
await db.store_telemetry_event(
session_id=payload.session_id,
event_type=event.type,
stop_id=event.stop_id,
timestamp=timestamp,
metrics=event.metrics,
)
accepted += 1
logger.debug(f"Telemetry: {event.type} for session {payload.session_id}")
except Exception as e:
logger.error(f"Failed to store telemetry event: {e}")
return TelemetryResponse(accepted=accepted)
@router.post("/sessions/{session_id}/complete", response_model=SessionSummaryResponse)
async def complete_session(
session_id: str,
request_data: CompleteSessionRequest,
request: Request,
) -> SessionSummaryResponse:
"""
Complete a unit session.
- Processes postcheck answers if provided
- Calculates learning gain
- Returns summary and recommendations
"""
# Verify session token
session_data = await get_session_from_token(request)
if REQUIRE_AUTH and session_data is None:
raise HTTPException(status_code=401, detail="Invalid or expired session token")
db = await get_unit_database()
summary = {}
recommendations = {}
if db:
try:
# Get session data
session = await db.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Calculate postcheck score if answers provided
postcheck_score = None
if request_data.postcheck_answers:
# Simple scoring: count correct answers
# In production, would validate against question bank
postcheck_score = len(request_data.postcheck_answers) * 0.2 # Placeholder
postcheck_score = min(postcheck_score, 1.0)
# Complete session in database
await db.complete_session(
session_id=session_id,
postcheck_score=postcheck_score,
)
# Get updated session summary
session = await db.get_session(session_id)
# Calculate learning gain
pre_score = session.get("precheck_score")
post_score = session.get("postcheck_score")
learning_gain = None
if pre_score is not None and post_score is not None:
learning_gain = post_score - pre_score
summary = {
"session_id": session_id,
"unit_id": session.get("unit_id"),
"duration_seconds": session.get("duration_seconds"),
"completion_rate": session.get("completion_rate"),
"precheck_score": pre_score,
"postcheck_score": post_score,
"pre_to_post_gain": learning_gain,
"stops_completed": session.get("stops_completed"),
"total_stops": session.get("total_stops"),
}
# Get recommendations
recommendations = await db.get_recommendations(
student_id=session.get("student_id"),
completed_unit_id=session.get("unit_id"),
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to complete session: {e}")
summary = {"session_id": session_id, "error": str(e)}
else:
# Fallback summary
summary = {
"session_id": session_id,
"duration_seconds": 0,
"completion_rate": 1.0,
"message": "Database not available",
}
return SessionSummaryResponse(
summary=summary,
next_recommendations=recommendations or {
"h5p_activity_ids": [],
"worksheet_pdf_url": None,
},
)
@router.get("/sessions/{session_id}")
async def get_session(
session_id: str,
request: Request,
) -> Dict[str, Any]:
"""
Get session details.
Returns current state of a session including progress.
"""
# Verify session token
session_data = await get_session_from_token(request)
if REQUIRE_AUTH and session_data is None:
raise HTTPException(status_code=401, detail="Invalid or expired session token")
db = await get_unit_database()
if db:
try:
session = await db.get_session(session_id)
if session:
return session
except Exception as e:
logger.error(f"Failed to get session: {e}")
raise HTTPException(status_code=404, detail="Session not found")
# ==============================================
# Recommendations & Analytics
# ==============================================
@router.get("/recommendations/{student_id}", response_model=List[RecommendedUnit])
async def get_recommendations(
student_id: str,
grade: Optional[str] = Query(None, description="Grade level filter"),
locale: str = Query("de-DE", description="Locale filter"),
limit: int = Query(5, ge=1, le=20),
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> List[RecommendedUnit]:
"""
Get recommended units for a student.
Based on completion status and performance.
"""
db = await get_unit_database()
if db:
try:
recommendations = await db.get_student_recommendations(
student_id=student_id,
grade=grade,
locale=locale,
limit=limit,
)
return [
RecommendedUnit(
unit_id=r["unit_id"],
template=r["template"],
difficulty=r["difficulty"],
reason=r["reason"],
)
for r in recommendations
]
except Exception as e:
logger.error(f"Failed to get recommendations: {e}")
# Fallback: recommend demo unit
return [
RecommendedUnit(
unit_id="demo_unit_v1",
template="flight_path",
difficulty="base",
reason="Neu: Noch nicht gespielt",
)
]
@router.get("/analytics/student/{student_id}")
async def get_student_analytics(
student_id: str,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> Dict[str, Any]:
"""
Get unit analytics for a student.
Includes completion rates, learning gains, time spent.
"""
db = await get_unit_database()
if db:
try:
analytics = await db.get_student_unit_analytics(student_id)
return analytics
except Exception as e:
logger.error(f"Failed to get analytics: {e}")
return {
"student_id": student_id,
"units_attempted": 0,
"units_completed": 0,
"avg_completion_rate": 0.0,
"avg_learning_gain": None,
"total_minutes": 0,
}
@router.get("/analytics/unit/{unit_id}")
async def get_unit_analytics(
unit_id: str,
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
) -> Dict[str, Any]:
"""
Get analytics for a specific unit.
Shows aggregate performance across all students.
"""
db = await get_unit_database()
if db:
try:
analytics = await db.get_unit_performance(unit_id)
return analytics
except Exception as e:
logger.error(f"Failed to get unit analytics: {e}")
return {
"unit_id": unit_id,
"total_sessions": 0,
"completed_sessions": 0,
"completion_percent": 0.0,
"avg_duration_minutes": 0,
"avg_learning_gain": None,
}
@router.get("/health")
async def health_check() -> Dict[str, Any]:
"""Health check for unit API."""
db = await get_unit_database()
db_status = "connected" if db else "disconnected"
return {
"status": "healthy",
"service": "breakpilot-units",
"database": db_status,
"auth_required": REQUIRE_AUTH,
}