feat: integrate profile search into top pick service

This commit is contained in:
2026-06-05 16:14:38 +02:00
parent e326f89d62
commit b4061bc9b1
3 changed files with 159 additions and 80 deletions

View File

@@ -119,11 +119,11 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
r.GET("/discover", h.HandleDiscover)
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
r.GET("/api/discover/top", h.HandleDiscoverTop)
r.GET("/api/discover/for-you", h.HandleDiscoverForYou)
r.GET("/schedule", h.HandleSchedule)
r.GET("/api/schedule", h.HandleScheduleSection)
r.GET("/browse", h.HandleBrowse)
@@ -252,6 +252,32 @@ func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
h.renderCatalogSection(c, "Continue")
}
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
if err != nil {
observability.Warn(
"top_pick_for_you_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = "TopPickForYou"
data.Fragment = "top_pick_for_you_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "index.gohtml", data)
}
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
@@ -288,32 +314,6 @@ func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
h.renderDiscoverSection(c, "Top")
}
func (h *AnimeHandler) HandleDiscoverForYou(c *gin.Context) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetDiscoverForYou(c.Request.Context(), userID)
if err != nil {
observability.Warn(
"discover_for_you_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = "ForYou"
data.Fragment = "discover_for_you_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "discover.gohtml", data)
}
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)

View File

@@ -108,25 +108,28 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
}, nil
}
func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (domain.DiscoverSectionData, error) {
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
if strings.TrimSpace(userID) == "" {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return domain.DiscoverSectionData{}, err
return domain.CatalogSectionData{}, err
}
now := time.Now()
seedPool := buildRecommendationSeeds(now, watchlist)
if len(seedPool) == 0 {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
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))
@@ -137,6 +140,34 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
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))
var seedFetchGroup errgroup.Group
seedFetchGroup.SetLimit(4)
@@ -153,18 +184,16 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
}
if err := seedFetchGroup.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
return domain.CatalogSectionData{}, err
}
profile := buildTasteProfile(seedAnimes)
profile := buildTasteProfile(now, seedPool, seedAnimes)
recommended := map[int]rankedCandidate{}
var recommendedMu sync.Mutex
var g errgroup.Group
g.SetLimit(4)
var recommendationGroup errgroup.Group
recommendationGroup.SetLimit(4)
for _, seed := range seedPool {
g.Go(func() error {
recommendationGroup.Go(func() error {
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
if recErr != nil {
return recErr
@@ -180,69 +209,119 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
if id == seed.animeID {
continue
}
if _, exists := watchlistAnimeIDs[id]; exists {
continue
}
recommendedMu.Lock()
current, ok := recommended[id]
if !ok {
recommended[id] = rankedCandidate{
id: id,
collaborativeScore: float64(rec.Votes) * seed.weight,
}
recommendedMu.Unlock()
continue
}
current.collaborativeScore += float64(rec.Votes) * seed.weight
recommended[id] = current
recommendedMu.Unlock()
upsertCandidate(rankedCandidate{
id: id,
collaborativeScore: float64(rec.Votes) * seed.weight,
})
}
return nil
})
}
if err := g.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
if err := recommendationGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
if len(recommended) == 0 {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
profileQueries := buildProfileSearchQueries(profile)
var profileSearchGroup errgroup.Group
profileSearchGroup.SetLimit(3)
for _, query := range profileQueries {
profileSearchGroup.Go(func() error {
res, searchErr := s.jikan.SearchAdvanced(
ctx,
"",
"",
"",
"score",
"desc",
query.genreIDs,
query.studioID,
true,
1,
forYouProfileSearchLimit,
)
if searchErr != nil {
observability.Warn(
"top_pick_profile_search_failed",
"anime",
"",
map[string]any{
"genres": query.genreIDs,
"studio_id": query.studioID,
},
searchErr,
)
return nil
}
for i, anime := range res.Animes {
if anime.MalID <= 0 {
continue
}
upsertCandidate(rankedCandidate{
id: anime.MalID,
profileSearchScore: query.weight * profileSearchRankWeight(i),
anime: anime,
hasAnime: true,
})
}
return nil
})
}
rankedIDs := make([]rankedCandidate, 0, len(recommended))
for _, item := range recommended {
if err := profileSearchGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
if len(candidatesByID) == 0 {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
rankedIDs := make([]rankedCandidate, 0, len(candidatesByID))
for _, item := range candidatesByID {
rankedIDs = append(rankedIDs, item)
}
sort.Slice(rankedIDs, func(i, j int) bool {
if rankedIDs[i].collaborativeScore == rankedIDs[j].collaborativeScore {
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 rankedIDs[i].collaborativeScore > rankedIDs[j].collaborativeScore
return left > right
})
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
candidates := make([]recommendationCandidate, 0, limit)
var candidatesMu sync.Mutex
g.SetLimit(6)
var detailGroup errgroup.Group
detailGroup.SetLimit(6)
for i := range limit {
g.Go(func() error {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id)
if fetchErr != nil {
observability.Warn(
"recommendation_anime_fetch_failed",
"anime",
"",
map[string]any{"anime_id": rankedIDs[i].id},
fetchErr,
)
return nil
for i := 0; i < limit; i++ {
item := rankedIDs[i]
detailGroup.Go(func() error {
anime := item.anime
if !item.hasAnime || !hasTasteMetadata(anime) {
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
if fetchErr != nil {
observability.Warn(
"recommendation_anime_fetch_failed",
"anime",
"",
map[string]any{"anime_id": item.id},
fetchErr,
)
return nil
}
anime = fetchedAnime
}
candidate := scoreRecommendationCandidate(
now,
profile,
anime,
rankedIDs[i].collaborativeScore,
item.collaborativeScore,
item.profileSearchScore,
)
candidatesMu.Lock()
candidates = append(candidates, candidate)
@@ -251,8 +330,8 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
})
}
if err := g.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
if err := detailGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
sort.Slice(candidates, func(i, j int) bool {
@@ -262,7 +341,7 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
return candidates[i].score > candidates[j].score
})
return domain.DiscoverSectionData{
return domain.CatalogSectionData{
Animes: rerankRecommendationCandidates(candidates, forYouResultLimit),
}, nil
}

View File

@@ -133,11 +133,11 @@ type ReviewEntry struct {
type AnimeCatalogService interface {
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
GetTopPickForYou(ctx context.Context, userID string) (CatalogSectionData, error)
}
type AnimeDiscoverService interface {
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
GetDiscoverForYou(ctx context.Context, userID string) (DiscoverSectionData, error)
GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error)
}