feat: add tag discovery analytics and reporting

This commit is contained in:
2026-03-17 18:23:38 +01:00
parent b3fc889452
commit 2728644477
29 changed files with 2660 additions and 112 deletions

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AggregateTagInteractionAnalyticsCommand extends Command
{
protected $signature = 'analytics:aggregate-tag-interactions {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate tag interaction analytics into daily metrics by surface, tag, source tag, and query';
public function handle(): int
{
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$normalizedTag = "COALESCE(tag_slug, '')";
$normalizedSourceTag = "COALESCE(source_tag_slug, '')";
$normalizedQuery = "LOWER(TRIM(COALESCE(query, '')))";
$rows = DB::table('tag_interaction_events')
->selectRaw('surface')
->selectRaw("{$normalizedTag} AS tag_slug")
->selectRaw("{$normalizedSourceTag} AS source_tag_slug")
->selectRaw("{$normalizedQuery} AS query")
->selectRaw('COUNT(*) AS clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw('AVG(position) AS avg_position')
->whereDate('event_date', $date)
->where('event_type', 'click')
->groupBy('surface', DB::raw($normalizedTag), DB::raw($normalizedSourceTag), DB::raw($normalizedQuery))
->get();
DB::transaction(function () use ($date, $rows): void {
DB::table('tag_interaction_daily_metrics')
->where('metric_date', $date)
->delete();
$payload = $rows->map(static function ($row) use ($date): array {
return [
'metric_date' => $date,
'surface' => (string) $row->surface,
'tag_slug' => trim((string) ($row->tag_slug ?? '')),
'source_tag_slug' => trim((string) ($row->source_tag_slug ?? '')),
'query' => trim((string) ($row->query ?? '')),
'clicks' => (int) ($row->clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
'created_at' => now(),
'updated_at' => now(),
];
})->all();
foreach (array_chunk($payload, 500) as $chunk) {
if ($chunk !== []) {
DB::table('tag_interaction_daily_metrics')->insert($chunk);
}
}
});
$this->info("Aggregated tag interaction analytics for {$date}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tag;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class SeedTagInteractionDemoCommand extends Command
{
protected $signature = 'analytics:seed-tag-interaction-demo
{--days=14 : Number of days to generate demo events for}
{--per-day=90 : Approximate number of demo events to write per day}
{--refresh : Remove existing seeded demo events first}
{--force : Allow running outside local/testing environments}';
protected $description = 'Generate demo tag interaction events for local analytics dashboards and ranking validation';
public function handle(): int
{
if (! app()->environment(['local', 'testing']) && ! $this->option('force')) {
$this->error('This command is restricted to local/testing unless --force is provided.');
return self::FAILURE;
}
$days = max(1, min(60, (int) $this->option('days')));
$perDay = max(10, min(500, (int) $this->option('per-day')));
$tags = Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit(20)
->get(['id', 'name', 'slug', 'usage_count']);
if ($tags->count() < 2) {
$this->error('At least two active tags are required to generate demo interaction data.');
return self::FAILURE;
}
$transitions = $this->buildTransitionMap($tags);
if ($this->option('refresh')) {
DB::table('tag_interaction_events')
->where('meta->seeded_demo', true)
->delete();
}
$now = now();
for ($offset = $days - 1; $offset >= 0; $offset--) {
$date = Carbon::today()->subDays($offset);
$rows = [];
for ($index = 0; $index < $perDay; $index++) {
$surface = $this->pickSurface();
$sourceTag = $tags->random();
$targetTag = $this->pickTargetTag($surface, $sourceTag->slug, $transitions, $tags);
$query = in_array($surface, ['search_suggestion', 'rescue_suggestion', 'recent_search'], true)
? $this->queryForTag($targetTag)
: null;
$rows[] = [
'event_date' => $date->toDateString(),
'event_type' => 'click',
'surface' => $surface,
'user_id' => null,
'session_key' => hash('sha256', 'demo-' . $date->toDateString() . '-' . $index . '-' . $surface),
'tag_slug' => $targetTag->slug,
'source_tag_slug' => in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)
? $sourceTag->slug
: null,
'query' => $query,
'position' => random_int(1, 4),
'meta' => json_encode([
'seeded_demo' => true,
'seeded_at' => $now->toISOString(),
], JSON_THROW_ON_ERROR),
'occurred_at' => $date->copy()->setTime(random_int(8, 23), random_int(0, 59), random_int(0, 59)),
'created_at' => $now,
'updated_at' => $now,
];
}
foreach (array_chunk($rows, 250) as $chunk) {
DB::table('tag_interaction_events')->insert($chunk);
}
$this->call('analytics:aggregate-tag-interactions', ['--date' => $date->toDateString()]);
}
$this->info("Seeded demo tag interaction events for the last {$days} days.");
return self::SUCCESS;
}
private function buildTransitionMap(Collection $tags): array
{
$pairs = DB::table('artwork_tag as source_pivot')
->join('tags as source_tag', 'source_tag.id', '=', 'source_pivot.tag_id')
->join('artwork_tag as target_pivot', 'target_pivot.artwork_id', '=', 'source_pivot.artwork_id')
->join('tags as target_tag', 'target_tag.id', '=', 'target_pivot.tag_id')
->whereIn('source_tag.id', $tags->pluck('id')->all())
->whereIn('target_tag.id', $tags->pluck('id')->all())
->whereColumn('source_tag.id', '!=', 'target_tag.id')
->groupBy('source_tag.slug', 'target_tag.slug')
->orderByRaw('COUNT(*) DESC')
->get([
'source_tag.slug as source_slug',
'target_tag.slug as target_slug',
DB::raw('COUNT(*) as pair_count'),
]);
$map = [];
foreach ($pairs as $pair) {
$map[$pair->source_slug][] = $pair->target_slug;
}
return $map;
}
private function pickSurface(): string
{
$roll = random_int(1, 100);
return match (true) {
$roll <= 32 => 'search_suggestion',
$roll <= 46 => 'rescue_suggestion',
$roll <= 58 => 'recent_search',
$roll <= 80 => 'related_chip',
$roll <= 94 => 'related_cluster',
default => 'top_companion',
};
}
private function pickTargetTag(string $surface, string $sourceSlug, array $transitions, Collection $tags): object
{
if (in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)) {
$candidateSlugs = $transitions[$sourceSlug] ?? [];
if ($candidateSlugs !== []) {
$slug = $candidateSlugs[array_rand($candidateSlugs)];
return $tags->firstWhere('slug', $slug) ?? $tags->where('slug', '!=', $sourceSlug)->random();
}
return $tags->where('slug', '!=', $sourceSlug)->random();
}
return $tags->random();
}
private function queryForTag(object $tag): string
{
$name = trim((string) ($tag->name ?? $tag->slug));
$options = array_values(array_filter([
strtolower($name),
strtolower((string) ($tag->slug ?? '')),
strtolower(substr($name, 0, max(3, min(strlen($name), 7)))),
]));
return $options[array_rand($options)];
}
}

View File

@@ -9,6 +9,8 @@ use App\Console\Commands\MigrateFeaturedWorks;
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
use App\Console\Commands\SeedTagInteractionDemoCommand;
use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\CompareFeedAbCommand;
@@ -41,6 +43,8 @@ class Kernel extends ConsoleKernel
BackfillArtworkEmbeddingsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class,
AggregateFeedAnalyticsCommand::class,
AggregateTagInteractionAnalyticsCommand::class,
SeedTagInteractionDemoCommand::class,
EvaluateFeedWeightsCommand::class,
CompareFeedAbCommand::class,
AiTagArtworksCommand::class,
@@ -66,6 +70,7 @@ class Kernel extends ConsoleKernel
->runInBackground();
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
$schedule->command('analytics:aggregate-tag-interactions')->dailyAt('03:30');
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();