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
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:
6
klausur-service/backend/korrektur/__init__.py
Normal file
6
klausur-service/backend/korrektur/__init__.py
Normal 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/.
|
||||
"""
|
||||
420
klausur-service/backend/korrektur/eh_pipeline.py
Normal file
420
klausur-service/backend/korrektur/eh_pipeline.py
Normal 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
|
||||
34
klausur-service/backend/korrektur/eh_templates.py
Normal file
34
klausur-service/backend/korrektur/eh_templates.py
Normal 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,
|
||||
)
|
||||
395
klausur-service/backend/korrektur/eh_templates_analyse.py
Normal file
395
klausur-service/backend/korrektur/eh_templates_analyse.py
Normal 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"
|
||||
]
|
||||
)
|
||||
101
klausur-service/backend/korrektur/eh_templates_eroerterung.py
Normal file
101
klausur-service/backend/korrektur/eh_templates_eroerterung.py
Normal 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"
|
||||
]
|
||||
)
|
||||
60
klausur-service/backend/korrektur/eh_templates_registry.py
Normal file
60
klausur-service/backend/korrektur/eh_templates_registry.py
Normal 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()
|
||||
100
klausur-service/backend/korrektur/eh_templates_types.py
Normal file
100
klausur-service/backend/korrektur/eh_templates_types.py
Normal 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
|
||||
17
klausur-service/backend/korrektur/pdf_export.py
Normal file
17
klausur-service/backend/korrektur/pdf_export.py
Normal 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,
|
||||
)
|
||||
315
klausur-service/backend/korrektur/pdf_export_gutachten.py
Normal file
315
klausur-service/backend/korrektur/pdf_export_gutachten.py
Normal 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']
|
||||
))
|
||||
297
klausur-service/backend/korrektur/pdf_export_overview.py
Normal file
297
klausur-service/backend/korrektur/pdf_export_overview.py
Normal 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()
|
||||
110
klausur-service/backend/korrektur/pdf_export_styles.py
Normal file
110
klausur-service/backend/korrektur/pdf_export_styles.py
Normal 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
|
||||
164
klausur-service/backend/korrektur/pdf_extraction.py
Normal file
164
klausur-service/backend/korrektur/pdf_extraction.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user