optimizations
This commit is contained in:
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