optimizations

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

View File

@@ -0,0 +1,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;
}
}