cel-summarizer 0.2.0

LLM-backed Summarizer implementations (Anthropic + Ollama) for cel-memory.
Documentation
//! LLM-backed [`Summarizer`](cel_memory::Summarizer) implementations for
//! [`cel-memory`](https://crates.io/crates/cel-memory).
//!
//! Provides [`AnthropicSummarizer`], [`OllamaSummarizer`], and [`build_default`].
//! Wire either into [`BasicMemoryProvider::with_summarizer`] or
//! [`SqliteMemoryProvider::with_summarizer`] to enable session summaries and
//! rollups.

#![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};

/// Default Anthropic model for summarization.
pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-haiku-4-5";

/// Default Ollama model for local summarization.
pub const DEFAULT_OLLAMA_MODEL: &str = "llama3.2:3b-instruct-q4_K_M";

/// Env var selecting the summarizer backend. Values: `anthropic` (default) or
/// `ollama`.
pub const PROVIDER_ENV: &str = "CEL_SUMMARIZER_PROVIDER";

/// Env var holding the Anthropic API key for [`AnthropicSummarizer`].
pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";

/// Env var overriding the default model for either provider.
pub const MODEL_ENV: &str = "CEL_SUMMARIZER_MODEL";

/// Anthropic-backed [`Summarizer`].
pub struct AnthropicSummarizer {
    name: String,
    model: String,
    backend: Arc<dyn CompletionBackend>,
    offdevice_hook: Option<Arc<dyn OffdeviceCallHook>>,
}

impl AnthropicSummarizer {
    /// Construct from an injectable backend and model id.
    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,
        }
    }

    /// Attach an [`OffdeviceCallHook`] consulted before every network call.
    pub fn with_offdevice_hook(mut self, hook: Arc<dyn OffdeviceCallHook>) -> Self {
        self.offdevice_hook = Some(hook);
        self
    }

    /// Construct from environment. Requires [`ANTHROPIC_API_KEY_ENV`]; honors
    /// [`MODEL_ENV`] when set.
    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
    }
}

/// Ollama-backed [`Summarizer`]. Local fallback path.
pub struct OllamaSummarizer {
    name: String,
    model: String,
    backend: Arc<dyn CompletionBackend>,
}

impl OllamaSummarizer {
    /// Construct from an injectable backend and model id.
    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,
        }
    }

    /// Construct from environment. Honors `OLLAMA_BASE_URL` and [`MODEL_ENV`].
    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
    }
}

/// Build the default summarizer from environment.
///
/// 1. `CEL_SUMMARIZER_PROVIDER=anthropic` (or unset) → try Anthropic; fall
///    back to Ollama when the API key is missing or construction fails.
/// 2. `CEL_SUMMARIZER_PROVIDER=ollama` → Ollama directly.
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);
        }
    }
}