Aktualisierung der AM-RL-Module und Retrieval-Logik
Browse files- src/__init__.py +15 -0
- src/amrl_austauschbarkeit.py +492 -0
- src/amrl_biosimilars.py +588 -0
- src/amrl_lifestyle.py +646 -0
- src/amrl_otc.py +415 -0
- src/amrl_substitution.py +672 -0
- src/amrl_tabakentwoehnung.py +529 -0
- src/amrl_verordnungsausschluss.py +471 -0
- src/answer_composer.py +211 -77
- src/answer_schema.py +106 -0
- src/app.py +730 -70
- src/corpus_amendments.py +277 -0
- src/corpus_boundary.py +273 -0
- src/corpus_registry.py +295 -0
- src/corpus_router.py +68 -0
- src/federated_retriever.py +619 -0
- src/fundstellen.py +190 -0
- src/llm_client_groq.py +65 -4
- src/norm_anchors.py +0 -0
- src/norm_rang.py +86 -0
- src/norm_verweise.py +347 -0
- src/orchestrator.py +3 -3
- src/retriever.py +44 -0
src/__init__.py
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend package.
|
| 2 |
+
|
| 3 |
+
The modules in here import each other flat (`from retriever import ...`) rather
|
| 4 |
+
than relative, which is what the test suite and `--app-dir src` already assume.
|
| 5 |
+
Adding this directory to `sys.path` on package import makes the documented and
|
| 6 |
+
deployed entrypoint `uvicorn src.app:app` resolve those same names, so the app
|
| 7 |
+
starts identically whether it is imported as `src.app` or as `app`.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
_SRC_DIR = str(Path(__file__).resolve().parent)
|
| 14 |
+
if _SRC_DIR not in sys.path:
|
| 15 |
+
sys.path.insert(0, _SRC_DIR)
|
src/amrl_austauschbarkeit.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Austauschbarkeit von Darreichungsformen nachschlagen (AM-RL Anlage VII Teil A).
|
| 2 |
+
|
| 3 |
+
Teil A beantwortet eine andere Frage als Teil B, und beide werden in der Offizin
|
| 4 |
+
mit demselben Satz gestellt. „Darf ich austauschen?" kann heißen:
|
| 5 |
+
|
| 6 |
+
* dasselbe Präparat eines anderen Herstellers → das regelt der
|
| 7 |
+
Substitutionsausschluss, siehe `amrl_substitution`;
|
| 8 |
+
* **eine andere Darreichungsform** → das regelt Teil A, und darum geht es hier.
|
| 9 |
+
|
| 10 |
+
Der Zuschnitt der Daten trägt die Rechtsfolge: Jede Tabellenzeile ist eine
|
| 11 |
+
**Gruppe**, deren Formen untereinander austauschbar sind — über Gruppengrenzen
|
| 12 |
+
hinweg nicht. Ambroxol steht deshalb zweimal in der Anlage, feste Formen in der
|
| 13 |
+
einen Gruppe, flüssige in der anderen. Wer beide zusammenwirft, erklärt
|
| 14 |
+
Brausetabletten für gegen Sirup austauschbar. Cetirizin steht sogar dreimal,
|
| 15 |
+
zweimal davon mit einer einzigen Form: genau das ist die Aussage, dass diese
|
| 16 |
+
Form mit den anderen *nicht* getauscht werden darf.
|
| 17 |
+
|
| 18 |
+
Wie in `amrl_substitution` gilt: kein Retaxationsurteil, kein Raten. Zurück
|
| 19 |
+
kommt der Listenstatus mit Fundstelle und Stand; was daraus für Abgabe und
|
| 20 |
+
Abrechnung folgt, bleibt Sache des RAG-Teils.
|
| 21 |
+
|
| 22 |
+
Die Zustände:
|
| 23 |
+
|
| 24 |
+
austauschbar beide Formen stehen in derselben Gruppe
|
| 25 |
+
nicht_austauschbar beide Formen stehen beim Wirkstoff, aber in
|
| 26 |
+
verschiedenen Gruppen — die aussagekräftigste
|
| 27 |
+
Auskunft, und ohne vollständige Liste nicht zu haben
|
| 28 |
+
form_nicht_gelistet der Wirkstoff ist gelistet, die erfragte Form nicht
|
| 29 |
+
wirkstoff_nicht_gelistet Teil A trifft für den Wirkstoff keine Aussage
|
| 30 |
+
uebersicht nur eine Form genannt: die Gruppe wird aufgezählt
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import json
|
| 36 |
+
import logging
|
| 37 |
+
import re
|
| 38 |
+
from dataclasses import dataclass, field
|
| 39 |
+
from datetime import date, datetime
|
| 40 |
+
from pathlib import Path
|
| 41 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 42 |
+
|
| 43 |
+
# Dieselbe Anlage, dieselbe Aufgabe: einen Wirkstoffnamen erkennen, der nicht auf
|
| 44 |
+
# der jeweiligen Liste steht. Die kuratierte Endungsliste wird geteilt, statt sie
|
| 45 |
+
# zweimal zu pflegen und auseinanderlaufen zu lassen.
|
| 46 |
+
from amrl_substitution import wirkstoff_kandidat
|
| 47 |
+
|
| 48 |
+
logger = logging.getLogger(__name__)
|
| 49 |
+
|
| 50 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 51 |
+
|
| 52 |
+
VERALTET_AB_TAGEN = 180
|
| 53 |
+
|
| 54 |
+
# Fragen nach der Darreichungsform, zusätzlich zu den Austauschsignalen aus
|
| 55 |
+
# `amrl_substitution`. Rein regelbasiert wie das Routing: eine Substring-Prüfung
|
| 56 |
+
# entscheidet das exakt.
|
| 57 |
+
FORM_SIGNALE: tuple[str, ...] = (
|
| 58 |
+
"darreichungsform",
|
| 59 |
+
"aut idem",
|
| 60 |
+
"aut-idem",
|
| 61 |
+
"statt",
|
| 62 |
+
"anstelle",
|
| 63 |
+
"umstellen",
|
| 64 |
+
"umstellung",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass(frozen=True)
|
| 69 |
+
class Gruppe:
|
| 70 |
+
nr: int
|
| 71 |
+
tabelle: int
|
| 72 |
+
wirkstoffe: Tuple[str, ...]
|
| 73 |
+
applikation: Optional[str]
|
| 74 |
+
qualifier: Optional[str]
|
| 75 |
+
formen: Tuple[str, ...]
|
| 76 |
+
bedingungen: Tuple[Tuple[str, str], ...] # (Form, Bedingung)
|
| 77 |
+
|
| 78 |
+
def label(self) -> str:
|
| 79 |
+
teile = [" / ".join(self.wirkstoffe)]
|
| 80 |
+
if self.applikation:
|
| 81 |
+
teile.append(f"[{self.applikation}]")
|
| 82 |
+
if self.qualifier:
|
| 83 |
+
teile.append(f"({self.qualifier})")
|
| 84 |
+
return " ".join(teile)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@dataclass
|
| 88 |
+
class Befund:
|
| 89 |
+
status: str
|
| 90 |
+
wirkstoff: Optional[str] = None
|
| 91 |
+
form_a: Optional[str] = None
|
| 92 |
+
form_b: Optional[str] = None
|
| 93 |
+
gruppen: List[Gruppe] = field(default_factory=list)
|
| 94 |
+
treffer_gruppe: Optional[Gruppe] = None
|
| 95 |
+
stand: str = ""
|
| 96 |
+
stand_iso: str = ""
|
| 97 |
+
veraltet: bool = False
|
| 98 |
+
alter_tage: Optional[int] = None
|
| 99 |
+
fundstelle: str = ""
|
| 100 |
+
url: str = ""
|
| 101 |
+
grund: str = ""
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def ist_belastbar(self) -> bool:
|
| 105 |
+
return self.status in {
|
| 106 |
+
"austauschbar",
|
| 107 |
+
"nicht_austauschbar",
|
| 108 |
+
"form_nicht_gelistet",
|
| 109 |
+
"wirkstoff_nicht_gelistet",
|
| 110 |
+
"uebersicht",
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 114 |
+
return {
|
| 115 |
+
"status": self.status,
|
| 116 |
+
"wirkstoff": self.wirkstoff,
|
| 117 |
+
"form_a": self.form_a,
|
| 118 |
+
"form_b": self.form_b,
|
| 119 |
+
"gruppen": [
|
| 120 |
+
{
|
| 121 |
+
"nr": g.nr,
|
| 122 |
+
"tabelle": g.tabelle,
|
| 123 |
+
"wirkstoffe": list(g.wirkstoffe),
|
| 124 |
+
"applikation": g.applikation,
|
| 125 |
+
"qualifier": g.qualifier,
|
| 126 |
+
"formen": list(g.formen),
|
| 127 |
+
}
|
| 128 |
+
for g in self.gruppen
|
| 129 |
+
],
|
| 130 |
+
"stand": self.stand,
|
| 131 |
+
"veraltet": self.veraltet,
|
| 132 |
+
"alter_tage": self.alter_tage,
|
| 133 |
+
"fundstelle": self.fundstelle,
|
| 134 |
+
"url": self.url,
|
| 135 |
+
"grund": self.grund,
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# Daten
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
def load_liste(path: Path) -> Dict[str, Any]:
|
| 144 |
+
key = str(path)
|
| 145 |
+
if key in _CACHE:
|
| 146 |
+
return _CACHE[key]
|
| 147 |
+
|
| 148 |
+
data: Dict[str, Any] = {}
|
| 149 |
+
try:
|
| 150 |
+
if path.exists():
|
| 151 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 152 |
+
except Exception as exc: # noqa: BLE001 - ein defekter Snapshot darf keine Antwort verhindern.
|
| 153 |
+
logger.warning("Austauschbarkeitsliste nicht lesbar: %s (%s)", path, exc)
|
| 154 |
+
data = {}
|
| 155 |
+
|
| 156 |
+
_CACHE[key] = data
|
| 157 |
+
return data
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
_MONATE = {
|
| 161 |
+
"januar": 1, "februar": 2, "märz": 3, "maerz": 3, "april": 4, "mai": 5,
|
| 162 |
+
"juni": 6, "juli": 7, "august": 8, "september": 9, "oktober": 10,
|
| 163 |
+
"november": 11, "dezember": 12,
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _stand_iso(stand: str) -> str:
|
| 168 |
+
text = " ".join(str(stand or "").split())
|
| 169 |
+
treffer = re.match(r"^(\d{1,2})\.\s*([A-Za-zÄÖÜäöü]+)\s*(\d{4})$", text)
|
| 170 |
+
if treffer:
|
| 171 |
+
monat = _MONATE.get(treffer.group(2).lower())
|
| 172 |
+
if monat:
|
| 173 |
+
return f"{int(treffer.group(3)):04d}-{monat:02d}-{int(treffer.group(1)):02d}"
|
| 174 |
+
return text if re.match(r"^\d{4}-\d{2}-\d{2}$", text) else ""
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _gruppen(daten: Dict[str, Any]) -> List[Gruppe]:
|
| 178 |
+
out: List[Gruppe] = []
|
| 179 |
+
for roh in daten.get("gruppen") or []:
|
| 180 |
+
formen = tuple(str(e.get("form") or "") for e in roh.get("darreichungsformen") or [])
|
| 181 |
+
bedingungen = tuple(
|
| 182 |
+
(str(e.get("form") or ""), str(e["bedingung"]))
|
| 183 |
+
for e in roh.get("darreichungsformen") or []
|
| 184 |
+
if e.get("bedingung")
|
| 185 |
+
)
|
| 186 |
+
out.append(
|
| 187 |
+
Gruppe(
|
| 188 |
+
nr=int(roh.get("nr") or 0),
|
| 189 |
+
tabelle=int(roh.get("tabelle") or 1),
|
| 190 |
+
wirkstoffe=tuple(str(w) for w in roh.get("wirkstoffe") or []),
|
| 191 |
+
applikation=(str(roh["applikation"]) if roh.get("applikation") else None),
|
| 192 |
+
qualifier=(str(roh["qualifier"]) if roh.get("qualifier") else None),
|
| 193 |
+
formen=formen,
|
| 194 |
+
bedingungen=bedingungen,
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
return out
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
# Erkennung in der Frage
|
| 202 |
+
# ---------------------------------------------------------------------------
|
| 203 |
+
|
| 204 |
+
def _norm(text: str) -> str:
|
| 205 |
+
return " ".join(str(text or "").lower().split())
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def wirkstoff_index(gruppen: Sequence[Gruppe]) -> List[Tuple[str, str]]:
|
| 209 |
+
"""(Suchbegriff, kanonischer Name), längste zuerst."""
|
| 210 |
+
paare: List[Tuple[str, str]] = []
|
| 211 |
+
for gruppe in gruppen:
|
| 212 |
+
for name in gruppe.wirkstoffe:
|
| 213 |
+
paare.append((name, name))
|
| 214 |
+
ohne = re.sub(r"\s*\([^)]*\)\s*", " ", name).strip()
|
| 215 |
+
if ohne and ohne != name:
|
| 216 |
+
paare.append((ohne, name))
|
| 217 |
+
return sorted(set(paare), key=lambda p: -len(p[0]))
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def erkannter_wirkstoff(question: str, gruppen: Sequence[Gruppe]) -> Optional[str]:
|
| 221 |
+
haystack = _norm(question)
|
| 222 |
+
for begriff, kanonisch in wirkstoff_index(gruppen):
|
| 223 |
+
if re.search(rf"(?<![\wäöüß]){re.escape(begriff.lower())}(?![\wäöüß])", haystack):
|
| 224 |
+
return kanonisch
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def genannte_formen(question: str, gruppen: Sequence[Gruppe]) -> List[str]:
|
| 229 |
+
"""Darreichungsformen aus der Frage, in der Reihenfolge ihres Auftretens.
|
| 230 |
+
|
| 231 |
+
Das Vokabular kommt aus der Anlage selbst — ein Standard Term, den Teil A
|
| 232 |
+
nirgends führt, kann auch keine Gruppenzugehörigkeit begründen.
|
| 233 |
+
"""
|
| 234 |
+
haystack = _norm(question)
|
| 235 |
+
alle = {f for gruppe in gruppen for f in gruppe.formen if f}
|
| 236 |
+
treffer: List[Tuple[int, str]] = []
|
| 237 |
+
belegt: List[Tuple[int, int]] = []
|
| 238 |
+
|
| 239 |
+
for form in sorted(alle, key=len, reverse=True):
|
| 240 |
+
for match in re.finditer(re.escape(_norm(form)), haystack):
|
| 241 |
+
span = (match.start(), match.end())
|
| 242 |
+
if any(s <= span[0] and span[1] <= e for s, e in belegt):
|
| 243 |
+
continue
|
| 244 |
+
belegt.append(span)
|
| 245 |
+
treffer.append((span[0], form))
|
| 246 |
+
|
| 247 |
+
return [form for _, form in sorted(treffer)]
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def fragt_nach_darreichungsform(question: str) -> bool:
|
| 251 |
+
haystack = _norm(question)
|
| 252 |
+
return any(signal in haystack for signal in FORM_SIGNALE)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# ---------------------------------------------------------------------------
|
| 256 |
+
# Prüfung
|
| 257 |
+
# ---------------------------------------------------------------------------
|
| 258 |
+
|
| 259 |
+
def pruefe(
|
| 260 |
+
question: str = "",
|
| 261 |
+
*,
|
| 262 |
+
path: Path,
|
| 263 |
+
wirkstoff: Optional[str] = None,
|
| 264 |
+
formen: Optional[Sequence[str]] = None,
|
| 265 |
+
heute: Optional[date] = None,
|
| 266 |
+
) -> Befund:
|
| 267 |
+
daten = load_liste(path)
|
| 268 |
+
gruppen_alle = _gruppen(daten)
|
| 269 |
+
if not gruppen_alle:
|
| 270 |
+
return Befund(status="nicht_pruefbar", grund="snapshot_fehlt_oder_leer")
|
| 271 |
+
|
| 272 |
+
quelle = daten.get("quelle") or {}
|
| 273 |
+
stand = str(quelle.get("stand") or "")
|
| 274 |
+
fundstelle = f"{quelle.get('dokument', 'AM-RL Anlage VII')} Teil A"
|
| 275 |
+
url = str(quelle.get("url") or "")
|
| 276 |
+
|
| 277 |
+
if not stand:
|
| 278 |
+
return Befund(status="nicht_pruefbar", grund="stand_unbekannt", fundstelle=fundstelle, url=url)
|
| 279 |
+
|
| 280 |
+
stand_iso = _stand_iso(stand)
|
| 281 |
+
alter_tage: Optional[int] = None
|
| 282 |
+
veraltet = False
|
| 283 |
+
if stand_iso:
|
| 284 |
+
try:
|
| 285 |
+
alter_tage = max(0, ((heute or date.today()) - datetime.strptime(stand_iso, "%Y-%m-%d").date()).days)
|
| 286 |
+
veraltet = alter_tage > VERALTET_AB_TAGEN
|
| 287 |
+
except ValueError:
|
| 288 |
+
alter_tage = None
|
| 289 |
+
|
| 290 |
+
def _fertig(befund: Befund) -> Befund:
|
| 291 |
+
befund.stand = stand
|
| 292 |
+
befund.stand_iso = stand_iso
|
| 293 |
+
befund.alter_tage = alter_tage
|
| 294 |
+
befund.veraltet = veraltet
|
| 295 |
+
befund.fundstelle = fundstelle
|
| 296 |
+
befund.url = url
|
| 297 |
+
return befund
|
| 298 |
+
|
| 299 |
+
name = wirkstoff or erkannter_wirkstoff(question, gruppen_alle)
|
| 300 |
+
gefragte = list(formen) if formen else genannte_formen(question, gruppen_alle)
|
| 301 |
+
|
| 302 |
+
if not name:
|
| 303 |
+
# Der Wirkstoff steht nicht in Teil A. Das ist selbst eine Auskunft —
|
| 304 |
+
# aber nur, wenn überhaupt ein Wirkstoff genannt wurde. Ohne einen
|
| 305 |
+
# wäre „Teil A sagt dazu nichts" eine Aussage über eine Frage, die
|
| 306 |
+
# keinen Wirkstoff nennt; Teil A ordnet Formen immer wirkstoffbezogen zu.
|
| 307 |
+
kandidat = wirkstoff_kandidat(question)
|
| 308 |
+
if not kandidat:
|
| 309 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="kein_wirkstoff_erkannt"))
|
| 310 |
+
return _fertig(
|
| 311 |
+
Befund(
|
| 312 |
+
status="wirkstoff_nicht_gelistet",
|
| 313 |
+
wirkstoff=kandidat,
|
| 314 |
+
grund="wirkstoff_nicht_in_teil_a",
|
| 315 |
+
)
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
passend = [g for g in gruppen_alle if name in g.wirkstoffe]
|
| 319 |
+
if not passend:
|
| 320 |
+
return _fertig(
|
| 321 |
+
Befund(status="wirkstoff_nicht_gelistet", wirkstoff=name, grund="wirkstoff_nicht_in_teil_a")
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
if len(gefragte) < 2:
|
| 325 |
+
if len(gefragte) == 1:
|
| 326 |
+
mit_form = [g for g in passend if gefragte[0] in g.formen]
|
| 327 |
+
if not mit_form:
|
| 328 |
+
return _fertig(
|
| 329 |
+
Befund(
|
| 330 |
+
status="form_nicht_gelistet",
|
| 331 |
+
wirkstoff=name,
|
| 332 |
+
form_a=gefragte[0],
|
| 333 |
+
gruppen=passend,
|
| 334 |
+
grund="form_nicht_in_den_gruppen",
|
| 335 |
+
)
|
| 336 |
+
)
|
| 337 |
+
return _fertig(
|
| 338 |
+
Befund(status="uebersicht", wirkstoff=name, form_a=gefragte[0], gruppen=mit_form)
|
| 339 |
+
)
|
| 340 |
+
return _fertig(Befund(status="uebersicht", wirkstoff=name, gruppen=passend))
|
| 341 |
+
|
| 342 |
+
form_a, form_b = gefragte[0], gefragte[1]
|
| 343 |
+
gemeinsam = [g for g in passend if form_a in g.formen and form_b in g.formen]
|
| 344 |
+
if gemeinsam:
|
| 345 |
+
return _fertig(
|
| 346 |
+
Befund(
|
| 347 |
+
status="austauschbar",
|
| 348 |
+
wirkstoff=name,
|
| 349 |
+
form_a=form_a,
|
| 350 |
+
form_b=form_b,
|
| 351 |
+
gruppen=gemeinsam,
|
| 352 |
+
treffer_gruppe=gemeinsam[0],
|
| 353 |
+
)
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
hat_a = [g for g in passend if form_a in g.formen]
|
| 357 |
+
hat_b = [g for g in passend if form_b in g.formen]
|
| 358 |
+
if not hat_a or not hat_b:
|
| 359 |
+
fehlend = form_a if not hat_a else form_b
|
| 360 |
+
return _fertig(
|
| 361 |
+
Befund(
|
| 362 |
+
status="form_nicht_gelistet",
|
| 363 |
+
wirkstoff=name,
|
| 364 |
+
form_a=fehlend,
|
| 365 |
+
form_b=(form_b if not hat_a else form_a),
|
| 366 |
+
gruppen=passend,
|
| 367 |
+
grund="form_nicht_in_den_gruppen",
|
| 368 |
+
)
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
return _fertig(
|
| 372 |
+
Befund(
|
| 373 |
+
status="nicht_austauschbar",
|
| 374 |
+
wirkstoff=name,
|
| 375 |
+
form_a=form_a,
|
| 376 |
+
form_b=form_b,
|
| 377 |
+
gruppen=hat_a + hat_b,
|
| 378 |
+
)
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
# ---------------------------------------------------------------------------
|
| 383 |
+
# Darstellung
|
| 384 |
+
# ---------------------------------------------------------------------------
|
| 385 |
+
|
| 386 |
+
_EINLEITUNG = "Austauschbarkeit der Darreichungsform — deterministische Listenprüfung"
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _gruppe_zeile(gruppe: Gruppe) -> str:
|
| 390 |
+
return f" · {gruppe.label()}: " + ", ".join(gruppe.formen)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def befund_block(befund: Befund) -> str:
|
| 394 |
+
if not befund.ist_belastbar:
|
| 395 |
+
return ""
|
| 396 |
+
|
| 397 |
+
zeilen: List[str] = [f"{_EINLEITUNG} ({befund.fundstelle}, Stand {befund.stand}):"]
|
| 398 |
+
|
| 399 |
+
if befund.status == "wirkstoff_nicht_gelistet":
|
| 400 |
+
zeilen.append(
|
| 401 |
+
f"Für {befund.wirkstoff} enthält Teil A keine Gruppe austauschbarer "
|
| 402 |
+
"Darreichungsformen. Ein Austausch über die Darreichungsform hinweg ist damit "
|
| 403 |
+
"nicht durch die Hinweise nach § 129 Absatz 1a Satz 1 SGB V gedeckt."
|
| 404 |
+
)
|
| 405 |
+
elif befund.status == "austauschbar":
|
| 406 |
+
gruppe = befund.treffer_gruppe
|
| 407 |
+
zeilen.append(
|
| 408 |
+
f"Ja. {befund.form_a} und {befund.form_b} stehen bei {befund.wirkstoff} in "
|
| 409 |
+
"derselben Gruppe und sind damit austauschbar."
|
| 410 |
+
)
|
| 411 |
+
if gruppe:
|
| 412 |
+
zeilen.append(f"Gruppe: {', '.join(gruppe.formen)}.")
|
| 413 |
+
if gruppe.qualifier:
|
| 414 |
+
zeilen.append(f"Die Gruppe gilt nur für: {gruppe.qualifier}.")
|
| 415 |
+
if gruppe.applikation:
|
| 416 |
+
zeilen.append(f"Applikation: {gruppe.applikation}.")
|
| 417 |
+
for form, bedingung in gruppe.bedingungen:
|
| 418 |
+
zeilen.append(f"Zu ��{form}“: {bedingung}.")
|
| 419 |
+
elif befund.status == "nicht_austauschbar":
|
| 420 |
+
zeilen.append(
|
| 421 |
+
f"Nein. {befund.form_a} und {befund.form_b} stehen bei {befund.wirkstoff} zwar "
|
| 422 |
+
"beide in Teil A, aber in verschiedenen Gruppen — und nur innerhalb einer "
|
| 423 |
+
"Gruppe sind Formen austauschbar."
|
| 424 |
+
)
|
| 425 |
+
for gruppe in befund.gruppen:
|
| 426 |
+
zeilen.append(_gruppe_zeile(gruppe))
|
| 427 |
+
elif befund.status == "form_nicht_gelistet":
|
| 428 |
+
zeilen.append(
|
| 429 |
+
f"„{befund.form_a}“ führt Teil A bei {befund.wirkstoff} nicht. Ein Austausch "
|
| 430 |
+
"gegen diese Form ist durch die Hinweise nach § 129 Absatz 1a Satz 1 SGB V "
|
| 431 |
+
"nicht gedeckt. Gelistet sind:"
|
| 432 |
+
)
|
| 433 |
+
for gruppe in befund.gruppen:
|
| 434 |
+
zeilen.append(_gruppe_zeile(gruppe))
|
| 435 |
+
else: # uebersicht
|
| 436 |
+
anzahl = len(befund.gruppen)
|
| 437 |
+
zeilen.append(
|
| 438 |
+
f"Teil A führt {befund.wirkstoff} in {anzahl} "
|
| 439 |
+
f"{'Gruppe' if anzahl == 1 else 'Gruppen'} austauschbarer Darreichungsformen. "
|
| 440 |
+
"Austauschbar ist nur innerhalb einer Gruppe:"
|
| 441 |
+
)
|
| 442 |
+
for gruppe in befund.gruppen:
|
| 443 |
+
zeilen.append(_gruppe_zeile(gruppe))
|
| 444 |
+
|
| 445 |
+
# Nur zu den verneinenden Befunden: dort entscheidet die Vorbemerkung
|
| 446 |
+
# mit, ob eine nicht aufgeführte Bezeichnung doch erfasst ist. Bei einem
|
| 447 |
+
# klaren „austauschbar" trägt sie nichts bei und verwässert die Auskunft.
|
| 448 |
+
if befund.status in {"nicht_austauschbar", "form_nicht_gelistet", "wirkstoff_nicht_gelistet"}:
|
| 449 |
+
zeilen.append(
|
| 450 |
+
"Nicht aufgeführte Bezeichnungen sind nach der Vorbemerkung mit erfasst, soweit "
|
| 451 |
+
"sie den definitorischen Voraussetzungen der gelisteten Standard Terms "
|
| 452 |
+
"entsprechen; das ist gesondert zu prüfen."
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
if befund.veraltet:
|
| 456 |
+
zeilen.append(
|
| 457 |
+
f"Achtung Aktualität: Dieser Auszug ist {befund.alter_tage} Tage alt. Vor einer "
|
| 458 |
+
"Entscheidung ist die geltende Fassung abzugleichen."
|
| 459 |
+
)
|
| 460 |
+
if befund.url:
|
| 461 |
+
zeilen.append(f"Quelle: {befund.url}")
|
| 462 |
+
|
| 463 |
+
return "\n".join(zeilen)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def verdikt(befund: Befund) -> str:
|
| 467 |
+
"""Die Antwort in einem Satz — nur wenn die Frage entschieden werden konnte."""
|
| 468 |
+
if befund.status == "austauschbar":
|
| 469 |
+
return (
|
| 470 |
+
f"Ja. {befund.form_a} und {befund.form_b} sind bei {befund.wirkstoff} nach Teil A "
|
| 471 |
+
f"der AM-RL-Anlage VII austauschbar (Listenprüfung oben, Stand {befund.stand})."
|
| 472 |
+
)
|
| 473 |
+
if befund.status == "nicht_austauschbar":
|
| 474 |
+
return (
|
| 475 |
+
f"Nein. {befund.form_a} und {befund.form_b} stehen bei {befund.wirkstoff} in "
|
| 476 |
+
f"verschiedenen Gruppen der AM-RL-Anlage VII Teil A und sind deshalb nicht "
|
| 477 |
+
f"austauschbar (Listenprüfung oben, Stand {befund.stand})."
|
| 478 |
+
)
|
| 479 |
+
if befund.status == "form_nicht_gelistet":
|
| 480 |
+
return (
|
| 481 |
+
f"„{befund.form_a}“ ist bei {befund.wirkstoff} in Teil A der AM-RL-Anlage VII "
|
| 482 |
+
f"nicht als austauschbare Darreichungsform geführt (Listenprüfung oben, "
|
| 483 |
+
f"Stand {befund.stand})."
|
| 484 |
+
)
|
| 485 |
+
if befund.status == "wirkstoff_nicht_gelistet":
|
| 486 |
+
return (
|
| 487 |
+
f"Für {befund.wirkstoff} trifft Teil A der AM-RL-Anlage VII keine Aussage zur "
|
| 488 |
+
f"Austauschbarkeit von Darreichungsformen (Listenprüfung oben, Stand {befund.stand})."
|
| 489 |
+
)
|
| 490 |
+
# Die Übersicht beantwortet keine Ja/Nein-Frage und ersetzt deshalb keine
|
| 491 |
+
# Kurzantwort — der Block darüber zeigt die Gruppen.
|
| 492 |
+
return ""
|
src/amrl_biosimilars.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Biosimilar-Zuordnung nachschlagen (AM-RL Anlage VIIa).
|
| 2 |
+
|
| 3 |
+
Diese Anlage ist die einzige der erschlossenen Listen, die die **Apotheke
|
| 4 |
+
unmittelbar adressiert**. § 40c AM-RL verpflichtet sie zur Ersetzung eines
|
| 5 |
+
verordneten biotechnologisch hergestellten Fertigarzneimittels, § 40b zur
|
| 6 |
+
Ersetzung des zu verarbeitenden Fertigarzneimittels in parenteralen
|
| 7 |
+
Zubereitungen. Wogegen ersetzt werden darf, sagt § 40c Absatz 3: gegen ein
|
| 8 |
+
Biosimilar zum verordneten Referenzarzneimittel oder gegen ein Biosimilar, das
|
| 9 |
+
mit Bezug auf *dasselbe* Referenzarzneimittel zugelassen ist. Welche
|
| 10 |
+
Arzneimittel in diesem Verhältnis zueinander stehen, führt Anlage VIIa auf.
|
| 11 |
+
|
| 12 |
+
Warum das ein Lookup ist und kein Retrieval: Die Zuordnung hängt an der
|
| 13 |
+
Tabellenzeile. Denosumab steht mit zwei Referenzarzneimitteln in der Anlage,
|
| 14 |
+
Prolia und Xgeva, mit vollständig verschiedenen Biosimilar-Listen; Wyost gehört
|
| 15 |
+
zu Xgeva. Trastuzumab, Tocilizumab, Ustekinumab, Rituximab und Natalizumab
|
| 16 |
+
trennen intravenös und subkutan ebenso. Ein Vektor-Retrieval liefert die
|
| 17 |
+
ähnlichsten Textstellen und kann diese Grenze nicht halten — es würde Prolia und
|
| 18 |
+
Wyost nebeneinander zeigen, weil beide unter „Denosumab" stehen.
|
| 19 |
+
|
| 20 |
+
**Der entscheidende Unterschied zu Anlage VII Teil B**: Dort trägt die
|
| 21 |
+
Negativauskunft — „steht nicht auf der Liste, also nicht ausgeschlossen". Hier
|
| 22 |
+
nicht. Die Anlage sagt über sich selbst, sie habe „keinen abschließenden
|
| 23 |
+
Charakter", und aus einem Nichtvorkommen folgt deshalb nichts. Dieses Modul
|
| 24 |
+
gibt darum **nur positive Befunde** zurück: was die Liste zeigt, und zwischen
|
| 25 |
+
welchen Arzneimitteln sie eine Zuordnung *ausschließt*, weil sie beide führt und
|
| 26 |
+
verschiedenen Referenzarzneimitteln zuordnet. Ein „nicht gelistet" gibt es hier
|
| 27 |
+
bewusst nicht.
|
| 28 |
+
|
| 29 |
+
Ebenso wenig trägt die Anlage die Ersetzungsentscheidung allein. Sie sagt
|
| 30 |
+
ausdrücklich nichts darüber, ob die Anwendungsgebiete übereinstimmen
|
| 31 |
+
(§ 40b Absatz 1 Satz 2, § 40c Absatz 1 Satz 4), und § 40c Absatz 1 Satz 2 und 3
|
| 32 |
+
verlangen zusätzlich identische Wirkstärke und Packungsgröße sowie — bei gleicher
|
| 33 |
+
gemeldeter Darreichungsform — ein übereinstimmendes Behältnis nach
|
| 34 |
+
Fachinformation. Der Befund benennt diese Bedingungen, statt sie zu
|
| 35 |
+
unterstellen.
|
| 36 |
+
|
| 37 |
+
Die Zustände:
|
| 38 |
+
|
| 39 |
+
austausch_gelistet beide Arzneimittel stehen in derselben Zeile
|
| 40 |
+
andere_referenz beide stehen beim selben Wirkstoff, aber in
|
| 41 |
+
verschiedenen Zeilen — die aussagekräftigste
|
| 42 |
+
Auskunft, und ohne die vollständige Liste nicht zu
|
| 43 |
+
haben (Prolia gegen Wyost)
|
| 44 |
+
anderer_wirkstoff beide gelistet, aber zu verschiedenen Wirkstoffen
|
| 45 |
+
keine_biosimilars das genannte Arzneimittel ist gelistet, seine Zeile
|
| 46 |
+
führt aber kein Biosimilar
|
| 47 |
+
uebersicht ein Arzneimittel genannt: die Zeile wird aufgezählt
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
from __future__ import annotations
|
| 51 |
+
|
| 52 |
+
import json
|
| 53 |
+
import logging
|
| 54 |
+
import re
|
| 55 |
+
from dataclasses import dataclass, field
|
| 56 |
+
from datetime import date, datetime
|
| 57 |
+
from pathlib import Path
|
| 58 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 59 |
+
|
| 60 |
+
# Dieselbe Aufgabe, dieselbe Schreibweise des Standes: die Datumsauswertung wird
|
| 61 |
+
# geteilt statt ein drittes Mal geschrieben und auseinanderlaufen gelassen.
|
| 62 |
+
from amrl_substitution import VERALTET_AB_TAGEN, _stand_iso, fragt_nach_austausch
|
| 63 |
+
|
| 64 |
+
logger = logging.getLogger(__name__)
|
| 65 |
+
|
| 66 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 67 |
+
|
| 68 |
+
# Signale zusätzlich zu denen aus `amrl_substitution`: die Frage nach einer
|
| 69 |
+
# Umstellung auf ein Biosimilar wird selten mit „austauschen" gestellt.
|
| 70 |
+
BIOSIMILAR_SIGNALE: tuple[str, ...] = (
|
| 71 |
+
"biosimilar",
|
| 72 |
+
"biologikum",
|
| 73 |
+
"biologika",
|
| 74 |
+
"biologisch",
|
| 75 |
+
"referenzarzneimittel",
|
| 76 |
+
"originalarzneimittel",
|
| 77 |
+
"originalpräparat",
|
| 78 |
+
"umstell",
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass(frozen=True)
|
| 83 |
+
class Arzneimittel:
|
| 84 |
+
name: str
|
| 85 |
+
zusatz: Optional[str] = None
|
| 86 |
+
fussnote: Optional[str] = None
|
| 87 |
+
|
| 88 |
+
def beschriftung(self) -> str:
|
| 89 |
+
return f"{self.name} ({self.zusatz})" if self.zusatz else self.name
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass(frozen=True)
|
| 93 |
+
class Gruppe:
|
| 94 |
+
"""Eine Tabellenzeile: ein Referenzarzneimittel mit seinen Biosimilars."""
|
| 95 |
+
|
| 96 |
+
nr: int
|
| 97 |
+
wirkstoff: str
|
| 98 |
+
applikation: Optional[str]
|
| 99 |
+
referenzen: Tuple[Arzneimittel, ...]
|
| 100 |
+
biosimilars: Tuple[Arzneimittel, ...]
|
| 101 |
+
seite: Optional[int]
|
| 102 |
+
|
| 103 |
+
def label(self) -> str:
|
| 104 |
+
namen = " / ".join(a.beschriftung() for a in self.referenzen) or "(kein Referenzarzneimittel)"
|
| 105 |
+
return f"{namen} [{self.applikation}]" if self.applikation else namen
|
| 106 |
+
|
| 107 |
+
def enthaelt(self, name: str) -> bool:
|
| 108 |
+
return any(a.name == name for a in self.referenzen + self.biosimilars)
|
| 109 |
+
|
| 110 |
+
def rolle(self, name: str) -> str:
|
| 111 |
+
if any(a.name == name for a in self.referenzen):
|
| 112 |
+
return "referenz"
|
| 113 |
+
return "biosimilar" if any(a.name == name for a in self.biosimilars) else ""
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class Befund:
|
| 118 |
+
status: str
|
| 119 |
+
produkt_a: Optional[str] = None
|
| 120 |
+
produkt_b: Optional[str] = None
|
| 121 |
+
wirkstoff: Optional[str] = None
|
| 122 |
+
# referenz_biosimilar | biosimilars_untereinander | referenzen_untereinander
|
| 123 |
+
bezug: str = ""
|
| 124 |
+
gruppen: List[Gruppe] = field(default_factory=list)
|
| 125 |
+
treffer_gruppe: Optional[Gruppe] = None
|
| 126 |
+
fussnoten: Dict[str, str] = field(default_factory=dict)
|
| 127 |
+
stand: str = ""
|
| 128 |
+
stand_iso: str = ""
|
| 129 |
+
veraltet: bool = False
|
| 130 |
+
alter_tage: Optional[int] = None
|
| 131 |
+
fundstelle: str = ""
|
| 132 |
+
url: str = ""
|
| 133 |
+
grund: str = ""
|
| 134 |
+
|
| 135 |
+
@property
|
| 136 |
+
def ist_belastbar(self) -> bool:
|
| 137 |
+
return self.status in {
|
| 138 |
+
"austausch_gelistet",
|
| 139 |
+
"andere_referenz",
|
| 140 |
+
"anderer_wirkstoff",
|
| 141 |
+
"keine_biosimilars",
|
| 142 |
+
"uebersicht",
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 146 |
+
return {
|
| 147 |
+
"status": self.status,
|
| 148 |
+
"produkt_a": self.produkt_a,
|
| 149 |
+
"produkt_b": self.produkt_b,
|
| 150 |
+
"wirkstoff": self.wirkstoff,
|
| 151 |
+
"bezug": self.bezug,
|
| 152 |
+
"gruppen": [
|
| 153 |
+
{
|
| 154 |
+
"nr": g.nr,
|
| 155 |
+
"wirkstoff": g.wirkstoff,
|
| 156 |
+
"applikation": g.applikation,
|
| 157 |
+
"referenzarzneimittel": [a.name for a in g.referenzen],
|
| 158 |
+
"biosimilars": [a.name for a in g.biosimilars],
|
| 159 |
+
}
|
| 160 |
+
for g in self.gruppen
|
| 161 |
+
],
|
| 162 |
+
"stand": self.stand,
|
| 163 |
+
"veraltet": self.veraltet,
|
| 164 |
+
"alter_tage": self.alter_tage,
|
| 165 |
+
"fundstelle": self.fundstelle,
|
| 166 |
+
"url": self.url,
|
| 167 |
+
"grund": self.grund,
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# Daten
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
def load_liste(path: Path) -> Dict[str, Any]:
|
| 176 |
+
key = str(path)
|
| 177 |
+
if key in _CACHE:
|
| 178 |
+
return _CACHE[key]
|
| 179 |
+
|
| 180 |
+
data: Dict[str, Any] = {}
|
| 181 |
+
try:
|
| 182 |
+
if path.exists():
|
| 183 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 184 |
+
except Exception as exc: # noqa: BLE001 - ein defekter Snapshot darf keine Antwort verhindern.
|
| 185 |
+
logger.warning("Biosimilar-Liste nicht lesbar: %s (%s)", path, exc)
|
| 186 |
+
data = {}
|
| 187 |
+
|
| 188 |
+
_CACHE[key] = data
|
| 189 |
+
return data
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _arzneimittel(rohe: Sequence[Dict[str, Any]]) -> Tuple[Arzneimittel, ...]:
|
| 193 |
+
return tuple(
|
| 194 |
+
Arzneimittel(
|
| 195 |
+
name=str(eintrag.get("name") or ""),
|
| 196 |
+
zusatz=(str(eintrag["zusatz"]) if eintrag.get("zusatz") else None),
|
| 197 |
+
fussnote=(str(eintrag["fussnote"]) if eintrag.get("fussnote") else None),
|
| 198 |
+
)
|
| 199 |
+
for eintrag in rohe or []
|
| 200 |
+
if eintrag.get("name")
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _gruppen(daten: Dict[str, Any]) -> List[Gruppe]:
|
| 205 |
+
out: List[Gruppe] = []
|
| 206 |
+
for wirkstoff in daten.get("wirkstoffe") or []:
|
| 207 |
+
for roh in wirkstoff.get("gruppen") or []:
|
| 208 |
+
out.append(
|
| 209 |
+
Gruppe(
|
| 210 |
+
nr=int(roh.get("nr") or 0),
|
| 211 |
+
wirkstoff=str(wirkstoff.get("wirkstoff") or ""),
|
| 212 |
+
applikation=(str(roh["applikation"]) if roh.get("applikation") else None),
|
| 213 |
+
referenzen=_arzneimittel(roh.get("referenzarzneimittel") or []),
|
| 214 |
+
biosimilars=_arzneimittel(roh.get("biosimilars") or []),
|
| 215 |
+
seite=roh.get("seite"),
|
| 216 |
+
)
|
| 217 |
+
)
|
| 218 |
+
return out
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
# Erkennung in der Frage
|
| 223 |
+
# ---------------------------------------------------------------------------
|
| 224 |
+
|
| 225 |
+
def _norm(text: str) -> str:
|
| 226 |
+
return " ".join(str(text or "").lower().split())
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def produkt_index(gruppen: Sequence[Gruppe]) -> List[str]:
|
| 230 |
+
"""Alle Handelsnamen der Anlage, längste zuerst.
|
| 231 |
+
|
| 232 |
+
Die Reihenfolge trägt: „Insulin aspart Sanofi" und „Tocilizumab Stada"
|
| 233 |
+
enthalten den Wirkstoffnamen, und der kürzere Treffer würde den längeren
|
| 234 |
+
verdecken.
|
| 235 |
+
"""
|
| 236 |
+
namen = {a.name for g in gruppen for a in g.referenzen + g.biosimilars if a.name}
|
| 237 |
+
return sorted(namen, key=len, reverse=True)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def erkannte_produkte(question: str, gruppen: Sequence[Gruppe]) -> List[str]:
|
| 241 |
+
"""Genannte Handelsnamen in der Reihenfolge ihres Auftretens, ohne Dubletten."""
|
| 242 |
+
haystack = _norm(question)
|
| 243 |
+
treffer: List[Tuple[int, str]] = []
|
| 244 |
+
belegt: List[Tuple[int, int]] = []
|
| 245 |
+
|
| 246 |
+
for name in produkt_index(gruppen):
|
| 247 |
+
for match in re.finditer(rf"(?<![\wäöüß]){re.escape(_norm(name))}(?![\wäöüß])", haystack):
|
| 248 |
+
span = (match.start(), match.end())
|
| 249 |
+
if any(s <= span[0] and span[1] <= e for s, e in belegt):
|
| 250 |
+
continue
|
| 251 |
+
belegt.append(span)
|
| 252 |
+
treffer.append((span[0], name))
|
| 253 |
+
|
| 254 |
+
out: List[str] = []
|
| 255 |
+
for _, name in sorted(treffer):
|
| 256 |
+
if name not in out:
|
| 257 |
+
out.append(name)
|
| 258 |
+
return out
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def erkannter_wirkstoff(question: str, gruppen: Sequence[Gruppe]) -> Optional[str]:
|
| 262 |
+
haystack = _norm(question)
|
| 263 |
+
namen = sorted({g.wirkstoff for g in gruppen if g.wirkstoff}, key=len, reverse=True)
|
| 264 |
+
for name in namen:
|
| 265 |
+
if re.search(rf"(?<![\wäöüß]){re.escape(_norm(name))}(?![\wäöüß])", haystack):
|
| 266 |
+
return name
|
| 267 |
+
return None
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def fragt_nach_biosimilar(question: str) -> bool:
|
| 271 |
+
haystack = _norm(question)
|
| 272 |
+
return fragt_nach_austausch(question) or any(s in haystack for s in BIOSIMILAR_SIGNALE)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# ---------------------------------------------------------------------------
|
| 276 |
+
# Prüfung
|
| 277 |
+
# ---------------------------------------------------------------------------
|
| 278 |
+
|
| 279 |
+
def pruefe(
|
| 280 |
+
question: str = "",
|
| 281 |
+
*,
|
| 282 |
+
path: Path,
|
| 283 |
+
produkte: Optional[Sequence[str]] = None,
|
| 284 |
+
heute: Optional[date] = None,
|
| 285 |
+
) -> Befund:
|
| 286 |
+
daten = load_liste(path)
|
| 287 |
+
gruppen_alle = _gruppen(daten)
|
| 288 |
+
if not gruppen_alle:
|
| 289 |
+
return Befund(status="nicht_pruefbar", grund="snapshot_fehlt_oder_leer")
|
| 290 |
+
|
| 291 |
+
quelle = daten.get("quelle") or {}
|
| 292 |
+
stand = str(quelle.get("stand") or "")
|
| 293 |
+
fundstelle = str(quelle.get("dokument") or "AM-RL Anlage VIIa")
|
| 294 |
+
url = str(quelle.get("url") or "")
|
| 295 |
+
|
| 296 |
+
if not stand:
|
| 297 |
+
return Befund(status="nicht_pruefbar", grund="stand_unbekannt", fundstelle=fundstelle, url=url)
|
| 298 |
+
|
| 299 |
+
stand_iso = _stand_iso(stand)
|
| 300 |
+
alter_tage: Optional[int] = None
|
| 301 |
+
veraltet = False
|
| 302 |
+
if stand_iso:
|
| 303 |
+
try:
|
| 304 |
+
alter_tage = max(0, ((heute or date.today()) - datetime.strptime(stand_iso, "%Y-%m-%d").date()).days)
|
| 305 |
+
veraltet = alter_tage > VERALTET_AB_TAGEN
|
| 306 |
+
except ValueError:
|
| 307 |
+
alter_tage = None
|
| 308 |
+
|
| 309 |
+
def _fertig(befund: Befund) -> Befund:
|
| 310 |
+
befund.stand = stand
|
| 311 |
+
befund.stand_iso = stand_iso
|
| 312 |
+
befund.alter_tage = alter_tage
|
| 313 |
+
befund.veraltet = veraltet
|
| 314 |
+
befund.fundstelle = fundstelle
|
| 315 |
+
befund.url = url
|
| 316 |
+
befund.fussnoten = dict(daten.get("fussnoten") or {})
|
| 317 |
+
return befund
|
| 318 |
+
|
| 319 |
+
genannt = list(produkte) if produkte is not None else erkannte_produkte(question, gruppen_alle)
|
| 320 |
+
|
| 321 |
+
# Ein einzelner Handelsname ist noch keine Austauschfrage. „Wie rechne ich
|
| 322 |
+
# Humira ab?" nennt ein gelistetes Arzneimittel, will aber keine
|
| 323 |
+
# Biosimilar-Liste vorangestellt bekommen. Zwei genannte Arzneimittel sind
|
| 324 |
+
# dagegen selbst die Frage, gleich wie sie formuliert ist.
|
| 325 |
+
if len(genannt) < 2 and not fragt_nach_biosimilar(question):
|
| 326 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="keine_austauschfrage"))
|
| 327 |
+
|
| 328 |
+
if not genannt:
|
| 329 |
+
wirkstoff = erkannter_wirkstoff(question, gruppen_alle)
|
| 330 |
+
if not wirkstoff:
|
| 331 |
+
# Kein Nichtvorkommen melden: die Anlage ist nach ihrer eigenen
|
| 332 |
+
# Vorbemerkung nicht abschließend, aus einer Lücke folgt nichts.
|
| 333 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="kein_gelistetes_arzneimittel"))
|
| 334 |
+
passend = [g for g in gruppen_alle if g.wirkstoff == wirkstoff]
|
| 335 |
+
if not any(g.biosimilars for g in passend):
|
| 336 |
+
# Insulin human steht mit sechs Originalarzneimitteln und ohne ein
|
| 337 |
+
# einziges Biosimilar in der Anlage. Das als „Übersicht" auszugeben
|
| 338 |
+
# zählte sechs leere Zeilen auf, statt die Auskunft zu geben.
|
| 339 |
+
return _fertig(
|
| 340 |
+
Befund(status="keine_biosimilars", wirkstoff=wirkstoff, gruppen=passend)
|
| 341 |
+
)
|
| 342 |
+
return _fertig(Befund(status="uebersicht", wirkstoff=wirkstoff, gruppen=passend))
|
| 343 |
+
|
| 344 |
+
if len(genannt) == 1:
|
| 345 |
+
name = genannt[0]
|
| 346 |
+
passend = [g for g in gruppen_alle if g.enthaelt(name)]
|
| 347 |
+
mit_biosimilar = [g for g in passend if g.biosimilars]
|
| 348 |
+
if not mit_biosimilar:
|
| 349 |
+
return _fertig(
|
| 350 |
+
Befund(
|
| 351 |
+
status="keine_biosimilars",
|
| 352 |
+
produkt_a=name,
|
| 353 |
+
wirkstoff=passend[0].wirkstoff,
|
| 354 |
+
gruppen=passend,
|
| 355 |
+
treffer_gruppe=passend[0],
|
| 356 |
+
)
|
| 357 |
+
)
|
| 358 |
+
return _fertig(
|
| 359 |
+
Befund(
|
| 360 |
+
status="uebersicht",
|
| 361 |
+
produkt_a=name,
|
| 362 |
+
wirkstoff=passend[0].wirkstoff,
|
| 363 |
+
gruppen=passend,
|
| 364 |
+
treffer_gruppe=mit_biosimilar[0],
|
| 365 |
+
)
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
name_a, name_b = genannt[0], genannt[1]
|
| 369 |
+
gruppen_a = [g for g in gruppen_alle if g.enthaelt(name_a)]
|
| 370 |
+
gruppen_b = [g for g in gruppen_alle if g.enthaelt(name_b)]
|
| 371 |
+
gemeinsam = [g for g in gruppen_a if g in gruppen_b]
|
| 372 |
+
|
| 373 |
+
if gemeinsam:
|
| 374 |
+
gruppe = gemeinsam[0]
|
| 375 |
+
rollen = {gruppe.rolle(name_a), gruppe.rolle(name_b)}
|
| 376 |
+
if rollen == {"referenz"}:
|
| 377 |
+
bezug = "referenzen_untereinander"
|
| 378 |
+
elif rollen == {"biosimilar"}:
|
| 379 |
+
bezug = "biosimilars_untereinander"
|
| 380 |
+
else:
|
| 381 |
+
bezug = "referenz_biosimilar"
|
| 382 |
+
return _fertig(
|
| 383 |
+
Befund(
|
| 384 |
+
status="austausch_gelistet",
|
| 385 |
+
produkt_a=name_a,
|
| 386 |
+
produkt_b=name_b,
|
| 387 |
+
wirkstoff=gruppe.wirkstoff,
|
| 388 |
+
bezug=bezug,
|
| 389 |
+
gruppen=gemeinsam,
|
| 390 |
+
treffer_gruppe=gruppe,
|
| 391 |
+
)
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
wirkstoffe = {g.wirkstoff for g in gruppen_a} | {g.wirkstoff for g in gruppen_b}
|
| 395 |
+
status = "andere_referenz" if len(wirkstoffe) == 1 else "anderer_wirkstoff"
|
| 396 |
+
return _fertig(
|
| 397 |
+
Befund(
|
| 398 |
+
status=status,
|
| 399 |
+
produkt_a=name_a,
|
| 400 |
+
produkt_b=name_b,
|
| 401 |
+
wirkstoff=next(iter(wirkstoffe)) if len(wirkstoffe) == 1 else None,
|
| 402 |
+
gruppen=gruppen_a[:1] + gruppen_b[:1],
|
| 403 |
+
)
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# ---------------------------------------------------------------------------
|
| 408 |
+
# Darstellung
|
| 409 |
+
# ---------------------------------------------------------------------------
|
| 410 |
+
|
| 411 |
+
_EINLEITUNG = "Biosimilar-Zuordnung — deterministische Listenprüfung"
|
| 412 |
+
|
| 413 |
+
# Was aus der Anlage gerade nicht folgt. Beide Sätze stehen in ihrer
|
| 414 |
+
# Vorbemerkung; ohne sie liest sich ein „steht in derselben Zeile" als
|
| 415 |
+
# Ersetzungsfreigabe, die die Anlage nicht erteilt.
|
| 416 |
+
_VORBEHALT = (
|
| 417 |
+
"Die Anlage ordnet nur zu. Ob die Anwendungsgebiete übereinstimmen, ist ihr "
|
| 418 |
+
"ausdrücklich nicht zu entnehmen (§ 40b Absatz 1 Satz 2, § 40c Absatz 1 Satz 4); "
|
| 419 |
+
"nach § 40c Absatz 1 Satz 2 und 3 müssen außerdem Wirkstärke und Packungsgröße "
|
| 420 |
+
"identisch sein und bei gleicher gemeldeter Darreichungsform das in der "
|
| 421 |
+
"Fachinformation angegebene Behältnis übereinstimmen. Das ist am Fall zu prüfen."
|
| 422 |
+
)
|
| 423 |
+
_NICHT_ABSCHLIESSEND = (
|
| 424 |
+
"Die Übersicht hat nach ihrer Vorbemerkung keinen abschließenden Charakter; "
|
| 425 |
+
"aus einem Nichtvorkommen folgt deshalb nichts."
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _gruppe_zeile(gruppe: Gruppe) -> str:
|
| 430 |
+
biosimilars = ", ".join(a.beschriftung() for a in gruppe.biosimilars) or "kein Biosimilar gelistet"
|
| 431 |
+
return f" · {gruppe.label()}: {biosimilars}"
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def _fussnoten_zeilen(befund: Befund) -> List[str]:
|
| 435 |
+
"""Fußnoten der beteiligten Arzneimittel — sie tragen den Zulassungsweg.
|
| 436 |
+
|
| 437 |
+
Mixtard, Protaphane und Liprolog sind nicht nach Artikel 10 Absatz 4
|
| 438 |
+
zugelassen, sondern nach Artikel 10c unter Verwendung fremder Unterlagen.
|
| 439 |
+
Wer das für ein Biosimilar hält, verwechselt zwei Zulassungswege.
|
| 440 |
+
"""
|
| 441 |
+
zeilen: List[str] = []
|
| 442 |
+
gesehen: set = set()
|
| 443 |
+
for gruppe in befund.gruppen:
|
| 444 |
+
for mittel in gruppe.referenzen + gruppe.biosimilars:
|
| 445 |
+
if not mittel.fussnote or mittel.fussnote in gesehen:
|
| 446 |
+
continue
|
| 447 |
+
text = befund.fussnoten.get(mittel.fussnote)
|
| 448 |
+
if text and text != "(nicht besetzt)":
|
| 449 |
+
gesehen.add(mittel.fussnote)
|
| 450 |
+
zeilen.append(f"Zu {mittel.name}: {text}.")
|
| 451 |
+
return zeilen
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def befund_block(befund: Befund) -> str:
|
| 455 |
+
if not befund.ist_belastbar:
|
| 456 |
+
return ""
|
| 457 |
+
|
| 458 |
+
zeilen: List[str] = [f"{_EINLEITUNG} ({befund.fundstelle}, Stand {befund.stand}):"]
|
| 459 |
+
|
| 460 |
+
if befund.status == "austausch_gelistet":
|
| 461 |
+
gruppe = befund.treffer_gruppe
|
| 462 |
+
if befund.bezug == "referenz_biosimilar":
|
| 463 |
+
zeilen.append(
|
| 464 |
+
f"{befund.produkt_a} und {befund.produkt_b} stehen bei {befund.wirkstoff} in "
|
| 465 |
+
"derselben Zeile der Anlage VIIa — als Referenzarzneimittel und als dazu "
|
| 466 |
+
"zugelassenes Biosimilar. Damit ist die Konstellation des § 40c Absatz 3 "
|
| 467 |
+
"erster Spiegelstrich (bei parenteralen Zubereitungen § 40b Absatz 3) erfüllt."
|
| 468 |
+
)
|
| 469 |
+
elif befund.bezug == "biosimilars_untereinander":
|
| 470 |
+
zeilen.append(
|
| 471 |
+
f"{befund.produkt_a} und {befund.produkt_b} sind bei {befund.wirkstoff} beide "
|
| 472 |
+
"als Biosimilar zu demselben Referenzarzneimittel gelistet. Das ist die "
|
| 473 |
+
"Konstellation des § 40c Absatz 3 zweiter Spiegelstrich."
|
| 474 |
+
)
|
| 475 |
+
else:
|
| 476 |
+
zeilen.append(
|
| 477 |
+
f"{befund.produkt_a} und {befund.produkt_b} stehen bei {befund.wirkstoff} in "
|
| 478 |
+
"derselben Zeile, beide als Original-/Referenzarzneimittel. § 40c Absatz 3 "
|
| 479 |
+
"benennt für die Ersetzung das Verhältnis von Referenzarzneimittel und "
|
| 480 |
+
"Biosimilar; für zwei Referenzarzneimittel untereinander trifft die Anlage "
|
| 481 |
+
"diese Aussage nicht."
|
| 482 |
+
)
|
| 483 |
+
if gruppe:
|
| 484 |
+
zeilen.append(_gruppe_zeile(gruppe))
|
| 485 |
+
zeilen += _fussnoten_zeilen(befund)
|
| 486 |
+
zeilen.append(_VORBEHALT)
|
| 487 |
+
|
| 488 |
+
elif befund.status == "andere_referenz":
|
| 489 |
+
zeilen.append(
|
| 490 |
+
f"{befund.produkt_a} und {befund.produkt_b} stehen bei {befund.wirkstoff} zwar beide "
|
| 491 |
+
"in der Anlage VIIa, aber in verschiedenen Zeilen — sie sind verschiedenen "
|
| 492 |
+
"Referenzarzneimitteln zugeordnet. § 40c Absatz 3 lässt die Ersetzung nur "
|
| 493 |
+
"zwischen einem Referenzarzneimittel und seinen Biosimilars sowie zwischen "
|
| 494 |
+
"Biosimilars desselben Referenzarzneimittels zu."
|
| 495 |
+
)
|
| 496 |
+
for gruppe in befund.gruppen:
|
| 497 |
+
zeilen.append(_gruppe_zeile(gruppe))
|
| 498 |
+
|
| 499 |
+
elif befund.status == "anderer_wirkstoff":
|
| 500 |
+
zeilen.append(
|
| 501 |
+
f"{befund.produkt_a} und {befund.produkt_b} sind in der Anlage VIIa "
|
| 502 |
+
"verschiedenen Wirkstoffen zugeordnet:"
|
| 503 |
+
)
|
| 504 |
+
for gruppe in befund.gruppen:
|
| 505 |
+
zeilen.append(f" · {gruppe.wirkstoff}: {gruppe.label()}")
|
| 506 |
+
|
| 507 |
+
elif befund.status == "keine_biosimilars":
|
| 508 |
+
if befund.produkt_a:
|
| 509 |
+
zeilen.append(
|
| 510 |
+
f"{befund.produkt_a} führt die Anlage VIIa bei {befund.wirkstoff}, in seiner "
|
| 511 |
+
"Zeile steht aber kein Biosimilar. Eine Ersetzung nach § 40c Absatz 3 kommt "
|
| 512 |
+
"danach nicht in Betracht."
|
| 513 |
+
)
|
| 514 |
+
else:
|
| 515 |
+
zeilen.append(
|
| 516 |
+
f"Die Anlage VIIa führt {befund.wirkstoff}, aber zu keinem der dort genannten "
|
| 517 |
+
"Arzneimittel ein Biosimilar. Eine Ersetzung nach § 40c Absatz 3 kommt danach "
|
| 518 |
+
"nicht in Betracht."
|
| 519 |
+
)
|
| 520 |
+
for gruppe in befund.gruppen:
|
| 521 |
+
zeilen.append(_gruppe_zeile(gruppe))
|
| 522 |
+
zeilen.append(_NICHT_ABSCHLIESSEND)
|
| 523 |
+
|
| 524 |
+
else: # uebersicht
|
| 525 |
+
anzahl = len(befund.gruppen)
|
| 526 |
+
zeilen_wort = "Zeile" if anzahl == 1 else "Zeilen"
|
| 527 |
+
if befund.produkt_a:
|
| 528 |
+
zeilen.append(
|
| 529 |
+
f"Die Anlage VIIa führt {befund.produkt_a} bei {befund.wirkstoff} in {anzahl} "
|
| 530 |
+
f"{zeilen_wort}. Zugeordnet ist immer nur innerhalb einer Zeile:"
|
| 531 |
+
)
|
| 532 |
+
else:
|
| 533 |
+
zeilen.append(
|
| 534 |
+
f"Die Anlage VIIa führt {befund.wirkstoff} in {anzahl} {zeilen_wort}. "
|
| 535 |
+
"Zugeordnet ist immer nur innerhalb einer Zeile:"
|
| 536 |
+
)
|
| 537 |
+
for gruppe in befund.gruppen:
|
| 538 |
+
zeilen.append(_gruppe_zeile(gruppe))
|
| 539 |
+
zeilen += _fussnoten_zeilen(befund)
|
| 540 |
+
zeilen.append(_VORBEHALT)
|
| 541 |
+
|
| 542 |
+
if befund.veraltet:
|
| 543 |
+
zeilen.append(
|
| 544 |
+
f"Achtung Aktualität: Dieser Auszug ist {befund.alter_tage} Tage alt. Vor einer "
|
| 545 |
+
"Entscheidung ist die geltende Fassung abzugleichen."
|
| 546 |
+
)
|
| 547 |
+
if befund.url:
|
| 548 |
+
zeilen.append(f"Quelle: {befund.url}")
|
| 549 |
+
|
| 550 |
+
return "\n".join(zeilen)
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
def verdikt(befund: Befund) -> str:
|
| 554 |
+
"""Die Antwort in einem Satz — nur wenn die Frage entschieden werden konnte."""
|
| 555 |
+
if befund.status == "austausch_gelistet":
|
| 556 |
+
if befund.bezug == "referenzen_untereinander":
|
| 557 |
+
return (
|
| 558 |
+
f"{befund.produkt_a} und {befund.produkt_b} stehen in der AM-RL-Anlage VIIa in "
|
| 559 |
+
f"derselben Zeile, beide als Referenzarzneimittel; ein Biosimilar-Verhältnis im "
|
| 560 |
+
f"Sinne des § 40c Absatz 3 stellt die Anlage zwischen ihnen nicht fest "
|
| 561 |
+
f"(Listenprüfung oben, Stand {befund.stand})."
|
| 562 |
+
)
|
| 563 |
+
return (
|
| 564 |
+
f"{befund.produkt_a} und {befund.produkt_b} sind in der AM-RL-Anlage VIIa "
|
| 565 |
+
f"einander zugeordnet; die Ersetzung nach § 40c Absatz 3 kommt damit in Betracht, "
|
| 566 |
+
f"sofern Anwendungsgebiet, Wirkstärke, Packungsgröße und Behältnis übereinstimmen "
|
| 567 |
+
f"(Listenprüfung oben, Stand {befund.stand})."
|
| 568 |
+
)
|
| 569 |
+
if befund.status == "andere_referenz":
|
| 570 |
+
return (
|
| 571 |
+
f"Nein. {befund.produkt_a} und {befund.produkt_b} sind in der AM-RL-Anlage VIIa "
|
| 572 |
+
f"verschiedenen Referenzarzneimitteln zugeordnet; § 40c Absatz 3 deckt die "
|
| 573 |
+
f"Ersetzung zwischen ihnen nicht (Listenprüfung oben, Stand {befund.stand})."
|
| 574 |
+
)
|
| 575 |
+
if befund.status == "anderer_wirkstoff":
|
| 576 |
+
return (
|
| 577 |
+
f"Nein. {befund.produkt_a} und {befund.produkt_b} sind in der AM-RL-Anlage VIIa "
|
| 578 |
+
f"verschiedenen Wirkstoffen zugeordnet (Listenprüfung oben, Stand {befund.stand})."
|
| 579 |
+
)
|
| 580 |
+
if befund.status == "keine_biosimilars":
|
| 581 |
+
return (
|
| 582 |
+
f"Zu {befund.produkt_a or befund.wirkstoff} führt die AM-RL-Anlage VIIa kein "
|
| 583 |
+
f"Biosimilar; eine Ersetzung nach § 40c Absatz 3 kommt danach nicht in Betracht "
|
| 584 |
+
f"(Listenprüfung oben, Stand {befund.stand})."
|
| 585 |
+
)
|
| 586 |
+
# Die Übersicht beantwortet keine Ja/Nein-Frage und ersetzt deshalb keine
|
| 587 |
+
# Kurzantwort — der Block darüber zeigt die Zeilen.
|
| 588 |
+
return ""
|
src/amrl_lifestyle.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lifestyle-Arzneimittel nachschlagen (AM-RL Anlage II).
|
| 2 |
+
|
| 3 |
+
Arzneimittel, bei deren Anwendung eine Erhöhung der Lebensqualität im
|
| 4 |
+
Vordergrund steht, sind nach § 34 Absatz 1 Satz 7 SGB V von der Versorgung
|
| 5 |
+
ausgeschlossen. Welche das sind, führt Anlage II in acht Indikationsgebieten
|
| 6 |
+
auf — Abmagerungsmittel, sexuelle Dysfunktion, Nikotinabhängigkeit, Steigerung
|
| 7 |
+
des sexuellen Verlangens, Haarwuchs, Aussehen und der Schlaf-Wach-Rhythmus. In
|
| 8 |
+
der Offizin sind das die Klassiker des grünen Rezepts: Viagra, Xenical,
|
| 9 |
+
Propecia, Champix — und seit 2023 die GLP-1-Analoga.
|
| 10 |
+
|
| 11 |
+
**Dieser Ausschluss ist härter als der der Anlage III, und das ist die
|
| 12 |
+
wichtigste Auskunft dieses Moduls.** § 16 Absatz 5 AM-RL öffnet den medizinisch
|
| 13 |
+
begründeten Einzelfall ausdrücklich nur für die nach den Absätzen 1 und 2
|
| 14 |
+
eingeschränkten und ausgeschlossenen Arzneimittel — also für die
|
| 15 |
+
Verordnungsausschlüsse *dieser Richtlinie* nach § 92 Absatz 1 Satz 1 Halbsatz 3
|
| 16 |
+
SGB V. Anlage II ruht auf § 34 Absatz 1 Satz 7 SGB V und § 14 AM-RL, nicht auf
|
| 17 |
+
§ 16. Der Weg über den Einzelfall führt an ihr vorbei; in der Systematik der
|
| 18 |
+
Anlage III ist das die Lage der Marker [1] und [2]. Ein Befund, der ihn hier
|
| 19 |
+
anböte, wäre in der Sache falsch.
|
| 20 |
+
|
| 21 |
+
**Der Ausschluss hängt am ATC-Code und an der Anwendung, nicht am
|
| 22 |
+
Wirkstoffnamen.** Das ist die zweite Auskunft, ohne die die erste falsch wird:
|
| 23 |
+
|
| 24 |
+
A 08 AX 02 Liraglutid -> ausgeschlossen (Saxenda)
|
| 25 |
+
A 10 BJ 02 (gilt nur bei der Anwendung -> nur dann (Victoza, Diabetes)
|
| 26 |
+
zur Gewichtsreduktion)
|
| 27 |
+
|
| 28 |
+
Derselbe Wirkstoff, zwei Codes, zwei Rechtsfolgen. Bei Semaglutid trägt die
|
| 29 |
+
Maßgabe beide Codes — Wegovy ist ausgeschlossen, Ozempic in der Diabetestherapie
|
| 30 |
+
nicht. Bei Bupropion trennt sie Zyban von der Anwendung als Antidepressivum,
|
| 31 |
+
bei Betamethasonacetat das Anwendungsgebiet Alopecia areata von jedem anderen.
|
| 32 |
+
Der Befund meldet deshalb nie den bloßen Wirkstoff, sondern immer den Code mit
|
| 33 |
+
seiner Maßgabe, und subsumiert nicht: ob die Anwendung im Fall vorliegt, ist
|
| 34 |
+
eine ärztliche Feststellung und keine Listenauskunft.
|
| 35 |
+
|
| 36 |
+
**Die Negativauskunft trägt hier nicht.** § 14 Absatz 2 AM-RL schließt
|
| 37 |
+
Lifestyle-Arzneimittel *insbesondere* aus, Absatz 3 nennt Anlage II eine
|
| 38 |
+
Übersicht; das Wort abschließend, auf dem die Negativauskunft der Anlage I ruht
|
| 39 |
+
(§ 12 Absatz 10 AM-RL), steht hier nirgends. Fußnote 2 der Anlage sagt es sogar
|
| 40 |
+
ausdrücklich in die andere Richtung: Arzneimittel mit *abweichenden* ATC-Codes
|
| 41 |
+
desselben Wirkprinzips (4. Ebene) sind bei entsprechender Verwendung ebenfalls
|
| 42 |
+
ausgeschlossen. Aus einem Nichtvorkommen folgt deshalb keine
|
| 43 |
+
Verordnungsfähigkeit — dieselbe Zurückhaltung wie bei Anlage III und VIIa.
|
| 44 |
+
|
| 45 |
+
**Ein Weg führt an der Liste vorbei, und nur einer.** Fußnote 1 hängt an der
|
| 46 |
+
Überschrift zur Nikotinabhängigkeit: Versicherte mit festgestellter schwerer
|
| 47 |
+
Tabakabhängigkeit haben nach § 34 Absatz 2 SGB V, § 14a AM-RL Anspruch auf eine
|
| 48 |
+
einmalige Versorgung mit Arzneimitteln zur Tabakentwöhnung im Rahmen
|
| 49 |
+
evidenzbasierter Programme. Die dafür verordnungsfähigen Arzneimittel stehen in
|
| 50 |
+
Anlage IIa, und die ist inzwischen ebenfalls nachschlagbar
|
| 51 |
+
(`amrl_tabakentwoehnung`). Dieses Modul löst den Wirkstoff auf — „Champix" steht
|
| 52 |
+
in *seiner* Fertigarzneimittelspalte, nicht in Anlage IIa — und der Aufrufer
|
| 53 |
+
reicht ihn dorthin weiter, sobald der Treffer die Nikotinabhängigkeit betrifft.
|
| 54 |
+
|
| 55 |
+
Die Zustände:
|
| 56 |
+
|
| 57 |
+
ausgeschlossen Wirkstoff steht in Anlage II, ohne Maßgabe
|
| 58 |
+
ausgeschlossen_nach_anwendung
|
| 59 |
+
er steht dort, aber der Ausschluss ist an eine
|
| 60 |
+
Anwendung gebunden oder von einer ausgenommen
|
| 61 |
+
nicht_gelistet Anlage II führt ihn nicht — was für sich noch
|
| 62 |
+
keine Verordnungsfähigkeit bedeutet
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
from __future__ import annotations
|
| 66 |
+
|
| 67 |
+
import json
|
| 68 |
+
import logging
|
| 69 |
+
import re
|
| 70 |
+
from dataclasses import dataclass, field
|
| 71 |
+
from datetime import date, datetime
|
| 72 |
+
from pathlib import Path
|
| 73 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 74 |
+
|
| 75 |
+
# Geteilt statt ein sechstes Mal geschrieben: dieselbe Standangabe, dieselbe
|
| 76 |
+
# Frist, dieselbe Endungsheuristik für einen Wirkstoffnamen.
|
| 77 |
+
from amrl_substitution import VERALTET_AB_TAGEN, _stand_iso, wirkstoff_kandidat
|
| 78 |
+
|
| 79 |
+
logger = logging.getLogger(__name__)
|
| 80 |
+
|
| 81 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 82 |
+
|
| 83 |
+
# Die Frage muss von der Verordnungsfähigkeit handeln. Regelbasiert wie in den
|
| 84 |
+
# übrigen Listenmodulen: eine Substring-Prüfung entscheidet das exakt.
|
| 85 |
+
#
|
| 86 |
+
# Die Wörter Lifestyle und Lebensqualität stehen mit dabei, weil sie hier die
|
| 87 |
+
# Sache selbst benennen — die Anlage heißt so.
|
| 88 |
+
VERORDNUNGS_SIGNALE: Tuple[str, ...] = (
|
| 89 |
+
"verordnungsfähig",
|
| 90 |
+
"verordnungsfaehig",
|
| 91 |
+
"verordnungsausschl",
|
| 92 |
+
"erstattungsfähig",
|
| 93 |
+
"erstattungsfaehig",
|
| 94 |
+
"erstattung",
|
| 95 |
+
"ausgeschlossen",
|
| 96 |
+
"zu lasten der",
|
| 97 |
+
"kassenrezept",
|
| 98 |
+
"auf kasse",
|
| 99 |
+
"grüne rezept",
|
| 100 |
+
"grünes rezept",
|
| 101 |
+
"privatrezept",
|
| 102 |
+
"selbstzahler",
|
| 103 |
+
"darf ich verordnen",
|
| 104 |
+
"verordnet werden",
|
| 105 |
+
"lifestyle",
|
| 106 |
+
"lebensqualität",
|
| 107 |
+
"lebensqualitaet",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Die Anlage beim Namen genannt. Der Ausdruck darf dabei weder Anlage III noch
|
| 111 |
+
# Anlage IIa einfangen: die eine regelt Verordnungseinschränkungen, die andere
|
| 112 |
+
# gerade den *Anspruch* auf Arzneimittel zur Tabakentwöhnung.
|
| 113 |
+
RE_ANLAGE_II = re.compile(r"\banlage\s+ii\b(?![ia])")
|
| 114 |
+
|
| 115 |
+
# Ein ATC-Code in der Frage, mit oder ohne Leerzeichen: A10BJ06, A 10 BJ 06.
|
| 116 |
+
RE_ATC_FRAGE = re.compile(r"\b([A-Za-z])\s*(\d{2})\s*([A-Za-z]{2})\s*(\d{2})\b")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@dataclass(frozen=True)
|
| 120 |
+
class Code:
|
| 121 |
+
atc: str
|
| 122 |
+
massgaben: Tuple[Dict[str, str], ...]
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def unbedingt(self) -> bool:
|
| 126 |
+
return not self.massgaben
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@dataclass(frozen=True)
|
| 130 |
+
class Eintrag:
|
| 131 |
+
lfd: int
|
| 132 |
+
indikationsgebiet: str
|
| 133 |
+
wirkstoff: str
|
| 134 |
+
zelle: str
|
| 135 |
+
codes: Tuple[Code, ...]
|
| 136 |
+
fertigarzneimittel: Tuple[str, ...]
|
| 137 |
+
generikaklauseln: Tuple[str, ...]
|
| 138 |
+
fussnote: Optional[str]
|
| 139 |
+
begriffe: Tuple[str, ...]
|
| 140 |
+
varianten: Tuple[str, ...]
|
| 141 |
+
seite: Optional[int]
|
| 142 |
+
|
| 143 |
+
@property
|
| 144 |
+
def massgaben(self) -> Tuple[Dict[str, str], ...]:
|
| 145 |
+
return tuple(m for c in self.codes for m in c.massgaben)
|
| 146 |
+
|
| 147 |
+
@property
|
| 148 |
+
def nach_anwendung(self) -> bool:
|
| 149 |
+
return bool(self.massgaben)
|
| 150 |
+
|
| 151 |
+
@property
|
| 152 |
+
def codes_verschieden_geregelt(self) -> bool:
|
| 153 |
+
"""Tragen die Codes dieses Eintrags *verschiedene* Rechtsfolgen?
|
| 154 |
+
|
| 155 |
+
Bei Liraglutid ist genau das die Auskunft: unter A08AX02 gilt der
|
| 156 |
+
Ausschluss unbedingt, unter A10BJ02 nur bei der Anwendung zur
|
| 157 |
+
Gewichtsreduktion.
|
| 158 |
+
"""
|
| 159 |
+
return (
|
| 160 |
+
len(self.codes) > 1
|
| 161 |
+
and any(c.unbedingt for c in self.codes)
|
| 162 |
+
and any(not c.unbedingt for c in self.codes)
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@dataclass
|
| 167 |
+
class Befund:
|
| 168 |
+
status: str
|
| 169 |
+
gefragter_stoff: Optional[str] = None
|
| 170 |
+
treffer: List[Eintrag] = field(default_factory=list)
|
| 171 |
+
# Womit der Treffer gefunden wurde: wirkstoff | produkt | atc | gebiet.
|
| 172 |
+
ueber: str = ""
|
| 173 |
+
stand: str = ""
|
| 174 |
+
stand_iso: str = ""
|
| 175 |
+
veraltet: bool = False
|
| 176 |
+
alter_tage: Optional[int] = None
|
| 177 |
+
fundstelle: str = ""
|
| 178 |
+
url: str = ""
|
| 179 |
+
grund: str = ""
|
| 180 |
+
fussnoten: Dict[str, str] = field(default_factory=dict)
|
| 181 |
+
|
| 182 |
+
@property
|
| 183 |
+
def ist_belastbar(self) -> bool:
|
| 184 |
+
return self.status in {
|
| 185 |
+
"ausgeschlossen",
|
| 186 |
+
"ausgeschlossen_nach_anwendung",
|
| 187 |
+
"nicht_gelistet",
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
@property
|
| 191 |
+
def tabakentwoehnung(self) -> bool:
|
| 192 |
+
"""Betrifft der Treffer das Gebiet mit dem einen Ausnahmeweg?"""
|
| 193 |
+
return any(
|
| 194 |
+
e.fussnote == "1" or e.indikationsgebiet == "Nikotinabhängigkeit"
|
| 195 |
+
for e in self.treffer
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 199 |
+
return {
|
| 200 |
+
"status": self.status,
|
| 201 |
+
"gefragter_stoff": self.gefragter_stoff,
|
| 202 |
+
"gefunden_ueber": self.ueber,
|
| 203 |
+
"treffer": [
|
| 204 |
+
{
|
| 205 |
+
"wirkstoff": e.wirkstoff,
|
| 206 |
+
"indikationsgebiet": e.indikationsgebiet,
|
| 207 |
+
"atc": [c.atc for c in e.codes],
|
| 208 |
+
"nach_anwendung": e.nach_anwendung,
|
| 209 |
+
"seite": e.seite,
|
| 210 |
+
}
|
| 211 |
+
for e in self.treffer
|
| 212 |
+
],
|
| 213 |
+
"stand": self.stand,
|
| 214 |
+
"veraltet": self.veraltet,
|
| 215 |
+
"alter_tage": self.alter_tage,
|
| 216 |
+
"fundstelle": self.fundstelle,
|
| 217 |
+
"url": self.url,
|
| 218 |
+
"grund": self.grund,
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
# Daten
|
| 224 |
+
# ---------------------------------------------------------------------------
|
| 225 |
+
|
| 226 |
+
def load_liste(path: Path) -> Dict[str, Any]:
|
| 227 |
+
key = str(path)
|
| 228 |
+
if key in _CACHE:
|
| 229 |
+
return _CACHE[key]
|
| 230 |
+
|
| 231 |
+
data: Dict[str, Any] = {}
|
| 232 |
+
try:
|
| 233 |
+
if path.exists():
|
| 234 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 235 |
+
except Exception as exc: # noqa: BLE001 - ein defekter Snapshot darf keine Antwort verhindern.
|
| 236 |
+
logger.warning("Anlage-II-Liste nicht lesbar: %s (%s)", path, exc)
|
| 237 |
+
data = {}
|
| 238 |
+
|
| 239 |
+
_CACHE[key] = data
|
| 240 |
+
return data
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _eintraege(daten: Dict[str, Any]) -> List[Eintrag]:
|
| 244 |
+
out: List[Eintrag] = []
|
| 245 |
+
for roh in daten.get("eintraege") or []:
|
| 246 |
+
out.append(
|
| 247 |
+
Eintrag(
|
| 248 |
+
lfd=int(roh.get("lfd") or 0),
|
| 249 |
+
indikationsgebiet=str(roh.get("indikationsgebiet") or ""),
|
| 250 |
+
wirkstoff=str(roh.get("wirkstoff") or ""),
|
| 251 |
+
zelle=str(roh.get("zelle") or ""),
|
| 252 |
+
codes=tuple(
|
| 253 |
+
Code(
|
| 254 |
+
atc=str(c.get("atc") or ""),
|
| 255 |
+
massgaben=tuple(c.get("massgaben") or []),
|
| 256 |
+
)
|
| 257 |
+
for c in roh.get("codes") or []
|
| 258 |
+
),
|
| 259 |
+
fertigarzneimittel=tuple(str(f) for f in roh.get("fertigarzneimittel") or []),
|
| 260 |
+
generikaklauseln=tuple(str(k) for k in roh.get("generikaklauseln") or []),
|
| 261 |
+
fussnote=roh.get("fussnote"),
|
| 262 |
+
begriffe=tuple(str(b) for b in roh.get("begriffe") or []),
|
| 263 |
+
varianten=tuple(str(v) for v in roh.get("varianten") or []),
|
| 264 |
+
seite=roh.get("seite"),
|
| 265 |
+
)
|
| 266 |
+
)
|
| 267 |
+
return out
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# ---------------------------------------------------------------------------
|
| 271 |
+
# Erkennung in der Frage
|
| 272 |
+
# ---------------------------------------------------------------------------
|
| 273 |
+
|
| 274 |
+
def _norm(text: str) -> str:
|
| 275 |
+
return " ".join(str(text or "").lower().split())
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _atc_norm(text: str) -> str:
|
| 279 |
+
return re.sub(r"\s+", "", str(text or "")).upper()
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _wortgrenze(begriff: str, haystack: str) -> bool:
|
| 283 |
+
muster = rf"(?<![\wäöüß]){re.escape(_norm(begriff))}(?![\wäöüß])"
|
| 284 |
+
return re.search(muster, haystack) is not None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def begriffsindex(eintraege: Sequence[Eintrag]) -> List[Tuple[str, str, Eintrag]]:
|
| 288 |
+
"""(Suchbegriff, Art, Eintrag), längste zuerst.
|
| 289 |
+
|
| 290 |
+
Die Reihenfolge trägt: der Eintrag „Turnera diffusa Kombinationen“ darf
|
| 291 |
+
nicht vom kürzeren „Turnera diffusa“ verdeckt werden und „Nicotinell“ nicht
|
| 292 |
+
von „Nicotin“.
|
| 293 |
+
"""
|
| 294 |
+
paare: List[Tuple[str, str, Eintrag]] = []
|
| 295 |
+
for eintrag in eintraege:
|
| 296 |
+
for begriff in (*eintrag.begriffe, *eintrag.varianten):
|
| 297 |
+
if begriff:
|
| 298 |
+
paare.append((begriff, "wirkstoff", eintrag))
|
| 299 |
+
# Die Fertigarzneimittel stehen in der Anlage selbst — sie zu erkennen
|
| 300 |
+
# ist Listenauskunft und keine pharmakologische Zuordnung. Die
|
| 301 |
+
# Generikaklauseln dagegen bezeichnen eine offene Klasse und taugen
|
| 302 |
+
# nicht als Suchbegriff.
|
| 303 |
+
for produkt in eintrag.fertigarzneimittel:
|
| 304 |
+
paare.append((produkt, "produkt", eintrag))
|
| 305 |
+
return sorted(paare, key=lambda p: -len(p[0]))
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def erkannte_eintraege(
|
| 309 |
+
question: str, eintraege: Sequence[Eintrag]
|
| 310 |
+
) -> Tuple[Optional[str], str, List[Eintrag]]:
|
| 311 |
+
"""(erkannter Begriff, Art, Einträge dazu) — mehrere, wenn er mehrfach steht.
|
| 312 |
+
|
| 313 |
+
Turnera diffusa steht unter der sexuellen Dysfunktion *und* unter der
|
| 314 |
+
Steigerung des sexuellen Verlangens, mit verschiedenen Fertigarzneimitteln.
|
| 315 |
+
Nur eines davon zu zeigen unterschlüge die halbe Regelung.
|
| 316 |
+
"""
|
| 317 |
+
haystack = _norm(question)
|
| 318 |
+
|
| 319 |
+
# Der ATC-Code zuerst: er ist das, worauf die Anlage zeigt, und eindeutiger
|
| 320 |
+
# als jeder Name.
|
| 321 |
+
codes = {_atc_norm("".join(t)) for t in RE_ATC_FRAGE.findall(question or "")}
|
| 322 |
+
if codes:
|
| 323 |
+
treffer = [e for e in eintraege if any(_atc_norm(c.atc) in codes for c in e.codes)]
|
| 324 |
+
if treffer:
|
| 325 |
+
gefragt = next(
|
| 326 |
+
c.atc for e in treffer for c in e.codes if _atc_norm(c.atc) in codes
|
| 327 |
+
)
|
| 328 |
+
return gefragt, "atc", treffer
|
| 329 |
+
|
| 330 |
+
for begriff, art, _ in begriffsindex(eintraege):
|
| 331 |
+
if not _wortgrenze(begriff, haystack):
|
| 332 |
+
continue
|
| 333 |
+
if art == "produkt":
|
| 334 |
+
passend = [
|
| 335 |
+
e
|
| 336 |
+
for e in eintraege
|
| 337 |
+
if any(_norm(p) == _norm(begriff) for p in e.fertigarzneimittel)
|
| 338 |
+
]
|
| 339 |
+
else:
|
| 340 |
+
passend = [
|
| 341 |
+
e
|
| 342 |
+
for e in eintraege
|
| 343 |
+
if any(_norm(b) == _norm(begriff) for b in (*e.begriffe, *e.varianten))
|
| 344 |
+
]
|
| 345 |
+
return begriff, art, passend
|
| 346 |
+
return None, "", []
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def erkanntes_gebiet(
|
| 350 |
+
question: str, daten: Dict[str, Any], eintraege: Sequence[Eintrag]
|
| 351 |
+
) -> Tuple[Optional[str], List[Eintrag]]:
|
| 352 |
+
"""Ein Indikationsgebiet in der Frage, etwa die Raucherentwöhnung.
|
| 353 |
+
|
| 354 |
+
Die zweite Zugangsart neben dem Stoff. Sie ist gröber und steht deshalb
|
| 355 |
+
hinter ihm: wer nach Sildenafil fragt, will den Eintrag und nicht alle
|
| 356 |
+
vierzehn Zeilen der sexuellen Dysfunktion.
|
| 357 |
+
"""
|
| 358 |
+
haystack = _norm(question)
|
| 359 |
+
kandidaten: List[Tuple[str, str]] = []
|
| 360 |
+
for gebiet in daten.get("indikationsgebiete") or []:
|
| 361 |
+
name = str(gebiet.get("name") or "")
|
| 362 |
+
for begriff in (name, *(gebiet.get("varianten") or [])):
|
| 363 |
+
if begriff:
|
| 364 |
+
kandidaten.append((str(begriff), name))
|
| 365 |
+
for begriff, name in sorted(kandidaten, key=lambda p: -len(p[0])):
|
| 366 |
+
if _wortgrenze(begriff, haystack):
|
| 367 |
+
return name, [e for e in eintraege if e.indikationsgebiet == name]
|
| 368 |
+
return None, []
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def fragt_nach_verordnungsfaehigkeit(question: str) -> bool:
|
| 372 |
+
haystack = _norm(question)
|
| 373 |
+
if RE_ANLAGE_II.search(haystack):
|
| 374 |
+
return True
|
| 375 |
+
return any(signal in haystack for signal in VERORDNUNGS_SIGNALE)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
# ---------------------------------------------------------------------------
|
| 379 |
+
# Prüfung
|
| 380 |
+
# ---------------------------------------------------------------------------
|
| 381 |
+
|
| 382 |
+
def pruefe(
|
| 383 |
+
question: str = "",
|
| 384 |
+
*,
|
| 385 |
+
path: Path,
|
| 386 |
+
stoff: Optional[str] = None,
|
| 387 |
+
heute: Optional[date] = None,
|
| 388 |
+
) -> Befund:
|
| 389 |
+
daten = load_liste(path)
|
| 390 |
+
alle = _eintraege(daten)
|
| 391 |
+
if not alle:
|
| 392 |
+
return Befund(status="nicht_pruefbar", grund="snapshot_fehlt_oder_leer")
|
| 393 |
+
|
| 394 |
+
quelle = daten.get("quelle") or {}
|
| 395 |
+
stand = str(quelle.get("stand") or "")
|
| 396 |
+
fundstelle = str(quelle.get("dokument") or "AM-RL Anlage II")
|
| 397 |
+
url = str(quelle.get("url") or "")
|
| 398 |
+
noten = {str(k): str(v) for k, v in (daten.get("fussnoten") or {}).items()}
|
| 399 |
+
|
| 400 |
+
if not stand:
|
| 401 |
+
return Befund(
|
| 402 |
+
status="nicht_pruefbar", grund="stand_unbekannt", fundstelle=fundstelle, url=url
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
stand_iso = _stand_iso(stand)
|
| 406 |
+
alter_tage: Optional[int] = None
|
| 407 |
+
veraltet = False
|
| 408 |
+
if stand_iso:
|
| 409 |
+
try:
|
| 410 |
+
alter_tage = max(
|
| 411 |
+
0,
|
| 412 |
+
((heute or date.today()) - datetime.strptime(stand_iso, "%Y-%m-%d").date()).days,
|
| 413 |
+
)
|
| 414 |
+
veraltet = alter_tage > VERALTET_AB_TAGEN
|
| 415 |
+
except ValueError:
|
| 416 |
+
alter_tage = None
|
| 417 |
+
|
| 418 |
+
def _fertig(befund: Befund) -> Befund:
|
| 419 |
+
befund.stand = stand
|
| 420 |
+
befund.stand_iso = stand_iso
|
| 421 |
+
befund.alter_tage = alter_tage
|
| 422 |
+
befund.veraltet = veraltet
|
| 423 |
+
befund.fundstelle = fundstelle
|
| 424 |
+
befund.url = url
|
| 425 |
+
befund.fussnoten = noten
|
| 426 |
+
return befund
|
| 427 |
+
|
| 428 |
+
# Ohne Bezug zur Verordnungsfähigkeit gar nichts: eine Dosierungsfrage nennt
|
| 429 |
+
# einen gelisteten Stoff, will aber keine Auskunft darüber, ob er zu Lasten
|
| 430 |
+
# der GKV verordnet werden darf.
|
| 431 |
+
if not fragt_nach_verordnungsfaehigkeit(question) and stoff is None:
|
| 432 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="keine_verordnungsfrage"))
|
| 433 |
+
|
| 434 |
+
if stoff:
|
| 435 |
+
begriff, art = stoff, "wirkstoff"
|
| 436 |
+
treffer = [
|
| 437 |
+
e
|
| 438 |
+
for e in alle
|
| 439 |
+
if any(_norm(b) == _norm(stoff) for b in (*e.begriffe, *e.varianten))
|
| 440 |
+
]
|
| 441 |
+
else:
|
| 442 |
+
begriff, art, treffer = erkannte_eintraege(question, alle)
|
| 443 |
+
if not treffer:
|
| 444 |
+
begriff, treffer = erkanntes_gebiet(question, daten, alle)
|
| 445 |
+
art = "gebiet" if treffer else ""
|
| 446 |
+
|
| 447 |
+
if treffer:
|
| 448 |
+
# Die Maßgabe eines einzigen Codes genügt, damit die Auskunft an der
|
| 449 |
+
# Anwendung hängt — und wo ein Code sie trägt und ein anderer nicht, ist
|
| 450 |
+
# gerade der Unterschied die Auskunft.
|
| 451 |
+
#
|
| 452 |
+
# Beim Indikationsgebiet gilt das *nicht*: dort stehen mehrere Zeilen
|
| 453 |
+
# nebeneinander, und die Maßgabe einer einzelnen von ihnen sagt nichts
|
| 454 |
+
# über das Gebiet. Ausgeschlossen ist es als solches (§ 34 Absatz 1
|
| 455 |
+
# Satz 8 SGB V zählt die Gebiete selbst auf); dass einzelne Zeilen enger
|
| 456 |
+
# geregelt sind, steht bei ihnen und wird im Block benannt.
|
| 457 |
+
status = (
|
| 458 |
+
"ausgeschlossen"
|
| 459 |
+
if art == "gebiet" or all(not e.nach_anwendung for e in treffer)
|
| 460 |
+
else "ausgeschlossen_nach_anwendung"
|
| 461 |
+
)
|
| 462 |
+
return _fertig(
|
| 463 |
+
Befund(status=status, gefragter_stoff=begriff, ueber=art, treffer=treffer)
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
# Keine Negativauskunft ins Blaue: ein Nichtvorkommen ist nur dann eine
|
| 467 |
+
# Aussage, wenn überhaupt ein Stoff genannt wurde.
|
| 468 |
+
kandidat = begriff or wirkstoff_kandidat(question)
|
| 469 |
+
if not kandidat:
|
| 470 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="kein_stoff_erkannt"))
|
| 471 |
+
|
| 472 |
+
return _fertig(
|
| 473 |
+
Befund(status="nicht_gelistet", gefragter_stoff=kandidat, grund="nicht_in_anlage_ii")
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
# ---------------------------------------------------------------------------
|
| 478 |
+
# Darstellung
|
| 479 |
+
# ---------------------------------------------------------------------------
|
| 480 |
+
|
| 481 |
+
_EINLEITUNG = "Lifestyle-Arzneimittel — deterministische Listenprüfung"
|
| 482 |
+
|
| 483 |
+
# Der Weg des medizinisch begründeten Einzelfalls, den Anlage III bei ihren
|
| 484 |
+
# Markern 3 bis 6 offenhält, steht hier nicht offen: § 16 Absatz 5 AM-RL nennt
|
| 485 |
+
# ausdrücklich nur die nach § 16 Absatz 1 und 2 ausgeschlossenen Arzneimittel.
|
| 486 |
+
_KEIN_EINZELFALL = (
|
| 487 |
+
"Der Ausschluss beruht auf dem Gesetz selbst (§ 34 Absatz 1 Satz 7 SGB V, § 14 AM-RL). "
|
| 488 |
+
"Der Weg über den medizinisch begründeten Einzelfall steht nach § 16 Absatz 5 AM-RL nur "
|
| 489 |
+
"bei Ausschlüssen und Einschränkungen durch die Richtlinie nach § 16 Absatz 1 und 2 offen "
|
| 490 |
+
"und greift hier nicht."
|
| 491 |
+
)
|
| 492 |
+
_ANWENDUNGSVORBEHALT = (
|
| 493 |
+
"Maßgeblich ist der Wortlaut des Eintrags samt seiner Maßgabe: Der Ausschluss hängt an "
|
| 494 |
+
"der Anwendung und nicht am Wirkstoff allein. Ob die geregelte Anwendung im Fall vorliegt, "
|
| 495 |
+
"ist eine ärztliche Feststellung und keine Listenauskunft."
|
| 496 |
+
)
|
| 497 |
+
_CODEVORBEHALT = (
|
| 498 |
+
"Die ATC-Codes zu {wirkstoff} sind verschieden geregelt — unter dem einen gilt der "
|
| 499 |
+
"Ausschluss unbedingt, unter dem anderen nur für die genannte Anwendung. Welcher Code "
|
| 500 |
+
"einschlägig ist, entscheidet die Anwendung im konkreten Fall."
|
| 501 |
+
)
|
| 502 |
+
_GEBIET_MASSGABEN = (
|
| 503 |
+
"Einzelne Einträge dieses Gebietes sind enger geregelt als das Gebiet selbst; die "
|
| 504 |
+
"Maßgabe steht oben bei ihrem Wirkstoff und ist dort zu lesen."
|
| 505 |
+
)
|
| 506 |
+
_PRODUKTVORBEHALT = (
|
| 507 |
+
"Die Anlage führt die Fertigarzneimittel namentlich auf; maßgeblich ist aber der Wirkstoff "
|
| 508 |
+
"in der Bezeichnung der ATC-Klassifikation. Ein Fertigarzneimittel kann seinen Wirkstoff "
|
| 509 |
+
"wechseln, und die Klauseln über generische Fertigarzneimittel sind offen."
|
| 510 |
+
)
|
| 511 |
+
_GEBIETSVORBEHALT = (
|
| 512 |
+
"Gezeigt ist das ganze Indikationsgebiet. Ob ein konkretes Arzneimittel darunterfällt, "
|
| 513 |
+
"entscheidet sein Wirkstoff mit seinem ATC-Code."
|
| 514 |
+
)
|
| 515 |
+
# Fußnote 2 der Anlage. Sie ist zugleich der Beleg dafür, dass die Liste nicht
|
| 516 |
+
# abschließend ist, und deshalb die Grenze jeder Negativauskunft.
|
| 517 |
+
_FUSSNOTE_ATC = "Zu diesem Wirkstoff gilt zusätzlich die Fußnote 2 der Anlage: {text}"
|
| 518 |
+
_TABAKENTWOEHNUNG = (
|
| 519 |
+
"Ausnahme nach Fußnote 1 der Anlage: {text} Anlage IIa ist deterministisch geprüft "
|
| 520 |
+
"(amrl_tabakentwoehnung); ihr Befund steht als eigener Block."
|
| 521 |
+
)
|
| 522 |
+
# Worauf die Negativauskunft *nicht* trägt.
|
| 523 |
+
_GRENZE_DER_NEGATIVAUSKUNFT = (
|
| 524 |
+
"Daraus folgt allein, dass dieser Ausschlussgrund nicht greift — keine "
|
| 525 |
+
"Verordnungsfähigkeit. § 14 Absatz 2 AM-RL schließt Arzneimittel zur Erhöhung der "
|
| 526 |
+
"Lebensqualität nur insbesondere aus, und § 14 Absatz 3 nennt Anlage II eine Übersicht; "
|
| 527 |
+
"abschließend nennt sie sich nirgends. Nach ihrer Fußnote 2 sind Arzneimittel mit "
|
| 528 |
+
"abweichenden ATC-Codes desselben Wirkprinzips (4. Ebene) bei entsprechender Verwendung "
|
| 529 |
+
"ebenfalls ausgeschlossen. Zu prüfen bleiben außerdem Anlage III "
|
| 530 |
+
"(Verordnungseinschränkungen) und, bei nicht verschreibungspflichtigen Arzneimitteln, "
|
| 531 |
+
"§ 34 Absatz 1 Satz 1 SGB V mit Anlage I."
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def _code_zeile(code: Code) -> str:
|
| 536 |
+
if not code.massgaben:
|
| 537 |
+
return f" {code.atc}"
|
| 538 |
+
zusatz = "; ".join(m.get("text", "") for m in code.massgaben)
|
| 539 |
+
return f" {code.atc} ({zusatz})"
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def _eintrag_block(eintrag: Eintrag) -> str:
|
| 543 |
+
zeilen = [f" {eintrag.wirkstoff} — Indikationsgebiet: {eintrag.indikationsgebiet}"]
|
| 544 |
+
for code in eintrag.codes:
|
| 545 |
+
zeilen.append(_code_zeile(code))
|
| 546 |
+
if not eintrag.codes:
|
| 547 |
+
# Neun Zeilen der Anlage führen keinen ATC-Code, sondern allein den
|
| 548 |
+
# Wirkstoff (Turnera diffusa, Dexamethason; Alfatradiol).
|
| 549 |
+
zeilen.append(" ohne ATC-Code in der Anlage geführt")
|
| 550 |
+
produkte = [*eintrag.fertigarzneimittel, *eintrag.generikaklauseln]
|
| 551 |
+
if produkte:
|
| 552 |
+
zeilen.append(f" Fertigarzneimittel: {', '.join(produkte)}")
|
| 553 |
+
return "\n".join(zeilen)
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def befund_block(befund: Befund) -> str:
|
| 557 |
+
if not befund.ist_belastbar:
|
| 558 |
+
return ""
|
| 559 |
+
|
| 560 |
+
zeilen: List[str] = [f"{_EINLEITUNG} ({befund.fundstelle}, Stand {befund.stand}):"]
|
| 561 |
+
|
| 562 |
+
if befund.status in {"ausgeschlossen", "ausgeschlossen_nach_anwendung"}:
|
| 563 |
+
anzahl = len(befund.treffer)
|
| 564 |
+
wo = "in einem Eintrag" if anzahl == 1 else f"in {anzahl} Einträgen"
|
| 565 |
+
gebiete = sorted({e.indikationsgebiet for e in befund.treffer})
|
| 566 |
+
if befund.ueber == "gebiet":
|
| 567 |
+
zeilen.append(
|
| 568 |
+
f"Das Indikationsgebiet {befund.gefragter_stoff} führt Anlage II mit {anzahl} "
|
| 569 |
+
"Einträgen; die dort geführten Arzneimittel sind nach § 34 Absatz 1 Satz 7 "
|
| 570 |
+
"SGB V von der Versorgung zu Lasten der GKV ausgeschlossen."
|
| 571 |
+
)
|
| 572 |
+
else:
|
| 573 |
+
zeilen.append(
|
| 574 |
+
f"{befund.gefragter_stoff} ist in Anlage II {wo} geführt und damit nach § 34 "
|
| 575 |
+
f"Absatz 1 Satz 7 SGB V von der Versorgung zu Lasten der GKV ausgeschlossen "
|
| 576 |
+
f"({'; '.join(gebiete)})."
|
| 577 |
+
)
|
| 578 |
+
for eintrag in befund.treffer:
|
| 579 |
+
zeilen.append(_eintrag_block(eintrag))
|
| 580 |
+
|
| 581 |
+
if befund.status == "ausgeschlossen_nach_anwendung":
|
| 582 |
+
zeilen.append(_ANWENDUNGSVORBEHALT)
|
| 583 |
+
elif befund.ueber == "gebiet" and any(e.nach_anwendung for e in befund.treffer):
|
| 584 |
+
zeilen.append(_GEBIET_MASSGABEN)
|
| 585 |
+
for eintrag in befund.treffer:
|
| 586 |
+
if eintrag.codes_verschieden_geregelt:
|
| 587 |
+
zeilen.append(_CODEVORBEHALT.format(wirkstoff=eintrag.wirkstoff))
|
| 588 |
+
zeilen.append(_KEIN_EINZELFALL)
|
| 589 |
+
if befund.tabakentwoehnung and befund.fussnoten.get("1"):
|
| 590 |
+
zeilen.append(_TABAKENTWOEHNUNG.format(text=befund.fussnoten["1"]))
|
| 591 |
+
if any(e.fussnote == "2" for e in befund.treffer) and befund.fussnoten.get("2"):
|
| 592 |
+
zeilen.append(_FUSSNOTE_ATC.format(text=befund.fussnoten["2"]))
|
| 593 |
+
if befund.ueber == "produkt":
|
| 594 |
+
zeilen.append(_PRODUKTVORBEHALT)
|
| 595 |
+
if befund.ueber == "gebiet":
|
| 596 |
+
zeilen.append(_GEBIETSVORBEHALT)
|
| 597 |
+
else: # nicht_gelistet
|
| 598 |
+
zeilen.append(
|
| 599 |
+
f"Anlage II führt {befund.gefragter_stoff} nicht. {_GRENZE_DER_NEGATIVAUSKUNFT}"
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
if befund.veraltet:
|
| 603 |
+
zeilen.append(
|
| 604 |
+
f"Achtung Aktualität: Dieser Auszug ist {befund.alter_tage} Tage alt. Vor einer "
|
| 605 |
+
"Entscheidung ist die geltende Fassung abzugleichen."
|
| 606 |
+
)
|
| 607 |
+
if befund.url:
|
| 608 |
+
zeilen.append(f"Quelle: {befund.url}")
|
| 609 |
+
|
| 610 |
+
return "\n".join(zeilen)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def verdikt(befund: Befund) -> str:
|
| 614 |
+
"""Die Antwort in einem Satz — nur wenn die Frage entschieden werden konnte.
|
| 615 |
+
|
| 616 |
+
Für den Zustand „nicht gelistet“ bleibt der Satz bewusst leer: die Auskunft
|
| 617 |
+
trägt hier nicht so weit wie bei Anlage I und taugt nicht als Kurzantwort.
|
| 618 |
+
Der Block oben nennt sie trotzdem, weil sie einen Ausschlussgrund ausräumt.
|
| 619 |
+
"""
|
| 620 |
+
if befund.status not in {"ausgeschlossen", "ausgeschlossen_nach_anwendung"}:
|
| 621 |
+
return ""
|
| 622 |
+
|
| 623 |
+
if befund.ueber == "gebiet":
|
| 624 |
+
satz = (
|
| 625 |
+
f"Arzneimittel des Indikationsgebietes {befund.gefragter_stoff} stehen in Anlage II "
|
| 626 |
+
"der AM-RL und sind nach § 34 Absatz 1 Satz 7 SGB V von der Verordnung zu Lasten "
|
| 627 |
+
"der GKV ausgeschlossen (Lifestyle-Arzneimittel)"
|
| 628 |
+
)
|
| 629 |
+
else:
|
| 630 |
+
satz = (
|
| 631 |
+
f"{befund.gefragter_stoff} ist nach Anlage II der AM-RL von der Verordnung zu "
|
| 632 |
+
"Lasten der GKV ausgeschlossen (Lifestyle-Arzneimittel, § 34 Absatz 1 Satz 7 SGB V)"
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
if befund.status == "ausgeschlossen_nach_anwendung":
|
| 636 |
+
massgaben: List[str] = []
|
| 637 |
+
for eintrag in befund.treffer:
|
| 638 |
+
for massgabe in eintrag.massgaben:
|
| 639 |
+
text = massgabe.get("text", "")
|
| 640 |
+
if text and text not in massgaben:
|
| 641 |
+
massgaben.append(text)
|
| 642 |
+
satz += f" — allerdings nur nach Maßgabe des Eintrags: {'; '.join(massgaben)}"
|
| 643 |
+
else:
|
| 644 |
+
satz += " — der medizinisch begründete Einzelfall trägt hier nicht"
|
| 645 |
+
|
| 646 |
+
return f"{satz} (Listenprüfung oben, Stand {befund.stand})."
|
src/amrl_otc.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OTC auf Kassenrezept nachschlagen (AM-RL Anlage I).
|
| 2 |
+
|
| 3 |
+
Nicht verschreibungspflichtige Arzneimittel sind nach § 34 Absatz 1 Satz 1 SGB V
|
| 4 |
+
von der Versorgung ausgeschlossen. Ausnahmsweise verordnungsfähig sind sie,
|
| 5 |
+
wenn sie bei der Behandlung schwerwiegender Erkrankungen als Therapiestandard
|
| 6 |
+
gelten — und welche das sind, führt Anlage I auf. In der Offizin ist das ein
|
| 7 |
+
häufiger Retaxgrund: OTC auf Kassenrezept ist die Ausnahme.
|
| 8 |
+
|
| 9 |
+
**Hier trägt die Negativauskunft — anders als bei Anlage VIIa.** § 12 Absatz 10
|
| 10 |
+
der Richtlinie sagt es ausdrücklich: die Absätze 1 bis 9 regeln *abschließend*,
|
| 11 |
+
unter welchen Voraussetzungen nicht verschreibungspflichtige Arzneimittel zu
|
| 12 |
+
Lasten der GKV verordnungsfähig sind. Ein „steht nicht in Anlage I" ist deshalb
|
| 13 |
+
eine Auskunft und nicht bloß ein fehlender Treffer. Genau deshalb ist es auch
|
| 14 |
+
ein Lookup: ein Vektor-Retrieval liefert immer die k ähnlichsten Chunks und kann
|
| 15 |
+
Abwesenheit prinzipiell nicht belegen.
|
| 16 |
+
|
| 17 |
+
**Der Eintrag ist Stoff *und* Indikation, und beide gehören zusammen.**
|
| 18 |
+
Lactulose ist nicht verordnungsfähig, sondern „nur zur Senkung der enteralen
|
| 19 |
+
Ammoniakresorption bei Leberversagen im Zusammenhang mit der hepatischen
|
| 20 |
+
Enzephalopathie". Der Befund zitiert die Bedingung deshalb vollständig und
|
| 21 |
+
subsumiert nicht — ob sie im Fall vorliegt, ist eine ärztliche Feststellung und
|
| 22 |
+
keine Listenauskunft. Dieselbe Zurückhaltung wie bei Everolimus in Teil B.
|
| 23 |
+
|
| 24 |
+
**Vier Wege führen an der Liste vorbei**, und ein Befund, der sie verschweigt,
|
| 25 |
+
ist falsch. Alle vier stehen in § 12 der Richtlinie, nicht in der Anlage:
|
| 26 |
+
|
| 27 |
+
Absatz 12 Der Ausschluss gilt überhaupt nicht für Kinder bis zum
|
| 28 |
+
vollendeten 12. Lebensjahr und Jugendliche mit
|
| 29 |
+
Entwicklungsstörungen bis zum vollendeten 18. Lebensjahr.
|
| 30 |
+
Absatz 7 Begleitmedikation, wenn die Fachinformation des
|
| 31 |
+
Hauptarzneimittels sie voraussetzt.
|
| 32 |
+
Absatz 8 Behandlung schwerwiegender unerwünschter Arzneimittelwirkungen
|
| 33 |
+
eines verordnungsfähigen Arzneimittels.
|
| 34 |
+
Absatz 6 Anthroposophie und Homöopathie für die in Anlage I gelisteten
|
| 35 |
+
Indikationsgebiete.
|
| 36 |
+
|
| 37 |
+
Die Zustände:
|
| 38 |
+
|
| 39 |
+
gelistet Stoff steht in Anlage I — mit seiner Bedingung
|
| 40 |
+
gruppe_gelistet die Anlage führt eine Stoffgruppe, kein Einzelpräparat
|
| 41 |
+
(Abführmittel, Antihistaminika); ob das konkrete
|
| 42 |
+
Präparat dazugehört, ist am Fall zu prüfen
|
| 43 |
+
nicht_gelistet die Auskunft, die ein Retrieval nicht geben kann
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
from __future__ import annotations
|
| 47 |
+
|
| 48 |
+
import json
|
| 49 |
+
import logging
|
| 50 |
+
import re
|
| 51 |
+
from dataclasses import dataclass, field
|
| 52 |
+
from datetime import date, datetime
|
| 53 |
+
from pathlib import Path
|
| 54 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 55 |
+
|
| 56 |
+
# Geteilt statt ein viertes Mal geschrieben: dieselbe Standangabe, dieselbe
|
| 57 |
+
# Frist, dieselbe Endungsheuristik für einen Wirkstoffnamen.
|
| 58 |
+
from amrl_substitution import VERALTET_AB_TAGEN, _stand_iso, wirkstoff_kandidat
|
| 59 |
+
|
| 60 |
+
logger = logging.getLogger(__name__)
|
| 61 |
+
|
| 62 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 63 |
+
|
| 64 |
+
# Die Frage muss von der Verordnungsfähigkeit handeln. Regelbasiert wie in den
|
| 65 |
+
# übrigen Listenmodulen: eine Substring-Prüfung entscheidet das exakt.
|
| 66 |
+
OTC_SIGNALE: tuple[str, ...] = (
|
| 67 |
+
"otc",
|
| 68 |
+
"kassenrezept",
|
| 69 |
+
"zu lasten der",
|
| 70 |
+
"verordnungsfähig",
|
| 71 |
+
"verordnungsfaehig",
|
| 72 |
+
"verordnungsausschluss",
|
| 73 |
+
"erstattungsfähig",
|
| 74 |
+
"erstattungsfaehig",
|
| 75 |
+
"erstattung",
|
| 76 |
+
"rezeptpflichtig",
|
| 77 |
+
"verschreibungspflichtig",
|
| 78 |
+
"apothekenpflichtig",
|
| 79 |
+
"grüne rezept",
|
| 80 |
+
"grünes rezept",
|
| 81 |
+
"privatrezept",
|
| 82 |
+
"selbstzahler",
|
| 83 |
+
"auf kasse",
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Die Anlage beim Namen genannt. Als Substring wäre „anlage i" auch in
|
| 87 |
+
# „Anlage III" enthalten — und die regelt Verordnungseinschränkungen, nicht die
|
| 88 |
+
# OTC-Ausnahmen.
|
| 89 |
+
RE_ANLAGE_I = re.compile(r"\banlage\s+i\b(?!i)")
|
| 90 |
+
|
| 91 |
+
# Einträge, die eine Stoffgruppe führen und kein Einzelpräparat. Die Zuordnung
|
| 92 |
+
# eines konkreten Präparats zur Gruppe ist eine pharmakologische Feststellung,
|
| 93 |
+
# die die Anlage nicht trifft — der Befund sagt das, statt sie zu treffen.
|
| 94 |
+
GRUPPENEINTRAEGE: frozenset[str] = frozenset(
|
| 95 |
+
{"1", "4", "5", "6", "7", "8", "9", "21", "22", "27", "37", "43", "46"}
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass(frozen=True)
|
| 100 |
+
class Eintrag:
|
| 101 |
+
nr: str
|
| 102 |
+
stoffangabe: str
|
| 103 |
+
text: str
|
| 104 |
+
spiegelstriche: Tuple[str, ...]
|
| 105 |
+
begriffe: Tuple[str, ...]
|
| 106 |
+
varianten: Tuple[str, ...]
|
| 107 |
+
seite: Optional[int]
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def ist_gruppe(self) -> bool:
|
| 111 |
+
return self.nr in GRUPPENEINTRAEGE
|
| 112 |
+
|
| 113 |
+
def volltext(self) -> str:
|
| 114 |
+
if not self.spiegelstriche:
|
| 115 |
+
return self.text
|
| 116 |
+
return self.text + "\n" + "\n".join(f" - {s}" for s in self.spiegelstriche)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@dataclass
|
| 120 |
+
class Befund:
|
| 121 |
+
status: str
|
| 122 |
+
gefragter_stoff: Optional[str] = None
|
| 123 |
+
treffer: List[Eintrag] = field(default_factory=list)
|
| 124 |
+
stand: str = ""
|
| 125 |
+
stand_iso: str = ""
|
| 126 |
+
veraltet: bool = False
|
| 127 |
+
alter_tage: Optional[int] = None
|
| 128 |
+
fundstelle: str = ""
|
| 129 |
+
url: str = ""
|
| 130 |
+
grund: str = ""
|
| 131 |
+
|
| 132 |
+
@property
|
| 133 |
+
def ist_belastbar(self) -> bool:
|
| 134 |
+
return self.status in {"gelistet", "gruppe_gelistet", "nicht_gelistet"}
|
| 135 |
+
|
| 136 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 137 |
+
return {
|
| 138 |
+
"status": self.status,
|
| 139 |
+
"gefragter_stoff": self.gefragter_stoff,
|
| 140 |
+
"treffer": [
|
| 141 |
+
{"nr": e.nr, "stoffangabe": e.stoffangabe, "seite": e.seite} for e in self.treffer
|
| 142 |
+
],
|
| 143 |
+
"stand": self.stand,
|
| 144 |
+
"veraltet": self.veraltet,
|
| 145 |
+
"alter_tage": self.alter_tage,
|
| 146 |
+
"fundstelle": self.fundstelle,
|
| 147 |
+
"url": self.url,
|
| 148 |
+
"grund": self.grund,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ---------------------------------------------------------------------------
|
| 153 |
+
# Daten
|
| 154 |
+
# ---------------------------------------------------------------------------
|
| 155 |
+
|
| 156 |
+
def load_liste(path: Path) -> Dict[str, Any]:
|
| 157 |
+
key = str(path)
|
| 158 |
+
if key in _CACHE:
|
| 159 |
+
return _CACHE[key]
|
| 160 |
+
|
| 161 |
+
data: Dict[str, Any] = {}
|
| 162 |
+
try:
|
| 163 |
+
if path.exists():
|
| 164 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 165 |
+
except Exception as exc: # noqa: BLE001 - ein defekter Snapshot darf keine Antwort verhindern.
|
| 166 |
+
logger.warning("OTC-Liste nicht lesbar: %s (%s)", path, exc)
|
| 167 |
+
data = {}
|
| 168 |
+
|
| 169 |
+
_CACHE[key] = data
|
| 170 |
+
return data
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _eintraege(daten: Dict[str, Any]) -> List[Eintrag]:
|
| 174 |
+
out: List[Eintrag] = []
|
| 175 |
+
for roh in daten.get("eintraege") or []:
|
| 176 |
+
if roh.get("unbesetzt"):
|
| 177 |
+
continue
|
| 178 |
+
out.append(
|
| 179 |
+
Eintrag(
|
| 180 |
+
nr=str(roh.get("nr") or ""),
|
| 181 |
+
stoffangabe=str(roh.get("stoffangabe") or ""),
|
| 182 |
+
text=str(roh.get("text") or ""),
|
| 183 |
+
spiegelstriche=tuple(str(s) for s in roh.get("spiegelstriche") or []),
|
| 184 |
+
begriffe=tuple(str(b) for b in roh.get("begriffe") or []),
|
| 185 |
+
varianten=tuple(str(v) for v in roh.get("varianten") or []),
|
| 186 |
+
seite=roh.get("seite"),
|
| 187 |
+
)
|
| 188 |
+
)
|
| 189 |
+
return out
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
# Erkennung in der Frage
|
| 194 |
+
# ---------------------------------------------------------------------------
|
| 195 |
+
|
| 196 |
+
def _norm(text: str) -> str:
|
| 197 |
+
return " ".join(str(text or "").lower().split())
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def begriffsindex(eintraege: Sequence[Eintrag]) -> List[Tuple[str, Eintrag]]:
|
| 201 |
+
"""(Suchbegriff, Eintrag), längste zuerst.
|
| 202 |
+
|
| 203 |
+
Die Reihenfolge trägt: „Wasserlösliche Vitamine" darf nicht vom kürzeren
|
| 204 |
+
„Vitamin" verdeckt werden, und „Eisen-(II)-Verbindungen" nicht von „Eisen".
|
| 205 |
+
"""
|
| 206 |
+
paare = [(b, e) for e in eintraege for b in (*e.begriffe, *e.varianten) if b]
|
| 207 |
+
return sorted(paare, key=lambda p: -len(p[0]))
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def erkannte_eintraege(question: str, eintraege: Sequence[Eintrag]) -> Tuple[Optional[str], List[Eintrag]]:
|
| 211 |
+
"""(erkannter Begriff, Einträge dazu) — mehrere, wenn der Stoff mehrfach steht.
|
| 212 |
+
|
| 213 |
+
Calciumverbindungen stehen in Nummer 11 und 12 mit verschiedenen
|
| 214 |
+
Bedingungen, Magnesiumverbindungen in 28 und 29, Folsäure in 19 und 44. Nur
|
| 215 |
+
einen davon zu zeigen unterschlüge eine Verordnungsmöglichkeit.
|
| 216 |
+
"""
|
| 217 |
+
haystack = _norm(question)
|
| 218 |
+
for begriff, _ in begriffsindex(eintraege):
|
| 219 |
+
if re.search(rf"(?<![\wäöüß]){re.escape(_norm(begriff))}(?![\wäöüß])", haystack):
|
| 220 |
+
passend = [
|
| 221 |
+
e
|
| 222 |
+
for e in eintraege
|
| 223 |
+
if any(_norm(b) == _norm(begriff) for b in (*e.begriffe, *e.varianten))
|
| 224 |
+
]
|
| 225 |
+
return begriff, passend
|
| 226 |
+
return None, []
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def fragt_nach_verordnungsfaehigkeit(question: str) -> bool:
|
| 230 |
+
haystack = _norm(question)
|
| 231 |
+
if RE_ANLAGE_I.search(haystack):
|
| 232 |
+
return True
|
| 233 |
+
return any(signal in haystack for signal in OTC_SIGNALE)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ---------------------------------------------------------------------------
|
| 237 |
+
# Prüfung
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
|
| 240 |
+
def pruefe(
|
| 241 |
+
question: str = "",
|
| 242 |
+
*,
|
| 243 |
+
path: Path,
|
| 244 |
+
stoff: Optional[str] = None,
|
| 245 |
+
heute: Optional[date] = None,
|
| 246 |
+
) -> Befund:
|
| 247 |
+
daten = load_liste(path)
|
| 248 |
+
alle = _eintraege(daten)
|
| 249 |
+
if not alle:
|
| 250 |
+
return Befund(status="nicht_pruefbar", grund="snapshot_fehlt_oder_leer")
|
| 251 |
+
|
| 252 |
+
quelle = daten.get("quelle") or {}
|
| 253 |
+
stand = str(quelle.get("stand") or "")
|
| 254 |
+
fundstelle = str(quelle.get("dokument") or "AM-RL Anlage I")
|
| 255 |
+
url = str(quelle.get("url") or "")
|
| 256 |
+
|
| 257 |
+
if not stand:
|
| 258 |
+
return Befund(status="nicht_pruefbar", grund="stand_unbekannt", fundstelle=fundstelle, url=url)
|
| 259 |
+
|
| 260 |
+
stand_iso = _stand_iso(stand)
|
| 261 |
+
alter_tage: Optional[int] = None
|
| 262 |
+
veraltet = False
|
| 263 |
+
if stand_iso:
|
| 264 |
+
try:
|
| 265 |
+
alter_tage = max(0, ((heute or date.today()) - datetime.strptime(stand_iso, "%Y-%m-%d").date()).days)
|
| 266 |
+
veraltet = alter_tage > VERALTET_AB_TAGEN
|
| 267 |
+
except ValueError:
|
| 268 |
+
alter_tage = None
|
| 269 |
+
|
| 270 |
+
def _fertig(befund: Befund) -> Befund:
|
| 271 |
+
befund.stand = stand
|
| 272 |
+
befund.stand_iso = stand_iso
|
| 273 |
+
befund.alter_tage = alter_tage
|
| 274 |
+
befund.veraltet = veraltet
|
| 275 |
+
befund.fundstelle = fundstelle
|
| 276 |
+
befund.url = url
|
| 277 |
+
return befund
|
| 278 |
+
|
| 279 |
+
# Ohne Bezug zur Verordnungsfähigkeit gar nichts: „Wie dosiere ich Iodid?"
|
| 280 |
+
# nennt einen gelisteten Stoff, will aber keine OTC-Auskunft.
|
| 281 |
+
if not fragt_nach_verordnungsfaehigkeit(question) and stoff is None:
|
| 282 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="keine_verordnungsfrage"))
|
| 283 |
+
|
| 284 |
+
if stoff:
|
| 285 |
+
begriff, treffer = stoff, [
|
| 286 |
+
e for e in alle if any(_norm(b) == _norm(stoff) for b in (*e.begriffe, *e.varianten))
|
| 287 |
+
]
|
| 288 |
+
else:
|
| 289 |
+
begriff, treffer = erkannte_eintraege(question, alle)
|
| 290 |
+
|
| 291 |
+
if treffer:
|
| 292 |
+
gruppe = all(e.ist_gruppe for e in treffer)
|
| 293 |
+
return _fertig(
|
| 294 |
+
Befund(
|
| 295 |
+
status="gruppe_gelistet" if gruppe else "gelistet",
|
| 296 |
+
gefragter_stoff=begriff,
|
| 297 |
+
treffer=treffer,
|
| 298 |
+
)
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# Keine Negativauskunft ins Blaue: „nicht in Anlage I" ist nur dann eine
|
| 302 |
+
# Aussage, wenn überhaupt ein Stoff genannt wurde. Sonst wäre es eine
|
| 303 |
+
# Aussage über eine Frage, die keinen nennt.
|
| 304 |
+
kandidat = begriff or wirkstoff_kandidat(question)
|
| 305 |
+
if not kandidat:
|
| 306 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="kein_stoff_erkannt"))
|
| 307 |
+
|
| 308 |
+
return _fertig(
|
| 309 |
+
Befund(status="nicht_gelistet", gefragter_stoff=kandidat, grund="nicht_in_anlage_i")
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# ---------------------------------------------------------------------------
|
| 314 |
+
# Darstellung
|
| 315 |
+
# ---------------------------------------------------------------------------
|
| 316 |
+
|
| 317 |
+
_EINLEITUNG = "OTC-Verordnungsfähigkeit — deterministische Listenprüfung"
|
| 318 |
+
|
| 319 |
+
# Die vier Wege an der Liste vorbei. Sie stehen in § 12 der Richtlinie und nicht
|
| 320 |
+
# in der Anlage; ein Befund, der nur die Liste wiedergibt, ist ohne sie falsch.
|
| 321 |
+
_AUSSERHALB = (
|
| 322 |
+
"Unabhängig von dieser Liste bleibt verordnungsfähig, was § 12 der Richtlinie "
|
| 323 |
+
"gesondert regelt: für versicherte Kinder bis zum vollendeten 12. Lebensjahr und "
|
| 324 |
+
"Jugendliche mit Entwicklungsstörungen bis zum vollendeten 18. Lebensjahr gilt der "
|
| 325 |
+
"Ausschluss überhaupt nicht (Absatz 12); dazu kommen Begleitmedikation, die die "
|
| 326 |
+
"Fachinformation des Hauptarzneimittels voraussetzt (Absatz 7), die Behandlung "
|
| 327 |
+
"schwerwiegender unerwünschter Arzneimittelwirkungen (Absatz 8) und Arzneimittel der "
|
| 328 |
+
"Anthroposophie und Homöopathie für die hier gelisteten Indikationsgebiete (Absatz 6)."
|
| 329 |
+
)
|
| 330 |
+
_GRUPPENVORBEHALT = (
|
| 331 |
+
"Die Anlage führt hier eine Stoffgruppe und kein einzelnes Präparat. Ob das konkrete "
|
| 332 |
+
"Arzneimittel darunter fällt, ist am Fall zu prüfen — die Liste sagt es nicht."
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _eintrag_block(eintrag: Eintrag) -> str:
|
| 337 |
+
zeilen = [f" Nummer {eintrag.nr}: {eintrag.text}"]
|
| 338 |
+
for strich in eintrag.spiegelstriche:
|
| 339 |
+
zeilen.append(f" – {strich}")
|
| 340 |
+
return "\n".join(zeilen)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def befund_block(befund: Befund) -> str:
|
| 344 |
+
if not befund.ist_belastbar:
|
| 345 |
+
return ""
|
| 346 |
+
|
| 347 |
+
zeilen: List[str] = [f"{_EINLEITUNG} ({befund.fundstelle}, Stand {befund.stand}):"]
|
| 348 |
+
|
| 349 |
+
if befund.status in {"gelistet", "gruppe_gelistet"}:
|
| 350 |
+
anzahl = len(befund.treffer)
|
| 351 |
+
wo = (
|
| 352 |
+
"in einem Eintrag, nur unter der dort genannten Indikation"
|
| 353 |
+
if anzahl == 1
|
| 354 |
+
else f"in {anzahl} Einträgen, jeweils nur unter der dort genannten Indikation"
|
| 355 |
+
)
|
| 356 |
+
zeilen.append(
|
| 357 |
+
f"{befund.gefragter_stoff} steht in Anlage I — {wo}. Die Verordnung zu Lasten der "
|
| 358 |
+
"GKV ist an diese Bedingung gebunden; ob sie im Fall vorliegt, ist eine ärztliche "
|
| 359 |
+
"Feststellung."
|
| 360 |
+
)
|
| 361 |
+
for eintrag in befund.treffer:
|
| 362 |
+
zeilen.append(_eintrag_block(eintrag))
|
| 363 |
+
if befund.status == "gruppe_gelistet":
|
| 364 |
+
zeilen.append(_GRUPPENVORBEHALT)
|
| 365 |
+
else: # nicht_gelistet
|
| 366 |
+
zeilen.append(
|
| 367 |
+
f"Anlage I führt {befund.gefragter_stoff} nicht. Nicht verschreibungspflichtige "
|
| 368 |
+
"Arzneimittel sind nach § 34 Absatz 1 Satz 1 SGB V von der Versorgung "
|
| 369 |
+
"ausgeschlossen, und § 12 Absatz 10 der Richtlinie regelt abschließend, wann "
|
| 370 |
+
"ausnahmsweise verordnet werden darf. Ist das Arzneimittel nicht "
|
| 371 |
+
"verschreibungspflichtig, kommt eine Verordnung zu Lasten der GKV danach nicht "
|
| 372 |
+
"in Betracht."
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
zeilen.append(_AUSSERHALB)
|
| 376 |
+
|
| 377 |
+
if befund.veraltet:
|
| 378 |
+
zeilen.append(
|
| 379 |
+
f"Achtung Aktualität: Dieser Auszug ist {befund.alter_tage} Tage alt. Vor einer "
|
| 380 |
+
"Entscheidung ist die geltende Fassung abzugleichen."
|
| 381 |
+
)
|
| 382 |
+
if befund.url:
|
| 383 |
+
zeilen.append(f"Quelle: {befund.url}")
|
| 384 |
+
|
| 385 |
+
return "\n".join(zeilen)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def verdikt(befund: Befund) -> str:
|
| 389 |
+
"""Die Antwort in einem Satz — nur wenn die Frage entschieden werden konnte."""
|
| 390 |
+
if befund.status == "gelistet":
|
| 391 |
+
nummern = ", ".join(e.nr for e in befund.treffer)
|
| 392 |
+
return (
|
| 393 |
+
f"{befund.gefragter_stoff} ist in Anlage I der AM-RL geführt (Nummer {nummern}) und "
|
| 394 |
+
f"damit zu Lasten der GKV verordnungsfähig, aber nur unter der dort genannten "
|
| 395 |
+
f"Indikation (Listenprüfung oben, Stand {befund.stand})."
|
| 396 |
+
)
|
| 397 |
+
if befund.status == "gruppe_gelistet":
|
| 398 |
+
nummern = ", ".join(e.nr for e in befund.treffer)
|
| 399 |
+
return (
|
| 400 |
+
f"Anlage I der AM-RL führt {befund.gefragter_stoff} als Stoffgruppe (Nummer "
|
| 401 |
+
f"{nummern}) und nur unter der dort genannten Indikation; ob das konkrete Präparat "
|
| 402 |
+
f"darunter fällt, ist am Fall zu prüfen (Listenprüfung oben, Stand {befund.stand})."
|
| 403 |
+
)
|
| 404 |
+
if befund.status == "nicht_gelistet":
|
| 405 |
+
# Der Vorbehalt der Verschreibungspflicht ist kein Hedging, sondern der
|
| 406 |
+
# Anwendungsbereich: Anlage I entscheidet nur über nicht
|
| 407 |
+
# verschreibungspflichtige Arzneimittel. Ibuprofen gibt es als beides,
|
| 408 |
+
# und ein pauschales „nicht verordnungsfähig" wäre für die 600 mg falsch.
|
| 409 |
+
return (
|
| 410 |
+
f"{befund.gefragter_stoff} steht nicht in Anlage I der AM-RL. Soweit das "
|
| 411 |
+
f"Arzneimittel nicht verschreibungspflichtig ist, kommt eine Verordnung zu Lasten "
|
| 412 |
+
f"der GKV damit nicht in Betracht — vorbehaltlich der Sonderfälle des § 12 AM-RL, "
|
| 413 |
+
f"insbesondere bei Kindern bis 12 Jahren (Listenprüfung oben, Stand {befund.stand})."
|
| 414 |
+
)
|
| 415 |
+
return ""
|
src/amrl_substitution.py
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Substitutionsausschluss nachschlagen statt danach suchen.
|
| 2 |
+
|
| 3 |
+
Teil B der AM-RL-Anlage VII listet die Wirkstoffe, deren Ersetzung durch ein
|
| 4 |
+
wirkstoffgleiches Arzneimittel nach § 129 Absatz 1a Satz 2 SGB V ausgeschlossen
|
| 5 |
+
ist. Diese Liste wird hier **nachgeschlagen**, nicht durchsucht, und der Grund
|
| 6 |
+
ist keine Bequemlichkeit:
|
| 7 |
+
|
| 8 |
+
* Die für die Apotheke entscheidende Auskunft ist häufig die *negative* — „steht
|
| 9 |
+
nicht drauf, also darf ausgetauscht werden". Ein Vektor-Retrieval kann
|
| 10 |
+
Abwesenheit nicht belegen: es liefert immer die k ähnlichsten Treffer, und
|
| 11 |
+
„nicht gefunden" heißt dort nie „nicht enthalten".
|
| 12 |
+
* Bei Wirkstoffnamen ist semantische Nähe aktiv gefährlich. Ciclosporin und
|
| 13 |
+
Ciclopirox stehen sich im Embedding nah und haben nichts miteinander zu tun.
|
| 14 |
+
|
| 15 |
+
Zwei Dinge, die dieses Modul bewusst **nicht** tut:
|
| 16 |
+
|
| 17 |
+
* **Es urteilt nicht über Retaxationsrisiko.** Zurück kommt der Listenstatus mit
|
| 18 |
+
Fundstelle — eine Normtatsache. Welche Abgabe- und Abrechnungsfolge daraus
|
| 19 |
+
erwächst, steht im Rahmenvertrag und bleibt Sache des RAG-Teils. Die
|
| 20 |
+
Verordnungsverantwortung des Arztes und die Prüfpflicht der Apotheke sind nicht
|
| 21 |
+
dasselbe, und ein Werkzeug, das beides vermischt, wäre fachlich falsch.
|
| 22 |
+
* **Es rät nicht.** Ein Eintrag wird nur gemeldet, wenn der Wirkstoff wörtlich
|
| 23 |
+
oder über ein kuratiertes Synonym getroffen wurde. Fuzzy-Matching auf
|
| 24 |
+
Wirkstoffnamen ist genau der Fehler, den das Retrieval schon macht.
|
| 25 |
+
|
| 26 |
+
Vier Fälle sind strikt getrennt, weil sie unterschiedliche Rechtsfolgen haben —
|
| 27 |
+
ein „steht auf der Liste, also verboten" wäre bei den beiden mittleren falsch:
|
| 28 |
+
|
| 29 |
+
ausschluss Austausch ausgeschlossen.
|
| 30 |
+
ausschluss_bedingt Nur unter der genannten Bedingung, z. B.
|
| 31 |
+
Everolimus nur bis 1 mg Wirkstoffgehalt.
|
| 32 |
+
ausschluss_zwischen_varianten Nur zwischen den genannten Varianten, z. B.
|
| 33 |
+
Buprenorphin-Pflaster unterschiedlicher
|
| 34 |
+
Applikationshöchstdauer. Innerhalb derselben
|
| 35 |
+
Variante bleibt der Austausch zulässig.
|
| 36 |
+
nicht_gelistet Kein gelisteter Wirkstoff in der Frage.
|
| 37 |
+
|
| 38 |
+
Der `stand` ist Pflichtangabe jedes Befundes, nicht Fußnote: eine veraltete
|
| 39 |
+
Liste, die selbstsicher antwortet, ist schlimmer als keine.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
import json
|
| 45 |
+
import logging
|
| 46 |
+
import re
|
| 47 |
+
from dataclasses import dataclass, field
|
| 48 |
+
from datetime import date, datetime
|
| 49 |
+
from pathlib import Path
|
| 50 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 51 |
+
|
| 52 |
+
from answer_schema import SHORT_ANSWER_HEADING, split_sections
|
| 53 |
+
|
| 54 |
+
logger = logging.getLogger(__name__)
|
| 55 |
+
|
| 56 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 57 |
+
|
| 58 |
+
# Ab hier gilt der Snapshot als überholt und der Befund sagt das. Der G-BA
|
| 59 |
+
# beschließt Ergänzungen unregelmäßig, in der Praxis wenige Male im Jahr; ein
|
| 60 |
+
# halbes Jahr ohne Abgleich ist die Grenze, ab der eine Auskunft ohne Warnung
|
| 61 |
+
# nicht mehr vertretbar ist.
|
| 62 |
+
VERALTET_AB_TAGEN = 180
|
| 63 |
+
|
| 64 |
+
# Die Frage muss überhaupt vom Austausch handeln. Regelbasiert wie
|
| 65 |
+
# `corpus_router`: eine Substring-Prüfung entscheidet das exakt, ein
|
| 66 |
+
# Modellaufruf brächte Latenz und Nichtdeterminismus an eine Stelle, an der
|
| 67 |
+
# Verlässlichkeit der ganze Zweck ist.
|
| 68 |
+
AUSTAUSCH_SIGNALE: tuple[str, ...] = (
|
| 69 |
+
"aut idem",
|
| 70 |
+
"aut-idem",
|
| 71 |
+
# "tausch" statt "austausch": es trägt auch "ausgetauscht" (aus-ge-TAUSCH-t),
|
| 72 |
+
# das die längere Form verfehlt hätte.
|
| 73 |
+
"tausch",
|
| 74 |
+
"ersetzen",
|
| 75 |
+
"ersetzt",
|
| 76 |
+
"ersetzung",
|
| 77 |
+
"substitut",
|
| 78 |
+
"wirkstoffgleich",
|
| 79 |
+
"importarzneimittel",
|
| 80 |
+
"rabattvertrag",
|
| 81 |
+
"rabattarzneimittel",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Darreichungsformen, die in einer Frage vorkommen können. Gebraucht, um zu
|
| 85 |
+
# erkennen, dass nach einer *anderen* Form gefragt wurde als die Liste führt:
|
| 86 |
+
# Ciclosporin steht dort mit „Lösung zum Einnehmen" und „Weichkapseln" — eine
|
| 87 |
+
# Infusionslösung ist davon nicht erfasst, und ein pauschales „ausgeschlossen"
|
| 88 |
+
# wäre an dieser Stelle schlicht falsch.
|
| 89 |
+
DARREICHUNGSFORMEN: tuple[str, ...] = (
|
| 90 |
+
"lösung zum einnehmen", "transdermale pflaster", "hartkapseln, retardiert",
|
| 91 |
+
"retardtabletten", "filmtabletten", "brausetabletten", "schmelztabletten",
|
| 92 |
+
"weichkapseln", "hartkapseln", "infusionslösung", "injektionslösung",
|
| 93 |
+
"augentropfen", "augensalbe", "zäpfchen", "suppositorien", "granulat",
|
| 94 |
+
"tabletten", "kapseln", "pflaster", "dragees", "tropfen", "salbe", "creme",
|
| 95 |
+
"gel", "saft", "sirup", "pulver", "spray", "inhalat", "ampullen",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Endungen, an denen ein Wirkstoffname erkennbar ist. Nur dafür da, eine
|
| 99 |
+
# Negativauskunft konkret zu machen („für ‚Simvastatin' ist nichts gelistet")
|
| 100 |
+
# statt bloß allgemein. Eine Lücke in dieser Liste kostet nur die konkrete
|
| 101 |
+
# Nennung, nie die Richtigkeit: ohne Kandidaten wird allgemeiner formuliert.
|
| 102 |
+
INN_ENDUNGEN: tuple[str, ...] = (
|
| 103 |
+
"cillin", "mycin", "floxacin", "prazol", "sartan", "pril", "olol", "statin",
|
| 104 |
+
"dipin", "tidin", "azepam", "barbital", "phyllin", "codon", "morphin",
|
| 105 |
+
"fentanil", "tinib", "zumab", "ximab", "umab", "cyclin", "conazol",
|
| 106 |
+
"gliptin", "glitazon", "formin", "parin", "coumon", "xaban", "gatran",
|
| 107 |
+
"setron", "triptan", "profen", "oxicam", "olimus", "sporin", "limus",
|
| 108 |
+
"thyroxin", "valproat", "phenidat", "toin", "midon", "oxin", "pam", "zepin",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Großgeschriebene Wörter, die in solchen Fragen laufend vorkommen und keine
|
| 112 |
+
# Wirkstoffe sind.
|
| 113 |
+
KEINE_WIRKSTOFFE: frozenset[str] = frozenset(
|
| 114 |
+
{
|
| 115 |
+
"apotheke", "arzneimittel", "fertigarzneimittel", "rabattvertrag",
|
| 116 |
+
"rabattverträge", "substitutionsausschluss", "substitutionsausschlussliste",
|
| 117 |
+
"darreichungsform", "darreichungsformen", "wirkstoff", "krankenkasse",
|
| 118 |
+
"retaxation", "verordnung", "versicherte", "anlage", "richtlinie",
|
| 119 |
+
"rahmenvertrag", "tabletten", "retardtabletten", "hartkapseln",
|
| 120 |
+
"weichkapseln", "pflaster", "kapseln", "lösung", "import",
|
| 121 |
+
"importarzneimittel", "generikum", "generika", "packung",
|
| 122 |
+
}
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@dataclass(frozen=True)
|
| 127 |
+
class Treffer:
|
| 128 |
+
"""Ein Listeneintrag, wie er für die Antwort gebraucht wird."""
|
| 129 |
+
|
| 130 |
+
wirkstoff: str
|
| 131 |
+
darreichungsform: str
|
| 132 |
+
regel: str
|
| 133 |
+
bedingung: Optional[str]
|
| 134 |
+
hinweis: Optional[str]
|
| 135 |
+
seite: Optional[int]
|
| 136 |
+
|
| 137 |
+
@property
|
| 138 |
+
def status(self) -> str:
|
| 139 |
+
if self.regel == "ausschluss_zwischen_varianten":
|
| 140 |
+
return "ausschluss_zwischen_varianten"
|
| 141 |
+
return "ausschluss_bedingt" if self.bedingung else "ausschluss"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@dataclass
|
| 145 |
+
class Befund:
|
| 146 |
+
"""Ergebnis einer Prüfung. `status` ist der Gesamtstatus über alle Treffer."""
|
| 147 |
+
|
| 148 |
+
status: str
|
| 149 |
+
gefragter_wirkstoff: Optional[str] = None
|
| 150 |
+
treffer: List[Treffer] = field(default_factory=list)
|
| 151 |
+
gefragte_darreichungsform: Optional[str] = None
|
| 152 |
+
# passend | unspezifischer | abweichend | nicht_genannt — siehe `_form_bezug`
|
| 153 |
+
form_bezug: str = "nicht_genannt"
|
| 154 |
+
stand: str = ""
|
| 155 |
+
stand_iso: str = ""
|
| 156 |
+
veraltet: bool = False
|
| 157 |
+
alter_tage: Optional[int] = None
|
| 158 |
+
fundstelle: str = ""
|
| 159 |
+
url: str = ""
|
| 160 |
+
grund: str = ""
|
| 161 |
+
|
| 162 |
+
@property
|
| 163 |
+
def ist_belastbar(self) -> bool:
|
| 164 |
+
"""True, wenn der Befund eine Auskunft trägt (positiv oder negativ)."""
|
| 165 |
+
return self.status in {
|
| 166 |
+
"ausschluss",
|
| 167 |
+
"ausschluss_bedingt",
|
| 168 |
+
"ausschluss_zwischen_varianten",
|
| 169 |
+
"nicht_gelistet",
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 173 |
+
return {
|
| 174 |
+
"status": self.status,
|
| 175 |
+
"gefragter_wirkstoff": self.gefragter_wirkstoff,
|
| 176 |
+
"treffer": [
|
| 177 |
+
{
|
| 178 |
+
"wirkstoff": t.wirkstoff,
|
| 179 |
+
"darreichungsform": t.darreichungsform,
|
| 180 |
+
"regel": t.regel,
|
| 181 |
+
"bedingung": t.bedingung,
|
| 182 |
+
"seite": t.seite,
|
| 183 |
+
}
|
| 184 |
+
for t in self.treffer
|
| 185 |
+
],
|
| 186 |
+
"gefragte_darreichungsform": self.gefragte_darreichungsform,
|
| 187 |
+
"form_bezug": self.form_bezug,
|
| 188 |
+
"stand": self.stand,
|
| 189 |
+
"veraltet": self.veraltet,
|
| 190 |
+
"alter_tage": self.alter_tage,
|
| 191 |
+
"fundstelle": self.fundstelle,
|
| 192 |
+
"url": self.url,
|
| 193 |
+
"grund": self.grund,
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ---------------------------------------------------------------------------
|
| 198 |
+
# Datenzugriff
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
|
| 201 |
+
def load_liste(path: Path) -> Dict[str, Any]:
|
| 202 |
+
"""Snapshot lesen und cachen. Eine fehlende Datei ist kein Absturz."""
|
| 203 |
+
key = str(path)
|
| 204 |
+
if key in _CACHE:
|
| 205 |
+
return _CACHE[key]
|
| 206 |
+
|
| 207 |
+
data: Dict[str, Any] = {}
|
| 208 |
+
try:
|
| 209 |
+
if path.exists():
|
| 210 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 211 |
+
except Exception as exc: # noqa: BLE001 - ein defekter Snapshot darf keine Antwort verhindern.
|
| 212 |
+
logger.warning("Substitutionsausschlussliste nicht lesbar: %s (%s)", path, exc)
|
| 213 |
+
data = {}
|
| 214 |
+
|
| 215 |
+
_CACHE[key] = data
|
| 216 |
+
return data
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
_MONATE = {
|
| 220 |
+
"januar": 1, "februar": 2, "märz": 3, "maerz": 3, "april": 4, "mai": 5,
|
| 221 |
+
"juni": 6, "juli": 7, "august": 8, "september": 9, "oktober": 10,
|
| 222 |
+
"november": 11, "dezember": 12,
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _stand_iso(stand: str) -> str:
|
| 227 |
+
"""'15. Mai 2026' -> '2026-05-15'. Leer, wenn nicht deutbar."""
|
| 228 |
+
text = " ".join(str(stand or "").split())
|
| 229 |
+
match = re.match(r"^(\d{1,2})\.\s*([A-Za-zÄÖÜäöü]+)\s*(\d{4})$", text)
|
| 230 |
+
if match:
|
| 231 |
+
monat = _MONATE.get(match.group(2).lower())
|
| 232 |
+
if monat:
|
| 233 |
+
return f"{int(match.group(3)):04d}-{monat:02d}-{int(match.group(1)):02d}"
|
| 234 |
+
match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", text)
|
| 235 |
+
return text if match else ""
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
# Erkennung in der Frage
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
def fragt_nach_austausch(question: str) -> bool:
|
| 243 |
+
haystack = " ".join(str(question or "").lower().split())
|
| 244 |
+
return any(signal in haystack for signal in AUSTAUSCH_SIGNALE)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _namen_index(daten: Dict[str, Any]) -> List[tuple[str, str]]:
|
| 248 |
+
"""(suchbegriff, kanonischer Wirkstoff), längste Begriffe zuerst.
|
| 249 |
+
|
| 250 |
+
Die Sortierung entscheidet über die Richtigkeit: „Levothyroxin-Natrium +
|
| 251 |
+
Kaliumiodid (fixe Kombination)" muss vor „Levothyroxin-Natrium" geprüft
|
| 252 |
+
werden, sonst gewinnt der kürzere Name und die fixe Kombination fällt weg.
|
| 253 |
+
"""
|
| 254 |
+
paare: List[tuple[str, str]] = []
|
| 255 |
+
for eintrag in daten.get("eintraege") or []:
|
| 256 |
+
name = str(eintrag.get("wirkstoff") or "").strip()
|
| 257 |
+
if not name:
|
| 258 |
+
continue
|
| 259 |
+
paare.append((name, name))
|
| 260 |
+
for synonym in eintrag.get("synonyme") or []:
|
| 261 |
+
if str(synonym).strip():
|
| 262 |
+
paare.append((str(synonym).strip(), name))
|
| 263 |
+
# Klammerzusätze sind in einer Frage nie wörtlich enthalten; der Name ohne
|
| 264 |
+
# sie bleibt trotzdem suchbar.
|
| 265 |
+
for name, kanonisch in list(paare):
|
| 266 |
+
ohne = re.sub(r"\s*\([^)]*\)\s*", " ", name).strip()
|
| 267 |
+
if ohne and ohne != name:
|
| 268 |
+
paare.append((ohne, kanonisch))
|
| 269 |
+
return sorted(set(paare), key=lambda p: -len(p[0]))
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def erkannte_wirkstoffe(question: str, daten: Dict[str, Any]) -> List[str]:
|
| 273 |
+
"""Gelistete Wirkstoffe, die die Frage nennt — kanonisch, ohne Dubletten."""
|
| 274 |
+
haystack = " ".join(str(question or "").lower().split())
|
| 275 |
+
gefunden: List[str] = []
|
| 276 |
+
verbraucht: List[tuple[int, int]] = []
|
| 277 |
+
|
| 278 |
+
for begriff, kanonisch in _namen_index(daten):
|
| 279 |
+
for match in re.finditer(rf"(?<![\wäöüß]){re.escape(begriff.lower())}(?![\wäöüß])", haystack):
|
| 280 |
+
span = (match.start(), match.end())
|
| 281 |
+
# Ein längerer Name hat diese Stelle schon belegt: „Levothyroxin-
|
| 282 |
+
# Natrium" darf nicht zusätzlich als „Levothyroxin" zählen.
|
| 283 |
+
if any(s <= span[0] and span[1] <= e for s, e in verbraucht):
|
| 284 |
+
continue
|
| 285 |
+
verbraucht.append(span)
|
| 286 |
+
if kanonisch not in gefunden:
|
| 287 |
+
gefunden.append(kanonisch)
|
| 288 |
+
return gefunden
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def wirkstoff_kandidat(question: str) -> Optional[str]:
|
| 292 |
+
"""Ein wirkstoffartiges Wort aus der Frage, nur zur Formulierung.
|
| 293 |
+
|
| 294 |
+
Dient allein dazu, eine Negativauskunft konkret zu benennen. Findet die
|
| 295 |
+
Heuristik nichts, wird allgemeiner formuliert — nie falscher.
|
| 296 |
+
"""
|
| 297 |
+
for wort in re.findall(r"\b([A-ZÄÖÜ][\wäöüß\-]{5,})\b", str(question or "")):
|
| 298 |
+
if wort.lower() in KEINE_WIRKSTOFFE:
|
| 299 |
+
continue
|
| 300 |
+
if wort.lower().endswith(INN_ENDUNGEN):
|
| 301 |
+
return wort
|
| 302 |
+
return None
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def genannte_darreichungsform(question: str) -> Optional[str]:
|
| 306 |
+
"""Die spezifischste Darreichungsform, die die Frage nennt."""
|
| 307 |
+
haystack = " ".join(str(question or "").lower().split())
|
| 308 |
+
for form in sorted(DARREICHUNGSFORMEN, key=len, reverse=True):
|
| 309 |
+
if form in haystack:
|
| 310 |
+
return form
|
| 311 |
+
return None
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def _form_bezug(genannt: Optional[str], gelistet: Sequence[str]) -> str:
|
| 315 |
+
"""Verhältnis der erfragten zur gelisteten Darreichungsform.
|
| 316 |
+
|
| 317 |
+
Drei Fälle mit verschiedener Rechtsfolge, und die Mitte ist die wichtige:
|
| 318 |
+
|
| 319 |
+
* ``passend`` — identisch, der Eintrag greift.
|
| 320 |
+
* ``unspezifischer`` — die Frage nennt „Tabletten", die Liste
|
| 321 |
+
„Retardtabletten". Nicht dasselbe: eine unretardierte Tablette desselben
|
| 322 |
+
Wirkstoffs ist nicht vom Ausschluss erfasst. Wird offengelegt statt
|
| 323 |
+
entschieden.
|
| 324 |
+
* ``abweichend`` — kein Bezug, z. B. Infusionslösung gegen „Lösung zum
|
| 325 |
+
Einnehmen". Der Eintrag trägt die Frage dann nicht.
|
| 326 |
+
"""
|
| 327 |
+
if not genannt:
|
| 328 |
+
return "nicht_genannt"
|
| 329 |
+
|
| 330 |
+
gefragt = " ".join(genannt.lower().split())
|
| 331 |
+
formen = [" ".join(str(f or "").lower().split()) for f in gelistet]
|
| 332 |
+
|
| 333 |
+
if any(gefragt == f for f in formen):
|
| 334 |
+
return "passend"
|
| 335 |
+
if any(gefragt in f or f in gefragt for f in formen):
|
| 336 |
+
return "unspezifischer"
|
| 337 |
+
return "abweichend"
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
# Prüfung
|
| 342 |
+
# ---------------------------------------------------------------------------
|
| 343 |
+
|
| 344 |
+
def pruefe(
|
| 345 |
+
question: str = "",
|
| 346 |
+
*,
|
| 347 |
+
path: Path,
|
| 348 |
+
wirkstoff: Optional[str] = None,
|
| 349 |
+
darreichungsform: Optional[str] = None,
|
| 350 |
+
kandidat: Optional[str] = None,
|
| 351 |
+
heute: Optional[date] = None,
|
| 352 |
+
) -> Befund:
|
| 353 |
+
"""Teil B für die Frage (oder den übergebenen Wirkstoff) nachschlagen.
|
| 354 |
+
|
| 355 |
+
`kandidat` ist ein Wirkstoffname, den ein anderer Nachschlag bereits sicher
|
| 356 |
+
erkannt hat — Teil A der Anlage führt rund 170 Wirkstoffe, Teil B zwanzig.
|
| 357 |
+
Ohne diese Übergabe könnte für „Ambroxol" keine Negativauskunft ergehen,
|
| 358 |
+
obwohl feststeht, dass es ein Wirkstoff ist: die Endungsheuristik in
|
| 359 |
+
`wirkstoff_kandidat` erkennt ihn nicht, und der Befund fiele auf
|
| 360 |
+
„nicht prüfbar" zurück, statt „steht nicht auf der Ausschlussliste" zu sagen.
|
| 361 |
+
"""
|
| 362 |
+
daten = load_liste(path)
|
| 363 |
+
if not daten or not (daten.get("eintraege") or []):
|
| 364 |
+
return Befund(status="nicht_pruefbar", grund="snapshot_fehlt_oder_leer")
|
| 365 |
+
|
| 366 |
+
quelle = daten.get("quelle") or {}
|
| 367 |
+
stand = str(quelle.get("stand") or "")
|
| 368 |
+
stand_iso = _stand_iso(stand)
|
| 369 |
+
fundstelle = f"{quelle.get('dokument', 'AM-RL Anlage VII')} Teil B"
|
| 370 |
+
url = str(quelle.get("url") or "")
|
| 371 |
+
|
| 372 |
+
if not stand:
|
| 373 |
+
return Befund(status="nicht_pruefbar", grund="stand_unbekannt", fundstelle=fundstelle, url=url)
|
| 374 |
+
|
| 375 |
+
alter_tage: Optional[int] = None
|
| 376 |
+
veraltet = False
|
| 377 |
+
if stand_iso:
|
| 378 |
+
try:
|
| 379 |
+
gemessen = (heute or date.today()) - datetime.strptime(stand_iso, "%Y-%m-%d").date()
|
| 380 |
+
alter_tage = max(0, gemessen.days)
|
| 381 |
+
veraltet = alter_tage > VERALTET_AB_TAGEN
|
| 382 |
+
except ValueError:
|
| 383 |
+
alter_tage = None
|
| 384 |
+
|
| 385 |
+
def _fertig(befund: Befund) -> Befund:
|
| 386 |
+
befund.stand = stand
|
| 387 |
+
befund.stand_iso = stand_iso
|
| 388 |
+
befund.alter_tage = alter_tage
|
| 389 |
+
befund.veraltet = veraltet
|
| 390 |
+
befund.fundstelle = fundstelle
|
| 391 |
+
befund.url = url
|
| 392 |
+
return befund
|
| 393 |
+
|
| 394 |
+
if wirkstoff:
|
| 395 |
+
namen = erkannte_wirkstoffe(wirkstoff, daten)
|
| 396 |
+
gefragt = wirkstoff.strip()
|
| 397 |
+
else:
|
| 398 |
+
if not fragt_nach_austausch(question):
|
| 399 |
+
return _fertig(Befund(status="nicht_einschlaegig", grund="frage_ohne_austauschbezug"))
|
| 400 |
+
namen = erkannte_wirkstoffe(question, daten)
|
| 401 |
+
gefragt = namen[0] if namen else (wirkstoff_kandidat(question) or (kandidat or "").strip())
|
| 402 |
+
|
| 403 |
+
if not namen:
|
| 404 |
+
if not gefragt:
|
| 405 |
+
# Ohne erkennbaren Wirkstoff gibt es nichts nachzuschlagen. Eine
|
| 406 |
+
# Auskunft „nichts gelistet" wäre hier eine Aussage über eine Frage,
|
| 407 |
+
# die gar keinen Wirkstoff nennt. Der Reichweiten-Hinweis zu den
|
| 408 |
+
# AM-RL-Anlagen greift in diesem Fall und sagt das Richtige.
|
| 409 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="kein_wirkstoff_erkannt"))
|
| 410 |
+
return _fertig(
|
| 411 |
+
Befund(
|
| 412 |
+
status="nicht_gelistet",
|
| 413 |
+
gefragter_wirkstoff=gefragt,
|
| 414 |
+
grund="wirkstoff_nicht_in_teil_b",
|
| 415 |
+
)
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
treffer = [
|
| 419 |
+
Treffer(
|
| 420 |
+
wirkstoff=str(e.get("wirkstoff") or ""),
|
| 421 |
+
darreichungsform=str(e.get("darreichungsform") or ""),
|
| 422 |
+
regel=str(e.get("regel") or "ausschluss"),
|
| 423 |
+
bedingung=(str(e["bedingung"]) if e.get("bedingung") else None),
|
| 424 |
+
hinweis=(str(e["hinweis"]) if e.get("hinweis") else None),
|
| 425 |
+
seite=e.get("seite"),
|
| 426 |
+
)
|
| 427 |
+
for e in daten["eintraege"]
|
| 428 |
+
if str(e.get("wirkstoff") or "") in namen
|
| 429 |
+
]
|
| 430 |
+
|
| 431 |
+
gefragte_form = darreichungsform or genannte_darreichungsform(question)
|
| 432 |
+
bezug = _form_bezug(gefragte_form, [t.darreichungsform for t in treffer])
|
| 433 |
+
|
| 434 |
+
if bezug == "passend":
|
| 435 |
+
gefragt_norm = " ".join(str(gefragte_form).lower().split())
|
| 436 |
+
treffer = [t for t in treffer if " ".join(t.darreichungsform.lower().split()) == gefragt_norm] or treffer
|
| 437 |
+
|
| 438 |
+
rangfolge = ("ausschluss", "ausschluss_bedingt", "ausschluss_zwischen_varianten")
|
| 439 |
+
status = min((t.status for t in treffer), key=rangfolge.index)
|
| 440 |
+
|
| 441 |
+
return _fertig(
|
| 442 |
+
Befund(
|
| 443 |
+
status=status,
|
| 444 |
+
gefragter_wirkstoff=namen[0],
|
| 445 |
+
treffer=treffer,
|
| 446 |
+
gefragte_darreichungsform=gefragte_form,
|
| 447 |
+
form_bezug=bezug,
|
| 448 |
+
)
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# ---------------------------------------------------------------------------
|
| 453 |
+
# Darstellung
|
| 454 |
+
# ---------------------------------------------------------------------------
|
| 455 |
+
|
| 456 |
+
_EINLEITUNG = "Substitutionsausschluss — deterministische Listenprüfung"
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def _gross(text: Optional[str]) -> str:
|
| 460 |
+
"""Erstes Zeichen groß. Das Formularvokabular ist kleingeschrieben, die
|
| 461 |
+
Ausgabe geht an einen Nutzer."""
|
| 462 |
+
value = str(text or "").strip()
|
| 463 |
+
return value[:1].upper() + value[1:] if value else ""
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def befund_block(befund: Befund) -> str:
|
| 467 |
+
"""Der Befund als Textblock, der der Antwort vorangestellt wird.
|
| 468 |
+
|
| 469 |
+
Bewusst von Code formuliert und nicht vom Modell: der Wert dieser Prüfung
|
| 470 |
+
liegt in ihrer Verlässlichkeit, und ein Umweg über die Sprachgenerierung
|
| 471 |
+
gäbe genau die wieder her, die sie beseitigt.
|
| 472 |
+
"""
|
| 473 |
+
if not befund.ist_belastbar:
|
| 474 |
+
return ""
|
| 475 |
+
|
| 476 |
+
stand = f"Stand {befund.stand}"
|
| 477 |
+
kopf = f"{_EINLEITUNG} ({befund.fundstelle}, {stand}):"
|
| 478 |
+
zeilen: List[str] = [kopf]
|
| 479 |
+
|
| 480 |
+
if befund.status == "nicht_gelistet":
|
| 481 |
+
if befund.gefragter_wirkstoff:
|
| 482 |
+
zeilen.append(
|
| 483 |
+
f"„{befund.gefragter_wirkstoff}“ ist in Teil B nicht gelistet. Ein "
|
| 484 |
+
"Substitutionsausschluss nach § 129 Absatz 1a Satz 2 SGB V besteht dafür nicht."
|
| 485 |
+
)
|
| 486 |
+
else:
|
| 487 |
+
zeilen.append(
|
| 488 |
+
"Die Frage nennt keinen der in Teil B gelisteten Wirkstoffe. Für die "
|
| 489 |
+
"genannten Arzneimittel besteht danach kein Substitutionsausschluss nach "
|
| 490 |
+
"§ 129 Absatz 1a Satz 2 SGB V."
|
| 491 |
+
)
|
| 492 |
+
zeilen.append(
|
| 493 |
+
"Die Liste ist wirkstoffbezogen: bei einem Handelsnamen ist der Wirkstoff (INN) "
|
| 494 |
+
"zu prüfen, bevor auf diese Auskunft abgestellt wird."
|
| 495 |
+
)
|
| 496 |
+
else:
|
| 497 |
+
formen = "; ".join(dict.fromkeys(t.darreichungsform for t in befund.treffer if t.darreichungsform))
|
| 498 |
+
zeilen.append(f"{befund.gefragter_wirkstoff} ist in Teil B gelistet — {formen}.")
|
| 499 |
+
|
| 500 |
+
# Die Einschränkung steht VOR der Rechtsfolge. Andersherum behauptet der
|
| 501 |
+
# Block erst einen Ausschluss und nimmt ihn im nächsten Satz zurück.
|
| 502 |
+
if befund.form_bezug == "abweichend":
|
| 503 |
+
zeilen.append(
|
| 504 |
+
f"Die Frage nennt „{_gross(befund.gefragte_darreichungsform)}“; diese Form "
|
| 505 |
+
"führt die Liste für diesen Wirkstoff nicht. Die folgende Rechtsfolge gilt "
|
| 506 |
+
"deshalb nur für die oben genannten Formen. Die Vorbemerkung zu Teil B "
|
| 507 |
+
"erfasst allerdings auch nicht aufgeführte Bezeichnungen, soweit sie den "
|
| 508 |
+
"definitorischen Voraussetzungen der gelisteten Standard Terms entsprechen; "
|
| 509 |
+
"das ist gesondert zu prüfen."
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
if befund.status == "ausschluss":
|
| 513 |
+
zeilen.append(
|
| 514 |
+
"Die Ersetzung durch ein wirkstoffgleiches Arzneimittel ist damit nach "
|
| 515 |
+
"§ 129 Absatz 1a Satz 2 SGB V ausgeschlossen."
|
| 516 |
+
)
|
| 517 |
+
elif befund.status == "ausschluss_bedingt":
|
| 518 |
+
bedingungen = "; ".join(dict.fromkeys(t.bedingung or "" for t in befund.treffer if t.bedingung))
|
| 519 |
+
zeilen.append(
|
| 520 |
+
"Der Ausschluss gilt nur unter der Einschränkung der Liste: "
|
| 521 |
+
f"{bedingungen}. Ob sie im konkreten Fall erfüllt ist, ist am Präparat zu prüfen."
|
| 522 |
+
)
|
| 523 |
+
else:
|
| 524 |
+
bedingungen = "; ".join(dict.fromkeys(t.bedingung or "" for t in befund.treffer if t.bedingung))
|
| 525 |
+
zeilen.append(
|
| 526 |
+
"Der Ausschluss ist hier nicht absolut: ausgeschlossen ist nur der Austausch "
|
| 527 |
+
f"zwischen unterschiedlichen Varianten — {bedingungen} Innerhalb derselben "
|
| 528 |
+
"Variante bleibt die Ersetzung zulässig."
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
hinweise = [t.hinweis for t in befund.treffer if t.hinweis]
|
| 532 |
+
if hinweise:
|
| 533 |
+
zeilen.append(f"Zum Wirkstoff: {hinweise[0]}.")
|
| 534 |
+
|
| 535 |
+
if befund.form_bezug == "unspezifischer":
|
| 536 |
+
zeilen.append(
|
| 537 |
+
f"Die Liste benennt die Form genauer als die Frage („{_gross(befund.gefragte_darreichungsform)}“ "
|
| 538 |
+
f"gegenüber „{formen}“). Ob das verordnete Präparat unter den gelisteten "
|
| 539 |
+
"Eintrag fällt, ist am Präparat zu prüfen."
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
if befund.veraltet:
|
| 543 |
+
zeilen.append(
|
| 544 |
+
f"Achtung Aktualität: Dieser Auszug ist {befund.alter_tage} Tage alt. Der G-BA "
|
| 545 |
+
"ergänzt Teil B durch Beschluss; vor einer Entscheidung ist die geltende Fassung "
|
| 546 |
+
"abzugleichen."
|
| 547 |
+
)
|
| 548 |
+
if befund.url:
|
| 549 |
+
zeilen.append(f"Quelle: {befund.url}")
|
| 550 |
+
|
| 551 |
+
return "\n".join(zeilen)
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
def verdikt(befund: Befund) -> str:
|
| 555 |
+
"""Die Antwort auf die Frage in einem Satz, aus den Daten formuliert."""
|
| 556 |
+
if not befund.ist_belastbar:
|
| 557 |
+
return ""
|
| 558 |
+
|
| 559 |
+
if befund.status == "nicht_gelistet":
|
| 560 |
+
wer = f"„{befund.gefragter_wirkstoff}“" if befund.gefragter_wirkstoff else "Der erfragte Wirkstoff"
|
| 561 |
+
return (
|
| 562 |
+
f"{wer} steht nicht in Teil B der AM-RL-Anlage VII; ein Substitutionsausschluss "
|
| 563 |
+
f"nach § 129 Absatz 1a Satz 2 SGB V besteht dafür nicht (Listenprüfung oben, "
|
| 564 |
+
f"Stand {befund.stand})."
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
formen = "; ".join(dict.fromkeys(t.darreichungsform for t in befund.treffer if t.darreichungsform))
|
| 568 |
+
bedingungen = "; ".join(dict.fromkeys(t.bedingung or "" for t in befund.treffer if t.bedingung))
|
| 569 |
+
|
| 570 |
+
if befund.status == "ausschluss_zwischen_varianten":
|
| 571 |
+
kern = (
|
| 572 |
+
f"Nur eingeschränkt: {befund.gefragter_wirkstoff} ({formen}) ist in Teil B der "
|
| 573 |
+
f"AM-RL-Anlage VII gelistet, ausgeschlossen ist aber allein der Austausch "
|
| 574 |
+
f"zwischen unterschiedlichen Varianten — {bedingungen} Innerhalb derselben "
|
| 575 |
+
f"Variante bleibt die Ersetzung zulässig"
|
| 576 |
+
)
|
| 577 |
+
elif befund.status == "ausschluss_bedingt":
|
| 578 |
+
kern = (
|
| 579 |
+
f"Nur bedingt: {befund.gefragter_wirkstoff} ({formen}) ist in Teil B der "
|
| 580 |
+
f"AM-RL-Anlage VII gelistet, der Ausschluss greift jedoch nur {bedingungen}"
|
| 581 |
+
)
|
| 582 |
+
else:
|
| 583 |
+
kern = (
|
| 584 |
+
f"Nein. {befund.gefragter_wirkstoff} ({formen}) ist in Teil B der AM-RL-Anlage VII "
|
| 585 |
+
f"gelistet; die Ersetzung durch ein wirkstoffgleiches Arzneimittel ist nach "
|
| 586 |
+
f"§ 129 Absatz 1a Satz 2 SGB V ausgeschlossen"
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
if befund.form_bezug == "abweichend":
|
| 590 |
+
kern += (
|
| 591 |
+
f". Das gilt für die gelisteten Formen — die erfragte Form "
|
| 592 |
+
f"„{_gross(befund.gefragte_darreichungsform)}“ führt die Liste nicht"
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
return f"{kern} (Listenprüfung oben, Stand {befund.stand})."
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def antwort_mit_befunden(answer: str, *, bloecke: Sequence[str], verdikt: str = "") -> str:
|
| 599 |
+
"""Listenbefunde voranstellen und die Kurzantwort daran angleichen.
|
| 600 |
+
|
| 601 |
+
Der gemeinsame Mechanismus für beide Teile der Anlage VII. Warum die
|
| 602 |
+
Kurzantwort ersetzt wird und nicht nur ein Block davorgesetzt: das Modell
|
| 603 |
+
beantwortet dieselbe Frage darunter aus § 129 SGB V noch einmal und kommt
|
| 604 |
+
zur allgemeinen Regel, weil ihm die Liste fehlt, die die Ausnahme trägt.
|
| 605 |
+
Gemessen an der ersten Livefrage stand „ist ausgeschlossen" direkt über „ist
|
| 606 |
+
möglich". Beide Sätze stimmen in ihrem Bezugsrahmen, aber nichts im Text
|
| 607 |
+
markiert den Wechsel — dieselbe Lage, die
|
| 608 |
+
`corpus_amendments.reconcile_negative_answer` für spätere
|
| 609 |
+
Änderungsvereinbarungen auflöst, und sie wird hier genauso aufgelöst.
|
| 610 |
+
"""
|
| 611 |
+
vorhanden = [b for b in bloecke if b and b.strip()]
|
| 612 |
+
if not vorhanden:
|
| 613 |
+
return answer
|
| 614 |
+
|
| 615 |
+
text = (answer or "").strip()
|
| 616 |
+
|
| 617 |
+
# Das Verhältnis der beiden Teile explizit machen. Die Kurzantwort unten
|
| 618 |
+
# wird angeglichen, die übrigen Abschnitte bleiben Modelltext — und der
|
| 619 |
+
# hedgt regelmäßig ("die Quellen liefern keine direkte Antwort", gemessen
|
| 620 |
+
# bei der Ambroxol-Frage), weil ihm die Liste fehlt. Ohne diesen Satz liest
|
| 621 |
+
# sich die Einordnung als Widerruf des Befundes drei Absätze darüber.
|
| 622 |
+
if text and verdikt:
|
| 623 |
+
vorhanden = list(vorhanden)
|
| 624 |
+
vorhanden[-1] += (
|
| 625 |
+
"\n\nDie Listenprüfung entscheidet die Zugehörigkeit abschließend. Die "
|
| 626 |
+
"nachfolgenden Abschnitte geben die allgemeine Regelung wieder und stehen "
|
| 627 |
+
"unter diesem Befund."
|
| 628 |
+
)
|
| 629 |
+
|
| 630 |
+
if text and verdikt:
|
| 631 |
+
blocks = split_sections(text)
|
| 632 |
+
if any(heading == SHORT_ANSWER_HEADING for heading, _ in blocks):
|
| 633 |
+
teile: List[str] = []
|
| 634 |
+
for heading, body_lines in blocks:
|
| 635 |
+
body = "\n".join(body_lines).strip()
|
| 636 |
+
if heading is None:
|
| 637 |
+
if body:
|
| 638 |
+
teile.append(body)
|
| 639 |
+
elif heading == SHORT_ANSWER_HEADING:
|
| 640 |
+
teile.append(f"{heading}:\n{verdikt}")
|
| 641 |
+
else:
|
| 642 |
+
teile.append(f"{heading}:\n{body}" if body else f"{heading}:")
|
| 643 |
+
text = "\n\n".join(t for t in teile if t.strip())
|
| 644 |
+
|
| 645 |
+
kopf = "\n\n".join(vorhanden)
|
| 646 |
+
return f"{kopf}\n\n{text}" if text else kopf
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def in_antwort_einsetzen(answer: str, befund: Befund) -> str:
|
| 650 |
+
"""Befund voranstellen und die Kurzantwort des Modells daran angleichen.
|
| 651 |
+
|
| 652 |
+
Beides gehört zusammen. Der Block allein reicht nicht: das Modell
|
| 653 |
+
beantwortet dieselbe Frage darunter aus Rahmenvertrag und Gesetz noch einmal
|
| 654 |
+
und kommt dabei zur allgemeinen Regel („Ersetzung ist vorzunehmen, wenn …"),
|
| 655 |
+
weil ihm die Liste fehlt, die die Ausnahme trägt. Gemessen an der ersten
|
| 656 |
+
Livefrage stand dann „ist ausgeschlossen" direkt über „ist möglich".
|
| 657 |
+
|
| 658 |
+
Beide Sätze sind in ihrem Bezugsrahmen richtig, aber nichts im Text markiert
|
| 659 |
+
den Wechsel — dieselbe Lage, die `corpus_amendments.reconcile_negative_answer`
|
| 660 |
+
für spätere Änderungsvereinbarungen auflöst, und sie wird hier genauso
|
| 661 |
+
aufgelöst: die deterministische Prüfung setzt die Kurzantwort, die übrigen
|
| 662 |
+
Abschnitte des Modells bleiben als Einordnung stehen.
|
| 663 |
+
"""
|
| 664 |
+
return antwort_mit_befunden(answer, bloecke=[befund_block(befund)], verdikt=verdikt(befund))
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
# Rückwärtskompatibler Name für Aufrufer, die nur den Block voranstellen wollen.
|
| 668 |
+
def voranstellen(answer: str, block: str) -> str:
|
| 669 |
+
if not block:
|
| 670 |
+
return answer
|
| 671 |
+
text = (answer or "").strip()
|
| 672 |
+
return f"{block}\n\n{text}" if text else block
|
src/amrl_tabakentwoehnung.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arzneimittel zur Tabakentwöhnung nachschlagen (AM-RL Anlage IIa).
|
| 2 |
+
|
| 3 |
+
Anlage IIa ist die Gegenanlage zu Anlage II, und nur als Paar ergeben beide
|
| 4 |
+
einen Sinn. Anlage II schließt Arzneimittel zur Raucherentwöhnung nach § 34
|
| 5 |
+
Absatz 1 Satz 7 SGB V von der Versorgung aus; Anlage IIa nimmt einen Fall davon
|
| 6 |
+
wieder aus. Versicherte mit festgestellter schwerer Tabakabhängigkeit haben
|
| 7 |
+
nach § 34 Absatz 2 SGB V Anspruch auf eine **einmalige** Versorgung mit
|
| 8 |
+
Arzneimitteln zur Tabakentwöhnung im Rahmen evidenzbasierter Programme, und
|
| 9 |
+
welche Arzneimittel das sind, steht hier. Fußnote 1 der Anlage II verweist auf
|
| 10 |
+
genau dieses Blatt; bis dahin konnte `amrl_lifestyle` den Weg nur benennen, nicht
|
| 11 |
+
einlösen.
|
| 12 |
+
|
| 13 |
+
**Die Negativauskunft trägt hier — anders als bei Anlage II.** Das ist die
|
| 14 |
+
wichtigste Auskunft dieses Moduls. Anlage II nennt sich selbst eine „Übersicht",
|
| 15 |
+
und § 14 Absatz 2 AM-RL schließt „insbesondere" aus; aus einem Nichtvorkommen
|
| 16 |
+
folgt dort nichts. Anlage IIa ist das Gegenteil: § 14a Absatz 3 Satz 1 AM-RL
|
| 17 |
+
sagt „Die ausnahmsweise zur Tabakentwöhnung verordnungsfähigen Arzneimittel sind
|
| 18 |
+
in Anlage IIa aufgeführt", und die Vorbemerkung des Blattes wiederholt es für die
|
| 19 |
+
Tabelle. Eine Ausnahme reicht so weit, wie sie geschrieben ist. Bupropion
|
| 20 |
+
(Zyban) und Cytisin stehen in Anlage II unter der Nikotinabhängigkeit, in Anlage
|
| 21 |
+
IIa aber **nicht** — für sie greift die Ausnahme nicht, und das ist eine
|
| 22 |
+
belastbare Auskunft und keine Wissenslücke. Dieselbe Lage wie bei Anlage I
|
| 23 |
+
(§ 12 Absatz 10 AM-RL), nicht die von Anlage II, III und VIIa.
|
| 24 |
+
|
| 25 |
+
**Die Kombinationsregel ist die praktisch entscheidende Auskunft**, und ein
|
| 26 |
+
Befund, der nur die beiden Wirkstoffe nennt, lässt die Hälfte der Anlage weg:
|
| 27 |
+
|
| 28 |
+
Nicotin Kombination mit Vareniclin ausgeschlossen; untereinander nur,
|
| 29 |
+
wenn ein transdermales Pflaster dabei ist
|
| 30 |
+
Vareniclin Kombination mit Nicotin ausgeschlossen
|
| 31 |
+
|
| 32 |
+
Die dritte Spalte wird deshalb wörtlich mitgeführt und wörtlich gezeigt.
|
| 33 |
+
|
| 34 |
+
**Das Modul entscheidet den Fall nicht.** Die Anlage führt zwei Wirkstoffe; ob
|
| 35 |
+
verordnet werden darf, hängt an zwei Feststellungen, die keine Listenauskunft
|
| 36 |
+
sind: einer bestehenden schweren Tabakabhängigkeit nach den Kriterien des § 14a
|
| 37 |
+
Absatz 3 AM-RL (ICD-10-GM F17.2, Fagerströmtest) und der Anwendung im Rahmen
|
| 38 |
+
eines evidenzbasierten Programms, dessen Anforderungen § 14a Absatz 4 und Teil B
|
| 39 |
+
der Anlage regeln. Dazu kommt die Schranke des Gesetzes selbst: die Versorgung
|
| 40 |
+
ist einmalig, erneut frühestens drei Jahre nach Abschluss der Behandlung (§ 34
|
| 41 |
+
Absatz 2 Satz 2 SGB V). Der Befund benennt alle drei und subsumiert keine davon.
|
| 42 |
+
|
| 43 |
+
**Keine Fertigarzneimittel in diesem Modul.** Anlage IIa nennt keine — sie sagt
|
| 44 |
+
„alle marktverfügbaren Arzneimittel, sämtliche Wirkstärken". Champix und
|
| 45 |
+
Nicorette stehen in Anlage II, und `amrl_lifestyle` löst sie dort auf; der
|
| 46 |
+
Aufrufer reicht den erkannten Wirkstoff über `stoff` herein, statt dass hier eine
|
| 47 |
+
zweite Produktliste entstünde. Das ist dieselbe Übergabe, mit der
|
| 48 |
+
`amrl_substitution` den Kandidaten aus Teil A übernimmt.
|
| 49 |
+
|
| 50 |
+
Die Zustände:
|
| 51 |
+
|
| 52 |
+
gelistet Wirkstoff steht in Anlage IIa — ausnahmsweise
|
| 53 |
+
verordnungsfähig, unter den Voraussetzungen oben
|
| 54 |
+
nicht_gelistet Anlage IIa führt ihn nicht; die Ausnahme des § 34 Absatz 2
|
| 55 |
+
SGB V erfasst ihn nicht, es bleibt bei dem Ausschluss
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
from __future__ import annotations
|
| 59 |
+
|
| 60 |
+
import json
|
| 61 |
+
import logging
|
| 62 |
+
import re
|
| 63 |
+
from dataclasses import dataclass, field
|
| 64 |
+
from datetime import date, datetime
|
| 65 |
+
from pathlib import Path
|
| 66 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 67 |
+
|
| 68 |
+
# Geteilt statt ein siebtes Mal geschrieben: dieselbe Standangabe, dieselbe
|
| 69 |
+
# Frist, dieselbe Endungsheuristik für einen Wirkstoffnamen.
|
| 70 |
+
from amrl_substitution import VERALTET_AB_TAGEN, _stand_iso, wirkstoff_kandidat
|
| 71 |
+
|
| 72 |
+
logger = logging.getLogger(__name__)
|
| 73 |
+
|
| 74 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 75 |
+
|
| 76 |
+
# Die Frage muss von der Verordnungsfähigkeit handeln — dieselbe Schranke wie in
|
| 77 |
+
# den übrigen Listenmodulen. Ohne sie beantwortete eine Frage nach der Dosierung
|
| 78 |
+
# eines Nicotinpflasters eine Frage nach der Erstattung.
|
| 79 |
+
VERORDNUNGS_SIGNALE: Tuple[str, ...] = (
|
| 80 |
+
"verordnungsfähig",
|
| 81 |
+
"verordnungsfaehig",
|
| 82 |
+
"verordnungsausschl",
|
| 83 |
+
"erstattungsfähig",
|
| 84 |
+
"erstattungsfaehig",
|
| 85 |
+
"erstattung",
|
| 86 |
+
"ausgeschlossen",
|
| 87 |
+
"anspruch",
|
| 88 |
+
"zu lasten der",
|
| 89 |
+
"kassenrezept",
|
| 90 |
+
"auf kasse",
|
| 91 |
+
"grüne rezept",
|
| 92 |
+
"grünes rezept",
|
| 93 |
+
"privatrezept",
|
| 94 |
+
"selbstzahler",
|
| 95 |
+
"darf ich verordnen",
|
| 96 |
+
"verordnet werden",
|
| 97 |
+
"übernimmt die kasse",
|
| 98 |
+
"uebernimmt die kasse",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Das Indikationsgebiet, das dieses Blatt regelt. Zweiter Zugang neben dem
|
| 102 |
+
# Wirkstoff: „Welche Arzneimittel sind zur Tabakentwöhnung verordnungsfähig?"
|
| 103 |
+
# nennt keinen Stoff und ist trotzdem genau die Frage der Anlage.
|
| 104 |
+
GEBIET_SIGNALE: Tuple[str, ...] = (
|
| 105 |
+
"tabakentwöhnung",
|
| 106 |
+
"tabakentwoehnung",
|
| 107 |
+
"raucherentwöhnung",
|
| 108 |
+
"raucherentwoehnung",
|
| 109 |
+
"tabakabhängigkeit",
|
| 110 |
+
"tabakabhaengigkeit",
|
| 111 |
+
"nikotinabhängigkeit",
|
| 112 |
+
"nikotinabhaengigkeit",
|
| 113 |
+
"rauchstopp",
|
| 114 |
+
"rauchentwöhnung",
|
| 115 |
+
"rauchentwoehnung",
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# Die Anlage beim Namen genannt. Der Ausdruck darf dabei weder Anlage II noch
|
| 119 |
+
# Anlage III einfangen — das Gegenstück zu `amrl_lifestyle.RE_ANLAGE_II`.
|
| 120 |
+
RE_ANLAGE_IIA = re.compile(r"\banlage\s+iia\b")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@dataclass(frozen=True)
|
| 124 |
+
class Eintrag:
|
| 125 |
+
lfd: int
|
| 126 |
+
wirkstoff: str
|
| 127 |
+
fertigarzneimittel: str
|
| 128 |
+
kombination: str
|
| 129 |
+
begriffe: Tuple[str, ...]
|
| 130 |
+
varianten: Tuple[str, ...]
|
| 131 |
+
seite: Optional[int]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@dataclass
|
| 135 |
+
class Befund:
|
| 136 |
+
status: str
|
| 137 |
+
gefragter_stoff: Optional[str] = None
|
| 138 |
+
treffer: List[Eintrag] = field(default_factory=list)
|
| 139 |
+
# Womit der Treffer gefunden wurde: wirkstoff | gebiet | uebergabe.
|
| 140 |
+
ueber: str = ""
|
| 141 |
+
stand: str = ""
|
| 142 |
+
stand_iso: str = ""
|
| 143 |
+
veraltet: bool = False
|
| 144 |
+
alter_tage: Optional[int] = None
|
| 145 |
+
fundstelle: str = ""
|
| 146 |
+
url: str = ""
|
| 147 |
+
grund: str = ""
|
| 148 |
+
programm: Dict[str, Any] = field(default_factory=dict)
|
| 149 |
+
|
| 150 |
+
@property
|
| 151 |
+
def ist_belastbar(self) -> bool:
|
| 152 |
+
return self.status in {"gelistet", "nicht_gelistet"}
|
| 153 |
+
|
| 154 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 155 |
+
return {
|
| 156 |
+
"status": self.status,
|
| 157 |
+
"gefragter_stoff": self.gefragter_stoff,
|
| 158 |
+
"gefunden_ueber": self.ueber,
|
| 159 |
+
"treffer": [
|
| 160 |
+
{
|
| 161 |
+
"wirkstoff": e.wirkstoff,
|
| 162 |
+
"fertigarzneimittel": e.fertigarzneimittel,
|
| 163 |
+
"kombination": e.kombination,
|
| 164 |
+
"seite": e.seite,
|
| 165 |
+
}
|
| 166 |
+
for e in self.treffer
|
| 167 |
+
],
|
| 168 |
+
"stand": self.stand,
|
| 169 |
+
"veraltet": self.veraltet,
|
| 170 |
+
"alter_tage": self.alter_tage,
|
| 171 |
+
"fundstelle": self.fundstelle,
|
| 172 |
+
"url": self.url,
|
| 173 |
+
"grund": self.grund,
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ---------------------------------------------------------------------------
|
| 178 |
+
# Daten
|
| 179 |
+
# ---------------------------------------------------------------------------
|
| 180 |
+
|
| 181 |
+
def load_liste(path: Path) -> Dict[str, Any]:
|
| 182 |
+
key = str(path)
|
| 183 |
+
if key in _CACHE:
|
| 184 |
+
return _CACHE[key]
|
| 185 |
+
|
| 186 |
+
data: Dict[str, Any] = {}
|
| 187 |
+
try:
|
| 188 |
+
if path.exists():
|
| 189 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 190 |
+
except Exception as exc: # noqa: BLE001 - ein defekter Snapshot darf keine Antwort verhindern.
|
| 191 |
+
logger.warning("Anlage-IIa-Liste nicht lesbar: %s (%s)", path, exc)
|
| 192 |
+
data = {}
|
| 193 |
+
|
| 194 |
+
_CACHE[key] = data
|
| 195 |
+
return data
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _eintraege(daten: Dict[str, Any]) -> List[Eintrag]:
|
| 199 |
+
return [
|
| 200 |
+
Eintrag(
|
| 201 |
+
lfd=int(roh.get("lfd") or 0),
|
| 202 |
+
wirkstoff=str(roh.get("wirkstoff") or ""),
|
| 203 |
+
fertigarzneimittel=str(roh.get("fertigarzneimittel") or ""),
|
| 204 |
+
kombination=str(roh.get("kombination") or ""),
|
| 205 |
+
begriffe=tuple(str(b) for b in roh.get("begriffe") or []),
|
| 206 |
+
varianten=tuple(str(v) for v in roh.get("varianten") or []),
|
| 207 |
+
seite=roh.get("seite"),
|
| 208 |
+
)
|
| 209 |
+
for roh in daten.get("eintraege") or []
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---------------------------------------------------------------------------
|
| 214 |
+
# Erkennung in der Frage
|
| 215 |
+
# ---------------------------------------------------------------------------
|
| 216 |
+
|
| 217 |
+
def _norm(text: str) -> str:
|
| 218 |
+
return " ".join(str(text or "").lower().split())
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _wortgrenze(begriff: str, haystack: str) -> bool:
|
| 222 |
+
muster = rf"(?<![\wäöüß]){re.escape(_norm(begriff))}(?![\wäöüß])"
|
| 223 |
+
return re.search(muster, haystack) is not None
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def erkannter_stoff(
|
| 227 |
+
question: str, eintraege: Sequence[Eintrag]
|
| 228 |
+
) -> Tuple[Optional[str], List[Eintrag]]:
|
| 229 |
+
"""(erkannter Begriff, Einträge dazu) — über Wirkstoff oder Schreibvariante."""
|
| 230 |
+
haystack = _norm(question)
|
| 231 |
+
paare = sorted(
|
| 232 |
+
(
|
| 233 |
+
(begriff, eintrag)
|
| 234 |
+
for eintrag in eintraege
|
| 235 |
+
for begriff in (*eintrag.begriffe, *eintrag.varianten)
|
| 236 |
+
if begriff
|
| 237 |
+
),
|
| 238 |
+
key=lambda p: -len(p[0]),
|
| 239 |
+
)
|
| 240 |
+
for begriff, eintrag in paare:
|
| 241 |
+
if _wortgrenze(begriff, haystack):
|
| 242 |
+
return begriff, [eintrag]
|
| 243 |
+
return None, []
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def fragt_nach_verordnungsfaehigkeit(question: str) -> bool:
|
| 247 |
+
haystack = _norm(question)
|
| 248 |
+
if RE_ANLAGE_IIA.search(haystack):
|
| 249 |
+
return True
|
| 250 |
+
return any(signal in haystack for signal in VERORDNUNGS_SIGNALE)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def fragt_nach_tabakentwoehnung(question: str) -> bool:
|
| 254 |
+
haystack = _norm(question)
|
| 255 |
+
if RE_ANLAGE_IIA.search(haystack):
|
| 256 |
+
return True
|
| 257 |
+
return any(signal in haystack for signal in GEBIET_SIGNALE)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ---------------------------------------------------------------------------
|
| 261 |
+
# Prüfung
|
| 262 |
+
# ---------------------------------------------------------------------------
|
| 263 |
+
|
| 264 |
+
def pruefe(
|
| 265 |
+
question: str = "",
|
| 266 |
+
*,
|
| 267 |
+
path: Path,
|
| 268 |
+
stoff: Optional[str] = None,
|
| 269 |
+
heute: Optional[date] = None,
|
| 270 |
+
) -> Befund:
|
| 271 |
+
"""Ist dieses Arzneimittel von der Ausnahme des § 34 Absatz 2 SGB V erfasst?
|
| 272 |
+
|
| 273 |
+
`stoff` ist die Übergabe aus `amrl_lifestyle`: dort wird „Champix" über die
|
| 274 |
+
Fertigarzneimittelspalte der Anlage II zu „Vareniclin" aufgelöst, und dieses
|
| 275 |
+
Modul bekommt den Wirkstoff, statt eine zweite Produktliste zu führen. Der
|
| 276 |
+
Aufrufer reicht ihn nur herein, wenn der Treffer die Nikotinabhängigkeit
|
| 277 |
+
betrifft — sonst beantwortete eine Frage nach Sildenafil eine Frage nach der
|
| 278 |
+
Tabakentwöhnung.
|
| 279 |
+
"""
|
| 280 |
+
daten = load_liste(path)
|
| 281 |
+
alle = _eintraege(daten)
|
| 282 |
+
if not alle:
|
| 283 |
+
return Befund(status="nicht_pruefbar", grund="snapshot_fehlt_oder_leer")
|
| 284 |
+
|
| 285 |
+
quelle = daten.get("quelle") or {}
|
| 286 |
+
stand = str(quelle.get("stand") or "")
|
| 287 |
+
fundstelle = str(quelle.get("dokument") or "AM-RL Anlage IIa")
|
| 288 |
+
url = str(quelle.get("url") or "")
|
| 289 |
+
programm = daten.get("programm") or {}
|
| 290 |
+
|
| 291 |
+
if not stand:
|
| 292 |
+
return Befund(
|
| 293 |
+
status="nicht_pruefbar", grund="stand_unbekannt", fundstelle=fundstelle, url=url
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
stand_iso = _stand_iso(stand)
|
| 297 |
+
alter_tage: Optional[int] = None
|
| 298 |
+
veraltet = False
|
| 299 |
+
if stand_iso:
|
| 300 |
+
try:
|
| 301 |
+
alter_tage = max(
|
| 302 |
+
0,
|
| 303 |
+
((heute or date.today()) - datetime.strptime(stand_iso, "%Y-%m-%d").date()).days,
|
| 304 |
+
)
|
| 305 |
+
veraltet = alter_tage > VERALTET_AB_TAGEN
|
| 306 |
+
except ValueError:
|
| 307 |
+
alter_tage = None
|
| 308 |
+
|
| 309 |
+
def _fertig(befund: Befund) -> Befund:
|
| 310 |
+
befund.stand = stand
|
| 311 |
+
befund.stand_iso = stand_iso
|
| 312 |
+
befund.alter_tage = alter_tage
|
| 313 |
+
befund.veraltet = veraltet
|
| 314 |
+
befund.fundstelle = fundstelle
|
| 315 |
+
befund.url = url
|
| 316 |
+
befund.programm = programm
|
| 317 |
+
return befund
|
| 318 |
+
|
| 319 |
+
if not fragt_nach_verordnungsfaehigkeit(question) and stoff is None:
|
| 320 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="keine_verordnungsfrage"))
|
| 321 |
+
|
| 322 |
+
# Der übergebene Wirkstoff zuerst: er ist bereits an Anlage II aufgelöst und
|
| 323 |
+
# trägt weiter als jede Erkennung am Fragetext.
|
| 324 |
+
if stoff:
|
| 325 |
+
treffer = [
|
| 326 |
+
e
|
| 327 |
+
for e in alle
|
| 328 |
+
if any(_norm(b) == _norm(stoff) for b in (*e.begriffe, *e.varianten))
|
| 329 |
+
]
|
| 330 |
+
if treffer:
|
| 331 |
+
return _fertig(
|
| 332 |
+
Befund(status="gelistet", gefragter_stoff=stoff, ueber="uebergabe", treffer=treffer)
|
| 333 |
+
)
|
| 334 |
+
return _fertig(
|
| 335 |
+
Befund(
|
| 336 |
+
status="nicht_gelistet",
|
| 337 |
+
gefragter_stoff=stoff,
|
| 338 |
+
ueber="uebergabe",
|
| 339 |
+
grund="nicht_in_anlage_iia",
|
| 340 |
+
)
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
begriff, treffer = erkannter_stoff(question, alle)
|
| 344 |
+
if treffer:
|
| 345 |
+
return _fertig(
|
| 346 |
+
Befund(status="gelistet", gefragter_stoff=begriff, ueber="wirkstoff", treffer=treffer)
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# Ohne Wirkstoff bleibt die Frage nach dem Gebiet — sie wird mit der ganzen
|
| 350 |
+
# Tabelle beantwortet, denn die ist zwei Zeilen lang.
|
| 351 |
+
if not fragt_nach_tabakentwoehnung(question):
|
| 352 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="keine_tabakfrage"))
|
| 353 |
+
|
| 354 |
+
# Ein anderer Stoff, ausdrücklich zur Tabakentwöhnung: hier trägt die
|
| 355 |
+
# Negativauskunft, weil Anlage IIa die Ausnahme abschließend aufzählt.
|
| 356 |
+
#
|
| 357 |
+
# Dieser Weg ist der Rückfall, nicht der Hauptweg. Für Cytisin und Bupropion
|
| 358 |
+
# — die beiden Stoffe, bei denen die Frage praktisch entsteht — greift er
|
| 359 |
+
# nicht, weil `INN_ENDUNGEN` ihre Endungen nicht führt; beide stehen aber in
|
| 360 |
+
# Anlage II, und `amrl_lifestyle` reicht sie über `stoff` herein. Die Lücke
|
| 361 |
+
# kostet damit dasselbe wie überall, wo diese Heuristik benutzt wird: die
|
| 362 |
+
# namentliche Nennung, nicht die Richtigkeit. Statt sie zu schließen — „isin"
|
| 363 |
+
# und „ion" wären als Endungen viel zu weit und schlügen in fünf anderen
|
| 364 |
+
# Modulen durch — bleibt es bei der Gebietsauskunft, die die zwei Wirkstoffe
|
| 365 |
+
# der Anlage vollständig zeigt.
|
| 366 |
+
kandidat = wirkstoff_kandidat(question)
|
| 367 |
+
if kandidat:
|
| 368 |
+
return _fertig(
|
| 369 |
+
Befund(
|
| 370 |
+
status="nicht_gelistet",
|
| 371 |
+
gefragter_stoff=kandidat,
|
| 372 |
+
ueber="wirkstoff",
|
| 373 |
+
grund="nicht_in_anlage_iia",
|
| 374 |
+
)
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
return _fertig(
|
| 378 |
+
Befund(
|
| 379 |
+
status="gelistet",
|
| 380 |
+
gefragter_stoff=str((daten.get("teile") or {}).get("A") or "Tabakentwöhnung"),
|
| 381 |
+
ueber="gebiet",
|
| 382 |
+
treffer=alle,
|
| 383 |
+
)
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# ---------------------------------------------------------------------------
|
| 388 |
+
# Darstellung
|
| 389 |
+
# ---------------------------------------------------------------------------
|
| 390 |
+
|
| 391 |
+
_EINLEITUNG = "Arzneimittel zur Tabakentwöhnung — deterministische Listenprüfung"
|
| 392 |
+
|
| 393 |
+
# Die Ausnahme steht nie für sich. Ohne diesen Satz liest sich der Block wie ein
|
| 394 |
+
# Widerspruch zu dem Block der Anlage II zwei Absätze darüber.
|
| 395 |
+
_AUSNAHME = (
|
| 396 |
+
"Arzneimittel zur Raucherentwöhnung sind nach § 34 Absatz 1 Satz 7 SGB V von der "
|
| 397 |
+
"Versorgung ausgeschlossen (Anlage II). Anlage IIa ist die einzige Ausnahme davon: "
|
| 398 |
+
"Versicherte mit festgestellter schwerer Tabakabhängigkeit haben nach § 34 Absatz 2 "
|
| 399 |
+
"SGB V, § 14a AM-RL Anspruch auf eine einmalige Versorgung im Rahmen evidenzbasierter "
|
| 400 |
+
"Programme zur Tabakentwöhnung."
|
| 401 |
+
)
|
| 402 |
+
_VORAUSSETZUNGEN = (
|
| 403 |
+
"Die Verordnung setzt zweierlei voraus, das keine Listenauskunft ist: eine "
|
| 404 |
+
"festgestellte bestehende schwere Tabakabhängigkeit nach den Kriterien des § 14a "
|
| 405 |
+
"Absatz 3 AM-RL (ICD-10-GM F17.2 in Verbindung mit dem Fagerströmtest oder einer "
|
| 406 |
+
"Risikokonstellation) und die Anwendung im Rahmen eines evidenzbasierten Programms."
|
| 407 |
+
)
|
| 408 |
+
_EINMALIG = (
|
| 409 |
+
"Der Anspruch ist einmalig; eine erneute Versorgung ist frühestens drei Jahre nach "
|
| 410 |
+
"Abschluss der Behandlung möglich (§ 34 Absatz 2 Satz 2 SGB V)."
|
| 411 |
+
)
|
| 412 |
+
_KOMBINATION = (
|
| 413 |
+
"Die Anlage regelt auch die Kombination der Arzneimittel; die Spalte ist Teil des "
|
| 414 |
+
"Eintrags und oben wörtlich wiedergegeben."
|
| 415 |
+
)
|
| 416 |
+
_PROGRAMM = (
|
| 417 |
+
"Die Anforderungen an das Programm stehen über § 14a hinaus in Teil B der Anlage: {teile}. "
|
| 418 |
+
"Ob ein konkretes Programm sie erfüllt, entscheidet diese Prüfung nicht."
|
| 419 |
+
)
|
| 420 |
+
# Worauf die Negativauskunft trägt — und warum sie hier weiter trägt als bei
|
| 421 |
+
# Anlage II.
|
| 422 |
+
_GRENZE_DER_NEGATIVAUSKUNFT = (
|
| 423 |
+
"Damit greift die Ausnahme des § 34 Absatz 2 SGB V für diesen Wirkstoff nicht: nach "
|
| 424 |
+
"§ 14a Absatz 3 Satz 1 AM-RL sind die ausnahmsweise zur Tabakentwöhnung "
|
| 425 |
+
"verordnungsfähigen Arzneimittel in Anlage IIa aufgeführt, und eine Ausnahme reicht "
|
| 426 |
+
"nur so weit, wie sie geschrieben ist. Es bleibt bei dem Ausschluss nach § 34 Absatz 1 "
|
| 427 |
+
"Satz 7 SGB V, soweit der Wirkstoff in Anlage II geführt ist."
|
| 428 |
+
)
|
| 429 |
+
_GEBIETSVORBEHALT = (
|
| 430 |
+
"Gezeigt ist die ganze Tabelle der Anlage. Ob ein konkretes Arzneimittel darunterfällt, "
|
| 431 |
+
"entscheidet sein Wirkstoff."
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def _eintrag_block(eintrag: Eintrag) -> str:
|
| 436 |
+
zeilen = [f" {eintrag.wirkstoff}"]
|
| 437 |
+
if eintrag.fertigarzneimittel:
|
| 438 |
+
zeilen.append(f" Fertigarzneimittel (apothekenpflichtig): {eintrag.fertigarzneimittel}")
|
| 439 |
+
if eintrag.kombination:
|
| 440 |
+
zeilen.append(f" Kombination: {eintrag.kombination}")
|
| 441 |
+
return "\n".join(zeilen)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _programmteile(befund: Befund) -> str:
|
| 445 |
+
abschnitte = (befund.programm or {}).get("abschnitte") or []
|
| 446 |
+
return "; ".join(
|
| 447 |
+
f"Buchstabe {a.get('buchstabe')} — {a.get('titel')}" for a in abschnitte if a.get("titel")
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def befund_block(befund: Befund) -> str:
|
| 452 |
+
if not befund.ist_belastbar:
|
| 453 |
+
return ""
|
| 454 |
+
|
| 455 |
+
zeilen: List[str] = [f"{_EINLEITUNG} ({befund.fundstelle}, Stand {befund.stand}):"]
|
| 456 |
+
|
| 457 |
+
if befund.status == "gelistet":
|
| 458 |
+
if befund.ueber == "gebiet":
|
| 459 |
+
zeilen.append(
|
| 460 |
+
f"Anlage IIa führt {len(befund.treffer)} Wirkstoffe, die ausnahmsweise zur "
|
| 461 |
+
"Tabakentwöhnung verordnungsfähig sind."
|
| 462 |
+
)
|
| 463 |
+
else:
|
| 464 |
+
zeilen.append(
|
| 465 |
+
f"{befund.gefragter_stoff} ist in Anlage IIa geführt und damit nach § 34 "
|
| 466 |
+
"Absatz 2 SGB V ausnahmsweise zu Lasten der GKV verordnungsfähig."
|
| 467 |
+
)
|
| 468 |
+
for eintrag in befund.treffer:
|
| 469 |
+
zeilen.append(_eintrag_block(eintrag))
|
| 470 |
+
zeilen.append(_AUSNAHME)
|
| 471 |
+
zeilen.append(_VORAUSSETZUNGEN)
|
| 472 |
+
zeilen.append(_EINMALIG)
|
| 473 |
+
if any(e.kombination for e in befund.treffer):
|
| 474 |
+
zeilen.append(_KOMBINATION)
|
| 475 |
+
teile = _programmteile(befund)
|
| 476 |
+
if teile:
|
| 477 |
+
zeilen.append(_PROGRAMM.format(teile=teile))
|
| 478 |
+
if befund.ueber == "gebiet":
|
| 479 |
+
zeilen.append(_GEBIETSVORBEHALT)
|
| 480 |
+
else: # nicht_gelistet
|
| 481 |
+
zeilen.append(f"Anlage IIa führt {befund.gefragter_stoff} nicht.")
|
| 482 |
+
zeilen.append(_GRENZE_DER_NEGATIVAUSKUNFT)
|
| 483 |
+
|
| 484 |
+
if befund.veraltet:
|
| 485 |
+
zeilen.append(
|
| 486 |
+
f"Achtung Aktualität: Dieser Auszug ist {befund.alter_tage} Tage alt. Vor einer "
|
| 487 |
+
"Entscheidung ist die geltende Fassung abzugleichen."
|
| 488 |
+
)
|
| 489 |
+
if befund.url:
|
| 490 |
+
zeilen.append(f"Quelle: {befund.url}")
|
| 491 |
+
|
| 492 |
+
return "\n".join(zeilen)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def verdikt(befund: Befund) -> str:
|
| 496 |
+
"""Die Antwort in einem Satz.
|
| 497 |
+
|
| 498 |
+
Der Satz trägt beide Hälften — Ausschluss und Ausnahme —, weil keine von
|
| 499 |
+
beiden allein die Frage beantwortet. „Vareniclin ist verordnungsfähig" wäre
|
| 500 |
+
zu großzügig, „Vareniclin ist ausgeschlossen" zu streng, und beide stünden im
|
| 501 |
+
Widerspruch zu dem Block der Anlage II darüber. Aus demselben Grund steht
|
| 502 |
+
dieses Modul in `_listenverdikt` vor `amrl_lifestyle`: es sagt, was jenes
|
| 503 |
+
sagt, und die Ausnahme dazu.
|
| 504 |
+
"""
|
| 505 |
+
if not befund.ist_belastbar:
|
| 506 |
+
return ""
|
| 507 |
+
|
| 508 |
+
if befund.status == "nicht_gelistet":
|
| 509 |
+
return (
|
| 510 |
+
f"{befund.gefragter_stoff} ist in Anlage IIa der AM-RL nicht aufgeführt; die "
|
| 511 |
+
"Ausnahme für die Tabakentwöhnung nach § 34 Absatz 2 SGB V erfasst den Wirkstoff "
|
| 512 |
+
f"nicht (Listenprüfung oben, Stand {befund.stand})."
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if befund.ueber == "gebiet":
|
| 516 |
+
stoffe = ", ".join(e.wirkstoff for e in befund.treffer)
|
| 517 |
+
return (
|
| 518 |
+
"Arzneimittel zur Raucherentwöhnung sind nach § 34 Absatz 1 Satz 7 SGB V "
|
| 519 |
+
f"ausgeschlossen; ausnahmsweise verordnungsfähig sind nach Anlage IIa {stoffe} — "
|
| 520 |
+
"bei festgestellter schwerer Tabakabhängigkeit, einmalig und im Rahmen eines "
|
| 521 |
+
f"evidenzbasierten Programms (Listenprüfung oben, Stand {befund.stand})."
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
return (
|
| 525 |
+
f"{befund.gefragter_stoff} ist nach § 34 Absatz 1 Satz 7 SGB V grundsätzlich "
|
| 526 |
+
"ausgeschlossen, steht aber in Anlage IIa der AM-RL und ist deshalb ausnahmsweise "
|
| 527 |
+
"verordnungsfähig — bei festgestellter schwerer Tabakabhängigkeit, einmalig und im "
|
| 528 |
+
f"Rahmen eines evidenzbasierten Programms (Listenprüfung oben, Stand {befund.stand})."
|
| 529 |
+
)
|
src/amrl_verordnungsausschluss.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verordnungseinschränkungen und -ausschlüsse nachschlagen (AM-RL Anlage III).
|
| 2 |
+
|
| 3 |
+
Anlage III führt zusammen, was in der Arzneimittelversorgung ausgeschlossen oder
|
| 4 |
+
nur eingeschränkt verordnungsfähig ist — 53 Nummern von „Acida" bis
|
| 5 |
+
„Dipyridamol in Kombination mit Acetylsalicylsäure". Sie ist das Gegenstück zu
|
| 6 |
+
Anlage I: dort die Ausnahmen *zugunsten* der Verordnungsfähigkeit, hier die
|
| 7 |
+
Einschränkungen zu ihren Lasten.
|
| 8 |
+
|
| 9 |
+
**Anders als bei Anlage I trägt die Negativauskunft hier nicht.** Anlage I ruht
|
| 10 |
+
auf § 12 Absatz 10 AM-RL („regeln abschließend"); ein solcher Satz steht in
|
| 11 |
+
dieser Anlage nirgends — das Wort „abschließend" kommt im ganzen Dokument nicht
|
| 12 |
+
vor. Sie ist ihrem Titel nach eine *Übersicht*, die Ausschlüsse aus mehreren
|
| 13 |
+
Quellen zusammenstellt. Aus „steht nicht in Anlage III" folgt deshalb keine
|
| 14 |
+
Verordnungsfähigkeit, sondern nur, dass dieser eine Weg nicht greift. Der
|
| 15 |
+
Befund sagt das, statt es zu unterschlagen — dieselbe Zurückhaltung wie bei
|
| 16 |
+
Anlage VIIa.
|
| 17 |
+
|
| 18 |
+
**Die Rechtsfolge steckt in der Ziffer hinter dem Hinweis.** Jeder Hinweis der
|
| 19 |
+
rechten Spalte endet auf einen Marker [1] bis [6], der auf eine der sechs
|
| 20 |
+
Rechtsgrundlagen der Vorbemerkung zeigt. Der Unterschied ist keine Formalie:
|
| 21 |
+
|
| 22 |
+
[1] Gesetzlicher Ausschluss (§ 34 Absatz 1 Satz 6 SGB V, Bagatellen)
|
| 23 |
+
[2] Negativliste (Rechtsverordnung nach § 34 Absatz 3 SGB V)
|
| 24 |
+
[3] Ausschluss nach dieser Richtlinie
|
| 25 |
+
[4] Einschränkung nach dieser Richtlinie
|
| 26 |
+
[5] Hinweis Kinder bis 12 / Jugendliche bis 18, Gefährdungspotential
|
| 27 |
+
[6] Hinweis auf unwirtschaftliche Verordnung bei denselben Gruppen
|
| 28 |
+
|
| 29 |
+
Nach § 31 Absatz 1 Satz 4 SGB V, § 16 Absatz 5 AM-RL darf die Ärztin ein
|
| 30 |
+
eingeschränktes oder ausgeschlossenes Arzneimittel im medizinisch begründeten
|
| 31 |
+
Einzelfall mit Begründung dennoch verordnen — aber die Vorbemerkung bindet das
|
| 32 |
+
ausdrücklich an die Nummern 3 bis 6. Bei [1] und [2] gibt es diesen Weg nicht.
|
| 33 |
+
Ein Befund, der beide gleich behandelte, wäre in der einen Richtung zu streng
|
| 34 |
+
und in der anderen zu großzügig. Deshalb steht der Einzelfall nur dort, wo
|
| 35 |
+
*alle* Marker einer Zeile ihn offenlassen.
|
| 36 |
+
|
| 37 |
+
**Der Eintrag ist Bezeichnung *und* Ausnahme, und beide gehören zusammen.**
|
| 38 |
+
Nummer 38 schließt Otologika aus — aber Ciprofloxacin bei chronisch eitriger
|
| 39 |
+
Otitis media ist ausgenommen. Ein Befund, der nur die Bezeichnung meldet, sagt
|
| 40 |
+
das Gegenteil dessen, was in der Anlage steht. Deshalb wird der Eintrag
|
| 41 |
+
vollständig zitiert und nicht subsumiert: ob die Ausnahme im Fall vorliegt, ist
|
| 42 |
+
eine ärztliche Feststellung und keine Listenauskunft.
|
| 43 |
+
|
| 44 |
+
**Die Anlage bezeichnet Gruppen, keine Präparate.** „Antacida in fixer
|
| 45 |
+
Kombination mit anderen Wirkstoffen" ist eine Produktklasse; ob ein konkretes
|
| 46 |
+
Arzneimittel darunterfällt, sagt die Liste nicht. Und wo eine Zeile eine
|
| 47 |
+
*Kombination* erfasst, trägt sie über den Einzelstoff nichts: Nummer 53 schließt
|
| 48 |
+
Dipyridamol in Kombination mit Acetylsalicylsäure aus, über Acetylsalicylsäure
|
| 49 |
+
allein steht dort nichts.
|
| 50 |
+
|
| 51 |
+
Die Zustände:
|
| 52 |
+
|
| 53 |
+
ausgeschlossen die Anlage schließt die Bezeichnung aus
|
| 54 |
+
eingeschraenkt sie schränkt sie ein, oft mit Ausnahmen
|
| 55 |
+
nicht_gelistet Anlage III führt sie nicht — was für sich noch keine
|
| 56 |
+
Verordnungsfähigkeit bedeutet
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
from __future__ import annotations
|
| 60 |
+
|
| 61 |
+
import json
|
| 62 |
+
import logging
|
| 63 |
+
import re
|
| 64 |
+
from dataclasses import dataclass, field
|
| 65 |
+
from datetime import date, datetime
|
| 66 |
+
from pathlib import Path
|
| 67 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 68 |
+
|
| 69 |
+
# Geteilt statt ein fünftes Mal geschrieben: dieselbe Standangabe, dieselbe
|
| 70 |
+
# Frist, dieselbe Endungsheuristik für einen Wirkstoffnamen.
|
| 71 |
+
from amrl_substitution import VERALTET_AB_TAGEN, _stand_iso, wirkstoff_kandidat
|
| 72 |
+
|
| 73 |
+
logger = logging.getLogger(__name__)
|
| 74 |
+
|
| 75 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 76 |
+
|
| 77 |
+
# Die Frage muss von der Verordnungsfähigkeit handeln. Regelbasiert wie in den
|
| 78 |
+
# übrigen Listenmodulen: eine Substring-Prüfung entscheidet das exakt.
|
| 79 |
+
VERORDNUNGS_SIGNALE: Tuple[str, ...] = (
|
| 80 |
+
"verordnungsfähig",
|
| 81 |
+
"verordnungsfaehig",
|
| 82 |
+
# Auf den Stamm gekürzt: der Plural bricht sonst am Umlaut
|
| 83 |
+
# („Verordnungsausschlüsse" enthält kein „verordnungsausschluss").
|
| 84 |
+
"verordnungsausschl",
|
| 85 |
+
"verordnungseinschränk",
|
| 86 |
+
"verordnungseinschraenk",
|
| 87 |
+
"ausgeschlossen",
|
| 88 |
+
"eingeschränkt",
|
| 89 |
+
"eingeschraenkt",
|
| 90 |
+
"erstattungsfähig",
|
| 91 |
+
"erstattungsfaehig",
|
| 92 |
+
"erstattung",
|
| 93 |
+
"zu lasten der",
|
| 94 |
+
"kassenrezept",
|
| 95 |
+
"auf kasse",
|
| 96 |
+
"darf ich verordnen",
|
| 97 |
+
"verordnet werden",
|
| 98 |
+
"unwirtschaftlich",
|
| 99 |
+
"wirtschaftlichkeitsgebot",
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Die Anlage beim Namen genannt. „anlage iii" ist als Substring nicht in
|
| 103 |
+
# „anlage i" enthalten, wohl aber umgekehrt — die Reihenfolge der Prüfung in
|
| 104 |
+
# `amrl_otc` hängt daran, hier genügt die genaue Form.
|
| 105 |
+
RE_ANLAGE_III = re.compile(r"\banlage\s+iii\b")
|
| 106 |
+
|
| 107 |
+
# Einträge, die ihre Stoffe selbst benennen — entweder weil die Bezeichnung der
|
| 108 |
+
# Wirkstoff ist (Reboxetin, Febuxostat, Evolocumab) oder weil der Eintrag sie
|
| 109 |
+
# aufzählt („Hierzu zählen: Pioglitazon, Rosiglitazon"). Nur bei den übrigen ist
|
| 110 |
+
# die Zuordnung eines konkreten Präparats zur Gruppe offen und muss im Befund
|
| 111 |
+
# als offen benannt werden; hier wäre derselbe Vorbehalt schlicht falsch.
|
| 112 |
+
#
|
| 113 |
+
# Kuratiert wie `GRUPPENEINTRAEGE` in `amrl_otc`: aus der Bezeichnung ableiten
|
| 114 |
+
# lässt sich das nicht — „Acida" ist eine Gruppe und „Reboxetin" ein Stoff, und
|
| 115 |
+
# beide sind ein einzelnes Wort.
|
| 116 |
+
STOFFGENAUE_EINTRAEGE: frozenset[str] = frozenset(
|
| 117 |
+
{"10a", "21", "21a", "29a", "33", "33a", "35a", "35b", "35c", "49", "50", "51", "53"}
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@dataclass(frozen=True)
|
| 122 |
+
class Eintrag:
|
| 123 |
+
nr: str
|
| 124 |
+
bezeichnung: str
|
| 125 |
+
absaetze: Tuple[str, ...]
|
| 126 |
+
hinweise: Tuple[str, ...]
|
| 127 |
+
marker: Tuple[str, ...]
|
| 128 |
+
art: str
|
| 129 |
+
einzelfall_moeglich: bool
|
| 130 |
+
kombination: bool
|
| 131 |
+
begriffe: Tuple[str, ...]
|
| 132 |
+
varianten: Tuple[str, ...]
|
| 133 |
+
seite: Optional[int]
|
| 134 |
+
|
| 135 |
+
@property
|
| 136 |
+
def ist_gruppe(self) -> bool:
|
| 137 |
+
return self.nr not in STOFFGENAUE_EINTRAEGE
|
| 138 |
+
|
| 139 |
+
def volltext(self) -> str:
|
| 140 |
+
return "\n".join(self.absaetze)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@dataclass
|
| 144 |
+
class Befund:
|
| 145 |
+
status: str
|
| 146 |
+
gefragter_stoff: Optional[str] = None
|
| 147 |
+
treffer: List[Eintrag] = field(default_factory=list)
|
| 148 |
+
stand: str = ""
|
| 149 |
+
stand_iso: str = ""
|
| 150 |
+
veraltet: bool = False
|
| 151 |
+
alter_tage: Optional[int] = None
|
| 152 |
+
fundstelle: str = ""
|
| 153 |
+
url: str = ""
|
| 154 |
+
grund: str = ""
|
| 155 |
+
|
| 156 |
+
@property
|
| 157 |
+
def ist_belastbar(self) -> bool:
|
| 158 |
+
return self.status in {"ausgeschlossen", "eingeschraenkt", "nicht_gelistet"}
|
| 159 |
+
|
| 160 |
+
@property
|
| 161 |
+
def einzelfall_moeglich(self) -> bool:
|
| 162 |
+
"""Nur wenn ihn *jede* getroffene Zeile offenlässt."""
|
| 163 |
+
return bool(self.treffer) and all(e.einzelfall_moeglich for e in self.treffer)
|
| 164 |
+
|
| 165 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 166 |
+
return {
|
| 167 |
+
"status": self.status,
|
| 168 |
+
"gefragter_stoff": self.gefragter_stoff,
|
| 169 |
+
"treffer": [
|
| 170 |
+
{"nr": e.nr, "bezeichnung": e.bezeichnung, "art": e.art, "seite": e.seite}
|
| 171 |
+
for e in self.treffer
|
| 172 |
+
],
|
| 173 |
+
"einzelfall_moeglich": self.einzelfall_moeglich,
|
| 174 |
+
"stand": self.stand,
|
| 175 |
+
"veraltet": self.veraltet,
|
| 176 |
+
"alter_tage": self.alter_tage,
|
| 177 |
+
"fundstelle": self.fundstelle,
|
| 178 |
+
"url": self.url,
|
| 179 |
+
"grund": self.grund,
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# Daten
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
def load_liste(path: Path) -> Dict[str, Any]:
|
| 188 |
+
key = str(path)
|
| 189 |
+
if key in _CACHE:
|
| 190 |
+
return _CACHE[key]
|
| 191 |
+
|
| 192 |
+
data: Dict[str, Any] = {}
|
| 193 |
+
try:
|
| 194 |
+
if path.exists():
|
| 195 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 196 |
+
except Exception as exc: # noqa: BLE001 - ein defekter Snapshot darf keine Antwort verhindern.
|
| 197 |
+
logger.warning("Anlage-III-Liste nicht lesbar: %s (%s)", path, exc)
|
| 198 |
+
data = {}
|
| 199 |
+
|
| 200 |
+
_CACHE[key] = data
|
| 201 |
+
return data
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _eintraege(daten: Dict[str, Any]) -> List[Eintrag]:
|
| 205 |
+
out: List[Eintrag] = []
|
| 206 |
+
for roh in daten.get("eintraege") or []:
|
| 207 |
+
if roh.get("unbesetzt"):
|
| 208 |
+
continue
|
| 209 |
+
out.append(
|
| 210 |
+
Eintrag(
|
| 211 |
+
nr=str(roh.get("nr") or ""),
|
| 212 |
+
bezeichnung=str(roh.get("bezeichnung") or ""),
|
| 213 |
+
absaetze=tuple(str(a) for a in roh.get("absaetze") or []),
|
| 214 |
+
hinweise=tuple(str(h) for h in roh.get("hinweise") or []),
|
| 215 |
+
marker=tuple(str(m) for m in roh.get("marker") or []),
|
| 216 |
+
art=str(roh.get("art") or ""),
|
| 217 |
+
einzelfall_moeglich=bool(roh.get("einzelfall_moeglich")),
|
| 218 |
+
kombination=bool(roh.get("kombination")),
|
| 219 |
+
begriffe=tuple(str(b) for b in roh.get("begriffe") or []),
|
| 220 |
+
varianten=tuple(str(v) for v in roh.get("varianten") or []),
|
| 221 |
+
seite=roh.get("seite"),
|
| 222 |
+
)
|
| 223 |
+
)
|
| 224 |
+
return out
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# ---------------------------------------------------------------------------
|
| 228 |
+
# Erkennung in der Frage
|
| 229 |
+
# ---------------------------------------------------------------------------
|
| 230 |
+
|
| 231 |
+
def _norm(text: str) -> str:
|
| 232 |
+
return " ".join(str(text or "").lower().split())
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def begriffsindex(eintraege: Sequence[Eintrag]) -> List[Tuple[str, Eintrag]]:
|
| 236 |
+
"""(Suchbegriff, Eintrag), längste zuerst.
|
| 237 |
+
|
| 238 |
+
Die Reihenfolge trägt: „Insulin glargin" darf nicht vom kürzeren
|
| 239 |
+
„Insulinanaloga" verdeckt werden und „Antidiarrhoika" nicht von „Antacida".
|
| 240 |
+
"""
|
| 241 |
+
paare = [(b, e) for e in eintraege for b in (*e.begriffe, *e.varianten) if b]
|
| 242 |
+
return sorted(paare, key=lambda p: -len(p[0]))
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def erkannte_eintraege(
|
| 246 |
+
question: str, eintraege: Sequence[Eintrag]
|
| 247 |
+
) -> Tuple[Optional[str], List[Eintrag]]:
|
| 248 |
+
"""(erkannter Begriff, Einträge dazu) — mehrere, wenn er mehrfach steht.
|
| 249 |
+
|
| 250 |
+
Clopidogrel steht in Nummer 21 (Monotherapie) und 21a (in Kombination mit
|
| 251 |
+
Acetylsalicylsäure) mit verschiedenen Rechtsfolgen, Insulinanaloga in 33 und
|
| 252 |
+
33a. Nur eine davon zu zeigen unterschlüge die halbe Regelung.
|
| 253 |
+
"""
|
| 254 |
+
haystack = _norm(question)
|
| 255 |
+
for begriff, _ in begriffsindex(eintraege):
|
| 256 |
+
if re.search(rf"(?<![\wäöüß]){re.escape(_norm(begriff))}(?![\wäöüß])", haystack):
|
| 257 |
+
passend = [
|
| 258 |
+
e
|
| 259 |
+
for e in eintraege
|
| 260 |
+
if any(_norm(b) == _norm(begriff) for b in (*e.begriffe, *e.varianten))
|
| 261 |
+
]
|
| 262 |
+
return begriff, passend
|
| 263 |
+
return None, []
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def fragt_nach_verordnungsfaehigkeit(question: str) -> bool:
|
| 267 |
+
haystack = _norm(question)
|
| 268 |
+
if RE_ANLAGE_III.search(haystack):
|
| 269 |
+
return True
|
| 270 |
+
return any(signal in haystack for signal in VERORDNUNGS_SIGNALE)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ---------------------------------------------------------------------------
|
| 274 |
+
# Prüfung
|
| 275 |
+
# ---------------------------------------------------------------------------
|
| 276 |
+
|
| 277 |
+
def pruefe(
|
| 278 |
+
question: str = "",
|
| 279 |
+
*,
|
| 280 |
+
path: Path,
|
| 281 |
+
stoff: Optional[str] = None,
|
| 282 |
+
heute: Optional[date] = None,
|
| 283 |
+
) -> Befund:
|
| 284 |
+
daten = load_liste(path)
|
| 285 |
+
alle = _eintraege(daten)
|
| 286 |
+
if not alle:
|
| 287 |
+
return Befund(status="nicht_pruefbar", grund="snapshot_fehlt_oder_leer")
|
| 288 |
+
|
| 289 |
+
quelle = daten.get("quelle") or {}
|
| 290 |
+
stand = str(quelle.get("stand") or "")
|
| 291 |
+
fundstelle = str(quelle.get("dokument") or "AM-RL Anlage III")
|
| 292 |
+
url = str(quelle.get("url") or "")
|
| 293 |
+
|
| 294 |
+
if not stand:
|
| 295 |
+
return Befund(
|
| 296 |
+
status="nicht_pruefbar", grund="stand_unbekannt", fundstelle=fundstelle, url=url
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
stand_iso = _stand_iso(stand)
|
| 300 |
+
alter_tage: Optional[int] = None
|
| 301 |
+
veraltet = False
|
| 302 |
+
if stand_iso:
|
| 303 |
+
try:
|
| 304 |
+
alter_tage = max(
|
| 305 |
+
0,
|
| 306 |
+
((heute or date.today()) - datetime.strptime(stand_iso, "%Y-%m-%d").date()).days,
|
| 307 |
+
)
|
| 308 |
+
veraltet = alter_tage > VERALTET_AB_TAGEN
|
| 309 |
+
except ValueError:
|
| 310 |
+
alter_tage = None
|
| 311 |
+
|
| 312 |
+
def _fertig(befund: Befund) -> Befund:
|
| 313 |
+
befund.stand = stand
|
| 314 |
+
befund.stand_iso = stand_iso
|
| 315 |
+
befund.alter_tage = alter_tage
|
| 316 |
+
befund.veraltet = veraltet
|
| 317 |
+
befund.fundstelle = fundstelle
|
| 318 |
+
befund.url = url
|
| 319 |
+
return befund
|
| 320 |
+
|
| 321 |
+
# Ohne Bezug zur Verordnungsfähigkeit gar nichts: „Wie dosiere ich
|
| 322 |
+
# Clopidogrel?" nennt einen gelisteten Stoff, will aber keine Auskunft
|
| 323 |
+
# darüber, ob er zu Lasten der GKV verordnet werden darf.
|
| 324 |
+
if not fragt_nach_verordnungsfaehigkeit(question) and stoff is None:
|
| 325 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="keine_verordnungsfrage"))
|
| 326 |
+
|
| 327 |
+
if stoff:
|
| 328 |
+
begriff, treffer = stoff, [
|
| 329 |
+
e for e in alle if any(_norm(b) == _norm(stoff) for b in (*e.begriffe, *e.varianten))
|
| 330 |
+
]
|
| 331 |
+
else:
|
| 332 |
+
begriff, treffer = erkannte_eintraege(question, alle)
|
| 333 |
+
|
| 334 |
+
if treffer:
|
| 335 |
+
# Die schärfere Rechtsfolge bestimmt die Auskunft: wo eine Zeile
|
| 336 |
+
# ausschließt und eine andere nur einschränkt, ist der Ausschluss das,
|
| 337 |
+
# was die Abgabe verhindert.
|
| 338 |
+
status = "ausgeschlossen" if any(e.art == "ausschluss" for e in treffer) else "eingeschraenkt"
|
| 339 |
+
return _fertig(Befund(status=status, gefragter_stoff=begriff, treffer=treffer))
|
| 340 |
+
|
| 341 |
+
# Keine Negativauskunft ins Blaue: „nicht in Anlage III" ist nur dann eine
|
| 342 |
+
# Aussage, wenn überhaupt ein Stoff genannt wurde.
|
| 343 |
+
kandidat = begriff or wirkstoff_kandidat(question)
|
| 344 |
+
if not kandidat:
|
| 345 |
+
return _fertig(Befund(status="nicht_pruefbar", grund="kein_stoff_erkannt"))
|
| 346 |
+
|
| 347 |
+
return _fertig(
|
| 348 |
+
Befund(status="nicht_gelistet", gefragter_stoff=kandidat, grund="nicht_in_anlage_iii")
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# ---------------------------------------------------------------------------
|
| 353 |
+
# Darstellung
|
| 354 |
+
# ---------------------------------------------------------------------------
|
| 355 |
+
|
| 356 |
+
_EINLEITUNG = "Verordnungsausschluss und -einschränkung — deterministische Listenprüfung"
|
| 357 |
+
|
| 358 |
+
_EINZELFALL = (
|
| 359 |
+
"Die Verordnung bleibt im medizinisch begründeten Einzelfall mit Begründung möglich "
|
| 360 |
+
"(§ 31 Absatz 1 Satz 4 SGB V, § 16 Absatz 5 AM-RL)."
|
| 361 |
+
)
|
| 362 |
+
# Der Weg des Einzelfalls steht nach der Vorbemerkung nur bei den
|
| 363 |
+
# Rechtsgrundlagen 3 bis 6 offen. Wo eine Zeile auch [1] oder [2] trägt, führt
|
| 364 |
+
# er an ihr vorbei — und das ist der Unterschied zwischen „mit Begründung
|
| 365 |
+
# verordnungsfähig" und „gar nicht".
|
| 366 |
+
_KEIN_EINZELFALL = (
|
| 367 |
+
"Dieser Ausschluss beruht auf dem Gesetz selbst oder auf der Rechtsverordnung nach "
|
| 368 |
+
"§ 34 Absatz 3 SGB V. Der Weg über den medizinisch begründeten Einzelfall (§ 31 "
|
| 369 |
+
"Absatz 1 Satz 4 SGB V) steht nach der Vorbemerkung der Anlage nur bei Ausschlüssen "
|
| 370 |
+
"und Einschränkungen durch die Richtlinie selbst offen und greift hier nicht."
|
| 371 |
+
)
|
| 372 |
+
_GRUPPENVORBEHALT = (
|
| 373 |
+
"Die Anlage bezeichnet Arzneimittelgruppen und Produktklassen, kein einzelnes "
|
| 374 |
+
"Präparat. Ob das konkrete Arzneimittel darunterfällt, ist am Fall zu prüfen."
|
| 375 |
+
)
|
| 376 |
+
_KOMBINATIONSVORBEHALT = (
|
| 377 |
+
"Erfasst ist die genannte Kombination, nicht der Einzelstoff: über dessen Verordnung "
|
| 378 |
+
"sagt der Eintrag nichts."
|
| 379 |
+
)
|
| 380 |
+
# Worauf die Negativauskunft *nicht* trägt. Anlage III nennt sich selbst eine
|
| 381 |
+
# Übersicht und enthält den Satz nicht, auf dem die Negativauskunft der Anlage I
|
| 382 |
+
# ruht („regeln abschließend", § 12 Absatz 10 AM-RL).
|
| 383 |
+
_GRENZE_DER_NEGATIVAUSKUNFT = (
|
| 384 |
+
"Daraus folgt allein, dass dieser Ausschlussgrund nicht greift — keine "
|
| 385 |
+
"Verordnungsfähigkeit. Anlage III ist eine Übersicht und bezeichnet sich nicht als "
|
| 386 |
+
"abschließend; nicht verschreibungspflichtige Arzneimittel sind zudem schon nach § 34 "
|
| 387 |
+
"Absatz 1 Satz 1 SGB V ausgeschlossen (Ausnahmen: Anlage I), und Arzneimittel zur "
|
| 388 |
+
"Erhöhung der Lebensqualität nach § 34 Absatz 1 Satz 7 SGB V (Anlage II, hier nicht "
|
| 389 |
+
"geprüft)."
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _eintrag_block(eintrag: Eintrag) -> str:
|
| 394 |
+
zeilen = [f" Nummer {eintrag.nr}: {eintrag.absaetze[0] if eintrag.absaetze else ''}"]
|
| 395 |
+
for absatz in eintrag.absaetze[1:]:
|
| 396 |
+
zeilen.append(f" {absatz}")
|
| 397 |
+
for hinweis in eintrag.hinweise:
|
| 398 |
+
zeilen.append(f" → {hinweis}")
|
| 399 |
+
return "\n".join(zeilen)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def befund_block(befund: Befund) -> str:
|
| 403 |
+
if not befund.ist_belastbar:
|
| 404 |
+
return ""
|
| 405 |
+
|
| 406 |
+
zeilen: List[str] = [f"{_EINLEITUNG} ({befund.fundstelle}, Stand {befund.stand}):"]
|
| 407 |
+
|
| 408 |
+
if befund.status in {"ausgeschlossen", "eingeschraenkt"}:
|
| 409 |
+
anzahl = len(befund.treffer)
|
| 410 |
+
wo = "in einem Eintrag" if anzahl == 1 else f"in {anzahl} Einträgen"
|
| 411 |
+
wie = (
|
| 412 |
+
"von der Verordnung ausgeschlossen"
|
| 413 |
+
if befund.status == "ausgeschlossen"
|
| 414 |
+
else "in der Verordnung eingeschränkt"
|
| 415 |
+
)
|
| 416 |
+
zeilen.append(
|
| 417 |
+
f"{befund.gefragter_stoff} ist in Anlage III {wo} geführt und dort {wie}. "
|
| 418 |
+
"Maßgeblich ist der Wortlaut des Eintrags samt seiner Ausnahmen; ob eine davon "
|
| 419 |
+
"im Fall vorliegt, ist eine ärztliche Feststellung."
|
| 420 |
+
)
|
| 421 |
+
for eintrag in befund.treffer:
|
| 422 |
+
zeilen.append(_eintrag_block(eintrag))
|
| 423 |
+
zeilen.append(_EINZELFALL if befund.einzelfall_moeglich else _KEIN_EINZELFALL)
|
| 424 |
+
if any(e.kombination for e in befund.treffer):
|
| 425 |
+
zeilen.append(_KOMBINATIONSVORBEHALT)
|
| 426 |
+
if any(e.ist_gruppe for e in befund.treffer):
|
| 427 |
+
zeilen.append(_GRUPPENVORBEHALT)
|
| 428 |
+
else: # nicht_gelistet
|
| 429 |
+
zeilen.append(
|
| 430 |
+
f"Anlage III führt {befund.gefragter_stoff} nicht. {_GRENZE_DER_NEGATIVAUSKUNFT}"
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
if befund.veraltet:
|
| 434 |
+
zeilen.append(
|
| 435 |
+
f"Achtung Aktualität: Dieser Auszug ist {befund.alter_tage} Tage alt. Vor einer "
|
| 436 |
+
"Entscheidung ist die geltende Fassung abzugleichen."
|
| 437 |
+
)
|
| 438 |
+
if befund.url:
|
| 439 |
+
zeilen.append(f"Quelle: {befund.url}")
|
| 440 |
+
|
| 441 |
+
return "\n".join(zeilen)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def verdikt(befund: Befund) -> str:
|
| 445 |
+
"""Die Antwort in einem Satz — nur wenn die Frage entschieden werden konnte.
|
| 446 |
+
|
| 447 |
+
Für „nicht gelistet" bleibt der Satz bewusst leer: die Auskunft trägt hier
|
| 448 |
+
nicht so weit wie bei Anlage I und taugt nicht als Kurzantwort. Der Block
|
| 449 |
+
oben nennt sie trotzdem, weil sie einen Ausschlussgrund ausräumt.
|
| 450 |
+
"""
|
| 451 |
+
nummern = ", ".join(e.nr for e in befund.treffer)
|
| 452 |
+
|
| 453 |
+
if befund.status == "ausgeschlossen":
|
| 454 |
+
satz = (
|
| 455 |
+
f"{befund.gefragter_stoff} ist nach Anlage III der AM-RL von der Verordnung zu "
|
| 456 |
+
f"Lasten der GKV ausgeschlossen (Nummer {nummern})"
|
| 457 |
+
)
|
| 458 |
+
elif befund.status == "eingeschraenkt":
|
| 459 |
+
satz = (
|
| 460 |
+
f"{befund.gefragter_stoff} ist nach Anlage III der AM-RL nur eingeschränkt "
|
| 461 |
+
f"verordnungsfähig (Nummer {nummern})"
|
| 462 |
+
)
|
| 463 |
+
else:
|
| 464 |
+
return ""
|
| 465 |
+
|
| 466 |
+
zusatz = (
|
| 467 |
+
" — im medizinisch begründeten Einzelfall bleibt die Verordnung mit Begründung möglich"
|
| 468 |
+
if befund.einzelfall_moeglich
|
| 469 |
+
else " — auch der medizinisch begründete Einzelfall trägt hier nicht"
|
| 470 |
+
)
|
| 471 |
+
return f"{satz}{zusatz} (Listenprüfung oben, Stand {befund.stand})."
|
src/answer_composer.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import re
|
| 4 |
-
from typing import Any, Dict, List, Optional, Tuple, Set
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
# ---------------------------------------------------------------------------
|
|
@@ -37,6 +39,44 @@ _ANSWER_REQUEST_PATTERNS = [
|
|
| 37 |
|
| 38 |
_SOURCE_MARKER_RE = re.compile(r"\[Quelle\s+(\d+)\]", re.I)
|
| 39 |
_SOURCE_MARKER_MULTI_RE = re.compile(r"\[Quellen\s+([\d,\s]+)\]", re.I)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
_GRANULAR_BUCHSTABE_REF_RE = re.compile(
|
| 41 |
r"(§\s*\d{1,3}[a-z]?\s+Abs\.?\s*\d+[a-z]?)\s+"
|
| 42 |
r"(?:Buchst\.?|Buchstabe)\s*([a-z])",
|
|
@@ -180,6 +220,30 @@ Wenn die bereitgestellte Textstelle nur auf eine externe Norm verweist:
|
|
| 180 |
|
| 181 |
Das ist eine gültige, vollständige Antwort und NICHT "nicht geregelt".
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
=========================
|
| 184 |
QUELLEN
|
| 185 |
=========================
|
|
@@ -240,6 +304,21 @@ Keine Quellenliste am Ende.
|
|
| 240 |
""".strip()
|
| 241 |
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
def _normalize(text: str) -> str:
|
| 244 |
return (
|
| 245 |
" ".join((text or "").lower().strip().split())
|
|
@@ -447,6 +526,23 @@ class AnswerComposer:
|
|
| 447 |
def _section(cls, hit: Dict[str, Any]) -> str:
|
| 448 |
return str(cls._hit_value(hit, "section", "section_id", default="ohne Abschnitt"))
|
| 449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
@classmethod
|
| 451 |
def _chunk_index(cls, hit: Dict[str, Any]) -> Any:
|
| 452 |
return cls._hit_value(hit, "chunk_index", "chunk_index_in_section", default="?")
|
|
@@ -591,6 +687,7 @@ class AnswerComposer:
|
|
| 591 |
return ("hash", text_hash)
|
| 592 |
|
| 593 |
return (
|
|
|
|
| 594 |
cls._container(hit),
|
| 595 |
cls._section(hit),
|
| 596 |
cls._page_range(hit),
|
|
@@ -598,8 +695,11 @@ class AnswerComposer:
|
|
| 598 |
)
|
| 599 |
|
| 600 |
@classmethod
|
| 601 |
-
def _source_display_key(cls, hit: Dict[str, Any]) -> Tuple[Any,
|
|
|
|
|
|
|
| 602 |
return (
|
|
|
|
| 603 |
cls._container(hit),
|
| 604 |
cls._section(hit),
|
| 605 |
cls._page_range(hit),
|
|
@@ -882,7 +982,13 @@ class AnswerComposer:
|
|
| 882 |
# Chunk, damit die UI die Fundstelle im PDF ansteuern kann.
|
| 883 |
page_start, page_end = cls._page_bounds(hit)
|
| 884 |
source_file = str(cls._hit_value(hit, "source_file", default="") or "")
|
| 885 |
-
doc_id =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 886 |
highlight = cls._highlight_text(hit)
|
| 887 |
highlight_entry = (
|
| 888 |
{"page_start": page_start, "page_end": page_end, "text": highlight}
|
|
@@ -906,6 +1012,8 @@ class AnswerComposer:
|
|
| 906 |
"page_end": page_end,
|
| 907 |
"source_file": source_file,
|
| 908 |
"doc_id": doc_id,
|
|
|
|
|
|
|
| 909 |
"highlights": [highlight_entry] if highlight_entry else [],
|
| 910 |
"path": cls._hit_value(hit, "path", "section_path", default=""),
|
| 911 |
"score": round(cls._score(hit), 4),
|
|
@@ -924,6 +1032,8 @@ class AnswerComposer:
|
|
| 924 |
item["source_file"] = source_file
|
| 925 |
if doc_id and not item.get("doc_id"):
|
| 926 |
item["doc_id"] = doc_id
|
|
|
|
|
|
|
| 927 |
if highlight_entry is not None:
|
| 928 |
existing_texts = {(h.get("text") or "")[:120] for h in item.get("highlights") or []}
|
| 929 |
if highlight[:120] not in existing_texts:
|
|
@@ -933,13 +1043,8 @@ class AnswerComposer:
|
|
| 933 |
item["source_numbers"].append(number)
|
| 934 |
item["source_numbers"].sort()
|
| 935 |
item["source_number"] = item["source_numbers"][0]
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
item["source_label"] = item["source_marker"]
|
| 939 |
-
else:
|
| 940 |
-
joined = ", ".join(str(n) for n in item["source_numbers"])
|
| 941 |
-
item["source_marker"] = f"[Quellen {joined}]"
|
| 942 |
-
item["source_label"] = item["source_marker"]
|
| 943 |
|
| 944 |
if canonical and canonical not in item["canonical_refs"]:
|
| 945 |
item["canonical_refs"].append(canonical)
|
|
@@ -954,14 +1059,7 @@ class AnswerComposer:
|
|
| 954 |
sources = list(grouped.values())
|
| 955 |
|
| 956 |
for source in sources:
|
| 957 |
-
|
| 958 |
-
if numbers:
|
| 959 |
-
if len(numbers) == 1:
|
| 960 |
-
label = f"[Quelle {numbers[0]}]"
|
| 961 |
-
else:
|
| 962 |
-
label = "[Quellen " + ", ".join(str(n) for n in numbers) + "]"
|
| 963 |
-
else:
|
| 964 |
-
label = ""
|
| 965 |
source["source_marker"] = label
|
| 966 |
source["source_label"] = label
|
| 967 |
canonical_refs = source.get("canonical_refs") or []
|
|
@@ -1001,14 +1099,7 @@ class AnswerComposer:
|
|
| 1001 |
pages = source.get("page_range", "?")
|
| 1002 |
marker = source.get("source_marker") or source.get("source_label") or ""
|
| 1003 |
if not marker:
|
| 1004 |
-
|
| 1005 |
-
if numbers:
|
| 1006 |
-
if len(numbers) == 1:
|
| 1007 |
-
marker = f"[Quelle {numbers[0]}]"
|
| 1008 |
-
else:
|
| 1009 |
-
marker = "[Quellen " + ", ".join(str(n) for n in numbers) + "]"
|
| 1010 |
-
else:
|
| 1011 |
-
marker = "-"
|
| 1012 |
|
| 1013 |
role = ""
|
| 1014 |
kinds = set(source.get("retrieval_kinds") or [])
|
|
@@ -1049,8 +1140,22 @@ class AnswerComposer:
|
|
| 1049 |
|
| 1050 |
chunk_kind = cls._hit_value(hit, "chunk_kind", default="")
|
| 1051 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1052 |
return (
|
| 1053 |
f"[Quelle {index}]\n"
|
|
|
|
|
|
|
| 1054 |
f"Container: {container}\n"
|
| 1055 |
f"Abschnitt: {section}\n"
|
| 1056 |
f"Norm: {canonical_ref}\n"
|
|
@@ -1233,6 +1338,28 @@ class AnswerComposer:
|
|
| 1233 |
pass
|
| 1234 |
return numbers
|
| 1235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1236 |
@classmethod
|
| 1237 |
def _postprocess_document_answer(
|
| 1238 |
cls,
|
|
@@ -1308,76 +1435,83 @@ class AnswerComposer:
|
|
| 1308 |
|
| 1309 |
return text
|
| 1310 |
|
| 1311 |
-
# Abschnitts-Überschriften, die als leere Reste entfernt werden dürfen.
|
| 1312 |
-
# Enthält die Labels des aktuellen Schemas sowie Alt-Reste.
|
| 1313 |
-
_SECTION_HEADINGS = (
|
| 1314 |
-
"Kurzantwort",
|
| 1315 |
-
"Maßgebliche Norm",
|
| 1316 |
-
"Maßgebliche Norm(en)",
|
| 1317 |
-
"Wortlaut / Kriterien",
|
| 1318 |
-
"Wortlaut",
|
| 1319 |
-
"Einordnung",
|
| 1320 |
-
"Ausnahmen",
|
| 1321 |
-
"Ergebnis",
|
| 1322 |
-
"Heilungen",
|
| 1323 |
-
"Retaxationsgrenzen",
|
| 1324 |
-
)
|
| 1325 |
-
|
| 1326 |
@classmethod
|
| 1327 |
def _strip_empty_section_headings(cls, text: str) -> str:
|
| 1328 |
-
"""Entfernt
|
| 1329 |
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1333 |
"""
|
| 1334 |
if not text:
|
| 1335 |
return text
|
| 1336 |
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
+ r"):?\s*$",
|
| 1341 |
-
re.I,
|
| 1342 |
-
)
|
| 1343 |
|
| 1344 |
-
|
| 1345 |
-
|
|
|
|
| 1346 |
|
| 1347 |
-
|
| 1348 |
-
|
|
|
|
| 1349 |
continue
|
| 1350 |
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
|
| 1356 |
-
|
| 1357 |
-
# Überschrift folgt.
|
| 1358 |
-
if j >= len(lines) or label_re.match(lines[j].strip()):
|
| 1359 |
-
keep[i] = False
|
| 1360 |
|
| 1361 |
-
|
|
|
|
|
|
|
| 1362 |
|
| 1363 |
@classmethod
|
| 1364 |
def _strip_invalid_source_markers(cls, answer: str, used_hits: List[Dict[str, Any]]) -> str:
|
| 1365 |
-
"""Entfernt Marker wie [Quelle 9], wenn Quelle 9 nicht im Kontext stand.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1366 |
valid_numbers = cls._used_source_numbers(used_hits)
|
| 1367 |
-
if not valid_numbers:
|
| 1368 |
-
return _SOURCE_MARKER_RE.sub("", answer or "").strip()
|
| 1369 |
|
| 1370 |
def repl(match: re.Match[str]) -> str:
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
return ""
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
cleaned =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1381 |
return cleaned.strip()
|
| 1382 |
|
| 1383 |
# ------------------------------------------------------------------
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import re
|
| 4 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Set
|
| 5 |
|
| 6 |
+
import norm_rang
|
| 7 |
+
from answer_schema import SHORT_ANSWER_HEADING, is_denial, split_sections
|
| 8 |
+
from llm_client_groq import ConversationMemory, is_meta_question
|
| 9 |
|
| 10 |
|
| 11 |
# ---------------------------------------------------------------------------
|
|
|
|
| 39 |
|
| 40 |
_SOURCE_MARKER_RE = re.compile(r"\[Quelle\s+(\d+)\]", re.I)
|
| 41 |
_SOURCE_MARKER_MULTI_RE = re.compile(r"\[Quellen\s+([\d,\s]+)\]", re.I)
|
| 42 |
+
|
| 43 |
+
# A citation as it actually occurs in an answer: the marker plus the
|
| 44 |
+
# enrichment tail the model likes to attach, e.g. "[Quelle 3] (§ 6 Abs. 1)".
|
| 45 |
+
# Removing only the marker leaves the tail behind as a free-floating "(§ 6)".
|
| 46 |
+
#
|
| 47 |
+
# Both the singular and the plural form matter. "[Quellen 3, 8]" is not a model
|
| 48 |
+
# invention — `build_sources` produces it whenever grouped chunks share a
|
| 49 |
+
# display entry — so a validator that only knows "[Quelle n]" lets every plural
|
| 50 |
+
# marker through unchecked, valid or not.
|
| 51 |
+
_CITATION_ATOM_RE = re.compile(
|
| 52 |
+
r"\[Quellen?\s+(?P<numbers>\d+(?:\s*,\s*\d+)*)\]"
|
| 53 |
+
r"(?P<tail>\s*\(§[^)]{1,120}\))?",
|
| 54 |
+
re.I,
|
| 55 |
+
)
|
| 56 |
+
_MARKER_NUMBER_RE = re.compile(r"\d+")
|
| 57 |
+
|
| 58 |
+
# Run after removing citation atoms. A stripped citation takes its text with it
|
| 59 |
+
# but not the punctuation that separated it from the next one, which is how a
|
| 60 |
+
# denial ended up reading "… in den bereitgestellten Quellen , , , , ,.".
|
| 61 |
+
# Ordered: separator runs first, then the leftovers that produces.
|
| 62 |
+
_CITATION_CLEANUP: Tuple[Tuple[re.Pattern[str], str], ...] = (
|
| 63 |
+
# Empty parentheses from a removed reference tail.
|
| 64 |
+
(re.compile(r"\(\s*\)"), ""),
|
| 65 |
+
# A run of separators immediately before sentence punctuation: drop it whole.
|
| 66 |
+
(re.compile(r"(?:\s*[,;])+\s*(?=[.;:!?])"), ""),
|
| 67 |
+
# A run of separators inside a sentence collapses to one.
|
| 68 |
+
(re.compile(r"(?:\s*,){2,}"), ","),
|
| 69 |
+
# ", und [Quelle n]" — the item between them was removed.
|
| 70 |
+
(re.compile(r",(\s*\b(?:und|oder)\b\s)", re.I), r"\1"),
|
| 71 |
+
# "Text und [Quelle n]" — the first item of the list was removed. The
|
| 72 |
+
# lookbehind on a word character is what keeps a real list ("[Quelle 1] und
|
| 73 |
+
# [Quelle 2]", preceded by "]") intact.
|
| 74 |
+
(re.compile(r"(?<=\w)\s+\b(?:und|oder)\b\s+(?=\[Quelle)", re.I), " "),
|
| 75 |
+
# A dangling conjunction where the trailing citation was removed.
|
| 76 |
+
(re.compile(r"\s+\b(?:und|oder)\b\s*(?=[.;:!?])", re.I), ""),
|
| 77 |
+
(re.compile(r"\s+([,.;:!?])"), r"\1"),
|
| 78 |
+
(re.compile(r"[ \t]{2,}"), " "),
|
| 79 |
+
)
|
| 80 |
_GRANULAR_BUCHSTABE_REF_RE = re.compile(
|
| 81 |
r"(§\s*\d{1,3}[a-z]?\s+Abs\.?\s*\d+[a-z]?)\s+"
|
| 82 |
r"(?:Buchst\.?|Buchstabe)\s*([a-z])",
|
|
|
|
| 220 |
|
| 221 |
Das ist eine gültige, vollständige Antwort und NICHT "nicht geregelt".
|
| 222 |
|
| 223 |
+
=========================
|
| 224 |
+
NORMRANG
|
| 225 |
+
=========================
|
| 226 |
+
|
| 227 |
+
Jede Quelle nennt im Feld "Rang", was sie ist: Gesetz, Richtlinie, Vertrag
|
| 228 |
+
oder Anlage. Bei Vertrag und Richtlinie steht dort zusätzlich die Vorschrift,
|
| 229 |
+
auf der das Regelwerk beruht.
|
| 230 |
+
|
| 231 |
+
Maßgeblich ist die Vorschrift, die die Frage entscheidet — nicht die, die der
|
| 232 |
+
Frage am ähnlichsten klingt.
|
| 233 |
+
|
| 234 |
+
Wenn der Kontext eine ausführende Regelung (Vertrag, Richtlinie, Anlage) UND
|
| 235 |
+
die Norm enthält, auf der sie beruht:
|
| 236 |
+
|
| 237 |
+
- Nenne beide unter "Maßgebliche Norm".
|
| 238 |
+
- Nenne zuerst die Norm, aus der sich die Rechtsfolge ergibt, danach die
|
| 239 |
+
Regelung, die sie ausführt.
|
| 240 |
+
|
| 241 |
+
Nenne eine Anlage nicht als alleinige maßgebliche Norm, wenn die Vorschrift,
|
| 242 |
+
die auf sie verweist, im Kontext steht.
|
| 243 |
+
|
| 244 |
+
Erschließe den Rang niemals aus dem Namen eines Dokuments, sondern
|
| 245 |
+
ausschließlich aus dem Feld "Rang".
|
| 246 |
+
|
| 247 |
=========================
|
| 248 |
QUELLEN
|
| 249 |
=========================
|
|
|
|
| 304 |
""".strip()
|
| 305 |
|
| 306 |
|
| 307 |
+
def _format_source_marker(numbers: Sequence[int]) -> str:
|
| 308 |
+
"""The one place that decides how a citation marker is written.
|
| 309 |
+
|
| 310 |
+
The stripper parses these markers back out, so builder and parser have to
|
| 311 |
+
agree on the format — three separate copies of the f-string were one edit
|
| 312 |
+
away from drifting apart.
|
| 313 |
+
"""
|
| 314 |
+
ordered = list(numbers)
|
| 315 |
+
if not ordered:
|
| 316 |
+
return ""
|
| 317 |
+
if len(ordered) == 1:
|
| 318 |
+
return f"[Quelle {ordered[0]}]"
|
| 319 |
+
return "[Quellen " + ", ".join(str(n) for n in ordered) + "]"
|
| 320 |
+
|
| 321 |
+
|
| 322 |
def _normalize(text: str) -> str:
|
| 323 |
return (
|
| 324 |
" ".join((text or "").lower().strip().split())
|
|
|
|
| 526 |
def _section(cls, hit: Dict[str, Any]) -> str:
|
| 527 |
return str(cls._hit_value(hit, "section", "section_id", default="ohne Abschnitt"))
|
| 528 |
|
| 529 |
+
@classmethod
|
| 530 |
+
def _doc_id(cls, hit: Dict[str, Any]) -> str:
|
| 531 |
+
return str(cls._hit_value(hit, "doc_id", default="") or "")
|
| 532 |
+
|
| 533 |
+
@classmethod
|
| 534 |
+
def _doc_title(cls, hit: Dict[str, Any]) -> str:
|
| 535 |
+
"""Kurzer Dokumentname für Zitate über mehrere Korpora hinweg.
|
| 536 |
+
|
| 537 |
+
Ohne diese Angabe ist eine Quelle mehrdeutig: Rahmenvertrag und SGB V
|
| 538 |
+
teilen sich 34 §-Nummern mit völlig verschiedenem Inhalt (§ 16 ist einmal
|
| 539 |
+
"Teilmenge, Auseinzelung", einmal "Ruhen des Anspruchs"). Leerer String
|
| 540 |
+
bei alten Collections ohne doc_title -> Anzeige fällt auf das bisherige
|
| 541 |
+
Format zurück.
|
| 542 |
+
"""
|
| 543 |
+
title = str(cls._hit_value(hit, "doc_title", default="") or "").strip()
|
| 544 |
+
return title or cls._doc_id(hit)
|
| 545 |
+
|
| 546 |
@classmethod
|
| 547 |
def _chunk_index(cls, hit: Dict[str, Any]) -> Any:
|
| 548 |
return cls._hit_value(hit, "chunk_index", "chunk_index_in_section", default="?")
|
|
|
|
| 687 |
return ("hash", text_hash)
|
| 688 |
|
| 689 |
return (
|
| 690 |
+
cls._doc_id(hit),
|
| 691 |
cls._container(hit),
|
| 692 |
cls._section(hit),
|
| 693 |
cls._page_range(hit),
|
|
|
|
| 695 |
)
|
| 696 |
|
| 697 |
@classmethod
|
| 698 |
+
def _source_display_key(cls, hit: Dict[str, Any]) -> Tuple[Any, ...]:
|
| 699 |
+
# doc_id zuerst: sonst könnten gleichnamige Abschnitte aus verschiedenen
|
| 700 |
+
# Dokumenten zu einer einzigen Quelle verschmelzen.
|
| 701 |
return (
|
| 702 |
+
cls._doc_id(hit),
|
| 703 |
cls._container(hit),
|
| 704 |
cls._section(hit),
|
| 705 |
cls._page_range(hit),
|
|
|
|
| 982 |
# Chunk, damit die UI die Fundstelle im PDF ansteuern kann.
|
| 983 |
page_start, page_end = cls._page_bounds(hit)
|
| 984 |
source_file = str(cls._hit_value(hit, "source_file", default="") or "")
|
| 985 |
+
doc_id = cls._doc_id(hit)
|
| 986 |
+
doc_title = cls._doc_title(hit)
|
| 987 |
+
# Vom föderierenden Retriever je Treffer gesetzt. Ohne Durchreichen
|
| 988 |
+
# weiß die API-Schicht nicht mehr, aus welchem Korpus eine Quelle
|
| 989 |
+
# stammt; korpusbezogene Hinweise (corpus_amendments.py) laufen dann
|
| 990 |
+
# ins Leere.
|
| 991 |
+
corpus_id = str(cls._hit_value(hit, "corpus_id", default="") or "")
|
| 992 |
highlight = cls._highlight_text(hit)
|
| 993 |
highlight_entry = (
|
| 994 |
{"page_start": page_start, "page_end": page_end, "text": highlight}
|
|
|
|
| 1012 |
"page_end": page_end,
|
| 1013 |
"source_file": source_file,
|
| 1014 |
"doc_id": doc_id,
|
| 1015 |
+
"doc_title": doc_title,
|
| 1016 |
+
"corpus_id": corpus_id,
|
| 1017 |
"highlights": [highlight_entry] if highlight_entry else [],
|
| 1018 |
"path": cls._hit_value(hit, "path", "section_path", default=""),
|
| 1019 |
"score": round(cls._score(hit), 4),
|
|
|
|
| 1032 |
item["source_file"] = source_file
|
| 1033 |
if doc_id and not item.get("doc_id"):
|
| 1034 |
item["doc_id"] = doc_id
|
| 1035 |
+
if doc_title and not item.get("doc_title"):
|
| 1036 |
+
item["doc_title"] = doc_title
|
| 1037 |
if highlight_entry is not None:
|
| 1038 |
existing_texts = {(h.get("text") or "")[:120] for h in item.get("highlights") or []}
|
| 1039 |
if highlight[:120] not in existing_texts:
|
|
|
|
| 1043 |
item["source_numbers"].append(number)
|
| 1044 |
item["source_numbers"].sort()
|
| 1045 |
item["source_number"] = item["source_numbers"][0]
|
| 1046 |
+
item["source_marker"] = _format_source_marker(item["source_numbers"])
|
| 1047 |
+
item["source_label"] = item["source_marker"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1048 |
|
| 1049 |
if canonical and canonical not in item["canonical_refs"]:
|
| 1050 |
item["canonical_refs"].append(canonical)
|
|
|
|
| 1059 |
sources = list(grouped.values())
|
| 1060 |
|
| 1061 |
for source in sources:
|
| 1062 |
+
label = _format_source_marker(source.get("source_numbers") or [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1063 |
source["source_marker"] = label
|
| 1064 |
source["source_label"] = label
|
| 1065 |
canonical_refs = source.get("canonical_refs") or []
|
|
|
|
| 1099 |
pages = source.get("page_range", "?")
|
| 1100 |
marker = source.get("source_marker") or source.get("source_label") or ""
|
| 1101 |
if not marker:
|
| 1102 |
+
marker = _format_source_marker(source.get("source_numbers") or []) or "-"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1103 |
|
| 1104 |
role = ""
|
| 1105 |
kinds = set(source.get("retrieval_kinds") or [])
|
|
|
|
| 1140 |
|
| 1141 |
chunk_kind = cls._hit_value(hit, "chunk_kind", default="")
|
| 1142 |
|
| 1143 |
+
# Das Dokument steht bewusst als erste Zeile: das Modell muss § 16 des
|
| 1144 |
+
# Rahmenvertrags von § 16 SGB V unterscheiden können, bevor es zitiert.
|
| 1145 |
+
doc_title = cls._doc_title(hit)
|
| 1146 |
+
doc_line = f"Dokument: {doc_title}\n" if doc_title else ""
|
| 1147 |
+
|
| 1148 |
+
# Und gleich darunter, was das Dokument ist. Ohne diese Zeile stand die
|
| 1149 |
+
# Anlage neben dem Gesetz, ohne dass etwas im Kontext den Unterschied
|
| 1150 |
+
# benannte — und die Antwort führte gelegentlich die Ausführungsregelung
|
| 1151 |
+
# als „Maßgebliche Norm".
|
| 1152 |
+
rang_wert = norm_rang.rang(hit)
|
| 1153 |
+
rang_line = f"Rang: {rang_wert}\n" if rang_wert else ""
|
| 1154 |
+
|
| 1155 |
return (
|
| 1156 |
f"[Quelle {index}]\n"
|
| 1157 |
+
f"{doc_line}"
|
| 1158 |
+
f"{rang_line}"
|
| 1159 |
f"Container: {container}\n"
|
| 1160 |
f"Abschnitt: {section}\n"
|
| 1161 |
f"Norm: {canonical_ref}\n"
|
|
|
|
| 1338 |
pass
|
| 1339 |
return numbers
|
| 1340 |
|
| 1341 |
+
@classmethod
|
| 1342 |
+
def cited_hits(cls, answer: str, hits: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 1343 |
+
"""Die Treffer, auf die sich die Antwort mit ``[Quelle n]`` beruft.
|
| 1344 |
+
|
| 1345 |
+
Der Trefferpool ist nicht dasselbe wie die herangezogenen Textstellen:
|
| 1346 |
+
das Modell bekommt acht Quellen und zitiert regelmäßig zwei. Wer über
|
| 1347 |
+
die Antwort etwas aussagen will — was sie belegt, worauf sie verweist,
|
| 1348 |
+
wo ihre Grenze liegt —, muss die zitierten meinen und nicht den Pool.
|
| 1349 |
+
|
| 1350 |
+
Die Nummern sind die Ränge aus dem RAG-Kontext. Der Aufruf gehört
|
| 1351 |
+
deshalb **vor** die Umnummerierung für die Anzeige; danach trägt der
|
| 1352 |
+
Antworttext Anzeigenummern und die Treffer weiter ihre Ränge.
|
| 1353 |
+
|
| 1354 |
+
Leere Rückgabe heißt „nicht feststellbar" und nicht „keine": eine
|
| 1355 |
+
Antwort ohne Marker lässt keine Aussage darüber zu, und der Aufrufer
|
| 1356 |
+
entscheidet, was das für ihn bedeutet.
|
| 1357 |
+
"""
|
| 1358 |
+
numbers = cls._referenced_source_numbers(answer)
|
| 1359 |
+
if not numbers:
|
| 1360 |
+
return []
|
| 1361 |
+
return [hit for hit in hits or [] if cls._source_number(hit) in numbers]
|
| 1362 |
+
|
| 1363 |
@classmethod
|
| 1364 |
def _postprocess_document_answer(
|
| 1365 |
cls,
|
|
|
|
| 1435 |
|
| 1436 |
return text
|
| 1437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1438 |
@classmethod
|
| 1439 |
def _strip_empty_section_headings(cls, text: str) -> str:
|
| 1440 |
+
"""Entfernt Abschnitte ohne eigenen Inhalt.
|
| 1441 |
|
| 1442 |
+
"Ohne Inhalt" heißt zweierlei: es folgt gar keine Zeile — oder es folgt
|
| 1443 |
+
nur die Feststellung, dass es nichts gibt. Der Prompt verlangt beides
|
| 1444 |
+
wegzulassen ("Abschnitte ohne Evidenz vollständig weglassen"); das Modell
|
| 1445 |
+
hält sich an die Überschriften und schreibt viermal "Es gibt keine ...".
|
| 1446 |
+
|
| 1447 |
+
Die Kurzantwort bleibt immer stehen: sie *ist* die Antwort, auch wenn sie
|
| 1448 |
+
negativ ausfällt. Bliebe sonst überhaupt nichts übrig, bleibt der Text
|
| 1449 |
+
unverändert — eine leere Antwort ist schlechter als eine redundante.
|
| 1450 |
"""
|
| 1451 |
if not text:
|
| 1452 |
return text
|
| 1453 |
|
| 1454 |
+
blocks = split_sections(text)
|
| 1455 |
+
if not any(heading for heading, _ in blocks):
|
| 1456 |
+
return text
|
|
|
|
|
|
|
|
|
|
| 1457 |
|
| 1458 |
+
parts: List[str] = []
|
| 1459 |
+
for heading, body_lines in blocks:
|
| 1460 |
+
body = "\n".join(body_lines).strip()
|
| 1461 |
|
| 1462 |
+
if heading is None:
|
| 1463 |
+
if body:
|
| 1464 |
+
parts.append(body)
|
| 1465 |
continue
|
| 1466 |
|
| 1467 |
+
if not body:
|
| 1468 |
+
continue
|
| 1469 |
+
if heading != SHORT_ANSWER_HEADING and is_denial(body):
|
| 1470 |
+
continue
|
| 1471 |
|
| 1472 |
+
parts.append(f"{heading}:\n{body}")
|
|
|
|
|
|
|
|
|
|
| 1473 |
|
| 1474 |
+
if not parts:
|
| 1475 |
+
return text
|
| 1476 |
+
return "\n\n".join(parts)
|
| 1477 |
|
| 1478 |
@classmethod
|
| 1479 |
def _strip_invalid_source_markers(cls, answer: str, used_hits: List[Dict[str, Any]]) -> str:
|
| 1480 |
+
"""Entfernt Marker wie [Quelle 9], wenn Quelle 9 nicht im Kontext stand.
|
| 1481 |
+
|
| 1482 |
+
Entfernt wird die ganze Zitat-Einheit: der Marker, eine angehängte
|
| 1483 |
+
Fundstelle wie "(§ 6 Abs. 1)" und die Trennzeichen, die sie mit dem
|
| 1484 |
+
nächsten Zitat verbanden. Blieben die Trennzeichen stehen, las sich eine
|
| 1485 |
+
Verneinung als „… in den bereitgestellten Quellen , , , , ,." — sechs
|
| 1486 |
+
entfernte Marker, deren Kommata den Eindruck von sechs Belegen
|
| 1487 |
+
hinterließen.
|
| 1488 |
+
|
| 1489 |
+
Sammelmarker werden auf ihren belegten Teil zurückgeführt: aus
|
| 1490 |
+
"[Quellen 3, 8]" wird "[Quelle 3]", wenn nur 3 im Kontext stand. Nur wenn
|
| 1491 |
+
keine einzige Nummer trägt, fällt die ganze Einheit weg. Ein Sammelmarker
|
| 1492 |
+
bündelt mehrere Belege für dieselbe Aussage — der belegte Teil bleibt
|
| 1493 |
+
richtig, auch wenn der Rest erfunden war.
|
| 1494 |
+
"""
|
| 1495 |
valid_numbers = cls._used_source_numbers(used_hits)
|
|
|
|
|
|
|
| 1496 |
|
| 1497 |
def repl(match: re.Match[str]) -> str:
|
| 1498 |
+
numbers = [int(n) for n in _MARKER_NUMBER_RE.findall(match.group("numbers"))]
|
| 1499 |
+
kept = [n for n in numbers if n in valid_numbers]
|
| 1500 |
+
if not kept:
|
| 1501 |
return ""
|
| 1502 |
+
if kept == numbers:
|
| 1503 |
+
return match.group(0)
|
| 1504 |
+
return f"{_format_source_marker(kept)}{match.group('tail') or ''}"
|
| 1505 |
+
|
| 1506 |
+
original = answer or ""
|
| 1507 |
+
cleaned = _CITATION_ATOM_RE.sub(repl, original)
|
| 1508 |
+
if cleaned == original:
|
| 1509 |
+
return cleaned.strip()
|
| 1510 |
+
|
| 1511 |
+
# Zeilenumbrüche und Schema bleiben erhalten; bereinigt wird nur, was das
|
| 1512 |
+
# Entfernen selbst hinterlassen hat.
|
| 1513 |
+
for pattern, replacement in _CITATION_CLEANUP:
|
| 1514 |
+
cleaned = pattern.sub(replacement, cleaned)
|
| 1515 |
return cleaned.strip()
|
| 1516 |
|
| 1517 |
# ------------------------------------------------------------------
|
src/answer_schema.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The answer schema: its headings, and what an empty section looks like.
|
| 2 |
+
|
| 3 |
+
`_DOCUMENT_TASK_PROMPT` prescribes a fixed set of section headings and tells the
|
| 4 |
+
model to leave out any section it has no evidence for. Models keep the headings
|
| 5 |
+
and ignore the omission rule: instead of dropping a section they fill it with
|
| 6 |
+
"Es gibt keine ... in den bereitgestellten Quellen". A denial therefore arrives
|
| 7 |
+
stated four times over, once per heading.
|
| 8 |
+
|
| 9 |
+
Two places have to recognise that filler — `answer_composer`, which enforces the
|
| 10 |
+
schema, and `corpus_amendments`, which rewrites a denial once it knows a later
|
| 11 |
+
amendment does regulate the point. The recognition lives here so neither owns
|
| 12 |
+
it and the two cannot drift apart. This module deliberately has no imports
|
| 13 |
+
beyond the standard library.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import re
|
| 19 |
+
from typing import List, Optional, Tuple
|
| 20 |
+
|
| 21 |
+
# The current schema plus labels from earlier versions of it. Recognising a
|
| 22 |
+
# stale label costs nothing and lets an old cached answer be cleaned up too.
|
| 23 |
+
SECTION_HEADINGS: Tuple[str, ...] = (
|
| 24 |
+
"Kurzantwort",
|
| 25 |
+
"Maßgebliche Norm",
|
| 26 |
+
"Maßgebliche Norm(en)",
|
| 27 |
+
"Wortlaut / Kriterien",
|
| 28 |
+
"Wortlaut",
|
| 29 |
+
"Einordnung",
|
| 30 |
+
"Ausnahmen",
|
| 31 |
+
"Ergebnis",
|
| 32 |
+
"Heilungen",
|
| 33 |
+
"Retaxationsgrenzen",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# The one section that is never dropped: it *is* the answer, negative or not.
|
| 37 |
+
SHORT_ANSWER_HEADING = "Kurzantwort"
|
| 38 |
+
|
| 39 |
+
_HEADING_PREFIX_RE = re.compile(r"^#+\s*")
|
| 40 |
+
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
|
| 41 |
+
|
| 42 |
+
# Deliberately narrow. This must match the "nothing found" filler the schema
|
| 43 |
+
# produces and nothing else — a false positive deletes real legal content.
|
| 44 |
+
_DENIAL_RE = re.compile(
|
| 45 |
+
r"es\s+gibt\s+kein"
|
| 46 |
+
r"|enthalten\s+(?:hierzu|dazu)\s+kein"
|
| 47 |
+
r"|liegt\s+(?:hierzu|dazu)?\s*kein"
|
| 48 |
+
r"|kein\w*\s+(?:relevante[nrs]?\s+)?"
|
| 49 |
+
r"(?:regelung|norm|vorschrift|textstelle|aussage|angabe|einordnung|information)"
|
| 50 |
+
r"|nichts?\s+(?:geregelt|enthalten|auffindbar|ersichtlich)"
|
| 51 |
+
r"|belastbare?\s+juristische\s+antwort",
|
| 52 |
+
re.I,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def heading_key(line: str) -> Optional[str]:
|
| 57 |
+
"""The schema heading a line represents, or None.
|
| 58 |
+
|
| 59 |
+
Tolerates a markdown prefix and a missing colon, both of which models
|
| 60 |
+
produce despite the prompt asking for plain text.
|
| 61 |
+
"""
|
| 62 |
+
text = _HEADING_PREFIX_RE.sub("", (line or "").strip())
|
| 63 |
+
if text.endswith(":"):
|
| 64 |
+
text = text[:-1]
|
| 65 |
+
text = re.sub(r"\s+", " ", re.sub(r"\s*/\s*", " / ", text.strip()))
|
| 66 |
+
if not text:
|
| 67 |
+
return None
|
| 68 |
+
for heading in SECTION_HEADINGS:
|
| 69 |
+
if text.lower() == heading.lower():
|
| 70 |
+
return heading
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def split_sections(answer: str) -> List[Tuple[Optional[str], List[str]]]:
|
| 75 |
+
"""Split an answer into (heading | None, body lines) blocks, in order.
|
| 76 |
+
|
| 77 |
+
The first block carries `None` as its heading and holds whatever preceded
|
| 78 |
+
the first one — usually nothing, but never assume that.
|
| 79 |
+
"""
|
| 80 |
+
blocks: List[Tuple[Optional[str], List[str]]] = []
|
| 81 |
+
heading: Optional[str] = None
|
| 82 |
+
body: List[str] = []
|
| 83 |
+
|
| 84 |
+
for line in (answer or "").split("\n"):
|
| 85 |
+
found = heading_key(line)
|
| 86 |
+
if found is None:
|
| 87 |
+
body.append(line)
|
| 88 |
+
continue
|
| 89 |
+
blocks.append((heading, body))
|
| 90 |
+
heading, body = found, []
|
| 91 |
+
|
| 92 |
+
blocks.append((heading, body))
|
| 93 |
+
return blocks
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def is_denial(body: str) -> bool:
|
| 97 |
+
"""True if every sentence of `body` merely states that nothing was found.
|
| 98 |
+
|
| 99 |
+
A single § anywhere disqualifies the body: the model only names a provision
|
| 100 |
+
when it found one, whatever it then claims about it.
|
| 101 |
+
"""
|
| 102 |
+
text = (body or "").strip()
|
| 103 |
+
if not text or "§" in text:
|
| 104 |
+
return False
|
| 105 |
+
sentences = [s for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()]
|
| 106 |
+
return bool(sentences) and all(_DENIAL_RE.search(s) for s in sentences)
|
src/app.py
CHANGED
|
@@ -10,25 +10,56 @@ from threading import Lock
|
|
| 10 |
from typing import Any, Callable, Dict, Iterable, List, Literal, Optional, TypedDict
|
| 11 |
from uuid import uuid4
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from fastapi import FastAPI, HTTPException, Request, Response
|
| 14 |
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
from fastapi.responses import FileResponse
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from pydantic import BaseModel, Field
|
| 18 |
|
| 19 |
-
from
|
| 20 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
DEFAULT_SYSTEM_PROMPT,
|
| 22 |
ConversationMemory,
|
| 23 |
GroqClient,
|
| 24 |
classify_question,
|
| 25 |
is_meta_question,
|
| 26 |
)
|
| 27 |
-
from
|
| 28 |
-
from
|
| 29 |
-
LegalAnswerOrchestrator,
|
| 30 |
-
OrchestratorOptions,
|
| 31 |
-
)
|
| 32 |
|
| 33 |
try:
|
| 34 |
from langgraph.graph import StateGraph, END
|
|
@@ -113,6 +144,82 @@ INDEX_FILE = _resolve_project_path("INDEX_FILE", Path("static") / "index.html")
|
|
| 113 |
# 1. PDF_DIR (Env, explizit)
|
| 114 |
# 2. DATA_DIR/pdfs -> HF-Dataset-Snapshot auf Spaces (dort pdfs/ ablegen)
|
| 115 |
# 3. BASE_DIR/data/pdfs bzw. BASE_DIR/data -> im Docker-Image mitgelieferte PDFs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
_PDF_ENV_DIR = os.getenv("PDF_DIR")
|
| 117 |
|
| 118 |
PDF_SEARCH_DIRS: List[Path] = [
|
|
@@ -185,13 +292,62 @@ RERANKER_CANDIDATES = int(os.getenv("RERANKER_CANDIDATES", "20"))
|
|
| 185 |
|
| 186 |
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
|
| 187 |
GROQ_TEMPERATURE = float(os.getenv("GROQ_TEMPERATURE", "0.05"))
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
GROQ_DEBUG_PROMPTS = os.getenv("GROQ_DEBUG_PROMPTS", "false").lower() == "true"
|
| 190 |
|
| 191 |
DEFAULT_TOP_K = int(os.getenv("DEFAULT_TOP_K", "6"))
|
| 192 |
DEFAULT_FETCH_K = int(os.getenv("DEFAULT_FETCH_K", "18"))
|
| 193 |
DEFAULT_MAX_FINAL_RESULTS = int(os.getenv("DEFAULT_MAX_FINAL_RESULTS", "10"))
|
| 194 |
DEFAULT_MAX_SOURCES = int(os.getenv("DEFAULT_MAX_SOURCES", "5"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
DEFAULT_RAG_CONTEXT_CHARS = int(os.getenv("DEFAULT_RAG_CONTEXT_CHARS", "12000"))
|
| 196 |
DEFAULT_MIN_SCORE = float(os.getenv("DEFAULT_MIN_SCORE", "0.0"))
|
| 197 |
|
|
@@ -308,48 +464,68 @@ async def session_middleware(request: Request, call_next):
|
|
| 308 |
# RAG-Komponenten
|
| 309 |
# -----------------------------------------------------------------------------
|
| 310 |
|
| 311 |
-
|
| 312 |
-
"""
|
| 313 |
-
Baut den Retriever und gibt Startdiagnose aus.
|
| 314 |
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
instance = LegalRetriever(
|
| 321 |
persist_dir=CHROMA_PERSIST_DIR,
|
| 322 |
-
collection=CHROMA_COLLECTION,
|
| 323 |
model_name=EMBEDDING_MODEL,
|
| 324 |
-
default_container_id=DEFAULT_CONTAINER_ID,
|
| 325 |
enable_reranker=ENABLE_RERANKER,
|
| 326 |
reranker_model=RERANKER_MODEL,
|
| 327 |
reranker_candidates=RERANKER_CANDIDATES,
|
| 328 |
)
|
| 329 |
-
|
| 330 |
-
instance = LegalRetriever(
|
| 331 |
-
persist_dir=CHROMA_PERSIST_DIR,
|
| 332 |
-
collection=CHROMA_COLLECTION,
|
| 333 |
-
model_name=EMBEDDING_MODEL,
|
| 334 |
-
)
|
| 335 |
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
print("APP USING CHROMA PATH:", CHROMA_PERSIST_DIR)
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
return instance
|
| 346 |
|
| 347 |
|
| 348 |
-
retriever: Optional[
|
| 349 |
retriever_lock = Lock()
|
| 350 |
|
| 351 |
|
| 352 |
-
def get_retriever() ->
|
| 353 |
"""Initialisiert Chroma/SentenceTransformer erst beim ersten echten Zugriff.
|
| 354 |
|
| 355 |
Vorteil im Deployment: FastAPI kann importieren und starten, auch wenn Chroma
|
|
@@ -409,6 +585,7 @@ def _build_llm(session: SessionState) -> GroqClient:
|
|
| 409 |
system_prompt=session.system_prompt,
|
| 410 |
temperature=GROQ_TEMPERATURE,
|
| 411 |
max_tokens=GROQ_MAX_TOKENS,
|
|
|
|
| 412 |
debug_prompts=GROQ_DEBUG_PROMPTS,
|
| 413 |
)
|
| 414 |
|
|
@@ -663,6 +840,34 @@ def _source_marker(numbers: List[int]) -> str:
|
|
| 663 |
return "[Quellen " + ", ".join(str(n) for n in numbers) + "]"
|
| 664 |
|
| 665 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
def _format_source_display_title(source: Dict[str, Any]) -> str:
|
| 667 |
"""Erzeugt eine UI-fertige, nummernstabile Quellenanzeige."""
|
| 668 |
marker = _coalesce(source.get("source_label"), source.get("source_marker")) or _source_marker(
|
|
@@ -675,6 +880,12 @@ def _format_source_display_title(source: Dict[str, Any]) -> str:
|
|
| 675 |
if not path:
|
| 676 |
path = f"{container}::{section}"
|
| 677 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
pages = _coalesce(source.get("page_range"), source.get("pages"), "?")
|
| 679 |
canonical_refs = [
|
| 680 |
str(ref).strip()
|
|
@@ -691,7 +902,7 @@ def _format_source_display_title(source: Dict[str, Any]) -> str:
|
|
| 691 |
if kinds == {"neighbor"}:
|
| 692 |
role = " · Kontext/Nachbar"
|
| 693 |
|
| 694 |
-
return f"{marker} {path}{ref_part}, Seiten {pages}{role}".strip()
|
| 695 |
|
| 696 |
|
| 697 |
def _source_from_hit(hit: Dict[str, Any], *, source_number: Optional[int] = None) -> Dict[str, Any]:
|
|
@@ -755,6 +966,11 @@ def _source_from_hit(hit: Dict[str, Any], *, source_number: Optional[int] = None
|
|
| 755 |
"page_end": _int_or_none(_coalesce(hit.get("page_end"), metadata.get("page_end"), hit.get("page_start"), metadata.get("page_start"))),
|
| 756 |
"source_file": _coalesce(hit.get("source_file"), metadata.get("source_file")),
|
| 757 |
"doc_id": _coalesce(hit.get("doc_id"), metadata.get("doc_id")),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
"highlights": _highlights_from_hit(hit),
|
| 759 |
"legal_unit_id": _coalesce(hit.get("legal_unit_id"), metadata.get("legal_unit_id")),
|
| 760 |
"parent_unit_id": _coalesce(hit.get("parent_unit_id"), metadata.get("parent_unit_id")),
|
|
@@ -789,6 +1005,27 @@ def _extract_answer_source_numbers(answer: str) -> List[int]:
|
|
| 789 |
return list(dict.fromkeys(nums))
|
| 790 |
|
| 791 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
def _merge_source(existing: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]:
|
| 793 |
"""Führt gruppierbare Quellen zusammen, ohne Nummern/Fundstellen zu verlieren."""
|
| 794 |
merged = dict(existing)
|
|
@@ -835,6 +1072,7 @@ def _merge_source(existing: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[st
|
|
| 835 |
merged["page_end"] = max(ends) if ends else merged["page_start"]
|
| 836 |
merged["source_file"] = _coalesce(existing.get("source_file"), incoming.get("source_file"))
|
| 837 |
merged["doc_id"] = _coalesce(existing.get("doc_id"), incoming.get("doc_id"))
|
|
|
|
| 838 |
|
| 839 |
highlights: List[Dict[str, Any]] = []
|
| 840 |
seen_highlights: set = set()
|
|
@@ -869,6 +1107,9 @@ def _source_dedupe_key(src: Dict[str, Any]) -> tuple[Any, ...]:
|
|
| 869 |
canonical_refs = tuple(src.get("canonical_refs") or [])
|
| 870 |
return (
|
| 871 |
"location",
|
|
|
|
|
|
|
|
|
|
| 872 |
src.get("container"),
|
| 873 |
src.get("section"),
|
| 874 |
src.get("pages") or src.get("page_range"),
|
|
@@ -1052,6 +1293,15 @@ def _postprocess_answer(
|
|
| 1052 |
logger.debug("Composer marker postprocessing skipped: %s", exc)
|
| 1053 |
|
| 1054 |
text = _sanitize_unsupported_fine_references(text, sources=sources, hits=hits)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1055 |
return text.strip()
|
| 1056 |
|
| 1057 |
|
|
@@ -1073,6 +1323,11 @@ def _normalize_returned_sources(
|
|
| 1073 |
max_sources = payload.max_sources or DEFAULT_MAX_SOURCES
|
| 1074 |
cited_numbers = _extract_answer_source_numbers(answer)
|
| 1075 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1076 |
normalized = _dedupe_sources(
|
| 1077 |
sources or [],
|
| 1078 |
max_sources=max(max_sources, len(cited_numbers), DEFAULT_MAX_SOURCES),
|
|
@@ -1155,6 +1410,17 @@ def _renumber_sources_for_display(
|
|
| 1155 |
for source in sources:
|
| 1156 |
old_numbers = _int_list(source.get("source_numbers"), source.get("source_number"))
|
| 1157 |
if not old_numbers:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1158 |
continue
|
| 1159 |
|
| 1160 |
assigned: Optional[int] = None
|
|
@@ -1190,13 +1456,25 @@ def _debug_hit(hit: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 1190 |
|
| 1191 |
|
| 1192 |
def _collection_count() -> Any:
|
|
|
|
| 1193 |
try:
|
| 1194 |
-
return
|
| 1195 |
except Exception as exc:
|
| 1196 |
logger.warning("Chroma count unavailable: %s", exc)
|
| 1197 |
return "unknown"
|
| 1198 |
|
| 1199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
# -----------------------------------------------------------------------------
|
| 1201 |
# Ask Graph Workflow
|
| 1202 |
# -----------------------------------------------------------------------------
|
|
@@ -1255,6 +1533,10 @@ class AskGraphContext:
|
|
| 1255 |
|
| 1256 |
route: Optional[AskRoute] = None
|
| 1257 |
hits: Optional[List[Dict[str, Any]]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1258 |
raw_sources: Optional[List[Dict[str, Any]]] = None
|
| 1259 |
sources: Optional[List[Dict[str, Any]]] = None
|
| 1260 |
answer: str = ""
|
|
@@ -1272,6 +1554,8 @@ class AskGraphContext:
|
|
| 1272 |
self.raw_sources = []
|
| 1273 |
if self.sources is None:
|
| 1274 |
self.sources = []
|
|
|
|
|
|
|
| 1275 |
if self.trace is None:
|
| 1276 |
self.trace = []
|
| 1277 |
|
|
@@ -1375,12 +1659,23 @@ def _ask_graph_normalize_sources(ctx: AskGraphContext) -> AskGraphStep:
|
|
| 1375 |
hits=ctx.hits,
|
| 1376 |
payload=ctx.payload,
|
| 1377 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1378 |
# Anzeige-Nummern stabil auf 1..n abbilden (statt Retrieval-Ränge wie
|
| 1379 |
# "[Quellen 3, 8]"). Muss als letzter Schritt laufen, damit Antworttext
|
| 1380 |
# und Quellenliste dieselben Nummern zeigen.
|
| 1381 |
ctx.answer, ctx.sources = _renumber_sources_for_display(ctx.answer, ctx.sources)
|
| 1382 |
else:
|
| 1383 |
ctx.sources = []
|
|
|
|
| 1384 |
|
| 1385 |
return AskGraphStep("update_memory", "sources normalized and answer postprocessed")
|
| 1386 |
|
|
@@ -1401,24 +1696,35 @@ _PAGE_RANGE_RE = re.compile(r"(\d+)\s*[–\-]\s*(\d+)|^(\d+)$")
|
|
| 1401 |
def _attach_pdf_locators(sources: List[Dict[str, Any]], hits: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 1402 |
"""Sicherheitsnetz: fehlende PDF-Locator aus Hits bzw. page_range ableiten.
|
| 1403 |
|
| 1404 |
-
Composer/Orchestrator liefern die Felder normalerweise bereits mit
|
| 1405 |
-
Pfade (Legacy-Fallbacks) können sie verlieren
|
| 1406 |
-
|
|
|
|
|
|
|
|
|
|
| 1407 |
"""
|
| 1408 |
-
|
| 1409 |
-
fallback_doc_id = None
|
| 1410 |
for hit in hits or []:
|
| 1411 |
metadata = hit.get("metadata") or {}
|
| 1412 |
-
|
| 1413 |
-
|
| 1414 |
-
|
| 1415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1416 |
|
| 1417 |
for source in sources or []:
|
| 1418 |
-
|
| 1419 |
-
|
| 1420 |
-
if
|
| 1421 |
-
source
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1422 |
|
| 1423 |
if source.get("page_start") is None:
|
| 1424 |
match = _PAGE_RANGE_RE.search(str(source.get("page_range") or source.get("pages") or ""))
|
|
@@ -1436,12 +1742,309 @@ def _attach_pdf_locators(sources: List[Dict[str, Any]], hits: List[Dict[str, Any
|
|
| 1436 |
return sources
|
| 1437 |
|
| 1438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1439 |
def _ask_graph_build_response(ctx: AskGraphContext) -> AskGraphStep:
|
| 1440 |
"""Baut exakt die bisherige API-Response-Struktur."""
|
| 1441 |
_attach_pdf_locators(ctx.sources or [], ctx.hits or [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1442 |
ctx.response_body = {
|
| 1443 |
"question": ctx.question,
|
| 1444 |
-
"answer":
|
| 1445 |
"answer_type": ctx.answer_type,
|
| 1446 |
"session_id": ctx.request.state.session_id,
|
| 1447 |
"factual_question_index": ctx.memory.factual_question_count,
|
|
@@ -1449,6 +2052,23 @@ def _ask_graph_build_response(ctx: AskGraphContext) -> AskGraphStep:
|
|
| 1449 |
"sources": ctx.sources or [],
|
| 1450 |
"needs_clarification": ctx.needs_clarification,
|
| 1451 |
"clarification_question": ctx.clarification_question,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1452 |
}
|
| 1453 |
return AskGraphStep("__end__", "response built")
|
| 1454 |
|
|
@@ -1715,6 +2335,10 @@ def _ask_graph_debug_payload(ctx: AskGraphContext) -> Dict[str, Any]:
|
|
| 1715 |
"chroma_persist_dir": CHROMA_PERSIST_DIR,
|
| 1716 |
"chroma_collection": CHROMA_COLLECTION,
|
| 1717 |
"chroma_count": _collection_count(),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1718 |
}
|
| 1719 |
|
| 1720 |
|
|
@@ -1758,6 +2382,16 @@ def ask(payload: Question, request: Request):
|
|
| 1758 |
"sources": ctx.sources or [],
|
| 1759 |
"needs_clarification": ctx.needs_clarification,
|
| 1760 |
"clarification_question": ctx.clarification_question,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1761 |
}
|
| 1762 |
|
| 1763 |
if payload.debug:
|
|
@@ -1820,6 +2454,7 @@ def health():
|
|
| 1820 |
"app": APP_TITLE,
|
| 1821 |
"version": APP_VERSION,
|
| 1822 |
"collection": CHROMA_COLLECTION,
|
|
|
|
| 1823 |
"chroma_persist_dir": CHROMA_PERSIST_DIR,
|
| 1824 |
"chroma_count": _collection_count(),
|
| 1825 |
"default_container_id": DEFAULT_CONTAINER_ID,
|
|
@@ -1833,42 +2468,67 @@ def health():
|
|
| 1833 |
|
| 1834 |
@app.get("/debug/retriever")
|
| 1835 |
def debug_retriever():
|
| 1836 |
-
|
| 1837 |
-
|
| 1838 |
try:
|
| 1839 |
-
|
| 1840 |
-
|
| 1841 |
-
docs = res.get("documents") or []
|
| 1842 |
-
metas = res.get("metadatas") or []
|
| 1843 |
-
ids = res.get("ids") or []
|
| 1844 |
-
|
| 1845 |
-
for idx, meta in enumerate(metas):
|
| 1846 |
-
doc = docs[idx] if idx < len(docs) else ""
|
| 1847 |
-
sample.append(
|
| 1848 |
-
{
|
| 1849 |
-
"id": ids[idx] if idx < len(ids) else None,
|
| 1850 |
-
"metadata": meta,
|
| 1851 |
-
"normalized_source": _source_from_hit({"metadata": meta}),
|
| 1852 |
-
"text_preview": (doc or "")[:250],
|
| 1853 |
-
}
|
| 1854 |
-
)
|
| 1855 |
except Exception as exc:
|
| 1856 |
return {
|
| 1857 |
"ok": False,
|
| 1858 |
"error": f"{type(exc).__name__}: {exc}",
|
| 1859 |
"chroma_persist_dir": CHROMA_PERSIST_DIR,
|
| 1860 |
-
"collection": CHROMA_COLLECTION,
|
| 1861 |
}
|
| 1862 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1863 |
return {
|
| 1864 |
"ok": True,
|
| 1865 |
"chroma_persist_dir": CHROMA_PERSIST_DIR,
|
| 1866 |
-
"collection": CHROMA_COLLECTION,
|
| 1867 |
"count": _collection_count(),
|
| 1868 |
-
"
|
|
|
|
| 1869 |
}
|
| 1870 |
|
| 1871 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1872 |
# -----------------------------------------------------------------------------
|
| 1873 |
# Session System-Prompt
|
| 1874 |
# -----------------------------------------------------------------------------
|
|
|
|
| 10 |
from typing import Any, Callable, Dict, Iterable, List, Literal, Optional, TypedDict
|
| 11 |
from uuid import uuid4
|
| 12 |
|
| 13 |
+
# .env laden, bevor irgendein os.getenv() ausgewertet wird.
|
| 14 |
+
#
|
| 15 |
+
# Ohne diesen Schritt ist die gesamte .env tote Konfiguration: CHROMA_COLLECTIONS
|
| 16 |
+
# erreicht specs_from_env() nie, die Registry fällt auf das einzelne
|
| 17 |
+
# CHROMA_COLLECTION zurück und das zweite Korpus (SGB V) wird nie geöffnet.
|
| 18 |
+
#
|
| 19 |
+
# override=False ist Absicht: echte Prozess-Umgebungsvariablen (Render, Docker,
|
| 20 |
+
# HF Spaces) haben Vorrang vor der lokalen Entwicklungsdatei.
|
| 21 |
+
try:
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
|
| 24 |
+
_ENV_FILE = Path(__file__).resolve().parent.parent / ".env"
|
| 25 |
+
load_dotenv(_ENV_FILE if _ENV_FILE.exists() else None, override=False)
|
| 26 |
+
except ImportError: # python-dotenv ist optional; Deployment setzt echte Env-Vars.
|
| 27 |
+
_ENV_FILE = None
|
| 28 |
+
|
| 29 |
from fastapi import FastAPI, HTTPException, Request, Response
|
| 30 |
from fastapi.middleware.cors import CORSMiddleware
|
| 31 |
from fastapi.responses import FileResponse
|
| 32 |
from fastapi.staticfiles import StaticFiles
|
| 33 |
from pydantic import BaseModel, Field
|
| 34 |
|
| 35 |
+
from retriever import LegalRetriever
|
| 36 |
+
from corpus_amendments import (
|
| 37 |
+
append_amendment_note,
|
| 38 |
+
describe as describe_amendments,
|
| 39 |
+
reconcile_negative_answer,
|
| 40 |
+
)
|
| 41 |
+
from corpus_boundary import append_boundary_note, detect_external_references
|
| 42 |
+
import amrl_austauschbarkeit
|
| 43 |
+
import amrl_biosimilars
|
| 44 |
+
import amrl_lifestyle
|
| 45 |
+
import amrl_otc
|
| 46 |
+
import amrl_substitution
|
| 47 |
+
import amrl_tabakentwoehnung
|
| 48 |
+
import amrl_verordnungsausschluss
|
| 49 |
+
import norm_anchors
|
| 50 |
+
import norm_verweise
|
| 51 |
+
from corpus_registry import CorpusRegistry, build_registry
|
| 52 |
+
from corpus_router import explain_routing, mentioned_corpora, route_question
|
| 53 |
+
from federated_retriever import FederatedRetriever
|
| 54 |
+
from llm_client_groq import (
|
| 55 |
DEFAULT_SYSTEM_PROMPT,
|
| 56 |
ConversationMemory,
|
| 57 |
GroqClient,
|
| 58 |
classify_question,
|
| 59 |
is_meta_question,
|
| 60 |
)
|
| 61 |
+
from answer_composer import AnswerComposer
|
| 62 |
+
from orchestrator import NEGATIVE_ANSWER_RE, LegalAnswerOrchestrator, OrchestratorOptions
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
try:
|
| 65 |
from langgraph.graph import StateGraph, END
|
|
|
|
| 144 |
# 1. PDF_DIR (Env, explizit)
|
| 145 |
# 2. DATA_DIR/pdfs -> HF-Dataset-Snapshot auf Spaces (dort pdfs/ ablegen)
|
| 146 |
# 3. BASE_DIR/data/pdfs bzw. BASE_DIR/data -> im Docker-Image mitgelieferte PDFs
|
| 147 |
+
# Kuratierte Änderungen zu Korpora, die nur als Grundfassung plus separate
|
| 148 |
+
# Änderungstexte veröffentlicht werden (siehe src/corpus_amendments.py).
|
| 149 |
+
AMENDMENTS_FILE = Path(
|
| 150 |
+
os.getenv("AMENDMENTS_FILE", str(BASE_DIR / "data" / "amabrv_aenderungen.json"))
|
| 151 |
+
)
|
| 152 |
+
# Teil B der AM-RL-Anlage VII als nachschlagbare Tabelle. Diese Liste wird nicht
|
| 153 |
+
# indiziert: die entscheidende Auskunft ist oft die negative, und die kann ein
|
| 154 |
+
# Vektor-Retrieval nicht belegen (siehe src/amrl_substitution.py).
|
| 155 |
+
AMRL_SUBSTITUTION_FILE = Path(
|
| 156 |
+
os.getenv(
|
| 157 |
+
"AMRL_SUBSTITUTION_FILE",
|
| 158 |
+
str(BASE_DIR / "data" / "amrl_anlage_vii_teil_b.json"),
|
| 159 |
+
)
|
| 160 |
+
)
|
| 161 |
+
# Teil A derselben Anlage: welche Darreichungsformen desselben Wirkstoffs
|
| 162 |
+
# gegeneinander austauschbar sind. Ebenfalls Lookup statt Index — die Aussage
|
| 163 |
+
# hängt an der Gruppenzugehörigkeit, und die ist nur über die vollständige
|
| 164 |
+
# Tabelle bestimmbar (siehe src/amrl_austauschbarkeit.py).
|
| 165 |
+
AMRL_AUSTAUSCHBARKEIT_FILE = Path(
|
| 166 |
+
os.getenv(
|
| 167 |
+
"AMRL_AUSTAUSCHBARKEIT_FILE",
|
| 168 |
+
str(BASE_DIR / "data" / "amrl_anlage_vii_teil_a.json"),
|
| 169 |
+
)
|
| 170 |
+
)
|
| 171 |
+
# Anlage VIIa: welches Biosimilar zu welchem Referenzarzneimittel zugelassen ist.
|
| 172 |
+
# Ebenfalls Lookup — die Zuordnung hängt an der Tabellenzeile, und ein Retrieval
|
| 173 |
+
# zeigte Prolia und ein Xgeva-Biosimilar nebeneinander, weil beide unter
|
| 174 |
+
# „Denosumab" stehen (siehe src/amrl_biosimilars.py).
|
| 175 |
+
AMRL_BIOSIMILARS_FILE = Path(
|
| 176 |
+
os.getenv(
|
| 177 |
+
"AMRL_BIOSIMILARS_FILE",
|
| 178 |
+
str(BASE_DIR / "data" / "amrl_anlage_viia.json"),
|
| 179 |
+
)
|
| 180 |
+
)
|
| 181 |
+
# Anlage I: die OTC-Ausnahmen vom Verordnungsausschluss des § 34 Absatz 1 SGB V.
|
| 182 |
+
# Lookup wie die übrigen — und hier trägt die Negativauskunft ausdrücklich, weil
|
| 183 |
+
# § 12 Absatz 10 AM-RL abschließend regelt (siehe src/amrl_otc.py).
|
| 184 |
+
AMRL_OTC_FILE = Path(
|
| 185 |
+
os.getenv(
|
| 186 |
+
"AMRL_OTC_FILE",
|
| 187 |
+
str(BASE_DIR / "data" / "amrl_anlage_i.json"),
|
| 188 |
+
)
|
| 189 |
+
)
|
| 190 |
+
# Anlage III: die Verordnungseinschränkungen und -ausschlüsse — das Gegenstück
|
| 191 |
+
# zu Anlage I. Lookup wie die übrigen, aber die Negativauskunft trägt hier
|
| 192 |
+
# *nicht*: die Anlage ist eine Übersicht und nennt sich nirgends abschließend
|
| 193 |
+
# (siehe src/amrl_verordnungsausschluss.py).
|
| 194 |
+
AMRL_VERORDNUNG_FILE = Path(
|
| 195 |
+
os.getenv(
|
| 196 |
+
"AMRL_VERORDNUNG_FILE",
|
| 197 |
+
str(BASE_DIR / "data" / "amrl_anlage_iii.json"),
|
| 198 |
+
)
|
| 199 |
+
)
|
| 200 |
+
# Anlage II: die Lifestyle-Arzneimittel. Von den drei Listen zur
|
| 201 |
+
# Verordnungsfähigkeit ist ihr Ausschluss der härteste — er ruht auf § 34
|
| 202 |
+
# Absatz 1 Satz 7 SGB V, und der medizinisch begründete Einzelfall des § 16
|
| 203 |
+
# Absatz 5 AM-RL führt an ihm vorbei. Die Negativauskunft trägt hier wie bei
|
| 204 |
+
# Anlage III nicht (siehe src/amrl_lifestyle.py).
|
| 205 |
+
AMRL_LIFESTYLE_FILE = Path(
|
| 206 |
+
os.getenv(
|
| 207 |
+
"AMRL_LIFESTYLE_FILE",
|
| 208 |
+
str(BASE_DIR / "data" / "amrl_anlage_ii.json"),
|
| 209 |
+
)
|
| 210 |
+
)
|
| 211 |
+
# Anlage IIa: die einzige Ausnahme von Anlage II. Sie gehört mit ihr zusammen —
|
| 212 |
+
# der Ausschluss der Raucherentwöhnung und der Anspruch auf Tabakentwöhnung nach
|
| 213 |
+
# § 34 Absatz 2 SGB V sind dieselbe Frage von zwei Seiten. Anders als bei Anlage
|
| 214 |
+
# II trägt die Negativauskunft hier: § 14a Absatz 3 Satz 1 AM-RL zählt die
|
| 215 |
+
# Ausnahme abschließend auf (siehe src/amrl_tabakentwoehnung.py).
|
| 216 |
+
AMRL_TABAKENTWOEHNUNG_FILE = Path(
|
| 217 |
+
os.getenv(
|
| 218 |
+
"AMRL_TABAKENTWOEHNUNG_FILE",
|
| 219 |
+
str(BASE_DIR / "data" / "amrl_anlage_iia.json"),
|
| 220 |
+
)
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
_PDF_ENV_DIR = os.getenv("PDF_DIR")
|
| 224 |
|
| 225 |
PDF_SEARCH_DIRS: List[Path] = [
|
|
|
|
| 292 |
|
| 293 |
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
|
| 294 |
GROQ_TEMPERATURE = float(os.getenv("GROQ_TEMPERATURE", "0.05"))
|
| 295 |
+
# Bei einem Reasoning-Modell (openai/gpt-oss-*) teilen sich Denk- und
|
| 296 |
+
# Antworttokens dieses Budget. Mit 1400 lief jede Antwort in `finish_reason:
|
| 297 |
+
# length`, und wenn das Denken es aufbrauchte, kam gar keine — gemessen am
|
| 298 |
+
# 19.08.2026: 1046 von 1400 Tokens allein fürs Reasoning. Der Client meldet die
|
| 299 |
+
# Kürzung jetzt im Log und behandelt eine leere Antwort als Fehler.
|
| 300 |
+
#
|
| 301 |
+
# Nach oben begrenzt das **Groq-Kontingent**, nicht das Modell: die
|
| 302 |
+
# Organisation liegt auf dem on_demand-Tier bei 8.000 Tokens pro Minute, und
|
| 303 |
+
# geprüft wird je Anfrage über Prompt **plus** max_tokens. Ein voller Kontext
|
| 304 |
+
# (DEFAULT_RAG_CONTEXT_CHARS=12.000) sind gemessen rund 4.825 Prompt-Tokens;
|
| 305 |
+
# 4.000 Antworttokens ergaben 8.825 und damit HTTP 413. 2.800 lässt gut 350
|
| 306 |
+
# Tokens Luft. Wer mehr braucht, braucht einen höheren Tier — oder einen
|
| 307 |
+
# kleineren Kontext.
|
| 308 |
+
GROQ_MAX_TOKENS = int(os.getenv("GROQ_MAX_TOKENS", "2800"))
|
| 309 |
+
# Nachschlag bei leerer Antwort. Ohne höheres Kontingent ist er ausgeschaltet
|
| 310 |
+
# (Decke = Budget): ein zweiter Aufruf mit mehr Tokens liefe in denselben 413.
|
| 311 |
+
GROQ_MAX_TOKENS_CEILING = int(os.getenv("GROQ_MAX_TOKENS_CEILING", str(GROQ_MAX_TOKENS)))
|
| 312 |
GROQ_DEBUG_PROMPTS = os.getenv("GROQ_DEBUG_PROMPTS", "false").lower() == "true"
|
| 313 |
|
| 314 |
DEFAULT_TOP_K = int(os.getenv("DEFAULT_TOP_K", "6"))
|
| 315 |
DEFAULT_FETCH_K = int(os.getenv("DEFAULT_FETCH_K", "18"))
|
| 316 |
DEFAULT_MAX_FINAL_RESULTS = int(os.getenv("DEFAULT_MAX_FINAL_RESULTS", "10"))
|
| 317 |
DEFAULT_MAX_SOURCES = int(os.getenv("DEFAULT_MAX_SOURCES", "5"))
|
| 318 |
+
# Garantierte Trefferplätze je abgefragtem Korpus, bevor global gekürzt wird.
|
| 319 |
+
# Ohne diese Untergrenze verliert ein kleines Korpus strukturell: die 106 Chunks
|
| 320 |
+
# der Abrechnungsvereinbarung kamen gegen die 6.670 des SGB V nie an, auch wenn
|
| 321 |
+
# die Regelung nur bei ihnen stand. Auf 0 gesetzt gilt wieder der reine
|
| 322 |
+
# Score-Schnitt. Reserviert wird höchstens die Hälfte der Ergebnisliste.
|
| 323 |
+
MIN_HITS_PER_CORPUS = int(os.getenv("MIN_HITS_PER_CORPUS", "2"))
|
| 324 |
+
# Wie viele Normen ein Lauf nachladen darf, weil der gefundene Text auf sie
|
| 325 |
+
# verweist. Auf 0 gesetzt ist die Verweisauflösung aus, und der Retriever
|
| 326 |
+
# verhält sich wie vorher.
|
| 327 |
+
#
|
| 328 |
+
# **Die Vorgabe ist seit dem 21.08.2026 die 0, und zwar gemessen.** Die Idee war
|
| 329 |
+
# richtig — ein einziger § des SGB V zitiert ein Dutzend andere, und was der Text
|
| 330 |
+
# selbst in Bezug nimmt, ist ein Signal, das die Ähnlichkeitssuche nicht hat.
|
| 331 |
+
# Nur ist das Budget fest: ein nachgeladener Verweis *ergänzt* den Kontext
|
| 332 |
+
# nicht, er *ersetzt* etwas.
|
| 333 |
+
#
|
| 334 |
+
# 19.08.2026, VERWEIS_SCORE 0.97 — Kettenabdeckung 85 % -> 75 %.
|
| 335 |
+
# 21.08.2026, VERWEIS_SCORE 0.50 — auf der damaligen Suite unauffällig (beide
|
| 336 |
+
# Konfigurationen 100 %), weil die Suite gesättigt war und nichts mehr
|
| 337 |
+
# trennen konnte. Auf der um fünf Fälle erweiterten Suite: 96,2 % ohne
|
| 338 |
+
# Verweise, 93,6 % mit.
|
| 339 |
+
#
|
| 340 |
+
# Sichtbar wird es an `kette_off_label_abgabe`: nachgeladen wurden § 3 SGB V
|
| 341 |
+
# (solidarische Finanzierung) und § 300 SGB V (Abrechnung), beide ohne Bezug zur
|
| 342 |
+
# Frage und beide mit dem festen Verweis-Score 0.50 — verdrängt hat das
|
| 343 |
+
# § 31 Abs. 1 SGB V mit Score 0.871. Kein Fall der Suite gewinnt umgekehrt durch
|
| 344 |
+
# die Verweise ein Kettenglied.
|
| 345 |
+
#
|
| 346 |
+
# Modul und Messschalter (`--ohne-verweise`) bleiben: die Frage ist beantwortet,
|
| 347 |
+
# nicht erledigt. Wer den Verweis wieder aufnimmt, braucht ein eigenes Budget
|
| 348 |
+
# **neben** dem Top-k, keinen Platz darin — und muss ihn nach Einschlägigkeit
|
| 349 |
+
# auswählen statt nach Häufigkeit im Text.
|
| 350 |
+
MAX_VERWEIS_NORMEN = int(os.getenv("MAX_VERWEIS_NORMEN", "0"))
|
| 351 |
DEFAULT_RAG_CONTEXT_CHARS = int(os.getenv("DEFAULT_RAG_CONTEXT_CHARS", "12000"))
|
| 352 |
DEFAULT_MIN_SCORE = float(os.getenv("DEFAULT_MIN_SCORE", "0.0"))
|
| 353 |
|
|
|
|
| 464 |
# RAG-Komponenten
|
| 465 |
# -----------------------------------------------------------------------------
|
| 466 |
|
| 467 |
+
corpus_registry: Optional[CorpusRegistry] = None
|
|
|
|
|
|
|
| 468 |
|
| 469 |
+
|
| 470 |
+
def get_corpus_registry() -> CorpusRegistry:
|
| 471 |
+
global corpus_registry
|
| 472 |
+
if corpus_registry is None:
|
| 473 |
+
corpus_registry = build_registry(
|
|
|
|
| 474 |
persist_dir=CHROMA_PERSIST_DIR,
|
|
|
|
| 475 |
model_name=EMBEDDING_MODEL,
|
|
|
|
| 476 |
enable_reranker=ENABLE_RERANKER,
|
| 477 |
reranker_model=RERANKER_MODEL,
|
| 478 |
reranker_candidates=RERANKER_CANDIDATES,
|
| 479 |
)
|
| 480 |
+
return corpus_registry
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
|
| 482 |
+
|
| 483 |
+
def _build_retriever() -> FederatedRetriever:
|
| 484 |
+
"""
|
| 485 |
+
Baut den Retriever über alle konfigurierten Korpora und gibt Startdiagnose aus.
|
| 486 |
+
|
| 487 |
+
Bei genau einem Korpus verhält sich der föderierende Retriever wie der
|
| 488 |
+
bisherige `LegalRetriever` — die Routing- und Fusionsschritte sind dann
|
| 489 |
+
Durchreichen.
|
| 490 |
+
"""
|
| 491 |
+
registry = get_corpus_registry()
|
| 492 |
+
instance = FederatedRetriever(
|
| 493 |
+
registry,
|
| 494 |
+
router=route_question,
|
| 495 |
+
# Die maßgeblichen Normen werden abgerufen, nicht erhofft. Das Register
|
| 496 |
+
# liefert nur Adressen; der Retriever bleibt frei von Rechtsinhalten.
|
| 497 |
+
anchors=norm_anchors.pflichtabruf,
|
| 498 |
+
# Und die Normen, die der gefundene Text selbst in Bezug nimmt. Das
|
| 499 |
+
# Register kennt die Kette eines Fragetyps, der Text die Kette seines
|
| 500 |
+
# eigenen Satzes — „nach Maßgabe der §§ 11 bis 14" löste bis dahin
|
| 501 |
+
# nichts aus. Seit dem 21.08.2026 steht `MAX_VERWEIS_NORMEN` auf 0 und
|
| 502 |
+
# damit ist der Abruf aus; die Begründung steht dort.
|
| 503 |
+
verweise=norm_verweise.abrufziele,
|
| 504 |
+
# Nennt die Frage ein Regelwerk, führt dessen Treffer die Liste an —
|
| 505 |
+
# auch gegen den Pflichtabruf. Der Anker behält seinen Platz im
|
| 506 |
+
# Kontext, aber nicht den ersten.
|
| 507 |
+
named_corpora=mentioned_corpora,
|
| 508 |
+
min_hits_per_corpus=MIN_HITS_PER_CORPUS,
|
| 509 |
+
max_verweis_normen=MAX_VERWEIS_NORMEN,
|
| 510 |
+
)
|
| 511 |
|
| 512 |
print("APP USING CHROMA PATH:", CHROMA_PERSIST_DIR)
|
| 513 |
+
for entry in registry.diagnostics()["corpora"]:
|
| 514 |
+
status = entry.get("count") if entry.get("ok") else entry.get("error")
|
| 515 |
+
print(f"APP CORPUS {entry['corpus_id']}: collection={entry['collection']} count={status}")
|
| 516 |
+
|
| 517 |
+
warning = registry.assert_consistent_embedding()
|
| 518 |
+
if warning:
|
| 519 |
+
print("APP WARNING:", warning)
|
| 520 |
|
| 521 |
return instance
|
| 522 |
|
| 523 |
|
| 524 |
+
retriever: Optional[FederatedRetriever] = None
|
| 525 |
retriever_lock = Lock()
|
| 526 |
|
| 527 |
|
| 528 |
+
def get_retriever() -> FederatedRetriever:
|
| 529 |
"""Initialisiert Chroma/SentenceTransformer erst beim ersten echten Zugriff.
|
| 530 |
|
| 531 |
Vorteil im Deployment: FastAPI kann importieren und starten, auch wenn Chroma
|
|
|
|
| 585 |
system_prompt=session.system_prompt,
|
| 586 |
temperature=GROQ_TEMPERATURE,
|
| 587 |
max_tokens=GROQ_MAX_TOKENS,
|
| 588 |
+
max_tokens_ceiling=GROQ_MAX_TOKENS_CEILING,
|
| 589 |
debug_prompts=GROQ_DEBUG_PROMPTS,
|
| 590 |
)
|
| 591 |
|
|
|
|
| 840 |
return "[Quellen " + ", ".join(str(n) for n in numbers) + "]"
|
| 841 |
|
| 842 |
|
| 843 |
+
_SGB_BOOK_RE = re.compile(r"Sozialgesetzbuch.*?\(([IVXLC]+)\)", re.I)
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
def _short_doc_title(title: str, *, max_chars: int = 32) -> str:
|
| 847 |
+
"""Kürzt einen Dokumenttitel auf eine zitierfähige Kurzform.
|
| 848 |
+
|
| 849 |
+
Die vollen Titel aus den Chunk-Metadaten sind für eine Quellenzeile zu lang
|
| 850 |
+
("Sozialgesetzbuch (SGB) Fünftes Buch (V) - Gesetzliche Krankenversicherung").
|
| 851 |
+
Das LLM bekommt weiterhin den vollen Titel im Kontext; gekürzt wird nur die
|
| 852 |
+
Anzeige.
|
| 853 |
+
"""
|
| 854 |
+
text = " ".join(str(title or "").split())
|
| 855 |
+
if not text:
|
| 856 |
+
return ""
|
| 857 |
+
|
| 858 |
+
match = _SGB_BOOK_RE.search(text)
|
| 859 |
+
if match:
|
| 860 |
+
return f"SGB {match.group(1).upper()}"
|
| 861 |
+
|
| 862 |
+
# Untertitel nach Gedankenstrich abschneiden.
|
| 863 |
+
text = re.split(r"\s+[–—-]\s+", text, maxsplit=1)[0].strip()
|
| 864 |
+
if text.lower().startswith("rahmenvertrag"):
|
| 865 |
+
return "Rahmenvertrag"
|
| 866 |
+
if len(text) <= max_chars:
|
| 867 |
+
return text
|
| 868 |
+
return text[:max_chars].rsplit(" ", 1)[0].rstrip(" ,;:-–—") + "…"
|
| 869 |
+
|
| 870 |
+
|
| 871 |
def _format_source_display_title(source: Dict[str, Any]) -> str:
|
| 872 |
"""Erzeugt eine UI-fertige, nummernstabile Quellenanzeige."""
|
| 873 |
marker = _coalesce(source.get("source_label"), source.get("source_marker")) or _source_marker(
|
|
|
|
| 880 |
if not path:
|
| 881 |
path = f"{container}::{section}"
|
| 882 |
|
| 883 |
+
# Dokumentangabe voranstellen, sobald der Korpus mehr als ein Dokument
|
| 884 |
+
# enthält. "§ 16" allein ist mehrdeutig: Rahmenvertrag und SGB V teilen sich
|
| 885 |
+
# 34 §-Nummern mit unterschiedlichem Inhalt.
|
| 886 |
+
doc = str(_coalesce(source.get("doc_title"), source.get("doc_id")) or "").strip()
|
| 887 |
+
doc_part = f"{_short_doc_title(doc)} · " if doc else ""
|
| 888 |
+
|
| 889 |
pages = _coalesce(source.get("page_range"), source.get("pages"), "?")
|
| 890 |
canonical_refs = [
|
| 891 |
str(ref).strip()
|
|
|
|
| 902 |
if kinds == {"neighbor"}:
|
| 903 |
role = " · Kontext/Nachbar"
|
| 904 |
|
| 905 |
+
return f"{marker} {doc_part}{path}{ref_part}, Seiten {pages}{role}".strip()
|
| 906 |
|
| 907 |
|
| 908 |
def _source_from_hit(hit: Dict[str, Any], *, source_number: Optional[int] = None) -> Dict[str, Any]:
|
|
|
|
| 966 |
"page_end": _int_or_none(_coalesce(hit.get("page_end"), metadata.get("page_end"), hit.get("page_start"), metadata.get("page_start"))),
|
| 967 |
"source_file": _coalesce(hit.get("source_file"), metadata.get("source_file")),
|
| 968 |
"doc_id": _coalesce(hit.get("doc_id"), metadata.get("doc_id")),
|
| 969 |
+
"doc_title": _coalesce(hit.get("doc_title"), metadata.get("doc_title")),
|
| 970 |
+
# Von FederatedRetriever je Treffer gesetzt. Ohne Durchreichen weiß die
|
| 971 |
+
# API-Schicht nicht mehr, aus welchem Korpus eine Quelle stammt — der
|
| 972 |
+
# Änderungshinweis in corpus_amendments.py könnte dann nie greifen.
|
| 973 |
+
"corpus_id": _coalesce(hit.get("corpus_id"), metadata.get("corpus_id")),
|
| 974 |
"highlights": _highlights_from_hit(hit),
|
| 975 |
"legal_unit_id": _coalesce(hit.get("legal_unit_id"), metadata.get("legal_unit_id")),
|
| 976 |
"parent_unit_id": _coalesce(hit.get("parent_unit_id"), metadata.get("parent_unit_id")),
|
|
|
|
| 1005 |
return list(dict.fromkeys(nums))
|
| 1006 |
|
| 1007 |
|
| 1008 |
+
def _answer_reports_nothing_found(answer: str) -> bool:
|
| 1009 |
+
"""Die Antwort zitiert nichts, nennt keine Norm und sagt, es gebe nichts.
|
| 1010 |
+
|
| 1011 |
+
Zwei Stellen hängen an dieser Frage: der Reichweiten-Hinweis, dessen Prämisse
|
| 1012 |
+
ist, dass Textstellen herangezogen *wurden*, und die zurückgegebene
|
| 1013 |
+
Quellenliste, die sonst fünf Fundstellen unter eine Aussage setzt, dass es
|
| 1014 |
+
keine gibt.
|
| 1015 |
+
|
| 1016 |
+
Bewusst eng gefasst, damit der gewollte Fallback erhalten bleibt: vergisst das
|
| 1017 |
+
Modell bei einer inhaltlichen Antwort die Marker, sollen seine Quellen weiter
|
| 1018 |
+
erscheinen. Ein § irgendwo im Text heißt deshalb "etwas gefunden", unabhängig
|
| 1019 |
+
davon, was die Antwort darüber behauptet.
|
| 1020 |
+
"""
|
| 1021 |
+
text = answer or ""
|
| 1022 |
+
if _extract_answer_source_numbers(text):
|
| 1023 |
+
return False
|
| 1024 |
+
if "§" in text:
|
| 1025 |
+
return False
|
| 1026 |
+
return bool(NEGATIVE_ANSWER_RE.search(text))
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
def _merge_source(existing: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]:
|
| 1030 |
"""Führt gruppierbare Quellen zusammen, ohne Nummern/Fundstellen zu verlieren."""
|
| 1031 |
merged = dict(existing)
|
|
|
|
| 1072 |
merged["page_end"] = max(ends) if ends else merged["page_start"]
|
| 1073 |
merged["source_file"] = _coalesce(existing.get("source_file"), incoming.get("source_file"))
|
| 1074 |
merged["doc_id"] = _coalesce(existing.get("doc_id"), incoming.get("doc_id"))
|
| 1075 |
+
merged["doc_title"] = _coalesce(existing.get("doc_title"), incoming.get("doc_title"))
|
| 1076 |
|
| 1077 |
highlights: List[Dict[str, Any]] = []
|
| 1078 |
seen_highlights: set = set()
|
|
|
|
| 1107 |
canonical_refs = tuple(src.get("canonical_refs") or [])
|
| 1108 |
return (
|
| 1109 |
"location",
|
| 1110 |
+
# doc_id zuerst: gleiche §-Nummern in verschiedenen Dokumenten dürfen
|
| 1111 |
+
# nicht zu einer Quelle verschmelzen.
|
| 1112 |
+
src.get("doc_id"),
|
| 1113 |
src.get("container"),
|
| 1114 |
src.get("section"),
|
| 1115 |
src.get("pages") or src.get("page_range"),
|
|
|
|
| 1293 |
logger.debug("Composer marker postprocessing skipped: %s", exc)
|
| 1294 |
|
| 1295 |
text = _sanitize_unsupported_fine_references(text, sources=sources, hits=hits)
|
| 1296 |
+
|
| 1297 |
+
# Zuletzt, damit die vorherigen Schritte erst entfernen können, was einen
|
| 1298 |
+
# Abschnitt nur scheinbar füllt (ungültige Marker, unbelegte Fundstellen).
|
| 1299 |
+
if hasattr(composer, "_strip_empty_section_headings"):
|
| 1300 |
+
try:
|
| 1301 |
+
text = composer._strip_empty_section_headings(text) # type: ignore[attr-defined]
|
| 1302 |
+
except Exception as exc:
|
| 1303 |
+
logger.debug("Composer section postprocessing skipped: %s", exc)
|
| 1304 |
+
|
| 1305 |
return text.strip()
|
| 1306 |
|
| 1307 |
|
|
|
|
| 1323 |
max_sources = payload.max_sources or DEFAULT_MAX_SOURCES
|
| 1324 |
cited_numbers = _extract_answer_source_numbers(answer)
|
| 1325 |
|
| 1326 |
+
# Hat die Antwort nichts gefunden, gibt es auch nichts anzuzeigen. Die
|
| 1327 |
+
# kuratierte Liste wäre hier kein Fallback, sondern eine Behauptung.
|
| 1328 |
+
if _answer_reports_nothing_found(answer):
|
| 1329 |
+
return []
|
| 1330 |
+
|
| 1331 |
normalized = _dedupe_sources(
|
| 1332 |
sources or [],
|
| 1333 |
max_sources=max(max_sources, len(cited_numbers), DEFAULT_MAX_SOURCES),
|
|
|
|
| 1410 |
for source in sources:
|
| 1411 |
old_numbers = _int_list(source.get("source_numbers"), source.get("source_number"))
|
| 1412 |
if not old_numbers:
|
| 1413 |
+
# Quelle ohne Nummer: entsteht über den Rückfall auf die Roh-Treffer
|
| 1414 |
+
# (`_dedupe_sources(hits, …)`), denn ein Treffer trägt keine
|
| 1415 |
+
# Quellennummer — die vergibt erst der Composer. Übersprungen bekam
|
| 1416 |
+
# sie hier nie `display_title`/`display_label` und erschien in der
|
| 1417 |
+
# UI als **leere Zeile**. Sie bekommt deshalb ihre Anzeige, aber
|
| 1418 |
+
# keinen Marker: zitiert wurde sie nicht, und eine erfundene Nummer
|
| 1419 |
+
# stünde im Antworttext nirgends.
|
| 1420 |
+
source.setdefault("source_marker", "")
|
| 1421 |
+
source.setdefault("source_label", "")
|
| 1422 |
+
source["display_title"] = _format_source_display_title(source)
|
| 1423 |
+
source["display_label"] = source["display_title"]
|
| 1424 |
continue
|
| 1425 |
|
| 1426 |
assigned: Optional[int] = None
|
|
|
|
| 1456 |
|
| 1457 |
|
| 1458 |
def _collection_count() -> Any:
|
| 1459 |
+
"""Chunks über alle Korpora. Bei einem Korpus identisch zum bisherigen Wert."""
|
| 1460 |
try:
|
| 1461 |
+
return get_corpus_registry().total_count()
|
| 1462 |
except Exception as exc:
|
| 1463 |
logger.warning("Chroma count unavailable: %s", exc)
|
| 1464 |
return "unknown"
|
| 1465 |
|
| 1466 |
|
| 1467 |
+
def _corpus_counts() -> Dict[str, Any]:
|
| 1468 |
+
try:
|
| 1469 |
+
return {
|
| 1470 |
+
entry["corpus_id"]: entry.get("count", entry.get("error"))
|
| 1471 |
+
for entry in get_corpus_registry().diagnostics()["corpora"]
|
| 1472 |
+
}
|
| 1473 |
+
except Exception as exc: # noqa: BLE001
|
| 1474 |
+
logger.warning("Korpus-Diagnose nicht verfügbar: %s", exc)
|
| 1475 |
+
return {}
|
| 1476 |
+
|
| 1477 |
+
|
| 1478 |
# -----------------------------------------------------------------------------
|
| 1479 |
# Ask Graph Workflow
|
| 1480 |
# -----------------------------------------------------------------------------
|
|
|
|
| 1533 |
|
| 1534 |
route: Optional[AskRoute] = None
|
| 1535 |
hits: Optional[List[Dict[str, Any]]] = None
|
| 1536 |
+
# Die Teilmenge von `hits`, die die Antwort auch zitiert. Wird im
|
| 1537 |
+
# Normalisierungsschritt gesetzt, solange der Antworttext noch die
|
| 1538 |
+
# Retrieval-Ränge trägt (siehe `AnswerComposer.cited_hits`).
|
| 1539 |
+
cited_hits: Optional[List[Dict[str, Any]]] = None
|
| 1540 |
raw_sources: Optional[List[Dict[str, Any]]] = None
|
| 1541 |
sources: Optional[List[Dict[str, Any]]] = None
|
| 1542 |
answer: str = ""
|
|
|
|
| 1554 |
self.raw_sources = []
|
| 1555 |
if self.sources is None:
|
| 1556 |
self.sources = []
|
| 1557 |
+
if self.cited_hits is None:
|
| 1558 |
+
self.cited_hits = []
|
| 1559 |
if self.trace is None:
|
| 1560 |
self.trace = []
|
| 1561 |
|
|
|
|
| 1659 |
hits=ctx.hits,
|
| 1660 |
payload=ctx.payload,
|
| 1661 |
)
|
| 1662 |
+
# Welche Treffer die Antwort tatsächlich zitiert — noch mit den
|
| 1663 |
+
# Retrieval-Rängen, die gleich umnummeriert werden. Danach wäre die
|
| 1664 |
+
# Zuordnung verloren: der Text trüge Anzeigenummern, die Treffer weiter
|
| 1665 |
+
# ihre Ränge.
|
| 1666 |
+
try:
|
| 1667 |
+
ctx.cited_hits = AnswerComposer.cited_hits(ctx.answer, ctx.hits or [])
|
| 1668 |
+
except Exception as exc: # noqa: BLE001 - eine Nebenrechnung darf nie die Antwort verhindern.
|
| 1669 |
+
logger.warning("Zitatzuordnung übersprungen: %s", exc)
|
| 1670 |
+
ctx.cited_hits = []
|
| 1671 |
+
|
| 1672 |
# Anzeige-Nummern stabil auf 1..n abbilden (statt Retrieval-Ränge wie
|
| 1673 |
# "[Quellen 3, 8]"). Muss als letzter Schritt laufen, damit Antworttext
|
| 1674 |
# und Quellenliste dieselben Nummern zeigen.
|
| 1675 |
ctx.answer, ctx.sources = _renumber_sources_for_display(ctx.answer, ctx.sources)
|
| 1676 |
else:
|
| 1677 |
ctx.sources = []
|
| 1678 |
+
ctx.cited_hits = []
|
| 1679 |
|
| 1680 |
return AskGraphStep("update_memory", "sources normalized and answer postprocessed")
|
| 1681 |
|
|
|
|
| 1696 |
def _attach_pdf_locators(sources: List[Dict[str, Any]], hits: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 1697 |
"""Sicherheitsnetz: fehlende PDF-Locator aus Hits bzw. page_range ableiten.
|
| 1698 |
|
| 1699 |
+
Composer/Orchestrator liefern die Felder normalerweise bereits mit; ältere
|
| 1700 |
+
Pfade (Legacy-Fallbacks) können sie verlieren.
|
| 1701 |
+
|
| 1702 |
+
Der Fallback wird pro Dokument gebildet, nicht global: bei mehreren Korpora
|
| 1703 |
+
würde "irgendein Hit" sonst das falsche PDF an die Quelle heften und der
|
| 1704 |
+
Viewer bei einer SGB-V-Fundstelle den Rahmenvertrag öffnen.
|
| 1705 |
"""
|
| 1706 |
+
by_doc: Dict[str, Dict[str, Any]] = {}
|
|
|
|
| 1707 |
for hit in hits or []:
|
| 1708 |
metadata = hit.get("metadata") or {}
|
| 1709 |
+
doc_id = str(_coalesce(hit.get("doc_id"), metadata.get("doc_id")) or "")
|
| 1710 |
+
entry = by_doc.setdefault(doc_id, {})
|
| 1711 |
+
entry.setdefault("source_file", _coalesce(hit.get("source_file"), metadata.get("source_file")))
|
| 1712 |
+
entry.setdefault("doc_title", _coalesce(hit.get("doc_title"), metadata.get("doc_title")))
|
| 1713 |
+
|
| 1714 |
+
# Nur wenn der Trefferpool eindeutig aus einem Dokument stammt, darf eine
|
| 1715 |
+
# Quelle ohne doc_id daraus ergänzt werden.
|
| 1716 |
+
single_doc = by_doc.get(next(iter(by_doc))) if len(by_doc) == 1 else None
|
| 1717 |
|
| 1718 |
for source in sources or []:
|
| 1719 |
+
doc_id = str(source.get("doc_id") or "")
|
| 1720 |
+
entry = by_doc.get(doc_id) if doc_id else single_doc
|
| 1721 |
+
if entry:
|
| 1722 |
+
if not source.get("source_file") and entry.get("source_file"):
|
| 1723 |
+
source["source_file"] = entry["source_file"]
|
| 1724 |
+
if not source.get("doc_title") and entry.get("doc_title"):
|
| 1725 |
+
source["doc_title"] = entry["doc_title"]
|
| 1726 |
+
if not source.get("doc_id") and single_doc is not None:
|
| 1727 |
+
source["doc_id"] = next(iter(by_doc))
|
| 1728 |
|
| 1729 |
if source.get("page_start") is None:
|
| 1730 |
match = _PAGE_RANGE_RE.search(str(source.get("page_range") or source.get("pages") or ""))
|
|
|
|
| 1742 |
return sources
|
| 1743 |
|
| 1744 |
|
| 1745 |
+
def _listenverdikt(
|
| 1746 |
+
befund: Any,
|
| 1747 |
+
austausch: Any,
|
| 1748 |
+
biosimilar: Any = None,
|
| 1749 |
+
otc: Any = None,
|
| 1750 |
+
verordnung: Any = None,
|
| 1751 |
+
lifestyle: Any = None,
|
| 1752 |
+
tabak: Any = None,
|
| 1753 |
+
) -> str:
|
| 1754 |
+
"""Welcher der Listenbefunde die Kurzantwort setzt.
|
| 1755 |
+
|
| 1756 |
+
Die fünf Listen beantworten teils dieselbe Frageformulierung, aber
|
| 1757 |
+
verschiedene Fragen — „dasselbe Präparat eines anderen Herstellers?"
|
| 1758 |
+
(Anlage VII Teil B), „eine andere Darreichungsform?" (Teil A), „ein
|
| 1759 |
+
Biosimilar statt des Originals?" (Anlage VIIa), „überhaupt zu Lasten der
|
| 1760 |
+
GKV?" (Anlagen II, III und I). Die Reihenfolge folgt daraus, wie
|
| 1761 |
+
einschneidend die Auskunft ist:
|
| 1762 |
+
|
| 1763 |
+
1. Ein Substitutionsausschluss aus Teil B ist eine harte Schranke und steht
|
| 1764 |
+
über allem anderen.
|
| 1765 |
+
2. Dann Anlage VIIa, wenn sie entschieden hat: ihre Wirkstoffe sind
|
| 1766 |
+
biotechnologisch hergestellt und kommen in keiner der anderen Listen vor
|
| 1767 |
+
(geprüft: die Wirkstoffmengen sind disjunkt). Wer nach Humira und
|
| 1768 |
+
Amgevita fragt, bekommt sonst „Adalimumab steht nicht auf der
|
| 1769 |
+
Substitutionsausschlussliste" — richtig, aber nicht die Frage.
|
| 1770 |
+
3. Sonst Teil A, wenn die Frage zwei Formen benannt hat.
|
| 1771 |
+
4. Dann Anlage IIa, dann Anlage II, danach Anlage III und erst danach
|
| 1772 |
+
Anlage I. Alle vier beantworten dieselbe Frage — „darf das überhaupt zu
|
| 1773 |
+
Lasten der GKV verordnet werden?" —, aber sie tragen verschieden weit.
|
| 1774 |
+
|
| 1775 |
+
Anlage IIa steht vor Anlage II, obwohl sie deren Ausnahme ist und nicht
|
| 1776 |
+
ihre Regel: ihr Verdikt trägt **beide** Hälften („grundsätzlich
|
| 1777 |
+
ausgeschlossen, ausnahmsweise verordnungsfähig, wenn …"), das der Anlage
|
| 1778 |
+
II nur eine. Bei Champix stünde sonst „ist ausgeschlossen" als
|
| 1779 |
+
Kurzantwort über einem Block, der zwei Absätze tiefer den Anspruch nach
|
| 1780 |
+
§ 34 Absatz 2 SGB V nennt — genau der Widerspruch, den
|
| 1781 |
+
`antwort_mit_befunden` auflösen soll.
|
| 1782 |
+
|
| 1783 |
+
Anlage II steht vor III und I, weil ihr Ausschluss der härteste ist: er
|
| 1784 |
+
beruht auf § 34 Absatz 1 Satz 7 SGB V, und der medizinisch begründete
|
| 1785 |
+
Einzelfall des § 16 Absatz 5 AM-RL führt an ihm vorbei. Anlage III
|
| 1786 |
+
entscheidet auch für verschreibungspflichtige Arzneimittel, hält den
|
| 1787 |
+
Einzelfall bei ihren Markern 3 bis 6 aber offen; Anlage I urteilt nur
|
| 1788 |
+
über nicht verschreibungspflichtige. Bei Sildenafil steht so „nach
|
| 1789 |
+
Anlage II ausgeschlossen" statt des blasseren „steht nicht in Anlage III".
|
| 1790 |
+
5. Alle drei stehen hinter den Austauschlisten, weil ihre Signale
|
| 1791 |
+
(„erstattungsfähig", „Kassenrezept") auch in einer Austauschfrage
|
| 1792 |
+
vorkommen, umgekehrt aber nicht.
|
| 1793 |
+
6. Erst danach das „nicht gelistet" aus Teil B — die schwächste Aussage von
|
| 1794 |
+
allen. Ohne diese Reihenfolge würde eine reine Darreichungsformfrage mit
|
| 1795 |
+
„Ambroxol steht nicht auf der Substitutionsausschlussliste" beantwortet:
|
| 1796 |
+
richtig, aber am Thema vorbei.
|
| 1797 |
+
"""
|
| 1798 |
+
if befund is not None and befund.status in {
|
| 1799 |
+
"ausschluss",
|
| 1800 |
+
"ausschluss_bedingt",
|
| 1801 |
+
"ausschluss_zwischen_varianten",
|
| 1802 |
+
}:
|
| 1803 |
+
return amrl_substitution.verdikt(befund)
|
| 1804 |
+
|
| 1805 |
+
for modul, listenbefund in (
|
| 1806 |
+
(amrl_biosimilars, biosimilar),
|
| 1807 |
+
(amrl_austauschbarkeit, austausch),
|
| 1808 |
+
(amrl_tabakentwoehnung, tabak),
|
| 1809 |
+
(amrl_lifestyle, lifestyle),
|
| 1810 |
+
(amrl_verordnungsausschluss, verordnung),
|
| 1811 |
+
(amrl_otc, otc),
|
| 1812 |
+
):
|
| 1813 |
+
if listenbefund is not None:
|
| 1814 |
+
satz = modul.verdikt(listenbefund)
|
| 1815 |
+
if satz:
|
| 1816 |
+
return satz
|
| 1817 |
+
|
| 1818 |
+
return amrl_substitution.verdikt(befund) if befund is not None else ""
|
| 1819 |
+
|
| 1820 |
+
|
| 1821 |
def _ask_graph_build_response(ctx: AskGraphContext) -> AskGraphStep:
|
| 1822 |
"""Baut exakt die bisherige API-Response-Struktur."""
|
| 1823 |
_attach_pdf_locators(ctx.sources or [], ctx.hits or [])
|
| 1824 |
+
|
| 1825 |
+
# Korpusgrenze offenlegen: verweist der herangezogene Kontext auf ein
|
| 1826 |
+
# Regelwerk, das nicht indiziert ist (AM-RL, AMPreisV, …), wird das benannt.
|
| 1827 |
+
# Ohne diesen Hinweis liest sich eine aus § 31 SGB V gebaute Antwort
|
| 1828 |
+
# vollständig, obwohl die Verordnungsausschlüsse der AM-RL fehlen.
|
| 1829 |
+
#
|
| 1830 |
+
# Gelesen werden die *zitierten* Treffer, nicht der ganze Pool. Der Hinweis
|
| 1831 |
+
# spricht von den „herangezogenen Textstellen"; im Ausdruck vom 20.08.2026
|
| 1832 |
+
# stand unter einer Frage zur Austauschbarkeit von Darreichungsformen der
|
| 1833 |
+
# Hinweis auf die BtMVV — sie kommt weder in § 9 Abs. 3 Rahmenvertrag noch
|
| 1834 |
+
# in § 40c AM-RL vor, wohl aber in § 6 Rahmenvertrag, den das Modell
|
| 1835 |
+
# verworfen hatte. Ein Hinweis auf ein Regelwerk, das die Antwort gar nicht
|
| 1836 |
+
# berührt, entwertet die Hinweise, die tragen.
|
| 1837 |
+
#
|
| 1838 |
+
# Ist keine Zuordnung möglich (Antwort ohne Marker), bleibt es beim Pool:
|
| 1839 |
+
# lieber ein Hinweis zu viel als eine verschwiegene Lücke.
|
| 1840 |
+
boundary: List[Dict[str, str]] = []
|
| 1841 |
+
if ctx.route != "meta":
|
| 1842 |
+
try:
|
| 1843 |
+
boundary = detect_external_references(
|
| 1844 |
+
ctx.cited_hits or ctx.hits or [],
|
| 1845 |
+
available_corpora=get_corpus_registry().corpus_ids,
|
| 1846 |
+
)
|
| 1847 |
+
except Exception as exc: # noqa: BLE001 - ein Hinweis darf nie die Antwort verhindern.
|
| 1848 |
+
logger.warning("Korpusgrenzen-Erkennung übersprungen: %s", exc)
|
| 1849 |
+
boundary = []
|
| 1850 |
+
|
| 1851 |
+
# Stand der zitierten Vorschriften: nennt konkret, welche spätere
|
| 1852 |
+
# Änderungsvereinbarung eine zitierte Norm geändert hat und was jetzt gilt.
|
| 1853 |
+
# Betrifft Korpora, die nur als Grundfassung plus separate Änderungstexte
|
| 1854 |
+
# veröffentlicht werden — dort wäre der indizierte Text sonst still veraltet.
|
| 1855 |
+
# Beide Hinweise unten hängen an derselben Frage. Sie muss vor dem Abgleich
|
| 1856 |
+
# beantwortet werden: reconcile_negative_answer setzt die Fundstelle der
|
| 1857 |
+
# Änderungsvereinbarung in den Text und kippte die Prüfung sonst ins
|
| 1858 |
+
# Gegenteil.
|
| 1859 |
+
nothing_found = _answer_reports_nothing_found(ctx.answer)
|
| 1860 |
+
|
| 1861 |
+
amendments: List[Dict[str, Any]] = []
|
| 1862 |
+
amendment_note = ""
|
| 1863 |
+
if ctx.route != "meta":
|
| 1864 |
+
try:
|
| 1865 |
+
# Bewusst die ungefilterten Quellen: ob eine geänderte Vorschrift
|
| 1866 |
+
# abgerufen wurde, entscheidet der Trefferbestand, nicht die für die
|
| 1867 |
+
# Anzeige gekürzte Liste — die bei einer Verneinung leer ist.
|
| 1868 |
+
amendment_note, amendments = describe_amendments(
|
| 1869 |
+
ctx.raw_sources or ctx.sources or [],
|
| 1870 |
+
path=AMENDMENTS_FILE,
|
| 1871 |
+
question=ctx.question,
|
| 1872 |
+
)
|
| 1873 |
+
except Exception as exc: # noqa: BLE001 - ein Hinweis darf nie die Antwort verhindern.
|
| 1874 |
+
logger.warning("Änderungshinweis übersprungen: %s", exc)
|
| 1875 |
+
amendment_note, amendments = "", []
|
| 1876 |
+
|
| 1877 |
+
# Das "nicht geregelt" des Modells gilt nur für den indizierten Stand. Folgt
|
| 1878 |
+
# darunter der Änderungshinweis mit der geltenden Regelung, widersprechen
|
| 1879 |
+
# sich beide Aussagen im Text, obwohl jede für sich zutrifft. Deshalb wird
|
| 1880 |
+
# die Verneinung vor dem Anhängen auf ihren Geltungsbereich zurückgeführt.
|
| 1881 |
+
answer = ctx.answer
|
| 1882 |
+
if amendments:
|
| 1883 |
+
try:
|
| 1884 |
+
answer = reconcile_negative_answer(answer, amendments, path=AMENDMENTS_FILE)
|
| 1885 |
+
except Exception as exc: # noqa: BLE001 - siehe oben: nie die Antwort verhindern.
|
| 1886 |
+
logger.warning("Abgleich mit dem Änderungshinweis übersprungen: %s", exc)
|
| 1887 |
+
|
| 1888 |
+
# Der Reichweiten-Hinweis spricht von den "herangezogenen Textstellen". Hat
|
| 1889 |
+
# die Antwort nichts gefunden, wurde nichts herangezogen: die Verweise
|
| 1890 |
+
# stammen dann aus Treffern, die das Modell verworfen hat, und benennen ein
|
| 1891 |
+
# fremdes Thema. Beim Beispiel Chargendokumentation waren das AM-RL und BtMVV.
|
| 1892 |
+
if boundary and nothing_found:
|
| 1893 |
+
logger.info("Reichweiten-Hinweis unterdrückt: Antwort zitiert keine Quelle.")
|
| 1894 |
+
boundary = []
|
| 1895 |
+
|
| 1896 |
+
# Substitutionsausschluss: deterministische Prüfung gegen AM-RL Anlage VII
|
| 1897 |
+
# Teil B. Die Liste ist bewusst nicht indiziert — über Retrieval wäre die
|
| 1898 |
+
# praktisch wichtigste Auskunft ("steht nicht drauf, also austauschbar")
|
| 1899 |
+
# nicht belegbar, weil top-k immer Treffer liefert.
|
| 1900 |
+
befund = None
|
| 1901 |
+
austausch = None
|
| 1902 |
+
biosimilar = None
|
| 1903 |
+
otc = None
|
| 1904 |
+
verordnung = None
|
| 1905 |
+
lifestyle = None
|
| 1906 |
+
tabak = None
|
| 1907 |
+
if ctx.route != "meta":
|
| 1908 |
+
# Teil A zuerst: seine Wirkstoffliste ist rund achtmal so lang wie die
|
| 1909 |
+
# von Teil B und liefert dessen Negativauskunft den Namen, den die
|
| 1910 |
+
# Endungsheuristik allein nicht sicher erkennt.
|
| 1911 |
+
try:
|
| 1912 |
+
austausch = amrl_austauschbarkeit.pruefe(ctx.question, path=AMRL_AUSTAUSCHBARKEIT_FILE)
|
| 1913 |
+
except Exception as exc: # noqa: BLE001 - eine Prüfung darf nie die Antwort verhindern.
|
| 1914 |
+
logger.warning("Austauschbarkeitsprüfung übersprungen: %s", exc)
|
| 1915 |
+
austausch = None
|
| 1916 |
+
try:
|
| 1917 |
+
befund = amrl_substitution.pruefe(
|
| 1918 |
+
ctx.question,
|
| 1919 |
+
path=AMRL_SUBSTITUTION_FILE,
|
| 1920 |
+
kandidat=(austausch.wirkstoff if austausch is not None else None),
|
| 1921 |
+
)
|
| 1922 |
+
except Exception as exc: # noqa: BLE001 - siehe oben.
|
| 1923 |
+
logger.warning("Substitutionsprüfung übersprungen: %s", exc)
|
| 1924 |
+
befund = None
|
| 1925 |
+
try:
|
| 1926 |
+
biosimilar = amrl_biosimilars.pruefe(ctx.question, path=AMRL_BIOSIMILARS_FILE)
|
| 1927 |
+
except Exception as exc: # noqa: BLE001 - siehe oben.
|
| 1928 |
+
logger.warning("Biosimilar-Prüfung übersprungen: %s", exc)
|
| 1929 |
+
biosimilar = None
|
| 1930 |
+
try:
|
| 1931 |
+
otc = amrl_otc.pruefe(ctx.question, path=AMRL_OTC_FILE)
|
| 1932 |
+
except Exception as exc: # noqa: BLE001 - siehe oben.
|
| 1933 |
+
logger.warning("OTC-Prüfung übersprungen: %s", exc)
|
| 1934 |
+
otc = None
|
| 1935 |
+
try:
|
| 1936 |
+
verordnung = amrl_verordnungsausschluss.pruefe(
|
| 1937 |
+
ctx.question, path=AMRL_VERORDNUNG_FILE
|
| 1938 |
+
)
|
| 1939 |
+
except Exception as exc: # noqa: BLE001 - siehe oben.
|
| 1940 |
+
logger.warning("Verordnungsausschluss-Prüfung übersprungen: %s", exc)
|
| 1941 |
+
verordnung = None
|
| 1942 |
+
try:
|
| 1943 |
+
lifestyle = amrl_lifestyle.pruefe(ctx.question, path=AMRL_LIFESTYLE_FILE)
|
| 1944 |
+
except Exception as exc: # noqa: BLE001 - siehe oben.
|
| 1945 |
+
logger.warning("Lifestyle-Prüfung übersprungen: %s", exc)
|
| 1946 |
+
lifestyle = None
|
| 1947 |
+
try:
|
| 1948 |
+
# Der Wirkstoff wird aus Anlage II übergeben, nicht neu erkannt:
|
| 1949 |
+
# „Champix" steht dort in der Fertigarzneimittelspalte und wird zu
|
| 1950 |
+
# „Vareniclin" aufgelöst, während Anlage IIa gar keine Produkte führt
|
| 1951 |
+
# („alle marktverfügbaren Arzneimittel"). Übergeben wird nur, wenn
|
| 1952 |
+
# der Treffer die Nikotinabhängigkeit betrifft — sonst beantwortete
|
| 1953 |
+
# eine Frage nach Sildenafil eine Frage nach der Tabakentwöhnung.
|
| 1954 |
+
tabak = amrl_tabakentwoehnung.pruefe(
|
| 1955 |
+
ctx.question,
|
| 1956 |
+
path=AMRL_TABAKENTWOEHNUNG_FILE,
|
| 1957 |
+
stoff=(
|
| 1958 |
+
lifestyle.treffer[0].wirkstoff
|
| 1959 |
+
if lifestyle is not None and lifestyle.tabakentwoehnung and lifestyle.treffer
|
| 1960 |
+
else None
|
| 1961 |
+
),
|
| 1962 |
+
)
|
| 1963 |
+
except Exception as exc: # noqa: BLE001 - siehe oben.
|
| 1964 |
+
logger.warning("Tabakentwöhnungs-Prüfung übersprungen: %s", exc)
|
| 1965 |
+
tabak = None
|
| 1966 |
+
|
| 1967 |
+
geprueft = [
|
| 1968 |
+
b
|
| 1969 |
+
for b in (befund, austausch, biosimilar, otc, verordnung, lifestyle, tabak)
|
| 1970 |
+
if b is not None and b.ist_belastbar
|
| 1971 |
+
]
|
| 1972 |
+
|
| 1973 |
+
# Trägt einer der Befunde eine Auskunft, wurde die Liste geprüft — dann darf
|
| 1974 |
+
# der Reichweiten-Hinweis nicht weiter behaupten, sie sei es nicht. Die
|
| 1975 |
+
# übrigen Anlagen (Medizinprodukte, Verordnungsausschlüsse …) bleiben
|
| 1976 |
+
# ungeprüft, deshalb wird nur der eine Eintrag entfernt und nicht der ganze
|
| 1977 |
+
# Hinweis.
|
| 1978 |
+
# Dasselbe gilt, wenn das Normregister eine AM-RL-Anlage als Fundstelle
|
| 1979 |
+
# benennt: der Hinweis meldete die Anlagen als ungeprüft, während die Antwort
|
| 1980 |
+
# sich zwei Absätze darüber auf Anlage VII Teil A berief.
|
| 1981 |
+
anlage_benannt = False
|
| 1982 |
+
if ctx.route != "meta":
|
| 1983 |
+
try:
|
| 1984 |
+
anlage_benannt = norm_anchors.anlagen_geprueft(ctx.question)
|
| 1985 |
+
except Exception as exc: # noqa: BLE001 - siehe oben.
|
| 1986 |
+
logger.warning("Anlagenprüfung des Normregisters übersprungen: %s", exc)
|
| 1987 |
+
|
| 1988 |
+
if (geprueft or anlage_benannt) and boundary:
|
| 1989 |
+
boundary = [ref for ref in boundary if ref.get("key") != "am_rl_anlagen"]
|
| 1990 |
+
|
| 1991 |
+
# Die Listenbefunde stehen ganz oben: sie sind die Auskunft, das Modell
|
| 1992 |
+
# darunter ordnet ein, was daraus für Abgabe und Abrechnung folgt. Die
|
| 1993 |
+
# Kurzantwort wird dabei angeglichen — ohne die Listen kommt das Modell zur
|
| 1994 |
+
# allgemeinen Ersetzungsregel und widerspricht dem Befund zwei Zeilen darüber.
|
| 1995 |
+
if geprueft:
|
| 1996 |
+
answer = amrl_substitution.antwort_mit_befunden(
|
| 1997 |
+
answer,
|
| 1998 |
+
bloecke=[
|
| 1999 |
+
amrl_substitution.befund_block(befund) if befund is not None else "",
|
| 2000 |
+
amrl_austauschbarkeit.befund_block(austausch) if austausch is not None else "",
|
| 2001 |
+
amrl_biosimilars.befund_block(biosimilar) if biosimilar is not None else "",
|
| 2002 |
+
amrl_lifestyle.befund_block(lifestyle) if lifestyle is not None else "",
|
| 2003 |
+
amrl_tabakentwoehnung.befund_block(tabak) if tabak is not None else "",
|
| 2004 |
+
amrl_verordnungsausschluss.befund_block(verordnung)
|
| 2005 |
+
if verordnung is not None
|
| 2006 |
+
else "",
|
| 2007 |
+
amrl_otc.befund_block(otc) if otc is not None else "",
|
| 2008 |
+
],
|
| 2009 |
+
verdikt=_listenverdikt(
|
| 2010 |
+
befund, austausch, biosimilar, otc, verordnung, lifestyle, tabak
|
| 2011 |
+
),
|
| 2012 |
+
)
|
| 2013 |
+
|
| 2014 |
+
# Normebene statt Stoffebene: die vier Listen prüfen, ob ein Wirkstoff auf
|
| 2015 |
+
# einer Anlage steht, und melden bei einer reinen Normfrage planmäßig
|
| 2016 |
+
# `nicht_pruefbar`. Genau dort lagen die beiden schwersten Fehler der
|
| 2017 |
+
# Auswertung — eine OTC-Frage mit § 31 statt § 34 beantwortet, das
|
| 2018 |
+
# Zuweisungsverbot aus § 360 hergeleitet statt aus § 31 Abs. 1 Satz 6.
|
| 2019 |
+
normbefund = None
|
| 2020 |
+
if ctx.route != "meta":
|
| 2021 |
+
try:
|
| 2022 |
+
normbefund = norm_anchors.pruefe(ctx.question, answer)
|
| 2023 |
+
except Exception as exc: # noqa: BLE001 - ein Abgleich darf nie die Antwort verhindern.
|
| 2024 |
+
logger.warning("Normabgleich übersprungen: %s", exc)
|
| 2025 |
+
normbefund = None
|
| 2026 |
+
|
| 2027 |
+
if normbefund is not None:
|
| 2028 |
+
# Steht die Antwort auf der falschen Vorschrift, werden „Kurzantwort" und
|
| 2029 |
+
# „Maßgebliche Norm" neu gesetzt — ein bloßer Nachtrag stünde neben der
|
| 2030 |
+
# falschen Antwort, und nichts im Text sagte, welcher gilt. Fehlt nur ein
|
| 2031 |
+
# Glied der Kette, bleibt die Antwort stehen und bekommt den Nachtrag.
|
| 2032 |
+
answer = norm_anchors.in_antwort_einsetzen(answer, normbefund)
|
| 2033 |
+
|
| 2034 |
+
# Beide Hinweise erst jetzt: sie werden ans Ende gehängt, und dort steht
|
| 2035 |
+
# ohne eigene Überschrift noch der Abschnitt „Kurzantwort". Setzt ein
|
| 2036 |
+
# Listenbefund diese Kurzantwort, ersetzt `antwort_mit_befunden` den ganzen
|
| 2037 |
+
# Abschnitt — ein vorher angehängter Hinweis steckte darin und wäre spurlos
|
| 2038 |
+
# verschwunden. Die Reihenfolge der beiden zueinander bleibt: der
|
| 2039 |
+
# Änderungshinweis trägt die eigentliche Auskunft und steht deshalb vor dem
|
| 2040 |
+
# Reichweiten-Hinweis, der nur eine Einschränkung benennt.
|
| 2041 |
+
answer = append_amendment_note(answer, amendment_note)
|
| 2042 |
+
if boundary:
|
| 2043 |
+
answer = append_boundary_note(answer, boundary)
|
| 2044 |
+
|
| 2045 |
ctx.response_body = {
|
| 2046 |
"question": ctx.question,
|
| 2047 |
+
"answer": answer,
|
| 2048 |
"answer_type": ctx.answer_type,
|
| 2049 |
"session_id": ctx.request.state.session_id,
|
| 2050 |
"factual_question_index": ctx.memory.factual_question_count,
|
|
|
|
| 2052 |
"sources": ctx.sources or [],
|
| 2053 |
"needs_clarification": ctx.needs_clarification,
|
| 2054 |
"clarification_question": ctx.clarification_question,
|
| 2055 |
+
# Maschinenlesbar für UI/Monitoring: welche Regelwerke fehlten.
|
| 2056 |
+
"corpus_boundary": boundary,
|
| 2057 |
+
# Zitierte Vorschriften, deren indizierter Stand überholt ist.
|
| 2058 |
+
"corpus_amendments": [
|
| 2059 |
+
{"locator": p.get("locator"), "status": p.get("status"), "current_rule": p.get("current_rule")}
|
| 2060 |
+
for p in amendments
|
| 2061 |
+
],
|
| 2062 |
+
# Welche Norm das Register für maßgeblich hält und ob die Antwort sie nennt.
|
| 2063 |
+
"norm_anchor": normbefund.to_dict() if normbefund is not None else None,
|
| 2064 |
+
# Maschinenlesbare Listenbefunde samt Stand — für UI und Monitoring.
|
| 2065 |
+
"substitutionsausschluss": befund.to_dict() if befund is not None else None,
|
| 2066 |
+
"austauschbarkeit": austausch.to_dict() if austausch is not None else None,
|
| 2067 |
+
"biosimilars": biosimilar.to_dict() if biosimilar is not None else None,
|
| 2068 |
+
"otc_verordnungsfaehigkeit": otc.to_dict() if otc is not None else None,
|
| 2069 |
+
"verordnungsausschluss": verordnung.to_dict() if verordnung is not None else None,
|
| 2070 |
+
"lifestyle_ausschluss": lifestyle.to_dict() if lifestyle is not None else None,
|
| 2071 |
+
"tabakentwoehnung": tabak.to_dict() if tabak is not None else None,
|
| 2072 |
}
|
| 2073 |
return AskGraphStep("__end__", "response built")
|
| 2074 |
|
|
|
|
| 2335 |
"chroma_persist_dir": CHROMA_PERSIST_DIR,
|
| 2336 |
"chroma_collection": CHROMA_COLLECTION,
|
| 2337 |
"chroma_count": _collection_count(),
|
| 2338 |
+
"corpus_counts": _corpus_counts(),
|
| 2339 |
+
"routed_corpora": sorted({
|
| 2340 |
+
str(hit.get("corpus_id")) for hit in (ctx.hits or []) if hit.get("corpus_id")
|
| 2341 |
+
}),
|
| 2342 |
}
|
| 2343 |
|
| 2344 |
|
|
|
|
| 2382 |
"sources": ctx.sources or [],
|
| 2383 |
"needs_clarification": ctx.needs_clarification,
|
| 2384 |
"clarification_question": ctx.clarification_question,
|
| 2385 |
+
"corpus_boundary": [],
|
| 2386 |
+
# Der Fallback greift nur, wenn der Graph keinen Body gebaut hat; die
|
| 2387 |
+
# Schlüssel bleiben trotzdem stabil, damit die UI nicht zwei Formen kennen muss.
|
| 2388 |
+
"substitutionsausschluss": None,
|
| 2389 |
+
"austauschbarkeit": None,
|
| 2390 |
+
"biosimilars": None,
|
| 2391 |
+
"otc_verordnungsfaehigkeit": None,
|
| 2392 |
+
"verordnungsausschluss": None,
|
| 2393 |
+
"lifestyle_ausschluss": None,
|
| 2394 |
+
"tabakentwoehnung": None,
|
| 2395 |
}
|
| 2396 |
|
| 2397 |
if payload.debug:
|
|
|
|
| 2454 |
"app": APP_TITLE,
|
| 2455 |
"version": APP_VERSION,
|
| 2456 |
"collection": CHROMA_COLLECTION,
|
| 2457 |
+
"corpora": _corpus_counts(),
|
| 2458 |
"chroma_persist_dir": CHROMA_PERSIST_DIR,
|
| 2459 |
"chroma_count": _collection_count(),
|
| 2460 |
"default_container_id": DEFAULT_CONTAINER_ID,
|
|
|
|
| 2468 |
|
| 2469 |
@app.get("/debug/retriever")
|
| 2470 |
def debug_retriever():
|
| 2471 |
+
"""Diagnose pro Korpus statt für eine einzelne Collection."""
|
|
|
|
| 2472 |
try:
|
| 2473 |
+
registry = get_corpus_registry()
|
| 2474 |
+
diagnostics = registry.diagnostics()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2475 |
except Exception as exc:
|
| 2476 |
return {
|
| 2477 |
"ok": False,
|
| 2478 |
"error": f"{type(exc).__name__}: {exc}",
|
| 2479 |
"chroma_persist_dir": CHROMA_PERSIST_DIR,
|
|
|
|
| 2480 |
}
|
| 2481 |
|
| 2482 |
+
for entry in diagnostics["corpora"]:
|
| 2483 |
+
if not entry.get("ok"):
|
| 2484 |
+
continue
|
| 2485 |
+
try:
|
| 2486 |
+
res = registry.retriever(entry["corpus_id"]).col.get(limit=3, include=["metadatas", "documents"])
|
| 2487 |
+
docs = res.get("documents") or []
|
| 2488 |
+
metas = res.get("metadatas") or []
|
| 2489 |
+
ids = res.get("ids") or []
|
| 2490 |
+
entry["sample"] = [
|
| 2491 |
+
{
|
| 2492 |
+
"id": ids[idx] if idx < len(ids) else None,
|
| 2493 |
+
"metadata": meta,
|
| 2494 |
+
"normalized_source": _source_from_hit({"metadata": meta}),
|
| 2495 |
+
"text_preview": (docs[idx] if idx < len(docs) else "")[:250],
|
| 2496 |
+
}
|
| 2497 |
+
for idx, meta in enumerate(metas)
|
| 2498 |
+
]
|
| 2499 |
+
except Exception as exc: # noqa: BLE001
|
| 2500 |
+
entry["sample_error"] = f"{type(exc).__name__}: {exc}"
|
| 2501 |
+
|
| 2502 |
return {
|
| 2503 |
"ok": True,
|
| 2504 |
"chroma_persist_dir": CHROMA_PERSIST_DIR,
|
|
|
|
| 2505 |
"count": _collection_count(),
|
| 2506 |
+
"embedding_consistency": registry.assert_consistent_embedding() or "ok",
|
| 2507 |
+
**diagnostics,
|
| 2508 |
}
|
| 2509 |
|
| 2510 |
|
| 2511 |
+
@app.get("/debug/routing")
|
| 2512 |
+
def debug_routing(question: str = ""):
|
| 2513 |
+
"""Zeigt, welche Korpora eine Frage erreichen würde — und warum."""
|
| 2514 |
+
try:
|
| 2515 |
+
available = get_corpus_registry().available()
|
| 2516 |
+
except Exception as exc: # noqa: BLE001
|
| 2517 |
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
| 2518 |
+
|
| 2519 |
+
out: Dict[str, Any] = {"ok": True, **explain_routing(question, available)}
|
| 2520 |
+
|
| 2521 |
+
# Der Router sieht nur den Wortlaut. Ob ein Korpus zusätzlich zwingend
|
| 2522 |
+
# abgefragt wird, entscheidet das Normregister — und genau dieser Fall ist
|
| 2523 |
+
# der, den man debuggen will ("Sonderkennzeichen" nennt kein Korpus).
|
| 2524 |
+
try:
|
| 2525 |
+
out["federation"] = get_retriever().explain_selection(question)
|
| 2526 |
+
except Exception as exc: # noqa: BLE001
|
| 2527 |
+
out["federation_error"] = f"{type(exc).__name__}: {exc}"
|
| 2528 |
+
|
| 2529 |
+
return out
|
| 2530 |
+
|
| 2531 |
+
|
| 2532 |
# -----------------------------------------------------------------------------
|
| 2533 |
# Session System-Prompt
|
| 2534 |
# -----------------------------------------------------------------------------
|
src/corpus_amendments.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Name the concrete changes a corpus's indexed version does not yet contain.
|
| 2 |
+
|
| 3 |
+
Some corpora are only published as a base version plus separate amendment
|
| 4 |
+
documents. The Arzneimittelabrechnungsvereinbarung is the case this was written
|
| 5 |
+
for: the GKV-Spitzenverband offers the Fassung vom 01.07.2023 and five later
|
| 6 |
+
amendment texts, but no consolidated version.
|
| 7 |
+
|
| 8 |
+
Indexing the base alone would be silently wrong, and the amendment texts are
|
| 9 |
+
useless as retrieval material — they consist of editing instructions ("In § 4
|
| 10 |
+
Absatz 2 werden die Wörter … ersetzt"), not of normative text. So the amendments
|
| 11 |
+
are curated once into `data/amabrv_aenderungen.json` and surfaced here: whenever
|
| 12 |
+
an answer cites a provision that was later changed, the answer says which
|
| 13 |
+
amendment changed it and what it now says.
|
| 14 |
+
|
| 15 |
+
This is deliberately narrower than `corpus_boundary`, which reports whole
|
| 16 |
+
regelwerke the index lacks. Here the corpus *has* the provision — it is just
|
| 17 |
+
older than the law in force.
|
| 18 |
+
|
| 19 |
+
The note alone is not enough. When an amendment *introduced* a rule, the model
|
| 20 |
+
answers "dazu gibt es keine Regelung" — true of the indexed text, false of the
|
| 21 |
+
law — and the note then states the rule two lines below. Both statements are
|
| 22 |
+
correct in their own scope, but nothing in the text marks the scope change, so
|
| 23 |
+
the answer reads as self-contradictory. `reconcile_negative_answer` therefore
|
| 24 |
+
rewrites the model's bare denial into a scoped one before the note is appended.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import logging
|
| 31 |
+
import re
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
| 34 |
+
|
| 35 |
+
from answer_schema import SHORT_ANSWER_HEADING, is_denial, split_sections
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_amendments(path: Path) -> Dict[str, Any]:
|
| 43 |
+
"""Read and cache one amendment file. A missing file is not an error."""
|
| 44 |
+
key = str(path)
|
| 45 |
+
if key in _CACHE:
|
| 46 |
+
return _CACHE[key]
|
| 47 |
+
|
| 48 |
+
data: Dict[str, Any] = {}
|
| 49 |
+
try:
|
| 50 |
+
if path.exists():
|
| 51 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 52 |
+
except Exception as exc: # noqa: BLE001 - a broken notice file must not break answers.
|
| 53 |
+
logger.warning("Änderungsdatei nicht lesbar: %s (%s)", path, exc)
|
| 54 |
+
data = {}
|
| 55 |
+
|
| 56 |
+
_CACHE[key] = data
|
| 57 |
+
return data
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _norm(text: Any) -> str:
|
| 61 |
+
return " ".join(str(text or "").strip().lower().split())
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _hit_corpus(hit: Dict[str, Any]) -> str:
|
| 65 |
+
metadata = hit.get("metadata") or {}
|
| 66 |
+
return str(hit.get("corpus_id") or metadata.get("corpus_id") or "")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _hit_container(hit: Dict[str, Any]) -> str:
|
| 70 |
+
metadata = hit.get("metadata") or {}
|
| 71 |
+
return str(hit.get("container") or hit.get("container_id") or metadata.get("container_id") or "")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _hit_section(hit: Dict[str, Any]) -> str:
|
| 75 |
+
metadata = hit.get("metadata") or {}
|
| 76 |
+
return str(hit.get("section") or hit.get("section_id") or metadata.get("section_id") or "")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def affected_provisions(
|
| 80 |
+
sources: Sequence[Dict[str, Any]],
|
| 81 |
+
*,
|
| 82 |
+
amendments: Dict[str, Any],
|
| 83 |
+
question: str = "",
|
| 84 |
+
) -> List[Dict[str, Any]]:
|
| 85 |
+
"""Provisions that later amendments changed, by citation or by topic.
|
| 86 |
+
|
| 87 |
+
Two triggers, because one alone leaves a hole:
|
| 88 |
+
|
| 89 |
+
* **Citation** — a cited source sits in an amended provision. Matching is on
|
| 90 |
+
corpus + container + section, never on the § number alone: this corpus
|
| 91 |
+
restarts its § numbering inside every Anlage.
|
| 92 |
+
* **Topic** — the question names something an amendment *introduced*. Such a
|
| 93 |
+
rule is by definition absent from the indexed base text, so nothing can be
|
| 94 |
+
retrieved and nothing gets cited; without this trigger the user would be
|
| 95 |
+
told nothing exists when a binding rule does. The Chargendokumentation
|
| 96 |
+
beim "Stellen" is exactly that case: added 2024, extended to 31.12.2026.
|
| 97 |
+
"""
|
| 98 |
+
provisions = amendments.get("provisions") or []
|
| 99 |
+
corpus_id = _norm(amendments.get("corpus_id"))
|
| 100 |
+
if not provisions or not corpus_id:
|
| 101 |
+
return []
|
| 102 |
+
|
| 103 |
+
wanted = {(_norm(p.get("container")), _norm(p.get("section"))): p for p in provisions}
|
| 104 |
+
haystack = _norm(question)
|
| 105 |
+
seen: set = set()
|
| 106 |
+
out: List[Dict[str, Any]] = []
|
| 107 |
+
|
| 108 |
+
for source in sources or []:
|
| 109 |
+
if _norm(_hit_corpus(source)) != corpus_id:
|
| 110 |
+
continue
|
| 111 |
+
key = (_norm(_hit_container(source)), _norm(_hit_section(source)))
|
| 112 |
+
if key in wanted and key not in seen:
|
| 113 |
+
seen.add(key)
|
| 114 |
+
out.append(wanted[key])
|
| 115 |
+
|
| 116 |
+
if haystack:
|
| 117 |
+
for provision in provisions:
|
| 118 |
+
key = (_norm(provision.get("container")), _norm(provision.get("section")))
|
| 119 |
+
if key in seen:
|
| 120 |
+
continue
|
| 121 |
+
terms = [_norm(t) for t in provision.get("trigger_terms") or []]
|
| 122 |
+
if any(term and term in haystack for term in terms):
|
| 123 |
+
seen.add(key)
|
| 124 |
+
out.append(provision)
|
| 125 |
+
|
| 126 |
+
return out
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def amendment_note(
|
| 130 |
+
provisions: Sequence[Dict[str, Any]],
|
| 131 |
+
*,
|
| 132 |
+
amendments: Dict[str, Any],
|
| 133 |
+
) -> str:
|
| 134 |
+
"""Spell out, per provision, what changed and what applies now."""
|
| 135 |
+
if not provisions:
|
| 136 |
+
return ""
|
| 137 |
+
|
| 138 |
+
version = amendments.get("indexed_version_label") or amendments.get("indexed_version") or ""
|
| 139 |
+
lines: List[str] = [
|
| 140 |
+
f"Hinweis zum Stand: Der indizierte Text gibt die {version} wieder. "
|
| 141 |
+
"Zu den folgenden Vorschriften gibt es spätere Änderungsvereinbarungen, "
|
| 142 |
+
"die nicht im durchsuchten Text enthalten sind:"
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
for provision in provisions:
|
| 146 |
+
locator = provision.get("locator") or f"{provision.get('container')} {provision.get('section')}"
|
| 147 |
+
lines.append("")
|
| 148 |
+
lines.append(f"{locator} — {provision.get('status') or 'geändert'}:")
|
| 149 |
+
for change in provision.get("changes") or []:
|
| 150 |
+
amendment = change.get("amendment") or "Änderungsvereinbarung"
|
| 151 |
+
kind = change.get("kind") or "geändert"
|
| 152 |
+
lines.append(f" - {amendment} ({kind})")
|
| 153 |
+
verbatim = str(change.get("verbatim") or "").strip()
|
| 154 |
+
if verbatim:
|
| 155 |
+
lines.append(f" „{verbatim}“")
|
| 156 |
+
current = str(provision.get("current_rule") or "").strip()
|
| 157 |
+
if current:
|
| 158 |
+
lines.append(f" Aktuell gilt danach: {current}")
|
| 159 |
+
|
| 160 |
+
return "\n".join(lines)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def append_amendment_note(answer: str, note: str) -> str:
|
| 164 |
+
if not note:
|
| 165 |
+
return answer
|
| 166 |
+
text = (answer or "").rstrip()
|
| 167 |
+
return f"{text}\n\n{note}" if text else note
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
# Reconciling a negative answer with the amendment note
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
def _bridge_sentence(
|
| 175 |
+
provisions: Sequence[Dict[str, Any]],
|
| 176 |
+
*,
|
| 177 |
+
amendments: Dict[str, Any],
|
| 178 |
+
) -> str:
|
| 179 |
+
"""The denial, restated with the scope it actually has."""
|
| 180 |
+
version = amendments.get("indexed_version_label") or amendments.get("indexed_version") or ""
|
| 181 |
+
scope = f"In der {version}" if version else "In der indizierten Fassung"
|
| 182 |
+
|
| 183 |
+
locators: List[str] = []
|
| 184 |
+
for provision in provisions:
|
| 185 |
+
locator = provision.get("locator") or " ".join(
|
| 186 |
+
str(provision.get(key) or "") for key in ("container", "section")
|
| 187 |
+
).strip()
|
| 188 |
+
if locator and locator not in locators:
|
| 189 |
+
locators.append(locator)
|
| 190 |
+
|
| 191 |
+
lead = f"{scope} — dem hier durchsuchten Stand — ist das nicht geregelt."
|
| 192 |
+
if locators:
|
| 193 |
+
return (
|
| 194 |
+
f"{lead} Die Antwort ergibt sich erst aus späteren Änderungsvereinbarungen "
|
| 195 |
+
f"zu {', '.join(locators)}; ihr Inhalt steht unten unter „Hinweis zum Stand“."
|
| 196 |
+
)
|
| 197 |
+
return (
|
| 198 |
+
f"{lead} Die Antwort ergibt sich erst aus späteren Änderungsvereinbarungen; "
|
| 199 |
+
"ihr Inhalt steht unten unter „Hinweis zum Stand“."
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def reconcile_negative_answer(
|
| 204 |
+
answer: str,
|
| 205 |
+
provisions: Sequence[Dict[str, Any]],
|
| 206 |
+
*,
|
| 207 |
+
path: Path,
|
| 208 |
+
) -> str:
|
| 209 |
+
"""Scope the model's "nicht geregelt" to the indexed version.
|
| 210 |
+
|
| 211 |
+
Only runs when `provisions` is non-empty, i.e. when the amendment note that
|
| 212 |
+
follows will state a rule the answer just denied. A genuine "not regulated"
|
| 213 |
+
answer — no amendment in sight — is left exactly as the model wrote it.
|
| 214 |
+
|
| 215 |
+
The leading `Kurzantwort` keeps its heading and gets the scoped statement.
|
| 216 |
+
Sections that say nothing but "gibt es nicht" are dropped here too; the
|
| 217 |
+
composer already removes them on every answer, so in the running system this
|
| 218 |
+
is redundant — it keeps the function correct when called on its own.
|
| 219 |
+
"""
|
| 220 |
+
if not answer or not provisions:
|
| 221 |
+
return answer
|
| 222 |
+
|
| 223 |
+
amendments = load_amendments(path)
|
| 224 |
+
if not amendments:
|
| 225 |
+
return answer
|
| 226 |
+
|
| 227 |
+
bridge = _bridge_sentence(provisions, amendments=amendments)
|
| 228 |
+
blocks = split_sections(answer)
|
| 229 |
+
|
| 230 |
+
# No schema at all — e.g. the composer's canned "keine relevante Textstelle".
|
| 231 |
+
if not any(heading for heading, _ in blocks):
|
| 232 |
+
return bridge if is_denial(answer) else answer
|
| 233 |
+
|
| 234 |
+
parts: List[str] = []
|
| 235 |
+
replaced = False
|
| 236 |
+
dropped = False
|
| 237 |
+
|
| 238 |
+
for heading, body_lines in blocks:
|
| 239 |
+
body = "\n".join(body_lines).strip()
|
| 240 |
+
|
| 241 |
+
if heading is None:
|
| 242 |
+
if body:
|
| 243 |
+
parts.append(body)
|
| 244 |
+
continue
|
| 245 |
+
|
| 246 |
+
if not is_denial(body):
|
| 247 |
+
parts.append(f"{heading}:\n{body}" if body else f"{heading}:")
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
if heading == SHORT_ANSWER_HEADING:
|
| 251 |
+
parts.append(f"{heading}:\n{bridge}")
|
| 252 |
+
replaced = True
|
| 253 |
+
else:
|
| 254 |
+
dropped = True
|
| 255 |
+
|
| 256 |
+
if not replaced and not dropped:
|
| 257 |
+
return answer
|
| 258 |
+
if not replaced:
|
| 259 |
+
parts.insert(0, f"{SHORT_ANSWER_HEADING}:\n{bridge}")
|
| 260 |
+
|
| 261 |
+
return "\n\n".join(part for part in parts if part.strip())
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def describe(
|
| 265 |
+
sources: Sequence[Dict[str, Any]],
|
| 266 |
+
*,
|
| 267 |
+
path: Path,
|
| 268 |
+
question: str = "",
|
| 269 |
+
) -> tuple[str, List[Dict[str, Any]]]:
|
| 270 |
+
"""Convenience wrapper: (note, machine-readable provisions)."""
|
| 271 |
+
amendments = load_amendments(path)
|
| 272 |
+
if not amendments:
|
| 273 |
+
return "", []
|
| 274 |
+
provisions = affected_provisions(sources, amendments=amendments, question=question)
|
| 275 |
+
if not provisions:
|
| 276 |
+
return "", []
|
| 277 |
+
return amendment_note(provisions, amendments=amendments), provisions
|
src/corpus_boundary.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Make the edge of the indexed corpus visible in the answer.
|
| 2 |
+
|
| 3 |
+
The retrieved text routinely points at legal sources this assistant does not
|
| 4 |
+
index. `§ 31 SGB V` grants the claim to medicines "soweit diese nicht … durch
|
| 5 |
+
Richtlinien nach § 92 Absatz 1 Satz 2 Nummer 6 ausgeschlossen sind" — that
|
| 6 |
+
exclusion lives in the Arzneimittel-Richtlinie. A model that only sees the
|
| 7 |
+
statute will answer confidently and omit the exception.
|
| 8 |
+
|
| 9 |
+
Since the `amrl` corpus was added, that particular reference is covered and the
|
| 10 |
+
note disappears by itself (`covered_by`). What did *not* move into the index are
|
| 11 |
+
the AM-RL's Anlagen — the substance and product lists — and they are tracked as
|
| 12 |
+
their own entry. Splitting the two matters: a single entry would either vanish
|
| 13 |
+
with the directive text and imply the lists were checked, or keep firing on
|
| 14 |
+
answers that were in fact fully covered.
|
| 15 |
+
|
| 16 |
+
That is the same failure mode `corpus_router` guards against on the way in, one
|
| 17 |
+
step later: not the wrong corpus, but a *missing* one. The cheap, honest fix is
|
| 18 |
+
to detect the dangling reference in the retrieved context and say so, rather than
|
| 19 |
+
to let the answer imply completeness it does not have.
|
| 20 |
+
|
| 21 |
+
Detection runs on the retrieved chunks, not on the question. A user asking "Ist
|
| 22 |
+
Ibuprofen erstattungsfähig?" names no source at all — but the § 31 chunk that
|
| 23 |
+
answers it carries the reference to the AM-RL verbatim, which makes the context
|
| 24 |
+
the far more reliable signal.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import re
|
| 30 |
+
from dataclasses import dataclass
|
| 31 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class ExternalSource:
|
| 36 |
+
"""A legal source the retrieved text may point at."""
|
| 37 |
+
|
| 38 |
+
key: str
|
| 39 |
+
label: str
|
| 40 |
+
# What the user should understand is missing, in one clause.
|
| 41 |
+
scope: str
|
| 42 |
+
pattern: re.Pattern[str]
|
| 43 |
+
# Corpus id that would cover this source once indexed. None = no corpus of
|
| 44 |
+
# this project will ever cover it (then the note is permanent, not a TODO).
|
| 45 |
+
covered_by: Optional[str] = None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _rx(pattern: str) -> re.Pattern[str]:
|
| 49 |
+
return re.compile(pattern, re.I)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Ordered by how often the reference actually decides a pharmacy question.
|
| 53 |
+
EXTERNAL_SOURCES: tuple[ExternalSource, ...] = (
|
| 54 |
+
# The AM-RL is indexed as its directive text only. Its Anlagen are substance
|
| 55 |
+
# and product lists that the G-BA publishes as separate documents, and they
|
| 56 |
+
# stay out on purpose: the decisive answer about a list is frequently the
|
| 57 |
+
# negative one ("not listed, so it may be substituted"), and a vector search
|
| 58 |
+
# cannot establish absence — it always returns the k nearest chunks. So this
|
| 59 |
+
# gap is not a TODO, it is a property of the retrieval method, and it is the
|
| 60 |
+
# sharper of the two AM-RL entries. It is listed first for that reason.
|
| 61 |
+
#
|
| 62 |
+
# Roman numerals are what make the detection specific: the Rahmenvertrag and
|
| 63 |
+
# the Abrechnungsvereinbarung number their own Anlagen in arabic digits, so
|
| 64 |
+
# "Anlage VII" in retrieved text always means the AM-RL. Measured over the
|
| 65 |
+
# indexed corpora: 57 hits in amrl, 6 in rv129 (§ 9 Abs. 1 points at the
|
| 66 |
+
# "Substitutions-Ausschlussliste … (Arzneimittel-Richtlinie Anlage VII
|
| 67 |
+
# Teil B)"), 0 in sgb5 and amabrv.
|
| 68 |
+
ExternalSource(
|
| 69 |
+
key="am_rl_anlagen",
|
| 70 |
+
label="Anlagen der Arzneimittel-Richtlinie (Wirkstoff- und Produktlisten)",
|
| 71 |
+
# Was hier steht, muss den *verbliebenen* Rest benennen. Sieben Anlagen
|
| 72 |
+
# sind inzwischen als Lookup nachschlagbar (I, II, IIa, III, VII Teil A
|
| 73 |
+
# und B, VIIa) und werden vom Aufrufer aus dem Hinweis genommen, sobald
|
| 74 |
+
# ein Befund vorliegt; nennte der Text sie weiter als ungeprüft, meldete
|
| 75 |
+
# er eine Lücke, die die Antwort zwei Absätze darüber geschlossen hat.
|
| 76 |
+
# Anlage IIa stand hier, solange `amrl_lifestyle` den Weg über § 34
|
| 77 |
+
# Absatz 2 SGB V nur benennen konnte; seit `amrl_tabakentwoehnung` löst
|
| 78 |
+
# ein eigener Befund ihn ein.
|
| 79 |
+
#
|
| 80 |
+
# Die Aufzählung nennt nur die wirkstoff- und produktbezogenen Listen.
|
| 81 |
+
# Das Muster unten trifft auch die Anlagen VIII bis XII (Analogpräparate,
|
| 82 |
+
# Festbeträge, Nutzenbewertung) — die sind keine solchen Listen, und der
|
| 83 |
+
# Hinweis benennt sie deshalb sammelnd am Ende, statt einen Katalog zu
|
| 84 |
+
# führen, der bei jeder Novelle nachzuziehen wäre.
|
| 85 |
+
scope="die übrigen wirkstoff- und produktbezogenen Listen — Therapiehinweise "
|
| 86 |
+
"(Anlage IV), verordnungsfähige Medizinprodukte (Anlage V), Verbandmittel "
|
| 87 |
+
"(Anlage Va) und der Off-Label-Use (Anlage VI) — sowie die übrigen Anlagen "
|
| 88 |
+
"der Richtlinie",
|
| 89 |
+
pattern=_rx(
|
| 90 |
+
r"Anlage\s+(?:XII|XI|X|IX|VIII|VII|VI|V|IV|III|II|I)a?\b"
|
| 91 |
+
r"|Substitutions-?\s?Ausschlussliste"
|
| 92 |
+
r"|Substitutionsausschluss"
|
| 93 |
+
r"|OTC-?\s?(?:Übersicht|Ausnahmeliste)"
|
| 94 |
+
r"|§\s*129\s+Abs(?:atz|\.)?\s*1a\s+Satz\s*2"
|
| 95 |
+
),
|
| 96 |
+
),
|
| 97 |
+
# The directive text itself: covered once the `amrl` corpus is loaded. Kept
|
| 98 |
+
# as a separate entry rather than deleted, so a single-corpus deployment
|
| 99 |
+
# still discloses the gap.
|
| 100 |
+
ExternalSource(
|
| 101 |
+
key="am_rl",
|
| 102 |
+
label="Arzneimittel-Richtlinie (AM-RL)",
|
| 103 |
+
scope="Verordnungsfähigkeit, Verordnungsausschlüsse und die Regeln zur "
|
| 104 |
+
"Austauschbarkeit",
|
| 105 |
+
pattern=_rx(
|
| 106 |
+
r"§\s*92\s+Abs(?:atz|\.)?\s*1\s+Satz\s*2\s+Nummer\s*6"
|
| 107 |
+
r"|§\s*92\s+Abs\.\s*1\s+Satz\s*2\s+Nr\.\s*6"
|
| 108 |
+
r"|Arzneimittel-?\s?Richtlinie"
|
| 109 |
+
r"|Richtlinien?\s+nach\s+§\s*92"
|
| 110 |
+
),
|
| 111 |
+
covered_by="amrl",
|
| 112 |
+
),
|
| 113 |
+
ExternalSource(
|
| 114 |
+
key="ampreisv",
|
| 115 |
+
label="Arzneimittelpreisverordnung (AMPreisV)",
|
| 116 |
+
scope="Apothekenzuschläge und Preisbildung",
|
| 117 |
+
pattern=_rx(r"Arzneimittelpreisverordnung|AMPreisV|Arzneimittelpreisrecht"),
|
| 118 |
+
),
|
| 119 |
+
# Die Verschreibungspflicht selbst steht in keinem der vier Korpora. Der
|
| 120 |
+
# Rahmenvertrag setzt sie voraus und verweist auf die Formerfordernisse
|
| 121 |
+
# („wenn die Angaben den §§ 2 Absatz 1 Nummern 4 bis 6 und 7 AMVV … nicht
|
| 122 |
+
# vollständig entsprechen", § 6); § 48 AMG ordnet sie an. Ohne diese beiden
|
| 123 |
+
# Einträge beantwortete der Prototyp eine Frage nach der Verschreibungs-
|
| 124 |
+
# pflicht aus § 31 SGB V — einer Anspruchsnorm, die dazu nichts sagt — und
|
| 125 |
+
# nichts im Text hätte die Lücke benannt.
|
| 126 |
+
ExternalSource(
|
| 127 |
+
key="amvv",
|
| 128 |
+
label="Arzneimittelverschreibungsverordnung (AMVV)",
|
| 129 |
+
scope="die Verschreibungspflicht einzelner Stoffe und die Formerfordernisse "
|
| 130 |
+
"der Verschreibung",
|
| 131 |
+
pattern=_rx(r"Arzneimittelverschreibungsverordnung|\bAMVV\b"),
|
| 132 |
+
),
|
| 133 |
+
ExternalSource(
|
| 134 |
+
key="amg",
|
| 135 |
+
label="Arzneimittelgesetz (AMG)",
|
| 136 |
+
scope="Zulassung, Verschreibungspflicht nach § 48 und Verkehrsfähigkeit",
|
| 137 |
+
pattern=_rx(r"Arzneimittelgesetz(?:es)?\b|\bAMG\b"),
|
| 138 |
+
),
|
| 139 |
+
ExternalSource(
|
| 140 |
+
key="btmvv",
|
| 141 |
+
label="Betäubungsmittel-Verschreibungsverordnung (BtMVV)",
|
| 142 |
+
scope="Betäubungsmittelrezepte und deren Formerfordernisse",
|
| 143 |
+
pattern=_rx(r"Bet[äa]ubungsmittel-?Verschreibungsverordnung|BtMVV"),
|
| 144 |
+
),
|
| 145 |
+
ExternalSource(
|
| 146 |
+
key="apog",
|
| 147 |
+
label="Apothekengesetz (ApoG)",
|
| 148 |
+
scope="Betriebserlaubnis, Zuweisungsverbot und Versandhandel",
|
| 149 |
+
pattern=_rx(r"Apothekengesetz(?:es)?\b|\bApoG\b"),
|
| 150 |
+
),
|
| 151 |
+
# Der Rahmenvertrag nennt sie ohne Paragraphenzeichen — „die mit dem
|
| 152 |
+
# kleinsten Packungsgrößenkennzeichen gemäß der PackungsV in Vertrieb
|
| 153 |
+
# befindliche Packung" (§ 17). Für den Verweisparser ist das kein Verweis,
|
| 154 |
+
# für die Reichweite sehr wohl: welche Packung N1 ist, steht dort und
|
| 155 |
+
# nirgends im Bestand.
|
| 156 |
+
ExternalSource(
|
| 157 |
+
key="packungsv",
|
| 158 |
+
label="Packungsgrößenverordnung (PackungsV)",
|
| 159 |
+
scope="die Packungsgrößenkennzeichen N1, N2 und N3 und ihre Bestimmung",
|
| 160 |
+
pattern=_rx(r"Packungsgr[öo]ßenverordnung|\bPackungsV\b"),
|
| 161 |
+
),
|
| 162 |
+
ExternalSource(
|
| 163 |
+
key="apbetro",
|
| 164 |
+
label="Apothekenbetriebsordnung (ApBetrO)",
|
| 165 |
+
scope="Betriebspflichten der Apotheke",
|
| 166 |
+
pattern=_rx(r"Apothekenbetriebsordnung|ApBetrO"),
|
| 167 |
+
),
|
| 168 |
+
# Die Vereinbarung ist indiziert, ihre Technischen Anlagen sind es nicht —
|
| 169 |
+
# und dort steht, welches Sonderkennzeichen für welchen Sachverhalt gilt.
|
| 170 |
+
# Deshalb ein eigener Eintrag statt eines Zusatzes beim amabrv-Eintrag: der
|
| 171 |
+
# verschwindet mit dem geladenen Korpus, die Anlagen bleiben draußen.
|
| 172 |
+
# Dieselbe Trennung wie bei der AM-RL und ihren Anlagen.
|
| 173 |
+
ExternalSource(
|
| 174 |
+
key="amabrv_technische_anlagen",
|
| 175 |
+
label="Technische Anlagen zur Arzneimittelabrechnungsvereinbarung",
|
| 176 |
+
scope="die einzelnen Sonderkennzeichen, der Datensatzaufbau und die "
|
| 177 |
+
"Feldbelegungen",
|
| 178 |
+
pattern=_rx(r"Technische[nrs]?\s+Anlage\s*\d*"),
|
| 179 |
+
),
|
| 180 |
+
ExternalSource(
|
| 181 |
+
key="amabrv",
|
| 182 |
+
label="Arzneimittelabrechnungsvereinbarung (§ 300 Abs. 3 SGB V)",
|
| 183 |
+
scope="Abrechnungsverfahren, Sonderkennzeichen, Beanstandung und Fristen",
|
| 184 |
+
pattern=_rx(
|
| 185 |
+
r"§\s*300\s+Abs(?:atz|\.)?\s*3"
|
| 186 |
+
r"|Abrechnungsvereinbarung"
|
| 187 |
+
r"|Vereinbarung\s+nach\s+§\s*300"
|
| 188 |
+
),
|
| 189 |
+
covered_by="amabrv",
|
| 190 |
+
),
|
| 191 |
+
ExternalSource(
|
| 192 |
+
key="rahmenvertrag",
|
| 193 |
+
label="Rahmenvertrag nach § 129 Abs. 2 SGB V",
|
| 194 |
+
scope="Abgaberegeln der Apotheke",
|
| 195 |
+
pattern=_rx(r"Rahmenvertrag\s+nach\s+§\s*129|Rahmenvertrag(?:es)?\s+über\s+die\s+Arzneimittelversorgung"),
|
| 196 |
+
covered_by="rv129",
|
| 197 |
+
),
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _hit_text(hit: Dict[str, Any]) -> str:
|
| 202 |
+
metadata = hit.get("metadata") or {}
|
| 203 |
+
parts = [
|
| 204 |
+
hit.get("text"),
|
| 205 |
+
hit.get("document"),
|
| 206 |
+
metadata.get("text"),
|
| 207 |
+
]
|
| 208 |
+
return " ".join(str(p) for p in parts if p)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def detect_external_references(
|
| 212 |
+
hits: Sequence[Dict[str, Any]],
|
| 213 |
+
*,
|
| 214 |
+
available_corpora: Iterable[str] = (),
|
| 215 |
+
max_results: int = 3,
|
| 216 |
+
) -> List[Dict[str, str]]:
|
| 217 |
+
"""External sources the retrieved context leans on but the index lacks.
|
| 218 |
+
|
| 219 |
+
A source whose `covered_by` corpus is loaded is not reported: the assistant
|
| 220 |
+
can answer from it, so there is no gap to disclose.
|
| 221 |
+
"""
|
| 222 |
+
available = {str(c).lower() for c in available_corpora}
|
| 223 |
+
blob = " ".join(_hit_text(hit) for hit in hits or [])
|
| 224 |
+
if not blob.strip():
|
| 225 |
+
return []
|
| 226 |
+
|
| 227 |
+
found: List[Dict[str, str]] = []
|
| 228 |
+
for source in EXTERNAL_SOURCES:
|
| 229 |
+
if source.covered_by and source.covered_by.lower() in available:
|
| 230 |
+
continue
|
| 231 |
+
if source.pattern.search(blob):
|
| 232 |
+
found.append({"key": source.key, "label": source.label, "scope": source.scope})
|
| 233 |
+
if len(found) >= max_results:
|
| 234 |
+
break
|
| 235 |
+
return found
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def boundary_note(references: Sequence[Dict[str, str]]) -> str:
|
| 239 |
+
"""One short paragraph naming what the answer could not consider."""
|
| 240 |
+
if not references:
|
| 241 |
+
return ""
|
| 242 |
+
|
| 243 |
+
# Die Labels tragen keinen Artikel, damit sie auch in der Aufzählung unten
|
| 244 |
+
# passen; der Satzbau vermeidet ihn deshalb durch den Doppelpunkt.
|
| 245 |
+
if len(references) == 1:
|
| 246 |
+
ref = references[0]
|
| 247 |
+
return (
|
| 248 |
+
"Hinweis zur Reichweite: Die herangezogenen Textstellen verweisen auf ein "
|
| 249 |
+
f"Regelwerk außerhalb des durchsuchten Bestands — {ref['label']}. "
|
| 250 |
+
f"Damit sind {ref['scope']} hier nicht geprüft."
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
labels = "; ".join(f"{r['label']} ({r['scope']})" for r in references)
|
| 254 |
+
return (
|
| 255 |
+
"Hinweis zur Reichweite: Die herangezogenen Textstellen verweisen auf Regelwerke "
|
| 256 |
+
f"außerhalb des durchsuchten Bestands — {labels}. Diese sind hier nicht geprüft."
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def append_boundary_note(answer: str, references: Sequence[Dict[str, str]]) -> str:
|
| 261 |
+
"""Attach the note without disturbing the answer's own structure.
|
| 262 |
+
|
| 263 |
+
Appended rather than woven in: the composer's schema (Kurzantwort /
|
| 264 |
+
Maßgebliche Norm / Wortlaut / Einordnung) is validated elsewhere, and the
|
| 265 |
+
note is a statement about the index, not about the law.
|
| 266 |
+
"""
|
| 267 |
+
note = boundary_note(references)
|
| 268 |
+
if not note:
|
| 269 |
+
return answer
|
| 270 |
+
text = (answer or "").rstrip()
|
| 271 |
+
if not text:
|
| 272 |
+
return note
|
| 273 |
+
return f"{text}\n\n{note}"
|
src/corpus_registry.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Registry of the legal corpora the assistant can search.
|
| 2 |
+
|
| 3 |
+
Each corpus is one Chroma collection inside the shared persist directory and
|
| 4 |
+
gets its own `LegalRetriever`, because the retrieval defaults differ per document
|
| 5 |
+
type: the Rahmenvertrag has a main contract container that explicit § lookups
|
| 6 |
+
should fall back to, a statute does not.
|
| 7 |
+
|
| 8 |
+
Retrievers are built lazily. A cold start therefore still only pays for the
|
| 9 |
+
collections a request actually touches, and a corpus that is missing from the
|
| 10 |
+
persist directory degrades to a clear error on first use instead of breaking
|
| 11 |
+
application startup.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import os
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from threading import Lock
|
| 20 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 21 |
+
|
| 22 |
+
from retriever import LegalRetriever
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _env_flag(name: str, default: bool) -> bool:
|
| 28 |
+
raw = os.getenv(name)
|
| 29 |
+
if raw is None:
|
| 30 |
+
return default
|
| 31 |
+
return raw.strip().lower() in {"1", "true", "yes", "ja", "on"}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class CorpusSpec:
|
| 36 |
+
"""Static description of one searchable corpus."""
|
| 37 |
+
|
| 38 |
+
corpus_id: str
|
| 39 |
+
collection: str
|
| 40 |
+
# Container the retriever falls back to for explicit § lookups. Empty for a
|
| 41 |
+
# statute, whose §§ are spread across Kapitel containers.
|
| 42 |
+
default_container_id: str = ""
|
| 43 |
+
label: str = ""
|
| 44 |
+
# Lower-case keywords that name this corpus in a question. Used by routing.
|
| 45 |
+
aliases: tuple[str, ...] = ()
|
| 46 |
+
|
| 47 |
+
def with_metadata(self, metadata: Dict[str, Any]) -> "CorpusSpec":
|
| 48 |
+
"""Fill in the label from the collection metadata written at ingest time."""
|
| 49 |
+
if self.label:
|
| 50 |
+
return self
|
| 51 |
+
label = str(metadata.get("doc_title") or metadata.get("doc_id") or self.corpus_id)
|
| 52 |
+
return CorpusSpec(
|
| 53 |
+
corpus_id=self.corpus_id,
|
| 54 |
+
collection=self.collection,
|
| 55 |
+
default_container_id=self.default_container_id,
|
| 56 |
+
label=label,
|
| 57 |
+
aliases=self.aliases,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# Aliases for the corpora this assistant ships with. Unknown corpora still work;
|
| 62 |
+
# they are simply routed to by name only.
|
| 63 |
+
CORPUS_ALIASES: Dict[str, tuple[str, ...]] = {
|
| 64 |
+
"rv129": ("rahmenvertrag", "rahmenvertrages", "rahmenvertrag nach § 129", "rv129", "rv 129"),
|
| 65 |
+
"sgb5": ("sgb v", "sgb 5", "sgb5", "sozialgesetzbuch", "fünftes buch", "fuenftes buch"),
|
| 66 |
+
# Die Korpus-Id ist bewusst "amabrv", nicht "abrechnung": `mentioned_corpora`
|
| 67 |
+
# nimmt die Id selbst als Alias, und "Abrechnung" ist ein Alltagswort, das in
|
| 68 |
+
# Rahmenvertrag und SGB V laufend vorkommt — als Alias würde es jede zweite
|
| 69 |
+
# Frage auf dieses eine Korpus verengen, statt sie an alle drei zu geben.
|
| 70 |
+
# Hier stehen deshalb nur Formulierungen, die das Dokument wirklich benennen.
|
| 71 |
+
"amabrv": (
|
| 72 |
+
"arzneimittelabrechnungsvereinbarung",
|
| 73 |
+
"abrechnungsvereinbarung",
|
| 74 |
+
"vereinbarung nach § 300",
|
| 75 |
+
"vereinbarung nach § 300",
|
| 76 |
+
"§ 300 abs. 3",
|
| 77 |
+
"§ 300 absatz 3",
|
| 78 |
+
),
|
| 79 |
+
# Dieselbe Regel wie bei amabrv: Aliase müssen das Dokument benennen, nicht
|
| 80 |
+
# sein Thema. "arzneimittel" oder "richtlinie" allein stehen deshalb nicht
|
| 81 |
+
# hier — beide kommen in Rahmenvertrag und SGB V laufend vor und würden die
|
| 82 |
+
# Frage auf dieses eine Korpus verengen. Indiziert ist der Richtlinientext;
|
| 83 |
+
# die Anlagen (Wirkstofflisten) sind bewusst nicht enthalten, worauf
|
| 84 |
+
# `corpus_boundary` in der Antwort hinweist.
|
| 85 |
+
"amrl": (
|
| 86 |
+
"arzneimittel-richtlinie",
|
| 87 |
+
"arzneimittelrichtlinie",
|
| 88 |
+
"arzneimittel richtlinie",
|
| 89 |
+
"am-rl",
|
| 90 |
+
"am rl",
|
| 91 |
+
"richtlinie nach § 92",
|
| 92 |
+
"richtlinie nach § 92",
|
| 93 |
+
"§ 92 abs. 1 satz 2 nr. 6",
|
| 94 |
+
"§ 92 absatz 1 satz 2 nummer 6",
|
| 95 |
+
),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def specs_from_env() -> List[CorpusSpec]:
|
| 100 |
+
"""Read the corpus list from the environment.
|
| 101 |
+
|
| 102 |
+
`CHROMA_COLLECTIONS` is a comma-separated list; when it is absent the single
|
| 103 |
+
`CHROMA_COLLECTION` is used, so an existing deployment keeps working without
|
| 104 |
+
any configuration change.
|
| 105 |
+
|
| 106 |
+
`DEFAULT_CONTAINER_ID` applies to the first corpus only — it is the
|
| 107 |
+
Rahmenvertrag's "Vertrag" and would wrongly restrict a statute. Per-corpus
|
| 108 |
+
overrides use `CORPUS_CONTAINER__<corpus_id>`.
|
| 109 |
+
"""
|
| 110 |
+
raw = os.getenv("CHROMA_COLLECTIONS", "")
|
| 111 |
+
collections = [c.strip() for c in raw.split(",") if c.strip()]
|
| 112 |
+
if not collections:
|
| 113 |
+
collections = [os.getenv("CHROMA_COLLECTION", "rv129").strip()]
|
| 114 |
+
|
| 115 |
+
primary_container = os.getenv("DEFAULT_CONTAINER_ID", "Vertrag")
|
| 116 |
+
|
| 117 |
+
specs: List[CorpusSpec] = []
|
| 118 |
+
for index, collection in enumerate(collections):
|
| 119 |
+
corpus_id = collection
|
| 120 |
+
container = os.getenv(
|
| 121 |
+
f"CORPUS_CONTAINER__{corpus_id}",
|
| 122 |
+
primary_container if index == 0 else "",
|
| 123 |
+
)
|
| 124 |
+
specs.append(
|
| 125 |
+
CorpusSpec(
|
| 126 |
+
corpus_id=corpus_id,
|
| 127 |
+
collection=collection,
|
| 128 |
+
default_container_id=container.strip(),
|
| 129 |
+
aliases=CORPUS_ALIASES.get(corpus_id.lower(), ()),
|
| 130 |
+
)
|
| 131 |
+
)
|
| 132 |
+
return specs
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@dataclass
|
| 136 |
+
class CorpusRegistry:
|
| 137 |
+
"""Lazily built `LegalRetriever` per corpus."""
|
| 138 |
+
|
| 139 |
+
specs: List[CorpusSpec]
|
| 140 |
+
persist_dir: str
|
| 141 |
+
model_name: str = "auto"
|
| 142 |
+
enable_reranker: bool = True
|
| 143 |
+
reranker_model: str = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
|
| 144 |
+
reranker_candidates: int = 20
|
| 145 |
+
|
| 146 |
+
_retrievers: Dict[str, LegalRetriever] = field(default_factory=dict, init=False, repr=False)
|
| 147 |
+
_lock: Lock = field(default_factory=Lock, init=False, repr=False)
|
| 148 |
+
|
| 149 |
+
@classmethod
|
| 150 |
+
def from_env(cls, *, persist_dir: str, **overrides: Any) -> "CorpusRegistry":
|
| 151 |
+
return cls(specs=specs_from_env(), persist_dir=persist_dir, **overrides)
|
| 152 |
+
|
| 153 |
+
# ------------------------------------------------------------------
|
| 154 |
+
# Access
|
| 155 |
+
# ------------------------------------------------------------------
|
| 156 |
+
|
| 157 |
+
@property
|
| 158 |
+
def corpus_ids(self) -> List[str]:
|
| 159 |
+
return [spec.corpus_id for spec in self.specs]
|
| 160 |
+
|
| 161 |
+
@property
|
| 162 |
+
def primary_id(self) -> str:
|
| 163 |
+
return self.specs[0].corpus_id if self.specs else ""
|
| 164 |
+
|
| 165 |
+
def spec(self, corpus_id: str) -> CorpusSpec:
|
| 166 |
+
for spec in self.specs:
|
| 167 |
+
if spec.corpus_id == corpus_id:
|
| 168 |
+
return spec
|
| 169 |
+
raise KeyError(f"unknown corpus: {corpus_id!r} (known: {self.corpus_ids})")
|
| 170 |
+
|
| 171 |
+
def retriever(self, corpus_id: str) -> LegalRetriever:
|
| 172 |
+
existing = self._retrievers.get(corpus_id)
|
| 173 |
+
if existing is not None:
|
| 174 |
+
return existing
|
| 175 |
+
|
| 176 |
+
with self._lock:
|
| 177 |
+
if corpus_id in self._retrievers:
|
| 178 |
+
return self._retrievers[corpus_id]
|
| 179 |
+
|
| 180 |
+
spec = self.spec(corpus_id)
|
| 181 |
+
instance = self._build(spec)
|
| 182 |
+
self._retrievers[corpus_id] = instance
|
| 183 |
+
|
| 184 |
+
# Adopt the document title the ingestion wrote into the collection
|
| 185 |
+
# metadata, so labels never drift from the indexed corpus.
|
| 186 |
+
metadata = dict(getattr(instance.col, "metadata", None) or {})
|
| 187 |
+
enriched = spec.with_metadata(metadata)
|
| 188 |
+
if enriched is not spec:
|
| 189 |
+
self.specs = [enriched if s.corpus_id == corpus_id else s for s in self.specs]
|
| 190 |
+
|
| 191 |
+
return instance
|
| 192 |
+
|
| 193 |
+
def _build(self, spec: CorpusSpec) -> LegalRetriever:
|
| 194 |
+
kwargs: Dict[str, Any] = {
|
| 195 |
+
"persist_dir": self.persist_dir,
|
| 196 |
+
"collection": spec.collection,
|
| 197 |
+
"model_name": self.model_name,
|
| 198 |
+
"default_container_id": spec.default_container_id,
|
| 199 |
+
"enable_reranker": self.enable_reranker,
|
| 200 |
+
"reranker_model": self.reranker_model,
|
| 201 |
+
"reranker_candidates": self.reranker_candidates,
|
| 202 |
+
}
|
| 203 |
+
try:
|
| 204 |
+
return LegalRetriever(**kwargs)
|
| 205 |
+
except TypeError:
|
| 206 |
+
# Older retriever signature.
|
| 207 |
+
return LegalRetriever(
|
| 208 |
+
persist_dir=self.persist_dir,
|
| 209 |
+
collection=spec.collection,
|
| 210 |
+
model_name=self.model_name,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
def available(self) -> List[str]:
|
| 214 |
+
"""Corpus ids whose collection can actually be opened."""
|
| 215 |
+
out: List[str] = []
|
| 216 |
+
for corpus_id in self.corpus_ids:
|
| 217 |
+
try:
|
| 218 |
+
self.retriever(corpus_id)
|
| 219 |
+
out.append(corpus_id)
|
| 220 |
+
except Exception as exc: # noqa: BLE001 - a missing corpus must not break the rest.
|
| 221 |
+
logger.warning("corpus unavailable: %s (%s)", corpus_id, exc)
|
| 222 |
+
return out
|
| 223 |
+
|
| 224 |
+
def diagnostics(self) -> Dict[str, Any]:
|
| 225 |
+
corpora: List[Dict[str, Any]] = []
|
| 226 |
+
for spec in self.specs:
|
| 227 |
+
entry: Dict[str, Any] = {
|
| 228 |
+
"corpus_id": spec.corpus_id,
|
| 229 |
+
"collection": spec.collection,
|
| 230 |
+
"default_container_id": spec.default_container_id,
|
| 231 |
+
"label": spec.label,
|
| 232 |
+
}
|
| 233 |
+
try:
|
| 234 |
+
instance = self.retriever(spec.corpus_id)
|
| 235 |
+
entry["count"] = instance.col.count()
|
| 236 |
+
entry["ok"] = True
|
| 237 |
+
metadata = dict(getattr(instance.col, "metadata", None) or {})
|
| 238 |
+
entry["embedding_model"] = metadata.get("embedding_model")
|
| 239 |
+
entry["doc_title"] = metadata.get("doc_title")
|
| 240 |
+
except Exception as exc: # noqa: BLE001
|
| 241 |
+
entry["ok"] = False
|
| 242 |
+
entry["error"] = f"{type(exc).__name__}: {exc}"
|
| 243 |
+
corpora.append(entry)
|
| 244 |
+
return {"persist_dir": self.persist_dir, "corpora": corpora}
|
| 245 |
+
|
| 246 |
+
def total_count(self) -> int:
|
| 247 |
+
total = 0
|
| 248 |
+
for corpus_id in self.corpus_ids:
|
| 249 |
+
try:
|
| 250 |
+
total += int(self.retriever(corpus_id).col.count())
|
| 251 |
+
except Exception: # noqa: BLE001
|
| 252 |
+
continue
|
| 253 |
+
return total
|
| 254 |
+
|
| 255 |
+
def assert_consistent_embedding(self) -> Optional[str]:
|
| 256 |
+
"""Return an error message if the corpora were not indexed alike.
|
| 257 |
+
|
| 258 |
+
Fusing results across corpora only makes sense when their scores are
|
| 259 |
+
comparable, which requires one embedding model and one distance metric.
|
| 260 |
+
Mixing them would silently rank one corpus above the other.
|
| 261 |
+
"""
|
| 262 |
+
models: Dict[str, set] = {"embedding_model": set(), "embedding_dim": set()}
|
| 263 |
+
for corpus_id in self.corpus_ids:
|
| 264 |
+
try:
|
| 265 |
+
metadata = dict(getattr(self.retriever(corpus_id).col, "metadata", None) or {})
|
| 266 |
+
except Exception: # noqa: BLE001
|
| 267 |
+
continue
|
| 268 |
+
for key in models:
|
| 269 |
+
value = metadata.get(key)
|
| 270 |
+
if value is not None:
|
| 271 |
+
models[key].add(value)
|
| 272 |
+
|
| 273 |
+
mismatched = {key: sorted(map(str, values)) for key, values in models.items() if len(values) > 1}
|
| 274 |
+
if mismatched:
|
| 275 |
+
return f"corpora were indexed with different embeddings: {mismatched}"
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def build_registry(
|
| 280 |
+
*,
|
| 281 |
+
persist_dir: str,
|
| 282 |
+
model_name: str,
|
| 283 |
+
enable_reranker: bool,
|
| 284 |
+
reranker_model: str,
|
| 285 |
+
reranker_candidates: int,
|
| 286 |
+
specs: Optional[Sequence[CorpusSpec]] = None,
|
| 287 |
+
) -> CorpusRegistry:
|
| 288 |
+
return CorpusRegistry(
|
| 289 |
+
specs=list(specs) if specs is not None else specs_from_env(),
|
| 290 |
+
persist_dir=persist_dir,
|
| 291 |
+
model_name=model_name,
|
| 292 |
+
enable_reranker=enable_reranker,
|
| 293 |
+
reranker_model=reranker_model,
|
| 294 |
+
reranker_candidates=reranker_candidates,
|
| 295 |
+
)
|
src/corpus_router.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Decide which corpora a question needs to reach.
|
| 2 |
+
|
| 3 |
+
Deliberately rule-based rather than model-based. Routing here is a cheap,
|
| 4 |
+
verifiable decision on a handful of surface signals, and an LLM call per question
|
| 5 |
+
would add latency and non-determinism to a step a substring match answers
|
| 6 |
+
exactly.
|
| 7 |
+
|
| 8 |
+
The policy is asymmetric on purpose:
|
| 9 |
+
|
| 10 |
+
* Narrowing to a subset requires the question to **name** a corpus —
|
| 11 |
+
"nach dem Rahmenvertrag", "§ 31 SGB V".
|
| 12 |
+
* Everything else queries all corpora and lets score fusion decide.
|
| 13 |
+
|
| 14 |
+
That asymmetry follows from the cost of being wrong. One corpus too many costs a
|
| 15 |
+
few hundred milliseconds; one corpus too few means the assistant answers
|
| 16 |
+
confidently from the wrong statute, which is the failure mode this pipeline
|
| 17 |
+
exists to prevent. A bare "§ 16" is therefore *not* narrowed even though the
|
| 18 |
+
Rahmenvertrag is the likelier intent — both documents have a § 16, and they say
|
| 19 |
+
entirely different things.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from typing import Any, Dict, List, Sequence
|
| 25 |
+
|
| 26 |
+
from corpus_registry import CORPUS_ALIASES
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _normalize(text: str) -> str:
|
| 30 |
+
return " ".join(str(text or "").lower().split())
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def mentioned_corpora(question: str, corpus_ids: Sequence[str]) -> List[str]:
|
| 34 |
+
"""Corpora named in the question, in registry order.
|
| 35 |
+
|
| 36 |
+
A statute reference such as "§ 31 SGB V" is covered by this same check: the
|
| 37 |
+
statute name is itself an alias, so no separate norm-reference parsing is
|
| 38 |
+
needed here. Parsing the § itself is the retriever's job.
|
| 39 |
+
"""
|
| 40 |
+
haystack = _normalize(question)
|
| 41 |
+
hits: List[str] = []
|
| 42 |
+
for corpus_id in corpus_ids:
|
| 43 |
+
candidates = (*CORPUS_ALIASES.get(corpus_id.lower(), ()), corpus_id.lower())
|
| 44 |
+
if any(alias and alias in haystack for alias in candidates):
|
| 45 |
+
hits.append(corpus_id)
|
| 46 |
+
return hits
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def route_question(question: str, corpus_ids: Sequence[str]) -> List[str]:
|
| 50 |
+
"""Return the corpora to query. Falls back to all of them without a signal."""
|
| 51 |
+
available = list(corpus_ids)
|
| 52 |
+
if len(available) <= 1:
|
| 53 |
+
return available
|
| 54 |
+
return mentioned_corpora(question, available) or available
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def explain_routing(question: str, corpus_ids: Sequence[str]) -> Dict[str, Any]:
|
| 58 |
+
"""Routing decision with its reason, for /debug/routing and tests."""
|
| 59 |
+
available = list(corpus_ids)
|
| 60 |
+
named = mentioned_corpora(question, available)
|
| 61 |
+
selected = route_question(question, available)
|
| 62 |
+
return {
|
| 63 |
+
"question": question,
|
| 64 |
+
"available": available,
|
| 65 |
+
"named_corpora": named,
|
| 66 |
+
"selected": selected,
|
| 67 |
+
"reason": "explicit_mention" if named and len(available) > 1 else "no_signal_query_all",
|
| 68 |
+
}
|
src/federated_retriever.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Search several legal corpora behind one retriever interface.
|
| 2 |
+
|
| 3 |
+
The design constraint that shaped this module: `LegalAnswerOrchestrator` and
|
| 4 |
+
`AnswerComposer` already contain the citation-verification logic this assistant
|
| 5 |
+
depends on, and they talk to exactly one object with a `query()` method. So the
|
| 6 |
+
federation preserves that method's signature and returns the same hit
|
| 7 |
+
dictionaries. Nothing upstream has to know that there is more than one corpus.
|
| 8 |
+
|
| 9 |
+
Score fusion is a plain merge by rank_score, which is only defensible because
|
| 10 |
+
every corpus is indexed with the same embedding model and distance metric, and
|
| 11 |
+
the optional cross-encoder scores the query against candidates from all corpora
|
| 12 |
+
with the same model. `CorpusRegistry.assert_consistent_embedding()` guards that
|
| 13 |
+
precondition.
|
| 14 |
+
|
| 15 |
+
Fusion by score alone turned out not to be enough, and the counterexample is
|
| 16 |
+
worth keeping: a question about Sonderkennzeichen never reached the
|
| 17 |
+
Arzneimittelabrechnungsvereinbarung, although the rule is in it and the corpus
|
| 18 |
+
was indexed. With 106 chunks against the SGB V's 6.670, its best hit simply lost
|
| 19 |
+
the similarity contest — a structural defeat that has nothing to do with
|
| 20 |
+
relevance. Two mechanisms answer that, and both are supplied from outside so
|
| 21 |
+
this module stays a search layer and does not become a legal register:
|
| 22 |
+
|
| 23 |
+
* a **per-corpus quota** that survives the global cut, so a small corpus cannot
|
| 24 |
+
be crowded out entirely, and
|
| 25 |
+
* **mandatory fetches** (`anchors`), addresses of norms that are retrieved by
|
| 26 |
+
metadata regardless of how they embed.
|
| 27 |
+
|
| 28 |
+
A third mechanism follows the same shape and closes a different gap. Anchors
|
| 29 |
+
know the chain for a *question type*; they are curated and therefore finite.
|
| 30 |
+
But a legal text names its own chain: § 10 of the Rahmenvertrag says the
|
| 31 |
+
selection is to be made "nach Maßgabe der §§ 11 bis 14", and § 11 selects "aus
|
| 32 |
+
dem Auswahlbereich nach § 9". Those references were read by nobody — the
|
| 33 |
+
retriever parses § references out of the *question* only. `verweise` supplies
|
| 34 |
+
the second pass: the references contained in the hits that were already found,
|
| 35 |
+
resolved to addresses and fetched with the same `get_units`. The provider is
|
| 36 |
+
injected for the same reason as the anchors: which name means which corpus is a
|
| 37 |
+
statement about the corpora, not about searching.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
import inspect
|
| 43 |
+
import logging
|
| 44 |
+
from collections import OrderedDict
|
| 45 |
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
| 46 |
+
|
| 47 |
+
from corpus_registry import CorpusRegistry
|
| 48 |
+
|
| 49 |
+
logger = logging.getLogger(__name__)
|
| 50 |
+
|
| 51 |
+
# A router decides which corpora a question should reach.
|
| 52 |
+
Router = Callable[[str, Sequence[str]], List[str]]
|
| 53 |
+
|
| 54 |
+
# An anchor provider names norms that must be in the context whatever the
|
| 55 |
+
# ranking says. It returns plain dicts — corpus_id, container_id, section_id,
|
| 56 |
+
# subsection, max_chunks — so that the curated register stays a stranger here.
|
| 57 |
+
Anchors = Callable[[str], Sequence[Dict[str, Any]]]
|
| 58 |
+
|
| 59 |
+
# A reference provider reads the hits that were found and returns the addresses
|
| 60 |
+
# they point at — same plain dicts as the anchors, so the fetch below does not
|
| 61 |
+
# have to know which of the two produced a target.
|
| 62 |
+
Verweise = Callable[[Sequence[Dict[str, Any]], Sequence[str]], Sequence[Dict[str, Any]]]
|
| 63 |
+
|
| 64 |
+
# Referenced norms are fetched, not ranked, so they need a score — and the
|
| 65 |
+
# first value chosen was wrong in a way worth recording.
|
| 66 |
+
#
|
| 67 |
+
# 0.97 was the reasoning: below the anchors' 1.0, above a merely similar
|
| 68 |
+
# passage. Measured on 19.08.2026 over five chain cases it cost ten points of
|
| 69 |
+
# chain coverage (85% -> 75%). The budget is fixed, so a referenced norm that
|
| 70 |
+
# ranks high does not *add* to the context, it *replaces* something — and what
|
| 71 |
+
# it replaced was § 6 Rahmenvertrag, itself a member of the chain being
|
| 72 |
+
# measured. A norm the text merely points at is not worth more than a norm the
|
| 73 |
+
# ranking actually found.
|
| 74 |
+
#
|
| 75 |
+
# 0.50 puts them in the tail: they fill slots that would otherwise go to the
|
| 76 |
+
# weakest semantic hits, and they yield to everything above.
|
| 77 |
+
VERWEIS_SCORE = 0.50
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def route_to_all(question: str, corpus_ids: Sequence[str]) -> List[str]:
|
| 81 |
+
"""Default policy: ask every corpus and let fusion sort it out."""
|
| 82 |
+
return list(corpus_ids)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _accepted_kwargs(func: Any, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
| 86 |
+
"""Drop kwargs the callable does not declare, keeping the rest intact."""
|
| 87 |
+
try:
|
| 88 |
+
params = inspect.signature(func).parameters
|
| 89 |
+
except (TypeError, ValueError): # builtins / C-implemented callables
|
| 90 |
+
return dict(kwargs)
|
| 91 |
+
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
| 92 |
+
return dict(kwargs)
|
| 93 |
+
return {key: value for key, value in kwargs.items() if key in params}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class FederatedRetriever:
|
| 97 |
+
"""Drop-in replacement for `LegalRetriever` that spans several corpora."""
|
| 98 |
+
|
| 99 |
+
def __init__(
|
| 100 |
+
self,
|
| 101 |
+
registry: CorpusRegistry,
|
| 102 |
+
*,
|
| 103 |
+
router: Optional[Router] = None,
|
| 104 |
+
max_corpora_per_query: int = 4,
|
| 105 |
+
anchors: Optional[Anchors] = None,
|
| 106 |
+
min_hits_per_corpus: int = 2,
|
| 107 |
+
verweise: Optional[Verweise] = None,
|
| 108 |
+
max_verweis_normen: int = 3,
|
| 109 |
+
named_corpora: Optional[Router] = None,
|
| 110 |
+
) -> None:
|
| 111 |
+
self.registry = registry
|
| 112 |
+
self.router = router or route_to_all
|
| 113 |
+
self.max_corpora_per_query = max_corpora_per_query
|
| 114 |
+
self.anchors = anchors
|
| 115 |
+
self.min_hits_per_corpus = min_hits_per_corpus
|
| 116 |
+
self.verweise = verweise
|
| 117 |
+
self.max_verweis_normen = max_verweis_normen
|
| 118 |
+
self.named_corpora = named_corpora
|
| 119 |
+
|
| 120 |
+
warning = registry.assert_consistent_embedding()
|
| 121 |
+
if warning:
|
| 122 |
+
# Not fatal: a single-corpus deployment is unaffected, and refusing to
|
| 123 |
+
# start would take the whole assistant down for a ranking problem.
|
| 124 |
+
logger.warning("federated retrieval may rank badly: %s", warning)
|
| 125 |
+
|
| 126 |
+
# ------------------------------------------------------------------
|
| 127 |
+
# Compatibility surface
|
| 128 |
+
# ------------------------------------------------------------------
|
| 129 |
+
|
| 130 |
+
@property
|
| 131 |
+
def col(self) -> Any:
|
| 132 |
+
"""Chroma collection of the primary corpus.
|
| 133 |
+
|
| 134 |
+
Kept so legacy diagnostics that reach for `.col` keep working; corpus-aware
|
| 135 |
+
callers should use `registry.diagnostics()` instead.
|
| 136 |
+
"""
|
| 137 |
+
return self.registry.retriever(self.registry.primary_id).col
|
| 138 |
+
|
| 139 |
+
@property
|
| 140 |
+
def collection_name(self) -> str:
|
| 141 |
+
return self.registry.spec(self.registry.primary_id).collection
|
| 142 |
+
|
| 143 |
+
@property
|
| 144 |
+
def persist_dir(self) -> str:
|
| 145 |
+
return self.registry.persist_dir
|
| 146 |
+
|
| 147 |
+
def diagnostics(self, *, sample: int = 3) -> Dict[str, Any]:
|
| 148 |
+
out = self.registry.diagnostics()
|
| 149 |
+
out["federated"] = True
|
| 150 |
+
out["router"] = getattr(self.router, "__name__", type(self.router).__name__)
|
| 151 |
+
out["min_hits_per_corpus"] = self.min_hits_per_corpus
|
| 152 |
+
out["norm_anchors"] = self.anchors is not None
|
| 153 |
+
return out
|
| 154 |
+
|
| 155 |
+
def explain_selection(self, question: str) -> Dict[str, Any]:
|
| 156 |
+
"""Why these corpora — for /debug/routing and for tests.
|
| 157 |
+
|
| 158 |
+
Kept separate from `corpus_router.explain_routing` because the router
|
| 159 |
+
only sees the question's wording. Whether a corpus is queried on top of
|
| 160 |
+
that is this layer's decision, and a debug endpoint that showed only half
|
| 161 |
+
of it would be misleading in exactly the case worth debugging.
|
| 162 |
+
"""
|
| 163 |
+
available = self.registry.available()
|
| 164 |
+
named = [c for c in self.router(question or "", available) if c in available]
|
| 165 |
+
required = [c for c in self.required_corpora(question) if c in available]
|
| 166 |
+
return {
|
| 167 |
+
"available": available,
|
| 168 |
+
"routed": named,
|
| 169 |
+
"required": required,
|
| 170 |
+
"selected": self.select_corpora(question),
|
| 171 |
+
"anchor_targets": self.anchor_targets(question),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
# ------------------------------------------------------------------
|
| 175 |
+
# Retrieval
|
| 176 |
+
# ------------------------------------------------------------------
|
| 177 |
+
|
| 178 |
+
def anchor_targets(self, question: str) -> List[Dict[str, Any]]:
|
| 179 |
+
"""Norms that have to be in the context, whatever the ranking says."""
|
| 180 |
+
if self.anchors is None:
|
| 181 |
+
return []
|
| 182 |
+
try:
|
| 183 |
+
return [dict(target) for target in self.anchors(question or "") or []]
|
| 184 |
+
except Exception as exc: # noqa: BLE001 - a register must never break retrieval.
|
| 185 |
+
logger.warning("norm anchors skipped (%s)", exc)
|
| 186 |
+
return []
|
| 187 |
+
|
| 188 |
+
def required_corpora(self, question: str) -> List[str]:
|
| 189 |
+
"""Corpora an anchored question must reach even without naming them.
|
| 190 |
+
|
| 191 |
+
This is the half of the Sonderkennzeichen fix that routing owns: the word
|
| 192 |
+
names no corpus, so `route_question` fans out to all of them and the
|
| 193 |
+
smallest one is then outranked. Naming it here makes the query
|
| 194 |
+
unconditional; the quota below makes sure its hits survive the cut.
|
| 195 |
+
"""
|
| 196 |
+
out: List[str] = []
|
| 197 |
+
for target in self.anchor_targets(question):
|
| 198 |
+
corpus_id = str(target.get("corpus_id") or "")
|
| 199 |
+
if corpus_id and corpus_id not in out:
|
| 200 |
+
out.append(corpus_id)
|
| 201 |
+
return out
|
| 202 |
+
|
| 203 |
+
def select_corpora(self, question: str) -> List[str]:
|
| 204 |
+
available = self.registry.available()
|
| 205 |
+
if not available:
|
| 206 |
+
return []
|
| 207 |
+
selected = [c for c in self.router(question or "", available) if c in available]
|
| 208 |
+
if not selected:
|
| 209 |
+
selected = available
|
| 210 |
+
|
| 211 |
+
# Required corpora go first so the cap below can never drop one, and the
|
| 212 |
+
# cap yields to them outright rather than silently discarding a norm the
|
| 213 |
+
# register calls decisive.
|
| 214 |
+
required = [c for c in self.required_corpora(question) if c in available]
|
| 215 |
+
ordered = required + [c for c in selected if c not in required]
|
| 216 |
+
return ordered[: max(self.max_corpora_per_query, len(required))]
|
| 217 |
+
|
| 218 |
+
def query(self, question: str = "", **kwargs: Any) -> List[Dict[str, Any]]:
|
| 219 |
+
# The orchestrator sometimes passes the question positionally, sometimes
|
| 220 |
+
# as a keyword; accept both like LegalRetriever does.
|
| 221 |
+
question = question or str(kwargs.pop("question", "") or "")
|
| 222 |
+
corpora = self.select_corpora(question)
|
| 223 |
+
if not corpora:
|
| 224 |
+
return []
|
| 225 |
+
|
| 226 |
+
max_final_results = kwargs.get("max_final_results")
|
| 227 |
+
top_k = kwargs.get("top_k")
|
| 228 |
+
|
| 229 |
+
merged: List[Dict[str, Any]] = []
|
| 230 |
+
for corpus_id in corpora:
|
| 231 |
+
try:
|
| 232 |
+
hits = self._query_one(corpus_id, question, kwargs)
|
| 233 |
+
except Exception as exc: # noqa: BLE001 - one broken corpus must not kill the answer.
|
| 234 |
+
# Der Typ gehört in die Meldung: ohne ihn steht dort eine
|
| 235 |
+
# Fehlerzeile, die nicht sagt, ob das Korpus fehlte, der
|
| 236 |
+
# Speicher ausging oder die Datenbank gesperrt war — und genau
|
| 237 |
+
# das ist die Frage, wenn diese Zeile einmal erscheint.
|
| 238 |
+
logger.warning(
|
| 239 |
+
"corpus query failed: %s (%s: %s)", corpus_id, exc.__class__.__name__, exc
|
| 240 |
+
)
|
| 241 |
+
continue
|
| 242 |
+
merged.extend(hits)
|
| 243 |
+
|
| 244 |
+
merged.extend(self._fetch_anchors(question, corpora))
|
| 245 |
+
|
| 246 |
+
merged = _fuse(merged)
|
| 247 |
+
|
| 248 |
+
# Erst nach der Fusion: die Verweise werden aus den *besten* Treffern
|
| 249 |
+
# gelesen, und welche das sind, steht vor der Fusion noch nicht fest.
|
| 250 |
+
nachgeladen = self._fetch_referenced_norms(merged, corpora)
|
| 251 |
+
if nachgeladen:
|
| 252 |
+
merged = _fuse(merged + nachgeladen)
|
| 253 |
+
|
| 254 |
+
limit = max_final_results or top_k
|
| 255 |
+
ergebnis = _apply_corpus_quota(merged, limit=limit, min_per_corpus=self.min_hits_per_corpus)
|
| 256 |
+
return self._benanntes_korpus_zuerst(ergebnis, question, corpora)
|
| 257 |
+
|
| 258 |
+
def _fetch_anchors(self, question: str, corpora: Sequence[str]) -> List[Dict[str, Any]]:
|
| 259 |
+
"""Retrieve the anchored norms by metadata instead of by similarity.
|
| 260 |
+
|
| 261 |
+
These hits are scored 1.0, the same as any explicit section lookup the
|
| 262 |
+
retriever performs on its own — the register's claim is that they decide
|
| 263 |
+
the question, so ranking them below a merely similar passage would defeat
|
| 264 |
+
the purpose. They stay cheap because the address carries the Absatz: the
|
| 265 |
+
parent chunk of one Absatz is usually the whole norm.
|
| 266 |
+
"""
|
| 267 |
+
out: List[Dict[str, Any]] = []
|
| 268 |
+
for target in self.anchor_targets(question):
|
| 269 |
+
corpus_id = str(target.get("corpus_id") or "")
|
| 270 |
+
if corpus_id not in corpora:
|
| 271 |
+
continue
|
| 272 |
+
retriever = self.registry.retriever(corpus_id)
|
| 273 |
+
fetch = getattr(retriever, "get_units", None)
|
| 274 |
+
if fetch is None:
|
| 275 |
+
# An older retriever without the targeted lookup. Skipping is the
|
| 276 |
+
# honest outcome: the rest of the answer is unaffected.
|
| 277 |
+
continue
|
| 278 |
+
try:
|
| 279 |
+
hits = fetch(
|
| 280 |
+
target.get("section_id") or "",
|
| 281 |
+
container=target.get("container_id") or None,
|
| 282 |
+
subsection=target.get("subsection") or None,
|
| 283 |
+
max_chunks=int(target.get("max_chunks") or 2),
|
| 284 |
+
)
|
| 285 |
+
except Exception as exc: # noqa: BLE001 - see above.
|
| 286 |
+
logger.warning(
|
| 287 |
+
"norm anchor lookup failed: %s %s (%s: %s)",
|
| 288 |
+
corpus_id,
|
| 289 |
+
target,
|
| 290 |
+
exc.__class__.__name__,
|
| 291 |
+
exc,
|
| 292 |
+
)
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
if not hits:
|
| 296 |
+
logger.warning(
|
| 297 |
+
"norm anchor matched nothing: %s %s %s",
|
| 298 |
+
corpus_id,
|
| 299 |
+
target.get("container_id"),
|
| 300 |
+
target.get("section_id"),
|
| 301 |
+
)
|
| 302 |
+
for hit in hits:
|
| 303 |
+
hit["corpus_id"] = corpus_id
|
| 304 |
+
metadata = hit.get("metadata")
|
| 305 |
+
if isinstance(metadata, dict):
|
| 306 |
+
metadata.setdefault("corpus_id", corpus_id)
|
| 307 |
+
# "section_lookup" keeps the composer's existing preference for
|
| 308 |
+
# explicit lookups working; "norm_anchor" is what monitoring reads.
|
| 309 |
+
hit["retrieval_kinds"] = sorted(
|
| 310 |
+
{*(hit.get("retrieval_kinds") or []), "norm_anchor", "section_lookup"}
|
| 311 |
+
)
|
| 312 |
+
out.append(hit)
|
| 313 |
+
return out
|
| 314 |
+
|
| 315 |
+
def _benanntes_korpus_zuerst(
|
| 316 |
+
self, hits: List[Dict[str, Any]], question: str, corpora: Sequence[str]
|
| 317 |
+
) -> List[Dict[str, Any]]:
|
| 318 |
+
"""Nennt die Frage ein Regelwerk, führt dessen bester Treffer.
|
| 319 |
+
|
| 320 |
+
Der Pflichtabruf holt seine Normen mit Score 1.0 und steht damit vorn —
|
| 321 |
+
richtig, solange die Frage kein Regelwerk nennt. Nennt sie eines, ist es
|
| 322 |
+
falsch: „Was regelt **die Arzneimittel-Richtlinie** zur Austauschbarkeit
|
| 323 |
+
von Darreichungsformen?" bekam § 9 des Rahmenvertrags auf Rang 1, weil
|
| 324 |
+
der Anker `darreichungsform_austausch` ihn als tragende Norm führt. Die
|
| 325 |
+
Auskunft ist nicht falsch, die Reihenfolge schon: gefragt war, was in
|
| 326 |
+
der Richtlinie steht.
|
| 327 |
+
|
| 328 |
+
Nur die **Reihenfolge** ändert sich, nichts fällt weg — der Anker bleibt
|
| 329 |
+
im Kontext, er führt ihn nur nicht mehr an. Der Score des vorgezogenen
|
| 330 |
+
Treffers wird auf den bisherigen Höchstwert gehoben, damit eine spätere
|
| 331 |
+
Sortierung nach `rank_score` die Entscheidung nicht wieder umdreht.
|
| 332 |
+
"""
|
| 333 |
+
if self.named_corpora is None or len(hits) < 2:
|
| 334 |
+
return hits
|
| 335 |
+
|
| 336 |
+
try:
|
| 337 |
+
benannt = [c for c in self.named_corpora(question or "", corpora) if c in corpora]
|
| 338 |
+
except Exception as exc: # noqa: BLE001 - eine Reihung darf nie die Suche kippen.
|
| 339 |
+
logger.warning("named-corpus ordering skipped (%s: %s)", exc.__class__.__name__, exc)
|
| 340 |
+
return hits
|
| 341 |
+
|
| 342 |
+
# Ohne Nennung oder wenn die Frage alle abgefragten Korpora nennt, gibt
|
| 343 |
+
# es nichts zu entscheiden.
|
| 344 |
+
if not benannt or len(benannt) >= len(corpora):
|
| 345 |
+
return hits
|
| 346 |
+
if str(hits[0].get("corpus_id") or "") in benannt:
|
| 347 |
+
return hits
|
| 348 |
+
|
| 349 |
+
for i, hit in enumerate(hits):
|
| 350 |
+
if str(hit.get("corpus_id") or "") in benannt:
|
| 351 |
+
hoechstwert = max(_rank(h) for h in hits)
|
| 352 |
+
hit["rank_score"] = max(_rank(hit), hoechstwert)
|
| 353 |
+
return [hit, *hits[:i], *hits[i + 1 :]]
|
| 354 |
+
|
| 355 |
+
# Das benannte Korpus hat nichts geliefert. Das ist eine Aussage über
|
| 356 |
+
# das Korpus und keine über die Reihenfolge — hier bleibt alles stehen.
|
| 357 |
+
return hits
|
| 358 |
+
|
| 359 |
+
def _fetch_referenced_norms(
|
| 360 |
+
self, hits: Sequence[Dict[str, Any]], corpora: Sequence[str]
|
| 361 |
+
) -> List[Dict[str, Any]]:
|
| 362 |
+
"""Die Normen nachladen, auf die die gefundenen Texte selbst verweisen.
|
| 363 |
+
|
| 364 |
+
Dieselbe Mechanik wie `_fetch_anchors`, aber die Adressen kommen nicht
|
| 365 |
+
aus einem Register, sondern aus dem Text. Der Unterschied steht im
|
| 366 |
+
Score: ein kuratierter Anker behauptet, *diese* Norm entscheide die
|
| 367 |
+
Frage; ein Verweis behauptet nur, der gefundene Text nehme sie in Bezug.
|
| 368 |
+
|
| 369 |
+
Der Container ist Pflicht, wo das Korpus einen führt. Ohne ihn holt
|
| 370 |
+
„§ 10" im Rahmenvertrag auch den § 10 der Anlage 11 — die Adresse wäre
|
| 371 |
+
dann mehrdeutig, und zwar still.
|
| 372 |
+
"""
|
| 373 |
+
if self.verweise is None or not hits or self.max_verweis_normen <= 0:
|
| 374 |
+
return []
|
| 375 |
+
|
| 376 |
+
try:
|
| 377 |
+
ziele = list(self.verweise(hits, corpora) or [])
|
| 378 |
+
except Exception as exc: # noqa: BLE001 - a parser must never break retrieval.
|
| 379 |
+
logger.warning("norm references skipped (%s: %s)", exc.__class__.__name__, exc)
|
| 380 |
+
return []
|
| 381 |
+
|
| 382 |
+
out: List[Dict[str, Any]] = []
|
| 383 |
+
for ziel in ziele[: self.max_verweis_normen]:
|
| 384 |
+
corpus_id = str(ziel.get("corpus_id") or "")
|
| 385 |
+
if corpus_id not in corpora:
|
| 386 |
+
continue
|
| 387 |
+
retriever = self.registry.retriever(corpus_id)
|
| 388 |
+
fetch = getattr(retriever, "get_units", None)
|
| 389 |
+
if fetch is None:
|
| 390 |
+
continue
|
| 391 |
+
|
| 392 |
+
container = ziel.get("container_id") or self.registry.spec(corpus_id).default_container_id
|
| 393 |
+
try:
|
| 394 |
+
treffer = fetch(
|
| 395 |
+
ziel.get("section_id") or "",
|
| 396 |
+
container=container or None,
|
| 397 |
+
subsection=ziel.get("subsection") or None,
|
| 398 |
+
max_chunks=int(ziel.get("max_chunks") or 1),
|
| 399 |
+
)
|
| 400 |
+
except Exception as exc: # noqa: BLE001 - see above.
|
| 401 |
+
logger.warning(
|
| 402 |
+
"norm reference lookup failed: %s %s (%s: %s)",
|
| 403 |
+
corpus_id,
|
| 404 |
+
ziel.get("section_id"),
|
| 405 |
+
exc.__class__.__name__,
|
| 406 |
+
exc,
|
| 407 |
+
)
|
| 408 |
+
continue
|
| 409 |
+
|
| 410 |
+
# Ein Verweis, der ins Leere zeigt, ist keine Warnung wert: der Text
|
| 411 |
+
# darf auf eine Vorschrift verweisen, die es im Bestand nicht gibt
|
| 412 |
+
# (aufgehobener Paragraph, andere Fassung). Für den Anker gilt das
|
| 413 |
+
# Gegenteil — dort ist die Adresse eine Behauptung.
|
| 414 |
+
for hit in treffer:
|
| 415 |
+
hit["corpus_id"] = corpus_id
|
| 416 |
+
hit["score"] = VERWEIS_SCORE
|
| 417 |
+
hit["rank_score"] = VERWEIS_SCORE
|
| 418 |
+
metadata = hit.get("metadata")
|
| 419 |
+
if isinstance(metadata, dict):
|
| 420 |
+
metadata.setdefault("corpus_id", corpus_id)
|
| 421 |
+
# Gesetzt, nicht ergänzt: `get_units` schreibt voreingestellt
|
| 422 |
+
# "norm_anchor" hinein, und das liest die Überwachung als
|
| 423 |
+
# kuratierten Pflichtabruf. Ein Verweis ist etwas anderes.
|
| 424 |
+
#
|
| 425 |
+
# Und bewusst OHNE "section_lookup": der Composer wertet diese
|
| 426 |
+
# Art in `_hit_relevance_key` mit Faktor 50 auf (expliziter
|
| 427 |
+
# Paragraphentreffer). Ein Verweis ist kein expliziter Treffer —
|
| 428 |
+
# niemand hat nach dieser Vorschrift gefragt.
|
| 429 |
+
hit["retrieval_kinds"] = ["verweis"]
|
| 430 |
+
out.append(hit)
|
| 431 |
+
|
| 432 |
+
return out
|
| 433 |
+
|
| 434 |
+
def _query_one(self, corpus_id: str, question: str, kwargs: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 435 |
+
retriever = self.registry.retriever(corpus_id)
|
| 436 |
+
spec = self.registry.spec(corpus_id)
|
| 437 |
+
|
| 438 |
+
call_kwargs = dict(kwargs)
|
| 439 |
+
call_kwargs.pop("question", None)
|
| 440 |
+
|
| 441 |
+
# A container filter is corpus-specific: "Vertrag" does not exist in a
|
| 442 |
+
# statute, and passing it through would return nothing at all.
|
| 443 |
+
where = call_kwargs.get("where")
|
| 444 |
+
if isinstance(where, dict) and "container_id" in where:
|
| 445 |
+
if not spec.default_container_id or where["container_id"] != spec.default_container_id:
|
| 446 |
+
call_kwargs["where"] = None
|
| 447 |
+
|
| 448 |
+
hits = self._call(retriever, question, call_kwargs)
|
| 449 |
+
for hit in hits:
|
| 450 |
+
hit["corpus_id"] = corpus_id
|
| 451 |
+
metadata = hit.get("metadata")
|
| 452 |
+
if isinstance(metadata, dict):
|
| 453 |
+
metadata.setdefault("corpus_id", corpus_id)
|
| 454 |
+
return hits
|
| 455 |
+
|
| 456 |
+
@staticmethod
|
| 457 |
+
def _call(retriever: Any, question: str, kwargs: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 458 |
+
# Only the kwargs this retriever actually declares. The orchestrator
|
| 459 |
+
# probes for optional features (e.g. `enable_definition_lookup`) and
|
| 460 |
+
# relies on a TypeError to retry without them — but that TypeError never
|
| 461 |
+
# reaches it through the federation. Filtering here keeps the valuable
|
| 462 |
+
# kwargs (explicit_sections, include_neighbors, max_final_results)
|
| 463 |
+
# instead of collapsing to the three-parameter emergency fallback below.
|
| 464 |
+
accepted = _accepted_kwargs(retriever.query, kwargs)
|
| 465 |
+
try:
|
| 466 |
+
return list(retriever.query(question=question, **accepted) or [])
|
| 467 |
+
except TypeError:
|
| 468 |
+
reduced = {k: v for k, v in accepted.items() if k in {"top_k", "where", "fetch_k"}}
|
| 469 |
+
try:
|
| 470 |
+
return list(retriever.query(question=question, **reduced) or [])
|
| 471 |
+
except TypeError:
|
| 472 |
+
return list(retriever.query(question, top_k=kwargs.get("top_k", 8)) or [])
|
| 473 |
+
|
| 474 |
+
def verify_negative_result(self, question: str, **kwargs: Any) -> Dict[str, Any]:
|
| 475 |
+
"""A negative answer is only safe when no corpus can refute it."""
|
| 476 |
+
corpora = self.select_corpora(question)
|
| 477 |
+
results: List[Dict[str, Any]] = []
|
| 478 |
+
blocking: List[str] = []
|
| 479 |
+
reasons: List[str] = []
|
| 480 |
+
|
| 481 |
+
for corpus_id in corpora:
|
| 482 |
+
retriever = self.registry.retriever(corpus_id)
|
| 483 |
+
try:
|
| 484 |
+
check = retriever.verify_negative_result(question, **kwargs)
|
| 485 |
+
except TypeError:
|
| 486 |
+
check = retriever.verify_negative_result(question=question)
|
| 487 |
+
except Exception as exc: # noqa: BLE001
|
| 488 |
+
logger.warning("negative recheck failed for %s (%s)", corpus_id, exc)
|
| 489 |
+
continue
|
| 490 |
+
|
| 491 |
+
for hit in check.get("results") or []:
|
| 492 |
+
hit["corpus_id"] = corpus_id
|
| 493 |
+
results.append(hit)
|
| 494 |
+
if not check.get("safe_to_answer_negative", True):
|
| 495 |
+
blocking.append(corpus_id)
|
| 496 |
+
reasons.append(f"{corpus_id}: {check.get('reason', '')}".strip())
|
| 497 |
+
|
| 498 |
+
return {
|
| 499 |
+
"safe_to_answer_negative": not blocking,
|
| 500 |
+
"reason": " | ".join(reasons)
|
| 501 |
+
or "Auch der breite Kontrollabruf hat in keinem Korpus belastbare Treffer gefunden.",
|
| 502 |
+
"results": _fuse(results),
|
| 503 |
+
"strong_result_count": len(blocking),
|
| 504 |
+
"corpora_checked": corpora,
|
| 505 |
+
"corpora_with_findings": blocking,
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _rank(hit: Dict[str, Any]) -> float:
|
| 510 |
+
try:
|
| 511 |
+
return float(hit.get("rank_score", hit.get("score", 0.0)) or 0.0)
|
| 512 |
+
except (TypeError, ValueError):
|
| 513 |
+
return 0.0
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def _key(hit: Dict[str, Any]) -> tuple:
|
| 517 |
+
metadata = hit.get("metadata") or {}
|
| 518 |
+
text_hash = metadata.get("text_hash")
|
| 519 |
+
if text_hash:
|
| 520 |
+
return ("hash", hit.get("corpus_id"), text_hash)
|
| 521 |
+
return (
|
| 522 |
+
"location",
|
| 523 |
+
hit.get("corpus_id"),
|
| 524 |
+
metadata.get("container_id") or hit.get("container"),
|
| 525 |
+
metadata.get("section_id") or hit.get("section"),
|
| 526 |
+
metadata.get("chunk_index_in_section") or hit.get("chunk_index"),
|
| 527 |
+
(hit.get("text") or "")[:160],
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def _fuse(hits: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 532 |
+
"""Merge hits from several corpora into one ranked list."""
|
| 533 |
+
best: Dict[tuple, Dict[str, Any]] = {}
|
| 534 |
+
for hit in hits:
|
| 535 |
+
key = _key(hit)
|
| 536 |
+
current = best.get(key)
|
| 537 |
+
if current is None:
|
| 538 |
+
best[key] = hit
|
| 539 |
+
continue
|
| 540 |
+
# The same chunk can arrive twice — once from the semantic pass, once
|
| 541 |
+
# from a mandatory fetch. Keeping only the better score would drop the
|
| 542 |
+
# other's retrieval_kind, and downstream ranking reads exactly that.
|
| 543 |
+
kinds = sorted({*(current.get("retrieval_kinds") or []), *(hit.get("retrieval_kinds") or [])})
|
| 544 |
+
winner = hit if _rank(hit) > _rank(current) else current
|
| 545 |
+
winner["retrieval_kinds"] = kinds
|
| 546 |
+
best[key] = winner
|
| 547 |
+
return sorted(best.values(), key=_rank, reverse=True)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _apply_corpus_quota(
|
| 551 |
+
hits: List[Dict[str, Any]],
|
| 552 |
+
*,
|
| 553 |
+
limit: Optional[int],
|
| 554 |
+
min_per_corpus: int,
|
| 555 |
+
min_ratio: float = 0.25,
|
| 556 |
+
) -> List[Dict[str, Any]]:
|
| 557 |
+
"""Cut to `limit`, but not before every corpus has had its share.
|
| 558 |
+
|
| 559 |
+
Without this the cut is purely global, and a corpus that is an order of
|
| 560 |
+
magnitude smaller than its neighbours never appears at all — not because it
|
| 561 |
+
has nothing to say but because it has fewer chances to say it.
|
| 562 |
+
|
| 563 |
+
Three limits keep the guarantee from turning into noise:
|
| 564 |
+
|
| 565 |
+
* The reservation is capped at **half** the result list. A floor against
|
| 566 |
+
structural defeat is not a mandate to fill the context with four documents
|
| 567 |
+
when the question belongs to one — with four corpora and ten results that
|
| 568 |
+
is one slot each and six left to the ranking.
|
| 569 |
+
* Only **whole rounds** are handed out. The corpus that would lose the last
|
| 570 |
+
slot of an incomplete round is always the weakest one, which is the one
|
| 571 |
+
this exists to protect.
|
| 572 |
+
* A corpus qualifies only if its best hit reaches `min_ratio` of the best hit
|
| 573 |
+
overall. Measured on the five live questions, that is the difference
|
| 574 |
+
between rescuing the Abrechnungsvereinbarung's 0.850 — genuinely
|
| 575 |
+
comparable, merely outnumbered — and admitting a 0.008 from a corpus that
|
| 576 |
+
simply has nothing to say. Having fewer chances is a defeat worth undoing;
|
| 577 |
+
being irrelevant is not.
|
| 578 |
+
"""
|
| 579 |
+
if not limit or limit <= 0:
|
| 580 |
+
return hits
|
| 581 |
+
if min_per_corpus <= 0 or len(hits) <= limit:
|
| 582 |
+
return hits[:limit]
|
| 583 |
+
|
| 584 |
+
# `hits` is ranked, so insertion order is the order of each corpus's best hit.
|
| 585 |
+
by_corpus: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
|
| 586 |
+
for hit in hits:
|
| 587 |
+
by_corpus.setdefault(str(hit.get("corpus_id") or ""), []).append(hit)
|
| 588 |
+
if len(by_corpus) <= 1:
|
| 589 |
+
return hits[:limit]
|
| 590 |
+
|
| 591 |
+
floor = _rank(hits[0]) * min_ratio
|
| 592 |
+
by_corpus = OrderedDict(
|
| 593 |
+
(corpus_id, bucket)
|
| 594 |
+
for corpus_id, bucket in by_corpus.items()
|
| 595 |
+
if _rank(bucket[0]) >= floor
|
| 596 |
+
)
|
| 597 |
+
if len(by_corpus) <= 1:
|
| 598 |
+
return hits[:limit]
|
| 599 |
+
|
| 600 |
+
budget = max(1, limit // 2)
|
| 601 |
+
reserved: List[Dict[str, Any]] = []
|
| 602 |
+
chosen: set = set()
|
| 603 |
+
|
| 604 |
+
for round_index in range(min_per_corpus):
|
| 605 |
+
runde = [b[round_index] for b in by_corpus.values() if round_index < len(b)]
|
| 606 |
+
if not runde or len(reserved) + len(runde) > budget:
|
| 607 |
+
break
|
| 608 |
+
for hit in runde:
|
| 609 |
+
reserved.append(hit)
|
| 610 |
+
chosen.add(id(hit))
|
| 611 |
+
|
| 612 |
+
for hit in hits:
|
| 613 |
+
if len(reserved) >= limit:
|
| 614 |
+
break
|
| 615 |
+
if id(hit) not in chosen:
|
| 616 |
+
reserved.append(hit)
|
| 617 |
+
chosen.add(id(hit))
|
| 618 |
+
|
| 619 |
+
return sorted(reserved, key=_rank, reverse=True)[:limit]
|
src/fundstellen.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Eine Fundstelle erkennen, gleich wie sie geschrieben ist.
|
| 2 |
+
|
| 3 |
+
„§ 34 Abs. 1 Satz 5 Nr. 1 SGB V", „§ 34 Absatz 1 Satz 5 Nummer 1 SGB V" und
|
| 4 |
+
„§ 34 Abs. 1 Satz 5 Nr. 1 des SGB V" sind dieselbe Vorschrift. „§ 34 Abs. 1
|
| 5 |
+
SGB V" ist es **nicht** — die Genauigkeit ist an vielen Stellen gerade der
|
| 6 |
+
Prüfgegenstand. Dieses Modul entscheidet beides.
|
| 7 |
+
|
| 8 |
+
Es liegt in `src`, weil zwei Seiten es brauchen: die Antwortprüfung der
|
| 9 |
+
Auswertung (`eval/antwortpruefung.py`) und, wo es einmal so weit ist, das
|
| 10 |
+
Normregister. `norm_anchors._genannt_in` prüft heute nur den **Paragrafen** —
|
| 11 |
+
eine Antwort mit „§ 92 Abs. 1 Satz 2 Nr. 6" gilt dort als „§ 92 genannt", und
|
| 12 |
+
die richtige Fundstelle wird nicht nachgetragen. Das darauf umzustellen ändert
|
| 13 |
+
das Verhalten **aller** Anker und gehört gemessen; bis dahin bleibt dieses
|
| 14 |
+
Modul, was es ist: der Matcher, an einer Stelle, an der beide ihn erreichen.
|
| 15 |
+
|
| 16 |
+
**Kanonisieren statt Muster basteln.** Forderung und Text gehen durch dieselbe
|
| 17 |
+
Wäsche: Abkürzungen auf je eine Schreibweise, Füllwörter weg, Striche und
|
| 18 |
+
Leerraum vereinheitlicht. Danach genügt ein schlichtes Muster. Der erste
|
| 19 |
+
Versuch baute stattdessen aus jeder Fundstelle ein tolerantes Suchmuster
|
| 20 |
+
zusammen und scheiterte an einer Kleinigkeit: `re.escape` maskiert seit
|
| 21 |
+
Python 3.7 auch das Leerzeichen, und der nachträgliche Ersatz des Leerraums
|
| 22 |
+
machte daraus einen doppelten Backslash — danach traf keine Forderung mehr,
|
| 23 |
+
auch die einfachste Wendung nicht.
|
| 24 |
+
|
| 25 |
+
Vier Fallen sind hier bezahlt und stehen unten an ihrer Stelle: die schmalen
|
| 26 |
+
Leerzeichen und geschützten Bindestriche der Antworten, das „I" von „in" unter
|
| 27 |
+
`re.I`, die Fundstelle, deren Bestandteile nicht zusammenhängen, und die
|
| 28 |
+
Flexion des Deutschen.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import re
|
| 34 |
+
from typing import Any, List, Sequence, Union
|
| 35 |
+
|
| 36 |
+
Forderung = Union[str, Sequence[str]]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Treffer im Text
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
# Normtexte tragen schmale und geschützte Leerzeichen. Ein Muster, das nur das
|
| 44 |
+
# gewöhnliche kennt, verfehlt „§ 34" im Zitat und trifft es in der Frage.
|
| 45 |
+
_LEERRAUM = re.compile("[\\s ]+")
|
| 46 |
+
|
| 47 |
+
_WAESCHE = tuple(
|
| 48 |
+
(re.compile(muster, re.I), ersatz)
|
| 49 |
+
for muster, ersatz in (
|
| 50 |
+
# Regelwerke zuerst, und der lange Name vor dem kurzen.
|
| 51 |
+
(r"Arzneimittel-?\s*Richtlinie", " amrl "),
|
| 52 |
+
(r"\bAM-?\s?RL\b", " amrl "),
|
| 53 |
+
# Ohne Ausschluss von „SGB VI": die Wortgrenze hinter dem V leistet das
|
| 54 |
+
# schon. Ein zusätzliches `(?!\s*I)` tat unter `re.I` das Gegenteil und
|
| 55 |
+
# verwarf „SGB V in Verbindung mit …" — das I von „in".
|
| 56 |
+
(r"\bSGB\s*V\b", " sgbv "),
|
| 57 |
+
(r"F[üu]nften?\s+Buch(?:es)?\s+Sozialgesetzbuch(?:es)?", " sgbv "),
|
| 58 |
+
(r"\bRahmenvertrag(?:es|s)?\b", " rahmenvertrag "),
|
| 59 |
+
(r"\bArzneimittelabrechnungsvereinbarung\b|\bAbrechnungsvereinbarung\b", " amabrv "),
|
| 60 |
+
# Gliederungsbezeichnungen auf je eine Form. Der Lookahead auf die
|
| 61 |
+
# Nummer verhindert, dass „Absatz" im Fließtext ohne Bezug fällt.
|
| 62 |
+
(r"\bAbs(?:atz|\.)?(?=\s*\d)", " abs "),
|
| 63 |
+
(r"\bN(?:r\.?|ummer)(?=\s*\d)", " nr "),
|
| 64 |
+
(r"\bHalbs(?:atz|\.)?(?=\s*\d)|\bHs\.?(?=\s*\d)", " halbsatz "),
|
| 65 |
+
(r"\bBuchst(?:abe|\.)?(?=\s*[a-z]\b)", " buchst "),
|
| 66 |
+
(r"\bS\.(?=\s*\d)", " satz "),
|
| 67 |
+
# Füllwörter zwischen den Bestandteilen: „nach § 34" und „des § 34"
|
| 68 |
+
# sollen dieselbe Fundstelle sein wie „§ 34". Die Wortgrenzen sind
|
| 69 |
+
# hier nicht Kosmetik — ohne sie fiele „in" mitten aus „Verbindung".
|
| 70 |
+
(r"\b(?:der|des|die|dem|den|das|nach|gemäß|gemaess|vom|im|in)\b", " "),
|
| 71 |
+
# Doppeltes Paragraphenzeichen kündigt mehrere an; für den Vergleich
|
| 72 |
+
# einer einzelnen Fundstelle ist es dasselbe Zeichen.
|
| 73 |
+
(r"§+", " § "),
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Die Antworten tragen den geschützten Bindestrich U+2011 („AM‑RL"), die
|
| 79 |
+
# Normtexte den Halbgeviertstrich. Ein Muster mit ASCII-Bindestrich verfehlt
|
| 80 |
+
# beides — gemessen am 21.08.2026, als „§ 40c AM-RL" gegen „§ 40c Abs. 1,
|
| 81 |
+
# Abs. 3 (Arzneimittel‑Richtlinie)" nicht traf.
|
| 82 |
+
_STRICHE = str.maketrans({c: "-" for c in "‐‑‒–—―−"})
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _glatt(text: str) -> str:
|
| 86 |
+
return _LEERRAUM.sub(" ", str(text or "").translate(_STRICHE)).strip()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def kanonisch(text: str) -> str:
|
| 90 |
+
"""Text und Forderung in derselben Schreibweise — klein, ohne Füllwörter."""
|
| 91 |
+
gewaschen = _glatt(text).lower()
|
| 92 |
+
for erkenner, ersatz in _WAESCHE:
|
| 93 |
+
gewaschen = erkenner.sub(ersatz, gewaschen)
|
| 94 |
+
return _glatt(gewaschen)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# Eine Zahl am Ende eines Bestandteils darf nicht in eine längere hineinlaufen:
|
| 98 |
+
# „§ 34" träfe sonst „§ 340", „nr 1" träfe „nr 12".
|
| 99 |
+
_ZAHL_AM_ENDE = re.compile(r"\d[a-z]?$", re.I)
|
| 100 |
+
|
| 101 |
+
_GLIEDERUNG = ("abs", "satz", "nr", "halbsatz", "buchst")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _wortmuster(wort: str) -> str:
|
| 105 |
+
"""Ein Wort der Forderung als Muster, mit Grenze nach hinten.
|
| 106 |
+
|
| 107 |
+
Die Grenze nur, wo sie eine ist: `\\b` hinter „12." verlangt ein Wortzeichen
|
| 108 |
+
dahinter und verfehlte damit „vollendeten 12. Lebensjahr" — vor dem
|
| 109 |
+
Leerzeichen steht keine Wortgrenze mehr, die Grenze liegt schon vor dem
|
| 110 |
+
Punkt.
|
| 111 |
+
"""
|
| 112 |
+
stueck = re.escape(wort)
|
| 113 |
+
if _ZAHL_AM_ENDE.search(wort):
|
| 114 |
+
return stueck + r"(?![0-9a-z])"
|
| 115 |
+
# Keine Wortgrenze, sondern eine erlaubte Endung: das Deutsche flektiert.
|
| 116 |
+
# „unverändert abgegeben" soll „unverändert abgegebene Fertigarzneimittel"
|
| 117 |
+
# treffen und „parenterale Zubereitung" auch „parenteralen Zubereitungen".
|
| 118 |
+
# Gemessen am 21.08.2026, als an beidem eine erfüllte Abgrenzung als fehlend
|
| 119 |
+
# gemeldet wurde. Die Genauigkeit, auf die es ankommt, sichert die
|
| 120 |
+
# Zahlengrenze darüber — nicht die Wortgrenze.
|
| 121 |
+
return stueck + r"[a-zäöüß]*"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _muster(wendung: str) -> "re.Pattern[str]":
|
| 125 |
+
"""Das Suchmuster zu einer Forderung ohne Paragraphenzeichen: Wort für Wort."""
|
| 126 |
+
stuecke = [_wortmuster(w) for w in kanonisch(wendung).split(" ") if w]
|
| 127 |
+
if not stuecke:
|
| 128 |
+
# Eine leere Forderung darf nicht auf alles passen.
|
| 129 |
+
return re.compile(r"(?!)")
|
| 130 |
+
return re.compile(r"\s+".join(stuecke), re.I)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _zerlegt(wendung: str) -> Any:
|
| 134 |
+
"""Eine Fundstelle als (Paragraf, geforderte Bestandteile) — oder None.
|
| 135 |
+
|
| 136 |
+
Die erste Fassung suchte die Fundstelle als **zusammenhängende** Wortkette
|
| 137 |
+
und verfehlte damit genau die Schreibweise, die Modelle bevorzugen:
|
| 138 |
+
„§ 40c Abs. 1, Abs. 3 (Arzneimittel-Richtlinie)" erfüllt eine Forderung nach
|
| 139 |
+
„§ 40c AM-RL", denn zwischen Nummer und Regelwerk steht nur mehr Genauigkeit.
|
| 140 |
+
Gemessen am 21.08.2026: die Prüfung meldete § 40c als „nicht genannt", obwohl
|
| 141 |
+
er im Normabschnitt stand — und § 40b als nicht beanstandet, obwohl er dort
|
| 142 |
+
an erster Stelle stand.
|
| 143 |
+
|
| 144 |
+
Gesucht wird deshalb zweistufig: erst der Paragraf, dann seine Bestandteile
|
| 145 |
+
**im Fenster bis zum nächsten Paragraphenzeichen**. Das Fenster ist der
|
| 146 |
+
ganze Trick — ohne es verbände die Suche „§ 40b Abs. 1" mit dem „AM-RL"
|
| 147 |
+
einer später genannten anderen Vorschrift.
|
| 148 |
+
"""
|
| 149 |
+
woerter = [w for w in kanonisch(wendung).split(" ") if w]
|
| 150 |
+
if not woerter or woerter[0] != "§" or len(woerter) < 2:
|
| 151 |
+
return None
|
| 152 |
+
paragraf = woerter[1]
|
| 153 |
+
rest = woerter[2:]
|
| 154 |
+
|
| 155 |
+
gefordert: List[str] = []
|
| 156 |
+
i = 0
|
| 157 |
+
while i < len(rest):
|
| 158 |
+
wort = rest[i]
|
| 159 |
+
if wort in _GLIEDERUNG and i + 1 < len(rest):
|
| 160 |
+
# Bestandteil und Wert gehören zusammen: „abs 1" darf nicht durch
|
| 161 |
+
# „abs 5 … satz 1" erfüllt werden.
|
| 162 |
+
gefordert.append(rf"{re.escape(wort)}\s+{_wortmuster(rest[i + 1])}")
|
| 163 |
+
i += 2
|
| 164 |
+
continue
|
| 165 |
+
gefordert.append(_wortmuster(wort))
|
| 166 |
+
i += 1
|
| 167 |
+
return paragraf, gefordert
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _fundstelle_trifft(wendung: str, gewaschen: str) -> bool:
|
| 171 |
+
zerlegt = _zerlegt(wendung)
|
| 172 |
+
if zerlegt is None:
|
| 173 |
+
return _muster(wendung).search(gewaschen) is not None
|
| 174 |
+
paragraf, gefordert = zerlegt
|
| 175 |
+
anker = re.compile(rf"§\s+{re.escape(paragraf)}(?![0-9a-z])", re.I)
|
| 176 |
+
for treffer in anker.finditer(gewaschen):
|
| 177 |
+
naechster = gewaschen.find("§", treffer.end())
|
| 178 |
+
fenster = gewaschen[treffer.end(): naechster if naechster != -1 else len(gewaschen)]
|
| 179 |
+
if all(re.search(m, fenster, re.I) for m in gefordert):
|
| 180 |
+
return True
|
| 181 |
+
return False
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def trifft(forderung: Forderung, text: str) -> bool:
|
| 185 |
+
"""Erfüllt der Text die Forderung — eine der Alternativen genügt."""
|
| 186 |
+
wendungen = [forderung] if isinstance(forderung, str) else list(forderung)
|
| 187 |
+
gewaschen = kanonisch(text)
|
| 188 |
+
return any(_fundstelle_trifft(w, gewaschen) for w in wendungen if str(w).strip())
|
| 189 |
+
|
| 190 |
+
|
src/llm_client_groq.py
CHANGED
|
@@ -406,6 +406,20 @@ class ConversationMemory:
|
|
| 406 |
# GroqClient
|
| 407 |
# ---------------------------------------------------------------------------
|
| 408 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
class GroqClient:
|
| 410 |
"""
|
| 411 |
Robuster Client-Wrapper um Groq Chat Completions.
|
|
@@ -428,8 +442,10 @@ class GroqClient:
|
|
| 428 |
model: str = "llama-3.3-70b-versatile",
|
| 429 |
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
| 430 |
temperature: float = 0.05,
|
| 431 |
-
max_tokens: int =
|
| 432 |
max_retries: int = 2,
|
|
|
|
|
|
|
| 433 |
retry_backoff_base: float = 0.8,
|
| 434 |
history_for_factual_questions: bool = False,
|
| 435 |
max_factual_history_chars: int = 8000,
|
|
@@ -447,6 +463,7 @@ class GroqClient:
|
|
| 447 |
self.system_prompt = (system_prompt or "").strip()
|
| 448 |
self.temperature = temperature
|
| 449 |
self.max_tokens = max_tokens
|
|
|
|
| 450 |
self.max_retries = max_retries
|
| 451 |
self.retry_backoff_base = retry_backoff_base
|
| 452 |
self.history_for_factual_questions = history_for_factual_questions
|
|
@@ -530,6 +547,18 @@ class GroqClient:
|
|
| 530 |
)
|
| 531 |
|
| 532 |
last_err: Optional[Exception] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
|
| 534 |
for attempt in range(self.max_retries + 1):
|
| 535 |
try:
|
|
@@ -537,10 +566,42 @@ class GroqClient:
|
|
| 537 |
model=actual_model,
|
| 538 |
messages=messages,
|
| 539 |
temperature=temperature,
|
| 540 |
-
max_tokens=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
)
|
| 542 |
-
|
| 543 |
-
return content or ""
|
| 544 |
|
| 545 |
except Exception as exc:
|
| 546 |
last_err = exc
|
|
|
|
| 406 |
# GroqClient
|
| 407 |
# ---------------------------------------------------------------------------
|
| 408 |
|
| 409 |
+
def _reasoning_tokens(response: Any) -> Optional[int]:
|
| 410 |
+
"""Wie viele der verbrauchten Tokens ins Denken gingen, wenn das Modell es meldet.
|
| 411 |
+
|
| 412 |
+
Nur fürs Log, und deshalb bewusst zahnlos: ein Modell ohne
|
| 413 |
+
`completion_tokens_details` ist keine Störung, sondern der Normalfall bei
|
| 414 |
+
den nicht-reasoning-Modellen.
|
| 415 |
+
"""
|
| 416 |
+
try:
|
| 417 |
+
details = getattr(getattr(response, "usage", None), "completion_tokens_details", None)
|
| 418 |
+
return getattr(details, "reasoning_tokens", None)
|
| 419 |
+
except Exception: # noqa: BLE001 - eine Logzeile darf nie die Antwort kosten.
|
| 420 |
+
return None
|
| 421 |
+
|
| 422 |
+
|
| 423 |
class GroqClient:
|
| 424 |
"""
|
| 425 |
Robuster Client-Wrapper um Groq Chat Completions.
|
|
|
|
| 442 |
model: str = "llama-3.3-70b-versatile",
|
| 443 |
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
| 444 |
temperature: float = 0.05,
|
| 445 |
+
max_tokens: int = 4000,
|
| 446 |
max_retries: int = 2,
|
| 447 |
+
# Obergrenze für den Nachschlag bei leerer Antwort. Siehe `_call`.
|
| 448 |
+
max_tokens_ceiling: int = 8000,
|
| 449 |
retry_backoff_base: float = 0.8,
|
| 450 |
history_for_factual_questions: bool = False,
|
| 451 |
max_factual_history_chars: int = 8000,
|
|
|
|
| 463 |
self.system_prompt = (system_prompt or "").strip()
|
| 464 |
self.temperature = temperature
|
| 465 |
self.max_tokens = max_tokens
|
| 466 |
+
self.max_tokens_ceiling = max(max_tokens, max_tokens_ceiling)
|
| 467 |
self.max_retries = max_retries
|
| 468 |
self.retry_backoff_base = retry_backoff_base
|
| 469 |
self.history_for_factual_questions = history_for_factual_questions
|
|
|
|
| 547 |
)
|
| 548 |
|
| 549 |
last_err: Optional[Exception] = None
|
| 550 |
+
# Ein Reasoning-Modell teilt sich `max_tokens` zwischen Denk- und
|
| 551 |
+
# Antworttokens. Reicht das Budget nicht, kommt eine **leere** Antwort
|
| 552 |
+
# zurück — mit HTTP 200 und `finish_reason: "length"`.
|
| 553 |
+
#
|
| 554 |
+
# Gemessen am 19.08.2026 mit `openai/gpt-oss-120b` und dem damaligen
|
| 555 |
+
# Budget von 1400: dieselbe Frage nach der Austauschbarkeit von
|
| 556 |
+
# Darreichungsformen lieferte einmal 0 Zeichen (reasoning_tokens 1400
|
| 557 |
+
# von 1400) und einmal 1143 (reasoning_tokens 1046). Die alte Zeile
|
| 558 |
+
# `return content or ""` machte daraus eine erfolgreiche Antwort; beim
|
| 559 |
+
# Nutzer kam der Reichweiten-Hinweis an und sonst nichts, und nichts im
|
| 560 |
+
# System hat es bemerkt. Ein leerer Inhalt ist kein Ergebnis.
|
| 561 |
+
versuchsbudget = max_tokens
|
| 562 |
|
| 563 |
for attempt in range(self.max_retries + 1):
|
| 564 |
try:
|
|
|
|
| 566 |
model=actual_model,
|
| 567 |
messages=messages,
|
| 568 |
temperature=temperature,
|
| 569 |
+
max_tokens=versuchsbudget,
|
| 570 |
+
)
|
| 571 |
+
choice = response.choices[0]
|
| 572 |
+
content = (choice.message.content or "").strip()
|
| 573 |
+
finish_reason = getattr(choice, "finish_reason", None)
|
| 574 |
+
|
| 575 |
+
if finish_reason == "length":
|
| 576 |
+
# Auch mit Inhalt: die Antwort endet dann mitten im Satz.
|
| 577 |
+
# Das gehört ins Log, weil es sonst als fachliche
|
| 578 |
+
# Unvollständigkeit gelesen wird.
|
| 579 |
+
logger.warning(
|
| 580 |
+
"Groq response hit the token limit",
|
| 581 |
+
extra={
|
| 582 |
+
"groq_model": actual_model,
|
| 583 |
+
"max_tokens": versuchsbudget,
|
| 584 |
+
"content_chars": len(content),
|
| 585 |
+
"reasoning_tokens": _reasoning_tokens(response),
|
| 586 |
+
},
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
if content:
|
| 590 |
+
return content
|
| 591 |
+
|
| 592 |
+
if versuchsbudget < self.max_tokens_ceiling and attempt < self.max_retries:
|
| 593 |
+
versuchsbudget = min(versuchsbudget * 2, self.max_tokens_ceiling)
|
| 594 |
+
logger.warning(
|
| 595 |
+
"Groq returned no content; retrying with a larger budget",
|
| 596 |
+
extra={"groq_model": actual_model, "next_max_tokens": versuchsbudget},
|
| 597 |
+
)
|
| 598 |
+
continue
|
| 599 |
+
|
| 600 |
+
last_err = RuntimeError(
|
| 601 |
+
f"Groq lieferte keinen Inhalt (finish_reason={finish_reason!r}, "
|
| 602 |
+
f"max_tokens={versuchsbudget})"
|
| 603 |
)
|
| 604 |
+
break
|
|
|
|
| 605 |
|
| 606 |
except Exception as exc:
|
| 607 |
last_err = exc
|
src/norm_anchors.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/norm_rang.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Welchen Rang eine Fundstelle hat — damit die Antwort sie einordnen kann.
|
| 2 |
+
|
| 3 |
+
Der Quellenblock nennt bisher das Dokument („Rahmenvertrag nach § 129 Absatz 2
|
| 4 |
+
SGB V"), und das reicht, um § 16 des Vertrages von § 16 SGB V zu unterscheiden.
|
| 5 |
+
Es reicht nicht, um zu entscheiden, welche der beiden die Frage **trägt**.
|
| 6 |
+
|
| 7 |
+
Die Metadaten helfen dabei nicht: `container_type` steht bei rv129, amabrv
|
| 8 |
+
*und* amrl auf „vertrag" — die Richtlinie ist über denselben Ingest-Pfad
|
| 9 |
+
gelaufen wie die Vertragswerke. Wer den Rang daraus liest, hält die
|
| 10 |
+
Arzneimittel-Richtlinie für einen Vertrag. Deshalb eine kuratierte Zuordnung
|
| 11 |
+
über die Korpus-Id, wie bei den übrigen Registern dieses Projekts.
|
| 12 |
+
|
| 13 |
+
**Warum der Rang in den Kontext gehört.** Die Auswertung zeigte Antworten, die
|
| 14 |
+
eine Anlage oder eine Ausführungsregelung als „Maßgebliche Norm" führten,
|
| 15 |
+
obwohl die tragende Vorschrift im selben Kontext stand. Das ist kein
|
| 16 |
+
Zitierfehler, sondern eine falsche Herleitung: Anlage VII Teil A sagt, welche
|
| 17 |
+
Darreichungsformen austauschbar sind, aber *dass* ausgetauscht werden darf,
|
| 18 |
+
ordnet § 129 Abs. 1 SGB V an und konkretisiert § 40 AM-RL. Wer nur die Anlage
|
| 19 |
+
nennt, nennt das Ergebnis ohne seinen Grund.
|
| 20 |
+
|
| 21 |
+
**Der Rang ist eine Angabe, keine Rangfolge.** Dieses Modul sagt, was eine
|
| 22 |
+
Quelle *ist* — Gesetz, Richtlinie, Vertrag, Anlage. Es sagt nicht, welche
|
| 23 |
+
Vorschrift vorgeht; das hängt am Fall und bleibt beim Modell und beim
|
| 24 |
+
Ankerregister. Eine automatische Regel „Gesetz schlägt Vertrag" wäre hier
|
| 25 |
+
falsch: der Rahmenvertrag konkretisiert § 129 SGB V bindend, und für die Frage,
|
| 26 |
+
welches Fertigarzneimittel abzugeben ist, ist er die genauere Vorschrift.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
from typing import Any, Dict
|
| 32 |
+
|
| 33 |
+
# Korpus-Id -> Rang, mit der Norm, auf der das Regelwerk beruht. Die Klammer
|
| 34 |
+
# ist der eigentliche Nutzen: sie nennt dem Modell die Ermächtigungsgrundlage,
|
| 35 |
+
# die es sonst erraten müsste.
|
| 36 |
+
RANG_JE_KORPUS: Dict[str, str] = {
|
| 37 |
+
"sgb5": "Gesetz",
|
| 38 |
+
"rv129": "Vertrag nach § 129 Abs. 2 SGB V",
|
| 39 |
+
"amabrv": "Vertrag nach § 300 Abs. 3 SGB V",
|
| 40 |
+
"amrl": "Richtlinie nach § 92 Abs. 1 Satz 2 Nr. 6 SGB V",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# Fällt die Korpus-Id aus (Einzelkorpus-Betrieb, alte Trefferdicts), trägt die
|
| 44 |
+
# doc_id dieselbe Auskunft — sie wird vom Ingest aus dem Dokument gebildet.
|
| 45 |
+
_RANG_JE_DOC_PREFIX = (
|
| 46 |
+
("SGB_V", "Gesetz"),
|
| 47 |
+
("RV_129", "Vertrag nach § 129 Abs. 2 SGB V"),
|
| 48 |
+
("AMAbrV", "Vertrag nach § 300 Abs. 3 SGB V"),
|
| 49 |
+
("AM_RL", "Richtlinie nach § 92 Abs. 1 Satz 2 Nr. 6 SGB V"),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _wert(hit: Dict[str, Any], *keys: str) -> str:
|
| 54 |
+
metadata = hit.get("metadata") or {}
|
| 55 |
+
for key in keys:
|
| 56 |
+
value = hit.get(key) or metadata.get(key)
|
| 57 |
+
if value:
|
| 58 |
+
return str(value)
|
| 59 |
+
return ""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def rang(hit: Dict[str, Any]) -> str:
|
| 63 |
+
"""Der Rang einer Fundstelle, als eine Zeile für den Quellenblock.
|
| 64 |
+
|
| 65 |
+
Anlagen werden gesondert ausgewiesen, weil sie im Index Container ihres
|
| 66 |
+
eigenen Regelwerks sind: § 10 gibt es im Rahmenvertrag und in dessen Anlage
|
| 67 |
+
11. Eine Fundstelle aus der Anlage als „Vertrag" auszuweisen, verwischt
|
| 68 |
+
genau den Unterschied, den die Zeile sichtbar machen soll.
|
| 69 |
+
"""
|
| 70 |
+
korpus = _wert(hit, "corpus_id")
|
| 71 |
+
grundrang = RANG_JE_KORPUS.get(korpus, "")
|
| 72 |
+
|
| 73 |
+
if not grundrang:
|
| 74 |
+
doc_id = _wert(hit, "doc_id")
|
| 75 |
+
for prefix, wert in _RANG_JE_DOC_PREFIX:
|
| 76 |
+
if doc_id.startswith(prefix):
|
| 77 |
+
grundrang = wert
|
| 78 |
+
break
|
| 79 |
+
|
| 80 |
+
container = _wert(hit, "container", "container_id")
|
| 81 |
+
if container.strip().lower().startswith("anlage"):
|
| 82 |
+
return f"Anlage zum Regelwerk ({container})" if not grundrang else (
|
| 83 |
+
f"Anlage zu: {grundrang} ({container})"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
return grundrang
|
src/norm_verweise.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verweise als Abrufauftrag — was der Treffertext selbst nennt.
|
| 2 |
+
|
| 3 |
+
Das Retrieval liest §-Angaben bisher nur aus der **Frage**
|
| 4 |
+
(`LegalRetriever._parse_norm_refs`). Was ein gefundener Text selbst zitiert,
|
| 5 |
+
löst nichts aus. Für ein Vertragswerk, das seine Regelungen über ein Dutzend
|
| 6 |
+
Vorschriften verteilt, ist das die eigentliche Lücke:
|
| 7 |
+
|
| 8 |
+
§ 10 Rahmenvertrag „Die Auswahl ist nach Maßgabe der §§ 11 bis 14 zu
|
| 9 |
+
treffen."
|
| 10 |
+
§ 11 Rahmenvertrag „… aus dem Auswahlbereich nach § 9 …"
|
| 11 |
+
§ 17 Rahmenvertrag „Die Regelungen nach den §§ 10 bis 15 gelten …"
|
| 12 |
+
|
| 13 |
+
Findet die Ähnlichkeitssuche § 11, steht die Rangfolge im Kontext, aber weder
|
| 14 |
+
der Auswahlbereich, aus dem gewählt wird, noch die Vorschriften, die die
|
| 15 |
+
Auswahl ausführen. Die Antwort ist dann richtig und unvollständig zugleich —
|
| 16 |
+
und das ist die Fehlerklasse, die in der Auswertung am häufigsten übrig blieb.
|
| 17 |
+
|
| 18 |
+
Dieses Modul liest die Verweise aus dem Text und gibt sie als Adressen zurück.
|
| 19 |
+
Abgerufen werden sie im `FederatedRetriever`, mit demselben `get_units`, das
|
| 20 |
+
schon die kuratierten Anker holt.
|
| 21 |
+
|
| 22 |
+
**Der Gesetzesname steht hinter der Nummer, nicht davor.** Das ist die Falle,
|
| 23 |
+
an der ein naiver Parser scheitert. § 6 des Rahmenvertrags enthält den Satz
|
| 24 |
+
|
| 25 |
+
„… die Angaben den §§ 2 Absatz 1 Nummern 4 bis 6 und 7 AMVV bzw. 9 Absatz 1
|
| 26 |
+
Nummern 3 bis 6 BtMVV nicht vollständig entsprechen."
|
| 27 |
+
|
| 28 |
+
Wer nur „§ 2" liest, holt § 2 des Rahmenvertrags (Definitionen) statt § 2 AMVV
|
| 29 |
+
— eine Vorschrift, die mit der Frage nichts zu tun hat, dafür aber wie eine
|
| 30 |
+
Fundstelle aussieht. Deshalb wird hinter jeder Nummer nach einem
|
| 31 |
+
Regelwerksnamen gesucht, bevor entschieden wird, wohin der Verweis zeigt.
|
| 32 |
+
|
| 33 |
+
**Drei Ausgänge, und der dritte ist so wichtig wie die ersten beiden:**
|
| 34 |
+
|
| 35 |
+
1. Der Verweis zeigt in ein indiziertes Korpus → Adresse zum Abruf.
|
| 36 |
+
2. Der Verweis nennt kein Regelwerk → er meint das eigene; Adresse im Korpus
|
| 37 |
+
des zitierenden Treffers.
|
| 38 |
+
3. Der Verweis zeigt auf ein Regelwerk, das nicht im Bestand ist (AMG, AMVV,
|
| 39 |
+
BtMVV, ApoG, AMPreisV …) → nicht abrufbar, aber **benennbar**. Diese Liste
|
| 40 |
+
ist die Grundlage der Reichweitenangabe: „die entscheidende Vorschrift liegt
|
| 41 |
+
außerhalb des Bestandes" ist eine belastbare Auskunft, „nicht geregelt" wäre
|
| 42 |
+
an derselben Stelle falsch.
|
| 43 |
+
|
| 44 |
+
**Die Namensliste ist kuratiert, nicht geraten** — dieselbe Bauweise wie
|
| 45 |
+
`norm_anchors` und die Listenmodule. Ein unbekannter Name führt nicht zu einer
|
| 46 |
+
Vermutung, sondern dazu, dass der Verweis als korpuseigen gilt; deshalb stehen
|
| 47 |
+
in `_FREMDE_REGELWERKE` die Namen, die in den vier Korpora tatsächlich
|
| 48 |
+
vorkommen, und nicht alles, was denkbar wäre.
|
| 49 |
+
|
| 50 |
+
**Budget statt Vollständigkeit.** Ein einziger § des SGB V kann zwanzig andere
|
| 51 |
+
zitieren. Würden alle abgerufen, verdrängte die Kette genau die Treffer, für
|
| 52 |
+
die sie gebaut wurde. Deshalb: nur die vordersten Treffer werden gelesen, ein
|
| 53 |
+
Verweis zählt häufiger, wenn ihn mehrere Treffer nennen, und die Zahl der
|
| 54 |
+
nachgeladenen Normen ist gedeckelt.
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
from __future__ import annotations
|
| 58 |
+
|
| 59 |
+
import re
|
| 60 |
+
from collections import Counter
|
| 61 |
+
from dataclasses import dataclass
|
| 62 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Regelwerksnamen
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
# Indizierte Regelwerke: Name im Text -> Korpus-Id. Die Reihenfolge ist
|
| 69 |
+
# bedeutsam, weil der erste Treffer im Fenster gewinnt: "SGB V" muss vor
|
| 70 |
+
# "Sozialgesetzbuch" stehen, sonst schluckt der längere Name den kürzeren nicht.
|
| 71 |
+
_INDIZIERTE_REGELWERKE: Tuple[Tuple[str, str], ...] = (
|
| 72 |
+
(r"SGB\s*V\b(?!\s*I)", "sgb5"),
|
| 73 |
+
(r"SGB\s*5\b", "sgb5"),
|
| 74 |
+
(r"F[üu]nften?\s+Buch(?:es)?\s+Sozialgesetzbuch", "sgb5"),
|
| 75 |
+
(r"Arzneimittel-?Richtlinie", "amrl"),
|
| 76 |
+
(r"\bAM-?RL\b", "amrl"),
|
| 77 |
+
(r"Arzneimittelabrechnungsvereinbarung", "amabrv"),
|
| 78 |
+
(r"Abrechnungsvereinbarung", "amabrv"),
|
| 79 |
+
(r"Rahmenvertrag(?:es|s)?", "rv129"),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# Regelwerke, die im Text vorkommen und nicht im Bestand sind. Der Wert ist
|
| 83 |
+
# der Name, unter dem die Reichweitenangabe sie nennt — abgekürzt zitiert der
|
| 84 |
+
# Vertrag, ausgeschrieben liest es die Apotheke.
|
| 85 |
+
_FREMDE_REGELWERKE: Tuple[Tuple[str, str], ...] = (
|
| 86 |
+
(r"\bAMVV\b", "AMVV (Arzneimittelverschreibungsverordnung)"),
|
| 87 |
+
(r"\bBtMVV\b", "BtMVV (Betäubungsmittel-Verschreibungsverordnung)"),
|
| 88 |
+
(r"\bBtMG\b", "BtMG (Betäubungsmittelgesetz)"),
|
| 89 |
+
(r"\bAMG\b", "AMG (Arzneimittelgesetz)"),
|
| 90 |
+
(r"\bApoG\b", "ApoG (Apothekengesetz)"),
|
| 91 |
+
(r"\bApBetrO\b", "ApBetrO (Apothekenbetriebsordnung)"),
|
| 92 |
+
(r"\bAMPreisV\b", "AMPreisV (Arzneimittelpreisverordnung)"),
|
| 93 |
+
(r"\bPackungsV\b", "PackungsV (Packungsgrößenverordnung)"),
|
| 94 |
+
(r"\bMPG\b", "MPG (Medizinproduktegesetz)"),
|
| 95 |
+
(r"\bHWG\b", "HWG (Heilmittelwerbegesetz)"),
|
| 96 |
+
(r"\bTFG\b", "TFG (Transfusionsgesetz)"),
|
| 97 |
+
(r"SGB\s*X\b", "SGB X"),
|
| 98 |
+
(r"SGB\s*XI\b", "SGB XI"),
|
| 99 |
+
(r"SGB\s*I\b(?!\s*V|\s*X)", "SGB I"),
|
| 100 |
+
(r"SGB\s*IV\b", "SGB IV"),
|
| 101 |
+
(r"\bBGB\b", "BGB"),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
_INDIZIERT_RX: Tuple[Tuple[re.Pattern[str], str], ...] = tuple(
|
| 105 |
+
(re.compile(muster, re.I), korpus) for muster, korpus in _INDIZIERTE_REGELWERKE
|
| 106 |
+
)
|
| 107 |
+
_FREMD_RX: Tuple[Tuple[re.Pattern[str], str], ...] = tuple(
|
| 108 |
+
(re.compile(muster), name) for muster, name in _FREMDE_REGELWERKE
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# ---------------------------------------------------------------------------
|
| 112 |
+
# Erkennung im Text
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
|
| 115 |
+
# Eine Nummer, wie sie hinter dem Paragraphenzeichen steht: 129, 40c, 12a.
|
| 116 |
+
_NUMMER = r"\d{1,3}[a-z]?"
|
| 117 |
+
|
| 118 |
+
# Ein Verweis beginnt am Paragraphenzeichen. Das doppelte § kündigt mehrere
|
| 119 |
+
# an; der Bereich wird nur erkannt, wenn "bis" unmittelbar auf die erste Nummer
|
| 120 |
+
# folgt — "§§ 2 Absatz 1 Nummern 4 bis 6" ist ein Nummernbereich und kein
|
| 121 |
+
# Paragraphenbereich, und der Unterschied entscheidet, ob vier Vorschriften
|
| 122 |
+
# abgerufen werden oder eine.
|
| 123 |
+
_VERWEIS_RX = re.compile(
|
| 124 |
+
rf"§§?\s*(?P<von>{_NUMMER})"
|
| 125 |
+
rf"(?:\s*(?:bis|–|—|-)\s*(?P<bis>{_NUMMER}))?"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Wie weit hinter der Nummer nach einem Regelwerksnamen gesucht wird. Gemessen
|
| 129 |
+
# an den Zitaten in den vier Korpora reicht das für "§ 129 Absatz 2 Satz 1
|
| 130 |
+
# SGB V" und bleibt vor dem übernächsten Satz stehen.
|
| 131 |
+
_NAMENSFENSTER = 90
|
| 132 |
+
|
| 133 |
+
# Ein Bereich über mehr als so viele Paragraphen ist kein Kettenglied mehr,
|
| 134 |
+
# sondern eine Verweisung auf einen ganzen Abschnitt. "§§ 10 bis 15" sind
|
| 135 |
+
# sechs Vorschriften und gemeint; "§§ 1 bis 68" ist der halbe Vertrag.
|
| 136 |
+
_MAX_BEREICH = 8
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@dataclass(frozen=True)
|
| 140 |
+
class Verweis:
|
| 141 |
+
"""Eine Fundstelle, auf die ein Treffertext zeigt."""
|
| 142 |
+
|
| 143 |
+
section_id: str
|
| 144 |
+
# Leer, wenn das Regelwerk nicht im Bestand ist.
|
| 145 |
+
corpus_id: str = ""
|
| 146 |
+
# Der Name, unter dem die Reichweitenangabe das fremde Regelwerk nennt.
|
| 147 |
+
regelwerk: str = ""
|
| 148 |
+
|
| 149 |
+
@property
|
| 150 |
+
def abrufbar(self) -> bool:
|
| 151 |
+
return bool(self.corpus_id)
|
| 152 |
+
|
| 153 |
+
def als_ziel(self, max_chunks: int = 1) -> Dict[str, Any]:
|
| 154 |
+
"""Die Adresse als schlichtes dict — wie bei `norm_anchors.Norm`."""
|
| 155 |
+
return {
|
| 156 |
+
"corpus_id": self.corpus_id,
|
| 157 |
+
"section_id": self.section_id,
|
| 158 |
+
"max_chunks": max_chunks,
|
| 159 |
+
"zitat": f"{self.section_id} {self.regelwerk}".strip(),
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _regelwerk_hinter(text: str, ab: int) -> Tuple[Optional[str], Optional[str]]:
|
| 164 |
+
"""Welches Regelwerk die Nummer meint — gelesen hinter der Nummer.
|
| 165 |
+
|
| 166 |
+
Rückgabe: ``(korpus_id, fremdname)``. Genau eines ist gesetzt, oder beide
|
| 167 |
+
sind ``None``: dann nennt der Verweis kein Regelwerk und meint das eigene.
|
| 168 |
+
"""
|
| 169 |
+
fenster = text[ab : ab + _NAMENSFENSTER]
|
| 170 |
+
# Am nächsten Paragraphenzeichen abschneiden: was dahinter steht, gehört
|
| 171 |
+
# zum nächsten Verweis. Ohne diesen Schnitt erbte "§ 9" in "… nach § 9
|
| 172 |
+
# sowie § 129 SGB V" das SGB V des Nachbarn.
|
| 173 |
+
schnitt = fenster.find("§")
|
| 174 |
+
if schnitt >= 0:
|
| 175 |
+
fenster = fenster[:schnitt]
|
| 176 |
+
|
| 177 |
+
treffer: List[Tuple[int, Optional[str], Optional[str]]] = []
|
| 178 |
+
for rx, korpus in _INDIZIERT_RX:
|
| 179 |
+
m = rx.search(fenster)
|
| 180 |
+
if m:
|
| 181 |
+
treffer.append((m.start(), korpus, None))
|
| 182 |
+
for rx, name in _FREMD_RX:
|
| 183 |
+
m = rx.search(fenster)
|
| 184 |
+
if m:
|
| 185 |
+
treffer.append((m.start(), None, name))
|
| 186 |
+
|
| 187 |
+
if not treffer:
|
| 188 |
+
return None, None
|
| 189 |
+
treffer.sort(key=lambda t: t[0])
|
| 190 |
+
_, korpus, name = treffer[0]
|
| 191 |
+
return korpus, name
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _nummern(von: str, bis: Optional[str]) -> List[str]:
|
| 195 |
+
if not bis:
|
| 196 |
+
return [von]
|
| 197 |
+
# Ein Bereich läuft nur über reine Zahlen. "§§ 12a bis 12c" kommt in den
|
| 198 |
+
# Korpora nicht vor, und geraten wäre hier schlimmer als ausgelassen.
|
| 199 |
+
if not von.isdigit() or not bis.isdigit():
|
| 200 |
+
return [von, bis]
|
| 201 |
+
start, ende = int(von), int(bis)
|
| 202 |
+
if ende < start or ende - start + 1 > _MAX_BEREICH:
|
| 203 |
+
return [von, bis]
|
| 204 |
+
return [str(n) for n in range(start, ende + 1)]
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def verweise_im_text(text: str, *, korpus: str = "") -> List[Verweis]:
|
| 208 |
+
"""Alle Verweise eines Textes, in Lesereihenfolge und ohne Dubletten.
|
| 209 |
+
|
| 210 |
+
`korpus` ist das Korpus des zitierenden Treffers: ein Verweis ohne
|
| 211 |
+
Regelwerksnamen meint das eigene Regelwerk.
|
| 212 |
+
"""
|
| 213 |
+
gesehen: Set[Tuple[str, str, str]] = set()
|
| 214 |
+
out: List[Verweis] = []
|
| 215 |
+
|
| 216 |
+
for m in _VERWEIS_RX.finditer(str(text or "")):
|
| 217 |
+
ziel_korpus, fremdname = _regelwerk_hinter(text, m.end())
|
| 218 |
+
if fremdname:
|
| 219 |
+
corpus_id, regelwerk = "", fremdname
|
| 220 |
+
elif ziel_korpus:
|
| 221 |
+
corpus_id, regelwerk = ziel_korpus, ""
|
| 222 |
+
else:
|
| 223 |
+
corpus_id, regelwerk = korpus, ""
|
| 224 |
+
|
| 225 |
+
for nummer in _nummern(m.group("von"), m.group("bis")):
|
| 226 |
+
verweis = Verweis(
|
| 227 |
+
section_id=f"§ {nummer}",
|
| 228 |
+
corpus_id=corpus_id,
|
| 229 |
+
regelwerk=regelwerk,
|
| 230 |
+
)
|
| 231 |
+
schluessel = (verweis.section_id, verweis.corpus_id, verweis.regelwerk)
|
| 232 |
+
if schluessel in gesehen:
|
| 233 |
+
continue
|
| 234 |
+
gesehen.add(schluessel)
|
| 235 |
+
out.append(verweis)
|
| 236 |
+
|
| 237 |
+
return out
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
# Auswahl über eine Trefferliste
|
| 242 |
+
# ---------------------------------------------------------------------------
|
| 243 |
+
|
| 244 |
+
def _hit_text(hit: Dict[str, Any]) -> str:
|
| 245 |
+
metadata = hit.get("metadata") or {}
|
| 246 |
+
return str(hit.get("text") or hit.get("document") or metadata.get("text") or "")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _hit_adresse(hit: Dict[str, Any]) -> Tuple[str, str]:
|
| 250 |
+
metadata = hit.get("metadata") or {}
|
| 251 |
+
korpus = str(hit.get("corpus_id") or metadata.get("corpus_id") or "")
|
| 252 |
+
section = str(metadata.get("section_id") or hit.get("section") or "")
|
| 253 |
+
return korpus, section
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def verweise_aus_hits(
|
| 257 |
+
hits: Sequence[Dict[str, Any]],
|
| 258 |
+
*,
|
| 259 |
+
gelesene_treffer: int = 6,
|
| 260 |
+
max_normen: int = 3,
|
| 261 |
+
verfuegbare_korpora: Optional[Iterable[str]] = None,
|
| 262 |
+
) -> Tuple[List[Verweis], List[Verweis]]:
|
| 263 |
+
"""Was aus einer Trefferliste nachzuladen ist — und was nur zu benennen.
|
| 264 |
+
|
| 265 |
+
Rückgabe: ``(abrufbar, ausserhalb)``.
|
| 266 |
+
|
| 267 |
+
Gelesen werden nur die vordersten Treffer: ein Verweis in Rang 14 sagt
|
| 268 |
+
weniger über die Frage als einer in Rang 1, und jeder gelesene Text
|
| 269 |
+
vergrößert die Kandidatenmenge. Mehrfach genannte Verweise stehen vorn —
|
| 270 |
+
dass zwei Vorschriften dieselbe dritte in Bezug nehmen, ist das stärkste
|
| 271 |
+
Signal, das der Text über seine eigene Kette hergibt.
|
| 272 |
+
"""
|
| 273 |
+
verfuegbar = set(verfuegbare_korpora) if verfuegbare_korpora is not None else None
|
| 274 |
+
|
| 275 |
+
# Was schon im Kontext liegt, muss nicht nachgeladen werden.
|
| 276 |
+
vorhanden: Set[Tuple[str, str]] = {_hit_adresse(hit) for hit in hits}
|
| 277 |
+
|
| 278 |
+
haeufigkeit: Counter = Counter()
|
| 279 |
+
reihenfolge: Dict[Tuple[str, str, str], int] = {}
|
| 280 |
+
kandidaten: Dict[Tuple[str, str, str], Verweis] = {}
|
| 281 |
+
ausserhalb: Dict[str, Verweis] = {}
|
| 282 |
+
|
| 283 |
+
for rang, hit in enumerate(hits[:gelesene_treffer]):
|
| 284 |
+
korpus, section = _hit_adresse(hit)
|
| 285 |
+
for verweis in verweise_im_text(_hit_text(hit), korpus=korpus):
|
| 286 |
+
if not verweis.abrufbar:
|
| 287 |
+
# Fremdes Regelwerk: nicht abrufbar, aber für die Reichweite.
|
| 288 |
+
ausserhalb.setdefault(
|
| 289 |
+
f"{verweis.section_id} {verweis.regelwerk}", verweis
|
| 290 |
+
)
|
| 291 |
+
continue
|
| 292 |
+
# Ein Paragraph, der sich selbst nennt, ist kein Kettenglied.
|
| 293 |
+
if (verweis.corpus_id, verweis.section_id) == (korpus, section):
|
| 294 |
+
continue
|
| 295 |
+
if (verweis.corpus_id, verweis.section_id) in vorhanden:
|
| 296 |
+
continue
|
| 297 |
+
if verfuegbar is not None and verweis.corpus_id not in verfuegbar:
|
| 298 |
+
continue
|
| 299 |
+
schluessel = (verweis.corpus_id, verweis.section_id, verweis.regelwerk)
|
| 300 |
+
kandidaten[schluessel] = verweis
|
| 301 |
+
haeufigkeit[schluessel] += 1
|
| 302 |
+
reihenfolge.setdefault(schluessel, rang)
|
| 303 |
+
|
| 304 |
+
geordnet = sorted(
|
| 305 |
+
kandidaten,
|
| 306 |
+
key=lambda k: (-haeufigkeit[k], reihenfolge[k], k[1]),
|
| 307 |
+
)
|
| 308 |
+
abrufbar = [kandidaten[k] for k in geordnet[:max_normen]]
|
| 309 |
+
return abrufbar, list(ausserhalb.values())
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# ---------------------------------------------------------------------------
|
| 313 |
+
# Die beiden Schnittstellen nach außen
|
| 314 |
+
# ---------------------------------------------------------------------------
|
| 315 |
+
|
| 316 |
+
def abrufziele(
|
| 317 |
+
hits: Sequence[Dict[str, Any]],
|
| 318 |
+
korpora: Sequence[str] = (),
|
| 319 |
+
*,
|
| 320 |
+
max_normen: int = 3,
|
| 321 |
+
) -> List[Dict[str, Any]]:
|
| 322 |
+
"""Adressen zum Nachladen — die Form, die der `FederatedRetriever` erwartet.
|
| 323 |
+
|
| 324 |
+
Dieselbe Signatur wie `norm_anchors.pflichtabruf`, damit der Retriever nicht
|
| 325 |
+
unterscheiden muss, woher eine Adresse kommt.
|
| 326 |
+
"""
|
| 327 |
+
abrufbar, _ = verweise_aus_hits(
|
| 328 |
+
hits,
|
| 329 |
+
max_normen=max_normen,
|
| 330 |
+
verfuegbare_korpora=korpora or None,
|
| 331 |
+
)
|
| 332 |
+
return [verweis.als_ziel() for verweis in abrufbar]
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# Die zweite Hälfte — die Regelwerke außerhalb des Bestandes — hat hier bewusst
|
| 336 |
+
# **keine** eigene Schnittstelle. `corpus_boundary` erkennt sie schon, und zwar
|
| 337 |
+
# besser: es führt zu jedem Regelwerk ein Label und einen Satz darüber, was
|
| 338 |
+
# damit ungeprüft bleibt („die Verschreibungspflicht einzelner Stoffe und die
|
| 339 |
+
# Formerfordernisse der Verschreibung"), und es erkennt auch die Nennungen ohne
|
| 340 |
+
# Paragraphenzeichen — § 17 des Rahmenvertrags verweist auf „die PackungsV"
|
| 341 |
+
# ohne §, und für diesen Parser ist das nichts.
|
| 342 |
+
#
|
| 343 |
+
# Zwei Register für dieselbe Auskunft wären eines zu viel. Was der Parser
|
| 344 |
+
# beiträgt, ist die Gegenprobe: `tests/test_reichweite_verweise.py` hält beide
|
| 345 |
+
# gegen dieselbe Zitatliste, damit ein Regelwerk nicht in einem der beiden
|
| 346 |
+
# fehlt. `verweise_aus_hits` gibt die Fremdverweise deshalb weiterhin zurück —
|
| 347 |
+
# als Rückgabewert für diesen Test, nicht als zweite Quelle der Wahrheit.
|
src/orchestrator.py
CHANGED
|
@@ -4,10 +4,10 @@ import re
|
|
| 4 |
from dataclasses import asdict, dataclass, field
|
| 5 |
from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING
|
| 6 |
|
| 7 |
-
from
|
| 8 |
|
| 9 |
if TYPE_CHECKING:
|
| 10 |
-
from
|
| 11 |
else:
|
| 12 |
ConversationMemory = Any # type: ignore[assignment]
|
| 13 |
|
|
@@ -33,7 +33,7 @@ SOURCE_MARKER_WITH_OPTIONAL_REF_RE = re.compile(
|
|
| 33 |
)
|
| 34 |
ORPHAN_LEGAL_REF_PAREN_RE = re.compile(r"\(§\s*\d{1,3}[a-z]?(?:\s+[^)]{0,80})?\)", re.I)
|
| 35 |
NEGATIVE_ANSWER_RE = re.compile(
|
| 36 |
-
r"\b(keine\s+(relevante\s+)?textstelle|
|
| 37 |
r"keine\s+(regelung|informationen|aussage)|nicht\s+belastbar\s+ableitbar|"
|
| 38 |
r"kann\s+.*?nicht\s+(festgestellt|beantwortet)\s+werden)\b",
|
| 39 |
re.I,
|
|
|
|
| 4 |
from dataclasses import asdict, dataclass, field
|
| 5 |
from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING
|
| 6 |
|
| 7 |
+
from llm_client_groq import classify_question, is_meta_question
|
| 8 |
|
| 9 |
if TYPE_CHECKING:
|
| 10 |
+
from llm_client_groq import ConversationMemory
|
| 11 |
else:
|
| 12 |
ConversationMemory = Any # type: ignore[assignment]
|
| 13 |
|
|
|
|
| 33 |
)
|
| 34 |
ORPHAN_LEGAL_REF_PAREN_RE = re.compile(r"\(§\s*\d{1,3}[a-z]?(?:\s+[^)]{0,80})?\)", re.I)
|
| 35 |
NEGATIVE_ANSWER_RE = re.compile(
|
| 36 |
+
r"\b(keine\s+(relevante\s+)?textstelle|nichts?\s+(geregelt|enthalten|auffindbar)|"
|
| 37 |
r"keine\s+(regelung|informationen|aussage)|nicht\s+belastbar\s+ableitbar|"
|
| 38 |
r"kann\s+.*?nicht\s+(festgestellt|beantwortet)\s+werden)\b",
|
| 39 |
re.I,
|
src/retriever.py
CHANGED
|
@@ -736,6 +736,50 @@ class LegalRetriever:
|
|
| 736 |
|
| 737 |
return out[:max_chunks]
|
| 738 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
def verify_negative_result(
|
| 740 |
self,
|
| 741 |
question: str,
|
|
|
|
| 736 |
|
| 737 |
return out[:max_chunks]
|
| 738 |
|
| 739 |
+
def get_units(
|
| 740 |
+
self,
|
| 741 |
+
section: str,
|
| 742 |
+
*,
|
| 743 |
+
container: Optional[str] = None,
|
| 744 |
+
subsection: Optional[str] = None,
|
| 745 |
+
max_chunks: int = 2,
|
| 746 |
+
retrieval_kind: str = "norm_anchor",
|
| 747 |
+
) -> List[Dict[str, Any]]:
|
| 748 |
+
"""Fetch one norm by its address, independent of any question.
|
| 749 |
+
|
| 750 |
+
Two things `get_section` cannot do, and both are needed for a mandatory
|
| 751 |
+
fetch driven by a curated register:
|
| 752 |
+
|
| 753 |
+
* No container is assumed. A statute's §§ live in Kapitel containers,
|
| 754 |
+
and `get_section` would filter on "Vertrag" and return nothing.
|
| 755 |
+
* The Absatz can be narrowed. "§ 129 SGB V" is eighty chunks;
|
| 756 |
+
"§ 129 Abs. 1" is two, and both of them carry.
|
| 757 |
+
|
| 758 |
+
Parent units are preferred over their children because the parent chunk
|
| 759 |
+
holds the full text of the Absatz while the children repeat fragments of
|
| 760 |
+
it — one chunk is then usually the whole answer.
|
| 761 |
+
"""
|
| 762 |
+
where = {
|
| 763 |
+
"container_id": container or None,
|
| 764 |
+
"section_id": {"$in": self._section_variants(section)},
|
| 765 |
+
"subsection": str(subsection) if subsection else None,
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
try:
|
| 769 |
+
res = self.col.get(where=self._build_where(where), include=["documents", "metadatas"])
|
| 770 |
+
except Exception as exc: # noqa: BLE001 - a missing norm must not break the answer.
|
| 771 |
+
logger.debug("norm unit lookup failed", exc_info=exc)
|
| 772 |
+
return []
|
| 773 |
+
|
| 774 |
+
formatted = self._format_get(res, retrieval_kind=retrieval_kind, score=1.0)
|
| 775 |
+
formatted.sort(
|
| 776 |
+
key=lambda hit: (
|
| 777 |
+
0 if (hit.get("metadata") or {}).get("chunk_kind") == "parent" else 1,
|
| 778 |
+
hit.get("chunk_index", 0),
|
| 779 |
+
)
|
| 780 |
+
)
|
| 781 |
+
return formatted[:max_chunks]
|
| 782 |
+
|
| 783 |
def verify_negative_result(
|
| 784 |
self,
|
| 785 |
question: str,
|