231 lines
6.0 KiB
Go
231 lines
6.0 KiB
Go
package recommend
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"math"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ContextAffinityProfile captures what a viewer tends to watch in broad local-time
|
||
|
|
// windows. It is intentionally compact: seven weekdays by four day parts is enough to
|
||
|
|
// learn household routines without pretending that a small history is a precise model.
|
||
|
|
type ContextAffinityProfile struct {
|
||
|
|
Slots map[string]ContextAffinityBucket `json:"slots,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ContextAffinityBucket struct {
|
||
|
|
Samples int `json:"samples"`
|
||
|
|
GenreWeights map[string]float64 `json:"genres,omitempty"`
|
||
|
|
StudioWeights map[string]float64 `json:"studios,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewContextAffinityProfile() ContextAffinityProfile {
|
||
|
|
return ContextAffinityProfile{Slots: map[string]ContextAffinityBucket{}}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add records one matched Tracearr session. Completion and recency determine how much
|
||
|
|
// taste evidence it contributes, while Samples controls confidence separately.
|
||
|
|
func (p *ContextAffinityProfile) Add(
|
||
|
|
item Item,
|
||
|
|
started time.Time,
|
||
|
|
completion float64,
|
||
|
|
recencyPosition int,
|
||
|
|
location *time.Location,
|
||
|
|
) {
|
||
|
|
if started.IsZero() {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if p.Slots == nil {
|
||
|
|
p.Slots = map[string]ContextAffinityBucket{}
|
||
|
|
}
|
||
|
|
if location == nil {
|
||
|
|
location = time.Local
|
||
|
|
}
|
||
|
|
local := started.In(location)
|
||
|
|
key := contextSlotKey(local.Weekday(), dayPart(local.Hour()))
|
||
|
|
bucket := p.Slots[key]
|
||
|
|
if bucket.GenreWeights == nil {
|
||
|
|
bucket.GenreWeights = map[string]float64{}
|
||
|
|
}
|
||
|
|
if bucket.StudioWeights == nil {
|
||
|
|
bucket.StudioWeights = map[string]float64{}
|
||
|
|
}
|
||
|
|
bucket.Samples++
|
||
|
|
weight := (0.2 + clamp01(completion)) * math.Pow(0.985, float64(recencyPosition))
|
||
|
|
for _, genre := range item.Genres {
|
||
|
|
if genre = strings.TrimSpace(genre); genre != "" {
|
||
|
|
bucket.GenreWeights[genre] += weight
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, studio := range item.Studios {
|
||
|
|
if name := strings.TrimSpace(studio.Name); name != "" {
|
||
|
|
bucket.StudioWeights[name] += weight * 0.4
|
||
|
|
}
|
||
|
|
}
|
||
|
|
p.Slots[key] = bucket
|
||
|
|
}
|
||
|
|
|
||
|
|
// Score returns a bounded contextual affinity source score and its confidence. Exact
|
||
|
|
// weekday/time behavior matters most; neighboring time windows and the same time on
|
||
|
|
// other days provide progressively weaker fallbacks. No match simply returns zero.
|
||
|
|
func (p ContextAffinityProfile) Score(
|
||
|
|
item Item,
|
||
|
|
now time.Time,
|
||
|
|
location *time.Location,
|
||
|
|
) (float64, float64) {
|
||
|
|
if len(p.Slots) == 0 {
|
||
|
|
return 0, 0
|
||
|
|
}
|
||
|
|
if location == nil {
|
||
|
|
location = time.Local
|
||
|
|
}
|
||
|
|
local := now.In(location)
|
||
|
|
part := dayPart(local.Hour())
|
||
|
|
var score, sampleWeight float64
|
||
|
|
for key, bucket := range p.Slots {
|
||
|
|
weekday, bucketPart, ok := parseContextSlotKey(key)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
weight := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
|
||
|
|
if weight == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
var affinity float64
|
||
|
|
for _, genre := range item.Genres {
|
||
|
|
affinity += weightFold(bucket.GenreWeights, genre)
|
||
|
|
}
|
||
|
|
if n := len(item.Genres); n > 1 {
|
||
|
|
affinity /= math.Sqrt(float64(n))
|
||
|
|
}
|
||
|
|
for _, studio := range item.Studios {
|
||
|
|
affinity += weightFold(bucket.StudioWeights, studio.Name)
|
||
|
|
}
|
||
|
|
score += affinity * weight
|
||
|
|
sampleWeight += float64(bucket.Samples) * weight
|
||
|
|
}
|
||
|
|
if sampleWeight == 0 {
|
||
|
|
return 0, 0
|
||
|
|
}
|
||
|
|
// Five effective sessions are enough for the full (still bounded) contextual nudge.
|
||
|
|
return score / sampleWeight, math.Min(1, sampleWeight/5)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Contextualized returns a copy of the base profile with a modest current-time taste
|
||
|
|
// nudge. It is used by dynamic shelves; the prepared For You pool applies the same
|
||
|
|
// signal directly to candidate placement.
|
||
|
|
func (p ContextAffinityProfile) Contextualized(
|
||
|
|
base Profile,
|
||
|
|
now time.Time,
|
||
|
|
location *time.Location,
|
||
|
|
) Profile {
|
||
|
|
out := base
|
||
|
|
out.GenreWeights = cloneWeights(base.GenreWeights)
|
||
|
|
out.StudioWeights = cloneWeights(base.StudioWeights)
|
||
|
|
probe := Item{Genres: keysFromWeights(out.GenreWeights)}
|
||
|
|
_, confidence := p.Score(probe, now, location)
|
||
|
|
if confidence == 0 {
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
local := now
|
||
|
|
if location != nil {
|
||
|
|
local = now.In(location)
|
||
|
|
}
|
||
|
|
part := dayPart(local.Hour())
|
||
|
|
for key, bucket := range p.Slots {
|
||
|
|
weekday, bucketPart, ok := parseContextSlotKey(key)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
similarity := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
|
||
|
|
if similarity == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
scale := 0.35 * confidence * similarity / math.Max(1, float64(bucket.Samples))
|
||
|
|
for genre, weight := range bucket.GenreWeights {
|
||
|
|
out.GenreWeights[genre] += weight * scale
|
||
|
|
}
|
||
|
|
for studio, weight := range bucket.StudioWeights {
|
||
|
|
out.StudioWeights[studio] += weight * scale
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func contextSlotSimilarity(currentDay time.Weekday, currentPart int, day time.Weekday, part int) float64 {
|
||
|
|
dayDistance := int(currentDay) - int(day)
|
||
|
|
if dayDistance < 0 {
|
||
|
|
dayDistance = -dayDistance
|
||
|
|
}
|
||
|
|
if dayDistance > 3 {
|
||
|
|
dayDistance = 7 - dayDistance
|
||
|
|
}
|
||
|
|
partDistance := currentPart - part
|
||
|
|
if partDistance < 0 {
|
||
|
|
partDistance = -partDistance
|
||
|
|
}
|
||
|
|
switch {
|
||
|
|
case dayDistance == 0 && partDistance == 0:
|
||
|
|
return 1
|
||
|
|
case dayDistance == 0 && partDistance == 1:
|
||
|
|
return 0.35
|
||
|
|
case dayDistance == 1 && partDistance == 0:
|
||
|
|
return 0.25
|
||
|
|
case partDistance == 0:
|
||
|
|
return 0.12
|
||
|
|
default:
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func dayPart(hour int) int {
|
||
|
|
switch {
|
||
|
|
case hour >= 5 && hour < 11:
|
||
|
|
return 0
|
||
|
|
case hour >= 11 && hour < 17:
|
||
|
|
return 1
|
||
|
|
case hour >= 17 && hour < 22:
|
||
|
|
return 2
|
||
|
|
default:
|
||
|
|
return 3
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func contextSlotKey(day time.Weekday, part int) string {
|
||
|
|
return string(rune('0'+day)) + ":" + string(rune('0'+part))
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseContextSlotKey(key string) (time.Weekday, int, bool) {
|
||
|
|
if len(key) != 3 || key[1] != ':' || key[0] < '0' || key[0] > '6' ||
|
||
|
|
key[2] < '0' || key[2] > '3' {
|
||
|
|
return 0, 0, false
|
||
|
|
}
|
||
|
|
return time.Weekday(key[0] - '0'), int(key[2] - '0'), true
|
||
|
|
}
|
||
|
|
|
||
|
|
func clamp01(value float64) float64 {
|
||
|
|
if value < 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
if value > 1 {
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
func cloneWeights(source map[string]float64) map[string]float64 {
|
||
|
|
out := make(map[string]float64, len(source))
|
||
|
|
for key, value := range source {
|
||
|
|
out[key] = value
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func keysFromWeights(source map[string]float64) []string {
|
||
|
|
out := make([]string, 0, len(source))
|
||
|
|
for key := range source {
|
||
|
|
out = append(out, key)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|