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