| """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 |
|
|
| |
| |
| SECTION_HEADINGS: Tuple[str, ...] = ( |
| "Kurzantwort", |
| "Maßgebliche Norm", |
| "Maßgebliche Norm(en)", |
| "Wortlaut / Kriterien", |
| "Wortlaut", |
| "Einordnung", |
| "Ausnahmen", |
| "Ergebnis", |
| "Heilungen", |
| "Retaxationsgrenzen", |
| ) |
|
|
| |
| SHORT_ANSWER_HEADING = "Kurzantwort" |
|
|
| _HEADING_PREFIX_RE = re.compile(r"^#+\s*") |
| _SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") |
|
|
| |
| |
| _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) |
|
|