feat: Ranking Engine V2 — intelligent scoring with shares, authority, decay & velocity\n\n- Add ArtworkRankingService with V2 formula:\n ranking_score = (base × authority × decay) + velocity_boost\n Base: views×0.2 + downloads×1.5 + favourites×2.5 + comments×3.0 + shares×4.0\n Authority: 1 + (log10(1+followers) + fav_received/1000) × 0.05\n Decay: 1 / (1 + hours/48)\n Velocity: 24h signals × velocity_weights × 0.5\n\n- Add nova:recalculate-rankings command (--chunk, --sync-rank-scores, --skip-index)\n- Add migration: ranking_score, engagement_velocity, shares/comments counts to artwork_stats\n- Upgrade RankingService.computeScores() with shares + comments + velocity\n- Update Meilisearch sortableAttributes: ranking_score, shares_count, engagement_velocity, comments_count\n- Update toSearchableArray() to expose V2 fields\n- Schedule every 30 min with overlap protection\n- Verified: 49733 artworks scored successfully"
This commit is contained in:
81
app/Console/Commands/RecalculateRankingsCommand.php
Normal file
81
app/Console/Commands/RecalculateRankingsCommand.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Ranking\ArtworkRankingService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* php artisan nova:recalculate-rankings [--chunk=500] [--sync-rank-scores] [--skip-index]
|
||||||
|
*
|
||||||
|
* Ranking Engine V2 — recalculates ranking_score and engagement_velocity
|
||||||
|
* for all public, approved artworks. Designed to run every 30 minutes.
|
||||||
|
*/
|
||||||
|
class RecalculateRankingsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'nova:recalculate-rankings
|
||||||
|
{--chunk=500 : DB chunk size for batch processing}
|
||||||
|
{--sync-rank-scores : Also update rank_artwork_scores table with V2 formula}
|
||||||
|
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
||||||
|
|
||||||
|
protected $description = 'Recalculate V2 ranking scores (engagement + shares + decay + authority + velocity)';
|
||||||
|
|
||||||
|
public function __construct(private readonly ArtworkRankingService $ranking)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunkSize = (int) $this->option('chunk');
|
||||||
|
$syncRankScores = (bool) $this->option('sync-rank-scores');
|
||||||
|
$skipIndex = (bool) $this->option('skip-index');
|
||||||
|
|
||||||
|
// ── Step 1: Recalculate ranking_score + engagement_velocity ─────
|
||||||
|
$this->info('Ranking V2: recalculating scores …');
|
||||||
|
$start = microtime(true);
|
||||||
|
$updated = $this->ranking->recalculateAll($chunkSize);
|
||||||
|
$elapsed = round(microtime(true) - $start, 2);
|
||||||
|
$this->info(" ✓ {$updated} artworks scored in {$elapsed}s");
|
||||||
|
|
||||||
|
// ── Step 2 (optional): Sync to rank_artwork_scores ─────────────
|
||||||
|
if ($syncRankScores) {
|
||||||
|
$this->info('Syncing to rank_artwork_scores …');
|
||||||
|
$start2 = microtime(true);
|
||||||
|
$synced = $this->ranking->syncToRankScores($chunkSize);
|
||||||
|
$elapsed2 = round(microtime(true) - $start2, 2);
|
||||||
|
$this->info(" ✓ {$synced} rank scores synced in {$elapsed2}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3 (optional): Trigger Meilisearch re-index ────────────
|
||||||
|
if (! $skipIndex) {
|
||||||
|
$this->info('Dispatching Meilisearch index jobs …');
|
||||||
|
$this->dispatchIndexJobs();
|
||||||
|
$this->info(' ✓ Index jobs dispatched');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch IndexArtworkJob for artworks updated in the last 24 hours
|
||||||
|
* (or recently scored). Keeps the search index current.
|
||||||
|
*/
|
||||||
|
private function dispatchIndexJobs(): void
|
||||||
|
{
|
||||||
|
\App\Models\Artwork::query()
|
||||||
|
->select('id')
|
||||||
|
->where('is_public', true)
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereNotNull('published_at')
|
||||||
|
->where('published_at', '>=', now()->subDays(30)->toDateTimeString())
|
||||||
|
->chunkById(500, function ($artworks): void {
|
||||||
|
foreach ($artworks as $artwork) {
|
||||||
|
\App\Jobs\IndexArtworkJob::dispatch($artwork->id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Console\Commands\EvaluateFeedWeightsCommand;
|
|||||||
use App\Console\Commands\AiTagArtworksCommand;
|
use App\Console\Commands\AiTagArtworksCommand;
|
||||||
use App\Console\Commands\CompareFeedAbCommand;
|
use App\Console\Commands\CompareFeedAbCommand;
|
||||||
use App\Console\Commands\RecalculateTrendingCommand;
|
use App\Console\Commands\RecalculateTrendingCommand;
|
||||||
|
use App\Console\Commands\RecalculateRankingsCommand;
|
||||||
use App\Jobs\RankComputeArtworkScoresJob;
|
use App\Jobs\RankComputeArtworkScoresJob;
|
||||||
use App\Jobs\RankBuildListsJob;
|
use App\Jobs\RankBuildListsJob;
|
||||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||||
@@ -40,6 +41,7 @@ class Kernel extends ConsoleKernel
|
|||||||
AiTagArtworksCommand::class,
|
AiTagArtworksCommand::class,
|
||||||
\App\Console\Commands\MigrateFollows::class,
|
\App\Console\Commands\MigrateFollows::class,
|
||||||
RecalculateTrendingCommand::class,
|
RecalculateTrendingCommand::class,
|
||||||
|
RecalculateRankingsCommand::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +61,13 @@ class Kernel extends ConsoleKernel
|
|||||||
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
|
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
|
||||||
// Step 2: build ranked lists every hour at :15 (after scores are ready)
|
// Step 2: build ranked lists every hour at :15 (after scores are ready)
|
||||||
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground();
|
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground();
|
||||||
|
|
||||||
|
// ── Ranking Engine V2 — runs every 30 min ──────────────────────────
|
||||||
|
$schedule->command('nova:recalculate-rankings --sync-rank-scores')
|
||||||
|
->everyThirtyMinutes()
|
||||||
|
->name('ranking-v2')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -256,6 +256,11 @@ class Artwork extends Model
|
|||||||
'favorites_count' => (int) ($stat?->favorites ?? 0),
|
'favorites_count' => (int) ($stat?->favorites ?? 0),
|
||||||
'awards_received_count' => (int) ($awardStat?->score_total ?? 0),
|
'awards_received_count' => (int) ($awardStat?->score_total ?? 0),
|
||||||
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||||
|
// ── Ranking V2 fields ───────────────────────────────────────────────
|
||||||
|
'ranking_score' => (float) ($stat?->ranking_score ?? 0),
|
||||||
|
'engagement_velocity' => (float) ($stat?->engagement_velocity ?? 0),
|
||||||
|
'shares_count' => (int) ($stat?->shares_count ?? 0),
|
||||||
|
'comments_count' => (int) ($stat?->comments_count ?? 0),
|
||||||
'awards' => [
|
'awards' => [
|
||||||
'gold' => $awardStat?->gold_count ?? 0,
|
'gold' => $awardStat?->gold_count ?? 0,
|
||||||
'silver' => $awardStat?->silver_count ?? 0,
|
'silver' => $awardStat?->silver_count ?? 0,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class ArtworkStats extends Model
|
|||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'artwork_id',
|
'artwork_id',
|
||||||
'views',
|
'views',
|
||||||
@@ -24,6 +26,14 @@ class ArtworkStats extends Model
|
|||||||
'favorites',
|
'favorites',
|
||||||
'rating_avg',
|
'rating_avg',
|
||||||
'rating_count',
|
'rating_count',
|
||||||
|
// V2 ranking columns
|
||||||
|
'comments_count',
|
||||||
|
'shares_count',
|
||||||
|
'ranking_score',
|
||||||
|
'engagement_velocity',
|
||||||
|
'shares_24h',
|
||||||
|
'comments_24h',
|
||||||
|
'favourites_24h',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function artwork(): BelongsTo
|
public function artwork(): BelongsTo
|
||||||
|
|||||||
532
app/Services/Ranking/ArtworkRankingService.php
Normal file
532
app/Services/Ranking/ArtworkRankingService.php
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Ranking;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArtworkRankingService — Skinbase Nova Ranking Engine V2
|
||||||
|
*
|
||||||
|
* Intelligent scoring system combining:
|
||||||
|
* 1. Base engagement (views, downloads, favourites, comments, shares)
|
||||||
|
* 2. Author authority multiplier (followers + favourites received)
|
||||||
|
* 3. Recency decay (half-life configurable, default 48h)
|
||||||
|
* 4. Engagement velocity (last-24h activity burst)
|
||||||
|
*
|
||||||
|
* Final formula:
|
||||||
|
* ranking_score = (base_score × authority_multiplier × decay_factor) + velocity_boost
|
||||||
|
*
|
||||||
|
* The service processes artworks in chunks and writes ranking_score +
|
||||||
|
* engagement_velocity to the artwork_stats table.
|
||||||
|
*
|
||||||
|
* Designed to run via `php artisan nova:recalculate-rankings` every 30 minutes.
|
||||||
|
*/
|
||||||
|
final class ArtworkRankingService
|
||||||
|
{
|
||||||
|
// ── Weight configuration keys ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default V2 weights — overridable via config('ranking.v2.weights.*')
|
||||||
|
*/
|
||||||
|
private const DEFAULT_WEIGHTS = [
|
||||||
|
'views' => 0.2,
|
||||||
|
'downloads' => 1.5,
|
||||||
|
'favourites' => 2.5,
|
||||||
|
'comments' => 3.0,
|
||||||
|
'shares' => 4.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default velocity weights for 24h window signals.
|
||||||
|
*/
|
||||||
|
private const DEFAULT_VELOCITY_WEIGHTS = [
|
||||||
|
'views' => 1.0,
|
||||||
|
'favourites' => 3.0,
|
||||||
|
'comments' => 4.0,
|
||||||
|
'shares' => 5.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
private const DEFAULT_HALF_LIFE_HOURS = 48;
|
||||||
|
private const DEFAULT_VELOCITY_MULTIPLIER = 0.5;
|
||||||
|
private const DEFAULT_AUTHORITY_FACTOR = 0.05;
|
||||||
|
private const DEFAULT_AUTHORITY_FAV_DIVISOR = 1000;
|
||||||
|
|
||||||
|
// ── Public scoring methods (per-artwork row) ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the base engagement score.
|
||||||
|
*
|
||||||
|
* base_score = (views × 0.2) + (downloads × 1.5) + (favourites × 2.5)
|
||||||
|
* + (comments × 3.0) + (shares × 4.0)
|
||||||
|
*/
|
||||||
|
public function calculateBaseScore(object $row): float
|
||||||
|
{
|
||||||
|
$w = $this->weights();
|
||||||
|
|
||||||
|
return ($w['views'] * (float) ($row->views_all ?? 0))
|
||||||
|
+ ($w['downloads'] * (float) ($row->downloads_all ?? 0))
|
||||||
|
+ ($w['favourites'] * (float) ($row->favourites_all ?? 0))
|
||||||
|
+ ($w['comments'] * (float) ($row->comments_count ?? 0))
|
||||||
|
+ ($w['shares'] * (float) ($row->shares_count ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the author authority multiplier.
|
||||||
|
*
|
||||||
|
* authority = log10(1 + followers_count) + (favourites_received / 1000)
|
||||||
|
* multiplier = 1 + (authority × 0.05)
|
||||||
|
*/
|
||||||
|
public function calculateAuthorityMultiplier(object $row): float
|
||||||
|
{
|
||||||
|
$followersCount = (float) ($row->author_followers_count ?? 0);
|
||||||
|
$favReceived = (float) ($row->author_favourites_received ?? 0);
|
||||||
|
|
||||||
|
$factor = (float) config('ranking.v2.authority_factor', self::DEFAULT_AUTHORITY_FACTOR);
|
||||||
|
$favDivisor = (float) config('ranking.v2.authority_fav_divisor', self::DEFAULT_AUTHORITY_FAV_DIVISOR);
|
||||||
|
|
||||||
|
$authority = log10(1 + $followersCount) + ($favReceived / $favDivisor);
|
||||||
|
|
||||||
|
return 1.0 + ($authority * $factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the recency decay factor.
|
||||||
|
*
|
||||||
|
* decay = 1 / (1 + (hours_since_upload / half_life))
|
||||||
|
*/
|
||||||
|
public function calculateDecayFactor(object $row): float
|
||||||
|
{
|
||||||
|
$hours = max(0.0, (float) ($row->age_hours ?? 0));
|
||||||
|
$halfLife = (float) config('ranking.v2.half_life', self::DEFAULT_HALF_LIFE_HOURS);
|
||||||
|
|
||||||
|
return 1.0 / (1.0 + ($hours / $halfLife));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the 24h engagement velocity boost.
|
||||||
|
*
|
||||||
|
* velocity = (views_24h × 1) + (favourites_24h × 3) + (comments_24h × 4) + (shares_24h × 5)
|
||||||
|
* boost = velocity × 0.5
|
||||||
|
*/
|
||||||
|
public function calculateVelocityBoost(object $row): float
|
||||||
|
{
|
||||||
|
$vw = $this->velocityWeights();
|
||||||
|
|
||||||
|
$velocity = ($vw['views'] * (float) ($row->views_24h ?? 0))
|
||||||
|
+ ($vw['favourites'] * (float) ($row->favourites_24h ?? 0))
|
||||||
|
+ ($vw['comments'] * (float) ($row->comments_24h ?? 0))
|
||||||
|
+ ($vw['shares'] * (float) ($row->shares_24h ?? 0));
|
||||||
|
|
||||||
|
$multiplier = (float) config('ranking.v2.velocity_multiplier', self::DEFAULT_VELOCITY_MULTIPLIER);
|
||||||
|
|
||||||
|
return $velocity * $multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the final ranking score for a single artwork row.
|
||||||
|
*
|
||||||
|
* ranking_score = (base_score × authority_multiplier × decay_factor) + velocity_boost
|
||||||
|
*
|
||||||
|
* @return array{ranking_score: float, engagement_velocity: float}
|
||||||
|
*/
|
||||||
|
public function calculateFinalScore(object $row): array
|
||||||
|
{
|
||||||
|
$base = $this->calculateBaseScore($row);
|
||||||
|
$authority = $this->calculateAuthorityMultiplier($row);
|
||||||
|
$decay = $this->calculateDecayFactor($row);
|
||||||
|
$velocity = $this->calculateVelocityBoost($row);
|
||||||
|
|
||||||
|
$recentScore = $base * $authority * $decay;
|
||||||
|
$rankingScore = $recentScore + $velocity;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ranking_score' => max(0.0, $rankingScore),
|
||||||
|
'engagement_velocity' => max(0.0, $velocity),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk recalculation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate ranking_score and engagement_velocity for all public artworks.
|
||||||
|
*
|
||||||
|
* Uses chunked processing with a single pre-aggregated JOIN query.
|
||||||
|
* Author authority data is cached per-author to avoid redundant lookups.
|
||||||
|
*
|
||||||
|
* @param int $chunkSize DB chunk size (default 500)
|
||||||
|
* @return int Number of artworks updated
|
||||||
|
*/
|
||||||
|
public function recalculateAll(int $chunkSize = 500): int
|
||||||
|
{
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
// Pre-load author authority data into memory (one query)
|
||||||
|
$authorityCache = $this->loadAuthorAuthorityMap();
|
||||||
|
|
||||||
|
$this->artworkSignalsQuery()
|
||||||
|
->orderBy('a.id')
|
||||||
|
->chunk($chunkSize, function ($rows) use ($authorityCache, &$total): void {
|
||||||
|
$rows = collect($rows);
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$upserts = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
/** @var object $row */
|
||||||
|
// Inject cached author authority data
|
||||||
|
$userId = (int) $row->user_id;
|
||||||
|
$row->author_followers_count = $authorityCache[$userId]['followers'] ?? 0;
|
||||||
|
$row->author_favourites_received = $authorityCache[$userId]['fav_received'] ?? 0;
|
||||||
|
|
||||||
|
$scores = $this->calculateFinalScore($row);
|
||||||
|
|
||||||
|
$upserts[] = [
|
||||||
|
'artwork_id' => (int) $row->id,
|
||||||
|
'ranking_score' => round($scores['ranking_score'], 4),
|
||||||
|
'engagement_velocity' => round($scores['engagement_velocity'], 4),
|
||||||
|
'comments_count' => (int) ($row->comments_count ?? 0),
|
||||||
|
'shares_count' => (int) ($row->shares_count ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk upsert into artwork_stats
|
||||||
|
if (! empty($upserts)) {
|
||||||
|
DB::table('artwork_stats')->upsert(
|
||||||
|
$upserts,
|
||||||
|
['artwork_id'],
|
||||||
|
['ranking_score', 'engagement_velocity', 'comments_count', 'shares_count']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total += count($upserts);
|
||||||
|
});
|
||||||
|
|
||||||
|
Log::info('ArtworkRankingService V2: recalculation complete', [
|
||||||
|
'total_updated' => $total,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Also update the existing rank_artwork_scores with V2 scores
|
||||||
|
* so the RankBuildListsJob benefits from the new formula.
|
||||||
|
*/
|
||||||
|
public function syncToRankScores(int $chunkSize = 500): int
|
||||||
|
{
|
||||||
|
$modelVersion = config('ranking.model_version', 'rank_v2');
|
||||||
|
$total = 0;
|
||||||
|
$now = now()->toDateTimeString();
|
||||||
|
|
||||||
|
// Re-use the existing RankingService to compute the 3 scores,
|
||||||
|
// but inject V2 signals (shares, comments, velocity) into the existing formula
|
||||||
|
$this->artworkSignalsQuery()
|
||||||
|
->orderBy('a.id')
|
||||||
|
->chunk($chunkSize, function ($rows) use ($modelVersion, $now, &$total): void {
|
||||||
|
$rows = collect($rows);
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$upserts = $rows->map(function ($row) use ($modelVersion, $now): array {
|
||||||
|
// Compute V2-enhanced scores for the 3 list types
|
||||||
|
$scores = $this->computeListScores($row);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'artwork_id' => (int) $row->id,
|
||||||
|
'score_trending' => $scores['score_trending'],
|
||||||
|
'score_new_hot' => $scores['score_new_hot'],
|
||||||
|
'score_best' => $scores['score_best'],
|
||||||
|
'model_version' => $modelVersion,
|
||||||
|
'computed_at' => $now,
|
||||||
|
];
|
||||||
|
})->all();
|
||||||
|
|
||||||
|
DB::table('rank_artwork_scores')->upsert(
|
||||||
|
$upserts,
|
||||||
|
['artwork_id'],
|
||||||
|
['score_trending', 'score_new_hot', 'score_best', 'model_version', 'computed_at']
|
||||||
|
);
|
||||||
|
|
||||||
|
$total += count($upserts);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the three list-type scores (trending, new_hot, best)
|
||||||
|
* using V2 engagement formula that includes shares and comments.
|
||||||
|
*/
|
||||||
|
public function computeListScores(object $row): array
|
||||||
|
{
|
||||||
|
$cfg = config('ranking');
|
||||||
|
$w = $this->weights();
|
||||||
|
|
||||||
|
// V2 engagement base (includes shares + comments)
|
||||||
|
$E = ($w['views'] * log(1 + (float) ($row->views_7d ?? 0)))
|
||||||
|
+ ($w['downloads'] * log(1 + (float) ($row->downloads_7d ?? 0)))
|
||||||
|
+ ($w['favourites'] * log(1 + (float) ($row->favourites_7d ?? 0)))
|
||||||
|
+ ($w['comments'] * log(1 + (float) ($row->comments_24h ?? 0) * 7))
|
||||||
|
+ ($w['shares'] * log(1 + (float) ($row->shares_24h ?? 0) * 7));
|
||||||
|
|
||||||
|
$E_all = ($w['views'] * log(1 + (float) ($row->views_all ?? 0)))
|
||||||
|
+ ($w['downloads'] * log(1 + (float) ($row->downloads_all ?? 0)))
|
||||||
|
+ ($w['favourites'] * log(1 + (float) ($row->favourites_all ?? 0)))
|
||||||
|
+ ($w['comments'] * log(1 + (float) ($row->comments_count ?? 0)))
|
||||||
|
+ ($w['shares'] * log(1 + (float) ($row->shares_count ?? 0)));
|
||||||
|
|
||||||
|
// Freshness decay
|
||||||
|
$ageH = max(0.0, (float) ($row->age_hours ?? 0));
|
||||||
|
$decayTrending = exp(-$ageH / (float) ($cfg['half_life']['trending'] ?? 72));
|
||||||
|
$decayNewHot = exp(-$ageH / (float) ($cfg['half_life']['new_hot'] ?? 36));
|
||||||
|
$decayBest = exp(-$ageH / (float) ($cfg['half_life']['best'] ?? 720));
|
||||||
|
|
||||||
|
// Quality modifier
|
||||||
|
$tagCount = (int) ($row->tag_count ?? 0);
|
||||||
|
$hasThumb = (bool) ($row->has_thumbnail ?? false);
|
||||||
|
$isVisible = (bool) ($row->is_public ?? false) && (bool) ($row->is_approved ?? false);
|
||||||
|
|
||||||
|
$Q = 1.0;
|
||||||
|
if ($tagCount > 0) { $Q += (float) ($cfg['quality']['has_tags'] ?? 0.05); }
|
||||||
|
if ($hasThumb) { $Q += (float) ($cfg['quality']['has_thumbnail'] ?? 0.02); }
|
||||||
|
$Q += (float) ($cfg['quality']['tag_count_bonus'] ?? 0.01)
|
||||||
|
* (min($tagCount, (int) ($cfg['quality']['tag_count_max'] ?? 10))
|
||||||
|
/ max((float) ($cfg['quality']['tag_count_max'] ?? 10), 1.0));
|
||||||
|
if (! $isVisible) { $Q -= (float) ($cfg['quality']['penalty_hidden'] ?? 0.50); }
|
||||||
|
|
||||||
|
// Novelty boost (New & Hot)
|
||||||
|
$noveltyW = (float) ($cfg['novelty_weight'] ?? 0.35);
|
||||||
|
$novelty = 1.0 + $noveltyW * exp(-$ageH / 24.0);
|
||||||
|
|
||||||
|
// Velocity boost for trending
|
||||||
|
$velocityBoost = $this->calculateVelocityBoost($row);
|
||||||
|
|
||||||
|
// Anti-spam
|
||||||
|
$spamFactor = 1.0;
|
||||||
|
$spam = $cfg['spam'] ?? [];
|
||||||
|
if (
|
||||||
|
(float) ($row->views_24h ?? 0) > (float) ($spam['views_24h_threshold'] ?? 2000)
|
||||||
|
&& (float) ($row->views_24h ?? 0) > 0
|
||||||
|
) {
|
||||||
|
$rF = (float) ($row->favourites_24h ?? 0) / (float) ($row->views_24h ?? 1);
|
||||||
|
$rD = (float) ($row->downloads_24h ?? 0) / (float) ($row->views_24h ?? 1);
|
||||||
|
if ($rF < (float) ($spam['fav_ratio_threshold'] ?? 0.002)
|
||||||
|
&& $rD < (float) ($spam['dl_ratio_threshold'] ?? 0.001)
|
||||||
|
) {
|
||||||
|
$spamFactor = (float) ($spam['trending_penalty_factor'] ?? 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoreTrending = ($E * $decayTrending * (1.0 + $Q) * $spamFactor) + $velocityBoost;
|
||||||
|
$scoreNewHot = ($E * $decayNewHot * $novelty * (1.0 + $Q)) + ($velocityBoost * 0.7);
|
||||||
|
$scoreBest = $E_all * $decayBest * (1.0 + $Q);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score_trending' => max(0.0, $scoreTrending),
|
||||||
|
'score_new_hot' => max(0.0, $scoreNewHot),
|
||||||
|
'score_best' => max(0.0, $scoreBest),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signal query ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the query that selects all artwork signals needed for V2 scoring.
|
||||||
|
*
|
||||||
|
* Columns returned:
|
||||||
|
* id, user_id, published_at, is_public, is_approved, has_thumbnail,
|
||||||
|
* views_all, downloads_all, favourites_all, comments_count, shares_count,
|
||||||
|
* views_7d, downloads_7d, favourites_7d,
|
||||||
|
* views_24h, downloads_24h, favourites_24h, comments_24h, shares_24h,
|
||||||
|
* tag_count, age_hours
|
||||||
|
*/
|
||||||
|
public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder
|
||||||
|
{
|
||||||
|
$hasSharesTable = Schema::hasTable('artwork_shares');
|
||||||
|
$hasCommentsTable = Schema::hasTable('artwork_comments');
|
||||||
|
|
||||||
|
$query = DB::table('artworks as a')
|
||||||
|
->select([
|
||||||
|
'a.id',
|
||||||
|
'a.user_id',
|
||||||
|
'a.published_at',
|
||||||
|
'a.is_public',
|
||||||
|
'a.is_approved',
|
||||||
|
DB::raw('(a.thumb_ext IS NOT NULL AND a.thumb_ext != "") AS has_thumbnail'),
|
||||||
|
// All-time counters
|
||||||
|
DB::raw('COALESCE(ast.views, 0) AS views_all'),
|
||||||
|
DB::raw('COALESCE(ast.downloads, 0) AS downloads_all'),
|
||||||
|
DB::raw('COALESCE(ast.favorites, 0) AS favourites_all'),
|
||||||
|
// Comments count (precomputed or subquery)
|
||||||
|
DB::raw(
|
||||||
|
$hasCommentsTable
|
||||||
|
? 'COALESCE(cc.cnt, 0) AS comments_count'
|
||||||
|
: '0 AS comments_count'
|
||||||
|
),
|
||||||
|
// Shares count
|
||||||
|
DB::raw(
|
||||||
|
$hasSharesTable
|
||||||
|
? 'COALESCE(sc.cnt, 0) AS shares_count'
|
||||||
|
: '0 AS shares_count'
|
||||||
|
),
|
||||||
|
// 7-day windowed
|
||||||
|
DB::raw('COALESCE(ast.views_7d, 0) AS views_7d'),
|
||||||
|
DB::raw('COALESCE(ast.downloads_7d, 0) AS downloads_7d'),
|
||||||
|
DB::raw('COALESCE(fav7.cnt, 0) AS favourites_7d'),
|
||||||
|
// 24-hour windowed
|
||||||
|
DB::raw('COALESCE(ast.views_24h, 0) AS views_24h'),
|
||||||
|
DB::raw('COALESCE(ast.downloads_24h, 0) AS downloads_24h'),
|
||||||
|
DB::raw('COALESCE(fav1.cnt, 0) AS favourites_24h'),
|
||||||
|
// 24h comments & shares
|
||||||
|
DB::raw(
|
||||||
|
$hasCommentsTable
|
||||||
|
? 'COALESCE(cc24.cnt, 0) AS comments_24h'
|
||||||
|
: '0 AS comments_24h'
|
||||||
|
),
|
||||||
|
DB::raw(
|
||||||
|
$hasSharesTable
|
||||||
|
? 'COALESCE(sc24.cnt, 0) AS shares_24h'
|
||||||
|
: '0 AS shares_24h'
|
||||||
|
),
|
||||||
|
// Tag count
|
||||||
|
DB::raw('COALESCE(tc.tag_count, 0) AS tag_count'),
|
||||||
|
// Age in hours
|
||||||
|
DB::raw('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'),
|
||||||
|
])
|
||||||
|
->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'a.id')
|
||||||
|
// Favourites (7 days)
|
||||||
|
->leftJoinSub(
|
||||||
|
DB::table('artwork_favourites')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 7 DAY)'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'fav7',
|
||||||
|
'fav7.artwork_id', '=', 'a.id'
|
||||||
|
)
|
||||||
|
// Favourites (24 hours)
|
||||||
|
->leftJoinSub(
|
||||||
|
DB::table('artwork_favourites')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'fav1',
|
||||||
|
'fav1.artwork_id', '=', 'a.id'
|
||||||
|
)
|
||||||
|
// Tag count
|
||||||
|
->leftJoinSub(
|
||||||
|
DB::table('artwork_tag')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as tag_count'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'tc',
|
||||||
|
'tc.artwork_id', '=', 'a.id'
|
||||||
|
)
|
||||||
|
->where('a.is_public', 1)
|
||||||
|
->where('a.is_approved', 1)
|
||||||
|
->whereNull('a.deleted_at')
|
||||||
|
->whereNotNull('a.published_at');
|
||||||
|
|
||||||
|
// Comments count (all-time)
|
||||||
|
if ($hasCommentsTable) {
|
||||||
|
$query->leftJoinSub(
|
||||||
|
DB::table('artwork_comments')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'cc',
|
||||||
|
'cc.artwork_id', '=', 'a.id'
|
||||||
|
);
|
||||||
|
// Comments (24h)
|
||||||
|
$query->leftJoinSub(
|
||||||
|
DB::table('artwork_comments')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'cc24',
|
||||||
|
'cc24.artwork_id', '=', 'a.id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shares count (all-time)
|
||||||
|
if ($hasSharesTable) {
|
||||||
|
$query->leftJoinSub(
|
||||||
|
DB::table('artwork_shares')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'sc',
|
||||||
|
'sc.artwork_id', '=', 'a.id'
|
||||||
|
);
|
||||||
|
// Shares (24h)
|
||||||
|
$query->leftJoinSub(
|
||||||
|
DB::table('artwork_shares')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'sc24',
|
||||||
|
'sc24.artwork_id', '=', 'a.id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Author authority pre-loading ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load author authority data for all users who have published artworks.
|
||||||
|
* Returns an array keyed by user_id:
|
||||||
|
* [ userId => ['followers' => int, 'fav_received' => int] ]
|
||||||
|
*/
|
||||||
|
private function loadAuthorAuthorityMap(): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
// Get all author user IDs with public artworks
|
||||||
|
$rows = DB::table('artworks as a')
|
||||||
|
->select([
|
||||||
|
'a.user_id',
|
||||||
|
DB::raw('COALESCE(us.followers_count, 0) AS followers_count'),
|
||||||
|
DB::raw('COALESCE(us.favorites_received_count, 0) AS favourites_received_count'),
|
||||||
|
])
|
||||||
|
->leftJoin('user_statistics as us', 'us.user_id', '=', 'a.user_id')
|
||||||
|
->where('a.is_public', 1)
|
||||||
|
->where('a.is_approved', 1)
|
||||||
|
->whereNull('a.deleted_at')
|
||||||
|
->groupBy('a.user_id', 'us.followers_count', 'us.favorites_received_count')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$map[(int) $row->user_id] = [
|
||||||
|
'followers' => (int) $row->followers_count,
|
||||||
|
'fav_received' => (int) $row->favourites_received_count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function weights(): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
self::DEFAULT_WEIGHTS,
|
||||||
|
(array) config('ranking.v2.weights', [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function velocityWeights(): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
self::DEFAULT_VELOCITY_WEIGHTS,
|
||||||
|
(array) config('ranking.v2.velocity_weights', [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,20 +5,23 @@ declare(strict_types=1);
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\RankArtworkScore;
|
|
||||||
use App\Models\RankList;
|
use App\Models\RankList;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RankingService — Skinbase Nova rank_v1
|
* RankingService — Skinbase Nova rank_v2
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* 1. Score computation — turn raw artwork signals into three float scores.
|
* 1. Score computation — turn raw artwork signals into three float scores.
|
||||||
* 2. Diversity filtering — cap items per author while keeping rank order.
|
* 2. Diversity filtering — cap items per author while keeping rank order.
|
||||||
* 3. List read / cache — serve ranked lists from Redis, falling back to DB,
|
* 3. List read / cache — serve ranked lists from Redis, falling back to DB,
|
||||||
* and ultimately to latest-first if no list is built yet.
|
* and ultimately to latest-first if no list is built yet.
|
||||||
|
*
|
||||||
|
* V2 enhancements:
|
||||||
|
* - Shares and comments are included in engagement scoring
|
||||||
|
* - Engagement velocity (24h burst) boosts trending artworks
|
||||||
*/
|
*/
|
||||||
final class RankingService
|
final class RankingService
|
||||||
{
|
{
|
||||||
@@ -27,10 +30,13 @@ final class RankingService
|
|||||||
/**
|
/**
|
||||||
* Compute all three ranking scores for a single artwork data row.
|
* Compute all three ranking scores for a single artwork data row.
|
||||||
*
|
*
|
||||||
|
* V2: includes shares, comments, and velocity boost.
|
||||||
|
*
|
||||||
* @param object $row stdClass with fields:
|
* @param object $row stdClass with fields:
|
||||||
* views_7d, favourites_7d, downloads_7d,
|
* views_7d, favourites_7d, downloads_7d,
|
||||||
* views_all, favourites_all, downloads_all,
|
* views_all, favourites_all, downloads_all,
|
||||||
* views_24h, favourites_24h, downloads_24h,
|
* views_24h, favourites_24h, downloads_24h,
|
||||||
|
* comments_count, shares_count, comments_24h, shares_24h,
|
||||||
* age_hours, tag_count, has_thumbnail (bool 0/1),
|
* age_hours, tag_count, has_thumbnail (bool 0/1),
|
||||||
* is_public, is_approved
|
* is_public, is_approved
|
||||||
* @return array{score_trending: float, score_new_hot: float, score_best: float}
|
* @return array{score_trending: float, score_new_hot: float, score_best: float}
|
||||||
@@ -43,15 +49,23 @@ final class RankingService
|
|||||||
$wF = (float) $cfg['weights']['favourites'];
|
$wF = (float) $cfg['weights']['favourites'];
|
||||||
$wD = (float) $cfg['weights']['downloads'];
|
$wD = (float) $cfg['weights']['downloads'];
|
||||||
|
|
||||||
// 3.1 Base engagement (7-day window)
|
// V2 weights for shares + comments (from v2 config, with defaults)
|
||||||
|
$wC = (float) ($cfg['v2']['weights']['comments'] ?? 3.0);
|
||||||
|
$wS = (float) ($cfg['v2']['weights']['shares'] ?? 4.0);
|
||||||
|
|
||||||
|
// 3.1 Base engagement (7-day window) — V2: includes shares & comments
|
||||||
$E = ($wV * log(1 + (float) $row->views_7d))
|
$E = ($wV * log(1 + (float) $row->views_7d))
|
||||||
+ ($wF * log(1 + (float) $row->favourites_7d))
|
+ ($wF * log(1 + (float) $row->favourites_7d))
|
||||||
+ ($wD * log(1 + (float) $row->downloads_7d));
|
+ ($wD * log(1 + (float) $row->downloads_7d))
|
||||||
|
+ ($wC * log(1 + (float) ($row->comments_24h ?? 0) * 7))
|
||||||
|
+ ($wS * log(1 + (float) ($row->shares_24h ?? 0) * 7));
|
||||||
|
|
||||||
// Base engagement (all-time, for "best" score)
|
// Base engagement (all-time, for "best" score)
|
||||||
$E_all = ($wV * log(1 + (float) $row->views_all))
|
$E_all = ($wV * log(1 + (float) $row->views_all))
|
||||||
+ ($wF * log(1 + (float) $row->favourites_all))
|
+ ($wF * log(1 + (float) $row->favourites_all))
|
||||||
+ ($wD * log(1 + (float) $row->downloads_all));
|
+ ($wD * log(1 + (float) $row->downloads_all))
|
||||||
|
+ ($wC * log(1 + (float) ($row->comments_count ?? 0)))
|
||||||
|
+ ($wS * log(1 + (float) ($row->shares_count ?? 0)));
|
||||||
|
|
||||||
// 3.2 Freshness decay
|
// 3.2 Freshness decay
|
||||||
$ageH = max(0.0, (float) $row->age_hours);
|
$ageH = max(0.0, (float) $row->age_hours);
|
||||||
@@ -77,6 +91,14 @@ final class RankingService
|
|||||||
$noveltyW = (float) $cfg['novelty_weight'];
|
$noveltyW = (float) $cfg['novelty_weight'];
|
||||||
$novelty = 1.0 + $noveltyW * exp(-$ageH / 24.0);
|
$novelty = 1.0 + $noveltyW * exp(-$ageH / 24.0);
|
||||||
|
|
||||||
|
// 3.5 Velocity boost (V2) — 24h engagement burst
|
||||||
|
$vw = $cfg['v2']['velocity_weights'] ?? ['views' => 1, 'favourites' => 3, 'comments' => 4, 'shares' => 5];
|
||||||
|
$velocityRaw = ((float) ($vw['views'] ?? 1) * (float) ($row->views_24h ?? 0))
|
||||||
|
+ ((float) ($vw['favourites'] ?? 3) * (float) ($row->favourites_24h ?? 0))
|
||||||
|
+ ((float) ($vw['comments'] ?? 4) * (float) ($row->comments_24h ?? 0))
|
||||||
|
+ ((float) ($vw['shares'] ?? 5) * (float) ($row->shares_24h ?? 0));
|
||||||
|
$velocityBoost = $velocityRaw * (float) ($cfg['v2']['velocity_multiplier'] ?? 0.5);
|
||||||
|
|
||||||
// Anti-spam damping on trending score only
|
// Anti-spam damping on trending score only
|
||||||
$spamFactor = 1.0;
|
$spamFactor = 1.0;
|
||||||
$spam = $cfg['spam'];
|
$spam = $cfg['spam'];
|
||||||
@@ -84,8 +106,8 @@ final class RankingService
|
|||||||
(float) $row->views_24h > (float) $spam['views_24h_threshold']
|
(float) $row->views_24h > (float) $spam['views_24h_threshold']
|
||||||
&& (float) $row->views_24h > 0
|
&& (float) $row->views_24h > 0
|
||||||
) {
|
) {
|
||||||
$rF = (float) $row->favourites_24h / (float) $row->views_24h;
|
$rF = (float) ($row->favourites_24h ?? 0) / (float) $row->views_24h;
|
||||||
$rD = (float) $row->downloads_24h / (float) $row->views_24h;
|
$rD = (float) ($row->downloads_24h ?? 0) / (float) $row->views_24h;
|
||||||
if ($rF < (float) $spam['fav_ratio_threshold']
|
if ($rF < (float) $spam['fav_ratio_threshold']
|
||||||
&& $rD < (float) $spam['dl_ratio_threshold']
|
&& $rD < (float) $spam['dl_ratio_threshold']
|
||||||
) {
|
) {
|
||||||
@@ -93,8 +115,8 @@ final class RankingService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$scoreTrending = $E * $decayTrending * (1.0 + $Q) * $spamFactor;
|
$scoreTrending = ($E * $decayTrending * (1.0 + $Q) * $spamFactor) + $velocityBoost;
|
||||||
$scoreNewHot = $E * $decayNewHot * $novelty * (1.0 + $Q);
|
$scoreNewHot = ($E * $decayNewHot * $novelty * (1.0 + $Q)) + ($velocityBoost * 0.7);
|
||||||
$scoreBest = $E_all * $decayBest * (1.0 + $Q);
|
$scoreBest = $E_all * $decayBest * (1.0 + $Q);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -276,18 +298,22 @@ final class RankingService
|
|||||||
* Return a query builder that selects all artwork signals needed for score
|
* Return a query builder that selects all artwork signals needed for score
|
||||||
* computation. Results are NOT paginated — callers chunk them.
|
* computation. Results are NOT paginated — callers chunk them.
|
||||||
*
|
*
|
||||||
* Columns returned:
|
* V2 columns returned:
|
||||||
* id, user_id, published_at, is_public, is_approved,
|
* id, user_id, published_at, is_public, is_approved,
|
||||||
* thumb_ext (→ has_thumbnail),
|
* has_thumbnail,
|
||||||
* views_7d, downloads_7d, views_24h, downloads_24h,
|
* views_7d, downloads_7d, views_24h, downloads_24h,
|
||||||
* views_all, downloads_all, favourites_all,
|
* views_all, downloads_all, favourites_all,
|
||||||
* favourites_7d, favourites_24h, downloads_24h,
|
* favourites_7d, favourites_24h,
|
||||||
|
* comments_count, shares_count, comments_24h, shares_24h,
|
||||||
* tag_count,
|
* tag_count,
|
||||||
* age_hours
|
* age_hours
|
||||||
*/
|
*/
|
||||||
public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder
|
public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder
|
||||||
{
|
{
|
||||||
return DB::table('artworks as a')
|
$hasSharesTable = \Illuminate\Support\Facades\Schema::hasTable('artwork_shares');
|
||||||
|
$hasCommentsTable = \Illuminate\Support\Facades\Schema::hasTable('artwork_comments');
|
||||||
|
|
||||||
|
$query = DB::table('artworks as a')
|
||||||
->select([
|
->select([
|
||||||
'a.id',
|
'a.id',
|
||||||
'a.user_id',
|
'a.user_id',
|
||||||
@@ -304,6 +330,27 @@ final class RankingService
|
|||||||
DB::raw('COALESCE(ast.favorites, 0) AS favourites_all'),
|
DB::raw('COALESCE(ast.favorites, 0) AS favourites_all'),
|
||||||
DB::raw('COALESCE(fav7.cnt, 0) AS favourites_7d'),
|
DB::raw('COALESCE(fav7.cnt, 0) AS favourites_7d'),
|
||||||
DB::raw('COALESCE(fav1.cnt, 0) AS favourites_24h'),
|
DB::raw('COALESCE(fav1.cnt, 0) AS favourites_24h'),
|
||||||
|
// V2: comments + shares
|
||||||
|
DB::raw(
|
||||||
|
$hasCommentsTable
|
||||||
|
? 'COALESCE(cc_all.cnt, 0) AS comments_count'
|
||||||
|
: '0 AS comments_count'
|
||||||
|
),
|
||||||
|
DB::raw(
|
||||||
|
$hasSharesTable
|
||||||
|
? 'COALESCE(sc_all.cnt, 0) AS shares_count'
|
||||||
|
: '0 AS shares_count'
|
||||||
|
),
|
||||||
|
DB::raw(
|
||||||
|
$hasCommentsTable
|
||||||
|
? 'COALESCE(cc24.cnt, 0) AS comments_24h'
|
||||||
|
: '0 AS comments_24h'
|
||||||
|
),
|
||||||
|
DB::raw(
|
||||||
|
$hasSharesTable
|
||||||
|
? 'COALESCE(sc24.cnt, 0) AS shares_24h'
|
||||||
|
: '0 AS shares_24h'
|
||||||
|
),
|
||||||
DB::raw('COALESCE(tc.tag_count, 0) AS tag_count'),
|
DB::raw('COALESCE(tc.tag_count, 0) AS tag_count'),
|
||||||
DB::raw('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'),
|
DB::raw('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'),
|
||||||
])
|
])
|
||||||
@@ -338,5 +385,47 @@ final class RankingService
|
|||||||
->where('a.is_approved', 1)
|
->where('a.is_approved', 1)
|
||||||
->whereNull('a.deleted_at')
|
->whereNull('a.deleted_at')
|
||||||
->whereNotNull('a.published_at');
|
->whereNotNull('a.published_at');
|
||||||
|
|
||||||
|
// V2: Comments (all-time + 24h)
|
||||||
|
if ($hasCommentsTable) {
|
||||||
|
$query->leftJoinSub(
|
||||||
|
DB::table('artwork_comments')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'cc_all',
|
||||||
|
'cc_all.artwork_id', '=', 'a.id'
|
||||||
|
);
|
||||||
|
$query->leftJoinSub(
|
||||||
|
DB::table('artwork_comments')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'cc24',
|
||||||
|
'cc24.artwork_id', '=', 'a.id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2: Shares (all-time + 24h)
|
||||||
|
if ($hasSharesTable) {
|
||||||
|
$query->leftJoinSub(
|
||||||
|
DB::table('artwork_shares')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'sc_all',
|
||||||
|
'sc_all.artwork_id', '=', 'a.id'
|
||||||
|
);
|
||||||
|
$query->leftJoinSub(
|
||||||
|
DB::table('artwork_shares')
|
||||||
|
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||||
|
->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
|
||||||
|
->groupBy('artwork_id'),
|
||||||
|
'sc24',
|
||||||
|
'sc24.artwork_id', '=', 'a.id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ declare(strict_types=1);
|
|||||||
return [
|
return [
|
||||||
|
|
||||||
// ── Model versioning ────────────────────────────────────────────────────
|
// ── Model versioning ────────────────────────────────────────────────────
|
||||||
'model_version' => 'rank_v1',
|
'model_version' => 'rank_v2',
|
||||||
|
|
||||||
// ── Engagement signal weights (log-scaled) ──────────────────────────────
|
// ── Engagement signal weights (log-scaled) ──────────────────────────────
|
||||||
'weights' => [
|
'weights' => [
|
||||||
@@ -20,6 +20,36 @@ return [
|
|||||||
'downloads' => 2.5,
|
'downloads' => 2.5,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ── V2 ranking formula weights ──────────────────────────────────────────
|
||||||
|
'v2' => [
|
||||||
|
// Base engagement weights (linear, applied to raw counts)
|
||||||
|
'weights' => [
|
||||||
|
'views' => 0.2,
|
||||||
|
'downloads' => 1.5,
|
||||||
|
'favourites' => 2.5,
|
||||||
|
'comments' => 3.0,
|
||||||
|
'shares' => 4.0, // highest — viral intent
|
||||||
|
],
|
||||||
|
|
||||||
|
// 24h velocity weights
|
||||||
|
'velocity_weights' => [
|
||||||
|
'views' => 1.0,
|
||||||
|
'favourites' => 3.0,
|
||||||
|
'comments' => 4.0,
|
||||||
|
'shares' => 5.0,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Velocity multiplier applied to the 24h velocity sum
|
||||||
|
'velocity_multiplier' => 0.5,
|
||||||
|
|
||||||
|
// Recency decay half-life in hours
|
||||||
|
'half_life' => 48,
|
||||||
|
|
||||||
|
// Author authority: multiplier = 1 + (authority * factor)
|
||||||
|
'authority_factor' => 0.05,
|
||||||
|
'authority_fav_divisor' => 1000,
|
||||||
|
],
|
||||||
|
|
||||||
// ── Time-decay half-lives (hours) ───────────────────────────────────────
|
// ── Time-decay half-lives (hours) ───────────────────────────────────────
|
||||||
'half_life' => [
|
'half_life' => [
|
||||||
'trending' => 72, // Explore / global trending
|
'trending' => 72, // Explore / global trending
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ return [
|
|||||||
'favorites_count',
|
'favorites_count',
|
||||||
'awards_received_count',
|
'awards_received_count',
|
||||||
'downloads_count',
|
'downloads_count',
|
||||||
|
'ranking_score',
|
||||||
|
'shares_count',
|
||||||
|
'engagement_velocity',
|
||||||
|
'comments_count',
|
||||||
],
|
],
|
||||||
'rankingRules' => [
|
'rankingRules' => [
|
||||||
'words',
|
'words',
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranking Engine V2 — add shares_count, comments_count, ranking_score,
|
||||||
|
* and engagement_velocity to artwork_stats.
|
||||||
|
*
|
||||||
|
* Also adds shares_24h, comments_24h, favourites_24h, and comments_7d
|
||||||
|
* sliding-window counters needed for velocity calculation.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('artwork_stats', function (Blueprint $table) {
|
||||||
|
// Denormalised counters (all-time)
|
||||||
|
$table->unsignedBigInteger('comments_count')->default(0)->after('favorites');
|
||||||
|
$table->unsignedBigInteger('shares_count')->default(0)->after('comments_count');
|
||||||
|
|
||||||
|
// Windowed counters for velocity calculation
|
||||||
|
$table->unsignedBigInteger('shares_24h')->default(0)->after('downloads_7d');
|
||||||
|
$table->unsignedBigInteger('comments_24h')->default(0)->after('shares_24h');
|
||||||
|
$table->unsignedBigInteger('favourites_24h')->default(0)->after('comments_24h');
|
||||||
|
|
||||||
|
// V2 computed scores
|
||||||
|
$table->double('ranking_score', 12, 4)->default(0)->after('rating_count');
|
||||||
|
$table->double('engagement_velocity', 10, 4)->default(0)->after('ranking_score');
|
||||||
|
|
||||||
|
// Indexes for sorting
|
||||||
|
$table->index('ranking_score');
|
||||||
|
$table->index('shares_count');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('artwork_stats', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['ranking_score']);
|
||||||
|
$table->dropIndex(['shares_count']);
|
||||||
|
$table->dropColumn([
|
||||||
|
'comments_count',
|
||||||
|
'shares_count',
|
||||||
|
'shares_24h',
|
||||||
|
'comments_24h',
|
||||||
|
'favourites_24h',
|
||||||
|
'ranking_score',
|
||||||
|
'engagement_velocity',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -63,3 +63,12 @@ Schedule::command('skinbase:prune-view-events --days=90')
|
|||||||
->at('04:00')
|
->at('04:00')
|
||||||
->name('prune-view-events')
|
->name('prune-view-events')
|
||||||
->withoutOverlapping();
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
// ── Ranking Engine V2 ──────────────────────────────────────────────────────────
|
||||||
|
// Recalculate ranking_score + engagement_velocity every 30 minutes.
|
||||||
|
// Also syncs V2 scores to rank_artwork_scores so list builds benefit.
|
||||||
|
Schedule::command('nova:recalculate-rankings --sync-rank-scores')
|
||||||
|
->everyThirtyMinutes()
|
||||||
|
->name('ranking-v2')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|||||||
Reference in New Issue
Block a user