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>
614 lines
19 KiB
Python
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
|