RICS / backend /core /reference_chunker.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
7.5 kB
"""Split uploaded past-report text into section-tagged paragraphs for REFERENCE RAG."""
from __future__ import annotations
import re
from backend.config import settings
from backend.core.rag_store import TIER_REFERENCE, Chunk
_RICS_HEADING_LINE = re.compile(
r"""
(?m)^\s*
(?:(?:section|part|element|item)\s+)? # optional descriptive label before the code
(?P<code>[A-N]\d{1,2})
\b
(?:[\s:.\-\u2013\u2014]+[^\n]{0,120})? # optional separator + title on the same line
\s*$
""",
re.IGNORECASE | re.VERBOSE,
)
# Page-header/footer furniture that bleeds into PDF text extraction. Stripped from
# section bodies so it never becomes the stored baseline (e.g. a section whose only
# captured body was "Page 24RICS Home Survey - Level 3"). Bare standalone numbers are
# deliberately NOT stripped here — a lone digit may be a rating value, not a page number.
_PAGE_HEADER_FURNITURE_RE = re.compile(
r"(?im)^[ \t]*(?:page[ \t]*\d+[ \t]*)?"
r"rics[ \t]+home[ \t]+survey[ \t]*[-\u2013\u2014][ \t]*level[ \t]+\d.*$"
)
_PAGE_LABEL_RE = re.compile(r"(?im)^[ \t]*page[ \t]*\d+[ \t]*$")
# Survey-report PDFs embed photo index lines and RICS procedural NOTE blocks inside
# section bodies. These are not mappable baseline prose — they bloat the baseline,
# confuse the weave gate, and cause grounding rollbacks. Stripped at ingest only.
_PHOTO_LINE_RE = re.compile(r"(?im)^[ \t]*Photo\s*[-\u2013]?\s*\d+.*$")
_PHOTO_INLINE_RE = re.compile(
r"(?i)\s*Photo\s*[-\u2013]?\s*\d+\s*(?:[^\n.!?]|(?:\n(?![ \t]*Photo\s*[-\u2013]?\s*\d+)))*?[.!?]?"
)
_ELEMENT_TABLE_STUB_RE = re.compile(
r"(?im)^[ \t]*Element\s+no\.?\s*Element\s+name\s*$"
)
# NOTE N: procedural blocks (Building Regs boilerplate etc.) precede real findings.
# Stop at the next line that reads like survey prose, not a caption or header.
_NOTE_BLOCK_RE = re.compile(
r"(?is)"
r"^NOTE\s+\d+\s*:.*?"
r"(?="
r"\n(?:"
r"The|There|We|It|A|An|In|Condition|Main|Our|Your|This|These|Some|No|Where|When|"
r"Whilst|While|During|Following|Upon|After|Before|Internal|External|Ground|Roof|"
r"Wall|Floor|Ceiling|Chimney|Window|Door|Gutter|Rainwater|Damp|Mould|Wood|Timber|"
r"Brick|Stone|Slate|Tile|Pipe|Drain|Electric|Gas|Water|Heating|Boiler|Insulation|"
r"Ventilation|Asbestos|Structural|Surface|Visible|Noted|Observed|Evidence|Appears|"
r"Found|Seen|Installed|Located|Constructed|Finished|Painted|Plastered|Rendered|"
r"Pointed|Bedded|Covering|Structure|Material|Building|Property|Survey|Client|"
r"Surveyor|Defective|Further|Regular|Although|However|Generally|Typically|"
r"Inspection|Inspected|Examined|Checked|Tested|Operated|Opened|Accessed"
r")\b|\Z)"
)
# Collapse runs of blank lines left after stripping.
_MULTI_BLANK_RE = re.compile(r"\n{3,}")
def _strip_page_furniture(text: str) -> str:
"""Remove repeated page header/footer lines bled in by PDF extraction."""
cleaned = _PAGE_HEADER_FURNITURE_RE.sub("", text)
cleaned = _PAGE_LABEL_RE.sub("", cleaned)
return cleaned
def _clean_reference_body(text: str) -> str:
"""Remove non-prose survey-report artifacts from a REFERENCE section body."""
cleaned = _strip_page_furniture(text or "")
cleaned = _NOTE_BLOCK_RE.sub("", cleaned)
cleaned = _ELEMENT_TABLE_STUB_RE.sub("", cleaned)
cleaned = _PHOTO_LINE_RE.sub("", cleaned)
# Trailing / inline photo runs (often concatenated without newlines at section tail).
prev = None
while prev != cleaned:
prev = cleaned
cleaned = _PHOTO_INLINE_RE.sub("", cleaned)
cleaned = _MULTI_BLANK_RE.sub("\n\n", cleaned)
return cleaned.strip()
def _alpha_len(text: str) -> int:
"""Count alphabetic characters — a body-richness proxy for de-duplication."""
return sum(1 for ch in text if ch.isalpha())
def _normalise_code(raw: str) -> str:
return re.sub(r"\s+", "", raw.strip().upper())
def _is_valid_code(code: str, valid_section_ids: set[str] | None) -> bool:
if valid_section_ids is None:
return True
return code in {s.upper() for s in valid_section_ids}
def split_into_section_paragraphs(
text: str,
valid_section_ids: set[str] | None = None,
) -> list[tuple[str, int, str]]:
"""Return ``(section_id, paragraph_index, paragraph_text)`` tuples."""
matches = list(_RICS_HEADING_LINE.finditer(text))
if not matches:
return []
# A RICS code typically appears twice in one report: once as a stub row in the
# ratings summary table (body ≈ "Element no. Element name") and once as the real
# section with prose. Keep only the richest-prose body per code so the table stub
# never shadows the genuine content (both previously shared one chunk_id).
best_body: dict[str, str] = {}
for i, match in enumerate(matches):
code = _normalise_code(match.group("code"))
if not _is_valid_code(code, valid_section_ids):
continue
body_start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = _clean_reference_body(text[body_start:end])
if not body:
continue
if _alpha_len(body) > _alpha_len(best_body.get(code, "")):
best_body[code] = body
if not best_body:
return []
out: list[tuple[str, int, str]] = []
max_chars = settings.reference_paragraph_max_chars
for section_id, body in best_body.items():
if len(body) <= max_chars:
out.append((section_id, 1, body))
continue
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]
for idx, para in enumerate(paragraphs, start=1):
out.append((section_id, idx, para))
return out
def _chunk_section_body(para_text: str) -> list[str]:
"""Chunk one section body; keep whole block when within REFERENCE max size."""
from backend.core.ingest import _chunk_reference_text
text = (para_text or "").strip()
if not text:
return []
if len(text) <= settings.reference_paragraph_max_chars:
return [text]
return _chunk_reference_text(text)
def build_reference_chunks(
text: str,
*,
source_filename: str,
valid_section_ids: set[str] | None = None,
) -> list[Chunk]:
"""Build REFERENCE chunks with section metadata; prefer long-form section bodies."""
from backend.core.ingest import _chunk_reference_text
tagged = split_into_section_paragraphs(text, valid_section_ids)
if not tagged:
return [
Chunk(
text=_clean_reference_body(t),
tier=TIER_REFERENCE,
is_scrubbed=False,
source_filename=source_filename,
)
for t in _chunk_reference_text(text)
if _clean_reference_body(t).strip()
]
chunks: list[Chunk] = []
for section_id, para_idx, para_text in tagged:
cleaned = _clean_reference_body(para_text)
if not cleaned:
continue
for piece in _chunk_section_body(cleaned):
chunks.append(
Chunk(
text=piece,
section_id=section_id,
tier=TIER_REFERENCE,
is_scrubbed=False,
source_filename=source_filename,
chunk_id=f"{source_filename}:{section_id}:p{para_idx}",
paragraph_index=para_idx,
)
)
return chunks