[split-required] Split final batch of monoliths >1000 LOC

Python (6 files in klausur-service):
- rbac.py (1,132 → 4), admin_api.py (1,012 → 4)
- routes/eh.py (1,111 → 4), ocr_pipeline_geometry.py (1,105 → 5)

Python (2 files in backend-lehrer):
- unit_api.py (1,226 → 6), game_api.py (1,129 → 5)

Website (6 page files):
- 4x klausur-korrektur pages (1,249-1,328 LOC each) → shared components
  in website/components/klausur-korrektur/ (17 shared files)
- companion (1,057 → 10), magic-help (1,017 → 8)

All re-export barrels preserve backward compatibility.
Zero import errors verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 23:17:30 +02:00
parent b2a0126f14
commit 6811264756
67 changed files with 12270 additions and 13651 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 []

View File

@@ -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"
),
]

View File

@@ -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!"
}

View File

@@ -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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)}")

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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] = []

View File

@@ -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,
}

File diff suppressed because it is too large Load Diff

View File

@@ -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))

View File

@@ -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,
}

View File

@@ -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))

View File

@@ -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"),
}

View File

@@ -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}

View File

@@ -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}

File diff suppressed because it is too large Load Diff

View File

@@ -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),
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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},
},
}

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)}")

View File

@@ -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 (
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Todo Column */}
<div className="bg-amber-50 rounded-xl p-4">
<h3 className="font-semibold text-amber-800 mb-3 flex items-center gap-2">
<span className="w-6 h-6 bg-amber-200 rounded-full flex items-center justify-center text-sm">
{features.filter(f => f.status === 'todo').length}
</span>
Todo
</h3>
<div className="space-y-2">
{features.filter(f => f.status === 'todo').map(f => (
<div key={f.id} className="bg-white p-3 rounded-lg shadow-sm">
<div className="font-medium text-sm text-slate-900">{f.title}</div>
<div className="text-xs text-slate-500 mt-1">{f.description}</div>
<div className="flex gap-1 mt-2">
<span className={`px-1.5 py-0.5 rounded text-xs ${priorityColors[f.priority]}`}>
{f.priority}
</span>
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{f.effort}
</span>
</div>
</div>
))}
</div>
</div>
{/* In Progress Column */}
<div className="bg-blue-50 rounded-xl p-4">
<h3 className="font-semibold text-blue-800 mb-3 flex items-center gap-2">
<span className="w-6 h-6 bg-blue-200 rounded-full flex items-center justify-center text-sm">
{features.filter(f => f.status === 'in_progress').length}
</span>
In Arbeit
</h3>
<div className="space-y-2">
{features.filter(f => f.status === 'in_progress').map(f => (
<div key={f.id} className="bg-white p-3 rounded-lg shadow-sm">
<div className="font-medium text-sm text-slate-900">{f.title}</div>
<div className="text-xs text-slate-500 mt-1">{f.description}</div>
<div className="flex gap-1 mt-2">
<span className={`px-1.5 py-0.5 rounded text-xs ${priorityColors[f.priority]}`}>
{f.priority}
</span>
</div>
</div>
))}
</div>
</div>
{/* Backlog Column */}
<div className="bg-slate-100 rounded-xl p-4">
<h3 className="font-semibold text-slate-700 mb-3 flex items-center gap-2">
<span className="w-6 h-6 bg-slate-300 rounded-full flex items-center justify-center text-sm">
{features.filter(f => f.status === 'backlog').length}
</span>
Backlog
</h3>
<div className="space-y-2">
{features.filter(f => f.status === 'backlog').map(f => (
<div key={f.id} className="bg-white p-3 rounded-lg shadow-sm">
<div className="font-medium text-sm text-slate-900">{f.title}</div>
<div className="text-xs text-slate-500 mt-1">{f.description}</div>
<div className="flex gap-1 mt-2">
<span className={`px-1.5 py-0.5 rounded text-xs ${priorityColors[f.priority]}`}>
{f.priority}
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div>
{/* Phase Filter */}
<div className="flex gap-2 mb-4 flex-wrap">
<button
onClick={() => setSelectedPhase(null)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
!selectedPhase ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Alle
</button>
{roadmapPhases.map(phase => (
<button
key={phase.id}
onClick={() => setSelectedPhase(phase.id)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
selectedPhase === phase.id ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{phase.name.replace('Phase ', 'P')}
</button>
))}
</div>
{/* Features List */}
<div className="space-y-2">
{features
.filter(f => !selectedPhase || f.phase === selectedPhase)
.map(feature => (
<div key={feature.id} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityColors[feature.priority]}`}>
{feature.priority}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900 truncate">{feature.title}</div>
<div className="text-xs text-slate-500 truncate">{feature.description}</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium whitespace-nowrap ${
feature.effort === 'small' ? 'bg-green-100 text-green-700' :
feature.effort === 'medium' ? 'bg-amber-100 text-amber-700' :
feature.effort === 'large' ? 'bg-orange-100 text-orange-700' :
'bg-red-100 text-red-700'
}`}>
{feature.effort}
</span>
<select
value={feature.status}
onChange={(e) => updateFeatureStatus(feature.id, e.target.value as Feature['status'])}
className={`px-2 py-1 rounded text-xs font-medium border-0 cursor-pointer ${statusColors[feature.status]}`}
>
<option value="done">Fertig</option>
<option value="in_progress">In Arbeit</option>
<option value="todo">Todo</option>
<option value="backlog">Backlog</option>
</select>
</div>
))}
</div>
</div>
)
}

View File

@@ -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 (
<div>
{/* Filter */}
<div className="flex gap-2 mb-4 flex-wrap">
{['all', 'new', 'bug', 'feature_request', 'improvement'].map(filter => (
<button
key={filter}
onClick={() => setFeedbackFilter(filter)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
feedbackFilter === filter ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{filter === 'all' ? 'Alle' :
filter === 'new' ? 'Neu' :
filter === 'bug' ? 'Bugs' :
filter === 'feature_request' ? 'Feature-Requests' : 'Verbesserungen'}
</button>
))}
</div>
{/* Feedback List */}
<div className="space-y-3">
{filteredFeedback.map(fb => (
<div key={fb.id} className="border border-slate-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
fb.type === 'bug' ? 'bg-red-100' :
fb.type === 'feature_request' ? 'bg-blue-100' :
fb.type === 'improvement' ? 'bg-amber-100' :
fb.type === 'praise' ? 'bg-pink-100' : 'bg-purple-100'
}`}>
<svg className={`w-5 h-5 ${
fb.type === 'bug' ? 'text-red-600' :
fb.type === 'feature_request' ? 'text-blue-600' :
fb.type === 'improvement' ? 'text-amber-600' :
fb.type === 'praise' ? 'text-pink-600' : 'text-purple-600'
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={feedbackTypeIcons[fb.type]} />
</svg>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">{fb.title}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${priorityColors[fb.priority]}`}>
{fb.priority}
</span>
</div>
<p className="text-sm text-slate-600 mb-2">{fb.description}</p>
<div className="flex items-center gap-4 text-xs text-slate-400">
<span>{fb.teacher}</span>
<span>{fb.date}</span>
{fb.relatedFeature && (
<span className="text-primary-600"> {features.find(f => f.id === fb.relatedFeature)?.title}</span>
)}
</div>
{fb.response && (
<div className="mt-2 p-2 bg-green-50 rounded text-sm text-green-800">
<strong>Antwort:</strong> {fb.response}
</div>
)}
</div>
<select
value={fb.status}
onChange={(e) => updateFeedbackStatus(fb.id, e.target.value as TeacherFeedback['status'])}
className={`px-2 py-1 rounded text-xs font-medium border-0 cursor-pointer ${
fb.status === 'new' ? 'bg-red-100 text-red-700' :
fb.status === 'acknowledged' ? 'bg-blue-100 text-blue-700' :
fb.status === 'planned' ? 'bg-amber-100 text-amber-700' :
fb.status === 'implemented' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-600'
}`}
>
<option value="new">Neu</option>
<option value="acknowledged">Gesehen</option>
<option value="planned">Geplant</option>
<option value="implemented">Umgesetzt</option>
<option value="declined">Abgelehnt</option>
</select>
</div>
</div>
))}
</div>
{/* Add Feedback Button */}
<button className="mt-4 w-full py-3 border-2 border-dashed border-slate-300 rounded-xl text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors">
+ Neues Feedback hinzufuegen
</button>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import { roadmapPhases } from './data'
export default function RoadmapTab() {
return (
<div className="space-y-4">
{roadmapPhases.map((phase, index) => (
<div
key={phase.id}
className={`border rounded-xl overflow-hidden ${
phase.status === 'completed' ? 'border-green-200 bg-green-50/50' :
phase.status === 'in_progress' ? 'border-blue-200 bg-blue-50/50' :
phase.status === 'planned' ? 'border-amber-200 bg-amber-50/50' :
'border-slate-200 bg-slate-50/50'
}`}
>
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
phase.status === 'completed' ? 'bg-green-500 text-white' :
phase.status === 'in_progress' ? 'bg-blue-500 text-white' :
phase.status === 'planned' ? 'bg-amber-500 text-white' :
'bg-slate-300 text-slate-600'
}`}>
{phase.status === 'completed' ? '✓' : index + 1}
</div>
<div>
<h3 className="font-semibold text-slate-900">{phase.name}</h3>
<p className="text-sm text-slate-500">{phase.description}</p>
</div>
</div>
<div className="text-right">
<span className={`px-2 py-1 rounded text-xs font-medium ${
phase.status === 'completed' ? 'bg-green-100 text-green-800' :
phase.status === 'in_progress' ? 'bg-blue-100 text-blue-800' :
phase.status === 'planned' ? 'bg-amber-100 text-amber-800' :
'bg-slate-100 text-slate-600'
}`}>
{phase.status === 'completed' ? 'Abgeschlossen' :
phase.status === 'in_progress' ? 'In Arbeit' :
phase.status === 'planned' ? 'Geplant' : 'Zukunft'}
</span>
{phase.startDate && (
<div className="text-xs text-slate-400 mt-1">
{phase.startDate} {phase.endDate ? `- ${phase.endDate}` : ''}
</div>
)}
</div>
</div>
{/* Progress Bar */}
<div className="mt-3 mb-3">
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>Fortschritt</span>
<span>{phase.progress}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
phase.status === 'completed' ? 'bg-green-500' :
phase.status === 'in_progress' ? 'bg-blue-500' :
'bg-amber-500'
}`}
style={{ width: `${phase.progress}%` }}
/>
</div>
</div>
{/* Features */}
<div className="flex flex-wrap gap-2">
{phase.features.map((feature, i) => (
<span key={i} className="px-2 py-1 bg-white border border-slate-200 rounded text-xs text-slate-600">
{feature}
</span>
))}
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -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 (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500 mb-1">Roadmap-Phasen</div>
<div className="text-2xl font-bold text-primary-600">{phaseStats.completed}/{phaseStats.total}</div>
<div className="text-xs text-slate-400">{phaseStats.inProgress} in Arbeit</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500 mb-1">Features</div>
<div className="text-2xl font-bold text-green-600">{featureStats.percentage}%</div>
<div className="text-xs text-slate-400">{featureStats.done}/{featureStats.total} fertig</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500 mb-1">Neues Feedback</div>
<div className="text-2xl font-bold text-amber-600">{feedbackStats.newCount}</div>
<div className="text-xs text-slate-400">{feedbackStats.total} gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500 mb-1">Offene Bugs</div>
<div className="text-2xl font-bold text-red-600">{feedbackStats.bugs}</div>
<div className="text-xs text-slate-400">{feedbackStats.requests} Feature-Requests</div>
</div>
</div>
)
}

View File

@@ -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.',
},
]

View File

@@ -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)',
],
}

View File

@@ -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<Feature['status'], string> = {
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<Feature['priority'], string> = {
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<TeacherFeedback['type'], string> = {
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',
}

View File

@@ -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<Feature[]>(initialFeatures)
const [feedback, setFeedback] = useState<TeacherFeedback[]>(initialFeedback)
const [activeTab, setActiveTab] = useState<'roadmap' | 'features' | 'feedback' | 'backlog'>('roadmap')
const [selectedPhase, setSelectedPhase] = useState<string | null>(null)
const [feedbackFilter, setFeedbackFilter] = useState<string>('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,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
'use client'
const ARCHITECTURE_DIAGRAM = `┌─────────────────────────────────────────────────────────────────────────────┐
│ MAGIC HELP ARCHITEKTUR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ FRONTEND │ │ BACKEND │ │ STORAGE │ │
│ │ (Next.js) │ │ (FastAPI) │ │ │ │
│ │ │ │ │ │ │ │
│ │ ┌─────────┐ │ REST │ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Admin │──┼─────────┼──│ TrOCR │ │ │ │ Models │ │ │
│ │ │ Panel │ │ │ │ Service │──┼─────────┼──│ (ONNX) │ │ │
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │ │ │ │
│ │ ┌─────────┐ │ WebSocket│ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Lehrer │──┼─────────┼──│ Klausur │ │ │ │ LoRA │ │ │
│ │ │ Portal │ │ │ │ Processor │──┼─────────┼──│ Adapter │ │ │
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │ │ │ │
│ └───────────────┘ │ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Pseudo- │ │ │ │Training │ │ │
│ │ │ nymizer │──┼─────────┼──│ Data │ │ │
│ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │
│ └──────────────────┘ └───────────────┘ │
│ │ │
│ │ (nur pseudonymisiert) │
│ ▼ │
│ ┌──────────────────┐ │
│ │ CLOUD LLM │ │
│ │ (SysEleven) │ │
│ │ Namespace- │ │
│ │ Isolation │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘`
const COMPONENTS = [
{
icon: '🔍',
title: 'TrOCR Service',
details: [
{ label: 'Modell', value: 'microsoft/trocr-base-handwritten' },
{ label: 'Größe', value: '~350 MB' },
{ label: 'Lizenz', value: 'MIT' },
{ label: 'Framework', value: 'PyTorch / Transformers' },
],
description: 'Das TrOCR-Modell von Microsoft ist speziell für Handschrifterkennung trainiert. Es verwendet eine Vision-Transformer (ViT) Architektur für Bildverarbeitung und einen Text-Decoder für die Textgenerierung.',
},
{
icon: '🎯',
title: 'LoRA Fine-Tuning',
details: [
{ label: 'Methode', value: 'Low-Rank Adaptation' },
{ label: 'Adapter-Größe', value: '~10 MB' },
{ label: 'Trainingszeit', value: '5-15 Min (CPU)' },
{ label: 'Min. Beispiele', value: '10' },
],
description: 'LoRA fügt kleine, trainierbare Matrizen zu bestimmten Schichten hinzu, ohne das Basismodell zu verändern. Dies ermöglicht effizientes Fine-Tuning mit minimaler Speichernutzung.',
},
{
icon: '🔒',
title: 'Pseudonymisierung',
details: [
{ label: 'Methode', value: 'QR-Code Tokens' },
{ label: 'Token-Format', value: 'UUID v4' },
{ label: 'Mapping', value: 'Lokal beim Lehrer' },
{ label: 'Cloud-Daten', value: 'Nur Tokens + Text' },
],
description: 'Schülernamen werden durch anonyme Tokens ersetzt, bevor Daten die lokale Umgebung verlassen. Das Mapping wird ausschließlich lokal gespeichert.',
},
{
icon: '☁️',
title: 'Cloud LLM',
details: [
{ label: 'Provider', value: 'SysEleven (DE)' },
{ label: 'Standort', value: 'Deutschland' },
{ label: 'Isolation', value: 'Namespace pro Schule' },
{ label: 'Datenverarbeitung', value: 'Nur pseudonymisiert' },
],
description: 'Die KI-Korrektur erfolgt auf deutschen Servern mit strikter Mandantentrennung. Es werden keine Klarnamen oder identifizierenden Informationen übertragen.',
},
]
const DATA_FLOW_STEPS = [
{ color: 'blue', num: 1, title: 'Lokale Header-Extraktion', desc: 'TrOCR erkennt Schülernamen, Klasse und Fach direkt im Browser/PWA (offline-fähig)' },
{ color: 'purple', num: 2, title: 'Pseudonymisierung', desc: 'Namen werden durch QR-Code Tokens ersetzt, Mapping bleibt lokal' },
{ color: 'green', num: 3, title: 'Cloud-Korrektur', desc: 'Nur pseudonymisierte Dokument-Tokens werden an die KI gesendet' },
{ color: 'yellow', num: 4, title: 'Re-Identifikation', desc: 'Ergebnisse werden lokal mit dem Mapping wieder den echten Namen zugeordnet' },
]
export default function ArchitectureTab() {
return (
<div className="space-y-6">
{/* Architecture Diagram */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-6">Systemarchitektur</h2>
<div className="bg-gray-900 rounded-lg p-6 font-mono text-xs overflow-x-auto">
<pre className="text-gray-300">{ARCHITECTURE_DIAGRAM}</pre>
</div>
</div>
{/* Components */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{COMPONENTS.map(comp => (
<div key={comp.title} className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<span>{comp.icon}</span> {comp.title}
</h3>
<div className="space-y-3 text-sm">
{comp.details.map(d => (
<div key={d.label} className="flex justify-between">
<span className="text-gray-400">{d.label}</span>
<span className="text-white">{d.value}</span>
</div>
))}
</div>
<p className="text-gray-400 text-sm mt-4">{comp.description}</p>
</div>
))}
</div>
{/* Data Flow */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Datenfluss</h2>
<div className="space-y-4">
{DATA_FLOW_STEPS.map(step => (
<div key={step.num} className="flex items-start gap-4 bg-gray-900/50 rounded-lg p-4">
<div className={`w-8 h-8 rounded-full bg-${step.color}-500/20 flex items-center justify-center text-${step.color}-400 font-bold`}>
{step.num}
</div>
<div>
<div className="font-medium text-white">{step.title}</div>
<div className="text-sm text-gray-400">{step.desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import type { OCRResult } from './types'
interface OcrTestTabProps {
ocrResult: OCRResult | null
ocrLoading: boolean
handleFileUpload: (file: File) => void
}
export default function OcrTestTab({ ocrResult, ocrLoading, handleFileUpload }: OcrTestTabProps) {
return (
<div className="space-y-6">
{/* OCR Test */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">OCR Test</h2>
<p className="text-sm text-gray-400 mb-4">
Teste die Handschrifterkennung mit einem eigenen Bild. Das Ergebnis zeigt
den erkannten Text, Konfidenz und Verarbeitungszeit.
</p>
<div
className="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500 transition-colors"
onClick={() => document.getElementById('ocr-file-input')?.click()}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-blue-500') }}
onDragLeave={(e) => { e.currentTarget.classList.remove('border-blue-500') }}
onDrop={(e) => {
e.preventDefault()
e.currentTarget.classList.remove('border-blue-500')
const file = e.dataTransfer.files[0]
if (file?.type.startsWith('image/')) handleFileUpload(file)
}}
>
<div className="text-4xl mb-2">📄</div>
<div className="text-gray-300">Bild hierher ziehen oder klicken zum Hochladen</div>
<div className="text-xs text-gray-500 mt-1">PNG, JPG - Handgeschriebener Text</div>
</div>
<input
type="file"
id="ocr-file-input"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileUpload(file)
}}
/>
{ocrLoading && (
<div className="mt-4 flex items-center gap-2 text-gray-400">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Analysiere Bild...
</div>
)}
{ocrResult && (
<div className="mt-4 bg-gray-900/50 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-300 mb-2">Erkannter Text:</h3>
<pre className="bg-gray-950 p-3 rounded text-sm text-white whitespace-pre-wrap max-h-48 overflow-y-auto">
{ocrResult.text || '(Kein Text erkannt)'}
</pre>
<div className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="bg-gray-800 rounded p-2">
<div className="text-gray-400 text-xs">Konfidenz</div>
<div className="text-white font-medium">{(ocrResult.confidence * 100).toFixed(1)}%</div>
</div>
<div className="bg-gray-800 rounded p-2">
<div className="text-gray-400 text-xs">Verarbeitungszeit</div>
<div className="text-white font-medium">{ocrResult.processing_time_ms}ms</div>
</div>
<div className="bg-gray-800 rounded p-2">
<div className="text-gray-400 text-xs">Modell</div>
<div className="text-white font-medium">{ocrResult.model || 'TrOCR'}</div>
</div>
<div className="bg-gray-800 rounded p-2">
<div className="text-gray-400 text-xs">LoRA Adapter</div>
<div className="text-white font-medium">{ocrResult.has_lora_adapter ? 'Ja' : 'Nein'}</div>
</div>
</div>
</div>
)}
</div>
{/* Confidence Interpretation */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Konfidenz-Interpretation</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-green-900/20 border border-green-800 rounded-lg p-4">
<div className="text-green-400 font-medium">90-100%</div>
<div className="text-sm text-gray-300 mt-1">Sehr hohe Sicherheit - Text kann direkt übernommen werden</div>
</div>
<div className="bg-yellow-900/20 border border-yellow-800 rounded-lg p-4">
<div className="text-yellow-400 font-medium">70-90%</div>
<div className="text-sm text-gray-300 mt-1">Gute Sicherheit - manuelle Überprüfung empfohlen</div>
</div>
<div className="bg-red-900/20 border border-red-800 rounded-lg p-4">
<div className="text-red-400 font-medium">&lt; 70%</div>
<div className="text-sm text-gray-300 mt-1">Niedrige Sicherheit - manuelle Eingabe erforderlich</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import type { TrOCRStatus } from './types'
interface OverviewTabProps {
status: TrOCRStatus | null
loading: boolean
fetchStatus: () => void
}
export default function OverviewTab({ status, loading, fetchStatus }: OverviewTabProps) {
return (
<div className="space-y-6">
{/* Status Card */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Systemstatus</h2>
<button
onClick={fetchStatus}
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 rounded text-sm transition-colors"
>
Aktualisieren
</button>
</div>
{loading ? (
<div className="text-gray-400">Lade Status...</div>
) : status?.status === 'available' ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900/50 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{status.model_name || 'trocr-base'}</div>
<div className="text-xs text-gray-400">Modell</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{status.device || 'CPU'}</div>
<div className="text-xs text-gray-400">Gerät</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{status.training_examples_count || 0}</div>
<div className="text-xs text-gray-400">Trainingsbeispiele</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{status.has_lora_adapter ? 'Aktiv' : 'Keiner'}</div>
<div className="text-xs text-gray-400">LoRA Adapter</div>
</div>
</div>
) : status?.status === 'not_installed' ? (
<div className="text-gray-400">
<p className="mb-2">TrOCR ist nicht installiert. Führe aus:</p>
<code className="bg-gray-900 px-3 py-2 rounded text-sm block">{status.install_command}</code>
</div>
) : (
<div className="text-red-400">{status?.error || 'Unbekannter Fehler'}</div>
)}
</div>
{/* Quick Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gradient-to-br from-purple-900/30 to-purple-800/20 border border-purple-700/50 rounded-xl p-6">
<div className="text-3xl mb-2">🎯</div>
<h3 className="text-lg font-semibold text-white mb-2">Handschrifterkennung</h3>
<p className="text-sm text-gray-300">
TrOCR erkennt automatisch handgeschriebenen Text in Klausuren.
Das Modell wurde speziell für deutsche Handschriften optimiert.
</p>
</div>
<div className="bg-gradient-to-br from-green-900/30 to-green-800/20 border border-green-700/50 rounded-xl p-6">
<div className="text-3xl mb-2">🔒</div>
<h3 className="text-lg font-semibold text-white mb-2">Privacy by Design</h3>
<p className="text-sm text-gray-300">
Alle Daten werden lokal verarbeitet. Schülernamen werden durch
QR-Codes pseudonymisiert - DSGVO-konform.
</p>
</div>
<div className="bg-gradient-to-br from-blue-900/30 to-blue-800/20 border border-blue-700/50 rounded-xl p-6">
<div className="text-3xl mb-2">📈</div>
<h3 className="text-lg font-semibold text-white mb-2">Kontinuierliches Lernen</h3>
<p className="text-sm text-gray-300">
Mit LoRA Fine-Tuning passt sich das Modell an individuelle
Handschriften an - ohne das Basismodell zu verändern.
</p>
</div>
</div>
{/* Workflow Overview */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Magic Onboarding Workflow</h2>
<div className="flex flex-wrap items-center gap-4 text-sm">
{[
{ icon: '📄', title: '1. Upload', desc: '25 Klausuren hochladen' },
{ icon: '🔍', title: '2. Analyse', desc: 'Lokale OCR in 5-10 Sek' },
{ icon: '✅', title: '3. Bestätigung', desc: 'Klasse, Schüler, Fach' },
{ icon: '🤖', title: '4. KI-Korrektur', desc: 'Cloud mit Pseudonymisierung' },
{ icon: '📊', title: '5. Integration', desc: 'Notenbuch, Zeugnisse' },
].map((step, i, arr) => (
<div key={step.title} className="contents">
<div className="flex items-center gap-2 bg-gray-900/50 rounded-lg px-4 py-3">
<span className="text-2xl">{step.icon}</span>
<div>
<div className="font-medium text-white">{step.title}</div>
<div className="text-gray-400">{step.desc}</div>
</div>
</div>
{i < arr.length - 1 && <div className="text-gray-600"></div>}
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,193 @@
'use client'
import type { MagicSettings } from './types'
import { DEFAULT_SETTINGS } from './types'
interface SettingsTabProps {
settings: MagicSettings
setSettings: (settings: MagicSettings) => void
settingsSaved: boolean
saveSettings: () => void
}
export default function SettingsTab({ settings, setSettings, settingsSaved, saveSettings }: SettingsTabProps) {
return (
<div className="space-y-6">
{/* OCR Settings */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">OCR Einstellungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.autoDetectLines}
onChange={(e) => setSettings({ ...settings, autoDetectLines: e.target.checked })}
className="w-5 h-5 rounded bg-gray-900 border-gray-700"
/>
<div>
<div className="text-white font-medium">Automatische Zeilenerkennung</div>
<div className="text-sm text-gray-400">Erkennt und verarbeitet einzelne Zeilen separat</div>
</div>
</label>
</div>
<div>
<label className="block text-sm text-gray-300 mb-2">Konfidenz-Schwellwert</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={settings.confidenceThreshold}
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0%</span>
<span className="text-white">{(settings.confidenceThreshold * 100).toFixed(0)}%</span>
<span>100%</span>
</div>
</div>
<div>
<label className="block text-sm text-gray-300 mb-2">Max. Bildgröße (px)</label>
<input
type="number"
value={settings.maxImageSize}
onChange={(e) => setSettings({ ...settings, maxImageSize: parseInt(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
/>
<div className="text-xs text-gray-500 mt-1">Größere Bilder werden skaliert</div>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.enableCache}
onChange={(e) => setSettings({ ...settings, enableCache: e.target.checked })}
className="w-5 h-5 rounded bg-gray-900 border-gray-700"
/>
<div>
<div className="text-white font-medium">Ergebnis-Cache aktivieren</div>
<div className="text-sm text-gray-400">Speichert OCR-Ergebnisse für identische Bilder</div>
</div>
</label>
</div>
</div>
</div>
{/* Training Settings */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Training Einstellungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm text-gray-300 mb-2">LoRA Rank</label>
<select
value={settings.loraRank}
onChange={(e) => setSettings({ ...settings, loraRank: parseInt(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
>
<option value="4">4 (Schnell, weniger Kapazität)</option>
<option value="8">8 (Ausgewogen)</option>
<option value="16">16 (Mehr Kapazität)</option>
<option value="32">32 (Maximum)</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-300 mb-2">LoRA Alpha</label>
<input
type="number"
value={settings.loraAlpha}
onChange={(e) => setSettings({ ...settings, loraAlpha: parseInt(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
/>
<div className="text-xs text-gray-500 mt-1">Empfohlen: 4 × LoRA Rank</div>
</div>
<div>
<label className="block text-sm text-gray-300 mb-2">Epochen</label>
<input
type="number"
min="1"
max="10"
value={settings.epochs}
onChange={(e) => setSettings({ ...settings, epochs: parseInt(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-2">Batch Size</label>
<select
value={settings.batchSize}
onChange={(e) => setSettings({ ...settings, batchSize: parseInt(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
>
<option value="1">1 (Wenig RAM)</option>
<option value="2">2</option>
<option value="4">4 (Standard)</option>
<option value="8">8 (Viel RAM)</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-300 mb-2">Learning Rate</label>
<select
value={settings.learningRate}
onChange={(e) => setSettings({ ...settings, learningRate: parseFloat(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
>
<option value="0.0001">0.0001 (Schnell)</option>
<option value="0.00005">0.00005 (Standard)</option>
<option value="0.00001">0.00001 (Konservativ)</option>
</select>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end gap-4">
<button
onClick={() => setSettings(DEFAULT_SETTINGS)}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium transition-colors"
>
Zurücksetzen
</button>
<button
onClick={saveSettings}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors"
>
{settingsSaved ? '✓ Gespeichert!' : 'Einstellungen speichern'}
</button>
</div>
{/* Technical Info */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Technische Informationen</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-400">API Endpoint:</span>
<code className="text-white ml-2 bg-gray-900 px-2 py-1 rounded text-xs">/api/klausur/trocr</code>
</div>
<div>
<span className="text-gray-400">Model Path:</span>
<code className="text-white ml-2 bg-gray-900 px-2 py-1 rounded text-xs">~/.cache/huggingface</code>
</div>
<div>
<span className="text-gray-400">LoRA Path:</span>
<code className="text-white ml-2 bg-gray-900 px-2 py-1 rounded text-xs">./models/lora</code>
</div>
<div>
<span className="text-gray-400">Training Data:</span>
<code className="text-white ml-2 bg-gray-900 px-2 py-1 rounded text-xs">./data/training</code>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,182 @@
'use client'
import type { TrOCRStatus, TrainingExample, MagicSettings } from './types'
interface TrainingTabProps {
status: TrOCRStatus | null
examples: TrainingExample[]
trainingImage: File | null
setTrainingImage: (file: File | null) => void
trainingText: string
setTrainingText: (text: string) => void
fineTuning: boolean
settings: MagicSettings
handleAddTrainingExample: () => void
handleFineTune: () => void
}
export default function TrainingTab({
status,
examples,
trainingImage,
setTrainingImage,
trainingText,
setTrainingText,
fineTuning,
settings,
handleAddTrainingExample,
handleFineTune,
}: TrainingTabProps) {
const examplesCount = status?.training_examples_count || 0
return (
<div className="space-y-6">
{/* Training Overview */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Training mit LoRA</h2>
<p className="text-sm text-gray-400 mb-4">
LoRA (Low-Rank Adaptation) ermöglicht effizientes Fine-Tuning ohne das Basismodell zu verändern.
Das Training erfolgt lokal auf Ihrem System.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-white">{examplesCount}</div>
<div className="text-xs text-gray-400">Trainingsbeispiele</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-white">10</div>
<div className="text-xs text-gray-400">Minimum benötigt</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-white">{settings.loraRank}</div>
<div className="text-xs text-gray-400">LoRA Rank</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-white">{status?.has_lora_adapter ? '✓' : '✗'}</div>
<div className="text-xs text-gray-400">Adapter aktiv</div>
</div>
</div>
{/* Progress Bar */}
<div className="mb-6">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Fortschritt zum Fine-Tuning</span>
<span className="text-gray-400">{Math.min(100, (examplesCount / 10) * 100).toFixed(0)}%</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
style={{ width: `${Math.min(100, (examplesCount / 10) * 100)}%` }}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Add Training Example */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Trainingsbeispiel hinzufügen</h2>
<p className="text-sm text-gray-400 mb-4">
Lade ein Bild mit handgeschriebenem Text hoch und gib die korrekte Transkription ein.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-300 mb-1">Bild</label>
<input
type="file"
accept="image/*"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm"
onChange={(e) => setTrainingImage(e.target.files?.[0] || null)}
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1">Korrekter Text (Ground Truth)</label>
<textarea
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white resize-none"
rows={3}
placeholder="Gib hier den korrekten Text ein..."
value={trainingText}
onChange={(e) => setTrainingText(e.target.value)}
/>
</div>
<button
onClick={handleAddTrainingExample}
className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition-colors"
>
+ Trainingsbeispiel hinzufügen
</button>
</div>
</div>
{/* Fine-Tuning */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Fine-Tuning starten</h2>
<p className="text-sm text-gray-400 mb-4">
Trainiere das Modell mit den gesammelten Beispielen. Der Prozess dauert
je nach Anzahl der Beispiele einige Minuten.
</p>
<div className="bg-gray-900/50 rounded-lg p-4 mb-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-400">Epochen:</span>
<span className="text-white ml-2">{settings.epochs}</span>
</div>
<div>
<span className="text-gray-400">Learning Rate:</span>
<span className="text-white ml-2">{settings.learningRate}</span>
</div>
<div>
<span className="text-gray-400">LoRA Rank:</span>
<span className="text-white ml-2">{settings.loraRank}</span>
</div>
<div>
<span className="text-gray-400">Batch Size:</span>
<span className="text-white ml-2">{settings.batchSize}</span>
</div>
</div>
</div>
<button
onClick={handleFineTune}
disabled={fineTuning || examplesCount < 10}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-colors"
>
{fineTuning ? (
<span className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Fine-Tuning läuft...
</span>
) : (
'Fine-Tuning starten'
)}
</button>
{examplesCount < 10 && (
<p className="text-xs text-yellow-400 mt-2 text-center">
Noch {10 - examplesCount} Beispiele benötigt
</p>
)}
</div>
</div>
{/* Training Examples List */}
{examples.length > 0 && (
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Trainingsbeispiele ({examples.length})</h2>
<div className="space-y-2 max-h-64 overflow-y-auto">
{examples.map((ex, i) => (
<div key={i} className="flex items-center gap-4 bg-gray-900/50 rounded-lg p-3">
<span className="text-gray-500 font-mono text-sm w-8">{i + 1}.</span>
<span className="text-white text-sm flex-1 truncate">{ex.ground_truth}</span>
<span className="text-gray-500 text-xs">{new Date(ex.created_at).toLocaleDateString('de-DE')}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,62 @@
export type TabId = 'overview' | 'test' | 'training' | 'architecture' | 'settings'
export interface TrOCRStatus {
status: 'available' | 'not_installed' | 'error'
model_name?: string
model_id?: string
device?: string
is_loaded?: boolean
has_lora_adapter?: boolean
training_examples_count?: number
error?: string
install_command?: string
}
export interface OCRResult {
text: string
confidence: number
processing_time_ms: number
model: string
has_lora_adapter: boolean
}
export interface TrainingExample {
image_path: string
ground_truth: string
teacher_id: string
created_at: string
}
export interface MagicSettings {
autoDetectLines: boolean
confidenceThreshold: number
maxImageSize: number
loraRank: number
loraAlpha: number
learningRate: number
epochs: number
batchSize: number
enableCache: boolean
cacheMaxAge: number
}
export const DEFAULT_SETTINGS: MagicSettings = {
autoDetectLines: true,
confidenceThreshold: 0.7,
maxImageSize: 4096,
loraRank: 8,
loraAlpha: 32,
learningRate: 0.00005,
epochs: 3,
batchSize: 4,
enableCache: true,
cacheMaxAge: 3600,
}
export const TABS = [
{ id: 'overview' as TabId, label: 'Übersicht', icon: '📊' },
{ id: 'test' as TabId, label: 'OCR Test', icon: '🔍' },
{ id: 'training' as TabId, label: 'Training', icon: '🎯' },
{ id: 'architecture' as TabId, label: 'Architektur', icon: '🏗️' },
{ id: 'settings' as TabId, label: 'Einstellungen', icon: '⚙️' },
]

View File

@@ -0,0 +1,180 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import type { TabId, TrOCRStatus, OCRResult, TrainingExample, MagicSettings } from './types'
import { DEFAULT_SETTINGS } from './types'
export function useMagicHelp() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [status, setStatus] = useState<TrOCRStatus | null>(null)
const [loading, setLoading] = useState(true)
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
const [ocrLoading, setOcrLoading] = useState(false)
const [examples, setExamples] = useState<TrainingExample[]>([])
const [trainingImage, setTrainingImage] = useState<File | null>(null)
const [trainingText, setTrainingText] = useState('')
const [fineTuning, setFineTuning] = useState(false)
const [settings, setSettings] = useState<MagicSettings>(DEFAULT_SETTINGS)
const [settingsSaved, setSettingsSaved] = useState(false)
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/klausur/trocr/status')
const data = await res.json()
setStatus(data)
} catch {
setStatus({ status: 'error', error: 'Failed to fetch status' })
} finally {
setLoading(false)
}
}, [])
const fetchExamples = useCallback(async () => {
try {
const res = await fetch('/api/klausur/trocr/training/examples')
const data = await res.json()
setExamples(data.examples || [])
} catch (error) {
console.error('Failed to fetch examples:', error)
}
}, [])
useEffect(() => {
fetchStatus()
fetchExamples()
// Load settings from localStorage
const saved = localStorage.getItem('magic-help-settings')
if (saved) {
try {
setSettings(JSON.parse(saved))
} catch {
// ignore parse errors
}
}
}, [fetchStatus, fetchExamples])
const handleFileUpload = async (file: File) => {
setOcrLoading(true)
setOcrResult(null)
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch(`/api/klausur/trocr/extract?detect_lines=${settings.autoDetectLines}`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.text !== undefined) {
setOcrResult(data)
} else {
setOcrResult({ text: `Error: ${data.detail || 'Unknown error'}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
}
} catch (error) {
setOcrResult({ text: `Error: ${error}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
} finally {
setOcrLoading(false)
}
}
const handleAddTrainingExample = async () => {
if (!trainingImage || !trainingText.trim()) {
alert('Please provide both an image and the correct text')
return
}
const formData = new FormData()
formData.append('file', trainingImage)
try {
const res = await fetch(`/api/klausur/trocr/training/add?ground_truth=${encodeURIComponent(trainingText)}`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.example_id) {
alert(`Training example added! Total: ${data.total_examples}`)
setTrainingImage(null)
setTrainingText('')
fetchStatus()
fetchExamples()
} else {
alert(`Error: ${data.detail || 'Unknown error'}`)
}
} catch (error) {
alert(`Error: ${error}`)
}
}
const handleFineTune = async () => {
if (!confirm('Start fine-tuning? This may take several minutes.')) return
setFineTuning(true)
try {
const res = await fetch('/api/klausur/trocr/training/fine-tune', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
epochs: settings.epochs,
learning_rate: settings.learningRate,
lora_rank: settings.loraRank,
lora_alpha: settings.loraAlpha,
}),
})
const data = await res.json()
if (data.status === 'success') {
alert(`Fine-tuning successful!\nExamples used: ${data.examples_used}\nEpochs: ${data.epochs}`)
fetchStatus()
} else {
alert(`Fine-tuning failed: ${data.message}`)
}
} catch (error) {
alert(`Error: ${error}`)
} finally {
setFineTuning(false)
}
}
const saveSettings = () => {
localStorage.setItem('magic-help-settings', JSON.stringify(settings))
setSettingsSaved(true)
setTimeout(() => setSettingsSaved(false), 2000)
}
const getStatusBadge = () => {
if (!status) return null
switch (status.status) {
case 'available':
return <span className="px-2 py-1 text-xs font-medium rounded-full bg-green-500/20 text-green-400">Available</span>
case 'not_installed':
return <span className="px-2 py-1 text-xs font-medium rounded-full bg-red-500/20 text-red-400">Not Installed</span>
case 'error':
return <span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-500/20 text-yellow-400">Error</span>
}
}
return {
activeTab,
setActiveTab,
status,
loading,
ocrResult,
ocrLoading,
examples,
trainingImage,
setTrainingImage,
trainingText,
setTrainingText,
fineTuning,
settings,
setSettings,
settingsSaved,
fetchStatus,
handleFileUpload,
handleAddTrainingExample,
handleFineTune,
saveSettings,
getStatusBadge,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,227 @@
'use client'
/**
* Right panel (1/3 width) for the Korrektur-Workspace.
* Contains tabs: Kriterien, Annotationen, Gutachten, EH-Vorschlaege.
*/
import type {
Annotation, CriteriaScores, GradeInfo, AnnotationType,
} from '../../app/admin/klausur-korrektur/types'
import { ANNOTATION_COLORS } from '../../app/admin/klausur-korrektur/types'
import type { ExaminerWorkflow, ActiveTab } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
import CriteriaTab from './CriteriaTab'
import WorkflowActions from './WorkflowActions'
interface CorrectionPanelProps {
activeTab: ActiveTab
onTabChange: (tab: ActiveTab) => void
annotations: Annotation[]
gradeInfo: GradeInfo | null
criteriaScores: CriteriaScores
gutachten: string
totals: { gradePoints: number; weighted: number }
workflow: ExaminerWorkflow | null
saving: boolean
generatingGutachten: boolean
exporting: boolean
submittingWorkflow: boolean
selectedAnnotation: Annotation | null
studentId: string
klausurId: string
klausurEhId?: string
onCriteriaChange: (criterion: string, value: number) => void
onGutachtenChange: (text: string) => void
onSaveGutachten: () => void
onGenerateGutachten: () => void
onExportGutachtenPDF: () => void
onSelectAnnotation: (ann: Annotation | null) => void
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
onDeleteAnnotation: (id: string) => void
onSelectTool: (tool: AnnotationType) => void
onSetActiveTab: (tab: ActiveTab) => void
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (id: string) => void
onSubmitZweitkorrektur: () => void
onShowEinigungModal: () => void
// Render props for route-specific components
AnnotationPanelComponent: React.ComponentType<{
annotations: Annotation[]
selectedAnnotation: Annotation | null
onSelectAnnotation: (ann: Annotation | null) => void
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
onDeleteAnnotation: (id: string) => void
}>
EHSuggestionPanelComponent: React.ComponentType<{
studentId: string
klausurId: string
hasEH: boolean
apiBase: string
onInsertSuggestion: (text: string, criterion: string) => void
}>
}
export default function CorrectionPanel(props: CorrectionPanelProps) {
const {
activeTab, onTabChange, annotations, gradeInfo, criteriaScores, gutachten,
totals, workflow, saving, generatingGutachten, exporting, submittingWorkflow,
selectedAnnotation, studentId, klausurId, klausurEhId,
onCriteriaChange, onGutachtenChange, onSaveGutachten, onGenerateGutachten,
onExportGutachtenPDF, onSelectAnnotation, onUpdateAnnotation, onDeleteAnnotation,
onSelectTool, onSetActiveTab, onSubmitErstkorrektur, onStartZweitkorrektur,
onSubmitZweitkorrektur, onShowEinigungModal,
AnnotationPanelComponent, EHSuggestionPanelComponent,
} = props
const apiBase = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
return (
<div className="w-1/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
{/* Tabs */}
<div className="border-b border-slate-200">
<nav className="flex">
{([
{ id: 'kriterien' as const, label: 'Kriterien' },
{ id: 'annotationen' as const, label: `Notizen (${annotations.length})` },
{ id: 'gutachten' as const, label: 'Gutachten' },
{ id: 'eh-vorschlaege' as const, label: 'EH' },
]).map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex-1 px-2 py-3 text-xs font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab content */}
<div className="flex-1 overflow-auto p-4">
{/* Kriterien Tab */}
{activeTab === 'kriterien' && gradeInfo && (
<div className="space-y-4">
<CriteriaTab
gradeInfo={gradeInfo}
criteriaScores={criteriaScores}
annotations={annotations}
onCriteriaChange={onCriteriaChange}
onSelectTool={onSelectTool}
/>
{/* Total and workflow actions */}
<div className="border-t border-slate-200 pt-4 mt-4">
<div className="flex justify-between items-center mb-4">
<span className="font-semibold text-slate-800">Gesamtergebnis</span>
<div className="text-right">
<div className="text-2xl font-bold text-primary-600">
{totals.gradePoints} Punkte
</div>
<div className="text-sm text-slate-500">
({totals.weighted}%) - Note {GRADE_LABELS[totals.gradePoints]}
</div>
</div>
</div>
<WorkflowActions
workflow={workflow}
gutachten={gutachten}
generatingGutachten={generatingGutachten}
submittingWorkflow={submittingWorkflow}
totals={totals}
onGenerateGutachten={onGenerateGutachten}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onShowEinigungModal={onShowEinigungModal}
/>
</div>
</div>
)}
{/* Annotationen Tab */}
{activeTab === 'annotationen' && (
<div className="h-full -m-4">
<AnnotationPanelComponent
annotations={annotations}
selectedAnnotation={selectedAnnotation}
onSelectAnnotation={onSelectAnnotation}
onUpdateAnnotation={onUpdateAnnotation}
onDeleteAnnotation={onDeleteAnnotation}
/>
</div>
)}
{/* Gutachten Tab */}
{activeTab === 'gutachten' && (
<div className="h-full flex flex-col">
<textarea
value={gutachten}
onChange={(e) => onGutachtenChange(e.target.value)}
placeholder="Gutachten hier eingeben oder generieren lassen..."
className="flex-1 w-full p-3 border border-slate-300 rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
/>
<div className="flex gap-2 mt-4">
<button
onClick={onGenerateGutachten}
disabled={generatingGutachten}
className="flex-1 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50 disabled:opacity-50"
>
{generatingGutachten ? 'Generiere...' : 'Neu generieren'}
</button>
<button
onClick={onSaveGutachten}
disabled={saving}
className="flex-1 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
{/* PDF Export */}
{gutachten && (
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
<button
onClick={onExportGutachtenPDF}
disabled={exporting}
className="flex-1 py-2 border border-slate-300 text-slate-600 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{exporting ? 'Exportiere...' : 'Als PDF exportieren'}
</button>
</div>
)}
</div>
)}
{/* EH-Vorschlaege Tab */}
{activeTab === 'eh-vorschlaege' && (
<div className="h-full -m-4">
<EHSuggestionPanelComponent
studentId={studentId}
klausurId={klausurId}
hasEH={!!klausurEhId || true}
apiBase={apiBase}
onInsertSuggestion={(text, criterion) => {
onGutachtenChange(
gutachten
? `${gutachten}\n\n[${criterion.toUpperCase()}]: ${text}`
: `[${criterion.toUpperCase()}]: ${text}`
)
onSetActiveTab('gutachten')
}}
/>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
/**
* Criteria scoring tab content.
* Shows sliders and annotation counts for each grading criterion.
*/
import type { Annotation, GradeInfo, CriteriaScores, AnnotationType } from '../../app/admin/klausur-korrektur/types'
import { ANNOTATION_COLORS } from '../../app/admin/klausur-korrektur/types'
interface CriteriaTabProps {
gradeInfo: GradeInfo
criteriaScores: CriteriaScores
annotations: Annotation[]
onCriteriaChange: (criterion: string, value: number) => void
onSelectTool: (tool: AnnotationType) => void
}
export default function CriteriaTab({
gradeInfo, criteriaScores, annotations, onCriteriaChange, onSelectTool,
}: CriteriaTabProps) {
return (
<>
{Object.entries(gradeInfo.criteria || {}).map(([key, criterion]) => {
const score = criteriaScores[key] || 0
const linkedAnnotations = annotations.filter(
(a) => a.linked_criterion === key || a.type === key
)
const errorCount = linkedAnnotations.length
const severityCounts = {
minor: linkedAnnotations.filter((a) => a.severity === 'minor').length,
major: linkedAnnotations.filter((a) => a.severity === 'major').length,
critical: linkedAnnotations.filter((a) => a.severity === 'critical').length,
}
const criterionColor = ANNOTATION_COLORS[key as AnnotationType] || '#6b7280'
return (
<div key={key} className="bg-slate-50 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: criterionColor }}
/>
<span className="font-medium text-slate-800">{criterion.name}</span>
<span className="text-xs text-slate-500">({criterion.weight}%)</span>
</div>
<div className="text-lg font-bold text-slate-800">{score}%</div>
</div>
{/* Annotation count for this criterion */}
{errorCount > 0 && (
<div className="flex items-center gap-2 mb-2 text-xs">
<span className="text-slate-500">{errorCount} Markierungen:</span>
{severityCounts.minor > 0 && (
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded">
{severityCounts.minor} leicht
</span>
)}
{severityCounts.major > 0 && (
<span className="px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded">
{severityCounts.major} mittel
</span>
)}
{severityCounts.critical > 0 && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
{severityCounts.critical} schwer
</span>
)}
</div>
)}
{/* Slider */}
<input
type="range"
min="0"
max="100"
value={score}
onChange={(e) => onCriteriaChange(key, parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer"
style={{ accentColor: criterionColor }}
/>
{/* Quick buttons */}
<div className="flex gap-1 mt-2">
{[0, 25, 50, 75, 100].map((val) => (
<button
key={val}
onClick={() => onCriteriaChange(key, val)}
className={`flex-1 py-1 text-xs rounded transition-colors ${
score === val
? 'text-white'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
}`}
style={score === val ? { backgroundColor: criterionColor } : undefined}
>
{val}%
</button>
))}
</div>
{/* Quick add annotation button for RS/Grammatik */}
{(key === 'rechtschreibung' || key === 'grammatik') && (
<button
onClick={() => onSelectTool(key as AnnotationType)}
className="mt-2 w-full py-1 text-xs border rounded hover:bg-slate-100 flex items-center justify-center gap-1"
style={{ borderColor: criterionColor, color: criterionColor }}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{key === 'rechtschreibung' ? 'RS-Fehler' : 'Grammatik-Fehler'} markieren
</button>
)}
</div>
)
})}
</>
)
}

View File

@@ -0,0 +1,285 @@
'use client'
/**
* Direct upload wizard tab (3 steps).
* Allows quick upload of student work files without creating a klausur first.
*/
import type { DirektuploadForm, TabId } from './list-types'
interface DirektuploadTabProps {
direktForm: DirektuploadForm
direktStep: 1 | 2 | 3
uploading: boolean
onFormChange: (form: DirektuploadForm) => void
onStepChange: (step: 1 | 2 | 3) => void
onUpload: () => void
onCancel: () => void
}
export default function DirektuploadTab({
direktForm, direktStep, uploading,
onFormChange, onStepChange, onUpload, onCancel,
}: DirektuploadTabProps) {
return (
<div className="max-w-3xl mx-auto">
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Progress Header */}
<div className="bg-slate-50 border-b border-slate-200 px-6 py-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-slate-800">Schnellstart - Direkt Korrigieren</h2>
<button onClick={onCancel} className="text-sm text-slate-500 hover:text-slate-700">
Abbrechen
</button>
</div>
<div className="flex items-center gap-2">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center gap-2 flex-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
direktStep >= step ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
}`}>
{step}
</div>
<span className={`text-sm ${direktStep >= step ? 'text-slate-800' : 'text-slate-400'}`}>
{step === 1 ? 'Arbeiten' : step === 2 ? 'Erwartungshorizont' : 'Starten'}
</span>
{step < 3 && <div className={`flex-1 h-1 rounded ${direktStep > step ? 'bg-blue-600' : 'bg-slate-200'}`} />}
</div>
))}
</div>
</div>
<div className="p-6">
{/* Step 1: Upload Files */}
{direktStep === 1 && (
<Step1Files
files={direktForm.files}
onFilesChange={(files) => onFormChange({ ...direktForm, files })}
onNext={() => onStepChange(2)}
/>
)}
{/* Step 2: EH */}
{direktStep === 2 && (
<Step2EH
aufgabentyp={direktForm.aufgabentyp}
ehText={direktForm.ehText}
onAufgabentypChange={(v) => onFormChange({ ...direktForm, aufgabentyp: v })}
onEhTextChange={(v) => onFormChange({ ...direktForm, ehText: v })}
onBack={() => onStepChange(1)}
onNext={() => onStepChange(3)}
/>
)}
{/* Step 3: Confirm */}
{direktStep === 3 && (
<Step3Confirm
direktForm={direktForm}
uploading={uploading}
onTitleChange={(v) => onFormChange({ ...direktForm, klausurTitle: v })}
onBack={() => onStepChange(2)}
onUpload={onUpload}
/>
)}
</div>
</div>
</div>
)
}
// --- Sub-components for each step ---
function Step1Files({ files, onFilesChange, onNext }: {
files: File[]; onFilesChange: (f: File[]) => void; onNext: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Schuelerarbeiten hochladen</h3>
<p className="text-sm text-slate-500 mb-4">
Laden Sie die eingescannten Klausuren hoch. Unterstuetzte Formate: PDF, JPG, PNG.
</p>
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
files.length > 0 ? 'border-green-300 bg-green-50' : 'border-slate-300 hover:border-blue-400 hover:bg-blue-50'
}`}
onDrop={(e) => {
e.preventDefault()
onFilesChange([...files, ...Array.from(e.dataTransfer.files)])
}}
onDragOver={(e) => e.preventDefault()}
>
<svg className="w-12 h-12 mx-auto text-slate-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-slate-600 mb-2">Dateien hier ablegen oder</p>
<label className="inline-block px-4 py-2 bg-blue-600 text-white rounded-lg cursor-pointer hover:bg-blue-700">
Dateien auswaehlen
<input type="file" multiple accept=".pdf,.jpg,.jpeg,.png" className="hidden"
onChange={(e) => onFilesChange([...files, ...Array.from(e.target.files || [])])}
/>
</label>
</div>
{files.length > 0 && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-sm text-slate-600">
<span>{files.length} Datei{files.length !== 1 ? 'en' : ''} ausgewaehlt</span>
<button onClick={() => onFilesChange([])} className="text-red-600 hover:text-red-700">Alle entfernen</button>
</div>
<div className="max-h-40 overflow-y-auto space-y-1">
{files.map((file, idx) => (
<div key={idx} className="flex items-center justify-between bg-slate-50 px-3 py-2 rounded-lg text-sm">
<span className="truncate">{file.name}</span>
<button
onClick={() => onFilesChange(files.filter((_, i) => i !== idx))}
className="text-slate-400 hover:text-red-600"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex justify-end">
<button
onClick={onNext}
disabled={files.length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
</div>
)
}
function Step2EH({ aufgabentyp, ehText, onAufgabentypChange, onEhTextChange, onBack, onNext }: {
aufgabentyp: string; ehText: string
onAufgabentypChange: (v: string) => void; onEhTextChange: (v: string) => void
onBack: () => void; onNext: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Erwartungshorizont (optional)</h3>
<p className="text-sm text-slate-500 mb-4">
Laden Sie Ihren eigenen Erwartungshorizont hoch oder beschreiben Sie die Aufgabenstellung.
Dies hilft der KI, passendere Bewertungen vorzuschlagen.
</p>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabentyp</label>
<select
value={aufgabentyp}
onChange={(e) => onAufgabentypChange(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">-- Waehlen Sie einen Aufgabentyp --</option>
<option value="textanalyse_pragmatisch">Textanalyse (Sachtexte)</option>
<option value="gedichtanalyse">Gedichtanalyse</option>
<option value="prosaanalyse">Prosaanalyse</option>
<option value="dramenanalyse">Dramenanalyse</option>
<option value="eroerterung_textgebunden">Textgebundene Eroerterung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabenstellung / Erwartungshorizont</label>
<textarea
value={ehText}
onChange={(e) => onEhTextChange(e.target.value)}
placeholder="Beschreiben Sie hier die Aufgabenstellung und Ihre Erwartungen an eine gute Loesung..."
rows={6}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
<p className="mt-1 text-xs text-slate-500">
Je detaillierter Sie die Erwartungen beschreiben, desto besser werden die KI-Vorschlaege.
</p>
</div>
</div>
<div className="flex justify-between">
<button onClick={onBack} className="px-6 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">Zurueck</button>
<button onClick={onNext} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Weiter</button>
</div>
</div>
)
}
function Step3Confirm({ direktForm, uploading, onTitleChange, onBack, onUpload }: {
direktForm: DirektuploadForm; uploading: boolean
onTitleChange: (v: string) => void; onBack: () => void; onUpload: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Zusammenfassung</h3>
<p className="text-sm text-slate-500 mb-4">Pruefen Sie Ihre Eingaben und starten Sie die Korrektur.</p>
<div className="bg-slate-50 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Titel</span>
<input
type="text" value={direktForm.klausurTitle}
onChange={(e) => onTitleChange(e.target.value)}
className="text-sm font-medium text-slate-800 bg-white border border-slate-200 rounded px-2 py-1 text-right"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Anzahl Arbeiten</span>
<span className="text-sm font-medium text-slate-800">{direktForm.files.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Aufgabentyp</span>
<span className="text-sm font-medium text-slate-800">{direktForm.aufgabentyp || 'Nicht angegeben'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Erwartungshorizont</span>
<span className="text-sm font-medium text-slate-800">{direktForm.ehText ? 'Vorhanden' : 'Nicht angegeben'}</span>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium">Was passiert jetzt?</p>
<ol className="list-decimal list-inside mt-1 space-y-1 text-blue-700">
<li>Eine neue Klausur wird automatisch erstellt</li>
<li>Alle {direktForm.files.length} Arbeiten werden hochgeladen</li>
<li>OCR-Erkennung der Handschrift startet automatisch</li>
<li>Sie werden zur Korrektur-Ansicht weitergeleitet</li>
</ol>
</div>
</div>
</div>
</div>
<div className="flex justify-between">
<button onClick={onBack} className="px-6 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">Zurueck</button>
<button
onClick={onUpload}
disabled={uploading}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird hochgeladen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Korrektur starten
</>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
/**
* Document Viewer with annotation overlay and page navigation.
* Left panel (2/3 width) in the Korrektur-Workspace.
*/
import type { Annotation, AnnotationType, AnnotationPosition, StudentWork } from '../../app/admin/klausur-korrektur/types'
// Re-use existing annotation components from the klausur-korrektur route
interface DocumentViewerProps {
student: StudentWork | null
documentUrl: string | null
zoom: number
currentPage: number
totalPages: number
annotations: Annotation[]
selectedTool: AnnotationType | null
selectedAnnotation: Annotation | null
annotationCounts: Record<AnnotationType, number>
onZoomChange: (zoom: number) => void
onPageChange: (page: number) => void
onSelectTool: (tool: AnnotationType | null) => void
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
onSelectAnnotation: (ann: Annotation) => void
// Render props for toolbar and annotation layer since they are imported from route-local components
AnnotationToolbarComponent: React.ComponentType<{
selectedTool: AnnotationType | null
onSelectTool: (tool: AnnotationType | null) => void
zoom: number
onZoomChange: (zoom: number) => void
annotationCounts: Record<AnnotationType, number>
}>
AnnotationLayerComponent: React.ComponentType<{
annotations: Annotation[]
selectedTool: AnnotationType | null
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
onSelectAnnotation: (ann: Annotation) => void
selectedAnnotationId?: string
}>
}
export default function DocumentViewer({
student, documentUrl, zoom, currentPage, totalPages,
annotations, selectedTool, selectedAnnotation, annotationCounts,
onZoomChange, onPageChange, onSelectTool,
onCreateAnnotation, onSelectAnnotation,
AnnotationToolbarComponent, AnnotationLayerComponent,
}: DocumentViewerProps) {
return (
<div className="w-2/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
{/* Toolbar */}
<AnnotationToolbarComponent
selectedTool={selectedTool}
onSelectTool={onSelectTool}
zoom={zoom}
onZoomChange={onZoomChange}
annotationCounts={annotationCounts}
/>
{/* Document display with annotation overlay */}
<div className="flex-1 overflow-auto p-4 bg-slate-100">
{documentUrl ? (
<div
className="mx-auto bg-white shadow-lg relative"
style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'top center' }}
>
{student?.file_path?.endsWith('.pdf') ? (
<iframe
src={documentUrl}
className="w-full h-[800px] border-0"
title="Studentenarbeit"
/>
) : (
<div className="relative">
<img
src={documentUrl}
alt="Studentenarbeit"
className="max-w-full"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-document.png'
}}
/>
<AnnotationLayerComponent
annotations={annotations.filter((ann) => ann.page === currentPage)}
selectedTool={selectedTool}
onCreateAnnotation={onCreateAnnotation}
onSelectAnnotation={onSelectAnnotation}
selectedAnnotationId={selectedAnnotation?.id}
/>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-full text-slate-400">
Kein Dokument verfuegbar
</div>
)}
</div>
{/* Page navigation */}
<div className="border-t border-slate-200 p-2 flex items-center justify-center gap-2 bg-slate-50">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm">
Seite {currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* OCR Text (collapsible) */}
{student?.ocr_text && (
<details className="border-t border-slate-200">
<summary className="p-3 bg-slate-50 cursor-pointer text-sm font-medium text-slate-600 hover:bg-slate-100">
OCR-Text anzeigen
</summary>
<div className="p-4 max-h-48 overflow-auto text-sm text-slate-700 bg-slate-50">
<pre className="whitespace-pre-wrap font-sans">{student.ocr_text}</pre>
</div>
</details>
)}
</div>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
/**
* Einigung (Consensus) Modal.
* Shown when first and second examiner grade difference requires manual resolution.
*/
import type { ExaminerWorkflow } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
interface EinigungModalProps {
workflow: ExaminerWorkflow
einigungGrade: number
einigungNotes: string
submittingWorkflow: boolean
onGradeChange: (grade: number) => void
onNotesChange: (notes: string) => void
onSubmit: (type: 'agreed' | 'split' | 'escalated') => void
onClose: () => void
}
export default function EinigungModal({
workflow, einigungGrade, einigungNotes, submittingWorkflow,
onGradeChange, onNotesChange, onSubmit, onClose,
}: EinigungModalProps) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold mb-4">Einigung erforderlich</h3>
{/* Grade comparison */}
<div className="bg-slate-50 rounded-lg p-4 mb-4">
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-sm text-slate-500">Erstkorrektor</div>
<div className="text-2xl font-bold text-blue-600">
{workflow.first_result?.grade_points || '-'} P
</div>
</div>
<div>
<div className="text-sm text-slate-500">Zweitkorrektor</div>
<div className="text-2xl font-bold text-amber-600">
{workflow.second_result?.grade_points || '-'} P
</div>
</div>
</div>
<div className="text-center mt-2 text-sm text-slate-500">
Differenz: {workflow.grade_difference} Punkte
</div>
</div>
{/* Final grade selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Endnote festlegen
</label>
<input
type="range"
min={Math.min(workflow.first_result?.grade_points || 0, workflow.second_result?.grade_points || 0) - 1}
max={Math.max(workflow.first_result?.grade_points || 15, workflow.second_result?.grade_points || 15) + 1}
value={einigungGrade}
onChange={(e) => onGradeChange(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-center text-2xl font-bold mt-2">
{einigungGrade} Punkte ({GRADE_LABELS[einigungGrade] || '-'})
</div>
</div>
{/* Notes */}
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Begruendung
</label>
<textarea
value={einigungNotes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Begruendung fuer die Einigung..."
className="w-full p-2 border border-slate-300 rounded-lg text-sm"
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => onSubmit('agreed')}
disabled={submittingWorkflow || !einigungNotes}
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Einigung bestaetigen
</button>
<button
onClick={() => onSubmit('escalated')}
disabled={submittingWorkflow}
className="py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
>
Eskalieren
</button>
<button
onClick={onClose}
className="py-2 px-4 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200"
>
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
'use client'
/**
* Error banner component for displaying dismissible error messages.
*/
interface ErrorBannerProps {
error: string
onDismiss: () => void
}
export default function ErrorBanner({ error, onDismiss }: ErrorBannerProps) {
return (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-800">{error}</span>
<button onClick={onDismiss} className="ml-auto text-red-600 hover:text-red-800">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
}

View File

@@ -0,0 +1,208 @@
'use client'
/**
* Create new Klausur form tab.
* Supports both Abitur and Vorabitur modes with EH template selection.
*/
import type { TabId, CreateKlausurForm, VorabiturEHForm, EHTemplate } from './list-types'
interface ErstellenTabProps {
form: CreateKlausurForm
ehForm: VorabiturEHForm
templates: EHTemplate[]
creating: boolean
loadingTemplates: boolean
onFormChange: (form: CreateKlausurForm) => void
onEhFormChange: (form: VorabiturEHForm) => void
onSubmit: (e: React.FormEvent) => void
onCancel: () => void
}
export default function ErstellenTab({
form, ehForm, templates, creating, loadingTemplates,
onFormChange, onEhFormChange, onSubmit, onCancel,
}: ErstellenTabProps) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
<h2 className="text-lg font-semibold text-slate-800 mb-6">Neue Klausur erstellen</h2>
<form onSubmit={onSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel der Klausur *</label>
<input
type="text"
value={form.title}
onChange={(e) => onFormChange({ ...form, title: e.target.value })}
placeholder="z.B. Deutsch LK Abitur 2025 - Kurs D1"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
required
/>
</div>
{/* Subject + Year */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Fach</label>
<select
value={form.subject}
onChange={(e) => onFormChange({ ...form, subject: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="Deutsch">Deutsch</option>
<option value="Englisch">Englisch</option>
<option value="Mathematik">Mathematik</option>
<option value="Geschichte">Geschichte</option>
<option value="Biologie">Biologie</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Jahr</label>
<input
type="number"
value={form.year}
onChange={(e) => onFormChange({ ...form, year: parseInt(e.target.value) })}
min={2020} max={2030}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
{/* Semester + Modus */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Semester / Pruefung</label>
<select
value={form.semester}
onChange={(e) => onFormChange({ ...form, semester: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="Abitur">Abitur</option>
<option value="Q1">Q1 (11/1)</option>
<option value="Q2">Q2 (11/2)</option>
<option value="Q3">Q3 (12/1)</option>
<option value="Q4">Q4 (12/2)</option>
<option value="Vorabitur">Vorabitur</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Modus</label>
<select
value={form.modus}
onChange={(e) => onFormChange({ ...form, modus: e.target.value as 'abitur' | 'vorabitur' })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="abitur">Abitur (mit offiziellem EH)</option>
<option value="vorabitur">Vorabitur (eigener EH)</option>
</select>
</div>
</div>
{/* Vorabitur EH Form */}
{form.modus === 'vorabitur' && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">Eigenen Erwartungshorizont erstellen</p>
<p>Waehlen Sie einen Aufgabentyp und beschreiben Sie die Aufgabenstellung. Der EH wird automatisch mit Ihrer Klausur verknuepft.</p>
</div>
</div>
{/* Aufgabentyp */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabentyp *</label>
{loadingTemplates ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Lade Vorlagen...
</div>
) : (
<select
value={ehForm.aufgabentyp}
onChange={(e) => onEhFormChange({ ...ehForm, aufgabentyp: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white"
>
<option value="">-- Aufgabentyp waehlen --</option>
{templates.map(t => (
<option key={t.aufgabentyp} value={t.aufgabentyp}>{t.name}</option>
))}
</select>
)}
{ehForm.aufgabentyp && templates.find(t => t.aufgabentyp === ehForm.aufgabentyp) && (
<p className="mt-1 text-xs text-slate-500">
{templates.find(t => t.aufgabentyp === ehForm.aufgabentyp)?.description}
</p>
)}
</div>
{/* Text Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Texttitel (optional)</label>
<input
type="text"
value={ehForm.text_titel}
onChange={(e) => onEhFormChange({ ...ehForm, text_titel: e.target.value })}
placeholder="z.B. 'Die Verwandlung'"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor (optional)</label>
<input
type="text"
value={ehForm.text_autor}
onChange={(e) => onEhFormChange({ ...ehForm, text_autor: e.target.value })}
placeholder="z.B. 'Franz Kafka'"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
{/* Aufgabenstellung */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabenstellung *</label>
<textarea
value={ehForm.aufgabenstellung}
onChange={(e) => onEhFormChange({ ...ehForm, aufgabenstellung: e.target.value })}
placeholder="Beschreiben Sie hier die konkrete Aufgabenstellung fuer die Schueler..."
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
/>
<p className="mt-1 text-xs text-slate-500">
Die Aufgabenstellung wird zusammen mit dem Template in den Erwartungshorizont eingebunden.
</p>
</div>
</div>
)}
{/* Submit */}
<div className="flex gap-3 pt-4">
<button type="button" onClick={onCancel} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">
Abbrechen
</button>
<button
type="submit"
disabled={creating}
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{creating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Erstelle...
</>
) : (
'Klausur erstellen'
)}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
/**
* Klausuren list tab - shows all exams in a grid with progress bars.
*/
import Link from 'next/link'
import type { Klausur } from '../../app/admin/klausur-korrektur/types'
import type { TabId } from './list-types'
interface KlausurenTabProps {
klausuren: Klausur[]
loading: boolean
basePath: string
onNavigate: (tab: TabId) => void
onDelete: (id: string) => void
}
export default function KlausurenTab({
klausuren, loading, basePath, onNavigate, onDelete,
}: KlausurenTabProps) {
return (
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold text-slate-800">Alle Klausuren</h2>
<p className="text-sm text-slate-500">{klausuren.length} Klausuren insgesamt</p>
</div>
<button
onClick={() => onNavigate('erstellen')}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Klausur
</button>
</div>
{/* Klausuren Grid */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : klausuren.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg border border-slate-200">
<svg className="mx-auto h-12 w-12 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-slate-900">Keine Klausuren</h3>
<p className="mt-1 text-sm text-slate-500">Erstellen Sie Ihre erste Klausur zum Korrigieren.</p>
<button
onClick={() => onNavigate('erstellen')}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Klausur erstellen
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{klausuren.map((klausur) => (
<div key={klausur.id} className="bg-white rounded-lg border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="p-4">
<div className="flex justify-between items-start mb-3">
<div>
<h3 className="font-semibold text-slate-800 truncate">{klausur.title}</h3>
<p className="text-sm text-slate-500">{klausur.subject} - {klausur.year}</p>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
klausur.modus === 'abitur' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'
}`}>
{klausur.modus === 'abitur' ? 'Abitur' : 'Vorabitur'}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600 mb-4">
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span>{klausur.student_count || 0} Arbeiten</span>
</div>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{klausur.completed_count || 0} fertig</span>
</div>
</div>
{(klausur.student_count || 0) > 0 && (
<div className="mb-4">
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>Fortschritt</span>
<span>{Math.round(((klausur.completed_count || 0) / (klausur.student_count || 1)) * 100)}%</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${((klausur.completed_count || 0) / (klausur.student_count || 1)) * 100}%` }}
/>
</div>
</div>
)}
<div className="flex gap-2">
<Link
href={`${basePath}/${klausur.id}`}
className="flex-1 px-3 py-2 bg-primary-600 text-white text-sm text-center rounded-lg hover:bg-primary-700"
>
Korrigieren
</Link>
<button
onClick={() => onDelete(klausur.id)}
className="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg"
title="Loeschen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
/**
* Tab navigation bar for the Klausur-Korrektur list page.
*/
import type { TabId } from './list-types'
interface TabDef {
id: TabId
name: string
icon: JSX.Element
hidden?: boolean
}
const tabs: TabDef[] = [
{
id: 'willkommen', name: 'Start',
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>,
},
{
id: 'klausuren', name: 'Klausuren',
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>,
},
{
id: 'erstellen', name: 'Neue Klausur',
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>,
},
{
id: 'direktupload', name: 'Schnellstart', hidden: true,
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>,
},
{
id: 'statistiken', name: 'Statistiken',
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" /></svg>,
},
]
interface ListTabNavProps {
activeTab: TabId
onTabChange: (tab: TabId) => void
markAsVisited: () => void
}
export default function ListTabNav({ activeTab, onTabChange, markAsVisited }: ListTabNavProps) {
return (
<div className="border-b border-slate-200 mb-6">
<nav className="flex gap-4">
{tabs.filter(tab => !tab.hidden).map((tab) => (
<button
key={tab.id}
onClick={() => {
if (tab.id !== 'willkommen') markAsVisited()
onTabChange(tab.id)
}}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
{tab.icon}
{tab.name}
</button>
))}
</nav>
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
/**
* Statistics tab for the Klausur-Korrektur page.
* Shows summary cards and grade criteria info.
*/
import type { Klausur, GradeInfo } from '../../app/admin/klausur-korrektur/types'
interface StatistikenTabProps {
klausuren: Klausur[]
gradeInfo: GradeInfo | null
}
export default function StatistikenTab({ klausuren, gradeInfo }: StatistikenTabProps) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-slate-800">Korrektur-Statistiken</h2>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-800">{klausuren.length}</div>
<div className="text-sm text-slate-500">Klausuren</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-800">
{klausuren.reduce((sum, k) => sum + (k.student_count || 0), 0)}
</div>
<div className="text-sm text-slate-500">Studentenarbeiten</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">
{klausuren.reduce((sum, k) => sum + (k.completed_count || 0), 0)}
</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-orange-600">
{klausuren.reduce((sum, k) => sum + ((k.student_count || 0) - (k.completed_count || 0)), 0)}
</div>
<div className="text-sm text-slate-500">Ausstehend</div>
</div>
</div>
{/* Grade Info */}
{gradeInfo && (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-semibold text-slate-800 mb-4">Bewertungskriterien (Niedersachsen)</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{Object.entries(gradeInfo.criteria || {}).map(([key, criterion]) => (
<div key={key} className="text-center p-3 bg-slate-50 rounded-lg">
<div className="text-lg font-semibold text-slate-700">{criterion.weight}%</div>
<div className="text-sm text-slate-500">{criterion.name}</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,151 @@
'use client'
/**
* Welcome/Onboarding tab for the Klausur-Korrektur page.
* Shows hero, workflow explanation, and action cards.
*/
import type { Klausur } from '../../app/admin/klausur-korrektur/types'
import type { TabId } from './list-types'
interface WillkommenTabProps {
klausuren: Klausur[]
onNavigate: (tab: TabId) => void
markAsVisited: () => void
}
export default function WillkommenTab({ klausuren, onNavigate, markAsVisited }: WillkommenTabProps) {
const goTo = (tab: TabId) => { markAsVisited(); onNavigate(tab) }
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* Hero Section */}
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-purple-500 to-purple-700 rounded-2xl mb-6">
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-slate-800 mb-3">Willkommen zur Abiturklausur-Korrektur</h1>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
KI-gestuetzte Korrektur fuer Deutsch-Abiturklausuren nach dem 15-Punkte-System.
Sparen Sie bis zu 80% Zeit bei der Erstkorrektur.
</p>
</div>
{/* Workflow Explanation */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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 2" />
</svg>
So funktioniert es
</h2>
<div className="grid md:grid-cols-4 gap-4">
{[
{ step: 1, title: 'Arbeiten hochladen', desc: 'Scans der Schuelerarbeiten als PDF oder Bilder hochladen' },
{ step: 2, title: 'EH bereitstellen', desc: 'Erwartungshorizont hochladen oder aus Vorlage erstellen' },
{ step: 3, title: 'KI korrigiert', desc: 'Automatische Bewertung und Gutachten-Vorschlaege erhalten' },
{ step: 4, title: 'Pruefen & Anpassen', desc: 'Vorschlaege pruefen, anpassen und finalisieren' },
].map(({ step, title, desc }) => (
<div key={step} className="text-center">
<div className="text-xs text-blue-600 font-medium mb-1">Schritt {step}</div>
<div className="font-medium text-slate-800 text-sm">{title}</div>
<div className="text-xs text-slate-500 mt-1">{desc}</div>
</div>
))}
</div>
</div>
{/* Action Cards */}
<div className="grid md:grid-cols-2 gap-6">
{/* Option 1: Standard Flow */}
<div className="bg-white border-2 border-slate-200 rounded-xl p-6 hover:border-purple-300 hover:shadow-lg transition-all cursor-pointer"
onClick={() => goTo('erstellen')}>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-800 mb-1">Neue Klausur erstellen</h3>
<p className="text-sm text-slate-600 mb-3">
Empfohlen fuer regelmaessige Nutzung. Erstellen Sie eine Klausur mit allen Metadaten,
laden Sie dann die Arbeiten hoch.
</p>
<ul className="text-xs text-slate-500 space-y-1">
{['Volle Metadaten (Fach, Jahr, Kurs)', 'Zweitkorrektur-Workflow', 'Fairness-Analyse'].map(text => (
<li key={text} className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
{text}
</li>
))}
</ul>
<div className="mt-4 text-sm text-purple-600 font-medium flex items-center gap-1">
Klausur erstellen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
{/* Option 2: Quick Upload */}
<div className="bg-white border-2 border-slate-200 rounded-xl p-6 hover:border-blue-300 hover:shadow-lg transition-all cursor-pointer"
onClick={() => goTo('direktupload')}>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-800 mb-1">Schnellstart - Direkt hochladen</h3>
<p className="text-sm text-slate-600 mb-3">
Ideal wenn Sie sofort loslegen moechten. Laden Sie Arbeiten und EH direkt hoch,
wir erstellen die Klausur automatisch.
</p>
<ul className="text-xs text-slate-500 space-y-1">
{['Schnellster Weg zum Korrigieren', 'Drag & Drop Upload', 'Sofort einsatzbereit'].map(text => (
<li key={text} className="flex items-center gap-1">
<svg className="w-3 h-3 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" /></svg>
{text}
</li>
))}
</ul>
<div className="mt-4 text-sm text-blue-600 font-medium flex items-center gap-1">
Schnellstart
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
</div>
{/* Already have klausuren? */}
{klausuren.length > 0 && (
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 flex items-center justify-between">
<div>
<p className="font-medium text-slate-800">Sie haben {klausuren.length} Klausur{klausuren.length !== 1 ? 'en' : ''}</p>
<p className="text-sm text-slate-500">Setzen Sie Ihre Arbeit fort oder starten Sie eine neue Korrektur.</p>
</div>
<button
onClick={() => goTo('klausuren')}
className="px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 text-sm"
>
Zu meinen Klausuren
</button>
</div>
)}
{/* Help Links */}
<div className="text-center text-sm text-slate-500">
<p>Fragen? Lesen Sie unsere <button className="text-purple-600 hover:underline">Dokumentation</button> oder kontaktieren Sie den <button className="text-purple-600 hover:underline">Support</button>.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,165 @@
'use client'
/**
* Workflow-aware action buttons for the criteria panel.
* Handles Erstkorrektur, Zweitkorrektur, Einigung, and completed states.
*/
import type { ExaminerWorkflow } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
interface WorkflowActionsProps {
workflow: ExaminerWorkflow | null
gutachten: string
generatingGutachten: boolean
submittingWorkflow: boolean
totals: { gradePoints: number }
onGenerateGutachten: () => void
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (id: string) => void
onSubmitZweitkorrektur: () => void
onShowEinigungModal: () => void
}
export default function WorkflowActions({
workflow, gutachten, generatingGutachten, submittingWorkflow, totals,
onGenerateGutachten, onSubmitErstkorrektur, onStartZweitkorrektur,
onSubmitZweitkorrektur, onShowEinigungModal,
}: WorkflowActionsProps) {
return (
<div className="space-y-2">
{/* Generate Gutachten button */}
<button
onClick={onGenerateGutachten}
disabled={generatingGutachten}
className="w-full py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{generatingGutachten ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-slate-700"></div>
Generiere Gutachten...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Gutachten generieren
</>
)}
</button>
{/* Erstkorrektur */}
{(!workflow || workflow.workflow_status === 'not_started' || workflow.workflow_status === 'ek_in_progress') && (
<button
onClick={onSubmitErstkorrektur}
disabled={submittingWorkflow || !gutachten}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{submittingWorkflow ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird abgeschlossen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Erstkorrektur abschliessen
</>
)}
</button>
)}
{/* Start Zweitkorrektur */}
{workflow?.workflow_status === 'ek_completed' && workflow.user_role === 'ek' && (
<button
onClick={() => {
const zkId = prompt('Zweitkorrektor-ID eingeben:')
if (zkId) onStartZweitkorrektur(zkId)
}}
disabled={submittingWorkflow}
className="w-full py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
Zur Zweitkorrektur weiterleiten
</button>
)}
{/* Submit Zweitkorrektur */}
{(workflow?.workflow_status === 'zk_assigned' || workflow?.workflow_status === 'zk_in_progress') &&
workflow?.user_role === 'zk' && (
<button
onClick={onSubmitZweitkorrektur}
disabled={submittingWorkflow || !gutachten}
className="w-full py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{submittingWorkflow ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird abgeschlossen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Zweitkorrektur abschliessen
</>
)}
</button>
)}
{/* Einigung */}
{workflow?.workflow_status === 'einigung_required' && (
<button
onClick={onShowEinigungModal}
className="w-full py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
Einigung starten
</button>
)}
{/* Completed */}
{workflow?.workflow_status === 'completed' && (
<div className="bg-green-100 text-green-800 p-4 rounded-lg text-center">
<div className="text-2xl font-bold">
Endnote: {workflow.final_grade} Punkte
</div>
<div className="text-sm mt-1">
({GRADE_LABELS[workflow.final_grade || 0]}) - {workflow.consensus_type === 'auto' ? 'Auto-Konsens' : workflow.consensus_type === 'drittkorrektur' ? 'Drittkorrektur' : 'Einigung'}
</div>
</div>
)}
{/* EK/ZK comparison */}
{workflow?.first_result && workflow?.second_result && workflow?.workflow_status !== 'completed' && (
<div className="bg-slate-50 rounded-lg p-3 mt-2">
<div className="text-xs text-slate-500 mb-2">Notenvergleich</div>
<div className="flex justify-between">
<div className="text-center">
<div className="text-sm text-slate-500">EK</div>
<div className="font-bold text-blue-600">{workflow.first_result.grade_points}P</div>
</div>
<div className="text-center">
<div className="text-sm text-slate-500">ZK</div>
<div className="font-bold text-amber-600">{workflow.second_result.grade_points}P</div>
</div>
<div className="text-center">
<div className="text-sm text-slate-500">Diff</div>
<div className={`font-bold ${(workflow.grade_difference || 0) >= 4 ? 'text-red-600' : 'text-slate-700'}`}>
{workflow.grade_difference}P
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
/**
* Top navigation bar for the Korrektur-Workspace.
* Shows back link, student navigation, workflow status, and grade.
*/
import Link from 'next/link'
import type { ExaminerWorkflow } from './workspace-types'
import { WORKFLOW_STATUS_LABELS, ROLE_LABELS, GRADE_LABELS } from './workspace-types'
interface WorkspaceTopBarProps {
klausurId: string
backPath: string
currentIndex: number
studentCount: number
workflow: ExaminerWorkflow | null
saving: boolean
totals: { gradePoints: number; weighted: number }
onGoToStudent: (direction: 'prev' | 'next') => void
}
export default function WorkspaceTopBar({
klausurId, backPath, currentIndex, studentCount,
workflow, saving, totals, onGoToStudent,
}: WorkspaceTopBarProps) {
return (
<div className="bg-white border-b border-slate-200 -mx-6 -mt-6 px-6 py-3 mb-4 flex items-center justify-between sticky top-0 z-10">
{/* Back link */}
<Link
href={backPath}
className="text-primary-600 hover:text-primary-800 flex items-center gap-1 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck
</Link>
{/* Student navigation */}
<div className="flex items-center gap-4">
<button
onClick={() => onGoToStudent('prev')}
disabled={currentIndex <= 0}
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-medium">
{currentIndex + 1} / {studentCount}
</span>
<button
onClick={() => onGoToStudent('next')}
disabled={currentIndex >= studentCount - 1}
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Workflow status and role */}
<div className="flex items-center gap-3">
{workflow && (
<div className="flex items-center gap-2">
<span
className={`px-2 py-1 text-xs font-medium rounded-full text-white ${
ROLE_LABELS[workflow.user_role]?.color || 'bg-slate-500'
}`}
>
{ROLE_LABELS[workflow.user_role]?.label || workflow.user_role}
</span>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.color || 'bg-slate-100'
}`}
>
{WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.label || workflow.workflow_status}
</span>
{workflow.user_role === 'zk' && workflow.visibility_mode !== 'full' && (
<span className="px-2 py-1 text-xs bg-purple-100 text-purple-700 rounded-full">
{workflow.visibility_mode === 'blind' ? 'Blind-Modus' : 'Semi-Blind'}
</span>
)}
</div>
)}
{saving && (
<span className="text-sm text-slate-500 flex items-center gap-1">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary-600"></div>
Speichern...
</span>
)}
<div className="text-right">
<div className="text-lg font-bold text-slate-800">
{totals.gradePoints} Punkte
</div>
<div className="text-sm text-slate-500">
Note: {GRADE_LABELS[totals.gradePoints] || '-'}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
/**
* Types and constants for the Klausur-Korrektur list page.
* Shared between admin and lehrer routes.
*/
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export type TabId = 'willkommen' | 'klausuren' | 'erstellen' | 'direktupload' | 'statistiken'
export interface CreateKlausurForm {
title: string
subject: string
year: number
semester: string
modus: 'abitur' | 'vorabitur'
}
export interface VorabiturEHForm {
aufgabentyp: string
titel: string
text_titel: string
text_autor: string
aufgabenstellung: string
}
export interface EHTemplate {
aufgabentyp: string
name: string
description: string
category: string
}
export interface DirektuploadForm {
files: File[]
ehFile: File | null
ehText: string
aufgabentyp: string
klausurTitle: string
}

View File

@@ -0,0 +1,239 @@
'use client'
/**
* Custom hook for the Klausur-Korrektur list page.
* Encapsulates all state and data fetching logic.
*/
import { useState, useEffect, useCallback } from 'react'
import type { Klausur, GradeInfo } from '../../app/admin/klausur-korrektur/types'
import type {
TabId, CreateKlausurForm, VorabiturEHForm, EHTemplate, DirektuploadForm,
} from './list-types'
import { API_BASE } from './list-types'
interface UseKlausurListArgs {
/** Base route path for navigation, e.g. '/admin/klausur-korrektur' or '/lehrer/klausur-korrektur' */
basePath: string
}
export function useKlausurList({ basePath }: UseKlausurListArgs) {
const [activeTab, setActiveTab] = useState<TabId>(() => {
if (typeof window !== 'undefined') {
const hasVisited = localStorage.getItem('klausur_korrektur_visited')
return hasVisited ? 'klausuren' : 'willkommen'
}
return 'willkommen'
})
const [klausuren, setKlausuren] = useState<Klausur[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [gradeInfo, setGradeInfo] = useState<GradeInfo | null>(null)
// Vorabitur templates
const [templates, setTemplates] = useState<EHTemplate[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
// Create form state
const [form, setForm] = useState<CreateKlausurForm>({
title: '', subject: 'Deutsch', year: new Date().getFullYear(),
semester: 'Abitur', modus: 'abitur',
})
const [ehForm, setEhForm] = useState<VorabiturEHForm>({
aufgabentyp: '', titel: '', text_titel: '', text_autor: '', aufgabenstellung: '',
})
// Direktupload form
const [direktForm, setDirektForm] = useState<DirektuploadForm>({
files: [], ehFile: null, ehText: '', aufgabentyp: '',
klausurTitle: `Schnellkorrektur ${new Date().toLocaleDateString('de-DE')}`,
})
const [direktStep, setDirektStep] = useState<1 | 2 | 3>(1)
const [uploading, setUploading] = useState(false)
// Fetch klausuren
const fetchKlausuren = useCallback(async () => {
try {
setLoading(true)
const res = await fetch(`${API_BASE}/api/v1/klausuren`)
if (res.ok) {
const data = await res.json()
setKlausuren(Array.isArray(data) ? data : data.klausuren || [])
setError(null)
} else {
setError(`Fehler beim Laden: ${res.status}`)
}
} catch (err) {
console.error('Failed to fetch klausuren:', err)
setError('Verbindung zum Klausur-Service fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
// Fetch grade info
const fetchGradeInfo = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/grade-info`)
if (res.ok) setGradeInfo(await res.json())
} catch (err) {
console.error('Failed to fetch grade info:', err)
}
}, [])
// Fetch templates
const fetchTemplates = useCallback(async () => {
try {
setLoadingTemplates(true)
const res = await fetch(`${API_BASE}/api/v1/vorabitur/templates`)
if (res.ok) {
const data = await res.json()
setTemplates(data.templates || [])
}
} catch (err) {
console.error('Failed to fetch templates:', err)
} finally {
setLoadingTemplates(false)
}
}, [])
useEffect(() => { fetchKlausuren(); fetchGradeInfo() }, [fetchKlausuren, fetchGradeInfo])
useEffect(() => {
if (form.modus === 'vorabitur' && templates.length === 0) fetchTemplates()
}, [form.modus, templates.length, fetchTemplates])
const markAsVisited = () => {
if (typeof window !== 'undefined') localStorage.setItem('klausur_korrektur_visited', 'true')
}
// Create new Klausur
const handleCreateKlausur = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.title.trim()) { setError('Bitte einen Titel eingeben'); return }
if (form.modus === 'vorabitur') {
if (!ehForm.aufgabentyp) { setError('Bitte einen Aufgabentyp auswaehlen'); return }
if (!ehForm.aufgabenstellung.trim()) { setError('Bitte die Aufgabenstellung eingeben'); return }
}
try {
setCreating(true)
const res = await fetch(`${API_BASE}/api/v1/klausuren`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!res.ok) {
const errorData = await res.json()
setError(errorData.detail || 'Fehler beim Erstellen'); return
}
const newKlausur = await res.json()
if (form.modus === 'vorabitur') {
const ehRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/vorabitur-eh`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aufgabentyp: ehForm.aufgabentyp, titel: ehForm.titel || `EH: ${form.title}`,
text_titel: ehForm.text_titel || null, text_autor: ehForm.text_autor || null,
aufgabenstellung: ehForm.aufgabenstellung,
}),
})
if (!ehRes.ok) {
console.error('Failed to create EH:', await ehRes.text())
setError('Klausur erstellt, aber Erwartungshorizont konnte nicht erstellt werden.')
}
}
setKlausuren(prev => [newKlausur, ...prev])
setForm({ title: '', subject: 'Deutsch', year: new Date().getFullYear(), semester: 'Abitur', modus: 'abitur' })
setEhForm({ aufgabentyp: '', titel: '', text_titel: '', text_autor: '', aufgabenstellung: '' })
setActiveTab('klausuren')
if (!error) setError(null)
} catch (err) {
console.error('Failed to create klausur:', err)
setError('Fehler beim Erstellen der Klausur')
} finally {
setCreating(false)
}
}
// Delete Klausur
const handleDeleteKlausur = async (id: string) => {
if (!confirm('Klausur wirklich loeschen? Alle Studentenarbeiten werden ebenfalls geloescht.')) return
try {
const res = await fetch(`${API_BASE}/api/v1/klausuren/${id}`, { method: 'DELETE' })
if (res.ok) setKlausuren(prev => prev.filter(k => k.id !== id))
else setError('Fehler beim Loeschen')
} catch (err) {
console.error('Failed to delete klausur:', err)
setError('Fehler beim Loeschen der Klausur')
}
}
// Direktupload
const handleDirektupload = async () => {
if (direktForm.files.length === 0) { setError('Bitte mindestens eine Arbeit hochladen'); return }
try {
setUploading(true)
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: direktForm.klausurTitle, subject: 'Deutsch',
year: new Date().getFullYear(), semester: 'Vorabitur', modus: 'vorabitur',
}),
})
if (!klausurRes.ok) {
const err = await klausurRes.json()
throw new Error(err.detail || 'Klausur erstellen fehlgeschlagen')
}
const newKlausur = await klausurRes.json()
if (direktForm.ehText.trim() || direktForm.aufgabentyp) {
const ehRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/vorabitur-eh`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aufgabentyp: direktForm.aufgabentyp || 'textanalyse_pragmatisch',
titel: `EH: ${direktForm.klausurTitle}`,
aufgabenstellung: direktForm.ehText || 'Individuelle Aufgabenstellung',
}),
})
if (!ehRes.ok) console.error('EH creation failed, continuing with upload')
}
for (let i = 0; i < direktForm.files.length; i++) {
const file = direktForm.files[i]
const formData = new FormData()
formData.append('file', file)
formData.append('anonym_id', `Arbeit-${i + 1}`)
const uploadRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/students`, {
method: 'POST', body: formData,
})
if (!uploadRes.ok) console.error(`Upload failed for file ${i + 1}:`, file.name)
}
setKlausuren(prev => [newKlausur, ...prev])
markAsVisited()
window.location.href = `${basePath}/${newKlausur.id}`
} catch (err) {
console.error('Direktupload failed:', err)
setError(err instanceof Error ? err.message : 'Fehler beim Direktupload')
} finally {
setUploading(false)
}
}
return {
// Data
klausuren, gradeInfo, templates,
// UI state
activeTab, loading, error, creating, loadingTemplates,
form, ehForm, direktForm, direktStep, uploading,
// Setters
setActiveTab, setError, setForm, setEhForm, setDirektForm, setDirektStep,
// Actions
markAsVisited, handleCreateKlausur, handleDeleteKlausur, handleDirektupload,
// Route config
basePath,
}
}

View File

@@ -0,0 +1,471 @@
'use client'
/**
* Custom hook for the Korrektur-Workspace.
* Encapsulates all state, data fetching, and actions.
*/
import { useState, useEffect, useCallback, useMemo } from 'react'
import type {
Klausur,
StudentWork,
Annotation,
CriteriaScores,
GradeInfo,
AnnotationType,
AnnotationPosition,
} from '../../app/admin/klausur-korrektur/types'
import type { ExaminerWorkflow, ActiveTab } from './workspace-types'
import { API_BASE } from './workspace-types'
interface UseKorrekturWorkspaceArgs {
klausurId: string
studentId: string
}
export function useKorrekturWorkspace({ klausurId, studentId }: UseKorrekturWorkspaceArgs) {
// Core state
const [klausur, setKlausur] = useState<Klausur | null>(null)
const [student, setStudent] = useState<StudentWork | null>(null)
const [students, setStudents] = useState<StudentWork[]>([])
const [annotations, setAnnotations] = useState<Annotation[]>([])
const [gradeInfo, setGradeInfo] = useState<GradeInfo | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<ActiveTab>('kriterien')
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [zoom, setZoom] = useState(100)
const [documentUrl, setDocumentUrl] = useState<string | null>(null)
const [generatingGutachten, setGeneratingGutachten] = useState(false)
const [exporting, setExporting] = useState(false)
// Annotation state
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null)
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null)
// Form state
const [criteriaScores, setCriteriaScores] = useState<CriteriaScores>({})
const [gutachten, setGutachten] = useState('')
// Examiner workflow state
const [workflow, setWorkflow] = useState<ExaminerWorkflow | null>(null)
const [showEinigungModal, setShowEinigungModal] = useState(false)
const [einigungGrade, setEinigungGrade] = useState<number>(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<AnnotationType, number> = {
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)
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
if (klausurRes.ok) setKlausur(await klausurRes.json())
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 || [])
}
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 || '')
}
const gradeInfoRes = await fetch(`${API_BASE}/api/v1/grade-info`)
if (gradeInfoRes.ok) setGradeInfo(await gradeInfoRes.json())
const workflowRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner-workflow`)
if (workflowRes.ok) {
const workflowData = await workflowRes.json()
setWorkflow(workflowData)
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)
}
}
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 || [])
}
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')
} 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<Annotation>) => {
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) setStudent(await res.json())
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) setStudent(await res.json())
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 helpers
const downloadBlob = useCallback((blob: Blob, filename: string) => {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, [])
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()
downloadBlob(blob, `Gutachten_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf`)
} 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, downloadBlob])
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()
downloadBlob(blob, `Anmerkungen_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf`)
} 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, downloadBlob])
// Handle criteria change
const handleCriteriaChange = (criterion: string, value: number) => {
const newScores = { ...criteriaScores, [criterion]: value }
setCriteriaScores(newScores)
saveCriteriaScores(newScores)
}
// Calculate total points
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
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
const submitErstkorrektur = useCallback(async () => {
try {
setSubmittingWorkflow(true)
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', examiner_role: 'first_examiner' }),
})
if (!assignRes.ok && assignRes.status !== 400) {
const error = await assignRes.json()
throw new Error(error.detail || 'Fehler bei der Zuweisung')
}
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) { 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()
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])
return {
// Data
klausur, student, students, annotations, gradeInfo, workflow, documentUrl,
// UI state
loading, saving, error, activeTab, currentPage, totalPages, zoom,
generatingGutachten, exporting, selectedTool, selectedAnnotation,
criteriaScores, gutachten, showEinigungModal, einigungGrade, einigungNotes,
submittingWorkflow, currentIndex, annotationCounts, totals,
// Setters
setError, setActiveTab, setCurrentPage, setZoom, setSelectedTool,
setSelectedAnnotation, setGutachten, setShowEinigungModal,
setEinigungGrade, setEinigungNotes, setCriteriaScores,
// Actions
createAnnotation, updateAnnotation, deleteAnnotation,
handleCriteriaChange, saveCriteriaScores, saveGutachten, generateGutachten,
exportGutachtenPDF, exportAnnotationsPDF,
submitErstkorrektur, startZweitkorrektur, submitZweitkorrektur, submitEinigung,
fetchData,
}
}

View File

@@ -0,0 +1,81 @@
/**
* Types and constants for the Korrektur-Workspace.
* Shared between admin and lehrer routes.
*/
import type { CriteriaScores } from '../../app/admin/klausur-korrektur/types'
// Examiner workflow types
export interface ExaminerInfo {
id: string
assigned_at: string
notes?: string
}
export interface ExaminerResult {
grade_points: number
criteria_scores?: CriteriaScores
notes?: string
submitted_at: string
}
export 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
}
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
// Workflow status labels
export const WORKFLOW_STATUS_LABELS: Record<string, { label: string; color: string }> = {
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' },
}
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
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' },
}
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export const GRADE_LABELS: Record<number, string> = {
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',
}