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:
2026-02-28 16:41:15 +01:00
parent 90f244f264
commit de3ec22ee5
10 changed files with 837 additions and 14 deletions

View File

@@ -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;
}
}