1use planarian_schema::{ChangeId, Confidence, Millis, Timestamp, Transition, UnitDefs, UnitId, Until, Value};
12use serde::{Deserialize, Serialize};
13
14pub const PROPOSE: Confidence = Confidence(800);
15pub const CONFIRM: Confidence = Confidence(500);
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct RuntimeOption {
19 pub path: String,
20 pub keywords: Vec<String>,
21 pub current: Option<Value>,
23 pub step_milli: i64,
25 pub min_milli: i64,
26 pub max_milli: i64,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30pub struct CatalogEntry {
31 pub id: ChangeId,
32 pub description: String,
33}
34
35#[derive(Clone, Debug, Default)]
36pub struct Index {
37 pub units: Vec<(UnitId, Vec<String>)>,
38 pub changes: Vec<CatalogEntry>,
39 pub options: Vec<RuntimeOption>,
40}
41
42impl Index {
43 pub fn new(defs: &UnitDefs, changes: Vec<CatalogEntry>, options: Vec<RuntimeOption>) -> Index {
44 let units = defs
45 .values()
46 .map(|d| {
47 let mut names = vec![d.id.name.clone(), d.id.name.replace(['-', '_', '.'], " ")];
48 names.extend(d.synonyms.iter().cloned());
49 (d.id.clone(), names.into_iter().map(|n| normalize(&n)).collect())
50 })
51 .collect();
52 Index { units, changes, options }
53 }
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Match {
58 pub transition: Transition,
59 pub confidence: Confidence,
60 pub understood: String,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub enum Interpretation {
66 Propose(Match),
68 Confirm(Vec<Match>),
70 Unmatched,
72}
73
74pub fn normalize(s: &str) -> String {
75 let cleaned: String =
76 s.to_lowercase().chars().map(|c| if c.is_alphanumeric() || c == '\'' { c } else { ' ' }).collect();
77 cleaned.replace('\'', "").split_whitespace().collect::<Vec<_>>().join(" ")
78}
79
80fn tokens(s: &str) -> Vec<&str> {
81 const STOP: [&str; 8] = ["the", "my", "a", "an", "please", "service", "it", "now"];
82 s.split(' ').filter(|t| !t.is_empty() && !STOP.contains(t)).collect()
83}
84
85fn name_score(object: &str, candidate: &str) -> u16 {
88 if object == candidate {
89 return 1000;
90 }
91 let a = tokens(object);
92 let b = tokens(candidate);
93 if a.is_empty() || b.is_empty() {
94 return 0;
95 }
96 let hits = a.iter().filter(|t| b.contains(t)).count();
97 (hits * 1000 / a.len().max(b.len())) as u16
98}
99
100fn resolve_unit(object: &str, idx: &Index) -> Vec<(UnitId, u16)> {
101 let mut scored: Vec<(UnitId, u16)> = idx
102 .units
103 .iter()
104 .map(|(id, names)| (id.clone(), names.iter().map(|n| name_score(object, n)).max().unwrap_or(0)))
105 .filter(|(_, s)| *s > 0)
106 .collect();
107 scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
108 scored
109}
110
111fn parse_until(rest: &str, now: Timestamp) -> Option<Until> {
112 let rest = rest.trim();
113 if rest.is_empty()
115 || rest.starts_with("while ")
116 || rest == "until i say"
117 || rest == "until i say so"
118 || rest == "until further notice"
119 {
120 return Some(Until::Explicit);
121 }
122 if rest == "until reboot" || rest == "until next boot" || rest == "until i reboot" {
123 return Some(Until::NextBoot);
124 }
125 let words: Vec<&str> = rest.split(' ').collect();
126 if let ["for", n, unit] = words[..] {
127 let n: u64 = match n {
128 "a" | "an" | "one" => 1,
129 "two" => 2,
130 "five" => 5,
131 "ten" => 10,
132 "twenty" => 20,
133 "thirty" => 30,
134 n => n.parse().ok()?,
135 };
136 let secs = match unit {
137 "minute" | "minutes" | "min" | "mins" => 60,
138 "hour" | "hours" => 3600,
139 _ => return None,
140 };
141 return Some(Until::At(now.plus(Millis::secs(n * secs))));
142 }
143 None
144}
145
146fn until_words(u: &Until) -> String {
147 match u {
148 Until::Explicit => "until you say otherwise".into(),
149 Until::NextBoot => "until the next boot".into(),
150 Until::At(t) => format!("until {}", t.0),
151 }
152}
153
154fn unit_matches(object: &str, idx: &Index, mk: impl Fn(&UnitId) -> (Transition, String)) -> Vec<Match> {
155 resolve_unit(object, idx)
156 .into_iter()
157 .take(2)
158 .map(|(id, score)| {
159 let (transition, understood) = mk(&id);
160 Match { transition, confidence: Confidence(score.saturating_sub(50).max(score / 2)), understood }
161 })
162 .collect()
163}
164
165fn milli(v: &Value) -> Option<i64> {
166 match v {
167 Value::Int(i) => Some(i * 1000),
168 Value::Str(s) => {
169 let (int, frac) = s.split_once('.').unwrap_or((s, ""));
170 let frac = format!("{frac:0<3}");
171 Some(int.parse::<i64>().ok()? * 1000 + frac.get(..3)?.parse::<i64>().ok()?)
172 }
173 _ => None,
174 }
175}
176
177fn from_milli(m: i64) -> Value {
178 let s = format!("{}.{:03}", m / 1000, m % 1000);
179 Value::Str(s.trim_end_matches('0').trim_end_matches('.').to_string())
180}
181
182fn option_matches(object: &str, bigger: bool, idx: &Index) -> Vec<Match> {
183 let mut out = vec![];
184 for o in &idx.options {
185 let score = o.keywords.iter().map(|k| name_score(object, &normalize(k))).max().unwrap_or(0);
186 if score == 0 {
187 continue;
188 }
189 let Some(cur) = o.current.as_ref().and_then(milli) else { continue };
190 let next = (cur + if bigger { o.step_milli } else { -o.step_milli }).clamp(o.min_milli, o.max_milli);
191 if next == cur {
192 continue;
193 }
194 out.push(Match {
195 transition: Transition::SetRuntimeOption { path: o.path.clone(), value: from_milli(next) },
196 confidence: Confidence(score.saturating_sub(50)),
197 understood: format!("set {} from {} to {}", o.path, from_milli(cur).render(), from_milli(next).render()),
198 });
199 }
200 out
201}
202
203trait Render {
204 fn render(&self) -> String;
205}
206
207impl Render for Value {
208 fn render(&self) -> String {
209 match self {
210 Value::Bool(b) => b.to_string(),
211 Value::Int(i) => i.to_string(),
212 Value::Str(s) => s.clone(),
213 Value::List(l) => format!("{l:?}"),
214 }
215 }
216}
217
218fn catalog_matches(phrase: &str, idx: &Index) -> Vec<Match> {
219 let p = tokens(phrase);
220 let mut out: Vec<Match> = idx
221 .changes
222 .iter()
223 .filter_map(|c| {
224 let d_norm = normalize(&c.description);
225 let d = tokens(&d_norm);
226 let hits = p.iter().filter(|t| d.contains(t)).count();
227 if hits == 0 {
228 return None;
229 }
230 let score = (hits * 1000 / p.len().max(1)).min(790) as u16;
233 Some(Match {
234 transition: Transition::ApplyChange { change: c.id.clone(), args: Default::default() },
235 confidence: Confidence(score),
236 understood: format!("apply \"{}\" (needs its settings)", c.description),
237 })
238 })
239 .collect();
240 out.sort_by_key(|m| std::cmp::Reverse(m.confidence));
241 out
242}
243
244pub fn interpret(phrase: &str, idx: &Index, now: Timestamp) -> Interpretation {
246 let p = normalize(phrase);
247 let mut matches: Vec<Match> = vec![];
248
249 let strip = |s: &str, prefixes: &[&str]| -> Option<String> {
250 prefixes.iter().find_map(|pre| s.strip_prefix(pre).map(|r| r.trim().to_string()))
251 };
252
253 if let Some(obj) = strip(&p, &["restart ", "reconnect ", "reload ", "kick "]) {
254 matches.extend(unit_matches(&obj, idx, |id| (Transition::RestartUnit(id.clone()), format!("restart {id}"))));
255 }
256 if let Some(rest) = strip(&p, &["stop ", "pause ", "disable ", "turn off "]) {
257 let cut = ["for ", "until ", "while "].iter().filter_map(|k| rest.find(&format!(" {k}"))).min();
259 let (obj, tail) = match cut {
260 Some(i) => (rest[..i].to_string(), rest[i..].trim().to_string()),
261 None => (rest.clone(), String::new()),
262 };
263 if let Some(until) = parse_until(&tail, now) {
264 matches.extend(unit_matches(&obj, idx, |id| {
265 (
266 Transition::StopAndSuppress {
267 unit: id.clone(),
268 until: until.clone(),
269 reason: format!("asked: \"{phrase}\""),
270 },
271 format!("stop {id} and leave it stopped {}", until_words(&until)),
272 )
273 }));
274 }
275 }
276 if let Some(obj) = p.strip_prefix("turn ").and_then(|r| r.strip_suffix(" off")) {
277 matches.extend(unit_matches(obj, idx, |id| {
278 (
279 Transition::StopAndSuppress {
280 unit: id.clone(),
281 until: Until::Explicit,
282 reason: format!("asked: \"{phrase}\""),
283 },
284 format!("stop {id} and leave it stopped until you say otherwise"),
285 )
286 }));
287 }
288 let start_again =
289 strip(&p, &["start ", "resume ", "unpause ", "done with ", "im done with ", "i am done with ", "turn on "])
290 .map(|o| o.trim_end_matches(" again").to_string())
291 .or_else(|| p.strip_prefix("turn ").and_then(|r| r.strip_suffix(" on")).map(String::from));
292 if let Some(obj) = start_again {
293 matches.extend(unit_matches(&obj, idx, |id| {
294 (Transition::LiftOverride(id.clone()), format!("let {id} run again and repair it if needed"))
295 }));
296 }
297 for (pre, bigger) in [("make ", true), ("make ", false)] {
298 if let Some(rest) = p.strip_prefix(pre) {
299 let words = if bigger { ["bigger", "larger"] } else { ["smaller", "tinier"] };
300 for w in words {
301 if let Some(obj) = rest.strip_suffix(&format!(" {w}")) {
302 matches.extend(option_matches(obj, bigger, idx));
303 }
304 }
305 }
306 }
307 if matches.is_empty() {
308 matches = catalog_matches(&p, idx);
309 }
310
311 matches.sort_by_key(|m| std::cmp::Reverse(m.confidence));
312 match matches.first() {
313 Some(m) if m.confidence >= PROPOSE && matches.get(1).is_none_or(|n| n.confidence < m.confidence) => {
314 Interpretation::Propose(m.clone())
315 }
316 Some(m) if m.confidence >= CONFIRM => Interpretation::Confirm(matches.into_iter().take(2).collect()),
317 _ => Interpretation::Unmatched,
318 }
319}
320
321#[cfg(test)]
322mod tests;