532 lines
21 KiB
Python
532 lines
21 KiB
Python
"""Wren: a typed command in, values for the targets wired to it out.
|
|
|
|
`Wren <https://github.com/stroblme/wren>`_ is a small language model that
|
|
turns a short command into calls on a list of targets. This node is the
|
|
bridge. Its ports *are* the targets: each output is one a command may set,
|
|
each input besides ``prompt`` a reading it may ask about. The wiring is the
|
|
allowlist: the model can reach nothing that is not wired to the node.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Iterable
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from threadpoolctl import threadpool_limits
|
|
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes.base import Node
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import FastAPI
|
|
|
|
|
|
def _wren() -> Any:
|
|
"""The model package, which is optional.
|
|
|
|
Imported when a node is built rather than at boot, so an instance without
|
|
it still offers every other type, and a node placed without it says so
|
|
on the canvas straight away.
|
|
"""
|
|
try:
|
|
import wren
|
|
except ImportError:
|
|
# Not `pip install wren`: that name on PyPI is another package.
|
|
raise RuntimeError(
|
|
"the wren node needs the model: pip install "
|
|
"git+https://github.com/stroblme/wren@v2.0.0"
|
|
) from None
|
|
if not hasattr(wren, "Target"):
|
|
raise RuntimeError(
|
|
"the wren node needs wren 2, the installed one is older: pip install "
|
|
"--upgrade git+https://github.com/stroblme/wren@v2.0.0"
|
|
)
|
|
return wren
|
|
|
|
|
|
def pack_meta(path: Any) -> dict[str, Any]:
|
|
"""What a model records of its training: its targets, threshold and,
|
|
when it had tests, ``test_exact_match``."""
|
|
found: dict[str, Any] = _wren().load_pack(path)["meta"]
|
|
return found
|
|
|
|
|
|
#: The input the command arrives on, and the only one that wakes the node.
|
|
PROMPT = "prompt"
|
|
#: The output the calls are reported on, and the two that say what came of
|
|
#: the command. Every other output is a target.
|
|
ACTIONS = "actions"
|
|
REPLY = "reply"
|
|
UNHANDLED = "unhandled"
|
|
#: The reply when nothing was done and nothing reads ``unhandled``.
|
|
CANT = "Sorry, I can't do that yet."
|
|
#: wren's training flow, which wren registers: ``fluksio sync wren.flow``.
|
|
TRAIN_FLOW = "wren_train"
|
|
|
|
_TARGET_TYPES = {DType.BOOL, DType.INT, DType.FLOAT, DType.STR}
|
|
|
|
#: Number words, which wren was trained on as digits.
|
|
_ONES = (
|
|
"zero one two three four five six seven eight nine ten eleven twelve "
|
|
"thirteen fourteen fifteen sixteen seventeen eighteen nineteen"
|
|
).split()
|
|
_TENS = "twenty thirty forty fifty sixty seventy eighty ninety".split()
|
|
_NUMBERS = {w: n for n, w in enumerate(_ONES)} | {
|
|
w: 20 + 10 * n for n, w in enumerate(_TENS)
|
|
}
|
|
|
|
|
|
def _plain(command: str) -> str:
|
|
"""Normalize case and sentence punctuation to match the training format."""
|
|
return re.sub(r"[.,!?;:]+(?=\s|$)", "", command).strip().lower()
|
|
|
|
|
|
def _aliased(prompt: str, aliases: dict[str, str]) -> str:
|
|
"""Replace whole-word aliases with target names, ignoring case."""
|
|
if not aliases:
|
|
return prompt
|
|
words = {k.lower(): v for k, v in aliases.items()}
|
|
pattern = "|".join(map(re.escape, sorted(words, key=len, reverse=True)))
|
|
return re.sub(
|
|
rf"\b({pattern})\b",
|
|
lambda m: words[m[1].lower()],
|
|
prompt,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _digits(command: str) -> str:
|
|
"""Convert number words to digits, as expected by the model."""
|
|
tens, ones = "|".join(_TENS), "|".join(_ONES[1:10])
|
|
command = re.sub(
|
|
rf"\b({tens})[ -]({ones})\b",
|
|
lambda m: str(_NUMBERS[m[1]] + _NUMBERS[m[2]]),
|
|
command,
|
|
)
|
|
words = "|".join(_NUMBERS)
|
|
return re.sub(rf"\b({words})\b", lambda m: str(_NUMBERS[m[1]]), command)
|
|
|
|
|
|
def _learnt(target: dict[str, Any]) -> tuple[Any, ...]:
|
|
"""What a model learns of a target: all but its unit, which it never sees,
|
|
and a range as floats, so [5, 30] is (5.0, 30.0)."""
|
|
low_high = target.get("range")
|
|
return (
|
|
target.get("name"),
|
|
target.get("type"),
|
|
target.get("values"),
|
|
None if low_high is None else tuple(float(n) for n in low_high),
|
|
target.get("description", ""),
|
|
bool(target.get("readonly", False)),
|
|
)
|
|
|
|
|
|
def _unset(target: dict[str, Any]) -> Any:
|
|
"""The value of a target nothing has set yet: off, 0 within its range, or
|
|
its first value."""
|
|
if target["type"] == "bool":
|
|
return False
|
|
if target["type"] == "str":
|
|
return (target["values"] or [""])[0]
|
|
low, high = target["range"] or (0, 0)
|
|
number = min(max(0, low), high)
|
|
return int(number) if target["type"] == "int" else float(number)
|
|
|
|
|
|
def _typed(value: Any, dtype: DType) -> Any:
|
|
"""A call's value as its port's type: wren may write 21 for a float."""
|
|
if dtype is DType.INT:
|
|
return int(value)
|
|
if dtype is DType.FLOAT:
|
|
return float(value)
|
|
return value
|
|
|
|
|
|
def _spoken(value: Any, unit: str | None) -> str:
|
|
"""A value as the reply says it: "on", "down", "21 °C"."""
|
|
if isinstance(value, bool):
|
|
return "on" if value else "off"
|
|
if isinstance(value, str):
|
|
return value.lower()
|
|
return f"{value:g}" + (f" {unit}" if unit else "")
|
|
|
|
|
|
def _said(port: str, value: Any, unit: str | None, delay_s: float | None) -> str:
|
|
"""An applied call as the reply says it: "living room heating to 21 °C in
|
|
5 minutes"."""
|
|
to = "" if isinstance(value, (bool, str)) else "to "
|
|
said = f"{port.replace('_', ' ')} {to}{_spoken(value, unit)}"
|
|
if delay_s:
|
|
minutes = delay_s >= 60
|
|
n = round(delay_s / 60 if minutes else delay_s)
|
|
said += f" in {n} {'minute' if minutes else 'second'}{'s' * (n != 1)}"
|
|
return said
|
|
|
|
|
|
def _text(call: Any) -> str:
|
|
"""The call as wren writes it, less its delay: ``set(kitchen_light, true)``."""
|
|
if call.value is None:
|
|
return f"read({call.target})"
|
|
value = call.value
|
|
if isinstance(value, bool):
|
|
value = "true" if value else "false"
|
|
elif isinstance(value, float):
|
|
value = f"{value:g}"
|
|
return f"set({call.target}, {value})"
|
|
|
|
|
|
class TargetInfo(BaseModel):
|
|
"""What the model is told of a target besides its port, and how far it is
|
|
trusted with it."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
description: str = Field(
|
|
default="",
|
|
description='More words for the target, which help pick it: "awning over the patio".',
|
|
)
|
|
values: list[str] | None = Field(
|
|
default=None,
|
|
description='The only values a str target takes: ["UP", "DOWN", "STOP"].',
|
|
)
|
|
range: tuple[float, float] | None = Field(
|
|
default=None,
|
|
description="The lowest and highest value of a number: [5, 30].",
|
|
)
|
|
unit: str | None = Field(
|
|
default=None,
|
|
description='Said after a number in the reply: "°C". The model never sees it.',
|
|
)
|
|
min_confidence: float | None = Field(
|
|
default=None,
|
|
ge=0,
|
|
le=1,
|
|
description="The node's min_confidence for this target alone, such as a "
|
|
"higher one for a lock.",
|
|
)
|
|
pass_on: bool = Field(
|
|
default=False,
|
|
description="Never set by wren: a command on it goes on to unhandled.",
|
|
)
|
|
|
|
|
|
class WrenNode(Node):
|
|
"""Turn the command on ``prompt`` into values for the targets wired to it.
|
|
|
|
Inputs: ``prompt``, the command and the only one that wakes the node, and
|
|
any number of readings, which a command may ask about but not set.
|
|
|
|
Outputs: ``actions``, a list of records, one per call, ``reply``, a
|
|
sentence saying what was done or what a reading holds, or that nothing
|
|
was done when no node reads ``unhandled``, ``unhandled``, the prompt when
|
|
nothing was, and any number of targets a command may set.
|
|
|
|
A target is named by its port. Its type, its current value and its entry
|
|
in ``targets`` are what the model is shown. The command reaches the model
|
|
with the ``aliases`` applied and its number words as digits. A delayed
|
|
call is held by the engine's work queue, like the Debounce node's second
|
|
value.
|
|
"""
|
|
|
|
class Params(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
apply: bool = Field(
|
|
default=False,
|
|
description="Write to the targets. Off, the node only reports what it would do.",
|
|
)
|
|
model: str = Field(
|
|
default="",
|
|
pattern=r"^(sha256:[0-9a-f]{64})?$",
|
|
description=(
|
|
"The trained model, an artifact digest. Empty runs wren's demo "
|
|
"model, which knows only its demo home."
|
|
),
|
|
)
|
|
min_confidence: float | None = Field(
|
|
default=None,
|
|
ge=0,
|
|
le=1,
|
|
description="Below this, nothing is written; the calls are still "
|
|
"reported. Empty takes the model's own threshold.",
|
|
)
|
|
targets: dict[str, TargetInfo] = Field(
|
|
default_factory=dict,
|
|
description="By target port: description, values, range, unit, "
|
|
'min_confidence and pass_on, as {"canopy": {"values": ["UP", '
|
|
'"DOWN", "STOP"], "description": "awning over the patio"}}.',
|
|
)
|
|
aliases: dict[str, str] = Field(
|
|
default_factory=dict,
|
|
description="Map your own names to the model's target names: "
|
|
'{"desk lamp": "office light"}.',
|
|
)
|
|
examples: list[dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description="Training only: commands to learn, targets named by port, "
|
|
'as {"state": {"user_location": "kitchen"}, "command": "lights on", '
|
|
'"calls": [["kitchen_light", true]]}.',
|
|
)
|
|
calibration: list[dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description="Training only: commands in your own words, like "
|
|
"examples, that a new model's threshold is chosen on. It never "
|
|
"trains on them.",
|
|
)
|
|
tests: list[dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description="Training only: commands a new model is scored on, like "
|
|
'examples. "calls": [] is one it must pass on.',
|
|
)
|
|
|
|
__slots__ = ("cfg", "targets", "pack", "untrained")
|
|
|
|
# A call is a command to a device; doing it twice is not doing it once.
|
|
idempotent = False
|
|
|
|
def __init__(
|
|
self,
|
|
requires: MessageSpec | Iterable[MessageSpec] = (),
|
|
provides: MessageSpec | Iterable[MessageSpec] = (),
|
|
params: dict[str, Any] | None = None,
|
|
name: str | None = None,
|
|
):
|
|
self.cfg = self.Params.model_validate(params or {})
|
|
_wren()
|
|
inputs = self._normalize_ports(requires)
|
|
outputs = self._normalize_ports(provides)
|
|
|
|
if not any(spec.port == PROMPT for spec in inputs):
|
|
raise ValueError("wren needs a 'prompt' input: the command to act on.")
|
|
ports: list[tuple[MessageSpec, bool]] = []
|
|
for spec in outputs:
|
|
if spec.port == ACTIONS:
|
|
if spec.dtype is not DType.LIST or spec.item_dtype is not DType.RECORD:
|
|
raise ValueError(f"'{ACTIONS}' is a list of records.")
|
|
elif spec.port in (REPLY, UNHANDLED):
|
|
if spec.dtype is not DType.STR:
|
|
raise ValueError(f"'{spec.port}' is a str.")
|
|
else:
|
|
ports.append((spec, False))
|
|
for spec in inputs:
|
|
if spec.port != PROMPT:
|
|
ports.append((spec, True))
|
|
elif spec.dtype is not DType.STR:
|
|
raise ValueError(f"'{PROMPT}' is a str.")
|
|
|
|
#: Each target by its port, as a model's ``meta`` lists the targets it
|
|
#: was trained on.
|
|
self.targets: dict[str, dict[str, Any]] = {}
|
|
for spec, readonly in ports:
|
|
if spec.dtype not in _TARGET_TYPES:
|
|
raise ValueError(
|
|
f"'{spec.port}' is {spec.dtype.value}; a target is bool, int, "
|
|
"float or str."
|
|
)
|
|
info = self.cfg.targets.get(spec.port, TargetInfo())
|
|
self.targets[spec.port] = {
|
|
"name": spec.port,
|
|
"type": spec.dtype.value,
|
|
"values": info.values,
|
|
"range": None if info.range is None else list(info.range),
|
|
"description": info.description,
|
|
"unit": info.unit,
|
|
"readonly": readonly,
|
|
}
|
|
for port, info in self.cfg.targets.items():
|
|
kind = self.targets[port]["type"] if port in self.targets else None
|
|
if kind is None:
|
|
raise ValueError(f"'targets' names '{port}', which no target port is.")
|
|
if info.values is not None and kind != "str":
|
|
raise ValueError(f"'{port}' is {kind}; only a str target has values.")
|
|
if info.range is not None and kind not in ("int", "float"):
|
|
raise ValueError(f"'{port}' is {kind}; only a number has a range.")
|
|
|
|
# Set on start, from the model and the targets it was trained on.
|
|
self.pack: dict[str, Any] | None = None
|
|
self.untrained: set[str] = set()
|
|
super().__init__(
|
|
self._act,
|
|
# The prompt is the event; the rest is read when it arrives.
|
|
requires=[
|
|
spec.model_copy(update={"trigger": spec.port == PROMPT})
|
|
for spec in inputs
|
|
],
|
|
provides=outputs,
|
|
params=params,
|
|
name=name or "wren",
|
|
)
|
|
|
|
def installation(self) -> dict[str, Any]:
|
|
"""What wren's training flow learns from: the targets, each with a
|
|
value, and the ``examples``, ``calibration`` and ``tests``."""
|
|
return {
|
|
"targets": [t | {"value": _unset(t)} for t in self.targets.values()],
|
|
"examples": self.cfg.examples,
|
|
"calibration": self.cfg.calibration,
|
|
"tests": self.cfg.tests,
|
|
}
|
|
|
|
async def start(self, app: FastAPI | None = None) -> None:
|
|
store = getattr(app.state, "artifact_store", None) if app else None
|
|
self.pack = self._load(store)
|
|
|
|
def _load(self, store: Any) -> dict[str, Any] | None:
|
|
"""The model, read once, or None when there is none to run.
|
|
|
|
A target it was not trained on, or one described otherwise since, is
|
|
a word it never learnt, so a call on it is never applied.
|
|
"""
|
|
wren = _wren()
|
|
path = wren.runtime.MODEL
|
|
if self.cfg.model:
|
|
path = store.path(self.cfg.model) if store is not None else None
|
|
if path is None:
|
|
self.report_health(
|
|
"degraded",
|
|
f"model {self.cfg.model[:19]}… not found, every command goes on",
|
|
)
|
|
return None
|
|
try:
|
|
pack: dict[str, Any] = wren.load_pack(path)
|
|
except Exception as exc:
|
|
self.report_health(
|
|
"degraded", f"model unreadable ({exc}), every command goes on"
|
|
)
|
|
return None
|
|
known = {t.get("name"): _learnt(t) for t in pack["meta"].get("targets", [])}
|
|
self.untrained = {
|
|
port for port, t in self.targets.items() if known.get(port) != _learnt(t)
|
|
}
|
|
if self.untrained:
|
|
self.report_health(
|
|
"degraded",
|
|
f"the model needs retraining for {', '.join(sorted(self.untrained))}; "
|
|
"commands on them go on",
|
|
)
|
|
else:
|
|
self.report_health("ok")
|
|
return pack
|
|
|
|
def _current(self, readings: dict[str, Any]) -> dict[str, Any]:
|
|
"""Each target's value now, by port: an output's from the state, a
|
|
reading's from its input."""
|
|
ports = {
|
|
spec.name: spec.port
|
|
for spec in self.output_ports
|
|
if spec.name and spec.port in self.targets
|
|
}
|
|
present = (
|
|
self._pipeline.state.get_present(list(ports))
|
|
if self._pipeline is not None
|
|
else {}
|
|
)
|
|
return {ports[name]: value for name, value in present.items()} | readings
|
|
|
|
def _bar(self, port: str, threshold: float) -> float:
|
|
"""The confidence a call on ``port`` needs: the target's own, the
|
|
node's, or else the model's."""
|
|
info = self.cfg.targets.get(port, TargetInfo())
|
|
bars = (info.min_confidence, self.cfg.min_confidence, threshold)
|
|
return next(bar for bar in bars if bar is not None)
|
|
|
|
def _act(
|
|
self, params: dict[str, Any], prompt: str = "", **readings: Any
|
|
) -> dict[str, Any] | None:
|
|
if not prompt.strip():
|
|
# An empty command is nothing to do, which is what a flow input
|
|
# starts as.
|
|
return None
|
|
prompt = _aliased(prompt, self.cfg.aliases)
|
|
command = _digits(_plain(prompt))
|
|
current = self._current(readings)
|
|
calls: list[Any] = []
|
|
threshold = 1.0
|
|
if self.pack is not None:
|
|
wren = _wren()
|
|
# A target the model never learnt is left out: shown, it is a line
|
|
# unlike any in training, which shifts the answers on the others.
|
|
targets = [
|
|
wren.Target(
|
|
**t, value=_unset(t) if current.get(p) is None else current[p]
|
|
)
|
|
for p, t in self.targets.items()
|
|
if p not in self.untrained
|
|
]
|
|
# One BLAS thread answers in about half the time for a model
|
|
# this small. Scoped to the call, so numpy elsewhere in the
|
|
# process keeps its threads.
|
|
with threadpool_limits(1):
|
|
calls = wren.act(command, targets, model=self.pack)
|
|
threshold = self.pack["meta"].get("threshold", threshold)
|
|
|
|
# All of a command or none of it: applying a part leaves half of a
|
|
# duration ("off for 7 minutes": off now, back on later), or lets the
|
|
# assistant do again what was applied. `apply` only holds back writes:
|
|
# a reading is answered either way.
|
|
sure = all(
|
|
call.confidence >= self._bar(call.target, threshold)
|
|
and call.target not in self.untrained
|
|
and not self.cfg.targets.get(call.target, TargetInfo()).pass_on
|
|
# A reading nothing has written yet has no answer.
|
|
and (call.value is not None or current.get(call.target) is not None)
|
|
for call in calls
|
|
)
|
|
# A reading answered beside a set that is not written would drop the
|
|
# set unsaid, so the whole command goes on instead.
|
|
held = not self.cfg.apply and any(call.value is not None for call in calls)
|
|
specs = {spec.port: spec for spec in [*self.input_ports, *self.output_ports]}
|
|
writes: dict[str, Any] = {}
|
|
actions: list[dict[str, Any]] = []
|
|
said: list[str] = []
|
|
answers: list[str] = []
|
|
for call in calls:
|
|
spec, unit = specs[call.target], self.targets[call.target]["unit"]
|
|
applied = sure and (not held if call.value is None else self.cfg.apply)
|
|
if call.value is None:
|
|
value = current.get(call.target)
|
|
if applied:
|
|
words = call.target.replace("_", " ").capitalize()
|
|
answers.append(f"{words} is {_spoken(value, unit)}.")
|
|
else:
|
|
value = _typed(call.value, spec.dtype)
|
|
if applied and call.delay_s:
|
|
applied = self._pipeline is not None and self._pipeline.defer(
|
|
self, self._to_messages({spec.port: value}) or {}, call.delay_s
|
|
)
|
|
elif applied:
|
|
writes[spec.port] = value
|
|
if applied:
|
|
said.append(_said(call.target, value, unit, call.delay_s))
|
|
actions.append(
|
|
{
|
|
"message": spec.name,
|
|
"value": value,
|
|
"applied": applied,
|
|
"delay_s": call.delay_s,
|
|
"call": _text(call),
|
|
"confidence": round(float(call.confidence), 3),
|
|
}
|
|
)
|
|
|
|
wired = {spec.port for spec in self.output_ports}
|
|
if ACTIONS in wired:
|
|
writes[ACTIONS] = actions
|
|
if said:
|
|
reply = ", ".join(dict.fromkeys(said))
|
|
answers.insert(0, reply[0].upper() + reply[1:] + ".")
|
|
if answers and REPLY in wired:
|
|
writes[REPLY] = " ".join(answers)
|
|
if not answers and UNHANDLED in wired:
|
|
writes[UNHANDLED] = prompt
|
|
if not answers and REPLY in wired and not self._passed_on():
|
|
writes[REPLY] = CANT
|
|
return writes or None
|
|
|
|
def _passed_on(self) -> bool:
|
|
"""Whether a node reads ``unhandled``, such as an assistant."""
|
|
name = next((s.name for s in self.output_ports if s.port == UNHANDLED), "")
|
|
return bool(name and self._pipeline and self._pipeline.consumes.get(name))
|