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 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
23pub 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 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
143pub 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
197pub 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}