Recommendations: explain WHICH of your artists drive each suggestion
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.
This commit is contained in:
parent
48b8247a0c
commit
f333607d76
3 changed files with 198 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
119
tests/test_recommendation_sources.py
Normal file
119
tests/test_recommendation_sources.py
Normal file
|
|
@ -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"]}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue