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>
411 lines
13 KiB
Python
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"}
|