This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/geo-service/services/learning_generator.py
Benjamin Admin 21a844cb8a 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>
2026-02-09 09:51:32 +01:00

356 lines
12 KiB
Python

"""
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",
}