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