Spaces:
Runtime error
Runtime error
| """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"}), | |
| } | |
| class MisplacementFlag: | |
| section_code: str | |
| topic: str | |
| suggested_section: str | |
| excerpt: str | |
| class CoverageGap: | |
| claim: str | |
| suggested_section: str | None = None | |
| 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 | |