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]>
260 lines
9.5 KiB
Rust
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");
|
|
}
|
|
}
|