client->isConfigured(); } /** * @return list> */ 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> */ 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}> */ 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}> $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; } }