planner / src/llm.rs

raw file 9280 bytes
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<String>, ollama_url: Option<String>, llama_url: Option<String>) -> 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<String> {
    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<String> {
    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<String> {
    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<String> {
    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<Vec<String>> {
    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::<Vec<String>>(&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<String> {
    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::<Vec<_>>().join(", "),
    );
    generate(cfg, &prompt)
}