"""Baut aus den amtlichen Gesetzes-XML einen prüfbaren Fundstellenindex. Warum das sein muss ------------------- Die Erklärungstexte zu den 575 Prüfungsfragen nennen Fundstellen wie „§ 12 Abs. 1 Nr. 3 Buchst. b WaffG". Eine erfundene oder verschobene Fundstelle ist in diesem Fach kein Schönheitsfehler: Wer sich darauf verlässt, lernt geltendes Recht falsch. Verlassen wird sich deshalb nicht auf Sorgfalt, sondern auf eine maschinelle Prüfung – dieser Index ist ihre Grundlage. Quelle ------ Ausschließlich das amtliche XML von gesetze-im-internet.de, herausgegeben vom Bundesministerium der Justiz gemeinsam mit der juris GmbH. Die Dateien liegen unverändert unter `content/gesetze/`; dieses Skript liest sie und schreibt `content/gesetze/index.json`. Gesetzestexte sind nach § 5 Abs. 1 UrhG gemeinfrei. Sie werden nicht mit der Anwendung ausgeliefert – der Index dient allein der Prüfung im Entwicklungsbaum. Gliederung ---------- Das Gesetzes-XML gliedert Aufzählungen als `
`-Blöcke ihren Absatznummern zu. Ein Absatz kann sich über mehrere `
` erstrecken. Ein Block ohne eigene Nummer gehört deshalb zum zuletzt begonnenen Absatz. """ absaetze: dict[str, Absatz] = {} laufend: str | None = None for text, gliederung in bloecke: treffer = ABSATZ_MUSTER.match(text) if treffer: laufend = treffer.group(1) absaetze[laufend] = Absatz( nummer=laufend, text=text[treffer.end() :], gliederung=dict(gliederung), ) elif laufend is not None: absaetze[laufend].text += '\n' + text for marke, unter in gliederung.items(): absaetze[laufend].gliederung.setdefault(marke, unter) return absaetze def norm_lesen(norm: ET.Element) -> Norm | None: md = norm.find('metadaten') if md is None: return None bezeichnung = (md.findtext('enbez') or '').strip() if not bezeichnung: return None inhalt = norm.find('textdaten/text/Content') bloecke: list[tuple[str, dict[str, list[str]]]] = [] if inhalt is not None: for block in inhalt: text = text_von(block) if text: bloecke.append((text, gliederung_von(block, bezeichnung))) ist_anlage = bezeichnung.lower().startswith('anlage') return Norm( bezeichnung=bezeichnung, titel=(md.findtext('titel') or '').strip(), text='\n'.join(t for t, _ in bloecke), absaetze={} if ist_anlage else absaetze_lesen(bloecke), ist_anlage=ist_anlage, ) def gesetz_lesen(pfad: Path) -> tuple[dict[str, str], dict[str, Norm]]: wurzel = ET.parse(pfad).getroot() kopf: dict[str, str] = {} normen: dict[str, Norm] = {} for norm_element in wurzel.findall('norm'): md = norm_element.find('metadaten') if md is None: continue # Der erste Datensatz trägt die Angaben zum Gesetz als Ganzes. if not kopf: stand = md.find('standangabe/standkommentar') kopf = { 'jurabk': (md.findtext('jurabk') or '').strip(), 'amtabk': (md.findtext('amtabk') or '').strip(), 'langtitel': (md.findtext('langue') or '').strip(), 'ausfertigung': (md.findtext('ausfertigung-datum') or '').strip(), 'stand': (stand.text or '').strip() if stand is not None else '', } norm = norm_lesen(norm_element) if norm is not None and (norm.bezeichnung.startswith('§') or norm.ist_anlage): normen[norm.bezeichnung] = norm return kopf, normen def abzugsdatum(pfad: Path) -> str: """Wann dieser Abzug gezogen wurde – das Datum der XML-Datei. **Warum die Dateizeit und nicht die Laufzeit.** Der Erzeuger liest die XML-Dateien, er holt sie nicht. Ein Datum aus dem Lauf sagte also nur, wann der Index neu gebaut wurde, und nicht, wie alt der Gesetzestext darin ist. Genau das war die Lücke: `content/gesetze/index.json` führte die Standangabe des Gesetzes („Zuletzt geändert durch …"), aber nirgends stand, wann jemand zuletzt nachgesehen hat, ob es dabei geblieben ist. Am 01.09.2026 lagen zwischen dem ältesten und dem jüngsten Abzug **sechs Jahre**. Bewusst kein Test, der mit dem Kalender rot wird: Eine Prüfung, die allein durch Zeitablauf ausfällt, ist am Tag ihres Ausfalls kein Befund, sondern eine Störung – dieselbe Bauart, die in `selbstsicherung.test.ts` schon einmal zugeschlagen hat. Sichtbar gemacht wird das Alter hier und in der Liste unter `docs/veroeffentlichen.md`; entscheiden muss es ein Mensch, denn nur er kann nachsehen, ob sich die Vorschrift geändert hat. """ return datetime.fromtimestamp(pfad.stat().st_mtime).date().isoformat() def main() -> int: gesetze: dict[str, object] = {} for kuerzel, (datei, bezeichnung, kennung) in QUELLEN.items(): pfad = GESETZE / datei if not pfad.exists(): print(f'FEHLT: {kuerzel} ({datei})') return 1 kopf, normen = gesetz_lesen(pfad) paragrafen = {b: n for b, n in normen.items() if not n.ist_anlage} anlagen = {b: n for b, n in normen.items() if n.ist_anlage} gesetze[kuerzel] = { 'bezeichnung': bezeichnung, 'langtitel': kopf.get('langtitel', ''), 'stand': kopf.get('stand', ''), 'ausfertigung': kopf.get('ausfertigung', ''), 'quelle': f'https://www.gesetze-im-internet.de/{kennung}/', 'abzug': abzugsdatum(pfad), 'normen': { b: { 'titel': n.titel, 'ist_anlage': n.ist_anlage, 'absaetze': { a.nummer: {'gliederung': a.gliederung, 'text': a.text} for a in n.absaetze.values() }, 'text': n.text, } for b, n in normen.items() }, } gegliedert = sum( 1 for n in paragrafen.values() for a in n.absaetze.values() if a.gliederung ) print( f'{kuerzel:10} {len(paragrafen):4} Paragrafen, {len(anlagen)} Anlagen, ' f'{gegliedert:4} gegliederte Absaetze' ) ergebnis = { 'hinweis': ( 'Maschinell erzeugt aus dem amtlichen XML von gesetze-im-internet.de ' '(Bundesministerium der Justiz / juris GmbH). Nicht von Hand aendern - ' 'neu erzeugen mit: python data-pipeline/gesetze_index.py' ), 'gesetze': gesetze, } with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei_aus: json.dump(ergebnis, datei_aus, ensure_ascii=False, indent=1) print(f'\ngeschrieben: {ZIEL.name} ({ZIEL.stat().st_size / 1_048_576:.1f} MB)') # Was beim Lesen nicht aufging, gehört ausgesprochen. Ein Absatz mit # unlesbarer Gliederung fällt sonst niemandem auf – und an ihm scheitern # anschliessend richtige Zitate. if MARKENBEFUNDE: print(f'\nUnsaubere Gliederungsmarken im amtlichen XML ({len(MARKENBEFUNDE)}):') for befund in MARKENBEFUNDE: print(f' {befund}') return 0 if __name__ == '__main__': raise SystemExit(main())