1349 lines
54 KiB
PHP
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));
|
|
}
|
|
}
|