Restructure: Move 52 files into 7 domain packages
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m22s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 23s

korrektur/ zeugnis/ admin/ compliance/ worksheet/ training/ metrics/
52 shims, relative imports, RAG untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 22:10:48 +02:00
parent 0504d22b8e
commit 165c493d1e
111 changed files with 11859 additions and 11609 deletions

View File

@@ -0,0 +1,6 @@
"""
korrektur package — exam correction, EH templates, PDF export.
Backward-compatible re-exports: consumers can still use
``from eh_pipeline import ...`` etc. via the shim files in backend/.
"""

View File

@@ -0,0 +1,420 @@
"""
BYOEH Processing Pipeline
Handles chunking, embedding generation, and encryption for Erwartungshorizonte.
Supports multiple embedding backends:
- local: sentence-transformers (default, no API key needed)
- openai: OpenAI text-embedding-3-small (requires OPENAI_API_KEY)
"""
import os
import io
import base64
import hashlib
from typing import List, Tuple, Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import httpx
# Embedding Configuration
# Backend: "local" (sentence-transformers) or "openai"
EMBEDDING_BACKEND = os.getenv("EMBEDDING_BACKEND", "local")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
# Local embedding model (all-MiniLM-L6-v2: 384 dimensions, fast, good quality)
LOCAL_EMBEDDING_MODEL = os.getenv("LOCAL_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
# Vector dimensions per backend
VECTOR_DIMENSIONS = {
"local": 384, # all-MiniLM-L6-v2
"openai": 1536, # text-embedding-3-small
}
CHUNK_SIZE = int(os.getenv("BYOEH_CHUNK_SIZE", "1000"))
CHUNK_OVERLAP = int(os.getenv("BYOEH_CHUNK_OVERLAP", "200"))
# Lazy-loaded sentence-transformers model
_local_model = None
class ChunkingError(Exception):
"""Error during text chunking."""
pass
class EmbeddingError(Exception):
"""Error during embedding generation."""
pass
class EncryptionError(Exception):
"""Error during encryption/decryption."""
pass
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
"""
Split text into overlapping chunks.
Uses a simple recursive character splitter approach:
- Try to split on paragraph boundaries first
- Then sentences
- Then words
- Finally characters
Args:
text: Input text to chunk
chunk_size: Target chunk size in characters
overlap: Overlap between chunks
Returns:
List of text chunks
"""
if not text or len(text) <= chunk_size:
return [text] if text else []
chunks = []
separators = ["\n\n", "\n", ". ", " ", ""]
def split_recursive(text: str, sep_idx: int = 0) -> List[str]:
if len(text) <= chunk_size:
return [text]
if sep_idx >= len(separators):
# Last resort: hard split
return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size - overlap)]
sep = separators[sep_idx]
if not sep:
# Empty separator = character split
parts = list(text)
else:
parts = text.split(sep)
result = []
current = ""
for part in parts:
test_chunk = current + sep + part if current else part
if len(test_chunk) <= chunk_size:
current = test_chunk
else:
if current:
result.append(current)
# If single part is too big, recursively split it
if len(part) > chunk_size:
result.extend(split_recursive(part, sep_idx + 1))
current = ""
else:
current = part
if current:
result.append(current)
return result
raw_chunks = split_recursive(text)
# Add overlap
final_chunks = []
for i, chunk in enumerate(raw_chunks):
if i > 0 and overlap > 0:
# Add overlap from previous chunk
prev_chunk = raw_chunks[i-1]
overlap_text = prev_chunk[-min(overlap, len(prev_chunk)):]
chunk = overlap_text + chunk
final_chunks.append(chunk.strip())
return [c for c in final_chunks if c]
def get_vector_size() -> int:
"""Get the vector dimension for the current embedding backend."""
return VECTOR_DIMENSIONS.get(EMBEDDING_BACKEND, 384)
def _get_local_model():
"""Lazy-load the sentence-transformers model."""
global _local_model
if _local_model is None:
try:
from sentence_transformers import SentenceTransformer
print(f"Loading local embedding model: {LOCAL_EMBEDDING_MODEL}")
_local_model = SentenceTransformer(LOCAL_EMBEDDING_MODEL)
print(f"Model loaded successfully (dim={_local_model.get_sentence_embedding_dimension()})")
except ImportError:
raise EmbeddingError(
"sentence-transformers not installed. "
"Install with: pip install sentence-transformers"
)
return _local_model
def _generate_local_embeddings(texts: List[str]) -> List[List[float]]:
"""Generate embeddings using local sentence-transformers model."""
if not texts:
return []
model = _get_local_model()
embeddings = model.encode(texts, show_progress_bar=len(texts) > 10)
return [emb.tolist() for emb in embeddings]
async def _generate_openai_embeddings(texts: List[str]) -> List[List[float]]:
"""Generate embeddings using OpenAI API."""
if not OPENAI_API_KEY:
raise EmbeddingError("OPENAI_API_KEY not configured")
try:
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.openai.com/v1/embeddings",
headers={
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json"
},
json={
"model": EMBEDDING_MODEL,
"input": texts
},
timeout=60.0
)
if response.status_code != 200:
raise EmbeddingError(f"OpenAI API error: {response.status_code} - {response.text}")
data = response.json()
embeddings = [item["embedding"] for item in data["data"]]
return embeddings
except httpx.TimeoutException:
raise EmbeddingError("OpenAI API timeout")
except Exception as e:
raise EmbeddingError(f"Failed to generate embeddings: {str(e)}")
async def generate_embeddings(texts: List[str]) -> List[List[float]]:
"""
Generate embeddings using configured backend.
Backends:
- local: sentence-transformers (default, no API key needed)
- openai: OpenAI text-embedding-3-small
Args:
texts: List of text chunks
Returns:
List of embedding vectors
Raises:
EmbeddingError: If embedding generation fails
"""
if not texts:
return []
if EMBEDDING_BACKEND == "local":
# Local model runs synchronously but is fast
return _generate_local_embeddings(texts)
elif EMBEDDING_BACKEND == "openai":
return await _generate_openai_embeddings(texts)
else:
raise EmbeddingError(f"Unknown embedding backend: {EMBEDDING_BACKEND}")
async def generate_single_embedding(text: str) -> List[float]:
"""Generate embedding for a single text."""
embeddings = await generate_embeddings([text])
return embeddings[0] if embeddings else []
def derive_key(passphrase: str, salt: bytes) -> bytes:
"""
Derive encryption key from passphrase using PBKDF2.
Args:
passphrase: User passphrase
salt: Random salt (16 bytes)
Returns:
32-byte AES key
"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
return kdf.derive(passphrase.encode())
def encrypt_text(text: str, passphrase: str, salt_hex: str) -> str:
"""
Encrypt text using AES-256-GCM.
Args:
text: Plaintext to encrypt
passphrase: User passphrase
salt_hex: Salt as hex string
Returns:
Base64-encoded ciphertext (IV + ciphertext)
"""
try:
salt = bytes.fromhex(salt_hex)
key = derive_key(passphrase, salt)
aesgcm = AESGCM(key)
iv = os.urandom(12)
ciphertext = aesgcm.encrypt(iv, text.encode(), None)
# Combine IV + ciphertext
combined = iv + ciphertext
return base64.b64encode(combined).decode()
except Exception as e:
raise EncryptionError(f"Encryption failed: {str(e)}")
def decrypt_text(encrypted_b64: str, passphrase: str, salt_hex: str) -> str:
"""
Decrypt text using AES-256-GCM.
Args:
encrypted_b64: Base64-encoded ciphertext (IV + ciphertext)
passphrase: User passphrase
salt_hex: Salt as hex string
Returns:
Decrypted plaintext
"""
try:
salt = bytes.fromhex(salt_hex)
key = derive_key(passphrase, salt)
combined = base64.b64decode(encrypted_b64)
iv = combined[:12]
ciphertext = combined[12:]
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(iv, ciphertext, None)
return plaintext.decode()
except Exception as e:
raise EncryptionError(f"Decryption failed: {str(e)}")
def hash_key(passphrase: str, salt_hex: str) -> str:
"""
Create SHA-256 hash of derived key for verification.
Args:
passphrase: User passphrase
salt_hex: Salt as hex string
Returns:
Hex-encoded key hash
"""
salt = bytes.fromhex(salt_hex)
key = derive_key(passphrase, salt)
return hashlib.sha256(key).hexdigest()
def verify_key_hash(passphrase: str, salt_hex: str, expected_hash: str) -> bool:
"""
Verify passphrase matches stored key hash.
Args:
passphrase: User passphrase to verify
salt_hex: Salt as hex string
expected_hash: Expected key hash
Returns:
True if passphrase is correct
"""
computed_hash = hash_key(passphrase, salt_hex)
return computed_hash == expected_hash
def extract_text_from_pdf(pdf_content: bytes) -> str:
"""
Extract text from PDF file.
Args:
pdf_content: Raw PDF bytes
Returns:
Extracted text
"""
try:
import PyPDF2
pdf_file = io.BytesIO(pdf_content)
reader = PyPDF2.PdfReader(pdf_file)
text_parts = []
for page in reader.pages:
text = page.extract_text()
if text:
text_parts.append(text)
return "\n\n".join(text_parts)
except ImportError:
raise ChunkingError("PyPDF2 not installed")
except Exception as e:
raise ChunkingError(f"Failed to extract PDF text: {str(e)}")
async def process_eh_for_indexing(
eh_id: str,
tenant_id: str,
subject: str,
text_content: str,
passphrase: str,
salt_hex: str
) -> Tuple[int, List[dict]]:
"""
Full processing pipeline for Erwartungshorizont indexing.
1. Chunk the text
2. Generate embeddings
3. Encrypt chunks
4. Return prepared data for Qdrant
Args:
eh_id: Erwartungshorizont ID
tenant_id: Tenant ID
subject: Subject (deutsch, englisch, etc.)
text_content: Decrypted text content
passphrase: User passphrase for re-encryption
salt_hex: Salt for encryption
Returns:
Tuple of (chunk_count, chunks_data)
"""
# 1. Chunk the text
chunks = chunk_text(text_content)
if not chunks:
return 0, []
# 2. Generate embeddings
embeddings = await generate_embeddings(chunks)
# 3. Encrypt chunks for storage
encrypted_chunks = []
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
encrypted_content = encrypt_text(chunk, passphrase, salt_hex)
encrypted_chunks.append({
"chunk_index": i,
"embedding": embedding,
"encrypted_content": encrypted_content
})
return len(chunks), encrypted_chunks

View File

@@ -0,0 +1,34 @@
"""
Erwartungshorizont Templates for Vorabitur Mode — barrel re-export.
The actual code lives in:
- eh_templates_types.py (AUFGABENTYPEN, EHKriterium, EHTemplate)
- eh_templates_analyse.py (Textanalyse, Gedicht, Prosa, Drama)
- eh_templates_eroerterung.py (Eroerterung textgebunden)
- eh_templates_registry.py (TEMPLATES, get_template, list_templates, etc.)
"""
# Types
from .eh_templates_types import ( # noqa: F401
AUFGABENTYPEN,
EHKriterium,
EHTemplate,
)
# Template factories
from .eh_templates_analyse import ( # noqa: F401
get_textanalyse_template,
get_gedichtanalyse_template,
get_prosaanalyse_template,
get_dramenanalyse_template,
)
from .eh_templates_eroerterung import get_eroerterung_template # noqa: F401
# Registry
from .eh_templates_registry import ( # noqa: F401
TEMPLATES,
initialize_templates,
get_template,
list_templates,
get_aufgabentypen,
)

View File

@@ -0,0 +1,395 @@
"""
Erwartungshorizont Templates — Analyse templates.
Contains templates for:
- Textanalyse (pragmatische Texte)
- Gedichtanalyse / Lyrikinterpretation
- Prosaanalyse
- Dramenanalyse
"""
from .eh_templates_types import EHTemplate, EHKriterium
def get_textanalyse_template() -> EHTemplate:
"""Template for pragmatic text analysis."""
return EHTemplate(
id="template_textanalyse_pragmatisch",
aufgabentyp="textanalyse_pragmatisch",
name="Textanalyse pragmatischer Texte",
beschreibung="Vorlage fuer die Analyse von Sachtexten, Reden, Kommentaren und Essays",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Wiedergabe des Textinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Textaussage/These",
"Vollstaendige Wiedergabe der Argumentationsstruktur",
"Erkennen von Intention und Adressatenbezug",
"Einordnung in den historischen/gesellschaftlichen Kontext",
"Beruecksichtigung aller relevanten Textaspekte"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau und Gliederung der Analyse",
gewichtung=15,
erwartungen=[
"Sinnvolle Einleitung mit Basisinformationen",
"Logische Gliederung des Hauptteils",
"Stringente Gedankenfuehrung",
"Angemessener Schluss mit Fazit/Wertung",
"Absatzgliederung und Ueberlaenge"
]
),
EHKriterium(
id="analyse",
name="Analytische Qualitaet",
beschreibung="Tiefe und Qualitaet der Analyse",
gewichtung=15,
erwartungen=[
"Erkennen rhetorischer Mittel",
"Funktionale Deutung der Stilmittel",
"Analyse der Argumentationsweise",
"Beruecksichtigung von Wortwahl und Satzbau",
"Verknuepfung von Form und Inhalt"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung",
"Korrekte Getrennt- und Zusammenschreibung",
"Korrekte Fremdwortschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Flexion",
"Korrekte Zeichensetzung",
"Korrekte Bezuege und Kongruenz"
]
)
],
einleitung_hinweise=[
"Nennung von Autor, Titel, Textsorte, Erscheinungsjahr",
"Benennung des Themas",
"Formulierung der Kernthese/Hauptaussage",
"Ggf. Einordnung in den Kontext"
],
hauptteil_hinweise=[
"Systematische Analyse der Argumentationsstruktur",
"Untersuchung der sprachlichen Gestaltung",
"Funktionale Deutung der Stilmittel",
"Beruecksichtigung von Adressatenbezug und Intention",
"Textbelege durch Zitate"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Bewertung der Ueberzeugungskraft",
"Ggf. aktuelle Relevanz",
"Persoenliche Stellungnahme (wenn gefordert)"
],
sprachliche_aspekte=[
"Fachsprachliche Begriffe korrekt verwenden",
"Konjunktiv fuer indirekte Rede",
"Praesens als Tempus der Analyse",
"Sachlicher, analytischer Stil"
]
)
def get_gedichtanalyse_template() -> EHTemplate:
"""Template for poetry analysis."""
return EHTemplate(
id="template_gedichtanalyse",
aufgabentyp="gedichtanalyse",
name="Gedichtanalyse / Lyrikinterpretation",
beschreibung="Vorlage fuer die Analyse und Interpretation lyrischer Texte",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Gedichtinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung des lyrischen Ichs und der Sprechsituation",
"Vollstaendige inhaltliche Erschliessung aller Strophen",
"Erkennen der zentralen Motive und Themen",
"Epochenzuordnung und literaturgeschichtliche Einordnung",
"Deutung der Bildlichkeit und Symbolik"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Interpretation",
gewichtung=15,
erwartungen=[
"Einleitung mit Basisinformationen",
"Systematische strophenweise oder aspektorientierte Analyse",
"Verknuepfung von Form- und Inhaltsanalyse",
"Schluessige Gesamtdeutung im Schluss"
]
),
EHKriterium(
id="formanalyse",
name="Formale Analyse",
beschreibung="Analyse der lyrischen Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Bestimmung von Metrum und Reimschema",
"Analyse der Klanggestaltung",
"Erkennen von Enjambements und Zaesuren",
"Deutung der formalen Mittel",
"Verknuepfung von Form und Inhalt"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung",
"Korrekte Getrennt- und Zusammenschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Flexion",
"Korrekte Zeichensetzung"
]
)
],
einleitung_hinweise=[
"Autor, Titel, Entstehungsjahr/Epoche",
"Thema/Motiv des Gedichts",
"Erste Deutungshypothese",
"Formale Grunddaten (Strophen, Verse)"
],
hauptteil_hinweise=[
"Inhaltliche Analyse (strophenweise oder aspektorientiert)",
"Formale Analyse (Metrum, Reim, Klang)",
"Sprachliche Analyse (Stilmittel, Bildlichkeit)",
"Funktionale Verknuepfung aller Ebenen",
"Textbelege durch Zitate mit Versangabe"
],
schluss_hinweise=[
"Zusammenfassung der Interpretationsergebnisse",
"Bestaetigung/Modifikation der Deutungshypothese",
"Einordnung in Epoche/Werk des Autors",
"Aktualitaetsbezug (wenn sinnvoll)"
],
sprachliche_aspekte=[
"Fachbegriffe der Lyrikanalyse verwenden",
"Zwischen lyrischem Ich und Autor unterscheiden",
"Praesens als Analysetempus",
"Deutende statt beschreibende Formulierungen"
]
)
def get_prosaanalyse_template() -> EHTemplate:
"""Template for prose/narrative text analysis."""
return EHTemplate(
id="template_prosaanalyse",
aufgabentyp="prosaanalyse",
name="Epische Textanalyse / Prosaanalyse",
beschreibung="Vorlage fuer die Analyse von Romanauszuegen, Kurzgeschichten und Novellen",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Textinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Handlung",
"Charakterisierung der Figuren",
"Erkennen der Erzaehlsituation",
"Deutung der Konflikte und Motive",
"Einordnung in den Gesamtzusammenhang"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Analyse",
gewichtung=15,
erwartungen=[
"Informative Einleitung",
"Systematische Analyse im Hauptteil",
"Verknuepfung der Analyseergebnisse",
"Schluessige Gesamtdeutung"
]
),
EHKriterium(
id="erzaehltechnik",
name="Erzaehltechnische Analyse",
beschreibung="Analyse narrativer Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Bestimmung der Erzaehlperspektive",
"Analyse von Zeitgestaltung",
"Raumgestaltung und Atmosphaere",
"Figurenrede und Bewusstseinsdarstellung",
"Funktionale Deutung"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung"
]
)
],
einleitung_hinweise=[
"Autor, Titel, Textsorte, Erscheinungsjahr",
"Einordnung des Auszugs in den Gesamttext",
"Thema und Deutungshypothese"
],
hauptteil_hinweise=[
"Kurze Inhaltsangabe des Auszugs",
"Analyse der Handlungsstruktur",
"Figurenanalyse mit Textbelegen",
"Erzaehltechnische Analyse",
"Sprachliche Analyse",
"Verknuepfung aller Ebenen"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Bestaetigung der Deutungshypothese",
"Bedeutung fuer Gesamtwerk",
"Ggf. Aktualitaetsbezug"
],
sprachliche_aspekte=[
"Fachbegriffe der Erzaehltextanalyse",
"Zwischen Erzaehler und Autor unterscheiden",
"Praesens als Analysetempus",
"Deutende Formulierungen"
]
)
def get_dramenanalyse_template() -> EHTemplate:
"""Template for drama analysis."""
return EHTemplate(
id="template_dramenanalyse",
aufgabentyp="dramenanalyse",
name="Dramenanalyse",
beschreibung="Vorlage fuer die Analyse dramatischer Texte und Szenen",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Szeneninhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Handlung",
"Analyse der Figurenkonstellation",
"Erkennen des dramatischen Konflikts",
"Einordnung in den Handlungsverlauf",
"Deutung der Szene im Gesamtzusammenhang"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Analyse",
gewichtung=15,
erwartungen=[
"Einleitung mit Kontextualisierung",
"Systematische Szenenanalyse",
"Verknuepfung der Analyseergebnisse",
"Schluessige Deutung"
]
),
EHKriterium(
id="dramentechnik",
name="Dramentechnische Analyse",
beschreibung="Analyse dramatischer Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Analyse der Dialoggestaltung",
"Regieanweisungen und Buehnenraum",
"Dramatische Spannung",
"Monolog/Dialog-Formen",
"Funktionale Deutung"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung"
]
)
],
einleitung_hinweise=[
"Autor, Titel, Urauffuehrungsjahr, Dramenform",
"Einordnung der Szene in den Handlungsverlauf",
"Thema und Deutungshypothese"
],
hauptteil_hinweise=[
"Situierung der Szene",
"Analyse des Dialogverlaufs",
"Figurenanalyse im Dialog",
"Sprachliche Analyse",
"Dramentechnische Mittel",
"Bedeutung fuer den Konflikt"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Funktion der Szene im Drama",
"Bedeutung fuer die Gesamtdeutung"
],
sprachliche_aspekte=[
"Fachbegriffe der Dramenanalyse",
"Praesens als Analysetempus",
"Korrekte Zitierweise mit Akt/Szene/Zeile"
]
)

View File

@@ -0,0 +1,101 @@
"""
Erwartungshorizont Templates — Eroerterung template.
"""
from .eh_templates_types import EHTemplate, EHKriterium
def get_eroerterung_template() -> EHTemplate:
"""Template for textgebundene Eroerterung."""
return EHTemplate(
id="template_eroerterung_textgebunden",
aufgabentyp="eroerterung_textgebunden",
name="Textgebundene Eroerterung",
beschreibung="Vorlage fuer die textgebundene Eroerterung auf Basis eines Sachtextes",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Qualitaet der Argumentation",
gewichtung=40,
erwartungen=[
"Korrekte Wiedergabe der Textposition",
"Differenzierte eigene Argumentation",
"Vielfaeltige und ueberzeugende Argumente",
"Beruecksichtigung von Pro und Contra",
"Sinnvolle Beispiele und Belege",
"Eigenstaendige Schlussfolgerung"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Eroerterung",
gewichtung=15,
erwartungen=[
"Problemorientierte Einleitung",
"Klare Gliederung der Argumentation",
"Logische Argumentationsfolge",
"Sinnvolle Ueberlaetze",
"Begruendetes Fazit"
]
),
EHKriterium(
id="textbezug",
name="Textbezug",
beschreibung="Verknuepfung mit dem Ausgangstext",
gewichtung=15,
erwartungen=[
"Angemessene Textwiedergabe",
"Kritische Auseinandersetzung mit Textposition",
"Korrekte Zitierweise",
"Verknuepfung eigener Argumente mit Text"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung",
"Variationsreicher Ausdruck"
]
)
],
einleitung_hinweise=[
"Hinfuehrung zum Thema",
"Nennung des Ausgangstextes",
"Formulierung der Leitfrage/These",
"Ueberleitung zum Hauptteil"
],
hauptteil_hinweise=[
"Kurze Wiedergabe der Textposition",
"Systematische Argumentation (dialektisch oder linear)",
"Jedes Argument: These - Begruendung - Beispiel",
"Gewichtung der Argumente",
"Verknuepfung mit Textposition"
],
schluss_hinweise=[
"Zusammenfassung der wichtigsten Argumente",
"Eigene begruendete Stellungnahme",
"Ggf. Ausblick oder Appell"
],
sprachliche_aspekte=[
"Argumentative Konnektoren verwenden",
"Sachlicher, ueberzeugender Stil",
"Eigene Meinung kennzeichnen",
"Konjunktiv fuer Textpositionen"
]
)

View File

@@ -0,0 +1,60 @@
"""
Erwartungshorizont Templates — registry for template lookup.
"""
from typing import Dict, List, Optional
from .eh_templates_types import EHTemplate, AUFGABENTYPEN
from .eh_templates_analyse import (
get_textanalyse_template,
get_gedichtanalyse_template,
get_prosaanalyse_template,
get_dramenanalyse_template,
)
from .eh_templates_eroerterung import get_eroerterung_template
TEMPLATES: Dict[str, EHTemplate] = {}
def initialize_templates():
"""Initialize all pre-defined templates."""
global TEMPLATES
TEMPLATES = {
"textanalyse_pragmatisch": get_textanalyse_template(),
"gedichtanalyse": get_gedichtanalyse_template(),
"eroerterung_textgebunden": get_eroerterung_template(),
"prosaanalyse": get_prosaanalyse_template(),
"dramenanalyse": get_dramenanalyse_template(),
}
def get_template(aufgabentyp: str) -> Optional[EHTemplate]:
"""Get a template by Aufgabentyp."""
if not TEMPLATES:
initialize_templates()
return TEMPLATES.get(aufgabentyp)
def list_templates() -> List[Dict]:
"""List all available templates."""
if not TEMPLATES:
initialize_templates()
return [
{
"aufgabentyp": typ,
"name": AUFGABENTYPEN.get(typ, {}).get("name", typ),
"description": AUFGABENTYPEN.get(typ, {}).get("description", ""),
"category": AUFGABENTYPEN.get(typ, {}).get("category", "other"),
}
for typ in TEMPLATES.keys()
]
def get_aufgabentypen() -> Dict:
"""Get all Aufgabentypen definitions."""
return AUFGABENTYPEN
# Initialize on import
initialize_templates()

View File

@@ -0,0 +1,100 @@
"""
Erwartungshorizont Templates — types and Aufgabentypen registry.
"""
from typing import Dict, List, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime
AUFGABENTYPEN = {
"textanalyse_pragmatisch": {
"name": "Textanalyse (pragmatische Texte)",
"description": "Analyse von Sachtexten, Reden, Kommentaren, Essays",
"category": "analyse"
},
"sachtextanalyse": {
"name": "Sachtextanalyse",
"description": "Analyse von informativen und appellativen Sachtexten",
"category": "analyse"
},
"gedichtanalyse": {
"name": "Gedichtanalyse / Lyrikinterpretation",
"description": "Analyse und Interpretation lyrischer Texte",
"category": "interpretation"
},
"dramenanalyse": {
"name": "Dramenanalyse",
"description": "Analyse dramatischer Texte und Szenen",
"category": "interpretation"
},
"prosaanalyse": {
"name": "Epische Textanalyse / Prosaanalyse",
"description": "Analyse von Romanauszuegen, Kurzgeschichten, Novellen",
"category": "interpretation"
},
"eroerterung_textgebunden": {
"name": "Textgebundene Eroerterung",
"description": "Eroerterung auf Basis eines Sachtextes",
"category": "argumentation"
},
"eroerterung_frei": {
"name": "Freie Eroerterung",
"description": "Freie Eroerterung zu einem Thema",
"category": "argumentation"
},
"eroerterung_literarisch": {
"name": "Literarische Eroerterung",
"description": "Eroerterung zu literarischen Fragestellungen",
"category": "argumentation"
},
"materialgestuetzt": {
"name": "Materialgestuetztes Schreiben",
"description": "Verfassen eines Textes auf Materialbasis",
"category": "produktion"
}
}
@dataclass
class EHKriterium:
"""Single criterion in an Erwartungshorizont."""
id: str
name: str
beschreibung: str
gewichtung: int # Percentage weight (0-100)
erwartungen: List[str] # Expected points/elements
max_punkte: int = 100
def to_dict(self):
return asdict(self)
@dataclass
class EHTemplate:
"""Complete Erwartungshorizont template."""
id: str
aufgabentyp: str
name: str
beschreibung: str
kriterien: List[EHKriterium]
einleitung_hinweise: List[str]
hauptteil_hinweise: List[str]
schluss_hinweise: List[str]
sprachliche_aspekte: List[str]
created_at: datetime = field(default_factory=lambda: datetime.now())
def to_dict(self):
d = {
'id': self.id,
'aufgabentyp': self.aufgabentyp,
'name': self.name,
'beschreibung': self.beschreibung,
'kriterien': [k.to_dict() for k in self.kriterien],
'einleitung_hinweise': self.einleitung_hinweise,
'hauptteil_hinweise': self.hauptteil_hinweise,
'schluss_hinweise': self.schluss_hinweise,
'sprachliche_aspekte': self.sprachliche_aspekte,
'created_at': self.created_at.isoformat()
}
return d

View File

@@ -0,0 +1,17 @@
"""
PDF Export Module for Abiturkorrektur System
Barrel re-export: all PDF generation functions and constants.
"""
from .pdf_export_styles import ( # noqa: F401
GRADE_POINTS_TO_NOTE,
CRITERIA_DISPLAY_NAMES,
CRITERIA_WEIGHTS,
get_custom_styles,
)
from .pdf_export_gutachten import generate_gutachten_pdf # noqa: F401
from .pdf_export_overview import ( # noqa: F401
generate_klausur_overview_pdf,
generate_annotations_pdf,
)

View File

@@ -0,0 +1,315 @@
"""
PDF Export - Individual Gutachten PDF generation.
Generates a single student's Gutachten with criteria table,
workflow info, and annotation summary.
"""
import io
from datetime import datetime
from typing import Dict, List, Optional, Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, KeepTogether
)
from .pdf_export_styles import (
GRADE_POINTS_TO_NOTE,
CRITERIA_DISPLAY_NAMES,
CRITERIA_WEIGHTS,
get_custom_styles,
)
def generate_gutachten_pdf(
student_data: Dict[str, Any],
klausur_data: Dict[str, Any],
annotations: List[Dict[str, Any]] = None,
workflow_data: Dict[str, Any] = None
) -> bytes:
"""
Generate a PDF Gutachten for a single student.
Args:
student_data: Student work data including criteria_scores, gutachten, grade_points
klausur_data: Klausur metadata (title, subject, year, etc.)
annotations: List of annotations for annotation summary
workflow_data: Examiner workflow data (EK, ZK, DK info)
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Gutachten zur Abiturklausur", styles['GutachtenTitle']))
story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
# Meta information table
meta_data = [
["Pruefling:", student_data.get('student_name', 'Anonym')],
["Schuljahr:", f"{klausur_data.get('year', 2025)}"],
["Kurs:", klausur_data.get('semester', 'Abitur')],
["Datum:", datetime.now().strftime("%d.%m.%Y")]
]
meta_table = Table(meta_data, colWidths=[4*cm, 10*cm])
meta_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(meta_table)
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Gutachten content
_add_gutachten_content(story, styles, student_data)
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Bewertungstabelle
_add_criteria_table(story, styles, student_data)
# Final grade box
_add_grade_box(story, styles, student_data)
# Examiner workflow information
if workflow_data:
_add_workflow_info(story, styles, workflow_data)
# Annotation summary
if annotations:
_add_annotation_summary(story, styles, annotations)
# Footer
_add_footer(story, styles)
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
def _add_gutachten_content(story, styles, student_data):
"""Add gutachten text sections to the story."""
gutachten = student_data.get('gutachten', {})
if gutachten:
if gutachten.get('einleitung'):
story.append(Paragraph("Einleitung", styles['SectionHeader']))
story.append(Paragraph(gutachten['einleitung'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
if gutachten.get('hauptteil'):
story.append(Paragraph("Hauptteil", styles['SectionHeader']))
story.append(Paragraph(gutachten['hauptteil'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
if gutachten.get('fazit'):
story.append(Paragraph("Fazit", styles['SectionHeader']))
story.append(Paragraph(gutachten['fazit'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
if gutachten.get('staerken') or gutachten.get('schwaechen'):
story.append(Spacer(1, 0.3*cm))
if gutachten.get('staerken'):
story.append(Paragraph("Staerken:", styles['SectionHeader']))
for s in gutachten['staerken']:
story.append(Paragraph(f"{s}", styles['ListItem']))
if gutachten.get('schwaechen'):
story.append(Paragraph("Verbesserungspotenzial:", styles['SectionHeader']))
for s in gutachten['schwaechen']:
story.append(Paragraph(f"{s}", styles['ListItem']))
else:
story.append(Paragraph("<i>Kein Gutachten-Text vorhanden.</i>", styles['GutachtenBody']))
def _add_criteria_table(story, styles, student_data):
"""Add criteria scoring table to the story."""
story.append(Paragraph("Bewertung nach Kriterien", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
criteria_scores = student_data.get('criteria_scores', {})
table_data = [["Kriterium", "Gewichtung", "Erreicht", "Punkte"]]
total_weighted = 0
total_weight = 0
for key, display_name in CRITERIA_DISPLAY_NAMES.items():
weight = CRITERIA_WEIGHTS.get(key, 0)
score_data = criteria_scores.get(key, {})
score = score_data.get('score', 0) if isinstance(score_data, dict) else score_data
weighted_score = (score / 100) * weight if score else 0
total_weighted += weighted_score
total_weight += weight
table_data.append([
display_name,
f"{weight}%",
f"{score}%",
f"{weighted_score:.1f}"
])
table_data.append([
"Gesamt",
f"{total_weight}%",
"",
f"{total_weighted:.1f}"
])
criteria_table = Table(table_data, colWidths=[8*cm, 2.5*cm, 2.5*cm, 2.5*cm])
criteria_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('ALIGN', (1, 0), (-1, -1), 'CENTER'),
('FONTSIZE', (0, 1), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#f7fafc')),
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#f7fafc')]),
]))
story.append(criteria_table)
story.append(Spacer(1, 0.5*cm))
def _add_grade_box(story, styles, student_data):
"""Add final grade box to the story."""
grade_points = student_data.get('grade_points', 0)
grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "?")
raw_points = student_data.get('raw_points', 0)
grade_data = [
["Rohpunkte:", f"{raw_points} / 100"],
["Notenpunkte:", f"{grade_points} Punkte"],
["Note:", grade_note]
]
grade_table = Table(grade_data, colWidths=[4*cm, 4*cm])
grade_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ebf8ff')),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, -1), (1, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 11),
('FONTSIZE', (1, -1), (1, -1), 14),
('TEXTCOLOR', (1, -1), (1, -1), colors.HexColor('#2c5282')),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 12),
('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#2c5282')),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
]))
story.append(KeepTogether([
Paragraph("Endergebnis", styles['SectionHeader']),
Spacer(1, 0.2*cm),
grade_table
]))
def _add_workflow_info(story, styles, workflow_data):
"""Add examiner workflow information to the story."""
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Korrekturverlauf", styles['SectionHeader']))
workflow_rows = []
if workflow_data.get('erst_korrektor'):
ek = workflow_data['erst_korrektor']
workflow_rows.append([
"Erstkorrektor:",
ek.get('name', 'Unbekannt'),
f"{ek.get('grade_points', '-')} Punkte"
])
if workflow_data.get('zweit_korrektor'):
zk = workflow_data['zweit_korrektor']
workflow_rows.append([
"Zweitkorrektor:",
zk.get('name', 'Unbekannt'),
f"{zk.get('grade_points', '-')} Punkte"
])
if workflow_data.get('dritt_korrektor'):
dk = workflow_data['dritt_korrektor']
workflow_rows.append([
"Drittkorrektor:",
dk.get('name', 'Unbekannt'),
f"{dk.get('grade_points', '-')} Punkte"
])
if workflow_data.get('final_grade_source'):
workflow_rows.append([
"Endnote durch:",
workflow_data['final_grade_source'],
""
])
if workflow_rows:
workflow_table = Table(workflow_rows, colWidths=[4*cm, 6*cm, 4*cm])
workflow_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(workflow_table)
def _add_annotation_summary(story, styles, annotations):
"""Add annotation summary to the story."""
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Anmerkungen (Zusammenfassung)", styles['SectionHeader']))
by_type = {}
for ann in annotations:
ann_type = ann.get('type', 'comment')
if ann_type not in by_type:
by_type[ann_type] = []
by_type[ann_type].append(ann)
for ann_type, anns in by_type.items():
type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title())
story.append(Paragraph(f"{type_name} ({len(anns)} Anmerkungen)", styles['ListItem']))
def _add_footer(story, styles):
"""Add generation footer to the story."""
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))

View File

@@ -0,0 +1,297 @@
"""
PDF Export - Klausur overview and annotations PDF generation.
Generates:
- Klausur overview with grade distribution for all students
- Annotations PDF for a single student
"""
import io
from datetime import datetime
from typing import Dict, List, Optional, Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable
)
from .pdf_export_styles import (
GRADE_POINTS_TO_NOTE,
CRITERIA_DISPLAY_NAMES,
get_custom_styles,
)
def generate_klausur_overview_pdf(
klausur_data: Dict[str, Any],
students: List[Dict[str, Any]],
fairness_data: Optional[Dict[str, Any]] = None
) -> bytes:
"""
Generate an overview PDF for an entire Klausur with all student grades.
Args:
klausur_data: Klausur metadata
students: List of all student work data
fairness_data: Optional fairness analysis data
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=1.5*cm,
leftMargin=1.5*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Notenuebersicht", styles['GutachtenTitle']))
story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
# Meta information
meta_data = [
["Schuljahr:", f"{klausur_data.get('year', 2025)}"],
["Kurs:", klausur_data.get('semester', 'Abitur')],
["Anzahl Arbeiten:", str(len(students))],
["Stand:", datetime.now().strftime("%d.%m.%Y")]
]
meta_table = Table(meta_data, colWidths=[4*cm, 10*cm])
meta_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(meta_table)
story.append(Spacer(1, 0.5*cm))
# Statistics (if fairness data available)
if fairness_data and fairness_data.get('statistics'):
_add_statistics(story, styles, fairness_data['statistics'])
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Student grades table
sorted_students = sorted(students, key=lambda s: s.get('grade_points', 0), reverse=True)
_add_student_table(story, styles, sorted_students)
# Grade distribution
_add_grade_distribution(story, styles, sorted_students)
# Footer
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
def _add_statistics(story, styles, stats):
"""Add statistics section."""
story.append(Paragraph("Statistik", styles['SectionHeader']))
stats_data = [
["Durchschnitt:", f"{stats.get('average_grade', 0):.1f} Punkte"],
["Minimum:", f"{stats.get('min_grade', 0)} Punkte"],
["Maximum:", f"{stats.get('max_grade', 0)} Punkte"],
["Standardabweichung:", f"{stats.get('standard_deviation', 0):.2f}"],
]
stats_table = Table(stats_data, colWidths=[4*cm, 4*cm])
stats_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f7fafc')),
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
]))
story.append(stats_table)
story.append(Spacer(1, 0.5*cm))
def _add_student_table(story, styles, sorted_students):
"""Add student grades table."""
story.append(Paragraph("Einzelergebnisse", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
table_data = [["#", "Name", "Rohpunkte", "Notenpunkte", "Note", "Status"]]
for idx, student in enumerate(sorted_students, 1):
grade_points = student.get('grade_points', 0)
grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "-")
raw_points = student.get('raw_points', 0)
status = student.get('status', 'unknown')
status_display = {
'completed': 'Abgeschlossen',
'first_examiner': 'In Korrektur',
'second_examiner': 'Zweitkorrektur',
'uploaded': 'Hochgeladen',
'ocr_complete': 'OCR fertig',
'analyzing': 'Wird analysiert'
}.get(status, status)
table_data.append([
str(idx),
student.get('student_name', 'Anonym'),
f"{raw_points}/100",
str(grade_points),
grade_note,
status_display
])
student_table = Table(table_data, colWidths=[1*cm, 5*cm, 2.5*cm, 3*cm, 2*cm, 3*cm])
student_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('FONTSIZE', (0, 1), (-1, -1), 9),
('ALIGN', (0, 1), (0, -1), 'CENTER'),
('ALIGN', (2, 1), (4, -1), 'CENTER'),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f7fafc')]),
]))
story.append(student_table)
def _add_grade_distribution(story, styles, sorted_students):
"""Add grade distribution table."""
story.append(Spacer(1, 0.5*cm))
story.append(Paragraph("Notenverteilung", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
grade_counts = {}
for student in sorted_students:
gp = student.get('grade_points', 0)
grade_counts[gp] = grade_counts.get(gp, 0) + 1
dist_data = [["Punkte", "Note", "Anzahl"]]
for points in range(15, -1, -1):
if points in grade_counts:
note = GRADE_POINTS_TO_NOTE.get(points, "-")
count = grade_counts[points]
dist_data.append([str(points), note, str(count)])
if len(dist_data) > 1:
dist_table = Table(dist_data, colWidths=[2.5*cm, 2.5*cm, 2.5*cm])
dist_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
]))
story.append(dist_table)
def generate_annotations_pdf(
student_data: Dict[str, Any],
klausur_data: Dict[str, Any],
annotations: List[Dict[str, Any]]
) -> bytes:
"""
Generate a PDF with all annotations for a student work.
Args:
student_data: Student work data
klausur_data: Klausur metadata
annotations: List of all annotations
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Anmerkungen zur Klausur", styles['GutachtenTitle']))
story.append(Paragraph(f"{student_data.get('student_name', 'Anonym')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
if not annotations:
story.append(Paragraph("<i>Keine Anmerkungen vorhanden.</i>", styles['GutachtenBody']))
else:
# Group by type
by_type = {}
for ann in annotations:
ann_type = ann.get('type', 'comment')
if ann_type not in by_type:
by_type[ann_type] = []
by_type[ann_type].append(ann)
for ann_type, anns in by_type.items():
type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title())
story.append(Paragraph(f"{type_name} ({len(anns)})", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
sorted_anns = sorted(anns, key=lambda a: (a.get('page', 0), a.get('position', {}).get('y', 0)))
for idx, ann in enumerate(sorted_anns, 1):
page = ann.get('page', 1)
text = ann.get('text', '')
suggestion = ann.get('suggestion', '')
severity = ann.get('severity', 'minor')
ann_text = f"<b>[S.{page}]</b> {text}"
if suggestion:
ann_text += f" -> <i>{suggestion}</i>"
if severity == 'critical':
ann_text = f"<font color='red'>{ann_text}</font>"
elif severity == 'major':
ann_text = f"<font color='orange'>{ann_text}</font>"
story.append(Paragraph(f"{idx}. {ann_text}", styles['ListItem']))
story.append(Spacer(1, 0.3*cm))
# Footer
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()

View File

@@ -0,0 +1,110 @@
"""
PDF Export - Constants and ReportLab styles for Abiturkorrektur PDFs.
"""
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
# =============================================
# CONSTANTS
# =============================================
GRADE_POINTS_TO_NOTE = {
15: "1+", 14: "1", 13: "1-",
12: "2+", 11: "2", 10: "2-",
9: "3+", 8: "3", 7: "3-",
6: "4+", 5: "4", 4: "4-",
3: "5+", 2: "5", 1: "5-",
0: "6"
}
CRITERIA_DISPLAY_NAMES = {
"rechtschreibung": "Sprachliche Richtigkeit (Rechtschreibung)",
"grammatik": "Sprachliche Richtigkeit (Grammatik)",
"inhalt": "Inhaltliche Leistung",
"struktur": "Aufbau und Struktur",
"stil": "Ausdruck und Stil"
}
CRITERIA_WEIGHTS = {
"rechtschreibung": 15,
"grammatik": 15,
"inhalt": 40,
"struktur": 15,
"stil": 15
}
# =============================================
# STYLES
# =============================================
def get_custom_styles():
"""Create custom paragraph styles for Gutachten."""
styles = getSampleStyleSheet()
# Title style
styles.add(ParagraphStyle(
name='GutachtenTitle',
parent=styles['Heading1'],
fontSize=16,
spaceAfter=12,
alignment=TA_CENTER,
textColor=colors.HexColor('#1e3a5f')
))
# Subtitle style
styles.add(ParagraphStyle(
name='GutachtenSubtitle',
parent=styles['Heading2'],
fontSize=12,
spaceAfter=8,
spaceBefore=16,
textColor=colors.HexColor('#2c5282')
))
# Section header
styles.add(ParagraphStyle(
name='SectionHeader',
parent=styles['Heading3'],
fontSize=11,
spaceAfter=6,
spaceBefore=12,
textColor=colors.HexColor('#2d3748'),
borderColor=colors.HexColor('#e2e8f0'),
borderWidth=0,
borderPadding=0
))
# Body text
styles.add(ParagraphStyle(
name='GutachtenBody',
parent=styles['Normal'],
fontSize=10,
leading=14,
alignment=TA_JUSTIFY,
spaceAfter=6
))
# Small text for footer/meta
styles.add(ParagraphStyle(
name='MetaText',
parent=styles['Normal'],
fontSize=8,
textColor=colors.grey,
alignment=TA_LEFT
))
# List item
styles.add(ParagraphStyle(
name='ListItem',
parent=styles['Normal'],
fontSize=10,
leftIndent=20,
bulletIndent=10,
spaceAfter=4
))
return styles

View File

@@ -0,0 +1,164 @@
"""
PDF Extraction Module
NOTE: This module delegates ML-heavy operations to the embedding-service via HTTP.
Provides enhanced PDF text extraction using multiple backends (in embedding-service):
1. Unstructured.io - Best for complex layouts, tables, headers (Apache 2.0)
2. pypdf - Modern, BSD-licensed PDF library (recommended default)
License Compliance:
- Default backends (unstructured, pypdf) are BSD/Apache licensed
"""
import os
import logging
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
# Configuration (for backward compatibility - actual config in embedding-service)
EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://embedding-service:8087")
PDF_BACKEND = os.getenv("PDF_EXTRACTION_BACKEND", "auto")
class PDFExtractionError(Exception):
"""Error during PDF extraction."""
pass
class PDFExtractionResult:
"""Result of PDF extraction with metadata."""
def __init__(
self,
text: str,
backend_used: str,
pages: int = 0,
elements: Optional[List[Dict]] = None,
tables: Optional[List[Dict]] = None,
metadata: Optional[Dict] = None,
):
self.text = text
self.backend_used = backend_used
self.pages = pages
self.elements = elements or []
self.tables = tables or []
self.metadata = metadata or {}
def to_dict(self) -> Dict:
return {
"text": self.text,
"backend_used": self.backend_used,
"pages": self.pages,
"element_count": len(self.elements),
"table_count": len(self.tables),
"metadata": self.metadata,
}
def _detect_available_backends() -> List[str]:
"""Get available backends from embedding-service."""
import httpx
try:
with httpx.Client(timeout=5.0) as client:
response = client.get(f"{EMBEDDING_SERVICE_URL}/models")
if response.status_code == 200:
data = response.json()
return data.get("available_pdf_backends", ["pypdf"])
except Exception as e:
logger.warning(f"Could not reach embedding-service: {e}")
return []
def extract_text_from_pdf_enhanced(
pdf_content: bytes,
backend: str = PDF_BACKEND,
fallback: bool = True,
) -> PDFExtractionResult:
"""
Extract text from PDF using embedding-service.
Args:
pdf_content: PDF file content as bytes
backend: Preferred backend (auto, unstructured, pypdf)
fallback: If True, try other backends if preferred fails
Returns:
PDFExtractionResult with extracted text and metadata
"""
import httpx
try:
with httpx.Client(timeout=120.0) as client:
response = client.post(
f"{EMBEDDING_SERVICE_URL}/extract-pdf",
content=pdf_content,
headers={"Content-Type": "application/octet-stream"}
)
response.raise_for_status()
data = response.json()
return PDFExtractionResult(
text=data.get("text", ""),
backend_used=data.get("backend_used", "unknown"),
pages=data.get("pages", 0),
tables=[{"count": data.get("table_count", 0)}] if data.get("table_count", 0) > 0 else [],
metadata={"embedding_service": True}
)
except httpx.TimeoutException:
raise PDFExtractionError("PDF extraction timeout")
except httpx.HTTPStatusError as e:
raise PDFExtractionError(f"PDF extraction error: {e.response.status_code}")
except Exception as e:
raise PDFExtractionError(f"Failed to extract PDF: {str(e)}")
def extract_text_from_pdf(pdf_content: bytes) -> str:
"""
Extract text from PDF (simple interface).
This is a drop-in replacement for the original function
that uses the embedding-service internally.
"""
result = extract_text_from_pdf_enhanced(pdf_content)
return result.text
def get_pdf_extraction_info() -> dict:
"""Get information about PDF extraction configuration."""
import httpx
try:
with httpx.Client(timeout=5.0) as client:
response = client.get(f"{EMBEDDING_SERVICE_URL}/models")
if response.status_code == 200:
data = response.json()
available = data.get("available_pdf_backends", [])
return {
"configured_backend": data.get("pdf_backend", PDF_BACKEND),
"available_backends": available,
"recommended": "unstructured" if "unstructured" in available else "pypdf",
"backend_licenses": {
"unstructured": "Apache-2.0",
"pypdf": "BSD-3-Clause",
},
"commercial_safe_backends": available,
"embedding_service_url": EMBEDDING_SERVICE_URL,
"embedding_service_available": True,
}
except Exception as e:
logger.warning(f"Could not reach embedding-service: {e}")
# Fallback when embedding-service is not available
return {
"configured_backend": PDF_BACKEND,
"available_backends": [],
"recommended": None,
"backend_licenses": {},
"commercial_safe_backends": [],
"embedding_service_url": EMBEDDING_SERVICE_URL,
"embedding_service_available": False,
}