Files
breakpilot-lehrer/klausur-service/backend/routes/grading.py
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

440 lines
16 KiB
Python

"""
Klausur-Service Grading Routes
Endpoints for grading, Gutachten, and examiner workflow.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from models.enums import StudentKlausurStatus
from models.grading import GRADE_LABELS, DEFAULT_CRITERIA
from models.requests import (
CriterionScoreUpdate,
GutachtenUpdate,
GutachtenGenerateRequest,
ExaminerAssignment,
ExaminerResult,
)
from services.auth_service import get_current_user
from services.grading_service import calculate_grade_points, calculate_raw_points
from services.eh_service import log_audit, log_eh_audit
from config import OPENAI_API_KEY
import storage
# BYOEH imports (conditional)
try:
from eh_pipeline import decrypt_text, EncryptionError
from qdrant_service import search_eh
from eh_pipeline import generate_single_embedding
BYOEH_AVAILABLE = True
except ImportError:
BYOEH_AVAILABLE = False
router = APIRouter()
@router.put("/api/v1/students/{student_id}/criteria")
async def update_criteria(student_id: str, data: CriterionScoreUpdate, request: Request):
"""Update a criterion score for a student."""
user = get_current_user(request)
if student_id not in storage.students_db:
raise HTTPException(status_code=404, detail="Student work not found")
student = storage.students_db[student_id]
# Get old value for audit
old_score = student.criteria_scores.get(data.criterion, {}).get("score", 0)
student.criteria_scores[data.criterion] = {
"score": data.score,
"annotations": data.annotations or []
}
# Recalculate points
student.raw_points = calculate_raw_points(student.criteria_scores)
student.grade_points = calculate_grade_points(student.raw_points)
# Audit log
log_audit(
user_id=user["user_id"],
action="score_update",
entity_type="student",
entity_id=student_id,
field=data.criterion,
old_value=str(old_score),
new_value=str(data.score)
)
return student.to_dict()
@router.put("/api/v1/students/{student_id}/gutachten")
async def update_gutachten(student_id: str, data: GutachtenUpdate, request: Request):
"""Update the Gutachten for a student."""
user = get_current_user(request)
if student_id not in storage.students_db:
raise HTTPException(status_code=404, detail="Student work not found")
student = storage.students_db[student_id]
had_gutachten = student.gutachten is not None
student.gutachten = {
"einleitung": data.einleitung,
"hauptteil": data.hauptteil,
"fazit": data.fazit,
"staerken": data.staerken or [],
"schwaechen": data.schwaechen or [],
"updated_at": datetime.now(timezone.utc).isoformat()
}
# Audit log
log_audit(
user_id=user["user_id"],
action="gutachten_update",
entity_type="student",
entity_id=student_id,
field="gutachten",
old_value="existing" if had_gutachten else "none",
new_value="updated",
details={"sections": ["einleitung", "hauptteil", "fazit"]}
)
return student.to_dict()
@router.post("/api/v1/students/{student_id}/finalize")
async def finalize_student(student_id: str, request: Request):
"""Finalize a student's grade."""
user = get_current_user(request)
if student_id not in storage.students_db:
raise HTTPException(status_code=404, detail="Student work not found")
student = storage.students_db[student_id]
old_status = student.status.value if hasattr(student.status, 'value') else student.status
student.status = StudentKlausurStatus.COMPLETED
# Audit log
log_audit(
user_id=user["user_id"],
action="status_change",
entity_type="student",
entity_id=student_id,
field="status",
old_value=old_status,
new_value="completed"
)
return student.to_dict()
@router.post("/api/v1/students/{student_id}/gutachten/generate")
async def generate_gutachten(student_id: str, data: GutachtenGenerateRequest, request: Request):
"""
Generate a KI-based Gutachten for a student's work.
Optionally uses RAG context from the teacher's Erwartungshorizonte
if use_eh=True and eh_passphrase is provided.
"""
user = get_current_user(request)
if student_id not in storage.students_db:
raise HTTPException(status_code=404, detail="Student work not found")
student = storage.students_db[student_id]
klausur = storage.klausuren_db.get(student.klausur_id)
if not klausur:
raise HTTPException(status_code=404, detail="Klausur not found")
# BYOEH RAG Context (optional)
eh_context = ""
eh_sources = []
if BYOEH_AVAILABLE and data.use_eh and data.eh_passphrase and OPENAI_API_KEY:
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
try:
# Use first part of student's OCR text as query
query_text = ""
if student.ocr_text:
query_text = student.ocr_text[:1000]
else:
query_text = f"Klausur {klausur.subject} {klausur.title}"
# Generate query embedding
query_embedding = await generate_single_embedding(query_text)
# Search in Qdrant (tenant-isolated)
results = await search_eh(
query_embedding=query_embedding,
tenant_id=tenant_id,
subject=klausur.subject,
limit=3
)
# Decrypt matching chunks
for r in results:
eh = storage.eh_db.get(r["eh_id"])
if eh and r.get("encrypted_content"):
try:
decrypted = decrypt_text(
r["encrypted_content"],
data.eh_passphrase,
eh.salt
)
eh_sources.append({
"text": decrypted,
"eh_title": eh.title,
"score": r["score"]
})
except EncryptionError:
pass # Skip chunks that can't be decrypted
if eh_sources:
eh_context = "\n\n--- Erwartungshorizont-Kontext ---\n"
eh_context += "\n\n".join([s["text"] for s in eh_sources])
# Audit log for RAG usage
log_eh_audit(
tenant_id=tenant_id,
user_id=user["user_id"],
action="rag_query_gutachten",
details={
"student_id": student_id,
"sources_count": len(eh_sources)
}
)
except Exception as e:
print(f"BYOEH RAG failed: {e}")
# Continue without RAG context
# Calculate overall percentage
total_percentage = calculate_raw_points(student.criteria_scores)
grade = calculate_grade_points(total_percentage)
grade_label = GRADE_LABELS.get(grade, "")
# Analyze criteria for strengths/weaknesses
staerken = []
schwaechen = []
for criterion, score_data in student.criteria_scores.items():
score = score_data.get("score", 0)
label = DEFAULT_CRITERIA.get(criterion, {}).get("label", criterion)
if score >= 80:
staerken.append(f"Sehr gute Leistung im Bereich {label} ({score}%)")
elif score >= 60:
staerken.append(f"Solide Leistung im Bereich {label} ({score}%)")
elif score < 40:
schwaechen.append(f"Verbesserungsbedarf im Bereich {label} ({score}%)")
elif score < 60:
schwaechen.append(f"Ausbaufaehiger Bereich: {label} ({score}%)")
# Generate Gutachten text based on scores
if total_percentage >= 85:
einleitung = f"Die vorliegende Arbeit von {student.student_name} zeigt eine hervorragende Leistung."
hauptteil = f"""Die Arbeit ueberzeugt durch eine durchweg starke Bearbeitung der gestellten Aufgaben.
Die inhaltliche Auseinandersetzung mit dem Thema ist tiefgruendig und zeigt ein fundiertes Verstaendnis.
Die sprachliche Gestaltung ist praezise und dem Anlass angemessen.
Die Argumentation ist schluessig und wird durch treffende Beispiele gestuetzt."""
fazit = f"Insgesamt eine sehr gelungene Arbeit, die mit {grade} Punkten ({grade_label}) bewertet wird."
elif total_percentage >= 65:
einleitung = f"Die Arbeit von {student.student_name} zeigt eine insgesamt gute Leistung mit einzelnen Staerken."
hauptteil = f"""Die Bearbeitung der Aufgaben erfolgt weitgehend vollstaendig und korrekt.
Die inhaltliche Analyse zeigt ein solides Verstaendnis des Themas.
Die sprachliche Gestaltung ist ueberwiegend angemessen, mit kleineren Unsicherheiten.
Die Struktur der Arbeit ist nachvollziehbar."""
fazit = f"Die Arbeit wird mit {grade} Punkten ({grade_label}) bewertet. Es bestehen Moeglichkeiten zur weiteren Verbesserung."
elif total_percentage >= 45:
einleitung = f"Die Arbeit von {student.student_name} erfuellt die grundlegenden Anforderungen."
hauptteil = f"""Die Aufgabenstellung wird im Wesentlichen bearbeitet, jedoch bleiben einige Aspekte unterbeleuchtet.
Die inhaltliche Auseinandersetzung zeigt grundlegende Kenntnisse, bedarf aber weiterer Vertiefung.
Die sprachliche Gestaltung weist Unsicherheiten auf, die die Klarheit beeintraechtigen.
Die Struktur koennte stringenter sein."""
fazit = f"Die Arbeit wird mit {grade} Punkten ({grade_label}) bewertet. Empfohlen wird eine intensivere Auseinandersetzung mit der Methodik."
else:
einleitung = f"Die Arbeit von {student.student_name} weist erhebliche Defizite auf."
hauptteil = f"""Die Bearbeitung der Aufgaben bleibt unvollstaendig oder geht nicht ausreichend auf die Fragestellung ein.
Die inhaltliche Analyse zeigt Luecken im Verstaendnis des Themas.
Die sprachliche Gestaltung erschwert das Verstaendnis erheblich.
Die Arbeit laesst eine klare Struktur vermissen."""
fazit = f"Die Arbeit wird mit {grade} Punkten ({grade_label}) bewertet. Dringend empfohlen wird eine grundlegende Wiederholung der Thematik."
generated_gutachten = {
"einleitung": einleitung,
"hauptteil": hauptteil,
"fazit": fazit,
"staerken": staerken if data.include_strengths else [],
"schwaechen": schwaechen if data.include_weaknesses else [],
"generated_at": datetime.now(timezone.utc).isoformat(),
"is_ki_generated": True,
"tone": data.tone,
# BYOEH RAG Integration
"eh_context_used": len(eh_sources) > 0,
"eh_sources": [
{"title": s["eh_title"], "score": s["score"]}
for s in eh_sources
] if eh_sources else []
}
# Audit log
log_audit(
user_id=user["user_id"],
action="gutachten_generate",
entity_type="student",
entity_id=student_id,
details={
"tone": data.tone,
"grade": grade,
"eh_context_used": len(eh_sources) > 0,
"eh_sources_count": len(eh_sources)
}
)
return generated_gutachten
# =============================================
# EXAMINER WORKFLOW
# =============================================
@router.post("/api/v1/students/{student_id}/examiner")
async def assign_examiner(student_id: str, data: ExaminerAssignment, request: Request):
"""Assign an examiner (first or second) to a student's work."""
user = get_current_user(request)
if student_id not in storage.students_db:
raise HTTPException(status_code=404, detail="Student work not found")
student = storage.students_db[student_id]
# Initialize examiner record if not exists
if student_id not in storage.examiner_db:
storage.examiner_db[student_id] = {
"first_examiner": None,
"second_examiner": None,
"first_result": None,
"second_result": None,
"consensus_reached": False,
"final_grade": None
}
exam_record = storage.examiner_db[student_id]
if data.examiner_role == "first_examiner":
exam_record["first_examiner"] = {
"id": data.examiner_id,
"assigned_at": datetime.now(timezone.utc).isoformat(),
"notes": data.notes
}
student.status = StudentKlausurStatus.FIRST_EXAMINER
elif data.examiner_role == "second_examiner":
exam_record["second_examiner"] = {
"id": data.examiner_id,
"assigned_at": datetime.now(timezone.utc).isoformat(),
"notes": data.notes
}
student.status = StudentKlausurStatus.SECOND_EXAMINER
else:
raise HTTPException(status_code=400, detail="Invalid examiner role")
# Audit log
log_audit(
user_id=user["user_id"],
action="examiner_assign",
entity_type="student",
entity_id=student_id,
field=data.examiner_role,
new_value=data.examiner_id
)
return {
"success": True,
"student_id": student_id,
"examiner": exam_record
}
@router.post("/api/v1/students/{student_id}/examiner/result")
async def submit_examiner_result(student_id: str, data: ExaminerResult, request: Request):
"""Submit an examiner's grading result."""
user = get_current_user(request)
if student_id not in storage.students_db:
raise HTTPException(status_code=404, detail="Student work not found")
if student_id not in storage.examiner_db:
raise HTTPException(status_code=400, detail="No examiner assigned")
exam_record = storage.examiner_db[student_id]
user_id = user["user_id"]
# Determine which examiner is submitting
if exam_record.get("first_examiner", {}).get("id") == user_id:
exam_record["first_result"] = {
"grade_points": data.grade_points,
"notes": data.notes,
"submitted_at": datetime.now(timezone.utc).isoformat()
}
elif exam_record.get("second_examiner", {}).get("id") == user_id:
exam_record["second_result"] = {
"grade_points": data.grade_points,
"notes": data.notes,
"submitted_at": datetime.now(timezone.utc).isoformat()
}
else:
raise HTTPException(status_code=403, detail="You are not assigned as examiner")
# Check if both results are in and calculate consensus
if exam_record["first_result"] and exam_record["second_result"]:
first_grade = exam_record["first_result"]["grade_points"]
second_grade = exam_record["second_result"]["grade_points"]
diff = abs(first_grade - second_grade)
if diff <= 2:
# Automatic consensus: average
exam_record["final_grade"] = round((first_grade + second_grade) / 2)
exam_record["consensus_reached"] = True
storage.students_db[student_id].grade_points = exam_record["final_grade"]
storage.students_db[student_id].status = StudentKlausurStatus.COMPLETED
else:
# Needs discussion
exam_record["consensus_reached"] = False
exam_record["needs_discussion"] = True
# Audit log
log_audit(
user_id=user_id,
action="examiner_result",
entity_type="student",
entity_id=student_id,
new_value=str(data.grade_points)
)
return exam_record
@router.get("/api/v1/students/{student_id}/examiner")
async def get_examiner_status(student_id: str, request: Request):
"""Get the examiner assignment and results for a student."""
user = get_current_user(request)
if student_id not in storage.students_db:
raise HTTPException(status_code=404, detail="Student work not found")
return storage.examiner_db.get(student_id, {
"first_examiner": None,
"second_examiner": None,
"first_result": None,
"second_result": None,
"consensus_reached": False,
"final_grade": None
})