use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use crate::memory_core::palace::Drawer;
use super::types::{
ConsolidationAction, OpenAiChatResponse, build_consolidation_prompt,
parse_consolidation_actions,
};
pub(super) const OPENROUTER_COMPLETIONS_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
pub(super) const CONSOLIDATION_PROMPT_SYSTEM: &str = r#"You are a knowledge consolidation assistant for a personal memory system. Given a batch of memory entries (drawers), identify:
1. Aliases: different names for the same concept (e.g. "ts" = "trusty-search")
2. Merge candidates: closely related facts that should be one canonical summary
3. Contradictions: entries that conflict (flag for human review; do NOT auto-resolve)
Return a JSON array of actions. Each action MUST have an "action" field.
Valid action types:
- {"action": "alias", "from": "<term>", "to": "<canonical_term>"}
- {"action": "merge", "canonical_content": "<single best summary>", "superseded_ids": ["<uuid>", ...]}
- {"action": "flag", "drawer_id": "<uuid>", "reason": "<why contradictory>"}
Rules:
- Be conservative: only merge if the entries express the SAME fact.
- Preserve nuance: if entries are related but distinct, do NOT merge.
- Return an empty array [] if no consolidation is warranted.
- The canonical_content for a merge MUST be a complete, standalone sentence or paragraph.
- Return ONLY the JSON array, no other text."#;
#[async_trait]
pub trait Inference: Send + Sync {
fn name(&self) -> &str;
async fn consolidate(&self, drawers: &[Drawer]) -> Result<Vec<ConsolidationAction>>;
}
pub struct OpenRouterInference {
api_key: String,
model: String,
}
impl OpenRouterInference {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
model: model.into(),
}
}
}
#[async_trait]
impl Inference for OpenRouterInference {
fn name(&self) -> &str {
"openrouter"
}
async fn consolidate(&self, drawers: &[Drawer]) -> Result<Vec<ConsolidationAction>> {
if self.api_key.is_empty() {
return Err(anyhow!("OpenRouter API key is empty"));
}
if drawers.is_empty() {
return Ok(vec![]);
}
let user_content = build_consolidation_prompt(drawers);
let messages = vec![
serde_json::json!({"role": "system", "content": CONSOLIDATION_PROMPT_SYSTEM}),
serde_json::json!({"role": "user", "content": user_content}),
];
let body = serde_json::json!({
"model": self.model,
"messages": messages,
"stream": false,
});
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
.build()
.context("build reqwest client for OpenRouterInference")?;
let resp = client
.post(OPENROUTER_COMPLETIONS_URL)
.bearer_auth(&self.api_key)
.header("HTTP-Referer", "https://github.com/bobmatnyc/trusty-tools")
.header("X-Title", "trusty-memory")
.json(&body)
.send()
.await
.context("POST OpenRouter consolidation")?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow!("OpenRouter HTTP {status}: {text}"));
}
let payload: OpenAiChatResponse = resp
.json()
.await
.context("parse OpenRouter consolidation response")?;
let raw_content = payload
.choices
.into_iter()
.next()
.and_then(|c| c.message.content)
.unwrap_or_default();
parse_consolidation_actions(&raw_content)
}
}
pub struct OllamaInference {
base_url: String,
model: String,
}
impl OllamaInference {
pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
model: model.into(),
}
}
}
#[async_trait]
impl Inference for OllamaInference {
fn name(&self) -> &str {
"ollama"
}
async fn consolidate(&self, drawers: &[Drawer]) -> Result<Vec<ConsolidationAction>> {
if drawers.is_empty() {
return Ok(vec![]);
}
let user_content = build_consolidation_prompt(drawers);
let messages = vec![
serde_json::json!({"role": "system", "content": CONSOLIDATION_PROMPT_SYSTEM}),
serde_json::json!({"role": "user", "content": user_content}),
];
let body = serde_json::json!({
"model": self.model,
"messages": messages,
"stream": false,
});
let url = format!(
"{}/v1/chat/completions",
self.base_url.trim_end_matches('/')
);
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(120))
.build()
.context("build reqwest client for OllamaInference")?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.with_context(|| format!("POST {url}"))?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow!("Ollama HTTP {status}: {text}"));
}
let payload: OpenAiChatResponse = resp
.json()
.await
.context("parse Ollama consolidation response")?;
let raw_content = payload
.choices
.into_iter()
.next()
.and_then(|c| c.message.content)
.unwrap_or_default();
parse_consolidation_actions(&raw_content)
}
}
pub struct MockInference {
pub fixture_actions: Vec<ConsolidationAction>,
pub call_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl MockInference {
pub fn new(fixture_actions: Vec<ConsolidationAction>) -> Self {
Self {
fixture_actions,
call_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
pub fn no_op() -> Self {
Self::new(vec![])
}
}
#[async_trait]
impl Inference for MockInference {
fn name(&self) -> &str {
"mock"
}
async fn consolidate(&self, _drawers: &[Drawer]) -> Result<Vec<ConsolidationAction>> {
self.call_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(self.fixture_actions.clone())
}
}