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