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>
This commit is contained in:
583
backend/state_engine_api.py
Normal file
583
backend/state_engine_api.py
Normal file
@@ -0,0 +1,583 @@
|
||||
"""
|
||||
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"}
|
||||
Reference in New Issue
Block a user