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