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
+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 {