diff --git a/backend/fluksio/flow/nodes/wren.py b/backend/fluksio/flow/nodes/wren.py index 93dfa9c..6459b15 100644 --- a/backend/fluksio/flow/nodes/wren.py +++ b/backend/fluksio/flow/nodes/wren.py @@ -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 `_ 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 ``_``, so the wiring is the +`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, ``_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 - ``_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 ``_``. 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 ``_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 _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"_, 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), "") diff --git a/backend/tests/flow/conftest.py b/backend/tests/flow/conftest.py index 7d6ab63..dde158d 100644 --- a/backend/tests/flow/conftest.py +++ b/backend/tests/flow/conftest.py @@ -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 diff --git a/backend/tests/flow/test_wren.py b/backend/tests/flow/test_wren.py index 102949b..46821a4 100644 --- a/backend/tests/flow/test_wren.py +++ b/backend/tests/flow/test_wren.py @@ -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} diff --git a/docs/code/agents.md b/docs/code/agents.md index 7386563..bd728ea 100644 --- a/docs/code/agents.md +++ b/docs/code/agents.md @@ -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. diff --git a/docs/reference/node-types.md b/docs/reference/node-types.md index da78f86..a7e4a0d 100644 --- a/docs/reference/node-types.md +++ b/docs/reference/node-types.md @@ -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 | -| `_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 `_` 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 `_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.