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