Merge pull request #30 from b-i-t-8/claude/great-heisenberg-o6ocah
b-i-t-8 · 2026-10-27 · 019cb65c38e8c8b21af95a98afa63eadb74d4b13 · parents 8252e62 7d35486
Add exchange rates and synonyms, with a join filter for lists in sentences
Changes
README.md | 10 +- _meta/tree-format.md | 3 +- examples/exchange-rate/exchange-rate.toml | 87 +++++ examples/synonyms/synonyms.toml | 62 ++++ src/pyrtan/js/pyrtan.js | 8 +- src/pyrtan/nlp.py | 12 +- src/pyrtan/page.py | 2 + src/pyrtan/template.py | 13 +- tests/conformance/make_vectors.py | 7 + tests/conformance/vectors.json | 992 ++++++++++++++++++++++++++++++++++++++++++++++---- tests/fixtures/examples.json | 67 ++++ tests/test_examples.py | 18 + tests/test_nlp.py | 6 + tests/test_template.py | 4 + 14 files changed, 1201 insertions(+), 90 deletions(-)
diff --git a/README.md b/README.md index cb7e97f..e2e405f 100644 --- a/README.md +++ b/README.md@@ -201,11 +201,11 @@ its `link`; and the gaps. The same console lives atjoin one, the browser is the hub. It holds the catalog's trees, reads the sentence and runs the tree in the page (`pyrtan.js`, the same runtime and the same reading as Python's), and keeps gaps and unanswered sentences-while the page is open. Seventeen of the examples run there: weather,-sunrise and sunset, air quality, Wikipedia, a dictionary, Hacker News-(top stories, an item, a search), books, npm and PyPI versions, GitHub-releases, earthquakes, where the ISS is, public holidays, a dad joke,-and small talk. Trees that need a box (files, programs, serial+while the page is open. Nineteen of the examples run there: weather,+sunrise and sunset, air quality, Wikipedia, a dictionary, synonyms,+exchange rates, Hacker News (top stories, an item, a search), books,+npm and PyPI versions, GitHub releases, earthquakes, where the ISS is,+public holidays, a dad joke, and small talk. Trees that need a box (files, programs, seriallines, brokers, a browser to drive) or a site that does not let a page read it say so; for those, join a hub started with `--cors https://pyrtan.com` by giving its URL and token. Forget it and the diff --git a/_meta/tree-format.md b/_meta/tree-format.md index 35b714a..fbf9aa3 100644 --- a/_meta/tree-format.md +++ b/_meta/tree-format.md@@ -327,7 +327,8 @@ service was started with, for tokens). Paths take `.key`, `[0]`, `[-1]`,`["key"]`. Filters: `url`, `json`, `int`, `float`, `str`, `lower`, `upper`, `strip`,-`title`, `first`, `last`, `length`.+`title`, `first`, `last`, `length`, `join` (a list as `a, b, c`, for a+sentence).A string that is exactly one placeholder keeps the value's type (a list stays a list). A placeholder that resolves to nothing fails the node, except diff --git a/examples/exchange-rate/exchange-rate.toml b/examples/exchange-rate/exchange-rate.toml new file mode 100644 index 0000000..1055216 --- /dev/null +++ b/examples/exchange-rate/exchange-rate.toml@@ -0,0 +1,87 @@+# Converting an amount between currencies at the European Central Bank's+# reference rate, from Frankfurter. Asked for one currency, the API answers+# with a table of one, so the value is read from it by its shape rather+# than by the currency's name.++name = "exchange-rate"+version = 1+namespace = "pyrtan"+description = "An amount in another currency at today's European Central Bank reference rate, from Frankfurter."+tags = ["money", "read-only"]+license = "MIT"+hosts = ["api.frankfurter.dev"]+start = "fetch"++[[triggers]]+pattern = "convert {amount:number} {from:word} (to|into|in) {to:word}"+[[triggers]]+pattern = "how much is {amount:number} {from:word} in {to:word}"+[[triggers]]+pattern = "[the] exchange rate (from|for) {from:word} to {to:word}"+[[triggers]]+pattern = "{from:word} to {to:word} (rate|exchange rate)"++# Kept as the digits read from the sentence, so 100 reads as 100, not 100.0.+[inputs.amount]+type = "str"+default = "1"+pattern = "^[0-9]+(\\.[0-9]+)?$"+description = "How much of the first currency, as in: 100 or 2.5"++[inputs.from]+type = "str"+pattern = "^[A-Za-z]{3}$"+description = "A three-letter currency code, as in: USD"++[inputs.to]+type = "str"+pattern = "^[A-Za-z]{3}$"+description = "A three-letter currency code, as in: EUR"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.frankfurter.dev/v1/latest?base={{ inputs.from | upper }}&symbols={{ inputs.to | upper }}&amount={{ inputs.amount }}" }+into = "resp"+extract = [+ { into = "value", regex = "\"rates\":\\s*\\{\\s*\"[A-Z]{3}\":\\s*(-?[0-9.]+(?:[eE][-+]?[0-9]+)?)\\s*\\}" },+ { into = "day", json = "json.date" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "has-rate" },+ { when = { eq = 404 }, then = "unknown-currency" },+ { when = { eq = 422 }, then = "same-currency" },+ { else = "unexpected" },+]++[nodes.has-rate]+kind = "decide"+on = "value"+branches = [+ { when = { exists = true }, then = "reply" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ inputs.amount }} {{ inputs.from | upper }} is {{ state.value }} {{ inputs.to | upper }} at the ECB rate of {{ state.day }}."+output = { amount = "{{ inputs.amount | float }}", from = "{{ inputs.from | upper }}", to = "{{ inputs.to | upper }}", value = "{{ state.value | float }}", date = "{{ state.day }}" }++[nodes.unknown-currency]+kind = "fail"+reason = "unknown-currency"+say = "The ECB publishes no rate between {{ inputs.from | upper }} and {{ inputs.to | upper }}; it covers about thirty currencies, by their three-letter codes."++[nodes.same-currency]+kind = "fail"+reason = "same-currency"+say = "{{ inputs.from | upper }} to {{ inputs.to | upper }} is the same currency."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "Frankfurter answered {{ state.resp.status }}."diff --git a/examples/synonyms/synonyms.toml b/examples/synonyms/synonyms.toml new file mode 100644 index 0000000..5bbde12 --- /dev/null +++ b/examples/synonyms/synonyms.toml@@ -0,0 +1,62 @@+# Words that mean the same, from Datamuse, best first.++name = "synonyms"+version = 1+namespace = "pyrtan"+description = "Synonyms for an English word, best first, from Datamuse."+tags = ["reference", "language", "read-only"]+license = "MIT"+hosts = ["api.datamuse.com"]+start = "fetch"++[[triggers]]+pattern = "synonyms (for|of) {word:word}"+[[triggers]]+pattern = "(another|other) word for {word:word}"+[[triggers]]+pattern = "what is another word for {word:word}"++[inputs.word]+type = "str"+pattern = "^[A-Za-z][A-Za-z'\\-]*$"+description = "One English word"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.datamuse.com/words?rel_syn={{ inputs.word | lower | url }}&max=8" }+into = "resp"+extract = [+ { into = "words", regex = "\"word\":\\s*\"([^\"]+)\"", all = true },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "any" },+ { else = "unexpected" },+]++[nodes.any]+kind = "decide"+on = "words"+branches = [+ { when = { empty = true }, then = "none" },+ { else = "reply" },+]++[nodes.reply]+kind = "done"+say = "Synonyms for {{ inputs.word }}: {{ state.words | join }}."+output = { word = "{{ inputs.word }}", synonyms = "{{ state.words }}" }++[nodes.none]+kind = "fail"+reason = "no-synonyms"+say = "Datamuse knows no synonyms for {{ inputs.word }}."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "Datamuse answered {{ state.resp.status }}."diff --git a/src/pyrtan/js/pyrtan.js b/src/pyrtan/js/pyrtan.js index 17683aa..897cf94 100644 --- a/src/pyrtan/js/pyrtan.js +++ b/src/pyrtan/js/pyrtan.js@@ -122,6 +122,7 @@return MISSING; } const pyQuote = (s) => encodeURIComponent(String(s)).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());+ const asText = (v) => (Array.isArray(v) || isObj(v) ? pyJson(v) : pyStr(v)); // a value as it reads inside a sentenceconst pyStr = (v) => (typeof v === "string" ? v : isBool(v) ? (v ? "True" : "False") : v === null || v === undefined ? "None" : Array.isArray(v) || isObj(v) ? pyrepr(v) : String(v)); const FILTERS = { url: (v) => pyQuote(pyStr(v)),@@ -135,6 +136,7 @@first: (v) => ((Array.isArray(v) || typeof v === "string") && v.length ? v[0] : MISSING), last: (v) => ((Array.isArray(v) || typeof v === "string") && v.length ? v[v.length - 1] : MISSING), length: (v) => (Array.isArray(v) || typeof v === "string" ? v.length : isObj(v) ? Object.keys(v).length : MISSING),+ join: (v) => (Array.isArray(v) ? v.map(asText).join(", ") : asText(v)),title: (v) => pyStr(v).toLowerCase().replace(/(^|[^a-z0-9'])([a-z])/gi, (m, pre, c) => pre + c.toUpperCase()).replace(/'([a-z])/gi, (m, c) => "'" + c.toUpperCase()), }; function renderValue(expr, scope) {@@ -159,8 +161,7 @@return template.replace(PLACEHOLDER, (m, expr) => { const value = renderValue(expr, scope); if (isMissing(value)) { if (strict) throw new TemplateError(`${m} resolves to nothing`); return ""; }- if (Array.isArray(value) || isObj(value)) return pyJson(value);- return pyStr(value);+ return asText(value);}); } if (Array.isArray(template)) return template.map((t) => render(t, scope, strict));@@ -587,7 +588,8 @@} function trim(text) { text = text.replace(/^[^\p{L}\p{N}]+|[\s?!.,;:]+$/gu, "");- text = text.replace(/\s*[,;!?]+\s*/g, " ");+ // A comma between digits (1,000) is part of a number and stays.+ text = text.replace(/\s*[,;!?]+\s*/g, (m, at, s) => (m === "," && /[0-9]/.test(s[at - 1] || "") && /[0-9]/.test(s[at + 1] || "") ? m : " "));text = text.replace(/(?<=[A-Za-z])\.(?=\s|$)/g, ""); return text.replace(/\s+/gu, " ").trim(); } diff --git a/src/pyrtan/nlp.py b/src/pyrtan/nlp.py index 34e33f6..a2120b1 100644 --- a/src/pyrtan/nlp.py +++ b/src/pyrtan/nlp.py@@ -92,6 +92,7 @@ DURATION_UNITS = {"w": 604800, "wk": 604800, "wks": 604800, "week": 604800, "weeks": 604800, }+DIGITS = "0123456789"WORD = re.compile(r"[A-Za-z][A-Za-z'’]*|\d+(?:[.,]\d+)*|\S") HOST_RE = re.compile(r"^(?:https?://)?((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|localhost|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:/\S*)?$", re.IGNORECASE) URL_RE = re.compile(r"^https?://[^\s<>\"']+$", re.IGNORECASE)@@ -113,13 +114,20 @@ def fold(text: str) -> str:def trim(text: str) -> str: """Drop punctuation that carries nothing: around the whole sentence, and- a comma or full stop between words."""+ a comma or full stop between words. A comma between digits (1,000) is+ part of a number and stays."""text = re.sub(r"^[\s\W_]+|[\s?!.,;:]+$", "", text)- text = re.sub(r"\s*[,;!?]+\s*", " ", text)+ text = re.sub(r"\s*[,;!?]+\s*", lambda m: m.group(0) if _in_number(m) else " ", text)text = re.sub(r"(?<=[A-Za-z])\.(?=\s|$)", "", text) return re.sub(r"\s+", " ", text).strip()+def _in_number(m: re.Match[str]) -> bool:+ s, i, j = m.string, m.start(), m.end()+ return m.group(0) == "," and 0 < i and j < len(s) and s[i - 1] in DIGITS and s[j] in DIGITS+++def strip_fillers(text: str, keep: frozenset[str] = frozenset()) -> tuple[str, list[str]]: """Greetings, politeness and request wrappers at either edge, repeatedly. A filler whose first word is in ``keep`` stays: a trigger somewhere diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 32042e8..1bc0256 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -335,6 +335,8 @@ const examples = {"hn-search": "search hacker news for sqlite", "books": "who wrote The Left Hand of Darkness", "holidays": "next holiday in US",+ "exchange-rate": "convert 100 USD to EUR",+ "synonyms": "synonyms for happy",}; // Served by a Pyrtan, only its token is kept, under its own origin; served elsewhere, the hub URL and token. const KEY = "pyrtan.console:" + location.origin; diff --git a/src/pyrtan/template.py b/src/pyrtan/template.py index 8288ac0..4b55d69 100644 --- a/src/pyrtan/template.py +++ b/src/pyrtan/template.py@@ -85,6 +85,13 @@ def _get_index(cur: Any, index: int) -> Any:return MISSING+def _text(value: Any) -> str:+ """A value as it reads inside a sentence: a list or table as JSON, the rest as itself."""+ if isinstance(value, (dict, list)):+ return json.dumps(value, ensure_ascii=False)+ return str(value)++FILTERS = { "url": lambda v: urllib.parse.quote(str(v), safe=""), "json": lambda v: json.dumps(v, ensure_ascii=False),@@ -98,6 +105,8 @@ FILTERS = {"last": lambda v: v[-1] if isinstance(v, (list, tuple, str)) and v else MISSING, "length": lambda v: len(v) if hasattr(v, "__len__") else MISSING, "title": lambda v: str(v).title(),+ # A list in a sentence: "a, b, c". Its items read as they would on their own.+ "join": lambda v: ", ".join(_text(x) for x in v) if isinstance(v, (list, tuple)) else _text(v),}@@ -143,9 +152,7 @@ def render(template: Any, scope: dict[str, Any], *, strict: bool = True) -> Any:if strict: raise TemplateError(f"{m.group(0)} resolves to nothing") return ""- if isinstance(value, (dict, list)):- return json.dumps(value, ensure_ascii=False)- return str(value)+ return _text(value)return PLACEHOLDER.sub(sub, template) if isinstance(template, list): diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index 22b4337..db73916 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py@@ -101,6 +101,12 @@ CASES: list[dict] = [{"intent": "next holiday in no"}, {"intent": "next holiday in XX"}, {"intent": "tell me a joke"},+ {"intent": "convert 100 usd to eur"},+ {"intent": "convert 1,000 EUR into JPY"},+ {"intent": "usd to xyz rate"},+ {"intent": "exchange rate from usd to usd"},+ {"intent": "synonyms for happy"},+ {"intent": "another word for qqqzzz"},]@@ -148,6 +154,7 @@ NLP = [("clean", "Hey, can you please tell me what's up?"), ("clean", "“Curly” quotes — and dashes"), ("clean", "FULL width"), ("clean", "I'm sure it's a dozen"), ("clean", "one hundred and twenty three things"), ("clean", "a hundred and one"), ("clean", "Thanks"), ("clean", "well, um, okay pyrtan: weather."), ("clean", "St. Ives. Then Mr. Smith"),+ ("clean", "convert 1,000.5 EUR, then 2, 3"), ("clean", "a,b and 1 ,2 and 3, 4"),("singular", "stories"), ("singular", "boxes"), ("singular", "news"), ("singular", "glass"), ("singular", "cats"), ("singular", "bus"), ("plural", "story"), ("plural", "box"), ("plural", "day"), ("plural", "status"), ("parse_number", "twenty-one"), ("parse_number", "1,234"), ("parse_number", "-3.25"), ("parse_number", "lots"), diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 1956998..801d22a 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json@@ -206,6 +206,47 @@], "version": 1 },+ {+ "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.",+ "hosts": [+ "api.frankfurter.dev"+ ],+ "inputs": {+ "amount": {+ "default": "1",+ "description": "How much of the first currency, as in: 100 or 2.5",+ "type": "str"+ },+ "from": {+ "description": "A three-letter currency code, as in: USD",+ "required": true,+ "type": "str"+ },+ "to": {+ "description": "A three-letter currency code, as in: EUR",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "exchange-rate",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "money",+ "read-only"+ ],+ "triggers": [+ "convert {amount:number} {from:word} (to|into|in) {to:word}",+ "how much is {amount:number} {from:word} in {to:word}",+ "[the] exchange rate (from|for) {from:word} to {to:word}",+ "{from:word} to {to:word} (rate|exchange rate)"+ ],+ "version": 1+ },{ "description": "echo for fixed", "hosts": [],@@ -817,6 +858,37 @@], "version": 1 },+ {+ "description": "Synonyms for an English word, best first, from Datamuse.",+ "hosts": [+ "api.datamuse.com"+ ],+ "inputs": {+ "word": {+ "description": "One English word",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "synonyms",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "reference",+ "language",+ "read-only"+ ],+ "triggers": [+ "synonyms (for|of) {word:word}",+ "(another|other) word for {word:word}",+ "what is another word for {word:word}"+ ],+ "version": 1+ },{ "description": "echo for tie-a", "hosts": [],@@ -2956,6 +3028,22 @@[] ] },+ {+ "fn": "clean",+ "text": "convert 1,000.5 EUR, then 2, 3",+ "value": [+ "convert 1,000.5 EUR then 2 3",+ []+ ]+ },+ {+ "fn": "clean",+ "text": "a,b and 1 ,2 and 3, 4",+ "value": [+ "a b and 1 2 and 3 4",+ []+ ]+ },{ "fn": "singular", "text": "stories",@@ -4907,18 +4995,51 @@}, { "expect": {- "cleaned": "count to 1 000.5",- "considered": [],- "matches": [],+ "cleaned": "count to 1,000.5",+ "considered": [+ {+ "inputs": {+ "n": 1000.5+ },+ "notes": [+ "n: '1,000.5' read as 1000.5"+ ],+ "rejected": false,+ "specificity": 7,+ "tree": "count",+ "trigger": "count to {n:number}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "n": 1000.5+ },+ "notes": [+ "n: '1,000.5' read as 1000.5"+ ],+ "rejected": false,+ "specificity": 7,+ "tree": "count",+ "trigger": "count to {n:number}"+ }+ ],"now": "2026-10-25T12:00:00+00:00", "step": { "kind": "intent", "node": "intent", "observed": {- "cleaned": "count to 1 000.5",- "steps": []+ "cleaned": "count to 1,000.5",+ "inputs": {+ "n": 1000.5+ },+ "notes": [+ "n: '1,000.5' read as 1000.5"+ ],+ "steps": [],+ "trigger": "count to {n:number}"},- "summary": "'count to 1,000.5' -> 'count to 1 000.5'; no trigger matched"+ "summary": "'count to 1,000.5'; count via 'count to {n:number}' (7 literal chars); n: '1,000.5' read as 1000.5"}, "steps": [], "text": "count to 1,000.5"@@ -10603,96 +10724,579 @@"inputs": {}, "intent": "tell me a joke", "tree": "dad-joke"- }- ],- "trees": {- "air-quality": {- "description": "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo.",- "hosts": [- "geocoding-api.open-meteo.com",- "air-quality-api.open-meteo.com"- ],- "inputs": {- "place": {- "description": "A town or city name",- "type": "str"- }- },- "license": "MIT",- "name": "air-quality",- "namespace": "pyrtan",- "nodes": {- "air": {- "extract": [+ },+ {+ "expect": {+ "inputs": {+ "amount": "100",+ "from": "usd",+ "to": "eur"+ },+ "output": {+ "amount": 100.0,+ "date": "2026-10-27",+ "from": "USD",+ "to": "EUR",+ "value": 89.69+ },+ "say": "100 USD is 89.69 EUR at the ECB rate of 2026-10-27.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [{- "into": "aqi",- "json": "json.current.european_aqi"+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "convert 100 usd to eur",+ "inputs": {+ "amount": 100,+ "from": "usd",+ "to": "eur"+ },+ "steps": [],+ "trigger": "convert {amount:number} {from:word} (to|into|in) {to:word}"+ },+ "summary": "'convert 100 usd to eur'; exchange-rate via 'convert {amount:number} {from:word} (to|into|in) {to:word}' (15 literal chars)"}, {- "into": "pm25",- "json": "json.current.pm2_5"+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "day": "2026-10-27",+ "value": "89.69"+ },+ "summary": "GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR&amount=100 -> 200; value=89.69, day=2026-10-27"}, {- "into": "pm10",- "json": "json.current.pm10"- }- ],- "http": {- "method": "GET",- "url": "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={{ state.lat }}&longitude={{ state.lon }}¤t=european_aqi,pm2_5,pm10"- },- "into": "aq",- "kind": "observe",- "next": "band"- },- "band": {- "branches": [- {- "then": "good",- "when": {- "lte": 20,- "type": "number"- }+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "has-rate",+ "node": "status",+ "observed": 200,+ "summary": "state.resp.status = 200"}, {- "then": "fair",- "when": {- "lte": 40- }+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "reply",+ "node": "has-rate",+ "observed": "89.69",+ "summary": "state.value = 89.69"}, {- "then": "moderate",- "when": {- "lte": 60- }+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "100 USD is 89.69 EUR at the ECB rate of 2026-10-27."+ }+ ],+ "tree": "exchange-rate"+ },+ "tree": "exchange-rate"+ },+ "inputs": {+ "amount": 100,+ "from": "usd",+ "to": "eur"+ },+ "intent": "convert 100 usd to eur",+ "tree": "exchange-rate"+ },+ {+ "expect": {+ "inputs": {+ "amount": "1000",+ "from": "EUR",+ "to": "JPY"+ },+ "output": {+ "amount": 1000.0,+ "date": "2026-10-27",+ "from": "EUR",+ "to": "JPY",+ "value": 175970.0+ },+ "say": "1000 EUR is 175970 JPY at the ECB rate of 2026-10-27.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "convert 1,000 EUR into JPY",+ "inputs": {+ "amount": 1000,+ "from": "EUR",+ "to": "JPY"+ },+ "notes": [+ "amount: '1,000' read as 1000"+ ],+ "steps": [],+ "trigger": "convert {amount:number} {from:word} (to|into|in) {to:word}"+ },+ "summary": "'convert 1,000 EUR into JPY'; exchange-rate via 'convert {amount:number} {from:word} (to|into|in) {to:word}' (15 literal chars); amount: '1,000' read as 1000"}, {- "then": "poor",- "when": {- "lte": 80- }+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "day": "2026-10-27",+ "value": "175970"+ },+ "summary": "GET https://api.frankfurter.dev/v1/latest?base=EUR&symbols=JPY&amount=1000 -> 200; value=175970, day=2026-10-27"}, {- "then": "very-poor",- "when": {- "lte": 100- }+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "has-rate",+ "node": "status",+ "observed": 200,+ "summary": "state.resp.status = 200"}, {- "then": "extremely-poor",- "when": {- "gt": 100- }+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "reply",+ "node": "has-rate",+ "observed": "175970",+ "summary": "state.value = 175970"}, {- "else": "no-reading"+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "1000 EUR is 175970 JPY at the ECB rate of 2026-10-27."} ],- "kind": "decide",- "on": "aqi"+ "tree": "exchange-rate"},- "extremely-poor": {- "kind": "set",+ "tree": "exchange-rate"+ },+ "inputs": {+ "amount": 1000,+ "from": "EUR",+ "to": "JPY"+ },+ "intent": "convert 1,000 EUR into JPY",+ "tree": "exchange-rate"+ },+ {+ "expect": {+ "inputs": {+ "amount": "1",+ "from": "usd",+ "to": "xyz"+ },+ "reason": "unknown-currency",+ "say": "The ECB publishes no rate between USD and XYZ; it covers about thirty currencies, by their three-letter codes.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "usd to xyz rate",+ "inputs": {+ "from": "usd",+ "to": "xyz"+ },+ "steps": [],+ "trigger": "{from:word} to {to:word} (rate|exchange rate)"+ },+ "summary": "'usd to xyz rate'; exchange-rate via '{from:word} to {to:word} (rate|exchange rate)' (18 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "day": null,+ "value": null+ },+ "summary": "GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=XYZ&amount=1 -> 404; value=None, day=None"+ },+ {+ "chosen": "eq 404",+ "depth": 0,+ "kind": "decide",+ "next": "unknown-currency",+ "node": "status",+ "observed": 404,+ "summary": "state.resp.status = 404"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "unknown-currency",+ "summary": "unknown-currency: The ECB publishes no rate between USD and XYZ; it covers about thirty currencies, by their three-letter codes."+ }+ ],+ "tree": "exchange-rate"+ },+ "tree": "exchange-rate"+ },+ "inputs": {+ "from": "usd",+ "to": "xyz"+ },+ "intent": "usd to xyz rate",+ "tree": "exchange-rate"+ },+ {+ "expect": {+ "inputs": {+ "amount": "1",+ "from": "usd",+ "to": "usd"+ },+ "reason": "same-currency",+ "say": "USD to USD is the same currency.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "exchange rate from usd to usd",+ "inputs": {+ "from": "usd",+ "to": "usd"+ },+ "steps": [],+ "trigger": "[the] exchange rate (from|for) {from:word} to {to:word}"+ },+ "summary": "'exchange rate from usd to usd'; exchange-rate via '[the] exchange rate (from|for) {from:word} to {to:word}' (24 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "day": null,+ "value": null+ },+ "summary": "GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=USD&amount=1 -> 422; value=None, day=None"+ },+ {+ "chosen": "eq 422",+ "depth": 0,+ "kind": "decide",+ "next": "same-currency",+ "node": "status",+ "observed": 422,+ "summary": "state.resp.status = 422"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "same-currency",+ "summary": "same-currency: USD to USD is the same currency."+ }+ ],+ "tree": "exchange-rate"+ },+ "tree": "exchange-rate"+ },+ "inputs": {+ "from": "usd",+ "to": "usd"+ },+ "intent": "exchange rate from usd to usd",+ "tree": "exchange-rate"+ },+ {+ "expect": {+ "inputs": {+ "word": "happy"+ },+ "output": {+ "synonyms": [+ "halcyon",+ "content",+ "bright",+ "felicitous",+ "riant",+ "expansive",+ "elysian",+ "joyful"+ ],+ "word": "happy"+ },+ "say": "Synonyms for happy: halcyon, content, bright, felicitous, riant, expansive, elysian, joyful.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "synonyms for happy",+ "inputs": {+ "word": "happy"+ },+ "steps": [],+ "trigger": "synonyms (for|of) {word:word}"+ },+ "summary": "'synonyms for happy'; synonyms via 'synonyms (for|of) {word:word}' (13 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "words": [+ "halcyon",+ "content",+ "bright",+ "felicitous",+ "riant",+ "expansive",+ "elysian",+ "joyful"+ ]+ },+ "summary": "GET https://api.datamuse.com/words?rel_syn=happy&max=8 -> 200; words=['halcyon', 'content', 'bright', 'felicitous', 'riant', 'expansive', 'elysian',…"+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "any",+ "node": "status",+ "observed": 200,+ "summary": "state.resp.status = 200"+ },+ {+ "chosen": "else",+ "depth": 0,+ "kind": "decide",+ "next": "reply",+ "node": "any",+ "observed": [+ "halcyon",+ "content",+ "bright",+ "felicitous",+ "riant",+ "expansive",+ "elysian",+ "joyful"+ ],+ "summary": "state.words = ['halcyon', 'content', 'bright', 'felicitous', 'riant', 'expansive', 'elysian',…"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Synonyms for happy: halcyon, content, bright, felicitous, riant, expansive, elysian, joyful."+ }+ ],+ "tree": "synonyms"+ },+ "tree": "synonyms"+ },+ "inputs": {+ "word": "happy"+ },+ "intent": "synonyms for happy",+ "tree": "synonyms"+ },+ {+ "expect": {+ "inputs": {+ "word": "qqqzzz"+ },+ "reason": "no-synonyms",+ "say": "Datamuse knows no synonyms for qqqzzz.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "another word for qqqzzz",+ "inputs": {+ "word": "qqqzzz"+ },+ "steps": [],+ "trigger": "(another|other) word for {word:word}"+ },+ "summary": "'another word for qqqzzz'; synonyms via '(another|other) word for {word:word}' (19 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "words": []+ },+ "summary": "GET https://api.datamuse.com/words?rel_syn=qqqzzz&max=8 -> 200; words=[]"+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "any",+ "node": "status",+ "observed": 200,+ "summary": "state.resp.status = 200"+ },+ {+ "chosen": "empty True",+ "depth": 0,+ "kind": "decide",+ "next": "none",+ "node": "any",+ "observed": [],+ "summary": "state.words = []"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "none",+ "summary": "no-synonyms: Datamuse knows no synonyms for qqqzzz."+ }+ ],+ "tree": "synonyms"+ },+ "tree": "synonyms"+ },+ "inputs": {+ "word": "qqqzzz"+ },+ "intent": "another word for qqqzzz",+ "tree": "synonyms"+ }+ ],+ "trees": {+ "air-quality": {+ "description": "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo.",+ "hosts": [+ "geocoding-api.open-meteo.com",+ "air-quality-api.open-meteo.com"+ ],+ "inputs": {+ "place": {+ "description": "A town or city name",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "air-quality",+ "namespace": "pyrtan",+ "nodes": {+ "air": {+ "extract": [+ {+ "into": "aqi",+ "json": "json.current.european_aqi"+ },+ {+ "into": "pm25",+ "json": "json.current.pm2_5"+ },+ {+ "into": "pm10",+ "json": "json.current.pm10"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={{ state.lat }}&longitude={{ state.lon }}¤t=european_aqi,pm2_5,pm10"+ },+ "into": "aq",+ "kind": "observe",+ "next": "band"+ },+ "band": {+ "branches": [+ {+ "then": "good",+ "when": {+ "lte": 20,+ "type": "number"+ }+ },+ {+ "then": "fair",+ "when": {+ "lte": 40+ }+ },+ {+ "then": "moderate",+ "when": {+ "lte": 60+ }+ },+ {+ "then": "poor",+ "when": {+ "lte": 80+ }+ },+ {+ "then": "very-poor",+ "when": {+ "lte": 100+ }+ },+ {+ "then": "extremely-poor",+ "when": {+ "gt": 100+ }+ },+ {+ "else": "no-reading"+ }+ ],+ "kind": "decide",+ "on": "aqi"+ },+ "extremely-poor": {+ "kind": "set","next": "reply", "set": { "band": "extremely poor"@@ -11399,6 +12003,142 @@], "version": 1 },+ "exchange-rate": {+ "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.",+ "hosts": [+ "api.frankfurter.dev"+ ],+ "inputs": {+ "amount": {+ "default": "1",+ "description": "How much of the first currency, as in: 100 or 2.5",+ "pattern": "^[0-9]+(\\.[0-9]+)?$",+ "type": "str"+ },+ "from": {+ "description": "A three-letter currency code, as in: USD",+ "pattern": "^[A-Za-z]{3}$",+ "type": "str"+ },+ "to": {+ "description": "A three-letter currency code, as in: EUR",+ "pattern": "^[A-Za-z]{3}$",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "exchange-rate",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "value",+ "regex": "\"rates\":\\s*\\{\\s*\"[A-Z]{3}\":\\s*(-?[0-9.]+(?:[eE][-+]?[0-9]+)?)\\s*\\}"+ },+ {+ "into": "day",+ "json": "json.date"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://api.frankfurter.dev/v1/latest?base={{ inputs.from | upper }}&symbols={{ inputs.to | upper }}&amount={{ inputs.amount }}"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "has-rate": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "value"+ },+ "reply": {+ "kind": "done",+ "output": {+ "amount": "{{ inputs.amount | float }}",+ "date": "{{ state.day }}",+ "from": "{{ inputs.from | upper }}",+ "to": "{{ inputs.to | upper }}",+ "value": "{{ state.value | float }}"+ },+ "say": "{{ inputs.amount }} {{ inputs.from | upper }} is {{ state.value }} {{ inputs.to | upper }} at the ECB rate of {{ state.day }}."+ },+ "same-currency": {+ "kind": "fail",+ "reason": "same-currency",+ "say": "{{ inputs.from | upper }} to {{ inputs.to | upper }} is the same currency."+ },+ "status": {+ "branches": [+ {+ "then": "has-rate",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "unknown-currency",+ "when": {+ "eq": 404+ }+ },+ {+ "then": "same-currency",+ "when": {+ "eq": 422+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "Frankfurter answered {{ state.resp.status }}."+ },+ "unknown-currency": {+ "kind": "fail",+ "reason": "unknown-currency",+ "say": "The ECB publishes no rate between {{ inputs.from | upper }} and {{ inputs.to | upper }}; it covers about thirty currencies, by their three-letter codes."+ }+ },+ "start": "fetch",+ "tags": [+ "money",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "convert {amount:number} {from:word} (to|into|in) {to:word}"+ },+ {+ "pattern": "how much is {amount:number} {from:word} in {to:word}"+ },+ {+ "pattern": "[the] exchange rate (from|for) {from:word} to {to:word}"+ },+ {+ "pattern": "{from:word} to {to:word} (rate|exchange rate)"+ }+ ],+ "version": 1+ },"github-release": { "description": "The latest release of a GitHub repository.", "hosts": [@@ -13202,6 +13942,106 @@], "version": 1 },+ "synonyms": {+ "description": "Synonyms for an English word, best first, from Datamuse.",+ "hosts": [+ "api.datamuse.com"+ ],+ "inputs": {+ "word": {+ "description": "One English word",+ "pattern": "^[A-Za-z][A-Za-z'\\-]*$",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "synonyms",+ "namespace": "pyrtan",+ "nodes": {+ "any": {+ "branches": [+ {+ "then": "none",+ "when": {+ "empty": true+ }+ },+ {+ "else": "reply"+ }+ ],+ "kind": "decide",+ "on": "words"+ },+ "fetch": {+ "extract": [+ {+ "all": true,+ "into": "words",+ "regex": "\"word\":\\s*\"([^\"]+)\""+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://api.datamuse.com/words?rel_syn={{ inputs.word | lower | url }}&max=8"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "none": {+ "kind": "fail",+ "reason": "no-synonyms",+ "say": "Datamuse knows no synonyms for {{ inputs.word }}."+ },+ "reply": {+ "kind": "done",+ "output": {+ "synonyms": "{{ state.words }}",+ "word": "{{ inputs.word }}"+ },+ "say": "Synonyms for {{ inputs.word }}: {{ state.words | join }}."+ },+ "status": {+ "branches": [+ {+ "then": "any",+ "when": {+ "eq": 200+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "Datamuse answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "reference",+ "language",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "synonyms (for|of) {word:word}"+ },+ {+ "pattern": "(another|other) word for {word:word}"+ },+ {+ "pattern": "what is another word for {word:word}"+ }+ ],+ "version": 1+ },"weather": { "description": "Current weather for a place, from Open-Meteo.", "hosts": [ diff --git a/tests/fixtures/examples.json b/tests/fixtures/examples.json index 458d526..c5fbde6 100644 --- a/tests/fixtures/examples.json +++ b/tests/fixtures/examples.json@@ -607,5 +607,72 @@"joke": "I am so good at sleeping I can do it with my eyes closed!", "status": 200 }+ },+ "GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=XYZ&amount=1": {+ "status": 404,+ "body": {+ "message": "not found"+ }+ },+ "GET https://api.datamuse.com/words?rel_syn=qqqzzz&max=8": {+ "status": 200,+ "body": []+ },+ "GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR&amount=100": {+ "status": 200,+ "body": {+ "amount": 100.0,+ "base": "USD",+ "date": "2026-10-27",+ "rates": {+ "EUR": 89.69+ }+ }+ },+ "GET https://api.frankfurter.dev/v1/latest?base=EUR&symbols=JPY&amount=1000": {+ "status": 200,+ "body": {+ "amount": 1000.0,+ "base": "EUR",+ "date": "2026-10-27",+ "rates": {+ "JPY": 175970+ }+ }+ },+ "GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=USD&amount=1": {+ "status": 422,+ "body": {+ "message": "bad currency pair"+ }+ },+ "GET https://api.datamuse.com/words?rel_syn=happy&max=8": {+ "status": 200,+ "body": [+ {+ "word": "halcyon"+ },+ {+ "word": "content"+ },+ {+ "word": "bright"+ },+ {+ "word": "felicitous"+ },+ {+ "word": "riant"+ },+ {+ "word": "expansive"+ },+ {+ "word": "elysian"+ },+ {+ "word": "joyful"+ }+ ]} } diff --git a/tests/test_examples.py b/tests/test_examples.py index ee5e384..b7ec64f 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py@@ -236,3 +236,21 @@ def test_dad_joke_asks_for_json(runner, recorded):out = runner.run_intent("tell me a joke") assert out.status == "done" and out.say == "I am so good at sleeping I can do it with my eyes closed!" assert runner.registry.get("dad-joke").nodes["fetch"].get("http")["headers"] == {"Accept": "application/json"}+++def test_exchange_rate_reads_one_rate_by_its_shape(runner):+ out = runner.run_intent("convert 100 usd to eur")+ assert out.say == "100 USD is 89.69 EUR at the ECB rate of 2026-10-27."+ assert out.output == {"amount": 100.0, "from": "USD", "to": "EUR", "value": 89.69, "date": "2026-10-27"}+ out = runner.run_intent("convert 1,000 EUR into JPY") # the comma is the number's, not a pause+ assert out.inputs["amount"] == "1000" and out.say == "1000 EUR is 175970 JPY at the ECB rate of 2026-10-27."+ assert runner.run_intent("usd to xyz rate").reason == "unknown-currency"+ assert runner.run_intent("exchange rate from usd to usd").reason == "same-currency"+ assert runner.run_intent("convert -5 usd to eur").reason == "bad-input"+++def test_synonyms_join_into_a_sentence(runner):+ out = runner.run_intent("synonyms for happy")+ assert out.say == "Synonyms for happy: halcyon, content, bright, felicitous, riant, expansive, elysian, joyful."+ assert out.output["synonyms"][:2] == ["halcyon", "content"]+ assert runner.run_intent("another word for qqqzzz").reason == "no-synonyms"diff --git a/tests/test_nlp.py b/tests/test_nlp.py index 2af8210..9673b45 100644 --- a/tests/test_nlp.py +++ b/tests/test_nlp.py@@ -36,6 +36,12 @@ def test_numbers(text, value):assert nlp.parse_number(text) == value+def test_a_comma_between_digits_is_part_of_the_number():+ assert nlp.clean("convert 1,000 EUR into JPY")[0] == "convert 1,000 EUR into JPY"+ assert nlp.clean("hi, 1, 2, 3 go")[0] == "1 2 3 go" # a comma and a space is a pause+ assert nlp.clean("a,b and 1 ,2")[0] == "a b and 1 2"++def test_number_words_only_touch_number_runs(): 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"]) assert nlp.words_to_digits("set a reminder and one more")[0] == "set a reminder and 1 more" diff --git a/tests/test_template.py b/tests/test_template.py index 991b498..2eac30b 100644 --- a/tests/test_template.py +++ b/tests/test_template.py@@ -25,6 +25,10 @@ def test_interpolation_and_filters():assert render("{{ state.ids | length }}", SCOPE) == 3 assert render("{{ state.ids | first }}", SCOPE) == 1 assert render("{{ state.resp.json.items | json }}", SCOPE) == '[{"title": "a"}, {"title": "b"}]'+ # join: a list in a sentence, each item as it would read on its own.+ assert render("ids {{ state.ids | join }}.", SCOPE) == "ids 1, 2, 3."+ assert render("{{ state.resp.json.items | join }}", SCOPE) == '{"title": "a"}, {"title": "b"}'+ assert render("{{ inputs.city | join }}", SCOPE) == "New York"def test_nested_structures_render():