fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
592
backend/worksheets_api.py
Normal file
592
backend/worksheets_api.py
Normal file
@@ -0,0 +1,592 @@
|
||||
"""
|
||||
Worksheets API - REST API für Arbeitsblatt-Generierung.
|
||||
|
||||
Integriert alle Content-Generatoren:
|
||||
- Multiple Choice Questions
|
||||
- Lückentexte (Cloze)
|
||||
- Mindmaps
|
||||
- Quizze (True/False, Matching, Sorting, Open)
|
||||
|
||||
Unterstützt:
|
||||
- H5P-Export für interaktive Inhalte
|
||||
- PDF-Export für Druckversionen
|
||||
- JSON-Export für Frontend-Integration
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from generators import (
|
||||
MultipleChoiceGenerator,
|
||||
ClozeGenerator,
|
||||
MindmapGenerator,
|
||||
QuizGenerator
|
||||
)
|
||||
from generators.mc_generator import Difficulty
|
||||
from generators.cloze_generator import ClozeType
|
||||
from generators.quiz_generator import QuizType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/worksheets",
|
||||
tags=["worksheets"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models
|
||||
# ============================================================================
|
||||
|
||||
class ContentType(str, Enum):
|
||||
"""Verfügbare Content-Typen."""
|
||||
MULTIPLE_CHOICE = "multiple_choice"
|
||||
CLOZE = "cloze"
|
||||
MINDMAP = "mindmap"
|
||||
QUIZ = "quiz"
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
"""Basis-Request für Generierung."""
|
||||
source_text: str = Field(..., min_length=50, description="Quelltext für Generierung")
|
||||
topic: Optional[str] = Field(None, description="Thema/Titel")
|
||||
subject: Optional[str] = Field(None, description="Fach")
|
||||
grade_level: Optional[str] = Field(None, description="Klassenstufe")
|
||||
|
||||
|
||||
class MCGenerateRequest(GenerateRequest):
|
||||
"""Request für Multiple-Choice-Generierung."""
|
||||
num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen")
|
||||
difficulty: str = Field("medium", description="easy, medium, hard")
|
||||
|
||||
|
||||
class ClozeGenerateRequest(GenerateRequest):
|
||||
"""Request für Lückentext-Generierung."""
|
||||
num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken")
|
||||
difficulty: str = Field("medium", description="easy, medium, hard")
|
||||
cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown")
|
||||
|
||||
|
||||
class MindmapGenerateRequest(GenerateRequest):
|
||||
"""Request für Mindmap-Generierung."""
|
||||
max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe")
|
||||
|
||||
|
||||
class QuizGenerateRequest(GenerateRequest):
|
||||
"""Request für Quiz-Generierung."""
|
||||
quiz_types: List[str] = Field(
|
||||
["true_false", "matching"],
|
||||
description="Typen: true_false, matching, sorting, open_ended"
|
||||
)
|
||||
num_items: int = Field(5, ge=1, le=10, description="Items pro Typ")
|
||||
difficulty: str = Field("medium", description="easy, medium, hard")
|
||||
|
||||
|
||||
class BatchGenerateRequest(BaseModel):
|
||||
"""Request für Batch-Generierung mehrerer Content-Typen."""
|
||||
source_text: str = Field(..., min_length=50)
|
||||
content_types: List[str] = Field(..., description="Liste von Content-Typen")
|
||||
topic: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
grade_level: Optional[str] = None
|
||||
difficulty: str = "medium"
|
||||
|
||||
|
||||
class WorksheetContent(BaseModel):
|
||||
"""Generierter Content."""
|
||||
id: str
|
||||
content_type: str
|
||||
data: Dict[str, Any]
|
||||
h5p_format: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
topic: Optional[str] = None
|
||||
difficulty: Optional[str] = None
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
"""Response mit generiertem Content."""
|
||||
success: bool
|
||||
content: Optional[WorksheetContent] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class BatchGenerateResponse(BaseModel):
|
||||
"""Response für Batch-Generierung."""
|
||||
success: bool
|
||||
contents: List[WorksheetContent] = []
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# In-Memory Storage (später durch DB ersetzen)
|
||||
# ============================================================================
|
||||
|
||||
_generated_content: Dict[str, WorksheetContent] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Generator Instances
|
||||
# ============================================================================
|
||||
|
||||
# Generatoren ohne LLM-Client (automatische Generierung)
|
||||
# In Produktion würde hier der LLM-Client injiziert
|
||||
mc_generator = MultipleChoiceGenerator()
|
||||
cloze_generator = ClozeGenerator()
|
||||
mindmap_generator = MindmapGenerator()
|
||||
quiz_generator = QuizGenerator()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def _parse_difficulty(difficulty_str: str) -> Difficulty:
|
||||
"""Konvertiert String zu Difficulty Enum."""
|
||||
mapping = {
|
||||
"easy": Difficulty.EASY,
|
||||
"medium": Difficulty.MEDIUM,
|
||||
"hard": Difficulty.HARD
|
||||
}
|
||||
return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM)
|
||||
|
||||
|
||||
def _parse_cloze_type(type_str: str) -> ClozeType:
|
||||
"""Konvertiert String zu ClozeType Enum."""
|
||||
mapping = {
|
||||
"fill_in": ClozeType.FILL_IN,
|
||||
"drag_drop": ClozeType.DRAG_DROP,
|
||||
"dropdown": ClozeType.DROPDOWN
|
||||
}
|
||||
return mapping.get(type_str.lower(), ClozeType.FILL_IN)
|
||||
|
||||
|
||||
def _parse_quiz_types(type_strs: List[str]) -> List[QuizType]:
|
||||
"""Konvertiert String-Liste zu QuizType Enums."""
|
||||
mapping = {
|
||||
"true_false": QuizType.TRUE_FALSE,
|
||||
"matching": QuizType.MATCHING,
|
||||
"sorting": QuizType.SORTING,
|
||||
"open_ended": QuizType.OPEN_ENDED
|
||||
}
|
||||
return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs]
|
||||
|
||||
|
||||
def _store_content(content: WorksheetContent) -> None:
|
||||
"""Speichert generierten Content."""
|
||||
_generated_content[content.id] = content
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/generate/multiple-choice", response_model=GenerateResponse)
|
||||
async def generate_multiple_choice(request: MCGenerateRequest):
|
||||
"""
|
||||
Generiert Multiple-Choice-Fragen aus Quelltext.
|
||||
|
||||
- **source_text**: Text mit mind. 50 Zeichen
|
||||
- **num_questions**: Anzahl Fragen (1-20)
|
||||
- **difficulty**: easy, medium, hard
|
||||
"""
|
||||
try:
|
||||
difficulty = _parse_difficulty(request.difficulty)
|
||||
|
||||
questions = mc_generator.generate(
|
||||
source_text=request.source_text,
|
||||
num_questions=request.num_questions,
|
||||
difficulty=difficulty,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level
|
||||
)
|
||||
|
||||
if not questions:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error="Keine Fragen generiert. Text möglicherweise zu kurz."
|
||||
)
|
||||
|
||||
# Konvertiere zu Dict
|
||||
questions_dict = mc_generator.to_dict(questions)
|
||||
h5p_format = mc_generator.to_h5p_format(questions)
|
||||
|
||||
content = WorksheetContent(
|
||||
id=str(uuid.uuid4()),
|
||||
content_type=ContentType.MULTIPLE_CHOICE.value,
|
||||
data={"questions": questions_dict},
|
||||
h5p_format=h5p_format,
|
||||
created_at=datetime.utcnow(),
|
||||
topic=request.topic,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
|
||||
_store_content(content)
|
||||
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating MC questions: {e}")
|
||||
return GenerateResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/cloze", response_model=GenerateResponse)
|
||||
async def generate_cloze(request: ClozeGenerateRequest):
|
||||
"""
|
||||
Generiert Lückentext aus Quelltext.
|
||||
|
||||
- **source_text**: Text mit mind. 50 Zeichen
|
||||
- **num_gaps**: Anzahl Lücken (1-15)
|
||||
- **cloze_type**: fill_in, drag_drop, dropdown
|
||||
"""
|
||||
try:
|
||||
cloze_type = _parse_cloze_type(request.cloze_type)
|
||||
|
||||
cloze = cloze_generator.generate(
|
||||
source_text=request.source_text,
|
||||
num_gaps=request.num_gaps,
|
||||
difficulty=request.difficulty,
|
||||
cloze_type=cloze_type,
|
||||
topic=request.topic
|
||||
)
|
||||
|
||||
if not cloze.gaps:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error="Keine Lücken generiert. Text möglicherweise zu kurz."
|
||||
)
|
||||
|
||||
cloze_dict = cloze_generator.to_dict(cloze)
|
||||
h5p_format = cloze_generator.to_h5p_format(cloze)
|
||||
|
||||
content = WorksheetContent(
|
||||
id=str(uuid.uuid4()),
|
||||
content_type=ContentType.CLOZE.value,
|
||||
data=cloze_dict,
|
||||
h5p_format=h5p_format,
|
||||
created_at=datetime.utcnow(),
|
||||
topic=request.topic,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
|
||||
_store_content(content)
|
||||
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating cloze: {e}")
|
||||
return GenerateResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/mindmap", response_model=GenerateResponse)
|
||||
async def generate_mindmap(request: MindmapGenerateRequest):
|
||||
"""
|
||||
Generiert Mindmap aus Quelltext.
|
||||
|
||||
- **source_text**: Text mit mind. 50 Zeichen
|
||||
- **max_depth**: Maximale Tiefe (2-5)
|
||||
"""
|
||||
try:
|
||||
mindmap = mindmap_generator.generate(
|
||||
source_text=request.source_text,
|
||||
title=request.topic,
|
||||
max_depth=request.max_depth,
|
||||
topic=request.topic
|
||||
)
|
||||
|
||||
if mindmap.total_nodes <= 1:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error="Mindmap konnte nicht generiert werden. Text möglicherweise zu kurz."
|
||||
)
|
||||
|
||||
mindmap_dict = mindmap_generator.to_dict(mindmap)
|
||||
mermaid = mindmap_generator.to_mermaid(mindmap)
|
||||
json_tree = mindmap_generator.to_json_tree(mindmap)
|
||||
|
||||
content = WorksheetContent(
|
||||
id=str(uuid.uuid4()),
|
||||
content_type=ContentType.MINDMAP.value,
|
||||
data={
|
||||
"mindmap": mindmap_dict,
|
||||
"mermaid": mermaid,
|
||||
"json_tree": json_tree
|
||||
},
|
||||
h5p_format=None, # Mindmaps haben kein H5P-Format
|
||||
created_at=datetime.utcnow(),
|
||||
topic=request.topic,
|
||||
difficulty=None
|
||||
)
|
||||
|
||||
_store_content(content)
|
||||
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating mindmap: {e}")
|
||||
return GenerateResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/quiz", response_model=GenerateResponse)
|
||||
async def generate_quiz(request: QuizGenerateRequest):
|
||||
"""
|
||||
Generiert Quiz mit verschiedenen Fragetypen.
|
||||
|
||||
- **source_text**: Text mit mind. 50 Zeichen
|
||||
- **quiz_types**: Liste von true_false, matching, sorting, open_ended
|
||||
- **num_items**: Items pro Typ (1-10)
|
||||
"""
|
||||
try:
|
||||
quiz_types = _parse_quiz_types(request.quiz_types)
|
||||
|
||||
# Generate quiz for each type and combine results
|
||||
all_questions = []
|
||||
quizzes = []
|
||||
|
||||
for quiz_type in quiz_types:
|
||||
quiz = quiz_generator.generate(
|
||||
source_text=request.source_text,
|
||||
quiz_type=quiz_type,
|
||||
num_questions=request.num_items,
|
||||
difficulty=request.difficulty,
|
||||
topic=request.topic
|
||||
)
|
||||
quizzes.append(quiz)
|
||||
all_questions.extend(quiz.questions)
|
||||
|
||||
if len(all_questions) == 0:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error="Quiz konnte nicht generiert werden. Text möglicherweise zu kurz."
|
||||
)
|
||||
|
||||
# Combine all quizzes into a single dict
|
||||
combined_quiz_dict = {
|
||||
"quiz_types": [qt.value for qt in quiz_types],
|
||||
"title": f"Combined Quiz - {request.topic or 'Various Topics'}",
|
||||
"topic": request.topic,
|
||||
"difficulty": request.difficulty,
|
||||
"questions": []
|
||||
}
|
||||
|
||||
# Add questions from each quiz
|
||||
for quiz in quizzes:
|
||||
quiz_dict = quiz_generator.to_dict(quiz)
|
||||
combined_quiz_dict["questions"].extend(quiz_dict.get("questions", []))
|
||||
|
||||
# Use first quiz's H5P format as base (or empty if none)
|
||||
h5p_format = quiz_generator.to_h5p_format(quizzes[0]) if quizzes else {}
|
||||
|
||||
content = WorksheetContent(
|
||||
id=str(uuid.uuid4()),
|
||||
content_type=ContentType.QUIZ.value,
|
||||
data=combined_quiz_dict,
|
||||
h5p_format=h5p_format,
|
||||
created_at=datetime.utcnow(),
|
||||
topic=request.topic,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
|
||||
_store_content(content)
|
||||
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating quiz: {e}")
|
||||
return GenerateResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/batch", response_model=BatchGenerateResponse)
|
||||
async def generate_batch(request: BatchGenerateRequest):
|
||||
"""
|
||||
Generiert mehrere Content-Typen aus einem Quelltext.
|
||||
|
||||
Ideal für die Erstellung kompletter Arbeitsblätter mit
|
||||
verschiedenen Übungstypen.
|
||||
"""
|
||||
contents = []
|
||||
errors = []
|
||||
|
||||
type_mapping = {
|
||||
"multiple_choice": MCGenerateRequest,
|
||||
"cloze": ClozeGenerateRequest,
|
||||
"mindmap": MindmapGenerateRequest,
|
||||
"quiz": QuizGenerateRequest
|
||||
}
|
||||
|
||||
for content_type in request.content_types:
|
||||
try:
|
||||
if content_type == "multiple_choice":
|
||||
mc_req = MCGenerateRequest(
|
||||
source_text=request.source_text,
|
||||
topic=request.topic,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
result = await generate_multiple_choice(mc_req)
|
||||
|
||||
elif content_type == "cloze":
|
||||
cloze_req = ClozeGenerateRequest(
|
||||
source_text=request.source_text,
|
||||
topic=request.topic,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
result = await generate_cloze(cloze_req)
|
||||
|
||||
elif content_type == "mindmap":
|
||||
mindmap_req = MindmapGenerateRequest(
|
||||
source_text=request.source_text,
|
||||
topic=request.topic,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level
|
||||
)
|
||||
result = await generate_mindmap(mindmap_req)
|
||||
|
||||
elif content_type == "quiz":
|
||||
quiz_req = QuizGenerateRequest(
|
||||
source_text=request.source_text,
|
||||
topic=request.topic,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level,
|
||||
difficulty=request.difficulty
|
||||
)
|
||||
result = await generate_quiz(quiz_req)
|
||||
|
||||
else:
|
||||
errors.append(f"Unbekannter Content-Typ: {content_type}")
|
||||
continue
|
||||
|
||||
if result.success and result.content:
|
||||
contents.append(result.content)
|
||||
elif result.error:
|
||||
errors.append(f"{content_type}: {result.error}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{content_type}: {str(e)}")
|
||||
|
||||
return BatchGenerateResponse(
|
||||
success=len(contents) > 0,
|
||||
contents=contents,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.get("/content/{content_id}", response_model=GenerateResponse)
|
||||
async def get_content(content_id: str):
|
||||
"""Ruft gespeicherten Content ab."""
|
||||
content = _generated_content.get(content_id)
|
||||
|
||||
if not content:
|
||||
raise HTTPException(status_code=404, detail="Content nicht gefunden")
|
||||
|
||||
return GenerateResponse(success=True, content=content)
|
||||
|
||||
|
||||
@router.get("/content/{content_id}/h5p")
|
||||
async def get_content_h5p(content_id: str):
|
||||
"""Gibt H5P-Format für Content zurück."""
|
||||
content = _generated_content.get(content_id)
|
||||
|
||||
if not content:
|
||||
raise HTTPException(status_code=404, detail="Content nicht gefunden")
|
||||
|
||||
if not content.h5p_format:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="H5P-Format für diesen Content-Typ nicht verfügbar"
|
||||
)
|
||||
|
||||
return content.h5p_format
|
||||
|
||||
|
||||
@router.delete("/content/{content_id}")
|
||||
async def delete_content(content_id: str):
|
||||
"""Löscht gespeicherten Content."""
|
||||
if content_id not in _generated_content:
|
||||
raise HTTPException(status_code=404, detail="Content nicht gefunden")
|
||||
|
||||
del _generated_content[content_id]
|
||||
return {"status": "deleted", "id": content_id}
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
async def list_content_types():
|
||||
"""Listet verfügbare Content-Typen und deren Optionen."""
|
||||
return {
|
||||
"content_types": [
|
||||
{
|
||||
"type": "multiple_choice",
|
||||
"name": "Multiple Choice",
|
||||
"description": "Fragen mit 4 Antwortmöglichkeiten",
|
||||
"options": {
|
||||
"num_questions": {"min": 1, "max": 20, "default": 5},
|
||||
"difficulty": ["easy", "medium", "hard"]
|
||||
},
|
||||
"h5p_supported": True
|
||||
},
|
||||
{
|
||||
"type": "cloze",
|
||||
"name": "Lückentext",
|
||||
"description": "Text mit ausgeblendeten Schlüsselwörtern",
|
||||
"options": {
|
||||
"num_gaps": {"min": 1, "max": 15, "default": 5},
|
||||
"difficulty": ["easy", "medium", "hard"],
|
||||
"cloze_type": ["fill_in", "drag_drop", "dropdown"]
|
||||
},
|
||||
"h5p_supported": True
|
||||
},
|
||||
{
|
||||
"type": "mindmap",
|
||||
"name": "Mindmap",
|
||||
"description": "Hierarchische Struktur aus Hauptthema und Unterthemen",
|
||||
"options": {
|
||||
"max_depth": {"min": 2, "max": 5, "default": 3}
|
||||
},
|
||||
"h5p_supported": False,
|
||||
"export_formats": ["mermaid", "json_tree"]
|
||||
},
|
||||
{
|
||||
"type": "quiz",
|
||||
"name": "Quiz",
|
||||
"description": "Verschiedene Fragetypen kombiniert",
|
||||
"options": {
|
||||
"quiz_types": ["true_false", "matching", "sorting", "open_ended"],
|
||||
"num_items": {"min": 1, "max": 10, "default": 5},
|
||||
"difficulty": ["easy", "medium", "hard"]
|
||||
},
|
||||
"h5p_supported": True
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_generation_history(limit: int = 10):
|
||||
"""Gibt die letzten generierten Contents zurück."""
|
||||
sorted_contents = sorted(
|
||||
_generated_content.values(),
|
||||
key=lambda x: x.created_at,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return {
|
||||
"total": len(_generated_content),
|
||||
"contents": [
|
||||
{
|
||||
"id": c.id,
|
||||
"content_type": c.content_type,
|
||||
"topic": c.topic,
|
||||
"difficulty": c.difficulty,
|
||||
"created_at": c.created_at.isoformat()
|
||||
}
|
||||
for c in sorted_contents[:limit]
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user