BAV_KI / src /fundstellen.py
AlixJabda's picture
Aktualisierung der AM-RL-Module und Retrieval-Logik
d80690d
Raw
History Blame Contribute Delete
8.77 kB
"""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())