optimizations
This commit is contained in:
@@ -8,6 +8,7 @@ use App\Jobs\RegenerateUserRecommendationCacheJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\UserInterestProfile;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -382,7 +383,13 @@ final class PersonalizedFeedService
|
||||
|
||||
/** @var Collection<int, Artwork> $artworks */
|
||||
$artworks = Artwork::query()
|
||||
->with(['user:id,name'])
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,name,slug',
|
||||
'tags:id,name,slug',
|
||||
])
|
||||
->whereIn('id', $ids)
|
||||
->public()
|
||||
->published()
|
||||
@@ -397,14 +404,51 @@ final class PersonalizedFeedService
|
||||
continue;
|
||||
}
|
||||
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$primaryTag = $artwork->tags->sortBy('name')->first();
|
||||
$source = (string) ($item['source'] ?? 'mixed');
|
||||
|
||||
$responseItems[] = [
|
||||
'id' => $artwork->id,
|
||||
'slug' => $artwork->slug,
|
||||
'title' => $artwork->title,
|
||||
'thumbnail_url' => $artwork->thumb_url,
|
||||
'thumbnail_srcset' => $artwork->thumb_srcset,
|
||||
'author' => $artwork->user?->name,
|
||||
'username' => $artwork->user?->username,
|
||||
'author_id' => $artwork->user?->id,
|
||||
'avatar_url' => AvatarUrl::forUser(
|
||||
(int) ($artwork->user?->id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash,
|
||||
64
|
||||
),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||
'category_name' => $primaryCategory?->name ?? '',
|
||||
'category_slug' => $primaryCategory?->slug ?? '',
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
|
||||
'primary_tag' => $primaryTag !== null ? [
|
||||
'id' => (int) $primaryTag->id,
|
||||
'name' => (string) $primaryTag->name,
|
||||
'slug' => (string) $primaryTag->slug,
|
||||
] : null,
|
||||
'tags' => $artwork->tags
|
||||
->sortBy('name')
|
||||
->take(3)
|
||||
->map(static fn ($tag): array => [
|
||||
'id' => (int) $tag->id,
|
||||
'name' => (string) $tag->name,
|
||||
'slug' => (string) $tag->slug,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'score' => (float) ($item['score'] ?? 0.0),
|
||||
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||
'source' => $source,
|
||||
'reason' => $this->buildRecommendationReason($artwork, $source),
|
||||
'algo_version' => $algoVersion,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -422,10 +466,30 @@ final class PersonalizedFeedService
|
||||
'cache_status' => $cacheStatus,
|
||||
'generated_at' => $generatedAt,
|
||||
'total_candidates' => count($items),
|
||||
'engine' => 'v1',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildRecommendationReason(Artwork $artwork, string $source): string
|
||||
{
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categoryName = trim((string) ($primaryCategory?->name ?? ''));
|
||||
|
||||
return match ($source) {
|
||||
'personalized' => $categoryName !== ''
|
||||
? 'Matched to your interest in ' . $categoryName
|
||||
: 'Matched to your recent interests',
|
||||
'cold_start' => $categoryName !== ''
|
||||
? 'Popular in ' . $categoryName . ' right now'
|
||||
: 'Popular with the community right now',
|
||||
'fallback' => $categoryName !== ''
|
||||
? 'Trending in ' . $categoryName
|
||||
: 'Trending across Skinbase',
|
||||
default => 'Picked for you',
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
|
||||
{
|
||||
if ($algoVersion !== null && $algoVersion !== '') {
|
||||
|
||||
76
app/Services/Recommendations/RecommendationFeedResolver.php
Normal file
76
app/Services/Recommendations/RecommendationFeedResolver.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
final class RecommendationFeedResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PersonalizedFeedService $personalizedFeed,
|
||||
private readonly \App\Services\Recommendations\RecommendationServiceV2 $v2Feed,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
|
||||
{
|
||||
if ($this->shouldUseV2($userId, $algoVersion)) {
|
||||
return $this->v2Feed->getFeed($userId, $limit, $cursor, $algoVersion);
|
||||
}
|
||||
|
||||
return $this->personalizedFeed->getFeed($userId, $limit, $cursor, $algoVersion);
|
||||
}
|
||||
|
||||
public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
|
||||
{
|
||||
if ($this->shouldUseV2($userId, $algoVersion)) {
|
||||
$this->v2Feed->regenerateCacheForUser($userId, $algoVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->personalizedFeed->regenerateCacheForUser($userId, $algoVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function inspectDecision(int $userId, ?string $algoVersion = null): array
|
||||
{
|
||||
$requested = trim((string) ($algoVersion ?? ''));
|
||||
$v2AlgoVersion = trim((string) config('discovery.v2.algo_version', 'clip-cosine-v2-adaptive'));
|
||||
$v2Enabled = (bool) config('discovery.v2.enabled', false);
|
||||
$rollout = max(0, min(100, (int) config('discovery.v2.rollout_percentage', 0)));
|
||||
$bucket = abs((int) crc32((string) $userId)) % 100;
|
||||
$forcedByAlgoVersion = $requested !== '' && $requested === $v2AlgoVersion;
|
||||
$usesV2 = $forcedByAlgoVersion
|
||||
|| ($v2Enabled && ($rollout >= 100 || ($rollout > 0 && $bucket < $rollout)));
|
||||
|
||||
$reason = match (true) {
|
||||
$forcedByAlgoVersion => 'explicit_algo_override',
|
||||
! $v2Enabled => 'v2_disabled',
|
||||
$rollout >= 100 => 'full_rollout',
|
||||
$rollout <= 0 => 'rollout_zero',
|
||||
$bucket < $rollout => 'bucket_in_rollout',
|
||||
default => 'bucket_outside_rollout',
|
||||
};
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'requested_algo_version' => $requested !== '' ? $requested : null,
|
||||
'v2_algo_version' => $v2AlgoVersion,
|
||||
'v2_enabled' => $v2Enabled,
|
||||
'rollout_percentage' => $rollout,
|
||||
'bucket' => $bucket,
|
||||
'bucket_in_rollout' => $bucket < $rollout,
|
||||
'forced_by_algo_version' => $forcedByAlgoVersion,
|
||||
'uses_v2' => $usesV2,
|
||||
'selected_engine' => $usesV2 ? 'v2' : 'v1',
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldUseV2(int $userId, ?string $algoVersion = null): bool
|
||||
{
|
||||
return (bool) ($this->inspectDecision($userId, $algoVersion)['uses_v2'] ?? false);
|
||||
}
|
||||
}
|
||||
1348
app/Services/Recommendations/RecommendationServiceV2.php
Normal file
1348
app/Services/Recommendations/RecommendationServiceV2.php
Normal file
File diff suppressed because it is too large
Load Diff
278
app/Services/Recommendations/SessionRecoService.php
Normal file
278
app/Services/Recommendations/SessionRecoService.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Models\UserInterestProfile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
final class SessionRecoService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function applyEvent(
|
||||
int $userId,
|
||||
string $eventType,
|
||||
int $artworkId,
|
||||
?int $categoryId,
|
||||
string $occurredAt,
|
||||
array $meta = []
|
||||
): void {
|
||||
if ($userId <= 0 || $artworkId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $this->readState($userId);
|
||||
$weights = (array) config('discovery.v2.session.event_weights', []);
|
||||
$fallbackWeights = (array) config('discovery.weights', []);
|
||||
$eventWeight = (float) ($weights[$eventType] ?? $fallbackWeights[$eventType] ?? 1.0);
|
||||
$timestamp = strtotime($occurredAt) ?: time();
|
||||
|
||||
$this->upsertSignal($state['signals'], 'artwork:' . $artworkId, $eventWeight, $timestamp);
|
||||
if ($categoryId !== null && $categoryId > 0) {
|
||||
$this->upsertSignal($state['signals'], 'category:' . $categoryId, $eventWeight, $timestamp);
|
||||
}
|
||||
|
||||
foreach ($this->tagSlugsForArtwork($artworkId) as $tagSlug) {
|
||||
$this->upsertSignal($state['signals'], 'tag:' . $tagSlug, $eventWeight, $timestamp);
|
||||
}
|
||||
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
if ($creatorId > 0) {
|
||||
$this->upsertSignal($state['signals'], 'creator:' . $creatorId, $eventWeight, $timestamp);
|
||||
}
|
||||
|
||||
$state['recent_artwork_ids'] = $this->prependUnique($state['recent_artwork_ids'], $artworkId);
|
||||
if ($creatorId > 0) {
|
||||
$state['recent_creator_ids'] = $this->prependUnique($state['recent_creator_ids'], $creatorId);
|
||||
}
|
||||
|
||||
foreach ($this->tagSlugsForArtwork($artworkId) as $tagSlug) {
|
||||
$state['recent_tag_slugs'] = $this->prependUnique($state['recent_tag_slugs'], $tagSlug);
|
||||
}
|
||||
|
||||
if (in_array($eventType, ['view', 'click', 'favorite', 'download', 'dwell', 'scroll'], true)) {
|
||||
$state['seen_artwork_ids'] = $this->prependUnique($state['seen_artwork_ids'], $artworkId, 200);
|
||||
}
|
||||
|
||||
$state['updated_at'] = $timestamp;
|
||||
|
||||
$this->writeState($userId, $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* merged_scores: array<string, float>,
|
||||
* session_scores: array<string, float>,
|
||||
* long_term_scores: array<string, float>,
|
||||
* recent_artwork_ids: array<int, int>,
|
||||
* recent_creator_ids: array<int, int>,
|
||||
* recent_tag_slugs: array<int, string>,
|
||||
* seen_artwork_ids: array<int, int>
|
||||
* }
|
||||
*/
|
||||
public function mergedProfile(int $userId, string $algoVersion): array
|
||||
{
|
||||
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
|
||||
|
||||
$profile = UserInterestProfile::query()
|
||||
->where('user_id', $userId)
|
||||
->where('profile_version', $profileVersion)
|
||||
->where('algo_version', $algoVersion)
|
||||
->first();
|
||||
|
||||
$longTermScores = $this->normalizeScores((array) ($profile?->normalized_scores_json ?? []));
|
||||
$state = $this->readState($userId);
|
||||
$sessionScores = $this->materializeSessionScores($state['signals']);
|
||||
$multiplier = max(0.0, (float) config('discovery.v2.session.merge_multiplier', 1.35));
|
||||
|
||||
$merged = $longTermScores;
|
||||
foreach ($sessionScores as $key => $score) {
|
||||
$merged[$key] = (float) ($merged[$key] ?? 0.0) + ($score * $multiplier);
|
||||
}
|
||||
|
||||
return [
|
||||
'merged_scores' => $this->normalizeScores($merged),
|
||||
'session_scores' => $sessionScores,
|
||||
'long_term_scores' => $longTermScores,
|
||||
'recent_artwork_ids' => array_values(array_map('intval', $state['recent_artwork_ids'])),
|
||||
'recent_creator_ids' => array_values(array_map('intval', $state['recent_creator_ids'])),
|
||||
'recent_tag_slugs' => array_values(array_map('strval', $state['recent_tag_slugs'])),
|
||||
'seen_artwork_ids' => array_values(array_map('intval', $state['seen_artwork_ids'])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function seenArtworkIds(int $userId): array
|
||||
{
|
||||
$state = $this->readState($userId);
|
||||
|
||||
return array_values(array_map('intval', $state['seen_artwork_ids']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function readState(int $userId): array
|
||||
{
|
||||
$key = $this->redisKey($userId);
|
||||
|
||||
try {
|
||||
$raw = Redis::get($key);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SessionRecoService read failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
|
||||
return $this->emptyState();
|
||||
}
|
||||
|
||||
if (! is_string($raw) || $raw === '') {
|
||||
return $this->emptyState();
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return is_array($decoded) ? array_merge($this->emptyState(), $decoded) : $this->emptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
private function writeState(int $userId, array $state): void
|
||||
{
|
||||
$key = $this->redisKey($userId);
|
||||
$ttlSeconds = max(60, (int) config('discovery.v2.session.ttl_seconds', 14400));
|
||||
|
||||
try {
|
||||
Redis::setex($key, $ttlSeconds, (string) json_encode($state, JSON_UNESCAPED_SLASHES));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SessionRecoService write failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $signals
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function materializeSessionScores(array $signals): array
|
||||
{
|
||||
$halfLifeHours = max(0.1, (float) config('discovery.v2.session.half_life_hours', 8));
|
||||
$now = time();
|
||||
$scores = [];
|
||||
|
||||
foreach ($signals as $key => $signal) {
|
||||
if (! is_array($signal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = (float) Arr::get($signal, 'score', 0.0);
|
||||
$updatedAt = (int) Arr::get($signal, 'updated_at', $now);
|
||||
$hoursElapsed = max(0.0, ($now - $updatedAt) / 3600);
|
||||
$decay = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
|
||||
$decayedScore = $score * $decay;
|
||||
|
||||
if ($decayedScore > 0.000001) {
|
||||
$scores[(string) $key] = $decayedScore;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeScores($scores);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $signals
|
||||
*/
|
||||
private function upsertSignal(array &$signals, string $key, float $weight, int $timestamp): void
|
||||
{
|
||||
$maxItems = max(20, (int) config('discovery.v2.session.max_items', 120));
|
||||
$current = (array) ($signals[$key] ?? []);
|
||||
|
||||
$signals[$key] = [
|
||||
'score' => (float) ($current['score'] ?? 0.0) + $weight,
|
||||
'updated_at' => $timestamp,
|
||||
];
|
||||
|
||||
if (count($signals) <= $maxItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
uasort($signals, static fn (array $left, array $right): int => ((int) ($right['updated_at'] ?? 0)) <=> ((int) ($left['updated_at'] ?? 0)));
|
||||
$signals = array_slice($signals, 0, $maxItems, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string> $items
|
||||
* @return array<int, int|string>
|
||||
*/
|
||||
private function prependUnique(array $items, int|string $value, int $maxItems = 40): array
|
||||
{
|
||||
$items = array_values(array_filter($items, static fn (mixed $item): bool => (string) $item !== (string) $value));
|
||||
array_unshift($items, $value);
|
||||
|
||||
return array_slice($items, 0, $maxItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function emptyState(): array
|
||||
{
|
||||
return [
|
||||
'signals' => [],
|
||||
'recent_artwork_ids' => [],
|
||||
'recent_creator_ids' => [],
|
||||
'recent_tag_slugs' => [],
|
||||
'seen_artwork_ids' => [],
|
||||
'updated_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function redisKey(int $userId): string
|
||||
{
|
||||
return 'session_reco:' . $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scores
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function normalizeScores(array $scores): array
|
||||
{
|
||||
$typed = [];
|
||||
foreach ($scores as $key => $score) {
|
||||
if (is_numeric($score) && (float) $score > 0.0) {
|
||||
$typed[(string) $key] = (float) $score;
|
||||
}
|
||||
}
|
||||
|
||||
$sum = array_sum($typed);
|
||||
if ($sum <= 0.0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($typed as $key => $score) {
|
||||
$typed[$key] = $score / $sum;
|
||||
}
|
||||
|
||||
return $typed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function tagSlugsForArtwork(int $artworkId): array
|
||||
{
|
||||
return DB::table('artwork_tag')
|
||||
->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
|
||||
->where('artwork_tag.artwork_id', $artworkId)
|
||||
->pluck('tags.slug')
|
||||
->map(static fn (mixed $slug): string => (string) $slug)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user