[split-required] [guardrail-change] Enforce 500 LOC budget across all services

Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-27 00:09:30 +02:00
parent 5ef039a6bc
commit 92c86ec6ba
162 changed files with 23853 additions and 23034 deletions

View File

@@ -1,83 +1,60 @@
"""
File Processor Service - Dokumentenverarbeitung für BreakPilot.
File Processor Service - Dokumentenverarbeitung fuer BreakPilot.
Shared Service für:
- OCR (Optical Character Recognition) für Handschrift und gedruckten Text
Shared Service fuer:
- OCR (Optical Character Recognition) fuer Handschrift und gedruckten Text
- PDF-Parsing und Textextraktion
- Bildverarbeitung und -optimierung
- DOCX/DOC Textextraktion
Verwendet:
- PaddleOCR für deutsche Handschrift
- PyMuPDF für PDF-Verarbeitung
- python-docx für DOCX-Dateien
- OpenCV für Bildvorverarbeitung
- PaddleOCR fuer deutsche Handschrift (via ImageProcessor)
- PyMuPDF fuer PDF-Verarbeitung
- python-docx fuer DOCX-Dateien
- OpenCV fuer Bildvorverarbeitung (via ImageProcessor)
"""
import logging
import os
import io
import base64
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple, Union
from dataclasses import dataclass
from enum import Enum
from typing import Optional, List, Dict, Any
import cv2
import numpy as np
from PIL import Image
from .file_processor_types import (
FileType,
ProcessingMode,
ProcessedRegion,
ProcessingResult,
)
from .image_processing import ImageProcessor
logger = logging.getLogger(__name__)
class FileType(str, Enum):
"""Unterstützte Dateitypen."""
PDF = "pdf"
IMAGE = "image"
DOCX = "docx"
DOC = "doc"
TXT = "txt"
UNKNOWN = "unknown"
class ProcessingMode(str, Enum):
"""Verarbeitungsmodi."""
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
OCR_PRINTED = "ocr_printed" # Gedruckter Text
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
MIXED = "mixed" # Kombiniert OCR + Textextraktion
@dataclass
class ProcessedRegion:
"""Ein erkannter Textbereich."""
text: str
confidence: float
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
page: int = 1
@dataclass
class ProcessingResult:
"""Ergebnis der Dokumentenverarbeitung."""
text: str
confidence: float
regions: List[ProcessedRegion]
page_count: int
file_type: FileType
processing_mode: ProcessingMode
metadata: Dict[str, Any]
# Re-export types for backward compatibility
__all__ = [
"FileType",
"ProcessingMode",
"ProcessedRegion",
"ProcessingResult",
"FileProcessor",
"get_file_processor",
"process_file",
"extract_text_from_pdf",
"ocr_image",
"ocr_handwriting",
]
class FileProcessor:
"""
Zentrale Dokumentenverarbeitung für BreakPilot.
Zentrale Dokumentenverarbeitung fuer BreakPilot.
Unterstützt:
- Handschrifterkennung (OCR) für Klausuren
Unterstuetzt:
- Handschrifterkennung (OCR) fuer Klausuren
- Textextraktion aus PDFs
- DOCX/DOC Verarbeitung
- Bildvorverarbeitung für bessere OCR-Ergebnisse
- Bildvorverarbeitung fuer bessere OCR-Ergebnisse
"""
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
@@ -85,37 +62,18 @@ class FileProcessor:
Initialisiert den File Processor.
Args:
ocr_lang: Sprache für OCR (default: "de" für Deutsch)
use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung)
ocr_lang: Sprache fuer OCR (default: "de" fuer Deutsch)
use_gpu: GPU fuer OCR nutzen (beschleunigt Verarbeitung)
"""
self.ocr_lang = ocr_lang
self.use_gpu = use_gpu
self._ocr_engine = None
self._image_processor = ImageProcessor(ocr_lang=ocr_lang, use_gpu=use_gpu)
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
@property
def ocr_engine(self):
"""Lazy-Loading des OCR-Engines."""
if self._ocr_engine is None:
self._ocr_engine = self._init_ocr_engine()
return self._ocr_engine
def _init_ocr_engine(self):
"""Initialisiert PaddleOCR oder Fallback."""
try:
from paddleocr import PaddleOCR
return PaddleOCR(
use_angle_cls=True,
lang='german', # Deutsch
use_gpu=self.use_gpu,
show_log=False
)
except ImportError:
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
return None
def detect_file_type(self, file_path: str = None, file_bytes: bytes = None) -> FileType:
def detect_file_type(
self, file_path: str = None, file_bytes: bytes = None
) -> FileType:
"""
Erkennt den Dateityp.
@@ -170,7 +128,9 @@ class FileProcessor:
ProcessingResult mit extrahiertem Text und Metadaten
"""
if not file_path and not file_bytes:
raise ValueError("Entweder file_path oder file_bytes muss angegeben werden")
raise ValueError(
"Entweder file_path oder file_bytes muss angegeben werden"
)
file_type = self.detect_file_type(file_path, file_bytes)
logger.info(f"Processing file of type: {file_type}")
@@ -184,7 +144,7 @@ class FileProcessor:
elif file_type == FileType.TXT:
return self._process_txt(file_path, file_bytes)
else:
raise ValueError(f"Nicht unterstützter Dateityp: {file_type}")
raise ValueError(f"Nicht unterstuetzter Dateityp: {file_type}")
def _process_pdf(
self,
@@ -197,7 +157,6 @@ class FileProcessor:
import fitz # PyMuPDF
except ImportError:
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
# Fallback: PDF als Bild behandeln
return self._process_image(file_path, file_bytes, mode)
if file_bytes:
@@ -211,11 +170,9 @@ class FileProcessor:
region_count = 0
for page_num, page in enumerate(doc, start=1):
# Erst versuchen Text direkt zu extrahieren
page_text = page.get_text()
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
# PDF enthält Text (nicht nur Bilder)
all_text.append(page_text)
all_regions.append(ProcessedRegion(
text=page_text,
@@ -227,11 +184,11 @@ class FileProcessor:
region_count += 1
else:
# Seite als Bild rendern und OCR anwenden
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x Auflösung
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
img_bytes = pix.tobytes("png")
img = Image.open(io.BytesIO(img_bytes))
ocr_result = self._ocr_image(img)
ocr_result = self._image_processor.ocr_image(img)
all_text.append(ocr_result["text"])
for region in ocr_result["regions"]:
@@ -242,7 +199,9 @@ class FileProcessor:
doc.close()
avg_confidence = total_confidence / region_count if region_count > 0 else 0.0
avg_confidence = (
total_confidence / region_count if region_count > 0 else 0.0
)
return ProcessingResult(
text="\n\n".join(all_text),
@@ -266,11 +225,8 @@ class FileProcessor:
else:
img = Image.open(file_path)
# Bildvorverarbeitung
processed_img = self._preprocess_image(img)
# OCR
ocr_result = self._ocr_image(processed_img)
processed_img = self._image_processor.preprocess_image(img)
ocr_result = self._image_processor.ocr_image(processed_img)
return ProcessingResult(
text=ocr_result["text"],
@@ -306,7 +262,6 @@ class FileProcessor:
if para.text.strip():
paragraphs.append(para.text)
# Auch Tabellen extrahieren
for table in doc.tables:
for row in table.rows:
row_text = " | ".join(cell.text for cell in row.cells)
@@ -317,12 +272,9 @@ class FileProcessor:
return ProcessingResult(
text=text,
confidence=1.0, # Direkte Textextraktion
confidence=1.0,
regions=[ProcessedRegion(
text=text,
confidence=1.0,
bbox=(0, 0, 0, 0),
page=1
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
)],
page_count=1,
file_type=FileType.DOCX,
@@ -346,10 +298,7 @@ class FileProcessor:
text=text,
confidence=1.0,
regions=[ProcessedRegion(
text=text,
confidence=1.0,
bbox=(0, 0, 0, 0),
page=1
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
)],
page_count=1,
file_type=FileType.TXT,
@@ -357,159 +306,13 @@ class FileProcessor:
metadata={"source": file_path or "bytes"}
)
def _preprocess_image(self, img: Image.Image) -> Image.Image:
"""
Vorverarbeitung des Bildes für bessere OCR-Ergebnisse.
- Konvertierung zu Graustufen
- Kontrastverstärkung
- Rauschunterdrückung
- Binarisierung
"""
# PIL zu OpenCV
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
# Zu Graustufen konvertieren
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
# Rauschunterdrückung
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
# Kontrastverstärkung (CLAHE)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(denoised)
# Adaptive Binarisierung
binary = cv2.adaptiveThreshold(
enhanced,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
11,
2
)
# Zurück zu PIL
return Image.fromarray(binary)
def _ocr_image(self, img: Image.Image) -> Dict[str, Any]:
"""
Führt OCR auf einem Bild aus.
Returns:
Dict mit text, confidence und regions
"""
if self.ocr_engine is None:
# Fallback wenn kein OCR-Engine verfügbar
return {
"text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]",
"confidence": 0.0,
"regions": []
}
# PIL zu numpy array
img_array = np.array(img)
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
if len(img_array.shape) == 2:
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
# OCR ausführen
result = self.ocr_engine.ocr(img_array, cls=True)
if not result or not result[0]:
return {"text": "", "confidence": 0.0, "regions": []}
all_text = []
all_regions = []
total_confidence = 0.0
for line in result[0]:
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
text, confidence = line[1]
# Bounding Box zu x1, y1, x2, y2 konvertieren
x_coords = [p[0] for p in bbox_points]
y_coords = [p[1] for p in bbox_points]
bbox = (
int(min(x_coords)),
int(min(y_coords)),
int(max(x_coords)),
int(max(y_coords))
)
all_text.append(text)
all_regions.append(ProcessedRegion(
text=text,
confidence=confidence,
bbox=bbox
))
total_confidence += confidence
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
return {
"text": "\n".join(all_text),
"confidence": avg_confidence,
"regions": all_regions
}
def extract_handwriting_regions(
self,
img: Image.Image,
min_area: int = 500
) -> List[Dict[str, Any]]:
"""
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
Nützlich für Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
Args:
img: Eingabebild
min_area: Minimale Fläche für erkannte Regionen
Returns:
Liste von Regionen mit Koordinaten und erkanntem Text
"""
# Bildvorverarbeitung
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
# Kanten erkennen
edges = cv2.Canny(gray, 50, 150)
# Morphologische Operationen zum Verbinden
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
dilated = cv2.dilate(edges, kernel, iterations=2)
# Konturen finden
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
regions = []
for contour in contours:
area = cv2.contourArea(contour)
if area < min_area:
continue
x, y, w, h = cv2.boundingRect(contour)
# Region ausschneiden
region_img = img.crop((x, y, x + w, y + h))
# OCR auf Region anwenden
ocr_result = self._ocr_image(region_img)
regions.append({
"bbox": (x, y, x + w, y + h),
"area": area,
"text": ocr_result["text"],
"confidence": ocr_result["confidence"]
})
# Nach Y-Position sortieren (oben nach unten)
regions.sort(key=lambda r: r["bbox"][1])
return regions
"""Delegate to ImageProcessor."""
return self._image_processor.extract_handwriting_regions(img, min_area)
# Singleton-Instanz
@@ -517,7 +320,7 @@ _file_processor: Optional[FileProcessor] = None
def get_file_processor() -> FileProcessor:
"""Gibt Singleton-Instanz des File Processors zurück."""
"""Gibt Singleton-Instanz des File Processors zurueck."""
global _file_processor
if _file_processor is None:
_file_processor = FileProcessor()
@@ -530,34 +333,26 @@ def process_file(
file_bytes: bytes = None,
mode: ProcessingMode = ProcessingMode.MIXED
) -> ProcessingResult:
"""
Convenience function zum Verarbeiten einer Datei.
Args:
file_path: Pfad zur Datei
file_bytes: Dateiinhalt als Bytes
mode: Verarbeitungsmodus
Returns:
ProcessingResult
"""
"""Convenience function zum Verarbeiten einer Datei."""
processor = get_file_processor()
return processor.process(file_path, file_bytes, mode)
def extract_text_from_pdf(file_path: str = None, file_bytes: bytes = None) -> str:
def extract_text_from_pdf(
file_path: str = None, file_bytes: bytes = None
) -> str:
"""Extrahiert Text aus einer PDF-Datei."""
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
return result.text
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
"""Führt OCR auf einem Bild aus."""
"""Fuehrt OCR auf einem Bild aus."""
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
return result.text
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
"""Führt Handschrift-OCR auf einem Bild aus."""
"""Fuehrt Handschrift-OCR auf einem Bild aus."""
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
return result.text

View File

@@ -0,0 +1,46 @@
"""
Shared types for file processing and image processing modules.
"""
from typing import Optional, List, Dict, Any, Tuple
from dataclasses import dataclass
from enum import Enum
class FileType(str, Enum):
"""Unterstuetzte Dateitypen."""
PDF = "pdf"
IMAGE = "image"
DOCX = "docx"
DOC = "doc"
TXT = "txt"
UNKNOWN = "unknown"
class ProcessingMode(str, Enum):
"""Verarbeitungsmodi."""
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
OCR_PRINTED = "ocr_printed" # Gedruckter Text
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
MIXED = "mixed" # Kombiniert OCR + Textextraktion
@dataclass
class ProcessedRegion:
"""Ein erkannter Textbereich."""
text: str
confidence: float
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
page: int = 1
@dataclass
class ProcessingResult:
"""Ergebnis der Dokumentenverarbeitung."""
text: str
confidence: float
regions: List[ProcessedRegion]
page_count: int
file_type: FileType
processing_mode: ProcessingMode
metadata: Dict[str, Any]

View File

@@ -0,0 +1,213 @@
"""
Image Processing and OCR Service.
Handles:
- Image preprocessing for better OCR results (grayscale, denoising, binarization)
- PaddleOCR integration for text recognition
- Handwriting region extraction from scanned documents
Used by FileProcessor for image and PDF-to-image OCR workflows.
"""
import logging
from typing import Optional, List, Dict, Any, Tuple
import cv2
import numpy as np
from PIL import Image
from .file_processor_types import ProcessedRegion
logger = logging.getLogger(__name__)
class ImageProcessor:
"""
Image preprocessing and OCR for BreakPilot.
Supports:
- PaddleOCR for German handwriting and printed text
- OpenCV-based preprocessing (denoising, CLAHE, adaptive binarization)
- Handwriting region extraction for exam correction
"""
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
self.ocr_lang = ocr_lang
self.use_gpu = use_gpu
self._ocr_engine = None
@property
def ocr_engine(self):
"""Lazy-Loading des OCR-Engines."""
if self._ocr_engine is None:
self._ocr_engine = self._init_ocr_engine()
return self._ocr_engine
def _init_ocr_engine(self):
"""Initialisiert PaddleOCR oder Fallback."""
try:
from paddleocr import PaddleOCR
return PaddleOCR(
use_angle_cls=True,
lang='german',
use_gpu=self.use_gpu,
show_log=False
)
except ImportError:
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
return None
def preprocess_image(self, img: Image.Image) -> Image.Image:
"""
Vorverarbeitung des Bildes fuer bessere OCR-Ergebnisse.
- Konvertierung zu Graustufen
- Kontrastverstaerkung
- Rauschunterdrueckung
- Binarisierung
"""
# PIL zu OpenCV
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
# Zu Graustufen konvertieren
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
# Rauschunterdrueckung
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
# Kontrastverstaerkung (CLAHE)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(denoised)
# Adaptive Binarisierung
binary = cv2.adaptiveThreshold(
enhanced,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
11,
2
)
# Zurueck zu PIL
return Image.fromarray(binary)
def ocr_image(self, img: Image.Image) -> Dict[str, Any]:
"""
Fuehrt OCR auf einem Bild aus.
Returns:
Dict mit text, confidence und regions
"""
if self.ocr_engine is None:
return {
"text": "[OCR nicht verfuegbar - bitte PaddleOCR installieren]",
"confidence": 0.0,
"regions": []
}
# PIL zu numpy array
img_array = np.array(img)
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
if len(img_array.shape) == 2:
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
# OCR ausfuehren
result = self.ocr_engine.ocr(img_array, cls=True)
if not result or not result[0]:
return {"text": "", "confidence": 0.0, "regions": []}
all_text = []
all_regions = []
total_confidence = 0.0
for line in result[0]:
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
text, confidence = line[1]
# Bounding Box zu x1, y1, x2, y2 konvertieren
x_coords = [p[0] for p in bbox_points]
y_coords = [p[1] for p in bbox_points]
bbox = (
int(min(x_coords)),
int(min(y_coords)),
int(max(x_coords)),
int(max(y_coords))
)
all_text.append(text)
all_regions.append(ProcessedRegion(
text=text,
confidence=confidence,
bbox=bbox
))
total_confidence += confidence
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
return {
"text": "\n".join(all_text),
"confidence": avg_confidence,
"regions": all_regions
}
def extract_handwriting_regions(
self,
img: Image.Image,
min_area: int = 500
) -> List[Dict[str, Any]]:
"""
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
Nuetzlich fuer Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
Args:
img: Eingabebild
min_area: Minimale Flaeche fuer erkannte Regionen
Returns:
Liste von Regionen mit Koordinaten und erkanntem Text
"""
# Bildvorverarbeitung
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
# Kanten erkennen
edges = cv2.Canny(gray, 50, 150)
# Morphologische Operationen zum Verbinden
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
dilated = cv2.dilate(edges, kernel, iterations=2)
# Konturen finden
contours, _ = cv2.findContours(
dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
regions = []
for contour in contours:
area = cv2.contourArea(contour)
if area < min_area:
continue
x, y, w, h = cv2.boundingRect(contour)
# Region ausschneiden
region_img = img.crop((x, y, x + w, y + h))
# OCR auf Region anwenden
ocr_result = self.ocr_image(region_img)
regions.append({
"bbox": (x, y, x + w, y + h),
"area": area,
"text": ocr_result["text"],
"confidence": ocr_result["confidence"]
})
# Nach Y-Position sortieren (oben nach unten)
regions.sort(key=lambda r: r["bbox"][1])
return regions

View File

@@ -0,0 +1,85 @@
"""
PDF Models - Dataclasses fuer PDF-Generierung.
Enthaelt alle Datenmodelle die von PDFService und den Convenience-Funktionen
in pdf_service.py verwendet werden.
"""
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
@dataclass
class SchoolInfo:
"""Schulinformationen fuer Header."""
name: str
address: str
phone: str
email: str
logo_path: Optional[str] = None
website: Optional[str] = None
principal: Optional[str] = None
@dataclass
class LetterData:
"""Daten fuer Elternbrief-PDF."""
recipient_name: str
recipient_address: str
student_name: str
student_class: str
subject: str
content: str
date: str
teacher_name: str
teacher_title: Optional[str] = None
school_info: Optional[SchoolInfo] = None
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
tone: str = "professional"
legal_references: Optional[List[Dict[str, str]]] = None
gfk_principles_applied: Optional[List[str]] = None
@dataclass
class CertificateData:
"""Daten fuer Zeugnis-PDF."""
student_name: str
student_birthdate: str
student_class: str
school_year: str
certificate_type: str # halbjahr, jahres, abschluss
subjects: List[Dict[str, Any]] # [{name, grade, note}]
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
remarks: Optional[str] = None
class_teacher: str = ""
principal: str = ""
school_info: Optional[SchoolInfo] = None
issue_date: str = ""
social_behavior: Optional[str] = None # A, B, C, D
work_behavior: Optional[str] = None # A, B, C, D
@dataclass
class StudentInfo:
"""Schuelerinformationen fuer Korrektur-PDFs."""
student_id: str
name: str
class_name: str
@dataclass
class CorrectionData:
"""Daten fuer Korrektur-Uebersicht PDF."""
student: StudentInfo
exam_title: str
subject: str
date: str
max_points: int
achieved_points: int
grade: str
percentage: float
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
teacher_notes: str = ""
ai_feedback: str = ""
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
class_average: Optional[float] = None

View File

@@ -1,115 +1,55 @@
"""
PDF Service - Zentrale PDF-Generierung für BreakPilot.
PDF Service - Zentrale PDF-Generierung fuer BreakPilot.
Shared Service für:
Shared Service fuer:
- Letters (Elternbriefe)
- Zeugnisse (Schulzeugnisse)
- Correction (Korrektur-Übersichten)
- Correction (Korrektur-Uebersichten)
Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates.
Verwendet WeasyPrint fuer PDF-Rendering und Jinja2 fuer Templates.
Datenmodelle: services/pdf_models.py
HTML-Templates: services/pdf_templates.py
"""
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional, List
from dataclasses import dataclass
from typing import Any, Dict, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
# Re-export models for backward compatibility
from .pdf_models import (
SchoolInfo,
LetterData,
CertificateData,
StudentInfo,
CorrectionData,
)
from .pdf_templates import (
get_base_css,
get_letter_template_html,
get_certificate_template_html,
get_correction_template_html,
)
logger = logging.getLogger(__name__)
# Template directory
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
@dataclass
class SchoolInfo:
"""Schulinformationen für Header."""
name: str
address: str
phone: str
email: str
logo_path: Optional[str] = None
website: Optional[str] = None
principal: Optional[str] = None
@dataclass
class LetterData:
"""Daten für Elternbrief-PDF."""
recipient_name: str
recipient_address: str
student_name: str
student_class: str
subject: str
content: str
date: str
teacher_name: str
teacher_title: Optional[str] = None
school_info: Optional[SchoolInfo] = None
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
tone: str = "professional"
legal_references: Optional[List[Dict[str, str]]] = None
gfk_principles_applied: Optional[List[str]] = None
@dataclass
class CertificateData:
"""Daten für Zeugnis-PDF."""
student_name: str
student_birthdate: str
student_class: str
school_year: str
certificate_type: str # halbjahr, jahres, abschluss
subjects: List[Dict[str, Any]] # [{name, grade, note}]
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
remarks: Optional[str] = None
class_teacher: str = ""
principal: str = ""
school_info: Optional[SchoolInfo] = None
issue_date: str = ""
social_behavior: Optional[str] = None # A, B, C, D
work_behavior: Optional[str] = None # A, B, C, D
@dataclass
class StudentInfo:
"""Schülerinformationen für Korrektur-PDFs."""
student_id: str
name: str
class_name: str
@dataclass
class CorrectionData:
"""Daten für Korrektur-Übersicht PDF."""
student: StudentInfo
exam_title: str
subject: str
date: str
max_points: int
achieved_points: int
grade: str
percentage: float
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
teacher_notes: str = ""
ai_feedback: str = ""
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
class_average: Optional[float] = None
class PDFService:
"""
Zentrale PDF-Generierung für BreakPilot.
Zentrale PDF-Generierung fuer BreakPilot.
Unterstützt:
Unterstuetzt:
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
- Korrektur-Übersichten für Klausuren
- Korrektur-Uebersichten fuer Klausuren
"""
def __init__(self, templates_dir: Optional[Path] = None):
@@ -143,7 +83,7 @@ class PDFService:
@staticmethod
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
"""Formatiert Datum für deutsche Darstellung."""
"""Formatiert Datum fuer deutsche Darstellung."""
if not value:
return ""
try:
@@ -154,10 +94,10 @@ class PDFService:
@staticmethod
def _grade_color(grade: str) -> str:
"""Gibt Farbe basierend auf Note zurück."""
"""Gibt Farbe basierend auf Note zurueck."""
grade_colors = {
"1": "#27ae60", # Grün
"2": "#2ecc71", # Hellgrün
"1": "#27ae60", # Gruen
"2": "#2ecc71", # Hellgruen
"3": "#f1c40f", # Gelb
"4": "#e67e22", # Orange
"5": "#e74c3c", # Rot
@@ -170,227 +110,12 @@ class PDFService:
return grade_colors.get(str(grade), "#333333")
def _get_base_css(self) -> str:
"""Gibt Basis-CSS für alle PDFs zurück."""
return """
@page {
size: A4;
margin: 2cm 2.5cm;
@top-right {
content: counter(page) " / " counter(pages);
font-size: 9pt;
color: #666;
}
}
body {
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
font-size: 11pt;
line-height: 1.5;
color: #333;
}
h1, h2, h3 {
font-weight: bold;
margin-top: 1em;
margin-bottom: 0.5em;
}
h1 { font-size: 16pt; }
h2 { font-size: 14pt; }
h3 { font-size: 12pt; }
.header {
border-bottom: 2px solid #2c3e50;
padding-bottom: 15px;
margin-bottom: 20px;
}
.school-name {
font-size: 18pt;
font-weight: bold;
color: #2c3e50;
}
.school-info {
font-size: 9pt;
color: #666;
}
.letter-date {
text-align: right;
margin-bottom: 20px;
}
.recipient {
margin-bottom: 30px;
}
.subject {
font-weight: bold;
margin-bottom: 20px;
}
.content {
text-align: justify;
margin-bottom: 30px;
}
.signature {
margin-top: 40px;
}
.legal-references {
font-size: 9pt;
color: #666;
border-top: 1px solid #ddd;
margin-top: 30px;
padding-top: 10px;
}
.gfk-badge {
display: inline-block;
background: #e8f5e9;
color: #27ae60;
font-size: 8pt;
padding: 2px 8px;
border-radius: 10px;
margin-right: 5px;
}
/* Zeugnis-Styles */
.certificate-header {
text-align: center;
margin-bottom: 30px;
}
.certificate-title {
font-size: 20pt;
font-weight: bold;
margin-bottom: 10px;
}
.student-info {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.grades-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.grades-table th,
.grades-table td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
.grades-table th {
background: #2c3e50;
color: white;
}
.grades-table tr:nth-child(even) {
background: #f9f9f9;
}
.grade-cell {
text-align: center;
font-weight: bold;
font-size: 12pt;
}
.attendance-box {
background: #fff3cd;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.signatures-row {
display: flex;
justify-content: space-between;
margin-top: 50px;
}
.signature-block {
text-align: center;
width: 40%;
}
.signature-line {
border-top: 1px solid #333;
margin-top: 40px;
padding-top: 5px;
}
/* Korrektur-Styles */
.exam-header {
background: #2c3e50;
color: white;
padding: 15px;
margin-bottom: 20px;
}
.result-box {
background: #e8f5e9;
padding: 20px;
text-align: center;
margin-bottom: 20px;
border-radius: 5px;
}
.result-grade {
font-size: 36pt;
font-weight: bold;
}
.result-points {
font-size: 14pt;
color: #666;
}
.corrections-list {
margin-bottom: 20px;
}
.correction-item {
border: 1px solid #ddd;
padding: 15px;
margin-bottom: 10px;
border-radius: 5px;
}
.correction-question {
font-weight: bold;
margin-bottom: 5px;
}
.correction-feedback {
background: #fff8e1;
padding: 10px;
margin-top: 10px;
border-left: 3px solid #ffc107;
font-size: 10pt;
}
.stats-table {
width: 100%;
margin-top: 20px;
}
.stats-table td {
padding: 5px 10px;
}
"""
"""Gibt Basis-CSS fuer alle PDFs zurueck (delegiert an pdf_templates)."""
return get_base_css()
def generate_letter_pdf(self, data: LetterData) -> bytes:
"""
Generiert PDF für Elternbrief.
Generiert PDF fuer Elternbrief.
Args:
data: LetterData mit allen Briefinformationen
@@ -417,7 +142,7 @@ class PDFService:
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
"""
Generiert PDF für Schulzeugnis.
Generiert PDF fuer Schulzeugnis.
Args:
data: CertificateData mit allen Zeugnisinformationen
@@ -444,7 +169,7 @@ class PDFService:
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
"""
Generiert PDF für Korrektur-Übersicht.
Generiert PDF fuer Korrektur-Uebersicht.
Args:
data: CorrectionData mit allen Korrekturinformationen
@@ -470,322 +195,29 @@ class PDFService:
return pdf_bytes
def _get_letter_template(self):
"""Gibt Letter-Template zurück (inline falls Datei nicht existiert)."""
"""Gibt Letter-Template zurueck (inline falls Datei nicht existiert)."""
template_path = self.templates_dir / "letter.html"
if template_path.exists():
return self.jinja_env.get_template("letter.html")
# Inline-Template als Fallback
return self.jinja_env.from_string(self._get_letter_template_html())
return self.jinja_env.from_string(get_letter_template_html())
def _get_certificate_template(self):
"""Gibt Certificate-Template zurück."""
"""Gibt Certificate-Template zurueck."""
template_path = self.templates_dir / "certificate.html"
if template_path.exists():
return self.jinja_env.get_template("certificate.html")
return self.jinja_env.from_string(self._get_certificate_template_html())
return self.jinja_env.from_string(get_certificate_template_html())
def _get_correction_template(self):
"""Gibt Correction-Template zurück."""
"""Gibt Correction-Template zurueck."""
template_path = self.templates_dir / "correction.html"
if template_path.exists():
return self.jinja_env.get_template("correction.html")
return self.jinja_env.from_string(self._get_correction_template_html())
@staticmethod
def _get_letter_template_html() -> str:
"""Inline HTML-Template für Elternbriefe."""
return """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{{ data.subject }}</title>
</head>
<body>
<div class="header">
{% if data.school_info %}
<div class="school-name">{{ data.school_info.name }}</div>
<div class="school-info">
{{ data.school_info.address }}<br>
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
</div>
{% else %}
<div class="school-name">Schule</div>
{% endif %}
</div>
<div class="letter-date">
{{ data.date }}
</div>
<div class="recipient">
{{ data.recipient_name }}<br>
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
</div>
<div class="subject">
Betreff: {{ data.subject }}
</div>
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
</div>
<div class="content">
{{ data.content | replace('\\n', '<br>') | safe }}
</div>
{% if data.gfk_principles_applied %}
<div style="margin-bottom: 20px;">
{% for principle in data.gfk_principles_applied %}
<span class="gfk-badge">✓ {{ principle }}</span>
{% endfor %}
</div>
{% endif %}
<div class="signature">
<p>Mit freundlichen Grüßen</p>
<p style="margin-top: 30px;">
{{ data.teacher_name }}
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
</p>
</div>
{% if data.legal_references %}
<div class="legal-references">
<strong>Rechtliche Grundlagen:</strong><br>
{% for ref in data.legal_references %}
{{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
{% endfor %}
</div>
{% endif %}
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
Erstellt mit BreakPilot | {{ generated_at }}
</div>
</body>
</html>
"""
@staticmethod
def _get_certificate_template_html() -> str:
"""Inline HTML-Template für Zeugnisse."""
return """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Zeugnis - {{ data.student_name }}</title>
</head>
<body>
<div class="certificate-header">
{% if data.school_info %}
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
{% endif %}
<div class="certificate-title">
{% if data.certificate_type == 'halbjahr' %}
Halbjahreszeugnis
{% elif data.certificate_type == 'jahres' %}
Jahreszeugnis
{% else %}
Abschlusszeugnis
{% endif %}
</div>
<div>Schuljahr {{ data.school_year }}</div>
</div>
<div class="student-info">
<table style="width: 100%;">
<tr>
<td><strong>Name:</strong> {{ data.student_name }}</td>
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
</tr>
<tr>
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
<td>&nbsp;</td>
</tr>
</table>
</div>
<h3>Leistungen</h3>
<table class="grades-table">
<thead>
<tr>
<th style="width: 70%;">Fach</th>
<th style="width: 15%;">Note</th>
<th style="width: 15%;">Punkte</th>
</tr>
</thead>
<tbody>
{% for subject in data.subjects %}
<tr>
<td>{{ subject.name }}</td>
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
{{ subject.grade }}
</td>
<td class="grade-cell">{{ subject.points | default('-') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if data.social_behavior or data.work_behavior %}
<h3>Verhalten</h3>
<table class="grades-table" style="width: 50%;">
{% if data.social_behavior %}
<tr>
<td>Sozialverhalten</td>
<td class="grade-cell">{{ data.social_behavior }}</td>
</tr>
{% endif %}
{% if data.work_behavior %}
<tr>
<td>Arbeitsverhalten</td>
<td class="grade-cell">{{ data.work_behavior }}</td>
</tr>
{% endif %}
</table>
{% endif %}
<div class="attendance-box">
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
</div>
{% if data.remarks %}
<div style="margin-bottom: 20px;">
<strong>Bemerkungen:</strong><br>
{{ data.remarks }}
</div>
{% endif %}
<div style="margin-top: 30px;">
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
</div>
<div class="signatures-row">
<div class="signature-block">
<div class="signature-line">{{ data.class_teacher }}</div>
<div style="font-size: 9pt;">Klassenlehrer/in</div>
</div>
<div class="signature-block">
<div class="signature-line">{{ data.principal }}</div>
<div style="font-size: 9pt;">Schulleiter/in</div>
</div>
</div>
<div style="text-align: center; margin-top: 40px;">
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
</div>
</body>
</html>
"""
@staticmethod
def _get_correction_template_html() -> str:
"""Inline HTML-Template für Korrektur-Übersichten."""
return """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Korrektur - {{ data.exam_title }}</title>
</head>
<body>
<div class="exam-header">
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
<div>{{ data.subject }} | {{ data.date }}</div>
</div>
<div class="student-info">
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
</div>
<div class="result-box">
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
Note: {{ data.grade }}
</div>
<div class="result-points">
{{ data.achieved_points }} von {{ data.max_points }} Punkten
({{ data.percentage | round(1) }}%)
</div>
</div>
<h3>Detaillierte Auswertung</h3>
<div class="corrections-list">
{% for item in data.corrections %}
<div class="correction-item">
<div class="correction-question">
{{ item.question }}
</div>
{% if item.answer %}
<div style="margin: 5px 0; font-style: italic; color: #555;">
<strong>Antwort:</strong> {{ item.answer }}
</div>
{% endif %}
<div>
<strong>Punkte:</strong> {{ item.points }}
</div>
{% if item.feedback %}
<div class="correction-feedback">
{{ item.feedback }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if data.teacher_notes %}
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
<strong>Lehrerkommentar:</strong><br>
{{ data.teacher_notes }}
</div>
{% endif %}
{% if data.ai_feedback %}
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
<strong>KI-Feedback:</strong><br>
{{ data.ai_feedback }}
</div>
{% endif %}
{% if data.class_average or data.grade_distribution %}
<h3>Klassenstatistik</h3>
<table class="stats-table">
{% if data.class_average %}
<tr>
<td><strong>Klassendurchschnitt:</strong></td>
<td>{{ data.class_average }}</td>
</tr>
{% endif %}
{% if data.grade_distribution %}
<tr>
<td><strong>Notenverteilung:</strong></td>
<td>
{% for grade, count in data.grade_distribution.items() %}
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
{% endif %}
</table>
{% endif %}
<div class="signature" style="margin-top: 40px;">
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
</div>
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
Erstellt mit BreakPilot | {{ generated_at }}
</div>
</body>
</html>
"""
return self.jinja_env.from_string(get_correction_template_html())
# Convenience functions for direct usage
@@ -793,7 +225,7 @@ _pdf_service: Optional[PDFService] = None
def get_pdf_service() -> PDFService:
"""Gibt Singleton-Instanz des PDF-Service zurück."""
"""Gibt Singleton-Instanz des PDF-Service zurueck."""
global _pdf_service
if _pdf_service is None:
_pdf_service = PDFService()

View File

@@ -0,0 +1,519 @@
"""
PDF Templates - Inline HTML-Templates und CSS fuer PDF-Generierung.
Fallback-Templates die verwendet werden wenn keine externen HTML-Dateien
im templates/pdf/ Verzeichnis vorhanden sind.
"""
def get_base_css() -> str:
"""Basis-CSS fuer alle PDFs (A4, Typografie, Komponenten-Styles)."""
return """
@page {
size: A4;
margin: 2cm 2.5cm;
@top-right {
content: counter(page) " / " counter(pages);
font-size: 9pt;
color: #666;
}
}
body {
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
font-size: 11pt;
line-height: 1.5;
color: #333;
}
h1, h2, h3 {
font-weight: bold;
margin-top: 1em;
margin-bottom: 0.5em;
}
h1 { font-size: 16pt; }
h2 { font-size: 14pt; }
h3 { font-size: 12pt; }
.header {
border-bottom: 2px solid #2c3e50;
padding-bottom: 15px;
margin-bottom: 20px;
}
.school-name {
font-size: 18pt;
font-weight: bold;
color: #2c3e50;
}
.school-info {
font-size: 9pt;
color: #666;
}
.letter-date {
text-align: right;
margin-bottom: 20px;
}
.recipient {
margin-bottom: 30px;
}
.subject {
font-weight: bold;
margin-bottom: 20px;
}
.content {
text-align: justify;
margin-bottom: 30px;
}
.signature {
margin-top: 40px;
}
.legal-references {
font-size: 9pt;
color: #666;
border-top: 1px solid #ddd;
margin-top: 30px;
padding-top: 10px;
}
.gfk-badge {
display: inline-block;
background: #e8f5e9;
color: #27ae60;
font-size: 8pt;
padding: 2px 8px;
border-radius: 10px;
margin-right: 5px;
}
/* Zeugnis-Styles */
.certificate-header {
text-align: center;
margin-bottom: 30px;
}
.certificate-title {
font-size: 20pt;
font-weight: bold;
margin-bottom: 10px;
}
.student-info {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.grades-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.grades-table th,
.grades-table td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
.grades-table th {
background: #2c3e50;
color: white;
}
.grades-table tr:nth-child(even) {
background: #f9f9f9;
}
.grade-cell {
text-align: center;
font-weight: bold;
font-size: 12pt;
}
.attendance-box {
background: #fff3cd;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.signatures-row {
display: flex;
justify-content: space-between;
margin-top: 50px;
}
.signature-block {
text-align: center;
width: 40%;
}
.signature-line {
border-top: 1px solid #333;
margin-top: 40px;
padding-top: 5px;
}
/* Korrektur-Styles */
.exam-header {
background: #2c3e50;
color: white;
padding: 15px;
margin-bottom: 20px;
}
.result-box {
background: #e8f5e9;
padding: 20px;
text-align: center;
margin-bottom: 20px;
border-radius: 5px;
}
.result-grade {
font-size: 36pt;
font-weight: bold;
}
.result-points {
font-size: 14pt;
color: #666;
}
.corrections-list {
margin-bottom: 20px;
}
.correction-item {
border: 1px solid #ddd;
padding: 15px;
margin-bottom: 10px;
border-radius: 5px;
}
.correction-question {
font-weight: bold;
margin-bottom: 5px;
}
.correction-feedback {
background: #fff8e1;
padding: 10px;
margin-top: 10px;
border-left: 3px solid #ffc107;
font-size: 10pt;
}
.stats-table {
width: 100%;
margin-top: 20px;
}
.stats-table td {
padding: 5px 10px;
}
"""
def get_letter_template_html() -> str:
"""Inline HTML-Template fuer Elternbriefe."""
return """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{{ data.subject }}</title>
</head>
<body>
<div class="header">
{% if data.school_info %}
<div class="school-name">{{ data.school_info.name }}</div>
<div class="school-info">
{{ data.school_info.address }}<br>
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
</div>
{% else %}
<div class="school-name">Schule</div>
{% endif %}
</div>
<div class="letter-date">
{{ data.date }}
</div>
<div class="recipient">
{{ data.recipient_name }}<br>
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
</div>
<div class="subject">
Betreff: {{ data.subject }}
</div>
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
</div>
<div class="content">
{{ data.content | replace('\\n', '<br>') | safe }}
</div>
{% if data.gfk_principles_applied %}
<div style="margin-bottom: 20px;">
{% for principle in data.gfk_principles_applied %}
<span class="gfk-badge">✓ {{ principle }}</span>
{% endfor %}
</div>
{% endif %}
<div class="signature">
<p>Mit freundlichen Grüßen</p>
<p style="margin-top: 30px;">
{{ data.teacher_name }}
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
</p>
</div>
{% if data.legal_references %}
<div class="legal-references">
<strong>Rechtliche Grundlagen:</strong><br>
{% for ref in data.legal_references %}
{{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
{% endfor %}
</div>
{% endif %}
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
Erstellt mit BreakPilot | {{ generated_at }}
</div>
</body>
</html>
"""
def get_certificate_template_html() -> str:
"""Inline HTML-Template fuer Zeugnisse."""
return """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Zeugnis - {{ data.student_name }}</title>
</head>
<body>
<div class="certificate-header">
{% if data.school_info %}
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
{% endif %}
<div class="certificate-title">
{% if data.certificate_type == 'halbjahr' %}
Halbjahreszeugnis
{% elif data.certificate_type == 'jahres' %}
Jahreszeugnis
{% else %}
Abschlusszeugnis
{% endif %}
</div>
<div>Schuljahr {{ data.school_year }}</div>
</div>
<div class="student-info">
<table style="width: 100%;">
<tr>
<td><strong>Name:</strong> {{ data.student_name }}</td>
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
</tr>
<tr>
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
<td>&nbsp;</td>
</tr>
</table>
</div>
<h3>Leistungen</h3>
<table class="grades-table">
<thead>
<tr>
<th style="width: 70%;">Fach</th>
<th style="width: 15%;">Note</th>
<th style="width: 15%;">Punkte</th>
</tr>
</thead>
<tbody>
{% for subject in data.subjects %}
<tr>
<td>{{ subject.name }}</td>
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
{{ subject.grade }}
</td>
<td class="grade-cell">{{ subject.points | default('-') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if data.social_behavior or data.work_behavior %}
<h3>Verhalten</h3>
<table class="grades-table" style="width: 50%;">
{% if data.social_behavior %}
<tr>
<td>Sozialverhalten</td>
<td class="grade-cell">{{ data.social_behavior }}</td>
</tr>
{% endif %}
{% if data.work_behavior %}
<tr>
<td>Arbeitsverhalten</td>
<td class="grade-cell">{{ data.work_behavior }}</td>
</tr>
{% endif %}
</table>
{% endif %}
<div class="attendance-box">
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
</div>
{% if data.remarks %}
<div style="margin-bottom: 20px;">
<strong>Bemerkungen:</strong><br>
{{ data.remarks }}
</div>
{% endif %}
<div style="margin-top: 30px;">
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
</div>
<div class="signatures-row">
<div class="signature-block">
<div class="signature-line">{{ data.class_teacher }}</div>
<div style="font-size: 9pt;">Klassenlehrer/in</div>
</div>
<div class="signature-block">
<div class="signature-line">{{ data.principal }}</div>
<div style="font-size: 9pt;">Schulleiter/in</div>
</div>
</div>
<div style="text-align: center; margin-top: 40px;">
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
</div>
</body>
</html>
"""
def get_correction_template_html() -> str:
"""Inline HTML-Template fuer Korrektur-Uebersichten."""
return """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Korrektur - {{ data.exam_title }}</title>
</head>
<body>
<div class="exam-header">
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
<div>{{ data.subject }} | {{ data.date }}</div>
</div>
<div class="student-info">
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
</div>
<div class="result-box">
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
Note: {{ data.grade }}
</div>
<div class="result-points">
{{ data.achieved_points }} von {{ data.max_points }} Punkten
({{ data.percentage | round(1) }}%)
</div>
</div>
<h3>Detaillierte Auswertung</h3>
<div class="corrections-list">
{% for item in data.corrections %}
<div class="correction-item">
<div class="correction-question">
{{ item.question }}
</div>
{% if item.answer %}
<div style="margin: 5px 0; font-style: italic; color: #555;">
<strong>Antwort:</strong> {{ item.answer }}
</div>
{% endif %}
<div>
<strong>Punkte:</strong> {{ item.points }}
</div>
{% if item.feedback %}
<div class="correction-feedback">
{{ item.feedback }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if data.teacher_notes %}
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
<strong>Lehrerkommentar:</strong><br>
{{ data.teacher_notes }}
</div>
{% endif %}
{% if data.ai_feedback %}
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
<strong>KI-Feedback:</strong><br>
{{ data.ai_feedback }}
</div>
{% endif %}
{% if data.class_average or data.grade_distribution %}
<h3>Klassenstatistik</h3>
<table class="stats-table">
{% if data.class_average %}
<tr>
<td><strong>Klassendurchschnitt:</strong></td>
<td>{{ data.class_average }}</td>
</tr>
{% endif %}
{% if data.grade_distribution %}
<tr>
<td><strong>Notenverteilung:</strong></td>
<td>
{% for grade, count in data.grade_distribution.items() %}
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
{% endif %}
</table>
{% endif %}
<div class="signature" style="margin-top: 40px;">
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
</div>
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
Erstellt mit BreakPilot | {{ generated_at }}
</div>
</body>
</html>
"""