Skip to main content

cel_memory/
summarizer.rs

1//! Summarizer trait and supporting types.
2//!
3//! The summarizer is the seam between the memory subsystem and whichever
4//! LLM client a deployment uses to synthesize end-of-session summaries,
5//! daily rollups, and rule-week rollups. The trait is intentionally tiny —
6//! given a list of chunks plus an
7//! optional pre-prompt the caller wants to inject (e.g. "session
8//! summary", "daily rollup for 2026-05-23"), produce a short string
9//! synthesis. The provider is responsible for writing the resulting
10//! `MemoryChunk` and linking its constituents in
11//! `memory_summary_members`; the summarizer itself only generates text.
12//!
13//! Production implementations live in the [`cel-summarizer`](https://crates.io/crates/cel-summarizer)
14//! crate: `AnthropicSummarizer` (cloud) and `OllamaSummarizer` (local).
15//!
16//! Object-safe: stored as `Arc<dyn Summarizer>` everywhere.
17//!
18//! # Test support
19//!
20//! [`MockSummarizer`] returns a fixed canned string for every call and
21//! records the chunks it received for assertion. It's a `pub` helper
22//! (no feature gate) so downstream tests can wire it directly, in the
23//! same shape as [`crate::ClosureHook`] for [`crate::MemoryWriteHook`].
24
25use async_trait::async_trait;
26use std::sync::{Arc, Mutex};
27use thiserror::Error;
28
29use crate::chunk::MemoryChunk;
30
31/// Errors produced by a [`Summarizer`].
32///
33/// Distinct from [`crate::MemoryError`] so the trait can be used in
34/// contexts that don't otherwise touch the memory crate's error type.
35/// The SQLite provider wraps these into `MemoryError::Provider` at the
36/// call site.
37#[derive(Debug, Error)]
38pub enum SummarizerError {
39    /// The summarizer's LLM client returned an error (rate limit, auth,
40    /// server error, malformed response).
41    #[error("provider error: {0}")]
42    Provider(String),
43
44    /// The summarizer received no chunks to summarize and refuses to
45    /// fabricate a summary out of thin air. Callers should treat this
46    /// as "no summary applicable" rather than retrying.
47    #[error("no chunks to summarize")]
48    NoInput,
49
50    /// The summarizer was configured incorrectly (missing API key, model
51    /// not available, etc.). Surfaced at construction time when possible;
52    /// at call time only if discovery is lazy.
53    #[error("invalid configuration: {0}")]
54    InvalidConfig(String),
55
56    /// An unexpected internal error. Indicates a bug.
57    #[error("internal error: {0}")]
58    Internal(String),
59}
60
61/// Result alias for summarizer operations.
62pub type SummarizerResult<T> = std::result::Result<T, SummarizerError>;
63
64/// Context the caller can pass alongside the chunks to bias the prompt
65/// the summarizer assembles.
66///
67/// Provider-agnostic: the field is wire-shape neutral so the same value
68/// flows through `AnthropicSummarizer`, `OllamaSummarizer`, or any
69/// future implementation. The Anthropic/Ollama impls render this into a
70/// system prompt.
71#[derive(Debug, Clone, Default)]
72pub struct SummaryContext {
73    /// Human-readable label for the kind of summary being produced —
74    /// e.g. `"session"`, `"day 2026-05-23"`, `"week of 2026-05-18 for
75    /// rule pii_redact"`. Inserted into the prompt so the model picks
76    /// up the unit of summarization.
77    pub kind_label: Option<String>,
78    /// Optional caller-supplied note ("user closed the chat
79    /// mid-conversation", "first daily rollup after deploy"). Appended
80    /// verbatim after the chunks.
81    pub note: Option<String>,
82    /// Optional cap on the output length, in words. The default impls
83    /// pass this through as a "Maximum N words" instruction; the
84    /// caller is responsible for any post-hoc truncation.
85    pub max_words: Option<u32>,
86}
87
88/// The summarizer trait. Object-safe.
89///
90/// Implementations MUST be `Send + Sync` because the provider may share
91/// a single summarizer across tokio tasks (e.g. the cron sweeper and
92/// the embedded agent calling `summarize_session` in parallel).
93#[async_trait]
94pub trait Summarizer: Send + Sync {
95    /// Human-readable identifier for the summarizer (e.g.
96    /// `"anthropic:claude-haiku-4-5"`, `"ollama:llama3.2:3b-instruct-q4_K_M"`,
97    /// `"mock"`). Used for tracing/diagnostics; not parsed by the
98    /// caller.
99    fn name(&self) -> &str;
100
101    /// Produce a summary string for the given chunks. Implementations
102    /// should respect [`SummaryContext::max_words`] when set and
103    /// produce neutral past-tense prose.
104    ///
105    /// Implementations MUST return [`SummarizerError::NoInput`] when
106    /// `chunks` is empty. The provider relies on this to skip writing
107    /// a `JobSummary` for sessions that have no member chunks.
108    async fn summarize(
109        &self,
110        chunks: &[MemoryChunk],
111        ctx: &SummaryContext,
112    ) -> SummarizerResult<String>;
113}
114
115/// Test double for [`Summarizer`]. Returns a fixed canned response and
116/// records the chunks it received for assertion. Exposed unconditionally
117/// so downstream integration tests can use it without a circular
118/// dev-dep, matching [`crate::ClosureHook`]'s shape for write hooks.
119pub struct MockSummarizer {
120    name: String,
121    canned: String,
122    calls: Mutex<Vec<MockSummaryCall>>,
123}
124
125/// One recorded invocation of [`MockSummarizer::summarize`].
126#[derive(Debug, Clone)]
127pub struct MockSummaryCall {
128    /// Snapshot of the chunk IDs received, in order.
129    pub chunk_ids: Vec<String>,
130    /// Snapshot of the context received.
131    pub kind_label: Option<String>,
132    /// Snapshot of any caller note.
133    pub note: Option<String>,
134    /// Snapshot of the requested cap.
135    pub max_words: Option<u32>,
136}
137
138impl MockSummarizer {
139    /// Construct a mock that returns a fixed canned summary for every
140    /// call. Returns an `Arc` so the caller can clone the handle and
141    /// still assert against the recorded calls.
142    pub fn new(canned: impl Into<String>) -> Arc<Self> {
143        Arc::new(Self {
144            name: "mock".into(),
145            canned: canned.into(),
146            calls: Mutex::new(Vec::new()),
147        })
148    }
149
150    /// Snapshot the calls this summarizer has seen.
151    pub fn calls(&self) -> Vec<MockSummaryCall> {
152        self.calls.lock().unwrap().clone()
153    }
154
155    /// Convenience: number of calls so far.
156    pub fn call_count(&self) -> usize {
157        self.calls.lock().unwrap().len()
158    }
159}
160
161#[async_trait]
162impl Summarizer for MockSummarizer {
163    fn name(&self) -> &str {
164        &self.name
165    }
166
167    async fn summarize(
168        &self,
169        chunks: &[MemoryChunk],
170        ctx: &SummaryContext,
171    ) -> SummarizerResult<String> {
172        if chunks.is_empty() {
173            return Err(SummarizerError::NoInput);
174        }
175        self.calls.lock().unwrap().push(MockSummaryCall {
176            chunk_ids: chunks.iter().map(|c| c.id.clone()).collect(),
177            kind_label: ctx.kind_label.clone(),
178            note: ctx.note.clone(),
179            max_words: ctx.max_words,
180        });
181        Ok(self.canned.clone())
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::chunk::{ChunkKind, ChunkSource, MemoryTier};
189    use chrono::Utc;
190    use serde_json::Value;
191
192    fn chunk(id: &str, content: &str) -> MemoryChunk {
193        MemoryChunk {
194            id: id.into(),
195            created_at: Utc::now(),
196            kind: ChunkKind::Chat,
197            tier: MemoryTier::Session,
198            source: ChunkSource::Embedded,
199            session_id: Some("s1".into()),
200            project_root: None,
201            caller_id: "embedded".into(),
202            content: content.into(),
203            metadata: Value::Null,
204            importance: 0.5,
205            pinned: false,
206            shareable: false,
207            superseded_by: None,
208            embedding_model: "mock".into(),
209            embedding_dim: 0,
210        }
211    }
212
213    #[tokio::test]
214    async fn mock_returns_canned_text() {
215        let s = MockSummarizer::new("done");
216        let chunks = vec![chunk("a", "first"), chunk("b", "second")];
217        let ctx = SummaryContext {
218            kind_label: Some("session".into()),
219            ..Default::default()
220        };
221        let out = s.summarize(&chunks, &ctx).await.unwrap();
222        assert_eq!(out, "done");
223        assert_eq!(s.call_count(), 1);
224        let calls = s.calls();
225        assert_eq!(calls[0].chunk_ids, vec!["a".to_string(), "b".to_string()]);
226        assert_eq!(calls[0].kind_label.as_deref(), Some("session"));
227    }
228
229    #[tokio::test]
230    async fn mock_no_input_errors() {
231        let s = MockSummarizer::new("ignored");
232        let err = s
233            .summarize(&[], &SummaryContext::default())
234            .await
235            .unwrap_err();
236        assert!(matches!(err, SummarizerError::NoInput));
237    }
238
239    #[tokio::test]
240    async fn mock_name_is_mock() {
241        let s = MockSummarizer::new("x");
242        assert_eq!(s.name(), "mock");
243    }
244}