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)