wren node: targets from the wiring, on wren 2

Every output is a target and every input besides prompt a reading. A
targets setting gives descriptions, values, ranges and per-target
guards, model takes a trained pack's artifact digest, min_confidence
defaults to the model's threshold. A command is applied whole or not at
all, and targets the model was not trained on read degraded. The home
path (area/kind ports, groups, brightness, temperature answers) is gone.
This commit is contained in:
2026-10-24 21:28:42 +02:00
parent 6892651e76
commit 07fa5a2dc2
5 changed files with 745 additions and 863 deletions
+292 -506
View File
@@ -1,18 +1,17 @@
"""Wren: a typed command in, values for the devices wired to it out.
"""Wren: a typed command in, values for the targets wired to it out.
`Wren <https://github.com/stroblme/wren>`_ is a small language model trained on
one job: read a house — its areas, the devices in each and their states — and
answer a command with calls on those devices. This node is the bridge. Its
output ports *are* the devices, named ``<area>_<kind>``, so the wiring is the
`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
import time
from collections.abc import Iterable
from typing import Any, NamedTuple
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field
from threadpoolctl import threadpool_limits
@@ -20,6 +19,9 @@ 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.
@@ -33,120 +35,29 @@ def _wren() -> Any:
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@eae4d6ff9ded68e36624972fd3f4a8f8d210b0c2"
"the wren node needs the model: pip install "
"git+https://github.com/stroblme/wren"
) 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"
)
return wren
#: The last word of a target port, and the kind of device it stands for.
KIND_WORDS = {
"light": "light",
"lamp": "light",
"brightness": "light",
"setpoint": "heat",
"heating": "heat",
"heat": "heat",
"boost": "heat",
"media": "media",
"music": "media",
"tv": "media",
"shutter": "shutter",
"blind": "shutter",
"cover": "shutter",
"water": "water",
"watering": "water",
"sprinkler": "water",
"switch": "switch",
"plug": "switch",
"socket": "switch",
"appliances": "switch",
}
#: What the node reads, and the types each may be. Only the prompt wakes it.
#: Besides these, ``<area>_temperature`` inputs answer "how warm is it".
INPUTS = {
"prompt": {DType.STR},
"presence": {DType.STR},
"outdoor": {DType.FLOAT, DType.INT},
"dark": {DType.BOOL},
}
#: 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 device.
#: 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."
#: The output for the house's brightness, 0 to 100, which the model's
#: brightness calls write whatever area they name.
BRIGHTNESS = "brightness"
_TARGET_TYPES = {DType.BOOL, DType.INT, DType.FLOAT, DType.STR}
#: The tools that act on each kind.
_TOOLS = {
"light": "lights",
"media": "media",
"heat": "heating",
"shutter": "shutters",
"water": "watering",
"switch": "switches",
}
#: What a call sets a target to, by the target's type: (bool, number, text).
#: None is a call that type has no value for, which is then not applied.
#: `heating.set` carries its own number.
_VALUES: dict[str, tuple[bool | None, float | None, str | None]] = {
"lights.on": (True, 100, "on"),
"lights.off": (False, 0, "off"),
"media.play": (True, None, "play"),
"media.stop": (False, None, "stop"),
"heating.boost": (True, None, "boost"),
"heating.off": (False, None, "off"),
"shutters.up": (True, 100, "up"),
"shutters.down": (False, 0, "down"),
"shutters.stop": (None, None, "stop"),
"watering.start": (True, None, "on"),
"watering.stop": (False, None, "off"),
"switches.on": (True, 1, "on"),
"switches.off": (False, 0, "off"),
}
#: The brightness calls, and what "dim" and "brighter" do: a step from the
#: brightness now, or from a usual one when none is known. Dimmed lights
#: stay on.
_LEVELS = ("lights.set", "lights.dim", "lights.brighten")
_STEP, _DIMMEST, _BRIGHTEST, _USUAL = 30, 10, 100, 70
#: An applied call, said: "kitchen light on". `heating.set` says its number.
_SAID = {
"lights.on": "light on",
"lights.off": "light off",
"media.play": "media playing",
"media.stop": "media stopped",
"heating.boost": "heating boosted",
"heating.off": "heating off",
"shutters.up": "shutters up",
"shutters.down": "shutters down",
"shutters.stop": "shutters stopped",
"watering.start": "watering on",
"watering.stop": "watering off",
"switches.on": "switch on",
"switches.off": "switch off",
}
#: What every area held in training: a light, and in the living room, which
#: always existed, media and heating besides. A house missing them reads as
#: unfamiliar and the confidence drops with it, so they are filled in; a call
#: on one that nothing is wired to is reported and not applied. Every area is
#: shown, wired or not, or "the bathroom light" lands on a light that is.
_TRAINED = {"living room": ("light", "media", "heat")}
#: wren's areas outside, whose temperature is the ``outdoor`` reading.
_OUTSIDE = {"garden", "patio", "garage", "driveway", "balcony"}
#: Number words, which wren was trained on as digits.
_ONES = (
"zero one two three four five six seven eight nine ten eleven twelve "
@@ -157,154 +68,6 @@ _NUMBERS = {w: n for n, w in enumerate(_ONES)} | {
w: 20 + 10 * n for n, w in enumerate(_TENS)
}
#: A command for a whole group of devices: "turn off all lights".
_GROUP = re.compile(r"\b(all|every|everything|everywhere)\b")
#: What leaves such a command to the model and the assistant: a question, or
#: an exception to the group.
_NOT_GROUP = re.compile(
r"^(is|are|was|were|do|does|did|can|could|what|which|how|why|when|where|who)\b"
r"|\b(except|but|apart from|other than)\b"
)
#: The kind a group command names, by its word. None named is every kind.
_GROUP_KINDS = {
"light": "light",
"lights": "light",
"lamps": "light",
"music": "media",
"media": "media",
"tv": "media",
"shutters": "shutter",
"blinds": "shutter",
"heating": "heat",
"watering": "water",
"sprinklers": "water",
"switches": "switch",
"plugs": "switch",
"appliances": "switch",
}
#: The call a group command makes on each kind, by its action word.
_GROUP_TOOLS = {
"on": {
"light": "lights.on",
"media": "media.play",
"water": "watering.start",
"switch": "switches.on",
},
"off": {
"light": "lights.off",
"media": "media.stop",
"heat": "heating.off",
"water": "watering.stop",
"switch": "switches.off",
},
"open": {"shutter": "shutters.up"},
"up": {"shutter": "shutters.up"},
"raise": {"shutter": "shutters.up"},
"close": {"shutter": "shutters.down"},
"down": {"shutter": "shutters.down"},
"lower": {"shutter": "shutters.down"},
"stop": {
"media": "media.stop",
"shutter": "shutters.stop",
"water": "watering.stop",
},
}
#: A group call, said: "all lights off".
_GROUP_SAID = {
"lights.on": "lights on",
"lights.off": "lights off",
"media.play": "media playing",
"media.stop": "media stopped",
"heating.off": "heating off",
"shutters.up": "shutters up",
"shutters.down": "shutters down",
"shutters.stop": "shutters stopped",
"watering.start": "watering on",
"watering.stop": "watering off",
"switches.on": "switches on",
"switches.off": "switches off",
}
class _Call(NamedTuple):
"""A call the node makes itself, shaped as the model's."""
tool: str
args: dict[str, Any]
delay_s: float | None = None
def _device(port: str, areas: dict[str, str]) -> tuple[str, str] | None:
"""The (area, kind) a port name stands for, or None."""
for slug, area in areas.items():
word = port.removeprefix(f"{slug}_")
if word != port and word in KIND_WORDS:
return area, KIND_WORDS[word]
return None
def _on(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value > 0
return str(value).strip().lower() in {"on", "up", "open", "play", "true"}
def _state(kind: str, values: list[Any]) -> str:
"""A device's state as the model reads it, from what its targets hold."""
if kind == "heat":
temps = [
v for v in values if isinstance(v, (int, float)) and not isinstance(v, bool)
]
return f"{round(temps[0])}C" if temps else "20C"
on = bool(values) and _on(values[0])
if kind == "shutter":
return "up" if on else "down"
return "on" if on else "off"
def _value(call: Any, dtype: DType) -> Any:
"""What ``call`` sets a target of this type to, or None."""
if call.tool == "heating.set":
temp = call.args.get("temp")
flag, number, text = (
None,
temp if isinstance(temp, (int, float)) else None,
None,
)
elif call.tool == "lights.set":
level = call.args.get("level")
flag, number, text = (
None,
level if isinstance(level, (int, float)) else None,
None,
)
else:
flag, number, text = _VALUES.get(call.tool, (None, None, None))
if dtype is DType.BOOL:
return flag
if dtype is DType.STR:
return text
if number is None:
return None
return int(number) if dtype is DType.INT else float(number)
def _said(call: Any, area: str) -> str:
"""An applied call as the reply says it: "garage light on in 5 minutes"."""
if call.tool == "heating.set":
said = f"heating to {_value(call, DType.FLOAT):g} degrees"
elif call.tool == "lights.set":
said = f"brightness at {_value(call, DType.FLOAT):g} percent"
else:
said = _SAID.get(call.tool, call.tool)
if call.delay_s:
minutes = round(call.delay_s / 60)
said += f" in {minutes} minute{'s' * (minutes != 1)}"
# The brightness is the house's, not the area's the call names.
return said if call.tool == "lights.set" else f"{area} {said}"
def _plain(command: str) -> str:
"""The command as the model was trained on it: lower case, no sentence marks.
@@ -343,52 +106,125 @@ def _digits(command: str) -> str:
return re.sub(rf"\b({words})\b", lambda m: str(_NUMBERS[m[1]]), command)
def _named(areas: Iterable[str], command: str) -> list[str]:
"""The areas ``command`` names, in the house's order."""
return [a for a in areas if re.search(rf"\b{re.escape(a)}\b", 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 _asked(house: Any, command: str) -> set[str]:
"""The areas a call may land on: those the command names, or else the
user's and the living room.
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)
That is how wren picks the areas it shows as the command's own. The few
it adds besides are there to be told apart, and a call on one of them is a
word the model never learned landing on a neighbour: "switch on the
appliances" turned on the bathroom light at 0.97.
"""
return set(_named(house.areas, command)) or {house.user_area, "living room"}
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 the chat shows it, less the device it names."""
args = ", ".join(f"{k}={v}" for k, v in call.args.items() if k != "id")
return f"{call.tool}({args})"
"""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 devices wired to it.
"""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),
``presence`` (the area the user is in), ``outdoor`` (°C), ``dark``
(otherwise read off the clock: before 07:00 or from 18:00) and any
``<area>_temperature``. A question for a temperature is answered from
that reading, or from ``outdoor`` for an area outside.
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.
The command reaches the model with the ``aliases`` applied and its number
words as digits. A command for all of a kind ("turn off all lights",
"everything off in the office") is not the model's: the node applies it
to every wired device of that kind itself.
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.
Outputs: ``actions``, a list of records, one per call and target,
``reply``, a sentence saying what was applied ("Kitchen light on."), or
that nothing was when no node reads ``unhandled``, ``unhandled``, the
prompt when nothing was, ``brightness``, the house's, for "dim the
lights" or "set the brightness to 30%", and any number of targets
named ``<area>_<kind>``. Targets of one area and kind are one device to
the model; each takes what the call means for its type.
A delayed call is held by the engine's work queue, like the Trigger
node's second value.
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):
@@ -398,19 +234,34 @@ class WrenNode(Node):
default=False,
description="Write to the targets. Off, the node only reports what it would do.",
)
min_confidence: float = Field(
default=0.93,
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.",
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="The house's own words, and words heard wrong, in wren's: "
description="Your own words, and words heard wrong, in the model's: "
'{"traverse": "living room light", "page too": "patio"}.',
)
__slots__ = ("cfg", "devices", "readings")
__slots__ = ("cfg", "targets", "pack", "untrained")
# A call is a command to a device; doing it twice is not doing it once.
idempotent = False
@@ -423,69 +274,64 @@ class WrenNode(Node):
name: str | None = None,
):
self.cfg = self.Params.model_validate(params or {})
wren = _wren()
_wren()
inputs = self._normalize_ports(requires)
outputs = self._normalize_ports(provides)
areas = {area.replace(" ", "_"): area for area in wren.AREAS}
#: The area each ``<area>_temperature`` input reads, to its port.
self.readings: dict[str, str] = {}
for spec in inputs:
reading = areas.get(spec.port.removesuffix("_temperature"))
if reading and spec.port.endswith("_temperature"):
self.readings[reading] = spec.port
allowed = INPUTS["outdoor"] if reading else INPUTS.get(spec.port)
if allowed is None:
raise ValueError(
f"'{spec.port}' is not an input of wren. It reads "
f"{', '.join(INPUTS)} and <area>_temperature."
)
if spec.dtype not in allowed:
raise ValueError(
f"'{spec.port}' is {spec.dtype.value}; wren reads it as "
f"{' or '.join(sorted(d.value for d in allowed))}."
)
if not any(spec.port == "prompt" for spec in inputs):
if not any(spec.port == PROMPT for spec in inputs):
raise ValueError("wren needs a 'prompt' input: the command to act on.")
#: (area, kind) to the ports standing for that device.
self.devices: dict[tuple[str, str], list[str]] = {}
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.")
continue
if spec.port in (REPLY, UNHANDLED):
elif spec.port in (REPLY, UNHANDLED):
if spec.dtype is not DType.STR:
raise ValueError(f"'{spec.port}' is a str.")
continue
if spec.port == BRIGHTNESS:
if spec.dtype not in (DType.FLOAT, DType.INT):
raise ValueError(f"'{BRIGHTNESS}' is a float or an int.")
continue
device = _device(spec.port, areas)
if device is None:
raise ValueError(
f"'{spec.port}' names no device wren knows. A target is "
f"<area>_<kind>, the area one of {', '.join(areas)}, the kind "
f"one of {', '.join(KIND_WORDS)}."
)
if device[1] not in wren.KINDS: # switches came with wren's s10
raise ValueError(
f"'{spec.port}' is a {device[1]}, which this wren does not know."
)
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."
)
self.devices.setdefault(device, []).append(spec.port)
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"})
spec.model_copy(update={"trigger": spec.port == PROMPT})
for spec in inputs
],
provides=outputs,
@@ -493,130 +339,141 @@ class WrenNode(Node):
name=name or "wren",
)
def _house(
self,
wren: Any,
specs: dict[str, MessageSpec],
presence: str | None,
outdoor: float | None,
dark: bool | None,
) -> Any:
"""The house as the model reads it, each device in its current state."""
names = [specs[port].name for ports in self.devices.values() for port in ports]
current = (
self._pipeline.state.get_present(names)
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 {}
)
areas: dict[str, list[Any]] = {}
for area in wren.AREAS:
wired = {kind for (a, kind) in self.devices if a == area}
kinds = wired | set(_TRAINED.get(area, ("light",)))
areas[area] = []
for kind in (k for k in wren.KINDS if k in kinds):
ports = self.devices.get((area, kind), [])
held = [
current[specs[p].name] for p in ports if specs[p].name in current
]
# The id is ours to choose: the model is shown aliases anyway.
areas[area].append(
wren.Device(kind, f"{area}:{kind}", _state(kind, held))
)
clock = time.localtime()
where = str(presence or "").strip().lower().replace("_", " ")
return wren.House(
time=time.strftime("%H:%M", clock),
# The range the model was trained on; past it a number is unfamiliar.
outdoor_c=min(max(float(20 if outdoor is None else outdoor), -5), 30),
dark=clock.tm_hour < 7 or clock.tm_hour >= 18 if dark is None else dark,
user_area=where if where in wren.AREAS else None,
areas=areas,
)
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 = "",
presence: str | None = None,
outdoor: float | None = None,
dark: bool | None = None,
**readings: float | None,
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
wren = _wren()
specs = {spec.port: spec for spec in self.output_ports}
house = self._house(wren, specs, presence, outdoor, dark)
prompt = _aliased(prompt, self.cfg.aliases)
command = _digits(_plain(prompt))
asked = _asked(house, command)
group = self._group(command, house.areas)
if group is not None:
calls, confidence, asked = group, 1.0, set(house.areas)
else:
current = self._current(readings)
calls: list[Any] = []
threshold = 1.0
if self.pack is not None:
wren = _wren()
targets = [
wren.Target(
**t, value=_unset(t) if current.get(p) is None else current[p]
)
for p, t in self.targets.items()
]
# 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, confidence = wren.act(command, house)
confidence = round(float(confidence), 3)
write = self.cfg.apply and confidence >= self.cfg.min_confidence
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
)
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:
area = str(call.args.get("area", ""))
if (
call.tool == "sensors.temperature"
and area in asked
and confidence >= self.cfg.min_confidence
and (answer := self._temperature(area, outdoor, readings))
):
answers.append(answer)
area, _, kind = str(call.args.get("id", "")).partition(":")
ports = self.devices.get((area, kind), [])
if call.tool.split(".")[0] != _TOOLS.get(kind) or area not in asked:
ports = []
if call.tool in _LEVELS:
ports = []
if BRIGHTNESS in specs:
ports = [BRIGHTNESS]
call = self._leveled(call, specs[BRIGHTNESS].name)
hits = [
(specs[port], value)
for port in ports
if (value := _value(call, specs[port].dtype)) is not None
]
record = {
"message": "",
"value": None,
"applied": False,
"delay_s": call.delay_s,
"call": _text(call),
"confidence": confidence,
}
if not hits:
actions.append(record)
done = False
for spec, value in hits:
applied = write
spec, unit = specs[call.target], self.targets[call.target]["unit"]
applied = sure if call.value is None else sure and 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
actions.append(
record | {"message": spec.name, "value": value, "applied": applied}
)
done = done or applied
if done:
said.append(_said(call, area))
if group is not None and said:
said = self._group_said(group, command, house.areas)
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:
@@ -632,77 +489,6 @@ class WrenNode(Node):
writes[REPLY] = CANT
return writes or None
def _leveled(self, call: Any, name: str) -> _Call:
"""A brightness call as the level it sets: "dim" is 30 below now."""
level = call.args.get("level")
if call.tool != "lights.set":
now = (
self._pipeline.state.get_present([name]).get(name)
if self._pipeline is not None
else None
)
if not isinstance(now, (int, float)) or isinstance(now, bool):
now = _USUAL
if call.tool == "lights.dim":
level = max(now - _STEP, min(now, _DIMMEST))
else:
level = min(now + _STEP, _BRIGHTEST)
elif isinstance(level, (int, float)):
level = min(max(level, 0), _BRIGHTEST)
return _Call("lights.set", {"level": level}, call.delay_s)
def _group(self, command: str, areas: Iterable[str]) -> list[_Call] | None:
"""The calls a command for a whole group means, one per wired device,
or None when it is not one: "turn off all lights" is every light.
Not the model's to answer: it makes one call of it, and "turn off all
lights" switched a living room light on at 0.98.
"""
action = re.search(rf"\b({'|'.join(_GROUP_TOOLS)})\b", command)
if not _GROUP.search(command) or _NOT_GROUP.search(command) or not action:
return None
tools = _GROUP_TOOLS[action[1]]
kinds = {_GROUP_KINDS[w] for w in command.split() if w in _GROUP_KINDS}
named = _named(areas, command)
delay = re.search(r"\bin (\d+) minutes?\b", command)
seconds = int(delay[1]) * 60.0 if delay else None
calls = [
_Call(tools[kind], {"id": f"{area}:{kind}"}, seconds)
for area, kind in self.devices
if kind in tools
and (not kinds or kind in kinds)
and (not named or area in named)
]
return calls or None
@staticmethod
def _group_said(
calls: list[_Call], command: str, areas: Iterable[str]
) -> list[str]:
"""A group command's reply: "all lights off", "lights off in the office"."""
said = list(dict.fromkeys(_GROUP_SAID[call.tool] for call in calls))
named = _named(areas, command)
if named:
said[-1] += " in the " + " and the ".join(named)
else:
said[0] = "all " + said[0]
if calls[0].delay_s:
minutes = round(calls[0].delay_s / 60)
said[-1] += f" in {minutes} minute{'s' * (minutes != 1)}"
return said
def _temperature(
self, area: str, outdoor: float | None, readings: dict[str, float | None]
) -> str | None:
""" "It's 8 degrees outside.", from the reading wired for the area."""
value = readings.get(self.readings.get(area, ""))
where = f"in the {area}"
if value is None and area in _OUTSIDE:
value, where = outdoor, "outside"
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
return f"It's {round(value)} degrees {where}."
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), "")
+35 -22
View File
@@ -1,5 +1,7 @@
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
@@ -12,40 +14,51 @@ def db() -> Generator[None, None, None]:
@dataclass
class _Device:
kind: str
id: str
state: str
class FakeTarget:
name: str
type: str
value: Any
values: list[str] | None = None
range: list[float] | None = None
description: str = ""
unit: str | None = None
readonly: bool = False
@dataclass
class _House:
time: str
outdoor_c: float
dark: bool
user_area: str | None
areas: dict[str, list[_Device]]
class FakeCall:
target: str
value: Any = None
delay_s: float | None = None
confidence: float = 1.0
class FakeWren:
"""The model package's surface, answering whatever a test sets ``answer`` to."""
"""The model package's surface: ``act`` answers whatever a test sets
``answer`` to, and ``load_pack`` returns ``pack``."""
AREAS = [
*("living room", "kitchen", "bathroom", "bedroom", "office"),
*("garden", "patio", "garage", "driveway", "balcony"),
]
KINDS = ["light", "media", "heat", "shutter", "water", "switch"]
Device = _Device
House = _House
Target = FakeTarget
Call = FakeCall
runtime = SimpleNamespace(MODEL=Path("demo.bin"))
def __init__(self) -> None:
self.answer: tuple[list[Any], float] = ([], 1.0)
self.houses: list[_House] = []
self.answer: list[FakeCall] = []
self.pack: dict[str, Any] = {"meta": {"threshold": 0.9, "targets": []}}
self.loaded: list[Path] = []
self.commands: list[str] = []
self.targets: list[list[FakeTarget]] = []
self.models: list[Any] = []
def act(self, command: str, house: _House) -> tuple[list[Any], float]:
self.houses.append(house)
def load_pack(self, path: Path) -> dict[str, Any]:
self.loaded.append(path)
return self.pack
def act(
self, command: str, targets: list[FakeTarget], *, model: Any = None
) -> list[FakeCall]:
self.commands.append(command)
self.targets.append(targets)
self.models.append(model)
return self.answer
+343 -244
View File
@@ -4,79 +4,96 @@ The model is an optional package, so ``fake_wren`` (conftest) answers in its
place: what the node does with an answer is what is under test here.
"""
from dataclasses import dataclass, field
import asyncio
from types import SimpleNamespace
from typing import Any
import pytest
from fluksio.flow.artifacts import ArtifactStore
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes.base import Node
from fluksio.flow.nodes.wren import CANT, WrenNode, _plain, _value
from fluksio.flow.nodes.wren import CANT, WrenNode, _plain
from fluksio.flow.pipeline import Pipeline
@dataclass
class FakeCall:
tool: str
args: dict[str, Any] = field(default_factory=dict)
delay_s: float | None = None
PROMPT = MessageSpec(name="command", port="prompt", dtype=DType.STR)
OUTDOOR = MessageSpec(name="outdoor", port="outdoor_temperature", dtype=DType.FLOAT)
LIGHT = MessageSpec(name="kitchen_light", dtype=DType.BOOL)
def _node(*targets: tuple[str, DType], **params: object) -> WrenNode:
def _node(
fake_wren,
*targets: tuple[str, DType],
inputs=(),
saying=False,
trained=None,
health=None,
app=None,
**params: Any,
) -> WrenNode:
"""A started node, its model trained on its own targets unless ``trained``
lists others."""
node = WrenNode(
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR)],
requires=[PROMPT, *inputs],
provides=[
MessageSpec(name="actions", dtype=DType.LIST),
*(MessageSpec(name=port, dtype=dtype) for port, dtype in targets),
*(
MessageSpec(name=port, dtype=DType.STR)
for port in ("reply", "unhandled")
if saying
),
],
params=params,
)
node.assign_flow("house", "control")
if health is not None:
node._on_health = lambda _node, status, detail: health.append((status, detail))
fake_wren.pack["meta"]["targets"] = (
list(node.targets.values()) if trained is None else trained
)
asyncio.run(node.start(app))
return node
def test_targets_of_one_area_and_kind_are_one_device(fake_wren):
node = _node(
("living_room_setpoint", DType.FLOAT),
("living_room_boost", DType.BOOL),
("kitchen_lamp", DType.BOOL),
("office_plug", DType.BOOL),
)
assert node.devices == {
("living room", "heat"): ["living_room_setpoint", "living_room_boost"],
("kitchen", "light"): ["kitchen_lamp"],
("office", "switch"): ["office_plug"],
}
def _record(port: str, value: Any, call: str, **more: Any) -> dict[str, Any]:
return {
"message": f"house.{port}",
"value": value,
"applied": True,
"delay_s": None,
"call": call,
"confidence": 1.0,
} | more
def test_a_kind_the_model_does_not_know_is_refused(fake_wren, monkeypatch):
monkeypatch.setattr(fake_wren, "KINDS", ["light"])
with pytest.raises(ValueError, match="switch"):
_node(("office_plug", DType.BOOL))
@pytest.mark.parametrize("port", ["hallway_light", "kitchen_fridge", "light"])
def test_a_port_naming_no_trained_device_is_refused(fake_wren, port):
with pytest.raises(ValueError, match="living_room") as refused:
_node((port, DType.BOOL))
assert port in str(refused.value)
def test_a_prompt_is_required(fake_wren):
with pytest.raises(ValueError, match="prompt"):
WrenNode(provides=[MessageSpec(name="kitchen_light", dtype=DType.BOOL)])
@pytest.mark.parametrize(
("requires", "provides", "params", "refusal"),
[
([], [LIGHT], {}, "prompt"),
([PROMPT], [MessageSpec(name="plan", dtype=DType.JSON)], {}, "plan"),
([PROMPT], [LIGHT], {"targets": {"hall_light": {}}}, "hall_light"),
(
[PROMPT],
[LIGHT],
{"targets": {"kitchen_light": {"values": ["ON"]}}},
"values",
),
([PROMPT], [LIGHT], {"targets": {"kitchen_light": {"range": [0, 1]}}}, "range"),
],
)
def test_a_wiring_wren_cannot_act_on_is_refused(
fake_wren, requires, provides, params, refusal
):
with pytest.raises(ValueError, match=refusal):
WrenNode(requires=requires, provides=provides, params=params)
def test_only_the_prompt_wakes_it(fake_wren):
node = WrenNode(
requires=[
MessageSpec(name="command", port="prompt", dtype=DType.STR),
MessageSpec(name="outside", port="outdoor", dtype=DType.FLOAT),
],
)
node = WrenNode(requires=[PROMPT, OUTDOOR])
assert {spec.port: spec.trigger for spec in node.input_ports} == {
"prompt": True,
"outdoor": False,
"outdoor_temperature": False,
}
@@ -85,83 +102,284 @@ def test_the_model_reads_the_command_plain():
assert _plain("Heat it to 22.5, please!") == "heat it to 22.5 please"
@pytest.mark.parametrize(
("tool", "args", "dtype", "value"),
[
("lights.on", {}, DType.BOOL, True),
("lights.on", {}, DType.FLOAT, 100.0),
("lights.off", {}, DType.STR, "off"),
("heating.set", {"temp": 22}, DType.FLOAT, 22.0),
("heating.set", {"temp": 22}, DType.BOOL, None),
("heating.boost", {}, DType.BOOL, True),
("heating.boost", {}, DType.INT, None),
("switches.off", {}, DType.INT, 0),
("sensors.temperature", {"area": "kitchen"}, DType.FLOAT, None),
],
)
def test_a_call_means_what_its_target_type_can_hold(tool, args, dtype, value):
assert _value(FakeCall(tool, args), dtype) == value
def test_applied_calls_reach_their_targets(fake_wren):
def test_the_targets_reach_the_model_with_their_values_and_settings(fake_wren):
node = _node(
("living_room_setpoint", DType.FLOAT),
("living_room_boost", DType.BOOL),
fake_wren,
("kitchen_light", DType.BOOL),
("living_room_heating", DType.FLOAT),
("kitchen_shutter", DType.STR),
inputs=[OUTDOOR],
targets={
"living_room_heating": {"range": [5, 30], "unit": "°C"},
"kitchen_shutter": {"values": ["UP", "DOWN"]},
"outdoor_temperature": {"description": "temperature outside"},
},
)
Pipeline([node]).state["house.kitchen_light"] = True
node.execute({"house.command": "warm it up", "house.outdoor": 6.2})
assert fake_wren.loaded == [fake_wren.runtime.MODEL]
assert fake_wren.models == [fake_wren.pack]
# Targets nothing has set yet are off, 0 within their range, or their
# first value.
target = fake_wren.Target
assert fake_wren.targets[0] == [
target("kitchen_light", "bool", True),
target("living_room_heating", "float", 5.0, range=[5.0, 30.0], unit="°C"),
target("kitchen_shutter", "str", "UP", values=["UP", "DOWN"]),
target(
"outdoor_temperature",
"float",
6.2,
description="temperature outside",
readonly=True,
),
]
def test_applied_calls_reach_their_targets_and_the_reply_says_so(fake_wren):
node = _node(
fake_wren,
("kitchen_light", DType.BOOL),
("living_room_heating", DType.FLOAT),
saying=True,
apply=True,
targets={"living_room_heating": {"range": [5, 30], "unit": "°C"}},
)
Pipeline([node]).state["house.living_room_setpoint"] = 21.0
fake_wren.answer = (
[
FakeCall("heating.set", {"id": "living room:heat", "temp": 23}),
# The living room's light is filled in for the model, not wired.
FakeCall("lights.on", {"id": "living room:light"}),
# 0.95 clears the model's own threshold, 0.9 here.
fake_wren.answer = [
fake_wren.Call("kitchen_light", True, confidence=0.95),
fake_wren.Call("living_room_heating", 21),
]
out = node.execute({"house.command": "kitchen light on and the heating to 21"})
assert out == {
"house.kitchen_light": True,
"house.living_room_heating": 21.0,
"house.reply": "Kitchen light on, living room heating to 21 °C.",
"house.actions": [
_record("kitchen_light", True, "set(kitchen_light, true)", confidence=0.95),
_record("living_room_heating", 21.0, "set(living_room_heating, 21)"),
],
0.98,
)
out = node.execute({"house.command": "set the heating to 23 degrees"})
assert out is not None
assert out["house.living_room_setpoint"] == 23.0
assert "house.living_room_boost" not in out
assert [(a["message"], a["applied"]) for a in out["house.actions"]] == [
("house.living_room_setpoint", True),
("", False),
]
# Areas nothing is wired to are shown too, so a call can name them.
assert set(fake_wren.houses[0].areas) == set(fake_wren.AREAS)
living = fake_wren.houses[0].areas["living room"]
assert [(d.kind, d.state) for d in living] == [
("light", "off"),
("media", "off"),
("heat", "21C"),
]
}
assert isinstance(out["house.living_room_heating"], float)
@pytest.mark.parametrize(
("params", "confidence"),
[({}, 0.99), ({"apply": True}, 0.5), ({"apply": True, "min_confidence": 1}, 0.99)],
[
({}, 0.99),
# Below the model's own threshold, 0.9 here.
({"apply": True}, 0.85),
({"apply": True, "min_confidence": 1}, 0.99),
],
)
def test_nothing_is_written_unless_applied_and_sure(fake_wren, params, confidence):
node = _node(("kitchen_light", DType.BOOL), **params)
fake_wren.answer = ([FakeCall("lights.on", {"id": "kitchen:light"})], confidence)
node = _node(fake_wren, ("kitchen_light", DType.BOOL), **params)
fake_wren.answer = [fake_wren.Call("kitchen_light", True, confidence=confidence)]
out = node.execute({"house.command": "turn on the lights in the kitchen"})
out = node.execute({"house.command": "turn on the kitchen light"})
assert out == {
"house.actions": [
{
"message": "house.kitchen_light",
"value": True,
"applied": False,
"delay_s": None,
"call": "lights.on()",
"confidence": confidence,
}
_record(
"kitchen_light",
True,
"set(kitchen_light, true)",
applied=False,
confidence=confidence,
)
]
}
@pytest.mark.parametrize(
("params", "applied"),
[
({}, True),
# One call short of its target's bar stops the whole command.
({"targets": {"kitchen_light": {"min_confidence": 0.99}}}, False),
({"targets": {"kitchen_light": {"pass_on": True}}}, False),
({"min_confidence": 0.99}, False),
# A target's own bar is taken over the node's.
(
{
"min_confidence": 0.99,
"targets": {
"kitchen_light": {"min_confidence": 0.9},
"garden_light": {"min_confidence": 0.9},
},
},
True,
),
],
)
def test_a_command_is_applied_whole_or_not_at_all(fake_wren, params, applied):
node = _node(
fake_wren,
("kitchen_light", DType.BOOL),
("garden_light", DType.BOOL),
saying=True,
apply=True,
**params,
)
fake_wren.answer = [
fake_wren.Call("garden_light", True, confidence=0.95),
fake_wren.Call("kitchen_light", True, confidence=0.95),
]
command = "garden and kitchen lights on"
out = node.execute({"house.command": command})
assert out is not None
assert [a["applied"] for a in out.pop("house.actions")] == [applied, applied]
assert out == (
{
"house.garden_light": True,
"house.kitchen_light": True,
"house.reply": "Garden light on, kitchen light on.",
}
if applied
else {"house.unhandled": command, "house.reply": CANT}
)
def test_a_target_the_model_was_not_trained_on_is_never_applied(fake_wren):
trained = [
# As the pack writes it: [5, 30] is the node's (5.0, 30.0), and the
# unit is not the model's to know.
{
"name": "living_room_heating",
"type": "float",
"values": None,
"range": [5, 30],
"description": "",
"unit": None,
"readonly": False,
},
# Described otherwise since.
{
"name": "canopy",
"type": "str",
"values": ["UP", "DOWN", "STOP"],
"range": None,
"description": "awning",
"unit": None,
"readonly": False,
},
]
health: list[tuple[str, str | None]] = []
node = _node(
fake_wren,
("living_room_heating", DType.FLOAT),
("canopy", DType.STR),
("garden_light", DType.BOOL),
saying=True,
trained=trained,
health=health,
apply=True,
targets={
"living_room_heating": {"range": [5, 30], "unit": "°C"},
"canopy": {
"values": ["UP", "DOWN", "STOP"],
"description": "awning over the patio",
},
},
)
((status, detail),) = health
assert status == "degraded"
assert detail is not None and "retraining for canopy, garden_light" in detail
fake_wren.answer = [fake_wren.Call("canopy", "DOWN")]
out = node.execute({"house.command": "close the awning"})
assert out is not None
assert [a["applied"] for a in out["house.actions"]] == [False]
assert out["house.unhandled"] == "close the awning"
def test_a_read_is_answered_from_the_target_value(fake_wren):
node = _node(
fake_wren,
inputs=[OUTDOOR],
saying=True,
# Not applied: a reading is answered anyway, as it writes nothing.
targets={"outdoor_temperature": {"unit": "°C"}},
)
fake_wren.answer = [fake_wren.Call("outdoor_temperature")]
command = "how warm is it outside"
out = node.execute({"house.command": command, "house.outdoor": 6.2})
assert out == {
"house.reply": "Outdoor temperature is 6.2 °C.",
"house.actions": [
_record("outdoor", 6.2, "read(outdoor_temperature)"),
],
}
# Nothing has written the reading yet: there is no answer to give.
out = node.execute({"house.command": command})
assert out is not None
assert out["house.unhandled"] == command
def test_a_delayed_call_is_held_by_the_work_queue(fake_wren, monkeypatch):
node = _node(fake_wren, ("kitchen_light", DType.BOOL), saying=True, apply=True)
deferred: list[tuple[dict[str, Any], float]] = []
def defer(self, node, outputs, seconds):
deferred.append((outputs, seconds))
return True
monkeypatch.setattr(Pipeline, "defer", defer)
Pipeline([node])
fake_wren.answer = [
fake_wren.Call("kitchen_light", False),
fake_wren.Call("kitchen_light", True, delay_s=420),
]
out = node.execute({"house.command": "kitchen light off for 7 minutes"})
assert deferred == [({"house.kitchen_light": True}, 420)]
assert out is not None
assert out["house.kitchen_light"] is False
assert out["house.reply"] == "Kitchen light off, kitchen light on in 7 minutes."
def test_a_trained_model_is_read_from_the_artifact_store(fake_wren, tmp_path):
store = ArtifactStore(tmp_path)
digest = store.put([b"WREN"])["digest"]
app = SimpleNamespace(state=SimpleNamespace(artifact_store=store))
health: list[tuple[str, str | None]] = []
_node(
fake_wren, ("kitchen_light", DType.BOOL), app=app, health=health, model=digest
)
assert fake_wren.loaded == [store.path(digest)]
assert health == [("ok", None)]
# One the store does not have: every command goes on.
node = _node(
fake_wren,
("kitchen_light", DType.BOOL),
saying=True,
app=app,
health=health,
apply=True,
model="sha256:" + "0" * 64,
)
assert health[-1][0] == "degraded"
out = node.execute({"house.command": "kitchen light on"})
assert out == {
"house.actions": [],
"house.unhandled": "kitchen light on",
"house.reply": CANT,
}
assert fake_wren.commands == []
def test_the_model_runs_on_one_blas_thread(fake_wren, monkeypatch):
"""About twice as fast for a model this small; numpy is loaded by `mlp`."""
from threadpoolctl import threadpool_info
@@ -169,163 +387,44 @@ def test_the_model_runs_on_one_blas_thread(fake_wren, monkeypatch):
threads: list[int] = []
act = fake_wren.act
def counted(command: str, house: Any) -> tuple[list[Any], float]:
def counted(command: str, targets: Any, *, model: Any = None) -> Any:
threads.extend(
pool["num_threads"]
for pool in threadpool_info()
if pool["user_api"] == "blas"
)
return act(command, house)
return act(command, targets, model=model)
monkeypatch.setattr(fake_wren, "act", counted)
_node(("kitchen_lamp", DType.BOOL)).execute({"house.command": "lights on"})
node = _node(fake_wren, ("kitchen_light", DType.BOOL))
node.execute({"house.command": "lights on"})
assert threads
assert set(threads) == {1}
def test_a_reply_says_what_was_applied_and_the_rest_is_unhandled(fake_wren):
node = WrenNode(
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR)],
provides=[
MessageSpec(name="kitchen_light", dtype=DType.BOOL),
MessageSpec(name="reply", dtype=DType.STR),
MessageSpec(name="unhandled", dtype=DType.STR),
],
params={"apply": True},
def test_own_words_and_number_words_reach_the_model_as_its_own(fake_wren):
node = _node(
fake_wren,
("garden_light", DType.BOOL),
saying=True,
apply=True,
aliases={"Page Too": "garden"},
)
node.assign_flow("house", "control")
fake_wren.answer = ([FakeCall("lights.on", {"id": "kitchen:light"})], 0.99)
out = node.execute({"house.command": "turn on the kitchen light"})
assert out == {"house.kitchen_light": True, "house.reply": "Kitchen light on."}
# Nothing wired for the call, or nothing to call at all. Nothing reads
# what is left, so the node says so itself.
asked = "what is the weather tomorrow"
for calls in ([FakeCall("lights.on", {"id": "garage:light"})], []):
fake_wren.answer = (calls, 0.99)
out = node.execute({"house.command": asked})
assert out == {"house.unhandled": asked, "house.reply": CANT}
# An assistant reading it answers instead.
unhandled = MessageSpec(name="house.unhandled", port="unhandled", dtype=DType.STR)
Pipeline([node, Node(lambda params, unhandled: None, requires=[unhandled])])
assert node.execute({"house.command": asked}) == {"house.unhandled": asked}
def test_a_call_on_an_area_the_command_does_not_name_is_not_applied(fake_wren):
"""A word the model never learned lands on a neighbour it was shown."""
node = _node(("bathroom_light", DType.BOOL), apply=True)
fake_wren.answer = ([FakeCall("lights.on", {"id": "bathroom:light"})], 0.97)
out = node.execute({"house.command": "switch on the appliances"})
assert out is not None
assert "house.bathroom_light" not in out
assert [a["applied"] for a in out["house.actions"]] == [False]
def _speaking(*targets: tuple[str, DType], inputs=(), **params: object) -> WrenNode:
node = WrenNode(
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR), *inputs],
provides=[
*(MessageSpec(name=port, dtype=dtype) for port, dtype in targets),
MessageSpec(name="reply", dtype=DType.STR),
MessageSpec(name="unhandled", dtype=DType.STR),
],
params={"apply": True, **params},
)
node.assign_flow("house", "control")
return node
def test_the_house_words_and_number_words_reach_the_model_as_its_own(fake_wren):
node = _speaking(("patio_light", DType.BOOL), aliases={"Page Too": "patio"})
fake_wren.answer = ([], 0.99)
out = node.execute({"house.command": "Turn on the page too light in five minutes."})
assert fake_wren.commands == ["turn on the patio light in 5 minutes"]
# The assistant reads the house's words too, as they were said otherwise.
assert fake_wren.commands == ["turn on the garden light in 5 minutes"]
# The assistant reads the own words too, as they were said otherwise.
passed = "Turn on the garden light in five minutes."
assert out == {
"house.unhandled": "Turn on the patio light in five minutes.",
"house.actions": [],
"house.unhandled": passed,
"house.reply": CANT,
}
def test_a_command_for_all_of_a_kind_reaches_every_wired_one(fake_wren):
lights = [(f"{area}_light", DType.BOOL) for area in ("kitchen", "office")]
node = _speaking(*lights, ("office_shutter", DType.STR))
out = node.execute({"house.command": "Turn off all lights."})
assert out == {
"house.kitchen_light": False,
"house.office_light": False,
"house.reply": "All lights off.",
}
out = node.execute({"house.command": "turn everything off in the office"})
assert out == {
"house.office_light": False,
"house.reply": "Lights off in the office.",
}
assert fake_wren.commands == [] # the node's, not the model's
# A question or an exception is the model's again.
fake_wren.answer = ([], 0.99)
node.execute({"house.command": "are all lights off"})
node.execute({"house.command": "turn off all lights but the kitchen"})
assert len(fake_wren.commands) == 2
def test_a_question_for_a_temperature_is_answered_from_its_reading(fake_wren):
node = _speaking(
inputs=[
MessageSpec(name="outside", port="outdoor", dtype=DType.FLOAT),
MessageSpec(
name="kitchen_temp", port="kitchen_temperature", dtype=DType.FLOAT
),
]
)
assert node.readings == {"kitchen": "kitchen_temperature"}
readings = {"house.outside": 7.6, "house.kitchen_temp": 21.2}
for area, said in [("garden", "outside"), ("kitchen", "in the kitchen")]:
fake_wren.answer = ([FakeCall("sensors.temperature", {"area": area})], 0.99)
command = f"what is the temperature in the {area}"
out = node.execute({"house.command": command, **readings})
degrees = 8 if area == "garden" else 21
assert out == {"house.reply": f"It's {degrees} degrees {said}."}
# No reading for the bedroom, or a model unsure of it: the assistant's.
for area, confidence in [("bedroom", 0.99), ("kitchen", 0.5)]:
fake_wren.answer = (
[FakeCall("sensors.temperature", {"area": area})],
confidence,
)
command = f"how warm is it in the {area}"
out = node.execute({"house.command": command, **readings})
assert out == {"house.unhandled": command, "house.reply": CANT}
def test_a_brightness_call_sets_the_house_brightness(fake_wren):
node = _speaking(("brightness", DType.INT))
# Whatever area the call names: the brightness is the house's.
for call, level in [
(FakeCall("lights.set", {"id": "kitchen:light", "level": 30}), 30),
(FakeCall("lights.dim", {"id": "kitchen:light"}), 40),
(FakeCall("lights.brighten", {"id": "bedroom:light"}), 100),
]:
fake_wren.answer = ([call], 0.95)
out = node.execute({"house.command": "dim the lights in the kitchen"})
assert out == {
"house.brightness": level,
"house.reply": f"Brightness at {level} percent.",
}
# Nothing wired for it: the assistant's.
node = _speaking(("kitchen_light", DType.BOOL))
fake_wren.answer = ([FakeCall("lights.dim", {"id": "kitchen:light"})], 0.95)
out = node.execute({"house.command": "dim the lights"})
assert out == {"house.unhandled": "dim the lights", "house.reply": CANT}
# An assistant reading `unhandled` answers instead.
unhandled = MessageSpec(name="house.unhandled", port="unhandled", dtype=DType.STR)
Pipeline([node, Node(lambda params, unhandled: None, requires=[unhandled])])
out = node.execute({"house.command": "Turn on the page too light in five minutes."})
assert out == {"house.actions": [], "house.unhandled": passed}
+3 -3
View File
@@ -77,6 +77,6 @@ which is where a `stt` node writes what a device heard, so typed and spoken
commands are one conversation: the words heard so far, the command, each
reply, and the reported actions with their target message, value and whether
each was applied. A node whose prompt is another's `unhandled` answers in that
node's conversation. Wren's `apply` and `min_confidence` settings decide
whether it writes to devices. Control conversations stay in the browser and
clear on reload.
node's conversation. Wren's `apply`, `min_confidence` and `targets` settings
decide whether it writes to its targets. Control conversations stay in the
browser and clear on reload.
+72 -88
View File
@@ -340,113 +340,97 @@ use a [batch flow](../concepts/runs.md) and a Function node.
### Wren
**`wren`** — turn a typed command into values for the devices wired to it.
**`wren`** — turn a typed command into values for the targets wired to it.
[Wren](https://github.com/stroblme/wren) is an optional local language model
for device commands. Install it in the engine's environment with
`pip install git+https://github.com/stroblme/wren@eae4d6ff9ded68e36624972fd3f4a8f8d210b0c2`.
The `wren` package on PyPI is an unrelated one. Without the model the node
reports a missing dependency.
that turns short commands into calls on a fixed list of targets. Install it in
the engine's environment with
`pip install git+https://github.com/stroblme/wren`. The `wren` package on PyPI
is an unrelated one. Without the model the node reports a missing dependency.
[Wren's own docs](https://stroblme.github.io/wren/) explain how to describe
targets, train a model for them, and what a model can and cannot do.
| Input | Type | Notes |
|---|---|---|
| `prompt` | `str` | the command, and the only input that runs the node |
| `presence` | `str` | the area the user is in; anything else reads as away |
| `outdoor` | `float` or `int` | °C; 20 without it, clamped to −5…30 |
| `dark` | `bool` | without it, dark before 07:00 and from 18:00 |
| `<area>_temperature` | `float` or `int` | °C, read when the command asks how warm the area is |
| any other | `bool`, `int`, `float` or `str` | a **reading**: a target a command may ask about but not set |
The outputs are `actions`, a list of records, `reply`, `unhandled`,
`brightness`, and any number of **targets**. A
target is an output named `<area>_<kind>` and bound to the message its device
listens on: `living_room_setpoint` bound to `home.setpoint` is the living
room's heating. The wiring is the allowlist, so the model can write nothing
that is not wired to the node.
| Output | Type | Notes |
|---|---|---|
| `actions` | `list` | one record per call |
| `reply` | `str` | what was set or read, as "Kitchen light on." |
| `unhandled` | `str` | the prompt, when the node did nothing with it |
| any other | `bool`, `int`, `float` or `str` | a **target** a command may set |
- **area**: `living_room`, `kitchen`, `bathroom`, `bedroom`, `office`,
`garden`, `patio`, `garage`, `driveway`, `balcony`
- **kind**: `light`, `lamp`, `brightness` for a light; `setpoint`, `heating`,
`heat`, `boost` for heating; `media`, `music`, `tv`; `shutter`, `blind`,
`cover`; `water`, `watering`, `sprinkler`; `switch`, `plug`, `socket`,
`appliances` for a switch, which only wren's s10 model knows
A port naming anything else, or a kind the installed model lacks, is refused
when the flow is built. A port is
named after its message unless it is given a name of its own, in the node
panel's port name field or as `{"name": "setpoint", "port":
"living_room_setpoint"}` through the flow API.
Targets of one area and kind are one device to the model, and each takes what
a call means for its type. A target's current value is the state the model
reads.
| Call | `bool` | number | `str` |
|---|---|---|---|
| `lights.on` / `off` | `true` / `false` | `100` / `0` | `on` / `off` |
| `media.play` / `stop` | `true` / `false` | — | `play` / `stop` |
| `heating.set` | — | the temperature | — |
| `heating.boost` / `off` | `true` / `false` | — | `boost` / `off` |
| `shutters.up` / `down` | `true` / `false` | `100` / `0` | `up` / `down` |
| `shutters.stop` | — | — | `stop` |
| `watering.start` / `stop` | `true` / `false` | — | `on` / `off` |
| `switches.on` / `off` | `true` / `false` | `1` / `0` | `on` / `off` |
| `lights.set` / `dim` / `brighten` | — | the level, on `brightness` only | — |
A target is named by its port and bound to the message it stands for:
`living_room_heating` bound to `home.setpoint` is the living room's heating.
Its name, type and current value are what the model is shown, so the wiring
is the allowlist and the model can reach nothing that is not wired to the
node. A target nothing has set yet is shown as off, as 0 within its range, or
as its first value. A port is named after its message unless it is given a
name of its own, in the node panel's port name field or as
`{"name": "setpoint", "port": "living_room_heating"}` through the flow API.
| Setting | Default | Notes |
|---|---|---|
| `apply` | `false` | write to the targets; off, the node only reports |
| `min_confidence` | `0.93` | below this nothing is written. wren's s10 model is less sure of itself, use `0.8` with it |
| `aliases` | empty | the house's own words, or words the recogniser hears wrong, in the model's: `{"traverse": "living room light", "page too": "patio"}` |
| `model` | empty | the trained model, an artifact digest; empty runs wren's demo model |
| `min_confidence` | empty | the confidence a call needs; empty takes the model's own threshold |
| `targets` | empty | by target port, what the model is told of it and how far it is trusted (below) |
| `aliases` | empty | your own words, or words the recogniser hears wrong, in the model's: `{"traverse": "living room light", "page too": "patio"}` |
Each record in `actions` contains `message`, `value`, `applied`, `delay_s`,
`call`, and `confidence`. Unwired calls have an empty `message` and are not
applied. Keep `apply=false` while checking how the model interprets commands.
Every field of a `targets` entry is optional:
`reply` is one sentence saying what was applied, such as "Kitchen light on."
or "Garage light on in 5 minutes.". When nothing was applied, `unhandled`
carries the prompt instead, for something else to answer, such as an
[Assistant](#assistant). When no node reads `unhandled`, `reply` says "Sorry,
I can't do that yet." instead, so a spoken command never goes unanswered.
| Field | Notes |
|---|---|
| `description` | more words for the target, which help pick it: `"awning over the patio"` |
| `values` | the only values a `str` target takes: `["UP", "DOWN", "STOP"]` |
| `range` | the lowest and highest value of a number: `[5, 30]` |
| `unit` | said after a number in the reply, such as `"°C"`; the model never sees it |
| `min_confidence` | this target's own bar, used over the node's, such as a higher one for a lock |
| `pass_on` | wren never sets it, and a command on it goes on to `unhandled` |
`brightness` is the house's, a `float` or `int` from 0 to 100. No wren model
makes brightness calls yet. For one that does, the node writes them to
`brightness` whatever area they name: "set the brightness in the kitchen to 30%" sets the
house to 30. "Dim" and "brighter" move it 30 from its current value (70
when nothing has set it), and a dim stops at 10. The reply is "Brightness at
40 percent.". Without the output these calls go on to `unhandled`.
A target that is not a `bool`, `int`, `float` or `str`, a `targets` entry
naming no target, `values` on a target that is not a `str` and a `range` on
one that is not a number are refused when the flow is built.
A call with a delay ("in 10m") is held by the engine's work queue, like the
Debounce node's second value: it survives a restart where the queue is in
A command is applied whole or not at all. Its calls are written only when
`apply` is on, every call reaches its target's bar, and none is on a
`pass_on` target or on one the model was not trained on. Applying part of a
command would leave half of a duration ("off for 7 minutes" is off now and on
again later), or let the assistant do again what was already done. Otherwise
nothing is written and `unhandled` carries the prompt, for something else to
answer, such as an [Assistant](#assistant). When no node reads `unhandled`,
`reply` says "Sorry, I can't do that yet." instead, so a spoken command never
goes unanswered.
`reply` says what was set, as "Kitchen light on, living room heating to 21 °C
in 5 minutes.", and answers a read from the target's current value, as
"Outdoor temperature is 6.2 °C.", with `apply` off too, since a read writes
nothing. A read of a target nothing has written yet goes on to `unhandled`. Each record in `actions` contains `message`, `value`,
`applied`, `delay_s`, `call` as wren writes it, such as
`set(kitchen_light, true)`, and that call's `confidence`. Keep `apply=false`
while checking how the model reads commands.
A call with a delay ("in 10 minutes") is held by the engine's work queue, like
the Debounce node's second value: it survives a restart where the queue is in
Redis, and not otherwise.
The model sees every area, wired or not, each with at least a light, so "the
bathroom light" is not taken for a light that is wired elsewhere. Such a call
is reported but not applied, and goes on to `unhandled`. So is a call on an
area the command does not name, unless it is the user's area or, with no area
named, the living room: "switch on the appliances" is a word the pinned model
never learned, and it lands on whichever light it was shown. The command is read
in lower case without sentence marks and with number words as digits ("in
five minutes" as "in 5 minutes"), as the model was trained. `aliases` are
applied before, whole words in any case, and `unhandled` carries the prompt
with them.
A model knows the targets it was trained on: their names, types, `values`,
`range`, descriptions and which are readings. A target wired since, or
described otherwise since, is one it never learnt. The node then reads
degraded, naming such targets and asking for the model to be retrained, and a
call on one is never applied. wren's demo model knows the 20 targets of its
[demo home](https://github.com/stroblme/wren/blob/main/wren/home.json), so
it runs without that warning only on ports named and described as there. A
`model` the artifact store does not have or cannot read also reads degraded,
and every command goes on to `unhandled`. A model trained with wren goes into
the store with `PUT /artifacts` (see the [API](../code/api.md#artifacts)).
A command for all of a kind, such as "turn off all lights", "switch every
light on in 5 minutes" or "everything off in the office", is not the
model's. The node applies it to every wired target of that kind, within the
areas it names, and replies "All lights off." or "Lights off in the
office.". A question ("are all lights off") or an exception ("all but the
kitchen") goes to the model as any other command.
A question for an area's temperature ("how warm is it in the kitchen") is
answered from its `<area>_temperature` input, or from `outdoor` for an area
outside, as "It's 8 degrees outside.". Without a reading it goes on to
`unhandled`.
!!! note "What the current model cannot do"
Commands are English, such as "turn on the lights in the kitchen".
The model uses the areas above, one device per kind and area, on/off
actions, and whole-minute delays. It does not dim lights or set colors.
The command is read in lower case without sentence marks and with number words
as digits ("in five minutes" as "in 5 minutes"), as the model was trained.
`aliases` are applied before, whole words in any case, and `unhandled` carries
the prompt with them.
The chat's [Control tab](../code/agents.md#control) sends commands by hand.