"""Baut ``content/normtexte.json`` – die Normtexte, die mitgeliefert werden. Warum es diese Datei gibt ------------------------- Jede Erklärungstafel schließt mit „Im Gesetz nachlesen" und einer Liste von Fundstellen. Bis 0.22.0 waren das reine Textzitate ohne Sprungziel, und die Anwendung lieferte keinen einzigen Normtext mit. Der Lernende einer ausdrücklich vollständig offline arbeitenden Software konnte die Aufforderung also gerade nicht offline erfüllen. Die Datenbasis liegt seit jeher vor: ``content/gesetze/index.json`` ist die Prüfgrundlage jeder einzelnen Fundstelle. Sie enthält die sieben Gesetze aber **vollständig** – 914 Normen, darunter katalogfremde wie § 173 StGB. Diese Datei hier ist der Auszug: nur die Normen, die in ``erklaerungen.json`` oder ``glossar.json`` wirklich zitiert werden. Zitiert wird nichts hinzugefügt und nichts umformuliert; Gesetzestexte sind nach § 5 Abs. 1 UrhG gemeinfrei. Die Anlagen sind der schwierige Teil ------------------------------------ 632 der 2138 Fundstellen zeigen in eine Anlage, allein 391 in Anlage 1 des WaffG – und die ist 29 000 Zeichen lang. Sie als eine Textwand anzuzeigen hilft niemandem, am wenigsten mit Bildschirmleser. Die Versuchung wäre, den zitierten Ausschnitt herauszuschneiden. Genau das macht dieses Skript **nicht**. Ein falsch gesetzter Schnitt wäre ein verfälschtes Gesetzeszitat in einer Lernsoftware für eine Rechtsprüfung – der schlimmste Fehler, den diese Anwendung machen könnte. Stattdessen wird die Anlage **verlustfrei** in ihre eigenen Gliederungsblöcke zerlegt: Aneinander gehängt ergeben die Blöcke wieder Zeichen für Zeichen den Ausgangstext, und genau das prüft das Skript, bevor es etwas schreibt. Die Blockmarken sind für die Anwendung nur Sprungziele. Beschnitten wird nichts. Aufruf ------ python data-pipeline/normtexte_bauen.py python data-pipeline/normtexte_bauen.py --selbsttest """ from __future__ import annotations import json import re import sys from datetime import date, timezone, datetime from pathlib import Path WURZEL = Path(__file__).resolve().parent.parent INDEX = WURZEL / 'content' / 'gesetze' / 'index.json' ERKLAERUNGEN = WURZEL / 'content' / 'erklaerungen.json' GLOSSAR = WURZEL / 'content' / 'glossar.json' ZIEL = WURZEL / 'content' / 'normtexte.json' #: Eine Gliederungsmarke am Zeilenanfang: „1.", „1.1", „2.3.4". #: Die Anlagen setzen sie auf eine eigene Zeile, der Text folgt darunter. MARKE = re.compile(r'^(\d+(?:\.\d+)*)\.?$') #: „Abschnitt 1:", „Unterabschnitt 3:" – ebenfalls auf eigener Zeile. GLIEDERUNGSZEILE = re.compile(r'^((?:Unter)?[Aa]bschnitt)\s+([0-9IVX]+)\s*:?$') HINWEIS = ( 'Maschinell erzeugter Auszug aus dem amtlichen XML von gesetze-im-internet.de ' '(Bundesministerium der Justiz / juris GmbH). Enthaelt ausschliesslich die Normen, ' 'die in den Erklaerungen oder im Glossar dieser Software zitiert werden. Der ' 'Wortlaut ist unveraendert; Gesetzestexte sind nach § 5 Abs. 1 UrhG gemeinfrei. ' 'Nicht von Hand aendern - neu erzeugen mit: python data-pipeline/normtexte_bauen.py' ) def zitierte_normen() -> dict[str, set[str]]: """Alle Normen, die irgendwo zitiert werden – Gesetz -> Menge der Normnamen.""" zitiert: dict[str, set[str]] = {} erklaerungen = json.loads(ERKLAERUNGEN.read_text(encoding='utf-8')) for eintrag in erklaerungen['zuFrage'].values(): for fundstelle in eintrag.get('fundstellen', []): zitiert.setdefault(fundstelle['gesetz'], set()).add(fundstelle['norm']) glossar = json.loads(GLOSSAR.read_text(encoding='utf-8')) for eintrag in glossar['eintraege']: for fundstelle in eintrag.get('fundstellen', []): zitiert.setdefault(fundstelle['gesetz'], set()).add(fundstelle['norm']) return zitiert def anlage_zerlegen(text: str) -> list[dict]: """Zerlegt eine Anlage verlustfrei in ihre Gliederungsblöcke. Jeder Block trägt die Marke, unter der er in der Anlage steht („1.3.1.3", „Abschnitt 2"), und seinen Text. **Verlustfrei** heißt: Die Texte aller Blöcke, in dieser Reihenfolge mit Zeilenumbruch verbunden, ergeben wieder genau den Ausgangstext. Der Aufrufer prüft das, bevor etwas geschrieben wird – ein beschnittenes Gesetzeszitat wäre der schlimmste Fehler, den diese Anwendung machen kann. Der Vorspann vor der ersten Marke bekommt ``marke = None``. Er fällt dadurch nicht weg, er ist nur kein Sprungziel. Jeder Block trägt zusätzlich seinen ``pfad`` – die Abschnitte, in denen er steht. Das ist keine Zugabe, sondern notwendig: In Anlage 1 des WaffG kommt die Marke „1.1" **vier**mal vor, in verschiedenen Unterabschnitten. Ohne Pfad spränge eine Fundstelle in die falsche Stelle des Gesetzes, und zwar unbemerkt. """ bloecke: list[dict] = [] pfad: list[str] = [] laufend: dict = {'marke': None, 'pfad': [], 'zeilen': []} for zeile in text.split('\n'): roh = zeile.strip() marke: str | None = None treffer = MARKE.match(roh) if treffer is not None: marke = treffer.group(1) else: gliederung = GLIEDERUNGSZEILE.match(roh) if gliederung is not None: wort = gliederung.group(1).capitalize() marke = f'{wort} {gliederung.group(2)}' # „Abschnitt 2" beginnt von vorn, „Unterabschnitt 3" haengt # sich an den laufenden Abschnitt. pfad = [marke] if wort == 'Abschnitt' else [*pfad[:1], marke] if marke is not None: # Der bisherige Block ist zu Ende – auch ein leerer Vorspann wird # aufgehoben, sonst ginge seine Zeile verloren. if laufend['zeilen'] or laufend['marke'] is not None: bloecke.append(laufend) # Der Abschnittsblock selbst steht ueber seinem Pfad, nicht darin. eigener = pfad[:-1] if marke == (pfad[-1] if pfad else None) else list(pfad) laufend = {'marke': marke, 'pfad': eigener, 'zeilen': [zeile]} else: laufend['zeilen'].append(zeile) if laufend['zeilen'] or laufend['marke'] is not None: bloecke.append(laufend) return [ {'marke': block['marke'], 'pfad': block['pfad'], 'text': '\n'.join(block['zeilen'])} for block in bloecke ] def zusammensetzen(bloecke: list[dict]) -> str: """Die Gegenprobe zu :func:`anlage_zerlegen`.""" return '\n'.join(block['text'] for block in bloecke) def bauen() -> dict: index = json.loads(INDEX.read_text(encoding='utf-8')) zitiert = zitierte_normen() gesetze: dict[str, dict] = {} fehlend: list[str] = [] anlagen = 0 paragrafen = 0 for kuerzel in sorted(zitiert): quelle = index['gesetze'].get(kuerzel) if quelle is None: fehlend.append(kuerzel) continue normen: dict[str, dict] = {} for name in sorted(zitiert[kuerzel], key=sortierschluessel): norm = quelle['normen'].get(name) if norm is None: fehlend.append(f'{kuerzel} {name}') continue if norm['ist_anlage']: bloecke = anlage_zerlegen(norm['text']) # Die Gegenprobe. Sie steht hier und nicht im Selbsttest: # Sie muss fuer jede ausgelieferte Anlage gelten, nicht nur # fuer ein Beispiel. if zusammensetzen(bloecke) != norm['text']: raise SystemExit( f'ABBRUCH: Die Zerlegung von {kuerzel} {name} ist nicht verlustfrei. ' 'Es wird nichts geschrieben.' ) normen[name] = { 'titel': norm['titel'], 'istAnlage': True, 'bloecke': bloecke, } anlagen += 1 else: # Alle Absaetze der zitierten Norm, nicht nur die zitierten: # Wer einen Absatz nachschlaegt, liest oft den daneben mit - # und eine halb ausgelieferte Norm waere eine zweite Sorte # Luecke, die niemand erwartet. normen[name] = { 'titel': norm['titel'], 'istAnlage': False, 'absaetze': { nummer: absatz['text'] for nummer, absatz in norm['absaetze'].items() }, } # Normen ganz ohne Absatzgliederung tragen ihren Text im Feld # „text"; ohne diesen Zweig kaemen sie leer heraus. if not normen[name]['absaetze']: normen[name]['text'] = norm['text'] paragrafen += 1 gesetze[kuerzel] = { 'bezeichnung': quelle['bezeichnung'], 'stand': quelle['stand'], 'quelle': quelle['quelle'], 'normen': normen, } if fehlend: raise SystemExit(f'ABBRUCH: nicht im Index gefunden: {", ".join(fehlend)}') print(f'{paragrafen} Paragrafen und {anlagen} Anlagen aus {len(gesetze)} Gesetzen.') return { 'meta': { 'version': 1, 'stand': date.today().isoformat(), 'hinweis': HINWEIS, 'gesetzesstand': {k: g['stand'] for k, g in gesetze.items()}, }, 'gesetze': gesetze, } def sortierschluessel(name: str) -> tuple: """Normen in ihrer natürlichen Reihenfolge: § 2 vor § 10, Anlagen zuletzt.""" treffer = re.match(r'^§ (\d+)([a-z]*)$', name) if treffer is not None: return (0, int(treffer.group(1)), treffer.group(2)) return (1, 0, name) def selbsttest() -> int: """Prüft die Zerlegung an Fällen, deren Ergebnis von Hand feststeht.""" fehler = 0 def pruefe(name: str, bedingung: bool) -> None: nonlocal fehler if not bedingung: print(f' FEHLER: {name}') fehler += 1 else: print(f' ok: {name}') text = 'Vorspann\nAbschnitt 1:\nÜberschrift\n1.1\nErster Satz.\n1.2\nZweiter Satz.' bloecke = anlage_zerlegen(text) pruefe('verlustfrei', zusammensetzen(bloecke) == text) pruefe('Vorspann bleibt erhalten', bloecke[0]['marke'] is None) pruefe('Abschnitt wird erkannt', bloecke[1]['marke'] == 'Abschnitt 1') pruefe('Nummern werden erkannt', [b['marke'] for b in bloecke[2:]] == ['1.1', '1.2']) pruefe( 'die Marke bleibt im Text stehen', bloecke[2]['text'].startswith('1.1'), ) tief = '1.3\nEbene zwei\n1.3.1.3\nEbene vier' pruefe('tiefe Nummern', [b['marke'] for b in anlage_zerlegen(tief)] == ['1.3', '1.3.1.3']) # Eine Zeile, die nur zufaellig mit einer Zahl beginnt, ist keine Marke. kein = '1. Januar 2026 gilt Folgendes\nWeiter im Text' pruefe('kein Fehlalarm bei Fliesstext', anlage_zerlegen(kein)[0]['marke'] is None) leer = '' pruefe('leerer Text bleibt verlustfrei', zusammensetzen(anlage_zerlegen(leer)) == leer) return fehler def main() -> int: if '--selbsttest' in sys.argv: fehler = selbsttest() print('Selbsttest bestanden.' if fehler == 0 else f'{fehler} Fehler.') return 1 if fehler else 0 daten = bauen() ZIEL.write_text( json.dumps(daten, ensure_ascii=False, indent=1) + '\n', encoding='utf-8', # LF wie im Archiv verlangt, nicht das CRLF von Windows. newline='\n', ) groesse = ZIEL.stat().st_size print(f'Geschrieben: {ZIEL.relative_to(WURZEL)} ({groesse / 1024:.0f} KiB)') return 0 if __name__ == '__main__': raise SystemExit(main())