Files
SkinbaseNova/app/Services/Vision/AiArtworkVectorSearchService.php

195 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
final class AiArtworkVectorSearchService
{
private const MAX_SIMILAR_RESULTS = 120;
public function __construct(
private readonly VectorGatewayClient $client,
private readonly ArtworkVisionImageUrl $imageUrl,
) {
}
public function isConfigured(): bool
{
return $this->client->isConfigured();
}
/**
* @return list<array<string, mixed>>
*/
public function similarToArtwork(Artwork $artwork, int $limit = 12): array
{
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
$cacheKey = sprintf('rec:artwork:%d:similar-ai:%d', $artwork->id, $safeLimit);
$ttl = max(60, (int) config('recommendations.ttl.similar_artworks', 30 * 60));
return Cache::remember($cacheKey, $ttl, function () use ($artwork, $safeLimit): array {
$matches = $this->searchMatchesForArtwork($artwork, $safeLimit + 1);
return $this->resolveMatches($matches, $safeLimit, $artwork->id);
});
}
/**
* @return list<array<string, mixed>>
*/
public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array
{
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
$matches = $this->client->searchByUploadedFile($file, $safeLimit);
return $this->resolveMatches($matches, $safeLimit);
}
/**
* @return array{contents: string, filename: string}|null
*/
private function downloadArtworkImage(Artwork $artwork, string $url): ?array
{
$response = Http::accept('*/*')
->connectTimeout(5)
->timeout(20)
->retry(1, 200, throw: false)
->get($url);
if (! $response->ok()) {
return null;
}
$contents = $response->body();
if ($contents === '') {
return null;
}
$ext = strtolower(ltrim((string) ($artwork->thumb_ext ?: 'webp'), '.'));
return [
'contents' => $contents,
'filename' => sprintf('artwork-%d.%s', (int) $artwork->id, $ext !== '' ? $ext : 'webp'),
];
}
/**
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
*/
private function searchMatchesForArtwork(Artwork $artwork, int $limit): array
{
$url = $this->imageUrl->fromArtwork($artwork);
if ($url === null || $url === '') {
return [];
}
$fileFailure = null;
try {
$payload = $this->downloadArtworkImage($artwork, $url);
if ($payload !== null) {
return $this->client->searchByFileContents($payload['contents'], $payload['filename'], $limit);
}
} catch (Throwable $e) {
$fileFailure = $e;
}
try {
return $this->client->searchByUrl($url, $limit);
} catch (Throwable $e) {
throw $this->normalizeSearchFailure($fileFailure, $e);
}
}
private function normalizeSearchFailure(?Throwable $fileFailure, Throwable $fallbackFailure): RuntimeException
{
if ($fileFailure === null) {
return $fallbackFailure instanceof RuntimeException
? $fallbackFailure
: new RuntimeException($fallbackFailure->getMessage(), 0, $fallbackFailure);
}
return new RuntimeException(sprintf(
'Vector search failed via file endpoint (%s) and URL fallback (%s).',
$fileFailure->getMessage(),
$fallbackFailure->getMessage(),
), 0, $fallbackFailure);
}
/**
* @param list<array{id: int|string, score: float, metadata: array<string, mixed>}> $matches
* @return list<array<string, mixed>>
*/
private function resolveMatches(array $matches, int $limit, ?int $excludeArtworkId = null): array
{
$orderedIds = [];
$scores = [];
foreach ($matches as $match) {
$artworkId = (int) ($match['id'] ?? 0);
if ($artworkId <= 0) {
continue;
}
if ($excludeArtworkId !== null && $artworkId === $excludeArtworkId) {
continue;
}
if (isset($scores[$artworkId])) {
continue;
}
$orderedIds[] = $artworkId;
$scores[$artworkId] = (float) ($match['score'] ?? 0.0);
}
if ($orderedIds === []) {
return [];
}
$artworks = Artwork::query()
->whereIn('id', $orderedIds)
->public()
->published()
->with(['user:id,name', 'user.profile:user_id,avatar_hash'])
->get()
->keyBy('id');
$items = [];
foreach ($orderedIds as $artworkId) {
/** @var Artwork|null $artwork */
$artwork = $artworks->get($artworkId);
if ($artwork === null) {
continue;
}
$items[] = [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb' => $artwork->thumbUrl('sm'),
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'author' => $artwork->user?->name ?? 'Artist',
'author_avatar' => $artwork->user?->profile?->avatar_url,
'author_id' => $artwork->user_id,
'score' => round((float) ($scores[$artworkId] ?? 0.0), 5),
'source' => 'vector_gateway',
];
if (count($items) >= $limit) {
break;
}
}
return $items;
}
}