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