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/letters_api.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

642 lines
22 KiB
Python

"""
Letters API - Elternbrief-Verwaltung für BreakPilot.
Bietet Endpoints für:
- Speichern und Laden von Elternbriefen
- PDF-Export von Briefen
- Versenden per Email
- GFK-Integration für Textverbesserung
Arbeitet zusammen mit:
- services/pdf_service.py für PDF-Generierung
- llm_gateway/services/communication_service.py für GFK-Verbesserungen
"""
import logging
import os
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
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:
from services.pdf_service import generate_letter_pdf, SchoolInfo
_pdf_available = True
except (ImportError, OSError):
generate_letter_pdf = None # type: ignore
SchoolInfo = None # type: ignore
_pdf_available = False
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)
# =============================================================================
_letters_store: Dict[str, Dict[str, Any]] = {}
def _get_letter(letter_id: str) -> Dict[str, Any]:
"""Holt Brief aus dem Store."""
if letter_id not in _letters_store:
raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden")
return _letters_store[letter_id]
def _save_letter(letter_data: Dict[str, Any]) -> str:
"""Speichert Brief und gibt ID zurück."""
letter_id = letter_data.get("id") or str(uuid.uuid4())
letter_data["id"] = letter_id
letter_data["updated_at"] = datetime.now()
if "created_at" not in letter_data:
letter_data["created_at"] = datetime.now()
_letters_store[letter_id] = letter_data
return letter_id
# =============================================================================
# API Endpoints
# =============================================================================
@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.
"""
logger.info(f"Creating new letter for student: {request.student_name}")
letter_data = {
"recipient_name": request.recipient_name,
"recipient_address": request.recipient_address,
"student_name": request.student_name,
"student_class": request.student_class,
"subject": request.subject,
"content": request.content,
"letter_type": request.letter_type,
"tone": request.tone,
"teacher_name": request.teacher_name,
"teacher_title": request.teacher_title,
"school_info": request.school_info.model_dump() if request.school_info else None,
"legal_references": [ref.model_dump() for ref in request.legal_references] if request.legal_references else None,
"gfk_principles_applied": request.gfk_principles_applied,
"gfk_score": None,
"status": LetterStatus.DRAFT,
"pdf_path": None,
"dsms_cid": None,
"sent_at": None,
}
letter_id = _save_letter(letter_data)
letter_data["id"] = letter_id
logger.info(f"Letter created with ID: {letter_id}")
return LetterResponse(**letter_data)
# 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
]
}
@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
]
}
@router.get("/{letter_id}", response_model=LetterResponse)
async def get_letter(letter_id: str):
"""
Lädt einen gespeicherten Brief.
"""
logger.info(f"Getting letter: {letter_id}")
letter_data = _get_letter(letter_id)
return LetterResponse(**letter_data)
@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")
):
"""
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:
filtered_letters = [l for l in filtered_letters if l.get("letter_type") == letter_type]
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]
return LetterListResponse(
letters=[LetterResponse(**l) for l in paginated_letters],
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.
"""
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:
if key == "school_info" and value:
letter_data[key] = value if isinstance(value, dict) else value.model_dump()
elif key == "legal_references" and value:
letter_data[key] = [ref if isinstance(ref, dict) else ref.model_dump() for ref in value]
else:
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.
"""
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"}
@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.
"""
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"
)
# 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))
}
)
@router.post("/{letter_id}/export-pdf")
async def export_saved_letter_pdf(letter_id: str):
"""
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.
"""
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"
)
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
)
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=[]
)
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,
changes=["Text entspricht bereits GFK-Standards"],
gfk_score=validation_data.get("gfk_score", 0.8),
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
)
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=[]
)
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=[]
)
@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.
"""
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_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
letter_data["status"] = LetterStatus.SENT
letter_data["sent_at"] = datetime.now()
_save_letter(letter_data)
return SendEmailResponse(
success=True,
message=f"Brief wurde an {request.recipient_email} gesendet",
sent_at=datetime.now()
)
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
)
@router.get("/student/{student_id}", response_model=LetterListResponse)
async def get_letters_for_student(
student_id: str,
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.
"""
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]
return LetterListResponse(
letters=[LetterResponse(**l) for l in paginated_letters],
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)