Files
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

727 lines
26 KiB
Python

"""
Classroom API - Context Routes
School year context, events, routines, and suggestions endpoints (Phase 8).
"""
from typing import Dict, Any, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query, Depends
from classroom_engine import (
FEDERAL_STATES,
SCHOOL_TYPES,
MacroPhaseEnum,
)
from ..models import (
TeacherContextResponse,
SchoolInfo,
SchoolYearInfo,
MacroPhaseInfo,
CoreCounts,
ContextFlags,
UpdateContextRequest,
CreateEventRequest,
EventResponse,
CreateRoutineRequest,
RoutineResponse,
)
from ..services.persistence import (
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Context"])
def get_db():
"""Database session dependency."""
if DB_ENABLED and SessionLocal:
db = SessionLocal()
try:
yield db
finally:
db.close()
else:
yield None
def _get_macro_phase_label(phase) -> str:
"""Gibt den Anzeigenamen einer Makro-Phase zurueck."""
labels = {
"onboarding": "Einrichtung",
"schuljahresstart": "Schuljahresstart",
"unterrichtsaufbau": "Unterrichtsaufbau",
"leistungsphase_1": "Leistungsphase 1",
"halbjahresabschluss": "Halbjahresabschluss",
"leistungsphase_2": "Leistungsphase 2",
"jahresabschluss": "Jahresabschluss",
}
phase_value = phase.value if hasattr(phase, 'value') else str(phase)
return labels.get(phase_value, phase_value)
# === Context Endpoints ===
@router.get("/v1/context", response_model=TeacherContextResponse)
async def get_teacher_context(
teacher_id: str = Query(..., description="Teacher ID"),
db=Depends(get_db)
):
"""
Liefert den aktuellen Makro-Kontext eines Lehrers.
Der Kontext beinhaltet:
- Schul-Informationen (Bundesland, Schulart)
- Schuljahr-Daten (aktuelles Jahr, Woche)
- Makro-Phase (ONBOARDING bis JAHRESABSCHLUSS)
- Zaehler (Klassen, geplante Klausuren, etc.)
- Status-Flags (Onboarding abgeschlossen, etc.)
"""
if DB_ENABLED and db:
try:
from classroom_engine.repository import TeacherContextRepository, SchoolyearEventRepository
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
# Zaehler berechnen
event_repo = SchoolyearEventRepository(db)
upcoming_exams = event_repo.get_upcoming(teacher_id, days=30)
exams_count = len([e for e in upcoming_exams if e.event_type.value == "exam"])
return TeacherContextResponse(
schema_version="1.0",
teacher_id=teacher_id,
school=SchoolInfo(
federal_state=context.federal_state or "BY",
federal_state_name=FEDERAL_STATES.get(context.federal_state, ""),
school_type=context.school_type or "gymnasium",
school_type_name=SCHOOL_TYPES.get(context.school_type, ""),
),
school_year=SchoolYearInfo(
id=context.schoolyear or "2024-2025",
start=context.schoolyear_start.isoformat() if context.schoolyear_start else None,
current_week=context.current_week or 1,
),
macro_phase=MacroPhaseInfo(
id=context.macro_phase.value,
label=_get_macro_phase_label(context.macro_phase),
confidence=1.0,
),
core_counts=CoreCounts(
classes=1 if context.has_classes else 0,
exams_scheduled=exams_count,
corrections_pending=0,
),
flags=ContextFlags(
onboarding_completed=context.onboarding_completed,
has_classes=context.has_classes,
has_schedule=context.has_schedule,
is_exam_period=context.is_exam_period,
is_before_holidays=context.is_before_holidays,
),
)
except Exception as e:
logger.error(f"Failed to get teacher context: {e}")
raise HTTPException(status_code=500, detail=f"Fehler beim Laden des Kontexts: {e}")
# Fallback ohne DB
return TeacherContextResponse(
schema_version="1.0",
teacher_id=teacher_id,
school=SchoolInfo(
federal_state="BY",
federal_state_name="Bayern",
school_type="gymnasium",
school_type_name="Gymnasium",
),
school_year=SchoolYearInfo(
id="2024-2025",
start=None,
current_week=1,
),
macro_phase=MacroPhaseInfo(
id="onboarding",
label="Einrichtung",
confidence=1.0,
),
core_counts=CoreCounts(),
flags=ContextFlags(),
)
@router.put("/v1/context", response_model=TeacherContextResponse)
async def update_teacher_context(
teacher_id: str,
request: UpdateContextRequest,
db=Depends(get_db)
):
"""
Aktualisiert den Kontext eines Lehrers.
"""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import TeacherContextRepository
repo = TeacherContextRepository(db)
# Validierung
if request.federal_state and request.federal_state not in FEDERAL_STATES:
raise HTTPException(status_code=400, detail=f"Ungueltiges Bundesland: {request.federal_state}")
if request.school_type and request.school_type not in SCHOOL_TYPES:
raise HTTPException(status_code=400, detail=f"Ungueltige Schulart: {request.school_type}")
# Parse datetime if provided
schoolyear_start = None
if request.schoolyear_start:
schoolyear_start = datetime.fromisoformat(request.schoolyear_start.replace('Z', '+00:00'))
repo.update_context(
teacher_id=teacher_id,
federal_state=request.federal_state,
school_type=request.school_type,
schoolyear=request.schoolyear,
schoolyear_start=schoolyear_start,
macro_phase=request.macro_phase,
current_week=request.current_week,
)
return await get_teacher_context(teacher_id, db)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update teacher context: {e}")
raise HTTPException(status_code=500, detail=f"Fehler beim Aktualisieren: {e}")
@router.post("/v1/context/complete-onboarding")
async def complete_onboarding(
teacher_id: str = Query(...),
db=Depends(get_db)
):
"""Markiert das Onboarding als abgeschlossen."""
if not DB_ENABLED or not db:
return {"success": True, "macro_phase": "schuljahresstart", "note": "DB not available"}
try:
from classroom_engine.repository import TeacherContextRepository
repo = TeacherContextRepository(db)
context = repo.complete_onboarding(teacher_id)
return {
"success": True,
"macro_phase": context.macro_phase.value,
"teacher_id": teacher_id,
}
except Exception as e:
logger.error(f"Failed to complete onboarding: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.post("/v1/context/reset-onboarding")
async def reset_onboarding(
teacher_id: str = Query(...),
db=Depends(get_db)
):
"""Setzt das Onboarding zurueck (fuer Tests)."""
if not DB_ENABLED or not db:
return {"success": True, "macro_phase": "onboarding", "note": "DB not available"}
try:
from classroom_engine.repository import TeacherContextRepository
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
context.onboarding_completed = False
context.macro_phase = MacroPhaseEnum.ONBOARDING
db.commit()
db.refresh(context)
return {
"success": True,
"macro_phase": "onboarding",
"teacher_id": teacher_id,
}
except Exception as e:
logger.error(f"Failed to reset onboarding: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
# === Events Endpoints ===
@router.get("/v1/events")
async def get_events(
teacher_id: str = Query(...),
status: Optional[str] = None,
event_type: Optional[str] = None,
limit: int = 50,
db=Depends(get_db)
):
"""Holt Events eines Lehrers."""
if not DB_ENABLED or not db:
return {"events": [], "count": 0}
try:
from classroom_engine.repository import SchoolyearEventRepository
repo = SchoolyearEventRepository(db)
events = repo.get_by_teacher(teacher_id, status=status, event_type=event_type, limit=limit)
return {
"events": [repo.to_dict(e) for e in events],
"count": len(events),
}
except Exception as e:
logger.error(f"Failed to get events: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.get("/v1/events/upcoming")
async def get_upcoming_events(
teacher_id: str = Query(...),
days: int = 30,
limit: int = 10,
db=Depends(get_db)
):
"""Holt anstehende Events der naechsten X Tage."""
if not DB_ENABLED or not db:
return {"events": [], "count": 0}
try:
from classroom_engine.repository import SchoolyearEventRepository
repo = SchoolyearEventRepository(db)
events = repo.get_upcoming(teacher_id, days=days, limit=limit)
return {
"events": [repo.to_dict(e) for e in events],
"count": len(events),
}
except Exception as e:
logger.error(f"Failed to get upcoming events: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.post("/v1/events", response_model=EventResponse)
async def create_event(
teacher_id: str,
request: CreateEventRequest,
db=Depends(get_db)
):
"""Erstellt ein neues Schuljahr-Event."""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import SchoolyearEventRepository
repo = SchoolyearEventRepository(db)
start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00'))
end_date = None
if request.end_date:
end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00'))
event = repo.create(
teacher_id=teacher_id,
title=request.title,
event_type=request.event_type,
start_date=start_date,
end_date=end_date,
class_id=request.class_id,
subject=request.subject,
description=request.description,
needs_preparation=request.needs_preparation,
reminder_days_before=request.reminder_days_before,
)
return EventResponse(
id=event.id,
teacher_id=event.teacher_id,
event_type=event.event_type.value,
title=event.title,
description=event.description,
start_date=event.start_date.isoformat(),
end_date=event.end_date.isoformat() if event.end_date else None,
class_id=event.class_id,
subject=event.subject,
status=event.status.value,
needs_preparation=event.needs_preparation,
preparation_done=event.preparation_done,
reminder_days_before=event.reminder_days_before,
)
except Exception as e:
logger.error(f"Failed to create event: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.delete("/v1/events/{event_id}")
async def delete_event(event_id: str, db=Depends(get_db)):
"""Loescht ein Event."""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import SchoolyearEventRepository
repo = SchoolyearEventRepository(db)
if repo.delete(event_id):
return {"success": True, "deleted_id": event_id}
raise HTTPException(status_code=404, detail="Event nicht gefunden")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete event: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
# === Routines Endpoints ===
@router.get("/v1/routines")
async def get_routines(
teacher_id: str = Query(...),
is_active: bool = True,
routine_type: Optional[str] = None,
db=Depends(get_db)
):
"""Holt Routinen eines Lehrers."""
if not DB_ENABLED or not db:
return {"routines": [], "count": 0}
try:
from classroom_engine.repository import RecurringRoutineRepository
repo = RecurringRoutineRepository(db)
routines = repo.get_by_teacher(teacher_id, is_active=is_active, routine_type=routine_type)
return {
"routines": [repo.to_dict(r) for r in routines],
"count": len(routines),
}
except Exception as e:
logger.error(f"Failed to get routines: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.get("/v1/routines/today")
async def get_today_routines(teacher_id: str = Query(...), db=Depends(get_db)):
"""Holt Routinen die heute stattfinden."""
if not DB_ENABLED or not db:
return {"routines": [], "count": 0}
try:
from classroom_engine.repository import RecurringRoutineRepository
repo = RecurringRoutineRepository(db)
routines = repo.get_today(teacher_id)
return {
"routines": [repo.to_dict(r) for r in routines],
"count": len(routines),
}
except Exception as e:
logger.error(f"Failed to get today's routines: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.post("/v1/routines", response_model=RoutineResponse)
async def create_routine(
teacher_id: str,
request: CreateRoutineRequest,
db=Depends(get_db)
):
"""Erstellt eine neue wiederkehrende Routine."""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import RecurringRoutineRepository
repo = RecurringRoutineRepository(db)
routine = repo.create(
teacher_id=teacher_id,
title=request.title,
routine_type=request.routine_type,
recurrence_pattern=request.recurrence_pattern,
day_of_week=request.day_of_week,
day_of_month=request.day_of_month,
time_of_day=request.time_of_day,
duration_minutes=request.duration_minutes,
description=request.description,
)
return RoutineResponse(
id=routine.id,
teacher_id=routine.teacher_id,
routine_type=routine.routine_type.value,
title=routine.title,
description=routine.description,
recurrence_pattern=routine.recurrence_pattern.value,
day_of_week=routine.day_of_week,
day_of_month=routine.day_of_month,
time_of_day=routine.time_of_day.isoformat() if routine.time_of_day else None,
duration_minutes=routine.duration_minutes,
is_active=routine.is_active,
)
except Exception as e:
logger.error(f"Failed to create routine: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.delete("/v1/routines/{routine_id}")
async def delete_routine(routine_id: str, db=Depends(get_db)):
"""Loescht eine Routine."""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import RecurringRoutineRepository
repo = RecurringRoutineRepository(db)
if repo.delete(routine_id):
return {"success": True, "deleted_id": routine_id}
raise HTTPException(status_code=404, detail="Routine nicht gefunden")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete routine: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
# === Static Data Endpoints ===
@router.get("/v1/federal-states")
async def get_federal_states():
"""Gibt alle Bundeslaender zurueck."""
return {
"federal_states": [{"id": k, "name": v} for k, v in FEDERAL_STATES.items()]
}
@router.get("/v1/school-types")
async def get_school_types():
"""Gibt alle Schularten zurueck."""
return {
"school_types": [{"id": k, "name": v} for k, v in SCHOOL_TYPES.items()]
}
@router.get("/v1/macro-phases")
async def get_macro_phases():
"""Gibt alle Makro-Phasen mit Beschreibungen zurueck."""
phases = [
{"id": "onboarding", "label": "Einrichtung", "description": "Ersteinrichtung (Klassen, Stundenplan)", "order": 1},
{"id": "schuljahresstart", "label": "Schuljahresstart", "description": "Erste 2-3 Wochen des Schuljahres", "order": 2},
{"id": "unterrichtsaufbau", "label": "Unterrichtsaufbau", "description": "Routinen etablieren, erste Bewertungen", "order": 3},
{"id": "leistungsphase_1", "label": "Leistungsphase 1", "description": "Erste Klassenarbeiten und Klausuren", "order": 4},
{"id": "halbjahresabschluss", "label": "Halbjahresabschluss", "description": "Notenschluss, Zeugnisse, Konferenzen", "order": 5},
{"id": "leistungsphase_2", "label": "Leistungsphase 2", "description": "Zweites Halbjahr, Pruefungsvorbereitung", "order": 6},
{"id": "jahresabschluss", "label": "Jahresabschluss", "description": "Finale Noten, Versetzung, Schuljahresende", "order": 7},
]
return {"macro_phases": phases}
@router.get("/v1/event-types")
async def get_event_types():
"""Gibt alle Event-Typen zurueck."""
types = [
{"id": "exam", "label": "Klassenarbeit/Klausur"},
{"id": "parent_evening", "label": "Elternabend"},
{"id": "trip", "label": "Klassenfahrt/Ausflug"},
{"id": "project", "label": "Projektwoche"},
{"id": "internship", "label": "Praktikum"},
{"id": "presentation", "label": "Referate/Praesentationen"},
{"id": "sports_day", "label": "Sporttag"},
{"id": "school_festival", "label": "Schulfest"},
{"id": "parent_consultation", "label": "Elternsprechtag"},
{"id": "grade_deadline", "label": "Notenschluss"},
{"id": "report_cards", "label": "Zeugnisausgabe"},
{"id": "holiday_start", "label": "Ferienbeginn"},
{"id": "holiday_end", "label": "Ferienende"},
{"id": "other", "label": "Sonstiges"},
]
return {"event_types": types}
@router.get("/v1/routine-types")
async def get_routine_types():
"""Gibt alle Routine-Typen zurueck."""
types = [
{"id": "teacher_conference", "label": "Lehrerkonferenz"},
{"id": "subject_conference", "label": "Fachkonferenz"},
{"id": "office_hours", "label": "Sprechstunde"},
{"id": "team_meeting", "label": "Teamsitzung"},
{"id": "supervision", "label": "Pausenaufsicht"},
{"id": "correction_time", "label": "Korrekturzeit"},
{"id": "prep_time", "label": "Vorbereitungszeit"},
{"id": "other", "label": "Sonstiges"},
]
return {"routine_types": types}
# === Suggestions & Sidebar ===
@router.get("/v1/suggestions")
async def get_suggestions(
teacher_id: str = Query(...),
limit: int = Query(5, ge=1, le=20),
db=Depends(get_db)
):
"""Generiert kontextbasierte Vorschlaege fuer einen Lehrer."""
if DB_ENABLED and db:
try:
from classroom_engine.suggestions import SuggestionGenerator
generator = SuggestionGenerator(db)
result = generator.generate(teacher_id, limit=limit)
return result
except Exception as e:
logger.error(f"Failed to generate suggestions: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
return {
"active_contexts": [],
"suggestions": [],
"signals_summary": {
"macro_phase": "onboarding",
"current_week": 1,
"has_classes": False,
"exams_soon": 0,
"routines_today": 0,
},
"total_suggestions": 0,
}
@router.get("/v1/sidebar")
async def get_sidebar(
teacher_id: str = Query(...),
mode: str = Query("companion"),
db=Depends(get_db)
):
"""Generiert das dynamische Sidebar-Model."""
if mode == "companion":
now_relevant = []
if DB_ENABLED and db:
try:
from classroom_engine.suggestions import SuggestionGenerator
generator = SuggestionGenerator(db)
result = generator.generate(teacher_id, limit=5)
now_relevant = [
{
"id": s["id"],
"label": s["title"],
"state": "recommended" if s["priority"] > 70 else "default",
"badge": s.get("badge"),
"icon": s.get("icon", "lightbulb"),
"action_url": s.get("action_url"),
}
for s in result.get("suggestions", [])
]
except Exception as e:
logger.warning(f"Failed to get suggestions for sidebar: {e}")
return {
"mode": "companion",
"sections": [
{"id": "SEARCH", "type": "search_bar", "placeholder": "Suchen..."},
{
"id": "NOW_RELEVANT",
"type": "list",
"title": "Jetzt relevant",
"items": now_relevant if now_relevant else [
{"id": "no_suggestions", "label": "Keine Vorschlaege", "state": "default", "icon": "check_circle"}
],
},
{
"id": "ALL_MODULES",
"type": "folder",
"label": "Alle Module",
"icon": "folder",
"collapsed": True,
"items": [
{"id": "lesson", "label": "Stundenmodus", "icon": "timer"},
{"id": "classes", "label": "Klassen", "icon": "groups"},
{"id": "exams", "label": "Klausuren", "icon": "quiz"},
{"id": "grades", "label": "Noten", "icon": "calculate"},
{"id": "calendar", "label": "Kalender", "icon": "calendar_month"},
{"id": "materials", "label": "Materialien", "icon": "folder_open"},
],
},
{
"id": "QUICK_ACTIONS",
"type": "actions",
"title": "Kurzaktionen",
"items": [
{"id": "scan", "label": "Scan hochladen", "icon": "upload_file"},
{"id": "note", "label": "Notiz erstellen", "icon": "note_add"},
],
},
],
}
else:
return {
"mode": "classic",
"sections": [
{
"id": "NAVIGATION",
"type": "tree",
"items": [
{"id": "dashboard", "label": "Dashboard", "icon": "dashboard", "url": "/dashboard"},
{"id": "lesson", "label": "Stundenmodus", "icon": "timer", "url": "/lesson"},
{"id": "classes", "label": "Klassen", "icon": "groups", "url": "/classes"},
{"id": "exams", "label": "Klausuren", "icon": "quiz", "url": "/exams"},
{"id": "grades", "label": "Noten", "icon": "calculate", "url": "/grades"},
{"id": "calendar", "label": "Kalender", "icon": "calendar_month", "url": "/calendar"},
{"id": "materials", "label": "Materialien", "icon": "folder_open", "url": "/materials"},
{"id": "settings", "label": "Einstellungen", "icon": "settings", "url": "/settings"},
],
},
],
}
@router.get("/v1/path")
async def get_schoolyear_path(teacher_id: str = Query(...), db=Depends(get_db)):
"""Generiert den Schuljahres-Pfad mit Meilensteinen."""
current_phase = "onboarding"
if DB_ENABLED and db:
try:
from classroom_engine.repository import TeacherContextRepository
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
current_phase = context.macro_phase.value
except Exception as e:
logger.warning(f"Failed to get context for path: {e}")
phase_order = [
"onboarding", "schuljahresstart", "unterrichtsaufbau",
"leistungsphase_1", "halbjahresabschluss", "leistungsphase_2", "jahresabschluss",
]
current_index = phase_order.index(current_phase) if current_phase in phase_order else 0
milestones = [
{"id": "MS_START", "label": "Start", "phase": "onboarding", "icon": "flag"},
{"id": "MS_SETUP", "label": "Einrichtung", "phase": "schuljahresstart", "icon": "tune"},
{"id": "MS_ROUTINE", "label": "Routinen", "phase": "unterrichtsaufbau", "icon": "repeat"},
{"id": "MS_EXAM_1", "label": "Klausuren", "phase": "leistungsphase_1", "icon": "quiz"},
{"id": "MS_HALFYEAR", "label": "Halbjahr", "phase": "halbjahresabschluss", "icon": "event"},
{"id": "MS_EXAM_2", "label": "Pruefungen", "phase": "leistungsphase_2", "icon": "school"},
{"id": "MS_END", "label": "Abschluss", "phase": "jahresabschluss", "icon": "celebration"},
]
for milestone in milestones:
phase = milestone["phase"]
phase_index = phase_order.index(phase) if phase in phase_order else 999
if phase_index < current_index:
milestone["status"] = "done"
elif phase_index == current_index:
milestone["status"] = "current"
else:
milestone["status"] = "upcoming"
current_milestone_id = next(
(m["id"] for m in milestones if m["status"] == "current"),
milestones[0]["id"]
)
progress = int((current_index / (len(phase_order) - 1)) * 100) if len(phase_order) > 1 else 0
return {
"milestones": milestones,
"current_milestone_id": current_milestone_id,
"progress_percent": progress,
"current_phase": current_phase,
}