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/content_generators/h5p_generator.py
Benjamin Admin 21a844cb8a 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

430 lines
14 KiB
Python

"""
H5P Content Generator for Breakpilot Units
==========================================
Generates H5P-compatible content structures from unit definitions.
Supported H5P content types:
- H5P.DragQuestion (Drag and Drop)
- H5P.Blanks (Fill in the Blanks)
- H5P.MultiChoice (Multiple Choice)
- H5P.ImageHotspots (Interactive Images)
"""
import json
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
from pathlib import Path
@dataclass
class H5PContent:
"""Represents an H5P content package"""
content_type: str
title: str
content: dict
metadata: dict = field(default_factory=dict)
def to_json(self) -> str:
return json.dumps({
"contentType": self.content_type,
"title": self.title,
"content": self.content,
"metadata": self.metadata
}, indent=2, ensure_ascii=False)
def to_h5p_structure(self) -> dict:
"""Generate H5P-compatible structure for export"""
return {
"h5p": {
"mainLibrary": self.content_type,
"title": self.title,
"language": "de",
"embedTypes": ["div"],
"license": "U",
"preloadedDependencies": self._get_dependencies()
},
"content": self.content
}
def _get_dependencies(self) -> list:
"""Get required H5P libraries for content type"""
deps = {
"H5P.DragQuestion": [
{"machineName": "H5P.DragQuestion", "majorVersion": 1, "minorVersion": 14}
],
"H5P.Blanks": [
{"machineName": "H5P.Blanks", "majorVersion": 1, "minorVersion": 14}
],
"H5P.MultiChoice": [
{"machineName": "H5P.MultiChoice", "majorVersion": 1, "minorVersion": 16}
],
"H5P.ImageHotspots": [
{"machineName": "H5P.ImageHotspots", "majorVersion": 1, "minorVersion": 10}
]
}
return deps.get(self.content_type, [])
class H5PGenerator:
"""Generates H5P content from unit definitions"""
def __init__(self, locale: str = "de-DE"):
self.locale = locale
def generate_from_unit(self, unit: dict) -> list[H5PContent]:
"""
Generate all H5P content items from a unit definition.
Args:
unit: Unit definition dictionary
Returns:
List of H5PContent objects
"""
contents = []
# Generate drag and drop from stops/vocab
drag_drop = self._generate_drag_drop(unit)
if drag_drop:
contents.append(drag_drop)
# Generate fill in blanks from concepts
blanks = self._generate_fill_blanks(unit)
if blanks:
contents.append(blanks)
# Generate multiple choice from pre/post check questions
mc_items = self._generate_multiple_choice(unit)
contents.extend(mc_items)
return contents
def _get_localized(self, obj: Optional[dict], key: str, default: str = "") -> str:
"""Get localized string from object"""
if not obj:
return default
if isinstance(obj, str):
return obj
if isinstance(obj, dict):
# Try locale-specific key
if self.locale in obj:
return obj[self.locale]
# Try without hyphen (de_DE vs de-DE)
alt_locale = self.locale.replace("-", "_")
if alt_locale in obj:
return obj[alt_locale]
# Fallback to first available
if obj:
return next(iter(obj.values()))
return default
def _generate_drag_drop(self, unit: dict) -> Optional[H5PContent]:
"""
Generate Drag and Drop content from unit vocabulary.
Students drag terms to their definitions.
"""
stops = unit.get("stops", [])
if not stops:
return None
# Collect all vocabulary items
vocab_items = []
for stop in stops:
label = self._get_localized(stop.get("label"), self.locale)
vocab = stop.get("vocab", [])
for v in vocab:
term = self._get_localized(v.get("term"), self.locale)
hint = self._get_localized(v.get("hint"), self.locale)
if term and hint:
vocab_items.append({
"term": term,
"definition": hint,
"stop": label
})
if len(vocab_items) < 2:
return None
# Build H5P.DragQuestion content
drop_zones = []
draggables = []
for i, item in enumerate(vocab_items[:8]): # Limit to 8 items
# Drop zone for definition
drop_zones.append({
"x": 10,
"y": 10 + i * 12,
"width": 35,
"height": 10,
"correctElements": [str(i)],
"showLabel": True,
"backgroundOpacity": 100,
"tipsAndFeedback": {
"tip": f"Ziehe den passenden Begriff hierher"
},
"label": f"<p>{item['definition']}</p>"
})
# Draggable term
draggables.append({
"x": 60,
"y": 10 + i * 12,
"width": 30,
"height": 8,
"dropZones": [str(i)],
"type": {
"library": "H5P.AdvancedText 1.1",
"params": {
"text": f"<p>{item['term']}</p>"
}
},
"multiple": False
})
unit_title = self._get_localized(unit.get("title"), self.locale, unit.get("unit_id", "Unit"))
content = {
"scoreShow": "Ergebnis anzeigen",
"tryAgain": "Nochmal versuchen",
"checkAnswer": "Antworten pruefen",
"submitAnswers": "Abgeben",
"behaviour": {
"enableRetry": True,
"enableCheckButton": True,
"showSolutionsRequiresInput": True,
"singlePoint": False,
"applyPenalties": False,
"enableScoreExplanation": True,
"dropZoneHighlighting": "dragging",
"autoAlignSpacing": 2,
"enableFullScreen": False
},
"question": {
"settings": {
"size": {
"width": 620,
"height": 400
}
},
"task": {
"elements": draggables,
"dropZones": drop_zones
}
},
"overallFeedback": [
{"from": 0, "to": 50, "feedback": "Versuche es nochmal!"},
{"from": 51, "to": 80, "feedback": "Gut gemacht!"},
{"from": 81, "to": 100, "feedback": "Ausgezeichnet!"}
]
}
return H5PContent(
content_type="H5P.DragQuestion",
title=f"{unit_title} - Begriffe zuordnen",
content=content,
metadata={
"unit_id": unit.get("unit_id"),
"generated_from": "vocabulary"
}
)
def _generate_fill_blanks(self, unit: dict) -> Optional[H5PContent]:
"""
Generate Fill in the Blanks from concept "why" explanations.
Key terms become blanks.
"""
stops = unit.get("stops", [])
if not stops:
return None
sentences = []
for stop in stops:
concept = stop.get("concept", {})
why = self._get_localized(concept.get("why"), self.locale)
vocab = stop.get("vocab", [])
if not why or not vocab:
continue
# Find terms to blank out
sentence = why
for v in vocab:
term = self._get_localized(v.get("term"), self.locale)
if term and term.lower() in sentence.lower():
# Replace term with blank (H5P format: *term*)
import re
pattern = re.compile(re.escape(term), re.IGNORECASE)
sentence = pattern.sub(f"*{term}*", sentence, count=1)
break
if "*" in sentence:
sentences.append(sentence)
if not sentences:
return None
unit_title = self._get_localized(unit.get("title"), self.locale, unit.get("unit_id", "Unit"))
content = {
"text": "<p>" + "</p><p>".join(sentences) + "</p>",
"overallFeedback": [
{"from": 0, "to": 50, "feedback": "Versuche es nochmal!"},
{"from": 51, "to": 80, "feedback": "Gut gemacht!"},
{"from": 81, "to": 100, "feedback": "Ausgezeichnet!"}
],
"showSolutions": "Loesung anzeigen",
"tryAgain": "Nochmal versuchen",
"checkAnswer": "Pruefen",
"submitAnswer": "Abgeben",
"notFilledOut": "Bitte fuellen Sie alle Luecken aus.",
"answerIsCorrect": "':ans' ist richtig!",
"answerIsWrong": "':ans' ist falsch.",
"answeredCorrectly": "Richtig!",
"answeredIncorrectly": "Falsch.",
"behaviour": {
"enableRetry": True,
"enableSolutionsButton": True,
"enableCheckButton": True,
"caseSensitive": False,
"autoCheck": False,
"separateLines": False
}
}
return H5PContent(
content_type="H5P.Blanks",
title=f"{unit_title} - Lueckentext",
content=content,
metadata={
"unit_id": unit.get("unit_id"),
"generated_from": "concepts"
}
)
def _generate_multiple_choice(self, unit: dict) -> list[H5PContent]:
"""
Generate Multiple Choice questions from precheck/postcheck.
"""
contents = []
# Load precheck questions if referenced
precheck = unit.get("precheck", {})
postcheck = unit.get("postcheck", {})
# For now, generate from stops' concepts as misconception-targeted questions
stops = unit.get("stops", [])
unit_title = self._get_localized(unit.get("title"), self.locale, unit.get("unit_id", "Unit"))
for i, stop in enumerate(stops):
concept = stop.get("concept", {})
misconception = self._get_localized(concept.get("common_misconception"), self.locale)
why = self._get_localized(concept.get("why"), self.locale)
label = self._get_localized(stop.get("label"), self.locale)
if not misconception or not why:
continue
# Create a question about the concept
content = {
"question": f"<p>Was ist richtig ueber {label}?</p>",
"answers": [
{
"correct": True,
"text": f"<p>{why}</p>",
"tpiAndFeedback": {
"chosenFeedback": "<p>Richtig!</p>",
"notChosenFeedback": ""
}
},
{
"correct": False,
"text": f"<p>{misconception}</p>",
"tipsAndFeedback": {
"chosenFeedback": f"<p>Das ist ein haeufiger Irrtum!</p>",
"notChosenFeedback": ""
}
}
],
"overallFeedback": [
{"from": 0, "to": 50, "feedback": "Versuche es nochmal!"},
{"from": 51, "to": 100, "feedback": "Sehr gut!"}
],
"behaviour": {
"enableRetry": True,
"enableSolutionsButton": True,
"enableCheckButton": True,
"type": "auto",
"singlePoint": True,
"randomAnswers": True,
"showSolutionsRequiresInput": True,
"confirmCheckDialog": False,
"confirmRetryDialog": False,
"autoCheck": False,
"passPercentage": 100
},
"UI": {
"checkAnswerButton": "Pruefen",
"submitAnswerButton": "Abgeben",
"showSolutionButton": "Loesung zeigen",
"tryAgainButton": "Nochmal",
"tipsLabel": "Tipp anzeigen",
"scoreBarLabel": "Du hast :num von :total Punkten",
"tipAvailable": "Tipp verfuegbar",
"feedbackAvailable": "Feedback verfuegbar",
"readFeedback": "Feedback lesen",
"wrongAnswer": "Falsche Antwort",
"correctAnswer": "Richtige Antwort"
}
}
contents.append(H5PContent(
content_type="H5P.MultiChoice",
title=f"{unit_title} - {label} Quiz",
content=content,
metadata={
"unit_id": unit.get("unit_id"),
"stop_id": stop.get("stop_id"),
"generated_from": "misconception"
}
))
return contents
def generate_h5p_for_unit(unit_definition: dict, locale: str = "de-DE") -> list[dict]:
"""
Main function to generate H5P content for a unit.
Args:
unit_definition: The unit JSON definition
locale: Target locale for content
Returns:
List of H5P content structures ready for export
"""
generator = H5PGenerator(locale=locale)
contents = generator.generate_from_unit(unit_definition)
return [c.to_h5p_structure() for c in contents]
def generate_h5p_manifest(contents: list[H5PContent], unit_id: str) -> dict:
"""
Generate a manifest listing all H5P content for a unit.
"""
return {
"unit_id": unit_id,
"generated_at": __import__("datetime").datetime.utcnow().isoformat(),
"content_count": len(contents),
"items": [
{
"id": str(uuid.uuid4()),
"type": c.content_type,
"title": c.title,
"metadata": c.metadata
}
for c in contents
]
}