Skip to main content

recall_echo/
tags.rs

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