#![allow(clippy::manual_pattern_char_comparison)]
#![allow(missing_docs)]
#![allow(dead_code)]
use std::pin::Pin;
use std::sync::Arc;
use futures::StreamExt;
use oxi_agent::tools::{MemoryBackend, MemoryItem, ToolError};
use oxi_ai::{Context, ProviderEvent};
use serde::Deserialize;
use super::settings::Settings;
#[derive(Debug, Clone, Deserialize)]
pub struct ExtractedFact {
pub content: String,
#[serde(default)]
pub importance: Option<f64>,
#[serde(default)]
pub kind: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ExtractionPayload {
#[serde(default)]
pub facts: Vec<ExtractedFact>,
}
pub trait FactExtractor: Send + Sync {
fn extract(&self, content: &str, kind: &str) -> Option<Vec<ExtractedFact>>;
}
pub struct HeuristicFactExtractor;
impl FactExtractor for HeuristicFactExtractor {
fn extract(&self, content: &str, _kind: &str) -> Option<Vec<ExtractedFact>> {
let facts: Vec<ExtractedFact> = content
.split(|c: char| c == '.' || c == '!' || c == '?' || c == '\n')
.map(str::trim)
.filter(|s| s.len() >= 10 && !s.ends_with('?'))
.map(|s| ExtractedFact {
content: s.to_string(),
importance: None,
kind: None,
})
.collect();
if facts.is_empty() { None } else { Some(facts) }
}
}
pub const STAGE_ONE_SYSTEM_PROMPT: &str = "\
You extract atomic durable facts from free-form text.\n\
Return STRICT JSON ONLY (no markdown, no commentary).\n\
Output contract: {\"facts\": [{\"content\": \"string\", \"importance\": number, \"kind\": \"string\"}]}.\n\
Rules:\n\
- Each fact is one self-contained claim (a constraint, decision, workflow, or resolved pitfall).\n\
- Skip transient chatter, greetings, and pure questions.\n\
- importance in [0, 1] (1 = critical, never forget).\n\
- kind is one of: \"fact\", \"preference\", \"context\", \"summary\".\n\
- When no durable signal exists, return {\"facts\": []}.";
pub const STAGE_ONE_USER_TEMPLATE: &str = "\
Text to extract from:\n\
{{content}}\n\n\
Return JSON now.";
pub struct LlmExtractor {
provider: Arc<dyn oxi_ai::Provider>,
model: oxi_ai::Model,
}
impl std::fmt::Debug for LlmExtractor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LlmExtractor")
.field("model", &self.model.id)
.finish_non_exhaustive()
}
}
impl LlmExtractor {
pub fn new(provider: Arc<dyn oxi_ai::Provider>, model: oxi_ai::Model) -> Self {
Self { provider, model }
}
}
async fn run_extraction(
provider: &Arc<dyn oxi_ai::Provider>,
model: &oxi_ai::Model,
content: &str,
) -> Option<Vec<ExtractedFact>> {
let user = STAGE_ONE_USER_TEMPLATE.replace("{{content}}", content);
let mut ctx = Context::default();
ctx.set_system_prompt(STAGE_ONE_SYSTEM_PROMPT.to_string());
ctx.add_message(oxi_ai::Message::user(user));
let mut stream = provider.stream(model, &ctx, None).await.ok()?;
let mut buf = String::new();
while let Some(ev) = stream.next().await {
match ev {
ProviderEvent::TextDelta { delta, .. } => buf.push_str(&delta),
ProviderEvent::Done { .. } => break,
ProviderEvent::Error { .. } => return None,
_ => {}
}
}
parse_facts(&buf)
}
fn parse_facts(buf: &str) -> Option<Vec<ExtractedFact>> {
let trimmed = buf
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let payload: ExtractionPayload = serde_json::from_str(trimmed).ok()?;
let facts: Vec<ExtractedFact> = payload
.facts
.into_iter()
.filter(|f| !f.content.trim().is_empty())
.collect();
if facts.is_empty() { None } else { Some(facts) }
}
pub struct ExtractingMemoryBackend {
inner: Arc<dyn MemoryBackend>,
heuristic: Arc<dyn FactExtractor>,
llm: Option<LlmExtractor>,
}
impl std::fmt::Debug for ExtractingMemoryBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExtractingMemoryBackend")
.field("inner", &"<dyn MemoryBackend>")
.field("heuristic", &"<dyn FactExtractor>")
.field("llm", &self.llm.as_ref().map(|l| l.model.id.clone()))
.finish()
}
}
impl ExtractingMemoryBackend {
pub fn new(
inner: Arc<dyn MemoryBackend>,
heuristic: Arc<dyn FactExtractor>,
llm: Option<LlmExtractor>,
) -> Self {
Self {
inner,
heuristic,
llm,
}
}
}
impl MemoryBackend for ExtractingMemoryBackend {
fn put<'a>(
&'a self,
content: &'a str,
kind: &'a str,
subject: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
let inner = Arc::clone(&self.inner);
let heuristic = Arc::clone(&self.heuristic);
let llm = self.llm.as_ref().map(|l| LlmExtractorHandle {
provider: Arc::clone(&l.provider),
model: l.model.clone(),
});
Box::pin(async move {
const MAX_FACTS_PER_PUT: usize = 16;
let facts = if let Some(handle) = llm {
handle.extract(content).await
} else {
heuristic.extract(content, kind)
};
match facts {
Some(fs) if !fs.is_empty() => {
let mut last: Result<String, ToolError> = Ok(content.to_string());
for fact in fs.into_iter().take(MAX_FACTS_PER_PUT) {
let fkind = fact.kind.as_deref().unwrap_or(kind);
match inner.put(&fact.content, fkind, subject).await {
Ok(id) => last = Ok(id),
Err(e) => {
last = Err(e);
break;
}
}
}
last
}
_ => inner.put(content, kind, subject).await,
}
})
}
fn search<'a>(
&'a self,
query: &'a str,
k: usize,
) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
self.inner.search(query, k)
}
fn list<'a>(
&'a self,
subject: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
self.inner.list(subject)
}
fn delete<'a>(
&'a self,
id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
self.inner.delete(id)
}
fn memory_info(&self) -> Option<String> {
self.inner.memory_info()
}
}
struct LlmExtractorHandle {
provider: Arc<dyn oxi_ai::Provider>,
model: oxi_ai::Model,
}
impl LlmExtractorHandle {
async fn extract(&self, content: &str) -> Option<Vec<ExtractedFact>> {
run_extraction(&self.provider, &self.model, content).await
}
}
pub fn try_make_llm_extractor(oxi: &oxi_sdk::Oxi, settings: &Settings) -> Option<LlmExtractor> {
let pat = settings.memory_llm_extract_model.trim();
if pat.is_empty() {
return None;
}
let model = oxi.resolve_model(pat).ok()?;
let provider = oxi.providers().get(model.provider.as_str())?;
Some(LlmExtractor::new(provider, model))
}
pub fn wrap_with_extractor(
inner: Arc<dyn MemoryBackend>,
settings: &Settings,
oxi: Option<&oxi_sdk::Oxi>,
) -> Arc<dyn MemoryBackend> {
if !settings.memory_llm_extract {
return inner;
}
let heuristic: Arc<dyn FactExtractor> = Arc::new(HeuristicFactExtractor);
let llm = oxi.and_then(|o| try_make_llm_extractor(o, settings));
Arc::new(ExtractingMemoryBackend::new(inner, heuristic, llm))
}