[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:
File diff suppressed because it is too large
Load Diff
189
backend-lehrer/game_extended_routes.py
Normal file
189
backend-lehrer/game_extended_routes.py
Normal 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 []
|
||||
322
backend-lehrer/game_models.py
Normal file
322
backend-lehrer/game_models.py
Normal 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"
|
||||
),
|
||||
]
|
||||
296
backend-lehrer/game_routes.py
Normal file
296
backend-lehrer/game_routes.py
Normal 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!"
|
||||
}
|
||||
|
||||
|
||||
395
backend-lehrer/game_session_routes.py
Normal file
395
backend-lehrer/game_session_routes.py
Normal 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
160
backend-lehrer/unit_content_routes.py
Normal file
160
backend-lehrer/unit_content_routes.py
Normal 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)}")
|
||||
301
backend-lehrer/unit_definition_routes.py
Normal file
301
backend-lehrer/unit_definition_routes.py
Normal 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)
|
||||
204
backend-lehrer/unit_helpers.py
Normal file
204
backend-lehrer/unit_helpers.py
Normal 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
|
||||
)
|
||||
149
backend-lehrer/unit_models.py
Normal file
149
backend-lehrer/unit_models.py
Normal 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] = []
|
||||
494
backend-lehrer/unit_routes.py
Normal file
494
backend-lehrer/unit_routes.py
Normal 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
316
klausur-service/backend/admin_nibis.py
Normal file
316
klausur-service/backend/admin_nibis.py
Normal 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))
|
||||
281
klausur-service/backend/admin_rag.py
Normal file
281
klausur-service/backend/admin_rag.py
Normal 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,
|
||||
}
|
||||
389
klausur-service/backend/admin_templates.py
Normal file
389
klausur-service/backend/admin_templates.py
Normal 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))
|
||||
293
klausur-service/backend/ocr_pipeline_columns.py
Normal file
293
klausur-service/backend/ocr_pipeline_columns.py
Normal 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"),
|
||||
}
|
||||
236
klausur-service/backend/ocr_pipeline_deskew.py
Normal file
236
klausur-service/backend/ocr_pipeline_deskew.py
Normal 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}
|
||||
346
klausur-service/backend/ocr_pipeline_dewarp.py
Normal file
346
klausur-service/backend/ocr_pipeline_dewarp.py
Normal 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
299
klausur-service/backend/ocr_pipeline_structure.py
Normal file
299
klausur-service/backend/ocr_pipeline_structure.py
Normal 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
498
klausur-service/backend/rbac_engine.py
Normal file
498
klausur-service/backend/rbac_engine.py
Normal 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
|
||||
221
klausur-service/backend/rbac_permissions.py
Normal file
221
klausur-service/backend/rbac_permissions.py
Normal 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},
|
||||
},
|
||||
}
|
||||
438
klausur-service/backend/rbac_types.py
Normal file
438
klausur-service/backend/rbac_types.py
Normal 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
343
klausur-service/backend/routes/eh_invitations.py
Normal file
343
klausur-service/backend/routes/eh_invitations.py
Normal 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
|
||||
}
|
||||
347
klausur-service/backend/routes/eh_sharing.py
Normal file
347
klausur-service/backend/routes/eh_sharing.py
Normal 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
|
||||
455
klausur-service/backend/routes/eh_upload.py
Normal file
455
klausur-service/backend/routes/eh_upload.py
Normal 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)}")
|
||||
88
website/app/admin/companion/_components/BacklogTab.tsx
Normal file
88
website/app/admin/companion/_components/BacklogTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
website/app/admin/companion/_components/FeaturesTab.tsx
Normal file
76
website/app/admin/companion/_components/FeaturesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
website/app/admin/companion/_components/FeedbackTab.tsx
Normal file
110
website/app/admin/companion/_components/FeedbackTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
website/app/admin/companion/_components/RoadmapTab.tsx
Normal file
84
website/app/admin/companion/_components/RoadmapTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
website/app/admin/companion/_components/StatsOverview.tsx
Normal file
34
website/app/admin/companion/_components/StatsOverview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
404
website/app/admin/companion/_components/data.ts
Normal file
404
website/app/admin/companion/_components/data.ts
Normal 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.',
|
||||
},
|
||||
]
|
||||
119
website/app/admin/companion/_components/system-info.ts
Normal file
119
website/app/admin/companion/_components/system-info.ts
Normal 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)',
|
||||
],
|
||||
}
|
||||
62
website/app/admin/companion/_components/types.ts
Normal file
62
website/app/admin/companion/_components/types.ts
Normal 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',
|
||||
}
|
||||
97
website/app/admin/companion/_components/useCompanionDev.ts
Normal file
97
website/app/admin/companion/_components/useCompanionDev.ts
Normal 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
File diff suppressed because it is too large
Load Diff
143
website/app/admin/magic-help/_components/ArchitectureTab.tsx
Normal file
143
website/app/admin/magic-help/_components/ArchitectureTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
website/app/admin/magic-help/_components/OcrTestTab.tsx
Normal file
104
website/app/admin/magic-help/_components/OcrTestTab.tsx
Normal 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">< 70%</div>
|
||||
<div className="text-sm text-gray-300 mt-1">Niedrige Sicherheit - manuelle Eingabe erforderlich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
website/app/admin/magic-help/_components/OverviewTab.tsx
Normal file
111
website/app/admin/magic-help/_components/OverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
website/app/admin/magic-help/_components/SettingsTab.tsx
Normal file
193
website/app/admin/magic-help/_components/SettingsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
website/app/admin/magic-help/_components/TrainingTab.tsx
Normal file
182
website/app/admin/magic-help/_components/TrainingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
website/app/admin/magic-help/_components/types.ts
Normal file
62
website/app/admin/magic-help/_components/types.ts
Normal 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: '⚙️' },
|
||||
]
|
||||
180
website/app/admin/magic-help/_components/useMagicHelp.tsx
Normal file
180
website/app/admin/magic-help/_components/useMagicHelp.tsx
Normal 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
File diff suppressed because it is too large
Load Diff
227
website/components/klausur-korrektur/CorrectionPanel.tsx
Normal file
227
website/components/klausur-korrektur/CorrectionPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
website/components/klausur-korrektur/CriteriaTab.tsx
Normal file
120
website/components/klausur-korrektur/CriteriaTab.tsx
Normal 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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
285
website/components/klausur-korrektur/DirektuploadTab.tsx
Normal file
285
website/components/klausur-korrektur/DirektuploadTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
website/components/klausur-korrektur/DocumentViewer.tsx
Normal file
139
website/components/klausur-korrektur/DocumentViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
website/components/klausur-korrektur/EinigungModal.tsx
Normal file
110
website/components/klausur-korrektur/EinigungModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
website/components/klausur-korrektur/ErrorBanner.tsx
Normal file
26
website/components/klausur-korrektur/ErrorBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
website/components/klausur-korrektur/ErstellenTab.tsx
Normal file
208
website/components/klausur-korrektur/ErstellenTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
website/components/klausur-korrektur/KlausurenTab.tsx
Normal file
131
website/components/klausur-korrektur/KlausurenTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
website/components/klausur-korrektur/ListTabNav.tsx
Normal file
69
website/components/klausur-korrektur/ListTabNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
website/components/klausur-korrektur/StatistikenTab.tsx
Normal file
62
website/components/klausur-korrektur/StatistikenTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
website/components/klausur-korrektur/WillkommenTab.tsx
Normal file
151
website/components/klausur-korrektur/WillkommenTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
website/components/klausur-korrektur/WorkflowActions.tsx
Normal file
165
website/components/klausur-korrektur/WorkflowActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
website/components/klausur-korrektur/WorkspaceTopBar.tsx
Normal file
108
website/components/klausur-korrektur/WorkspaceTopBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
website/components/klausur-korrektur/list-types.ts
Normal file
39
website/components/klausur-korrektur/list-types.ts
Normal 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
|
||||
}
|
||||
239
website/components/klausur-korrektur/useKlausurList.ts
Normal file
239
website/components/klausur-korrektur/useKlausurList.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
471
website/components/klausur-korrektur/useKorrekturWorkspace.ts
Normal file
471
website/components/klausur-korrektur/useKorrekturWorkspace.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
81
website/components/klausur-korrektur/workspace-types.ts
Normal file
81
website/components/klausur-korrektur/workspace-types.ts
Normal 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',
|
||||
}
|
||||
Reference in New Issue
Block a user