""" School Resolver Service - Schul-Auswahl und Klassen-Erstellung. Funktionen: - Bundesland -> Schulform -> Schule Kaskade - Auto-Erstellung von Klassen aus erkannten Daten - Integration mit Go School Service (Port 8084) Privacy: - Schuldaten sind Stammdaten (kein DSGVO-Problem) - Schueler-Erstellung nur im Lehrer-Namespace """ import httpx import os from dataclasses import dataclass, field from typing import List, Optional, Dict, Any from enum import Enum # ============================================================================ # KONSTANTEN # ============================================================================ BUNDESLAENDER = { "BW": "Baden-Wuerttemberg", "BY": "Bayern", "BE": "Berlin", "BB": "Brandenburg", "HB": "Bremen", "HH": "Hamburg", "HE": "Hessen", "MV": "Mecklenburg-Vorpommern", "NI": "Niedersachsen", "NW": "Nordrhein-Westfalen", "RP": "Rheinland-Pfalz", "SL": "Saarland", "SN": "Sachsen", "ST": "Sachsen-Anhalt", "SH": "Schleswig-Holstein", "TH": "Thueringen" } SCHULFORMEN = { "grundschule": { "name": "Grundschule", "grades": [1, 2, 3, 4], "short": "GS" }, "hauptschule": { "name": "Hauptschule", "grades": [5, 6, 7, 8, 9, 10], "short": "HS" }, "realschule": { "name": "Realschule", "grades": [5, 6, 7, 8, 9, 10], "short": "RS" }, "gymnasium": { "name": "Gymnasium", "grades": [5, 6, 7, 8, 9, 10, 11, 12, 13], "short": "GYM" }, "gesamtschule": { "name": "Gesamtschule", "grades": [5, 6, 7, 8, 9, 10, 11, 12, 13], "short": "IGS" }, "oberschule": { "name": "Oberschule", "grades": [5, 6, 7, 8, 9, 10], "short": "OBS" }, "sekundarschule": { "name": "Sekundarschule", "grades": [5, 6, 7, 8, 9, 10], "short": "SEK" }, "foerderschule": { "name": "Foerderschule", "grades": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "short": "FS" }, "berufsschule": { "name": "Berufsschule", "grades": [10, 11, 12, 13], "short": "BS" }, "fachoberschule": { "name": "Fachoberschule", "grades": [11, 12, 13], "short": "FOS" } } # Faecher mit Standardbezeichnungen FAECHER = { "mathematik": {"name": "Mathematik", "short": "Ma"}, "deutsch": {"name": "Deutsch", "short": "De"}, "englisch": {"name": "Englisch", "short": "En"}, "franzoesisch": {"name": "Franzoesisch", "short": "Fr"}, "spanisch": {"name": "Spanisch", "short": "Sp"}, "latein": {"name": "Latein", "short": "La"}, "physik": {"name": "Physik", "short": "Ph"}, "chemie": {"name": "Chemie", "short": "Ch"}, "biologie": {"name": "Biologie", "short": "Bio"}, "geschichte": {"name": "Geschichte", "short": "Ge"}, "erdkunde": {"name": "Erdkunde", "short": "Ek"}, "politik": {"name": "Politik", "short": "Po"}, "wirtschaft": {"name": "Wirtschaft", "short": "Wi"}, "kunst": {"name": "Kunst", "short": "Ku"}, "musik": {"name": "Musik", "short": "Mu"}, "sport": {"name": "Sport", "short": "Sp"}, "religion": {"name": "Religion", "short": "Re"}, "ethik": {"name": "Ethik", "short": "Et"}, "informatik": {"name": "Informatik", "short": "If"}, "sachunterricht": {"name": "Sachunterricht", "short": "SU"} } # ============================================================================ # DATA CLASSES # ============================================================================ @dataclass class School: """Schule.""" id: str name: str bundesland: str schulform: str address: Optional[str] = None city: Optional[str] = None @dataclass class SchoolClass: """Schulklasse.""" id: str school_id: str name: str # z.B. "3a" grade_level: int # z.B. 3 school_year: str # z.B. "2025/2026" teacher_id: str student_count: int = 0 @dataclass class Student: """Schueler (Stammdaten, keine PII im Klausur-Kontext).""" id: str class_id: str first_name: str last_name: str student_number: Optional[str] = None @dataclass class DetectedClassInfo: """Aus Klausuren erkannte Klasseninformationen.""" class_name: str # z.B. "3a" grade_level: Optional[int] = None # z.B. 3 subject: Optional[str] = None date: Optional[str] = None students: List[Dict[str, str]] = field(default_factory=list) confidence: float = 0.0 @dataclass class SchoolContext: """Vollstaendiger Schulkontext fuer einen Lehrer.""" teacher_id: str school: Optional[School] = None classes: List[SchoolClass] = field(default_factory=list) current_school_year: str = "2025/2026" # ============================================================================ # SCHOOL RESOLVER # ============================================================================ class SchoolResolver: """ Verwaltet Schul- und Klassenkontext. Beispiel: resolver = SchoolResolver() # Schul-Kaskade schools = await resolver.search_schools("Niedersachsen", "Grundschule", "Jever") # Klasse auto-erstellen class_obj = await resolver.auto_create_class( teacher_id="teacher-123", school_id="school-456", detected_info=DetectedClassInfo( class_name="3a", students=[{"firstName": "Max"}, {"firstName": "Anna"}] ) ) """ def __init__(self): self.school_service_url = os.getenv( "SCHOOL_SERVICE_URL", "http://school-service:8084" ) # Fallback auf lokale Daten wenn Service nicht erreichbar self._local_schools: Dict[str, School] = {} self._local_classes: Dict[str, SchoolClass] = {} # ========================================================================= # BUNDESLAND / SCHULFORM LOOKUP # ========================================================================= def get_bundeslaender(self) -> Dict[str, str]: """Gibt alle Bundeslaender zurueck.""" return BUNDESLAENDER def get_schulformen(self) -> Dict[str, Dict]: """Gibt alle Schulformen zurueck.""" return SCHULFORMEN def get_faecher(self) -> Dict[str, Dict]: """Gibt alle Faecher zurueck.""" return FAECHER def get_grades_for_schulform(self, schulform: str) -> List[int]: """Gibt die Klassenstufen fuer eine Schulform zurueck.""" if schulform in SCHULFORMEN: return SCHULFORMEN[schulform]["grades"] return list(range(1, 14)) # Default: alle Stufen def detect_grade_from_class_name(self, class_name: str) -> Optional[int]: """ Erkennt die Klassenstufe aus dem Klassennamen. Beispiele: - "3a" -> 3 - "10b" -> 10 - "Q1" -> 11 - "EF" -> 10 """ import re # Standard-Format: Zahl + Buchstabe match = re.match(r'^(\d{1,2})[a-zA-Z]?$', class_name) if match: return int(match.group(1)) # Oberstufen-Formate upper_grades = { 'ef': 10, 'e': 10, 'q1': 11, 'q2': 12, 'k1': 11, 'k2': 12, '11': 11, '12': 12, '13': 13 } class_lower = class_name.lower() if class_lower in upper_grades: return upper_grades[class_lower] return None def normalize_subject(self, detected_subject: str) -> Optional[str]: """ Normalisiert einen erkannten Fachnamen. Beispiel: "Mathe" -> "mathematik" """ subject_lower = detected_subject.lower().strip() # Direkte Matches if subject_lower in FAECHER: return subject_lower # Abkuerzungen und Varianten subject_aliases = { 'mathe': 'mathematik', 'bio': 'biologie', 'phy': 'physik', 'che': 'chemie', 'geo': 'erdkunde', 'geographie': 'erdkunde', 'powi': 'politik', 'sowi': 'politik', 'reli': 'religion', 'info': 'informatik', 'su': 'sachunterricht' } if subject_lower in subject_aliases: return subject_aliases[subject_lower] # Teilstring-Match for key in FAECHER: if key.startswith(subject_lower) or subject_lower.startswith(key[:3]): return key return None # ========================================================================= # SCHOOL SERVICE INTEGRATION # ========================================================================= async def search_schools( self, bundesland: Optional[str] = None, schulform: Optional[str] = None, name_query: Optional[str] = None, limit: int = 20 ) -> List[School]: """ Sucht Schulen im School Service. Args: bundesland: Bundesland-Kuerzel (z.B. "NI") schulform: Schulform-Key (z.B. "grundschule") name_query: Suchbegriff fuer Schulname limit: Max. Anzahl Ergebnisse """ try: async with httpx.AsyncClient(timeout=10.0) as client: params = {} if bundesland: params['state'] = bundesland if schulform: params['type'] = schulform if name_query: params['q'] = name_query params['limit'] = limit response = await client.get( f"{self.school_service_url}/api/schools", params=params ) if response.status_code == 200: data = response.json() return [ School( id=s['id'], name=s['name'], bundesland=s.get('state', bundesland or ''), schulform=s.get('type', schulform or ''), address=s.get('address'), city=s.get('city') ) for s in data.get('schools', []) ] except Exception as e: print(f"[SchoolResolver] Service error: {e}") # Fallback: Leere Liste (Schule kann manuell angelegt werden) return [] async def get_or_create_school( self, teacher_id: str, bundesland: str, schulform: str, school_name: str, city: Optional[str] = None ) -> School: """ Holt oder erstellt eine Schule. Falls die Schule existiert, wird sie zurueckgegeben. Sonst wird sie neu erstellt. """ # Zuerst suchen existing = await self.search_schools( bundesland=bundesland, schulform=schulform, name_query=school_name, limit=1 ) if existing: return existing[0] # Neu erstellen try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{self.school_service_url}/api/schools", json={ "name": school_name, "state": bundesland, "type": schulform, "city": city, "created_by": teacher_id } ) if response.status_code in (200, 201): data = response.json() return School( id=data['id'], name=school_name, bundesland=bundesland, schulform=schulform, city=city ) except Exception as e: print(f"[SchoolResolver] Create school error: {e}") # Fallback: Lokale Schule erstellen import uuid school_id = str(uuid.uuid4()) school = School( id=school_id, name=school_name, bundesland=bundesland, schulform=schulform, city=city ) self._local_schools[school_id] = school return school # ========================================================================= # CLASS MANAGEMENT # ========================================================================= async def get_classes_for_teacher( self, teacher_id: str, school_id: Optional[str] = None ) -> List[SchoolClass]: """Holt alle Klassen eines Lehrers.""" try: async with httpx.AsyncClient(timeout=10.0) as client: params = {"teacher_id": teacher_id} if school_id: params["school_id"] = school_id response = await client.get( f"{self.school_service_url}/api/classes", params=params ) if response.status_code == 200: data = response.json() return [ SchoolClass( id=c['id'], school_id=c.get('school_id', ''), name=c['name'], grade_level=c.get('grade_level', 0), school_year=c.get('school_year', '2025/2026'), teacher_id=teacher_id, student_count=c.get('student_count', 0) ) for c in data.get('classes', []) ] except Exception as e: print(f"[SchoolResolver] Get classes error: {e}") return list(self._local_classes.values()) async def auto_create_class( self, teacher_id: str, school_id: str, detected_info: DetectedClassInfo, school_year: str = "2025/2026" ) -> SchoolClass: """ Erstellt automatisch eine Klasse aus erkannten Daten. Args: teacher_id: ID des Lehrers school_id: ID der Schule detected_info: Aus Klausuren erkannte Informationen school_year: Schuljahr """ grade_level = detected_info.grade_level or self.detect_grade_from_class_name( detected_info.class_name ) or 0 try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{self.school_service_url}/api/classes", json={ "school_id": school_id, "name": detected_info.class_name, "grade_level": grade_level, "school_year": school_year, "teacher_id": teacher_id } ) if response.status_code in (200, 201): data = response.json() class_id = data['id'] # Schueler hinzufuegen if detected_info.students: await self._bulk_create_students( class_id, detected_info.students ) return SchoolClass( id=class_id, school_id=school_id, name=detected_info.class_name, grade_level=grade_level, school_year=school_year, teacher_id=teacher_id, student_count=len(detected_info.students) ) except Exception as e: print(f"[SchoolResolver] Create class error: {e}") # Fallback: Lokale Klasse import uuid class_id = str(uuid.uuid4()) school_class = SchoolClass( id=class_id, school_id=school_id, name=detected_info.class_name, grade_level=grade_level, school_year=school_year, teacher_id=teacher_id, student_count=len(detected_info.students) ) self._local_classes[class_id] = school_class return school_class async def _bulk_create_students( self, class_id: str, students: List[Dict[str, str]] ) -> List[Student]: """Erstellt mehrere Schueler auf einmal.""" created = [] try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{self.school_service_url}/api/classes/{class_id}/students/bulk", json={ "students": [ { "first_name": s.get("firstName", s.get("first_name", "")), "last_name": s.get("lastName", s.get("last_name", "")) } for s in students ] } ) if response.status_code in (200, 201): data = response.json() created = [ Student( id=s['id'], class_id=class_id, first_name=s['first_name'], last_name=s.get('last_name', '') ) for s in data.get('students', []) ] except Exception as e: print(f"[SchoolResolver] Bulk create students error: {e}") return created # ========================================================================= # CONTEXT MANAGEMENT # ========================================================================= async def get_teacher_context(self, teacher_id: str) -> SchoolContext: """ Holt den vollstaendigen Schulkontext eines Lehrers. Beinhaltet Schule, Klassen und aktuelles Schuljahr. """ context = SchoolContext(teacher_id=teacher_id) # Klassen laden classes = await self.get_classes_for_teacher(teacher_id) context.classes = classes # Schule aus erster Klasse ableiten if classes and classes[0].school_id: schools = await self.search_schools() for school in schools: if school.id == classes[0].school_id: context.school = school break return context # Singleton _school_resolver: Optional[SchoolResolver] = None def get_school_resolver() -> SchoolResolver: """Gibt die Singleton-Instanz des SchoolResolvers zurueck.""" global _school_resolver if _school_resolver is None: _school_resolver = SchoolResolver() return _school_resolver