Files
breakpilot-lehrer/backend-lehrer/state_engine_api.py
Benjamin Admin bd4b956e3c [split-required] Split final 43 files (500-668 LOC) to complete refactoring
klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:41:42 +02:00

411 lines
13 KiB
Python

"""
State Engine API - REST API für Begleiter-Modus.
Endpoints:
- GET /api/state/context - TeacherContext abrufen
- GET /api/state/suggestions - Vorschläge abrufen
- GET /api/state/dashboard - Dashboard-Daten
- POST /api/state/milestone - Meilenstein abschließen
- POST /api/state/transition - Phasen-Übergang
"""
import logging
import uuid
from datetime import datetime, timedelta
from typing import Dict, Any, List
from fastapi import APIRouter, HTTPException, Query
from state_engine import (
AnticipationEngine,
PhaseService,
SchoolYearPhase,
ClassSummary,
Event,
get_phase_info,
)
from state_engine_models import (
MilestoneRequest,
TransitionRequest,
ContextResponse,
SuggestionsResponse,
DashboardResponse,
_teacher_contexts,
_milestones,
get_or_create_context,
update_context_from_services,
get_phase_display_name,
)
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/state",
tags=["state-engine"],
)
# Singleton instances
_engine = AnticipationEngine()
_phase_service = PhaseService()
# ============================================================================
# API Endpoints
# ============================================================================
@router.get("/context", response_model=ContextResponse)
async def get_teacher_context(teacher_id: str = Query("demo-teacher")):
"""Gibt den aggregierten TeacherContext zurück."""
ctx = get_or_create_context(teacher_id)
ctx = update_context_from_services(ctx)
phase_info = get_phase_info(ctx.current_phase)
return ContextResponse(
context=ctx.to_dict(),
phase_info={
"phase": phase_info.phase.value,
"display_name": phase_info.display_name,
"description": phase_info.description,
"typical_months": phase_info.typical_months,
"required_actions": phase_info.required_actions,
"optional_actions": phase_info.optional_actions,
}
)
@router.get("/phase")
async def get_current_phase(teacher_id: str = Query("demo-teacher")):
"""Gibt die aktuelle Phase mit Details zurück."""
ctx = get_or_create_context(teacher_id)
phase_info = get_phase_info(ctx.current_phase)
return {
"current_phase": ctx.current_phase.value,
"phase_info": {
"display_name": phase_info.display_name,
"description": phase_info.description,
"expected_duration_weeks": phase_info.expected_duration_weeks,
},
"days_in_phase": ctx.days_in_phase,
"progress": _phase_service.get_progress_percentage(ctx),
}
@router.get("/phases")
async def get_all_phases():
"""Gibt alle Phasen mit Metadaten zurück."""
return {
"phases": _phase_service.get_all_phases()
}
@router.get("/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(teacher_id: str = Query("demo-teacher")):
"""Gibt Vorschläge basierend auf dem aktuellen Kontext zurück."""
ctx = get_or_create_context(teacher_id)
ctx = update_context_from_services(ctx)
suggestions = _engine.get_suggestions(ctx)
priority_counts = _engine.count_by_priority(ctx)
return SuggestionsResponse(
suggestions=[s.to_dict() for s in suggestions],
current_phase=ctx.current_phase.value,
phase_display_name=get_phase_display_name(ctx.current_phase.value),
priority_counts=priority_counts,
)
@router.get("/suggestions/top")
async def get_top_suggestion(teacher_id: str = Query("demo-teacher")):
"""Gibt den wichtigsten einzelnen Vorschlag zurück."""
ctx = get_or_create_context(teacher_id)
ctx = update_context_from_services(ctx)
suggestion = _engine.get_top_suggestion(ctx)
if not suggestion:
return {
"suggestion": None,
"message": "Alles erledigt! Keine offenen Aufgaben."
}
return {
"suggestion": suggestion.to_dict()
}
@router.get("/dashboard", response_model=DashboardResponse)
async def get_dashboard_data(teacher_id: str = Query("demo-teacher")):
"""Gibt alle Daten für das Begleiter-Dashboard zurück."""
ctx = get_or_create_context(teacher_id)
ctx = update_context_from_services(ctx)
suggestions = _engine.get_suggestions(ctx)
phase_info = get_phase_info(ctx.current_phase)
required = set(phase_info.required_actions)
completed = set(ctx.completed_milestones)
completed_in_phase = len(required.intersection(completed))
all_phases = []
phase_order = [
SchoolYearPhase.ONBOARDING,
SchoolYearPhase.SCHOOL_YEAR_START,
SchoolYearPhase.TEACHING_SETUP,
SchoolYearPhase.PERFORMANCE_1,
SchoolYearPhase.SEMESTER_END,
SchoolYearPhase.TEACHING_2,
SchoolYearPhase.PERFORMANCE_2,
SchoolYearPhase.YEAR_END,
]
current_idx = phase_order.index(ctx.current_phase) if ctx.current_phase in phase_order else 0
for i, phase in enumerate(phase_order):
info = get_phase_info(phase)
all_phases.append({
"phase": phase.value,
"display_name": info.display_name,
"short_name": info.display_name[:10],
"is_current": phase == ctx.current_phase,
"is_completed": i < current_idx,
"is_future": i > current_idx,
})
return DashboardResponse(
context={
"current_phase": ctx.current_phase.value,
"phase_display_name": phase_info.display_name,
"phase_description": phase_info.description,
"weeks_since_start": ctx.weeks_since_start,
"days_in_phase": ctx.days_in_phase,
"federal_state": ctx.federal_state,
"school_type": ctx.school_type,
},
suggestions=[s.to_dict() for s in suggestions],
stats={
"learning_units_created": ctx.stats.learning_units_created,
"exams_scheduled": ctx.stats.exams_scheduled,
"exams_graded": ctx.stats.exams_graded,
"grades_entered": ctx.stats.grades_entered,
"classes_count": len(ctx.classes),
"students_count": ctx.total_students,
},
upcoming_events=[
{
"type": e.type,
"title": e.title,
"date": e.date.isoformat(),
"in_days": e.in_days,
"priority": e.priority,
}
for e in ctx.upcoming_events[:5]
],
progress={
"completed": completed_in_phase,
"total": len(required),
"percentage": (completed_in_phase / len(required) * 100) if required else 100,
"milestones_completed": list(completed.intersection(required)),
"milestones_pending": list(required - completed),
},
phases=all_phases,
)
@router.post("/milestone")
async def complete_milestone(
request: MilestoneRequest,
teacher_id: str = Query("demo-teacher")
):
"""Markiert einen Meilenstein als erledigt."""
milestone = request.milestone
if teacher_id not in _milestones:
_milestones[teacher_id] = []
if milestone not in _milestones[teacher_id]:
_milestones[teacher_id].append(milestone)
logger.info(f"Milestone '{milestone}' completed for teacher {teacher_id}")
ctx = get_or_create_context(teacher_id)
ctx.completed_milestones = _milestones[teacher_id]
_teacher_contexts[teacher_id] = ctx
new_phase = _phase_service.check_and_transition(ctx)
if new_phase:
ctx.current_phase = new_phase
ctx.phase_entered_at = datetime.now()
ctx.days_in_phase = 0
_teacher_contexts[teacher_id] = ctx
logger.info(f"Auto-transitioned to {new_phase} for teacher {teacher_id}")
return {
"success": True,
"milestone": milestone,
"new_phase": new_phase.value if new_phase else None,
"current_phase": ctx.current_phase.value,
"completed_milestones": ctx.completed_milestones,
}
@router.post("/transition")
async def transition_phase(
request: TransitionRequest,
teacher_id: str = Query("demo-teacher")
):
"""Führt einen manuellen Phasen-Übergang durch."""
try:
target_phase = SchoolYearPhase(request.target_phase)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Ungültige Phase: {request.target_phase}"
)
ctx = get_or_create_context(teacher_id)
if not _phase_service.can_transition_to(ctx, target_phase):
raise HTTPException(
status_code=400,
detail=f"Übergang von {ctx.current_phase.value} zu {target_phase.value} nicht erlaubt"
)
old_phase = ctx.current_phase
ctx.current_phase = target_phase
ctx.phase_entered_at = datetime.now()
ctx.days_in_phase = 0
_teacher_contexts[teacher_id] = ctx
logger.info(f"Manual transition from {old_phase} to {target_phase} for teacher {teacher_id}")
return {
"success": True,
"old_phase": old_phase.value,
"new_phase": target_phase.value,
"phase_info": get_phase_info(target_phase).__dict__,
}
@router.get("/next-phase")
async def get_next_phase(teacher_id: str = Query("demo-teacher")):
"""Gibt die nächste Phase und Anforderungen zurück."""
ctx = get_or_create_context(teacher_id)
next_phase = _phase_service.get_next_phase(ctx.current_phase)
if not next_phase:
return {
"next_phase": None,
"message": "Letzte Phase erreicht"
}
can_transition = _phase_service.can_transition_to(ctx, next_phase)
next_info = get_phase_info(next_phase)
current_info = get_phase_info(ctx.current_phase)
missing = [
m for m in current_info.required_actions
if m not in ctx.completed_milestones
]
return {
"current_phase": ctx.current_phase.value,
"next_phase": next_phase.value,
"next_phase_info": {
"display_name": next_info.display_name,
"description": next_info.description,
},
"can_transition": can_transition,
"missing_requirements": missing,
}
# ============================================================================
# Demo Data Endpoints (nur für Entwicklung)
# ============================================================================
@router.post("/demo/add-class")
async def demo_add_class(
name: str = Query(...),
grade_level: int = Query(...),
student_count: int = Query(25),
teacher_id: str = Query("demo-teacher")
):
"""Demo: Fügt eine Klasse zum Kontext hinzu."""
ctx = get_or_create_context(teacher_id)
ctx.classes.append(ClassSummary(
class_id=str(uuid.uuid4()),
name=name,
grade_level=grade_level,
student_count=student_count,
subject="Deutsch"
))
ctx.total_students += student_count
_teacher_contexts[teacher_id] = ctx
return {"success": True, "classes": len(ctx.classes)}
@router.post("/demo/add-event")
async def demo_add_event(
event_type: str = Query(...),
title: str = Query(...),
in_days: int = Query(...),
teacher_id: str = Query("demo-teacher")
):
"""Demo: Fügt ein Event zum Kontext hinzu."""
ctx = get_or_create_context(teacher_id)
ctx.upcoming_events.append(Event(
type=event_type,
title=title,
date=datetime.now() + timedelta(days=in_days),
in_days=in_days,
priority="high" if in_days <= 3 else "medium"
))
_teacher_contexts[teacher_id] = ctx
return {"success": True, "events": len(ctx.upcoming_events)}
@router.post("/demo/update-stats")
async def demo_update_stats(
learning_units: int = Query(0),
exams_scheduled: int = Query(0),
exams_graded: int = Query(0),
grades_entered: int = Query(0),
unanswered_messages: int = Query(0),
teacher_id: str = Query("demo-teacher")
):
"""Demo: Aktualisiert Statistiken."""
ctx = get_or_create_context(teacher_id)
if learning_units:
ctx.stats.learning_units_created = learning_units
if exams_scheduled:
ctx.stats.exams_scheduled = exams_scheduled
if exams_graded:
ctx.stats.exams_graded = exams_graded
if grades_entered:
ctx.stats.grades_entered = grades_entered
if unanswered_messages:
ctx.stats.unanswered_messages = unanswered_messages
_teacher_contexts[teacher_id] = ctx
return {"success": True, "stats": ctx.stats.__dict__}
@router.post("/demo/reset")
async def demo_reset(teacher_id: str = Query("demo-teacher")):
"""Demo: Setzt den Kontext zurück."""
if teacher_id in _teacher_contexts:
del _teacher_contexts[teacher_id]
if teacher_id in _milestones:
del _milestones[teacher_id]
return {"success": True, "message": "Kontext zurückgesetzt"}