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