| """Make the edge of the indexed corpus visible in the answer. |
| |
| The retrieved text routinely points at legal sources this assistant does not |
| index. `§ 31 SGB V` grants the claim to medicines "soweit diese nicht … durch |
| Richtlinien nach § 92 Absatz 1 Satz 2 Nummer 6 ausgeschlossen sind" — that |
| exclusion lives in the Arzneimittel-Richtlinie. A model that only sees the |
| statute will answer confidently and omit the exception. |
| |
| Since the `amrl` corpus was added, that particular reference is covered and the |
| note disappears by itself (`covered_by`). What did *not* move into the index are |
| the AM-RL's Anlagen — the substance and product lists — and they are tracked as |
| their own entry. Splitting the two matters: a single entry would either vanish |
| with the directive text and imply the lists were checked, or keep firing on |
| answers that were in fact fully covered. |
| |
| That is the same failure mode `corpus_router` guards against on the way in, one |
| step later: not the wrong corpus, but a *missing* one. The cheap, honest fix is |
| to detect the dangling reference in the retrieved context and say so, rather than |
| to let the answer imply completeness it does not have. |
| |
| Detection runs on the retrieved chunks, not on the question. A user asking "Ist |
| Ibuprofen erstattungsfähig?" names no source at all — but the § 31 chunk that |
| answers it carries the reference to the AM-RL verbatim, which makes the context |
| the far more reliable signal. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
| from dataclasses import dataclass |
| from typing import Any, Dict, Iterable, List, Optional, Sequence |
|
|
|
|
| @dataclass(frozen=True) |
| class ExternalSource: |
| """A legal source the retrieved text may point at.""" |
|
|
| key: str |
| label: str |
| |
| scope: str |
| pattern: re.Pattern[str] |
| |
| |
| covered_by: Optional[str] = None |
|
|
|
|
| def _rx(pattern: str) -> re.Pattern[str]: |
| return re.compile(pattern, re.I) |
|
|
|
|
| |
| EXTERNAL_SOURCES: tuple[ExternalSource, ...] = ( |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ExternalSource( |
| key="am_rl_anlagen", |
| label="Anlagen der Arzneimittel-Richtlinie (Wirkstoff- und Produktlisten)", |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| scope="die übrigen wirkstoff- und produktbezogenen Listen — Therapiehinweise " |
| "(Anlage IV), verordnungsfähige Medizinprodukte (Anlage V), Verbandmittel " |
| "(Anlage Va) und der Off-Label-Use (Anlage VI) — sowie die übrigen Anlagen " |
| "der Richtlinie", |
| pattern=_rx( |
| r"Anlage\s+(?:XII|XI|X|IX|VIII|VII|VI|V|IV|III|II|I)a?\b" |
| r"|Substitutions-?\s?Ausschlussliste" |
| r"|Substitutionsausschluss" |
| r"|OTC-?\s?(?:Übersicht|Ausnahmeliste)" |
| r"|§\s*129\s+Abs(?:atz|\.)?\s*1a\s+Satz\s*2" |
| ), |
| ), |
| |
| |
| |
| ExternalSource( |
| key="am_rl", |
| label="Arzneimittel-Richtlinie (AM-RL)", |
| scope="Verordnungsfähigkeit, Verordnungsausschlüsse und die Regeln zur " |
| "Austauschbarkeit", |
| pattern=_rx( |
| r"§\s*92\s+Abs(?:atz|\.)?\s*1\s+Satz\s*2\s+Nummer\s*6" |
| r"|§\s*92\s+Abs\.\s*1\s+Satz\s*2\s+Nr\.\s*6" |
| r"|Arzneimittel-?\s?Richtlinie" |
| r"|Richtlinien?\s+nach\s+§\s*92" |
| ), |
| covered_by="amrl", |
| ), |
| ExternalSource( |
| key="ampreisv", |
| label="Arzneimittelpreisverordnung (AMPreisV)", |
| scope="Apothekenzuschläge und Preisbildung", |
| pattern=_rx(r"Arzneimittelpreisverordnung|AMPreisV|Arzneimittelpreisrecht"), |
| ), |
| |
| |
| |
| |
| |
| |
| |
| ExternalSource( |
| key="amvv", |
| label="Arzneimittelverschreibungsverordnung (AMVV)", |
| scope="die Verschreibungspflicht einzelner Stoffe und die Formerfordernisse " |
| "der Verschreibung", |
| pattern=_rx(r"Arzneimittelverschreibungsverordnung|\bAMVV\b"), |
| ), |
| ExternalSource( |
| key="amg", |
| label="Arzneimittelgesetz (AMG)", |
| scope="Zulassung, Verschreibungspflicht nach § 48 und Verkehrsfähigkeit", |
| pattern=_rx(r"Arzneimittelgesetz(?:es)?\b|\bAMG\b"), |
| ), |
| ExternalSource( |
| key="btmvv", |
| label="Betäubungsmittel-Verschreibungsverordnung (BtMVV)", |
| scope="Betäubungsmittelrezepte und deren Formerfordernisse", |
| pattern=_rx(r"Bet[äa]ubungsmittel-?Verschreibungsverordnung|BtMVV"), |
| ), |
| ExternalSource( |
| key="apog", |
| label="Apothekengesetz (ApoG)", |
| scope="Betriebserlaubnis, Zuweisungsverbot und Versandhandel", |
| pattern=_rx(r"Apothekengesetz(?:es)?\b|\bApoG\b"), |
| ), |
| |
| |
| |
| |
| |
| ExternalSource( |
| key="packungsv", |
| label="Packungsgrößenverordnung (PackungsV)", |
| scope="die Packungsgrößenkennzeichen N1, N2 und N3 und ihre Bestimmung", |
| pattern=_rx(r"Packungsgr[öo]ßenverordnung|\bPackungsV\b"), |
| ), |
| ExternalSource( |
| key="apbetro", |
| label="Apothekenbetriebsordnung (ApBetrO)", |
| scope="Betriebspflichten der Apotheke", |
| pattern=_rx(r"Apothekenbetriebsordnung|ApBetrO"), |
| ), |
| |
| |
| |
| |
| |
| ExternalSource( |
| key="amabrv_technische_anlagen", |
| label="Technische Anlagen zur Arzneimittelabrechnungsvereinbarung", |
| scope="die einzelnen Sonderkennzeichen, der Datensatzaufbau und die " |
| "Feldbelegungen", |
| pattern=_rx(r"Technische[nrs]?\s+Anlage\s*\d*"), |
| ), |
| ExternalSource( |
| key="amabrv", |
| label="Arzneimittelabrechnungsvereinbarung (§ 300 Abs. 3 SGB V)", |
| scope="Abrechnungsverfahren, Sonderkennzeichen, Beanstandung und Fristen", |
| pattern=_rx( |
| r"§\s*300\s+Abs(?:atz|\.)?\s*3" |
| r"|Abrechnungsvereinbarung" |
| r"|Vereinbarung\s+nach\s+§\s*300" |
| ), |
| covered_by="amabrv", |
| ), |
| ExternalSource( |
| key="rahmenvertrag", |
| label="Rahmenvertrag nach § 129 Abs. 2 SGB V", |
| scope="Abgaberegeln der Apotheke", |
| pattern=_rx(r"Rahmenvertrag\s+nach\s+§\s*129|Rahmenvertrag(?:es)?\s+über\s+die\s+Arzneimittelversorgung"), |
| covered_by="rv129", |
| ), |
| ) |
|
|
|
|
| def _hit_text(hit: Dict[str, Any]) -> str: |
| metadata = hit.get("metadata") or {} |
| parts = [ |
| hit.get("text"), |
| hit.get("document"), |
| metadata.get("text"), |
| ] |
| return " ".join(str(p) for p in parts if p) |
|
|
|
|
| def detect_external_references( |
| hits: Sequence[Dict[str, Any]], |
| *, |
| available_corpora: Iterable[str] = (), |
| max_results: int = 3, |
| ) -> List[Dict[str, str]]: |
| """External sources the retrieved context leans on but the index lacks. |
| |
| A source whose `covered_by` corpus is loaded is not reported: the assistant |
| can answer from it, so there is no gap to disclose. |
| """ |
| available = {str(c).lower() for c in available_corpora} |
| blob = " ".join(_hit_text(hit) for hit in hits or []) |
| if not blob.strip(): |
| return [] |
|
|
| found: List[Dict[str, str]] = [] |
| for source in EXTERNAL_SOURCES: |
| if source.covered_by and source.covered_by.lower() in available: |
| continue |
| if source.pattern.search(blob): |
| found.append({"key": source.key, "label": source.label, "scope": source.scope}) |
| if len(found) >= max_results: |
| break |
| return found |
|
|
|
|
| def boundary_note(references: Sequence[Dict[str, str]]) -> str: |
| """One short paragraph naming what the answer could not consider.""" |
| if not references: |
| return "" |
|
|
| |
| |
| if len(references) == 1: |
| ref = references[0] |
| return ( |
| "Hinweis zur Reichweite: Die herangezogenen Textstellen verweisen auf ein " |
| f"Regelwerk außerhalb des durchsuchten Bestands — {ref['label']}. " |
| f"Damit sind {ref['scope']} hier nicht geprüft." |
| ) |
|
|
| labels = "; ".join(f"{r['label']} ({r['scope']})" for r in references) |
| return ( |
| "Hinweis zur Reichweite: Die herangezogenen Textstellen verweisen auf Regelwerke " |
| f"außerhalb des durchsuchten Bestands — {labels}. Diese sind hier nicht geprüft." |
| ) |
|
|
|
|
| def append_boundary_note(answer: str, references: Sequence[Dict[str, str]]) -> str: |
| """Attach the note without disturbing the answer's own structure. |
| |
| Appended rather than woven in: the composer's schema (Kurzantwort / |
| Maßgebliche Norm / Wortlaut / Einordnung) is validated elsewhere, and the |
| note is a statement about the index, not about the law. |
| """ |
| note = boundary_note(references) |
| if not note: |
| return answer |
| text = (answer or "").rstrip() |
| if not text: |
| return note |
| return f"{text}\n\n{note}" |
|
|