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)
}