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