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}