Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
573 lines
20 KiB
Go
573 lines
20 KiB
Go
// Package recsettings is the DB-backed home of the recommendation
|
||
// tuning knobs: the scoring-weight profiles (radio, daily_mix,
|
||
// songs_like), Discover's settings, 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).
|
||
//
|
||
// This is a lab for finding good defaults: an admin turns knobs to find
|
||
// good values, and those get baked back into the Shipped* functions below
|
||
// as new shipped defaults. A typical install never needs to touch it.
|
||
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. Its own scope rather
|
||
// than columns on taste: SnoozeDays lives here, and a snooze must never
|
||
// be read as taste signal — filing it under taste would put it one
|
||
// careless join from that leak.
|
||
ScopeDiscover = "discover"
|
||
// ScopeSongsLike is the "Songs like {X}" surface. It is separate from
|
||
// daily_mix because the two 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.
|
||
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), and the
|
||
// era-facet weight (how strongly a decade-play imprints).
|
||
type TasteTuning struct {
|
||
HalfLifeDays float64
|
||
EngagementHardSkip float64
|
||
EngagementNeutral float64
|
||
EngagementFull float64
|
||
EnrichedTagScale float64
|
||
EraScale float64
|
||
MoodScale float64
|
||
}
|
||
|
||
// ShippedRadioWeights are the shipped radio-profile defaults. Radio is
|
||
// seed-directed (the user picked a direction), so taste is a lighter nudge
|
||
// than in the daily mixes. ContextTimeWeight is 1.0 in both profiles; the
|
||
// metrics view is what would justify splitting them.
|
||
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. The
|
||
// whole point is that SIMILARITY DOMINATES; every other profile balances it
|
||
// against taste and engagement, and this one must not.
|
||
//
|
||
// Under the daily_mix weights, for example:
|
||
//
|
||
// 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: an admin turns them in the tuning card
|
||
// and good values get baked back here. They are a defensible starting point
|
||
// rather than a measured optimum, and `likes_overlap` contributes a flat 0.3
|
||
// that a high SimilarityWeight amplifies.
|
||
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.
|
||
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.
|
||
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, not a tuned value: the way to pick it is the metrics trend
|
||
// view after some real use.
|
||
//
|
||
// SnoozeDays 90: 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.
|
||
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,
|
||
}
|
||
}
|