use crate::planner::PlanInput; use crate::model::ProjectPlan; use serde_json::json; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Backend { Auto, Ollama, Llamacpp, } impl Backend { pub fn parse(s: &str) -> Self { match s.to_lowercase().as_str() { "ollama" => Backend::Ollama, "llama" | "llamacpp" | "llama.cpp" => Backend::Llamacpp, _ => Backend::Auto, } } } #[derive(Clone)] pub struct LlmConfig { pub backend: Backend, pub model: String, pub ollama_url: String, pub llama_url: String, } impl LlmConfig { pub fn new(backend: Backend, model: Option, ollama_url: Option, llama_url: Option) -> Self { Self { backend, model: model.unwrap_or_else(default_model), ollama_url: ollama_url.unwrap_or_else(default_ollama_url), llama_url: llama_url.unwrap_or_else(default_llama_url), } } } pub fn default_model() -> String { std::env::var("PLANNER_MODEL").unwrap_or_else(|_| "phi4:14b-longctx".to_string()) } pub fn max_tokens() -> u32 { std::env::var("PLANNER_MAX_TOKENS").ok().and_then(|s| s.parse().ok()).unwrap_or(16384) } pub fn default_ollama_url() -> String { std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://localhost:11434".to_string()) } pub fn default_llama_url() -> String { std::env::var("LLAMA_CPP_URL").unwrap_or_else(|_| "http://localhost:8085".to_string()) } pub fn ollama_generate(model: &str, base_url: &str, prompt: &str) -> anyhow::Result { let url = format!("{}/api/generate", base_url.trim_end_matches('/')); let body = json!({ "model": model, "prompt": prompt, "system": "Return only the final requested Markdown sections. Do not include reasoning.", "stream": false, "think": false, "options": {"temperature": 0.3, "num_predict": max_tokens()} }); let resp = ureq::post(&url) .set("Content-Type", "application/json") .timeout(std::time::Duration::from_secs(300)) .send_json(body)?; let text = resp.into_string()?; let v: serde_json::Value = serde_json::from_str(&text)?; Ok(v.get("response").and_then(|x| x.as_str()).unwrap_or("").to_string()) } pub fn llamacpp_generate(base_url: &str, prompt: &str) -> anyhow::Result { let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); let body = json!({ "model": "local", "messages": [ {"role":"system","content": "Return only the final requested Markdown sections. Do not include reasoning, analysis, or chain-of-thought."}, {"role":"user","content": prompt} ], "max_tokens": max_tokens(), "temperature": 0.3, "chat_template_kwargs": {"thinking": false} }); let resp = ureq::post(&url) .set("Content-Type", "application/json") .timeout(std::time::Duration::from_secs(300)) .send_json(body)?; let text = resp.into_string()?; let v: serde_json::Value = serde_json::from_str(&text)?; let msg = v.get("choices").and_then(|c| c.get(0)).and_then(|c| c.get("message")); let content = msg.and_then(|m| m.get("content")).and_then(|x| x.as_str()).unwrap_or(""); let reasoning = msg.and_then(|m| m.get("reasoning_content")).and_then(|x| x.as_str()).unwrap_or(""); // Prefer content; if empty, extract from reasoning starting at first "## " if !content.trim().is_empty() && content.contains("## ") { return Ok(content.to_string()); } if !content.trim().is_empty() { return Ok(content.to_string()); } // extract final sections from reasoning if let Some(idx) = reasoning.find("## ") { return Ok(reasoning[idx..].to_string()); } Ok(reasoning.to_string()) } pub fn generate(cfg: &LlmConfig, prompt: &str) -> anyhow::Result { match cfg.backend { Backend::Ollama => ollama_generate(&cfg.model, &cfg.ollama_url, prompt), Backend::Llamacpp => llamacpp_generate(&cfg.llama_url, prompt), Backend::Auto => { if let Ok(out) = llamacpp_generate(&cfg.llama_url, prompt) { if !out.trim().is_empty() { return Ok(out); } } ollama_generate(&cfg.model, &cfg.ollama_url, prompt) } } } pub fn parse_feature_lines(text: &str) -> Vec { let mut out = Vec::new(); for line in text.lines() { let l = line.trim().trim_start_matches(['-', '*', '•', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ')', ' ']).trim().to_string(); if l.is_empty() { continue; } let low = l.to_lowercase(); if low.contains("need ") || low.contains("let's") || low.contains("we ") || low.contains("i ") || low.contains("think") || low.contains("analyze") || low.contains("provided") || low.contains("intended") || low.contains("maybe") || low.contains("user") || low.contains("features:") || l.contains(':') || l.contains('?') || low.contains("##") || low.contains("```") || l.len() > 120 { continue; } out.push(l); } out } pub fn refine_features(cfg: &LlmConfig, features: &[String], project_type: &str, description: &str) -> anyhow::Result> { if features.is_empty() { return Ok(Vec::new()); } let prompt = format!( "You are a software architect. Restate each of the following features for a {} project ({}) in a slightly more technical, implementation-oriented way. Return ONLY a JSON array of strings, one per feature, no other text.\n\nFeatures:\n{}", project_type, description, features.join("\n") ); let out = generate(cfg, &prompt)?; // try JSON first if let Some(start) = out.find('[') { if let Some(end) = out.rfind(']') { if end > start { if let Ok(arr) = serde_json::from_str::>(&out[start..=end]) { let mut refined = Vec::new(); for s in arr { let l = s.trim().to_string(); if l.is_empty() { continue; } let low = l.to_lowercase(); if low.contains("need ") || low.contains("let's") || low.contains("we ") || low.contains("i ") || low.contains("think") || low.contains("analyze") || low.contains("provided") || low.contains("intended") || low.contains("maybe") || low.contains("user") || l.contains(':') || l.contains('?') || l.len() > 120 { continue; } refined.push(l); } if !refined.is_empty() && refined.len() >= features.len() / 2 { return Ok(refined); } } } } } // strict line fallback let text = if let Some(idx) = out.find("## ") { &out[idx..] } else { &out }; let mut refined = Vec::new(); for line in text.lines() { let l = line.trim().trim_start_matches(['-', '*', '•', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ')', ' ']).trim().to_string(); if l.is_empty() { continue; } let low = l.to_lowercase(); if low.contains("need ") || low.contains("let's") || low.contains("we ") || low.contains("i ") || low.contains("think") || low.contains("analyze") || low.contains("provided") || low.contains("intended") || low.contains("maybe") || low.contains("user") || low.contains("features:") || l.contains(':') || l.contains('?') || low.contains("##") || low.contains("```") || l.len() > 120 { continue; } refined.push(l); } if refined.is_empty() || refined.len() < features.len() / 2 { Ok(features.to_vec()) } else { Ok(refined) } } pub fn plan_assistance(cfg: &LlmConfig, input: &PlanInput, plan: &ProjectPlan) -> anyhow::Result { let prompt = format!( r#"You are an expert software architect assisting a project planner. Given the following project, produce a concise enhancement in Markdown with these sections: ## AI-Suggested Refinements - Clarified scope and non-goals. ## Recommended Additional Libraries - Bullet list with reason. ## Architecture Notes - 3-6 bullets. ## Extra Tasks - 5-8 concrete implementation tasks as checkboxes. ## Risks to Watch - 3-5 bullets. ## Common Pitfalls (based on ideas/features) - 5-8 concrete pitfalls specific to this project's ideas and features. Project: Name: {name} Description: {desc} Type: {ptype} Language: {lang} Scale: {scale} Database: {db} Auth: {auth} Deployment: {deploy} Features: {features} Constraints: {constraints} Existing milestones: {ms} "#, name = input.name, desc = input.description, ptype = plan.project_type, lang = plan.stack.language, scale = input.scale, db = input.database, auth = input.auth, deploy = input.deployment, features = input.features.join(", "), constraints = input.constraints.join(", "), ms = plan.milestones.iter().map(|m| m.name.clone()).collect::>().join(", "), ); generate(cfg, &prompt) }