Files
app/backend/tests/flow/test_assistant.py
T
stroblme bf63b3d6d4 wren leaves unnamed areas to the assistant; assistant targets take a closed set of values
wren drops a call on an area the command does not name (other than the
user's or, with none named, the living room): "switch on the appliances"
turned on the bathroom light at 0.97. The assistant's `values` setting gives
a str target its only values, explains them in the tool and confirms in the
word set for each ("Canopy open.").
2026-10-22 10:56:20 +02:00

177 lines
6.1 KiB
Python

"""The assistant node: the wiring is its allowlist, and it speaks as it writes."""
import asyncio
import json
from collections.abc import AsyncIterator
from typing import Any
import httpx
import pytest
from fluksio.ai import client
from fluksio.ai import config as ai_config
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes.assistant import UNAVAILABLE, AssistantNode, cut
from fluksio.flow.pipeline import Pipeline
PROVIDER = ai_config.AIConfig(api_base="http://llm:11434/v1", api_key="k", model="m")
def _call(name: str, value: Any) -> dict[str, Any]:
arguments = json.dumps({"value": value})
return {"id": name, "function": {"name": name, "arguments": arguments}}
def _node() -> AssistantNode:
node = AssistantNode(
requires=[
MessageSpec(name="unhandled", port="prompt", dtype=DType.STR),
MessageSpec(name="outdoor", port="outside_temperature", dtype=DType.FLOAT),
],
provides=[
MessageSpec(name="kitchen_light", dtype=DType.BOOL),
MessageSpec(
name="setpoint", port="living_room_setpoint", dtype=DType.FLOAT
),
MessageSpec(name="reply", dtype=DType.STR),
MessageSpec(name="actions", dtype=DType.LIST),
],
)
node.assign_flow("voice", "assistant")
Pipeline([node])
return node
def _ask(node: AssistantNode, prompt: str) -> dict[str, Any] | None:
async def scenario() -> dict[str, Any] | None:
await node.start()
inputs = {"voice.unhandled": prompt, "voice.outdoor": 7.5}
return await asyncio.to_thread(node.execute, inputs)
return asyncio.run(scenario())
def test_a_sentence_is_cut_once_it_ends():
assert cut("Done. It is 7.5 degr") == (["Done."], "It is 7.5 degr")
assert cut("Lights off! Anything else? ") == (["Lights off!", "Anything else?"], "")
def test_only_wired_targets_are_tools_a_change_is_confirmed_and_an_answer_streamed(
monkeypatch: pytest.MonkeyPatch,
):
log: list[Any] = []
seen: list[dict[str, Any]] = []
rounds = iter(
[
# The model reaches for a tool nothing is wired to, as well.
[
_call("kitchen_light", False),
_call("delete_flow", "demo"),
_call("living_room_setpoint", 22),
],
["It is 7.5", " degrees outside. Wear a ", "jacket."],
]
)
async def completion(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]:
seen.append(kwargs)
step = next(rounds)
if isinstance(step[0], dict):
yield {"role": "assistant", "content": None, "tool_calls": step}
return
for piece in step:
log.append(piece)
yield piece
yield {"role": "assistant", "content": "".join(step)}
monkeypatch.setattr(ai_config, "load", lambda: PROVIDER)
monkeypatch.setattr(client, "stream_completion", completion)
monkeypatch.setattr(AssistantNode, "emit", lambda self, values: log.append(values))
node = _node()
out = _ask(node, "turn off the kitchen light and make it warmer")
tools = {tool["function"]["name"] for tool in seen[0]["tools"]}
assert tools == {"kitchen_light", "living_room_setpoint"}
system = seen[0]["messages"][0]["content"]
assert "outside_temperature: 7.5" in system
assert seen[0]["extra"]["reasoning_effort"] == "none"
# Set as called, and confirmed without asking the model again.
assert len(seen) == 1
assert log == [
{"kitchen_light": False},
{"living_room_setpoint": 22.0},
{"reply": "Kitchen light off, living room setpoint to 22."},
]
assert out is not None
assert [a["message"] for a in out["voice.actions"]] == [
"voice.kitchen_light",
"voice.setpoint",
]
log.clear()
_ask(node, "what is it like outside")
# The first sentence is said before the second is written.
assert log == [
"It is 7.5",
" degrees outside. Wear a ",
{"reply": "It is 7.5 degrees outside."},
"jacket.",
{"reply": "Wear a jacket."},
]
def test_no_provider_or_one_that_does_not_answer_is_said(
monkeypatch: pytest.MonkeyPatch,
):
said: list[dict[str, Any]] = []
monkeypatch.setattr(AssistantNode, "emit", lambda self, values: said.append(values))
async def refused(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]:
raise httpx.ConnectError("refused")
yield ""
monkeypatch.setattr(client, "stream_completion", refused)
for provider in (None, PROVIDER):
monkeypatch.setattr(ai_config, "load", lambda provider=provider: provider)
node = _node()
_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."}]