# ============================================== # 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