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

{item['definition']}

" }) # 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"

{item['term']}

" } }, "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": "

" + "

".join(sentences) + "

", "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"

Was ist richtig ueber {label}?

", "answers": [ { "correct": True, "text": f"

{why}

", "tpiAndFeedback": { "chosenFeedback": "

Richtig!

", "notChosenFeedback": "" } }, { "correct": False, "text": f"

{misconception}

", "tipsAndFeedback": { "chosenFeedback": f"

Das ist ein haeufiger Irrtum!

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