Upload beautify
This commit is contained in:
139
app/Services/Recommendations/FeedOfflineEvaluationService.php
Normal file
139
app/Services/Recommendations/FeedOfflineEvaluationService.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class FeedOfflineEvaluationService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function evaluateAlgo(string $algoVersion, string $from, string $to): array
|
||||
{
|
||||
$row = DB::table('feed_daily_metrics')
|
||||
->selectRaw('SUM(impressions) as impressions')
|
||||
->selectRaw('SUM(clicks) as clicks')
|
||||
->selectRaw('SUM(saves) as saves')
|
||||
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
|
||||
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
|
||||
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
|
||||
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
|
||||
->where('algo_version', $algoVersion)
|
||||
->whereBetween('metric_date', [$from, $to])
|
||||
->first();
|
||||
|
||||
$impressions = (int) ($row->impressions ?? 0);
|
||||
$clicks = (int) ($row->clicks ?? 0);
|
||||
$saves = (int) ($row->saves ?? 0);
|
||||
|
||||
$dwell05 = (int) ($row->dwell_0_5 ?? 0);
|
||||
$dwell530 = (int) ($row->dwell_5_30 ?? 0);
|
||||
$dwell30120 = (int) ($row->dwell_30_120 ?? 0);
|
||||
$dwell120Plus = (int) ($row->dwell_120_plus ?? 0);
|
||||
|
||||
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
|
||||
$longDwellShare = $clicks > 0 ? ($dwell30120 + $dwell120Plus) / $clicks : 0.0;
|
||||
$bounceRate = $clicks > 0 ? $dwell05 / $clicks : 0.0;
|
||||
|
||||
$objectiveWeights = (array) config('discovery.evaluation.objective_weights', []);
|
||||
$wCtr = (float) ($objectiveWeights['ctr'] ?? 0.45);
|
||||
$wSave = (float) ($objectiveWeights['save_rate'] ?? 0.35);
|
||||
$wLong = (float) ($objectiveWeights['long_dwell_share'] ?? 0.25);
|
||||
$wBouncePenalty = (float) ($objectiveWeights['bounce_rate_penalty'] ?? 0.15);
|
||||
|
||||
$saveRateInformational = (bool) config('discovery.evaluation.save_rate_informational', true);
|
||||
if ($saveRateInformational) {
|
||||
$wSave = 0.0;
|
||||
}
|
||||
|
||||
$normalizationSum = $wCtr + $wSave + $wLong + $wBouncePenalty;
|
||||
if ($normalizationSum > 0.0) {
|
||||
$wCtr /= $normalizationSum;
|
||||
$wSave /= $normalizationSum;
|
||||
$wLong /= $normalizationSum;
|
||||
$wBouncePenalty /= $normalizationSum;
|
||||
}
|
||||
|
||||
$objectiveScore = ($wCtr * $ctr)
|
||||
+ ($wSave * $saveRate)
|
||||
+ ($wLong * $longDwellShare)
|
||||
- ($wBouncePenalty * $bounceRate);
|
||||
|
||||
return [
|
||||
'algo_version' => $algoVersion,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'saves' => $saves,
|
||||
'ctr' => round($ctr, 6),
|
||||
'save_rate' => round($saveRate, 6),
|
||||
'long_dwell_share' => round($longDwellShare, 6),
|
||||
'bounce_rate' => round($bounceRate, 6),
|
||||
'dwell_buckets' => [
|
||||
'0_5' => $dwell05,
|
||||
'5_30' => $dwell530,
|
||||
'30_120' => $dwell30120,
|
||||
'120_plus' => $dwell120Plus,
|
||||
],
|
||||
'objective_score' => round($objectiveScore, 6),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function evaluateAll(string $from, string $to): array
|
||||
{
|
||||
$algoVersions = DB::table('feed_daily_metrics')
|
||||
->select('algo_version')
|
||||
->whereBetween('metric_date', [$from, $to])
|
||||
->distinct()
|
||||
->orderBy('algo_version')
|
||||
->pluck('algo_version')
|
||||
->map(static fn (mixed $v): string => (string) $v)
|
||||
->all();
|
||||
|
||||
$out = [];
|
||||
foreach ($algoVersions as $algoVersion) {
|
||||
$out[] = $this->evaluateAlgo($algoVersion, $from, $to);
|
||||
}
|
||||
|
||||
usort($out, static fn (array $a, array $b): int => $b['objective_score'] <=> $a['objective_score']);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function compareBaselineCandidate(string $baselineAlgoVersion, string $candidateAlgoVersion, string $from, string $to): array
|
||||
{
|
||||
$baseline = $this->evaluateAlgo($baselineAlgoVersion, $from, $to);
|
||||
$candidate = $this->evaluateAlgo($candidateAlgoVersion, $from, $to);
|
||||
|
||||
$deltaObjective = (float) $candidate['objective_score'] - (float) $baseline['objective_score'];
|
||||
$objectiveLiftPct = (float) $baseline['objective_score'] !== 0.0
|
||||
? ($deltaObjective / (float) $baseline['objective_score']) * 100.0
|
||||
: null;
|
||||
|
||||
return [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'baseline' => $baseline,
|
||||
'candidate' => $candidate,
|
||||
'delta' => [
|
||||
'objective_score' => round($deltaObjective, 6),
|
||||
'objective_lift_pct' => $objectiveLiftPct !== null ? round($objectiveLiftPct, 4) : null,
|
||||
'ctr' => round((float) $candidate['ctr'] - (float) $baseline['ctr'], 6),
|
||||
'save_rate' => round((float) $candidate['save_rate'] - (float) $baseline['save_rate'], 6),
|
||||
'long_dwell_share' => round((float) $candidate['long_dwell_share'] - (float) $baseline['long_dwell_share'], 6),
|
||||
'bounce_rate' => round((float) $candidate['bounce_rate'] - (float) $baseline['bounce_rate'], 6),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
567
app/Services/Recommendations/PersonalizedFeedService.php
Normal file
567
app/Services/Recommendations/PersonalizedFeedService.php
Normal file
@@ -0,0 +1,567 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Jobs\RegenerateUserRecommendationCacheJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\UserInterestProfile;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class PersonalizedFeedService
|
||||
{
|
||||
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
|
||||
{
|
||||
$safeLimit = max(1, min(50, $limit));
|
||||
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
|
||||
$weightSet = $this->resolveRankingWeights($resolvedAlgoVersion);
|
||||
$offset = $this->decodeCursorToOffset($cursor);
|
||||
|
||||
$cache = UserRecommendationCache::query()
|
||||
->where('user_id', $userId)
|
||||
->where('algo_version', $resolvedAlgoVersion)
|
||||
->first();
|
||||
|
||||
$cacheItems = $this->extractCacheItems($cache);
|
||||
$isFresh = $cache !== null && $cache->expires_at !== null && $cache->expires_at->isFuture();
|
||||
|
||||
$cacheStatus = 'hit';
|
||||
if ($cache === null) {
|
||||
$cacheStatus = 'miss';
|
||||
} elseif (! $isFresh) {
|
||||
$cacheStatus = 'stale';
|
||||
}
|
||||
|
||||
if ($cache === null || ! $isFresh) {
|
||||
RegenerateUserRecommendationCacheJob::dispatch($userId, $resolvedAlgoVersion)
|
||||
->onQueue((string) config('discovery.queue', 'default'));
|
||||
}
|
||||
|
||||
$items = $cacheItems;
|
||||
if ($items === []) {
|
||||
$items = $this->buildColdStartRecommendations($resolvedAlgoVersion, 240, 'fallback');
|
||||
$cacheStatus = $cacheStatus . '-fallback';
|
||||
}
|
||||
|
||||
return $this->buildFeedPageResponse(
|
||||
items: $items,
|
||||
offset: $offset,
|
||||
limit: $safeLimit,
|
||||
algoVersion: $resolvedAlgoVersion,
|
||||
weightVersion: (string) $weightSet['version'],
|
||||
cacheStatus: $cacheStatus,
|
||||
generatedAt: $cache?->generated_at?->toIso8601String()
|
||||
);
|
||||
}
|
||||
|
||||
public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
|
||||
{
|
||||
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
|
||||
$cacheVersion = (string) config('discovery.cache_version', 'cache-v1');
|
||||
$ttlMinutes = max(1, (int) config('discovery.cache_ttl_minutes', 60));
|
||||
|
||||
$items = $this->buildRecommendations($userId, $resolvedAlgoVersion, 240);
|
||||
$generatedAt = now();
|
||||
$expiresAt = now()->addMinutes($ttlMinutes);
|
||||
|
||||
UserRecommendationCache::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'algo_version' => $resolvedAlgoVersion,
|
||||
],
|
||||
[
|
||||
'cache_version' => $cacheVersion,
|
||||
'recommendations_json' => [
|
||||
'items' => $items,
|
||||
'algo_version' => $resolvedAlgoVersion,
|
||||
'weight_version' => (string) $this->resolveRankingWeights($resolvedAlgoVersion)['version'],
|
||||
'generated_at' => $generatedAt->toIso8601String(),
|
||||
],
|
||||
'generated_at' => $generatedAt,
|
||||
'expires_at' => $expiresAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
public function buildRecommendations(int $userId, string $algoVersion, int $maxItems = 240): 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();
|
||||
|
||||
$normalized = $profile !== null ? (array) ($profile->normalized_scores_json ?? []) : [];
|
||||
$personalized = $this->buildProfileBasedRecommendations($normalized, $maxItems, $algoVersion);
|
||||
|
||||
if ($personalized === []) {
|
||||
return $this->buildColdStartRecommendations($algoVersion, $maxItems, 'cold_start');
|
||||
}
|
||||
|
||||
$fallback = $this->buildColdStartRecommendations($algoVersion, $maxItems, 'fallback');
|
||||
|
||||
$combined = [];
|
||||
foreach (array_merge($personalized, $fallback) as $item) {
|
||||
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||
if ($artworkId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($combined[$artworkId])) {
|
||||
$combined[$artworkId] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => (float) ($item['score'] ?? 0.0),
|
||||
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((float) $item['score'] > (float) $combined[$artworkId]['score']) {
|
||||
$combined[$artworkId]['score'] = (float) $item['score'];
|
||||
$combined[$artworkId]['source'] = (string) ($item['source'] ?? $combined[$artworkId]['source']);
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = array_values($combined);
|
||||
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
|
||||
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $normalizedScores
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
private function buildProfileBasedRecommendations(array $normalizedScores, int $maxItems, string $algoVersion): array
|
||||
{
|
||||
$weightSet = $this->resolveRankingWeights($algoVersion);
|
||||
$w1 = (float) $weightSet['w1'];
|
||||
$w2 = (float) $weightSet['w2'];
|
||||
$w3 = (float) $weightSet['w3'];
|
||||
$w4 = (float) $weightSet['w4'];
|
||||
|
||||
$categoryAffinities = [];
|
||||
foreach ($normalizedScores as $key => $score) {
|
||||
if (! is_numeric($score)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with((string) $key, 'category:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryId = (int) str_replace('category:', '', (string) $key);
|
||||
if ($categoryId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryAffinities[$categoryId] = (float) $score;
|
||||
}
|
||||
|
||||
if ($categoryAffinities === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('artworks')
|
||||
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->whereIn('artwork_category.category_id', array_keys($categoryAffinities))
|
||||
->whereNull('artworks.deleted_at')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now())
|
||||
->orderByDesc('artworks.published_at')
|
||||
->limit(max(200, $maxItems * 8))
|
||||
->get([
|
||||
'artworks.id',
|
||||
'artworks.published_at',
|
||||
'artwork_category.category_id',
|
||||
DB::raw('COALESCE(artwork_stats.views, 0) as views'),
|
||||
]);
|
||||
|
||||
$scored = [];
|
||||
foreach ($rows as $row) {
|
||||
$artworkId = (int) $row->id;
|
||||
$categoryId = (int) $row->category_id;
|
||||
$affinity = (float) ($categoryAffinities[$categoryId] ?? 0.0);
|
||||
if ($affinity <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$publishedAt = CarbonImmutable::parse((string) $row->published_at);
|
||||
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||
$recency = exp(-$ageDays / 30.0);
|
||||
$popularity = log(1 + max(0, (int) $row->views)) / 10.0;
|
||||
$novelty = max(0.0, 1.0 - min(1.0, $popularity));
|
||||
|
||||
// Phase 8B blend with versioned weights (manual tuning, no auto-tuning yet).
|
||||
$score = ($w1 * $affinity) + ($w2 * $recency) + ($w3 * $popularity) + ($w4 * $novelty);
|
||||
|
||||
if (! isset($scored[$artworkId]) || $score > $scored[$artworkId]['score']) {
|
||||
$scored[$artworkId] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => $score,
|
||||
'source' => 'personalized',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = array_values($scored);
|
||||
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
|
||||
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
private function buildColdStartRecommendations(string $algoVersion, int $maxItems, string $sourceLabel = 'cold_start'): array
|
||||
{
|
||||
$popularIds = DB::table('artworks')
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now())
|
||||
->orderByDesc('artwork_stats.views')
|
||||
->orderByDesc('artwork_stats.downloads')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->limit(max(40, $maxItems))
|
||||
->pluck('artworks.id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
$seedIds = array_slice($popularIds, 0, 12);
|
||||
|
||||
$similarIds = [];
|
||||
if ($seedIds !== []) {
|
||||
$similarIds = DB::table('artwork_similarities')
|
||||
->where('algo_version', $algoVersion)
|
||||
->whereIn('artwork_id', $seedIds)
|
||||
->orderBy('rank')
|
||||
->orderByDesc('score')
|
||||
->limit(max(80, $maxItems * 2))
|
||||
->pluck('similar_artwork_id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach ($popularIds as $index => $artworkId) {
|
||||
$candidates[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => max(0.0, 1.0 - ($index * 0.003)),
|
||||
'source' => $sourceLabel,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($similarIds as $index => $artworkId) {
|
||||
$candidates[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => max(0.0, 0.75 - ($index * 0.002)),
|
||||
'source' => $sourceLabel,
|
||||
];
|
||||
}
|
||||
|
||||
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
|
||||
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{artwork_id:int,score:float,source:string}> $candidates
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
private function applyDiversityGuard(array $candidates, string $algoVersion, int $maxItems): array
|
||||
{
|
||||
if ($candidates === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$uniqueCandidates = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$artworkId = (int) ($candidate['artwork_id'] ?? 0);
|
||||
if ($artworkId <= 0 || isset($uniqueCandidates[$artworkId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uniqueCandidates[$artworkId] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => (float) ($candidate['score'] ?? 0.0),
|
||||
'source' => (string) ($candidate['source'] ?? 'mixed'),
|
||||
];
|
||||
}
|
||||
|
||||
$flattened = array_values($uniqueCandidates);
|
||||
$candidateIds = array_map(static fn (array $item): int => (int) $item['artwork_id'], $flattened);
|
||||
|
||||
$nearDuplicatePairs = DB::table('artwork_similarities')
|
||||
->where('algo_version', $algoVersion)
|
||||
->where('score', '>=', 0.97)
|
||||
->whereIn('artwork_id', $candidateIds)
|
||||
->whereIn('similar_artwork_id', $candidateIds)
|
||||
->get(['artwork_id', 'similar_artwork_id']);
|
||||
|
||||
$adjacency = [];
|
||||
foreach ($nearDuplicatePairs as $pair) {
|
||||
$left = (int) $pair->artwork_id;
|
||||
$right = (int) $pair->similar_artwork_id;
|
||||
|
||||
if ($left === $right) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$adjacency[$left][$right] = true;
|
||||
$adjacency[$right][$left] = true;
|
||||
}
|
||||
|
||||
$selected = [];
|
||||
$selectedSet = [];
|
||||
|
||||
foreach ($flattened as $candidate) {
|
||||
$id = (int) $candidate['artwork_id'];
|
||||
|
||||
$isNearDuplicate = false;
|
||||
foreach ($selectedSet as $selectedId => $value) {
|
||||
if (($adjacency[$id][$selectedId] ?? false) || ($adjacency[$selectedId][$id] ?? false)) {
|
||||
$isNearDuplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isNearDuplicate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$selected[] = [
|
||||
'artwork_id' => $id,
|
||||
'score' => round((float) $candidate['score'], 6),
|
||||
'source' => (string) $candidate['source'],
|
||||
];
|
||||
$selectedSet[$id] = true;
|
||||
|
||||
if (count($selected) >= $maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{artwork_id:int,score:float,source:string}> $items
|
||||
*/
|
||||
private function buildFeedPageResponse(
|
||||
array $items,
|
||||
int $offset,
|
||||
int $limit,
|
||||
string $algoVersion,
|
||||
string $weightVersion,
|
||||
string $cacheStatus,
|
||||
?string $generatedAt
|
||||
): array {
|
||||
$safeOffset = max(0, $offset);
|
||||
$pageItems = array_slice($items, $safeOffset, $limit);
|
||||
|
||||
$ids = array_values(array_unique(array_map(
|
||||
static fn (array $item): int => (int) ($item['artwork_id'] ?? 0),
|
||||
$pageItems
|
||||
)));
|
||||
|
||||
/** @var Collection<int, Artwork> $artworks */
|
||||
$artworks = Artwork::query()
|
||||
->with(['user:id,name'])
|
||||
->whereIn('id', $ids)
|
||||
->public()
|
||||
->published()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$responseItems = [];
|
||||
foreach ($pageItems as $item) {
|
||||
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||
$artwork = $artworks->get($artworkId);
|
||||
if ($artwork === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$responseItems[] = [
|
||||
'id' => $artwork->id,
|
||||
'slug' => $artwork->slug,
|
||||
'title' => $artwork->title,
|
||||
'thumbnail_url' => $artwork->thumb_url,
|
||||
'author' => $artwork->user?->name,
|
||||
'score' => (float) ($item['score'] ?? 0.0),
|
||||
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||
];
|
||||
}
|
||||
|
||||
$nextOffset = $safeOffset + $limit;
|
||||
$hasNext = $nextOffset < count($items);
|
||||
|
||||
return [
|
||||
'data' => $responseItems,
|
||||
'meta' => [
|
||||
'algo_version' => $algoVersion,
|
||||
'weight_version' => $weightVersion,
|
||||
'cursor' => $this->encodeOffsetToCursor($safeOffset),
|
||||
'next_cursor' => $hasNext ? $this->encodeOffsetToCursor($nextOffset) : null,
|
||||
'limit' => $limit,
|
||||
'cache_status' => $cacheStatus,
|
||||
'generated_at' => $generatedAt,
|
||||
'total_candidates' => count($items),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
|
||||
{
|
||||
if ($algoVersion !== null && $algoVersion !== '') {
|
||||
return $algoVersion;
|
||||
}
|
||||
|
||||
$forcedAlgoVersion = trim((string) config('discovery.rollout.force_algo_version', ''));
|
||||
if ($forcedAlgoVersion !== '') {
|
||||
return $forcedAlgoVersion;
|
||||
}
|
||||
|
||||
$defaultAlgoVersion = (string) config('discovery.algo_version', 'clip-cosine-v1');
|
||||
$rolloutEnabled = (bool) config('discovery.rollout.enabled', false);
|
||||
if (! $rolloutEnabled || $userId === null || $userId <= 0) {
|
||||
return $defaultAlgoVersion;
|
||||
}
|
||||
|
||||
$baselineAlgoVersion = (string) config('discovery.rollout.baseline_algo_version', $defaultAlgoVersion);
|
||||
$candidateAlgoVersion = (string) config('discovery.rollout.candidate_algo_version', $defaultAlgoVersion);
|
||||
if ($candidateAlgoVersion === '' || $candidateAlgoVersion === $baselineAlgoVersion) {
|
||||
return $baselineAlgoVersion;
|
||||
}
|
||||
|
||||
$activeGate = (string) config('discovery.rollout.active_gate', 'g10');
|
||||
$gates = (array) config('discovery.rollout.gates', []);
|
||||
$gate = (array) ($gates[$activeGate] ?? []);
|
||||
$rolloutPercentage = (int) ($gate['percentage'] ?? 0);
|
||||
$rolloutPercentage = max(0, min(100, $rolloutPercentage));
|
||||
|
||||
if ($rolloutPercentage <= 0) {
|
||||
return $baselineAlgoVersion;
|
||||
}
|
||||
|
||||
if ($rolloutPercentage >= 100) {
|
||||
return $candidateAlgoVersion;
|
||||
}
|
||||
|
||||
$bucket = abs((int) crc32((string) $userId)) % 100;
|
||||
|
||||
return $bucket < $rolloutPercentage
|
||||
? $candidateAlgoVersion
|
||||
: $baselineAlgoVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version:string,w1:float,w2:float,w3:float,w4:float}
|
||||
*/
|
||||
public function resolveRankingWeights(string $algoVersion): array
|
||||
{
|
||||
$defaults = (array) config('discovery.ranking.default_weights', []);
|
||||
$byAlgo = (array) config('discovery.ranking.algo_weight_sets', []);
|
||||
$override = (array) ($byAlgo[$algoVersion] ?? []);
|
||||
|
||||
$resolved = array_merge($defaults, $override);
|
||||
|
||||
$weights = [
|
||||
'version' => (string) ($resolved['version'] ?? 'rank-w-v1'),
|
||||
'w1' => max(0.0, (float) ($resolved['w1'] ?? 0.65)),
|
||||
'w2' => max(0.0, (float) ($resolved['w2'] ?? 0.20)),
|
||||
'w3' => max(0.0, (float) ($resolved['w3'] ?? 0.10)),
|
||||
'w4' => max(0.0, (float) ($resolved['w4'] ?? 0.05)),
|
||||
];
|
||||
|
||||
$sum = $weights['w1'] + $weights['w2'] + $weights['w3'] + $weights['w4'];
|
||||
if ($sum > 0.0) {
|
||||
$weights['w1'] /= $sum;
|
||||
$weights['w2'] /= $sum;
|
||||
$weights['w3'] /= $sum;
|
||||
$weights['w4'] /= $sum;
|
||||
}
|
||||
|
||||
return $weights;
|
||||
}
|
||||
|
||||
private function decodeCursorToOffset(?string $cursor): int
|
||||
{
|
||||
if ($cursor === null || $cursor === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
|
||||
if ($decoded === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$json = json_decode($decoded, true);
|
||||
if (! is_array($json)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, (int) Arr::get($json, 'offset', 0));
|
||||
}
|
||||
|
||||
private function encodeOffsetToCursor(int $offset): string
|
||||
{
|
||||
$payload = json_encode(['offset' => max(0, $offset)]);
|
||||
if (! is_string($payload)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
private function extractCacheItems(?UserRecommendationCache $cache): array
|
||||
{
|
||||
if ($cache === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$raw = (array) ($cache->recommendations_json ?? []);
|
||||
$items = $raw['items'] ?? null;
|
||||
if (! is_array($items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$typed = [];
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||
if ($artworkId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$typed[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => (float) ($item['score'] ?? 0.0),
|
||||
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||
];
|
||||
}
|
||||
|
||||
return $typed;
|
||||
}
|
||||
}
|
||||
45
app/Services/Recommendations/SimilarArtworksService.php
Normal file
45
app/Services/Recommendations/SimilarArtworksService.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SimilarArtworksService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
public function forArtwork(int $artworkId, int $limit = 12, ?string $algoVersion = null): Collection
|
||||
{
|
||||
$effectiveAlgo = $algoVersion ?: (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
||||
|
||||
$ids = DB::table('artwork_similarities')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('algo_version', $effectiveAlgo)
|
||||
->orderBy('rank')
|
||||
->limit(max(1, min($limit, 50)))
|
||||
->pluck('similar_artwork_id')
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->whereIn('id', $ids)
|
||||
->public()
|
||||
->published()
|
||||
->get();
|
||||
|
||||
$byId = $artworks->keyBy('id');
|
||||
|
||||
return collect($ids)
|
||||
->map(static fn (int $id) => $byId->get($id))
|
||||
->filter();
|
||||
}
|
||||
}
|
||||
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