| """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: |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| 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 |
|
|