""" 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, Optional from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field from state_engine import ( AnticipationEngine, PhaseService, TeacherContext, SchoolYearPhase, ClassSummary, Event, TeacherStats, get_phase_info, PHASE_INFO ) logger = logging.getLogger(__name__) router = APIRouter( prefix="/state", tags=["state-engine"], ) # Singleton instances _engine = AnticipationEngine() _phase_service = PhaseService() # ============================================================================ # In-Memory Storage (später durch DB ersetzen) # ============================================================================ # Simulierter Lehrer-Kontext (in Produktion aus DB) _teacher_contexts: Dict[str, TeacherContext] = {} _milestones: Dict[str, List[str]] = {} # teacher_id -> milestones # ============================================================================ # Pydantic Models # ============================================================================ class MilestoneRequest(BaseModel): """Request zum Abschließen eines Meilensteins.""" milestone: str = Field(..., description="Name des Meilensteins") class TransitionRequest(BaseModel): """Request für Phasen-Übergang.""" target_phase: str = Field(..., description="Zielphase") class ContextResponse(BaseModel): """Response mit TeacherContext.""" context: Dict[str, Any] phase_info: Dict[str, Any] class SuggestionsResponse(BaseModel): """Response mit Vorschlägen.""" suggestions: List[Dict[str, Any]] current_phase: str phase_display_name: str priority_counts: Dict[str, int] class DashboardResponse(BaseModel): """Response mit Dashboard-Daten.""" context: Dict[str, Any] suggestions: List[Dict[str, Any]] stats: Dict[str, Any] upcoming_events: List[Dict[str, Any]] progress: Dict[str, Any] phases: List[Dict[str, Any]] # ============================================================================ # Helper Functions # ============================================================================ def _get_or_create_context(teacher_id: str) -> TeacherContext: """ Holt oder erstellt TeacherContext. In Produktion würde dies aus der Datenbank geladen. """ if teacher_id not in _teacher_contexts: # Erstelle Demo-Kontext now = datetime.now() school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1) weeks_since_start = (now - school_year_start).days // 7 # Bestimme Phase basierend auf Monat month = now.month if month in [8, 9]: phase = SchoolYearPhase.SCHOOL_YEAR_START elif month in [10, 11]: phase = SchoolYearPhase.TEACHING_SETUP elif month == 12: phase = SchoolYearPhase.PERFORMANCE_1 elif month in [1, 2]: phase = SchoolYearPhase.SEMESTER_END elif month in [3, 4]: phase = SchoolYearPhase.TEACHING_2 elif month in [5, 6]: phase = SchoolYearPhase.PERFORMANCE_2 else: phase = SchoolYearPhase.YEAR_END _teacher_contexts[teacher_id] = TeacherContext( teacher_id=teacher_id, school_id=str(uuid.uuid4()), school_year_id=str(uuid.uuid4()), federal_state="niedersachsen", school_type="gymnasium", school_year_start=school_year_start, current_phase=phase, phase_entered_at=now - timedelta(days=7), weeks_since_start=weeks_since_start, days_in_phase=7, classes=[], total_students=0, upcoming_events=[], completed_milestones=_milestones.get(teacher_id, []), pending_milestones=[], stats=TeacherStats(), ) return _teacher_contexts[teacher_id] def _update_context_from_services(ctx: TeacherContext) -> TeacherContext: """ Aktualisiert Kontext mit Daten aus anderen Services. In Produktion würde dies von school-service, gradebook etc. laden. """ # Simulierte Daten - in Produktion API-Calls # Hier könnten wir den Kontext mit echten Daten anreichern # Berechne days_in_phase ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days # Lade abgeschlossene Meilensteine ctx.completed_milestones = _milestones.get(ctx.teacher_id, []) # Berechne pending milestones phase_info = get_phase_info(ctx.current_phase) ctx.pending_milestones = [ m for m in phase_info.required_actions if m not in ctx.completed_milestones ] return ctx def _get_phase_display_name(phase: str) -> str: """Gibt Display-Name für Phase zurück.""" try: return get_phase_info(SchoolYearPhase(phase)).display_name except (ValueError, KeyError): return phase # ============================================================================ # 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. Enthält alle relevanten Informationen für: - Phasen-Anzeige - Antizipations-Engine - Dashboard """ 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. Nützlich für die Phasen-Anzeige im Dashboard. """ 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. Die Vorschläge sind priorisiert und auf max. 5 limitiert. """ 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. Kombiniert: - TeacherContext - Vorschläge - Statistiken - Termine - Fortschritt """ 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) # Berechne Fortschritt required = set(phase_info.required_actions) completed = set(ctx.completed_milestones) completed_in_phase = len(required.intersection(completed)) # Alle Phasen für Anzeige 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. Prüft automatisch ob ein Phasen-Übergang möglich ist. """ milestone = request.milestone # Speichere Meilenstein 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}") # Aktualisiere Kontext ctx = _get_or_create_context(teacher_id) ctx.completed_milestones = _milestones[teacher_id] _teacher_contexts[teacher_id] = ctx # Prüfe automatischen Phasen-Übergang 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) # Prüfe ob Übergang erlaubt 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" ) # Führe Übergang durch 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) # Fehlende Anforderungen 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"}