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    }];
88
89    let response = provider
90        .invoke(SUMMARIZE_PROMPT, &llm_messages, 500, None)
91        .await?;
92
93    let text = response.text();
94    parse_summary_response(&text)
95}
96
97#[cfg(feature = "pulse-null")]
98fn parse_summary_response(
99    text: &str,
100) -> Result<ConversationSummary, Box<dyn std::error::Error + Send + Sync>> {
101    let cleaned = text
102        .trim()
103        .strip_prefix("```json")
104        .or(text.trim().strip_prefix("```"))
105        .unwrap_or(text.trim());
106    let cleaned = cleaned.strip_suffix("```").unwrap_or(cleaned).trim();
107
108    let v: serde_json::Value = serde_json::from_str(cleaned)?;
109
110    Ok(ConversationSummary {
111        summary: v
112            .get("summary")
113            .and_then(|s| s.as_str())
114            .unwrap_or("")
115            .to_string(),
116        topics: v
117            .get("topics")
118            .and_then(|a| a.as_array())
119            .map(|arr| {
120                arr.iter()
121                    .filter_map(|v| v.as_str().map(String::from))
122                    .take(5)
123                    .collect()
124            })
125            .unwrap_or_default(),
126        decisions: v
127            .get("decisions")
128            .and_then(|a| a.as_array())
129            .map(|arr| {
130                arr.iter()
131                    .filter_map(|v| v.as_str().map(String::from))
132                    .take(5)
133                    .collect()
134            })
135            .unwrap_or_default(),
136        action_items: v
137            .get("action_items")
138            .and_then(|a| a.as_array())
139            .map(|arr| {
140                arr.iter()
141                    .filter_map(|v| v.as_str().map(String::from))
142                    .take(5)
143                    .collect()
144            })
145            .unwrap_or_default(),
146    })
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn algorithmic_fallback_produces_output() {
155        let conv = Conversation {
156            session_id: "test".to_string(),
157            first_timestamp: None,
158            last_timestamp: None,
159            user_message_count: 1,
160            assistant_message_count: 1,
161            entries: vec![
162                conversation::ConversationEntry::UserMessage(
163                    "Let's set up authentication with JWT tokens".to_string(),
164                ),
165                conversation::ConversationEntry::AssistantText(
166                    "I'll implement JWT auth. We decided to use RS256 signing.".to_string(),
167                ),
168            ],
169        };
170        let summary = algorithmic_summary(&conv);
171        assert!(!summary.summary.is_empty());
172        assert!(!summary.topics.is_empty());
173    }
174
175    #[cfg(feature = "pulse-null")]
176    #[test]
177    fn parse_valid_json_response() {
178        let json = r#"{"summary": "Set up JWT auth.", "topics": ["auth", "jwt"], "decisions": ["Use RS256"], "action_items": ["Add refresh tokens"]}"#;
179        let result = parse_summary_response(json).unwrap();
180        assert_eq!(result.summary, "Set up JWT auth.");
181        assert_eq!(result.topics, vec!["auth", "jwt"]);
182        assert_eq!(result.decisions, vec!["Use RS256"]);
183        assert_eq!(result.action_items, vec!["Add refresh tokens"]);
184    }
185
186    #[cfg(feature = "pulse-null")]
187    #[test]
188    fn parse_json_with_fencing() {
189        let json = "```json\n{\"summary\": \"test\", \"topics\": [], \"decisions\": [], \"action_items\": []}\n```";
190        let result = parse_summary_response(json).unwrap();
191        assert_eq!(result.summary, "test");
192    }
193
194    #[cfg(feature = "pulse-null")]
195    #[test]
196    fn parse_malformed_json_returns_error() {
197        let result = parse_summary_response("not json at all");
198        assert!(result.is_err());
199    }
200
201    #[test]
202    fn empty_conversation_produces_empty_summary() {
203        let conv = Conversation::new("test");
204        let summary = algorithmic_summary(&conv);
205        assert_eq!(summary.summary, "Empty session");
206        assert!(summary.topics.is_empty());
207    }
208}