"""Post-generation report auditing (non-destructive). These helpers run AFTER all sections are generated. They never rewrite or move prose between sections (auto-relocation can splice sentences mid-context and corrupt otherwise-correct output). Instead they: - aggregate the TRUE AI involvement (average of measured per-section values) and flag sections that exceeded a high-involvement threshold (RC7); - FLAG content that appears misplaced relative to its section's scope, naming the correct destination, without moving it (RC5, non-destructive variant); - check coverage of source notes and report claims not surfaced anywhere (RC6); - write a JSON audit log for traceability. """ from __future__ import annotations import json import logging import re from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any logger = logging.getLogger(__name__) HIGH_INVOLVEMENT_THRESHOLD = 70 # Keyword → correct RICS destination for content that commonly lands in the # wrong section. Used for NON-DESTRUCTIVE flagging only. _MISPLACEMENT_RULES: tuple[tuple[str, re.Pattern[str], str], ...] = ( ("Japanese knotweed", re.compile(r"\bjapanese\s+knotweed\b|\bknotweed\b", re.I), "I3"), ("Tree preservation order", re.compile(r"\btpo\b|tree\s+preservation", re.I), "I3"), ("Asbestos", re.compile(r"\basbestos\b", re.I), "I3"), ("Bathroom/mould", re.compile(r"\b(?:bathroom|mould|mold)\b", re.I), "F8"), ("Legal/solicitor", re.compile(r"\bsolicitor\b|\bconveyanc", re.I), "I1\u2013I3"), ) # Sections where a given keyword is legitimately in-scope (so we don't flag it). _MISPLACEMENT_ALLOWED: dict[str, frozenset[str]] = { "Japanese knotweed": frozenset({"I2", "I3"}), "Tree preservation order": frozenset({"I2", "I3", "H3"}), "Asbestos": frozenset({"I3", "J4", "J"}), "Bathroom/mould": frozenset({"F8"}), "Legal/solicitor": frozenset({"I1", "I2", "I3", "D"}), } @dataclass(slots=True) class MisplacementFlag: section_code: str topic: str suggested_section: str excerpt: str @dataclass(slots=True) class CoverageGap: claim: str suggested_section: str | None = None @dataclass(slots=True) class InvolvementSummary: average_percent: int high_sections: list[tuple[str, int]] = field(default_factory=list) def aggregate_ai_involvement( per_section: dict[str, int | None], *, threshold: int = HIGH_INVOLVEMENT_THRESHOLD, ) -> InvolvementSummary: """Average measured per-section involvement; list sections over ``threshold``. ``per_section`` maps section_code → measured AI involvement percent (or None when the section was not generated / has no measurement). """ vals = [int(v) for v in per_section.values() if v is not None] avg = round(sum(vals) / len(vals)) if vals else 0 high = sorted( ((code, int(v)) for code, v in per_section.items() if v is not None and int(v) > threshold), key=lambda kv: kv[1], reverse=True, ) return InvolvementSummary(average_percent=avg, high_sections=high) def detect_misplaced_content(section_texts: dict[str, str]) -> list[MisplacementFlag]: """Flag (do NOT move) content that belongs in a different section.""" flags: list[MisplacementFlag] = [] for code, text in section_texts.items(): if not text: continue cu = code.strip().upper() for topic, pattern, dest in _MISPLACEMENT_RULES: allowed = _MISPLACEMENT_ALLOWED.get(topic, frozenset()) if cu in allowed or any(cu.startswith(a) for a in allowed): continue m = pattern.search(text) if m: start = max(0, m.start() - 40) end = min(len(text), m.end() + 40) excerpt = text[start:end].strip().replace("\n", " ") flags.append( MisplacementFlag( section_code=code, topic=topic, suggested_section=dest, excerpt=excerpt, ) ) return flags _CLAIM_SPLIT_RE = re.compile(r"[.\n;]+") _POSITIVE_RE = re.compile( r"\b(okay|ok|fine|functional|operational|working|lit|connected|" r"no significant defects|satisfactory|average condition|good condition)\b", re.I, ) _STOP = frozenset( { "the", "a", "an", "is", "are", "was", "were", "to", "of", "and", "in", "on", "with", "for", "no", "it", "this", "that", "be", "as", "at", } ) def _content_words(s: str) -> set[str]: return {w for w in re.findall(r"[a-z0-9]+", s.lower()) if w not in _STOP and len(w) > 2} def coverage_check( raw_notes: list[str], section_texts: dict[str, str], *, overlap_threshold: float = 0.5, ) -> list[CoverageGap]: """Return source claims that do not appear (even paraphrased) in any section. A claim is considered covered when at least ``overlap_threshold`` of its content words appear together in some generated section. Positive/short confirmations ("taps are functional") are exactly the claims that get silently dropped, so they are reported here. """ if not raw_notes: return [] all_text_words = [_content_words(t) for t in section_texts.values() if t] gaps: list[CoverageGap] = [] seen: set[str] = set() for note in raw_notes: for clause in _CLAIM_SPLIT_RE.split(note or ""): clause = clause.strip() if len(clause) < 4: continue cw = _content_words(clause) if not cw: continue key = " ".join(sorted(cw)) if key in seen: continue seen.add(key) covered = any( len(cw & tw) / len(cw) >= overlap_threshold for tw in all_text_words ) if not covered: gaps.append(CoverageGap(claim=clause)) return gaps def write_audit_log( report_id: str, *, involvement: InvolvementSummary | None = None, misplacement_flags: list[MisplacementFlag] | None = None, coverage_gaps: list[CoverageGap] | None = None, extra: dict[str, Any] | None = None, log_dir: str | Path = "logs", ) -> Path | None: """Write a JSON audit log to ``logs/audit_{report_id}.json`` (best-effort).""" try: d = Path(log_dir) d.mkdir(parents=True, exist_ok=True) payload: dict[str, Any] = { "report_id": report_id, "generated_at": datetime.now(timezone.utc).isoformat(), } if involvement is not None: payload["ai_involvement"] = { "average_percent": involvement.average_percent, "high_sections": [ {"section": c, "percent": p} for c, p in involvement.high_sections ], } if misplacement_flags: payload["misplaced_content"] = [ { "section": f.section_code, "topic": f.topic, "suggested_section": f.suggested_section, "excerpt": f.excerpt, } for f in misplacement_flags ] if coverage_gaps: payload["coverage_gaps"] = [ {"claim": g.claim, "suggested_section": g.suggested_section} for g in coverage_gaps ] if extra: payload.update(extra) out = d / f"audit_{report_id}.json" out.write_text(json.dumps(payload, indent=2), encoding="utf-8") return out except Exception: # noqa: BLE001 — auditing must never break export logger.warning("write_audit_log failed for report=%s", report_id, exc_info=True) return None