167 lines
6.7 KiB
PHP
167 lines
6.7 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
/**
|
||
* Recalculate heat_score for artworks based on hourly metric snapshots.
|
||
*
|
||
* Runs every 10–15 minutes via scheduler.
|
||
*
|
||
* Formula:
|
||
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||
* + comments_delta*8 + shares_delta*12
|
||
*
|
||
* age_factor = 1 / (1 + hours_since_upload / 24)
|
||
*
|
||
* heat_score = raw_heat * age_factor
|
||
*
|
||
* Usage: php artisan nova:recalculate-heat
|
||
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
|
||
*/
|
||
class RecalculateHeatCommand extends Command
|
||
{
|
||
protected $signature = 'nova:recalculate-heat
|
||
{--days=60 : Only process artworks created within this many days}
|
||
{--chunk=1000 : Chunk size for DB queries}
|
||
{--dry-run : Compute scores without writing to DB}';
|
||
|
||
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
||
|
||
/** Delta weights per the spec */
|
||
private const WEIGHTS = [
|
||
'views' => 1,
|
||
'downloads' => 3,
|
||
'favourites' => 6,
|
||
'comments' => 8,
|
||
'shares' => 12,
|
||
];
|
||
|
||
public function handle(): int
|
||
{
|
||
$days = (int) $this->option('days');
|
||
$chunk = (int) $this->option('chunk');
|
||
$dryRun = (bool) $this->option('dry-run');
|
||
$now = now();
|
||
$currentHour = $now->copy()->startOfHour();
|
||
$prevHour = $currentHour->copy()->subHour();
|
||
|
||
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||
|
||
$updatedCount = 0;
|
||
$skippedCount = 0;
|
||
|
||
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
|
||
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
|
||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||
->distinct()
|
||
->pluck('artwork_id');
|
||
|
||
if ($artworkIds->isEmpty()) {
|
||
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
// Load all snapshots for the two hours in bulk
|
||
$snapshots = DB::table('artwork_metric_snapshots_hourly')
|
||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||
->whereIn('artwork_id', $artworkIds)
|
||
->get()
|
||
->groupBy('artwork_id');
|
||
|
||
// Load artwork published_at dates for age factor (use published_at, fall back to created_at)
|
||
$artworkDates = DB::table('artworks')
|
||
->whereIn('id', $artworkIds)
|
||
->whereNull('deleted_at')
|
||
->where('is_approved', true)
|
||
->select('id', 'published_at', 'created_at')
|
||
->get()
|
||
->mapWithKeys(fn ($row) => [
|
||
$row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at),
|
||
]);
|
||
|
||
// Process in chunks
|
||
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
|
||
$upsertRows = [];
|
||
|
||
foreach ($chunkIds as $artworkId) {
|
||
$createdAt = $artworkDates->get($artworkId);
|
||
if (!$createdAt) {
|
||
$skippedCount++;
|
||
continue;
|
||
}
|
||
|
||
$artworkSnapshots = $snapshots->get($artworkId);
|
||
if (!$artworkSnapshots || $artworkSnapshots->isEmpty()) {
|
||
$skippedCount++;
|
||
continue;
|
||
}
|
||
|
||
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
|
||
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||
|
||
// If we only have one snapshot, use it as current with zero deltas
|
||
if (!$currentSnapshot && !$prevSnapshot) {
|
||
$skippedCount++;
|
||
continue;
|
||
}
|
||
|
||
// Calculate deltas
|
||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||
|
||
// Raw heat
|
||
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
|
||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||
+ ($sharesDelta * self::WEIGHTS['shares']);
|
||
|
||
// Age factor: favors newer works
|
||
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
|
||
$ageFactor = 1.0 / (1.0 + ($hoursSinceUpload / 24.0));
|
||
|
||
// Final heat score
|
||
$heatScore = max(0, $rawHeat * $ageFactor);
|
||
|
||
$upsertRows[] = [
|
||
'artwork_id' => $artworkId,
|
||
'heat_score' => round($heatScore, 4),
|
||
'heat_score_updated_at' => $now,
|
||
'views_1h' => $viewsDelta,
|
||
'downloads_1h' => $downloadsDelta,
|
||
'favourites_1h' => $favouritesDelta,
|
||
'comments_1h' => $commentsDelta,
|
||
'shares_1h' => $sharesDelta,
|
||
];
|
||
|
||
$updatedCount++;
|
||
}
|
||
|
||
if (!$dryRun && !empty($upsertRows)) {
|
||
DB::table('artwork_stats')->upsert(
|
||
$upsertRows,
|
||
['artwork_id'],
|
||
['heat_score', 'heat_score_updated_at', 'views_1h', 'downloads_1h', 'favourites_1h', 'comments_1h', 'shares_1h']
|
||
);
|
||
}
|
||
}
|
||
|
||
$this->info("Heat scores updated: {$updatedCount} | Skipped: {$skippedCount}");
|
||
|
||
Log::info('[nova:recalculate-heat] completed', [
|
||
'updated' => $updatedCount,
|
||
'skipped' => $skippedCount,
|
||
'dry_run' => $dryRun,
|
||
]);
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
}
|