-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathvalidate_sets.py
More file actions
219 lines (183 loc) · 9.35 KB
/
Copy pathvalidate_sets.py
File metadata and controls
219 lines (183 loc) · 9.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#!/usr/bin/env python3
"""
Qualitätssicherungs-Skript für MC-Test Fragensets.
Dieses Skript validiert alle `questions_*.json`-Dateien im `data/`-Verzeichnis
gegen eine Reihe von Regeln, die aus den Projekt-Konventionen (z.B. README.md)
abgeleitet sind.
Zweck:
- Sicherstellung der JSON-Struktur und Datenintegrität.
- Aufdeckung von häufigen Formatierungsfehlern (z.B. LaTeX in Backticks).
- Überprüfung didaktischer Vorgaben (z.B. Themenverteilung).
Ausführung:
python validate_sets.py
python validate_sets.py data/questions_IoT:_Protokolle_&_Konnektivität.json
python validate_sets.py data/questions_*.json
Das Skript gibt einen Exit-Code > 0 zurück, wenn Fehler gefunden wurden,
und ist somit für CI/CD-Pipelines geeignet.
"""
import json
import re
from pathlib import Path
import logging
import sys
import glob
from i18n import translate
# Configure a simple logger for this CLI script so messages are visible when
# the script is run directly. Keep formatting minimal to match previous prints.
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(message)s")
# --- Konfiguration der Prüfregeln ---
MIN_THEMA_OCCURRENCES = 2
MAX_UNIQUE_THEMES = 12
MIN_GLOSSARY_ENTRIES = 2
MAX_GLOSSARY_ENTRIES = 6
# Regex, um LaTeX in Backticks zu finden.
# Sucht nach einem Backtick, gefolgt von einem Dollarzeichen (oder umgekehrt),
# was auf eine falsche Verschachtelung wie `$k$` hindeutet. Die neue Regex
# ist präziser und vermeidet False Positives über Wortgrenzen hinweg.
LATEX_IN_BACKTICKS_PATTERN = re.compile(r"`(\s*?\$[^`]+\$?\s*?)`|`([^`]*?\$\s*?)`")
# Regex, um spitze Klammern in LaTeX zu finden, die in HTML-Renderern Probleme verursachen.
# Ignoriert dabei explizite LaTeX-Befehle wie \langle und \rangle.
PROBLEMATIC_ANGLE_BRACKETS_IN_LATEX = re.compile(r"\$[^$]*?(?<!\\)<[^<>\n]*?(?<!\\)>[^$]*?\$")
def validate_question_set(filepath: Path) -> tuple[list[str], list[str]]:
"""
Prüft eine einzelne Fragenset-Datei.
Gibt ein Tupel aus zwei Listen zurück: (errors, warnings).
"""
errors = []
warnings = []
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
# Localized error message
msg = translate("validate_sets.errors.invalid_json", default="Ungültiges JSON: {error}")
errors.append(msg.format(error=e))
return errors, warnings
except Exception as e:
msg = translate("validate_sets.errors.could_not_read", default="Konnte Datei nicht lesen: {error}")
errors.append(msg.format(error=e))
return errors, warnings
# --- Meta-Prüfungen ---
if "meta" not in data or "questions" not in data:
warnings.append("Top-Level-Keys 'meta' und 'questions' müssen existieren. Weitere Prüfungen für diese Datei werden übersprungen.")
return errors, warnings # Frühzeitiger Abbruch, da weitere Prüfungen fehlschlagen würden
meta, questions = data.get("meta", {}), data.get("questions", [])
if meta.get("question_count") != len(questions):
errors.append(
f"meta.question_count ({meta.get('question_count')}) stimmt nicht mit der "
f"Anzahl der Fragen ({len(questions)}) überein."
)
# Prüfung von meta.title
if "title" not in meta or not isinstance(meta["title"], str) or not meta["title"].strip():
errors.append("meta.title muss ein nicht-leerer String sein.")
# --- Prüfungen pro Frage ---
all_themes = []
for i, q in enumerate(questions, 1):
# Pflichtfelder pro Frage (nun englische Schlüssel)
required_fields = ["question", "options", "answer", "explanation", "weight", "topic", "concept"]
for field in required_fields:
if field not in q:
errors.append(f"Frage {i}: Pflichtfeld '{field}' fehlt.")
# Logik-Prüfungen
if "answer" in q and "options" in q:
if not (0 <= q["answer"] < len(q["options"])):
errors.append(f"Frage {i}: 'answer' ({q['answer']}) ist ein ungültiger Index für 'options' (Länge {len(q['options'])}).")
if "weight" in q and q["weight"] not in [1, 2, 3]:
warnings.append(f"Frage {i}: 'weight' ({q['weight']}) sollte 1, 2 oder 3 sein.")
# Formatierungs-Prüfungen
for key, value in q.items():
if isinstance(value, str):
if LATEX_IN_BACKTICKS_PATTERN.search(value):
errors.append(f"Frage {i}, Feld '{key}': LaTeX-Formel in Backticks gefunden. Bitte korrigieren.")
if PROBLEMATIC_ANGLE_BRACKETS_IN_LATEX.search(value):
warnings.append(f"Frage {i}, Feld '{key}': LaTeX mit '<' oder '>' gefunden. Für HTML-Exporte '$\\langle x, y \\rangle$' statt '$<x,y>$' verwenden.")
elif isinstance(value, list):
for item in value:
if isinstance(item, str) and LATEX_IN_BACKTICKS_PATTERN.search(item):
errors.append(f"Frage {i}, Feld '{key}': LaTeX-Formel in Backticks in einem Listenelement gefunden.")
if isinstance(item, str) and PROBLEMATIC_ANGLE_BRACKETS_IN_LATEX.search(item):
warnings.append(f"Frage {i}, Feld '{key}': LaTeX mit '<' oder '>' in einem Listenelement gefunden. Für HTML-Exporte '$\\langle ... \\rangle$' verwenden.")
elif isinstance(value, dict):
for sub_key, sub_value in value.items():
if isinstance(sub_value, str) and LATEX_IN_BACKTICKS_PATTERN.search(sub_value):
errors.append(f"Frage {i}, Feld '{key}.{sub_key}': LaTeX-Formel in Backticks gefunden.")
if isinstance(sub_value, str) and PROBLEMATIC_ANGLE_BRACKETS_IN_LATEX.search(sub_value):
warnings.append(f"Frage {i}, Feld '{key}.{sub_key}': LaTeX mit '<' oder '>' gefunden. Für HTML-Exporte '$\\langle ... \\rangle$' verwenden.")
# Glossar-Anzahl prüfen
if "mini_glossary" in q and isinstance(q["mini_glossary"], dict):
glossary_count = len(q["mini_glossary"])
if not (MIN_GLOSSARY_ENTRIES <= glossary_count <= MAX_GLOSSARY_ENTRIES):
warnings.append(
f"Frage {i}: mini_glossary hat {glossary_count} Einträge. "
f"Empfohlen sind {MIN_GLOSSARY_ENTRIES}-{MAX_GLOSSARY_ENTRIES}."
)
all_themes.append(q.get("topic", "Unbekannt"))
# --- Globale Prüfungen (Themen, etc.) ---
if all_themes:
theme_counts = {theme: all_themes.count(theme) for theme in set(all_themes)}
if len(theme_counts) > MAX_UNIQUE_THEMES:
warnings.append(f"Mehr als {MAX_UNIQUE_THEMES} verschiedene Themen gefunden ({len(theme_counts)}).")
for theme, count in theme_counts.items():
if count < MIN_THEMA_OCCURRENCES:
warnings.append(f"Thema '{theme}' kommt nur {count}x vor (empfohlen: mind. {MIN_THEMA_OCCURRENCES}x).")
return errors, warnings
def main():
"""
Hauptfunktion des Skripts.
Durchläuft alle Fragensets und gibt die Ergebnisse aus.
"""
project_root = Path(__file__).parent
data_dir = project_root / "data"
all_files_ok = True
total_errors = 0
total_warnings = 0
args = [arg for arg in sys.argv[1:] if arg.strip()]
if args:
question_files: list[Path] = []
for arg in args:
if any(ch in arg for ch in ["*", "?", "["]):
for match in glob.glob(arg):
question_files.append(Path(match))
continue
candidate = Path(arg)
if candidate.is_dir():
question_files.extend(candidate.glob("questions_*.json"))
continue
question_files.append(candidate)
question_files = sorted({path.resolve() for path in question_files})
else:
question_files = sorted(list(data_dir.glob("questions_*.json")))
if not question_files:
if args:
logger.error("Keine Fragenset-Dateien für die angegebenen Pfade gefunden.")
else:
logger.error("Keine Fragenset-Dateien (questions_*.json) im 'data'-Verzeichnis gefunden.")
return
logger.info(f"Starte Validierung für {len(question_files)} Fragensets...\n")
for filepath in question_files:
logger.info(f"--- Prüfe {filepath.name} ---")
errors, warnings = validate_question_set(filepath)
if errors:
all_files_ok = False
total_errors += len(errors)
logger.error(f"🔴 {len(errors)} Fehler gefunden:")
for error in errors:
logger.error(f" - {error}")
if warnings:
total_warnings += len(warnings)
logger.warning(f"🟡 {len(warnings)} Warnungen gefunden:")
for warning in warnings:
logger.warning(f" - {warning}")
if not errors and not warnings:
logger.info("✅ Alles in Ordnung.")
logger.info("-" * (len(filepath.name) + 8) + "\n")
logger.info("--- Validierung abgeschlossen ---")
if all_files_ok:
logger.info(f"✅ Alle {len(question_files)} Fragensets sind valide. {total_warnings} Warnungen gefunden.")
exit(0)
else:
logger.error(f"❌ {total_errors} Fehler und {total_warnings} Warnungen in {len(question_files)} Fragensets gefunden.")
exit(1)
if __name__ == "__main__":
main()