optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -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 !== '') {

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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();
}
}