Files
SkinbaseNova/app/Services/Recommendation/RecommendationService.php
Gregor Klevze 67ef79766c 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
2026-02-27 13:34:08 +01:00

421 lines
17 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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), '+/', '-_'), '=');
}
}