""" Cloze Generator - Erstellt Lückentexte aus Quelltexten. Generiert: - Lückentexte mit ausgeblendeten Schlüsselwörtern - Verschiedene Schwierigkeitsgrade - Hinweise und Erklärungen """ import logging import json import re from typing import List, Dict, Any, Optional from dataclasses import dataclass from enum import Enum logger = logging.getLogger(__name__) class ClozeType(str, Enum): """Typen von Lückentexten.""" FILL_IN = "fill_in" # Freies Ausfüllen DRAG_DROP = "drag_drop" # Drag & Drop DROPDOWN = "dropdown" # Dropdown-Auswahl @dataclass class ClozeGap: """Eine Lücke im Text.""" position: int # Position im Text (0-basiert) answer: str # Korrekte Antwort alternatives: List[str] # Alternative korrekte Antworten hint: Optional[str] # Hinweis distractors: List[str] # Falsche Optionen (für Dropdown/Drag-Drop) @dataclass class ClozeText: """Ein kompletter Lückentext.""" text_with_gaps: str # Text mit Platzhaltern original_text: str # Originaltext gaps: List[ClozeGap] # Liste der Lücken cloze_type: ClozeType # Typ des Lückentexts topic: Optional[str] # Thema difficulty: str # easy, medium, hard class ClozeGenerator: """ Generiert Lückentexte aus Quelltexten. Unterstützt verschiedene Modi: - Automatische Erkennung wichtiger Begriffe - LLM-basierte intelligente Auswahl - Manuelle Vorgabe von Lücken """ def __init__(self, llm_client=None): """ Initialisiert den Generator. Args: llm_client: Optional - LLM-Client für intelligente Generierung """ self.llm_client = llm_client logger.info("ClozeGenerator initialized") # Wortarten, die oft als Lücken geeignet sind self._important_pos = {"NOUN", "VERB", "ADJ"} # Substantive, Verben, Adjektive def generate( self, source_text: str, num_gaps: int = 5, difficulty: str = "medium", cloze_type: ClozeType = ClozeType.FILL_IN, topic: Optional[str] = None ) -> ClozeText: """ Generiert einen Lückentext aus einem Quelltext. Args: source_text: Der Ausgangstext num_gaps: Anzahl der Lücken difficulty: Schwierigkeitsgrad (easy, medium, hard) cloze_type: Art des Lückentexts topic: Optionales Thema Returns: ClozeText-Objekt """ logger.info(f"Generating cloze text with {num_gaps} gaps (difficulty: {difficulty})") if not source_text or len(source_text.strip()) < 50: logger.warning("Source text too short") return self._empty_cloze(source_text, cloze_type) if self.llm_client: return self._generate_with_llm( source_text, num_gaps, difficulty, cloze_type, topic ) else: return self._generate_automatic( source_text, num_gaps, difficulty, cloze_type, topic ) def _generate_with_llm( self, source_text: str, num_gaps: int, difficulty: str, cloze_type: ClozeType, topic: Optional[str] ) -> ClozeText: """Generiert Lückentext mit LLM.""" prompt = f""" Erstelle einen Lückentext auf Deutsch basierend auf folgendem Text. Ersetze {num_gaps} wichtige Begriffe durch Lücken. Schwierigkeitsgrad: {difficulty} {f'Thema: {topic}' if topic else ''} Originaltext: {source_text} Wähle {num_gaps} wichtige Begriffe (Substantive, Verben, Fachbegriffe) aus. Für jeden Begriff gib an: - Das Wort, das ausgeblendet wird - Alternative Schreibweisen (falls vorhanden) - Einen Hinweis - 3 ähnliche aber falsche Wörter (Distraktoren) Antworte im JSON-Format: {{ "gaps": [ {{ "word": "Photosynthese", "alternatives": ["Fotosynthese"], "hint": "Prozess bei dem Pflanzen Licht nutzen", "distractors": ["Zellatmung", "Osmose", "Diffusion"] }} ] }} """ try: response = self.llm_client.generate(prompt) data = json.loads(response) return self._create_cloze_from_llm( source_text, data, difficulty, cloze_type, topic ) except Exception as e: logger.error(f"Error generating with LLM: {e}") return self._generate_automatic( source_text, num_gaps, difficulty, cloze_type, topic ) def _generate_automatic( self, source_text: str, num_gaps: int, difficulty: str, cloze_type: ClozeType, topic: Optional[str] ) -> ClozeText: """Generiert Lückentext automatisch ohne LLM.""" # Finde wichtige Wörter words = self._find_important_words(source_text) # Wähle Wörter basierend auf Schwierigkeit selected = self._select_words_by_difficulty(words, num_gaps, difficulty) # Erstelle Lücken gaps = [] text_with_gaps = source_text for i, (word, pos) in enumerate(selected): # Position im aktuellen Text finden match = re.search(r'\b' + re.escape(word) + r'\b', text_with_gaps) if match: # Ersetze durch Platzhalter placeholder = f"[_{i+1}_]" text_with_gaps = text_with_gaps[:match.start()] + placeholder + text_with_gaps[match.end():] gap = ClozeGap( position=i, answer=word, alternatives=[word.lower(), word.upper()], hint=self._generate_hint(word, source_text), distractors=self._generate_distractors(word, words) ) gaps.append(gap) return ClozeText( text_with_gaps=text_with_gaps, original_text=source_text, gaps=gaps, cloze_type=cloze_type, topic=topic, difficulty=difficulty ) def _find_important_words(self, text: str) -> List[tuple]: """Findet wichtige Wörter im Text.""" # Einfache Heuristik: Längere Wörter sind oft wichtiger words = re.findall(r'\b[A-Za-zäöüÄÖÜß]{4,}\b', text) # Zähle Häufigkeit word_count = {} for word in words: word_lower = word.lower() word_count[word_lower] = word_count.get(word_lower, 0) + 1 # Sortiere nach Länge und Häufigkeit unique_words = list(set(words)) scored = [] for word in unique_words: score = len(word) + word_count[word.lower()] * 2 # Bevorzuge Wörter mit Großbuchstaben (Substantive) if word[0].isupper(): score += 3 scored.append((word, score)) scored.sort(key=lambda x: x[1], reverse=True) return [(w, s) for w, s in scored] def _select_words_by_difficulty( self, words: List[tuple], num_gaps: int, difficulty: str ) -> List[tuple]: """Wählt Wörter basierend auf Schwierigkeit.""" if difficulty == "easy": # Einfach: Häufige, wichtige Wörter return words[:num_gaps] elif difficulty == "hard": # Schwer: Weniger häufige Wörter return words[num_gaps:num_gaps*2] if len(words) > num_gaps else words[:num_gaps] else: # Medium: Mischung return words[:num_gaps] def _generate_hint(self, word: str, text: str) -> str: """Generiert einen Hinweis für ein Wort.""" # Einfacher Hinweis basierend auf Kontext sentences = text.split('.') for sentence in sentences: if word in sentence: # Extrahiere Kontext words_in_sentence = sentence.split() if len(words_in_sentence) > 5: return f"Beginnt mit '{word[0]}' ({len(word)} Buchstaben)" return f"Beginnt mit '{word[0]}'" def _generate_distractors(self, word: str, all_words: List[tuple]) -> List[str]: """Generiert Distraktoren (falsche Optionen).""" distractors = [] word_len = len(word) # Finde ähnlich lange Wörter for w, _ in all_words: if w.lower() != word.lower(): if abs(len(w) - word_len) <= 2: distractors.append(w) if len(distractors) >= 3: break # Falls nicht genug, füge generische hinzu while len(distractors) < 3: distractors.append(f"[Option {len(distractors)+1}]") return distractors[:3] def _create_cloze_from_llm( self, source_text: str, data: Dict[str, Any], difficulty: str, cloze_type: ClozeType, topic: Optional[str] ) -> ClozeText: """Erstellt ClozeText aus LLM-Antwort.""" text_with_gaps = source_text gaps = [] for i, gap_data in enumerate(data.get("gaps", [])): word = gap_data.get("word", "") if word: # Ersetze im Text pattern = r'\b' + re.escape(word) + r'\b' placeholder = f"[_{i+1}_]" text_with_gaps = re.sub(pattern, placeholder, text_with_gaps, count=1) gap = ClozeGap( position=i, answer=word, alternatives=gap_data.get("alternatives", []), hint=gap_data.get("hint"), distractors=gap_data.get("distractors", []) ) gaps.append(gap) return ClozeText( text_with_gaps=text_with_gaps, original_text=source_text, gaps=gaps, cloze_type=cloze_type, topic=topic, difficulty=difficulty ) def _empty_cloze(self, text: str, cloze_type: ClozeType) -> ClozeText: """Erstellt leeren ClozeText bei Fehler.""" return ClozeText( text_with_gaps=text, original_text=text, gaps=[], cloze_type=cloze_type, topic=None, difficulty="medium" ) def to_h5p_format(self, cloze: ClozeText) -> Dict[str, Any]: """ Konvertiert Lückentext ins H5P-Format. Args: cloze: ClozeText-Objekt Returns: H5P-kompatibles Dict """ # H5P Fill in the Blanks Format h5p_text = cloze.text_with_gaps # Ersetze Platzhalter durch H5P-Format for i, gap in enumerate(cloze.gaps): placeholder = f"[_{i+1}_]" answers = [gap.answer] + gap.alternatives h5p_answer = "/".join(answers) if cloze.cloze_type == ClozeType.DROPDOWN: # Mit Distraktoren all_options = answers + gap.distractors h5p_answer = "/".join(all_options) h5p_text = h5p_text.replace(placeholder, f"*{h5p_answer}*") return { "library": "H5P.Blanks", "params": { "text": h5p_text, "behaviour": { "enableRetry": True, "enableSolutionsButton": True, "caseSensitive": False, "showSolutionsRequiresInput": True } } } def to_dict(self, cloze: ClozeText) -> Dict[str, Any]: """Konvertiert ClozeText zu Dictionary-Format.""" return { "text_with_gaps": cloze.text_with_gaps, "original_text": cloze.original_text, "gaps": [ { "position": gap.position, "answer": gap.answer, "alternatives": gap.alternatives, "hint": gap.hint, "distractors": gap.distractors } for gap in cloze.gaps ], "cloze_type": cloze.cloze_type.value, "topic": cloze.topic, "difficulty": cloze.difficulty }