feat: integrate profile search into top pick service
This commit is contained in:
@@ -119,11 +119,11 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
|||||||
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
||||||
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
||||||
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
||||||
|
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
||||||
r.GET("/discover", h.HandleDiscover)
|
r.GET("/discover", h.HandleDiscover)
|
||||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
||||||
r.GET("/api/discover/for-you", h.HandleDiscoverForYou)
|
|
||||||
r.GET("/schedule", h.HandleSchedule)
|
r.GET("/schedule", h.HandleSchedule)
|
||||||
r.GET("/api/schedule", h.HandleScheduleSection)
|
r.GET("/api/schedule", h.HandleScheduleSection)
|
||||||
r.GET("/browse", h.HandleBrowse)
|
r.GET("/browse", h.HandleBrowse)
|
||||||
@@ -252,6 +252,32 @@ func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
|||||||
h.renderCatalogSection(c, "Continue")
|
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) {
|
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||||
userID := server.CurrentUserID(c)
|
userID := server.CurrentUserID(c)
|
||||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
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")
|
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) {
|
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
||||||
userID := server.CurrentUserID(c)
|
userID := server.CurrentUserID(c)
|
||||||
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
||||||
|
|||||||
@@ -108,25 +108,28 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
|
|||||||
}, nil
|
}, 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) == "" {
|
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)
|
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DiscoverSectionData{}, err
|
return domain.CatalogSectionData{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||||
if len(seedPool) == 0 {
|
if len(seedPool) == 0 {
|
||||||
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type rankedCandidate struct {
|
type rankedCandidate struct {
|
||||||
id int
|
id int
|
||||||
collaborativeScore float64
|
collaborativeScore float64
|
||||||
|
profileSearchScore float64
|
||||||
|
anime jikan.Anime
|
||||||
|
hasAnime bool
|
||||||
}
|
}
|
||||||
|
|
||||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
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{}{}
|
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 seedFetchGroup errgroup.Group
|
||||||
seedFetchGroup.SetLimit(4)
|
seedFetchGroup.SetLimit(4)
|
||||||
@@ -153,18 +184,16 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := seedFetchGroup.Wait(); err != nil {
|
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 recommendationGroup errgroup.Group
|
||||||
var recommendedMu sync.Mutex
|
recommendationGroup.SetLimit(4)
|
||||||
var g errgroup.Group
|
|
||||||
g.SetLimit(4)
|
|
||||||
|
|
||||||
for _, seed := range seedPool {
|
for _, seed := range seedPool {
|
||||||
g.Go(func() error {
|
recommendationGroup.Go(func() error {
|
||||||
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||||
if recErr != nil {
|
if recErr != nil {
|
||||||
return recErr
|
return recErr
|
||||||
@@ -180,69 +209,119 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
|
|||||||
if id == seed.animeID {
|
if id == seed.animeID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := watchlistAnimeIDs[id]; exists {
|
upsertCandidate(rankedCandidate{
|
||||||
continue
|
id: id,
|
||||||
}
|
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
if err := recommendationGroup.Wait(); err != nil {
|
||||||
return domain.DiscoverSectionData{}, err
|
return domain.CatalogSectionData{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(recommended) == 0 {
|
profileQueries := buildProfileSearchQueries(profile)
|
||||||
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
|
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))
|
if err := profileSearchGroup.Wait(); err != nil {
|
||||||
for _, item := range recommended {
|
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)
|
rankedIDs = append(rankedIDs, item)
|
||||||
}
|
}
|
||||||
sort.Slice(rankedIDs, func(i, j int) bool {
|
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].id < rankedIDs[j].id
|
||||||
}
|
}
|
||||||
return rankedIDs[i].collaborativeScore > rankedIDs[j].collaborativeScore
|
return left > right
|
||||||
})
|
})
|
||||||
|
|
||||||
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
|
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
|
||||||
candidates := make([]recommendationCandidate, 0, limit)
|
candidates := make([]recommendationCandidate, 0, limit)
|
||||||
var candidatesMu sync.Mutex
|
var candidatesMu sync.Mutex
|
||||||
g.SetLimit(6)
|
var detailGroup errgroup.Group
|
||||||
|
detailGroup.SetLimit(6)
|
||||||
|
|
||||||
for i := range limit {
|
for i := 0; i < limit; i++ {
|
||||||
g.Go(func() error {
|
item := rankedIDs[i]
|
||||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id)
|
detailGroup.Go(func() error {
|
||||||
if fetchErr != nil {
|
anime := item.anime
|
||||||
observability.Warn(
|
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||||
"recommendation_anime_fetch_failed",
|
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
|
||||||
"anime",
|
if fetchErr != nil {
|
||||||
"",
|
observability.Warn(
|
||||||
map[string]any{"anime_id": rankedIDs[i].id},
|
"recommendation_anime_fetch_failed",
|
||||||
fetchErr,
|
"anime",
|
||||||
)
|
"",
|
||||||
return nil
|
map[string]any{"anime_id": item.id},
|
||||||
|
fetchErr,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
anime = fetchedAnime
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate := scoreRecommendationCandidate(
|
candidate := scoreRecommendationCandidate(
|
||||||
now,
|
now,
|
||||||
profile,
|
profile,
|
||||||
anime,
|
anime,
|
||||||
rankedIDs[i].collaborativeScore,
|
item.collaborativeScore,
|
||||||
|
item.profileSearchScore,
|
||||||
)
|
)
|
||||||
candidatesMu.Lock()
|
candidatesMu.Lock()
|
||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
@@ -251,8 +330,8 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
if err := detailGroup.Wait(); err != nil {
|
||||||
return domain.DiscoverSectionData{}, err
|
return domain.CatalogSectionData{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(candidates, func(i, j int) bool {
|
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 candidates[i].score > candidates[j].score
|
||||||
})
|
})
|
||||||
|
|
||||||
return domain.DiscoverSectionData{
|
return domain.CatalogSectionData{
|
||||||
Animes: rerankRecommendationCandidates(candidates, forYouResultLimit),
|
Animes: rerankRecommendationCandidates(candidates, forYouResultLimit),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,11 +133,11 @@ type ReviewEntry struct {
|
|||||||
|
|
||||||
type AnimeCatalogService interface {
|
type AnimeCatalogService interface {
|
||||||
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
||||||
|
GetTopPickForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnimeDiscoverService interface {
|
type AnimeDiscoverService interface {
|
||||||
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
|
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)
|
GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user