[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:
395
backend-lehrer/game_session_routes.py
Normal file
395
backend-lehrer/game_session_routes.py
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user