Upload beautify
This commit is contained in:
106
app/Console/Commands/AggregateFeedAnalyticsCommand.php
Normal file
106
app/Console/Commands/AggregateFeedAnalyticsCommand.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AggregateFeedAnalyticsCommand extends Command
|
||||
{
|
||||
protected $signature = 'analytics:aggregate-feed {--date= : Date (Y-m-d), defaults to yesterday}';
|
||||
|
||||
protected $description = 'Aggregate feed analytics into daily metrics by algo version and source';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$date = $this->option('date')
|
||||
? (string) $this->option('date')
|
||||
: now()->subDay()->toDateString();
|
||||
|
||||
$rows = DB::table('feed_events')
|
||||
->selectRaw('algo_version, source')
|
||||
->selectRaw("SUM(CASE WHEN event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
|
||||
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
|
||||
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds IS NOT NULL AND dwell_seconds < 5 THEN 1 ELSE 0 END) AS dwell_0_5")
|
||||
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 5 AND dwell_seconds < 30 THEN 1 ELSE 0 END) AS dwell_5_30")
|
||||
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 30 AND dwell_seconds < 120 THEN 1 ELSE 0 END) AS dwell_30_120")
|
||||
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 120 THEN 1 ELSE 0 END) AS dwell_120_plus")
|
||||
->whereDate('event_date', $date)
|
||||
->groupBy('algo_version', 'source')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$algoVersion = (string) $row->algo_version;
|
||||
$source = (string) $row->source;
|
||||
$impressions = (int) ($row->impressions ?? 0);
|
||||
$clicks = (int) ($row->clicks ?? 0);
|
||||
|
||||
$saves = $this->countSavesForGroup($date, $algoVersion, $source);
|
||||
|
||||
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
|
||||
|
||||
DB::table('feed_daily_metrics')->updateOrInsert(
|
||||
[
|
||||
'metric_date' => $date,
|
||||
'algo_version' => $algoVersion,
|
||||
'source' => $source,
|
||||
],
|
||||
[
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'saves' => $saves,
|
||||
'ctr' => $ctr,
|
||||
'save_rate' => $saveRate,
|
||||
'dwell_0_5' => (int) ($row->dwell_0_5 ?? 0),
|
||||
'dwell_5_30' => (int) ($row->dwell_5_30 ?? 0),
|
||||
'dwell_30_120' => (int) ($row->dwell_30_120 ?? 0),
|
||||
'dwell_120_plus' => (int) ($row->dwell_120_plus ?? 0),
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->info("Aggregated feed analytics for {$date}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function countSavesForGroup(string $date, string $algoVersion, string $source): int
|
||||
{
|
||||
/** @var Collection<int, object{user_id:int,artwork_id:int}> $clickedPairs */
|
||||
$clickedPairs = DB::table('feed_events')
|
||||
->select('user_id', 'artwork_id')
|
||||
->whereDate('event_date', $date)
|
||||
->where('event_type', 'feed_click')
|
||||
->where('algo_version', $algoVersion)
|
||||
->where('source', $source)
|
||||
->groupBy('user_id', 'artwork_id')
|
||||
->get();
|
||||
|
||||
if ($clickedPairs->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$saves = 0;
|
||||
foreach ($clickedPairs as $pair) {
|
||||
$hasSave = DB::table('user_discovery_events')
|
||||
->whereDate('event_date', $date)
|
||||
->where('user_id', (int) $pair->user_id)
|
||||
->where('artwork_id', (int) $pair->artwork_id)
|
||||
->where('algo_version', $algoVersion)
|
||||
->whereIn('event_type', ['favorite', 'download'])
|
||||
->exists();
|
||||
|
||||
if ($hasSave) {
|
||||
$saves++;
|
||||
}
|
||||
}
|
||||
|
||||
return $saves;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AggregateSimilarArtworkAnalyticsCommand extends Command
|
||||
{
|
||||
protected $signature = 'analytics:aggregate-similar-artworks {--date= : Date (Y-m-d), defaults to yesterday}';
|
||||
|
||||
protected $description = 'Aggregate similar artwork analytics into daily counts by algo version';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$date = $this->option('date')
|
||||
? (string) $this->option('date')
|
||||
: now()->subDay()->toDateString();
|
||||
|
||||
$rows = DB::table('similar_artwork_events')
|
||||
->selectRaw('algo_version')
|
||||
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
|
||||
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
|
||||
->whereDate('event_date', $date)
|
||||
->groupBy('algo_version')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$impressions = (int) ($row->impressions ?? 0);
|
||||
$clicks = (int) ($row->clicks ?? 0);
|
||||
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||
|
||||
DB::table('similar_artwork_daily_metrics')->updateOrInsert(
|
||||
[
|
||||
'metric_date' => $date,
|
||||
'algo_version' => (string) $row->algo_version,
|
||||
],
|
||||
[
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'ctr' => $ctr,
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->info("Aggregated similar artwork analytics for {$date}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
83
app/Console/Commands/AvatarsMigrate.php
Normal file
83
app/Console/Commands/AvatarsMigrate.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\AvatarService;
|
||||
|
||||
class AvatarsMigrate extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'avatars:migrate {--force}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Migrate legacy avatars to new WebP avatar storage';
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct(AvatarService $service)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting avatar migration...');
|
||||
|
||||
// Try to read legacy data from user_profiles.avatar_legacy or users.avatar_legacy or users.icon
|
||||
$rows = DB::table('user_profiles')->select('user_id', 'avatar_legacy')->whereNotNull('avatar_legacy')->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
// fallback to users table
|
||||
$rows = DB::table('users')->select('user_id', 'icon as avatar_legacy')->whereNotNull('icon')->get();
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
$userId = $row->user_id;
|
||||
$legacy = $row->avatar_legacy ?? null;
|
||||
if (!$legacy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try common legacy paths
|
||||
$candidates = [
|
||||
public_path('user-picture/' . $legacy),
|
||||
public_path('avatar/' . $userId . '/' . $legacy),
|
||||
storage_path('app/public/user-picture/' . $legacy),
|
||||
storage_path('app/public/avatar/' . $userId . '/' . $legacy),
|
||||
];
|
||||
|
||||
$found = false;
|
||||
foreach ($candidates as $p) {
|
||||
if (file_exists($p) && is_readable($p)) {
|
||||
$this->info("Processing user {$userId} from {$p}");
|
||||
$hash = $this->service->storeFromLegacyFile($userId, $p);
|
||||
if ($hash) {
|
||||
$this->info(" -> migrated, hash={$hash}");
|
||||
$count++;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
$this->warn("Legacy file not found for user {$userId}, filename={$legacy}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Migration complete. Processed: {$count}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/BackfillArtworkEmbeddingsCommand.php
Normal file
28
app/Console/Commands/BackfillArtworkEmbeddingsCommand.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\BackfillArtworkEmbeddingsJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class BackfillArtworkEmbeddingsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:embeddings-backfill {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--force : Regenerate even when source hash matches}';
|
||||
|
||||
protected $description = 'Queue resumable CLIP embedding backfill for artworks';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
$batch = max(1, min((int) $this->option('batch'), 1000));
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
BackfillArtworkEmbeddingsJob::dispatch($afterId, $batch, $force);
|
||||
|
||||
$this->info("Queued artwork embedding backfill (after_id={$afterId}, batch={$batch}, force=" . ($force ? 'yes' : 'no') . ').');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
71
app/Console/Commands/CompareFeedAbCommand.php
Normal file
71
app/Console/Commands/CompareFeedAbCommand.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class CompareFeedAbCommand extends Command
|
||||
{
|
||||
protected $signature = 'analytics:compare-feed-ab
|
||||
{baseline : Baseline algo_version}
|
||||
{candidate : Candidate algo_version}
|
||||
{--from= : Start date (Y-m-d), defaults to last 30 days}
|
||||
{--to= : End date (Y-m-d), defaults to today}
|
||||
{--json : Output as JSON}';
|
||||
|
||||
protected $description = 'A/B helper for baseline vs candidate feed algo comparison';
|
||||
|
||||
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
|
||||
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||
|
||||
if ($from > $to) {
|
||||
$this->error('Invalid range: --from must be <= --to');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$baseline = (string) $this->argument('baseline');
|
||||
$candidate = (string) $this->argument('candidate');
|
||||
|
||||
$comparison = $this->evaluator->compareBaselineCandidate($baseline, $candidate, $from, $to);
|
||||
|
||||
if ((bool) $this->option('json')) {
|
||||
$this->line((string) json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||
[[
|
||||
(string) $comparison['baseline']['algo_version'],
|
||||
(float) $comparison['baseline']['ctr'],
|
||||
(float) $comparison['baseline']['save_rate'],
|
||||
(float) $comparison['baseline']['long_dwell_share'],
|
||||
(float) $comparison['baseline']['bounce_rate'],
|
||||
(float) $comparison['baseline']['objective_score'],
|
||||
], [
|
||||
(string) $comparison['candidate']['algo_version'],
|
||||
(float) $comparison['candidate']['ctr'],
|
||||
(float) $comparison['candidate']['save_rate'],
|
||||
(float) $comparison['candidate']['long_dwell_share'],
|
||||
(float) $comparison['candidate']['bounce_rate'],
|
||||
(float) $comparison['candidate']['objective_score'],
|
||||
]]
|
||||
);
|
||||
|
||||
$delta = (array) $comparison['delta'];
|
||||
$this->line('Δ objective_score: ' . (string) $delta['objective_score']);
|
||||
$this->line('Δ objective_lift_pct: ' . (string) ($delta['objective_lift_pct'] ?? 'n/a'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
81
app/Console/Commands/EvaluateFeedWeightsCommand.php
Normal file
81
app/Console/Commands/EvaluateFeedWeightsCommand.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class EvaluateFeedWeightsCommand extends Command
|
||||
{
|
||||
protected $signature = 'analytics:evaluate-feed-weights
|
||||
{--algo= : Optional algo_version to evaluate}
|
||||
{--from= : Start date (Y-m-d), defaults to last 30 days}
|
||||
{--to= : End date (Y-m-d), defaults to today}
|
||||
{--json : Output as JSON}';
|
||||
|
||||
protected $description = 'Offline feed weight evaluation using feed_daily_metrics';
|
||||
|
||||
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
|
||||
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||
$algo = $this->option('algo') ? (string) $this->option('algo') : null;
|
||||
|
||||
if ($from > $to) {
|
||||
$this->error('Invalid range: --from must be <= --to');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($algo !== null && $algo !== '') {
|
||||
$result = $this->evaluator->evaluateAlgo($algo, $from, $to);
|
||||
|
||||
if ((bool) $this->option('json')) {
|
||||
$this->line((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
} else {
|
||||
$this->table(
|
||||
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||
[[
|
||||
(string) $result['algo_version'],
|
||||
(float) $result['ctr'],
|
||||
(float) $result['save_rate'],
|
||||
(float) $result['long_dwell_share'],
|
||||
(float) $result['bounce_rate'],
|
||||
(float) $result['objective_score'],
|
||||
]]
|
||||
);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$results = $this->evaluator->evaluateAll($from, $to);
|
||||
|
||||
if ((bool) $this->option('json')) {
|
||||
$this->line((string) json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = array_map(static fn (array $row): array => [
|
||||
(string) $row['algo_version'],
|
||||
(float) $row['ctr'],
|
||||
(float) $row['save_rate'],
|
||||
(float) $row['long_dwell_share'],
|
||||
(float) $row['bounce_rate'],
|
||||
(float) $row['objective_score'],
|
||||
], $results);
|
||||
|
||||
$this->table(
|
||||
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||
$rows
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
DB::table('user_profiles')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'bio' => $row->about_me ?: $row->description ?: null,
|
||||
'about' => $row->about_me ?: $row->description ?: null,
|
||||
'avatar' => $row->picture ?: null,
|
||||
'cover_image' => $row->cover_art ?: null,
|
||||
'country' => $row->country ?: null,
|
||||
@@ -115,15 +115,7 @@ class ImportLegacyUsers extends Command
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (!empty($row->web)) {
|
||||
DB::table('user_social_links')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'platform' => 'website',
|
||||
'url' => $row->web,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
// Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`.
|
||||
|
||||
DB::table('user_statistics')->insert([
|
||||
'user_id' => $legacyId,
|
||||
|
||||
@@ -6,6 +6,12 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use App\Console\Commands\ImportLegacyUsers;
|
||||
use App\Console\Commands\ImportCategories;
|
||||
use App\Console\Commands\MigrateFeaturedWorks;
|
||||
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
@@ -18,7 +24,14 @@ class Kernel extends ConsoleKernel
|
||||
ImportLegacyUsers::class,
|
||||
ImportCategories::class,
|
||||
MigrateFeaturedWorks::class,
|
||||
\App\Console\Commands\AvatarsMigrate::class,
|
||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||
CleanupUploadsCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
EvaluateFeedWeightsCommand::class,
|
||||
CompareFeedAbCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -26,7 +39,9 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user