//! Mission refiner — take the user's freeform description on a draft //! mission and rewrite it into a coherent, sectioned Markdown brief //! that downstream research + coding agents can ingest cleanly. //! //! Calls Anthropic Claude Opus 4.8 by default. Prod already carries //! ANTHROPIC_API_KEY for ZeroClaw's provider config, so no separate //! env is needed. use serde_json::json; use sqlx::PgPool; use uuid::Uuid; const DEFAULT_MODEL: &str = "claude-opus-4-8"; const ANTHROPIC_API_VERSION: &str = "2023-06-01"; fn model_name() -> String { std::env::var("CLAWMATES_REFINER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()) } pub struct RefineResult { pub original: String, pub refined: String, } /// Generate a refined description without touching the database. The /// caller (frontend) reviews the diff and calls `set_description` to /// commit — that separation makes Accept/Cancel + undo trivial without /// an audit table. pub async fn refine( pool: &PgPool, workspace_id: cm_domain::WorkspaceId, mission_id: Uuid, ) -> Result { let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid()) .await .map_err(|e| format!("load mission: {e}"))? .ok_or_else(|| "mission not found".to_string())?; if mission.status != "draft" { return Err(format!( "mission is {}, refine only allowed on draft", mission.status )); } let raw = mission.description.unwrap_or_default(); if raw.trim().is_empty() { return Err("description is empty — nothing to refine".into()); } let phase_kinds: Vec = cm_db::repo::missions::phases_for(pool, mission_id) .await .map_err(|e| format!("load phases: {e}"))? .into_iter() .map(|p| p.kind) .collect(); let refined = call_anthropic(&mission.title, &mission.template_kind, &phase_kinds, &raw).await?; Ok(RefineResult { original: raw, refined, }) } async fn call_anthropic( title: &str, template_kind: &str, phase_kinds: &[String], raw: &str, ) -> Result { let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY unset".to_string())?; let model = model_name(); let system = "You are a technical brief editor for an autonomous software \ engineering platform. Rewrite the user's raw mission description into a \ clean, sectioned Markdown brief that research + coding agents can ingest \ directly. Preserve every concrete fact, requirement, constraint, and \ acceptance criterion the user provided — do not invent new scope. \ Structure the output with these sections when the source material \ supports them (omit sections with nothing to say):\n\ \n\ # \n\ \n\ ## Objective\n\ A 1–3 sentence framing of what success looks like.\n\ \n\ ## Context & Background\n\ Any relevant prior art, files, systems, or motivation the user gave.\n\ \n\ ## Scope\n\ Bullet list of concrete deliverables (in scope). If the user \ called out non-goals, add an `### Out of scope` subsection.\n\ \n\ ## Constraints\n\ Technical, stylistic, or process constraints (languages, versions, \ style guides, migration paths, existing conventions to respect).\n\ \n\ ## Acceptance Criteria\n\ Numbered list of concrete, verifiable pass/fail conditions the \ coding agents should treat as done-definitions.\n\ \n\ ## Open Questions\n\ Only include if the source material has genuine ambiguity worth \ flagging to the research phase before coding starts.\n\ \n\ Rules:\n\ - Output raw Markdown only — no code fence around the whole doc, \ no preamble like \"Here is the refined brief\".\n\ - Never make up file paths, APIs, repo names, or version numbers.\n\ - If the user's text is very short, produce a short brief — do not \ pad with generic filler.\n\ - Use `**bold**` sparingly for load-bearing terms; do not bold entire \ sentences.\n\ - Prefer bullet lists over paragraphs for scope, constraints, and criteria."; let user = format!( "Mission title: {title}\n\ Template kind: {template_kind}\n\ Planned phases: {phases}\n\ \n\ Raw description:\n\ ---\n\ {raw}\n\ ---", phases = if phase_kinds.is_empty() { "(none configured yet)".to_string() } else { phase_kinds.join(", ") } ); // Opus 4.8 rejects the `temperature` parameter — the model runs at // its own calibrated setting. Older Claude models accepted 0.0–1.0. let body = json!({ "model": model, "max_tokens": 4096, "system": system, "messages": [ { "role": "user", "content": user } ] }); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(90)) .build() .map_err(|e| format!("http client: {e}"))?; let resp = client .post("https://api.anthropic.com/v1/messages") .header("x-api-key", &api_key) .header("anthropic-version", ANTHROPIC_API_VERSION) .header("content-type", "application/json") .json(&body) .send() .await .map_err(|e| format!("anthropic call: {e}"))?; if !resp.status().is_success() { let code = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!( "anthropic {code}: {}", &body[..body.len().min(500)] )); } let json: serde_json::Value = resp .json() .await .map_err(|e| format!("anthropic json: {e}"))?; // Anthropic Messages API returns content as an array of blocks; // the first text block holds the assistant's reply. let text = json .get("content") .and_then(|c| c.as_array()) .and_then(|arr| { arr.iter() .find(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) }) .and_then(|b| b.get("text")) .and_then(|t| t.as_str()) .ok_or_else(|| "anthropic response missing text block".to_string())? .trim() .to_string(); if text.is_empty() { return Err("anthropic returned empty text".into()); } Ok(text) }