gradio-project-deployment / backend /utils /report_postprocessor.py
StormShadow308's picture
Deploy RICS Gradio UI with ZeroGPU-safe generation
6143b69
Raw
History Blame Contribute Delete
20.4 kB
"""Report Post-Processing & Quality Normalisation Pipeline.
A pure, deterministic transformation pass that runs as the FINAL stage after the
RAG report generator produces a section's draft text. It removes PDF-extraction
artifacts, repairs grammar broken by redaction tokens, deduplicates stitched
content and normalises whitespace/punctuation.
HARD CONSTRAINTS (enforced by design):
* Zero ML models, zero LLM calls, zero network I/O.
* Never paraphrases or rewrites substantive survey findings — only removes
artifacts, repairs grammar around redaction tokens, and deduplicates.
* Stage order is fixed; later stages depend on earlier ones.
* Does not touch the RAG pipeline, redaction pipeline, or template ingestion.
The single public entry point is :func:`polish_report`.
"""
from __future__ import annotations
import re
from functools import lru_cache
# ── Project defaults ─────────────────────────────────────────────────────────
# The redaction layer emits both base tokens ([REDACTED_REF]) and referential
# tokens ([REDACTED_NAME_1]); some draft text contains double brackets
# ([[REDACTED_NAME_2]]). This default covers all three. Override per-call/config.
DEFAULT_PLACEHOLDER_PATTERN = r"\[{1,2}REDACTED_[A-Z]+(?:_\d+)?\]{1,2}"
DEFAULT_SECTION_SEPARATOR = "\n\n"
DEFAULT_DEDUP_THRESHOLD = 0.85
# Short, meaningful rating boilerplate (e.g. a one-line condition score) must
# never be deduplicated — only substantial repeated content is collapsed.
_MIN_DEDUP_SENTENCE_CHARS = 40
_MIN_DEDUP_PARAGRAPH_CHARS = 80
# Private-use sentinel for placeholder grammar repair (never appears in input).
_PH_SENTINEL = "\ue000"
# Visible, honest placeholder for an unresolved redaction token. Never emit the
# bare word "specified" — an operator must be able to see what needs confirming.
NEUTRAL_PLACEHOLDER = "[SURVEYOR TO CONFIRM]"
# ── Stage 1: PDF extraction artifacts ────────────────────────────────────────
_RE_PAGE_LABEL = re.compile(r"(?i)\bpage\s*\d{1,4}\b")
_RE_ORPHAN_PAGE_NUMBER = re.compile(r"(?m)^[ \t]*\d{1,4}[ \t]*$")
_RE_DEFAULT_DOC_HEADER = re.compile(
r"(?i)RICS\s+Home\s+Survey\s*[-\u2013\u2014]?\s*Level\s+\d+",
)
_RE_TOC_LINE = re.compile(r"(?im)^[ \t]*ToC\s*[:\-].*$")
# ── Stage 2: repeated section headers ────────────────────────────────────────
_RE_SECTION_HEADER = re.compile(r"^([A-Z][A-Z0-9\s\-/&]{3,}:)\s*$")
_HEADER_DEDUP_WINDOW_WORDS = 500
# ── Stage 4: sentence splitting (newline-agnostic) ───────────────────────────
_RE_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
_RE_WS = re.compile(r"\s+")
# ── Stage 5: ALL-CAPS internal directives ────────────────────────────────────
_DIRECTIVE_CUES = frozenset(
{"SEE", "REFER", "OUTLINED", "DESCRIBED", "ABOVE", "BELOW", "STATED", "NOTED"}
)
_RE_WORD_TOKEN = re.compile(r"[A-Za-z']+")
# ── Stage 6: whitespace / punctuation ────────────────────────────────────────
_RE_MULTI_NEWLINE = re.compile(r"\n{3,}")
_RE_TRAILING_SPACE = re.compile(r"[ \t]+$", re.MULTILINE)
_RE_DOUBLE_SPACE = re.compile(r"[ \t]{2,}")
_RE_HYPHEN_LINEBREAK = re.compile(r"([A-Za-z])-\n[ \t]*([a-z])")
# Rejoin a mid-sentence line break (prev char is alnum/comma/semicolon, next is
# lowercase/digit). Crosses blank-line gaps left by stripped page-break artifacts,
# but never merges across sentence-ending punctuation.
_RE_SOFT_WRAP = re.compile(r"(?<=[A-Za-z,;0-9])[ \t]*\n[ \t\n]*(?=[a-z0-9(])")
_RE_SPACE_BEFORE_PUNCT = re.compile(r"[ \t]+([,.;:!?])")
_RE_SPACE_AFTER_SENTENCE = re.compile(r"([a-z0-9])([.!?])([A-Z])")
_UNICODE_TRANSLATION = {
ord("\u201c"): '"',
ord("\u201d"): '"',
ord("\u2018"): "'",
ord("\u2019"): "'",
ord("\u2013"): "-",
ord("\u2014"): "-",
ord("\u00a0"): " ",
ord("\u2022"): "-",
}
# ── Stage 7: paragraph flow ──────────────────────────────────────────────────
_LEADING_CONJUNCTIONS = frozenset({"and", "but", "or", "nor", "so", "yet"})
_RE_LEADING_CONJUNCTION = re.compile(r"^(and|but|or|nor|so|yet)\b[ ,]*", re.IGNORECASE)
_RE_INLINE_HEADER = re.compile(r"[ \t]*\b([A-Z][A-Z0-9 \-/&]{3,}:)[ \t]*")
def _stage_1_remove_extraction_artifacts(
text: str,
extra_header_patterns: list[re.Pattern[str]] | None = None,
) -> str:
"""Remove orphaned page numbers and repeated document headers from PDF extraction."""
text = _RE_PAGE_LABEL.sub(" ", text)
text = _RE_DEFAULT_DOC_HEADER.sub(" ", text)
for pattern in extra_header_patterns or ():
text = pattern.sub(" ", text)
text = _RE_TOC_LINE.sub("", text)
text = _RE_ORPHAN_PAGE_NUMBER.sub("", text)
return text
def _stage_2_deduplicate_section_headers(text: str) -> str:
"""Keep only the first occurrence of each ALL-CAPS section header within a 500-word window."""
last_seen: dict[str, int] = {}
kept_lines: list[str] = []
word_count = 0
for line in text.split("\n"):
match = _RE_SECTION_HEADER.match(line.strip())
if match:
key = _RE_WS.sub(" ", match.group(1).strip().upper())
previous = last_seen.get(key)
if (
previous is not None
and (word_count - previous) <= _HEADER_DEDUP_WINDOW_WORDS
):
last_seen[key] = word_count
continue
last_seen[key] = word_count
word_count += len(line.split())
kept_lines.append(line)
return "\n".join(kept_lines)
@lru_cache(maxsize=16)
def _placeholder_regexes(
placeholder_pattern: str,
) -> tuple[re.Pattern[str], re.Pattern[str]]:
"""Compile (once per unique pattern) the standalone-line and token-run regexes."""
token = rf"(?:{placeholder_pattern})"
standalone = re.compile(
rf"(?m)^[ \t]*{token}(?:[ \t]*{token})*[ \t]*[.;:,\-]?[ \t]*$\n?"
)
run = re.compile(rf"{token}(?:\s*{token})*")
return standalone, run
# Sentinel-stage regexes (independent of the configurable token pattern).
_RE_PH_POSSESSIVE = re.compile(rf"{_PH_SENTINEL}'s\b")
_RE_PH_SENTENCE_INITIAL = re.compile(
rf"(\A|[.!?][\"')\]]?\s+|\n[ \t]*){_PH_SENTINEL}[ \t]*(\w)"
)
_RE_PLACEHOLDER_DUP = re.compile(
r"\[SURVEYOR TO CONFIRM\](?:\s+\[SURVEYOR TO CONFIRM\])+"
)
_RE_ARTICLE_DUP = re.compile(r"\b(the|a|an)\s+(the|a|an)\b", re.IGNORECASE)
# ── Dangling redaction-date artifacts ────────────────────────────────────────
# Fixed safety/regulatory boilerplate embeds a date/reference that PII redaction
# sometimes over-masks, leaving "...work undertaken after [SURVEYOR TO CONFIRM]
# should..." or a trailing "...installed in [SURVEYOR TO CONFIRM].". Dropping the
# temporal/locative-date clause yields clean professional prose without inventing
# a value or losing a finding. A placeholder standing for a material, location or
# other finding is deliberately left visible for the surveyor to confirm.
_PH_LITERAL = re.escape(NEUTRAL_PLACEHOLDER)
_RE_PH_DATED_BEFORE_MODAL = re.compile(
r"\b(?:undertaken|carried out|completed|performed|installed|fitted|constructed|built)\s+"
r"(?:after|before|since|from|in|on|around|circa|prior to|up to)\s+"
rf"{_PH_LITERAL}\s+"
r"(?=(?:should|must|shall|will|would|may|might|can|are|is|was|were|be)\b)",
re.IGNORECASE,
)
# Trailing rule is restricted to unambiguously TEMPORAL prepositions — "of", "in",
# "on", "from" can introduce a redacted name or location, which must stay visible.
_RE_PH_TRAILING_DATE = re.compile(
r"\s*,?\s*(?:after|before|since|dated|around|circa|prior to|up to)\s+"
rf"{_PH_LITERAL}(?=\s*[.;:,)])",
re.IGNORECASE,
)
def neutralise_dangling_placeholders(text: str) -> str:
"""Drop a temporal/date clause whose only value is an unresolved placeholder.
Targets the recurring redaction-date artifact in fixed safety/regulatory
boilerplate. Never removes a placeholder that stands for a material, location
or finding — that stays visible for surveyor confirmation.
"""
if NEUTRAL_PLACEHOLDER not in text:
return text
text = _RE_PH_DATED_BEFORE_MODAL.sub("", text)
text = _RE_PH_TRAILING_DATE.sub("", text)
return text
def _stage_3_normalise_placeholder_grammar(text: str, placeholder_pattern: str) -> str:
"""Repair grammatical breakage caused by inline redaction tokens.
Tokens are replaced with neutral language; the redacted value is never
revealed or approximated.
"""
standalone_re, run_re = _placeholder_regexes(placeholder_pattern)
# 1. Standalone token(s) on their own line → drop the entire line.
text = standalone_re.sub("", text)
# 2. Collapse any run of (adjacent) tokens to a single sentinel.
text = run_re.sub(_PH_SENTINEL, text)
# 3. Drop a possessive clitic clinging to a sentinel ([REDACTED]'s → sentinel).
text = _RE_PH_POSSESSIVE.sub(_PH_SENTINEL, text)
# 4. Sentence-initial sentinel → drop token, capitalise the following word.
text = _RE_PH_SENTENCE_INITIAL.sub(lambda m: m.group(1) + m.group(2).upper(), text)
# 5. Remaining mid-sentence sentinels → explicit surveyor placeholder
# (never the bare word "specified", which reads as real content).
text = text.replace(_PH_SENTINEL, NEUTRAL_PLACEHOLDER)
# 6. Tidy artefacts of the substitution.
text = _RE_PLACEHOLDER_DUP.sub(NEUTRAL_PLACEHOLDER, text)
text = _RE_ARTICLE_DUP.sub(r"\1", text)
text = neutralise_dangling_placeholders(text)
return text
def _normalise_for_compare(text: str) -> str:
return _RE_WS.sub(" ", text.lower()).strip()
def _trigrams(text: str) -> set[str]:
return {text[i : i + 3] for i in range(len(text) - 2)}
def _jaccard_similarity(a: str, b: str) -> float:
"""Character-trigram Jaccard similarity — fast, zero dependencies."""
t_a, t_b = _trigrams(a.lower()), _trigrams(b.lower())
if not t_a or not t_b:
return 0.0
return len(t_a & t_b) / len(t_a | t_b)
def _stage_4_deduplicate_content(
text: str, similarity_threshold: float = DEFAULT_DEDUP_THRESHOLD
) -> str:
"""Remove duplicate sentences (exact) and near-duplicate paragraphs (Jaccard).
Short boilerplate below ``_MIN_DEDUP_SENTENCE_CHARS`` (e.g. condition-rating
tags) is preserved verbatim. Pure string comparison only — no ML.
"""
paragraphs = re.split(r"\n{2,}", text)
seen_sentences: set[str] = set()
kept_paragraphs: list[str] = []
kept_norms: list[str] = []
for paragraph in paragraphs:
sentences = _RE_SENTENCE_SPLIT.split(paragraph.strip())
deduped: list[str] = []
prev_norm: str | None = None
for sentence in sentences:
norm = _normalise_for_compare(sentence)
# Collapse immediately-adjacent identical sentences (stitching noise),
# regardless of length — non-adjacent short repeats are preserved.
if norm and norm == prev_norm:
continue
if len(norm) >= _MIN_DEDUP_SENTENCE_CHARS:
if norm in seen_sentences:
prev_norm = norm
continue
seen_sentences.add(norm)
if sentence.strip():
deduped.append(sentence.strip())
prev_norm = norm
rebuilt = " ".join(deduped)
if not rebuilt.strip():
continue
norm_paragraph = _normalise_for_compare(rebuilt)
if len(norm_paragraph) >= _MIN_DEDUP_PARAGRAPH_CHARS and any(
_jaccard_similarity(norm_paragraph, prior) >= similarity_threshold
for prior in kept_norms
):
continue
kept_paragraphs.append(rebuilt)
kept_norms.append(norm_paragraph)
return "\n\n".join(kept_paragraphs)
def _is_caps_directive(sentence: str) -> bool:
"""True when a sentence is an ALL-CAPS internal directive (not client-facing)."""
tokens = _RE_WORD_TOKEN.findall(sentence)
alpha = [t for t in tokens if any(c.isalpha() for c in t)]
if len(alpha) < 2:
return False
upper = [t for t in alpha if len(t) >= 2 and t.upper() == t]
if len(upper) / len(alpha) <= 0.6:
return False
return bool({t.upper() for t in tokens} & _DIRECTIVE_CUES)
def _stage_5_scrub_internal_directives(text: str) -> str:
"""Remove ALL-CAPS procedural directives (e.g. SEE THE LIMITATIONS ... ABOVE)."""
paragraphs = re.split(r"\n{2,}", text)
out: list[str] = []
for paragraph in paragraphs:
sentences = _RE_SENTENCE_SPLIT.split(paragraph)
kept = [s for s in sentences if not _is_caps_directive(s)]
rebuilt = " ".join(s.strip() for s in kept if s.strip())
if rebuilt.strip():
out.append(rebuilt)
return "\n\n".join(out)
def _stage_6_normalise_whitespace_and_punctuation(text: str) -> str:
"""Normalise unicode, rejoin soft-wrapped lines, and tidy spacing/newlines."""
text = text.translate(_UNICODE_TRANSLATION)
text = _RE_HYPHEN_LINEBREAK.sub(r"\1\2", text) # de-hyphenate broken words
text = _RE_SOFT_WRAP.sub(" ", text) # rejoin mid-sentence line wraps
text = _RE_TRAILING_SPACE.sub("", text)
text = _RE_DOUBLE_SPACE.sub(" ", text)
text = _RE_SPACE_BEFORE_PUNCT.sub(r"\1", text)
text = _RE_SPACE_AFTER_SENTENCE.sub(r"\1\2 \3", text)
text = _RE_MULTI_NEWLINE.sub("\n\n", text)
return text.strip()
def _repair_paragraph(paragraph: str) -> str:
stripped = paragraph.strip()
if not stripped:
return ""
# Drop a coordinating conjunction orphaned from a stripped predecessor sentence.
first_word = stripped.split(maxsplit=1)[0].lower().strip(",")
if first_word in _LEADING_CONJUNCTIONS and not stripped[0].isupper():
stripped = _RE_LEADING_CONJUNCTION.sub("", stripped, count=1).lstrip()
if not stripped:
return ""
# Capitalise the first alphabetic character.
if stripped[0].islower():
stripped = stripped[0].upper() + stripped[1:]
# Ensure terminal punctuation (headers ending ':' are left alone).
if stripped[-1] not in ".!?:":
stripped = f"{stripped}."
return stripped
def _stage_7_repair_paragraph_flow(text: str) -> str:
"""Capitalise paragraph starts, drop orphan leading conjunctions, ensure terminal stops."""
# Lift inline ALL-CAPS headers onto their own line for readability.
text = _RE_INLINE_HEADER.sub(lambda m: f"\n\n{m.group(1)}\n", text)
text = _RE_MULTI_NEWLINE.sub("\n\n", text)
paragraphs = re.split(r"\n{2,}", text)
repaired = [_repair_paragraph(p) for p in paragraphs]
return "\n\n".join(p for p in repaired if p)
def _resolve_defaults(
placeholder_pattern: str | None,
dedup_similarity_threshold: float | None,
extra_header_patterns: list[re.Pattern[str]] | list[str] | None,
debug: bool | None,
) -> tuple[str, float, list[re.Pattern[str]], bool]:
"""Pull unset arguments from project config, with hard-coded fallbacks."""
cfg_pattern = DEFAULT_PLACEHOLDER_PATTERN
cfg_threshold = DEFAULT_DEDUP_THRESHOLD
cfg_extra: list[str] = []
cfg_debug = False
try: # config is optional so the module stays standalone-importable/testable.
from backend.config import settings
cfg_pattern = getattr(settings, "postprocess_placeholder_pattern", cfg_pattern)
cfg_threshold = getattr(settings, "dedup_similarity_threshold", cfg_threshold)
cfg_extra = list(getattr(settings, "extra_header_patterns", []) or [])
cfg_debug = bool(getattr(settings, "postprocess_debug", False))
except Exception: # noqa: BLE001 — never let config import break a pure transform.
pass
pattern = placeholder_pattern if placeholder_pattern is not None else cfg_pattern
threshold = (
dedup_similarity_threshold
if dedup_similarity_threshold is not None
else cfg_threshold
)
raw_extra = (
extra_header_patterns if extra_header_patterns is not None else cfg_extra
)
compiled_extra: list[re.Pattern[str]] = []
for item in raw_extra:
compiled_extra.append(
item if isinstance(item, re.Pattern) else re.compile(item, re.IGNORECASE)
)
return (
pattern,
threshold,
compiled_extra,
(debug if debug is not None else cfg_debug),
)
def polish_report(
raw_report: str,
placeholder_pattern: str | None = None,
section_separator: str = DEFAULT_SECTION_SEPARATOR,
extra_header_patterns: list[re.Pattern[str]] | list[str] | None = None,
dedup_similarity_threshold: float | None = None,
debug: bool | None = None,
) -> str:
"""Apply a sequential, non-destructive post-processing pipeline to a draft report.
Stages (in strict order):
1. PDF extraction artifact removal
2. Repeated section header suppression
3. Placeholder grammar normalisation
4. Duplicate sentence / paragraph deduplication
5. ALL-CAPS directive scrubbing
6. Whitespace and punctuation normalisation
7. Paragraph-level flow repair
Args:
raw_report: The full draft report string from the RAG pipeline.
placeholder_pattern: Regex matching redacted tokens. Defaults to the
project pattern (config ``postprocess_placeholder_pattern``), which
covers ``[REDACTED_NAME_1]``, ``[REDACTED_REF]`` and ``[[...]]``.
section_separator: Delimiter used to join the polished section blocks.
extra_header_patterns: Additional firm-specific header patterns to strip
(strings or compiled patterns). Falls back to config
``extra_header_patterns``; keeps the pipeline firm-agnostic.
dedup_similarity_threshold: 0.0-1.0 character-trigram Jaccard threshold
for near-duplicate paragraph removal (config ``dedup_similarity_threshold``).
debug: When true, print a before/after preview to stdout.
Returns:
A clean, publication-ready report string.
Raises:
ValueError: If ``raw_report`` is empty or None.
"""
if not raw_report or not raw_report.strip():
raise ValueError("raw_report cannot be empty.")
pattern, threshold, compiled_extra, debug_on = _resolve_defaults(
placeholder_pattern, dedup_similarity_threshold, extra_header_patterns, debug
)
text = raw_report
text = _stage_1_remove_extraction_artifacts(text, compiled_extra)
text = _stage_2_deduplicate_section_headers(text)
text = _stage_3_normalise_placeholder_grammar(text, pattern)
text = _stage_4_deduplicate_content(text, threshold)
text = _stage_5_scrub_internal_directives(text)
text = _stage_6_normalise_whitespace_and_punctuation(text)
text = _stage_7_repair_paragraph_flow(text)
result = section_separator.join(
block for block in re.split(r"\n{2,}", text) if block.strip()
).strip()
if debug_on:
print("=" * 72)
print("polish_report — BEFORE:\n")
print(raw_report[:1200])
print("\n" + "-" * 72)
print("polish_report — AFTER:\n")
print(result[:1200])
print("=" * 72)
return result
if __name__ == "__main__": # pragma: no cover — manual before/after demo
_SAMPLE = (
"[REDACTED_NAME_1] repairs tend to be expensive due to the associated "
"scaffolding costs. SEE THE LIMITATIONS OF OUR INSPECTION ABOVE. MAIN ROOF:\n"
"The main roof structure is of hipped pitch and [[REDACTED_NAME_2]] "
"construction, partially extended to form\ndormers. The pitched roof "
"covering appears to be of plain concrete tiles. However, some areas were "
"concealed from view due to the loft\n2\nRICS Home Survey - Level 3\n26\n"
"conversion. The element rating is 2. The element rating is 2."
)
polish_report(_SAMPLE, debug=True)