Files
Melvin Strobl 261cf1ec2a
Docs / docs (push) Successful in 39s
Playwright Tests / test-playwright (1, 2) (push) Successful in 4m26s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m25s
pre-commit / pre-commit (push) Successful in 3m24s
Test Backend / test-backend (3.12) (push) Successful in 6m3s
Test Backend / test-backend (3.14) (push) Successful in 5m41s
Compose Smoke Test / test-compose (push) Successful in 35s
Playwright Tests / merge-reports (push) Successful in 1m20s
Assistant answers general questions and stays silent for overheard talk ("Ignored." is not said); stt prompt setting sent as the transcription prompt
2026-11-06 07:27:44 +01:00

193 lines
6.7 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 IGNORED, 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_overheard_talk_gets_no_answer(monkeypatch: pytest.MonkeyPatch):
said: list[dict[str, Any]] = []
async def completion(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]:
yield IGNORED
yield {"role": "assistant", "content": IGNORED}
monkeypatch.setattr(ai_config, "load", lambda: PROVIDER)
monkeypatch.setattr(client, "stream_completion", completion)
monkeypatch.setattr(AssistantNode, "emit", lambda self, values: said.append(values))
_ask(_node(), "I don't know what to get, Francis.")
assert said == []
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."}]