BAV_KI / src /corpus_amendments.py
AlixJabda's picture
Aktualisierung der AM-RL-Module und Retrieval-Logik
d80690d
Raw
History Blame Contribute Delete
10.3 kB
"""Name the concrete changes a corpus's indexed version does not yet contain.
Some corpora are only published as a base version plus separate amendment
documents. The Arzneimittelabrechnungsvereinbarung is the case this was written
for: the GKV-Spitzenverband offers the Fassung vom 01.07.2023 and five later
amendment texts, but no consolidated version.
Indexing the base alone would be silently wrong, and the amendment texts are
useless as retrieval material — they consist of editing instructions ("In § 4
Absatz 2 werden die Wörter … ersetzt"), not of normative text. So the amendments
are curated once into `data/amabrv_aenderungen.json` and surfaced here: whenever
an answer cites a provision that was later changed, the answer says which
amendment changed it and what it now says.
This is deliberately narrower than `corpus_boundary`, which reports whole
regelwerke the index lacks. Here the corpus *has* the provision — it is just
older than the law in force.
The note alone is not enough. When an amendment *introduced* a rule, the model
answers "dazu gibt es keine Regelung" — true of the indexed text, false of the
law — and the note then states the rule two lines below. Both statements are
correct in their own scope, but nothing in the text marks the scope change, so
the answer reads as self-contradictory. `reconcile_negative_answer` therefore
rewrites the model's bare denial into a scoped one before the note is appended.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence
from answer_schema import SHORT_ANSWER_HEADING, is_denial, split_sections
logger = logging.getLogger(__name__)
_CACHE: Dict[str, Dict[str, Any]] = {}
def load_amendments(path: Path) -> Dict[str, Any]:
"""Read and cache one amendment file. A missing file is not an error."""
key = str(path)
if key in _CACHE:
return _CACHE[key]
data: Dict[str, Any] = {}
try:
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 - a broken notice file must not break answers.
logger.warning("Änderungsdatei nicht lesbar: %s (%s)", path, exc)
data = {}
_CACHE[key] = data
return data
def _norm(text: Any) -> str:
return " ".join(str(text or "").strip().lower().split())
def _hit_corpus(hit: Dict[str, Any]) -> str:
metadata = hit.get("metadata") or {}
return str(hit.get("corpus_id") or metadata.get("corpus_id") or "")
def _hit_container(hit: Dict[str, Any]) -> str:
metadata = hit.get("metadata") or {}
return str(hit.get("container") or hit.get("container_id") or metadata.get("container_id") or "")
def _hit_section(hit: Dict[str, Any]) -> str:
metadata = hit.get("metadata") or {}
return str(hit.get("section") or hit.get("section_id") or metadata.get("section_id") or "")
def affected_provisions(
sources: Sequence[Dict[str, Any]],
*,
amendments: Dict[str, Any],
question: str = "",
) -> List[Dict[str, Any]]:
"""Provisions that later amendments changed, by citation or by topic.
Two triggers, because one alone leaves a hole:
* **Citation** — a cited source sits in an amended provision. Matching is on
corpus + container + section, never on the § number alone: this corpus
restarts its § numbering inside every Anlage.
* **Topic** — the question names something an amendment *introduced*. Such a
rule is by definition absent from the indexed base text, so nothing can be
retrieved and nothing gets cited; without this trigger the user would be
told nothing exists when a binding rule does. The Chargendokumentation
beim "Stellen" is exactly that case: added 2024, extended to 31.12.2026.
"""
provisions = amendments.get("provisions") or []
corpus_id = _norm(amendments.get("corpus_id"))
if not provisions or not corpus_id:
return []
wanted = {(_norm(p.get("container")), _norm(p.get("section"))): p for p in provisions}
haystack = _norm(question)
seen: set = set()
out: List[Dict[str, Any]] = []
for source in sources or []:
if _norm(_hit_corpus(source)) != corpus_id:
continue
key = (_norm(_hit_container(source)), _norm(_hit_section(source)))
if key in wanted and key not in seen:
seen.add(key)
out.append(wanted[key])
if haystack:
for provision in provisions:
key = (_norm(provision.get("container")), _norm(provision.get("section")))
if key in seen:
continue
terms = [_norm(t) for t in provision.get("trigger_terms") or []]
if any(term and term in haystack for term in terms):
seen.add(key)
out.append(provision)
return out
def amendment_note(
provisions: Sequence[Dict[str, Any]],
*,
amendments: Dict[str, Any],
) -> str:
"""Spell out, per provision, what changed and what applies now."""
if not provisions:
return ""
version = amendments.get("indexed_version_label") or amendments.get("indexed_version") or ""
lines: List[str] = [
f"Hinweis zum Stand: Der indizierte Text gibt die {version} wieder. "
"Zu den folgenden Vorschriften gibt es spätere Änderungsvereinbarungen, "
"die nicht im durchsuchten Text enthalten sind:"
]
for provision in provisions:
locator = provision.get("locator") or f"{provision.get('container')} {provision.get('section')}"
lines.append("")
lines.append(f"{locator}{provision.get('status') or 'geändert'}:")
for change in provision.get("changes") or []:
amendment = change.get("amendment") or "Änderungsvereinbarung"
kind = change.get("kind") or "geändert"
lines.append(f" - {amendment} ({kind})")
verbatim = str(change.get("verbatim") or "").strip()
if verbatim:
lines.append(f" „{verbatim}“")
current = str(provision.get("current_rule") or "").strip()
if current:
lines.append(f" Aktuell gilt danach: {current}")
return "\n".join(lines)
def append_amendment_note(answer: str, note: str) -> str:
if not note:
return answer
text = (answer or "").rstrip()
return f"{text}\n\n{note}" if text else note
# ---------------------------------------------------------------------------
# Reconciling a negative answer with the amendment note
# ---------------------------------------------------------------------------
def _bridge_sentence(
provisions: Sequence[Dict[str, Any]],
*,
amendments: Dict[str, Any],
) -> str:
"""The denial, restated with the scope it actually has."""
version = amendments.get("indexed_version_label") or amendments.get("indexed_version") or ""
scope = f"In der {version}" if version else "In der indizierten Fassung"
locators: List[str] = []
for provision in provisions:
locator = provision.get("locator") or " ".join(
str(provision.get(key) or "") for key in ("container", "section")
).strip()
if locator and locator not in locators:
locators.append(locator)
lead = f"{scope} — dem hier durchsuchten Stand — ist das nicht geregelt."
if locators:
return (
f"{lead} Die Antwort ergibt sich erst aus späteren Änderungsvereinbarungen "
f"zu {', '.join(locators)}; ihr Inhalt steht unten unter „Hinweis zum Stand“."
)
return (
f"{lead} Die Antwort ergibt sich erst aus späteren Änderungsvereinbarungen; "
"ihr Inhalt steht unten unter „Hinweis zum Stand“."
)
def reconcile_negative_answer(
answer: str,
provisions: Sequence[Dict[str, Any]],
*,
path: Path,
) -> str:
"""Scope the model's "nicht geregelt" to the indexed version.
Only runs when `provisions` is non-empty, i.e. when the amendment note that
follows will state a rule the answer just denied. A genuine "not regulated"
answer — no amendment in sight — is left exactly as the model wrote it.
The leading `Kurzantwort` keeps its heading and gets the scoped statement.
Sections that say nothing but "gibt es nicht" are dropped here too; the
composer already removes them on every answer, so in the running system this
is redundant — it keeps the function correct when called on its own.
"""
if not answer or not provisions:
return answer
amendments = load_amendments(path)
if not amendments:
return answer
bridge = _bridge_sentence(provisions, amendments=amendments)
blocks = split_sections(answer)
# No schema at all — e.g. the composer's canned "keine relevante Textstelle".
if not any(heading for heading, _ in blocks):
return bridge if is_denial(answer) else answer
parts: List[str] = []
replaced = False
dropped = False
for heading, body_lines in blocks:
body = "\n".join(body_lines).strip()
if heading is None:
if body:
parts.append(body)
continue
if not is_denial(body):
parts.append(f"{heading}:\n{body}" if body else f"{heading}:")
continue
if heading == SHORT_ANSWER_HEADING:
parts.append(f"{heading}:\n{bridge}")
replaced = True
else:
dropped = True
if not replaced and not dropped:
return answer
if not replaced:
parts.insert(0, f"{SHORT_ANSWER_HEADING}:\n{bridge}")
return "\n\n".join(part for part in parts if part.strip())
def describe(
sources: Sequence[Dict[str, Any]],
*,
path: Path,
question: str = "",
) -> tuple[str, List[Dict[str, Any]]]:
"""Convenience wrapper: (note, machine-readable provisions)."""
amendments = load_amendments(path)
if not amendments:
return "", []
provisions = affected_provisions(sources, amendments=amendments, question=question)
if not provisions:
return "", []
return amendment_note(provisions, amendments=amendments), provisions