""" State Engine - Antizipations-Engine und Phasen-Service. Komponenten: - AnticipationEngine: Evaluiert Regeln und generiert Vorschläge - PhaseService: Verwaltet Phasen-Übergänge """ import logging from datetime import datetime from typing import List, Dict, Any, Optional, Callable from dataclasses import dataclass from .models import ( SchoolYearPhase, TeacherContext, PhaseInfo, get_phase_info, PHASE_INFO ) from .rules import Rule, Suggestion, SuggestionPriority, RULES logger = logging.getLogger(__name__) # ============================================================================ # Phasen-Übergänge # ============================================================================ @dataclass class PhaseTransition: """Definition eines erlaubten Phasen-Übergangs.""" from_phase: SchoolYearPhase to_phase: SchoolYearPhase condition: Callable[[TeacherContext], bool] auto_trigger: bool = False # Automatisch wenn Condition erfüllt # Vordefinierte Übergänge VALID_TRANSITIONS: List[PhaseTransition] = [ # Onboarding → SchoolYearStart PhaseTransition( from_phase=SchoolYearPhase.ONBOARDING, to_phase=SchoolYearPhase.SCHOOL_YEAR_START, condition=lambda ctx: ( ctx.has_completed_milestone("school_select") and ctx.has_completed_milestone("consent_accept") and ctx.has_completed_milestone("profile_complete") ), auto_trigger=True, ), # SchoolYearStart → TeachingSetup PhaseTransition( from_phase=SchoolYearPhase.SCHOOL_YEAR_START, to_phase=SchoolYearPhase.TEACHING_SETUP, condition=lambda ctx: ( len(ctx.classes) > 0 and ctx.has_completed_milestone("add_students") ), auto_trigger=True, ), # TeachingSetup → Performance1 PhaseTransition( from_phase=SchoolYearPhase.TEACHING_SETUP, to_phase=SchoolYearPhase.PERFORMANCE_1, condition=lambda ctx: ( ctx.weeks_since_start >= 6 and ctx.has_learning_units() ), auto_trigger=False, # Manueller Übergang ), # Performance1 → SemesterEnd PhaseTransition( from_phase=SchoolYearPhase.PERFORMANCE_1, to_phase=SchoolYearPhase.SEMESTER_END, condition=lambda ctx: ( (ctx.is_in_month(1) or ctx.is_in_month(2)) and ctx.has_completed_milestone("enter_grades") ), auto_trigger=True, ), # SemesterEnd → Teaching2 PhaseTransition( from_phase=SchoolYearPhase.SEMESTER_END, to_phase=SchoolYearPhase.TEACHING_2, condition=lambda ctx: ( ctx.has_completed_milestone("generate_certificates") ), auto_trigger=True, ), # Teaching2 → Performance2 PhaseTransition( from_phase=SchoolYearPhase.TEACHING_2, to_phase=SchoolYearPhase.PERFORMANCE_2, condition=lambda ctx: ( ctx.weeks_since_start >= 30 or ctx.is_in_month(4) or ctx.is_in_month(5) ), auto_trigger=False, ), # Performance2 → YearEnd PhaseTransition( from_phase=SchoolYearPhase.PERFORMANCE_2, to_phase=SchoolYearPhase.YEAR_END, condition=lambda ctx: ( (ctx.is_in_month(6) or ctx.is_in_month(7)) and ctx.has_completed_milestone("enter_final_grades") ), auto_trigger=True, ), # YearEnd → Archived PhaseTransition( from_phase=SchoolYearPhase.YEAR_END, to_phase=SchoolYearPhase.ARCHIVED, condition=lambda ctx: ( ctx.has_completed_milestone("generate_final_certificates") and ctx.has_completed_milestone("archive_year") ), auto_trigger=True, ), ] class PhaseService: """ Verwaltet Schuljahres-Phasen und deren Übergänge. Funktionen: - Phasen-Status abrufen - Automatische Übergänge prüfen - Manuelle Übergänge durchführen """ def __init__(self, transitions: List[PhaseTransition] = None): self.transitions = transitions or VALID_TRANSITIONS def get_current_phase_info(self, phase: SchoolYearPhase) -> PhaseInfo: """Gibt Metadaten zur aktuellen Phase zurück.""" return get_phase_info(phase) def get_all_phases(self) -> List[Dict[str, Any]]: """Gibt alle Phasen mit Metadaten zurück.""" return [ { "phase": info.phase.value, "display_name": info.display_name, "description": info.description, "typical_months": info.typical_months, } for info in PHASE_INFO.values() ] def check_and_transition(self, ctx: TeacherContext) -> Optional[SchoolYearPhase]: """ Prüft ob ein automatischer Phasen-Übergang möglich ist. Args: ctx: Aktueller TeacherContext Returns: Neue Phase wenn Übergang stattfand, sonst None """ current_phase = ctx.current_phase for transition in self.transitions: if (transition.from_phase == current_phase and transition.auto_trigger and transition.condition(ctx)): logger.info( f"Auto-transition from {current_phase} to {transition.to_phase} " f"for teacher {ctx.teacher_id}" ) return transition.to_phase return None def can_transition_to(self, ctx: TeacherContext, target_phase: SchoolYearPhase) -> bool: """ Prüft ob ein Übergang zu einer bestimmten Phase möglich ist. Args: ctx: Aktueller TeacherContext target_phase: Zielphase Returns: True wenn Übergang erlaubt """ for transition in self.transitions: if (transition.from_phase == ctx.current_phase and transition.to_phase == target_phase): return transition.condition(ctx) return False def get_next_phase(self, current: SchoolYearPhase) -> Optional[SchoolYearPhase]: """Gibt die nächste Phase in der Sequenz zurück.""" phase_order = [ SchoolYearPhase.ONBOARDING, SchoolYearPhase.SCHOOL_YEAR_START, SchoolYearPhase.TEACHING_SETUP, SchoolYearPhase.PERFORMANCE_1, SchoolYearPhase.SEMESTER_END, SchoolYearPhase.TEACHING_2, SchoolYearPhase.PERFORMANCE_2, SchoolYearPhase.YEAR_END, SchoolYearPhase.ARCHIVED, ] try: idx = phase_order.index(current) if idx < len(phase_order) - 1: return phase_order[idx + 1] except ValueError: pass return None def get_progress_percentage(self, ctx: TeacherContext) -> float: """ Berechnet Fortschritt in der aktuellen Phase. Returns: Prozent (0-100) """ phase_info = get_phase_info(ctx.current_phase) required = set(phase_info.required_actions) completed = set(ctx.completed_milestones) if not required: return 100.0 done = len(required.intersection(completed)) return (done / len(required)) * 100 class AnticipationEngine: """ Evaluiert Antizipations-Regeln und generiert priorisierte Vorschläge. Die Engine: - Wendet alle aktiven Regeln auf den TeacherContext an - Priorisiert Vorschläge nach Dringlichkeit - Limitiert auf max. 5 Vorschläge """ def __init__(self, rules: List[Rule] = None, max_suggestions: int = 5): self.rules = rules or RULES self.max_suggestions = max_suggestions def get_suggestions(self, ctx: TeacherContext) -> List[Suggestion]: """ Evaluiert alle Regeln und gibt priorisierte Vorschläge zurück. Args: ctx: TeacherContext mit allen relevanten Informationen Returns: Liste von max. 5 Vorschlägen, sortiert nach Priorität """ suggestions = [] for rule in self.rules: try: suggestion = rule.evaluate(ctx) if suggestion: suggestions.append(suggestion) except Exception as e: logger.warning(f"Rule {rule.id} evaluation failed: {e}") return self._prioritize(suggestions) def _prioritize(self, suggestions: List[Suggestion]) -> List[Suggestion]: """ Sortiert Vorschläge nach Priorität und limitiert. Reihenfolge: 1. URGENT (1) 2. HIGH (2) 3. MEDIUM (3) 4. LOW (4) """ sorted_suggestions = sorted( suggestions, key=lambda s: s.priority.value ) return sorted_suggestions[:self.max_suggestions] def get_top_suggestion(self, ctx: TeacherContext) -> Optional[Suggestion]: """ Gibt den wichtigsten Vorschlag zurück. Args: ctx: TeacherContext Returns: Wichtigster Vorschlag oder None """ suggestions = self.get_suggestions(ctx) return suggestions[0] if suggestions else None def get_suggestions_by_category( self, ctx: TeacherContext ) -> Dict[str, List[Suggestion]]: """ Gruppiert Vorschläge nach Kategorie. Returns: Dict mit Kategorien als Keys und Listen von Vorschlägen """ suggestions = self.get_suggestions(ctx) by_category: Dict[str, List[Suggestion]] = {} for suggestion in suggestions: if suggestion.category not in by_category: by_category[suggestion.category] = [] by_category[suggestion.category].append(suggestion) return by_category def count_by_priority(self, ctx: TeacherContext) -> Dict[str, int]: """ Zählt Vorschläge nach Priorität. Returns: Dict mit Prioritäten und Anzahlen """ suggestions = self.get_suggestions(ctx) counts = { "urgent": 0, "high": 0, "medium": 0, "low": 0, } for s in suggestions: if s.priority == SuggestionPriority.URGENT: counts["urgent"] += 1 elif s.priority == SuggestionPriority.HIGH: counts["high"] += 1 elif s.priority == SuggestionPriority.MEDIUM: counts["medium"] += 1 else: counts["low"] += 1 return counts # ============================================================================ # Factory Functions # ============================================================================ def create_anticipation_engine(custom_rules: List[Rule] = None) -> AnticipationEngine: """Erstellt eine neue AnticipationEngine.""" return AnticipationEngine(rules=custom_rules) def create_phase_service(custom_transitions: List[PhaseTransition] = None) -> PhaseService: """Erstellt einen neuen PhaseService.""" return PhaseService(transitions=custom_transitions)