File size: 16,500 Bytes
d80690d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
"""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

# Geteilt statt ein viertes Mal geschrieben: dieselbe Standangabe, dieselbe
# Frist, dieselbe Endungsheuristik für einen Wirkstoffnamen.
from amrl_substitution import VERALTET_AB_TAGEN, _stand_iso, wirkstoff_kandidat

logger = logging.getLogger(__name__)

_CACHE: Dict[str, Dict[str, Any]] = {}

# Die Frage muss von der Verordnungsfähigkeit handeln. Regelbasiert wie in den
# übrigen Listenmodulen: eine Substring-Prüfung entscheidet das exakt.
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",
)

# Die Anlage beim Namen genannt. Als Substring wäre „anlage i" auch in
# „Anlage III" enthalten — und die regelt Verordnungseinschränkungen, nicht die
# OTC-Ausnahmen.
RE_ANLAGE_I = re.compile(r"\banlage\s+i\b(?!i)")

# Einträge, die eine Stoffgruppe führen und kein Einzelpräparat. Die Zuordnung
# eines konkreten Präparats zur Gruppe ist eine pharmakologische Feststellung,
# die die Anlage nicht trifft — der Befund sagt das, statt sie zu treffen.
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,
        }


# ---------------------------------------------------------------------------
# Daten
# ---------------------------------------------------------------------------

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:  # noqa: BLE001 - ein defekter Snapshot darf keine Antwort verhindern.
        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


# ---------------------------------------------------------------------------
# Erkennung in der Frage
# ---------------------------------------------------------------------------

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)


# ---------------------------------------------------------------------------
# Prüfung
# ---------------------------------------------------------------------------

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

    # Ohne Bezug zur Verordnungsfähigkeit gar nichts: „Wie dosiere ich Iodid?"
    # nennt einen gelisteten Stoff, will aber keine OTC-Auskunft.
    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,
            )
        )

    # Keine Negativauskunft ins Blaue: „nicht in Anlage I" ist nur dann eine
    # Aussage, wenn überhaupt ein Stoff genannt wurde. Sonst wäre es eine
    # Aussage über eine Frage, die keinen nennt.
    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")
    )


# ---------------------------------------------------------------------------
# Darstellung
# ---------------------------------------------------------------------------

_EINLEITUNG = "OTC-Verordnungsfähigkeit — deterministische Listenprüfung"

# Die vier Wege an der Liste vorbei. Sie stehen in § 12 der Richtlinie und nicht
# in der Anlage; ein Befund, der nur die Liste wiedergibt, ist ohne sie falsch.
_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:  # nicht_gelistet
        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":
        # Der Vorbehalt der Verschreibungspflicht ist kein Hedging, sondern der
        # Anwendungsbereich: Anlage I entscheidet nur über nicht
        # verschreibungspflichtige Arzneimittel. Ibuprofen gibt es als beides,
        # und ein pauschales „nicht verordnungsfähig" wäre für die 600 mg falsch.
        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 ""