Skip to main content

recall_echo/
summarize.rs

1//! Conversation summarization with optional LLM enhancement.
2//!
3//! When the `pulse-null` feature is enabled and an `LmProvider` is available,
4//! uses the LLM for high-quality summaries. Otherwise falls back to
5//! algorithmic extraction from conversation entries.
6
7use crate::conversation::{self, Conversation};
8
9/// Structured summary of a conversation.
10#[derive(Debug, Clone, Default)]
11pub struct ConversationSummary {
12    /// 2-3 sentence summary of the conversation
13    pub summary: String,
14    /// Up to 5 key topics
15    pub topics: Vec<String>,
16    /// Key decisions made
17    pub decisions: Vec<String>,
18    /// Outstanding action items
19    pub action_items: Vec<String>,
20}
21
22/// Pure algorithmic summary — no LLM calls. Always available.
23pub fn algorithmic_summary(conv: &Conversation) -> ConversationSummary {
24    ConversationSummary {
25        summary: conversation::extract_summary(conv),
26        topics: conversation::extract_topics(conv, 5),
27        decisions: Vec::new(),
28        action_items: Vec::new(),
29    }
30}
31
32// ---------------------------------------------------------------------------
33// LLM-enhanced summarization — behind pulse-null feature
34// ---------------------------------------------------------------------------
35
36#[cfg(feature = "pulse-null")]
37const SUMMARIZE_PROMPT: &str = r#"You are a conversation summarizer. Analyze the conversation and return a JSON object with exactly these fields:
38
39{
40  "summary": "2-3 sentence summary of what was discussed and accomplished",
41  "topics": ["topic1", "topic2", ...],
42  "decisions": ["decision1", "decision2", ...],
43  "action_items": ["item1", "item2", ...]
44}
45
46Rules:
47- summary: 2-3 sentences max. Focus on what was accomplished.
48- topics: Up to 5 single-word or short-phrase topics. Lowercase.
49- decisions: Key decisions made during the conversation. Empty array if none.
50- action_items: Outstanding tasks or follow-ups. Empty array if none.
51- Return ONLY valid JSON, no markdown fencing, no explanation."#;
52
53/// Extract summary with fallback: LLM if available, algorithmic otherwise.
54///
55/// This is the main entry point for pulse-null usage. It never fails — if the
56/// LLM call errors, it falls back to algorithmic extraction silently.
57#[cfg(feature = "pulse-null")]
58pub async fn extract_with_fallback(
59    provider: Option<&dyn pulse_system_types::llm::LmProvider>,
60    conv: &Conversation,
61) -> ConversationSummary {
62    if let Some(p) = provider {
63        match summarize_conversation(p, conv).await {
64            Ok(summary) => return summary,
65            Err(e) => {
66                eprintln!("recall-echo: LLM summarization failed, using fallback: {e}");
67            }
68        }
69    }
70
71    algorithmic_summary(conv)
72}
73
74/// Summarize using an LLM provider.
75#[cfg(feature = "pulse-null")]
76pub async fn summarize_conversation(
77    provider: &dyn pulse_system_types::llm::LmProvider,
78    conv: &Conversation,
79) -> Result<ConversationSummary, Box<dyn std::error::Error + Send + Sync>> {
80    use pulse_system_types::llm::{Message, MessageContent, Role};
81
82    let condensed = conversation::condense_for_summary(conv);
83
84    let llm_messages = vec![Message {
85        role: Role::User,
86        content: MessageContent::Text(condensed),
87        source: None,
88    }];
89
90    let response = provider
91        .invoke(SUMMARIZE_PROMPT, &llm_messages, 500, None)
92        .await?;
93
94    let text = response.text();
95    parse_summary_response(&text)
96}
97
98#[cfg(feature = "pulse-null")]
99fn parse_summary_response(
100    text: &str,
101) -> Result<ConversationSummary, Box<dyn std::error::Error + Send + Sync>> {
102    let cleaned = text
103        .trim()
104        .strip_prefix("```json")
105        .or(text.trim().strip_prefix("```"))
106        .unwrap_or(text.trim());
107    let cleaned = cleaned.strip_suffix("```").unwrap_or(cleaned).trim();
108
109    let v: serde_json::Value = serde_json::from_str(cleaned)?;
110
111    Ok(ConversationSummary {
112        summary: v
113            .get("summary")
114            .and_then(|s| s.as_str())
115            .unwrap_or("")
116            .to_string(),
117        topics: v
118            .get("topics")
119            .and_then(|a| a.as_array())
120            .map(|arr| {
121                arr.iter()
122                    .filter_map(|v| v.as_str().map(String::from))
123                    .take(5)
124                    .collect()
125            })
126            .unwrap_or_default(),
127        decisions: v
128            .get("decisions")
129            .and_then(|a| a.as_array())
130            .map(|arr| {
131                arr.iter()
132                    .filter_map(|v| v.as_str().map(String::from))
133                    .take(5)
134                    .collect()
135            })
136            .unwrap_or_default(),
137        action_items: v
138            .get("action_items")
139            .and_then(|a| a.as_array())
140            .map(|arr| {
141                arr.iter()
142                    .filter_map(|v| v.as_str().map(String::from))
143                    .take(5)
144                    .collect()
145            })
146            .unwrap_or_default(),
147    })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn algorithmic_fallback_produces_output() {
156        let conv = Conversation {
157            session_id: "test".to_string(),
158            first_timestamp: None,
159            last_timestamp: None,
160            user_message_count: 1,
161            assistant_message_count: 1,
162            entries: vec![
163                conversation::ConversationEntry::UserMessage(
164                    "Let's set up authentication with JWT tokens".to_string(),
165                ),
166                conversation::ConversationEntry::AssistantText(
167                    "I'll implement JWT auth. We decided to use RS256 signing.".to_string(),
168                ),
169            ],
170        };
171        let summary = algorithmic_summary(&conv);
172        assert!(!summary.summary.is_empty());
173        assert!(!summary.topics.is_empty());
174    }
175
176    #[cfg(feature = "pulse-null")]
177    #[test]
178    fn parse_valid_json_response() {
179        let json = r#"{"summary": "Set up JWT auth.", "topics": ["auth", "jwt"], "decisions": ["Use RS256"], "action_items": ["Add refresh tokens"]}"#;
180        let result = parse_summary_response(json).unwrap();
181        assert_eq!(result.summary, "Set up JWT auth.");
182        assert_eq!(result.topics, vec!["auth", "jwt"]);
183        assert_eq!(result.decisions, vec!["Use RS256"]);
184        assert_eq!(result.action_items, vec!["Add refresh tokens"]);
185    }
186
187    #[cfg(feature = "pulse-null")]
188    #[test]
189    fn parse_json_with_fencing() {
190        let json = "```json\n{\"summary\": \"test\", \"topics\": [], \"decisions\": [], \"action_items\": []}\n```";
191        let result = parse_summary_response(json).unwrap();
192        assert_eq!(result.summary, "test");
193    }
194
195    #[cfg(feature = "pulse-null")]
196    #[test]
197    fn parse_malformed_json_returns_error() {
198        let result = parse_summary_response("not json at all");
199        assert!(result.is_err());
200    }
201
202    #[test]
203    fn empty_conversation_produces_empty_summary() {
204        let conv = Conversation::new("test");
205        let summary = algorithmic_summary(&conv);
206        assert_eq!(summary.summary, "Empty session");
207        assert!(summary.topics.is_empty());
208    }
209}