diff --git a/backend/fluksio/flow/nodes/assistant.py b/backend/fluksio/flow/nodes/assistant.py index b8ece95..b89a6d7 100644 --- a/backend/fluksio/flow/nodes/assistant.py +++ b/backend/fluksio/flow/nodes/assistant.py @@ -74,9 +74,11 @@ few words, such as "Sorry, I can't do that." _END = re.compile(r"(?<=[.!?])\s+") -def phrase(port: str, value: Any) -> str: - """A value set, as the reply says it: "kitchen light off".""" +def phrase(port: str, value: Any, word: str | None = None) -> str: + """A value set, as the reply says it: "kitchen light off", "canopy open".""" label = port.replace("_", " ") + if word: + return f"{label} {word}" if isinstance(value, bool): return f"{label} {'on' if value else 'off'}" return ( @@ -120,6 +122,13 @@ class AssistantNode(Node): "loaded for 30 minutes on Ollama; drop what a provider refuses." ), ) + values: dict[str, dict[str, str]] = Field( + default_factory=dict, + description=( + "For a str target, by port: the only values it takes, each with " + 'what the reply says for it, as {"UP": "open", "DOWN": "closed"}.' + ), + ) __slots__ = ("cfg", "_listening", "_lock", "_loop") @@ -183,22 +192,31 @@ class AssistantNode(Node): if spec.name and spec.port not in (REPLY, ACTIONS) ] - def _tools(self) -> list[dict[str, Any]]: - return [ - { - "type": "function", - "function": { - "name": spec.port, - "description": f"Set the {spec.port.replace('_', ' ')}.", - "parameters": { - "type": "object", - "properties": {"value": _SCHEMAS[spec.dtype]}, - "required": ["value"], - }, + def _tool(self, spec: MessageSpec) -> dict[str, Any]: + label = spec.port.replace("_", " ") + schema: dict[str, Any] = _SCHEMAS[spec.dtype] + description = f"Set the {label}." + words = self.cfg.values.get(spec.port) + if words: + # A code such as UP means nothing to the model until it is told. + schema = {**schema, "enum": list(words)} + meant = ", ".join(f'"{v}" means {label} {w}' for v, w in words.items()) + description = f"Set the {label}: {meant}." + return { + "type": "function", + "function": { + "name": spec.port, + "description": description, + "parameters": { + "type": "object", + "properties": {"value": schema}, + "required": ["value"], }, - } - for spec in self._targets() - ] + }, + } + + def _tools(self) -> list[dict[str, Any]]: + return [self._tool(spec) for spec in self._targets()] def _system(self, context: dict[str, Any]) -> str: """The instructions, then what the inputs read and the targets hold.""" @@ -328,7 +346,11 @@ class AssistantNode(Node): def _confirm(self, actions: list[dict[str, Any]]) -> str: """The values set, as one sentence: "Kitchen light off, setpoint to 22.".""" ports = {spec.name: spec.port for spec in self._targets()} - text = ", ".join(phrase(ports[a["message"]], a["value"]) for a in actions) + said = [] + for action in actions: + port, value = ports[action["message"]], action["value"] + said.append(phrase(port, value, self.cfg.values.get(port, {}).get(value))) + text = ", ".join(said) return text[:1].upper() + text[1:] + "." if text else "" def _caller(self, actions: list[dict[str, Any]]) -> agent.ToolCaller: @@ -346,6 +368,11 @@ class AssistantNode(Node): spec.check(value) except TypeError as exc: return json.dumps({"error": str(exc)}) + words = self.cfg.values.get(name) + if words and value not in words: + # A device takes its own codes only: a motor would run "OPEN" + # as a direction it has never heard of. + return json.dumps({"error": f"{name} is one of {', '.join(words)}."}) await asyncio.to_thread(self.emit, {name: value}) actions.append( { diff --git a/backend/fluksio/flow/nodes/wren.py b/backend/fluksio/flow/nodes/wren.py index f63e8d8..d448037 100644 --- a/backend/fluksio/flow/nodes/wren.py +++ b/backend/fluksio/flow/nodes/wren.py @@ -197,6 +197,19 @@ def _plain(command: str) -> str: return re.sub(r"[.,!?;:]+(?=\s|$)", "", command).strip().lower() +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. + + 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. + """ + named = {a for a in house.areas if re.search(rf"\b{re.escape(a)}\b", command)} + return named or {house.user_area, "living room"} + + 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") @@ -359,11 +372,13 @@ class WrenNode(Node): wren = _wren() specs = {spec.port: spec for spec in self.output_ports} house = self._house(wren, specs, presence, outdoor, dark) + command = _plain(prompt) + asked = _asked(house, command) # 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(_plain(prompt), house) + calls, confidence = wren.act(command, house) confidence = round(float(confidence), 3) write = self.cfg.apply and confidence >= self.cfg.min_confidence writes: dict[str, Any] = {} @@ -372,7 +387,7 @@ class WrenNode(Node): 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): + if call.tool.split(".")[0] != _TOOLS.get(kind) or area not in asked: ports = [] hits = [ (specs[port], value) diff --git a/backend/tests/flow/test_assistant.py b/backend/tests/flow/test_assistant.py index 283a0b2..8240d26 100644 --- a/backend/tests/flow/test_assistant.py +++ b/backend/tests/flow/test_assistant.py @@ -138,3 +138,39 @@ def test_no_provider_or_one_that_does_not_answer_is_said( _ask(node, "what is the weather tomorrow") assert said.pop() == {"reply": UNAVAILABLE} assert node._health == "degraded" + + +def test_a_target_with_values_takes_only_those_and_is_confirmed_in_words( + monkeypatch: pytest.MonkeyPatch, +): + seen: list[dict[str, Any]] = [] + log: list[Any] = [] + + async def completion(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]: + seen.append(kwargs) + calls = [_call("canopy", "OPEN"), _call("canopy", "UP")] + yield {"role": "assistant", "content": None, "tool_calls": calls} + + monkeypatch.setattr(ai_config, "load", lambda: PROVIDER) + monkeypatch.setattr(client, "stream_completion", completion) + monkeypatch.setattr(AssistantNode, "emit", lambda self, values: log.append(values)) + node = AssistantNode( + requires=[MessageSpec(name="unhandled", port="prompt", dtype=DType.STR)], + provides=[ + MessageSpec(name="canopy_manual", port="canopy", dtype=DType.STR), + MessageSpec(name="reply", dtype=DType.STR), + ], + params={"values": {"canopy": {"UP": "open", "DOWN": "closed"}}}, + ) + node.assign_flow("voice", "assistant") + Pipeline([node]) + + _ask(node, "open the canopy") + + [tool] = seen[0]["tools"] + assert tool["function"]["parameters"]["properties"]["value"]["enum"] == [ + "UP", + "DOWN", + ] + assert '"UP" means canopy open' in tool["function"]["description"] + assert log == [{"canopy": "UP"}, {"reply": "Canopy open."}] diff --git a/backend/tests/flow/test_wren.py b/backend/tests/flow/test_wren.py index a96fde7..c82eec2 100644 --- a/backend/tests/flow/test_wren.py +++ b/backend/tests/flow/test_wren.py @@ -204,3 +204,15 @@ def test_a_reply_says_what_was_applied_and_the_rest_is_unhandled(fake_wren): 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] diff --git a/docs/reference/node-types.md b/docs/reference/node-types.md index ed870e6..e55950b 100644 --- a/docs/reference/node-types.md +++ b/docs/reference/node-types.md @@ -408,7 +408,10 @@ 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`. The command is read +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 model never +learned, and it lands on whichever light it was shown. The command is read in lower case without sentence marks, as the model was trained. !!! note "What the current model cannot do" @@ -440,13 +443,16 @@ on what is wired to it. |---|---|---| | `model` | empty | the model to ask; empty asks the instance's | | `extra_body` | `{"temperature": 0, "reasoning_effort": "none", "keep_alive": "30m"}` | added to every request as it is | +| `values` | empty | for a `str` target, by port: the only values it takes and what the reply says for each, as `{"canopy": {"UP": "open", "DOWN": "closed"}}` | It asks the provider set under **Settings → Assistant**. As for Wren, the wiring is the allowlist: the model is offered the targets and nothing else, never the tools the chat's assistant uses. Its instructions list the context inputs and what each target holds now. A target is set the moment the model calls its tool. The reply then names what was set ("Living room light on, -living room setpoint to 22."), so the model is asked once per request. +living room setpoint to 22."), so the model is asked once per request. A +target with `values` is offered only those, each explained to the model, and +confirmed in its word ("Canopy open."). Any other value is refused. Every request ends in a reply: