Files
explore/index_normen.py
Michael Hermann bdaa21dc31
Some checks failed
Deploy explore / deploy (push) Failing after 18s
Initialisiere Explor.Code.Law mit RII-Pipeline und Strato-Deploy.
Entscheidungen als Markdown, Normen-Index und schlankes Git-Sync nach /opt/explore (Gitea Actions, ohne Docker).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:03:40 +02:00

246 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""
Baut data/normen_index.json: Gesetz/Paragraph → Liste von Entscheidungen (doknr).
Liest YAML-Köpfe aus ge_md/, mappt Abkürzungen auf Lawgit-Slugs und prüft,
ob laws_md/<slug>/§N.md existiert.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from collections import defaultdict
from pathlib import Path
# Häufige RII-Abkürzungen → Ordnername unter laws_md/
GESETZ_ALIASE: dict[str, str] = {
"bgb": "bgb",
"gg": "gg",
"stgb": "stgb",
"stpo": "stpo",
"zpo": "zpo",
"inso": "inso",
"hgb": "hgb",
"gmbhg": "gmbhg",
"aktg": "aktg",
"sgb": "sgb", # oft mit Bandnummer — unmapped oft
"bverfgg": "bverfgg",
"vwgo": "vwgo",
"fgo": "fgo",
"sgg": "sgg",
"arbgg": "arbgg",
"gvg": "gvg",
"egbgb": "egbgb",
"agg": "agg",
"urhg": "urhg",
"tmg": "tmg",
"bdsg": "bdsg",
"ao": "ao",
"ustg": "ustg",
"estg": "estg",
"vvg": "vvg",
"vob": "vob",
"baugb": "baugb",
"whg": "whg",
"bnatschg": "bnatschg",
}
def map_gesetz_slug(abbrev: str) -> str:
key = (abbrev or "").strip().lower()
key = (
key.replace("ä", "ae")
.replace("ö", "oe")
.replace("ü", "ue")
.replace("ß", "ss")
.replace(".", "")
)
return GESETZ_ALIASE.get(key, key)
def lawgit_path_for(
lawgit_root: Path, gesetz_slug: str, paragraph: str
) -> tuple[Path | None, bool]:
"""Pfad zu laws_md/<slug>/§N.md (oder Art. N.md) und ob die Datei existiert."""
if not gesetz_slug:
return None, False
slug = map_gesetz_slug(gesetz_slug)
base = lawgit_root / "laws_md" / slug
if not paragraph:
readme = base / "README.md"
return readme, readme.is_file()
candidates = [
base / f"§{paragraph}.md",
base / f"§{paragraph.lower()}.md",
base / f"Art. {paragraph}.md",
base / f"Art. {paragraph.lower()}.md",
base / f"Art {paragraph}.md",
base / f"Artikel {paragraph}.md",
]
for path in candidates:
if path.is_file():
return path, True
return candidates[0], False
def parse_frontmatter(md: str) -> dict:
"""Einfacher YAML-Kopf-Parser für unsere festgelegten Felder."""
if not md.startswith("---"):
return {}
end = md.find("\n---", 3)
if end < 0:
return {}
block = md[3:end].strip()
meta: dict = {"normen": [], "vorinstanz": []}
current_list: str | None = None
for line in block.splitlines():
if line.startswith("normen:"):
current_list = "normen"
if line.strip() == "normen: []":
current_list = None
continue
if line.startswith("vorinstanz:"):
current_list = "vorinstanz"
if line.strip() == "vorinstanz: []":
current_list = None
continue
if line.startswith(" - ") and current_list == "normen":
# { gesetz: "...", paragraph: "...", roh: "..." }
m = re.search(
r'gesetz:\s*"([^"]*)".*?paragraph:\s*"([^"]*)".*?roh:\s*"([^"]*)"',
line,
)
if m:
meta["normen"].append(
{"gesetz": m.group(1), "paragraph": m.group(2), "roh": m.group(3)}
)
continue
if line.startswith(" - ") and current_list == "vorinstanz":
continue
if re.match(r"^[a-z_]+:", line) and not line.startswith(" "):
current_list = None
key, _, val = line.partition(":")
val = val.strip()
if val.startswith('"') and val.endswith('"'):
val = json.loads(val)
meta[key.strip()] = val
return meta
def parse_frontmatter_normen(md: str) -> list[dict]:
return parse_frontmatter(md).get("normen") or []
def build_index(
ge_md: Path,
lawgit_root: Path,
) -> dict:
by_norm: dict[str, list[dict]] = defaultdict(list)
unmapped: list[dict] = []
decisions = 0
for path in sorted(ge_md.rglob("*.md")):
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
meta = parse_frontmatter(text)
if not meta.get("doknr"):
continue
decisions += 1
entry_base = {
"doknr": meta.get("doknr"),
"gericht": meta.get("gericht"),
"ebene": meta.get("ebene"),
"aktenzeichen": meta.get("aktenzeichen"),
"datum": meta.get("datum"),
"path": str(path.relative_to(ge_md)),
"doktyp": meta.get("doktyp"),
"hat_leitsatz": bool(
re.search(r"^## Leitsatz\s*$", text, re.M)
and re.search(
r"^## Leitsatz\s*\n\n.+\n", text, re.M | re.S
)
),
}
norms = meta.get("normen") or []
if not norms:
unmapped.append({**entry_base, "grund": "keine_normen"})
continue
for n in norms:
slug = map_gesetz_slug(n.get("gesetz", ""))
para = n.get("paragraph", "")
key = f"{slug}{para}" if para else slug
lg_path, ok = lawgit_path_for(lawgit_root, slug, para)
item = {
**entry_base,
"roh": n.get("roh"),
"gesetz": slug,
"paragraph": para,
"lawgit": str(lg_path.relative_to(lawgit_root)) if lg_path and ok else None,
"mapped": ok,
}
if ok:
by_norm[key].append(item)
else:
unmapped.append({**item, "grund": "kein_lawgit_pfad"})
return {
"lawgit_root": str(lawgit_root.resolve()),
"ge_md": str(ge_md.resolve()),
"entscheidungen": decisions,
"normen": {k: v for k, v in sorted(by_norm.items())},
"unmapped": unmapped,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Normen-Index aus ge_md/ bauen")
parser.add_argument(
"--lawgit",
default=os.environ.get("LAWGIT_ROOT", "../lawgit"),
help="Pfad zu lawgit (Default: ../lawgit oder LAWGIT_ROOT)",
)
parser.add_argument("--ge-md", default="ge_md", help="Verzeichnis der Entscheidungen")
parser.add_argument(
"--out",
default="data/normen_index.json",
help="Ausgabedatei",
)
args = parser.parse_args()
lawgit = Path(args.lawgit).resolve()
ge_md = Path(args.ge_md).resolve()
if not ge_md.is_dir():
print(f"ge_md fehlt: {ge_md}", file=sys.stderr)
return 1
if not (lawgit / "laws_md").is_dir():
print(
f"Warnung: {lawgit}/laws_md fehlt — Mapping wird überwiegend unmapped.",
file=sys.stderr,
)
index = build_index(ge_md, lawgit)
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(
json.dumps(index, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
mapped_norms = len(index["normen"])
print(
f"Index: {index['entscheidungen']} Entscheidungen, "
f"{mapped_norms} Norm-Schlüssel, "
f"{len(index['unmapped'])} unmapped → {out}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())