fix: force tool calls for drifted intents and harden tool-use prompt
Small local models (qwen2.5-vl-3b, gemma-4-e4b) narrate actions ('Sure,
I'll pause the music') without emitting the tool call once the full
system prompt and 26-tool set are in context. Measured: qwen emitted
tools in 2/8 realistic intents, gemma 5/8; single-tool tests pass 100%,
so this is context-induced drift, not plumbing.
- agent/intent_router.py: deterministic forced-call router for
high-frequency intents — media controls, now_playing/list_library,
'remember that ...' memory saves, explicit YouTube Music searches,
and personal-fact recall (memory_recall/search_notes/skill_recall,
excluded for weather/time questions). Fires only when the tool is
available; guards against how-do-I questions.
- agent.py: generalize _ForcedToolCallStream to any tool; route through
intent_router after the dispatch check. System prompt adds a hard
'call, don't narrate' rule and reconditions 'answer directly'.
- tests/test_intent_router.py: 30 unit tests pinning router behavior.
Verified against the live LLM with the full fidelity harness: combined
forced-or-emitted coverage 8/8 on qwen2.5-vl-3b (was 2/8) and 8/8 on
gemma-4-e4b (was 5/8). Full suite: 79 passed.
This commit is contained in:
1 parent
48b92585c6
commit
8a7cca456f
4 files changed
+464
-14
No files matched your search
@@ -47,6 +47,7 @@ COPY agent/weather_mcp.py /opt/voice-agent/weather_mcp.py
|
|||||||
COPY agent/skills_mcp.py /opt/voice-agent/skills_mcp.py
|
COPY agent/skills_mcp.py /opt/voice-agent/skills_mcp.py
|
||||||
COPY agent/task_registry.py /opt/voice-agent/task_registry.py
|
COPY agent/task_registry.py /opt/voice-agent/task_registry.py
|
||||||
COPY agent/task_router.py /opt/voice-agent/task_router.py
|
COPY agent/task_router.py /opt/voice-agent/task_router.py
|
||||||
|
COPY agent/intent_router.py /opt/voice-agent/intent_router.py
|
||||||
COPY agent/task_worker.py /opt/voice-agent/task_worker.py
|
COPY agent/task_worker.py /opt/voice-agent/task_worker.py
|
||||||
COPY agent/dispatch_mcp.py /opt/voice-agent/dispatch_mcp.py
|
COPY agent/dispatch_mcp.py /opt/voice-agent/dispatch_mcp.py
|
||||||
COPY agent/media_mcp.py /opt/voice-agent/media_mcp.py
|
COPY agent/media_mcp.py /opt/voice-agent/media_mcp.py
|
||||||
|
|||||||
+59
-14
@@ -127,11 +127,19 @@ SYSTEM_PROMPT = textwrap.dedent("""\
|
|||||||
no markdown, and no formatting except the Azure delivery markers below.
|
no markdown, and no formatting except the Azure delivery markers below.
|
||||||
- Spell out numbers when it sounds more natural ("twenty twenty-six"
|
- Spell out numbers when it sounds more natural ("twenty twenty-six"
|
||||||
instead of "2026").
|
instead of "2026").
|
||||||
- Make reasonable assumptions and answer directly. Ask a follow-up only
|
- Make reasonable assumptions and answer directly once any relevant tool
|
||||||
when it is genuinely impossible to proceed without one.
|
call has been made. Ask a follow-up only when it is genuinely
|
||||||
|
impossible to proceed without one.
|
||||||
- Be warm and direct. Don't be sycophantic or overly formal.
|
- Be warm and direct. Don't be sycophantic or overly formal.
|
||||||
- If you don't know something, say so briefly and move on.
|
- If you don't know something, say so briefly and move on.
|
||||||
|
|
||||||
|
# Tools: call them, don't narrate them
|
||||||
|
When a request matches one of your tools, you MUST emit that tool call
|
||||||
|
in this reply. Never only say "I'll pause the music", "let me check", or
|
||||||
|
"I'm saving that now" without the tool call — narrating an action is a
|
||||||
|
failure because nothing actually happens. After the tool result comes
|
||||||
|
back, confirm briefly in your normal voice.
|
||||||
|
|
||||||
# Expressive delivery (Azure Dragon HD Omni)
|
# Expressive delivery (Azure Dragon HD Omni)
|
||||||
Azure understands a square-bracket delivery marker at the start of spoken
|
Azure understands a square-bracket delivery marker at the start of spoken
|
||||||
text. Use zero to two markers per response, only when they genuinely improve
|
text. Use zero to two markers per response, only when they genuinely improve
|
||||||
@@ -223,12 +231,14 @@ SYSTEM_PROMPT = textwrap.dedent("""\
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
# ── Background-task routing ─────────────────────────────────────────────────
|
# ── Intent routing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from agent.task_router import should_force_background_task
|
from agent.task_router import should_force_background_task
|
||||||
|
from agent.intent_router import detect_forced_call
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from task_router import should_force_background_task
|
from task_router import should_force_background_task
|
||||||
|
from intent_router import detect_forced_call
|
||||||
|
|
||||||
|
|
||||||
# ── OpenAI-compatible LLM with reasoning_content fallback ───────────────────
|
# ── OpenAI-compatible LLM with reasoning_content fallback ───────────────────
|
||||||
@@ -320,23 +330,39 @@ class _ReasoningFallbackWrapper:
|
|||||||
|
|
||||||
|
|
||||||
class _ForcedToolCallStream(lk_llm.LLMStream):
|
class _ForcedToolCallStream(lk_llm.LLMStream):
|
||||||
"""Emit a dispatch_task call without asking the small LLM to route it."""
|
"""Emit a tool call without asking the small LLM to produce it.
|
||||||
|
|
||||||
def __init__(self, llm, *, chat_ctx, tools, conn_options, description: str):
|
Two callers: the task router (dispatch_task for background work) and the
|
||||||
self._description = description
|
intent router (deterministic intents like "pause the music" or "remember
|
||||||
|
that ..."), where small models otherwise narrate the action without ever
|
||||||
|
emitting the call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm,
|
||||||
|
*,
|
||||||
|
chat_ctx,
|
||||||
|
tools,
|
||||||
|
conn_options,
|
||||||
|
tool_name: str = "dispatch_task",
|
||||||
|
arguments: dict | None = None,
|
||||||
|
):
|
||||||
|
self._tool_name = tool_name
|
||||||
|
self._arguments = arguments if arguments is not None else {}
|
||||||
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
|
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
|
||||||
|
|
||||||
async def _run(self) -> None:
|
async def _run(self) -> None:
|
||||||
self._event_ch.send_nowait(
|
self._event_ch.send_nowait(
|
||||||
lk_llm.ChatChunk(
|
lk_llm.ChatChunk(
|
||||||
id=f"forced-dispatch-{uuid.uuid4().hex}",
|
id=f"forced-{uuid.uuid4().hex}",
|
||||||
delta=lk_llm.ChoiceDelta(
|
delta=lk_llm.ChoiceDelta(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
lk_llm.FunctionToolCall(
|
lk_llm.FunctionToolCall(
|
||||||
name="dispatch_task",
|
name=self._tool_name,
|
||||||
arguments=json.dumps({"description": self._description}),
|
arguments=json.dumps(self._arguments),
|
||||||
call_id=f"dispatch-{uuid.uuid4().hex}",
|
call_id=f"forced-{uuid.uuid4().hex}",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -415,9 +441,11 @@ class HopeLLM(openai.LLM):
|
|||||||
)
|
)
|
||||||
chat_ctx = self._compact_context(chat_ctx, conn_options)
|
chat_ctx = self._compact_context(chat_ctx, conn_options)
|
||||||
|
|
||||||
# Force dispatch for clear background-work requests. Without this,
|
# Force calls for intents small models drift on. Without this they
|
||||||
# small local models often say "I'll get on that" but emit no tool
|
# say "I'll pause the music" or "I'll get on that" but emit no tool
|
||||||
# call at all.
|
# call at all. Task router handles background work; the intent router
|
||||||
|
# handles deterministic in-conversation intents (media controls,
|
||||||
|
# memory saves, music searches).
|
||||||
items = list(chat_ctx.items)
|
items = list(chat_ctx.items)
|
||||||
latest = items[-1] if items else None
|
latest = items[-1] if items else None
|
||||||
latest_role = getattr(latest, "role", None)
|
latest_role = getattr(latest, "role", None)
|
||||||
@@ -432,7 +460,24 @@ class HopeLLM(openai.LLM):
|
|||||||
chat_ctx=chat_ctx,
|
chat_ctx=chat_ctx,
|
||||||
tools=tools or [],
|
tools=tools or [],
|
||||||
conn_options=conn_options,
|
conn_options=conn_options,
|
||||||
description=latest_text,
|
tool_name="dispatch_task",
|
||||||
|
arguments={"description": latest_text},
|
||||||
|
)
|
||||||
|
|
||||||
|
forced = detect_forced_call(latest_text, tools or []) if latest_text else None
|
||||||
|
if forced:
|
||||||
|
tool_name, arguments = forced
|
||||||
|
logger.info(
|
||||||
|
"Routing request to forced %s(%s): %.120s",
|
||||||
|
tool_name, arguments, latest_text,
|
||||||
|
)
|
||||||
|
return _ForcedToolCallStream(
|
||||||
|
self,
|
||||||
|
chat_ctx=chat_ctx,
|
||||||
|
tools=tools or [],
|
||||||
|
conn_options=conn_options,
|
||||||
|
tool_name=tool_name,
|
||||||
|
arguments=arguments,
|
||||||
)
|
)
|
||||||
|
|
||||||
stream = super().chat(
|
stream = super().chat(
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Deterministic intent router — forces tool calls for high-frequency intents.
|
||||||
|
|
||||||
|
Small local models drift under long context: they narrate an action ("Sure,
|
||||||
|
I'll pause the music") without emitting the tool call, which is exactly the
|
||||||
|
failure mode _ForcedToolCallStream was built to bypass for dispatch_task.
|
||||||
|
This module extends the same idea to the other unambiguous, high-frequency
|
||||||
|
voice intents: music playback controls, now-playing/library questions,
|
||||||
|
"remember that ..." memory saves, and explicit YouTube Music searches.
|
||||||
|
|
||||||
|
Conservatism rules:
|
||||||
|
- Never fires when the matching tool is not available in the current
|
||||||
|
session's toolset (MCP toolsets differ per user config).
|
||||||
|
- Never fires on explanation/questions ("how do I pause the music?") for
|
||||||
|
action intents — those deserve a spoken answer, not a side effect.
|
||||||
|
- "play <something>" only forces a search for what looks like a song name;
|
||||||
|
generic objects ("play some music", "play a game") fall through to the
|
||||||
|
LLM.
|
||||||
|
|
||||||
|
Returns (tool_name, arguments) or None. The agent emits the call directly;
|
||||||
|
the tool result then flows back through the normal loop, so Hope still
|
||||||
|
speaks a natural confirmation afterwards.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
# Question/explanation guard for *action* intents (not for question intents
|
||||||
|
# like now_playing, which are questions by design).
|
||||||
|
_QUESTION_GUARD = re.compile(
|
||||||
|
r"\b(how (?:do|can) i|how to|what happens if|what does|can you explain|"
|
||||||
|
r"tell me about|why (?:do|does|is)|is there a way to)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MEDIA_CONTROLS: list[tuple[re.Pattern[str], str]] = [
|
||||||
|
(re.compile(r"\bpause\b|\bput .* on hold\b", re.I), "pause_music"),
|
||||||
|
(re.compile(
|
||||||
|
r"\bstop\b(?:\s+(?:the|this|that))?\s*(?:music|song|playback|player|tune)s?\b",
|
||||||
|
re.I,
|
||||||
|
), "stop_music"),
|
||||||
|
(re.compile(r"\b(?:resume|unpause)\b", re.I), "resume_music"),
|
||||||
|
(re.compile(
|
||||||
|
r"\b(?:skip|next)\b.*\b(?:song|track|one)\b|\bskip\s+(?:this|it|that)\b",
|
||||||
|
re.I,
|
||||||
|
), "skip_song"),
|
||||||
|
(re.compile(r"\bplay\b.*\bagain\b|\bfrom the (?:top|beginning)\b", re.I), "resume_music"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_NOW_PLAYING = re.compile(
|
||||||
|
r"\bwhat(?:'s| is|s)\s+(?:currently\s+)?playing\b"
|
||||||
|
r"|\bwhat song is (?:this|playing)\b"
|
||||||
|
r"|\bwhich song is (?:this|playing)\b"
|
||||||
|
r"|\bname this song\b"
|
||||||
|
r"|\bwhat(?:'s| is) this (?:track|tune)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LIBRARY = re.compile(
|
||||||
|
r"\bwhat(?:'s| is)?\s*(?:in|of)\s+my\s+(?:music\s+)?library\b"
|
||||||
|
r"|\b(?:list|show)(?:\s+me)?\s+my\s+(?:music|library|songs|tracks)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MEMORY_SAVE = re.compile(
|
||||||
|
r"^\s*(?:please\s+)?remember\s+(?:that\s+|this\s*[::]\s*)?(?P<body>.+?)\s*[.!]?\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Personal-fact recall. Excluded when the utterance is really a
|
||||||
|
# weather/time/date question — those tools already work lexically on small
|
||||||
|
# models and must win.
|
||||||
|
_MEMORY_RECALL = re.compile(
|
||||||
|
r"\bwhat(?:'s| is|s)\s+my\b"
|
||||||
|
r"|\bwhat(?:'s| is)\s+(?:the\s+)?(?:name|birthday|anniversary)\b.*\bmy\b"
|
||||||
|
r"|\bdo you remember\b"
|
||||||
|
r"|\bwhat did i (?:say|tell you|mention)\b"
|
||||||
|
r"|\bwho am i\b"
|
||||||
|
r"|\bwhere (?:do|did) i (?:live|work|leave)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_RECALL_EXCLUDE = re.compile(
|
||||||
|
r"\b(?:weather|temperature|forecast|time|date|day is it|clock)\b", re.I
|
||||||
|
)
|
||||||
|
|
||||||
|
_SEARCH_NOTES = re.compile(
|
||||||
|
r"\bdo i have (?:any |some )?notes\b"
|
||||||
|
r"|\b(?:check|search|look (?:in|through|at))\s+my\s+notes\b"
|
||||||
|
r"|\bsearch\s+notes\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SKILL_RECALL = re.compile(
|
||||||
|
r"\bdo i have (?:a|any) (?:skill|procedure)\b"
|
||||||
|
r"|\b(?:check|search)\s+(?:your|my)\s+skills\b"
|
||||||
|
r"|\bany skills (?:for|about)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SEARCH_EXPLICIT = re.compile(
|
||||||
|
r"\b(?:find|search)\s+(?:for\s+)?(?P<q>.+?)\s+on\s+(?:youtube|yt)\s*music\b"
|
||||||
|
r"|\bsearch\s+(?:youtube\s+music\s+)?for\s+(?P<q2>.+?)\s*(?:on\s+youtube\s*music)?\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PLAY_IMPERATIVE = re.compile(r"^\s*(?:could you\s+|please\s+)?play\s+(?P<q>.+?)\s*[.!]?\s*$", re.I)
|
||||||
|
|
||||||
|
# "play <q>" where q is not a song request — fall through to the LLM.
|
||||||
|
_GENERIC_PLAY = re.compile(
|
||||||
|
r"^(?:some |any )?(?:music|something|anything|tunes?|songs?|a game|games?|the radio|it|that|one)$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_names(tool) -> list[str]:
|
||||||
|
names = []
|
||||||
|
for attr in ("name", "id"):
|
||||||
|
value = getattr(tool, attr, None)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
names.append(value)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def tool_available(tools, name: str) -> bool:
|
||||||
|
"""True if a tool with this name is in the session's toolset.
|
||||||
|
|
||||||
|
Handles MCP name mangling (server prefix) by comparing the last
|
||||||
|
__-separated segment.
|
||||||
|
"""
|
||||||
|
for tool in tools or []:
|
||||||
|
for candidate in _tool_names(tool):
|
||||||
|
if candidate == name or candidate.split("__")[-1] == name:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _title_from(body: str) -> str:
|
||||||
|
words = body.split()
|
||||||
|
title = " ".join(words[:6]).strip(" .,!?;:")
|
||||||
|
return (title[:60] or body[:60]).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_forced_call(text: str, tools) -> tuple[str, dict] | None:
|
||||||
|
"""Match a user utterance to a forced tool call.
|
||||||
|
|
||||||
|
Returns (tool_name, arguments) when an unambiguous high-frequency intent
|
||||||
|
matches an available tool, else None. Media controls win over memory and
|
||||||
|
search so "pause" is never eaten by a wider pattern.
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return None
|
||||||
|
utterance = text.strip()
|
||||||
|
lowered = utterance.lower()
|
||||||
|
|
||||||
|
# ── question intents (no action guard — they ARE questions) ────────────
|
||||||
|
if tool_available(tools, "now_playing") and _NOW_PLAYING.search(utterance):
|
||||||
|
return ("now_playing", {})
|
||||||
|
if tool_available(tools, "list_library") and _LIBRARY.search(utterance):
|
||||||
|
return ("list_library", {})
|
||||||
|
|
||||||
|
guarded = bool(_QUESTION_GUARD.search(utterance))
|
||||||
|
|
||||||
|
# ── media playback controls ─────────────────────────────────────────────
|
||||||
|
if not guarded:
|
||||||
|
for pattern, tool_name in _MEDIA_CONTROLS:
|
||||||
|
if pattern.search(utterance) and tool_available(tools, tool_name):
|
||||||
|
return (tool_name, {})
|
||||||
|
|
||||||
|
# ── memory save ─────────────────────────────────────────────────────────
|
||||||
|
if not guarded and tool_available(tools, "memory_save"):
|
||||||
|
m = _MEMORY_SAVE.match(utterance)
|
||||||
|
if m:
|
||||||
|
body = m.group("body").strip()
|
||||||
|
if len(body) >= 3:
|
||||||
|
return ("memory_save", {
|
||||||
|
"title": _title_from(body),
|
||||||
|
"body": body,
|
||||||
|
"tags": ["voice"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── YouTube Music search ────────────────────────────────────────────────
|
||||||
|
if not guarded and tool_available(tools, "search_music"):
|
||||||
|
m = _SEARCH_EXPLICIT.search(utterance)
|
||||||
|
query = (m.group("q") or m.group("q2") or "").strip() if m else None
|
||||||
|
if not query:
|
||||||
|
m = _PLAY_IMPERATIVE.match(utterance)
|
||||||
|
if m:
|
||||||
|
candidate = m.group("q").strip()
|
||||||
|
if not _GENERIC_PLAY.match(candidate):
|
||||||
|
query = candidate
|
||||||
|
if query:
|
||||||
|
return ("search_music", {"query": query})
|
||||||
|
|
||||||
|
# ── recall (memory / notes / skills) ────────────────────────────────────
|
||||||
|
# The whole utterance is passed as the query — the memory and skills
|
||||||
|
# backends do their own semantic/keyword matching.
|
||||||
|
if not guarded:
|
||||||
|
if (tool_available(tools, "memory_recall")
|
||||||
|
and _MEMORY_RECALL.search(utterance)
|
||||||
|
and not _RECALL_EXCLUDE.search(utterance)):
|
||||||
|
return ("memory_recall", {"query": utterance})
|
||||||
|
if tool_available(tools, "search_notes") and _SEARCH_NOTES.search(utterance):
|
||||||
|
return ("search_notes", {"query": utterance})
|
||||||
|
if tool_available(tools, "skill_recall") and _SKILL_RECALL.search(utterance):
|
||||||
|
return ("skill_recall", {"query": utterance})
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Unit tests for the deterministic intent router (host-side, pure functions).
|
||||||
|
|
||||||
|
The router forces tool calls for intents small local models drift on —
|
||||||
|
these tests pin the matching behavior that the agent relies on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"intent_router", Path(__file__).parents[1] / "agent" / "intent_router.py"
|
||||||
|
)
|
||||||
|
intent_router = importlib.util.module_from_spec(_SPEC)
|
||||||
|
sys.modules["intent_router"] = intent_router
|
||||||
|
_SPEC.loader.exec_module(intent_router)
|
||||||
|
|
||||||
|
detect = intent_router.detect_forced_call
|
||||||
|
available = intent_router.tool_available
|
||||||
|
|
||||||
|
FULL_TOOLSET = [
|
||||||
|
"get_weather", "get_time",
|
||||||
|
"memory_save", "memory_recall", "search_notes", "read_note",
|
||||||
|
"skill_recall", "skill_save",
|
||||||
|
"dispatch_task",
|
||||||
|
"search_music", "play_song", "offer_song_choices", "pause_music",
|
||||||
|
"resume_music", "stop_music", "skip_song", "now_playing", "list_library",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTool:
|
||||||
|
def __init__(self, name, id=None):
|
||||||
|
self.name = name
|
||||||
|
self.id = id
|
||||||
|
|
||||||
|
|
||||||
|
def tools(names):
|
||||||
|
return [FakeTool(n) for n in names]
|
||||||
|
|
||||||
|
|
||||||
|
# ── tool availability ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_tool_available_exact_and_prefixed():
|
||||||
|
assert available(tools(["pause_music"]), "pause_music")
|
||||||
|
assert available(tools(["media__pause_music"]), "pause_music")
|
||||||
|
assert not available(tools(["play_song"]), "pause_music")
|
||||||
|
assert not available([], "pause_music")
|
||||||
|
# id-only attribute (dispatch check path used by agent.py)
|
||||||
|
assert available([FakeTool(None, id="dispatch_task")], "dispatch_task")
|
||||||
|
|
||||||
|
|
||||||
|
# ── media controls ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("utterance,expected", [
|
||||||
|
("pause the music", "pause_music"),
|
||||||
|
("pause", "pause_music"),
|
||||||
|
("could you pause it", "pause_music"),
|
||||||
|
("stop the music", "stop_music"),
|
||||||
|
("stop this song", "stop_music"),
|
||||||
|
("resume the music", "resume_music"),
|
||||||
|
("unpause", "resume_music"),
|
||||||
|
("play it again", "resume_music"),
|
||||||
|
("skip this song", "skip_song"),
|
||||||
|
("next track", "skip_song"),
|
||||||
|
])
|
||||||
|
def test_media_controls_forced(utterance, expected):
|
||||||
|
assert detect(utterance, tools(FULL_TOOLSET)) == (expected, {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_without_music_context_not_forced():
|
||||||
|
# "stop" alone is ambiguous (could mean ending the conversation) — the
|
||||||
|
# router leaves it to the LLM.
|
||||||
|
assert detect("stop", tools(FULL_TOOLSET)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_controls_guard_against_questions():
|
||||||
|
assert detect("how do I pause the music?", tools(FULL_TOOLSET)) is None
|
||||||
|
assert detect("what happens if I stop the music", tools(FULL_TOOLSET)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_control_missing_tool_not_forced():
|
||||||
|
no_media = [n for n in FULL_TOOLSET if not n.endswith("_music") or n == "play_song"]
|
||||||
|
assert detect("pause the music", tools(no_media)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── memory saves ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_memory_save_forced_with_extracted_body():
|
||||||
|
result = detect("Remember that my coffee order is an oat milk latte",
|
||||||
|
tools(FULL_TOOLSET))
|
||||||
|
assert result is not None
|
||||||
|
tool_name, args = result
|
||||||
|
assert tool_name == "memory_save"
|
||||||
|
assert args["body"] == "my coffee order is an oat milk latte"
|
||||||
|
assert args["title"]
|
||||||
|
assert isinstance(args["tags"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_save_variants():
|
||||||
|
for utterance in (
|
||||||
|
"Remember my wifi password is falcon9",
|
||||||
|
"please remember that the garage code is 1234",
|
||||||
|
):
|
||||||
|
result = detect(utterance, tools(FULL_TOOLSET))
|
||||||
|
assert result and result[0] == "memory_save", utterance
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_save_guard_and_short_body():
|
||||||
|
assert detect("how do I make you remember things?", tools(FULL_TOOLSET)) is None
|
||||||
|
assert detect("remember", tools(FULL_TOOLSET)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── music search ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_search_forced_for_explicit_and_play_imperative():
|
||||||
|
cases = [
|
||||||
|
("find Bohemian Rhapsody on YouTube Music", "Bohemian Rhapsody"),
|
||||||
|
("search for timeless miracle", "timeless miracle"),
|
||||||
|
("play Timeless Miracle by Blind Guardian", "Timeless Miracle by Blind Guardian"),
|
||||||
|
]
|
||||||
|
for utterance, query in cases:
|
||||||
|
result = detect(utterance, tools(FULL_TOOLSET))
|
||||||
|
assert result and result[0] == "search_music", utterance
|
||||||
|
assert result[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("utterance", [
|
||||||
|
"play some music",
|
||||||
|
"play something",
|
||||||
|
"play a game",
|
||||||
|
"how do I find a song on YouTube Music?",
|
||||||
|
])
|
||||||
|
def test_search_not_forced_for_generic_or_questions(utterance):
|
||||||
|
assert detect(utterance, tools(FULL_TOOLSET)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── question intents ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("utterance,expected", [
|
||||||
|
("what's playing right now?", "now_playing"),
|
||||||
|
("what song is this", "now_playing"),
|
||||||
|
("what's in my music library?", "list_library"),
|
||||||
|
("list my songs", "list_library"),
|
||||||
|
])
|
||||||
|
def test_question_intents_forced_even_though_questions(utterance, expected):
|
||||||
|
assert detect(utterance, tools(FULL_TOOLSET)) == (expected, {})
|
||||||
|
|
||||||
|
|
||||||
|
# ── recall (memory / notes / skills) ────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_memory_recall_forced_for_personal_facts():
|
||||||
|
cases = [
|
||||||
|
"what's my coffee order?",
|
||||||
|
"What is my wifi password",
|
||||||
|
"do you remember my garage code?",
|
||||||
|
"who am I?",
|
||||||
|
]
|
||||||
|
for utterance in cases:
|
||||||
|
result = detect(utterance, tools(FULL_TOOLSET))
|
||||||
|
assert result and result[0] == "memory_recall", utterance
|
||||||
|
assert result[1]["query"] == utterance
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_recall_not_stolen_from_weather_time():
|
||||||
|
# These must reach the weather/time tools, which work lexically.
|
||||||
|
for utterance in (
|
||||||
|
"what's the weather in Raleigh?",
|
||||||
|
"what time is it?",
|
||||||
|
"what's my weather like today?",
|
||||||
|
"what's the date today?",
|
||||||
|
):
|
||||||
|
assert detect(utterance, tools(FULL_TOOLSET)) != "memory_recall"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_notes_and_skill_recall_forced():
|
||||||
|
assert detect("do I have any notes about project deadlines?",
|
||||||
|
tools(FULL_TOOLSET))[0] == "search_notes"
|
||||||
|
assert detect("check my notes for the garage code",
|
||||||
|
tools(FULL_TOOLSET))[0] == "search_notes"
|
||||||
|
assert detect("do I have a skill for brewing coffee?",
|
||||||
|
tools(FULL_TOOLSET))[0] == "skill_recall"
|
||||||
|
assert detect("check your skills for baking bread",
|
||||||
|
tools(FULL_TOOLSET))[0] == "skill_recall"
|
||||||
|
|
||||||
|
|
||||||
|
# ── non-intents fall through to the LLM ─────────────────────────────────────
|
||||||
|
|
||||||
|
def test_plain_conversation_not_forced():
|
||||||
|
for utterance in (
|
||||||
|
"hello",
|
||||||
|
"what's the weather in Raleigh?",
|
||||||
|
"tell me about the Roman empire",
|
||||||
|
"",
|
||||||
|
):
|
||||||
|
assert detect(utterance, tools(FULL_TOOLSET)) is None
|
||||||
Reference in new issue
Block a user