feat(reports): add multi-format rendering (html, pdf, docx)

- Add report_templates package (_renderer.py) with MD/HTML/PDF/DOCX
  support using lazy optional imports (markdown, weasyprint, python-docx)
- Apply Wheels brand colors (#003865/#0066cc) to HTML and DOCX output
- Extend save_report MCP tool with `format` param (default: "md");
  binary formats use write_bytes, text formats keep async writer
- Add [report] optional dep group to pyproject.toml for one-step install
- Fix ReportConfig.output_dir default: ./reports → ./output-reports
  to align config.py with the codified REPORT_OUTPUT_DIR env setting

Ref: session 2026-04-15 — exec-friendly report output track

BREAKING CHANGE: ReportConfig.output_dir default changed from
./reports to ./output-reports. Environments without REPORT_OUTPUT_DIR
set in .env will write to a different path after this change.
This commit is contained in:
Nathan Castaldi 2026-04-15 18:33:54 -04:00
parent fa366e8b72
commit 58566aba0b
5 changed files with 352 additions and 10 deletions

View File

@ -135,8 +135,8 @@ class ReportConfig(BaseSettings):
env_file_encoding="utf-8",
extra="ignore"
)
output_dir: Path = Path("./reports")
output_dir: Path = Path("./output-reports")
@field_validator("output_dir", mode="before")
@classmethod

View File

@ -32,6 +32,14 @@ test = [
"pytest-cov>=5.0.0",
"pytest-asyncio>=0.24.0",
]
report = [
# Markdown → HTML conversion
"markdown>=3.6",
# HTML → PDF (Windows: also needs GTK3 DLLs — see weasyprint docs)
"weasyprint>=62.0",
# Markdown → DOCX
"python-docx>=1.1.0",
]
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -0,0 +1,28 @@
"""report_templates — multi-format rendering for Nexus output reports.
Usage
-----
from report_templates import ReportFormat, render
# Markdown (default — no extra deps)
md_str = render(markdown, ReportFormat.MD, title="My Report")
# HTML (requires: pip install markdown)
html_str = render(markdown, ReportFormat.HTML, title="My Report")
# PDF (requires: pip install weasyprint markdown)
# On Windows: also install GTK3 runtime DLLs
# https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows
pdf_bytes = render(markdown, ReportFormat.PDF, title="My Report")
# DOCX (requires: pip install python-docx)
docx_bytes = render(markdown, ReportFormat.DOCX, title="My Report")
Install all at once
-------------------
pip install "nexus-mcp[report]"
"""
from ._renderer import ReportFormat, render
__all__ = ["ReportFormat", "render"]

View File

@ -0,0 +1,288 @@
"""Multi-format report renderer for Nexus MCP.
Converts a markdown string to the requested output format.
All non-markdown renderers have optional dependencies guarded with
clear ImportError messages the server starts fine without them.
Formats
-------
md plain markdown (no extra deps)
html standalone HTML with Wheels-branded CSS
requires: pip install markdown
pdf PDF generated from the branded HTML
requires: pip install weasyprint markdown
Windows note: GTK3 runtime DLLs are also required
https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows
docx Word document
requires: pip install python-docx
"""
from __future__ import annotations
import io
from enum import Enum
# ── Brand / style constants ───────────────────────────────────────────────────
_BRAND_PRIMARY = "#003865" # Wheels dark blue
_BRAND_ACCENT = "#0066cc" # Wheels mid blue
_BRAND_ROW_ALT = "#f5f8fc" # table row stripe
_CSS = f"""
body {{
font-family: 'Segoe UI', Arial, sans-serif;
margin: 40px auto;
max-width: 960px;
color: #222;
line-height: 1.55;
}}
h1 {{
color: {_BRAND_PRIMARY};
border-bottom: 3px solid {_BRAND_PRIMARY};
padding-bottom: 8px;
margin-bottom: 4px;
}}
h2 {{
color: {_BRAND_ACCENT};
margin-top: 32px;
}}
h3 {{ color: #444; }}
table {{
border-collapse: collapse;
width: 100%;
margin: 16px 0;
font-size: 0.92em;
}}
th {{
background: {_BRAND_PRIMARY};
color: #fff;
padding: 8px 12px;
text-align: left;
font-weight: 600;
}}
td {{
padding: 7px 12px;
border-bottom: 1px solid #ddd;
}}
tr:nth-child(even) td {{
background: {_BRAND_ROW_ALT};
}}
code, pre {{
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
font-family: 'Consolas', 'Courier New', monospace;
}}
ul, ol {{ padding-left: 1.4em; }}
li {{ margin-bottom: 4px; }}
.meta {{
color: #666;
font-size: 0.88em;
margin-bottom: 20px;
}}
"""
_HTML_WRAPPER = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>{css}</style>
</head>
<body>
{body}
</body>
</html>
"""
# ── Public API ────────────────────────────────────────────────────────────────
class ReportFormat(str, Enum):
MD = "md"
HTML = "html"
PDF = "pdf"
DOCX = "docx"
@property
def extension(self) -> str:
return self.value
@property
def is_binary(self) -> bool:
return self in (ReportFormat.PDF, ReportFormat.DOCX)
def render(
markdown_content: str,
fmt: ReportFormat,
title: str = "Report",
) -> bytes | str:
"""Render *markdown_content* to the requested format.
Returns:
str for MD and HTML
bytes for PDF and DOCX
"""
if fmt == ReportFormat.MD:
return markdown_content
html = _md_to_html(markdown_content, title)
if fmt == ReportFormat.HTML:
return html
if fmt == ReportFormat.PDF:
return _html_to_pdf(html)
if fmt == ReportFormat.DOCX:
return _md_to_docx(markdown_content, title)
raise ValueError(f"Unsupported format: {fmt!r}")
# ── Internal renderers ────────────────────────────────────────────────────────
def _md_to_html(md: str, title: str) -> str:
"""Convert markdown string to a full, styled HTML document."""
try:
import markdown as _md_lib
except ImportError as exc:
raise ImportError(
"HTML/PDF output requires the 'markdown' package.\n"
" pip install markdown\n"
" or: pip install 'nexus-mcp[report]'"
) from exc
body = _md_lib.markdown(
md,
extensions=["tables", "fenced_code", "nl2br"],
)
return _HTML_WRAPPER.format(title=title, css=_CSS, body=body)
def _html_to_pdf(html: str) -> bytes:
"""Convert a full HTML string to PDF bytes using WeasyPrint."""
try:
from weasyprint import HTML as _HTML
except ImportError as exc:
raise ImportError(
"PDF output requires WeasyPrint.\n"
" pip install weasyprint\n"
" or: pip install 'nexus-mcp[report]'\n"
"\n"
"Windows: GTK3 runtime DLLs are also required:\n"
" https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows"
) from exc
return _HTML(string=html).write_pdf()
def _md_to_docx(md: str, title: str) -> bytes:
"""Convert markdown to a .docx document using python-docx.
Handles: headings (#/##/###), bullet lists (- …), pipe tables,
key-value pairs (- Key: Value), and plain paragraphs.
"""
try:
from docx import Document
from docx.shared import Pt, RGBColor
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
except ImportError as exc:
raise ImportError(
"DOCX output requires python-docx.\n"
" pip install python-docx\n"
" or: pip install 'nexus-mcp[report]'"
) from exc
doc = Document()
# ── document title ────────────────────────────────────────────────────────
heading = doc.add_heading(title, level=0)
heading.runs[0].font.color.rgb = RGBColor(0x00, 0x38, 0x65)
lines = md.splitlines()
i = 0
while i < len(lines):
line = lines[i]
# Skip blank lines
if not line.strip():
i += 1
continue
# Heading 1
if line.startswith("# "):
doc.add_heading(line[2:].strip(), level=1)
i += 1
continue
# Heading 2
if line.startswith("## "):
doc.add_heading(line[3:].strip(), level=2)
i += 1
continue
# Heading 3
if line.startswith("### "):
doc.add_heading(line[4:].strip(), level=3)
i += 1
continue
# Bullet
if line.startswith("- "):
doc.add_paragraph(line[2:].strip(), style="List Bullet")
i += 1
continue
# Pipe table: collect header + separator + data rows
if line.startswith("|") and i + 1 < len(lines) and lines[i + 1].startswith("|---"):
headers = [c.strip() for c in line.strip().strip("|").split("|")]
i += 2 # skip separator row
data_rows: list[list[str]] = []
while i < len(lines) and lines[i].startswith("|"):
data_rows.append(
[c.strip() for c in lines[i].strip().strip("|").split("|")]
)
i += 1
ncols = len(headers)
t = doc.add_table(rows=1 + len(data_rows), cols=ncols)
t.style = "Table Grid"
# Header row — bold + brand colour fill
hdr_row = t.rows[0]
for j, cell_text in enumerate(headers):
cell = hdr_row.cells[j]
cell.text = cell_text
run = cell.paragraphs[0].runs[0]
run.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# Fill background
tc_pr = cell._tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), "003865")
tc_pr.append(shd)
for r_idx, row_data in enumerate(data_rows):
for j, cell_text in enumerate(row_data):
if j < ncols:
t.rows[r_idx + 1].cells[j].text = cell_text
continue
# Fallback: plain paragraph
doc.add_paragraph(line)
i += 1
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()

View File

@ -6,10 +6,11 @@ import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from typing import Literal, Optional
import aiofiles
from mcp.server.fastmcp import FastMCP
from report_templates import ReportFormat, render as _render
logger = logging.getLogger("nexus-mcp.reports")
@ -55,25 +56,34 @@ def register(mcp: FastMCP) -> None:
content: str,
subfolder: Optional[str] = None,
filename: Optional[str] = None,
format: Literal["md", "html", "pdf", "docx"] = "md",
) -> dict:
"""Save markdown content to the configured reports directory.
Accepts a large markdown string, writes it as a .md file, and returns
only a small metadata dict (path, filename, size_bytes) so the chat
context stays lightweight regardless of report size.
Accepts a markdown string, converts it to the requested format, writes
it to disk, and returns a small metadata dict so the chat context stays
lightweight regardless of report size.
Args:
title: Human-readable report title. Used to derive filename when
'filename' is not provided.
content: Full markdown body to write to disk.
content: Full markdown body to render and write to disk.
subfolder: Optional subdirectory under the output dir (e.g. "identity").
Non-alphanumeric characters are replaced with hyphens.
filename: Override filename base (without extension). Defaults to a
slugified version of 'title' with an ISO timestamp suffix.
format: Output format "md" (default), "html", "pdf", or "docx".
Extra packages are required for non-markdown formats:
html/pdf pip install markdown (+ weasyprint for pdf)
pdf also needs GTK3 DLLs on Windows
docx pip install python-docx
Install all at once: pip install "nexus-mcp[report]"
Returns:
{"status": "saved", "path": str, "filename": str, "size_bytes": int}
"""
fmt = ReportFormat(format)
base_dir = _output_dir
if subfolder:
safe_sub = re.sub(r"[^\w\-]", "-", subfolder.strip())
@ -83,7 +93,7 @@ def register(mcp: FastMCP) -> None:
safe_filename = (
re.sub(r"[^\w\-]", "-", filename.strip()) if filename else _slugify(title)
)
final_filename = f"{safe_filename}-{timestamp}.md"
final_filename = f"{safe_filename}-{timestamp}.{fmt.extension}"
abs_path = (base_dir / final_filename).resolve()
# Safety guard: keep all writes inside the permitted output tree
@ -97,8 +107,16 @@ def register(mcp: FastMCP) -> None:
abs_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(abs_path, "w", encoding="utf-8") as f:
await f.write(content)
try:
rendered = _render(content, fmt, title=title)
except ImportError as exc:
return {"status": "error", "error": str(exc)}
if fmt.is_binary:
abs_path.write_bytes(rendered)
else:
async with aiofiles.open(abs_path, "w", encoding="utf-8") as f:
await f.write(rendered)
size_bytes = abs_path.stat().st_size
logger.info("save_report: wrote %d bytes → %s", size_bytes, abs_path)