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:
91
backend/classroom_engine/__init__.py
Normal file
91
backend/classroom_engine/__init__.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Classroom Engine - State Machine fuer Unterrichtsphasen
|
||||
|
||||
Dieses Package implementiert eine Finite State Machine fuer die
|
||||
Steuerung von Unterrichtsstunden mit 5 Phasen:
|
||||
|
||||
1. Einstieg (8 Min) - Motivation, Problemstellung
|
||||
2. Erarbeitung (20 Min) - Hauptarbeitsphase
|
||||
3. Sicherung (10 Min) - Ergebnisse festhalten
|
||||
4. Transfer (7 Min) - Anwendung
|
||||
5. Reflexion (5 Min) - Rueckblick, Hausaufgaben
|
||||
|
||||
Features:
|
||||
- Phasen-Timer mit Countdown und Warnungen
|
||||
- Phasenspezifische Content-Vorschlaege
|
||||
- Session-History fuer Analytics
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
LessonPhase,
|
||||
PhaseConfig,
|
||||
LessonSession,
|
||||
LessonTemplate,
|
||||
PhaseSuggestion,
|
||||
Homework,
|
||||
HomeworkStatus,
|
||||
PhaseMaterial,
|
||||
MaterialType,
|
||||
LESSON_PHASES,
|
||||
SYSTEM_TEMPLATES,
|
||||
get_default_durations,
|
||||
)
|
||||
from .fsm import LessonStateMachine
|
||||
from .timer import PhaseTimer
|
||||
from .suggestions import SuggestionEngine, PHASE_SUGGESTIONS, SUBJECT_SUGGESTIONS
|
||||
from .context_models import (
|
||||
MacroPhaseEnum,
|
||||
EventTypeEnum,
|
||||
EventStatusEnum,
|
||||
RoutineTypeEnum,
|
||||
RecurrencePatternEnum,
|
||||
TeacherContextDB,
|
||||
SchoolyearEventDB,
|
||||
RecurringRoutineDB,
|
||||
FEDERAL_STATES,
|
||||
SCHOOL_TYPES,
|
||||
)
|
||||
from .antizipation import (
|
||||
SignalCollector,
|
||||
RuleEngine,
|
||||
SuggestionGenerator,
|
||||
Signals,
|
||||
Suggestion,
|
||||
SuggestionTone,
|
||||
ActiveContext,
|
||||
ContextType,
|
||||
)
|
||||
from .analytics import LessonReflection
|
||||
|
||||
__all__ = [
|
||||
"LessonPhase",
|
||||
"PhaseConfig",
|
||||
"LessonSession",
|
||||
"LessonTemplate",
|
||||
"PhaseSuggestion",
|
||||
"Homework",
|
||||
"HomeworkStatus",
|
||||
"PhaseMaterial",
|
||||
"MaterialType",
|
||||
"LESSON_PHASES",
|
||||
"SYSTEM_TEMPLATES",
|
||||
"get_default_durations",
|
||||
"LessonStateMachine",
|
||||
"PhaseTimer",
|
||||
"SuggestionEngine",
|
||||
"PHASE_SUGGESTIONS",
|
||||
"SUBJECT_SUGGESTIONS",
|
||||
# Phase 8: Schuljahres-Kontext
|
||||
"MacroPhaseEnum",
|
||||
"EventTypeEnum",
|
||||
"EventStatusEnum",
|
||||
"RoutineTypeEnum",
|
||||
"RecurrencePatternEnum",
|
||||
"TeacherContextDB",
|
||||
"SchoolyearEventDB",
|
||||
"RecurringRoutineDB",
|
||||
"FEDERAL_STATES",
|
||||
"SCHOOL_TYPES",
|
||||
# Analytics
|
||||
"LessonReflection",
|
||||
]
|
||||
520
backend/classroom_engine/analytics.py
Normal file
520
backend/classroom_engine/analytics.py
Normal file
@@ -0,0 +1,520 @@
|
||||
"""
|
||||
Analytics-Modul fuer Classroom Engine (Phase 5).
|
||||
|
||||
Bietet Statistiken und Auswertungen fuer Unterrichtsstunden:
|
||||
- Phasen-Dauer Statistiken
|
||||
- Overtime-Analyse
|
||||
- Lehrer-Dashboard Daten
|
||||
- Post-Lesson Reflection
|
||||
|
||||
WICHTIG: Keine wertenden Metriken (z.B. "Sie haben 70% geredet").
|
||||
Fokus auf neutrale, hilfreiche Statistiken.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ==================== Analytics Models ====================
|
||||
|
||||
@dataclass
|
||||
class PhaseStatistics:
|
||||
"""Statistik fuer eine einzelne Phase."""
|
||||
phase: str
|
||||
display_name: str
|
||||
|
||||
# Dauer-Metriken
|
||||
planned_duration_seconds: int
|
||||
actual_duration_seconds: int
|
||||
difference_seconds: int # positiv = laenger als geplant
|
||||
|
||||
# Overtime
|
||||
had_overtime: bool
|
||||
overtime_seconds: int = 0
|
||||
|
||||
# Erweiterungen
|
||||
was_extended: bool = False
|
||||
extension_minutes: int = 0
|
||||
|
||||
# Pausen
|
||||
pause_count: int = 0
|
||||
total_pause_seconds: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"display_name": self.display_name,
|
||||
"planned_duration_seconds": self.planned_duration_seconds,
|
||||
"actual_duration_seconds": self.actual_duration_seconds,
|
||||
"difference_seconds": self.difference_seconds,
|
||||
"difference_formatted": self._format_difference(),
|
||||
"had_overtime": self.had_overtime,
|
||||
"overtime_seconds": self.overtime_seconds,
|
||||
"overtime_formatted": self._format_seconds(self.overtime_seconds),
|
||||
"was_extended": self.was_extended,
|
||||
"extension_minutes": self.extension_minutes,
|
||||
"pause_count": self.pause_count,
|
||||
"total_pause_seconds": self.total_pause_seconds,
|
||||
}
|
||||
|
||||
def _format_difference(self) -> str:
|
||||
"""Formatiert die Differenz als +/-MM:SS."""
|
||||
prefix = "+" if self.difference_seconds >= 0 else ""
|
||||
return f"{prefix}{self._format_seconds(abs(self.difference_seconds))}"
|
||||
|
||||
def _format_seconds(self, seconds: int) -> str:
|
||||
"""Formatiert Sekunden als MM:SS."""
|
||||
mins = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{mins:02d}:{secs:02d}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionSummary:
|
||||
"""
|
||||
Zusammenfassung einer Unterrichtsstunde.
|
||||
|
||||
Wird nach Stundenende generiert und fuer das Lehrer-Dashboard verwendet.
|
||||
"""
|
||||
session_id: str
|
||||
teacher_id: str
|
||||
class_id: str
|
||||
subject: str
|
||||
topic: Optional[str]
|
||||
date: datetime
|
||||
|
||||
# Dauer
|
||||
total_duration_seconds: int
|
||||
planned_duration_seconds: int
|
||||
|
||||
# Phasen-Statistiken
|
||||
phases_completed: int
|
||||
total_phases: int = 5
|
||||
phase_statistics: List[PhaseStatistics] = field(default_factory=list)
|
||||
|
||||
# Overtime-Zusammenfassung
|
||||
total_overtime_seconds: int = 0
|
||||
phases_with_overtime: int = 0
|
||||
|
||||
# Pausen-Zusammenfassung
|
||||
total_pause_count: int = 0
|
||||
total_pause_seconds: int = 0
|
||||
|
||||
# Post-Lesson Reflection
|
||||
reflection_notes: str = ""
|
||||
reflection_rating: Optional[int] = None # 1-5 Sterne (optional)
|
||||
key_learnings: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"teacher_id": self.teacher_id,
|
||||
"class_id": self.class_id,
|
||||
"subject": self.subject,
|
||||
"topic": self.topic,
|
||||
"date": self.date.isoformat() if self.date else None,
|
||||
"date_formatted": self._format_date(),
|
||||
"total_duration_seconds": self.total_duration_seconds,
|
||||
"total_duration_formatted": self._format_seconds(self.total_duration_seconds),
|
||||
"planned_duration_seconds": self.planned_duration_seconds,
|
||||
"planned_duration_formatted": self._format_seconds(self.planned_duration_seconds),
|
||||
"phases_completed": self.phases_completed,
|
||||
"total_phases": self.total_phases,
|
||||
"completion_percentage": round(self.phases_completed / self.total_phases * 100),
|
||||
"phase_statistics": [p.to_dict() for p in self.phase_statistics],
|
||||
"total_overtime_seconds": self.total_overtime_seconds,
|
||||
"total_overtime_formatted": self._format_seconds(self.total_overtime_seconds),
|
||||
"phases_with_overtime": self.phases_with_overtime,
|
||||
"total_pause_count": self.total_pause_count,
|
||||
"total_pause_seconds": self.total_pause_seconds,
|
||||
"reflection_notes": self.reflection_notes,
|
||||
"reflection_rating": self.reflection_rating,
|
||||
"key_learnings": self.key_learnings,
|
||||
}
|
||||
|
||||
def _format_seconds(self, seconds: int) -> str:
|
||||
mins = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{mins:02d}:{secs:02d}"
|
||||
|
||||
def _format_date(self) -> str:
|
||||
if not self.date:
|
||||
return ""
|
||||
return self.date.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeacherAnalytics:
|
||||
"""
|
||||
Aggregierte Statistiken fuer einen Lehrer.
|
||||
|
||||
Zeigt Trends und Muster ueber mehrere Stunden.
|
||||
"""
|
||||
teacher_id: str
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
|
||||
# Stunden-Uebersicht
|
||||
total_sessions: int = 0
|
||||
completed_sessions: int = 0
|
||||
total_teaching_minutes: int = 0
|
||||
|
||||
# Durchschnittliche Phasendauern
|
||||
avg_phase_durations: Dict[str, float] = field(default_factory=dict)
|
||||
|
||||
# Overtime-Trends
|
||||
sessions_with_overtime: int = 0
|
||||
avg_overtime_seconds: float = 0
|
||||
most_overtime_phase: Optional[str] = None
|
||||
|
||||
# Pausen-Statistik
|
||||
avg_pause_count: float = 0
|
||||
avg_pause_duration_seconds: float = 0
|
||||
|
||||
# Faecher-Verteilung
|
||||
subjects_taught: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# Klassen-Verteilung
|
||||
classes_taught: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"teacher_id": self.teacher_id,
|
||||
"period_start": self.period_start.isoformat() if self.period_start else None,
|
||||
"period_end": self.period_end.isoformat() if self.period_end else None,
|
||||
"total_sessions": self.total_sessions,
|
||||
"completed_sessions": self.completed_sessions,
|
||||
"total_teaching_minutes": self.total_teaching_minutes,
|
||||
"total_teaching_hours": round(self.total_teaching_minutes / 60, 1),
|
||||
"avg_phase_durations": self.avg_phase_durations,
|
||||
"sessions_with_overtime": self.sessions_with_overtime,
|
||||
"overtime_percentage": round(self.sessions_with_overtime / max(self.total_sessions, 1) * 100),
|
||||
"avg_overtime_seconds": round(self.avg_overtime_seconds),
|
||||
"avg_overtime_formatted": self._format_seconds(int(self.avg_overtime_seconds)),
|
||||
"most_overtime_phase": self.most_overtime_phase,
|
||||
"avg_pause_count": round(self.avg_pause_count, 1),
|
||||
"avg_pause_duration_seconds": round(self.avg_pause_duration_seconds),
|
||||
"subjects_taught": self.subjects_taught,
|
||||
"classes_taught": self.classes_taught,
|
||||
}
|
||||
|
||||
def _format_seconds(self, seconds: int) -> str:
|
||||
mins = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{mins:02d}:{secs:02d}"
|
||||
|
||||
|
||||
# ==================== Reflection Model ====================
|
||||
|
||||
@dataclass
|
||||
class LessonReflection:
|
||||
"""
|
||||
Post-Lesson Reflection (Feature).
|
||||
|
||||
Ermoeglicht Lehrern, nach der Stunde Notizen zu machen.
|
||||
Keine Bewertung, nur Reflexion.
|
||||
"""
|
||||
reflection_id: str
|
||||
session_id: str
|
||||
teacher_id: str
|
||||
|
||||
# Reflexionsnotizen
|
||||
notes: str = ""
|
||||
|
||||
# Optional: Sterne-Bewertung (selbst-eingeschaetzt)
|
||||
overall_rating: Optional[int] = None # 1-5
|
||||
|
||||
# Was hat gut funktioniert?
|
||||
what_worked: List[str] = field(default_factory=list)
|
||||
|
||||
# Was wuerde ich naechstes Mal anders machen?
|
||||
improvements: List[str] = field(default_factory=list)
|
||||
|
||||
# Notizen fuer naechste Stunde
|
||||
notes_for_next_lesson: str = ""
|
||||
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"reflection_id": self.reflection_id,
|
||||
"session_id": self.session_id,
|
||||
"teacher_id": self.teacher_id,
|
||||
"notes": self.notes,
|
||||
"overall_rating": self.overall_rating,
|
||||
"what_worked": self.what_worked,
|
||||
"improvements": self.improvements,
|
||||
"notes_for_next_lesson": self.notes_for_next_lesson,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Analytics Calculator ====================
|
||||
|
||||
class AnalyticsCalculator:
|
||||
"""
|
||||
Berechnet Analytics aus Session-Daten.
|
||||
|
||||
Verwendet In-Memory-Daten oder DB-Daten.
|
||||
"""
|
||||
|
||||
PHASE_DISPLAY_NAMES = {
|
||||
"einstieg": "Einstieg",
|
||||
"erarbeitung": "Erarbeitung",
|
||||
"sicherung": "Sicherung",
|
||||
"transfer": "Transfer",
|
||||
"reflexion": "Reflexion",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def calculate_session_summary(
|
||||
cls,
|
||||
session_data: Dict[str, Any],
|
||||
phase_history: List[Dict[str, Any]]
|
||||
) -> SessionSummary:
|
||||
"""
|
||||
Berechnet die Zusammenfassung einer Session.
|
||||
|
||||
Args:
|
||||
session_data: Session-Dictionary (aus LessonSession.to_dict())
|
||||
phase_history: Liste der Phasen-History-Eintraege
|
||||
|
||||
Returns:
|
||||
SessionSummary mit allen berechneten Statistiken
|
||||
"""
|
||||
# Basis-Daten
|
||||
session_id = session_data.get("session_id", "")
|
||||
teacher_id = session_data.get("teacher_id", "")
|
||||
class_id = session_data.get("class_id", "")
|
||||
subject = session_data.get("subject", "")
|
||||
topic = session_data.get("topic")
|
||||
|
||||
# Timestamps
|
||||
lesson_started = session_data.get("lesson_started_at")
|
||||
lesson_ended = session_data.get("lesson_ended_at")
|
||||
|
||||
if isinstance(lesson_started, str):
|
||||
lesson_started = datetime.fromisoformat(lesson_started.replace("Z", "+00:00"))
|
||||
if isinstance(lesson_ended, str):
|
||||
lesson_ended = datetime.fromisoformat(lesson_ended.replace("Z", "+00:00"))
|
||||
|
||||
# Dauer berechnen
|
||||
total_duration = 0
|
||||
if lesson_started and lesson_ended:
|
||||
total_duration = int((lesson_ended - lesson_started).total_seconds())
|
||||
|
||||
# Geplante Dauer
|
||||
phase_durations = session_data.get("phase_durations", {})
|
||||
planned_duration = sum(phase_durations.values()) * 60 # Minuten zu Sekunden
|
||||
|
||||
# Phasen-Statistiken berechnen
|
||||
phase_stats = []
|
||||
total_overtime = 0
|
||||
phases_with_overtime = 0
|
||||
total_pause_count = 0
|
||||
total_pause_seconds = 0
|
||||
phases_completed = 0
|
||||
|
||||
for entry in phase_history:
|
||||
phase = entry.get("phase", "")
|
||||
if phase in ["not_started", "ended"]:
|
||||
continue
|
||||
|
||||
# Geplante Dauer fuer diese Phase
|
||||
planned_seconds = phase_durations.get(phase, 0) * 60
|
||||
|
||||
# Tatsaechliche Dauer
|
||||
actual_seconds = entry.get("duration_seconds", 0)
|
||||
if actual_seconds is None:
|
||||
actual_seconds = 0
|
||||
|
||||
# Differenz
|
||||
difference = actual_seconds - planned_seconds
|
||||
|
||||
# Overtime (nur positive Differenz zaehlt)
|
||||
had_overtime = difference > 0
|
||||
overtime_seconds = max(0, difference)
|
||||
|
||||
if had_overtime:
|
||||
total_overtime += overtime_seconds
|
||||
phases_with_overtime += 1
|
||||
|
||||
# Pausen
|
||||
pause_count = entry.get("pause_count", 0) or 0
|
||||
pause_seconds = entry.get("total_pause_seconds", 0) or 0
|
||||
total_pause_count += pause_count
|
||||
total_pause_seconds += pause_seconds
|
||||
|
||||
# Phase als abgeschlossen zaehlen
|
||||
if entry.get("ended_at"):
|
||||
phases_completed += 1
|
||||
|
||||
phase_stats.append(PhaseStatistics(
|
||||
phase=phase,
|
||||
display_name=cls.PHASE_DISPLAY_NAMES.get(phase, phase),
|
||||
planned_duration_seconds=planned_seconds,
|
||||
actual_duration_seconds=actual_seconds,
|
||||
difference_seconds=difference,
|
||||
had_overtime=had_overtime,
|
||||
overtime_seconds=overtime_seconds,
|
||||
was_extended=entry.get("was_extended", False),
|
||||
extension_minutes=entry.get("extension_minutes", 0) or 0,
|
||||
pause_count=pause_count,
|
||||
total_pause_seconds=pause_seconds,
|
||||
))
|
||||
|
||||
return SessionSummary(
|
||||
session_id=session_id,
|
||||
teacher_id=teacher_id,
|
||||
class_id=class_id,
|
||||
subject=subject,
|
||||
topic=topic,
|
||||
date=lesson_started or datetime.now(),
|
||||
total_duration_seconds=total_duration,
|
||||
planned_duration_seconds=planned_duration,
|
||||
phases_completed=phases_completed,
|
||||
total_phases=5,
|
||||
phase_statistics=phase_stats,
|
||||
total_overtime_seconds=total_overtime,
|
||||
phases_with_overtime=phases_with_overtime,
|
||||
total_pause_count=total_pause_count,
|
||||
total_pause_seconds=total_pause_seconds,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def calculate_teacher_analytics(
|
||||
cls,
|
||||
sessions: List[Dict[str, Any]],
|
||||
period_start: datetime,
|
||||
period_end: datetime
|
||||
) -> TeacherAnalytics:
|
||||
"""
|
||||
Berechnet aggregierte Statistiken fuer einen Lehrer.
|
||||
|
||||
Args:
|
||||
sessions: Liste von Session-Dictionaries
|
||||
period_start: Beginn des Zeitraums
|
||||
period_end: Ende des Zeitraums
|
||||
|
||||
Returns:
|
||||
TeacherAnalytics mit aggregierten Statistiken
|
||||
"""
|
||||
if not sessions:
|
||||
return TeacherAnalytics(
|
||||
teacher_id="",
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
)
|
||||
|
||||
teacher_id = sessions[0].get("teacher_id", "")
|
||||
|
||||
# Basis-Zaehler
|
||||
total_sessions = len(sessions)
|
||||
completed_sessions = sum(1 for s in sessions if s.get("lesson_ended_at"))
|
||||
|
||||
# Gesamtdauer berechnen
|
||||
total_minutes = 0
|
||||
for session in sessions:
|
||||
started = session.get("lesson_started_at")
|
||||
ended = session.get("lesson_ended_at")
|
||||
if started and ended:
|
||||
if isinstance(started, str):
|
||||
started = datetime.fromisoformat(started.replace("Z", "+00:00"))
|
||||
if isinstance(ended, str):
|
||||
ended = datetime.fromisoformat(ended.replace("Z", "+00:00"))
|
||||
total_minutes += (ended - started).total_seconds() / 60
|
||||
|
||||
# Durchschnittliche Phasendauern
|
||||
phase_durations_sum: Dict[str, List[int]] = {
|
||||
"einstieg": [],
|
||||
"erarbeitung": [],
|
||||
"sicherung": [],
|
||||
"transfer": [],
|
||||
"reflexion": [],
|
||||
}
|
||||
|
||||
# Overtime-Tracking
|
||||
overtime_count = 0
|
||||
overtime_seconds_total = 0
|
||||
phase_overtime: Dict[str, int] = {}
|
||||
|
||||
# Pausen-Tracking
|
||||
pause_counts = []
|
||||
pause_durations = []
|
||||
|
||||
# Faecher und Klassen
|
||||
subjects: Dict[str, int] = {}
|
||||
classes: Dict[str, int] = {}
|
||||
|
||||
for session in sessions:
|
||||
# Fach und Klasse zaehlen
|
||||
subject = session.get("subject", "")
|
||||
class_id = session.get("class_id", "")
|
||||
subjects[subject] = subjects.get(subject, 0) + 1
|
||||
classes[class_id] = classes.get(class_id, 0) + 1
|
||||
|
||||
# Phase History analysieren
|
||||
history = session.get("phase_history", [])
|
||||
session_has_overtime = False
|
||||
session_pause_count = 0
|
||||
session_pause_duration = 0
|
||||
|
||||
phase_durations_dict = session.get("phase_durations", {})
|
||||
|
||||
for entry in history:
|
||||
phase = entry.get("phase", "")
|
||||
if phase in phase_durations_sum:
|
||||
duration = entry.get("duration_seconds", 0) or 0
|
||||
phase_durations_sum[phase].append(duration)
|
||||
|
||||
# Overtime berechnen
|
||||
planned = phase_durations_dict.get(phase, 0) * 60
|
||||
if duration > planned:
|
||||
overtime = duration - planned
|
||||
overtime_seconds_total += overtime
|
||||
session_has_overtime = True
|
||||
phase_overtime[phase] = phase_overtime.get(phase, 0) + overtime
|
||||
|
||||
# Pausen zaehlen
|
||||
session_pause_count += entry.get("pause_count", 0) or 0
|
||||
session_pause_duration += entry.get("total_pause_seconds", 0) or 0
|
||||
|
||||
if session_has_overtime:
|
||||
overtime_count += 1
|
||||
|
||||
pause_counts.append(session_pause_count)
|
||||
pause_durations.append(session_pause_duration)
|
||||
|
||||
# Durchschnitte berechnen
|
||||
avg_durations = {}
|
||||
for phase, durations in phase_durations_sum.items():
|
||||
if durations:
|
||||
avg_durations[phase] = round(sum(durations) / len(durations))
|
||||
else:
|
||||
avg_durations[phase] = 0
|
||||
|
||||
# Phase mit meistem Overtime finden
|
||||
most_overtime_phase = None
|
||||
if phase_overtime:
|
||||
most_overtime_phase = max(phase_overtime, key=phase_overtime.get)
|
||||
|
||||
return TeacherAnalytics(
|
||||
teacher_id=teacher_id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
total_sessions=total_sessions,
|
||||
completed_sessions=completed_sessions,
|
||||
total_teaching_minutes=int(total_minutes),
|
||||
avg_phase_durations=avg_durations,
|
||||
sessions_with_overtime=overtime_count,
|
||||
avg_overtime_seconds=overtime_seconds_total / max(total_sessions, 1),
|
||||
most_overtime_phase=most_overtime_phase,
|
||||
avg_pause_count=sum(pause_counts) / max(len(pause_counts), 1),
|
||||
avg_pause_duration_seconds=sum(pause_durations) / max(len(pause_durations), 1),
|
||||
subjects_taught=subjects,
|
||||
classes_taught=classes,
|
||||
)
|
||||
676
backend/classroom_engine/antizipation.py
Normal file
676
backend/classroom_engine/antizipation.py
Normal file
@@ -0,0 +1,676 @@
|
||||
"""
|
||||
Antizipations-Engine fuer proaktive Vorschlaege (Phase 8b).
|
||||
|
||||
Die Engine sammelt Signale aus verschiedenen Quellen und generiert
|
||||
kontextbasierte Vorschlaege fuer Lehrer basierend auf definierten Regeln.
|
||||
|
||||
Architektur:
|
||||
1. SignalCollector - Sammelt Inputs (Zeit, Nutzung, Events)
|
||||
2. RuleEngine - Evaluiert Regeln gegen Signale
|
||||
3. SuggestionGenerator - Generiert priorisierte Vorschlaege
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ==================== Enums & Types ====================
|
||||
|
||||
class SuggestionTone(str, Enum):
|
||||
"""Ton/Dringlichkeit eines Vorschlags."""
|
||||
HINT = "hint" # Sanfter Hinweis
|
||||
SUGGESTION = "suggestion" # Aktiver Vorschlag
|
||||
REMINDER = "reminder" # Erinnerung
|
||||
URGENT = "urgent" # Dringend
|
||||
|
||||
|
||||
class ContextType(str, Enum):
|
||||
"""Typ eines aktiven Kontexts."""
|
||||
EVENT_WINDOW = "event_window" # Event steht bevor
|
||||
ROUTINE = "routine" # Routine heute
|
||||
PHASE = "phase" # Makro-Phase bedingt
|
||||
TIME = "time" # Zeitbasiert (Ferien, Wochenende)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Signal:
|
||||
"""Ein einzelnes Signal aus einer Quelle."""
|
||||
name: str
|
||||
value: Any
|
||||
source: str # "calendar", "usage", "events", "routines"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveContext:
|
||||
"""Ein aktiver Kontext der Vorschlaege beeinflusst."""
|
||||
id: str
|
||||
context_type: ContextType
|
||||
label: str
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Suggestion:
|
||||
"""Ein generierter Vorschlag."""
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
tone: SuggestionTone
|
||||
action_url: Optional[str] = None
|
||||
badge: Optional[str] = None # z.B. "in 7 Tagen"
|
||||
priority: int = 50 # 0-100, hoeher = wichtiger
|
||||
rule_id: str = ""
|
||||
icon: str = "lightbulb"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Signals:
|
||||
"""Container fuer alle gesammelten Signale."""
|
||||
# Zeit/Kalender
|
||||
current_week: int = 1
|
||||
weeks_since_start: int = 0
|
||||
is_weekend: bool = False
|
||||
is_before_holidays: bool = False
|
||||
days_until_holidays: int = 999
|
||||
|
||||
# Makro-Phase
|
||||
macro_phase: str = "onboarding"
|
||||
onboarding_completed: bool = False
|
||||
|
||||
# Produktnutzung
|
||||
classes_count: int = 0
|
||||
has_classes: bool = False
|
||||
has_schedule: bool = False
|
||||
|
||||
# Events
|
||||
exams_scheduled_count: int = 0
|
||||
exams_in_7_days: List[Dict] = field(default_factory=list)
|
||||
exams_past_ungraded: List[Dict] = field(default_factory=list)
|
||||
upcoming_events: List[Dict] = field(default_factory=list)
|
||||
trips_in_30_days: List[Dict] = field(default_factory=list)
|
||||
parent_evenings_soon: List[Dict] = field(default_factory=list)
|
||||
|
||||
# Routinen
|
||||
routines_today: List[Dict] = field(default_factory=list)
|
||||
has_conference_today: bool = False
|
||||
|
||||
# Statistiken (aus Analytics)
|
||||
corrections_pending: int = 0
|
||||
grades_completion_ratio: float = 0.0
|
||||
|
||||
|
||||
# ==================== Signal Collector ====================
|
||||
|
||||
class SignalCollector:
|
||||
"""
|
||||
Sammelt Signale aus verschiedenen Quellen.
|
||||
|
||||
Quellen:
|
||||
- TeacherContext (Makro-Phase, Schuljahr)
|
||||
- SchoolyearEvents (Klausuren, Elternabende, etc.)
|
||||
- RecurringRoutines (Konferenzen heute)
|
||||
- Zeit/Kalender (Wochenende, Ferien)
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
self.db = db_session
|
||||
|
||||
def collect(self, teacher_id: str) -> Signals:
|
||||
"""Sammelt alle Signale fuer einen Lehrer."""
|
||||
signals = Signals()
|
||||
|
||||
# Zeit-Signale
|
||||
self._collect_time_signals(signals)
|
||||
|
||||
if self.db:
|
||||
# Kontext-Signale
|
||||
self._collect_context_signals(signals, teacher_id)
|
||||
# Event-Signale
|
||||
self._collect_event_signals(signals, teacher_id)
|
||||
# Routine-Signale
|
||||
self._collect_routine_signals(signals, teacher_id)
|
||||
|
||||
return signals
|
||||
|
||||
def _collect_time_signals(self, signals: Signals):
|
||||
"""Sammelt zeitbasierte Signale."""
|
||||
now = datetime.utcnow()
|
||||
signals.is_weekend = now.weekday() >= 5
|
||||
|
||||
# TODO: Ferien-Kalender pro Bundesland integrieren
|
||||
# Fuer jetzt: Dummy-Werte
|
||||
signals.is_before_holidays = False
|
||||
signals.days_until_holidays = 999
|
||||
|
||||
def _collect_context_signals(self, signals: Signals, teacher_id: str):
|
||||
"""Sammelt Signale aus dem Teacher-Kontext."""
|
||||
from .repository import TeacherContextRepository
|
||||
|
||||
try:
|
||||
repo = TeacherContextRepository(self.db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
|
||||
signals.macro_phase = context.macro_phase.value
|
||||
signals.current_week = context.current_week or 1
|
||||
signals.onboarding_completed = context.onboarding_completed
|
||||
signals.has_classes = context.has_classes
|
||||
signals.has_schedule = context.has_schedule
|
||||
signals.classes_count = 1 if context.has_classes else 0
|
||||
|
||||
# Wochen seit Schuljahresstart berechnen
|
||||
if context.schoolyear_start:
|
||||
delta = datetime.utcnow() - context.schoolyear_start
|
||||
signals.weeks_since_start = max(0, delta.days // 7)
|
||||
|
||||
signals.is_before_holidays = context.is_before_holidays
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect context signals: {e}")
|
||||
|
||||
def _collect_event_signals(self, signals: Signals, teacher_id: str):
|
||||
"""Sammelt Signale aus Events."""
|
||||
from .repository import SchoolyearEventRepository
|
||||
|
||||
try:
|
||||
repo = SchoolyearEventRepository(self.db)
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Alle anstehenden Events (30 Tage)
|
||||
upcoming = repo.get_upcoming(teacher_id, days=30, limit=20)
|
||||
signals.upcoming_events = [repo.to_dict(e) for e in upcoming]
|
||||
|
||||
# Klausuren in den naechsten 7 Tagen
|
||||
seven_days = now + timedelta(days=7)
|
||||
signals.exams_in_7_days = [
|
||||
repo.to_dict(e) for e in upcoming
|
||||
if e.event_type.value == "exam" and e.start_date <= seven_days
|
||||
]
|
||||
signals.exams_scheduled_count = len([
|
||||
e for e in upcoming if e.event_type.value == "exam"
|
||||
])
|
||||
|
||||
# Klassenfahrten in 30 Tagen
|
||||
signals.trips_in_30_days = [
|
||||
repo.to_dict(e) for e in upcoming
|
||||
if e.event_type.value == "trip"
|
||||
]
|
||||
|
||||
# Elternabende bald
|
||||
signals.parent_evenings_soon = [
|
||||
repo.to_dict(e) for e in upcoming
|
||||
if e.event_type.value in ("parent_evening", "parent_consultation")
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect event signals: {e}")
|
||||
|
||||
def _collect_routine_signals(self, signals: Signals, teacher_id: str):
|
||||
"""Sammelt Signale aus Routinen."""
|
||||
from .repository import RecurringRoutineRepository
|
||||
|
||||
try:
|
||||
repo = RecurringRoutineRepository(self.db)
|
||||
today_routines = repo.get_today(teacher_id)
|
||||
|
||||
signals.routines_today = [repo.to_dict(r) for r in today_routines]
|
||||
signals.has_conference_today = any(
|
||||
r.routine_type.value in ("teacher_conference", "subject_conference")
|
||||
for r in today_routines
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect routine signals: {e}")
|
||||
|
||||
|
||||
# ==================== Rule Engine ====================
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
"""Eine Regel die Signale zu Vorschlaegen mappt."""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
"""Evaluiert die Regel und gibt einen Vorschlag zurueck oder None."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class R01_CreateClasses(Rule):
|
||||
"""Klassen anlegen wenn noch keine vorhanden."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R01",
|
||||
name="Klassen anlegen",
|
||||
description="Empfiehlt Klassen anzulegen bei neuem Lehrer"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if signals.macro_phase == "onboarding" and not signals.has_classes:
|
||||
return Suggestion(
|
||||
id="suggest_create_classes",
|
||||
title="Klassen anlegen",
|
||||
description="Legen Sie Ihre Klassen an, um den vollen Funktionsumfang zu nutzen.",
|
||||
tone=SuggestionTone.HINT,
|
||||
priority=90,
|
||||
rule_id=self.id,
|
||||
icon="group_add",
|
||||
action_url="/classes/new",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R02_PrepareRubric(Rule):
|
||||
"""Erwartungshorizont erstellen wenn Klausur in 7 Tagen."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R02",
|
||||
name="Erwartungshorizont",
|
||||
description="Empfiehlt Erwartungshorizont vor Klausur"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if signals.exams_in_7_days:
|
||||
exam = signals.exams_in_7_days[0]
|
||||
# Pruefen ob Vorbereitung noch nicht erledigt
|
||||
if not exam.get("preparation_done", False):
|
||||
days = 7 # Vereinfacht
|
||||
return Suggestion(
|
||||
id=f"suggest_rubric_{exam['id'][:8]}",
|
||||
title="Erwartungshorizont erstellen",
|
||||
description=f"Klausur '{exam['title']}' steht bevor.",
|
||||
tone=SuggestionTone.SUGGESTION,
|
||||
badge=f"in {days} Tagen",
|
||||
priority=80,
|
||||
rule_id=self.id,
|
||||
icon="assignment",
|
||||
action_url=f"/exams/{exam['id']}/rubric",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R03_StartCorrection(Rule):
|
||||
"""Korrektur starten nach Klausur."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R03",
|
||||
name="Korrektur starten",
|
||||
description="Empfiehlt Korrektur nach Klausur"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if signals.exams_past_ungraded:
|
||||
exam = signals.exams_past_ungraded[0]
|
||||
return Suggestion(
|
||||
id=f"suggest_correction_{exam['id'][:8]}",
|
||||
title="Korrektur-Setup starten",
|
||||
description=f"Klausur '{exam['title']}' ist geschrieben.",
|
||||
tone=SuggestionTone.HINT,
|
||||
badge="bereit",
|
||||
priority=75,
|
||||
rule_id=self.id,
|
||||
icon="rate_review",
|
||||
action_url=f"/exams/{exam['id']}/correct",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R05_PrepareAgenda(Rule):
|
||||
"""Agenda vorbereiten wenn Konferenz heute."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R05",
|
||||
name="Konferenz-Agenda",
|
||||
description="Empfiehlt Agenda wenn Konferenz heute"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if signals.has_conference_today:
|
||||
# Finde die Konferenz
|
||||
conf = next(
|
||||
(r for r in signals.routines_today
|
||||
if r.get("routine_type") in ("teacher_conference", "subject_conference")),
|
||||
None
|
||||
)
|
||||
if conf:
|
||||
return Suggestion(
|
||||
id="suggest_agenda",
|
||||
title="Konferenz-Agenda vorbereiten",
|
||||
description=f"{conf.get('title', 'Konferenz')} heute.",
|
||||
tone=SuggestionTone.SUGGESTION,
|
||||
badge="heute",
|
||||
priority=70,
|
||||
rule_id=self.id,
|
||||
icon="event_note",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R07_PlanFirstExam(Rule):
|
||||
"""Erste Klausur planen nach 4 Wochen."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R07",
|
||||
name="Erste Arbeit planen",
|
||||
description="Empfiehlt erste Klausur nach Anlaufphase"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if (signals.weeks_since_start >= 4 and
|
||||
signals.exams_scheduled_count == 0 and
|
||||
signals.has_classes):
|
||||
return Suggestion(
|
||||
id="suggest_first_exam",
|
||||
title="Erste Klassenarbeit planen",
|
||||
description="Nach 4 Wochen Unterricht ist ein guter Zeitpunkt fuer die erste Leistungsueberpruefung.",
|
||||
tone=SuggestionTone.SUGGESTION,
|
||||
priority=60,
|
||||
rule_id=self.id,
|
||||
icon="quiz",
|
||||
action_url="/exams/new",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R08_CorrectionMode(Rule):
|
||||
"""Korrekturmodus vor Ferien."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R08",
|
||||
name="Ferien-Korrekturmodus",
|
||||
description="Empfiehlt Korrekturen vor Ferien"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if signals.is_before_holidays and signals.corrections_pending > 0:
|
||||
return Suggestion(
|
||||
id="suggest_correction_mode",
|
||||
title="Ferien-Korrekturmodus",
|
||||
description=f"{signals.corrections_pending} Korrekturen noch offen vor den Ferien.",
|
||||
tone=SuggestionTone.REMINDER,
|
||||
badge=f"{signals.days_until_holidays}d bis Ferien",
|
||||
priority=65,
|
||||
rule_id=self.id,
|
||||
icon="grading",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R09_TripChecklist(Rule):
|
||||
"""Klassenfahrt-Checkliste wenn Fahrt in 30 Tagen."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R09",
|
||||
name="Klassenfahrt-Checkliste",
|
||||
description="Empfiehlt Checkliste vor Klassenfahrt"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if signals.trips_in_30_days:
|
||||
trip = signals.trips_in_30_days[0]
|
||||
return Suggestion(
|
||||
id=f"suggest_trip_{trip['id'][:8]}",
|
||||
title="Klassenfahrt-Checkliste",
|
||||
description=f"'{trip['title']}' steht bevor.",
|
||||
tone=SuggestionTone.SUGGESTION,
|
||||
badge="in 30 Tagen",
|
||||
priority=55,
|
||||
rule_id=self.id,
|
||||
icon="luggage",
|
||||
action_url=f"/trips/{trip['id']}/checklist",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R10_CompleteGrades(Rule):
|
||||
"""Noten vervollstaendigen vor Halbjahresende."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R10",
|
||||
name="Noten vervollstaendigen",
|
||||
description="Empfiehlt Noten vor Notenschluss"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if (signals.macro_phase in ("halbjahresabschluss", "jahresabschluss") and
|
||||
signals.grades_completion_ratio < 0.8):
|
||||
pct = int(signals.grades_completion_ratio * 100)
|
||||
return Suggestion(
|
||||
id="suggest_complete_grades",
|
||||
title="Noten vervollstaendigen",
|
||||
description=f"Nur {pct}% der Noten eingetragen. Notenschluss naht!",
|
||||
tone=SuggestionTone.REMINDER,
|
||||
priority=85,
|
||||
rule_id=self.id,
|
||||
icon="calculate",
|
||||
action_url="/grades",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R11_SetupSchedule(Rule):
|
||||
"""Stundenplan einrichten wenn noch nicht vorhanden."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R11",
|
||||
name="Stundenplan einrichten",
|
||||
description="Empfiehlt Stundenplan-Setup"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if signals.macro_phase == "onboarding" and not signals.has_schedule:
|
||||
return Suggestion(
|
||||
id="suggest_setup_schedule",
|
||||
title="Stundenplan einrichten",
|
||||
description="Richten Sie Ihren Stundenplan ein fuer personalisierte Vorschlaege.",
|
||||
tone=SuggestionTone.HINT,
|
||||
priority=85,
|
||||
rule_id=self.id,
|
||||
icon="calendar_month",
|
||||
action_url="/schedule/setup",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class R12_ParentEvening(Rule):
|
||||
"""Elternabend vorbereiten."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="R12",
|
||||
name="Elternabend vorbereiten",
|
||||
description="Empfiehlt Vorbereitung vor Elternabend"
|
||||
)
|
||||
|
||||
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
|
||||
if signals.parent_evenings_soon:
|
||||
event = signals.parent_evenings_soon[0]
|
||||
return Suggestion(
|
||||
id=f"suggest_parent_{event['id'][:8]}",
|
||||
title="Elternabend vorbereiten",
|
||||
description=f"'{event['title']}' steht bevor.",
|
||||
tone=SuggestionTone.SUGGESTION,
|
||||
badge="bald",
|
||||
priority=65,
|
||||
rule_id=self.id,
|
||||
icon="family_restroom",
|
||||
action_url=f"/events/{event['id']}/prepare",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class RuleEngine:
|
||||
"""
|
||||
Evaluiert alle Regeln gegen die gesammelten Signale.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.rules: List[Rule] = [
|
||||
R01_CreateClasses(),
|
||||
R02_PrepareRubric(),
|
||||
R03_StartCorrection(),
|
||||
R05_PrepareAgenda(),
|
||||
R07_PlanFirstExam(),
|
||||
R08_CorrectionMode(),
|
||||
R09_TripChecklist(),
|
||||
R10_CompleteGrades(),
|
||||
R11_SetupSchedule(),
|
||||
R12_ParentEvening(),
|
||||
]
|
||||
|
||||
def evaluate(self, signals: Signals) -> List[Suggestion]:
|
||||
"""Evaluiert alle Regeln und gibt passende Vorschlaege zurueck."""
|
||||
suggestions = []
|
||||
|
||||
for rule in self.rules:
|
||||
try:
|
||||
suggestion = rule.evaluate(signals)
|
||||
if suggestion:
|
||||
suggestions.append(suggestion)
|
||||
except Exception as e:
|
||||
logger.warning(f"Rule {rule.id} failed: {e}")
|
||||
|
||||
# Nach Prioritaet sortieren (hoechste zuerst)
|
||||
suggestions.sort(key=lambda s: s.priority, reverse=True)
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
# ==================== Suggestion Generator ====================
|
||||
|
||||
class SuggestionGenerator:
|
||||
"""
|
||||
Hauptklasse die Signale sammelt, Regeln evaluiert und
|
||||
Vorschlaege generiert.
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
self.collector = SignalCollector(db_session)
|
||||
self.rule_engine = RuleEngine()
|
||||
|
||||
def generate(self, teacher_id: str, limit: int = 5) -> Dict[str, Any]:
|
||||
"""
|
||||
Generiert Vorschlaege fuer einen Lehrer.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"active_contexts": [...],
|
||||
"suggestions": [...],
|
||||
"signals_summary": {...}
|
||||
}
|
||||
"""
|
||||
# 1. Signale sammeln
|
||||
signals = self.collector.collect(teacher_id)
|
||||
|
||||
# 2. Regeln evaluieren
|
||||
all_suggestions = self.rule_engine.evaluate(signals)
|
||||
|
||||
# 3. Aktive Kontexte bestimmen
|
||||
active_contexts = self._determine_active_contexts(signals)
|
||||
|
||||
# 4. Top N Vorschlaege
|
||||
top_suggestions = all_suggestions[:limit]
|
||||
|
||||
return {
|
||||
"active_contexts": [
|
||||
{
|
||||
"id": ctx.id,
|
||||
"type": ctx.context_type.value,
|
||||
"label": ctx.label,
|
||||
}
|
||||
for ctx in active_contexts
|
||||
],
|
||||
"suggestions": [
|
||||
{
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"description": s.description,
|
||||
"tone": s.tone.value,
|
||||
"badge": s.badge,
|
||||
"priority": s.priority,
|
||||
"icon": s.icon,
|
||||
"action_url": s.action_url,
|
||||
}
|
||||
for s in top_suggestions
|
||||
],
|
||||
"signals_summary": {
|
||||
"macro_phase": signals.macro_phase,
|
||||
"current_week": signals.current_week,
|
||||
"has_classes": signals.has_classes,
|
||||
"exams_soon": len(signals.exams_in_7_days),
|
||||
"routines_today": len(signals.routines_today),
|
||||
},
|
||||
"total_suggestions": len(all_suggestions),
|
||||
}
|
||||
|
||||
def _determine_active_contexts(self, signals: Signals) -> List[ActiveContext]:
|
||||
"""Bestimmt die aktiven Kontexte basierend auf Signalen."""
|
||||
contexts = []
|
||||
|
||||
# Event-Kontexte
|
||||
if signals.exams_in_7_days:
|
||||
contexts.append(ActiveContext(
|
||||
id="EXAM_IN_7_DAYS",
|
||||
context_type=ContextType.EVENT_WINDOW,
|
||||
label="Klausur in 7 Tagen",
|
||||
))
|
||||
|
||||
if signals.trips_in_30_days:
|
||||
contexts.append(ActiveContext(
|
||||
id="TRIP_UPCOMING",
|
||||
context_type=ContextType.EVENT_WINDOW,
|
||||
label="Klassenfahrt geplant",
|
||||
))
|
||||
|
||||
# Routine-Kontexte
|
||||
if signals.has_conference_today:
|
||||
contexts.append(ActiveContext(
|
||||
id="CONFERENCE_TODAY",
|
||||
context_type=ContextType.ROUTINE,
|
||||
label="Konferenz heute",
|
||||
))
|
||||
|
||||
# Zeit-Kontexte
|
||||
if signals.is_weekend:
|
||||
contexts.append(ActiveContext(
|
||||
id="WEEKEND",
|
||||
context_type=ContextType.TIME,
|
||||
label="Wochenende",
|
||||
))
|
||||
|
||||
if signals.is_before_holidays:
|
||||
contexts.append(ActiveContext(
|
||||
id="BEFORE_HOLIDAYS",
|
||||
context_type=ContextType.TIME,
|
||||
label="Vor den Ferien",
|
||||
))
|
||||
|
||||
# Phase-Kontexte
|
||||
if signals.macro_phase == "onboarding":
|
||||
contexts.append(ActiveContext(
|
||||
id="ONBOARDING",
|
||||
context_type=ContextType.PHASE,
|
||||
label="Einrichtung",
|
||||
))
|
||||
elif signals.macro_phase in ("halbjahresabschluss", "jahresabschluss"):
|
||||
contexts.append(ActiveContext(
|
||||
id="GRADE_PERIOD",
|
||||
context_type=ContextType.PHASE,
|
||||
label="Notenphase",
|
||||
))
|
||||
|
||||
return contexts
|
||||
291
backend/classroom_engine/context_models.py
Normal file
291
backend/classroom_engine/context_models.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
SQLAlchemy Database Models fuer Schuljahres-Kontext (Phase 8).
|
||||
|
||||
Erweitert das Companion-Modul um ein 2-Schichten-Modell:
|
||||
- Makro-Ebene: 7 Schuljahres-Phasen (langsam wechselnd)
|
||||
- Mikro-Ebene: Events, Routinen, Arbeitsmodi (tagesaktuell)
|
||||
"""
|
||||
from datetime import datetime, time
|
||||
from sqlalchemy import (
|
||||
Column, String, Integer, DateTime, JSON,
|
||||
Boolean, Text, Enum as SQLEnum, Time
|
||||
)
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from .database import Base
|
||||
|
||||
# Exports
|
||||
__all__ = [
|
||||
"MacroPhaseEnum",
|
||||
"EventTypeEnum",
|
||||
"EventStatusEnum",
|
||||
"RoutineTypeEnum",
|
||||
"RecurrencePatternEnum",
|
||||
"TeacherContextDB",
|
||||
"SchoolyearEventDB",
|
||||
"RecurringRoutineDB",
|
||||
"FEDERAL_STATES",
|
||||
"SCHOOL_TYPES",
|
||||
]
|
||||
|
||||
|
||||
# ==================== Konstanten ====================
|
||||
|
||||
FEDERAL_STATES = {
|
||||
"BW": "Baden-Wuerttemberg",
|
||||
"BY": "Bayern",
|
||||
"BE": "Berlin",
|
||||
"BB": "Brandenburg",
|
||||
"HB": "Bremen",
|
||||
"HH": "Hamburg",
|
||||
"HE": "Hessen",
|
||||
"MV": "Mecklenburg-Vorpommern",
|
||||
"NI": "Niedersachsen",
|
||||
"NW": "Nordrhein-Westfalen",
|
||||
"RP": "Rheinland-Pfalz",
|
||||
"SL": "Saarland",
|
||||
"SN": "Sachsen",
|
||||
"ST": "Sachsen-Anhalt",
|
||||
"SH": "Schleswig-Holstein",
|
||||
"TH": "Thueringen",
|
||||
}
|
||||
|
||||
SCHOOL_TYPES = {
|
||||
"grundschule": "Grundschule",
|
||||
"hauptschule": "Hauptschule",
|
||||
"realschule": "Realschule",
|
||||
"gymnasium": "Gymnasium",
|
||||
"gesamtschule": "Gesamtschule",
|
||||
"foerderschule": "Foerderschule",
|
||||
"berufsschule": "Berufsschule",
|
||||
"gemeinschaftsschule": "Gemeinschaftsschule",
|
||||
}
|
||||
|
||||
|
||||
# ==================== Enums ====================
|
||||
|
||||
class MacroPhaseEnum(str, enum.Enum):
|
||||
"""
|
||||
7 Schuljahres-Phasen (Makro-State).
|
||||
|
||||
Wechselt alle paar Wochen basierend auf Schulkalender und Nutzungsverhalten.
|
||||
"""
|
||||
ONBOARDING = "onboarding" # Ersteinrichtung (Klassen, Stundenplan)
|
||||
SCHULJAHRESSTART = "schuljahresstart" # Erste 2-3 Wochen
|
||||
UNTERRICHTSAUFBAU = "unterrichtsaufbau" # Routinen etablieren
|
||||
LEISTUNGSPHASE_1 = "leistungsphase_1" # Erste Klausuren
|
||||
HALBJAHRESABSCHLUSS = "halbjahresabschluss" # Notenschluss, Zeugnisse
|
||||
LEISTUNGSPHASE_2 = "leistungsphase_2" # Pruefungsvorbereitung
|
||||
JAHRESABSCHLUSS = "jahresabschluss" # Finale Noten, Versetzung
|
||||
|
||||
|
||||
class EventTypeEnum(str, enum.Enum):
|
||||
"""Event-Typen fuer Schuljahr-Events."""
|
||||
EXAM = "exam" # Klassenarbeit, Klausur
|
||||
PARENT_EVENING = "parent_evening" # Elternabend
|
||||
TRIP = "trip" # Klassenfahrt, Ausflug
|
||||
PROJECT = "project" # Projektwoche
|
||||
INTERNSHIP = "internship" # Praktikum
|
||||
PRESENTATION = "presentation" # Referate, Praesentationen
|
||||
SPORTS_DAY = "sports_day" # Sporttag, Bundesjugendspiele
|
||||
SCHOOL_FESTIVAL = "school_festival" # Schulfest
|
||||
PARENT_CONSULTATION = "parent_consultation" # Elternsprechtag
|
||||
GRADE_DEADLINE = "grade_deadline" # Notenschluss
|
||||
REPORT_CARDS = "report_cards" # Zeugnisausgabe
|
||||
HOLIDAY_START = "holiday_start" # Ferienbeginn
|
||||
HOLIDAY_END = "holiday_end" # Ferienende
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class EventStatusEnum(str, enum.Enum):
|
||||
"""Status eines Events."""
|
||||
PLANNED = "planned"
|
||||
IN_PROGRESS = "in_progress"
|
||||
DONE = "done"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class RoutineTypeEnum(str, enum.Enum):
|
||||
"""Typen wiederkehrender Routinen."""
|
||||
TEACHER_CONFERENCE = "teacher_conference" # Lehrerkonferenz
|
||||
SUBJECT_CONFERENCE = "subject_conference" # Fachkonferenz
|
||||
OFFICE_HOURS = "office_hours" # Sprechstunde
|
||||
TEAM_MEETING = "team_meeting" # Teamsitzung
|
||||
SUPERVISION = "supervision" # Pausenaufsicht
|
||||
CORRECTION_TIME = "correction_time" # Korrekturzeit
|
||||
PREP_TIME = "prep_time" # Vorbereitungszeit
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class RecurrencePatternEnum(str, enum.Enum):
|
||||
"""Wiederholungsmuster fuer Routinen."""
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
BIWEEKLY = "biweekly"
|
||||
MONTHLY = "monthly"
|
||||
|
||||
|
||||
# ==================== Models ====================
|
||||
|
||||
class TeacherContextDB(Base):
|
||||
"""
|
||||
Lehrer-Kontext fuer Schuljahres-Begleitung (Phase 8).
|
||||
|
||||
Speichert den Makro-Kontext eines Lehrers:
|
||||
- Bundesland (fuer Schulkalender/Ferien)
|
||||
- Schulart
|
||||
- Aktuelles Schuljahr
|
||||
- Aktuelle Makro-Phase
|
||||
|
||||
Ein Lehrer hat genau einen Context-Eintrag.
|
||||
"""
|
||||
__tablename__ = 'teacher_contexts'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
teacher_id = Column(String(100), unique=True, nullable=False, index=True)
|
||||
|
||||
# Schul-Kontext
|
||||
federal_state = Column(String(10), default="BY") # BY, NRW, etc.
|
||||
school_type = Column(String(50), default="gymnasium") # gymnasium, realschule, etc.
|
||||
|
||||
# Schuljahr
|
||||
schoolyear = Column(String(20), default="2024-2025") # z.B. "2024-2025"
|
||||
schoolyear_start = Column(DateTime, nullable=True) # Erster Schultag
|
||||
|
||||
# Aktueller Stand (Makro-Phase)
|
||||
macro_phase = Column(
|
||||
SQLEnum(MacroPhaseEnum),
|
||||
default=MacroPhaseEnum.ONBOARDING,
|
||||
nullable=False
|
||||
)
|
||||
current_week = Column(Integer, default=1) # Schulwoche 1-52
|
||||
|
||||
# Berechnete Flags (werden beim Abrufen aktualisiert)
|
||||
is_exam_period = Column(Boolean, default=False)
|
||||
is_before_holidays = Column(Boolean, default=False)
|
||||
|
||||
# Onboarding-Status
|
||||
onboarding_completed = Column(Boolean, default=False)
|
||||
has_classes = Column(Boolean, default=False)
|
||||
has_schedule = Column(Boolean, default=False)
|
||||
|
||||
# Metadaten
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TeacherContext {self.teacher_id} ({self.macro_phase.value})>"
|
||||
|
||||
|
||||
class SchoolyearEventDB(Base):
|
||||
"""
|
||||
Schuljahr-Event (Phase 8).
|
||||
|
||||
Ein einmaliges Event im Schuljahr wie:
|
||||
- Klassenarbeit
|
||||
- Elternabend
|
||||
- Klassenfahrt
|
||||
- Projektwoche
|
||||
|
||||
Events haben einen Zeitraum und optionale Klassen-/Fach-Zuordnung.
|
||||
"""
|
||||
__tablename__ = 'schoolyear_events'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Event-Typ und Titel
|
||||
event_type = Column(
|
||||
SQLEnum(EventTypeEnum),
|
||||
default=EventTypeEnum.OTHER,
|
||||
nullable=False
|
||||
)
|
||||
title = Column(String(300), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
|
||||
# Zeitraum
|
||||
start_date = Column(DateTime, nullable=False, index=True)
|
||||
end_date = Column(DateTime, nullable=True) # Null = eintaegiges Event
|
||||
|
||||
# Optionale Zuordnung
|
||||
class_id = Column(String(100), nullable=True, index=True)
|
||||
subject = Column(String(100), nullable=True)
|
||||
|
||||
# Status
|
||||
status = Column(
|
||||
SQLEnum(EventStatusEnum),
|
||||
default=EventStatusEnum.PLANNED,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Flags fuer Antizipation
|
||||
needs_preparation = Column(Boolean, default=True) # Braucht Vorbereitung?
|
||||
preparation_done = Column(Boolean, default=False) # Vorbereitung erledigt?
|
||||
reminder_days_before = Column(Integer, default=7) # Tage vorher erinnern
|
||||
|
||||
# Flexible Zusatzdaten (z.B. Rubric-ID fuer Klausur)
|
||||
extra_data = Column(JSON, default=dict) # Renamed from 'metadata' which is reserved in SQLAlchemy
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SchoolyearEvent {self.title} ({self.event_type.value})>"
|
||||
|
||||
|
||||
class RecurringRoutineDB(Base):
|
||||
"""
|
||||
Wiederkehrende Routine (Phase 8).
|
||||
|
||||
Eine regelmaessig wiederkehrende Aktivitaet wie:
|
||||
- Lehrerkonferenz (woechentlich/monatlich)
|
||||
- Fachkonferenz
|
||||
- Sprechstunde
|
||||
- Korrekturzeit
|
||||
|
||||
Routinen wiederholen sich nach einem bestimmten Muster.
|
||||
"""
|
||||
__tablename__ = 'recurring_routines'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Routine-Typ und Titel
|
||||
routine_type = Column(
|
||||
SQLEnum(RoutineTypeEnum),
|
||||
default=RoutineTypeEnum.OTHER,
|
||||
nullable=False
|
||||
)
|
||||
title = Column(String(300), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
|
||||
# Wiederholung
|
||||
recurrence_pattern = Column(
|
||||
SQLEnum(RecurrencePatternEnum),
|
||||
default=RecurrencePatternEnum.WEEKLY,
|
||||
nullable=False
|
||||
)
|
||||
day_of_week = Column(Integer, nullable=True) # 0=Mo, 6=So (Null wenn daily/monthly)
|
||||
day_of_month = Column(Integer, nullable=True) # 1-31 fuer monthly
|
||||
time_of_day = Column(Time, nullable=True) # z.B. 14:00
|
||||
|
||||
# Dauer in Minuten
|
||||
duration_minutes = Column(Integer, default=60)
|
||||
|
||||
# Aktiv?
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Startdatum (ab wann gilt die Routine?)
|
||||
valid_from = Column(DateTime, nullable=True)
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
|
||||
# Metadaten
|
||||
extra_data = Column(JSON, default=dict) # Renamed from 'metadata' which is reserved in SQLAlchemy
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RecurringRoutine {self.title} ({self.recurrence_pattern.value})>"
|
||||
49
backend/classroom_engine/database.py
Normal file
49
backend/classroom_engine/database.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Database Configuration fuer Classroom Engine.
|
||||
|
||||
Stellt PostgreSQL-Anbindung fuer Session-Persistenz bereit.
|
||||
Sessions ueberleben Server-Neustarts (Feature f22).
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Database URL from environment (nutzt gleiche DB wie Backend)
|
||||
# Korrigiert postgres:// zu postgresql:// fuer SQLAlchemy 2.0 Kompatibilitaet
|
||||
_raw_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot"
|
||||
)
|
||||
# SQLAlchemy 2.0 erfordert "postgresql://" statt "postgres://"
|
||||
DATABASE_URL = _raw_url.replace("postgres://", "postgresql://", 1) if _raw_url.startswith("postgres://") else _raw_url
|
||||
|
||||
# Engine configuration
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
echo=os.getenv("SQL_ECHO", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Declarative Base
|
||||
Base = declarative_base()
|
||||
|
||||
# Session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Database dependency for FastAPI endpoints."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Erstellt alle Tabellen (fuer Entwicklung)."""
|
||||
from . import db_models # Import models to register them
|
||||
Base.metadata.create_all(bind=engine)
|
||||
429
backend/classroom_engine/db_models.py
Normal file
429
backend/classroom_engine/db_models.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
SQLAlchemy Database Models fuer Classroom Engine (Feature f13).
|
||||
|
||||
Persistiert Unterrichtsstunden und deren Verlauf in PostgreSQL.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column, String, Integer, Float, DateTime, JSON,
|
||||
Boolean, Text, Enum as SQLEnum, ForeignKey
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from .database import Base
|
||||
|
||||
# Import Phase 8 Context Models
|
||||
from .context_models import (
|
||||
MacroPhaseEnum,
|
||||
EventTypeEnum,
|
||||
EventStatusEnum,
|
||||
RoutineTypeEnum,
|
||||
RecurrencePatternEnum,
|
||||
TeacherContextDB,
|
||||
SchoolyearEventDB,
|
||||
RecurringRoutineDB,
|
||||
FEDERAL_STATES,
|
||||
SCHOOL_TYPES,
|
||||
)
|
||||
|
||||
# Exports
|
||||
__all__ = [
|
||||
"LessonPhaseEnum",
|
||||
"LessonSessionDB",
|
||||
"PhaseHistoryDB",
|
||||
"LessonTemplateDB",
|
||||
"TeacherSettingsDB",
|
||||
"HomeworkStatusEnum",
|
||||
"HomeworkDB",
|
||||
"MaterialTypeEnum",
|
||||
"PhaseMaterialDB",
|
||||
"LessonReflectionDB",
|
||||
"FeedbackTypeEnum",
|
||||
"FeedbackStatusEnum",
|
||||
"FeedbackPriorityEnum",
|
||||
"TeacherFeedbackDB",
|
||||
# Phase 8: Schuljahres-Kontext
|
||||
"MacroPhaseEnum",
|
||||
"EventTypeEnum",
|
||||
"EventStatusEnum",
|
||||
"RoutineTypeEnum",
|
||||
"RecurrencePatternEnum",
|
||||
"TeacherContextDB",
|
||||
"SchoolyearEventDB",
|
||||
"RecurringRoutineDB",
|
||||
"FEDERAL_STATES",
|
||||
"SCHOOL_TYPES",
|
||||
]
|
||||
|
||||
|
||||
class LessonPhaseEnum(str, enum.Enum):
|
||||
"""Unterrichtsphasen als DB-Enum."""
|
||||
NOT_STARTED = "not_started"
|
||||
EINSTIEG = "einstieg"
|
||||
ERARBEITUNG = "erarbeitung"
|
||||
SICHERUNG = "sicherung"
|
||||
TRANSFER = "transfer"
|
||||
REFLEXION = "reflexion"
|
||||
ENDED = "ended"
|
||||
|
||||
|
||||
class LessonSessionDB(Base):
|
||||
"""
|
||||
Persistierte Unterrichtsstunde.
|
||||
|
||||
Speichert alle Session-Daten inklusive Timer-Status und History.
|
||||
"""
|
||||
__tablename__ = 'lesson_sessions'
|
||||
|
||||
# Primary Key
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Beziehungen
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
class_id = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Session Metadaten
|
||||
subject = Column(String(100), nullable=False)
|
||||
topic = Column(String(500))
|
||||
|
||||
# Status
|
||||
current_phase = Column(
|
||||
SQLEnum(LessonPhaseEnum),
|
||||
default=LessonPhaseEnum.NOT_STARTED,
|
||||
nullable=False
|
||||
)
|
||||
is_paused = Column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
lesson_started_at = Column(DateTime)
|
||||
lesson_ended_at = Column(DateTime)
|
||||
phase_started_at = Column(DateTime)
|
||||
pause_started_at = Column(DateTime)
|
||||
|
||||
# Timer-Daten
|
||||
total_paused_seconds = Column(Integer, default=0)
|
||||
phase_durations = Column(JSON, default=dict) # {"einstieg": 8, ...}
|
||||
|
||||
# History & Notizen
|
||||
phase_history = Column(JSON, default=list) # [{phase, started_at, ended_at, duration}]
|
||||
notes = Column(Text, default="")
|
||||
homework = Column(Text, default="")
|
||||
|
||||
# Relationship zu PhaseHistory (optional fuer detaillierte Abfragen)
|
||||
history_entries = relationship("PhaseHistoryDB", back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LessonSession {self.id}: {self.subject} ({self.current_phase.value})>"
|
||||
|
||||
|
||||
class PhaseHistoryDB(Base):
|
||||
"""
|
||||
Einzelner Phasen-Verlaufseintrag.
|
||||
|
||||
Ermoeglicht detaillierte Statistiken ueber Phasendauern.
|
||||
"""
|
||||
__tablename__ = 'lesson_phase_history'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
session_id = Column(String(36), ForeignKey('lesson_sessions.id'), nullable=False, index=True)
|
||||
|
||||
phase = Column(SQLEnum(LessonPhaseEnum), nullable=False)
|
||||
started_at = Column(DateTime, nullable=False)
|
||||
ended_at = Column(DateTime)
|
||||
duration_seconds = Column(Integer)
|
||||
|
||||
# Zusaetzliche Metriken
|
||||
was_extended = Column(Boolean, default=False)
|
||||
extension_minutes = Column(Integer, default=0)
|
||||
pause_count = Column(Integer, default=0)
|
||||
total_pause_seconds = Column(Integer, default=0)
|
||||
|
||||
# Relationship
|
||||
session = relationship("LessonSessionDB", back_populates="history_entries")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PhaseHistory {self.phase.value} ({self.duration_seconds}s)>"
|
||||
|
||||
|
||||
class LessonTemplateDB(Base):
|
||||
"""
|
||||
Stunden-Vorlage (Feature f37).
|
||||
|
||||
Ermoeglicht Speicherung von wiederverwendbaren Stundenkonfigurationen.
|
||||
"""
|
||||
__tablename__ = 'lesson_templates'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Basis-Infos
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
subject = Column(String(100), default="")
|
||||
grade_level = Column(String(50), default="")
|
||||
|
||||
# Phasenkonfiguration
|
||||
phase_durations = Column(JSON, default=dict)
|
||||
|
||||
# Vorbelegungen
|
||||
default_topic = Column(String(500), default="")
|
||||
default_notes = Column(Text, default="")
|
||||
|
||||
# Metadaten
|
||||
is_public = Column(Boolean, default=False)
|
||||
usage_count = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LessonTemplate {self.name} ({self.teacher_id})>"
|
||||
|
||||
|
||||
class TeacherSettingsDB(Base):
|
||||
"""
|
||||
Lehrer-spezifische Einstellungen (Feature f16).
|
||||
|
||||
Speichert individuelle Praeferenzen wie Phasendauern.
|
||||
"""
|
||||
__tablename__ = 'teacher_settings'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
teacher_id = Column(String(100), unique=True, nullable=False, index=True)
|
||||
|
||||
# Individuelle Phasendauern
|
||||
default_phase_durations = Column(JSON, default=dict)
|
||||
|
||||
# UI Praeferenzen
|
||||
audio_enabled = Column(Boolean, default=True)
|
||||
high_contrast = Column(Boolean, default=False)
|
||||
|
||||
# Statistik-Einstellungen
|
||||
show_statistics = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TeacherSettings {self.teacher_id}>"
|
||||
|
||||
|
||||
class HomeworkStatusEnum(str, enum.Enum):
|
||||
"""Status-Enum fuer Hausaufgaben."""
|
||||
ASSIGNED = "assigned"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
OVERDUE = "overdue"
|
||||
|
||||
|
||||
class HomeworkDB(Base):
|
||||
"""
|
||||
Hausaufgaben-Tracking (Feature f20).
|
||||
|
||||
Ermoeglicht die Verfolgung von Hausaufgaben ueber Sessions hinweg.
|
||||
"""
|
||||
__tablename__ = 'homework_assignments'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
class_id = Column(String(50), nullable=False, index=True)
|
||||
subject = Column(String(100), nullable=False)
|
||||
|
||||
# Aufgaben-Details
|
||||
title = Column(String(300), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
|
||||
# Verknuepfung zur Session (optional)
|
||||
session_id = Column(String(36), ForeignKey('lesson_sessions.id'), nullable=True, index=True)
|
||||
|
||||
# Faelligkeit und Status
|
||||
due_date = Column(DateTime, nullable=True, index=True)
|
||||
status = Column(
|
||||
SQLEnum(HomeworkStatusEnum),
|
||||
default=HomeworkStatusEnum.ASSIGNED,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Homework {self.title} ({self.status.value})>"
|
||||
|
||||
|
||||
class MaterialTypeEnum(str, enum.Enum):
|
||||
"""Typ-Enum fuer Materialien."""
|
||||
DOCUMENT = "document"
|
||||
LINK = "link"
|
||||
VIDEO = "video"
|
||||
IMAGE = "image"
|
||||
WORKSHEET = "worksheet"
|
||||
PRESENTATION = "presentation"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class PhaseMaterialDB(Base):
|
||||
"""
|
||||
Phasen-Materialien (Feature f19).
|
||||
|
||||
Ermoeglicht das Anhaengen von Dokumenten und Links an Unterrichtsphasen.
|
||||
"""
|
||||
__tablename__ = 'phase_materials'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Material-Details
|
||||
title = Column(String(300), nullable=False)
|
||||
material_type = Column(
|
||||
SQLEnum(MaterialTypeEnum),
|
||||
default=MaterialTypeEnum.DOCUMENT,
|
||||
nullable=False
|
||||
)
|
||||
url = Column(String(2000), nullable=True) # URL oder Dateipfad
|
||||
description = Column(Text, default="")
|
||||
|
||||
# Phasen-Zuordnung
|
||||
phase = Column(String(50), nullable=True, index=True) # einstieg, erarbeitung, etc.
|
||||
subject = Column(String(100), default="")
|
||||
grade_level = Column(String(50), default="")
|
||||
|
||||
# Tags als JSON Array
|
||||
tags = Column(JSON, default=list)
|
||||
|
||||
# Sharing und Nutzung
|
||||
is_public = Column(Boolean, default=False)
|
||||
usage_count = Column(Integer, default=0)
|
||||
|
||||
# Optionale Session-Verknuepfung
|
||||
session_id = Column(String(36), ForeignKey('lesson_sessions.id'), nullable=True, index=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PhaseMaterial {self.title} ({self.material_type.value})>"
|
||||
|
||||
|
||||
class LessonReflectionDB(Base):
|
||||
"""
|
||||
Post-Lesson Reflection (Phase 5: Analytics).
|
||||
|
||||
Ermoeglicht Lehrern, nach der Stunde Reflexionsnotizen zu speichern.
|
||||
"""
|
||||
__tablename__ = 'lesson_reflections'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
session_id = Column(String(36), ForeignKey('lesson_sessions.id'), nullable=False, unique=True, index=True)
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Reflexions-Inhalt
|
||||
notes = Column(Text, default="")
|
||||
|
||||
# Optionale Selbst-Bewertung (1-5)
|
||||
overall_rating = Column(Integer, nullable=True)
|
||||
|
||||
# Was hat gut funktioniert? (JSON Array)
|
||||
what_worked = Column(JSON, default=list)
|
||||
|
||||
# Was wuerde ich anders machen? (JSON Array)
|
||||
improvements = Column(JSON, default=list)
|
||||
|
||||
# Notizen fuer naechste Stunde
|
||||
notes_for_next_lesson = Column(Text, default="")
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LessonReflection session={self.session_id}>"
|
||||
|
||||
|
||||
class FeedbackTypeEnum(str, enum.Enum):
|
||||
"""Feedback-Typen fuer Lehrer-Rueckmeldungen."""
|
||||
BUG = "bug"
|
||||
FEATURE_REQUEST = "feature_request"
|
||||
IMPROVEMENT = "improvement"
|
||||
PRAISE = "praise"
|
||||
QUESTION = "question"
|
||||
|
||||
|
||||
class FeedbackStatusEnum(str, enum.Enum):
|
||||
"""Status eines Feedbacks."""
|
||||
NEW = "new"
|
||||
ACKNOWLEDGED = "acknowledged"
|
||||
PLANNED = "planned"
|
||||
IMPLEMENTED = "implemented"
|
||||
DECLINED = "declined"
|
||||
|
||||
|
||||
class FeedbackPriorityEnum(str, enum.Enum):
|
||||
"""Prioritaet eines Feedbacks."""
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
|
||||
|
||||
class TeacherFeedbackDB(Base):
|
||||
"""
|
||||
Lehrer-Feedback zum Companion-Modul (Phase 7).
|
||||
|
||||
Ermoeglicht Lehrern, Bug-Reports, Feature-Requests und Verbesserungen
|
||||
direkt aus dem Lehrer-Frontend zu senden.
|
||||
"""
|
||||
__tablename__ = 'teacher_feedback'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Wer hat das Feedback gegeben?
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
teacher_name = Column(String(200), default="")
|
||||
teacher_email = Column(String(200), default="")
|
||||
|
||||
# Feedback-Inhalt
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
|
||||
# Kategorisierung
|
||||
feedback_type = Column(
|
||||
SQLEnum(FeedbackTypeEnum),
|
||||
default=FeedbackTypeEnum.IMPROVEMENT,
|
||||
nullable=False
|
||||
)
|
||||
priority = Column(
|
||||
SQLEnum(FeedbackPriorityEnum),
|
||||
default=FeedbackPriorityEnum.MEDIUM,
|
||||
nullable=False
|
||||
)
|
||||
status = Column(
|
||||
SQLEnum(FeedbackStatusEnum),
|
||||
default=FeedbackStatusEnum.NEW,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Optionale Verknuepfung zu Feature
|
||||
related_feature = Column(String(50), nullable=True)
|
||||
|
||||
# Kontext: Wo war der User als er Feedback gab?
|
||||
context_url = Column(String(500), default="")
|
||||
context_phase = Column(String(50), default="") # z.B. "einstieg"
|
||||
context_session_id = Column(String(36), nullable=True)
|
||||
|
||||
# Browser/Device Info
|
||||
user_agent = Column(String(500), default="")
|
||||
|
||||
# Entwickler-Antwort
|
||||
response = Column(Text, default="")
|
||||
responded_at = Column(DateTime, nullable=True)
|
||||
responded_by = Column(String(100), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TeacherFeedback {self.id[:8]} - {self.title[:30]}>"
|
||||
222
backend/classroom_engine/fsm.py
Normal file
222
backend/classroom_engine/fsm.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Finite State Machine fuer Unterrichtsphasen.
|
||||
|
||||
Definiert die erlaubten Phasen-Uebergaenge und fuehrt Transitionen durch.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from .models import LessonPhase, LessonSession, LESSON_PHASES
|
||||
|
||||
|
||||
class LessonStateMachine:
|
||||
"""
|
||||
Finite State Machine fuer die Unterrichtsphasen.
|
||||
|
||||
Erlaubte Uebergaenge:
|
||||
NOT_STARTED -> EINSTIEG -> ERARBEITUNG -> SICHERUNG -> TRANSFER -> REFLEXION -> ENDED
|
||||
"""
|
||||
|
||||
# Definiert erlaubte Uebergaenge: von_phase -> [moeglich_zu_phasen]
|
||||
TRANSITIONS: Dict[LessonPhase, List[LessonPhase]] = {
|
||||
LessonPhase.NOT_STARTED: [LessonPhase.EINSTIEG],
|
||||
LessonPhase.EINSTIEG: [LessonPhase.ERARBEITUNG],
|
||||
LessonPhase.ERARBEITUNG: [LessonPhase.SICHERUNG],
|
||||
LessonPhase.SICHERUNG: [LessonPhase.TRANSFER],
|
||||
LessonPhase.TRANSFER: [LessonPhase.REFLEXION],
|
||||
LessonPhase.REFLEXION: [LessonPhase.ENDED],
|
||||
LessonPhase.ENDED: [], # Terminal State
|
||||
}
|
||||
|
||||
# Phasen-Reihenfolge fuer Timeline
|
||||
PHASE_ORDER: List[LessonPhase] = [
|
||||
LessonPhase.EINSTIEG,
|
||||
LessonPhase.ERARBEITUNG,
|
||||
LessonPhase.SICHERUNG,
|
||||
LessonPhase.TRANSFER,
|
||||
LessonPhase.REFLEXION,
|
||||
]
|
||||
|
||||
def can_transition(self, from_phase: LessonPhase, to_phase: LessonPhase) -> bool:
|
||||
"""
|
||||
Prueft ob ein Uebergang von from_phase zu to_phase erlaubt ist.
|
||||
|
||||
Args:
|
||||
from_phase: Ausgangsphase
|
||||
to_phase: Zielphase
|
||||
|
||||
Returns:
|
||||
True wenn Uebergang erlaubt, False sonst
|
||||
"""
|
||||
allowed_targets = self.TRANSITIONS.get(from_phase, [])
|
||||
return to_phase in allowed_targets
|
||||
|
||||
def next_phase(self, current: LessonPhase) -> Optional[LessonPhase]:
|
||||
"""
|
||||
Gibt die naechste Phase zurueck (oder None wenn keine vorhanden).
|
||||
|
||||
Args:
|
||||
current: Aktuelle Phase
|
||||
|
||||
Returns:
|
||||
Naechste Phase oder None
|
||||
"""
|
||||
transitions = self.TRANSITIONS.get(current, [])
|
||||
return transitions[0] if transitions else None
|
||||
|
||||
def previous_phase(self, current: LessonPhase) -> Optional[LessonPhase]:
|
||||
"""
|
||||
Gibt die vorherige Phase zurueck (oder None wenn keine vorhanden).
|
||||
|
||||
Args:
|
||||
current: Aktuelle Phase
|
||||
|
||||
Returns:
|
||||
Vorherige Phase oder None
|
||||
"""
|
||||
if current == LessonPhase.NOT_STARTED or current == LessonPhase.ENDED:
|
||||
return None
|
||||
|
||||
try:
|
||||
idx = self.PHASE_ORDER.index(current)
|
||||
if idx > 0:
|
||||
return self.PHASE_ORDER[idx - 1]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def transition(self, session: LessonSession, to_phase: LessonPhase) -> LessonSession:
|
||||
"""
|
||||
Fuehrt einen Phasen-Uebergang durch.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
to_phase: Zielphase
|
||||
|
||||
Returns:
|
||||
Aktualisierte Session
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn Uebergang nicht erlaubt
|
||||
"""
|
||||
if not self.can_transition(session.current_phase, to_phase):
|
||||
raise ValueError(
|
||||
f"Ungueltiger Uebergang: {session.current_phase.value} -> {to_phase.value}. "
|
||||
f"Erlaubte Ziele: {[p.value for p in self.TRANSITIONS.get(session.current_phase, [])]}"
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Historie der aktuellen Phase speichern (wenn nicht NOT_STARTED)
|
||||
if session.phase_started_at and session.current_phase != LessonPhase.NOT_STARTED:
|
||||
duration_seconds = (now - session.phase_started_at).total_seconds()
|
||||
session.phase_history.append({
|
||||
"phase": session.current_phase.value,
|
||||
"started_at": session.phase_started_at.isoformat(),
|
||||
"ended_at": now.isoformat(),
|
||||
"duration_seconds": int(duration_seconds),
|
||||
})
|
||||
|
||||
# State aktualisieren
|
||||
session.current_phase = to_phase
|
||||
session.phase_started_at = now
|
||||
|
||||
# Spezielle Behandlung fuer Start und Ende
|
||||
if to_phase == LessonPhase.EINSTIEG:
|
||||
session.lesson_started_at = now
|
||||
elif to_phase == LessonPhase.ENDED:
|
||||
session.lesson_ended_at = now
|
||||
session.phase_started_at = None # Beendete Stunde hat keinen aktiven Timer
|
||||
|
||||
return session
|
||||
|
||||
def get_phase_index(self, phase: LessonPhase) -> int:
|
||||
"""
|
||||
Gibt den Index der Phase in der Reihenfolge zurueck.
|
||||
|
||||
Args:
|
||||
phase: Die Phase
|
||||
|
||||
Returns:
|
||||
Index (0-4) oder -1 fuer NOT_STARTED/ENDED
|
||||
"""
|
||||
if phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
|
||||
return -1
|
||||
|
||||
try:
|
||||
return self.PHASE_ORDER.index(phase)
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
def get_phases_info(self, session: LessonSession) -> List[Dict]:
|
||||
"""
|
||||
Gibt Informationen ueber alle Phasen fuer die Timeline zurueck.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Liste mit Phasen-Informationen
|
||||
"""
|
||||
current_index = self.get_phase_index(session.current_phase)
|
||||
|
||||
phases_info = []
|
||||
for i, phase in enumerate(self.PHASE_ORDER):
|
||||
phase_id = phase.value
|
||||
phase_config = LESSON_PHASES.get(phase_id, {})
|
||||
|
||||
phases_info.append({
|
||||
"phase": phase_id,
|
||||
"display_name": phase_config.get("display_name", phase_id.capitalize()),
|
||||
"icon": phase_config.get("icon", "circle"),
|
||||
"duration_minutes": session.phase_durations.get(
|
||||
phase_id, phase_config.get("default_duration_minutes", 10)
|
||||
),
|
||||
"is_completed": i < current_index if current_index >= 0 else session.current_phase == LessonPhase.ENDED,
|
||||
"is_current": i == current_index,
|
||||
"is_future": i > current_index if current_index >= 0 else session.current_phase == LessonPhase.NOT_STARTED,
|
||||
})
|
||||
|
||||
return phases_info
|
||||
|
||||
def is_lesson_active(self, session: LessonSession) -> bool:
|
||||
"""
|
||||
Prueft ob die Stunde gerade aktiv ist.
|
||||
|
||||
Args:
|
||||
session: Die Session
|
||||
|
||||
Returns:
|
||||
True wenn aktiv, False sonst
|
||||
"""
|
||||
return session.current_phase not in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]
|
||||
|
||||
def is_lesson_ended(self, session: LessonSession) -> bool:
|
||||
"""
|
||||
Prueft ob die Stunde beendet ist.
|
||||
|
||||
Args:
|
||||
session: Die Session
|
||||
|
||||
Returns:
|
||||
True wenn beendet, False sonst
|
||||
"""
|
||||
return session.current_phase == LessonPhase.ENDED
|
||||
|
||||
def get_total_elapsed_seconds(self, session: LessonSession) -> int:
|
||||
"""
|
||||
Berechnet die gesamte verstrichene Zeit seit Stundenbeginn.
|
||||
|
||||
Args:
|
||||
session: Die Session
|
||||
|
||||
Returns:
|
||||
Verstrichene Sekunden oder 0 wenn nicht gestartet
|
||||
"""
|
||||
if not session.lesson_started_at:
|
||||
return 0
|
||||
|
||||
end_time = session.lesson_ended_at or datetime.utcnow()
|
||||
return int((end_time - session.lesson_started_at).total_seconds())
|
||||
407
backend/classroom_engine/models.py
Normal file
407
backend/classroom_engine/models.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
Datenmodelle fuer die Classroom State Machine.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LessonPhase(str, Enum):
|
||||
"""Unterrichtsphasen als Enum."""
|
||||
NOT_STARTED = "not_started"
|
||||
EINSTIEG = "einstieg"
|
||||
ERARBEITUNG = "erarbeitung"
|
||||
SICHERUNG = "sicherung"
|
||||
TRANSFER = "transfer"
|
||||
REFLEXION = "reflexion"
|
||||
ENDED = "ended"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseConfig:
|
||||
"""Konfiguration einer einzelnen Phase."""
|
||||
phase: LessonPhase
|
||||
display_name: str
|
||||
duration_minutes: int
|
||||
activities: List[str]
|
||||
icon: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
# Phasen-Definitionen mit Default-Werten
|
||||
LESSON_PHASES: Dict[str, Dict[str, Any]] = {
|
||||
"einstieg": {
|
||||
"display_name": "Einstieg",
|
||||
"default_duration_minutes": 8,
|
||||
"next_phase": "erarbeitung",
|
||||
"activities": ["warmup", "motivation", "problem_introduction"],
|
||||
"icon": "play_circle",
|
||||
"description": "Motivation und Problemstellung"
|
||||
},
|
||||
"erarbeitung": {
|
||||
"display_name": "Erarbeitung",
|
||||
"default_duration_minutes": 20,
|
||||
"next_phase": "sicherung",
|
||||
"activities": ["instruction", "individual_work", "partner_work", "group_work"],
|
||||
"icon": "edit",
|
||||
"description": "Hauptarbeitsphase mit Input und Uebungen"
|
||||
},
|
||||
"sicherung": {
|
||||
"display_name": "Sicherung",
|
||||
"default_duration_minutes": 10,
|
||||
"next_phase": "transfer",
|
||||
"activities": ["summary", "visualization", "documentation"],
|
||||
"icon": "save",
|
||||
"description": "Ergebnisse festhalten und zusammenfassen"
|
||||
},
|
||||
"transfer": {
|
||||
"display_name": "Transfer",
|
||||
"default_duration_minutes": 7,
|
||||
"next_phase": "reflexion",
|
||||
"activities": ["application", "real_world_connection", "differentiation"],
|
||||
"icon": "compare_arrows",
|
||||
"description": "Anwendung auf neue Kontexte"
|
||||
},
|
||||
"reflexion": {
|
||||
"display_name": "Reflexion",
|
||||
"default_duration_minutes": 5,
|
||||
"next_phase": None,
|
||||
"activities": ["feedback", "homework", "preview"],
|
||||
"icon": "lightbulb",
|
||||
"description": "Rueckblick, Hausaufgaben und Ausblick"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_default_durations() -> Dict[str, int]:
|
||||
"""Gibt die Standard-Phasendauern zurueck."""
|
||||
return {
|
||||
phase_id: config["default_duration_minutes"]
|
||||
for phase_id, config in LESSON_PHASES.items()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LessonSession:
|
||||
"""Repräsentiert eine laufende Unterrichtsstunde."""
|
||||
session_id: str
|
||||
teacher_id: str
|
||||
class_id: str
|
||||
subject: str
|
||||
topic: Optional[str] = None
|
||||
|
||||
# State
|
||||
current_phase: LessonPhase = LessonPhase.NOT_STARTED
|
||||
phase_started_at: Optional[datetime] = None
|
||||
lesson_started_at: Optional[datetime] = None
|
||||
lesson_ended_at: Optional[datetime] = None
|
||||
|
||||
# Pause state (Feature f26/f27)
|
||||
is_paused: bool = False
|
||||
pause_started_at: Optional[datetime] = None
|
||||
total_paused_seconds: int = 0 # Cumulative pause time for current phase
|
||||
|
||||
# Phase durations (customizable per session)
|
||||
phase_durations: Dict[str, int] = field(default_factory=get_default_durations)
|
||||
|
||||
# History
|
||||
phase_history: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
notes: str = ""
|
||||
homework: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Konvertiert die Session in ein Dictionary."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"teacher_id": self.teacher_id,
|
||||
"class_id": self.class_id,
|
||||
"subject": self.subject,
|
||||
"topic": self.topic,
|
||||
"current_phase": self.current_phase.value,
|
||||
"phase_started_at": self.phase_started_at.isoformat() if self.phase_started_at else None,
|
||||
"lesson_started_at": self.lesson_started_at.isoformat() if self.lesson_started_at else None,
|
||||
"lesson_ended_at": self.lesson_ended_at.isoformat() if self.lesson_ended_at else None,
|
||||
"is_paused": self.is_paused,
|
||||
"pause_started_at": self.pause_started_at.isoformat() if self.pause_started_at else None,
|
||||
"total_paused_seconds": self.total_paused_seconds,
|
||||
"phase_durations": self.phase_durations,
|
||||
"phase_history": self.phase_history,
|
||||
"notes": self.notes,
|
||||
"homework": self.homework,
|
||||
}
|
||||
|
||||
def get_phase_display_name(self) -> str:
|
||||
"""Gibt den Anzeigenamen der aktuellen Phase zurueck."""
|
||||
if self.current_phase == LessonPhase.NOT_STARTED:
|
||||
return "Nicht gestartet"
|
||||
elif self.current_phase == LessonPhase.ENDED:
|
||||
return "Beendet"
|
||||
else:
|
||||
return LESSON_PHASES.get(self.current_phase.value, {}).get("display_name", "Unbekannt")
|
||||
|
||||
def get_total_duration_minutes(self) -> int:
|
||||
"""Gibt die Gesamtdauer aller Phasen in Minuten zurueck."""
|
||||
return sum(self.phase_durations.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
class LessonTemplate:
|
||||
"""
|
||||
Vorlage fuer Unterrichtsstunden (Feature f37).
|
||||
|
||||
Ermoeglicht Lehrern, haeufig genutzte Stundenkonfigurationen zu speichern.
|
||||
"""
|
||||
template_id: str
|
||||
teacher_id: str # Ersteller der Vorlage
|
||||
name: str
|
||||
description: str = ""
|
||||
subject: str = ""
|
||||
grade_level: str = "" # z.B. "7", "10", "Oberstufe"
|
||||
|
||||
# Phasenkonfiguration
|
||||
phase_durations: Dict[str, int] = field(default_factory=get_default_durations)
|
||||
|
||||
# Optionale Vorbelegungen
|
||||
default_topic: str = ""
|
||||
default_notes: str = ""
|
||||
|
||||
# Metadaten
|
||||
is_public: bool = False # Oeffentlich fuer alle Lehrer?
|
||||
usage_count: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Konvertiert das Template in ein Dictionary."""
|
||||
return {
|
||||
"template_id": self.template_id,
|
||||
"teacher_id": self.teacher_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"subject": self.subject,
|
||||
"grade_level": self.grade_level,
|
||||
"phase_durations": self.phase_durations,
|
||||
"default_topic": self.default_topic,
|
||||
"default_notes": self.default_notes,
|
||||
"is_public": self.is_public,
|
||||
"usage_count": self.usage_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"total_duration_minutes": sum(self.phase_durations.values()),
|
||||
}
|
||||
|
||||
|
||||
# Vordefinierte System-Templates
|
||||
SYSTEM_TEMPLATES: List[Dict[str, Any]] = [
|
||||
{
|
||||
"template_id": "system_standard_45",
|
||||
"name": "Standard 45 Min",
|
||||
"description": "Klassische Unterrichtsstunde mit 45 Minuten",
|
||||
"phase_durations": {
|
||||
"einstieg": 5,
|
||||
"erarbeitung": 20,
|
||||
"sicherung": 10,
|
||||
"transfer": 5,
|
||||
"reflexion": 5,
|
||||
},
|
||||
"is_public": True,
|
||||
},
|
||||
{
|
||||
"template_id": "system_standard_90",
|
||||
"name": "Doppelstunde 90 Min",
|
||||
"description": "Ausfuehrliche Doppelstunde mit mehr Zeit fuer Erarbeitung",
|
||||
"phase_durations": {
|
||||
"einstieg": 10,
|
||||
"erarbeitung": 40,
|
||||
"sicherung": 20,
|
||||
"transfer": 10,
|
||||
"reflexion": 10,
|
||||
},
|
||||
"is_public": True,
|
||||
},
|
||||
{
|
||||
"template_id": "system_workshop",
|
||||
"name": "Workshop-Stil",
|
||||
"description": "Praxisorientiert mit langer Erarbeitungsphase",
|
||||
"phase_durations": {
|
||||
"einstieg": 5,
|
||||
"erarbeitung": 30,
|
||||
"sicherung": 5,
|
||||
"transfer": 5,
|
||||
"reflexion": 5,
|
||||
},
|
||||
"is_public": True,
|
||||
},
|
||||
{
|
||||
"template_id": "system_discussion",
|
||||
"name": "Diskussion & Reflexion",
|
||||
"description": "Fuer Themen mit viel Diskussionsbedarf",
|
||||
"phase_durations": {
|
||||
"einstieg": 8,
|
||||
"erarbeitung": 15,
|
||||
"sicherung": 7,
|
||||
"transfer": 10,
|
||||
"reflexion": 10,
|
||||
},
|
||||
"is_public": True,
|
||||
},
|
||||
{
|
||||
"template_id": "system_test_prep",
|
||||
"name": "Pruefungsvorbereitung",
|
||||
"description": "Kompakte Wiederholung vor Tests",
|
||||
"phase_durations": {
|
||||
"einstieg": 3,
|
||||
"erarbeitung": 25,
|
||||
"sicherung": 12,
|
||||
"transfer": 3,
|
||||
"reflexion": 2,
|
||||
},
|
||||
"is_public": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseSuggestion:
|
||||
"""Ein Vorschlag fuer eine Aktivitaet in einer Phase (Feature f18)."""
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
activity_type: str # warmup, instruction, exercise, etc.
|
||||
estimated_minutes: int
|
||||
icon: str
|
||||
content_url: Optional[str] = None # Link to learning unit
|
||||
subjects: Optional[List[str]] = None # Faecher fuer die der Vorschlag passt (None = alle)
|
||||
grade_levels: Optional[List[str]] = None # Klassenstufen (None = alle)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Konvertiert den Vorschlag in ein Dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"activity_type": self.activity_type,
|
||||
"estimated_minutes": self.estimated_minutes,
|
||||
"icon": self.icon,
|
||||
"content_url": self.content_url,
|
||||
"subjects": self.subjects,
|
||||
"grade_levels": self.grade_levels,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Homework Tracker (Feature f20) ====================
|
||||
|
||||
class HomeworkStatus(Enum):
|
||||
"""Status einer Hausaufgabe."""
|
||||
ASSIGNED = "assigned" # Aufgegeben
|
||||
IN_PROGRESS = "in_progress" # In Bearbeitung
|
||||
COMPLETED = "completed" # Erledigt
|
||||
OVERDUE = "overdue" # Ueberfaellig
|
||||
|
||||
|
||||
@dataclass
|
||||
class Homework:
|
||||
"""
|
||||
Eine Hausaufgabe (Feature f20).
|
||||
|
||||
Ermoeglicht das Tracking von Hausaufgaben ueber Sessions hinweg.
|
||||
"""
|
||||
homework_id: str
|
||||
teacher_id: str
|
||||
class_id: str
|
||||
subject: str
|
||||
title: str
|
||||
description: str = ""
|
||||
session_id: Optional[str] = None # Verknuepfte Session
|
||||
due_date: Optional[datetime] = None
|
||||
status: HomeworkStatus = HomeworkStatus.ASSIGNED
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Konvertiert die Hausaufgabe in ein Dictionary."""
|
||||
return {
|
||||
"homework_id": self.homework_id,
|
||||
"teacher_id": self.teacher_id,
|
||||
"class_id": self.class_id,
|
||||
"subject": self.subject,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"session_id": self.session_id,
|
||||
"due_date": self.due_date.isoformat() if self.due_date else None,
|
||||
"status": self.status.value,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"is_overdue": self.is_overdue,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_overdue(self) -> bool:
|
||||
"""Prueft ob die Hausaufgabe ueberfaellig ist."""
|
||||
if not self.due_date:
|
||||
return False
|
||||
if self.status == HomeworkStatus.COMPLETED:
|
||||
return False
|
||||
return datetime.now() > self.due_date
|
||||
|
||||
|
||||
# ==================== Phase Materials (Feature f19) ====================
|
||||
|
||||
class MaterialType(Enum):
|
||||
"""Typ des Materials."""
|
||||
DOCUMENT = "document" # PDF, Word, etc.
|
||||
LINK = "link" # URL
|
||||
VIDEO = "video" # Video-Link
|
||||
IMAGE = "image" # Bild
|
||||
WORKSHEET = "worksheet" # Arbeitsblatt
|
||||
PRESENTATION = "presentation" # Praesentation
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseMaterial:
|
||||
"""
|
||||
Material fuer eine Unterrichtsphase (Feature f19).
|
||||
|
||||
Ermoeglicht das Anhaengen von Dokumenten, Links und Medien
|
||||
an bestimmte Phasen einer Unterrichtsstunde.
|
||||
"""
|
||||
material_id: str
|
||||
teacher_id: str
|
||||
title: str
|
||||
material_type: MaterialType = MaterialType.DOCUMENT
|
||||
url: Optional[str] = None # URL oder Dateipfad
|
||||
description: str = ""
|
||||
phase: Optional[str] = None # Phasen-ID (einstieg, erarbeitung, etc.)
|
||||
subject: str = ""
|
||||
grade_level: str = ""
|
||||
tags: List[str] = field(default_factory=list)
|
||||
is_public: bool = False # Fuer Sharing mit anderen Lehrern
|
||||
usage_count: int = 0
|
||||
session_id: Optional[str] = None # Verknuepfung mit einer Session
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Konvertiert das Material in ein Dictionary."""
|
||||
return {
|
||||
"material_id": self.material_id,
|
||||
"teacher_id": self.teacher_id,
|
||||
"title": self.title,
|
||||
"material_type": self.material_type.value,
|
||||
"url": self.url,
|
||||
"description": self.description,
|
||||
"phase": self.phase,
|
||||
"subject": self.subject,
|
||||
"grade_level": self.grade_level,
|
||||
"tags": self.tags,
|
||||
"is_public": self.is_public,
|
||||
"usage_count": self.usage_count,
|
||||
"session_id": self.session_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
1705
backend/classroom_engine/repository.py
Normal file
1705
backend/classroom_engine/repository.py
Normal file
File diff suppressed because it is too large
Load Diff
668
backend/classroom_engine/suggestions.py
Normal file
668
backend/classroom_engine/suggestions.py
Normal file
@@ -0,0 +1,668 @@
|
||||
"""
|
||||
Phasenspezifische Content-Vorschlaege (Feature f18 erweitert).
|
||||
|
||||
Generiert Aktivitaets-Vorschlaege basierend auf der aktuellen Unterrichtsphase
|
||||
und optional dem Fach.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from .models import LessonPhase, LessonSession, PhaseSuggestion
|
||||
|
||||
|
||||
# Unterstuetzte Faecher fuer fachspezifische Vorschlaege
|
||||
SUPPORTED_SUBJECTS = [
|
||||
"mathematik", "mathe", "math",
|
||||
"deutsch",
|
||||
"englisch", "english",
|
||||
"biologie", "bio",
|
||||
"physik",
|
||||
"chemie",
|
||||
"geschichte",
|
||||
"geografie", "erdkunde",
|
||||
"kunst",
|
||||
"musik",
|
||||
"sport",
|
||||
"informatik",
|
||||
]
|
||||
|
||||
|
||||
# Fachspezifische Vorschlaege (Feature f18)
|
||||
SUBJECT_SUGGESTIONS: Dict[str, Dict[LessonPhase, List[Dict[str, Any]]]] = {
|
||||
"mathematik": {
|
||||
LessonPhase.EINSTIEG: [
|
||||
{
|
||||
"id": "math_warm_up",
|
||||
"title": "Kopfrechnen-Challenge",
|
||||
"description": "5 schnelle Kopfrechenaufgaben zum Aufwaermen",
|
||||
"activity_type": "warmup",
|
||||
"estimated_minutes": 3,
|
||||
"icon": "calculate",
|
||||
"subjects": ["mathematik", "mathe"],
|
||||
},
|
||||
{
|
||||
"id": "math_puzzle",
|
||||
"title": "Mathematisches Raetsel",
|
||||
"description": "Ein kniffliges Zahlenraetsel als Einstieg",
|
||||
"activity_type": "motivation",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "extension",
|
||||
"subjects": ["mathematik", "mathe"],
|
||||
},
|
||||
],
|
||||
LessonPhase.ERARBEITUNG: [
|
||||
{
|
||||
"id": "math_geogebra",
|
||||
"title": "GeoGebra-Exploration",
|
||||
"description": "Interaktive Visualisierung mit GeoGebra",
|
||||
"activity_type": "individual_work",
|
||||
"estimated_minutes": 15,
|
||||
"icon": "functions",
|
||||
"subjects": ["mathematik", "mathe"],
|
||||
},
|
||||
{
|
||||
"id": "math_peer_explain",
|
||||
"title": "Rechenweg erklaeren",
|
||||
"description": "Schueler erklaeren sich gegenseitig ihre Loesungswege",
|
||||
"activity_type": "partner_work",
|
||||
"estimated_minutes": 10,
|
||||
"icon": "groups",
|
||||
"subjects": ["mathematik", "mathe"],
|
||||
},
|
||||
],
|
||||
LessonPhase.SICHERUNG: [
|
||||
{
|
||||
"id": "math_formula_card",
|
||||
"title": "Formelkarte erstellen",
|
||||
"description": "Wichtigste Formeln auf einer Karte festhalten",
|
||||
"activity_type": "documentation",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "note_alt",
|
||||
"subjects": ["mathematik", "mathe"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"deutsch": {
|
||||
LessonPhase.EINSTIEG: [
|
||||
{
|
||||
"id": "deutsch_wordle",
|
||||
"title": "Wordle-Variante",
|
||||
"description": "Wort des Tages erraten",
|
||||
"activity_type": "warmup",
|
||||
"estimated_minutes": 4,
|
||||
"icon": "abc",
|
||||
"subjects": ["deutsch"],
|
||||
},
|
||||
{
|
||||
"id": "deutsch_zitat",
|
||||
"title": "Zitat-Interpretation",
|
||||
"description": "Ein literarisches Zitat gemeinsam deuten",
|
||||
"activity_type": "motivation",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "format_quote",
|
||||
"subjects": ["deutsch"],
|
||||
},
|
||||
],
|
||||
LessonPhase.ERARBEITUNG: [
|
||||
{
|
||||
"id": "deutsch_textarbeit",
|
||||
"title": "Textanalyse in Gruppen",
|
||||
"description": "Gruppenarbeit zu verschiedenen Textabschnitten",
|
||||
"activity_type": "group_work",
|
||||
"estimated_minutes": 15,
|
||||
"icon": "menu_book",
|
||||
"subjects": ["deutsch"],
|
||||
},
|
||||
{
|
||||
"id": "deutsch_schreibworkshop",
|
||||
"title": "Schreibwerkstatt",
|
||||
"description": "Kreatives Schreiben mit Peer-Feedback",
|
||||
"activity_type": "individual_work",
|
||||
"estimated_minutes": 20,
|
||||
"icon": "edit_note",
|
||||
"subjects": ["deutsch"],
|
||||
},
|
||||
],
|
||||
LessonPhase.SICHERUNG: [
|
||||
{
|
||||
"id": "deutsch_zusammenfassung",
|
||||
"title": "Text-Zusammenfassung",
|
||||
"description": "Die wichtigsten Punkte in 3 Saetzen formulieren",
|
||||
"activity_type": "summary",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "summarize",
|
||||
"subjects": ["deutsch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"englisch": {
|
||||
LessonPhase.EINSTIEG: [
|
||||
{
|
||||
"id": "english_smalltalk",
|
||||
"title": "Small Talk Warm-Up",
|
||||
"description": "2-Minuten Gespraeche zu einem Alltagsthema",
|
||||
"activity_type": "warmup",
|
||||
"estimated_minutes": 4,
|
||||
"icon": "chat",
|
||||
"subjects": ["englisch", "english"],
|
||||
},
|
||||
{
|
||||
"id": "english_video",
|
||||
"title": "Authentic Video Clip",
|
||||
"description": "Kurzer Clip aus einer englischen Serie oder Nachricht",
|
||||
"activity_type": "motivation",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "movie",
|
||||
"subjects": ["englisch", "english"],
|
||||
},
|
||||
],
|
||||
LessonPhase.ERARBEITUNG: [
|
||||
{
|
||||
"id": "english_role_play",
|
||||
"title": "Role Play Activity",
|
||||
"description": "Dialoguebung in authentischen Situationen",
|
||||
"activity_type": "partner_work",
|
||||
"estimated_minutes": 12,
|
||||
"icon": "theater_comedy",
|
||||
"subjects": ["englisch", "english"],
|
||||
},
|
||||
{
|
||||
"id": "english_reading_circle",
|
||||
"title": "Reading Circle",
|
||||
"description": "Gemeinsames Lesen mit verteilten Rollen",
|
||||
"activity_type": "group_work",
|
||||
"estimated_minutes": 15,
|
||||
"icon": "auto_stories",
|
||||
"subjects": ["englisch", "english"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"biologie": {
|
||||
LessonPhase.EINSTIEG: [
|
||||
{
|
||||
"id": "bio_nature_question",
|
||||
"title": "Naturfrage",
|
||||
"description": "Eine spannende Frage aus der Natur diskutieren",
|
||||
"activity_type": "motivation",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "eco",
|
||||
"subjects": ["biologie", "bio"],
|
||||
},
|
||||
],
|
||||
LessonPhase.ERARBEITUNG: [
|
||||
{
|
||||
"id": "bio_experiment",
|
||||
"title": "Mini-Experiment",
|
||||
"description": "Einfaches Experiment zum Thema durchfuehren",
|
||||
"activity_type": "group_work",
|
||||
"estimated_minutes": 20,
|
||||
"icon": "science",
|
||||
"subjects": ["biologie", "bio"],
|
||||
},
|
||||
{
|
||||
"id": "bio_diagram",
|
||||
"title": "Biologische Zeichnung",
|
||||
"description": "Beschriftete Zeichnung eines Organismus",
|
||||
"activity_type": "individual_work",
|
||||
"estimated_minutes": 15,
|
||||
"icon": "draw",
|
||||
"subjects": ["biologie", "bio"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"physik": {
|
||||
LessonPhase.EINSTIEG: [
|
||||
{
|
||||
"id": "physik_demo",
|
||||
"title": "Phaenomen-Demo",
|
||||
"description": "Ein physikalisches Phaenomen vorfuehren",
|
||||
"activity_type": "motivation",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "bolt",
|
||||
"subjects": ["physik"],
|
||||
},
|
||||
],
|
||||
LessonPhase.ERARBEITUNG: [
|
||||
{
|
||||
"id": "physik_simulation",
|
||||
"title": "PhET-Simulation",
|
||||
"description": "Interaktive Simulation von phet.colorado.edu",
|
||||
"activity_type": "individual_work",
|
||||
"estimated_minutes": 15,
|
||||
"icon": "smart_toy",
|
||||
"subjects": ["physik"],
|
||||
},
|
||||
{
|
||||
"id": "physik_rechnung",
|
||||
"title": "Physikalische Rechnung",
|
||||
"description": "Rechenaufgabe mit physikalischem Kontext",
|
||||
"activity_type": "partner_work",
|
||||
"estimated_minutes": 12,
|
||||
"icon": "calculate",
|
||||
"subjects": ["physik"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"informatik": {
|
||||
LessonPhase.EINSTIEG: [
|
||||
{
|
||||
"id": "info_code_puzzle",
|
||||
"title": "Code-Puzzle",
|
||||
"description": "Kurzen Code-Schnipsel analysieren - was macht er?",
|
||||
"activity_type": "warmup",
|
||||
"estimated_minutes": 4,
|
||||
"icon": "code",
|
||||
"subjects": ["informatik"],
|
||||
},
|
||||
],
|
||||
LessonPhase.ERARBEITUNG: [
|
||||
{
|
||||
"id": "info_live_coding",
|
||||
"title": "Live Coding",
|
||||
"description": "Gemeinsam Code entwickeln mit Erklaerungen",
|
||||
"activity_type": "instruction",
|
||||
"estimated_minutes": 15,
|
||||
"icon": "terminal",
|
||||
"subjects": ["informatik"],
|
||||
},
|
||||
{
|
||||
"id": "info_pair_programming",
|
||||
"title": "Pair Programming",
|
||||
"description": "Zu zweit programmieren - Driver und Navigator",
|
||||
"activity_type": "partner_work",
|
||||
"estimated_minutes": 20,
|
||||
"icon": "computer",
|
||||
"subjects": ["informatik"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Vordefinierte allgemeine Vorschlaege pro Phase
|
||||
PHASE_SUGGESTIONS: Dict[LessonPhase, List[Dict[str, Any]]] = {
|
||||
LessonPhase.EINSTIEG: [
|
||||
{
|
||||
"id": "warmup_quiz",
|
||||
"title": "Kurzes Quiz zum Einstieg",
|
||||
"description": "Aktivieren Sie das Vorwissen der Schueler mit 3-5 Fragen zum Thema",
|
||||
"activity_type": "warmup",
|
||||
"estimated_minutes": 3,
|
||||
"icon": "quiz"
|
||||
},
|
||||
{
|
||||
"id": "problem_story",
|
||||
"title": "Problemgeschichte erzaehlen",
|
||||
"description": "Stellen Sie ein alltagsnahes Problem vor, das zum Thema fuehrt",
|
||||
"activity_type": "motivation",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "auto_stories"
|
||||
},
|
||||
{
|
||||
"id": "video_intro",
|
||||
"title": "Kurzes Erklaervideo",
|
||||
"description": "Zeigen Sie ein 2-3 Minuten Video zur Einfuehrung ins Thema",
|
||||
"activity_type": "motivation",
|
||||
"estimated_minutes": 4,
|
||||
"icon": "play_circle"
|
||||
},
|
||||
{
|
||||
"id": "brainstorming",
|
||||
"title": "Brainstorming",
|
||||
"description": "Sammeln Sie Ideen und Vorkenntnisse der Schueler an der Tafel",
|
||||
"activity_type": "warmup",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "psychology"
|
||||
},
|
||||
{
|
||||
"id": "daily_challenge",
|
||||
"title": "Tagesaufgabe vorstellen",
|
||||
"description": "Praesentieren Sie die zentrale Frage oder Aufgabe der Stunde",
|
||||
"activity_type": "problem_introduction",
|
||||
"estimated_minutes": 3,
|
||||
"icon": "flag"
|
||||
}
|
||||
],
|
||||
LessonPhase.ERARBEITUNG: [
|
||||
{
|
||||
"id": "think_pair_share",
|
||||
"title": "Think-Pair-Share",
|
||||
"description": "Schueler denken erst einzeln nach, tauschen sich dann zu zweit aus und praesentieren im Plenum",
|
||||
"activity_type": "partner_work",
|
||||
"estimated_minutes": 10,
|
||||
"icon": "groups"
|
||||
},
|
||||
{
|
||||
"id": "worksheet_digital",
|
||||
"title": "Digitales Arbeitsblatt",
|
||||
"description": "Schueler bearbeiten ein interaktives Arbeitsblatt am Tablet oder Computer",
|
||||
"activity_type": "individual_work",
|
||||
"estimated_minutes": 15,
|
||||
"icon": "description"
|
||||
},
|
||||
{
|
||||
"id": "station_learning",
|
||||
"title": "Stationenlernen",
|
||||
"description": "Verschiedene Stationen mit unterschiedlichen Aufgaben und Materialien",
|
||||
"activity_type": "group_work",
|
||||
"estimated_minutes": 20,
|
||||
"icon": "hub"
|
||||
},
|
||||
{
|
||||
"id": "expert_puzzle",
|
||||
"title": "Expertenrunde (Jigsaw)",
|
||||
"description": "Schueler werden Experten fuer ein Teilthema und lehren es anderen",
|
||||
"activity_type": "group_work",
|
||||
"estimated_minutes": 15,
|
||||
"icon": "extension"
|
||||
},
|
||||
{
|
||||
"id": "guided_instruction",
|
||||
"title": "Geleitete Instruktion",
|
||||
"description": "Schrittweise Erklaerung mit Uebungsphasen zwischendurch",
|
||||
"activity_type": "instruction",
|
||||
"estimated_minutes": 12,
|
||||
"icon": "school"
|
||||
},
|
||||
{
|
||||
"id": "pair_programming",
|
||||
"title": "Partnerarbeit",
|
||||
"description": "Zwei Schueler loesen gemeinsam eine Aufgabe",
|
||||
"activity_type": "partner_work",
|
||||
"estimated_minutes": 10,
|
||||
"icon": "people"
|
||||
}
|
||||
],
|
||||
LessonPhase.SICHERUNG: [
|
||||
{
|
||||
"id": "mindmap_class",
|
||||
"title": "Gemeinsame Mindmap",
|
||||
"description": "Ergebnisse als Mindmap an der Tafel oder digital sammeln und strukturieren",
|
||||
"activity_type": "visualization",
|
||||
"estimated_minutes": 8,
|
||||
"icon": "account_tree"
|
||||
},
|
||||
{
|
||||
"id": "exit_ticket",
|
||||
"title": "Exit Ticket",
|
||||
"description": "Schueler notieren 3 Dinge die sie gelernt haben und 1 offene Frage",
|
||||
"activity_type": "summary",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "sticky_note_2"
|
||||
},
|
||||
{
|
||||
"id": "gallery_walk",
|
||||
"title": "Galerie-Rundgang",
|
||||
"description": "Schueler praesentieren ihre Ergebnisse und geben sich Feedback",
|
||||
"activity_type": "presentation",
|
||||
"estimated_minutes": 10,
|
||||
"icon": "photo_library"
|
||||
},
|
||||
{
|
||||
"id": "key_points",
|
||||
"title": "Kernpunkte zusammenfassen",
|
||||
"description": "Gemeinsam die wichtigsten Erkenntnisse der Stunde formulieren",
|
||||
"activity_type": "summary",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "format_list_bulleted"
|
||||
},
|
||||
{
|
||||
"id": "quick_check",
|
||||
"title": "Schneller Wissenscheck",
|
||||
"description": "5 kurze Fragen zur Ueberpruefung des Verstaendnisses",
|
||||
"activity_type": "documentation",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "fact_check"
|
||||
}
|
||||
],
|
||||
LessonPhase.TRANSFER: [
|
||||
{
|
||||
"id": "real_world_example",
|
||||
"title": "Alltagsbeispiele finden",
|
||||
"description": "Schueler suchen Beispiele aus ihrem Alltag, wo das Gelernte vorkommt",
|
||||
"activity_type": "application",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "public"
|
||||
},
|
||||
{
|
||||
"id": "challenge_task",
|
||||
"title": "Knobelaufgabe",
|
||||
"description": "Eine anspruchsvollere Aufgabe fuer schnelle Schueler oder als Bonus",
|
||||
"activity_type": "differentiation",
|
||||
"estimated_minutes": 7,
|
||||
"icon": "psychology"
|
||||
},
|
||||
{
|
||||
"id": "creative_application",
|
||||
"title": "Kreative Anwendung",
|
||||
"description": "Schueler wenden das Gelernte in einem kreativen Projekt an",
|
||||
"activity_type": "application",
|
||||
"estimated_minutes": 10,
|
||||
"icon": "palette"
|
||||
},
|
||||
{
|
||||
"id": "peer_teaching",
|
||||
"title": "Peer-Teaching",
|
||||
"description": "Schueler erklaeren sich gegenseitig das Gelernte",
|
||||
"activity_type": "real_world_connection",
|
||||
"estimated_minutes": 5,
|
||||
"icon": "supervisor_account"
|
||||
}
|
||||
],
|
||||
LessonPhase.REFLEXION: [
|
||||
{
|
||||
"id": "thumbs_feedback",
|
||||
"title": "Daumen-Feedback",
|
||||
"description": "Schnelle Stimmungsabfrage: Daumen hoch/mitte/runter",
|
||||
"activity_type": "feedback",
|
||||
"estimated_minutes": 2,
|
||||
"icon": "thumb_up"
|
||||
},
|
||||
{
|
||||
"id": "homework_assign",
|
||||
"title": "Hausaufgabe vergeben",
|
||||
"description": "Passende Hausaufgabe zur Vertiefung des Gelernten",
|
||||
"activity_type": "homework",
|
||||
"estimated_minutes": 3,
|
||||
"icon": "home_work"
|
||||
},
|
||||
{
|
||||
"id": "one_word",
|
||||
"title": "Ein-Wort-Reflexion",
|
||||
"description": "Jeder Schueler nennt ein Wort, das die Stunde beschreibt",
|
||||
"activity_type": "feedback",
|
||||
"estimated_minutes": 3,
|
||||
"icon": "chat"
|
||||
},
|
||||
{
|
||||
"id": "preview_next",
|
||||
"title": "Ausblick naechste Stunde",
|
||||
"description": "Kurzer Ausblick auf das Thema der naechsten Stunde",
|
||||
"activity_type": "preview",
|
||||
"estimated_minutes": 2,
|
||||
"icon": "event"
|
||||
},
|
||||
{
|
||||
"id": "learning_log",
|
||||
"title": "Lerntagebuch",
|
||||
"description": "Schueler notieren ihre wichtigsten Erkenntnisse im Lerntagebuch",
|
||||
"activity_type": "feedback",
|
||||
"estimated_minutes": 4,
|
||||
"icon": "menu_book"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class SuggestionEngine:
|
||||
"""
|
||||
Engine zur Generierung von phasenspezifischen Vorschlaegen (Feature f18 erweitert).
|
||||
|
||||
Liefert Aktivitaets-Vorschlaege basierend auf:
|
||||
- Aktueller Phase
|
||||
- Fach (priorisiert fachspezifische Vorschlaege)
|
||||
- Optional: Klassenstufe, bisherige Nutzung
|
||||
"""
|
||||
|
||||
def _normalize_subject(self, subject: str) -> Optional[str]:
|
||||
"""Normalisiert Fachnamen fuer die Suche."""
|
||||
if not subject:
|
||||
return None
|
||||
normalized = subject.lower().strip()
|
||||
# Mapping zu Hauptkategorien
|
||||
mappings = {
|
||||
"mathe": "mathematik",
|
||||
"math": "mathematik",
|
||||
"bio": "biologie",
|
||||
"english": "englisch",
|
||||
"erdkunde": "geografie",
|
||||
}
|
||||
return mappings.get(normalized, normalized)
|
||||
|
||||
def _get_subject_suggestions(
|
||||
self,
|
||||
subject: str,
|
||||
phase: LessonPhase
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Holt fachspezifische Vorschlaege (Feature f18)."""
|
||||
normalized = self._normalize_subject(subject)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
subject_data = SUBJECT_SUGGESTIONS.get(normalized, {})
|
||||
return subject_data.get(phase, [])
|
||||
|
||||
def get_suggestions(
|
||||
self,
|
||||
session: LessonSession,
|
||||
limit: int = 3
|
||||
) -> List[PhaseSuggestion]:
|
||||
"""
|
||||
Gibt Vorschlaege fuer die aktuelle Phase zurueck.
|
||||
|
||||
Priorisiert fachspezifische Vorschlaege (Feature f18),
|
||||
ergaenzt mit allgemeinen Vorschlaegen.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
limit: Maximale Anzahl Vorschlaege
|
||||
|
||||
Returns:
|
||||
Liste von PhaseSuggestion Objekten
|
||||
"""
|
||||
# Keine Vorschlaege fuer inaktive Phasen
|
||||
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
|
||||
return []
|
||||
|
||||
suggestions = []
|
||||
seen_ids = set()
|
||||
|
||||
# 1. Fachspezifische Vorschlaege zuerst (Feature f18)
|
||||
if session.subject:
|
||||
subject_suggestions = self._get_subject_suggestions(
|
||||
session.subject,
|
||||
session.current_phase
|
||||
)
|
||||
for s in subject_suggestions:
|
||||
if s["id"] not in seen_ids:
|
||||
suggestions.append(PhaseSuggestion(**s))
|
||||
seen_ids.add(s["id"])
|
||||
if len(suggestions) >= limit:
|
||||
return suggestions
|
||||
|
||||
# 2. Allgemeine Vorschlaege ergaenzen
|
||||
phase_suggestions = PHASE_SUGGESTIONS.get(session.current_phase, [])
|
||||
for s in phase_suggestions:
|
||||
if s["id"] not in seen_ids:
|
||||
suggestions.append(PhaseSuggestion(**s))
|
||||
seen_ids.add(s["id"])
|
||||
if len(suggestions) >= limit:
|
||||
break
|
||||
|
||||
return suggestions
|
||||
|
||||
def get_all_suggestions(self, session: LessonSession) -> List[PhaseSuggestion]:
|
||||
"""
|
||||
Gibt alle Vorschlaege fuer die aktuelle Phase zurueck.
|
||||
|
||||
Inkludiert fachspezifische und allgemeine Vorschlaege (Feature f18).
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Alle Vorschlaege fuer die Phase
|
||||
"""
|
||||
return self.get_suggestions(session, limit=100)
|
||||
|
||||
def get_suggestion_by_id(
|
||||
self,
|
||||
session: LessonSession,
|
||||
suggestion_id: str
|
||||
) -> Optional[PhaseSuggestion]:
|
||||
"""
|
||||
Gibt einen spezifischen Vorschlag zurueck.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
suggestion_id: ID des Vorschlags
|
||||
|
||||
Returns:
|
||||
Der Vorschlag oder None
|
||||
"""
|
||||
all_suggestions = self.get_all_suggestions(session)
|
||||
for s in all_suggestions:
|
||||
if s.id == suggestion_id:
|
||||
return s
|
||||
return None
|
||||
|
||||
def get_suggestions_by_type(
|
||||
self,
|
||||
session: LessonSession,
|
||||
activity_type: str
|
||||
) -> List[PhaseSuggestion]:
|
||||
"""
|
||||
Gibt Vorschlaege eines bestimmten Typs zurueck.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
activity_type: Der Aktivitaetstyp (z.B. "warmup", "group_work")
|
||||
|
||||
Returns:
|
||||
Gefilterte Vorschlaege
|
||||
"""
|
||||
all_suggestions = self.get_all_suggestions(session)
|
||||
return [s for s in all_suggestions if s.activity_type == activity_type]
|
||||
|
||||
def get_suggestions_response(
|
||||
self,
|
||||
session: LessonSession,
|
||||
limit: int = 3
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Gibt die Vorschlaege als API-Response-Format zurueck (Feature f18 erweitert).
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
limit: Maximale Anzahl Vorschlaege
|
||||
|
||||
Returns:
|
||||
Dictionary fuer API-Response
|
||||
"""
|
||||
suggestions = self.get_suggestions(session, limit)
|
||||
|
||||
# Zaehle fachspezifische und allgemeine Vorschlaege
|
||||
general_count = len(PHASE_SUGGESTIONS.get(session.current_phase, []))
|
||||
subject_count = len(self._get_subject_suggestions(
|
||||
session.subject,
|
||||
session.current_phase
|
||||
)) if session.subject else 0
|
||||
|
||||
return {
|
||||
"suggestions": [s.to_dict() for s in suggestions],
|
||||
"current_phase": session.current_phase.value,
|
||||
"phase_display_name": session.get_phase_display_name(),
|
||||
"total_available": general_count + subject_count,
|
||||
"subject_specific_available": subject_count,
|
||||
"subject": session.subject,
|
||||
}
|
||||
272
backend/classroom_engine/timer.py
Normal file
272
backend/classroom_engine/timer.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Timer Service fuer Phasen-Countdown.
|
||||
|
||||
Berechnet verbleibende Zeit, Warnungen und Overtime-Status.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from .models import LessonPhase, LessonSession
|
||||
|
||||
|
||||
class PhaseTimer:
|
||||
"""
|
||||
Timer Service fuer den Phasen-Countdown.
|
||||
|
||||
Features:
|
||||
- Berechnet verbleibende Zeit pro Phase
|
||||
- Warnung bei 2 Minuten vor Ende
|
||||
- Overtime-Erkennung wenn Zeit abgelaufen
|
||||
"""
|
||||
|
||||
# Warnung X Sekunden vor Ende (2 Minuten)
|
||||
WARNING_THRESHOLD_SECONDS = 120
|
||||
|
||||
def get_remaining_seconds(self, session: LessonSession) -> int:
|
||||
"""
|
||||
Berechnet die verbleibende Zeit in der aktuellen Phase.
|
||||
|
||||
Beruecksichtigt Pause-Zeit: Wenn pausiert, wird die Zeit seit Pause-Start
|
||||
nicht mitgezaehlt. Die kumulative Pause-Zeit (total_paused_seconds) wird
|
||||
von der verstrichenen Zeit abgezogen.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Verbleibende Sekunden (min 0)
|
||||
"""
|
||||
# Inaktive Phasen haben keinen Timer
|
||||
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
|
||||
return 0
|
||||
|
||||
if not session.phase_started_at:
|
||||
return 0
|
||||
|
||||
# Phasendauer holen
|
||||
phase_id = session.current_phase.value
|
||||
duration_minutes = session.phase_durations.get(phase_id, 10)
|
||||
duration_seconds = duration_minutes * 60
|
||||
|
||||
# Verstrichene Zeit berechnen (mit Pause-Beruecksichtigung)
|
||||
elapsed = self._get_effective_elapsed(session)
|
||||
remaining = duration_seconds - elapsed
|
||||
|
||||
return max(0, int(remaining))
|
||||
|
||||
def _get_effective_elapsed(self, session: LessonSession) -> float:
|
||||
"""
|
||||
Berechnet die effektive verstrichene Zeit abzueglich Pausenzeit.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Effektive verstrichene Sekunden
|
||||
"""
|
||||
if not session.phase_started_at:
|
||||
return 0
|
||||
|
||||
# Basis: Zeit seit Phasenstart
|
||||
total_elapsed = (datetime.utcnow() - session.phase_started_at).total_seconds()
|
||||
|
||||
# Abzug: Kumulative Pause-Zeit
|
||||
total_elapsed -= session.total_paused_seconds
|
||||
|
||||
# Wenn aktuell pausiert: Zeit seit Pause-Start auch abziehen
|
||||
if session.is_paused and session.pause_started_at:
|
||||
current_pause = (datetime.utcnow() - session.pause_started_at).total_seconds()
|
||||
total_elapsed -= current_pause
|
||||
|
||||
return max(0, total_elapsed)
|
||||
|
||||
def get_elapsed_seconds(self, session: LessonSession) -> int:
|
||||
"""
|
||||
Berechnet die effektive verstrichene Zeit in der aktuellen Phase.
|
||||
|
||||
Beruecksichtigt Pause-Zeit analog zu get_remaining_seconds.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Verstrichene Sekunden (abzueglich Pausen)
|
||||
"""
|
||||
if not session.phase_started_at:
|
||||
return 0
|
||||
|
||||
if session.current_phase == LessonPhase.ENDED:
|
||||
return 0
|
||||
|
||||
return int(self._get_effective_elapsed(session))
|
||||
|
||||
def get_total_seconds(self, session: LessonSession) -> int:
|
||||
"""
|
||||
Gibt die Gesamtdauer der aktuellen Phase in Sekunden zurueck.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Gesamtdauer in Sekunden
|
||||
"""
|
||||
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
|
||||
return 0
|
||||
|
||||
phase_id = session.current_phase.value
|
||||
duration_minutes = session.phase_durations.get(phase_id, 10)
|
||||
return duration_minutes * 60
|
||||
|
||||
def get_percentage_remaining(self, session: LessonSession) -> int:
|
||||
"""
|
||||
Berechnet den Prozentsatz der verbleibenden Zeit.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Prozent (0-100)
|
||||
"""
|
||||
total = self.get_total_seconds(session)
|
||||
if total == 0:
|
||||
return 0
|
||||
|
||||
remaining = self.get_remaining_seconds(session)
|
||||
return round((remaining / total) * 100)
|
||||
|
||||
def get_percentage_elapsed(self, session: LessonSession) -> int:
|
||||
"""
|
||||
Berechnet den Prozentsatz der verstrichenen Zeit.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Prozent (0-100)
|
||||
"""
|
||||
return 100 - self.get_percentage_remaining(session)
|
||||
|
||||
def is_warning(self, session: LessonSession) -> bool:
|
||||
"""
|
||||
Prueft ob die Warnungszeit erreicht ist (2 Min vor Ende).
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
True wenn Warnung aktiv
|
||||
"""
|
||||
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
|
||||
return False
|
||||
|
||||
remaining = self.get_remaining_seconds(session)
|
||||
return 0 < remaining <= self.WARNING_THRESHOLD_SECONDS
|
||||
|
||||
def is_overtime(self, session: LessonSession) -> bool:
|
||||
"""
|
||||
Prueft ob die Phase ueberzogen wurde.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
True wenn Overtime
|
||||
"""
|
||||
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
|
||||
return False
|
||||
|
||||
remaining = self.get_remaining_seconds(session)
|
||||
return remaining == 0
|
||||
|
||||
def get_overtime_seconds(self, session: LessonSession) -> int:
|
||||
"""
|
||||
Berechnet wie viele Sekunden die Phase ueberzogen wurde.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Overtime-Sekunden (0 wenn nicht ueberzogen)
|
||||
"""
|
||||
if not self.is_overtime(session):
|
||||
return 0
|
||||
|
||||
elapsed = self.get_elapsed_seconds(session)
|
||||
total = self.get_total_seconds(session)
|
||||
return max(0, elapsed - total)
|
||||
|
||||
def format_time(self, seconds: int) -> str:
|
||||
"""
|
||||
Formatiert Sekunden als MM:SS String.
|
||||
|
||||
Args:
|
||||
seconds: Sekunden
|
||||
|
||||
Returns:
|
||||
Formatierte Zeit (z.B. "12:34")
|
||||
"""
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
def get_phase_status(self, session: LessonSession) -> Dict[str, Any]:
|
||||
"""
|
||||
Gibt den vollstaendigen Timer-Status zurueck.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Dictionary mit Timer-Informationen
|
||||
"""
|
||||
remaining = self.get_remaining_seconds(session)
|
||||
total = self.get_total_seconds(session)
|
||||
elapsed = self.get_elapsed_seconds(session)
|
||||
overtime = self.get_overtime_seconds(session)
|
||||
|
||||
return {
|
||||
"remaining_seconds": remaining,
|
||||
"remaining_formatted": self.format_time(remaining),
|
||||
"total_seconds": total,
|
||||
"total_formatted": self.format_time(total),
|
||||
"elapsed_seconds": elapsed,
|
||||
"elapsed_formatted": self.format_time(elapsed),
|
||||
"percentage_remaining": self.get_percentage_remaining(session),
|
||||
"percentage_elapsed": self.get_percentage_elapsed(session),
|
||||
"percentage": self.get_percentage_remaining(session), # Alias for Visual Timer
|
||||
"warning": self.is_warning(session),
|
||||
"overtime": self.is_overtime(session),
|
||||
"overtime_seconds": overtime,
|
||||
"overtime_formatted": self.format_time(overtime) if overtime > 0 else None,
|
||||
"is_paused": session.is_paused,
|
||||
}
|
||||
|
||||
def get_lesson_timer(self, session: LessonSession) -> Dict[str, Any]:
|
||||
"""
|
||||
Gibt den Timer-Status fuer die gesamte Stunde zurueck.
|
||||
|
||||
Args:
|
||||
session: Die aktuelle Session
|
||||
|
||||
Returns:
|
||||
Dictionary mit Stunden-Timer-Informationen
|
||||
"""
|
||||
total_planned = session.get_total_duration_minutes() * 60
|
||||
|
||||
if session.lesson_started_at:
|
||||
if session.lesson_ended_at:
|
||||
actual_duration = (session.lesson_ended_at - session.lesson_started_at).total_seconds()
|
||||
else:
|
||||
actual_duration = (datetime.utcnow() - session.lesson_started_at).total_seconds()
|
||||
else:
|
||||
actual_duration = 0
|
||||
|
||||
return {
|
||||
"total_planned_seconds": total_planned,
|
||||
"total_planned_formatted": self.format_time(total_planned),
|
||||
"actual_duration_seconds": int(actual_duration),
|
||||
"actual_duration_formatted": self.format_time(int(actual_duration)),
|
||||
"started_at": session.lesson_started_at.isoformat() if session.lesson_started_at else None,
|
||||
"ended_at": session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
|
||||
}
|
||||
Reference in New Issue
Block a user