diff --git a/backend-lehrer/game_api.py b/backend-lehrer/game_api.py index cc5496d..ae71a5e 100644 --- a/backend-lehrer/game_api.py +++ b/backend-lehrer/game_api.py @@ -1,1129 +1,46 @@ # ============================================== -# Breakpilot Drive - Game API +# Breakpilot Drive - Game API (barrel re-export) # ============================================== -# API-Endpunkte fuer das Lernspiel: -# - Lernniveau aus Breakpilot abrufen -# - Quiz-Fragen bereitstellen -# - Spielsessions protokollieren -# - Offline-Sync unterstuetzen +# This module was split into: +# - game_models.py (Pydantic models, difficulty mapping, sample questions) +# - game_routes.py (Core game routes: level, quiz, session, leaderboard) +# - game_extended_routes.py (Phase 5: achievements, progress, parent, class) # -# Mit PostgreSQL-Integration fuer persistente Speicherung. -# Fallback auf In-Memory wenn DB nicht verfuegbar. -# -# Auth: Optional via GAME_REQUIRE_AUTH=true - -from fastapi import APIRouter, HTTPException, Query, Depends, Request -from pydantic import BaseModel -from typing import List, Optional, Literal, Dict, Any -from datetime import datetime -import random -import uuid -import os -import logging - -logger = logging.getLogger(__name__) - -# Feature flags -USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" -REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" - -router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) - - -# ============================================== -# Auth Dependency (Optional) -# ============================================== - -async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: - """ - Optional auth dependency for Game API. - - If GAME_REQUIRE_AUTH=true: Requires valid JWT token - If GAME_REQUIRE_AUTH=false: Returns None (anonymous access) - - In development mode without auth, returns demo user. - """ - if not REQUIRE_AUTH: - return None - - try: - from auth import get_current_user - return await get_current_user(request) - except ImportError: - logger.warning("Auth module not available") - return None - except HTTPException: - raise # Re-raise auth errors - except Exception as e: - logger.error(f"Auth error: {e}") - raise HTTPException(status_code=401, detail="Authentication failed") - - -def get_user_id_from_auth( - user: Optional[Dict[str, Any]], - requested_user_id: str -) -> str: - """ - Get the effective user ID, respecting auth when enabled. - - If auth is enabled and user is authenticated: - - Returns user's own ID if requested_user_id matches - - For parents: allows access to child IDs from token - - For teachers: allows access to student IDs (future) - - If auth is disabled: Returns requested_user_id as-is - """ - if not REQUIRE_AUTH or user is None: - return requested_user_id - - user_id = user.get("user_id", "") - - # Same user - always allowed - if requested_user_id == user_id: - return user_id - - # Check for parent accessing child data - children_ids = user.get("raw_claims", {}).get("children_ids", []) - if requested_user_id in children_ids: - return requested_user_id - - # Check for teacher accessing student data (future) - realm_roles = user.get("realm_roles", []) - if "lehrer" in realm_roles or "teacher" in realm_roles: - # Teachers can access any student in their class (implement class check later) - return requested_user_id - - # Admin bypass - if "admin" in realm_roles: - return requested_user_id - - # Not authorized - raise HTTPException( - status_code=403, - detail="Not authorized to access this user's data" - ) - - -# ============================================== -# Pydantic Models -# ============================================== - -class LearningLevel(BaseModel): - """Lernniveau eines Benutzers aus dem Breakpilot-System""" - user_id: str - overall_level: int # 1-5 (1=Anfaenger/Klasse 2, 5=Fortgeschritten/Klasse 6) - math_level: float - german_level: float - english_level: float - last_updated: datetime - - -class GameDifficulty(BaseModel): - """Spielschwierigkeit basierend auf Lernniveau""" - lane_speed: float # Geschwindigkeit in m/s - obstacle_frequency: float # Hindernisse pro Sekunde - power_up_chance: float # Wahrscheinlichkeit fuer Power-Ups (0-1) - question_complexity: int # 1-5 - answer_time: int # Sekunden zum Antworten - hints_enabled: bool - speech_speed: float # Sprechgeschwindigkeit fuer Audio-Version - - -class QuizQuestion(BaseModel): - """Quiz-Frage fuer das Spiel""" - id: str - question_text: str - audio_url: Optional[str] = None - options: List[str] # 2-4 Antwortmoeglichkeiten - correct_index: int # 0-3 - difficulty: int # 1-5 - subject: Literal["math", "german", "english", "general"] - grade_level: Optional[int] = None # 2-6 - # NEU: Quiz-Modus - quiz_mode: Literal["quick", "pause"] = "quick" # quick=waehrend Fahrt, pause=Spiel haelt an - visual_trigger: Optional[str] = None # z.B. "bridge", "house", "tree" - loest Frage aus - time_limit_seconds: Optional[float] = None # Zeit bis Antwort noetig (bei quick) - - -class QuizAnswer(BaseModel): - """Antwort auf eine Quiz-Frage""" - question_id: str - selected_index: int - answer_time_ms: int # Zeit bis zur Antwort in ms - was_correct: bool - - -class GameSession(BaseModel): - """Spielsession-Daten fuer Analytics""" - user_id: str - game_mode: Literal["video", "audio"] - duration_seconds: int - distance_traveled: float - score: int - questions_answered: int - questions_correct: int - difficulty_level: int - quiz_answers: Optional[List[QuizAnswer]] = None - - -class SessionResponse(BaseModel): - """Antwort nach Session-Speicherung""" - session_id: str - status: str - new_level: Optional[int] = None # Falls Lernniveau angepasst wurde - - -# ============================================== -# Schwierigkeits-Mapping -# ============================================== - -DIFFICULTY_MAPPING = { - 1: GameDifficulty( - lane_speed=3.0, - obstacle_frequency=0.3, - power_up_chance=0.4, - question_complexity=1, - answer_time=15, - hints_enabled=True, - speech_speed=0.8 - ), - 2: GameDifficulty( - lane_speed=4.0, - obstacle_frequency=0.4, - power_up_chance=0.35, - question_complexity=2, - answer_time=12, - hints_enabled=True, - speech_speed=0.9 - ), - 3: GameDifficulty( - lane_speed=5.0, - obstacle_frequency=0.5, - power_up_chance=0.3, - question_complexity=3, - answer_time=10, - hints_enabled=True, - speech_speed=1.0 - ), - 4: GameDifficulty( - lane_speed=6.0, - obstacle_frequency=0.6, - power_up_chance=0.25, - question_complexity=4, - answer_time=8, - hints_enabled=False, - speech_speed=1.1 - ), - 5: GameDifficulty( - lane_speed=7.0, - obstacle_frequency=0.7, - power_up_chance=0.2, - question_complexity=5, - answer_time=6, - hints_enabled=False, - speech_speed=1.2 - ), -} - -# ============================================== -# Beispiel Quiz-Fragen (spaeter aus DB laden) -# ============================================== - -SAMPLE_QUESTIONS = [ - # ============================================== - # QUICK QUESTIONS (waehrend der Fahrt, visuell getriggert) - # ============================================== - - # Englisch Vokabeln - Objekte im Spiel (QUICK MODE) - QuizQuestion( - id="vq-bridge", question_text="What is this?", - options=["Bridge", "House"], correct_index=0, - difficulty=1, subject="english", grade_level=3, - quiz_mode="quick", visual_trigger="bridge", time_limit_seconds=3.0 - ), - QuizQuestion( - id="vq-tree", question_text="What is this?", - options=["Tree", "Flower"], correct_index=0, - difficulty=1, subject="english", grade_level=3, - quiz_mode="quick", visual_trigger="tree", time_limit_seconds=3.0 - ), - QuizQuestion( - id="vq-house", question_text="What is this?", - options=["House", "Car"], correct_index=0, - difficulty=1, subject="english", grade_level=3, - quiz_mode="quick", visual_trigger="house", time_limit_seconds=3.0 - ), - QuizQuestion( - id="vq-car", question_text="What is this?", - options=["Car", "Bus"], correct_index=0, - difficulty=1, subject="english", grade_level=3, - quiz_mode="quick", visual_trigger="car", time_limit_seconds=2.5 - ), - QuizQuestion( - id="vq-mountain", question_text="What is this?", - options=["Hill", "Mountain", "Valley"], correct_index=1, - difficulty=2, subject="english", grade_level=4, - quiz_mode="quick", visual_trigger="mountain", time_limit_seconds=3.5 - ), - QuizQuestion( - id="vq-river", question_text="What is this?", - options=["Lake", "River", "Sea"], correct_index=1, - difficulty=2, subject="english", grade_level=4, - quiz_mode="quick", visual_trigger="river", time_limit_seconds=3.5 - ), - - # Schnelle Rechenaufgaben (QUICK MODE) - QuizQuestion( - id="mq-1", question_text="3 + 4 = ?", - options=["6", "7"], correct_index=1, - difficulty=1, subject="math", grade_level=2, - quiz_mode="quick", time_limit_seconds=4.0 - ), - QuizQuestion( - id="mq-2", question_text="5 x 2 = ?", - options=["10", "12"], correct_index=0, - difficulty=1, subject="math", grade_level=2, - quiz_mode="quick", time_limit_seconds=4.0 - ), - QuizQuestion( - id="mq-3", question_text="8 - 3 = ?", - options=["4", "5"], correct_index=1, - difficulty=1, subject="math", grade_level=2, - quiz_mode="quick", time_limit_seconds=3.5 - ), - QuizQuestion( - id="mq-4", question_text="6 x 7 = ?", - options=["42", "48"], correct_index=0, - difficulty=2, subject="math", grade_level=3, - quiz_mode="quick", time_limit_seconds=5.0 - ), - QuizQuestion( - id="mq-5", question_text="9 x 8 = ?", - options=["72", "64"], correct_index=0, - difficulty=3, subject="math", grade_level=4, - quiz_mode="quick", time_limit_seconds=5.0 - ), - - # ============================================== - # PAUSE QUESTIONS (Spiel haelt an, mehr Zeit) - # ============================================== - - # Mathe Level 1-2 (Klasse 2-3) - PAUSE MODE - QuizQuestion( - id="mp1-1", question_text="Anna hat 5 Aepfel. Sie bekommt 3 dazu. Wie viele hat sie jetzt?", - options=["6", "7", "8", "9"], correct_index=2, - difficulty=1, subject="math", grade_level=2, - quiz_mode="pause" - ), - QuizQuestion( - id="mp2-1", question_text="Ein Bus hat 24 Sitze. 18 sind besetzt. Wie viele sind frei?", - options=["4", "5", "6", "7"], correct_index=2, - difficulty=2, subject="math", grade_level=3, - quiz_mode="pause" - ), - QuizQuestion( - id="mp2-2", question_text="Was ist 45 + 27?", - options=["72", "62", "82", "70"], correct_index=0, - difficulty=2, subject="math", grade_level=3, - quiz_mode="pause" - ), - - # Mathe Level 3-4 (Klasse 4-5) - PAUSE MODE - QuizQuestion( - id="mp3-1", question_text="Was ist 7 x 8?", - options=["54", "56", "58", "48"], correct_index=1, - difficulty=3, subject="math", grade_level=4, - quiz_mode="pause" - ), - QuizQuestion( - id="mp3-2", question_text="Ein Rechteck ist 8m lang und 5m breit. Wie gross ist die Flaeche?", - options=["35 m2", "40 m2", "45 m2", "26 m2"], correct_index=1, - difficulty=3, subject="math", grade_level=4, - quiz_mode="pause" - ), - QuizQuestion( - id="mp4-1", question_text="Was ist 15% von 80?", - options=["10", "12", "8", "15"], correct_index=1, - difficulty=4, subject="math", grade_level=5, - quiz_mode="pause" - ), - QuizQuestion( - id="mp4-2", question_text="Was ist 3/4 + 1/2?", - options=["5/4", "4/6", "1", "5/6"], correct_index=0, - difficulty=4, subject="math", grade_level=5, - quiz_mode="pause" - ), - - # Mathe Level 5 (Klasse 6) - PAUSE MODE - QuizQuestion( - id="mp5-1", question_text="Was ist (-5) x (-3)?", - options=["-15", "15", "-8", "8"], correct_index=1, - difficulty=5, subject="math", grade_level=6, - quiz_mode="pause" - ), - QuizQuestion( - id="mp5-2", question_text="Loesung von 2x + 5 = 11?", - options=["2", "3", "4", "6"], correct_index=1, - difficulty=5, subject="math", grade_level=6, - quiz_mode="pause" - ), - - # Deutsch - PAUSE MODE (brauchen Lesezeit) - QuizQuestion( - id="dp1-1", question_text="Welches Wort ist ein Nomen?", - options=["laufen", "schnell", "Hund", "und"], correct_index=2, - difficulty=1, subject="german", grade_level=2, - quiz_mode="pause" - ), - QuizQuestion( - id="dp2-1", question_text="Was ist die Mehrzahl von 'Haus'?", - options=["Haeuse", "Haeuser", "Hausern", "Haus"], correct_index=1, - difficulty=2, subject="german", grade_level=3, - quiz_mode="pause" - ), - QuizQuestion( - id="dp3-1", question_text="Welches Verb steht im Praeteritum?", - options=["geht", "ging", "gegangen", "gehen"], correct_index=1, - difficulty=3, subject="german", grade_level=4, - quiz_mode="pause" - ), - QuizQuestion( - id="dp3-2", question_text="Finde den Rechtschreibfehler: 'Der Hund leuft schnell.'", - options=["Hund", "leuft", "schnell", "Der"], correct_index=1, - difficulty=3, subject="german", grade_level=4, - quiz_mode="pause" - ), - - # Englisch Saetze - PAUSE MODE - QuizQuestion( - id="ep3-1", question_text="How do you say 'Schmetterling'?", - options=["bird", "bee", "butterfly", "beetle"], correct_index=2, - difficulty=3, subject="english", grade_level=4, - quiz_mode="pause" - ), - QuizQuestion( - id="ep4-1", question_text="Choose the correct form: She ___ to school.", - options=["go", "goes", "going", "gone"], correct_index=1, - difficulty=4, subject="english", grade_level=5, - quiz_mode="pause" - ), - QuizQuestion( - id="ep4-2", question_text="What is the past tense of 'run'?", - options=["runned", "ran", "runed", "running"], correct_index=1, - difficulty=4, subject="english", grade_level=5, - quiz_mode="pause" - ), -] - -# In-Memory Session Storage (Fallback wenn DB nicht verfuegbar) -_sessions: dict[str, GameSession] = {} -_user_levels: dict[str, LearningLevel] = {} - -# Database integration -_game_db = None - -async def get_game_database(): - """Get game database instance with lazy initialization.""" - global _game_db - if not USE_DATABASE: - return None - if _game_db is None: - try: - from game.database import get_game_db - _game_db = await get_game_db() - logger.info("Game database initialized") - except Exception as e: - logger.warning(f"Game database not available, using in-memory: {e}") - return _game_db - - -# ============================================== -# API Endpunkte -# ============================================== - -@router.get("/learning-level/{user_id}", response_model=LearningLevel) -async def get_learning_level( - user_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> LearningLevel: - """ - Holt das aktuelle Lernniveau eines Benutzers aus Breakpilot. - - - Wird beim Spielstart aufgerufen um Schwierigkeit anzupassen - - Gibt Level 1-5 zurueck (1=Anfaenger, 5=Fortgeschritten) - - Cached Werte fuer schnellen Zugriff - - Speichert in PostgreSQL wenn verfuegbar - - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - # Try database first - db = await get_game_database() - if db: - state = await db.get_learning_state(user_id) - if state: - return LearningLevel( - user_id=user_id, - overall_level=state.overall_level, - math_level=state.math_level, - german_level=state.german_level, - english_level=state.english_level, - last_updated=state.updated_at or datetime.now() - ) - - # Create new state in database - new_state = await db.create_or_update_learning_state( - student_id=user_id, - overall_level=3, - math_level=3.0, - german_level=3.0, - english_level=3.0 - ) - if new_state: - return LearningLevel( - user_id=user_id, - overall_level=new_state.overall_level, - math_level=new_state.math_level, - german_level=new_state.german_level, - english_level=new_state.english_level, - last_updated=new_state.updated_at or datetime.now() - ) - - # Fallback to in-memory - if user_id in _user_levels: - return _user_levels[user_id] - - # Standard-Level fuer neue Benutzer - default_level = LearningLevel( - user_id=user_id, - overall_level=3, # Mittleres Level als Default - math_level=3.0, - german_level=3.0, - english_level=3.0, - last_updated=datetime.now() - ) - _user_levels[user_id] = default_level - return default_level - - -@router.get("/difficulty/{level}", response_model=GameDifficulty) -async def get_game_difficulty(level: int) -> GameDifficulty: - """ - Gibt Spielparameter basierend auf Lernniveau zurueck. - - Level 1-5 werden auf Spielgeschwindigkeit, Hindernisfrequenz, - Fragen-Schwierigkeit etc. gemappt. - """ - if level < 1 or level > 5: - raise HTTPException(status_code=400, detail="Level muss zwischen 1 und 5 sein") - - return DIFFICULTY_MAPPING[level] - - -@router.get("/quiz/questions", response_model=List[QuizQuestion]) -async def get_quiz_questions( - difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), - count: int = Query(10, ge=1, le=50, description="Anzahl der Fragen"), - subject: Optional[str] = Query(None, description="Fach: math, german, english, oder None fuer gemischt"), - mode: Optional[str] = Query(None, description="Quiz-Modus: quick (waehrend Fahrt), pause (Spiel pausiert), oder None fuer beide") -) -> List[QuizQuestion]: - """ - Holt Quiz-Fragen fuer das Spiel. - - - Filtert nach Schwierigkeitsgrad (+/- 1 Level) - - Optional nach Fach filterbar - - Optional nach Modus: "quick" (visuelle Fragen waehrend Fahrt) oder "pause" (Denkaufgaben) - - Gibt zufaellige Auswahl zurueck - """ - # Fragen nach Schwierigkeit filtern (+/- 1 Level Toleranz) - filtered = [ - q for q in SAMPLE_QUESTIONS - if abs(q.difficulty - difficulty) <= 1 - and (subject is None or q.subject == subject) - and (mode is None or q.quiz_mode == mode) - ] - - if not filtered: - # Fallback: Alle Fragen wenn keine passenden gefunden - filtered = [q for q in SAMPLE_QUESTIONS if mode is None or q.quiz_mode == mode] - - # Zufaellige Auswahl - selected = random.sample(filtered, min(count, len(filtered))) - return selected - - -@router.get("/quiz/visual-triggers") -async def get_visual_triggers() -> List[dict]: - """ - Gibt alle verfuegbaren visuellen Trigger zurueck. - - Unity verwendet diese Liste um zu wissen, welche Objekte - im Spiel Quiz-Fragen ausloesen koennen. - """ - triggers = {} - for q in SAMPLE_QUESTIONS: - if q.visual_trigger and q.quiz_mode == "quick": - if q.visual_trigger not in triggers: - triggers[q.visual_trigger] = { - "trigger": q.visual_trigger, - "question_count": 0, - "difficulties": set(), - "subjects": set() - } - triggers[q.visual_trigger]["question_count"] += 1 - triggers[q.visual_trigger]["difficulties"].add(q.difficulty) - triggers[q.visual_trigger]["subjects"].add(q.subject) - - # Sets zu Listen konvertieren fuer JSON - return [ - { - "trigger": t["trigger"], - "question_count": t["question_count"], - "difficulties": list(t["difficulties"]), - "subjects": list(t["subjects"]) - } - for t in triggers.values() - ] - - -@router.post("/quiz/answer") -async def submit_quiz_answer(answer: QuizAnswer) -> dict: - """ - Verarbeitet eine Quiz-Antwort (fuer Echtzeit-Feedback). - - In der finalen Version: Speichert in Session, updated Analytics. - """ - return { - "question_id": answer.question_id, - "was_correct": answer.was_correct, - "points": 500 if answer.was_correct else -100, - "message": "Richtig! Weiter so!" if answer.was_correct else "Nicht ganz, versuch es nochmal!" - } - - -@router.post("/session", response_model=SessionResponse) -async def save_game_session( - session: GameSession, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> SessionResponse: - """ - Speichert eine komplette Spielsession. - - - Protokolliert Score, Distanz, Fragen-Performance - - Aktualisiert Lernniveau bei genuegend Daten - - Wird am Ende jedes Spiels aufgerufen - - Speichert in PostgreSQL wenn verfuegbar - - Bei GAME_REQUIRE_AUTH=true: User-ID aus Token - """ - # If auth is enabled, use user_id from token (ignore session.user_id) - effective_user_id = session.user_id - if REQUIRE_AUTH and user: - effective_user_id = user.get("user_id", session.user_id) - - session_id = str(uuid.uuid4()) - - # Lernniveau-Anpassung basierend auf Performance - new_level = None - old_level = 3 # Default - - # Try to get current level first - db = await get_game_database() - if db: - state = await db.get_learning_state(effective_user_id) - if state: - old_level = state.overall_level - else: - # Create initial state if not exists - await db.create_or_update_learning_state(effective_user_id) - old_level = 3 - elif effective_user_id in _user_levels: - old_level = _user_levels[effective_user_id].overall_level - - # Calculate level adjustment - if session.questions_answered >= 5: - accuracy = session.questions_correct / session.questions_answered - - # Anpassung: Wenn >80% korrekt und max nicht erreicht → Level up - if accuracy >= 0.8 and old_level < 5: - new_level = old_level + 1 - # Wenn <40% korrekt und min nicht erreicht → Level down - elif accuracy < 0.4 and old_level > 1: - new_level = old_level - 1 - - # Save to database - if db: - # Save session - db_session_id = await db.save_game_session( - student_id=effective_user_id, - game_mode=session.game_mode, - duration_seconds=session.duration_seconds, - distance_traveled=session.distance_traveled, - score=session.score, - questions_answered=session.questions_answered, - questions_correct=session.questions_correct, - difficulty_level=session.difficulty_level, - ) - if db_session_id: - session_id = db_session_id - - # Save individual quiz answers if provided - if session.quiz_answers: - for answer in session.quiz_answers: - await db.save_quiz_answer( - session_id=session_id, - question_id=answer.question_id, - subject="general", # Could be enhanced to track actual subject - difficulty=session.difficulty_level, - is_correct=answer.was_correct, - answer_time_ms=answer.answer_time_ms, - ) - - # Update learning stats - duration_minutes = session.duration_seconds // 60 - await db.update_learning_stats( - student_id=effective_user_id, - duration_minutes=duration_minutes, - questions_answered=session.questions_answered, - questions_correct=session.questions_correct, - new_level=new_level, - ) - else: - # Fallback to in-memory - _sessions[session_id] = session - - if new_level: - _user_levels[effective_user_id] = LearningLevel( - user_id=effective_user_id, - overall_level=new_level, - math_level=float(new_level), - german_level=float(new_level), - english_level=float(new_level), - last_updated=datetime.now() - ) - - return SessionResponse( - session_id=session_id, - status="saved", - new_level=new_level - ) - - -@router.get("/sessions/{user_id}") -async def get_user_sessions( - user_id: str, - limit: int = Query(10, ge=1, le=100), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> List[dict]: - """ - Holt die letzten Spielsessions eines Benutzers. - - Fuer Statistiken und Fortschrittsanzeige. - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - # Try database first - db = await get_game_database() - if db: - sessions = await db.get_user_sessions(user_id, limit) - if sessions: - return sessions - - # Fallback to in-memory - user_sessions = [ - {"session_id": sid, **s.model_dump()} - for sid, s in _sessions.items() - if s.user_id == user_id - ] - return user_sessions[:limit] - - -@router.get("/leaderboard") -async def get_leaderboard( - timeframe: str = Query("day", description="day, week, month, all"), - limit: int = Query(10, ge=1, le=100) -) -> List[dict]: - """ - Gibt Highscore-Liste zurueck. - - - Sortiert nach Punktzahl - - Optional nach Zeitraum filterbar - """ - # Try database first - db = await get_game_database() - if db: - leaderboard = await db.get_leaderboard(timeframe, limit) - if leaderboard: - return leaderboard - - # Fallback to in-memory - # Aggregiere Scores pro User - user_scores: dict[str, int] = {} - for session in _sessions.values(): - if session.user_id not in user_scores: - user_scores[session.user_id] = 0 - user_scores[session.user_id] += session.score - - # Sortieren und limitieren - leaderboard = [ - {"rank": i + 1, "user_id": uid, "total_score": score} - for i, (uid, score) in enumerate( - sorted(user_scores.items(), key=lambda x: x[1], reverse=True)[:limit] - ) - ] - - return leaderboard - - -@router.get("/stats/{user_id}") -async def get_user_stats( - user_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Gibt detaillierte Statistiken fuer einen Benutzer zurueck. - - - Gesamtstatistiken - - Fach-spezifische Statistiken - - Lernniveau-Verlauf - - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - db = await get_game_database() - if db: - state = await db.get_learning_state(user_id) - subject_stats = await db.get_subject_stats(user_id) - - if state: - return { - "user_id": user_id, - "overall_level": state.overall_level, - "math_level": state.math_level, - "german_level": state.german_level, - "english_level": state.english_level, - "total_play_time_minutes": state.total_play_time_minutes, - "total_sessions": state.total_sessions, - "questions_answered": state.questions_answered, - "questions_correct": state.questions_correct, - "accuracy": state.accuracy, - "subjects": subject_stats, - } - - # Fallback - return defaults - return { - "user_id": user_id, - "overall_level": 3, - "math_level": 3.0, - "german_level": 3.0, - "english_level": 3.0, - "total_play_time_minutes": 0, - "total_sessions": 0, - "questions_answered": 0, - "questions_correct": 0, - "accuracy": 0.0, - "subjects": {}, - } - - -@router.get("/suggestions/{user_id}") -async def get_learning_suggestions( - user_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Gibt adaptive Lernvorschlaege fuer einen Benutzer zurueck. - - Basierend auf aktueller Performance und Lernhistorie. - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - db = await get_game_database() - if not db: - return {"suggestions": [], "message": "Database not available"} - - state = await db.get_learning_state(user_id) - if not state: - return {"suggestions": [], "message": "No learning state found"} - - try: - from game.learning_rules import ( - LearningContext, - get_rule_engine, - ) - - # Create context from state - context = LearningContext.from_learning_state(state) - - # Get suggestions from rule engine - engine = get_rule_engine() - suggestions = engine.evaluate(context) - - return { - "user_id": user_id, - "overall_level": state.overall_level, - "suggestions": [ - { - "title": s.title, - "description": s.description, - "action": s.action.value, - "priority": s.priority.name.lower(), - "metadata": s.metadata or {}, - } - for s in suggestions[:3] # Top 3 suggestions - ] - } - except ImportError: - return {"suggestions": [], "message": "Learning rules not available"} - except Exception as e: - logger.warning(f"Failed to get suggestions: {e}") - return {"suggestions": [], "message": str(e)} - - -@router.get("/quiz/generate") -async def generate_quiz_questions( - difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), - count: int = Query(5, ge=1, le=20, description="Anzahl der Fragen"), - subject: Optional[str] = Query(None, description="Fach: math, german, english"), - mode: str = Query("quick", description="Quiz-Modus: quick oder pause"), - visual_trigger: Optional[str] = Query(None, description="Visueller Trigger: bridge, tree, house, etc.") -) -> List[dict]: - """ - Generiert Quiz-Fragen dynamisch via LLM. - - Fallback auf statische Fragen wenn LLM nicht verfuegbar. - """ - try: - from game.quiz_generator import get_quiz_generator - - generator = await get_quiz_generator() - questions = await generator.get_questions( - difficulty=difficulty, - subject=subject or "general", - mode=mode, - count=count, - visual_trigger=visual_trigger - ) - - if questions: - return [ - { - "id": f"gen-{i}", - "question_text": q.question_text, - "options": q.options, - "correct_index": q.correct_index, - "difficulty": q.difficulty, - "subject": q.subject, - "grade_level": q.grade_level, - "quiz_mode": q.quiz_mode, - "visual_trigger": q.visual_trigger, - "time_limit_seconds": q.time_limit_seconds, - } - for i, q in enumerate(questions) - ] - except ImportError: - logger.info("Quiz generator not available, using static questions") - except Exception as e: - logger.warning(f"Quiz generation failed: {e}") - - # Fallback to static questions - return await get_quiz_questions(difficulty, count, subject, mode) - - -@router.get("/health") -async def health_check() -> dict: - """Health-Check fuer das Spiel-Backend.""" - db = await get_game_database() - db_status = "connected" if db and db._connected else "disconnected" - - # Check LLM availability - llm_status = "disabled" - try: - from game.quiz_generator import get_quiz_generator - generator = await get_quiz_generator() - llm_status = "connected" if generator._llm_available else "disconnected" - except: - pass - - return { - "status": "healthy", - "service": "breakpilot-drive", - "database": db_status, - "llm_generator": llm_status, - "auth_required": REQUIRE_AUTH, - "questions_available": len(SAMPLE_QUESTIONS), - "active_sessions": len(_sessions) - } - - -# ============================================== -# Phase 5: Erweiterte Features -# ============================================== - -@router.get("/achievements/{user_id}") -async def get_achievements( - user_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Gibt Achievements mit Fortschritt fuer einen Benutzer zurueck. - - Achievements werden basierend auf Spielstatistiken berechnet. - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - db = await get_game_database() - if not db: - return {"achievements": [], "message": "Database not available"} - - try: - achievements = await db.get_student_achievements(user_id) - - unlocked = [a for a in achievements if a.unlocked] - locked = [a for a in achievements if not a.unlocked] - - return { - "user_id": user_id, - "total": len(achievements), - "unlocked_count": len(unlocked), - "achievements": [ - { - "id": a.id, - "name": a.name, - "description": a.description, - "icon": a.icon, - "category": a.category, - "threshold": a.threshold, - "progress": a.progress, - "unlocked": a.unlocked, - } - for a in achievements - ] - } - except Exception as e: - logger.error(f"Failed to get achievements: {e}") - return {"achievements": [], "message": str(e)} - - -@router.get("/progress/{user_id}") -async def get_progress( - user_id: str, - days: int = Query(30, ge=7, le=90, description="Anzahl Tage zurueck"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Gibt Lernfortschritt ueber Zeit zurueck (fuer Charts). - - - Taegliche Statistiken - - Fuer Eltern-Dashboard und Fortschrittsanzeige - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - db = await get_game_database() - if not db: - return {"progress": [], "message": "Database not available"} - - try: - progress = await db.get_progress_over_time(user_id, days) - return { - "user_id": user_id, - "days": days, - "data_points": len(progress), - "progress": progress, - } - except Exception as e: - logger.error(f"Failed to get progress: {e}") - return {"progress": [], "message": str(e)} - - -@router.get("/parent/children") -async def get_children_dashboard( - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Eltern-Dashboard: Statistiken fuer alle Kinder. - - Erfordert Auth mit Eltern-Rolle und children_ids Claim. - """ - if not REQUIRE_AUTH or user is None: - return { - "message": "Auth required for parent dashboard", - "children": [] - } - - # Get children IDs from token - children_ids = user.get("raw_claims", {}).get("children_ids", []) - - if not children_ids: - return { - "message": "No children associated with this account", - "children": [] - } - - db = await get_game_database() - if not db: - return {"children": [], "message": "Database not available"} - - try: - children_stats = await db.get_children_stats(children_ids) - return { - "parent_id": user.get("user_id"), - "children_count": len(children_ids), - "children": children_stats, - } - except Exception as e: - logger.error(f"Failed to get children stats: {e}") - return {"children": [], "message": str(e)} - - -@router.get("/leaderboard/class/{class_id}") -async def get_class_leaderboard( - class_id: str, - timeframe: str = Query("week", description="day, week, month, all"), - limit: int = Query(10, ge=1, le=50), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> List[dict]: - """ - Klassenspezifische Rangliste. - - Nur fuer Lehrer oder Schueler der Klasse sichtbar. - """ - db = await get_game_database() - if not db: - return [] - - try: - leaderboard = await db.get_class_leaderboard(class_id, timeframe, limit) - return leaderboard - except Exception as e: - logger.error(f"Failed to get class leaderboard: {e}") - return [] - - -@router.get("/leaderboard/display") -async def get_display_leaderboard( - timeframe: str = Query("day", description="day, week, month, all"), - limit: int = Query(10, ge=1, le=100), - anonymize: bool = Query(True, description="Namen anonymisieren") -) -> List[dict]: - """ - Oeffentliche Rangliste mit Anzeigenamen. - - Standardmaessig anonymisiert fuer Datenschutz. - """ - db = await get_game_database() - if not db: - return [] - - try: - return await db.get_leaderboard_with_names(timeframe, limit, anonymize) - except Exception as e: - logger.error(f"Failed to get display leaderboard: {e}") - return [] +# The `router` object is assembled here by including all sub-routers. +# Importers that did `from game_api import router` continue to work. + +from fastapi import APIRouter + +from game_routes import router as _core_router +from game_session_routes import router as _session_router +from game_extended_routes import router as _extended_router + +# Re-export models for any direct importers +from game_models import ( # noqa: F401 + LearningLevel, + GameDifficulty, + QuizQuestion, + QuizAnswer, + GameSession, + SessionResponse, + DIFFICULTY_MAPPING, + SAMPLE_QUESTIONS, +) + +# Re-export helpers/state for any direct importers +from game_routes import ( # noqa: F401 + get_optional_current_user, + get_user_id_from_auth, + get_game_database, + _sessions, + _user_levels, + USE_DATABASE, + REQUIRE_AUTH, +) + +# Assemble the combined router. +# Both sub-routers use prefix="/api/game", so include without extra prefix. +router = APIRouter() +router.include_router(_core_router) +router.include_router(_session_router) +router.include_router(_extended_router) diff --git a/backend-lehrer/game_extended_routes.py b/backend-lehrer/game_extended_routes.py new file mode 100644 index 0000000..513eefe --- /dev/null +++ b/backend-lehrer/game_extended_routes.py @@ -0,0 +1,189 @@ +# ============================================== +# Breakpilot Drive - Game Extended Routes +# ============================================== +# Phase 5 features: achievements, progress, parent dashboard, +# class leaderboard, and display leaderboard. +# Extracted from game_api.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +import logging + +from game_routes import ( + get_optional_current_user, + get_user_id_from_auth, + get_game_database, + REQUIRE_AUTH, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) + + +# ============================================== +# Phase 5: Erweiterte Features +# ============================================== + +@router.get("/achievements/{user_id}") +async def get_achievements( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt Achievements mit Fortschritt fuer einen Benutzer zurueck. + + Achievements werden basierend auf Spielstatistiken berechnet. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"achievements": [], "message": "Database not available"} + + try: + achievements = await db.get_student_achievements(user_id) + + unlocked = [a for a in achievements if a.unlocked] + locked = [a for a in achievements if not a.unlocked] + + return { + "user_id": user_id, + "total": len(achievements), + "unlocked_count": len(unlocked), + "achievements": [ + { + "id": a.id, + "name": a.name, + "description": a.description, + "icon": a.icon, + "category": a.category, + "threshold": a.threshold, + "progress": a.progress, + "unlocked": a.unlocked, + } + for a in achievements + ] + } + except Exception as e: + logger.error(f"Failed to get achievements: {e}") + return {"achievements": [], "message": str(e)} + + +@router.get("/progress/{user_id}") +async def get_progress( + user_id: str, + days: int = Query(30, ge=7, le=90, description="Anzahl Tage zurueck"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt Lernfortschritt ueber Zeit zurueck (fuer Charts). + + - Taegliche Statistiken + - Fuer Eltern-Dashboard und Fortschrittsanzeige + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"progress": [], "message": "Database not available"} + + try: + progress = await db.get_progress_over_time(user_id, days) + return { + "user_id": user_id, + "days": days, + "data_points": len(progress), + "progress": progress, + } + except Exception as e: + logger.error(f"Failed to get progress: {e}") + return {"progress": [], "message": str(e)} + + +@router.get("/parent/children") +async def get_children_dashboard( + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Eltern-Dashboard: Statistiken fuer alle Kinder. + + Erfordert Auth mit Eltern-Rolle und children_ids Claim. + """ + if not REQUIRE_AUTH or user is None: + return { + "message": "Auth required for parent dashboard", + "children": [] + } + + # Get children IDs from token + children_ids = user.get("raw_claims", {}).get("children_ids", []) + + if not children_ids: + return { + "message": "No children associated with this account", + "children": [] + } + + db = await get_game_database() + if not db: + return {"children": [], "message": "Database not available"} + + try: + children_stats = await db.get_children_stats(children_ids) + return { + "parent_id": user.get("user_id"), + "children_count": len(children_ids), + "children": children_stats, + } + except Exception as e: + logger.error(f"Failed to get children stats: {e}") + return {"children": [], "message": str(e)} + + +@router.get("/leaderboard/class/{class_id}") +async def get_class_leaderboard( + class_id: str, + timeframe: str = Query("week", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=50), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[dict]: + """ + Klassenspezifische Rangliste. + + Nur fuer Lehrer oder Schueler der Klasse sichtbar. + """ + db = await get_game_database() + if not db: + return [] + + try: + leaderboard = await db.get_class_leaderboard(class_id, timeframe, limit) + return leaderboard + except Exception as e: + logger.error(f"Failed to get class leaderboard: {e}") + return [] + + +@router.get("/leaderboard/display") +async def get_display_leaderboard( + timeframe: str = Query("day", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=100), + anonymize: bool = Query(True, description="Namen anonymisieren") +) -> List[dict]: + """ + Oeffentliche Rangliste mit Anzeigenamen. + + Standardmaessig anonymisiert fuer Datenschutz. + """ + db = await get_game_database() + if not db: + return [] + + try: + return await db.get_leaderboard_with_names(timeframe, limit, anonymize) + except Exception as e: + logger.error(f"Failed to get display leaderboard: {e}") + return [] diff --git a/backend-lehrer/game_models.py b/backend-lehrer/game_models.py new file mode 100644 index 0000000..4258b2d --- /dev/null +++ b/backend-lehrer/game_models.py @@ -0,0 +1,322 @@ +# ============================================== +# Breakpilot Drive - Game API Models & Data +# ============================================== +# Pydantic models, difficulty mappings, and sample questions. +# Extracted from game_api.py for file-size compliance. + +from pydantic import BaseModel +from typing import List, Optional, Literal, Dict, Any +from datetime import datetime + + +# ============================================== +# Pydantic Models +# ============================================== + +class LearningLevel(BaseModel): + """Lernniveau eines Benutzers aus dem Breakpilot-System""" + user_id: str + overall_level: int # 1-5 (1=Anfaenger/Klasse 2, 5=Fortgeschritten/Klasse 6) + math_level: float + german_level: float + english_level: float + last_updated: datetime + + +class GameDifficulty(BaseModel): + """Spielschwierigkeit basierend auf Lernniveau""" + lane_speed: float # Geschwindigkeit in m/s + obstacle_frequency: float # Hindernisse pro Sekunde + power_up_chance: float # Wahrscheinlichkeit fuer Power-Ups (0-1) + question_complexity: int # 1-5 + answer_time: int # Sekunden zum Antworten + hints_enabled: bool + speech_speed: float # Sprechgeschwindigkeit fuer Audio-Version + + +class QuizQuestion(BaseModel): + """Quiz-Frage fuer das Spiel""" + id: str + question_text: str + audio_url: Optional[str] = None + options: List[str] # 2-4 Antwortmoeglichkeiten + correct_index: int # 0-3 + difficulty: int # 1-5 + subject: Literal["math", "german", "english", "general"] + grade_level: Optional[int] = None # 2-6 + # NEU: Quiz-Modus + quiz_mode: Literal["quick", "pause"] = "quick" # quick=waehrend Fahrt, pause=Spiel haelt an + visual_trigger: Optional[str] = None # z.B. "bridge", "house", "tree" - loest Frage aus + time_limit_seconds: Optional[float] = None # Zeit bis Antwort noetig (bei quick) + + +class QuizAnswer(BaseModel): + """Antwort auf eine Quiz-Frage""" + question_id: str + selected_index: int + answer_time_ms: int # Zeit bis zur Antwort in ms + was_correct: bool + + +class GameSession(BaseModel): + """Spielsession-Daten fuer Analytics""" + user_id: str + game_mode: Literal["video", "audio"] + duration_seconds: int + distance_traveled: float + score: int + questions_answered: int + questions_correct: int + difficulty_level: int + quiz_answers: Optional[List[QuizAnswer]] = None + + +class SessionResponse(BaseModel): + """Antwort nach Session-Speicherung""" + session_id: str + status: str + new_level: Optional[int] = None # Falls Lernniveau angepasst wurde + + +# ============================================== +# Schwierigkeits-Mapping +# ============================================== + +DIFFICULTY_MAPPING = { + 1: GameDifficulty( + lane_speed=3.0, + obstacle_frequency=0.3, + power_up_chance=0.4, + question_complexity=1, + answer_time=15, + hints_enabled=True, + speech_speed=0.8 + ), + 2: GameDifficulty( + lane_speed=4.0, + obstacle_frequency=0.4, + power_up_chance=0.35, + question_complexity=2, + answer_time=12, + hints_enabled=True, + speech_speed=0.9 + ), + 3: GameDifficulty( + lane_speed=5.0, + obstacle_frequency=0.5, + power_up_chance=0.3, + question_complexity=3, + answer_time=10, + hints_enabled=True, + speech_speed=1.0 + ), + 4: GameDifficulty( + lane_speed=6.0, + obstacle_frequency=0.6, + power_up_chance=0.25, + question_complexity=4, + answer_time=8, + hints_enabled=False, + speech_speed=1.1 + ), + 5: GameDifficulty( + lane_speed=7.0, + obstacle_frequency=0.7, + power_up_chance=0.2, + question_complexity=5, + answer_time=6, + hints_enabled=False, + speech_speed=1.2 + ), +} + + +# ============================================== +# Beispiel Quiz-Fragen (spaeter aus DB laden) +# ============================================== + +SAMPLE_QUESTIONS = [ + # ============================================== + # QUICK QUESTIONS (waehrend der Fahrt, visuell getriggert) + # ============================================== + + # Englisch Vokabeln - Objekte im Spiel (QUICK MODE) + QuizQuestion( + id="vq-bridge", question_text="What is this?", + options=["Bridge", "House"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="bridge", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-tree", question_text="What is this?", + options=["Tree", "Flower"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="tree", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-house", question_text="What is this?", + options=["House", "Car"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="house", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-car", question_text="What is this?", + options=["Car", "Bus"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="car", time_limit_seconds=2.5 + ), + QuizQuestion( + id="vq-mountain", question_text="What is this?", + options=["Hill", "Mountain", "Valley"], correct_index=1, + difficulty=2, subject="english", grade_level=4, + quiz_mode="quick", visual_trigger="mountain", time_limit_seconds=3.5 + ), + QuizQuestion( + id="vq-river", question_text="What is this?", + options=["Lake", "River", "Sea"], correct_index=1, + difficulty=2, subject="english", grade_level=4, + quiz_mode="quick", visual_trigger="river", time_limit_seconds=3.5 + ), + + # Schnelle Rechenaufgaben (QUICK MODE) + QuizQuestion( + id="mq-1", question_text="3 + 4 = ?", + options=["6", "7"], correct_index=1, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=4.0 + ), + QuizQuestion( + id="mq-2", question_text="5 x 2 = ?", + options=["10", "12"], correct_index=0, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=4.0 + ), + QuizQuestion( + id="mq-3", question_text="8 - 3 = ?", + options=["4", "5"], correct_index=1, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=3.5 + ), + QuizQuestion( + id="mq-4", question_text="6 x 7 = ?", + options=["42", "48"], correct_index=0, + difficulty=2, subject="math", grade_level=3, + quiz_mode="quick", time_limit_seconds=5.0 + ), + QuizQuestion( + id="mq-5", question_text="9 x 8 = ?", + options=["72", "64"], correct_index=0, + difficulty=3, subject="math", grade_level=4, + quiz_mode="quick", time_limit_seconds=5.0 + ), + + # ============================================== + # PAUSE QUESTIONS (Spiel haelt an, mehr Zeit) + # ============================================== + + # Mathe Level 1-2 (Klasse 2-3) - PAUSE MODE + QuizQuestion( + id="mp1-1", question_text="Anna hat 5 Aepfel. Sie bekommt 3 dazu. Wie viele hat sie jetzt?", + options=["6", "7", "8", "9"], correct_index=2, + difficulty=1, subject="math", grade_level=2, + quiz_mode="pause" + ), + QuizQuestion( + id="mp2-1", question_text="Ein Bus hat 24 Sitze. 18 sind besetzt. Wie viele sind frei?", + options=["4", "5", "6", "7"], correct_index=2, + difficulty=2, subject="math", grade_level=3, + quiz_mode="pause" + ), + QuizQuestion( + id="mp2-2", question_text="Was ist 45 + 27?", + options=["72", "62", "82", "70"], correct_index=0, + difficulty=2, subject="math", grade_level=3, + quiz_mode="pause" + ), + + # Mathe Level 3-4 (Klasse 4-5) - PAUSE MODE + QuizQuestion( + id="mp3-1", question_text="Was ist 7 x 8?", + options=["54", "56", "58", "48"], correct_index=1, + difficulty=3, subject="math", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="mp3-2", question_text="Ein Rechteck ist 8m lang und 5m breit. Wie gross ist die Flaeche?", + options=["35 m2", "40 m2", "45 m2", "26 m2"], correct_index=1, + difficulty=3, subject="math", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="mp4-1", question_text="Was ist 15% von 80?", + options=["10", "12", "8", "15"], correct_index=1, + difficulty=4, subject="math", grade_level=5, + quiz_mode="pause" + ), + QuizQuestion( + id="mp4-2", question_text="Was ist 3/4 + 1/2?", + options=["5/4", "4/6", "1", "5/6"], correct_index=0, + difficulty=4, subject="math", grade_level=5, + quiz_mode="pause" + ), + + # Mathe Level 5 (Klasse 6) - PAUSE MODE + QuizQuestion( + id="mp5-1", question_text="Was ist (-5) x (-3)?", + options=["-15", "15", "-8", "8"], correct_index=1, + difficulty=5, subject="math", grade_level=6, + quiz_mode="pause" + ), + QuizQuestion( + id="mp5-2", question_text="Loesung von 2x + 5 = 11?", + options=["2", "3", "4", "6"], correct_index=1, + difficulty=5, subject="math", grade_level=6, + quiz_mode="pause" + ), + + # Deutsch - PAUSE MODE (brauchen Lesezeit) + QuizQuestion( + id="dp1-1", question_text="Welches Wort ist ein Nomen?", + options=["laufen", "schnell", "Hund", "und"], correct_index=2, + difficulty=1, subject="german", grade_level=2, + quiz_mode="pause" + ), + QuizQuestion( + id="dp2-1", question_text="Was ist die Mehrzahl von 'Haus'?", + options=["Haeuse", "Haeuser", "Hausern", "Haus"], correct_index=1, + difficulty=2, subject="german", grade_level=3, + quiz_mode="pause" + ), + QuizQuestion( + id="dp3-1", question_text="Welches Verb steht im Praeteritum?", + options=["geht", "ging", "gegangen", "gehen"], correct_index=1, + difficulty=3, subject="german", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="dp3-2", question_text="Finde den Rechtschreibfehler: 'Der Hund leuft schnell.'", + options=["Hund", "leuft", "schnell", "Der"], correct_index=1, + difficulty=3, subject="german", grade_level=4, + quiz_mode="pause" + ), + + # Englisch Saetze - PAUSE MODE + QuizQuestion( + id="ep3-1", question_text="How do you say 'Schmetterling'?", + options=["bird", "bee", "butterfly", "beetle"], correct_index=2, + difficulty=3, subject="english", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="ep4-1", question_text="Choose the correct form: She ___ to school.", + options=["go", "goes", "going", "gone"], correct_index=1, + difficulty=4, subject="english", grade_level=5, + quiz_mode="pause" + ), + QuizQuestion( + id="ep4-2", question_text="What is the past tense of 'run'?", + options=["runned", "ran", "runed", "running"], correct_index=1, + difficulty=4, subject="english", grade_level=5, + quiz_mode="pause" + ), +] diff --git a/backend-lehrer/game_routes.py b/backend-lehrer/game_routes.py new file mode 100644 index 0000000..21a1e4e --- /dev/null +++ b/backend-lehrer/game_routes.py @@ -0,0 +1,296 @@ +# ============================================== +# Breakpilot Drive - Game API Core Routes +# ============================================== +# Core game endpoints: learning level, difficulty, quiz questions. +# Session/stats/leaderboard routes are in game_session_routes.py. +# Extracted from game_api.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +from datetime import datetime +import random +import uuid +import os +import logging + +from game_models import ( + LearningLevel, + GameDifficulty, + QuizQuestion, + QuizAnswer, + GameSession, + SessionResponse, + DIFFICULTY_MAPPING, + SAMPLE_QUESTIONS, +) + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" +REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" + +router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) + + +# ============================================== +# Auth Dependency (Optional) +# ============================================== + +async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: + """ + Optional auth dependency for Game API. + + If GAME_REQUIRE_AUTH=true: Requires valid JWT token + If GAME_REQUIRE_AUTH=false: Returns None (anonymous access) + + In development mode without auth, returns demo user. + """ + if not REQUIRE_AUTH: + return None + + try: + from auth import get_current_user + return await get_current_user(request) + except ImportError: + logger.warning("Auth module not available") + return None + except HTTPException: + raise # Re-raise auth errors + except Exception as e: + logger.error(f"Auth error: {e}") + raise HTTPException(status_code=401, detail="Authentication failed") + + +def get_user_id_from_auth( + user: Optional[Dict[str, Any]], + requested_user_id: str +) -> str: + """ + Get the effective user ID, respecting auth when enabled. + + If auth is enabled and user is authenticated: + - Returns user's own ID if requested_user_id matches + - For parents: allows access to child IDs from token + - For teachers: allows access to student IDs (future) + + If auth is disabled: Returns requested_user_id as-is + """ + if not REQUIRE_AUTH or user is None: + return requested_user_id + + user_id = user.get("user_id", "") + + # Same user - always allowed + if requested_user_id == user_id: + return user_id + + # Check for parent accessing child data + children_ids = user.get("raw_claims", {}).get("children_ids", []) + if requested_user_id in children_ids: + return requested_user_id + + # Check for teacher accessing student data (future) + realm_roles = user.get("realm_roles", []) + if "lehrer" in realm_roles or "teacher" in realm_roles: + # Teachers can access any student in their class (implement class check later) + return requested_user_id + + # Admin bypass + if "admin" in realm_roles: + return requested_user_id + + # Not authorized + raise HTTPException( + status_code=403, + detail="Not authorized to access this user's data" + ) + + +# In-Memory Session Storage (Fallback wenn DB nicht verfuegbar) +_sessions: dict[str, GameSession] = {} +_user_levels: dict[str, LearningLevel] = {} + +# Database integration +_game_db = None + +async def get_game_database(): + """Get game database instance with lazy initialization.""" + global _game_db + if not USE_DATABASE: + return None + if _game_db is None: + try: + from game.database import get_game_db + _game_db = await get_game_db() + logger.info("Game database initialized") + except Exception as e: + logger.warning(f"Game database not available, using in-memory: {e}") + return _game_db + + +# ============================================== +# API Endpunkte +# ============================================== + +@router.get("/learning-level/{user_id}", response_model=LearningLevel) +async def get_learning_level( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> LearningLevel: + """ + Holt das aktuelle Lernniveau eines Benutzers aus Breakpilot. + + - Wird beim Spielstart aufgerufen um Schwierigkeit anzupassen + - Gibt Level 1-5 zurueck (1=Anfaenger, 5=Fortgeschritten) + - Cached Werte fuer schnellen Zugriff + - Speichert in PostgreSQL wenn verfuegbar + - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + # Try database first + db = await get_game_database() + if db: + state = await db.get_learning_state(user_id) + if state: + return LearningLevel( + user_id=user_id, + overall_level=state.overall_level, + math_level=state.math_level, + german_level=state.german_level, + english_level=state.english_level, + last_updated=state.updated_at or datetime.now() + ) + + # Create new state in database + new_state = await db.create_or_update_learning_state( + student_id=user_id, + overall_level=3, + math_level=3.0, + german_level=3.0, + english_level=3.0 + ) + if new_state: + return LearningLevel( + user_id=user_id, + overall_level=new_state.overall_level, + math_level=new_state.math_level, + german_level=new_state.german_level, + english_level=new_state.english_level, + last_updated=new_state.updated_at or datetime.now() + ) + + # Fallback to in-memory + if user_id in _user_levels: + return _user_levels[user_id] + + # Standard-Level fuer neue Benutzer + default_level = LearningLevel( + user_id=user_id, + overall_level=3, # Mittleres Level als Default + math_level=3.0, + german_level=3.0, + english_level=3.0, + last_updated=datetime.now() + ) + _user_levels[user_id] = default_level + return default_level + + +@router.get("/difficulty/{level}", response_model=GameDifficulty) +async def get_game_difficulty(level: int) -> GameDifficulty: + """ + Gibt Spielparameter basierend auf Lernniveau zurueck. + + Level 1-5 werden auf Spielgeschwindigkeit, Hindernisfrequenz, + Fragen-Schwierigkeit etc. gemappt. + """ + if level < 1 or level > 5: + raise HTTPException(status_code=400, detail="Level muss zwischen 1 und 5 sein") + + return DIFFICULTY_MAPPING[level] + + +@router.get("/quiz/questions", response_model=List[QuizQuestion]) +async def get_quiz_questions( + difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), + count: int = Query(10, ge=1, le=50, description="Anzahl der Fragen"), + subject: Optional[str] = Query(None, description="Fach: math, german, english, oder None fuer gemischt"), + mode: Optional[str] = Query(None, description="Quiz-Modus: quick (waehrend Fahrt), pause (Spiel pausiert), oder None fuer beide") +) -> List[QuizQuestion]: + """ + Holt Quiz-Fragen fuer das Spiel. + + - Filtert nach Schwierigkeitsgrad (+/- 1 Level) + - Optional nach Fach filterbar + - Optional nach Modus: "quick" (visuelle Fragen waehrend Fahrt) oder "pause" (Denkaufgaben) + - Gibt zufaellige Auswahl zurueck + """ + # Fragen nach Schwierigkeit filtern (+/- 1 Level Toleranz) + filtered = [ + q for q in SAMPLE_QUESTIONS + if abs(q.difficulty - difficulty) <= 1 + and (subject is None or q.subject == subject) + and (mode is None or q.quiz_mode == mode) + ] + + if not filtered: + # Fallback: Alle Fragen wenn keine passenden gefunden + filtered = [q for q in SAMPLE_QUESTIONS if mode is None or q.quiz_mode == mode] + + # Zufaellige Auswahl + selected = random.sample(filtered, min(count, len(filtered))) + return selected + + +@router.get("/quiz/visual-triggers") +async def get_visual_triggers() -> List[dict]: + """ + Gibt alle verfuegbaren visuellen Trigger zurueck. + + Unity verwendet diese Liste um zu wissen, welche Objekte + im Spiel Quiz-Fragen ausloesen koennen. + """ + triggers = {} + for q in SAMPLE_QUESTIONS: + if q.visual_trigger and q.quiz_mode == "quick": + if q.visual_trigger not in triggers: + triggers[q.visual_trigger] = { + "trigger": q.visual_trigger, + "question_count": 0, + "difficulties": set(), + "subjects": set() + } + triggers[q.visual_trigger]["question_count"] += 1 + triggers[q.visual_trigger]["difficulties"].add(q.difficulty) + triggers[q.visual_trigger]["subjects"].add(q.subject) + + # Sets zu Listen konvertieren fuer JSON + return [ + { + "trigger": t["trigger"], + "question_count": t["question_count"], + "difficulties": list(t["difficulties"]), + "subjects": list(t["subjects"]) + } + for t in triggers.values() + ] + + +@router.post("/quiz/answer") +async def submit_quiz_answer(answer: QuizAnswer) -> dict: + """ + Verarbeitet eine Quiz-Antwort (fuer Echtzeit-Feedback). + + In der finalen Version: Speichert in Session, updated Analytics. + """ + return { + "question_id": answer.question_id, + "was_correct": answer.was_correct, + "points": 500 if answer.was_correct else -100, + "message": "Richtig! Weiter so!" if answer.was_correct else "Nicht ganz, versuch es nochmal!" + } + + diff --git a/backend-lehrer/game_session_routes.py b/backend-lehrer/game_session_routes.py new file mode 100644 index 0000000..ef48ad8 --- /dev/null +++ b/backend-lehrer/game_session_routes.py @@ -0,0 +1,395 @@ +# ============================================== +# Breakpilot Drive - Game Session & Stats Routes +# ============================================== +# Session saving, leaderboard, stats, suggestions, +# quiz generation, and health check. +# Extracted from game_routes.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +from datetime import datetime +import uuid +import logging + +from game_models import ( + LearningLevel, + QuizQuestion, + GameSession, + SessionResponse, + SAMPLE_QUESTIONS, +) + +logger = logging.getLogger(__name__) + +# Import shared state and helpers from game_routes +# (these are the canonical instances) +from game_routes import ( + get_optional_current_user, + get_user_id_from_auth, + get_game_database, + get_quiz_questions, + _sessions, + _user_levels, + REQUIRE_AUTH, +) + +router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) + + +@router.post("/session", response_model=SessionResponse) +async def save_game_session( + session: GameSession, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> SessionResponse: + """ + Speichert eine komplette Spielsession. + + - Protokolliert Score, Distanz, Fragen-Performance + - Aktualisiert Lernniveau bei genuegend Daten + - Wird am Ende jedes Spiels aufgerufen + - Speichert in PostgreSQL wenn verfuegbar + - Bei GAME_REQUIRE_AUTH=true: User-ID aus Token + """ + # If auth is enabled, use user_id from token (ignore session.user_id) + effective_user_id = session.user_id + if REQUIRE_AUTH and user: + effective_user_id = user.get("user_id", session.user_id) + + session_id = str(uuid.uuid4()) + + # Lernniveau-Anpassung basierend auf Performance + new_level = None + old_level = 3 # Default + + # Try to get current level first + db = await get_game_database() + if db: + state = await db.get_learning_state(effective_user_id) + if state: + old_level = state.overall_level + else: + # Create initial state if not exists + await db.create_or_update_learning_state(effective_user_id) + old_level = 3 + elif effective_user_id in _user_levels: + old_level = _user_levels[effective_user_id].overall_level + + # Calculate level adjustment + if session.questions_answered >= 5: + accuracy = session.questions_correct / session.questions_answered + + # Anpassung: Wenn >80% korrekt und max nicht erreicht -> Level up + if accuracy >= 0.8 and old_level < 5: + new_level = old_level + 1 + # Wenn <40% korrekt und min nicht erreicht -> Level down + elif accuracy < 0.4 and old_level > 1: + new_level = old_level - 1 + + # Save to database + if db: + # Save session + db_session_id = await db.save_game_session( + student_id=effective_user_id, + game_mode=session.game_mode, + duration_seconds=session.duration_seconds, + distance_traveled=session.distance_traveled, + score=session.score, + questions_answered=session.questions_answered, + questions_correct=session.questions_correct, + difficulty_level=session.difficulty_level, + ) + if db_session_id: + session_id = db_session_id + + # Save individual quiz answers if provided + if session.quiz_answers: + for answer in session.quiz_answers: + await db.save_quiz_answer( + session_id=session_id, + question_id=answer.question_id, + subject="general", # Could be enhanced to track actual subject + difficulty=session.difficulty_level, + is_correct=answer.was_correct, + answer_time_ms=answer.answer_time_ms, + ) + + # Update learning stats + duration_minutes = session.duration_seconds // 60 + await db.update_learning_stats( + student_id=effective_user_id, + duration_minutes=duration_minutes, + questions_answered=session.questions_answered, + questions_correct=session.questions_correct, + new_level=new_level, + ) + else: + # Fallback to in-memory + _sessions[session_id] = session + + if new_level: + _user_levels[effective_user_id] = LearningLevel( + user_id=effective_user_id, + overall_level=new_level, + math_level=float(new_level), + german_level=float(new_level), + english_level=float(new_level), + last_updated=datetime.now() + ) + + return SessionResponse( + session_id=session_id, + status="saved", + new_level=new_level + ) + + +@router.get("/sessions/{user_id}") +async def get_user_sessions( + user_id: str, + limit: int = Query(10, ge=1, le=100), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[dict]: + """ + Holt die letzten Spielsessions eines Benutzers. + + Fuer Statistiken und Fortschrittsanzeige. + Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + # Try database first + db = await get_game_database() + if db: + sessions = await db.get_user_sessions(user_id, limit) + if sessions: + return sessions + + # Fallback to in-memory + user_sessions = [ + {"session_id": sid, **s.model_dump()} + for sid, s in _sessions.items() + if s.user_id == user_id + ] + return user_sessions[:limit] + + +@router.get("/leaderboard") +async def get_leaderboard( + timeframe: str = Query("day", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=100) +) -> List[dict]: + """ + Gibt Highscore-Liste zurueck. + + - Sortiert nach Punktzahl + - Optional nach Zeitraum filterbar + """ + # Try database first + db = await get_game_database() + if db: + leaderboard = await db.get_leaderboard(timeframe, limit) + if leaderboard: + return leaderboard + + # Fallback to in-memory + # Aggregiere Scores pro User + user_scores: dict[str, int] = {} + for session in _sessions.values(): + if session.user_id not in user_scores: + user_scores[session.user_id] = 0 + user_scores[session.user_id] += session.score + + # Sortieren und limitieren + leaderboard = [ + {"rank": i + 1, "user_id": uid, "total_score": score} + for i, (uid, score) in enumerate( + sorted(user_scores.items(), key=lambda x: x[1], reverse=True)[:limit] + ) + ] + + return leaderboard + + +@router.get("/stats/{user_id}") +async def get_user_stats( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt detaillierte Statistiken fuer einen Benutzer zurueck. + + - Gesamtstatistiken + - Fach-spezifische Statistiken + - Lernniveau-Verlauf + - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if db: + state = await db.get_learning_state(user_id) + subject_stats = await db.get_subject_stats(user_id) + + if state: + return { + "user_id": user_id, + "overall_level": state.overall_level, + "math_level": state.math_level, + "german_level": state.german_level, + "english_level": state.english_level, + "total_play_time_minutes": state.total_play_time_minutes, + "total_sessions": state.total_sessions, + "questions_answered": state.questions_answered, + "questions_correct": state.questions_correct, + "accuracy": state.accuracy, + "subjects": subject_stats, + } + + # Fallback - return defaults + return { + "user_id": user_id, + "overall_level": 3, + "math_level": 3.0, + "german_level": 3.0, + "english_level": 3.0, + "total_play_time_minutes": 0, + "total_sessions": 0, + "questions_answered": 0, + "questions_correct": 0, + "accuracy": 0.0, + "subjects": {}, + } + + +@router.get("/suggestions/{user_id}") +async def get_learning_suggestions( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt adaptive Lernvorschlaege fuer einen Benutzer zurueck. + + Basierend auf aktueller Performance und Lernhistorie. + Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"suggestions": [], "message": "Database not available"} + + state = await db.get_learning_state(user_id) + if not state: + return {"suggestions": [], "message": "No learning state found"} + + try: + from game.learning_rules import ( + LearningContext, + get_rule_engine, + ) + + # Create context from state + context = LearningContext.from_learning_state(state) + + # Get suggestions from rule engine + engine = get_rule_engine() + suggestions = engine.evaluate(context) + + return { + "user_id": user_id, + "overall_level": state.overall_level, + "suggestions": [ + { + "title": s.title, + "description": s.description, + "action": s.action.value, + "priority": s.priority.name.lower(), + "metadata": s.metadata or {}, + } + for s in suggestions[:3] # Top 3 suggestions + ] + } + except ImportError: + return {"suggestions": [], "message": "Learning rules not available"} + except Exception as e: + logger.warning(f"Failed to get suggestions: {e}") + return {"suggestions": [], "message": str(e)} + + +@router.get("/quiz/generate") +async def generate_quiz_questions( + difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), + count: int = Query(5, ge=1, le=20, description="Anzahl der Fragen"), + subject: Optional[str] = Query(None, description="Fach: math, german, english"), + mode: str = Query("quick", description="Quiz-Modus: quick oder pause"), + visual_trigger: Optional[str] = Query(None, description="Visueller Trigger: bridge, tree, house, etc.") +) -> List[dict]: + """ + Generiert Quiz-Fragen dynamisch via LLM. + + Fallback auf statische Fragen wenn LLM nicht verfuegbar. + """ + try: + from game.quiz_generator import get_quiz_generator + + generator = await get_quiz_generator() + questions = await generator.get_questions( + difficulty=difficulty, + subject=subject or "general", + mode=mode, + count=count, + visual_trigger=visual_trigger + ) + + if questions: + return [ + { + "id": f"gen-{i}", + "question_text": q.question_text, + "options": q.options, + "correct_index": q.correct_index, + "difficulty": q.difficulty, + "subject": q.subject, + "grade_level": q.grade_level, + "quiz_mode": q.quiz_mode, + "visual_trigger": q.visual_trigger, + "time_limit_seconds": q.time_limit_seconds, + } + for i, q in enumerate(questions) + ] + except ImportError: + logger.info("Quiz generator not available, using static questions") + except Exception as e: + logger.warning(f"Quiz generation failed: {e}") + + # Fallback to static questions + return await get_quiz_questions(difficulty, count, subject, mode) + + +@router.get("/health") +async def health_check() -> dict: + """Health-Check fuer das Spiel-Backend.""" + db = await get_game_database() + db_status = "connected" if db and db._connected else "disconnected" + + # Check LLM availability + llm_status = "disabled" + try: + from game.quiz_generator import get_quiz_generator + generator = await get_quiz_generator() + llm_status = "connected" if generator._llm_available else "disconnected" + except: + pass + + return { + "status": "healthy", + "service": "breakpilot-drive", + "database": db_status, + "llm_generator": llm_status, + "auth_required": REQUIRE_AUTH, + "questions_available": len(SAMPLE_QUESTIONS), + "active_sessions": len(_sessions) + } diff --git a/backend-lehrer/unit_api.py b/backend-lehrer/unit_api.py index cab4c66..1f534f4 100644 --- a/backend-lehrer/unit_api.py +++ b/backend-lehrer/unit_api.py @@ -1,1226 +1,57 @@ # ============================================== -# Breakpilot Drive - Unit API +# Breakpilot Drive - Unit API (barrel re-export) # ============================================== -# API-Endpunkte fuer kontextuelle Lerneinheiten: -# - Unit-Sessions erstellen und verwalten -# - Telemetrie-Events empfangen -# - Unit-Definitionen abrufen -# - Pre/Post-Check verarbeiten +# This module was split into: +# - unit_models.py (Pydantic models) +# - unit_helpers.py (Auth, DB, token, validation helpers) +# - unit_routes.py (Definition, session, analytics routes) +# - unit_content_routes.py (H5P, worksheet, PDF routes) # -# Mit PostgreSQL-Integration fuer persistente Speicherung. -# Auth: Optional via GAME_REQUIRE_AUTH=true - -from fastapi import APIRouter, HTTPException, Query, Depends, Request -from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any -from datetime import datetime, timedelta -import uuid -import os -import logging -import jwt - -logger = logging.getLogger(__name__) - -# Feature flags -USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" -REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" -SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production") - -router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) - - -# ============================================== -# Auth Dependency (reuse from game_api) -# ============================================== - -async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: - """Optional auth dependency for Unit API.""" - if not REQUIRE_AUTH: - return None - - try: - from auth import get_current_user - return await get_current_user(request) - except ImportError: - logger.warning("Auth module not available") - return None - except HTTPException: - raise - except Exception as e: - logger.error(f"Auth error: {e}") - raise HTTPException(status_code=401, detail="Authentication failed") - - -# ============================================== -# Pydantic Models -# ============================================== - -class UnitDefinitionResponse(BaseModel): - """Unit definition response""" - unit_id: str - template: str - version: str - locale: List[str] - grade_band: List[str] - duration_minutes: int - difficulty: str - definition: Dict[str, Any] - - -class CreateSessionRequest(BaseModel): - """Request to create a unit session""" - unit_id: str - student_id: str - locale: str = "de-DE" - difficulty: str = "base" - - -class SessionResponse(BaseModel): - """Response after creating a session""" - session_id: str - unit_definition_url: str - session_token: str - telemetry_endpoint: str - expires_at: datetime - - -class TelemetryEvent(BaseModel): - """Single telemetry event""" - ts: Optional[str] = None - type: str = Field(..., alias="type") - stop_id: Optional[str] = None - metrics: Optional[Dict[str, Any]] = None - - class Config: - populate_by_name = True - - -class TelemetryPayload(BaseModel): - """Batch telemetry payload""" - session_id: str - events: List[TelemetryEvent] - - -class TelemetryResponse(BaseModel): - """Response after receiving telemetry""" - accepted: int - - -class PostcheckAnswer(BaseModel): - """Single postcheck answer""" - question_id: str - answer: str - - -class CompleteSessionRequest(BaseModel): - """Request to complete a session""" - postcheck_answers: Optional[List[PostcheckAnswer]] = None - - -class SessionSummaryResponse(BaseModel): - """Response with session summary""" - summary: Dict[str, Any] - next_recommendations: Dict[str, Any] - - -class UnitListItem(BaseModel): - """Unit list item""" - unit_id: str - template: str - difficulty: str - duration_minutes: int - locale: List[str] - grade_band: List[str] - - -class RecommendedUnit(BaseModel): - """Recommended unit with reason""" - unit_id: str - template: str - difficulty: str - reason: str - - -class CreateUnitRequest(BaseModel): - """Request to create a new unit definition""" - unit_id: str = Field(..., description="Unique unit identifier") - template: str = Field(..., description="Template type: flight_path or station_loop") - version: str = Field(default="1.0.0", description="Version string") - locale: List[str] = Field(default=["de-DE"], description="Supported locales") - grade_band: List[str] = Field(default=["5", "6", "7"], description="Target grade levels") - duration_minutes: int = Field(default=8, ge=3, le=20, description="Expected duration") - difficulty: str = Field(default="base", description="Difficulty level: base or advanced") - subject: Optional[str] = Field(default=None, description="Subject area") - topic: Optional[str] = Field(default=None, description="Topic within subject") - learning_objectives: List[str] = Field(default=[], description="Learning objectives") - stops: List[Dict[str, Any]] = Field(default=[], description="Unit stops/stations") - precheck: Optional[Dict[str, Any]] = Field(default=None, description="Pre-check configuration") - postcheck: Optional[Dict[str, Any]] = Field(default=None, description="Post-check configuration") - teacher_controls: Optional[Dict[str, Any]] = Field(default=None, description="Teacher control settings") - assets: Optional[Dict[str, Any]] = Field(default=None, description="Asset configuration") - metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata") - status: str = Field(default="draft", description="Publication status: draft or published") - - -class UpdateUnitRequest(BaseModel): - """Request to update an existing unit definition""" - version: Optional[str] = None - locale: Optional[List[str]] = None - grade_band: Optional[List[str]] = None - duration_minutes: Optional[int] = Field(default=None, ge=3, le=20) - difficulty: Optional[str] = None - subject: Optional[str] = None - topic: Optional[str] = None - learning_objectives: Optional[List[str]] = None - stops: Optional[List[Dict[str, Any]]] = None - precheck: Optional[Dict[str, Any]] = None - postcheck: Optional[Dict[str, Any]] = None - teacher_controls: Optional[Dict[str, Any]] = None - assets: Optional[Dict[str, Any]] = None - metadata: Optional[Dict[str, Any]] = None - status: Optional[str] = None - - -class ValidationError(BaseModel): - """Single validation error""" - field: str - message: str - severity: str = "error" # error or warning - - -class ValidationResult(BaseModel): - """Result of unit validation""" - valid: bool - errors: List[ValidationError] = [] - warnings: List[ValidationError] = [] - - -# ============================================== -# Database Integration -# ============================================== - -_unit_db = None - -async def get_unit_database(): - """Get unit database instance with lazy initialization.""" - global _unit_db - if not USE_DATABASE: - return None - if _unit_db is None: - try: - from unit.database import get_unit_db - _unit_db = await get_unit_db() - logger.info("Unit database initialized") - except ImportError: - logger.warning("Unit database module not available") - except Exception as e: - logger.warning(f"Unit database not available: {e}") - return _unit_db - - -# ============================================== -# Helper Functions -# ============================================== - -def create_session_token(session_id: str, student_id: str, expires_hours: int = 4) -> str: - """Create a JWT session token for telemetry authentication.""" - payload = { - "session_id": session_id, - "student_id": student_id, - "exp": datetime.utcnow() + timedelta(hours=expires_hours), - "iat": datetime.utcnow(), - } - return jwt.encode(payload, SECRET_KEY, algorithm="HS256") - - -def verify_session_token(token: str) -> Optional[Dict[str, Any]]: - """Verify a session token and return payload.""" - try: - return jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) - except jwt.ExpiredSignatureError: - return None - except jwt.InvalidTokenError: - return None - - -async def get_session_from_token(request: Request) -> Optional[Dict[str, Any]]: - """Extract and verify session from Authorization header.""" - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - return None - token = auth_header[7:] - return verify_session_token(token) - - -def validate_unit_definition(unit_data: Dict[str, Any]) -> ValidationResult: - """ - Validate a unit definition structure. - - Returns validation result with errors and warnings. - """ - errors = [] - warnings = [] - - # Required fields - if not unit_data.get("unit_id"): - errors.append(ValidationError(field="unit_id", message="unit_id ist erforderlich")) - - if not unit_data.get("template"): - errors.append(ValidationError(field="template", message="template ist erforderlich")) - elif unit_data["template"] not in ["flight_path", "station_loop"]: - errors.append(ValidationError( - field="template", - message="template muss 'flight_path' oder 'station_loop' sein" - )) - - # Validate stops - stops = unit_data.get("stops", []) - if not stops: - errors.append(ValidationError(field="stops", message="Mindestens 1 Stop erforderlich")) - else: - # Check minimum stops for flight_path - if unit_data.get("template") == "flight_path" and len(stops) < 3: - warnings.append(ValidationError( - field="stops", - message="FlightPath sollte mindestens 3 Stops haben", - severity="warning" - )) - - # Validate each stop - stop_ids = set() - for i, stop in enumerate(stops): - if not stop.get("stop_id"): - errors.append(ValidationError( - field=f"stops[{i}].stop_id", - message=f"Stop {i}: stop_id fehlt" - )) - else: - if stop["stop_id"] in stop_ids: - errors.append(ValidationError( - field=f"stops[{i}].stop_id", - message=f"Stop {i}: Doppelte stop_id '{stop['stop_id']}'" - )) - stop_ids.add(stop["stop_id"]) - - # Check interaction type - interaction = stop.get("interaction", {}) - if not interaction.get("type"): - errors.append(ValidationError( - field=f"stops[{i}].interaction.type", - message=f"Stop {stop.get('stop_id', i)}: Interaktionstyp fehlt" - )) - elif interaction["type"] not in [ - "aim_and_pass", "slider_adjust", "slider_equivalence", - "sequence_arrange", "toggle_switch", "drag_match", - "error_find", "transfer_apply" - ]: - warnings.append(ValidationError( - field=f"stops[{i}].interaction.type", - message=f"Stop {stop.get('stop_id', i)}: Unbekannter Interaktionstyp '{interaction['type']}'", - severity="warning" - )) - - # Check for label - if not stop.get("label"): - warnings.append(ValidationError( - field=f"stops[{i}].label", - message=f"Stop {stop.get('stop_id', i)}: Label fehlt", - severity="warning" - )) - - # Validate duration - duration = unit_data.get("duration_minutes", 0) - if duration < 3 or duration > 20: - warnings.append(ValidationError( - field="duration_minutes", - message="Dauer sollte zwischen 3 und 20 Minuten liegen", - severity="warning" - )) - - # Validate difficulty - if unit_data.get("difficulty") and unit_data["difficulty"] not in ["base", "advanced"]: - warnings.append(ValidationError( - field="difficulty", - message="difficulty sollte 'base' oder 'advanced' sein", - severity="warning" - )) - - return ValidationResult( - valid=len(errors) == 0, - errors=errors, - warnings=warnings - ) - - -# ============================================== -# API Endpoints -# ============================================== - -@router.get("/definitions", response_model=List[UnitListItem]) -async def list_unit_definitions( - template: Optional[str] = Query(None, description="Filter by template: flight_path, station_loop"), - grade: Optional[str] = Query(None, description="Filter by grade level"), - locale: str = Query("de-DE", description="Filter by locale"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> List[UnitListItem]: - """ - List available unit definitions. - - Returns published units matching the filter criteria. - """ - db = await get_unit_database() - if db: - try: - units = await db.list_units( - template=template, - grade=grade, - locale=locale, - published_only=True - ) - return [ - UnitListItem( - unit_id=u["unit_id"], - template=u["template"], - difficulty=u["difficulty"], - duration_minutes=u["duration_minutes"], - locale=u["locale"], - grade_band=u["grade_band"], - ) - for u in units - ] - except Exception as e: - logger.error(f"Failed to list units: {e}") - - # Fallback: return demo unit - return [ - UnitListItem( - unit_id="demo_unit_v1", - template="flight_path", - difficulty="base", - duration_minutes=5, - locale=["de-DE"], - grade_band=["5", "6", "7"], - ) - ] - - -@router.get("/definitions/{unit_id}", response_model=UnitDefinitionResponse) -async def get_unit_definition( - unit_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> UnitDefinitionResponse: - """ - Get a specific unit definition. - - Returns the full unit configuration including stops, interactions, etc. - """ - db = await get_unit_database() - if db: - try: - unit = await db.get_unit_definition(unit_id) - if unit: - return UnitDefinitionResponse( - unit_id=unit["unit_id"], - template=unit["template"], - version=unit["version"], - locale=unit["locale"], - grade_band=unit["grade_band"], - duration_minutes=unit["duration_minutes"], - difficulty=unit["difficulty"], - definition=unit["definition"], - ) - except Exception as e: - logger.error(f"Failed to get unit definition: {e}") - - # Demo unit fallback - if unit_id == "demo_unit_v1": - return UnitDefinitionResponse( - unit_id="demo_unit_v1", - template="flight_path", - version="1.0.0", - locale=["de-DE"], - grade_band=["5", "6", "7"], - duration_minutes=5, - difficulty="base", - definition={ - "unit_id": "demo_unit_v1", - "template": "flight_path", - "version": "1.0.0", - "learning_objectives": ["Demo: Grundfunktion testen"], - "stops": [ - {"stop_id": "stop_1", "label": {"de-DE": "Start"}, "interaction": {"type": "aim_and_pass"}}, - {"stop_id": "stop_2", "label": {"de-DE": "Mitte"}, "interaction": {"type": "aim_and_pass"}}, - {"stop_id": "stop_3", "label": {"de-DE": "Ende"}, "interaction": {"type": "aim_and_pass"}}, - ], - "teacher_controls": {"allow_skip": True, "allow_replay": True}, - }, - ) - - raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") - - -@router.post("/definitions", response_model=UnitDefinitionResponse) -async def create_unit_definition( - request_data: CreateUnitRequest, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> UnitDefinitionResponse: - """ - Create a new unit definition. - - - Validates unit structure - - Saves to database or JSON file - - Returns created unit - """ - import json - from pathlib import Path - - # Build full definition - definition = { - "unit_id": request_data.unit_id, - "template": request_data.template, - "version": request_data.version, - "locale": request_data.locale, - "grade_band": request_data.grade_band, - "duration_minutes": request_data.duration_minutes, - "difficulty": request_data.difficulty, - "subject": request_data.subject, - "topic": request_data.topic, - "learning_objectives": request_data.learning_objectives, - "stops": request_data.stops, - "precheck": request_data.precheck or { - "question_set_id": f"{request_data.unit_id}_precheck", - "required": True, - "time_limit_seconds": 120 - }, - "postcheck": request_data.postcheck or { - "question_set_id": f"{request_data.unit_id}_postcheck", - "required": True, - "time_limit_seconds": 180 - }, - "teacher_controls": request_data.teacher_controls or { - "allow_skip": True, - "allow_replay": True, - "max_time_per_stop_sec": 90, - "show_hints": True, - "require_precheck": True, - "require_postcheck": True - }, - "assets": request_data.assets or {}, - "metadata": request_data.metadata or { - "author": user.get("email", "Unknown") if user else "Unknown", - "created": datetime.utcnow().isoformat(), - "curriculum_reference": "" - } - } - - # Validate - validation = validate_unit_definition(definition) - if not validation.valid: - error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] - raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") - - # Check if unit_id already exists - db = await get_unit_database() - if db: - try: - existing = await db.get_unit_definition(request_data.unit_id) - if existing: - raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") - - # Save to database - await db.create_unit_definition( - unit_id=request_data.unit_id, - template=request_data.template, - version=request_data.version, - locale=request_data.locale, - grade_band=request_data.grade_band, - duration_minutes=request_data.duration_minutes, - difficulty=request_data.difficulty, - definition=definition, - status=request_data.status - ) - logger.info(f"Unit created in database: {request_data.unit_id}") - except HTTPException: - raise - except Exception as e: - logger.warning(f"Database save failed, using JSON fallback: {e}") - # Fallback to JSON - units_dir = Path(__file__).parent / "data" / "units" - units_dir.mkdir(parents=True, exist_ok=True) - json_path = units_dir / f"{request_data.unit_id}.json" - if json_path.exists(): - raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") - with open(json_path, "w", encoding="utf-8") as f: - json.dump(definition, f, ensure_ascii=False, indent=2) - logger.info(f"Unit created as JSON: {json_path}") - else: - # JSON only mode - units_dir = Path(__file__).parent / "data" / "units" - units_dir.mkdir(parents=True, exist_ok=True) - json_path = units_dir / f"{request_data.unit_id}.json" - if json_path.exists(): - raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") - with open(json_path, "w", encoding="utf-8") as f: - json.dump(definition, f, ensure_ascii=False, indent=2) - logger.info(f"Unit created as JSON: {json_path}") - - return UnitDefinitionResponse( - unit_id=request_data.unit_id, - template=request_data.template, - version=request_data.version, - locale=request_data.locale, - grade_band=request_data.grade_band, - duration_minutes=request_data.duration_minutes, - difficulty=request_data.difficulty, - definition=definition - ) - - -@router.put("/definitions/{unit_id}", response_model=UnitDefinitionResponse) -async def update_unit_definition( - unit_id: str, - request_data: UpdateUnitRequest, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> UnitDefinitionResponse: - """ - Update an existing unit definition. - - - Merges updates with existing definition - - Re-validates - - Saves updated version - """ - import json - from pathlib import Path - - # Get existing unit - db = await get_unit_database() - existing = None - - if db: - try: - existing = await db.get_unit_definition(unit_id) - except Exception as e: - logger.warning(f"Database read failed: {e}") - - if not existing: - # Try JSON file - json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" - if json_path.exists(): - with open(json_path, "r", encoding="utf-8") as f: - file_data = json.load(f) - existing = { - "unit_id": file_data.get("unit_id"), - "template": file_data.get("template"), - "version": file_data.get("version", "1.0.0"), - "locale": file_data.get("locale", ["de-DE"]), - "grade_band": file_data.get("grade_band", []), - "duration_minutes": file_data.get("duration_minutes", 8), - "difficulty": file_data.get("difficulty", "base"), - "definition": file_data - } - - if not existing: - raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") - - # Merge updates into existing definition - definition = existing.get("definition", {}) - update_dict = request_data.model_dump(exclude_unset=True) - - for key, value in update_dict.items(): - if value is not None: - definition[key] = value - - # Validate updated definition - validation = validate_unit_definition(definition) - if not validation.valid: - error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] - raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") - - # Save - if db: - try: - await db.update_unit_definition( - unit_id=unit_id, - version=definition.get("version"), - locale=definition.get("locale"), - grade_band=definition.get("grade_band"), - duration_minutes=definition.get("duration_minutes"), - difficulty=definition.get("difficulty"), - definition=definition, - status=update_dict.get("status") - ) - logger.info(f"Unit updated in database: {unit_id}") - except Exception as e: - logger.warning(f"Database update failed, using JSON: {e}") - json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" - with open(json_path, "w", encoding="utf-8") as f: - json.dump(definition, f, ensure_ascii=False, indent=2) - else: - json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" - with open(json_path, "w", encoding="utf-8") as f: - json.dump(definition, f, ensure_ascii=False, indent=2) - logger.info(f"Unit updated as JSON: {json_path}") - - return UnitDefinitionResponse( - unit_id=unit_id, - template=definition.get("template", existing.get("template")), - version=definition.get("version", existing.get("version", "1.0.0")), - locale=definition.get("locale", existing.get("locale", ["de-DE"])), - grade_band=definition.get("grade_band", existing.get("grade_band", [])), - duration_minutes=definition.get("duration_minutes", existing.get("duration_minutes", 8)), - difficulty=definition.get("difficulty", existing.get("difficulty", "base")), - definition=definition - ) - - -@router.delete("/definitions/{unit_id}") -async def delete_unit_definition( - unit_id: str, - force: bool = Query(False, description="Force delete even if published"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Delete a unit definition. - - - By default, only drafts can be deleted - - Use force=true to delete published units - """ - import json - from pathlib import Path - - db = await get_unit_database() - deleted = False - - if db: - try: - existing = await db.get_unit_definition(unit_id) - if existing: - status = existing.get("status", "draft") - if status == "published" and not force: - raise HTTPException( - status_code=400, - detail="Veroeffentlichte Units koennen nicht geloescht werden. Verwende force=true." - ) - await db.delete_unit_definition(unit_id) - deleted = True - logger.info(f"Unit deleted from database: {unit_id}") - except HTTPException: - raise - except Exception as e: - logger.warning(f"Database delete failed: {e}") - - # Also check JSON file - json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" - if json_path.exists(): - json_path.unlink() - deleted = True - logger.info(f"Unit JSON deleted: {json_path}") - - if not deleted: - raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") - - return {"success": True, "unit_id": unit_id, "message": "Unit geloescht"} - - -@router.post("/definitions/validate", response_model=ValidationResult) -async def validate_unit( - unit_data: Dict[str, Any], - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> ValidationResult: - """ - Validate a unit definition without saving. - - Returns validation result with errors and warnings. - """ - return validate_unit_definition(unit_data) - - -@router.post("/sessions", response_model=SessionResponse) -async def create_unit_session( - request_data: CreateSessionRequest, - request: Request, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> SessionResponse: - """ - Create a new unit session. - - - Validates unit exists - - Creates session record - - Returns session token for telemetry - """ - session_id = str(uuid.uuid4()) - expires_at = datetime.utcnow() + timedelta(hours=4) - - # Validate unit exists - db = await get_unit_database() - if db: - try: - unit = await db.get_unit_definition(request_data.unit_id) - if not unit: - raise HTTPException(status_code=404, detail=f"Unit not found: {request_data.unit_id}") - - # Create session in database - total_stops = len(unit.get("definition", {}).get("stops", [])) - await db.create_session( - session_id=session_id, - unit_id=request_data.unit_id, - student_id=request_data.student_id, - locale=request_data.locale, - difficulty=request_data.difficulty, - total_stops=total_stops, - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to create session: {e}") - # Continue with in-memory fallback - - # Create session token - session_token = create_session_token(session_id, request_data.student_id) - - # Build definition URL - base_url = str(request.base_url).rstrip("/") - definition_url = f"{base_url}/api/units/definitions/{request_data.unit_id}" - - return SessionResponse( - session_id=session_id, - unit_definition_url=definition_url, - session_token=session_token, - telemetry_endpoint="/api/units/telemetry", - expires_at=expires_at, - ) - - -@router.post("/telemetry", response_model=TelemetryResponse) -async def receive_telemetry( - payload: TelemetryPayload, - request: Request, -) -> TelemetryResponse: - """ - Receive batched telemetry events from Unity client. - - - Validates session token - - Stores events in database - - Returns count of accepted events - """ - # Verify session token - session_data = await get_session_from_token(request) - if session_data is None: - # Allow without auth in dev mode - if REQUIRE_AUTH: - raise HTTPException(status_code=401, detail="Invalid or expired session token") - logger.warning("Telemetry received without valid token (dev mode)") - - # Verify session_id matches - if session_data and session_data.get("session_id") != payload.session_id: - raise HTTPException(status_code=403, detail="Session ID mismatch") - - accepted = 0 - db = await get_unit_database() - - for event in payload.events: - try: - # Set timestamp if not provided - timestamp = event.ts or datetime.utcnow().isoformat() - - if db: - await db.store_telemetry_event( - session_id=payload.session_id, - event_type=event.type, - stop_id=event.stop_id, - timestamp=timestamp, - metrics=event.metrics, - ) - - accepted += 1 - logger.debug(f"Telemetry: {event.type} for session {payload.session_id}") - - except Exception as e: - logger.error(f"Failed to store telemetry event: {e}") - - return TelemetryResponse(accepted=accepted) - - -@router.post("/sessions/{session_id}/complete", response_model=SessionSummaryResponse) -async def complete_session( - session_id: str, - request_data: CompleteSessionRequest, - request: Request, -) -> SessionSummaryResponse: - """ - Complete a unit session. - - - Processes postcheck answers if provided - - Calculates learning gain - - Returns summary and recommendations - """ - # Verify session token - session_data = await get_session_from_token(request) - if REQUIRE_AUTH and session_data is None: - raise HTTPException(status_code=401, detail="Invalid or expired session token") - - db = await get_unit_database() - summary = {} - recommendations = {} - - if db: - try: - # Get session data - session = await db.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - # Calculate postcheck score if answers provided - postcheck_score = None - if request_data.postcheck_answers: - # Simple scoring: count correct answers - # In production, would validate against question bank - postcheck_score = len(request_data.postcheck_answers) * 0.2 # Placeholder - postcheck_score = min(postcheck_score, 1.0) - - # Complete session in database - await db.complete_session( - session_id=session_id, - postcheck_score=postcheck_score, - ) - - # Get updated session summary - session = await db.get_session(session_id) - - # Calculate learning gain - pre_score = session.get("precheck_score") - post_score = session.get("postcheck_score") - learning_gain = None - if pre_score is not None and post_score is not None: - learning_gain = post_score - pre_score - - summary = { - "session_id": session_id, - "unit_id": session.get("unit_id"), - "duration_seconds": session.get("duration_seconds"), - "completion_rate": session.get("completion_rate"), - "precheck_score": pre_score, - "postcheck_score": post_score, - "pre_to_post_gain": learning_gain, - "stops_completed": session.get("stops_completed"), - "total_stops": session.get("total_stops"), - } - - # Get recommendations - recommendations = await db.get_recommendations( - student_id=session.get("student_id"), - completed_unit_id=session.get("unit_id"), - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to complete session: {e}") - summary = {"session_id": session_id, "error": str(e)} - - else: - # Fallback summary - summary = { - "session_id": session_id, - "duration_seconds": 0, - "completion_rate": 1.0, - "message": "Database not available", - } - - return SessionSummaryResponse( - summary=summary, - next_recommendations=recommendations or { - "h5p_activity_ids": [], - "worksheet_pdf_url": None, - }, - ) - - -@router.get("/sessions/{session_id}") -async def get_session( - session_id: str, - request: Request, -) -> Dict[str, Any]: - """ - Get session details. - - Returns current state of a session including progress. - """ - # Verify session token - session_data = await get_session_from_token(request) - if REQUIRE_AUTH and session_data is None: - raise HTTPException(status_code=401, detail="Invalid or expired session token") - - db = await get_unit_database() - if db: - try: - session = await db.get_session(session_id) - if session: - return session - except Exception as e: - logger.error(f"Failed to get session: {e}") - - raise HTTPException(status_code=404, detail="Session not found") - - -@router.get("/recommendations/{student_id}", response_model=List[RecommendedUnit]) -async def get_recommendations( - student_id: str, - grade: Optional[str] = Query(None, description="Grade level filter"), - locale: str = Query("de-DE", description="Locale filter"), - limit: int = Query(5, ge=1, le=20), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> List[RecommendedUnit]: - """ - Get recommended units for a student. - - Based on completion status and performance. - """ - db = await get_unit_database() - if db: - try: - recommendations = await db.get_student_recommendations( - student_id=student_id, - grade=grade, - locale=locale, - limit=limit, - ) - return [ - RecommendedUnit( - unit_id=r["unit_id"], - template=r["template"], - difficulty=r["difficulty"], - reason=r["reason"], - ) - for r in recommendations - ] - except Exception as e: - logger.error(f"Failed to get recommendations: {e}") - - # Fallback: recommend demo unit - return [ - RecommendedUnit( - unit_id="demo_unit_v1", - template="flight_path", - difficulty="base", - reason="Neu: Noch nicht gespielt", - ) - ] - - -@router.get("/analytics/student/{student_id}") -async def get_student_analytics( - student_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Get unit analytics for a student. - - Includes completion rates, learning gains, time spent. - """ - db = await get_unit_database() - if db: - try: - analytics = await db.get_student_unit_analytics(student_id) - return analytics - except Exception as e: - logger.error(f"Failed to get analytics: {e}") - - return { - "student_id": student_id, - "units_attempted": 0, - "units_completed": 0, - "avg_completion_rate": 0.0, - "avg_learning_gain": None, - "total_minutes": 0, - } - - -@router.get("/analytics/unit/{unit_id}") -async def get_unit_analytics( - unit_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Get analytics for a specific unit. - - Shows aggregate performance across all students. - """ - db = await get_unit_database() - if db: - try: - analytics = await db.get_unit_performance(unit_id) - return analytics - except Exception as e: - logger.error(f"Failed to get unit analytics: {e}") - - return { - "unit_id": unit_id, - "total_sessions": 0, - "completed_sessions": 0, - "completion_percent": 0.0, - "avg_duration_minutes": 0, - "avg_learning_gain": None, - } - - -# ============================================== -# Content Generation Endpoints -# ============================================== - -@router.get("/content/{unit_id}/h5p") -async def generate_h5p_content( - unit_id: str, - locale: str = Query("de-DE", description="Target locale"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Generate H5P content items for a unit. - - Returns H5P-compatible content structures for: - - Drag and Drop (vocabulary matching) - - Fill in the Blanks (concept texts) - - Multiple Choice (misconception targeting) - """ - from content_generators import generate_h5p_for_unit, H5PGenerator, generate_h5p_manifest - - # Get unit definition - db = await get_unit_database() - unit_def = None - - if db: - try: - unit = await db.get_unit_definition(unit_id) - if unit: - unit_def = unit.get("definition", {}) - except Exception as e: - logger.error(f"Failed to get unit for H5P generation: {e}") - - if not unit_def: - raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") - - try: - generator = H5PGenerator(locale=locale) - contents = generator.generate_from_unit(unit_def) - manifest = generate_h5p_manifest(contents, unit_id) - - return { - "unit_id": unit_id, - "locale": locale, - "generated_count": len(contents), - "manifest": manifest, - "contents": [c.to_h5p_structure() for c in contents] - } - except Exception as e: - logger.error(f"H5P generation failed: {e}") - raise HTTPException(status_code=500, detail=f"H5P generation failed: {str(e)}") - - -@router.get("/content/{unit_id}/worksheet") -async def generate_worksheet_html( - unit_id: str, - locale: str = Query("de-DE", description="Target locale"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Generate worksheet HTML for a unit. - - Returns HTML that can be: - - Displayed in browser - - Converted to PDF using weasyprint - - Printed directly - """ - from content_generators import PDFGenerator - - # Get unit definition - db = await get_unit_database() - unit_def = None - - if db: - try: - unit = await db.get_unit_definition(unit_id) - if unit: - unit_def = unit.get("definition", {}) - except Exception as e: - logger.error(f"Failed to get unit for worksheet generation: {e}") - - if not unit_def: - raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") - - try: - generator = PDFGenerator(locale=locale) - worksheet = generator.generate_from_unit(unit_def) - - return { - "unit_id": unit_id, - "locale": locale, - "title": worksheet.title, - "sections": len(worksheet.sections), - "html": worksheet.to_html() - } - except Exception as e: - logger.error(f"Worksheet generation failed: {e}") - raise HTTPException(status_code=500, detail=f"Worksheet generation failed: {str(e)}") - - -@router.get("/content/{unit_id}/worksheet.pdf") -async def download_worksheet_pdf( - unit_id: str, - locale: str = Query("de-DE", description="Target locale"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -): - """ - Generate and download worksheet as PDF. - - Requires weasyprint to be installed on the server. - """ - from fastapi.responses import Response - - # Get unit definition - db = await get_unit_database() - unit_def = None - - if db: - try: - unit = await db.get_unit_definition(unit_id) - if unit: - unit_def = unit.get("definition", {}) - except Exception as e: - logger.error(f"Failed to get unit for PDF generation: {e}") - - if not unit_def: - raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") - - try: - from content_generators import generate_worksheet_pdf - pdf_bytes = generate_worksheet_pdf(unit_def, locale) - - return Response( - content=pdf_bytes, - media_type="application/pdf", - headers={ - "Content-Disposition": f'attachment; filename="{unit_id}_worksheet.pdf"' - } - ) - except ImportError: - raise HTTPException( - status_code=501, - detail="PDF generation not available. Install weasyprint: pip install weasyprint" - ) - except Exception as e: - logger.error(f"PDF generation failed: {e}") - raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}") - - -@router.get("/health") -async def health_check() -> Dict[str, Any]: - """Health check for unit API.""" - db = await get_unit_database() - db_status = "connected" if db else "disconnected" - - return { - "status": "healthy", - "service": "breakpilot-units", - "database": db_status, - "auth_required": REQUIRE_AUTH, - } +# The `router` object is assembled here by including all sub-routers. +# Importers that did `from unit_api import router` continue to work. + +from fastapi import APIRouter + +from unit_routes import router as _routes_router +from unit_definition_routes import router as _definition_router +from unit_content_routes import router as _content_router + +# Re-export models for any direct importers +from unit_models import ( # noqa: F401 + UnitDefinitionResponse, + CreateSessionRequest, + SessionResponse, + TelemetryEvent, + TelemetryPayload, + TelemetryResponse, + PostcheckAnswer, + CompleteSessionRequest, + SessionSummaryResponse, + UnitListItem, + RecommendedUnit, + CreateUnitRequest, + UpdateUnitRequest, + ValidationError, + ValidationResult, +) + +# Re-export helpers for any direct importers +from unit_helpers import ( # noqa: F401 + get_optional_current_user, + get_unit_database, + create_session_token, + verify_session_token, + get_session_from_token, + validate_unit_definition, + USE_DATABASE, + REQUIRE_AUTH, + SECRET_KEY, +) + +# Assemble the combined router. +# _routes_router and _content_router both use prefix="/api/units", +# so we create a plain router and include them without extra prefix. +router = APIRouter() +router.include_router(_routes_router) +router.include_router(_definition_router) +router.include_router(_content_router) diff --git a/backend-lehrer/unit_content_routes.py b/backend-lehrer/unit_content_routes.py new file mode 100644 index 0000000..dcf745d --- /dev/null +++ b/backend-lehrer/unit_content_routes.py @@ -0,0 +1,160 @@ +# ============================================== +# Breakpilot Drive - Unit Content Generation Routes +# ============================================== +# API endpoints for H5P content, worksheets, and PDF generation. +# Extracted from unit_api.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends +from typing import Optional, Dict, Any +import logging + +from unit_models import UnitDefinitionResponse +from unit_helpers import get_optional_current_user, get_unit_database + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) + + +@router.get("/content/{unit_id}/h5p") +async def generate_h5p_content( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Generate H5P content items for a unit. + + Returns H5P-compatible content structures for: + - Drag and Drop (vocabulary matching) + - Fill in the Blanks (concept texts) + - Multiple Choice (misconception targeting) + """ + from content_generators import generate_h5p_for_unit, H5PGenerator, generate_h5p_manifest + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for H5P generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + generator = H5PGenerator(locale=locale) + contents = generator.generate_from_unit(unit_def) + manifest = generate_h5p_manifest(contents, unit_id) + + return { + "unit_id": unit_id, + "locale": locale, + "generated_count": len(contents), + "manifest": manifest, + "contents": [c.to_h5p_structure() for c in contents] + } + except Exception as e: + logger.error(f"H5P generation failed: {e}") + raise HTTPException(status_code=500, detail=f"H5P generation failed: {str(e)}") + + +@router.get("/content/{unit_id}/worksheet") +async def generate_worksheet_html( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Generate worksheet HTML for a unit. + + Returns HTML that can be: + - Displayed in browser + - Converted to PDF using weasyprint + - Printed directly + """ + from content_generators import PDFGenerator + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for worksheet generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + generator = PDFGenerator(locale=locale) + worksheet = generator.generate_from_unit(unit_def) + + return { + "unit_id": unit_id, + "locale": locale, + "title": worksheet.title, + "sections": len(worksheet.sections), + "html": worksheet.to_html() + } + except Exception as e: + logger.error(f"Worksheet generation failed: {e}") + raise HTTPException(status_code=500, detail=f"Worksheet generation failed: {str(e)}") + + +@router.get("/content/{unit_id}/worksheet.pdf") +async def download_worksheet_pdf( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +): + """ + Generate and download worksheet as PDF. + + Requires weasyprint to be installed on the server. + """ + from fastapi.responses import Response + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for PDF generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + from content_generators import generate_worksheet_pdf + pdf_bytes = generate_worksheet_pdf(unit_def, locale) + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="{unit_id}_worksheet.pdf"' + } + ) + except ImportError: + raise HTTPException( + status_code=501, + detail="PDF generation not available. Install weasyprint: pip install weasyprint" + ) + except Exception as e: + logger.error(f"PDF generation failed: {e}") + raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}") diff --git a/backend-lehrer/unit_definition_routes.py b/backend-lehrer/unit_definition_routes.py new file mode 100644 index 0000000..f8d875d --- /dev/null +++ b/backend-lehrer/unit_definition_routes.py @@ -0,0 +1,301 @@ +# ============================================== +# Breakpilot Drive - Unit Definition CRUD Routes +# ============================================== +# Endpoints for creating, updating, deleting, and validating +# unit definitions. Extracted from unit_routes.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends +from typing import Optional, Dict, Any +from datetime import datetime +import logging + +from unit_models import ( + UnitDefinitionResponse, + CreateUnitRequest, + UpdateUnitRequest, + ValidationResult, +) +from unit_helpers import ( + get_optional_current_user, + get_unit_database, + validate_unit_definition, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) + + +@router.post("/definitions", response_model=UnitDefinitionResponse) +async def create_unit_definition( + request_data: CreateUnitRequest, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Create a new unit definition. + + - Validates unit structure + - Saves to database or JSON file + - Returns created unit + """ + import json + from pathlib import Path + + # Build full definition + definition = { + "unit_id": request_data.unit_id, + "template": request_data.template, + "version": request_data.version, + "locale": request_data.locale, + "grade_band": request_data.grade_band, + "duration_minutes": request_data.duration_minutes, + "difficulty": request_data.difficulty, + "subject": request_data.subject, + "topic": request_data.topic, + "learning_objectives": request_data.learning_objectives, + "stops": request_data.stops, + "precheck": request_data.precheck or { + "question_set_id": f"{request_data.unit_id}_precheck", + "required": True, + "time_limit_seconds": 120 + }, + "postcheck": request_data.postcheck or { + "question_set_id": f"{request_data.unit_id}_postcheck", + "required": True, + "time_limit_seconds": 180 + }, + "teacher_controls": request_data.teacher_controls or { + "allow_skip": True, + "allow_replay": True, + "max_time_per_stop_sec": 90, + "show_hints": True, + "require_precheck": True, + "require_postcheck": True + }, + "assets": request_data.assets or {}, + "metadata": request_data.metadata or { + "author": user.get("email", "Unknown") if user else "Unknown", + "created": datetime.utcnow().isoformat(), + "curriculum_reference": "" + } + } + + # Validate + validation = validate_unit_definition(definition) + if not validation.valid: + error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] + raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") + + # Check if unit_id already exists + db = await get_unit_database() + if db: + try: + existing = await db.get_unit_definition(request_data.unit_id) + if existing: + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + + # Save to database + await db.create_unit_definition( + unit_id=request_data.unit_id, + template=request_data.template, + version=request_data.version, + locale=request_data.locale, + grade_band=request_data.grade_band, + duration_minutes=request_data.duration_minutes, + difficulty=request_data.difficulty, + definition=definition, + status=request_data.status + ) + logger.info(f"Unit created in database: {request_data.unit_id}") + except HTTPException: + raise + except Exception as e: + logger.warning(f"Database save failed, using JSON fallback: {e}") + # Fallback to JSON + units_dir = Path(__file__).parent / "data" / "units" + units_dir.mkdir(parents=True, exist_ok=True) + json_path = units_dir / f"{request_data.unit_id}.json" + if json_path.exists(): + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit created as JSON: {json_path}") + else: + # JSON only mode + units_dir = Path(__file__).parent / "data" / "units" + units_dir.mkdir(parents=True, exist_ok=True) + json_path = units_dir / f"{request_data.unit_id}.json" + if json_path.exists(): + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit created as JSON: {json_path}") + + return UnitDefinitionResponse( + unit_id=request_data.unit_id, + template=request_data.template, + version=request_data.version, + locale=request_data.locale, + grade_band=request_data.grade_band, + duration_minutes=request_data.duration_minutes, + difficulty=request_data.difficulty, + definition=definition + ) + + +@router.put("/definitions/{unit_id}", response_model=UnitDefinitionResponse) +async def update_unit_definition( + unit_id: str, + request_data: UpdateUnitRequest, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Update an existing unit definition. + + - Merges updates with existing definition + - Re-validates + - Saves updated version + """ + import json + from pathlib import Path + + # Get existing unit + db = await get_unit_database() + existing = None + + if db: + try: + existing = await db.get_unit_definition(unit_id) + except Exception as e: + logger.warning(f"Database read failed: {e}") + + if not existing: + # Try JSON file + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + if json_path.exists(): + with open(json_path, "r", encoding="utf-8") as f: + file_data = json.load(f) + existing = { + "unit_id": file_data.get("unit_id"), + "template": file_data.get("template"), + "version": file_data.get("version", "1.0.0"), + "locale": file_data.get("locale", ["de-DE"]), + "grade_band": file_data.get("grade_band", []), + "duration_minutes": file_data.get("duration_minutes", 8), + "difficulty": file_data.get("difficulty", "base"), + "definition": file_data + } + + if not existing: + raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") + + # Merge updates into existing definition + definition = existing.get("definition", {}) + update_dict = request_data.model_dump(exclude_unset=True) + + for key, value in update_dict.items(): + if value is not None: + definition[key] = value + + # Validate updated definition + validation = validate_unit_definition(definition) + if not validation.valid: + error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] + raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") + + # Save + if db: + try: + await db.update_unit_definition( + unit_id=unit_id, + version=definition.get("version"), + locale=definition.get("locale"), + grade_band=definition.get("grade_band"), + duration_minutes=definition.get("duration_minutes"), + difficulty=definition.get("difficulty"), + definition=definition, + status=update_dict.get("status") + ) + logger.info(f"Unit updated in database: {unit_id}") + except Exception as e: + logger.warning(f"Database update failed, using JSON: {e}") + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + else: + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit updated as JSON: {json_path}") + + return UnitDefinitionResponse( + unit_id=unit_id, + template=definition.get("template", existing.get("template")), + version=definition.get("version", existing.get("version", "1.0.0")), + locale=definition.get("locale", existing.get("locale", ["de-DE"])), + grade_band=definition.get("grade_band", existing.get("grade_band", [])), + duration_minutes=definition.get("duration_minutes", existing.get("duration_minutes", 8)), + difficulty=definition.get("difficulty", existing.get("difficulty", "base")), + definition=definition + ) + + +@router.delete("/definitions/{unit_id}") +async def delete_unit_definition( + unit_id: str, + force: bool = Query(False, description="Force delete even if published"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Delete a unit definition. + + - By default, only drafts can be deleted + - Use force=true to delete published units + """ + from pathlib import Path + + db = await get_unit_database() + deleted = False + + if db: + try: + existing = await db.get_unit_definition(unit_id) + if existing: + status = existing.get("status", "draft") + if status == "published" and not force: + raise HTTPException( + status_code=400, + detail="Veroeffentlichte Units koennen nicht geloescht werden. Verwende force=true." + ) + await db.delete_unit_definition(unit_id) + deleted = True + logger.info(f"Unit deleted from database: {unit_id}") + except HTTPException: + raise + except Exception as e: + logger.warning(f"Database delete failed: {e}") + + # Also check JSON file + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + if json_path.exists(): + json_path.unlink() + deleted = True + logger.info(f"Unit JSON deleted: {json_path}") + + if not deleted: + raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") + + return {"success": True, "unit_id": unit_id, "message": "Unit geloescht"} + + +@router.post("/definitions/validate", response_model=ValidationResult) +async def validate_unit( + unit_data: Dict[str, Any], + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> ValidationResult: + """ + Validate a unit definition without saving. + + Returns validation result with errors and warnings. + """ + return validate_unit_definition(unit_data) diff --git a/backend-lehrer/unit_helpers.py b/backend-lehrer/unit_helpers.py new file mode 100644 index 0000000..cc0eba1 --- /dev/null +++ b/backend-lehrer/unit_helpers.py @@ -0,0 +1,204 @@ +# ============================================== +# Breakpilot Drive - Unit API Helpers +# ============================================== +# Auth, database, token, and validation helpers for the Unit API. +# Extracted from unit_api.py for file-size compliance. + +from fastapi import HTTPException, Request +from typing import Optional, Dict, Any, List +from datetime import datetime, timedelta +import os +import logging +import jwt + +from unit_models import ValidationError, ValidationResult + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" +REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production") + + +# ============================================== +# Auth Dependency (reuse from game_api) +# ============================================== + +async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: + """Optional auth dependency for Unit API.""" + if not REQUIRE_AUTH: + return None + + try: + from auth import get_current_user + return await get_current_user(request) + except ImportError: + logger.warning("Auth module not available") + return None + except HTTPException: + raise + except Exception as e: + logger.error(f"Auth error: {e}") + raise HTTPException(status_code=401, detail="Authentication failed") + + +# ============================================== +# Database Integration +# ============================================== + +_unit_db = None + +async def get_unit_database(): + """Get unit database instance with lazy initialization.""" + global _unit_db + if not USE_DATABASE: + return None + if _unit_db is None: + try: + from unit.database import get_unit_db + _unit_db = await get_unit_db() + logger.info("Unit database initialized") + except ImportError: + logger.warning("Unit database module not available") + except Exception as e: + logger.warning(f"Unit database not available: {e}") + return _unit_db + + +# ============================================== +# Token Helpers +# ============================================== + +def create_session_token(session_id: str, student_id: str, expires_hours: int = 4) -> str: + """Create a JWT session token for telemetry authentication.""" + payload = { + "session_id": session_id, + "student_id": student_id, + "exp": datetime.utcnow() + timedelta(hours=expires_hours), + "iat": datetime.utcnow(), + } + return jwt.encode(payload, SECRET_KEY, algorithm="HS256") + + +def verify_session_token(token: str) -> Optional[Dict[str, Any]]: + """Verify a session token and return payload.""" + try: + return jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +async def get_session_from_token(request: Request) -> Optional[Dict[str, Any]]: + """Extract and verify session from Authorization header.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + return verify_session_token(token) + + +# ============================================== +# Validation +# ============================================== + +def validate_unit_definition(unit_data: Dict[str, Any]) -> ValidationResult: + """ + Validate a unit definition structure. + + Returns validation result with errors and warnings. + """ + errors: List[ValidationError] = [] + warnings: List[ValidationError] = [] + + # Required fields + if not unit_data.get("unit_id"): + errors.append(ValidationError(field="unit_id", message="unit_id ist erforderlich")) + + if not unit_data.get("template"): + errors.append(ValidationError(field="template", message="template ist erforderlich")) + elif unit_data["template"] not in ["flight_path", "station_loop"]: + errors.append(ValidationError( + field="template", + message="template muss 'flight_path' oder 'station_loop' sein" + )) + + # Validate stops + stops = unit_data.get("stops", []) + if not stops: + errors.append(ValidationError(field="stops", message="Mindestens 1 Stop erforderlich")) + else: + # Check minimum stops for flight_path + if unit_data.get("template") == "flight_path" and len(stops) < 3: + warnings.append(ValidationError( + field="stops", + message="FlightPath sollte mindestens 3 Stops haben", + severity="warning" + )) + + # Validate each stop + stop_ids = set() + for i, stop in enumerate(stops): + if not stop.get("stop_id"): + errors.append(ValidationError( + field=f"stops[{i}].stop_id", + message=f"Stop {i}: stop_id fehlt" + )) + else: + if stop["stop_id"] in stop_ids: + errors.append(ValidationError( + field=f"stops[{i}].stop_id", + message=f"Stop {i}: Doppelte stop_id '{stop['stop_id']}'" + )) + stop_ids.add(stop["stop_id"]) + + # Check interaction type + interaction = stop.get("interaction", {}) + if not interaction.get("type"): + errors.append(ValidationError( + field=f"stops[{i}].interaction.type", + message=f"Stop {stop.get('stop_id', i)}: Interaktionstyp fehlt" + )) + elif interaction["type"] not in [ + "aim_and_pass", "slider_adjust", "slider_equivalence", + "sequence_arrange", "toggle_switch", "drag_match", + "error_find", "transfer_apply" + ]: + warnings.append(ValidationError( + field=f"stops[{i}].interaction.type", + message=f"Stop {stop.get('stop_id', i)}: Unbekannter Interaktionstyp '{interaction['type']}'", + severity="warning" + )) + + # Check for label + if not stop.get("label"): + warnings.append(ValidationError( + field=f"stops[{i}].label", + message=f"Stop {stop.get('stop_id', i)}: Label fehlt", + severity="warning" + )) + + # Validate duration + duration = unit_data.get("duration_minutes", 0) + if duration < 3 or duration > 20: + warnings.append(ValidationError( + field="duration_minutes", + message="Dauer sollte zwischen 3 und 20 Minuten liegen", + severity="warning" + )) + + # Validate difficulty + if unit_data.get("difficulty") and unit_data["difficulty"] not in ["base", "advanced"]: + warnings.append(ValidationError( + field="difficulty", + message="difficulty sollte 'base' oder 'advanced' sein", + severity="warning" + )) + + return ValidationResult( + valid=len(errors) == 0, + errors=errors, + warnings=warnings + ) diff --git a/backend-lehrer/unit_models.py b/backend-lehrer/unit_models.py new file mode 100644 index 0000000..dce74b9 --- /dev/null +++ b/backend-lehrer/unit_models.py @@ -0,0 +1,149 @@ +# ============================================== +# Breakpilot Drive - Unit API Models +# ============================================== +# Pydantic models for the Unit API. +# Extracted from unit_api.py for file-size compliance. + +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime + + +class UnitDefinitionResponse(BaseModel): + """Unit definition response""" + unit_id: str + template: str + version: str + locale: List[str] + grade_band: List[str] + duration_minutes: int + difficulty: str + definition: Dict[str, Any] + + +class CreateSessionRequest(BaseModel): + """Request to create a unit session""" + unit_id: str + student_id: str + locale: str = "de-DE" + difficulty: str = "base" + + +class SessionResponse(BaseModel): + """Response after creating a session""" + session_id: str + unit_definition_url: str + session_token: str + telemetry_endpoint: str + expires_at: datetime + + +class TelemetryEvent(BaseModel): + """Single telemetry event""" + ts: Optional[str] = None + type: str = Field(..., alias="type") + stop_id: Optional[str] = None + metrics: Optional[Dict[str, Any]] = None + + class Config: + populate_by_name = True + + +class TelemetryPayload(BaseModel): + """Batch telemetry payload""" + session_id: str + events: List[TelemetryEvent] + + +class TelemetryResponse(BaseModel): + """Response after receiving telemetry""" + accepted: int + + +class PostcheckAnswer(BaseModel): + """Single postcheck answer""" + question_id: str + answer: str + + +class CompleteSessionRequest(BaseModel): + """Request to complete a session""" + postcheck_answers: Optional[List[PostcheckAnswer]] = None + + +class SessionSummaryResponse(BaseModel): + """Response with session summary""" + summary: Dict[str, Any] + next_recommendations: Dict[str, Any] + + +class UnitListItem(BaseModel): + """Unit list item""" + unit_id: str + template: str + difficulty: str + duration_minutes: int + locale: List[str] + grade_band: List[str] + + +class RecommendedUnit(BaseModel): + """Recommended unit with reason""" + unit_id: str + template: str + difficulty: str + reason: str + + +class CreateUnitRequest(BaseModel): + """Request to create a new unit definition""" + unit_id: str = Field(..., description="Unique unit identifier") + template: str = Field(..., description="Template type: flight_path or station_loop") + version: str = Field(default="1.0.0", description="Version string") + locale: List[str] = Field(default=["de-DE"], description="Supported locales") + grade_band: List[str] = Field(default=["5", "6", "7"], description="Target grade levels") + duration_minutes: int = Field(default=8, ge=3, le=20, description="Expected duration") + difficulty: str = Field(default="base", description="Difficulty level: base or advanced") + subject: Optional[str] = Field(default=None, description="Subject area") + topic: Optional[str] = Field(default=None, description="Topic within subject") + learning_objectives: List[str] = Field(default=[], description="Learning objectives") + stops: List[Dict[str, Any]] = Field(default=[], description="Unit stops/stations") + precheck: Optional[Dict[str, Any]] = Field(default=None, description="Pre-check configuration") + postcheck: Optional[Dict[str, Any]] = Field(default=None, description="Post-check configuration") + teacher_controls: Optional[Dict[str, Any]] = Field(default=None, description="Teacher control settings") + assets: Optional[Dict[str, Any]] = Field(default=None, description="Asset configuration") + metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata") + status: str = Field(default="draft", description="Publication status: draft or published") + + +class UpdateUnitRequest(BaseModel): + """Request to update an existing unit definition""" + version: Optional[str] = None + locale: Optional[List[str]] = None + grade_band: Optional[List[str]] = None + duration_minutes: Optional[int] = Field(default=None, ge=3, le=20) + difficulty: Optional[str] = None + subject: Optional[str] = None + topic: Optional[str] = None + learning_objectives: Optional[List[str]] = None + stops: Optional[List[Dict[str, Any]]] = None + precheck: Optional[Dict[str, Any]] = None + postcheck: Optional[Dict[str, Any]] = None + teacher_controls: Optional[Dict[str, Any]] = None + assets: Optional[Dict[str, Any]] = None + metadata: Optional[Dict[str, Any]] = None + status: Optional[str] = None + + +class ValidationError(BaseModel): + """Single validation error""" + field: str + message: str + severity: str = "error" # error or warning + + +class ValidationResult(BaseModel): + """Result of unit validation""" + valid: bool + errors: List[ValidationError] = [] + warnings: List[ValidationError] = [] diff --git a/backend-lehrer/unit_routes.py b/backend-lehrer/unit_routes.py new file mode 100644 index 0000000..7f6d7bf --- /dev/null +++ b/backend-lehrer/unit_routes.py @@ -0,0 +1,494 @@ +# ============================================== +# Breakpilot Drive - Unit API Routes +# ============================================== +# Endpoints for listing/getting definitions, sessions, telemetry, +# recommendations, and analytics. +# CRUD definition routes are in unit_definition_routes.py. +# Extracted from unit_api.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +import uuid +import logging + +from unit_models import ( + UnitDefinitionResponse, + CreateSessionRequest, + SessionResponse, + TelemetryPayload, + TelemetryResponse, + CompleteSessionRequest, + SessionSummaryResponse, + UnitListItem, + RecommendedUnit, +) +from unit_helpers import ( + get_optional_current_user, + get_unit_database, + create_session_token, + get_session_from_token, + REQUIRE_AUTH, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) + + +# ============================================== +# Definition List/Get Endpoints +# ============================================== + +@router.get("/definitions", response_model=List[UnitListItem]) +async def list_unit_definitions( + template: Optional[str] = Query(None, description="Filter by template: flight_path, station_loop"), + grade: Optional[str] = Query(None, description="Filter by grade level"), + locale: str = Query("de-DE", description="Filter by locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[UnitListItem]: + """ + List available unit definitions. + + Returns published units matching the filter criteria. + """ + db = await get_unit_database() + if db: + try: + units = await db.list_units( + template=template, + grade=grade, + locale=locale, + published_only=True + ) + return [ + UnitListItem( + unit_id=u["unit_id"], + template=u["template"], + difficulty=u["difficulty"], + duration_minutes=u["duration_minutes"], + locale=u["locale"], + grade_band=u["grade_band"], + ) + for u in units + ] + except Exception as e: + logger.error(f"Failed to list units: {e}") + + # Fallback: return demo unit + return [ + UnitListItem( + unit_id="demo_unit_v1", + template="flight_path", + difficulty="base", + duration_minutes=5, + locale=["de-DE"], + grade_band=["5", "6", "7"], + ) + ] + + +@router.get("/definitions/{unit_id}", response_model=UnitDefinitionResponse) +async def get_unit_definition( + unit_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Get a specific unit definition. + + Returns the full unit configuration including stops, interactions, etc. + """ + db = await get_unit_database() + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + return UnitDefinitionResponse( + unit_id=unit["unit_id"], + template=unit["template"], + version=unit["version"], + locale=unit["locale"], + grade_band=unit["grade_band"], + duration_minutes=unit["duration_minutes"], + difficulty=unit["difficulty"], + definition=unit["definition"], + ) + except Exception as e: + logger.error(f"Failed to get unit definition: {e}") + + # Demo unit fallback + if unit_id == "demo_unit_v1": + return UnitDefinitionResponse( + unit_id="demo_unit_v1", + template="flight_path", + version="1.0.0", + locale=["de-DE"], + grade_band=["5", "6", "7"], + duration_minutes=5, + difficulty="base", + definition={ + "unit_id": "demo_unit_v1", + "template": "flight_path", + "version": "1.0.0", + "learning_objectives": ["Demo: Grundfunktion testen"], + "stops": [ + {"stop_id": "stop_1", "label": {"de-DE": "Start"}, "interaction": {"type": "aim_and_pass"}}, + {"stop_id": "stop_2", "label": {"de-DE": "Mitte"}, "interaction": {"type": "aim_and_pass"}}, + {"stop_id": "stop_3", "label": {"de-DE": "Ende"}, "interaction": {"type": "aim_and_pass"}}, + ], + "teacher_controls": {"allow_skip": True, "allow_replay": True}, + }, + ) + + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + +# ============================================== +# Session Endpoints +# ============================================== + +@router.post("/sessions", response_model=SessionResponse) +async def create_unit_session( + request_data: CreateSessionRequest, + request: Request, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> SessionResponse: + """ + Create a new unit session. + + - Validates unit exists + - Creates session record + - Returns session token for telemetry + """ + session_id = str(uuid.uuid4()) + expires_at = datetime.utcnow() + timedelta(hours=4) + + # Validate unit exists + db = await get_unit_database() + if db: + try: + unit = await db.get_unit_definition(request_data.unit_id) + if not unit: + raise HTTPException(status_code=404, detail=f"Unit not found: {request_data.unit_id}") + + # Create session in database + total_stops = len(unit.get("definition", {}).get("stops", [])) + await db.create_session( + session_id=session_id, + unit_id=request_data.unit_id, + student_id=request_data.student_id, + locale=request_data.locale, + difficulty=request_data.difficulty, + total_stops=total_stops, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to create session: {e}") + # Continue with in-memory fallback + + # Create session token + session_token = create_session_token(session_id, request_data.student_id) + + # Build definition URL + base_url = str(request.base_url).rstrip("/") + definition_url = f"{base_url}/api/units/definitions/{request_data.unit_id}" + + return SessionResponse( + session_id=session_id, + unit_definition_url=definition_url, + session_token=session_token, + telemetry_endpoint="/api/units/telemetry", + expires_at=expires_at, + ) + + +@router.post("/telemetry", response_model=TelemetryResponse) +async def receive_telemetry( + payload: TelemetryPayload, + request: Request, +) -> TelemetryResponse: + """ + Receive batched telemetry events from Unity client. + + - Validates session token + - Stores events in database + - Returns count of accepted events + """ + # Verify session token + session_data = await get_session_from_token(request) + if session_data is None: + # Allow without auth in dev mode + if REQUIRE_AUTH: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + logger.warning("Telemetry received without valid token (dev mode)") + + # Verify session_id matches + if session_data and session_data.get("session_id") != payload.session_id: + raise HTTPException(status_code=403, detail="Session ID mismatch") + + accepted = 0 + db = await get_unit_database() + + for event in payload.events: + try: + # Set timestamp if not provided + timestamp = event.ts or datetime.utcnow().isoformat() + + if db: + await db.store_telemetry_event( + session_id=payload.session_id, + event_type=event.type, + stop_id=event.stop_id, + timestamp=timestamp, + metrics=event.metrics, + ) + + accepted += 1 + logger.debug(f"Telemetry: {event.type} for session {payload.session_id}") + + except Exception as e: + logger.error(f"Failed to store telemetry event: {e}") + + return TelemetryResponse(accepted=accepted) + + +@router.post("/sessions/{session_id}/complete", response_model=SessionSummaryResponse) +async def complete_session( + session_id: str, + request_data: CompleteSessionRequest, + request: Request, +) -> SessionSummaryResponse: + """ + Complete a unit session. + + - Processes postcheck answers if provided + - Calculates learning gain + - Returns summary and recommendations + """ + # Verify session token + session_data = await get_session_from_token(request) + if REQUIRE_AUTH and session_data is None: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + + db = await get_unit_database() + summary = {} + recommendations = {} + + if db: + try: + # Get session data + session = await db.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Calculate postcheck score if answers provided + postcheck_score = None + if request_data.postcheck_answers: + # Simple scoring: count correct answers + # In production, would validate against question bank + postcheck_score = len(request_data.postcheck_answers) * 0.2 # Placeholder + postcheck_score = min(postcheck_score, 1.0) + + # Complete session in database + await db.complete_session( + session_id=session_id, + postcheck_score=postcheck_score, + ) + + # Get updated session summary + session = await db.get_session(session_id) + + # Calculate learning gain + pre_score = session.get("precheck_score") + post_score = session.get("postcheck_score") + learning_gain = None + if pre_score is not None and post_score is not None: + learning_gain = post_score - pre_score + + summary = { + "session_id": session_id, + "unit_id": session.get("unit_id"), + "duration_seconds": session.get("duration_seconds"), + "completion_rate": session.get("completion_rate"), + "precheck_score": pre_score, + "postcheck_score": post_score, + "pre_to_post_gain": learning_gain, + "stops_completed": session.get("stops_completed"), + "total_stops": session.get("total_stops"), + } + + # Get recommendations + recommendations = await db.get_recommendations( + student_id=session.get("student_id"), + completed_unit_id=session.get("unit_id"), + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to complete session: {e}") + summary = {"session_id": session_id, "error": str(e)} + + else: + # Fallback summary + summary = { + "session_id": session_id, + "duration_seconds": 0, + "completion_rate": 1.0, + "message": "Database not available", + } + + return SessionSummaryResponse( + summary=summary, + next_recommendations=recommendations or { + "h5p_activity_ids": [], + "worksheet_pdf_url": None, + }, + ) + + +@router.get("/sessions/{session_id}") +async def get_session( + session_id: str, + request: Request, +) -> Dict[str, Any]: + """ + Get session details. + + Returns current state of a session including progress. + """ + # Verify session token + session_data = await get_session_from_token(request) + if REQUIRE_AUTH and session_data is None: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + + db = await get_unit_database() + if db: + try: + session = await db.get_session(session_id) + if session: + return session + except Exception as e: + logger.error(f"Failed to get session: {e}") + + raise HTTPException(status_code=404, detail="Session not found") + + +# ============================================== +# Recommendations & Analytics +# ============================================== + +@router.get("/recommendations/{student_id}", response_model=List[RecommendedUnit]) +async def get_recommendations( + student_id: str, + grade: Optional[str] = Query(None, description="Grade level filter"), + locale: str = Query("de-DE", description="Locale filter"), + limit: int = Query(5, ge=1, le=20), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[RecommendedUnit]: + """ + Get recommended units for a student. + + Based on completion status and performance. + """ + db = await get_unit_database() + if db: + try: + recommendations = await db.get_student_recommendations( + student_id=student_id, + grade=grade, + locale=locale, + limit=limit, + ) + return [ + RecommendedUnit( + unit_id=r["unit_id"], + template=r["template"], + difficulty=r["difficulty"], + reason=r["reason"], + ) + for r in recommendations + ] + except Exception as e: + logger.error(f"Failed to get recommendations: {e}") + + # Fallback: recommend demo unit + return [ + RecommendedUnit( + unit_id="demo_unit_v1", + template="flight_path", + difficulty="base", + reason="Neu: Noch nicht gespielt", + ) + ] + + +@router.get("/analytics/student/{student_id}") +async def get_student_analytics( + student_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Get unit analytics for a student. + + Includes completion rates, learning gains, time spent. + """ + db = await get_unit_database() + if db: + try: + analytics = await db.get_student_unit_analytics(student_id) + return analytics + except Exception as e: + logger.error(f"Failed to get analytics: {e}") + + return { + "student_id": student_id, + "units_attempted": 0, + "units_completed": 0, + "avg_completion_rate": 0.0, + "avg_learning_gain": None, + "total_minutes": 0, + } + + +@router.get("/analytics/unit/{unit_id}") +async def get_unit_analytics( + unit_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Get analytics for a specific unit. + + Shows aggregate performance across all students. + """ + db = await get_unit_database() + if db: + try: + analytics = await db.get_unit_performance(unit_id) + return analytics + except Exception as e: + logger.error(f"Failed to get unit analytics: {e}") + + return { + "unit_id": unit_id, + "total_sessions": 0, + "completed_sessions": 0, + "completion_percent": 0.0, + "avg_duration_minutes": 0, + "avg_learning_gain": None, + } + + +@router.get("/health") +async def health_check() -> Dict[str, Any]: + """Health check for unit API.""" + db = await get_unit_database() + db_status = "connected" if db else "disconnected" + + return { + "status": "healthy", + "service": "breakpilot-units", + "database": db_status, + "auth_required": REQUIRE_AUTH, + } diff --git a/klausur-service/backend/admin_api.py b/klausur-service/backend/admin_api.py index 52a7d2b..b6f005c 100644 --- a/klausur-service/backend/admin_api.py +++ b/klausur-service/backend/admin_api.py @@ -1,1012 +1,33 @@ """ -Admin API for NiBiS Data Management -Endpoints for ingestion, monitoring, and data management. +Admin API for NiBiS Data Management (barrel re-export) + +This module was split into: + - admin_nibis.py (NiBiS ingestion, search, stats) + - admin_rag.py (RAG upload, metrics, storage) + - admin_templates.py (Legal templates ingestion, search) + +The `router` object is assembled here by including all sub-routers. +Importers that did `from admin_api import router` continue to work. """ -from fastapi import APIRouter, HTTPException, BackgroundTasks, Query, UploadFile, File, Form -from pydantic import BaseModel -from typing import Optional, List, Dict -from datetime import datetime -from pathlib import Path -import asyncio -import zipfile -import shutil -import tempfile -import os +from fastapi import APIRouter -from nibis_ingestion import ( - run_ingestion, - discover_documents, - extract_zip_files, - DOCS_BASE_PATH, - NiBiSDocument, +from admin_nibis import router as _nibis_router +from admin_rag import router as _rag_router +from admin_templates import router as _templates_router + +# Re-export internal state for test importers +from admin_nibis import ( # noqa: F401 + _ingestion_status, + NiBiSSearchRequest, + search_nibis, ) -from qdrant_service import QdrantService, search_nibis_eh, get_qdrant_client -from eh_pipeline import generate_single_embedding - -# Optional: MinIO and PostgreSQL integrations -try: - from minio_storage import upload_rag_document, get_storage_stats, init_minio_bucket - MINIO_AVAILABLE = True -except ImportError: - MINIO_AVAILABLE = False - -try: - from metrics_db import ( - init_metrics_tables, store_feedback, log_search, log_upload, - calculate_metrics, get_recent_feedback, get_upload_history - ) - METRICS_DB_AVAILABLE = True -except ImportError: - METRICS_DB_AVAILABLE = False - -router = APIRouter(prefix="/api/v1/admin", tags=["Admin"]) - -# Store for background task status -_ingestion_status: Dict = { - "running": False, - "last_run": None, - "last_result": None, -} - - -# ============================================================================= -# Models -# ============================================================================= - -class IngestionRequest(BaseModel): - ewh_only: bool = True - year_filter: Optional[int] = None - subject_filter: Optional[str] = None - - -class IngestionStatus(BaseModel): - running: bool - last_run: Optional[str] - documents_indexed: Optional[int] - chunks_created: Optional[int] - errors: Optional[List[str]] - - -class NiBiSSearchRequest(BaseModel): - query: str - year: Optional[int] = None - subject: Optional[str] = None - niveau: Optional[str] = None - limit: int = 5 - - -class NiBiSSearchResult(BaseModel): - id: str - score: float - text: str - year: Optional[int] - subject: Optional[str] - niveau: Optional[str] - task_number: Optional[int] - - -class DataSourceStats(BaseModel): - source_dir: str - year: int - document_count: int - subjects: List[str] - - -# ============================================================================= -# Endpoints -# ============================================================================= - -@router.get("/nibis/status", response_model=IngestionStatus) -async def get_ingestion_status(): - """Get status of NiBiS ingestion pipeline.""" - last_result = _ingestion_status.get("last_result") or {} - return IngestionStatus( - running=_ingestion_status["running"], - last_run=_ingestion_status.get("last_run"), - documents_indexed=last_result.get("documents_indexed"), - chunks_created=last_result.get("chunks_created"), - errors=(last_result.get("errors") or [])[:10], - ) - - -@router.post("/nibis/extract-zips") -async def extract_zip_files_endpoint(): - """Extract all ZIP files in za-download directories.""" - try: - extracted = extract_zip_files(DOCS_BASE_PATH) - return { - "status": "success", - "extracted_count": len(extracted), - "directories": [str(d) for d in extracted], - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/nibis/discover") -async def discover_nibis_documents( - ewh_only: bool = Query(True, description="Only return Erwartungshorizonte"), - year: Optional[int] = Query(None, description="Filter by year"), - subject: Optional[str] = Query(None, description="Filter by subject"), -): - """ - Discover available NiBiS documents without indexing. - Useful for previewing what will be indexed. - """ - try: - documents = discover_documents(DOCS_BASE_PATH, ewh_only=ewh_only) - - # Apply filters - if year: - documents = [d for d in documents if d.year == year] - if subject: - documents = [d for d in documents if subject.lower() in d.subject.lower()] - - # Group by year and subject - by_year: Dict[int, int] = {} - by_subject: Dict[str, int] = {} - for doc in documents: - by_year[doc.year] = by_year.get(doc.year, 0) + 1 - by_subject[doc.subject] = by_subject.get(doc.subject, 0) + 1 - - return { - "total_documents": len(documents), - "by_year": dict(sorted(by_year.items())), - "by_subject": dict(sorted(by_subject.items(), key=lambda x: -x[1])), - "sample_documents": [ - { - "id": d.id, - "filename": d.raw_filename, - "year": d.year, - "subject": d.subject, - "niveau": d.niveau, - "doc_type": d.doc_type, - } - for d in documents[:20] - ], - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/nibis/ingest") -async def start_ingestion( - request: IngestionRequest, - background_tasks: BackgroundTasks, -): - """ - Start NiBiS data ingestion in background. - This will: - 1. Extract any ZIP files - 2. Discover all Erwartungshorizonte - 3. Extract text from PDFs - 4. Generate embeddings - 5. Index in Qdrant - """ - if _ingestion_status["running"]: - raise HTTPException( - status_code=409, - detail="Ingestion already running. Check /nibis/status for progress." - ) - - async def run_ingestion_task(): - global _ingestion_status - _ingestion_status["running"] = True - _ingestion_status["last_run"] = datetime.now().isoformat() - - try: - result = await run_ingestion( - ewh_only=request.ewh_only, - dry_run=False, - year_filter=request.year_filter, - subject_filter=request.subject_filter, - ) - _ingestion_status["last_result"] = result - except Exception as e: - _ingestion_status["last_result"] = {"error": str(e), "errors": [str(e)]} - finally: - _ingestion_status["running"] = False - - background_tasks.add_task(run_ingestion_task) - - return { - "status": "started", - "message": "Ingestion started in background. Check /nibis/status for progress.", - "filters": { - "ewh_only": request.ewh_only, - "year": request.year_filter, - "subject": request.subject_filter, - }, - } - - -@router.post("/nibis/search", response_model=List[NiBiSSearchResult]) -async def search_nibis(request: NiBiSSearchRequest): - """ - Semantic search in NiBiS Erwartungshorizonte. - Returns relevant chunks based on query. - """ - try: - # Generate query embedding - query_embedding = await generate_single_embedding(request.query) - - if not query_embedding: - raise HTTPException(status_code=500, detail="Failed to generate embedding") - - # Search - results = await search_nibis_eh( - query_embedding=query_embedding, - year=request.year, - subject=request.subject, - niveau=request.niveau, - limit=request.limit, - ) - - return [ - NiBiSSearchResult( - id=r["id"], - score=r["score"], - text=r.get("text", "")[:500], # Truncate for response - year=r.get("year"), - subject=r.get("subject"), - niveau=r.get("niveau"), - task_number=r.get("task_number"), - ) - for r in results - ] - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/nibis/collections") -async def get_collections_info(): - """Get information about all Qdrant collections.""" - try: - client = get_qdrant_client() - collections = client.get_collections().collections - - result = [] - for c in collections: - try: - info = client.get_collection(c.name) - result.append({ - "name": c.name, - "vectors_count": info.vectors_count, - "points_count": info.points_count, - "status": info.status.value, - }) - except Exception as e: - result.append({ - "name": c.name, - "error": str(e), - }) - - return {"collections": result} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/nibis/stats") -async def get_nibis_stats(): - """ - Get detailed statistics about indexed NiBiS data. - """ - try: - qdrant = QdrantService() - stats = await qdrant.get_stats("bp_nibis_eh") - - if "error" in stats: - return { - "indexed": False, - "message": "NiBiS collection not yet created. Run ingestion first.", - } - - # Get sample data to show coverage - client = get_qdrant_client() - - # Scroll to get unique years/subjects - scroll_result = client.scroll( - collection_name="bp_nibis_eh", - limit=1000, - with_payload=True, - with_vectors=False, - ) - - years = set() - subjects = set() - niveaus = set() - - for point in scroll_result[0]: - if point.payload: - if "year" in point.payload: - years.add(point.payload["year"]) - if "subject" in point.payload: - subjects.add(point.payload["subject"]) - if "niveau" in point.payload: - niveaus.add(point.payload["niveau"]) - - return { - "indexed": True, - "total_chunks": stats.get("points_count", 0), - "years": sorted(list(years)), - "subjects": sorted(list(subjects)), - "niveaus": sorted(list(niveaus)), - } - except Exception as e: - return { - "indexed": False, - "error": str(e), - } - - -@router.delete("/nibis/collection") -async def delete_nibis_collection(): - """ - Delete the entire NiBiS collection. - WARNING: This will remove all indexed data! - """ - try: - client = get_qdrant_client() - client.delete_collection("bp_nibis_eh") - return {"status": "deleted", "collection": "bp_nibis_eh"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -# ============================================================================= -# RAG Upload API - ZIP and PDF Upload Support -# ============================================================================= - -# Upload directory configuration -RAG_UPLOAD_BASE = Path(os.getenv("RAG_UPLOAD_BASE", str(DOCS_BASE_PATH))) - -# Store for upload tracking -_upload_history: List[Dict] = [] - - -class UploadResult(BaseModel): - status: str - files_received: int - pdfs_extracted: int - target_directory: str - errors: List[str] - - -@router.post("/rag/upload", response_model=UploadResult) -async def upload_rag_documents( - background_tasks: BackgroundTasks, - file: UploadFile = File(...), - collection: str = Form(default="bp_nibis_eh"), - year: Optional[int] = Form(default=None), - auto_ingest: bool = Form(default=False), -): - """ - Upload documents for RAG indexing. - - Supports: - - ZIP archives (automatically extracted) - - Individual PDF files - - Files are stored in the NiBiS directory structure for ingestion. - """ - errors = [] - pdfs_extracted = 0 - - # Determine target year - target_year = year or datetime.now().year - - # Target directory: za-download/YYYY/ - target_dir = RAG_UPLOAD_BASE / "za-download" / str(target_year) - target_dir.mkdir(parents=True, exist_ok=True) - - try: - filename = file.filename or "upload" - - if filename.lower().endswith(".zip"): - # Handle ZIP file - with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: - content = await file.read() - tmp.write(content) - tmp_path = tmp.name - - try: - with zipfile.ZipFile(tmp_path, 'r') as zf: - # Extract PDFs from ZIP - for member in zf.namelist(): - if member.lower().endswith(".pdf") and not member.startswith("__MACOSX"): - # Get just the filename, ignore directory structure in ZIP - pdf_name = Path(member).name - if pdf_name: - target_path = target_dir / pdf_name - - # Extract to target - with zf.open(member) as src: - with open(target_path, 'wb') as dst: - dst.write(src.read()) - - pdfs_extracted += 1 - finally: - os.unlink(tmp_path) - - elif filename.lower().endswith(".pdf"): - # Handle single PDF - target_path = target_dir / filename - content = await file.read() - - with open(target_path, 'wb') as f: - f.write(content) - - pdfs_extracted = 1 - else: - raise HTTPException( - status_code=400, - detail=f"Unsupported file type: {filename}. Only .zip and .pdf are allowed." - ) - - # Track upload in memory - upload_record = { - "timestamp": datetime.now().isoformat(), - "filename": filename, - "collection": collection, - "year": target_year, - "pdfs_extracted": pdfs_extracted, - "target_directory": str(target_dir), - } - _upload_history.append(upload_record) - - # Keep only last 100 uploads in memory - if len(_upload_history) > 100: - _upload_history.pop(0) - - # Store in PostgreSQL if available - if METRICS_DB_AVAILABLE: - await log_upload( - filename=filename, - collection_name=collection, - year=target_year, - pdfs_extracted=pdfs_extracted, - minio_path=str(target_dir), - ) - - # Auto-ingest if requested - if auto_ingest and not _ingestion_status["running"]: - async def run_auto_ingest(): - global _ingestion_status - _ingestion_status["running"] = True - _ingestion_status["last_run"] = datetime.now().isoformat() - - try: - result = await run_ingestion( - ewh_only=True, - dry_run=False, - year_filter=target_year, - ) - _ingestion_status["last_result"] = result - except Exception as e: - _ingestion_status["last_result"] = {"error": str(e), "errors": [str(e)]} - finally: - _ingestion_status["running"] = False - - background_tasks.add_task(run_auto_ingest) - - return UploadResult( - status="success", - files_received=1, - pdfs_extracted=pdfs_extracted, - target_directory=str(target_dir), - errors=errors, - ) - - except HTTPException: - raise - except Exception as e: - errors.append(str(e)) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/rag/upload/history") -async def get_upload_history(limit: int = Query(default=20, le=100)): - """Get recent upload history.""" - return { - "uploads": _upload_history[-limit:][::-1], # Most recent first - "total": len(_upload_history), - } - - -@router.get("/rag/metrics") -async def get_rag_metrics( - collection: Optional[str] = Query(default=None), - days: int = Query(default=7, le=90), -): - """ - Get RAG quality metrics. - Uses PostgreSQL for real metrics if available, otherwise returns defaults. - """ - if METRICS_DB_AVAILABLE: - metrics = await calculate_metrics(collection_name=collection, days=days) - if metrics.get("connected"): - return metrics - - # Fallback: Return placeholder metrics - return { - "precision_at_5": 0.78, - "recall_at_10": 0.85, - "mrr": 0.72, - "avg_latency_ms": 52, - "total_ratings": len(_upload_history), - "error_rate": 0.3, - "score_distribution": { - "0.9+": 23, - "0.7-0.9": 41, - "0.5-0.7": 28, - "<0.5": 8, - }, - "note": "Placeholder metrics - PostgreSQL not connected", - "connected": False, - } - - -@router.post("/rag/search/feedback") -async def submit_search_feedback( - result_id: str = Form(...), - rating: int = Form(..., ge=1, le=5), - notes: Optional[str] = Form(default=None), - query: Optional[str] = Form(default=None), - collection: Optional[str] = Form(default=None), - score: Optional[float] = Form(default=None), -): - """ - Submit feedback for a search result. - Used for quality tracking and metrics. - """ - feedback_record = { - "timestamp": datetime.now().isoformat(), - "result_id": result_id, - "rating": rating, - "notes": notes, - } - - stored = False - if METRICS_DB_AVAILABLE: - stored = await store_feedback( - result_id=result_id, - rating=rating, - query_text=query, - collection_name=collection, - score=score, - notes=notes, - ) - - return { - "status": "stored" if stored else "received", - "feedback": feedback_record, - "persisted": stored, - } - - -@router.get("/rag/storage/stats") -async def get_storage_statistics(): - """Get MinIO storage statistics.""" - if MINIO_AVAILABLE: - stats = await get_storage_stats() - return stats - return { - "error": "MinIO not available", - "connected": False, - } - - -@router.post("/rag/init") -async def initialize_rag_services(): - """Initialize RAG services (MinIO bucket, PostgreSQL tables).""" - results = { - "minio": False, - "postgres": False, - } - - if MINIO_AVAILABLE: - results["minio"] = await init_minio_bucket() - - if METRICS_DB_AVAILABLE: - results["postgres"] = await init_metrics_tables() - - return { - "status": "initialized", - "services": results, - } - - -# ============================================================================= -# Legal Templates API - Document Generator Support -# ============================================================================= - -# Import legal templates modules -try: - from legal_templates_ingestion import ( - LegalTemplatesIngestion, - LEGAL_TEMPLATES_COLLECTION, - ) - from template_sources import ( - TEMPLATE_SOURCES, - TEMPLATE_TYPES, - JURISDICTIONS, - LicenseType, - get_enabled_sources, - get_sources_by_priority, - ) - from qdrant_service import ( - search_legal_templates, - get_legal_templates_stats, - init_legal_templates_collection, - ) - LEGAL_TEMPLATES_AVAILABLE = True -except ImportError as e: - print(f"Legal templates module not available: {e}") - LEGAL_TEMPLATES_AVAILABLE = False - -# Store for templates ingestion status -_templates_ingestion_status: Dict = { - "running": False, - "last_run": None, - "current_source": None, - "results": {}, -} - - -class TemplatesSearchRequest(BaseModel): - query: str - template_type: Optional[str] = None - license_types: Optional[List[str]] = None - language: Optional[str] = None - jurisdiction: Optional[str] = None - attribution_required: Optional[bool] = None - limit: int = 10 - - -class TemplatesSearchResult(BaseModel): - id: str - score: float - text: str - document_title: Optional[str] - template_type: Optional[str] - clause_category: Optional[str] - language: Optional[str] - jurisdiction: Optional[str] - license_id: Optional[str] - license_name: Optional[str] - attribution_required: Optional[bool] - attribution_text: Optional[str] - source_name: Optional[str] - source_url: Optional[str] - placeholders: Optional[List[str]] - is_complete_document: Optional[bool] - requires_customization: Optional[bool] - - -class SourceIngestRequest(BaseModel): - source_name: str - - -@router.get("/templates/status") -async def get_templates_status(): - """Get status of legal templates collection and ingestion.""" - if not LEGAL_TEMPLATES_AVAILABLE: - return { - "available": False, - "error": "Legal templates module not available", - } - - try: - stats = await get_legal_templates_stats() - - return { - "available": True, - "collection": LEGAL_TEMPLATES_COLLECTION, - "ingestion": { - "running": _templates_ingestion_status["running"], - "last_run": _templates_ingestion_status.get("last_run"), - "current_source": _templates_ingestion_status.get("current_source"), - "results": _templates_ingestion_status.get("results", {}), - }, - "stats": stats, - } - except Exception as e: - return { - "available": True, - "error": str(e), - "ingestion": _templates_ingestion_status, - } - - -@router.get("/templates/sources") -async def get_templates_sources(): - """Get list of all template sources with their configuration.""" - if not LEGAL_TEMPLATES_AVAILABLE: - raise HTTPException(status_code=503, detail="Legal templates module not available") - - sources = [] - for source in TEMPLATE_SOURCES: - sources.append({ - "name": source.name, - "description": source.description, - "license_type": source.license_type.value, - "license_name": source.license_info.name, - "template_types": source.template_types, - "languages": source.languages, - "jurisdiction": source.jurisdiction, - "repo_url": source.repo_url, - "web_url": source.web_url, - "priority": source.priority, - "enabled": source.enabled, - "attribution_required": source.license_info.attribution_required, - }) - - return { - "sources": sources, - "total": len(sources), - "enabled": len([s for s in TEMPLATE_SOURCES if s.enabled]), - "template_types": TEMPLATE_TYPES, - "jurisdictions": JURISDICTIONS, - } - - -@router.get("/templates/licenses") -async def get_templates_licenses(): - """Get license statistics for indexed templates.""" - if not LEGAL_TEMPLATES_AVAILABLE: - raise HTTPException(status_code=503, detail="Legal templates module not available") - - try: - stats = await get_legal_templates_stats() - return { - "licenses": stats.get("licenses", {}), - "total_chunks": stats.get("points_count", 0), - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/templates/ingest") -async def start_templates_ingestion( - background_tasks: BackgroundTasks, - max_priority: int = Query(default=3, ge=1, le=5, description="Maximum priority level (1=highest)"), -): - """ - Start legal templates ingestion in background. - Ingests all enabled sources up to the specified priority level. - - Priority levels: - - 1: CC0 sources (github-site-policy, opr-vc, etc.) - - 2: MIT sources (webflorist, tempest, etc.) - - 3: CC BY 4.0 sources (common-paper, etc.) - - 4: Public domain/Unlicense (bundestag-gesetze) - - 5: Reuse notice sources - """ - if not LEGAL_TEMPLATES_AVAILABLE: - raise HTTPException(status_code=503, detail="Legal templates module not available") - - if _templates_ingestion_status["running"]: - raise HTTPException( - status_code=409, - detail="Templates ingestion already running. Check /templates/status for progress." - ) - - async def run_templates_ingestion(): - global _templates_ingestion_status - _templates_ingestion_status["running"] = True - _templates_ingestion_status["last_run"] = datetime.now().isoformat() - _templates_ingestion_status["results"] = {} - - try: - ingestion = LegalTemplatesIngestion() - sources = get_sources_by_priority(max_priority) - - for source in sources: - _templates_ingestion_status["current_source"] = source.name - - try: - status = await ingestion.ingest_source(source) - _templates_ingestion_status["results"][source.name] = { - "status": status.status, - "documents_found": status.documents_found, - "chunks_indexed": status.chunks_indexed, - "errors": status.errors[:5] if status.errors else [], - } - except Exception as e: - _templates_ingestion_status["results"][source.name] = { - "status": "failed", - "error": str(e), - } - - await ingestion.close() - - except Exception as e: - _templates_ingestion_status["results"]["_global_error"] = str(e) - finally: - _templates_ingestion_status["running"] = False - _templates_ingestion_status["current_source"] = None - - background_tasks.add_task(run_templates_ingestion) - - sources = get_sources_by_priority(max_priority) - return { - "status": "started", - "message": f"Ingesting {len(sources)} sources up to priority {max_priority}", - "sources": [s.name for s in sources], - } - - -@router.post("/templates/ingest-source") -async def ingest_single_source( - request: SourceIngestRequest, - background_tasks: BackgroundTasks, -): - """Ingest a single template source by name.""" - if not LEGAL_TEMPLATES_AVAILABLE: - raise HTTPException(status_code=503, detail="Legal templates module not available") - - source = next((s for s in TEMPLATE_SOURCES if s.name == request.source_name), None) - if not source: - raise HTTPException( - status_code=404, - detail=f"Source not found: {request.source_name}. Use /templates/sources to list available sources." - ) - - if not source.enabled: - raise HTTPException( - status_code=400, - detail=f"Source is disabled: {request.source_name}" - ) - - if _templates_ingestion_status["running"]: - raise HTTPException( - status_code=409, - detail="Templates ingestion already running." - ) - - async def run_single_ingestion(): - global _templates_ingestion_status - _templates_ingestion_status["running"] = True - _templates_ingestion_status["current_source"] = source.name - _templates_ingestion_status["last_run"] = datetime.now().isoformat() - - try: - ingestion = LegalTemplatesIngestion() - status = await ingestion.ingest_source(source) - _templates_ingestion_status["results"][source.name] = { - "status": status.status, - "documents_found": status.documents_found, - "chunks_indexed": status.chunks_indexed, - "errors": status.errors[:5] if status.errors else [], - } - await ingestion.close() - - except Exception as e: - _templates_ingestion_status["results"][source.name] = { - "status": "failed", - "error": str(e), - } - finally: - _templates_ingestion_status["running"] = False - _templates_ingestion_status["current_source"] = None - - background_tasks.add_task(run_single_ingestion) - - return { - "status": "started", - "source": source.name, - "license": source.license_type.value, - "template_types": source.template_types, - } - - -@router.post("/templates/search", response_model=List[TemplatesSearchResult]) -async def search_templates(request: TemplatesSearchRequest): - """ - Semantic search in legal templates collection. - Returns relevant template chunks with license and attribution info. - """ - if not LEGAL_TEMPLATES_AVAILABLE: - raise HTTPException(status_code=503, detail="Legal templates module not available") - - try: - # Generate query embedding - query_embedding = await generate_single_embedding(request.query) - - if not query_embedding: - raise HTTPException(status_code=500, detail="Failed to generate embedding") - - # Search - results = await search_legal_templates( - query_embedding=query_embedding, - template_type=request.template_type, - license_types=request.license_types, - language=request.language, - jurisdiction=request.jurisdiction, - attribution_required=request.attribution_required, - limit=request.limit, - ) - - return [ - TemplatesSearchResult( - id=r["id"], - score=r["score"], - text=r.get("text", "")[:1000], # Truncate for response - document_title=r.get("document_title"), - template_type=r.get("template_type"), - clause_category=r.get("clause_category"), - language=r.get("language"), - jurisdiction=r.get("jurisdiction"), - license_id=r.get("license_id"), - license_name=r.get("license_name"), - attribution_required=r.get("attribution_required"), - attribution_text=r.get("attribution_text"), - source_name=r.get("source_name"), - source_url=r.get("source_url"), - placeholders=r.get("placeholders"), - is_complete_document=r.get("is_complete_document"), - requires_customization=r.get("requires_customization"), - ) - for r in results - ] - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/templates/reset") -async def reset_templates_collection(): - """ - Delete and recreate the legal templates collection. - WARNING: This will remove all indexed templates! - """ - if not LEGAL_TEMPLATES_AVAILABLE: - raise HTTPException(status_code=503, detail="Legal templates module not available") - - if _templates_ingestion_status["running"]: - raise HTTPException( - status_code=409, - detail="Cannot reset while ingestion is running" - ) - - try: - ingestion = LegalTemplatesIngestion() - ingestion.reset_collection() - await ingestion.close() - - # Clear ingestion status - _templates_ingestion_status["results"] = {} - - return { - "status": "reset", - "collection": LEGAL_TEMPLATES_COLLECTION, - "message": "Collection deleted and recreated. Run ingestion to populate.", - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/templates/source/{source_name}") -async def delete_templates_source(source_name: str): - """Delete all templates from a specific source.""" - if not LEGAL_TEMPLATES_AVAILABLE: - raise HTTPException(status_code=503, detail="Legal templates module not available") - - try: - from qdrant_service import delete_legal_templates_by_source - - count = await delete_legal_templates_by_source(source_name) - - # Update status - if source_name in _templates_ingestion_status.get("results", {}): - del _templates_ingestion_status["results"][source_name] - - return { - "status": "deleted", - "source": source_name, - "chunks_deleted": count, - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) +from admin_rag import _upload_history # noqa: F401 +from admin_templates import _templates_ingestion_status # noqa: F401 + +# Assemble the combined router. +# All sub-routers use prefix="/api/v1/admin", so include without extra prefix. +router = APIRouter() +router.include_router(_nibis_router) +router.include_router(_rag_router) +router.include_router(_templates_router) diff --git a/klausur-service/backend/admin_nibis.py b/klausur-service/backend/admin_nibis.py new file mode 100644 index 0000000..a7e04d3 --- /dev/null +++ b/klausur-service/backend/admin_nibis.py @@ -0,0 +1,316 @@ +""" +Admin API - NiBiS Ingestion & Search + +Endpoints for NiBiS data discovery, ingestion, search, and statistics. +Extracted from admin_api.py for file-size compliance. +""" + +from fastapi import APIRouter, HTTPException, BackgroundTasks, Query +from pydantic import BaseModel +from typing import Optional, List, Dict +from datetime import datetime + +from nibis_ingestion import ( + run_ingestion, + discover_documents, + extract_zip_files, + DOCS_BASE_PATH, +) +from qdrant_service import QdrantService, search_nibis_eh, get_qdrant_client +from eh_pipeline import generate_single_embedding + +router = APIRouter(prefix="/api/v1/admin", tags=["Admin"]) + +# Store for background task status +_ingestion_status: Dict = { + "running": False, + "last_run": None, + "last_result": None, +} + + +# ============================================================================= +# Models +# ============================================================================= + +class IngestionRequest(BaseModel): + ewh_only: bool = True + year_filter: Optional[int] = None + subject_filter: Optional[str] = None + + +class IngestionStatus(BaseModel): + running: bool + last_run: Optional[str] + documents_indexed: Optional[int] + chunks_created: Optional[int] + errors: Optional[List[str]] + + +class NiBiSSearchRequest(BaseModel): + query: str + year: Optional[int] = None + subject: Optional[str] = None + niveau: Optional[str] = None + limit: int = 5 + + +class NiBiSSearchResult(BaseModel): + id: str + score: float + text: str + year: Optional[int] + subject: Optional[str] + niveau: Optional[str] + task_number: Optional[int] + + +class DataSourceStats(BaseModel): + source_dir: str + year: int + document_count: int + subjects: List[str] + + +# ============================================================================= +# Endpoints +# ============================================================================= + +@router.get("/nibis/status", response_model=IngestionStatus) +async def get_ingestion_status(): + """Get status of NiBiS ingestion pipeline.""" + last_result = _ingestion_status.get("last_result") or {} + return IngestionStatus( + running=_ingestion_status["running"], + last_run=_ingestion_status.get("last_run"), + documents_indexed=last_result.get("documents_indexed"), + chunks_created=last_result.get("chunks_created"), + errors=(last_result.get("errors") or [])[:10], + ) + + +@router.post("/nibis/extract-zips") +async def extract_zip_files_endpoint(): + """Extract all ZIP files in za-download directories.""" + try: + extracted = extract_zip_files(DOCS_BASE_PATH) + return { + "status": "success", + "extracted_count": len(extracted), + "directories": [str(d) for d in extracted], + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/nibis/discover") +async def discover_nibis_documents( + ewh_only: bool = Query(True, description="Only return Erwartungshorizonte"), + year: Optional[int] = Query(None, description="Filter by year"), + subject: Optional[str] = Query(None, description="Filter by subject"), +): + """ + Discover available NiBiS documents without indexing. + Useful for previewing what will be indexed. + """ + try: + documents = discover_documents(DOCS_BASE_PATH, ewh_only=ewh_only) + + # Apply filters + if year: + documents = [d for d in documents if d.year == year] + if subject: + documents = [d for d in documents if subject.lower() in d.subject.lower()] + + # Group by year and subject + by_year: Dict[int, int] = {} + by_subject: Dict[str, int] = {} + for doc in documents: + by_year[doc.year] = by_year.get(doc.year, 0) + 1 + by_subject[doc.subject] = by_subject.get(doc.subject, 0) + 1 + + return { + "total_documents": len(documents), + "by_year": dict(sorted(by_year.items())), + "by_subject": dict(sorted(by_subject.items(), key=lambda x: -x[1])), + "sample_documents": [ + { + "id": d.id, + "filename": d.raw_filename, + "year": d.year, + "subject": d.subject, + "niveau": d.niveau, + "doc_type": d.doc_type, + } + for d in documents[:20] + ], + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/nibis/ingest") +async def start_ingestion( + request: IngestionRequest, + background_tasks: BackgroundTasks, +): + """ + Start NiBiS data ingestion in background. + """ + if _ingestion_status["running"]: + raise HTTPException( + status_code=409, + detail="Ingestion already running. Check /nibis/status for progress." + ) + + async def run_ingestion_task(): + global _ingestion_status + _ingestion_status["running"] = True + _ingestion_status["last_run"] = datetime.now().isoformat() + + try: + result = await run_ingestion( + ewh_only=request.ewh_only, + dry_run=False, + year_filter=request.year_filter, + subject_filter=request.subject_filter, + ) + _ingestion_status["last_result"] = result + except Exception as e: + _ingestion_status["last_result"] = {"error": str(e), "errors": [str(e)]} + finally: + _ingestion_status["running"] = False + + background_tasks.add_task(run_ingestion_task) + + return { + "status": "started", + "message": "Ingestion started in background. Check /nibis/status for progress.", + "filters": { + "ewh_only": request.ewh_only, + "year": request.year_filter, + "subject": request.subject_filter, + }, + } + + +@router.post("/nibis/search", response_model=List[NiBiSSearchResult]) +async def search_nibis(request: NiBiSSearchRequest): + """ + Semantic search in NiBiS Erwartungshorizonte. + """ + try: + query_embedding = await generate_single_embedding(request.query) + + if not query_embedding: + raise HTTPException(status_code=500, detail="Failed to generate embedding") + + results = await search_nibis_eh( + query_embedding=query_embedding, + year=request.year, + subject=request.subject, + niveau=request.niveau, + limit=request.limit, + ) + + return [ + NiBiSSearchResult( + id=r["id"], + score=r["score"], + text=r.get("text", "")[:500], + year=r.get("year"), + subject=r.get("subject"), + niveau=r.get("niveau"), + task_number=r.get("task_number"), + ) + for r in results + ] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/nibis/collections") +async def get_collections_info(): + """Get information about all Qdrant collections.""" + try: + client = get_qdrant_client() + collections = client.get_collections().collections + + result = [] + for c in collections: + try: + info = client.get_collection(c.name) + result.append({ + "name": c.name, + "vectors_count": info.vectors_count, + "points_count": info.points_count, + "status": info.status.value, + }) + except Exception as e: + result.append({ + "name": c.name, + "error": str(e), + }) + + return {"collections": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/nibis/stats") +async def get_nibis_stats(): + """Get detailed statistics about indexed NiBiS data.""" + try: + qdrant = QdrantService() + stats = await qdrant.get_stats("bp_nibis_eh") + + if "error" in stats: + return { + "indexed": False, + "message": "NiBiS collection not yet created. Run ingestion first.", + } + + client = get_qdrant_client() + scroll_result = client.scroll( + collection_name="bp_nibis_eh", + limit=1000, + with_payload=True, + with_vectors=False, + ) + + years = set() + subjects = set() + niveaus = set() + + for point in scroll_result[0]: + if point.payload: + if "year" in point.payload: + years.add(point.payload["year"]) + if "subject" in point.payload: + subjects.add(point.payload["subject"]) + if "niveau" in point.payload: + niveaus.add(point.payload["niveau"]) + + return { + "indexed": True, + "total_chunks": stats.get("points_count", 0), + "years": sorted(list(years)), + "subjects": sorted(list(subjects)), + "niveaus": sorted(list(niveaus)), + } + except Exception as e: + return { + "indexed": False, + "error": str(e), + } + + +@router.delete("/nibis/collection") +async def delete_nibis_collection(): + """Delete the entire NiBiS collection. WARNING: removes all indexed data!""" + try: + client = get_qdrant_client() + client.delete_collection("bp_nibis_eh") + return {"status": "deleted", "collection": "bp_nibis_eh"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/klausur-service/backend/admin_rag.py b/klausur-service/backend/admin_rag.py new file mode 100644 index 0000000..8bacb70 --- /dev/null +++ b/klausur-service/backend/admin_rag.py @@ -0,0 +1,281 @@ +""" +Admin API - RAG Upload & Metrics + +Endpoints for uploading documents, tracking uploads, RAG metrics, +search feedback, storage stats, and service initialization. +Extracted from admin_api.py for file-size compliance. +""" + +from fastapi import APIRouter, HTTPException, BackgroundTasks, Query, UploadFile, File, Form +from pydantic import BaseModel +from typing import Optional, List, Dict +from datetime import datetime +from pathlib import Path +import zipfile +import tempfile +import os + +from nibis_ingestion import run_ingestion, DOCS_BASE_PATH + +# Import ingestion status from nibis module for auto-ingest +from admin_nibis import _ingestion_status + +# Optional: MinIO and PostgreSQL integrations +try: + from minio_storage import upload_rag_document, get_storage_stats, init_minio_bucket + MINIO_AVAILABLE = True +except ImportError: + MINIO_AVAILABLE = False + +try: + from metrics_db import ( + init_metrics_tables, store_feedback, log_search, log_upload, + calculate_metrics, get_recent_feedback, get_upload_history + ) + METRICS_DB_AVAILABLE = True +except ImportError: + METRICS_DB_AVAILABLE = False + +router = APIRouter(prefix="/api/v1/admin", tags=["Admin"]) + +# Upload directory configuration +RAG_UPLOAD_BASE = Path(os.getenv("RAG_UPLOAD_BASE", str(DOCS_BASE_PATH))) + +# Store for upload tracking +_upload_history: List[Dict] = [] + + +class UploadResult(BaseModel): + status: str + files_received: int + pdfs_extracted: int + target_directory: str + errors: List[str] + + +@router.post("/rag/upload", response_model=UploadResult) +async def upload_rag_documents( + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + collection: str = Form(default="bp_nibis_eh"), + year: Optional[int] = Form(default=None), + auto_ingest: bool = Form(default=False), +): + """ + Upload documents for RAG indexing. + + Supports: + - ZIP archives (automatically extracted) + - Individual PDF files + """ + errors = [] + pdfs_extracted = 0 + + # Determine target year + target_year = year or datetime.now().year + + # Target directory: za-download/YYYY/ + target_dir = RAG_UPLOAD_BASE / "za-download" / str(target_year) + target_dir.mkdir(parents=True, exist_ok=True) + + try: + filename = file.filename or "upload" + + if filename.lower().endswith(".zip"): + # Handle ZIP file + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: + content = await file.read() + tmp.write(content) + tmp_path = tmp.name + + try: + with zipfile.ZipFile(tmp_path, 'r') as zf: + for member in zf.namelist(): + if member.lower().endswith(".pdf") and not member.startswith("__MACOSX"): + pdf_name = Path(member).name + if pdf_name: + target_path = target_dir / pdf_name + with zf.open(member) as src: + with open(target_path, 'wb') as dst: + dst.write(src.read()) + pdfs_extracted += 1 + finally: + os.unlink(tmp_path) + + elif filename.lower().endswith(".pdf"): + target_path = target_dir / filename + content = await file.read() + with open(target_path, 'wb') as f: + f.write(content) + pdfs_extracted = 1 + else: + raise HTTPException( + status_code=400, + detail=f"Unsupported file type: {filename}. Only .zip and .pdf are allowed." + ) + + # Track upload in memory + upload_record = { + "timestamp": datetime.now().isoformat(), + "filename": filename, + "collection": collection, + "year": target_year, + "pdfs_extracted": pdfs_extracted, + "target_directory": str(target_dir), + } + _upload_history.append(upload_record) + + # Keep only last 100 uploads in memory + if len(_upload_history) > 100: + _upload_history.pop(0) + + # Store in PostgreSQL if available + if METRICS_DB_AVAILABLE: + await log_upload( + filename=filename, + collection_name=collection, + year=target_year, + pdfs_extracted=pdfs_extracted, + minio_path=str(target_dir), + ) + + # Auto-ingest if requested + if auto_ingest and not _ingestion_status["running"]: + async def run_auto_ingest(): + global _ingestion_status + _ingestion_status["running"] = True + _ingestion_status["last_run"] = datetime.now().isoformat() + + try: + result = await run_ingestion( + ewh_only=True, + dry_run=False, + year_filter=target_year, + ) + _ingestion_status["last_result"] = result + except Exception as e: + _ingestion_status["last_result"] = {"error": str(e), "errors": [str(e)]} + finally: + _ingestion_status["running"] = False + + background_tasks.add_task(run_auto_ingest) + + return UploadResult( + status="success", + files_received=1, + pdfs_extracted=pdfs_extracted, + target_directory=str(target_dir), + errors=errors, + ) + + except HTTPException: + raise + except Exception as e: + errors.append(str(e)) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/rag/upload/history") +async def get_upload_history_endpoint(limit: int = Query(default=20, le=100)): + """Get recent upload history.""" + return { + "uploads": _upload_history[-limit:][::-1], + "total": len(_upload_history), + } + + +@router.get("/rag/metrics") +async def get_rag_metrics( + collection: Optional[str] = Query(default=None), + days: int = Query(default=7, le=90), +): + """Get RAG quality metrics.""" + if METRICS_DB_AVAILABLE: + metrics = await calculate_metrics(collection_name=collection, days=days) + if metrics.get("connected"): + return metrics + + # Fallback: Return placeholder metrics + return { + "precision_at_5": 0.78, + "recall_at_10": 0.85, + "mrr": 0.72, + "avg_latency_ms": 52, + "total_ratings": len(_upload_history), + "error_rate": 0.3, + "score_distribution": { + "0.9+": 23, + "0.7-0.9": 41, + "0.5-0.7": 28, + "<0.5": 8, + }, + "note": "Placeholder metrics - PostgreSQL not connected", + "connected": False, + } + + +@router.post("/rag/search/feedback") +async def submit_search_feedback( + result_id: str = Form(...), + rating: int = Form(..., ge=1, le=5), + notes: Optional[str] = Form(default=None), + query: Optional[str] = Form(default=None), + collection: Optional[str] = Form(default=None), + score: Optional[float] = Form(default=None), +): + """Submit feedback for a search result.""" + feedback_record = { + "timestamp": datetime.now().isoformat(), + "result_id": result_id, + "rating": rating, + "notes": notes, + } + + stored = False + if METRICS_DB_AVAILABLE: + stored = await store_feedback( + result_id=result_id, + rating=rating, + query_text=query, + collection_name=collection, + score=score, + notes=notes, + ) + + return { + "status": "stored" if stored else "received", + "feedback": feedback_record, + "persisted": stored, + } + + +@router.get("/rag/storage/stats") +async def get_storage_statistics(): + """Get MinIO storage statistics.""" + if MINIO_AVAILABLE: + stats = await get_storage_stats() + return stats + return { + "error": "MinIO not available", + "connected": False, + } + + +@router.post("/rag/init") +async def initialize_rag_services(): + """Initialize RAG services (MinIO bucket, PostgreSQL tables).""" + results = { + "minio": False, + "postgres": False, + } + + if MINIO_AVAILABLE: + results["minio"] = await init_minio_bucket() + + if METRICS_DB_AVAILABLE: + results["postgres"] = await init_metrics_tables() + + return { + "status": "initialized", + "services": results, + } diff --git a/klausur-service/backend/admin_templates.py b/klausur-service/backend/admin_templates.py new file mode 100644 index 0000000..77f0e11 --- /dev/null +++ b/klausur-service/backend/admin_templates.py @@ -0,0 +1,389 @@ +""" +Admin API - Legal Templates + +Endpoints for legal template ingestion, search, source management, +license info, and collection management. +Extracted from admin_api.py for file-size compliance. +""" + +from fastapi import APIRouter, HTTPException, BackgroundTasks, Query +from pydantic import BaseModel +from typing import Optional, List, Dict +from datetime import datetime + +from eh_pipeline import generate_single_embedding + +# Import legal templates modules +try: + from legal_templates_ingestion import ( + LegalTemplatesIngestion, + LEGAL_TEMPLATES_COLLECTION, + ) + from template_sources import ( + TEMPLATE_SOURCES, + TEMPLATE_TYPES, + JURISDICTIONS, + LicenseType, + get_enabled_sources, + get_sources_by_priority, + ) + from qdrant_service import ( + search_legal_templates, + get_legal_templates_stats, + init_legal_templates_collection, + ) + LEGAL_TEMPLATES_AVAILABLE = True +except ImportError as e: + print(f"Legal templates module not available: {e}") + LEGAL_TEMPLATES_AVAILABLE = False + +router = APIRouter(prefix="/api/v1/admin", tags=["Admin"]) + +# Store for templates ingestion status +_templates_ingestion_status: Dict = { + "running": False, + "last_run": None, + "current_source": None, + "results": {}, +} + + +class TemplatesSearchRequest(BaseModel): + query: str + template_type: Optional[str] = None + license_types: Optional[List[str]] = None + language: Optional[str] = None + jurisdiction: Optional[str] = None + attribution_required: Optional[bool] = None + limit: int = 10 + + +class TemplatesSearchResult(BaseModel): + id: str + score: float + text: str + document_title: Optional[str] + template_type: Optional[str] + clause_category: Optional[str] + language: Optional[str] + jurisdiction: Optional[str] + license_id: Optional[str] + license_name: Optional[str] + attribution_required: Optional[bool] + attribution_text: Optional[str] + source_name: Optional[str] + source_url: Optional[str] + placeholders: Optional[List[str]] + is_complete_document: Optional[bool] + requires_customization: Optional[bool] + + +class SourceIngestRequest(BaseModel): + source_name: str + + +@router.get("/templates/status") +async def get_templates_status(): + """Get status of legal templates collection and ingestion.""" + if not LEGAL_TEMPLATES_AVAILABLE: + return { + "available": False, + "error": "Legal templates module not available", + } + + try: + stats = await get_legal_templates_stats() + + return { + "available": True, + "collection": LEGAL_TEMPLATES_COLLECTION, + "ingestion": { + "running": _templates_ingestion_status["running"], + "last_run": _templates_ingestion_status.get("last_run"), + "current_source": _templates_ingestion_status.get("current_source"), + "results": _templates_ingestion_status.get("results", {}), + }, + "stats": stats, + } + except Exception as e: + return { + "available": True, + "error": str(e), + "ingestion": _templates_ingestion_status, + } + + +@router.get("/templates/sources") +async def get_templates_sources(): + """Get list of all template sources with their configuration.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + sources = [] + for source in TEMPLATE_SOURCES: + sources.append({ + "name": source.name, + "description": source.description, + "license_type": source.license_type.value, + "license_name": source.license_info.name, + "template_types": source.template_types, + "languages": source.languages, + "jurisdiction": source.jurisdiction, + "repo_url": source.repo_url, + "web_url": source.web_url, + "priority": source.priority, + "enabled": source.enabled, + "attribution_required": source.license_info.attribution_required, + }) + + return { + "sources": sources, + "total": len(sources), + "enabled": len([s for s in TEMPLATE_SOURCES if s.enabled]), + "template_types": TEMPLATE_TYPES, + "jurisdictions": JURISDICTIONS, + } + + +@router.get("/templates/licenses") +async def get_templates_licenses(): + """Get license statistics for indexed templates.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + try: + stats = await get_legal_templates_stats() + return { + "licenses": stats.get("licenses", {}), + "total_chunks": stats.get("points_count", 0), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/templates/ingest") +async def start_templates_ingestion( + background_tasks: BackgroundTasks, + max_priority: int = Query(default=3, ge=1, le=5, description="Maximum priority level (1=highest)"), +): + """ + Start legal templates ingestion in background. + Ingests all enabled sources up to the specified priority level. + """ + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + if _templates_ingestion_status["running"]: + raise HTTPException( + status_code=409, + detail="Templates ingestion already running. Check /templates/status for progress." + ) + + async def run_templates_ingestion(): + global _templates_ingestion_status + _templates_ingestion_status["running"] = True + _templates_ingestion_status["last_run"] = datetime.now().isoformat() + _templates_ingestion_status["results"] = {} + + try: + ingestion = LegalTemplatesIngestion() + sources = get_sources_by_priority(max_priority) + + for source in sources: + _templates_ingestion_status["current_source"] = source.name + + try: + status = await ingestion.ingest_source(source) + _templates_ingestion_status["results"][source.name] = { + "status": status.status, + "documents_found": status.documents_found, + "chunks_indexed": status.chunks_indexed, + "errors": status.errors[:5] if status.errors else [], + } + except Exception as e: + _templates_ingestion_status["results"][source.name] = { + "status": "failed", + "error": str(e), + } + + await ingestion.close() + + except Exception as e: + _templates_ingestion_status["results"]["_global_error"] = str(e) + finally: + _templates_ingestion_status["running"] = False + _templates_ingestion_status["current_source"] = None + + background_tasks.add_task(run_templates_ingestion) + + sources = get_sources_by_priority(max_priority) + return { + "status": "started", + "message": f"Ingesting {len(sources)} sources up to priority {max_priority}", + "sources": [s.name for s in sources], + } + + +@router.post("/templates/ingest-source") +async def ingest_single_source( + request: SourceIngestRequest, + background_tasks: BackgroundTasks, +): + """Ingest a single template source by name.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + source = next((s for s in TEMPLATE_SOURCES if s.name == request.source_name), None) + if not source: + raise HTTPException( + status_code=404, + detail=f"Source not found: {request.source_name}. Use /templates/sources to list available sources." + ) + + if not source.enabled: + raise HTTPException( + status_code=400, + detail=f"Source is disabled: {request.source_name}" + ) + + if _templates_ingestion_status["running"]: + raise HTTPException( + status_code=409, + detail="Templates ingestion already running." + ) + + async def run_single_ingestion(): + global _templates_ingestion_status + _templates_ingestion_status["running"] = True + _templates_ingestion_status["current_source"] = source.name + _templates_ingestion_status["last_run"] = datetime.now().isoformat() + + try: + ingestion = LegalTemplatesIngestion() + status = await ingestion.ingest_source(source) + _templates_ingestion_status["results"][source.name] = { + "status": status.status, + "documents_found": status.documents_found, + "chunks_indexed": status.chunks_indexed, + "errors": status.errors[:5] if status.errors else [], + } + await ingestion.close() + + except Exception as e: + _templates_ingestion_status["results"][source.name] = { + "status": "failed", + "error": str(e), + } + finally: + _templates_ingestion_status["running"] = False + _templates_ingestion_status["current_source"] = None + + background_tasks.add_task(run_single_ingestion) + + return { + "status": "started", + "source": source.name, + "license": source.license_type.value, + "template_types": source.template_types, + } + + +@router.post("/templates/search", response_model=List[TemplatesSearchResult]) +async def search_templates(request: TemplatesSearchRequest): + """Semantic search in legal templates collection.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + try: + query_embedding = await generate_single_embedding(request.query) + + if not query_embedding: + raise HTTPException(status_code=500, detail="Failed to generate embedding") + + results = await search_legal_templates( + query_embedding=query_embedding, + template_type=request.template_type, + license_types=request.license_types, + language=request.language, + jurisdiction=request.jurisdiction, + attribution_required=request.attribution_required, + limit=request.limit, + ) + + return [ + TemplatesSearchResult( + id=r["id"], + score=r["score"], + text=r.get("text", "")[:1000], + document_title=r.get("document_title"), + template_type=r.get("template_type"), + clause_category=r.get("clause_category"), + language=r.get("language"), + jurisdiction=r.get("jurisdiction"), + license_id=r.get("license_id"), + license_name=r.get("license_name"), + attribution_required=r.get("attribution_required"), + attribution_text=r.get("attribution_text"), + source_name=r.get("source_name"), + source_url=r.get("source_url"), + placeholders=r.get("placeholders"), + is_complete_document=r.get("is_complete_document"), + requires_customization=r.get("requires_customization"), + ) + for r in results + ] + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/templates/reset") +async def reset_templates_collection(): + """Delete and recreate the legal templates collection.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + if _templates_ingestion_status["running"]: + raise HTTPException( + status_code=409, + detail="Cannot reset while ingestion is running" + ) + + try: + ingestion = LegalTemplatesIngestion() + ingestion.reset_collection() + await ingestion.close() + + _templates_ingestion_status["results"] = {} + + return { + "status": "reset", + "collection": LEGAL_TEMPLATES_COLLECTION, + "message": "Collection deleted and recreated. Run ingestion to populate.", + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/templates/source/{source_name}") +async def delete_templates_source(source_name: str): + """Delete all templates from a specific source.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + try: + from qdrant_service import delete_legal_templates_by_source + + count = await delete_legal_templates_by_source(source_name) + + if source_name in _templates_ingestion_status.get("results", {}): + del _templates_ingestion_status["results"][source_name] + + return { + "status": "deleted", + "source": source_name, + "chunks_deleted": count, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/klausur-service/backend/ocr_pipeline_columns.py b/klausur-service/backend/ocr_pipeline_columns.py new file mode 100644 index 0000000..6572509 --- /dev/null +++ b/klausur-service/backend/ocr_pipeline_columns.py @@ -0,0 +1,293 @@ +""" +OCR Pipeline Column Detection Endpoints (Step 5) + +Detect invisible columns, manual column override, and ground truth. +Extracted from ocr_pipeline_geometry.py for file-size compliance. +""" + +import logging +import time +from dataclasses import asdict +from datetime import datetime +from typing import Dict, List + +import cv2 +from fastapi import APIRouter, HTTPException + +from cv_vocab_pipeline import ( + _detect_header_footer_gaps, + _detect_sub_columns, + classify_column_types, + create_layout_image, + create_ocr_image, + analyze_layout, + detect_column_geometry_zoned, + expand_narrow_columns, +) +from ocr_pipeline_session_store import ( + get_session_db, + update_session_db, +) +from ocr_pipeline_common import ( + _cache, + _load_session_to_cache, + _get_cached, + _append_pipeline_log, + ManualColumnsRequest, + ColumnGroundTruthRequest, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"]) + + +@router.post("/sessions/{session_id}/columns") +async def detect_columns(session_id: str): + """Run column detection on the cropped (or dewarped) image.""" + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + img_bgr = cached.get("cropped_bgr") if cached.get("cropped_bgr") is not None else cached.get("dewarped_bgr") + if img_bgr is None: + raise HTTPException(status_code=400, detail="Crop or dewarp must be completed before column detection") + + # ----------------------------------------------------------------------- + # Sub-sessions (box crops): skip column detection entirely. + # Instead, create a single pseudo-column spanning the full image width. + # Also run Tesseract + binarization here so that the row detection step + # can reuse the cached intermediates (_word_dicts, _inv, _content_bounds) + # instead of falling back to detect_column_geometry() which may fail + # on small box images with < 5 words. + # ----------------------------------------------------------------------- + session = await get_session_db(session_id) + if session and session.get("parent_session_id"): + h, w = img_bgr.shape[:2] + + # Binarize + invert for row detection (horizontal projection profile) + ocr_img = create_ocr_image(img_bgr) + inv = cv2.bitwise_not(ocr_img) + + # Run Tesseract to get word bounding boxes. + try: + from PIL import Image as PILImage + pil_img = PILImage.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)) + import pytesseract + data = pytesseract.image_to_data(pil_img, lang='eng+deu', output_type=pytesseract.Output.DICT) + word_dicts = [] + for i in range(len(data['text'])): + conf = int(data['conf'][i]) if str(data['conf'][i]).lstrip('-').isdigit() else -1 + text = str(data['text'][i]).strip() + if conf < 30 or not text: + continue + word_dicts.append({ + 'text': text, 'conf': conf, + 'left': int(data['left'][i]), + 'top': int(data['top'][i]), + 'width': int(data['width'][i]), + 'height': int(data['height'][i]), + }) + # Log all words including low-confidence ones for debugging + all_count = sum(1 for i in range(len(data['text'])) + if str(data['text'][i]).strip()) + low_conf = [(str(data['text'][i]).strip(), int(data['conf'][i]) if str(data['conf'][i]).lstrip('-').isdigit() else -1) + for i in range(len(data['text'])) + if str(data['text'][i]).strip() + and (int(data['conf'][i]) if str(data['conf'][i]).lstrip('-').isdigit() else -1) < 30 + and (int(data['conf'][i]) if str(data['conf'][i]).lstrip('-').isdigit() else -1) >= 0] + if low_conf: + logger.info(f"OCR Pipeline: sub-session {session_id}: {len(low_conf)} words below conf 30: {low_conf[:20]}") + logger.info(f"OCR Pipeline: sub-session {session_id}: Tesseract found {len(word_dicts)}/{all_count} words (conf>=30)") + except Exception as e: + logger.warning(f"OCR Pipeline: sub-session {session_id}: Tesseract failed: {e}") + word_dicts = [] + + # Cache intermediates for row detection (detect_rows reuses these) + cached["_word_dicts"] = word_dicts + cached["_inv"] = inv + cached["_content_bounds"] = (0, w, 0, h) + + column_result = { + "columns": [{ + "type": "column_text", + "x": 0, "y": 0, + "width": w, "height": h, + }], + "zones": None, + "boxes_detected": 0, + "duration_seconds": 0, + "method": "sub_session_pseudo_column", + } + await update_session_db( + session_id, + column_result=column_result, + row_result=None, + word_result=None, + current_step=6, + ) + cached["column_result"] = column_result + cached.pop("row_result", None) + cached.pop("word_result", None) + logger.info(f"OCR Pipeline: sub-session {session_id}: pseudo-column {w}x{h}px") + return {"session_id": session_id, **column_result} + + t0 = time.time() + + # Binarized image for layout analysis + ocr_img = create_ocr_image(img_bgr) + h, w = ocr_img.shape[:2] + + # Phase A: Zone-aware geometry detection + zoned_result = detect_column_geometry_zoned(ocr_img, img_bgr) + + boxes_detected = 0 + if zoned_result is None: + # Fallback to projection-based layout + layout_img = create_layout_image(img_bgr) + regions = analyze_layout(layout_img, ocr_img) + zones_data = None + else: + geometries, left_x, right_x, top_y, bottom_y, word_dicts, inv, zones_data, boxes = zoned_result + content_w = right_x - left_x + boxes_detected = len(boxes) + + # Cache intermediates for row detection (avoids second Tesseract run) + cached["_word_dicts"] = word_dicts + cached["_inv"] = inv + cached["_content_bounds"] = (left_x, right_x, top_y, bottom_y) + cached["_zones_data"] = zones_data + cached["_boxes_detected"] = boxes_detected + + # Detect header/footer early so sub-column clustering ignores them + header_y, footer_y = _detect_header_footer_gaps(inv, w, h) if inv is not None else (None, None) + + # Split sub-columns (e.g. page references) before classification + geometries = _detect_sub_columns(geometries, content_w, left_x=left_x, + top_y=top_y, header_y=header_y, footer_y=footer_y) + + # Expand narrow columns (sub-columns are often very narrow) + geometries = expand_narrow_columns(geometries, content_w, left_x, word_dicts) + + # Phase B: Content-based classification + regions = classify_column_types(geometries, content_w, top_y, w, h, bottom_y, + left_x=left_x, right_x=right_x, inv=inv) + + duration = time.time() - t0 + + columns = [asdict(r) for r in regions] + + # Determine classification methods used + methods = list(set( + c.get("classification_method", "") for c in columns + if c.get("classification_method") + )) + + column_result = { + "columns": columns, + "classification_methods": methods, + "duration_seconds": round(duration, 2), + "boxes_detected": boxes_detected, + } + + # Add zone data when boxes are present + if zones_data and boxes_detected > 0: + column_result["zones"] = zones_data + + # Persist to DB -- also invalidate downstream results (rows, words) + await update_session_db( + session_id, + column_result=column_result, + row_result=None, + word_result=None, + current_step=6, + ) + + # Update cache + cached["column_result"] = column_result + cached.pop("row_result", None) + cached.pop("word_result", None) + + col_count = len([c for c in columns if c["type"].startswith("column")]) + logger.info(f"OCR Pipeline: columns session {session_id}: " + f"{col_count} columns detected, {boxes_detected} box(es) ({duration:.2f}s)") + + img_w = img_bgr.shape[1] + await _append_pipeline_log(session_id, "columns", { + "total_columns": len(columns), + "column_widths_pct": [round(c["width"] / img_w * 100, 1) for c in columns], + "column_types": [c["type"] for c in columns], + "boxes_detected": boxes_detected, + }, duration_ms=int(duration * 1000)) + + return { + "session_id": session_id, + **column_result, + } + + +@router.post("/sessions/{session_id}/columns/manual") +async def set_manual_columns(session_id: str, req: ManualColumnsRequest): + """Override detected columns with manual definitions.""" + column_result = { + "columns": req.columns, + "duration_seconds": 0, + "method": "manual", + } + + await update_session_db(session_id, column_result=column_result, + row_result=None, word_result=None) + + if session_id in _cache: + _cache[session_id]["column_result"] = column_result + _cache[session_id].pop("row_result", None) + _cache[session_id].pop("word_result", None) + + logger.info(f"OCR Pipeline: manual columns session {session_id}: " + f"{len(req.columns)} columns set") + + return {"session_id": session_id, **column_result} + + +@router.post("/sessions/{session_id}/ground-truth/columns") +async def save_column_ground_truth(session_id: str, req: ColumnGroundTruthRequest): + """Save ground truth feedback for the column detection step.""" + session = await get_session_db(session_id) + if not session: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + ground_truth = session.get("ground_truth") or {} + gt = { + "is_correct": req.is_correct, + "corrected_columns": req.corrected_columns, + "notes": req.notes, + "saved_at": datetime.utcnow().isoformat(), + "column_result": session.get("column_result"), + } + ground_truth["columns"] = gt + + await update_session_db(session_id, ground_truth=ground_truth) + + if session_id in _cache: + _cache[session_id]["ground_truth"] = ground_truth + + return {"session_id": session_id, "ground_truth": gt} + + +@router.get("/sessions/{session_id}/ground-truth/columns") +async def get_column_ground_truth(session_id: str): + """Retrieve saved ground truth for column detection, including auto vs GT diff.""" + session = await get_session_db(session_id) + if not session: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + ground_truth = session.get("ground_truth") or {} + columns_gt = ground_truth.get("columns") + if not columns_gt: + raise HTTPException(status_code=404, detail="No column ground truth saved") + + return { + "session_id": session_id, + "columns_gt": columns_gt, + "columns_auto": session.get("column_result"), + } diff --git a/klausur-service/backend/ocr_pipeline_deskew.py b/klausur-service/backend/ocr_pipeline_deskew.py new file mode 100644 index 0000000..07dc270 --- /dev/null +++ b/klausur-service/backend/ocr_pipeline_deskew.py @@ -0,0 +1,236 @@ +""" +OCR Pipeline Deskew Endpoints (Step 2) + +Auto deskew, manual deskew, and ground truth for the deskew step. +Extracted from ocr_pipeline_geometry.py for file-size compliance. +""" + +import logging +import time +from datetime import datetime + +import cv2 +from fastapi import APIRouter, HTTPException + +from cv_vocab_pipeline import ( + create_ocr_image, + deskew_image, + deskew_image_by_word_alignment, + deskew_two_pass, +) +from ocr_pipeline_session_store import ( + get_session_db, + update_session_db, +) +from ocr_pipeline_common import ( + _cache, + _load_session_to_cache, + _get_cached, + _append_pipeline_log, + ManualDeskewRequest, + DeskewGroundTruthRequest, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"]) + + +@router.post("/sessions/{session_id}/deskew") +async def auto_deskew(session_id: str): + """Two-pass deskew: iterative projection (wide range) + word-alignment residual.""" + # Ensure session is in cache + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + # Deskew runs right after orientation -- use oriented image, fall back to original + img_bgr = next((v for k in ("oriented_bgr", "original_bgr") + if (v := cached.get(k)) is not None), None) + if img_bgr is None: + raise HTTPException(status_code=400, detail="No image available for deskewing") + + t0 = time.time() + + # Two-pass deskew: iterative (+-5 deg) + word-alignment residual check + deskewed_bgr, angle_applied, two_pass_debug = deskew_two_pass(img_bgr.copy()) + + # Also run individual methods for reporting (non-authoritative) + try: + _, angle_hough = deskew_image(img_bgr.copy()) + except Exception: + angle_hough = 0.0 + + success_enc, png_orig = cv2.imencode(".png", img_bgr) + orig_bytes = png_orig.tobytes() if success_enc else b"" + try: + _, angle_wa = deskew_image_by_word_alignment(orig_bytes) + except Exception: + angle_wa = 0.0 + + angle_iterative = two_pass_debug.get("pass1_angle", 0.0) + angle_residual = two_pass_debug.get("pass2_angle", 0.0) + angle_textline = two_pass_debug.get("pass3_angle", 0.0) + + duration = time.time() - t0 + + method_used = "three_pass" if abs(angle_textline) >= 0.01 else ( + "two_pass" if abs(angle_residual) >= 0.01 else "iterative" + ) + + # Encode as PNG + success, deskewed_png_buf = cv2.imencode(".png", deskewed_bgr) + deskewed_png = deskewed_png_buf.tobytes() if success else b"" + + # Create binarized version + binarized_png = None + try: + binarized = create_ocr_image(deskewed_bgr) + success_bin, bin_buf = cv2.imencode(".png", binarized) + binarized_png = bin_buf.tobytes() if success_bin else None + except Exception as e: + logger.warning(f"Binarization failed: {e}") + + confidence = max(0.5, 1.0 - abs(angle_applied) / 5.0) + + deskew_result = { + "angle_hough": round(angle_hough, 3), + "angle_word_alignment": round(angle_wa, 3), + "angle_iterative": round(angle_iterative, 3), + "angle_residual": round(angle_residual, 3), + "angle_textline": round(angle_textline, 3), + "angle_applied": round(angle_applied, 3), + "method_used": method_used, + "confidence": round(confidence, 2), + "duration_seconds": round(duration, 2), + "two_pass_debug": two_pass_debug, + } + + # Update cache + cached["deskewed_bgr"] = deskewed_bgr + cached["binarized_png"] = binarized_png + cached["deskew_result"] = deskew_result + + # Persist to DB + db_update = { + "deskewed_png": deskewed_png, + "deskew_result": deskew_result, + "current_step": 3, + } + if binarized_png: + db_update["binarized_png"] = binarized_png + await update_session_db(session_id, **db_update) + + logger.info(f"OCR Pipeline: deskew session {session_id}: " + f"hough={angle_hough:.2f} wa={angle_wa:.2f} " + f"iter={angle_iterative:.2f} residual={angle_residual:.2f} " + f"textline={angle_textline:.2f} " + f"-> {method_used} total={angle_applied:.2f}") + + await _append_pipeline_log(session_id, "deskew", { + "angle_applied": round(angle_applied, 3), + "angle_iterative": round(angle_iterative, 3), + "angle_residual": round(angle_residual, 3), + "angle_textline": round(angle_textline, 3), + "confidence": round(confidence, 2), + "method": method_used, + }, duration_ms=int(duration * 1000)) + + return { + "session_id": session_id, + **deskew_result, + "deskewed_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/deskewed", + "binarized_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/binarized", + } + + +@router.post("/sessions/{session_id}/deskew/manual") +async def manual_deskew(session_id: str, req: ManualDeskewRequest): + """Apply a manual rotation angle to the oriented image.""" + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + img_bgr = next((v for k in ("oriented_bgr", "original_bgr") + if (v := cached.get(k)) is not None), None) + if img_bgr is None: + raise HTTPException(status_code=400, detail="No image available for deskewing") + + angle = max(-5.0, min(5.0, req.angle)) + + h, w = img_bgr.shape[:2] + center = (w // 2, h // 2) + M = cv2.getRotationMatrix2D(center, angle, 1.0) + rotated = cv2.warpAffine(img_bgr, M, (w, h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE) + + success, png_buf = cv2.imencode(".png", rotated) + deskewed_png = png_buf.tobytes() if success else b"" + + # Binarize + binarized_png = None + try: + binarized = create_ocr_image(rotated) + success_bin, bin_buf = cv2.imencode(".png", binarized) + binarized_png = bin_buf.tobytes() if success_bin else None + except Exception: + pass + + deskew_result = { + **(cached.get("deskew_result") or {}), + "angle_applied": round(angle, 3), + "method_used": "manual", + } + + # Update cache + cached["deskewed_bgr"] = rotated + cached["binarized_png"] = binarized_png + cached["deskew_result"] = deskew_result + + # Persist to DB + db_update = { + "deskewed_png": deskewed_png, + "deskew_result": deskew_result, + } + if binarized_png: + db_update["binarized_png"] = binarized_png + await update_session_db(session_id, **db_update) + + logger.info(f"OCR Pipeline: manual deskew session {session_id}: {angle:.2f}") + + return { + "session_id": session_id, + "angle_applied": round(angle, 3), + "method_used": "manual", + "deskewed_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/deskewed", + } + + +@router.post("/sessions/{session_id}/ground-truth/deskew") +async def save_deskew_ground_truth(session_id: str, req: DeskewGroundTruthRequest): + """Save ground truth feedback for the deskew step.""" + session = await get_session_db(session_id) + if not session: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + ground_truth = session.get("ground_truth") or {} + gt = { + "is_correct": req.is_correct, + "corrected_angle": req.corrected_angle, + "notes": req.notes, + "saved_at": datetime.utcnow().isoformat(), + "deskew_result": session.get("deskew_result"), + } + ground_truth["deskew"] = gt + + await update_session_db(session_id, ground_truth=ground_truth) + + # Update cache + if session_id in _cache: + _cache[session_id]["ground_truth"] = ground_truth + + logger.info(f"OCR Pipeline: ground truth deskew session {session_id}: " + f"correct={req.is_correct}, corrected_angle={req.corrected_angle}") + + return {"session_id": session_id, "ground_truth": gt} diff --git a/klausur-service/backend/ocr_pipeline_dewarp.py b/klausur-service/backend/ocr_pipeline_dewarp.py new file mode 100644 index 0000000..b8eaa38 --- /dev/null +++ b/klausur-service/backend/ocr_pipeline_dewarp.py @@ -0,0 +1,346 @@ +""" +OCR Pipeline Dewarp Endpoints + +Auto dewarp (with VLM/CV ensemble), manual dewarp, combined +rotation+shear adjustment, and ground truth. +Extracted from ocr_pipeline_geometry.py for file-size compliance. +""" + +import json +import logging +import os +import re +import time +from datetime import datetime +from typing import Any, Dict + +import cv2 +from fastapi import APIRouter, HTTPException, Query + +from cv_vocab_pipeline import ( + _apply_shear, + create_ocr_image, + dewarp_image, + dewarp_image_manual, +) +from ocr_pipeline_session_store import ( + get_session_db, + update_session_db, +) +from ocr_pipeline_common import ( + _cache, + _load_session_to_cache, + _get_cached, + _append_pipeline_log, + ManualDewarpRequest, + CombinedAdjustRequest, + DewarpGroundTruthRequest, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"]) + + +async def _detect_shear_with_vlm(image_bytes: bytes) -> Dict[str, Any]: + """Ask qwen2.5vl:32b to estimate the vertical shear angle of a scanned page. + + The VLM is shown the image and asked: are the column/table borders tilted? + If yes, by how many degrees? Returns a dict with shear_degrees and confidence. + Confidence is 0.0 if Ollama is unavailable or parsing fails. + """ + import httpx + import base64 + + ollama_base = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") + model = os.getenv("OLLAMA_HTR_MODEL", "qwen2.5vl:32b") + + prompt = ( + "This is a scanned vocabulary worksheet. Look at the vertical borders of the table columns. " + "Are they perfectly vertical, or do they tilt slightly? " + "If they tilt, estimate the tilt angle in degrees (positive = top tilts right, negative = top tilts left). " + "Reply with ONLY a JSON object like: {\"shear_degrees\": 1.2, \"confidence\": 0.8} " + "Use confidence 0.0-1.0 based on how clearly you can see the tilt. " + "If the columns look straight, return {\"shear_degrees\": 0.0, \"confidence\": 0.9}" + ) + + img_b64 = base64.b64encode(image_bytes).decode("utf-8") + payload = { + "model": model, + "prompt": prompt, + "images": [img_b64], + "stream": False, + } + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post(f"{ollama_base}/api/generate", json=payload) + resp.raise_for_status() + text = resp.json().get("response", "") + + # Parse JSON from response (may have surrounding text) + match = re.search(r'\{[^}]+\}', text) + if match: + data = json.loads(match.group(0)) + shear = float(data.get("shear_degrees", 0.0)) + conf = float(data.get("confidence", 0.0)) + # Clamp to reasonable range + shear = max(-3.0, min(3.0, shear)) + conf = max(0.0, min(1.0, conf)) + return {"method": "vlm_qwen2.5vl", "shear_degrees": round(shear, 3), "confidence": round(conf, 2)} + except Exception as e: + logger.warning(f"VLM dewarp failed: {e}") + + return {"method": "vlm_qwen2.5vl", "shear_degrees": 0.0, "confidence": 0.0} + + +@router.post("/sessions/{session_id}/dewarp") +async def auto_dewarp( + session_id: str, + method: str = Query("ensemble", description="Detection method: ensemble | vlm | cv"), +): + """Detect and correct vertical shear on the deskewed image. + + Methods: + - **ensemble** (default): 3-method CV ensemble (vertical edges + projection + Hough) + - **cv**: CV ensemble only (same as ensemble) + - **vlm**: Ask qwen2.5vl:32b to estimate the shear angle visually + """ + if method not in ("ensemble", "cv", "vlm"): + raise HTTPException(status_code=400, detail="method must be one of: ensemble, cv, vlm") + + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + deskewed_bgr = cached.get("deskewed_bgr") + if deskewed_bgr is None: + raise HTTPException(status_code=400, detail="Deskew must be completed before dewarp") + + t0 = time.time() + + if method == "vlm": + # Encode deskewed image to PNG for VLM + success, png_buf = cv2.imencode(".png", deskewed_bgr) + img_bytes = png_buf.tobytes() if success else b"" + vlm_det = await _detect_shear_with_vlm(img_bytes) + shear_deg = vlm_det["shear_degrees"] + if abs(shear_deg) >= 0.05 and vlm_det["confidence"] >= 0.3: + dewarped_bgr = _apply_shear(deskewed_bgr, -shear_deg) + else: + dewarped_bgr = deskewed_bgr + dewarp_info = { + "method": vlm_det["method"], + "shear_degrees": shear_deg, + "confidence": vlm_det["confidence"], + "detections": [vlm_det], + } + else: + dewarped_bgr, dewarp_info = dewarp_image(deskewed_bgr) + + duration = time.time() - t0 + + # Encode as PNG + success, png_buf = cv2.imencode(".png", dewarped_bgr) + dewarped_png = png_buf.tobytes() if success else b"" + + dewarp_result = { + "method_used": dewarp_info["method"], + "shear_degrees": dewarp_info["shear_degrees"], + "confidence": dewarp_info["confidence"], + "duration_seconds": round(duration, 2), + "detections": dewarp_info.get("detections", []), + } + + # Update cache + cached["dewarped_bgr"] = dewarped_bgr + cached["dewarp_result"] = dewarp_result + + # Persist to DB + await update_session_db( + session_id, + dewarped_png=dewarped_png, + dewarp_result=dewarp_result, + auto_shear_degrees=dewarp_info.get("shear_degrees", 0.0), + current_step=4, + ) + + logger.info(f"OCR Pipeline: dewarp session {session_id}: " + f"method={dewarp_info['method']} shear={dewarp_info['shear_degrees']:.3f} " + f"conf={dewarp_info['confidence']:.2f} ({duration:.2f}s)") + + await _append_pipeline_log(session_id, "dewarp", { + "shear_degrees": dewarp_info["shear_degrees"], + "confidence": dewarp_info["confidence"], + "method": dewarp_info["method"], + "ensemble_methods": [d.get("method", "") for d in dewarp_info.get("detections", [])], + }, duration_ms=int(duration * 1000)) + + return { + "session_id": session_id, + **dewarp_result, + "dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped", + } + + +@router.post("/sessions/{session_id}/dewarp/manual") +async def manual_dewarp(session_id: str, req: ManualDewarpRequest): + """Apply shear correction with a manual angle.""" + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + deskewed_bgr = cached.get("deskewed_bgr") + if deskewed_bgr is None: + raise HTTPException(status_code=400, detail="Deskew must be completed before dewarp") + + shear_deg = max(-2.0, min(2.0, req.shear_degrees)) + + if abs(shear_deg) < 0.001: + dewarped_bgr = deskewed_bgr + else: + dewarped_bgr = dewarp_image_manual(deskewed_bgr, shear_deg) + + success, png_buf = cv2.imencode(".png", dewarped_bgr) + dewarped_png = png_buf.tobytes() if success else b"" + + dewarp_result = { + **(cached.get("dewarp_result") or {}), + "method_used": "manual", + "shear_degrees": round(shear_deg, 3), + } + + # Update cache + cached["dewarped_bgr"] = dewarped_bgr + cached["dewarp_result"] = dewarp_result + + # Persist to DB + await update_session_db( + session_id, + dewarped_png=dewarped_png, + dewarp_result=dewarp_result, + ) + + logger.info(f"OCR Pipeline: manual dewarp session {session_id}: shear={shear_deg:.3f}") + + return { + "session_id": session_id, + "shear_degrees": round(shear_deg, 3), + "method_used": "manual", + "dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped", + } + + +@router.post("/sessions/{session_id}/adjust-combined") +async def adjust_combined(session_id: str, req: CombinedAdjustRequest): + """Apply rotation + shear combined to the original image. + + Used by the fine-tuning sliders to preview arbitrary rotation/shear + combinations without re-running the full deskew/dewarp pipeline. + """ + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + img_bgr = cached.get("original_bgr") + if img_bgr is None: + raise HTTPException(status_code=400, detail="Original image not available") + + rotation = max(-15.0, min(15.0, req.rotation_degrees)) + shear_deg = max(-5.0, min(5.0, req.shear_degrees)) + + h, w = img_bgr.shape[:2] + result_bgr = img_bgr + + # Step 1: Apply rotation + if abs(rotation) >= 0.001: + center = (w // 2, h // 2) + M = cv2.getRotationMatrix2D(center, rotation, 1.0) + result_bgr = cv2.warpAffine(result_bgr, M, (w, h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE) + + # Step 2: Apply shear + if abs(shear_deg) >= 0.001: + result_bgr = dewarp_image_manual(result_bgr, shear_deg) + + # Encode + success, png_buf = cv2.imencode(".png", result_bgr) + dewarped_png = png_buf.tobytes() if success else b"" + + # Binarize + binarized_png = None + try: + binarized = create_ocr_image(result_bgr) + success_bin, bin_buf = cv2.imencode(".png", binarized) + binarized_png = bin_buf.tobytes() if success_bin else None + except Exception: + pass + + # Build combined result dicts + deskew_result = { + **(cached.get("deskew_result") or {}), + "angle_applied": round(rotation, 3), + "method_used": "manual_combined", + } + dewarp_result = { + **(cached.get("dewarp_result") or {}), + "method_used": "manual_combined", + "shear_degrees": round(shear_deg, 3), + } + + # Update cache + cached["deskewed_bgr"] = result_bgr + cached["dewarped_bgr"] = result_bgr + cached["deskew_result"] = deskew_result + cached["dewarp_result"] = dewarp_result + + # Persist to DB + db_update = { + "dewarped_png": dewarped_png, + "deskew_result": deskew_result, + "dewarp_result": dewarp_result, + } + if binarized_png: + db_update["binarized_png"] = binarized_png + db_update["deskewed_png"] = dewarped_png + await update_session_db(session_id, **db_update) + + logger.info(f"OCR Pipeline: combined adjust session {session_id}: " + f"rotation={rotation:.3f} shear={shear_deg:.3f}") + + return { + "session_id": session_id, + "rotation_degrees": round(rotation, 3), + "shear_degrees": round(shear_deg, 3), + "method_used": "manual_combined", + "dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped", + } + + +@router.post("/sessions/{session_id}/ground-truth/dewarp") +async def save_dewarp_ground_truth(session_id: str, req: DewarpGroundTruthRequest): + """Save ground truth feedback for the dewarp step.""" + session = await get_session_db(session_id) + if not session: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + ground_truth = session.get("ground_truth") or {} + gt = { + "is_correct": req.is_correct, + "corrected_shear": req.corrected_shear, + "notes": req.notes, + "saved_at": datetime.utcnow().isoformat(), + "dewarp_result": session.get("dewarp_result"), + } + ground_truth["dewarp"] = gt + + await update_session_db(session_id, ground_truth=ground_truth) + + if session_id in _cache: + _cache[session_id]["ground_truth"] = ground_truth + + logger.info(f"OCR Pipeline: ground truth dewarp session {session_id}: " + f"correct={req.is_correct}, corrected_shear={req.corrected_shear}") + + return {"session_id": session_id, "ground_truth": gt} diff --git a/klausur-service/backend/ocr_pipeline_geometry.py b/klausur-service/backend/ocr_pipeline_geometry.py index 510c6c8..3d03619 100644 --- a/klausur-service/backend/ocr_pipeline_geometry.py +++ b/klausur-service/backend/ocr_pipeline_geometry.py @@ -1,1105 +1,27 @@ """ -OCR Pipeline Geometry API - Deskew, Dewarp, Structure Detection, Column Detection. +OCR Pipeline Geometry API (barrel re-export) -Extracted from ocr_pipeline_api.py to keep modules focused. -Each endpoint group handles a geometric correction or detection step: -- Deskew (Step 2): Correct scan rotation -- Dewarp (Step 3): Correct vertical shear / book warp -- Structure Detection: Boxes, zones, color regions, graphics -- Column Detection (Step 5): Find invisible columns +This module was split into: + - ocr_pipeline_deskew.py (Deskew endpoints) + - ocr_pipeline_dewarp.py (Dewarp endpoints) + - ocr_pipeline_structure.py (Structure detection + exclude regions) + - ocr_pipeline_columns.py (Column detection + ground truth) -Lizenz: Apache 2.0 -DATENSCHUTZ: Alle Verarbeitung erfolgt lokal. +The `router` object is assembled here by including all sub-routers. +Importers that did `from ocr_pipeline_geometry import router` continue to work. """ -import logging -import os -import time -from dataclasses import asdict -from datetime import datetime -from typing import Any, Dict, List, Optional - -import cv2 -import numpy as np -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -from cv_vocab_pipeline import ( - _apply_shear, - _detect_header_footer_gaps, - _detect_sub_columns, - classify_column_types, - create_layout_image, - create_ocr_image, - analyze_layout, - deskew_image, - deskew_image_by_word_alignment, - deskew_two_pass, - detect_column_geometry_zoned, - dewarp_image, - dewarp_image_manual, - expand_narrow_columns, -) -from cv_box_detect import detect_boxes -from cv_color_detect import _COLOR_RANGES, _COLOR_HEX -from cv_graphic_detect import detect_graphic_elements -from ocr_pipeline_session_store import ( - get_session_db, - update_session_db, -) -from ocr_pipeline_common import ( - _cache, - _load_session_to_cache, - _get_cached, - _get_base_image_png, - _append_pipeline_log, - _filter_border_ghost_words, - ManualDeskewRequest, - DeskewGroundTruthRequest, - ManualDewarpRequest, - CombinedAdjustRequest, - DewarpGroundTruthRequest, - ManualColumnsRequest, - ColumnGroundTruthRequest, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"]) - -# --------------------------------------------------------------------------- -# Deskew Endpoints (Step 2) -# --------------------------------------------------------------------------- - -@router.post("/sessions/{session_id}/deskew") -async def auto_deskew(session_id: str): - """Two-pass deskew: iterative projection (wide range) + word-alignment residual.""" - # Ensure session is in cache - if session_id not in _cache: - await _load_session_to_cache(session_id) - cached = _get_cached(session_id) - - # Deskew runs right after orientation — use oriented image, fall back to original - img_bgr = next((v for k in ("oriented_bgr", "original_bgr") - if (v := cached.get(k)) is not None), None) - if img_bgr is None: - raise HTTPException(status_code=400, detail="No image available for deskewing") - - t0 = time.time() - - # Two-pass deskew: iterative (±5°) + word-alignment residual check - deskewed_bgr, angle_applied, two_pass_debug = deskew_two_pass(img_bgr.copy()) - - # Also run individual methods for reporting (non-authoritative) - try: - _, angle_hough = deskew_image(img_bgr.copy()) - except Exception: - angle_hough = 0.0 - - success_enc, png_orig = cv2.imencode(".png", img_bgr) - orig_bytes = png_orig.tobytes() if success_enc else b"" - try: - _, angle_wa = deskew_image_by_word_alignment(orig_bytes) - except Exception: - angle_wa = 0.0 - - angle_iterative = two_pass_debug.get("pass1_angle", 0.0) - angle_residual = two_pass_debug.get("pass2_angle", 0.0) - angle_textline = two_pass_debug.get("pass3_angle", 0.0) - - duration = time.time() - t0 - - method_used = "three_pass" if abs(angle_textline) >= 0.01 else ( - "two_pass" if abs(angle_residual) >= 0.01 else "iterative" - ) - - # Encode as PNG - success, deskewed_png_buf = cv2.imencode(".png", deskewed_bgr) - deskewed_png = deskewed_png_buf.tobytes() if success else b"" - - # Create binarized version - binarized_png = None - try: - binarized = create_ocr_image(deskewed_bgr) - success_bin, bin_buf = cv2.imencode(".png", binarized) - binarized_png = bin_buf.tobytes() if success_bin else None - except Exception as e: - logger.warning(f"Binarization failed: {e}") - - confidence = max(0.5, 1.0 - abs(angle_applied) / 5.0) - - deskew_result = { - "angle_hough": round(angle_hough, 3), - "angle_word_alignment": round(angle_wa, 3), - "angle_iterative": round(angle_iterative, 3), - "angle_residual": round(angle_residual, 3), - "angle_textline": round(angle_textline, 3), - "angle_applied": round(angle_applied, 3), - "method_used": method_used, - "confidence": round(confidence, 2), - "duration_seconds": round(duration, 2), - "two_pass_debug": two_pass_debug, - } - - # Update cache - cached["deskewed_bgr"] = deskewed_bgr - cached["binarized_png"] = binarized_png - cached["deskew_result"] = deskew_result - - # Persist to DB - db_update = { - "deskewed_png": deskewed_png, - "deskew_result": deskew_result, - "current_step": 3, - } - if binarized_png: - db_update["binarized_png"] = binarized_png - await update_session_db(session_id, **db_update) - - logger.info(f"OCR Pipeline: deskew session {session_id}: " - f"hough={angle_hough:.2f} wa={angle_wa:.2f} " - f"iter={angle_iterative:.2f} residual={angle_residual:.2f} " - f"textline={angle_textline:.2f} " - f"-> {method_used} total={angle_applied:.2f}") - - await _append_pipeline_log(session_id, "deskew", { - "angle_applied": round(angle_applied, 3), - "angle_iterative": round(angle_iterative, 3), - "angle_residual": round(angle_residual, 3), - "angle_textline": round(angle_textline, 3), - "confidence": round(confidence, 2), - "method": method_used, - }, duration_ms=int(duration * 1000)) - - return { - "session_id": session_id, - **deskew_result, - "deskewed_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/deskewed", - "binarized_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/binarized", - } - - -@router.post("/sessions/{session_id}/deskew/manual") -async def manual_deskew(session_id: str, req: ManualDeskewRequest): - """Apply a manual rotation angle to the oriented image.""" - if session_id not in _cache: - await _load_session_to_cache(session_id) - cached = _get_cached(session_id) - - img_bgr = next((v for k in ("oriented_bgr", "original_bgr") - if (v := cached.get(k)) is not None), None) - if img_bgr is None: - raise HTTPException(status_code=400, detail="No image available for deskewing") - - angle = max(-5.0, min(5.0, req.angle)) - - h, w = img_bgr.shape[:2] - center = (w // 2, h // 2) - M = cv2.getRotationMatrix2D(center, angle, 1.0) - rotated = cv2.warpAffine(img_bgr, M, (w, h), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE) - - success, png_buf = cv2.imencode(".png", rotated) - deskewed_png = png_buf.tobytes() if success else b"" - - # Binarize - binarized_png = None - try: - binarized = create_ocr_image(rotated) - success_bin, bin_buf = cv2.imencode(".png", binarized) - binarized_png = bin_buf.tobytes() if success_bin else None - except Exception: - pass - - deskew_result = { - **(cached.get("deskew_result") or {}), - "angle_applied": round(angle, 3), - "method_used": "manual", - } - - # Update cache - cached["deskewed_bgr"] = rotated - cached["binarized_png"] = binarized_png - cached["deskew_result"] = deskew_result - - # Persist to DB - db_update = { - "deskewed_png": deskewed_png, - "deskew_result": deskew_result, - } - if binarized_png: - db_update["binarized_png"] = binarized_png - await update_session_db(session_id, **db_update) - - logger.info(f"OCR Pipeline: manual deskew session {session_id}: {angle:.2f}") - - return { - "session_id": session_id, - "angle_applied": round(angle, 3), - "method_used": "manual", - "deskewed_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/deskewed", - } - - -@router.post("/sessions/{session_id}/ground-truth/deskew") -async def save_deskew_ground_truth(session_id: str, req: DeskewGroundTruthRequest): - """Save ground truth feedback for the deskew step.""" - session = await get_session_db(session_id) - if not session: - raise HTTPException(status_code=404, detail=f"Session {session_id} not found") - - ground_truth = session.get("ground_truth") or {} - gt = { - "is_correct": req.is_correct, - "corrected_angle": req.corrected_angle, - "notes": req.notes, - "saved_at": datetime.utcnow().isoformat(), - "deskew_result": session.get("deskew_result"), - } - ground_truth["deskew"] = gt - - await update_session_db(session_id, ground_truth=ground_truth) - - # Update cache - if session_id in _cache: - _cache[session_id]["ground_truth"] = ground_truth - - logger.info(f"OCR Pipeline: ground truth deskew session {session_id}: " - f"correct={req.is_correct}, corrected_angle={req.corrected_angle}") - - return {"session_id": session_id, "ground_truth": gt} - - -# --------------------------------------------------------------------------- -# Dewarp Endpoints -# --------------------------------------------------------------------------- - -async def _detect_shear_with_vlm(image_bytes: bytes) -> Dict[str, Any]: - """Ask qwen2.5vl:32b to estimate the vertical shear angle of a scanned page. - - The VLM is shown the image and asked: are the column/table borders tilted? - If yes, by how many degrees? Returns a dict with shear_degrees and confidence. - Confidence is 0.0 if Ollama is unavailable or parsing fails. - """ - import httpx - import base64 - import re - - ollama_base = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") - model = os.getenv("OLLAMA_HTR_MODEL", "qwen2.5vl:32b") - - prompt = ( - "This is a scanned vocabulary worksheet. Look at the vertical borders of the table columns. " - "Are they perfectly vertical, or do they tilt slightly? " - "If they tilt, estimate the tilt angle in degrees (positive = top tilts right, negative = top tilts left). " - "Reply with ONLY a JSON object like: {\"shear_degrees\": 1.2, \"confidence\": 0.8} " - "Use confidence 0.0-1.0 based on how clearly you can see the tilt. " - "If the columns look straight, return {\"shear_degrees\": 0.0, \"confidence\": 0.9}" - ) - - img_b64 = base64.b64encode(image_bytes).decode("utf-8") - payload = { - "model": model, - "prompt": prompt, - "images": [img_b64], - "stream": False, - } - - try: - async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.post(f"{ollama_base}/api/generate", json=payload) - resp.raise_for_status() - text = resp.json().get("response", "") - - # Parse JSON from response (may have surrounding text) - match = re.search(r'\{[^}]+\}', text) - if match: - import json - data = json.loads(match.group(0)) - shear = float(data.get("shear_degrees", 0.0)) - conf = float(data.get("confidence", 0.0)) - # Clamp to reasonable range - shear = max(-3.0, min(3.0, shear)) - conf = max(0.0, min(1.0, conf)) - return {"method": "vlm_qwen2.5vl", "shear_degrees": round(shear, 3), "confidence": round(conf, 2)} - except Exception as e: - logger.warning(f"VLM dewarp failed: {e}") - - return {"method": "vlm_qwen2.5vl", "shear_degrees": 0.0, "confidence": 0.0} - - -@router.post("/sessions/{session_id}/dewarp") -async def auto_dewarp( - session_id: str, - method: str = Query("ensemble", description="Detection method: ensemble | vlm | cv"), -): - """Detect and correct vertical shear on the deskewed image. - - Methods: - - **ensemble** (default): 3-method CV ensemble (vertical edges + projection + Hough) - - **cv**: CV ensemble only (same as ensemble) - - **vlm**: Ask qwen2.5vl:32b to estimate the shear angle visually - """ - if method not in ("ensemble", "cv", "vlm"): - raise HTTPException(status_code=400, detail="method must be one of: ensemble, cv, vlm") - - if session_id not in _cache: - await _load_session_to_cache(session_id) - cached = _get_cached(session_id) - - deskewed_bgr = cached.get("deskewed_bgr") - if deskewed_bgr is None: - raise HTTPException(status_code=400, detail="Deskew must be completed before dewarp") - - t0 = time.time() - - if method == "vlm": - # Encode deskewed image to PNG for VLM - success, png_buf = cv2.imencode(".png", deskewed_bgr) - img_bytes = png_buf.tobytes() if success else b"" - vlm_det = await _detect_shear_with_vlm(img_bytes) - shear_deg = vlm_det["shear_degrees"] - if abs(shear_deg) >= 0.05 and vlm_det["confidence"] >= 0.3: - dewarped_bgr = _apply_shear(deskewed_bgr, -shear_deg) - else: - dewarped_bgr = deskewed_bgr - dewarp_info = { - "method": vlm_det["method"], - "shear_degrees": shear_deg, - "confidence": vlm_det["confidence"], - "detections": [vlm_det], - } - else: - dewarped_bgr, dewarp_info = dewarp_image(deskewed_bgr) - - duration = time.time() - t0 - - # Encode as PNG - success, png_buf = cv2.imencode(".png", dewarped_bgr) - dewarped_png = png_buf.tobytes() if success else b"" - - dewarp_result = { - "method_used": dewarp_info["method"], - "shear_degrees": dewarp_info["shear_degrees"], - "confidence": dewarp_info["confidence"], - "duration_seconds": round(duration, 2), - "detections": dewarp_info.get("detections", []), - } - - # Update cache - cached["dewarped_bgr"] = dewarped_bgr - cached["dewarp_result"] = dewarp_result - - # Persist to DB - await update_session_db( - session_id, - dewarped_png=dewarped_png, - dewarp_result=dewarp_result, - auto_shear_degrees=dewarp_info.get("shear_degrees", 0.0), - current_step=4, - ) - - logger.info(f"OCR Pipeline: dewarp session {session_id}: " - f"method={dewarp_info['method']} shear={dewarp_info['shear_degrees']:.3f} " - f"conf={dewarp_info['confidence']:.2f} ({duration:.2f}s)") - - await _append_pipeline_log(session_id, "dewarp", { - "shear_degrees": dewarp_info["shear_degrees"], - "confidence": dewarp_info["confidence"], - "method": dewarp_info["method"], - "ensemble_methods": [d.get("method", "") for d in dewarp_info.get("detections", [])], - }, duration_ms=int(duration * 1000)) - - return { - "session_id": session_id, - **dewarp_result, - "dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped", - } - - -@router.post("/sessions/{session_id}/dewarp/manual") -async def manual_dewarp(session_id: str, req: ManualDewarpRequest): - """Apply shear correction with a manual angle.""" - if session_id not in _cache: - await _load_session_to_cache(session_id) - cached = _get_cached(session_id) - - deskewed_bgr = cached.get("deskewed_bgr") - if deskewed_bgr is None: - raise HTTPException(status_code=400, detail="Deskew must be completed before dewarp") - - shear_deg = max(-2.0, min(2.0, req.shear_degrees)) - - if abs(shear_deg) < 0.001: - dewarped_bgr = deskewed_bgr - else: - dewarped_bgr = dewarp_image_manual(deskewed_bgr, shear_deg) - - success, png_buf = cv2.imencode(".png", dewarped_bgr) - dewarped_png = png_buf.tobytes() if success else b"" - - dewarp_result = { - **(cached.get("dewarp_result") or {}), - "method_used": "manual", - "shear_degrees": round(shear_deg, 3), - } - - # Update cache - cached["dewarped_bgr"] = dewarped_bgr - cached["dewarp_result"] = dewarp_result - - # Persist to DB - await update_session_db( - session_id, - dewarped_png=dewarped_png, - dewarp_result=dewarp_result, - ) - - logger.info(f"OCR Pipeline: manual dewarp session {session_id}: shear={shear_deg:.3f}") - - return { - "session_id": session_id, - "shear_degrees": round(shear_deg, 3), - "method_used": "manual", - "dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped", - } - - -@router.post("/sessions/{session_id}/adjust-combined") -async def adjust_combined(session_id: str, req: CombinedAdjustRequest): - """Apply rotation + shear combined to the original image. - - Used by the fine-tuning sliders to preview arbitrary rotation/shear - combinations without re-running the full deskew/dewarp pipeline. - """ - if session_id not in _cache: - await _load_session_to_cache(session_id) - cached = _get_cached(session_id) - - img_bgr = cached.get("original_bgr") - if img_bgr is None: - raise HTTPException(status_code=400, detail="Original image not available") - - rotation = max(-15.0, min(15.0, req.rotation_degrees)) - shear_deg = max(-5.0, min(5.0, req.shear_degrees)) - - h, w = img_bgr.shape[:2] - result_bgr = img_bgr - - # Step 1: Apply rotation - if abs(rotation) >= 0.001: - center = (w // 2, h // 2) - M = cv2.getRotationMatrix2D(center, rotation, 1.0) - result_bgr = cv2.warpAffine(result_bgr, M, (w, h), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE) - - # Step 2: Apply shear - if abs(shear_deg) >= 0.001: - result_bgr = dewarp_image_manual(result_bgr, shear_deg) - - # Encode - success, png_buf = cv2.imencode(".png", result_bgr) - dewarped_png = png_buf.tobytes() if success else b"" - - # Binarize - binarized_png = None - try: - binarized = create_ocr_image(result_bgr) - success_bin, bin_buf = cv2.imencode(".png", binarized) - binarized_png = bin_buf.tobytes() if success_bin else None - except Exception: - pass - - # Build combined result dicts - deskew_result = { - **(cached.get("deskew_result") or {}), - "angle_applied": round(rotation, 3), - "method_used": "manual_combined", - } - dewarp_result = { - **(cached.get("dewarp_result") or {}), - "method_used": "manual_combined", - "shear_degrees": round(shear_deg, 3), - } - - # Update cache - cached["deskewed_bgr"] = result_bgr - cached["dewarped_bgr"] = result_bgr - cached["deskew_result"] = deskew_result - cached["dewarp_result"] = dewarp_result - - # Persist to DB - db_update = { - "dewarped_png": dewarped_png, - "deskew_result": deskew_result, - "dewarp_result": dewarp_result, - } - if binarized_png: - db_update["binarized_png"] = binarized_png - db_update["deskewed_png"] = dewarped_png - await update_session_db(session_id, **db_update) - - logger.info(f"OCR Pipeline: combined adjust session {session_id}: " - f"rotation={rotation:.3f} shear={shear_deg:.3f}") - - return { - "session_id": session_id, - "rotation_degrees": round(rotation, 3), - "shear_degrees": round(shear_deg, 3), - "method_used": "manual_combined", - "dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped", - } - - -@router.post("/sessions/{session_id}/ground-truth/dewarp") -async def save_dewarp_ground_truth(session_id: str, req: DewarpGroundTruthRequest): - """Save ground truth feedback for the dewarp step.""" - session = await get_session_db(session_id) - if not session: - raise HTTPException(status_code=404, detail=f"Session {session_id} not found") - - ground_truth = session.get("ground_truth") or {} - gt = { - "is_correct": req.is_correct, - "corrected_shear": req.corrected_shear, - "notes": req.notes, - "saved_at": datetime.utcnow().isoformat(), - "dewarp_result": session.get("dewarp_result"), - } - ground_truth["dewarp"] = gt - - await update_session_db(session_id, ground_truth=ground_truth) - - if session_id in _cache: - _cache[session_id]["ground_truth"] = ground_truth - - logger.info(f"OCR Pipeline: ground truth dewarp session {session_id}: " - f"correct={req.is_correct}, corrected_shear={req.corrected_shear}") - - return {"session_id": session_id, "ground_truth": gt} - - -# --------------------------------------------------------------------------- -# Structure Detection Endpoint -# --------------------------------------------------------------------------- - -@router.post("/sessions/{session_id}/detect-structure") -async def detect_structure(session_id: str): - """Detect document structure: boxes, zones, and color regions. - - Runs box detection (line + shading) and color analysis on the cropped - image. Returns structured JSON with all detected elements for the - structure visualization step. - """ - if session_id not in _cache: - await _load_session_to_cache(session_id) - cached = _get_cached(session_id) - - img_bgr = ( - cached.get("cropped_bgr") - if cached.get("cropped_bgr") is not None - else cached.get("dewarped_bgr") - ) - if img_bgr is None: - raise HTTPException(status_code=400, detail="Crop or dewarp must be completed first") - - t0 = time.time() - h, w = img_bgr.shape[:2] - - # --- Content bounds from word result (if available) or full image --- - word_result = cached.get("word_result") - words: List[Dict] = [] - if word_result and word_result.get("cells"): - for cell in word_result["cells"]: - for wb in (cell.get("word_boxes") or []): - words.append(wb) - # Fallback: use raw OCR words if cell word_boxes are empty - if not words and word_result: - for key in ("raw_paddle_words_split", "raw_tesseract_words", "raw_paddle_words"): - raw = word_result.get(key, []) - if raw: - words = raw - logger.info("detect-structure: using %d words from %s (no cell word_boxes)", len(words), key) - break - # If no words yet, use image dimensions with small margin - if words: - content_x = max(0, min(int(wb["left"]) for wb in words)) - content_y = max(0, min(int(wb["top"]) for wb in words)) - content_r = min(w, max(int(wb["left"] + wb["width"]) for wb in words)) - content_b = min(h, max(int(wb["top"] + wb["height"]) for wb in words)) - content_w_px = content_r - content_x - content_h_px = content_b - content_y - else: - margin = int(min(w, h) * 0.03) - content_x, content_y = margin, margin - content_w_px = w - 2 * margin - content_h_px = h - 2 * margin - - # --- Box detection --- - boxes = detect_boxes( - img_bgr, - content_x=content_x, - content_w=content_w_px, - content_y=content_y, - content_h=content_h_px, - ) - - # --- Zone splitting --- - from cv_box_detect import split_page_into_zones as _split_zones - zones = _split_zones(content_x, content_y, content_w_px, content_h_px, boxes) - - # --- Color region sampling --- - # Sample background shading in each detected box - hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) - box_colors = [] - for box in boxes: - # Sample the center region of each box - cy1 = box.y + box.height // 4 - cy2 = box.y + 3 * box.height // 4 - cx1 = box.x + box.width // 4 - cx2 = box.x + 3 * box.width // 4 - cy1 = max(0, min(cy1, h - 1)) - cy2 = max(0, min(cy2, h - 1)) - cx1 = max(0, min(cx1, w - 1)) - cx2 = max(0, min(cx2, w - 1)) - if cy2 > cy1 and cx2 > cx1: - roi_hsv = hsv[cy1:cy2, cx1:cx2] - med_h = float(np.median(roi_hsv[:, :, 0])) - med_s = float(np.median(roi_hsv[:, :, 1])) - med_v = float(np.median(roi_hsv[:, :, 2])) - if med_s > 15: - from cv_color_detect import _hue_to_color_name - bg_name = _hue_to_color_name(med_h) - bg_hex = _COLOR_HEX.get(bg_name, "#6b7280") - else: - bg_name = "gray" if med_v < 220 else "white" - bg_hex = "#6b7280" if bg_name == "gray" else "#ffffff" - else: - bg_name = "unknown" - bg_hex = "#6b7280" - box_colors.append({"color_name": bg_name, "color_hex": bg_hex}) - - # --- Color text detection overview --- - # Quick scan for colored text regions across the page - color_summary: Dict[str, int] = {} - for color_name, ranges in _COLOR_RANGES.items(): - mask = np.zeros((h, w), dtype=np.uint8) - for lower, upper in ranges: - mask = cv2.bitwise_or(mask, cv2.inRange(hsv, lower, upper)) - pixel_count = int(np.sum(mask > 0)) - if pixel_count > 50: # minimum threshold - color_summary[color_name] = pixel_count - - # --- Graphic element detection --- - box_dicts = [ - {"x": b.x, "y": b.y, "w": b.width, "h": b.height} - for b in boxes - ] - graphics = detect_graphic_elements( - img_bgr, words, - detected_boxes=box_dicts, - ) - - # --- Filter border-ghost words from OCR result --- - ghost_count = 0 - if boxes and word_result: - ghost_count = _filter_border_ghost_words(word_result, boxes) - if ghost_count: - logger.info("detect-structure: removed %d border-ghost words", ghost_count) - await update_session_db(session_id, word_result=word_result) - cached["word_result"] = word_result - - duration = time.time() - t0 - - # Preserve user-drawn exclude regions from previous run - prev_sr = cached.get("structure_result") or {} - prev_exclude = prev_sr.get("exclude_regions", []) - - result_dict = { - "image_width": w, - "image_height": h, - "content_bounds": { - "x": content_x, "y": content_y, - "w": content_w_px, "h": content_h_px, - }, - "boxes": [ - { - "x": b.x, "y": b.y, "w": b.width, "h": b.height, - "confidence": b.confidence, - "border_thickness": b.border_thickness, - "bg_color_name": box_colors[i]["color_name"], - "bg_color_hex": box_colors[i]["color_hex"], - } - for i, b in enumerate(boxes) - ], - "zones": [ - { - "index": z.index, - "zone_type": z.zone_type, - "y": z.y, "h": z.height, - "x": z.x, "w": z.width, - } - for z in zones - ], - "graphics": [ - { - "x": g.x, "y": g.y, "w": g.width, "h": g.height, - "area": g.area, - "shape": g.shape, - "color_name": g.color_name, - "color_hex": g.color_hex, - "confidence": round(g.confidence, 2), - } - for g in graphics - ], - "exclude_regions": prev_exclude, - "color_pixel_counts": color_summary, - "has_words": len(words) > 0, - "word_count": len(words), - "border_ghosts_removed": ghost_count, - "duration_seconds": round(duration, 2), - } - - # Persist to session - await update_session_db(session_id, structure_result=result_dict) - cached["structure_result"] = result_dict - - logger.info("detect-structure session %s: %d boxes, %d zones, %d graphics, %.2fs", - session_id, len(boxes), len(zones), len(graphics), duration) - - return {"session_id": session_id, **result_dict} - - -# --------------------------------------------------------------------------- -# Exclude Regions — user-drawn rectangles to exclude from OCR results -# --------------------------------------------------------------------------- - -class _ExcludeRegionIn(BaseModel): - x: int - y: int - w: int - h: int - label: str = "" - - -class _ExcludeRegionsBatchIn(BaseModel): - regions: list[_ExcludeRegionIn] - - -@router.put("/sessions/{session_id}/exclude-regions") -async def set_exclude_regions(session_id: str, body: _ExcludeRegionsBatchIn): - """Replace all exclude regions for a session. - - Regions are stored inside ``structure_result.exclude_regions``. - """ - session = await get_session_db(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - sr = session.get("structure_result") or {} - sr["exclude_regions"] = [r.model_dump() for r in body.regions] - - # Invalidate grid so it rebuilds with new exclude regions - await update_session_db(session_id, structure_result=sr, grid_editor_result=None) - - # Update cache - if session_id in _cache: - _cache[session_id]["structure_result"] = sr - _cache[session_id].pop("grid_editor_result", None) - - return { - "session_id": session_id, - "exclude_regions": sr["exclude_regions"], - "count": len(sr["exclude_regions"]), - } - - -@router.delete("/sessions/{session_id}/exclude-regions/{region_index}") -async def delete_exclude_region(session_id: str, region_index: int): - """Remove a single exclude region by index.""" - session = await get_session_db(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - sr = session.get("structure_result") or {} - regions = sr.get("exclude_regions", []) - - if region_index < 0 or region_index >= len(regions): - raise HTTPException(status_code=404, detail="Region index out of range") - - removed = regions.pop(region_index) - sr["exclude_regions"] = regions - - # Invalidate grid so it rebuilds with new exclude regions - await update_session_db(session_id, structure_result=sr, grid_editor_result=None) - - if session_id in _cache: - _cache[session_id]["structure_result"] = sr - _cache[session_id].pop("grid_editor_result", None) - - return { - "session_id": session_id, - "removed": removed, - "remaining": len(regions), - } - - -# --------------------------------------------------------------------------- -# Column Detection Endpoints (Step 3) -# --------------------------------------------------------------------------- - -@router.post("/sessions/{session_id}/columns") -async def detect_columns(session_id: str): - """Run column detection on the cropped (or dewarped) image.""" - if session_id not in _cache: - await _load_session_to_cache(session_id) - cached = _get_cached(session_id) - - img_bgr = cached.get("cropped_bgr") if cached.get("cropped_bgr") is not None else cached.get("dewarped_bgr") - if img_bgr is None: - raise HTTPException(status_code=400, detail="Crop or dewarp must be completed before column detection") - - # ----------------------------------------------------------------------- - # Sub-sessions (box crops): skip column detection entirely. - # Instead, create a single pseudo-column spanning the full image width. - # Also run Tesseract + binarization here so that the row detection step - # can reuse the cached intermediates (_word_dicts, _inv, _content_bounds) - # instead of falling back to detect_column_geometry() which may fail - # on small box images with < 5 words. - # ----------------------------------------------------------------------- - session = await get_session_db(session_id) - if session and session.get("parent_session_id"): - h, w = img_bgr.shape[:2] - - # Binarize + invert for row detection (horizontal projection profile) - ocr_img = create_ocr_image(img_bgr) - inv = cv2.bitwise_not(ocr_img) - - # Run Tesseract to get word bounding boxes. - # Word positions are relative to the full image (no ROI crop needed - # because the sub-session image IS the cropped box already). - # detect_row_geometry expects word positions relative to content ROI, - # so with content_bounds = (0, w, 0, h) the coordinates are correct. - try: - from PIL import Image as PILImage - pil_img = PILImage.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)) - import pytesseract - data = pytesseract.image_to_data(pil_img, lang='eng+deu', output_type=pytesseract.Output.DICT) - word_dicts = [] - for i in range(len(data['text'])): - conf = int(data['conf'][i]) if str(data['conf'][i]).lstrip('-').isdigit() else -1 - text = str(data['text'][i]).strip() - if conf < 30 or not text: - continue - word_dicts.append({ - 'text': text, 'conf': conf, - 'left': int(data['left'][i]), - 'top': int(data['top'][i]), - 'width': int(data['width'][i]), - 'height': int(data['height'][i]), - }) - # Log all words including low-confidence ones for debugging - all_count = sum(1 for i in range(len(data['text'])) - if str(data['text'][i]).strip()) - low_conf = [(str(data['text'][i]).strip(), int(data['conf'][i]) if str(data['conf'][i]).lstrip('-').isdigit() else -1) - for i in range(len(data['text'])) - if str(data['text'][i]).strip() - and (int(data['conf'][i]) if str(data['conf'][i]).lstrip('-').isdigit() else -1) < 30 - and (int(data['conf'][i]) if str(data['conf'][i]).lstrip('-').isdigit() else -1) >= 0] - if low_conf: - logger.info(f"OCR Pipeline: sub-session {session_id}: {len(low_conf)} words below conf 30: {low_conf[:20]}") - logger.info(f"OCR Pipeline: sub-session {session_id}: Tesseract found {len(word_dicts)}/{all_count} words (conf>=30)") - except Exception as e: - logger.warning(f"OCR Pipeline: sub-session {session_id}: Tesseract failed: {e}") - word_dicts = [] - - # Cache intermediates for row detection (detect_rows reuses these) - cached["_word_dicts"] = word_dicts - cached["_inv"] = inv - cached["_content_bounds"] = (0, w, 0, h) - - column_result = { - "columns": [{ - "type": "column_text", - "x": 0, "y": 0, - "width": w, "height": h, - }], - "zones": None, - "boxes_detected": 0, - "duration_seconds": 0, - "method": "sub_session_pseudo_column", - } - await update_session_db( - session_id, - column_result=column_result, - row_result=None, - word_result=None, - current_step=6, - ) - cached["column_result"] = column_result - cached.pop("row_result", None) - cached.pop("word_result", None) - logger.info(f"OCR Pipeline: sub-session {session_id}: pseudo-column {w}x{h}px") - return {"session_id": session_id, **column_result} - - t0 = time.time() - - # Binarized image for layout analysis - ocr_img = create_ocr_image(img_bgr) - h, w = ocr_img.shape[:2] - - # Phase A: Zone-aware geometry detection - zoned_result = detect_column_geometry_zoned(ocr_img, img_bgr) - - if zoned_result is None: - # Fallback to projection-based layout - layout_img = create_layout_image(img_bgr) - regions = analyze_layout(layout_img, ocr_img) - zones_data = None - boxes_detected = 0 - else: - geometries, left_x, right_x, top_y, bottom_y, word_dicts, inv, zones_data, boxes = zoned_result - content_w = right_x - left_x - boxes_detected = len(boxes) - - # Cache intermediates for row detection (avoids second Tesseract run) - cached["_word_dicts"] = word_dicts - cached["_inv"] = inv - cached["_content_bounds"] = (left_x, right_x, top_y, bottom_y) - cached["_zones_data"] = zones_data - cached["_boxes_detected"] = boxes_detected - - # Detect header/footer early so sub-column clustering ignores them - header_y, footer_y = _detect_header_footer_gaps(inv, w, h) if inv is not None else (None, None) - - # Split sub-columns (e.g. page references) before classification - geometries = _detect_sub_columns(geometries, content_w, left_x=left_x, - top_y=top_y, header_y=header_y, footer_y=footer_y) - - # Expand narrow columns (sub-columns are often very narrow) - geometries = expand_narrow_columns(geometries, content_w, left_x, word_dicts) - - # Phase B: Content-based classification - regions = classify_column_types(geometries, content_w, top_y, w, h, bottom_y, - left_x=left_x, right_x=right_x, inv=inv) - - duration = time.time() - t0 - - columns = [asdict(r) for r in regions] - - # Determine classification methods used - methods = list(set( - c.get("classification_method", "") for c in columns - if c.get("classification_method") - )) - - column_result = { - "columns": columns, - "classification_methods": methods, - "duration_seconds": round(duration, 2), - "boxes_detected": boxes_detected, - } - - # Add zone data when boxes are present - if zones_data and boxes_detected > 0: - column_result["zones"] = zones_data - - # Persist to DB — also invalidate downstream results (rows, words) - await update_session_db( - session_id, - column_result=column_result, - row_result=None, - word_result=None, - current_step=6, - ) - - # Update cache - cached["column_result"] = column_result - cached.pop("row_result", None) - cached.pop("word_result", None) - - col_count = len([c for c in columns if c["type"].startswith("column")]) - logger.info(f"OCR Pipeline: columns session {session_id}: " - f"{col_count} columns detected, {boxes_detected} box(es) ({duration:.2f}s)") - - img_w = img_bgr.shape[1] - await _append_pipeline_log(session_id, "columns", { - "total_columns": len(columns), - "column_widths_pct": [round(c["width"] / img_w * 100, 1) for c in columns], - "column_types": [c["type"] for c in columns], - "boxes_detected": boxes_detected, - }, duration_ms=int(duration * 1000)) - - return { - "session_id": session_id, - **column_result, - } - - -@router.post("/sessions/{session_id}/columns/manual") -async def set_manual_columns(session_id: str, req: ManualColumnsRequest): - """Override detected columns with manual definitions.""" - column_result = { - "columns": req.columns, - "duration_seconds": 0, - "method": "manual", - } - - await update_session_db(session_id, column_result=column_result, - row_result=None, word_result=None) - - if session_id in _cache: - _cache[session_id]["column_result"] = column_result - _cache[session_id].pop("row_result", None) - _cache[session_id].pop("word_result", None) - - logger.info(f"OCR Pipeline: manual columns session {session_id}: " - f"{len(req.columns)} columns set") - - return {"session_id": session_id, **column_result} - - -@router.post("/sessions/{session_id}/ground-truth/columns") -async def save_column_ground_truth(session_id: str, req: ColumnGroundTruthRequest): - """Save ground truth feedback for the column detection step.""" - session = await get_session_db(session_id) - if not session: - raise HTTPException(status_code=404, detail=f"Session {session_id} not found") - - ground_truth = session.get("ground_truth") or {} - gt = { - "is_correct": req.is_correct, - "corrected_columns": req.corrected_columns, - "notes": req.notes, - "saved_at": datetime.utcnow().isoformat(), - "column_result": session.get("column_result"), - } - ground_truth["columns"] = gt - - await update_session_db(session_id, ground_truth=ground_truth) - - if session_id in _cache: - _cache[session_id]["ground_truth"] = ground_truth - - return {"session_id": session_id, "ground_truth": gt} - - -@router.get("/sessions/{session_id}/ground-truth/columns") -async def get_column_ground_truth(session_id: str): - """Retrieve saved ground truth for column detection, including auto vs GT diff.""" - session = await get_session_db(session_id) - if not session: - raise HTTPException(status_code=404, detail=f"Session {session_id} not found") - - ground_truth = session.get("ground_truth") or {} - columns_gt = ground_truth.get("columns") - if not columns_gt: - raise HTTPException(status_code=404, detail="No column ground truth saved") - - return { - "session_id": session_id, - "columns_gt": columns_gt, - "columns_auto": session.get("column_result"), - } +from fastapi import APIRouter + +from ocr_pipeline_deskew import router as _deskew_router +from ocr_pipeline_dewarp import router as _dewarp_router +from ocr_pipeline_structure import router as _structure_router +from ocr_pipeline_columns import router as _columns_router + +# Assemble the combined router. +# All sub-routers use prefix="/api/v1/ocr-pipeline", so include without extra prefix. +router = APIRouter() +router.include_router(_deskew_router) +router.include_router(_dewarp_router) +router.include_router(_structure_router) +router.include_router(_columns_router) diff --git a/klausur-service/backend/ocr_pipeline_structure.py b/klausur-service/backend/ocr_pipeline_structure.py new file mode 100644 index 0000000..77a5b1b --- /dev/null +++ b/klausur-service/backend/ocr_pipeline_structure.py @@ -0,0 +1,299 @@ +""" +OCR Pipeline Structure Detection and Exclude Regions + +Detect document structure (boxes, zones, color regions, graphics) +and manage user-drawn exclude regions. +Extracted from ocr_pipeline_geometry.py for file-size compliance. +""" + +import logging +import time +from typing import Any, Dict, List + +import cv2 +import numpy as np +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from cv_box_detect import detect_boxes +from cv_color_detect import _COLOR_RANGES, _COLOR_HEX +from cv_graphic_detect import detect_graphic_elements +from ocr_pipeline_session_store import ( + get_session_db, + update_session_db, +) +from ocr_pipeline_common import ( + _cache, + _load_session_to_cache, + _get_cached, + _filter_border_ghost_words, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"]) + + +# --------------------------------------------------------------------------- +# Structure Detection Endpoint +# --------------------------------------------------------------------------- + +@router.post("/sessions/{session_id}/detect-structure") +async def detect_structure(session_id: str): + """Detect document structure: boxes, zones, and color regions. + + Runs box detection (line + shading) and color analysis on the cropped + image. Returns structured JSON with all detected elements for the + structure visualization step. + """ + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + img_bgr = ( + cached.get("cropped_bgr") + if cached.get("cropped_bgr") is not None + else cached.get("dewarped_bgr") + ) + if img_bgr is None: + raise HTTPException(status_code=400, detail="Crop or dewarp must be completed first") + + t0 = time.time() + h, w = img_bgr.shape[:2] + + # --- Content bounds from word result (if available) or full image --- + word_result = cached.get("word_result") + words: List[Dict] = [] + if word_result and word_result.get("cells"): + for cell in word_result["cells"]: + for wb in (cell.get("word_boxes") or []): + words.append(wb) + # Fallback: use raw OCR words if cell word_boxes are empty + if not words and word_result: + for key in ("raw_paddle_words_split", "raw_tesseract_words", "raw_paddle_words"): + raw = word_result.get(key, []) + if raw: + words = raw + logger.info("detect-structure: using %d words from %s (no cell word_boxes)", len(words), key) + break + # If no words yet, use image dimensions with small margin + if words: + content_x = max(0, min(int(wb["left"]) for wb in words)) + content_y = max(0, min(int(wb["top"]) for wb in words)) + content_r = min(w, max(int(wb["left"] + wb["width"]) for wb in words)) + content_b = min(h, max(int(wb["top"] + wb["height"]) for wb in words)) + content_w_px = content_r - content_x + content_h_px = content_b - content_y + else: + margin = int(min(w, h) * 0.03) + content_x, content_y = margin, margin + content_w_px = w - 2 * margin + content_h_px = h - 2 * margin + + # --- Box detection --- + boxes = detect_boxes( + img_bgr, + content_x=content_x, + content_w=content_w_px, + content_y=content_y, + content_h=content_h_px, + ) + + # --- Zone splitting --- + from cv_box_detect import split_page_into_zones as _split_zones + zones = _split_zones(content_x, content_y, content_w_px, content_h_px, boxes) + + # --- Color region sampling --- + # Sample background shading in each detected box + hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) + box_colors = [] + for box in boxes: + # Sample the center region of each box + cy1 = box.y + box.height // 4 + cy2 = box.y + 3 * box.height // 4 + cx1 = box.x + box.width // 4 + cx2 = box.x + 3 * box.width // 4 + cy1 = max(0, min(cy1, h - 1)) + cy2 = max(0, min(cy2, h - 1)) + cx1 = max(0, min(cx1, w - 1)) + cx2 = max(0, min(cx2, w - 1)) + if cy2 > cy1 and cx2 > cx1: + roi_hsv = hsv[cy1:cy2, cx1:cx2] + med_h = float(np.median(roi_hsv[:, :, 0])) + med_s = float(np.median(roi_hsv[:, :, 1])) + med_v = float(np.median(roi_hsv[:, :, 2])) + if med_s > 15: + from cv_color_detect import _hue_to_color_name + bg_name = _hue_to_color_name(med_h) + bg_hex = _COLOR_HEX.get(bg_name, "#6b7280") + else: + bg_name = "gray" if med_v < 220 else "white" + bg_hex = "#6b7280" if bg_name == "gray" else "#ffffff" + else: + bg_name = "unknown" + bg_hex = "#6b7280" + box_colors.append({"color_name": bg_name, "color_hex": bg_hex}) + + # --- Color text detection overview --- + # Quick scan for colored text regions across the page + color_summary: Dict[str, int] = {} + for color_name, ranges in _COLOR_RANGES.items(): + mask = np.zeros((h, w), dtype=np.uint8) + for lower, upper in ranges: + mask = cv2.bitwise_or(mask, cv2.inRange(hsv, lower, upper)) + pixel_count = int(np.sum(mask > 0)) + if pixel_count > 50: # minimum threshold + color_summary[color_name] = pixel_count + + # --- Graphic element detection --- + box_dicts = [ + {"x": b.x, "y": b.y, "w": b.width, "h": b.height} + for b in boxes + ] + graphics = detect_graphic_elements( + img_bgr, words, + detected_boxes=box_dicts, + ) + + # --- Filter border-ghost words from OCR result --- + ghost_count = 0 + if boxes and word_result: + ghost_count = _filter_border_ghost_words(word_result, boxes) + if ghost_count: + logger.info("detect-structure: removed %d border-ghost words", ghost_count) + await update_session_db(session_id, word_result=word_result) + cached["word_result"] = word_result + + duration = time.time() - t0 + + # Preserve user-drawn exclude regions from previous run + prev_sr = cached.get("structure_result") or {} + prev_exclude = prev_sr.get("exclude_regions", []) + + result_dict = { + "image_width": w, + "image_height": h, + "content_bounds": { + "x": content_x, "y": content_y, + "w": content_w_px, "h": content_h_px, + }, + "boxes": [ + { + "x": b.x, "y": b.y, "w": b.width, "h": b.height, + "confidence": b.confidence, + "border_thickness": b.border_thickness, + "bg_color_name": box_colors[i]["color_name"], + "bg_color_hex": box_colors[i]["color_hex"], + } + for i, b in enumerate(boxes) + ], + "zones": [ + { + "index": z.index, + "zone_type": z.zone_type, + "y": z.y, "h": z.height, + "x": z.x, "w": z.width, + } + for z in zones + ], + "graphics": [ + { + "x": g.x, "y": g.y, "w": g.width, "h": g.height, + "area": g.area, + "shape": g.shape, + "color_name": g.color_name, + "color_hex": g.color_hex, + "confidence": round(g.confidence, 2), + } + for g in graphics + ], + "exclude_regions": prev_exclude, + "color_pixel_counts": color_summary, + "has_words": len(words) > 0, + "word_count": len(words), + "border_ghosts_removed": ghost_count, + "duration_seconds": round(duration, 2), + } + + # Persist to session + await update_session_db(session_id, structure_result=result_dict) + cached["structure_result"] = result_dict + + logger.info("detect-structure session %s: %d boxes, %d zones, %d graphics, %.2fs", + session_id, len(boxes), len(zones), len(graphics), duration) + + return {"session_id": session_id, **result_dict} + + +# --------------------------------------------------------------------------- +# Exclude Regions -- user-drawn rectangles to exclude from OCR results +# --------------------------------------------------------------------------- + +class _ExcludeRegionIn(BaseModel): + x: int + y: int + w: int + h: int + label: str = "" + + +class _ExcludeRegionsBatchIn(BaseModel): + regions: list[_ExcludeRegionIn] + + +@router.put("/sessions/{session_id}/exclude-regions") +async def set_exclude_regions(session_id: str, body: _ExcludeRegionsBatchIn): + """Replace all exclude regions for a session. + + Regions are stored inside ``structure_result.exclude_regions``. + """ + session = await get_session_db(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + sr = session.get("structure_result") or {} + sr["exclude_regions"] = [r.model_dump() for r in body.regions] + + # Invalidate grid so it rebuilds with new exclude regions + await update_session_db(session_id, structure_result=sr, grid_editor_result=None) + + # Update cache + if session_id in _cache: + _cache[session_id]["structure_result"] = sr + _cache[session_id].pop("grid_editor_result", None) + + return { + "session_id": session_id, + "exclude_regions": sr["exclude_regions"], + "count": len(sr["exclude_regions"]), + } + + +@router.delete("/sessions/{session_id}/exclude-regions/{region_index}") +async def delete_exclude_region(session_id: str, region_index: int): + """Remove a single exclude region by index.""" + session = await get_session_db(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + sr = session.get("structure_result") or {} + regions = sr.get("exclude_regions", []) + + if region_index < 0 or region_index >= len(regions): + raise HTTPException(status_code=404, detail="Region index out of range") + + removed = regions.pop(region_index) + sr["exclude_regions"] = regions + + # Invalidate grid so it rebuilds with new exclude regions + await update_session_db(session_id, structure_result=sr, grid_editor_result=None) + + if session_id in _cache: + _cache[session_id]["structure_result"] = sr + _cache[session_id].pop("grid_editor_result", None) + + return { + "session_id": session_id, + "removed": removed, + "remaining": len(regions), + } diff --git a/klausur-service/backend/rbac.py b/klausur-service/backend/rbac.py index 7165260..f9d0fba 100644 --- a/klausur-service/backend/rbac.py +++ b/klausur-service/backend/rbac.py @@ -1,1132 +1,38 @@ """ -RBAC/ABAC Policy System for Klausur-Service +RBAC/ABAC Policy System for Klausur-Service (barrel re-export) -Implements: -- Role-Based Access Control (RBAC) with hierarchical roles -- Attribute-Based Access Control (ABAC) via policy sets -- Bundesland-specific policies -- Key sharing for exam packages +This module was split into: + - rbac_types.py (Enums, data structures) + - rbac_permissions.py (Permission matrix) + - rbac_engine.py (PolicyEngine, default policies, API guards) + +All public symbols are re-exported here for backwards compatibility. """ -import json -from enum import Enum -from dataclasses import dataclass, field, asdict -from typing import Optional, List, Dict, Set, Any -from datetime import datetime, timezone -import uuid - - -# ============================================= -# ENUMS: Roles, Actions, Resources -# ============================================= - -class Role(str, Enum): - """Fachliche Rollen in Korrektur- und Zeugniskette.""" - - # === Klausur-Korrekturkette === - ERSTKORREKTOR = "erstkorrektor" # EK - ZWEITKORREKTOR = "zweitkorrektor" # ZK - DRITTKORREKTOR = "drittkorrektor" # DK - - # === Zeugnis-Workflow === - KLASSENLEHRER = "klassenlehrer" # KL - Erstellt Zeugnis, Kopfnoten, Bemerkungen - FACHLEHRER = "fachlehrer" # FL - Traegt Fachnoten ein - ZEUGNISBEAUFTRAGTER = "zeugnisbeauftragter" # ZB - Qualitaetskontrolle - SEKRETARIAT = "sekretariat" # SEK - Druck, Versand, Archivierung - - # === Leitung (Klausur + Zeugnis) === - FACHVORSITZ = "fachvorsitz" # FVL - Fachpruefungsleitung - PRUEFUNGSVORSITZ = "pruefungsvorsitz" # PV - Schulleitung / Pruefungsvorsitz - SCHULLEITUNG = "schulleitung" # SL - Finale Zeugnis-Freigabe - STUFENLEITUNG = "stufenleitung" # STL - Stufenkoordination - - # === Administration === - SCHUL_ADMIN = "schul_admin" # SA - LAND_ADMIN = "land_admin" # LA - Behoerde - - # === Spezial === - AUDITOR = "auditor" # DSB/Auditor - OPERATOR = "operator" # OPS - Support - TEACHER_ASSISTANT = "teacher_assistant" # TA - Referendar - EXAM_AUTHOR = "exam_author" # EA - nur Vorabi - - -class Action(str, Enum): - """Moegliche Operationen auf Ressourcen.""" - CREATE = "create" - READ = "read" - UPDATE = "update" - DELETE = "delete" - - ASSIGN_ROLE = "assign_role" - INVITE_USER = "invite_user" - REMOVE_USER = "remove_user" - - UPLOAD = "upload" - DOWNLOAD = "download" - - LOCK = "lock" # Finalisieren - UNLOCK = "unlock" # Nur mit Sonderrecht - SIGN_OFF = "sign_off" # Freigabe - - SHARE_KEY = "share_key" # Key Share erzeugen - VIEW_PII = "view_pii" # Falls PII vorhanden - BREAK_GLASS = "break_glass" # Notfallzugriff - - PUBLISH_OFFICIAL = "publish_official" # Amtliche EH verteilen - - -class ResourceType(str, Enum): - """Ressourcentypen im System.""" - TENANT = "tenant" - NAMESPACE = "namespace" - - # === Klausur-Korrektur === - EXAM_PACKAGE = "exam_package" - STUDENT_WORK = "student_work" - EH_DOCUMENT = "eh_document" - RUBRIC = "rubric" # Punkteraster - ANNOTATION = "annotation" - EVALUATION = "evaluation" # Kriterien/Punkte - REPORT = "report" # Gutachten - GRADE_DECISION = "grade_decision" - - # === Zeugnisgenerator === - ZEUGNIS = "zeugnis" # Zeugnisdokument - ZEUGNIS_VORLAGE = "zeugnis_vorlage" # Zeugnisvorlage/Template - ZEUGNIS_ENTWURF = "zeugnis_entwurf" # Zeugnisentwurf (vor Freigabe) - SCHUELER_DATEN = "schueler_daten" # Schueler-Stammdaten, Noten - FACHNOTE = "fachnote" # Einzelne Fachnote - KOPFNOTE = "kopfnote" # Arbeits-/Sozialverhalten - FEHLZEITEN = "fehlzeiten" # Fehlzeiten - BEMERKUNG = "bemerkung" # Zeugnisbemerkungen - KONFERENZ_BESCHLUSS = "konferenz_beschluss" # Konferenzergebnis - VERSETZUNG = "versetzung" # Versetzungsentscheidung - - # === Allgemein === - DOCUMENT = "document" # Generischer Dokumenttyp (EH, Vorlagen, etc.) - TEMPLATE = "template" # Generische Vorlagen - EXPORT = "export" - AUDIT_LOG = "audit_log" - KEY_MATERIAL = "key_material" - - -class ZKVisibilityMode(str, Enum): - """Sichtbarkeitsmodus fuer Zweitkorrektoren.""" - BLIND = "blind" # ZK sieht keine EK-Note/Gutachten - SEMI = "semi" # ZK sieht Annotationen, aber keine Note - FULL = "full" # ZK sieht alles - - -class EHVisibilityMode(str, Enum): - """Sichtbarkeitsmodus fuer Erwartungshorizonte.""" - BLIND = "blind" # ZK sieht EH nicht (selten) - SHARED = "shared" # ZK sieht EH (Standard) - - -class VerfahrenType(str, Enum): - """Verfahrenstypen fuer Klausuren und Zeugnisse.""" - - # === Klausur/Pruefungsverfahren === - ABITUR = "abitur" - VORABITUR = "vorabitur" - KLAUSUR = "klausur" - NACHPRUEFUNG = "nachpruefung" - - # === Zeugnisverfahren === - HALBJAHRESZEUGNIS = "halbjahreszeugnis" - JAHRESZEUGNIS = "jahreszeugnis" - ABSCHLUSSZEUGNIS = "abschlusszeugnis" - ABGANGSZEUGNIS = "abgangszeugnis" - - @classmethod - def is_exam_type(cls, verfahren: str) -> bool: - """Pruefe ob Verfahren ein Pruefungstyp ist.""" - exam_types = {cls.ABITUR, cls.VORABITUR, cls.KLAUSUR, cls.NACHPRUEFUNG} - try: - return cls(verfahren) in exam_types - except ValueError: - return False - - @classmethod - def is_certificate_type(cls, verfahren: str) -> bool: - """Pruefe ob Verfahren ein Zeugnistyp ist.""" - cert_types = {cls.HALBJAHRESZEUGNIS, cls.JAHRESZEUGNIS, cls.ABSCHLUSSZEUGNIS, cls.ABGANGSZEUGNIS} - try: - return cls(verfahren) in cert_types - except ValueError: - return False - - -# ============================================= -# DATA STRUCTURES -# ============================================= - -@dataclass -class PolicySet: - """ - Policy-Konfiguration pro Bundesland/Jahr/Fach. - - Ermoeglicht bundesland-spezifische Unterschiede ohne - harte Codierung im Quellcode. - - Unterstuetzte Verfahrenstypen: - - Pruefungen: abitur, vorabitur, klausur, nachpruefung - - Zeugnisse: halbjahreszeugnis, jahreszeugnis, abschlusszeugnis, abgangszeugnis - """ - id: str - bundesland: str - jahr: int - fach: Optional[str] # None = gilt fuer alle Faecher - verfahren: str # See VerfahrenType enum - - # Sichtbarkeitsregeln (Klausur) - zk_visibility_mode: ZKVisibilityMode = ZKVisibilityMode.FULL - eh_visibility_mode: EHVisibilityMode = EHVisibilityMode.SHARED - - # EH-Quellen (Klausur) - allow_teacher_uploaded_eh: bool = True - allow_land_uploaded_eh: bool = True - require_rights_confirmation_on_upload: bool = True - require_dual_control_for_official_eh_update: bool = False - - # Korrekturregeln (Klausur) - third_correction_threshold: int = 4 # Notenpunkte Abweichung - final_signoff_role: str = "fachvorsitz" - - # Zeugnisregeln (Zeugnis) - require_klassenlehrer_approval: bool = True - require_schulleitung_signoff: bool = True - allow_sekretariat_edit_after_approval: bool = False - konferenz_protokoll_required: bool = True - bemerkungen_require_review: bool = True - fehlzeiten_auto_import: bool = True - kopfnoten_enabled: bool = False - versetzung_auto_calculate: bool = True - - # Export & Anzeige - quote_verbatim_allowed: bool = False # Amtliche Texte in UI - export_template_id: str = "default" - - # Zusaetzliche Flags - flags: Dict[str, Any] = field(default_factory=dict) - - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - def is_exam_policy(self) -> bool: - """Pruefe ob diese Policy fuer Pruefungen ist.""" - return VerfahrenType.is_exam_type(self.verfahren) - - def is_certificate_policy(self) -> bool: - """Pruefe ob diese Policy fuer Zeugnisse ist.""" - return VerfahrenType.is_certificate_type(self.verfahren) - - def to_dict(self): - d = asdict(self) - d['zk_visibility_mode'] = self.zk_visibility_mode.value - d['eh_visibility_mode'] = self.eh_visibility_mode.value - d['created_at'] = self.created_at.isoformat() - return d - - -@dataclass -class RoleAssignment: - """ - Zuweisung einer Rolle zu einem User fuer eine spezifische Ressource. - """ - id: str - user_id: str - role: Role - resource_type: ResourceType - resource_id: str - - # Optionale Einschraenkungen - tenant_id: Optional[str] = None - namespace_id: Optional[str] = None - - # Gueltigkeit - valid_from: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - valid_to: Optional[datetime] = None - - # Metadaten - granted_by: str = "" - granted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - revoked_at: Optional[datetime] = None - - def is_active(self) -> bool: - now = datetime.now(timezone.utc) - if self.revoked_at: - return False - if self.valid_to and now > self.valid_to: - return False - return now >= self.valid_from - - def to_dict(self): - return { - 'id': self.id, - 'user_id': self.user_id, - 'role': self.role.value, - 'resource_type': self.resource_type.value, - 'resource_id': self.resource_id, - 'tenant_id': self.tenant_id, - 'namespace_id': self.namespace_id, - 'valid_from': self.valid_from.isoformat(), - 'valid_to': self.valid_to.isoformat() if self.valid_to else None, - 'granted_by': self.granted_by, - 'granted_at': self.granted_at.isoformat(), - 'revoked_at': self.revoked_at.isoformat() if self.revoked_at else None, - 'is_active': self.is_active() - } - - -@dataclass -class KeyShare: - """ - Berechtigung fuer einen User, auf verschluesselte Inhalte zuzugreifen. - - Ein KeyShare ist KEIN Schluessel im Klartext, sondern eine - Berechtigung in Verbindung mit Role Assignment. - """ - id: str - user_id: str - package_id: str - - # Berechtigungsumfang - permissions: Set[str] = field(default_factory=set) - # z.B. {"read_original", "read_eh", "read_ek_outputs", "write_annotations"} - - # Optionale Einschraenkungen - scope: str = "full" # "full", "original_only", "eh_only", "outputs_only" - - # Kette - granted_by: str = "" - granted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - # Akzeptanz (fuer Invite-Flow) - invite_token: Optional[str] = None - accepted_at: Optional[datetime] = None - - # Widerruf - revoked_at: Optional[datetime] = None - revoked_by: Optional[str] = None - - def is_active(self) -> bool: - return self.revoked_at is None and ( - self.invite_token is None or self.accepted_at is not None - ) - - def to_dict(self): - return { - 'id': self.id, - 'user_id': self.user_id, - 'package_id': self.package_id, - 'permissions': list(self.permissions), - 'scope': self.scope, - 'granted_by': self.granted_by, - 'granted_at': self.granted_at.isoformat(), - 'invite_token': self.invite_token, - 'accepted_at': self.accepted_at.isoformat() if self.accepted_at else None, - 'revoked_at': self.revoked_at.isoformat() if self.revoked_at else None, - 'is_active': self.is_active() - } - - -@dataclass -class Tenant: - """ - Hoechste Isolationseinheit - typischerweise eine Schule. - """ - id: str - name: str - bundesland: str - tenant_type: str = "school" # "school", "pruefungszentrum", "behoerde" - - # Verschluesselung - encryption_enabled: bool = True - - # Metadaten - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - deleted_at: Optional[datetime] = None - - def to_dict(self): - return { - 'id': self.id, - 'name': self.name, - 'bundesland': self.bundesland, - 'tenant_type': self.tenant_type, - 'encryption_enabled': self.encryption_enabled, - 'created_at': self.created_at.isoformat() - } - - -@dataclass -class Namespace: - """ - Arbeitsraum innerhalb eines Tenants. - z.B. "Abitur 2026 - Deutsch LK - Kurs 12a" - """ - id: str - tenant_id: str - name: str - - # Kontext - jahr: int - fach: str - kurs: Optional[str] = None - pruefungsart: str = "abitur" # "abitur", "vorabitur" - - # Policy - policy_set_id: Optional[str] = None - - # Metadaten - created_by: str = "" - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - deleted_at: Optional[datetime] = None - - def to_dict(self): - return { - 'id': self.id, - 'tenant_id': self.tenant_id, - 'name': self.name, - 'jahr': self.jahr, - 'fach': self.fach, - 'kurs': self.kurs, - 'pruefungsart': self.pruefungsart, - 'policy_set_id': self.policy_set_id, - 'created_by': self.created_by, - 'created_at': self.created_at.isoformat() - } - - -@dataclass -class ExamPackage: - """ - Pruefungspaket - kompletter Satz Arbeiten mit allen Artefakten. - """ - id: str - namespace_id: str - tenant_id: str - - name: str - beschreibung: Optional[str] = None - - # Workflow-Status - status: str = "draft" # "draft", "in_progress", "locked", "signed_off" - - # Beteiligte (Rollen werden separat zugewiesen) - owner_id: str = "" # Typischerweise EK - - # Verschluesselung - encryption_key_id: Optional[str] = None - - # Timestamps - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - locked_at: Optional[datetime] = None - signed_off_at: Optional[datetime] = None - signed_off_by: Optional[str] = None - - def to_dict(self): - return { - 'id': self.id, - 'namespace_id': self.namespace_id, - 'tenant_id': self.tenant_id, - 'name': self.name, - 'beschreibung': self.beschreibung, - 'status': self.status, - 'owner_id': self.owner_id, - 'created_at': self.created_at.isoformat(), - 'locked_at': self.locked_at.isoformat() if self.locked_at else None, - 'signed_off_at': self.signed_off_at.isoformat() if self.signed_off_at else None, - 'signed_off_by': self.signed_off_by - } - - -# ============================================= -# RBAC PERMISSION MATRIX -# ============================================= - -# Standard-Berechtigungsmatrix (kann durch Policies ueberschrieben werden) -DEFAULT_PERMISSIONS: Dict[Role, Dict[ResourceType, Set[Action]]] = { - # Erstkorrektor - Role.ERSTKORREKTOR: { - ResourceType.EXAM_PACKAGE: {Action.READ, Action.UPDATE, Action.SHARE_KEY, Action.LOCK}, - ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, - ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE}, - ResourceType.RUBRIC: {Action.READ, Action.UPDATE}, - ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, - ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Zweitkorrektor (Standard: FULL visibility) - Role.ZWEITKORREKTOR: { - ResourceType.EXAM_PACKAGE: {Action.READ}, - ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, - ResourceType.EH_DOCUMENT: {Action.READ}, - ResourceType.RUBRIC: {Action.READ}, - ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.EXPORT: {Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Drittkorrektor - Role.DRITTKORREKTOR: { - ResourceType.EXAM_PACKAGE: {Action.READ}, - ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, - ResourceType.EH_DOCUMENT: {Action.READ}, - ResourceType.RUBRIC: {Action.READ}, - ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Fachvorsitz - Role.FACHVORSITZ: { - ResourceType.TENANT: {Action.READ}, - ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, - ResourceType.EXAM_PACKAGE: {Action.READ, Action.UPDATE, Action.LOCK, Action.UNLOCK, Action.SIGN_OFF}, - ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, - ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE}, - ResourceType.RUBRIC: {Action.READ, Action.UPDATE}, - ResourceType.ANNOTATION: {Action.READ, Action.UPDATE}, - ResourceType.EVALUATION: {Action.READ, Action.UPDATE}, - ResourceType.REPORT: {Action.READ, Action.UPDATE}, - ResourceType.GRADE_DECISION: {Action.READ, Action.UPDATE, Action.SIGN_OFF}, - ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Pruefungsvorsitz - Role.PRUEFUNGSVORSITZ: { - ResourceType.TENANT: {Action.READ}, - ResourceType.NAMESPACE: {Action.READ, Action.CREATE}, - ResourceType.EXAM_PACKAGE: {Action.READ, Action.SIGN_OFF}, - ResourceType.STUDENT_WORK: {Action.READ}, - ResourceType.EH_DOCUMENT: {Action.READ}, - ResourceType.GRADE_DECISION: {Action.READ, Action.SIGN_OFF}, - ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Schul-Admin - Role.SCHUL_ADMIN: { - ResourceType.TENANT: {Action.READ, Action.UPDATE}, - ResourceType.NAMESPACE: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, - ResourceType.EXAM_PACKAGE: {Action.CREATE, Action.READ, Action.DELETE, Action.ASSIGN_ROLE}, - ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.DELETE}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Land-Admin (Behoerde) - Role.LAND_ADMIN: { - ResourceType.TENANT: {Action.READ}, - ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE, Action.DELETE, Action.PUBLISH_OFFICIAL}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Auditor - Role.AUDITOR: { - ResourceType.AUDIT_LOG: {Action.READ}, - ResourceType.EXAM_PACKAGE: {Action.READ}, # Nur Metadaten - # Kein Zugriff auf Inhalte! - }, - - # Operator - Role.OPERATOR: { - ResourceType.TENANT: {Action.READ}, - ResourceType.NAMESPACE: {Action.READ}, - ResourceType.EXAM_PACKAGE: {Action.READ}, # Nur Metadaten - ResourceType.AUDIT_LOG: {Action.READ}, - # Break-glass separat gehandhabt - }, - - # Teacher Assistant - Role.TEACHER_ASSISTANT: { - ResourceType.STUDENT_WORK: {Action.READ}, - ResourceType.ANNOTATION: {Action.CREATE, Action.READ}, # Nur bestimmte Typen - ResourceType.EH_DOCUMENT: {Action.READ}, - }, - - # Exam Author (nur Vorabi) - Role.EXAM_AUTHOR: { - ResourceType.EH_DOCUMENT: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, - ResourceType.RUBRIC: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, - }, - - # ============================================= - # ZEUGNIS-WORKFLOW ROLLEN - # ============================================= - - # Klassenlehrer - Erstellt Zeugnisse, Kopfnoten, Bemerkungen - Role.KLASSENLEHRER: { - ResourceType.NAMESPACE: {Action.READ}, - ResourceType.ZEUGNIS: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.ZEUGNIS_ENTWURF: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, - ResourceType.ZEUGNIS_VORLAGE: {Action.READ}, - ResourceType.SCHUELER_DATEN: {Action.READ, Action.UPDATE}, - ResourceType.FACHNOTE: {Action.READ}, # Liest Fachnoten der Fachlehrer - ResourceType.KOPFNOTE: {Action.CREATE, Action.READ, Action.UPDATE}, - ResourceType.FEHLZEITEN: {Action.READ, Action.UPDATE}, - ResourceType.BEMERKUNG: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, - ResourceType.VERSETZUNG: {Action.READ}, - ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Fachlehrer - Traegt Fachnoten ein - Role.FACHLEHRER: { - ResourceType.NAMESPACE: {Action.READ}, - ResourceType.SCHUELER_DATEN: {Action.READ}, # Nur eigene Schueler - ResourceType.FACHNOTE: {Action.CREATE, Action.READ, Action.UPDATE}, # Nur eigenes Fach - ResourceType.BEMERKUNG: {Action.CREATE, Action.READ}, # Fachbezogene Bemerkungen - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Zeugnisbeauftragter - Qualitaetskontrolle - Role.ZEUGNISBEAUFTRAGTER: { - ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, - ResourceType.ZEUGNIS: {Action.READ, Action.UPDATE}, - ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, - ResourceType.ZEUGNIS_VORLAGE: {Action.READ, Action.UPDATE, Action.UPLOAD}, - ResourceType.SCHUELER_DATEN: {Action.READ}, - ResourceType.FACHNOTE: {Action.READ}, - ResourceType.KOPFNOTE: {Action.READ, Action.UPDATE}, - ResourceType.FEHLZEITEN: {Action.READ}, - ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, - ResourceType.VERSETZUNG: {Action.READ}, - ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Sekretariat - Druck, Versand, Archivierung - Role.SEKRETARIAT: { - ResourceType.ZEUGNIS: {Action.READ, Action.DOWNLOAD}, - ResourceType.ZEUGNIS_VORLAGE: {Action.READ}, - ResourceType.SCHUELER_DATEN: {Action.READ}, # Fuer Adressdaten - ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Schulleitung - Finale Zeugnis-Freigabe - Role.SCHULLEITUNG: { - ResourceType.TENANT: {Action.READ}, - ResourceType.NAMESPACE: {Action.READ, Action.CREATE}, - ResourceType.ZEUGNIS: {Action.READ, Action.SIGN_OFF, Action.LOCK}, - ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, - ResourceType.ZEUGNIS_VORLAGE: {Action.READ, Action.UPDATE}, - ResourceType.SCHUELER_DATEN: {Action.READ}, - ResourceType.FACHNOTE: {Action.READ}, - ResourceType.KOPFNOTE: {Action.READ, Action.UPDATE}, - ResourceType.FEHLZEITEN: {Action.READ}, - ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, - ResourceType.KONFERENZ_BESCHLUSS: {Action.CREATE, Action.READ, Action.UPDATE, Action.SIGN_OFF}, - ResourceType.VERSETZUNG: {Action.CREATE, Action.READ, Action.UPDATE, Action.SIGN_OFF}, - ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, - - # Stufenleitung - Stufenkoordination (z.B. Oberstufe) - Role.STUFENLEITUNG: { - ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, - ResourceType.ZEUGNIS: {Action.READ, Action.UPDATE}, - ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, - ResourceType.SCHUELER_DATEN: {Action.READ}, - ResourceType.FACHNOTE: {Action.READ}, - ResourceType.KOPFNOTE: {Action.READ}, - ResourceType.FEHLZEITEN: {Action.READ}, - ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, - ResourceType.KONFERENZ_BESCHLUSS: {Action.READ}, - ResourceType.VERSETZUNG: {Action.READ, Action.UPDATE}, - ResourceType.EXPORT: {Action.READ, Action.DOWNLOAD}, - ResourceType.AUDIT_LOG: {Action.READ}, - }, -} - - -# ============================================= -# POLICY ENGINE -# ============================================= - -class PolicyEngine: - """ - Engine fuer RBAC/ABAC Entscheidungen. - - Prueft: - 1. Basis-Rollenberechtigung (RBAC) - 2. Policy-Einschraenkungen (ABAC) - 3. Key Share Berechtigungen - """ - - def __init__(self): - self.policy_sets: Dict[str, PolicySet] = {} - self.role_assignments: Dict[str, List[RoleAssignment]] = {} # user_id -> assignments - self.key_shares: Dict[str, List[KeyShare]] = {} # user_id -> shares - - def register_policy_set(self, policy: PolicySet): - """Registriere ein Policy Set.""" - self.policy_sets[policy.id] = policy - - def get_policy_for_context( - self, - bundesland: str, - jahr: int, - fach: Optional[str] = None, - verfahren: str = "abitur" - ) -> Optional[PolicySet]: - """Finde das passende Policy Set fuer einen Kontext.""" - # Exakte Uebereinstimmung - for policy in self.policy_sets.values(): - if (policy.bundesland == bundesland and - policy.jahr == jahr and - policy.verfahren == verfahren): - if policy.fach is None or policy.fach == fach: - return policy - - # Fallback: Default Policy - for policy in self.policy_sets.values(): - if policy.bundesland == "DEFAULT": - return policy - - return None - - def assign_role( - self, - user_id: str, - role: Role, - resource_type: ResourceType, - resource_id: str, - granted_by: str, - tenant_id: Optional[str] = None, - namespace_id: Optional[str] = None, - valid_to: Optional[datetime] = None - ) -> RoleAssignment: - """Weise einem User eine Rolle zu.""" - assignment = RoleAssignment( - id=str(uuid.uuid4()), - user_id=user_id, - role=role, - resource_type=resource_type, - resource_id=resource_id, - tenant_id=tenant_id, - namespace_id=namespace_id, - granted_by=granted_by, - valid_to=valid_to - ) - - if user_id not in self.role_assignments: - self.role_assignments[user_id] = [] - self.role_assignments[user_id].append(assignment) - - return assignment - - def revoke_role(self, assignment_id: str, revoked_by: str) -> bool: - """Widerrufe eine Rollenzuweisung.""" - for user_assignments in self.role_assignments.values(): - for assignment in user_assignments: - if assignment.id == assignment_id: - assignment.revoked_at = datetime.now(timezone.utc) - return True - return False - - def get_user_roles( - self, - user_id: str, - resource_type: Optional[ResourceType] = None, - resource_id: Optional[str] = None - ) -> List[Role]: - """Hole alle aktiven Rollen eines Users.""" - assignments = self.role_assignments.get(user_id, []) - roles = [] - - for assignment in assignments: - if not assignment.is_active(): - continue - if resource_type and assignment.resource_type != resource_type: - continue - if resource_id and assignment.resource_id != resource_id: - continue - roles.append(assignment.role) - - return list(set(roles)) - - def create_key_share( - self, - user_id: str, - package_id: str, - permissions: Set[str], - granted_by: str, - scope: str = "full", - invite_token: Optional[str] = None - ) -> KeyShare: - """Erstelle einen Key Share.""" - share = KeyShare( - id=str(uuid.uuid4()), - user_id=user_id, - package_id=package_id, - permissions=permissions, - scope=scope, - granted_by=granted_by, - invite_token=invite_token - ) - - if user_id not in self.key_shares: - self.key_shares[user_id] = [] - self.key_shares[user_id].append(share) - - return share - - def accept_key_share(self, share_id: str, token: str) -> bool: - """Akzeptiere einen Key Share via Invite Token.""" - for user_shares in self.key_shares.values(): - for share in user_shares: - if share.id == share_id and share.invite_token == token: - share.accepted_at = datetime.now(timezone.utc) - return True - return False - - def revoke_key_share(self, share_id: str, revoked_by: str) -> bool: - """Widerrufe einen Key Share.""" - for user_shares in self.key_shares.values(): - for share in user_shares: - if share.id == share_id: - share.revoked_at = datetime.now(timezone.utc) - share.revoked_by = revoked_by - return True - return False - - def check_permission( - self, - user_id: str, - action: Action, - resource_type: ResourceType, - resource_id: str, - policy: Optional[PolicySet] = None, - package_id: Optional[str] = None - ) -> bool: - """ - Pruefe ob ein User eine Aktion ausfuehren darf. - - Prueft: - 1. Basis-RBAC - 2. Policy-Einschraenkungen - 3. Key Share (falls package_id angegeben) - """ - # 1. Hole aktive Rollen - roles = self.get_user_roles(user_id, resource_type, resource_id) - - if not roles: - return False - - # 2. Pruefe Basis-RBAC - has_permission = False - for role in roles: - role_permissions = DEFAULT_PERMISSIONS.get(role, {}) - resource_permissions = role_permissions.get(resource_type, set()) - if action in resource_permissions: - has_permission = True - break - - if not has_permission: - return False - - # 3. Pruefe Policy-Einschraenkungen - if policy: - # ZK Visibility Mode - if Role.ZWEITKORREKTOR in roles: - if policy.zk_visibility_mode == ZKVisibilityMode.BLIND: - # Blind: ZK darf EK-Outputs nicht sehen - if resource_type in [ResourceType.EVALUATION, ResourceType.REPORT, ResourceType.GRADE_DECISION]: - if action == Action.READ: - # Pruefe ob es EK-Outputs sind (muesste ueber Metadaten geprueft werden) - pass # Implementierung abhaengig von Datenmodell - - elif policy.zk_visibility_mode == ZKVisibilityMode.SEMI: - # Semi: ZK sieht Annotationen, aber keine Note - if resource_type == ResourceType.GRADE_DECISION and action == Action.READ: - return False - - # 4. Pruefe Key Share (falls Package-basiert) - if package_id: - user_shares = self.key_shares.get(user_id, []) - has_key_share = any( - share.package_id == package_id and share.is_active() - for share in user_shares - ) - if not has_key_share: - return False - - return True - - def get_allowed_actions( - self, - user_id: str, - resource_type: ResourceType, - resource_id: str, - policy: Optional[PolicySet] = None - ) -> Set[Action]: - """Hole alle erlaubten Aktionen fuer einen User auf einer Ressource.""" - roles = self.get_user_roles(user_id, resource_type, resource_id) - allowed = set() - - for role in roles: - role_permissions = DEFAULT_PERMISSIONS.get(role, {}) - resource_permissions = role_permissions.get(resource_type, set()) - allowed.update(resource_permissions) - - # Policy-Einschraenkungen anwenden - if policy and Role.ZWEITKORREKTOR in roles: - if policy.zk_visibility_mode == ZKVisibilityMode.BLIND: - # Entferne READ fuer bestimmte Ressourcen - pass # Detailimplementierung - - return allowed - - -# ============================================= -# DEFAULT POLICY SETS (alle Bundeslaender) -# ============================================= - -def create_default_policy_sets() -> List[PolicySet]: - """ - Erstelle Default Policy Sets fuer alle Bundeslaender. - - Diese koennen spaeter pro Land verfeinert werden. - """ - bundeslaender = [ - "baden-wuerttemberg", "bayern", "berlin", "brandenburg", - "bremen", "hamburg", "hessen", "mecklenburg-vorpommern", - "niedersachsen", "nordrhein-westfalen", "rheinland-pfalz", - "saarland", "sachsen", "sachsen-anhalt", "schleswig-holstein", - "thueringen" - ] - - policies = [] - - # Default Policy (Fallback) - policies.append(PolicySet( - id="DEFAULT-2025", - bundesland="DEFAULT", - jahr=2025, - fach=None, - verfahren="abitur", - zk_visibility_mode=ZKVisibilityMode.FULL, - eh_visibility_mode=EHVisibilityMode.SHARED, - allow_teacher_uploaded_eh=True, - allow_land_uploaded_eh=True, - require_rights_confirmation_on_upload=True, - third_correction_threshold=4, - final_signoff_role="fachvorsitz" - )) - - # Niedersachsen (Beispiel mit spezifischen Anpassungen) - policies.append(PolicySet( - id="NI-2025-ABITUR", - bundesland="niedersachsen", - jahr=2025, - fach=None, - verfahren="abitur", - zk_visibility_mode=ZKVisibilityMode.FULL, # In NI sieht ZK alles - eh_visibility_mode=EHVisibilityMode.SHARED, - allow_teacher_uploaded_eh=True, - allow_land_uploaded_eh=True, - require_rights_confirmation_on_upload=True, - third_correction_threshold=4, - final_signoff_role="fachvorsitz", - export_template_id="niedersachsen-abitur" - )) - - # Bayern (Beispiel mit SEMI visibility) - policies.append(PolicySet( - id="BY-2025-ABITUR", - bundesland="bayern", - jahr=2025, - fach=None, - verfahren="abitur", - zk_visibility_mode=ZKVisibilityMode.SEMI, # ZK sieht Annotationen, nicht Note - eh_visibility_mode=EHVisibilityMode.SHARED, - allow_teacher_uploaded_eh=True, - allow_land_uploaded_eh=True, - require_rights_confirmation_on_upload=True, - third_correction_threshold=4, - final_signoff_role="fachvorsitz", - export_template_id="bayern-abitur" - )) - - # NRW (Beispiel) - policies.append(PolicySet( - id="NW-2025-ABITUR", - bundesland="nordrhein-westfalen", - jahr=2025, - fach=None, - verfahren="abitur", - zk_visibility_mode=ZKVisibilityMode.FULL, - eh_visibility_mode=EHVisibilityMode.SHARED, - allow_teacher_uploaded_eh=True, - allow_land_uploaded_eh=True, - require_rights_confirmation_on_upload=True, - third_correction_threshold=4, - final_signoff_role="fachvorsitz", - export_template_id="nrw-abitur" - )) - - # Generiere Basis-Policies fuer alle anderen Bundeslaender - for bl in bundeslaender: - if bl not in ["niedersachsen", "bayern", "nordrhein-westfalen"]: - policies.append(PolicySet( - id=f"{bl[:2].upper()}-2025-ABITUR", - bundesland=bl, - jahr=2025, - fach=None, - verfahren="abitur", - zk_visibility_mode=ZKVisibilityMode.FULL, - eh_visibility_mode=EHVisibilityMode.SHARED, - allow_teacher_uploaded_eh=True, - allow_land_uploaded_eh=True, - require_rights_confirmation_on_upload=True, - third_correction_threshold=4, - final_signoff_role="fachvorsitz" - )) - - return policies - - -# ============================================= -# GLOBAL POLICY ENGINE INSTANCE -# ============================================= - -# Singleton Policy Engine -_policy_engine: Optional[PolicyEngine] = None - - -def get_policy_engine() -> PolicyEngine: - """Hole die globale Policy Engine Instanz.""" - global _policy_engine - if _policy_engine is None: - _policy_engine = PolicyEngine() - # Registriere Default Policies - for policy in create_default_policy_sets(): - _policy_engine.register_policy_set(policy) - return _policy_engine - - -# ============================================= -# API GUARDS (Decorators fuer FastAPI) -# ============================================= - -from functools import wraps -from fastapi import HTTPException, Request - - -def require_permission( - action: Action, - resource_type: ResourceType, - resource_id_param: str = "resource_id" -): - """ - Decorator fuer FastAPI Endpoints. - - Prueft ob der aktuelle User die angegebene Berechtigung hat. - - Usage: - @app.get("/api/v1/packages/{package_id}") - @require_permission(Action.READ, ResourceType.EXAM_PACKAGE, "package_id") - async def get_package(package_id: str, request: Request): - ... - """ - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - request = kwargs.get('request') - if not request: - for arg in args: - if isinstance(arg, Request): - request = arg - break - - if not request: - raise HTTPException(status_code=500, detail="Request not found") - - # User aus Token holen - user = getattr(request.state, 'user', None) - if not user: - raise HTTPException(status_code=401, detail="Not authenticated") - - user_id = user.get('user_id') - resource_id = kwargs.get(resource_id_param) - - # Policy Engine pruefen - engine = get_policy_engine() - - # Optional: Policy aus Kontext laden - policy = None - bundesland = user.get('bundesland') - if bundesland: - policy = engine.get_policy_for_context(bundesland, 2025) - - if not engine.check_permission( - user_id=user_id, - action=action, - resource_type=resource_type, - resource_id=resource_id, - policy=policy - ): - raise HTTPException( - status_code=403, - detail=f"Permission denied: {action.value} on {resource_type.value}" - ) - - return await func(*args, **kwargs) - - return wrapper - return decorator - - -def require_role(role: Role): - """ - Decorator der prueft ob User eine bestimmte Rolle hat. - - Usage: - @app.post("/api/v1/eh/publish") - @require_role(Role.LAND_ADMIN) - async def publish_eh(request: Request): - ... - """ - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - request = kwargs.get('request') - if not request: - for arg in args: - if isinstance(arg, Request): - request = arg - break - - if not request: - raise HTTPException(status_code=500, detail="Request not found") - - user = getattr(request.state, 'user', None) - if not user: - raise HTTPException(status_code=401, detail="Not authenticated") - - user_id = user.get('user_id') - engine = get_policy_engine() - - user_roles = engine.get_user_roles(user_id) - if role not in user_roles: - raise HTTPException( - status_code=403, - detail=f"Role required: {role.value}" - ) - - return await func(*args, **kwargs) - - return wrapper - return decorator +# Types and enums +from rbac_types import ( # noqa: F401 + Role, + Action, + ResourceType, + ZKVisibilityMode, + EHVisibilityMode, + VerfahrenType, + PolicySet, + RoleAssignment, + KeyShare, + Tenant, + Namespace, + ExamPackage, +) + +# Permission matrix +from rbac_permissions import DEFAULT_PERMISSIONS # noqa: F401 + +# Engine, policies, guards +from rbac_engine import ( # noqa: F401 + PolicyEngine, + create_default_policy_sets, + get_policy_engine, + require_permission, + require_role, +) diff --git a/klausur-service/backend/rbac_engine.py b/klausur-service/backend/rbac_engine.py new file mode 100644 index 0000000..2448e36 --- /dev/null +++ b/klausur-service/backend/rbac_engine.py @@ -0,0 +1,498 @@ +""" +RBAC Policy Engine + +Core engine for RBAC/ABAC permission checks, +role assignments, key shares, and default policies. +Extracted from rbac.py for file-size compliance. +""" + +from typing import Optional, List, Dict, Set +from datetime import datetime, timezone +import uuid +from functools import wraps + +from fastapi import HTTPException, Request + +from rbac_types import ( + Role, + Action, + ResourceType, + ZKVisibilityMode, + PolicySet, + RoleAssignment, + KeyShare, +) +from rbac_permissions import DEFAULT_PERMISSIONS + + +# ============================================= +# POLICY ENGINE +# ============================================= + +class PolicyEngine: + """ + Engine fuer RBAC/ABAC Entscheidungen. + + Prueft: + 1. Basis-Rollenberechtigung (RBAC) + 2. Policy-Einschraenkungen (ABAC) + 3. Key Share Berechtigungen + """ + + def __init__(self): + self.policy_sets: Dict[str, PolicySet] = {} + self.role_assignments: Dict[str, List[RoleAssignment]] = {} # user_id -> assignments + self.key_shares: Dict[str, List[KeyShare]] = {} # user_id -> shares + + def register_policy_set(self, policy: PolicySet): + """Registriere ein Policy Set.""" + self.policy_sets[policy.id] = policy + + def get_policy_for_context( + self, + bundesland: str, + jahr: int, + fach: Optional[str] = None, + verfahren: str = "abitur" + ) -> Optional[PolicySet]: + """Finde das passende Policy Set fuer einen Kontext.""" + # Exakte Uebereinstimmung + for policy in self.policy_sets.values(): + if (policy.bundesland == bundesland and + policy.jahr == jahr and + policy.verfahren == verfahren): + if policy.fach is None or policy.fach == fach: + return policy + + # Fallback: Default Policy + for policy in self.policy_sets.values(): + if policy.bundesland == "DEFAULT": + return policy + + return None + + def assign_role( + self, + user_id: str, + role: Role, + resource_type: ResourceType, + resource_id: str, + granted_by: str, + tenant_id: Optional[str] = None, + namespace_id: Optional[str] = None, + valid_to: Optional[datetime] = None + ) -> RoleAssignment: + """Weise einem User eine Rolle zu.""" + assignment = RoleAssignment( + id=str(uuid.uuid4()), + user_id=user_id, + role=role, + resource_type=resource_type, + resource_id=resource_id, + tenant_id=tenant_id, + namespace_id=namespace_id, + granted_by=granted_by, + valid_to=valid_to + ) + + if user_id not in self.role_assignments: + self.role_assignments[user_id] = [] + self.role_assignments[user_id].append(assignment) + + return assignment + + def revoke_role(self, assignment_id: str, revoked_by: str) -> bool: + """Widerrufe eine Rollenzuweisung.""" + for user_assignments in self.role_assignments.values(): + for assignment in user_assignments: + if assignment.id == assignment_id: + assignment.revoked_at = datetime.now(timezone.utc) + return True + return False + + def get_user_roles( + self, + user_id: str, + resource_type: Optional[ResourceType] = None, + resource_id: Optional[str] = None + ) -> List[Role]: + """Hole alle aktiven Rollen eines Users.""" + assignments = self.role_assignments.get(user_id, []) + roles = [] + + for assignment in assignments: + if not assignment.is_active(): + continue + if resource_type and assignment.resource_type != resource_type: + continue + if resource_id and assignment.resource_id != resource_id: + continue + roles.append(assignment.role) + + return list(set(roles)) + + def create_key_share( + self, + user_id: str, + package_id: str, + permissions: Set[str], + granted_by: str, + scope: str = "full", + invite_token: Optional[str] = None + ) -> KeyShare: + """Erstelle einen Key Share.""" + share = KeyShare( + id=str(uuid.uuid4()), + user_id=user_id, + package_id=package_id, + permissions=permissions, + scope=scope, + granted_by=granted_by, + invite_token=invite_token + ) + + if user_id not in self.key_shares: + self.key_shares[user_id] = [] + self.key_shares[user_id].append(share) + + return share + + def accept_key_share(self, share_id: str, token: str) -> bool: + """Akzeptiere einen Key Share via Invite Token.""" + for user_shares in self.key_shares.values(): + for share in user_shares: + if share.id == share_id and share.invite_token == token: + share.accepted_at = datetime.now(timezone.utc) + return True + return False + + def revoke_key_share(self, share_id: str, revoked_by: str) -> bool: + """Widerrufe einen Key Share.""" + for user_shares in self.key_shares.values(): + for share in user_shares: + if share.id == share_id: + share.revoked_at = datetime.now(timezone.utc) + share.revoked_by = revoked_by + return True + return False + + def check_permission( + self, + user_id: str, + action: Action, + resource_type: ResourceType, + resource_id: str, + policy: Optional[PolicySet] = None, + package_id: Optional[str] = None + ) -> bool: + """ + Pruefe ob ein User eine Aktion ausfuehren darf. + + Prueft: + 1. Basis-RBAC + 2. Policy-Einschraenkungen + 3. Key Share (falls package_id angegeben) + """ + # 1. Hole aktive Rollen + roles = self.get_user_roles(user_id, resource_type, resource_id) + + if not roles: + return False + + # 2. Pruefe Basis-RBAC + has_permission = False + for role in roles: + role_permissions = DEFAULT_PERMISSIONS.get(role, {}) + resource_permissions = role_permissions.get(resource_type, set()) + if action in resource_permissions: + has_permission = True + break + + if not has_permission: + return False + + # 3. Pruefe Policy-Einschraenkungen + if policy: + # ZK Visibility Mode + if Role.ZWEITKORREKTOR in roles: + if policy.zk_visibility_mode == ZKVisibilityMode.BLIND: + # Blind: ZK darf EK-Outputs nicht sehen + if resource_type in [ResourceType.EVALUATION, ResourceType.REPORT, ResourceType.GRADE_DECISION]: + if action == Action.READ: + # Pruefe ob es EK-Outputs sind (muesste ueber Metadaten geprueft werden) + pass # Implementierung abhaengig von Datenmodell + + elif policy.zk_visibility_mode == ZKVisibilityMode.SEMI: + # Semi: ZK sieht Annotationen, aber keine Note + if resource_type == ResourceType.GRADE_DECISION and action == Action.READ: + return False + + # 4. Pruefe Key Share (falls Package-basiert) + if package_id: + user_shares = self.key_shares.get(user_id, []) + has_key_share = any( + share.package_id == package_id and share.is_active() + for share in user_shares + ) + if not has_key_share: + return False + + return True + + def get_allowed_actions( + self, + user_id: str, + resource_type: ResourceType, + resource_id: str, + policy: Optional[PolicySet] = None + ) -> Set[Action]: + """Hole alle erlaubten Aktionen fuer einen User auf einer Ressource.""" + roles = self.get_user_roles(user_id, resource_type, resource_id) + allowed = set() + + for role in roles: + role_permissions = DEFAULT_PERMISSIONS.get(role, {}) + resource_permissions = role_permissions.get(resource_type, set()) + allowed.update(resource_permissions) + + # Policy-Einschraenkungen anwenden + if policy and Role.ZWEITKORREKTOR in roles: + if policy.zk_visibility_mode == ZKVisibilityMode.BLIND: + # Entferne READ fuer bestimmte Ressourcen + pass # Detailimplementierung + + return allowed + + +# ============================================= +# DEFAULT POLICY SETS (alle Bundeslaender) +# ============================================= + +def create_default_policy_sets() -> List[PolicySet]: + """ + Erstelle Default Policy Sets fuer alle Bundeslaender. + + Diese koennen spaeter pro Land verfeinert werden. + """ + bundeslaender = [ + "baden-wuerttemberg", "bayern", "berlin", "brandenburg", + "bremen", "hamburg", "hessen", "mecklenburg-vorpommern", + "niedersachsen", "nordrhein-westfalen", "rheinland-pfalz", + "saarland", "sachsen", "sachsen-anhalt", "schleswig-holstein", + "thueringen" + ] + + policies = [] + + # Default Policy (Fallback) + policies.append(PolicySet( + id="DEFAULT-2025", + bundesland="DEFAULT", + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.FULL, + eh_visibility_mode=PolicySet.__dataclass_fields__["eh_visibility_mode"].default, + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz" + )) + + # Niedersachsen (Beispiel mit spezifischen Anpassungen) + policies.append(PolicySet( + id="NI-2025-ABITUR", + bundesland="niedersachsen", + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.FULL, # In NI sieht ZK alles + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz", + export_template_id="niedersachsen-abitur" + )) + + # Bayern (Beispiel mit SEMI visibility) + policies.append(PolicySet( + id="BY-2025-ABITUR", + bundesland="bayern", + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.SEMI, # ZK sieht Annotationen, nicht Note + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz", + export_template_id="bayern-abitur" + )) + + # NRW (Beispiel) + policies.append(PolicySet( + id="NW-2025-ABITUR", + bundesland="nordrhein-westfalen", + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.FULL, + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz", + export_template_id="nrw-abitur" + )) + + # Generiere Basis-Policies fuer alle anderen Bundeslaender + for bl in bundeslaender: + if bl not in ["niedersachsen", "bayern", "nordrhein-westfalen"]: + policies.append(PolicySet( + id=f"{bl[:2].upper()}-2025-ABITUR", + bundesland=bl, + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.FULL, + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz" + )) + + return policies + + +# ============================================= +# GLOBAL POLICY ENGINE INSTANCE +# ============================================= + +# Singleton Policy Engine +_policy_engine: Optional[PolicyEngine] = None + + +def get_policy_engine() -> PolicyEngine: + """Hole die globale Policy Engine Instanz.""" + global _policy_engine + if _policy_engine is None: + _policy_engine = PolicyEngine() + # Registriere Default Policies + for policy in create_default_policy_sets(): + _policy_engine.register_policy_set(policy) + return _policy_engine + + +# ============================================= +# API GUARDS (Decorators fuer FastAPI) +# ============================================= + +def require_permission( + action: Action, + resource_type: ResourceType, + resource_id_param: str = "resource_id" +): + """ + Decorator fuer FastAPI Endpoints. + + Prueft ob der aktuelle User die angegebene Berechtigung hat. + + Usage: + @app.get("/api/v1/packages/{package_id}") + @require_permission(Action.READ, ResourceType.EXAM_PACKAGE, "package_id") + async def get_package(package_id: str, request: Request): + ... + """ + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + request = kwargs.get('request') + if not request: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if not request: + raise HTTPException(status_code=500, detail="Request not found") + + # User aus Token holen + user = getattr(request.state, 'user', None) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + user_id = user.get('user_id') + resource_id = kwargs.get(resource_id_param) + + # Policy Engine pruefen + engine = get_policy_engine() + + # Optional: Policy aus Kontext laden + policy = None + bundesland = user.get('bundesland') + if bundesland: + policy = engine.get_policy_for_context(bundesland, 2025) + + if not engine.check_permission( + user_id=user_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + policy=policy + ): + raise HTTPException( + status_code=403, + detail=f"Permission denied: {action.value} on {resource_type.value}" + ) + + return await func(*args, **kwargs) + + return wrapper + return decorator + + +def require_role(role: Role): + """ + Decorator der prueft ob User eine bestimmte Rolle hat. + + Usage: + @app.post("/api/v1/eh/publish") + @require_role(Role.LAND_ADMIN) + async def publish_eh(request: Request): + ... + """ + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + request = kwargs.get('request') + if not request: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if not request: + raise HTTPException(status_code=500, detail="Request not found") + + user = getattr(request.state, 'user', None) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + user_id = user.get('user_id') + engine = get_policy_engine() + + user_roles = engine.get_user_roles(user_id) + if role not in user_roles: + raise HTTPException( + status_code=403, + detail=f"Role required: {role.value}" + ) + + return await func(*args, **kwargs) + + return wrapper + return decorator diff --git a/klausur-service/backend/rbac_permissions.py b/klausur-service/backend/rbac_permissions.py new file mode 100644 index 0000000..65f6f36 --- /dev/null +++ b/klausur-service/backend/rbac_permissions.py @@ -0,0 +1,221 @@ +""" +RBAC Permission Matrix + +Default role-to-resource permission mappings for +Klausur-Korrektur and Zeugnis workflows. +Extracted from rbac.py for file-size compliance. +""" + +from typing import Dict, Set + +from rbac_types import Role, Action, ResourceType + + +# ============================================= +# RBAC PERMISSION MATRIX +# ============================================= + +# Standard-Berechtigungsmatrix (kann durch Policies ueberschrieben werden) +DEFAULT_PERMISSIONS: Dict[Role, Dict[ResourceType, Set[Action]]] = { + # Erstkorrektor + Role.ERSTKORREKTOR: { + ResourceType.EXAM_PACKAGE: {Action.READ, Action.UPDATE, Action.SHARE_KEY, Action.LOCK}, + ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, + ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE}, + ResourceType.RUBRIC: {Action.READ, Action.UPDATE}, + ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Zweitkorrektor (Standard: FULL visibility) + Role.ZWEITKORREKTOR: { + ResourceType.EXAM_PACKAGE: {Action.READ}, + ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, + ResourceType.EH_DOCUMENT: {Action.READ}, + ResourceType.RUBRIC: {Action.READ}, + ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.EXPORT: {Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Drittkorrektor + Role.DRITTKORREKTOR: { + ResourceType.EXAM_PACKAGE: {Action.READ}, + ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, + ResourceType.EH_DOCUMENT: {Action.READ}, + ResourceType.RUBRIC: {Action.READ}, + ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Fachvorsitz + Role.FACHVORSITZ: { + ResourceType.TENANT: {Action.READ}, + ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, + ResourceType.EXAM_PACKAGE: {Action.READ, Action.UPDATE, Action.LOCK, Action.UNLOCK, Action.SIGN_OFF}, + ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, + ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE}, + ResourceType.RUBRIC: {Action.READ, Action.UPDATE}, + ResourceType.ANNOTATION: {Action.READ, Action.UPDATE}, + ResourceType.EVALUATION: {Action.READ, Action.UPDATE}, + ResourceType.REPORT: {Action.READ, Action.UPDATE}, + ResourceType.GRADE_DECISION: {Action.READ, Action.UPDATE, Action.SIGN_OFF}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Pruefungsvorsitz + Role.PRUEFUNGSVORSITZ: { + ResourceType.TENANT: {Action.READ}, + ResourceType.NAMESPACE: {Action.READ, Action.CREATE}, + ResourceType.EXAM_PACKAGE: {Action.READ, Action.SIGN_OFF}, + ResourceType.STUDENT_WORK: {Action.READ}, + ResourceType.EH_DOCUMENT: {Action.READ}, + ResourceType.GRADE_DECISION: {Action.READ, Action.SIGN_OFF}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Schul-Admin + Role.SCHUL_ADMIN: { + ResourceType.TENANT: {Action.READ, Action.UPDATE}, + ResourceType.NAMESPACE: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.EXAM_PACKAGE: {Action.CREATE, Action.READ, Action.DELETE, Action.ASSIGN_ROLE}, + ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.DELETE}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Land-Admin (Behoerde) + Role.LAND_ADMIN: { + ResourceType.TENANT: {Action.READ}, + ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE, Action.DELETE, Action.PUBLISH_OFFICIAL}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Auditor + Role.AUDITOR: { + ResourceType.AUDIT_LOG: {Action.READ}, + ResourceType.EXAM_PACKAGE: {Action.READ}, # Nur Metadaten + # Kein Zugriff auf Inhalte! + }, + + # Operator + Role.OPERATOR: { + ResourceType.TENANT: {Action.READ}, + ResourceType.NAMESPACE: {Action.READ}, + ResourceType.EXAM_PACKAGE: {Action.READ}, # Nur Metadaten + ResourceType.AUDIT_LOG: {Action.READ}, + # Break-glass separat gehandhabt + }, + + # Teacher Assistant + Role.TEACHER_ASSISTANT: { + ResourceType.STUDENT_WORK: {Action.READ}, + ResourceType.ANNOTATION: {Action.CREATE, Action.READ}, # Nur bestimmte Typen + ResourceType.EH_DOCUMENT: {Action.READ}, + }, + + # Exam Author (nur Vorabi) + Role.EXAM_AUTHOR: { + ResourceType.EH_DOCUMENT: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.RUBRIC: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + }, + + # ============================================= + # ZEUGNIS-WORKFLOW ROLLEN + # ============================================= + + # Klassenlehrer - Erstellt Zeugnisse, Kopfnoten, Bemerkungen + Role.KLASSENLEHRER: { + ResourceType.NAMESPACE: {Action.READ}, + ResourceType.ZEUGNIS: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ}, + ResourceType.SCHUELER_DATEN: {Action.READ, Action.UPDATE}, + ResourceType.FACHNOTE: {Action.READ}, # Liest Fachnoten der Fachlehrer + ResourceType.KOPFNOTE: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.FEHLZEITEN: {Action.READ, Action.UPDATE}, + ResourceType.BEMERKUNG: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.VERSETZUNG: {Action.READ}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Fachlehrer - Traegt Fachnoten ein + Role.FACHLEHRER: { + ResourceType.NAMESPACE: {Action.READ}, + ResourceType.SCHUELER_DATEN: {Action.READ}, # Nur eigene Schueler + ResourceType.FACHNOTE: {Action.CREATE, Action.READ, Action.UPDATE}, # Nur eigenes Fach + ResourceType.BEMERKUNG: {Action.CREATE, Action.READ}, # Fachbezogene Bemerkungen + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Zeugnisbeauftragter - Qualitaetskontrolle + Role.ZEUGNISBEAUFTRAGTER: { + ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ, Action.UPDATE, Action.UPLOAD}, + ResourceType.SCHUELER_DATEN: {Action.READ}, + ResourceType.FACHNOTE: {Action.READ}, + ResourceType.KOPFNOTE: {Action.READ, Action.UPDATE}, + ResourceType.FEHLZEITEN: {Action.READ}, + ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, + ResourceType.VERSETZUNG: {Action.READ}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Sekretariat - Druck, Versand, Archivierung + Role.SEKRETARIAT: { + ResourceType.ZEUGNIS: {Action.READ, Action.DOWNLOAD}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ}, + ResourceType.SCHUELER_DATEN: {Action.READ}, # Fuer Adressdaten + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Schulleitung - Finale Zeugnis-Freigabe + Role.SCHULLEITUNG: { + ResourceType.TENANT: {Action.READ}, + ResourceType.NAMESPACE: {Action.READ, Action.CREATE}, + ResourceType.ZEUGNIS: {Action.READ, Action.SIGN_OFF, Action.LOCK}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ, Action.UPDATE}, + ResourceType.SCHUELER_DATEN: {Action.READ}, + ResourceType.FACHNOTE: {Action.READ}, + ResourceType.KOPFNOTE: {Action.READ, Action.UPDATE}, + ResourceType.FEHLZEITEN: {Action.READ}, + ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, + ResourceType.KONFERENZ_BESCHLUSS: {Action.CREATE, Action.READ, Action.UPDATE, Action.SIGN_OFF}, + ResourceType.VERSETZUNG: {Action.CREATE, Action.READ, Action.UPDATE, Action.SIGN_OFF}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Stufenleitung - Stufenkoordination (z.B. Oberstufe) + Role.STUFENLEITUNG: { + ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.SCHUELER_DATEN: {Action.READ}, + ResourceType.FACHNOTE: {Action.READ}, + ResourceType.KOPFNOTE: {Action.READ}, + ResourceType.FEHLZEITEN: {Action.READ}, + ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, + ResourceType.KONFERENZ_BESCHLUSS: {Action.READ}, + ResourceType.VERSETZUNG: {Action.READ, Action.UPDATE}, + ResourceType.EXPORT: {Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, +} diff --git a/klausur-service/backend/rbac_types.py b/klausur-service/backend/rbac_types.py new file mode 100644 index 0000000..77f1baf --- /dev/null +++ b/klausur-service/backend/rbac_types.py @@ -0,0 +1,438 @@ +""" +RBAC/ABAC Type Definitions + +Enums, data structures, and models for the policy system. +Extracted from rbac.py for file-size compliance. +""" + +import json +from enum import Enum +from dataclasses import dataclass, field, asdict +from typing import Optional, List, Dict, Set, Any +from datetime import datetime, timezone +import uuid + + +# ============================================= +# ENUMS: Roles, Actions, Resources +# ============================================= + +class Role(str, Enum): + """Fachliche Rollen in Korrektur- und Zeugniskette.""" + + # === Klausur-Korrekturkette === + ERSTKORREKTOR = "erstkorrektor" # EK + ZWEITKORREKTOR = "zweitkorrektor" # ZK + DRITTKORREKTOR = "drittkorrektor" # DK + + # === Zeugnis-Workflow === + KLASSENLEHRER = "klassenlehrer" # KL - Erstellt Zeugnis, Kopfnoten, Bemerkungen + FACHLEHRER = "fachlehrer" # FL - Traegt Fachnoten ein + ZEUGNISBEAUFTRAGTER = "zeugnisbeauftragter" # ZB - Qualitaetskontrolle + SEKRETARIAT = "sekretariat" # SEK - Druck, Versand, Archivierung + + # === Leitung (Klausur + Zeugnis) === + FACHVORSITZ = "fachvorsitz" # FVL - Fachpruefungsleitung + PRUEFUNGSVORSITZ = "pruefungsvorsitz" # PV - Schulleitung / Pruefungsvorsitz + SCHULLEITUNG = "schulleitung" # SL - Finale Zeugnis-Freigabe + STUFENLEITUNG = "stufenleitung" # STL - Stufenkoordination + + # === Administration === + SCHUL_ADMIN = "schul_admin" # SA + LAND_ADMIN = "land_admin" # LA - Behoerde + + # === Spezial === + AUDITOR = "auditor" # DSB/Auditor + OPERATOR = "operator" # OPS - Support + TEACHER_ASSISTANT = "teacher_assistant" # TA - Referendar + EXAM_AUTHOR = "exam_author" # EA - nur Vorabi + + +class Action(str, Enum): + """Moegliche Operationen auf Ressourcen.""" + CREATE = "create" + READ = "read" + UPDATE = "update" + DELETE = "delete" + + ASSIGN_ROLE = "assign_role" + INVITE_USER = "invite_user" + REMOVE_USER = "remove_user" + + UPLOAD = "upload" + DOWNLOAD = "download" + + LOCK = "lock" # Finalisieren + UNLOCK = "unlock" # Nur mit Sonderrecht + SIGN_OFF = "sign_off" # Freigabe + + SHARE_KEY = "share_key" # Key Share erzeugen + VIEW_PII = "view_pii" # Falls PII vorhanden + BREAK_GLASS = "break_glass" # Notfallzugriff + + PUBLISH_OFFICIAL = "publish_official" # Amtliche EH verteilen + + +class ResourceType(str, Enum): + """Ressourcentypen im System.""" + TENANT = "tenant" + NAMESPACE = "namespace" + + # === Klausur-Korrektur === + EXAM_PACKAGE = "exam_package" + STUDENT_WORK = "student_work" + EH_DOCUMENT = "eh_document" + RUBRIC = "rubric" # Punkteraster + ANNOTATION = "annotation" + EVALUATION = "evaluation" # Kriterien/Punkte + REPORT = "report" # Gutachten + GRADE_DECISION = "grade_decision" + + # === Zeugnisgenerator === + ZEUGNIS = "zeugnis" # Zeugnisdokument + ZEUGNIS_VORLAGE = "zeugnis_vorlage" # Zeugnisvorlage/Template + ZEUGNIS_ENTWURF = "zeugnis_entwurf" # Zeugnisentwurf (vor Freigabe) + SCHUELER_DATEN = "schueler_daten" # Schueler-Stammdaten, Noten + FACHNOTE = "fachnote" # Einzelne Fachnote + KOPFNOTE = "kopfnote" # Arbeits-/Sozialverhalten + FEHLZEITEN = "fehlzeiten" # Fehlzeiten + BEMERKUNG = "bemerkung" # Zeugnisbemerkungen + KONFERENZ_BESCHLUSS = "konferenz_beschluss" # Konferenzergebnis + VERSETZUNG = "versetzung" # Versetzungsentscheidung + + # === Allgemein === + DOCUMENT = "document" # Generischer Dokumenttyp (EH, Vorlagen, etc.) + TEMPLATE = "template" # Generische Vorlagen + EXPORT = "export" + AUDIT_LOG = "audit_log" + KEY_MATERIAL = "key_material" + + +class ZKVisibilityMode(str, Enum): + """Sichtbarkeitsmodus fuer Zweitkorrektoren.""" + BLIND = "blind" # ZK sieht keine EK-Note/Gutachten + SEMI = "semi" # ZK sieht Annotationen, aber keine Note + FULL = "full" # ZK sieht alles + + +class EHVisibilityMode(str, Enum): + """Sichtbarkeitsmodus fuer Erwartungshorizonte.""" + BLIND = "blind" # ZK sieht EH nicht (selten) + SHARED = "shared" # ZK sieht EH (Standard) + + +class VerfahrenType(str, Enum): + """Verfahrenstypen fuer Klausuren und Zeugnisse.""" + + # === Klausur/Pruefungsverfahren === + ABITUR = "abitur" + VORABITUR = "vorabitur" + KLAUSUR = "klausur" + NACHPRUEFUNG = "nachpruefung" + + # === Zeugnisverfahren === + HALBJAHRESZEUGNIS = "halbjahreszeugnis" + JAHRESZEUGNIS = "jahreszeugnis" + ABSCHLUSSZEUGNIS = "abschlusszeugnis" + ABGANGSZEUGNIS = "abgangszeugnis" + + @classmethod + def is_exam_type(cls, verfahren: str) -> bool: + """Pruefe ob Verfahren ein Pruefungstyp ist.""" + exam_types = {cls.ABITUR, cls.VORABITUR, cls.KLAUSUR, cls.NACHPRUEFUNG} + try: + return cls(verfahren) in exam_types + except ValueError: + return False + + @classmethod + def is_certificate_type(cls, verfahren: str) -> bool: + """Pruefe ob Verfahren ein Zeugnistyp ist.""" + cert_types = {cls.HALBJAHRESZEUGNIS, cls.JAHRESZEUGNIS, cls.ABSCHLUSSZEUGNIS, cls.ABGANGSZEUGNIS} + try: + return cls(verfahren) in cert_types + except ValueError: + return False + + +# ============================================= +# DATA STRUCTURES +# ============================================= + +@dataclass +class PolicySet: + """ + Policy-Konfiguration pro Bundesland/Jahr/Fach. + + Ermoeglicht bundesland-spezifische Unterschiede ohne + harte Codierung im Quellcode. + + Unterstuetzte Verfahrenstypen: + - Pruefungen: abitur, vorabitur, klausur, nachpruefung + - Zeugnisse: halbjahreszeugnis, jahreszeugnis, abschlusszeugnis, abgangszeugnis + """ + id: str + bundesland: str + jahr: int + fach: Optional[str] # None = gilt fuer alle Faecher + verfahren: str # See VerfahrenType enum + + # Sichtbarkeitsregeln (Klausur) + zk_visibility_mode: ZKVisibilityMode = ZKVisibilityMode.FULL + eh_visibility_mode: EHVisibilityMode = EHVisibilityMode.SHARED + + # EH-Quellen (Klausur) + allow_teacher_uploaded_eh: bool = True + allow_land_uploaded_eh: bool = True + require_rights_confirmation_on_upload: bool = True + require_dual_control_for_official_eh_update: bool = False + + # Korrekturregeln (Klausur) + third_correction_threshold: int = 4 # Notenpunkte Abweichung + final_signoff_role: str = "fachvorsitz" + + # Zeugnisregeln (Zeugnis) + require_klassenlehrer_approval: bool = True + require_schulleitung_signoff: bool = True + allow_sekretariat_edit_after_approval: bool = False + konferenz_protokoll_required: bool = True + bemerkungen_require_review: bool = True + fehlzeiten_auto_import: bool = True + kopfnoten_enabled: bool = False + versetzung_auto_calculate: bool = True + + # Export & Anzeige + quote_verbatim_allowed: bool = False # Amtliche Texte in UI + export_template_id: str = "default" + + # Zusaetzliche Flags + flags: Dict[str, Any] = field(default_factory=dict) + + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def is_exam_policy(self) -> bool: + """Pruefe ob diese Policy fuer Pruefungen ist.""" + return VerfahrenType.is_exam_type(self.verfahren) + + def is_certificate_policy(self) -> bool: + """Pruefe ob diese Policy fuer Zeugnisse ist.""" + return VerfahrenType.is_certificate_type(self.verfahren) + + def to_dict(self): + d = asdict(self) + d['zk_visibility_mode'] = self.zk_visibility_mode.value + d['eh_visibility_mode'] = self.eh_visibility_mode.value + d['created_at'] = self.created_at.isoformat() + return d + + +@dataclass +class RoleAssignment: + """ + Zuweisung einer Rolle zu einem User fuer eine spezifische Ressource. + """ + id: str + user_id: str + role: Role + resource_type: ResourceType + resource_id: str + + # Optionale Einschraenkungen + tenant_id: Optional[str] = None + namespace_id: Optional[str] = None + + # Gueltigkeit + valid_from: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + valid_to: Optional[datetime] = None + + # Metadaten + granted_by: str = "" + granted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + revoked_at: Optional[datetime] = None + + def is_active(self) -> bool: + now = datetime.now(timezone.utc) + if self.revoked_at: + return False + if self.valid_to and now > self.valid_to: + return False + return now >= self.valid_from + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'role': self.role.value, + 'resource_type': self.resource_type.value, + 'resource_id': self.resource_id, + 'tenant_id': self.tenant_id, + 'namespace_id': self.namespace_id, + 'valid_from': self.valid_from.isoformat(), + 'valid_to': self.valid_to.isoformat() if self.valid_to else None, + 'granted_by': self.granted_by, + 'granted_at': self.granted_at.isoformat(), + 'revoked_at': self.revoked_at.isoformat() if self.revoked_at else None, + 'is_active': self.is_active() + } + + +@dataclass +class KeyShare: + """ + Berechtigung fuer einen User, auf verschluesselte Inhalte zuzugreifen. + + Ein KeyShare ist KEIN Schluessel im Klartext, sondern eine + Berechtigung in Verbindung mit Role Assignment. + """ + id: str + user_id: str + package_id: str + + # Berechtigungsumfang + permissions: Set[str] = field(default_factory=set) + # z.B. {"read_original", "read_eh", "read_ek_outputs", "write_annotations"} + + # Optionale Einschraenkungen + scope: str = "full" # "full", "original_only", "eh_only", "outputs_only" + + # Kette + granted_by: str = "" + granted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + # Akzeptanz (fuer Invite-Flow) + invite_token: Optional[str] = None + accepted_at: Optional[datetime] = None + + # Widerruf + revoked_at: Optional[datetime] = None + revoked_by: Optional[str] = None + + def is_active(self) -> bool: + return self.revoked_at is None and ( + self.invite_token is None or self.accepted_at is not None + ) + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'package_id': self.package_id, + 'permissions': list(self.permissions), + 'scope': self.scope, + 'granted_by': self.granted_by, + 'granted_at': self.granted_at.isoformat(), + 'invite_token': self.invite_token, + 'accepted_at': self.accepted_at.isoformat() if self.accepted_at else None, + 'revoked_at': self.revoked_at.isoformat() if self.revoked_at else None, + 'is_active': self.is_active() + } + + +@dataclass +class Tenant: + """ + Hoechste Isolationseinheit - typischerweise eine Schule. + """ + id: str + name: str + bundesland: str + tenant_type: str = "school" # "school", "pruefungszentrum", "behoerde" + + # Verschluesselung + encryption_enabled: bool = True + + # Metadaten + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + deleted_at: Optional[datetime] = None + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'bundesland': self.bundesland, + 'tenant_type': self.tenant_type, + 'encryption_enabled': self.encryption_enabled, + 'created_at': self.created_at.isoformat() + } + + +@dataclass +class Namespace: + """ + Arbeitsraum innerhalb eines Tenants. + z.B. "Abitur 2026 - Deutsch LK - Kurs 12a" + """ + id: str + tenant_id: str + name: str + + # Kontext + jahr: int + fach: str + kurs: Optional[str] = None + pruefungsart: str = "abitur" # "abitur", "vorabitur" + + # Policy + policy_set_id: Optional[str] = None + + # Metadaten + created_by: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + deleted_at: Optional[datetime] = None + + def to_dict(self): + return { + 'id': self.id, + 'tenant_id': self.tenant_id, + 'name': self.name, + 'jahr': self.jahr, + 'fach': self.fach, + 'kurs': self.kurs, + 'pruefungsart': self.pruefungsart, + 'policy_set_id': self.policy_set_id, + 'created_by': self.created_by, + 'created_at': self.created_at.isoformat() + } + + +@dataclass +class ExamPackage: + """ + Pruefungspaket - kompletter Satz Arbeiten mit allen Artefakten. + """ + id: str + namespace_id: str + tenant_id: str + + name: str + beschreibung: Optional[str] = None + + # Workflow-Status + status: str = "draft" # "draft", "in_progress", "locked", "signed_off" + + # Beteiligte (Rollen werden separat zugewiesen) + owner_id: str = "" # Typischerweise EK + + # Verschluesselung + encryption_key_id: Optional[str] = None + + # Timestamps + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + locked_at: Optional[datetime] = None + signed_off_at: Optional[datetime] = None + signed_off_by: Optional[str] = None + + def to_dict(self): + return { + 'id': self.id, + 'namespace_id': self.namespace_id, + 'tenant_id': self.tenant_id, + 'name': self.name, + 'beschreibung': self.beschreibung, + 'status': self.status, + 'owner_id': self.owner_id, + 'created_at': self.created_at.isoformat(), + 'locked_at': self.locked_at.isoformat() if self.locked_at else None, + 'signed_off_at': self.signed_off_at.isoformat() if self.signed_off_at else None, + 'signed_off_by': self.signed_off_by + } diff --git a/klausur-service/backend/routes/eh.py b/klausur-service/backend/routes/eh.py index e83c3ec..f7753ce 100644 --- a/klausur-service/backend/routes/eh.py +++ b/klausur-service/backend/routes/eh.py @@ -1,1111 +1,24 @@ """ -Klausur-Service BYOEH Routes +Klausur-Service BYOEH Routes (barrel re-export) -Endpoints for Bring-Your-Own-Expectation-Horizon (BYOEH). +This module was split into: + - routes/eh_upload.py (Upload, list, CRUD, index, RAG query) + - routes/eh_sharing.py (Key sharing, klausur linking, access chain) + - routes/eh_invitations.py (Invitation flow: invite, accept, decline, revoke) + +The `router` object is assembled here by including all sub-routers. +Importers that did `from .eh import router` continue to work. """ -import os -import uuid -import json -from datetime import datetime, timezone, timedelta -from typing import Optional +from fastapi import APIRouter -from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form, BackgroundTasks - -from models.enums import EHStatus -from models.eh import ( - Erwartungshorizont, - EHRightsConfirmation, - EHKeyShare, - EHKlausurLink, - EHShareInvitation, -) -from models.requests import ( - EHMetadata, - EHUploadMetadata, - EHRAGQuery, - EHIndexRequest, - EHShareRequest, - EHLinkKlausurRequest, - EHInviteRequest, - EHAcceptInviteRequest, -) -from services.auth_service import get_current_user -from services.eh_service import log_eh_audit -from config import EH_UPLOAD_DIR, OPENAI_API_KEY, ENVIRONMENT, RIGHTS_CONFIRMATION_TEXT -import storage - -# BYOEH imports -from qdrant_service import ( - get_collection_info, delete_eh_vectors, search_eh, index_eh_chunks -) -from eh_pipeline import ( - decrypt_text, verify_key_hash, process_eh_for_indexing, - generate_single_embedding, EncryptionError, EmbeddingError -) +from .eh_upload import router as _upload_router +from .eh_sharing import router as _sharing_router +from .eh_invitations import router as _invitations_router +# Assemble the combined router. +# All sub-routers define their own full paths, so no prefix needed. router = APIRouter() - - -# ============================================= -# EH UPLOAD & LIST -# ============================================= - -@router.post("/api/v1/eh/upload") -async def upload_erwartungshorizont( - file: UploadFile = File(...), - metadata_json: str = Form(...), - request: Request = None, - background_tasks: BackgroundTasks = None -): - """ - Upload an encrypted Erwartungshorizont. - - The file MUST be client-side encrypted. - Server stores only the encrypted blob + key hash (never the passphrase). - """ - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - try: - data = EHUploadMetadata(**json.loads(metadata_json)) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Invalid metadata: {str(e)}") - - if not data.rights_confirmed: - raise HTTPException(status_code=400, detail="Rights confirmation required") - - eh_id = str(uuid.uuid4()) - - # Create tenant-isolated directory - upload_dir = f"{EH_UPLOAD_DIR}/{tenant_id}/{eh_id}" - os.makedirs(upload_dir, exist_ok=True) - - # Save encrypted file - encrypted_path = f"{upload_dir}/encrypted.bin" - content = await file.read() - with open(encrypted_path, "wb") as f: - f.write(content) - - # Save salt separately - with open(f"{upload_dir}/salt.txt", "w") as f: - f.write(data.salt) - - # Create EH record - eh = Erwartungshorizont( - id=eh_id, - tenant_id=tenant_id, - teacher_id=user["user_id"], - title=data.metadata.title, - subject=data.metadata.subject, - niveau=data.metadata.niveau, - year=data.metadata.year, - aufgaben_nummer=data.metadata.aufgaben_nummer, - encryption_key_hash=data.encryption_key_hash, - salt=data.salt, - encrypted_file_path=encrypted_path, - file_size_bytes=len(content), - original_filename=data.original_filename, - rights_confirmed=True, - rights_confirmed_at=datetime.now(timezone.utc), - status=EHStatus.PENDING_RIGHTS, - chunk_count=0, - indexed_at=None, - error_message=None, - training_allowed=False, # ALWAYS FALSE - critical for compliance - created_at=datetime.now(timezone.utc), - deleted_at=None - ) - - storage.eh_db[eh_id] = eh - - # Store rights confirmation - rights_confirmation = EHRightsConfirmation( - id=str(uuid.uuid4()), - eh_id=eh_id, - teacher_id=user["user_id"], - confirmation_type="upload", - confirmation_text=RIGHTS_CONFIRMATION_TEXT, - ip_address=request.client.host if request.client else None, - user_agent=request.headers.get("user-agent"), - confirmed_at=datetime.now(timezone.utc) - ) - storage.eh_rights_db[rights_confirmation.id] = rights_confirmation - - # Audit log - log_eh_audit( - tenant_id=tenant_id, - user_id=user["user_id"], - action="upload", - eh_id=eh_id, - details={ - "subject": data.metadata.subject, - "year": data.metadata.year, - "file_size": len(content) - }, - ip_address=request.client.host if request.client else None, - user_agent=request.headers.get("user-agent") - ) - - return eh.to_dict() - - -@router.get("/api/v1/eh") -async def list_erwartungshorizonte( - request: Request, - subject: Optional[str] = None, - year: Optional[int] = None -): - """List all Erwartungshorizonte for the current teacher.""" - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - results = [] - for eh in storage.eh_db.values(): - if eh.tenant_id == tenant_id and eh.deleted_at is None: - if subject and eh.subject != subject: - continue - if year and eh.year != year: - continue - results.append(eh.to_dict()) - - return results - - -# ============================================= -# SPECIFIC EH ROUTES (must come before {eh_id} catch-all) -# ============================================= - -@router.get("/api/v1/eh/audit-log") -async def get_eh_audit_log( - request: Request, - eh_id: Optional[str] = None, - limit: int = 100 -): - """Get BYOEH audit log entries.""" - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - # Filter by tenant - entries = [e for e in storage.eh_audit_db if e.tenant_id == tenant_id] - - # Filter by EH if specified - if eh_id: - entries = [e for e in entries if e.eh_id == eh_id] - - # Sort and limit - entries = sorted(entries, key=lambda e: e.created_at, reverse=True)[:limit] - - return [e.to_dict() for e in entries] - - -@router.get("/api/v1/eh/rights-text") -async def get_rights_confirmation_text(): - """Get the rights confirmation text for display in UI.""" - return { - "text": RIGHTS_CONFIRMATION_TEXT, - "version": "v1.0" - } - - -@router.get("/api/v1/eh/qdrant-status") -async def get_qdrant_status(request: Request): - """Get Qdrant collection status (admin only).""" - user = get_current_user(request) - if user.get("role") != "admin" and ENVIRONMENT != "development": - raise HTTPException(status_code=403, detail="Admin access required") - - return await get_collection_info() - - -@router.get("/api/v1/eh/shared-with-me") -async def list_shared_eh(request: Request): - """List all EH shared with the current user.""" - user = get_current_user(request) - user_id = user["user_id"] - - shared_ehs = [] - for eh_id, shares in storage.eh_key_shares_db.items(): - for share in shares: - if share.user_id == user_id and share.active: - if eh_id in storage.eh_db: - eh = storage.eh_db[eh_id] - shared_ehs.append({ - "eh": eh.to_dict(), - "share": share.to_dict() - }) - - return shared_ehs - - -# ============================================= -# GENERIC EH ROUTES -# ============================================= - -@router.get("/api/v1/eh/{eh_id}") -async def get_erwartungshorizont(eh_id: str, request: Request): - """Get a specific Erwartungshorizont by ID.""" - user = get_current_user(request) - - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - if eh.teacher_id != user["user_id"] and user.get("role") != "admin": - raise HTTPException(status_code=403, detail="Access denied") - - if eh.deleted_at is not None: - raise HTTPException(status_code=404, detail="Erwartungshorizont was deleted") - - return eh.to_dict() - - -@router.delete("/api/v1/eh/{eh_id}") -async def delete_erwartungshorizont(eh_id: str, request: Request): - """Soft-delete an Erwartungshorizont and remove vectors from Qdrant.""" - user = get_current_user(request) - - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - if eh.teacher_id != user["user_id"] and user.get("role") != "admin": - raise HTTPException(status_code=403, detail="Access denied") - - # Soft delete - eh.deleted_at = datetime.now(timezone.utc) - - # Delete vectors from Qdrant - try: - deleted_count = await delete_eh_vectors(eh_id) - print(f"Deleted {deleted_count} vectors for EH {eh_id}") - except Exception as e: - print(f"Warning: Failed to delete vectors: {e}") - - # Audit log - log_eh_audit( - tenant_id=eh.tenant_id, - user_id=user["user_id"], - action="delete", - eh_id=eh_id, - ip_address=request.client.host if request.client else None, - user_agent=request.headers.get("user-agent") - ) - - return {"status": "deleted", "id": eh_id} - - -@router.post("/api/v1/eh/{eh_id}/index") -async def index_erwartungshorizont( - eh_id: str, - data: EHIndexRequest, - request: Request -): - """ - Index an Erwartungshorizont for RAG queries. - - Requires the passphrase to decrypt, chunk, embed, and re-encrypt chunks. - The passphrase is only used transiently and never stored. - """ - user = get_current_user(request) - - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - if eh.teacher_id != user["user_id"] and user.get("role") != "admin": - raise HTTPException(status_code=403, detail="Access denied") - - # Verify passphrase matches key hash - if not verify_key_hash(data.passphrase, eh.salt, eh.encryption_key_hash): - raise HTTPException(status_code=401, detail="Invalid passphrase") - - eh.status = EHStatus.PROCESSING - - try: - # Read encrypted file - with open(eh.encrypted_file_path, "rb") as f: - encrypted_content = f.read() - - # Decrypt the file - decrypted_text = decrypt_text( - encrypted_content.decode('utf-8'), - data.passphrase, - eh.salt - ) - - # Process for indexing - chunk_count, chunks_data = await process_eh_for_indexing( - eh_id=eh_id, - tenant_id=eh.tenant_id, - subject=eh.subject, - text_content=decrypted_text, - passphrase=data.passphrase, - salt_hex=eh.salt - ) - - # Index in Qdrant - await index_eh_chunks( - eh_id=eh_id, - tenant_id=eh.tenant_id, - subject=eh.subject, - chunks=chunks_data - ) - - # Update EH record - eh.status = EHStatus.INDEXED - eh.chunk_count = chunk_count - eh.indexed_at = datetime.now(timezone.utc) - - # Audit log - log_eh_audit( - tenant_id=eh.tenant_id, - user_id=user["user_id"], - action="indexed", - eh_id=eh_id, - details={"chunk_count": chunk_count} - ) - - return { - "status": "indexed", - "id": eh_id, - "chunk_count": chunk_count - } - - except EncryptionError as e: - eh.status = EHStatus.ERROR - eh.error_message = str(e) - raise HTTPException(status_code=400, detail=f"Decryption failed: {str(e)}") - except EmbeddingError as e: - eh.status = EHStatus.ERROR - eh.error_message = str(e) - raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}") - except Exception as e: - eh.status = EHStatus.ERROR - eh.error_message = str(e) - raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}") - - -@router.post("/api/v1/eh/rag-query") -async def rag_query_eh(data: EHRAGQuery, request: Request): - """ - RAG query against teacher's Erwartungshorizonte. - - 1. Semantic search in Qdrant (tenant-isolated) - 2. Decrypt relevant chunks on-the-fly - 3. Return context for LLM usage - """ - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - if not OPENAI_API_KEY: - raise HTTPException(status_code=500, detail="OpenAI API key not configured") - - try: - # Generate embedding for query - query_embedding = await generate_single_embedding(data.query_text) - - # Search in Qdrant (tenant-isolated) - results = await search_eh( - query_embedding=query_embedding, - tenant_id=tenant_id, - subject=data.subject, - limit=data.limit - ) - - # Decrypt matching chunks - decrypted_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.passphrase, - eh.salt - ) - decrypted_chunks.append({ - "text": decrypted, - "eh_id": r["eh_id"], - "eh_title": eh.title, - "chunk_index": r["chunk_index"], - "score": r["score"] - }) - except EncryptionError: - # Skip chunks that can't be decrypted (wrong passphrase for different EH) - pass - - # Audit log - log_eh_audit( - tenant_id=tenant_id, - user_id=user["user_id"], - action="rag_query", - details={ - "query_length": len(data.query_text), - "results_count": len(results), - "decrypted_count": len(decrypted_chunks) - }, - ip_address=request.client.host if request.client else None, - user_agent=request.headers.get("user-agent") - ) - - return { - "context": "\n\n---\n\n".join([c["text"] for c in decrypted_chunks]), - "sources": decrypted_chunks, - "query": data.query_text - } - - except EmbeddingError as e: - raise HTTPException(status_code=500, detail=f"Query embedding failed: {str(e)}") - except Exception as e: - raise HTTPException(status_code=500, detail=f"RAG query failed: {str(e)}") - - -# ============================================= -# BYOEH KEY SHARING -# ============================================= - -@router.post("/api/v1/eh/{eh_id}/share") -async def share_erwartungshorizont( - eh_id: str, - share_request: EHShareRequest, - request: Request -): - """ - Share an Erwartungshorizont with another examiner. - - The first examiner shares their EH by providing an encrypted passphrase - that the recipient can use. - """ - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - # Check EH exists and belongs to user - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - if eh.teacher_id != user["user_id"]: - raise HTTPException(status_code=403, detail="Only the owner can share this EH") - - # Validate role - valid_roles = ['second_examiner', 'third_examiner', 'supervisor', 'department_head'] - if share_request.role not in valid_roles: - raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {valid_roles}") - - # Create key share entry - share_id = str(uuid.uuid4()) - key_share = EHKeyShare( - id=share_id, - eh_id=eh_id, - user_id=share_request.user_id, - encrypted_passphrase=share_request.encrypted_passphrase, - passphrase_hint=share_request.passphrase_hint or "", - granted_by=user["user_id"], - granted_at=datetime.now(timezone.utc), - role=share_request.role, - klausur_id=share_request.klausur_id, - active=True - ) - - # Store in memory - if eh_id not in storage.eh_key_shares_db: - storage.eh_key_shares_db[eh_id] = [] - storage.eh_key_shares_db[eh_id].append(key_share) - - # Audit log - log_eh_audit( - tenant_id=tenant_id, - user_id=user["user_id"], - action="share", - eh_id=eh_id, - details={ - "shared_with": share_request.user_id, - "role": share_request.role, - "klausur_id": share_request.klausur_id - } - ) - - return { - "status": "shared", - "share_id": share_id, - "eh_id": eh_id, - "shared_with": share_request.user_id, - "role": share_request.role - } - - -@router.get("/api/v1/eh/{eh_id}/shares") -async def list_eh_shares(eh_id: str, request: Request): - """List all users who have access to an EH.""" - user = get_current_user(request) - - # Check EH exists and belongs to user - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - if eh.teacher_id != user["user_id"]: - raise HTTPException(status_code=403, detail="Only the owner can view shares") - - shares = storage.eh_key_shares_db.get(eh_id, []) - return [share.to_dict() for share in shares if share.active] - - -@router.delete("/api/v1/eh/{eh_id}/shares/{share_id}") -async def revoke_eh_share(eh_id: str, share_id: str, request: Request): - """Revoke a shared EH access.""" - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - # Check EH exists and belongs to user - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - if eh.teacher_id != user["user_id"]: - raise HTTPException(status_code=403, detail="Only the owner can revoke shares") - - # Find and deactivate share - shares = storage.eh_key_shares_db.get(eh_id, []) - for share in shares: - if share.id == share_id: - share.active = False - - log_eh_audit( - tenant_id=tenant_id, - user_id=user["user_id"], - action="revoke_share", - eh_id=eh_id, - details={"revoked_user": share.user_id, "share_id": share_id} - ) - - return {"status": "revoked", "share_id": share_id} - - raise HTTPException(status_code=404, detail="Share not found") - - -# ============================================= -# KLAUSUR LINKING -# ============================================= - -@router.post("/api/v1/eh/{eh_id}/link-klausur") -async def link_eh_to_klausur( - eh_id: str, - link_request: EHLinkKlausurRequest, - request: Request -): - """ - Link an Erwartungshorizont to a Klausur. - - This creates an association between the EH and a specific Klausur. - """ - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - # Check EH exists and user has access - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - user_has_access = ( - eh.teacher_id == user["user_id"] or - any( - share.user_id == user["user_id"] and share.active - for share in storage.eh_key_shares_db.get(eh_id, []) - ) - ) - - if not user_has_access: - raise HTTPException(status_code=403, detail="No access to this EH") - - # Check Klausur exists - klausur_id = link_request.klausur_id - if klausur_id not in storage.klausuren_db: - raise HTTPException(status_code=404, detail="Klausur not found") - - # Create link - link_id = str(uuid.uuid4()) - link = EHKlausurLink( - id=link_id, - eh_id=eh_id, - klausur_id=klausur_id, - linked_by=user["user_id"], - linked_at=datetime.now(timezone.utc) - ) - - if klausur_id not in storage.eh_klausur_links_db: - storage.eh_klausur_links_db[klausur_id] = [] - storage.eh_klausur_links_db[klausur_id].append(link) - - # Audit log - log_eh_audit( - tenant_id=tenant_id, - user_id=user["user_id"], - action="link_klausur", - eh_id=eh_id, - details={"klausur_id": klausur_id} - ) - - return { - "status": "linked", - "link_id": link_id, - "eh_id": eh_id, - "klausur_id": klausur_id - } - - -@router.get("/api/v1/klausuren/{klausur_id}/linked-eh") -async def get_linked_eh(klausur_id: str, request: Request): - """Get all EH linked to a specific Klausur.""" - user = get_current_user(request) - user_id = user["user_id"] - - # Check Klausur exists - if klausur_id not in storage.klausuren_db: - raise HTTPException(status_code=404, detail="Klausur not found") - - # Get all links for this Klausur - links = storage.eh_klausur_links_db.get(klausur_id, []) - - linked_ehs = [] - for link in links: - if link.eh_id in storage.eh_db: - eh = storage.eh_db[link.eh_id] - - # Check if user has access to this EH - is_owner = eh.teacher_id == user_id - is_shared = any( - share.user_id == user_id and share.active - for share in storage.eh_key_shares_db.get(link.eh_id, []) - ) - - if is_owner or is_shared: - # Find user's share info if shared - share_info = None - if is_shared: - for share in storage.eh_key_shares_db.get(link.eh_id, []): - if share.user_id == user_id and share.active: - share_info = share.to_dict() - break - - linked_ehs.append({ - "eh": eh.to_dict(), - "link": link.to_dict(), - "is_owner": is_owner, - "share": share_info - }) - - return linked_ehs - - -@router.delete("/api/v1/eh/{eh_id}/link-klausur/{klausur_id}") -async def unlink_eh_from_klausur(eh_id: str, klausur_id: str, request: Request): - """Remove the link between an EH and a Klausur.""" - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - # Check EH exists and user has access - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - if eh.teacher_id != user["user_id"]: - raise HTTPException(status_code=403, detail="Only the owner can unlink") - - # Find and remove link - links = storage.eh_klausur_links_db.get(klausur_id, []) - for i, link in enumerate(links): - if link.eh_id == eh_id: - del links[i] - - log_eh_audit( - tenant_id=tenant_id, - user_id=user["user_id"], - action="unlink_klausur", - eh_id=eh_id, - details={"klausur_id": klausur_id} - ) - - return {"status": "unlinked", "eh_id": eh_id, "klausur_id": klausur_id} - - raise HTTPException(status_code=404, detail="Link not found") - - -# ============================================= -# INVITATION FLOW -# ============================================= - -@router.post("/api/v1/eh/{eh_id}/invite") -async def invite_to_eh( - eh_id: str, - invite_request: EHInviteRequest, - request: Request -): - """ - Invite another user to access an Erwartungshorizont. - - This creates a pending invitation that the recipient must accept. - """ - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - - # Check EH exists and belongs to user - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - if eh.teacher_id != user["user_id"]: - raise HTTPException(status_code=403, detail="Only the owner can invite others") - - # Validate role - valid_roles = ['second_examiner', 'third_examiner', 'supervisor', 'department_head', 'fachvorsitz'] - if invite_request.role not in valid_roles: - raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {valid_roles}") - - # Check for existing pending invitation to same user - for inv in storage.eh_invitations_db.values(): - if (inv.eh_id == eh_id and - inv.invitee_email == invite_request.invitee_email and - inv.status == 'pending'): - raise HTTPException( - status_code=409, - detail="Pending invitation already exists for this user" - ) - - # Create invitation - invitation_id = str(uuid.uuid4()) - now = datetime.now(timezone.utc) - expires_at = now + timedelta(days=invite_request.expires_in_days) - - invitation = EHShareInvitation( - id=invitation_id, - eh_id=eh_id, - inviter_id=user["user_id"], - invitee_id=invite_request.invitee_id or "", - invitee_email=invite_request.invitee_email, - role=invite_request.role, - klausur_id=invite_request.klausur_id, - message=invite_request.message, - status='pending', - expires_at=expires_at, - created_at=now, - accepted_at=None, - declined_at=None - ) - - storage.eh_invitations_db[invitation_id] = invitation - - # Audit log - log_eh_audit( - tenant_id=tenant_id, - user_id=user["user_id"], - action="invite", - eh_id=eh_id, - details={ - "invitation_id": invitation_id, - "invitee_email": invite_request.invitee_email, - "role": invite_request.role, - "expires_at": expires_at.isoformat() - } - ) - - return { - "status": "invited", - "invitation_id": invitation_id, - "eh_id": eh_id, - "invitee_email": invite_request.invitee_email, - "role": invite_request.role, - "expires_at": expires_at.isoformat(), - "eh_title": eh.title - } - - -@router.get("/api/v1/eh/invitations/pending") -async def list_pending_invitations(request: Request): - """List all pending invitations for the current user.""" - user = get_current_user(request) - user_email = user.get("email", "") - user_id = user["user_id"] - now = datetime.now(timezone.utc) - - pending = [] - for inv in storage.eh_invitations_db.values(): - # Match by email or user_id - if (inv.invitee_email == user_email or inv.invitee_id == user_id): - if inv.status == 'pending' and inv.expires_at > now: - # Get EH info - eh_info = None - if inv.eh_id in storage.eh_db: - eh = storage.eh_db[inv.eh_id] - eh_info = { - "id": eh.id, - "title": eh.title, - "subject": eh.subject, - "niveau": eh.niveau, - "year": eh.year - } - - pending.append({ - "invitation": inv.to_dict(), - "eh": eh_info - }) - - return pending - - -@router.get("/api/v1/eh/invitations/sent") -async def list_sent_invitations(request: Request): - """List all invitations sent by the current user.""" - user = get_current_user(request) - user_id = user["user_id"] - - sent = [] - for inv in storage.eh_invitations_db.values(): - if inv.inviter_id == user_id: - # Get EH info - eh_info = None - if inv.eh_id in storage.eh_db: - eh = storage.eh_db[inv.eh_id] - eh_info = { - "id": eh.id, - "title": eh.title, - "subject": eh.subject - } - - sent.append({ - "invitation": inv.to_dict(), - "eh": eh_info - }) - - return sent - - -@router.post("/api/v1/eh/invitations/{invitation_id}/accept") -async def accept_eh_invitation( - invitation_id: str, - accept_request: EHAcceptInviteRequest, - request: Request -): - """Accept an invitation and receive access to the EH.""" - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - user_email = user.get("email", "") - user_id = user["user_id"] - now = datetime.now(timezone.utc) - - # Find invitation - if invitation_id not in storage.eh_invitations_db: - raise HTTPException(status_code=404, detail="Invitation not found") - - invitation = storage.eh_invitations_db[invitation_id] - - # Verify recipient - if invitation.invitee_email != user_email and invitation.invitee_id != user_id: - raise HTTPException(status_code=403, detail="This invitation is not for you") - - # Check status - if invitation.status != 'pending': - raise HTTPException( - status_code=400, - detail=f"Invitation is {invitation.status}, cannot accept" - ) - - # Check expiration - if invitation.expires_at < now: - invitation.status = 'expired' - raise HTTPException(status_code=400, detail="Invitation has expired") - - # Create key share - share_id = str(uuid.uuid4()) - key_share = EHKeyShare( - id=share_id, - eh_id=invitation.eh_id, - user_id=user_id, - encrypted_passphrase=accept_request.encrypted_passphrase, - passphrase_hint="", - granted_by=invitation.inviter_id, - granted_at=now, - role=invitation.role, - klausur_id=invitation.klausur_id, - active=True - ) - - # Store key share - if invitation.eh_id not in storage.eh_key_shares_db: - storage.eh_key_shares_db[invitation.eh_id] = [] - storage.eh_key_shares_db[invitation.eh_id].append(key_share) - - # Update invitation status - invitation.status = 'accepted' - invitation.accepted_at = now - invitation.invitee_id = user_id # Update with actual user ID - - # Audit log - log_eh_audit( - tenant_id=tenant_id, - user_id=user_id, - action="accept_invite", - eh_id=invitation.eh_id, - details={ - "invitation_id": invitation_id, - "share_id": share_id, - "role": invitation.role - } - ) - - return { - "status": "accepted", - "share_id": share_id, - "eh_id": invitation.eh_id, - "role": invitation.role, - "klausur_id": invitation.klausur_id - } - - -@router.post("/api/v1/eh/invitations/{invitation_id}/decline") -async def decline_eh_invitation(invitation_id: str, request: Request): - """Decline an invitation.""" - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - user_email = user.get("email", "") - user_id = user["user_id"] - now = datetime.now(timezone.utc) - - # Find invitation - if invitation_id not in storage.eh_invitations_db: - raise HTTPException(status_code=404, detail="Invitation not found") - - invitation = storage.eh_invitations_db[invitation_id] - - # Verify recipient - if invitation.invitee_email != user_email and invitation.invitee_id != user_id: - raise HTTPException(status_code=403, detail="This invitation is not for you") - - # Check status - if invitation.status != 'pending': - raise HTTPException( - status_code=400, - detail=f"Invitation is {invitation.status}, cannot decline" - ) - - # Update status - invitation.status = 'declined' - invitation.declined_at = now - - # Audit log - log_eh_audit( - tenant_id=tenant_id, - user_id=user_id, - action="decline_invite", - eh_id=invitation.eh_id, - details={"invitation_id": invitation_id} - ) - - return { - "status": "declined", - "invitation_id": invitation_id, - "eh_id": invitation.eh_id - } - - -@router.delete("/api/v1/eh/invitations/{invitation_id}") -async def revoke_eh_invitation(invitation_id: str, request: Request): - """Revoke a pending invitation (by the inviter).""" - user = get_current_user(request) - tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] - user_id = user["user_id"] - - # Find invitation - if invitation_id not in storage.eh_invitations_db: - raise HTTPException(status_code=404, detail="Invitation not found") - - invitation = storage.eh_invitations_db[invitation_id] - - # Verify inviter - if invitation.inviter_id != user_id: - raise HTTPException(status_code=403, detail="Only the inviter can revoke") - - # Check status - if invitation.status != 'pending': - raise HTTPException( - status_code=400, - detail=f"Invitation is {invitation.status}, cannot revoke" - ) - - # Update status - invitation.status = 'revoked' - - # Audit log - log_eh_audit( - tenant_id=tenant_id, - user_id=user_id, - action="revoke_invite", - eh_id=invitation.eh_id, - details={ - "invitation_id": invitation_id, - "invitee_email": invitation.invitee_email - } - ) - - return { - "status": "revoked", - "invitation_id": invitation_id, - "eh_id": invitation.eh_id - } - - -@router.get("/api/v1/eh/{eh_id}/access-chain") -async def get_eh_access_chain(eh_id: str, request: Request): - """ - Get the complete access chain for an EH. - - Shows the correction chain: EK -> ZK -> DK -> FVL - with their current access status. - """ - user = get_current_user(request) - - # Check EH exists - if eh_id not in storage.eh_db: - raise HTTPException(status_code=404, detail="Erwartungshorizont not found") - - eh = storage.eh_db[eh_id] - - # Check access - owner or shared user - is_owner = eh.teacher_id == user["user_id"] - is_shared = any( - share.user_id == user["user_id"] and share.active - for share in storage.eh_key_shares_db.get(eh_id, []) - ) - - if not is_owner and not is_shared: - raise HTTPException(status_code=403, detail="No access to this EH") - - # Build access chain - chain = { - "eh_id": eh_id, - "eh_title": eh.title, - "owner": { - "user_id": eh.teacher_id, - "role": "erstkorrektor" - }, - "active_shares": [], - "pending_invitations": [], - "revoked_shares": [] - } - - # Active shares - for share in storage.eh_key_shares_db.get(eh_id, []): - share_dict = share.to_dict() - if share.active: - chain["active_shares"].append(share_dict) - else: - chain["revoked_shares"].append(share_dict) - - # Pending invitations (only for owner) - if is_owner: - for inv in storage.eh_invitations_db.values(): - if inv.eh_id == eh_id and inv.status == 'pending': - chain["pending_invitations"].append(inv.to_dict()) - - return chain +router.include_router(_upload_router) +router.include_router(_sharing_router) +router.include_router(_invitations_router) diff --git a/klausur-service/backend/routes/eh_invitations.py b/klausur-service/backend/routes/eh_invitations.py new file mode 100644 index 0000000..8c59a04 --- /dev/null +++ b/klausur-service/backend/routes/eh_invitations.py @@ -0,0 +1,343 @@ +""" +BYOEH Invitation Flow Routes + +Endpoints for inviting users, listing/accepting/declining/revoking +invitations to access Erwartungshorizonte. +Extracted from routes/eh.py for file-size compliance. +""" + +import uuid +from datetime import datetime, timezone, timedelta + +from fastapi import APIRouter, HTTPException, Request + +from models.eh import EHKeyShare, EHShareInvitation +from models.requests import EHInviteRequest, EHAcceptInviteRequest +from services.auth_service import get_current_user +from services.eh_service import log_eh_audit +import storage + +router = APIRouter() + + +# ============================================= +# INVITATION FLOW +# ============================================= + +@router.post("/api/v1/eh/{eh_id}/invite") +async def invite_to_eh( + eh_id: str, + invite_request: EHInviteRequest, + request: Request +): + """ + Invite another user to access an Erwartungshorizont. + + This creates a pending invitation that the recipient must accept. + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and belongs to user + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can invite others") + + # Validate role + valid_roles = ['second_examiner', 'third_examiner', 'supervisor', 'department_head', 'fachvorsitz'] + if invite_request.role not in valid_roles: + raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {valid_roles}") + + # Check for existing pending invitation to same user + for inv in storage.eh_invitations_db.values(): + if (inv.eh_id == eh_id and + inv.invitee_email == invite_request.invitee_email and + inv.status == 'pending'): + raise HTTPException( + status_code=409, + detail="Pending invitation already exists for this user" + ) + + # Create invitation + invitation_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc) + expires_at = now + timedelta(days=invite_request.expires_in_days) + + invitation = EHShareInvitation( + id=invitation_id, + eh_id=eh_id, + inviter_id=user["user_id"], + invitee_id=invite_request.invitee_id or "", + invitee_email=invite_request.invitee_email, + role=invite_request.role, + klausur_id=invite_request.klausur_id, + message=invite_request.message, + status='pending', + expires_at=expires_at, + created_at=now, + accepted_at=None, + declined_at=None + ) + + storage.eh_invitations_db[invitation_id] = invitation + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="invite", + eh_id=eh_id, + details={ + "invitation_id": invitation_id, + "invitee_email": invite_request.invitee_email, + "role": invite_request.role, + "expires_at": expires_at.isoformat() + } + ) + + return { + "status": "invited", + "invitation_id": invitation_id, + "eh_id": eh_id, + "invitee_email": invite_request.invitee_email, + "role": invite_request.role, + "expires_at": expires_at.isoformat(), + "eh_title": eh.title + } + + +@router.get("/api/v1/eh/invitations/pending") +async def list_pending_invitations(request: Request): + """List all pending invitations for the current user.""" + user = get_current_user(request) + user_email = user.get("email", "") + user_id = user["user_id"] + now = datetime.now(timezone.utc) + + pending = [] + for inv in storage.eh_invitations_db.values(): + # Match by email or user_id + if (inv.invitee_email == user_email or inv.invitee_id == user_id): + if inv.status == 'pending' and inv.expires_at > now: + # Get EH info + eh_info = None + if inv.eh_id in storage.eh_db: + eh = storage.eh_db[inv.eh_id] + eh_info = { + "id": eh.id, + "title": eh.title, + "subject": eh.subject, + "niveau": eh.niveau, + "year": eh.year + } + + pending.append({ + "invitation": inv.to_dict(), + "eh": eh_info + }) + + return pending + + +@router.get("/api/v1/eh/invitations/sent") +async def list_sent_invitations(request: Request): + """List all invitations sent by the current user.""" + user = get_current_user(request) + user_id = user["user_id"] + + sent = [] + for inv in storage.eh_invitations_db.values(): + if inv.inviter_id == user_id: + # Get EH info + eh_info = None + if inv.eh_id in storage.eh_db: + eh = storage.eh_db[inv.eh_id] + eh_info = { + "id": eh.id, + "title": eh.title, + "subject": eh.subject + } + + sent.append({ + "invitation": inv.to_dict(), + "eh": eh_info + }) + + return sent + + +@router.post("/api/v1/eh/invitations/{invitation_id}/accept") +async def accept_eh_invitation( + invitation_id: str, + accept_request: EHAcceptInviteRequest, + request: Request +): + """Accept an invitation and receive access to the EH.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + user_email = user.get("email", "") + user_id = user["user_id"] + now = datetime.now(timezone.utc) + + # Find invitation + if invitation_id not in storage.eh_invitations_db: + raise HTTPException(status_code=404, detail="Invitation not found") + + invitation = storage.eh_invitations_db[invitation_id] + + # Verify recipient + if invitation.invitee_email != user_email and invitation.invitee_id != user_id: + raise HTTPException(status_code=403, detail="This invitation is not for you") + + # Check status + if invitation.status != 'pending': + raise HTTPException( + status_code=400, + detail=f"Invitation is {invitation.status}, cannot accept" + ) + + # Check expiration + if invitation.expires_at < now: + invitation.status = 'expired' + raise HTTPException(status_code=400, detail="Invitation has expired") + + # Create key share + share_id = str(uuid.uuid4()) + key_share = EHKeyShare( + id=share_id, + eh_id=invitation.eh_id, + user_id=user_id, + encrypted_passphrase=accept_request.encrypted_passphrase, + passphrase_hint="", + granted_by=invitation.inviter_id, + granted_at=now, + role=invitation.role, + klausur_id=invitation.klausur_id, + active=True + ) + + # Store key share + if invitation.eh_id not in storage.eh_key_shares_db: + storage.eh_key_shares_db[invitation.eh_id] = [] + storage.eh_key_shares_db[invitation.eh_id].append(key_share) + + # Update invitation status + invitation.status = 'accepted' + invitation.accepted_at = now + invitation.invitee_id = user_id # Update with actual user ID + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user_id, + action="accept_invite", + eh_id=invitation.eh_id, + details={ + "invitation_id": invitation_id, + "share_id": share_id, + "role": invitation.role + } + ) + + return { + "status": "accepted", + "share_id": share_id, + "eh_id": invitation.eh_id, + "role": invitation.role, + "klausur_id": invitation.klausur_id + } + + +@router.post("/api/v1/eh/invitations/{invitation_id}/decline") +async def decline_eh_invitation(invitation_id: str, request: Request): + """Decline an invitation.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + user_email = user.get("email", "") + user_id = user["user_id"] + now = datetime.now(timezone.utc) + + # Find invitation + if invitation_id not in storage.eh_invitations_db: + raise HTTPException(status_code=404, detail="Invitation not found") + + invitation = storage.eh_invitations_db[invitation_id] + + # Verify recipient + if invitation.invitee_email != user_email and invitation.invitee_id != user_id: + raise HTTPException(status_code=403, detail="This invitation is not for you") + + # Check status + if invitation.status != 'pending': + raise HTTPException( + status_code=400, + detail=f"Invitation is {invitation.status}, cannot decline" + ) + + # Update status + invitation.status = 'declined' + invitation.declined_at = now + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user_id, + action="decline_invite", + eh_id=invitation.eh_id, + details={"invitation_id": invitation_id} + ) + + return { + "status": "declined", + "invitation_id": invitation_id, + "eh_id": invitation.eh_id + } + + +@router.delete("/api/v1/eh/invitations/{invitation_id}") +async def revoke_eh_invitation(invitation_id: str, request: Request): + """Revoke a pending invitation (by the inviter).""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + user_id = user["user_id"] + + # Find invitation + if invitation_id not in storage.eh_invitations_db: + raise HTTPException(status_code=404, detail="Invitation not found") + + invitation = storage.eh_invitations_db[invitation_id] + + # Verify inviter + if invitation.inviter_id != user_id: + raise HTTPException(status_code=403, detail="Only the inviter can revoke") + + # Check status + if invitation.status != 'pending': + raise HTTPException( + status_code=400, + detail=f"Invitation is {invitation.status}, cannot revoke" + ) + + # Update status + invitation.status = 'revoked' + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user_id, + action="revoke_invite", + eh_id=invitation.eh_id, + details={ + "invitation_id": invitation_id, + "invitee_email": invitation.invitee_email + } + ) + + return { + "status": "revoked", + "invitation_id": invitation_id, + "eh_id": invitation.eh_id + } diff --git a/klausur-service/backend/routes/eh_sharing.py b/klausur-service/backend/routes/eh_sharing.py new file mode 100644 index 0000000..a13117f --- /dev/null +++ b/klausur-service/backend/routes/eh_sharing.py @@ -0,0 +1,347 @@ +""" +BYOEH Key Sharing and Klausur Linking Routes + +Endpoints for sharing EH access with other examiners +and linking EH to Klausuren. +Extracted from routes/eh.py for file-size compliance. +""" + +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, HTTPException, Request + +from models.eh import EHKeyShare, EHKlausurLink +from models.requests import EHShareRequest, EHLinkKlausurRequest +from services.auth_service import get_current_user +from services.eh_service import log_eh_audit +import storage + +router = APIRouter() + + +# ============================================= +# BYOEH KEY SHARING +# ============================================= + +@router.post("/api/v1/eh/{eh_id}/share") +async def share_erwartungshorizont( + eh_id: str, + share_request: EHShareRequest, + request: Request +): + """ + Share an Erwartungshorizont with another examiner. + + The first examiner shares their EH by providing an encrypted passphrase + that the recipient can use. + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and belongs to user + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can share this EH") + + # Validate role + valid_roles = ['second_examiner', 'third_examiner', 'supervisor', 'department_head'] + if share_request.role not in valid_roles: + raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {valid_roles}") + + # Create key share entry + share_id = str(uuid.uuid4()) + key_share = EHKeyShare( + id=share_id, + eh_id=eh_id, + user_id=share_request.user_id, + encrypted_passphrase=share_request.encrypted_passphrase, + passphrase_hint=share_request.passphrase_hint or "", + granted_by=user["user_id"], + granted_at=datetime.now(timezone.utc), + role=share_request.role, + klausur_id=share_request.klausur_id, + active=True + ) + + # Store in memory + if eh_id not in storage.eh_key_shares_db: + storage.eh_key_shares_db[eh_id] = [] + storage.eh_key_shares_db[eh_id].append(key_share) + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="share", + eh_id=eh_id, + details={ + "shared_with": share_request.user_id, + "role": share_request.role, + "klausur_id": share_request.klausur_id + } + ) + + return { + "status": "shared", + "share_id": share_id, + "eh_id": eh_id, + "shared_with": share_request.user_id, + "role": share_request.role + } + + +@router.get("/api/v1/eh/{eh_id}/shares") +async def list_eh_shares(eh_id: str, request: Request): + """List all users who have access to an EH.""" + user = get_current_user(request) + + # Check EH exists and belongs to user + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can view shares") + + shares = storage.eh_key_shares_db.get(eh_id, []) + return [share.to_dict() for share in shares if share.active] + + +@router.delete("/api/v1/eh/{eh_id}/shares/{share_id}") +async def revoke_eh_share(eh_id: str, share_id: str, request: Request): + """Revoke a shared EH access.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and belongs to user + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can revoke shares") + + # Find and deactivate share + shares = storage.eh_key_shares_db.get(eh_id, []) + for share in shares: + if share.id == share_id: + share.active = False + + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="revoke_share", + eh_id=eh_id, + details={"revoked_user": share.user_id, "share_id": share_id} + ) + + return {"status": "revoked", "share_id": share_id} + + raise HTTPException(status_code=404, detail="Share not found") + + +# ============================================= +# KLAUSUR LINKING +# ============================================= + +@router.post("/api/v1/eh/{eh_id}/link-klausur") +async def link_eh_to_klausur( + eh_id: str, + link_request: EHLinkKlausurRequest, + request: Request +): + """ + Link an Erwartungshorizont to a Klausur. + + This creates an association between the EH and a specific Klausur. + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and user has access + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + user_has_access = ( + eh.teacher_id == user["user_id"] or + any( + share.user_id == user["user_id"] and share.active + for share in storage.eh_key_shares_db.get(eh_id, []) + ) + ) + + if not user_has_access: + raise HTTPException(status_code=403, detail="No access to this EH") + + # Check Klausur exists + klausur_id = link_request.klausur_id + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + # Create link + link_id = str(uuid.uuid4()) + link = EHKlausurLink( + id=link_id, + eh_id=eh_id, + klausur_id=klausur_id, + linked_by=user["user_id"], + linked_at=datetime.now(timezone.utc) + ) + + if klausur_id not in storage.eh_klausur_links_db: + storage.eh_klausur_links_db[klausur_id] = [] + storage.eh_klausur_links_db[klausur_id].append(link) + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="link_klausur", + eh_id=eh_id, + details={"klausur_id": klausur_id} + ) + + return { + "status": "linked", + "link_id": link_id, + "eh_id": eh_id, + "klausur_id": klausur_id + } + + +@router.get("/api/v1/klausuren/{klausur_id}/linked-eh") +async def get_linked_eh(klausur_id: str, request: Request): + """Get all EH linked to a specific Klausur.""" + user = get_current_user(request) + user_id = user["user_id"] + + # Check Klausur exists + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + # Get all links for this Klausur + links = storage.eh_klausur_links_db.get(klausur_id, []) + + linked_ehs = [] + for link in links: + if link.eh_id in storage.eh_db: + eh = storage.eh_db[link.eh_id] + + # Check if user has access to this EH + is_owner = eh.teacher_id == user_id + is_shared = any( + share.user_id == user_id and share.active + for share in storage.eh_key_shares_db.get(link.eh_id, []) + ) + + if is_owner or is_shared: + # Find user's share info if shared + share_info = None + if is_shared: + for share in storage.eh_key_shares_db.get(link.eh_id, []): + if share.user_id == user_id and share.active: + share_info = share.to_dict() + break + + linked_ehs.append({ + "eh": eh.to_dict(), + "link": link.to_dict(), + "is_owner": is_owner, + "share": share_info + }) + + return linked_ehs + + +@router.delete("/api/v1/eh/{eh_id}/link-klausur/{klausur_id}") +async def unlink_eh_from_klausur(eh_id: str, klausur_id: str, request: Request): + """Remove the link between an EH and a Klausur.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and user has access + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can unlink") + + # Find and remove link + links = storage.eh_klausur_links_db.get(klausur_id, []) + for i, link in enumerate(links): + if link.eh_id == eh_id: + del links[i] + + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="unlink_klausur", + eh_id=eh_id, + details={"klausur_id": klausur_id} + ) + + return {"status": "unlinked", "eh_id": eh_id, "klausur_id": klausur_id} + + raise HTTPException(status_code=404, detail="Link not found") + + +@router.get("/api/v1/eh/{eh_id}/access-chain") +async def get_eh_access_chain(eh_id: str, request: Request): + """ + Get the complete access chain for an EH. + + Shows the correction chain: EK -> ZK -> DK -> FVL + with their current access status. + """ + user = get_current_user(request) + + # Check EH exists + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + + # Check access - owner or shared user + is_owner = eh.teacher_id == user["user_id"] + is_shared = any( + share.user_id == user["user_id"] and share.active + for share in storage.eh_key_shares_db.get(eh_id, []) + ) + + if not is_owner and not is_shared: + raise HTTPException(status_code=403, detail="No access to this EH") + + # Build access chain + chain = { + "eh_id": eh_id, + "eh_title": eh.title, + "owner": { + "user_id": eh.teacher_id, + "role": "erstkorrektor" + }, + "active_shares": [], + "pending_invitations": [], + "revoked_shares": [] + } + + # Active shares + for share in storage.eh_key_shares_db.get(eh_id, []): + share_dict = share.to_dict() + if share.active: + chain["active_shares"].append(share_dict) + else: + chain["revoked_shares"].append(share_dict) + + # Pending invitations (only for owner) + if is_owner: + for inv in storage.eh_invitations_db.values(): + if inv.eh_id == eh_id and inv.status == 'pending': + chain["pending_invitations"].append(inv.to_dict()) + + return chain diff --git a/klausur-service/backend/routes/eh_upload.py b/klausur-service/backend/routes/eh_upload.py new file mode 100644 index 0000000..f02f475 --- /dev/null +++ b/klausur-service/backend/routes/eh_upload.py @@ -0,0 +1,455 @@ +""" +BYOEH Upload, List, and Core CRUD Routes + +Endpoints for uploading, listing, getting, deleting, +indexing, and RAG-querying Erwartungshorizonte. +Extracted from routes/eh.py for file-size compliance. +""" + +import os +import uuid +import json +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form, BackgroundTasks + +from models.enums import EHStatus +from models.eh import ( + Erwartungshorizont, + EHRightsConfirmation, +) +from models.requests import ( + EHUploadMetadata, + EHRAGQuery, + EHIndexRequest, +) +from services.auth_service import get_current_user +from services.eh_service import log_eh_audit +from config import EH_UPLOAD_DIR, OPENAI_API_KEY, ENVIRONMENT, RIGHTS_CONFIRMATION_TEXT +import storage + +# BYOEH imports +from qdrant_service import ( + get_collection_info, delete_eh_vectors, search_eh, index_eh_chunks +) +from eh_pipeline import ( + decrypt_text, verify_key_hash, process_eh_for_indexing, + generate_single_embedding, EncryptionError, EmbeddingError +) + +router = APIRouter() + + +# ============================================= +# EH UPLOAD & LIST +# ============================================= + +@router.post("/api/v1/eh/upload") +async def upload_erwartungshorizont( + file: UploadFile = File(...), + metadata_json: str = Form(...), + request: Request = None, + background_tasks: BackgroundTasks = None +): + """ + Upload an encrypted Erwartungshorizont. + + The file MUST be client-side encrypted. + Server stores only the encrypted blob + key hash (never the passphrase). + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + try: + data = EHUploadMetadata(**json.loads(metadata_json)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid metadata: {str(e)}") + + if not data.rights_confirmed: + raise HTTPException(status_code=400, detail="Rights confirmation required") + + eh_id = str(uuid.uuid4()) + + # Create tenant-isolated directory + upload_dir = f"{EH_UPLOAD_DIR}/{tenant_id}/{eh_id}" + os.makedirs(upload_dir, exist_ok=True) + + # Save encrypted file + encrypted_path = f"{upload_dir}/encrypted.bin" + content = await file.read() + with open(encrypted_path, "wb") as f: + f.write(content) + + # Save salt separately + with open(f"{upload_dir}/salt.txt", "w") as f: + f.write(data.salt) + + # Create EH record + eh = Erwartungshorizont( + id=eh_id, + tenant_id=tenant_id, + teacher_id=user["user_id"], + title=data.metadata.title, + subject=data.metadata.subject, + niveau=data.metadata.niveau, + year=data.metadata.year, + aufgaben_nummer=data.metadata.aufgaben_nummer, + encryption_key_hash=data.encryption_key_hash, + salt=data.salt, + encrypted_file_path=encrypted_path, + file_size_bytes=len(content), + original_filename=data.original_filename, + rights_confirmed=True, + rights_confirmed_at=datetime.now(timezone.utc), + status=EHStatus.PENDING_RIGHTS, + chunk_count=0, + indexed_at=None, + error_message=None, + training_allowed=False, # ALWAYS FALSE - critical for compliance + created_at=datetime.now(timezone.utc), + deleted_at=None + ) + + storage.eh_db[eh_id] = eh + + # Store rights confirmation + rights_confirmation = EHRightsConfirmation( + id=str(uuid.uuid4()), + eh_id=eh_id, + teacher_id=user["user_id"], + confirmation_type="upload", + confirmation_text=RIGHTS_CONFIRMATION_TEXT, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + confirmed_at=datetime.now(timezone.utc) + ) + storage.eh_rights_db[rights_confirmation.id] = rights_confirmation + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="upload", + eh_id=eh_id, + details={ + "subject": data.metadata.subject, + "year": data.metadata.year, + "file_size": len(content) + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent") + ) + + return eh.to_dict() + + +@router.get("/api/v1/eh") +async def list_erwartungshorizonte( + request: Request, + subject: Optional[str] = None, + year: Optional[int] = None +): + """List all Erwartungshorizonte for the current teacher.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + results = [] + for eh in storage.eh_db.values(): + if eh.tenant_id == tenant_id and eh.deleted_at is None: + if subject and eh.subject != subject: + continue + if year and eh.year != year: + continue + results.append(eh.to_dict()) + + return results + + +# ============================================= +# SPECIFIC EH ROUTES (must come before {eh_id} catch-all) +# ============================================= + +@router.get("/api/v1/eh/audit-log") +async def get_eh_audit_log( + request: Request, + eh_id: Optional[str] = None, + limit: int = 100 +): + """Get BYOEH audit log entries.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Filter by tenant + entries = [e for e in storage.eh_audit_db if e.tenant_id == tenant_id] + + # Filter by EH if specified + if eh_id: + entries = [e for e in entries if e.eh_id == eh_id] + + # Sort and limit + entries = sorted(entries, key=lambda e: e.created_at, reverse=True)[:limit] + + return [e.to_dict() for e in entries] + + +@router.get("/api/v1/eh/rights-text") +async def get_rights_confirmation_text(): + """Get the rights confirmation text for display in UI.""" + return { + "text": RIGHTS_CONFIRMATION_TEXT, + "version": "v1.0" + } + + +@router.get("/api/v1/eh/qdrant-status") +async def get_qdrant_status(request: Request): + """Get Qdrant collection status (admin only).""" + user = get_current_user(request) + if user.get("role") != "admin" and ENVIRONMENT != "development": + raise HTTPException(status_code=403, detail="Admin access required") + + return await get_collection_info() + + +@router.get("/api/v1/eh/shared-with-me") +async def list_shared_eh(request: Request): + """List all EH shared with the current user.""" + user = get_current_user(request) + user_id = user["user_id"] + + shared_ehs = [] + for eh_id, shares in storage.eh_key_shares_db.items(): + for share in shares: + if share.user_id == user_id and share.active: + if eh_id in storage.eh_db: + eh = storage.eh_db[eh_id] + shared_ehs.append({ + "eh": eh.to_dict(), + "share": share.to_dict() + }) + + return shared_ehs + + +# ============================================= +# GENERIC EH ROUTES +# ============================================= + +@router.get("/api/v1/eh/{eh_id}") +async def get_erwartungshorizont(eh_id: str, request: Request): + """Get a specific Erwartungshorizont by ID.""" + user = get_current_user(request) + + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + if eh.deleted_at is not None: + raise HTTPException(status_code=404, detail="Erwartungshorizont was deleted") + + return eh.to_dict() + + +@router.delete("/api/v1/eh/{eh_id}") +async def delete_erwartungshorizont(eh_id: str, request: Request): + """Soft-delete an Erwartungshorizont and remove vectors from Qdrant.""" + user = get_current_user(request) + + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + # Soft delete + eh.deleted_at = datetime.now(timezone.utc) + + # Delete vectors from Qdrant + try: + deleted_count = await delete_eh_vectors(eh_id) + print(f"Deleted {deleted_count} vectors for EH {eh_id}") + except Exception as e: + print(f"Warning: Failed to delete vectors: {e}") + + # Audit log + log_eh_audit( + tenant_id=eh.tenant_id, + user_id=user["user_id"], + action="delete", + eh_id=eh_id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent") + ) + + return {"status": "deleted", "id": eh_id} + + +@router.post("/api/v1/eh/{eh_id}/index") +async def index_erwartungshorizont( + eh_id: str, + data: EHIndexRequest, + request: Request +): + """ + Index an Erwartungshorizont for RAG queries. + + Requires the passphrase to decrypt, chunk, embed, and re-encrypt chunks. + The passphrase is only used transiently and never stored. + """ + user = get_current_user(request) + + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + # Verify passphrase matches key hash + if not verify_key_hash(data.passphrase, eh.salt, eh.encryption_key_hash): + raise HTTPException(status_code=401, detail="Invalid passphrase") + + eh.status = EHStatus.PROCESSING + + try: + # Read encrypted file + with open(eh.encrypted_file_path, "rb") as f: + encrypted_content = f.read() + + # Decrypt the file + decrypted_text = decrypt_text( + encrypted_content.decode('utf-8'), + data.passphrase, + eh.salt + ) + + # Process for indexing + chunk_count, chunks_data = await process_eh_for_indexing( + eh_id=eh_id, + tenant_id=eh.tenant_id, + subject=eh.subject, + text_content=decrypted_text, + passphrase=data.passphrase, + salt_hex=eh.salt + ) + + # Index in Qdrant + await index_eh_chunks( + eh_id=eh_id, + tenant_id=eh.tenant_id, + subject=eh.subject, + chunks=chunks_data + ) + + # Update EH record + eh.status = EHStatus.INDEXED + eh.chunk_count = chunk_count + eh.indexed_at = datetime.now(timezone.utc) + + # Audit log + log_eh_audit( + tenant_id=eh.tenant_id, + user_id=user["user_id"], + action="indexed", + eh_id=eh_id, + details={"chunk_count": chunk_count} + ) + + return { + "status": "indexed", + "id": eh_id, + "chunk_count": chunk_count + } + + except EncryptionError as e: + eh.status = EHStatus.ERROR + eh.error_message = str(e) + raise HTTPException(status_code=400, detail=f"Decryption failed: {str(e)}") + except EmbeddingError as e: + eh.status = EHStatus.ERROR + eh.error_message = str(e) + raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}") + except Exception as e: + eh.status = EHStatus.ERROR + eh.error_message = str(e) + raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}") + + +@router.post("/api/v1/eh/rag-query") +async def rag_query_eh(data: EHRAGQuery, request: Request): + """ + RAG query against teacher's Erwartungshorizonte. + + 1. Semantic search in Qdrant (tenant-isolated) + 2. Decrypt relevant chunks on-the-fly + 3. Return context for LLM usage + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + if not OPENAI_API_KEY: + raise HTTPException(status_code=500, detail="OpenAI API key not configured") + + try: + # Generate embedding for query + query_embedding = await generate_single_embedding(data.query_text) + + # Search in Qdrant (tenant-isolated) + results = await search_eh( + query_embedding=query_embedding, + tenant_id=tenant_id, + subject=data.subject, + limit=data.limit + ) + + # Decrypt matching chunks + decrypted_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.passphrase, + eh.salt + ) + decrypted_chunks.append({ + "text": decrypted, + "eh_id": r["eh_id"], + "eh_title": eh.title, + "chunk_index": r["chunk_index"], + "score": r["score"] + }) + except EncryptionError: + # Skip chunks that can't be decrypted (wrong passphrase for different EH) + pass + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="rag_query", + details={ + "query_length": len(data.query_text), + "results_count": len(results), + "decrypted_count": len(decrypted_chunks) + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent") + ) + + return { + "context": "\n\n---\n\n".join([c["text"] for c in decrypted_chunks]), + "sources": decrypted_chunks, + "query": data.query_text + } + + except EmbeddingError as e: + raise HTTPException(status_code=500, detail=f"Query embedding failed: {str(e)}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"RAG query failed: {str(e)}") diff --git a/website/app/admin/companion/_components/BacklogTab.tsx b/website/app/admin/companion/_components/BacklogTab.tsx new file mode 100644 index 0000000..299c78b --- /dev/null +++ b/website/app/admin/companion/_components/BacklogTab.tsx @@ -0,0 +1,88 @@ +'use client' + +import type { Feature } from './types' +import { priorityColors } from './types' + +interface BacklogTabProps { + features: Feature[] +} + +export default function BacklogTab({ features }: BacklogTabProps) { + return ( +
+
+ {/* Todo Column */} +
+

+ + {features.filter(f => f.status === 'todo').length} + + Todo +

+
+ {features.filter(f => f.status === 'todo').map(f => ( +
+
{f.title}
+
{f.description}
+
+ + {f.priority} + + + {f.effort} + +
+
+ ))} +
+
+ + {/* In Progress Column */} +
+

+ + {features.filter(f => f.status === 'in_progress').length} + + In Arbeit +

+
+ {features.filter(f => f.status === 'in_progress').map(f => ( +
+
{f.title}
+
{f.description}
+
+ + {f.priority} + +
+
+ ))} +
+
+ + {/* Backlog Column */} +
+

+ + {features.filter(f => f.status === 'backlog').length} + + Backlog +

+
+ {features.filter(f => f.status === 'backlog').map(f => ( +
+
{f.title}
+
{f.description}
+
+ + {f.priority} + +
+
+ ))} +
+
+
+
+ ) +} diff --git a/website/app/admin/companion/_components/FeaturesTab.tsx b/website/app/admin/companion/_components/FeaturesTab.tsx new file mode 100644 index 0000000..5f11f24 --- /dev/null +++ b/website/app/admin/companion/_components/FeaturesTab.tsx @@ -0,0 +1,76 @@ +'use client' + +import type { Feature } from './types' +import { statusColors, priorityColors } from './types' +import { roadmapPhases } from './data' + +interface FeaturesTabProps { + features: Feature[] + selectedPhase: string | null + setSelectedPhase: (phase: string | null) => void + updateFeatureStatus: (id: string, status: Feature['status']) => void +} + +export default function FeaturesTab({ features, selectedPhase, setSelectedPhase, updateFeatureStatus }: FeaturesTabProps) { + return ( +
+ {/* Phase Filter */} +
+ + {roadmapPhases.map(phase => ( + + ))} +
+ + {/* Features List */} +
+ {features + .filter(f => !selectedPhase || f.phase === selectedPhase) + .map(feature => ( +
+ + {feature.priority} + +
+
{feature.title}
+
{feature.description}
+
+ + {feature.effort} + + +
+ ))} +
+
+ ) +} diff --git a/website/app/admin/companion/_components/FeedbackTab.tsx b/website/app/admin/companion/_components/FeedbackTab.tsx new file mode 100644 index 0000000..ba8594c --- /dev/null +++ b/website/app/admin/companion/_components/FeedbackTab.tsx @@ -0,0 +1,110 @@ +'use client' + +import type { Feature, TeacherFeedback } from './types' +import { priorityColors, feedbackTypeIcons } from './types' + +interface FeedbackTabProps { + features: Feature[] + filteredFeedback: TeacherFeedback[] + feedbackFilter: string + setFeedbackFilter: (filter: string) => void + updateFeedbackStatus: (id: string, status: TeacherFeedback['status']) => void +} + +export default function FeedbackTab({ + features, + filteredFeedback, + feedbackFilter, + setFeedbackFilter, + updateFeedbackStatus, +}: FeedbackTabProps) { + return ( +
+ {/* Filter */} +
+ {['all', 'new', 'bug', 'feature_request', 'improvement'].map(filter => ( + + ))} +
+ + {/* Feedback List */} +
+ {filteredFeedback.map(fb => ( +
+
+
+ + + +
+
+
+ {fb.title} + + {fb.priority} + +
+

{fb.description}

+
+ {fb.teacher} + {fb.date} + {fb.relatedFeature && ( + → {features.find(f => f.id === fb.relatedFeature)?.title} + )} +
+ {fb.response && ( +
+ Antwort: {fb.response} +
+ )} +
+ +
+
+ ))} +
+ + {/* Add Feedback Button */} + +
+ ) +} diff --git a/website/app/admin/companion/_components/RoadmapTab.tsx b/website/app/admin/companion/_components/RoadmapTab.tsx new file mode 100644 index 0000000..2582fc4 --- /dev/null +++ b/website/app/admin/companion/_components/RoadmapTab.tsx @@ -0,0 +1,84 @@ +'use client' + +import { roadmapPhases } from './data' + +export default function RoadmapTab() { + return ( +
+ {roadmapPhases.map((phase, index) => ( +
+
+
+
+
+ {phase.status === 'completed' ? '✓' : index + 1} +
+
+

{phase.name}

+

{phase.description}

+
+
+
+ + {phase.status === 'completed' ? 'Abgeschlossen' : + phase.status === 'in_progress' ? 'In Arbeit' : + phase.status === 'planned' ? 'Geplant' : 'Zukunft'} + + {phase.startDate && ( +
+ {phase.startDate} {phase.endDate ? `- ${phase.endDate}` : ''} +
+ )} +
+
+ + {/* Progress Bar */} +
+
+ Fortschritt + {phase.progress}% +
+
+
+
+
+ + {/* Features */} +
+ {phase.features.map((feature, i) => ( + + {feature} + + ))} +
+
+
+ ))} +
+ ) +} diff --git a/website/app/admin/companion/_components/StatsOverview.tsx b/website/app/admin/companion/_components/StatsOverview.tsx new file mode 100644 index 0000000..ea6116e --- /dev/null +++ b/website/app/admin/companion/_components/StatsOverview.tsx @@ -0,0 +1,34 @@ +'use client' + +interface StatsOverviewProps { + phaseStats: { completed: number; total: number; inProgress: number } + featureStats: { percentage: number; done: number; total: number } + feedbackStats: { newCount: number; total: number; bugs: number; requests: number } +} + +export default function StatsOverview({ phaseStats, featureStats, feedbackStats }: StatsOverviewProps) { + return ( +
+
+
Roadmap-Phasen
+
{phaseStats.completed}/{phaseStats.total}
+
{phaseStats.inProgress} in Arbeit
+
+
+
Features
+
{featureStats.percentage}%
+
{featureStats.done}/{featureStats.total} fertig
+
+
+
Neues Feedback
+
{feedbackStats.newCount}
+
{feedbackStats.total} gesamt
+
+
+
Offene Bugs
+
{feedbackStats.bugs}
+
{feedbackStats.requests} Feature-Requests
+
+
+ ) +} diff --git a/website/app/admin/companion/_components/data.ts b/website/app/admin/companion/_components/data.ts new file mode 100644 index 0000000..28bea53 --- /dev/null +++ b/website/app/admin/companion/_components/data.ts @@ -0,0 +1,404 @@ +import type { RoadmapPhase, Feature, TeacherFeedback } from './types' + +// ==================== ROADMAP DATA ==================== + +export const roadmapPhases: RoadmapPhase[] = [ + { + id: 'phase-1', + name: 'Phase 1: Core Engine', + status: 'completed', + progress: 100, + startDate: '2026-01-10', + endDate: '2026-01-14', + description: 'Grundlegende State Machine und API-Endpunkte', + features: [ + 'Finite State Machine (5 Phasen)', + 'Timer Service mit Countdown', + 'Phasenspezifische Suggestions', + 'REST API Endpoints', + 'In-Memory Session Storage', + ], + }, + { + id: 'phase-2', + name: 'Phase 2: Frontend Integration', + status: 'completed', + progress: 100, + startDate: '2026-01-14', + endDate: '2026-01-14', + description: 'Integration in das Studio-Frontend', + features: [ + 'Lesson-Modus im Companion', + 'Timer-Anzeige mit Warning/Overtime', + 'Phasen-Timeline Visualisierung', + 'Suggestions pro Phase', + 'Session Start/End UI', + ], + }, + { + id: 'phase-2b', + name: 'Phase 2b: Teacher UX Optimierung', + status: 'completed', + progress: 100, + startDate: '2026-01-15', + endDate: '2026-01-15', + description: 'Forschungsbasierte UX-Verbesserungen fuer intuitive Lehrer-Bedienung', + features: [ + 'Visual Pie Timer (Kreis statt Zahlen)', + 'Phasen-Farbschema (Blau→Orange→Gruen→Lila→Grau)', + 'Quick Actions Bar (+5min, Pause, Skip)', + 'Tablet-First Responsive Design', + 'Large Touch Targets (48x48px min)', + 'High Contrast fuer Beamer', + 'Audio Cues (sanfte Toene)', + ], + }, + { + id: 'phase-3', + name: 'Phase 3: Persistenz', + status: 'completed', + progress: 100, + startDate: '2026-01-15', + endDate: '2026-01-15', + description: 'Datenbank-Anbindung und Session-Persistenz', + features: [ + 'PostgreSQL Integration (done)', + 'SQLAlchemy Models (done)', + 'Session Repository (done)', + 'Alembic Migration Scripts (done)', + 'Session History API (done)', + 'Hybrid Storage (Memory+DB) (done)', + 'Lehrer-spezifische Settings (backlog)', + 'Keycloak Auth Integration (backlog)', + ], + }, + { + id: 'phase-4', + name: 'Phase 4: Content Integration', + status: 'completed', + progress: 100, + startDate: '2026-01-15', + endDate: '2026-01-15', + description: 'Verknuepfung mit Learning Units', + features: [ + 'Lesson Templates (done)', + 'Fachspezifische Unit-Vorschlaege (done)', + 'Hausaufgaben-Tracker (done)', + 'Material-Verknuepfung (done)', + ], + }, + { + id: 'phase-5', + name: 'Phase 5: Analytics', + status: 'completed', + progress: 100, + startDate: '2026-01-15', + endDate: '2026-01-15', + description: 'Unterrichtsanalyse und Optimierung (ohne wertende Metriken)', + features: [ + 'Phasen-Dauer Statistiken (done)', + 'Overtime-Analyse (done)', + 'Post-Lesson Reflection API (done)', + 'Lehrer-Dashboard UI (done)', + 'HTML/PDF Export (done)', + ], + }, + { + id: 'phase-6', + name: 'Phase 6: Real-time', + status: 'completed', + progress: 100, + startDate: '2026-01-15', + endDate: '2026-01-15', + description: 'WebSocket-basierte Echtzeit-Updates', + features: [ + 'WebSocket API Endpoint (done)', + 'Connection Manager mit Multi-Device Support (done)', + 'Timer Broadcast Loop (1-Sekunden-Genauigkeit) (done)', + 'Client-seitiger WebSocket Handler (done)', + 'Automatischer Reconnect mit Fallback zu Polling (done)', + 'Phase Change & Session End Notifications (done)', + 'Connection Status Indicator (done)', + 'WebSocket Tests (done)', + ], + }, + { + id: 'phase-7', + name: 'Phase 7: Erweiterungen', + status: 'completed', + progress: 100, + startDate: '2026-01-15', + endDate: '2026-01-15', + description: 'Lehrer-Feedback und Authentifizierung', + features: [ + 'Teacher Feedback API (done)', + 'Feedback Modal im Lehrer-Frontend (done)', + 'Keycloak Auth Integration (done)', + 'Optional Auth Dependency (done)', + 'Feedback DB Model & Migration (done)', + 'Feedback Repository (done)', + ], + }, + { + id: 'phase-8', + name: 'Phase 8: Schuljahres-Begleiter', + status: 'in_progress', + progress: 85, + startDate: '2026-01-15', + description: '2-Schichten-Modell: Makro-Phasen (Schuljahr) + Mikro-Engine (Events/Routinen)', + features: [ + 'Kontext-Datenmodell (TeacherContextDB, SchoolyearEventDB, RecurringRoutineDB) (done)', + 'Alembic Migration 007 (done)', + 'GET /v1/context Endpoint (done)', + 'Events & Routinen CRUD-APIs (done)', + 'Bundeslaender & Schularten Stammdaten (done)', + 'Antizipations-Engine mit 12 Regeln (done)', + 'GET /v1/suggestions Endpoint (done)', + 'Dynamische Sidebar /v1/sidebar (done)', + 'Schuljahres-Pfad /v1/path (done)', + 'Frontend ContextBar Component (done)', + 'Frontend Dynamic Sidebar (done)', + 'Frontend PathPanel Component (done)', + 'Main Content Actions Integration (done)', + 'Onboarding-Flow (geplant)', + ], + }, + { + id: 'phase-9', + name: 'Phase 9: Zukunft', + status: 'future', + progress: 0, + description: 'Weitere geplante Features', + features: [ + 'Push Notifications', + 'Dark Mode', + 'Lesson Templates Library (erweitert)', + 'Multi-Language Support', + 'KI-Assistenz fuer Unterrichtsplanung', + ], + }, +] + +// ==================== FEATURES DATA ==================== + +export const initialFeatures: Feature[] = [ + // Phase 1 - Done + { id: 'f1', title: 'LessonPhase Enum', description: '7 Zustaende: not_started, 5 Phasen, ended', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'small' }, + { id: 'f2', title: 'LessonSession Dataclass', description: 'Session-Datenmodell mit History', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'medium' }, + { id: 'f3', title: 'FSM Transitions', description: 'Erlaubte Phasen-Uebergaenge', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'medium' }, + { id: 'f4', title: 'PhaseTimer Service', description: 'Countdown, Warning, Overtime', priority: 'high', status: 'done', phase: 'phase-1', effort: 'medium' }, + { id: 'f5', title: 'SuggestionEngine', description: 'Phasenspezifische Aktivitaets-Vorschlaege', priority: 'high', status: 'done', phase: 'phase-1', effort: 'large' }, + { id: 'f6', title: 'REST API Endpoints', description: '10 Endpoints unter /api/classroom', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'large' }, + + // Phase 2 - Done + { id: 'f7', title: 'Mode Toggle (3 Modi)', description: 'Begleiter, Stunde, Klassisch', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' }, + { id: 'f8', title: 'Timer-Display', description: 'Grosser Countdown mit Styling', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' }, + { id: 'f9', title: 'Phasen-Timeline', description: 'Horizontale 5-Phasen-Anzeige', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' }, + { id: 'f10', title: 'Control Buttons', description: 'Naechste Phase, Beenden', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' }, + { id: 'f11', title: 'Suggestions Cards', description: 'Aktivitaets-Vorschlaege UI', priority: 'medium', status: 'done', phase: 'phase-2', effort: 'medium' }, + { id: 'f12', title: 'Session Start Form', description: 'Klasse, Fach, Thema auswaehlen', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' }, + + // Phase 3 - In Progress (Persistenz) + { id: 'f13', title: 'PostgreSQL Models', description: 'SQLAlchemy Models fuer Sessions (LessonSessionDB, PhaseHistoryDB, TeacherSettingsDB)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'medium' }, + { id: 'f14', title: 'Session Repository', description: 'CRUD Operationen fuer Sessions (SessionRepository, TeacherSettingsRepository)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'medium' }, + { id: 'f15', title: 'Migration Scripts', description: 'Alembic Migrationen fuer Classroom Tables', priority: 'high', status: 'done', phase: 'phase-3', effort: 'small' }, + { id: 'f16', title: 'Teacher Settings', description: 'Individuelle Phasen-Dauern speichern (API + Settings Modal UI)', priority: 'medium', status: 'done', phase: 'phase-7', effort: 'medium' }, + { id: 'f17', title: 'Session History API', description: 'GET /history/{teacher_id} mit Pagination', priority: 'medium', status: 'done', phase: 'phase-3', effort: 'small' }, + + // Phase 4 - In Progress (Content) + { id: 'f18', title: 'Unit-Vorschlaege', description: 'Fachspezifische Learning Units pro Phase (Mathe, Deutsch, Englisch, Bio, Physik, Informatik)', priority: 'high', status: 'done', phase: 'phase-4', effort: 'large' }, + { id: 'f19', title: 'Material-Verknuepfung', description: 'Dokumente an Phasen anhaengen (PhaseMaterial Model, Repository, 8 API-Endpoints, Frontend-Integration)', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' }, + { id: 'f20', title: 'Hausaufgaben-Tracker', description: 'CRUD API fuer Hausaufgaben mit Status und Faelligkeit', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' }, + + // ==================== NEUE UX FEATURES (aus Research) ==================== + + // P0 - KRITISCH (UX Research basiert) + { id: 'f21', title: 'Visual Pie Timer', description: 'Kreisfoermiger Countdown mit Farbverlauf (Gruen→Gelb→Rot) - reduziert Stress laut Forschung', priority: 'critical', status: 'done', phase: 'phase-2', effort: 'large' }, + { id: 'f22', title: 'Database Persistence', description: 'PostgreSQL statt In-Memory - Sessions ueberleben Neustart (Hybrid Storage)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'large' }, + { id: 'f23', title: 'Teacher Auth Integration', description: 'Keycloak-Anbindung mit optionalem Demo-User Fallback', priority: 'critical', status: 'done', phase: 'phase-7', effort: 'large' }, + { id: 'f24', title: 'Tablet-First Responsive', description: 'Optimiert fuer 10" Touch-Screens, Einhand-Bedienung im Klassenraum', priority: 'critical', status: 'done', phase: 'phase-2b', effort: 'medium' }, + + // P1 - WICHTIG (UX Research basiert) + { id: 'f25', title: 'Phasen-Farbschema', description: 'Forschungsbasierte Farben: Blau(Einstieg), Orange(Erarbeitung), Gruen(Sicherung), Lila(Transfer), Grau(Reflexion)', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' }, + { id: 'f26', title: 'Quick Actions Bar', description: '+5min, Pause, Skip-Phase als One-Click Touch-Buttons (min 56px)', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' }, + { id: 'f27', title: 'Pause Timer API', description: 'POST /sessions/{id}/pause - Timer anhalten bei Stoerungen', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' }, + { id: 'f28', title: 'Extend Phase API', description: 'POST /sessions/{id}/extend?minutes=5 - Phase verlaengern', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' }, + { id: 'f29', title: 'Non-Intrusive Suggestions', description: 'Vorschlaege in dedizierter Sektion, nicht als stoerende Popups', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' }, + { id: 'f30', title: 'WebSocket Real-Time Timer', description: 'Sub-Sekunden Genauigkeit statt 5s Polling', priority: 'high', status: 'done', phase: 'phase-6', effort: 'large' }, + { id: 'f31', title: 'Mobile Breakpoints', description: 'Responsive Design fuer 600px, 900px, 1200px Screens', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' }, + { id: 'f32', title: 'Large Touch Targets', description: 'Alle Buttons min 48x48px fuer sichere Touch-Bedienung', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' }, + + // P2 - NICE-TO-HAVE (UX Research basiert) + { id: 'f33', title: 'Audio Cues', description: 'Sanfte Toene bei Phasenwechsel und Warnungen (Taste A zum Toggle)', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' }, + { id: 'f34', title: 'Keyboard Shortcuts', description: 'Space=Pause, N=Next Phase, E=Extend, H=High Contrast - fuer Desktop-Nutzung', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' }, + { id: 'f35', title: 'Offline Timer Fallback', description: 'Client-seitige Timer-Berechnung bei Verbindungsverlust', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'medium' }, + { id: 'f36', title: 'Post-Lesson Analytics', description: 'Phasen-Dauer Statistiken ohne wertende Metriken (SessionSummary, TeacherAnalytics, 4 API-Endpoints)', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'large' }, + { id: 'f37', title: 'Lesson Templates', description: '5 System-Templates + eigene Vorlagen erstellen/speichern', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' }, + { id: 'f38', title: 'ARIA Labels', description: 'Screen-Reader Unterstuetzung fuer Barrierefreiheit', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' }, + { id: 'f39', title: 'High Contrast Mode', description: 'Erhoehter Kontrast fuer Beamer/Projektor (Taste H)', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' }, + { id: 'f40', title: 'Export to PDF', description: 'Stundenprotokoll als druckbares HTML mit Browser-PDF-Export (Strg+P)', priority: 'low', status: 'done', phase: 'phase-5', effort: 'medium' }, + { id: 'f41', title: 'Overtime-Analyse', description: 'Phase-by-Phase Overtime-Statistiken und Trends', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'medium' }, + { id: 'f42', title: 'Post-Lesson Reflection', description: 'Reflexions-Notizen nach Stundenende (CRUD API, DB-Model)', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'medium' }, + { id: 'f43', title: 'Phase Duration Trends', description: 'Visualisierung der Phasendauer-Entwicklung', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'small' }, + { id: 'f44', title: 'Analytics Dashboard UI', description: 'Lehrer-Frontend fuer Analytics-Anzeige (Phasen-Bars, Overtime, Reflection)', priority: 'high', status: 'done', phase: 'phase-5', effort: 'medium' }, + + // Phase 6 - Real-time (WebSocket) + { id: 'f45', title: 'WebSocket API Endpoint', description: 'Real-time Verbindung unter /api/classroom/ws/{session_id}', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'large' }, + { id: 'f46', title: 'Connection Manager', description: 'Multi-Device Support mit Session-basierter Verbindungsverwaltung', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'medium' }, + { id: 'f47', title: 'Timer Broadcast Loop', description: 'Hintergrund-Task sendet Timer-Updates jede Sekunde an alle Clients', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'medium' }, + { id: 'f48', title: 'Client WebSocket Handler', description: 'Frontend-Integration mit automatischem Reconnect und Fallback zu Polling', priority: 'high', status: 'done', phase: 'phase-6', effort: 'large' }, + { id: 'f49', title: 'Phase Change Notifications', description: 'Echtzeit-Benachrichtigung bei Phasenwechsel an alle Devices', priority: 'high', status: 'done', phase: 'phase-6', effort: 'small' }, + { id: 'f50', title: 'Session End Notifications', description: 'Automatische Benachrichtigung bei Stundenende', priority: 'high', status: 'done', phase: 'phase-6', effort: 'small' }, + { id: 'f51', title: 'Connection Status Indicator', description: 'UI-Element zeigt Live/Polling/Offline Status', priority: 'medium', status: 'done', phase: 'phase-6', effort: 'small' }, + { id: 'f52', title: 'WebSocket Status API', description: 'GET /ws/status zeigt aktive Sessions und Verbindungszahlen', priority: 'low', status: 'done', phase: 'phase-6', effort: 'small' }, + + // Phase 7 - Erweiterungen (Auth & Feedback) + { id: 'f53', title: 'Teacher Feedback API', description: 'POST/GET /feedback Endpoints fuer Bug-Reports und Feature-Requests', priority: 'high', status: 'done', phase: 'phase-7', effort: 'large' }, + { id: 'f54', title: 'Feedback Modal UI', description: 'Floating Action Button und Modal im Lehrer-Frontend', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' }, + { id: 'f55', title: 'Feedback DB Model', description: 'TeacherFeedbackDB SQLAlchemy Model mit Alembic Migration', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' }, + { id: 'f56', title: 'Feedback Repository', description: 'CRUD-Operationen fuer Feedback mit Status-Management', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' }, + { id: 'f57', title: 'Keycloak Auth Integration', description: 'Optional Auth Dependency mit Demo-User Fallback', priority: 'critical', status: 'done', phase: 'phase-7', effort: 'medium' }, + { id: 'f58', title: 'Feedback Stats API', description: 'GET /feedback/stats fuer Dashboard-Statistiken', priority: 'medium', status: 'done', phase: 'phase-7', effort: 'small' }, + + // Phase 8 - Schuljahres-Begleiter (2-Schichten-Modell) + { id: 'f59', title: 'TeacherContextDB Model', description: 'Makro-Kontext pro Lehrer (Bundesland, Schulart, Schuljahr, Phase)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' }, + { id: 'f60', title: 'SchoolyearEventDB Model', description: 'Events (Klausuren, Elternabende, Klassenfahrten, etc.)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' }, + { id: 'f61', title: 'RecurringRoutineDB Model', description: 'Wiederkehrende Routinen (Konferenzen, Sprechstunden, etc.)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' }, + { id: 'f62', title: 'Alembic Migration 007', description: 'DB-Migration fuer teacher_contexts, schoolyear_events, recurring_routines', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'small' }, + { id: 'f63', title: 'GET /v1/context Endpoint', description: 'Makro-Kontext abrufen (Schuljahr, Woche, Phase, Flags)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'large' }, + { id: 'f64', title: 'PUT /v1/context Endpoint', description: 'Kontext aktualisieren (Bundesland, Schulart, Schuljahr)', priority: 'high', status: 'done', phase: 'phase-8', effort: 'medium' }, + { id: 'f65', title: 'Events CRUD-API', description: 'GET/POST/DELETE /v1/events mit Status und Vorbereitung', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' }, + { id: 'f66', title: 'Routines CRUD-API', description: 'GET/POST/DELETE /v1/routines mit Wiederholungsmustern', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' }, + { id: 'f67', title: 'Stammdaten-APIs', description: '/v1/federal-states, /v1/school-types, /v1/macro-phases, /v1/event-types', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'small' }, + { id: 'f68', title: 'Context Repositories', description: 'TeacherContextRepository, SchoolyearEventRepository, RecurringRoutineRepository', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' }, + { id: 'f69', title: 'Antizipations-Engine', description: 'Signal-Collector + Regel-Engine (12 Regeln) fuer proaktive Vorschlaege', priority: 'high', status: 'done', phase: 'phase-8', effort: 'epic' }, + { id: 'f70', title: 'GET /v1/suggestions Endpoint', description: 'Kontextbasierte Vorschlaege mit active_contexts[]', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' }, + { id: 'f71', title: 'GET /v1/sidebar Endpoint', description: 'Dynamisches Sidebar-Model (Companion vs Classic Mode)', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' }, + { id: 'f72', title: 'GET /v1/path Endpoint', description: 'Schuljahres-Meilensteine mit Status (DONE, CURRENT, UPCOMING)', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' }, + { id: 'f73', title: 'ContextBar Component', description: 'Schuljahr, Woche, Bundesland Anzeige im Header', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' }, + { id: 'f74', title: 'Begleiter-Sidebar', description: 'Top 5 relevante Module + Alle Module + Suche', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'large' }, + { id: 'f75', title: 'PathPanel Component', description: 'Vertikaler Schuljahres-Pfad mit "Du bist hier" Markierung', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' }, + { id: 'f76', title: 'Onboarding-Flow', description: 'Bundesland, Schulart, Schuljahres-Start, erste Klassen', priority: 'high', status: 'backlog', phase: 'phase-8', effort: 'large' }, + { id: 'f77', title: 'Complete Onboarding API', description: 'POST /v1/context/complete-onboarding zum Abschliessen', priority: 'high', status: 'done', phase: 'phase-8', effort: 'small' }, +] + +// ==================== FEEDBACK DATA ==================== + +export const initialFeedback: TeacherFeedback[] = [ + { + id: 'fb1', + teacher: 'Frau Mueller', + date: '2026-01-14', + type: 'feature_request', + priority: 'high', + status: 'implemented', + title: 'Individuelle Phasen-Dauern', + description: 'Ich moechte die Dauern der einzelnen Phasen selbst festlegen koennen, je nach Unterrichtseinheit.', + relatedFeature: 'f16', + response: 'In Phase 7 implementiert: Einstellungen-Button oeffnet Modal zur Konfiguration der Phasendauern.', + }, + { + id: 'fb2', + teacher: 'Herr Schmidt', + date: '2026-01-14', + type: 'improvement', + priority: 'medium', + status: 'implemented', + title: 'Akustisches Signal bei Phasen-Ende', + description: 'Ein kurzer Ton wuerde helfen, das Ende einer Phase nicht zu verpassen.', + relatedFeature: 'f33', + response: 'Audio Cues wurden in Phase 2b implementiert - sanfte Toene statt harter Alarme. Taste A zum Toggle.', + }, + { + id: 'fb3', + teacher: 'Frau Wagner', + date: '2026-01-15', + type: 'praise', + priority: 'low', + status: 'acknowledged', + title: 'Super einfache Bedienung!', + description: 'Die Stunden-Steuerung ist sehr intuitiv. Meine erste Stunde damit hat super geklappt.', + }, + { + id: 'fb4', + teacher: 'Herr Becker', + date: '2026-01-15', + type: 'bug', + priority: 'high', + status: 'implemented', + title: 'Timer stoppt bei Browser-Tab-Wechsel', + description: 'Wenn ich den Browser-Tab wechsle und zurueckkomme, zeigt der Timer manchmal falsche Werte.', + relatedFeature: 'f35', + response: 'Offline Timer Fallback in Phase 2b implementiert + WebSocket Real-time in Phase 6.', + }, + { + id: 'fb5', + teacher: 'Frau Klein', + date: '2026-01-15', + type: 'feature_request', + priority: 'critical', + status: 'implemented', + title: 'Pause-Funktion', + description: 'Manchmal muss ich die Stunde kurz unterbrechen (Stoerung, Durchsage). Eine Pause-Funktion waere super.', + relatedFeature: 'f27', + response: 'Pause Timer API und Quick Actions Bar wurden in Phase 2b implementiert. Tastenkuerzel: Leertaste.', + }, + { + id: 'fb6', + teacher: 'Herr Hoffmann', + date: '2026-01-15', + type: 'feature_request', + priority: 'high', + status: 'implemented', + title: 'Visueller Timer statt Zahlen', + description: 'Der numerische Countdown ist manchmal stressig. Ein visueller Kreis-Timer waere entspannter.', + relatedFeature: 'f21', + response: 'Visual Pie Timer mit Farbverlauf (Gruen→Gelb→Rot) wurde in Phase 2b implementiert.', + }, + { + id: 'fb7', + teacher: 'Frau Richter', + date: '2026-01-15', + type: 'feature_request', + priority: 'high', + status: 'implemented', + title: 'Tablet-Nutzung im Klassenraum', + description: 'Ich laufe waehrend des Unterrichts herum. Die Anzeige muesste auch auf meinem iPad gut funktionieren.', + relatedFeature: 'f24', + response: 'Tablet-First Responsive Design wurde in Phase 2b implementiert. Touch-Targets min 48x48px.', + }, + { + id: 'fb8', + teacher: 'Herr Weber', + date: '2026-01-15', + type: 'improvement', + priority: 'medium', + status: 'implemented', + title: '+5 Minuten Button', + description: 'Manchmal brauche ich einfach nur 5 Minuten mehr fuer eine Phase. Ein Schnell-Button waere praktisch.', + relatedFeature: 'f28', + response: 'In Quick Actions Bar integriert. Tastenkuerzel: E.', + }, + { + id: 'fb9', + teacher: 'Frau Schneider', + date: '2026-01-15', + type: 'praise', + priority: 'low', + status: 'acknowledged', + title: 'Phasen-Vorschlaege sind hilfreich', + description: 'Die Aktivitaets-Vorschlaege pro Phase geben mir gute Ideen. Weiter so!', + }, + { + id: 'fb10', + teacher: 'Herr Meier', + date: '2026-01-15', + type: 'feature_request', + priority: 'medium', + status: 'implemented', + title: 'Stundenvorlage speichern', + description: 'Fuer Mathe-Stunden nutze ich immer die gleiche Phasen-Aufteilung. Waere cool, das als Template zu speichern.', + relatedFeature: 'f37', + response: 'Lesson Templates wurden in Phase 4 implementiert. 5 System-Templates + eigene Vorlagen moeglich.', + }, +] diff --git a/website/app/admin/companion/_components/system-info.ts b/website/app/admin/companion/_components/system-info.ts new file mode 100644 index 0000000..121b022 --- /dev/null +++ b/website/app/admin/companion/_components/system-info.ts @@ -0,0 +1,119 @@ +// ==================== SYSTEM INFO CONFIG ==================== + +export const companionSystemInfo = { + title: 'Companion Module System Info', + description: 'Technische Details zur Classroom State Machine', + version: '1.1.0', + architecture: { + layers: [ + { + title: 'Frontend Layer', + components: [ + 'companion.py (Lesson-Modus UI)', + 'Mode Toggle (Begleiter/Stunde/Klassisch)', + 'Timer Display Component', + 'Phase Timeline Component', + 'Suggestions Cards', + 'Material Design Icons (CDN)', + ], + color: 'bg-blue-50', + }, + { + title: 'API Layer', + components: [ + 'classroom_api.py (FastAPI Router)', + 'POST /sessions - Session erstellen', + 'POST /sessions/{id}/start - Stunde starten', + 'POST /sessions/{id}/next-phase - Naechste Phase', + 'POST /sessions/{id}/pause - Timer pausieren', + 'POST /sessions/{id}/extend - Phase verlaengern', + 'GET /sessions/{id}/timer - Timer Status', + 'GET /sessions/{id}/suggestions - Vorschlaege', + 'GET /history/{teacher_id} - Session History', + 'GET /health - Health Check mit DB-Status', + 'GET/PUT /v1/context - Schuljahres-Kontext', + 'GET/POST/DELETE /v1/events - Events CRUD', + 'GET/POST/DELETE /v1/routines - Routinen CRUD', + 'GET /v1/federal-states, /v1/school-types, etc.', + ], + color: 'bg-green-50', + }, + { + title: 'Engine Layer', + components: [ + 'classroom_engine/ Package', + 'models.py - LessonPhase, LessonSession', + 'fsm.py - LessonStateMachine', + 'timer.py - PhaseTimer', + 'suggestions.py - SuggestionEngine', + 'context_models.py - TeacherContextDB, SchoolyearEventDB, RecurringRoutineDB', + 'antizipation.py - AntizipationsEngine (geplant)', + ], + color: 'bg-amber-50', + }, + { + title: 'Storage Layer', + components: [ + 'Hybrid Storage (Memory + PostgreSQL)', + 'SessionRepository (CRUD)', + 'TeacherSettingsRepository', + 'TeacherContextRepository (Phase 8)', + 'SchoolyearEventRepository (Phase 8)', + 'RecurringRoutineRepository (Phase 8)', + 'Alembic Migrations (007: Phase 8 Tables)', + 'Session History API', + ], + color: 'bg-purple-50', + }, + ], + }, + features: [ + { name: '5-Phasen-Modell', status: 'active' as const, description: 'Einstieg, Erarbeitung, Sicherung, Transfer, Reflexion' }, + { name: 'Timer mit Warning', status: 'active' as const, description: '2 Minuten Warnung vor Phasen-Ende' }, + { name: 'Overtime Detection', status: 'active' as const, description: 'Anzeige wenn Phase ueberzogen wird' }, + { name: 'Phasen-Suggestions', status: 'active' as const, description: '3-6 Aktivitaets-Vorschlaege pro Phase' }, + { name: 'Visual Pie Timer', status: 'active' as const, description: 'Kreisfoermiger Countdown mit Farbverlauf' }, + { name: 'Quick Actions Bar', status: 'active' as const, description: '+5min, Pause, Skip Buttons' }, + { name: 'Tablet-First Design', status: 'active' as const, description: 'Touch-optimiert fuer Tablets' }, + { name: 'Phasen-Farbschema', status: 'active' as const, description: 'Blau→Orange→Gruen→Lila→Grau' }, + { name: 'Keyboard Shortcuts', status: 'active' as const, description: 'Space=Pause, N=Next, E=Extend, H=Contrast' }, + { name: 'Audio Cues', status: 'active' as const, description: 'Sanfte Toene bei Phasenwechsel' }, + { name: 'Offline Timer', status: 'active' as const, description: 'Client-seitige Fallback bei Verbindungsverlust' }, + { name: 'DB Persistenz', status: 'active' as const, description: 'PostgreSQL Hybrid Storage' }, + { name: 'Session History', status: 'active' as const, description: 'GET /history/{teacher_id} API' }, + { name: 'Alembic Migrations', status: 'active' as const, description: 'Versionierte DB-Schema-Aenderungen' }, + { name: 'Teacher Auth', status: 'active' as const, description: 'Keycloak Integration mit Optional Fallback (Phase 7)' }, + { name: 'WebSocket Real-time', status: 'active' as const, description: 'Echtzeit-Updates mit 1-Sekunden-Genauigkeit (Phase 6)' }, + { name: 'Schuljahres-Kontext', status: 'active' as const, description: 'Makro-Phasen, Bundesland, Schulart (Phase 8)' }, + { name: 'Events & Routinen', status: 'active' as const, description: 'Klausuren, Konferenzen, Elternabende (Phase 8)' }, + { name: 'Antizipations-Engine', status: 'active' as const, description: '12 Regeln fuer proaktive Vorschlaege (Phase 8)' }, + { name: 'Dynamische Sidebar', status: 'active' as const, description: 'Top 5 relevante Module + Alle Module (Phase 8)' }, + { name: 'Schuljahres-Pfad', status: 'active' as const, description: '7 Meilensteine mit Fortschrittsanzeige (Phase 8)' }, + ], + roadmap: [ + { phase: 'Phase 1: Core Engine', priority: 'high' as const, items: ['FSM', 'Timer', 'Suggestions', 'API'] }, + { phase: 'Phase 2: Frontend', priority: 'high' as const, items: ['Lesson-Modus UI', 'Timer Display', 'Timeline'] }, + { phase: 'Phase 2b: UX Optimierung', priority: 'high' as const, items: ['Visual Timer', 'Farbschema', 'Tablet-First', 'Quick Actions'] }, + { phase: 'Phase 3: Persistenz', priority: 'high' as const, items: ['PostgreSQL', 'Keycloak Auth', 'Session History'] }, + { phase: 'Phase 4: Content', priority: 'medium' as const, items: ['Unit-Vorschlaege', 'Templates', 'Hausaufgaben'] }, + { phase: 'Phase 5: Analytics', priority: 'medium' as const, items: ['Statistiken (ohne Bewertung)', 'PDF Export'] }, + { phase: 'Phase 6: Real-time', priority: 'low' as const, items: ['WebSocket', 'Offline Fallback', 'Multi-Device'] }, + ], + technicalDetails: [ + { component: 'Backend', technology: 'Python FastAPI', version: '0.123+', description: 'Async REST API' }, + { component: 'State Machine', technology: 'Python Enum + Dataclass', description: 'Finite State Machine Pattern' }, + { component: 'Timer', technology: 'datetime.utcnow()', description: 'Server-side Time Calculation' }, + { component: 'Frontend', technology: 'Vanilla JavaScript ES6+', description: 'In companion.py eingebettet' }, + { component: 'Icons', technology: 'Material Design Icons', description: 'Via Google Fonts CDN (Apache-2.0)' }, + { component: 'WebSocket', technology: 'FastAPI WebSocket', description: 'Echtzeit-Updates mit 1-Sekunden-Genauigkeit + Polling Fallback' }, + { component: 'Database', technology: 'PostgreSQL + SQLAlchemy 2.0', description: 'Hybrid Storage (Memory + DB)' }, + { component: 'Migrations', technology: 'Alembic 1.14', description: 'Versionierte Schema-Migrationen' }, + ], + privacyNotes: [ + 'Keine Schueler-Daten werden gespeichert', + 'Session-Daten sind nur waehrend der Stunde verfuegbar', + 'Lehrer-ID wird fuer Session-Zuordnung verwendet', + 'Keine Tracking-Cookies oder externe Services', + 'Analytics ohne bewertende Metriken (keine "70% Redezeit"-Anzeigen)', + ], +} diff --git a/website/app/admin/companion/_components/types.ts b/website/app/admin/companion/_components/types.ts new file mode 100644 index 0000000..1d9344f --- /dev/null +++ b/website/app/admin/companion/_components/types.ts @@ -0,0 +1,62 @@ +// ==================== TYPES ==================== + +export interface RoadmapPhase { + id: string + name: string + status: 'completed' | 'in_progress' | 'planned' | 'future' + progress: number + startDate?: string + endDate?: string + description: string + features: string[] +} + +export interface Feature { + id: string + title: string + description: string + priority: 'critical' | 'high' | 'medium' | 'low' + status: 'done' | 'in_progress' | 'todo' | 'backlog' + phase: string + effort: 'small' | 'medium' | 'large' | 'epic' + assignee?: string + dueDate?: string + feedback?: string[] +} + +export interface TeacherFeedback { + id: string + teacher: string + date: string + type: 'bug' | 'feature_request' | 'improvement' | 'praise' | 'question' + priority: 'critical' | 'high' | 'medium' | 'low' + status: 'new' | 'acknowledged' | 'planned' | 'implemented' | 'declined' + title: string + description: string + relatedFeature?: string + response?: string +} + +// ==================== STYLE MAPS ==================== + +export const statusColors: Record = { + done: 'bg-green-100 text-green-800', + in_progress: 'bg-blue-100 text-blue-800', + todo: 'bg-amber-100 text-amber-800', + backlog: 'bg-slate-100 text-slate-600', +} + +export const priorityColors: Record = { + critical: 'bg-red-500 text-white', + high: 'bg-orange-500 text-white', + medium: 'bg-yellow-500 text-white', + low: 'bg-slate-400 text-white', +} + +export const feedbackTypeIcons: Record = { + bug: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', + feature_request: 'M12 6v6m0 0v6m0-6h6m-6 0H6', + improvement: 'M13 10V3L4 14h7v7l9-11h-7z', + praise: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z', + question: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', +} diff --git a/website/app/admin/companion/_components/useCompanionDev.ts b/website/app/admin/companion/_components/useCompanionDev.ts new file mode 100644 index 0000000..c2043ca --- /dev/null +++ b/website/app/admin/companion/_components/useCompanionDev.ts @@ -0,0 +1,97 @@ +'use client' + +import { useState, useEffect } from 'react' +import type { Feature, TeacherFeedback } from './types' +import { initialFeatures, initialFeedback, roadmapPhases } from './data' + +// Data version - increment when adding new features/feedback to force refresh +const DATA_VERSION = '8.2.0' // Phase 8e: Frontend UI-Komponenten (ContextBar, Sidebar, PathPanel) + +export function useCompanionDev() { + const [features, setFeatures] = useState(initialFeatures) + const [feedback, setFeedback] = useState(initialFeedback) + const [activeTab, setActiveTab] = useState<'roadmap' | 'features' | 'feedback' | 'backlog'>('roadmap') + const [selectedPhase, setSelectedPhase] = useState(null) + const [feedbackFilter, setFeedbackFilter] = useState('all') + + // Load from localStorage with version check + useEffect(() => { + const savedVersion = localStorage.getItem('companion-dev-version') + const savedFeatures = localStorage.getItem('companion-dev-features') + const savedFeedback = localStorage.getItem('companion-dev-feedback') + + // If version mismatch or no version, use initial data and save new version + if (savedVersion !== DATA_VERSION) { + console.log(`Companion Dev: Data version updated from ${savedVersion} to ${DATA_VERSION}`) + localStorage.setItem('companion-dev-version', DATA_VERSION) + localStorage.setItem('companion-dev-features', JSON.stringify(initialFeatures)) + localStorage.setItem('companion-dev-feedback', JSON.stringify(initialFeedback)) + // State already initialized with initialFeatures/initialFeedback, no need to setFeatures + return + } + + // Load saved data if version matches + if (savedFeatures) setFeatures(JSON.parse(savedFeatures)) + if (savedFeedback) setFeedback(JSON.parse(savedFeedback)) + }, []) + + // Save to localStorage + useEffect(() => { + localStorage.setItem('companion-dev-features', JSON.stringify(features)) + }, [features]) + + useEffect(() => { + localStorage.setItem('companion-dev-feedback', JSON.stringify(feedback)) + }, [feedback]) + + const getPhaseStats = () => { + const total = roadmapPhases.length + const completed = roadmapPhases.filter(p => p.status === 'completed').length + const inProgress = roadmapPhases.filter(p => p.status === 'in_progress').length + return { total, completed, inProgress } + } + + const getFeatureStats = () => { + const total = features.length + const done = features.filter(f => f.status === 'done').length + const inProgress = features.filter(f => f.status === 'in_progress').length + return { total, done, inProgress, percentage: Math.round((done / total) * 100) } + } + + const getFeedbackStats = () => { + const total = feedback.length + const newCount = feedback.filter(f => f.status === 'new').length + const bugs = feedback.filter(f => f.type === 'bug').length + const requests = feedback.filter(f => f.type === 'feature_request').length + return { total, newCount, bugs, requests } + } + + const updateFeatureStatus = (id: string, status: Feature['status']) => { + setFeatures(features.map(f => f.id === id ? { ...f, status } : f)) + } + + const updateFeedbackStatus = (id: string, status: TeacherFeedback['status']) => { + setFeedback(feedback.map(f => f.id === id ? { ...f, status } : f)) + } + + const filteredFeedback = feedbackFilter === 'all' + ? feedback + : feedback.filter(f => f.type === feedbackFilter || f.status === feedbackFilter) + + return { + features, + feedback, + activeTab, + setActiveTab, + selectedPhase, + setSelectedPhase, + feedbackFilter, + setFeedbackFilter, + phaseStats: getPhaseStats(), + featureStats: getFeatureStats(), + feedbackStats: getFeedbackStats(), + updateFeatureStatus, + updateFeedbackStatus, + filteredFeedback, + } +} diff --git a/website/app/admin/companion/page.tsx b/website/app/admin/companion/page.tsx index 660190f..d69b40c 100644 --- a/website/app/admin/companion/page.tsx +++ b/website/app/admin/companion/page.tsx @@ -12,708 +12,55 @@ */ import AdminLayout from '@/components/admin/AdminLayout' -import SystemInfoSection, { SYSTEM_INFO_CONFIGS } from '@/components/admin/SystemInfoSection' -import { useState, useEffect } from 'react' +import SystemInfoSection from '@/components/admin/SystemInfoSection' +import { useCompanionDev } from './_components/useCompanionDev' +import { companionSystemInfo } from './_components/system-info' +import StatsOverview from './_components/StatsOverview' +import RoadmapTab from './_components/RoadmapTab' +import FeaturesTab from './_components/FeaturesTab' +import FeedbackTab from './_components/FeedbackTab' +import BacklogTab from './_components/BacklogTab' -// ==================== TYPES ==================== - -interface RoadmapPhase { - id: string - name: string - status: 'completed' | 'in_progress' | 'planned' | 'future' - progress: number - startDate?: string - endDate?: string - description: string - features: string[] -} - -interface Feature { - id: string - title: string - description: string - priority: 'critical' | 'high' | 'medium' | 'low' - status: 'done' | 'in_progress' | 'todo' | 'backlog' - phase: string - effort: 'small' | 'medium' | 'large' | 'epic' - assignee?: string - dueDate?: string - feedback?: string[] -} - -interface TeacherFeedback { - id: string - teacher: string - date: string - type: 'bug' | 'feature_request' | 'improvement' | 'praise' | 'question' - priority: 'critical' | 'high' | 'medium' | 'low' - status: 'new' | 'acknowledged' | 'planned' | 'implemented' | 'declined' - title: string - description: string - relatedFeature?: string - response?: string -} - -// ==================== INITIAL DATA ==================== - -const roadmapPhases: RoadmapPhase[] = [ - { - id: 'phase-1', - name: 'Phase 1: Core Engine', - status: 'completed', - progress: 100, - startDate: '2026-01-10', - endDate: '2026-01-14', - description: 'Grundlegende State Machine und API-Endpunkte', - features: [ - 'Finite State Machine (5 Phasen)', - 'Timer Service mit Countdown', - 'Phasenspezifische Suggestions', - 'REST API Endpoints', - 'In-Memory Session Storage', - ], - }, - { - id: 'phase-2', - name: 'Phase 2: Frontend Integration', - status: 'completed', - progress: 100, - startDate: '2026-01-14', - endDate: '2026-01-14', - description: 'Integration in das Studio-Frontend', - features: [ - 'Lesson-Modus im Companion', - 'Timer-Anzeige mit Warning/Overtime', - 'Phasen-Timeline Visualisierung', - 'Suggestions pro Phase', - 'Session Start/End UI', - ], - }, - { - id: 'phase-2b', - name: 'Phase 2b: Teacher UX Optimierung', - status: 'completed', - progress: 100, - startDate: '2026-01-15', - endDate: '2026-01-15', - description: 'Forschungsbasierte UX-Verbesserungen fuer intuitive Lehrer-Bedienung', - features: [ - 'Visual Pie Timer (Kreis statt Zahlen)', - 'Phasen-Farbschema (Blau→Orange→Gruen→Lila→Grau)', - 'Quick Actions Bar (+5min, Pause, Skip)', - 'Tablet-First Responsive Design', - 'Large Touch Targets (48x48px min)', - 'High Contrast fuer Beamer', - 'Audio Cues (sanfte Toene)', - ], - }, - { - id: 'phase-3', - name: 'Phase 3: Persistenz', - status: 'completed', - progress: 100, - startDate: '2026-01-15', - endDate: '2026-01-15', - description: 'Datenbank-Anbindung und Session-Persistenz', - features: [ - 'PostgreSQL Integration (done)', - 'SQLAlchemy Models (done)', - 'Session Repository (done)', - 'Alembic Migration Scripts (done)', - 'Session History API (done)', - 'Hybrid Storage (Memory+DB) (done)', - 'Lehrer-spezifische Settings (backlog)', - 'Keycloak Auth Integration (backlog)', - ], - }, - { - id: 'phase-4', - name: 'Phase 4: Content Integration', - status: 'completed', - progress: 100, - startDate: '2026-01-15', - endDate: '2026-01-15', - description: 'Verknuepfung mit Learning Units', - features: [ - 'Lesson Templates (done)', - 'Fachspezifische Unit-Vorschlaege (done)', - 'Hausaufgaben-Tracker (done)', - 'Material-Verknuepfung (done)', - ], - }, - { - id: 'phase-5', - name: 'Phase 5: Analytics', - status: 'completed', - progress: 100, - startDate: '2026-01-15', - endDate: '2026-01-15', - description: 'Unterrichtsanalyse und Optimierung (ohne wertende Metriken)', - features: [ - 'Phasen-Dauer Statistiken (done)', - 'Overtime-Analyse (done)', - 'Post-Lesson Reflection API (done)', - 'Lehrer-Dashboard UI (done)', - 'HTML/PDF Export (done)', - ], - }, - { - id: 'phase-6', - name: 'Phase 6: Real-time', - status: 'completed', - progress: 100, - startDate: '2026-01-15', - endDate: '2026-01-15', - description: 'WebSocket-basierte Echtzeit-Updates', - features: [ - 'WebSocket API Endpoint (done)', - 'Connection Manager mit Multi-Device Support (done)', - 'Timer Broadcast Loop (1-Sekunden-Genauigkeit) (done)', - 'Client-seitiger WebSocket Handler (done)', - 'Automatischer Reconnect mit Fallback zu Polling (done)', - 'Phase Change & Session End Notifications (done)', - 'Connection Status Indicator (done)', - 'WebSocket Tests (done)', - ], - }, - { - id: 'phase-7', - name: 'Phase 7: Erweiterungen', - status: 'completed', - progress: 100, - startDate: '2026-01-15', - endDate: '2026-01-15', - description: 'Lehrer-Feedback und Authentifizierung', - features: [ - 'Teacher Feedback API (done)', - 'Feedback Modal im Lehrer-Frontend (done)', - 'Keycloak Auth Integration (done)', - 'Optional Auth Dependency (done)', - 'Feedback DB Model & Migration (done)', - 'Feedback Repository (done)', - ], - }, - { - id: 'phase-8', - name: 'Phase 8: Schuljahres-Begleiter', - status: 'in_progress', - progress: 85, - startDate: '2026-01-15', - description: '2-Schichten-Modell: Makro-Phasen (Schuljahr) + Mikro-Engine (Events/Routinen)', - features: [ - 'Kontext-Datenmodell (TeacherContextDB, SchoolyearEventDB, RecurringRoutineDB) (done)', - 'Alembic Migration 007 (done)', - 'GET /v1/context Endpoint (done)', - 'Events & Routinen CRUD-APIs (done)', - 'Bundeslaender & Schularten Stammdaten (done)', - 'Antizipations-Engine mit 12 Regeln (done)', - 'GET /v1/suggestions Endpoint (done)', - 'Dynamische Sidebar /v1/sidebar (done)', - 'Schuljahres-Pfad /v1/path (done)', - 'Frontend ContextBar Component (done)', - 'Frontend Dynamic Sidebar (done)', - 'Frontend PathPanel Component (done)', - 'Main Content Actions Integration (done)', - 'Onboarding-Flow (geplant)', - ], - }, - { - id: 'phase-9', - name: 'Phase 9: Zukunft', - status: 'future', - progress: 0, - description: 'Weitere geplante Features', - features: [ - 'Push Notifications', - 'Dark Mode', - 'Lesson Templates Library (erweitert)', - 'Multi-Language Support', - 'KI-Assistenz fuer Unterrichtsplanung', - ], - }, -] - -const initialFeatures: Feature[] = [ - // Phase 1 - Done - { id: 'f1', title: 'LessonPhase Enum', description: '7 Zustaende: not_started, 5 Phasen, ended', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'small' }, - { id: 'f2', title: 'LessonSession Dataclass', description: 'Session-Datenmodell mit History', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'medium' }, - { id: 'f3', title: 'FSM Transitions', description: 'Erlaubte Phasen-Uebergaenge', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'medium' }, - { id: 'f4', title: 'PhaseTimer Service', description: 'Countdown, Warning, Overtime', priority: 'high', status: 'done', phase: 'phase-1', effort: 'medium' }, - { id: 'f5', title: 'SuggestionEngine', description: 'Phasenspezifische Aktivitaets-Vorschlaege', priority: 'high', status: 'done', phase: 'phase-1', effort: 'large' }, - { id: 'f6', title: 'REST API Endpoints', description: '10 Endpoints unter /api/classroom', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'large' }, - - // Phase 2 - Done - { id: 'f7', title: 'Mode Toggle (3 Modi)', description: 'Begleiter, Stunde, Klassisch', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' }, - { id: 'f8', title: 'Timer-Display', description: 'Grosser Countdown mit Styling', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' }, - { id: 'f9', title: 'Phasen-Timeline', description: 'Horizontale 5-Phasen-Anzeige', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' }, - { id: 'f10', title: 'Control Buttons', description: 'Naechste Phase, Beenden', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' }, - { id: 'f11', title: 'Suggestions Cards', description: 'Aktivitaets-Vorschlaege UI', priority: 'medium', status: 'done', phase: 'phase-2', effort: 'medium' }, - { id: 'f12', title: 'Session Start Form', description: 'Klasse, Fach, Thema auswaehlen', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' }, - - // Phase 3 - In Progress (Persistenz) - { id: 'f13', title: 'PostgreSQL Models', description: 'SQLAlchemy Models fuer Sessions (LessonSessionDB, PhaseHistoryDB, TeacherSettingsDB)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'medium' }, - { id: 'f14', title: 'Session Repository', description: 'CRUD Operationen fuer Sessions (SessionRepository, TeacherSettingsRepository)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'medium' }, - { id: 'f15', title: 'Migration Scripts', description: 'Alembic Migrationen fuer Classroom Tables', priority: 'high', status: 'done', phase: 'phase-3', effort: 'small' }, - { id: 'f16', title: 'Teacher Settings', description: 'Individuelle Phasen-Dauern speichern (API + Settings Modal UI)', priority: 'medium', status: 'done', phase: 'phase-7', effort: 'medium' }, - { id: 'f17', title: 'Session History API', description: 'GET /history/{teacher_id} mit Pagination', priority: 'medium', status: 'done', phase: 'phase-3', effort: 'small' }, - - // Phase 4 - In Progress (Content) - { id: 'f18', title: 'Unit-Vorschlaege', description: 'Fachspezifische Learning Units pro Phase (Mathe, Deutsch, Englisch, Bio, Physik, Informatik)', priority: 'high', status: 'done', phase: 'phase-4', effort: 'large' }, - { id: 'f19', title: 'Material-Verknuepfung', description: 'Dokumente an Phasen anhaengen (PhaseMaterial Model, Repository, 8 API-Endpoints, Frontend-Integration)', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' }, - { id: 'f20', title: 'Hausaufgaben-Tracker', description: 'CRUD API fuer Hausaufgaben mit Status und Faelligkeit', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' }, - - // ==================== NEUE UX FEATURES (aus Research) ==================== - - // P0 - KRITISCH (UX Research basiert) - { id: 'f21', title: 'Visual Pie Timer', description: 'Kreisfoermiger Countdown mit Farbverlauf (Gruen→Gelb→Rot) - reduziert Stress laut Forschung', priority: 'critical', status: 'done', phase: 'phase-2', effort: 'large' }, - { id: 'f22', title: 'Database Persistence', description: 'PostgreSQL statt In-Memory - Sessions ueberleben Neustart (Hybrid Storage)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'large' }, - { id: 'f23', title: 'Teacher Auth Integration', description: 'Keycloak-Anbindung mit optionalem Demo-User Fallback', priority: 'critical', status: 'done', phase: 'phase-7', effort: 'large' }, - { id: 'f24', title: 'Tablet-First Responsive', description: 'Optimiert fuer 10" Touch-Screens, Einhand-Bedienung im Klassenraum', priority: 'critical', status: 'done', phase: 'phase-2b', effort: 'medium' }, - - // P1 - WICHTIG (UX Research basiert) - { id: 'f25', title: 'Phasen-Farbschema', description: 'Forschungsbasierte Farben: Blau(Einstieg), Orange(Erarbeitung), Gruen(Sicherung), Lila(Transfer), Grau(Reflexion)', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' }, - { id: 'f26', title: 'Quick Actions Bar', description: '+5min, Pause, Skip-Phase als One-Click Touch-Buttons (min 56px)', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' }, - { id: 'f27', title: 'Pause Timer API', description: 'POST /sessions/{id}/pause - Timer anhalten bei Stoerungen', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' }, - { id: 'f28', title: 'Extend Phase API', description: 'POST /sessions/{id}/extend?minutes=5 - Phase verlaengern', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' }, - { id: 'f29', title: 'Non-Intrusive Suggestions', description: 'Vorschlaege in dedizierter Sektion, nicht als stoerende Popups', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' }, - { id: 'f30', title: 'WebSocket Real-Time Timer', description: 'Sub-Sekunden Genauigkeit statt 5s Polling', priority: 'high', status: 'done', phase: 'phase-6', effort: 'large' }, - { id: 'f31', title: 'Mobile Breakpoints', description: 'Responsive Design fuer 600px, 900px, 1200px Screens', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' }, - { id: 'f32', title: 'Large Touch Targets', description: 'Alle Buttons min 48x48px fuer sichere Touch-Bedienung', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' }, - - // P2 - NICE-TO-HAVE (UX Research basiert) - { id: 'f33', title: 'Audio Cues', description: 'Sanfte Toene bei Phasenwechsel und Warnungen (Taste A zum Toggle)', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' }, - { id: 'f34', title: 'Keyboard Shortcuts', description: 'Space=Pause, N=Next Phase, E=Extend, H=High Contrast - fuer Desktop-Nutzung', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' }, - { id: 'f35', title: 'Offline Timer Fallback', description: 'Client-seitige Timer-Berechnung bei Verbindungsverlust', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'medium' }, - { id: 'f36', title: 'Post-Lesson Analytics', description: 'Phasen-Dauer Statistiken ohne wertende Metriken (SessionSummary, TeacherAnalytics, 4 API-Endpoints)', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'large' }, - { id: 'f37', title: 'Lesson Templates', description: '5 System-Templates + eigene Vorlagen erstellen/speichern', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' }, - { id: 'f38', title: 'ARIA Labels', description: 'Screen-Reader Unterstuetzung fuer Barrierefreiheit', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' }, - { id: 'f39', title: 'High Contrast Mode', description: 'Erhoehter Kontrast fuer Beamer/Projektor (Taste H)', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' }, - { id: 'f40', title: 'Export to PDF', description: 'Stundenprotokoll als druckbares HTML mit Browser-PDF-Export (Strg+P)', priority: 'low', status: 'done', phase: 'phase-5', effort: 'medium' }, - { id: 'f41', title: 'Overtime-Analyse', description: 'Phase-by-Phase Overtime-Statistiken und Trends', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'medium' }, - { id: 'f42', title: 'Post-Lesson Reflection', description: 'Reflexions-Notizen nach Stundenende (CRUD API, DB-Model)', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'medium' }, - { id: 'f43', title: 'Phase Duration Trends', description: 'Visualisierung der Phasendauer-Entwicklung', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'small' }, - { id: 'f44', title: 'Analytics Dashboard UI', description: 'Lehrer-Frontend fuer Analytics-Anzeige (Phasen-Bars, Overtime, Reflection)', priority: 'high', status: 'done', phase: 'phase-5', effort: 'medium' }, - - // Phase 6 - Real-time (WebSocket) - { id: 'f45', title: 'WebSocket API Endpoint', description: 'Real-time Verbindung unter /api/classroom/ws/{session_id}', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'large' }, - { id: 'f46', title: 'Connection Manager', description: 'Multi-Device Support mit Session-basierter Verbindungsverwaltung', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'medium' }, - { id: 'f47', title: 'Timer Broadcast Loop', description: 'Hintergrund-Task sendet Timer-Updates jede Sekunde an alle Clients', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'medium' }, - { id: 'f48', title: 'Client WebSocket Handler', description: 'Frontend-Integration mit automatischem Reconnect und Fallback zu Polling', priority: 'high', status: 'done', phase: 'phase-6', effort: 'large' }, - { id: 'f49', title: 'Phase Change Notifications', description: 'Echtzeit-Benachrichtigung bei Phasenwechsel an alle Devices', priority: 'high', status: 'done', phase: 'phase-6', effort: 'small' }, - { id: 'f50', title: 'Session End Notifications', description: 'Automatische Benachrichtigung bei Stundenende', priority: 'high', status: 'done', phase: 'phase-6', effort: 'small' }, - { id: 'f51', title: 'Connection Status Indicator', description: 'UI-Element zeigt Live/Polling/Offline Status', priority: 'medium', status: 'done', phase: 'phase-6', effort: 'small' }, - { id: 'f52', title: 'WebSocket Status API', description: 'GET /ws/status zeigt aktive Sessions und Verbindungszahlen', priority: 'low', status: 'done', phase: 'phase-6', effort: 'small' }, - - // Phase 7 - Erweiterungen (Auth & Feedback) - { id: 'f53', title: 'Teacher Feedback API', description: 'POST/GET /feedback Endpoints fuer Bug-Reports und Feature-Requests', priority: 'high', status: 'done', phase: 'phase-7', effort: 'large' }, - { id: 'f54', title: 'Feedback Modal UI', description: 'Floating Action Button und Modal im Lehrer-Frontend', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' }, - { id: 'f55', title: 'Feedback DB Model', description: 'TeacherFeedbackDB SQLAlchemy Model mit Alembic Migration', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' }, - { id: 'f56', title: 'Feedback Repository', description: 'CRUD-Operationen fuer Feedback mit Status-Management', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' }, - { id: 'f57', title: 'Keycloak Auth Integration', description: 'Optional Auth Dependency mit Demo-User Fallback', priority: 'critical', status: 'done', phase: 'phase-7', effort: 'medium' }, - { id: 'f58', title: 'Feedback Stats API', description: 'GET /feedback/stats fuer Dashboard-Statistiken', priority: 'medium', status: 'done', phase: 'phase-7', effort: 'small' }, - - // Phase 8 - Schuljahres-Begleiter (2-Schichten-Modell) - { id: 'f59', title: 'TeacherContextDB Model', description: 'Makro-Kontext pro Lehrer (Bundesland, Schulart, Schuljahr, Phase)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' }, - { id: 'f60', title: 'SchoolyearEventDB Model', description: 'Events (Klausuren, Elternabende, Klassenfahrten, etc.)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' }, - { id: 'f61', title: 'RecurringRoutineDB Model', description: 'Wiederkehrende Routinen (Konferenzen, Sprechstunden, etc.)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' }, - { id: 'f62', title: 'Alembic Migration 007', description: 'DB-Migration fuer teacher_contexts, schoolyear_events, recurring_routines', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'small' }, - { id: 'f63', title: 'GET /v1/context Endpoint', description: 'Makro-Kontext abrufen (Schuljahr, Woche, Phase, Flags)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'large' }, - { id: 'f64', title: 'PUT /v1/context Endpoint', description: 'Kontext aktualisieren (Bundesland, Schulart, Schuljahr)', priority: 'high', status: 'done', phase: 'phase-8', effort: 'medium' }, - { id: 'f65', title: 'Events CRUD-API', description: 'GET/POST/DELETE /v1/events mit Status und Vorbereitung', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' }, - { id: 'f66', title: 'Routines CRUD-API', description: 'GET/POST/DELETE /v1/routines mit Wiederholungsmustern', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' }, - { id: 'f67', title: 'Stammdaten-APIs', description: '/v1/federal-states, /v1/school-types, /v1/macro-phases, /v1/event-types', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'small' }, - { id: 'f68', title: 'Context Repositories', description: 'TeacherContextRepository, SchoolyearEventRepository, RecurringRoutineRepository', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' }, - { id: 'f69', title: 'Antizipations-Engine', description: 'Signal-Collector + Regel-Engine (12 Regeln) fuer proaktive Vorschlaege', priority: 'high', status: 'done', phase: 'phase-8', effort: 'epic' }, - { id: 'f70', title: 'GET /v1/suggestions Endpoint', description: 'Kontextbasierte Vorschlaege mit active_contexts[]', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' }, - { id: 'f71', title: 'GET /v1/sidebar Endpoint', description: 'Dynamisches Sidebar-Model (Companion vs Classic Mode)', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' }, - { id: 'f72', title: 'GET /v1/path Endpoint', description: 'Schuljahres-Meilensteine mit Status (DONE, CURRENT, UPCOMING)', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' }, - { id: 'f73', title: 'ContextBar Component', description: 'Schuljahr, Woche, Bundesland Anzeige im Header', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' }, - { id: 'f74', title: 'Begleiter-Sidebar', description: 'Top 5 relevante Module + Alle Module + Suche', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'large' }, - { id: 'f75', title: 'PathPanel Component', description: 'Vertikaler Schuljahres-Pfad mit "Du bist hier" Markierung', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' }, - { id: 'f76', title: 'Onboarding-Flow', description: 'Bundesland, Schulart, Schuljahres-Start, erste Klassen', priority: 'high', status: 'backlog', phase: 'phase-8', effort: 'large' }, - { id: 'f77', title: 'Complete Onboarding API', description: 'POST /v1/context/complete-onboarding zum Abschliessen', priority: 'high', status: 'done', phase: 'phase-8', effort: 'small' }, -] - -const initialFeedback: TeacherFeedback[] = [ - { - id: 'fb1', - teacher: 'Frau Mueller', - date: '2026-01-14', - type: 'feature_request', - priority: 'high', - status: 'implemented', - title: 'Individuelle Phasen-Dauern', - description: 'Ich moechte die Dauern der einzelnen Phasen selbst festlegen koennen, je nach Unterrichtseinheit.', - relatedFeature: 'f16', - response: 'In Phase 7 implementiert: Einstellungen-Button oeffnet Modal zur Konfiguration der Phasendauern.', - }, - { - id: 'fb2', - teacher: 'Herr Schmidt', - date: '2026-01-14', - type: 'improvement', - priority: 'medium', - status: 'implemented', - title: 'Akustisches Signal bei Phasen-Ende', - description: 'Ein kurzer Ton wuerde helfen, das Ende einer Phase nicht zu verpassen.', - relatedFeature: 'f33', - response: 'Audio Cues wurden in Phase 2b implementiert - sanfte Toene statt harter Alarme. Taste A zum Toggle.', - }, - { - id: 'fb3', - teacher: 'Frau Wagner', - date: '2026-01-15', - type: 'praise', - priority: 'low', - status: 'acknowledged', - title: 'Super einfache Bedienung!', - description: 'Die Stunden-Steuerung ist sehr intuitiv. Meine erste Stunde damit hat super geklappt.', - }, - { - id: 'fb4', - teacher: 'Herr Becker', - date: '2026-01-15', - type: 'bug', - priority: 'high', - status: 'implemented', - title: 'Timer stoppt bei Browser-Tab-Wechsel', - description: 'Wenn ich den Browser-Tab wechsle und zurueckkomme, zeigt der Timer manchmal falsche Werte.', - relatedFeature: 'f35', - response: 'Offline Timer Fallback in Phase 2b implementiert + WebSocket Real-time in Phase 6.', - }, - { - id: 'fb5', - teacher: 'Frau Klein', - date: '2026-01-15', - type: 'feature_request', - priority: 'critical', - status: 'implemented', - title: 'Pause-Funktion', - description: 'Manchmal muss ich die Stunde kurz unterbrechen (Stoerung, Durchsage). Eine Pause-Funktion waere super.', - relatedFeature: 'f27', - response: 'Pause Timer API und Quick Actions Bar wurden in Phase 2b implementiert. Tastenkuerzel: Leertaste.', - }, - { - id: 'fb6', - teacher: 'Herr Hoffmann', - date: '2026-01-15', - type: 'feature_request', - priority: 'high', - status: 'implemented', - title: 'Visueller Timer statt Zahlen', - description: 'Der numerische Countdown ist manchmal stressig. Ein visueller Kreis-Timer waere entspannter.', - relatedFeature: 'f21', - response: 'Visual Pie Timer mit Farbverlauf (Gruen→Gelb→Rot) wurde in Phase 2b implementiert.', - }, - { - id: 'fb7', - teacher: 'Frau Richter', - date: '2026-01-15', - type: 'feature_request', - priority: 'high', - status: 'implemented', - title: 'Tablet-Nutzung im Klassenraum', - description: 'Ich laufe waehrend des Unterrichts herum. Die Anzeige muesste auch auf meinem iPad gut funktionieren.', - relatedFeature: 'f24', - response: 'Tablet-First Responsive Design wurde in Phase 2b implementiert. Touch-Targets min 48x48px.', - }, - { - id: 'fb8', - teacher: 'Herr Weber', - date: '2026-01-15', - type: 'improvement', - priority: 'medium', - status: 'implemented', - title: '+5 Minuten Button', - description: 'Manchmal brauche ich einfach nur 5 Minuten mehr fuer eine Phase. Ein Schnell-Button waere praktisch.', - relatedFeature: 'f28', - response: 'In Quick Actions Bar integriert. Tastenkuerzel: E.', - }, - { - id: 'fb9', - teacher: 'Frau Schneider', - date: '2026-01-15', - type: 'praise', - priority: 'low', - status: 'acknowledged', - title: 'Phasen-Vorschlaege sind hilfreich', - description: 'Die Aktivitaets-Vorschlaege pro Phase geben mir gute Ideen. Weiter so!', - }, - { - id: 'fb10', - teacher: 'Herr Meier', - date: '2026-01-15', - type: 'feature_request', - priority: 'medium', - status: 'implemented', - title: 'Stundenvorlage speichern', - description: 'Fuer Mathe-Stunden nutze ich immer die gleiche Phasen-Aufteilung. Waere cool, das als Template zu speichern.', - relatedFeature: 'f37', - response: 'Lesson Templates wurden in Phase 4 implementiert. 5 System-Templates + eigene Vorlagen moeglich.', - }, -] - -// ==================== SYSTEM INFO CONFIG ==================== - -const companionSystemInfo = { - title: 'Companion Module System Info', - description: 'Technische Details zur Classroom State Machine', - version: '1.1.0', - architecture: { - layers: [ - { - title: 'Frontend Layer', - components: [ - 'companion.py (Lesson-Modus UI)', - 'Mode Toggle (Begleiter/Stunde/Klassisch)', - 'Timer Display Component', - 'Phase Timeline Component', - 'Suggestions Cards', - 'Material Design Icons (CDN)', - ], - color: 'bg-blue-50', - }, - { - title: 'API Layer', - components: [ - 'classroom_api.py (FastAPI Router)', - 'POST /sessions - Session erstellen', - 'POST /sessions/{id}/start - Stunde starten', - 'POST /sessions/{id}/next-phase - Naechste Phase', - 'POST /sessions/{id}/pause - Timer pausieren', - 'POST /sessions/{id}/extend - Phase verlaengern', - 'GET /sessions/{id}/timer - Timer Status', - 'GET /sessions/{id}/suggestions - Vorschlaege', - 'GET /history/{teacher_id} - Session History', - 'GET /health - Health Check mit DB-Status', - 'GET/PUT /v1/context - Schuljahres-Kontext', - 'GET/POST/DELETE /v1/events - Events CRUD', - 'GET/POST/DELETE /v1/routines - Routinen CRUD', - 'GET /v1/federal-states, /v1/school-types, etc.', - ], - color: 'bg-green-50', - }, - { - title: 'Engine Layer', - components: [ - 'classroom_engine/ Package', - 'models.py - LessonPhase, LessonSession', - 'fsm.py - LessonStateMachine', - 'timer.py - PhaseTimer', - 'suggestions.py - SuggestionEngine', - 'context_models.py - TeacherContextDB, SchoolyearEventDB, RecurringRoutineDB', - 'antizipation.py - AntizipationsEngine (geplant)', - ], - color: 'bg-amber-50', - }, - { - title: 'Storage Layer', - components: [ - 'Hybrid Storage (Memory + PostgreSQL)', - 'SessionRepository (CRUD)', - 'TeacherSettingsRepository', - 'TeacherContextRepository (Phase 8)', - 'SchoolyearEventRepository (Phase 8)', - 'RecurringRoutineRepository (Phase 8)', - 'Alembic Migrations (007: Phase 8 Tables)', - 'Session History API', - ], - color: 'bg-purple-50', - }, - ], - }, - features: [ - { name: '5-Phasen-Modell', status: 'active' as const, description: 'Einstieg, Erarbeitung, Sicherung, Transfer, Reflexion' }, - { name: 'Timer mit Warning', status: 'active' as const, description: '2 Minuten Warnung vor Phasen-Ende' }, - { name: 'Overtime Detection', status: 'active' as const, description: 'Anzeige wenn Phase ueberzogen wird' }, - { name: 'Phasen-Suggestions', status: 'active' as const, description: '3-6 Aktivitaets-Vorschlaege pro Phase' }, - { name: 'Visual Pie Timer', status: 'active' as const, description: 'Kreisfoermiger Countdown mit Farbverlauf' }, - { name: 'Quick Actions Bar', status: 'active' as const, description: '+5min, Pause, Skip Buttons' }, - { name: 'Tablet-First Design', status: 'active' as const, description: 'Touch-optimiert fuer Tablets' }, - { name: 'Phasen-Farbschema', status: 'active' as const, description: 'Blau→Orange→Gruen→Lila→Grau' }, - { name: 'Keyboard Shortcuts', status: 'active' as const, description: 'Space=Pause, N=Next, E=Extend, H=Contrast' }, - { name: 'Audio Cues', status: 'active' as const, description: 'Sanfte Toene bei Phasenwechsel' }, - { name: 'Offline Timer', status: 'active' as const, description: 'Client-seitige Fallback bei Verbindungsverlust' }, - { name: 'DB Persistenz', status: 'active' as const, description: 'PostgreSQL Hybrid Storage' }, - { name: 'Session History', status: 'active' as const, description: 'GET /history/{teacher_id} API' }, - { name: 'Alembic Migrations', status: 'active' as const, description: 'Versionierte DB-Schema-Aenderungen' }, - { name: 'Teacher Auth', status: 'active' as const, description: 'Keycloak Integration mit Optional Fallback (Phase 7)' }, - { name: 'WebSocket Real-time', status: 'active' as const, description: 'Echtzeit-Updates mit 1-Sekunden-Genauigkeit (Phase 6)' }, - { name: 'Schuljahres-Kontext', status: 'active' as const, description: 'Makro-Phasen, Bundesland, Schulart (Phase 8)' }, - { name: 'Events & Routinen', status: 'active' as const, description: 'Klausuren, Konferenzen, Elternabende (Phase 8)' }, - { name: 'Antizipations-Engine', status: 'active' as const, description: '12 Regeln fuer proaktive Vorschlaege (Phase 8)' }, - { name: 'Dynamische Sidebar', status: 'active' as const, description: 'Top 5 relevante Module + Alle Module (Phase 8)' }, - { name: 'Schuljahres-Pfad', status: 'active' as const, description: '7 Meilensteine mit Fortschrittsanzeige (Phase 8)' }, - ], - roadmap: [ - { phase: 'Phase 1: Core Engine', priority: 'high' as const, items: ['FSM', 'Timer', 'Suggestions', 'API'] }, - { phase: 'Phase 2: Frontend', priority: 'high' as const, items: ['Lesson-Modus UI', 'Timer Display', 'Timeline'] }, - { phase: 'Phase 2b: UX Optimierung', priority: 'high' as const, items: ['Visual Timer', 'Farbschema', 'Tablet-First', 'Quick Actions'] }, - { phase: 'Phase 3: Persistenz', priority: 'high' as const, items: ['PostgreSQL', 'Keycloak Auth', 'Session History'] }, - { phase: 'Phase 4: Content', priority: 'medium' as const, items: ['Unit-Vorschlaege', 'Templates', 'Hausaufgaben'] }, - { phase: 'Phase 5: Analytics', priority: 'medium' as const, items: ['Statistiken (ohne Bewertung)', 'PDF Export'] }, - { phase: 'Phase 6: Real-time', priority: 'low' as const, items: ['WebSocket', 'Offline Fallback', 'Multi-Device'] }, - ], - technicalDetails: [ - { component: 'Backend', technology: 'Python FastAPI', version: '0.123+', description: 'Async REST API' }, - { component: 'State Machine', technology: 'Python Enum + Dataclass', description: 'Finite State Machine Pattern' }, - { component: 'Timer', technology: 'datetime.utcnow()', description: 'Server-side Time Calculation' }, - { component: 'Frontend', technology: 'Vanilla JavaScript ES6+', description: 'In companion.py eingebettet' }, - { component: 'Icons', technology: 'Material Design Icons', description: 'Via Google Fonts CDN (Apache-2.0)' }, - { component: 'WebSocket', technology: 'FastAPI WebSocket', description: 'Echtzeit-Updates mit 1-Sekunden-Genauigkeit + Polling Fallback' }, - { component: 'Database', technology: 'PostgreSQL + SQLAlchemy 2.0', description: 'Hybrid Storage (Memory + DB)' }, - { component: 'Migrations', technology: 'Alembic 1.14', description: 'Versionierte Schema-Migrationen' }, - ], - privacyNotes: [ - 'Keine Schueler-Daten werden gespeichert', - 'Session-Daten sind nur waehrend der Stunde verfuegbar', - 'Lehrer-ID wird fuer Session-Zuordnung verwendet', - 'Keine Tracking-Cookies oder externe Services', - 'Analytics ohne bewertende Metriken (keine "70% Redezeit"-Anzeigen)', - ], -} - -// ==================== COMPONENT ==================== +const TAB_CONFIG = [ + { id: 'roadmap', label: 'Roadmap', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' }, + { id: 'features', label: 'Features', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' }, + { id: 'feedback', label: 'Lehrer-Feedback', icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z' }, + { id: 'backlog', label: 'Backlog', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, +] as const export default function CompanionDevPage() { - const [features, setFeatures] = useState(initialFeatures) - const [feedback, setFeedback] = useState(initialFeedback) - const [activeTab, setActiveTab] = useState<'roadmap' | 'features' | 'feedback' | 'backlog'>('roadmap') - const [selectedPhase, setSelectedPhase] = useState(null) - const [feedbackFilter, setFeedbackFilter] = useState('all') - - // Data version - increment when adding new features/feedback to force refresh - const DATA_VERSION = '8.2.0' // Phase 8e: Frontend UI-Komponenten (ContextBar, Sidebar, PathPanel) - - // Load from localStorage with version check - useEffect(() => { - const savedVersion = localStorage.getItem('companion-dev-version') - const savedFeatures = localStorage.getItem('companion-dev-features') - const savedFeedback = localStorage.getItem('companion-dev-feedback') - - // If version mismatch or no version, use initial data and save new version - if (savedVersion !== DATA_VERSION) { - console.log(`Companion Dev: Data version updated from ${savedVersion} to ${DATA_VERSION}`) - localStorage.setItem('companion-dev-version', DATA_VERSION) - localStorage.setItem('companion-dev-features', JSON.stringify(initialFeatures)) - localStorage.setItem('companion-dev-feedback', JSON.stringify(initialFeedback)) - // State already initialized with initialFeatures/initialFeedback, no need to setFeatures - return - } - - // Load saved data if version matches - if (savedFeatures) setFeatures(JSON.parse(savedFeatures)) - if (savedFeedback) setFeedback(JSON.parse(savedFeedback)) - }, []) - - // Save to localStorage - useEffect(() => { - localStorage.setItem('companion-dev-features', JSON.stringify(features)) - }, [features]) - - useEffect(() => { - localStorage.setItem('companion-dev-feedback', JSON.stringify(feedback)) - }, [feedback]) - - const getPhaseStats = () => { - const total = roadmapPhases.length - const completed = roadmapPhases.filter(p => p.status === 'completed').length - const inProgress = roadmapPhases.filter(p => p.status === 'in_progress').length - return { total, completed, inProgress } - } - - const getFeatureStats = () => { - const total = features.length - const done = features.filter(f => f.status === 'done').length - const inProgress = features.filter(f => f.status === 'in_progress').length - return { total, done, inProgress, percentage: Math.round((done / total) * 100) } - } - - const getFeedbackStats = () => { - const total = feedback.length - const newCount = feedback.filter(f => f.status === 'new').length - const bugs = feedback.filter(f => f.type === 'bug').length - const requests = feedback.filter(f => f.type === 'feature_request').length - return { total, newCount, bugs, requests } - } - - const updateFeatureStatus = (id: string, status: Feature['status']) => { - setFeatures(features.map(f => f.id === id ? { ...f, status } : f)) - } - - const updateFeedbackStatus = (id: string, status: TeacherFeedback['status']) => { - setFeedback(feedback.map(f => f.id === id ? { ...f, status } : f)) - } - - const phaseStats = getPhaseStats() - const featureStats = getFeatureStats() - const feedbackStats = getFeedbackStats() - - const statusColors = { - done: 'bg-green-100 text-green-800', - in_progress: 'bg-blue-100 text-blue-800', - todo: 'bg-amber-100 text-amber-800', - backlog: 'bg-slate-100 text-slate-600', - } - - const priorityColors = { - critical: 'bg-red-500 text-white', - high: 'bg-orange-500 text-white', - medium: 'bg-yellow-500 text-white', - low: 'bg-slate-400 text-white', - } - - const feedbackTypeIcons: Record = { - bug: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', - feature_request: 'M12 6v6m0 0v6m0-6h6m-6 0H6', - improvement: 'M13 10V3L4 14h7v7l9-11h-7z', - praise: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z', - question: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', - } - - const filteredFeedback = feedbackFilter === 'all' - ? feedback - : feedback.filter(f => f.type === feedbackFilter || f.status === feedbackFilter) + const { + features, + activeTab, + setActiveTab, + selectedPhase, + setSelectedPhase, + feedbackFilter, + setFeedbackFilter, + phaseStats, + featureStats, + feedbackStats, + updateFeatureStatus, + updateFeedbackStatus, + filteredFeedback, + } = useCompanionDev() return ( - {/* Stats Overview */} -
-
-
Roadmap-Phasen
-
{phaseStats.completed}/{phaseStats.total}
-
{phaseStats.inProgress} in Arbeit
-
-
-
Features
-
{featureStats.percentage}%
-
{featureStats.done}/{featureStats.total} fertig
-
-
-
Neues Feedback
-
{feedbackStats.newCount}
-
{feedbackStats.total} gesamt
-
-
-
Offene Bugs
-
{feedbackStats.bugs}
-
{feedbackStats.requests} Feature-Requests
-
-
+ {/* Tab Navigation */}
- {/* Roadmap Tab */} - {activeTab === 'roadmap' && ( -
- {roadmapPhases.map((phase, index) => ( -
-
-
-
-
- {phase.status === 'completed' ? '✓' : index + 1} -
-
-

{phase.name}

-

{phase.description}

-
-
-
- - {phase.status === 'completed' ? 'Abgeschlossen' : - phase.status === 'in_progress' ? 'In Arbeit' : - phase.status === 'planned' ? 'Geplant' : 'Zukunft'} - - {phase.startDate && ( -
- {phase.startDate} {phase.endDate ? `- ${phase.endDate}` : ''} -
- )} -
-
+ {activeTab === 'roadmap' && } - {/* Progress Bar */} -
-
- Fortschritt - {phase.progress}% -
-
-
-
-
- - {/* Features */} -
- {phase.features.map((feature, i) => ( - - {feature} - - ))} -
-
-
- ))} -
- )} - - {/* Features Tab */} {activeTab === 'features' && ( -
- {/* Phase Filter */} -
- - {roadmapPhases.map(phase => ( - - ))} -
- - {/* Features List */} -
- {features - .filter(f => !selectedPhase || f.phase === selectedPhase) - .map(feature => ( -
- - {feature.priority} - -
-
{feature.title}
-
{feature.description}
-
- - {feature.effort} - - -
- ))} -
-
+ )} - {/* Feedback Tab */} {activeTab === 'feedback' && ( -
- {/* Filter */} -
- {['all', 'new', 'bug', 'feature_request', 'improvement'].map(filter => ( - - ))} -
- - {/* Feedback List */} -
- {filteredFeedback.map(fb => ( -
-
-
- - - -
-
-
- {fb.title} - - {fb.priority} - -
-

{fb.description}

-
- {fb.teacher} - {fb.date} - {fb.relatedFeature && ( - → {features.find(f => f.id === fb.relatedFeature)?.title} - )} -
- {fb.response && ( -
- Antwort: {fb.response} -
- )} -
- -
-
- ))} -
- - {/* Add Feedback Button */} - -
+ )} - {/* Backlog Tab */} - {activeTab === 'backlog' && ( -
-
- {/* Todo Column */} -
-

- - {features.filter(f => f.status === 'todo').length} - - Todo -

-
- {features.filter(f => f.status === 'todo').map(f => ( -
-
{f.title}
-
{f.description}
-
- - {f.priority} - - - {f.effort} - -
-
- ))} -
-
- - {/* In Progress Column */} -
-

- - {features.filter(f => f.status === 'in_progress').length} - - In Arbeit -

-
- {features.filter(f => f.status === 'in_progress').map(f => ( -
-
{f.title}
-
{f.description}
-
- - {f.priority} - -
-
- ))} -
-
- - {/* Backlog Column */} -
-

- - {features.filter(f => f.status === 'backlog').length} - - Backlog -

-
- {features.filter(f => f.status === 'backlog').map(f => ( -
-
{f.title}
-
{f.description}
-
- - {f.priority} - -
-
- ))} -
-
-
-
- )} + {activeTab === 'backlog' && }
diff --git a/website/app/admin/klausur-korrektur/[klausurId]/[studentId]/page.tsx b/website/app/admin/klausur-korrektur/[klausurId]/[studentId]/page.tsx index 0b29085..0a85a33 100644 --- a/website/app/admin/klausur-korrektur/[klausurId]/[studentId]/page.tsx +++ b/website/app/admin/klausur-korrektur/[klausurId]/[studentId]/page.tsx @@ -1,107 +1,25 @@ 'use client' /** - * Korrektur-Workspace + * Korrektur-Workspace (Admin Route) * * Main correction interface with 2/3 - 1/3 layout: * - Left (2/3): Document viewer with annotation overlay * - Right (1/3): Criteria scoring, Gutachten editor, Annotations */ -import { useState, useEffect, useCallback, useMemo } from 'react' import { useParams, useRouter } from 'next/navigation' import AdminLayout from '@/components/admin/AdminLayout' -import Link from 'next/link' +import { useKorrekturWorkspace } from '@/components/klausur-korrektur/useKorrekturWorkspace' +import WorkspaceTopBar from '@/components/klausur-korrektur/WorkspaceTopBar' +import DocumentViewer from '@/components/klausur-korrektur/DocumentViewer' +import CorrectionPanel from '@/components/klausur-korrektur/CorrectionPanel' +import EinigungModal from '@/components/klausur-korrektur/EinigungModal' +import ErrorBanner from '@/components/klausur-korrektur/ErrorBanner' import AnnotationLayer from '../../components/AnnotationLayer' import AnnotationToolbar from '../../components/AnnotationToolbar' import AnnotationPanel from '../../components/AnnotationPanel' import EHSuggestionPanel from '../../components/EHSuggestionPanel' -import type { - Klausur, - StudentWork, - Annotation, - CriteriaScores, - GradeInfo, - AnnotationType, - AnnotationPosition, -} from '../../types' -import { ANNOTATION_COLORS } from '../../types' - -// Examiner workflow types -interface ExaminerInfo { - id: string - assigned_at: string - notes?: string -} - -interface ExaminerResult { - grade_points: number - criteria_scores?: CriteriaScores - notes?: string - submitted_at: string -} - -interface ExaminerWorkflow { - student_id: string - workflow_status: string - visibility_mode: string - user_role: 'ek' | 'zk' | 'dk' | 'viewer' - first_examiner?: ExaminerInfo - second_examiner?: ExaminerInfo - third_examiner?: ExaminerInfo - first_result?: ExaminerResult - first_result_visible?: boolean - second_result?: ExaminerResult - third_result?: ExaminerResult - grade_difference?: number - final_grade?: number - consensus_reached?: boolean - consensus_type?: string - einigung?: { - final_grade: number - notes: string - type: string - submitted_by: string - submitted_at: string - ek_grade: number - zk_grade: number - } - drittkorrektur_reason?: string -} - -// Workflow status labels -const WORKFLOW_STATUS_LABELS: Record = { - not_started: { label: 'Nicht gestartet', color: 'bg-slate-100 text-slate-700' }, - ek_in_progress: { label: 'EK in Arbeit', color: 'bg-blue-100 text-blue-700' }, - ek_completed: { label: 'EK abgeschlossen', color: 'bg-blue-200 text-blue-800' }, - zk_assigned: { label: 'ZK zugewiesen', color: 'bg-amber-100 text-amber-700' }, - zk_in_progress: { label: 'ZK in Arbeit', color: 'bg-amber-200 text-amber-800' }, - zk_completed: { label: 'ZK abgeschlossen', color: 'bg-amber-300 text-amber-900' }, - einigung_required: { label: 'Einigung erforderlich', color: 'bg-orange-100 text-orange-700' }, - einigung_completed: { label: 'Einigung abgeschlossen', color: 'bg-green-100 text-green-700' }, - drittkorrektur_required: { label: 'DK erforderlich', color: 'bg-red-100 text-red-700' }, - drittkorrektur_assigned: { label: 'DK zugewiesen', color: 'bg-red-200 text-red-800' }, - drittkorrektur_in_progress: { label: 'DK in Arbeit', color: 'bg-red-300 text-red-900' }, - completed: { label: 'Abgeschlossen', color: 'bg-green-200 text-green-800' }, -} - -const ROLE_LABELS: Record = { - ek: { label: 'Erstkorrektor', color: 'bg-blue-500' }, - zk: { label: 'Zweitkorrektor', color: 'bg-amber-500' }, - dk: { label: 'Drittkorrektor', color: 'bg-purple-500' }, - viewer: { label: 'Betrachter', color: 'bg-slate-500' }, -} - -const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086' - -// Grade thresholds and labels -const GRADE_LABELS: Record = { - 15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-', - 9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-', - 3: '5+', 2: '5', 1: '5-', 0: '6' -} - -type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege' export default function KorrekturWorkspacePage() { const params = useParams() @@ -109,563 +27,18 @@ export default function KorrekturWorkspacePage() { const klausurId = params.klausurId as string const studentId = params.studentId as string - // State - const [klausur, setKlausur] = useState(null) - const [student, setStudent] = useState(null) - const [students, setStudents] = useState([]) - const [annotations, setAnnotations] = useState([]) - const [gradeInfo, setGradeInfo] = useState(null) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(null) - const [activeTab, setActiveTab] = useState('kriterien') - const [currentPage, setCurrentPage] = useState(1) - const [totalPages, setTotalPages] = useState(1) - const [zoom, setZoom] = useState(100) - const [documentUrl, setDocumentUrl] = useState(null) - const [generatingGutachten, setGeneratingGutachten] = useState(false) - const [exporting, setExporting] = useState(false) + const ws = useKorrekturWorkspace({ klausurId, studentId }) - // Annotation state - const [selectedTool, setSelectedTool] = useState(null) - const [selectedAnnotation, setSelectedAnnotation] = useState(null) - - // Form state - const [criteriaScores, setCriteriaScores] = useState({}) - const [gutachten, setGutachten] = useState('') - - // Examiner workflow state - const [workflow, setWorkflow] = useState(null) - const [showEinigungModal, setShowEinigungModal] = useState(false) - const [einigungGrade, setEinigungGrade] = useState(0) - const [einigungNotes, setEinigungNotes] = useState('') - const [submittingWorkflow, setSubmittingWorkflow] = useState(false) - - // Current student index - const currentIndex = students.findIndex(s => s.id === studentId) - - // Annotation counts by type - const annotationCounts = useMemo(() => { - const counts: Record = { - rechtschreibung: 0, - grammatik: 0, - inhalt: 0, - struktur: 0, - stil: 0, - comment: 0, - highlight: 0, - } - annotations.forEach((ann) => { - counts[ann.type] = (counts[ann.type] || 0) + 1 - }) - return counts - }, [annotations]) - - // Fetch all data - const fetchData = useCallback(async () => { - try { - setLoading(true) - - // Fetch klausur - const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`) - if (klausurRes.ok) { - setKlausur(await klausurRes.json()) - } - - // Fetch students list - const studentsRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`) - if (studentsRes.ok) { - const data = await studentsRes.json() - setStudents(Array.isArray(data) ? data : data.students || []) - } - - // Fetch current student - const studentRes = await fetch(`${API_BASE}/api/v1/students/${studentId}`) - if (studentRes.ok) { - const studentData = await studentRes.json() - setStudent(studentData) - setCriteriaScores(studentData.criteria_scores || {}) - setGutachten(studentData.gutachten || '') - } - - // Fetch grade info - const gradeInfoRes = await fetch(`${API_BASE}/api/v1/grade-info`) - if (gradeInfoRes.ok) { - setGradeInfo(await gradeInfoRes.json()) - } - - // Fetch examiner workflow status - const workflowRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner-workflow`) - if (workflowRes.ok) { - const workflowData = await workflowRes.json() - setWorkflow(workflowData) - - // If Einigung is required, pre-populate the grade field - if (workflowData.workflow_status === 'einigung_required' && workflowData.first_result && workflowData.second_result) { - const avgGrade = Math.round((workflowData.first_result.grade_points + workflowData.second_result.grade_points) / 2) - setEinigungGrade(avgGrade) - } - } - - // Fetch annotations (use filtered endpoint if we have a workflow) - const annotationsEndpoint = workflow?.user_role === 'zk' - ? `${API_BASE}/api/v1/students/${studentId}/annotations-filtered` - : `${API_BASE}/api/v1/students/${studentId}/annotations` - - const annotationsRes = await fetch(annotationsEndpoint) - if (annotationsRes.ok) { - const annotationsData = await annotationsRes.json() - setAnnotations(Array.isArray(annotationsData) ? annotationsData : annotationsData.annotations || []) - } - - // Build document URL - setDocumentUrl(`${API_BASE}/api/v1/students/${studentId}/file`) - - setError(null) - } catch (err) { - console.error('Failed to fetch data:', err) - setError('Fehler beim Laden der Daten') - } finally { - setLoading(false) - } - }, [klausurId, studentId]) - - // Create annotation - const createAnnotation = useCallback(async (position: AnnotationPosition, type: AnnotationType) => { - try { - const newAnnotation = { - page: currentPage, - position, - type, - text: '', - severity: type === 'rechtschreibung' || type === 'grammatik' ? 'minor' : 'major', - role: 'first_examiner', - linked_criterion: ['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].includes(type) - ? type - : undefined, - } - - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/annotations`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newAnnotation), - }) - - if (res.ok) { - const created = await res.json() - setAnnotations((prev) => [...prev, created]) - setSelectedAnnotation(created) - setActiveTab('annotationen') // Switch to annotations tab to edit - } else { - const errorData = await res.json() - setError(errorData.detail || 'Fehler beim Erstellen der Annotation') - } - } catch (err) { - console.error('Failed to create annotation:', err) - setError('Fehler beim Erstellen der Annotation') - } - }, [studentId, currentPage]) - - // Update annotation - const updateAnnotation = useCallback(async (id: string, updates: Partial) => { - try { - const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }) - - if (res.ok) { - const updated = await res.json() - setAnnotations((prev) => prev.map((ann) => (ann.id === id ? updated : ann))) - if (selectedAnnotation?.id === id) { - setSelectedAnnotation(updated) - } - } else { - const errorData = await res.json() - setError(errorData.detail || 'Fehler beim Aktualisieren der Annotation') - } - } catch (err) { - console.error('Failed to update annotation:', err) - setError('Fehler beim Aktualisieren der Annotation') - } - }, [selectedAnnotation?.id]) - - // Delete annotation - const deleteAnnotation = useCallback(async (id: string) => { - try { - const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, { - method: 'DELETE', - }) - - if (res.ok) { - setAnnotations((prev) => prev.filter((ann) => ann.id !== id)) - if (selectedAnnotation?.id === id) { - setSelectedAnnotation(null) - } - } else { - const errorData = await res.json() - setError(errorData.detail || 'Fehler beim Loeschen der Annotation') - } - } catch (err) { - console.error('Failed to delete annotation:', err) - setError('Fehler beim Loeschen der Annotation') - } - }, [selectedAnnotation?.id]) - - useEffect(() => { - fetchData() - }, [fetchData]) - - // Save criteria scores - const saveCriteriaScores = useCallback(async (newScores: CriteriaScores) => { - try { - setSaving(true) - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/criteria`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ criteria_scores: newScores }), - }) - - if (res.ok) { - const updated = await res.json() - setStudent(updated) - } else { - setError('Fehler beim Speichern') - } - } catch (err) { - console.error('Failed to save criteria:', err) - setError('Fehler beim Speichern') - } finally { - setSaving(false) - } - }, [studentId]) - - // Save gutachten - const saveGutachten = useCallback(async () => { - try { - setSaving(true) - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gutachten }), - }) - - if (res.ok) { - const updated = await res.json() - setStudent(updated) - } else { - setError('Fehler beim Speichern') - } - } catch (err) { - console.error('Failed to save gutachten:', err) - setError('Fehler beim Speichern') - } finally { - setSaving(false) - } - }, [studentId, gutachten]) - - // Generate gutachten - const generateGutachten = useCallback(async () => { - try { - setGeneratingGutachten(true) - setError(null) - - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten/generate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ criteria_scores: criteriaScores }), - }) - - if (res.ok) { - const data = await res.json() - const generatedText = [ - data.einleitung || '', - '', - data.hauptteil || '', - '', - data.fazit || '', - ].filter(Boolean).join('\n\n') - - setGutachten(generatedText) - setActiveTab('gutachten') - } else { - const errorData = await res.json() - setError(errorData.detail || 'Fehler bei der Gutachten-Generierung') - } - } catch (err) { - console.error('Failed to generate gutachten:', err) - setError('Fehler bei der Gutachten-Generierung') - } finally { - setGeneratingGutachten(false) - } - }, [studentId, criteriaScores]) - - // Export PDF functions - const exportGutachtenPDF = useCallback(async () => { - try { - setExporting(true) - setError(null) - - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/export/gutachten`) - - if (res.ok) { - const blob = await res.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `Gutachten_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - window.URL.revokeObjectURL(url) - } else { - setError('Fehler beim PDF-Export') - } - } catch (err) { - console.error('Failed to export PDF:', err) - setError('Fehler beim PDF-Export') - } finally { - setExporting(false) - } - }, [studentId, student?.anonym_id]) - - const exportAnnotationsPDF = useCallback(async () => { - try { - setExporting(true) - setError(null) - - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/export/annotations`) - - if (res.ok) { - const blob = await res.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `Anmerkungen_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - window.URL.revokeObjectURL(url) - } else { - setError('Fehler beim PDF-Export') - } - } catch (err) { - console.error('Failed to export annotations PDF:', err) - setError('Fehler beim PDF-Export') - } finally { - setExporting(false) - } - }, [studentId, student?.anonym_id]) - - // Handle criteria change - const handleCriteriaChange = (criterion: string, value: number) => { - const newScores = { ...criteriaScores, [criterion]: value } - setCriteriaScores(newScores) - // Auto-save after short delay - saveCriteriaScores(newScores) - } - - // Calculate total points - moved before workflow functions to avoid "used before declaration" error - const calculateTotalPoints = useCallback(() => { - if (!gradeInfo?.criteria) return { raw: 0, weighted: 0, gradePoints: 0 } - - let totalWeighted = 0 - let totalWeight = 0 - - Object.entries(gradeInfo.criteria).forEach(([key, criterion]) => { - const score = criteriaScores[key] || 0 - totalWeighted += score * (criterion.weight / 100) - totalWeight += criterion.weight - }) - - const percentage = totalWeight > 0 ? (totalWeighted / totalWeight) * 100 : 0 - - // Calculate grade points from percentage - let gradePoints = 0 - const thresholds = [ - { points: 15, min: 95 }, { points: 14, min: 90 }, { points: 13, min: 85 }, - { points: 12, min: 80 }, { points: 11, min: 75 }, { points: 10, min: 70 }, - { points: 9, min: 65 }, { points: 8, min: 60 }, { points: 7, min: 55 }, - { points: 6, min: 50 }, { points: 5, min: 45 }, { points: 4, min: 40 }, - { points: 3, min: 33 }, { points: 2, min: 27 }, { points: 1, min: 20 }, - ] - - for (const t of thresholds) { - if (percentage >= t.min) { - gradePoints = t.points - break - } - } - - return { - raw: Math.round(totalWeighted), - weighted: Math.round(percentage), - gradePoints, - } - }, [gradeInfo, criteriaScores]) - - const totals = calculateTotalPoints() - - // Submit Erstkorrektur (EK completes their work) - const submitErstkorrektur = useCallback(async () => { - try { - setSubmittingWorkflow(true) - - // First assign as EK if not already assigned - const assignRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - examiner_id: 'current-user', // In production, get from auth context - examiner_role: 'first_examiner', - }), - }) - - if (!assignRes.ok && assignRes.status !== 400) { - // 400 might mean already assigned, which is fine - const error = await assignRes.json() - throw new Error(error.detail || 'Fehler bei der Zuweisung') - } - - // Submit result - const submitRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner/result`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grade_points: totals.gradePoints, - notes: gutachten, - }), - }) - - if (submitRes.ok) { - // Refresh workflow status - fetchData() - } else { - const error = await submitRes.json() - setError(error.detail || 'Fehler beim Abschliessen der Erstkorrektur') - } - } catch (err) { - console.error('Failed to submit Erstkorrektur:', err) - setError('Fehler beim Abschliessen der Erstkorrektur') - } finally { - setSubmittingWorkflow(false) - } - }, [studentId, totals.gradePoints, gutachten, fetchData]) - - // Start Zweitkorrektur - const startZweitkorrektur = useCallback(async (zweitkorrektorId: string) => { - try { - setSubmittingWorkflow(true) - - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/start-zweitkorrektur`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - zweitkorrektor_id: zweitkorrektorId, - }), - }) - - if (res.ok) { - fetchData() - } else { - const error = await res.json() - setError(error.detail || 'Fehler beim Starten der Zweitkorrektur') - } - } catch (err) { - console.error('Failed to start Zweitkorrektur:', err) - setError('Fehler beim Starten der Zweitkorrektur') - } finally { - setSubmittingWorkflow(false) - } - }, [studentId, fetchData]) - - // Submit Zweitkorrektur - const submitZweitkorrektur = useCallback(async () => { - try { - setSubmittingWorkflow(true) - - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/submit-zweitkorrektur`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grade_points: totals.gradePoints, - criteria_scores: criteriaScores, - gutachten: gutachten ? { text: gutachten } : null, - notes: '', - }), - }) - - if (res.ok) { - const result = await res.json() - // Show result message - if (result.workflow_status === 'completed') { - alert(`Auto-Konsens erreicht! Endnote: ${result.final_grade} Punkte`) - } else if (result.workflow_status === 'einigung_required') { - setShowEinigungModal(true) - } else if (result.workflow_status === 'drittkorrektur_required') { - alert(`Drittkorrektur erforderlich: Differenz ${result.grade_difference} Punkte`) - } - fetchData() - } else { - const error = await res.json() - setError(error.detail || 'Fehler beim Abschliessen der Zweitkorrektur') - } - } catch (err) { - console.error('Failed to submit Zweitkorrektur:', err) - setError('Fehler beim Abschliessen der Zweitkorrektur') - } finally { - setSubmittingWorkflow(false) - } - }, [studentId, totals.gradePoints, criteriaScores, gutachten, fetchData]) - - // Submit Einigung - const submitEinigung = useCallback(async (type: 'agreed' | 'split' | 'escalated') => { - try { - setSubmittingWorkflow(true) - - const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/einigung`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - final_grade: einigungGrade, - einigung_notes: einigungNotes, - einigung_type: type, - }), - }) - - if (res.ok) { - const result = await res.json() - setShowEinigungModal(false) - if (result.workflow_status === 'drittkorrektur_required') { - alert('Eskaliert zu Drittkorrektur') - } else { - alert(`Einigung abgeschlossen: Endnote ${result.final_grade} Punkte`) - } - fetchData() - } else { - const error = await res.json() - setError(error.detail || 'Fehler bei der Einigung') - } - } catch (err) { - console.error('Failed to submit Einigung:', err) - setError('Fehler bei der Einigung') - } finally { - setSubmittingWorkflow(false) - } - }, [studentId, einigungGrade, einigungNotes, fetchData]) - - // Navigation const goToStudent = (direction: 'prev' | 'next') => { - const newIndex = direction === 'prev' ? currentIndex - 1 : currentIndex + 1 - if (newIndex >= 0 && newIndex < students.length) { - router.push(`/admin/klausur-korrektur/${klausurId}/${students[newIndex].id}`) + const newIndex = direction === 'prev' ? ws.currentIndex - 1 : ws.currentIndex + 1 + if (newIndex >= 0 && newIndex < ws.students.length) { + router.push(`/admin/klausur-korrektur/${klausurId}/${ws.students[newIndex].id}`) } } - if (loading) { + if (ws.loading) { return ( - +
@@ -675,653 +48,92 @@ export default function KorrekturWorkspacePage() { return ( - {/* Top Navigation Bar */} -
- {/* Back link */} - - - - - Zurück - + - {/* Student navigation */} -
- - - {currentIndex + 1} / {students.length} - - -
- - {/* Workflow status and role */} -
- {/* Role badge */} - {workflow && ( -
- - {ROLE_LABELS[workflow.user_role]?.label || workflow.user_role} - - - {/* Workflow status badge */} - - {WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.label || workflow.workflow_status} - - - {/* Visibility mode indicator for ZK */} - {workflow.user_role === 'zk' && workflow.visibility_mode !== 'full' && ( - - {workflow.visibility_mode === 'blind' ? 'Blind-Modus' : 'Semi-Blind'} - - )} -
- )} - - {saving && ( - -
- Speichern... -
- )} -
-
- {totals.gradePoints} Punkte -
-
- Note: {GRADE_LABELS[totals.gradePoints] || '-'} -
-
-
-
- - {/* Einigung Modal */} - {showEinigungModal && workflow && ( -
-
-

Einigung erforderlich

- - {/* Grade comparison */} -
-
-
-
Erstkorrektor
-
- {workflow.first_result?.grade_points || '-'} P -
-
-
-
Zweitkorrektor
-
- {workflow.second_result?.grade_points || '-'} P -
-
-
-
- Differenz: {workflow.grade_difference} Punkte -
-
- - {/* Final grade selection */} -
- - setEinigungGrade(parseInt(e.target.value))} - className="w-full" - /> -
- {einigungGrade} Punkte ({GRADE_LABELS[einigungGrade] || '-'}) -
-
- - {/* Notes */} -
- -