*/ public function embed(string $imageUrl, int $artworkId, string $sourceHash): array { $base = trim((string) config('vision.clip.base_url', '')); if ($base === '') { return []; } $endpoint = (string) config('recommendations.embedding.endpoint', '/embed'); $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/'); $timeout = (int) config('recommendations.embedding.timeout_seconds', 8); $connectTimeout = (int) config('recommendations.embedding.connect_timeout_seconds', 2); $retries = (int) config('recommendations.embedding.retries', 1); $delay = (int) config('recommendations.embedding.retry_delay_ms', 200); $response = Http::acceptJson() ->connectTimeout(max(1, $connectTimeout)) ->timeout(max(1, $timeout)) ->retry(max(0, $retries), max(0, $delay), throw: false) ->post($url, [ 'image_url' => $imageUrl, 'artwork_id' => $artworkId, 'hash' => $sourceHash, ]); if (! $response->ok()) { return []; } return $this->extractEmbedding($response->json()); } /** * @param mixed $json * @return array */ private function extractEmbedding(mixed $json): array { $candidate = null; if (is_array($json) && $this->isNumericVector($json)) { $candidate = $json; } elseif (is_array($json) && isset($json['embedding']) && is_array($json['embedding'])) { $candidate = $json['embedding']; } elseif (is_array($json) && isset($json['data']['embedding']) && is_array($json['data']['embedding'])) { $candidate = $json['data']['embedding']; } if (! is_array($candidate) || ! $this->isNumericVector($candidate)) { return []; } $vector = array_map(static fn ($value): float => (float) $value, $candidate); $dim = count($vector); $minDim = (int) config('recommendations.embedding.min_dim', 64); $maxDim = (int) config('recommendations.embedding.max_dim', 4096); if ($dim < $minDim || $dim > $maxDim) { return []; } return $vector; } /** * @param array $arr */ private function isNumericVector(array $arr): bool { if ($arr === []) { return false; } foreach ($arr as $value) { if (! is_numeric($value)) { return false; } } return true; } }