1use crate::error::{Result, SconeError};
7
8#[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 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
21pub 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 fn answer_with_system(&self, system: &str, question: &str, context: &str) -> Result<String>;
35 fn answer(&self, question: &str, context: &str) -> Result<String> {
37 self.answer_with_system(ANSWER_SYSTEM_V1, question, context)
38 }
39}
40
41pub 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 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
149pub 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
170pub 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
230pub 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}