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> */ 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> */ 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}> */ private function searchMatchesForArtwork(Artwork $artwork, int $limit): array { $this->client->assertCircuitClosed(); /* * Reuse the artwork's already-indexed Qdrant vector when available. * A missing point is expected for older/rebuilt indexes, so retain the * existing image/CLIP fallback below. */ try { $pointMatches = $this->client->searchByPointId((int) $artwork->id, $limit); if ($pointMatches !== null) { return $pointMatches; } } catch (VectorGatewayException $pointFailure) { $this->client->tripIfCircuitWorthy( [$pointFailure], 'similar_ai_point', ['artwork_id' => (int) $artwork->id], ); throw $pointFailure; } $url = $this->imageUrl->fromArtwork($artwork); if ($url === null || $url === '') { return []; } $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}> $matches * @return list> */ 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; } }