>, * meta: array * } */ public function forYouFeed(User $user, int $limit = self::DEFAULT_PAGE_SIZE, ?string $cursor = null): array { $safeLimit = max(1, min(50, $limit)); $cursorHash = $cursor ? md5($cursor) : '0'; $cacheKey = "for_you:{$user->id}:{$cursorHash}"; $ttl = (int) config('recommendations.ttl.for_you_feed', 5 * 60); return Cache::remember($cacheKey, $ttl, function () use ($user, $safeLimit, $cursor) { return $this->build($user, $safeLimit, $cursor); }); } /** * Convenience method for the homepage preview (first N items, no cursor). * * @return array> */ public function forYouPreview(User $user, int $limit = 12): array { $result = $this->forYouFeed($user, $limit); return $result['data'] ?? []; } // ─── Build pipeline ─────────────────────────────────────────────────────── /** * @return array{data: array>, meta: array} */ private function build(User $user, int $limit, ?string $cursor): array { $profile = $this->prefBuilder->build($user); $userId = (int) $user->id; if (! $profile->hasSignals()) { return $this->coldStart($userId, $limit, $cursor); } $poolSize = (int) config('recommendations.candidate_pool_size', 200); $tagSlugs = array_slice($profile->topTagSlugs, 0, 10); // ── 1. Meilisearch candidate retrieval ──────────────────────────────── $candidates = $this->fetchCandidates($tagSlugs, $userId, $poolSize); if ($candidates->isEmpty()) { return $this->coldStart($userId, $limit, $cursor); } // ── 2. Exclude already-favourited artworks ──────────────────────────── $favoritedIds = $this->getFavoritedIds((int) $user->id); $candidates = $candidates->whereNotIn('id', $favoritedIds)->values(); // ── 3. Enrich: load tags + stats for all candidates (2 IN queries) ──── $candidates->load(['tags:id,slug', 'stats']); // ── 4. PHP reranking ────────────────────────────────────────────────── $scored = $this->rerank($candidates, $profile); // ── 5. Diversity controls ───────────────────────────────────────────── $diversified = $this->applyDiversity($scored); // ── 6. Paginate ─────────────────────────────────────────────────────── return $this->paginate($diversified, $limit, $cursor, $profile); } // ─── Meilisearch retrieval ──────────────────────────────────────────────── /** * @return Collection */ private function fetchCandidates(array $tagSlugs, int $userId, int $poolSize): Collection { $filterParts = [ 'is_public = true', 'is_approved = true', 'author_id != ' . $userId, ]; if ($tagSlugs !== []) { $tagFilter = implode(' OR ', array_map( fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs )); $filterParts[] = '(' . $tagFilter . ')'; } try { $results = Artwork::search('') ->options([ 'filter' => implode(' AND ', $filterParts), 'sort' => ['trending_score_7d:desc', 'created_at:desc'], ]) ->paginate($poolSize, 'page', 1); return $results->getCollection(); } catch (\Throwable $e) { Log::warning('RecommendationService: Meilisearch unavailable, using DB fallback', [ 'error' => $e->getMessage(), ]); return $this->dbFallbackCandidates($userId, $tagSlugs, $poolSize); } } /** * DB fallback when Meilisearch is unavailable. * * @return Collection */ private function dbFallbackCandidates(int $userId, array $tagSlugs, int $poolSize): Collection { $query = Artwork::public() ->published() ->where('user_id', '!=', $userId) ->orderByDesc('trending_score_7d') ->orderByDesc('published_at') ->limit($poolSize); if ($tagSlugs !== []) { $query->whereHas('tags', fn ($q) => $q->whereIn('slug', $tagSlugs)); } return $query->get(); } // ─── Reranking ──────────────────────────────────────────────────────────── /** * Score each candidate and return a sorted array of [score, artwork]. * * @param Collection $candidates * @return array */ private function rerank(Collection $candidates, UserRecoProfileDTO $profile): array { $weights = (array) config('recommendations.weights', []); $wTag = (float) ($weights['tag_overlap'] ?? 0.40); $wCre = (float) ($weights['creator_affinity'] ?? 0.25); $wPop = (float) ($weights['popularity'] ?? 0.20); $wFresh = (float) ($weights['freshness'] ?? 0.15); $userTagSet = array_flip($profile->topTagSlugs); // slug → index (fast lookup) $scored = []; foreach ($candidates as $artwork) { $artworkTagSlugs = $artwork->tags->pluck('slug')->all(); $artworkTagSet = array_flip($artworkTagSlugs); // ── Tag overlap (Jaccard-like) ───────────────────────────────────── $commonTags = count(array_intersect_key($userTagSet, $artworkTagSet)); $totalTags = max(1, count($userTagSet) + count($artworkTagSet) - $commonTags); $tagOverlap = $commonTags / $totalTags; // ── Creator affinity ────────────────────────────────────────────── $creatorAffinity = $profile->followsCreator((int) $artwork->user_id) ? 1.0 : 0.0; // ── Popularity boost (log-normalised views) ─────────────────────── $views = max(0, (int) ($artwork->stats?->views ?? 0)); $popularity = min(1.0, log(1 + $views) / 12.0); // ── Freshness boost (exponential decay over 30 days) ───────────── $publishedAt = $artwork->published_at ?? $artwork->created_at ?? now(); $ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400); $freshness = exp(-$ageDays / 30.0); $score = ($wTag * $tagOverlap) + ($wCre * $creatorAffinity) + ($wPop * $popularity) + ($wFresh * $freshness); $scored[] = [ 'score' => $score, 'artwork' => $artwork, 'tag_slugs' => $artworkTagSlugs, ]; } usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']); return $scored; } // ─── Diversity controls ─────────────────────────────────────────────────── /** * Apply per-creator cap and tag variety enforcement. * * @param array $scored * @return array */ private function applyDiversity(array $scored): array { $maxPerCreator = (int) config('recommendations.max_per_creator', 3); $minUniqueTags = (int) config('recommendations.min_unique_tags', 5); $creatorCount = []; $seenTagSlugs = []; $result = []; $deferred = []; // items over per-creator cap (added back at end) foreach ($scored as $item) { $creatorId = (int) $item['artwork']->user_id; if (($creatorCount[$creatorId] ?? 0) >= $maxPerCreator) { $deferred[] = $item; continue; } $result[] = $item; $creatorCount[$creatorId] = ($creatorCount[$creatorId] ?? 0) + 1; foreach ($item['tag_slugs'] as $slug) { $seenTagSlugs[$slug] = true; } } // Check tag variety in first 20 – if insufficient, inject from deferred if (count($seenTagSlugs) < $minUniqueTags && $deferred !== []) { foreach ($deferred as $item) { $newTags = array_diff($item['tag_slugs'], array_keys($seenTagSlugs)); if ($newTags !== []) { $result[] = $item; foreach ($newTags as $slug) { $seenTagSlugs[$slug] = true; } if (count($seenTagSlugs) >= $minUniqueTags) { break; } } } } return $result; } // ─── Cold-start fallback ────────────────────────────────────────────────── /** * @return array{data: array>, meta: array} */ private function coldStart(int $userId, int $limit, ?string $cursor): array { $offset = $this->decodeCursor($cursor); try { $results = Artwork::search('') ->options([ 'filter' => 'is_public = true AND is_approved = true AND author_id != ' . $userId, 'sort' => ['trending_score_7d:desc', 'created_at:desc'], ]) ->paginate(self::COLD_START_LIMIT + $offset, 'page', 1); $artworks = $results->getCollection()->slice($offset, $limit)->values(); } catch (\Throwable) { $artworks = Artwork::public() ->published() ->where('user_id', '!=', $userId) ->orderByDesc('trending_score_7d') ->orderByDesc('published_at') ->skip($offset) ->limit($limit) ->get(); } $nextOffset = $offset + $limit; $hasMore = $artworks->count() >= $limit; return [ 'data' => $artworks->map(fn (Artwork $a): array => $this->serializeArtwork($a))->values()->all(), 'meta' => [ 'source' => 'cold_start', 'cursor' => $this->encodeCursor($offset), 'next_cursor' => $hasMore ? $this->encodeCursor($nextOffset) : null, 'limit' => $limit, ], ]; } // ─── Pagination ─────────────────────────────────────────────────────────── /** * @param array $diversified * @return array{data: array>, meta: array} */ private function paginate(array $diversified, int $limit, ?string $cursor, UserRecoProfileDTO $profile): array { $offset = $this->decodeCursor($cursor); $pageItems = array_slice($diversified, $offset, $limit); $total = count($diversified); $nextOffset = $offset + $limit; $data = array_map( fn (array $item): array => array_merge( $this->serializeArtwork($item['artwork']), ['score' => round((float) $item['score'], 5), 'source' => 'personalised'] ), $pageItems ); return [ 'data' => array_values($data), 'meta' => [ 'source' => 'personalised', 'cursor' => $this->encodeCursor($offset), 'next_cursor' => $nextOffset < $total ? $this->encodeCursor($nextOffset) : null, 'limit' => $limit, 'total_candidates' => $total, 'has_signals' => true, ], ]; } // ─── Helpers ────────────────────────────────────────────────────────────── /** @return array */ private function getFavoritedIds(int $userId): array { return DB::table('artwork_favourites') ->where('user_id', $userId) ->pluck('artwork_id') ->map(fn ($id) => (int) $id) ->all(); } /** @return array */ private function serializeArtwork(Artwork $artwork): array { return [ 'id' => $artwork->id, 'title' => $artwork->title ?? 'Untitled', 'slug' => $artwork->slug ?? '', 'thumbnail_url' => $artwork->thumbUrl('md'), 'author' => $artwork->user?->name ?? 'Artist', 'author_id' => $artwork->user_id, 'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''), 'width' => $artwork->width, 'height' => $artwork->height, 'published_at' => $artwork->published_at?->toIso8601String(), ]; } private function decodeCursor(?string $cursor): int { if (! $cursor) { return 0; } $decoded = base64_decode(strtr($cursor, '-_', '+/'), true); if ($decoded === false) { return 0; } $json = json_decode($decoded, true); return max(0, (int) Arr::get((array) $json, 'offset', 0)); } private function encodeCursor(int $offset): string { $payload = json_encode(['offset' => max(0, $offset)]); return rtrim(strtr(base64_encode((string) $payload), '+/', '-_'), '='); } }