RICS / app /generator /docx_builder.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
22 kB
"""Assemble all generated report sections into a professional RICS Level 3 DOCX.
The builder takes a ``dict[section_code, SectionPayload]`` and produces a
ready-to-download ``.docx`` file matching the canonical RICS Level 3 Building
Survey structure (sections A β†’ B β†’ C β†’ D β†’ E1–E9 β†’ F1–F9 β†’ G1–G8 β†’
H1–H3 β†’ I1–I3 β†’ J1–J4 β†’ K1–K5 β†’ L).
Features:
* Branded title page (report title, generated date, disclaimer).
* Group headings matching official RICS wording (e.g. "E β€” Outside the
property") rendered as Heading 1.
* Element sub-headings (e.g. "E2 Roof coverings") as Heading 2.
* Condition Rating badge printed after each element with a rating.
* Legacy ``[VERIFY: …]`` markers are sanitised during export (the reconstruction pipeline does not emit them).
* Editor notes (from proofread mode) in an indented italic callout.
* Professional footer on every page: RICS disclaimer + reviewer signature.
* Per-section **estimated AI involvement** (when metadata is present) and
**source references** (filename, relevance, heading hint, snippet preview)
for traceability to tenant uploads.
No external dependencies beyond ``python-docx`` (already in ``pyproject.toml``).
"""
from __future__ import annotations
import io
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from docx import Document
from docx.document import Document as DocxDocument
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_COLOR_INDEX
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor
from docx.text.run import Run
from app.templates.registry import get_survey_pack, get_template
if TYPE_CHECKING:
from app.models.schemas import SectionPayload
# Regex to find legacy [VERIFY: …] or [VERIFY] tags
_VERIFY_RE = re.compile(r"(\[VERIFY[^\]]*\])")
_DISCLAIMER = (
"IMPORTANT NOTICE: This report has been generated with AI assistance and must be "
"reviewed, verified, and approved by a qualified RICS surveyor before use. "
"Items marked as missing/unverifiable require human confirmation. Nothing herein constitutes "
"professional surveying advice without a surveyor's written sign-off."
)
def build_docx(
sections: dict[str, SectionPayload],
report_id: str,
tenant_id: str,
survey_level: int | None = None,
section_photo_paths: dict[str, list[str]] | None = None,
write_audit: bool = True,
) -> bytes:
"""Assemble a professional RICS Home Survey DOCX for the report's product tier.
Section order, group headings, and the title page use the template pack for
``survey_level`` (1 = Condition Report, 2 = HomeBuyer / L2, 3 = Building Survey).
When ``survey_level`` is omitted, Level 3 is used (legacy).
Args:
sections: Mapping of ``section_code β†’ SectionPayload`` from the API.
report_id: UUID of the report (shown on the title page).
tenant_id: Tenant identifier (shown on the title page).
survey_level: RICS product tier (1/2/3) from ``Report.survey_level``.
Returns:
Raw ``.docx`` bytes ready to serve as a file download.
Example::
raw = build_docx(sections=data["sections"], report_id="...", tenant_id="...", survey_level=1)
return Response(content=raw, media_type="application/.../wordprocessingml.document")
"""
pack = get_survey_pack(survey_level)
doc = Document()
# Post-generation audit (non-destructive): true AI-involvement average,
# high-involvement sections, and misplaced-content flags. Computed from the
# section payloads themselves so the cover page reflects ACTUAL behaviour
# rather than the requested slider value.
from app.generator.report_audit import (
aggregate_ai_involvement,
detect_misplaced_content,
write_audit_log,
)
_per_section_pct: dict[str, int | None] = {}
_section_texts: dict[str, str] = {}
for _code, _pl in sections.items():
_trx = getattr(_pl, "ai_transparency", None)
_per_section_pct[_code] = (
_trx.get("ai_involvement_percent") if isinstance(_trx, dict) else None
)
_section_texts[_code] = getattr(_pl, "text", "") or ""
_involvement = aggregate_ai_involvement(_per_section_pct)
_misplaced = detect_misplaced_content(_section_texts)
_apply_global_styles(doc)
_add_page_footer(doc)
_add_title_page(
doc,
report_id,
tenant_id,
docx_title=pack.docx_title,
product_label=pack.product_label,
level=pack.level,
)
_add_report_audit_summary(doc, _involvement, _misplaced)
# Reconstruction output must not include placeholder tokens (e.g. [VERIFY]).
# Legacy tokens are sanitised per-section; we do not add any "[VERIFY]" legend page.
has_verify = False
current_group: str | None = None
for code in pack.section_order:
tmpl = get_template(code, survey_level)
if tmpl is None:
continue
# Insert a group-level heading whenever the group changes
if tmpl.group != current_group:
current_group = tmpl.group
group_label = pack.group_labels.get(current_group, current_group)
h1 = doc.add_heading(group_label, level=1)
h1.paragraph_format.space_before = Pt(18)
h1.paragraph_format.space_after = Pt(6)
payload = sections.get(code)
_add_section(
doc,
code,
tmpl.title,
payload,
tmpl.has_condition_rating,
photo_paths=(section_photo_paths or {}).get(code, []),
)
# No verify-token detection (sanitised per-section).
# No verify legend in reconstruction mode.
if write_audit:
write_audit_log(
report_id,
involvement=_involvement,
misplacement_flags=_misplaced,
)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def _add_report_audit_summary(doc: DocxDocument, involvement: Any, misplaced: list[Any]) -> None:
"""Cover-page audit block: true average AI involvement + quality flags."""
p = doc.add_paragraph()
r = p.add_run(f"Average AI involvement across generated sections: {involvement.average_percent}%")
r.font.bold = True
r.font.size = Pt(10)
r.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
p.paragraph_format.space_before = Pt(6)
if involvement.high_sections:
names = ", ".join(f"{c} ({v}%)" for c, v in involvement.high_sections)
pw = doc.add_paragraph()
rw = pw.add_run(
"\u26a0 High-AI-involvement sections requiring scrutiny before "
f"sign-off: {names}."
)
rw.font.bold = True
rw.font.size = Pt(9)
rw.font.color.rgb = RGBColor(0xB0, 0x3A, 0x2E)
if misplaced:
ph = doc.add_paragraph()
rh = ph.add_run("Content placement flags (review β€” not auto-moved):")
rh.font.bold = True
rh.font.size = Pt(9)
rh.font.color.rgb = RGBColor(0xB0, 0x3A, 0x2E)
for f in misplaced[:20]:
pf = doc.add_paragraph()
pf.paragraph_format.left_indent = Inches(0.15)
rf = pf.add_run(
f"\u2022 {f.topic} found in {f.section_code} \u2014 likely belongs "
f"in {f.suggested_section}: \u201c{f.excerpt}\u201d"
)
rf.font.size = Pt(8.5)
rf.font.color.rgb = RGBColor(0x55, 0x66, 0x77)
# ── Document-level styling ────────────────────────────────────────────────────
def _apply_global_styles(doc: DocxDocument) -> None:
"""Set default font, margins, and paragraph spacing."""
section = doc.sections[0]
section.top_margin = Inches(1.1)
section.bottom_margin = Inches(1.1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
style = doc.styles["Normal"]
font = style.font
font.name = "Calibri"
font.size = Pt(11)
font.color.rgb = RGBColor(0x1A, 0x23, 0x32)
for level, size, bold in [(1, 14, True), (2, 12, True), (3, 11, True)]:
name = f"Heading {level}"
if name in doc.styles:
h = doc.styles[name]
h.font.name = "Calibri"
h.font.size = Pt(size)
h.font.bold = bold
h.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C) # RICS navy
def _add_page_footer(doc: DocxDocument) -> None:
"""Add footer: RICS disclaimer on the left, reviewer signature on a second line."""
section = doc.sections[0]
footer = section.footer
footer.is_linked_to_previous = False
p1 = footer.paragraphs[0]
p1.clear()
r = p1.add_run(_DISCLAIMER)
r.font.size = Pt(7.5)
r.font.italic = True
r.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
p2 = footer.add_paragraph()
r2 = p2.add_run(
"Reviewed and approved by (RICS surveyor): ________________________ "
"Date: ________________"
)
r2.font.size = Pt(9)
r2.font.bold = True
r2.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
# ── Title page ────────────────────────────────────────────────────────────────
def _add_title_page(
doc: DocxDocument,
report_id: str,
tenant_id: str,
*,
docx_title: str,
product_label: str,
level: int,
) -> None:
"""Insert a branded title page."""
for _ in range(4):
doc.add_paragraph()
p_title = doc.add_paragraph()
p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p_title.add_run(docx_title.strip() or "RICS Home Survey Report")
run.font.name = "Calibri"
run.font.size = Pt(22)
run.font.bold = True
run.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
p_sub = doc.add_paragraph()
p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sub = p_sub.add_run("AI-Assisted Draft β€” For Professional Review and Sign-Off")
sub.font.name = "Calibri"
sub.font.size = Pt(13)
sub.font.italic = True
sub.font.color.rgb = RGBColor(0xC8, 0xA0, 0x20) # gold
doc.add_paragraph()
doc.add_paragraph()
p_lvl = doc.add_paragraph()
p_lvl.alignment = WD_ALIGN_PARAGRAPH.CENTER
r_lvl = p_lvl.add_run(f"Survey level: Level {int(level)} β€” {product_label}")
r_lvl.font.name = "Calibri"
r_lvl.font.size = Pt(11)
r_lvl.font.bold = True
r_lvl.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
generated_at = datetime.now(UTC).strftime("%d %B %Y at %H:%M UTC")
for label, value in [
("Generated", generated_at),
("Report ID", report_id),
("Surveying Firm / Tenant", tenant_id),
("Status", "DRAFT β€” Requires RICS surveyor review before release to client"),
]:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
rl = p.add_run(f"{label}: ")
rl.font.bold = True
rl.font.size = Pt(11)
rl.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
rv = p.add_run(value)
rv.font.size = Pt(11)
if label == "Status":
rv.font.color.rgb = RGBColor(0xC0, 0x39, 0x2B) # red
doc.add_page_break() # type: ignore[no-untyped-call]
# ── Section rendering ─────────────────────────────────────────────────────────
def _add_section(
doc: DocxDocument,
code: str,
title: str,
payload: SectionPayload | None,
has_condition_rating: bool,
photo_paths: list[str] | None = None,
) -> None:
"""Render one section element as Heading 2 + body."""
heading = doc.add_heading(f"{code} {title}", level=2)
heading.paragraph_format.space_before = Pt(12)
heading.paragraph_format.space_after = Pt(4)
if payload is None:
p = doc.add_paragraph()
r = p.add_run("[Section not yet generated β€” to be completed by the surveyor]")
r.font.italic = True
r.font.color.rgb = RGBColor(0x90, 0x90, 0x90)
return
# Photo evidence (uploaded by the user)
paths = [p for p in (photo_paths or []) if p]
if paths:
cap = doc.add_paragraph()
cap_r = cap.add_run("Photo evidence (uploaded)")
cap_r.font.bold = True
cap_r.font.size = Pt(9.5)
cap_r.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
cap.paragraph_format.space_after = Pt(4)
for p in paths[:8]:
try:
doc.add_picture(p, width=Inches(5.8))
except Exception:
# Never break export for a missing/corrupt image; continue.
continue
doc.add_paragraph().paragraph_format.space_after = Pt(6)
# Strip editor notes appended by proofread mode
body_text = payload.text
# Legacy placeholder clean-up: the reconstruction pipeline must not emit [VERIFY] tokens.
# If older cached content or manual edits include them, replace with an approved phrase.
if _VERIFY_RE.search(body_text):
body_text = _VERIFY_RE.sub("Information not provided in source document.", body_text)
editor_notes: str | None = None
if "[Editor notes:" in body_text:
idx = body_text.index("[Editor notes:")
editor_notes = body_text[idx:].removeprefix("[Editor notes:").removesuffix("]").strip()
body_text = body_text[:idx].strip()
# Detect and render inline condition rating (e.g. "Condition Rating 3")
condition_badge, body_text = _extract_condition_rating(body_text, has_condition_rating)
# Body text (no placeholder highlighting in reconstruction mode)
_add_text_with_verify_highlights(doc, body_text)
# Condition rating badge
if condition_badge:
cr_p = doc.add_paragraph()
cr_run = cr_p.add_run(f" Condition Rating: {condition_badge} ")
cr_run.font.bold = True
cr_run.font.size = Pt(10)
color = {
"3": RGBColor(0xC0, 0x39, 0x2B), # red β€” urgent
"2": RGBColor(0xC8, 0x6E, 0x00), # amber β€” attention needed
"1": RGBColor(0x1A, 0x7F, 0x4B), # green β€” satisfactory
"NI": RGBColor(0x60, 0x70, 0x80), # grey β€” not inspected
}.get(condition_badge, RGBColor(0x1A, 0x23, 0x32))
cr_run.font.color.rgb = color
cr_p.paragraph_format.space_after = Pt(4)
# Mode + confidence metadata line
mode_map = {"generate": "✍ Generated", "proofread": "πŸ“ Proofread", "enhance": "βš™ Enhanced"}
mode_label = mode_map.get(payload.mode, payload.mode.title())
conf_pct = round(payload.confidence * 100)
meta_p = doc.add_paragraph()
meta_r = meta_p.add_run(
f"{mode_label} Β· Confidence {conf_pct}%"
+ (" · ⚑ Cached" if payload.cached else "")
)
meta_r.font.size = Pt(8.5)
meta_r.font.color.rgb = RGBColor(0x6A, 0x7A, 0x8A)
meta_r.font.italic = True
meta_p.paragraph_format.space_after = Pt(6)
_add_transparency_and_sources(doc, payload)
if editor_notes:
_add_editor_notes(doc, editor_notes)
def _extract_condition_rating(text: str, has_condition_rating: bool) -> tuple[str, str]:
"""Pull the final Condition Rating out of the text and return (badge, cleaned_text).
Args:
text: Section body text, possibly ending with "Condition Rating 2."
has_condition_rating: Whether this section type carries a rating.
Returns:
``(badge_string, cleaned_text)`` where badge_string is ``"1"``, ``"2"``,
``"3"``, ``"NI"`` or ``""`` if not found/applicable.
"""
if not has_condition_rating:
return "", text
# Match "Condition Rating 3", "Condition Rating NI" at end of text
pattern = re.compile(
r"[\.\s]*Condition\s+Rating[:\s]+([123]|NI)[\.\s]*$", re.IGNORECASE
)
m = pattern.search(text)
if m:
badge = m.group(1).upper()
cleaned = text[: m.start()].rstrip()
return badge, cleaned
return "", text
def _add_text_with_verify_highlights(doc: DocxDocument, text: str) -> None:
"""Write text as a Normal paragraph (legacy [VERIFY] spans are already sanitised)."""
p = doc.add_paragraph()
p.style = doc.styles["Normal"]
p.paragraph_format.space_after = Pt(6)
parts = _VERIFY_RE.split(text)
for part in parts:
if not part:
continue
run = p.add_run(part)
if _VERIFY_RE.match(part):
run.font.bold = True
run.font.color.rgb = RGBColor(0xC0, 0x5A, 0x00)
_set_run_highlight(run, WD_COLOR_INDEX.YELLOW)
def _prov_field(row: object, key: str) -> Any:
"""Read a provenance field from a Pydantic model or dict."""
if hasattr(row, key):
return getattr(row, key)
if isinstance(row, dict):
return row.get(key)
return None
def _add_transparency_and_sources(doc: DocxDocument, payload: SectionPayload) -> None:
"""Print estimated AI involvement and retrieved-source citations (DOCX)."""
trx = getattr(payload, "ai_transparency", None)
if isinstance(trx, dict):
pct = trx.get("ai_involvement_percent")
if pct is not None:
p = doc.add_paragraph()
r = p.add_run(f"Estimated AI involvement: {pct}%")
r.font.bold = True
r.font.size = Pt(9)
r.font.color.rgb = RGBColor(0x2E, 0x5E, 0x7E)
try:
pct_val = int(pct)
except (TypeError, ValueError):
pct_val = 0
if pct_val > 70:
pw = doc.add_paragraph()
rw = pw.add_run(
f"\u26a0 WARNING: this section was generated with {pct_val}% AI "
"involvement due to limited source notes. High scrutiny "
"required before sign-off."
)
rw.font.bold = True
rw.font.size = Pt(9)
rw.font.color.rgb = RGBColor(0xB0, 0x3A, 0x2E)
summary = (trx.get("plain_language") or trx.get("summary") or "").strip()
if summary:
p2 = doc.add_paragraph()
short = summary if len(summary) <= 480 else summary[:477] + "…"
r2 = p2.add_run(short)
r2.font.size = Pt(8.5)
r2.font.italic = True
r2.font.color.rgb = RGBColor(0x6A, 0x7A, 0x8A)
trans = trx.get("transformations")
if isinstance(trans, list) and trans:
p3 = doc.add_paragraph()
bullets = " Β· ".join(str(t) for t in trans[:5])
r3 = p3.add_run("Transformations: " + bullets)
r3.font.size = Pt(8)
r3.font.color.rgb = RGBColor(0x55, 0x66, 0x77)
p.paragraph_format.space_after = Pt(4)
prov = getattr(payload, "provenance", None)
if not isinstance(prov, list) or not prov:
return
head = doc.add_paragraph()
rh = head.add_run("Source references (your uploaded documents)")
rh.font.bold = True
rh.font.size = Pt(9)
rh.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
head.paragraph_format.space_before = Pt(2)
for i, row in enumerate(prov, 1):
fn = _prov_field(row, "filename") or "Your upload"
score = _prov_field(row, "score")
hint = (_prov_field(row, "section_hint") or "").strip()
snip = (_prov_field(row, "snippet_preview") or "").strip()
line1 = doc.add_paragraph()
line1.paragraph_format.left_indent = Inches(0.15)
t1 = line1.add_run(f"[{i}] {fn}")
t1.font.size = Pt(8.5)
if score is not None:
try:
sc = float(score)
t2 = line1.add_run(f" Β· relevance {sc:.3f}")
except (TypeError, ValueError):
t2 = line1.add_run("")
t2.font.size = Pt(8)
t2.font.color.rgb = RGBColor(0x6A, 0x7A, 0x8A)
if hint:
h = doc.add_paragraph()
h.paragraph_format.left_indent = Inches(0.3)
hr = h.add_run(hint if len(hint) <= 220 else hint[:217] + "…")
hr.font.italic = True
hr.font.size = Pt(8)
hr.font.color.rgb = RGBColor(0x70, 0x80, 0x90)
if snip:
s = doc.add_paragraph()
s.paragraph_format.left_indent = Inches(0.3)
stext = snip if len(snip) <= 360 else snip[:357] + "…"
sr = s.add_run(stext)
sr.font.size = Pt(7.5)
sr.font.color.rgb = RGBColor(0x55, 0x66, 0x77)
def _add_editor_notes(doc: DocxDocument, notes_text: str) -> None:
"""Render proofread editor notes in an indented italic callout."""
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.4)
p.paragraph_format.space_before = Pt(2)
p.paragraph_format.space_after = Pt(8)
rl = p.add_run("πŸ“‹ Editor notes: ")
rl.font.bold = True
rl.font.size = Pt(10)
rl.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
rn = p.add_run(notes_text)
rn.font.italic = True
rn.font.size = Pt(10)
rn.font.color.rgb = RGBColor(0x44, 0x55, 0x66)
def _add_verify_legend(doc: DocxDocument) -> None:
"""Legacy no-op (kept for backwards compatibility)."""
return
# ── XML helper ────────────────────────────────────────────────────────────────
def _set_run_highlight(run: Run, color_index: WD_COLOR_INDEX) -> None:
"""Apply a highlight colour to a run via direct OOXML (works across all python-docx versions)."""
run_props = run._r.get_or_add_rPr()
highlight = OxmlElement("w:highlight")
highlight.set(
qn("w:val"),
color_index.name.lower() if hasattr(color_index, "name") else "yellow",
)
run_props.append(highlight)