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
193 lines
6.7 KiB
Python
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."}]
|