Studio: make grid checkbox rectangular and commit table changes

This commit is contained in:
2026-03-01 08:43:48 +01:00
parent 211dc58884
commit e3ca845a6d
89 changed files with 7323 additions and 475 deletions

View File

@@ -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 3060 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ørensenDice-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))
);
}