131 lines
4.3 KiB
PHP
131 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Collection;
|
|
use App\Models\CollectionRecommendationSnapshot;
|
|
|
|
class CollectionRankingService
|
|
{
|
|
public function explain(Collection $collection, string $context = 'default'): array
|
|
{
|
|
$signals = [
|
|
'quality_score' => (float) ($collection->quality_score ?? 0),
|
|
'health_score' => (float) ($collection->health_score ?? 0),
|
|
'freshness_score' => (float) ($collection->freshness_score ?? 0),
|
|
'engagement_score' => (float) ($collection->engagement_score ?? 0),
|
|
'editorial_readiness_score' => (float) ($collection->editorial_readiness_score ?? 0),
|
|
];
|
|
|
|
$score = ($signals['quality_score'] * 0.3)
|
|
+ ($signals['health_score'] * 0.3)
|
|
+ ($signals['freshness_score'] * 0.15)
|
|
+ ($signals['engagement_score'] * 0.2)
|
|
+ ($signals['editorial_readiness_score'] * 0.05);
|
|
|
|
if ($collection->is_featured) {
|
|
$score += 5.0;
|
|
}
|
|
|
|
if ($context === 'campaign' && filled($collection->campaign_key)) {
|
|
$score += 7.5;
|
|
}
|
|
|
|
if ($context === 'evergreen' && $signals['freshness_score'] < 35 && $signals['quality_score'] >= 70) {
|
|
$score += 6.0;
|
|
}
|
|
|
|
$score = round(max(0.0, min(150.0, $score)), 2);
|
|
|
|
return [
|
|
'score' => $score,
|
|
'context' => $context,
|
|
'signals' => $signals,
|
|
'bucket' => $this->rankingBucket($score),
|
|
'recommendation_tier' => $this->recommendationTier($score),
|
|
'search_boost_tier' => $this->searchBoostTier($collection, $score),
|
|
'rationale' => [
|
|
sprintf('Quality %.1f', $signals['quality_score']),
|
|
sprintf('Health %.1f', $signals['health_score']),
|
|
sprintf('Freshness %.1f', $signals['freshness_score']),
|
|
sprintf('Engagement %.1f', $signals['engagement_score']),
|
|
],
|
|
];
|
|
}
|
|
|
|
public function refresh(Collection $collection, string $context = 'default'): Collection
|
|
{
|
|
$explanation = $this->explain($collection->fresh(), $context);
|
|
$snapshotDate = now()->toDateString();
|
|
|
|
$collection->forceFill([
|
|
'ranking_bucket' => $explanation['bucket'],
|
|
'recommendation_tier' => $explanation['recommendation_tier'],
|
|
'search_boost_tier' => $explanation['search_boost_tier'],
|
|
'last_recommendation_refresh_at' => now(),
|
|
])->save();
|
|
|
|
$snapshot = CollectionRecommendationSnapshot::query()
|
|
->where('collection_id', $collection->id)
|
|
->where('context_key', $context)
|
|
->whereDate('snapshot_date', $snapshotDate)
|
|
->first();
|
|
|
|
if ($snapshot) {
|
|
$snapshot->forceFill([
|
|
'recommendation_score' => $explanation['score'],
|
|
'rationale_json' => $explanation,
|
|
])->save();
|
|
} else {
|
|
CollectionRecommendationSnapshot::query()->create([
|
|
'collection_id' => $collection->id,
|
|
'context_key' => $context,
|
|
'recommendation_score' => $explanation['score'],
|
|
'rationale_json' => $explanation,
|
|
'snapshot_date' => $snapshotDate,
|
|
]);
|
|
}
|
|
|
|
return $collection->fresh();
|
|
}
|
|
|
|
private function rankingBucket(float $score): string
|
|
{
|
|
return match (true) {
|
|
$score >= 110 => 'elite',
|
|
$score >= 85 => 'strong',
|
|
$score >= 60 => 'steady',
|
|
$score >= 35 => 'emerging',
|
|
default => 'cold',
|
|
};
|
|
}
|
|
|
|
private function recommendationTier(float $score): string
|
|
{
|
|
return match (true) {
|
|
$score >= 105 => 'premium',
|
|
$score >= 80 => 'primary',
|
|
$score >= 55 => 'secondary',
|
|
default => 'fallback',
|
|
};
|
|
}
|
|
|
|
private function searchBoostTier(Collection $collection, float $score): string
|
|
{
|
|
if ($collection->type === Collection::TYPE_EDITORIAL && $score >= 80) {
|
|
return 'editorial';
|
|
}
|
|
|
|
if ($collection->placement_eligibility && $score >= 70) {
|
|
return 'high';
|
|
}
|
|
|
|
if ($score >= 45) {
|
|
return 'standard';
|
|
}
|
|
|
|
return 'low';
|
|
}
|
|
} |