feat(mcp-docugen): Markdown autocontenuto con CSS Tielogic iniettato inline

Problema: i template puntavano a un path host hardcoded
(stylesheet: /home/adriano/.../themes/tielogic.css), quindi il file .md
generato non era portabile — su un'altra macchina md-to-pdf non trovava
il CSS e produceva PDF senza stile.

Soluzione: il Renderer legge il CSS da Settings.inline_stylesheet_path
(default /app/themes/tielogic.css nel container) e lo inietta come
blocco <style>...</style> subito dopo il frontmatter YAML del Markdown
restituito dall'LLM. Il file .md risultante è autocontenuto e portabile.

- renderer.py: nuovo arg inline_stylesheet_path + funzione
  _inject_inline_stylesheet (idempotente, gestisce Markdown senza
  frontmatter, no-op se CSS vuoto)
- config.py: Settings.inline_stylesheet_path: Path | None
- main.py: passa il path al Renderer
- mcp-docugen.Dockerfile: COPY themes ./themes nello stage builder per
  trasportare /app/themes/tielogic.css nell'immagine runtime
- templates_seed/{offerta,report-analisi}/template.md: rimossa la riga
  `stylesheet:` dal frontmatter di output + regola tassativa che vieta
  all'LLM di emettere blocchi <style> di sua iniziativa (evita
  conflitti di cascade visti in test)
- 4 nuovi test unit (76 totali): iniezione dopo frontmatter, prepend
  quando frontmatter assente, no-op CSS vuoto, integrazione full E2E
  via Renderer.generate

scripts/bundle-css.py: utility per fixare file .md legacy che
referenziavano stylesheet: come path host (sostituisce la riga con
<style> inline pescando il CSS dal repo)

README aggiornato con rationale e workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 15:35:41 +02:00
parent 7955e0e517
commit b32669caa7
9 changed files with 205 additions and 7 deletions
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
public_base_url: str = Field(...)
data_dir: Path = Path("/data")
templates_seed_dir: Path = Path("/app/services/mcp-docugen/templates_seed")
inline_stylesheet_path: Path | None = Path("/app/themes/tielogic.css")
asset_ttl_days: int = 30
max_image_size_mb: int = 10
llm_timeout_seconds: int = 60
@@ -48,6 +48,7 @@ async def build_app(settings: Settings | None = None) -> FastAPI:
default_model=settings.llm_model_default,
asset_ttl_days=settings.asset_ttl_days,
max_image_size_mb=settings.max_image_size_mb,
inline_stylesheet_path=settings.inline_stylesheet_path,
)
mcp = build_mcp_server(template_store, renderer)
@@ -4,6 +4,7 @@ import base64
import binascii
import re
import uuid
from pathlib import Path
import aiofiles
from pydantic import ValidationError
@@ -52,6 +53,33 @@ class AssetStorageError(RendererError):
_ASSET_TEMPLATE_REF_RE = re.compile(r"\.\/assets\/([A-Za-z0-9._-]+)")
_FRONTMATTER_DELIM = "---"
def _inject_inline_stylesheet(markdown: str, css: str) -> str:
"""Insert a `<style>` block right after the YAML frontmatter so the
Markdown file is self-contained (no external CSS path required)."""
if not css.strip():
return markdown
style_block = f"<style>\n{css.strip()}\n</style>"
if markdown.startswith(_FRONTMATTER_DELIM + "\n") or markdown.startswith(
_FRONTMATTER_DELIM + "\r\n"
):
# Find the closing delimiter on its own line.
end_marker = f"\n{_FRONTMATTER_DELIM}\n"
idx = markdown.find(end_marker, len(_FRONTMATTER_DELIM))
if idx != -1:
insert_at = idx + len(end_marker)
return markdown[:insert_at] + "\n" + style_block + "\n\n" + markdown[
insert_at:
]
# No frontmatter: prepend the style block.
return style_block + "\n\n" + markdown
class Renderer:
def __init__(
self,
@@ -62,6 +90,7 @@ class Renderer:
default_model: str,
asset_ttl_days: int,
max_image_size_mb: int,
inline_stylesheet_path: Path | None = None,
) -> None:
self.template_store = template_store
self.generation_store = generation_store
@@ -70,6 +99,7 @@ class Renderer:
self.default_model = default_model
self.asset_ttl_days = asset_ttl_days
self.max_image_size_bytes = max_image_size_mb * 1024 * 1024
self.inline_stylesheet_path = inline_stylesheet_path
async def generate(
self,
@@ -126,16 +156,19 @@ class Renderer:
expires_at = None
if ephemeral_urls:
from pathlib import Path
info = await self.generation_store.get_ephemeral_asset(
gen_id, Path(ephemeral_urls[0]).name
)
expires_at = info.expires_at if info else None
markdown_out = response.text
if self.inline_stylesheet_path and self.inline_stylesheet_path.is_file():
css = self.inline_stylesheet_path.read_text(encoding="utf-8")
markdown_out = _inject_inline_stylesheet(markdown_out, css)
return GenerationResult(
generation_id=gen_id,
markdown=response.text,
markdown=markdown_out,
model=response.model,
tokens=TokenUsage(input=response.tokens_in, output=response.tokens_out),
cost_usd=response.cost_usd,
@@ -39,7 +39,6 @@ Italiano formale, terza persona impersonale, tono professionale ma asciutto. Nie
```
---
stylesheet: /home/adriano/Documenti/Git_XYZ/ArcaSuite/themes/tielogic.css
pdf_options:
format: A4
margin:
@@ -54,6 +53,10 @@ pdf_options:
---
```
**Niente** `stylesheet:``css:` nel frontmatter: il sistema inietta automaticamente il foglio di stile come `<style>` inline subito dopo il frontmatter, così il file Markdown è autocontenuto e portabile (cliente che riceve il `.md` può convertirlo in PDF anche senza avere il CSS sull'host).
**REGOLA TASSATIVA**: NON emettere mai blocchi `<style>...</style>` di tua iniziativa né tag `<link rel="stylesheet">`. Il foglio di stile è gestito esclusivamente dal sistema in fase di post-processing. Aggiungere CSS personalizzato genera conflitti di cascade e rovina il render.
## Cover (pagina 1, HTML)
```
@@ -51,7 +51,6 @@ Il documento DEVE iniziare con questo YAML frontmatter compatibile `md-to-pdf` (
```
---
stylesheet: /home/adriano/Documenti/Git_XYZ/ArcaSuite/themes/tielogic.css
pdf_options:
format: A4
margin:
@@ -66,6 +65,10 @@ pdf_options:
---
```
**Niente** `stylesheet:``css:` nel frontmatter: il sistema inietta automaticamente il foglio di stile come `<style>` inline subito dopo il frontmatter, così il file Markdown è autocontenuto e portabile (chi riceve il `.md` può convertirlo in PDF anche senza avere il CSS sull'host).
**REGOLA TASSATIVA**: NON emettere mai blocchi `<style>...</style>` di tua iniziativa né tag `<link rel="stylesheet">`. Il foglio di stile è gestito esclusivamente dal sistema in fase di post-processing. Aggiungere CSS personalizzato genera conflitti di cascade e rovina il render.
La cover (sezione 1) è impostata in CSS con `margin: -22mm` per andare bordo-a-bordo nonostante il margine top di 18mm; header/footer NON appaiono in cover (Chromium li disegna sopra il contenuto solo se questo non occupa l'intera area; la cover è pagina 1 con `page-break-after: always`).
I margini interni del corpo sono gestiti dal CSS (`@page` e `.cover`). Margini PDF a 0 sui lati per consentire alla cover scura di andare bordo-a-bordo.
@@ -272,3 +272,71 @@ async def test_generate_uses_frontmatter_model_override(tmp_path):
template_name="x", content_md="c", variables={}, instructions=None
)
assert llm.chat.await_args.kwargs["model"] == "openai/gpt-4o"
def test_inject_inline_stylesheet_after_frontmatter():
from mcp_docugen.renderer import _inject_inline_stylesheet
md = "---\ntitle: T\n---\n\n# Body\n"
out = _inject_inline_stylesheet(md, "body { color: red; }")
assert out.startswith("---\ntitle: T\n---\n")
assert "<style>\nbody { color: red; }\n</style>" in out
body_idx = out.find("# Body")
style_idx = out.find("<style>")
assert 0 < style_idx < body_idx
def test_inject_inline_stylesheet_no_frontmatter_prepends():
from mcp_docugen.renderer import _inject_inline_stylesheet
out = _inject_inline_stylesheet("# Body only", "h1 { color: blue; }")
assert out.startswith("<style>\nh1 { color: blue; }\n</style>")
def test_inject_inline_stylesheet_empty_css_is_noop():
from mcp_docugen.renderer import _inject_inline_stylesheet
md = "---\nx: 1\n---\nbody"
assert _inject_inline_stylesheet(md, "") == md
assert _inject_inline_stylesheet(md, " \n") == md
async def test_generate_injects_inline_stylesheet_when_configured(tmp_path):
css_path = tmp_path / "tielogic.css"
css_path.write_text("body { font-family: Inter; }", encoding="utf-8")
template_store = TemplateStore(base_dir=tmp_path / "templates")
generation_store = GenerationStore(
db_path=tmp_path / "gen.db",
generated_dir=tmp_path / "generated",
)
await generation_store.init()
llm = AsyncMock(spec=OpenRouterClient)
llm.chat.return_value = LLMResponse(
text="---\ntitle: T\n---\n\n# Body\n",
model="anthropic/claude-sonnet-4",
tokens_in=1,
tokens_out=1,
cost_usd=0.0,
latency_ms=10,
)
renderer = Renderer(
template_store=template_store,
generation_store=generation_store,
llm=llm,
public_base_url="https://mcp.example.com",
default_model="anthropic/claude-sonnet-4",
asset_ttl_days=30,
max_image_size_mb=10,
inline_stylesheet_path=css_path,
)
fm = TemplateFrontmatter(name="t", description="d")
await template_store.create(name="t", frontmatter=fm, body="b")
result = await renderer.generate(
template_name="t", content_md="c", variables={}, instructions=None
)
assert "<style>" in result.markdown
assert "font-family: Inter" in result.markdown
assert result.markdown.startswith("---\n")