The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.
- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
8 weight columns), taste_tuning singleton (engagement half-life +
completion-curve points), recommendation_tuning_audit (one row per
change with a {field, old, new} diff — the trend view's markers,
#1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
clobbering tuned rows (coverart SettingsService pattern), validates
patches (bounds, curve ordering), writes audit rows, and pushes
daily_mix weights + taste config into package playlists. No-op
patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
under a RWMutex — no signature threading through the producers; the
scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
weight fields leave config.RecommendationConfig (YAML keeps only
RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
card, per-scope save (changed fields only) + reset, deviation dots
against shipped defaults.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
The daily 03:00 scheduler rebuild (and the manual refresh endpoint) replace
a user's system playlists + You-might-like rows but published no event, so a
client left open across the rebuild served yesterday's snapshot until a
manual reload — the stale-tab case behind #968. Add a user-scoped
playlist.system_rebuilt event (envelope {kind,user_id,data:{}}) from both the
scheduler (bus threaded into NewScheduler) and handleSystemPlaylistRefresh.
Clients consume it to invalidate home / system-playlist views and proactively
re-pull a stale active queue. Issue #968.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
golangci-lint v2 (CI-only; local is v1) flagged two unused-parameter issues:
- BuildTasteProfile's `now` was genuinely dead — decay/windowing are computed
DB-side via now(), so no Go-side timestamp is threaded. Removed it (a
phase-3 context model that needs a pinned reference time would re-add it);
updated the scheduler call site.
- the degenerate-params engagement test ignored t; reworked it to assert the
result stays in [-1,1], which also strengthens the test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
gocron v2's WithLocation is a SchedulerOption (process-wide), not a
JobOption — there's no per-job location knob. To get per-user
timezones we use a cron expression with the CRON_TZ= prefix, which
the underlying robfig/cron parser honors:
CRON_TZ=America/New_York 0 3 * * *
Fires at 03:00 in the named zone every day. Same DST-correctness as
the original WithLocation approach.
Fall-back to UTC moves inline (was validateTimezoneOrUTC); kept the
helper because the test file still exercises it.
Caught by go vet on CI after slice 2 pushed:
"cannot use gocron.WithLocation(loc) (value of type
gocron.SchedulerOption) as gocron.JobOption value"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds the per-user daily-at-03:00-local scheduler for #392 Half B.
Uses github.com/go-co-op/gocron/v2 with WithLocation(userTZ) for
each user's job. Hourly reconciliation pass keeps the in-memory
job set in sync with the active-users query.
Start sequence: clear stale in_flight rows; register a daily job
for every active user at their stored timezone; fire a one-shot
runOnce to catch up missed schedules during downtime; start gocron.
Stop drains and shuts down the gocron loop.
Refresh(ctx, userID) removes the user's existing job (if any) and
registers a fresh one at their current timezone — wired into the
PUT /api/me/timezone handler and new-user registration in the next
commit.
Server struct gains PlaylistScheduler; main.go constructs it after
the eventbus and threads it through to api.Mount. The existing
StartSystemPlaylistCron call stays for one more commit so we don't
have a window where no scheduler is running; Task 3 deletes it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>