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>
This commit is contained in:
613
backend/klausur/services/school_resolver.py
Normal file
613
backend/klausur/services/school_resolver.py
Normal file
@@ -0,0 +1,613 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user