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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user