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
17pub trait LlmProvider: Send {
18    fn id(&self) -> &str;
19    fn extract_facts(&self, text: &str) -> Result<Vec<ExtractedFact>>;
20    fn answer(&self, question: &str, context: &str) -> Result<String>;
21}
22
23/// Deterministic in-process provider for tests: returns programmed facts
24/// and records every call.
25pub struct FakeLlm {
26    facts: Vec<ExtractedFact>,
27    fail: Option<String>,
28    answer: Option<String>,
29    calls: std::cell::RefCell<Vec<String>>,
30}
31
32impl FakeLlm {
33    pub fn new(facts: Vec<ExtractedFact>) -> Self {
34        Self {
35            facts,
36            fail: None,
37            answer: None,
38            calls: std::cell::RefCell::new(Vec::new()),
39        }
40    }
41
42    /// Program the exact string `answer` returns.
43    pub fn with_answer(mut self, answer: &str) -> Self {
44        self.answer = Some(answer.to_owned());
45        self
46    }
47
48    pub fn failing(message: &str) -> Self {
49        Self {
50            facts: Vec::new(),
51            fail: Some(message.to_owned()),
52            answer: None,
53            calls: std::cell::RefCell::new(Vec::new()),
54        }
55    }
56
57    pub fn calls(&self) -> Vec<String> {
58        self.calls.borrow().clone()
59    }
60}
61
62impl LlmProvider for FakeLlm {
63    fn id(&self) -> &str {
64        "fake"
65    }
66
67    fn extract_facts(&self, text: &str) -> Result<Vec<ExtractedFact>> {
68        self.calls.borrow_mut().push(text.to_owned());
69        match &self.fail {
70            Some(msg) => Err(SconeError::Llm(msg.clone())),
71            None => Ok(self.facts.clone()),
72        }
73    }
74
75    fn answer(&self, question: &str, context: &str) -> Result<String> {
76        match (&self.fail, &self.answer) {
77            (Some(msg), _) => Err(SconeError::Llm(msg.clone())),
78            (None, Some(programmed)) => Ok(programmed.clone()),
79            (None, None) => Ok(format!(
80                "answer to {question} given {} bytes",
81                context.len()
82            )),
83        }
84    }
85}
86
87const EXTRACTION_PROMPT: &str = "Extract durable factual statements from the text as a STRICT \
88JSON array. Each element: {\"subject\": string, \"predicate\": string, \"object\": string, \
89\"confidence\": number 0..1}. Subjects are entities (people, projects, tools, places). \
90Predicates are short verb phrases. Only facts stated or strongly implied; no speculation. \
91Reply with the JSON array ONLY — no prose, no code fences.";
92
93fn parse_extraction(content: &str) -> Result<Vec<ExtractedFact>> {
94    let trimmed = content
95        .trim()
96        .trim_start_matches("```json")
97        .trim_start_matches("```")
98        .trim_end_matches("```")
99        .trim();
100    let value: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| {
101        SconeError::Llm(format!(
102            "model did not return JSON: {e}; got: {}",
103            content.chars().take(120).collect::<String>()
104        ))
105    })?;
106    let array = value
107        .as_array()
108        .ok_or_else(|| SconeError::Llm("model did not return JSON array".into()))?;
109    array
110        .iter()
111        .map(|f| {
112            Ok(ExtractedFact {
113                subject: f["subject"]
114                    .as_str()
115                    .ok_or_else(|| SconeError::Llm("fact missing subject".into()))?
116                    .to_owned(),
117                predicate: f["predicate"]
118                    .as_str()
119                    .ok_or_else(|| SconeError::Llm("fact missing predicate".into()))?
120                    .to_owned(),
121                object: f["object"]
122                    .as_str()
123                    .ok_or_else(|| SconeError::Llm("fact missing object".into()))?
124                    .to_owned(),
125                confidence: f["confidence"].as_f64().unwrap_or(0.5) as f32,
126            })
127        })
128        .collect()
129}
130
131fn http_json(
132    req: ureq::RequestBuilder<ureq::typestate::WithBody>,
133    body: serde_json::Value,
134) -> Result<serde_json::Value> {
135    let mut res = req
136        .send_json(body)
137        .map_err(|e| SconeError::Llm(format!("http: {e}")))?;
138    res.body_mut()
139        .read_json()
140        .map_err(|e| SconeError::Llm(format!("http body: {e}")))
141}
142
143/// Any OpenAI-compatible chat endpoint: OpenAI itself, Ollama, vLLM, …
144pub struct OpenAiCompatible {
145    base_url: String,
146    model: String,
147    api_key: Option<String>,
148}
149
150impl OpenAiCompatible {
151    pub fn new(base_url: &str, model: &str, api_key: Option<String>) -> Self {
152        Self {
153            base_url: base_url.trim_end_matches('/').to_owned(),
154            model: model.to_owned(),
155            api_key,
156        }
157    }
158
159    fn chat(&self, system: &str, user: &str) -> Result<String> {
160        let mut req = ureq::post(format!("{}/chat/completions", self.base_url));
161        if let Some(key) = &self.api_key {
162            req = req.header("authorization", format!("Bearer {key}"));
163        }
164        let body = serde_json::json!({
165            "model": self.model,
166            "messages": [
167                {"role": "system", "content": system},
168                {"role": "user", "content": user},
169            ],
170        });
171        let value = http_json(req, body)?;
172        value["choices"][0]["message"]["content"]
173            .as_str()
174            .map(str::to_owned)
175            .ok_or_else(|| SconeError::Llm("no content in chat response".into()))
176    }
177}
178
179impl LlmProvider for OpenAiCompatible {
180    fn id(&self) -> &str {
181        &self.model
182    }
183
184    fn extract_facts(&self, text: &str) -> Result<Vec<ExtractedFact>> {
185        parse_extraction(&self.chat(EXTRACTION_PROMPT, text)?)
186    }
187
188    fn answer(&self, question: &str, context: &str) -> Result<String> {
189        self.chat(
190            "Answer from the provided memory context. Cite nothing you cannot find there; \
191             say so when the context lacks the answer.",
192            &format!("Context:\n{context}\n\nQuestion: {question}"),
193        )
194    }
195}
196
197/// Anthropic's native messages API.
198pub struct AnthropicProvider {
199    base_url: String,
200    model: String,
201    api_key: String,
202}
203
204impl AnthropicProvider {
205    pub fn new(base_url: &str, model: &str, api_key: &str) -> Self {
206        Self {
207            base_url: base_url.trim_end_matches('/').to_owned(),
208            model: model.to_owned(),
209            api_key: api_key.to_owned(),
210        }
211    }
212
213    fn message(&self, system: &str, user: &str) -> Result<String> {
214        let req = ureq::post(format!("{}/v1/messages", self.base_url))
215            .header("x-api-key", self.api_key.as_str())
216            .header("anthropic-version", "2023-06-01");
217        let body = serde_json::json!({
218            "model": self.model,
219            "max_tokens": 1024,
220            "system": system,
221            "messages": [{"role": "user", "content": user}],
222        });
223        let value = http_json(req, body)?;
224        value["content"][0]["text"]
225            .as_str()
226            .map(str::to_owned)
227            .ok_or_else(|| SconeError::Llm("no text in messages response".into()))
228    }
229}
230
231impl LlmProvider for AnthropicProvider {
232    fn id(&self) -> &str {
233        &self.model
234    }
235
236    fn extract_facts(&self, text: &str) -> Result<Vec<ExtractedFact>> {
237        parse_extraction(&self.message(EXTRACTION_PROMPT, text)?)
238    }
239
240    fn answer(&self, question: &str, context: &str) -> Result<String> {
241        self.message(
242            "Answer from the provided memory context. Say so when the context lacks the answer.",
243            &format!("Context:\n{context}\n\nQuestion: {question}"),
244        )
245    }
246}