From f333607d76b05f9056d601aad0b4f592234b14f1 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 18:23:51 -0700 Subject: [PATCH] Recommendations: explain WHICH of your artists drive each suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds get_recommendation_sources() — for each recommended similar artist it resolves the polymorphic similar_artists.source_artist_id back to the display names of the user's OWN artists (library + watchlist) that list it, by matching against every provider-id column on both tables. The /api/discover/similar-artists endpoint now attaches a 'because' array per recommendation so the UI can show 'because you have X, Y, Z' instead of just a count. Seam tests cover: library + watchlist resolution across different provider-id columns, dedup + name-sort, max_per cap, orphan source omission, profile scoping. --- database/music_database.py | 64 ++++++++++++++ tests/test_recommendation_sources.py | 119 +++++++++++++++++++++++++++ web_server.py | 15 ++++ 3 files changed, 198 insertions(+) create mode 100644 tests/test_recommendation_sources.py diff --git a/database/music_database.py b/database/music_database.py index 3e7cdd67..fa187d74 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -9298,6 +9298,70 @@ class MusicDatabase: logger.error(f"Error getting top similar artists: {e}") return [] + def get_recommendation_sources( + self, + similar_artist_names: List[str], + profile_id: int = 1, + max_per: int = 6, + ) -> Dict[str, List[str]]: + """The 'because you have X, Y, Z' explanation behind each recommendation. + + For each name in ``similar_artist_names``, return the display names of the + user's OWN artists (library or watchlist) that list it as a similar + artist. ``similar_artists.source_artist_id`` is a polymorphic provider id + (the spotify / itunes / deezer / musicbrainz id of one of the user's + artists), so we resolve it back to a name by matching against every + provider-id column on ``artists`` and ``watchlist_artists``. + + Returns ``{similar_artist_name: [source_name, ...]}`` — deduped, + name-sorted, capped at ``max_per`` per recommendation. Names with no + resolvable source are omitted from the dict. + """ + names = [n for n in (similar_artist_names or []) if n] + if not names: + return {} + try: + with self._get_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in names) + cursor.execute(f""" + SELECT sa.similar_artist_name AS rec_name, + COALESCE(a.name, wa.artist_name) AS source_name + FROM similar_artists sa + LEFT JOIN artists a ON ( + (a.spotify_artist_id IS NOT NULL AND a.spotify_artist_id = sa.source_artist_id) + OR (a.itunes_artist_id IS NOT NULL AND a.itunes_artist_id = sa.source_artist_id) + OR (a.deezer_id IS NOT NULL AND a.deezer_id = sa.source_artist_id) + OR (a.musicbrainz_id IS NOT NULL AND a.musicbrainz_id = sa.source_artist_id) + ) + LEFT JOIN watchlist_artists wa ON ( + wa.profile_id = ? AND ( + (wa.spotify_artist_id IS NOT NULL AND wa.spotify_artist_id = sa.source_artist_id) + OR (wa.itunes_artist_id IS NOT NULL AND wa.itunes_artist_id = sa.source_artist_id) + OR (wa.deezer_artist_id IS NOT NULL AND wa.deezer_artist_id = sa.source_artist_id) + OR (wa.musicbrainz_artist_id IS NOT NULL AND wa.musicbrainz_artist_id = sa.source_artist_id) + ) + ) + WHERE sa.profile_id = ? AND sa.similar_artist_name IN ({placeholders}) + """, (profile_id, profile_id, *names)) + + # Collect distinct source names per recommendation, preserving + # nothing-special order then sorting for a deterministic result. + buckets: Dict[str, set] = {} + for row in cursor.fetchall(): + src = row['source_name'] + if not src: + continue + buckets.setdefault(row['rec_name'], set()).add(src) + + return { + rec: sorted(srcs, key=lambda s: s.lower())[:max_per] + for rec, srcs in buckets.items() + } + except Exception as e: + logger.error(f"Error resolving recommendation sources: {e}") + return {} + def mark_artists_featured(self, artist_names: List[str]): """Update last_featured timestamp for artists shown in the hero slider""" if not artist_names: diff --git a/tests/test_recommendation_sources.py b/tests/test_recommendation_sources.py new file mode 100644 index 00000000..8040e2b5 --- /dev/null +++ b/tests/test_recommendation_sources.py @@ -0,0 +1,119 @@ +"""Seam tests for recommendation explainability — get_recommendation_sources. + +The similar-artists worker stores rows keyed by a polymorphic +``source_artist_id`` (one of the user's artists' spotify / itunes / deezer / +musicbrainz ids). The "because you have X, Y, Z" explanation has to resolve +that id back to a display name by matching it against every provider-id column +on BOTH the library (`artists`) and `watchlist_artists` tables. + +These tests build a real schema via MusicDatabase(tmp) and insert rows directly +so the SQL join is exercised end to end. +""" + +from __future__ import annotations + +import sqlite3 + +from database.music_database import MusicDatabase + + +def _seed(path): + """Insert two library artists + one watchlist artist, and similar_artists + rows that point at them via different provider-id columns.""" + conn = sqlite3.connect(path) + cur = conn.cursor() + # Library artists, each matched on a DIFFERENT provider id column + cur.execute("INSERT INTO artists (name, spotify_artist_id) VALUES ('Radiohead', 'sp_radiohead')") + cur.execute("INSERT INTO artists (name, musicbrainz_id) VALUES ('Portishead', 'mb_portishead')") + # A watchlist-only artist (not in library), matched on deezer id + cur.execute( + "INSERT INTO watchlist_artists (artist_name, deezer_artist_id, profile_id) " + "VALUES ('Bjork', 'dz_bjork', 1)" + ) + + # 'Thom Yorke' is listed as similar by Radiohead (spotify id) AND Bjork + # (watchlist, deezer id) -> two distinct sources. + cur.execute( + "INSERT INTO similar_artists (source_artist_id, similar_artist_name, profile_id) " + "VALUES ('sp_radiohead', 'Thom Yorke', 1)" + ) + cur.execute( + "INSERT INTO similar_artists (source_artist_id, similar_artist_name, profile_id) " + "VALUES ('dz_bjork', 'Thom Yorke', 1)" + ) + # 'Massive Attack' listed by Portishead (musicbrainz id) only. + cur.execute( + "INSERT INTO similar_artists (source_artist_id, similar_artist_name, profile_id) " + "VALUES ('mb_portishead', 'Massive Attack', 1)" + ) + # An orphan: source id matches nobody -> must NOT produce a phantom source. + cur.execute( + "INSERT INTO similar_artists (source_artist_id, similar_artist_name, profile_id) " + "VALUES ('sp_ghost', 'Nobody Knows', 1)" + ) + conn.commit() + conn.close() + + +def test_resolves_library_and_watchlist_sources(tmp_path): + path = str(tmp_path / "m.db") + MusicDatabase(path) # build schema via migrations + _seed(path) + db = MusicDatabase(path) + + out = db.get_recommendation_sources(["Thom Yorke", "Massive Attack", "Nobody Knows"]) + + # Thom Yorke: resolved from a library spotify id AND a watchlist deezer id + assert out["Thom Yorke"] == ["Bjork", "Radiohead"] # deduped + name-sorted + # Massive Attack: resolved via musicbrainz id on a library artist + assert out["Massive Attack"] == ["Portishead"] + # Orphan recommendation has no resolvable source -> omitted entirely + assert "Nobody Knows" not in out + + +def test_empty_input_returns_empty(tmp_path): + path = str(tmp_path / "m.db") + db = MusicDatabase(path) + assert db.get_recommendation_sources([]) == {} + assert db.get_recommendation_sources([None, ""]) == {} + + +def test_max_per_caps_sources(tmp_path): + path = str(tmp_path / "m.db") + MusicDatabase(path) + conn = sqlite3.connect(path) + cur = conn.cursor() + # Five library artists all listing the same recommendation + for i in range(5): + cur.execute("INSERT INTO artists (name, spotify_artist_id) VALUES (?, ?)", + (f"Artist{i}", f"sp_{i}")) + cur.execute( + "INSERT INTO similar_artists (source_artist_id, similar_artist_name, profile_id) " + "VALUES (?, 'Shared Rec', 1)", (f"sp_{i}",)) + conn.commit() + conn.close() + + db = MusicDatabase(path) + out = db.get_recommendation_sources(["Shared Rec"], max_per=3) + assert len(out["Shared Rec"]) == 3 # capped + assert out["Shared Rec"] == ["Artist0", "Artist1", "Artist2"] # name-sorted + + +def test_profile_scoping(tmp_path): + """A source artist in another profile must not leak into the explanation.""" + path = str(tmp_path / "m.db") + MusicDatabase(path) + conn = sqlite3.connect(path) + cur = conn.cursor() + cur.execute("INSERT INTO artists (name, spotify_artist_id) VALUES ('Mine', 'sp_mine')") + # similar row belongs to profile 2, not 1 + cur.execute( + "INSERT INTO similar_artists (source_artist_id, similar_artist_name, profile_id) " + "VALUES ('sp_mine', 'Rec X', 2)" + ) + conn.commit() + conn.close() + + db = MusicDatabase(path) + assert db.get_recommendation_sources(["Rec X"], profile_id=1) == {} + assert db.get_recommendation_sources(["Rec X"], profile_id=2) == {"Rec X": ["Mine"]} diff --git a/web_server.py b/web_server.py index 6009563d..389d311a 100644 --- a/web_server.py +++ b/web_server.py @@ -26238,6 +26238,17 @@ def get_discover_similar_artists(): if not similar_artists: return jsonify({"success": True, "artists": [], "source": active_source, "count": 0}) + # Explainability: resolve which of the user's OWN artists point to each + # recommendation, so the UI can show "because you have X, Y, Z". + try: + sources_by_name = database.get_recommendation_sources( + [a.similar_artist_name for a in similar_artists], + profile_id=get_current_profile_id(), + ) + except Exception as e: + logger.debug("recommendation-sources lookup failed: %s", e) + sources_by_name = {} + # Artists already filtered by source in SQL result_artists = [] for artist in similar_artists: @@ -26268,6 +26279,10 @@ def get_discover_similar_artists(): artist_data["genres"] = artist.genres[:3] if artist.popularity: artist_data["popularity"] = artist.popularity + # "because you have X, Y, Z" — the artists of yours that point here + because = sources_by_name.get(artist.similar_artist_name) + if because: + artist_data["because"] = because result_artists.append(artist_data) logger.info(