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}