//! 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. //! //! Uses Gemini 2.5 Flash (same call shape as level_up.rs) but with a //! text-mode response — we want Markdown out, not JSON. use serde_json::json; use sqlx::PgPool; use uuid::Uuid; const DEFAULT_MODEL: &str = "gemini-2.5-flash"; 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_gemini(&mission.title, &mission.template_kind, &phase_kinds, &raw).await?; Ok(RefineResult { original: raw, refined }) } async fn call_gemini( title: &str, template_kind: &str, phase_kinds: &[String], raw: &str, ) -> Result { let api_key = std::env::var("GEMINI_API_KEY").map_err(|_| "GEMINI_API_KEY unset".to_string())?; let model = model_name(); let url = format!( "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}" ); 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(", ") } ); let body = json!({ "system_instruction": { "parts": [{ "text": system }] }, "contents": [{ "role": "user", "parts": [{ "text": user }] }], "generationConfig": { "temperature": 0.3, "maxOutputTokens": 4096, } }); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) .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())? .trim() .to_string(); if text.is_empty() { return Err("gemini returned empty text".into()); } Ok(text) }