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:
35
klausur-service/backend/routes/__init__.py
Normal file
35
klausur-service/backend/routes/__init__.py
Normal 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",
|
||||
]
|
||||
490
klausur-service/backend/routes/archiv.py
Normal file
490
klausur-service/backend/routes/archiv.py
Normal 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)}")
|
||||
1111
klausur-service/backend/routes/eh.py
Normal file
1111
klausur-service/backend/routes/eh.py
Normal file
File diff suppressed because it is too large
Load Diff
109
klausur-service/backend/routes/exams.py
Normal file
109
klausur-service/backend/routes/exams.py
Normal 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}
|
||||
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)
|
||||
]
|
||||
439
klausur-service/backend/routes/grading.py
Normal file
439
klausur-service/backend/routes/grading.py
Normal 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
|
||||
})
|
||||
131
klausur-service/backend/routes/students.py
Normal file
131
klausur-service/backend/routes/students.py
Normal 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}
|
||||
Reference in New Issue
Block a user