Files
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

1130 lines
37 KiB
Python

# ==============================================
# Breakpilot Drive - Game API
# ==============================================
# API-Endpunkte fuer das Lernspiel:
# - Lernniveau aus Breakpilot abrufen
# - Quiz-Fragen bereitstellen
# - Spielsessions protokollieren
# - Offline-Sync unterstuetzen
#
# Mit PostgreSQL-Integration fuer persistente Speicherung.
# Fallback auf In-Memory wenn DB nicht verfuegbar.
#
# Auth: Optional via GAME_REQUIRE_AUTH=true
from fastapi import APIRouter, HTTPException, Query, Depends, Request
from pydantic import BaseModel
from typing import List, Optional, Literal, Dict, Any
from datetime import datetime
import random
import uuid
import os
import logging
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"
)
# ==============================================
# 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"
),
]
# 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!"
}
@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)
}
# ==============================================
# 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 []