"""Wren: a typed command in, values for the devices 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 allowlist: the model can reach nothing that is not wired to the node. """ from __future__ import annotations import time from collections.abc import Iterable from typing import Any from pydantic import BaseModel, ConfigDict, Field from threadpoolctl import threadpool_limits from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.nodes.base import Node def _wren() -> Any: """The model package, which is optional. Imported when a node is built rather than at boot, so an instance without it still offers every other type, and a node placed without it says so on the canvas straight away. """ try: import wren except ImportError: raise RuntimeError("the wren node needs the model: pip install wren") from None 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", } #: What the node reads, and the types each may be. Only the prompt wakes it. INPUTS = { "prompt": {DType.STR}, "presence": {DType.STR}, "outdoor": {DType.FLOAT, DType.INT}, "dark": {DType.BOOL}, } #: The output the calls are reported on. Every other output is a device. ACTIONS = "actions" _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", } #: 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"), } #: 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. _TRAINED = {"living room": ("light", "media", "heat")} 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, ) 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 _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})" class WrenNode(Node): """Turn the command on ``prompt`` into values for the devices wired to it. Inputs: ``prompt`` (the command, and the only one that wakes the node), ``presence`` (the area the user is in), ``outdoor`` (°C) and ``dark`` (otherwise read off the clock: before 07:00 or from 18:00). Outputs: ``actions``, a list of records, one per call and target, 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. """ class Params(BaseModel): model_config = ConfigDict(extra="allow") apply: bool = Field( default=False, description="Write to the targets. Off, the node only reports what it would do.", ) min_confidence: float = Field( default=0.93, ge=0, le=1, description="Below this, nothing is written; the calls are still reported.", ) __slots__ = ("cfg", "devices") # A call is a command to a device; doing it twice is not doing it once. idempotent = False def __init__( self, requires: MessageSpec | Iterable[MessageSpec] = (), provides: MessageSpec | Iterable[MessageSpec] = (), params: dict[str, Any] | None = None, name: str | None = None, ): self.cfg = self.Params.model_validate(params or {}) wren = _wren() inputs = self._normalize_ports(requires) outputs = self._normalize_ports(provides) for spec in inputs: allowed = INPUTS.get(spec.port) if allowed is None: raise ValueError( f"'{spec.port}' is not an input of wren. It reads " f"{', '.join(INPUTS)}." ) 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): raise ValueError("wren needs a 'prompt' input: the command to act on.") areas = {area.replace(" ", "_"): area for area in wren.AREAS} #: (area, kind) to the ports standing for that device. self.devices: dict[tuple[str, str], list[str]] = {} 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 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 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) super().__init__( self._act, # The prompt is the event; the rest is read when it arrives. requires=[ spec.model_copy(update={"trigger": spec.port == "prompt"}) for spec in inputs ], provides=outputs, params=params, name=name or "wren", ) def _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) 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} if not wired and area not in _TRAINED: continue 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, ) def _act( self, params: dict[str, Any], prompt: str = "", presence: str | None = None, outdoor: float | None = None, dark: bool | None = None, ) -> 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) # 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(prompt, house) confidence = round(float(confidence), 3) write = self.cfg.apply and confidence >= self.cfg.min_confidence writes: dict[str, Any] = {} actions: list[dict[str, Any]] = [] for call in calls: area, _, kind = str(call.args.get("id", "")).partition(":") ports = self.devices.get((area, kind), []) if call.tool.split(".")[0] != _TOOLS.get(kind): ports = [] 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) for spec, value in hits: applied = write 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} ) if any(spec.port == ACTIONS for spec in self.output_ports): writes[ACTIONS] = actions return writes or None