set('discovery.v2.enabled', true); config()->set('discovery.v2.rollout_percentage', 100); config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive'); $user = User::factory()->create(); $creator = User::factory()->create(); $artwork = Artwork::factory()->create([ 'user_id' => $creator->id, 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subHour(), 'trending_score_1h' => 5, 'trending_score_24h' => 10, 'trending_score_7d' => 20, ]); $tag = Tag::query()->create(['name' => 'Abstract', 'slug' => 'abstract']); DB::table('artwork_tag')->insert([ 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'source' => 'user', 'created_at' => now(), ]); DB::table('artwork_stats')->insert([ 'artwork_id' => $artwork->id, 'views' => 150, 'downloads' => 20, 'favorites' => 15, 'comments_count' => 5, 'shares_count' => 2, 'views_24h' => 150, 'views_7d' => 150, 'downloads_24h' => 20, 'downloads_7d' => 20, 'shares_24h' => 2, 'comments_24h' => 5, 'favourites_24h' => 15, 'views_1h' => 40, 'downloads_1h' => 5, 'favourites_1h' => 4, 'comments_1h' => 2, 'shares_1h' => 1, 'ranking_score' => 50, 'engagement_velocity' => 12, 'heat_score' => 30, 'rating_avg' => 0, 'rating_count' => 0, ]); UserRecommendationCache::query()->create([ 'user_id' => $user->id, 'algo_version' => 'clip-cosine-v2-adaptive', 'cache_version' => 'cache-v2', 'recommendations_json' => [ 'items' => [ ['artwork_id' => $artwork->id, 'score' => 1.2, 'source' => 'trending', 'layer_sources' => ['trending']], ], ], 'generated_at' => now(), 'expires_at' => now()->addMinutes(10), ]); actingAs($user); $response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2'); $response->assertOk(); $response->assertJsonPath('meta.engine', 'v2'); $response->assertJsonPath('meta.algo_version', 'clip-cosine-v2-adaptive'); $response->assertJsonPath('meta.local_embedding_count', 0); $response->assertJsonPath('meta.vector_indexed_count', 0); $response->assertJsonPath('data.0.primary_tag.slug', 'abstract'); $response->assertJsonPath('data.0.has_local_embedding', false); $response->assertJsonPath('data.0.vector_indexed_at', null); $response->assertJsonPath('data.0.ranking_signals.local_embedding_present', false); $response->assertJsonPath('data.0.ranking_signals.vector_indexed_at', null); expect((array) $response->json('data'))->toHaveCount(1); }); it('boosts vector-similar candidates in the v3 hybrid feed', function () { config()->set('discovery.v2.enabled', true); config()->set('discovery.v2.rollout_percentage', 100); config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive'); config()->set('discovery.v3.enabled', true); config()->set('discovery.v3.vector_similarity_weight', 2.0); config()->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_endpoint', '/vectors/search'); config()->set('cdn.files_url', 'https://files.skinbase.org'); $user = User::factory()->create(); $creator = User::factory()->create(); $seedArtwork = Artwork::factory()->create([ 'user_id' => $creator->id, 'hash' => 'aabbcc112233', 'thumb_ext' => 'webp', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDays(10), ]); $vectorMatch = Artwork::factory()->create([ 'user_id' => $creator->id, 'hash' => 'ddeeff445566', 'thumb_ext' => 'webp', 'title' => 'Vector winner', 'slug' => 'vector-winner', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDays(10), 'trending_score_1h' => 5, 'trending_score_24h' => 5, 'trending_score_7d' => 5, 'last_vector_indexed_at' => now()->subMinutes(5), ]); $trendingLeader = Artwork::factory()->create([ 'user_id' => $creator->id, 'hash' => '778899001122', 'thumb_ext' => 'webp', 'title' => 'Trending leader', 'slug' => 'trending-leader', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDays(10), 'trending_score_1h' => 30, 'trending_score_24h' => 30, 'trending_score_7d' => 30, ]); $sectionArtwork = Artwork::factory()->create([ 'user_id' => $creator->id, 'hash' => '334455667788', 'thumb_ext' => 'webp', 'title' => 'Section artwork', 'slug' => 'section-artwork', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDays(10), 'trending_score_1h' => 6, 'trending_score_24h' => 6, 'trending_score_7d' => 6, ]); ArtworkEmbedding::query()->create([ 'artwork_id' => $vectorMatch->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' => 'ddeeff445566', 'is_normalized' => true, 'generated_at' => now(), 'meta' => ['source' => 'clip'], ]); foreach ([$seedArtwork, $vectorMatch, $trendingLeader, $sectionArtwork] as $artwork) { DB::table('artwork_stats')->insert([ 'artwork_id' => $artwork->id, 'views' => 100, 'downloads' => 10, 'favorites' => 8, 'comments_count' => 3, 'shares_count' => 1, 'views_24h' => 100, 'views_7d' => 100, 'downloads_24h' => 10, 'downloads_7d' => 10, 'shares_24h' => 1, 'comments_24h' => 3, 'favourites_24h' => 8, 'views_1h' => 20, 'downloads_1h' => 2, 'favourites_1h' => 2, 'comments_1h' => 1, 'shares_1h' => 1, 'ranking_score' => 25, 'engagement_velocity' => 8, 'heat_score' => 15, 'rating_avg' => 0, 'rating_count' => 0, ]); } Http::fake(function ($request) use ($seedArtwork, $vectorMatch, $sectionArtwork) { $payload = json_decode($request->body(), true); $url = (string) ($payload['url'] ?? ''); if (str_contains($url, 'aabbcc112233')) { return Http::response([ 'results' => [ ['id' => $seedArtwork->id, 'score' => 1.0], ['id' => $vectorMatch->id, 'score' => 0.98], ], ], 200); } if (str_contains($url, 'ddeeff445566')) { return Http::response([ 'results' => [ ['id' => $vectorMatch->id, 'score' => 1.0], ['id' => $sectionArtwork->id, 'score' => 0.91], ], ], 200); } return Http::response(['results' => []], 200); }); app(SessionRecoService::class)->applyEvent( userId: $user->id, eventType: 'view', artworkId: $seedArtwork->id, categoryId: null, occurredAt: now()->toIso8601String(), meta: [] ); actingAs($user); $response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2'); $response->assertOk(); $response->assertJsonPath('meta.engine', 'v2'); $response->assertJsonPath('meta.vector_influenced_count', 1); $response->assertJsonPath('meta.local_embedding_count', 1); $response->assertJsonPath('meta.vector_indexed_count', 1); $response->assertJsonPath('data.0.id', $vectorMatch->id); $response->assertJsonPath('data.0.source', 'vector'); $response->assertJsonPath('data.0.reason', 'Visually similar to art you engaged with'); $response->assertJsonPath('data.0.vector_influenced', true); $response->assertJsonPath('data.0.has_local_embedding', true); expect($response->json('data.0.vector_indexed_at'))->not->toBeNull(); $response->assertJsonPath('data.0.ranking_signals.vector_similarity_score', 0.98); $response->assertJsonPath('data.0.ranking_signals.local_embedding_present', true); expect($response->json('data.0.ranking_signals.vector_indexed_at'))->not->toBeNull(); $response->assertJsonPath('sections.0.key', 'similar_style'); $response->assertJsonPath('sections.1.key', 'you_may_also_like'); $response->assertJsonPath('sections.2.key', 'visually_related'); $response->assertJsonPath('sections.0.items.0.id', $sectionArtwork->id); $response->assertJsonPath('sections.2.items.0.id', $sectionArtwork->id); });