fix(gallery): fill tall portrait cards to full block width with object-cover crop
- 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
This commit is contained in:
420
app/Services/Recommendation/RecommendationService.php
Normal file
420
app/Services/Recommendation/RecommendationService.php
Normal file
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendation;
|
||||
|
||||
use App\DTOs\UserRecoProfileDTO;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
/**
|
||||
* RecommendationService – Phase 1 "For You" feed pipeline.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Load UserRecoProfileDTO (cached, built by UserPreferenceBuilder)
|
||||
* 2. Generate 200 Meilisearch candidates filtered by user's top tags
|
||||
* – Exclude user's own artworks
|
||||
* – Exclude already-favourited artworks (PHP post-filter)
|
||||
* 3. PHP reranking:
|
||||
* score = (tag_overlap × w_tag_overlap)
|
||||
* + (creator_affinity × w_creator_affinity)
|
||||
* + (popularity_boost × w_popularity)
|
||||
* + (freshness_boost × w_freshness)
|
||||
* 4. Diversity controls:
|
||||
* – max N results per creator per page (config recommendations.max_per_creator)
|
||||
* – penalise repeated tag-pattern clusters
|
||||
* 5. Cursor-based pagination, top 40 per page
|
||||
* 6. Cold-start fallback (trending + fresh blend) when user has no signals
|
||||
*
|
||||
* Caching:
|
||||
* – key: for_you:{user_id}:{cursor_hash} TTL: config recommendations.ttl.for_you_feed
|
||||
*/
|
||||
final class RecommendationService
|
||||
{
|
||||
private const DEFAULT_PAGE_SIZE = 40;
|
||||
private const COLD_START_LIMIT = 40;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserPreferenceBuilder $prefBuilder
|
||||
) {}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return a fully ranked, paginated "For You" feed for the given user.
|
||||
*
|
||||
* @return array{
|
||||
* data: array<int, array<string, mixed>>,
|
||||
* meta: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
public function forYouPreview(User $user, int $limit = 12): array
|
||||
{
|
||||
$result = $this->forYouFeed($user, $limit);
|
||||
return $result['data'] ?? [];
|
||||
}
|
||||
|
||||
// ─── Build pipeline ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||
*/
|
||||
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<int, Artwork>
|
||||
*/
|
||||
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<int, Artwork>
|
||||
*/
|
||||
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<int, Artwork> $candidates
|
||||
* @return array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}>
|
||||
*/
|
||||
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<int, array{score: float, artwork: Artwork, tag_slugs: string[]}> $scored
|
||||
* @return array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}>
|
||||
*/
|
||||
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<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||
*/
|
||||
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<int, array{score: float, artwork: Artwork, tag_slugs: string[]}> $diversified
|
||||
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||
*/
|
||||
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<int, int> */
|
||||
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<string, mixed> */
|
||||
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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user