Files
SkinbaseNova/app/Console/Commands/GenerateArtworkAiSuggestionsCommand.php
2026-04-18 17:02:56 +02:00

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, '...'));
}
}
}