RICS / app /generator /report_audit.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
7.87 kB
"""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