""" Learning Generator Service Generates educational content for geographic areas using LLM """ import os import json import uuid from typing import Optional import structlog import httpx from config import settings from models.learning_node import LearningNode, LearningTheme, NodeType logger = structlog.get_logger(__name__) # In-memory storage for learning nodes (use database in production) _learning_nodes = {} class LearningGeneratorService: """ Service for generating educational learning nodes using Ollama LLM. Generates themed educational content based on geographic features and didactic principles. """ def __init__(self): self.ollama_url = settings.ollama_base_url self.model = settings.ollama_model self.timeout = settings.ollama_timeout async def generate_nodes( self, aoi_id: str, theme: LearningTheme, difficulty: str, node_count: int, grade_level: Optional[str] = None, language: str = "de", ) -> list[LearningNode]: """ Generate learning nodes for an AOI. Uses the Ollama LLM to create educational content appropriate for the theme, difficulty, and grade level. """ # Get AOI information aoi_info = await self._get_aoi_info(aoi_id) if aoi_info is None: raise FileNotFoundError(f"AOI {aoi_id} not found") # Build prompt for LLM prompt = self._build_generation_prompt( aoi_info=aoi_info, theme=theme, difficulty=difficulty, node_count=node_count, grade_level=grade_level, language=language, ) # Call Ollama try: response = await self._call_ollama(prompt) nodes = self._parse_llm_response(response, aoi_id, theme) except ConnectionError: logger.warning("Ollama not available, using mock data") nodes = self._generate_mock_nodes(aoi_id, theme, difficulty, node_count) # Store nodes if aoi_id not in _learning_nodes: _learning_nodes[aoi_id] = [] _learning_nodes[aoi_id].extend(nodes) return nodes async def _get_aoi_info(self, aoi_id: str) -> Optional[dict]: """Get information about an AOI from its manifest.""" manifest_path = os.path.join(settings.bundle_dir, aoi_id, "manifest.json") if os.path.exists(manifest_path): with open(manifest_path) as f: return json.load(f) # Check in-memory storage from services.aoi_packager import _aoi_storage return _aoi_storage.get(aoi_id) def _build_generation_prompt( self, aoi_info: dict, theme: LearningTheme, difficulty: str, node_count: int, grade_level: Optional[str], language: str, ) -> str: """Build a prompt for the LLM to generate learning nodes.""" theme_descriptions = { LearningTheme.TOPOGRAPHIE: "Landschaftsformen, Höhen und Geländemerkmale", LearningTheme.LANDNUTZUNG: "Siedlungen, Landwirtschaft und Flächennutzung", LearningTheme.ORIENTIERUNG: "Kartenlesen, Kompass und Navigation", LearningTheme.GEOLOGIE: "Gesteinsarten und geologische Formationen", LearningTheme.HYDROLOGIE: "Gewässer, Einzugsgebiete und Wasserkreislauf", LearningTheme.VEGETATION: "Pflanzengemeinschaften und Klimazonen", } difficulty_descriptions = { "leicht": "Grundlegende Beobachtungen und einfache Fakten", "mittel": "Verknüpfung von Zusammenhängen und Vergleiche", "schwer": "Analyse, Transfer und kritisches Denken", } bounds = aoi_info.get("bounds", {}) center = aoi_info.get("center", {}) prompt = f"""Du bist ein Erdkunde-Didaktiker und erstellst Lernstationen für eine interaktive 3D-Lernwelt. GEBIET: - Zentrum: {center.get('latitude', 0):.4f}°N, {center.get('longitude', 0):.4f}°E - Fläche: ca. {aoi_info.get('area_km2', 0):.2f} km² - Grenzen: West {bounds.get('west', 0):.4f}°, Süd {bounds.get('south', 0):.4f}°, Ost {bounds.get('east', 0):.4f}°, Nord {bounds.get('north', 0):.4f}° THEMA: {theme.value} - {theme_descriptions.get(theme, '')} SCHWIERIGKEITSGRAD: {difficulty} - {difficulty_descriptions.get(difficulty, '')} ZIELGRUPPE: {grade_level if grade_level else 'Allgemein (Klasse 5-10)'} AUFGABE: Erstelle {node_count} Lernstationen im JSON-Format. Jede Station soll: 1. Eine geografische Position innerhalb des Gebiets haben 2. Eine Lernfrage oder Aufgabe enthalten 3. Hinweise zur Lösung bieten 4. Die richtige Antwort mit Erklärung enthalten FORMAT (JSON-Array): [ {{ "title": "Titel der Station", "position": {{"latitude": 0.0, "longitude": 0.0}}, "question": "Die Lernfrage", "hints": ["Hinweis 1", "Hinweis 2"], "answer": "Die Antwort", "explanation": "Didaktische Erklärung", "node_type": "question|observation|exploration", "points": 10 }} ] WICHTIG: - Positionen müssen innerhalb der Gebietsgrenzen liegen - Fragen sollen zum Thema {theme.value} passen - Sprache: {"Deutsch" if language == "de" else "English"} - Altersgerechte Formulierungen verwenden Antworte NUR mit dem JSON-Array, ohne weitere Erklärungen.""" return prompt async def _call_ollama(self, prompt: str) -> str: """Call Ollama API to generate content.""" try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( f"{self.ollama_url}/api/generate", json={ "model": self.model, "prompt": prompt, "stream": False, "options": { "temperature": 0.7, "top_p": 0.9, }, }, ) if response.status_code != 200: raise ConnectionError(f"Ollama returned {response.status_code}") result = response.json() return result.get("response", "") except httpx.ConnectError: raise ConnectionError("Cannot connect to Ollama") except Exception as e: logger.error("Ollama API error", error=str(e)) raise ConnectionError(f"Ollama error: {str(e)}") def _parse_llm_response( self, response: str, aoi_id: str, theme: LearningTheme ) -> list[LearningNode]: """Parse LLM response into LearningNode objects.""" try: # Find JSON array in response start = response.find("[") end = response.rfind("]") + 1 if start == -1 or end == 0: raise ValueError("No JSON array found in response") json_str = response[start:end] data = json.loads(json_str) nodes = [] for item in data: node = LearningNode( id=str(uuid.uuid4()), aoi_id=aoi_id, title=item.get("title", "Unbenannte Station"), theme=theme, position={ "latitude": item.get("position", {}).get("latitude", 0), "longitude": item.get("position", {}).get("longitude", 0), }, question=item.get("question", ""), hints=item.get("hints", []), answer=item.get("answer", ""), explanation=item.get("explanation", ""), node_type=NodeType(item.get("node_type", "question")), points=item.get("points", 10), approved=False, ) nodes.append(node) return nodes except (json.JSONDecodeError, ValueError) as e: logger.error("Failed to parse LLM response", error=str(e)) return [] def _generate_mock_nodes( self, aoi_id: str, theme: LearningTheme, difficulty: str, node_count: int, ) -> list[LearningNode]: """Generate mock learning nodes for development.""" mock_questions = { LearningTheme.TOPOGRAPHIE: [ ("Höhenbestimmung", "Schätze die Höhe dieses Punktes.", "Ca. 500m über NN"), ("Hangneigung", "Beschreibe die Steilheit des Hanges.", "Mäßig steil, ca. 15-20°"), ("Talform", "Welche Form hat dieses Tal?", "V-förmiges Erosionstal"), ], LearningTheme.LANDNUTZUNG: [ ("Gebäudetypen", "Welche Gebäude siehst du hier?", "Wohnhäuser und landwirtschaftliche Gebäude"), ("Flächennutzung", "Wie wird das Land genutzt?", "Landwirtschaft und Siedlung"), ("Infrastruktur", "Welche Verkehrswege erkennst du?", "Straße und Feldweg"), ], LearningTheme.ORIENTIERUNG: [ ("Himmelsrichtung", "In welche Richtung fließt der Bach?", "Nach Nordwesten"), ("Entfernung", "Wie weit ist es bis zum Waldrand?", "Etwa 200 Meter"), ("Wegbeschreibung", "Beschreibe den Weg zum Aussichtspunkt.", "Nordöstlich, bergauf"), ], } questions = mock_questions.get(theme, mock_questions[LearningTheme.TOPOGRAPHIE]) nodes = [] for i in range(min(node_count, len(questions))): title, question, answer = questions[i] nodes.append(LearningNode( id=str(uuid.uuid4()), aoi_id=aoi_id, title=title, theme=theme, position={"latitude": 47.7 + i * 0.001, "longitude": 9.19 + i * 0.001}, question=question, hints=[f"Hinweis {j + 1}" for j in range(2)], answer=answer, explanation=f"Diese Aufgabe trainiert die Beobachtung von {theme.value}.", node_type=NodeType.QUESTION, points=10, approved=False, )) return nodes async def get_nodes_for_aoi( self, aoi_id: str, theme: Optional[LearningTheme] = None ) -> Optional[list[LearningNode]]: """Get all learning nodes for an AOI.""" nodes = _learning_nodes.get(aoi_id) if nodes is None: return None if theme is not None: nodes = [n for n in nodes if n.theme == theme] return nodes async def update_node( self, aoi_id: str, node_id: str, node_update: LearningNode ) -> bool: """Update a learning node.""" nodes = _learning_nodes.get(aoi_id) if nodes is None: return False for i, node in enumerate(nodes): if node.id == node_id: _learning_nodes[aoi_id][i] = node_update return True return False async def delete_node(self, aoi_id: str, node_id: str) -> bool: """Delete a learning node.""" nodes = _learning_nodes.get(aoi_id) if nodes is None: return False for i, node in enumerate(nodes): if node.id == node_id: del _learning_nodes[aoi_id][i] return True return False async def approve_node(self, aoi_id: str, node_id: str) -> bool: """Approve a learning node for student use.""" nodes = _learning_nodes.get(aoi_id) if nodes is None: return False for node in nodes: if node.id == node_id: node.approved = True return True return False async def get_statistics(self) -> dict: """Get statistics about learning node usage.""" total = 0 by_theme = {} by_difficulty = {} for aoi_nodes in _learning_nodes.values(): for node in aoi_nodes: total += 1 theme = node.theme.value by_theme[theme] = by_theme.get(theme, 0) + 1 return { "total_nodes": total, "by_theme": by_theme, "by_difficulty": by_difficulty, "avg_per_aoi": total / len(_learning_nodes) if _learning_nodes else 0, "popular_theme": max(by_theme, key=by_theme.get) if by_theme else "topographie", }