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
22#[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#[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#[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#[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}