Files
SkinbaseNova/app/Services/Recommendations/RecommendationServiceV2.php
2026-03-28 19:15:39 +01:00

1349 lines
54 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Models\UserRecommendationCache;
use App\Models\UserNegativeSignal;
use App\Support\AvatarUrl;
use App\Services\Vision\VectorService;
use Carbon\CarbonImmutable;
use Laravel\Scout\Builder as ScoutBuilder;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
final class RecommendationServiceV2
{
public function __construct(
private readonly SessionRecoService $sessionReco,
private readonly VectorService $vectors,
)
{
}
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
{
$safeLimit = max(1, min(50, $limit));
$resolvedAlgoVersion = $this->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<int, array<string, mixed>>
*/
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<int, int> $hiddenArtworkIds
* @param array<string, mixed> $profile
* @return array<int, array<string, mixed>>
*/
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<int, int> $hiddenArtworkIds
* @return array<int, array<string, mixed>>
*/
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<int, int> $hiddenArtworkIds
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $profile
* @param array<int, int> $hiddenArtworkIds
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $profile
* @param array<int, int> $hiddenArtworkIds
* @return array<int, array<string, mixed>>
*/
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<int, int> $candidateIds
* @param array<int, int> $seenArtworkIds
* @param array<int, int> $dislikedTagIds
* @param array<int, string> $dislikedTagSlugs
* @return array<int, array<string, mixed>>
*/
private function loadCandidateRows(array $candidateIds, int $userId, array $seenArtworkIds, array $dislikedTagIds, array $dislikedTagSlugs): array
{
if ($candidateIds === []) {
return [];
}
/** @var Collection<int, Artwork> $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<int, int> $creatorCounts
* @param array<string, int> $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<int, int>, disliked_tag_ids: array<int, int>, disliked_tag_slugs: array<int, string>}
*/
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<string, int>
*/
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<int, string> $tagSlugs
* @return array<int, int>
*/
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<int, string> $knownTagSlugs
* @return array<int, int>
*/
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<string, float> $scores
* @return array<int, string>
*/
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<int, int>
*/
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<int, string>
*/
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<int, Artwork>
*/
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<int, string> $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<int, array<string, mixed>> $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<int, Artwork> $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<int, Artwork> $artworks
* @param array<int, array<string, mixed>> $responseItems
* @param array<int, array<string, mixed>> $spilloverItems
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $spilloverItems
* @param array<int, int> $excludeIds
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $responseItems
* @param array<int, int> $excludeIds
* @return array<int, array<string, mixed>>
*/
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<int, string> $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<int, array<string, mixed>>
*/
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));
}
}