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}