diff --git a/app_clap_search.py b/app_clap_search.py index 42b8413f..1a515439 100644 --- a/app_clap_search.py +++ b/app_clap_search.py @@ -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 diff --git a/app_external.py b/app_external.py index 9db7d3bb..098dec94 100644 --- a/app_external.py +++ b/app_external.py @@ -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) diff --git a/app_lyrics.py b/app_lyrics.py index 3c865b63..32981366 100644 --- a/app_lyrics.py +++ b/app_lyrics.py @@ -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)) diff --git a/app_voyager.py b/app_voyager.py index 4fef7413..283ab8c1 100644 --- a/app_voyager.py +++ b/app_voyager.py @@ -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' diff --git a/tasks/ai/planner.py b/tasks/ai/planner.py index e81d1b6d..b4139158 100644 --- a/tasks/ai/planner.py +++ b/tasks/ai/planner.py @@ -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 ) @@ -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: @@ -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 @@ -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) diff --git a/tasks/ai/prompts.py b/tasks/ai/prompts.py index 7ab814fd..a4191a62 100644 --- a/tasks/ai/prompts.py +++ b/tasks/ai/prompts.py @@ -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:""" diff --git a/tasks/ai/tool_impl.py b/tasks/ai/tool_impl.py index a0328852..c2795479 100644 --- a/tasks/ai/tool_impl.py +++ b/tasks/ai/tool_impl.py @@ -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]: @@ -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: @@ -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 @@ -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) + ")") @@ -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: @@ -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: @@ -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(""" @@ -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'}") diff --git a/tasks/ai/tools.py b/tasks/ai/tools.py index 9c8d912d..c519e80a 100644 --- a/tasks/ai/tools.py +++ b/tasks/ai/tools.py @@ -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}"} @@ -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( @@ -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": { @@ -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}, }, }, diff --git a/templates/clap_search.html b/templates/clap_search.html index 7403f60a..09bac800 100644 --- a/templates/clap_search.html +++ b/templates/clap_search.html @@ -297,8 +297,8 @@