""" 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 })