pyrtan / sourcev0.19.0 · main

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 at
join 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, serial
lines, 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 sentence
const 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 }}&current=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 }}&current=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():