This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/worksheets_api.py
Benjamin Admin bfdaf63ba9 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>
2026-02-09 09:51:32 +01:00

593 lines
19 KiB
Python

"""
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]
]
}