Add an inline-rendering, versionable html artifact kind

Add an html kind to the artifact generator: it renders a styled HTML report from
a stored JSON spec (title + heading/paragraph/list/table/code blocks), producing
text/html bytes the sidebar already previews inline in the sandboxed iframe,
while keeping the spec as the source of truth so edit and rewrite make real
versions. So a single artifact both renders inline and versions conversationally.
The renderer is a fixed program fed the spec as data, and every spec text value
is HTML-escaped into the output.
This commit is contained in:
Alex committed 2026-06-24 16:30:26 +01:00
1 parent d573bab4a0
commit 6c8a72c836
3 files changed
+286 -7

No files matched your search

+146 -7
View File
@@ -1,7 +1,7 @@
"""Artifact Generator tool: render editable documents from a JSON spec and version them append-only.
The ``artifact_versions.spec`` JSONB is the source of truth; the rendered
``.pptx``/``.docx``/``.xlsx``/``.pdf`` is derived. ``create_artifact`` stores
``.pptx``/``.docx``/``.xlsx``/``.pdf``/``.html`` is derived. ``create_artifact`` stores
v1, ``edit_artifact`` applies an RFC 7386 merge-patch to the current spec and
appends a version, ``rewrite_artifact`` replaces the spec wholesale and appends
a version. Rendering runs a FIXED program in the sandbox that reads the spec as
@@ -58,6 +58,11 @@ _KIND_INFO: Dict[str, Dict[str, str]] = {
"ext": "pdf",
"mime": "application/pdf",
},
"html": {
"kind": "html",
"ext": "html",
"mime": "text/html",
},
}
# Tight per-kind JSON schemas. ``additionalProperties: false`` keeps specs minimal
@@ -144,6 +149,72 @@ _SCHEMAS: Dict[str, Dict[str, Any]] = {
},
"required": ["blocks"],
},
"html": {
"type": "object",
"additionalProperties": False,
"properties": {
"title": {"type": "string"},
"blocks": {
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"additionalProperties": False,
"properties": {
"type": {"const": "heading"},
"text": {"type": "string"},
"level": {"type": "integer", "minimum": 1, "maximum": 3},
},
"required": ["type", "text"],
},
{
"type": "object",
"additionalProperties": False,
"properties": {
"type": {"const": "paragraph"},
"text": {"type": "string"},
},
"required": ["type", "text"],
},
{
"type": "object",
"additionalProperties": False,
"properties": {
"type": {"const": "list"},
"ordered": {"type": "boolean"},
"items": {"type": "array", "items": {"type": "string"}},
},
"required": ["type", "items"],
},
{
"type": "object",
"additionalProperties": False,
"properties": {
"type": {"const": "table"},
"headers": {"type": "array", "items": {"type": "string"}},
"rows": {
"type": "array",
"items": {"type": "array", "items": {"type": "string"}},
},
},
"required": ["type", "rows"],
},
{
"type": "object",
"additionalProperties": False,
"properties": {
"type": {"const": "code"},
"text": {"type": "string"},
},
"required": ["type", "text"],
},
],
},
},
},
"required": ["blocks"],
},
}
# FIXED renderer programs. Each reads ``spec.json`` from the workspace as DATA
@@ -227,6 +298,66 @@ _RENDERERS: Dict[str, str] = {
" story.append(Spacer(1, 6))\n"
"SimpleDocTemplate({out_path!r}, pagesize=letter).build(story)\n"
),
"html": (
"import json\n"
"import html\n"
"spec = json.load(open({spec_path!r}))\n"
"def esc(value):\n"
" return html.escape('' if value is None else str(value))\n"
"parts = []\n"
"title = spec.get('title')\n"
"if title:\n"
" parts.append('<h1>' + esc(title) + '</h1>')\n"
"for block in spec.get('blocks', []):\n"
" kind = block.get('type')\n"
" if kind == 'heading':\n"
" level = block.get('level') or 2\n"
" try:\n"
" level = int(level)\n"
" except (TypeError, ValueError):\n"
" level = 2\n"
" level = min(max(level, 1), 3) + 1\n"
" parts.append('<h%d>%s</h%d>' % (level, esc(block.get('text', '')), level))\n"
" elif kind == 'paragraph':\n"
" parts.append('<p>' + esc(block.get('text', '')) + '</p>')\n"
" elif kind == 'list':\n"
" tag = 'ol' if block.get('ordered') else 'ul'\n"
" items = ''.join('<li>' + esc(i) + '</li>' for i in (block.get('items') or []))\n"
" parts.append('<%s>%s</%s>' % (tag, items, tag))\n"
" elif kind == 'table':\n"
" rows_html = []\n"
" headers = block.get('headers')\n"
" if headers:\n"
" cells = ''.join('<th>' + esc(h) + '</th>' for h in headers)\n"
" rows_html.append('<thead><tr>' + cells + '</tr></thead>')\n"
" body = []\n"
" for row in (block.get('rows') or []):\n"
" cells = ''.join('<td>' + esc(c) + '</td>' for c in row)\n"
" body.append('<tr>' + cells + '</tr>')\n"
" rows_html.append('<tbody>' + ''.join(body) + '</tbody>')\n"
" parts.append('<table>' + ''.join(rows_html) + '</table>')\n"
" elif kind == 'code':\n"
" parts.append('<pre><code>' + esc(block.get('text', '')) + '</code></pre>')\n"
# CSS braces are doubled so the outer ``_RENDERERS[kind].format(...)`` leaves them literal.
"css = (\n"
" 'body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;'\n"
" 'line-height:1.6;color:#1a1a1a;max-width:800px;margin:0 auto;padding:24px}}'\n"
" 'h1,h2,h3,h4{{line-height:1.25;margin:1.2em 0 0.5em}}'\n"
" 'table{{border-collapse:collapse;width:100%;margin:1em 0}}'\n"
" 'th,td{{border:1px solid #d0d0d0;padding:6px 10px;text-align:left}}'\n"
" 'th{{background:#f5f5f5}}'\n"
" 'pre{{background:#f5f5f5;padding:12px;border-radius:6px;overflow:auto}}'\n"
" 'code{{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}}'\n"
")\n"
"doc = (\n"
" '<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">'\n"
" '<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">'\n"
" '<title>' + esc(title or 'Report') + '</title>'\n"
" '<style>' + css + '</style></head><body>'\n"
" + ''.join(parts) + '</body></html>'\n"
")\n"
"open({out_path!r}, 'w', encoding='utf-8').write(doc)\n"
),
}
@@ -247,7 +378,7 @@ def merge_patch(target: Any, patch: Any) -> Any:
class ArtifactGeneratorTool(Tool):
"""Artifact Generator
Render editable documents (presentation/document/spreadsheet/pdf) from a JSON spec and version them.
Render editable documents (presentation/document/spreadsheet/pdf/html) from a JSON spec and version them.
"""
def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None:
@@ -277,7 +408,13 @@ class ArtifactGeneratorTool(Tool):
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": kinds, "description": "Document kind to render."},
"kind": {
"type": "string",
"enum": kinds,
"description": (
"Document kind to render; `html` is an inline-rendered, versionable HTML report."
),
},
"title": {"type": "string", "description": "Optional artifact title."},
"spec": {"type": "object", "description": "Document spec matching the kind's schema."},
},
@@ -373,7 +510,7 @@ class ArtifactGeneratorTool(Tool):
return {"status": "error", "error": rendered["error"]}
info = _KIND_INFO[kind]
filename = self._filename(title, info["ext"])
filename = self._filename(title, kind)
try:
ref = persist_new_artifact(
user_id=self.user_id,
@@ -422,7 +559,7 @@ class ArtifactGeneratorTool(Tool):
if rendered.get("error"):
return {"status": "error", "error": rendered["error"]}
info = _KIND_INFO[kind]
filename = self._filename(None, info["ext"])
filename = self._filename(None, kind)
try:
ref = append_artifact_version(
user_id=self.user_id,
@@ -565,10 +702,12 @@ class ArtifactGeneratorTool(Tool):
}
@staticmethod
def _filename(title: Optional[str], ext: str) -> str:
def _filename(title: Optional[str], kind: str) -> str:
"""Derive a download filename from a title (or a generic stem) plus the kind extension."""
if kind == "html":
return "report.html"
stem = (title or "artifact").strip() or "artifact"
return f"{stem}.{ext}"
return f"{stem}.{_KIND_INFO[kind]['ext']}"
def _resolve_session_id(self) -> Optional[str]:
"""Derive the sandbox session id from the bound conversation/run; sanitize to the gateway charset."""
@@ -158,3 +158,84 @@ def test_pdf_renderer_escapes_markup_and_does_not_execute():
spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
out_path = _render_in_process("pdf", spec)
assert os.path.getsize(out_path) > 0
# ---------------------------------------------------------------------------
# html kind: schema, renderer injection safety, output escaping
# ---------------------------------------------------------------------------
def test_validate_accepts_html_spec():
spec = {
"title": "Report",
"blocks": [
{"type": "heading", "text": "Intro", "level": 2},
{"type": "paragraph", "text": "Body."},
{"type": "list", "ordered": True, "items": ["a", "b"]},
{"type": "table", "headers": ["h"], "rows": [["c"]]},
{"type": "code", "text": "print(1)"},
],
}
assert _tool()._validate("html", spec) is None
def test_validate_rejects_html_unknown_block_key():
err = _tool()._validate("html", {"blocks": [{"type": "paragraph", "text": "x", "bogus": 1}]})
assert err["status"] == "error"
def test_validate_rejects_html_bad_heading_level():
err = _tool()._validate("html", {"blocks": [{"type": "heading", "text": "x", "level": 9}]})
assert err["status"] == "error"
def test_html_renderer_does_not_execute_spec_code(capfd, tmp_path):
# The renderer json.loads the spec as data; a value that is Python source
# must never run. If the program string-interpolated the spec it would.
sentinel = tmp_path / "pwned.txt"
payload = (
f"'''__import__('os').system('echo PWNED > {sentinel}')'''; "
"print('SHOULD_NOT_PRINT')"
)
spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
out_path = _render_in_process("html", spec)
captured = capfd.readouterr()
assert "SHOULD_NOT_PRINT" not in captured.out
assert "PWNED" not in captured.out
assert not sentinel.exists()
assert os.path.getsize(out_path) > 0
def test_html_renderer_escapes_spec_markup():
# Spec text carrying live markup must appear HTML-ESCAPED in the output, so
# no raw <script>/<img onerror> tag from spec content reaches the document.
script = "<script>alert(1)</script>"
img = '<img src=x onerror="alert(2)">'
spec = {
"title": script,
"blocks": [
{"type": "paragraph", "text": img},
{"type": "heading", "text": "<b>h</b>", "level": 1},
{"type": "list", "items": ["<i>x</i>"]},
{"type": "table", "headers": ["<u>th</u>"], "rows": [["<em>td</em>"]]},
{"type": "code", "text": "</code><script>x</script>"},
],
}
with open(_render_in_process("html", spec), encoding="utf-8") as handle:
html_doc = handle.read()
# No live tag from spec content survives.
assert "<script>alert(1)</script>" not in html_doc
assert "<img src=x onerror=" not in html_doc
assert "<b>h</b>" not in html_doc
assert "<i>x</i>" not in html_doc
assert "<u>th</u>" not in html_doc
assert "<em>td</em>" not in html_doc
# The escaped forms are present instead.
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html_doc
assert "&lt;img src=x onerror=" in html_doc
# The fixed structural HTML the renderer emits is intact.
assert "<!doctype html>" in html_doc
assert "<p>" in html_doc and "</p>" in html_doc
assert "<table>" in html_doc and "<pre><code>" in html_doc
@@ -211,6 +211,65 @@ def test_create_spreadsheet_xlsx(wired_tool):
assert ws.cell(row=2, column=2).value == 2
def test_create_edit_html_versions(wired_tool):
tool, conversation_id, pg_engine, storage = wired_tool
# create -> v1: an html report rendered to report.html (kind html, text/html).
spec = {
"title": "Findings <hi>",
"blocks": [
{"type": "heading", "text": "Overview", "level": 2},
{"type": "paragraph", "text": "Body <script>alert(1)</script>."},
{"type": "list", "ordered": False, "items": ["one", "two"]},
{"type": "table", "headers": ["k", "v"], "rows": [["a", "1"]]},
],
}
created = tool.execute_action("create_artifact", kind="html", title="Report", spec=spec)
assert created["status"] == "ok", created
assert created["version"] == 1
assert created["mime_type"] == "text/html"
assert created["filename"] == "report.html"
assert created["ref"] == "A1"
artifact_id = created["artifact_id"]
with pg_engine.connect() as conn:
repo = ArtifactsRepository(conn)
artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=conversation_id)
v1 = repo.get_version(artifact_id, 1)
assert artifact["kind"] == "html"
assert artifact["current_version"] == 1
assert v1["spec"] == spec
assert v1["produced_by"]["spec_kind"] == "html"
# The stored bytes are a valid HTML doc with escaped title/blocks (no live tag).
html_doc = storage.get_file(v1["storage_path"]).read().decode("utf-8")
assert html_doc.startswith("<!doctype html>")
assert "Findings &lt;hi&gt;" in html_doc
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html_doc
assert "<script>alert(1)</script>" not in html_doc
assert "<li>one</li>" in html_doc and "<td>a</td>" in html_doc
# edit (merge-patch) -> v2: replace the title, keep v1 intact.
edited = tool.execute_action("edit_artifact", id="A1", spec_patch={"title": "Updated"})
assert edited["status"] == "ok", edited
assert edited["version"] == 2
assert edited["artifact_id"] == artifact_id
assert edited["ref"] == "A1"
with pg_engine.connect() as conn:
repo = ArtifactsRepository(conn)
artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=conversation_id)
v1_again = repo.get_version(artifact_id, 1)
v2 = repo.get_version(artifact_id, 2)
assert artifact["current_version"] == 2
# Append-only: v1 unchanged; v2 carries the merged spec re-rendered.
assert v1_again["spec"] == spec
assert v2["spec"]["title"] == "Updated"
assert len(v2["spec"]["blocks"]) == 4
v2_doc = storage.get_file(v2["storage_path"]).read().decode("utf-8")
assert "<h1>Updated</h1>" in v2_doc
def test_invalid_spec_creates_no_artifact(wired_tool):
tool, conversation_id, pg_engine, _storage = wired_tool