""" 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