$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, * session_scores: array, * long_term_scores: array, * recent_artwork_ids: array, * recent_creator_ids: array, * recent_tag_slugs: array, * seen_artwork_ids: array * } */ 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 */ public function seenArtworkIds(int $userId): array { $state = $this->readState($userId); return array_values(array_map('intval', $state['seen_artwork_ids'])); } /** * @return array */ 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 $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 $signals * @return array */ 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 $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 $items * @return array */ 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 */ 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 $scores * @return array */ 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 */ 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(); } }