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>
249 lines
8.4 KiB
Python
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)
|
|
]
|