pyrtan / sourcev0.14.0 · main

Merge pull request #1 from b-i-t-8/claude/festive-thompson-81w1oy

b-i-t-8 · 2026-10-25 · 3ec395d88c46a29af43ececd08d63e6c08b1cb2e · parents 303de0c 90a991d

Claude/festive thompson 81w1oy

Changes

 CONTRIBUTING.md           |   7 +-
 Dockerfile                |   2 +-
 README.md                 |  85 ++++++++++++++++------
 _meta/design.md           | 105 ++++++++++++++++++---------
 _meta/tree-format.md      |   5 ++
 deploy/README.md          |   3 +
 pyproject.toml            |   2 +-
 src/pyrtan/__init__.py    |   4 +-
 src/pyrtan/cli.py         | 108 +++++++++++++++------------
 src/pyrtan/grow.py        | 262 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/pyrtan/hub.py         |  28 ++++++-
 src/pyrtan/page.py        |  31 +++++++-
 src/pyrtan/runtime.py     |   7 +-
 src/pyrtan/service.py     |  20 ++++-
 src/pyrtan/transport.py   |   7 +-
 tests/test_cli.py         |  44 +++++++++++
 tests/test_grow.py        |  92 +++++++++++++++++++++++
 tests/test_service_hub.py |  18 +++++
 18 files changed, 707 insertions(+), 123 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ffac9e0..fc547c9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -5,8 +5,11 @@ does not exist, write it; if one gives a wrong answer, the trace shows where.
## Adding or fixing a tree
-1. `pyrtan new my-tree --dir examples/my-tree` and edit the file. Keep the
- `hosts` list to exactly the hosts it needs.
+1. `pyrtan new --from "the sentence it answers" --dir examples/my-tree`
+ (or `pyrtan new my-tree`) and edit the file. If a running swarm has
+ already been asked the sentence, `pyrtan unanswered --at URL --scaffold
+ DIR` writes the same file. Keep the `hosts` list to exactly the hosts it
+ needs.
2. `pyrtan check examples/my-tree` until it is clean. Warnings are worth reading; an `info` about a `decide` without `else` is a choice, not a fault. diff --git a/Dockerfile b/Dockerfile index 6f06e6d..0c6c422 100644 --- a/Dockerfile +++ b/Dockerfile
@@ -12,7 +12,7 @@ RUN pip install --no-cache-dir --prefix=/install .
FROM python:3.12-slim LABEL org.opencontainers.image.title="pyrtan" \
- org.opencontainers.image.description="A swarm of small services that act on the web through hardcoded, inspectable decision trees." \
+ org.opencontainers.image.description="A core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees." \
org.opencontainers.image.source="https://github.com/b-i-t-8/pyrtan" \ org.opencontainers.image.licenses="AGPL-3.0-or-later" COPY --from=build /install /usr/local diff --git a/README.md b/README.md index d71a881..583963d 100644 --- a/README.md +++ b/README.md
@@ -1,26 +1,32 @@
# Pyrtan
-**Open-source total automation: a swarm of small services that act on the web
+**Open-source total automation: a core set of expandable services that act on all forms of I/O
through hardcoded, inspectable decision trees.** Pyrtan looks like an assistant from the outside. You give it a sentence; it
-does something on the web and tells you what happened. Inside there is no
-model. Every service is a set of **trees**: declarative files that say which
-sentences they answer to, which hosts they may touch, what they observe, and
-exactly how they decide what to do next. Every run produces a **trace** that
-names each observation and the condition that chose each branch.
+reads or changes something through whatever form of I/O it has an adapter
+for, and tells you what happened. Today that form is HTTP: APIs and pages.
+The core is built so that files, shells, serial lines, queues and devices
+arrive as further adapters under the same rules. Inside there is no model.
+Every service is a set of **trees**: declarative files that say which
+sentences they answer to, which endpoints they may touch, what they observe,
+and exactly how they decide what to do next. Every run produces a **trace**
+that names each observation and the condition that chose each branch.
That is the trade. A model generalises to sentences nobody wrote a tree for;
-Pyrtan does not, and says so. In return, Pyrtan is deterministic, costs
-nothing per run, works offline against recorded responses, cannot reach a
-host its author did not list, and can always explain itself.
+Pyrtan does not, says so, and hands back the tree that would, ready to fill
+in. In return, Pyrtan is deterministic, costs nothing per run, works offline
+against recorded responses, cannot reach an endpoint its author did not
+list, and can always explain itself.
Trees are close in spirit to Claude's skills, with two differences. They are
-much narrower: a tree does one thing against one web property. And they are
+much narrower: a tree does one thing against one property, whether that is
+an API, a site, a device or any other source or sink of I/O. And they are
live: a service reloads a tree the moment its file changes, a tree can hand
-part of its work to any other tree in the swarm, and every decision that
-meets a value no branch covers is recorded as a **gap**, which is how a tree
-tells its author where to grow next.
+part of its work to any other tree in the swarm, every decision that meets a
+value no branch covers is recorded as a **gap**, and every sentence nobody
+answers is recorded as **unanswered** with a proposed tree. That is how the
+swarm tells its authors where to grow next.
``` $ pyrtan run "what's the weather in Oslo" --trees examples
@@ -116,11 +122,12 @@ whole format is in [`_meta/tree-format.md`](_meta/tree-format.md).
| `pyrtan list DIR` | What is loaded and the sentences it answers to. | | `pyrtan run "…" --trees DIR` | Answers a sentence in this process, printing the trace. `--replay FIXTURE` answers HTTP from a recording; `--record FIXTURE` makes one. | | `pyrtan run --tree NAME --input k=v` | Runs a named tree. |
-| `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`. Reloads trees when their files change. |
+| `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`, `/unanswered`. Reloads trees when their files change. |
| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. | | `pyrtan ask "…" --at URL` | Answers a sentence through a running service or hub. |
-| `pyrtan gaps --at URL` | Every decision that met a value with no branch: the list of what to write next. |
-| `pyrtan new NAME` | Scaffolds a tree file. |
+| `pyrtan gaps --at URL` | Every decision that met a value with no branch: the branches to write next. |
+| `pyrtan unanswered --at URL` | Every sentence no tree answered, with the tree that would: the trees to write next. `--scaffold DIR` writes them. |
+| `pyrtan new --from "…"` | Scaffolds a tree file from a sentence: numbers and hosts in it become slots, and the file passes `pyrtan check`. `pyrtan new NAME` scaffolds from a name. |
## A swarm
@@ -142,14 +149,41 @@ tree and splits into many without the callers changing. A tree that
routes it to whichever peer can; the peer's steps come back inside the caller's trace.
+## When no service in the swarm answers
+
+A sentence nobody answers is not a dead end; it is the next tree. The hub
+(or a service on its own) records it at `/unanswered` and answers with a
+**proposal**: a tree name drawn from the words, a trigger pattern with slots
+where the sentence had numbers or hosts, the inputs those slots need, and
+the one command that writes the file.
+
+```
+$ pyrtan ask "top 3 stories on lobsters"
+error (no-match): No service in the swarm answers 'top 3 stories on lobsters'. To write the tree that would: pyrtan new top-stories-lobsters --from "top 3 stories on lobsters"
+
+$ pyrtan unanswered --at http://127.0.0.1:8700 --scaffold trees/
+'top 3 stories on lobsters' x1
+ would be: top-stories-lobsters answering top {count:int} stories on lobsters
+ wrote trees/top-stories-lobsters.toml
+now: pyrtan check trees/
+```
+
+The scaffold is a complete tree that checks clean: a trigger, its inputs, an
+`observe`, a `decide` on the status and a `done` and `fail`. The author
+fills in what it observes and how it decides; a service serving that
+directory picks the file up the moment it is saved. No model drafts
+anything: the proposal is derived from the words by rules, and the demo page
+shows the same proposal and the same command whenever nothing answers.
+
## What Pyrtan refuses - **No model in the loop.** Nothing in this repository calls an LLM. A model may one day sit *beside* the swarm, proposing branches for recorded gaps; it will not sit inside a run deciding anything.
-- **No host a tree did not name.** Every HTTP node's host is checked against
- the tree's `hosts` at load time where it is literal and at run time where
- it is templated.
+- **No endpoint a tree did not name.** Every HTTP node's host is checked
+ against the tree's `hosts` at load time where it is literal and at run
+ time where it is templated. Every further form of I/O arrives with an
+ allow-list of the same kind, or does not arrive.
- **No code in trees.** Templates are paths and filters; conditions are comparisons. Branching is only ever a `decide` node, visible in the trace. - **No loops.** A path through a tree is finite by construction.
@@ -159,7 +193,8 @@ caller's trace.
``` src/pyrtan/ the package: tree, intent, template, conditions, extract,
- html, transport, runtime, trace, registry, service, hub, cli
+ html, transport, runtime, trace, registry, grow, service,
+ hub, cli
examples/ trees that run against real public APIs, and offline against tests/fixtures/examples.json tests/ pytest; a swarm of two services behind a hub runs on localhost
@@ -170,10 +205,12 @@ deploy/ Dockerfile at the root; a compose stack for OpenMediaVault
## Status
-0.1.0. The core, the service, the hub and the command are built and tested.
-Not yet built: a browser adapter (for pages that need a script to render),
-authentication between services, persistent gap storage, and a tree
-marketplace. See [`_meta/design.md`](_meta/design.md) for the sequencing.
+0.1.0. The core, the service, the hub and the command are built and tested,
+and HTTP is the one form of I/O the core speaks. Not yet built: further
+adapters (a browser for pages that need a script to render; files, shells,
+serial lines, message queues), authentication between services, persistent
+storage for gaps and unanswered sentences, and a tree marketplace. See
+[`_meta/design.md`](_meta/design.md) for the sequencing.
Pyrtan is a product of [Hillvon.io](https://hillvon.io). diff --git a/_meta/design.md b/_meta/design.md index 2510377..1a142bb 100644 --- a/_meta/design.md +++ b/_meta/design.md
@@ -1,10 +1,10 @@
-# Design — Pyrtan: a swarm of services that act on the web through hardcoded decision trees
+# Design — Pyrtan: a core set of expandable services that act on all forms of I/O through hardcoded decision trees
| | | |---|---|
-| **Status** | **Built, v0.1.** The core, the service, the hub and the command exist and are tested (111 tests, including a two-service swarm behind a hub on localhost). No deployment, no users, no browser adapter yet. |
+| **Status** | **Built, v0.1.** The core, the service, the hub and the command exist and are tested (133 tests, including a two-service swarm behind a hub on localhost). No deployment, no users; HTTP is the only form of I/O the core speaks yet. |
| **Date** | 2026-10-24 |
-| **Decision** | Pyrtan is an open-source automation tool: many small services, each holding declarative decision trees that read and act on the web, routed by a hub, with no model in the loop and a complete trace of every run. |
+| **Decision** | Pyrtan is an open-source automation tool: a core set of expandable services, each holding declarative decision trees that read and act on all forms of I/O, routed by a hub, with no model in the loop and a complete trace of every run. |
| **Where** | This repository: `src/pyrtan`, `examples/`, `tests/`. AGPL-3.0-or-later. | ## 0. The brief, and the reading this design commits to
@@ -21,17 +21,25 @@ output, and an explanation. Inside there is no model. The explanation is
not a model's account of itself; it is the literal path through the tree. **"Hardcoded decision trees of the web."** A tree is a file an author
-writes, naming the hosts it may touch, the requests it makes, the values it
-extracts, and the comparisons that choose the next step. "Of the web" means
-the tree encodes how one web property behaves: what a 404 means here, which
-JSON path holds the title, which weather code means rain. The knowledge a
-model would hold implicitly, a tree holds explicitly, for one site.
-
-**"Swarm of services."** A tree is held by a service; a service is a small
-process that answers `/intent` and `/run`; a hub routes between services
-and reports which are up. The swarm is the unit of deployment and of
-ownership: one team can own the trees for one property, run them on their
-own box, and register with a shared hub.
+writes, naming the endpoints it may touch, the requests it makes, the
+values it extracts, and the comparisons that choose the next step. "Of the
+web" means the tree encodes how one property behaves: what a 404 means
+here, which JSON path holds the title, which weather code means rain. The
+knowledge a model would hold implicitly, a tree holds explicitly, for one
+property. The web was the brief's example and is the first form of I/O the
+core speaks; the shape is the same for any other. An adapter names what it
+may reach, a node observes or acts through it, and a `decide` reads what
+came back. A file, a shell, a serial line or a queue is the same tree with
+a different adapter under it.
+
+**"Swarm of services."** Read as a core set of expandable services. A tree
+is held by a service; a service is a small process that answers `/intent`
+and `/run`; a hub routes between services and reports which are up. The
+core is the runtime, the service and the hub; what expands is the set of
+trees, the set of services holding them, and the forms of I/O they reach.
+The swarm is the unit of deployment and of ownership: one team can own the
+trees for one property, run them on their own box, and register with a
+shared hub.
**"Similar to Claude skills but much more focused and dynamic."** A skill is a prose instruction package a model reads and interprets. A tree is
@@ -41,8 +49,9 @@ delegate to any other tree in the swarm by sentence or by name, resolved at
run time; and a decision that meets a value no branch covers is recorded as a *gap*, so the set of trees grows where reality disagreed with its author.
-**"Total automation."** Read as the ambition, not as a claim. v0.1 reads
-APIs and pages and can POST. What it does not yet do is in Section 7.
+**"Total automation."** Read as the ambition, not as a claim. v0.1 speaks
+HTTP: it reads APIs and pages and can POST. Other forms of I/O are adapters
+still to come (Section 6); what is open is in Section 7.
## 1. What this design refuses
@@ -60,9 +69,12 @@ Four things a reader might expect, and will not find.
shows. The cost is verbosity (the weather tree spends seven `set` nodes turning a code into a word); the benefit is that a tree can be audited by someone who does not program.
-- **Reaching a host the author did not list.** `hosts` is checked at load
- time for literal URLs and at run time for templated ones. A tree that
- takes a host from its input must say `hosts = ["*"]` out loud.
+- **Reaching an endpoint the author did not list.** `hosts` is checked at
+ load time for literal URLs and at run time for templated ones. A tree
+ that takes a host from its input must say `hosts = ["*"]` out loud. The
+ same rule binds every later form of I/O: an adapter arrives with an
+ allow-list of the same kind (paths, devices, queues), or it does not
+ arrive.
- **Loops.** A tree is a directed acyclic graph; `pyrtan check` names the cycle and refuses. Repetition is `each` over a list with a limit, so every run is finite and every trace is complete.
@@ -78,8 +90,9 @@ traded away by a convenient feature.
2. **The trace is complete.** Every node visited appears, with what it observed and, for a decision, which condition held. Delegated runs appear inside the caller's trace one level deeper.
-3. **Hosts are closed.** No request leaves to a host outside the tree's
- list, whether the URL was literal or built at run time.
+3. **Endpoints are closed.** No request leaves to a host outside the
+ tree's list, whether the URL was literal or built at run time, and no
+ future adapter is admitted without an allow-list of the same kind.
4. **Reads retry; writes do not.** An `observe` may be retried on transport failure; an `act` is attempted once. The checker refuses `retries` on an act and refuses a write method on an observe.
@@ -101,9 +114,11 @@ sentence ──► intent.py ──► registry.py ──► runtime.py ──
│ └─ html.py parser + selector subset └─ transport.py urllib | recorded | recording
-service.py one process, one registry, HTTP endpoints, hot reload, gaps
+grow.py an unanswered sentence -> a proposed tree, a scaffold, a log
+service.py one process, one registry, HTTP endpoints, hot reload, gaps,
+ unanswered
hub.py peers, link up/stale/down, routing by capability, forwarding
-cli.py check · list · run · ask · serve · hub · gaps · new
+cli.py check · list · run · ask · serve · hub · gaps · unanswered · new
``` **The tree** (`tree.py`) is TOML, loaded into dataclasses and checked on
@@ -132,7 +147,22 @@ carry.
the standard library's client, a recorded fixture that raises on any request it did not foresee, and a recorder that writes such fixtures. Every example tree is tested offline through the second, and
-`pyrtan run --record` produces fixtures through the third.
+`pyrtan run --record` produces fixtures through the third. The transport
+is also the seam where forms of I/O plug in. A node names a source (`http`
+today), the runtime enforces that source's allow-list, and the transport
+moves the bytes. A file, shell, serial or queue adapter is a new node
+source with the same three parts, and the same recorded and recording
+wrappers around it.
+
+**Growth** (`grow.py`) is where a dead end becomes the next tree. A
+sentence no tree answers is recorded as *unanswered*, counted, and
+answered with a proposal: a name drawn from the words, a trigger with slots
+where the sentence had numbers or hosts, the inputs those slots need, and
+the `pyrtan new … --from` command that writes a scaffold which passes
+`check`. The proposal is derived by rules, not by a model, and it is a
+first draft for an author, not an answer for a caller. A service without a
+hub keeps its own unanswered log; one with a hub leaves the record to the
+hub, which also collects its peers' at `/unanswered`.
**The service** (`service.py`) wraps a registry in the standard library's threading HTTP server. It reloads trees from disk on every request that
@@ -174,8 +204,10 @@ to end.
**A model fallback for unmatched sentences.** The obvious feature, and the one that would quietly turn the product into the thing it is defined
-against. Not built. A sentence no tree answers returns `no-match`, and the
-honest response to a frequent `no-match` is a new tree.
+against. Not built. A sentence no tree answers returns `no-match`, is
+recorded as unanswered, and comes back with the tree that would answer it.
+The honest response to a frequent `no-match` is a new tree, and the swarm
+now hands the author its first draft rather than an excuse.
**A message bus between services.** NATS or similar would give the swarm events and fan-out. v0.1 has `act.emit`, which records an event on the
@@ -183,8 +215,9 @@ service; nothing consumes it yet. Request/response over HTTP covers every
case the examples needed, and a bus would be a dependency and an operational component before there is a swarm large enough to need one.
-**Persisting gaps.** Gaps live in a service's memory and are lost on
-restart. Enough to prove the loop (run, see gap, add branch, reload), not
+**Persisting gaps and unanswered sentences.** Both live in a service's or
+hub's memory and are lost on restart. Enough to prove the two loops (run,
+see gap, add branch, reload; ask, see unanswered, scaffold, reload), not
enough for a fleet. Deferred to Section 7. ## 5. What the examples show
@@ -208,9 +241,13 @@ Five trees, all against real public APIs, all tested offline:
file per service and collected by the hub. A `pyrtan watch` that re-runs a set of sentences on a schedule and reports when a path changes (the way to learn a site has changed before a person does).
-3. **v0.3: browser adapter.** `observe` with `browser = { url, wait_for,
- actions }` on Playwright as an optional extra, for pages that need a
- script to render, with the same host rule and the same extraction.
+3. **v0.3: more forms of I/O.** First the browser: `observe` with
+ `browser = { url, wait_for, actions }` on Playwright as an optional
+ extra, for pages that need a script to render, with the same host rule
+ and the same extraction. Then file, shell, serial and queue adapters,
+ each a node source with its own allow-list (paths, commands, devices,
+ topics), so a tree reads a log, runs a named command or polls a device
+ by the same rules it reads a page, and the trace reads the same.
4. **v0.4: trees as a shared thing.** A directory of trees people can pull by name, with the checker run on contribution, and `version` meaning something.
@@ -239,8 +276,10 @@ Five trees, all against real public APIs, all tested offline:
## 8. What this design does not claim
-It does not claim to answer sentences nobody wrote a tree for. It does not
-claim trees are quick to write: the weather tree is 15 nodes for a two-call
+It does not claim to answer sentences nobody wrote a tree for; it claims
+to say which tree would, and to write that tree's first draft. It does not
+claim to reach anything beyond HTTP yet. It does not claim trees are quick
+to write: the weather tree is 15 nodes for a two-call
API, and that is the price of leaving nothing implicit. It does not claim the swarm is secure across a network boundary (Section 7). It does not claim the examples will keep working as the sites they read change; it diff --git a/_meta/tree-format.md b/_meta/tree-format.md index 2705f56..ea344b6 100644 --- a/_meta/tree-format.md +++ b/_meta/tree-format.md
@@ -228,3 +228,8 @@ empty string into a URL.
Every outcome carries `trace`: `steps` (node, kind, summary, observed, chosen, next, elapsed_ms, depth) and `gaps`.
+
+A `no-match` error also carries `output.proposal`: the tree that would
+answer the sentence, as `name`, `trigger` (slots where the sentence had
+numbers or hosts), `inputs`, and `scaffold`, the `pyrtan new … --from`
+command that writes it. The sentence is recorded at `/unanswered`.
diff --git a/deploy/README.md b/deploy/README.md index 53307b1..a1a16fb 100644 --- a/deploy/README.md +++ b/deploy/README.md
@@ -112,4 +112,7 @@ same `uv tool install` line with `--force`, then
`hacker-news-item` three times. - Hot reload: edit a file in `trees/` from the share and ask again. - Gaps: a decision that met a value no branch covers, at `/gaps`.
+- Unanswered: ask something no tree answers and the page shows the tree
+ that would, with the command that writes it; the list is at
+ `/unanswered`.
- Health as one field: `/peers` on the hub shows each service's `link`. diff --git a/pyproject.toml b/pyproject.toml index f9ceebc..5f22a04 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project] name = "pyrtan" version = "0.1.0"
-description = "Open-source total automation: a swarm of small services that act on the web through hardcoded, inspectable decision trees."
+description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees."
readme = "README.md" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index 68cfaf5..038ac85 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py
@@ -1,4 +1,4 @@
-"""Pyrtan: a swarm of small services that act on the web through hardcoded,
+"""Pyrtan: a core set of expandable services that act on all forms of I/O through hardcoded,
inspectable decision trees. The public surface is small on purpose:
@@ -7,6 +7,8 @@ The public surface is small on purpose:
- :class:`pyrtan.Registry` holds the trees a service knows about. - :class:`pyrtan.Runner` executes a tree against a transport and returns an :class:`pyrtan.Outcome` with a full :class:`pyrtan.Trace`.
+- :func:`pyrtan.grow.propose` turns a sentence nobody answers into the tree
+ that would.
""" from .tree import Tree, load_tree, load_tree_text, TreeError diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index cd5bb0c..8a0e655 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -7,8 +7,9 @@
pyrtan ask TEXT --at URL answer a sentence through a service or hub pyrtan serve [PATH ...] [--port N] host trees as a service pyrtan hub [--peer URL ...] route between services
- pyrtan gaps [PATH ... | --at URL] branches waiting to be written
- pyrtan new NAME [--dir PATH] scaffold a tree file
+ pyrtan gaps --at URL branches waiting to be written
+ pyrtan unanswered --at URL [--scaffold DIR] trees waiting to be written
+ pyrtan new [NAME] [--from TEXT] [--dir PATH] scaffold a tree file
""" from __future__ import annotations
@@ -21,53 +22,17 @@ from pathlib import Path
from typing import Any from . import __version__
+from .grow import Proposal, propose
from .registry import Registry, default_paths from .runtime import Outcome, Runner from .service import RemoteResolver, http_json, outcome_from_dict, serve from .transport import RecordedTransport, RecordingTransport, UrllibTransport from .tree import check, load_tree
-TEMPLATE = '''# {name}: say what this tree does, in one line, below.
-
-name = "{name}"
-version = 1
-description = ""
-tags = []
-hosts = ["example.com"]
-start = "fetch"
-
-[[triggers]]
-pattern = "{words}"
-
-[nodes.fetch]
-kind = "observe"
-http = {{ method = "GET", url = "https://example.com/" }}
-into = "resp"
-extract = [{{ into = "title", css = "title" }}]
-next = "status"
-
-[nodes.status]
-kind = "decide"
-on = "resp.status"
-branches = [
- {{ when = {{ eq = 200 }}, then = "reply" }},
- {{ else = "unexpected" }},
-]
-
-[nodes.reply]
-kind = "done"
-say = "{{{{ state.title }}}}"
-output = {{ title = "{{{{ state.title }}}}" }}
-
-[nodes.unexpected]
-kind = "fail"
-reason = "unexpected-response"
-say = "example.com answered {{{{ state.resp.status }}}}."
-'''
def main(argv: list[str] | None = None) -> int:
- parser = argparse.ArgumentParser(prog="pyrtan", description="A swarm of small services that act on the web through hardcoded, inspectable decision trees.")
+ parser = argparse.ArgumentParser(prog="pyrtan", description="A core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees.")
parser.add_argument("--version", action="version", version=f"pyrtan {__version__}") sub = parser.add_subparsers(dest="command", required=True)
@@ -118,12 +83,18 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--at", help="a running service or hub") p.add_argument("--json", action="store_true")
+ p = sub.add_parser("unanswered", help="sentences nobody answered, each with the tree that would")
+ p.add_argument("--at", help="a running service or hub")
+ p.add_argument("--json", action="store_true")
+ p.add_argument("--scaffold", metavar="DIR", help="write a tree file for each sentence into DIR (existing files are kept)")
+
p = sub.add_parser("new", help="scaffold a tree file")
- p.add_argument("name")
+ p.add_argument("name", nargs="?", help="the tree's name (derived from --from when omitted)")
+ p.add_argument("--from", dest="sentence", metavar="TEXT", help="the sentence the tree should answer; numbers and hosts in it become slots")
p.add_argument("--dir", default=".") args = parser.parse_args(argv)
- return {"check": cmd_check, "list": cmd_list, "run": cmd_run, "ask": cmd_ask, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "new": cmd_new}[args.command](args)
+ return {"check": cmd_check, "list": cmd_list, "run": cmd_run, "ask": cmd_ask, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new}[args.command](args)
# ---------------------------------------------------------------- commands
@@ -321,15 +292,58 @@ def cmd_gaps(args: argparse.Namespace) -> int:
return 0
+def cmd_unanswered(args: argparse.Namespace) -> int:
+ if not args.at:
+ print("pyrtan unanswered: give --at URL of a running service or hub", file=sys.stderr)
+ return 2
+ try:
+ status, body = http_json("GET", f"{args.at.rstrip('/')}/unanswered")
+ except Exception as e:
+ print(f"pyrtan: {args.at}: {e}", file=sys.stderr)
+ return 3
+ entries = (body or {}).get("unanswered", []) if isinstance(body, dict) else []
+ if args.json:
+ print(json.dumps({"unanswered": entries}, indent=2, ensure_ascii=False))
+ return 0
+ if not entries:
+ print("nothing unanswered: every sentence so far found a tree")
+ return 0
+ for e in entries:
+ prop = e.get("proposal") or propose(e["text"]).to_dict()
+ peer = f" ({e['peer']})" if e.get("peer") else ""
+ print(f"{e['text']!r} x{e.get('count', 1)}{peer}")
+ print(f" would be: {prop['name']} answering {prop['trigger']}")
+ if args.scaffold:
+ path = _write_scaffold(Proposal(prop["text"], prop["name"], prop["trigger"], prop.get("inputs") or {}), Path(args.scaffold))
+ print(f" {'wrote' if path else 'kept'} {Path(args.scaffold) / (prop['name'] + '.toml')}")
+ else:
+ print(f" {prop['scaffold']}")
+ if args.scaffold:
+ print(f"now: pyrtan check {args.scaffold}")
+ return 0
+
+
+def _write_scaffold(proposal: Proposal, directory: Path) -> Path | None:
+ """Write the proposal's tree file unless one is already there."""
+ path = directory / f"{proposal.name}.toml"
+ if path.exists():
+ return None
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(proposal.scaffold(), encoding="utf-8")
+ return path
+
+
def cmd_new(args: argparse.Namespace) -> int:
- name = args.name
- path = Path(args.dir) / f"{name}.toml"
+ if not args.name and not args.sentence:
+ print("pyrtan new: give a NAME, or --from \"the sentence it should answer\"", file=sys.stderr)
+ return 2
+ proposal = propose(args.sentence or args.name.replace("-", " "), name=args.name)
+ path = Path(args.dir) / f"{proposal.name}.toml"
if path.exists(): print(f"pyrtan: {path} exists", file=sys.stderr) return 1
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(TEMPLATE.format(name=name, words=name.replace("-", " ")), encoding="utf-8")
- print(f"wrote {path}; edit it, then: pyrtan check {path}")
+ _write_scaffold(proposal, Path(args.dir))
+ print(f"wrote {path}: {proposal.name!r} answering {proposal.trigger!r}; edit it, then: pyrtan check {path}")
return 0 diff --git a/src/pyrtan/grow.py b/src/pyrtan/grow.py new file mode 100644 index 0000000..f659210 --- /dev/null +++ b/src/pyrtan/grow.py
@@ -0,0 +1,262 @@
+"""Where the swarm grows: sentences nobody answered, and the tree that would.
+
+A ``decide`` that meets a value no branch covers records a *gap*: a branch
+waiting to be written. A sentence no tree answers is the same thing one
+level up: a tree waiting to be written. This module gives that second case
+the same treatment the first already has.
+
+- :func:`propose` turns a sentence into a :class:`Proposal`: a tree name
+ drawn from its words, a trigger pattern with slots where the sentence had
+ numbers or hosts, and the inputs those slots need.
+- :meth:`Proposal.scaffold` renders the proposal as a tree file an author
+ fills in, the same skeleton ``pyrtan new`` writes.
+- :class:`Unanswered` is the log a service or hub keeps of sentences it
+ could not answer, counted, each with its proposal. ``GET /unanswered``
+ serves it; ``pyrtan unanswered --at URL --scaffold DIR`` turns it into
+ files.
+
+No model is involved. The proposal is derived from the words by rules, and
+it is a starting point, not an answer: the author decides what the tree
+observes and how it decides.
+"""
+
+from __future__ import annotations
+
+import re
+import shlex
+import threading
+import time
+from dataclasses import dataclass, field
+from typing import Any
+
+from .intent import Trigger, TriggerError, normalise
+
+# Words that carry no meaning for a tree's name: dropped from the name,
+# kept in the trigger (where "what's the" is worth matching literally).
+FILLER = {"a", "an", "the", "what", "what's", "whats", "how", "please", "me", "my", "tell", "show", "give", "is", "are", "of", "to", "for", "in", "on", "at", "do", "does", "it", "and", "with"}
+INT_RE = re.compile(r"^-?\d+$")
+FLOAT_RE = re.compile(r"^-?\d+\.\d+$")
+HOST_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?$", re.IGNORECASE)
+PATH_RE = re.compile(r"^[\w.\-]+/[\w.\-/]+$")
+NAME_MAX = 40
+UNANSWERED_LIMIT = 200
+
+SCAFFOLD = '''# {name}: say what this tree does, in one line, below.
+# Proposed from the sentence: {text}
+
+name = "{name}"
+version = 1
+description = ""
+tags = []
+hosts = {hosts}
+start = "fetch"
+
+[[triggers]]
+pattern = "{trigger}"
+{inputs}
+[nodes.fetch]
+kind = "observe"
+http = {{ method = "GET", url = "{url}" }}
+into = "resp"
+extract = [{{ into = "title", css = "title" }}]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ {{ when = {{ eq = 200 }}, then = "reply" }},
+ {{ else = "unexpected" }},
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{{{ state.title }}}}"
+output = {{ title = "{{{{ state.title }}}}" }}
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-response"
+say = "{host_word} answered {{{{ state.resp.status }}}}."
+'''
+
+INPUT_BLOCK = '''
+[inputs.{slot}]
+type = "{type}"
+description = "{description}"
+'''
+
+
+@dataclass
+class Proposal:
+ """A tree that would answer ``text``, before anyone has written it."""
+
+ text: str
+ name: str
+ trigger: str
+ inputs: dict[str, dict[str, Any]] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {"text": self.text, "name": self.name, "trigger": self.trigger, "inputs": self.inputs, "scaffold": self.command()}
+
+ def command(self, directory: str | None = None) -> str:
+ """The ``pyrtan new`` invocation that writes this tree's file."""
+ parts = ["pyrtan", "new", self.name, "--from", _quote(self.text)]
+ if directory:
+ parts += ["--dir", _quote(directory)]
+ return " ".join(parts)
+
+ def scaffold(self) -> str:
+ """The tree file, ready for ``pyrtan check`` and for an author."""
+ host_slot = next((s for s, spec in self.inputs.items() if spec.get("role") == "host"), None)
+ if host_slot:
+ hosts, url, host_word = '["*"]', f"https://{{{{ inputs.{host_slot} | lower }}}}/", f"{{{{ inputs.{host_slot} }}}}"
+ else:
+ hosts, url, host_word = '["example.com"]', "https://example.com/", "example.com"
+ inputs = "".join(INPUT_BLOCK.format(slot=slot, type=spec["type"], description=spec.get("description", "")) for slot, spec in self.inputs.items())
+ return SCAFFOLD.format(name=self.name, text=_toml_safe(self.text), hosts=hosts, trigger=_toml_safe(self.trigger), inputs=inputs, url=url, host_word=host_word)
+
+
+def propose(text: str, name: str | None = None) -> Proposal:
+ """A proposal for the sentence ``text``.
+
+ Numbers in the sentence become ``{count:int}`` / ``{value:float}``
+ slots, host names ``{host:word}``, ``owner/repo``-shaped words
+ ``{target:word}``; every other word is literal. The name is the literal
+ words with filler dropped, or ``name`` if given.
+ """
+ clean = normalise(text) or text.strip() or "new tree"
+ tokens = clean.split(" ")
+ pieces: list[str] = []
+ inputs: dict[str, dict[str, Any]] = {}
+ literal_words: list[str] = []
+ for tok in tokens:
+ slot = _slot_for(tok, inputs)
+ if slot is not None:
+ pieces.append(slot)
+ else:
+ pieces.append(_literal(tok))
+ literal_words.append(tok)
+ trigger = " ".join(p for p in pieces if p)
+ try:
+ Trigger(trigger)
+ except TriggerError:
+ # Everything became a slot, or the words left nothing matchable:
+ # fall back to the sentence itself, literally.
+ trigger = " ".join(_literal(t) for t in tokens if _literal(t)) or "new tree"
+ inputs = {}
+ literal_words = tokens
+ return Proposal(text=clean, name=_name_for(name, literal_words, tokens, list(inputs)), trigger=trigger, inputs=inputs)
+
+
+def _slot_for(tok: str, inputs: dict[str, dict[str, Any]]) -> str | None:
+ bare = tok.strip("\"'(),;:!?")
+ if INT_RE.match(bare):
+ slot = _unique("count", inputs)
+ inputs[slot] = {"type": "int", "description": f"as in: {bare}"}
+ return f"{{{slot}:int}}"
+ if FLOAT_RE.match(bare):
+ slot = _unique("value", inputs)
+ inputs[slot] = {"type": "float", "description": f"as in: {bare}"}
+ return f"{{{slot}:float}}"
+ if HOST_RE.match(bare) and not bare.endswith(("...", ".")):
+ slot = _unique("host", inputs)
+ inputs[slot] = {"type": "str", "description": f"a host name, as in: {bare}", "role": "host"}
+ return f"{{{slot}:word}}"
+ if PATH_RE.match(bare):
+ slot = _unique("target", inputs)
+ inputs[slot] = {"type": "str", "description": f"as in: {bare}"}
+ return f"{{{slot}:word}}"
+ return None
+
+
+def _unique(base: str, taken: dict[str, Any]) -> str:
+ if base not in taken:
+ return base
+ n = 2
+ while f"{base}{n}" in taken:
+ n += 1
+ return f"{base}{n}"
+
+
+def _literal(tok: str) -> str:
+ # Trigger syntax characters would be read as slots or groups; a literal
+ # word must not contain them.
+ return re.sub(r"[{}\[\]()|]", "", tok).strip()
+
+
+def _name_for(given: str | None, literal_words: list[str], tokens: list[str], slots: list[str] = ()) -> str:
+ if given:
+ return given
+ words = [re.sub(r"[^a-z0-9]+", "-", w.lower()).strip("-") for w in literal_words if w.lower() not in FILLER]
+ words = [w for w in words if w]
+ if len(words) < 2 and slots:
+ # "is example.com up" would be called "up"; "host-up" says more.
+ words = [re.sub(r"\d+$", "", s) for s in slots] + words
+ if not words:
+ words = [re.sub(r"[^a-z0-9]+", "-", w.lower()).strip("-") for w in tokens]
+ words = [w for w in words if w]
+ name = "-".join(words)[:NAME_MAX].strip("-")
+ name = re.sub(r"-{2,}", "-", name)
+ if not name or not name[0].isalnum():
+ name = "new-tree"
+ return name
+
+
+def _quote(s: str) -> str:
+ """Shell-quote ``s``, preferring double quotes so an apostrophe in a
+ sentence does not turn into ``'what'"'"'s'``."""
+ if re.fullmatch(r"[\w./:@%+=,-]+", s):
+ return s
+ if not any(c in s for c in '"$`\\'):
+ return f'"{s}"'
+ return shlex.quote(s)
+
+
+def _toml_safe(s: str) -> str:
+ return s.replace("\\", "\\\\").replace('"', '\\"')
+
+
+def no_match_outcome(text: str, where: str) -> dict[str, Any]:
+ """The ``say`` and ``output`` an unanswered sentence gets back: what
+ happened, and the one command that would change it."""
+ p = propose(text)
+ say = f"{where} answers {text!r}. To write the tree that would: {p.command()}"
+ return {"say": say, "output": {"unanswered": text, "proposal": p.to_dict()}}
+
+
+class Unanswered:
+ """Sentences nobody answered, counted, with a proposal for each.
+
+ Bounded: beyond ``limit`` distinct sentences the least recently seen is
+ dropped. Lives in memory, like gaps; a restart forgets it.
+ """
+
+ def __init__(self, limit: int = UNANSWERED_LIMIT) -> None:
+ self.limit = limit
+ self._entries: dict[str, dict[str, Any]] = {}
+ self._lock = threading.Lock()
+
+ def record(self, text: str, peer: str | None = None) -> dict[str, Any]:
+ key = normalise(text).lower() or text
+ now = time.time()
+ with self._lock:
+ entry = self._entries.get(key)
+ if entry is None:
+ entry = {"text": normalise(text) or text, "count": 0, "first": now, "last": now, "proposal": propose(text).to_dict()}
+ if peer:
+ entry["peer"] = peer
+ self._entries[key] = entry
+ entry["count"] += 1
+ entry["last"] = now
+ while len(self._entries) > self.limit:
+ oldest = min(self._entries, key=lambda k: self._entries[k]["last"])
+ del self._entries[oldest]
+ return dict(entry)
+
+ def to_list(self) -> list[dict[str, Any]]:
+ with self._lock:
+ return sorted((dict(e) for e in self._entries.values()), key=lambda e: (-e["count"], -e["last"]))
+
+ def __len__(self) -> int:
+ return len(self._entries)
diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 8480497..75ed25f 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py
@@ -16,6 +16,7 @@ Endpoints:
``POST /run`` forwarded to the peer holding ``tree`` ``POST /resume`` forwarded to the peer named in the snapshot ``GET /gaps`` every peer's gaps, with the peer that saw them
+``GET /unanswered`` sentences no peer answered, each with a proposed tree
``GET /`` the demo page; ``GET /api`` is the JSON index A service that holds several trees and a hub that routes between services
@@ -31,6 +32,7 @@ import time
from dataclasses import dataclass, field from typing import Any
+from .grow import Unanswered, no_match_outcome
from .intent import Trigger, best_matches from .page import PAGE from .service import Server, http_json
@@ -68,6 +70,7 @@ class Hub:
self.name = name self.poll_every = poll_every self.peers: dict[str, Peer] = {}
+ self.unanswered = Unanswered()
self.started = time.time() self._lock = threading.RLock() self._stop = threading.Event()
@@ -155,6 +158,7 @@ class Hub:
"stale": sum(1 for p in peers if p.link == "stale"), "down": sum(1 for p in peers if p.link == "down"), "trees": sum(len(p.trees) for p in peers),
+ "unanswered": len(self.unanswered),
"uptime_s": int(time.time() - self.started), }
@@ -172,7 +176,10 @@ class Hub:
return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."} matches = best_matches(text, self.candidates()) # type: ignore[arg-type] if not matches:
- return 200, {"status": "error", "reason": "no-match", "say": f"No service in the swarm answers {text!r}."}
+ entry = self.unanswered.record(text)
+ answer = no_match_outcome(text, "No service in the swarm")
+ answer["output"]["seen"] = entry["count"]
+ return 200, {"status": "error", "reason": "no-match", **answer}
tree_names = {m.tree[1] for m in matches} # type: ignore[index] if len(tree_names) > 1: return 200, {"status": "error", "reason": "ambiguous", "say": f"{text!r} matches more than one tree: {sorted(tree_names)}", "output": {"candidates": sorted(tree_names)}}
@@ -221,6 +228,21 @@ class Hub:
out.extend({**g, "peer": peer.url} for g in body.get("gaps", [])) return {"gaps": out}
+ def unanswered_all(self) -> dict[str, Any]:
+ """This hub's unanswered sentences, then each peer's own (a peer
+ without a hub keeps its own record; one with a hub leaves it here)."""
+ out = self.unanswered.to_list()
+ for peer in list(self.peers.values()):
+ if peer.link == "down":
+ continue
+ try:
+ status, body = http_json("GET", f"{peer.url}/unanswered", timeout=5.0)
+ except Exception:
+ continue
+ if status == 200 and isinstance(body, dict):
+ out.extend({**u, "peer": peer.url} for u in body.get("unanswered", []))
+ return {"unanswered": out}
+
def route(self, method: str, path: str, body: dict[str, Any] | None) -> tuple[int, Any]: body = body or {} if method == "GET" and path == "/health":
@@ -232,10 +254,12 @@ class Hub:
return 200, self.capabilities() if method == "GET" and path == "/gaps": return 200, self.gaps()
+ if method == "GET" and path == "/unanswered":
+ return 200, self.unanswered_all()
if method == "GET" and path in ("/", "/index.html"): return 200, PAGE if method == "GET" and path == "/api":
- return 200, {"pyrtan": "hub", "name": self.name, "endpoints": ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/register"]}
+ return 200, {"pyrtan": "hub", "name": self.name, "endpoints": ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/register"]}
if method == "POST" and path == "/register": url = body.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")): diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 17edb61..9298fbd 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -2,8 +2,10 @@
It lists what the swarm can answer, takes a sentence, posts it to ``/intent`` on the same origin, and shows the answer, the output and the
-trace. No external assets, no build step; it exists so that someone can
-point a browser at a running Pyrtan and understand it in a minute.
+trace. When nothing answers, it shows the tree that would, and the command
+that scaffolds it; the sentences nobody answered are listed beside what the
+swarm can do. No external assets, no build step; it exists so that someone
+can point a browser at a running Pyrtan and understand it in a minute.
""" from __future__ import annotations
@@ -42,7 +44,7 @@ PAGE = r"""<!doctype html>
<body> <main> <h1>Pyrtan <small id="who">…</small></h1>
- <p class="lede">A swarm of small services that act on the web through hardcoded, inspectable decision trees. Type a sentence a tree answers to; the answer comes back with the exact path that produced it. No model is involved.</p>
+ <p class="lede">A core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees. Type a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved.</p>
<form id="ask"> <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off">
@@ -58,7 +60,10 @@ PAGE = r"""<!doctype html>
<h2 style="font-size:17px;margin:32px 0 8px">What this swarm can answer</h2> <div class="card"><table id="caps"><thead><tr><th>Tree</th><th>Answers to</th><th>From</th></tr></thead><tbody></tbody></table></div>
- <footer>The same endpoints the page uses: <code>POST /intent {"text": "…"}</code>, <code>POST /run {"tree": "…", "inputs": {…}}</code>, <code>GET /capabilities</code>, <code>GET /health</code>, <code>GET /gaps</code>. <a href="https://github.com/b-i-t-8/pyrtan">Source</a>.</footer>
+ <h2 style="font-size:17px;margin:32px 0 8px">Waiting to be written</h2>
+ <div class="card"><table id="unanswered"><thead><tr><th>Nobody answered</th><th>Would be</th><th>To write it</th></tr></thead><tbody><tr><td colspan="3" class="mute">Nothing yet: every sentence so far found a tree.</td></tr></tbody></table></div>
+
+ <footer>The same endpoints the page uses: <code>POST /intent {"text": "…"}</code>, <code>POST /run {"tree": "…", "inputs": {…}}</code>, <code>GET /capabilities</code>, <code>GET /health</code>, <code>GET /gaps</code>, <code>GET /unanswered</code>. <a href="https://github.com/b-i-t-8/pyrtan">Source</a>.</footer>
</main> <script> const $ = (s) => document.querySelector(s);
@@ -89,6 +94,19 @@ async function load() {
const s = sample(t); if (s && !s.includes("…")) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); } } tries.addEventListener("click", (e) => { const a = e.target.closest("a[data-q]"); if (!a) return; e.preventDefault(); $("#text").value = a.dataset.q; ask(); });
+ loadUnanswered();
+}
+async function loadUnanswered() {
+ let u; try { u = await (await fetch("unanswered")).json(); } catch (e) { return; }
+ const rows = (u.unanswered || []);
+ if (!rows.length) return;
+ const body = $("#unanswered tbody"); body.innerHTML = "";
+ for (const e of rows) {
+ const p = e.proposal || {};
+ const tr = document.createElement("tr");
+ tr.innerHTML = `<td>${esc(e.text)} <span class="mute">×${esc(e.count||1)}${e.peer ? " · " + esc(e.peer) : ""}</span></td><td><code>${esc(p.name||"")}</code><div class="mute"><code>${esc(p.trigger||"")}</code></div></td><td><code>${esc(p.scaffold||"")}</code></td>`;
+ body.appendChild(tr);
+ }
} function renderTrace(tr) { if (!tr || !tr.steps) return "";
@@ -111,6 +129,10 @@ async function ask(snapshot, answer) {
const card = document.createElement("div"); card.className = "card"; let html = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${ms} ms${r.peer ? " · " + esc(r.peer) : ""}</span></div>`; if (r.say) html += `<div class="say">${esc(r.say)}</div>`;
+ if (r.reason === "no-match" && r.output && r.output.proposal) {
+ const p = r.output.proposal;
+ html += `<div class="gap">No tree answers this yet. The one that would: <code>${esc(p.name)}</code>, answering <code>${esc(p.trigger)}</code>.</div><pre>${esc(p.scaffold)}</pre>`;
+ }
if (r.status === "asked") html += `<form class="resume"><input type="text" placeholder="${esc(r.question||"")}" autofocus><button>Answer</button></form>`; if (r.output != null) html += `<details open><summary>output</summary><pre>${esc(JSON.stringify(r.output, null, 2))}</pre></details>`; if (r.trace) html += `<details open><summary>trace</summary><pre>${esc(renderTrace(r.trace))}</pre></details>`;
@@ -118,6 +140,7 @@ async function ask(snapshot, answer) {
out.prepend(card); const rf = card.querySelector("form.resume"); if (rf) rf.addEventListener("submit", (e) => { e.preventDefault(); ask(r.snapshot, rf.querySelector("input").value); });
+ if (r.reason === "no-match") loadUnanswered();
$("#go").disabled = false; } $("#ask").addEventListener("submit", (e) => { e.preventDefault(); ask(); }); diff --git a/src/pyrtan/runtime.py b/src/pyrtan/runtime.py index 8d480a8..5932203 100644 --- a/src/pyrtan/runtime.py +++ b/src/pyrtan/runtime.py
@@ -16,7 +16,9 @@ An outcome's ``status`` is one of:
``error`` The run could not proceed: a transport failure, an unhandled observation (a ``decide`` with no matching branch, recorded as a gap), a host outside
- the allow-list, a missing template value, or a limit reached.
+ the allow-list, a missing template value, or a limit reached. A
+ ``no-match`` error carries, in ``output.proposal``, the tree that would
+ answer the sentence, so a dead end always points at the next tree.
Nothing here calls a model. The runner is the whole of Pyrtan's "thinking"; the trace is the whole of its explanation.
@@ -31,6 +33,7 @@ from typing import Any, Protocol
from . import conditions from .extract import extract
+from .grow import no_match_outcome
from .registry import Ambiguous, NoMatch, Registry from .template import MISSING, TemplateError, render, resolve from .trace import Step, Trace
@@ -107,7 +110,7 @@ class Runner:
except NoMatch: if self.resolver is not None: return self.resolver.run_intent(text, depth)
- return Outcome("error", "", reason="no-match", say=f"Nothing here answers {text!r}.")
+ return Outcome("error", "", reason="no-match", **no_match_outcome(text, "Nothing here"))
except Ambiguous as e: return Outcome("error", "", reason="ambiguous", say=str(e), output={"candidates": [m.tree for m in e.matches]}) return self.run_tree(match.tree, match.inputs, depth) diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py index f641f1d..023cfce 100644 --- a/src/pyrtan/service.py +++ b/src/pyrtan/service.py
@@ -15,6 +15,10 @@ Endpoints, all JSON:
``GET /gaps`` Decisions that met a value no branch covered, since the service started. Each is a branch waiting to be written.
+``GET /unanswered``
+ Sentences no tree answered, counted, each with a proposed tree. Each is
+ a tree waiting to be written; ``pyrtan unanswered --at URL --scaffold
+ DIR`` writes the files.
``GET /`` The demo page (:mod:`pyrtan.page`); ``GET /api`` is the JSON index.
@@ -22,7 +26,7 @@ A service may be given a hub (``--hub``). Then it registers itself on start,
and a ``delegate`` to a tree it does not hold is forwarded to the hub. The server is the standard library's threading HTTP server. It is meant for
-a swarm of small processes on a trusted network, behind whatever ingress
+a set of small processes on a trusted network, behind whatever ingress
the operator already runs; it does not do TLS or authentication itself. """
@@ -38,6 +42,7 @@ from pathlib import Path
from typing import Any, Callable from .page import PAGE
+from .grow import Unanswered
from .registry import Registry from .runtime import Outcome, Runner from .trace import Trace
@@ -104,6 +109,7 @@ class Service:
self.started = time.time() self.runner = Runner(registry, transport or UrllibTransport(), resolver=RemoteResolver(self.hub) if self.hub else None, env=env or {}) self.gaps: list[dict[str, Any]] = []
+ self.unanswered = Unanswered()
self.runs = 0 self._lock = threading.Lock()
@@ -111,7 +117,7 @@ class Service:
def health(self) -> dict[str, Any]: self.registry.reload()
- return {"status": "ok", "name": self.name, "trees": self.registry.names(), "errors": self.registry.errors, "runs": self.runs, "gaps": len(self.gaps), "uptime_s": int(time.time() - self.started)}
+ return {"status": "ok", "name": self.name, "trees": self.registry.names(), "errors": self.registry.errors, "runs": self.runs, "gaps": len(self.gaps), "unanswered": len(self.unanswered), "uptime_s": int(time.time() - self.started)}
def capabilities(self) -> dict[str, Any]: self.registry.reload()
@@ -122,7 +128,11 @@ class Service:
if not isinstance(text, str) or not text.strip(): return {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."} self.registry.reload()
- return self._finish(self.runner.run_intent(text, int(body.get("depth", 0))), body)
+ outcome = self.runner.run_intent(text, int(body.get("depth", 0)))
+ if outcome.reason == "no-match" and self.hub is None:
+ # With a hub, the hub saw the sentence and keeps the record.
+ self.unanswered.record(text)
+ return self._finish(outcome, body)
def run(self, body: dict[str, Any]) -> dict[str, Any]: tree = body.get("tree")
@@ -165,10 +175,12 @@ class Service:
return 200, self.capabilities() if method == "GET" and path == "/gaps": return 200, {"gaps": self.gaps}
+ if method == "GET" and path == "/unanswered":
+ return 200, {"unanswered": self.unanswered.to_list()}
if method == "GET" and path in ("/", "/index.html"): return 200, PAGE if method == "GET" and path == "/api":
- return 200, {"pyrtan": "service", "name": self.name, "endpoints": ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps"]}
+ return 200, {"pyrtan": "service", "name": self.name, "endpoints": ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered"]}
if method == "POST" and path == "/intent": return 200, self.intent(body) if method == "POST" and path == "/run": diff --git a/src/pyrtan/transport.py b/src/pyrtan/transport.py index a530357..aa9fa49 100644 --- a/src/pyrtan/transport.py +++ b/src/pyrtan/transport.py
@@ -1,6 +1,9 @@
-"""How a tree reaches the web.
+"""How a tree reaches the outside: the HTTP form of I/O.
-A :class:`Transport` turns a request into a :class:`Response`. The real one is
+A :class:`Transport` turns a request into a :class:`Response`. Further forms
+of I/O (files, shells, serial lines, queues) arrive as further transports
+behind the same seam, each with a recorded and a recording wrapper so trees
+over them are testable offline the same way. The real one is
:class:`UrllibTransport`, on the standard library. :class:`RecordedTransport` replays responses from a fixture, so a tree can be tested end to end with no network, and :class:`RecordingTransport` wraps a real transport to write such diff --git a/tests/test_cli.py b/tests/test_cli.py index 63b91ed..f990965 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py
@@ -62,5 +62,49 @@ def test_new_scaffolds_a_tree_that_checks(tmp_path, capsys):
assert run(capsys, "new", "my-thing", "--dir", str(tmp_path))[0] == 1
+def test_new_from_a_sentence(tmp_path, capsys):
+ code, out, _ = run(capsys, "new", "--from", "top 3 stories on hacker news", "--dir", str(tmp_path))
+ assert code == 0 and "top {count:int} stories on hacker news" in out
+ path = tmp_path / "top-stories-hacker-news.toml"
+ assert path.exists() and "[inputs.count]" in path.read_text()
+ assert run(capsys, "check", str(path))[0] == 0
+ code, out, _ = run(capsys, "new", "hn", "--from", "top 3 stories on hacker news", "--dir", str(tmp_path))
+ assert code == 0 and (tmp_path / "hn.toml").exists()
+ assert run(capsys, "new", "--dir", str(tmp_path))[0] == 2
+
+
+def test_run_no_match_proposes_a_tree(capsys):
+ code, out, _ = run(capsys, "run", "tell me a joke", "--trees", str(EXAMPLES), "--replay", str(FIXTURES / "examples.json"), "--json")
+ assert code == 3
+ data = json.loads(out)
+ assert data["reason"] == "no-match" and data["output"]["proposal"]["name"] == "joke"
+
+
def test_gaps_needs_a_target(capsys): assert run(capsys, "gaps")[0] == 2
+ assert run(capsys, "unanswered")[0] == 2
+
+
+def test_unanswered_lists_and_scaffolds(tmp_path, capsys):
+ from pyrtan import Registry
+ from pyrtan.service import Server, Service, http_json
+
+ svc = Service(Registry.from_paths([EXAMPLES]), name="x")
+ server = Server(svc.route, quiet=True).start()
+ try:
+ code, out, _ = run(capsys, "unanswered", "--at", server.url)
+ assert code == 0 and "nothing unanswered" in out
+ http_json("POST", f"{server.url}/intent", {"text": "ping example.org"})
+ http_json("POST", f"{server.url}/intent", {"text": "ping example.org"})
+ http_json("POST", f"{server.url}/intent", {"text": "tell me a joke"})
+ code, out, _ = run(capsys, "unanswered", "--at", server.url)
+ assert code == 0 and out.startswith("'ping example.org' x2") and "would be: host-ping answering ping {host:word}" in out and "pyrtan new joke --from" in out
+ code, out, _ = run(capsys, "unanswered", "--at", server.url, "--json")
+ assert [e["text"] for e in json.loads(out)["unanswered"]] == ["ping example.org", "tell me a joke"]
+ code, out, _ = run(capsys, "unanswered", "--at", server.url, "--scaffold", str(tmp_path))
+ assert code == 0 and "wrote" in out and (tmp_path / "host-ping.toml").exists() and (tmp_path / "joke.toml").exists()
+ assert run(capsys, "check", str(tmp_path))[0] == 0
+ code, out, _ = run(capsys, "unanswered", "--at", server.url, "--scaffold", str(tmp_path))
+ assert "kept" in out and "wrote" not in out
+ finally:
+ server.stop()
diff --git a/tests/test_grow.py b/tests/test_grow.py new file mode 100644 index 0000000..098da22 --- /dev/null +++ b/tests/test_grow.py
@@ -0,0 +1,92 @@
+"""An unanswered sentence becomes a proposed tree, a scaffold that checks,
+and an entry in the unanswered log."""
+
+import pytest
+
+from pyrtan.grow import Proposal, Unanswered, no_match_outcome, propose
+from pyrtan.intent import Trigger
+from pyrtan.tree import check, load_tree_text
+
+
+@pytest.mark.parametrize(
+ "text, name, trigger, inputs",
+ [
+ ("top 3 stories on hacker news", "top-stories-hacker-news", "top {count:int} stories on hacker news", {"count": 3}),
+ ("is example.com up", "host-up", "is {host:word} up", {"host": "example.com"}),
+ ("what's the latest release of astral-sh/uv", "latest-release", "what's the latest release of {target:word}", {"target": "astral-sh/uv"}),
+ ("convert 12.5 km to miles", "convert-km-miles", "convert {value:float} km to miles", {"value": 12.5}),
+ ("turn on the lights", "turn-lights", "turn on the lights", {}),
+ ("Weather in Oslo?", "weather-oslo", "Weather in Oslo", {}),
+ ("ping 1 then 2", "ping-then", "ping {count:int} then {count2:int}", {"count": 1, "count2": 2}),
+ ],
+)
+def test_propose_names_slots_and_matches_the_sentence(text, name, trigger, inputs):
+ p = propose(text)
+ assert (p.name, p.trigger) == (name, trigger)
+ assert Trigger(p.trigger).match(text) == inputs
+ assert set(p.inputs) == set(inputs)
+
+
+def test_propose_survives_trigger_syntax_and_bare_numbers():
+ p = propose("{weird} [tokens] | here")
+ Trigger(p.trigger) # compiles
+ assert "{" not in p.trigger.replace("{count", "")
+ p = propose("42")
+ assert p.trigger == "42" and p.inputs == {}
+ assert propose("").name == "new-tree"
+
+
+def test_given_name_wins():
+ assert propose("my thing", name="custom").name == "custom"
+
+
+@pytest.mark.parametrize("text", ["top 3 stories on hacker news", "is example.com up", "what's the latest release of astral-sh/uv", 'say "hi" to {everyone}', "turn on the lights"])
+def test_scaffold_loads_and_checks_clean(text):
+ p = propose(text)
+ tree = load_tree_text(p.scaffold())
+ assert tree.name == p.name
+ assert [t.pattern for t in tree.triggers] == [p.trigger]
+ assert [x for x in check(tree) if x.level == "error"] == []
+ assert set(tree.inputs) == set(p.inputs)
+
+
+def test_host_slot_opens_hosts_and_builds_the_url():
+ tree = load_tree_text(propose("is example.com up").scaffold())
+ assert tree.hosts == ["*"]
+ assert "inputs.host" in tree.node("fetch").get("http")["url"]
+
+
+def test_command_quotes_for_a_shell():
+ assert propose("top 3 stories on hacker news").command() == 'pyrtan new top-stories-hacker-news --from "top 3 stories on hacker news"'
+ assert propose("what's up").command("trees") == 'pyrtan new up --from "what\'s up" --dir trees'
+ assert propose("costs $5").command().endswith("--from 'costs $5'")
+
+
+def test_no_match_outcome_points_at_the_next_tree():
+ out = no_match_outcome("tell me a joke", "Nothing here")
+ assert out["say"].startswith("Nothing here answers 'tell me a joke'. To write the tree that would: pyrtan new joke --from")
+ assert out["output"]["unanswered"] == "tell me a joke"
+ assert out["output"]["proposal"]["name"] == "joke"
+
+
+def test_unanswered_counts_and_bounds():
+ log = Unanswered(limit=2)
+ first = log.record("Tell me a joke")
+ assert first["count"] == 1 and first["proposal"]["name"] == "joke"
+ assert log.record("tell me a joke!")["count"] == 2 # same sentence, normalised
+ log.record("second")
+ log.record("third")
+ texts = [e["text"] for e in log.to_list()]
+ assert texts == ["third", "second"] # the least recently seen went
+ log = Unanswered()
+ log.record("rare")
+ log.record("common")
+ log.record("common")
+ assert [e["count"] for e in log.to_list()] == [2, 1] # most seen first
+
+
+def test_proposal_round_trips_through_its_dict():
+ p = propose("is example.com up")
+ d = p.to_dict()
+ again = Proposal(d["text"], d["name"], d["trigger"], d["inputs"])
+ assert again.scaffold() == p.scaffold()
diff --git a/tests/test_service_hub.py b/tests/test_service_hub.py index ad07a2e..6493b03 100644 --- a/tests/test_service_hub.py +++ b/tests/test_service_hub.py
@@ -94,6 +94,12 @@ def test_service_endpoints(example_service):
assert out["status"] == "done" and "trace" not in out _, out = http_json("POST", f"{server.url}/intent", {"text": "nothing matches this"}) assert out["reason"] == "no-match"
+ assert out["output"]["proposal"]["trigger"] == "nothing matches this"
+ assert "pyrtan new nothing-matches-this --from" in out["say"]
+ _, un = http_json("GET", f"{server.url}/unanswered")
+ assert [(u["text"], u["count"]) for u in un["unanswered"]] == [("nothing matches this", 1)]
+ _, health = http_json("GET", f"{server.url}/health")
+ assert health["unanswered"] == 1
status, out = http_json("GET", f"{server.url}/nowhere") assert status == 404 _, out = http_json("POST", f"{server.url}/run", {"tree": "site-status", "inputs": {"host": "teapot.example"}})
@@ -170,6 +176,18 @@ def test_swarm_of_two_behind_a_hub(tmp_path):
_, out = http_json("POST", f"{hub_srv.url}/run", {"tree": "nope"}) assert out["reason"] == "no-such-tree"
+ # A sentence nobody answers: the hub says so, proposes the tree, and
+ # remembers. B, which has a hub, leaves the record to the hub.
+ _, out = http_json("POST", f"{hub_srv.url}/intent", {"text": "trace route to 10.0.0.1"})
+ assert out["reason"] == "no-match" and out["output"]["seen"] == 1
+ assert out["say"].startswith("No service in the swarm answers 'trace route to 10.0.0.1'. To write the tree that would: pyrtan new trace-route --from")
+ _, out = http_json("POST", f"{srv_b.url}/intent", {"text": "trace route to 10.0.0.1"})
+ assert out["reason"] == "no-match"
+ assert http_json("GET", f"{srv_b.url}/unanswered")[1]["unanswered"] == []
+ _, un = http_json("GET", f"{hub_srv.url}/unanswered")
+ assert [(u["text"], u["count"]) for u in un["unanswered"]] == [("trace route to 10.0.0.1", 2)]
+ assert un["unanswered"][0]["proposal"]["inputs"]["host"]["type"] == "str"
+
# A peer that dies is marked and skipped. srv_a.stop() hub.refresh_all()