Keep similar-ai from tripping the global circuit on a lone URL 502, clamp Qdrant search to 100, and add Server-Timing plus slow-request logging. Studio shared props, Academy S3 exists caching, heat chunking, and Redis/scheduler hygiene stay in this rollout.
253 lines
8.0 KiB
PHP
253 lines
8.0 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 Throwable;
|
|
|
|
final class AiArtworkVectorSearchService
|
|
{
|
|
/** qdrant-svc /vectors/search* reject limit > 100. */
|
|
private const VECTOR_GATEWAY_MAX_SEARCH_RESULTS = 100;
|
|
|
|
/**
|
|
* Public similar-ai may request at most 99 items because we fetch one extra
|
|
* candidate so the source artwork can be excluded from the result set.
|
|
*/
|
|
private const MAX_ARTWORK_SIMILAR_RESULTS = 99;
|
|
|
|
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_ARTWORK_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 {
|
|
$gatewayLimit = min(self::VECTOR_GATEWAY_MAX_SEARCH_RESULTS, $safeLimit + 1);
|
|
$matches = $this->searchMatchesForArtwork($artwork, $gatewayLimit);
|
|
|
|
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::VECTOR_GATEWAY_MAX_SEARCH_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
|
|
{
|
|
// Runs synchronously in the web request path — keep the budget tight
|
|
// so a slow origin can't pin an FPM worker for 20s+.
|
|
$response = Http::accept('*/*')
|
|
->connectTimeout(2)
|
|
->timeout(6)
|
|
->retry(0)
|
|
->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 [];
|
|
}
|
|
|
|
$this->client->assertCircuitClosed();
|
|
|
|
$fileFailure = null;
|
|
$fileGatewayAttempted = false;
|
|
|
|
try {
|
|
$payload = $this->downloadArtworkImage($artwork, $url);
|
|
if ($payload !== null) {
|
|
$fileGatewayAttempted = true;
|
|
return $this->client->searchByFileContents($payload['contents'], $payload['filename'], $limit);
|
|
}
|
|
} catch (Throwable $e) {
|
|
$fileFailure = $e;
|
|
}
|
|
|
|
try {
|
|
return $this->client->searchByUrl($url, $limit);
|
|
} catch (Throwable $fallbackFailure) {
|
|
if ($this->shouldTripSimilarAiCircuit($fileGatewayAttempted, $fileFailure, $fallbackFailure)) {
|
|
$this->client->tripIfCircuitWorthy(
|
|
[$fileFailure, $fallbackFailure],
|
|
'similar_ai',
|
|
['artwork_id' => (int) $artwork->id],
|
|
);
|
|
}
|
|
|
|
throw $this->normalizeSearchFailure($fileFailure, $fallbackFailure);
|
|
}
|
|
}
|
|
|
|
private function normalizeSearchFailure(?Throwable $fileFailure, Throwable $fallbackFailure): VectorGatewayException
|
|
{
|
|
if ($fallbackFailure instanceof VectorGatewayException && $fileFailure === null) {
|
|
return $fallbackFailure;
|
|
}
|
|
|
|
$status = $fallbackFailure instanceof VectorGatewayException
|
|
? $fallbackFailure->httpStatus
|
|
: null;
|
|
$circuitWorthy = $this->isCircuitWorthyFailure($fileFailure)
|
|
&& $this->isCircuitWorthyFailure($fallbackFailure);
|
|
|
|
if ($fileFailure === null) {
|
|
return $fallbackFailure instanceof VectorGatewayException
|
|
? $fallbackFailure
|
|
: new VectorGatewayException(
|
|
'Vector gateway search failed.',
|
|
'search_url',
|
|
$status,
|
|
$this->isCircuitWorthyFailure($fallbackFailure),
|
|
$fallbackFailure,
|
|
);
|
|
}
|
|
|
|
return new VectorGatewayException(
|
|
'Vector gateway search failed.',
|
|
'search_combined',
|
|
$status,
|
|
$circuitWorthy,
|
|
$fallbackFailure,
|
|
);
|
|
}
|
|
|
|
private function isCircuitWorthyFailure(Throwable $failure): bool
|
|
{
|
|
return $failure instanceof VectorGatewayException && $failure->circuitWorthy;
|
|
}
|
|
|
|
private function shouldTripSimilarAiCircuit(
|
|
bool $fileGatewayAttempted,
|
|
?Throwable $fileFailure,
|
|
Throwable $fallbackFailure,
|
|
): bool {
|
|
return $fileGatewayAttempted
|
|
&& $fileFailure instanceof VectorGatewayException
|
|
&& $fileFailure->operation === 'search_file'
|
|
&& $fileFailure->circuitWorthy
|
|
&& $fallbackFailure instanceof VectorGatewayException
|
|
&& $fallbackFailure->operation === 'search_url'
|
|
&& $fallbackFailure->circuitWorthy;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|