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>
223 lines
7.0 KiB
Python
223 lines
7.0 KiB
Python
"""
|
|
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())
|