Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app_clap_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ def clap_search_api():
if not query:
return jsonify({'error': 'Query cannot be empty'}), 400

if len(query) < 3:
return jsonify({'error': 'Query must be at least 3 characters'}), 400
if len(query) < 1:
return jsonify({'error': 'Query must be at least 1 character'}), 400

# Validate limit
limit = min(max(1, int(limit)), 500) # Between 1 and 500
Expand Down
4 changes: 2 additions & 2 deletions app_external.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ def search_tracks_endpoint():
return jsonify([])

# Enforce minimum length constraint
if len(search_query) < 3:
return jsonify({"error": "Query must be at least 3 characters long"}), 400
if len(search_query) < 1:
return jsonify({"error": "Query must be at least 1 character long"}), 400

try:
results = search_tracks_unified(search_query)
Expand Down
4 changes: 2 additions & 2 deletions app_lyrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,8 @@ def lyrics_search_text_api():
query = (data.get('query') or '').strip()
if not query:
return jsonify({'error': 'Missing "query".'}), 400
if len(query) < 3:
return jsonify({'error': 'Query must be at least 3 characters.'}), 400
if len(query) < 1:
return jsonify({'error': 'Query must be at least 1 character.'}), 400

try:
limit = int(data.get('limit', 50))
Expand Down
2 changes: 1 addition & 1 deletion app_voyager.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def search_tracks_endpoint():
if not search_query:
return jsonify([])

if len(search_query) < 3:
if len(search_query) < 1:
return jsonify([])

# Optional index filter: 'musicnn' (default) or 'sem_grove'
Expand Down
14 changes: 13 additions & 1 deletion tasks/ai/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ def _cat_conf(d):
FILTER_LIST_KEYS = ('genres', 'voices', 'moods', 'other_features')
FILTER_MIN_KEYS = ('tempo_min', 'energy_min', 'year_min', 'min_rating')
FILTER_MAX_KEYS = ('tempo_max', 'energy_max', 'year_max')
FILTER_SCALAR_KEYS = ('key', 'scale', 'album', 'artist')
FILTER_SCALAR_KEYS = ('key', 'scale', 'album', 'artist', 'instrumental')
FILTER_ALL_KEYS = (
FILTER_LIST_KEYS + FILTER_MIN_KEYS + FILTER_MAX_KEYS + FILTER_SCALAR_KEYS
)
Expand All @@ -420,6 +420,11 @@ class ToolPlan:
_ENERGY_NUM_RE = re.compile(
r"\benergy\s*(?:above|>=?|over|min(?:imum)?)\s*([0-9]*\.[0-9]+|[0-9]+)\b", re.IGNORECASE
)
_INSTRUMENTAL_RE = re.compile(
r'\b(?:instrumentals?|no\s+(?:vocals?|lyrics|singing|voice)|'
r'without\s+(?:vocals?|lyrics|singing|voice))\b',
re.IGNORECASE,
)


def _normalize_decade(prefix: str) -> int:
Expand Down Expand Up @@ -486,6 +491,11 @@ def extract_hints(text: str) -> Dict:
except ValueError:
pass

# Instrumental detection: keyword-based, same pattern as tempo/energy above.
if _INSTRUMENTAL_RE.search(text):
hints['instrumental'] = True
notes.append("instrumental requested")

if notes:
hints['notes'] = notes
return hints
Expand All @@ -510,6 +520,8 @@ def format_hints_block(hints: Optional[Dict]) -> str:
lines.append(
f" energy: {hints.get('energy_min', '?')}..{hints.get('energy_max', '?')}"
)
if hints.get('instrumental') is True:
lines.append(" instrumental: true (use instrumental=true in search_database)")
if not lines:
return ""
return "EXTRACTED_HINTS (use these values directly in search_database if relevant):\n" + "\n".join(lines)
Expand Down
2 changes: 2 additions & 0 deletions tasks/ai/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ def build_intent_classifier_prompt(user_message: str) -> str:
"top pop radio songs of 2025" -> {{"primaries": ["knowledge"], "needs_filter": true}}
"sad jazz from the 90s" -> {{"primaries": [], "needs_filter": true}}
"upbeat pop roadtrip songs about summer with female vocals" -> {{"primaries": ["text"], "needs_filter": true}}
"pop instrumental" -> {{"primaries": [], "needs_filter": true}}
"instrumental jazz" -> {{"primaries": [], "needs_filter": true}}

Request: "{user_message}"
JSON:"""
Expand Down
82 changes: 50 additions & 32 deletions tasks/ai/tool_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,27 @@ def _reroute_mood_labels_from_genres(genres, moods):

_FUZZY_MATCH_CUTOFF = 75
_FUZZY_CANDIDATE_POOL_LIMIT = 500
_FUZZY_PREFIX_LEN = 3
_FUZZY_PREFIX_LEN = 1

# Shared SQL fragments for mood_vector substring queries (avoids literal duplication).
_MOOD_VECTOR_GE_SQL = (
"COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) >= %s"
)
_MOOD_VECTOR_LT_SQL = (
"COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) < %s"
)
_MOOD_VECTOR_SCORE_SQL = """\
COALESCE(
CAST(
NULLIF(
SUBSTRING(mood_vector FROM %s),
''
) AS NUMERIC
),
0
)"""
# Canonical regex for the instrumental label in mood_vector (musicnn output).
_INSTRUMENTAL_REGEX = r"(?i)(?:^|,)\s*instrumental:(\d+\.?\d*)"


def _fetch_pool_features(item_ids: List[str]) -> Dict[str, Dict]:
Expand Down Expand Up @@ -175,12 +195,12 @@ def _fuzzy_match_author_title(
prefix_params: List = []
if author_prefix:
prefix_conditions.append(
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(author), ' ', ''), '-', ''), '‐', ''), '/', ''), '''', '') LIKE %s"
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(author), ' ', ''), '-', ''), '‐', ''), '/', ''), '''', '') ILIKE %s"
)
prefix_params.append(f"{author_prefix}%")
if title_prefix:
prefix_conditions.append(
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(title), ' ', ''), '-', ''), '‐', ''), '/', ''), '''', '') LIKE %s"
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(title), ' ', ''), '-', ''), '‐', ''), '/', ''), '''', '') ILIKE %s"
)
prefix_params.append(f"{title_prefix}%")
if not prefix_conditions:
Expand Down Expand Up @@ -643,6 +663,7 @@ def _database_genre_query_sync(
candidate_item_ids: Optional[List[str]] = None,
voices: Optional[List[str]] = None,
score_threshold: Optional[float] = None,
instrumental: Optional[bool] = None,
) -> Dict:
get_songs = int(get_songs) if get_songs is not None else 100

Expand Down Expand Up @@ -685,9 +706,7 @@ def _database_genre_query_sync(
if genres:
genre_conditions = []
for genre in genres:
genre_conditions.append(
"COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) >= %s"
)
genre_conditions.append(_MOOD_VECTOR_GE_SQL)
params.append(f"(?i)(?:^|,)\\s*{re.escape(genre)}:(\\d+\\.?\\d*)")
params.append(mood_vector_threshold)
conditions.append("(" + " OR ".join(genre_conditions) + ")")
Expand All @@ -697,9 +716,7 @@ def _database_genre_query_sync(
if voices:
voice_conditions = []
for voice in voices:
voice_conditions.append(
"COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) >= %s"
)
voice_conditions.append(_MOOD_VECTOR_GE_SQL)
params.append(f"(?i)(?:^|,)\\s*{re.escape(voice)}:(\\d+\\.?\\d*)")
params.append(mood_vector_threshold)
if len(voice_conditions) == 1:
Expand All @@ -708,6 +725,22 @@ def _database_genre_query_sync(
conditions.append("(" + " OR ".join(voice_conditions) + ")")
has_voice_filter = True

has_instrumental_filter = False
if instrumental is not None:
# Coerce string values (AI models sometimes pass 'true'/'false' as strings).
if isinstance(instrumental, str):
instrumental = instrumental.strip().lower() in ('true', '1', 'yes')
instrumental = bool(instrumental)
if instrumental:
conditions.append(_MOOD_VECTOR_GE_SQL)
params.append(_INSTRUMENTAL_REGEX)
params.append(mood_vector_threshold)
has_instrumental_filter = True
else:
conditions.append(_MOOD_VECTOR_LT_SQL)
params.append(_INSTRUMENTAL_REGEX)
params.append(mood_vector_threshold)

has_other_filter = False
other_confidence_threshold = other_features_threshold
if other_features:
Expand Down Expand Up @@ -773,37 +806,20 @@ def _database_genre_query_sync(

order_clause = "ORDER BY RANDOM()" if pool_order_index is None else ""

if has_genre_filter or has_voice_filter or has_other_filter:
if has_genre_filter or has_voice_filter or has_other_filter or has_instrumental_filter:
score_parts = []
score_params = []
if has_genre_filter:
for genre in genres:
score_parts.append("""
COALESCE(
CAST(
NULLIF(
SUBSTRING(mood_vector FROM %s),
''
) AS NUMERIC
),
0
)
""")
score_parts.append(_MOOD_VECTOR_SCORE_SQL)
score_params.append(f"(?i)(?:^|,)\\s*{re.escape(genre)}:(\\d+\\.?\\d*)")
if has_voice_filter:
for voice in voices:
score_parts.append("""
COALESCE(
CAST(
NULLIF(
SUBSTRING(mood_vector FROM %s),
''
) AS NUMERIC
),
0
)
""")
score_parts.append(_MOOD_VECTOR_SCORE_SQL)
score_params.append(f"(?i)(?:^|,)\\s*{re.escape(voice)}:(\\d+\\.?\\d*)")
if has_instrumental_filter:
score_parts.append(_MOOD_VECTOR_SCORE_SQL)
score_params.append(_INSTRUMENTAL_REGEX)
if has_other_filter:
for of in other_features:
score_parts.append("""
Expand Down Expand Up @@ -880,6 +896,8 @@ def _database_genre_query_sync(
filters.append(f"album: {album}")
if artist:
filters.append(f"artist: {artist}")
if instrumental is not None:
filters.append(f"instrumental: {instrumental}")

log_messages.append(f"Found {len(songs)} songs matching {', '.join(filters) if filters else 'all criteria'}")

Expand Down
15 changes: 11 additions & 4 deletions tasks/ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ def execute_mcp_tool(tool_name: str, tool_args: Dict, ai_config: Dict) -> Dict:
candidate_item_ids=tool_args.get("candidate_item_ids"),
voices=tool_args.get("voices"),
score_threshold=tool_args.get("score_threshold"),
instrumental=tool_args.get("instrumental"),
)

return {"error": f"Unknown tool: {tool_name}"}
Expand Down Expand Up @@ -278,9 +279,9 @@ def get_mcp_tools() -> List[Dict]:
if text_match_modes:
mode_desc_parts = []
if "audio" in text_match_modes:
mode_desc_parts.append("'audio' (default): match sound/instruments/textures ('calm piano', 'energetic guitar', 'romantic strings')")
mode_desc_parts.append("'audio' (default): match sound/instruments/textures. Include 'instrumental' in the query to find instrumental-sounding tracks ('calm instrumental piano', 'epic orchestral instrumental').")
if "lyrics" in text_match_modes:
mode_desc_parts.append("'lyrics': match lyrical themes ('songs about heartbreak', 'lyrics about freedom')")
mode_desc_parts.append("'lyrics': match lyrical themes ('songs about heartbreak', 'lyrics about freedom').")
mode_desc = ". ".join(mode_desc_parts)

tools.append(
Expand Down Expand Up @@ -349,8 +350,10 @@ def get_mcp_tools() -> List[Dict]:
{
"name": "search_database",
"description": (
"Filter the library by metadata. Use when the user names genres, moods, vocals, "
"year/decade, tempo, energy, scale, key, rating, album, or a single artist. "
"Filter the library by metadata. Use when the user names genres, vocals, "
"year/decade, tempo, energy, scale, key, rating, album, artist, or instrumental. "
"For instrumental tracks, set instrumental=true (queries musicnn score). "
"For non-instrumental, set instrumental=false. "
"Can stand alone OR refine a seed_search/text_match/knowledge_lookup pool."
),
"inputSchema": {
Expand Down Expand Up @@ -393,6 +396,10 @@ def get_mcp_tools() -> List[Dict]:
"min_rating": {"type": "integer", "description": "Minimum user rating 1-5"},
"album": {"type": "string", "description": "Album name to filter by"},
"artist": {"type": "string", "description": "Single artist name (use seed_search for multiple)"},
"instrumental": {
"type": "boolean",
"description": "true = only instrumental tracks. false = only tracks with vocals.",
},
"get_songs": {"type": "integer", "default": 200},
},
},
Expand Down
4 changes: 2 additions & 2 deletions templates/clap_search.html
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,8 @@ <h3>Create a Playlist from Results</h3>
const query = searchQuery.value.trim();
const limit = parseInt(document.getElementById('limit').value);

if (!query || query.length < 3) {
alert('Please enter at least 3 characters');
if (!query || query.length < 1) {
alert('Please enter at least 1 character');
return;
}

Expand Down
6 changes: 3 additions & 3 deletions templates/lyrics_search.html
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ <h3>Create a Playlist from Results</h3>
clearTimeout(sgTimeout);
sgTimeout = setTimeout(async () => {
const q = sgInput.value.trim();
if (q.length < 3) { sgHide(); return; }
if (q.length < 1) { sgHide(); return; }
sgQuery = q; sgOffset = 0;
try {
const p = new URLSearchParams({ search_query: q, start: 0, end: PAGE_SIZE, index: 'sem_grove' });
Expand Down Expand Up @@ -498,8 +498,8 @@ <h3>Create a Playlist from Results</h3>
document.getElementById('search-form').addEventListener('submit', async function (e) {
e.preventDefault();
const query = document.getElementById('search-query').value.trim();
if (!query || query.length < 3) {
alert('Please enter at least 3 characters.');
if (!query || query.length < 1) {
alert('Please enter at least 1 character.');
return;
}
const limit = parseInt(document.getElementById('text-limit').value, 10) || 50;
Expand Down
2 changes: 1 addition & 1 deletion templates/path.html
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ <h3>Create a Playlist from Path</h3>
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const searchQuery = searchInputEl.value.trim();
if (searchQuery.length < 3) {
if (searchQuery.length < 1) {
resultsEl.classList.add('hidden');
return;
}
Expand Down
2 changes: 1 addition & 1 deletion templates/similarity.html
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ <h3>Create a Playlist from Results</h3>
searchTimeout = setTimeout(async () => {
const searchQuery = searchInput.value.trim();

if (searchQuery.length < 3) {
if (searchQuery.length < 1) {
hideAutocomplete();
return;
}
Expand Down
2 changes: 1 addition & 1 deletion templates/waveform.html
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ <h3 id="track-title">Track Title</h3>
searchTimeout = setTimeout(async () => {
const searchQuery = searchInput.value.trim();

if (searchQuery.length < 3) {
if (searchQuery.length < 1) {
hideAutocomplete();
return;
}
Expand Down
13 changes: 7 additions & 6 deletions tests/unit/test_external_search_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ def test_explicit_empty_query_returns_empty_list(self, ext, client):
assert resp.get_json() == []
backend.assert_not_called()

def test_two_char_query_returns_400(self, ext, client):
with patch.object(ext, 'search_tracks_unified') as backend:
resp = client.get('/search', query_string={'search_query': 'ab'})
assert resp.status_code == 400
assert resp.get_json() == {"error": "Query must be at least 3 characters long"}
backend.assert_not_called()
def test_one_char_query_reaches_backend(self, ext, client):
results = [{'item_id': 'id-1', 'title': 'Song', 'author': 'Artist'}]
with patch.object(ext, 'search_tracks_unified', return_value=results) as backend:
resp = client.get('/search', query_string={'search_query': 'a'})
assert resp.status_code == 200
assert resp.get_json() == results
backend.assert_called_once_with('a')

def test_valid_query_reaches_backend_and_returns_its_value(self, ext, client):
results = [{'item_id': 'id-1', 'title': 'Song', 'author': 'Artist'}]
Expand Down
Loading