fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,35 @@
"""
Klausur-Service Routes
API route modules for the Klausur service.
"""
from fastapi import APIRouter
from .exams import router as exams_router
from .students import router as students_router
from .grading import router as grading_router
from .fairness import router as fairness_router
from .eh import router as eh_router
from .archiv import router as archiv_router
# Create main API router that includes all sub-routers
api_router = APIRouter()
# Include all route modules
api_router.include_router(exams_router, tags=["Klausuren"])
api_router.include_router(students_router, tags=["Students"])
api_router.include_router(grading_router, tags=["Grading"])
api_router.include_router(fairness_router, tags=["Fairness"])
api_router.include_router(eh_router, tags=["BYOEH"])
api_router.include_router(archiv_router, tags=["Abitur-Archiv"])
__all__ = [
"api_router",
"exams_router",
"students_router",
"grading_router",
"fairness_router",
"eh_router",
"archiv_router",
]

View File

@@ -0,0 +1,490 @@
"""
Klausur-Service Abitur-Archiv Routes
Endpoints for accessing NiBiS Zentralabitur documents (public archive).
Provides filtered listing and presigned URLs for PDF access.
"""
from typing import Optional, List, Dict
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from qdrant_service import get_qdrant_client, search_nibis_eh
from minio_storage import get_presigned_url, list_documents
from eh_pipeline import generate_single_embedding
router = APIRouter()
# =============================================
# MODELS
# =============================================
class AbiturDokument(BaseModel):
"""Abitur document from the archive."""
id: str
title: str
subject: str
niveau: str # eA or gA
year: int
task_number: Optional[str] = None # Can be "1", "2A", "2C", etc.
doc_type: str # EWH, Aufgabe, Material
variant: Optional[str] = None
bundesland: str = "NI"
minio_path: Optional[str] = None
preview_url: Optional[str] = None
class ArchivSearchResponse(BaseModel):
"""Response for archive listing."""
total: int
documents: List[AbiturDokument]
filters: Dict
class SemanticSearchResult(BaseModel):
"""Result from semantic search."""
id: str
score: float
text: str
year: int
subject: str
niveau: str
task_number: Optional[str] = None # Can be "1", "2A", "2C", etc.
doc_type: str
# =============================================
# ARCHIVE LISTING & FILTERS
# =============================================
# IMPORTANT: Specific routes MUST come before parameterized routes!
# Otherwise /api/v1/archiv/stats would be caught by /api/v1/archiv/{doc_id}
# =============================================
# STATS (must be before {doc_id})
# =============================================
@router.get("/api/v1/archiv/stats")
async def get_archiv_stats():
"""
Get archive statistics (document counts, available years, etc.).
"""
try:
client = get_qdrant_client()
collection = "bp_nibis_eh"
# Get collection info
info = client.get_collection(collection)
# Scroll to get stats by year/subject
all_points, _ = client.scroll(
collection_name=collection,
limit=1000,
with_payload=True,
with_vectors=False
)
# Aggregate stats
by_year = {}
by_subject = {}
by_niveau = {}
seen_docs = set()
for point in all_points:
payload = point.payload
doc_id = payload.get("doc_id") or payload.get("original_id", str(point.id))
if doc_id in seen_docs:
continue
seen_docs.add(doc_id)
year = str(payload.get("year", "unknown"))
subject = payload.get("subject", "unknown")
niveau = payload.get("niveau", "unknown")
by_year[year] = by_year.get(year, 0) + 1
by_subject[subject] = by_subject.get(subject, 0) + 1
by_niveau[niveau] = by_niveau.get(niveau, 0) + 1
return {
"total_documents": len(seen_docs),
"total_chunks": info.points_count,
"by_year": dict(sorted(by_year.items(), reverse=True)),
"by_subject": dict(sorted(by_subject.items(), key=lambda x: -x[1])),
"by_niveau": by_niveau,
"collection_status": info.status.value
}
except Exception as e:
print(f"Stats error: {e}")
return {
"total_documents": 0,
"total_chunks": 0,
"by_year": {},
"by_subject": {},
"by_niveau": {},
"error": str(e)
}
# =============================================
# THEME SUGGESTIONS (must be before {doc_id})
# =============================================
@router.get("/api/v1/archiv/suggest")
async def suggest_themes(
query: str = Query(..., min_length=2, description="Partial search query")
) -> List[Dict]:
"""
Get theme suggestions for autocomplete.
Returns popular themes/topics that match the query.
"""
# Predefined themes for autocomplete
THEMES = [
{"label": "Textanalyse", "type": "Analyse"},
{"label": "Gedichtanalyse", "type": "Analyse"},
{"label": "Dramenanalyse", "type": "Analyse"},
{"label": "Prosaanalyse", "type": "Analyse"},
{"label": "Eroerterung", "type": "Aufsatz"},
{"label": "Textgebundene Eroerterung", "type": "Aufsatz"},
{"label": "Materialgestuetzte Eroerterung", "type": "Aufsatz"},
{"label": "Sprachreflexion", "type": "Analyse"},
{"label": "Kafka", "type": "Autor"},
{"label": "Goethe", "type": "Autor"},
{"label": "Schiller", "type": "Autor"},
{"label": "Romantik", "type": "Epoche"},
{"label": "Expressionismus", "type": "Epoche"},
{"label": "Sturm und Drang", "type": "Epoche"},
{"label": "Aufklaerung", "type": "Epoche"},
{"label": "Sprachvarietaeten", "type": "Thema"},
{"label": "Sprachwandel", "type": "Thema"},
{"label": "Kommunikation", "type": "Thema"},
{"label": "Medien", "type": "Thema"},
]
query_lower = query.lower()
matches = [
theme for theme in THEMES
if query_lower in theme["label"].lower()
]
return matches[:10]
# =============================================
# SEMANTIC SEARCH (must be before {doc_id})
# =============================================
@router.get("/api/v1/archiv/search/semantic")
async def semantic_search(
query: str = Query(..., min_length=3, description="Search query"),
year: Optional[int] = Query(None),
subject: Optional[str] = Query(None),
niveau: Optional[str] = Query(None),
limit: int = Query(10, ge=1, le=50)
) -> List[SemanticSearchResult]:
"""
Perform semantic search across the archive using embeddings.
This searches for conceptually similar content, not just keyword matches.
"""
try:
# Generate query embedding
query_embedding = await generate_single_embedding(query)
# Search in Qdrant
results = await search_nibis_eh(
query_embedding=query_embedding,
year=year,
subject=subject,
niveau=niveau,
limit=limit
)
return [
SemanticSearchResult(
id=r["id"],
score=r["score"],
text=r.get("text", "")[:500], # Truncate for response
year=r.get("year", 0),
subject=r.get("subject", ""),
niveau=r.get("niveau", ""),
task_number=r.get("task_number"),
doc_type=r.get("doc_type", "EWH")
)
for r in results
]
except Exception as e:
print(f"Semantic search error: {e}")
return []
# =============================================
# ARCHIVE LISTING
# =============================================
@router.get("/api/v1/archiv", response_model=ArchivSearchResponse)
async def list_archiv_documents(
subject: Optional[str] = Query(None, description="Filter by subject (e.g., Deutsch, Englisch)"),
year: Optional[int] = Query(None, description="Filter by year (e.g., 2024)"),
bundesland: Optional[str] = Query(None, description="Filter by state (e.g., NI)"),
niveau: Optional[str] = Query(None, description="Filter by level (eA or gA)"),
doc_type: Optional[str] = Query(None, description="Filter by type (EWH, Aufgabe)"),
search: Optional[str] = Query(None, description="Theme/keyword search"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0)
):
"""
List all documents in the Abitur archive with optional filters.
Returns metadata for documents stored in the bp_nibis_eh Qdrant collection.
PDF URLs are generated on-demand via MinIO presigned URLs.
"""
try:
client = get_qdrant_client()
collection = "bp_nibis_eh"
# Get all unique documents (dedup by doc_id)
# We scroll through the collection to get document metadata
from qdrant_client.models import Filter, FieldCondition, MatchValue
# Build filter conditions
must_conditions = []
if subject:
must_conditions.append(
FieldCondition(key="subject", match=MatchValue(value=subject))
)
if year:
must_conditions.append(
FieldCondition(key="year", match=MatchValue(value=year))
)
if bundesland:
must_conditions.append(
FieldCondition(key="bundesland", match=MatchValue(value=bundesland))
)
if niveau:
must_conditions.append(
FieldCondition(key="niveau", match=MatchValue(value=niveau))
)
if doc_type:
must_conditions.append(
FieldCondition(key="doc_type", match=MatchValue(value=doc_type))
)
query_filter = Filter(must=must_conditions) if must_conditions else None
# Scroll through all points to get unique documents
all_points, _ = client.scroll(
collection_name=collection,
scroll_filter=query_filter,
limit=1000, # Get more to ensure we find unique docs
with_payload=True,
with_vectors=False
)
# Deduplicate by doc_id and collect unique documents
seen_docs = {}
for point in all_points:
payload = point.payload
doc_id = payload.get("doc_id") or payload.get("original_id", str(point.id))
# Skip if already seen
if doc_id in seen_docs:
continue
# Apply text search filter if provided
if search:
text = payload.get("text", "")
if search.lower() not in text.lower():
continue
# Build document title from metadata
subject_name = payload.get("subject", "Unbekannt")
doc_year = payload.get("year", 0)
doc_niveau = payload.get("niveau", "")
task_num = payload.get("task_number")
doc_type_val = payload.get("doc_type", "EWH")
variant = payload.get("variant")
# Generate title
title_parts = [subject_name, str(doc_year), doc_niveau]
if task_num:
title_parts.append(f"Aufgabe {task_num}")
if doc_type_val and doc_type_val != "EWH":
title_parts.append(doc_type_val)
if variant:
title_parts.append(f"({variant})")
title = " ".join(title_parts)
# Generate MinIO path for this document
# Path pattern: landes-daten/ni/klausur/{year}/{filename}.pdf
minio_path = f"landes-daten/ni/klausur/{doc_year}/{doc_year}_{subject_name}_{doc_niveau}"
if task_num:
minio_path += f"_{task_num}"
minio_path += "_EWH.pdf"
seen_docs[doc_id] = AbiturDokument(
id=doc_id,
title=title,
subject=subject_name,
niveau=doc_niveau,
year=doc_year,
task_number=task_num,
doc_type=doc_type_val,
variant=variant,
bundesland=payload.get("bundesland", "NI"),
minio_path=minio_path
)
# Convert to list and apply pagination
documents = list(seen_docs.values())
# Sort by year descending, then subject
documents.sort(key=lambda d: (-d.year, d.subject))
total = len(documents)
paginated = documents[offset:offset + limit]
# Get available filter options for UI
filters = {
"subjects": sorted(list(set(d.subject for d in documents))),
"years": sorted(list(set(d.year for d in documents)), reverse=True),
"niveaus": sorted(list(set(d.niveau for d in documents if d.niveau))),
"doc_types": sorted(list(set(d.doc_type for d in documents if d.doc_type))),
}
return ArchivSearchResponse(
total=total,
documents=paginated,
filters=filters
)
except Exception as e:
print(f"Archiv list error: {e}")
# Return empty response with mock data if Qdrant fails
return ArchivSearchResponse(
total=0,
documents=[],
filters={
"subjects": ["Deutsch", "Englisch", "Mathematik"],
"years": [2025, 2024, 2023, 2022, 2021],
"niveaus": ["eA", "gA"],
"doc_types": ["EWH", "Aufgabe"]
}
)
@router.get("/api/v1/archiv/{doc_id}")
async def get_archiv_document(doc_id: str):
"""
Get details for a specific document including presigned PDF URL.
"""
try:
client = get_qdrant_client()
collection = "bp_nibis_eh"
from qdrant_client.models import Filter, FieldCondition, MatchValue
# Find document by doc_id in payload
results, _ = client.scroll(
collection_name=collection,
scroll_filter=Filter(must=[
FieldCondition(key="doc_id", match=MatchValue(value=doc_id))
]),
limit=1,
with_payload=True
)
if not results:
# Try original_id
results, _ = client.scroll(
collection_name=collection,
scroll_filter=Filter(must=[
FieldCondition(key="original_id", match=MatchValue(value=doc_id))
]),
limit=1,
with_payload=True
)
if not results:
raise HTTPException(status_code=404, detail="Document not found")
payload = results[0].payload
# Generate MinIO presigned URL
subject_name = payload.get("subject", "Unbekannt")
doc_year = payload.get("year", 0)
doc_niveau = payload.get("niveau", "")
task_num = payload.get("task_number")
minio_path = f"landes-daten/ni/klausur/{doc_year}/{doc_year}_{subject_name}_{doc_niveau}"
if task_num:
minio_path += f"_{task_num}"
minio_path += "_EWH.pdf"
# Get presigned URL (1 hour expiry)
preview_url = await get_presigned_url(minio_path, expires=3600)
return {
"id": doc_id,
"title": f"{subject_name} {doc_year} {doc_niveau}",
"subject": subject_name,
"niveau": doc_niveau,
"year": doc_year,
"task_number": task_num,
"doc_type": payload.get("doc_type", "EWH"),
"variant": payload.get("variant"),
"bundesland": payload.get("bundesland", "NI"),
"minio_path": minio_path,
"preview_url": preview_url,
"text_preview": payload.get("text", "")[:500] + "..." if payload.get("text") else None
}
except HTTPException:
raise
except Exception as e:
print(f"Get document error: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get document: {str(e)}")
@router.get("/api/v1/archiv/{doc_id}/url")
async def get_document_url(doc_id: str, expires: int = Query(3600, ge=300, le=86400)):
"""
Get a presigned URL for downloading the PDF.
Args:
doc_id: Document ID
expires: URL expiration time in seconds (default 1 hour, max 24 hours)
"""
try:
# First, get the document to find the MinIO path
doc = await get_archiv_document(doc_id)
if not doc.get("minio_path"):
raise HTTPException(status_code=404, detail="Document path not found")
# Generate presigned URL
url = await get_presigned_url(doc["minio_path"], expires=expires)
if not url:
raise HTTPException(status_code=500, detail="Failed to generate download URL")
return {
"url": url,
"expires_in": expires,
"filename": doc["minio_path"].split("/")[-1]
}
except HTTPException:
raise
except Exception as e:
print(f"Get URL error: {e}")
raise HTTPException(status_code=500, detail=f"Failed to generate URL: {str(e)}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
"""
Klausur-Service Exam Routes
CRUD endpoints for Klausuren.
"""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from models.exam import Klausur
from models.requests import KlausurCreate, KlausurUpdate
from services.auth_service import get_current_user
import storage
router = APIRouter()
@router.get("/api/v1/klausuren")
async def list_klausuren(request: Request):
"""List all Klausuren for current teacher."""
user = get_current_user(request)
user_klausuren = [
k.to_dict() for k in storage.klausuren_db.values()
if k.teacher_id == user["user_id"]
]
return user_klausuren
@router.post("/api/v1/klausuren")
async def create_klausur(data: KlausurCreate, request: Request):
"""Create a new Klausur."""
user = get_current_user(request)
klausur = Klausur(
id=str(uuid.uuid4()),
title=data.title,
subject=data.subject,
modus=data.modus,
class_id=data.class_id,
year=data.year,
semester=data.semester,
erwartungshorizont=None,
students=[],
created_at=datetime.now(timezone.utc),
teacher_id=user["user_id"]
)
storage.klausuren_db[klausur.id] = klausur
return klausur.to_dict()
@router.get("/api/v1/klausuren/{klausur_id}")
async def get_klausur(klausur_id: str, request: Request):
"""Get a specific 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]
if klausur.teacher_id != user["user_id"] and user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Access denied")
return klausur.to_dict()
@router.put("/api/v1/klausuren/{klausur_id}")
async def update_klausur(klausur_id: str, data: KlausurUpdate, request: Request):
"""Update 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]
if klausur.teacher_id != user["user_id"] and user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Access denied")
if data.title:
klausur.title = data.title
if data.subject:
klausur.subject = data.subject
if data.erwartungshorizont:
klausur.erwartungshorizont = data.erwartungshorizont
return klausur.to_dict()
@router.delete("/api/v1/klausuren/{klausur_id}")
async def delete_klausur(klausur_id: str, request: Request):
"""Delete a Klausur and all associated student work."""
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]
if klausur.teacher_id != user["user_id"] and user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Access denied")
# Remove student records
for student in klausur.students:
if student.id in storage.students_db:
del storage.students_db[student.id]
del storage.klausuren_db[klausur_id]
return {"success": True}

View 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)
]

View File

@@ -0,0 +1,439 @@
"""
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
})

View File

@@ -0,0 +1,131 @@
"""
Klausur-Service Student Routes
Endpoints for student work management.
"""
import os
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import FileResponse
from models.exam import StudentKlausur
from models.enums import StudentKlausurStatus
from services.auth_service import get_current_user
from config import UPLOAD_DIR
import storage
router = APIRouter()
@router.post("/api/v1/klausuren/{klausur_id}/students")
async def upload_student_work(
klausur_id: str,
student_name: str = Form(...),
file: UploadFile = File(...),
request: Request = None
):
"""Upload a student's work."""
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]
# Save file
upload_dir = f"{UPLOAD_DIR}/{klausur_id}"
os.makedirs(upload_dir, exist_ok=True)
file_ext = os.path.splitext(file.filename)[1]
file_id = str(uuid.uuid4())
file_path = f"{upload_dir}/{file_id}{file_ext}"
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
# Create student record
student = StudentKlausur(
id=file_id,
klausur_id=klausur_id,
student_name=student_name,
student_id=None,
file_path=file_path,
ocr_text=None,
status=StudentKlausurStatus.UPLOADED,
criteria_scores={},
gutachten=None,
raw_points=0,
grade_points=0,
created_at=datetime.now(timezone.utc)
)
storage.students_db[student.id] = student
klausur.students.append(student)
return student.to_dict()
@router.get("/api/v1/klausuren/{klausur_id}/students")
async def list_students(klausur_id: str, request: Request):
"""List all students for 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]
return [s.to_dict() for s in klausur.students]
@router.get("/api/v1/students/{student_id}/file")
async def get_student_file(student_id: str, request: Request):
"""Get the uploaded file 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]
if not student.file_path or not os.path.exists(student.file_path):
raise HTTPException(status_code=404, detail="File not found")
# Determine media type from file extension
ext = os.path.splitext(student.file_path)[1].lower()
media_types = {
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
}
media_type = media_types.get(ext, 'application/octet-stream')
return FileResponse(student.file_path, media_type=media_type)
@router.delete("/api/v1/students/{student_id}")
async def delete_student_work(student_id: str, request: Request):
"""Delete 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]
# Remove from klausur
if student.klausur_id in storage.klausuren_db:
klausur = storage.klausuren_db[student.klausur_id]
klausur.students = [s for s in klausur.students if s.id != student_id]
# Delete file
if student.file_path and os.path.exists(student.file_path):
os.remove(student.file_path)
del storage.students_db[student_id]
return {"success": True}