163 lines
4.9 KiB
PHP
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;
|
|
}
|
|
}
|