263 lines
9.4 KiB
PHP
263 lines
9.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkEmbedding;
|
|
use App\Models\Tag;
|
|
use App\Models\User;
|
|
use App\Models\UserRecommendationCache;
|
|
use App\Services\Recommendations\SessionRecoService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use function Pest\Laravel\actingAs;
|
|
use function Pest\Laravel\getJson;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('can serve the feed through the v2 selector path', function () {
|
|
config()->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);
|
|
});
|