File size: 13,320 Bytes
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
"""Notes-coverage guard — ensures generated section text preserves the facts
in the inspector's RAW NOTES.

The pipeline already enforces *non-invention* (output cannot add facts that
aren't in notes/RAG). This module enforces the *converse*: output must
**preserve** the facts that ARE in the notes. Without it, the LLM happily
compresses 600 words of dense field notes into a 140-word tier-target
paragraph and silently drops half the observations — the exact failure
mode the user reports.

We tokenise each bullet into "fact tokens" (numbers + multi-word noun
phrases + low-frequency content terms), compute the overlap ratio against
the generated text, and surface any bullet whose key tokens never appear
in the output. The caller (``_generate_section_text`` /
``run_inspector_tool_loop``) uses ``coverage_report`` to decide whether to
regenerate with a stricter hint listing the missing facts.

This module is deliberately deterministic (no LLM, no embeddings). Coverage
checking inside a regenerate loop must be fast and cheap, otherwise the
"regenerate up to 3 times" knob doubles latency for every section.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Tokenisation
# ---------------------------------------------------------------------------

# Words ignored when extracting fact tokens — these are not factual content.
# Kept intentionally narrow; we WANT "damp", "crack", "mould", "boiler" etc.
# to be treated as facts. The list contains pure function words plus a few
# RICS-grade fillers that appear in nearly every bullet and would otherwise
# inflate the false-coverage score.
_STOPWORDS: frozenset[str] = frozenset(
    {
        "a", "an", "the", "and", "or", "but", "if", "then", "of", "to", "in",
        "on", "at", "by", "for", "with", "from", "into", "onto", "as", "is",
        "are", "was", "were", "be", "been", "being", "this", "that", "these",
        "those", "it", "its", "their", "them", "they", "we", "our", "you",
        "your", "i", "me", "my", "any", "some", "all", "no", "not", "so",
        "do", "does", "did", "has", "have", "had", "will", "would", "should",
        "may", "might", "can", "could", "must", "shall", "also", "very", "more",
        "less", "much", "many", "few", "such", "etc", "ie", "eg", "vs",
        "than", "then", "now", "yet", "still", "just", "only", "even", "ever",
        "ok", "okay", "fine", "good", "bad",
    }
)

# Compass / location modifiers — we want these to count as facts so dropping
# "north elevation" actually triggers coverage failure. Hence they are NOT in
# _STOPWORDS even though they're short.

# Token splitter: words (incl. apostrophes/hyphens), numbers with optional
# units, postcodes.
_TOKEN_RE = re.compile(
    r"""
    (?P<postcode>[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})    # UK postcode
    |
    (?P<money>£\s*\d[\d,]*(?:\.\d+)?)                  # money
    |
    (?P<number>
        \d[\d,]*(?:\.\d+)?                             # base number
        (?:\s*(?:sq\s*m|sqm|m²|sq\s*ft|sqft|mm|cm|m|km|ft|in|%|kg|kw|hrs?|hr|yrs?|yr|years?|months?|bedrooms?|bedroom|beds?|bathrooms?|baths?|storeys?|stories?|floors?))?
    )
    |
    (?P<word>[A-Za-z][A-Za-z'\-]{1,})                  # word (>=2 chars after lead letter, so "a" is 1 char and skipped)
    """,
    re.VERBOSE | re.IGNORECASE,
)


def _tokenise(text: str) -> list[str]:
    """Lowercased token stream from arbitrary text."""
    out: list[str] = []
    if not text:
        return out
    for m in _TOKEN_RE.finditer(text):
        if m.group("postcode"):
            out.append(re.sub(r"\s+", "", m.group("postcode")).upper())
        elif m.group("money"):
            out.append(re.sub(r"\s+", "", m.group("money")).lower())
        elif m.group("number"):
            out.append(re.sub(r"\s+", "", m.group("number")).lower())
        else:
            w = m.group("word").strip().lower()
            if w:
                out.append(w)
    return out


def _is_factish(token: str) -> bool:
    """Return True if ``token`` carries factual information worth tracking.

    A token is fact-bearing if it is:
      - a number, money, or postcode (has a digit or starts with £); OR
      - a content word that isn't a stopword and is at least 3 chars.

    We deliberately keep "damp", "crack", "leak", "rust" etc. (4-5 chars)
    because dropping them is a meaningful detail loss.
    """
    if not token:
        return False
    if any(ch.isdigit() for ch in token) or token.startswith("£"):
        return True
    if token in _STOPWORDS:
        return False
    if len(token) < 3:
        return False
    return True


@dataclass(frozen=True, slots=True)
class BulletFacts:
    """Per-bullet fact summary used by the coverage check."""

    bullet: str
    tokens: tuple[str, ...]
    key_tokens: tuple[str, ...]


def extract_bullet_facts(bullets: list[str]) -> list[BulletFacts]:
    """Tokenise each bullet into a ``BulletFacts`` summary.

    ``key_tokens`` is the deduplicated subset of ``tokens`` that are
    fact-bearing (per ``_is_factish``). When ``key_tokens`` is empty (the
    bullet is purely stopwords / very short), it is dropped from the
    returned list because there is nothing to verify coverage for.
    """
    out: list[BulletFacts] = []
    for raw in bullets or []:
        b = str(raw or "").strip()
        if not b:
            continue
        toks = tuple(_tokenise(b))
        keys: list[str] = []
        seen: set[str] = set()
        for t in toks:
            if not _is_factish(t):
                continue
            if t in seen:
                continue
            seen.add(t)
            keys.append(t)
        if not keys:
            continue
        out.append(BulletFacts(bullet=b, tokens=toks, key_tokens=tuple(keys)))
    return out


# ---------------------------------------------------------------------------
# Coverage scoring
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class BulletCoverage:
    """How well a single bullet's facts appear in the generated text."""

    bullet: str
    coverage_ratio: float
    matched_tokens: tuple[str, ...]
    missing_tokens: tuple[str, ...]


@dataclass(frozen=True, slots=True)
class CoverageReport:
    """Aggregate coverage summary across all bullets."""

    coverage_ratio: float
    bullets_total: int
    bullets_well_covered: int
    bullets_poorly_covered: int
    poor_bullets: tuple[BulletCoverage, ...]
    missing_tokens: tuple[str, ...] = field(default_factory=tuple)

    @property
    def needs_regenerate(self) -> bool:
        """Default threshold: regenerate when ratio < 0.70 OR ≥3 poor bullets."""
        return self.coverage_ratio < 0.70 or self.bullets_poorly_covered >= 3


_OUTPUT_TOKEN_CACHE_LIMIT = 16


def _output_token_set(text: str) -> set[str]:
    """Build a set of normalised tokens from the generated text.

    Numbers / money / postcodes match exactly (after whitespace
    normalisation). Words match case-insensitively. We also collapse
    common derivational endings so "cracked" / "cracking" / "cracks"
    all hit the same bucket as "crack" — otherwise the LLM's perfectly
    valid rewording of "minor cracking to the rear" looks like a missing
    fact for the bullet that said "cracks at rear".
    """
    tokens = set(_tokenise(text))
    expanded: set[str] = set()
    for t in tokens:
        expanded.add(t)
        if any(ch.isdigit() for ch in t) or t.startswith("£"):
            continue
        for suf in ("ing", "ed", "es", "s", "ly"):
            if t.endswith(suf) and len(t) > len(suf) + 2:
                expanded.add(t[: -len(suf)])
        # Stem singular/plural roughly: "houses" -> "house"
        if t.endswith("y") and len(t) > 3:
            expanded.add(t[:-1] + "ies")
    return expanded


def _bullet_coverage(
    facts: BulletFacts, output_tokens: set[str]
) -> BulletCoverage:
    matched: list[str] = []
    missing: list[str] = []
    for k in facts.key_tokens:
        if k in output_tokens:
            matched.append(k)
            continue
        # Loosen for stem-collisions: try stripping common suffixes from the
        # bullet token too. The LLM may write "crack" when bullet says
        # "cracks", which the output_token expansion already covers, but
        # we also reverse-stem in case the LLM uses an even shorter root.
        hit = False
        if not any(ch.isdigit() for ch in k):
            for suf in ("ing", "ed", "es", "s", "ly"):
                if k.endswith(suf) and len(k) > len(suf) + 2:
                    root = k[: -len(suf)]
                    if root in output_tokens:
                        matched.append(k)
                        hit = True
                        break
        if not hit:
            missing.append(k)
    total = len(facts.key_tokens) or 1
    ratio = len(matched) / total
    return BulletCoverage(
        bullet=facts.bullet,
        coverage_ratio=ratio,
        matched_tokens=tuple(matched),
        missing_tokens=tuple(missing),
    )


def coverage_report(
    bullets: list[str],
    text: str,
    *,
    well_covered_threshold: float = 0.6,
    poor_bullet_threshold: float = 0.34,
    extra_trusted_text: list[str] | None = None,
) -> CoverageReport:
    """Compute coverage of ``bullets`` inside the generated ``text``.

    ``extra_trusted_text`` lets the caller mark additional strings (e.g.
    other sections of the same report) as "already covered" so a fact
    that legitimately belongs to another section isn't double-counted as
    missing here.

    Thresholds:
      * ``well_covered_threshold``  — a bullet is "well covered" when its
        key-token coverage ratio is at least this value.
      * ``poor_bullet_threshold``   — a bullet is "poorly covered" when
        its ratio is *below* this value. Bullets between the two
        thresholds are partial — neither well nor poor.
    """
    facts = extract_bullet_facts(bullets)
    if not facts:
        return CoverageReport(
            coverage_ratio=1.0,
            bullets_total=0,
            bullets_well_covered=0,
            bullets_poorly_covered=0,
            poor_bullets=(),
            missing_tokens=(),
        )
    output_tokens = _output_token_set(text or "")
    if extra_trusted_text:
        for s in extra_trusted_text:
            output_tokens |= _output_token_set(s or "")
    well = 0
    poor: list[BulletCoverage] = []
    ratios: list[float] = []
    missing_aggregate: list[str] = []
    seen_missing: set[str] = set()
    for f in facts:
        bc = _bullet_coverage(f, output_tokens)
        ratios.append(bc.coverage_ratio)
        if bc.coverage_ratio >= well_covered_threshold:
            well += 1
        if bc.coverage_ratio < poor_bullet_threshold:
            poor.append(bc)
        for m in bc.missing_tokens:
            if m in seen_missing:
                continue
            seen_missing.add(m)
            missing_aggregate.append(m)
    overall = sum(ratios) / len(ratios) if ratios else 1.0
    return CoverageReport(
        coverage_ratio=overall,
        bullets_total=len(facts),
        bullets_well_covered=well,
        bullets_poorly_covered=len(poor),
        poor_bullets=tuple(poor),
        missing_tokens=tuple(missing_aggregate[:120]),
    )


def coverage_regenerate_hint(report: CoverageReport, *, max_bullets: int = 6) -> str:
    """Render a strict regenerate hint listing the dropped facts/bullets.

    The hint is meant to be appended to ``creativity_hint`` for the
    one-more-attempt path. We list the most poorly covered bullets first,
    plus a flat list of missing tokens, so the LLM has both shapes to
    re-anchor against.
    """
    if not report.poor_bullets and not report.missing_tokens:
        return ""
    lines: list[str] = [
        "RAW NOTES COVERAGE VIOLATION — your previous draft dropped observations.",
        (
            "You MUST regenerate so every observation from the RAW NOTES surfaces in the output. "
            "Do not summarise away facts. Do not compress two bullets into a single generic clause. "
            "If a fact is genuinely off-topic for this section, leave a one-line professional "
            "acknowledgment rather than dropping it silently."
        ),
        f"Coverage measured: {int(round(report.coverage_ratio * 100))}% "
        f"(target ≥ 70%; {report.bullets_poorly_covered} of {report.bullets_total} bullets poorly covered).",
    ]
    if report.poor_bullets:
        lines.append("POORLY COVERED BULLETS (re-anchor every one of these):")
        for i, bc in enumerate(report.poor_bullets[:max_bullets], 1):
            preview = bc.bullet.strip().replace("\n", " ")
            if len(preview) > 260:
                preview = preview[:259] + "…"
            missing_preview = ", ".join(bc.missing_tokens[:8])
            lines.append(f"  {i}. \"{preview}\" — missing tokens: {missing_preview}")
    if report.missing_tokens:
        flat = ", ".join(report.missing_tokens[:24])
        lines.append(f"DROPPED FACT TOKENS (must appear in the regenerated text): {flat}")
    return "\n".join(lines)