waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ data-pipeline build_review.py

10,1 KB Rohdatei
data-pipeline/build_review.py — 233 Zeilen
1 # -*- coding: utf-8 -*-
2 """Erzeugt einen HTML-Prüfbericht für die manuelle Sichtprüfung des Katalogs.
3
4 Stellt alle 575 Fragen mit Lösungen, Musterantworten, hervorgehobenen
5 Kernelementen und Abbildungen dar, damit sie gegen das Original-PDF geprüft
6 werden können. Der Bericht ist selbst tastaturbedienbar und kontraststark.
7 """
8 from __future__ import annotations
9
10 import base64
11 import html
12 import io
13 import json
14 import sys
15 from pathlib import Path
16
17 sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
18
19 BASE = Path(__file__).resolve().parent.parent
20 KATALOG = BASE / "content" / "katalog"
21 ZIEL = BASE / "docs" / "katalog-pruefbericht.html"
22
23
24 def bild_tag(bid: str, bilder: dict) -> str:
25 meta = bilder.get(bid)
26 if not meta:
27 return f'<span class="fehlt">Bild {html.escape(bid)} fehlt</span>'
28 daten = base64.b64encode((KATALOG / "assets" / meta["datei"]).read_bytes()).decode()
29 alt = meta.get("alt") or ""
30 return (f'<img src="data:image/png;base64,{daten}" alt="{html.escape(alt)}" '
31 f'title="{html.escape(bid)}">')
32
33
34 def segmente_html(feld: dict) -> str:
35 teile = []
36 for s in feld["segmente"]:
37 text = html.escape(s["t"]).replace("\n", "<br>")
38 teile.append(f"<mark>{text}</mark>" if s.get("h") else text)
39 return "".join(teile) or '<span class="fehlt">(kein Text)</span>'
40
41
42 def main() -> int:
43 data = json.loads((KATALOG / "katalog.json").read_text(encoding="utf-8"))
44 bilder = {b["id"]: b for b in data["bilder"]}
45 meta = data["meta"]
46
47 abschnitt_titel = {}
48 for kap in data["kapitel"]:
49 for a in kap["abschnitte"]:
50 abschnitt_titel[a["id"]] = a["titel"]
51 abschnitt_titel[kap["id"]] = kap["titel"]
52
53 zeilen: list[str] = []
54 aktuelle_gruppe = None
55 for f in data["fragen"]:
56 gruppe = f["abschnitt"] or f["kapitel"]
57 if gruppe != aktuelle_gruppe:
58 aktuelle_gruppe = gruppe
59 titel = abschnitt_titel.get(gruppe, gruppe)
60 zeilen.append(f'<h2 id="g-{html.escape(gruppe)}">{html.escape(gruppe)} · '
61 f'{html.escape(titel)}</h2>')
62
63 typ_label = {"mc": "Multiple Choice", "freitext": "offene Frage",
64 "lueckentext": "Lückentext"}[f["typ"]]
65 kopf = (f'<div class="kopf"><span class="id">{html.escape(f["id"])}</span>'
66 f'<span class="amtlich">amtlich {html.escape(f["amtliche_nummer"])}</span>'
67 f'<span class="typ typ-{f["typ"]}">{typ_label}</span>'
68 f'<span class="seite">PDF-Seite {f["seite"]}</span></div>')
69
70 koerper = [f'<p class="frage">{segmente_html(f["frage"])}</p>']
71 for bid in f["bilder"]:
72 koerper.append(f'<p class="bilder">{bild_tag(bid, bilder)}</p>')
73
74 if f["typ"] == "mc":
75 richtige = sum(1 for o in f["optionen"] if o["korrekt"])
76 if richtige > 1:
77 koerper.append(f'<p class="mehrfach">{richtige} richtige Antworten</p>')
78 koerper.append("<ul class='optionen'>")
79 for o in f["optionen"]:
80 marke = "richtig" if o["korrekt"] else "falsch"
81 zeichen = "✓" if o["korrekt"] else "✗"
82 inhalt = segmente_html(o["inhalt"]) if o["inhalt"]["text"].strip() else ""
83 bild = "".join(bild_tag(b, bilder) for b in o["bilder"])
84 koerper.append(
85 f'<li class="{marke}"><span class="marke" aria-label="'
86 f'{"richtig" if o["korrekt"] else "falsch"}">{zeichen}</span>'
87 f'<span class="label">{html.escape(o["label"])})</span>'
88 f'<span class="text">{inhalt}{bild}</span></li>')
89 koerper.append("</ul>")
90 else:
91 koerper.append(f'<div class="antwort"><span class="ml">Musterantwort</span>'
92 f'{segmente_html(f["musterantwort"])}</div>')
93
94 if f.get("warnungen"):
95 hinweise = "; ".join(html.escape(w) for w in f["warnungen"])
96 koerper.append(f'<p class="warnung">Hinweis aus der Extraktion: {hinweise}</p>')
97
98 zeilen.append(f'<article class="frage-block" id="{html.escape(f["id"])}">'
99 f'{kopf}{"".join(koerper)}</article>')
100
101 kennzahlen = [
102 ("Fragen", len(data["fragen"])),
103 ("Multiple Choice", sum(1 for f in data["fragen"] if f["typ"] == "mc")),
104 ("offene Fragen", sum(1 for f in data["fragen"] if f["typ"] != "mc")),
105 ("Antwortoptionen", sum(len(f.get("optionen", [])) for f in data["fragen"])),
106 ("markierte Lösungen", sum(sum(1 for o in f.get("optionen", []) if o["korrekt"])
107 for f in data["fragen"])),
108 ("Abbildungen", len(data["bilder"])),
109 ]
110 kacheln = "".join(f'<div class="kachel"><span class="wert">{w}</span>'
111 f'<span class="bez">{html.escape(b)}</span></div>'
112 for b, w in kennzahlen)
113
114 nav = "".join(
115 f'<li><a href="#g-{html.escape(g)}">{html.escape(g)} · {html.escape(t)}</a></li>'
116 for g, t in abschnitt_titel.items()
117 if any((f["abschnitt"] or f["kapitel"]) == g for f in data["fragen"]))
118
119 ZIEL.parent.mkdir(parents=True, exist_ok=True)
120 ZIEL.write_text(SEITE.format(
121 titel="Prüfbericht Fragenkatalog",
122 quelle=html.escape(meta["quellenangabe"]),
123 stand=html.escape(meta["stand"]),
124 kacheln=kacheln, nav=nav, inhalt="\n".join(zeilen),
125 # LF wie im Archiv verlangt, nicht das CRLF von Windows.
126 ), encoding="utf-8", newline="\n")
127 print(f"Prüfbericht geschrieben: {ZIEL}")
128 print(f"Größe: {ZIEL.stat().st_size / 1024:.0f} kB")
129 return 0
130
131
132 SEITE = """<!doctype html>
133 <html lang="de">
134 <head>
135 <meta charset="utf-8">
136 <meta name="viewport" content="width=device-width, initial-scale=1">
137 <title>{titel}</title>
138 <style>
139 :root {{
140 --bg: #fbfcfb; --flaeche: #f1f5f2; --rand: #d9e0db;
141 --text: #161c18; --gedaempft: #4f5a53;
142 --akzent: #2c5c4c; --richtig: #1c6b4a; --falsch: #8a3324;
143 --markierung: #f7edc9;
144 }}
145 @media (prefers-color-scheme: dark) {{
146 :root:not([data-theme="light"]) {{
147 --bg: #12160f; --flaeche: #1b211c; --rand: #2d352f;
148 --text: #e8eee9; --gedaempft: #9daaa2;
149 --akzent: #8ac7ab; --richtig: #7fc9a3; --falsch: #e2957f;
150 --markierung: #3a3520;
151 }}
152 }}
153 * {{ box-sizing: border-box; }}
154 body {{
155 margin: 0; background: var(--bg); color: var(--text);
156 font: 16px/1.6 -apple-system, "Segoe UI", system-ui, sans-serif;
157 }}
158 a {{ color: var(--akzent); }}
159 :focus-visible {{ outline: 3px solid var(--akzent); outline-offset: 2px; }}
160 .huelle {{ max-width: 62rem; margin: 0 auto; padding: 0 1.25rem 5rem; }}
161 header {{ padding: 2.5rem 0 1.25rem; border-bottom: 2px solid var(--text); }}
162 h1 {{ font-size: 1.9rem; margin: 0 0 .4rem; letter-spacing: -.01em; }}
163 .quelle {{ color: var(--gedaempft); font-size: .9rem; margin: 0; max-width: 46rem; }}
164 .kacheln {{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.4rem 0; }}
165 .kachel {{
166 background: var(--flaeche); border: 1px solid var(--rand); border-radius: 8px;
167 padding: .6rem .9rem; min-width: 8.5rem;
168 }}
169 .kachel .wert {{ display: block; font-size: 1.4rem; font-weight: 700;
170 color: var(--akzent); font-variant-numeric: tabular-nums; }}
171 .kachel .bez {{ display: block; font-size: .8rem; color: var(--gedaempft); }}
172 nav ul {{ list-style: none; padding: 0; margin: 0 0 2rem;
173 display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .3rem; }}
174 nav a {{ text-decoration: none; }}
175 nav a:hover {{ text-decoration: underline; }}
176 h2 {{ font-size: 1.25rem; margin: 2.5rem 0 1rem; padding-top: 1rem;
177 border-top: 1px solid var(--rand); }}
178 .frage-block {{
179 border: 1px solid var(--rand); border-radius: 10px; background: var(--flaeche);
180 padding: 1rem 1.2rem; margin: 0 0 1rem;
181 }}
182 .kopf {{ display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline;
183 font-size: .8rem; color: var(--gedaempft); margin-bottom: .5rem; }}
184 .kopf .id {{ font-weight: 700; color: var(--text); font-size: .95rem; }}
185 .typ {{ border: 1px solid var(--rand); border-radius: 99px; padding: .05rem .55rem; }}
186 .typ-mc {{ border-color: var(--akzent); color: var(--akzent); }}
187 .frage {{ margin: 0 0 .7rem; font-size: 1.02rem; }}
188 .mehrfach {{ margin: 0 0 .5rem; font-size: .8rem; color: var(--akzent); font-weight: 600; }}
189 ul.optionen {{ list-style: none; margin: 0; padding: 0; display: flex;
190 flex-direction: column; gap: .35rem; }}
191 ul.optionen li {{ display: grid; grid-template-columns: 1.4rem 1.4rem 1fr;
192 gap: .4rem; align-items: start; }}
193 .marke {{ font-weight: 700; }}
194 li.richtig .marke {{ color: var(--richtig); }}
195 li.falsch .marke {{ color: var(--falsch); }}
196 li.richtig .text {{ font-weight: 600; }}
197 .label {{ color: var(--gedaempft); }}
198 .antwort {{ border-left: 3px solid var(--akzent); padding: .3rem 0 .3rem .8rem; }}
199 .antwort .ml {{ display: block; font-size: .75rem; text-transform: uppercase;
200 letter-spacing: .08em; color: var(--gedaempft); }}
201 mark {{ background: var(--markierung); color: var(--text);
202 padding: 0 .15em; border-radius: 3px; font-weight: 600; }}
203 img {{ max-height: 3.2rem; width: auto; vertical-align: middle; margin: .15rem .4rem .15rem 0;
204 background: #fff; border-radius: 3px; padding: 2px; }}
205 .warnung {{ font-size: .82rem; color: var(--falsch); margin: .6rem 0 0; }}
206 .fehlt {{ color: var(--falsch); font-style: italic; }}
207 footer {{ margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--rand);
208 font-size: .85rem; color: var(--gedaempft); }}
209 </style>
210 </head>
211 <body>
212 <div class="huelle">
213 <header>
214 <h1>Prüfbericht Fragenkatalog</h1>
215 <p class="quelle">{quelle} — Katalogstand {stand}. Grün markierte Antworten sind
216 im Original angekreuzt; <mark>hervorgehobene</mark> Stellen sind die Kernelemente
217 der Musterantworten.</p>
218 </header>
219 <div class="kacheln">{kacheln}</div>
220 <nav aria-label="Abschnitte"><ul>{nav}</ul></nav>
221 <main>
222 {inhalt}
223 </main>
224 <footer>Automatisch erzeugt aus dem amtlichen Katalog-PDF. Dient der Sichtprüfung
225 der Extraktion, nicht dem Lernen.</footer>
226 </div>
227 </body>
228 </html>
229 """
230
231
232 if __name__ == "__main__":
233 raise SystemExit(main())