[split-required] Split remaining 500-680 LOC files (final batch)

website (17 pages + 3 components):
- multiplayer/wizard, middleware/wizard+test-wizard, communication
- builds/wizard, staff-search, voice, sbom/wizard
- foerderantrag, mail/tasks, tools/communication, sbom
- compliance/evidence, uni-crawler, brandbook (already done)
- CollectionsTab, IngestionTab, RiskHeatmap

backend-lehrer (5 files):
- letters_api (641 → 2), certificates_api (636 → 2)
- alerts_agent/db/models (636 → 3)
- llm_gateway/communication_service (614 → 2)
- game/database already done in prior batch

klausur-service (2 files):
- hybrid_vocab_extractor (664 → 2)
- klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2)

voice-service (3 files):
- bqas/rag_judge (618 → 3), runner (529 → 2)
- enhanced_task_orchestrator (519 → 2)

studio-v2 (6 files):
- korrektur/[klausurId] (578 → 4), fairness (569 → 2)
- AlertsWizard (552 → 2), OnboardingWizard (513 → 2)
- korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 08:56:45 +02:00
parent b4613e26f3
commit 451365a312
115 changed files with 10694 additions and 13839 deletions

View File

@@ -1,29 +1,25 @@
"""
Letters API - Elternbrief-Verwaltung für BreakPilot.
Letters API - Elternbrief-Verwaltung fuer BreakPilot.
Bietet Endpoints für:
Bietet Endpoints fuer:
- Speichern und Laden von Elternbriefen
- PDF-Export von Briefen
- Versenden per Email
- GFK-Integration für Textverbesserung
- GFK-Integration fuer Textverbesserung
Arbeitet zusammen mit:
- services/pdf_service.py für PDF-Generierung
- llm_gateway/services/communication_service.py für GFK-Verbesserungen
Split into:
- letters_models.py: Enums, Pydantic models, helper functions
- letters_api.py (this file): API endpoints and in-memory store
"""
import logging
import os
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
from typing import Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Response, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
import httpx
import io
# PDF service requires WeasyPrint with system libraries - make optional for CI
try:
@@ -34,171 +30,30 @@ except (ImportError, OSError):
SchoolInfo = None # type: ignore
_pdf_available = False
from letters_models import (
LetterType,
LetterTone,
LetterStatus,
LetterCreateRequest,
LetterUpdateRequest,
LetterResponse,
LetterListResponse,
ExportPDFRequest,
ImproveRequest,
ImproveResponse,
SendEmailRequest,
SendEmailResponse,
get_type_label as _get_type_label,
get_tone_label as _get_tone_label,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/letters", tags=["letters"])
# =============================================================================
# Enums
# =============================================================================
class LetterType(str, Enum):
"""Typen von Elternbriefen."""
GENERAL = "general" # Allgemeine Information
HALBJAHR = "halbjahr" # Halbjahresinformation
FEHLZEITEN = "fehlzeiten" # Fehlzeiten-Mitteilung
ELTERNABEND = "elternabend" # Einladung Elternabend
LOB = "lob" # Positives Feedback
CUSTOM = "custom" # Benutzerdefiniert
class LetterTone(str, Enum):
"""Tonalität der Briefe."""
FORMAL = "formal"
PROFESSIONAL = "professional"
WARM = "warm"
CONCERNED = "concerned"
APPRECIATIVE = "appreciative"
class LetterStatus(str, Enum):
"""Status eines Briefes."""
DRAFT = "draft"
SENT = "sent"
ARCHIVED = "archived"
# =============================================================================
# Pydantic Models
# =============================================================================
class SchoolInfoModel(BaseModel):
"""Schulinformationen für Briefkopf."""
name: str
address: str
phone: str
email: str
website: Optional[str] = None
principal: Optional[str] = None
logo_path: Optional[str] = None
class LegalReferenceModel(BaseModel):
"""Rechtliche Referenz."""
law: str
paragraph: str
title: str
summary: Optional[str] = None
relevance: Optional[str] = None
class LetterCreateRequest(BaseModel):
"""Request zum Erstellen eines neuen Briefes."""
recipient_name: str = Field(..., description="Name des Empfängers (z.B. 'Familie Müller')")
recipient_address: str = Field(..., description="Adresse des Empfängers")
student_name: str = Field(..., description="Name des Schülers")
student_class: str = Field(..., description="Klasse des Schülers")
subject: str = Field(..., description="Betreff des Briefes")
content: str = Field(..., description="Inhalt des Briefes")
letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes")
tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalität des Briefes")
teacher_name: str = Field(..., description="Name des Lehrers")
teacher_title: Optional[str] = Field(None, description="Titel des Lehrers (z.B. 'Klassenlehrerin')")
school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen für Briefkopf")
legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen")
gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien")
class LetterUpdateRequest(BaseModel):
"""Request zum Aktualisieren eines Briefes."""
recipient_name: Optional[str] = None
recipient_address: Optional[str] = None
student_name: Optional[str] = None
student_class: Optional[str] = None
subject: Optional[str] = None
content: Optional[str] = None
letter_type: Optional[LetterType] = None
tone: Optional[LetterTone] = None
teacher_name: Optional[str] = None
teacher_title: Optional[str] = None
school_info: Optional[SchoolInfoModel] = None
legal_references: Optional[List[LegalReferenceModel]] = None
gfk_principles_applied: Optional[List[str]] = None
status: Optional[LetterStatus] = None
class LetterResponse(BaseModel):
"""Response mit Briefdaten."""
id: str
recipient_name: str
recipient_address: str
student_name: str
student_class: str
subject: str
content: str
letter_type: LetterType
tone: LetterTone
teacher_name: str
teacher_title: Optional[str]
school_info: Optional[SchoolInfoModel]
legal_references: Optional[List[LegalReferenceModel]]
gfk_principles_applied: Optional[List[str]]
gfk_score: Optional[float]
status: LetterStatus
pdf_path: Optional[str]
dsms_cid: Optional[str]
sent_at: Optional[datetime]
created_at: datetime
updated_at: datetime
class LetterListResponse(BaseModel):
"""Response mit Liste von Briefen."""
letters: List[LetterResponse]
total: int
page: int
page_size: int
class ExportPDFRequest(BaseModel):
"""Request zum PDF-Export."""
letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes")
letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten")
class ImproveRequest(BaseModel):
"""Request zur GFK-Verbesserung."""
content: str = Field(..., description="Text zur Verbesserung")
communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation")
tone: Optional[str] = Field("professional", description="Gewünschte Tonalität")
class ImproveResponse(BaseModel):
"""Response mit verbessertem Text."""
improved_content: str
changes: List[str]
gfk_score: float
gfk_principles_applied: List[str]
class SendEmailRequest(BaseModel):
"""Request zum Email-Versand."""
letter_id: str
recipient_email: str
cc_emails: Optional[List[str]] = None
include_pdf: bool = True
class SendEmailResponse(BaseModel):
"""Response nach Email-Versand."""
success: bool
message: str
sent_at: Optional[datetime]
# =============================================================================
# In-Memory Storage (Prototyp - später durch DB ersetzen)
# In-Memory Storage (Prototyp - spaeter durch DB ersetzen)
# =============================================================================
_letters_store: Dict[str, Dict[str, Any]] = {}
@@ -212,7 +67,7 @@ def _get_letter(letter_id: str) -> Dict[str, Any]:
def _save_letter(letter_data: Dict[str, Any]) -> str:
"""Speichert Brief und gibt ID zurück."""
"""Speichert Brief und gibt ID zurueck."""
letter_id = letter_data.get("id") or str(uuid.uuid4())
letter_data["id"] = letter_id
letter_data["updated_at"] = datetime.now()
@@ -228,12 +83,7 @@ def _save_letter(letter_data: Dict[str, Any]) -> str:
@router.post("/", response_model=LetterResponse)
async def create_letter(request: LetterCreateRequest):
"""
Erstellt einen neuen Elternbrief.
Der Brief wird als Entwurf gespeichert und kann später bearbeitet,
als PDF exportiert oder per Email versendet werden.
"""
"""Erstellt einen neuen Elternbrief."""
logger.info(f"Creating new letter for student: {request.student_name}")
letter_data = {
@@ -259,7 +109,6 @@ async def create_letter(request: LetterCreateRequest):
letter_id = _save_letter(letter_data)
letter_data["id"] = letter_id
logger.info(f"Letter created with ID: {letter_id}")
return LetterResponse(**letter_data)
@@ -267,35 +116,19 @@ async def create_letter(request: LetterCreateRequest):
# NOTE: Static routes must come BEFORE dynamic routes like /{letter_id}
@router.get("/types")
async def get_letter_types():
"""
Gibt alle verfügbaren Brieftypen zurück.
"""
return {
"types": [
{"value": t.value, "label": _get_type_label(t)}
for t in LetterType
]
}
"""Gibt alle verfuegbaren Brieftypen zurueck."""
return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in LetterType]}
@router.get("/tones")
async def get_letter_tones():
"""
Gibt alle verfügbaren Tonalitäten zurück.
"""
return {
"tones": [
{"value": t.value, "label": _get_tone_label(t)}
for t in LetterTone
]
}
"""Gibt alle verfuegbaren Tonalitaeten zurueck."""
return {"tones": [{"value": t.value, "label": _get_tone_label(t)} for t in LetterTone]}
@router.get("/{letter_id}", response_model=LetterResponse)
async def get_letter(letter_id: str):
"""
Lädt einen gespeicherten Brief.
"""
"""Laedt einen gespeicherten Brief."""
logger.info(f"Getting letter: {letter_id}")
letter_data = _get_letter(letter_id)
return LetterResponse(**letter_data)
@@ -303,21 +136,17 @@ async def get_letter(letter_id: str):
@router.get("/", response_model=LetterListResponse)
async def list_letters(
student_id: Optional[str] = Query(None, description="Filter nach Schüler-ID"),
class_name: Optional[str] = Query(None, description="Filter nach Klasse"),
letter_type: Optional[LetterType] = Query(None, description="Filter nach Brief-Typ"),
status: Optional[LetterStatus] = Query(None, description="Filter nach Status"),
page: int = Query(1, ge=1, description="Seitennummer"),
page_size: int = Query(20, ge=1, le=100, description="Einträge pro Seite")
student_id: Optional[str] = Query(None),
class_name: Optional[str] = Query(None),
letter_type: Optional[LetterType] = Query(None),
status: Optional[LetterStatus] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
"""
Listet alle gespeicherten Briefe mit optionalen Filtern.
"""
"""Listet alle gespeicherten Briefe mit optionalen Filtern."""
logger.info("Listing letters with filters")
# Filter anwenden
filtered_letters = list(_letters_store.values())
if class_name:
filtered_letters = [l for l in filtered_letters if l.get("student_class") == class_name]
if letter_type:
@@ -325,32 +154,23 @@ async def list_letters(
if status:
filtered_letters = [l for l in filtered_letters if l.get("status") == status]
# Sortieren nach Erstelldatum (neueste zuerst)
filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
# Paginierung
total = len(filtered_letters)
start = (page - 1) * page_size
end = start + page_size
paginated_letters = filtered_letters[start:end]
paginated_letters = filtered_letters[start:start + page_size]
return LetterListResponse(
letters=[LetterResponse(**l) for l in paginated_letters],
total=total,
page=page,
page_size=page_size
total=total, page=page, page_size=page_size
)
@router.put("/{letter_id}", response_model=LetterResponse)
async def update_letter(letter_id: str, request: LetterUpdateRequest):
"""
Aktualisiert einen bestehenden Brief.
"""
"""Aktualisiert einen bestehenden Brief."""
logger.info(f"Updating letter: {letter_id}")
letter_data = _get_letter(letter_id)
# Nur übergebene Felder aktualisieren
update_data = request.model_dump(exclude_unset=True)
for key, value in update_data.items():
if value is not None:
@@ -362,118 +182,80 @@ async def update_letter(letter_id: str, request: LetterUpdateRequest):
letter_data[key] = value
_save_letter(letter_data)
return LetterResponse(**letter_data)
@router.delete("/{letter_id}")
async def delete_letter(letter_id: str):
"""
Löscht einen Brief.
"""
"""Loescht einen Brief."""
logger.info(f"Deleting letter: {letter_id}")
if letter_id not in _letters_store:
raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden")
del _letters_store[letter_id]
return {"message": f"Brief {letter_id} wurde gelöscht"}
return {"message": f"Brief {letter_id} wurde geloescht"}
@router.post("/export-pdf")
async def export_letter_pdf(request: ExportPDFRequest):
"""
Exportiert einen Brief als PDF.
Kann entweder einen gespeicherten Brief (per letter_id) oder
direkte Briefdaten (per letter_data) als PDF exportieren.
Gibt das PDF als Download zurück.
"""
"""Exportiert einen Brief als PDF."""
logger.info("Exporting letter as PDF")
# Briefdaten ermitteln
if request.letter_id:
letter_data = _get_letter(request.letter_id)
elif request.letter_data:
letter_data = request.letter_data.model_dump()
else:
raise HTTPException(
status_code=400,
detail="Entweder letter_id oder letter_data muss angegeben werden"
)
raise HTTPException(status_code=400, detail="Entweder letter_id oder letter_data muss angegeben werden")
# Datum hinzufügen falls nicht vorhanden
if "date" not in letter_data:
letter_data["date"] = datetime.now().strftime("%d.%m.%Y")
# PDF generieren
try:
pdf_bytes = generate_letter_pdf(letter_data)
except Exception as e:
logger.error(f"Error generating PDF: {e}")
raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}")
# Dateiname erstellen
student_name = letter_data.get("student_name", "Brief").replace(" ", "_")
date_str = datetime.now().strftime("%Y%m%d")
filename = f"Elternbrief_{student_name}_{date_str}.pdf"
# PDF als Download zurückgeben
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes))
}
content=pdf_bytes, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(pdf_bytes))}
)
@router.post("/{letter_id}/export-pdf")
async def export_saved_letter_pdf(letter_id: str):
"""
Exportiert einen gespeicherten Brief als PDF (Kurzform).
"""
"""Exportiert einen gespeicherten Brief als PDF (Kurzform)."""
return await export_letter_pdf(ExportPDFRequest(letter_id=letter_id))
@router.post("/improve", response_model=ImproveResponse)
async def improve_letter_content(request: ImproveRequest):
"""
Verbessert den Briefinhalt nach GFK-Prinzipien.
Nutzt die Communication Service API für KI-gestützte Verbesserungen.
"""
"""Verbessert den Briefinhalt nach GFK-Prinzipien."""
logger.info("Improving letter content with GFK principles")
# Communication Service URL (läuft im gleichen Backend)
comm_service_url = os.getenv(
"COMMUNICATION_SERVICE_URL",
"http://localhost:8000/v1/communication"
)
comm_service_url = os.getenv("COMMUNICATION_SERVICE_URL", "http://localhost:8000/v1/communication")
try:
async with httpx.AsyncClient() as client:
# Validierung des aktuellen Textes
validate_response = await client.post(
f"{comm_service_url}/validate",
json={"text": request.content},
timeout=30.0
json={"text": request.content}, timeout=30.0
)
if validate_response.status_code != 200:
logger.warning(f"Validation service returned {validate_response.status_code}")
# Fallback: Original-Text zurückgeben
return ImproveResponse(
improved_content=request.content,
changes=["Verbesserungsservice nicht verfügbar"],
gfk_score=0.5,
gfk_principles_applied=[]
changes=["Verbesserungsservice nicht verfuegbar"],
gfk_score=0.5, gfk_principles_applied=[]
)
validation_data = validate_response.json()
# Falls Text schon gut ist, keine Änderungen
if validation_data.get("is_valid", False) and validation_data.get("gfk_score", 0) > 0.8:
return ImproveResponse(
improved_content=request.content,
@@ -482,84 +264,48 @@ async def improve_letter_content(request: ImproveRequest):
gfk_principles_applied=validation_data.get("positive_elements", [])
)
# Verbesserungsvorschläge als Änderungen
changes = validation_data.get("suggestions", [])
gfk_score = validation_data.get("gfk_score", 0.5)
gfk_principles = validation_data.get("positive_elements", [])
# TODO: Hier könnte ein LLM den Text basierend auf den Vorschlägen verbessern
# Für jetzt geben wir den Original-Text mit den Verbesserungsvorschlägen zurück
return ImproveResponse(
improved_content=request.content,
changes=changes,
gfk_score=gfk_score,
gfk_principles_applied=gfk_principles
changes=validation_data.get("suggestions", []),
gfk_score=validation_data.get("gfk_score", 0.5),
gfk_principles_applied=validation_data.get("positive_elements", [])
)
except httpx.TimeoutException:
logger.error("Timeout while calling communication service")
return ImproveResponse(
improved_content=request.content,
changes=["Zeitüberschreitung beim Verbesserungsservice"],
gfk_score=0.5,
gfk_principles_applied=[]
changes=["Zeitueberschreitung beim Verbesserungsservice"],
gfk_score=0.5, gfk_principles_applied=[]
)
except Exception as e:
logger.error(f"Error improving content: {e}")
return ImproveResponse(
improved_content=request.content,
changes=[f"Fehler: {str(e)}"],
gfk_score=0.5,
gfk_principles_applied=[]
gfk_score=0.5, gfk_principles_applied=[]
)
@router.post("/{letter_id}/send", response_model=SendEmailResponse)
async def send_letter_email(letter_id: str, request: SendEmailRequest):
"""
Versendet einen Brief per Email.
Der Brief wird als PDF angehängt (wenn include_pdf=True)
und der Status wird auf 'sent' gesetzt.
"""
"""Versendet einen Brief per Email."""
logger.info(f"Sending letter {letter_id} to {request.recipient_email}")
# Brief laden
letter_data = _get_letter(letter_id)
# Email-Service URL (Mailpit oder SMTP)
email_service_url = os.getenv(
"EMAIL_SERVICE_URL",
"http://localhost:8025/api/v1/send" # Mailpit default
)
try:
# PDF generieren falls gewünscht
pdf_attachment = None
if request.include_pdf:
letter_data["date"] = datetime.now().strftime("%d.%m.%Y")
pdf_bytes = generate_letter_pdf(letter_data)
pdf_attachment = {
"filename": f"Elternbrief_{letter_data.get('student_name', 'Brief').replace(' ', '_')}.pdf",
"content": pdf_bytes.hex(), # Hex-encoded für JSON
"content": pdf_bytes.hex(),
"content_type": "application/pdf"
}
# Email senden (vereinfachte Implementierung)
# In der Praxis würde hier ein richtiger Email-Service aufgerufen
async with httpx.AsyncClient() as client:
email_data = {
"to": request.recipient_email,
"cc": request.cc_emails or [],
"subject": letter_data.get("subject", "Elternbrief"),
"body": letter_data.get("content", ""),
"attachments": [pdf_attachment] if pdf_attachment else []
}
# Für Prototyp: Nur loggen, nicht wirklich senden
logger.info(f"Would send email: {email_data['subject']} to {email_data['to']}")
# Status aktualisieren
logger.info(f"Would send email: {letter_data.get('subject')} to {request.recipient_email}")
letter_data["status"] = LetterStatus.SENT
letter_data["sent_at"] = datetime.now()
_save_letter(letter_data)
@@ -572,11 +318,7 @@ async def send_letter_email(letter_id: str, request: SendEmailRequest):
except Exception as e:
logger.error(f"Error sending email: {e}")
return SendEmailResponse(
success=False,
message=f"Fehler beim Versenden: {str(e)}",
sent_at=None
)
return SendEmailResponse(success=False, message=f"Fehler beim Versenden: {str(e)}", sent_at=None)
@router.get("/student/{student_id}", response_model=LetterListResponse)
@@ -585,57 +327,20 @@ async def get_letters_for_student(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
"""
Lädt alle Briefe für einen bestimmten Schüler.
"""
"""Laedt alle Briefe fuer einen bestimmten Schueler."""
logger.info(f"Getting letters for student: {student_id}")
# In einem echten System würde hier nach student_id gefiltert
# Für Prototyp filtern wir nach student_name
filtered_letters = [
l for l in _letters_store.values()
if student_id.lower() in l.get("student_name", "").lower()
]
# Sortieren und Paginierung
filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
total = len(filtered_letters)
start = (page - 1) * page_size
end = start + page_size
paginated_letters = filtered_letters[start:end]
paginated_letters = filtered_letters[start:start + page_size]
return LetterListResponse(
letters=[LetterResponse(**l) for l in paginated_letters],
total=total,
page=page,
page_size=page_size
total=total, page=page, page_size=page_size
)
# =============================================================================
# Helper Functions
# =============================================================================
def _get_type_label(letter_type: LetterType) -> str:
"""Gibt menschenlesbare Labels für Brieftypen zurück."""
labels = {
LetterType.GENERAL: "Allgemeine Information",
LetterType.HALBJAHR: "Halbjahresinformation",
LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung",
LetterType.ELTERNABEND: "Einladung Elternabend",
LetterType.LOB: "Positives Feedback",
LetterType.CUSTOM: "Benutzerdefiniert",
}
return labels.get(letter_type, letter_type.value)
def _get_tone_label(tone: LetterTone) -> str:
"""Gibt menschenlesbare Labels für Tonalitäten zurück."""
labels = {
LetterTone.FORMAL: "Sehr förmlich",
LetterTone.PROFESSIONAL: "Professionell-freundlich",
LetterTone.WARM: "Warmherzig",
LetterTone.CONCERNED: "Besorgt",
LetterTone.APPRECIATIVE: "Wertschätzend",
}
return labels.get(tone, tone.value)