feat: Enhance local command handling and introduce local intents
- 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.
This commit is contained in:
@@ -221,10 +221,13 @@ def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
||||
assert not ctrl._pending_follow_up
|
||||
|
||||
|
||||
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
||||
"""Only a reply that *ends* on a question is waiting for an answer."""
|
||||
ctrl._speak("What time is it? It's 7:15 AM.")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
def test_a_question_anywhere_in_the_reply_keeps_listening(spoke, ctrl):
|
||||
"""Bolt often asks and then keeps talking ("Want me to fix it? I'd start
|
||||
with the config."), so the question mark doesn't have to be last. An
|
||||
unwanted extra listen ends itself on VAD_GRACE_SECONDS of silence; a missed
|
||||
one costs you a wake word, which is the more expensive mistake."""
|
||||
ctrl._speak("Want me to restart it? It's been up for 40 days.")
|
||||
assert ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
|
||||
@@ -382,6 +385,148 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
||||
assert spoken == ["always"]
|
||||
|
||||
|
||||
# ── local intents ───────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def heard(monkeypatch):
|
||||
"""A turn where you said something, with the server and TTS recorded.
|
||||
Returns (utterance_setter, sent, spoken)."""
|
||||
said = {"text": ""}
|
||||
sent, spoken = [], []
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: said["text"])
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply("from the server"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None:
|
||||
spoken.append(text) or True)
|
||||
return said, sent, spoken
|
||||
|
||||
|
||||
def test_a_local_intent_never_reaches_the_server(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "come here"
|
||||
actions = _capture(ctrl.action)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert sent == [] # no round trip at all
|
||||
assert actions == [{"action": "move", "anchor": "cursor"}]
|
||||
assert spoken == [] # walking over is the reply
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_stop_is_answered_with_silence(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "be quiet"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert (sent, spoken) == ([], [])
|
||||
|
||||
|
||||
def test_a_request_that_merely_starts_with_an_intent_word_goes_to_the_server(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "stop the docker container"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert sent and "stop the docker container" in sent[0]
|
||||
assert spoken == ["from the server"]
|
||||
|
||||
|
||||
def test_local_intents_are_skipped_while_answering_a_question(heard, ctrl):
|
||||
"""Bolt asked something; "never mind" is an answer to him, not a body
|
||||
command. Swallowing it locally would leave the server holding a question it
|
||||
never got a reply to."""
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "never mind"
|
||||
ctrl._pending_follow_up = True
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert sent and "never mind" in sent[0]
|
||||
|
||||
|
||||
def test_local_intents_can_be_turned_off(monkeypatch, heard, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "LOCAL_INTENTS", False)
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "come here"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert sent and "come here" in sent[0]
|
||||
|
||||
|
||||
def test_say_that_again_replays_the_last_line_without_duplicating_history(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
ctrl.history.add(controller_mod.history_mod.PET, "it's 7:15 AM", 0.0)
|
||||
said["text"] = "what did you say?"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["it's 7:15 AM"]
|
||||
assert sent == []
|
||||
pet_lines = [e.text for e in ctrl.history.entries()
|
||||
if e.role == controller_mod.history_mod.PET]
|
||||
assert pet_lines == ["it's 7:15 AM"] # replayed, not re-recorded
|
||||
|
||||
|
||||
def test_repeat_with_nothing_to_repeat_says_so(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "say that again"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert spoken == ["I haven't said anything yet."]
|
||||
|
||||
|
||||
def test_going_back_to_the_normal_voice_needs_no_server_prompt_support(heard, ctrl):
|
||||
"""The server can only offer `petctl voice reset` if its prompt happens to
|
||||
advertise the verb; recognising the phrase here works regardless."""
|
||||
said, sent, spoken = heard
|
||||
ctrl._voice_id, ctrl._voice_name = "voice-123", "Brian"
|
||||
changed = _capture(ctrl.voice_changed)
|
||||
said["text"] = "go back to your normal voice"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ctrl._voice_id == ""
|
||||
assert changed == [""]
|
||||
assert spoken == ["Back to my own voice."]
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_go_to_sleep_overrides_the_quiet_hours_schedule(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "go to sleep"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ctrl._napping is True
|
||||
assert ctrl._nap_forced is True # not undone by the next schedule check
|
||||
assert spoken == ["Night."]
|
||||
|
||||
|
||||
# ── failure containment ─────────────────────────────────────────────────────
|
||||
|
||||
def test_one_bad_turn_does_not_end_the_session(monkeypatch, ctrl):
|
||||
"""A turn raising something unforeseen used to unwind _loop and kill the
|
||||
thread — the pet would go deaf until it was restarted by hand."""
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
def explode():
|
||||
raise RuntimeError("numpy said no")
|
||||
|
||||
assert ctrl._guarded(explode, "conversation turn") is False
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
assert any("Recovered from a conversation turn failure" in m for m in logs)
|
||||
|
||||
|
||||
def test_a_command_handler_crash_is_reported_up_the_relay(monkeypatch, ctrl):
|
||||
"""The server is blocked on /desk/tool_result while this runs. Raising would
|
||||
leave it waiting out its own timeout on a turn that can never finish."""
|
||||
monkeypatch.setattr(controller_mod.pet_actions, "parse",
|
||||
lambda command: (_ for _ in ()).throw(KeyError("boom")))
|
||||
|
||||
output = ctrl._handle_command("petctl move top-left")
|
||||
|
||||
assert output.startswith("[error]") and "boom" in output
|
||||
|
||||
|
||||
# ── notification bridge ─────────────────────────────────────────────────────
|
||||
|
||||
def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
@@ -404,7 +549,54 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
def test_filtered_out_notifications_are_never_queued(ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
||||
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
|
||||
assert ctrl._pending_notifications == []
|
||||
assert not ctrl._pending_notifications
|
||||
|
||||
|
||||
def test_the_notification_queue_is_bounded(monkeypatch, ctrl):
|
||||
"""An overnight nap can't grow the queue without limit — the drain only runs
|
||||
from the heartbeat, and the heartbeat doesn't run while napping."""
|
||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_QUEUE_LIMIT", 3)
|
||||
ctrl = controller_mod.PetController()
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
|
||||
for index in range(10):
|
||||
ctrl._queue_notification(Notification(app="CI", summary=f"build {index}", body=""))
|
||||
|
||||
queued = [notification.summary for _stamp, notification in ctrl._pending_notifications]
|
||||
assert queued == ["build 7", "build 8", "build 9"] # oldest dropped
|
||||
|
||||
|
||||
def test_stale_notifications_are_dropped_instead_of_read_out(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MAX_AGE_SECONDS", 900)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply(""))
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
ctrl._queue_notification(Notification(app="CI", summary="fresh", body=""))
|
||||
# Backdate it past the age limit, as an overnight backlog would be.
|
||||
stamp, notification = ctrl._pending_notifications.pop()
|
||||
ctrl._pending_notifications.append((stamp - 4000, notification))
|
||||
|
||||
ctrl._drain_notifications()
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_a_nap_starting_mid_drain_keeps_the_rest_queued(monkeypatch, ctrl):
|
||||
"""The old code swapped the queue out and returned, losing the remainder."""
|
||||
def converse(text, on_command=None):
|
||||
ctrl._napping = True # e.g. quiet hours began, or a fullscreen app opened
|
||||
return Reply("")
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", converse)
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
for index in range(3):
|
||||
ctrl._queue_notification(Notification(app="CI", summary=f"build {index}", body=""))
|
||||
|
||||
ctrl._drain_notifications()
|
||||
|
||||
remaining = [notification.summary for _stamp, notification in ctrl._pending_notifications]
|
||||
assert remaining == ["build 1", "build 2"]
|
||||
|
||||
|
||||
def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
|
||||
|
||||
Reference in New Issue
Block a user