Skip to main content

planarian_intent/
lib.rs

1//! Intent matcher (SPEC §14): a phrase → a transition, deterministically. No
2//! language model; identical behaviour in degraded mode.
3//!
4//! A fixed set of patterns ("restart X", "stop X for 20 minutes", "start X
5//! again", "make text bigger", …) is tried first; token overlap against the
6//! change catalog's descriptions is the fallback. The object X resolves
7//! against unit names and synonyms and runtime-option keywords.
8//!
9//! "stop X" is always `StopAndSuppress`, never a bare `StopUnit` or `Suppress`.
10
11use 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    /// Current value, as a decimal string for fractional settings ("1.25").
22    pub current: Option<Value>,
23    /// Step for "bigger/smaller", in thousandths.
24    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    /// What was understood, in plain words.
61    pub understood: String,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub enum Interpretation {
66    /// ≥ 0.8: propose with `Source::Intent`.
67    Propose(Match),
68    /// 0.5–0.8: ask the user to confirm one of the top two.
69    Confirm(Vec<Match>),
70    /// < 0.5. Phase A says so; Phase B summons the model.
71    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
85/// Score how well `object` names `candidate` (per-mille): exact = 1000,
86/// otherwise the share of the longer side's tokens that match.
87fn 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    // "while I work on it": open-ended; the user lifts it ("done with X").
114    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            // Never above the propose threshold: a catalog change from fuzzy words
231            // is always confirmed first.
232            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
244/// Interpret a phrase. `now` resolves "for N minutes".
245pub 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        // Split "X [for N minutes | until …]".
258        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;