fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

63
backend/game/__init__.py Normal file
View File

@@ -0,0 +1,63 @@
# ==============================================
# Breakpilot Drive - Game Module
# ==============================================
# Database, quiz generation, and learning rules for the game.
from .database import (
GameDatabase,
StudentLearningState,
GameSessionRecord,
GameQuizAnswer,
LearningLevel,
Achievement,
ACHIEVEMENTS,
get_game_db,
)
from .quiz_generator import (
QuizGenerator,
GeneratedQuestion,
Subject,
QuizMode,
get_quiz_generator,
)
from .learning_rules import (
LearningRuleEngine,
LearningRule,
LearningContext,
Suggestion,
ActionType,
RulePriority,
get_rule_engine,
calculate_level_adjustment,
get_subject_focus_recommendation,
)
__all__ = [
# Database
"GameDatabase",
"StudentLearningState",
"GameSessionRecord",
"GameQuizAnswer",
"LearningLevel",
"Achievement",
"ACHIEVEMENTS",
"get_game_db",
# Quiz Generator
"QuizGenerator",
"GeneratedQuestion",
"Subject",
"QuizMode",
"get_quiz_generator",
# Learning Rules
"LearningRuleEngine",
"LearningRule",
"LearningContext",
"Suggestion",
"ActionType",
"RulePriority",
"get_rule_engine",
"calculate_level_adjustment",
"get_subject_focus_recommendation",
]

785
backend/game/database.py Normal file
View File

@@ -0,0 +1,785 @@
# ==============================================
# Breakpilot Drive - Game Database
# ==============================================
# Async PostgreSQL database access for game sessions
# and student learning state.
import os
import json
import logging
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field
from enum import IntEnum
logger = logging.getLogger(__name__)
# Database URL from environment
GAME_DB_URL = os.getenv(
"DATABASE_URL",
"postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot"
)
class LearningLevel(IntEnum):
"""Learning level enum mapping to grade ranges."""
BEGINNER = 1 # Klasse 2-3
ELEMENTARY = 2 # Klasse 3-4
INTERMEDIATE = 3 # Klasse 4-5
ADVANCED = 4 # Klasse 5-6
EXPERT = 5 # Klasse 6+
@dataclass
class StudentLearningState:
"""Student learning state data model."""
id: Optional[str] = None
student_id: str = ""
overall_level: int = 3
math_level: float = 3.0
german_level: float = 3.0
english_level: float = 3.0
total_play_time_minutes: int = 0
total_sessions: int = 0
questions_answered: int = 0
questions_correct: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"student_id": self.student_id,
"overall_level": self.overall_level,
"math_level": self.math_level,
"german_level": self.german_level,
"english_level": self.english_level,
"total_play_time_minutes": self.total_play_time_minutes,
"total_sessions": self.total_sessions,
"questions_answered": self.questions_answered,
"questions_correct": self.questions_correct,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@property
def accuracy(self) -> float:
"""Calculate overall accuracy percentage."""
if self.questions_answered == 0:
return 0.0
return self.questions_correct / self.questions_answered
@dataclass
class GameSessionRecord:
"""Game session record for database storage."""
id: Optional[str] = None
student_id: str = ""
game_mode: str = "video"
duration_seconds: int = 0
distance_traveled: float = 0.0
score: int = 0
questions_answered: int = 0
questions_correct: int = 0
difficulty_level: int = 3
started_at: Optional[datetime] = None
ended_at: Optional[datetime] = None
metadata: Optional[Dict[str, Any]] = None
@dataclass
class GameQuizAnswer:
"""Individual quiz answer record."""
id: Optional[str] = None
session_id: Optional[str] = None
question_id: str = ""
subject: str = ""
difficulty: int = 3
is_correct: bool = False
answer_time_ms: int = 0
created_at: Optional[datetime] = None
@dataclass
class Achievement:
"""Achievement definition and unlock status."""
id: str
name: str
description: str
icon: str = "star"
category: str = "general" # general, streak, accuracy, time, score
threshold: int = 1
unlocked: bool = False
unlocked_at: Optional[datetime] = None
progress: int = 0
# Achievement definitions (static, not in DB)
ACHIEVEMENTS = [
# Erste Schritte
Achievement(id="first_game", name="Erste Fahrt", description="Spiele dein erstes Spiel", icon="rocket", category="general", threshold=1),
Achievement(id="five_games", name="Regelmaessiger Fahrer", description="Spiele 5 Spiele", icon="car", category="general", threshold=5),
Achievement(id="twenty_games", name="Erfahrener Pilot", description="Spiele 20 Spiele", icon="trophy", category="general", threshold=20),
# Serien
Achievement(id="streak_3", name="Guter Start", description="3 richtige Antworten hintereinander", icon="fire", category="streak", threshold=3),
Achievement(id="streak_5", name="Auf Feuer", description="5 richtige Antworten hintereinander", icon="fire", category="streak", threshold=5),
Achievement(id="streak_10", name="Unaufhaltsam", description="10 richtige Antworten hintereinander", icon="fire", category="streak", threshold=10),
# Genauigkeit
Achievement(id="perfect_game", name="Perfektes Spiel", description="100% richtig in einem Spiel (min. 5 Fragen)", icon="star", category="accuracy", threshold=100),
Achievement(id="accuracy_80", name="Scharfschuetze", description="80% Gesamtgenauigkeit (min. 50 Fragen)", icon="target", category="accuracy", threshold=80),
# Zeit
Achievement(id="play_30min", name="Ausdauer", description="30 Minuten Gesamtspielzeit", icon="clock", category="time", threshold=30),
Achievement(id="play_60min", name="Marathon", description="60 Minuten Gesamtspielzeit", icon="clock", category="time", threshold=60),
# Score
Achievement(id="score_5000", name="Punktejaeger", description="5.000 Punkte in einem Spiel", icon="gem", category="score", threshold=5000),
Achievement(id="score_10000", name="Highscore Hero", description="10.000 Punkte in einem Spiel", icon="crown", category="score", threshold=10000),
# Level
Achievement(id="level_up", name="Aufsteiger", description="Erreiche Level 2", icon="arrow-up", category="level", threshold=2),
Achievement(id="master", name="Meister", description="Erreiche Level 5", icon="medal", category="level", threshold=5),
]
class GameDatabase:
"""
Async database access for Breakpilot Drive game data.
Uses asyncpg for PostgreSQL access with connection pooling.
"""
def __init__(self, database_url: Optional[str] = None):
self.database_url = database_url or GAME_DB_URL
self._pool = None
self._connected = False
async def connect(self):
"""Initialize connection pool."""
if self._connected:
return
try:
import asyncpg
self._pool = await asyncpg.create_pool(
self.database_url,
min_size=2,
max_size=10,
)
self._connected = True
logger.info("Game database connected")
except ImportError:
logger.warning("asyncpg not installed, database features disabled")
except Exception as e:
logger.error(f"Game database connection failed: {e}")
async def close(self):
"""Close connection pool."""
if self._pool:
await self._pool.close()
self._connected = False
async def _ensure_connected(self):
"""Ensure database is connected."""
if not self._connected:
await self.connect()
# ==============================================
# Learning State Methods
# ==============================================
async def get_learning_state(self, student_id: str) -> Optional[StudentLearningState]:
"""Get learning state for a student."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, student_id, overall_level, math_level, german_level,
english_level, total_play_time_minutes, total_sessions,
questions_answered, questions_correct, created_at, updated_at
FROM student_learning_state
WHERE student_id = $1
""",
student_id
)
if row:
return StudentLearningState(
id=str(row["id"]),
student_id=str(row["student_id"]),
overall_level=row["overall_level"],
math_level=float(row["math_level"]),
german_level=float(row["german_level"]),
english_level=float(row["english_level"]),
total_play_time_minutes=row["total_play_time_minutes"],
total_sessions=row["total_sessions"],
questions_answered=row["questions_answered"] or 0,
questions_correct=row["questions_correct"] or 0,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
except Exception as e:
logger.error(f"Failed to get learning state: {e}")
return None
async def create_or_update_learning_state(
self,
student_id: str,
overall_level: int = 3,
math_level: float = 3.0,
german_level: float = 3.0,
english_level: float = 3.0,
) -> Optional[StudentLearningState]:
"""Create or update learning state for a student."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO student_learning_state (
student_id, overall_level, math_level, german_level, english_level
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (student_id) DO UPDATE SET
overall_level = EXCLUDED.overall_level,
math_level = EXCLUDED.math_level,
german_level = EXCLUDED.german_level,
english_level = EXCLUDED.english_level,
updated_at = NOW()
RETURNING id, student_id, overall_level, math_level, german_level,
english_level, total_play_time_minutes, total_sessions,
questions_answered, questions_correct, created_at, updated_at
""",
student_id, overall_level, math_level, german_level, english_level
)
if row:
return StudentLearningState(
id=str(row["id"]),
student_id=str(row["student_id"]),
overall_level=row["overall_level"],
math_level=float(row["math_level"]),
german_level=float(row["german_level"]),
english_level=float(row["english_level"]),
total_play_time_minutes=row["total_play_time_minutes"],
total_sessions=row["total_sessions"],
questions_answered=row["questions_answered"] or 0,
questions_correct=row["questions_correct"] or 0,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
except Exception as e:
logger.error(f"Failed to create/update learning state: {e}")
return None
async def update_learning_stats(
self,
student_id: str,
duration_minutes: int,
questions_answered: int,
questions_correct: int,
new_level: Optional[int] = None,
) -> bool:
"""Update learning stats after a game session."""
await self._ensure_connected()
if not self._pool:
return False
try:
async with self._pool.acquire() as conn:
if new_level is not None:
await conn.execute(
"""
UPDATE student_learning_state SET
total_play_time_minutes = total_play_time_minutes + $2,
total_sessions = total_sessions + 1,
questions_answered = COALESCE(questions_answered, 0) + $3,
questions_correct = COALESCE(questions_correct, 0) + $4,
overall_level = $5,
updated_at = NOW()
WHERE student_id = $1
""",
student_id, duration_minutes, questions_answered,
questions_correct, new_level
)
else:
await conn.execute(
"""
UPDATE student_learning_state SET
total_play_time_minutes = total_play_time_minutes + $2,
total_sessions = total_sessions + 1,
questions_answered = COALESCE(questions_answered, 0) + $3,
questions_correct = COALESCE(questions_correct, 0) + $4,
updated_at = NOW()
WHERE student_id = $1
""",
student_id, duration_minutes, questions_answered, questions_correct
)
return True
except Exception as e:
logger.error(f"Failed to update learning stats: {e}")
return False
# ==============================================
# Game Session Methods
# ==============================================
async def save_game_session(
self,
student_id: str,
game_mode: str,
duration_seconds: int,
distance_traveled: float,
score: int,
questions_answered: int,
questions_correct: int,
difficulty_level: int,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""Save a game session and return the session ID."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO game_sessions (
student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
started_at, ended_at, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8,
NOW() - make_interval(secs => $3), NOW(), $9)
RETURNING id
""",
student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
json.dumps(metadata) if metadata else None
)
if row:
return str(row["id"])
except Exception as e:
logger.error(f"Failed to save game session: {e}")
return None
async def get_user_sessions(
self,
student_id: str,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get recent game sessions for a user."""
await self._ensure_connected()
if not self._pool:
return []
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
started_at, ended_at, metadata
FROM game_sessions
WHERE student_id = $1
ORDER BY ended_at DESC
LIMIT $2
""",
student_id, limit
)
return [
{
"session_id": str(row["id"]),
"user_id": str(row["student_id"]),
"game_mode": row["game_mode"],
"duration_seconds": row["duration_seconds"],
"distance_traveled": float(row["distance_traveled"]) if row["distance_traveled"] else 0.0,
"score": row["score"],
"questions_answered": row["questions_answered"],
"questions_correct": row["questions_correct"],
"difficulty_level": row["difficulty_level"],
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
"ended_at": row["ended_at"].isoformat() if row["ended_at"] else None,
}
for row in rows
]
except Exception as e:
logger.error(f"Failed to get user sessions: {e}")
return []
async def get_leaderboard(
self,
timeframe: str = "day",
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get leaderboard data."""
await self._ensure_connected()
if not self._pool:
return []
# Timeframe filter
timeframe_sql = {
"day": "ended_at > NOW() - INTERVAL '1 day'",
"week": "ended_at > NOW() - INTERVAL '7 days'",
"month": "ended_at > NOW() - INTERVAL '30 days'",
"all": "1=1",
}.get(timeframe, "1=1")
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT student_id, SUM(score) as total_score
FROM game_sessions
WHERE {timeframe_sql}
GROUP BY student_id
ORDER BY total_score DESC
LIMIT $1
""",
limit
)
return [
{
"rank": i + 1,
"user_id": str(row["student_id"]),
"total_score": int(row["total_score"]),
}
for i, row in enumerate(rows)
]
except Exception as e:
logger.error(f"Failed to get leaderboard: {e}")
return []
# ==============================================
# Quiz Answer Methods
# ==============================================
async def save_quiz_answer(
self,
session_id: str,
question_id: str,
subject: str,
difficulty: int,
is_correct: bool,
answer_time_ms: int,
) -> bool:
"""Save an individual quiz answer."""
await self._ensure_connected()
if not self._pool:
return False
try:
async with self._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO game_quiz_answers (
session_id, question_id, subject, difficulty,
is_correct, answer_time_ms
) VALUES ($1, $2, $3, $4, $5, $6)
""",
session_id, question_id, subject, difficulty,
is_correct, answer_time_ms
)
return True
except Exception as e:
logger.error(f"Failed to save quiz answer: {e}")
return False
async def get_subject_stats(
self,
student_id: str
) -> Dict[str, Dict[str, Any]]:
"""Get per-subject statistics for a student."""
await self._ensure_connected()
if not self._pool:
return {}
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
qa.subject,
COUNT(*) as total,
SUM(CASE WHEN qa.is_correct THEN 1 ELSE 0 END) as correct,
AVG(qa.answer_time_ms) as avg_time_ms
FROM game_quiz_answers qa
JOIN game_sessions gs ON qa.session_id = gs.id
WHERE gs.student_id = $1
GROUP BY qa.subject
""",
student_id
)
return {
row["subject"]: {
"total": row["total"],
"correct": row["correct"],
"accuracy": row["correct"] / row["total"] if row["total"] > 0 else 0.0,
"avg_time_ms": int(row["avg_time_ms"]) if row["avg_time_ms"] else 0,
}
for row in rows
}
except Exception as e:
logger.error(f"Failed to get subject stats: {e}")
return {}
# ==============================================
# Extended Leaderboard Methods
# ==============================================
async def get_class_leaderboard(
self,
class_id: str,
timeframe: str = "week",
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Get leaderboard filtered by class.
Note: Requires class_id to be stored in user metadata or
a separate class_memberships table. For now, this is a
placeholder that can be extended.
"""
# For now, fall back to regular leaderboard
# TODO: Join with class_memberships table when available
return await self.get_leaderboard(timeframe, limit)
async def get_leaderboard_with_names(
self,
timeframe: str = "day",
limit: int = 10,
anonymize: bool = True
) -> List[Dict[str, Any]]:
"""Get leaderboard with anonymized display names."""
leaderboard = await self.get_leaderboard(timeframe, limit)
# Anonymize names for privacy (e.g., "Spieler 1", "Spieler 2")
if anonymize:
for entry in leaderboard:
entry["display_name"] = f"Spieler {entry['rank']}"
else:
# In production: Join with users table to get real names
for entry in leaderboard:
entry["display_name"] = f"Spieler {entry['rank']}"
return leaderboard
# ==============================================
# Parent Dashboard Methods
# ==============================================
async def get_children_stats(
self,
children_ids: List[str]
) -> List[Dict[str, Any]]:
"""Get stats for multiple children (parent dashboard)."""
if not children_ids:
return []
results = []
for child_id in children_ids:
state = await self.get_learning_state(child_id)
sessions = await self.get_user_sessions(child_id, limit=5)
results.append({
"student_id": child_id,
"learning_state": state.to_dict() if state else None,
"recent_sessions": sessions,
"has_played": state is not None and state.total_sessions > 0,
})
return results
async def get_progress_over_time(
self,
student_id: str,
days: int = 30
) -> List[Dict[str, Any]]:
"""Get learning progress over time for charts."""
await self._ensure_connected()
if not self._pool:
return []
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
DATE(ended_at) as date,
COUNT(*) as sessions,
SUM(score) as total_score,
SUM(questions_answered) as questions,
SUM(questions_correct) as correct,
AVG(difficulty_level) as avg_difficulty
FROM game_sessions
WHERE student_id = $1
AND ended_at > NOW() - make_interval(days => $2)
GROUP BY DATE(ended_at)
ORDER BY date ASC
""",
student_id, days
)
return [
{
"date": row["date"].isoformat(),
"sessions": row["sessions"],
"total_score": int(row["total_score"]),
"questions": row["questions"],
"correct": row["correct"],
"accuracy": row["correct"] / row["questions"] if row["questions"] > 0 else 0,
"avg_difficulty": float(row["avg_difficulty"]) if row["avg_difficulty"] else 3.0,
}
for row in rows
]
except Exception as e:
logger.error(f"Failed to get progress over time: {e}")
return []
# ==============================================
# Achievement Methods
# ==============================================
async def get_student_achievements(
self,
student_id: str
) -> List[Achievement]:
"""Get achievements with unlock status for a student."""
await self._ensure_connected()
# Get student stats for progress calculation
state = await self.get_learning_state(student_id)
# Calculate progress for each achievement
achievements = []
for a in ACHIEVEMENTS:
achievement = Achievement(
id=a.id,
name=a.name,
description=a.description,
icon=a.icon,
category=a.category,
threshold=a.threshold,
)
# Calculate progress based on category
if state:
if a.category == "general":
achievement.progress = state.total_sessions
achievement.unlocked = state.total_sessions >= a.threshold
elif a.category == "time":
achievement.progress = state.total_play_time_minutes
achievement.unlocked = state.total_play_time_minutes >= a.threshold
elif a.category == "level":
achievement.progress = state.overall_level
achievement.unlocked = state.overall_level >= a.threshold
elif a.category == "accuracy":
if a.id == "accuracy_80" and state.questions_answered >= 50:
achievement.progress = int(state.accuracy * 100)
achievement.unlocked = state.accuracy >= 0.8
achievements.append(achievement)
# Check DB for unlocked achievements (streak, score, perfect game)
if self._pool:
try:
async with self._pool.acquire() as conn:
# Check for score achievements
max_score = await conn.fetchval(
"SELECT MAX(score) FROM game_sessions WHERE student_id = $1",
student_id
)
if max_score:
for a in achievements:
if a.category == "score":
a.progress = max_score
a.unlocked = max_score >= a.threshold
# Check for perfect game
perfect = await conn.fetchval(
"""
SELECT COUNT(*) FROM game_sessions
WHERE student_id = $1
AND questions_answered >= 5
AND questions_correct = questions_answered
""",
student_id
)
for a in achievements:
if a.id == "perfect_game":
a.progress = 100 if perfect and perfect > 0 else 0
a.unlocked = perfect is not None and perfect > 0
except Exception as e:
logger.error(f"Failed to check achievements: {e}")
return achievements
async def check_new_achievements(
self,
student_id: str,
session_score: int,
session_accuracy: float,
streak: int
) -> List[Achievement]:
"""
Check for newly unlocked achievements after a session.
Returns list of newly unlocked achievements.
"""
all_achievements = await self.get_student_achievements(student_id)
newly_unlocked = []
for a in all_achievements:
# Check streak achievements
if a.category == "streak" and streak >= a.threshold and not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
# Check score achievements
if a.category == "score" and session_score >= a.threshold and not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
# Check perfect game
if a.id == "perfect_game" and session_accuracy == 1.0:
if not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
return newly_unlocked
# Global database instance
_game_db: Optional[GameDatabase] = None
async def get_game_db() -> GameDatabase:
"""Get or create the global game database instance."""
global _game_db
if _game_db is None:
_game_db = GameDatabase()
await _game_db.connect()
return _game_db

View File

@@ -0,0 +1,439 @@
# ==============================================
# Breakpilot Drive - Learning Rules
# ==============================================
# Adaptive Regeln fuer Lernniveau-Anpassung.
# Integriert mit der bestehenden State Engine.
import logging
from dataclasses import dataclass
from typing import Optional, List, Dict, Any, Callable
from enum import Enum, auto
logger = logging.getLogger(__name__)
class RulePriority(Enum):
"""Priority levels for rule suggestions."""
LOW = auto()
MEDIUM = auto()
HIGH = auto()
CRITICAL = auto()
class ActionType(str, Enum):
"""Available actions for learning adjustments."""
INCREASE_DIFFICULTY = "increase_difficulty"
DECREASE_DIFFICULTY = "decrease_difficulty"
FOCUS_SUBJECT = "focus_subject"
ENCOURAGE = "encourage"
SUGGEST_BREAK = "suggest_break"
CELEBRATE = "celebrate"
REVIEW_TOPIC = "review_topic"
@dataclass
class LearningContext:
"""Context for rule evaluation."""
student_id: str
overall_level: int
math_level: float
german_level: float
english_level: float
recent_accuracy: float
recent_questions: int
total_play_time_minutes: int
total_sessions: int
current_streak: int
session_duration_minutes: int
weakest_subject: Optional[str] = None
strongest_subject: Optional[str] = None
@classmethod
def from_learning_state(cls, state: Any, session_stats: Dict[str, Any] = None):
"""Create context from StudentLearningState."""
session_stats = session_stats or {}
# Determine weakest and strongest subjects
levels = {
"math": state.math_level,
"german": state.german_level,
"english": state.english_level,
}
weakest = min(levels, key=levels.get)
strongest = max(levels, key=levels.get)
return cls(
student_id=state.student_id,
overall_level=state.overall_level,
math_level=state.math_level,
german_level=state.german_level,
english_level=state.english_level,
recent_accuracy=session_stats.get("accuracy", state.accuracy),
recent_questions=session_stats.get("questions", state.questions_answered),
total_play_time_minutes=state.total_play_time_minutes,
total_sessions=state.total_sessions,
current_streak=session_stats.get("streak", 0),
session_duration_minutes=session_stats.get("duration_minutes", 0),
weakest_subject=weakest if levels[weakest] < state.overall_level - 0.5 else None,
strongest_subject=strongest if levels[strongest] > state.overall_level + 0.5 else None,
)
@dataclass
class Suggestion:
"""A suggestion generated by a rule."""
title: str
description: str
action: ActionType
priority: RulePriority
metadata: Optional[Dict[str, Any]] = None
@dataclass
class LearningRule:
"""A rule for adaptive learning adjustments."""
id: str
name: str
description: str
condition: Callable[[LearningContext], bool]
suggestion_generator: Callable[[LearningContext], Suggestion]
cooldown_minutes: int = 0 # Minimum time between triggers
# ==============================================
# Learning Rules Definitions
# ==============================================
LEARNING_RULES: List[LearningRule] = [
# ------------------------------------------
# Difficulty Adjustment Rules
# ------------------------------------------
LearningRule(
id="level_up_ready",
name="Bereit fuer naechstes Level",
description="Erhoehe Schwierigkeit wenn 80%+ richtig ueber 10 Fragen",
condition=lambda ctx: (
ctx.recent_accuracy >= 0.8 and
ctx.recent_questions >= 10 and
ctx.overall_level < 5
),
suggestion_generator=lambda ctx: Suggestion(
title="Super gemacht!",
description=f"Du hast {int(ctx.recent_accuracy * 100)}% richtig! Zeit fuer schwerere Aufgaben!",
action=ActionType.INCREASE_DIFFICULTY,
priority=RulePriority.HIGH,
metadata={"new_level": ctx.overall_level + 1}
),
cooldown_minutes=10
),
LearningRule(
id="level_down_needed",
name="Schwierigkeit reduzieren",
description="Verringere Schwierigkeit wenn weniger als 40% richtig",
condition=lambda ctx: (
ctx.recent_accuracy < 0.4 and
ctx.recent_questions >= 5 and
ctx.overall_level > 1
),
suggestion_generator=lambda ctx: Suggestion(
title="Lass uns einfacher anfangen",
description="Kein Problem! Uebung macht den Meister. Wir machen es etwas leichter.",
action=ActionType.DECREASE_DIFFICULTY,
priority=RulePriority.HIGH,
metadata={"new_level": ctx.overall_level - 1}
),
cooldown_minutes=5
),
# ------------------------------------------
# Subject Focus Rules
# ------------------------------------------
LearningRule(
id="weak_subject_detected",
name="Schwaches Fach erkannt",
description="Fokussiere auf Fach mit niedrigstem Level",
condition=lambda ctx: (
ctx.weakest_subject is not None and
ctx.recent_questions >= 15
),
suggestion_generator=lambda ctx: Suggestion(
title=f"Uebe mehr {_subject_name(ctx.weakest_subject)}",
description=f"In {_subject_name(ctx.weakest_subject)} kannst du noch besser werden!",
action=ActionType.FOCUS_SUBJECT,
priority=RulePriority.MEDIUM,
metadata={"subject": ctx.weakest_subject}
),
cooldown_minutes=30
),
LearningRule(
id="strong_subject_celebration",
name="Starkes Fach feiern",
description="Lobe wenn ein Fach besonders stark ist",
condition=lambda ctx: (
ctx.strongest_subject is not None and
getattr(ctx, f"{ctx.strongest_subject}_level", 0) >= 4.5
),
suggestion_generator=lambda ctx: Suggestion(
title=f"Du bist super in {_subject_name(ctx.strongest_subject)}!",
description="Weiter so! Du bist ein echtes Talent!",
action=ActionType.CELEBRATE,
priority=RulePriority.MEDIUM,
metadata={"subject": ctx.strongest_subject}
),
cooldown_minutes=60
),
# ------------------------------------------
# Motivation Rules
# ------------------------------------------
LearningRule(
id="streak_celebration",
name="Serie feiern",
description="Feiere Erfolgsserien",
condition=lambda ctx: ctx.current_streak >= 5,
suggestion_generator=lambda ctx: Suggestion(
title=f"{ctx.current_streak}x richtig hintereinander!",
description="Unglaublich! Du bist auf Feuer!",
action=ActionType.CELEBRATE,
priority=RulePriority.HIGH,
metadata={"streak": ctx.current_streak}
),
cooldown_minutes=0 # Can trigger every time
),
LearningRule(
id="encourage_after_wrong",
name="Ermutigen nach Fehler",
description="Ermutige nach mehreren falschen Antworten",
condition=lambda ctx: (
ctx.recent_questions >= 3 and
ctx.recent_accuracy < 0.35 and
ctx.recent_accuracy > 0 # At least some attempts
),
suggestion_generator=lambda ctx: Suggestion(
title="Nicht aufgeben!",
description="Fehler sind zum Lernen da. Du schaffst das!",
action=ActionType.ENCOURAGE,
priority=RulePriority.MEDIUM,
metadata={}
),
cooldown_minutes=2
),
LearningRule(
id="first_session_welcome",
name="Erste Session Willkommen",
description="Begruesse neue Spieler",
condition=lambda ctx: ctx.total_sessions == 0,
suggestion_generator=lambda ctx: Suggestion(
title="Willkommen bei Breakpilot Drive!",
description="Los geht's! Sammle Punkte und lerne dabei!",
action=ActionType.ENCOURAGE,
priority=RulePriority.HIGH,
metadata={"is_first_session": True}
),
cooldown_minutes=0
),
# ------------------------------------------
# Break Suggestion Rules
# ------------------------------------------
LearningRule(
id="suggest_break_long_session",
name="Pause vorschlagen (lange Session)",
description="Schlage Pause nach 30 Minuten vor",
condition=lambda ctx: ctx.session_duration_minutes >= 30,
suggestion_generator=lambda ctx: Suggestion(
title="Zeit fuer eine Pause?",
description="Du spielst schon lange. Eine kurze Pause tut gut!",
action=ActionType.SUGGEST_BREAK,
priority=RulePriority.LOW,
metadata={"minutes_played": ctx.session_duration_minutes}
),
cooldown_minutes=30
),
LearningRule(
id="suggest_break_declining_performance",
name="Pause vorschlagen (sinkende Leistung)",
description="Schlage Pause vor wenn Leistung nachlässt",
condition=lambda ctx: (
ctx.session_duration_minutes >= 15 and
ctx.recent_accuracy < 0.5 and
ctx.recent_questions >= 10
),
suggestion_generator=lambda ctx: Suggestion(
title="Kurze Pause?",
description="Eine kleine Pause kann helfen, wieder fit zu werden!",
action=ActionType.SUGGEST_BREAK,
priority=RulePriority.MEDIUM,
metadata={}
),
cooldown_minutes=15
),
# ------------------------------------------
# Review Topic Rules
# ------------------------------------------
LearningRule(
id="review_failed_topic",
name="Thema wiederholen",
description="Schlage Wiederholung vor bei wiederholten Fehlern",
condition=lambda ctx: (
ctx.recent_accuracy < 0.3 and
ctx.recent_questions >= 5
),
suggestion_generator=lambda ctx: Suggestion(
title="Nochmal ueben?",
description="Lass uns das Thema nochmal gemeinsam anschauen.",
action=ActionType.REVIEW_TOPIC,
priority=RulePriority.MEDIUM,
metadata={}
),
cooldown_minutes=10
),
]
def _subject_name(subject: str) -> str:
"""Get German display name for subject."""
names = {
"math": "Mathe",
"german": "Deutsch",
"english": "Englisch",
"general": "Allgemeinwissen"
}
return names.get(subject, subject)
class LearningRuleEngine:
"""
Evaluates learning rules against context.
Tracks cooldowns and returns applicable suggestions.
"""
def __init__(self):
self._cooldowns: Dict[str, float] = {} # rule_id -> last_triggered_timestamp
def evaluate(
self,
context: LearningContext,
current_time: float = None
) -> List[Suggestion]:
"""
Evaluate all rules and return applicable suggestions.
Returns suggestions sorted by priority (highest first).
"""
import time
current_time = current_time or time.time()
suggestions = []
for rule in LEARNING_RULES:
# Check cooldown
last_triggered = self._cooldowns.get(rule.id, 0)
cooldown_seconds = rule.cooldown_minutes * 60
if current_time - last_triggered < cooldown_seconds:
continue
# Evaluate condition
try:
if rule.condition(context):
suggestion = rule.suggestion_generator(context)
suggestions.append(suggestion)
self._cooldowns[rule.id] = current_time
except Exception as e:
logger.warning(f"Rule {rule.id} evaluation failed: {e}")
# Sort by priority (highest first)
priority_order = {
RulePriority.CRITICAL: 0,
RulePriority.HIGH: 1,
RulePriority.MEDIUM: 2,
RulePriority.LOW: 3,
}
suggestions.sort(key=lambda s: priority_order.get(s.priority, 99))
return suggestions
def get_top_suggestion(self, context: LearningContext) -> Optional[Suggestion]:
"""Get the highest priority suggestion."""
suggestions = self.evaluate(context)
return suggestions[0] if suggestions else None
def reset_cooldowns(self):
"""Reset all cooldowns (e.g., for new session)."""
self._cooldowns.clear()
# Global instance
_rule_engine: Optional[LearningRuleEngine] = None
def get_rule_engine() -> LearningRuleEngine:
"""Get the global rule engine instance."""
global _rule_engine
if _rule_engine is None:
_rule_engine = LearningRuleEngine()
return _rule_engine
# ==============================================
# Helper Functions
# ==============================================
def calculate_level_adjustment(
recent_accuracy: float,
recent_questions: int,
current_level: int
) -> int:
"""
Calculate recommended level adjustment.
Returns: -1 (decrease), 0 (keep), 1 (increase)
"""
if recent_questions < 5:
return 0 # Not enough data
if recent_accuracy >= 0.8 and current_level < 5:
return 1 # Increase
if recent_accuracy < 0.4 and current_level > 1:
return -1 # Decrease
return 0 # Keep
def get_subject_focus_recommendation(
math_level: float,
german_level: float,
english_level: float,
overall_level: int
) -> Optional[str]:
"""
Get recommendation for which subject to focus on.
Returns subject name or None if all balanced.
"""
levels = {
"math": math_level,
"german": german_level,
"english": english_level,
}
# Find subject most below overall level
min_subject = min(levels, key=levels.get)
min_level = levels[min_subject]
# Only recommend if significantly below overall
if min_level < overall_level - 0.5:
return min_subject
return None

View File

@@ -0,0 +1,439 @@
# ==============================================
# Breakpilot Drive - Quiz Generator Service
# ==============================================
# Generiert Quiz-Fragen dynamisch via LLM Gateway.
# Unterstuetzt Caching via Valkey fuer Performance.
import os
import json
import logging
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
# Configuration
LLM_MODEL = os.getenv("GAME_LLM_MODEL", "llama-3.1-8b")
LLM_FALLBACK_MODEL = os.getenv("GAME_LLM_FALLBACK_MODEL", "claude-3-haiku")
CACHE_TTL = int(os.getenv("GAME_QUESTION_CACHE_TTL", "3600")) # 1 hour
class Subject(str, Enum):
"""Available subjects for quiz questions."""
MATH = "math"
GERMAN = "german"
ENGLISH = "english"
GENERAL = "general"
class QuizMode(str, Enum):
"""Quiz modes with different time constraints."""
QUICK = "quick" # 2-3 options, 3-5 seconds
PAUSE = "pause" # 4 options, unlimited time
@dataclass
class GeneratedQuestion:
"""Generated question from LLM."""
question_text: str
options: List[str]
correct_index: int
explanation: Optional[str] = None
difficulty: int = 3
subject: str = "general"
grade_level: int = 4
quiz_mode: str = "quick"
visual_trigger: Optional[str] = None
time_limit_seconds: Optional[float] = None
# ==============================================
# Prompt Templates
# ==============================================
QUICK_QUESTION_PROMPT = """Du bist ein Lehrer fuer Grundschulkinder (Klasse {grade}).
Erstelle eine SCHNELLE Quiz-Frage zum Thema "{subject}" mit Schwierigkeit {difficulty}/5.
Kontext: Das Kind faehrt in einem Autorennen-Spiel und sieht gerade ein(e) {visual_trigger}.
Die Frage soll zum visuellen Element passen und in 3-5 Sekunden beantwortbar sein.
Regeln:
- NUR 2-3 kurze Antwortoptionen
- Frage muss sehr kurz sein (max 10 Woerter)
- Antworten muessen eindeutig richtig/falsch sein
- Kindgerecht und motivierend
Antworte NUR im JSON-Format:
{{
"question_text": "Kurze Frage?",
"options": ["Antwort1", "Antwort2"],
"correct_index": 0,
"explanation": "Kurze Erklaerung"
}}"""
PAUSE_QUESTION_PROMPT = """Du bist ein Lehrer fuer Grundschulkinder (Klasse {grade}).
Erstelle eine DENKAUFGABE zum Thema "{subject}" mit Schwierigkeit {difficulty}/5.
Das Kind hat Zeit zum Nachdenken (Spiel ist pausiert).
Die Frage darf komplexer sein und Textverstaendnis erfordern.
Regeln:
- 4 Antwortoptionen
- Frage kann laenger sein (Textaufgabe erlaubt)
- Eine Option ist eindeutig richtig
- Kindgerecht formulieren
Antworte NUR im JSON-Format:
{{
"question_text": "Die vollstaendige Frage oder Aufgabe?",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_index": 0,
"explanation": "Erklaerung warum diese Antwort richtig ist"
}}"""
SUBJECT_CONTEXTS = {
"math": {
"quick": ["Kopfrechnen", "Einmaleins", "Plus/Minus"],
"pause": ["Textaufgaben", "Geometrie", "Brueche", "Prozent"]
},
"german": {
"quick": ["Rechtschreibung", "Artikel"],
"pause": ["Grammatik", "Wortarten", "Satzglieder", "Zeitformen"]
},
"english": {
"quick": ["Vokabeln", "Farben", "Zahlen", "Tiere"],
"pause": ["Grammatik", "Saetze bilden", "Uebersetzung"]
},
"general": {
"quick": ["Allgemeinwissen"],
"pause": ["Sachkunde", "Natur", "Geographie"]
}
}
VISUAL_TRIGGER_THEMES = {
"bridge": {
"math": "Wie lang ist die Bruecke? Wie viele Autos passen drauf?",
"german": "Wie schreibt man Bruecke? Was reimt sich?",
"english": "What is this? Bridge vocabulary"
},
"tree": {
"math": "Wie viele Blaetter? Wie hoch ist der Baum?",
"german": "Nomen oder Verb? Einzahl/Mehrzahl",
"english": "Tree, leaf, branch vocabulary"
},
"house": {
"math": "Fenster zaehlen, Stockwerke",
"german": "Wortfamilie Haus",
"english": "House, room vocabulary"
},
"car": {
"math": "Raeder zaehlen, Geschwindigkeit",
"german": "Fahrzeug-Woerter",
"english": "Car, vehicle vocabulary"
},
"mountain": {
"math": "Hoehe, Entfernung",
"german": "Landschafts-Begriffe",
"english": "Mountain, hill vocabulary"
},
"river": {
"math": "Laenge, Breite",
"german": "Wasser-Woerter",
"english": "River, water vocabulary"
}
}
class QuizGenerator:
"""
Generates quiz questions using LLM Gateway.
Supports caching via Valkey for performance.
Falls back to static questions if LLM unavailable.
"""
def __init__(self):
self._llm_client = None
self._valkey_client = None
self._llm_available = False
self._cache_available = False
async def connect(self):
"""Initialize LLM and cache connections."""
await self._connect_llm()
await self._connect_cache()
async def _connect_llm(self):
"""Connect to LLM Gateway."""
try:
# Try to import LLM client from existing gateway
from llm_gateway.services.inference import InferenceService
self._llm_client = InferenceService()
self._llm_available = True
logger.info("Quiz Generator connected to LLM Gateway")
except ImportError:
logger.warning("LLM Gateway not available, using static questions")
self._llm_available = False
except Exception as e:
logger.warning(f"LLM connection failed: {e}")
self._llm_available = False
async def _connect_cache(self):
"""Connect to Valkey cache."""
try:
import redis.asyncio as redis
valkey_url = os.getenv("VALKEY_URL", "redis://localhost:6379")
self._valkey_client = redis.from_url(
valkey_url,
encoding="utf-8",
decode_responses=True,
)
await self._valkey_client.ping()
self._cache_available = True
logger.info("Quiz Generator connected to Valkey cache")
except Exception as e:
logger.warning(f"Valkey cache not available: {e}")
self._cache_available = False
def _get_cache_key(
self,
difficulty: int,
subject: str,
mode: str,
visual_trigger: Optional[str] = None
) -> str:
"""Generate cache key for questions."""
if visual_trigger:
return f"quiz:d{difficulty}:s{subject}:m{mode}:v{visual_trigger}"
return f"quiz:d{difficulty}:s{subject}:m{mode}"
async def get_cached_questions(
self,
difficulty: int,
subject: str,
mode: str,
count: int,
visual_trigger: Optional[str] = None
) -> List[GeneratedQuestion]:
"""Get questions from cache."""
if not self._cache_available:
return []
try:
cache_key = self._get_cache_key(difficulty, subject, mode, visual_trigger)
cached = await self._valkey_client.lrange(cache_key, 0, count - 1)
questions = []
for item in cached:
data = json.loads(item)
questions.append(GeneratedQuestion(**data))
return questions
except Exception as e:
logger.warning(f"Cache read failed: {e}")
return []
async def cache_questions(
self,
questions: List[GeneratedQuestion],
difficulty: int,
subject: str,
mode: str,
visual_trigger: Optional[str] = None
):
"""Store questions in cache."""
if not self._cache_available:
return
try:
cache_key = self._get_cache_key(difficulty, subject, mode, visual_trigger)
for q in questions:
data = {
"question_text": q.question_text,
"options": q.options,
"correct_index": q.correct_index,
"explanation": q.explanation,
"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,
}
await self._valkey_client.rpush(cache_key, json.dumps(data))
await self._valkey_client.expire(cache_key, CACHE_TTL)
except Exception as e:
logger.warning(f"Cache write failed: {e}")
async def generate_question(
self,
difficulty: int = 3,
subject: str = "general",
mode: str = "quick",
grade: int = 4,
visual_trigger: Optional[str] = None
) -> Optional[GeneratedQuestion]:
"""
Generate a single question using LLM.
Falls back to None if LLM unavailable (caller should use static questions).
"""
if not self._llm_available or not self._llm_client:
return None
# Select prompt template
if mode == "quick":
prompt = QUICK_QUESTION_PROMPT.format(
grade=grade,
subject=subject,
difficulty=difficulty,
visual_trigger=visual_trigger or "Strasse"
)
time_limit = 3.0 + (difficulty * 0.5) # 3.5 - 5.5 seconds
else:
prompt = PAUSE_QUESTION_PROMPT.format(
grade=grade,
subject=subject,
difficulty=difficulty
)
time_limit = None
try:
# Call LLM Gateway
response = await self._llm_client.chat_completion(
messages=[{"role": "user", "content": prompt}],
model=LLM_MODEL,
temperature=0.7,
max_tokens=500
)
# Parse JSON response
content = response.get("content", "")
# Extract JSON from response (handle markdown code blocks)
if "```json" in content:
content = content.split("```json")[1].split("```")[0]
elif "```" in content:
content = content.split("```")[1].split("```")[0]
data = json.loads(content.strip())
return GeneratedQuestion(
question_text=data["question_text"],
options=data["options"],
correct_index=data["correct_index"],
explanation=data.get("explanation"),
difficulty=difficulty,
subject=subject,
grade_level=grade,
quiz_mode=mode,
visual_trigger=visual_trigger,
time_limit_seconds=time_limit
)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse LLM response: {e}")
return None
except Exception as e:
logger.error(f"LLM question generation failed: {e}")
return None
async def generate_questions_batch(
self,
difficulty: int,
subject: str,
mode: str,
count: int,
grade: int = 4,
visual_trigger: Optional[str] = None
) -> List[GeneratedQuestion]:
"""Generate multiple questions."""
questions = []
for _ in range(count):
q = await self.generate_question(
difficulty=difficulty,
subject=subject,
mode=mode,
grade=grade,
visual_trigger=visual_trigger
)
if q:
questions.append(q)
return questions
async def get_questions(
self,
difficulty: int = 3,
subject: str = "general",
mode: str = "quick",
count: int = 5,
grade: int = 4,
visual_trigger: Optional[str] = None
) -> List[GeneratedQuestion]:
"""
Get questions with caching.
1. Check cache first
2. Generate new if not enough cached
3. Cache new questions
4. Return combined result
"""
# Try cache first
cached = await self.get_cached_questions(
difficulty, subject, mode, count, visual_trigger
)
if len(cached) >= count:
return cached[:count]
# Generate more questions
needed = count - len(cached)
new_questions = await self.generate_questions_batch(
difficulty=difficulty,
subject=subject,
mode=mode,
count=needed * 2, # Generate extra for cache
grade=grade,
visual_trigger=visual_trigger
)
# Cache new questions
if new_questions:
await self.cache_questions(
new_questions, difficulty, subject, mode, visual_trigger
)
# Combine and return
all_questions = cached + new_questions
return all_questions[:count]
def get_grade_for_difficulty(self, difficulty: int) -> int:
"""Map difficulty level to grade level."""
mapping = {
1: 2, # Klasse 2
2: 3, # Klasse 3
3: 4, # Klasse 4
4: 5, # Klasse 5
5: 6, # Klasse 6
}
return mapping.get(difficulty, 4)
# Global instance
_quiz_generator: Optional[QuizGenerator] = None
async def get_quiz_generator() -> QuizGenerator:
"""Get or create the global quiz generator instance."""
global _quiz_generator
if _quiz_generator is None:
_quiz_generator = QuizGenerator()
await _quiz_generator.connect()
return _quiz_generator