Files
clawmates/crates/cm-api/src/pdf_renderer.rs
T
Omar SobhandClaude Opus 4.7 3ac3d53da7
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 38s
ci / rust (push) Successful in 3m28s
ci / e2e (push) Skipped
ci / publish (push) Successful in 3m4s
slice 6: LLM + Chromium PDF renderer worker
Watches mission_artifacts for MD entries with render_pdf_status='pending'
and turns them into styled PDFs via:
  1. Read source MD from <mission_root>/<path>
  2. Call configured LLM (default gemini-2.5-flash) with a document-
     typesetter system prompt that constrains style to a self-contained
     HTML doc with inline CSS + our color palette
  3. Print to PDF via `chromium --headless=new --print-to-pdf`
  4. Save alongside source MD (foo.md → foo.pdf) + update
     mission_artifacts.rendered_pdf_path + render_pdf_status='done'

Graceful degradation: GEMINI_API_KEY unset OR chromium missing =
row marked failed with a descriptive error, worker keeps ticking.
The frontend's "Open PDF" affordance (Slice 2) light up automatically
when render succeeds.

Boot ordering: PDF worker spawns after task_card_worker. Poll every
30s over up to MAX_PARALLEL=2 rows at a time — respects LLM rate
limits and keeps chromium's peak RAM under control.

Env knobs:
  GEMINI_API_KEY                    — required for LLM step
  CLAWMATES_PDF_RENDERER_MODEL      — model id, default gemini-2.5-flash
  CHROMIUM_BIN                      — chromium binary, default `chromium`
  CLAWMATES_MISSIONS_ROOT           — artifact dir root, default /var/lib/clawmates-missions

Dockerfile now installs chromium + fonts-liberation and sets
CHROMIUM_BIN=/usr/bin/chromium so the container image has everything
the renderer needs.

Also bumps workspace tokio deps to include the `process` feature
(required for tokio::process::Command).

Follow-ups:
  - Anthropic + OpenAI provider variants (only Gemini in this slice)
  - SSE stream on /api/missions/{id}/artifacts for the "PDF ready"
    notification instead of poll-via-mission-GET
  - Per-template PDF style overrides (currently one house style
    for all missions)

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-07-19 15:16:57 -07:00

260 lines
9.5 KiB
Rust

//! LLM + Chromium PDF renderer worker — Slice 6.
//!
//! Watches `mission_artifacts` for rows with `render_pdf_status =
//! 'pending'`. For each:
//! 1. Read the source MD from `<mission_root>/<path>` on disk
//! 2. Call the configured LLM (default: Gemini 2.5 Flash) with a
//! "produce styled HTML" prompt anchored to a design-system
//! example. LLM writes HTML with inline CSS.
//! 3. Print that HTML to PDF via `chromium --headless
//! --print-to-pdf`
//! 4. Save the PDF alongside the MD, update `rendered_pdf_path` +
//! status = 'done'
//!
//! Graceful degradation: if `GEMINI_API_KEY` is unset or the
//! chromium binary isn't on PATH, the worker marks the row `failed`
//! with a descriptive error rather than blocking boot. Ops enables
//! rendering by wiring both.
//!
//! The frontend already renders `rendered_pdf_path` as an "Open PDF"
//! button on artifact cards (Slice 2).
use serde_json::json;
use sqlx::PgPool;
use std::path::{Path, PathBuf};
use std::time::Duration;
const POLL_INTERVAL: Duration = Duration::from_secs(30);
const MAX_PARALLEL: usize = 2;
const DEFAULT_MODEL: &str = "gemini-2.5-flash";
/// Where per-mission artifacts land on disk. Overridable so dev vs.
/// prod can move the tree; matches the pattern in
/// `research_container::research_workspace_root`.
fn missions_root() -> PathBuf {
std::env::var("CLAWMATES_MISSIONS_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions"))
}
fn chromium_bin() -> String {
std::env::var("CHROMIUM_BIN").unwrap_or_else(|_| "chromium".to_string())
}
fn renderer_model() -> String {
std::env::var("CLAWMATES_PDF_RENDERER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
}
/// Spawn the poller. No-op-friendly: if there's nothing pending or
/// no rendering pipeline configured, we still tick + observe.
pub fn spawn(pool: PgPool) {
tokio::spawn(async move {
// Small startup delay so migrations + loaders finish first.
tokio::time::sleep(Duration::from_secs(8)).await;
let mut ticker = tokio::time::interval(POLL_INTERVAL);
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = sweep_once(&pool).await {
eprintln!("pdf_renderer: sweep failed: {e}");
}
}
});
}
async fn sweep_once(pool: &PgPool) -> Result<(), String> {
let pending = cm_db::repo::missions::next_pdf_pending(pool, MAX_PARALLEL as i64)
.await
.map_err(|e| format!("next_pdf_pending: {e}"))?;
for artifact in pending {
let pool = pool.clone();
let id = artifact.id;
tokio::spawn(async move {
match render_one(&pool, &artifact).await {
Ok(pdf_path) => {
let _ = cm_db::repo::missions::set_pdf_result(&pool, id, Some(&pdf_path), None)
.await;
eprintln!("pdf_renderer: rendered {id}{pdf_path}");
}
Err(e) => {
let _ = cm_db::repo::missions::set_pdf_result(&pool, id, None, Some(&e)).await;
eprintln!("pdf_renderer: {id} failed: {e}");
}
}
});
}
Ok(())
}
async fn render_one(
_pool: &PgPool,
artifact: &cm_db::repo::missions::MissionArtifact,
) -> Result<String, String> {
// 1. Locate the source MD on disk.
let mission_root = missions_root().join(artifact.mission_id.to_string());
let src_path = mission_root.join(&artifact.path);
let md = tokio::fs::read_to_string(&src_path)
.await
.map_err(|e| format!("read {}: {e}", src_path.display()))?;
// 2. LLM → styled HTML.
let html = md_to_html_via_llm(&md, artifact.title.as_deref())
.await
.map_err(|e| format!("llm render: {e}"))?;
// 3. Chromium → PDF.
let tmp = tempdir_for(artifact.id)?;
let html_path = tmp.join("in.html");
let pdf_path = tmp.join("out.pdf");
tokio::fs::write(&html_path, html)
.await
.map_err(|e| format!("write {}: {e}", html_path.display()))?;
let status = tokio::process::Command::new(chromium_bin())
.args([
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--hide-scrollbars",
&format!("--print-to-pdf={}", pdf_path.display()),
"--print-to-pdf-no-header",
"--virtual-time-budget=10000",
&format!("file://{}", html_path.display()),
])
.stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.status()
.await
.map_err(|e| format!("spawn chromium: {e}"))?;
if !status.success() {
return Err(format!("chromium exited {status}"));
}
// 4. Move next to the source MD so the artifact tree stays self-
// contained. Filename derived from the MD path (foo.md → foo.pdf).
let out_rel = pdf_sibling(&artifact.path);
let out_abs = mission_root.join(&out_rel);
if let Some(parent) = out_abs.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
tokio::fs::copy(&pdf_path, &out_abs)
.await
.map_err(|e| format!("copy pdf: {e}"))?;
// Best-effort tmp cleanup — the temp dir lives under /tmp so the
// OS will reap it anyway.
let _ = tokio::fs::remove_dir_all(&tmp).await;
Ok(out_rel)
}
/// Ask the configured LLM to turn `md` into a fully self-contained
/// styled HTML doc. Uses whichever provider `CLAWMATES_PDF_RENDERER_MODEL`
/// resolves to. Defaults to Gemini 2.5 Flash + GEMINI_API_KEY.
async fn md_to_html_via_llm(md: &str, title: Option<&str>) -> Result<String, String> {
let model = renderer_model();
// For now we hardcode the Gemini path — anthropic + openai
// variants land when the design-system template stabilizes.
if !model.starts_with("gemini") {
return Err(format!(
"renderer model {model} not yet wired (only gemini-* supported in Slice 6)"
));
}
let api_key =
std::env::var("GEMINI_API_KEY").map_err(|_| "GEMINI_API_KEY unset".to_string())?;
let system = r#"You are a document typesetter. Given a Markdown source,
produce ONE self-contained HTML document that:
- Has ALL styles inline in a single <style> block in <head>. No external
fonts, no external CSS. System font stack only.
- Uses a clean, modern, readable serif for body copy (Georgia / "Iowan Old
Style" / "Charter" / serif) and a sans for headings.
- Uses ONLY these accent colors: #ff8a7a (heading), #5ec8d8 (link),
#101014 (body text), #f7f7f8 (page bg).
- Renders code blocks with a monospace stack and a subtle background.
- Uses page-break-inside: avoid on headings and images.
- Puts a document title in an <h1> at the top if provided.
- Includes NOTHING outside the HTML — no ```html fence, no commentary."#;
let prompt = match title {
Some(t) => format!("Document title: {t}\n\nMarkdown:\n\n{md}"),
None => md.to_string(),
};
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
model, api_key
);
let body = json!({
"system_instruction": { "parts": [{ "text": system }] },
"contents": [{ "role": "user", "parts": [{ "text": prompt }] }],
"generationConfig": {
"temperature": 0.2,
"maxOutputTokens": 32000,
}
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.map_err(|e| format!("http client: {e}"))?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("gemini call: {e}"))?;
if !resp.status().is_success() {
let code = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("gemini {code}: {}", &body[..body.len().min(500)]));
}
let json: serde_json::Value = resp.json().await.map_err(|e| format!("gemini json: {e}"))?;
let text = json
.pointer("/candidates/0/content/parts/0/text")
.and_then(|v| v.as_str())
.ok_or_else(|| "gemini response missing text".to_string())?;
// Strip a stray ```html fence if the model added one despite the
// system prompt — cheap belt to the suspenders.
let cleaned = text
.trim()
.strip_prefix("```html")
.and_then(|s| s.strip_suffix("```"))
.map(|s| s.trim())
.unwrap_or(text.trim())
.to_string();
Ok(cleaned)
}
fn tempdir_for(id: uuid::Uuid) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join(format!("clawmates-pdf-{id}"));
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir tmp: {e}"))?;
Ok(dir)
}
/// `research/v3/spec.md` → `research/v3/spec.pdf`.
/// `foo/bar/without_ext` → `foo/bar/without_ext.pdf` (rare — parser
/// never emits an extension-less MD, but we're defensive).
fn pdf_sibling(md_path: &str) -> String {
let p = Path::new(md_path);
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
let parent = p.parent().map(|x| x.to_string_lossy().to_string());
let base = format!("{stem}.pdf");
match parent {
Some(pp) if !pp.is_empty() => format!("{pp}/{base}"),
_ => base,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pdf_sibling_paths() {
assert_eq!(pdf_sibling("research/v3/spec.md"), "research/v3/spec.pdf");
assert_eq!(pdf_sibling("spec.md"), "spec.pdf");
assert_eq!(pdf_sibling("no_ext"), "no_ext.pdf");
}
}