Skip to main content

scone_core/
llm.rs

1//! LLM providers (spec §9): the semantic lane's only model dependency.
2//!
3//! The engine works with `None` — lane 2 pauses loudly, episodic search
4//! stays at full strength (spec §6, memory/lessons.md L-9).
5
6use crate::error::{Result, SconeError};
7
8/// One fact proposed by extraction, before entity resolution.
9#[derive(Debug, Clone, PartialEq)]
10pub struct ExtractedFact {
11    pub subject: String,
12    pub predicate: String,
13    pub object: String,
14    pub confidence: f32,
15}
16
17/// Default system prompt for answering from memory (v1).
18pub const ANSWER_SYSTEM_V1: &str = "Answer from the provided memory context. \
19Cite nothing you cannot find there; say so when the context lacks the answer.";
20
21/// Extraction-style answering for small readers (v2): short, literal,
22/// time-aware. Benchmarked against v1 in memory/benchmarks.md.
23pub const ANSWER_SYSTEM_V2: &str = "You answer questions from retrieved \
24personal memory. Reply with ONLY the specific fact or detail asked for - a \
25short phrase, no preamble, no explanation. Prefer the exact wording found in \
26the context. If the question refers to time ('first', 'last', 'in May'), use \
27the timestamps and ordering in the context to pick the right instance. If the \
28context does not contain the answer, reply exactly: unknown";
29
30pub trait LlmProvider: Send {
31    fn id(&self) -> &str;
32    fn extract_facts(&self, text: &str) -> Result<Vec<ExtractedFact>>;
33    /// Answer with an explicit system prompt.
34    fn answer_with_system(&self, system: &str, question: &str, context: &str) -> Result<String>;
35    /// Answer with the default (v1) system prompt.
36    fn answer(&self, question: &str, context: &str) -> Result<String> {
37        self.answer_with_system(ANSWER_SYSTEM_V1, question, context)
38    }
39}
40
41/// Deterministic in-process provider for tests: returns programmed facts
42/// and records every call.
43pub struct FakeLlm {
44    facts: Vec<ExtractedFact>,
45    fail: Option<String>,
46    answer: Option<String>,
47    calls: std::cell::RefCell<Vec<String>>,
48}
49
50impl FakeLlm {
51    pub fn new(facts: Vec<ExtractedFact>) -> Self {
52        Self {
53            facts,
54            fail: None,
55            answer: None,
56            calls: std::cell::RefCell::new(Vec::new()),
57        }
58    }
59
60    /// Program the exact string `answer` returns.
61    pub fn with_answer(mut self, answer: &str) -> Self {
62        self.answer = Some(answer.to_owned());
63        self
64    }
65
66    pub fn failing(message: &str) -> Self {
67        Self {
68            facts: Vec::new(),
69            fail: Some(message.to_owned()),
70            answer: None,
71            calls: std::cell::RefCell::new(Vec::new()),
72        }
73    }
74
75    pub fn calls(&self) -> Vec<String> {
76        self.calls.borrow().clone()
77    }
78}
79
80impl LlmProvider for FakeLlm {
81    fn id(&self) -> &str {
82        "fake"
83    }
84
85    fn extract_facts(&self, text: &str) -> Result<Vec<ExtractedFact>> {
86        self.calls.borrow_mut().push(text.to_owned());
87        match &self.fail {
88            Some(msg) => Err(SconeError::Llm(msg.clone())),
89            None => Ok(self.facts.clone()),
90        }
91    }
92
93    fn answer_with_system(&self, _system: &str, question: &str, context: &str) -> Result<String> {
94        match (&self.fail, &self.answer) {
95            (Some(msg), _) => Err(SconeError::Llm(msg.clone())),
96            (None, Some(programmed)) => Ok(programmed.clone()),
97            (None, None) => Ok(format!(
98                "answer to {question} given {} bytes",
99                context.len()
100            )),
101        }
102    }
103}
104
105const EXTRACTION_PROMPT: &str = "Extract durable factual statements from the text as a STRICT \
106JSON array. Each element: {\"subject\": string, \"predicate\": string, \"object\": string, \
107\"confidence\": number 0..1}. Subjects are entities (people, projects, tools, places). \
108Predicates are short verb phrases. Only facts stated or strongly implied; no speculation. \
109Reply with the JSON array ONLY — no prose, no code fences.";
110
111fn parse_extraction(content: &str) -> Result<Vec<ExtractedFact>> {
112    let trimmed = content
113        .trim()
114        .trim_start_matches("```json")
115        .trim_start_matches("```")
116        .trim_end_matches("```")
117        .trim();
118    let value: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| {
119        SconeError::Llm(format!(
120            "model did not return JSON: {e}; got: {}",
121            content.chars().take(120).collect::<String>()
122        ))
123    })?;
124    let array = value
125        .as_array()
126        .ok_or_else(|| SconeError::Llm("model did not return JSON array".into()))?;
127    array
128        .iter()
129        .map(|f| {
130            Ok(ExtractedFact {
131                subject: f["subject"]
132                    .as_str()
133                    .ok_or_else(|| SconeError::Llm("fact missing subject".into()))?
134                    .to_owned(),
135                predicate: f["predicate"]
136                    .as_str()
137                    .ok_or_else(|| SconeError::Llm("fact missing predicate".into()))?
138                    .to_owned(),
139                object: f["object"]
140                    .as_str()
141                    .ok_or_else(|| SconeError::Llm("fact missing object".into()))?
142                    .to_owned(),
143                confidence: f["confidence"].as_f64().unwrap_or(0.5) as f32,
144            })
145        })
146        .collect()
147}
148
149/// Default ceiling for one model call. A hung provider must become a typed
150/// error, never a stuck process (the missing-timeout class already cost us
151/// a stalled sweep and an unobservable bench leg, 2026-08-27/28).
152pub const DEFAULT_LLM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
153
154fn http_json(
155    req: ureq::RequestBuilder<ureq::typestate::WithBody>,
156    timeout: std::time::Duration,
157    body: serde_json::Value,
158) -> Result<serde_json::Value> {
159    let mut res = req
160        .config()
161        .timeout_global(Some(timeout))
162        .build()
163        .send_json(body)
164        .map_err(|e| SconeError::Llm(format!("http: {e}")))?;
165    res.body_mut()
166        .read_json()
167        .map_err(|e| SconeError::Llm(format!("http body: {e}")))
168}
169
170/// Any OpenAI-compatible chat endpoint: OpenAI itself, Ollama, vLLM, …
171pub struct OpenAiCompatible {
172    base_url: String,
173    model: String,
174    api_key: Option<String>,
175    timeout: std::time::Duration,
176}
177
178impl OpenAiCompatible {
179    pub fn new(base_url: &str, model: &str, api_key: Option<String>) -> Self {
180        Self {
181            base_url: base_url.trim_end_matches('/').to_owned(),
182            model: model.to_owned(),
183            api_key,
184            timeout: DEFAULT_LLM_TIMEOUT,
185        }
186    }
187
188    pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
189        self.timeout = timeout;
190        self
191    }
192
193    fn chat(&self, system: &str, user: &str) -> Result<String> {
194        let mut req = ureq::post(format!("{}/chat/completions", self.base_url));
195        if let Some(key) = &self.api_key {
196            req = req.header("authorization", format!("Bearer {key}"));
197        }
198        let body = serde_json::json!({
199            "model": self.model,
200            "messages": [
201                {"role": "system", "content": system},
202                {"role": "user", "content": user},
203            ],
204        });
205        let value = http_json(req, self.timeout, body)?;
206        value["choices"][0]["message"]["content"]
207            .as_str()
208            .map(str::to_owned)
209            .ok_or_else(|| SconeError::Llm("no content in chat response".into()))
210    }
211}
212
213impl LlmProvider for OpenAiCompatible {
214    fn id(&self) -> &str {
215        &self.model
216    }
217
218    fn extract_facts(&self, text: &str) -> Result<Vec<ExtractedFact>> {
219        parse_extraction(&self.chat(EXTRACTION_PROMPT, text)?)
220    }
221
222    fn answer_with_system(&self, system: &str, question: &str, context: &str) -> Result<String> {
223        self.chat(
224            system,
225            &format!("Context:\n{context}\n\nQuestion: {question}"),
226        )
227    }
228}
229
230/// Anthropic's native messages API.
231pub struct AnthropicProvider {
232    base_url: String,
233    model: String,
234    api_key: String,
235    timeout: std::time::Duration,
236}
237
238impl AnthropicProvider {
239    pub fn new(base_url: &str, model: &str, api_key: &str) -> Self {
240        Self {
241            base_url: base_url.trim_end_matches('/').to_owned(),
242            model: model.to_owned(),
243            api_key: api_key.to_owned(),
244            timeout: DEFAULT_LLM_TIMEOUT,
245        }
246    }
247
248    pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
249        self.timeout = timeout;
250        self
251    }
252
253    fn message(&self, system: &str, user: &str) -> Result<String> {
254        let req = ureq::post(format!("{}/v1/messages", self.base_url))
255            .header("x-api-key", self.api_key.as_str())
256            .header("anthropic-version", "2023-06-01");
257        let body = serde_json::json!({
258            "model": self.model,
259            "max_tokens": 1024,
260            "system": system,
261            "messages": [{"role": "user", "content": user}],
262        });
263        let value = http_json(req, self.timeout, body)?;
264        value["content"][0]["text"]
265            .as_str()
266            .map(str::to_owned)
267            .ok_or_else(|| SconeError::Llm("no text in messages response".into()))
268    }
269}
270
271impl LlmProvider for AnthropicProvider {
272    fn id(&self) -> &str {
273        &self.model
274    }
275
276    fn extract_facts(&self, text: &str) -> Result<Vec<ExtractedFact>> {
277        parse_extraction(&self.message(EXTRACTION_PROMPT, text)?)
278    }
279
280    fn answer_with_system(&self, system: &str, question: &str, context: &str) -> Result<String> {
281        self.message(
282            system,
283            &format!("Context:\n{context}\n\nQuestion: {question}"),
284        )
285    }
286}