Skip to main content

recall_echo/
summarize.rs

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