published() ->with(['tags:id,slug', 'categories:id,slug']) ->find($id); if (! $artwork) { return response()->json(['error' => 'Artwork not found'], 404); } $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 } // 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 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); $filterParts = [ 'is_public = true', 'is_approved = true', 'id != ' . $artwork->id, 'author_id != ' . $artwork->user_id, ]; if ($tagSlugs !== []) { $tagFilter = implode(' OR ', array_map( fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs )); $filterParts[] = '(' . $tagFilter . ')'; } elseif ($categorySlugs !== []) { $catFilter = implode(' OR ', array_map( fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs )); $filterParts[] = '(' . $catFilter . ')'; } $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']); $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); $common = count(array_intersect_key($srcTagSet, $cTagSet)); $total = max(1, count($srcTagSet) + count($cTagSet) - $common); $tagOverlap = $common / $total; $orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0; $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; $views = max(0, (int) ($candidate->stats?->views ?? 0)); $popularity = min(0.15, log(1 + $views) / 13.0); $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 => array_merge( $this->formatArtwork($item['artwork']), ['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', }; } }