resolveAlgoVersion($algoVersion); $offset = $this->decodeCursorToOffset($cursor); $cache = UserRecommendationCache::query() ->where('user_id', $userId) ->where('algo_version', $resolvedAlgoVersion) ->first(); $cacheItems = $this->extractCacheItems($cache); $expectedCacheVersion = $this->currentCacheVersion(); $isFresh = $cache !== null && (string) ($cache->cache_version ?? '') === $expectedCacheVersion && $cache->expires_at !== null && $cache->expires_at->isFuture(); $cacheStatus = 'hit'; if ($cache === null) { $cacheStatus = 'miss'; } elseif (! $isFresh) { $cacheStatus = 'stale'; } if ($cache === null || ! $isFresh) { RegenerateUserRecommendationCacheJob::dispatch($userId, $resolvedAlgoVersion) ->onQueue((string) config('discovery.queue', 'default')); } $items = $cacheItems; if ($items === []) { $items = $this->buildRecommendations($userId, $resolvedAlgoVersion); $cacheStatus .= '-fallback'; } return $this->buildFeedPageResponse( items: $items, offset: $offset, limit: $safeLimit, algoVersion: $resolvedAlgoVersion, cacheStatus: $cacheStatus, generatedAt: $cache?->generated_at?->toIso8601String() ); } public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void { $resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion); $cacheVersion = $this->currentCacheVersion(); $ttlMinutes = $this->currentCacheTtlMinutes(); $items = $this->buildRecommendations($userId, $resolvedAlgoVersion); $generatedAt = now(); UserRecommendationCache::query()->updateOrCreate( [ 'user_id' => $userId, 'algo_version' => $resolvedAlgoVersion, ], [ 'cache_version' => $cacheVersion, 'recommendations_json' => [ 'items' => $items, 'algo_version' => $resolvedAlgoVersion, 'generated_at' => $generatedAt->toIso8601String(), ], 'generated_at' => $generatedAt, 'expires_at' => now()->addMinutes($ttlMinutes), ] ); } /** * @return array> */ public function buildRecommendations(int $userId, string $algoVersion): array { $poolLimit = max(40, (int) config('discovery.v2.feed_pool_size', 240)); $profile = $this->sessionReco->mergedProfile($userId, $algoVersion); $negativeSignals = $this->negativeSignals($userId); $hiddenArtworkIds = $negativeSignals['hidden_artwork_ids']; $dislikedTagIds = $negativeSignals['disliked_tag_ids']; $dislikedTagSlugs = $negativeSignals['disliked_tag_slugs']; $seenArtworkIds = array_values(array_unique(array_merge( $profile['seen_artwork_ids'], $this->recentDiscoveryArtworkIds($userId) ))); $layerTargets = $this->resolveLayerTargets($poolLimit); $layered = array_merge( $this->buildPersonalizedLayer($userId, $algoVersion, $profile, $hiddenArtworkIds, $layerTargets['personalized']), $this->buildSocialLayer($userId, $hiddenArtworkIds, $layerTargets['social']), $this->buildTrendingLayer($userId, $hiddenArtworkIds, $layerTargets['trending']), $this->buildExplorationLayer($userId, $algoVersion, $profile, $hiddenArtworkIds, $layerTargets['exploration']), $this->buildVectorLayer($profile, $hiddenArtworkIds) ); $deduped = []; foreach ($layered as $candidate) { $artworkId = (int) ($candidate['artwork_id'] ?? 0); if ($artworkId <= 0 || in_array($artworkId, $hiddenArtworkIds, true)) { continue; } if (! isset($deduped[$artworkId])) { $deduped[$artworkId] = $candidate; continue; } $deduped[$artworkId]['layer_sources'] = array_values(array_unique(array_merge( (array) ($deduped[$artworkId]['layer_sources'] ?? []), (array) ($candidate['layer_sources'] ?? []), ))); $deduped[$artworkId]['source'] = (string) $deduped[$artworkId]['source']; $deduped[$artworkId]['base_score'] = max( (float) ($deduped[$artworkId]['base_score'] ?? 0.0), (float) ($candidate['base_score'] ?? 0.0) ); $deduped[$artworkId]['session_seed'] = max( (float) ($deduped[$artworkId]['session_seed'] ?? 0.0), (float) ($candidate['session_seed'] ?? 0.0) ); $deduped[$artworkId]['vector_seed'] = max( (float) ($deduped[$artworkId]['vector_seed'] ?? 0.0), (float) ($candidate['vector_seed'] ?? 0.0) ); } $candidateRows = $this->loadCandidateRows(array_keys($deduped), $userId, $seenArtworkIds, $dislikedTagIds, $dislikedTagSlugs); if ($candidateRows === []) { return []; } $weights = (array) config('discovery.v2.weights', []); $selected = []; $creatorCounts = []; $recentTagCounts = []; $maxPerCreator = max(1, (int) config('discovery.v2.max_per_creator', 3)); foreach ($candidateRows as $row) { $artworkId = (int) $row['id']; $seed = (array) ($deduped[$artworkId] ?? []); $baseScore = (float) ($seed['base_score'] ?? 0.0); $sessionBoost = (float) ($row['session_boost'] ?? 0.0) * (float) ($weights['session'] ?? 1.4); $socialBoost = (float) ($row['social_boost'] ?? 0.0) * (float) ($weights['social'] ?? 1.1); $trendingBoost = (float) ($row['trending_boost'] ?? 0.0) * (float) ($weights['trending'] ?? 0.95); $explorationBoost = (float) ($row['exploration_boost'] ?? 0.0) * (float) ($weights['exploration'] ?? 0.7); $creatorBoost = (float) ($row['creator_boost'] ?? 0.0) * (float) ($weights['creator'] ?? 0.5); $vectorBoost = (float) ($seed['vector_seed'] ?? 0.0) * (float) config('discovery.v3.vector_similarity_weight', 0.8); $negativePenalty = (float) ($row['negative_penalty'] ?? 0.0); $repetitionPenalty = $this->repetitionPenalty($row, $creatorCounts, $recentTagCounts) * (float) ($weights['repetition_penalty'] ?? 0.45); $row['score'] = max(0.0, ($baseScore * (float) ($weights['base'] ?? 1.0)) + $sessionBoost + $socialBoost + $trendingBoost + $explorationBoost + $creatorBoost + $vectorBoost - $negativePenalty - $repetitionPenalty); $row['layer_sources'] = array_values(array_unique((array) ($seed['layer_sources'] ?? []))); if ($row['layer_sources'] === []) { $row['layer_sources'] = [(string) ($seed['source'] ?? $row['source'] ?? 'personalized')]; } $row['source'] = $this->resolveSource($row['layer_sources']); $row['vector_similarity_score'] = round((float) ($seed['vector_seed'] ?? 0.0), 6); $row['vector_influenced'] = in_array('vector', $row['layer_sources'], true) || ((float) ($seed['vector_seed'] ?? 0.0) > 0.0); $row['ranking_signals'] = [ 'base_score' => round($baseScore, 6), 'session_boost' => round($sessionBoost, 6), 'social_boost' => round($socialBoost, 6), 'trending_boost' => round($trendingBoost, 6), 'exploration_boost' => round($explorationBoost, 6), 'creator_boost' => round($creatorBoost, 6), 'vector_similarity_score' => round((float) ($seed['vector_seed'] ?? 0.0), 6), 'vector_boost' => round($vectorBoost, 6), 'negative_penalty' => round($negativePenalty, 6), 'repetition_penalty' => round($repetitionPenalty, 6), ]; $selected[] = $row; } usort($selected, static fn (array $left, array $right): int => ((float) $right['score']) <=> ((float) $left['score'])); $output = []; $creatorCounts = []; $recentTagCounts = []; foreach ($selected as $row) { $creatorId = (int) ($row['creator_id'] ?? 0); if (($creatorCounts[$creatorId] ?? 0) >= $maxPerCreator) { continue; } $output[] = [ 'artwork_id' => (int) $row['id'], 'score' => round((float) $row['score'], 6), 'source' => (string) $row['source'], 'layer_sources' => array_values(array_unique((array) $row['layer_sources'])), 'vector_influenced' => (bool) ($row['vector_influenced'] ?? false), 'vector_similarity_score' => round((float) ($row['vector_similarity_score'] ?? 0.0), 6), 'ranking_signals' => (array) ($row['ranking_signals'] ?? []), ]; $creatorCounts[$creatorId] = ($creatorCounts[$creatorId] ?? 0) + 1; foreach ((array) ($row['tag_slugs'] ?? []) as $tagSlug) { $recentTagCounts[$tagSlug] = ($recentTagCounts[$tagSlug] ?? 0) + 1; } if (count($output) >= $poolLimit) { break; } } return $output; } /** * @param array $hiddenArtworkIds * @param array $profile * @return array> */ private function buildPersonalizedLayer(int $userId, string $algoVersion, array $profile, array $hiddenArtworkIds, int $target): array { if ($target <= 0) { return []; } $mergedScores = (array) ($profile['merged_scores'] ?? []); $sessionScores = (array) ($profile['session_scores'] ?? []); $tagSlugs = $this->topKeysByPrefix($mergedScores, 'tag:', 12); $categoryIds = array_map('intval', $this->topKeysByPrefix($mergedScores, 'category:', 8)); $recentArtworkIds = array_slice(array_map('intval', (array) ($profile['recent_artwork_ids'] ?? [])), 0, 8); $candidateIds = []; foreach ($this->searchByTags($userId, $tagSlugs, $target * 3) as $artworkId) { $candidateIds[] = $artworkId; } foreach ($recentArtworkIds as $artworkId) { $similar = DB::table('artwork_similarities') ->where('algo_version', $algoVersion) ->where('artwork_id', $artworkId) ->orderBy('rank') ->orderByDesc('score') ->limit(max(8, (int) round($target / 2))) ->pluck('similar_artwork_id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $candidateIds = array_merge($candidateIds, $similar); } if ($categoryIds !== []) { $categoryCandidates = DB::table('artworks') ->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->whereIn('artwork_category.category_id', $categoryIds) ->where('artworks.user_id', '!=', $userId) ->whereNull('artworks.deleted_at') ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->where('artworks.published_at', '<=', now()) ->orderByDesc('artwork_stats.ranking_score') ->orderByDesc('artworks.trending_score_24h') ->limit($target * 2) ->pluck('artworks.id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $candidateIds = array_merge($candidateIds, $categoryCandidates); } $candidateIds = array_values(array_unique(array_filter($candidateIds, static fn (int $id): bool => $id > 0 && ! in_array($id, $hiddenArtworkIds, true)))); $items = []; foreach (array_slice($candidateIds, 0, $target * 3) as $artworkId) { $sessionSeed = (float) ($sessionScores['artwork:' . $artworkId] ?? 0.0); $items[] = [ 'artwork_id' => $artworkId, 'base_score' => 1.0 + $sessionSeed, 'session_seed' => $sessionSeed, 'source' => 'personalized', 'layer_sources' => ['personalized'], ]; } return array_slice($items, 0, $target); } /** * @param array $hiddenArtworkIds * @return array> */ private function buildSocialLayer(int $userId, array $hiddenArtworkIds, int $target): array { if ($target <= 0) { return []; } $followedCreatorIds = DB::table('user_followers') ->where('follower_id', $userId) ->pluck('user_id') ->map(static fn (mixed $id): int => (int) $id) ->all(); if ($followedCreatorIds === []) { return []; } $ownCreatorArtworks = DB::table('artworks') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->whereIn('artworks.user_id', $followedCreatorIds) ->whereNull('artworks.deleted_at') ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->where('artworks.published_at', '>=', now()->subDays(30)) ->orderByDesc('artworks.trending_score_24h') ->orderByDesc('artwork_stats.ranking_score') ->limit($target * 2) ->pluck('artworks.id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $likedByFollowed = DB::table('artwork_favourites') ->join('artworks', 'artworks.id', '=', 'artwork_favourites.artwork_id') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->whereIn('artwork_favourites.user_id', $followedCreatorIds) ->where('artworks.user_id', '!=', $userId) ->whereNull('artworks.deleted_at') ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->orderByDesc('artwork_favourites.created_at') ->orderByDesc('artwork_stats.ranking_score') ->limit($target * 2) ->pluck('artworks.id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $items = []; foreach (array_slice(array_values(array_unique(array_merge($ownCreatorArtworks, $likedByFollowed))), 0, $target * 2) as $artworkId) { if (in_array($artworkId, $hiddenArtworkIds, true)) { continue; } $items[] = [ 'artwork_id' => $artworkId, 'base_score' => 0.9, 'session_seed' => 0.0, 'source' => 'social', 'layer_sources' => ['social'], ]; } return array_slice($items, 0, $target); } /** * @param array $hiddenArtworkIds * @return array> */ private function buildTrendingLayer(int $userId, array $hiddenArtworkIds, int $target): array { if ($target <= 0) { return []; } $candidateIds = DB::table('artworks') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->where('artworks.user_id', '!=', $userId) ->whereNull('artworks.deleted_at') ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->where('artworks.published_at', '<=', now()) ->orderByDesc('artworks.trending_score_1h') ->orderByDesc('artworks.trending_score_24h') ->orderByDesc('artworks.trending_score_7d') ->orderByDesc('artwork_stats.heat_score') ->limit($target * 3) ->pluck('artworks.id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $items = []; foreach ($candidateIds as $artworkId) { if (in_array($artworkId, $hiddenArtworkIds, true)) { continue; } $items[] = [ 'artwork_id' => $artworkId, 'base_score' => 0.8, 'session_seed' => 0.0, 'source' => 'trending', 'layer_sources' => ['trending'], ]; } return array_slice($items, 0, $target); } /** * @param array $profile * @param array $hiddenArtworkIds * @return array> */ private function buildExplorationLayer(int $userId, string $algoVersion, array $profile, array $hiddenArtworkIds, int $target): array { if ($target <= 0) { return []; } $mergedScores = (array) ($profile['merged_scores'] ?? []); $seenCreatorIds = array_map('intval', (array) ($profile['recent_creator_ids'] ?? [])); $knownTagSlugs = $this->topKeysByPrefix($mergedScores, 'tag:', 16); $freshHours = max(1, (int) config('discovery.v2.fresh_upload_hours', 72)); $newCreatorDays = max(7, (int) config('discovery.v2.new_creator_days', 45)); $freshUploads = DB::table('artworks') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->where('artworks.user_id', '!=', $userId) ->whereNull('artworks.deleted_at') ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->where('artworks.published_at', '>=', now()->subHours($freshHours)) ->when($seenCreatorIds !== [], fn ($query) => $query->whereNotIn('artworks.user_id', $seenCreatorIds)) ->orderByDesc('artworks.published_at') ->orderByDesc('artwork_stats.heat_score') ->limit($target * 2) ->pluck('artworks.id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $newCreators = DB::table('artworks') ->join('users', 'users.id', '=', 'artworks.user_id') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->where('artworks.user_id', '!=', $userId) ->whereNull('artworks.deleted_at') ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->where('users.created_at', '>=', now()->subDays($newCreatorDays)) ->when($seenCreatorIds !== [], fn ($query) => $query->whereNotIn('artworks.user_id', $seenCreatorIds)) ->orderByDesc('artwork_stats.heat_score') ->orderByDesc('artworks.published_at') ->limit($target * 2) ->pluck('artworks.id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $unseenTagCandidates = []; if ($knownTagSlugs !== []) { $unseenTagCandidates = $this->searchOutsideKnownTags($userId, $knownTagSlugs, $target * 2); } $items = []; foreach (array_slice(array_values(array_unique(array_merge($freshUploads, $newCreators, $unseenTagCandidates))), 0, $target * 3) as $artworkId) { if (in_array($artworkId, $hiddenArtworkIds, true)) { continue; } $items[] = [ 'artwork_id' => $artworkId, 'base_score' => 0.65, 'session_seed' => 0.0, 'source' => 'exploration', 'layer_sources' => ['exploration'], ]; } return array_slice($items, 0, $target); } /** * @param array $profile * @param array $hiddenArtworkIds * @return array> */ private function buildVectorLayer(array $profile, array $hiddenArtworkIds): array { if (! $this->v3Enabled() || ! $this->vectors->isConfigured()) { return []; } $seedArtworkIds = array_slice(array_values(array_unique(array_map('intval', (array) ($profile['recent_artwork_ids'] ?? [])))), 0, max(1, (int) config('discovery.v3.max_seed_artworks', 3))); if ($seedArtworkIds === []) { return []; } $candidatePool = max(1, (int) config('discovery.v3.vector_candidate_pool', 60)); $perSeedLimit = max(6, (int) ceil($candidatePool / max(1, count($seedArtworkIds)))); $seedArtworks = Artwork::query()->whereIn('id', $seedArtworkIds)->public()->published()->get()->keyBy('id'); $baseScore = (float) config('discovery.v3.vector_base_score', 0.75); $merged = []; foreach ($seedArtworkIds as $seedArtworkId) { /** @var Artwork|null $seedArtwork */ $seedArtwork = $seedArtworks->get($seedArtworkId); if ($seedArtwork === null) { continue; } try { $matches = $this->vectors->similarToArtwork($seedArtwork, $perSeedLimit); } catch (\Throwable $e) { Log::warning('RecommendationServiceV2 vector layer failed', [ 'seed_artwork_id' => $seedArtworkId, 'error' => $e->getMessage(), ]); continue; } foreach ($matches as $match) { $artworkId = (int) ($match['id'] ?? 0); if ($artworkId <= 0 || in_array($artworkId, $hiddenArtworkIds, true)) { continue; } $vectorSeed = (float) ($match['score'] ?? 0.0); if (! isset($merged[$artworkId])) { $merged[$artworkId] = [ 'artwork_id' => $artworkId, 'base_score' => $baseScore, 'session_seed' => 0.0, 'vector_seed' => $vectorSeed, 'source' => 'vector', 'layer_sources' => ['vector'], ]; continue; } $merged[$artworkId]['vector_seed'] = max((float) ($merged[$artworkId]['vector_seed'] ?? 0.0), $vectorSeed); $merged[$artworkId]['base_score'] = max((float) ($merged[$artworkId]['base_score'] ?? 0.0), $baseScore); } } return array_slice(array_values($merged), 0, $candidatePool); } /** * @param array $candidateIds * @param array $seenArtworkIds * @param array $dislikedTagIds * @param array $dislikedTagSlugs * @return array> */ private function loadCandidateRows(array $candidateIds, int $userId, array $seenArtworkIds, array $dislikedTagIds, array $dislikedTagSlugs): array { if ($candidateIds === []) { return []; } /** @var Collection $artworks */ $artworks = Artwork::query() ->with([ 'user:id,name,username', 'user.profile:user_id,avatar_hash', 'user.statistics:user_id,followers_count,following_count', 'categories:id,name,slug,content_type_id,parent_id,sort_order', 'categories.contentType:id,name,slug', 'tags:id,slug', 'stats:artwork_id,views,downloads,favorites,comments_count,shares_count,views_1h,favourites_1h,comments_1h,shares_1h,ranking_score,engagement_velocity,heat_score', ]) ->whereIn('id', $candidateIds) ->public() ->published() ->get() ->keyBy('id'); $followedCreatorIds = DB::table('user_followers') ->where('follower_id', $userId) ->pluck('user_id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $followedLikedArtworkIds = DB::table('artwork_favourites') ->whereIn('user_id', $followedCreatorIds === [] ? [-1] : $followedCreatorIds) ->whereIn('artwork_id', array_keys($artworks->all())) ->pluck('artwork_id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $weights = (array) config('discovery.v2.weights', []); $trendingWeights = (array) config('discovery.v2.trending.period_weights', []); $explorationWeights = (array) config('discovery.v2.exploration', []); $negativePenaltyWeight = (float) config('discovery.v2.negative_signals.dislike_tag_penalty', 0.75); $rows = []; foreach ($candidateIds as $artworkId) { $artwork = $artworks->get($artworkId); if ($artwork === null) { continue; } $tagSlugs = $artwork->tags->pluck('slug')->map(static fn (mixed $slug): string => (string) $slug)->values()->all(); $tagIds = $artwork->tags->pluck('id')->map(static fn (mixed $id): int => (int) $id)->values()->all(); $creatorId = (int) ($artwork->user_id ?? 0); $stats = $artwork->stats; $publishedAt = $artwork->published_at ? CarbonImmutable::parse($artwork->published_at) : null; $ageHours = $publishedAt ? max(0.0, $publishedAt->diffInSeconds(now()) / 3600) : 0.0; $isSeenArtwork = in_array($artworkId, $seenArtworkIds, true); $isNewCreator = $artwork->user?->created_at?->greaterThanOrEqualTo(now()->subDays((int) config('discovery.v2.new_creator_days', 45))) ?? false; $hasUnseenTag = count(array_diff($tagSlugs, $this->recentDiscoveryTagSlugs($userId))) > 0; $isFreshUpload = $publishedAt !== null && $publishedAt->greaterThanOrEqualTo(now()->subHours((int) config('discovery.v2.fresh_upload_hours', 72))); $negativePenalty = 0.0; if (array_intersect($tagIds, $dislikedTagIds) !== [] || array_intersect($tagSlugs, $dislikedTagSlugs) !== []) { $negativePenalty += $negativePenaltyWeight; } $trendingBoost = ((float) ($artwork->trending_score_1h ?? 0.0) * (float) ($trendingWeights['1h'] ?? 1.0)) + ((float) ($artwork->trending_score_24h ?? 0.0) * (float) ($trendingWeights['24h'] ?? 0.7)) + ((float) ($artwork->trending_score_7d ?? 0.0) * (float) ($trendingWeights['7d'] ?? 0.45)); $creatorBoost = min(1.0, ((int) ($artwork->user?->statistics?->followers_count ?? 0)) / 5000) + min(1.0, ((float) ($stats?->engagement_velocity ?? 0.0)) / 40) + min(1.0, ((float) ($stats?->heat_score ?? 0.0)) / 100); $socialBoost = 0.0; if (in_array($creatorId, $followedCreatorIds, true)) { $socialBoost += (float) ($weights['followed_creator'] ?? 0.85); } if (in_array($artworkId, $followedLikedArtworkIds, true)) { $socialBoost += (float) ($weights['followed_like'] ?? 0.55); } $explorationBoost = 0.0; if (! $isSeenArtwork && $isNewCreator) { $explorationBoost += (float) ($explorationWeights['creator_bonus'] ?? 0.6); } if (! $isSeenArtwork && $hasUnseenTag) { $explorationBoost += (float) ($explorationWeights['tag_bonus'] ?? 0.45); } if ($isFreshUpload) { $explorationBoost += (float) ($explorationWeights['freshness_bonus'] ?? 0.55); } $rows[] = [ 'id' => (int) $artwork->id, 'creator_id' => $creatorId, 'tag_slugs' => $tagSlugs, 'session_boost' => $isSeenArtwork ? 0.15 : 0.35, 'social_boost' => $socialBoost, 'trending_boost' => $trendingBoost / 100, 'exploration_boost' => $explorationBoost, 'creator_boost' => $creatorBoost / 3, 'negative_penalty' => $negativePenalty, 'age_hours' => $ageHours, ]; } return $rows; } /** * @param array $creatorCounts * @param array $recentTagCounts */ private function repetitionPenalty(array $row, array $creatorCounts, array $recentTagCounts): float { $creatorPenalty = ((int) ($creatorCounts[(int) ($row['creator_id'] ?? 0)] ?? 0)) * 0.2; $tagPenalty = 0.0; foreach ((array) ($row['tag_slugs'] ?? []) as $tagSlug) { $tagPenalty += ((int) ($recentTagCounts[$tagSlug] ?? 0)) * 0.05; } return $creatorPenalty + $tagPenalty; } /** * @return array{hidden_artwork_ids: array, disliked_tag_ids: array, disliked_tag_slugs: array} */ private function negativeSignals(int $userId): array { $signals = UserNegativeSignal::query() ->with('tag:id,slug') ->where('user_id', $userId) ->get(); return [ 'hidden_artwork_ids' => $signals->where('signal_type', 'hide_artwork')->pluck('artwork_id')->filter()->map(static fn (mixed $id): int => (int) $id)->values()->all(), 'disliked_tag_ids' => $signals->where('signal_type', 'dislike_tag')->pluck('tag_id')->filter()->map(static fn (mixed $id): int => (int) $id)->values()->all(), 'disliked_tag_slugs' => $signals->where('signal_type', 'dislike_tag')->map(static fn (UserNegativeSignal $signal): string => (string) ($signal->tag?->slug ?? ''))->filter()->values()->all(), ]; } /** * @return array */ private function resolveLayerTargets(int $poolLimit): array { $ratios = (array) config('discovery.v2.layers', []); $normalized = [ 'personalized' => max(0.0, (float) ($ratios['personalized'] ?? 0.50)), 'social' => max(0.0, (float) ($ratios['social'] ?? 0.20)), 'trending' => max(0.0, (float) ($ratios['trending'] ?? 0.20)), 'exploration' => max(0.0, (float) ($ratios['exploration'] ?? 0.10)), ]; $sum = array_sum($normalized); if ($sum <= 0.0) { $sum = 1.0; } $targets = []; $assigned = 0; foreach ($normalized as $key => $ratio) { $targets[$key] = (int) floor(($ratio / $sum) * $poolLimit); $assigned += $targets[$key]; } if ($assigned < $poolLimit) { $targets['personalized'] += ($poolLimit - $assigned); } return $targets; } /** * @param array $tagSlugs * @return array */ private function searchByTags(int $userId, array $tagSlugs, int $poolSize): array { if ($tagSlugs === []) { return []; } $filterParts = [ 'is_public = true', 'is_approved = true', 'author_id != ' . $userId, ]; $tagFilter = implode(' OR ', array_map( static fn (string $tagSlug): string => 'tags = "' . addslashes($tagSlug) . '"', $tagSlugs )); $filterParts[] = '(' . $tagFilter . ')'; try { $results = Artwork::search('') ->options([ 'filter' => implode(' AND ', $filterParts), 'sort' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'created_at:desc'], ]) ->paginate(min($poolSize, max(1, (int) config('discovery.v2.candidate_pool_max', 300))), 'page', 1); return $this->searchResultCollection($results)->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all(); } catch (\Throwable $e) { Log::warning('RecommendationServiceV2 searchByTags fallback', ['error' => $e->getMessage()]); return DB::table('artworks') ->join('artwork_tag', 'artwork_tag.artwork_id', '=', 'artworks.id') ->join('tags', 'tags.id', '=', 'artwork_tag.tag_id') ->whereIn('tags.slug', $tagSlugs) ->where('artworks.user_id', '!=', $userId) ->whereNull('artworks.deleted_at') ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->orderByDesc('artworks.trending_score_24h') ->orderByDesc('artworks.trending_score_7d') ->limit($poolSize) ->pluck('artworks.id') ->map(static fn (mixed $id): int => (int) $id) ->all(); } } /** * @param array $knownTagSlugs * @return array */ private function searchOutsideKnownTags(int $userId, array $knownTagSlugs, int $poolSize): array { try { $results = Artwork::search('') ->options([ 'filter' => 'is_public = true AND is_approved = true AND author_id != ' . $userId, 'sort' => ['created_at:desc', 'trending_score_24h:desc'], ]) ->paginate(min($poolSize, max(1, (int) config('discovery.v2.candidate_pool_max', 300))), 'page', 1); return $this->searchResultCollection($results) ->filter(function (Artwork $artwork) use ($knownTagSlugs): bool { $artworkTags = collect($artwork->searchableTags ?? $artwork->tags?->pluck('slug')->all() ?? [])->map(static fn (mixed $slug): string => (string) $slug)->all(); return count(array_intersect($artworkTags, $knownTagSlugs)) === 0; }) ->pluck('id') ->map(static fn (mixed $id): int => (int) $id) ->values() ->all(); } catch (\Throwable $e) { Log::warning('RecommendationServiceV2 searchOutsideKnownTags fallback', ['error' => $e->getMessage()]); return []; } } /** * @param array $scores * @return array */ private function topKeysByPrefix(array $scores, string $prefix, int $limit): array { $filtered = []; foreach ($scores as $key => $score) { if (! str_starts_with((string) $key, $prefix)) { continue; } $filtered[(string) $key] = (float) $score; } arsort($filtered); return array_values(array_map( static fn (string $key): string => str_replace($prefix, '', $key), array_slice(array_keys($filtered), 0, $limit) )); } /** * @return array */ private function recentDiscoveryArtworkIds(int $userId): array { return DB::table('user_discovery_events') ->where('user_id', $userId) ->orderByDesc('occurred_at') ->limit((int) config('discovery.v2.repetition_window', 24)) ->pluck('artwork_id') ->map(static fn (mixed $id): int => (int) $id) ->values() ->all(); } /** * @return array */ private function recentDiscoveryTagSlugs(int $userId): array { return DB::table('user_discovery_events') ->join('artwork_tag', 'artwork_tag.artwork_id', '=', 'user_discovery_events.artwork_id') ->join('tags', 'tags.id', '=', 'artwork_tag.tag_id') ->where('user_discovery_events.user_id', $userId) ->orderByDesc('user_discovery_events.occurred_at') ->limit(max(10, (int) config('discovery.v2.repetition_window', 24) * 2)) ->pluck('tags.slug') ->map(static fn (mixed $slug): string => (string) $slug) ->unique() ->values() ->all(); } /** * @return Collection */ private function searchResultCollection(mixed $results): Collection { if ($results instanceof Collection) { return $results; } if ($results instanceof ScoutBuilder) { return collect(); } if (is_object($results) && method_exists($results, 'getCollection')) { $collection = $results->getCollection(); if ($collection instanceof Collection) { return $collection; } } return collect(); } /** * @param array $layerSources */ private function resolveSource(array $layerSources): string { if (in_array('personalized', $layerSources, true)) { return 'personalized'; } if (in_array('vector', $layerSources, true)) { return 'vector'; } if (in_array('social', $layerSources, true)) { return 'social'; } if (in_array('trending', $layerSources, true)) { return 'trending'; } return 'exploration'; } private function resolveAlgoVersion(?string $algoVersion = null): string { if ($algoVersion !== null && $algoVersion !== '') { return $algoVersion; } return (string) config('discovery.v2.algo_version', 'clip-cosine-v2-adaptive'); } /** * @param array> $items */ private function buildFeedPageResponse( array $items, int $offset, int $limit, string $algoVersion, string $cacheStatus, ?string $generatedAt ): array { $safeOffset = max(0, $offset); $pageItems = array_slice($items, $safeOffset, $limit); $spilloverItems = array_slice($items, $safeOffset + $limit, 12); $ids = array_values(array_unique(array_merge( array_map(static fn (array $item): int => (int) ($item['artwork_id'] ?? 0), $pageItems), array_map(static fn (array $item): int => (int) ($item['artwork_id'] ?? 0), $spilloverItems), ))); /** @var Collection $artworks */ $artworks = Artwork::query() ->with([ 'user:id,name,username', 'user.profile:user_id,avatar_hash', 'categories:id,name,slug,content_type_id,parent_id,sort_order', 'categories.contentType:id,name,slug', 'tags:id,name,slug', ]) ->whereIn('id', $ids) ->public() ->published() ->get() ->keyBy('id'); $embeddedArtworkIds = ArtworkEmbedding::query() ->whereIn('artwork_id', $ids) ->distinct() ->pluck('artwork_id') ->map(static fn ($artworkId): int => (int) $artworkId) ->all(); $responseItems = []; foreach ($pageItems as $item) { $artworkId = (int) ($item['artwork_id'] ?? 0); $artwork = $artworks->get($artworkId); if ($artwork === null) { continue; } $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $primaryTag = $artwork->tags->sortBy('name')->first(); $source = (string) ($item['source'] ?? 'personalized'); $hasLocalEmbedding = in_array($artwork->id, $embeddedArtworkIds, true); $vectorIndexedAt = $artwork->last_vector_indexed_at?->toIso8601String(); $rankingSignals = (array) ($item['ranking_signals'] ?? []); $rankingSignals['local_embedding_present'] = $hasLocalEmbedding; $rankingSignals['vector_indexed_at'] = $vectorIndexedAt; $responseItems[] = [ 'id' => $artwork->id, 'slug' => $artwork->slug, 'title' => $artwork->title, 'thumbnail_url' => $artwork->thumb_url, 'thumbnail_srcset' => $artwork->thumb_srcset, 'author' => $artwork->user?->name, 'username' => $artwork->user?->username, 'author_id' => $artwork->user?->id, 'avatar_url' => AvatarUrl::forUser( (int) ($artwork->user?->id ?? 0), $artwork->user?->profile?->avatar_hash, 64 ), 'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $primaryCategory?->name ?? '', 'category_slug' => $primaryCategory?->slug ?? '', 'width' => $artwork->width, 'height' => $artwork->height, 'published_at' => $artwork->published_at?->toIso8601String(), 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]), 'primary_tag' => $primaryTag !== null ? [ 'id' => (int) $primaryTag->id, 'name' => (string) $primaryTag->name, 'slug' => (string) $primaryTag->slug, ] : null, 'tags' => $artwork->tags ->sortBy('name') ->take(3) ->map(static fn ($tag): array => [ 'id' => (int) $tag->id, 'name' => (string) $tag->name, 'slug' => (string) $tag->slug, ]) ->values() ->all(), 'score' => (float) ($item['score'] ?? 0.0), 'source' => $source, 'reason' => $this->recommendationReason($source, (array) ($item['layer_sources'] ?? []), (string) ($primaryCategory?->name ?? '')), 'vector_influenced' => (bool) ($item['vector_influenced'] ?? false), 'vector_similarity_score' => (float) ($item['vector_similarity_score'] ?? 0.0), 'has_local_embedding' => $hasLocalEmbedding, 'vector_indexed_at' => $vectorIndexedAt, 'ranking_signals' => $rankingSignals, 'algo_version' => $algoVersion, ]; } $nextOffset = $safeOffset + $limit; $discoverySections = $this->buildDiscoverySections($artworks, $responseItems, $spilloverItems); return [ 'data' => $responseItems, 'sections' => $discoverySections, 'meta' => [ 'algo_version' => $algoVersion, 'cursor' => $this->encodeOffsetToCursor($safeOffset), 'next_cursor' => $nextOffset < count($items) ? $this->encodeOffsetToCursor($nextOffset) : null, 'limit' => $limit, 'cache_status' => $cacheStatus, 'generated_at' => $generatedAt, 'total_candidates' => count($items), 'vector_influenced_count' => count(array_filter($responseItems, static fn (array $item): bool => (bool) ($item['vector_influenced'] ?? false))), 'local_embedding_count' => count(array_filter($responseItems, static fn (array $item): bool => (bool) ($item['has_local_embedding'] ?? false))), 'vector_indexed_count' => count(array_filter($responseItems, static fn (array $item): bool => (string) ($item['vector_indexed_at'] ?? '') !== '')), 'engine' => 'v2', ], ]; } /** * @param Collection $artworks * @param array> $responseItems * @param array> $spilloverItems * @return array> */ private function buildDiscoverySections(Collection $artworks, array $responseItems, array $spilloverItems): array { if (! $this->v3Enabled() || ! $this->vectors->isConfigured() || $responseItems === []) { return []; } $sectionConfig = (array) config('discovery.v3.sections', []); $similarStyleLimit = max(1, (int) ($sectionConfig['similar_style_limit'] ?? 3)); $youMayAlsoLikeLimit = max(1, (int) ($sectionConfig['you_may_also_like_limit'] ?? 6)); $visuallyRelatedLimit = max(1, (int) ($sectionConfig['visually_related_limit'] ?? 6)); $anchorId = (int) ($responseItems[0]['id'] ?? 0); if ($anchorId <= 0) { return []; } /** @var Artwork|null $anchorArtwork */ $anchorArtwork = $artworks->get($anchorId); if ($anchorArtwork === null) { return []; } $sections = []; $pageIds = array_values(array_unique(array_map(static fn (array $item): int => (int) ($item['id'] ?? 0), $responseItems))); $similarStyleItems = $this->mapSpilloverSectionItems($spilloverItems, $artworks, [], $similarStyleLimit); if ($similarStyleItems !== []) { $sections[] = [ 'key' => 'similar_style', 'title' => 'Similar Style', 'source' => 'hybrid_feed', 'anchor_artwork_id' => $anchorId, 'items' => $similarStyleItems, ]; } $usedSpilloverIds = array_values(array_unique(array_map(static fn (array $item): int => (int) ($item['id'] ?? 0), $similarStyleItems))); $youMayAlsoLikeItems = $this->mapSpilloverSectionItems($spilloverItems, $artworks, $usedSpilloverIds, $youMayAlsoLikeLimit); if ($youMayAlsoLikeItems === []) { $youMayAlsoLikeItems = $this->mapResponseSectionItems($responseItems, [$anchorId], $youMayAlsoLikeLimit); } if ($youMayAlsoLikeItems !== []) { $sections[] = [ 'key' => 'you_may_also_like', 'title' => 'You may also like', 'source' => 'hybrid_feed', 'anchor_artwork_id' => $anchorId, 'items' => $youMayAlsoLikeItems, ]; } try { $items = $this->vectors->similarToArtwork($anchorArtwork, $visuallyRelatedLimit); } catch (\Throwable $e) { Log::warning('RecommendationServiceV2 discovery sections failed', [ 'anchor_artwork_id' => $anchorId, 'error' => $e->getMessage(), ]); return $sections; } $items = array_values(array_filter($items, static fn (array $item): bool => ! in_array((int) ($item['id'] ?? 0), $pageIds, true))); if ($items !== []) { $sections[] = [ 'key' => 'visually_related', 'title' => 'Visually related', 'source' => 'vector_gateway', 'anchor_artwork_id' => $anchorId, 'items' => array_slice($items, 0, $visuallyRelatedLimit), ]; } return $sections; } /** * @param array> $spilloverItems * @param array $excludeIds * @return array> */ private function mapSpilloverSectionItems(array $spilloverItems, Collection $artworks, array $excludeIds, int $limit): array { if ($spilloverItems === [] || $limit <= 0) { return []; } $mapped = []; foreach ($spilloverItems as $item) { $artworkId = (int) ($item['artwork_id'] ?? 0); if ($artworkId <= 0 || in_array($artworkId, $excludeIds, true)) { continue; } /** @var Artwork|null $artwork */ $artwork = $artworks->get($artworkId); if ($artwork === null) { continue; } $mapped[] = [ 'id' => (int) $artwork->id, 'title' => (string) $artwork->title, 'slug' => (string) $artwork->slug, 'thumb' => $artwork->thumbUrl('md'), 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]), 'author' => $artwork->user?->name ?? 'Artist', 'author_avatar' => $artwork->user?->profile?->avatar_url, 'author_id' => $artwork->user_id, 'score' => round((float) ($item['score'] ?? 0.0), 5), 'source' => (string) ($item['source'] ?? 'hybrid_feed'), 'reason' => $this->recommendationReason( (string) ($item['source'] ?? 'personalized'), (array) ($item['layer_sources'] ?? []), (string) ($artwork->categories->sortBy('sort_order')->first()?->name ?? '') ), ]; if (count($mapped) >= $limit) { break; } } return $mapped; } /** * @param array> $responseItems * @param array $excludeIds * @return array> */ private function mapResponseSectionItems(array $responseItems, array $excludeIds, int $limit): array { if ($responseItems === [] || $limit <= 0) { return []; } $mapped = []; foreach ($responseItems as $item) { $artworkId = (int) ($item['id'] ?? 0); if ($artworkId <= 0 || in_array($artworkId, $excludeIds, true)) { continue; } $mapped[] = [ 'id' => $artworkId, 'title' => (string) ($item['title'] ?? ''), 'slug' => (string) ($item['slug'] ?? ''), 'thumb' => $item['thumbnail_url'] ?? null, 'url' => (string) ($item['url'] ?? ''), 'author' => (string) ($item['author'] ?? 'Artist'), 'author_avatar' => $item['avatar_url'] ?? null, 'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null, 'score' => round((float) ($item['score'] ?? 0.0), 5), 'source' => (string) ($item['source'] ?? 'hybrid_feed'), 'reason' => (string) ($item['reason'] ?? ''), ]; if (count($mapped) >= $limit) { break; } } return $mapped; } /** * @param array $layerSources */ private function recommendationReason(string $source, array $layerSources, string $categoryName): string { if (in_array('vector', $layerSources, true)) { return 'Visually similar to art you engaged with'; } if (in_array('social', $layerSources, true)) { return 'Popular with creators you follow'; } if (in_array('exploration', $layerSources, true)) { return 'Exploring something fresh for you'; } if (in_array('trending', $layerSources, true)) { return $categoryName !== '' ? 'Trending in ' . $categoryName . ' right now' : 'Trending across Skinbase right now'; } return $categoryName !== '' ? 'Matched to your current interest in ' . $categoryName : 'Matched to your current interests'; } private function decodeCursorToOffset(?string $cursor): int { if ($cursor === null || $cursor === '') { return 0; } $decoded = base64_decode(strtr($cursor, '-_', '+/'), true); if ($decoded === false) { return 0; } $json = json_decode($decoded, true); if (! is_array($json)) { return 0; } return max(0, (int) Arr::get($json, 'offset', 0)); } private function encodeOffsetToCursor(int $offset): string { $payload = json_encode(['offset' => max(0, $offset)]); return is_string($payload) ? rtrim(strtr(base64_encode($payload), '+/', '-_'), '=') : ''; } /** * @return array> */ private function extractCacheItems(?UserRecommendationCache $cache): array { if ($cache === null) { return []; } $raw = (array) ($cache->recommendations_json ?? []); $items = $raw['items'] ?? null; if (! is_array($items)) { return []; } $typed = []; foreach ($items as $item) { if (! is_array($item)) { continue; } $artworkId = (int) ($item['artwork_id'] ?? 0); if ($artworkId <= 0) { continue; } $typed[] = [ 'artwork_id' => $artworkId, 'score' => (float) ($item['score'] ?? 0.0), 'source' => (string) ($item['source'] ?? 'personalized'), 'layer_sources' => array_values(array_unique(array_map('strval', (array) ($item['layer_sources'] ?? [])))), 'vector_influenced' => (bool) ($item['vector_influenced'] ?? false), 'vector_similarity_score' => (float) ($item['vector_similarity_score'] ?? 0.0), 'ranking_signals' => (array) ($item['ranking_signals'] ?? []), ]; } return $typed; } private function v3Enabled(): bool { return (bool) config('discovery.v3.enabled', false); } private function currentCacheVersion(): string { if ($this->v3Enabled()) { return (string) config('discovery.v3.cache_version', 'cache-v3'); } return (string) config('discovery.v2.cache_version', 'cache-v2'); } private function currentCacheTtlMinutes(): int { if ($this->v3Enabled()) { return max(1, (int) config('discovery.v3.cache_ttl_minutes', 5)); } return max(1, (int) config('discovery.v2.cache_ttl_minutes', 15)); } }