clawft_plugin/voice/
commands.rs1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct VoiceCommand {
14 pub triggers: Vec<String>,
17 pub tool: String,
19 #[serde(default)]
21 pub params: serde_json::Value,
22 #[serde(default)]
24 pub confirm: bool,
25 pub description: String,
27}
28
29pub struct VoiceCommandRegistry {
34 commands: Vec<VoiceCommand>,
35 trigger_index: HashMap<String, usize>,
37}
38
39impl VoiceCommandRegistry {
40 pub fn new(commands: Vec<VoiceCommand>) -> Self {
42 let mut trigger_index = HashMap::new();
43 for (idx, cmd) in commands.iter().enumerate() {
44 for trigger in &cmd.triggers {
45 trigger_index.insert(trigger.to_lowercase(), idx);
46 }
47 }
48 Self {
49 commands,
50 trigger_index,
51 }
52 }
53
54 pub fn match_command(&self, transcription: &str) -> Option<&VoiceCommand> {
60 let lower = transcription.to_lowercase();
61 let lower = lower.trim();
62
63 for (trigger, idx) in &self.trigger_index {
65 if lower.starts_with(trigger.as_str()) {
66 return Some(&self.commands[*idx]);
67 }
68 }
69
70 let words: Vec<&str> = lower.split_whitespace().collect();
72 for (trigger, idx) in &self.trigger_index {
73 let trigger_words: Vec<&str> = trigger.split_whitespace().collect();
74 if words.len() >= trigger_words.len() {
75 let spoken = words[..trigger_words.len()].join(" ");
76 if levenshtein_distance(&spoken, trigger) <= 2 {
77 return Some(&self.commands[*idx]);
78 }
79 }
80 }
81
82 None
83 }
84
85 pub fn list(&self) -> &[VoiceCommand] {
87 &self.commands
88 }
89
90 pub fn with_defaults() -> Self {
92 let commands = vec![
93 VoiceCommand {
94 triggers: vec!["stop listening".into(), "stop voice".into()],
95 tool: "voice_stop".into(),
96 params: serde_json::json!({}),
97 confirm: false,
98 description: "Stop the voice listening session.".into(),
99 },
100 VoiceCommand {
101 triggers: vec!["what time is it".into(), "current time".into()],
102 tool: "system_info".into(),
103 params: serde_json::json!({"query": "time"}),
104 confirm: false,
105 description: "Show the current time.".into(),
106 },
107 VoiceCommand {
108 triggers: vec!["list files".into(), "show files".into()],
109 tool: "list_directory".into(),
110 params: serde_json::json!({"path": "."}),
111 confirm: false,
112 description: "List files in the current directory.".into(),
113 },
114 ];
115 Self::new(commands)
116 }
117}
118
119pub fn levenshtein_distance(a: &str, b: &str) -> usize {
124 let a_chars: Vec<char> = a.chars().collect();
125 let b_chars: Vec<char> = b.chars().collect();
126 let m = a_chars.len();
127 let n = b_chars.len();
128
129 let mut dp = vec![vec![0usize; n + 1]; m + 1];
130 for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
131 row[0] = i;
132 }
133 for j in 0..=n {
134 dp[0][j] = j;
135 }
136
137 for i in 1..=m {
138 for j in 1..=n {
139 let cost = if a_chars[i - 1] == b_chars[j - 1] {
140 0
141 } else {
142 1
143 };
144 dp[i][j] = (dp[i - 1][j] + 1)
145 .min(dp[i][j - 1] + 1)
146 .min(dp[i - 1][j - 1] + cost);
147 }
148 }
149
150 dp[m][n]
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 fn test_registry() -> VoiceCommandRegistry {
158 VoiceCommandRegistry::with_defaults()
159 }
160
161 #[test]
162 fn exact_match_stop_listening() {
163 let registry = test_registry();
164 let cmd = registry.match_command("stop listening").unwrap();
165 assert_eq!(cmd.tool, "voice_stop");
166 }
167
168 #[test]
169 fn exact_match_what_time() {
170 let registry = test_registry();
171 let cmd = registry.match_command("what time is it").unwrap();
172 assert_eq!(cmd.tool, "system_info");
173 }
174
175 #[test]
176 fn exact_match_list_files_with_suffix() {
177 let registry = test_registry();
178 let cmd = registry.match_command("list files in the current directory").unwrap();
179 assert_eq!(cmd.tool, "list_directory");
180 }
181
182 #[test]
183 fn exact_match_case_insensitive() {
184 let registry = test_registry();
185 let cmd = registry.match_command("Stop Listening").unwrap();
186 assert_eq!(cmd.tool, "voice_stop");
187 }
188
189 #[test]
190 fn fuzzy_match_within_distance_2() {
191 let registry = test_registry();
192 let cmd = registry.match_command("stopp listening");
194 assert!(cmd.is_some());
195 assert_eq!(cmd.unwrap().tool, "voice_stop");
196 }
197
198 #[test]
199 fn no_match_for_unrelated_phrase() {
200 let registry = test_registry();
201 let cmd = registry.match_command("tell me a joke");
202 assert!(cmd.is_none());
203 }
204
205 #[test]
206 fn levenshtein_identical_strings() {
207 assert_eq!(levenshtein_distance("hello", "hello"), 0);
208 }
209
210 #[test]
211 fn levenshtein_one_insertion() {
212 assert_eq!(levenshtein_distance("hell", "hello"), 1);
213 }
214
215 #[test]
216 fn levenshtein_one_deletion() {
217 assert_eq!(levenshtein_distance("hello", "helo"), 1);
218 }
219
220 #[test]
221 fn levenshtein_one_substitution() {
222 assert_eq!(levenshtein_distance("hello", "hallo"), 1);
223 }
224
225 #[test]
226 fn levenshtein_completely_different() {
227 assert_eq!(levenshtein_distance("abc", "xyz"), 3);
228 }
229
230 #[test]
231 fn levenshtein_empty_strings() {
232 assert_eq!(levenshtein_distance("", ""), 0);
233 assert_eq!(levenshtein_distance("abc", ""), 3);
234 assert_eq!(levenshtein_distance("", "abc"), 3);
235 }
236
237 #[test]
238 fn voice_command_serde_roundtrip() {
239 let cmd = VoiceCommand {
240 triggers: vec!["test".into()],
241 tool: "test_tool".into(),
242 params: serde_json::json!({"key": "value"}),
243 confirm: true,
244 description: "A test command".into(),
245 };
246 let json = serde_json::to_string(&cmd).unwrap();
247 let restored: VoiceCommand = serde_json::from_str(&json).unwrap();
248 assert_eq!(restored.triggers, vec!["test"]);
249 assert_eq!(restored.tool, "test_tool");
250 assert!(restored.confirm);
251 }
252
253 #[test]
254 fn registry_list_returns_all_commands() {
255 let registry = test_registry();
256 assert_eq!(registry.list().len(), 3);
257 }
258
259 #[test]
260 fn custom_commands_registry() {
261 let commands = vec![VoiceCommand {
262 triggers: vec!["deploy now".into(), "ship it".into()],
263 tool: "deploy".into(),
264 params: serde_json::json!({"env": "production"}),
265 confirm: true,
266 description: "Deploy to production.".into(),
267 }];
268 let registry = VoiceCommandRegistry::new(commands);
269 let cmd = registry.match_command("ship it").unwrap();
270 assert_eq!(cmd.tool, "deploy");
271 assert!(cmd.confirm);
272 }
273}