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:
94
app/DTOs/UserRecoProfileDTO.php
Normal file
94
app/DTOs/UserRecoProfileDTO.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
/**
|
||||
* Lightweight value object representing a user's recommendation preference profile.
|
||||
*
|
||||
* Built by UserPreferenceBuilder from signals:
|
||||
* - favourited artworks (+3)
|
||||
* - awards given (+5)
|
||||
* - creator follows (+2 for their tags)
|
||||
* - own uploads (category bias)
|
||||
*
|
||||
* Cached in `user_reco_profiles` with a configurable TTL (default 6 hours).
|
||||
*/
|
||||
final class UserRecoProfileDTO
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $topTagSlugs Top tag slugs by weighted score (up to 20)
|
||||
* @param array<int, string> $topCategorySlugs Top category slugs (up to 5)
|
||||
* @param array<int, int> $strongCreatorIds Followed creator user IDs (up to 50)
|
||||
* @param array<string, float> $tagWeights Tag slug → normalised weight (0–1)
|
||||
* @param array<string, float> $categoryWeights Category slug → normalised weight
|
||||
* @param array<int, string> $dislikedTagSlugs Future: blocked/hidden tag slugs
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $topTagSlugs = [],
|
||||
public readonly array $topCategorySlugs = [],
|
||||
public readonly array $strongCreatorIds = [],
|
||||
public readonly array $tagWeights = [],
|
||||
public readonly array $categoryWeights = [],
|
||||
public readonly array $dislikedTagSlugs = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* True if the user has enough signals to drive personalised recommendations.
|
||||
*/
|
||||
public function hasSignals(): bool
|
||||
{
|
||||
return $this->topTagSlugs !== [] || $this->strongCreatorIds !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalised tag weight for a given slug (0.0 if unknown).
|
||||
*/
|
||||
public function tagWeight(string $slug): float
|
||||
{
|
||||
return (float) ($this->tagWeights[$slug] ?? 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the creator is in the user's strong-follow list.
|
||||
*/
|
||||
public function followsCreator(int $userId): bool
|
||||
{
|
||||
return in_array($userId, $this->strongCreatorIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise for storage in the DB / Redis cache.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'top_tags' => $this->topTagSlugs,
|
||||
'top_categories' => $this->topCategorySlugs,
|
||||
'strong_creators' => $this->strongCreatorIds,
|
||||
'tag_weights' => $this->tagWeights,
|
||||
'category_weights' => $this->categoryWeights,
|
||||
'disliked_tags' => $this->dislikedTagSlugs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-hydrate from a stored array (e.g. from the DB JSON column).
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
topTagSlugs: (array) ($data['top_tags'] ?? []),
|
||||
topCategorySlugs: (array) ($data['top_categories'] ?? []),
|
||||
strongCreatorIds: array_map('intval', (array) ($data['strong_creators'] ?? [])),
|
||||
tagWeights: array_map('floatval', (array) ($data['tag_weights'] ?? [])),
|
||||
categoryWeights: array_map('floatval', (array) ($data['category_weights'] ?? [])),
|
||||
dislikedTagSlugs: (array) ($data['disliked_tags'] ?? []),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ use Illuminate\Support\Facades\Cache;
|
||||
final class SimilarArtworksController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
/** Spec §5: cache similar artworks 30–60 min; using config with 30 min default. */
|
||||
private const CACHE_TTL = 1800; // 30 minutes
|
||||
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
|
||||
@@ -50,9 +51,9 @@ final class SimilarArtworksController extends Controller
|
||||
|
||||
private function findSimilar(Artwork $artwork): array
|
||||
{
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||||
$orientation = $this->orientation($artwork);
|
||||
$srcOrientation = $this->orientation($artwork);
|
||||
|
||||
// Build Meilisearch filter: exclude self and same creator
|
||||
$filterParts = [
|
||||
@@ -62,11 +63,6 @@ final class SimilarArtworksController extends Controller
|
||||
'author_id != ' . $artwork->user_id,
|
||||
];
|
||||
|
||||
// Filter by same orientation (landscape/portrait) — improves visual coherence
|
||||
if ($orientation !== 'square') {
|
||||
$filterParts[] = 'orientation = "' . $orientation . '"';
|
||||
}
|
||||
|
||||
// Priority 1: tag overlap (OR match across tags)
|
||||
if ($tagSlugs !== []) {
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
@@ -83,27 +79,80 @@ final class SimilarArtworksController extends Controller
|
||||
$filterParts[] = '(' . $catFilter . ')';
|
||||
}
|
||||
|
||||
// ── Fetch 200-candidate pool from Meilisearch ─────────────────────────
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
||||
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate(self::LIMIT);
|
||||
->paginate(200, 'page', 1);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn (Artwork $a): array => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'thumb' => $a->thumbUrl('md'),
|
||||
'url' => '/art/' . $a->id . '/' . $a->slug,
|
||||
'author_id' => $a->user_id,
|
||||
'orientation' => $this->orientation($a),
|
||||
'width' => $a->width,
|
||||
'height' => $a->height,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
$collection = $results->getCollection();
|
||||
$collection->load(['tags:id,slug', 'stats']);
|
||||
|
||||
// ── PHP reranking ──────────────────────────────────────────────────────
|
||||
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
|
||||
// +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10
|
||||
$srcTagSet = array_flip($tagSlugs);
|
||||
$srcW = (int) ($artwork->width ?? 0);
|
||||
$srcH = (int) ($artwork->height ?? 0);
|
||||
|
||||
$scored = $collection->map(function (Artwork $candidate) use (
|
||||
$srcTagSet, $tagSlugs, $srcOrientation, $srcW, $srcH
|
||||
): array {
|
||||
$cTagSlugs = $candidate->tags->pluck('slug')->all();
|
||||
$cTagSet = array_flip($cTagSlugs);
|
||||
|
||||
// Tag overlap (Sørensen–Dice-like)
|
||||
$common = count(array_intersect_key($srcTagSet, $cTagSet));
|
||||
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
|
||||
$tagOverlap = $common / $total;
|
||||
|
||||
// Orientation bonus
|
||||
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
|
||||
|
||||
// Resolution proximity bonus (both axes within 25 %)
|
||||
$cW = (int) ($candidate->width ?? 0);
|
||||
$cH = (int) ($candidate->height ?? 0);
|
||||
$resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0
|
||||
&& abs($cW - $srcW) / $srcW <= 0.25
|
||||
&& abs($cH - $srcH) / $srcH <= 0.25
|
||||
) ? 0.05 : 0.0;
|
||||
|
||||
// Popularity boost (log-normalised views, capped at 0.15)
|
||||
$views = max(0, (int) ($candidate->stats?->views ?? 0));
|
||||
$popularity = min(0.15, log(1 + $views) / 13.0);
|
||||
|
||||
// Freshness boost (exp decay, 60-day half-life, weight 0.10)
|
||||
$publishedAt = $candidate->published_at ?? $candidate->created_at ?? now();
|
||||
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||
$freshness = exp(-$ageDays / 60.0) * 0.10;
|
||||
|
||||
$score = $tagOverlap * 0.60
|
||||
+ $orientBonus
|
||||
+ $resBonus
|
||||
+ $popularity
|
||||
+ $freshness;
|
||||
|
||||
return ['score' => $score, 'artwork' => $candidate];
|
||||
})->all();
|
||||
|
||||
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
return array_values(
|
||||
array_map(fn (array $item): array => [
|
||||
'id' => $item['artwork']->id,
|
||||
'title' => $item['artwork']->title,
|
||||
'slug' => $item['artwork']->slug,
|
||||
'thumb' => $item['artwork']->thumbUrl('md'),
|
||||
'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug,
|
||||
'author_id' => $item['artwork']->user_id,
|
||||
'orientation' => $this->orientation($item['artwork']),
|
||||
'width' => $item['artwork']->width,
|
||||
'height' => $item['artwork']->height,
|
||||
'score' => round((float) $item['score'], 5),
|
||||
], array_slice($scored, 0, self::LIMIT))
|
||||
);
|
||||
}
|
||||
|
||||
private function orientation(Artwork $artwork): string
|
||||
|
||||
217
app/Http/Controllers/Api/SuggestedCreatorsController.php
Normal file
217
app/Http/Controllers/Api/SuggestedCreatorsController.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* GET /api/user/suggestions/creators
|
||||
*
|
||||
* Returns up to 12 creators the authenticated user might want to follow.
|
||||
*
|
||||
* Ranking algorithm (Phase 1 – no embeddings):
|
||||
* 1. Creators followed by people you follow (mutual-follow signal)
|
||||
* 2. Creators whose recent works overlap your top tags
|
||||
* 3. High-quality creators (followers_count / artworks_count) in your categories
|
||||
*
|
||||
* Exclusions: yourself, already-followed creators.
|
||||
*
|
||||
* Cached per user for config('recommendations.ttl.creator_suggestions') seconds (default 30 min).
|
||||
*/
|
||||
final class SuggestedCreatorsController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
|
||||
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$ttl = (int) config('recommendations.ttl.creator_suggestions', 30 * 60);
|
||||
$cacheKey = "creator_suggestions:{$user->id}";
|
||||
|
||||
$data = Cache::remember($cacheKey, $ttl, function () use ($user) {
|
||||
return $this->buildSuggestions($user);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
private function buildSuggestions(\App\Models\User $user): array
|
||||
{
|
||||
try {
|
||||
$profile = $this->prefBuilder->build($user);
|
||||
$followingIds = $profile->strongCreatorIds;
|
||||
$topTagSlugs = array_slice($profile->topTagSlugs, 0, 10);
|
||||
|
||||
// ── 1. Mutual-follow candidates ───────────────────────────────────
|
||||
$mutualCandidates = [];
|
||||
if ($followingIds !== []) {
|
||||
$rows = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->whereIn('uf.follower_id', $followingIds)
|
||||
->where('uf.user_id', '!=', $user->id)
|
||||
->whereNotIn('uf.user_id', array_merge($followingIds, [$user->id]))
|
||||
->where('u.is_active', true)
|
||||
->selectRaw('
|
||||
u.id,
|
||||
u.name,
|
||||
u.username,
|
||||
up.avatar_hash,
|
||||
COALESCE(us.followers_count, 0) as followers_count,
|
||||
COALESCE(us.artworks_count, 0) as artworks_count,
|
||||
COUNT(*) as mutual_weight
|
||||
')
|
||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
|
||||
->orderByDesc('mutual_weight')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$mutualCandidates[(int) $row->id] = [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name,
|
||||
'username' => $row->username,
|
||||
'avatar_hash' => $row->avatar_hash,
|
||||
'followers_count' => (int) $row->followers_count,
|
||||
'artworks_count' => (int) $row->artworks_count,
|
||||
'score' => (float) $row->mutual_weight * 3.0,
|
||||
'reason' => 'Popular among creators you follow',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Tag-affinity candidates ────────────────────────────────────
|
||||
$tagCandidates = [];
|
||||
if ($topTagSlugs !== []) {
|
||||
$tagFilter = implode(',', array_fill(0, count($topTagSlugs), '?'));
|
||||
|
||||
$rows = DB::table('tags as t')
|
||||
->join('artwork_tag as at', 'at.tag_id', '=', 't.id')
|
||||
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
|
||||
->join('users as u', 'u.id', '=', 'a.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->whereIn('t.slug', $topTagSlugs)
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('u.id', '!=', $user->id)
|
||||
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
|
||||
->where('u.is_active', true)
|
||||
->selectRaw('
|
||||
u.id,
|
||||
u.name,
|
||||
u.username,
|
||||
up.avatar_hash,
|
||||
COALESCE(us.followers_count, 0) as followers_count,
|
||||
COALESCE(us.artworks_count, 0) as artworks_count,
|
||||
COUNT(DISTINCT t.id) as matched_tags
|
||||
')
|
||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
|
||||
->orderByDesc('matched_tags')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (isset($mutualCandidates[(int) $row->id])) {
|
||||
// Boost mutual candidate that also matches tags
|
||||
$mutualCandidates[(int) $row->id]['score'] += (float) $row->matched_tags;
|
||||
continue;
|
||||
}
|
||||
|
||||
$tagCandidates[(int) $row->id] = [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name,
|
||||
'username' => $row->username,
|
||||
'avatar_hash' => $row->avatar_hash,
|
||||
'followers_count' => (int) $row->followers_count,
|
||||
'artworks_count' => (int) $row->artworks_count,
|
||||
'score' => (float) $row->matched_tags * 2.0,
|
||||
'reason' => 'Matches your interests',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Merge & rank ───────────────────────────────────────────────
|
||||
$combined = array_values(array_merge($mutualCandidates, $tagCandidates));
|
||||
usort($combined, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
$top = array_slice($combined, 0, self::LIMIT);
|
||||
|
||||
if (count($top) < self::LIMIT) {
|
||||
$topIds = array_column($top, 'id');
|
||||
$excluded = array_unique(array_merge($followingIds, [$user->id], $topIds));
|
||||
$top = array_merge($top, $this->highQualityFallback($excluded, self::LIMIT - count($top)));
|
||||
}
|
||||
|
||||
return array_map(fn (array $c): array => [
|
||||
'id' => $c['id'],
|
||||
'name' => $c['name'],
|
||||
'username' => $c['username'],
|
||||
'url' => $c['username'] ? '/@' . $c['username'] : '/profile/' . $c['id'],
|
||||
'avatar' => AvatarUrl::forUser((int) $c['id'], $c['avatar_hash'] ?? null, 64),
|
||||
'followers_count' => (int) ($c['followers_count'] ?? 0),
|
||||
'artworks_count' => (int) ($c['artworks_count'] ?? 0),
|
||||
'reason' => $c['reason'] ?? null,
|
||||
], $top);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SuggestedCreatorsController: failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $excludedIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function highQualityFallback(array $excludedIds, int $limit): array
|
||||
{
|
||||
if ($limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('users as u')
|
||||
->join('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->whereNotIn('u.id', $excludedIds)
|
||||
->where('u.is_active', true)
|
||||
->selectRaw('
|
||||
u.id,
|
||||
u.name,
|
||||
u.username,
|
||||
up.avatar_hash,
|
||||
COALESCE(us.followers_count, 0) as followers_count,
|
||||
COALESCE(us.artworks_count, 0) as artworks_count
|
||||
')
|
||||
->orderByDesc('followers_count')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $rows->map(fn ($r) => [
|
||||
'id' => (int) $r->id,
|
||||
'name' => $r->name,
|
||||
'username' => $r->username,
|
||||
'avatar_hash' => $r->avatar_hash,
|
||||
'followers_count' => (int) $r->followers_count,
|
||||
'artworks_count' => (int) $r->artworks_count,
|
||||
'score' => (float) $r->followers_count * 0.1,
|
||||
'reason' => 'Popular creator',
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
152
app/Http/Controllers/Api/SuggestedTagsController.php
Normal file
152
app/Http/Controllers/Api/SuggestedTagsController.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* GET /api/user/suggestions/tags
|
||||
*
|
||||
* Returns up to 20 tag suggestions for the authenticated user.
|
||||
*
|
||||
* Sources:
|
||||
* 1. Tags from the user's favourited artworks and awards (affinity-ranked)
|
||||
* 2. Trending tags from global activity (fallback / discovery)
|
||||
*
|
||||
* Does NOT require the user to follow tags. This endpoint provides the foundation
|
||||
* for a future "follow tags" feature while being useful immediately as discovery input.
|
||||
*
|
||||
* Cached per user for config('recommendations.ttl.tag_suggestions') seconds (default 60 min).
|
||||
*/
|
||||
final class SuggestedTagsController extends Controller
|
||||
{
|
||||
private const LIMIT = 20;
|
||||
|
||||
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$ttl = (int) config('recommendations.ttl.tag_suggestions', 60 * 60);
|
||||
$cacheKey = "tag_suggestions:{$user->id}";
|
||||
|
||||
$data = Cache::remember($cacheKey, $ttl, function () use ($user) {
|
||||
return $this->buildSuggestions($user);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
private function buildSuggestions(\App\Models\User $user): array
|
||||
{
|
||||
try {
|
||||
$profile = $this->prefBuilder->build($user);
|
||||
$knownTagSlugs = $profile->topTagSlugs; // already in user's profile – skip
|
||||
|
||||
// ── Personalised tags (with normalised weights) ───────────────────
|
||||
$personalised = [];
|
||||
foreach ($profile->tagWeights as $slug => $weight) {
|
||||
if ($weight > 0.0) {
|
||||
$personalised[$slug] = (float) $weight;
|
||||
}
|
||||
}
|
||||
arsort($personalised);
|
||||
|
||||
// ── Trending tags (global, last 7 days) ───────────────────────────
|
||||
$trending = $this->trendingTags(40);
|
||||
|
||||
// ── Merge: personalised first, then trending discovery ─────────────
|
||||
$merged = [];
|
||||
foreach ($personalised as $slug => $weight) {
|
||||
$merged[$slug] = [
|
||||
'slug' => $slug,
|
||||
'score' => $weight * 2.0, // boost personal signal
|
||||
'source' => 'affinity',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($trending as $row) {
|
||||
$slug = (string) $row->slug;
|
||||
if (isset($merged[$slug])) {
|
||||
$merged[$slug]['score'] += (float) $row->trend_score;
|
||||
} else {
|
||||
$merged[$slug] = [
|
||||
'slug' => $slug,
|
||||
'score' => (float) $row->trend_score,
|
||||
'source' => 'trending',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
uasort($merged, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
$top = array_slice(array_values($merged), 0, self::LIMIT);
|
||||
|
||||
// ── Hydrate with DB info ──────────────────────────────────────────
|
||||
$slugs = array_column($top, 'slug');
|
||||
$tagRows = DB::table('tags')
|
||||
->whereIn('slug', $slugs)
|
||||
->where('is_active', true)
|
||||
->get(['id', 'name', 'slug', 'usage_count'])
|
||||
->keyBy('slug');
|
||||
|
||||
$result = [];
|
||||
foreach ($top as $item) {
|
||||
$tag = $tagRows->get($item['slug']);
|
||||
if ($tag === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'id' => (int) $tag->id,
|
||||
'name' => (string) $tag->name,
|
||||
'slug' => (string) $tag->slug,
|
||||
'usage_count' => (int) $tag->usage_count,
|
||||
'source' => (string) $item['source'],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SuggestedTagsController: failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate tag usage over the last 7 days as a proxy for trend score.
|
||||
* Uses artwork_tag + artworks.published_at to avoid a heavy events table dependency.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
|
||||
*/
|
||||
private function trendingTags(int $limit): \Illuminate\Support\Collection
|
||||
{
|
||||
$since = now()->subDays(7);
|
||||
|
||||
return DB::table('artwork_tag as at')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
|
||||
->where('a.published_at', '>=', $since)
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('t.is_active', true)
|
||||
->selectRaw('t.slug, COUNT(*) / 1.0 as trend_score')
|
||||
->groupBy('t.id', 't.slug')
|
||||
->orderByDesc('trend_score')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\Recommendation\RecommendationService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -21,12 +22,14 @@ use Illuminate\Support\Facades\Schema;
|
||||
* - /discover/top-rated → highest favourite count
|
||||
* - /discover/most-downloaded → most downloaded all-time
|
||||
* - /discover/on-this-day → published on this calendar day in previous years
|
||||
* - /discover/for-you → personalised feed (auth required)
|
||||
*/
|
||||
final class DiscoverController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly ArtworkSearchService $searchService,
|
||||
private readonly RecommendationService $recoService,
|
||||
) {}
|
||||
|
||||
// ─── /discover/trending ──────────────────────────────────────────────────
|
||||
@@ -178,6 +181,56 @@ final class DiscoverController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/for-you ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Personalised "For You" feed page.
|
||||
*
|
||||
* Uses RecommendationService (Phase 1 tag-affinity + creator-affinity pipeline)
|
||||
* and renders the standard discover grid view. Guest users are redirected
|
||||
* to the trending page per spec.
|
||||
*/
|
||||
public function forYou(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$limit = 40;
|
||||
$cursor = $request->query('cursor') ?: null;
|
||||
|
||||
// Retrieve the paginated feed (service handles Meilisearch + reranking + cache)
|
||||
$feedResult = $this->recoService->forYouFeed(
|
||||
user: $user,
|
||||
limit: $limit,
|
||||
cursor: is_string($cursor) ? $cursor : null,
|
||||
);
|
||||
|
||||
$artworkItems = $feedResult['data'] ?? [];
|
||||
|
||||
// Build a simple presentable collection
|
||||
$artworks = collect($artworkItems)->map(fn (array $item) => (object) [
|
||||
'id' => $item['id'] ?? 0,
|
||||
'name' => $item['title'] ?? 'Untitled',
|
||||
'category_name' => '',
|
||||
'thumb_url' => $item['thumbnail_url'] ?? null,
|
||||
'thumb_srcset' => $item['thumbnail_url'] ?? null,
|
||||
'uname' => $item['author'] ?? 'Artist',
|
||||
'published_at' => null,
|
||||
'slug' => $item['slug'] ?? '',
|
||||
]);
|
||||
|
||||
$meta = $feedResult['meta'] ?? [];
|
||||
$nextCursor = $meta['next_cursor'] ?? null;
|
||||
|
||||
return view('web.discover.for-you', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'For You',
|
||||
'section' => 'for-you',
|
||||
'description' => 'Artworks picked for you based on your taste.',
|
||||
'icon' => 'fa-wand-magic-sparkles',
|
||||
'next_cursor' => $nextCursor,
|
||||
'cache_status' => $meta['cache_status'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/following ─────────────────────────────────────────────────
|
||||
|
||||
public function following(Request $request)
|
||||
@@ -264,11 +317,14 @@ final class DiscoverController extends Controller
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'category_name' => $primaryCategory->name ?? '',
|
||||
'category_slug' => $primaryCategory->slug ?? '',
|
||||
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'published_at' => $artwork->published_at,
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Models/UserRecoProfile.php
Normal file
73
app/Models/UserRecoProfile.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\DTOs\UserRecoProfileDTO;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Persisted cache of a user's recommendation preference profile.
|
||||
*
|
||||
* Schema: user_reco_profiles (user_id PK, json columns, timestamps).
|
||||
* Rebuilt by UserPreferenceBuilder → stale when updated_at < now() - TTL.
|
||||
*/
|
||||
class UserRecoProfile extends Model
|
||||
{
|
||||
protected $table = 'user_reco_profiles';
|
||||
|
||||
protected $primaryKey = 'user_id';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'int';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'top_tags_json' => 'array',
|
||||
'top_categories_json' => 'array',
|
||||
'followed_creator_ids_json' => 'array',
|
||||
'tag_weights_json' => 'array',
|
||||
'category_weights_json' => 'array',
|
||||
'disliked_tag_ids_json' => 'array',
|
||||
];
|
||||
|
||||
// ── Relations ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hydrate a DTO from this model's JSON columns.
|
||||
*/
|
||||
public function toDTO(): UserRecoProfileDTO
|
||||
{
|
||||
return new UserRecoProfileDTO(
|
||||
topTagSlugs: (array) ($this->top_tags_json ?? []),
|
||||
topCategorySlugs: (array) ($this->top_categories_json ?? []),
|
||||
strongCreatorIds: array_map('intval', (array) ($this->followed_creator_ids_json ?? [])),
|
||||
tagWeights: array_map('floatval', (array) ($this->tag_weights_json ?? [])),
|
||||
categoryWeights: array_map('floatval', (array) ($this->category_weights_json ?? [])),
|
||||
dislikedTagSlugs: (array) ($this->disliked_tag_ids_json ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the stored profile is still within the configured TTL.
|
||||
*/
|
||||
public function isFresh(): bool
|
||||
{
|
||||
if ($this->updated_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ttl = (int) config('recommendations.ttl.user_reco_profile', 6 * 3600);
|
||||
|
||||
return $this->updated_at->addSeconds($ttl)->isFuture();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\Recommendation\RecommendationService;
|
||||
use App\Services\UserPreferenceService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -26,9 +27,10 @@ final class HomepageService
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworks,
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly UserPreferenceService $prefs,
|
||||
private readonly ArtworkService $artworks,
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly UserPreferenceService $prefs,
|
||||
private readonly RecommendationService $reco,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -70,6 +72,7 @@ final class HomepageService
|
||||
'is_logged_in' => true,
|
||||
'user_data' => $this->getUserData($user),
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'for_you' => $this->getForYouPreview($user),
|
||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||
'trending' => $this->getTrending(),
|
||||
'fresh' => $this->getFreshUploads(),
|
||||
@@ -86,6 +89,22 @@ final class HomepageService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* "For You" homepage preview: first 12 results from the Phase 1 personalised feed.
|
||||
*
|
||||
* Uses RecommendationService which handles Meilisearch retrieval, PHP reranking,
|
||||
* diversity controls, and its own Redis cache layer.
|
||||
*/
|
||||
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
|
||||
{
|
||||
try {
|
||||
return $this->reco->forYouPreview($user, $limit);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sections
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
307
app/Services/Recommendation/UserPreferenceBuilder.php
Normal file
307
app/Services/Recommendation/UserPreferenceBuilder.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?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'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user