feat(discovery): run_discovery aggregates similar artists into suggestions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import psycopg
|
||||
|
||||
from lyra_worker.browser import MbBrowser
|
||||
from lyra_worker.similarity.base import SimilaritySource
|
||||
|
||||
_TRUE = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveryConfig:
|
||||
enabled: bool = False
|
||||
interval_hours: int = 168
|
||||
max_seeds: int = 50
|
||||
similar_per_seed: int = 20
|
||||
albums_per_artist: int = 1
|
||||
min_score: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: dict) -> "DiscoveryConfig":
|
||||
def _int(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(config[key])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(config[key])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return cls(
|
||||
enabled=str(config.get("discover.enabled", "")).strip().lower() in _TRUE,
|
||||
interval_hours=_int("discover.intervalHours", 168),
|
||||
max_seeds=_int("discover.maxSeeds", 50),
|
||||
similar_per_seed=_int("discover.similarPerSeed", 20),
|
||||
albums_per_artist=_int("discover.albumsPerArtist", 1),
|
||||
min_score=_float("discover.minScore", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveryResult:
|
||||
artists: int
|
||||
albums: int
|
||||
|
||||
|
||||
def _seed_artists(conn: psycopg.Connection, cfg: DiscoveryConfig):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
'SELECT mbid, name FROM "WatchedArtist" '
|
||||
'WHERE "lastDiscoveredAt" IS NULL '
|
||||
' OR "lastDiscoveredAt" < now() - make_interval(hours => %s) '
|
||||
'ORDER BY "lastDiscoveredAt" ASC NULLS FIRST LIMIT %s',
|
||||
(cfg.interval_hours, cfg.max_seeds),
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def _followed_mbids(conn: psycopg.Connection) -> set[str]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT mbid FROM "WatchedArtist"')
|
||||
return {r[0] for r in cur.fetchall()}
|
||||
|
||||
|
||||
def _upsert_artist(conn: psycopg.Connection, cand: dict) -> int:
|
||||
dedupe = f"artist:{cand['mbid']}:"
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", '
|
||||
'score, "seedCount", sources, status, "dedupeKey", "createdAt", "updatedAt") '
|
||||
"VALUES (gen_random_uuid()::text, 'artist', %s, %s, %s, %s, %s, 'pending', %s, now(), now()) "
|
||||
'ON CONFLICT ("dedupeKey") DO UPDATE SET '
|
||||
'score = EXCLUDED.score, "seedCount" = EXCLUDED."seedCount", '
|
||||
'sources = EXCLUDED.sources, "updatedAt" = now() '
|
||||
"WHERE \"DiscoverySuggestion\".status = 'pending'",
|
||||
(cand["mbid"], cand["name"], cand["score"], cand["seed_count"],
|
||||
sorted(cand["sources"]), dedupe),
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[dict],
|
||||
cfg: DiscoveryConfig) -> int:
|
||||
return 0 # replaced in the album-derivation task
|
||||
|
||||
|
||||
def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource],
|
||||
browser: MbBrowser, cfg: DiscoveryConfig) -> DiscoveryResult:
|
||||
"""Aggregate similar artists across the library's seed artists; upsert suggestions."""
|
||||
seeds = _seed_artists(conn, cfg)
|
||||
followed = _followed_mbids(conn)
|
||||
live = [s for s in sources if s.health()]
|
||||
|
||||
agg: dict[str, dict] = {}
|
||||
for seed_mbid, _seed_name in seeds:
|
||||
for src in live:
|
||||
try:
|
||||
similar = src.similar_artists(seed_mbid)
|
||||
except Exception as e: # a bad source/seed must not abort the sweep
|
||||
print(f"worker: discovery source {src.name} failed for {seed_mbid}: {e}", flush=True)
|
||||
continue
|
||||
for sa in sorted(similar, key=lambda a: a.score, reverse=True)[: cfg.similar_per_seed]:
|
||||
if sa.mbid in followed:
|
||||
continue
|
||||
c = agg.setdefault(sa.mbid, {"mbid": sa.mbid, "name": sa.name,
|
||||
"score": 0.0, "seeds": set(), "sources": set()})
|
||||
c["score"] += sa.score
|
||||
c["seeds"].add(seed_mbid)
|
||||
c["sources"].add(src.name)
|
||||
if sa.name and not c["name"]:
|
||||
c["name"] = sa.name
|
||||
|
||||
artists = 0
|
||||
surfaced: list[dict] = []
|
||||
for c in sorted(agg.values(), key=lambda c: c["score"], reverse=True):
|
||||
if c["score"] < cfg.min_score:
|
||||
continue
|
||||
cand = {"mbid": c["mbid"], "name": c["name"], "score": c["score"],
|
||||
"seed_count": len(c["seeds"]), "sources": c["sources"]}
|
||||
inserted = _upsert_artist(conn, cand)
|
||||
artists += inserted
|
||||
# Only derive albums for candidates that produced a live pending suggestion.
|
||||
# A no-op upsert (rowcount 0) means the artist was already dismissed/wanted —
|
||||
# don't surface their albums against a dismissal.
|
||||
if inserted:
|
||||
surfaced.append(cand)
|
||||
|
||||
albums = _derive_albums(conn, browser, surfaced, cfg)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
for seed_mbid, _seed_name in seeds:
|
||||
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s',
|
||||
(seed_mbid,))
|
||||
conn.commit()
|
||||
return DiscoveryResult(artists=artists, albums=albums)
|
||||
Reference in New Issue
Block a user