diff --git a/internal/recommendation/sessionvector.go b/internal/recommendation/sessionvector.go new file mode 100644 index 00000000..94cd3154 --- /dev/null +++ b/internal/recommendation/sessionvector.go @@ -0,0 +1,43 @@ +package recommendation + +import ( + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +type SessionVector struct { + Seed bool `json:"seed"` + Artists []string `json:"artists"` + Tags map[string]int `json:"tags"` + RecentTrackIDs []string `json:"recent_track_ids"` +} + +func BuildSessionVector(priorTracks []dbq.Track) SessionVector { + v := SessionVector{ + Seed: len(priorTracks) < 3, + Artists: []string{}, + Tags: map[string]int{}, + RecentTrackIDs: []string{}, + } + seen := map[string]bool{} + for _, t := range priorTracks { + artistID := uuidString(t.ArtistID) + if !seen[artistID] { + seen[artistID] = true + v.Artists = append(v.Artists, artistID) + } + if t.Genre != nil && *t.Genre != "" { + v.Tags[*t.Genre]++ + } + v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID)) + } + return v +} + +func uuidString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + return u.String() +} diff --git a/internal/recommendation/sessionvector_test.go b/internal/recommendation/sessionvector_test.go new file mode 100644 index 00000000..745b3bf9 --- /dev/null +++ b/internal/recommendation/sessionvector_test.go @@ -0,0 +1,151 @@ +package recommendation + +import ( + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func track(artistID pgtype.UUID, genre string) dbq.Track { + t := dbq.Track{ + ArtistID: artistID, + } + if genre != "" { + g := genre + t.Genre = &g + } + return t +} + +func uuid(s string) pgtype.UUID { + var u pgtype.UUID + _ = u.Scan(s) + return u +} + +func TestBuildSessionVector_Empty_IsSeed(t *testing.T) { + v := BuildSessionVector(nil) + if !v.Seed { + t.Errorf("Seed = false, want true for empty input") + } + if len(v.Artists) != 0 || len(v.Tags) != 0 || len(v.RecentTrackIDs) != 0 { + t.Errorf("non-empty collections: %+v", v) + } +} + +func TestBuildSessionVector_OneTrack_IsSeed(t *testing.T) { + a1 := uuid("11111111-1111-1111-1111-111111111111") + tr := track(a1, "rock") + tr.ID = uuid("22222222-2222-2222-2222-222222222222") + v := BuildSessionVector([]dbq.Track{tr}) + if !v.Seed { + t.Errorf("1 track: Seed=false, want true (< 3)") + } + if len(v.Artists) != 1 { + t.Errorf("Artists: %+v", v.Artists) + } + if v.Tags["rock"] != 1 { + t.Errorf("Tags: %+v", v.Tags) + } + if len(v.RecentTrackIDs) != 1 { + t.Errorf("RecentTrackIDs: %+v", v.RecentTrackIDs) + } +} + +func TestBuildSessionVector_TwoTracks_IsSeed(t *testing.T) { + a1 := uuid("11111111-1111-1111-1111-111111111111") + v := BuildSessionVector([]dbq.Track{track(a1, "rock"), track(a1, "jazz")}) + if !v.Seed { + t.Errorf("2 tracks: Seed=false, want true (< 3)") + } +} + +func TestBuildSessionVector_ThreeTracks_NotSeed(t *testing.T) { + a1 := uuid("11111111-1111-1111-1111-111111111111") + v := BuildSessionVector([]dbq.Track{ + track(a1, "rock"), track(a1, "rock"), track(a1, "rock"), + }) + if v.Seed { + t.Errorf("3 tracks: Seed=true, want false") + } + // All same artist → 1 entry. + if len(v.Artists) != 1 { + t.Errorf("Artists len = %d, want 1 (dedup)", len(v.Artists)) + } + // All same genre → count 3. + if v.Tags["rock"] != 3 { + t.Errorf("Tags['rock'] = %d, want 3", v.Tags["rock"]) + } +} + +func TestBuildSessionVector_DistinctArtistsAndGenres(t *testing.T) { + a1 := uuid("11111111-1111-1111-1111-111111111111") + a2 := uuid("22222222-2222-2222-2222-222222222222") + a3 := uuid("33333333-3333-3333-3333-333333333333") + v := BuildSessionVector([]dbq.Track{ + track(a1, "rock"), + track(a2, "jazz"), + track(a3, "rock"), + track(a1, "experimental"), + }) + // 4 tracks → not seed. + if v.Seed { + t.Errorf("Seed=true, want false") + } + // Distinct artists: 3 (a1 dedup'd). + if len(v.Artists) != 3 { + t.Errorf("Artists len = %d, want 3", len(v.Artists)) + } + // Tag counts. + if v.Tags["rock"] != 2 || v.Tags["jazz"] != 1 || v.Tags["experimental"] != 1 { + t.Errorf("Tags = %+v", v.Tags) + } + if len(v.RecentTrackIDs) != 4 { + t.Errorf("RecentTrackIDs len = %d", len(v.RecentTrackIDs)) + } +} + +func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) { + a1 := uuid("11111111-1111-1111-1111-111111111111") + v := BuildSessionVector([]dbq.Track{ + track(a1, ""), // empty genre via the helper (nil pointer) + track(a1, "rock"), + track(a1, ""), + }) + if v.Tags["rock"] != 1 { + t.Errorf("Tags['rock'] = %d, want 1", v.Tags["rock"]) + } + if _, exists := v.Tags[""]; exists { + t.Errorf("Tags has empty-string entry: %+v", v.Tags) + } +} + +func TestBuildSessionVector_JSONRoundTrip(t *testing.T) { + a1 := uuid("11111111-1111-1111-1111-111111111111") + v := BuildSessionVector([]dbq.Track{ + track(a1, "rock"), track(a1, "jazz"), track(a1, "rock"), + }) + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got SessionVector + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Seed != v.Seed { + t.Errorf("Seed: %v != %v", got.Seed, v.Seed) + } + if got.Tags["rock"] != v.Tags["rock"] { + t.Errorf("Tags: %+v != %+v", got.Tags, v.Tags) + } + if len(got.Artists) != len(v.Artists) { + t.Errorf("Artists: len %d != %d", len(got.Artists), len(v.Artists)) + } + if len(got.RecentTrackIDs) != len(v.RecentTrackIDs) { + t.Errorf("RecentTrackIDs: len %d != %d", len(got.RecentTrackIDs), len(v.RecentTrackIDs)) + } +}