185 lines
6.4 KiB
PHP
185 lines
6.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\Category;
|
|
use App\Services\Vision\ArtworkVisionImageUrl;
|
|
use App\Services\Vision\VectorGatewayClient;
|
|
use Illuminate\Console\Command;
|
|
|
|
final class IndexArtworkVectorsCommand extends Command
|
|
{
|
|
protected $signature = 'artworks:vectors-index
|
|
{--start-id=0 : Start from this artwork id (inclusive)}
|
|
{--after-id=0 : Resume after this artwork id}
|
|
{--batch=100 : Batch size per iteration}
|
|
{--limit=0 : Maximum artworks to process in this run}
|
|
{--public-only : Index only public, approved, published artworks}
|
|
{--dry-run : Preview requests without sending them}';
|
|
|
|
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
|
|
|
|
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
|
|
{
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
if (! $dryRun && ! $client->isConfigured()) {
|
|
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$startId = max(0, (int) $this->option('start-id'));
|
|
$afterId = max(0, (int) $this->option('after-id'));
|
|
$batch = max(1, min((int) $this->option('batch'), 1000));
|
|
$limit = max(0, (int) $this->option('limit'));
|
|
$publicOnly = (bool) $this->option('public-only');
|
|
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
|
|
|
|
$processed = 0;
|
|
$indexed = 0;
|
|
$skipped = 0;
|
|
$failed = 0;
|
|
$lastId = $afterId;
|
|
|
|
if ($startId > 0 && $afterId > 0) {
|
|
$this->warn(sprintf(
|
|
'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.',
|
|
$startId,
|
|
$afterId
|
|
));
|
|
}
|
|
|
|
$this->info(sprintf(
|
|
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s',
|
|
$startId,
|
|
$afterId,
|
|
$nextId,
|
|
$batch,
|
|
$limit > 0 ? (string) $limit : 'all',
|
|
$publicOnly ? 'yes' : 'no',
|
|
$dryRun ? 'yes' : 'no'
|
|
));
|
|
|
|
while (true) {
|
|
$remaining = $limit > 0 ? max(0, $limit - $processed) : $batch;
|
|
if ($limit > 0 && $remaining === 0) {
|
|
break;
|
|
}
|
|
|
|
$take = $limit > 0 ? min($batch, $remaining) : $batch;
|
|
|
|
$query = Artwork::query()
|
|
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
|
->where('id', '>=', $nextId)
|
|
->whereNotNull('hash')
|
|
->orderBy('id')
|
|
->limit($take);
|
|
|
|
if ($publicOnly) {
|
|
$query->public()->published();
|
|
}
|
|
|
|
$artworks = $query->get();
|
|
if ($artworks->isEmpty()) {
|
|
$this->line('No more artworks matched the current query window.');
|
|
break;
|
|
}
|
|
|
|
$this->line(sprintf(
|
|
'Fetched batch: count=%d first_id=%d last_id=%d',
|
|
$artworks->count(),
|
|
(int) $artworks->first()->id,
|
|
(int) $artworks->last()->id
|
|
));
|
|
|
|
foreach ($artworks as $artwork) {
|
|
$processed++;
|
|
$lastId = (int) $artwork->id;
|
|
$nextId = $lastId + 1;
|
|
|
|
$url = $imageUrl->fromArtwork($artwork);
|
|
if ($url === null) {
|
|
$skipped++;
|
|
$this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated.");
|
|
continue;
|
|
}
|
|
|
|
$metadata = $this->metadataForArtwork($artwork);
|
|
$this->line(sprintf(
|
|
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
|
|
(int) $artwork->id,
|
|
(string) ($artwork->hash ?? ''),
|
|
(string) ($artwork->thumb_ext ?? ''),
|
|
$url,
|
|
$this->json($metadata)
|
|
));
|
|
|
|
if ($dryRun) {
|
|
$indexed++;
|
|
$this->line(sprintf(
|
|
'[dry] artwork=%d indexed=%d/%d',
|
|
(int) $artwork->id,
|
|
$indexed,
|
|
$processed
|
|
));
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$client->upsertByUrl($url, (int) $artwork->id, $metadata);
|
|
$indexed++;
|
|
$this->info(sprintf(
|
|
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
|
|
(int) $artwork->id,
|
|
$processed,
|
|
$indexed,
|
|
$skipped,
|
|
$failed
|
|
));
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}");
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}");
|
|
|
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $payload
|
|
*/
|
|
private function json(array $payload): string
|
|
{
|
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
return is_string($json) ? $json : '{}';
|
|
}
|
|
|
|
/**
|
|
* @return array{content_type: string, category: string, user_id: string}
|
|
*/
|
|
private function metadataForArtwork(Artwork $artwork): array
|
|
{
|
|
$category = $this->primaryCategory($artwork);
|
|
|
|
return [
|
|
'content_type' => (string) ($category?->contentType?->name ?? ''),
|
|
'category' => (string) ($category?->name ?? ''),
|
|
'user_id' => (string) ($artwork->user_id ?? ''),
|
|
];
|
|
}
|
|
|
|
private function primaryCategory(Artwork $artwork): ?Category
|
|
{
|
|
/** @var Category|null $category */
|
|
$category = $artwork->categories->sortBy('sort_order')->first();
|
|
|
|
return $category;
|
|
}
|
|
}
|