//! 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. //! //! Asks for Claude Opus 4.8 by default, but goes through //! `subscription::complete_with_fallback` like every other server-side model //! call. It used to hand-roll its own HTTPS POST to the Messages API with the //! metered key — a comment above this line still claimed prod "already carries //! ANTHROPIC_API_KEY, so no separate env is needed", which stopped being true //! the moment that account ran out of credit. See `subscription`, whose //! source-walk test is what found this module. use sqlx::PgPool; use uuid::Uuid; const DEFAULT_MODEL: &str = "claude-opus-4-8"; 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, } /// Refine a description that has no mission behind it yet. /// /// The wizard's polish button runs BEFORE the mission is created — there is no /// row to load and no id to pass — while [`refine`] deliberately requires a /// saved draft so Accept/Cancel can write back to it. Same prompt, same model /// chain; only where the inputs come from differs. pub async fn refine_draft( runtime: &cm_runtime::Runtime, title: &str, template_kind: &str, phase_kinds: &[String], raw: &str, ) -> Result { if raw.trim().is_empty() { return Err("description is empty — nothing to refine".into()); } let refined = call_anthropic(runtime, title, template_kind, phase_kinds, raw).await?; Ok(RefineResult { original: raw.to_string(), refined, }) } /// 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, runtime: &cm_runtime::Runtime, 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(runtime, &mission.title, &mission.template_kind, &phase_kinds, &raw) .await?; Ok(RefineResult { original: raw, refined, }) } async fn call_anthropic( runtime: &cm_runtime::Runtime, title: &str, template_kind: &str, phase_kinds: &[String], raw: &str, ) -> Result { 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, and // `ChatRequest` does not carry one, so nothing is lost by the move. let (text, answered_by) = crate::subscription::complete_with_fallback(runtime, system, &user, &model, 4096, false) .await?; let text = text.trim().to_string(); if text.is_empty() { return Err(format!("{answered_by} returned empty text")); } Ok(text) }