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>
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""
|
|
Learning Nodes API Endpoints
|
|
Generates and manages educational content for AOI regions
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Path, Query, BackgroundTasks
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional
|
|
import structlog
|
|
|
|
from config import settings
|
|
from models.learning_node import LearningNode, LearningNodeRequest, LearningTheme
|
|
from services.learning_generator import LearningGeneratorService
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
router = APIRouter()
|
|
|
|
# Initialize learning generator service
|
|
learning_service = LearningGeneratorService()
|
|
|
|
|
|
class GenerateNodesRequest(BaseModel):
|
|
"""Request model for generating learning nodes."""
|
|
|
|
aoi_id: str = Field(..., description="AOI UUID to generate nodes for")
|
|
theme: LearningTheme = Field(LearningTheme.TOPOGRAPHIE, description="Learning theme")
|
|
difficulty: str = Field("mittel", pattern="^(leicht|mittel|schwer)$", description="Difficulty level")
|
|
node_count: int = Field(5, ge=1, le=20, description="Number of nodes to generate")
|
|
grade_level: Optional[str] = Field(None, description="Target grade level (e.g., '5-6', '7-8')")
|
|
language: str = Field("de", description="Content language")
|
|
|
|
|
|
class GenerateNodesResponse(BaseModel):
|
|
"""Response model for generated learning nodes."""
|
|
|
|
aoi_id: str
|
|
theme: str
|
|
nodes: list[LearningNode]
|
|
total_count: int
|
|
generation_model: str
|
|
|
|
|
|
@router.post("/generate", response_model=GenerateNodesResponse)
|
|
async def generate_learning_nodes(
|
|
request: GenerateNodesRequest,
|
|
):
|
|
"""
|
|
Generate learning nodes for an AOI using LLM.
|
|
|
|
Uses Ollama with the configured model to generate educational content
|
|
based on the AOI's geographic features and selected theme.
|
|
|
|
Themes:
|
|
- topographie: Landforms, elevation, terrain features
|
|
- landnutzung: Land use, settlement patterns, agriculture
|
|
- orientierung: Navigation, compass, map reading
|
|
- geologie: Rock types, geological formations
|
|
- hydrologie: Water features, drainage, watersheds
|
|
- vegetation: Plant communities, climate zones
|
|
"""
|
|
try:
|
|
nodes = await learning_service.generate_nodes(
|
|
aoi_id=request.aoi_id,
|
|
theme=request.theme,
|
|
difficulty=request.difficulty,
|
|
node_count=request.node_count,
|
|
grade_level=request.grade_level,
|
|
language=request.language,
|
|
)
|
|
|
|
logger.info(
|
|
"Learning nodes generated",
|
|
aoi_id=request.aoi_id,
|
|
theme=request.theme.value,
|
|
count=len(nodes),
|
|
)
|
|
|
|
return GenerateNodesResponse(
|
|
aoi_id=request.aoi_id,
|
|
theme=request.theme.value,
|
|
nodes=nodes,
|
|
total_count=len(nodes),
|
|
generation_model=settings.ollama_model,
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="AOI not found. Please create an AOI first.",
|
|
)
|
|
except ConnectionError:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="LLM service (Ollama) not available. Please check if Ollama is running.",
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error generating learning nodes", error=str(e))
|
|
raise HTTPException(status_code=500, detail="Error generating learning nodes")
|
|
|
|
|
|
@router.get("/templates")
|
|
async def get_learning_templates():
|
|
"""
|
|
Get available learning theme templates.
|
|
|
|
Returns theme definitions with descriptions, suitable grade levels,
|
|
and example questions.
|
|
"""
|
|
return {
|
|
"themes": [
|
|
{
|
|
"id": "topographie",
|
|
"name": "Topographie",
|
|
"description": "Landschaftsformen, Höhen und Geländemerkmale",
|
|
"icon": "mountain",
|
|
"grade_levels": ["5-6", "7-8", "9-10"],
|
|
"example_questions": [
|
|
"Welche Höhe hat der höchste Punkt in diesem Gebiet?",
|
|
"Beschreibe die Hangneigung im nördlichen Bereich.",
|
|
"Wo befinden sich Täler und wo Bergrücken?",
|
|
],
|
|
"keywords": ["Höhe", "Hang", "Tal", "Berg", "Hügel", "Ebene"],
|
|
},
|
|
{
|
|
"id": "landnutzung",
|
|
"name": "Landnutzung",
|
|
"description": "Siedlungen, Landwirtschaft und Flächennutzung",
|
|
"icon": "home",
|
|
"grade_levels": ["5-6", "7-8", "9-10", "11-12"],
|
|
"example_questions": [
|
|
"Welche Arten von Gebäuden sind in diesem Bereich zu finden?",
|
|
"Wie viel Prozent der Fläche wird landwirtschaftlich genutzt?",
|
|
"Wo verläuft die Grenze zwischen Siedlung und Naturraum?",
|
|
],
|
|
"keywords": ["Siedlung", "Acker", "Wald", "Industrie", "Straße"],
|
|
},
|
|
{
|
|
"id": "orientierung",
|
|
"name": "Orientierung",
|
|
"description": "Kartenlesen, Kompass und Navigation",
|
|
"icon": "compass",
|
|
"grade_levels": ["5-6", "7-8"],
|
|
"example_questions": [
|
|
"In welcher Himmelsrichtung liegt der See?",
|
|
"Wie lang ist der Weg von A nach B?",
|
|
"Beschreibe den Weg vom Parkplatz zum Aussichtspunkt.",
|
|
],
|
|
"keywords": ["Norden", "Süden", "Entfernung", "Maßstab", "Legende"],
|
|
},
|
|
{
|
|
"id": "geologie",
|
|
"name": "Geologie",
|
|
"description": "Gesteinsarten und geologische Formationen",
|
|
"icon": "layers",
|
|
"grade_levels": ["7-8", "9-10", "11-12"],
|
|
"example_questions": [
|
|
"Welches Gestein dominiert in diesem Gebiet?",
|
|
"Wie sind die Felsformationen entstanden?",
|
|
"Welche Rolle spielt die Geologie für die Landschaft?",
|
|
],
|
|
"keywords": ["Gestein", "Formation", "Erosion", "Schicht", "Mineral"],
|
|
},
|
|
{
|
|
"id": "hydrologie",
|
|
"name": "Hydrologie",
|
|
"description": "Gewässer, Einzugsgebiete und Wasserkreislauf",
|
|
"icon": "droplet",
|
|
"grade_levels": ["5-6", "7-8", "9-10"],
|
|
"example_questions": [
|
|
"Wohin fließt das Wasser in diesem Gebiet?",
|
|
"Welche Gewässerarten sind vorhanden?",
|
|
"Wo könnte sich bei Starkregen Wasser sammeln?",
|
|
],
|
|
"keywords": ["Fluss", "See", "Bach", "Einzugsgebiet", "Quelle"],
|
|
},
|
|
{
|
|
"id": "vegetation",
|
|
"name": "Vegetation",
|
|
"description": "Pflanzengemeinschaften und Klimazonen",
|
|
"icon": "tree",
|
|
"grade_levels": ["5-6", "7-8", "9-10", "11-12"],
|
|
"example_questions": [
|
|
"Welche Waldtypen sind in diesem Gebiet zu finden?",
|
|
"Wie beeinflusst die Höhenlage die Vegetation?",
|
|
"Welche Pflanzen würdest du hier erwarten?",
|
|
],
|
|
"keywords": ["Wald", "Wiese", "Laubbaum", "Nadelbaum", "Höhenstufe"],
|
|
},
|
|
],
|
|
"difficulties": [
|
|
{"id": "leicht", "name": "Leicht", "description": "Grundlegende Beobachtungen"},
|
|
{"id": "mittel", "name": "Mittel", "description": "Verknüpfung von Zusammenhängen"},
|
|
{"id": "schwer", "name": "Schwer", "description": "Analyse und Transfer"},
|
|
],
|
|
"supported_languages": ["de", "en"],
|
|
}
|
|
|
|
|
|
@router.get("/{aoi_id}/nodes")
|
|
async def get_aoi_learning_nodes(
|
|
aoi_id: str = Path(..., description="AOI UUID"),
|
|
theme: Optional[LearningTheme] = Query(None, description="Filter by theme"),
|
|
):
|
|
"""
|
|
Get all learning nodes for an AOI.
|
|
|
|
Returns previously generated nodes, optionally filtered by theme.
|
|
"""
|
|
nodes = await learning_service.get_nodes_for_aoi(aoi_id, theme)
|
|
|
|
if nodes is None:
|
|
raise HTTPException(status_code=404, detail="AOI not found")
|
|
|
|
return {
|
|
"aoi_id": aoi_id,
|
|
"nodes": nodes,
|
|
"total_count": len(nodes),
|
|
}
|
|
|
|
|
|
@router.put("/{aoi_id}/nodes/{node_id}")
|
|
async def update_learning_node(
|
|
aoi_id: str = Path(..., description="AOI UUID"),
|
|
node_id: str = Path(..., description="Node UUID"),
|
|
node_update: LearningNode,
|
|
):
|
|
"""
|
|
Update a learning node (teacher review/edit).
|
|
|
|
Allows teachers to modify AI-generated content before use.
|
|
"""
|
|
success = await learning_service.update_node(aoi_id, node_id, node_update)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Node not found")
|
|
|
|
logger.info("Learning node updated", aoi_id=aoi_id, node_id=node_id)
|
|
|
|
return {"message": "Node updated successfully", "node_id": node_id}
|
|
|
|
|
|
@router.delete("/{aoi_id}/nodes/{node_id}")
|
|
async def delete_learning_node(
|
|
aoi_id: str = Path(..., description="AOI UUID"),
|
|
node_id: str = Path(..., description="Node UUID"),
|
|
):
|
|
"""
|
|
Delete a learning node.
|
|
"""
|
|
success = await learning_service.delete_node(aoi_id, node_id)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Node not found")
|
|
|
|
return {"message": "Node deleted successfully", "node_id": node_id}
|
|
|
|
|
|
@router.post("/{aoi_id}/nodes/{node_id}/approve")
|
|
async def approve_learning_node(
|
|
aoi_id: str = Path(..., description="AOI UUID"),
|
|
node_id: str = Path(..., description="Node UUID"),
|
|
):
|
|
"""
|
|
Approve a learning node for student use.
|
|
|
|
Only approved nodes will be visible to students.
|
|
"""
|
|
success = await learning_service.approve_node(aoi_id, node_id)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Node not found")
|
|
|
|
return {"message": "Node approved", "node_id": node_id}
|
|
|
|
|
|
@router.get("/statistics")
|
|
async def get_learning_statistics():
|
|
"""
|
|
Get statistics about learning node usage.
|
|
"""
|
|
stats = await learning_service.get_statistics()
|
|
|
|
return {
|
|
"total_nodes_generated": stats.get("total_nodes", 0),
|
|
"nodes_by_theme": stats.get("by_theme", {}),
|
|
"nodes_by_difficulty": stats.get("by_difficulty", {}),
|
|
"average_nodes_per_aoi": stats.get("avg_per_aoi", 0),
|
|
"most_popular_theme": stats.get("popular_theme", "topographie"),
|
|
}
|