Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m41s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 38s
classroom/ (+2): state_engine_api, state_engine_models vocabulary/ (2): api, db worksheets/ (2): api, models services/ (+6): audio, email, translation, claude_vision, ai_processor, story_generator api/ (4): school, klausur_proxy, progress, user_language Only main.py + config.py remain at root. 16 shims added. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
440 lines
14 KiB
Python
440 lines
14 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 Dict
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
from generators import (
|
|
MultipleChoiceGenerator,
|
|
ClozeGenerator,
|
|
MindmapGenerator,
|
|
QuizGenerator
|
|
)
|
|
|
|
from .models import (
|
|
ContentType,
|
|
GenerateRequest,
|
|
MCGenerateRequest,
|
|
ClozeGenerateRequest,
|
|
MindmapGenerateRequest,
|
|
QuizGenerateRequest,
|
|
BatchGenerateRequest,
|
|
WorksheetContent,
|
|
GenerateResponse,
|
|
BatchGenerateResponse,
|
|
parse_difficulty,
|
|
parse_cloze_type,
|
|
parse_quiz_types,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/worksheets",
|
|
tags=["worksheets"],
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# In-Memory Storage (später durch DB ersetzen)
|
|
# ============================================================================
|
|
|
|
_generated_content: Dict[str, WorksheetContent] = {}
|
|
|
|
|
|
# ============================================================================
|
|
# Generator Instances
|
|
# ============================================================================
|
|
|
|
mc_generator = MultipleChoiceGenerator()
|
|
cloze_generator = ClozeGenerator()
|
|
mindmap_generator = MindmapGenerator()
|
|
quiz_generator = QuizGenerator()
|
|
|
|
|
|
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."""
|
|
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."
|
|
)
|
|
|
|
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."""
|
|
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."""
|
|
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,
|
|
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."""
|
|
try:
|
|
quiz_types = parse_quiz_types(request.quiz_types)
|
|
|
|
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."
|
|
)
|
|
|
|
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": []
|
|
}
|
|
|
|
for quiz in quizzes:
|
|
quiz_dict = quiz_generator.to_dict(quiz)
|
|
combined_quiz_dict["questions"].extend(quiz_dict.get("questions", []))
|
|
|
|
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."""
|
|
contents = []
|
|
errors = []
|
|
|
|
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]
|
|
]
|
|
}
|