buildContext($collection, $draft); $theme = $context['primary_theme'] ?: $context['top_category'] ?: $context['event_label'] ?: 'Curated Highlights'; $primary = match ($context['type']) { Collection::TYPE_EDITORIAL => 'Staff Picks: ' . $theme, Collection::TYPE_COMMUNITY => ($context['allow_submissions'] ? 'Community Picks: ' : '') . $theme, default => $theme, }; $alternatives = array_values(array_unique(array_filter([ $primary, $theme . ' Showcase', $context['event_label'] ? $context['event_label'] . ': ' . $theme : null, $context['type'] === Collection::TYPE_EDITORIAL ? $theme . ' Editorial' : $theme . ' Collection', ]))); return [ 'title' => $alternatives[0] ?? $primary, 'alternatives' => array_slice($alternatives, 1, 3), 'rationale' => sprintf( 'Built from the strongest recurring theme%s across %d artworks.', $context['primary_theme'] ? ' (' . $context['primary_theme'] . ')' : '', $context['artworks_count'] ), 'source' => 'heuristic-ai', ]; } public function suggestSummary(Collection $collection, array $draft = []): array { $context = $this->buildContext($collection, $draft); $typeLabel = match ($context['type']) { Collection::TYPE_EDITORIAL => 'editorial', Collection::TYPE_COMMUNITY => 'community', default => 'curated', }; $themePart = $context['theme_sentence'] !== '' ? ' focused on ' . $context['theme_sentence'] : ''; $creatorPart = $context['creator_count'] > 1 ? sprintf(' featuring work from %d creators', $context['creator_count']) : ' featuring a tightly selected set of pieces'; $summary = sprintf( 'A %s collection%s with %d artworks%s.', $typeLabel, $themePart, $context['artworks_count'], $creatorPart ); $seo = sprintf( '%s on Skinbase Nova: %d curated artworks%s.', $this->draftString($collection, $draft, 'title') ?: $collection->title, $context['artworks_count'], $context['theme_sentence'] !== '' ? ' exploring ' . $context['theme_sentence'] : '' ); return [ 'summary' => Str::limit($summary, 220, ''), 'seo_description' => Str::limit($seo, 155, ''), 'rationale' => 'Summarised from the collection type, artwork count, creator mix, and recurring artwork themes.', 'source' => 'heuristic-ai', ]; } public function suggestCover(Collection $collection, array $draft = []): array { $artworks = $this->candidateArtworks($collection, $draft, 24); /** @var Artwork|null $winner */ $winner = $artworks ->sortByDesc(fn (Artwork $artwork) => $this->coverScore($artwork)) ->first(); if (! $winner) { return [ 'artwork' => null, 'rationale' => 'Add or match a few artworks first so the assistant has something to rank.', 'source' => 'heuristic-ai', ]; } $stats = $winner->stats; return [ 'artwork' => [ 'id' => (int) $winner->id, 'title' => (string) $winner->title, 'thumb' => $winner->thumbUrl('md'), 'url' => route('art.show', [ 'id' => $winner->id, 'slug' => Str::slug((string) ($winner->slug ?: $winner->title)) ?: (string) $winner->id, ]), ], 'rationale' => sprintf( 'Ranked highest for cover impact based on engagement, recency, and display-friendly proportions (%dx%d, %d views, %d likes).', (int) ($winner->width ?? 0), (int) ($winner->height ?? 0), (int) ($stats?->views ?? $winner->view_count ?? 0), (int) ($stats?->favorites ?? $winner->favourite_count ?? 0), ), 'source' => 'heuristic-ai', ]; } public function suggestGrouping(Collection $collection, array $draft = []): array { $artworks = $this->candidateArtworks($collection, $draft, 36); $themeBuckets = []; foreach ($artworks as $artwork) { $tag = $artwork->tags ->sortByDesc(fn ($item) => $item->pivot?->source === 'ai' ? 1 : 0) ->first(); $label = $tag?->name ?: ($artwork->categories->first()?->name) ?: ($artwork->stats?->views ? 'Popular highlights' : 'Curated picks'); if (! isset($themeBuckets[$label])) { $themeBuckets[$label] = [ 'label' => $label, 'artwork_ids' => [], ]; } if (count($themeBuckets[$label]['artwork_ids']) < 5) { $themeBuckets[$label]['artwork_ids'][] = (int) $artwork->id; } } $groups = collect($themeBuckets) ->map(fn (array $bucket) => [ 'label' => $bucket['label'], 'artwork_ids' => $bucket['artwork_ids'], 'count' => count($bucket['artwork_ids']), ]) ->sortByDesc('count') ->take(4) ->values() ->all(); return [ 'groups' => $groups, 'rationale' => $groups !== [] ? 'Grouped by the strongest recurring artwork themes so the collection can be split into cleaner sections.' : 'No strong theme groups were found yet.', 'source' => 'heuristic-ai', ]; } public function suggestRelatedArtworks(Collection $collection, array $draft = []): array { $seedArtworks = $this->candidateArtworks($collection, $draft, 24); $tagSlugs = $seedArtworks ->flatMap(fn (Artwork $artwork) => $artwork->tags->pluck('slug')) ->filter() ->unique() ->values(); $categoryIds = $seedArtworks ->flatMap(fn (Artwork $artwork) => $artwork->categories->pluck('id')) ->filter() ->unique() ->values(); $attachedIds = $collection->artworks()->pluck('artworks.id')->map(static fn ($id) => (int) $id)->all(); $candidates = Artwork::query() ->with(['tags', 'categories']) ->where('user_id', $collection->user_id) ->whereNotIn('id', $attachedIds) ->where(function ($query) use ($tagSlugs, $categoryIds): void { if ($tagSlugs->isNotEmpty()) { $query->orWhereHas('tags', fn ($tagQuery) => $tagQuery->whereIn('slug', $tagSlugs->all())); } if ($categoryIds->isNotEmpty()) { $query->orWhereHas('categories', fn ($categoryQuery) => $categoryQuery->whereIn('categories.id', $categoryIds->all())); } }) ->latest('published_at') ->limit(18) ->get() ->map(function (Artwork $artwork) use ($tagSlugs, $categoryIds): array { $sharedTags = $artwork->tags->pluck('slug')->intersect($tagSlugs)->values(); $sharedCategories = $artwork->categories->pluck('id')->intersect($categoryIds)->values(); $score = ($sharedTags->count() * 3) + ($sharedCategories->count() * 2); return [ 'id' => (int) $artwork->id, 'title' => (string) $artwork->title, 'thumb' => $artwork->thumbUrl('sm'), 'score' => $score, 'shared_tags' => $sharedTags->take(3)->values()->all(), 'shared_categories' => $sharedCategories->count(), ]; }) ->sortByDesc('score') ->take(6) ->values() ->all(); return [ 'artworks' => $candidates, 'rationale' => $candidates !== [] ? 'Suggested from your unassigned artworks that overlap most with the collection’s current themes and categories.' : 'No closely related unassigned artworks were found in your gallery yet.', 'source' => 'heuristic-ai', ]; } public function suggestTags(Collection $collection, array $draft = []): array { $context = $this->buildContext($collection, $draft); $artworks = $this->candidateArtworks($collection, $draft, 24); $tags = collect([ $context['primary_theme'], $context['top_category'], $context['event_label'] !== '' ? Str::slug($context['event_label']) : null, $collection->type === Collection::TYPE_EDITORIAL ? 'staff-picks' : null, $collection->type === Collection::TYPE_COMMUNITY ? 'community-curation' : null, ]) ->filter() ->merge( $artworks->flatMap(fn (Artwork $artwork) => $artwork->tags->pluck('slug')) ->filter() ->countBy() ->sortDesc() ->keys() ->take(4) ) ->map(fn ($value) => Str::of((string) $value)->replace('-', ' ')->trim()->lower()->value()) ->filter() ->unique() ->take(6) ->values() ->all(); return [ 'tags' => $tags, 'rationale' => 'Suggested from recurring artwork themes, categories, and collection type signals.', 'source' => 'heuristic-ai', ]; } public function suggestSeoDescription(Collection $collection, array $draft = []): array { $summary = $this->suggestSummary($collection, $draft); $title = $this->draftString($collection, $draft, 'title') ?: $collection->title; $label = match ($collection->type) { Collection::TYPE_EDITORIAL => 'Staff Pick', Collection::TYPE_COMMUNITY => 'Community Collection', default => 'Collection', }; return [ 'description' => Str::limit(sprintf('%s: %s. %s', $label, $title, $summary['seo_description']), 155, ''), 'rationale' => 'Optimised for social previews and search snippets using the title, type, and strongest collection theme.', 'source' => 'heuristic-ai', ]; } public function detectWeakMetadata(Collection $collection, array $draft = []): array { $context = $this->buildContext($collection, $draft); $issues = []; $title = $this->draftString($collection, $draft, 'title') ?: (string) $collection->title; $summary = $this->draftString($collection, $draft, 'summary'); $description = $this->draftString($collection, $draft, 'description'); $metadataScore = (float) ($collection->metadata_completeness_score ?? 0); $coverArtworkId = $draft['cover_artwork_id'] ?? $collection->cover_artwork_id; if ($title === '' || Str::length($title) < 12 || preg_match('/^(untitled|new collection|my collection)/i', $title) === 1) { $issues[] = $this->metadataIssue( 'title', 'high', 'Title needs more specificity', 'Use a more descriptive title that signals the theme, series, or purpose of the collection.' ); } if ($summary === null || Str::length($summary) < 60) { $issues[] = $this->metadataIssue( 'summary', 'high', 'Summary is missing or too short', 'Add a concise summary that explains what ties these artworks together and why the collection matters.' ); } if ($description === null || Str::length(strip_tags($description)) < 140) { $issues[] = $this->metadataIssue( 'description', 'medium', 'Description lacks depth', 'Expand the description with context, mood, creator intent, or campaign framing so the collection reads as deliberate curation.' ); } if (! $coverArtworkId) { $issues[] = $this->metadataIssue( 'cover', 'medium', 'No explicit cover artwork is set', 'Choose a cover artwork so the collection has a stronger visual anchor across profile, saved-library, and programming surfaces.' ); } if (($context['primary_theme'] ?? null) === null && ($context['top_category'] ?? null) === null) { $issues[] = $this->metadataIssue( 'theme', 'medium', 'Theme signals are weak', 'Add or retag a few representative artworks so the collection has a clearer theme for discovery and recommendations.' ); } if ($metadataScore > 0 && $metadataScore < 65) { $issues[] = $this->metadataIssue( 'metadata_score', 'medium', 'Metadata completeness is below the recommended threshold', sprintf('The current metadata completeness score is %.1f. Tightening title, summary, description, and cover selection should improve it.', $metadataScore) ); } return [ 'status' => $issues === [] ? 'healthy' : 'needs_work', 'issues' => $issues, 'rationale' => $issues === [] ? 'The collection metadata is strong enough for creator-facing surfaces and AI assistance did not find obvious weak spots.' : 'Detected from title specificity, metadata coverage, theme clarity, and cover readiness heuristics.', 'source' => 'heuristic-ai', ]; } public function suggestStaleRefresh(Collection $collection, array $draft = []): array { $context = $this->buildContext($collection, $draft); $referenceAt = $this->staleReferenceAt($collection); $daysSinceRefresh = $referenceAt?->diffInDays(now()) ?? null; $isStale = $daysSinceRefresh !== null && ($daysSinceRefresh >= 45 || (float) ($collection->freshness_score ?? 0) < 40); $actions = []; if ($isStale) { $actions[] = [ 'key' => 'refresh_summary', 'label' => 'Refresh the summary and description', 'reason' => 'A quick metadata pass helps older collections feel current again when they resurface in search or recommendations.', ]; if (! $collection->cover_artwork_id) { $actions[] = [ 'key' => 'set_cover', 'label' => 'Choose a stronger cover artwork', 'reason' => 'A defined cover is the fastest way to make a stale collection feel newly curated.', ]; } if (($context['artworks_count'] ?? 0) < 6) { $actions[] = [ 'key' => 'add_recent_artworks', 'label' => 'Add newer artworks to the set', 'reason' => 'The collection is still relatively small, so a few fresh pieces would meaningfully improve recency and depth.', ]; } else { $actions[] = [ 'key' => 'resequence_highlights', 'label' => 'Resequence the leading artworks', 'reason' => 'Reordering the strongest pieces can refresh the collection without changing its core theme.', ]; } if (($context['primary_theme'] ?? null) === null) { $actions[] = [ 'key' => 'tighten_theme', 'label' => 'Clarify the collection theme', 'reason' => 'The current artwork set does not emit a strong recurring theme, so a tighter selection would improve discovery quality.', ]; } } return [ 'stale' => $isStale, 'days_since_refresh' => $daysSinceRefresh, 'last_active_at' => $referenceAt?->toIso8601String(), 'actions' => $actions, 'rationale' => $isStale ? 'Detected from freshness, recent activity, and current collection depth.' : 'The collection has recent enough activity that a refresh is not urgent right now.', 'source' => 'heuristic-ai', ]; } public function suggestCampaignFit(Collection $collection, array $draft = []): array { $context = $this->buildContext($collection, $draft); $campaignSummary = $this->campaigns->campaignSummary($collection); $seasonKey = $this->draftString($collection, $draft, 'season_key') ?: (string) ($collection->season_key ?? ''); $eventKey = $this->draftString($collection, $draft, 'event_key') ?: (string) ($collection->event_key ?? ''); $eventLabel = $this->draftString($collection, $draft, 'event_label') ?: (string) ($collection->event_label ?? ''); $candidates = Collection::query() ->public() ->where('id', '!=', $collection->id) ->whereNotNull('campaign_key') ->with(['user:id,username,name']) ->orderByDesc('ranking_score') ->orderByDesc('saves_count') ->orderByDesc('updated_at') ->limit(36) ->get() ->map(function (Collection $candidate) use ($collection, $context, $seasonKey, $eventKey): array { $score = 0; $reasons = []; if ((string) $candidate->type === (string) $collection->type) { $score += 4; $reasons[] = 'Matches the same collection type.'; } if ($seasonKey !== '' && $candidate->season_key === $seasonKey) { $score += 5; $reasons[] = 'Shares the same seasonal window.'; } if ($eventKey !== '' && $candidate->event_key === $eventKey) { $score += 6; $reasons[] = 'Shares the same event context.'; } if ((int) $candidate->user_id === (int) $collection->user_id) { $score += 2; $reasons[] = 'Comes from the same curator account.'; } if ($context['top_category'] !== null && filled($candidate->theme_token) && Str::contains(mb_strtolower((string) $candidate->theme_token), mb_strtolower((string) $context['top_category']))) { $score += 2; $reasons[] = 'Theme token overlaps with the collection category.'; } if ((float) ($candidate->ranking_score ?? 0) >= 70) { $score += 1; $reasons[] = 'Campaign is represented by a strong-performing collection.'; } return [ 'score' => $score, 'candidate' => $candidate, 'reasons' => $reasons, ]; }) ->filter(fn (array $item): bool => $item['score'] > 0) ->sortByDesc(fn (array $item): string => sprintf('%08d-%s', $item['score'], optional($item['candidate']->updated_at)?->timestamp ?? 0)) ->groupBy(fn (array $item): string => (string) $item['candidate']->campaign_key) ->map(function ($items, string $campaignKey): array { $top = collect($items)->sortByDesc('score')->first(); $candidate = $top['candidate']; return [ 'campaign_key' => $campaignKey, 'campaign_label' => $candidate->campaign_label ?: Str::headline(str_replace(['_', '-'], ' ', $campaignKey)), 'score' => (int) $top['score'], 'reasons' => array_values(array_unique($top['reasons'])), 'sample_collection' => [ 'id' => (int) $candidate->id, 'title' => (string) $candidate->title, 'slug' => (string) $candidate->slug, ], ]; }) ->sortByDesc('score') ->take(4) ->values() ->all(); if ($candidates === [] && ($seasonKey !== '' || $eventKey !== '' || $eventLabel !== '')) { $fallbackKey = $collection->campaign_key ?: ($eventKey !== '' ? Str::slug($eventKey) : ($seasonKey !== '' ? $seasonKey . '-editorial' : Str::slug($eventLabel))); if ($fallbackKey !== '') { $candidates[] = [ 'campaign_key' => $fallbackKey, 'campaign_label' => $collection->campaign_label ?: ($eventLabel !== '' ? $eventLabel : Str::headline(str_replace(['_', '-'], ' ', $fallbackKey))), 'score' => 72, 'reasons' => array_values(array_filter([ $seasonKey !== '' ? 'Season metadata is already present and can anchor a campaign.' : null, $eventLabel !== '' ? 'Event labeling is already specific enough for campaign framing.' : null, 'Surface suggestions indicate this collection is promotable once reviewed.', ])), 'sample_collection' => null, ]; } } return [ 'current_context' => [ 'campaign_key' => $collection->campaign_key, 'campaign_label' => $collection->campaign_label, 'event_key' => $eventKey !== '' ? $eventKey : null, 'event_label' => $eventLabel !== '' ? $eventLabel : null, 'season_key' => $seasonKey !== '' ? $seasonKey : null, ], 'eligibility' => $campaignSummary['eligibility'] ?? [], 'recommended_surfaces' => $campaignSummary['recommended_surfaces'] ?? [], 'fits' => $candidates, 'rationale' => $candidates !== [] ? 'Suggested from existing campaign-aware collections with overlapping type, season, event, and performance context.' : 'No strong campaign fits were detected yet; tighten seasonal or event metadata first.', 'source' => 'heuristic-ai', ]; } public function suggestRelatedCollectionsToLink(Collection $collection, array $draft = []): array { $alreadyLinkedIds = $collection->manualRelatedCollections()->pluck('collections.id')->map(static fn ($id): int => (int) $id)->all(); $candidates = $this->recommendations->relatedPublicCollections($collection, 8) ->reject(fn (Collection $candidate): bool => in_array((int) $candidate->id, $alreadyLinkedIds, true)) ->take(6) ->values(); $suggestions = $candidates->map(function (Collection $candidate) use ($collection): array { $reasons = []; if ((string) $candidate->type === (string) $collection->type) { $reasons[] = 'Matches the same collection type.'; } if ((int) $candidate->user_id === (int) $collection->user_id) { $reasons[] = 'Owned by the same curator.'; } if (filled($collection->campaign_key) && $candidate->campaign_key === $collection->campaign_key) { $reasons[] = 'Shares the same campaign context.'; } if (filled($collection->event_key) && $candidate->event_key === $collection->event_key) { $reasons[] = 'Shares the same event context.'; } if (filled($collection->season_key) && $candidate->season_key === $collection->season_key) { $reasons[] = 'Shares the same seasonal framing.'; } if ($reasons === []) { $reasons[] = 'Ranks as a closely related public collection based on the platform recommendation model.'; } return [ 'id' => (int) $candidate->id, 'title' => (string) $candidate->title, 'slug' => (string) $candidate->slug, 'owner' => $candidate->displayOwnerName(), 'reasons' => $reasons, 'link_type' => (int) $candidate->user_id === (int) $collection->user_id ? 'same_curator' : 'discovery_adjacent', ]; })->all(); return [ 'linked_collection_ids' => $alreadyLinkedIds, 'suggestions' => $suggestions, 'rationale' => $suggestions !== [] ? 'Suggested from the related-public-collections model after excluding collections already linked manually.' : 'No additional related public collections were strong enough to suggest right now.', 'source' => 'heuristic-ai', ]; } public function explainSmartRules(Collection $collection, array $draft = []): array { $rules = is_array($draft['smart_rules_json'] ?? null) ? $draft['smart_rules_json'] : $collection->smart_rules_json; if (! is_array($rules)) { return [ 'explanation' => 'This collection is currently curated manually, so there are no smart rules to explain.', 'source' => 'heuristic-ai', ]; } $summary = $this->smartCollections->smartSummary($rules); $preview = $this->smartCollections->preview($collection->user, $rules, true, 6); return [ 'explanation' => sprintf('%s The current rule set matches %d artworks in the preview.', $summary, $preview->total()), 'rationale' => 'Translated from the active smart rule JSON into a human-readable curator summary.', 'source' => 'heuristic-ai', ]; } public function suggestSplitThemes(Collection $collection, array $draft = []): array { $grouping = $this->suggestGrouping($collection, $draft); $groups = collect($grouping['groups'] ?? [])->take(2)->values(); if ($groups->count() < 2) { return [ 'splits' => [], 'rationale' => 'There are not enough distinct recurring themes yet to justify splitting this collection into two focused sets.', 'source' => 'heuristic-ai', ]; } return [ 'splits' => $groups->map(function (array $group, int $index) use ($collection): array { $label = (string) ($group['label'] ?? ('Theme ' . ($index + 1))); return [ 'label' => $label, 'title' => trim(sprintf('%s: %s', $collection->title, $label)), 'artwork_ids' => array_values(array_map('intval', $group['artwork_ids'] ?? [])), 'count' => (int) ($group['count'] ?? 0), ]; })->all(), 'rationale' => 'Suggested from the two strongest artwork theme clusters so you can split the collection into clearer destination pages.', 'source' => 'heuristic-ai', ]; } public function suggestMergeIdea(Collection $collection, array $draft = []): array { $related = $this->suggestRelatedArtworks($collection, $draft); $context = $this->buildContext($collection, $draft); $artworks = collect($related['artworks'] ?? [])->take(3)->values(); if ($artworks->isEmpty()) { return [ 'idea' => null, 'rationale' => 'No closely related artworks were found that would strengthen a merged follow-up collection yet.', 'source' => 'heuristic-ai', ]; } $theme = $context['primary_theme'] ?: $context['top_category'] ?: 'Extended Showcase'; return [ 'idea' => [ 'title' => sprintf('%s Extended', Str::title((string) $theme)), 'summary' => sprintf('A follow-up collection idea that combines the current theme with %d closely related artworks from the same gallery.', $artworks->count()), 'related_artwork_ids' => $artworks->pluck('id')->map(static fn ($id) => (int) $id)->all(), ], 'rationale' => 'Suggested as a merge or spin-out concept using the current theme and the strongest related artworks not already attached.', 'source' => 'heuristic-ai', ]; } private function buildContext(Collection $collection, array $draft = []): array { $artworks = $this->candidateArtworks($collection, $draft, 36); $themes = $this->topThemes($artworks); $categories = $artworks ->map(fn (Artwork $artwork) => $artwork->categories->first()?->name) ->filter() ->countBy() ->sortDesc(); return [ 'type' => $this->draftString($collection, $draft, 'type') ?: $collection->type, 'allow_submissions' => array_key_exists('allow_submissions', $draft) ? (bool) $draft['allow_submissions'] : (bool) $collection->allow_submissions, 'artworks_count' => max(1, $artworks->count()), 'creator_count' => max(1, $artworks->pluck('user_id')->filter()->unique()->count()), 'primary_theme' => $themes->keys()->first(), 'theme_sentence' => $themes->keys()->take(2)->implode(' and '), 'top_category' => $categories->keys()->first(), 'event_label' => $this->draftString($collection, $draft, 'event_label') ?: (string) ($collection->event_label ?? ''), ]; } /** * @return SupportCollection */ private function candidateArtworks(Collection $collection, array $draft = [], int $limit = 24): SupportCollection { $mode = $this->draftString($collection, $draft, 'mode') ?: $collection->mode; $smartRules = is_array($draft['smart_rules_json'] ?? null) ? $draft['smart_rules_json'] : $collection->smart_rules_json; if ($mode === Collection::MODE_SMART && is_array($smartRules)) { return $this->smartCollections ->preview($collection->user, $smartRules, true, max(6, $limit)) ->getCollection() ->loadMissing(['tags', 'categories.contentType', 'stats']); } return $collection->artworks() ->with(['tags', 'categories.contentType', 'stats']) ->whereNull('artworks.deleted_at') ->select('artworks.*') ->limit(max(6, $limit)) ->get(); } /** * @return SupportCollection */ private function topThemes(SupportCollection $artworks): SupportCollection { return $artworks ->flatMap(function (Artwork $artwork): array { return $artwork->tags->map(function ($tag): array { return [ 'label' => (string) $tag->name, 'weight' => $tag->pivot?->source === 'ai' ? 1.25 : 1.0, ]; })->all(); }) ->groupBy('label') ->map(fn (SupportCollection $items) => (float) $items->sum('weight')) ->sortDesc() ->take(6); } private function coverScore(Artwork $artwork): float { $stats = $artwork->stats; $views = (int) ($stats?->views ?? $artwork->view_count ?? 0); $likes = (int) ($stats?->favorites ?? $artwork->favourite_count ?? 0); $downloads = (int) ($stats?->downloads ?? 0); $width = max(1, (int) ($artwork->width ?? 1)); $height = max(1, (int) ($artwork->height ?? 1)); $ratio = $width / $height; $ratioBonus = $ratio >= 1.1 && $ratio <= 1.8 ? 40 : 0; $freshness = $artwork->published_at ? max(0, 30 - min(30, $artwork->published_at->diffInDays(now()))) : 0; return ($likes * 8) + ($downloads * 5) + ($views * 0.05) + $ratioBonus + $freshness; } private function draftString(Collection $collection, array $draft, string $key): ?string { if (! array_key_exists($key, $draft)) { return $collection->{$key} !== null ? (string) $collection->{$key} : null; } $value = $draft[$key]; if ($value === null) { return null; } return trim((string) $value); } /** * @return array{key:string,severity:string,label:string,detail:string} */ private function metadataIssue(string $key, string $severity, string $label, string $detail): array { return [ 'key' => $key, 'severity' => $severity, 'label' => $label, 'detail' => $detail, ]; } private function staleReferenceAt(Collection $collection): ?CarbonInterface { return $collection->last_activity_at ?? $collection->updated_at ?? $collection->published_at; } }