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:
@@ -13,7 +13,7 @@ Due pezzi, stesso repo:
|
|||||||
|
|
||||||
| Servizio | Stato | Funzione |
|
| Servizio | Stato | Funzione |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `mcp-docugen` | Implementato, 72 test verde, deploy Docker via gateway Caddy (porta 8090), 6 tool MCP esposti, template seed versionati con auto-seed al boot | Genera Markdown formale da template + LLM (OpenRouter). Vedi [`docs/mcp-docugen-design.md`](docs/mcp-docugen-design.md) + [`docs/mcp-docugen-implementation.md`](docs/mcp-docugen-implementation.md). |
|
| `mcp-docugen` | Implementato, 76 test verde, deploy Docker via gateway Caddy (porta 8090), 6 tool MCP esposti, template seed versionati con auto-seed al boot, CSS Tielogic iniettato inline nel Markdown generato | Genera Markdown formale da template + LLM (OpenRouter). Vedi [`docs/mcp-docugen-design.md`](docs/mcp-docugen-design.md) + [`docs/mcp-docugen-implementation.md`](docs/mcp-docugen-implementation.md). |
|
||||||
| `mcp-convert` | Da progettare | Conversione Markdown → PDF / DOCX / HTML (pandoc/typst backend). |
|
| `mcp-convert` | Da progettare | Conversione Markdown → PDF / DOCX / HTML (pandoc/typst backend). |
|
||||||
| `mcp-inbox` | Da progettare | Ingest da Telegram (+ STT opzionale via Whisper) verso draft inbox consumati da Claude Code desktop. |
|
| `mcp-inbox` | Da progettare | Ingest da Telegram (+ STT opzionale via Whisper) verso draft inbox consumati da Claude Code desktop. |
|
||||||
|
|
||||||
@@ -88,7 +88,11 @@ Template attualmente disponibili:
|
|||||||
|
|
||||||
Per propagare modifiche al template versionato su un'installazione esistente: usare il tool MCP `template_update`, oppure rimuovere il template dal volume e fare restart del container.
|
Per propagare modifiche al template versionato su un'installazione esistente: usare il tool MCP `template_update`, oppure rimuovere il template dal volume e fare restart del container.
|
||||||
|
|
||||||
Conversione Markdown→PDF (in attesa di `mcp-convert`): `md-to-pdf <file>.md` con il CSS `themes/tielogic.css` referenziato dal frontmatter dei template.
|
Conversione Markdown→PDF (in attesa di `mcp-convert`): `md-to-pdf <file>.md`.
|
||||||
|
|
||||||
|
Il CSS Tielogic non viene referenziato come path esterno: il `Renderer` lo legge da `themes/tielogic.css` (copiato nell'immagine Docker in `/app/themes/`) e lo inietta come blocco `<style>` inline subito dopo il frontmatter del Markdown generato. Vantaggio: il file `.md` prodotto è **autocontenuto e portabile** — chi lo riceve può convertirlo in PDF stilizzato anche senza avere il CSS sull'host.
|
||||||
|
|
||||||
|
Per documenti `.md` legacy che ancora referenziano `stylesheet:` come path host: usare `scripts/bundle-css.py <file.md> [--in-place]` per rimuovere la riga e iniettare il CSS inline.
|
||||||
|
|
||||||
## Remote
|
## Remote
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ ARG BASE_TAG=latest
|
|||||||
|
|
||||||
FROM arca-base:${BASE_TAG} AS builder
|
FROM arca-base:${BASE_TAG} AS builder
|
||||||
COPY services/mcp-docugen ./services/mcp-docugen
|
COPY services/mcp-docugen ./services/mcp-docugen
|
||||||
|
COPY themes ./themes
|
||||||
RUN uv sync --frozen --no-dev --package mcp-docugen
|
RUN uv sync --frozen --no-dev --package mcp-docugen
|
||||||
|
|
||||||
FROM python:3.11-slim AS runtime
|
FROM python:3.11-slim AS runtime
|
||||||
|
|||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rendere autocontenuto un .md generato da mcp-docugen.
|
||||||
|
|
||||||
|
Sostituisce un eventuale `stylesheet: <path>` nel frontmatter con un
|
||||||
|
blocco `<style>` inline contenente il CSS letto dal path indicato (o dal
|
||||||
|
default `themes/tielogic.css` del repo). Il file risultante è portabile:
|
||||||
|
chi lo riceve può convertirlo in PDF anche senza avere il CSS sull'host.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
scripts/bundle-css.py <file.md> [--css path/to/theme.css] [--in-place]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_CSS = REPO_ROOT / "themes" / "tielogic.css"
|
||||||
|
|
||||||
|
FRONTMATTER_DELIM = "---"
|
||||||
|
|
||||||
|
|
||||||
|
def strip_stylesheet_line(frontmatter: str) -> str:
|
||||||
|
out = []
|
||||||
|
for line in frontmatter.splitlines():
|
||||||
|
if line.lstrip().startswith("stylesheet:"):
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def bundle(md_path: Path, css_path: Path, in_place: bool) -> Path:
|
||||||
|
text = md_path.read_text(encoding="utf-8")
|
||||||
|
css = css_path.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
if not text.startswith(FRONTMATTER_DELIM):
|
||||||
|
raise SystemExit(f"{md_path}: nessun frontmatter YAML iniziale")
|
||||||
|
|
||||||
|
end_idx = text.find(f"\n{FRONTMATTER_DELIM}\n", len(FRONTMATTER_DELIM))
|
||||||
|
if end_idx == -1:
|
||||||
|
raise SystemExit(f"{md_path}: chiusura frontmatter non trovata")
|
||||||
|
|
||||||
|
fm = text[: end_idx + 1]
|
||||||
|
rest = text[end_idx + len(f"\n{FRONTMATTER_DELIM}\n") :]
|
||||||
|
|
||||||
|
fm = strip_stylesheet_line(fm)
|
||||||
|
if "<style>" in rest:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{md_path}: blocco <style> già presente — niente da fare"
|
||||||
|
)
|
||||||
|
|
||||||
|
bundled = (
|
||||||
|
f"{fm}\n{FRONTMATTER_DELIM}\n\n<style>\n{css}\n</style>\n\n{rest.lstrip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if in_place:
|
||||||
|
md_path.write_text(bundled, encoding="utf-8")
|
||||||
|
return md_path
|
||||||
|
|
||||||
|
out_path = md_path.with_name(md_path.stem + ".bundled.md")
|
||||||
|
out_path.write_text(bundled, encoding="utf-8")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("md_file", type=Path)
|
||||||
|
parser.add_argument("--css", type=Path, default=DEFAULT_CSS)
|
||||||
|
parser.add_argument("--in-place", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.md_file.is_file():
|
||||||
|
raise SystemExit(f"file non trovato: {args.md_file}")
|
||||||
|
if not args.css.is_file():
|
||||||
|
raise SystemExit(f"CSS non trovato: {args.css}")
|
||||||
|
|
||||||
|
out = bundle(args.md_file, args.css, args.in_place)
|
||||||
|
print(f"OK: {out}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
|
|||||||
public_base_url: str = Field(...)
|
public_base_url: str = Field(...)
|
||||||
data_dir: Path = Path("/data")
|
data_dir: Path = Path("/data")
|
||||||
templates_seed_dir: Path = Path("/app/services/mcp-docugen/templates_seed")
|
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
|
asset_ttl_days: int = 30
|
||||||
max_image_size_mb: int = 10
|
max_image_size_mb: int = 10
|
||||||
llm_timeout_seconds: int = 60
|
llm_timeout_seconds: int = 60
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async def build_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
default_model=settings.llm_model_default,
|
default_model=settings.llm_model_default,
|
||||||
asset_ttl_days=settings.asset_ttl_days,
|
asset_ttl_days=settings.asset_ttl_days,
|
||||||
max_image_size_mb=settings.max_image_size_mb,
|
max_image_size_mb=settings.max_image_size_mb,
|
||||||
|
inline_stylesheet_path=settings.inline_stylesheet_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
mcp = build_mcp_server(template_store, renderer)
|
mcp = build_mcp_server(template_store, renderer)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
@@ -52,6 +53,33 @@ class AssetStorageError(RendererError):
|
|||||||
_ASSET_TEMPLATE_REF_RE = re.compile(r"\.\/assets\/([A-Za-z0-9._-]+)")
|
_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:
|
class Renderer:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -62,6 +90,7 @@ class Renderer:
|
|||||||
default_model: str,
|
default_model: str,
|
||||||
asset_ttl_days: int,
|
asset_ttl_days: int,
|
||||||
max_image_size_mb: int,
|
max_image_size_mb: int,
|
||||||
|
inline_stylesheet_path: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.template_store = template_store
|
self.template_store = template_store
|
||||||
self.generation_store = generation_store
|
self.generation_store = generation_store
|
||||||
@@ -70,6 +99,7 @@ class Renderer:
|
|||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.asset_ttl_days = asset_ttl_days
|
self.asset_ttl_days = asset_ttl_days
|
||||||
self.max_image_size_bytes = max_image_size_mb * 1024 * 1024
|
self.max_image_size_bytes = max_image_size_mb * 1024 * 1024
|
||||||
|
self.inline_stylesheet_path = inline_stylesheet_path
|
||||||
|
|
||||||
async def generate(
|
async def generate(
|
||||||
self,
|
self,
|
||||||
@@ -126,16 +156,19 @@ class Renderer:
|
|||||||
|
|
||||||
expires_at = None
|
expires_at = None
|
||||||
if ephemeral_urls:
|
if ephemeral_urls:
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
info = await self.generation_store.get_ephemeral_asset(
|
info = await self.generation_store.get_ephemeral_asset(
|
||||||
gen_id, Path(ephemeral_urls[0]).name
|
gen_id, Path(ephemeral_urls[0]).name
|
||||||
)
|
)
|
||||||
expires_at = info.expires_at if info else None
|
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(
|
return GenerationResult(
|
||||||
generation_id=gen_id,
|
generation_id=gen_id,
|
||||||
markdown=response.text,
|
markdown=markdown_out,
|
||||||
model=response.model,
|
model=response.model,
|
||||||
tokens=TokenUsage(input=response.tokens_in, output=response.tokens_out),
|
tokens=TokenUsage(input=response.tokens_in, output=response.tokens_out),
|
||||||
cost_usd=response.cost_usd,
|
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:
|
pdf_options:
|
||||||
format: A4
|
format: A4
|
||||||
margin:
|
margin:
|
||||||
@@ -54,6 +53,10 @@ pdf_options:
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Niente** `stylesheet:` né `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)
|
## 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:
|
pdf_options:
|
||||||
format: A4
|
format: A4
|
||||||
margin:
|
margin:
|
||||||
@@ -66,6 +65,10 @@ pdf_options:
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Niente** `stylesheet:` né `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`).
|
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.
|
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
|
template_name="x", content_md="c", variables={}, instructions=None
|
||||||
)
|
)
|
||||||
assert llm.chat.await_args.kwargs["model"] == "openai/gpt-4o"
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user