pyrtan / sourcev0.14.0 · main

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

1import pytest
2
3from pyrtan.intent import Trigger, TriggerError, best_matches, normalise
4
5
6def 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
11def 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
22def test_slot_keeps_case():
23 assert Trigger("weather in {place}").match("Weather in New York") == {"place": "New York"}
24
25
26def 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
37def 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
42def 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
48def 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
57def 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
65import datetime as dt
66
67from pyrtan.intent import Language, read
68
69
70def 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
77def 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
84def 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
97def 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
112def 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
129def 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 == []