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'], ] ); } }