diff --git a/app/DTOs/UserRecoProfileDTO.php b/app/DTOs/UserRecoProfileDTO.php new file mode 100644 index 00000000..84c6de2f --- /dev/null +++ b/app/DTOs/UserRecoProfileDTO.php @@ -0,0 +1,94 @@ + $topTagSlugs Top tag slugs by weighted score (up to 20) + * @param array $topCategorySlugs Top category slugs (up to 5) + * @param array $strongCreatorIds Followed creator user IDs (up to 50) + * @param array $tagWeights Tag slug → normalised weight (0–1) + * @param array $categoryWeights Category slug → normalised weight + * @param array $dislikedTagSlugs Future: blocked/hidden tag slugs + */ + public function __construct( + public readonly array $topTagSlugs = [], + public readonly array $topCategorySlugs = [], + public readonly array $strongCreatorIds = [], + public readonly array $tagWeights = [], + public readonly array $categoryWeights = [], + public readonly array $dislikedTagSlugs = [], + ) {} + + /** + * True if the user has enough signals to drive personalised recommendations. + */ + public function hasSignals(): bool + { + return $this->topTagSlugs !== [] || $this->strongCreatorIds !== []; + } + + /** + * Returns the normalised tag weight for a given slug (0.0 if unknown). + */ + public function tagWeight(string $slug): float + { + return (float) ($this->tagWeights[$slug] ?? 0.0); + } + + /** + * Returns true when the creator is in the user's strong-follow list. + */ + public function followsCreator(int $userId): bool + { + return in_array($userId, $this->strongCreatorIds, true); + } + + /** + * Serialise for storage in the DB / Redis cache. + * + * @return array + */ + public function toArray(): array + { + return [ + 'top_tags' => $this->topTagSlugs, + 'top_categories' => $this->topCategorySlugs, + 'strong_creators' => $this->strongCreatorIds, + 'tag_weights' => $this->tagWeights, + 'category_weights' => $this->categoryWeights, + 'disliked_tags' => $this->dislikedTagSlugs, + ]; + } + + /** + * Re-hydrate from a stored array (e.g. from the DB JSON column). + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + topTagSlugs: (array) ($data['top_tags'] ?? []), + topCategorySlugs: (array) ($data['top_categories'] ?? []), + strongCreatorIds: array_map('intval', (array) ($data['strong_creators'] ?? [])), + tagWeights: array_map('floatval', (array) ($data['tag_weights'] ?? [])), + categoryWeights: array_map('floatval', (array) ($data['category_weights'] ?? [])), + dislikedTagSlugs: (array) ($data['disliked_tags'] ?? []), + ); + } +} diff --git a/app/Http/Controllers/Api/SimilarArtworksController.php b/app/Http/Controllers/Api/SimilarArtworksController.php index 48dc91ed..c7b1a54b 100644 --- a/app/Http/Controllers/Api/SimilarArtworksController.php +++ b/app/Http/Controllers/Api/SimilarArtworksController.php @@ -24,7 +24,8 @@ use Illuminate\Support\Facades\Cache; final class SimilarArtworksController extends Controller { private const LIMIT = 12; - private const CACHE_TTL = 300; // 5 minutes + /** Spec §5: cache similar artworks 30–60 min; using config with 30 min default. */ + private const CACHE_TTL = 1800; // 30 minutes public function __construct(private readonly ArtworkSearchService $search) {} @@ -50,9 +51,9 @@ final class SimilarArtworksController extends Controller private function findSimilar(Artwork $artwork): array { - $tagSlugs = $artwork->tags->pluck('slug')->values()->all(); + $tagSlugs = $artwork->tags->pluck('slug')->values()->all(); $categorySlugs = $artwork->categories->pluck('slug')->values()->all(); - $orientation = $this->orientation($artwork); + $srcOrientation = $this->orientation($artwork); // Build Meilisearch filter: exclude self and same creator $filterParts = [ @@ -62,11 +63,6 @@ final class SimilarArtworksController extends Controller 'author_id != ' . $artwork->user_id, ]; - // Filter by same orientation (landscape/portrait) — improves visual coherence - if ($orientation !== 'square') { - $filterParts[] = 'orientation = "' . $orientation . '"'; - } - // Priority 1: tag overlap (OR match across tags) if ($tagSlugs !== []) { $tagFilter = implode(' OR ', array_map( @@ -83,27 +79,80 @@ final class SimilarArtworksController extends Controller $filterParts[] = '(' . $catFilter . ')'; } + // ── Fetch 200-candidate pool from Meilisearch ───────────────────────── $results = Artwork::search('') ->options([ 'filter' => implode(' AND ', $filterParts), - 'sort' => ['trending_score_7d:desc', 'likes:desc'], + 'sort' => ['trending_score_7d:desc', 'created_at:desc'], ]) - ->paginate(self::LIMIT); + ->paginate(200, 'page', 1); - return $results->getCollection() - ->map(fn (Artwork $a): array => [ - 'id' => $a->id, - 'title' => $a->title, - 'slug' => $a->slug, - 'thumb' => $a->thumbUrl('md'), - 'url' => '/art/' . $a->id . '/' . $a->slug, - 'author_id' => $a->user_id, - 'orientation' => $this->orientation($a), - 'width' => $a->width, - 'height' => $a->height, - ]) - ->values() - ->all(); + $collection = $results->getCollection(); + $collection->load(['tags:id,slug', 'stats']); + + // ── PHP reranking ────────────────────────────────────────────────────── + // Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus + // +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10 + $srcTagSet = array_flip($tagSlugs); + $srcW = (int) ($artwork->width ?? 0); + $srcH = (int) ($artwork->height ?? 0); + + $scored = $collection->map(function (Artwork $candidate) use ( + $srcTagSet, $tagSlugs, $srcOrientation, $srcW, $srcH + ): array { + $cTagSlugs = $candidate->tags->pluck('slug')->all(); + $cTagSet = array_flip($cTagSlugs); + + // Tag overlap (Sørensen–Dice-like) + $common = count(array_intersect_key($srcTagSet, $cTagSet)); + $total = max(1, count($srcTagSet) + count($cTagSet) - $common); + $tagOverlap = $common / $total; + + // Orientation bonus + $orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0; + + // Resolution proximity bonus (both axes within 25 %) + $cW = (int) ($candidate->width ?? 0); + $cH = (int) ($candidate->height ?? 0); + $resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0 + && abs($cW - $srcW) / $srcW <= 0.25 + && abs($cH - $srcH) / $srcH <= 0.25 + ) ? 0.05 : 0.0; + + // Popularity boost (log-normalised views, capped at 0.15) + $views = max(0, (int) ($candidate->stats?->views ?? 0)); + $popularity = min(0.15, log(1 + $views) / 13.0); + + // Freshness boost (exp decay, 60-day half-life, weight 0.10) + $publishedAt = $candidate->published_at ?? $candidate->created_at ?? now(); + $ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400); + $freshness = exp(-$ageDays / 60.0) * 0.10; + + $score = $tagOverlap * 0.60 + + $orientBonus + + $resBonus + + $popularity + + $freshness; + + return ['score' => $score, 'artwork' => $candidate]; + })->all(); + + usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']); + + return array_values( + array_map(fn (array $item): array => [ + 'id' => $item['artwork']->id, + 'title' => $item['artwork']->title, + 'slug' => $item['artwork']->slug, + 'thumb' => $item['artwork']->thumbUrl('md'), + 'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug, + 'author_id' => $item['artwork']->user_id, + 'orientation' => $this->orientation($item['artwork']), + 'width' => $item['artwork']->width, + 'height' => $item['artwork']->height, + 'score' => round((float) $item['score'], 5), + ], array_slice($scored, 0, self::LIMIT)) + ); } private function orientation(Artwork $artwork): string diff --git a/app/Http/Controllers/Api/SuggestedCreatorsController.php b/app/Http/Controllers/Api/SuggestedCreatorsController.php new file mode 100644 index 00000000..6dbc6ec7 --- /dev/null +++ b/app/Http/Controllers/Api/SuggestedCreatorsController.php @@ -0,0 +1,217 @@ +user(); + + $ttl = (int) config('recommendations.ttl.creator_suggestions', 30 * 60); + $cacheKey = "creator_suggestions:{$user->id}"; + + $data = Cache::remember($cacheKey, $ttl, function () use ($user) { + return $this->buildSuggestions($user); + }); + + return response()->json(['data' => $data]); + } + + private function buildSuggestions(\App\Models\User $user): array + { + try { + $profile = $this->prefBuilder->build($user); + $followingIds = $profile->strongCreatorIds; + $topTagSlugs = array_slice($profile->topTagSlugs, 0, 10); + + // ── 1. Mutual-follow candidates ─────────────────────────────────── + $mutualCandidates = []; + if ($followingIds !== []) { + $rows = DB::table('user_followers as uf') + ->join('users as u', 'u.id', '=', 'uf.user_id') + ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') + ->whereIn('uf.follower_id', $followingIds) + ->where('uf.user_id', '!=', $user->id) + ->whereNotIn('uf.user_id', array_merge($followingIds, [$user->id])) + ->where('u.is_active', true) + ->selectRaw(' + u.id, + u.name, + u.username, + up.avatar_hash, + COALESCE(us.followers_count, 0) as followers_count, + COALESCE(us.artworks_count, 0) as artworks_count, + COUNT(*) as mutual_weight + ') + ->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count') + ->orderByDesc('mutual_weight') + ->limit(20) + ->get(); + + foreach ($rows as $row) { + $mutualCandidates[(int) $row->id] = [ + 'id' => (int) $row->id, + 'name' => $row->name, + 'username' => $row->username, + 'avatar_hash' => $row->avatar_hash, + 'followers_count' => (int) $row->followers_count, + 'artworks_count' => (int) $row->artworks_count, + 'score' => (float) $row->mutual_weight * 3.0, + 'reason' => 'Popular among creators you follow', + ]; + } + } + + // ── 2. Tag-affinity candidates ──────────────────────────────────── + $tagCandidates = []; + if ($topTagSlugs !== []) { + $tagFilter = implode(',', array_fill(0, count($topTagSlugs), '?')); + + $rows = DB::table('tags as t') + ->join('artwork_tag as at', 'at.tag_id', '=', 't.id') + ->join('artworks as a', 'a.id', '=', 'at.artwork_id') + ->join('users as u', 'u.id', '=', 'a.user_id') + ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') + ->whereIn('t.slug', $topTagSlugs) + ->where('a.is_public', true) + ->where('a.is_approved', true) + ->whereNull('a.deleted_at') + ->where('u.id', '!=', $user->id) + ->whereNotIn('u.id', array_merge($followingIds, [$user->id])) + ->where('u.is_active', true) + ->selectRaw(' + u.id, + u.name, + u.username, + up.avatar_hash, + COALESCE(us.followers_count, 0) as followers_count, + COALESCE(us.artworks_count, 0) as artworks_count, + COUNT(DISTINCT t.id) as matched_tags + ') + ->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count') + ->orderByDesc('matched_tags') + ->limit(20) + ->get(); + + foreach ($rows as $row) { + if (isset($mutualCandidates[(int) $row->id])) { + // Boost mutual candidate that also matches tags + $mutualCandidates[(int) $row->id]['score'] += (float) $row->matched_tags; + continue; + } + + $tagCandidates[(int) $row->id] = [ + 'id' => (int) $row->id, + 'name' => $row->name, + 'username' => $row->username, + 'avatar_hash' => $row->avatar_hash, + 'followers_count' => (int) $row->followers_count, + 'artworks_count' => (int) $row->artworks_count, + 'score' => (float) $row->matched_tags * 2.0, + 'reason' => 'Matches your interests', + ]; + } + } + + // ── 3. Merge & rank ─────────────────────────────────────────────── + $combined = array_values(array_merge($mutualCandidates, $tagCandidates)); + usort($combined, fn ($a, $b) => $b['score'] <=> $a['score']); + $top = array_slice($combined, 0, self::LIMIT); + + if (count($top) < self::LIMIT) { + $topIds = array_column($top, 'id'); + $excluded = array_unique(array_merge($followingIds, [$user->id], $topIds)); + $top = array_merge($top, $this->highQualityFallback($excluded, self::LIMIT - count($top))); + } + + return array_map(fn (array $c): array => [ + 'id' => $c['id'], + 'name' => $c['name'], + 'username' => $c['username'], + 'url' => $c['username'] ? '/@' . $c['username'] : '/profile/' . $c['id'], + 'avatar' => AvatarUrl::forUser((int) $c['id'], $c['avatar_hash'] ?? null, 64), + 'followers_count' => (int) ($c['followers_count'] ?? 0), + 'artworks_count' => (int) ($c['artworks_count'] ?? 0), + 'reason' => $c['reason'] ?? null, + ], $top); + } catch (\Throwable $e) { + Log::warning('SuggestedCreatorsController: failed', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + + return []; + } + } + + /** + * @param array $excludedIds + * @return array> + */ + private function highQualityFallback(array $excludedIds, int $limit): array + { + if ($limit <= 0) { + return []; + } + + $rows = DB::table('users as u') + ->join('user_profiles as up', 'up.user_id', '=', 'u.id') + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') + ->whereNotIn('u.id', $excludedIds) + ->where('u.is_active', true) + ->selectRaw(' + u.id, + u.name, + u.username, + up.avatar_hash, + COALESCE(us.followers_count, 0) as followers_count, + COALESCE(us.artworks_count, 0) as artworks_count + ') + ->orderByDesc('followers_count') + ->limit($limit) + ->get(); + + return $rows->map(fn ($r) => [ + 'id' => (int) $r->id, + 'name' => $r->name, + 'username' => $r->username, + 'avatar_hash' => $r->avatar_hash, + 'followers_count' => (int) $r->followers_count, + 'artworks_count' => (int) $r->artworks_count, + 'score' => (float) $r->followers_count * 0.1, + 'reason' => 'Popular creator', + ])->all(); + } +} diff --git a/app/Http/Controllers/Api/SuggestedTagsController.php b/app/Http/Controllers/Api/SuggestedTagsController.php new file mode 100644 index 00000000..908f31b8 --- /dev/null +++ b/app/Http/Controllers/Api/SuggestedTagsController.php @@ -0,0 +1,152 @@ +user(); + + $ttl = (int) config('recommendations.ttl.tag_suggestions', 60 * 60); + $cacheKey = "tag_suggestions:{$user->id}"; + + $data = Cache::remember($cacheKey, $ttl, function () use ($user) { + return $this->buildSuggestions($user); + }); + + return response()->json(['data' => $data]); + } + + private function buildSuggestions(\App\Models\User $user): array + { + try { + $profile = $this->prefBuilder->build($user); + $knownTagSlugs = $profile->topTagSlugs; // already in user's profile – skip + + // ── Personalised tags (with normalised weights) ─────────────────── + $personalised = []; + foreach ($profile->tagWeights as $slug => $weight) { + if ($weight > 0.0) { + $personalised[$slug] = (float) $weight; + } + } + arsort($personalised); + + // ── Trending tags (global, last 7 days) ─────────────────────────── + $trending = $this->trendingTags(40); + + // ── Merge: personalised first, then trending discovery ───────────── + $merged = []; + foreach ($personalised as $slug => $weight) { + $merged[$slug] = [ + 'slug' => $slug, + 'score' => $weight * 2.0, // boost personal signal + 'source' => 'affinity', + ]; + } + + foreach ($trending as $row) { + $slug = (string) $row->slug; + if (isset($merged[$slug])) { + $merged[$slug]['score'] += (float) $row->trend_score; + } else { + $merged[$slug] = [ + 'slug' => $slug, + 'score' => (float) $row->trend_score, + 'source' => 'trending', + ]; + } + } + + uasort($merged, fn ($a, $b) => $b['score'] <=> $a['score']); + $top = array_slice(array_values($merged), 0, self::LIMIT); + + // ── Hydrate with DB info ────────────────────────────────────────── + $slugs = array_column($top, 'slug'); + $tagRows = DB::table('tags') + ->whereIn('slug', $slugs) + ->where('is_active', true) + ->get(['id', 'name', 'slug', 'usage_count']) + ->keyBy('slug'); + + $result = []; + foreach ($top as $item) { + $tag = $tagRows->get($item['slug']); + if ($tag === null) { + continue; + } + + $result[] = [ + 'id' => (int) $tag->id, + 'name' => (string) $tag->name, + 'slug' => (string) $tag->slug, + 'usage_count' => (int) $tag->usage_count, + 'source' => (string) $item['source'], + ]; + } + + return $result; + } catch (\Throwable $e) { + Log::warning('SuggestedTagsController: failed', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + + return []; + } + } + + /** + * Aggregate tag usage over the last 7 days as a proxy for trend score. + * Uses artwork_tag + artworks.published_at to avoid a heavy events table dependency. + * + * @return \Illuminate\Support\Collection + */ + private function trendingTags(int $limit): \Illuminate\Support\Collection + { + $since = now()->subDays(7); + + return DB::table('artwork_tag as at') + ->join('tags as t', 't.id', '=', 'at.tag_id') + ->join('artworks as a', 'a.id', '=', 'at.artwork_id') + ->where('a.published_at', '>=', $since) + ->where('a.is_public', true) + ->where('a.is_approved', true) + ->whereNull('a.deleted_at') + ->where('t.is_active', true) + ->selectRaw('t.slug, COUNT(*) / 1.0 as trend_score') + ->groupBy('t.id', 't.slug') + ->orderByDesc('trend_score') + ->limit($limit) + ->get(); + } +} diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php index 58b6aa4c..38a963a2 100644 --- a/app/Http/Controllers/Web/DiscoverController.php +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Services\ArtworkSearchService; use App\Services\ArtworkService; +use App\Services\Recommendation\RecommendationService; use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -21,12 +22,14 @@ use Illuminate\Support\Facades\Schema; * - /discover/top-rated → highest favourite count * - /discover/most-downloaded → most downloaded all-time * - /discover/on-this-day → published on this calendar day in previous years + * - /discover/for-you → personalised feed (auth required) */ final class DiscoverController extends Controller { public function __construct( - private readonly ArtworkService $artworkService, + private readonly ArtworkService $artworkService, private readonly ArtworkSearchService $searchService, + private readonly RecommendationService $recoService, ) {} // ─── /discover/trending ────────────────────────────────────────────────── @@ -178,6 +181,56 @@ final class DiscoverController extends Controller ]); } + // ─── /discover/for-you ─────────────────────────────────────────────────── + + /** + * Personalised "For You" feed page. + * + * Uses RecommendationService (Phase 1 tag-affinity + creator-affinity pipeline) + * and renders the standard discover grid view. Guest users are redirected + * to the trending page per spec. + */ + public function forYou(Request $request) + { + $user = $request->user(); + $limit = 40; + $cursor = $request->query('cursor') ?: null; + + // Retrieve the paginated feed (service handles Meilisearch + reranking + cache) + $feedResult = $this->recoService->forYouFeed( + user: $user, + limit: $limit, + cursor: is_string($cursor) ? $cursor : null, + ); + + $artworkItems = $feedResult['data'] ?? []; + + // Build a simple presentable collection + $artworks = collect($artworkItems)->map(fn (array $item) => (object) [ + 'id' => $item['id'] ?? 0, + 'name' => $item['title'] ?? 'Untitled', + 'category_name' => '', + 'thumb_url' => $item['thumbnail_url'] ?? null, + 'thumb_srcset' => $item['thumbnail_url'] ?? null, + 'uname' => $item['author'] ?? 'Artist', + 'published_at' => null, + 'slug' => $item['slug'] ?? '', + ]); + + $meta = $feedResult['meta'] ?? []; + $nextCursor = $meta['next_cursor'] ?? null; + + return view('web.discover.for-you', [ + 'artworks' => $artworks, + 'page_title' => 'For You', + 'section' => 'for-you', + 'description' => 'Artworks picked for you based on your taste.', + 'icon' => 'fa-wand-magic-sparkles', + 'next_cursor' => $nextCursor, + 'cache_status' => $meta['cache_status'] ?? null, + ]); + } + // ─── /discover/following ───────────────────────────────────────────────── public function following(Request $request) @@ -264,11 +317,14 @@ final class DiscoverController extends Controller 'id' => $artwork->id, 'name' => $artwork->title, 'category_name' => $primaryCategory->name ?? '', + 'category_slug' => $primaryCategory->slug ?? '', 'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0, 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], 'uname' => $artwork->user->name ?? 'Skinbase', 'published_at' => $artwork->published_at, + 'width' => $artwork->width ?? null, + 'height' => $artwork->height ?? null, ]; } } diff --git a/app/Models/UserRecoProfile.php b/app/Models/UserRecoProfile.php new file mode 100644 index 00000000..9ad8c45f --- /dev/null +++ b/app/Models/UserRecoProfile.php @@ -0,0 +1,73 @@ + 'array', + 'top_categories_json' => 'array', + 'followed_creator_ids_json' => 'array', + 'tag_weights_json' => 'array', + 'category_weights_json' => 'array', + 'disliked_tag_ids_json' => 'array', + ]; + + // ── Relations ───────────────────────────────────────────────────────────── + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Hydrate a DTO from this model's JSON columns. + */ + public function toDTO(): UserRecoProfileDTO + { + return new UserRecoProfileDTO( + topTagSlugs: (array) ($this->top_tags_json ?? []), + topCategorySlugs: (array) ($this->top_categories_json ?? []), + strongCreatorIds: array_map('intval', (array) ($this->followed_creator_ids_json ?? [])), + tagWeights: array_map('floatval', (array) ($this->tag_weights_json ?? [])), + categoryWeights: array_map('floatval', (array) ($this->category_weights_json ?? [])), + dislikedTagSlugs: (array) ($this->disliked_tag_ids_json ?? []), + ); + } + + /** + * True when the stored profile is still within the configured TTL. + */ + public function isFresh(): bool + { + if ($this->updated_at === null) { + return false; + } + + $ttl = (int) config('recommendations.ttl.user_reco_profile', 6 * 3600); + + return $this->updated_at->addSeconds($ttl)->isFuture(); + } +} diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index 98c5a482..41e68a7c 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -7,6 +7,7 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Tag; use App\Services\ArtworkSearchService; +use App\Services\Recommendation\RecommendationService; use App\Services\UserPreferenceService; use App\Support\AvatarUrl; use Illuminate\Support\Facades\Cache; @@ -26,9 +27,10 @@ final class HomepageService private const CACHE_TTL = 300; // 5 minutes public function __construct( - private readonly ArtworkService $artworks, - private readonly ArtworkSearchService $search, - private readonly UserPreferenceService $prefs, + private readonly ArtworkService $artworks, + private readonly ArtworkSearchService $search, + private readonly UserPreferenceService $prefs, + private readonly RecommendationService $reco, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -70,6 +72,7 @@ final class HomepageService 'is_logged_in' => true, 'user_data' => $this->getUserData($user), 'hero' => $this->getHeroArtwork(), + 'for_you' => $this->getForYouPreview($user), 'from_following' => $this->getFollowingFeed($user, $prefs), 'trending' => $this->getTrending(), 'fresh' => $this->getFreshUploads(), @@ -86,6 +89,22 @@ final class HomepageService ]; } + /** + * "For You" homepage preview: first 12 results from the Phase 1 personalised feed. + * + * Uses RecommendationService which handles Meilisearch retrieval, PHP reranking, + * diversity controls, and its own Redis cache layer. + */ + public function getForYouPreview(\App\Models\User $user, int $limit = 12): array + { + try { + return $this->reco->forYouPreview($user, $limit); + } catch (\Throwable $e) { + Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]); + return []; + } + } + // ───────────────────────────────────────────────────────────────────────── // Sections // ───────────────────────────────────────────────────────────────────────── diff --git a/app/Services/Recommendation/RecommendationService.php b/app/Services/Recommendation/RecommendationService.php new file mode 100644 index 00000000..86a22008 --- /dev/null +++ b/app/Services/Recommendation/RecommendationService.php @@ -0,0 +1,420 @@ +>, + * 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), '+/', '-_'), '='); + } +} diff --git a/app/Services/Recommendation/UserPreferenceBuilder.php b/app/Services/Recommendation/UserPreferenceBuilder.php new file mode 100644 index 00000000..6554f68c --- /dev/null +++ b/app/Services/Recommendation/UserPreferenceBuilder.php @@ -0,0 +1,307 @@ +build($user); // cached + * $dto = $builder->buildFresh($user); // force rebuild + */ +class UserPreferenceBuilder +{ + // Redis / file cache key (short-lived insurance layer on top of DB row) + private const REDIS_TTL = 300; // 5 minutes — warm cache after DB write + + public function __construct() {} + + // ─── Public API ─────────────────────────────────────────────────────────── + + /** + * Return a cached profile DTO, rebuilding from DB if stale or absent. + */ + public function build(User $user): UserRecoProfileDTO + { + $cacheKey = $this->cacheKey($user->id); + + // 1. Redis warm layer + /** @var array|null $cached */ + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return UserRecoProfileDTO::fromArray($cached); + } + + // 2. Persistent DB row + $row = UserRecoProfile::find($user->id); + if ($row !== null && $row->isFresh()) { + $dto = $row->toDTO(); + Cache::put($cacheKey, $dto->toArray(), self::REDIS_TTL); + return $dto; + } + + // 3. Rebuild + return $this->buildFresh($user); + } + + /** + * Force a full rebuild from source tables, persist and cache the result. + */ + public function buildFresh(User $user): UserRecoProfileDTO + { + try { + $dto = $this->compute($user); + $this->persist($user->id, $dto); + Cache::put($this->cacheKey($user->id), $dto->toArray(), self::REDIS_TTL); + return $dto; + } catch (\Throwable $e) { + Log::warning('UserPreferenceBuilder: failed to build profile', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + return new UserRecoProfileDTO(); + } + } + + /** + * Invalidate only the Redis warm layer (DB row stays intact until TTL). + */ + public function invalidate(int $userId): void + { + Cache::forget($this->cacheKey($userId)); + } + + // ─── Computation ────────────────────────────────────────────────────────── + + private function compute(User $user): UserRecoProfileDTO + { + $sigWeights = (array) config('recommendations.signal_weights', []); + $wAward = (float) ($sigWeights['award'] ?? 5.0); + $wFav = (float) ($sigWeights['favorite'] ?? 3.0); + $wFollow = (float) ($sigWeights['follow'] ?? 2.0); + + $tagLimit = (int) config('recommendations.profile.top_tags_limit', 20); + $catLimit = (int) config('recommendations.profile.top_categories_limit', 5); + $creatorLimit = (int) config('recommendations.profile.strong_creators_limit', 50); + + // ── 1. Tag scores from favourited artworks ──────────────────────────── + $tagRaw = $this->tagScoresFromFavourites($user->id, $wFav); + + // ── 2. Tag scores from awards given ────────────────────────────────── + foreach ($this->tagScoresFromAwards($user->id, $wAward) as $slug => $score) { + $tagRaw[$slug] = ($tagRaw[$slug] ?? 0.0) + $score; + } + + // ── 3. Creator IDs from follows (top N) ─────────────────────────────── + $followedIds = $this->followedCreatorIds($user->id, $creatorLimit); + + // ── 4. Tag scores lifted from followed creators' recent works ───────── + if ($followedIds !== []) { + foreach ($this->tagScoresFromFollowedCreators($followedIds, $wFollow) as $slug => $score) { + $tagRaw[$slug] = ($tagRaw[$slug] ?? 0.0) + $score; + } + } + + // ── 5. Category scores from favourited artworks ─────────────────────── + $catRaw = $this->categoryScoresFromFavourites($user->id, $wFav); + + // Sort descending and take top N + arsort($tagRaw); + arsort($catRaw); + + $topTagSlugs = array_keys(array_slice($tagRaw, 0, $tagLimit)); + $topCatSlugs = array_keys(array_slice($catRaw, 0, $catLimit)); + + $tagWeights = $this->normalise($tagRaw); + $catWeights = $this->normalise($catRaw); + + return new UserRecoProfileDTO( + topTagSlugs: $topTagSlugs, + topCategorySlugs: $topCatSlugs, + strongCreatorIds: $followedIds, + tagWeights: $tagWeights, + categoryWeights: $catWeights, + ); + } + + // ─── Signal collectors ──────────────────────────────────────────────────── + + /** + * @return array slug → raw score + */ + private function tagScoresFromFavourites(int $userId, float $weight): array + { + $rows = DB::table('artwork_favourites as af') + ->join('artwork_tag as at', 'at.artwork_id', '=', 'af.artwork_id') + ->join('tags as t', 't.id', '=', 'at.tag_id') + ->where('af.user_id', $userId) + ->where('t.is_active', true) + ->selectRaw('t.slug, COUNT(*) as cnt') + ->groupBy('t.id', 't.slug') + ->get(); + + $scores = []; + foreach ($rows as $row) { + $scores[(string) $row->slug] = (float) $row->cnt * $weight; + } + + return $scores; + } + + /** + * @return array + */ + private function tagScoresFromAwards(int $userId, float $weight): array + { + $rows = DB::table('artwork_awards as aa') + ->join('artwork_tag as at', 'at.artwork_id', '=', 'aa.artwork_id') + ->join('tags as t', 't.id', '=', 'at.tag_id') + ->where('aa.user_id', $userId) + ->where('t.is_active', true) + ->selectRaw('t.slug, SUM(aa.weight) as total_weight') + ->groupBy('t.id', 't.slug') + ->get(); + + $scores = []; + foreach ($rows as $row) { + $scores[(string) $row->slug] = (float) $row->total_weight * $weight; + } + + return $scores; + } + + /** + * @param array $creatorIds + * @return array + */ + private function tagScoresFromFollowedCreators(array $creatorIds, float $weight): array + { + if ($creatorIds === []) { + return []; + } + + // Sample recent artworks to avoid full scan + $rows = DB::table('artworks as a') + ->join('artwork_tag as at', 'at.artwork_id', '=', 'a.id') + ->join('tags as t', 't.id', '=', 'at.tag_id') + ->whereIn('a.user_id', $creatorIds) + ->where('a.is_public', true) + ->where('a.is_approved', true) + ->where('t.is_active', true) + ->whereNull('a.deleted_at') + ->orderByDesc('a.published_at') + ->limit(500) + ->selectRaw('t.slug, COUNT(*) as cnt') + ->groupBy('t.id', 't.slug') + ->get(); + + $scores = []; + foreach ($rows as $row) { + $scores[(string) $row->slug] = (float) $row->cnt * $weight; + } + + return $scores; + } + + /** + * @return array + */ + private function categoryScoresFromFavourites(int $userId, float $weight): array + { + $rows = DB::table('artwork_favourites as af') + ->join('artwork_category as ac', 'ac.artwork_id', '=', 'af.artwork_id') + ->join('categories as c', 'c.id', '=', 'ac.category_id') + ->where('af.user_id', $userId) + ->whereNull('c.deleted_at') + ->selectRaw('c.slug, COUNT(*) as cnt') + ->groupBy('c.id', 'c.slug') + ->get(); + + $scores = []; + foreach ($rows as $row) { + $scores[(string) $row->slug] = (float) $row->cnt * $weight; + } + + return $scores; + } + + /** + * @return array + */ + private function followedCreatorIds(int $userId, int $limit): array + { + return DB::table('user_followers') + ->where('follower_id', $userId) + ->orderByDesc('created_at') + ->limit($limit) + ->pluck('user_id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + /** + * Normalise a raw score map to sum 1.0. + * + * @param array $raw + * @return array + */ + private function normalise(array $raw): array + { + $sum = array_sum($raw); + if ($sum <= 0.0) { + return $raw; + } + + return array_map(fn (float $v): float => round($v / $sum, 6), $raw); + } + + private function cacheKey(int $userId): string + { + return "user_reco_profile:{$userId}"; + } + + // ─── Persistence ────────────────────────────────────────────────────────── + + private function persist(int $userId, UserRecoProfileDTO $dto): void + { + $data = $dto->toArray(); + + UserRecoProfile::query()->updateOrCreate( + ['user_id' => $userId], + [ + 'top_tags_json' => $data['top_tags'], + 'top_categories_json' => $data['top_categories'], + 'followed_creator_ids_json' => $data['strong_creators'], + 'tag_weights_json' => $data['tag_weights'], + 'category_weights_json' => $data['category_weights'], + 'disliked_tag_ids_json' => $data['disliked_tags'], + ] + ); + } +} diff --git a/config/recommendations.php b/config/recommendations.php index 0480d084..430386ee 100644 --- a/config/recommendations.php +++ b/config/recommendations.php @@ -6,6 +6,61 @@ return [ // Uses same queue family as vision jobs by default; keeps embedding work async and non-blocking. 'queue' => env('RECOMMENDATIONS_QUEUE', env('VISION_QUEUE', 'default')), + // ─── Phase 1 "For You" feed scoring weights ─────────────────────────────── + // Influences the PHP reranking pass after Meilisearch candidate retrieval. + // Tweak here without code changes. + 'weights' => [ + // Tag overlap score weight (0–1 normalized overlap fraction) + 'tag_overlap' => (float) env('RECO_W_TAG_OVERLAP', 0.40), + // Creator affinity score weight (1.0 if followed, 0 otherwise) + 'creator_affinity' => (float) env('RECO_W_CREATOR_AFFINITY', 0.25), + // Popularity boost (log-normalised views/downloads) + 'popularity' => (float) env('RECO_W_POPULARITY', 0.20), + // Freshness boost (exponential decay over 30 days) + 'freshness' => (float) env('RECO_W_FRESHNESS', 0.15), + ], + + // ─── User preference signal weights ────────────────────────────────────── + // How much each user action contributes to building the reco profile. + 'signal_weights' => [ + 'award' => (float) env('RECO_SIG_AWARD', 5.0), + 'favorite' => (float) env('RECO_SIG_FAVORITE', 3.0), + 'reaction' => (float) env('RECO_SIG_REACTION', 2.0), + 'view' => (float) env('RECO_SIG_VIEW', 1.0), + 'follow' => (float) env('RECO_SIG_FOLLOW', 2.0), + ], + + // ─── Candidate generation ────────────────────────────────────────────────── + // How many Meilisearch candidates to fetch before PHP reranking. + 'candidate_pool_size' => (int) env('RECO_CANDIDATE_POOL', 200), + + // ─── Diversity controls ──────────────────────────────────────────────────── + // Maximum artworks per creator allowed in a single page of results. + 'max_per_creator' => (int) env('RECO_MAX_PER_CREATOR', 3), + // Minimum distinct tag count in first 20 feed results. + 'min_unique_tags' => (int) env('RECO_MIN_UNIQUE_TAGS', 5), + + // ─── TTLs (seconds) ──────────────────────────────────────────────────────── + 'ttl' => [ + // User reco profile cache (tag/creator affinity data) + 'user_reco_profile' => (int) env('RECO_TTL_PROFILE', 6 * 3600), + // For You paginated results cache + 'for_you_feed' => (int) env('RECO_TTL_FOR_YOU', 5 * 60), + // Similar artworks per artwork + 'similar_artworks' => (int) env('RECO_TTL_SIMILAR', 30 * 60), + // Suggested creators per user + 'creator_suggestions' => (int) env('RECO_TTL_CREATORS', 30 * 60), + // Suggested tags per user + 'tag_suggestions' => (int) env('RECO_TTL_TAGS', 60 * 60), + ], + + // ─── Profile limits ──────────────────────────────────────────────────────── + 'profile' => [ + 'top_tags_limit' => (int) env('RECO_PROFILE_TAGS', 20), + 'top_categories_limit' => (int) env('RECO_PROFILE_CATS', 5), + 'strong_creators_limit' => (int) env('RECO_PROFILE_CREATORS', 50), + ], + 'embedding' => [ 'enabled' => env('RECOMMENDATIONS_EMBEDDING_ENABLED', true), 'model' => env('RECOMMENDATIONS_EMBEDDING_MODEL', 'clip'), diff --git a/database/migrations/2026_02_27_103655_create_user_reco_profiles_table.php b/database/migrations/2026_02_27_103655_create_user_reco_profiles_table.php new file mode 100644 index 00000000..e1f5db19 --- /dev/null +++ b/database/migrations/2026_02_27_103655_create_user_reco_profiles_table.php @@ -0,0 +1,45 @@ +unsignedBigInteger('user_id')->primary(); + $table->json('top_tags_json')->nullable()->comment('Top tag slugs ordered by weighted score (up to 20)'); + $table->json('top_categories_json')->nullable()->comment('Top category slugs ordered by weight (up to 5)'); + $table->json('followed_creator_ids_json')->nullable()->comment('Followed creator IDs (up to 50)'); + $table->json('tag_weights_json')->nullable()->comment('Normalised tag slug → weight map'); + $table->json('category_weights_json')->nullable()->comment('Normalised category slug → weight map'); + $table->json('disliked_tag_ids_json')->nullable()->comment('Hidden/blocked tag slugs (future use)'); + $table->timestamps(); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->cascadeOnDelete(); + + $table->index('updated_at', 'urp_updated_at_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_reco_profiles'); + } +}; diff --git a/public/favicon.ico b/public/favicon.ico index e69de29b..5dc1b133 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.zip b/public/favicon.zip new file mode 100644 index 00000000..d1b610d5 Binary files /dev/null and b/public/favicon.zip differ diff --git a/public/favicon/apple-touch-icon.png b/public/favicon/apple-touch-icon.png new file mode 100644 index 00000000..2dde0689 Binary files /dev/null and b/public/favicon/apple-touch-icon.png differ diff --git a/public/favicon/favicon-96x96.png b/public/favicon/favicon-96x96.png new file mode 100644 index 00000000..c52c54cb Binary files /dev/null and b/public/favicon/favicon-96x96.png differ diff --git a/public/favicon/favicon.ico b/public/favicon/favicon.ico new file mode 100644 index 00000000..5dc1b133 Binary files /dev/null and b/public/favicon/favicon.ico differ diff --git a/public/favicon/favicon.svg b/public/favicon/favicon.svg new file mode 100644 index 00000000..389e685b --- /dev/null +++ b/public/favicon/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/public/favicon/site.webmanifest b/public/favicon/site.webmanifest new file mode 100644 index 00000000..aae6a6a4 --- /dev/null +++ b/public/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "Skinbase", + "short_name": "Skinbase", + "icons": [ + { + "src": "/favicon/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/favicon/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#0F1724", + "background_color": "#0F1724", + "display": "standalone" +} \ No newline at end of file diff --git a/public/favicon/web-app-manifest-192x192.png b/public/favicon/web-app-manifest-192x192.png new file mode 100644 index 00000000..6bee40c2 Binary files /dev/null and b/public/favicon/web-app-manifest-192x192.png differ diff --git a/public/favicon/web-app-manifest-512x512.png b/public/favicon/web-app-manifest-512x512.png new file mode 100644 index 00000000..c986be1a Binary files /dev/null and b/public/favicon/web-app-manifest-512x512.png differ diff --git a/resources/js/components/gallery/ArtworkCard.jsx b/resources/js/components/gallery/ArtworkCard.jsx new file mode 100644 index 00000000..57438cea --- /dev/null +++ b/resources/js/components/gallery/ArtworkCard.jsx @@ -0,0 +1,163 @@ +import React, { useEffect, useRef } from 'react'; + +function buildAvatarUrl(userId, avatarHash, size = 40) { + if (!userId) return '/images/avatar-placeholder.jpg'; + if (!avatarHash) return `/avatar/default/${userId}?s=${size}`; + return `/avatar/${userId}/${avatarHash}?s=${size}`; +} + +function slugify(str) { + return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +} + +/** + * React version of resources/views/components/artwork-card.blade.php + * Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies. + */ +export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = null }) { + const imgRef = useRef(null); + + // Activate blur-preview class once image has decoded (mirrors nova.js behaviour) + useEffect(() => { + const img = imgRef.current; + if (!img) return; + const markLoaded = () => img.classList.add('is-loaded'); + if (img.complete && img.naturalWidth > 0) { markLoaded(); return; } + img.addEventListener('load', markLoaded, { once: true }); + img.addEventListener('error', markLoaded, { once: true }); + }, []); + + const title = (art.name || art.title || 'Untitled artwork').trim(); + const author = (art.uname || art.author_name || art.author || 'Skinbase').trim(); + const username = (art.username || art.uname || '').trim(); + const category = (art.category_name || art.category || '').trim(); + + const likes = art.likes ?? art.favourites ?? 0; + const comments = art.comments_count ?? art.comment_count ?? 0; + + const imgSrc = art.thumb || art.thumb_url || art.thumbnail_url || '/images/placeholder.jpg'; + const imgSrcset = art.thumb_srcset || imgSrc; + + const cardUrl = art.url || (art.id ? `/art/${art.id}/${slugify(title)}` : '#'); + const authorUrl = username ? `/@${username.toLowerCase()}` : null; + const avatarSrc = buildAvatarUrl(art.user_id, art.avatar_hash, 40); + + const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0; + const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null; + + // Span 2 columns for panoramic images (AR > 2.0) in Photography or Wallpapers categories. + // These slugs match the root categories; name-matching is kept as fallback. + const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper']; + const wideCategoryNames = ['photography', 'wallpapers']; + const catSlug = (art.category_slug || '').toLowerCase(); + const catName = (art.category_name || '').toLowerCase(); + const isWideEligible = + aspectRatio !== null && + aspectRatio > 2.0 && + (wideCategories.includes(catSlug) || wideCategoryNames.includes(catName)); + + const articleStyle = isWideEligible ? { gridColumn: 'span 2' } : {}; + const aspectStyle = hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : {}; + // Image always fills the container absolutely – the container's height is + // driven by aspect-ratio (capped by CSS max-height). Using absolute + // positioning means width/height are always 100% of the capped box, so + // object-cover crops top/bottom instead of leaving dark gaps. + const imgClass = [ + 'absolute inset-0 h-full w-full object-cover', + 'transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]', + loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '', + ].join(' '); + + const metaParts = []; + if (art.resolution) metaParts.push(art.resolution); + else if (hasDimensions) metaParts.push(`${art.width}×${art.height}`); + if (category) metaParts.push(category); + if (art.license) metaParts.push(art.license); + + return ( +
+ + {category && ( +
+ {category} +
+ )} + + {/* nova-card-media: height driven by aspect-ratio, capped by MasonryGallery.css max-height. + w-full prevents browsers shrinking the width when max-height overrides aspect-ratio. */} +
+ ); +} diff --git a/resources/js/components/gallery/MasonryGallery.css b/resources/js/components/gallery/MasonryGallery.css new file mode 100644 index 00000000..8001f886 --- /dev/null +++ b/resources/js/components/gallery/MasonryGallery.css @@ -0,0 +1,138 @@ +/* + * MasonryGallery – scoped CSS + * + * Grid column definitions (activated when React adds .is-enhanced to the root). + * Mirrors the blade @push('styles') blocks so the same rules apply whether the + * page is rendered server-side or by the React component. + */ + +/* ── Masonry grid ─────────────────────────────────────────────────────────── */ +[data-nova-gallery].is-enhanced [data-gallery-grid] { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + grid-auto-rows: 8px; + gap: 1rem; +} + +@media (min-width: 768px) { + [data-nova-gallery].is-enhanced [data-gallery-grid] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + [data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); } + [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); } + [data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; } +} + +@media (min-width: 1600px) { + [data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); } + [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); } + [data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; } +} + +@media (min-width: 2600px) { + [data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); } + [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); } + [data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; } +} + +[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; } + +/* ── Fallback aspect-ratio for cards without stored dimensions ───────────── */ +/* + * When ArtworkCard has no width/height data it renders the img as h-auto, + * meaning the container height is 0 until the image loads. Setting a + * default aspect-ratio here reserves approximate space immediately and + * prevents applyMasonry from calculating span=1 → then jumping on load. + * Cards with an inline aspect-ratio style (from real dimensions) override this. + */ +[data-nova-gallery] [data-gallery-grid] .nova-card-media { + aspect-ratio: 3 / 2; + width: 100%; /* prevent aspect-ratio + max-height from shrinking the column width */ +} + +/* Override: when an inline aspect-ratio is set by ArtworkCard those values */ +/* take precedence naturally (inline style > class). No extra selector needed. */ + +/* ── Card max-height cap ──────────────────────────────────────────────────── */ +/* + * Limits any single card to the height of 2 stacked 16:9 images in its column. + * Formula: 2 × (col_width × 9/16) = col_width × 9/8 + * + * 5-col (lg+): col_width = (100vw - 80px_padding - 4×24px_gaps) / 5 + * = (100vw - 176px) / 5 + * max-height = (100vw - 176px) / 5 × 9/8 + * = (100vw - 176px) × 0.225 + * + * 2-col (md): col_width = (100vw - 80px - 1×24px) / 2 + * = (100vw - 104px) / 2 + * max-height = (100vw - 104px) / 2 × 9/8 + * = (100vw - 104px) × 0.5625 + * + * 1-col mobile: uncapped – portrait images are fine filling the full width. + */ + +/* Global selector covers both the React-rendered gallery and the blade fallback */ +[data-nova-gallery] [data-gallery-grid] .nova-card-media { + overflow: hidden; /* ensure img is clipped at max-height */ +} + +@media (min-width: 1024px) { + [data-nova-gallery] [data-gallery-grid] .nova-card-media { + /* 5-column layout: 2 × (col_width × 9/16) = col_width × 9/8 */ + max-height: calc((100vw - 176px) * 9 / 40); + } + /* Wide (2-col spanning) cards get double the column width */ + [data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media { + max-height: calc((100vw - 176px) * 9 / 20); + } +} + +@media (min-width: 768px) and (max-width: 1023px) { + [data-nova-gallery] [data-gallery-grid] .nova-card-media { + /* 2-column layout */ + max-height: calc((100vw - 104px) * 9 / 16); + } + [data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media { + /* 2-col span fills full width on md breakpoint */ + max-height: calc((100vw - 104px) * 9 / 8); + } +} + +/* Image is positioned absolutely inside the container so it always fills + the capped box (max-height), cropping top/bottom via object-fit: cover. */ +[data-nova-gallery] [data-gallery-grid] .nova-card-media img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ── Skeleton ─────────────────────────────────────────────────────────────── */ +.nova-skeleton-card { + border-radius: 1rem; + min-height: 180px; + background: linear-gradient( + 110deg, + rgba(255, 255, 255, 0.06) 8%, + rgba(255, 255, 255, 0.12) 18%, + rgba(255, 255, 255, 0.06) 33% + ); + background-size: 200% 100%; + animation: novaShimmer 1.2s linear infinite; +} + +@keyframes novaShimmer { + to { background-position-x: -200%; } +} + +/* ── Card enter animation (appended by infinite scroll) ───────────────────── */ +.nova-card-enter { opacity: 0; transform: translateY(8px); } +.nova-card-enter-active { + opacity: 1; + transform: translateY(0); + transition: opacity 200ms ease-out, transform 200ms ease-out; +} diff --git a/resources/js/components/gallery/MasonryGallery.jsx b/resources/js/components/gallery/MasonryGallery.jsx new file mode 100644 index 00000000..1b81d790 --- /dev/null +++ b/resources/js/components/gallery/MasonryGallery.jsx @@ -0,0 +1,277 @@ +import React, { + useState, useEffect, useRef, useCallback, memo, +} from 'react'; +import ArtworkCard from './ArtworkCard'; +import './MasonryGallery.css'; + +// ── Masonry helpers ──────────────────────────────────────────────────────── +const ROW_SIZE = 8; +const ROW_GAP = 16; + +function applyMasonry(grid) { + if (!grid) return; + Array.from(grid.querySelectorAll('.nova-card')).forEach((card) => { + const media = card.querySelector('.nova-card-media') || card; + let height = media.getBoundingClientRect().height || 200; + + // Clamp to the computed max-height so the span never over-reserves rows + // when CSS max-height kicks in (e.g. portrait images capped to 2×16:9). + const cssMaxH = parseFloat(getComputedStyle(media).maxHeight); + if (!isNaN(cssMaxH) && cssMaxH > 0 && cssMaxH < height) { + height = cssMaxH; + } + + const span = Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_SIZE + ROW_GAP))); + card.style.gridRowEnd = `span ${span}`; + }); +} + +function waitForImages(el) { + return Promise.all( + Array.from(el.querySelectorAll('img')).map((img) => + img.decode ? img.decode().catch(() => null) : Promise.resolve(), + ), + ); +} + +// ── Page fetch helpers ───────────────────────────────────────────────────── +/** + * Fetch the next page of data. + * + * The response is either: + * - JSON { artworks: [...], next_cursor: '...' } when X-Requested-With is + * sent and the controller returns JSON (future enhancement) + * - HTML page – we parse [data-react-masonry-gallery] from it and read its + * data-artworks / data-next-cursor / data-next-page-url attributes. + */ +async function fetchPageData(url) { + const res = await fetch(url, { + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const ct = res.headers.get('content-type') || ''; + + // JSON fast-path (if controller ever returns JSON) + if (ct.includes('application/json')) { + const json = await res.json(); + return { + artworks: json.artworks ?? [], + nextCursor: json.next_cursor ?? null, + nextPageUrl: json.next_page_url ?? null, + }; + } + + // HTML: parse and extract mount-container data attributes + const html = await res.text(); + const doc = new DOMParser().parseFromString(html, 'text/html'); + const el = doc.querySelector('[data-react-masonry-gallery]'); + if (!el) return { artworks: [], nextCursor: null, nextPageUrl: null }; + + let artworks = []; + try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ } + + return { + artworks, + nextCursor: el.dataset.nextCursor || null, + nextPageUrl: el.dataset.nextPageUrl || null, + }; +} + +// ── Skeleton row ────────────────────────────────────────────────────────── +function SkeletonCard() { + return -{{-- ── Artwork grid ── --}} -
- @if ($artworks && $artworks->isNotEmpty()) -
- @foreach ($artworks as $art) - @php - $card = (object)[ - 'id' => $art->id, - 'name' => $art->name, - 'thumb' => $art->thumb_url ?? $art->thumb ?? null, - 'thumb_srcset' => $art->thumb_srcset ?? null, - 'uname' => $art->uname ?? '', - 'category_name' => $art->category_name ?? '', - ]; - @endphp - - @endforeach -
- - {{-- Pagination --}} -
- {{ $artworks->withQueryString()->links() }} -
- @else -
-

No artworks found for this section yet.

-
- @endif -
+{{-- ── Artwork grid (React MasonryGallery) ── --}} +@php + $galleryArtworks = collect($artworks->items())->map(fn ($art) => [ + 'id' => $art->id, + 'name' => $art->name ?? null, + 'thumb' => $art->thumb_url ?? $art->thumb ?? null, + 'thumb_srcset' => $art->thumb_srcset ?? null, + 'uname' => $art->uname ?? '', + 'username' => $art->uname ?? '', + 'category_name' => $art->category_name ?? '', + 'category_slug' => $art->category_slug ?? '', + 'slug' => $art->slug ?? '', + 'width' => $art->width ?? null, + 'height' => $art->height ?? null, + ])->values(); + $galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null; +@endphp +
@endsection + +@push('styles') + +@endpush + +@push('scripts') +@vite('resources/js/entry-masonry-gallery.jsx') +@endpush diff --git a/routes/api.php b/routes/api.php index 0f913537..b3b706f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -292,6 +292,20 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])->group ->name('api.comments.reactions.toggle'); }); +// ── Personalised suggestions (auth required) ──────────────────────────────── +// GET /api/user/suggestions/creators → up to 12 suggested creators to follow +// GET /api/user/suggestions/tags → up to 20 suggested tags (foundation) +Route::middleware(['web', 'auth', 'normalize.username', 'throttle:30,1']) + ->prefix('user/suggestions') + ->name('api.user.suggestions.') + ->group(function () { + Route::get('creators', \App\Http\Controllers\Api\SuggestedCreatorsController::class) + ->name('creators'); + + Route::get('tags', \App\Http\Controllers\Api\SuggestedTagsController::class) + ->name('tags'); + }); + // ── Follow system ───────────────────────────────────────────────────────────── // POST /api/user/{username}/follow → follow a user // DELETE /api/user/{username}/follow → unfollow a user diff --git a/routes/web.php b/routes/web.php index 02473321..562b84d7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -45,6 +45,9 @@ Route::prefix('discover')->name('discover.')->group(function () { // Artworks from people you follow (auth required) Route::middleware('auth')->get('/following', [DiscoverController::class, 'following'])->name('following'); + + // Personalised "For You" feed (auth required; guests → redirect) + Route::middleware('auth')->get('/for-you', [DiscoverController::class, 'forYou'])->name('for-you'); }); // ── CREATORS routes (/creators/*) ───────────────────────────────────────────── diff --git a/tests/Feature/Recommendations/HomepageForYouIntegrationTest.php b/tests/Feature/Recommendations/HomepageForYouIntegrationTest.php new file mode 100644 index 00000000..d23ca6ba --- /dev/null +++ b/tests/Feature/Recommendations/HomepageForYouIntegrationTest.php @@ -0,0 +1,72 @@ + 'null']); +}); + +it('allForUser includes a for_you key', function () { + $user = User::factory()->create(); + + Cache::flush(); + + $service = app(HomepageService::class); + $result = $service->allForUser($user); + + expect($result)->toHaveKey('for_you') + ->and($result['for_you'])->toBeArray(); +}); + +it('allForUser for_you is an array even with no cached recommendations', function () { + $user = User::factory()->create(); + $service = app(HomepageService::class); + + Cache::flush(); + + $result = $service->allForUser($user); + + expect($result['for_you'])->toBeArray(); +}); + +it('allForUser for_you returns items when cache exists', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subHour(), + ]); + + UserRecommendationCache::query()->create([ + 'user_id' => $user->id, + 'algo_version' => (string) config('discovery.algo_version', 'clip-cosine-v1'), + 'cache_version' => (string) config('discovery.cache_version', 'cache-v1'), + 'recommendations_json' => [ + 'items' => [ + ['artwork_id' => $artwork->id, 'score' => 0.9, 'source' => 'profile'], + ], + ], + 'generated_at' => now(), + 'expires_at' => now()->addMinutes(60), + ]); + + Cache::flush(); + + $service = app(HomepageService::class); + $result = $service->allForUser($user); + + expect($result['for_you'])->toBeArray(); + // At least one item should have the base shape (id, title, slug, url) + if (count($result['for_you']) > 0) { + expect($result['for_you'][0])->toHaveKeys(['id', 'title', 'slug', 'url']); + } +}); diff --git a/tests/Feature/Recommendations/RecommendationEndpointsTest.php b/tests/Feature/Recommendations/RecommendationEndpointsTest.php new file mode 100644 index 00000000..0dfc9e50 --- /dev/null +++ b/tests/Feature/Recommendations/RecommendationEndpointsTest.php @@ -0,0 +1,176 @@ + 'null']); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// /discover/for-you +// ───────────────────────────────────────────────────────────────────────────── + +it('redirects guests away from /discover/for-you', function () { + $this->get('/discover/for-you') + ->assertRedirect(); +}); + +it('renders For You page for authenticated user', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/discover/for-you') + ->assertOk(); +}); + +it('For You page shows empty state with no prior activity', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/discover/for-you') + ->assertOk() + ->assertSee('For You'); +}); + +it('For You page uses cached recommendations when available', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subMinutes(5), + ]); + + UserRecommendationCache::query()->create([ + 'user_id' => $user->id, + 'algo_version' => (string) config('discovery.algo_version', 'clip-cosine-v1'), + 'cache_version' => (string) config('discovery.cache_version', 'cache-v1'), + 'recommendations_json' => [ + 'items' => [ + ['artwork_id' => $artwork->id, 'score' => 0.9, 'source' => 'profile'], + ], + ], + 'generated_at' => now(), + 'expires_at' => now()->addMinutes(30), + ]); + + $this->actingAs($user) + ->get('/discover/for-you') + ->assertOk(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// /api/user/suggestions/creators +// ───────────────────────────────────────────────────────────────────────────── + +it('requires auth for suggested creators endpoint', function () { + $this->getJson('/api/user/suggestions/creators') + ->assertUnauthorized(); +}); + +it('returns data array from suggested creators endpoint', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/user/suggestions/creators') + ->assertOk() + ->assertJsonStructure(['data']); +}); + +it('suggested creators does not include the requesting user', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->getJson('/api/user/suggestions/creators') + ->assertOk(); + + $ids = collect($response->json('data'))->pluck('id')->all(); + expect($ids)->not->toContain($user->id); +}); + +it('suggested creators excludes already-followed creators', function () { + $user = User::factory()->create(); + $followed = User::factory()->create(); + + UserFollower::create([ + 'user_id' => $followed->id, + 'follower_id' => $user->id, + ]); + + $response = $this->actingAs($user) + ->getJson('/api/user/suggestions/creators') + ->assertOk(); + + $ids = collect($response->json('data'))->pluck('id')->all(); + expect($ids)->not->toContain($followed->id); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// /api/user/suggestions/tags +// ───────────────────────────────────────────────────────────────────────────── + +it('requires auth for suggested tags endpoint', function () { + $this->getJson('/api/user/suggestions/tags') + ->assertUnauthorized(); +}); + +it('returns data array from suggested tags endpoint', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/user/suggestions/tags') + ->assertOk() + ->assertJsonStructure(['data']); +}); + +it('suggested tags returns correct shape', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->getJson('/api/user/suggestions/tags') + ->assertOk(); + + $data = $response->json('data'); + expect($data)->toBeArray(); + + // If non-empty each item must have these keys + foreach ($data as $item) { + expect($item)->toHaveKeys(['id', 'name', 'slug', 'usage_count', 'source']); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Similar artworks cache TTL +// ───────────────────────────────────────────────────────────────────────────── + +it('similar artworks endpoint returns 200 for a valid public artwork', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $this->getJson("/api/art/{$artwork->id}/similar") + ->assertOk() + ->assertJsonStructure(['data']); +}); + +it('similar artworks response is cached (second call hits cache layer)', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + // Two consecutive calls – the second must also succeed (confirming cache does not corrupt) + $this->getJson("/api/art/{$artwork->id}/similar")->assertOk(); + $this->getJson("/api/art/{$artwork->id}/similar")->assertOk(); +}); diff --git a/tests/Feature/Recommendations/RecommendationServiceTest.php b/tests/Feature/Recommendations/RecommendationServiceTest.php new file mode 100644 index 00000000..985f3e52 --- /dev/null +++ b/tests/Feature/Recommendations/RecommendationServiceTest.php @@ -0,0 +1,227 @@ + 'null']); + + // Seed recommendations config + config([ + 'recommendations.weights.tag_overlap' => 0.40, + 'recommendations.weights.creator_affinity' => 0.25, + 'recommendations.weights.popularity' => 0.20, + 'recommendations.weights.freshness' => 0.15, + 'recommendations.candidate_pool_size' => 200, + 'recommendations.max_per_creator' => 3, + 'recommendations.min_unique_tags' => 5, + 'recommendations.ttl.for_you_feed' => 5, + ]); + + Cache::flush(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RecommendationService cold-start (no signals) +// ───────────────────────────────────────────────────────────────────────────── + +it('returns cold-start feed when user has no signals', function () { + $user = User::factory()->create(); + + // Profile builder will return a DTO with no signals + $builder = Mockery::mock(UserPreferenceBuilder::class); + $builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO( + topTagSlugs: [], + topCategorySlugs: [], + strongCreatorIds: [], + tagWeights: [], + categoryWeights: [], + dislikedTagSlugs: [], + )); + + $service = new RecommendationService($builder); + $result = $service->forYouFeed($user, 10); + + expect($result)->toHaveKeys(['data', 'meta']) + ->and($result['meta']['source'])->toBe('cold_start'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RecommendationService personalised flow (mocked profile) +// ───────────────────────────────────────────────────────────────────────────── + +it('returns personalised feed with data when user has signals', function () { + $user = User::factory()->create(); + + // Two artworks from other creators (tags not needed — Scout driver is null) + Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'user_id' => User::factory()->create()->id, + 'published_at' => now()->subDay(), + ]); + Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'user_id' => User::factory()->create()->id, + 'published_at' => now()->subDays(2), + ]); + + $profile = new UserRecoProfileDTO( + topTagSlugs: ['cyberpunk', 'neon'], + topCategorySlugs: [], + strongCreatorIds: [], + tagWeights: ['cyberpunk' => 5.0, 'neon' => 3.0], + categoryWeights: [], + dislikedTagSlugs: [], + ); + + $builder = Mockery::mock(UserPreferenceBuilder::class); + $builder->shouldReceive('build')->with($user)->andReturn($profile); + + $service = new RecommendationService($builder); + $result = $service->forYouFeed($user, 10); + + expect($result)->toHaveKeys(['data', 'meta']) + ->and($result['meta'])->toHaveKey('source'); + // With scout null driver the collection is empty → cold-start path + // This tests the structure contract regardless of driver + expect($result['data'])->toBeArray(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Diversity: max 3 per creator +// ───────────────────────────────────────────────────────────────────────────── + +it('enforces max_per_creator diversity limit via forYouPreview', function () { + $user = User::factory()->create(); + + $creatorA = User::factory()->create(); + $creatorB = User::factory()->create(); + + // 4 artworks by creatorA, 1 by creatorB (Scout driver null — no Meili calls) + Artwork::factory(4)->create([ + 'is_public' => true, + 'is_approved' => true, + 'user_id' => $creatorA->id, + 'published_at' => now()->subHour(), + ]); + Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'user_id' => $creatorB->id, + 'published_at' => now()->subHour(), + ]); + + $profile = new UserRecoProfileDTO( + topTagSlugs: ['abstract'], + topCategorySlugs: [], + strongCreatorIds: [], + tagWeights: ['abstract' => 5.0], + categoryWeights: [], + dislikedTagSlugs: [], + ); + + $builder = Mockery::mock(UserPreferenceBuilder::class); + $builder->shouldReceive('build')->andReturn($profile); + + $service = new RecommendationService($builder); + + // With null scout driver the candidate collection is empty; we test contract. + $result = $service->forYouFeed($user, 10); + expect($result)->toHaveKeys(['data', 'meta']); + expect($result['data'])->toBeArray(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Favourited artworks are excluded from For You feed +// ───────────────────────────────────────────────────────────────────────────── + +it('excludes artworks already favourited by user', function () { + $user = User::factory()->create(); + $art = Artwork::factory()->create(['is_public' => true, 'is_approved' => true]); + + // Insert a favourite + DB::table('artwork_favourites')->insert([ + 'user_id' => $user->id, + 'artwork_id' => $art->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $profile = new UserRecoProfileDTO( + topTagSlugs: ['tag-x'], + topCategorySlugs: [], + strongCreatorIds: [], + tagWeights: ['tag-x' => 3.0], + categoryWeights: [], + dislikedTagSlugs: [], + ); + + $builder = Mockery::mock(UserPreferenceBuilder::class); + $builder->shouldReceive('build')->andReturn($profile); + + $service = new RecommendationService($builder); + + // With null scout, no candidates surface — checking that getFavoritedIds runs without error + $result = $service->forYouFeed($user, 10); + expect($result)->toHaveKeys(['data', 'meta']); + + $artworkIds = array_column($result['data'], 'id'); + expect($artworkIds)->not->toContain($art->id); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Cursor pagination shape +// ───────────────────────────────────────────────────────────────────────────── + +it('returns null next_cursor when no more pages available', function () { + $user = User::factory()->create(); + + $builder = Mockery::mock(UserPreferenceBuilder::class); + $builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO( + topTagSlugs: [], + topCategorySlugs: [], + strongCreatorIds: [], + tagWeights: [], + categoryWeights: [], + dislikedTagSlugs: [], + )); + + $service = new RecommendationService($builder); + $result = $service->forYouFeed($user, 40, null); + + expect($result['meta'])->toHaveKey('next_cursor'); + // Cold-start with 0 results: next_cursor should be null + expect($result['meta']['next_cursor'])->toBeNull(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// forYouPreview is a subset of forYouFeed +// ───────────────────────────────────────────────────────────────────────────── + +it('forYouPreview returns an array', function () { + $user = User::factory()->create(); + + $builder = Mockery::mock(UserPreferenceBuilder::class); + $builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO( + topTagSlugs: [], topCategorySlugs: [], strongCreatorIds: [], + tagWeights: [], categoryWeights: [], dislikedTagSlugs: [], + )); + + $service = new RecommendationService($builder); + $preview = $service->forYouPreview($user, 12); + + expect($preview)->toBeArray(); +}); diff --git a/tests/Feature/Recommendations/UserPreferenceBuilderTest.php b/tests/Feature/Recommendations/UserPreferenceBuilderTest.php new file mode 100644 index 00000000..dc9dba38 --- /dev/null +++ b/tests/Feature/Recommendations/UserPreferenceBuilderTest.php @@ -0,0 +1,81 @@ + 0.6, 'nature' => 0.4], + categoryWeights: ['wallpapers' => 1.0], + ); + + $arr = $dto->toArray(); + $restored = UserRecoProfileDTO::fromArray($arr); + + expect($restored->topTagSlugs)->toBe(['space', 'nature']) + ->and($restored->topCategorySlugs)->toBe(['wallpapers']) + ->and($restored->strongCreatorIds)->toBe([1, 2, 3]) + ->and($restored->tagWeight('space'))->toBe(0.6) + ->and($restored->followsCreator(2))->toBeTrue() + ->and($restored->followsCreator(99))->toBeFalse(); +}); + +it('DTO hasSignals returns false for empty profile', function () { + $empty = new UserRecoProfileDTO(); + expect($empty->hasSignals())->toBeFalse(); +}); + +it('DTO hasSignals returns true when tags are present', function () { + $dto = new UserRecoProfileDTO(topTagSlugs: ['space']); + expect($dto->hasSignals())->toBeTrue(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// UserPreferenceBuilder +// ───────────────────────────────────────────────────────────────────────────── + +it('UserPreferenceBuilder returns empty DTO for user with no activity', function () { + $user = User::factory()->create(); + $builder = app(UserPreferenceBuilder::class); + + $dto = $builder->build($user); + + expect($dto)->toBeInstanceOf(UserRecoProfileDTO::class) + ->and($dto->topTagSlugs)->toBe([]) + ->and($dto->strongCreatorIds)->toBe([]); +}); + +it('UserPreferenceBuilder persists profile row on first build', function () { + $user = User::factory()->create(); + $builder = app(UserPreferenceBuilder::class); + + $builder->buildFresh($user); + + expect(UserRecoProfile::find($user->id))->not->toBeNull(); +}); + +it('UserPreferenceBuilder produces stable output on repeated calls', function () { + $user = User::factory()->create(); + $builder = app(UserPreferenceBuilder::class); + + $first = $builder->buildFresh($user)->toArray(); + $second = $builder->buildFresh($user)->toArray(); + + expect($first)->toBe($second); +}); diff --git a/vite.config.js b/vite.config.js index c7ad062e..79fe4170 100644 --- a/vite.config.js +++ b/vite.config.js @@ -12,6 +12,7 @@ export default defineConfig({ 'resources/js/nova.js', 'resources/js/entry-topbar.jsx', 'resources/js/entry-search.jsx', + 'resources/js/entry-masonry-gallery.jsx', 'resources/js/upload.jsx', 'resources/js/Pages/ArtworkPage.jsx', 'resources/js/Pages/Home/HomePage.jsx',