pyrtan / tests / test_intent.py
7,382 bytes · last changed 2026-10-25 in Read sentences with rules: rewrites, typed slots, synonyms, typos, pyrtan match
| 1 | import pytest |
| 2 | |
| 3 | from pyrtan.intent import Trigger, TriggerError, best_matches, normalise |
| 4 | |
| 5 | |
| 6 | def test_normalise(): |
| 7 | assert normalise(" What's the Weather in Oslo?! ") == "What's the Weather in Oslo" |
| 8 | assert normalise("a b") == "a b" |
| 9 | |
| 10 | |
| 11 | def test_slots_and_types(): |
| 12 | t = Trigger("top {count:int} stories on hacker news") |
| 13 | assert t.match("Top 5 stories on Hacker News") == {"count": 5} |
| 14 | assert t.match("top five stories on hacker news") == {"count": 5} # number words are read |
| 15 | assert t.match("top many stories on hacker news") is None |
| 16 | assert Trigger("is {host:word} up").match("is example.com up") == {"host": "example.com"} |
| 17 | assert Trigger("is {host:word} up").match("is example dot com up") is None |
| 18 | assert Trigger("set {x:float}").match("set 1.5") == {"x": 1.5} |
| 19 | assert Trigger("find {q}").match("find a thing with spaces") == {"q": "a thing with spaces"} |
| 20 | |
| 21 | |
| 22 | def test_slot_keeps_case(): |
| 23 | assert Trigger("weather in {place}").match("Weather in New York") == {"place": "New York"} |
| 24 | |
| 25 | |
| 26 | def test_optional_and_alternatives(): |
| 27 | t = Trigger("[what's the] weather in {place}") |
| 28 | assert t.match("weather in Oslo") == {"place": "Oslo"} |
| 29 | assert t.match("What's the weather in Oslo") == {"place": "Oslo"} |
| 30 | t = Trigger("how (hot|cold) is it in {place}") |
| 31 | assert t.match("how cold is it in Oslo") == {"place": "Oslo"} |
| 32 | assert t.match("how warm is it in Oslo") is None |
| 33 | t = Trigger("how [hot|cold] is it in {place}") |
| 34 | assert t.match("how is it in Oslo") == {"place": "Oslo"} |
| 35 | |
| 36 | |
| 37 | def test_fixed_inputs(): |
| 38 | t = Trigger("top story on hacker news", {"count": 1}) |
| 39 | assert t.match("top story on hacker news") == {"count": 1} |
| 40 | |
| 41 | |
| 42 | def test_bad_patterns(): |
| 43 | for bad in ["", "{x", "{x:money}", "[unbalanced", "unbalanced]", "{a} {a}", "{x}"]: |
| 44 | with pytest.raises(TriggerError): |
| 45 | Trigger(bad) |
| 46 | |
| 47 | |
| 48 | def test_specificity_picks_the_longer_literal(): |
| 49 | specific = Trigger("weather in {place}") |
| 50 | general = Trigger("tell me {anything}") # a bare "{anything}" is rejected: it would match everything |
| 51 | matches = best_matches("tell me weather in Oslo", [("a", general), ("b", specific)]) |
| 52 | assert [m.tree for m in matches] == ["a"] # only one matches at all |
| 53 | matches = best_matches("weather in Oslo", [("a", Trigger("weather {place}")), ("b", specific)]) |
| 54 | assert [m.tree for m in matches] == ["b"] |
| 55 | |
| 56 | |
| 57 | def test_ties_are_reported_not_resolved(): |
| 58 | a, b = Trigger("ping {x}"), Trigger("ping {y}") |
| 59 | matches = best_matches("ping me", [("a", a), ("b", b)]) |
| 60 | assert {m.tree for m in matches} == {"a", "b"} |
| 61 | |
| 62 | |
| 63 | # ---------------------------------------------------------------- reading |
| 64 | |
| 65 | import datetime as dt |
| 66 | |
| 67 | from pyrtan.intent import Language, read |
| 68 | |
| 69 | |
| 70 | def test_patterns_and_sentences_get_the_same_rewrites(): |
| 71 | t = Trigger("[what's the] weather in {place}") |
| 72 | assert t.match("What is the weather in Oslo") == {"place": "Oslo"} |
| 73 | assert t.match("Hey Pyrtan, what's the weather in Oslo, please?") == {"place": "Oslo"} |
| 74 | assert t.match("could you tell me the weather in Oslo") is None # "tell me" is not filler |
| 75 | |
| 76 | |
| 77 | def test_literal_words_match_their_plural_or_singular(): |
| 78 | t = Trigger("top {count:int} stories on hacker news") |
| 79 | assert t.match("top 1 story on hacker news") == {"count": 1} |
| 80 | assert Trigger("list the item").match("list the items") == {} |
| 81 | assert Trigger("latest news").match("latest new") is None # "news" is not a plural |
| 82 | |
| 83 | |
| 84 | def test_synonyms_from_the_language_table(): |
| 85 | lang = Language.from_raw({"synonyms": {"weather": ["forecast", "temperature"], "stories": "posts"}}) |
| 86 | t = Trigger("weather in {place}", language=lang) |
| 87 | assert t.match("forecast in Oslo") == {"place": "Oslo"} |
| 88 | assert t.match("temperature in Oslo") == {"place": "Oslo"} |
| 89 | assert t.match("climate in Oslo") is None |
| 90 | assert Trigger("top {n:int} stories", language=lang).match("top 3 posts") == {"n": 3} |
| 91 | assert Trigger("hacker news", language=Language.from_raw({"synonyms": {"hacker": ["hn", "y combinator"]}})).match("y combinator news") == {} |
| 92 | for bad in [{"synonyms": "weather"}, {"synonyms": {"weather": [1]}}, {"typos": "yes"}, {"other": 1}, {"synonyms": {"two words": ["x"]}}]: |
| 93 | with pytest.raises(TriggerError): |
| 94 | Language.from_raw(bad) |
| 95 | |
| 96 | |
| 97 | def test_typos_are_opt_in_and_only_when_one_word_is_close(): |
| 98 | strict = Trigger("weather in {place}") |
| 99 | loose = Trigger("weather in {place}", language=Language(typos=True)) |
| 100 | assert strict.match("wether in Oslo") is None |
| 101 | got = loose.match_detail("wether in Oslo") |
| 102 | assert got.inputs == {"place": "Oslo"} and got.notes == ["typo: wether->weather"] |
| 103 | assert loose.match("weathr in Oslo") == {"place": "Oslo"} |
| 104 | assert loose.match("wather in Oslo") == {"place": "Oslo"} |
| 105 | assert loose.match("water in Oslo") is None # two edits away: not corrected |
| 106 | assert loose.match("in in Oslo") is None # short words are never corrected |
| 107 | both = Trigger("(hotel|model) in {place}", language=Language(typos=True)) |
| 108 | assert both.match("hodel in Oslo") is None # one edit from both: a guess, so refused |
| 109 | assert both.match("hostel in Oslo") == {"place": "Oslo"} # one edit from hotel only |
| 110 | |
| 111 | |
| 112 | def test_typed_slots_read_values(): |
| 113 | sunday = dt.date(2026, 10, 25) |
| 114 | t = Trigger("remind me {when:date} at {at:time}") |
| 115 | assert t.match("remind me tomorrow at 3pm", now=sunday) == {"when": "2026-10-26", "at": "15:00"} |
| 116 | assert t.match("remind me next friday at 15:30", now=sunday) == {"when": "2026-10-30", "at": "15:30"} |
| 117 | got = t.match_detail("remind me someday at 3pm", now=sunday) |
| 118 | assert got.rejected and got.notes == ["slot when: 'someday' is not a date this reads"] |
| 119 | assert Trigger("wait {for:duration}").match("wait an hour and a half") == {"for": 5400} |
| 120 | assert Trigger("is {site:host} up").match("is https://Example.com/ up") == {"site": "example.com"} |
| 121 | assert Trigger("is {site:host} up").match("is example dot com up") is None |
| 122 | assert Trigger("open {u:url}").match("open https://example.com/a") == {"u": "https://example.com/a"} |
| 123 | assert Trigger("mail {to:email}").match("mail Someone@Example.com") == {"to": "someone@example.com"} |
| 124 | assert Trigger("top {n:number} stories").match("top twenty-one stories") == {"n": 21} |
| 125 | with pytest.raises(TriggerError): |
| 126 | Trigger("{x:money}") |
| 127 | |
| 128 | |
| 129 | def test_read_reports_the_rewrites_and_every_verdict(): |
| 130 | sunday = dt.date(2026, 10, 25) |
| 131 | candidates = [("w", Trigger("[what's the] weather in {place}")), ("r", Trigger("remind me {when:date}")), ("g", Trigger("hi {name}"))] |
| 132 | r = read("Hey, what's the weather in Oslo please", candidates, sunday) |
| 133 | assert r.cleaned == "what is the weather in Oslo" |
| 134 | assert r.steps == ["fillers: 'Hey', 'please'", "contractions: what's->what is"] |
| 135 | assert r.winner.tree == "w" and r.winner.inputs == {"place": "Oslo"} |
| 136 | step = r.step() |
| 137 | assert step["kind"] == "intent" and "w via" in step["summary"] and step["observed"]["inputs"] == {"place": "Oslo"} |
| 138 | r = read("remind me someday", candidates, sunday) |
| 139 | assert r.matches == [] and [m.rejected for m in r.considered] == [True] |
| 140 | assert "no trigger matched" in r.step()["summary"] |
| 141 | # "hi" is kept because a trigger starts with it. |
| 142 | r = read("hi Bob", candidates, sunday) |
| 143 | assert r.winner.tree == "g" and r.winner.inputs == {"name": "Bob"} |
| 144 | r = read("hi", [("w", candidates[0][1])], sunday) |
| 145 | assert r.cleaned == "" and r.matches == [] |