optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,788 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Str;
class CollectionAiCurationService
{
public function __construct(
private readonly SmartCollectionService $smartCollections,
private readonly CollectionCampaignService $campaigns,
private readonly CollectionRecommendationService $recommendations,
) {
}
public function suggestTitle(Collection $collection, array $draft = []): array
{
$context = $this->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 collections 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<int, Artwork>
*/
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<string, float>
*/
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;
}
}