198 lines
7.0 KiB
PHP
198 lines
7.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Artwork;
|
|
use App\Services\ArtworkSearchService;
|
|
use App\Services\Recommendations\HybridSimilarArtworksService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
|
|
/**
|
|
* GET /api/art/{id}/similar
|
|
*
|
|
* Returns up to 12 similar artworks using the hybrid recommender (precomputed lists)
|
|
* with a Meilisearch-based fallback if no precomputed data exists.
|
|
*
|
|
* Query params:
|
|
* ?type=similar (default) | visual | tags | behavior
|
|
*
|
|
* Priority (default):
|
|
* 1. Hybrid precomputed (tag + behavior + optional vector)
|
|
* 2. Meilisearch tag-overlap fallback (legacy)
|
|
*/
|
|
final class SimilarArtworksController extends Controller
|
|
{
|
|
private const LIMIT = 12;
|
|
|
|
public function __construct(
|
|
private readonly ArtworkSearchService $search,
|
|
private readonly HybridSimilarArtworksService $hybridService,
|
|
) {}
|
|
|
|
public function __invoke(Request $request, int $id): JsonResponse
|
|
{
|
|
$artwork = Artwork::public()
|
|
->published()
|
|
->with(['tags:id,slug', 'categories:id,slug'])
|
|
->find($id);
|
|
|
|
if (! $artwork) {
|
|
return response()->json(['error' => 'Artwork not found'], 404);
|
|
}
|
|
|
|
$type = $request->query('type');
|
|
$validTypes = ['similar', 'visual', 'tags', 'behavior'];
|
|
if ($type !== null && ! in_array($type, $validTypes, true)) {
|
|
$type = null; // ignore invalid, fall through to default
|
|
}
|
|
|
|
// Service handles its own caching (6h TTL), no extra controller-level cache
|
|
$hybridResults = $this->hybridService->forArtwork($artwork->id, self::LIMIT, $type);
|
|
|
|
if ($hybridResults->isNotEmpty()) {
|
|
// Eager-load relations needed for formatting
|
|
$ids = $hybridResults->pluck('id')->all();
|
|
$loaded = Artwork::query()
|
|
->whereIn('id', $ids)
|
|
->with(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash'])
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$items = $hybridResults->values()->map(function (Artwork $a) use ($loaded) {
|
|
$full = $loaded->get($a->id) ?? $a;
|
|
return $this->formatArtwork($full);
|
|
})->all();
|
|
|
|
return response()->json(['data' => $items]);
|
|
}
|
|
|
|
// Fall back to Meilisearch tag-overlap search
|
|
$items = $this->findSimilarViaSearch($artwork);
|
|
|
|
return response()->json(['data' => $items]);
|
|
}
|
|
|
|
private function formatArtwork(Artwork $artwork): array
|
|
{
|
|
return [
|
|
'id' => $artwork->id,
|
|
'title' => $artwork->title,
|
|
'slug' => $artwork->slug,
|
|
'thumb' => $artwork->thumbUrl('md'),
|
|
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
|
'author' => $artwork->user?->name ?? 'Artist',
|
|
'author_avatar' => $artwork->user?->profile?->avatar_url,
|
|
'author_id' => $artwork->user_id,
|
|
'orientation' => $this->orientation($artwork),
|
|
'width' => $artwork->width,
|
|
'height' => $artwork->height,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Legacy Meilisearch-based similar artworks (fallback).
|
|
*/
|
|
private function findSimilarViaSearch(Artwork $artwork): array
|
|
{
|
|
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
|
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
|
$srcOrientation = $this->orientation($artwork);
|
|
|
|
$filterParts = [
|
|
'is_public = true',
|
|
'is_approved = true',
|
|
'id != ' . $artwork->id,
|
|
'author_id != ' . $artwork->user_id,
|
|
];
|
|
|
|
if ($tagSlugs !== []) {
|
|
$tagFilter = implode(' OR ', array_map(
|
|
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
|
$tagSlugs
|
|
));
|
|
$filterParts[] = '(' . $tagFilter . ')';
|
|
} elseif ($categorySlugs !== []) {
|
|
$catFilter = implode(' OR ', array_map(
|
|
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
|
$categorySlugs
|
|
));
|
|
$filterParts[] = '(' . $catFilter . ')';
|
|
}
|
|
|
|
$results = Artwork::search('')
|
|
->options([
|
|
'filter' => implode(' AND ', $filterParts),
|
|
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
|
])
|
|
->paginate(200, 'page', 1);
|
|
|
|
$collection = $results->getCollection();
|
|
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
|
|
|
|
$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);
|
|
|
|
$common = count(array_intersect_key($srcTagSet, $cTagSet));
|
|
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
|
|
$tagOverlap = $common / $total;
|
|
|
|
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
|
|
|
|
$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;
|
|
|
|
$views = max(0, (int) ($candidate->stats?->views ?? 0));
|
|
$popularity = min(0.15, log(1 + $views) / 13.0);
|
|
|
|
$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 => array_merge(
|
|
$this->formatArtwork($item['artwork']),
|
|
['score' => round((float) $item['score'], 5)]
|
|
), array_slice($scored, 0, self::LIMIT))
|
|
);
|
|
}
|
|
|
|
private function orientation(Artwork $artwork): string
|
|
{
|
|
if (! $artwork->width || ! $artwork->height) {
|
|
return 'square';
|
|
}
|
|
|
|
return match (true) {
|
|
$artwork->width > $artwork->height => 'landscape',
|
|
$artwork->height > $artwork->width => 'portrait',
|
|
default => 'square',
|
|
};
|
|
}
|
|
}
|