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>
475 lines
16 KiB
Python
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)
|