789 lines
33 KiB
PHP
789 lines
33 KiB
PHP
<?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 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<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;
|
||
}
|
||
}
|