AlixJabda commited on
Commit
d80690d
·
1 Parent(s): b3ab2e3

Aktualisierung der AM-RL-Module und Retrieval-Logik

Browse files
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
- from src.llm_client_groq import ConversationMemory, is_meta_question
 
 
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, Any, 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 = str(cls._hit_value(hit, "doc_id", default="") or "")
 
 
 
 
 
 
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
- if len(item["source_numbers"]) == 1:
937
- item["source_marker"] = f"[Quelle {item['source_numbers'][0]}]"
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
- numbers = source.get("source_numbers") or []
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
- numbers = source.get("source_numbers") or []
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 Abschnitts-Überschriften, denen kein Inhalt folgt.
1329
 
1330
- Eine Überschrift gilt als leer, wenn bis zur nächsten Überschrift oder
1331
- bis zum Textende keine inhaltliche Zeile folgt. Überschriften mit Inhalt
1332
- bleiben unangetastet.
 
 
 
 
 
1333
  """
1334
  if not text:
1335
  return text
1336
 
1337
- label_re = re.compile(
1338
- r"^(?:#+\s*)?(?:"
1339
- + "|".join(re.escape(h) for h in cls._SECTION_HEADINGS)
1340
- + r"):?\s*$",
1341
- re.I,
1342
- )
1343
 
1344
- lines = text.split("\n")
1345
- keep = [True] * len(lines)
 
1346
 
1347
- for i, line in enumerate(lines):
1348
- if not label_re.match(line.strip()):
 
1349
  continue
1350
 
1351
- # Nächste nicht-leere Zeile suchen.
1352
- j = i + 1
1353
- while j < len(lines) and not lines[j].strip():
1354
- j += 1
1355
 
1356
- # Leer, wenn Textende erreicht ist oder direkt die nächste
1357
- # Überschrift folgt.
1358
- if j >= len(lines) or label_re.match(lines[j].strip()):
1359
- keep[i] = False
1360
 
1361
- return "\n".join(line for idx, line in enumerate(lines) if keep[idx])
 
 
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
- try:
1372
- number = int(match.group(1))
1373
- except (TypeError, ValueError):
1374
  return ""
1375
- return match.group(0) if number in valid_numbers else ""
1376
-
1377
- cleaned = _SOURCE_MARKER_RE.sub(repl, answer or "")
1378
- # Nur überflüssige Leerzeichen vor Satzzeichen bereinigen; Zeilenumbrüche und Schema bleiben erhalten.
1379
- cleaned = re.sub(r"[ ]{2,}", " ", cleaned)
1380
- cleaned = re.sub(r"\s+([,.;:])", r"\1", 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 src.retriever import LegalRetriever
20
- from src.llm_client_groq import (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  DEFAULT_SYSTEM_PROMPT,
22
  ConversationMemory,
23
  GroqClient,
24
  classify_question,
25
  is_meta_question,
26
  )
27
- from src.answer_composer import AnswerComposer
28
- from src.orchestrator import (
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
- GROQ_MAX_TOKENS = int(os.getenv("GROQ_MAX_TOKENS", "1400"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def _build_retriever() -> LegalRetriever:
312
- """
313
- Baut den Retriever und gibt Startdiagnose aus.
314
 
315
- Wichtig für deine Debugging-Schleife:
316
- Die Konsole muss idealerweise anzeigen:
317
- APP CHROMA COUNT: 163
318
- """
319
- try:
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
- except TypeError:
330
- instance = LegalRetriever(
331
- persist_dir=CHROMA_PERSIST_DIR,
332
- collection=CHROMA_COLLECTION,
333
- model_name=EMBEDDING_MODEL,
334
- )
335
 
336
- try:
337
- count = instance.col.count()
338
- except Exception:
339
- count = "unknown"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
  print("APP USING CHROMA PATH:", CHROMA_PERSIST_DIR)
342
- print("APP USING CHROMA COLLECTION:", CHROMA_COLLECTION)
343
- print("APP CHROMA COUNT:", count)
 
 
 
 
 
344
 
345
  return instance
346
 
347
 
348
- retriever: Optional[LegalRetriever] = None
349
  retriever_lock = Lock()
350
 
351
 
352
- def get_retriever() -> LegalRetriever:
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 get_retriever().col.count()
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. Ältere
1405
- Pfade (Legacy-Fallbacks) können sie verlieren; da der Korpus derzeit aus
1406
- einem Dokument besteht, ist source_file aus einem beliebigen Hit belastbar.
 
 
 
1407
  """
1408
- fallback_file = None
1409
- fallback_doc_id = None
1410
  for hit in hits or []:
1411
  metadata = hit.get("metadata") or {}
1412
- fallback_file = fallback_file or _coalesce(hit.get("source_file"), metadata.get("source_file"))
1413
- fallback_doc_id = fallback_doc_id or _coalesce(hit.get("doc_id"), metadata.get("doc_id"))
1414
- if fallback_file and fallback_doc_id:
1415
- break
 
 
 
 
1416
 
1417
  for source in sources or []:
1418
- if not source.get("source_file") and fallback_file:
1419
- source["source_file"] = fallback_file
1420
- if not source.get("doc_id") and fallback_doc_id:
1421
- source["doc_id"] = fallback_doc_id
 
 
 
 
 
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": ctx.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
- sample: List[Dict[str, Any]] = []
1837
-
1838
  try:
1839
- retriever_instance = get_retriever()
1840
- res = retriever_instance.col.get(limit=5, include=["metadatas", "documents"])
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
- "sample": sample,
 
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 = 1400,
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=max_tokens,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  )
542
- content = response.choices[0].message.content
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 src.llm_client_groq import classify_question, is_meta_question
8
 
9
  if TYPE_CHECKING:
10
- from src.llm_client_groq import ConversationMemory
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|nicht\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,
 
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,