""" Module Linker Service - Cross-Module Verknuepfungen. Verknuepft Klausur-Ergebnisse mit anderen BreakPilot-Modulen: - Notenbuch (School Service) - Elternabend (Gespraechsvorschlaege) - Zeugnisse (Notenuebernahme) - Kalender (Termine) Privacy: - Verknuepfungen nutzen doc_tokens (pseudonymisiert) - Deanonymisierung nur Client-seitig moeglich """ import httpx import os from dataclasses import dataclass, field from typing import List, Optional, Dict, Any from datetime import datetime, timedelta from enum import Enum # ============================================================================ # DATA CLASSES # ============================================================================ class LinkType(str, Enum): """Typ der Modul-Verknuepfung.""" NOTENBUCH = "notenbuch" ELTERNABEND = "elternabend" ZEUGNIS = "zeugnis" CALENDAR = "calendar" KLASSENBUCH = "klassenbuch" class MeetingUrgency(str, Enum): """Dringlichkeit eines Elterngespraechs.""" LOW = "niedrig" MEDIUM = "mittel" HIGH = "hoch" @dataclass class CorrectionResult: """Korrektur-Ergebnis (pseudonymisiert).""" doc_token: str score: float # Punkte max_score: float grade: str # z.B. "2+" feedback: str question_results: List[Dict[str, Any]] = field(default_factory=list) @dataclass class GradeEntry: """Notenbuch-Eintrag.""" student_id: str # Im Notenbuch: echte Student-ID doc_token: str # Aus Klausur: pseudonymisiert grade: str points: float max_points: float exam_name: str date: str @dataclass class ParentMeetingSuggestion: """Vorschlag fuer ein Elterngespraech.""" doc_token: str # Pseudonymisiert reason: str urgency: MeetingUrgency grade: str subject: str suggested_topics: List[str] = field(default_factory=list) @dataclass class CalendarEvent: """Kalender-Eintrag.""" id: str title: str description: str start_time: datetime end_time: datetime event_type: str linked_doc_tokens: List[str] = field(default_factory=list) @dataclass class ModuleLink: """Verknuepfung zu einem anderen Modul.""" id: str klausur_session_id: str link_type: LinkType target_module: str target_entity_id: str target_url: Optional[str] = None link_metadata: Dict[str, Any] = field(default_factory=dict) created_at: datetime = field(default_factory=datetime.utcnow) @dataclass class LinkResult: """Ergebnis einer Verknuepfungs-Operation.""" success: bool link: Optional[ModuleLink] = None message: str = "" target_url: Optional[str] = None # ============================================================================ # MODULE LINKER # ============================================================================ class ModuleLinker: """ Verknuepft Klausur-Ergebnisse mit anderen Modulen. Beispiel: linker = ModuleLinker() # Noten ins Notenbuch uebertragen result = await linker.link_to_notenbuch( session_id="session-123", class_id="class-456", results=correction_results ) # Elterngespraeche vorschlagen suggestions = linker.suggest_elternabend( results=correction_results, subject="Mathematik" ) """ # Notenschwellen fuer Elterngespraeche GRADE_THRESHOLDS = { "1+": 0.95, "1": 0.90, "1-": 0.85, "2+": 0.80, "2": 0.75, "2-": 0.70, "3+": 0.65, "3": 0.60, "3-": 0.55, "4+": 0.50, "4": 0.45, "4-": 0.40, "5+": 0.33, "5": 0.25, "5-": 0.17, "6": 0.0 } # Noten die Gespraeche erfordern MEETING_TRIGGER_GRADES = ["4", "4-", "5+", "5", "5-", "6"] def __init__(self): self.school_service_url = os.getenv( "SCHOOL_SERVICE_URL", "http://school-service:8084" ) self.calendar_service_url = os.getenv( "CALENDAR_SERVICE_URL", "http://calendar-service:8085" ) # ========================================================================= # NOTENBUCH INTEGRATION # ========================================================================= async def link_to_notenbuch( self, session_id: str, class_id: str, subject: str, results: List[CorrectionResult], exam_name: str, exam_date: str, identity_map: Optional[Dict[str, str]] = None ) -> LinkResult: """ Uebertraegt Noten ins Notenbuch (School Service). Args: session_id: Klausur-Session-ID class_id: Klassen-ID im School Service subject: Fach results: Liste der Korrektur-Ergebnisse exam_name: Name der Klausur exam_date: Datum der Klausur identity_map: Optional: doc_token -> student_id Mapping Note: Das identity_map wird nur serverseitig genutzt, wenn der Lehrer explizit die Verknuepfung freigibt. Normalerweise bleibt das Mapping Client-seitig. """ try: # Noten-Daten aufbereiten grades_data = [] for result in results: grade_entry = { "doc_token": result.doc_token, "grade": result.grade, "points": result.score, "max_points": result.max_score, "percentage": result.score / result.max_score if result.max_score > 0 else 0 } # Falls identity_map vorhanden: Student-ID hinzufuegen if identity_map and result.doc_token in identity_map: grade_entry["student_id"] = identity_map[result.doc_token] grades_data.append(grade_entry) async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{self.school_service_url}/api/classes/{class_id}/exams", json={ "name": exam_name, "subject": subject, "date": exam_date, "max_points": results[0].max_score if results else 100, "grades": grades_data, "klausur_session_id": session_id } ) if response.status_code in (200, 201): data = response.json() return LinkResult( success=True, link=ModuleLink( id=data.get('id', ''), klausur_session_id=session_id, link_type=LinkType.NOTENBUCH, target_module="school", target_entity_id=data.get('id', ''), target_url=f"/app?module=school&class={class_id}&exam={data.get('id')}" ), message=f"Noten erfolgreich uebertragen ({len(results)} Eintraege)", target_url=f"/app?module=school&class={class_id}" ) return LinkResult( success=False, message=f"Fehler beim Uebertragen: {response.status_code}" ) except Exception as e: return LinkResult( success=False, message=f"Verbindungsfehler: {str(e)}" ) # ========================================================================= # ELTERNABEND VORSCHLAEGE # ========================================================================= def suggest_elternabend( self, results: List[CorrectionResult], subject: str, threshold_grade: str = "4" ) -> List[ParentMeetingSuggestion]: """ Schlaegt Elterngespraeche fuer schwache Schueler vor. Args: results: Liste der Korrektur-Ergebnisse subject: Fach threshold_grade: Ab dieser Note wird ein Gespraech vorgeschlagen Returns: Liste von Gespraechs-Vorschlaegen (pseudonymisiert) """ suggestions = [] threshold_idx = list(self.GRADE_THRESHOLDS.keys()).index(threshold_grade) \ if threshold_grade in self.GRADE_THRESHOLDS else 9 for result in results: # Pruefe ob Note Gespraech erfordert if result.grade in self.MEETING_TRIGGER_GRADES: urgency = self._determine_urgency(result.grade) topics = self._generate_meeting_topics(result, subject) suggestions.append(ParentMeetingSuggestion( doc_token=result.doc_token, reason=f"Note {result.grade} in {subject}", urgency=urgency, grade=result.grade, subject=subject, suggested_topics=topics )) # Nach Dringlichkeit sortieren urgency_order = { MeetingUrgency.HIGH: 0, MeetingUrgency.MEDIUM: 1, MeetingUrgency.LOW: 2 } suggestions.sort(key=lambda s: urgency_order[s.urgency]) return suggestions def _determine_urgency(self, grade: str) -> MeetingUrgency: """Bestimmt die Dringlichkeit basierend auf der Note.""" if grade in ["5-", "6"]: return MeetingUrgency.HIGH elif grade in ["5", "5+"]: return MeetingUrgency.MEDIUM else: return MeetingUrgency.LOW def _generate_meeting_topics( self, result: CorrectionResult, subject: str ) -> List[str]: """Generiert Gespraechsthemen basierend auf den Ergebnissen.""" topics = [] # Allgemeine Themen topics.append(f"Leistungsstand in {subject}") # Basierend auf Feedback if "Verstaendnis" in result.feedback.lower() or "grundlagen" in result.feedback.lower(): topics.append("Grundlagenverstaendnis foerdern") if "uebung" in result.feedback.lower(): topics.append("Zusaetzliche Uebungsmoeglichkeiten") # Basierend auf Aufgaben-Ergebnissen if result.question_results: weak_areas = [] for qr in result.question_results: if qr.get('points_awarded', 0) / qr.get('max_points', 1) < 0.5: weak_areas.append(qr.get('question_text', '')) if weak_areas: topics.append("Gezielte Foerderung in Schwachstellen") # Standard-Themen if not topics or len(topics) < 3: topics.extend([ "Lernstrategien besprechen", "Unterstuetzungsmoeglichkeiten zu Hause", "Nachhilfe-Optionen" ]) return topics[:5] # Max 5 Themen async def create_elternabend_link( self, session_id: str, suggestions: List[ParentMeetingSuggestion], teacher_id: str ) -> LinkResult: """Erstellt Verknuepfungen zum Elternabend-Modul.""" # TODO: Integration mit Elternabend-Modul # Vorerst nur Metadaten speichern return LinkResult( success=True, link=ModuleLink( id=f"elternabend-{session_id}", klausur_session_id=session_id, link_type=LinkType.ELTERNABEND, target_module="elternabend", target_entity_id="", link_metadata={ "suggestion_count": len(suggestions), "high_urgency_count": sum( 1 for s in suggestions if s.urgency == MeetingUrgency.HIGH ) } ), message=f"{len(suggestions)} Elterngespraeche vorgeschlagen", target_url="/app?module=elternabend" ) # ========================================================================= # ZEUGNIS INTEGRATION # ========================================================================= async def update_zeugnis( self, class_id: str, subject: str, grades: Dict[str, str], exam_weight: float = 1.0 ) -> LinkResult: """ Aktualisiert Zeugnis-Aggregation mit neuen Noten. Args: class_id: Klassen-ID subject: Fach grades: doc_token -> Note Mapping exam_weight: Gewichtung der Klausur (Standard: 1.0) """ try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{self.school_service_url}/api/classes/{class_id}/grades/aggregate", json={ "subject": subject, "grades": grades, "weight": exam_weight, "type": "klausur" } ) if response.status_code in (200, 201): return LinkResult( success=True, message="Zeugnis-Daten aktualisiert", target_url=f"/app?module=school&class={class_id}&tab=certificates" ) return LinkResult( success=False, message=f"Fehler: {response.status_code}" ) except Exception as e: return LinkResult( success=False, message=f"Verbindungsfehler: {str(e)}" ) # ========================================================================= # KALENDER INTEGRATION # ========================================================================= async def create_calendar_events( self, teacher_id: str, suggestions: List[ParentMeetingSuggestion], default_duration_minutes: int = 30 ) -> List[CalendarEvent]: """ Erstellt Kalender-Eintraege fuer Elterngespraeche. Args: teacher_id: ID des Lehrers suggestions: Liste der Gespraechs-Vorschlaege default_duration_minutes: Standard-Dauer pro Gespraech """ events = [] # Zeitslots generieren (ab naechster Woche, nachmittags) start_date = datetime.now() + timedelta(days=7 - datetime.now().weekday()) start_date = start_date.replace(hour=14, minute=0, second=0, microsecond=0) slot_index = 0 for suggestion in suggestions: # Zeitslot berechnen event_start = start_date + timedelta(minutes=slot_index * default_duration_minutes) event_end = event_start + timedelta(minutes=default_duration_minutes) # Naechster Tag wenn nach 18 Uhr if event_start.hour >= 18: start_date += timedelta(days=1) start_date = start_date.replace(hour=14) slot_index = 0 event_start = start_date event_end = event_start + timedelta(minutes=default_duration_minutes) event = CalendarEvent( id=f"meeting-{suggestion.doc_token[:8]}", title=f"Elterngespraech ({suggestion.grade})", description=f"Anlass: {suggestion.reason}\n\nThemen:\n" + "\n".join(f"- {t}" for t in suggestion.suggested_topics), start_time=event_start, end_time=event_end, event_type="parent_meeting", linked_doc_tokens=[suggestion.doc_token] ) events.append(event) slot_index += 1 # An Kalender-Service senden try: async with httpx.AsyncClient(timeout=10.0) as client: for event in events: await client.post( f"{self.calendar_service_url}/api/events", json={ "teacher_id": teacher_id, "title": event.title, "description": event.description, "start": event.start_time.isoformat(), "end": event.end_time.isoformat(), "type": event.event_type, "metadata": { "doc_tokens": event.linked_doc_tokens } } ) except Exception as e: print(f"[ModuleLinker] Calendar service error: {e}") return events # ========================================================================= # STATISTIKEN # ========================================================================= def calculate_grade_statistics( self, results: List[CorrectionResult] ) -> Dict[str, Any]: """ Berechnet Notenstatistiken. Returns: Dict mit Durchschnitt, Verteilung, Median, etc. """ if not results: return {} # Notenwerte (fuer Durchschnitt) grade_values = { "1+": 0.7, "1": 1.0, "1-": 1.3, "2+": 1.7, "2": 2.0, "2-": 2.3, "3+": 2.7, "3": 3.0, "3-": 3.3, "4+": 3.7, "4": 4.0, "4-": 4.3, "5+": 4.7, "5": 5.0, "5-": 5.3, "6": 6.0 } # Noten sammeln grades = [r.grade for r in results] points = [r.score for r in results] max_points = results[0].max_score if results else 100 # Durchschnitt berechnen numeric_grades = [grade_values.get(g, 4.0) for g in grades] avg_grade = sum(numeric_grades) / len(numeric_grades) # Notenverteilung distribution = {} for grade in grades: distribution[grade] = distribution.get(grade, 0) + 1 # Prozent-Verteilung percent_distribution = { "sehr gut (1)": sum(1 for g in grades if g.startswith("1")), "gut (2)": sum(1 for g in grades if g.startswith("2")), "befriedigend (3)": sum(1 for g in grades if g.startswith("3")), "ausreichend (4)": sum(1 for g in grades if g.startswith("4")), "mangelhaft (5)": sum(1 for g in grades if g.startswith("5")), "ungenuegend (6)": sum(1 for g in grades if g == "6") } return { "count": len(results), "average_grade": round(avg_grade, 2), "average_grade_display": self._numeric_to_grade(avg_grade), "average_points": round(sum(points) / len(points), 1), "max_points": max_points, "average_percent": round((sum(points) / len(points) / max_points) * 100, 1), "best_grade": min(grades, key=lambda g: grade_values.get(g, 6)), "worst_grade": max(grades, key=lambda g: grade_values.get(g, 0)), "median_grade": self._calculate_median_grade(grades), "distribution": distribution, "percent_distribution": percent_distribution, "passing_count": sum(1 for g in grades if not g.startswith("5") and g != "6"), "failing_count": sum(1 for g in grades if g.startswith("5") or g == "6") } def _numeric_to_grade(self, value: float) -> str: """Konvertiert Notenwert zu Note.""" if value <= 1.15: return "1+" elif value <= 1.5: return "1" elif value <= 1.85: return "1-" elif value <= 2.15: return "2+" elif value <= 2.5: return "2" elif value <= 2.85: return "2-" elif value <= 3.15: return "3+" elif value <= 3.5: return "3" elif value <= 3.85: return "3-" elif value <= 4.15: return "4+" elif value <= 4.5: return "4" elif value <= 4.85: return "4-" elif value <= 5.15: return "5+" elif value <= 5.5: return "5" elif value <= 5.85: return "5-" else: return "6" def _calculate_median_grade(self, grades: List[str]) -> str: """Berechnet die Median-Note.""" grade_values = { "1+": 0.7, "1": 1.0, "1-": 1.3, "2+": 1.7, "2": 2.0, "2-": 2.3, "3+": 2.7, "3": 3.0, "3-": 3.3, "4+": 3.7, "4": 4.0, "4-": 4.3, "5+": 4.7, "5": 5.0, "5-": 5.3, "6": 6.0 } numeric = sorted([grade_values.get(g, 4.0) for g in grades]) n = len(numeric) if n % 2 == 0: median = (numeric[n // 2 - 1] + numeric[n // 2]) / 2 else: median = numeric[n // 2] return self._numeric_to_grade(median) # Singleton _module_linker: Optional[ModuleLinker] = None def get_module_linker() -> ModuleLinker: """Gibt die Singleton-Instanz des ModuleLinkers zurueck.""" global _module_linker if _module_linker is None: _module_linker = ModuleLinker() return _module_linker