130 lines
4.3 KiB
PHP
130 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\NovaCards;
|
|
|
|
use App\Models\NovaCard;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* Computes and returns related cards for a given card using multiple
|
|
* similarity signals: template family, category, mood tags, format,
|
|
* style family, palette family, and creator.
|
|
*/
|
|
class NovaCardRelatedCardsService
|
|
{
|
|
private const CACHE_TTL = 600;
|
|
|
|
private const LIMIT = 8;
|
|
|
|
public function related(NovaCard $card, int $limit = self::LIMIT, bool $cached = true): Collection
|
|
{
|
|
if ($cached) {
|
|
return Cache::remember(
|
|
'nova_cards.related.' . $card->id . '.' . $limit,
|
|
self::CACHE_TTL,
|
|
fn () => $this->compute($card, $limit),
|
|
);
|
|
}
|
|
|
|
return $this->compute($card, $limit);
|
|
}
|
|
|
|
public function invalidateForCard(NovaCard $card): void
|
|
{
|
|
foreach ([4, 6, 8, 12] as $limit) {
|
|
Cache::forget('nova_cards.related.' . $card->id . '.' . $limit);
|
|
}
|
|
}
|
|
|
|
private function compute(NovaCard $card, int $limit): Collection
|
|
{
|
|
$card->loadMissing(['tags', 'category', 'template']);
|
|
|
|
$tagIds = $card->tags->pluck('id')->all();
|
|
$templateId = $card->template_id;
|
|
$categoryId = $card->category_id;
|
|
$format = $card->format;
|
|
$styleFamily = $card->style_family;
|
|
$paletteFamily = $card->palette_family;
|
|
$creatorId = $card->user_id;
|
|
|
|
$query = NovaCard::query()
|
|
->publiclyVisible()
|
|
->where('nova_cards.id', '!=', $card->id)
|
|
->select(['nova_cards.*'])
|
|
->selectRaw('0 AS relevance_score');
|
|
|
|
// We build a union-ranked set via scored sub-queries, then re-aggregate
|
|
// in PHP (simpler than scoring in MySQL without a full-text index).
|
|
$candidates = NovaCard::query()
|
|
->publiclyVisible()
|
|
->where('nova_cards.id', '!=', $card->id)
|
|
->where(function ($q) use ($tagIds, $templateId, $categoryId, $format, $styleFamily, $paletteFamily, $creatorId): void {
|
|
$q->whereHas('tags', fn ($tq) => $tq->whereIn('nova_card_tags.id', $tagIds))
|
|
->orWhere('template_id', $templateId)
|
|
->orWhere('category_id', $categoryId)
|
|
->orWhere('format', $format)
|
|
->orWhere('style_family', $styleFamily)
|
|
->orWhere('palette_family', $paletteFamily)
|
|
->orWhere('user_id', $creatorId);
|
|
})
|
|
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
|
|
->limit(80)
|
|
->get();
|
|
|
|
// Score in PHP — lightweight for this candidate set size.
|
|
$scored = $candidates->map(function (NovaCard $c) use ($tagIds, $templateId, $categoryId, $format, $styleFamily, $paletteFamily, $creatorId): array {
|
|
$score = 0;
|
|
|
|
// Tag overlap: up to 10 points
|
|
$overlap = count(array_intersect($c->tags->pluck('id')->all(), $tagIds));
|
|
$score += min($overlap * 2, 10);
|
|
|
|
// Same template: 5 pts
|
|
if ($templateId && $c->template_id === $templateId) {
|
|
$score += 5;
|
|
}
|
|
|
|
// Same category: 3 pts
|
|
if ($categoryId && $c->category_id === $categoryId) {
|
|
$score += 3;
|
|
}
|
|
|
|
// Same format: 2 pts
|
|
if ($c->format === $format) {
|
|
$score += 2;
|
|
}
|
|
|
|
// Same style family: 4 pts
|
|
if ($styleFamily && $c->style_family === $styleFamily) {
|
|
$score += 4;
|
|
}
|
|
|
|
// Same palette: 3 pts
|
|
if ($paletteFamily && $c->palette_family === $paletteFamily) {
|
|
$score += 3;
|
|
}
|
|
|
|
// Same creator (more cards by creator): 1 pt
|
|
if ($c->user_id === $creatorId) {
|
|
$score += 1;
|
|
}
|
|
|
|
// Engagement quality boost (saves + remixes weighted)
|
|
$engagementBoost = min(($c->saves_count + $c->remixes_count * 2) * 0.1, 3.0);
|
|
$score += $engagementBoost;
|
|
|
|
return ['card' => $c, 'score' => $score];
|
|
});
|
|
|
|
return $scored
|
|
->sortByDesc('score')
|
|
->take($limit)
|
|
->pluck('card')
|
|
->values();
|
|
}
|
|
}
|