Add tool-call disambiguation test harness for search_spotify/control_playback
Renders the Extended OpenAI Conversation prompt/function definitions and checks the model's first tool-call choice against test phrases, bypassing Home Assistant.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
- spec:
|
||||
name: execute_services
|
||||
description: Use this function to execute service of devices in Home Assistant.
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
list:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
domain:
|
||||
type: string
|
||||
description: The domain of the service
|
||||
service:
|
||||
type: string
|
||||
description: The service to be called
|
||||
service_data:
|
||||
type: object
|
||||
description: The service data object to indicate what to control.
|
||||
properties:
|
||||
entity_id:
|
||||
type: string
|
||||
description: The entity_id retrieved from available devices. It
|
||||
must start with domain, followed by dot character.
|
||||
required:
|
||||
- entity_id
|
||||
required:
|
||||
- domain
|
||||
- service
|
||||
- service_data
|
||||
function:
|
||||
type: native
|
||||
name: execute_service
|
||||
|
||||
|
||||
# Spotify Music Control Functions
|
||||
|
||||
- spec:
|
||||
name: search_spotify
|
||||
description: >-
|
||||
Search Spotify for an artist, album, track, or playlist and return the Spotify URI.
|
||||
Use this before playing music to get the URI. Use this whenever the user asks to play
|
||||
or queue a specific song, artist, album, or playlist — even if the name contains a
|
||||
number (e.g. "Back on 74", "1979", "Blink 182"). A number that is part of a
|
||||
song/artist/album name is NOT a volume level; only use control_playback's volume_set
|
||||
if the user explicitly says "volume" or "percent".
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Artist, album, track, or playlist name to search for (e.g., "Coldplay", "Parachutes", "Yellow", "Today's Top Hits")
|
||||
type:
|
||||
type: string
|
||||
enum: [artist, album, track, playlist]
|
||||
description: Type of content to search for. Playlist searches check your personal playlists first, then fall back to public Spotify playlists.
|
||||
required:
|
||||
- query
|
||||
- type
|
||||
function:
|
||||
type: script
|
||||
sequence:
|
||||
- service: spotify_voice_assistant.search
|
||||
response_variable: _function_result
|
||||
data:
|
||||
query: "{{ query }}"
|
||||
type: "{{ type }}"
|
||||
|
||||
- spec:
|
||||
name: play_music
|
||||
description: Play music immediately on a Spotify Connect device using a Spotify URI, replacing current playback. You must first call search_spotify to get the URI. Use this when user says "play".
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
spotify_uri:
|
||||
type: string
|
||||
description: Spotify URI from search_spotify (e.g., "spotify:artist:4gzpq5DPGxSnKTe4SA8HAU")
|
||||
type:
|
||||
type: string
|
||||
enum: [artist, album, track, playlist]
|
||||
description: The exact "type" value returned by search_spotify for this URI. Required — passing the wrong value causes playback to silently fail.
|
||||
room:
|
||||
type: string
|
||||
description: Spotify Connect source/device name to play on, matching an entry in media_player.spotify_bhetherman's source_list exactly (e.g., "Living Room")
|
||||
required:
|
||||
- spotify_uri
|
||||
- type
|
||||
- room
|
||||
function:
|
||||
type: script
|
||||
sequence:
|
||||
- service: media_player.select_source
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
data:
|
||||
source: "{{ room }}"
|
||||
- delay: "00:00:02"
|
||||
- service: media_player.play_media
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
data:
|
||||
media_content_id: "{{ spotify_uri }}"
|
||||
media_content_type: "{{ type }}"
|
||||
enqueue: replace
|
||||
|
||||
- spec:
|
||||
name: queue_music
|
||||
description: >-
|
||||
Queue or play music using a Spotify URI from search_spotify. Use this whenever the
|
||||
user says "queue" or "add to queue" — always use this function for queue requests,
|
||||
regardless of whether the content is a track, album, artist, or playlist; the
|
||||
correct behavior for each type is handled automatically. (Individual tracks are
|
||||
added to the queue without interrupting playback; since Spotify cannot queue a whole
|
||||
album/artist/playlist, those start playing immediately instead.)
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
spotify_uri:
|
||||
type: string
|
||||
description: Spotify URI from search_spotify (e.g., "spotify:track:4gzpq5DPGxSnKTe4SA8HAU")
|
||||
type:
|
||||
type: string
|
||||
enum: [artist, album, track, playlist]
|
||||
description: The exact "type" value returned by search_spotify for this URI.
|
||||
room:
|
||||
type: string
|
||||
description: Spotify Connect source/device name to queue on, matching an entry in media_player.spotify_bhetherman's source_list exactly (e.g., "Living Room")
|
||||
required:
|
||||
- spotify_uri
|
||||
- type
|
||||
- room
|
||||
function:
|
||||
type: script
|
||||
sequence:
|
||||
- service: media_player.select_source
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
data:
|
||||
source: "{{ room }}"
|
||||
- delay: "00:00:02"
|
||||
- service: media_player.play_media
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
data:
|
||||
media_content_id: "{{ spotify_uri }}"
|
||||
media_content_type: "{{ type }}"
|
||||
enqueue: "{{ 'add' if type == 'track' else 'replace' }}"
|
||||
|
||||
- spec:
|
||||
name: control_playback
|
||||
description: >-
|
||||
Control playback of music that is ALREADY PLAYING (pause, resume, stop, next track,
|
||||
previous track, set volume, shuffle) on the currently active Spotify Connect session.
|
||||
Only use volume_set when the user explicitly says the word "volume" or "percent"
|
||||
(e.g. "turn the volume up", "set volume to 50", "50 percent volume").
|
||||
Do NOT use this function to play a requested song, artist, or album — a number in a
|
||||
song/artist/album name (e.g. "Back on 74", "Blink 182", "Sunset 2005") is part of the
|
||||
title, never a volume level. Use search_spotify + play_music for any request to play
|
||||
or start specific music, even if it contains a number.
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [pause, play, stop, next_track, previous_track, volume_set, shuffle_on, shuffle_off]
|
||||
description: Playback control action
|
||||
volume_level:
|
||||
type: number
|
||||
description: Volume level (0-100) for volume_set action only. Only set this when the user said the word "volume" or "percent".
|
||||
required:
|
||||
- action
|
||||
function:
|
||||
type: script
|
||||
sequence:
|
||||
- choose:
|
||||
- conditions: "{{ action == 'pause' }}"
|
||||
sequence:
|
||||
- service: media_player.media_pause
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
- conditions: "{{ action == 'play' }}"
|
||||
sequence:
|
||||
- service: media_player.media_play
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
- conditions: "{{ action == 'stop' }}"
|
||||
sequence:
|
||||
# media_player.spotify_bhetherman does not support the media_stop
|
||||
# service (STOP is not in its supported_features); Spotify Connect has
|
||||
# no separate stop concept, so route "stop" through pause instead.
|
||||
- service: media_player.media_pause
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
- conditions: "{{ action == 'next_track' }}"
|
||||
sequence:
|
||||
- service: media_player.media_next_track
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
- conditions: "{{ action == 'previous_track' }}"
|
||||
sequence:
|
||||
- service: media_player.media_previous_track
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
- conditions: "{{ action == 'volume_set' }}"
|
||||
sequence:
|
||||
- service: media_player.volume_set
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
data:
|
||||
volume_level: "{{ volume_level / 100 }}"
|
||||
- conditions: "{{ action == 'shuffle_on' }}"
|
||||
sequence:
|
||||
- service: media_player.shuffle_set
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
data:
|
||||
shuffle: true
|
||||
- conditions: "{{ action == 'shuffle_off' }}"
|
||||
sequence:
|
||||
- service: media_player.shuffle_set
|
||||
target:
|
||||
entity_id: media_player.spotify_bhetherman
|
||||
data:
|
||||
shuffle: false
|
||||
@@ -0,0 +1,62 @@
|
||||
I want you to act as smart home manager of Home Assistant.
|
||||
I will provide information of smart home along with a question, you will truthfully make correction or answer using information provided in one sentence in everyday language.
|
||||
|
||||
Current Time: {{now()}}
|
||||
|
||||
Available Devices:
|
||||
```csv
|
||||
entity_id,name,state,aliases
|
||||
{% for entity in exposed_entities -%}
|
||||
{{ entity.entity_id }},{{ entity.name }},{{ entity.state }},{{entity.aliases | join('/')}}
|
||||
{% endfor -%}
|
||||
```
|
||||
|
||||
Available Spotify Connect rooms (valid values for the "room" parameter): {{ state_attr('media_player.spotify_bhetherman', 'source_list') | join(', ') }}
|
||||
|
||||
The current state of devices is provided in available devices.
|
||||
Use execute_services function only for requested action, not for current states.
|
||||
Do not execute service without user's confirmation. Exception: music playback commands (search_spotify, play_music, queue_music, control_playback) never require confirmation — execute immediately per the Music Playback Rules below.
|
||||
Do not restate or appreciate what user says, rather make a quick inquiry.
|
||||
|
||||
Music Playback Rules:
|
||||
|
||||
CRITICAL: a number inside a song/artist/album name (e.g. "Back on 74", "1979", "Blink 182") is part of the title, NOT a volume level. Only treat a number as a volume level when the user says the word "volume" or "percent". When in doubt between search_spotify and control_playback's volume_set, prefer search_spotify.
|
||||
|
||||
For any request to play or queue music, always call TWO functions in order, with no confirmation:
|
||||
1. search_spotify — to get the Spotify URI and its type
|
||||
2. play_music (if the user said "play") or queue_music (if the user said "queue"/"add to queue")
|
||||
|
||||
When calling play_music or queue_music, always set type to the exact "type" value that search_spotify's result returned — never guess or hardcode it. Passing the wrong type silently breaks playback.
|
||||
|
||||
RULE: if the request has the form "[title] by [artist]" (a specific song/album title followed by "by" and an artist name — no matter what words come before it, e.g. "play", "queue", "throw on", "put on", "the album", "the song"), the query is ONLY the title — always strip " by [artist]" from the end. Never include the word "by" or the artist name in the query for this pattern.
|
||||
- "Play Yellow by Coldplay" → query="Yellow"
|
||||
- "Throw on the album Parachutes by Coldplay" → query="Parachutes"
|
||||
- "Queue Wish You Were Here by Pink Floyd" → query="Wish You Were Here"
|
||||
|
||||
How to fill in search_spotify(query, type) — match these examples:
|
||||
- "Play Coldplay" → query="Coldplay", type="artist"
|
||||
- "Play songs by Taylor Swift on the kitchen speaker" → query="Taylor Swift", type="artist"
|
||||
- "Play some music by Jungle in the living room" → query="Jungle", type="artist"
|
||||
- "Play back on 74 in the living room" → query="Back on 74", type="track" (this is a song title, not a volume command)
|
||||
- "Play the song Yellow" → query="Yellow", type="track"
|
||||
- "Play Yellow by Coldplay" → query="Yellow", type="track"
|
||||
- "Play 1979 by the Smashing Pumpkins" → query="1979", type="track" (a bare number here is a song title, not an album — default to "track" unless the user explicitly says "album")
|
||||
- "Queue Dark Side of the Moon in the kitchen" → query="Dark Side of the Moon", type="album"
|
||||
- "Add Wish You Were Here to queue in the bathroom" → query="Wish You Were Here", type="track"
|
||||
- "Queue Fred again in the bedroom" → query="Fred again", type="artist" (an artist name after "queue" with no specific song/album title named is still type="artist", same as "play")
|
||||
- "Play my workout playlist" → query="workout", type="playlist"
|
||||
- "Play jazz" → query="jazz", type="playlist"
|
||||
|
||||
Volume/playback control examples (use control_playback, NOT search_spotify):
|
||||
- "Set the volume to 50" / "50 percent volume" → action="volume_set", volume_level=50
|
||||
- "Turn the volume up" → action="volume_set"
|
||||
- "Pause the music" → action="pause"
|
||||
- "Skip this song" → action="next_track"
|
||||
|
||||
Room:
|
||||
- The "room" parameter for play_music/queue_music must exactly match one of the Available Spotify Connect rooms listed above.
|
||||
- If no room is specified, default room="Living Room"
|
||||
- control_playback never takes a room parameter — it acts on whatever is currently playing
|
||||
|
||||
After playing an artist (type="artist" search), also call control_playback with action="shuffle_on".
|
||||
Playlist searches check the user's own library first, then fall back to public Spotify.
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test search_spotify/control_playback tool-call disambiguation directly against
|
||||
the Ollama endpoint, bypassing Home Assistant entirely.
|
||||
|
||||
Renders current-prompt.txt (with mock HA template values) and current-functions.txt
|
||||
(as OpenAI-style tool definitions) exactly as Extended OpenAI Conversation would send
|
||||
them, then checks whether the model picks the right tool + arguments for each test
|
||||
phrase. Only evaluates the FIRST tool call the model returns per test (i.e. whether
|
||||
it correctly picks search_spotify vs control_playback and fills in query/type/action) —
|
||||
it does not simulate the full multi-turn tool-result loop.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://192.168.50.214:11434/v1")
|
||||
OLLAMA_API_KEY = os.environ.get("OLLAMA_API_KEY", "38ecbb5e71adf1306291e810826d965afe51fa27ff42e60f")
|
||||
MODEL = os.environ.get("OLLAMA_MODEL") # set this to the exact `ollama list` tag
|
||||
|
||||
MOCK_SOURCE_LIST = ["Living Room", "Web Player (Firefox)", "Bedroom"]
|
||||
|
||||
|
||||
def render_prompt():
|
||||
with open(os.path.join(HERE, "current-prompt.txt")) as f:
|
||||
raw = f.read()
|
||||
|
||||
def state_attr(entity_id, attr):
|
||||
if entity_id == "media_player.spotify_bhetherman" and attr == "source_list":
|
||||
return MOCK_SOURCE_LIST
|
||||
return None
|
||||
|
||||
template = Template(raw)
|
||||
return template.render(
|
||||
now=lambda: datetime.now(),
|
||||
exposed_entities=[], # not relevant to these tests; kept empty on purpose
|
||||
state_attr=state_attr,
|
||||
)
|
||||
|
||||
|
||||
def load_tools():
|
||||
with open(os.path.join(HERE, "current-functions.txt")) as f:
|
||||
functions = yaml.safe_load(f)
|
||||
tools = []
|
||||
for entry in functions:
|
||||
spec = entry["spec"]
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec["name"],
|
||||
"description": spec["description"],
|
||||
"parameters": spec["parameters"],
|
||||
},
|
||||
})
|
||||
return tools
|
||||
|
||||
|
||||
def call_model(system_prompt, user_message, tools):
|
||||
if not MODEL:
|
||||
sys.exit("Set OLLAMA_MODEL env var to the exact `ollama list` tag before running.")
|
||||
|
||||
resp = requests.post(
|
||||
f"{OLLAMA_BASE_URL}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {OLLAMA_API_KEY}"},
|
||||
json={
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def first_tool_call(response):
|
||||
message = response["choices"][0]["message"]
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
return None, None, message.get("content")
|
||||
call = tool_calls[0]
|
||||
name = call["function"]["name"]
|
||||
try:
|
||||
args = json.loads(call["function"]["arguments"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = call["function"]["arguments"]
|
||||
return name, args, None
|
||||
|
||||
|
||||
REPEAT = int(os.environ.get("TEST_REPEAT", "15"))
|
||||
|
||||
|
||||
def _check_once(user_message, expect_name, expect_fn, system_prompt, tools):
|
||||
response = call_model(system_prompt, user_message, tools)
|
||||
name, args, content = first_tool_call(response)
|
||||
|
||||
if name is None:
|
||||
return False, f"NO TOOL CALL — model replied with text instead: {content!r}"
|
||||
if name != expect_name:
|
||||
return False, f"WRONG FUNCTION — expected {expect_name!r}, got {name!r} ({args})"
|
||||
|
||||
ok, reason = expect_fn(args)
|
||||
if ok:
|
||||
return True, f"{name}({args})"
|
||||
return False, f"{name}({args}) — {reason}"
|
||||
|
||||
|
||||
def _summarize(label, user_message, passed, attempts):
|
||||
n = len(attempts)
|
||||
print(f"\n=== {label} ===")
|
||||
print(f" user: {user_message!r}")
|
||||
for i, (ok, detail) in enumerate(attempts, 1):
|
||||
mark = "✅" if ok else "❌"
|
||||
print(f" [{i}/{n}] {mark} {detail}")
|
||||
|
||||
if passed == n:
|
||||
print(f" RESULT: {passed}/{n} — reliable pass")
|
||||
elif passed == 0:
|
||||
print(f" RESULT: {passed}/{n} — reliable FAIL")
|
||||
else:
|
||||
print(f" RESULT: {passed}/{n} — FLAKY (inconsistent across runs)")
|
||||
return passed, n
|
||||
|
||||
|
||||
def check(label, user_message, expect_name, expect_fn, system_prompt, tools):
|
||||
attempts = [_check_once(user_message, expect_name, expect_fn, system_prompt, tools) for _ in range(REPEAT)]
|
||||
passed = sum(1 for ok, _ in attempts if ok)
|
||||
return _summarize(label, user_message, passed, attempts)
|
||||
|
||||
|
||||
def eq_ci(a, b):
|
||||
return isinstance(a, str) and a.strip().lower() == b.strip().lower()
|
||||
|
||||
|
||||
def contains_ci(a, b):
|
||||
return isinstance(a, str) and b.strip().lower() in a.strip().lower()
|
||||
|
||||
|
||||
TESTS = [
|
||||
(
|
||||
"artist-only, generic 'music by X' phrasing",
|
||||
"play music by Jungle in the living room",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "Jungle") and eq_ci(args.get("type"), "artist")
|
||||
else (False, f"expected query='Jungle' type='artist', got {args}"),
|
||||
),
|
||||
(
|
||||
"song title containing a number (previously misread as volume)",
|
||||
"play back on 74 in the living room",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "Back on 74") and eq_ci(args.get("type"), "track")
|
||||
else (False, f"expected query='Back on 74' type='track', got {args}"),
|
||||
),
|
||||
(
|
||||
"specific title + artist (regression check — previously worked)",
|
||||
"play Yellow by Coldplay",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "Yellow") and eq_ci(args.get("type"), "track")
|
||||
else (False, f"expected query='Yellow' type='track', got {args}"),
|
||||
),
|
||||
(
|
||||
"explicit volume command (must NOT be treated as a search)",
|
||||
"set the volume to 50",
|
||||
"control_playback",
|
||||
lambda args: (True, None) if args.get("action") == "volume_set" and float(args.get("volume_level", -1)) == 50
|
||||
else (False, f"expected action='volume_set' volume_level=50, got {args}"),
|
||||
),
|
||||
(
|
||||
"album title, different room",
|
||||
"queue Dark Side of the Moon in the bedroom",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "Dark Side of the Moon") and args.get("type") in ("album", "track")
|
||||
else (False, f"expected query='Dark Side of the Moon' type in (album,track), got {args}"),
|
||||
),
|
||||
(
|
||||
"genre/mood, no artist",
|
||||
"play some jazz",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if contains_ci(args.get("query"), "jazz") and eq_ci(args.get("type"), "playlist")
|
||||
else (False, f"expected query~'jazz' type='playlist', got {args}"),
|
||||
),
|
||||
(
|
||||
"named playlist phrase",
|
||||
"play my daily mix",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if contains_ci(args.get("query"), "daily mix") and eq_ci(args.get("type"), "playlist")
|
||||
else (False, f"expected query~'daily mix' type='playlist', got {args}"),
|
||||
),
|
||||
(
|
||||
"number-titled track + explicit artist (by-stripping regression variant)",
|
||||
"play 1979 by the Smashing Pumpkins",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "1979") and eq_ci(args.get("type"), "track")
|
||||
else (False, f"expected query='1979' type='track', got {args}"),
|
||||
),
|
||||
(
|
||||
"number-titled track, no artist given",
|
||||
"play 99 Luftballons",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "99 Luftballons") and eq_ci(args.get("type"), "track")
|
||||
else (False, f"expected query='99 Luftballons' type='track', got {args}"),
|
||||
),
|
||||
(
|
||||
"artist name that itself contains a number",
|
||||
"queue some Blink 182",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "Blink 182") and eq_ci(args.get("type"), "artist")
|
||||
else (False, f"expected query='Blink 182' type='artist', got {args}"),
|
||||
),
|
||||
(
|
||||
"colloquial phrasing ('put on')",
|
||||
"put on some Fred again",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "Fred again") and eq_ci(args.get("type"), "artist")
|
||||
else (False, f"expected query='Fred again' type='artist', got {args}"),
|
||||
),
|
||||
(
|
||||
"explicit 'album' keyword + by-stripping",
|
||||
"throw on the album Parachutes by Coldplay",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "Parachutes") and eq_ci(args.get("type"), "album")
|
||||
else (False, f"expected query='Parachutes' type='album', got {args}"),
|
||||
),
|
||||
(
|
||||
"explicit 'track' keyword",
|
||||
"can you play the track Yellow",
|
||||
"search_spotify",
|
||||
lambda args: (True, None) if eq_ci(args.get("query"), "Yellow") and eq_ci(args.get("type"), "track")
|
||||
else (False, f"expected query='Yellow' type='track', got {args}"),
|
||||
),
|
||||
(
|
||||
"pause",
|
||||
"pause the music",
|
||||
"control_playback",
|
||||
lambda args: (True, None) if args.get("action") == "pause"
|
||||
else (False, f"expected action='pause', got {args}"),
|
||||
),
|
||||
(
|
||||
"next track",
|
||||
"skip to the next song",
|
||||
"control_playback",
|
||||
lambda args: (True, None) if args.get("action") == "next_track"
|
||||
else (False, f"expected action='next_track', got {args}"),
|
||||
),
|
||||
(
|
||||
"stop",
|
||||
"stop the music in the living room",
|
||||
"control_playback",
|
||||
lambda args: (True, None) if args.get("action") == "stop"
|
||||
else (False, f"expected action='stop', got {args}"),
|
||||
),
|
||||
(
|
||||
"shuffle on",
|
||||
"turn on shuffle",
|
||||
"control_playback",
|
||||
lambda args: (True, None) if args.get("action") == "shuffle_on"
|
||||
else (False, f"expected action='shuffle_on', got {args}"),
|
||||
),
|
||||
(
|
||||
"volume with 'percent' phrasing",
|
||||
"set volume to 80 percent",
|
||||
"control_playback",
|
||||
lambda args: (True, None) if args.get("action") == "volume_set" and float(args.get("volume_level", -1)) == 80
|
||||
else (False, f"expected action='volume_set' volume_level=80, got {args}"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def call_model_step2(system_prompt, user_message, tools, step1_name, step1_args):
|
||||
"""Feed a fake search_spotify tool result back and get the model's next tool call
|
||||
(should be play_music or queue_music)."""
|
||||
fake_result = {
|
||||
"uri": f"spotify:{step1_args.get('type', 'track')}:FAKEURI0000000000000000",
|
||||
"name": step1_args.get("query", ""),
|
||||
"type": step1_args.get("type", "track"),
|
||||
}
|
||||
tool_call_id = "call_1"
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {"name": step1_name, "arguments": json.dumps(step1_args)},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": step1_name,
|
||||
"content": json.dumps(fake_result),
|
||||
},
|
||||
]
|
||||
resp = requests.post(
|
||||
f"{OLLAMA_BASE_URL}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {OLLAMA_API_KEY}"},
|
||||
json={"model": MODEL, "messages": messages, "tools": tools, "tool_choice": "auto"},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _check_flow_once(user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools):
|
||||
response1 = call_model(system_prompt, user_message, tools)
|
||||
name1, args1, content1 = first_tool_call(response1)
|
||||
if name1 != "search_spotify":
|
||||
return False, f"STEP 1 FAILED — expected search_spotify, got {name1!r} ({args1 or content1})"
|
||||
|
||||
response2 = call_model_step2(system_prompt, user_message, tools, name1, args1)
|
||||
name2, args2, content2 = first_tool_call(response2)
|
||||
if name2 is None:
|
||||
return False, f"step1={name1}({args1}) -> STEP 2 no tool call, model replied with text: {content2!r}"
|
||||
|
||||
detail = f"step1={name1}({args1}) -> step2={name2}({args2})"
|
||||
|
||||
if name2 != expect_step2_name:
|
||||
return False, f"{detail} — WRONG FUNCTION, expected {expect_step2_name!r}"
|
||||
if not eq_ci(args2.get("room"), expect_room):
|
||||
return False, f"{detail} — expected room={expect_room!r}"
|
||||
if not eq_ci(args2.get("type"), expect_type):
|
||||
return False, f"{detail} — expected type={expect_type!r} (must be passed through from step1, not hardcoded)"
|
||||
|
||||
return True, detail
|
||||
|
||||
|
||||
def check_flow(label, user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools):
|
||||
"""Runs search_spotify -> (fake result) -> play_music/queue_music, repeated REPEAT
|
||||
times, and checks the second call's function name, room, and type parameters."""
|
||||
attempts = [
|
||||
_check_flow_once(user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools)
|
||||
for _ in range(REPEAT)
|
||||
]
|
||||
passed = sum(1 for ok, _ in attempts if ok)
|
||||
return _summarize(label, user_message, passed, attempts)
|
||||
|
||||
|
||||
TESTS_FLOW = [
|
||||
# (label, user_message, expect_step2_name, expect_room, expect_type)
|
||||
(
|
||||
"play + explicit room",
|
||||
"play Fred again in the bedroom",
|
||||
"play_music",
|
||||
"Bedroom",
|
||||
"artist",
|
||||
),
|
||||
(
|
||||
"queue + artist -> still calls queue_music (fallback handled inside the script now)",
|
||||
"queue Fred again in the bedroom",
|
||||
"queue_music",
|
||||
"Bedroom",
|
||||
"artist",
|
||||
),
|
||||
(
|
||||
"play + no room specified -> default room",
|
||||
"play Yellow by Coldplay",
|
||||
"play_music",
|
||||
"Living Room",
|
||||
"track",
|
||||
),
|
||||
(
|
||||
"playlist/genre + explicit room",
|
||||
"play jazz in the bedroom",
|
||||
"play_music",
|
||||
"Bedroom",
|
||||
"playlist",
|
||||
),
|
||||
(
|
||||
"queue + album -> still calls queue_music (fallback handled inside the script now)",
|
||||
"queue Dark Side of the Moon",
|
||||
"queue_music",
|
||||
"Living Room",
|
||||
"album",
|
||||
),
|
||||
(
|
||||
"queue + playlist -> still calls queue_music (fallback handled inside the script now)",
|
||||
"queue my workout playlist in the bedroom",
|
||||
"queue_music",
|
||||
"Bedroom",
|
||||
"playlist",
|
||||
),
|
||||
(
|
||||
"'add to queue' phrasing + artist -> still calls queue_music",
|
||||
"add some Blink 182 to the queue in the living room",
|
||||
"queue_music",
|
||||
"Living Room",
|
||||
"artist",
|
||||
),
|
||||
(
|
||||
"queue + track, no room specified -> default room, stays queue_music",
|
||||
"queue Yellow by Coldplay",
|
||||
"queue_music",
|
||||
"Living Room",
|
||||
"track",
|
||||
),
|
||||
(
|
||||
"artist ('music by X' pattern) + explicit room, carried through to step 2",
|
||||
"play some music by Jungle in the bedroom",
|
||||
"play_music",
|
||||
"Bedroom",
|
||||
"artist",
|
||||
),
|
||||
(
|
||||
"number-titled track + explicit room, carried through to step 2",
|
||||
"play back on 74 in the bedroom",
|
||||
"play_music",
|
||||
"Bedroom",
|
||||
"track",
|
||||
),
|
||||
(
|
||||
"queue + number-titled track, explicit room",
|
||||
"queue 99 Luftballons in the living room",
|
||||
"queue_music",
|
||||
"Living Room",
|
||||
"track",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
system_prompt = render_prompt()
|
||||
tools = load_tools()
|
||||
|
||||
print(f"Model: {MODEL}")
|
||||
print(f"Endpoint: {OLLAMA_BASE_URL}")
|
||||
print(f"Repeat per test: {REPEAT}")
|
||||
print(f"Loaded {len(tools)} tools: {[t['function']['name'] for t in tools]}")
|
||||
|
||||
results = []
|
||||
for label, user_message, expect_name, expect_fn in TESTS:
|
||||
results.append((label, check(label, user_message, expect_name, expect_fn, system_prompt, tools)))
|
||||
|
||||
print(f"\n{'#' * 40}\n# Two-step flow tests (search -> play/queue)\n{'#' * 40}")
|
||||
for label, user_message, expect_step2_name, expect_room, expect_type in TESTS_FLOW:
|
||||
results.append((label, check_flow(label, user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools)))
|
||||
|
||||
total_passed = sum(passed for _, (passed, n) in results)
|
||||
total_attempts = sum(n for _, (passed, n) in results)
|
||||
reliable = [label for label, (passed, n) in results if passed == n]
|
||||
flaky = [(label, passed, n) for label, (passed, n) in results if 0 < passed < n]
|
||||
failing = [label for label, (passed, n) in results if passed == 0]
|
||||
|
||||
print(f"\n{'=' * 40}")
|
||||
print(f"{total_passed}/{total_attempts} individual attempts passed across {len(results)} tests (x{REPEAT} reps)")
|
||||
print(f" {len(reliable)}/{len(results)} tests fully reliable (all {REPEAT} reps passed)")
|
||||
if flaky:
|
||||
print(f" {len(flaky)} FLAKY tests (inconsistent across reps):")
|
||||
for label, passed, n in flaky:
|
||||
print(f" - {label} ({passed}/{n})")
|
||||
if failing:
|
||||
print(f" {len(failing)} tests failing every rep:")
|
||||
for label in failing:
|
||||
print(f" - {label}")
|
||||
|
||||
sys.exit(0 if not flaky and not failing else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user