diff --git a/app/Jobs/GenerateArtworkEmbeddingJob.php b/app/Jobs/GenerateArtworkEmbeddingJob.php index ecadd2b9..81934b3a 100644 --- a/app/Jobs/GenerateArtworkEmbeddingJob.php +++ b/app/Jobs/GenerateArtworkEmbeddingJob.php @@ -7,9 +7,8 @@ namespace App\Jobs; use App\Models\Artwork; use App\Models\ArtworkEmbedding; use App\Services\Vision\ArtworkEmbeddingClient; -use App\Services\Vision\ArtworkVisionImageUrl; use App\Services\Vision\ArtworkVectorIndexService; -use App\Services\Vision\VectorService; +use App\Services\Vision\ArtworkVisionImageUrl; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -49,8 +48,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue ArtworkEmbeddingClient $client, ArtworkVisionImageUrl $imageUrlBuilder, ArtworkVectorIndexService $vectors, - ): void - { + ): void { if (! (bool) config('recommendations.embedding.enabled', true)) { return; } @@ -78,17 +76,73 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue ->where('model_version', $modelVersion) ->first(); - if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) { + if ( + $existing + && (string) ($existing->source_hash ?? '') === $sourceHash + && $this->vectorIndexCoversEmbedding( + $artwork, + $existing + ) + ) { return; } } - $lockKey = $this->lockKey($artwork->id, $model, $modelVersion); + $lockKey = $this->lockKey( + $artwork->id, + $model, + $modelVersion + ); + if (! $this->acquireLock($lockKey)) { return; } try { + /* + * Another worker may have generated or indexed the + * embedding between our first read and lock acquisition. + * Re-read both records under the lock. + */ + if (! $this->force) { + $artwork->refresh(); + + $existing = ArtworkEmbedding::query() + ->where('artwork_id', $artwork->id) + ->where('model', $model) + ->where('model_version', $modelVersion) + ->first(); + + if ( + $existing + && (string) ($existing->source_hash ?? '') + === $sourceHash + ) { + if ( + $this->vectorIndexCoversEmbedding( + $artwork, + $existing + ) + ) { + return; + } + + $storedVector = $this->decodeStoredVector( + $existing + ); + + if ($storedVector !== []) { + $this->upsertVectorIndex( + $vectors, + $artwork, + $storedVector + ); + + return; + } + } + } + $imageUrl = $imageUrlBuilder->fromArtwork($artwork); if ($imageUrl === null) { return; @@ -121,23 +175,33 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue ] ); - $this->upsertVectorIndex($vectors, $artwork); + $this->upsertVectorIndex( + $vectors, + $artwork, + $normalized + ); } finally { $this->releaseLock($lockKey); } } + /** + * @param array $vector + */ private function upsertVectorIndex( ArtworkVectorIndexService $vectors, - Artwork $artwork - ): void - { + Artwork $artwork, + array $vector + ): void { if (! $vectors->isConfigured()) { return; } try { - $vectors->upsertArtwork($artwork); + $vectors->upsertArtworkVector( + $artwork, + $vector + ); } catch (\Throwable $e) { Log::warning('GenerateArtworkEmbeddingJob vector upsert failed', [ 'artwork_id' => (int) $artwork->id, @@ -146,8 +210,96 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue } } + private function vectorIndexCoversEmbedding( + Artwork $artwork, + ArtworkEmbedding $embedding + ): bool { + $indexedAt = $artwork->last_vector_indexed_at; + $generatedAt = $embedding->generated_at; + + if ($indexedAt === null || $generatedAt === null) { + return false; + } + + return $indexedAt->greaterThanOrEqualTo( + $generatedAt + ); + } + /** - * @param array $vector + * @return array + */ + private function decodeStoredVector( + ArtworkEmbedding $embedding + ): array { + try { + $decoded = json_decode( + (string) $embedding->embedding_json, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (\Throwable) { + return []; + } + + if (! is_array($decoded) || $decoded === []) { + return []; + } + + $vector = []; + + foreach ($decoded as $value) { + if (! is_int($value) && ! is_float($value)) { + return []; + } + + $value = (float) $value; + + if (! is_finite($value)) { + return []; + } + + $vector[] = $value; + } + + $count = count($vector); + + $minDim = max( + 1, + (int) config( + 'recommendations.embedding.min_dim', + 64 + ) + ); + + $maxDim = max( + $minDim, + (int) config( + 'recommendations.embedding.max_dim', + 4096 + ) + ); + + if ( + $count < $minDim + || $count > $maxDim + ) { + return []; + } + + if ( + (int) $embedding->dim > 0 + && $count !== (int) $embedding->dim + ) { + return []; + } + + return $vector; + } + + /** + * @param array $vector * @return array */ private function normalize(array $vector): array @@ -162,12 +314,13 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue } $norm = sqrt($sumSquares); + return array_map(static fn (float $value): float => $value / $norm, $vector); } private function lockKey(int $artworkId, string $model, string $version): string { - return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version; + return 'artwork-embedding:lock:'.$artworkId.':'.$model.':'.$version; } private function acquireLock(string $key): bool @@ -177,6 +330,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue if ($didSet) { Redis::expire($key, 1800); } + return (bool) $didSet; } catch (\Throwable) { return true; diff --git a/app/Services/Vision/AiArtworkVectorSearchService.php b/app/Services/Vision/AiArtworkVectorSearchService.php index b2a6e74d..4a5513cd 100644 --- a/app/Services/Vision/AiArtworkVectorSearchService.php +++ b/app/Services/Vision/AiArtworkVectorSearchService.php @@ -95,13 +95,34 @@ final class AiArtworkVectorSearchService */ 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 []; } - $this->client->assertCircuitClosed(); - $fileFailure = null; $fileGatewayAttempted = false; diff --git a/app/Services/Vision/ArtworkEmbeddingClient.php b/app/Services/Vision/ArtworkEmbeddingClient.php index f64b6f77..21082bda 100644 --- a/app/Services/Vision/ArtworkEmbeddingClient.php +++ b/app/Services/Vision/ArtworkEmbeddingClient.php @@ -26,14 +26,19 @@ final class ArtworkEmbeddingClient $retries = (int) config('recommendations.embedding.retries', 1); $delay = (int) config('recommendations.embedding.retry_delay_ms', 200); + $apiKey = trim( + (string) config('vision.clip.api_key', '') + ); + $response = Http::acceptJson() + ->withHeaders([ + 'X-API-Key' => $apiKey, + ]) ->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, + 'url' => $imageUrl, ]); if (! $response->ok()) { @@ -53,6 +58,8 @@ final class ArtworkEmbeddingClient if (is_array($json) && $this->isNumericVector($json)) { $candidate = $json; + } elseif (is_array($json) && isset($json['vector']) && is_array($json['vector'])) { + $candidate = $json['vector']; } 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'])) { diff --git a/app/Services/Vision/ArtworkVectorIndexService.php b/app/Services/Vision/ArtworkVectorIndexService.php index bb4544d6..88efa885 100644 --- a/app/Services/Vision/ArtworkVectorIndexService.php +++ b/app/Services/Vision/ArtworkVectorIndexService.php @@ -59,6 +59,31 @@ final class ArtworkVectorIndexService return $payload; } + /** + * Store a vector that has already been generated for this artwork. + * + * This avoids downloading and embedding the artwork a second time. + * + * @param array $vector + * @return array{url: string, metadata: array} + */ + public function upsertArtworkVector(Artwork $artwork, array $vector): array + { + $payload = $this->payloadForArtwork($artwork); + + $this->client->upsertByVector( + $vector, + (int) $artwork->id, + $payload['metadata'] + ); + + $artwork->forceFill([ + 'last_vector_indexed_at' => now(), + ])->save(); + + return $payload; + } + /** * @return array{contents: string, filename: string}|null */ diff --git a/app/Services/Vision/VectorGatewayClient.php b/app/Services/Vision/VectorGatewayClient.php index 4c473056..e6b121d4 100644 --- a/app/Services/Vision/VectorGatewayClient.php +++ b/app/Services/Vision/VectorGatewayClient.php @@ -74,6 +74,43 @@ final class VectorGatewayClient return is_array($json) ? $json : []; } + /** + * Store an already-computed vector directly in Qdrant. + * + * @param array $vector + * @param array $metadata + */ + public function upsertByVector(array $vector, int|string $id, array $metadata = []): array + { + $response = $this->postJson( + $this->request(), + $this->url( + (string) config( + 'vision.vector_gateway.upsert_vector_endpoint', + '/vectors/upsert/vector' + ) + ), + [ + 'vector' => array_values($vector), + 'id' => (string) $id, + 'metadata' => $metadata, + ] + ); + + if ($response->failed()) { + throw new RuntimeException( + $this->failureMessage( + 'Vector upsert', + $response + ) + ); + } + + $json = $response->json(); + + return is_array($json) ? $json : []; + } + public function upsertByFileContents(string $contents, string $filename, int|string $id, array $metadata = []): array { $response = $this->request() @@ -224,6 +261,46 @@ final class VectorGatewayClient return is_array($json) ? $json : []; } + /** + * Search using an artwork vector that is already stored in Qdrant. + * + * Returns null when the source point does not exist so the caller may fall + * back to the existing image/CLIP search path. + * + * @return list}>|null + */ + public function searchByPointId(int $artworkId, int $limit = 5): ?array + { + try { + $response = $this->postJson( + $this->searchRequest(), + $this->url( + (string) config( + 'vision.vector_gateway.search_point_endpoint', + '/vectors/search/point' + ) + ), + [ + 'id' => $artworkId, + 'limit' => $this->clampSearchLimit($limit), + ] + ); + } catch (Throwable $e) { + throw $this->classifyThrowable('search_point', $e); + } + + // Missing indexed vector is expected and is not a gateway failure. + if ($response->status() === 404) { + return null; + } + + if ($response->failed()) { + throw $this->classifyHttpFailure('search_point', $response); + } + + return $this->extractMatches($response->json()); + } + /** * Used by upsert/delete — only ever called from queued/console indexing * jobs, so a more generous budget is fine. diff --git a/config/vision.php b/config/vision.php index 4424c9b8..6538a18f 100644 --- a/config/vision.php +++ b/config/vision.php @@ -98,7 +98,9 @@ return [ 'circuit_breaker_seconds' => (int) env('VISION_VECTOR_GATEWAY_CIRCUIT_SECONDS', 30), 'upsert_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT', '/vectors/upsert'), 'upsert_file_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_FILE_ENDPOINT', '/vectors/upsert/file'), + 'upsert_vector_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_VECTOR_ENDPOINT', '/vectors/upsert/vector'), 'search_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT', '/vectors/search'), + 'search_point_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_POINT_ENDPOINT', '/vectors/search/point'), 'search_file_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_FILE_ENDPOINT', '/vectors/search/file'), 'delete_endpoint' => env('VISION_VECTOR_GATEWAY_DELETE_ENDPOINT', '/vectors/delete'), 'collections_endpoint' => env('VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT', '/vectors/collections'), diff --git a/tests/Feature/Vision/AiArtworkPointSearchTest.php b/tests/Feature/Vision/AiArtworkPointSearchTest.php new file mode 100644 index 00000000..e970a1c4 --- /dev/null +++ b/tests/Feature/Vision/AiArtworkPointSearchTest.php @@ -0,0 +1,109 @@ +set('vision.vector_gateway.enabled', true); + config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net'); + config()->set('vision.vector_gateway.api_key', 'test-key'); + config()->set('vision.vector_gateway.search_point_endpoint', '/vectors/search/point'); + config()->set('cdn.files_url', 'https://files.skinbase.org'); + config()->set('app.url', 'https://skinbase.test'); + Cache::flush(); + Storage::fake('public'); +}); + +function pointSearchArtwork(): Artwork +{ + return Artwork::factory()->create([ + 'hash' => 'point-source-hash', + 'thumb_ext' => 'webp', + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subHour(), + ]); +} + +it('uses the stored point and does not fetch or embed the source image', function (): void { + $source = pointSearchArtwork(); + $match = Artwork::factory()->create(['title' => 'Point match']); + + Http::fake([ + 'https://vision.klevze.net/vectors/search/point' => Http::response([ + 'results' => [ + ['id' => $source->id, 'score' => 1.0], + ['id' => $match->id, 'score' => 0.91], + ], + ], 200), + 'https://files.skinbase.org/*' => Http::response('should not download', 500), + 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'should not embed'], 500), + 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'should not search by URL'], 500), + ]); + + getJson('/api/art/' . $source->id . '/similar-ai') + ->assertOk() + ->assertJsonPath('data.0.id', $match->id); + + Http::assertSent(fn ($request): bool => str_contains($request->url(), '/vectors/search/point') + && ($request->data()['id'] ?? null) === $source->id); + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '/vectors/search/file')); + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '/vectors/search') && ! str_contains($request->url(), '/point')); + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'files.skinbase.org')); +}); + +it('falls back to the existing image path only when the point is missing', function (): void { + $source = pointSearchArtwork(); + $match = Artwork::factory()->create(['title' => 'Fallback match']); + + Http::fake([ + 'https://vision.klevze.net/vectors/search/point' => Http::response(['detail' => 'missing'], 404), + 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), + 'https://vision.klevze.net/vectors/search/file' => Http::response([ + 'results' => [['id' => $match->id, 'score' => 0.8]], + ], 200), + ]); + + getJson('/api/art/' . $source->id . '/similar-ai') + ->assertOk() + ->assertJsonPath('data.0.id', $match->id); +}); + +it('does not fall back to CLIP after a point-search server failure', function (): void { + $source = pointSearchArtwork(); + + Http::fake([ + 'https://vision.klevze.net/vectors/search/point' => Http::response(['error' => 'upstream'], 500), + 'https://files.skinbase.org/*' => Http::response('should not download', 500), + 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'should not embed'], 500), + 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'should not search by URL'], 500), + ]); + + getJson('/api/art/' . $source->id . '/similar-ai')->assertStatus(502); + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'files.skinbase.org')); + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '/vectors/search/file')); +}); + +it('does not fall back to CLIP after a point-search timeout', function (): void { + $source = pointSearchArtwork(); + + Http::fake(function ($request) { + if (str_contains($request->url(), '/vectors/search/point')) { + throw new ConnectionException('cURL error 28: Operation timed out'); + } + + throw new RuntimeException('fallback must not be called'); + }); + + getJson('/api/art/' . $source->id . '/similar-ai')->assertStatus(502); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Vision/AiArtworkSearchApiTest.php b/tests/Feature/Vision/AiArtworkSearchApiTest.php index 17fff758..ed9c4635 100644 --- a/tests/Feature/Vision/AiArtworkSearchApiTest.php +++ b/tests/Feature/Vision/AiArtworkSearchApiTest.php @@ -46,6 +46,7 @@ it('returns AI similar artworks for a public artwork', function (): void { Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), + 'https://vision.klevze.net/vectors/search/point' => Http::response(['detail' => 'Point not found'], 404), 'https://vision.klevze.net/vectors/search/file' => Http::response([ 'results' => [ ['id' => $source->id, 'score' => 1.0], @@ -84,6 +85,7 @@ it('falls back to URL search when the file vector endpoint fails for similar-ai' Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), + 'https://vision.klevze.net/vectors/search/point' => Http::response(['detail' => 'Point not found'], 404), 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'missing endpoint'], 404), 'https://vision.klevze.net/vectors/search' => Http::response([ 'results' => [ @@ -141,4 +143,4 @@ it('searches by uploaded image through the vector gateway', function (): void { return $request->url() === 'https://vision.klevze.net/vectors/search/file' && $request->hasHeader('X-API-Key', 'test-key'); }); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Vision/ArtworkVectorGatewayCommandsTest.php b/tests/Feature/Vision/ArtworkVectorGatewayCommandsTest.php index 63130c5f..7f425ad0 100644 --- a/tests/Feature/Vision/ArtworkVectorGatewayCommandsTest.php +++ b/tests/Feature/Vision/ArtworkVectorGatewayCommandsTest.php @@ -18,7 +18,9 @@ beforeEach(function (): void { config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net'); config()->set('vision.vector_gateway.api_key', 'test-key'); config()->set('vision.vector_gateway.upsert_endpoint', '/vectors/upsert'); + config()->set('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file'); config()->set('vision.vector_gateway.search_endpoint', '/vectors/search'); + config()->set('vision.vector_gateway.search_point_endpoint', '/vectors/search/point'); config()->set('vision.image_variant', 'md'); config()->set('cdn.files_url', 'https://files.skinbase.org'); }); @@ -49,35 +51,110 @@ it('indexes artworks into the vector gateway with artwork metadata', function () $artwork->tags()->attach($tag->id, ['source' => 'ai', 'confidence' => 0.88]); Http::fake([ - 'https://vision.klevze.net/vectors/upsert' => Http::response(['ok' => true], 200), + 'https://files.skinbase.org/*' + => Http::response( + 'fake-image-bytes', + 200, + ['Content-Type' => 'image/webp'] + ), + + 'https://vision.klevze.net/vectors/upsert/file' + => Http::response([ + 'id' => $artwork->id, + 'collection' => 'images', + 'dim' => 512, + ], 200), ]); artisan('artworks:vectors-index', ['--limit' => 1]) ->assertSuccessful(); $artwork->refresh(); - expect($artwork->last_vector_indexed_at)->not->toBeNull(); + + expect($artwork->last_vector_indexed_at) + ->not->toBeNull(); + + Http::assertSentCount(2); + + Http::assertSent(function ($request): bool { + return $request->method() === 'GET' + && $request->url() + === 'https://files.skinbase.org/artworks/md/aa/bb/aabbcc112233.webp'; + }); Http::assertSent(function ($request) use ($artwork): bool { - if ($request->url() !== 'https://vision.klevze.net/vectors/upsert') { + if ( + $request->url() + !== 'https://vision.klevze.net/vectors/upsert/file' + ) { return false; } - $payload = json_decode($request->body(), true); + $parts = collect( + $request->data() + )->keyBy('name'); - return $request->hasHeader('X-API-Key', 'test-key') - && is_array($payload) - && ($payload['id'] ?? null) === (string) $artwork->id - && ($payload['url'] ?? null) === 'https://files.skinbase.org/artworks/md/aa/bb/aabbcc112233.webp' - && ($payload['metadata']['content_type'] ?? null) === 'Photography' - && ($payload['metadata']['category'] ?? null) === 'Abstract' - && ($payload['metadata']['tags'] ?? null) === ['skyline'] - && array_key_exists('is_public', $payload['metadata']) - && array_key_exists('is_deleted', $payload['metadata']) - && array_key_exists('is_nsfw', $payload['metadata']) - && array_key_exists('category_id', $payload['metadata']) - && array_key_exists('content_type_id', $payload['metadata']) - && array_key_exists('status', $payload['metadata']); + $idPart = $parts->get('id', []); + $metadataPart = $parts->get( + 'metadata_json', + [] + ); + $filePart = $parts->get('file', []); + + $metadata = json_decode( + (string) ( + $metadataPart['contents'] ?? '' + ), + true + ); + + return $request->hasHeader( + 'X-API-Key', + 'test-key' + ) + && (string) ( + $idPart['contents'] ?? '' + ) === (string) $artwork->id + && (string) ( + $filePart['contents'] ?? '' + ) === 'fake-image-bytes' + && (string) ( + $filePart['filename'] ?? '' + ) === sprintf( + 'artwork-%d.webp', + $artwork->id + ) + && is_array($metadata) + && ($metadata['content_type'] ?? null) + === 'Photography' + && ($metadata['category'] ?? null) + === 'Abstract' + && ($metadata['tags'] ?? null) + === ['skyline'] + && array_key_exists( + 'is_public', + $metadata + ) + && array_key_exists( + 'is_deleted', + $metadata + ) + && array_key_exists( + 'is_nsfw', + $metadata + ) + && array_key_exists( + 'category_id', + $metadata + ) + && array_key_exists( + 'content_type_id', + $metadata + ) + && array_key_exists( + 'status', + $metadata + ); }); }); @@ -113,12 +190,19 @@ it('searches similar artworks through the vector gateway', function (): void { $similar->categories()->attach($category->id); Http::fake([ - 'https://vision.klevze.net/vectors/search' => Http::response([ - 'results' => [ - ['id' => $source->id, 'score' => 1.0], - ['id' => $similar->id, 'score' => 0.9876], - ], - ], 200), + 'https://vision.klevze.net/vectors/search/point' + => Http::response([ + 'results' => [ + [ + 'id' => $source->id, + 'score' => 1.0, + ], + [ + 'id' => $similar->id, + 'score' => 0.9876, + ], + ], + ], 200), ]); artisan('artworks:vectors-search', [ @@ -133,6 +217,25 @@ it('searches similar artworks through the vector gateway', function (): void { 'category' => 'Nature', ]]) ->assertSuccessful(); + + Http::assertSentCount(1); + + Http::assertSent( + function ($request) use ($source): bool { + return $request->url() + === 'https://vision.klevze.net/vectors/search/point' + && ($request->data()['id'] ?? null) + === $source->id; + } + ); + + Http::assertNotSent( + fn ($request): bool => + str_contains( + $request->url(), + 'files.skinbase.org' + ) + ); }); it('can re-upsert only artworks that already have local embeddings', function (): void { @@ -184,28 +287,81 @@ it('can re-upsert only artworks that already have local embeddings', function () $nonEmbeddedArtwork->categories()->attach($category->id); Http::fake([ - 'https://vision.klevze.net/vectors/upsert' => Http::response(['ok' => true], 200), + 'https://files.skinbase.org/*' + => Http::response( + 'fake-image-bytes', + 200, + ['Content-Type' => 'image/webp'] + ), + + 'https://vision.klevze.net/vectors/upsert/file' + => Http::response([ + 'id' => $embeddedArtwork->id, + 'collection' => 'images', + 'dim' => 512, + ], 200), ]); - artisan('artworks:vectors-index', ['--embedded-only' => true, '--limit' => 10]) - ->assertSuccessful(); + artisan('artworks:vectors-index', [ + '--embedded-only' => true, + '--limit' => 10, + ])->assertSuccessful(); $embeddedArtwork->refresh(); $nonEmbeddedArtwork->refresh(); - expect($embeddedArtwork->last_vector_indexed_at)->not->toBeNull() - ->and($nonEmbeddedArtwork->last_vector_indexed_at)->toBeNull(); + expect($embeddedArtwork->last_vector_indexed_at) + ->not->toBeNull() + ->and( + $nonEmbeddedArtwork->last_vector_indexed_at + ) + ->toBeNull(); - Http::assertSentCount(1); - Http::assertSent(function ($request) use ($embeddedArtwork): bool { - if ($request->url() !== 'https://vision.klevze.net/vectors/upsert') { - return false; + /* + * One indexed artwork: + * 1x CDN image download + * 1x /vectors/upsert/file + */ + Http::assertSentCount(2); + + Http::assertSent( + function ($request): bool { + return $request->method() === 'GET' + && $request->url() + === 'https://files.skinbase.org/artworks/md/11/22/112233445566.webp'; } + ); - $payload = json_decode($request->body(), true); + Http::assertSent( + function ($request) use ( + $embeddedArtwork + ): bool { + if ( + $request->url() + !== 'https://vision.klevze.net/vectors/upsert/file' + ) { + return false; + } - return is_array($payload) - && ($payload['id'] ?? null) === (string) $embeddedArtwork->id - && ($payload['url'] ?? null) === 'https://files.skinbase.org/artworks/md/11/22/112233445566.webp'; - }); + $parts = collect( + $request->data() + )->keyBy('name'); + + $idPart = $parts->get('id', []); + $filePart = $parts->get('file', []); + + return (string) ( + $idPart['contents'] ?? '' + ) === (string) $embeddedArtwork->id + && (string) ( + $filePart['contents'] ?? '' + ) === 'fake-image-bytes' + && (string) ( + $filePart['filename'] ?? '' + ) === sprintf( + 'artwork-%d.webp', + $embeddedArtwork->id + ); + } + ); }); diff --git a/tests/Feature/Vision/GenerateArtworkEmbeddingJobTest.php b/tests/Feature/Vision/GenerateArtworkEmbeddingJobTest.php index 5ea81833..7d3b5c40 100644 --- a/tests/Feature/Vision/GenerateArtworkEmbeddingJobTest.php +++ b/tests/Feature/Vision/GenerateArtworkEmbeddingJobTest.php @@ -21,15 +21,14 @@ it('persists a normalized embedding and upserts the artwork to the vector gatewa config()->set('vision.vector_gateway.enabled', true); config()->set('vision.vector_gateway.base_url', 'https://vision.local'); config()->set('vision.vector_gateway.api_key', 'test-key'); - config()->set('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file'); + config()->set('vision.vector_gateway.upsert_vector_endpoint', '/vectors/upsert/vector'); config()->set('cdn.files_url', 'https://files.local'); Http::fake([ 'https://clip.local/embed' => Http::response([ 'embedding' => [3.0, 4.0], ], 200), - 'https://files.local/*' => Http::response('fake-image-bytes', 200), - 'https://vision.local/vectors/upsert/file' => Http::response([ + 'https://vision.local/vectors/upsert/vector' => Http::response([ 'status' => 'ok', ], 200), ]); @@ -82,7 +81,7 @@ it('persists a normalized embedding and upserts the artwork to the vector gatewa return str_contains($request->url(), 'vision.local/vectors/upsert'); }); - Http::assertSentCount(3); + Http::assertSentCount(2); }); it('keeps the local embedding when vector upsert fails', function () { @@ -93,15 +92,14 @@ it('keeps the local embedding when vector upsert fails', function () { config()->set('vision.vector_gateway.enabled', true); config()->set('vision.vector_gateway.base_url', 'https://vision.local'); config()->set('vision.vector_gateway.api_key', 'test-key'); - config()->set('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file'); + config()->set('vision.vector_gateway.upsert_vector_endpoint', '/vectors/upsert/vector'); config()->set('cdn.files_url', 'https://files.local'); Http::fake([ 'https://clip.local/embed' => Http::response([ 'embedding' => [1.0, 2.0, 2.0], ], 200), - 'https://files.local/*' => Http::response('fake-image-bytes', 200), - 'https://vision.local/vectors/upsert/file' => Http::response([ + 'https://vision.local/vectors/upsert/vector' => Http::response([ 'message' => 'gateway error', ], 500), ]); @@ -122,5 +120,286 @@ it('keeps the local embedding when vector upsert fails', function () { expect(ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->exists())->toBeTrue() ->and($artwork->last_vector_indexed_at)->toBeNull(); - Http::assertSentCount(3); + Http::assertSentCount(2); +}); + + +it('reuses the stored embedding when vector indexing is retried', function () { + config()->set('recommendations.embedding.enabled', true); + config()->set('recommendations.embedding.endpoint', '/embed'); + config()->set('recommendations.embedding.min_dim', 2); + config()->set('vision.clip.base_url', 'https://clip.local'); + config()->set('vision.clip.api_key', 'clip-key'); + config()->set('vision.vector_gateway.enabled', true); + config()->set('vision.vector_gateway.base_url', 'https://vision.local'); + config()->set('vision.vector_gateway.api_key', 'test-key'); + config()->set( + 'vision.vector_gateway.upsert_vector_endpoint', + '/vectors/upsert/vector' + ); + config()->set('cdn.files_url', 'https://files.local'); + + $clipCalls = 0; + $vectorCalls = 0; + + Http::fake(function ( + \Illuminate\Http\Client\Request $request + ) use (&$clipCalls, &$vectorCalls) { + if ( + $request->url() + === 'https://clip.local/embed' + ) { + $clipCalls++; + + return Http::response([ + 'vector' => [3.0, 4.0], + ], 200); + } + + if ( + $request->url() + === 'https://vision.local/vectors/upsert/vector' + ) { + $vectorCalls++; + + if ($vectorCalls === 1) { + return Http::response([ + 'message' => 'temporary gateway error', + ], 500); + } + + return Http::response([ + 'id' => 123, + 'collection' => 'images', + 'dim' => 2, + ], 200); + } + + return Http::response([ + 'error' => 'unexpected request', + ], 500); + }); + + $artwork = Artwork::factory()->create([ + 'hash' => '9988776655443322', + 'thumb_ext' => 'webp', + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $first = new GenerateArtworkEmbeddingJob( + $artwork->id, + '9988776655443322' + ); + + app()->call([ + $first, + 'handle', + ]); + + $artwork->refresh(); + + expect( + ArtworkEmbedding::query() + ->where('artwork_id', $artwork->id) + ->exists() + )->toBeTrue() + ->and($artwork->last_vector_indexed_at) + ->toBeNull() + ->and($clipCalls) + ->toBe(1) + ->and($vectorCalls) + ->toBe(1); + + $second = new GenerateArtworkEmbeddingJob( + $artwork->id, + '9988776655443322' + ); + + app()->call([ + $second, + 'handle', + ]); + + $artwork->refresh(); + + expect($artwork->last_vector_indexed_at) + ->not->toBeNull() + ->and($clipCalls) + ->toBe(1) + ->and($vectorCalls) + ->toBe(2); + + Http::assertSent(function ( + \Illuminate\Http\Client\Request $request + ): bool { + if ( + $request->url() + !== 'https://vision.local/vectors/upsert/vector' + ) { + return false; + } + + $payload = $request->data(); + + return isset($payload['vector']) + && count($payload['vector']) === 2 + && round( + (float) $payload['vector'][0], + 4 + ) === 0.6 + && round( + (float) $payload['vector'][1], + 4 + ) === 0.8; + }); +}); + + +it('recovers when the vector marker predates the stored embedding', function () { + config()->set('recommendations.embedding.enabled', true); + config()->set('recommendations.embedding.endpoint', '/embed'); + config()->set('recommendations.embedding.min_dim', 2); + + config()->set( + 'vision.clip.base_url', + 'https://clip.local' + ); + + config()->set( + 'vision.clip.api_key', + 'clip-key' + ); + + config()->set( + 'vision.vector_gateway.enabled', + true + ); + + config()->set( + 'vision.vector_gateway.base_url', + 'https://vision.local' + ); + + config()->set( + 'vision.vector_gateway.api_key', + 'test-key' + ); + + config()->set( + 'vision.vector_gateway.upsert_vector_endpoint', + '/vectors/upsert/vector' + ); + + $clipCalls = 0; + $vectorCalls = 0; + + Http::fake(function ( + \Illuminate\Http\Client\Request $request + ) use (&$clipCalls, &$vectorCalls) { + if ( + $request->url() + === 'https://clip.local/embed' + ) { + $clipCalls++; + + return Http::response([ + 'vector' => [9.0, 9.0], + ], 200); + } + + if ( + $request->url() + === 'https://vision.local/vectors/upsert/vector' + ) { + $vectorCalls++; + + return Http::response([ + 'id' => 123, + 'collection' => 'images', + 'dim' => 2, + ], 200); + } + + return Http::response([ + 'error' => 'unexpected request', + ], 500); + }); + + $oldMarker = now()->subMinutes(10); + $embeddingGeneratedAt = now()->subMinute(); + + $artwork = Artwork::factory()->create([ + 'hash' => 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', + 'thumb_ext' => 'webp', + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + 'last_vector_indexed_at' => $oldMarker, + ]); + + ArtworkEmbedding::query()->create([ + 'artwork_id' => $artwork->id, + 'model' => 'clip', + 'model_version' => 'v1', + 'algo_version' => 'clip-cosine-v1', + 'dim' => 2, + 'embedding_json' => json_encode( + [0.6, 0.8], + JSON_THROW_ON_ERROR + ), + 'source_hash' => 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', + 'is_normalized' => true, + 'generated_at' => $embeddingGeneratedAt, + 'meta' => ['source' => 'clip'], + ]); + + $job = new GenerateArtworkEmbeddingJob( + $artwork->id, + 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899' + ); + + app()->call([ + $job, + 'handle', + ]); + + $artwork->refresh(); + + expect($clipCalls) + ->toBe(0) + ->and($vectorCalls) + ->toBe(1) + ->and($artwork->last_vector_indexed_at) + ->not->toBeNull() + ->and( + $artwork->last_vector_indexed_at + ->greaterThan($oldMarker) + ) + ->toBeTrue(); + + Http::assertSent(function ( + \Illuminate\Http\Client\Request $request + ): bool { + if ( + $request->url() + !== 'https://vision.local/vectors/upsert/vector' + ) { + return false; + } + + $payload = $request->data(); + + return isset($payload['vector']) + && count($payload['vector']) === 2 + && round( + (float) $payload['vector'][0], + 4 + ) === 0.6 + && round( + (float) $payload['vector'][1], + 4 + ) === 0.8; + }); }); diff --git a/tests/Feature/Vision/VectorGatewaySearchCircuitTest.php b/tests/Feature/Vision/VectorGatewaySearchCircuitTest.php index 51497847..86c36289 100644 --- a/tests/Feature/Vision/VectorGatewaySearchCircuitTest.php +++ b/tests/Feature/Vision/VectorGatewaySearchCircuitTest.php @@ -56,6 +56,7 @@ function fakeSuccessfulUrlSearch(Artwork $source, Artwork $match): array { return [ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), + 'https://vision.klevze.net/vectors/search/point' => Http::response(['detail' => 'Point not found'], 404), 'https://vision.klevze.net/vectors/search' => Http::response([ 'results' => [ ['id' => $source->id, 'score' => 1.0], @@ -70,6 +71,7 @@ it('does not call URL search when file search succeeds and leaves the circuit cl Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), + 'https://vision.klevze.net/vectors/search/point' => Http::response(['detail' => 'Point not found'], 404), 'https://vision.klevze.net/vectors/search/file' => Http::response([ 'results' => [ ['id' => $source->id, 'score' => 1.0], @@ -143,6 +145,7 @@ it('opens the circuit when both search strategies fail transiently', function () Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), + 'https://vision.klevze.net/vectors/search/point' => Http::response(['detail' => 'Point not found'], 404), 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'down'], 503), 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'down'], 503), ]); @@ -353,6 +356,7 @@ it('serves similar-ai from cache without contacting the gateway', function (): v Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), + 'https://vision.klevze.net/vectors/search/point' => Http::response(['detail' => 'Point not found'], 404), 'https://vision.klevze.net/vectors/search/file' => Http::response([ 'results' => [ ['id' => $source->id, 'score' => 1.0], diff --git a/tests/Feature/Vision/VectorGatewaySearchLimitTest.php b/tests/Feature/Vision/VectorGatewaySearchLimitTest.php index 295d821c..924b2dd8 100644 --- a/tests/Feature/Vision/VectorGatewaySearchLimitTest.php +++ b/tests/Feature/Vision/VectorGatewaySearchLimitTest.php @@ -38,6 +38,9 @@ function fakeFileSearchOk(): void { Http::fake(function ($request) { $url = $request->url(); + if (str_contains($url, '/vectors/search/point')) { + return Http::response(['detail' => 'Point not found'], 404); + } if (str_contains($url, '/vectors/search')) { return Http::response(['results' => []], 200); }