release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875 for artists), and they reached a scorer that reads similarity as 0 to 1. One edge added 64 to 5,800 points under Songs-like, so likes, skips and recency stopped mattering for any candidate with an edge. - ResolveListenBrainzTrackEdges divides each edge by the seed's best score in its whole cached answer. The cache keeps the raw answer. - The worker stores matched and unmatched artist rows over the seed's top artist. - Migration 0096 rescales the rows already stored. - The candidate arms hold sim_score bands so tier 1 stays above tier 2: lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3. Otherwise a seed's weaker LB edges fell below a shared genre tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.3 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,
|
||
}
|
||
}
|