This commit is contained in:
ponzischeme89
2026-08-23 09:40:45 +12:00
parent 9a1f44da46
commit 766cea2199
19 changed files with 828 additions and 164 deletions
+6 -5
View File
@@ -238,11 +238,12 @@ fuzzily joined without an explicit operator decision.
Keeping the pool deliberately much larger than the cards shown gives the request-time
`minutes` filter enough headroom. `/v1/for-you` reads up to 240 ranked candidates and
derives several de-duplicated shelves: top picks, up to two distinct “Because you
finished …” shelves, up to two genre shelves, and a television-compatible shelf when
there is enough playback evidence. Completed-title evidence is distributed
deterministically across relevant candidates rather than allowing the newest Drama
title to explain every Drama recommendation.
derives several de-duplicated shelves: top picks, up to two distinct title-based shelves,
up to two theme or genre shelves, and a television-compatible shelf when there is enough
playback evidence. Reasons prefer defensible title relationships, specific themes and
viewing eras, then cast or creator matches; a top-level genre such as Drama is only a
fallback. One source title is capped and distributed deterministically across relevant
candidates rather than being allowed to explain an entire row.
The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Tracearr imports
stay frequent but do not rebuild pools. A session's first transition to stopped/completed
+9 -2
View File
@@ -33,7 +33,7 @@ const (
maxPreparedCandidates = 750
// Increment only when stored eligibility, scoring, or explanation behavior changes.
preparedAlgorithmVersion = "2026-07-31.2"
preparedAlgorithmVersion = "2026-08-23.1"
)
type ImportResult struct {
@@ -676,7 +676,14 @@ func buildPreparedRows(
"for-you:because:",
func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID },
func(item store.PreparedForYouItem) string {
return "Because you finished " + item.ReasonSourceTitle
switch item.ReasonKind {
case "completed-title":
return "Because you finished " + item.ReasonSourceTitle
case "favourite-title":
return "Because you like " + item.ReasonSourceTitle
default:
return "Because you watched " + item.ReasonSourceTitle
}
},
2,
func(item store.PreparedForYouItem) bool {
+1
View File
@@ -214,6 +214,7 @@ func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T)
CompatibilityScore: 0.8,
CompatibilityLabel: "Direct plays well on this TV",
RecommendationReason: "Because you finished " + sourceTitle,
ReasonKind: "completed-title",
ReasonGenre: genre, ReasonSourceItemID: sourceID,
ReasonSourceTitle: sourceTitle,
})
+21 -22
View File
@@ -444,28 +444,11 @@ func explainRecommendation(
compatibility compatibilityProfile,
browsed bool,
) (string, string) {
top := profile.TopGenres(5)
matched := ""
for _, wanted := range top {
for _, genre := range item.Genres {
if strings.EqualFold(wanted, genre) {
matched = genre
break
}
}
if matched != "" {
break
}
}
reasons := make([]string, 0, 3)
if browsed {
reasons = append(reasons, "You explored this recently")
} else if matched != "" {
reasons = append(reasons, "Matches your "+matched+" viewing")
} else if len(profile.Seeds) > 0 {
reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name)
} else {
reasons = append(reasons, "Matches your recent viewing")
reasons = append(reasons, strongestPersonalReason(profile, item).Text)
}
if availableMinutes > 0 && item.RuntimeMinutes() > 0 {
reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window")
@@ -758,7 +741,7 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed str
ID: definition.ID,
Title: definition.Title,
Kind: definition.Kind,
Items: Raws(items),
Items: enrichRecommendationReasons(profile, items),
})
}
return rows
@@ -1054,11 +1037,17 @@ func (e *Engine) similarRow(
return Row{}, false
}
items = diversifyRanked(items, variation+":similar:"+seed.ID, 5)
raws := make([]json.RawMessage, 0, len(items))
for _, item := range items {
raws = append(raws, enrichRecommendation(
item.Raw, "Because you watched "+seed.Name, "",
))
}
return Row{
ID: "similar:" + seed.ID,
Title: "Because you watched " + seed.Name,
Kind: "similar",
Items: Raws(items),
Items: raws,
}, true
}
@@ -1208,7 +1197,7 @@ func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile
ID: "recommended",
Title: "Recommended from your watching history",
Kind: "recommended",
Items: Raws(items),
Items: enrichRecommendationReasons(profile, items),
}, true
}
@@ -1240,6 +1229,16 @@ func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile
ID: "recommended",
Title: "Recommended from your watching history",
Kind: "recommended",
Items: Raws(items),
Items: enrichRecommendationReasons(profile, items),
}, true
}
func enrichRecommendationReasons(profile Profile, items []Item) []json.RawMessage {
raws := make([]json.RawMessage, 0, len(items))
for _, item := range items {
raws = append(raws, enrichRecommendation(
item.Raw, strongestPersonalReason(profile, item).Text, "",
))
}
return raws
}
+8 -2
View File
@@ -248,6 +248,9 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") {
t.Fatalf("unexpected first row: %+v", rows[0])
}
if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason":"Because you watched `) {
t.Fatalf("similar recommendation did not carry its server reason: %s", rows[0].Items[0])
}
last := rows[len(rows)-1]
if last.Kind != "recommended" || last.Title != "Recommended from your watching history" {
t.Fatalf("unexpected history row: %+v", last)
@@ -255,6 +258,9 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
if last.ID != "recommended" {
t.Fatalf("history row id should be stable, got %q", last.ID)
}
if !strings.Contains(string(last.Items[0]), `"MembyRecommendationReason":`) {
t.Fatalf("history recommendation did not carry a server reason: %s", last.Items[0])
}
}
func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) {
@@ -485,7 +491,7 @@ func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
evidence := map[string][]PreparedEvidence{
"drama": {{
ItemID: "arrival", Title: "Arrival",
Genres: []string{"Drama", "Science Fiction"},
Genres: []string{"Drama", "Science Fiction"}, Completed: true,
}},
}
counts := map[string]int{}
@@ -503,7 +509,7 @@ func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 {
t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"])
}
if kinds["genre"] == 0 {
if kinds["theme"] == 0 {
t.Fatalf("reason kinds were not mixed: %+v", kinds)
}
}
+267 -57
View File
@@ -7,63 +7,288 @@ import (
"time"
)
// Why a title suits one viewer, in that viewer's own words.
//
// This is deliberately separate from Score. The scorer decides *order* and is allowed to
// be opaque; this decides *wording* and must never claim an affinity the profile did not
// actually learn — every reason below is read straight out of the weights built from real
// history, so a viewer who has watched nothing gets the honest, taste-free ones.
// ReasonLimit is what fits on one line of a detail page without wrapping. Beyond three
// the strip stops reading as an explanation and starts reading as marketing.
const ReasonLimit = 3
// reasonFloor is the weight below which an affinity is a coincidence rather than a
// habit — one stray episode should not put a genre on the screen as a reason.
const reasonFloor = 0.35
const decadeReasonFloor = 1.5
// Why returns up to limit short phrases explaining the item to this viewer, strongest
// first. Never nil: a profile with nothing in it still yields the catalogue facts.
type personalReason struct {
Text string
Kind string
Genre string
SourceID string
SourceTitle string
}
// Why returns short, human-readable reasons in evidence-strength order. Ranking stays
// separate: none of the scorer's technical values or reason codes reach this wording.
func Why(profile Profile, item Item, limit int) []string {
if limit <= 0 {
limit = ReasonLimit
}
reasons := make([]string, 0, limit)
add := func(reason string) {
if len(reasons) < limit && reason != "" {
reasons = append(reasons, reason)
for _, candidate := range personalReasons(profile, item) {
if len(reasons) == limit {
return reasons
}
reasons = append(reasons, candidate.Text)
}
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor {
add("Because you watch " + genre)
if len(reasons) < limit && item.CommunityRating >= 7.5 {
reasons = append(reasons, "Well rated ("+
strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64)+")")
}
if name, weight := heaviestPerson(profile, item); weight >= reasonFloor {
add("You've watched " + name + " before")
if len(reasons) < limit && item.ProductionYear > 0 &&
time.Now().Year()-item.ProductionYear <= 1 {
reasons = append(reasons, "A recent release")
}
if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor {
add("More from " + studio)
}
// Catalogue facts, used to fill the strip out. They are true for everyone, which is
// exactly why they come last: they explain the title, not the viewer.
// Kept short deliberately. Three chips share one line on a 960dp TV, and this is the
// one that most often lands third, where a long phrase is the one that gets clipped.
if item.CommunityRating >= 7.5 {
add("Well rated (" + strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64) + ")")
}
if item.ProductionYear > 0 && time.Now().Year()-item.ProductionYear <= 1 {
add("A recent release")
}
if len(reasons) == 0 && len(item.Genres) > 0 {
add(strings.TrimSpace(item.Genres[0]) + " from your library")
if len(reasons) == 0 {
reasons = append(reasons, "Recommended from your library")
}
return reasons
}
// heaviest picks the wanted key with the most weight behind it, matched case-insensitively
// because Emby's own tagging is not consistent about it. Ties break alphabetically so the
// same profile and item always produce the same sentence.
func strongestPersonalReason(profile Profile, item Item) personalReason {
if reasons := personalReasons(profile, item); len(reasons) > 0 {
return reasons[0]
}
return personalReason{Text: "Recommended from your library", Kind: "generic"}
}
// personalReasons is the human-facing priority: related viewing, specific themes,
// viewing era, cast/creator, studio, then a broad genre as the final personal fallback.
func personalReasons(profile Profile, item Item) []personalReason {
reasons := make([]personalReason, 0, 6)
if reason := relatedViewingReason(profile, item); reason.Text != "" {
reasons = append(reasons, reason)
}
if reason := themeReason(profile, item); reason.Text != "" {
reasons = append(reasons, reason)
}
if reason := decadeReason(profile, item); reason.Text != "" {
reasons = append(reasons, reason)
}
if reason := personReason(profile, item); reason.Text != "" {
reasons = append(reasons, reason)
}
if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor {
reasons = append(reasons, personalReason{Text: "More from " + studio, Kind: "studio"})
}
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor {
reasons = append(reasons, personalReason{
Text: "Because you watch " + genre, Kind: "genre", Genre: genre,
})
}
return uniquePersonalReasons(reasons)
}
func relatedViewingReason(profile Profile, candidate Item) personalReason {
bestIndex, bestStrength := -1, 0
for index, evidence := range profile.ReasonEvidence {
if sameTitle(evidence.Item, candidate) {
continue
}
strength := titleRelationshipStrength(evidence.Item, candidate)
if strength > bestStrength {
bestIndex, bestStrength = index, strength
}
}
if bestIndex < 0 {
return personalReason{}
}
evidence := profile.ReasonEvidence[bestIndex]
title := evidenceTitle(evidence.Item)
verb, kind := "watched ", "recent-title"
switch {
case evidence.Favourite:
verb, kind = "like ", "favourite-title"
case evidence.Item.UserData.Played && !strings.EqualFold(evidence.Item.Type, "Episode"):
verb, kind = "finished ", "completed-title"
}
return personalReason{
Text: "Because you " + verb + title, Kind: kind,
SourceID: evidenceID(evidence.Item), SourceTitle: title,
}
}
func titleRelationshipStrength(source, candidate Item) int {
if source.CollectionName != "" && candidate.CollectionName != "" &&
strings.EqualFold(strings.TrimSpace(source.CollectionName), strings.TrimSpace(candidate.CollectionName)) {
return 140
}
shared, specific := sharedGenreCount(source.Genres, candidate.Genres)
switch {
case shared >= 2:
return 100 + specific*10
case specific >= 1:
return 80
default:
return 0
}
}
func sharedGenreCount(left, right []string) (shared, specific int) {
seen := map[string]bool{}
for _, a := range left {
a = strings.TrimSpace(a)
if a == "" {
continue
}
for _, b := range right {
if !strings.EqualFold(a, strings.TrimSpace(b)) {
continue
}
key := strings.ToLower(a)
if !seen[key] {
seen[key] = true
shared++
if !isBroadGenre(a) {
specific++
}
}
break
}
}
return shared, specific
}
func themeReason(profile Profile, item Item) personalReason {
type match struct {
name string
weight float64
}
matches := make([]match, 0, len(item.Genres))
for _, genre := range item.Genres {
genre = strings.TrimSpace(genre)
if weight := weightFold(profile.GenreWeights, genre); genre != "" && weight >= reasonFloor {
matches = append(matches, match{genre, weight})
}
}
sort.SliceStable(matches, func(i, j int) bool {
iBroad, jBroad := isBroadGenre(matches[i].name), isBroadGenre(matches[j].name)
if iBroad != jBroad {
return !iBroad
}
return matches[i].weight > matches[j].weight
})
if len(matches) == 0 || isBroadGenre(matches[0].name) {
return personalReason{}
}
primary := matches[0].name
phrase := strings.ToLower(primary)
for _, candidate := range matches[1:] {
if isBroadGenre(candidate.name) {
phrase += " " + pluralGenre(candidate.name)
break
}
}
return personalReason{
Text: "Because you like " + phrase, Kind: "theme", Genre: primary,
}
}
func decadeReason(profile Profile, item Item) personalReason {
if item.ProductionYear <= 0 {
return personalReason{}
}
decade := item.ProductionYear / 10 * 10
if profile.DecadeWeights[decade] < decadeReasonFloor {
return personalReason{}
}
noun := "titles"
if strings.EqualFold(item.Type, "Series") {
noun = "series"
} else if strings.EqualFold(item.Type, "Movie") {
noun = "films"
}
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor && isBroadGenre(genre) {
noun = pluralGenre(genre)
}
return personalReason{
Text: "Because you've been watching " + strconv.Itoa(decade) + "s " + noun,
Kind: "decade",
}
}
func personReason(profile Profile, item Item) personalReason {
best := Person{}
bestWeight := 0.0
for _, person := range item.People {
if !isExplainablePerson(person.Type) {
continue
}
weight := weightFold(profile.PersonWeights, person.Name)
if weight > bestWeight || weight == bestWeight && person.Name < best.Name {
best, bestWeight = person, weight
}
}
if bestWeight < reasonFloor {
return personalReason{}
}
switch strings.ToLower(strings.TrimSpace(best.Type)) {
case "director", "writer":
return personalReason{Text: "More from " + best.Name, Kind: "creator"}
default:
return personalReason{Text: "Because you watch " + best.Name, Kind: "person"}
}
}
func evidenceTitle(item Item) string {
if strings.EqualFold(item.Type, "Episode") && strings.TrimSpace(item.SeriesName) != "" {
return strings.TrimSpace(item.SeriesName)
}
return strings.TrimSpace(item.Name)
}
func evidenceID(item Item) string {
if item.SeriesID != "" {
return item.SeriesID
}
return item.ID
}
func sameTitle(left, right Item) bool {
if evidenceID(left) != "" && evidenceID(left) == evidenceID(right) {
return true
}
leftKey, rightKey := left.SeenKey(), right.SeenKey()
return leftKey != "" && leftKey == rightKey
}
func isBroadGenre(genre string) bool {
switch strings.ToLower(strings.TrimSpace(genre)) {
case "action", "adventure", "comedy", "drama", "family", "thriller":
return true
default:
return false
}
}
func pluralGenre(genre string) string {
switch strings.ToLower(strings.TrimSpace(genre)) {
case "comedy":
return "comedies"
case "family":
return "family titles"
case "action", "adventure":
return strings.ToLower(strings.TrimSpace(genre)) + " titles"
default:
return strings.ToLower(strings.TrimSpace(genre)) + "s"
}
}
func uniquePersonalReasons(reasons []personalReason) []personalReason {
out := make([]personalReason, 0, len(reasons))
seen := map[string]bool{}
for _, reason := range reasons {
key := strings.ToLower(strings.TrimSpace(reason.Text))
if key == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, reason)
}
return out
}
func heaviest(weights map[string]float64, wanted []string) (string, float64) {
best, bestWeight := "", 0.0
for _, candidate := range wanted {
@@ -72,32 +297,17 @@ func heaviest(weights map[string]float64, wanted []string) (string, float64) {
continue
}
weight := weightFold(weights, candidate)
if weight <= 0 {
continue
}
if weight > bestWeight || (weight == bestWeight && candidate < best) {
if weight > bestWeight || weight == bestWeight && weight > 0 && candidate < best {
best, bestWeight = candidate, weight
}
}
return best, bestWeight
}
func heaviestPerson(profile Profile, item Item) (string, float64) {
names := make([]string, 0, len(item.People))
for _, person := range item.People {
if isExplainablePerson(person.Type) {
names = append(names, person.Name)
}
}
return heaviest(profile.PersonWeights, names)
}
func round1(value float64) float64 {
return float64(int(value*10+0.5)) / 10
}
// TopPeople is the explanation layer's view of the cast a viewer follows, heaviest first.
// Exported for the admin page, which shows what the engine believes about a household.
func (p Profile) TopPeople(n int) []string {
type kv struct {
name string
+105 -8
View File
@@ -30,17 +30,17 @@ const thrillerHistory = `{
"People":[{"Name":"Denis Villeneuve","Type":"Director"},{"Name":"Emily Blunt","Type":"Actor"}]
}`
func TestWhyNamesTheGenreTheViewerActuallyWatches(t *testing.T) {
func TestWhyPrefersTheRelatedTitleOverItsGenres(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
"Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller"],
"Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller","Crime"],
"People":[{"Name":"Denis Villeneuve","Type":"Director"}]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if len(reasons) == 0 || reasons[0] != "Because you watch Thriller" {
t.Fatalf("expected the genre reason first, got %v", reasons)
if len(reasons) == 0 || reasons[0] != "Because you watched Sicario" {
t.Fatalf("expected the related viewing reason first, got %v", reasons)
}
if !strings.Contains(strings.Join(reasons, "|"), "Denis Villeneuve") {
t.Fatalf("expected the shared director to be named, got %v", reasons)
@@ -73,7 +73,7 @@ func TestWhyAlwaysSaysSomething(t *testing.T) {
reasons := Why(Profile{}, candidate, ReasonLimit)
if len(reasons) != 1 || reasons[0] != "Drama from your library" {
if len(reasons) != 1 || reasons[0] != "Recommended from your library" {
t.Fatalf("an empty profile should still explain the title, got %v", reasons)
}
}
@@ -98,9 +98,9 @@ func TestWhyIsCappedAndOrdered(t *testing.T) {
t.Fatalf("expected exactly %d reasons, got %v", ReasonLimit, reasons)
}
want := []string{
"Because you watch Thriller",
"You've watched Emily Blunt before",
"More from Lionsgate",
"Because you watched Sicario",
"Because you like crime thrillers",
"Because you watch Emily Blunt",
}
for i, reason := range want {
if reasons[i] != reason {
@@ -109,6 +109,103 @@ func TestWhyIsCappedAndOrdered(t *testing.T) {
}
}
func TestWhyDoesNotTreatDramaAloneAsATitleRelationship(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"A Drama","Type":"Movie","Genres":["Drama"]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"Another Drama","Type":"Movie","Genres":["Drama"]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if reasons[0] != "Because you watch Drama" {
t.Fatalf("broad genre should be the fallback, got %v", reasons)
}
if strings.Contains(strings.Join(reasons, "|"), "A Drama") {
t.Fatalf("Drama alone invented a title relationship: %v", reasons)
}
}
func TestWhyUsesSpecificSubgenreBeforeBroadGenre(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"Crime One","Type":"Movie","Genres":["Crime","Drama"]
}`)
// Do not retain title evidence here: this test isolates the learned theme wording.
profile.ReasonEvidence = nil
candidate := explainItem(t, `{
"Id":"c1","Name":"Crime Two","Type":"Series","Genres":["Drama","Crime"]
}`)
if got := Why(profile, candidate, 1); len(got) != 1 || got[0] != "Because you like crime dramas" {
t.Fatalf("specific theme reason = %v", got)
}
}
func TestWhyUsesCreatorBeforeBroadGenre(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"The First","Type":"Movie","Genres":["Drama"],
"People":[{"Name":"Vince Gilligan","Type":"Writer"}]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"The Second","Type":"Series","Genres":["Drama"],
"People":[{"Name":"Vince Gilligan","Type":"Writer"}]
}`)
if got := Why(profile, candidate, 1); len(got) != 1 || got[0] != "More from Vince Gilligan" {
t.Fatalf("creator reason = %v", got)
}
}
func TestWhyDistinguishesFinishedTitlesFromEpisodes(t *testing.T) {
finished := explainItem(t, `{
"Id":"h1","Name":"Breaking Bad","Type":"Series","Genres":["Crime","Drama"],
"UserData":{"Played":true}
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"Better Call Saul","Type":"Series","Genres":["Crime","Drama"]
}`)
if got := Why(BuildProfile([]Item{finished}, nil), candidate, 1); got[0] != "Because you finished Breaking Bad" {
t.Fatalf("completed title reason = %v", got)
}
episodeHistory := explainItem(t, `{
"Id":"ep1","Name":"Pilot","Type":"Episode","SeriesId":"show-1",
"SeriesName":"Breaking Bad","Genres":["Crime","Drama"],"UserData":{"Played":true}
}`)
if got := Why(BuildProfile([]Item{episodeHistory}, nil), candidate, 1); got[0] != "Because you watched Breaking Bad" {
t.Fatalf("completed episode overstated series completion: %v", got)
}
}
func TestWhyCanUseARepeatedViewingEra(t *testing.T) {
profile := explainProfile(t,
`{"Id":"h1","Name":"One","Type":"Movie","ProductionYear":2003,"Genres":["Drama"]}`,
`{"Id":"h2","Name":"Two","Type":"Movie","ProductionYear":2007,"Genres":["Drama"]}`,
)
profile.ReasonEvidence = nil
candidate := explainItem(t, `{
"Id":"c1","Name":"Three","Type":"Movie","ProductionYear":2005,"Genres":["Drama"]
}`)
if got := Why(profile, candidate, 1); got[0] != "Because you've been watching 2000s dramas" {
t.Fatalf("viewing-era reason = %v", got)
}
}
func TestWhyCanNameAFavouriteTitle(t *testing.T) {
favourite := explainItem(t, `{
"Id":"h1","Name":"The Wire","Type":"Series","Genres":["Crime","Drama"]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"We Own This City","Type":"Series","Genres":["Crime","Drama"]
}`)
if got := Why(BuildProfile(nil, []Item{favourite}), candidate, 1); got[0] != "Because you like The Wire" {
t.Fatalf("favourite-title reason = %v", got)
}
}
func TestWhyIsStableAcrossCalls(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
+53 -54
View File
@@ -25,6 +25,7 @@ type PreparedEvidence struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
Genres []string `json:"genres,omitempty"`
Completed bool `json:"completed,omitempty"`
}
type PreparedTitleAffinity struct {
@@ -128,7 +129,7 @@ func (e *Engine) PrepareForYou(
index := newCatalogueIndex(catalogue)
evidenceByGenre := map[string][]PreparedEvidence{}
evidenceSeen := map[string]bool{}
addCompletedEvidence := func(item Item, sessionID string) {
addCompletedEvidence := func(item Item, sessionID string, completed bool) {
itemID := item.ID
title := item.Name
if strings.EqualFold(item.Type, "Episode") &&
@@ -152,6 +153,7 @@ func (e *Engine) PrepareForYou(
ItemID: itemID,
Title: title,
Genres: append([]string(nil), item.Genres...),
Completed: completed,
},
)
}
@@ -160,7 +162,7 @@ func (e *Engine) PrepareForYou(
// explanation pool breadth even when Tracearr title matching is sparse.
for _, item := range history {
if item.UserData.Played {
addCompletedEvidence(item, "")
addCompletedEvidence(item, "", !strings.EqualFold(item.Type, "Episode"))
}
}
titleAffinity := map[string]PreparedTitleAffinity{}
@@ -263,7 +265,9 @@ func (e *Engine) PrepareForYou(
}
if completion >= 0.9 {
addCompletedEvidence(item, session.ID)
addCompletedEvidence(
item, session.ID, !strings.EqualFold(session.MediaType, "episode"),
)
}
}
@@ -559,49 +563,46 @@ func explainPreparedRecommendation(
evidenceByGenre map[string][]PreparedEvidence,
completedReasonCounts map[string]int,
) (reason, label, kind, genre string, evidence PreparedEvidence) {
for _, wanted := range profile.TopGenres(5) {
for _, candidateGenre := range item.Genres {
if strings.EqualFold(wanted, candidateGenre) {
genre = candidateGenre
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))]
strong := make([]PreparedEvidence, 0, len(options))
for _, option := range options {
if strongEvidenceMatch(item, option) {
strong = append(strong, option)
}
}
if len(strong) > 0 {
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
}
break
strong := make([]PreparedEvidence, 0)
seenEvidence := map[string]bool{}
for _, candidateGenre := range item.Genres {
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(candidateGenre))]
for _, option := range options {
if seenEvidence[option.ItemID] || !strongEvidenceMatch(item, option) {
continue
}
}
if genre != "" {
break
seenEvidence[option.ItemID] = true
strong = append(strong, option)
}
}
// Keep specific evidence prominent without letting it monopolise a row. One third
// of otherwise eligible cards deliberately uses the broader genre explanation,
// and no completed title can explain more than four candidates in a prepared pool.
useCompleted := evidence.Title != "" &&
stableEvidenceIndex("reason-kind:"+item.ID, 3) != 0 &&
completedReasonCounts[evidence.ItemID] < 4
if len(strong) > 0 {
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
}
useTitle := evidence.Title != "" && completedReasonCounts[evidence.ItemID] < 4
switch {
case useTitle:
verb := "watched "
kind = "recent-title"
if evidence.Completed {
verb, kind = "finished ", "completed-title"
}
reason = "Because you " + verb + evidence.Title
completedReasonCounts[evidence.ItemID]++
case browsed:
reason, kind = "You explored this recently", "browsed"
evidence = PreparedEvidence{}
case useCompleted:
reason, kind = "Because you finished "+evidence.Title, "completed-title"
completedReasonCounts[evidence.ItemID]++
case genre != "":
reason, kind = "Matches your "+genre+" viewing", "genre"
evidence = PreparedEvidence{}
case len(profile.Seeds) > 0:
reason, kind = "Inspired by "+profile.Seeds[0].Name, "recent-title"
evidence = PreparedEvidence{}
default:
reason, kind = "Matches your recent viewing", "generic"
evidence = PreparedEvidence{}
selected := strongestAvailableReason(profile, item, completedReasonCounts)
reason, kind, genre = selected.Text, selected.Kind, selected.Genre
if selected.SourceID != "" {
completedReasonCounts[selected.SourceID]++
evidence = PreparedEvidence{
ItemID: selected.SourceID, Title: selected.SourceTitle,
Completed: selected.Kind == "completed-title",
}
} else {
evidence = PreparedEvidence{}
}
}
switch score := compatibilityScore(item, compatibility); {
case score > 0.2:
@@ -615,24 +616,22 @@ func explainPreparedRecommendation(
return reason, label, kind, genre, evidence
}
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
shared := 0
broadOnly := true
for _, candidateGenre := range item.Genres {
for _, evidenceGenre := range evidence.Genres {
if !strings.EqualFold(strings.TrimSpace(candidateGenre), strings.TrimSpace(evidenceGenre)) {
continue
}
shared++
switch strings.ToLower(strings.TrimSpace(candidateGenre)) {
case "action", "adventure", "comedy", "drama", "thriller":
default:
broadOnly = false
}
break
func strongestAvailableReason(
profile Profile,
item Item,
titleReasonCounts map[string]int,
) personalReason {
for _, reason := range personalReasons(profile, item) {
if reason.SourceID == "" || titleReasonCounts[reason.SourceID] < 4 {
return reason
}
}
return shared >= 2 || shared == 1 && !broadOnly
return personalReason{Text: "Recommended from your library", Kind: "generic"}
}
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
shared, specific := sharedGenreCount(item.Genres, evidence.Genres)
return shared >= 2 || specific >= 1
}
func stableEvidenceIndex(itemID string, size int) int {
+37
View File
@@ -109,6 +109,11 @@ type Profile struct {
// good reason to *tell* someone about a title and a poor reason to rank by it: two
// films sharing an actor are often nothing alike.
PersonWeights map[string]float64
// DecadeWeights and ReasonEvidence retain just enough of the source history for the
// explanation layer to say why a particular title fits. They do not participate in
// ranking: ordering and wording remain deliberately separate concerns.
DecadeWeights map[int]float64
ReasonEvidence []ReasonEvidence
// Seen holds item ids *and* series ids already watched or in progress, so a
// recommendation never suggests something the user is already partway through.
Seen map[string]bool
@@ -116,6 +121,11 @@ type Profile struct {
Seeds []Seed
}
type ReasonEvidence struct {
Item Item
Favourite bool
}
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
// Decode parses raw Emby items, keeping the original payload attached.
@@ -141,14 +151,26 @@ func BuildProfile(history, favorites []Item) Profile {
GenreWeights: map[string]float64{},
StudioWeights: map[string]float64{},
PersonWeights: map[string]float64{},
DecadeWeights: map[int]float64{},
Seen: map[string]bool{},
SeenTitles: map[string]bool{},
}
seedSeen := map[string]bool{}
tasteSeen := map[string]bool{}
reasonSeen := map[string]bool{}
for i, item := range history {
profile.markSeen(item)
reasonItem := item
reasonItem.Raw = nil
reasonID := item.ID
if item.SeriesID != "" {
reasonID = item.SeriesID
}
if reasonID != "" && !reasonSeen[reasonID] {
reasonSeen[reasonID] = true
profile.ReasonEvidence = append(profile.ReasonEvidence, ReasonEvidence{Item: reasonItem})
}
// Several episodes of one series are evidence for one taste, not several
// independent tastes. Keep the newest occurrence's recency weight and still
@@ -176,6 +198,15 @@ func BuildProfile(history, favorites []Item) Profile {
for _, item := range favorites {
profile.absorb(item, favoriteWeight)
reasonItem := item
reasonItem.Raw = nil
reasonID := item.ID
if reasonID != "" && !reasonSeen[reasonID] {
reasonSeen[reasonID] = true
profile.ReasonEvidence = append(profile.ReasonEvidence, ReasonEvidence{
Item: reasonItem, Favourite: true,
})
}
}
return profile
}
@@ -223,6 +254,12 @@ func (p *Profile) absorbTaste(item Item, weight float64) {
p.PersonWeights[name] += weight
}
}
if item.ProductionYear > 0 {
if p.DecadeWeights == nil {
p.DecadeWeights = map[int]float64{}
}
p.DecadeWeights[item.ProductionYear/10*10] += weight
}
}
// isExplainablePerson keeps the cast list down to the roles a viewer would recognise as