use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use thiserror::Error;
use crate::chunk::MemoryChunk;
#[derive(Debug, Error)]
pub enum SummarizerError {
#[error("provider error: {0}")]
Provider(String),
#[error("no chunks to summarize")]
NoInput,
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("internal error: {0}")]
Internal(String),
}
pub type SummarizerResult<T> = std::result::Result<T, SummarizerError>;
#[derive(Debug, Clone, Default)]
pub struct SummaryContext {
pub kind_label: Option<String>,
pub note: Option<String>,
pub max_words: Option<u32>,
}
#[async_trait]
pub trait Summarizer: Send + Sync {
fn name(&self) -> &str;
async fn summarize(
&self,
chunks: &[MemoryChunk],
ctx: &SummaryContext,
) -> SummarizerResult<String>;
}
pub struct MockSummarizer {
name: String,
canned: String,
calls: Mutex<Vec<MockSummaryCall>>,
}
#[derive(Debug, Clone)]
pub struct MockSummaryCall {
pub chunk_ids: Vec<String>,
pub kind_label: Option<String>,
pub note: Option<String>,
pub max_words: Option<u32>,
}
impl MockSummarizer {
pub fn new(canned: impl Into<String>) -> Arc<Self> {
Arc::new(Self {
name: "mock".into(),
canned: canned.into(),
calls: Mutex::new(Vec::new()),
})
}
pub fn calls(&self) -> Vec<MockSummaryCall> {
self.calls.lock().unwrap().clone()
}
pub fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
}
#[async_trait]
impl Summarizer for MockSummarizer {
fn name(&self) -> &str {
&self.name
}
async fn summarize(
&self,
chunks: &[MemoryChunk],
ctx: &SummaryContext,
) -> SummarizerResult<String> {
if chunks.is_empty() {
return Err(SummarizerError::NoInput);
}
self.calls.lock().unwrap().push(MockSummaryCall {
chunk_ids: chunks.iter().map(|c| c.id.clone()).collect(),
kind_label: ctx.kind_label.clone(),
note: ctx.note.clone(),
max_words: ctx.max_words,
});
Ok(self.canned.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::{ChunkKind, ChunkSource, MemoryTier};
use chrono::Utc;
use serde_json::Value;
fn chunk(id: &str, content: &str) -> MemoryChunk {
MemoryChunk {
id: id.into(),
created_at: Utc::now(),
kind: ChunkKind::Chat,
tier: MemoryTier::Session,
source: ChunkSource::Embedded,
session_id: Some("s1".into()),
project_root: None,
caller_id: "embedded".into(),
content: content.into(),
metadata: Value::Null,
importance: 0.5,
pinned: false,
shareable: false,
superseded_by: None,
embedding_model: "mock".into(),
embedding_dim: 0,
}
}
#[tokio::test]
async fn mock_returns_canned_text() {
let s = MockSummarizer::new("done");
let chunks = vec![chunk("a", "first"), chunk("b", "second")];
let ctx = SummaryContext {
kind_label: Some("session".into()),
..Default::default()
};
let out = s.summarize(&chunks, &ctx).await.unwrap();
assert_eq!(out, "done");
assert_eq!(s.call_count(), 1);
let calls = s.calls();
assert_eq!(calls[0].chunk_ids, vec!["a".to_string(), "b".to_string()]);
assert_eq!(calls[0].kind_label.as_deref(), Some("session"));
}
#[tokio::test]
async fn mock_no_input_errors() {
let s = MockSummarizer::new("ignored");
let err = s
.summarize(&[], &SummaryContext::default())
.await
.unwrap_err();
assert!(matches!(err, SummarizerError::NoInput));
}
#[tokio::test]
async fn mock_name_is_mock() {
let s = MockSummarizer::new("x");
assert_eq!(s.name(), "mock");
}
}