published() ->with(['tags:id,slug', 'categories:id,slug']) ->find($id); if (! $artwork) { return response()->json(['error' => 'Artwork not found'], 404); } $cacheKey = "api.similar.{$artwork->id}"; $items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) { return $this->findSimilar($artwork); }); return response()->json(['data' => $items]); } private function findSimilar(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', 'id != ' . $artwork->id, '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) . '"', $tagSlugs )); $filterParts[] = '(' . $tagFilter . ')'; } elseif ($categorySlugs !== []) { // Fallback to category if no tags $catFilter = implode(' OR ', array_map( fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs )); $filterParts[] = '(' . $catFilter . ')'; } // ── Fetch 200-candidate pool from Meilisearch ───────────────────────── $results = Artwork::search('') ->options([ 'filter' => implode(' AND ', $filterParts), 'sort' => ['trending_score_7d:desc', 'created_at:desc'], ]) ->paginate(200, 'page', 1); $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); $scored = $collection->map(function (Artwork $candidate) use ( $srcTagSet, $tagSlugs, $srcOrientation, $srcW, $srcH ): array { $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 && abs($cW - $srcW) / $srcW <= 0.25 && 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; $score = $tagOverlap * 0.60 + $orientBonus + $resBonus + $popularity + $freshness; return ['score' => $score, 'artwork' => $candidate]; })->all(); 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)) ); } private function orientation(Artwork $artwork): string { if (! $artwork->width || ! $artwork->height) { return 'square'; } return match (true) { $artwork->width > $artwork->height => 'landscape', $artwork->height > $artwork->width => 'portrait', default => 'square', }; } }