3ee67cb4d6
- Refactor `run_local_command` to manage subprocesses more effectively, ensuring child processes are terminated on timeout. - Introduce `_terminate` function to handle process group termination and capture output. - Implement `_command_output` to format command results with a character limit. - Add local intent recognition in `intents.py` to handle commands like "stop", "go to sleep", and "come here" without server interaction. - Normalize user input to match local intents while stripping filler words. - Update tests to cover new local intent functionality and ensure proper command handling. - Enhance speech processing to handle abbreviations and improve spoken output clarity.
207 lines
8.8 KiB
Python
207 lines
8.8 KiB
Python
"""Things you say to the pet that the server has no business answering.
|
|
|
|
"stop", "come here", "go to sleep", "say that again", "use your normal voice" —
|
|
none of these are questions for Bolt's brain. They're commands to the *body*,
|
|
and today every one of them costs a full turn: Deepgram, a `/desk/converse`
|
|
round trip, a model deciding to emit `petctl`, then ElevenLabs. Two to four
|
|
seconds and three network hops to make the pet walk left, and it only works at
|
|
all if the server's prompt happens to advertise the right verb — which is
|
|
exactly why `petctl voice reset` needs a block in the server's pet prompt (see
|
|
CLAUDE.md) or the model never emits it. Recognising the phrase here removes
|
|
both the latency and that coupling: "go back to your normal voice" works
|
|
whether or not the server was ever told the voice can be reset.
|
|
|
|
The whole design problem is **not stealing real requests**. Three rules keep
|
|
it honest:
|
|
|
|
1. **Whole-utterance, exact match after normalisation.** Never substring. So
|
|
"stop" is an intent and "stop the docker container" is a question for the
|
|
server — the distinction a substring match would destroy.
|
|
2. **The phrase table is closed and small.** Every entry is something with no
|
|
plausible reading as a request for Bolt to *do work*. Anything arguable
|
|
("no thanks", "nothing") is deliberately absent — see rule 3 for why a
|
|
wrong guess is expensive.
|
|
3. **Nothing is recognised mid-conversation.** The controller skips this
|
|
entirely on a follow-up turn: if Bolt just asked you something, your answer
|
|
belongs to him, and swallowing "never mind" locally would leave the server
|
|
holding a question it never got an answer to. Local intents are only ever
|
|
for turns *you* started.
|
|
|
|
Both sides of the comparison go through `normalize()` — the table is
|
|
canonicalised at import — so phrases can be written the way a person says them
|
|
("go back to your normal voice") without every variant having to be spelled
|
|
out. Filler is dropped from anywhere, not just the ends, because STT scatters
|
|
it ("hey bolt, could you please just stop now").
|
|
|
|
Pure classification, like pet_actions.parse: this module decides *what was
|
|
meant* and hands back an action in the same shape pet_actions produces, so
|
|
`controller.action` and `PetWindow.apply_action` need no new vocabulary. The
|
|
effects live in controller._handle_local_intent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
# Words with no bearing on any command in the table, dropped wherever they
|
|
# appear. Kept deliberately short: every entry here is a word that can't
|
|
# distinguish one of these phrases from another, and adding one that can is how
|
|
# two intents quietly collide (the builder below raises if that happens).
|
|
_FILLER = frozenset({
|
|
"the", "a", "an", "my", "your", "yours", "its", "to", "of", "and",
|
|
"please", "just", "that", "some", "bolt", "thunderbolt", "pet", "buddy",
|
|
})
|
|
|
|
# Dropped only from the front — the politeness/address ramp STT reliably
|
|
# prefixes. Not safe to drop mid-phrase (a bare "do" or "go" carries meaning
|
|
# elsewhere), which is why this is separate from _FILLER.
|
|
_LEADING_FILLER = frozenset({
|
|
"hey", "hi", "hello", "yo", "ok", "okay", "um", "uh", "er", "so",
|
|
"can", "could", "would", "will", "you", "i", "id", "like", "lets",
|
|
"let", "us", "do", "go", "then", "now",
|
|
})
|
|
|
|
_TRAILING_FILLER = frozenset({
|
|
"ok", "okay", "thanks", "thank", "you", "boy", "already", "now",
|
|
})
|
|
|
|
_KEEP = re.compile(r"[^a-z0-9 ]+")
|
|
|
|
|
|
def normalize(text: str) -> str:
|
|
"""Reduce an utterance to the bare command, or "" if nothing is left.
|
|
|
|
Lowercase, punctuation stripped (STT punctuates inconsistently), filler
|
|
dropped. Not a stemmer and deliberately not clever — its only job is to
|
|
make the same command spoken two ways land on the same string, without
|
|
ever turning one command into a different one."""
|
|
words = [word for word in _KEEP.sub(" ", (text or "").lower()).split()
|
|
if word not in _FILLER]
|
|
while words and words[0] in _LEADING_FILLER:
|
|
words.pop(0)
|
|
while words and words[-1] in _TRAILING_FILLER:
|
|
words.pop()
|
|
return " ".join(words)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Intent:
|
|
"""One recognised local command.
|
|
|
|
*action* is a pet_actions-shaped dict for the UI (or None when there's
|
|
nothing for the body to do); *speak* is what to say out loud, empty for the
|
|
intents where doing the thing silently *is* the acknowledgement — the pet
|
|
visibly moves, and a spoken confirmation would only make it slower. "stop"
|
|
in particular has to be silent: answering "okay!" when told to be quiet is
|
|
a comedy sketch, not a feature.
|
|
"""
|
|
|
|
name: str
|
|
action: Optional[dict] = None
|
|
speak: str = ""
|
|
|
|
|
|
# Intent -> (the Intent, the phrases that mean it, written as spoken).
|
|
_TABLE: tuple[tuple[Intent, tuple[str, ...]], ...] = (
|
|
(
|
|
Intent("stop"),
|
|
("stop", "stop talking", "stop it", "be quiet", "quiet", "shut up",
|
|
"hush", "never mind", "nevermind", "forget it", "cancel",
|
|
"cancel that", "drop it", "enough"),
|
|
),
|
|
(
|
|
Intent("nap", {"action": "nap", "enabled": True}, "Night."),
|
|
("go to sleep", "take a nap", "have a nap", "go to bed", "bedtime",
|
|
"goodnight", "good night", "get some rest"),
|
|
),
|
|
(
|
|
Intent("wake", {"action": "nap", "enabled": False}, "I'm up."),
|
|
("wake up", "get up", "rise and shine", "you're awake", "are you awake"),
|
|
),
|
|
(
|
|
Intent("come", {"action": "move", "anchor": "cursor"}),
|
|
("come here", "come to me", "come back", "over here", "follow me",
|
|
"follow my cursor"),
|
|
),
|
|
(
|
|
Intent("go_away", {"action": "move", "anchor": "bottom-right"}),
|
|
("go away", "move over", "move out of the way", "get out of the way",
|
|
"out of the way", "hide", "get lost", "shoo", "scram",
|
|
"go somewhere else"),
|
|
),
|
|
(
|
|
Intent("repeat"), # answered from history by the controller
|
|
("say that again", "say again", "repeat that", "repeat",
|
|
"what did you say", "what was that", "come again", "one more time",
|
|
"again", "sorry what"),
|
|
),
|
|
(
|
|
Intent("wander_on", {"action": "wander", "enabled": True}),
|
|
("go for a walk", "wander", "wander around", "walk around", "explore",
|
|
"stretch your legs", "roam"),
|
|
),
|
|
(
|
|
Intent("wander_off", {"action": "wander", "enabled": False}),
|
|
("stay still", "stay put", "stop moving", "stop wandering",
|
|
"don't move", "sit", "sit still", "stay", "settle down", "hold still"),
|
|
),
|
|
(
|
|
# Reachable from the server too (petctl voice reset), but only if its
|
|
# prompt mentions the verb. Recognising it here is what makes the
|
|
# phrase work regardless of what the server was told.
|
|
Intent("voice_reset", None, "Back to my own voice."),
|
|
("use your normal voice", "use your own voice", "your normal voice",
|
|
"go back to your normal voice", "be yourself", "be yourself again",
|
|
"stop doing that voice", "drop the voice", "talk normally",
|
|
"speak normally", "use your real voice"),
|
|
),
|
|
)
|
|
|
|
|
|
def _build() -> dict[str, Intent]:
|
|
"""Canonicalise the table, refusing to build an ambiguous one.
|
|
|
|
A phrase that normalises to "" would match an utterance of pure filler
|
|
("hey bolt"), and one that lands on the same string as a phrase from
|
|
another intent would silently bind to whichever was declared last. Both are
|
|
edit-time mistakes, so they fail at import rather than at 3am on a mic."""
|
|
table: dict[str, Intent] = {}
|
|
for intent, phrases in _TABLE:
|
|
for phrase in phrases:
|
|
key = normalize(phrase)
|
|
if not key:
|
|
raise ValueError(f"intent phrase {phrase!r} normalises to nothing")
|
|
existing = table.get(key)
|
|
if existing is not None and existing.name != intent.name:
|
|
raise ValueError(
|
|
f"phrase {phrase!r} ({key!r}) is claimed by both "
|
|
f"{existing.name} and {intent.name}"
|
|
)
|
|
table[key] = intent
|
|
return table
|
|
|
|
|
|
_BY_PHRASE = _build()
|
|
|
|
# Longest phrase in the table, in words. Anything longer can't match, so a real
|
|
# request skips normalisation entirely — this runs on every turn.
|
|
_MAX_WORDS = max(len(phrase.split()) for phrase in _BY_PHRASE)
|
|
|
|
|
|
def recognize(text: str) -> Optional[Intent]:
|
|
"""The intent *text* expresses, or None to send it to the server.
|
|
|
|
None is the safe answer and the common one: anything not matched verbatim
|
|
against the table belongs to Bolt."""
|
|
raw = (text or "").strip()
|
|
if not raw:
|
|
return None
|
|
# +6 words of slack for the filler about to be stripped ("hey bolt, could
|
|
# you please stop" is six words to reach a one-word command).
|
|
if len(raw.split()) > _MAX_WORDS + 6:
|
|
return None
|
|
intent = _BY_PHRASE.get(normalize(raw))
|
|
return intent
|