Files
SkinbaseNova/app/Services/CollectionAiCurationService.php
2026-03-28 19:15:39 +01:00

789 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}