Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Collect hourly metric snapshots for artworks.
|
||||
*
|
||||
* Runs on cron every hour. Inserts a row per artwork into
|
||||
* artwork_metric_snapshots_hourly with the current totals.
|
||||
* Deltas are computed by the heat recalculation command.
|
||||
*
|
||||
* Usage: php artisan nova:metrics-snapshot-hourly
|
||||
* php artisan nova:metrics-snapshot-hourly --days=30 --chunk=500 --dry-run
|
||||
*/
|
||||
class MetricsSnapshotHourlyCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:metrics-snapshot-hourly
|
||||
{--days=60 : Only snapshot artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--dry-run : Log what would be written without persisting}';
|
||||
|
||||
protected $description = 'Collect hourly metric snapshots for rising/heat calculation';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$bucketHour = now()->startOfHour();
|
||||
|
||||
$this->info("[nova:metrics-snapshot-hourly] bucket={$bucketHour->toDateTimeString()} days={$days} chunk={$chunk}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$snapshotCount = 0;
|
||||
$skipCount = 0;
|
||||
|
||||
// Query artworks eligible for snapshotting:
|
||||
// - created within $days OR has a ranking_score above 0
|
||||
// First collect eligible IDs, then process in chunks
|
||||
$eligibleIds = DB::table('artworks')
|
||||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
||||
->where(function ($q) use ($days) {
|
||||
$q->where('artworks.created_at', '>=', now()->subDays($days))
|
||||
->orWhere(function ($q2) {
|
||||
$q2->whereNotNull('s.ranking_score')
|
||||
->where('s.ranking_score', '>', 0);
|
||||
});
|
||||
})
|
||||
->whereNull('artworks.deleted_at')
|
||||
->where('artworks.is_approved', true)
|
||||
->pluck('artworks.id');
|
||||
|
||||
if ($eligibleIds->isEmpty()) {
|
||||
$this->info('No eligible artworks found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($eligibleIds->chunk($chunk) as $chunkIds) {
|
||||
$artworkIds = $chunkIds->values()->all();
|
||||
|
||||
$stats = DB::table('artwork_stats')
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->get()
|
||||
->keyBy('artwork_id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($artworkIds as $artworkId) {
|
||||
$stat = $stats->get($artworkId);
|
||||
|
||||
$rows[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'bucket_hour' => $bucketHour,
|
||||
'views_count' => (int) ($stat?->views ?? 0),
|
||||
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||
'favourites_count' => (int) ($stat?->favorites ?? 0),
|
||||
'comments_count' => (int) ($stat?->comments_count ?? 0),
|
||||
'shares_count' => (int) ($stat?->shares_count ?? 0),
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$snapshotCount += count($rows);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($rows)) {
|
||||
// Upsert: if (artwork_id, bucket_hour) already exists, update totals
|
||||
DB::table('artwork_metric_snapshots_hourly')->upsert(
|
||||
$rows,
|
||||
['artwork_id', 'bucket_hour'],
|
||||
['views_count', 'downloads_count', 'favourites_count', 'comments_count', 'shares_count']
|
||||
);
|
||||
|
||||
$snapshotCount += count($rows);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Snapshots written: {$snapshotCount} | Skipped: {$skipCount}");
|
||||
|
||||
Log::info('[nova:metrics-snapshot-hourly] completed', [
|
||||
'bucket' => $bucketHour->toDateTimeString(),
|
||||
'written' => $snapshotCount,
|
||||
'skipped' => $skipCount,
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Prune old hourly metric snapshots to prevent unbounded table growth.
|
||||
*
|
||||
* Usage: php artisan nova:prune-metric-snapshots
|
||||
* php artisan nova:prune-metric-snapshots --keep-days=7
|
||||
*/
|
||||
class PruneMetricSnapshotsCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:prune-metric-snapshots
|
||||
{--keep-days=7 : Keep snapshots for this many days}';
|
||||
|
||||
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$keepDays = (int) $this->option('keep-days');
|
||||
$cutoff = now()->subDays($keepDays);
|
||||
|
||||
$deleted = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('bucket_hour', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days.");
|
||||
|
||||
Log::info('[nova:prune-metric-snapshots] completed', [
|
||||
'deleted' => $deleted,
|
||||
'keep_days' => $keepDays,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?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
|
||||
*
|
||||
* 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 --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}
|
||||
{--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');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$now = now();
|
||||
$currentHour = $now->copy()->startOfHour();
|
||||
$prevHour = $currentHour->copy()->subHour();
|
||||
|
||||
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$updatedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
|
||||
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->distinct()
|
||||
->pluck('artwork_id');
|
||||
|
||||
if ($artworkIds->isEmpty()) {
|
||||
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Load all snapshots for the two hours in bulk
|
||||
$snapshots = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
// Load artwork published_at dates for age factor (use published_at, fall back to created_at)
|
||||
$artworkDates = DB::table('artworks')
|
||||
->whereIn('id', $artworkIds)
|
||||
->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),
|
||||
]);
|
||||
|
||||
// Process in chunks
|
||||
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
|
||||
$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());
|
||||
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||
|
||||
// If we only have one snapshot, use it as current with zero deltas
|
||||
if (!$currentSnapshot && !$prevSnapshot) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate deltas
|
||||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||||
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||||
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||||
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||||
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||||
|
||||
// Raw heat
|
||||
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
|
||||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||
+ ($sharesDelta * self::WEIGHTS['shares']);
|
||||
|
||||
// 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' => $viewsDelta,
|
||||
'downloads_1h' => $downloadsDelta,
|
||||
'favourites_1h' => $favouritesDelta,
|
||||
'comments_1h' => $commentsDelta,
|
||||
'shares_1h' => $sharesDelta,
|
||||
];
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Console\Commands\RecalculateTrendingCommand;
|
||||
use App\Console\Commands\RecalculateRankingsCommand;
|
||||
use App\Console\Commands\MetricsSnapshotHourlyCommand;
|
||||
use App\Console\Commands\RecalculateHeatCommand;
|
||||
use App\Jobs\RankComputeArtworkScoresJob;
|
||||
use App\Jobs\RankBuildListsJob;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
@@ -42,6 +44,8 @@ class Kernel extends ConsoleKernel
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
RecalculateRankingsCommand::class,
|
||||
MetricsSnapshotHourlyCommand::class,
|
||||
RecalculateHeatCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -68,6 +72,23 @@ class Kernel extends ConsoleKernel
|
||||
->name('ranking-v2')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
|
||||
// Step 1: snapshot metric totals every hour at :00
|
||||
$schedule->command('nova:metrics-snapshot-hourly')
|
||||
->hourly()
|
||||
->name('metrics-snapshot-hourly')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
// Step 2: recalculate heat scores every 15 minutes
|
||||
$schedule->command('nova:recalculate-heat')
|
||||
->everyFifteenMinutes()
|
||||
->name('recalculate-heat')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
// Step 3: prune old snapshots daily at 04:00
|
||||
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
|
||||
->dailyAt('04:00');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,29 +7,33 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\Recommendations\HybridSimilarArtworksService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/art/{id}/similar
|
||||
*
|
||||
* Returns up to 12 similar artworks based on:
|
||||
* 1. Tag overlap (primary signal)
|
||||
* 2. Same category
|
||||
* 3. Similar orientation
|
||||
* Returns up to 12 similar artworks using the hybrid recommender (precomputed lists)
|
||||
* with a Meilisearch-based fallback if no precomputed data exists.
|
||||
*
|
||||
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
|
||||
* Current artwork and its creator are excluded from results.
|
||||
* Query params:
|
||||
* ?type=similar (default) | visual | tags | behavior
|
||||
*
|
||||
* Priority (default):
|
||||
* 1. Hybrid precomputed (tag + behavior + optional vector)
|
||||
* 2. Meilisearch tag-overlap fallback (legacy)
|
||||
*/
|
||||
final class SimilarArtworksController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
/** Spec §5: cache similar artworks 30–60 min; using config with 30 min default. */
|
||||
private const CACHE_TTL = 1800; // 30 minutes
|
||||
private const LIMIT = 12;
|
||||
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly HybridSimilarArtworksService $hybridService,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): JsonResponse
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()
|
||||
->published()
|
||||
@@ -40,22 +44,64 @@ final class SimilarArtworksController extends Controller
|
||||
return response()->json(['error' => 'Artwork not found'], 404);
|
||||
}
|
||||
|
||||
$cacheKey = "api.similar.{$artwork->id}";
|
||||
$type = $request->query('type');
|
||||
$validTypes = ['similar', 'visual', 'tags', 'behavior'];
|
||||
if ($type !== null && ! in_array($type, $validTypes, true)) {
|
||||
$type = null; // ignore invalid, fall through to default
|
||||
}
|
||||
|
||||
$items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) {
|
||||
return $this->findSimilar($artwork);
|
||||
});
|
||||
// Service handles its own caching (6h TTL), no extra controller-level cache
|
||||
$hybridResults = $this->hybridService->forArtwork($artwork->id, self::LIMIT, $type);
|
||||
|
||||
if ($hybridResults->isNotEmpty()) {
|
||||
// Eager-load relations needed for formatting
|
||||
$ids = $hybridResults->pluck('id')->all();
|
||||
$loaded = Artwork::query()
|
||||
->whereIn('id', $ids)
|
||||
->with(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$items = $hybridResults->values()->map(function (Artwork $a) use ($loaded) {
|
||||
$full = $loaded->get($a->id) ?? $a;
|
||||
return $this->formatArtwork($full);
|
||||
})->all();
|
||||
|
||||
return response()->json(['data' => $items]);
|
||||
}
|
||||
|
||||
// Fall back to Meilisearch tag-overlap search
|
||||
$items = $this->findSimilarViaSearch($artwork);
|
||||
|
||||
return response()->json(['data' => $items]);
|
||||
}
|
||||
|
||||
private function findSimilar(Artwork $artwork): array
|
||||
private function formatArtwork(Artwork $artwork): array
|
||||
{
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb' => $artwork->thumbUrl('md'),
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'author' => $artwork->user?->name ?? 'Artist',
|
||||
'author_avatar' => $artwork->user?->profile?->avatar_url,
|
||||
'author_id' => $artwork->user_id,
|
||||
'orientation' => $this->orientation($artwork),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Meilisearch-based similar artworks (fallback).
|
||||
*/
|
||||
private function findSimilarViaSearch(Artwork $artwork): array
|
||||
{
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||||
$srcOrientation = $this->orientation($artwork);
|
||||
|
||||
// Build Meilisearch filter: exclude self and same creator
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
@@ -63,7 +109,6 @@ final class SimilarArtworksController extends Controller
|
||||
'author_id != ' . $artwork->user_id,
|
||||
];
|
||||
|
||||
// Priority 1: tag overlap (OR match across tags)
|
||||
if ($tagSlugs !== []) {
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
@@ -71,7 +116,6 @@ final class SimilarArtworksController extends Controller
|
||||
));
|
||||
$filterParts[] = '(' . $tagFilter . ')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
// Fallback to category if no tags
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
$categorySlugs
|
||||
@@ -79,7 +123,6 @@ final class SimilarArtworksController extends Controller
|
||||
$filterParts[] = '(' . $catFilter . ')';
|
||||
}
|
||||
|
||||
// ── Fetch 200-candidate pool from Meilisearch ─────────────────────────
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
@@ -90,9 +133,6 @@ final class SimilarArtworksController extends Controller
|
||||
$collection = $results->getCollection();
|
||||
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
// ── PHP reranking ──────────────────────────────────────────────────────
|
||||
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
|
||||
// +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10
|
||||
$srcTagSet = array_flip($tagSlugs);
|
||||
$srcW = (int) ($artwork->width ?? 0);
|
||||
$srcH = (int) ($artwork->height ?? 0);
|
||||
@@ -103,15 +143,12 @@ final class SimilarArtworksController extends Controller
|
||||
$cTagSlugs = $candidate->tags->pluck('slug')->all();
|
||||
$cTagSet = array_flip($cTagSlugs);
|
||||
|
||||
// Tag overlap (Sørensen–Dice-like)
|
||||
$common = count(array_intersect_key($srcTagSet, $cTagSet));
|
||||
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
|
||||
$tagOverlap = $common / $total;
|
||||
|
||||
// Orientation bonus
|
||||
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
|
||||
|
||||
// Resolution proximity bonus (both axes within 25 %)
|
||||
$cW = (int) ($candidate->width ?? 0);
|
||||
$cH = (int) ($candidate->height ?? 0);
|
||||
$resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0
|
||||
@@ -119,11 +156,9 @@ final class SimilarArtworksController extends Controller
|
||||
&& abs($cH - $srcH) / $srcH <= 0.25
|
||||
) ? 0.05 : 0.0;
|
||||
|
||||
// Popularity boost (log-normalised views, capped at 0.15)
|
||||
$views = max(0, (int) ($candidate->stats?->views ?? 0));
|
||||
$popularity = min(0.15, log(1 + $views) / 13.0);
|
||||
|
||||
// Freshness boost (exp decay, 60-day half-life, weight 0.10)
|
||||
$publishedAt = $candidate->published_at ?? $candidate->created_at ?? now();
|
||||
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||
$freshness = exp(-$ageDays / 60.0) * 0.10;
|
||||
@@ -140,20 +175,10 @@ final class SimilarArtworksController extends Controller
|
||||
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
return array_values(
|
||||
array_map(fn (array $item): array => [
|
||||
'id' => $item['artwork']->id,
|
||||
'title' => $item['artwork']->title,
|
||||
'slug' => $item['artwork']->slug,
|
||||
'thumb' => $item['artwork']->thumbUrl('md'),
|
||||
'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug,
|
||||
'author' => $item['artwork']->user?->name ?? 'Artist',
|
||||
'author_avatar' => $item['artwork']->user?->profile?->avatar_url,
|
||||
'author_id' => $item['artwork']->user_id,
|
||||
'orientation' => $this->orientation($item['artwork']),
|
||||
'width' => $item['artwork']->width,
|
||||
'height' => $item['artwork']->height,
|
||||
'score' => round((float) $item['score'], 5),
|
||||
], array_slice($scored, 0, self::LIMIT))
|
||||
array_map(fn (array $item): array => array_merge(
|
||||
$this->formatArtwork($item['artwork']),
|
||||
['score' => round((float) $item['score'], 5)]
|
||||
), array_slice($scored, 0, self::LIMIT))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ArtworkIndexRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\Recommendations\SimilarArtworksService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -97,84 +96,12 @@ class ArtworkController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$foundArtwork->loadMissing(['categories.contentType', 'user']);
|
||||
|
||||
$defaultAlgoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
||||
$selectedAlgoVersion = $this->selectAlgoVersionForRequest($request, $defaultAlgoVersion);
|
||||
|
||||
$similarService = app(SimilarArtworksService::class);
|
||||
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $selectedAlgoVersion);
|
||||
|
||||
if ($similarArtworks->isEmpty() && $selectedAlgoVersion !== $defaultAlgoVersion) {
|
||||
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $defaultAlgoVersion);
|
||||
$selectedAlgoVersion = $defaultAlgoVersion;
|
||||
}
|
||||
|
||||
$similarArtworks->each(static function (Artwork $item): void {
|
||||
$item->loadMissing(['categories.contentType', 'user']);
|
||||
});
|
||||
|
||||
$similarItems = $similarArtworks
|
||||
->map(function (Artwork $item): ?array {
|
||||
$category = $item->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
|
||||
if (! $category || ! $contentType || empty($item->slug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $item->id,
|
||||
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'author' => html_entity_decode((string) optional($item->user)->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'),
|
||||
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
|
||||
'url' => route('artworks.show', [
|
||||
'contentTypeSlug' => (string) $contentType->slug,
|
||||
'categoryPath' => (string) $category->slug,
|
||||
'artwork' => (string) $item->slug,
|
||||
]),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return view('artworks.show', [
|
||||
'artwork' => $foundArtwork,
|
||||
'similarItems' => $similarItems,
|
||||
'similarAlgoVersion' => $selectedAlgoVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
private function selectAlgoVersionForRequest(Request $request, string $default): string
|
||||
{
|
||||
$configured = (array) config('recommendations.ab.algo_versions', []);
|
||||
$versions = array_values(array_filter(array_map(static fn ($value): string => trim((string) $value), $configured)));
|
||||
|
||||
if ($versions === []) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (! in_array($default, $versions, true)) {
|
||||
array_unshift($versions, $default);
|
||||
$versions = array_values(array_unique($versions));
|
||||
}
|
||||
|
||||
$forced = trim((string) $request->query('algo_version', ''));
|
||||
if ($forced !== '' && in_array($forced, $versions, true)) {
|
||||
return $forced;
|
||||
}
|
||||
|
||||
if (count($versions) === 1) {
|
||||
return $versions[0];
|
||||
}
|
||||
|
||||
$visitorKey = $request->user()?->id
|
||||
? 'u:' . (string) $request->user()->id
|
||||
: 's:' . (string) $request->session()->getId();
|
||||
|
||||
$bucket = abs(crc32($visitorKey)) % count($versions);
|
||||
|
||||
return $versions[$bucket] ?? $default;
|
||||
// Delegate to the canonical ArtworkPageController which builds all
|
||||
// required view data ($meta, thumbnails, related items, comments, etc.)
|
||||
return app(\App\Http\Controllers\Web\ArtworkPageController::class)->show(
|
||||
$request,
|
||||
(int) $foundArtwork->id,
|
||||
$foundArtwork->slug,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class LatestCommentsController extends Controller
|
||||
$user = $c->user;
|
||||
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg';
|
||||
$thumb = $present ? ($present['url']) : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
|
||||
return (object) [
|
||||
'comment_id' => $c->getKey(),
|
||||
|
||||
@@ -43,7 +43,7 @@ class TodayDownloadsController extends Controller
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null; // legacy encoding unavailable; leave null
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : '/gfx/sb_join.jpg';
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
|
||||
return (object) [
|
||||
|
||||
@@ -58,7 +58,7 @@ class LegacyController extends Controller
|
||||
(object) [
|
||||
'id' => 0,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'category' => null,
|
||||
'datum' => now(),
|
||||
'category_name' => 'Photography',
|
||||
@@ -289,7 +289,7 @@ class LegacyController extends Controller
|
||||
$featured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Featured Artwork',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
];
|
||||
}
|
||||
@@ -298,7 +298,7 @@ class LegacyController extends Controller
|
||||
$memberFeatured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Members Pick',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
'votes' => 0,
|
||||
];
|
||||
@@ -430,7 +430,7 @@ class LegacyController extends Controller
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
'category_name' => 'Photography',
|
||||
],
|
||||
|
||||
349
app/Http/Controllers/Studio/StudioArtworksApiController.php
Normal file
349
app/Http/Controllers/Studio/StudioArtworksApiController.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* JSON API endpoints for the Studio artwork manager.
|
||||
*/
|
||||
final class StudioArtworksApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StudioArtworkQueryService $queryService,
|
||||
private readonly StudioBulkActionService $bulkService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/studio/artworks
|
||||
* List artworks with search, filter, sort, pagination.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
$filters = $request->only([
|
||||
'q', 'status', 'category', 'tags', 'date_from', 'date_to',
|
||||
'performance', 'sort',
|
||||
]);
|
||||
|
||||
$perPage = (int) $request->get('per_page', 24);
|
||||
$perPage = min(max($perPage, 12), 100);
|
||||
|
||||
$paginator = $this->queryService->list($userId, $filters, $perPage);
|
||||
|
||||
// Transform the paginator items to a clean DTO
|
||||
$items = collect($paginator->items())->map(fn ($artwork) => $this->transformArtwork($artwork));
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/bulk
|
||||
* Execute bulk operations.
|
||||
*/
|
||||
public function bulk(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
|
||||
'artwork_ids' => 'required|array|min:1|max:200',
|
||||
'artwork_ids.*' => 'integer',
|
||||
'params' => 'sometimes|array',
|
||||
'params.category_id' => 'sometimes|integer|exists:categories,id',
|
||||
'params.tag_ids' => 'sometimes|array',
|
||||
'params.tag_ids.*' => 'integer|exists:tags,id',
|
||||
'confirm' => 'required_if:action,delete|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
// Require explicit DELETE confirmation
|
||||
if ($data['action'] === 'delete' && ($data['confirm'] ?? '') !== 'DELETE') {
|
||||
return response()->json([
|
||||
'errors' => ['confirm' => ['You must type DELETE to confirm permanent deletion.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$result = $this->bulkService->execute(
|
||||
$request->user()->id,
|
||||
$data['action'],
|
||||
$data['artwork_ids'],
|
||||
$data['params'] ?? [],
|
||||
);
|
||||
|
||||
$statusCode = $result['failed'] > 0 && $result['success'] === 0 ? 422 : 200;
|
||||
|
||||
return response()->json($result, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/studio/artworks/{id}
|
||||
* Update artwork details (title, description, visibility).
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'description' => 'sometimes|nullable|string|max:5000',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
||||
'tags' => 'sometimes|array|max:15',
|
||||
'tags.*' => 'string|max:64',
|
||||
]);
|
||||
|
||||
if (isset($validated['is_public'])) {
|
||||
if ($validated['is_public'] && !$artwork->is_public) {
|
||||
$validated['published_at'] = $artwork->published_at ?? now();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tags and category before updating core fields
|
||||
$tags = $validated['tags'] ?? null;
|
||||
$categoryId = $validated['category_id'] ?? null;
|
||||
unset($validated['tags'], $validated['category_id']);
|
||||
|
||||
$artwork->update($validated);
|
||||
|
||||
// Sync category
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([(int) $categoryId]);
|
||||
}
|
||||
|
||||
// Sync tags (by slug/name)
|
||||
if ($tags !== null) {
|
||||
$tagIds = [];
|
||||
foreach ($tags as $tagSlug) {
|
||||
$tag = \App\Models\Tag::firstOrCreate(
|
||||
['slug' => \Illuminate\Support\Str::slug($tagSlug)],
|
||||
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
|
||||
);
|
||||
$tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0];
|
||||
}
|
||||
$artwork->tags()->sync($tagIds);
|
||||
}
|
||||
|
||||
// Reindex in Meilisearch
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
// Meilisearch may be unavailable
|
||||
}
|
||||
|
||||
// Reload relationships for response
|
||||
$artwork->load(['categories.contentType', 'tags']);
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'slug' => $artwork->slug,
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/toggle
|
||||
* Toggle publish/unpublish/archive for a single artwork.
|
||||
*/
|
||||
public function toggle(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'action' => 'required|string|in:publish,unpublish,archive,unarchive',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$result = $this->bulkService->execute(
|
||||
$request->user()->id,
|
||||
$validator->validated()['action'],
|
||||
[$id],
|
||||
);
|
||||
|
||||
if ($result['success'] === 0) {
|
||||
return response()->json(['error' => 'Action failed', 'details' => $result['errors']], 404);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/studio/artworks/{id}/analytics
|
||||
* Analytics data for a single artwork.
|
||||
*/
|
||||
public function analytics(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()
|
||||
->with(['stats', 'awardStat'])
|
||||
->findOrFail($id);
|
||||
|
||||
$stats = $artwork->stats;
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
],
|
||||
'analytics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function transformArtwork($artwork): array
|
||||
{
|
||||
$stats = $artwork->stats ?? null;
|
||||
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'created_at' => $artwork->created_at?->toIso8601String(),
|
||||
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
|
||||
'category' => $artwork->categories->first()?->name,
|
||||
'category_slug' => $artwork->categories->first()?->slug,
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/studio/tags/search?q=...
|
||||
* Search active tags by name for the bulk tag picker.
|
||||
*/
|
||||
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']);
|
||||
|
||||
return response()->json($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/replace-file
|
||||
* Replace the artwork's primary image file and regenerate derivatives.
|
||||
*/
|
||||
public function replaceFile(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50MB
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getRealPath();
|
||||
|
||||
// Compute SHA-256 hash
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
|
||||
try {
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
|
||||
|
||||
// Store original
|
||||
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp');
|
||||
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
|
||||
|
||||
// Generate public derivatives
|
||||
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
||||
foreach ($publicAbsolute as $variant => $absolutePath) {
|
||||
$filename = $variant . '.webp';
|
||||
$relativePath = $storage->publicRelativePath($hash, $filename);
|
||||
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
|
||||
}
|
||||
|
||||
// Get dimensions
|
||||
$dimensions = @getimagesize($tempPath);
|
||||
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : $artwork->width;
|
||||
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : $artwork->height;
|
||||
|
||||
// Update artwork record
|
||||
$artwork->update([
|
||||
'file_name' => 'orig.webp',
|
||||
'file_path' => '',
|
||||
'file_size' => (int) filesize($originalPath),
|
||||
'mime_type' => 'image/webp',
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'webp',
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
]);
|
||||
|
||||
// Reindex
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'file_size' => $artwork->file_size,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'File processing failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
174
app/Http/Controllers/Studio/StudioController.php
Normal file
174
app/Http/Controllers/Studio/StudioController.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\Studio\StudioMetricsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Serves Studio Inertia pages for authenticated creators.
|
||||
*/
|
||||
final class StudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StudioMetricsService $metrics,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Studio Overview Dashboard (/studio)
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
return Inertia::render('Studio/StudioDashboard', [
|
||||
'kpis' => $this->metrics->getDashboardKpis($userId),
|
||||
'topPerformers' => $this->metrics->getTopPerformers($userId, 6),
|
||||
'recentComments' => $this->metrics->getRecentComments($userId, 5),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artwork Manager (/studio/artworks)
|
||||
*/
|
||||
public function artworks(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioArtworks', [
|
||||
'categories' => $this->getCategories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drafts (/studio/artworks/drafts)
|
||||
*/
|
||||
public function drafts(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioDrafts', [
|
||||
'categories' => $this->getCategories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archived (/studio/artworks/archived)
|
||||
*/
|
||||
public function archived(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioArchived', [
|
||||
'categories' => $this->getCategories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit artwork (/studio/artworks/:id/edit)
|
||||
*/
|
||||
public function edit(Request $request, int $id): Response
|
||||
{
|
||||
$artwork = $request->user()->artworks()
|
||||
->with(['stats', 'categories.contentType', 'tags'])
|
||||
->findOrFail($id);
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkEdit', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'file_name' => $artwork->file_name,
|
||||
'file_size' => $artwork->file_size,
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'mime_type' => $artwork->mime_type,
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id,
|
||||
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
|
||||
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
],
|
||||
'contentTypes' => $this->getCategories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics v1 (/studio/artworks/:id/analytics)
|
||||
*/
|
||||
public function analytics(Request $request, int $id): Response
|
||||
{
|
||||
$artwork = $request->user()->artworks()
|
||||
->with(['stats', 'awardStat'])
|
||||
->findOrFail($id);
|
||||
|
||||
$stats = $artwork->stats;
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkAnalytics', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
],
|
||||
'analytics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Studio-wide Analytics (/studio/analytics)
|
||||
*/
|
||||
public function analyticsOverview(Request $request): Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$data = $this->metrics->getAnalyticsOverview($userId);
|
||||
|
||||
return Inertia::render('Studio/StudioAnalytics', [
|
||||
'totals' => $data['totals'],
|
||||
'topArtworks' => $data['top_artworks'],
|
||||
'contentBreakdown' => $data['content_breakdown'],
|
||||
'recentComments' => $this->metrics->getRecentComments($userId, 8),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getCategories(): array
|
||||
{
|
||||
return ContentType::with(['rootCategories.children'])->get()->map(function ($ct) {
|
||||
return [
|
||||
'id' => $ct->id,
|
||||
'name' => $ct->name,
|
||||
'slug' => $ct->slug,
|
||||
'categories' => $ct->rootCategories->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'slug' => $c->slug,
|
||||
'children' => $c->children->map(fn ($ch) => [
|
||||
'id' => $ch->id,
|
||||
'name' => $ch->name,
|
||||
'slug' => $ch->slug,
|
||||
])->values()->all(),
|
||||
];
|
||||
})->values()->all(),
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ class TodayDownloadsController extends Controller
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null;
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : '/gfx/sb_join.jpg';
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
|
||||
return (object) [
|
||||
|
||||
@@ -68,11 +68,11 @@ class TodayInHistoryController extends Controller
|
||||
/** @var ?Artwork $art */
|
||||
$art = $modelsById->get($row->id);
|
||||
if ($art) {
|
||||
$row->thumb_url = $art->thumbUrl('md') ?? '/gfx/sb_join.jpg';
|
||||
$row->thumb_url = $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$row->art_url = '/art/' . $art->id . '/' . $art->slug;
|
||||
$row->name = $art->title ?: ($row->name ?? 'Untitled');
|
||||
} else {
|
||||
$row->thumb_url = '/gfx/sb_join.jpg';
|
||||
$row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$row->art_url = '/art/' . $row->id;
|
||||
$row->name = $row->name ?? 'Untitled';
|
||||
}
|
||||
|
||||
@@ -49,6 +49,23 @@ final class DiscoverController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/rising ────────────────────────────────────────────────────
|
||||
|
||||
public function rising(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverRising($perPage);
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Rising Now',
|
||||
'section' => 'rising',
|
||||
'description' => 'Fastest growing artworks right now.',
|
||||
'icon' => 'fa-rocket',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/fresh ─────────────────────────────────────────────────────
|
||||
|
||||
public function fresh(Request $request)
|
||||
|
||||
@@ -11,6 +11,18 @@ final class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
protected $rootView = 'upload';
|
||||
|
||||
/**
|
||||
* Select the root Blade view based on route prefix.
|
||||
*/
|
||||
public function rootView(Request $request): string
|
||||
{
|
||||
if (str_starts_with($request->path(), 'studio')) {
|
||||
return 'studio';
|
||||
}
|
||||
|
||||
return $this->rootView;
|
||||
}
|
||||
|
||||
public function version(Request $request): ?string
|
||||
{
|
||||
return parent::version($request);
|
||||
|
||||
148
app/Jobs/RecBuildItemPairsFromFavouritesJob.php
Normal file
148
app/Jobs/RecBuildItemPairsFromFavouritesJob.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Build item-item co-occurrence pairs from user favourites.
|
||||
*
|
||||
* Spec §7.1 — runs hourly or every few hours.
|
||||
* For each user: take last N favourites, create pairs, increment weights.
|
||||
*
|
||||
* Safety: limits per-user pairs to avoid O(n²) explosion.
|
||||
*/
|
||||
final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $userBatchSize = 500,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$favCap = (int) config('recommendations.similarity.user_favourites_cap', 50);
|
||||
|
||||
// ── Pre-compute per-artwork total favourite counts for cosine normalization ──
|
||||
$this->artworkLikeCounts = DB::table('artwork_favourites')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('artwork_id')
|
||||
->pluck('cnt', 'artwork_id')
|
||||
->all();
|
||||
|
||||
// ── Accumulate co-occurrence counts across all users ──
|
||||
$coOccurrenceCounts = [];
|
||||
|
||||
DB::table('artwork_favourites')
|
||||
->select('user_id')
|
||||
->groupBy('user_id')
|
||||
->orderBy('user_id')
|
||||
->chunk($this->userBatchSize, function ($userRows) use ($favCap, &$coOccurrenceCounts) {
|
||||
foreach ($userRows as $row) {
|
||||
$pairs = $this->pairsForUser((int) $row->user_id, $favCap);
|
||||
foreach ($pairs as $pair) {
|
||||
$key = $pair[0] . ':' . $pair[1];
|
||||
$coOccurrenceCounts[$key] = ($coOccurrenceCounts[$key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Normalize to cosine-like scores and flush ──
|
||||
$normalized = [];
|
||||
foreach ($coOccurrenceCounts as $key => $count) {
|
||||
[$a, $b] = explode(':', $key);
|
||||
$likesA = $this->artworkLikeCounts[(int) $a] ?? 1;
|
||||
$likesB = $this->artworkLikeCounts[(int) $b] ?? 1;
|
||||
$normalized[$key] = $count / sqrt($likesA * $likesB);
|
||||
}
|
||||
|
||||
$this->flushPairs($normalized);
|
||||
}
|
||||
|
||||
/** @var array<int, int> artwork_id => total favourite count */
|
||||
private array $artworkLikeCounts = [];
|
||||
|
||||
/**
|
||||
* Collect pairs from a single user's last N favourites.
|
||||
*
|
||||
* @return list<array{0: int, 1: int}>
|
||||
*/
|
||||
public function pairsForUser(int $userId, int $cap): array
|
||||
{
|
||||
$artworkIds = DB::table('artwork_favourites')
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('created_at')
|
||||
->limit($cap)
|
||||
->pluck('artwork_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$count = count($artworkIds);
|
||||
if ($count < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pairs = [];
|
||||
// Cap max pairs per user to avoid explosion: C(50,2) = 1225 worst case = acceptable
|
||||
for ($i = 0; $i < $count - 1; $i++) {
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$a = min($artworkIds[$i], $artworkIds[$j]);
|
||||
$b = max($artworkIds[$i], $artworkIds[$j]);
|
||||
$pairs[] = [$a, $b];
|
||||
}
|
||||
}
|
||||
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert normalized pair weights into rec_item_pairs.
|
||||
*
|
||||
* Uses Laravel's DB-agnostic upsert (works on MySQL, Postgres, SQLite).
|
||||
*
|
||||
* @param array<string, float> $upserts key = "a:b", value = cosine-normalized weight
|
||||
*/
|
||||
private function flushPairs(array $upserts): void
|
||||
{
|
||||
if ($upserts === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
foreach (array_chunk($upserts, 500, preserve_keys: true) as $chunk) {
|
||||
$rows = [];
|
||||
foreach ($chunk as $key => $weight) {
|
||||
[$a, $b] = explode(':', $key);
|
||||
$rows[] = [
|
||||
'a_artwork_id' => (int) $a,
|
||||
'b_artwork_id' => (int) $b,
|
||||
'weight' => $weight,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('rec_item_pairs')->upsert(
|
||||
$rows,
|
||||
['a_artwork_id', 'b_artwork_id'],
|
||||
['weight', 'updated_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/Jobs/RecComputeSimilarByBehaviorJob.php
Normal file
129
app/Jobs/RecComputeSimilarByBehaviorJob.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Compute behavior-based (co-like) similarity from precomputed item pairs.
|
||||
*
|
||||
* Spec §7.3 — runs nightly.
|
||||
* For each artwork: read top pairs from rec_item_pairs, store top N.
|
||||
*/
|
||||
final class RecComputeSimilarByBehaviorJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?int $artworkId = null,
|
||||
private readonly int $batchSize = 200,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
|
||||
$query = Artwork::query()->public()->published()->select('id', 'user_id');
|
||||
|
||||
if ($this->artworkId !== null) {
|
||||
$query->where('id', $this->artworkId);
|
||||
}
|
||||
|
||||
$query->chunkById($this->batchSize, function ($artworks) use ($modelVersion, $resultLimit, $maxPerAuthor) {
|
||||
foreach ($artworks as $artwork) {
|
||||
$this->processArtwork($artwork, $modelVersion, $resultLimit, $maxPerAuthor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processArtwork(
|
||||
Artwork $artwork,
|
||||
string $modelVersion,
|
||||
int $resultLimit,
|
||||
int $maxPerAuthor,
|
||||
): void {
|
||||
// Fetch top co-occurring artworks (bi-directional)
|
||||
$candidates = DB::table('rec_item_pairs')
|
||||
->where('a_artwork_id', $artwork->id)
|
||||
->select(DB::raw('b_artwork_id AS related_id'), 'weight')
|
||||
->union(
|
||||
DB::table('rec_item_pairs')
|
||||
->where('b_artwork_id', $artwork->id)
|
||||
->select(DB::raw('a_artwork_id AS related_id'), 'weight')
|
||||
)
|
||||
->orderByDesc('weight')
|
||||
->limit($resultLimit * 3)
|
||||
->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relatedIds = $candidates->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
// Fetch author info for diversity filtering
|
||||
$authorMap = DB::table('artworks')
|
||||
->whereIn('id', $relatedIds)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->whereNull('deleted_at')
|
||||
->pluck('user_id', 'id')
|
||||
->all();
|
||||
|
||||
// Apply diversity cap
|
||||
$authorCounts = [];
|
||||
$final = [];
|
||||
foreach ($candidates as $cand) {
|
||||
$relatedId = (int) $cand->related_id;
|
||||
if (! isset($authorMap[$relatedId])) {
|
||||
continue; // not public/published
|
||||
}
|
||||
$authorId = (int) $authorMap[$relatedId];
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
$final[] = $relatedId;
|
||||
if (count($final) >= $resultLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($final === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
RecArtworkRec::query()->updateOrCreate(
|
||||
[
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => $modelVersion,
|
||||
],
|
||||
[
|
||||
'recs' => $final,
|
||||
'computed_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
225
app/Jobs/RecComputeSimilarByTagsJob.php
Normal file
225
app/Jobs/RecComputeSimilarByTagsJob.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Compute tag-based (+ category boost) similarity for artworks.
|
||||
*
|
||||
* Spec §7.2 — runs nightly + on-demand.
|
||||
* For each artwork: find candidates by shared tags/category, score with IDF-weighted
|
||||
* tag overlap, apply diversity, store top N.
|
||||
*/
|
||||
final class RecComputeSimilarByTagsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?int $artworkId = null,
|
||||
private readonly int $batchSize = 200,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$candidatePool = (int) config('recommendations.similarity.candidate_pool', 100);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
|
||||
|
||||
// ── Tag IDF weights (global) ───────────────────────────────────────────
|
||||
$tagFreqs = DB::table('artwork_tag')
|
||||
->select('tag_id', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('tag_id')
|
||||
->pluck('cnt', 'tag_id')
|
||||
->all();
|
||||
|
||||
$query = Artwork::query()->public()->published()->select('id', 'user_id');
|
||||
|
||||
if ($this->artworkId !== null) {
|
||||
$query->where('id', $this->artworkId);
|
||||
}
|
||||
|
||||
$query->chunkById($this->batchSize, function ($artworks) use (
|
||||
$tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit
|
||||
) {
|
||||
foreach ($artworks as $artwork) {
|
||||
$this->processArtwork($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processArtwork(
|
||||
Artwork $artwork,
|
||||
array $tagFreqs,
|
||||
string $modelVersion,
|
||||
int $candidatePool,
|
||||
int $maxPerAuthor,
|
||||
int $resultLimit,
|
||||
): void {
|
||||
// Get source artwork's tags and categories
|
||||
$srcTagIds = DB::table('artwork_tag')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('tag_id')
|
||||
->all();
|
||||
|
||||
$srcCatIds = DB::table('artwork_category')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('category_id')
|
||||
->all();
|
||||
|
||||
// Source content_type_ids (via categories)
|
||||
$srcContentTypeIds = $srcCatIds !== []
|
||||
? DB::table('categories')
|
||||
->whereIn('id', $srcCatIds)
|
||||
->whereNotNull('content_type_id')
|
||||
->pluck('content_type_id')
|
||||
->unique()
|
||||
->all()
|
||||
: [];
|
||||
|
||||
if ($srcTagIds === [] && $srcCatIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Find candidates that share at least one tag ────────────────────────
|
||||
$candidateQuery = DB::table('artwork_tag')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_tag.artwork_id')
|
||||
->whereIn('artwork_tag.tag_id', $srcTagIds)
|
||||
->where('artwork_tag.artwork_id', '!=', $artwork->id)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now())
|
||||
->whereNull('artworks.deleted_at')
|
||||
->select('artwork_tag.artwork_id', 'artworks.user_id')
|
||||
->groupBy('artwork_tag.artwork_id', 'artworks.user_id')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit($candidatePool * 3); // over-fetch before scoring
|
||||
|
||||
$candidates = $candidateQuery->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather tags for all candidates in one query
|
||||
$candidateIds = $candidates->pluck('artwork_id')->all();
|
||||
$candidateTagMap = DB::table('artwork_tag')
|
||||
->whereIn('artwork_id', $candidateIds)
|
||||
->select('artwork_id', 'tag_id')
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
$candidateCatMap = DB::table('artwork_category')
|
||||
->whereIn('artwork_id', $candidateIds)
|
||||
->select('artwork_id', 'category_id')
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
// Build content_type_id lookup for candidates (via categories table)
|
||||
$allCandidateCatIds = $candidateCatMap->flatten(1)->pluck('category_id')->unique()->all();
|
||||
$catContentTypeMap = $allCandidateCatIds !== []
|
||||
? DB::table('categories')
|
||||
->whereIn('id', $allCandidateCatIds)
|
||||
->whereNotNull('content_type_id')
|
||||
->pluck('content_type_id', 'id')
|
||||
->all()
|
||||
: [];
|
||||
$srcContentTypeSet = array_flip($srcContentTypeIds);
|
||||
|
||||
$srcTagSet = array_flip($srcTagIds);
|
||||
$srcCatSet = array_flip($srcCatIds);
|
||||
|
||||
// ── Score each candidate ───────────────────────────────────────────────
|
||||
$scored = [];
|
||||
foreach ($candidates as $cand) {
|
||||
$cTagIds = $candidateTagMap->get($cand->artwork_id, collect())->pluck('tag_id')->all();
|
||||
$cCatIds = $candidateCatMap->get($cand->artwork_id, collect())->pluck('category_id')->all();
|
||||
|
||||
// IDF-weighted tag overlap (spec §5.1)
|
||||
$tagScore = 0.0;
|
||||
foreach ($cTagIds as $tagId) {
|
||||
if (isset($srcTagSet[$tagId])) {
|
||||
$freq = $tagFreqs[$tagId] ?? 1;
|
||||
$tagScore += 1.0 / log(2 + $freq);
|
||||
}
|
||||
}
|
||||
|
||||
// Category match bonus
|
||||
$catScore = 0.0;
|
||||
foreach ($cCatIds as $catId) {
|
||||
if (isset($srcCatSet[$catId])) {
|
||||
$catScore = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Content type match bonus (spec §5.1)
|
||||
$ctScore = 0.0;
|
||||
foreach ($cCatIds as $catId) {
|
||||
$ctId = $catContentTypeMap[$catId] ?? null;
|
||||
if ($ctId !== null && isset($srcContentTypeSet[$ctId])) {
|
||||
$ctScore = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$scored[] = [
|
||||
'artwork_id' => (int) $cand->artwork_id,
|
||||
'user_id' => (int) $cand->user_id,
|
||||
'tag_score' => $tagScore,
|
||||
'cat_score' => $catScore,
|
||||
'score' => $tagScore + $catScore * 0.1 + $ctScore * 0.05,
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
// ── Apply diversity (max per author) ───────────────────────────────────
|
||||
$authorCounts = [];
|
||||
$final = [];
|
||||
foreach ($scored as $item) {
|
||||
$authorId = $item['user_id'];
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
$final[] = $item['artwork_id'];
|
||||
if (count($final) >= $resultLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persist ────────────────────────────────────────────────────────────
|
||||
RecArtworkRec::query()->updateOrCreate(
|
||||
[
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => $modelVersion,
|
||||
],
|
||||
[
|
||||
'recs' => $final,
|
||||
'computed_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
286
app/Jobs/RecComputeSimilarHybridJob.php
Normal file
286
app/Jobs/RecComputeSimilarHybridJob.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Compute hybrid similarity by blending tag, behavior, and optionally visual scores.
|
||||
*
|
||||
* Spec §7.4 — runs nightly.
|
||||
* Merges candidates from tag + behavior + vector lists, applies hybrid blend weights,
|
||||
* enforces diversity, stores top 30.
|
||||
*/
|
||||
final class RecComputeSimilarHybridJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 900;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?int $artworkId = null,
|
||||
private readonly int $batchSize = 200,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false);
|
||||
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
$minCatsTop12 = (int) config('recommendations.similarity.min_categories_top12', 2);
|
||||
|
||||
$weights = $vectorEnabled
|
||||
? (array) config('recommendations.similarity.weights_with_vector')
|
||||
: (array) config('recommendations.similarity.weights_without_vector');
|
||||
|
||||
$query = Artwork::query()->public()->published()->select('id', 'user_id');
|
||||
|
||||
if ($this->artworkId !== null) {
|
||||
$query->where('id', $this->artworkId);
|
||||
}
|
||||
|
||||
$query->chunkById($this->batchSize, function ($artworks) use (
|
||||
$modelVersion, $vectorEnabled, $resultLimit, $maxPerAuthor, $minCatsTop12, $weights
|
||||
) {
|
||||
foreach ($artworks as $artwork) {
|
||||
try {
|
||||
$this->processArtwork(
|
||||
$artwork, $modelVersion, $vectorEnabled, $resultLimit,
|
||||
$maxPerAuthor, $minCatsTop12, $weights
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[RecComputeSimilarHybrid] Failed for artwork {$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processArtwork(
|
||||
Artwork $artwork,
|
||||
string $modelVersion,
|
||||
bool $vectorEnabled,
|
||||
int $resultLimit,
|
||||
int $maxPerAuthor,
|
||||
int $minCatsTop12,
|
||||
array $weights,
|
||||
): void {
|
||||
// ── Collect sub-lists ──────────────────────────────────────────────────
|
||||
$tagRec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('rec_type', 'similar_tags')
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
$behRec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('rec_type', 'similar_behavior')
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
$tagIds = $tagRec ? ($tagRec->recs ?? []) : [];
|
||||
$behIds = $behRec ? ($behRec->recs ?? []) : [];
|
||||
|
||||
$vecIds = [];
|
||||
$vecScores = [];
|
||||
if ($vectorEnabled) {
|
||||
$vecRec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('rec_type', 'similar_visual')
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
if ($vecRec) {
|
||||
$vecIds = $vecRec->recs ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all candidate IDs
|
||||
$allIds = array_values(array_unique(array_merge($tagIds, $behIds, $vecIds)));
|
||||
|
||||
if ($allIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Build normalized score maps ────────────────────────────────────────
|
||||
$tagScoreMap = $this->rankToScore($tagIds);
|
||||
$behScoreMap = $this->rankToScore($behIds);
|
||||
$vecScoreMap = $this->rankToScore($vecIds);
|
||||
|
||||
// Fetch artwork metadata for category + author diversity
|
||||
$metaRows = DB::table('artworks')
|
||||
->whereIn('id', $allIds)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->whereNull('deleted_at')
|
||||
->select('id', 'user_id')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$catMap = DB::table('artwork_category')
|
||||
->whereIn('artwork_id', $allIds)
|
||||
->select('artwork_id', 'category_id')
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
// Source artwork categories
|
||||
$srcCatIds = DB::table('artwork_category')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('category_id')
|
||||
->all();
|
||||
$srcCatSet = array_flip($srcCatIds);
|
||||
|
||||
// ── Compute hybrid score ───────────────────────────────────────────────
|
||||
$scored = [];
|
||||
foreach ($allIds as $candidateId) {
|
||||
if (! $metaRows->has($candidateId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta = $metaRows->get($candidateId);
|
||||
$candidateCats = $catMap->get($candidateId, collect())->pluck('category_id')->all();
|
||||
|
||||
// Category overlap
|
||||
$catScore = 0.0;
|
||||
foreach ($candidateCats as $catId) {
|
||||
if (isset($srcCatSet[$catId])) {
|
||||
$catScore = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$tagS = $tagScoreMap[$candidateId] ?? 0.0;
|
||||
$behS = $behScoreMap[$candidateId] ?? 0.0;
|
||||
$vecS = $vecScoreMap[$candidateId] ?? 0.0;
|
||||
|
||||
if ($vectorEnabled) {
|
||||
$score = ($weights['visual'] ?? 0.45) * $vecS
|
||||
+ ($weights['tag'] ?? 0.25) * $tagS
|
||||
+ ($weights['behavior'] ?? 0.20) * $behS
|
||||
+ ($weights['category'] ?? 0.10) * $catScore;
|
||||
} else {
|
||||
$score = ($weights['tag'] ?? 0.55) * $tagS
|
||||
+ ($weights['behavior'] ?? 0.35) * $behS
|
||||
+ ($weights['category'] ?? 0.10) * $catScore;
|
||||
}
|
||||
|
||||
$scored[] = [
|
||||
'artwork_id' => $candidateId,
|
||||
'user_id' => (int) $meta->user_id,
|
||||
'cat_ids' => $candidateCats,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
// ── Diversity enforcement ──────────────────────────────────────────────
|
||||
$authorCounts = [];
|
||||
$final = [];
|
||||
$catsInTop12 = [];
|
||||
|
||||
foreach ($scored as $item) {
|
||||
$authorId = $item['user_id'];
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$final[] = $item;
|
||||
|
||||
if (count($final) <= 12) {
|
||||
foreach ($item['cat_ids'] as $cId) {
|
||||
$catsInTop12[$cId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($final) >= $resultLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Min-categories enforcement in top 12 (spec §6) ────────────────────
|
||||
if (count($catsInTop12) < $minCatsTop12 && count($final) >= 12) {
|
||||
// Find items beyond the initial selection that introduce a new category
|
||||
$usedIds = array_flip(array_column($final, 'artwork_id'));
|
||||
$promotable = [];
|
||||
foreach ($scored as $item) {
|
||||
if (isset($usedIds[$item['artwork_id']])) {
|
||||
continue;
|
||||
}
|
||||
$newCats = array_diff($item['cat_ids'], array_keys($catsInTop12));
|
||||
if ($newCats !== []) {
|
||||
$promotable[] = $item;
|
||||
if (count($promotable) >= ($minCatsTop12 - count($catsInTop12))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inject promoted items at position 12 (end of visible top block)
|
||||
if ($promotable !== []) {
|
||||
$top = array_slice($final, 0, 11);
|
||||
$rest = array_slice($final, 11);
|
||||
$final = array_merge($top, $promotable, $rest);
|
||||
$final = array_slice($final, 0, $resultLimit);
|
||||
}
|
||||
}
|
||||
|
||||
$finalIds = array_column($final, 'artwork_id');
|
||||
|
||||
if ($finalIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
RecArtworkRec::query()->updateOrCreate(
|
||||
[
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => $modelVersion,
|
||||
],
|
||||
[
|
||||
'recs' => $finalIds,
|
||||
'computed_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ranked list of IDs into a score map (1.0 at rank 0, decaying).
|
||||
*
|
||||
* @param list<int> $ids
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function rankToScore(array $ids): array
|
||||
{
|
||||
$map = [];
|
||||
$total = count($ids);
|
||||
if ($total === 0) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
foreach ($ids as $rank => $id) {
|
||||
// Linear decay from 1.0 → ~0.0
|
||||
$map[(int) $id] = 1.0 - ($rank / max(1, $total));
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ class Artwork extends Model
|
||||
*/
|
||||
public function getThumbAttribute(): string
|
||||
{
|
||||
return $this->thumbUrl('md') ?? '/gfx/sb_join.jpg';
|
||||
return $this->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,6 +261,8 @@ class Artwork extends Model
|
||||
'engagement_velocity' => (float) ($stat?->engagement_velocity ?? 0),
|
||||
'shares_count' => (int) ($stat?->shares_count ?? 0),
|
||||
'comments_count' => (int) ($stat?->comments_count ?? 0),
|
||||
// ── Rising / Heat fields ────────────────────────────────────────────────────
|
||||
'heat_score' => (float) ($stat?->heat_score ?? 0),
|
||||
'awards' => [
|
||||
'gold' => $awardStat?->gold_count ?? 0,
|
||||
'silver' => $awardStat?->silver_count ?? 0,
|
||||
|
||||
53
app/Models/ArtworkMetricSnapshotHourly.php
Normal file
53
app/Models/ArtworkMetricSnapshotHourly.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkMetricSnapshotHourly
|
||||
*
|
||||
* Stores hourly totals for artwork metrics. Deltas are computed by
|
||||
* subtracting the previous hour's snapshot from the current one.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property \Illuminate\Support\Carbon $bucket_hour
|
||||
* @property int $views_count
|
||||
* @property int $downloads_count
|
||||
* @property int $favourites_count
|
||||
* @property int $comments_count
|
||||
* @property int $shares_count
|
||||
* @property \Illuminate\Support\Carbon $created_at
|
||||
*/
|
||||
class ArtworkMetricSnapshotHourly extends Model
|
||||
{
|
||||
protected $table = 'artwork_metric_snapshots_hourly';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'bucket_hour',
|
||||
'views_count',
|
||||
'downloads_count',
|
||||
'favourites_count',
|
||||
'comments_count',
|
||||
'shares_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'bucket_hour' => 'datetime',
|
||||
'views_count' => 'integer',
|
||||
'downloads_count' => 'integer',
|
||||
'favourites_count' => 'integer',
|
||||
'comments_count' => 'integer',
|
||||
'shares_count' => 'integer',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class, 'artwork_id');
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ class ArtworkStats extends Model
|
||||
'shares_24h',
|
||||
'comments_24h',
|
||||
'favourites_24h',
|
||||
// Rising / Heat columns
|
||||
'heat_score',
|
||||
'heat_score_updated_at',
|
||||
'views_1h',
|
||||
'favourites_1h',
|
||||
'comments_1h',
|
||||
'shares_1h',
|
||||
'downloads_1h',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
|
||||
48
app/Models/RecArtworkRec.php
Normal file
48
app/Models/RecArtworkRec.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Precomputed recommendation list for an artwork.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property string $rec_type similar_hybrid|similar_visual|similar_tags|similar_behavior
|
||||
* @property array $recs Ordered array of artwork IDs
|
||||
* @property string $model_version e.g. "sim_v1"
|
||||
* @property \Carbon\Carbon $computed_at
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*
|
||||
* @property-read Artwork $artwork
|
||||
*/
|
||||
final class RecArtworkRec extends Model
|
||||
{
|
||||
protected $table = 'rec_artwork_recs';
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'rec_type',
|
||||
'recs',
|
||||
'model_version',
|
||||
'computed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'artwork_id' => 'integer',
|
||||
'recs' => 'array',
|
||||
'computed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relations ──────────────────────────────────────────────────────────
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class, 'artwork_id');
|
||||
}
|
||||
}
|
||||
54
app/Models/RecEvent.php
Normal file
54
app/Models/RecEvent.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Recommendation event log entry.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $user_id
|
||||
* @property string|null $session_id
|
||||
* @property string $event_type view|favourite|download
|
||||
* @property int $artwork_id
|
||||
* @property \Carbon\Carbon $created_at
|
||||
*
|
||||
* @property-read User|null $user
|
||||
* @property-read Artwork $artwork
|
||||
*/
|
||||
final class RecEvent extends Model
|
||||
{
|
||||
protected $table = 'rec_events';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'session_id',
|
||||
'event_type',
|
||||
'artwork_id',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'artwork_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relations ──────────────────────────────────────────────────────────
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
}
|
||||
54
app/Models/RecItemPair.php
Normal file
54
app/Models/RecItemPair.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Item-item co-occurrence pair for behavior-based similarity.
|
||||
*
|
||||
* @property int $a_artwork_id
|
||||
* @property int $b_artwork_id
|
||||
* @property float $weight
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*
|
||||
* @property-read Artwork $artworkA
|
||||
* @property-read Artwork $artworkB
|
||||
*/
|
||||
final class RecItemPair extends Model
|
||||
{
|
||||
protected $table = 'rec_item_pairs';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'a_artwork_id',
|
||||
'b_artwork_id',
|
||||
'weight',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'a_artwork_id' => 'integer',
|
||||
'b_artwork_id' => 'integer',
|
||||
'weight' => 'double',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relations ──────────────────────────────────────────────────────────
|
||||
|
||||
public function artworkA(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class, 'a_artwork_id');
|
||||
}
|
||||
|
||||
public function artworkB(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class, 'b_artwork_id');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Jobs\RecComputeSimilarByBehaviorJob;
|
||||
use App\Jobs\RecComputeSimilarHybridJob;
|
||||
use App\Models\ArtworkFavourite;
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -24,6 +26,9 @@ class ArtworkFavouriteObserver
|
||||
if ($creatorId) {
|
||||
$this->userStats->incrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
// §7.5 On-demand: recompute behavior similarity when artwork reaches threshold
|
||||
$this->maybeRecomputeBehavior($favourite->artwork_id);
|
||||
}
|
||||
|
||||
public function deleted(ArtworkFavourite $favourite): void
|
||||
@@ -42,4 +47,22 @@ class ArtworkFavouriteObserver
|
||||
|
||||
return $id !== null ? (int) $id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch on-demand behavior recomputation when an artwork crosses a
|
||||
* favourites threshold (5, 10, 25, 50 …).
|
||||
*/
|
||||
private function maybeRecomputeBehavior(int $artworkId): void
|
||||
{
|
||||
$count = (int) DB::table('artwork_favourites')
|
||||
->where('artwork_id', $artworkId)
|
||||
->count();
|
||||
|
||||
$thresholds = [5, 10, 25, 50, 100];
|
||||
|
||||
if (in_array($count, $thresholds, true)) {
|
||||
RecComputeSimilarByBehaviorJob::dispatch($artworkId)->delay(now()->addSeconds(30));
|
||||
RecComputeSimilarHybridJob::dispatch($artworkId)->delay(now()->addMinute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Jobs\RecComputeSimilarByTagsJob;
|
||||
use App\Jobs\RecComputeSimilarHybridJob;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\UserStatsService;
|
||||
|
||||
@@ -39,6 +41,14 @@ class ArtworkObserver
|
||||
}
|
||||
|
||||
$this->indexer->update($artwork);
|
||||
|
||||
// §7.5 On-demand: recompute similarity when tags/categories could have changed.
|
||||
// The pivot sync happens outside this observer, so we dispatch on every
|
||||
// meaningful update and let the job be idempotent (cheap if nothing changed).
|
||||
if ($artwork->is_public && $artwork->published_at) {
|
||||
RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30));
|
||||
RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinutes(1));
|
||||
}
|
||||
}
|
||||
|
||||
/** Soft delete — remove from search and decrement uploads_count. */
|
||||
|
||||
@@ -36,6 +36,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->singleton(UploadDraftServiceInterface::class, function ($app) {
|
||||
return new UploadDraftService($app->make('filesystem'));
|
||||
});
|
||||
|
||||
// Bind vector adapter interface for similarity system (resolves via factory)
|
||||
$this->app->bind(
|
||||
\App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface::class,
|
||||
fn () => \App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory::make(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -269,6 +269,27 @@ final class ArtworkSearchService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rising: sorted by heat_score (recalculated every 15 min).
|
||||
*
|
||||
* Surfaces artworks with rapid recent engagement growth.
|
||||
* Restricts to last 30 days, sorted by heat_score DESC.
|
||||
*/
|
||||
public function discoverRising(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$cutoff = now()->subDays(30)->toDateString();
|
||||
|
||||
return Cache::remember("discover.rising.{$page}", 120, function () use ($perPage, $cutoff) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh: newest uploads first.
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,7 @@ final class HomepageService
|
||||
{
|
||||
return [
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'rising' => $this->getRising(),
|
||||
'trending' => $this->getTrending(),
|
||||
'fresh' => $this->getFreshUploads(),
|
||||
'tags' => $this->getPopularTags(),
|
||||
@@ -74,6 +75,7 @@ final class HomepageService
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'for_you' => $this->getForYouPreview($user),
|
||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||
'rising' => $this->getRising(),
|
||||
'trending' => $this->getTrending(),
|
||||
'fresh' => $this->getFreshUploads(),
|
||||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||||
@@ -132,6 +134,65 @@ final class HomepageService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
|
||||
*
|
||||
* Surfaces artworks with the fastest recent engagement growth.
|
||||
* Falls back to DB ORDER BY heat_score if Meilisearch is unavailable.
|
||||
*/
|
||||
public function getRising(int $limit = 10): array
|
||||
{
|
||||
$cutoff = now()->subDays(30)->toDateString();
|
||||
|
||||
return Cache::remember("homepage.rising.{$limit}", 120, function () use ($limit, $cutoff): array {
|
||||
try {
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate($limit, 'page', 1);
|
||||
|
||||
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
return $this->getRisingFromDb($limit);
|
||||
}
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getRising Meilisearch unavailable, DB fallback', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->getRisingFromDb($limit);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-only fallback for rising (Meilisearch unavailable).
|
||||
*/
|
||||
private function getRisingFromDb(int $limit): array
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('artwork_stats.heat_score')
|
||||
->orderByDesc('artwork_stats.engagement_velocity')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
|
||||
*
|
||||
|
||||
@@ -49,7 +49,7 @@ class LegacyService
|
||||
$featured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Featured Artwork',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
];
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class LegacyService
|
||||
$memberFeatured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Members Pick',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
'votes' => 0,
|
||||
];
|
||||
@@ -106,7 +106,7 @@ class LegacyService
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
'category_name' => 'Photography',
|
||||
],
|
||||
@@ -282,7 +282,7 @@ class LegacyService
|
||||
} else {
|
||||
$row->ext = null;
|
||||
$row->encoded = null;
|
||||
$row->thumb_url = '/gfx/sb_join.jpg';
|
||||
$row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$row->thumb_srcset = null;
|
||||
}
|
||||
|
||||
|
||||
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal file
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Runtime service for the Similar Artworks hybrid recommender (spec §8).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Try precomputed similar_hybrid list
|
||||
* 2. Else similar_visual (if enabled)
|
||||
* 3. Else similar_tags
|
||||
* 4. Else similar_behavior
|
||||
* 5. Else trending fallback in the same category/content_type
|
||||
*
|
||||
* Lists are cached in Redis/cache with a configurable TTL.
|
||||
* Hydration fetches artworks in one query, preserving stored order.
|
||||
* An author-cap diversity filter is applied at runtime as a final check.
|
||||
*/
|
||||
final class HybridSimilarArtworksService
|
||||
{
|
||||
private const FALLBACK_ORDER = [
|
||||
'similar_hybrid',
|
||||
'similar_visual',
|
||||
'similar_tags',
|
||||
'similar_behavior',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get similar artworks for the given artwork.
|
||||
*
|
||||
* @param string|null $type null|'similar'='hybrid fallback', 'visual', 'tags', 'behavior'
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
public function forArtwork(int $artworkId, int $limit = 12, ?string $type = null): Collection
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$cacheTtl = (int) config('recommendations.similarity.cache_ttl', 6 * 3600);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
$vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false);
|
||||
|
||||
$typeSuffix = $type && $type !== 'similar' ? ":{$type}" : '';
|
||||
$cacheKey = "rec:artwork:{$artworkId}:similar:{$modelVersion}{$typeSuffix}";
|
||||
|
||||
$ids = Cache::remember($cacheKey, $cacheTtl, function () use (
|
||||
$artworkId, $modelVersion, $vectorEnabled, $type
|
||||
): array {
|
||||
return $this->resolveIds($artworkId, $modelVersion, $vectorEnabled, $type);
|
||||
});
|
||||
|
||||
if ($ids === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Take requested limit + buffer for author-diversity filtering
|
||||
$idSlice = array_slice($ids, 0, $limit * 3);
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->whereIn('id', $idSlice)
|
||||
->public()
|
||||
->published()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Preserve precomputed order + apply author cap
|
||||
$authorCounts = [];
|
||||
$result = [];
|
||||
|
||||
foreach ($idSlice as $id) {
|
||||
/** @var Artwork|null $artwork */
|
||||
$artwork = $artworks->get($id);
|
||||
if (! $artwork) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$authorId = $artwork->user_id;
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = $artwork;
|
||||
if (count($result) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return collect($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the precomputed ID list, falling through rec types.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function resolveIds(int $artworkId, string $modelVersion, bool $vectorEnabled, ?string $type = null): array
|
||||
{
|
||||
// If a specific type was requested, try only that type + trending fallback
|
||||
if ($type && $type !== 'similar') {
|
||||
$recType = match ($type) {
|
||||
'visual' => 'similar_visual',
|
||||
'tags' => 'similar_tags',
|
||||
'behavior' => 'similar_behavior',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($recType) {
|
||||
$rec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('rec_type', $recType)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($rec && is_array($rec->recs) && $rec->recs !== []) {
|
||||
return array_map('intval', $rec->recs);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->trendingFallback($artworkId);
|
||||
}
|
||||
|
||||
// Default: hybrid fallback chain
|
||||
$tryTypes = $vectorEnabled
|
||||
? self::FALLBACK_ORDER
|
||||
: array_filter(self::FALLBACK_ORDER, fn (string $t) => $t !== 'similar_visual');
|
||||
|
||||
foreach ($tryTypes as $recType) {
|
||||
$rec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('rec_type', $recType)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($rec && is_array($rec->recs) && $rec->recs !== []) {
|
||||
return array_map('intval', $rec->recs);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trending fallback (category-scoped) ────────────────────────────────
|
||||
return $this->trendingFallback($artworkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending fallback: fetch recent popular artworks in the same category.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function trendingFallback(int $artworkId): array
|
||||
{
|
||||
$catIds = DB::table('artwork_category')
|
||||
->where('artwork_id', $artworkId)
|
||||
->pluck('category_id')
|
||||
->all();
|
||||
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->where('id', '!=', $artworkId);
|
||||
|
||||
if ($catIds !== []) {
|
||||
$query->whereHas('categories', function ($q) use ($catIds) {
|
||||
$q->whereIn('categories.id', $catIds);
|
||||
});
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('published_at')
|
||||
->limit(30)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* pgvector adapter — uses the artwork_embeddings table with cosine similarity.
|
||||
*
|
||||
* Requires PostgreSQL with the pgvector extension installed.
|
||||
* Schema: artwork_embeddings (artwork_id PK, model, dims, embedding vector(N), ...)
|
||||
*
|
||||
* Spec §9 Option A.
|
||||
*/
|
||||
final class PgvectorAdapter implements VectorAdapterInterface
|
||||
{
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array
|
||||
{
|
||||
// Fetch reference embedding
|
||||
$ref = DB::table('artwork_embeddings')
|
||||
->where('artwork_id', $artworkId)
|
||||
->select('embedding_json')
|
||||
->first();
|
||||
|
||||
if (! $ref || ! $ref->embedding_json) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$embedding = json_decode($ref->embedding_json, true);
|
||||
if (! is_array($embedding) || $embedding === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// pgvector cosine distance operator: <=>
|
||||
// Score = 1 - distance (higher = more similar)
|
||||
$vecLiteral = '[' . implode(',', array_map('floatval', $embedding)) . ']';
|
||||
|
||||
try {
|
||||
$rows = DB::select(
|
||||
"SELECT artwork_id, 1 - (embedding_json::vector <=> ?::vector) AS score
|
||||
FROM artwork_embeddings
|
||||
WHERE artwork_id != ?
|
||||
ORDER BY embedding_json::vector <=> ?::vector
|
||||
LIMIT ?",
|
||||
[$vecLiteral, $artworkId, $vecLiteral, $topK]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PgvectorAdapter] Query failed: {$e->getMessage()}");
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn ($row) => [
|
||||
'artwork_id' => (int) $row->artwork_id,
|
||||
'score' => (float) $row->score,
|
||||
], $rows);
|
||||
}
|
||||
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
|
||||
{
|
||||
$json = json_encode($embedding);
|
||||
|
||||
DB::table('artwork_embeddings')->updateOrInsert(
|
||||
['artwork_id' => $artworkId],
|
||||
[
|
||||
'embedding_json' => $json,
|
||||
'model' => $metadata['model'] ?? 'clip',
|
||||
'model_version' => $metadata['model_version'] ?? 'v1',
|
||||
'dim' => count($embedding),
|
||||
'is_normalized' => $metadata['is_normalized'] ?? true,
|
||||
'generated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteEmbedding(int $artworkId): void
|
||||
{
|
||||
DB::table('artwork_embeddings')
|
||||
->where('artwork_id', $artworkId)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Managed vector DB adapter (Pinecone-style REST API).
|
||||
*
|
||||
* Spec §9 Option B.
|
||||
*
|
||||
* Configuration:
|
||||
* recommendations.similarity.pinecone.api_key
|
||||
* recommendations.similarity.pinecone.index_host
|
||||
* recommendations.similarity.pinecone.index_name
|
||||
* recommendations.similarity.pinecone.namespace
|
||||
* recommendations.similarity.pinecone.top_k
|
||||
*/
|
||||
final class PineconeAdapter implements VectorAdapterInterface
|
||||
{
|
||||
private function apiKey(): string
|
||||
{
|
||||
return (string) config('recommendations.similarity.pinecone.api_key', '');
|
||||
}
|
||||
|
||||
private function host(): string
|
||||
{
|
||||
return rtrim((string) config('recommendations.similarity.pinecone.index_host', ''), '/');
|
||||
}
|
||||
|
||||
private function namespace(): string
|
||||
{
|
||||
return (string) config('recommendations.similarity.pinecone.namespace', '');
|
||||
}
|
||||
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array
|
||||
{
|
||||
$configTopK = (int) config('recommendations.similarity.pinecone.top_k', 100);
|
||||
$effectiveTopK = min($topK, $configTopK);
|
||||
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/query", array_filter([
|
||||
'id' => $vectorId,
|
||||
'topK' => $effectiveTopK + 1, // +1 to exclude self
|
||||
'includeMetadata' => true,
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
'filter' => [
|
||||
'is_active' => ['$eq' => true],
|
||||
],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning("[PineconeAdapter] Query failed: HTTP {$response->status()}");
|
||||
return [];
|
||||
}
|
||||
|
||||
$matches = $response->json('matches', []);
|
||||
|
||||
$results = [];
|
||||
foreach ($matches as $match) {
|
||||
$matchId = $match['id'] ?? '';
|
||||
// Extract artwork ID from "artwork:123" format
|
||||
if (! str_starts_with($matchId, 'artwork:')) {
|
||||
continue;
|
||||
}
|
||||
$matchArtworkId = (int) substr($matchId, 8);
|
||||
if ($matchArtworkId === $artworkId) {
|
||||
continue; // skip self
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'artwork_id' => $matchArtworkId,
|
||||
'score' => (float) ($match['score'] ?? 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Query exception: {$e->getMessage()}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
|
||||
{
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
// Spec §9B: metadata should include category_id, content_type, author_id, is_active, nsfw
|
||||
$pineconeMetadata = array_merge([
|
||||
'is_active' => true,
|
||||
'category_id' => $metadata['category_id'] ?? null,
|
||||
'content_type' => $metadata['content_type'] ?? null,
|
||||
'author_id' => $metadata['author_id'] ?? null,
|
||||
'nsfw' => $metadata['nsfw'] ?? false,
|
||||
], array_diff_key($metadata, array_flip([
|
||||
'category_id', 'content_type', 'author_id', 'nsfw', 'is_active',
|
||||
])));
|
||||
|
||||
// Remove null values (Pinecone doesn't accept nulls in metadata)
|
||||
$pineconeMetadata = array_filter($pineconeMetadata, fn ($v) => $v !== null);
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/vectors/upsert", array_filter([
|
||||
'vectors' => [
|
||||
[
|
||||
'id' => $vectorId,
|
||||
'values' => array_map('floatval', $embedding),
|
||||
'metadata' => $pineconeMetadata,
|
||||
],
|
||||
],
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning("[PineconeAdapter] Upsert failed for artwork {$artworkId}: HTTP {$response->status()}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Upsert exception for artwork {$artworkId}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteEmbedding(int $artworkId): void
|
||||
{
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
try {
|
||||
Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/vectors/delete", array_filter([
|
||||
'ids' => [$vectorId],
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Delete exception for artwork {$artworkId}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Factory to resolve the configured VectorAdapterInterface implementation.
|
||||
*/
|
||||
final class VectorAdapterFactory
|
||||
{
|
||||
/**
|
||||
* @return VectorAdapterInterface|null null when vector similarity is disabled
|
||||
*/
|
||||
public static function make(): ?VectorAdapterInterface
|
||||
{
|
||||
if (! (bool) config('recommendations.similarity.vector_enabled', false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$adapter = (string) config('recommendations.similarity.vector_adapter', 'pgvector');
|
||||
|
||||
return match ($adapter) {
|
||||
'pgvector' => new PgvectorAdapter(),
|
||||
'pinecone' => new PineconeAdapter(),
|
||||
default => self::fallback($adapter),
|
||||
};
|
||||
}
|
||||
|
||||
private static function fallback(string $adapter): PgvectorAdapter
|
||||
{
|
||||
Log::warning("[VectorAdapterFactory] Unknown adapter '{$adapter}', falling back to pgvector.");
|
||||
return new PgvectorAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
/**
|
||||
* Contract for vector-similarity adapters (pgvector, Pinecone, etc.).
|
||||
*
|
||||
* Each adapter can query nearest-neighbor artworks for a given artwork ID
|
||||
* and return an ordered list of (artwork_id, score) pairs.
|
||||
*/
|
||||
interface VectorAdapterInterface
|
||||
{
|
||||
/**
|
||||
* Find the most visually similar artworks.
|
||||
*
|
||||
* @param int $artworkId Source artwork
|
||||
* @param int $topK Max neighbors to return
|
||||
* @return list<array{artwork_id: int, score: float}> Ordered by score descending
|
||||
*/
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array;
|
||||
|
||||
/**
|
||||
* Upsert an artwork embedding into the vector store.
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param array $embedding Raw float vector
|
||||
* @param array $metadata Optional metadata (category, author, etc.)
|
||||
*/
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void;
|
||||
|
||||
/**
|
||||
* Delete an artwork embedding from the vector store.
|
||||
*/
|
||||
public function deleteEmbedding(int $artworkId): void;
|
||||
}
|
||||
209
app/Services/Studio/StudioArtworkQueryService.php
Normal file
209
app/Services/Studio/StudioArtworkQueryService.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Handles artwork listing queries for Studio, using Meilisearch with DB fallback.
|
||||
*/
|
||||
final class StudioArtworkQueryService
|
||||
{
|
||||
/**
|
||||
* List artworks for a creator with search, filter, and sort via Meilisearch.
|
||||
*
|
||||
* Supported $filters keys:
|
||||
* q string — free-text search
|
||||
* status string — published|draft|archived
|
||||
* category string — category slug
|
||||
* tags array — tag slugs
|
||||
* date_from string — Y-m-d
|
||||
* date_to string — Y-m-d
|
||||
* performance string — rising|top|low
|
||||
* sort string — created_at:desc (default), ranking_score:desc, heat_score:desc, etc.
|
||||
*/
|
||||
public function list(int $userId, array $filters = [], int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
// Skip Meilisearch when driver is null (e.g. in tests)
|
||||
$driver = config('scout.driver');
|
||||
if (empty($driver) || $driver === 'null') {
|
||||
return $this->listViaDatabase($userId, $filters, $perPage);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->listViaMeilisearch($userId, $filters, $perPage);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Meilisearch unavailable, falling back to DB', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return $this->listViaDatabase($userId, $filters, $perPage);
|
||||
}
|
||||
}
|
||||
|
||||
private function listViaMeilisearch(int $userId, array $filters, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$q = $filters['q'] ?? '';
|
||||
$filterParts = ["author_id = {$userId}"];
|
||||
$sort = [];
|
||||
|
||||
// Status filter
|
||||
$status = $filters['status'] ?? null;
|
||||
if ($status === 'published') {
|
||||
$filterParts[] = 'is_public = true AND is_approved = true';
|
||||
} elseif ($status === 'draft') {
|
||||
$filterParts[] = 'is_public = false';
|
||||
}
|
||||
// archived handled at DB level since Meili doesn't see soft-deleted
|
||||
|
||||
// Category filter
|
||||
if (!empty($filters['category'])) {
|
||||
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (!empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
|
||||
}
|
||||
}
|
||||
|
||||
// Date range
|
||||
if (!empty($filters['date_from'])) {
|
||||
$filterParts[] = 'created_at >= "' . $filters['date_from'] . '"';
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$filterParts[] = 'created_at <= "' . $filters['date_to'] . '"';
|
||||
}
|
||||
|
||||
// Performance quick filters
|
||||
if (!empty($filters['performance'])) {
|
||||
match ($filters['performance']) {
|
||||
'rising' => $filterParts[] = 'heat_score > 5',
|
||||
'top' => $filterParts[] = 'ranking_score > 50',
|
||||
'low' => $filterParts[] = 'views < 10',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortParam = $filters['sort'] ?? 'created_at:desc';
|
||||
$validSortFields = [
|
||||
'created_at', 'ranking_score', 'heat_score',
|
||||
'views', 'likes', 'shares_count',
|
||||
'downloads', 'comments_count', 'favorites_count',
|
||||
];
|
||||
$parts = explode(':', $sortParam);
|
||||
if (count($parts) === 2 && in_array($parts[0], $validSortFields, true)) {
|
||||
$sort[] = $parts[0] . ':' . ($parts[1] === 'asc' ? 'asc' : 'desc');
|
||||
}
|
||||
|
||||
$options = ['filter' => implode(' AND ', $filterParts)];
|
||||
if ($sort !== []) {
|
||||
$options['sort'] = $sort;
|
||||
}
|
||||
|
||||
return Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
->query(fn (Builder $query) => $query
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->withCount(['comments', 'downloads'])
|
||||
)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
private function listViaDatabase(int $userId, array $filters, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->withCount(['comments', 'downloads']);
|
||||
|
||||
$status = $filters['status'] ?? null;
|
||||
if ($status === 'published') {
|
||||
$query->where('is_public', true)->where('is_approved', true);
|
||||
} elseif ($status === 'draft') {
|
||||
$query->where('is_public', false);
|
||||
} elseif ($status === 'archived') {
|
||||
$query->onlyTrashed();
|
||||
} else {
|
||||
// Show all except archived by default
|
||||
$query->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
// Free-text search
|
||||
if (!empty($filters['q'])) {
|
||||
$q = $filters['q'];
|
||||
$query->where(function (Builder $w) use ($q) {
|
||||
$w->where('title', 'LIKE', "%{$q}%")
|
||||
->orWhereHas('tags', fn (Builder $t) => $t->where('slug', 'LIKE', "%{$q}%"));
|
||||
});
|
||||
}
|
||||
|
||||
// Category
|
||||
if (!empty($filters['category'])) {
|
||||
$query->whereHas('categories', fn (Builder $c) => $c->where('slug', $filters['category']));
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (!empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$query->whereHas('tags', fn (Builder $t) => $t->where('slug', $tag));
|
||||
}
|
||||
}
|
||||
|
||||
// Date range
|
||||
if (!empty($filters['date_from'])) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
// Performance
|
||||
if (!empty($filters['performance'])) {
|
||||
$query->whereHas('stats', function (Builder $s) use ($filters) {
|
||||
match ($filters['performance']) {
|
||||
'rising' => $s->where('heat_score', '>', 5),
|
||||
'top' => $s->where('ranking_score', '>', 50),
|
||||
'low' => $s->where('views', '<', 10),
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortParam = $filters['sort'] ?? 'created_at:desc';
|
||||
$parts = explode(':', $sortParam);
|
||||
$sortField = $parts[0] ?? 'created_at';
|
||||
$sortDir = ($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$dbSortMap = [
|
||||
'created_at' => 'artworks.created_at',
|
||||
'ranking_score' => 'ranking_score',
|
||||
'heat_score' => 'heat_score',
|
||||
'views' => 'views',
|
||||
'likes' => 'favorites',
|
||||
'shares_count' => 'shares_count',
|
||||
'downloads' => 'downloads',
|
||||
'comments_count' => 'comments_count',
|
||||
'favorites_count' => 'favorites',
|
||||
];
|
||||
|
||||
$statsSortFields = ['ranking_score', 'heat_score', 'views', 'likes', 'shares_count', 'downloads', 'comments_count', 'favorites_count'];
|
||||
|
||||
if (in_array($sortField, $statsSortFields, true)) {
|
||||
$dbCol = $dbSortMap[$sortField] ?? $sortField;
|
||||
$query->leftJoin('artwork_stats', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->orderBy("artwork_stats.{$dbCol}", $sortDir)
|
||||
->select('artworks.*');
|
||||
} else {
|
||||
$query->orderBy($dbSortMap[$sortField] ?? 'artworks.created_at', $sortDir);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
}
|
||||
165
app/Services/Studio/StudioBulkActionService.php
Normal file
165
app/Services/Studio/StudioBulkActionService.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Handles bulk operations on artworks for the Studio module.
|
||||
*/
|
||||
final class StudioBulkActionService
|
||||
{
|
||||
/**
|
||||
* Execute a bulk action on the given artwork IDs, enforcing ownership.
|
||||
*
|
||||
* @param int $userId The authenticated user ID
|
||||
* @param string $action publish|unpublish|archive|unarchive|delete|change_category|add_tags|remove_tags
|
||||
* @param array $artworkIds Array of artwork IDs
|
||||
* @param array $params Extra params (category_id, tag_ids)
|
||||
* @return array{success: int, failed: int, errors: array}
|
||||
*/
|
||||
public function execute(int $userId, string $action, array $artworkIds, array $params = []): array
|
||||
{
|
||||
$result = ['success' => 0, 'failed' => 0, 'errors' => []];
|
||||
|
||||
// Validate ownership — fetch only artworks belonging to this user
|
||||
$query = Artwork::where('user_id', $userId);
|
||||
if ($action === 'unarchive') {
|
||||
$query->onlyTrashed();
|
||||
}
|
||||
$artworks = $query->whereIn('id', $artworkIds)->get();
|
||||
|
||||
$foundIds = $artworks->pluck('id')->all();
|
||||
$missingIds = array_diff($artworkIds, $foundIds);
|
||||
foreach ($missingIds as $id) {
|
||||
$result['failed']++;
|
||||
$result['errors'][] = "Artwork #{$id}: not found or not owned by you";
|
||||
}
|
||||
|
||||
if ($artworks->isEmpty()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($artworks as $artwork) {
|
||||
$this->applyAction($artwork, $action, $params);
|
||||
$result['success']++;
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Reindex affected artworks in Meilisearch
|
||||
$this->reindexArtworks($artworks);
|
||||
|
||||
Log::info('Studio bulk action completed', [
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'count' => $result['success'],
|
||||
'ids' => $foundIds,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
$result['failed'] += $result['success'];
|
||||
$result['success'] = 0;
|
||||
$result['errors'][] = 'Transaction failed: ' . $e->getMessage();
|
||||
|
||||
Log::error('Studio bulk action failed', [
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function applyAction(Artwork $artwork, string $action, array $params): void
|
||||
{
|
||||
match ($action) {
|
||||
'publish' => $this->publish($artwork),
|
||||
'unpublish' => $this->unpublish($artwork),
|
||||
'archive' => $artwork->delete(), // Soft delete
|
||||
'unarchive' => $artwork->restore(),
|
||||
'delete' => $artwork->forceDelete(),
|
||||
'change_category' => $this->changeCategory($artwork, $params),
|
||||
'add_tags' => $this->addTags($artwork, $params),
|
||||
'remove_tags' => $this->removeTags($artwork, $params),
|
||||
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
private function publish(Artwork $artwork): void
|
||||
{
|
||||
$artwork->update([
|
||||
'is_public' => true,
|
||||
'published_at' => $artwork->published_at ?? now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function unpublish(Artwork $artwork): void
|
||||
{
|
||||
$artwork->update(['is_public' => false]);
|
||||
}
|
||||
|
||||
private function changeCategory(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['category_id'])) {
|
||||
throw new \InvalidArgumentException('category_id required for change_category');
|
||||
}
|
||||
|
||||
$artwork->categories()->sync([(int) $params['category_id']]);
|
||||
}
|
||||
|
||||
private function addTags(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['tag_ids'])) {
|
||||
throw new \InvalidArgumentException('tag_ids required for add_tags');
|
||||
}
|
||||
|
||||
$pivotData = [];
|
||||
foreach ((array) $params['tag_ids'] as $tagId) {
|
||||
$pivotData[(int) $tagId] = ['source' => 'studio_bulk', 'confidence' => 1.0];
|
||||
}
|
||||
|
||||
$artwork->tags()->syncWithoutDetaching($pivotData);
|
||||
|
||||
// Increment usage counts
|
||||
Tag::whereIn('id', array_keys($pivotData))
|
||||
->increment('usage_count');
|
||||
}
|
||||
|
||||
private function removeTags(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['tag_ids'])) {
|
||||
throw new \InvalidArgumentException('tag_ids required for remove_tags');
|
||||
}
|
||||
|
||||
$tagIds = array_map('intval', (array) $params['tag_ids']);
|
||||
$artwork->tags()->detach($tagIds);
|
||||
|
||||
Tag::whereIn('id', $tagIds)
|
||||
->where('usage_count', '>', 0)
|
||||
->decrement('usage_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Meilisearch reindex for the given artworks.
|
||||
*/
|
||||
private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void
|
||||
{
|
||||
try {
|
||||
$artworks->each->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Failed to reindex artworks after bulk action', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
229
app/Services/Studio/StudioMetricsService.php
Normal file
229
app/Services/Studio/StudioMetricsService.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Provides dashboard KPI data for the Studio overview page.
|
||||
*/
|
||||
final class StudioMetricsService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get dashboard KPI metrics for a creator.
|
||||
*
|
||||
* @return array{total_artworks: int, views_30d: int, favourites_30d: int, shares_30d: int, followers: int}
|
||||
*/
|
||||
public function getDashboardKpis(int $userId): array
|
||||
{
|
||||
$cacheKey = "studio.kpi.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
|
||||
$totalArtworks = Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// Aggregate stats from artwork_stats for this user's artworks
|
||||
$statsAgg = DB::table('artwork_stats')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('
|
||||
COALESCE(SUM(artwork_stats.views), 0) as total_views,
|
||||
COALESCE(SUM(artwork_stats.favorites), 0) as total_favourites,
|
||||
COALESCE(SUM(artwork_stats.shares_count), 0) as total_shares
|
||||
')
|
||||
->first();
|
||||
|
||||
// Views in last 30 days from hourly snapshots if available, fallback to totals
|
||||
$views30d = 0;
|
||||
try {
|
||||
if (\Illuminate\Support\Facades\Schema::hasTable('artwork_metric_snapshots_hourly')) {
|
||||
$views30d = (int) DB::table('artwork_metric_snapshots_hourly')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_metric_snapshots_hourly.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artwork_metric_snapshots_hourly.bucket_hour', '>=', now()->subDays(30))
|
||||
->sum('artwork_metric_snapshots_hourly.views_count');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Table or column doesn't exist — fall back to totals
|
||||
}
|
||||
|
||||
if ($views30d === 0) {
|
||||
$views30d = (int) ($statsAgg->total_views ?? 0);
|
||||
}
|
||||
|
||||
$followers = DB::table('user_followers')
|
||||
->where('user_id', $userId)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_artworks' => $totalArtworks,
|
||||
'views_30d' => $views30d,
|
||||
'favourites_30d' => (int) ($statsAgg->total_favourites ?? 0),
|
||||
'shares_30d' => (int) ($statsAgg->total_shares ?? 0),
|
||||
'followers' => $followers,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top performing artworks for a creator in the last 7 days.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getTopPerformers(int $userId, int $limit = 6): \Illuminate\Support\Collection
|
||||
{
|
||||
$cacheKey = "studio.top_performers.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId, $limit) {
|
||||
return Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->with(['stats', 'tags'])
|
||||
->whereHas('stats')
|
||||
->orderByDesc(
|
||||
ArtworkStats::select('heat_score')
|
||||
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (Artwork $art) => [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug,
|
||||
'thumb_url' => $art->thumbUrl('md'),
|
||||
'favourites' => (int) ($art->stats?->favorites ?? 0),
|
||||
'shares' => (int) ($art->stats?->shares_count ?? 0),
|
||||
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
|
||||
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent comments on a creator's artworks.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getRecentComments(int $userId, int $limit = 5): \Illuminate\Support\Collection
|
||||
{
|
||||
return DB::table('artwork_comments')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
|
||||
->join('users', 'users.id', '=', 'artwork_comments.user_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artwork_comments.deleted_at')
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->limit($limit)
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.content as body',
|
||||
'artwork_comments.created_at',
|
||||
'users.name as author_name',
|
||||
'users.username as author_username',
|
||||
'artworks.title as artwork_title',
|
||||
'artworks.slug as artwork_slug',
|
||||
])
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate analytics across all artworks for the Studio Analytics page.
|
||||
*
|
||||
* @return array{totals: array, top_artworks: array, content_breakdown: array}
|
||||
*/
|
||||
public function getAnalyticsOverview(int $userId): array
|
||||
{
|
||||
$cacheKey = "studio.analytics_overview.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
|
||||
// Totals
|
||||
$totals = DB::table('artwork_stats')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('
|
||||
COALESCE(SUM(artwork_stats.views), 0) as views,
|
||||
COALESCE(SUM(artwork_stats.favorites), 0) as favourites,
|
||||
COALESCE(SUM(artwork_stats.shares_count), 0) as shares,
|
||||
COALESCE(SUM(artwork_stats.downloads), 0) as downloads,
|
||||
COALESCE(SUM(artwork_stats.comments_count), 0) as comments,
|
||||
COALESCE(AVG(artwork_stats.ranking_score), 0) as avg_ranking,
|
||||
COALESCE(AVG(artwork_stats.heat_score), 0) as avg_heat
|
||||
')
|
||||
->first();
|
||||
|
||||
// Top 10 artworks by ranking score
|
||||
$topArtworks = Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->with(['stats'])
|
||||
->whereHas('stats')
|
||||
->orderByDesc(
|
||||
ArtworkStats::select('ranking_score')
|
||||
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (Artwork $art) => [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug,
|
||||
'thumb_url' => $art->thumbUrl('sq'),
|
||||
'views' => (int) ($art->stats?->views ?? 0),
|
||||
'favourites' => (int) ($art->stats?->favorites ?? 0),
|
||||
'shares' => (int) ($art->stats?->shares_count ?? 0),
|
||||
'downloads' => (int) ($art->stats?->downloads ?? 0),
|
||||
'comments' => (int) ($art->stats?->comments_count ?? 0),
|
||||
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
|
||||
]);
|
||||
|
||||
// Content type breakdown
|
||||
$contentBreakdown = DB::table('artworks')
|
||||
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
|
||||
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
|
||||
->join('content_types', 'content_types.id', '=', 'categories.content_type_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->groupBy('content_types.id', 'content_types.name', 'content_types.slug')
|
||||
->select([
|
||||
'content_types.name',
|
||||
'content_types.slug',
|
||||
DB::raw('COUNT(DISTINCT artworks.id) as count'),
|
||||
])
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'name' => $row->name,
|
||||
'slug' => $row->slug,
|
||||
'count' => (int) $row->count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'totals' => [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'favourites' => (int) ($totals->favourites ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'downloads' => (int) ($totals->downloads ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'avg_ranking' => round((float) ($totals->avg_ranking ?? 0), 1),
|
||||
'avg_heat' => round((float) ($totals->avg_heat ?? 0), 1),
|
||||
],
|
||||
'top_artworks' => $topArtworks->values()->all(),
|
||||
'content_breakdown' => $contentBreakdown,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Jobs\RecComputeSimilarByTagsJob;
|
||||
use App\Jobs\RecComputeSimilarHybridJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\TagNormalizer;
|
||||
@@ -346,5 +348,12 @@ final class TagService
|
||||
private function queueReindex(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
|
||||
// §7.5 On-demand: recompute tag/hybrid similarity when tags change.
|
||||
// Pivot syncs don't trigger the Artwork "updated" event, so we dispatch here.
|
||||
if ($artwork->is_public && $artwork->published_at) {
|
||||
RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30));
|
||||
RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user