"""The answer schema: its headings, and what an empty section looks like. `_DOCUMENT_TASK_PROMPT` prescribes a fixed set of section headings and tells the model to leave out any section it has no evidence for. Models keep the headings and ignore the omission rule: instead of dropping a section they fill it with "Es gibt keine ... in den bereitgestellten Quellen". A denial therefore arrives stated four times over, once per heading. Two places have to recognise that filler — `answer_composer`, which enforces the schema, and `corpus_amendments`, which rewrites a denial once it knows a later amendment does regulate the point. The recognition lives here so neither owns it and the two cannot drift apart. This module deliberately has no imports beyond the standard library. """ from __future__ import annotations import re from typing import List, Optional, Tuple # The current schema plus labels from earlier versions of it. Recognising a # stale label costs nothing and lets an old cached answer be cleaned up too. SECTION_HEADINGS: Tuple[str, ...] = ( "Kurzantwort", "Maßgebliche Norm", "Maßgebliche Norm(en)", "Wortlaut / Kriterien", "Wortlaut", "Einordnung", "Ausnahmen", "Ergebnis", "Heilungen", "Retaxationsgrenzen", ) # The one section that is never dropped: it *is* the answer, negative or not. SHORT_ANSWER_HEADING = "Kurzantwort" _HEADING_PREFIX_RE = re.compile(r"^#+\s*") _SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") # Deliberately narrow. This must match the "nothing found" filler the schema # produces and nothing else — a false positive deletes real legal content. _DENIAL_RE = re.compile( r"es\s+gibt\s+kein" r"|enthalten\s+(?:hierzu|dazu)\s+kein" r"|liegt\s+(?:hierzu|dazu)?\s*kein" r"|kein\w*\s+(?:relevante[nrs]?\s+)?" r"(?:regelung|norm|vorschrift|textstelle|aussage|angabe|einordnung|information)" r"|nichts?\s+(?:geregelt|enthalten|auffindbar|ersichtlich)" r"|belastbare?\s+juristische\s+antwort", re.I, ) def heading_key(line: str) -> Optional[str]: """The schema heading a line represents, or None. Tolerates a markdown prefix and a missing colon, both of which models produce despite the prompt asking for plain text. """ text = _HEADING_PREFIX_RE.sub("", (line or "").strip()) if text.endswith(":"): text = text[:-1] text = re.sub(r"\s+", " ", re.sub(r"\s*/\s*", " / ", text.strip())) if not text: return None for heading in SECTION_HEADINGS: if text.lower() == heading.lower(): return heading return None def split_sections(answer: str) -> List[Tuple[Optional[str], List[str]]]: """Split an answer into (heading | None, body lines) blocks, in order. The first block carries `None` as its heading and holds whatever preceded the first one — usually nothing, but never assume that. """ blocks: List[Tuple[Optional[str], List[str]]] = [] heading: Optional[str] = None body: List[str] = [] for line in (answer or "").split("\n"): found = heading_key(line) if found is None: body.append(line) continue blocks.append((heading, body)) heading, body = found, [] blocks.append((heading, body)) return blocks def is_denial(body: str) -> bool: """True if every sentence of `body` merely states that nothing was found. A single § anywhere disqualifies the body: the model only names a provision when it found one, whatever it then claims about it. """ text = (body or "").strip() if not text or "§" in text: return False sentences = [s for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()] return bool(sentences) and all(_DENIAL_RE.search(s) for s in sentences)