"""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)