Skip to main content

opendev_runtime/
action_summarizer.rs

1//! Action summarizer — create concise spinner text from LLM responses.
2//!
3//! Two modes:
4//! 1. **Heuristic** (default, zero-cost): Regex-based extraction of action phrases.
5//! 2. **LLM-based** (optional): Uses a small model for high-quality summaries.
6
7use std::borrow::Cow;
8
9/// Maximum default summary length.
10const DEFAULT_MAX_LENGTH: usize = 60;
11
12/// Common action verbs to detect at the start of sentences.
13const ACTION_VERBS: &[&str] = &[
14    "reading",
15    "writing",
16    "editing",
17    "searching",
18    "analyzing",
19    "creating",
20    "modifying",
21    "updating",
22    "checking",
23    "running",
24    "building",
25    "testing",
26    "fixing",
27    "implementing",
28    "refactoring",
29    "debugging",
30    "deploying",
31    "installing",
32    "configuring",
33    "deleting",
34    "removing",
35    "moving",
36    "renaming",
37    "copying",
38    "downloading",
39    "uploading",
40    "parsing",
41    "compiling",
42    "formatting",
43    "linting",
44    "reviewing",
45    "examining",
46    "looking",
47    "inspecting",
48    "exploring",
49    "scanning",
50    "fetching",
51    "loading",
52    "saving",
53    "committing",
54    "pushing",
55    "pulling",
56    "merging",
57    "rebasing",
58    "cloning",
59];
60
61/// Prefixes that indicate the LLM is about to take an action.
62const INTENT_PREFIXES: &[&str] = &[
63    "I'll ",
64    "I will ",
65    "Let me ",
66    "I need to ",
67    "I'm going to ",
68    "I am going to ",
69    "Now I'll ",
70    "Now let me ",
71    "First, I'll ",
72    "Next, I'll ",
73];
74
75/// Summarize an LLM response into a concise action phrase for spinner display.
76///
77/// Uses heuristics — no API call needed.
78pub fn summarize_action(text: &str, max_length: usize) -> String {
79    let max_len = if max_length == 0 {
80        DEFAULT_MAX_LENGTH
81    } else {
82        max_length
83    };
84
85    // Try extracting from intent prefixes
86    if let Some(summary) = extract_from_intent(text) {
87        return truncate_to(&summary, max_len);
88    }
89
90    // Try finding a sentence starting with an action verb
91    if let Some(summary) = extract_action_verb_sentence(text) {
92        return truncate_to(&summary, max_len);
93    }
94
95    // Fallback: first sentence, cleaned up
96    let first = first_sentence(text);
97    truncate_to(&first, max_len)
98}
99
100/// Extract action from intent prefix ("I'll search the files" → "Searching the files").
101fn extract_from_intent(text: &str) -> Option<String> {
102    for prefix in INTENT_PREFIXES {
103        if let Some(rest) = text.strip_prefix(prefix) {
104            let sentence = first_clause(rest);
105            if sentence.is_empty() {
106                continue;
107            }
108            // Convert "search the files" → "Searching the files"
109            let converted = verb_to_gerund(&sentence);
110            return Some(capitalize_first(&converted));
111        }
112    }
113    None
114}
115
116/// Find a sentence starting with an action verb in gerund form.
117fn extract_action_verb_sentence(text: &str) -> Option<String> {
118    let lower = text.to_lowercase();
119    for verb in ACTION_VERBS {
120        if let Some(pos) = lower.find(verb) {
121            // Only match at start of sentence (after newline, period, or start)
122            if pos > 0 {
123                let before = text.as_bytes()[pos - 1];
124                if before != b'\n' && before != b'.' && before != b' ' {
125                    continue;
126                }
127            }
128            let rest = &text[pos..];
129            let sentence = first_clause(rest);
130            return Some(capitalize_first(&sentence));
131        }
132    }
133    None
134}
135
136/// Convert base verb to gerund: "search" → "Searching", "read" → "Reading".
137fn verb_to_gerund(text: &str) -> String {
138    let words: Vec<&str> = text.splitn(2, char::is_whitespace).collect();
139    if words.is_empty() {
140        return text.to_string();
141    }
142
143    let verb = words[0].to_lowercase();
144    let rest = if words.len() > 1 { words[1] } else { "" };
145
146    // Check if already a gerund
147    if verb.ends_with("ing") {
148        return text.to_string();
149    }
150
151    let last_char = verb.chars().last();
152    let second_last = verb.chars().nth(verb.len().saturating_sub(2));
153    let third_last = verb.chars().nth(verb.len().saturating_sub(3));
154
155    let gerund = if verb.ends_with('e') && !verb.ends_with("ee") {
156        format!("{}ing", &verb[..verb.len() - 1])
157    } else if verb.len() >= 3
158        && last_char.is_some_and(is_consonant)
159        && second_last.is_some_and(is_vowel)
160        && third_last.is_some_and(is_consonant)
161        && !verb.ends_with('w')
162        && !verb.ends_with('x')
163        && !verb.ends_with('y')
164    {
165        // Double the final consonant before adding -ing (e.g., "run" → "running")
166        format!("{}{}", verb, last_char.unwrap_or_default()) + "ing"
167    } else {
168        format!("{verb}ing")
169    };
170
171    if rest.is_empty() {
172        gerund
173    } else {
174        format!("{gerund} {rest}")
175    }
176}
177
178fn is_vowel(c: char) -> bool {
179    matches!(c, 'a' | 'e' | 'i' | 'o' | 'u')
180}
181
182fn is_consonant(c: char) -> bool {
183    c.is_ascii_alphabetic() && !is_vowel(c)
184}
185
186fn capitalize_first(s: &str) -> String {
187    let mut chars = s.chars();
188    match chars.next() {
189        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
190        None => String::new(),
191    }
192}
193
194fn first_sentence(text: &str) -> Cow<'_, str> {
195    let line = text.lines().next().unwrap_or(text);
196    if let Some(pos) = line.find(['.', '!', '?']) {
197        Cow::Borrowed(&line[..pos])
198    } else {
199        Cow::Borrowed(line)
200    }
201}
202
203fn first_clause(text: &str) -> String {
204    let line = text.lines().next().unwrap_or(text);
205    // End at period, comma, semicolon, or em-dash
206    let end = line.find(['.', ';', '—']).unwrap_or(line.len());
207    line[..end].trim().to_string()
208}
209
210fn truncate_to(s: &str, max_len: usize) -> String {
211    if s.len() <= max_len {
212        s.to_string()
213    } else {
214        format!("{}...", &s[..max_len - 3])
215    }
216}
217
218#[cfg(test)]
219#[path = "action_summarizer_tests.rs"]
220mod tests;