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 $payload * @return array */ 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 */ 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> */ 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> $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 $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 */ 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 $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; } }