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/klausur/services/school_resolver.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

614 lines
19 KiB
Python

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