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