Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
593 lines
19 KiB
Python
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]
|
|
]
|
|
}
|