Publish current app and server
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user