Files
app/backend/fluksio/flow/nodes/wren.py
T

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))