Skip to main content

recall_echo/
tags.rs

1//! Structured tag extraction from conversations.
2//!
3//! Extracts decisions, action items, project references, files touched,
4//! and tools used from conversation entries.
5
6use crate::conversation::ConversationEntry;
7
8/// Structured tags extracted from a conversation.
9#[derive(Debug, Clone, Default)]
10pub struct ConversationTags {
11    pub decisions: Vec<String>,
12    pub action_items: Vec<String>,
13    pub project: Option<String>,
14    pub files_touched: Vec<String>,
15    pub tools_used: Vec<String>,
16}
17
18impl ConversationTags {
19    pub fn is_empty(&self) -> bool {
20        self.decisions.is_empty()
21            && self.action_items.is_empty()
22            && self.project.is_none()
23            && self.files_touched.is_empty()
24            && self.tools_used.is_empty()
25    }
26}
27
28const DECISION_MARKERS: &[&str] = &[
29    "decided to",
30    "decision:",
31    "we'll go with",
32    "going with",
33    "let's use",
34    "chose to",
35    "choosing",
36    "settled on",
37    "agreed on",
38    "switched to",
39    "instead of",
40    "rather than",
41    "the approach is",
42    "plan is to",
43];
44
45const ACTION_MARKERS: &[&str] = &[
46    "todo:",
47    "todo -",
48    "action item:",
49    "next step:",
50    "need to",
51    "needs to",
52    "should be",
53    "will need",
54    "follow up",
55    "follow-up",
56    "remaining:",
57    "still need",
58    "don't forget",
59    "remember to",
60    "make sure to",
61];
62
63/// Extract structured tags from flattened conversation entries.
64pub fn extract_tags(entries: &[ConversationEntry]) -> ConversationTags {
65    let mut tags = ConversationTags::default();
66    let mut tool_set = std::collections::HashSet::new();
67    let mut file_set = std::collections::HashSet::new();
68
69    for entry in entries {
70        match entry {
71            ConversationEntry::UserMessage(text) | ConversationEntry::AssistantText(text) => {
72                extract_decisions(text, &mut tags.decisions);
73                extract_action_items(text, &mut tags.action_items);
74                if tags.project.is_none() {
75                    tags.project = detect_project(text);
76                }
77            }
78            ConversationEntry::ToolUse {
79                name,
80                input_summary,
81            } => {
82                tool_set.insert(name.clone());
83                let summary = input_summary.trim_matches('`');
84                if !summary.is_empty() && (summary.contains('/') || summary.contains('.')) {
85                    file_set.insert(summary.to_string());
86                }
87            }
88            ConversationEntry::ToolResult { .. } => {}
89        }
90    }
91
92    tags.tools_used = tool_set.into_iter().collect();
93    tags.tools_used.sort();
94    tags.files_touched = file_set.into_iter().collect();
95    tags.files_touched.sort();
96
97    tags.decisions.truncate(5);
98    tags.action_items.truncate(5);
99    tags.files_touched.truncate(10);
100
101    tags
102}
103
104fn extract_decisions(text: &str, decisions: &mut Vec<String>) {
105    let lower = text.to_lowercase();
106    for marker in DECISION_MARKERS {
107        if let Some(pos) = lower.find(marker) {
108            let start = text[..pos].rfind(['.', '\n']).map(|p| p + 1).unwrap_or(pos);
109            let end_offset = pos + marker.len();
110            let end = text[end_offset..]
111                .find(['.', '\n'])
112                .map(|p| end_offset + p + 1)
113                .unwrap_or(text.len().min(end_offset + 100));
114            let sentence = text[start..end].trim();
115            if sentence.len() >= 10 && sentence.len() <= 200 && decisions.len() < 5 {
116                let sentence_lower = sentence.to_lowercase();
117                if !decisions.iter().any(|d| d.to_lowercase() == sentence_lower) {
118                    decisions.push(sentence.to_string());
119                }
120            }
121        }
122    }
123}
124
125fn extract_action_items(text: &str, actions: &mut Vec<String>) {
126    let lower = text.to_lowercase();
127    for marker in ACTION_MARKERS {
128        if let Some(pos) = lower.find(marker) {
129            let end_offset = pos + marker.len();
130            let end = text[end_offset..]
131                .find(['.', '\n'])
132                .map(|p| end_offset + p + 1)
133                .unwrap_or(text.len().min(end_offset + 100));
134            let item = text[pos..end].trim();
135            if item.len() >= 5 && item.len() <= 200 && actions.len() < 5 {
136                let item_lower = item.to_lowercase();
137                if !actions.iter().any(|a| a.to_lowercase() == item_lower) {
138                    actions.push(item.to_string());
139                }
140            }
141        }
142    }
143}
144
145fn detect_project(text: &str) -> Option<String> {
146    let patterns = ["project:", "repo:", "repository:", "working on", "in the"];
147    let lower = text.to_lowercase();
148
149    for pattern in &patterns {
150        if let Some(pos) = lower.find(pattern) {
151            let after = &text[pos + pattern.len()..];
152            let word: String = after
153                .trim()
154                .chars()
155                .take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
156                .collect();
157            if word.len() >= 2 {
158                return Some(word);
159            }
160        }
161    }
162
163    None
164}
165
166/// Format tags as a markdown section for inclusion in conversation archives.
167pub fn format_tags_section(tags: &ConversationTags) -> String {
168    if tags.is_empty() {
169        return String::new();
170    }
171
172    let mut section = String::from("\n## Tags\n");
173
174    if let Some(ref project) = tags.project {
175        section.push_str(&format!("\n**Project**: {project}\n"));
176    }
177
178    if !tags.decisions.is_empty() {
179        section.push_str("\n**Decisions**:\n");
180        for d in &tags.decisions {
181            section.push_str(&format!("- {d}\n"));
182        }
183    }
184
185    if !tags.action_items.is_empty() {
186        section.push_str("\n**Action Items**:\n");
187        for a in &tags.action_items {
188            section.push_str(&format!("- {a}\n"));
189        }
190    }
191
192    if !tags.files_touched.is_empty() {
193        section.push_str("\n**Files**: ");
194        section.push_str(&tags.files_touched.join(", "));
195        section.push('\n');
196    }
197
198    if !tags.tools_used.is_empty() {
199        section.push_str("\n**Tools**: ");
200        section.push_str(&tags.tools_used.join(", "));
201        section.push('\n');
202    }
203
204    section
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn extract_decisions_basic() {
213        let entries = vec![ConversationEntry::AssistantText(
214            "After reviewing the options, I decided to use JWT tokens instead of session cookies."
215                .to_string(),
216        )];
217        let tags = extract_tags(&entries);
218        assert!(!tags.decisions.is_empty());
219        assert!(tags.decisions[0].contains("JWT"));
220    }
221
222    #[test]
223    fn extract_action_items_basic() {
224        let entries = vec![ConversationEntry::AssistantText(
225            "The auth module works now. Still need to add rate limiting to the API endpoints."
226                .to_string(),
227        )];
228        let tags = extract_tags(&entries);
229        assert!(!tags.action_items.is_empty());
230        assert!(tags.action_items[0].contains("rate limiting"));
231    }
232
233    #[test]
234    fn extract_tools_and_files() {
235        let entries = vec![
236            ConversationEntry::ToolUse {
237                name: "Read".to_string(),
238                input_summary: "/src/auth.rs".to_string(),
239            },
240            ConversationEntry::ToolUse {
241                name: "Edit".to_string(),
242                input_summary: "/src/config.rs".to_string(),
243            },
244            ConversationEntry::ToolUse {
245                name: "Read".to_string(),
246                input_summary: "/src/main.rs".to_string(),
247            },
248        ];
249        let tags = extract_tags(&entries);
250        assert_eq!(tags.tools_used, vec!["Edit", "Read"]);
251        assert_eq!(tags.files_touched.len(), 3);
252    }
253
254    #[test]
255    fn empty_entries_empty_tags() {
256        let tags = extract_tags(&[]);
257        assert!(tags.is_empty());
258    }
259
260    #[test]
261    fn format_tags_section_basic() {
262        let tags = ConversationTags {
263            decisions: vec!["Use JWT instead of sessions".to_string()],
264            action_items: vec!["Still need to add rate limiting".to_string()],
265            project: Some("voice-echo".to_string()),
266            files_touched: vec!["/src/auth.rs".to_string()],
267            tools_used: vec!["Edit".to_string(), "Read".to_string()],
268        };
269        let section = format_tags_section(&tags);
270        assert!(section.contains("## Tags"));
271        assert!(section.contains("**Project**: voice-echo"));
272        assert!(section.contains("**Decisions**:"));
273        assert!(section.contains("JWT"));
274    }
275
276    #[test]
277    fn format_empty_tags_returns_empty() {
278        let tags = ConversationTags::default();
279        assert!(format_tags_section(&tags).is_empty());
280    }
281}