feat(similarity): persist unmatched similar-artist MBIDs for M5c
upsertArtistSimilar keeps the existing matched path (top-K rows into artist_similarity) and adds a parallel unmatched-persist loop with the same top-K cap. Empty-name rows are skipped — we can't render a suggestion card without a name. Logs unmatched-side errors at WARN without aborting the tick (mirrors the matched-path policy).
This commit is contained in:
@@ -189,9 +189,10 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
taken := 0
|
// Matched: in-library similars → artist_similarity (existing path).
|
||||||
|
takenMatched := 0
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if taken >= w.topK {
|
if takenMatched >= w.topK {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
localID, ok := idByMBID[r.MBID]
|
localID, ok := idByMBID[r.MBID]
|
||||||
@@ -207,6 +208,34 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist
|
|||||||
w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr)
|
w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
taken++
|
takenMatched++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmatched: out-of-library similars → artist_similarity_unmatched (M5c).
|
||||||
|
// Same top-K cap as the matched path. Skip rows missing a name — we can't
|
||||||
|
// render a suggestion card without one.
|
||||||
|
takenUnmatched := 0
|
||||||
|
for _, r := range results {
|
||||||
|
if takenUnmatched >= w.topK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if _, inLib := idByMBID[r.MBID]; inLib {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Name == "" {
|
||||||
|
w.logger.Debug("similarity: skipping unmatched similar with empty name", "mbid", r.MBID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uerr := q.UpsertArtistSimilarityUnmatched(ctx, dbq.UpsertArtistSimilarityUnmatchedParams{
|
||||||
|
SeedArtistID: artistAID,
|
||||||
|
CandidateMbid: r.MBID,
|
||||||
|
CandidateName: r.Name,
|
||||||
|
Score: r.Score,
|
||||||
|
Source: "listenbrainz",
|
||||||
|
}); uerr != nil {
|
||||||
|
w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
takenUnmatched++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,3 +394,73 @@ func TestTickOnce_NoMBIDOnTrack_Skipped(t *testing.T) {
|
|||||||
t.Errorf("no-MBID track produced rows: %d", got)
|
t.Errorf("no-MBID track produced rows: %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUpsertArtistSimilar_PersistsUnmatchedToTable: M5c. Calls
|
||||||
|
// upsertArtistSimilar directly with a mix of in-library and out-of-library
|
||||||
|
// similar-artist payloads; verifies the matched path lands in
|
||||||
|
// artist_similarity (1 row), the unmatched path lands in
|
||||||
|
// artist_similarity_unmatched (3 rows), and rows with empty Name are
|
||||||
|
// skipped (we can't render a suggestion without a name).
|
||||||
|
func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Seed one additional in-library artist that will be the in-library match.
|
||||||
|
inLibMBID := "in-lib-mbid-123"
|
||||||
|
inLibArtist, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{
|
||||||
|
Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed in-lib artist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := newTestWorker(f, "")
|
||||||
|
|
||||||
|
similars := []listenbrainz.SimilarArtist{
|
||||||
|
{MBID: inLibMBID, Name: "InLib Artist", Score: 0.95},
|
||||||
|
{MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85},
|
||||||
|
{MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80},
|
||||||
|
{MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70},
|
||||||
|
{MBID: "out-mbid-4", Name: "", Score: 0.60}, // empty name — skipped
|
||||||
|
}
|
||||||
|
w.upsertArtistSimilar(ctx, f.q, f.artist.ID, similars)
|
||||||
|
|
||||||
|
// Matched path: 1 row in artist_similarity for the in-library candidate.
|
||||||
|
var matchedCount int
|
||||||
|
if err := f.pool.QueryRow(ctx,
|
||||||
|
"SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1",
|
||||||
|
f.artist.ID,
|
||||||
|
).Scan(&matchedCount); err != nil {
|
||||||
|
t.Fatalf("count matched: %v", err)
|
||||||
|
}
|
||||||
|
if matchedCount != 1 {
|
||||||
|
t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped.
|
||||||
|
var unmatchedCount int
|
||||||
|
if err := f.pool.QueryRow(ctx,
|
||||||
|
"SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1",
|
||||||
|
f.artist.ID,
|
||||||
|
).Scan(&unmatchedCount); err != nil {
|
||||||
|
t.Fatalf("count unmatched: %v", err)
|
||||||
|
}
|
||||||
|
if unmatchedCount != 3 {
|
||||||
|
t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify a specific row's name + score round-tripped correctly.
|
||||||
|
var name string
|
||||||
|
var score float64
|
||||||
|
if err := f.pool.QueryRow(ctx,
|
||||||
|
"SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2",
|
||||||
|
f.artist.ID, "out-mbid-1",
|
||||||
|
).Scan(&name, &score); err != nil {
|
||||||
|
t.Fatalf("fetch out-mbid-1: %v", err)
|
||||||
|
}
|
||||||
|
if name != "Outsider One" || score != 0.85 {
|
||||||
|
t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = inLibArtist
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user