Spaces:
Runtime error
Runtime error
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)
|