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