| """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]] |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| _LEERRAUM = re.compile("[\\s ]+") |
|
|
| _WAESCHE = tuple( |
| (re.compile(muster, re.I), ersatz) |
| for muster, ersatz in ( |
| |
| (r"Arzneimittel-?\s*Richtlinie", " amrl "), |
| (r"\bAM-?\s?RL\b", " amrl "), |
| |
| |
| |
| (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 "), |
| |
| |
| (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 "), |
| |
| |
| |
| (r"\b(?:der|des|die|dem|den|das|nach|gemäß|gemaess|vom|im|in)\b", " "), |
| |
| |
| (r"§+", " § "), |
| ) |
| ) |
|
|
|
|
| |
| |
| |
| |
| _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) |
|
|
|
|
| |
| |
| _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])" |
| |
| |
| |
| |
| |
| |
| 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: |
| |
| 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): |
| |
| |
| 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()) |
|
|
|
|
|
|