192 lines
7.3 KiB
PHP
192 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkAiAssist;
|
|
use App\Services\Studio\StudioAiAssistService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class GenerateArtworkAiSuggestionsCommand extends Command
|
|
{
|
|
protected $signature = 'artworks:ai-suggest
|
|
{artwork_id? : Generate suggestions for a single artwork}
|
|
{--after-id=0 : Skip artworks with ID <= this value}
|
|
{--limit= : Stop after processing this many artworks}
|
|
{--chunk=50 : Database chunk size}
|
|
{--provider= : Override tag suggestion provider (lm_studio|together)}
|
|
{--force : Regenerate even when suggestions already exist}
|
|
{--queue : Queue generation instead of running inline}
|
|
{--skip-existing : Skip artworks that already have stored tag suggestions}';
|
|
|
|
protected $description = 'Generate and store studio AI suggestions for artworks, including 10-15 suggested tags from the md thumbnail';
|
|
|
|
public function handle(StudioAiAssistService $aiAssist): int
|
|
{
|
|
$artworkId = $this->argument('artwork_id');
|
|
$afterId = max(0, (int) $this->option('after-id'));
|
|
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
|
$chunk = max(1, min(200, (int) $this->option('chunk')));
|
|
$provider = $this->normalizeProviderOption($this->option('provider'));
|
|
$force = (bool) $this->option('force');
|
|
$queue = (bool) $this->option('queue');
|
|
$skipExisting = (bool) $this->option('skip-existing');
|
|
|
|
if ($provider === null && $this->option('provider') !== null) {
|
|
$this->error('Invalid provider. Supported values: lm_studio, together.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$processed = 0;
|
|
$generated = 0;
|
|
$skipped = 0;
|
|
$failed = 0;
|
|
|
|
$query = Artwork::query()
|
|
->with('artworkAiAssist')
|
|
->whereNull('deleted_at')
|
|
->whereNotNull('hash')
|
|
->orderBy('id');
|
|
|
|
if ($artworkId !== null) {
|
|
$query->where('id', (int) $artworkId);
|
|
} else {
|
|
$query->where('id', '>', $afterId);
|
|
}
|
|
|
|
$query->chunkById($chunk, function ($artworks) use (&$processed, &$generated, &$skipped, &$failed, $limit, $skipExisting, $force, $queue, $provider, $aiAssist) {
|
|
foreach ($artworks as $artwork) {
|
|
if ($limit !== null && $processed >= $limit) {
|
|
return false;
|
|
}
|
|
|
|
$processed++;
|
|
|
|
$assist = $artwork->artworkAiAssist;
|
|
$hasStoredTags = $assist instanceof ArtworkAiAssist
|
|
&& is_array($assist->tag_suggestions_json)
|
|
&& $assist->tag_suggestions_json !== [];
|
|
|
|
if ($skipExisting && $hasStoredTags && ! $force) {
|
|
$this->line("[#{$artwork->id}] skip existing suggestions");
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
$this->line("[#{$artwork->id}] {$artwork->title}");
|
|
|
|
try {
|
|
if ($queue) {
|
|
$result = $aiAssist->queueAnalysis($artwork, $force, 'tags', $provider);
|
|
$this->line(" queued ({$result->status})");
|
|
} else {
|
|
$result = $aiAssist->analyze($artwork, $force, 'tags', $provider);
|
|
$this->line(" {$result->status}");
|
|
$this->renderInlineResult($result);
|
|
}
|
|
|
|
if ($result->status === ArtworkAiAssist::STATUS_FAILED) {
|
|
$failed++;
|
|
continue;
|
|
}
|
|
|
|
$generated++;
|
|
} catch (\Throwable $exception) {
|
|
$this->error(' failed: ' . $exception->getMessage());
|
|
$failed++;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
$this->newLine();
|
|
$this->info("Done. processed={$processed} generated={$generated} skipped={$skipped} failed={$failed}");
|
|
|
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
private function normalizeProviderOption(mixed $value): ?string
|
|
{
|
|
if ($value === null || trim((string) $value) === '') {
|
|
return null;
|
|
}
|
|
|
|
return match (strtolower(trim((string) $value))) {
|
|
'lm_studio', 'lm-studio', 'local', 'home' => 'lm_studio',
|
|
'together', 'together_ai' => 'together',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function renderInlineResult(ArtworkAiAssist $assist): void
|
|
{
|
|
if ($assist->status === ArtworkAiAssist::STATUS_FAILED) {
|
|
if (is_string($assist->error_message) && $assist->error_message !== '') {
|
|
$this->error(' error: ' . $assist->error_message);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($assist->status !== ArtworkAiAssist::STATUS_READY) {
|
|
return;
|
|
}
|
|
|
|
$request = is_array($assist->raw_response_json) ? (array) ($assist->raw_response_json['request'] ?? []) : [];
|
|
$tagGeneration = is_array($assist->raw_response_json) ? (array) ($assist->raw_response_json['tag_generation'] ?? []) : [];
|
|
$categorySuggestions = is_array($assist->category_suggestions_json) ? $assist->category_suggestions_json : [];
|
|
|
|
$provider = $tagGeneration['provider'] ?? $request['provider'] ?? config('vision.tag_suggestions.provider');
|
|
if (is_string($provider) && $provider !== '') {
|
|
$this->line(' provider: ' . $provider);
|
|
}
|
|
|
|
$titles = collect((array) $assist->title_suggestions_json)
|
|
->pluck('text')
|
|
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
|
->take(3)
|
|
->values()
|
|
->all();
|
|
|
|
if ($titles !== []) {
|
|
$this->line(' titles: ' . implode(' | ', $titles));
|
|
}
|
|
|
|
$tags = collect((array) $assist->tag_suggestions_json)
|
|
->pluck('tag')
|
|
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
|
->take(12)
|
|
->values()
|
|
->all();
|
|
|
|
if ($tags !== []) {
|
|
$this->line(' tags: ' . implode(', ', $tags));
|
|
}
|
|
|
|
$contentType = $categorySuggestions['content_type']['value'] ?? null;
|
|
$category = $categorySuggestions['category']['value'] ?? null;
|
|
|
|
if (is_string($contentType) && $contentType !== '') {
|
|
$line = ' content type: ' . $contentType;
|
|
if (is_string($category) && $category !== '') {
|
|
$line .= ' | category: ' . $category;
|
|
}
|
|
$this->line($line);
|
|
} elseif (is_string($category) && $category !== '') {
|
|
$this->line(' category: ' . $category);
|
|
}
|
|
|
|
$description = collect((array) $assist->description_suggestions_json)
|
|
->pluck('text')
|
|
->first(fn (mixed $value): bool => is_string($value) && trim($value) !== '');
|
|
|
|
if (is_string($description) && $description !== '') {
|
|
$this->line(' description: ' . Str::limit($description, 140, '...'));
|
|
}
|
|
}
|
|
} |