1use crate::conversation::{self, Conversation};
8
9#[derive(Debug, Clone, Default)]
11pub struct ConversationSummary {
12 pub summary: String,
14 pub topics: Vec<String>,
16 pub decisions: Vec<String>,
18 pub action_items: Vec<String>,
20}
21
22pub 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#[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#[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#[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}