[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>
This commit is contained in:
474
backend-lehrer/correction_endpoints.py
Normal file
474
backend-lehrer/correction_endpoints.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user