Files
SkinbaseNova/app/Services/Recommendations/UserInterestProfileService.php
2026-02-14 15:14:12 +01:00

163 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\UserInterestProfile;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
final class UserInterestProfileService
{
/**
* @param array<string, mixed> $eventMeta
*/
public function applyEvent(
int $userId,
string $eventType,
int $artworkId,
?int $categoryId,
CarbonInterface $occurredAt,
string $eventId,
string $algoVersion,
array $eventMeta = []
): void {
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
$halfLifeHours = (float) config('discovery.decay.half_life_hours', 72);
$weightMap = (array) config('discovery.weights', []);
$eventWeight = (float) ($weightMap[$eventType] ?? 1.0);
DB::transaction(function () use (
$userId,
$categoryId,
$artworkId,
$occurredAt,
$eventId,
$algoVersion,
$profileVersion,
$halfLifeHours,
$eventWeight,
$eventMeta
): void {
$profile = UserInterestProfile::query()
->where('user_id', $userId)
->where('profile_version', $profileVersion)
->where('algo_version', $algoVersion)
->lockForUpdate()
->first();
$rawScores = $profile !== null ? (array) ($profile->raw_scores_json ?? []) : [];
$lastEventAt = $profile?->last_event_at;
if ($lastEventAt !== null && $occurredAt->greaterThan($lastEventAt)) {
$hours = max(0.0, (float) $lastEventAt->diffInSeconds($occurredAt) / 3600);
$rawScores = $this->applyRecencyDecay($rawScores, $hours, $halfLifeHours);
}
$interestKey = $categoryId !== null
? sprintf('category:%d', $categoryId)
: sprintf('artwork:%d', $artworkId);
$rawScores[$interestKey] = (float) ($rawScores[$interestKey] ?? 0.0) + $eventWeight;
$rawScores = array_filter(
$rawScores,
static fn (mixed $value): bool => is_numeric($value) && (float) $value > 0.000001
);
$normalizedScores = $this->normalizeScores($rawScores);
$totalWeight = array_sum($rawScores);
$payload = [
'user_id' => $userId,
'profile_version' => $profileVersion,
'algo_version' => $algoVersion,
'raw_scores_json' => $rawScores,
'normalized_scores_json' => $normalizedScores,
'total_weight' => $totalWeight,
'event_count' => $profile !== null ? ((int) $profile->event_count + 1) : 1,
'last_event_at' => $lastEventAt === null || $occurredAt->greaterThan($lastEventAt)
? $occurredAt
: $lastEventAt,
'half_life_hours' => $halfLifeHours,
'updated_from_event_id' => $eventId,
'updated_at' => now(),
];
if ($profile === null) {
$payload['created_at'] = now();
UserInterestProfile::query()->create($payload);
return;
}
$profile->fill($payload);
$profile->save();
}, 3);
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
public function applyRecencyDecay(array $scores, float $hoursElapsed, float $halfLifeHours): array
{
if ($hoursElapsed <= 0 || $halfLifeHours <= 0) {
return $this->castToFloatScores($scores);
}
$decayFactor = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
$output = [];
foreach ($scores as $key => $score) {
if (! is_numeric($score)) {
continue;
}
$decayed = (float) $score * $decayFactor;
if ($decayed > 0.000001) {
$output[(string) $key] = $decayed;
}
}
return $output;
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
public function normalizeScores(array $scores): array
{
$typedScores = $this->castToFloatScores($scores);
$sum = array_sum($typedScores);
if ($sum <= 0.0) {
return [];
}
$normalized = [];
foreach ($typedScores as $key => $score) {
$normalized[$key] = $score / $sum;
}
return $normalized;
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
private function castToFloatScores(array $scores): array
{
$output = [];
foreach ($scores as $key => $score) {
if (is_numeric($score) && (float) $score > 0.0) {
$output[(string) $key] = (float) $score;
}
}
return $output;
}
}