feat(taste): phase 1 — persistent per-user taste profile from graded engagement (#796)
Build a persistent, decaying model of each user's taste, recomputed daily, that later phases consume across every recommendation surface. Phase 1 only BUILDS the object — no behaviour change to what's surfaced yet. Core mechanic — graded engagement (replaces binary was_skipped for learning; was_skipped stays for History): a play's completion ratio maps to a signal in [-1,+1] via two linear ramps (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1). Time-decayed (half-life ~75d) so recent behaviour dominates and the profile tracks drift. Per operator constraints: - No explicit dislike button — negatives come only from passive behaviour (early skips). Nothing recorded to regret or opt out of. - Negatives are track-scoped; artist/tag weight is the decayed SUM of their tracks' engagement, so one skip nets out against many good plays (a DB test asserts a liked artist stays positive despite an early-skipped track). A floor clamp bounds how negative any single entity can get. - migration 0035: taste_profile_artists / taste_profile_tags (signed weight, indexed by (user, weight DESC)). - internal/taste: engagement.go (pure curve + decay) + profile.go (accumulate plays + like bonuses, floor damping, size caps, atomic-replace). - scheduler: rebuildUserDaily recomputes the profile before the playlist build (so phase 2 can read it), best-effort — a taste failure never blocks playlist building. Wired into the daily job + startup catch-up only (not manual/lazy rebuilds). - tests: pure (engagement curve, decay, ranking, floor, genre split) + DB-backed (positive/negative weights, aggregation-protects-artist, like bonus, atomic replace). All green vs real Postgres. Config knobs live in taste.DefaultConfig() for now; wiring them into the server RecommendationConfig is a later follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||
)
|
||||
|
||||
// dailyRebuildHour is the local-time hour at which each user's system
|
||||
@@ -158,10 +159,7 @@ func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz s
|
||||
job, err := s.gocron.NewJob(
|
||||
gocron.CronJob(cronExpr, false /* withSeconds */),
|
||||
gocron.NewTask(func() {
|
||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil {
|
||||
s.logger.Warn("scheduler: build failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
}
|
||||
s.rebuildUserDaily(ctx, userID, time.Now())
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -220,10 +218,22 @@ func (s *Scheduler) reconcile(ctx context.Context) {
|
||||
// whose scheduled fire was missed during downtime get caught up.
|
||||
func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) {
|
||||
for _, u := range users {
|
||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, u.ID, time.Now(), s.dataDir); err != nil {
|
||||
s.logger.Warn("scheduler: startup catch-up build failed",
|
||||
"user_id", uuidStringPL(u.ID), "err", err)
|
||||
}
|
||||
s.rebuildUserDaily(ctx, u.ID, time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildUserDaily recomputes the user's taste profile, then rebuilds their
|
||||
// system playlists + You-might-like rows. Taste runs first so the playlist
|
||||
// build can read a fresh profile (phase-2 consumption); both steps are
|
||||
// best-effort and a failure in one is logged without blocking the other.
|
||||
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
|
||||
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, now, taste.DefaultConfig()); err != nil {
|
||||
s.logger.Warn("scheduler: taste profile rebuild failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
}
|
||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
|
||||
s.logger.Warn("scheduler: build failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user