option('dry-run'); if (! $dryRun && ! $vectors->isConfigured()) { $this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.'); return self::FAILURE; } $order = $this->normalizeOrder((string) $this->option('order')); $startId = max(0, (int) $this->option('start-id')); $afterId = max(0, (int) $this->option('after-id')); try { $afterUpdatedAt = $this->resolveAfterUpdatedAt($order, (string) $this->option('after-updated-at')); } catch (InvalidArgumentException $exception) { $this->error($exception->getMessage()); return self::INVALID; } $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); $embeddedOnly = (bool) $this->option('embedded-only'); $processed = 0; $indexed = 0; $skipped = 0; $failed = 0; $lastId = $afterId; $nextUpdatedAt = $afterUpdatedAt; 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 )); } if ($order === 'updated-desc' && ($startId > 0 || $afterId > 0) && $afterUpdatedAt === null) { $this->warn('The --start-id/--after-id options are legacy id-asc cursors. They are ignored unless --order=id-asc is used, or unless --after-updated-at is also provided for updated-desc mode.'); } $this->info(sprintf( 'Starting vector index: order=%s start_id=%d after_id=%d after_updated_at=%s next_id=%d batch=%d limit=%s embedded_only=%s public_only=%s dry_run=%s', $order, $startId, $afterId, $afterUpdatedAt?->toIso8601String() ?? 'none', $nextId, $batch, $limit > 0 ? (string) $limit : 'all', $embeddedOnly ? 'yes' : 'no', $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')]) ->whereNotNull('hash'); if ($order === 'updated-desc') { $query->orderByDesc('updated_at') ->orderByDesc('id') ->limit($take); if ($nextUpdatedAt !== null) { $query->where(function ($cursorQuery) use ($nextUpdatedAt, $afterId): void { $cursorQuery->where('updated_at', '<', $nextUpdatedAt) ->orWhere(function ($sameTimestampQuery) use ($nextUpdatedAt, $afterId): void { $sameTimestampQuery->where('updated_at', '=', $nextUpdatedAt) ->where('id', '<', $afterId); }); }); } } else { $query->where('id', '>=', $nextId) ->orderBy('id') ->limit($take); } if ($embeddedOnly) { $query->whereHas('embeddings'); } 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 first_updated_at=%s last_updated_at=%s', $artworks->count(), (int) $artworks->first()->id, (int) $artworks->last()->id, optional($artworks->first()->updated_at)->toIso8601String() ?? 'null', optional($artworks->last()->updated_at)->toIso8601String() ?? 'null' )); foreach ($artworks as $artwork) { $processed++; $lastId = (int) $artwork->id; if ($order === 'updated-desc') { $nextUpdatedAt = $artwork->updated_at !== null ? CarbonImmutable::instance($artwork->updated_at) : null; $afterId = $lastId; } else { $nextId = $lastId + 1; } try { $payload = $vectors->payloadForArtwork($artwork); } catch (\Throwable $e) { $skipped++; $this->warn("Skipped artwork {$artwork->id}: {$e->getMessage()}"); continue; } $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 ?? ''), $payload['url'], $this->json($payload['metadata']) )); if ($dryRun) { $indexed++; $this->line(sprintf( '[dry] artwork=%d indexed=%d/%d', (int) $artwork->id, $indexed, $processed )); continue; } try { $vectors->upsertArtwork($artwork); $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(sprintf( 'Vector index finished. processed=%d indexed=%d skipped=%d failed=%d last_id=%d next_id=%d next_updated_at=%s', $processed, $indexed, $skipped, $failed, $lastId, $nextId, $nextUpdatedAt?->toIso8601String() ?? 'none' )); return $failed > 0 ? self::FAILURE : self::SUCCESS; } private function normalizeOrder(string $order): string { $normalized = strtolower(trim($order)); return match ($normalized) { 'updated-desc', 'updated', 'latest', 'latest-updated' => 'updated-desc', 'id-asc', 'id', 'legacy' => 'id-asc', default => 'updated-desc', }; } private function resolveAfterUpdatedAt(string $order, string $afterUpdatedAt): ?CarbonImmutable { if ($order !== 'updated-desc') { return null; } $value = trim($afterUpdatedAt); if ($value === '') { return null; } try { return CarbonImmutable::parse($value); } catch (\Throwable) { throw new InvalidArgumentException(sprintf('Invalid --after-updated-at value [%s]. Use an ISO-8601 timestamp.', $afterUpdatedAt)); } } /** * @param array $payload */ private function json(array $payload): string { $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return is_string($json) ? $json : '{}'; } }