wren applies all/every commands itself, answers temperatures, reads aliases and number words; tts says units and clock times
wren's model answers "turn off all lights" with one call, and at 0.98 a
living room light switched on. The node now applies such a command to every
wired target of the kind. Number words become digits ("in five minutes"
lost its delay), aliases put the house's words and known mishearings in the
model's, and sensors.temperature is answered from <area>_temperature or
outdoor. tts writes out units and clock times before speaking.
This commit is contained in:
@@ -44,6 +44,15 @@ PLAYING_SLACK_S = 1.0
|
||||
#: The partial for an utterance that came back empty, often a wake word that
|
||||
#: was not one, or was lost: shown on the caption, never spoken.
|
||||
NOTHING_HEARD = "Didn't catch that."
|
||||
#: Units read out after a number, the longer first. Kokoro says "22 °C" as
|
||||
#: "22 degrees C" and "3.4 kWh" letter by letter.
|
||||
UNITS = {
|
||||
"°C": "degrees",
|
||||
"°": "degrees",
|
||||
"kWh": "kilowatt hours",
|
||||
"kW": "kilowatts",
|
||||
"W": "watts",
|
||||
}
|
||||
|
||||
|
||||
def _check(specs: list[MessageSpec], ports: dict[str, DType], node: str) -> None:
|
||||
@@ -70,6 +79,23 @@ def sentences(text: str) -> list[str]:
|
||||
return [s for s in (p.strip() for p in re.split(r"(?<=[.!?])\s+", text)) if s]
|
||||
|
||||
|
||||
def speakable(text: str) -> str:
|
||||
"""``text`` as it is said: a unit after a number in words, and a clock
|
||||
time as its hour and minutes, since "07:30" is read "zero seven thirty"."""
|
||||
units = "|".join(map(re.escape, UNITS))
|
||||
text = re.sub(
|
||||
rf"(\d)\s*({units})(?![A-Za-z])", lambda m: f"{m[1]} {UNITS[m[2]]}", text
|
||||
)
|
||||
return re.sub(r"\b([01]?\d|2[0-3]):([0-5]\d)\b", _clock, text)
|
||||
|
||||
|
||||
def _clock(time: re.Match[str]) -> str:
|
||||
hour, minute = int(time[1]), int(time[2])
|
||||
if minute == 0:
|
||||
return f"{hour} o'clock"
|
||||
return f"{hour} oh {minute}" if minute < 10 else f"{hour} {minute}"
|
||||
|
||||
|
||||
def mp3_seconds(data: bytes) -> float:
|
||||
"""An MP3's length, from the frame count LAME writes into its first frame."""
|
||||
header = int.from_bytes(data[:4], "big")
|
||||
@@ -358,7 +384,7 @@ class TtsNode(_Speech):
|
||||
if not text.strip() or not self._reachable(url):
|
||||
return None
|
||||
with self._lock:
|
||||
for sentence in sentences(text):
|
||||
for sentence in sentences(speakable(text)):
|
||||
reference = self._spoken(url, sentence)
|
||||
now = time.monotonic()
|
||||
if now < self._until + PLAYING_SLACK_S:
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from threadpoolctl import threadpool_limits
|
||||
@@ -60,6 +60,7 @@ KIND_WORDS = {
|
||||
}
|
||||
|
||||
#: What the node reads, and the types each may be. Only the prompt wakes it.
|
||||
#: Besides these, ``<area>_temperature`` inputs answer "how warm is it".
|
||||
INPUTS = {
|
||||
"prompt": {DType.STR},
|
||||
"presence": {DType.STR},
|
||||
@@ -125,6 +126,84 @@ _SAID = {
|
||||
#: shown, wired or not, or "the bathroom light" lands on a light that is.
|
||||
_TRAINED = {"living room": ("light", "media", "heat")}
|
||||
|
||||
#: wren's areas outside, whose temperature is the ``outdoor`` reading.
|
||||
_OUTSIDE = {"garden", "patio", "garage", "driveway", "balcony"}
|
||||
|
||||
#: Number words, which wren was trained on as digits.
|
||||
_ONES = (
|
||||
"zero one two three four five six seven eight nine ten eleven twelve "
|
||||
"thirteen fourteen fifteen sixteen seventeen eighteen nineteen"
|
||||
).split()
|
||||
_TENS = "twenty thirty forty fifty sixty seventy eighty ninety".split()
|
||||
_NUMBERS = {w: n for n, w in enumerate(_ONES)} | {
|
||||
w: 20 + 10 * n for n, w in enumerate(_TENS)
|
||||
}
|
||||
|
||||
#: A command for a whole group of devices: "turn off all lights".
|
||||
_GROUP = re.compile(r"\b(all|every|everything|everywhere)\b")
|
||||
#: What leaves such a command to the model and the assistant: a question, or
|
||||
#: an exception to the group.
|
||||
_NOT_GROUP = re.compile(
|
||||
r"^(is|are|was|were|do|does|did|can|could|what|which|how|why|when|where|who)\b"
|
||||
r"|\b(except|but|apart from|other than)\b"
|
||||
)
|
||||
#: The kind a group command names, by its word. None named is every kind.
|
||||
_GROUP_KINDS = {
|
||||
"light": "light",
|
||||
"lights": "light",
|
||||
"lamps": "light",
|
||||
"music": "media",
|
||||
"media": "media",
|
||||
"tv": "media",
|
||||
"shutters": "shutter",
|
||||
"blinds": "shutter",
|
||||
"heating": "heat",
|
||||
"watering": "water",
|
||||
"sprinklers": "water",
|
||||
}
|
||||
#: The call a group command makes on each kind, by its action word.
|
||||
_GROUP_TOOLS = {
|
||||
"on": {"light": "lights.on", "media": "media.play", "water": "watering.start"},
|
||||
"off": {
|
||||
"light": "lights.off",
|
||||
"media": "media.stop",
|
||||
"heat": "heating.off",
|
||||
"water": "watering.stop",
|
||||
},
|
||||
"open": {"shutter": "shutters.up"},
|
||||
"up": {"shutter": "shutters.up"},
|
||||
"raise": {"shutter": "shutters.up"},
|
||||
"close": {"shutter": "shutters.down"},
|
||||
"down": {"shutter": "shutters.down"},
|
||||
"lower": {"shutter": "shutters.down"},
|
||||
"stop": {
|
||||
"media": "media.stop",
|
||||
"shutter": "shutters.stop",
|
||||
"water": "watering.stop",
|
||||
},
|
||||
}
|
||||
#: A group call, said: "all lights off".
|
||||
_GROUP_SAID = {
|
||||
"lights.on": "lights on",
|
||||
"lights.off": "lights off",
|
||||
"media.play": "media playing",
|
||||
"media.stop": "media stopped",
|
||||
"heating.off": "heating off",
|
||||
"shutters.up": "shutters up",
|
||||
"shutters.down": "shutters down",
|
||||
"shutters.stop": "shutters stopped",
|
||||
"watering.start": "watering on",
|
||||
"watering.stop": "watering off",
|
||||
}
|
||||
|
||||
|
||||
class _Call(NamedTuple):
|
||||
"""A call the node makes itself, shaped as the model's."""
|
||||
|
||||
tool: str
|
||||
args: dict[str, Any]
|
||||
delay_s: float | None = None
|
||||
|
||||
|
||||
def _device(port: str, areas: dict[str, str]) -> tuple[str, str] | None:
|
||||
"""The (area, kind) a port name stands for, or None."""
|
||||
@@ -197,6 +276,39 @@ def _plain(command: str) -> str:
|
||||
return re.sub(r"[.,!?;:]+(?=\s|$)", "", command).strip().lower()
|
||||
|
||||
|
||||
def _aliased(prompt: str, aliases: dict[str, str]) -> str:
|
||||
"""``prompt`` with the house's words in wren's, whole words in any case:
|
||||
"the traverse" as "the living room light", "page too" as "patio"."""
|
||||
if not aliases:
|
||||
return prompt
|
||||
words = {k.lower(): v for k, v in aliases.items()}
|
||||
pattern = "|".join(map(re.escape, sorted(words, key=len, reverse=True)))
|
||||
return re.sub(
|
||||
rf"\b({pattern})\b",
|
||||
lambda m: words[m[1].lower()],
|
||||
prompt,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _digits(command: str) -> str:
|
||||
"""Number words as digits: "in five minutes" kept its delay only as "in 5
|
||||
minutes", and "twenty two degrees" read as 21."""
|
||||
tens, ones = "|".join(_TENS), "|".join(_ONES[1:10])
|
||||
command = re.sub(
|
||||
rf"\b({tens})[ -]({ones})\b",
|
||||
lambda m: str(_NUMBERS[m[1]] + _NUMBERS[m[2]]),
|
||||
command,
|
||||
)
|
||||
words = "|".join(_NUMBERS)
|
||||
return re.sub(rf"\b({words})\b", lambda m: str(_NUMBERS[m[1]]), command)
|
||||
|
||||
|
||||
def _named(areas: Iterable[str], command: str) -> list[str]:
|
||||
"""The areas ``command`` names, in the house's order."""
|
||||
return [a for a in areas if re.search(rf"\b{re.escape(a)}\b", command)]
|
||||
|
||||
|
||||
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.
|
||||
@@ -206,8 +318,7 @@ def _asked(house: Any, command: str) -> set[str]:
|
||||
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"}
|
||||
return set(_named(house.areas, command)) or {house.user_area, "living room"}
|
||||
|
||||
|
||||
def _text(call: Any) -> str:
|
||||
@@ -220,8 +331,15 @@ class WrenNode(Node):
|
||||
"""Turn the command on ``prompt`` into values for the devices wired to it.
|
||||
|
||||
Inputs: ``prompt`` (the command, and the only one that wakes the node),
|
||||
``presence`` (the area the user is in), ``outdoor`` (°C) and ``dark``
|
||||
(otherwise read off the clock: before 07:00 or from 18:00).
|
||||
``presence`` (the area the user is in), ``outdoor`` (°C), ``dark``
|
||||
(otherwise read off the clock: before 07:00 or from 18:00) and any
|
||||
``<area>_temperature``. A question for a temperature is answered from
|
||||
that reading, or from ``outdoor`` for an area outside.
|
||||
|
||||
The command reaches the model with the ``aliases`` applied and its number
|
||||
words as digits. A command for all of a kind ("turn off all lights",
|
||||
"everything off in the office") is not the model's: the node applies it
|
||||
to every wired device of that kind itself.
|
||||
|
||||
Outputs: ``actions``, a list of records, one per call and target,
|
||||
``reply``, a sentence saying what was applied ("Kitchen light on."), or
|
||||
@@ -246,8 +364,13 @@ class WrenNode(Node):
|
||||
le=1,
|
||||
description="Below this, nothing is written; the calls are still reported.",
|
||||
)
|
||||
aliases: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="The house's own words, and words heard wrong, in wren's: "
|
||||
'{"traverse": "living room light", "page too": "patio"}.',
|
||||
)
|
||||
|
||||
__slots__ = ("cfg", "devices")
|
||||
__slots__ = ("cfg", "devices", "readings")
|
||||
|
||||
# A call is a command to a device; doing it twice is not doing it once.
|
||||
idempotent = False
|
||||
@@ -264,12 +387,18 @@ class WrenNode(Node):
|
||||
inputs = self._normalize_ports(requires)
|
||||
outputs = self._normalize_ports(provides)
|
||||
|
||||
areas = {area.replace(" ", "_"): area for area in wren.AREAS}
|
||||
#: The area each ``<area>_temperature`` input reads, to its port.
|
||||
self.readings: dict[str, str] = {}
|
||||
for spec in inputs:
|
||||
allowed = INPUTS.get(spec.port)
|
||||
reading = areas.get(spec.port.removesuffix("_temperature"))
|
||||
if reading and spec.port.endswith("_temperature"):
|
||||
self.readings[reading] = spec.port
|
||||
allowed = INPUTS["outdoor"] if reading else INPUTS.get(spec.port)
|
||||
if allowed is None:
|
||||
raise ValueError(
|
||||
f"'{spec.port}' is not an input of wren. It reads "
|
||||
f"{', '.join(INPUTS)}."
|
||||
f"{', '.join(INPUTS)} and <area>_temperature."
|
||||
)
|
||||
if spec.dtype not in allowed:
|
||||
raise ValueError(
|
||||
@@ -279,7 +408,6 @@ class WrenNode(Node):
|
||||
if not any(spec.port == "prompt" for spec in inputs):
|
||||
raise ValueError("wren needs a 'prompt' input: the command to act on.")
|
||||
|
||||
areas = {area.replace(" ", "_"): area for area in wren.AREAS}
|
||||
#: (area, kind) to the ports standing for that device.
|
||||
self.devices: dict[tuple[str, str], list[str]] = {}
|
||||
for spec in outputs:
|
||||
@@ -364,6 +492,7 @@ class WrenNode(Node):
|
||||
presence: str | None = None,
|
||||
outdoor: float | None = None,
|
||||
dark: bool | None = None,
|
||||
**readings: float | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not prompt.strip():
|
||||
# An empty command is nothing to do, which is what a flow input
|
||||
@@ -372,19 +501,33 @@ 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)
|
||||
prompt = _aliased(prompt, self.cfg.aliases)
|
||||
command = _digits(_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(command, house)
|
||||
group = self._group(command, house.areas)
|
||||
if group is not None:
|
||||
calls, confidence, asked = group, 1.0, set(house.areas)
|
||||
else:
|
||||
# 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(command, house)
|
||||
confidence = round(float(confidence), 3)
|
||||
write = self.cfg.apply and confidence >= self.cfg.min_confidence
|
||||
writes: dict[str, Any] = {}
|
||||
actions: list[dict[str, Any]] = []
|
||||
said: list[str] = []
|
||||
answers: list[str] = []
|
||||
for call in calls:
|
||||
area = str(call.args.get("area", ""))
|
||||
if (
|
||||
call.tool == "sensors.temperature"
|
||||
and area in asked
|
||||
and confidence >= self.cfg.min_confidence
|
||||
and (answer := self._temperature(area, outdoor, readings))
|
||||
):
|
||||
answers.append(answer)
|
||||
area, _, kind = str(call.args.get("id", "")).partition(":")
|
||||
ports = self.devices.get((area, kind), [])
|
||||
if call.tool.split(".")[0] != _TOOLS.get(kind) or area not in asked:
|
||||
@@ -419,19 +562,75 @@ class WrenNode(Node):
|
||||
done = done or applied
|
||||
if done:
|
||||
said.append(_said(call, area))
|
||||
if group is not None and said:
|
||||
said = self._group_said(group, command, house.areas)
|
||||
|
||||
wired = {spec.port for spec in self.output_ports}
|
||||
if ACTIONS in wired:
|
||||
writes[ACTIONS] = actions
|
||||
if said and REPLY in wired:
|
||||
if said:
|
||||
reply = ", ".join(said)
|
||||
writes[REPLY] = reply[0].upper() + reply[1:] + "."
|
||||
if not said and UNHANDLED in wired:
|
||||
answers.insert(0, reply[0].upper() + reply[1:] + ".")
|
||||
if answers and REPLY in wired:
|
||||
writes[REPLY] = " ".join(answers)
|
||||
if not answers and UNHANDLED in wired:
|
||||
writes[UNHANDLED] = prompt
|
||||
if not said and REPLY in wired and not self._passed_on():
|
||||
if not answers and REPLY in wired and not self._passed_on():
|
||||
writes[REPLY] = CANT
|
||||
return writes or None
|
||||
|
||||
def _group(self, command: str, areas: Iterable[str]) -> list[_Call] | None:
|
||||
"""The calls a command for a whole group means, one per wired device,
|
||||
or None when it is not one: "turn off all lights" is every light.
|
||||
|
||||
Not the model's to answer: it makes one call of it, and "turn off all
|
||||
lights" switched a living room light on at 0.98.
|
||||
"""
|
||||
action = re.search(rf"\b({'|'.join(_GROUP_TOOLS)})\b", command)
|
||||
if not _GROUP.search(command) or _NOT_GROUP.search(command) or not action:
|
||||
return None
|
||||
tools = _GROUP_TOOLS[action[1]]
|
||||
kinds = {_GROUP_KINDS[w] for w in command.split() if w in _GROUP_KINDS}
|
||||
named = _named(areas, command)
|
||||
delay = re.search(r"\bin (\d+) minutes?\b", command)
|
||||
seconds = int(delay[1]) * 60.0 if delay else None
|
||||
calls = [
|
||||
_Call(tools[kind], {"id": f"{area}:{kind}"}, seconds)
|
||||
for area, kind in self.devices
|
||||
if kind in tools
|
||||
and (not kinds or kind in kinds)
|
||||
and (not named or area in named)
|
||||
]
|
||||
return calls or None
|
||||
|
||||
@staticmethod
|
||||
def _group_said(
|
||||
calls: list[_Call], command: str, areas: Iterable[str]
|
||||
) -> list[str]:
|
||||
"""A group command's reply: "all lights off", "lights off in the office"."""
|
||||
said = list(dict.fromkeys(_GROUP_SAID[call.tool] for call in calls))
|
||||
named = _named(areas, command)
|
||||
if named:
|
||||
said[-1] += " in the " + " and the ".join(named)
|
||||
else:
|
||||
said[0] = "all " + said[0]
|
||||
if calls[0].delay_s:
|
||||
minutes = round(calls[0].delay_s / 60)
|
||||
said[-1] += f" in {minutes} minute{'s' * (minutes != 1)}"
|
||||
return said
|
||||
|
||||
def _temperature(
|
||||
self, area: str, outdoor: float | None, readings: dict[str, float | None]
|
||||
) -> str | None:
|
||||
""" "It's 8 degrees outside.", from the reading wired for the area."""
|
||||
value = readings.get(self.readings.get(area, ""))
|
||||
where = f"in the {area}"
|
||||
if value is None and area in _OUTSIDE:
|
||||
value, where = outdoor, "outside"
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
return None
|
||||
return f"It's {round(value)} degrees {where}."
|
||||
|
||||
def _passed_on(self) -> bool:
|
||||
"""Whether a node reads ``unhandled``, such as an assistant."""
|
||||
name = next((s.name for s in self.output_ports if s.port == UNHANDLED), "")
|
||||
|
||||
@@ -41,9 +41,11 @@ class FakeWren:
|
||||
def __init__(self) -> None:
|
||||
self.answer: tuple[list[Any], float] = ([], 1.0)
|
||||
self.houses: list[_House] = []
|
||||
self.commands: list[str] = []
|
||||
|
||||
def act(self, command: str, house: _House) -> tuple[list[Any], float]:
|
||||
self.houses.append(house)
|
||||
self.commands.append(command)
|
||||
return self.answer
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from fluksio.ai import client
|
||||
from fluksio.flow.messages import DType, MessageSpec
|
||||
from fluksio.flow.nodes.speech import SttNode, TtsNode, sentences
|
||||
from fluksio.flow.nodes.speech import SttNode, TtsNode, sentences, speakable
|
||||
from fluksio.flow.pipeline import Pipeline
|
||||
|
||||
URL = "http://speech:8100"
|
||||
@@ -40,6 +40,15 @@ def test_sentences_are_cut_after_their_ending():
|
||||
]
|
||||
|
||||
|
||||
def test_units_and_clock_times_are_said_as_words():
|
||||
said = speakable("22 °C and 21.5° at 07:30, 3.4 kWh, 505 W, 2 kW, 7:05 or 18:00.")
|
||||
assert said == (
|
||||
"22 degrees and 21.5 degrees at 7 30, 3.4 kilowatt hours, 505 watts, "
|
||||
"2 kilowatts, 7 oh 5 or 18 o'clock."
|
||||
)
|
||||
assert speakable("Kitchen light on, 3 Watts.") == "Kitchen light on, 3 Watts."
|
||||
|
||||
|
||||
def test_an_utterance_is_one_session_committed_when_listening_ends(monkeypatch):
|
||||
heard: list[bytes] = []
|
||||
|
||||
|
||||
@@ -216,3 +216,85 @@ def test_a_call_on_an_area_the_command_does_not_name_is_not_applied(fake_wren):
|
||||
assert out is not None
|
||||
assert "house.bathroom_light" not in out
|
||||
assert [a["applied"] for a in out["house.actions"]] == [False]
|
||||
|
||||
|
||||
def _speaking(*targets: tuple[str, DType], inputs=(), **params: object) -> WrenNode:
|
||||
node = WrenNode(
|
||||
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR), *inputs],
|
||||
provides=[
|
||||
*(MessageSpec(name=port, dtype=dtype) for port, dtype in targets),
|
||||
MessageSpec(name="reply", dtype=DType.STR),
|
||||
MessageSpec(name="unhandled", dtype=DType.STR),
|
||||
],
|
||||
params={"apply": True, **params},
|
||||
)
|
||||
node.assign_flow("house", "control")
|
||||
return node
|
||||
|
||||
|
||||
def test_the_house_words_and_number_words_reach_the_model_as_its_own(fake_wren):
|
||||
node = _speaking(("patio_light", DType.BOOL), aliases={"Page Too": "patio"})
|
||||
fake_wren.answer = ([], 0.99)
|
||||
|
||||
out = node.execute({"house.command": "Turn on the page too light in five minutes."})
|
||||
|
||||
assert fake_wren.commands == ["turn on the patio light in 5 minutes"]
|
||||
# The assistant reads the house's words too, as they were said otherwise.
|
||||
assert out == {
|
||||
"house.unhandled": "Turn on the patio light in five minutes.",
|
||||
"house.reply": CANT,
|
||||
}
|
||||
|
||||
|
||||
def test_a_command_for_all_of_a_kind_reaches_every_wired_one(fake_wren):
|
||||
lights = [(f"{area}_light", DType.BOOL) for area in ("kitchen", "office")]
|
||||
node = _speaking(*lights, ("office_shutter", DType.STR))
|
||||
|
||||
out = node.execute({"house.command": "Turn off all lights."})
|
||||
assert out == {
|
||||
"house.kitchen_light": False,
|
||||
"house.office_light": False,
|
||||
"house.reply": "All lights off.",
|
||||
}
|
||||
out = node.execute({"house.command": "turn everything off in the office"})
|
||||
assert out == {
|
||||
"house.office_light": False,
|
||||
"house.reply": "Lights off in the office.",
|
||||
}
|
||||
assert fake_wren.commands == [] # the node's, not the model's
|
||||
|
||||
# A question or an exception is the model's again.
|
||||
fake_wren.answer = ([], 0.99)
|
||||
node.execute({"house.command": "are all lights off"})
|
||||
node.execute({"house.command": "turn off all lights but the kitchen"})
|
||||
assert len(fake_wren.commands) == 2
|
||||
|
||||
|
||||
def test_a_question_for_a_temperature_is_answered_from_its_reading(fake_wren):
|
||||
node = _speaking(
|
||||
inputs=[
|
||||
MessageSpec(name="outside", port="outdoor", dtype=DType.FLOAT),
|
||||
MessageSpec(
|
||||
name="kitchen_temp", port="kitchen_temperature", dtype=DType.FLOAT
|
||||
),
|
||||
]
|
||||
)
|
||||
assert node.readings == {"kitchen": "kitchen_temperature"}
|
||||
readings = {"house.outside": 7.6, "house.kitchen_temp": 21.2}
|
||||
|
||||
for area, said in [("garden", "outside"), ("kitchen", "in the kitchen")]:
|
||||
fake_wren.answer = ([FakeCall("sensors.temperature", {"area": area})], 0.99)
|
||||
command = f"what is the temperature in the {area}"
|
||||
out = node.execute({"house.command": command, **readings})
|
||||
degrees = 8 if area == "garden" else 21
|
||||
assert out == {"house.reply": f"It's {degrees} degrees {said}."}
|
||||
|
||||
# No reading for the bedroom, or a model unsure of it: the assistant's.
|
||||
for area, confidence in [("bedroom", 0.99), ("kitchen", 0.5)]:
|
||||
fake_wren.answer = (
|
||||
[FakeCall("sensors.temperature", {"area": area})],
|
||||
confidence,
|
||||
)
|
||||
command = f"how warm is it in the {area}"
|
||||
out = node.execute({"house.command": command, **readings})
|
||||
assert out == {"house.unhandled": command, "house.reply": CANT}
|
||||
|
||||
@@ -354,6 +354,7 @@ reports a missing dependency.
|
||||
| `presence` | `str` | the area the user is in; anything else reads as away |
|
||||
| `outdoor` | `float` or `int` | °C; 20 without it, clamped to −5…30 |
|
||||
| `dark` | `bool` | without it, dark before 07:00 and from 18:00 |
|
||||
| `<area>_temperature` | `float` or `int` | °C, read when the command asks how warm the area is |
|
||||
|
||||
The outputs are `actions`, a list of records, `reply`, `unhandled`, and any
|
||||
number of **targets**. A
|
||||
@@ -391,6 +392,7 @@ reads.
|
||||
|---|---|---|
|
||||
| `apply` | `false` | write to the targets; off, the node only reports |
|
||||
| `min_confidence` | `0.93` | below this nothing is written |
|
||||
| `aliases` | empty | the house's own words, or words the recogniser hears wrong, in the model's: `{"traverse": "living room light", "page too": "patio"}` |
|
||||
|
||||
Each record in `actions` contains `message`, `value`, `applied`, `delay_s`,
|
||||
`call`, and `confidence`. Unwired calls have an empty `message` and are not
|
||||
@@ -412,7 +414,22 @@ 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.
|
||||
in lower case without sentence marks and with number words as digits ("in
|
||||
five minutes" as "in 5 minutes"), as the model was trained. `aliases` are
|
||||
applied before, whole words in any case, and `unhandled` carries the prompt
|
||||
with them.
|
||||
|
||||
A command for all of a kind, such as "turn off all lights", "switch every
|
||||
light on in 5 minutes" or "everything off in the office", is not the
|
||||
model's. The node applies it to every wired target of that kind, within the
|
||||
areas it names, and replies "All lights off." or "Lights off in the
|
||||
office.". A question ("are all lights off") or an exception ("all but the
|
||||
kitchen") goes to the model as any other command.
|
||||
|
||||
A question for an area's temperature ("how warm is it in the kitchen") is
|
||||
answered from its `<area>_temperature` input, or from `outdoor` for an area
|
||||
outside, as "It's 8 degrees outside.". Without a reading it goes on to
|
||||
`unhandled`.
|
||||
|
||||
!!! note "What the current model cannot do"
|
||||
|
||||
@@ -519,7 +536,11 @@ such as one that sends whole clips.
|
||||
| `voice` | `af_heart` | one of Kokoro's voices, such as `bm_george` |
|
||||
|
||||
Each sentence is one call to `{url}/v1/audio/speech` and published as soon as
|
||||
it is spoken. Calls to one server share a connection, which Speech to text opens
|
||||
it is spoken. Units after a number and clock times are written out first,
|
||||
since a speech model reads them letter by letter: "22 °C" is said "22
|
||||
degrees", "3.4 kWh" "3.4 kilowatt hours" and "07:30" "7 30". How a word
|
||||
itself is pronounced is the server's: `parrot serve --lexicon` gives words
|
||||
their phonemes. Calls to one server share a connection, which Speech to text opens
|
||||
when an utterance starts, so a reply's first sentence does not wait for one. A sentence spoken before in the same voice is reused rather than
|
||||
spoken again, for the last 256 sentences. A reference carries `"append": true`
|
||||
while the sentences before it are still playing, which queues it on a panel's
|
||||
|
||||
Reference in New Issue
Block a user