"""Führt einzeln erarbeitete Erklärungen zu `content/erklaerungen.json` zusammen. Die Erklärungen entstehen abschnittweise in Teilstücken. Dieses Skript legt sie zusammen, setzt den Kopf mit dem tatsächlichen Gesetzesstand und schreibt das Ergebnis in einer festen Reihenfolge – so bleibt der Unterschied zwischen zwei Ständen im Versionsverlauf lesbar und besteht nicht aus umsortierten Zeilen. Ein Teilstück ist eine JSON-Datei mit denselben Einträgen wie `zuFrage`, entweder unmittelbar:: { "I.1-01": { … }, "I.1-02": { … } } oder in einen Umschlag gepackt:: { "zuFrage": { "I.1-01": { … } } } Aufruf ------ python data-pipeline/erklaerungen_zusammenfuehren.py … Vorhandene Erklärungen bleiben erhalten; gleichnamige Einträge aus einem Teilstück ersetzen sie. Mit `--nur-neu` bleibt der Bestand unangetastet und es werden ausschließlich fehlende Einträge ergänzt. Nach dem Zusammenführen läuft die Prüfung – ohne sie wird nicht geschrieben. """ from __future__ import annotations import argparse import io import json import sys from pathlib import Path WURZEL = Path(__file__).resolve().parent.parent GESETZE = WURZEL / 'content' / 'gesetze' / 'index.json' KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json' ZIEL = WURZEL / 'content' / 'erklaerungen.json' ZWEIFEL = WURZEL / 'docs' / 'erklaerungen-zweifel.md' #: Ablage der Redaktionsvermerke - **nicht** ausgeliefert. #: #: Sie braucht einen eigenen Ort, und das ist der Kern der Sache. Bis Fassung #: 0.24.1 wurden die Vermerke aus dem Bestand herausgenommen und nur nach #: `ZWEIFEL` geschrieben; jener Lauf ueberschrieb die Datei vollstaendig, und #: beim naechsten Durchgang war der Vermerk nirgends mehr - weder im Bestand #: noch in der Datei. Sechs von zwoelf gingen so verloren, nachweisbar in der #: Projektgeschichte. #: #: `content/erklaerungen.json` kommt als Ablage nicht in Frage: Sie wird #: ausgeliefert, und die Vermerke sollen Lernende ausdruecklich nicht #: erreichen. Deshalb diese Datei hier neben dem Werkzeug. ZWEIFEL_ABLAGE = WURZEL / 'data-pipeline' / 'zweifel.json' #: Muss Wort fuer Wort dem meta.hinweis in content/erklaerungen.json #: entsprechen - ein Zusammenfuehren schreibt diesen Text dorthin zurueck und #: wuerde eine Praezisierung sonst stillschweigend wieder einkassieren. HINWEIS = ( 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Fragenkatalogs des ' 'Bundesverwaltungsamtes. Jede Fundstelle wird von ' 'data-pipeline/pruefe_erklaerungen.py gegen den amtlichen Gesetzestext von ' 'gesetze-im-internet.de geprueft: nachgewiesen werden Existenz der Norm und ' 'jede angegebene Feinstelle (Absatz, Nummer, Buchstabe, Anlagen-Stelle; ' 'Satzangaben als Plausibilitaetsgrenze, da das amtliche XML Saetze nicht ' 'auszeichnet). Ob eine Norm die Aussage inhaltlich traegt, prueft die ' 'Redaktion, nicht die Maschine.' ) def teilstuecke_einlesen(orte: list[str]) -> dict[str, dict]: """Liest alle angegebenen Dateien und Verzeichnisse ein.""" gesammelt: dict[str, dict] = {} dateien: list[Path] = [] for ort in orte: pfad = Path(ort) if pfad.is_dir(): dateien.extend(sorted(pfad.glob('*.json'))) elif pfad.is_file(): dateien.append(pfad) else: raise SystemExit(f'Nicht gefunden: {ort}') for datei in dateien: if datei.resolve() == ZIEL.resolve(): continue # das Ziel ist kein Teilstück roh = json.load(io.open(datei, encoding='utf-8')) eintraege = roh.get('zuFrage') if isinstance(roh, dict) and 'zuFrage' in roh else roh if not isinstance(eintraege, dict): raise SystemExit(f'{datei}: erwartet wird ein Objekt mit Frage-Kennungen.') for frage_id, erklaerung in eintraege.items(): if frage_id in gesammelt: print(f' Hinweis: {frage_id} kommt mehrfach vor – {datei.name} gilt.') gesammelt[frage_id] = erklaerung print(f' {datei.name}: {len(eintraege)} Erklärungen') return gesammelt #: Felder einer Fundstelle, die als Zeichenkette vorliegen müssen. FUNDSTELLENFELDER = ('gesetz', 'norm', 'absatz', 'nummer', 'buchstabe', 'satz', 'stelle') def fundstellen_normalisieren(bestand: dict[str, dict]) -> int: """Macht aus Zahlen Zeichenketten. `"absatz": 2` statt `"absatz": "2"` ist eine naheliegende Verwechslung und inhaltlich harmlos – der Lader in der Anwendung weist sie aber ab, weil er auf den Typ prüft. Statt das jedes Mal von Hand zu berichtigen, wird hier einmal umgesetzt: an der Stelle, an der die Teilstücke ohnehin zusammengeführt werden. Bewusst nur Zahlen. Alles andere bleibt stehen und fällt der Prüfung auf. """ geaendert = 0 for erklaerung in bestand.values(): for fundstelle in erklaerung.get('fundstellen') or []: if not isinstance(fundstelle, dict): continue for feld in FUNDSTELLENFELDER: wert = fundstelle.get(feld) if isinstance(wert, bool): continue # bool ist in Python eine Zahl – hier keine if isinstance(wert, (int, float)): fundstelle[feld] = str(wert) geaendert += 1 return geaendert def sortierschluessel(frage_id: str, reihenfolge: dict[str, int]) -> tuple[int, str]: """Katalogreihenfolge, Unbekanntes ans Ende.""" return (reihenfolge.get(frage_id, len(reihenfolge)), frage_id) def main() -> int: zerleger = argparse.ArgumentParser(description='Erklärungen zusammenführen.') zerleger.add_argument('quellen', nargs='+', help='Dateien oder Verzeichnisse') zerleger.add_argument( '--nur-neu', action='store_true', help='Vorhandene Einträge nicht ersetzen, nur fehlende ergänzen', ) argumente = zerleger.parse_args() print('Teilstücke:') neue = teilstuecke_einlesen(argumente.quellen) bestand: dict[str, dict] = {} if ZIEL.exists(): bestand = json.load(io.open(ZIEL, encoding='utf-8')).get('zuFrage') or {} vorher = len(bestand) ersetzt = 0 for frage_id, erklaerung in neue.items(): if frage_id in bestand: if argumente.nur_neu: continue ersetzt += 1 bestand[frage_id] = erklaerung normalisiert = fundstellen_normalisieren(bestand) if normalisiert: print(f' {normalisiert} Zahlangaben in Fundstellen zu Zeichenketten gemacht.') katalog = json.load(io.open(KATALOG, encoding='utf-8')) reihenfolge = {f['id']: i for i, f in enumerate(katalog['fragen'])} # Zweifel an der amtlichen Loesung sind eine Notiz fuer die Redaktion und # gehoeren nicht in die Auslieferung: Sie wuerden Lernende verunsichern, # ohne dass sie an der Pruefung etwas aendern koennten. Verloren gehen # duerfen sie trotzdem nicht - sie sind das Wertvollste am Durchgang. # # Genau das ist bis Fassung 0.24.1 geschehen. Hier stand `pop`: Der # Vermerk wurde aus dem Bestand HERAUSGENOMMEN und nur in die # Markdown-Datei geschrieben, die anschliessend vollstaendig ueberschrieben # wird. Beim naechsten Lauf war er im Bestand nicht mehr da, also stand er # auch nicht mehr in der Datei. Die Projektgeschichte zeigt es Schritt fuer # Schritt: erst I.1-83, dann nur noch I.2-21, dann nur noch vier aus # Kapitel II/III, zuletzt nur noch die sechs des letzten Durchgangs. # Sechs von zwoelf Vermerken waren damit fort. # # Jetzt bleibt der Vermerk im Bestand stehen; herausgefiltert wird er erst # beim Schreiben von erklaerungen.json (siehe `ohne_zweifel`). zweifel = zweifel_zusammenfuehren(bestand) zweifel_ablegen(zweifel) zweifel_schreiben(zweifel, katalog) gesetze = json.load(io.open(GESETZE, encoding='utf-8'))['gesetze'] # Nur die Gesetze vermerken, die auch zitiert werden – ein Stand, auf den # sich nichts bezieht, wäre eine Behauptung ohne Gegenstand. zitiert = { f.get('gesetz') for e in bestand.values() for f in (e.get('fundstellen') or []) if isinstance(f, dict) } ergebnis = { 'meta': { 'version': 1, 'stand': heute(), 'hinweis': HINWEIS, 'gesetzesstand': { k: gesetze[k]['stand'] for k in gesetze if k in zitiert }, }, 'zuFrage': { frage_id: ohne_zweifel(bestand[frage_id]) for frage_id in sorted(bestand, key=lambda k: sortierschluessel(k, reihenfolge)) }, } with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei: json.dump(ergebnis, datei, ensure_ascii=False, indent=1) datei.write('\n') gesamt = len(katalog['fragen']) print( f'\nvorher {vorher}, neu {len(bestand) - vorher}, ersetzt {ersetzt} ' f'-> {len(bestand)} von {gesamt} Fragen ({len(bestand) / gesamt * 100:.1f} %)' ) print(f'geschrieben: {ZIEL.name}') print('\nJetzt prüfen mit: python data-pipeline/pruefe_erklaerungen.py') return 0 def zweifel_schreiben(zweifel: dict[str, str], katalog: dict) -> None: """Haelt Zweifel an der amtlichen Loesung als Liste zur Durchsicht fest.""" if not zweifel: return fragen = {f['id']: f for f in katalog['fragen']} zeilen = [ '# Zweifel an amtlichen Loesungen', '', 'Beim Schreiben der Erklaerungen aufgefallen: Stellen, an denen der', 'amtliche Fragenkatalog von der abgerufenen Fassung des Gesetzes', 'abweicht oder in sich unstimmig wirkt.', '', 'Diese Vermerke werden **nicht** ausgeliefert. Die Erklaerungen folgen', 'durchgaengig der amtlichen Loesung - in der Pruefung wird danach', 'bewertet. Die Liste dient der Redaktion und einer moeglichen Rueckmeldung', 'an das Bundesverwaltungsamt.', '', 'Maschinell erzeugt von data-pipeline/erklaerungen_zusammenfuehren.py.', '', ] for frage_id in sorted(zweifel, key=lambda k: (fragen.get(k, {}).get('seite', 0), k)): frage = fragen.get(frage_id, {}) zeilen.append(f'## {frage_id} (amtliche Nr. {frage.get("amtliche_nummer", "?")})') zeilen.append('') zeilen.append(f'**Frage:** {frage.get("frage", {}).get("text", "")}') zeilen.append('') zeilen.append(zweifel[frage_id]) zeilen.append('') with io.open(ZWEIFEL, 'w', encoding='utf-8', newline='\n') as datei: datei.write('\n'.join(zeilen)) print(f'{len(zweifel)} Zweifel vermerkt in {ZWEIFEL.name}') def zweifel_zusammenfuehren(bestand: dict[str, dict]) -> dict[str, str]: """Die Vermerke aus der Ablage und aus dem frisch Eingelesenen. Was in einem Teilstueck steht, gewinnt: Wer einen Vermerk neu formuliert, will die neue Fassung. Was nur in der Ablage steht, bleibt erhalten - das ist der ganze Zweck der Ablage. """ gesammelt: dict[str, str] = {} if ZWEIFEL_ABLAGE.exists(): gesammelt.update(json.load(io.open(ZWEIFEL_ABLAGE, encoding='utf-8')).get('zuFrage') or {}) for frage_id, erklaerung in bestand.items(): vermerk = erklaerung.get('zweifel') if vermerk: gesammelt[frage_id] = vermerk return gesammelt def zweifel_ablegen(zweifel: dict[str, str]) -> None: """Schreibt die Ablage - die Quelle, aus der die Liste jedesmal entsteht.""" ergebnis = { 'hinweis': ( 'Redaktionsvermerke zu Zweifeln an amtlichen Loesungen. Wird NICHT ' 'ausgeliefert. Erzeugt und fortgeschrieben von ' 'data-pipeline/erklaerungen_zusammenfuehren.py; die lesbare Fassung ' 'steht in docs/erklaerungen-zweifel.md.' ), 'zuFrage': dict(sorted(zweifel.items())), } with io.open(ZWEIFEL_ABLAGE, 'w', encoding='utf-8', newline='\n') as datei: json.dump(ergebnis, datei, ensure_ascii=False, indent=1) datei.write('\n') def ohne_zweifel(erklaerung: dict) -> dict: """Die Erklaerung, wie sie ausgeliefert wird - ohne den Redaktionsvermerk. Herausgefiltert statt herausgenommen: Im Bestand bleibt der Vermerk stehen, damit er den naechsten Durchgang ueberlebt. Siehe `main()`. """ return {schluessel: wert for schluessel, wert in erklaerung.items() if schluessel != 'zweifel'} def heute() -> str: """Datum als ISO-Zeichenkette.""" from datetime import date return date.today().isoformat() if __name__ == '__main__': sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] raise SystemExit(main())