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)];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Analytics\TagInteractionReportService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class TagInteractionReportController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TagInteractionReportService $reportService) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
|
||||
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||
$limit = (int) ($validated['limit'] ?? 15);
|
||||
|
||||
abort_if($from > $to, 422, 'Invalid date range.');
|
||||
|
||||
$report = $this->reportService->buildReport($from, $to, $limit);
|
||||
|
||||
return view('admin.reports.tags', [
|
||||
'filters' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'limit' => $limit,
|
||||
],
|
||||
'overview' => $report['overview'],
|
||||
'dailyClicks' => $report['daily_clicks'],
|
||||
'bySurface' => $report['by_surface'],
|
||||
'topTags' => $report['top_tags'],
|
||||
'topQueries' => $report['top_queries'],
|
||||
'topTransitions' => $report['top_transitions'],
|
||||
'latestAggregatedDate' => $report['latest_aggregated_date'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Analytics\TagInteractionReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class TagInteractionReportController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TagInteractionReportService $reportService) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
|
||||
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||
$limit = (int) ($validated['limit'] ?? 15);
|
||||
|
||||
if ($from > $to) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$report = $this->reportService->buildReport($from, $to, $limit);
|
||||
|
||||
return response()->json([
|
||||
'meta' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'limit' => $limit,
|
||||
'generated_at' => now()->toISOString(),
|
||||
'latest_aggregated_date' => $report['latest_aggregated_date'],
|
||||
],
|
||||
'overview' => $report['overview'],
|
||||
'daily_clicks' => $report['daily_clicks'],
|
||||
'by_surface' => $report['by_surface'],
|
||||
'top_tags' => $report['top_tags'],
|
||||
'top_queries' => $report['top_queries'],
|
||||
'top_transitions' => $report['top_transitions'],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -30,7 +31,10 @@ final class SuggestedTagsController extends Controller
|
||||
{
|
||||
private const LIMIT = 20;
|
||||
|
||||
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
|
||||
public function __construct(
|
||||
private readonly UserPreferenceBuilder $prefBuilder,
|
||||
private readonly TagDiscoveryService $tagDiscoveryService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
@@ -126,27 +130,18 @@ final class SuggestedTagsController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate tag usage over the last 7 days as a proxy for trend score.
|
||||
* Uses artwork_tag + artworks.published_at to avoid a heavy events table dependency.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
|
||||
*/
|
||||
private function trendingTags(int $limit): \Illuminate\Support\Collection
|
||||
{
|
||||
$since = now()->subDays(7);
|
||||
|
||||
return DB::table('artwork_tag as at')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
|
||||
->where('a.published_at', '>=', $since)
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('t.is_active', true)
|
||||
->selectRaw('t.slug, COUNT(*) / 1.0 as trend_score')
|
||||
->groupBy('t.id', 't.slug')
|
||||
->orderByDesc('trend_score')
|
||||
->limit($limit)
|
||||
->get();
|
||||
return $this->tagDiscoveryService
|
||||
->popularTags($limit, 7)
|
||||
->map(static fn ($tag) => (object) [
|
||||
'slug' => (string) $tag->slug,
|
||||
'trend_score' => max(
|
||||
(float) ($tag->recent_clicks ?? 0),
|
||||
(float) ($tag->usage_count ?? 0) / 1000
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,33 +7,24 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tags\PopularTagsRequest;
|
||||
use App\Http\Requests\Tags\TagSearchRequest;
|
||||
use App\Models\Tag;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class TagController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TagDiscoveryService $tagDiscoveryService) {}
|
||||
|
||||
public function search(TagSearchRequest $request): JsonResponse
|
||||
{
|
||||
$q = trim((string) ($request->validated()['q'] ?? ''));
|
||||
|
||||
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
|
||||
$ttl = $q === '' ? 300 : 120;
|
||||
$cacheKey = 'tags.search.' . ($q === '' ? '__empty__' : md5($q));
|
||||
$cacheKey = 'tags.search.v2.' . ($q === '' ? '__empty__' : md5($q));
|
||||
|
||||
$data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
|
||||
$query = Tag::query()->where('is_active', true);
|
||||
if ($q !== '') {
|
||||
$query->where(function ($sub) use ($q): void {
|
||||
$sub->where('name', 'like', $q . '%')
|
||||
->orWhere('slug', 'like', $q . '%');
|
||||
});
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('usage_count')
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
return $this->tagDiscoveryService->searchSuggestions($q, 20);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
@@ -42,14 +33,10 @@ final class TagController extends Controller
|
||||
public function popular(PopularTagsRequest $request): JsonResponse
|
||||
{
|
||||
$limit = (int) ($request->validated()['limit'] ?? 20);
|
||||
$cacheKey = 'tags.popular.' . $limit;
|
||||
$cacheKey = 'tags.popular.v2.' . $limit;
|
||||
|
||||
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
|
||||
return Tag::query()
|
||||
->where('is_active', true)
|
||||
->orderByDesc('usage_count')
|
||||
->limit($limit)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
return $this->tagDiscoveryService->popularTags($limit);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class TagInteractionAnalyticsController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'event_type' => ['required', 'string', 'in:click'],
|
||||
'surface' => ['required', 'string', 'in:search_suggestion,recent_search,rescue_suggestion,related_chip,related_cluster,top_companion'],
|
||||
'tag_slug' => ['nullable', 'string', 'max:120'],
|
||||
'source_tag_slug' => ['nullable', 'string', 'max:120'],
|
||||
'query' => ['nullable', 'string', 'max:120'],
|
||||
'position' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'occurred_at' => ['nullable', 'date'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
if (($payload['surface'] ?? null) !== 'recent_search' && empty($payload['tag_slug'])) {
|
||||
return response()->json([
|
||||
'message' => 'The selected analytics surface requires a tag slug.',
|
||||
'errors' => ['tag_slug' => ['The tag slug field is required for this surface.']],
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$occurredAt = isset($payload['occurred_at'])
|
||||
? CarbonImmutable::parse((string) $payload['occurred_at'])
|
||||
: CarbonImmutable::now();
|
||||
$sessionId = $request->hasSession() ? (string) $request->session()->getId() : '';
|
||||
$sessionKey = $sessionId !== '' ? hash('sha256', $sessionId) : null;
|
||||
|
||||
DB::table('tag_interaction_events')->insert([
|
||||
'event_date' => $occurredAt->toDateString(),
|
||||
'event_type' => (string) $payload['event_type'],
|
||||
'surface' => (string) $payload['surface'],
|
||||
'user_id' => $request->user()?->id,
|
||||
'session_key' => $sessionKey,
|
||||
'tag_slug' => isset($payload['tag_slug']) ? (string) $payload['tag_slug'] : null,
|
||||
'source_tag_slug' => isset($payload['source_tag_slug']) ? (string) $payload['source_tag_slug'] : null,
|
||||
'query' => isset($payload['query']) ? trim((string) $payload['query']) : null,
|
||||
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
|
||||
'meta' => $payload['meta'] ?? null,
|
||||
'occurred_at' => $occurredAt,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkVersioningService;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -27,6 +28,7 @@ final class StudioArtworksApiController extends Controller
|
||||
private readonly StudioBulkActionService $bulkService,
|
||||
private readonly ArtworkVersioningService $versioningService,
|
||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||
private readonly TagDiscoveryService $tagDiscoveryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -264,18 +266,13 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
/**
|
||||
* GET /api/studio/tags/search?q=...
|
||||
* Search active tags by name for the bulk tag picker.
|
||||
* Search active tags for studio pickers, with empty-query fallback to popular tags.
|
||||
*/
|
||||
public function searchTags(Request $request): JsonResponse
|
||||
{
|
||||
$query = trim((string) $request->input('q'));
|
||||
|
||||
$tags = \App\Models\Tag::query()
|
||||
->where('is_active', true)
|
||||
->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%"))
|
||||
->orderByDesc('usage_count')
|
||||
->limit(30)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
$tags = $this->tagDiscoveryService->searchSuggestions($query, 30);
|
||||
|
||||
return response()->json($tags);
|
||||
}
|
||||
|
||||
191
app/Services/Analytics/TagInteractionReportService.php
Normal file
191
app/Services/Analytics/TagInteractionReportService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Analytics;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class TagInteractionReportService
|
||||
{
|
||||
public function buildReport(string $from, string $to, int $limit = 20): array
|
||||
{
|
||||
return [
|
||||
'overview' => $this->overview($from, $to),
|
||||
'daily_clicks' => $this->dailyClicks($from, $to),
|
||||
'by_surface' => $this->bySurface($from, $to),
|
||||
'top_tags' => $this->topTags($from, $to, $limit),
|
||||
'top_queries' => $this->topQueries($from, $to, $limit),
|
||||
'top_transitions' => $this->topTransitions($from, $to, $limit),
|
||||
'latest_aggregated_date' => $this->latestAggregatedDate(),
|
||||
];
|
||||
}
|
||||
|
||||
private function overview(string $from, string $to): array
|
||||
{
|
||||
$row = DB::table('tag_interaction_events')
|
||||
->selectRaw('COUNT(*) AS total_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("COUNT(DISTINCT CASE WHEN tag_slug IS NOT NULL AND tag_slug <> '' THEN tag_slug END) AS distinct_tags")
|
||||
->selectRaw('MAX(occurred_at) AS latest_event_at')
|
||||
->whereBetween('event_date', [$from, $to])
|
||||
->where('event_type', 'click')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_clicks' => (int) ($row->total_clicks ?? 0),
|
||||
'unique_users' => (int) ($row->unique_users ?? 0),
|
||||
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||
'distinct_tags' => (int) ($row->distinct_tags ?? 0),
|
||||
'latest_event_at' => $row->latest_event_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function dailyClicks(string $from, string $to): array
|
||||
{
|
||||
if (Schema::hasTable('tag_interaction_daily_metrics')) {
|
||||
return DB::table('tag_interaction_daily_metrics')
|
||||
->selectRaw('metric_date')
|
||||
->selectRaw('SUM(clicks) AS clicks')
|
||||
->whereBetween('metric_date', [$from, $to])
|
||||
->groupBy('metric_date')
|
||||
->orderBy('metric_date')
|
||||
->get()
|
||||
->map(static fn ($row): array => [
|
||||
'date' => (string) $row->metric_date,
|
||||
'clicks' => (int) ($row->clicks ?? 0),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
return DB::table('tag_interaction_events')
|
||||
->selectRaw('event_date')
|
||||
->selectRaw('COUNT(*) AS clicks')
|
||||
->whereBetween('event_date', [$from, $to])
|
||||
->where('event_type', 'click')
|
||||
->groupBy('event_date')
|
||||
->orderBy('event_date')
|
||||
->get()
|
||||
->map(static fn ($row): array => [
|
||||
'date' => (string) $row->event_date,
|
||||
'clicks' => (int) ($row->clicks ?? 0),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function bySurface(string $from, string $to): array
|
||||
{
|
||||
return DB::table('tag_interaction_events')
|
||||
->selectRaw('surface')
|
||||
->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')
|
||||
->whereBetween('event_date', [$from, $to])
|
||||
->where('event_type', 'click')
|
||||
->groupBy('surface')
|
||||
->orderByDesc('clicks')
|
||||
->get()
|
||||
->map(static fn ($row): array => [
|
||||
'surface' => (string) $row->surface,
|
||||
'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),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function topTags(string $from, string $to, int $limit): array
|
||||
{
|
||||
return DB::table('tag_interaction_events')
|
||||
->selectRaw('tag_slug')
|
||||
->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("SUM(CASE WHEN surface IN ('related_chip', 'related_cluster', 'top_companion') THEN 1 ELSE 0 END) AS recommendation_clicks")
|
||||
->selectRaw("SUM(CASE WHEN surface IN ('search_suggestion', 'rescue_suggestion') THEN 1 ELSE 0 END) AS search_clicks")
|
||||
->whereBetween('event_date', [$from, $to])
|
||||
->where('event_type', 'click')
|
||||
->whereNotNull('tag_slug')
|
||||
->where('tag_slug', '<>', '')
|
||||
->groupBy('tag_slug')
|
||||
->orderByDesc('clicks')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(static fn ($row): array => [
|
||||
'tag_slug' => (string) $row->tag_slug,
|
||||
'clicks' => (int) ($row->clicks ?? 0),
|
||||
'unique_users' => (int) ($row->unique_users ?? 0),
|
||||
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||
'recommendation_clicks' => (int) ($row->recommendation_clicks ?? 0),
|
||||
'search_clicks' => (int) ($row->search_clicks ?? 0),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function topQueries(string $from, string $to, int $limit): array
|
||||
{
|
||||
return DB::table('tag_interaction_events')
|
||||
->selectRaw("LOWER(TRIM(query)) AS query")
|
||||
->selectRaw('COUNT(*) AS clicks')
|
||||
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
|
||||
->selectRaw("COUNT(DISTINCT CASE WHEN tag_slug IS NOT NULL AND tag_slug <> '' THEN tag_slug END) AS resolved_tags")
|
||||
->whereBetween('event_date', [$from, $to])
|
||||
->where('event_type', 'click')
|
||||
->whereNotNull('query')
|
||||
->whereRaw("TRIM(query) <> ''")
|
||||
->groupBy(DB::raw("LOWER(TRIM(query))"))
|
||||
->orderByDesc('clicks')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(static fn ($row): array => [
|
||||
'query' => (string) $row->query,
|
||||
'clicks' => (int) ($row->clicks ?? 0),
|
||||
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||
'resolved_tags' => (int) ($row->resolved_tags ?? 0),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function topTransitions(string $from, string $to, int $limit): array
|
||||
{
|
||||
return DB::table('tag_interaction_events')
|
||||
->selectRaw('source_tag_slug')
|
||||
->selectRaw('tag_slug')
|
||||
->selectRaw('COUNT(*) AS clicks')
|
||||
->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')
|
||||
->whereBetween('event_date', [$from, $to])
|
||||
->where('event_type', 'click')
|
||||
->whereNotNull('source_tag_slug')
|
||||
->whereNotNull('tag_slug')
|
||||
->where('source_tag_slug', '<>', '')
|
||||
->where('tag_slug', '<>', '')
|
||||
->groupBy('source_tag_slug', 'tag_slug')
|
||||
->orderByDesc('clicks')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(static fn ($row): array => [
|
||||
'source_tag_slug' => (string) $row->source_tag_slug,
|
||||
'tag_slug' => (string) $row->tag_slug,
|
||||
'clicks' => (int) ($row->clicks ?? 0),
|
||||
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function latestAggregatedDate(): ?string
|
||||
{
|
||||
if (!Schema::hasTable('tag_interaction_daily_metrics')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = DB::table('tag_interaction_daily_metrics')->max('metric_date');
|
||||
|
||||
return $date ? (string) $date : null;
|
||||
}
|
||||
}
|
||||
215
app/Services/Tags/TagDiscoveryService.php
Normal file
215
app/Services/Tags/TagDiscoveryService.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Tags;
|
||||
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class TagDiscoveryService
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function discoverySelectColumns(): array
|
||||
{
|
||||
return ['tags.id', 'tags.name', 'tags.slug', 'tags.usage_count'];
|
||||
}
|
||||
|
||||
public function featuredTags(int $limit = 6, int $windowDays = 14): Collection
|
||||
{
|
||||
return $this->activeTagsQuery($windowDays)
|
||||
->withCount('artworks')
|
||||
->orderByDesc('recent_clicks')
|
||||
->orderByDesc('usage_count')
|
||||
->orderByDesc('artworks_count')
|
||||
->orderBy('name')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function risingTags(Collection $featuredTags, int $limit = 12, int $windowDays = 14): Collection
|
||||
{
|
||||
$featuredSlugs = $featuredTags->pluck('slug')->filter()->values();
|
||||
|
||||
$risingTags = $this->activeTagsQuery($windowDays)
|
||||
->withCount('artworks')
|
||||
->when($featuredSlugs->isNotEmpty(), function ($builder) use ($featuredSlugs): void {
|
||||
$builder->whereNotIn('tags.slug', $featuredSlugs->all());
|
||||
})
|
||||
->when($this->hasDailyMetrics(), function ($builder): void {
|
||||
$builder->whereRaw('COALESCE(tag_momentum.recent_clicks, 0) > 0');
|
||||
})
|
||||
->orderByDesc('recent_clicks')
|
||||
->orderByDesc('artworks_count')
|
||||
->orderBy('name')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($risingTags->count() >= $limit) {
|
||||
return $risingTags;
|
||||
}
|
||||
|
||||
$excludeSlugs = $risingTags
|
||||
->pluck('slug')
|
||||
->merge($featuredSlugs)
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$fallback = $this->activeTagsQuery($windowDays)
|
||||
->withCount('artworks')
|
||||
->when($excludeSlugs->isNotEmpty(), function ($builder) use ($excludeSlugs): void {
|
||||
$builder->whereNotIn('tags.slug', $excludeSlugs->all());
|
||||
})
|
||||
->orderByDesc('usage_count')
|
||||
->orderByDesc('artworks_count')
|
||||
->orderBy('name')
|
||||
->limit($limit - $risingTags->count())
|
||||
->get();
|
||||
|
||||
return $risingTags->concat($fallback)->values();
|
||||
}
|
||||
|
||||
public function paginatedTags(string $query = '', int $perPage = 48, int $windowDays = 14): LengthAwarePaginator
|
||||
{
|
||||
$tagsQuery = $this->activeTagsQuery($windowDays)
|
||||
->withCount('artworks');
|
||||
|
||||
if ($query !== '') {
|
||||
$tagsQuery->where(function ($builder) use ($query): void {
|
||||
$builder
|
||||
->where('tags.name', 'like', '%' . $query . '%')
|
||||
->orWhere('tags.slug', 'like', '%' . $query . '%');
|
||||
});
|
||||
}
|
||||
|
||||
return $tagsQuery
|
||||
->orderByDesc('recent_clicks')
|
||||
->orderByDesc('usage_count')
|
||||
->orderByDesc('artworks_count')
|
||||
->orderBy('name')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
public function stats(int $matchingTotal, int $windowDays = 14): array
|
||||
{
|
||||
$activeTags = $this->activeTagsQuery($windowDays);
|
||||
|
||||
return [
|
||||
'active' => (clone $activeTags)->count(),
|
||||
'usage' => (clone $activeTags)->sum('usage_count'),
|
||||
'matching' => $matchingTotal,
|
||||
'recent_clicks' => $this->hasDailyMetrics()
|
||||
? (int) DB::table('tag_interaction_daily_metrics')
|
||||
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
|
||||
->sum('clicks')
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function popularTags(int $limit = 20, int $windowDays = 14): Collection
|
||||
{
|
||||
return $this->activeTagsQuery($windowDays)
|
||||
->orderByDesc('recent_clicks')
|
||||
->orderByDesc('usage_count')
|
||||
->orderBy('name')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function searchSuggestions(string $query, int $limit = 20, int $windowDays = 14): Collection
|
||||
{
|
||||
$normalizedQuery = trim($query);
|
||||
|
||||
return $this->activeTagsQuery($windowDays)
|
||||
->when($normalizedQuery !== '', function ($builder) use ($normalizedQuery): void {
|
||||
$builder->where(function ($subQuery) use ($normalizedQuery): void {
|
||||
$subQuery->where('tags.name', 'like', $normalizedQuery . '%')
|
||||
->orWhere('tags.slug', 'like', $normalizedQuery . '%');
|
||||
});
|
||||
})
|
||||
->orderByDesc('recent_clicks')
|
||||
->orderByDesc('usage_count')
|
||||
->orderBy('name')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function relatedTags(Tag $tag, int $limit = 8, int $windowDays = 14): Collection
|
||||
{
|
||||
return DB::table('artwork_tag as current_tag')
|
||||
->join('artwork_tag as related_tag', 'related_tag.artwork_id', '=', 'current_tag.artwork_id')
|
||||
->join('tags', 'tags.id', '=', 'related_tag.tag_id')
|
||||
->select([
|
||||
'tags.name',
|
||||
'tags.slug',
|
||||
'tags.usage_count',
|
||||
])
|
||||
->selectRaw('COUNT(DISTINCT current_tag.artwork_id) as shared_artworks_count')
|
||||
->when($this->hasDailyMetrics(), function ($builder) use ($tag, $windowDays): void {
|
||||
$transitionMomentum = DB::table('tag_interaction_daily_metrics')
|
||||
->selectRaw('tag_slug')
|
||||
->selectRaw('SUM(clicks) AS transition_clicks')
|
||||
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
|
||||
->where('surface', '!=', 'recent_search')
|
||||
->where('source_tag_slug', $tag->slug)
|
||||
->where('tag_slug', '<>', '')
|
||||
->groupBy('tag_slug');
|
||||
|
||||
$builder
|
||||
->leftJoinSub($transitionMomentum, 'tag_transition_momentum', function ($join): void {
|
||||
$join->on('tag_transition_momentum.tag_slug', '=', 'tags.slug');
|
||||
})
|
||||
->selectRaw('COALESCE(tag_transition_momentum.transition_clicks, 0) as transition_clicks')
|
||||
->groupBy('tag_transition_momentum.transition_clicks')
|
||||
->orderByDesc('transition_clicks');
|
||||
})
|
||||
->where('current_tag.tag_id', '=', $tag->getKey())
|
||||
->where('related_tag.tag_id', '!=', $tag->getKey())
|
||||
->where('tags.is_active', true)
|
||||
->groupBy('tags.id', 'tags.name', 'tags.slug', 'tags.usage_count')
|
||||
->orderByRaw('COUNT(DISTINCT current_tag.artwork_id) DESC')
|
||||
->orderByDesc('tags.usage_count')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function activeTagsQuery(int $windowDays)
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select($this->discoverySelectColumns())
|
||||
->where('tags.is_active', true);
|
||||
|
||||
if (! $this->hasDailyMetrics()) {
|
||||
return $query->selectRaw('0 AS recent_clicks');
|
||||
}
|
||||
|
||||
$tagMomentum = DB::table('tag_interaction_daily_metrics')
|
||||
->selectRaw('tag_slug')
|
||||
->selectRaw('SUM(clicks) AS recent_clicks')
|
||||
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
|
||||
->where('tag_slug', '<>', '')
|
||||
->groupBy('tag_slug');
|
||||
|
||||
return $query
|
||||
->leftJoinSub($tagMomentum, 'tag_momentum', function ($join): void {
|
||||
$join->on('tag_momentum.tag_slug', '=', 'tags.slug');
|
||||
})
|
||||
->selectRaw('COALESCE(tag_momentum.recent_clicks, 0) AS recent_clicks');
|
||||
}
|
||||
|
||||
private function hasDailyMetrics(): bool
|
||||
{
|
||||
return Schema::hasTable('tag_interaction_daily_metrics');
|
||||
}
|
||||
|
||||
private function windowStartDate(int $windowDays): string
|
||||
{
|
||||
return now()->subDays(max(0, $windowDays - 1))->toDateString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user