| """OTC auf Kassenrezept nachschlagen (AM-RL Anlage I). |
| |
| Nicht verschreibungspflichtige Arzneimittel sind nach § 34 Absatz 1 Satz 1 SGB V |
| von der Versorgung ausgeschlossen. Ausnahmsweise verordnungsfähig sind sie, |
| wenn sie bei der Behandlung schwerwiegender Erkrankungen als Therapiestandard |
| gelten — und welche das sind, führt Anlage I auf. In der Offizin ist das ein |
| häufiger Retaxgrund: OTC auf Kassenrezept ist die Ausnahme. |
| |
| **Hier trägt die Negativauskunft — anders als bei Anlage VIIa.** § 12 Absatz 10 |
| der Richtlinie sagt es ausdrücklich: die Absätze 1 bis 9 regeln *abschließend*, |
| unter welchen Voraussetzungen nicht verschreibungspflichtige Arzneimittel zu |
| Lasten der GKV verordnungsfähig sind. Ein „steht nicht in Anlage I" ist deshalb |
| eine Auskunft und nicht bloß ein fehlender Treffer. Genau deshalb ist es auch |
| ein Lookup: ein Vektor-Retrieval liefert immer die k ähnlichsten Chunks und kann |
| Abwesenheit prinzipiell nicht belegen. |
| |
| **Der Eintrag ist Stoff *und* Indikation, und beide gehören zusammen.** |
| Lactulose ist nicht verordnungsfähig, sondern „nur zur Senkung der enteralen |
| Ammoniakresorption bei Leberversagen im Zusammenhang mit der hepatischen |
| Enzephalopathie". Der Befund zitiert die Bedingung deshalb vollständig und |
| subsumiert nicht — ob sie im Fall vorliegt, ist eine ärztliche Feststellung und |
| keine Listenauskunft. Dieselbe Zurückhaltung wie bei Everolimus in Teil B. |
| |
| **Vier Wege führen an der Liste vorbei**, und ein Befund, der sie verschweigt, |
| ist falsch. Alle vier stehen in § 12 der Richtlinie, nicht in der Anlage: |
| |
| Absatz 12 Der Ausschluss gilt überhaupt nicht für Kinder bis zum |
| vollendeten 12. Lebensjahr und Jugendliche mit |
| Entwicklungsstörungen bis zum vollendeten 18. Lebensjahr. |
| Absatz 7 Begleitmedikation, wenn die Fachinformation des |
| Hauptarzneimittels sie voraussetzt. |
| Absatz 8 Behandlung schwerwiegender unerwünschter Arzneimittelwirkungen |
| eines verordnungsfähigen Arzneimittels. |
| Absatz 6 Anthroposophie und Homöopathie für die in Anlage I gelisteten |
| Indikationsgebiete. |
| |
| Die Zustände: |
| |
| gelistet Stoff steht in Anlage I — mit seiner Bedingung |
| gruppe_gelistet die Anlage führt eine Stoffgruppe, kein Einzelpräparat |
| (Abführmittel, Antihistaminika); ob das konkrete |
| Präparat dazugehört, ist am Fall zu prüfen |
| nicht_gelistet die Auskunft, die ein Retrieval nicht geben kann |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import re |
| from dataclasses import dataclass, field |
| from datetime import date, datetime |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional, Sequence, Tuple |
|
|
| |
| |
| from amrl_substitution import VERALTET_AB_TAGEN, _stand_iso, wirkstoff_kandidat |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _CACHE: Dict[str, Dict[str, Any]] = {} |
|
|
| |
| |
| OTC_SIGNALE: tuple[str, ...] = ( |
| "otc", |
| "kassenrezept", |
| "zu lasten der", |
| "verordnungsfähig", |
| "verordnungsfaehig", |
| "verordnungsausschluss", |
| "erstattungsfähig", |
| "erstattungsfaehig", |
| "erstattung", |
| "rezeptpflichtig", |
| "verschreibungspflichtig", |
| "apothekenpflichtig", |
| "grüne rezept", |
| "grünes rezept", |
| "privatrezept", |
| "selbstzahler", |
| "auf kasse", |
| ) |
|
|
| |
| |
| |
| RE_ANLAGE_I = re.compile(r"\banlage\s+i\b(?!i)") |
|
|
| |
| |
| |
| GRUPPENEINTRAEGE: frozenset[str] = frozenset( |
| {"1", "4", "5", "6", "7", "8", "9", "21", "22", "27", "37", "43", "46"} |
| ) |
|
|
|
|
| @dataclass(frozen=True) |
| class Eintrag: |
| nr: str |
| stoffangabe: str |
| text: str |
| spiegelstriche: Tuple[str, ...] |
| begriffe: Tuple[str, ...] |
| varianten: Tuple[str, ...] |
| seite: Optional[int] |
|
|
| @property |
| def ist_gruppe(self) -> bool: |
| return self.nr in GRUPPENEINTRAEGE |
|
|
| def volltext(self) -> str: |
| if not self.spiegelstriche: |
| return self.text |
| return self.text + "\n" + "\n".join(f" - {s}" for s in self.spiegelstriche) |
|
|
|
|
| @dataclass |
| class Befund: |
| status: str |
| gefragter_stoff: Optional[str] = None |
| treffer: List[Eintrag] = field(default_factory=list) |
| stand: str = "" |
| stand_iso: str = "" |
| veraltet: bool = False |
| alter_tage: Optional[int] = None |
| fundstelle: str = "" |
| url: str = "" |
| grund: str = "" |
|
|
| @property |
| def ist_belastbar(self) -> bool: |
| return self.status in {"gelistet", "gruppe_gelistet", "nicht_gelistet"} |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| return { |
| "status": self.status, |
| "gefragter_stoff": self.gefragter_stoff, |
| "treffer": [ |
| {"nr": e.nr, "stoffangabe": e.stoffangabe, "seite": e.seite} for e in self.treffer |
| ], |
| "stand": self.stand, |
| "veraltet": self.veraltet, |
| "alter_tage": self.alter_tage, |
| "fundstelle": self.fundstelle, |
| "url": self.url, |
| "grund": self.grund, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def load_liste(path: Path) -> Dict[str, Any]: |
| key = str(path) |
| if key in _CACHE: |
| return _CACHE[key] |
|
|
| data: Dict[str, Any] = {} |
| try: |
| if path.exists(): |
| data = json.loads(path.read_text(encoding="utf-8")) |
| except Exception as exc: |
| logger.warning("OTC-Liste nicht lesbar: %s (%s)", path, exc) |
| data = {} |
|
|
| _CACHE[key] = data |
| return data |
|
|
|
|
| def _eintraege(daten: Dict[str, Any]) -> List[Eintrag]: |
| out: List[Eintrag] = [] |
| for roh in daten.get("eintraege") or []: |
| if roh.get("unbesetzt"): |
| continue |
| out.append( |
| Eintrag( |
| nr=str(roh.get("nr") or ""), |
| stoffangabe=str(roh.get("stoffangabe") or ""), |
| text=str(roh.get("text") or ""), |
| spiegelstriche=tuple(str(s) for s in roh.get("spiegelstriche") or []), |
| begriffe=tuple(str(b) for b in roh.get("begriffe") or []), |
| varianten=tuple(str(v) for v in roh.get("varianten") or []), |
| seite=roh.get("seite"), |
| ) |
| ) |
| return out |
|
|
|
|
| |
| |
| |
|
|
| def _norm(text: str) -> str: |
| return " ".join(str(text or "").lower().split()) |
|
|
|
|
| def begriffsindex(eintraege: Sequence[Eintrag]) -> List[Tuple[str, Eintrag]]: |
| """(Suchbegriff, Eintrag), längste zuerst. |
| |
| Die Reihenfolge trägt: „Wasserlösliche Vitamine" darf nicht vom kürzeren |
| „Vitamin" verdeckt werden, und „Eisen-(II)-Verbindungen" nicht von „Eisen". |
| """ |
| paare = [(b, e) for e in eintraege for b in (*e.begriffe, *e.varianten) if b] |
| return sorted(paare, key=lambda p: -len(p[0])) |
|
|
|
|
| def erkannte_eintraege(question: str, eintraege: Sequence[Eintrag]) -> Tuple[Optional[str], List[Eintrag]]: |
| """(erkannter Begriff, Einträge dazu) — mehrere, wenn der Stoff mehrfach steht. |
| |
| Calciumverbindungen stehen in Nummer 11 und 12 mit verschiedenen |
| Bedingungen, Magnesiumverbindungen in 28 und 29, Folsäure in 19 und 44. Nur |
| einen davon zu zeigen unterschlüge eine Verordnungsmöglichkeit. |
| """ |
| haystack = _norm(question) |
| for begriff, _ in begriffsindex(eintraege): |
| if re.search(rf"(?<![\wäöüß]){re.escape(_norm(begriff))}(?![\wäöüß])", haystack): |
| passend = [ |
| e |
| for e in eintraege |
| if any(_norm(b) == _norm(begriff) for b in (*e.begriffe, *e.varianten)) |
| ] |
| return begriff, passend |
| return None, [] |
|
|
|
|
| def fragt_nach_verordnungsfaehigkeit(question: str) -> bool: |
| haystack = _norm(question) |
| if RE_ANLAGE_I.search(haystack): |
| return True |
| return any(signal in haystack for signal in OTC_SIGNALE) |
|
|
|
|
| |
| |
| |
|
|
| def pruefe( |
| question: str = "", |
| *, |
| path: Path, |
| stoff: Optional[str] = None, |
| heute: Optional[date] = None, |
| ) -> Befund: |
| daten = load_liste(path) |
| alle = _eintraege(daten) |
| if not alle: |
| return Befund(status="nicht_pruefbar", grund="snapshot_fehlt_oder_leer") |
|
|
| quelle = daten.get("quelle") or {} |
| stand = str(quelle.get("stand") or "") |
| fundstelle = str(quelle.get("dokument") or "AM-RL Anlage I") |
| url = str(quelle.get("url") or "") |
|
|
| if not stand: |
| return Befund(status="nicht_pruefbar", grund="stand_unbekannt", fundstelle=fundstelle, url=url) |
|
|
| stand_iso = _stand_iso(stand) |
| alter_tage: Optional[int] = None |
| veraltet = False |
| if stand_iso: |
| try: |
| alter_tage = max(0, ((heute or date.today()) - datetime.strptime(stand_iso, "%Y-%m-%d").date()).days) |
| veraltet = alter_tage > VERALTET_AB_TAGEN |
| except ValueError: |
| alter_tage = None |
|
|
| def _fertig(befund: Befund) -> Befund: |
| befund.stand = stand |
| befund.stand_iso = stand_iso |
| befund.alter_tage = alter_tage |
| befund.veraltet = veraltet |
| befund.fundstelle = fundstelle |
| befund.url = url |
| return befund |
|
|
| |
| |
| if not fragt_nach_verordnungsfaehigkeit(question) and stoff is None: |
| return _fertig(Befund(status="nicht_pruefbar", grund="keine_verordnungsfrage")) |
|
|
| if stoff: |
| begriff, treffer = stoff, [ |
| e for e in alle if any(_norm(b) == _norm(stoff) for b in (*e.begriffe, *e.varianten)) |
| ] |
| else: |
| begriff, treffer = erkannte_eintraege(question, alle) |
|
|
| if treffer: |
| gruppe = all(e.ist_gruppe for e in treffer) |
| return _fertig( |
| Befund( |
| status="gruppe_gelistet" if gruppe else "gelistet", |
| gefragter_stoff=begriff, |
| treffer=treffer, |
| ) |
| ) |
|
|
| |
| |
| |
| kandidat = begriff or wirkstoff_kandidat(question) |
| if not kandidat: |
| return _fertig(Befund(status="nicht_pruefbar", grund="kein_stoff_erkannt")) |
|
|
| return _fertig( |
| Befund(status="nicht_gelistet", gefragter_stoff=kandidat, grund="nicht_in_anlage_i") |
| ) |
|
|
|
|
| |
| |
| |
|
|
| _EINLEITUNG = "OTC-Verordnungsfähigkeit — deterministische Listenprüfung" |
|
|
| |
| |
| _AUSSERHALB = ( |
| "Unabhängig von dieser Liste bleibt verordnungsfähig, was § 12 der Richtlinie " |
| "gesondert regelt: für versicherte Kinder bis zum vollendeten 12. Lebensjahr und " |
| "Jugendliche mit Entwicklungsstörungen bis zum vollendeten 18. Lebensjahr gilt der " |
| "Ausschluss überhaupt nicht (Absatz 12); dazu kommen Begleitmedikation, die die " |
| "Fachinformation des Hauptarzneimittels voraussetzt (Absatz 7), die Behandlung " |
| "schwerwiegender unerwünschter Arzneimittelwirkungen (Absatz 8) und Arzneimittel der " |
| "Anthroposophie und Homöopathie für die hier gelisteten Indikationsgebiete (Absatz 6)." |
| ) |
| _GRUPPENVORBEHALT = ( |
| "Die Anlage führt hier eine Stoffgruppe und kein einzelnes Präparat. Ob das konkrete " |
| "Arzneimittel darunter fällt, ist am Fall zu prüfen — die Liste sagt es nicht." |
| ) |
|
|
|
|
| def _eintrag_block(eintrag: Eintrag) -> str: |
| zeilen = [f" Nummer {eintrag.nr}: {eintrag.text}"] |
| for strich in eintrag.spiegelstriche: |
| zeilen.append(f" – {strich}") |
| return "\n".join(zeilen) |
|
|
|
|
| def befund_block(befund: Befund) -> str: |
| if not befund.ist_belastbar: |
| return "" |
|
|
| zeilen: List[str] = [f"{_EINLEITUNG} ({befund.fundstelle}, Stand {befund.stand}):"] |
|
|
| if befund.status in {"gelistet", "gruppe_gelistet"}: |
| anzahl = len(befund.treffer) |
| wo = ( |
| "in einem Eintrag, nur unter der dort genannten Indikation" |
| if anzahl == 1 |
| else f"in {anzahl} Einträgen, jeweils nur unter der dort genannten Indikation" |
| ) |
| zeilen.append( |
| f"{befund.gefragter_stoff} steht in Anlage I — {wo}. Die Verordnung zu Lasten der " |
| "GKV ist an diese Bedingung gebunden; ob sie im Fall vorliegt, ist eine ärztliche " |
| "Feststellung." |
| ) |
| for eintrag in befund.treffer: |
| zeilen.append(_eintrag_block(eintrag)) |
| if befund.status == "gruppe_gelistet": |
| zeilen.append(_GRUPPENVORBEHALT) |
| else: |
| zeilen.append( |
| f"Anlage I führt {befund.gefragter_stoff} nicht. Nicht verschreibungspflichtige " |
| "Arzneimittel sind nach § 34 Absatz 1 Satz 1 SGB V von der Versorgung " |
| "ausgeschlossen, und § 12 Absatz 10 der Richtlinie regelt abschließend, wann " |
| "ausnahmsweise verordnet werden darf. Ist das Arzneimittel nicht " |
| "verschreibungspflichtig, kommt eine Verordnung zu Lasten der GKV danach nicht " |
| "in Betracht." |
| ) |
|
|
| zeilen.append(_AUSSERHALB) |
|
|
| if befund.veraltet: |
| zeilen.append( |
| f"Achtung Aktualität: Dieser Auszug ist {befund.alter_tage} Tage alt. Vor einer " |
| "Entscheidung ist die geltende Fassung abzugleichen." |
| ) |
| if befund.url: |
| zeilen.append(f"Quelle: {befund.url}") |
|
|
| return "\n".join(zeilen) |
|
|
|
|
| def verdikt(befund: Befund) -> str: |
| """Die Antwort in einem Satz — nur wenn die Frage entschieden werden konnte.""" |
| if befund.status == "gelistet": |
| nummern = ", ".join(e.nr for e in befund.treffer) |
| return ( |
| f"{befund.gefragter_stoff} ist in Anlage I der AM-RL geführt (Nummer {nummern}) und " |
| f"damit zu Lasten der GKV verordnungsfähig, aber nur unter der dort genannten " |
| f"Indikation (Listenprüfung oben, Stand {befund.stand})." |
| ) |
| if befund.status == "gruppe_gelistet": |
| nummern = ", ".join(e.nr for e in befund.treffer) |
| return ( |
| f"Anlage I der AM-RL führt {befund.gefragter_stoff} als Stoffgruppe (Nummer " |
| f"{nummern}) und nur unter der dort genannten Indikation; ob das konkrete Präparat " |
| f"darunter fällt, ist am Fall zu prüfen (Listenprüfung oben, Stand {befund.stand})." |
| ) |
| if befund.status == "nicht_gelistet": |
| |
| |
| |
| |
| return ( |
| f"{befund.gefragter_stoff} steht nicht in Anlage I der AM-RL. Soweit das " |
| f"Arzneimittel nicht verschreibungspflichtig ist, kommt eine Verordnung zu Lasten " |
| f"der GKV damit nicht in Betracht — vorbehaltlich der Sonderfälle des § 12 AM-RL, " |
| f"insbesondere bei Kindern bis 12 Jahren (Listenprüfung oben, Stand {befund.stand})." |
| ) |
| return "" |
|
|