Files
breakpilot-lehrer/klausur-service/backend/routes/fairness.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

249 lines
8.4 KiB
Python

"""
Klausur-Service Fairness Routes
Endpoints for fairness analysis, audit logs, and utility functions.
"""
from typing import Optional
import httpx
from fastapi import APIRouter, HTTPException, Request
from models.grading import GRADE_THRESHOLDS, GRADE_LABELS, DEFAULT_CRITERIA
from services.auth_service import get_current_user
from config import SCHOOL_SERVICE_URL
import storage
router = APIRouter()
@router.get("/api/v1/klausuren/{klausur_id}/fairness")
async def get_fairness_analysis(klausur_id: str, request: Request):
"""Analyze grading fairness across all students in a Klausur."""
user = get_current_user(request)
if klausur_id not in storage.klausuren_db:
raise HTTPException(status_code=404, detail="Klausur not found")
klausur = storage.klausuren_db[klausur_id]
students = klausur.students
if len(students) < 2:
return {
"klausur_id": klausur_id,
"analysis": "Zu wenige Arbeiten fuer Vergleich (mindestens 2 benoetigt)",
"students_count": len(students)
}
# Calculate statistics
grades = [s.grade_points for s in students if s.grade_points > 0]
raw_points = [s.raw_points for s in students if s.raw_points > 0]
if not grades:
return {
"klausur_id": klausur_id,
"analysis": "Keine bewerteten Arbeiten vorhanden",
"students_count": len(students)
}
avg_grade = sum(grades) / len(grades)
avg_raw = sum(raw_points) / len(raw_points) if raw_points else 0
min_grade = min(grades)
max_grade = max(grades)
spread = max_grade - min_grade
# Calculate standard deviation
variance = sum((g - avg_grade) ** 2 for g in grades) / len(grades)
std_dev = variance ** 0.5
# Identify outliers (more than 2 std deviations from mean)
outliers = []
for s in students:
if s.grade_points > 0:
deviation = abs(s.grade_points - avg_grade)
if deviation > 2 * std_dev:
outliers.append({
"student_id": s.id,
"student_name": s.student_name,
"grade_points": s.grade_points,
"deviation": round(deviation, 2),
"direction": "above" if s.grade_points > avg_grade else "below"
})
# Criteria comparison
criteria_averages = {}
for criterion in DEFAULT_CRITERIA.keys():
scores = []
for s in students:
if criterion in s.criteria_scores:
scores.append(s.criteria_scores[criterion].get("score", 0))
if scores:
criteria_averages[criterion] = {
"average": round(sum(scores) / len(scores), 1),
"min": min(scores),
"max": max(scores),
"count": len(scores)
}
# Fairness score (0-100, higher is more consistent)
# Based on: low std deviation, no extreme outliers, reasonable spread
fairness_score = 100
if std_dev > 3:
fairness_score -= min(30, std_dev * 5)
if len(outliers) > 0:
fairness_score -= len(outliers) * 10
if spread > 10:
fairness_score -= min(20, spread)
fairness_score = max(0, fairness_score)
# Warnings
warnings = []
if spread > 12:
warnings.append("Sehr grosse Notenspreizung - bitte Extremwerte pruefen")
if std_dev > 4:
warnings.append("Hohe Streuung der Noten - moegliche Inkonsistenz")
if len(outliers) > 2:
warnings.append(f"{len(outliers)} Ausreisser erkannt - manuelle Pruefung empfohlen")
if avg_grade < 5:
warnings.append("Durchschnitt unter 5 Punkten - sind die Aufgaben angemessen?")
if avg_grade > 12:
warnings.append("Durchschnitt ueber 12 Punkten - Bewertungsmassstab pruefen")
return {
"klausur_id": klausur_id,
"students_count": len(students),
"graded_count": len(grades),
"statistics": {
"average_grade": round(avg_grade, 2),
"average_raw_points": round(avg_raw, 2),
"min_grade": min_grade,
"max_grade": max_grade,
"spread": spread,
"standard_deviation": round(std_dev, 2)
},
"criteria_breakdown": criteria_averages,
"outliers": outliers,
"fairness_score": round(fairness_score),
"warnings": warnings,
"recommendation": "Bewertung konsistent" if fairness_score >= 70 else "Pruefung empfohlen"
}
# =============================================
# AUDIT LOG ENDPOINTS
# =============================================
@router.get("/api/v1/audit-log")
async def get_audit_log(
request: Request,
entity_type: Optional[str] = None,
entity_id: Optional[str] = None,
limit: int = 100
):
"""Get audit log entries."""
user = get_current_user(request)
# Only admins can see full audit log
if user.get("role") != "admin":
# Regular users only see their own actions
entries = [e for e in storage.audit_log_db if e.user_id == user["user_id"]]
else:
entries = storage.audit_log_db
# Filter by entity
if entity_type:
entries = [e for e in entries if e.entity_type == entity_type]
if entity_id:
entries = [e for e in entries if e.entity_id == entity_id]
# Sort by timestamp descending and limit
entries = sorted(entries, key=lambda e: e.timestamp, reverse=True)[:limit]
return [e.to_dict() for e in entries]
@router.get("/api/v1/students/{student_id}/audit-log")
async def get_student_audit_log(student_id: str, request: Request):
"""Get audit log for a specific student."""
user = get_current_user(request)
if student_id not in storage.students_db:
raise HTTPException(status_code=404, detail="Student work not found")
entries = [e for e in storage.audit_log_db if e.entity_id == student_id]
entries = sorted(entries, key=lambda e: e.timestamp, reverse=True)
return [e.to_dict() for e in entries]
# =============================================
# UTILITY ENDPOINTS
# =============================================
@router.get("/api/v1/grade-info")
async def get_grade_info():
"""Get grade thresholds and labels."""
return {
"thresholds": GRADE_THRESHOLDS,
"labels": GRADE_LABELS,
"criteria": DEFAULT_CRITERIA
}
# =============================================
# SCHOOL CLASSES PROXY
# =============================================
@router.get("/api/school/classes")
async def get_school_classes(request: Request):
"""Proxy to school-service or return demo data."""
try:
auth_header = request.headers.get("Authorization", "")
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{SCHOOL_SERVICE_URL}/api/v1/school/classes",
headers={"Authorization": auth_header},
timeout=5.0
)
if resp.status_code == 200:
return resp.json()
except Exception as e:
print(f"School service not available: {e}")
# Return demo classes if school service unavailable
return [
{"id": "demo-q1", "name": "Q1 Deutsch GK", "student_count": 24},
{"id": "demo-q2", "name": "Q2 Deutsch LK", "student_count": 18},
{"id": "demo-q3", "name": "Q3 Deutsch GK", "student_count": 22},
{"id": "demo-q4", "name": "Q4 Deutsch LK", "student_count": 15}
]
@router.get("/api/school/classes/{class_id}/students")
async def get_class_students(class_id: str, request: Request):
"""Proxy to school-service or return demo data."""
try:
auth_header = request.headers.get("Authorization", "")
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{SCHOOL_SERVICE_URL}/api/v1/school/classes/{class_id}/students",
headers={"Authorization": auth_header},
timeout=5.0
)
if resp.status_code == 200:
return resp.json()
except Exception as e:
print(f"School service not available: {e}")
# Return demo students
demo_names = [
"Anna Mueller", "Ben Schmidt", "Clara Weber", "David Fischer",
"Emma Schneider", "Felix Wagner", "Greta Becker", "Hans Hoffmann",
"Ida Schulz", "Jan Koch", "Katharina Bauer", "Leon Richter",
"Maria Braun", "Niklas Lange", "Olivia Wolf", "Paul Krause"
]
return [
{"id": f"student-{i}", "name": name, "class_id": class_id}
for i, name in enumerate(demo_names)
]