1#![allow(clippy::manual_pattern_char_comparison)]
19#![allow(missing_docs)]
20#![allow(dead_code)]
21
22use std::pin::Pin;
23use std::sync::Arc;
24
25use futures::StreamExt;
26use oxi_agent::tools::{MemoryBackend, MemoryItem, ToolError};
27use oxi_ai::{Context, ProviderEvent};
28use serde::Deserialize;
29
30use super::settings::Settings;
31
32#[derive(Debug, Clone, Deserialize)]
34pub struct ExtractedFact {
35 pub content: String,
37 #[serde(default)]
39 pub importance: Option<f64>,
40 #[serde(default)]
43 pub kind: Option<String>,
44}
45
46#[derive(Debug, Default, Deserialize)]
48struct ExtractionPayload {
49 #[serde(default)]
50 pub facts: Vec<ExtractedFact>,
51}
52
53pub trait FactExtractor: Send + Sync {
55 fn extract(&self, content: &str, kind: &str) -> Option<Vec<ExtractedFact>>;
57}
58
59pub struct HeuristicFactExtractor;
61
62impl FactExtractor for HeuristicFactExtractor {
63 fn extract(&self, content: &str, _kind: &str) -> Option<Vec<ExtractedFact>> {
64 let facts: Vec<ExtractedFact> = content
65 .split(|c: char| c == '.' || c == '!' || c == '?' || c == '\n')
66 .map(str::trim)
67 .filter(|s| s.len() >= 10 && !s.ends_with('?'))
68 .map(|s| ExtractedFact {
69 content: s.to_string(),
70 importance: None,
71 kind: None,
72 })
73 .collect();
74 if facts.is_empty() { None } else { Some(facts) }
75 }
76}
77
78pub const STAGE_ONE_SYSTEM_PROMPT: &str = "\
82You extract atomic durable facts from free-form text.\n\
83Return STRICT JSON ONLY (no markdown, no commentary).\n\
84Output contract: {\"facts\": [{\"content\": \"string\", \"importance\": number, \"kind\": \"string\"}]}.\n\
85Rules:\n\
86- Each fact is one self-contained claim (a constraint, decision, workflow, or resolved pitfall).\n\
87- Skip transient chatter, greetings, and pure questions.\n\
88- importance in [0, 1] (1 = critical, never forget).\n\
89- kind is one of: \"fact\", \"preference\", \"context\", \"summary\".\n\
90- When no durable signal exists, return {\"facts\": []}.";
91
92pub const STAGE_ONE_USER_TEMPLATE: &str = "\
94Text to extract from:\n\
95{{content}}\n\n\
96Return JSON now.";
97
98pub struct LlmExtractor {
100 provider: Arc<dyn oxi_ai::Provider>,
101 model: oxi_ai::Model,
102}
103
104impl std::fmt::Debug for LlmExtractor {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("LlmExtractor")
107 .field("model", &self.model.id)
108 .finish_non_exhaustive()
109 }
110}
111
112impl LlmExtractor {
113 pub fn new(provider: Arc<dyn oxi_ai::Provider>, model: oxi_ai::Model) -> Self {
115 Self { provider, model }
116 }
117}
118
119async fn run_extraction(
121 provider: &Arc<dyn oxi_ai::Provider>,
122 model: &oxi_ai::Model,
123 content: &str,
124) -> Option<Vec<ExtractedFact>> {
125 let user = STAGE_ONE_USER_TEMPLATE.replace("{{content}}", content);
126 let mut ctx = Context::default();
127 ctx.set_system_prompt(STAGE_ONE_SYSTEM_PROMPT.to_string());
128 ctx.add_message(oxi_ai::Message::user(user));
129 let mut stream = provider.stream(model, &ctx, None).await.ok()?;
130 let mut buf = String::new();
131 while let Some(ev) = stream.next().await {
132 match ev {
133 ProviderEvent::TextDelta { delta, .. } => buf.push_str(&delta),
134 ProviderEvent::Done { .. } => break,
135 ProviderEvent::Error { .. } => return None,
136 _ => {}
137 }
138 }
139 parse_facts(&buf)
140}
141
142fn parse_facts(buf: &str) -> Option<Vec<ExtractedFact>> {
145 let trimmed = buf
146 .trim()
147 .trim_start_matches("```json")
148 .trim_start_matches("```")
149 .trim_end_matches("```")
150 .trim();
151 let payload: ExtractionPayload = serde_json::from_str(trimmed).ok()?;
152 let facts: Vec<ExtractedFact> = payload
153 .facts
154 .into_iter()
155 .filter(|f| !f.content.trim().is_empty())
156 .collect();
157 if facts.is_empty() { None } else { Some(facts) }
158}
159
160pub struct ExtractingMemoryBackend {
162 inner: Arc<dyn MemoryBackend>,
163 heuristic: Arc<dyn FactExtractor>,
164 llm: Option<LlmExtractor>,
165}
166
167impl std::fmt::Debug for ExtractingMemoryBackend {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 f.debug_struct("ExtractingMemoryBackend")
170 .field("inner", &"<dyn MemoryBackend>")
171 .field("heuristic", &"<dyn FactExtractor>")
172 .field("llm", &self.llm.as_ref().map(|l| l.model.id.clone()))
173 .finish()
174 }
175}
176
177impl ExtractingMemoryBackend {
178 pub fn new(
181 inner: Arc<dyn MemoryBackend>,
182 heuristic: Arc<dyn FactExtractor>,
183 llm: Option<LlmExtractor>,
184 ) -> Self {
185 Self {
186 inner,
187 heuristic,
188 llm,
189 }
190 }
191}
192
193impl MemoryBackend for ExtractingMemoryBackend {
194 fn put<'a>(
195 &'a self,
196 content: &'a str,
197 kind: &'a str,
198 subject: &'a str,
199 ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
200 let inner = Arc::clone(&self.inner);
201 let heuristic = Arc::clone(&self.heuristic);
202 let llm = self.llm.as_ref().map(|l| LlmExtractorHandle {
203 provider: Arc::clone(&l.provider),
204 model: l.model.clone(),
205 });
206
207 Box::pin(async move {
208 const MAX_FACTS_PER_PUT: usize = 16;
209
210 let facts = if let Some(handle) = llm {
211 handle.extract(content).await
212 } else {
213 heuristic.extract(content, kind)
214 };
215
216 match facts {
217 Some(fs) if !fs.is_empty() => {
218 let mut last: Result<String, ToolError> = Ok(content.to_string());
219 for fact in fs.into_iter().take(MAX_FACTS_PER_PUT) {
220 let fkind = fact.kind.as_deref().unwrap_or(kind);
221 match inner.put(&fact.content, fkind, subject).await {
222 Ok(id) => last = Ok(id),
223 Err(e) => {
224 last = Err(e);
225 break;
226 }
227 }
228 }
229 last
230 }
231 _ => inner.put(content, kind, subject).await,
232 }
233 })
234 }
235
236 fn search<'a>(
237 &'a self,
238 query: &'a str,
239 k: usize,
240 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
241 self.inner.search(query, k)
242 }
243
244 fn list<'a>(
245 &'a self,
246 subject: &'a str,
247 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
248 self.inner.list(subject)
249 }
250
251 fn delete<'a>(
252 &'a self,
253 id: &'a str,
254 ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
255 self.inner.delete(id)
256 }
257
258 fn memory_info(&self) -> Option<String> {
259 self.inner.memory_info()
260 }
261}
262
263struct LlmExtractorHandle {
266 provider: Arc<dyn oxi_ai::Provider>,
267 model: oxi_ai::Model,
268}
269
270impl LlmExtractorHandle {
271 async fn extract(&self, content: &str) -> Option<Vec<ExtractedFact>> {
272 run_extraction(&self.provider, &self.model, content).await
273 }
274}
275
276pub fn try_make_llm_extractor(oxi: &oxi_sdk::Oxi, settings: &Settings) -> Option<LlmExtractor> {
280 let pat = settings.memory_llm_extract_model.trim();
281 if pat.is_empty() {
282 return None;
283 }
284 let model = oxi.resolve_model(pat).ok()?;
285 let provider = oxi.providers().get(model.provider.as_str())?;
286 Some(LlmExtractor::new(provider, model))
287}
288
289pub fn wrap_with_extractor(
291 inner: Arc<dyn MemoryBackend>,
292 settings: &Settings,
293 oxi: Option<&oxi_sdk::Oxi>,
294) -> Arc<dyn MemoryBackend> {
295 if !settings.memory_llm_extract {
296 return inner;
297 }
298 let heuristic: Arc<dyn FactExtractor> = Arc::new(HeuristicFactExtractor);
299 let llm = oxi.and_then(|o| try_make_llm_extractor(o, settings));
300 Arc::new(ExtractingMemoryBackend::new(inner, heuristic, llm))
301}