Files
SkinbaseNova/app/Console/Commands/RecalculateHeatCommand.php
T
klevze 8a80aae21e Ship production optimization M1-M12.5A: queues, metrics, HTTP observability, and vector search reliability.
Keep similar-ai from tripping the global circuit on a lone URL 502, clamp Qdrant search to 100, and add Server-Timing plus slow-request logging. Studio shared props, Academy S3 exists caching, heat chunking, and Redis/scheduler hygiene stay in this rollout.
2026-08-25 07:58:47 +02:00

207 lines
9.1 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 10–15 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;
// Distinct IDs only — do not hydrate the full 24h snapshot window in one query.
$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;
}
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
$snapshots = DB::table('artwork_metric_snapshots_hourly')
->select([
'artwork_id',
'bucket_hour',
'views_count',
'downloads_count',
'favourites_count',
'comments_count',
'shares_count',
])
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->whereIn('artwork_id', $chunkIds)
->orderBy('bucket_hour')
->get()
->groupBy('artwork_id');
$artworkDates = DB::table('artworks')
->whereIn('id', $chunkIds)
->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),
]);
$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;
}
}