refactor: split getTopPicksForYou into focused helpers
This commit is contained in:
@@ -22,6 +22,20 @@ type animeService struct {
|
|||||||
repo domain.AnimeRepository
|
repo domain.AnimeRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rankedCandidate struct {
|
||||||
|
id int
|
||||||
|
collaborativeScore float64
|
||||||
|
profileSearchScore float64
|
||||||
|
anime jikan.Anime
|
||||||
|
hasAnime bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type candidateStore struct {
|
||||||
|
watchlistAnimeIDs map[int]struct{}
|
||||||
|
byID map[int]rankedCandidate
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
||||||
out := make([]domain.Anime, 0, len(in))
|
out := make([]domain.Anime, 0, len(in))
|
||||||
for _, a := range in {
|
for _, a := range in {
|
||||||
@@ -34,6 +48,65 @@ func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeSer
|
|||||||
return &animeService{jikan: jikan, repo: repo}
|
return &animeService{jikan: jikan, repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
|
||||||
|
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
||||||
|
for _, entry := range watchlist {
|
||||||
|
if entry.AnimeID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &candidateStore{
|
||||||
|
watchlistAnimeIDs: watchlistAnimeIDs,
|
||||||
|
byID: map[int]rankedCandidate{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *candidateStore) upsert(candidate rankedCandidate) {
|
||||||
|
if candidate.id <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := s.watchlistAnimeIDs[candidate.id]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
current, ok := s.byID[candidate.id]
|
||||||
|
if !ok {
|
||||||
|
s.byID[candidate.id] = candidate
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current.collaborativeScore += candidate.collaborativeScore
|
||||||
|
current.profileSearchScore += candidate.profileSearchScore
|
||||||
|
if candidate.hasAnime {
|
||||||
|
current.anime = candidate.anime
|
||||||
|
current.hasAnime = true
|
||||||
|
}
|
||||||
|
s.byID[candidate.id] = current
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *candidateStore) ranked() []rankedCandidate {
|
||||||
|
ranked := make([]rankedCandidate, 0, len(s.byID))
|
||||||
|
for _, item := range s.byID {
|
||||||
|
ranked = append(ranked, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(ranked, func(i, j int) bool {
|
||||||
|
left := rankedCandidateRetrievalScore(ranked[i].collaborativeScore, ranked[i].profileSearchScore)
|
||||||
|
right := rankedCandidateRetrievalScore(ranked[j].collaborativeScore, ranked[j].profileSearchScore)
|
||||||
|
if left == right {
|
||||||
|
return ranked[i].id < ranked[j].id
|
||||||
|
}
|
||||||
|
return left > right
|
||||||
|
})
|
||||||
|
|
||||||
|
return ranked
|
||||||
|
}
|
||||||
|
|
||||||
func isAiringScheduleCandidate(entry db.GetUserWatchListRow) bool {
|
func isAiringScheduleCandidate(entry db.GetUserWatchListRow) bool {
|
||||||
status := strings.TrimSpace(entry.Status)
|
status := strings.TrimSpace(entry.Status)
|
||||||
if status != "watching" && status != "plan_to_watch" {
|
if status != "watching" && status != "plan_to_watch" {
|
||||||
@@ -160,112 +233,48 @@ func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (do
|
|||||||
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) getTopPicksForYou(
|
func (s *animeService) fetchSeedAnimes(ctx context.Context, seedPool []recommendationSeed) ([]jikan.Anime, error) {
|
||||||
ctx context.Context,
|
|
||||||
userID string,
|
|
||||||
resultLimit int,
|
|
||||||
) (domain.CatalogSectionData, error) {
|
|
||||||
if strings.TrimSpace(userID) == "" {
|
|
||||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.CatalogSectionData{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
|
||||||
if len(seedPool) == 0 {
|
|
||||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type rankedCandidate struct {
|
|
||||||
id int
|
|
||||||
collaborativeScore float64
|
|
||||||
profileSearchScore float64
|
|
||||||
anime jikan.Anime
|
|
||||||
hasAnime bool
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
|
||||||
for _, entry := range watchlist {
|
|
||||||
if entry.AnimeID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidatesByID := map[int]rankedCandidate{}
|
|
||||||
var candidatesByIDMu sync.Mutex
|
|
||||||
upsertCandidate := func(candidate rankedCandidate) {
|
|
||||||
if candidate.id <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, exists := watchlistAnimeIDs[candidate.id]; exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
candidatesByIDMu.Lock()
|
|
||||||
defer candidatesByIDMu.Unlock()
|
|
||||||
|
|
||||||
current, ok := candidatesByID[candidate.id]
|
|
||||||
if !ok {
|
|
||||||
candidatesByID[candidate.id] = candidate
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
current.collaborativeScore += candidate.collaborativeScore
|
|
||||||
current.profileSearchScore += candidate.profileSearchScore
|
|
||||||
if candidate.hasAnime {
|
|
||||||
current.anime = candidate.anime
|
|
||||||
current.hasAnime = true
|
|
||||||
}
|
|
||||||
candidatesByID[candidate.id] = current
|
|
||||||
}
|
|
||||||
|
|
||||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||||
var seedFetchGroup errgroup.Group
|
var g errgroup.Group
|
||||||
seedFetchGroup.SetLimit(4)
|
g.SetLimit(4)
|
||||||
|
|
||||||
for i, seed := range seedPool {
|
for i, seed := range seedPool {
|
||||||
seedFetchGroup.Go(func() error {
|
g.Go(func() error {
|
||||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
anime, err := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||||
if fetchErr != nil {
|
if err != nil {
|
||||||
return fetchErr
|
return err
|
||||||
}
|
}
|
||||||
seedAnimes[i] = anime
|
seedAnimes[i] = anime
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := seedFetchGroup.Wait(); err != nil {
|
if err := g.Wait(); err != nil {
|
||||||
return domain.CatalogSectionData{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
return seedAnimes, nil
|
||||||
|
}
|
||||||
|
|
||||||
var recommendationGroup errgroup.Group
|
func (s *animeService) collectCollaborativeCandidates(ctx context.Context, seedPool []recommendationSeed, store *candidateStore) error {
|
||||||
recommendationGroup.SetLimit(4)
|
var g errgroup.Group
|
||||||
|
g.SetLimit(4)
|
||||||
|
|
||||||
for _, seed := range seedPool {
|
for _, seed := range seedPool {
|
||||||
recommendationGroup.Go(func() error {
|
g.Go(func() error {
|
||||||
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
recs, err := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||||
if recErr != nil {
|
if err != nil {
|
||||||
return recErr
|
return err
|
||||||
}
|
}
|
||||||
for i, rec := range recs {
|
for i, rec := range recs {
|
||||||
if i >= forYouMaxRecommendations {
|
if i >= forYouMaxRecommendations {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
id := rec.Entry.MalID
|
id := rec.Entry.MalID
|
||||||
if id <= 0 {
|
if id <= 0 || id == seed.animeID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if id == seed.animeID {
|
store.upsert(rankedCandidate{
|
||||||
continue
|
|
||||||
}
|
|
||||||
upsertCandidate(rankedCandidate{
|
|
||||||
id: id,
|
id: id,
|
||||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||||
})
|
})
|
||||||
@@ -274,17 +283,17 @@ func (s *animeService) getTopPicksForYou(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := recommendationGroup.Wait(); err != nil {
|
return g.Wait()
|
||||||
return domain.CatalogSectionData{}, err
|
}
|
||||||
}
|
|
||||||
|
|
||||||
profileQueries := buildProfileSearchQueries(profile)
|
func (s *animeService) collectProfileSearchCandidates(ctx context.Context, profile userTasteProfile, store *candidateStore) error {
|
||||||
var profileSearchGroup errgroup.Group
|
queries := buildProfileSearchQueries(profile)
|
||||||
profileSearchGroup.SetLimit(3)
|
var g errgroup.Group
|
||||||
|
g.SetLimit(3)
|
||||||
|
|
||||||
for _, query := range profileQueries {
|
for _, query := range queries {
|
||||||
profileSearchGroup.Go(func() error {
|
g.Go(func() error {
|
||||||
res, searchErr := s.jikan.SearchAdvanced(
|
res, err := s.jikan.SearchAdvanced(
|
||||||
ctx,
|
ctx,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
@@ -297,7 +306,7 @@ func (s *animeService) getTopPicksForYou(
|
|||||||
1,
|
1,
|
||||||
forYouProfileSearchLimit,
|
forYouProfileSearchLimit,
|
||||||
)
|
)
|
||||||
if searchErr != nil {
|
if err != nil {
|
||||||
observability.Warn(
|
observability.Warn(
|
||||||
"top_pick_profile_search_failed",
|
"top_pick_profile_search_failed",
|
||||||
"anime",
|
"anime",
|
||||||
@@ -306,7 +315,7 @@ func (s *animeService) getTopPicksForYou(
|
|||||||
"genres": query.genreIDs,
|
"genres": query.genreIDs,
|
||||||
"studio_id": query.studioID,
|
"studio_id": query.studioID,
|
||||||
},
|
},
|
||||||
searchErr,
|
err,
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -315,7 +324,7 @@ func (s *animeService) getTopPicksForYou(
|
|||||||
if anime.MalID <= 0 {
|
if anime.MalID <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
upsertCandidate(rankedCandidate{
|
store.upsert(rankedCandidate{
|
||||||
id: anime.MalID,
|
id: anime.MalID,
|
||||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||||
anime: anime,
|
anime: anime,
|
||||||
@@ -326,46 +335,34 @@ func (s *animeService) getTopPicksForYou(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := profileSearchGroup.Wait(); err != nil {
|
return g.Wait()
|
||||||
return domain.CatalogSectionData{}, err
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if len(candidatesByID) == 0 {
|
func (s *animeService) scoreRankedCandidates(
|
||||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
ctx context.Context,
|
||||||
}
|
now time.Time,
|
||||||
|
profile userTasteProfile,
|
||||||
rankedIDs := make([]rankedCandidate, 0, len(candidatesByID))
|
ranked []rankedCandidate,
|
||||||
for _, item := range candidatesByID {
|
) ([]recommendationCandidate, error) {
|
||||||
rankedIDs = append(rankedIDs, item)
|
limit := min(len(ranked), forYouCandidateFetchLimit)
|
||||||
}
|
|
||||||
sort.Slice(rankedIDs, func(i, j int) bool {
|
|
||||||
left := rankedCandidateRetrievalScore(rankedIDs[i].collaborativeScore, rankedIDs[i].profileSearchScore)
|
|
||||||
right := rankedCandidateRetrievalScore(rankedIDs[j].collaborativeScore, rankedIDs[j].profileSearchScore)
|
|
||||||
if left == right {
|
|
||||||
return rankedIDs[i].id < rankedIDs[j].id
|
|
||||||
}
|
|
||||||
return left > right
|
|
||||||
})
|
|
||||||
|
|
||||||
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
|
|
||||||
candidates := make([]recommendationCandidate, 0, limit)
|
candidates := make([]recommendationCandidate, 0, limit)
|
||||||
var candidatesMu sync.Mutex
|
var candidatesMu sync.Mutex
|
||||||
var detailGroup errgroup.Group
|
var g errgroup.Group
|
||||||
detailGroup.SetLimit(6)
|
g.SetLimit(6)
|
||||||
|
|
||||||
for i := 0; i < limit; i++ {
|
for i := 0; i < limit; i++ {
|
||||||
item := rankedIDs[i]
|
item := ranked[i]
|
||||||
detailGroup.Go(func() error {
|
g.Go(func() error {
|
||||||
anime := item.anime
|
anime := item.anime
|
||||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||||
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
|
fetchedAnime, err := s.jikan.GetAnimeByID(ctx, item.id)
|
||||||
if fetchErr != nil {
|
if err != nil {
|
||||||
observability.Warn(
|
observability.Warn(
|
||||||
"recommendation_anime_fetch_failed",
|
"recommendation_anime_fetch_failed",
|
||||||
"anime",
|
"anime",
|
||||||
"",
|
"",
|
||||||
map[string]any{"anime_id": item.id},
|
map[string]any{"anime_id": item.id},
|
||||||
fetchErr,
|
err,
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -386,8 +383,8 @@ func (s *animeService) getTopPicksForYou(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := detailGroup.Wait(); err != nil {
|
if err := g.Wait(); err != nil {
|
||||||
return domain.CatalogSectionData{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(candidates, func(i, j int) bool {
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
@@ -397,6 +394,54 @@ func (s *animeService) getTopPicksForYou(
|
|||||||
return candidates[i].score > candidates[j].score
|
return candidates[i].score > candidates[j].score
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *animeService) getTopPicksForYou(
|
||||||
|
ctx context.Context,
|
||||||
|
userID string,
|
||||||
|
resultLimit int,
|
||||||
|
) (domain.CatalogSectionData, error) {
|
||||||
|
if strings.TrimSpace(userID) == "" {
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CatalogSectionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||||
|
if len(seedPool) == 0 {
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seedAnimes, err := s.fetchSeedAnimes(ctx, seedPool)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CatalogSectionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||||
|
store := newCandidateStore(watchlist)
|
||||||
|
|
||||||
|
if err := s.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
|
||||||
|
return domain.CatalogSectionData{}, err
|
||||||
|
}
|
||||||
|
if err := s.collectProfileSearchCandidates(ctx, profile, store); err != nil {
|
||||||
|
return domain.CatalogSectionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ranked := store.ranked()
|
||||||
|
if len(ranked) == 0 {
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := s.scoreRankedCandidates(ctx, now, profile, ranked)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CatalogSectionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return domain.CatalogSectionData{
|
return domain.CatalogSectionData{
|
||||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user