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:
@@ -5,20 +5,23 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RankArtworkScore;
|
||||
use App\Models\RankList;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* RankingService — Skinbase Nova rank_v1
|
||||
* RankingService — Skinbase Nova rank_v2
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Score computation — turn raw artwork signals into three float scores.
|
||||
* 2. Diversity filtering — cap items per author while keeping rank order.
|
||||
* 3. List read / cache — serve ranked lists from Redis, falling back to DB,
|
||||
* 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
|
||||
{
|
||||
@@ -27,10 +30,13 @@ final class RankingService
|
||||
/**
|
||||
* Compute all three ranking scores for a single artwork data row.
|
||||
*
|
||||
* V2: includes shares, comments, and velocity boost.
|
||||
*
|
||||
* @param object $row stdClass with fields:
|
||||
* views_7d, favourites_7d, downloads_7d,
|
||||
* views_all, favourites_all, downloads_all,
|
||||
* views_24h, favourites_24h, downloads_24h,
|
||||
* comments_count, shares_count, comments_24h, shares_24h,
|
||||
* age_hours, tag_count, has_thumbnail (bool 0/1),
|
||||
* is_public, is_approved
|
||||
* @return array{score_trending: float, score_new_hot: float, score_best: float}
|
||||
@@ -43,15 +49,23 @@ final class RankingService
|
||||
$wF = (float) $cfg['weights']['favourites'];
|
||||
$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))
|
||||
+ ($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)
|
||||
$E_all = ($wV * log(1 + (float) $row->views_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
|
||||
$ageH = max(0.0, (float) $row->age_hours);
|
||||
@@ -77,6 +91,14 @@ final class RankingService
|
||||
$noveltyW = (float) $cfg['novelty_weight'];
|
||||
$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
|
||||
$spamFactor = 1.0;
|
||||
$spam = $cfg['spam'];
|
||||
@@ -84,8 +106,8 @@ final class RankingService
|
||||
(float) $row->views_24h > (float) $spam['views_24h_threshold']
|
||||
&& (float) $row->views_24h > 0
|
||||
) {
|
||||
$rF = (float) $row->favourites_24h / (float) $row->views_24h;
|
||||
$rD = (float) $row->downloads_24h / (float) $row->views_24h;
|
||||
$rF = (float) ($row->favourites_24h ?? 0) / (float) $row->views_24h;
|
||||
$rD = (float) ($row->downloads_24h ?? 0) / (float) $row->views_24h;
|
||||
if ($rF < (float) $spam['fav_ratio_threshold']
|
||||
&& $rD < (float) $spam['dl_ratio_threshold']
|
||||
) {
|
||||
@@ -93,8 +115,8 @@ final class RankingService
|
||||
}
|
||||
}
|
||||
|
||||
$scoreTrending = $E * $decayTrending * (1.0 + $Q) * $spamFactor;
|
||||
$scoreNewHot = $E * $decayNewHot * $novelty * (1.0 + $Q);
|
||||
$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 [
|
||||
@@ -276,18 +298,22 @@ final class RankingService
|
||||
* Return a query builder that selects all artwork signals needed for score
|
||||
* computation. Results are NOT paginated — callers chunk them.
|
||||
*
|
||||
* Columns returned:
|
||||
* V2 columns returned:
|
||||
* id, user_id, published_at, is_public, is_approved,
|
||||
* thumb_ext (→ has_thumbnail),
|
||||
* has_thumbnail,
|
||||
* views_7d, downloads_7d, views_24h, downloads_24h,
|
||||
* 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,
|
||||
* age_hours
|
||||
*/
|
||||
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([
|
||||
'a.id',
|
||||
'a.user_id',
|
||||
@@ -304,6 +330,27 @@ final class RankingService
|
||||
DB::raw('COALESCE(ast.favorites, 0) AS favourites_all'),
|
||||
DB::raw('COALESCE(fav7.cnt, 0) AS favourites_7d'),
|
||||
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('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'),
|
||||
])
|
||||
@@ -338,5 +385,47 @@ final class RankingService
|
||||
->where('a.is_approved', 1)
|
||||
->whereNull('a.deleted_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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user