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/backend/state_engine_api.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

584 lines
18 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, 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"}