*/ 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 */ 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 */ 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(); } }