optimizations
This commit is contained in:
28
app/Services/Studio/StudioAiAssistEventService.php
Normal file
28
app/Services/Studio/StudioAiAssistEventService.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAiAssist;
|
||||
use App\Models\ArtworkAiAssistEvent;
|
||||
|
||||
final class StudioAiAssistEventService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function record(Artwork $artwork, string $eventType, array $meta = [], ?ArtworkAiAssist $assist = null): ArtworkAiAssistEvent
|
||||
{
|
||||
$assist ??= $artwork->artworkAiAssist;
|
||||
|
||||
return ArtworkAiAssistEvent::query()->create([
|
||||
'artwork_ai_assist_id' => $assist?->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'event_type' => $eventType,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
}
|
||||
514
app/Services/Studio/StudioAiAssistService.php
Normal file
514
app/Services/Studio/StudioAiAssistService.php
Normal file
@@ -0,0 +1,514 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAiAssist;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\TagNormalizer;
|
||||
use App\Services\TagService;
|
||||
use App\Services\Vision\AiArtworkVectorSearchService;
|
||||
use App\Services\Vision\VisionService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class StudioAiAssistService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VisionService $vision,
|
||||
private readonly StudioAiSuggestionBuilder $builder,
|
||||
private readonly StudioAiCategoryMapper $categoryMapper,
|
||||
private readonly AiArtworkVectorSearchService $similarity,
|
||||
private readonly TagService $tagService,
|
||||
private readonly TagNormalizer $tagNormalizer,
|
||||
private readonly StudioAiAssistEventService $eventService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function queueAnalysis(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
{
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$mode = $assist->mode ?: $this->builder->detectMode($artwork->loadMissing(['tags', 'categories.contentType']), []);
|
||||
|
||||
$assist->forceFill([
|
||||
'status' => ArtworkAiAssist::STATUS_QUEUED,
|
||||
'mode' => $mode,
|
||||
'error_message' => null,
|
||||
])->save();
|
||||
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_QUEUED])->saveQuietly();
|
||||
$meta = ['force' => $force, 'direct' => false, 'intent' => $intent];
|
||||
$this->appendAction($assist, 'analysis_requested', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
|
||||
|
||||
AnalyzeArtworkAiAssistJob::dispatch($artwork->id, $force)->afterCommit();
|
||||
|
||||
return $assist->fresh();
|
||||
}
|
||||
|
||||
public function analyzeDirect(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
{
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$meta = ['force' => $force, 'direct' => true, 'intent' => $intent];
|
||||
$this->appendAction($assist, 'analysis_requested', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
|
||||
|
||||
return $this->analyze($artwork, $force, $intent);
|
||||
}
|
||||
|
||||
public function analyze(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
{
|
||||
$artwork->loadMissing(['tags', 'categories.contentType', 'user']);
|
||||
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$assist->forceFill([
|
||||
'status' => ArtworkAiAssist::STATUS_PROCESSING,
|
||||
'error_message' => null,
|
||||
])->save();
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_PROCESSING])->saveQuietly();
|
||||
|
||||
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
|
||||
if ($hash === '') {
|
||||
return $this->failAssist($assist, $artwork, 'Artwork hash is missing, so AI analysis could not start.');
|
||||
}
|
||||
|
||||
if (! $this->vision->isEnabled()) {
|
||||
return $this->failAssist($assist, $artwork, 'Vision analysis is disabled in the current environment.');
|
||||
}
|
||||
|
||||
try {
|
||||
$visionResult = $this->vision->analyzeArtworkDetailed($artwork, $hash);
|
||||
$analysis = (array) ($visionResult['analysis'] ?? []);
|
||||
$visionDebug = (array) ($visionResult['debug'] ?? []);
|
||||
$this->vision->persistVisionMetadata(
|
||||
$artwork,
|
||||
(array) ($analysis['clip_tags'] ?? []),
|
||||
isset($analysis['blip_caption']) ? (string) $analysis['blip_caption'] : null,
|
||||
(array) ($analysis['yolo_objects'] ?? [])
|
||||
);
|
||||
|
||||
$mode = $this->builder->detectMode($artwork, $analysis);
|
||||
$signals = $this->builder->buildSignals($artwork, $analysis);
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categorySuggestions = $this->categoryMapper->map($signals, $primaryCategory instanceof Category ? $primaryCategory : null);
|
||||
|
||||
$titleSuggestions = $this->builder->buildTitleSuggestions($artwork, $analysis, $mode);
|
||||
$descriptionSuggestions = $this->builder->buildDescriptionSuggestions($artwork, $analysis, $mode);
|
||||
$tagSuggestions = $this->builder->buildTagSuggestions($artwork, $analysis, $mode);
|
||||
$similarCandidates = $this->buildSimilarCandidates($artwork);
|
||||
|
||||
$assist->forceFill([
|
||||
'status' => ArtworkAiAssist::STATUS_READY,
|
||||
'mode' => $mode,
|
||||
'title_suggestions_json' => $titleSuggestions,
|
||||
'description_suggestions_json' => $descriptionSuggestions,
|
||||
'tag_suggestions_json' => $tagSuggestions,
|
||||
'category_suggestions_json' => $categorySuggestions,
|
||||
'similar_candidates_json' => $similarCandidates,
|
||||
'raw_response_json' => [
|
||||
'request' => [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'hash' => $hash,
|
||||
'intent' => $intent,
|
||||
'force' => $force,
|
||||
'current_title' => (string) ($artwork->title ?? ''),
|
||||
'current_description' => (string) ($artwork->description ?? ''),
|
||||
'current_tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
],
|
||||
'vision_debug' => $visionDebug,
|
||||
'analysis' => $analysis,
|
||||
'generated_at' => \now()->toIso8601String(),
|
||||
'force' => $force,
|
||||
],
|
||||
'error_message' => null,
|
||||
'processed_at' => \now(),
|
||||
])->save();
|
||||
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_READY])->saveQuietly();
|
||||
$meta = [
|
||||
'force' => $force,
|
||||
'mode' => $mode,
|
||||
'intent' => $intent,
|
||||
'title_suggestion_count' => count($titleSuggestions),
|
||||
'description_suggestion_count' => count($descriptionSuggestions),
|
||||
'tag_suggestion_count' => count($tagSuggestions),
|
||||
'similar_candidate_count' => count($similarCandidates),
|
||||
];
|
||||
$this->appendAction($assist, 'analysis_completed', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_completed', $meta, $assist);
|
||||
|
||||
return $assist->fresh();
|
||||
} catch (\Throwable $exception) {
|
||||
return $this->failAssist($assist, $artwork, $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function applySuggestions(Artwork $artwork, array $payload): array
|
||||
{
|
||||
$artwork->loadMissing(['tags', 'categories.contentType']);
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$updated = false;
|
||||
$applied = [];
|
||||
|
||||
DB::transaction(function () use ($artwork, $payload, &$updated, &$applied): void {
|
||||
if (\filled($payload['title'] ?? null)) {
|
||||
$mode = (string) ($payload['title_mode'] ?? 'replace');
|
||||
$incoming = trim((string) $payload['title']);
|
||||
$artwork->title = $mode === 'insert' && $artwork->title
|
||||
? trim($artwork->title . ' ' . $incoming)
|
||||
: $incoming;
|
||||
$artwork->title_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'title';
|
||||
}
|
||||
|
||||
if (\filled($payload['description'] ?? null)) {
|
||||
$mode = (string) ($payload['description_mode'] ?? 'replace');
|
||||
$incoming = trim((string) $payload['description']);
|
||||
$artwork->description = $mode === 'append' && \filled($artwork->description)
|
||||
? trim((string) $artwork->description . "\n\n" . $incoming)
|
||||
: $incoming;
|
||||
$artwork->description_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'description';
|
||||
}
|
||||
|
||||
if (array_key_exists('tags', $payload) && is_array($payload['tags'])) {
|
||||
$tagMode = (string) ($payload['tag_mode'] ?? 'add');
|
||||
$tags = array_values(array_filter(array_map(fn (mixed $tag): string => $this->tagNormalizer->normalize((string) $tag), $payload['tags'])));
|
||||
|
||||
if ($tagMode === 'replace') {
|
||||
$currentTags = $artwork->tags->pluck('slug')->all();
|
||||
if ($currentTags !== []) {
|
||||
$this->tagService->detachTags($artwork, $currentTags);
|
||||
}
|
||||
$this->tagService->attachAiTags($artwork, array_map(fn (string $tag): array => ['tag' => $tag], $tags));
|
||||
} elseif ($tagMode === 'remove') {
|
||||
$this->tagService->detachTags($artwork, $tags);
|
||||
} else {
|
||||
$this->tagService->attachAiTags($artwork, array_map(fn (string $tag): array => ['tag' => $tag], $tags));
|
||||
}
|
||||
|
||||
$artwork->tags_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'tags';
|
||||
}
|
||||
|
||||
$categoryId = $this->resolveCategoryId($payload);
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
$artwork->category_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'category';
|
||||
|
||||
if (isset($payload['content_type_id']) && $payload['content_type_id'] !== null) {
|
||||
$applied[] = 'content_type';
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
$artwork->save();
|
||||
$artwork->load(['tags', 'categories.contentType']);
|
||||
}
|
||||
});
|
||||
|
||||
if (! empty($payload['similar_actions']) && is_array($payload['similar_actions'])) {
|
||||
$this->applySimilarActions($assist, $payload['similar_actions']);
|
||||
$applied[] = 'similar_candidates';
|
||||
$this->eventService->record($artwork, 'similar_candidates_updated', [
|
||||
'count' => count($payload['similar_actions']),
|
||||
'states' => array_values(array_unique(array_map(
|
||||
static fn (array $action): string => (string) ($action['state'] ?? 'unknown'),
|
||||
array_filter($payload['similar_actions'], 'is_array')
|
||||
))),
|
||||
], $assist);
|
||||
|
||||
foreach (array_filter($payload['similar_actions'], 'is_array') as $action) {
|
||||
$state = (string) ($action['state'] ?? 'unknown');
|
||||
$candidateId = (int) ($action['artwork_id'] ?? 0);
|
||||
if ($candidateId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventType = match ($state) {
|
||||
'ignored' => 'duplicate_candidate_ignored',
|
||||
'reviewed' => 'duplicate_candidate_reviewed',
|
||||
default => 'duplicate_candidate_updated',
|
||||
};
|
||||
|
||||
$this->eventService->record($artwork, $eventType, [
|
||||
'candidate_artwork_id' => $candidateId,
|
||||
'state' => $state,
|
||||
], $assist);
|
||||
}
|
||||
}
|
||||
|
||||
if ($applied !== []) {
|
||||
$fields = array_values(array_unique($applied));
|
||||
$meta = ['fields' => $fields];
|
||||
$this->appendAction($assist, 'suggestions_applied', $meta);
|
||||
$this->eventService->record($artwork, 'suggestions_applied', $meta, $assist);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$eventType = match ($field) {
|
||||
'title' => 'title_suggestion_applied',
|
||||
'description' => 'description_suggestion_applied',
|
||||
'tags' => 'tags_suggestion_applied',
|
||||
'content_type' => 'content_type_suggestion_applied',
|
||||
'category' => 'category_suggestion_applied',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($eventType === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->eventService->record($artwork, $eventType, [
|
||||
'fields' => $fields,
|
||||
], $assist);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->payloadFor($artwork->fresh(['tags', 'categories.contentType', 'artworkAiAssist']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function payloadFor(Artwork $artwork): array
|
||||
{
|
||||
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType']);
|
||||
$assist = $artwork->artworkAiAssist;
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
|
||||
if (! $assist) {
|
||||
return [
|
||||
'status' => 'not_analyzed',
|
||||
'mode' => null,
|
||||
'title_suggestions' => [],
|
||||
'description_suggestions' => [],
|
||||
'tag_suggestions' => [],
|
||||
'content_type' => null,
|
||||
'category' => null,
|
||||
'similar_candidates' => [],
|
||||
'processed_at' => null,
|
||||
'error_message' => null,
|
||||
'current' => $this->currentPayload($artwork, $primaryCategory),
|
||||
];
|
||||
}
|
||||
|
||||
$categorySuggestions = is_array($assist->category_suggestions_json) ? $assist->category_suggestions_json : [];
|
||||
|
||||
return [
|
||||
'status' => (string) $assist->status,
|
||||
'mode' => $assist->mode,
|
||||
'title_suggestions' => array_values((array) ($assist->title_suggestions_json ?? [])),
|
||||
'description_suggestions' => array_values((array) ($assist->description_suggestions_json ?? [])),
|
||||
'tag_suggestions' => array_values((array) ($assist->tag_suggestions_json ?? [])),
|
||||
'content_type' => $categorySuggestions['content_type'] ?? null,
|
||||
'category' => $categorySuggestions['category'] ?? null,
|
||||
'similar_candidates' => array_values((array) ($assist->similar_candidates_json ?? [])),
|
||||
'processed_at' => optional($assist->processed_at)?->toIso8601String(),
|
||||
'error_message' => $assist->error_message,
|
||||
'current' => $this->currentPayload($artwork, $primaryCategory),
|
||||
'debug' => is_array($assist->raw_response_json) ? [
|
||||
'request' => $assist->raw_response_json['request'] ?? null,
|
||||
'vision_debug' => $assist->raw_response_json['vision_debug'] ?? null,
|
||||
'analysis' => $assist->raw_response_json['analysis'] ?? null,
|
||||
'generated_at' => $assist->raw_response_json['generated_at'] ?? null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function assistRecord(Artwork $artwork): ArtworkAiAssist
|
||||
{
|
||||
return ArtworkAiAssist::query()->firstOrCreate(
|
||||
['artwork_id' => (int) $artwork->id],
|
||||
['status' => ArtworkAiAssist::STATUS_PENDING]
|
||||
);
|
||||
}
|
||||
|
||||
private function failAssist(ArtworkAiAssist $assist, Artwork $artwork, string $message): ArtworkAiAssist
|
||||
{
|
||||
$assist->forceFill([
|
||||
'status' => ArtworkAiAssist::STATUS_FAILED,
|
||||
'error_message' => Str::limit($message, 1500, ''),
|
||||
])->save();
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_FAILED])->saveQuietly();
|
||||
$meta = ['message' => Str::limit($message, 240, '')];
|
||||
$this->appendAction($assist, 'analysis_failed', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_failed', $meta, $assist);
|
||||
|
||||
return $assist->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildSimilarCandidates(Artwork $artwork): array
|
||||
{
|
||||
$exactMatches = Artwork::query()
|
||||
->with('user:id,name')
|
||||
->where('id', '!=', $artwork->id)
|
||||
->whereNotNull('hash')
|
||||
->where('hash', $artwork->hash)
|
||||
->latest('id')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn (Artwork $candidate): array => [
|
||||
'artwork_id' => (int) $candidate->id,
|
||||
'title' => (string) $candidate->title,
|
||||
'thumbnail_url' => $candidate->thumbUrl('md'),
|
||||
'match_type' => 'exact_hash',
|
||||
'score' => 1.0,
|
||||
'owner' => $candidate->user?->name,
|
||||
'url' => '/art/' . $candidate->id . '/' . $candidate->slug,
|
||||
'review_state' => null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$vectorMatches = [];
|
||||
if ($this->similarity->isConfigured()) {
|
||||
try {
|
||||
foreach ($this->similarity->similarToArtwork($artwork, 5) as $candidate) {
|
||||
$vectorMatches[] = [
|
||||
'artwork_id' => (int) ($candidate['id'] ?? 0),
|
||||
'title' => (string) ($candidate['title'] ?? ''),
|
||||
'thumbnail_url' => $candidate['thumb'] ?? null,
|
||||
'match_type' => (string) ($candidate['source'] ?? 'vector_gateway'),
|
||||
'score' => (float) ($candidate['score'] ?? 0.0),
|
||||
'owner' => $candidate['author'] ?? null,
|
||||
'url' => $candidate['url'] ?? null,
|
||||
'review_state' => null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Studio AI assist similar lookup failed', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return collect($exactMatches)
|
||||
->merge($vectorMatches)
|
||||
->unique('artwork_id')
|
||||
->take(5)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $similarActions
|
||||
*/
|
||||
private function applySimilarActions(ArtworkAiAssist $assist, array $similarActions): void
|
||||
{
|
||||
$current = collect((array) ($assist->similar_candidates_json ?? []));
|
||||
if ($current->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexedActions = collect($similarActions)
|
||||
->filter(fn (mixed $item): bool => is_array($item) && isset($item['artwork_id'], $item['state']))
|
||||
->keyBy(fn (array $item): int => (int) $item['artwork_id']);
|
||||
|
||||
$updated = $current->map(function (array $candidate) use ($indexedActions): array {
|
||||
$action = $indexedActions->get((int) ($candidate['artwork_id'] ?? 0));
|
||||
if (! $action) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$candidate['review_state'] = (string) $action['state'];
|
||||
return $candidate;
|
||||
})->values()->all();
|
||||
|
||||
$assist->forceFill(['similar_candidates_json' => $updated])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function appendAction(ArtworkAiAssist $assist, string $type, array $meta = []): void
|
||||
{
|
||||
$log = collect((array) ($assist->action_log_json ?? []))
|
||||
->take(-24)
|
||||
->push([
|
||||
'type' => $type,
|
||||
'meta' => $meta,
|
||||
'created_at' => \now()->toIso8601String(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$assist->forceFill(['action_log_json' => $log])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function currentPayload(Artwork $artwork, mixed $primaryCategory): array
|
||||
{
|
||||
return [
|
||||
'title' => (string) $artwork->title,
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'sources' => [
|
||||
'title' => $artwork->title_source ?: 'manual',
|
||||
'description' => $artwork->description_source ?: 'manual',
|
||||
'tags' => $artwork->tags_source ?: 'manual',
|
||||
'category' => $artwork->category_source ?: 'manual',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function resolveCategoryId(array $payload): ?int
|
||||
{
|
||||
if (isset($payload['category_id']) && $payload['category_id'] !== null) {
|
||||
return (int) $payload['category_id'];
|
||||
}
|
||||
|
||||
if (! isset($payload['content_type_id']) || $payload['content_type_id'] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contentType = ContentType::query()->find((int) $payload['content_type_id']);
|
||||
if (! $contentType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$category = $contentType->rootCategories()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->first();
|
||||
|
||||
if (! $category) {
|
||||
$category = Category::query()
|
||||
->where('content_type_id', $contentType->id)
|
||||
->where('is_active', true)
|
||||
->orderByRaw('CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->first();
|
||||
}
|
||||
|
||||
return $category?->id;
|
||||
}
|
||||
}
|
||||
250
app/Services/Studio/StudioAiCategoryMapper.php
Normal file
250
app/Services/Studio/StudioAiCategoryMapper.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class StudioAiCategoryMapper
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $signals
|
||||
* @return array{content_type: array<string, mixed>|null, category: array<string, mixed>|null}
|
||||
*/
|
||||
public function map(array $signals, ?Category $currentCategory = null): array
|
||||
{
|
||||
$tokens = $this->tokenize($signals);
|
||||
$haystack = ' ' . implode(' ', $tokens) . ' ';
|
||||
|
||||
$contentTypes = ContentType::query()->with(['rootCategories.children'])->get();
|
||||
$contentTypeScores = $contentTypes
|
||||
->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack))
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->sortByDesc('score')
|
||||
->values();
|
||||
|
||||
$selectedContentTypeRow = $contentTypeScores->first();
|
||||
$selectedContentType = is_array($selectedContentTypeRow) ? ($selectedContentTypeRow['model'] ?? null) : null;
|
||||
if (! $selectedContentType) {
|
||||
$selectedContentType = $currentCategory?->contentType;
|
||||
}
|
||||
|
||||
$categoryScores = $this->scoreCategories($contentTypes, $tokens, $haystack, $selectedContentType?->id);
|
||||
$selectedCategoryRow = $categoryScores->first();
|
||||
$selectedCategory = is_array($selectedCategoryRow) ? ($selectedCategoryRow['model'] ?? null) : null;
|
||||
if (! $selectedCategory) {
|
||||
$selectedCategory = $currentCategory;
|
||||
}
|
||||
|
||||
return [
|
||||
'content_type' => $selectedContentType ? $this->serializeContentType(
|
||||
$selectedContentType,
|
||||
$this->confidenceForModel($contentTypeScores, $selectedContentType->id)
|
||||
) : null,
|
||||
'category' => $selectedCategory ? $this->serializeCategory(
|
||||
$selectedCategory,
|
||||
$this->confidenceForModel($categoryScores, $selectedCategory->id),
|
||||
$categoryScores
|
||||
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id)
|
||||
->take(3)
|
||||
->map(fn (array $row): array => $this->serializeCategory($row['model'], $row['confidence']))
|
||||
->all()
|
||||
) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tokens
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function scoreContentType(ContentType $contentType, array $tokens, string $haystack): array
|
||||
{
|
||||
$keywords = array_merge([$contentType->slug, $contentType->name], $this->keywordsForContentType($contentType->slug));
|
||||
$score = $this->keywordScore($keywords, $tokens, $haystack);
|
||||
|
||||
return [
|
||||
'model' => $contentType,
|
||||
'score' => $score,
|
||||
'confidence' => $this->normalizeConfidence($score),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{model: Category, score: int, confidence: float}>
|
||||
*/
|
||||
private function scoreCategories(Collection $contentTypes, array $tokens, string $haystack, ?int $contentTypeId = null): Collection
|
||||
{
|
||||
return $contentTypes
|
||||
->filter(fn (ContentType $contentType): bool => $contentTypeId === null || (int) $contentType->id === (int) $contentTypeId)
|
||||
->flatMap(function (ContentType $contentType) use ($tokens, $haystack): array {
|
||||
$categories = [];
|
||||
|
||||
foreach ($contentType->rootCategories as $rootCategory) {
|
||||
$categories[] = $rootCategory;
|
||||
foreach ($rootCategory->children as $childCategory) {
|
||||
$categories[] = $childCategory;
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(function (Category $category) use ($tokens, $haystack): array {
|
||||
$keywords = array_filter([
|
||||
$category->slug,
|
||||
$category->name,
|
||||
$category->parent?->slug,
|
||||
$category->parent?->name,
|
||||
]);
|
||||
$score = $this->keywordScore($keywords, $tokens, $haystack);
|
||||
|
||||
return [
|
||||
'model' => $category,
|
||||
'score' => $score,
|
||||
'confidence' => $this->normalizeConfidence($score),
|
||||
];
|
||||
}, $categories);
|
||||
})
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->sortByDesc('score')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $signals
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function tokenize(array $signals): array
|
||||
{
|
||||
return Collection::make($signals)
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->flatMap(function (string $value): array {
|
||||
$normalized = Str::of($value)
|
||||
->lower()
|
||||
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
|
||||
->replace('-', ' ')
|
||||
->squish()
|
||||
->value();
|
||||
|
||||
return $normalized === '' ? [] : explode(' ', $normalized);
|
||||
})
|
||||
->filter(fn (string $value): bool => $value !== '' && strlen($value) >= 3)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $keywords
|
||||
* @param array<int, string> $tokens
|
||||
*/
|
||||
private function keywordScore(array $keywords, array $tokens, string $haystack): int
|
||||
{
|
||||
$score = 0;
|
||||
$tokenVariants = Collection::make($tokens)
|
||||
->flatMap(fn (string $token): array => array_unique([$token, $this->singularize($token), $this->pluralize($token)]))
|
||||
->filter(fn (string $token): bool => $token !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$normalized = Str::of((string) $keyword)
|
||||
->lower()
|
||||
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
|
||||
->replace('-', ' ')
|
||||
->squish()
|
||||
->value();
|
||||
|
||||
if ($normalized === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($haystack, ' ' . $normalized . ' ')) {
|
||||
$score += str_contains($normalized, ' ') ? 4 : 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (explode(' ', $normalized) as $part) {
|
||||
if ($part !== '' && in_array($part, $tokenVariants, true)) {
|
||||
$score += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function keywordsForContentType(string $slug): array
|
||||
{
|
||||
return match ($slug) {
|
||||
'skins' => ['skin', 'winamp', 'theme', 'interface skin'],
|
||||
'wallpapers' => ['wallpaper', 'background', 'desktop', 'lockscreen'],
|
||||
'photography' => ['photo', 'photograph', 'photography', 'portrait', 'macro', 'nature', 'camera'],
|
||||
'members' => ['profile', 'avatar', 'member'],
|
||||
default => ['artwork', 'illustration', 'digital art', 'painting', 'concept art', 'screenshot', 'ui', 'game'],
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeConfidence(int $score): float
|
||||
{
|
||||
if ($score <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return min(0.99, round(0.45 + ($score * 0.08), 2));
|
||||
}
|
||||
|
||||
private function singularize(string $value): string
|
||||
{
|
||||
return str_ends_with($value, 's') ? rtrim($value, 's') : $value;
|
||||
}
|
||||
|
||||
private function pluralize(string $value): string
|
||||
{
|
||||
return str_ends_with($value, 's') ? $value : $value . 's';
|
||||
}
|
||||
|
||||
private function confidenceForModel(Collection $scores, int $modelId): float
|
||||
{
|
||||
$row = $scores->first(fn (array $item): bool => (int) $item['model']->id === $modelId);
|
||||
|
||||
return (float) ($row['confidence'] ?? 0.55);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeContentType(ContentType $contentType, float $confidence): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $contentType->id,
|
||||
'value' => (string) $contentType->slug,
|
||||
'label' => (string) $contentType->name,
|
||||
'confidence' => $confidence,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $alternatives
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeCategory(Category $category, float $confidence, array $alternatives = []): array
|
||||
{
|
||||
$rootCategory = $category->parent ?: $category;
|
||||
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'value' => (string) $category->slug,
|
||||
'label' => (string) $category->name,
|
||||
'confidence' => $confidence,
|
||||
'content_type_id' => (int) $category->content_type_id,
|
||||
'root_category_id' => (int) $rootCategory->id,
|
||||
'sub_category_id' => $category->parent_id ? (int) $category->id : null,
|
||||
'alternatives' => array_values($alternatives),
|
||||
];
|
||||
}
|
||||
}
|
||||
247
app/Services/Studio/StudioAiSuggestionBuilder.php
Normal file
247
app/Services/Studio/StudioAiSuggestionBuilder.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TagNormalizer;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class StudioAiSuggestionBuilder
|
||||
{
|
||||
private const GENERIC_TAGS = [
|
||||
'image', 'picture', 'artwork', 'art', 'design', 'visual', 'graphic', 'photo of', 'image of',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly TagNormalizer $normalizer,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
*/
|
||||
public function detectMode(Artwork $artwork, array $analysis): string
|
||||
{
|
||||
$signals = Collection::make([
|
||||
$artwork->title,
|
||||
$artwork->description,
|
||||
$artwork->file_name,
|
||||
$analysis['blip_caption'] ?? null,
|
||||
...Collection::make((array) ($analysis['clip_tags'] ?? []))->pluck('tag')->all(),
|
||||
...Collection::make((array) ($analysis['yolo_objects'] ?? []))->pluck('tag')->all(),
|
||||
])->filter()->implode(' ');
|
||||
|
||||
return preg_match('/\b(screenshot|screen|ui|interface|menu|hud|dashboard|settings|launcher|app|game)\b/i', $signals) === 1
|
||||
? 'screenshot'
|
||||
: 'artwork';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, array{text: string, confidence: float}>
|
||||
*/
|
||||
public function buildTitleSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
||||
{
|
||||
$caption = $this->cleanCaption((string) ($analysis['blip_caption'] ?? ''));
|
||||
$topTerms = $this->topTerms($analysis, 4);
|
||||
$titleSeeds = Collection::make([
|
||||
$this->titleCase($caption),
|
||||
$this->titleCase($this->limitWords($caption, 6)),
|
||||
$mode === 'screenshot'
|
||||
? $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Screen'))
|
||||
: $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 3)))),
|
||||
$mode === 'screenshot'
|
||||
? $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Interface'))
|
||||
: $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Study')),
|
||||
$mode === 'screenshot'
|
||||
? $this->titleCase(trim(($topTerms[0] ?? 'Interface') . ' View'))
|
||||
: $this->titleCase(trim(($topTerms[0] ?? 'Artwork') . ' Composition')),
|
||||
])
|
||||
->filter(fn (?string $value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(fn (string $value): string => Str::limit(trim($value), 80, ''))
|
||||
->unique()
|
||||
->take(5)
|
||||
->values();
|
||||
|
||||
return $titleSeeds->map(fn (string $text, int $index): array => [
|
||||
'text' => $text,
|
||||
'confidence' => round(max(0.55, 0.92 - ($index * 0.07)), 2),
|
||||
])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, array{variant: string, text: string, confidence: float}>
|
||||
*/
|
||||
public function buildDescriptionSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
||||
{
|
||||
$caption = $this->cleanCaption((string) ($analysis['blip_caption'] ?? ''));
|
||||
$terms = $this->topTerms($analysis, 5);
|
||||
$termSentence = $terms !== [] ? implode(', ', array_slice($terms, 0, 3)) : null;
|
||||
|
||||
$short = $caption !== ''
|
||||
? Str::ucfirst(Str::finish($caption, '.'))
|
||||
: ($mode === 'screenshot'
|
||||
? 'A clear screenshot with interface-focused visual details.'
|
||||
: 'A visually focused artwork with clear subject and style cues.');
|
||||
|
||||
$normal = $short;
|
||||
if ($termSentence) {
|
||||
$normal .= ' It highlights ' . $termSentence . ' without overclaiming details.';
|
||||
}
|
||||
|
||||
$seo = $artwork->title !== ''
|
||||
? $artwork->title . ' is presented with ' . ($termSentence ?: ($mode === 'screenshot' ? 'useful interface context' : 'strong visual detail')) . ' for discovery on Skinbase.'
|
||||
: $normal;
|
||||
|
||||
return [
|
||||
['variant' => 'short', 'text' => Str::limit($short, 180, ''), 'confidence' => 0.89],
|
||||
['variant' => 'normal', 'text' => Str::limit($normal, 280, ''), 'confidence' => 0.85],
|
||||
['variant' => 'seo', 'text' => Str::limit($seo, 220, ''), 'confidence' => 0.8],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, array{tag: string, confidence: float|null}>
|
||||
*/
|
||||
public function buildTagSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
||||
{
|
||||
$rawTags = Collection::make()
|
||||
->merge((array) ($analysis['clip_tags'] ?? []))
|
||||
->merge((array) ($analysis['yolo_objects'] ?? []))
|
||||
->map(function (mixed $item): array {
|
||||
if (is_string($item)) {
|
||||
return ['tag' => $item, 'confidence' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'tag' => (string) ($item['tag'] ?? ''),
|
||||
'confidence' => isset($item['confidence']) && is_numeric($item['confidence']) ? (float) $item['confidence'] : null,
|
||||
];
|
||||
});
|
||||
|
||||
foreach ($this->extractCaptionTags((string) ($analysis['blip_caption'] ?? '')) as $captionTag) {
|
||||
$rawTags->push(['tag' => $captionTag, 'confidence' => 0.62]);
|
||||
}
|
||||
|
||||
if ($mode === 'screenshot') {
|
||||
foreach (['screenshot', 'ui'] as $fallbackTag) {
|
||||
$rawTags->push(['tag' => $fallbackTag, 'confidence' => 0.58]);
|
||||
}
|
||||
}
|
||||
|
||||
$suggestions = $rawTags
|
||||
->map(function (array $row): ?array {
|
||||
$tag = $this->normalizer->normalize((string) ($row['tag'] ?? ''));
|
||||
if ($tag === '' || in_array($tag, self::GENERIC_TAGS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'tag' => $tag,
|
||||
'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? round((float) $row['confidence'], 2) : null,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->unique('tag')
|
||||
->sortByDesc(fn (array $row): float => (float) ($row['confidence'] ?? 0.0))
|
||||
->take(15)
|
||||
->values();
|
||||
|
||||
return $suggestions->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function buildSignals(Artwork $artwork, array $analysis): array
|
||||
{
|
||||
return Collection::make([
|
||||
$artwork->title,
|
||||
$artwork->description,
|
||||
$artwork->file_name,
|
||||
$analysis['blip_caption'] ?? null,
|
||||
...Collection::make((array) ($analysis['clip_tags'] ?? []))->pluck('tag')->all(),
|
||||
...Collection::make((array) ($analysis['yolo_objects'] ?? []))->pluck('tag')->all(),
|
||||
...$artwork->tags->pluck('slug')->all(),
|
||||
])
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function topTerms(array $analysis, int $limit): array
|
||||
{
|
||||
return Collection::make()
|
||||
->merge((array) ($analysis['clip_tags'] ?? []))
|
||||
->merge((array) ($analysis['yolo_objects'] ?? []))
|
||||
->map(fn (mixed $item): string => trim((string) (is_array($item) ? ($item['tag'] ?? '') : $item)))
|
||||
->filter()
|
||||
->flatMap(fn (string $term): array => preg_split('/\s+/', Str::of($term)->replace('-', ' ')->value()) ?: [])
|
||||
->filter(fn (string $term): bool => strlen($term) >= 3)
|
||||
->map(fn (string $term): string => Str::title($term))
|
||||
->unique()
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractCaptionTags(string $caption): array
|
||||
{
|
||||
$clean = Str::of($caption)
|
||||
->lower()
|
||||
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
|
||||
->replace('-', ' ')
|
||||
->squish()
|
||||
->value();
|
||||
|
||||
if ($clean === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = Collection::make(explode(' ', $clean))
|
||||
->filter(fn (string $value): bool => strlen($value) >= 3)
|
||||
->reject(fn (string $value): bool => in_array($value, ['with', 'from', 'into', 'over', 'under', 'image', 'picture', 'artwork'], true))
|
||||
->values();
|
||||
|
||||
$bigrams = [];
|
||||
for ($index = 0; $index < $tokens->count() - 1; $index++) {
|
||||
$bigrams[] = $tokens[$index] . ' ' . $tokens[$index + 1];
|
||||
}
|
||||
|
||||
return $tokens->merge($bigrams)->unique()->take(10)->all();
|
||||
}
|
||||
|
||||
private function cleanCaption(string $caption): string
|
||||
{
|
||||
return Str::of($caption)
|
||||
->replaceMatches('/^(a|an|the)\s+/i', '')
|
||||
->replaceMatches('/^(image|photo|screenshot) of\s+/i', '')
|
||||
->squish()
|
||||
->value();
|
||||
}
|
||||
|
||||
private function titleCase(string $value): string
|
||||
{
|
||||
return Str::title(trim($value));
|
||||
}
|
||||
|
||||
private function limitWords(string $value, int $maxWords): string
|
||||
{
|
||||
$words = preg_split('/\s+/', trim($value)) ?: [];
|
||||
|
||||
return implode(' ', array_slice($words, 0, $maxWords));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user