6,103 bytes · last changed 2026-10-25 in Read sentences with rules: rewrites, typed slots, synonyms, typos, pyrtan match
| 1 | """The language rules: fixed tables and fixed rules, the same answer every |
| 2 | time, and a refusal where a guess would be needed.""" |
| 3 | |
| 4 | import datetime as dt |
| 5 | |
| 6 | import pytest |
| 7 | |
| 8 | from pyrtan import nlp |
| 9 | |
| 10 | SUNDAY = dt.date(2026, 10, 25) |
| 11 | |
| 12 | |
| 13 | def test_clean_names_every_rule_that_fired(): |
| 14 | text, steps = nlp.clean("Hey Pyrtan, what's the weather in Oslo please?") |
| 15 | assert text == "what is the weather in Oslo" # the place keeps its case |
| 16 | assert steps == ["fillers: 'Hey Pyrtan', 'please'", "contractions: what's->what is"] |
| 17 | text, steps = nlp.clean("Could you tell me twenty-one things") |
| 18 | assert text == "tell me 21 things" and steps == ["fillers: 'Could you'", "numbers: twenty-one->21"] |
| 19 | assert nlp.clean("is example.com up") == ("is example.com up", []) |
| 20 | assert nlp.clean("What’s New York like")[0] == "What is New York like" # curly quote folded |
| 21 | |
| 22 | |
| 23 | def test_fillers_are_kept_when_a_trigger_starts_with_them(): |
| 24 | assert nlp.clean("hi there")[0] == "there" |
| 25 | assert nlp.clean("hi there", keep=frozenset({"hi"}))[0] == "hi there" |
| 26 | assert nlp.clean("hey pyrtan, hi there", keep=frozenset({"hi"}))[0] == "hi there" |
| 27 | assert nlp.clean("say thanks", keep=frozenset({"thanks"}))[0] == "say thanks" |
| 28 | assert nlp.clean("say thanks")[0] == "say" |
| 29 | |
| 30 | |
| 31 | @pytest.mark.parametrize("text,value", [ |
| 32 | ("three", 3), ("twenty-one", 21), ("twenty one", 21), ("a dozen", 12), ("two hundred and five", 205), |
| 33 | ("one hundred thousand", 100000), ("1,000", 1000), ("2.5", 2.5), ("-4", -4), ("ninety nine", 99), |
| 34 | ]) |
| 35 | def test_numbers(text, value): |
| 36 | assert nlp.parse_number(text) == value |
| 37 | |
| 38 | |
| 39 | def test_number_words_only_touch_number_runs(): |
| 40 | assert nlp.words_to_digits("a dozen eggs and two hundred and five items") == ("12 eggs and 205 items", ["a dozen->12", "two hundred and five->205"]) |
| 41 | assert nlp.words_to_digits("set a reminder and one more")[0] == "set a reminder and 1 more" |
| 42 | assert nlp.words_to_digits("one of a kind")[0] == "1 of a kind" |
| 43 | with pytest.raises(nlp.ParseError): |
| 44 | nlp.parse_number("many") |
| 45 | |
| 46 | |
| 47 | @pytest.mark.parametrize("text,expected", [ |
| 48 | ("today", "2026-10-25"), ("tomorrow", "2026-10-26"), ("yesterday", "2026-10-24"), |
| 49 | ("the day after tomorrow", "2026-10-27"), ("friday", "2026-10-30"), ("next friday", "2026-10-30"), |
| 50 | ("last friday", "2026-10-23"), ("sunday", "2026-10-25"), ("this sunday", "2026-10-25"), ("next sunday", "2026-11-01"), |
| 51 | ("mon", "2026-10-26"), ("in 3 days", "2026-10-28"), ("in two weeks", "2026-11-08"), ("2 weeks ago", "2026-10-11"), |
| 52 | ("oct 25", "2026-10-25"), ("25 october 2026", "2026-10-25"), ("October 3rd", "2026-10-03"), ("3rd of march", "2026-03-03"), |
| 53 | ("13/10", "2026-10-13"), ("25.10.2026", "2026-10-25"), ("2026-02-28", "2026-02-28"), ("next month", "2026-11-25"), |
| 54 | ("last year", "2025-10-25"), ("end of month", "2026-10-31"), ("end of year", "2026-12-31"), ("on tuesday", "2026-10-27"), |
| 55 | ]) |
| 56 | def test_dates_relative_to_a_fixed_now(text, expected): |
| 57 | assert nlp.parse_date(text, SUNDAY) == expected |
| 58 | |
| 59 | |
| 60 | def test_dates_refuse_a_guess(): |
| 61 | with pytest.raises(nlp.ParseError, match="could be day/month or month/day"): |
| 62 | nlp.parse_date("3/10/2026", SUNDAY) |
| 63 | with pytest.raises(nlp.ParseError): |
| 64 | nlp.parse_date("2026-02-30", SUNDAY) |
| 65 | with pytest.raises(nlp.ParseError): |
| 66 | nlp.parse_date("soonish", SUNDAY) |
| 67 | assert nlp.parse_date("2024-01-31", SUNDAY) == "2024-01-31" |
| 68 | assert nlp.parse_date("in 1 month", dt.date(2026, 1, 31)) == "2026-02-28" # clamped, not rolled over |
| 69 | |
| 70 | |
| 71 | @pytest.mark.parametrize("text,expected", [ |
| 72 | ("3pm", "15:00"), ("15:30", "15:30"), ("3:05 pm", "15:05"), ("noon", "12:00"), ("midnight", "00:00"), |
| 73 | ("9 o'clock", "09:00"), ("12am", "00:00"), ("12 pm", "12:00"), ("7.45am", "07:45"), |
| 74 | ]) |
| 75 | def test_times(text, expected): |
| 76 | assert nlp.parse_time(text) == expected |
| 77 | |
| 78 | |
| 79 | def test_times_refuse(): |
| 80 | for bad in ["25:00", "7", "13pm", "half past"]: |
| 81 | with pytest.raises(nlp.ParseError): |
| 82 | nlp.parse_time(bad) |
| 83 | |
| 84 | |
| 85 | @pytest.mark.parametrize("text,seconds", [ |
| 86 | ("2 hours", 7200), ("30 min", 1800), ("1h30m", 5400), ("an hour", 3600), ("two days", 172800), ("90s", 90), |
| 87 | ("1 hour 15 minutes", 4500), ("1 hour and a half", 5400), ("half an hour", 1800), ("a week", 604800), |
| 88 | ]) |
| 89 | def test_durations(text, seconds): |
| 90 | assert nlp.parse_duration(text) == seconds |
| 91 | |
| 92 | |
| 93 | def test_durations_refuse(): |
| 94 | for bad in ["3 fortnights", "soon", "2", "hours"]: |
| 95 | with pytest.raises(nlp.ParseError): |
| 96 | nlp.parse_duration(bad) |
| 97 | |
| 98 | |
| 99 | def test_hosts_urls_emails(): |
| 100 | assert nlp.parse_host("https://Example.com/x") == "example.com" |
| 101 | assert nlp.parse_host("api.github.com") == "api.github.com" |
| 102 | assert nlp.parse_host("localhost:8700") == "localhost" |
| 103 | assert nlp.parse_url("https://example.com/a?b=1") == "https://example.com/a?b=1" |
| 104 | assert nlp.parse_email("Someone@Example.COM") == "someone@example.com" |
| 105 | for fn, bad in [(nlp.parse_host, "example dot com"), (nlp.parse_host, "not a host"), (nlp.parse_url, "example.com"), (nlp.parse_email, "nope@")]: |
| 106 | with pytest.raises(nlp.ParseError): |
| 107 | fn(bad) |
| 108 | |
| 109 | |
| 110 | @pytest.mark.parametrize("word,base", [ |
| 111 | ("stories", "story"), ("news", "news"), ("boxes", "box"), ("items", "item"), ("status", "status"), |
| 112 | ("cities", "city"), ("bus", "bus"), ("this", "this"), ("series", "series"), ("addresses", "address"), |
| 113 | ("analysis", "analysis"), ("is", "is"), ("releases", "release"), ("up", "up"), |
| 114 | ]) |
| 115 | def test_singular_is_conservative(word, base): |
| 116 | assert nlp.singular(word) == base |
| 117 | |
| 118 | |
| 119 | def test_forms_cover_singular_and_plural_only(): |
| 120 | assert nlp.forms("story") == {"story", "stories"} |
| 121 | assert nlp.forms("stories") == {"story", "stories"} |
| 122 | assert nlp.forms("news") == {"news"} |
| 123 | assert nlp.forms("up") == {"up"} |
| 124 | |
| 125 | |
| 126 | def test_distance(): |
| 127 | assert nlp.distance("weather", "weather") == 0 |
| 128 | assert nlp.distance("wether", "weather") == 1 |
| 129 | assert nlp.distance("waether", "weather") == 1 # a transposition is one edit |
| 130 | assert nlp.distance("wather", "weather") == 1 |
| 131 | assert nlp.distance("abc", "xyz") == 3 |
| 132 | assert nlp.distance("a", "abcdef", 1) == 2 # stops early past the limit |