Files
gesetzblatt/parse_bgbl.py
Michael Hermann 2bb70ae0ed Initiales gesetzblatt-Repo: Parser-Zweig ab 2023 von recht.bund.de.
Historie 1949–2022 bleibt in lawgit, bis der GPU-Lauf endet; Schnitt ohne Lücke.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 11:56:05 +02:00

447 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Strukturiert BGBl-Texte zu Ereignissen der Normzeitachse.
Standard: Heuristik (kein GPU, keine Kosten).
Optional: --llm (LLM_BASE_URL in .env) — nicht starten, solange lawgit den
Historien-Parse auf RunPod fährt.
python3 parse_bgbl.py --year 2025
python3 parse_bgbl.py --year 2025 --llm # nur bewusst, kostet GPU-Zeit
"""
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime, timedelta
from pathlib import Path
from bgbl_util import META_DIR, PARSED_DIR, TEXT_DIR, fundstelle
TYP_ERLASS = "Erlass"
TYP_AENDERUNG = "Änderung"
TYP_NEUBEKANNT = "Neubekanntmachung"
TYP_AUFHEBUNG = "Aufhebung"
TYP_VERORDNUNG = "Verordnung"
TYP_BEKANNT = "Bekanntmachung"
TYP_SONSTIGE = "Sonstige"
ALLE_TYPEN = {
TYP_ERLASS, TYP_AENDERUNG, TYP_NEUBEKANNT, TYP_AUFHEBUNG,
TYP_VERORDNUNG, TYP_BEKANNT, TYP_SONSTIGE,
}
STAMMGESETZE = [
(r"Grundgesetz", "GG", "gg"),
(r"Bürgerlichen Gesetzbuchs|Bürgerliche(?:s)? Gesetzbuch", "BGB", "bgb"),
(r"Strafgesetzbuch", "StGB", "stgb"),
(r"Zivilprozessordnung", "ZPO", "zpo"),
(r"Strafprozessordnung", "StPO", "stpo"),
(r"Handelsgesetzbuch", "HGB", "hgb"),
(r"Aktiengesetz", "AktG", "aktg"),
(r"GmbH-Gesetz|Gesetz betreffend die Gesellschaften mit beschränkter Haftung", "GmbHG", "gmbhg"),
(r"Verwaltungsverfahrensgesetz", "VwVfG", "vwvfg"),
(r"Verwaltungsgerichtsordnung", "VwGO", "vwgo"),
(r"Abgabenordnung", "AO", "ao"),
(r"Einkommensteuergesetz", "EStG", "estg"),
(r"Umsatzsteuergesetz", "UStG", "ustg"),
(r"Sozialgesetzbuch", "SGB", None),
(r"Aufenthaltsgesetz", "AufenthG", "aufenthg"),
(r"Asylgesetz", "AsylG", "asylvfg_1992"),
(r"Bundes-Immissionsschutzgesetz", "BImSchG", "bimschg"),
(r"Straßenverkehrsgesetz", "StVG", "stvg"),
(r"Straßenverkehrs-Ordnung", "StVO", "stvo_2013"),
]
INKRAFT_RE = re.compile(
r"(?:tritt|treten)\s+"
r"(?:am\s+)?"
r"("
r"mit\s+Ablauf\s+des\s+Tages\s+der\s+Verkündung"
r"|Tage?s?\s+der\s+Verkündung"
r"|Tage?s?\s+nach\s+(?:der\s+)?Verkündung"
r"|\d{1,2}\.\s*(?:Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s+\d{4}"
r"|\d{1,2}\.\s*\d{1,2}\.\s*\d{4}"
r")"
r"[^.]*?in\s+Kraft",
re.IGNORECASE | re.DOTALL,
)
DATE_DE = re.compile(
r"(\d{1,2})\.\s*(Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s+(\d{4})"
)
DATE_NUM = re.compile(r"(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})")
MONTHS = {
"januar": 1, "februar": 2, "märz": 3, "april": 4, "mai": 5, "juni": 6,
"juli": 7, "august": 8, "september": 9, "oktober": 10, "november": 11, "dezember": 12,
}
SYSTEM_PROMPT = """Du bist ein Parser für das deutsche Bundesgesetzblatt.
Aus dem Text einer Verkündung extrahierst du strukturierte JSON-Daten für eine Normzeitachse.
Antworte NUR mit einem JSON-Objekt.
Schema:
{
"titel": "amtlicher Titel",
"typ": "Erlass|Änderung|Neubekanntmachung|Aufhebung|Verordnung|Bekanntmachung|Sonstige",
"ausfertigung": "YYYY-MM-DD oder null",
"inkrafttreten": [
{"datum": "YYYY-MM-DD oder 'Tag nach der Verkündung'", "geltung": "ganzes Gesetz oder z.B. Art. 2"}
],
"betroffene_normen": [
{
"name": "Bürgerliches Gesetzbuch",
"kurz": "BGB",
"operation": "erlass|aenderung|aufhebung|neubekanntmachung",
"aenderungen": [
{"stelle": "§ 433", "aktion": "neufassung|einfuegung|streichung|aenderung|wortlaut", "kurzbeschreibung": "ein Satz"}
]
}
],
"zusammenfassung": "1-2 Sätze, verständlich für Juristinnen ohne IT-Hintergrund"
}
Regeln:
- Änderungsgesetze ändern oft MEHRERE Stammgesetze (Artikelgesetz). Jedes Stammgesetz einzeln auflisten.
- Stelle immer mit § oder Art. angeben.
- Keine Git-Begriffe. Keine Spekulation: unsichere Felder null / leere Liste.
"""
def normalize_ocr(text: str) -> str:
t = text.replace("\u00ad", "")
t = re.sub(r"-\s*\n\s*", "", t)
t = re.sub(r"(?<=[A-Za-zÄÖÜäöüß])\n(?=[a-zäöüß])", "", t)
t = t.replace("~", "m")
t = re.sub(r"\s+", " ", t)
return t
def parse_date_de(text: str) -> str | None:
m = DATE_DE.search(text)
if m:
d, mon, y = int(m.group(1)), MONTHS[m.group(2).lower()], int(m.group(3))
return f"{y:04d}-{mon:02d}-{d:02d}"
m = DATE_NUM.search(text)
if m:
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
if 1 <= mo <= 12 and 1 <= d <= 31:
return f"{y:04d}-{mo:02d}-{d:02d}"
return None
def classify_typ(title: str) -> str:
t = title.lower()
if "bekanntmachung der neufassung" in t or "neufassung" in t:
return TYP_NEUBEKANNT
if "aufhebung" in t:
return TYP_AUFHEBUNG
if "änderung" in t or "aenderung" in t:
return TYP_AENDERUNG
if t.startswith("verordnung") or " verordnung " in f" {t} ":
return TYP_VERORDNUNG
if t.startswith("bekanntmachung") or "bekanntmachung" in t:
return TYP_BEKANNT
if t.startswith("gesetz") or "gesetz" in t:
return TYP_ERLASS
return TYP_SONSTIGE
def _norm_hit(kurz: str, gii: str | None, operation: str) -> dict:
return {"name": kurz, "kurz": kurz, "gii": gii, "operation": operation, "aenderungen": []}
def find_betroffene(title: str, text: str, typ: str) -> list[dict]:
found: list[dict] = []
seen: set[str] = set()
op_title = "aenderung" if typ == TYP_AENDERUNG else "erlass"
for pattern, kurz, gii in STAMMGESETZE:
if re.search(pattern, title, re.IGNORECASE) and kurz not in seen:
seen.add(kurz)
found.append(_norm_hit(kurz, gii, op_title))
head = text[:8000]
for pattern, kurz, gii in STAMMGESETZE:
if kurz in seen:
continue
aend = re.search(
rf"(?:Änderung|Anderung|Abänderung)\s+(?:des|der|von)\s+(?:[^\n.]{{0,40}})?{pattern}",
head,
re.IGNORECASE,
)
geaendert = re.search(
rf"{pattern}[^.{{]{{0,120}}wird wie folgt geändert",
head,
re.IGNORECASE,
)
if aend or geaendert:
seen.add(kurz)
found.append(_norm_hit(kurz, gii, "aenderung"))
return found
def find_inkrafttreten(text: str, verkundung: str | None) -> list[dict]:
hits = []
seen = set()
blob = normalize_ocr(text)
for m in INKRAFT_RE.finditer(blob):
raw = re.sub(r"\s+", " ", m.group(1)).strip()
low = raw.lower()
hinweis = None
datum: str | None = None
if "verkündung" in low:
if verkundung:
try:
d = datetime.fromisoformat(verkundung[:10])
if "ablauf" in low:
datum = (d + timedelta(days=1)).strftime("%Y-%m-%d")
hinweis = "mit Ablauf des Verkündungstages, gilt ab dem Folgetag"
elif "nach" in low:
datum = (d + timedelta(days=1)).strftime("%Y-%m-%d")
hinweis = "Tag nach der Verkündung"
else:
datum = d.strftime("%Y-%m-%d")
hinweis = "Tag der Verkündung"
except ValueError:
datum = raw
else:
datum = raw
else:
datum = parse_date_de(raw) or raw
key = (datum, hinweis)
if key in seen:
continue
seen.add(key)
rec = {"datum": datum, "geltung": "ganzes Gesetz"}
if hinweis:
rec["hinweis"] = hinweis
hits.append(rec)
return hits
def load_meta() -> list[dict]:
items = []
if not META_DIR.exists():
return items
seen: set[str] = set()
for path in sorted(META_DIR.glob("*.json")):
if path.name in {"offenegesetze-quelle.json"}:
continue
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, list):
continue
for item in data:
key = item.get("id")
if not key or key in seen:
continue
seen.add(key)
items.append(item)
return items
def _load_extracted(item: dict) -> dict | None:
kind = item.get("kind") or "bgbl1"
year = item.get("year")
number = item.get("number_raw") or item.get("number")
candidates = [
TEXT_DIR / kind / str(year) / f"{kind}_{year}_{number}.json",
]
doc = (item.get("document_url") or "").split("#")[0]
if doc:
name = Path(doc).name
candidates.append(TEXT_DIR / kind / str(year) / name.replace(".pdf", ".json"))
candidates.append(TEXT_DIR / kind / str(year) / Path(name).with_suffix(".json").name)
pdf = item.get("pdf")
if pdf:
candidates.append(TEXT_DIR / kind / str(year) / Path(pdf).with_suffix(".json").name)
for c in candidates:
if c.exists():
return json.loads(c.read_text(encoding="utf-8"))
return None
def text_for_item(item: dict) -> dict | None:
extracted = _load_extracted(item)
if not extracted:
return None
page_texts = extracted.get("page_texts") or []
if not page_texts:
return extracted if extracted.get("text") else None
start = item.get("pdf_page") or 1
n = item.get("num_pages") or 0
seiten = [p for p in page_texts if start <= p["page"] < start + n] if n else page_texts
return {
**extracted,
"text": "\n\n".join(f"--- Seite {p['page']} ---\n{p['text']}" for p in seiten),
"pages": len(seiten),
}
def heuristic_parse(item: dict, extracted: dict | None) -> dict:
title = item.get("title") or item.get("titel") or ""
text = (extracted or {}).get("text") or ""
date = (item.get("date") or item.get("verkundung") or "")[:10] or None
kind = item.get("kind") or "bgbl1"
year = item.get("year")
number = item.get("number_raw") or item.get("number")
page = item.get("page")
fs = item.get("fundstelle") or fundstelle(kind, year or 0, number or 0, page)
typ = classify_typ(title)
body = normalize_ocr(text)
if typ == TYP_ERLASS and re.search(r"wird wie folgt geändert", body, re.IGNORECASE):
typ = TYP_AENDERUNG
return {
"id": item.get("id"),
"fundstelle": fs,
"verkundung": date,
"titel": title,
"typ": typ,
"ausfertigung": item.get("ausfertigung") or parse_date_de(title) or date,
"inkrafttreten": find_inkrafttreten(text, date),
"betroffene_normen": find_betroffene(title, text, typ),
"zusammenfassung": title,
"pdf": (extracted or {}).get("pdf") or item.get("pdf"),
"text_quality": (extracted or {}).get("quality"),
"quelle": item.get("quelle") or ("offenegesetze" if year and year < 2023 else "recht.bund.de"),
"parser": "heuristik",
"kind": kind,
"year": year,
"number": number,
"page": page,
"url": item.get("url"),
"document_url": item.get("document_url"),
"sha256": item.get("sha256"),
}
def llm_parse(item: dict, extracted: dict, base: dict) -> dict:
import llm_client
meta = json.dumps(
{k: item.get(k) for k in ("id", "title", "titel", "date", "year", "number", "kind")},
ensure_ascii=False,
)
text = (extracted.get("text") or "")[:18000]
daten = llm_client.chat_json(
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Metadaten: {meta}\n\nText der Verkündung:\n{text}"},
],
max_tokens=4000,
)
event = dict(base)
for feld in ("titel", "typ", "ausfertigung", "inkrafttreten", "zusammenfassung", "betroffene_normen"):
if daten.get(feld):
if feld == "typ" and daten[feld] not in ALLE_TYPEN:
continue
event[feld] = daten[feld]
event["parser"] = "llm"
return event
def parse_item(item: dict, use_llm: bool) -> dict:
extracted = text_for_item(item)
event = heuristic_parse(item, extracted)
if use_llm and extracted and extracted.get("text"):
try:
return llm_parse(item, extracted, event)
except Exception as exc:
event["parser"] = "heuristik"
event["parser_fehler"] = [f"{type(exc).__name__}: {exc}"]
return event
def _schreibe(event: dict) -> Path:
dest = PARSED_DIR / f"{event['id']}.json"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(json.dumps(event, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return dest
def baue_index() -> Path:
alle = []
for f in sorted(PARSED_DIR.glob("bgbl*.json")):
try:
alle.append(json.loads(f.read_text(encoding="utf-8")))
except (json.JSONDecodeError, OSError) as exc:
print(f" übersprungen: {f.name} ({exc})")
alle.sort(key=lambda e: (e.get("verkundung") or "", e.get("id") or ""))
index = PARSED_DIR / "index.json"
index.write_text(json.dumps(alle, ensure_ascii=False), encoding="utf-8")
return index
def main() -> None:
parser = argparse.ArgumentParser(description="BGBl-Texte zu Zeitachsen-Ereignissen parsen")
parser.add_argument("--year", type=int, action="append")
parser.add_argument("--von", type=int)
parser.add_argument("--bis", type=int)
parser.add_argument(
"--llm",
action="store_true",
help="RunPod/vLLM verwenden (LLM_BASE_URL). Standard ist Heuristik — --llm nicht "
"starten, solange der lawgit-Historien-Parse auf der GPU läuft.",
)
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--force", action="store_true")
parser.add_argument("--kein-index", action="store_true")
args = parser.parse_args()
items = load_meta()
if args.year:
years = set(args.year)
items = [i for i in items if i.get("year") in years]
if args.von:
items = [i for i in items if (i.get("year") or 0) >= args.von]
if args.bis:
items = [i for i in items if (i.get("year") or 9999) <= args.bis]
items.sort(key=lambda i: (i.get("year") or 0, i.get("number") or 0, i.get("id") or ""))
if args.limit:
items = items[: args.limit]
if not items:
print("Keine Metadaten. Zuerst: python3 download_rechtbund.py --live-test")
return
use_llm = args.llm
if use_llm:
import llm_client
if not llm_client.llm_configured():
print("LLM_BASE_URL ist nicht gesetzt — falle auf Heuristik zurück.")
use_llm = False
PARSED_DIR.mkdir(parents=True, exist_ok=True)
if not args.force:
offen = []
for item in items:
dest = PARSED_DIR / f"{item.get('id')}.json"
if dest.exists():
continue
offen.append(item)
if len(offen) < len(items):
print(f"{len(items) - len(offen)} bereits geparst, übersprungen")
items = offen
if not items:
print("Nichts zu tun.")
return
for i, item in enumerate(items, 1):
print(f"[{i}/{len(items)}] {item.get('id')} {(item.get('title') or item.get('titel') or '')[:70]}")
_schreibe(parse_item(item, use_llm))
if args.kein_index:
print(f"{len(items)} Ereignisse geschrieben, Index nicht neu gebaut")
return
index = baue_index()
print(f"{len(items)} in diesem Lauf → {index}")
if __name__ == "__main__":
main()