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>
595 lines
17 KiB
Python
595 lines
17 KiB
Python
"""
|
|
Quiz Generator - Erstellt verschiedene Quiz-Typen aus Quelltexten.
|
|
|
|
Generiert:
|
|
- True/False Fragen
|
|
- Zuordnungsaufgaben (Matching)
|
|
- Sortieraufgaben
|
|
- Offene Fragen mit Musterlösungen
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
import re
|
|
from typing import List, Dict, Any, Optional, Tuple
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class QuizType(str, Enum):
|
|
"""Typen von Quiz-Aufgaben."""
|
|
TRUE_FALSE = "true_false"
|
|
MATCHING = "matching"
|
|
SORTING = "sorting"
|
|
OPEN_ENDED = "open_ended"
|
|
|
|
|
|
@dataclass
|
|
class TrueFalseQuestion:
|
|
"""Eine Wahr/Falsch-Frage."""
|
|
statement: str
|
|
is_true: bool
|
|
explanation: str
|
|
source_reference: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class MatchingPair:
|
|
"""Ein Zuordnungspaar."""
|
|
left: str
|
|
right: str
|
|
hint: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class SortingItem:
|
|
"""Ein Element zum Sortieren."""
|
|
text: str
|
|
correct_position: int
|
|
category: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class OpenQuestion:
|
|
"""Eine offene Frage."""
|
|
question: str
|
|
model_answer: str
|
|
keywords: List[str]
|
|
points: int = 1
|
|
|
|
|
|
@dataclass
|
|
class Quiz:
|
|
"""Ein komplettes Quiz."""
|
|
quiz_type: QuizType
|
|
title: str
|
|
questions: List[Any] # Je nach Typ unterschiedlich
|
|
topic: Optional[str] = None
|
|
difficulty: str = "medium"
|
|
|
|
|
|
class QuizGenerator:
|
|
"""
|
|
Generiert verschiedene Quiz-Typen aus Quelltexten.
|
|
"""
|
|
|
|
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("QuizGenerator initialized")
|
|
|
|
def generate(
|
|
self,
|
|
source_text: str,
|
|
quiz_type: QuizType,
|
|
num_questions: int = 5,
|
|
title: Optional[str] = None,
|
|
topic: Optional[str] = None,
|
|
difficulty: str = "medium"
|
|
) -> Quiz:
|
|
"""
|
|
Generiert ein Quiz aus einem Quelltext.
|
|
|
|
Args:
|
|
source_text: Der Ausgangstext
|
|
quiz_type: Art des Quiz
|
|
num_questions: Anzahl der Fragen/Aufgaben
|
|
title: Optionaler Titel
|
|
topic: Optionales Thema
|
|
difficulty: Schwierigkeitsgrad
|
|
|
|
Returns:
|
|
Quiz-Objekt
|
|
"""
|
|
logger.info(f"Generating {quiz_type} quiz with {num_questions} questions")
|
|
|
|
if not source_text or len(source_text.strip()) < 50:
|
|
logger.warning("Source text too short")
|
|
return self._empty_quiz(quiz_type, title or "Quiz")
|
|
|
|
generators = {
|
|
QuizType.TRUE_FALSE: self._generate_true_false,
|
|
QuizType.MATCHING: self._generate_matching,
|
|
QuizType.SORTING: self._generate_sorting,
|
|
QuizType.OPEN_ENDED: self._generate_open_ended,
|
|
}
|
|
|
|
generator = generators.get(quiz_type)
|
|
if not generator:
|
|
raise ValueError(f"Unbekannter Quiz-Typ: {quiz_type}")
|
|
|
|
questions = generator(source_text, num_questions, difficulty)
|
|
|
|
return Quiz(
|
|
quiz_type=quiz_type,
|
|
title=title or f"{quiz_type.value.replace('_', ' ').title()} Quiz",
|
|
questions=questions,
|
|
topic=topic,
|
|
difficulty=difficulty
|
|
)
|
|
|
|
def _generate_true_false(
|
|
self,
|
|
source_text: str,
|
|
num_questions: int,
|
|
difficulty: str
|
|
) -> List[TrueFalseQuestion]:
|
|
"""Generiert Wahr/Falsch-Fragen."""
|
|
if self.llm_client:
|
|
return self._generate_true_false_llm(source_text, num_questions, difficulty)
|
|
|
|
# Automatische Generierung
|
|
sentences = self._extract_factual_sentences(source_text)
|
|
questions = []
|
|
|
|
for i, sentence in enumerate(sentences[:num_questions]):
|
|
# Abwechselnd wahre und falsche Aussagen
|
|
if i % 2 == 0:
|
|
# Wahre Aussage
|
|
questions.append(TrueFalseQuestion(
|
|
statement=sentence,
|
|
is_true=True,
|
|
explanation="Diese Aussage entspricht dem Text.",
|
|
source_reference=sentence[:50]
|
|
))
|
|
else:
|
|
# Falsche Aussage (Negation)
|
|
false_statement = self._negate_sentence(sentence)
|
|
questions.append(TrueFalseQuestion(
|
|
statement=false_statement,
|
|
is_true=False,
|
|
explanation=f"Richtig wäre: {sentence}",
|
|
source_reference=sentence[:50]
|
|
))
|
|
|
|
return questions
|
|
|
|
def _generate_true_false_llm(
|
|
self,
|
|
source_text: str,
|
|
num_questions: int,
|
|
difficulty: str
|
|
) -> List[TrueFalseQuestion]:
|
|
"""Generiert Wahr/Falsch-Fragen mit LLM."""
|
|
prompt = f"""
|
|
Erstelle {num_questions} Wahr/Falsch-Aussagen auf Deutsch basierend auf folgendem Text.
|
|
Schwierigkeit: {difficulty}
|
|
Erstelle etwa gleich viele wahre und falsche Aussagen.
|
|
|
|
Text:
|
|
{source_text}
|
|
|
|
Antworte im JSON-Format:
|
|
{{
|
|
"questions": [
|
|
{{
|
|
"statement": "Die Aussage...",
|
|
"is_true": true,
|
|
"explanation": "Erklärung warum wahr/falsch"
|
|
}}
|
|
]
|
|
}}
|
|
"""
|
|
try:
|
|
response = self.llm_client.generate(prompt)
|
|
data = json.loads(response)
|
|
return [
|
|
TrueFalseQuestion(
|
|
statement=q["statement"],
|
|
is_true=q["is_true"],
|
|
explanation=q["explanation"]
|
|
)
|
|
for q in data.get("questions", [])
|
|
]
|
|
except Exception as e:
|
|
logger.error(f"LLM error: {e}")
|
|
return self._generate_true_false(source_text, num_questions, difficulty)
|
|
|
|
def _generate_matching(
|
|
self,
|
|
source_text: str,
|
|
num_pairs: int,
|
|
difficulty: str
|
|
) -> List[MatchingPair]:
|
|
"""Generiert Zuordnungsaufgaben."""
|
|
if self.llm_client:
|
|
return self._generate_matching_llm(source_text, num_pairs, difficulty)
|
|
|
|
# Automatische Generierung: Begriff -> Definition
|
|
pairs = []
|
|
definitions = self._extract_definitions(source_text)
|
|
|
|
for term, definition in definitions[:num_pairs]:
|
|
pairs.append(MatchingPair(
|
|
left=term,
|
|
right=definition,
|
|
hint=f"Beginnt mit '{definition[0]}'"
|
|
))
|
|
|
|
return pairs
|
|
|
|
def _generate_matching_llm(
|
|
self,
|
|
source_text: str,
|
|
num_pairs: int,
|
|
difficulty: str
|
|
) -> List[MatchingPair]:
|
|
"""Generiert Zuordnungen mit LLM."""
|
|
prompt = f"""
|
|
Erstelle {num_pairs} Zuordnungspaare auf Deutsch basierend auf folgendem Text.
|
|
Jedes Paar besteht aus einem Begriff und seiner Definition/Erklärung.
|
|
Schwierigkeit: {difficulty}
|
|
|
|
Text:
|
|
{source_text}
|
|
|
|
Antworte im JSON-Format:
|
|
{{
|
|
"pairs": [
|
|
{{
|
|
"term": "Begriff",
|
|
"definition": "Definition des Begriffs",
|
|
"hint": "Optionaler Hinweis"
|
|
}}
|
|
]
|
|
}}
|
|
"""
|
|
try:
|
|
response = self.llm_client.generate(prompt)
|
|
data = json.loads(response)
|
|
return [
|
|
MatchingPair(
|
|
left=p["term"],
|
|
right=p["definition"],
|
|
hint=p.get("hint")
|
|
)
|
|
for p in data.get("pairs", [])
|
|
]
|
|
except Exception as e:
|
|
logger.error(f"LLM error: {e}")
|
|
return self._generate_matching(source_text, num_pairs, difficulty)
|
|
|
|
def _generate_sorting(
|
|
self,
|
|
source_text: str,
|
|
num_items: int,
|
|
difficulty: str
|
|
) -> List[SortingItem]:
|
|
"""Generiert Sortieraufgaben."""
|
|
if self.llm_client:
|
|
return self._generate_sorting_llm(source_text, num_items, difficulty)
|
|
|
|
# Automatische Generierung: Chronologische Reihenfolge
|
|
items = []
|
|
steps = self._extract_sequence(source_text)
|
|
|
|
for i, step in enumerate(steps[:num_items]):
|
|
items.append(SortingItem(
|
|
text=step,
|
|
correct_position=i + 1
|
|
))
|
|
|
|
return items
|
|
|
|
def _generate_sorting_llm(
|
|
self,
|
|
source_text: str,
|
|
num_items: int,
|
|
difficulty: str
|
|
) -> List[SortingItem]:
|
|
"""Generiert Sortierung mit LLM."""
|
|
prompt = f"""
|
|
Erstelle eine Sortieraufgabe auf Deutsch basierend auf folgendem Text.
|
|
Finde {num_items} Elemente, die in eine logische Reihenfolge gebracht werden müssen.
|
|
(z.B. chronologisch, nach Wichtigkeit, nach Größe, etc.)
|
|
Schwierigkeit: {difficulty}
|
|
|
|
Text:
|
|
{source_text}
|
|
|
|
Antworte im JSON-Format:
|
|
{{
|
|
"category": "chronologisch/nach Größe/etc.",
|
|
"items": [
|
|
{{"text": "Erstes Element", "position": 1}},
|
|
{{"text": "Zweites Element", "position": 2}}
|
|
]
|
|
}}
|
|
"""
|
|
try:
|
|
response = self.llm_client.generate(prompt)
|
|
data = json.loads(response)
|
|
category = data.get("category")
|
|
return [
|
|
SortingItem(
|
|
text=item["text"],
|
|
correct_position=item["position"],
|
|
category=category
|
|
)
|
|
for item in data.get("items", [])
|
|
]
|
|
except Exception as e:
|
|
logger.error(f"LLM error: {e}")
|
|
return self._generate_sorting(source_text, num_items, difficulty)
|
|
|
|
def _generate_open_ended(
|
|
self,
|
|
source_text: str,
|
|
num_questions: int,
|
|
difficulty: str
|
|
) -> List[OpenQuestion]:
|
|
"""Generiert offene Fragen."""
|
|
if self.llm_client:
|
|
return self._generate_open_ended_llm(source_text, num_questions, difficulty)
|
|
|
|
# Automatische Generierung
|
|
questions = []
|
|
sentences = self._extract_factual_sentences(source_text)
|
|
|
|
question_starters = [
|
|
"Was bedeutet",
|
|
"Erkläre",
|
|
"Warum",
|
|
"Wie funktioniert",
|
|
"Nenne die Hauptmerkmale von"
|
|
]
|
|
|
|
for i, sentence in enumerate(sentences[:num_questions]):
|
|
# Extrahiere Schlüsselwort
|
|
keywords = self._extract_keywords(sentence)
|
|
if keywords:
|
|
keyword = keywords[0]
|
|
starter = question_starters[i % len(question_starters)]
|
|
question = f"{starter} '{keyword}'?"
|
|
|
|
questions.append(OpenQuestion(
|
|
question=question,
|
|
model_answer=sentence,
|
|
keywords=keywords,
|
|
points=1
|
|
))
|
|
|
|
return questions
|
|
|
|
def _generate_open_ended_llm(
|
|
self,
|
|
source_text: str,
|
|
num_questions: int,
|
|
difficulty: str
|
|
) -> List[OpenQuestion]:
|
|
"""Generiert offene Fragen mit LLM."""
|
|
prompt = f"""
|
|
Erstelle {num_questions} offene Fragen auf Deutsch basierend auf folgendem Text.
|
|
Jede Frage sollte eine ausführliche Antwort erfordern.
|
|
Schwierigkeit: {difficulty}
|
|
|
|
Text:
|
|
{source_text}
|
|
|
|
Antworte im JSON-Format:
|
|
{{
|
|
"questions": [
|
|
{{
|
|
"question": "Die Frage...",
|
|
"model_answer": "Eine vollständige Musterantwort",
|
|
"keywords": ["Schlüsselwort1", "Schlüsselwort2"],
|
|
"points": 2
|
|
}}
|
|
]
|
|
}}
|
|
"""
|
|
try:
|
|
response = self.llm_client.generate(prompt)
|
|
data = json.loads(response)
|
|
return [
|
|
OpenQuestion(
|
|
question=q["question"],
|
|
model_answer=q["model_answer"],
|
|
keywords=q.get("keywords", []),
|
|
points=q.get("points", 1)
|
|
)
|
|
for q in data.get("questions", [])
|
|
]
|
|
except Exception as e:
|
|
logger.error(f"LLM error: {e}")
|
|
return self._generate_open_ended(source_text, num_questions, difficulty)
|
|
|
|
# Hilfsmethoden
|
|
|
|
def _extract_factual_sentences(self, text: str) -> List[str]:
|
|
"""Extrahiert Fakten-Sätze aus dem Text."""
|
|
sentences = re.split(r'[.!?]+', text)
|
|
factual = []
|
|
|
|
for sentence in sentences:
|
|
sentence = sentence.strip()
|
|
# Filtere zu kurze oder fragende Sätze
|
|
if len(sentence) > 20 and '?' not in sentence:
|
|
factual.append(sentence)
|
|
|
|
return factual
|
|
|
|
def _negate_sentence(self, sentence: str) -> str:
|
|
"""Negiert eine Aussage einfach."""
|
|
# Einfache Negation durch Einfügen von "nicht"
|
|
words = sentence.split()
|
|
if len(words) > 2:
|
|
# Nach erstem Verb "nicht" einfügen
|
|
for i, word in enumerate(words):
|
|
if word.endswith(('t', 'en', 'st')) and i > 0:
|
|
words.insert(i + 1, 'nicht')
|
|
break
|
|
return ' '.join(words)
|
|
|
|
def _extract_definitions(self, text: str) -> List[Tuple[str, str]]:
|
|
"""Extrahiert Begriff-Definition-Paare."""
|
|
definitions = []
|
|
|
|
# Suche nach Mustern wie "X ist Y" oder "X bezeichnet Y"
|
|
patterns = [
|
|
r'(\w+)\s+ist\s+(.+?)[.]',
|
|
r'(\w+)\s+bezeichnet\s+(.+?)[.]',
|
|
r'(\w+)\s+bedeutet\s+(.+?)[.]',
|
|
r'(\w+):\s+(.+?)[.]',
|
|
]
|
|
|
|
for pattern in patterns:
|
|
matches = re.findall(pattern, text)
|
|
for term, definition in matches:
|
|
if len(definition) > 10:
|
|
definitions.append((term, definition.strip()))
|
|
|
|
return definitions
|
|
|
|
def _extract_sequence(self, text: str) -> List[str]:
|
|
"""Extrahiert eine Sequenz von Schritten."""
|
|
steps = []
|
|
|
|
# Suche nach nummerierten Schritten
|
|
numbered = re.findall(r'\d+[.)]\s*([^.]+)', text)
|
|
steps.extend(numbered)
|
|
|
|
# Suche nach Signalwörtern
|
|
signal_words = ['zuerst', 'dann', 'danach', 'anschließend', 'schließlich']
|
|
for word in signal_words:
|
|
pattern = rf'{word}\s+([^.]+)'
|
|
matches = re.findall(pattern, text, re.IGNORECASE)
|
|
steps.extend(matches)
|
|
|
|
return steps
|
|
|
|
def _extract_keywords(self, text: str) -> List[str]:
|
|
"""Extrahiert Schlüsselwörter."""
|
|
# Längere Wörter mit Großbuchstaben (meist Substantive)
|
|
words = re.findall(r'\b[A-ZÄÖÜ][a-zäöüß]+\b', text)
|
|
return list(set(words))[:5]
|
|
|
|
def _empty_quiz(self, quiz_type: QuizType, title: str) -> Quiz:
|
|
"""Erstellt leeres Quiz bei Fehler."""
|
|
return Quiz(
|
|
quiz_type=quiz_type,
|
|
title=title,
|
|
questions=[],
|
|
difficulty="medium"
|
|
)
|
|
|
|
def to_dict(self, quiz: Quiz) -> Dict[str, Any]:
|
|
"""Konvertiert Quiz zu Dictionary-Format."""
|
|
questions_data = []
|
|
|
|
for q in quiz.questions:
|
|
if isinstance(q, TrueFalseQuestion):
|
|
questions_data.append({
|
|
"type": "true_false",
|
|
"statement": q.statement,
|
|
"is_true": q.is_true,
|
|
"explanation": q.explanation
|
|
})
|
|
elif isinstance(q, MatchingPair):
|
|
questions_data.append({
|
|
"type": "matching",
|
|
"left": q.left,
|
|
"right": q.right,
|
|
"hint": q.hint
|
|
})
|
|
elif isinstance(q, SortingItem):
|
|
questions_data.append({
|
|
"type": "sorting",
|
|
"text": q.text,
|
|
"correct_position": q.correct_position,
|
|
"category": q.category
|
|
})
|
|
elif isinstance(q, OpenQuestion):
|
|
questions_data.append({
|
|
"type": "open_ended",
|
|
"question": q.question,
|
|
"model_answer": q.model_answer,
|
|
"keywords": q.keywords,
|
|
"points": q.points
|
|
})
|
|
|
|
return {
|
|
"quiz_type": quiz.quiz_type.value,
|
|
"title": quiz.title,
|
|
"topic": quiz.topic,
|
|
"difficulty": quiz.difficulty,
|
|
"questions": questions_data
|
|
}
|
|
|
|
def to_h5p_format(self, quiz: Quiz) -> Dict[str, Any]:
|
|
"""Konvertiert Quiz ins H5P-Format."""
|
|
if quiz.quiz_type == QuizType.TRUE_FALSE:
|
|
return self._true_false_to_h5p(quiz)
|
|
elif quiz.quiz_type == QuizType.MATCHING:
|
|
return self._matching_to_h5p(quiz)
|
|
# Weitere Typen...
|
|
return {}
|
|
|
|
def _true_false_to_h5p(self, quiz: Quiz) -> Dict[str, Any]:
|
|
"""Konvertiert True/False zu H5P."""
|
|
statements = []
|
|
for q in quiz.questions:
|
|
statements.append({
|
|
"text": q.statement,
|
|
"correct": q.is_true,
|
|
"feedback": q.explanation
|
|
})
|
|
|
|
return {
|
|
"library": "H5P.TrueFalse",
|
|
"params": {
|
|
"statements": statements,
|
|
"behaviour": {
|
|
"enableRetry": True,
|
|
"enableSolutionsButton": True
|
|
}
|
|
}
|
|
}
|
|
|
|
def _matching_to_h5p(self, quiz: Quiz) -> Dict[str, Any]:
|
|
"""Konvertiert Matching zu H5P."""
|
|
pairs = []
|
|
for q in quiz.questions:
|
|
pairs.append({
|
|
"question": q.left,
|
|
"answer": q.right
|
|
})
|
|
|
|
return {
|
|
"library": "H5P.DragText",
|
|
"params": {
|
|
"pairs": pairs,
|
|
"behaviour": {
|
|
"enableRetry": True,
|
|
"enableSolutionsButton": True
|
|
}
|
|
}
|
|
}
|