Spaces:
Sleeping
Sleeping
| """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, | |
| ) -> 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() | |
| _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, | |
| ) | |
| # 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. | |
| buf = io.BytesIO() | |
| doc.save(buf) | |
| return buf.getvalue() | |
| # ββ 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) | |
| 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) | |