Add the wren node: a typed command into values for the devices wired to it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -7,3 +9,47 @@ import pytest
|
||||
def db() -> Generator[None, None, None]:
|
||||
"""The engine holds no database state, so these tests need no database."""
|
||||
yield
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Device:
|
||||
kind: str
|
||||
id: str
|
||||
state: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _House:
|
||||
time: str
|
||||
outdoor_c: float
|
||||
dark: bool
|
||||
user_area: str | None
|
||||
areas: dict[str, list[_Device]]
|
||||
|
||||
|
||||
class FakeWren:
|
||||
"""The model package's surface, answering whatever a test sets ``answer`` to."""
|
||||
|
||||
AREAS = [
|
||||
*("living room", "kitchen", "bathroom", "bedroom", "office"),
|
||||
*("garden", "patio", "garage", "driveway", "balcony"),
|
||||
]
|
||||
KINDS = ["light", "media", "heat", "shutter", "water"]
|
||||
Device = _Device
|
||||
House = _House
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.answer: tuple[list[Any], float] = ([], 1.0)
|
||||
self.houses: list[_House] = []
|
||||
|
||||
def act(self, command: str, house: _House) -> tuple[list[Any], float]:
|
||||
self.houses.append(house)
|
||||
return self.answer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_wren(monkeypatch: pytest.MonkeyPatch) -> FakeWren:
|
||||
"""Stands in for the optional model package the wren node imports."""
|
||||
fake = FakeWren()
|
||||
monkeypatch.setattr("fluksio.flow.nodes.wren._wren", lambda: fake)
|
||||
return fake
|
||||
|
||||
@@ -90,11 +90,16 @@ FIXTURES: dict[str, dict] = {
|
||||
"requires": [MessageSpec(name="text", dtype=DType.STR)],
|
||||
"provides": [],
|
||||
},
|
||||
"wren": {
|
||||
"params": {"apply": True},
|
||||
"requires": [MessageSpec(name="prompt", dtype=DType.STR)],
|
||||
"provides": [MessageSpec(name="kitchen_light", dtype=DType.BOOL)],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_name", sorted(FIXTURES))
|
||||
def test_node_type_builds_and_qualifies(type_name: str):
|
||||
def test_node_type_builds_and_qualifies(type_name: str, fake_wren):
|
||||
fixture = FIXTURES[type_name]
|
||||
node = NODE_TYPES[type_name].cls(
|
||||
requires=fixture["requires"],
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""The wren node: its wiring is the allowlist, and nothing is written unasked.
|
||||
|
||||
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
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from fluksio.flow.messages import DType, MessageSpec
|
||||
from fluksio.flow.nodes.wren import WrenNode, _value
|
||||
from fluksio.flow.pipeline import Pipeline
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeCall:
|
||||
tool: str
|
||||
args: dict[str, Any] = field(default_factory=dict)
|
||||
delay_s: float | None = None
|
||||
|
||||
|
||||
def _node(*targets: tuple[str, DType], **params: object) -> WrenNode:
|
||||
node = WrenNode(
|
||||
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR)],
|
||||
provides=[
|
||||
MessageSpec(name="actions", dtype=DType.LIST),
|
||||
*(MessageSpec(name=port, dtype=dtype) for port, dtype in targets),
|
||||
],
|
||||
params=params,
|
||||
)
|
||||
node.assign_flow("house", "control")
|
||||
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),
|
||||
)
|
||||
assert node.devices == {
|
||||
("living room", "heat"): ["living_room_setpoint", "living_room_boost"],
|
||||
("kitchen", "light"): ["kitchen_lamp"],
|
||||
}
|
||||
|
||||
|
||||
@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)])
|
||||
|
||||
|
||||
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),
|
||||
],
|
||||
)
|
||||
assert {spec.port: spec.trigger for spec in node.input_ports} == {
|
||||
"prompt": True,
|
||||
"outdoor": False,
|
||||
}
|
||||
|
||||
|
||||
@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),
|
||||
("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):
|
||||
node = _node(
|
||||
("living_room_setpoint", DType.FLOAT),
|
||||
("living_room_boost", DType.BOOL),
|
||||
apply=True,
|
||||
)
|
||||
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.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),
|
||||
]
|
||||
living = fake_wren.houses[0].areas["living room"]
|
||||
assert [(d.kind, d.state) for d in living] == [
|
||||
("light", "off"),
|
||||
("media", "off"),
|
||||
("heat", "21C"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("params", "confidence"),
|
||||
[({}, 0.99), ({"apply": True}, 0.5), ({"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)
|
||||
|
||||
out = node.execute({"house.command": "turn on the lights in the kitchen"})
|
||||
|
||||
assert out == {
|
||||
"house.actions": [
|
||||
{
|
||||
"message": "house.kitchen_light",
|
||||
"value": True,
|
||||
"applied": False,
|
||||
"delay_s": None,
|
||||
"call": "lights.on()",
|
||||
"confidence": confidence,
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user