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.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'); Http::fake([ 'https://clip.local/embed' => Http::response([ 'embedding' => [3.0, 4.0], ], 200), 'https://vision.local/vectors/upsert/vector' => Http::response([ 'status' => 'ok', ], 200), ]); $contentType = ContentType::query()->create([ 'name' => 'Wallpapers', 'slug' => 'wallpapers', 'description' => '', ]); $category = Category::query()->create([ 'content_type_id' => $contentType->id, 'parent_id' => null, 'name' => 'Abstract', 'slug' => 'abstract', 'description' => '', 'image' => null, 'is_active' => true, 'sort_order' => 10, ]); $artwork = Artwork::factory()->create([ 'hash' => 'aabbccddeeff1122', 'thumb_ext' => 'webp', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(), ]); $artwork->categories()->attach($category->id); $tag = Tag::query()->create(['name' => 'Neon', 'slug' => 'neon']); $artwork->tags()->attach($tag->id, ['source' => 'ai', 'confidence' => 0.91]); $job = new GenerateArtworkEmbeddingJob($artwork->id, 'aabbccddeeff1122'); app()->call([$job, 'handle']); $embedding = ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->first(); $artwork->refresh(); expect($embedding)->not->toBeNull() ->and($embedding?->dim)->toBe(2) ->and($embedding?->is_normalized)->toBeTrue() ->and($artwork->last_vector_indexed_at)->not->toBeNull(); $vector = json_decode((string) $embedding?->embedding_json, true, 512, JSON_THROW_ON_ERROR); expect(round((float) $vector[0], 4))->toBe(0.6) ->and(round((float) $vector[1], 4))->toBe(0.8); Http::assertSent(function (\Illuminate\Http\Client\Request $request): bool { return str_contains($request->url(), 'vision.local/vectors/upsert'); }); Http::assertSentCount(2); }); it('keeps the local embedding when vector upsert fails', 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.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'); Http::fake([ 'https://clip.local/embed' => Http::response([ 'embedding' => [1.0, 2.0, 2.0], ], 200), 'https://vision.local/vectors/upsert/vector' => Http::response([ 'message' => 'gateway error', ], 500), ]); $artwork = Artwork::factory()->create([ 'hash' => '1122334455667788', 'thumb_ext' => 'webp', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(), ]); $job = new GenerateArtworkEmbeddingJob($artwork->id, '1122334455667788'); app()->call([$job, 'handle']); $artwork->refresh(); expect(ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->exists())->toBeTrue() ->and($artwork->last_vector_indexed_at)->toBeNull(); 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; }); });