Upload beautify
This commit is contained in:
162
app/Services/Recommendations/UserInterestProfileService.php
Normal file
162
app/Services/Recommendations/UserInterestProfileService.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user