This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/classroom_engine/fsm.py
Benjamin Admin 21a844cb8a 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>
2026-02-09 09:51:32 +01:00

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())