[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:
+43
-1126
File diff suppressed because it is too large
Load Diff
@@ -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 []
|
||||||
@@ -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"
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+54
-1223
File diff suppressed because it is too large
Load Diff
@@ -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)}")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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] = []
|
||||||
@@ -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
@@ -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))
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
+34
-1128
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)}")
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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)',
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: '⚙️' },
|
||||||
|
]
|
||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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