Files
SkinbaseNova/.deploy/artwork-evolution-release/app/Console/Commands/RecalculateHeatCommand.php
2026-04-18 17:02:56 +02:00

201 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 1015 minutes via scheduler.
*
* Formula:
* raw_heat = ((views_delta*1 + downloads_delta*3 + favourites_delta*6
* + comments_delta*8 + shares_delta*12) / window_hours)
*
* 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 --lookback-hours=24 --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}
{--lookback-hours=24 : Smooth heat deltas over this many trailing hours}
{--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');
$lookbackHours = max(1, (int) $this->option('lookback-hours'));
$dryRun = (bool) $this->option('dry-run');
$now = now();
$currentHour = $now->copy()->startOfHour();
$prevHour = $currentHour->copy()->subHour();
$lookbackStart = $currentHour->copy()->subHours($lookbackHours);
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} lookback_start={$lookbackStart->toDateTimeString()} lookback_hours={$lookbackHours} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
$updatedCount = 0;
$skippedCount = 0;
// Process in chunks using artwork IDs that have at least one snapshot in the smoothing window
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->distinct()
->pluck('artwork_id');
if ($artworkIds->isEmpty()) {
$this->warn('No snapshots found inside the requested lookback window. Run nova:metrics-snapshot-hourly first.');
return self::SUCCESS;
}
// Load all snapshots for the lookback window in bulk
$snapshots = DB::table('artwork_metric_snapshots_hourly')
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->whereIn('artwork_id', $artworkIds)
->orderBy('bucket_hour')
->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());
if (! $currentSnapshot) {
$currentSnapshot = $artworkSnapshots->last();
}
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
$baselineSnapshot = $artworkSnapshots
->filter(fn ($snapshot) => (string) $snapshot->bucket_hour < (string) ($currentSnapshot->bucket_hour ?? ''))
->first();
if (! $currentSnapshot) {
$skippedCount++;
continue;
}
// One-hour counters remain explicit fields for dashboards and debugging.
$viewsDelta1h = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
$downloadsDelta1h = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
$favouritesDelta1h = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
$commentsDelta1h = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
$sharesDelta1h = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
// Smooth the heat signal over a trailing window so low-traffic periods do not flatten Rising.
// A single snapshot without an earlier baseline should not count as new momentum.
if ($baselineSnapshot) {
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($baselineSnapshot->views_count ?? 0));
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($baselineSnapshot->downloads_count ?? 0));
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($baselineSnapshot->favourites_count ?? 0));
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($baselineSnapshot->comments_count ?? 0));
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($baselineSnapshot->shares_count ?? 0));
$windowHours = max(
1.0,
abs($currentHour->copy()->parse($currentSnapshot->bucket_hour)->floatDiffInHours($currentHour->copy()->parse($baselineSnapshot->bucket_hour)))
);
} else {
$viewsDelta = 0;
$downloadsDelta = 0;
$favouritesDelta = 0;
$commentsDelta = 0;
$sharesDelta = 0;
$windowHours = 1.0;
}
// Raw heat
$rawHeat = (
($viewsDelta * self::WEIGHTS['views'])
+ ($downloadsDelta * self::WEIGHTS['downloads'])
+ ($favouritesDelta * self::WEIGHTS['favourites'])
+ ($commentsDelta * self::WEIGHTS['comments'])
+ ($sharesDelta * self::WEIGHTS['shares'])
) / $windowHours;
// 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' => $viewsDelta1h,
'downloads_1h' => $downloadsDelta1h,
'favourites_1h' => $favouritesDelta1h,
'comments_1h' => $commentsDelta1h,
'shares_1h' => $sharesDelta1h,
];
$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;
}
}