Files
2026-04-18 17:02:56 +02: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), '+/', '-_'), '=');
}
}