test-web / test (push) Successful in 1m7s
test-go / test (push) Successful in 1m31s
test-go / integration (push) Failing after 4m21s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: "when I play it I'm expecting to get a consistent
sound and style from the experience... I was getting a seeming wide variety
of music from each one when I was hoping to stay in a certain neighborhood."
Songs-like shared the `daily_mix` weight profile with For-You, and that
sharing WAS the bug. The two surfaces want opposite things: For-You answers
"what will they enjoy today" and is supposed to roam; Songs-like answers
"what sounds like THIS". Under one profile the broad answer wins.
The arithmetic, from the shared weights:
unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
Liking something outranked sounding like the seed, because LikeBoost (2.0)
exceeded SimilarityWeight's whole range (1.5) and TasteWeight (1.5, and
seed-INDEPENDENT) matched it outright. Under the new profile the same pair
scores 5.00 vs 2.00.
Two levers, because either alone leaves the other's failure intact:
POOL. Songs-like now takes its own CandidateSourceLimits. The default gave
~29% of candidates a sim_score of literally zero — `taste_overlap` and
`random_fill` are both `0.0::float8` in recommendation.sql, seed-independent
by construction. Same total pool size; composition shifts to arms that
measure distance from the seed, LBSimilar doubled.
WEIGHTS. A third profile beside radio and daily_mix, DB-backed and live per
rule 25, with the property that similarity's range exceeds the combined
range of every seed-independent differentiator — so a closer match cannot
be beaten on likes, freshness and taste alone, while tracks within ~0.39
similarity of each other still get ordered by what the user likes.
Rule 131 changed the pool design mid-way and for the better. Zeroing the
two seed-independent arms was the first instinct and is exactly the
vanish-or-nothing shape that rule forbids: a seed with thin ListenBrainz
coverage would yield a short mix or none. They are the tier-3 FLOOR — cut
hard, never removed — and the weights keep them at the bottom of the
ranking rather than out of the pool. "A few tracks further from the seed
than we'd like" beats "no playlist".
Caught while wiring it: switching only pickTopN's final Score would have
been nearly INERT. scoreAndSortCandidates does the selection sort, and the
caller caps and truncates in that order — so the playlist would still have
been chosen by daily_mix and merely relabelled with songs_like numbers. It
now takes the profile as a parameter, and each surface passes its own.
Also corrects the daily_mix card's blurb, which claimed Songs-like as one
of its surfaces and no longer is.
Guards pin behaviour rather than the numbers, since numbers get retuned:
that similarity beats an unrelated liked track, that daily_mix still
DOESN'T (or the split buys nothing), that the tier-3 floor is non-zero,
and that the UI card shows its own values rather than falling back. Each
falsified against its named regression first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
581 lines
20 KiB
Go
581 lines
20 KiB
Go
// Package recsettings is the DB-backed home of the recommendation
|
||
// tuning knobs (#1250): the two scoring-weight profiles (radio /
|
||
// daily_mix) and the taste-profile build settings (engagement
|
||
// half-life + completion curve). It follows the coverart
|
||
// SettingsService pattern — boot reconcile seeds shipped defaults for
|
||
// missing rows, values are cached under a RWMutex, and every change
|
||
// takes effect live (the daily_mix profile + taste config are pushed
|
||
// into package playlists; radio reads the cache per request).
|
||
//
|
||
// Framing (decision #1247): this is the defaults-discovery lab. The
|
||
// operator turns knobs to FIND good values; found-good values get
|
||
// baked back into the Shipped* functions below as new shipped
|
||
// defaults. End users and other operators should never need the card.
|
||
package recsettings
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"sort"
|
||
"sync"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||
)
|
||
|
||
// Scope names — the three tunable groups. radio + daily_mix are weight
|
||
// profiles; taste is the profile-build settings singleton.
|
||
const (
|
||
ScopeRadio = "radio"
|
||
ScopeDailyMix = "daily_mix"
|
||
ScopeTaste = "taste"
|
||
// ScopeDiscover is the Discover request surface (#2377). Its own scope
|
||
// rather than columns on taste: SnoozeDays lives here, and a snooze must
|
||
// never be read as taste signal (#2374) — filing it under taste would put
|
||
// it one careless join from the leak that design forbids.
|
||
ScopeDiscover = "discover"
|
||
// ScopeSongsLike is the "Songs like {X}" surface (#3881). It shared
|
||
// daily_mix with For-You until 2026-09-10, and that sharing WAS the bug:
|
||
// the two surfaces want opposite things. For-You answers "what will they
|
||
// enjoy today" and is supposed to roam; Songs-like answers "what sounds
|
||
// like THIS" and is the tightest surface in the product. One set of
|
||
// weights cannot serve both, and the broad answer was winning.
|
||
ScopeSongsLike = "songs_like"
|
||
)
|
||
|
||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||
// half-life, the completion→engagement curve points, the enriched-tag
|
||
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
|
||
// era-facet weight (how strongly a decade-play imprints, #1530).
|
||
type TasteTuning struct {
|
||
HalfLifeDays float64
|
||
EngagementHardSkip float64
|
||
EngagementNeutral float64
|
||
EngagementFull float64
|
||
EnrichedTagScale float64
|
||
EraScale float64
|
||
MoodScale float64
|
||
}
|
||
|
||
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||
// here from config.RecommendationConfig — YAML is bootstrap-only,
|
||
// rule: config in UI). Radio is seed-directed (the user picked a
|
||
// direction), so taste is a lighter nudge than in the daily mixes.
|
||
// ContextTimeWeight starts uniform (1.0) across both profiles pending
|
||
// trend data (#1531); split them once the metrics view justifies it.
|
||
func ShippedRadioWeights() recommendation.ScoringWeights {
|
||
return recommendation.ScoringWeights{
|
||
BaseWeight: 1.0,
|
||
LikeBoost: 2.0,
|
||
RecencyWeight: 1.0,
|
||
SkipPenalty: 1.0,
|
||
JitterMagnitude: 0.1,
|
||
ContextWeight: 2.0,
|
||
SimilarityWeight: 2.0,
|
||
TasteWeight: 1.0,
|
||
ContextTimeWeight: 1.0,
|
||
}
|
||
}
|
||
|
||
// ShippedDailyMixWeights are the shipped daily_mix-profile defaults.
|
||
// Must stay in sync with the pre-push literal in playlists/system.go.
|
||
func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||
return recommendation.ScoringWeights{
|
||
BaseWeight: 1.0,
|
||
LikeBoost: 2.0,
|
||
RecencyWeight: 1.0,
|
||
SkipPenalty: 2.0,
|
||
JitterMagnitude: 0.1,
|
||
ContextWeight: 0.5,
|
||
SimilarityWeight: 1.5,
|
||
TasteWeight: 1.5,
|
||
ContextTimeWeight: 1.0,
|
||
}
|
||
}
|
||
|
||
// ShippedSongsLikeWeights are the shipped songs_like-profile defaults
|
||
// (#3881). The whole point is that SIMILARITY DOMINATES; every other
|
||
// profile balances it against taste and engagement, and this one must not.
|
||
//
|
||
// The failure being corrected, arithmetic from the daily_mix profile that
|
||
// this surface used to share:
|
||
//
|
||
// unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
|
||
// PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
|
||
//
|
||
// Liking something outranked sounding like the seed, because LikeBoost (2.0)
|
||
// exceeded SimilarityWeight's entire range (1.5) and TasteWeight (1.5, and
|
||
// seed-independent) matched it outright.
|
||
//
|
||
// THE PROPERTY THESE NUMBERS ENCODE, which is what to preserve if they are
|
||
// retuned: the similarity term's range must exceed the combined range of
|
||
// every seed-INDEPENDENT differentiator, so that a closer match cannot be
|
||
// beaten on the strength of likes, freshness and taste alone.
|
||
//
|
||
// seed-independent spread = LikeBoost 0.5 + Recency 0.25
|
||
// + Taste 0.25 + ContextTime 0.5
|
||
// + jitter 0.05 = 1.55
|
||
// similarity spread = 0 → 4.0
|
||
//
|
||
// So a similarity advantage of ~0.39 (1.55/4.0) wins outright regardless of
|
||
// everything else, while tracks within that band still get ordered by what
|
||
// the user likes and has not heard lately. Tight, not deaf.
|
||
//
|
||
// BaseWeight stays 1.0: it is identical for every candidate and so
|
||
// differentiates nothing — it sets the floor, not the shape. SkipPenalty
|
||
// stays 2.0 because a track the user skips is still unwanted no matter how
|
||
// similar it is.
|
||
//
|
||
// These are DEFAULTS, not settings (rule 25) — the operator turns them in the
|
||
// admin tuning card and good values get baked back here. They are a
|
||
// defensible starting point rather than a measured optimum: the per-arm fill
|
||
// rates and the real sim_score distribution are still unknown (#3879), and
|
||
// `likes_overlap` contributes a flat 0.6 that a high SimilarityWeight
|
||
// amplifies. Expect to move these once that lands.
|
||
func ShippedSongsLikeWeights() recommendation.ScoringWeights {
|
||
return recommendation.ScoringWeights{
|
||
BaseWeight: 1.0, // same for all candidates; differentiates nothing
|
||
LikeBoost: 0.5, // was 2.0 — a tie-break among similar tracks, not an override
|
||
RecencyWeight: 0.25, // was 1.0 — freshness must not outrank sounding right
|
||
SkipPenalty: 2.0, // unchanged — a skipped track stays unwanted
|
||
JitterMagnitude: 0.05, // was 0.1 — less shuffle on a coherence surface
|
||
ContextWeight: 0.5,
|
||
SimilarityWeight: 4.0, // was 1.5 — dominant, by design
|
||
TasteWeight: 0.25, // was 1.5 — seed-INDEPENDENT, so demoted hard
|
||
ContextTimeWeight: 0.5, // was 1.0
|
||
}
|
||
}
|
||
|
||
// shippedWeightsFor returns the shipped defaults for a weight-profile scope,
|
||
// or ok=false if the scope is not a weight profile. Single source for the
|
||
// three call sites (seed, update-validation, reset) so adding a fourth
|
||
// profile cannot be half-wired — which is how a scope ends up seedable but
|
||
// not resettable.
|
||
func shippedWeightsFor(scope string) (recommendation.ScoringWeights, bool) {
|
||
switch scope {
|
||
case ScopeRadio:
|
||
return ShippedRadioWeights(), true
|
||
case ScopeDailyMix:
|
||
return ShippedDailyMixWeights(), true
|
||
case ScopeSongsLike:
|
||
return ShippedSongsLikeWeights(), true
|
||
}
|
||
return recommendation.ScoringWeights{}, false
|
||
}
|
||
|
||
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
|
||
type DiscoverTuning struct {
|
||
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
|
||
// 0 disables it and restores pure similarity ranking.
|
||
TagOverlapWeight float64
|
||
// SnoozeDays is the default "not right now" duration (#2374).
|
||
SnoozeDays float64
|
||
}
|
||
|
||
// ShippedDiscoverTuning are the shipped Discover defaults.
|
||
//
|
||
// TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's
|
||
// similarity score — enough to reorder the deck meaningfully, not enough for a
|
||
// popular-tag coincidence to beat a genuinely strong similarity match. It is a
|
||
// starting point for the tuning lab, not a tuned value: the honest way to pick
|
||
// it is the metrics trend view after some real use.
|
||
//
|
||
// SnoozeDays 90 matches the operator's approved shape: long enough that a
|
||
// parked suggestion stops nagging, short enough that a taste shift brings it
|
||
// back on its own.
|
||
func ShippedDiscoverTuning() DiscoverTuning {
|
||
return DiscoverTuning{
|
||
TagOverlapWeight: 1.0,
|
||
SnoozeDays: 90,
|
||
}
|
||
}
|
||
|
||
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
|
||
func ShippedTasteTuning() TasteTuning {
|
||
d := taste.DefaultConfig()
|
||
return TasteTuning{
|
||
HalfLifeDays: d.HalfLifeDays,
|
||
EngagementHardSkip: d.Engagement.HardSkip,
|
||
EngagementNeutral: d.Engagement.NeutralCompletion,
|
||
EngagementFull: d.Engagement.FullCompletion,
|
||
EnrichedTagScale: d.EnrichedTagScale,
|
||
EraScale: d.EraScale,
|
||
MoodScale: d.MoodScale,
|
||
}
|
||
}
|
||
|
||
// Service caches the tuning values and owns their DB persistence +
|
||
// audit trail. Construct with New at boot.
|
||
type Service struct {
|
||
pool *pgxpool.Pool
|
||
logger *slog.Logger
|
||
|
||
mu sync.RWMutex
|
||
profiles map[string]recommendation.ScoringWeights
|
||
taste TasteTuning
|
||
discover DiscoverTuning
|
||
}
|
||
|
||
// New boots the service: seeds shipped defaults for missing rows,
|
||
// loads the current values, and pushes the daily_mix weights + taste
|
||
// config into package playlists so the daily builds pick them up.
|
||
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
|
||
s := &Service{
|
||
pool: pool,
|
||
logger: logger,
|
||
profiles: map[string]recommendation.ScoringWeights{},
|
||
}
|
||
if err := s.reconcile(ctx); err != nil {
|
||
return nil, fmt.Errorf("recsettings boot: %w", err)
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// reconcile seeds missing rows with shipped defaults, reads everything
|
||
// back into the cache, and pushes the playlist-side values.
|
||
func (s *Service) reconcile(ctx context.Context) error {
|
||
q := dbq.New(s.pool)
|
||
for profile, w := range map[string]recommendation.ScoringWeights{
|
||
ScopeRadio: ShippedRadioWeights(),
|
||
ScopeDailyMix: ShippedDailyMixWeights(),
|
||
ScopeSongsLike: ShippedSongsLikeWeights(),
|
||
} {
|
||
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
|
||
return fmt.Errorf("seed profile %q: %w", profile, err)
|
||
}
|
||
}
|
||
st := ShippedTasteTuning()
|
||
if err := q.UpsertTasteTuningDefaults(ctx, dbq.UpsertTasteTuningDefaultsParams{
|
||
HalfLifeDays: st.HalfLifeDays,
|
||
EngagementHardSkip: st.EngagementHardSkip,
|
||
EngagementNeutral: st.EngagementNeutral,
|
||
EngagementFull: st.EngagementFull,
|
||
EnrichedTagScale: st.EnrichedTagScale,
|
||
EraScale: st.EraScale,
|
||
MoodScale: st.MoodScale,
|
||
}); err != nil {
|
||
return fmt.Errorf("seed taste tuning: %w", err)
|
||
}
|
||
sd := ShippedDiscoverTuning()
|
||
if err := q.UpsertDiscoverTuningDefaults(ctx, dbq.UpsertDiscoverTuningDefaultsParams{
|
||
TagOverlapWeight: sd.TagOverlapWeight,
|
||
SnoozeDays: sd.SnoozeDays,
|
||
}); err != nil {
|
||
return fmt.Errorf("seed discover tuning: %w", err)
|
||
}
|
||
|
||
rows, err := q.ListWeightProfiles(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("list weight profiles: %w", err)
|
||
}
|
||
tt, err := q.GetTasteTuning(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("get taste tuning: %w", err)
|
||
}
|
||
dt, err := q.GetDiscoverTuning(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("get discover tuning: %w", err)
|
||
}
|
||
|
||
s.mu.Lock()
|
||
s.profiles = map[string]recommendation.ScoringWeights{}
|
||
for _, r := range rows {
|
||
s.profiles[r.Profile] = weightsFromRow(r)
|
||
}
|
||
s.taste = TasteTuning{
|
||
HalfLifeDays: tt.HalfLifeDays,
|
||
EngagementHardSkip: tt.EngagementHardSkip,
|
||
EngagementNeutral: tt.EngagementNeutral,
|
||
EngagementFull: tt.EngagementFull,
|
||
EnrichedTagScale: tt.EnrichedTagScale,
|
||
EraScale: tt.EraScale,
|
||
MoodScale: tt.MoodScale,
|
||
}
|
||
s.discover = DiscoverTuning{
|
||
TagOverlapWeight: dt.TagOverlapWeight,
|
||
SnoozeDays: dt.SnoozeDays,
|
||
}
|
||
s.mu.Unlock()
|
||
|
||
s.push()
|
||
return nil
|
||
}
|
||
|
||
// push installs the daily-build values into package playlists (the
|
||
// coverart Configure() pattern). Radio needs no push — its handler
|
||
// reads Weights(ScopeRadio) per request.
|
||
func (s *Service) push() {
|
||
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
|
||
playlists.SetSongsLikeWeights(s.Weights(ScopeSongsLike))
|
||
playlists.SetTasteConfig(s.TasteConfig())
|
||
}
|
||
|
||
// Weights returns the cached weights for a profile scope. Unknown
|
||
// scopes return the shipped radio defaults (defensive; callers pass
|
||
// the Scope* constants).
|
||
func (s *Service) Weights(profile string) recommendation.ScoringWeights {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
if w, ok := s.profiles[profile]; ok {
|
||
return w
|
||
}
|
||
return ShippedRadioWeights()
|
||
}
|
||
|
||
// Taste returns the cached taste-tuning values.
|
||
func (s *Service) Taste() TasteTuning {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.taste
|
||
}
|
||
|
||
// Discover returns the cached Discover-tuning values. Read per request by the
|
||
// suggestions handler, so an admin change takes effect on the next refresh
|
||
// with no restart (rule #25).
|
||
func (s *Service) Discover() DiscoverTuning {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.discover
|
||
}
|
||
|
||
// TasteConfig assembles the full taste.Config the profile builder
|
||
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
|
||
// plus the tuned half-life and curve. WindowDays scales with the
|
||
// half-life at the shipped ratio (270/75 = 3.6 half-lives) — the
|
||
// window is a query-cost bound, not an independent knob.
|
||
func (s *Service) TasteConfig() taste.Config {
|
||
t := s.Taste()
|
||
cfg := taste.DefaultConfig()
|
||
cfg.HalfLifeDays = t.HalfLifeDays
|
||
cfg.WindowDays = t.HalfLifeDays * 3.6
|
||
cfg.Engagement = taste.EngagementParams{
|
||
HardSkip: t.EngagementHardSkip,
|
||
NeutralCompletion: t.EngagementNeutral,
|
||
FullCompletion: t.EngagementFull,
|
||
}
|
||
cfg.EnrichedTagScale = t.EnrichedTagScale
|
||
cfg.EraScale = t.EraScale
|
||
cfg.MoodScale = t.MoodScale
|
||
return cfg
|
||
}
|
||
|
||
// fieldChange is one entry of an audit row's changes array.
|
||
type fieldChange struct {
|
||
Field string `json:"field"`
|
||
Old float64 `json:"old"`
|
||
New float64 `json:"new"`
|
||
}
|
||
|
||
// UpdateProfile applies a partial update to one weight profile.
|
||
// Unknown fields and out-of-range values reject the whole patch. A
|
||
// no-op patch (all values equal to current) writes no audit row.
|
||
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
|
||
if _, ok := shippedWeightsFor(profile); !ok {
|
||
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
|
||
}
|
||
current := s.Weights(profile)
|
||
next, changes, err := applyWeightPatch(current, patch)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistProfile(ctx, profile, next, "update", changes)
|
||
}
|
||
|
||
// UpdateTaste applies a partial update to the taste tuning singleton.
|
||
func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) error {
|
||
current := s.Taste()
|
||
next, changes, err := applyTastePatch(current, patch)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistTaste(ctx, next, "update", changes)
|
||
}
|
||
|
||
// UpdateDiscover applies a partial update to the Discover tuning singleton.
|
||
func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64) error {
|
||
current := s.Discover()
|
||
next, changes, err := applyDiscoverPatch(current, patch)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistDiscover(ctx, next, "update", changes)
|
||
}
|
||
|
||
// Reset restores a scope to its shipped defaults, with one audit row
|
||
// carrying the full diff. A scope already at defaults is a no-op.
|
||
func (s *Service) Reset(ctx context.Context, scope string) error {
|
||
if shipped, ok := shippedWeightsFor(scope); ok {
|
||
// Every weight profile resets the same way; the per-scope defaults
|
||
// come from one place so a new profile cannot be seedable but not
|
||
// resettable. This was an if/else over two hard-coded scopes until
|
||
// songs_like made it three.
|
||
changes := diffWeights(s.Weights(scope), shipped)
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistProfile(ctx, scope, shipped, "reset", changes)
|
||
}
|
||
switch scope {
|
||
case ScopeTaste:
|
||
shipped := ShippedTasteTuning()
|
||
changes := diffTaste(s.Taste(), shipped)
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistTaste(ctx, shipped, "reset", changes)
|
||
case ScopeDiscover:
|
||
shipped := ShippedDiscoverTuning()
|
||
changes := diffDiscover(s.Discover(), shipped)
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistDiscover(ctx, shipped, "reset", changes)
|
||
default:
|
||
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
|
||
}
|
||
}
|
||
|
||
// persistProfile writes the profile row + audit entry, refreshes the
|
||
// cache, and pushes daily-build values.
|
||
func (s *Service) persistProfile(
|
||
ctx context.Context, profile string, w recommendation.ScoringWeights,
|
||
action string, changes []fieldChange,
|
||
) error {
|
||
q := dbq.New(s.pool)
|
||
if _, err := q.UpdateWeightProfile(ctx, updateParams(profile, w)); err != nil {
|
||
return fmt.Errorf("update profile %q: %w", profile, err)
|
||
}
|
||
if err := s.audit(ctx, q, profile, action, changes); err != nil {
|
||
return err
|
||
}
|
||
s.mu.Lock()
|
||
s.profiles[profile] = w
|
||
s.mu.Unlock()
|
||
s.push()
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) persistTaste(
|
||
ctx context.Context, t TasteTuning, action string, changes []fieldChange,
|
||
) error {
|
||
q := dbq.New(s.pool)
|
||
if _, err := q.UpdateTasteTuning(ctx, dbq.UpdateTasteTuningParams{
|
||
HalfLifeDays: t.HalfLifeDays,
|
||
EngagementHardSkip: t.EngagementHardSkip,
|
||
EngagementNeutral: t.EngagementNeutral,
|
||
EngagementFull: t.EngagementFull,
|
||
EnrichedTagScale: t.EnrichedTagScale,
|
||
EraScale: t.EraScale,
|
||
MoodScale: t.MoodScale,
|
||
}); err != nil {
|
||
return fmt.Errorf("update taste tuning: %w", err)
|
||
}
|
||
if err := s.audit(ctx, q, ScopeTaste, action, changes); err != nil {
|
||
return err
|
||
}
|
||
s.mu.Lock()
|
||
s.taste = t
|
||
s.mu.Unlock()
|
||
s.push()
|
||
return nil
|
||
}
|
||
|
||
// persistDiscover writes the discover row + audit entry and refreshes the
|
||
// cache. No push(): unlike taste and daily_mix, nothing precomputes from these
|
||
// — the suggestions handler reads Discover() per request.
|
||
func (s *Service) persistDiscover(
|
||
ctx context.Context, d DiscoverTuning, action string, changes []fieldChange,
|
||
) error {
|
||
q := dbq.New(s.pool)
|
||
if _, err := q.UpdateDiscoverTuning(ctx, dbq.UpdateDiscoverTuningParams{
|
||
TagOverlapWeight: d.TagOverlapWeight,
|
||
SnoozeDays: d.SnoozeDays,
|
||
}); err != nil {
|
||
return fmt.Errorf("update discover tuning: %w", err)
|
||
}
|
||
if err := s.audit(ctx, q, ScopeDiscover, action, changes); err != nil {
|
||
return err
|
||
}
|
||
s.mu.Lock()
|
||
s.discover = d
|
||
s.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// audit writes one recommendation_tuning_audit row. Changes are
|
||
// sorted by field so rows are deterministic and diff-friendly.
|
||
func (s *Service) audit(
|
||
ctx context.Context, q *dbq.Queries, scope, action string, changes []fieldChange,
|
||
) error {
|
||
sort.Slice(changes, func(i, j int) bool { return changes[i].Field < changes[j].Field })
|
||
payload, err := json.Marshal(changes)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal audit changes: %w", err)
|
||
}
|
||
if err := q.InsertTuningAudit(ctx, dbq.InsertTuningAuditParams{
|
||
Scope: scope, Action: action, Changes: payload,
|
||
}); err != nil {
|
||
return fmt.Errorf("insert audit row: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
|
||
return dbq.UpsertWeightProfileDefaultsParams{
|
||
Profile: profile,
|
||
BaseWeight: w.BaseWeight,
|
||
LikeBoost: w.LikeBoost,
|
||
RecencyWeight: w.RecencyWeight,
|
||
SkipPenalty: w.SkipPenalty,
|
||
JitterMagnitude: w.JitterMagnitude,
|
||
ContextWeight: w.ContextWeight,
|
||
SimilarityWeight: w.SimilarityWeight,
|
||
TasteWeight: w.TasteWeight,
|
||
ContextTimeWeight: w.ContextTimeWeight,
|
||
}
|
||
}
|
||
|
||
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
|
||
return dbq.UpdateWeightProfileParams{
|
||
Profile: profile,
|
||
BaseWeight: w.BaseWeight,
|
||
LikeBoost: w.LikeBoost,
|
||
RecencyWeight: w.RecencyWeight,
|
||
SkipPenalty: w.SkipPenalty,
|
||
JitterMagnitude: w.JitterMagnitude,
|
||
ContextWeight: w.ContextWeight,
|
||
SimilarityWeight: w.SimilarityWeight,
|
||
TasteWeight: w.TasteWeight,
|
||
ContextTimeWeight: w.ContextTimeWeight,
|
||
}
|
||
}
|
||
|
||
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
|
||
return recommendation.ScoringWeights{
|
||
BaseWeight: r.BaseWeight,
|
||
LikeBoost: r.LikeBoost,
|
||
RecencyWeight: r.RecencyWeight,
|
||
SkipPenalty: r.SkipPenalty,
|
||
JitterMagnitude: r.JitterMagnitude,
|
||
ContextWeight: r.ContextWeight,
|
||
SimilarityWeight: r.SimilarityWeight,
|
||
TasteWeight: r.TasteWeight,
|
||
ContextTimeWeight: r.ContextTimeWeight,
|
||
}
|
||
}
|