lean_ctx/core/
llm_enhance.rs1use std::time::Duration;
9
10const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
11const MAX_PROMPT_CHARS: usize = 2000;
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14#[serde(default)]
15pub struct LlmConfig {
16 pub enabled: bool,
17 pub backend: LlmBackend,
18 pub model: String,
19 pub timeout_secs: u64,
20 pub base_url: Option<String>,
21}
22
23impl Default for LlmConfig {
24 fn default() -> Self {
25 Self {
26 enabled: false,
27 backend: LlmBackend::Ollama,
28 model: "qwen2.5-coder:1.5b".to_string(),
29 timeout_secs: 10,
30 base_url: None,
31 }
32 }
33}
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "lowercase")]
37#[derive(Default)]
38pub enum LlmBackend {
39 #[default]
40 Ollama,
41 OpenRouter,
42 Anthropic,
43}
44
45impl LlmConfig {
46 fn effective_base_url(&self) -> String {
47 if let Some(ref url) = self.base_url {
48 return url.clone();
49 }
50 match self.backend {
51 LlmBackend::Ollama => "http://localhost:11434".to_string(),
52 LlmBackend::OpenRouter => "https://openrouter.ai/api".to_string(),
53 LlmBackend::Anthropic => "https://api.anthropic.com".to_string(),
54 }
55 }
56
57 fn api_key(&self) -> Option<String> {
58 match self.backend {
59 LlmBackend::Ollama => None,
60 LlmBackend::OpenRouter => std::env::var("OPENROUTER_API_KEY").ok(),
61 LlmBackend::Anthropic => std::env::var("ANTHROPIC_API_KEY").ok(),
62 }
63 }
64
65 fn timeout(&self) -> Duration {
66 if self.timeout_secs > 0 {
67 Duration::from_secs(self.timeout_secs)
68 } else {
69 DEFAULT_TIMEOUT
70 }
71 }
72}
73
74pub fn expand_query(query: &str) -> String {
76 let cfg = crate::core::config::Config::load().llm;
77 if !cfg.enabled {
78 return query.to_string();
79 }
80
81 let prompt = format!(
82 "Expand this code search query with 2-3 related terms. \
83 Return ONLY the expanded query, no explanation.\n\
84 Query: {query}"
85 );
86
87 match call_llm(&cfg, &prompt) {
88 Ok(expanded) => {
89 let cleaned = expanded.trim().to_string();
90 if cleaned.is_empty() || cleaned.len() > query.len() * 5 {
91 query.to_string()
92 } else {
93 cleaned
94 }
95 }
96 Err(_) => query.to_string(),
97 }
98}
99
100pub fn explain_contradiction(fact_a: &str, fact_b: &str) -> String {
103 let cfg = crate::core::config::Config::load().llm;
104 if !cfg.enabled {
105 return deterministic_contradiction(fact_a, fact_b);
106 }
107
108 let prompt = format!(
109 "These two facts contradict. Explain the conflict in one sentence:\n\
110 A: {fact_a}\nB: {fact_b}"
111 );
112
113 match call_llm(&cfg, &prompt) {
114 Ok(explanation) => explanation.trim().to_string(),
115 Err(_) => deterministic_contradiction(fact_a, fact_b),
116 }
117}
118
119fn deterministic_contradiction(a: &str, b: &str) -> String {
120 format!("Conflict: \"{a}\" vs \"{b}\"")
121}
122
123pub fn enhance_observation(entity: &str, deterministic: &str) -> String {
130 let cfg = crate::core::config::Config::load().llm;
131 if !cfg.enabled {
132 return deterministic.to_string();
133 }
134
135 let prompt = format!(
136 "Summarize what is known about `{entity}` in ONE concise, factual sentence. \
137 Use ONLY these notes; do not invent. Return ONLY the sentence.\n\
138 Notes: {deterministic}"
139 );
140
141 match call_llm(&cfg, &prompt) {
142 Ok(text) => {
143 let cleaned = text.trim();
144 if cleaned.is_empty() || cleaned.len() > deterministic.len() * 4 {
146 deterministic.to_string()
147 } else {
148 format!("{entity} — {cleaned}")
149 }
150 }
151 Err(_) => deterministic.to_string(),
152 }
153}
154
155fn call_llm(cfg: &LlmConfig, prompt: &str) -> Result<String, String> {
157 let truncated = if prompt.len() > MAX_PROMPT_CHARS {
158 &prompt[..prompt.floor_char_boundary(MAX_PROMPT_CHARS)]
159 } else {
160 prompt
161 };
162
163 match cfg.backend {
164 LlmBackend::Ollama => call_ollama(cfg, truncated),
165 LlmBackend::OpenRouter => call_openai_compatible(cfg, truncated),
166 LlmBackend::Anthropic => call_anthropic(cfg, truncated),
167 }
168}
169
170fn make_agent(cfg: &LlmConfig) -> ureq::Agent {
171 crate::core::http_client::ureq_agent(
172 ureq::config::Config::builder()
173 .tls_config(crate::core::http_client::platform_tls_config())
174 .timeout_global(Some(cfg.timeout()))
175 .build(),
176 )
177}
178
179fn call_ollama(cfg: &LlmConfig, prompt: &str) -> Result<String, String> {
180 let url = format!("{}/api/generate", cfg.effective_base_url());
181 let body = serde_json::json!({
182 "model": cfg.model,
183 "prompt": prompt,
184 "stream": false,
185 "options": { "num_predict": 100 }
186 });
187
188 let agent = make_agent(cfg);
189 let payload = serde_json::to_vec(&body).map_err(|e| format!("json: {e}"))?;
190 let resp = agent
191 .post(&url)
192 .header("Content-Type", "application/json")
193 .send(payload.as_slice())
194 .map_err(|e| format!("ollama: {e}"))?;
195
196 let text = resp
197 .into_body()
198 .read_to_string()
199 .map_err(|e| format!("read: {e}"))?;
200 let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("parse: {e}"))?;
201 json.get("response")
202 .and_then(|v| v.as_str())
203 .map(str::to_string)
204 .ok_or_else(|| "no response field".to_string())
205}
206
207fn call_openai_compatible(cfg: &LlmConfig, prompt: &str) -> Result<String, String> {
208 let key = cfg.api_key().ok_or("OPENROUTER_API_KEY not set")?;
209 let url = format!("{}/v1/chat/completions", cfg.effective_base_url());
210 let body = serde_json::json!({
211 "model": cfg.model,
212 "messages": [{"role": "user", "content": prompt}],
213 "max_tokens": 100
214 });
215
216 let agent = make_agent(cfg);
217 let payload = serde_json::to_vec(&body).map_err(|e| format!("json: {e}"))?;
218 let resp = agent
219 .post(&url)
220 .header("Authorization", &format!("Bearer {key}"))
221 .header("Content-Type", "application/json")
222 .send(payload.as_slice())
223 .map_err(|e| format!("openrouter: {e}"))?;
224
225 let text = resp
226 .into_body()
227 .read_to_string()
228 .map_err(|e| format!("read: {e}"))?;
229 let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("parse: {e}"))?;
230 json.pointer("/choices/0/message/content")
231 .and_then(|v| v.as_str())
232 .map(str::to_string)
233 .ok_or_else(|| "no content in response".to_string())
234}
235
236fn call_anthropic(cfg: &LlmConfig, prompt: &str) -> Result<String, String> {
237 let key = cfg.api_key().ok_or("ANTHROPIC_API_KEY not set")?;
238 let url = format!("{}/v1/messages", cfg.effective_base_url());
239 let body = serde_json::json!({
240 "model": cfg.model,
241 "max_tokens": 100,
242 "messages": [{"role": "user", "content": prompt}]
243 });
244
245 let agent = make_agent(cfg);
246 let payload = serde_json::to_vec(&body).map_err(|e| format!("json: {e}"))?;
247 let resp = agent
248 .post(&url)
249 .header("x-api-key", &key)
250 .header("anthropic-version", "2023-06-01")
251 .header("Content-Type", "application/json")
252 .send(payload.as_slice())
253 .map_err(|e| format!("anthropic: {e}"))?;
254
255 let text = resp
256 .into_body()
257 .read_to_string()
258 .map_err(|e| format!("read: {e}"))?;
259 let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("parse: {e}"))?;
260 json.pointer("/content/0/text")
261 .and_then(|v| v.as_str())
262 .map(str::to_string)
263 .ok_or_else(|| "no text in response".to_string())
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn default_config_disabled() {
272 let cfg = LlmConfig::default();
273 assert!(!cfg.enabled);
274 assert!(matches!(cfg.backend, LlmBackend::Ollama));
275 }
276
277 #[test]
278 fn expand_query_passthrough_when_disabled() {
279 let result = expand_query("test query");
280 assert_eq!(result, "test query");
281 }
282
283 #[test]
284 fn deterministic_contradiction_format() {
285 let result = deterministic_contradiction("A is true", "A is false");
286 assert!(result.contains("Conflict"));
287 assert!(result.contains("A is true"));
288 }
289
290 #[test]
291 fn effective_base_url_defaults() {
292 let cfg = LlmConfig::default();
293 assert!(cfg.effective_base_url().contains("11434"));
294
295 let cfg = LlmConfig {
296 backend: LlmBackend::OpenRouter,
297 ..Default::default()
298 };
299 assert!(cfg.effective_base_url().contains("openrouter"));
300 }
301}