Files
silo-server/internal/recommendations/taste_signals.go
T

338 lines
7.5 KiB
Go

package recommendations
import (
"fmt"
"time"
)
type canonicalContentKind string
const (
canonicalKindMovie canonicalContentKind = "movie"
canonicalKindSeries canonicalContentKind = "series"
canonicalKindSeason canonicalContentKind = "season"
canonicalKindEpisode canonicalContentKind = "episode"
)
const (
maxSeasonSampleCount = 10
maxSeasonContributionShare = 0.35
)
type canonicalContentRef struct {
Kind canonicalContentKind
CanonicalID string
SeriesID string
SeasonNumber int
HasSeason bool
}
func (r canonicalContentRef) isCanonicalTasteItem() bool {
return r.Kind == canonicalKindMovie || r.Kind == canonicalKindSeries
}
func (r canonicalContentRef) seasonKey() string {
if !r.HasSeason || r.SeriesID == "" {
return ""
}
return fmt.Sprintf("%s:%d", r.SeriesID, r.SeasonNumber)
}
type canonicalWeightComponents struct {
Rating *int
ExplicitWeight float64
ImplicitWeight float64
IntentWeight float64
Genres []string
}
type rawImplicitSignal struct {
Ref canonicalContentRef
Weight float64
Timestamp time.Time
Completed bool
}
type seasonAggregate struct {
Score float64
SampleCount int
LastSignalAt time.Time
}
func parseSignalTime(raw string, fallback time.Time) time.Time {
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return parsed
}
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed
}
return fallback
}
func implicitWatchWeight(wp WatchProgressRow) (float64, bool) {
var progressPct float64
if wp.Completed {
progressPct = 1.0
} else if wp.DurationSeconds > 0 {
progressPct = wp.PositionSeconds / wp.DurationSeconds
} else {
return 0, false
}
switch {
case progressPct >= 0.9:
return WeightWatchHigh, true
case progressPct >= 0.5:
return WeightWatchMed, true
case progressPct < 0.15:
return WeightWatchLow, true
default:
return 0, false
}
}
func combineCanonicalWeight(rating *int, explicitWeight, implicitWeight, intentWeight float64) float64 {
if rating == nil {
return implicitWeight + intentWeight
}
switch {
case *rating >= 4:
total := explicitWeight + implicitWeight + intentWeight
if total < 0 {
return 0
}
return total
case *rating == 3:
return explicitWeight + implicitWeight + intentWeight
default:
total := explicitWeight
if implicitWeight < 0 {
total += implicitWeight
}
return total
}
}
func aggregateSeriesImplicitScore(seasons []seasonAggregate, now time.Time, halfLife float64) float64 {
if len(seasons) == 0 {
return 0
}
rawWeights := make([]float64, len(seasons))
for i, season := range seasons {
sampleCount := season.SampleCount
if sampleCount <= 0 {
continue
}
if sampleCount > maxSeasonSampleCount {
sampleCount = maxSeasonSampleCount
}
lastSignalAt := season.LastSignalAt
if lastSignalAt.IsZero() {
lastSignalAt = now
}
rawWeights[i] = float64(sampleCount) * timeDecay(lastSignalAt, now, halfLife)
}
shares := cappedNormalizedWeights(rawWeights, maxSeasonContributionShare)
score := 0.0
for i, share := range shares {
score += share * seasons[i].Score
}
return score
}
func cappedNormalizedWeights(rawWeights []float64, cap float64) []float64 {
shares := make([]float64, len(rawWeights))
total := 0.0
positiveCount := 0
for _, weight := range rawWeights {
if weight <= 0 {
continue
}
total += weight
positiveCount++
}
if total == 0 {
return shares
}
if cap <= 0 || float64(positiveCount)*cap < 1 {
for i, weight := range rawWeights {
if weight > 0 {
shares[i] = weight / total
}
}
return shares
}
remaining := make([]int, 0, positiveCount)
for i, weight := range rawWeights {
if weight > 0 {
remaining = append(remaining, i)
}
}
remainingShare := 1.0
for len(remaining) > 0 {
remainingRaw := 0.0
for _, idx := range remaining {
remainingRaw += rawWeights[idx]
}
if remainingRaw == 0 {
return shares
}
nextRemaining := make([]int, 0, len(remaining))
cappedAny := false
for _, idx := range remaining {
proposed := remainingShare * rawWeights[idx] / remainingRaw
if proposed > cap {
shares[idx] = cap
remainingShare -= cap
cappedAny = true
continue
}
nextRemaining = append(nextRemaining, idx)
}
if !cappedAny {
for _, idx := range remaining {
shares[idx] = remainingShare * rawWeights[idx] / remainingRaw
}
return shares
}
remaining = nextRemaining
if remainingShare <= 0 {
return shares
}
}
return shares
}
func buildCanonicalImplicitSignals(progress []WatchProgressRow, rewatches []RewatchCount, refs map[string]canonicalContentRef, now time.Time, halfLife float64) (map[string]float64, map[string]struct{}) {
rawSignals := make(map[string]*rawImplicitSignal)
ensureRawSignal := func(sourceID string, ref canonicalContentRef) *rawImplicitSignal {
if existing, ok := rawSignals[sourceID]; ok {
return existing
}
signal := &rawImplicitSignal{Ref: ref}
rawSignals[sourceID] = signal
return signal
}
for _, wp := range progress {
ref, ok := refs[wp.MediaItemID]
if !ok || ref.CanonicalID == "" {
continue
}
weight, ok := implicitWatchWeight(wp)
if !ok {
continue
}
signal := ensureRawSignal(wp.MediaItemID, ref)
signal.Weight += weight
if wp.Completed {
signal.Completed = true
}
if wp.UpdatedAt.After(signal.Timestamp) {
signal.Timestamp = wp.UpdatedAt
}
}
for _, rc := range rewatches {
if rc.Count < 2 {
continue
}
ref, ok := refs[rc.MediaItemID]
if !ok || ref.CanonicalID == "" {
continue
}
signal := ensureRawSignal(rc.MediaItemID, ref)
signal.Weight += WeightRewatch
lastWatchedAt := rc.LastWatchedAt
if lastWatchedAt.IsZero() {
lastWatchedAt = now
}
if lastWatchedAt.After(signal.Timestamp) {
signal.Timestamp = lastWatchedAt
}
}
signals := make(map[string]float64)
completedSet := make(map[string]struct{})
type seasonBuilder struct {
sumWeight float64
sampleCount int
lastSignalAt time.Time
}
seriesBuilders := make(map[string]map[string]*seasonBuilder)
for _, signal := range rawSignals {
if signal.Weight == 0 || signal.Ref.CanonicalID == "" {
continue
}
timestamp := signal.Timestamp
if timestamp.IsZero() {
timestamp = now
}
if signal.Completed {
completedSet[signal.Ref.CanonicalID] = struct{}{}
}
switch signal.Ref.Kind {
case canonicalKindEpisode, canonicalKindSeason:
seasonKey := signal.Ref.seasonKey()
if seasonKey == "" {
continue
}
bySeason := seriesBuilders[signal.Ref.CanonicalID]
if bySeason == nil {
bySeason = make(map[string]*seasonBuilder)
seriesBuilders[signal.Ref.CanonicalID] = bySeason
}
builder := bySeason[seasonKey]
if builder == nil {
builder = &seasonBuilder{}
bySeason[seasonKey] = builder
}
builder.sumWeight += signal.Weight
builder.sampleCount++
if timestamp.After(builder.lastSignalAt) {
builder.lastSignalAt = timestamp
}
case canonicalKindMovie, canonicalKindSeries:
signals[signal.Ref.CanonicalID] += signal.Weight * timeDecay(timestamp, now, halfLife)
}
}
for canonicalID, bySeason := range seriesBuilders {
seasons := make([]seasonAggregate, 0, len(bySeason))
for _, season := range bySeason {
if season.sampleCount == 0 {
continue
}
seasons = append(seasons, seasonAggregate{
Score: season.sumWeight / float64(season.sampleCount),
SampleCount: season.sampleCount,
LastSignalAt: season.lastSignalAt,
})
}
if len(seasons) == 0 {
continue
}
signals[canonicalID] += aggregateSeriesImplicitScore(seasons, now, halfLife)
}
return signals, completedSet
}