- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so object-cover fills the max-height capped box instead of collapsing the width - MasonryGallery.css: add width:100% to media container, position img absolutely so top/bottom is cropped rather than leaving dark gaps - Add React MasonryGallery + ArtworkCard components and entry point - Add recommendation system: UserRecoProfile model/DTO/migration, SuggestedCreatorsController, SuggestedTagsController, Recommendation services, config/recommendations.php - SimilarArtworksController, DiscoverController, HomepageService updates - Update routes (api + web) and discover/for-you views - Refresh favicon assets, update vite.config.js
308 lines
11 KiB
PHP
308 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Recommendation;
|
|
|
|
use App\DTOs\UserRecoProfileDTO;
|
|
use App\Models\User;
|
|
use App\Models\UserRecoProfile;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* UserPreferenceBuilder
|
|
*
|
|
* Builds (and caches) a UserRecoProfileDTO for a given user by aggregating
|
|
* signals from their interaction history:
|
|
*
|
|
* Signal Weight (default)
|
|
* ───────────────────────── ────────────────
|
|
* Award given (gold/silver) +5
|
|
* Artwork favourited +3
|
|
* Reaction given +2
|
|
* Creator followed (tags) +2 for their tags
|
|
* View recorded +1
|
|
*
|
|
* The result is persisted to `user_reco_profiles` (6-hour TTL by default) so
|
|
* subsequent requests in the same window skip all DB queries.
|
|
*
|
|
* Usage:
|
|
* $builder = app(UserPreferenceBuilder::class);
|
|
* $dto = $builder->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<string,mixed>|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<string, float> 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<string, float>
|
|
*/
|
|
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<int, int> $creatorIds
|
|
* @return array<string, float>
|
|
*/
|
|
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<string, float>
|
|
*/
|
|
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<int, int>
|
|
*/
|
|
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<string, float> $raw
|
|
* @return array<string, float>
|
|
*/
|
|
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'],
|
|
]
|
|
);
|
|
}
|
|
}
|