Current state with latest updates

This commit is contained in:
2026-08-23 13:28:34 +02:00
parent 5af95f6533
commit f52879edbb
74 changed files with 5174 additions and 960 deletions
@@ -58,10 +58,12 @@ final class AiArtworkVectorSearchService
*/
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(5)
->timeout(20)
->retry(1, 200, throw: false)
->connectTimeout(2)
->timeout(6)
->retry(0)
->get($url);
if (! $response->ok()) {
@@ -91,6 +93,10 @@ final class AiArtworkVectorSearchService
return [];
}
if ($this->client->circuitOpen()) {
throw new RuntimeException('Vector gateway temporarily unavailable (circuit open after a recent failure).');
}
$fileFailure = null;
try {
+88 -17
View File
@@ -7,11 +7,14 @@ namespace App\Services\Vision;
use Illuminate\Http\UploadedFile;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class VectorGatewayClient
{
private const CIRCUIT_KEY = 'vision.vector_gateway.circuit_open';
public function isConfigured(): bool
{
return (bool) config('vision.vector_gateway.enabled', true)
@@ -19,9 +22,25 @@ final class VectorGatewayClient
&& $this->apiKey() !== '';
}
/**
* True while the gateway is presumed down after a recent failure — callers
* on the request path should skip the network round trip entirely.
*/
public function circuitOpen(): bool
{
return Cache::has(self::CIRCUIT_KEY);
}
public function tripCircuit(): void
{
$seconds = max(1, (int) config('vision.vector_gateway.circuit_breaker_seconds', 30));
Cache::put(self::CIRCUIT_KEY, true, $seconds);
}
public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array
{
$response = $this->postJson(
$this->request(),
$this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')),
[
'url' => $imageUrl,
@@ -65,15 +84,24 @@ final class VectorGatewayClient
*/
public function searchByUrl(string $imageUrl, int $limit = 5): array
{
$response = $this->postJson(
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
[
'url' => $imageUrl,
'limit' => max(1, $limit),
]
);
$this->guardCircuit();
try {
$response = $this->postJson(
$this->searchRequest(),
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
[
'url' => $imageUrl,
'limit' => max(1, $limit),
]
);
} catch (\Throwable $e) {
$this->tripCircuit();
throw $e;
}
if ($response->failed()) {
$this->tripCircuit();
throw new RuntimeException($this->failureMessage('Vector search', $response));
}
@@ -85,16 +113,24 @@ final class VectorGatewayClient
*/
public function searchByFileContents(string $contents, string $filename, int $limit = 5): array
{
$response = $this->request()
->attach('file', $contents, $filename)
->post(
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
[
'limit' => max(1, $limit),
]
);
$this->guardCircuit();
try {
$response = $this->searchRequest()
->attach('file', $contents, $filename)
->post(
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
[
'limit' => max(1, $limit),
]
);
} catch (\Throwable $e) {
$this->tripCircuit();
throw $e;
}
if ($response->failed()) {
$this->tripCircuit();
throw new RuntimeException($this->failureMessage('Vector search', $response));
}
@@ -122,6 +158,7 @@ final class VectorGatewayClient
public function deleteByIds(array $ids): array
{
$response = $this->postJson(
$this->request(),
$this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')),
[
'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)),
@@ -137,6 +174,10 @@ final class VectorGatewayClient
return is_array($json) ? $json : [];
}
/**
* Used by upsert/delete — only ever called from queued/console indexing
* jobs, so a more generous budget is fine.
*/
private function request(): PendingRequest
{
if (! $this->isConfigured()) {
@@ -156,12 +197,42 @@ final class VectorGatewayClient
);
}
/**
* Used by search — runs synchronously inside web requests, so it gets a
* tight timeout budget and no retries to avoid pinning FPM workers.
*/
private function searchRequest(): PendingRequest
{
if (! $this->isConfigured()) {
throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
}
return Http::acceptJson()
->withHeaders([
'X-API-Key' => $this->apiKey(),
])
->connectTimeout(max(1, (int) config('vision.vector_gateway.search_connect_timeout_seconds', 2)))
->timeout(max(1, (int) config('vision.vector_gateway.search_timeout_seconds', 6)))
->retry(
max(0, (int) config('vision.vector_gateway.search_retries', 0)),
max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)),
throw: false,
);
}
private function guardCircuit(): void
{
if ($this->circuitOpen()) {
throw new RuntimeException('Vector gateway temporarily unavailable (circuit open after a recent failure).');
}
}
/**
* @param array<string, mixed> $payload
*/
private function postJson(string $url, array $payload): Response
private function postJson(PendingRequest $request, string $url, array $payload): Response
{
$response = $this->request()->post($url, $payload);
$response = $request->post($url, $payload);
if (! $response instanceof Response) {
throw new RuntimeException('Vector gateway request did not return an HTTP response.');