File size: 3,805 Bytes
d80690d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | """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)
|