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>
This commit is contained in:
248
klausur-service/backend/routes/fairness.py
Normal file
248
klausur-service/backend/routes/fairness.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
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)
|
||||
]
|
||||
Reference in New Issue
Block a user