"""Eine Fundstelle erkennen, gleich wie sie geschrieben ist. „§ 34 Abs. 1 Satz 5 Nr. 1 SGB V", „§ 34 Absatz 1 Satz 5 Nummer 1 SGB V" und „§ 34 Abs. 1 Satz 5 Nr. 1 des SGB V" sind dieselbe Vorschrift. „§ 34 Abs. 1 SGB V" ist es **nicht** — die Genauigkeit ist an vielen Stellen gerade der Prüfgegenstand. Dieses Modul entscheidet beides. Es liegt in `src`, weil zwei Seiten es brauchen: die Antwortprüfung der Auswertung (`eval/antwortpruefung.py`) und, wo es einmal so weit ist, das Normregister. `norm_anchors._genannt_in` prüft heute nur den **Paragrafen** — eine Antwort mit „§ 92 Abs. 1 Satz 2 Nr. 6" gilt dort als „§ 92 genannt", und die richtige Fundstelle wird nicht nachgetragen. Das darauf umzustellen ändert das Verhalten **aller** Anker und gehört gemessen; bis dahin bleibt dieses Modul, was es ist: der Matcher, an einer Stelle, an der beide ihn erreichen. **Kanonisieren statt Muster basteln.** Forderung und Text gehen durch dieselbe Wäsche: Abkürzungen auf je eine Schreibweise, Füllwörter weg, Striche und Leerraum vereinheitlicht. Danach genügt ein schlichtes Muster. Der erste Versuch baute stattdessen aus jeder Fundstelle ein tolerantes Suchmuster zusammen und scheiterte an einer Kleinigkeit: `re.escape` maskiert seit Python 3.7 auch das Leerzeichen, und der nachträgliche Ersatz des Leerraums machte daraus einen doppelten Backslash — danach traf keine Forderung mehr, auch die einfachste Wendung nicht. Vier Fallen sind hier bezahlt und stehen unten an ihrer Stelle: die schmalen Leerzeichen und geschützten Bindestriche der Antworten, das „I" von „in" unter `re.I`, die Fundstelle, deren Bestandteile nicht zusammenhängen, und die Flexion des Deutschen. """ from __future__ import annotations import re from typing import Any, List, Sequence, Union Forderung = Union[str, Sequence[str]] # --------------------------------------------------------------------------- # Treffer im Text # --------------------------------------------------------------------------- # Normtexte tragen schmale und geschützte Leerzeichen. Ein Muster, das nur das # gewöhnliche kennt, verfehlt „§ 34" im Zitat und trifft es in der Frage. _LEERRAUM = re.compile("[\\s   ]+") _WAESCHE = tuple( (re.compile(muster, re.I), ersatz) for muster, ersatz in ( # Regelwerke zuerst, und der lange Name vor dem kurzen. (r"Arzneimittel-?\s*Richtlinie", " amrl "), (r"\bAM-?\s?RL\b", " amrl "), # Ohne Ausschluss von „SGB VI": die Wortgrenze hinter dem V leistet das # schon. Ein zusätzliches `(?!\s*I)` tat unter `re.I` das Gegenteil und # verwarf „SGB V in Verbindung mit …" — das I von „in". (r"\bSGB\s*V\b", " sgbv "), (r"F[üu]nften?\s+Buch(?:es)?\s+Sozialgesetzbuch(?:es)?", " sgbv "), (r"\bRahmenvertrag(?:es|s)?\b", " rahmenvertrag "), (r"\bArzneimittelabrechnungsvereinbarung\b|\bAbrechnungsvereinbarung\b", " amabrv "), # Gliederungsbezeichnungen auf je eine Form. Der Lookahead auf die # Nummer verhindert, dass „Absatz" im Fließtext ohne Bezug fällt. (r"\bAbs(?:atz|\.)?(?=\s*\d)", " abs "), (r"\bN(?:r\.?|ummer)(?=\s*\d)", " nr "), (r"\bHalbs(?:atz|\.)?(?=\s*\d)|\bHs\.?(?=\s*\d)", " halbsatz "), (r"\bBuchst(?:abe|\.)?(?=\s*[a-z]\b)", " buchst "), (r"\bS\.(?=\s*\d)", " satz "), # Füllwörter zwischen den Bestandteilen: „nach § 34" und „des § 34" # sollen dieselbe Fundstelle sein wie „§ 34". Die Wortgrenzen sind # hier nicht Kosmetik — ohne sie fiele „in" mitten aus „Verbindung". (r"\b(?:der|des|die|dem|den|das|nach|gemäß|gemaess|vom|im|in)\b", " "), # Doppeltes Paragraphenzeichen kündigt mehrere an; für den Vergleich # einer einzelnen Fundstelle ist es dasselbe Zeichen. (r"§+", " § "), ) ) # Die Antworten tragen den geschützten Bindestrich U+2011 („AM‑RL"), die # Normtexte den Halbgeviertstrich. Ein Muster mit ASCII-Bindestrich verfehlt # beides — gemessen am 21.08.2026, als „§ 40c AM-RL" gegen „§ 40c Abs. 1, # Abs. 3 (Arzneimittel‑Richtlinie)" nicht traf. _STRICHE = str.maketrans({c: "-" for c in "‐‑‒–—―−­"}) def _glatt(text: str) -> str: return _LEERRAUM.sub(" ", str(text or "").translate(_STRICHE)).strip() def kanonisch(text: str) -> str: """Text und Forderung in derselben Schreibweise — klein, ohne Füllwörter.""" gewaschen = _glatt(text).lower() for erkenner, ersatz in _WAESCHE: gewaschen = erkenner.sub(ersatz, gewaschen) return _glatt(gewaschen) # Eine Zahl am Ende eines Bestandteils darf nicht in eine längere hineinlaufen: # „§ 34" träfe sonst „§ 340", „nr 1" träfe „nr 12". _ZAHL_AM_ENDE = re.compile(r"\d[a-z]?$", re.I) _GLIEDERUNG = ("abs", "satz", "nr", "halbsatz", "buchst") def _wortmuster(wort: str) -> str: """Ein Wort der Forderung als Muster, mit Grenze nach hinten. Die Grenze nur, wo sie eine ist: `\\b` hinter „12." verlangt ein Wortzeichen dahinter und verfehlte damit „vollendeten 12. Lebensjahr" — vor dem Leerzeichen steht keine Wortgrenze mehr, die Grenze liegt schon vor dem Punkt. """ stueck = re.escape(wort) if _ZAHL_AM_ENDE.search(wort): return stueck + r"(?![0-9a-z])" # Keine Wortgrenze, sondern eine erlaubte Endung: das Deutsche flektiert. # „unverändert abgegeben" soll „unverändert abgegebene Fertigarzneimittel" # treffen und „parenterale Zubereitung" auch „parenteralen Zubereitungen". # Gemessen am 21.08.2026, als an beidem eine erfüllte Abgrenzung als fehlend # gemeldet wurde. Die Genauigkeit, auf die es ankommt, sichert die # Zahlengrenze darüber — nicht die Wortgrenze. return stueck + r"[a-zäöüß]*" def _muster(wendung: str) -> "re.Pattern[str]": """Das Suchmuster zu einer Forderung ohne Paragraphenzeichen: Wort für Wort.""" stuecke = [_wortmuster(w) for w in kanonisch(wendung).split(" ") if w] if not stuecke: # Eine leere Forderung darf nicht auf alles passen. return re.compile(r"(?!)") return re.compile(r"\s+".join(stuecke), re.I) def _zerlegt(wendung: str) -> Any: """Eine Fundstelle als (Paragraf, geforderte Bestandteile) — oder None. Die erste Fassung suchte die Fundstelle als **zusammenhängende** Wortkette und verfehlte damit genau die Schreibweise, die Modelle bevorzugen: „§ 40c Abs. 1, Abs. 3 (Arzneimittel-Richtlinie)" erfüllt eine Forderung nach „§ 40c AM-RL", denn zwischen Nummer und Regelwerk steht nur mehr Genauigkeit. Gemessen am 21.08.2026: die Prüfung meldete § 40c als „nicht genannt", obwohl er im Normabschnitt stand — und § 40b als nicht beanstandet, obwohl er dort an erster Stelle stand. Gesucht wird deshalb zweistufig: erst der Paragraf, dann seine Bestandteile **im Fenster bis zum nächsten Paragraphenzeichen**. Das Fenster ist der ganze Trick — ohne es verbände die Suche „§ 40b Abs. 1" mit dem „AM-RL" einer später genannten anderen Vorschrift. """ woerter = [w for w in kanonisch(wendung).split(" ") if w] if not woerter or woerter[0] != "§" or len(woerter) < 2: return None paragraf = woerter[1] rest = woerter[2:] gefordert: List[str] = [] i = 0 while i < len(rest): wort = rest[i] if wort in _GLIEDERUNG and i + 1 < len(rest): # Bestandteil und Wert gehören zusammen: „abs 1" darf nicht durch # „abs 5 … satz 1" erfüllt werden. gefordert.append(rf"{re.escape(wort)}\s+{_wortmuster(rest[i + 1])}") i += 2 continue gefordert.append(_wortmuster(wort)) i += 1 return paragraf, gefordert def _fundstelle_trifft(wendung: str, gewaschen: str) -> bool: zerlegt = _zerlegt(wendung) if zerlegt is None: return _muster(wendung).search(gewaschen) is not None paragraf, gefordert = zerlegt anker = re.compile(rf"§\s+{re.escape(paragraf)}(?![0-9a-z])", re.I) for treffer in anker.finditer(gewaschen): naechster = gewaschen.find("§", treffer.end()) fenster = gewaschen[treffer.end(): naechster if naechster != -1 else len(gewaschen)] if all(re.search(m, fenster, re.I) for m in gefordert): return True return False def trifft(forderung: Forderung, text: str) -> bool: """Erfüllt der Text die Forderung — eine der Alternativen genügt.""" wendungen = [forderung] if isinstance(forderung, str) else list(forderung) gewaschen = kanonisch(text) return any(_fundstelle_trifft(w, gewaschen) for w in wendungen if str(w).strip())