#![deny(missing_docs)]
#![warn(rust_2018_idioms)]
mod backend;
mod prompts;
use std::sync::Arc;
use async_trait::async_trait;
use cel_memory::{
offdevice_hook::{OffdeviceCallDescriptor, OffdeviceCallHook, OffdeviceDecision},
summarizer::{Summarizer, SummarizerError, SummarizerResult, SummaryContext},
MemoryChunk,
};
pub use backend::{AnthropicBackend, CompletionBackend, MockBackend, OllamaBackend};
pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-haiku-4-5";
pub const DEFAULT_OLLAMA_MODEL: &str = "llama3.2:3b-instruct-q4_K_M";
pub const PROVIDER_ENV: &str = "CEL_SUMMARIZER_PROVIDER";
pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
pub const MODEL_ENV: &str = "CEL_SUMMARIZER_MODEL";
pub struct AnthropicSummarizer {
name: String,
model: String,
backend: Arc<dyn CompletionBackend>,
offdevice_hook: Option<Arc<dyn OffdeviceCallHook>>,
}
impl AnthropicSummarizer {
pub fn new(backend: Arc<dyn CompletionBackend>, model: impl Into<String>) -> Self {
let model: String = model.into();
let name = format!("anthropic:{model}");
Self {
name,
model,
backend,
offdevice_hook: None,
}
}
pub fn with_offdevice_hook(mut self, hook: Arc<dyn OffdeviceCallHook>) -> Self {
self.offdevice_hook = Some(hook);
self
}
pub fn from_env() -> SummarizerResult<Self> {
let backend = Arc::new(AnthropicBackend::from_env()?);
let model =
std::env::var(MODEL_ENV).unwrap_or_else(|_| DEFAULT_ANTHROPIC_MODEL.to_string());
Ok(Self::new(backend, model))
}
}
#[async_trait]
impl Summarizer for AnthropicSummarizer {
fn name(&self) -> &str {
&self.name
}
async fn summarize(
&self,
chunks: &[MemoryChunk],
ctx: &SummaryContext,
) -> SummarizerResult<String> {
if chunks.is_empty() {
return Err(SummarizerError::NoInput);
}
if let Some(hook) = &self.offdevice_hook {
let mut desc = OffdeviceCallDescriptor::new(
"summarizer",
"anthropic",
&self.model,
"memory_summarizer",
);
if let Some(label) = &ctx.kind_label {
desc = desc.with_metadata("kind_label", label.clone());
}
desc = desc.with_metadata("chunk_count", chunks.len() as i64);
match hook.before_call(&desc).await {
OffdeviceDecision::Allow => {}
OffdeviceDecision::Veto { reason } => {
return Err(SummarizerError::Provider(format!(
"off-device call vetoed: {reason}"
)));
}
}
}
backend::summarize_with_backend(self.backend.as_ref(), &self.model, chunks, ctx).await
}
}
pub struct OllamaSummarizer {
name: String,
model: String,
backend: Arc<dyn CompletionBackend>,
}
impl OllamaSummarizer {
pub fn new(backend: Arc<dyn CompletionBackend>, model: impl Into<String>) -> Self {
let model: String = model.into();
let name = format!("ollama:{model}");
Self {
name,
model,
backend,
}
}
pub fn from_env() -> SummarizerResult<Self> {
let backend = Arc::new(OllamaBackend::from_env());
let model = std::env::var(MODEL_ENV).unwrap_or_else(|_| DEFAULT_OLLAMA_MODEL.to_string());
Ok(Self::new(backend, model))
}
}
#[async_trait]
impl Summarizer for OllamaSummarizer {
fn name(&self) -> &str {
&self.name
}
async fn summarize(
&self,
chunks: &[MemoryChunk],
ctx: &SummaryContext,
) -> SummarizerResult<String> {
backend::summarize_with_backend(self.backend.as_ref(), &self.model, chunks, ctx).await
}
}
pub fn build_default() -> SummarizerResult<Arc<dyn Summarizer>> {
let kind = std::env::var(PROVIDER_ENV)
.ok()
.map(|s| s.to_lowercase())
.unwrap_or_else(|| "anthropic".to_string());
match kind.as_str() {
"ollama" => Ok(Arc::new(OllamaSummarizer::from_env()?)),
_ => {
let key_present = std::env::var(ANTHROPIC_API_KEY_ENV)
.ok()
.filter(|s| !s.trim().is_empty())
.is_some();
if key_present {
match AnthropicSummarizer::from_env() {
Ok(s) => Ok(Arc::new(s)),
Err(_) => Ok(Arc::new(OllamaSummarizer::from_env()?)),
}
} else {
tracing::info!(
"{ANTHROPIC_API_KEY_ENV} not set; memory summarizer falling back to Ollama \
({DEFAULT_OLLAMA_MODEL})"
);
Ok(Arc::new(OllamaSummarizer::from_env()?))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cel_memory::{ChunkKind, ChunkSource, ClosureOffdeviceHook, MemoryTier};
use chrono::Utc;
use serde_json::Value;
fn chunk(id: &str, content: &str) -> MemoryChunk {
MemoryChunk {
id: id.into(),
created_at: Utc::now(),
kind: ChunkKind::Chat,
tier: MemoryTier::Session,
source: ChunkSource::Embedded,
session_id: Some("s1".into()),
project_root: None,
caller_id: "embedded".into(),
content: content.into(),
metadata: Value::Null,
importance: 0.5,
pinned: false,
shareable: false,
superseded_by: None,
embedding_model: "mock".into(),
embedding_dim: 0,
}
}
#[tokio::test]
async fn anthropic_summarizer_returns_backend_text() {
let backend = Arc::new(MockBackend::with_text("the user closed the chat"));
let s = AnthropicSummarizer::new(backend, DEFAULT_ANTHROPIC_MODEL);
assert_eq!(s.name(), "anthropic:claude-haiku-4-5");
let out = s
.summarize(
&[chunk("a", "hi"), chunk("b", "bye")],
&SummaryContext {
kind_label: Some("session".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(out, "the user closed the chat");
}
#[tokio::test]
async fn ollama_summarizer_returns_backend_text() {
let backend = Arc::new(MockBackend::with_text("local model summary"));
let s = OllamaSummarizer::new(backend, DEFAULT_OLLAMA_MODEL);
assert!(s.name().starts_with("ollama:"));
let out = s
.summarize(&[chunk("a", "x")], &SummaryContext::default())
.await
.unwrap();
assert_eq!(out, "local model summary");
}
#[tokio::test]
async fn empty_chunks_errors_no_input() {
let backend = Arc::new(MockBackend::with_text("unused"));
let s = AnthropicSummarizer::new(backend, DEFAULT_ANTHROPIC_MODEL);
let err = s
.summarize(&[], &SummaryContext::default())
.await
.unwrap_err();
assert!(matches!(err, SummarizerError::NoInput));
}
#[tokio::test]
async fn anthropic_offdevice_veto_short_circuits() {
let backend = Arc::new(MockBackend::with_text("should not be called"));
let hook: Arc<dyn OffdeviceCallHook> =
Arc::new(ClosureOffdeviceHook(|_d: &OffdeviceCallDescriptor| {
OffdeviceDecision::Veto {
reason: "work hours".into(),
}
}));
let s =
AnthropicSummarizer::new(backend, DEFAULT_ANTHROPIC_MODEL).with_offdevice_hook(hook);
let err = s
.summarize(&[chunk("a", "alpha")], &SummaryContext::default())
.await
.unwrap_err();
match err {
SummarizerError::Provider(msg) => {
assert!(msg.contains("off-device call vetoed"));
}
other => panic!("expected Provider, got {other:?}"),
}
}
#[tokio::test]
async fn build_default_falls_back_to_ollama_when_no_api_key() {
let prior_provider = std::env::var(PROVIDER_ENV).ok();
let prior_key = std::env::var(ANTHROPIC_API_KEY_ENV).ok();
std::env::remove_var(PROVIDER_ENV);
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
let summ = build_default().expect("ollama fallback should construct");
assert!(summ.name().starts_with("ollama:"));
if let Some(v) = prior_provider {
std::env::set_var(PROVIDER_ENV, v);
}
if let Some(v) = prior_key {
std::env::set_var(ANTHROPIC_API_KEY_ENV, v);
}
}
}