feat: add tag discovery analytics and reporting
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
167
app/Console/Commands/SeedTagInteractionDemoCommand.php
Normal file
167
app/Console/Commands/SeedTagInteractionDemoCommand.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user