klausur-service (11 files): - cv_gutter_repair, ocr_pipeline_regression, upload_api - ocr_pipeline_sessions, smart_spell, nru_worksheet_generator - ocr_pipeline_overlays, mail/aggregator, zeugnis_api - cv_syllable_detect, self_rag backend-lehrer (17 files): - classroom_engine/suggestions, generators/quiz_generator - worksheets_api, llm_gateway/comparison, state_engine_api - classroom/models (→ 4 submodules), services/file_processor - alerts_agent/api/wizard+digests+routes, content_generators/pdf - classroom/routes/sessions, llm_gateway/inference - classroom_engine/analytics, auth/keycloak_auth - alerts_agent/processing/rule_engine, ai_processor/print_versions agent-core (5 files): - brain/memory_store, brain/knowledge_graph, brain/context_manager - orchestrator/supervisor, sessions/session_manager admin-lehrer (5 components): - GridOverlay, StepGridReview, DevOpsPipelineSidebar - DataFlowDiagram, sbom/wizard/page website (2 files): - DependencyMap, lehrer/abitur-archiv Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx 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 worksheets_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]
|
|
]
|
|
}
|