Files
breakpilot-lehrer/backend-lehrer/correction_endpoints.py
Benjamin Admin b4613e26f3 [split-required] Split 500-850 LOC files (batch 2)
backend-lehrer (10 files):
- game/database.py (785 → 5), correction_api.py (683 → 4)
- classroom_engine/antizipation.py (676 → 5)
- llm_gateway schools/edu_search already done in prior batch

klausur-service (12 files):
- orientation_crop_api.py (694 → 5), pdf_export.py (677 → 4)
- zeugnis_crawler.py (676 → 5), grid_editor_api.py (671 → 5)
- eh_templates.py (658 → 5), mail/api.py (651 → 5)
- qdrant_service.py (638 → 5), training_api.py (625 → 4)

website (6 pages):
- middleware (696 → 8), mail (733 → 6), consent (628 → 8)
- compliance/risks (622 → 5), export (502 → 5), brandbook (629 → 7)

studio-v2 (3 components):
- B2BMigrationWizard (848 → 3), CleanupPanel (765 → 2)
- dashboard-experimental (739 → 2)

admin-lehrer (4 files):
- uebersetzungen (769 → 4), manager (670 → 2)
- ChunkBrowserQA (675 → 6), dsfa/page (674 → 5)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:24:01 +02:00

475 lines
16 KiB
Python

"""
Correction API - REST endpoint handlers.
Workflow:
1. Upload: Gescannte Klassenarbeit hochladen
2. OCR: Text aus Handschrift extrahieren
3. Analyse: Antworten analysieren und bewerten
4. Feedback: KI-generiertes Feedback erstellen
5. Export: Korrigierte Arbeit als PDF exportieren
"""
import logging
import uuid
import os
from datetime import datetime
from typing import Dict, Any, Optional
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks
from correction_models import (
CorrectionStatus,
AnswerEvaluation,
CorrectionCreate,
CorrectionUpdate,
Correction,
CorrectionResponse,
AnalysisResponse,
UPLOAD_DIR,
)
from correction_helpers import (
corrections_store,
calculate_grade,
generate_ai_feedback,
process_ocr,
PDFService,
CorrectionData,
StudentInfo,
)
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/corrections",
tags=["corrections"],
)
# ============================================================================
# API Endpoints
# ============================================================================
@router.post("/", response_model=CorrectionResponse)
async def create_correction(data: CorrectionCreate):
"""
Erstellt eine neue Korrektur.
Noch ohne Datei - diese wird separat hochgeladen.
"""
correction_id = str(uuid.uuid4())
now = datetime.utcnow()
correction = Correction(
id=correction_id,
student_id=data.student_id,
student_name=data.student_name,
class_name=data.class_name,
exam_title=data.exam_title,
subject=data.subject,
max_points=data.max_points,
status=CorrectionStatus.UPLOADED,
created_at=now,
updated_at=now
)
corrections_store[correction_id] = correction
logger.info(f"Created correction {correction_id} for {data.student_name}")
return CorrectionResponse(success=True, correction=correction)
@router.post("/{correction_id}/upload", response_model=CorrectionResponse)
async def upload_exam(
correction_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...)
):
"""
Laedt gescannte Klassenarbeit hoch und startet OCR.
Unterstuetzte Formate: PDF, PNG, JPG, JPEG
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
# Validiere Dateiformat
allowed_extensions = {".pdf", ".png", ".jpg", ".jpeg"}
file_ext = Path(file.filename).suffix.lower() if file.filename else ""
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Ungueltiges Dateiformat. Erlaubt: {', '.join(allowed_extensions)}"
)
# Speichere Datei
file_path = UPLOAD_DIR / f"{correction_id}{file_ext}"
try:
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
correction.file_path = str(file_path)
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
# Starte OCR im Hintergrund
background_tasks.add_task(process_ocr, correction_id, str(file_path))
logger.info(f"Uploaded file for correction {correction_id}: {file.filename}")
return CorrectionResponse(success=True, correction=correction)
except Exception as e:
logger.error(f"Upload error: {e}")
return CorrectionResponse(success=False, error=str(e))
@router.get("/{correction_id}", response_model=CorrectionResponse)
async def get_correction(correction_id: str):
"""Ruft eine Korrektur ab."""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
return CorrectionResponse(success=True, correction=correction)
@router.get("/", response_model=Dict[str, Any])
async def list_corrections(
class_name: Optional[str] = None,
status: Optional[CorrectionStatus] = None,
limit: int = 50
):
"""Listet Korrekturen auf, optional gefiltert."""
corrections = list(corrections_store.values())
if class_name:
corrections = [c for c in corrections if c.class_name == class_name]
if status:
corrections = [c for c in corrections if c.status == status]
# Sortiere nach Erstellungsdatum (neueste zuerst)
corrections.sort(key=lambda x: x.created_at, reverse=True)
return {
"total": len(corrections),
"corrections": [c.dict() for c in corrections[:limit]]
}
@router.post("/{correction_id}/analyze", response_model=AnalysisResponse)
async def analyze_correction(
correction_id: str,
expected_answers: Optional[Dict[str, str]] = None
):
"""
Analysiert die extrahierten Antworten.
Optional mit Musterloesung fuer automatische Bewertung.
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if correction.status not in [CorrectionStatus.OCR_COMPLETE, CorrectionStatus.ANALYZED]:
raise HTTPException(
status_code=400,
detail=f"Korrektur im falschen Status: {correction.status}"
)
if not correction.extracted_text:
raise HTTPException(status_code=400, detail="Kein extrahierter Text vorhanden")
try:
correction.status = CorrectionStatus.ANALYZING
corrections_store[correction_id] = correction
# Einfache Analyse ohne LLM
# Teile Text in Abschnitte (simuliert Aufgabenerkennung)
text_parts = correction.extracted_text.split('\n\n')
evaluations = []
for i, part in enumerate(text_parts[:10], start=1): # Max 10 Aufgaben
if len(part.strip()) < 5:
continue
# Simulierte Bewertung
# In Produktion wuerde hier LLM-basierte Analyse stattfinden
expected = expected_answers.get(str(i), "") if expected_answers else ""
# Einfacher Textvergleich (in Produktion: semantischer Vergleich)
is_correct = bool(expected and expected.lower() in part.lower())
points = correction.max_points / len(text_parts) if text_parts else 0
evaluation = AnswerEvaluation(
question_number=i,
extracted_text=part[:200], # Kuerzen fuer Response
points_possible=points,
points_awarded=points if is_correct else points * 0.5, # Teilpunkte
feedback=f"Antwort zu Aufgabe {i}" + (" korrekt." if is_correct else " mit Verbesserungsbedarf."),
is_correct=is_correct,
confidence=0.7 # Simulierte Confidence
)
evaluations.append(evaluation)
# Berechne Gesamtergebnis
total_points = sum(e.points_awarded for e in evaluations)
percentage = (total_points / correction.max_points * 100) if correction.max_points > 0 else 0
suggested_grade = calculate_grade(percentage)
# Generiere Feedback
ai_feedback = generate_ai_feedback(
evaluations, total_points, correction.max_points, correction.subject
)
# Aktualisiere Korrektur
correction.evaluations = evaluations
correction.total_points = total_points
correction.percentage = percentage
correction.grade = suggested_grade
correction.ai_feedback = ai_feedback
correction.status = CorrectionStatus.ANALYZED
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
logger.info(f"Analysis complete for {correction_id}: {total_points}/{correction.max_points}")
return AnalysisResponse(
success=True,
evaluations=evaluations,
total_points=total_points,
percentage=percentage,
suggested_grade=suggested_grade,
ai_feedback=ai_feedback
)
except Exception as e:
logger.error(f"Analysis error: {e}")
correction.status = CorrectionStatus.ERROR
corrections_store[correction_id] = correction
return AnalysisResponse(success=False, error=str(e))
@router.put("/{correction_id}", response_model=CorrectionResponse)
async def update_correction(correction_id: str, data: CorrectionUpdate):
"""
Aktualisiert eine Korrektur.
Ermoeglicht manuelle Anpassungen durch die Lehrkraft.
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if data.evaluations is not None:
correction.evaluations = data.evaluations
correction.total_points = sum(e.points_awarded for e in data.evaluations)
correction.percentage = (
correction.total_points / correction.max_points * 100
) if correction.max_points > 0 else 0
if data.total_points is not None:
correction.total_points = data.total_points
correction.percentage = (
data.total_points / correction.max_points * 100
) if correction.max_points > 0 else 0
if data.grade is not None:
correction.grade = data.grade
if data.teacher_notes is not None:
correction.teacher_notes = data.teacher_notes
if data.status is not None:
correction.status = data.status
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
return CorrectionResponse(success=True, correction=correction)
@router.post("/{correction_id}/complete", response_model=CorrectionResponse)
async def complete_correction(correction_id: str):
"""Markiert Korrektur als abgeschlossen."""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
correction.status = CorrectionStatus.COMPLETED
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
logger.info(f"Correction {correction_id} completed: {correction.grade}")
return CorrectionResponse(success=True, correction=correction)
@router.get("/{correction_id}/export-pdf")
async def export_correction_pdf(correction_id: str):
"""
Exportiert korrigierte Arbeit als PDF.
Enthaelt:
- Originalscan
- Bewertungen
- Feedback
- Gesamtergebnis
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
try:
pdf_service = PDFService()
# Erstelle CorrectionData
correction_data = CorrectionData(
student=StudentInfo(
student_id=correction.student_id,
name=correction.student_name,
class_name=correction.class_name
),
exam_title=correction.exam_title,
subject=correction.subject,
date=correction.created_at.strftime("%d.%m.%Y"),
max_points=correction.max_points,
achieved_points=correction.total_points,
grade=correction.grade or "",
percentage=correction.percentage,
corrections=[
{
"question": f"Aufgabe {e.question_number}",
"answer": e.extracted_text,
"points": f"{e.points_awarded}/{e.points_possible}",
"feedback": e.feedback
}
for e in correction.evaluations
],
teacher_notes=correction.teacher_notes or "",
ai_feedback=correction.ai_feedback or ""
)
# Generiere PDF
pdf_bytes = pdf_service.generate_correction_pdf(correction_data)
from fastapi.responses import Response
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="korrektur_{correction.student_name}_{correction.exam_title}.pdf"'
}
)
except Exception as e:
logger.error(f"PDF export error: {e}")
raise HTTPException(status_code=500, detail=f"PDF-Export fehlgeschlagen: {str(e)}")
@router.delete("/{correction_id}")
async def delete_correction(correction_id: str):
"""Loescht eine Korrektur."""
if correction_id not in corrections_store:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
correction = corrections_store[correction_id]
# Loesche auch die hochgeladene Datei
if correction.file_path and os.path.exists(correction.file_path):
try:
os.remove(correction.file_path)
except Exception as e:
logger.warning(f"Could not delete file {correction.file_path}: {e}")
del corrections_store[correction_id]
logger.info(f"Deleted correction {correction_id}")
return {"status": "deleted", "id": correction_id}
@router.get("/class/{class_name}/summary")
async def get_class_summary(class_name: str):
"""
Gibt Zusammenfassung fuer eine Klasse zurueck.
Enthaelt Statistiken ueber alle Korrekturen der Klasse.
"""
class_corrections = [
c for c in corrections_store.values()
if c.class_name == class_name and c.status == CorrectionStatus.COMPLETED
]
if not class_corrections:
return {
"class_name": class_name,
"total_students": 0,
"average_percentage": 0,
"grade_distribution": {},
"corrections": []
}
# Berechne Statistiken
percentages = [c.percentage for c in class_corrections]
average_percentage = sum(percentages) / len(percentages) if percentages else 0
# Notenverteilung
grade_distribution = {}
for c in class_corrections:
grade = c.grade or "?"
grade_distribution[grade] = grade_distribution.get(grade, 0) + 1
return {
"class_name": class_name,
"total_students": len(class_corrections),
"average_percentage": round(average_percentage, 1),
"average_points": round(
sum(c.total_points for c in class_corrections) / len(class_corrections), 1
),
"grade_distribution": grade_distribution,
"corrections": [
{
"id": c.id,
"student_name": c.student_name,
"total_points": c.total_points,
"percentage": c.percentage,
"grade": c.grade
}
for c in sorted(class_corrections, key=lambda x: x.student_name)
]
}
@router.post("/{correction_id}/ocr/retry", response_model=CorrectionResponse)
async def retry_ocr(correction_id: str, background_tasks: BackgroundTasks):
"""
Wiederholt OCR-Verarbeitung.
Nuetzlich wenn erste Verarbeitung fehlgeschlagen ist.
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if not correction.file_path:
raise HTTPException(status_code=400, detail="Keine Datei vorhanden")
if not os.path.exists(correction.file_path):
raise HTTPException(status_code=400, detail="Datei nicht mehr vorhanden")
# Starte OCR erneut
correction.status = CorrectionStatus.UPLOADED
correction.extracted_text = None
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
background_tasks.add_task(process_ocr, correction_id, correction.file_path)
return CorrectionResponse(success=True, correction=correction)