false, 'reason' => '', 'embedded' => 0, 'unchanged' => 0, 'deleted' => 0, 'failed' => 0, 'copied' => 0, 'requeued' => 0, 'message' => '', 'stopped' => '']; if (!Settings::enabled()) { $out['reason'] = __('Embeddings are switched off.', 'aipcf-ai-provider-for-cloudflare'); return $out; } $state = State::get(); if ($state['paused'] !== '' && !$manual) { /* translators: %s: why Embeddings are paused */ $out['reason'] = sprintf(__('Paused: %s', 'aipcf-ai-provider-for-cloudflare'), $state['paused']); return $out; } if (!Lock::acquire(self::LOCK)) { $out['reason'] = self::busyReason(); return $out; } $out['ran'] = true; $deadline = microtime(true) + max(1, $timeBudget); // The scheduled run is the plugin's work in the activity log, even under WP-CLI's cron runner. \WordPress\CloudflareWorkersAiProvider\Activity\ActivityLog::background(!$manual); try { if ($manual && $state['paused'] !== '') { State::resume(); } IndexTable::install(); $out = self::run($out, $deadline); /** * After a run, with the lock still held: features that work from the index * (related posts) use the time left. * * @param array $out The run's result. * @param float $deadline microtime(true) the run must finish by. */ do_action('aipcf_embed_tick_done', $out, $deadline); } finally { State::update(['last_run' => time(), 'last_message' => $out['message'] !== '' ? $out['message'] : self::summary($out)]); \WordPress\CloudflareWorkersAiProvider\Activity\ActivityLog::background(false); Lock::release(self::LOCK); } return $out; } /** * The reason tick() gives when another batch holds the lock. */ public static function busyReason(): string { return __('Another batch is running.', 'aipcf-ai-provider-for-cloudflare'); } /** * The message while the vector store prepares a new index (Embeddings wait for it). */ public static function preparingMessage(string $index): string { /* translators: %s: index name */ return sprintf(__('The vector store is preparing the index %s; embedding starts once it is ready (usually a few minutes).', 'aipcf-ai-provider-for-cloudflare'), $index); } /** * @param array{ran: bool, reason: string, embedded: int, unchanged: int, deleted: int, failed: int, message: string, stopped: string} $out * @return array{ran: bool, reason: string, embedded: int, unchanged: int, deleted: int, failed: int, copied: int, requeued: int, message: string, stopped: string} */ private static function run(array $out, float $deadline): array { // Database only, so it runs even while the index is still being prepared. self::reconcileStep($deadline); $store = StoreFactory::current(); if (is_wp_error($store)) { return self::stop($out, $store->get_error_message(), true); } $ready = self::ensureReady($store); if ($ready !== true) { if (!$ready[1]) { // Index still being prepared: look again in a few minutes, not in an hour. wp_schedule_single_event(time() + 3 * MINUTE_IN_SECONDS, self::HOOK); } return self::stop($out, $ready[0], $ready[1]); } self::reconcileStep($deadline); // continues a reconcile that the readiness check left time for $signature = Settings::signature(); $modelId = Models::current(); $maxTokens = (int) (Models::get($modelId)['max_tokens'] ?? 512); if (self::processDeletions($store, $signature, $out) !== '') { return $out; } // A move to this store copies vectors first (no neurons). Until it is done, rows still // written to the old store are left to it, not embedded again. $moving = Mover::active(); if ($moving) { $m = Mover::step($store, $deadline); $out['copied'] = $m['copied']; $out['requeued'] = $m['requeued']; if ($m['message'] !== '') { $out['message'] = $m['message']; } $moving = Mover::active(); } while (microtime(true) < $deadline) { $rows = IndexTable::work($signature, 40, !$moving); if ($rows === []) { break; } $batch = []; $texts = 0; foreach ($rows as $row) { $item = self::prepare($row, $signature, $maxTokens, $store, $out); if ($item === null) { continue; } $batch[] = $item; $texts += count($item['chunks']); if ($texts >= self::TEXTS_PER_CALL) { break; } } if ($batch === []) { continue; // every row was unchanged, skipped or removed; take the next rows } $stop = self::embedBatch($batch, $store, $signature, $modelId, $out); self::releaseMemory(); if ($stop !== '') { if ($stop === 'budget') { $out['stopped'] = 'budget'; } return $out; } } if (self::processDeletions($store, $signature, $out) === '') { self::finishRebuild(); } return $out; } /** * @return true|array{0: string, 1: bool} True, or [message, pause?]. */ private static function ensureReady(VectorStore $store) { $dims = (int) (Models::get(Models::current())['dims'] ?? 0); $status = $store->status(); if (is_wp_error($status)) { return [$status->get_error_message(), $status->get_error_code() !== 'aipcf_retry']; } if ($status['exists'] && $status['dims'] !== $dims) { // Rebuild starts a new index only for the site's own index; a custom name is re-embedded in place. $text = $store->isSiteOwn() /* translators: 1: index name, 2: numbers in each vector the index holds, 3: numbers in each vector the model makes */ ? __('The index %1$s holds vectors of %2$s numbers, but the model makes vectors of %3$s. Press Rebuild (under More actions) to start a new index.', 'aipcf-ai-provider-for-cloudflare') /* translators: 1: index name, 2: numbers in each vector the index holds, 3: numbers in each vector the model makes */ : __('The index %1$s holds vectors of %2$s numbers, but the model makes vectors of %3$s. Enter a new index name, or go back to a model with the same vector size.', 'aipcf-ai-provider-for-cloudflare'); return [sprintf( $text, $store->indexName(), number_format((int) $status['dims']), number_format($dims) ), true]; } if (!$status['exists'] || !$status['ready']) { $r = $store->ensureIndex($dims); if (is_wp_error($r)) { return [$r->get_error_message(), $r->get_error_code() !== 'aipcf_retry']; } // Qdrant and Postgres are ready at once; Vectorize takes minutes. $status = $store->status(); if (is_wp_error($status) || !$status['ready']) { return [self::preparingMessage($store->indexName()), false]; } } return true; } /** * @param array $row * @param array $out * @return array{row: array, chunks: list, metadata: array, hash: string, words: int}|null */ private static function prepare(array $row, string $signature, int $maxTokens, VectorStore $store, array &$out): ?array { $type = (string) $row['object_type']; $id = (int) $row['object_id']; $post = get_post($id); if (!$post instanceof \WP_Post || IndexTable::objectType($post) !== $type || !Selection::isEligible($post)) { IndexTable::markDeleting($type, $id); return null; } $doc = Extractor::extract($post); $words = Extractor::words($doc['body']); $min = Settings::minWords(); if ($type === 'post' && $min > 0 && $words < $min) { // Too short to be a useful match: not embedded, and any vectors it had are removed. if ((int) $row['chunk_count'] > 0) { \WordPress\CloudflareWorkersAiProvider\Features\Related::flushCache(); // lists that show it are drawn again without it } self::removeVectors($store, $row, $signature, 0); IndexTable::update($type, $id, [ 'status' => IndexTable::SKIPPED, /* translators: 1: number of words in the post, 2: the minimum */ 'last_error' => sprintf(__('%1$d words, under the minimum of %2$d.', 'aipcf-ai-provider-for-cloudflare'), $words, $min), 'chunk_count' => 0, 'word_count' => $words, 'signature' => $signature, ]); return null; } $chunks = Chunker::chunk($doc['title'], $doc['body'], $maxTokens); if ($chunks === []) { self::removeVectors($store, $row, $signature, 0); IndexTable::update($type, $id, ['status' => IndexTable::SKIPPED, 'last_error' => __('No text to embed.', 'aipcf-ai-provider-for-cloudflare'), 'chunk_count' => 0, 'word_count' => $words, 'signature' => $signature]); return null; } $hash = sha1(implode('|', [$signature, Settings::storeText() ? '1' : '0', $doc['title'], $doc['body'], (string) wp_json_encode($doc['metadata'])])); if ($row['signature'] === $signature && $row['content_hash'] === $hash && (int) $row['chunk_count'] === count($chunks)) { IndexTable::update($type, $id, ['status' => IndexTable::INDEXED, 'attempts' => 0, 'last_error' => '', 'word_count' => $words]); $out['unchanged']++; return null; } return ['row' => $row, 'chunks' => $chunks, 'metadata' => $doc['metadata'], 'hash' => $hash, 'words' => $words]; } /** * Embeds and writes one batch. Returns '' to carry on, or why it stopped. * * @param list, chunks: list, metadata: array, hash: string, words: int}> $batch * @param array $out */ private static function embedBatch(array $batch, VectorStore $store, string $signature, string $modelId, array &$out): string { $cap = Settings::dailyNeurons(); if ($cap > 0 && UsageTracker::todayEmbedNeurons() >= $cap) { /* translators: 1: feature name, 2: the daily limit in neurons */ $out['message'] = sprintf(__('The %1$s daily limit (%2$s neurons) is used up. It resumes at midnight UTC.', 'aipcf-ai-provider-for-cloudflare'), __('Embeddings', 'aipcf-ai-provider-for-cloudflare'), number_format($cap)); return 'budget'; } if (UsageTracker::overBudget()) { $out['message'] = __('The daily budget is used up. Embeddings resume at midnight UTC.', 'aipcf-ai-provider-for-cloudflare'); return 'budget'; } $texts = []; foreach ($batch as $item) { foreach ($item['chunks'] as $c) { $texts[] = $c['text']; } } $vectors = EmbeddingClient::embed($texts, $modelId); if (is_wp_error($vectors)) { return self::batchError($batch, $vectors, $out, __('Embedding model', 'aipcf-ai-provider-for-cloudflare')); } $rows = []; $owners = []; $i = 0; foreach ($batch as $b => $item) { $type = (string) $item['row']['object_type']; $id = (int) $item['row']['object_id']; foreach ($item['chunks'] as $n => $chunk) { $metadata = self::metadataFor(['metadata' => $item['metadata']], $n, $chunk); $vid = self::vectorId($type, $id, $n); $owners[$vid] = $b; $rows[] = ['id' => $vid, 'values' => $vectors[$i++], 'metadata' => $metadata]; } } $written = $store->upsert($rows); if (is_wp_error($written) && $written->get_error_code() === 'aipcf_store_item') { // Mark the owners of the invalid vectors failed and write the rest. $bad = []; foreach ((array) ($written->get_error_data()['ids'] ?? []) as $vid) { if (isset($owners[(string) $vid])) { $bad[$owners[(string) $vid]] = true; } } foreach (array_keys($bad) as $b) { /* translators: %s: error message from the vector store */ self::fail($batch[$b]['row'], sprintf(__('The vector store refused this item: %s', 'aipcf-ai-provider-for-cloudflare'), $written->get_error_message()), $out); unset($batch[$b]); } $rows = array_values(array_filter($rows, static fn($r): bool => !isset($bad[$owners[$r['id']]]))); $written = $rows === [] ? true : $store->upsert($rows); } if (is_wp_error($written)) { return self::batchError(array_values($batch), $written, $out, __('Vector store', 'aipcf-ai-provider-for-cloudflare')); } if (State::get()['index_model'] !== $modelId) { State::update(['index_model' => $modelId]); // what the index holds, for a later rebuild } $now = current_time('mysql', true); foreach ($batch as $item) { $row = $item['row']; $type = (string) $row['object_type']; $id = (int) $row['object_id']; $written = count($item['chunks']); $done = IndexTable::updateIfUnchanged($type, $id, [ 'status' => IndexTable::INDEXED, 'content_hash' => $item['hash'], 'chunk_count' => $written, 'word_count' => (int) $item['words'], 'signature' => $signature, 'attempts' => 0, 'last_error' => '', 'indexed_at' => $now, // New content: its words and links are read again (timestamps have one-second // resolution, so "linked before indexed" cannot be relied on within a run). 'linked_at' => null, ], (int) $row['revision']); if (!$done) { self::changedDuringBatch($store, $type, $id, $written, $signature); continue; } self::removeVectors($store, $row, $signature, $written); $out['embedded']++; } State::update(['done' => State::get()['done'] + count($batch)]); return ''; } /** * The post was saved, trashed or deleted while its vectors were being written. * Its row (as the hooks left it) decides what happens next; the row is made to * cover the vectors just written, so the next deletion or re-embed removes them. */ private static function changedDuringBatch(VectorStore $store, string $type, int $id, int $written, string $signature): void { $current = IndexTable::get($type, $id); if ($current === null) { // Deleted (or never written) meanwhile: nothing will track these vectors, so remove them now. $ids = []; for ($n = 0; $n < $written; $n++) { $ids[] = self::vectorId($type, $id, $n); } $store->delete($ids); return; } IndexTable::update($type, $id, ['chunk_count' => max((int) $current['chunk_count'], $written), 'signature' => $signature]); } /** * @param list}> $batch * @param array $out */ private static function batchError(array $batch, \WP_Error $e, array &$out, string $where): string { /* translators: 1: where it failed ("Vector store" or "Embedding model"), 2: error message */ $message = sprintf(__('%1$s: %2$s', 'aipcf-ai-provider-for-cloudflare'), $where, $e->get_error_message()); $code = $e->get_error_code(); if (in_array($code, ['aipcf_embed_auth', 'aipcf_store_auth', 'aipcf_store_missing', 'aipcf_store_mismatch'], true)) { State::pause($message); $out['message'] = self::pausedMessage($message); return 'paused'; } if (in_array($code, ['aipcf_retry', 'aipcf_embed_response'], true)) { foreach ($batch as $item) { $row = $item['row']; $attempts = (int) $row['attempts'] + 1; if ($attempts >= self::MAX_ATTEMPTS) { /* translators: 1: error message, 2: number of attempts */ self::fail($row, sprintf(_n('%1$s. Failed after %2$d attempt.', '%1$s. Failed after %2$d attempts.', $attempts, 'aipcf-ai-provider-for-cloudflare'), rtrim($message, '. '), $attempts), $out); } else { IndexTable::update((string) $row['object_type'], (int) $row['object_id'], ['attempts' => $attempts, 'last_error' => $message]); } } State::update(['last_error' => $message]); /* translators: %s: error message */ $out['message'] = sprintf(__('%s. It is tried again on the next run.', 'aipcf-ai-provider-for-cloudflare'), rtrim($message, '. ')); return 'retry'; } foreach ($batch as $item) { self::fail($item['row'], $message, $out); } return ''; } /** * @param array $row * @param array $out */ private static function fail(array $row, string $reason, array &$out): void { IndexTable::update((string) $row['object_type'], (int) $row['object_id'], ['status' => IndexTable::FAILED, 'last_error' => $reason, 'attempts' => 0]); State::update(['failed' => State::get()['failed'] + 1, 'last_error' => sprintf('#%d: %s', (int) $row['object_id'], $reason)]); $out['failed']++; } /** * Deletes an object's vectors from chunk $from up, when they are in the current index. * * @param array $row * @return true|\WP_Error */ private static function removeVectors(VectorStore $store, array $row, string $signature, int $from) { $count = (int) $row['chunk_count']; if ($count <= $from) { return true; } // Vector IDs do not depend on the signature: delete in the current index even when the // row was written under another model (a model switch without a rebuild keeps the index). $ids = []; for ($n = $from; $n < $count; $n++) { $ids[] = self::vectorId((string) $row['object_type'], (int) $row['object_id'], $n); } return $store->delete($ids); } /** * Removes the vectors of rows marked deleting. Returns '' when done, or why it stopped. * * @param array $out */ private static function processDeletions(VectorStore $store, string $signature, array &$out): string { $previous = State::get()['previous_index']; $old = $previous !== '' ? StoreFactory::make(StoreFactory::currentId(), $previous) : null; do { $rows = IndexTable::deleting(200); $ids = []; foreach ($rows as $row) { // Whatever signature the row has: the IDs are the same in every index. for ($n = 0; $n < (int) $row['chunk_count']; $n++) { $ids[] = self::vectorId((string) $row['object_type'], (int) $row['object_id'], $n); } } if ($old instanceof VectorStore && $ids !== []) { // During a rebuild the old index still answers queries until it is dropped. $old->delete($ids); } $r = $store->delete($ids); if (is_wp_error($r)) { $stop = self::batchError([], $r, $out, __('Vector store', 'aipcf-ai-provider-for-cloudflare')); return $stop !== '' ? $stop : 'failed'; } foreach ($rows as $row) { IndexTable::delete((string) $row['object_type'], (int) $row['object_id']); $out['deleted']++; } } while (count($rows) === 200); return ''; } /** * Drops WordPress's in-request object cache between pages and batches: every * get_post() caches the whole post, which at 50,000 posts is over 200 MB. With a * persistent object cache (Redis, Memcached) only this process's copy is cleared. */ private static function releaseMemory(): void { if (function_exists('wp_cache_flush_runtime')) { wp_cache_flush_runtime(); } } /** * What is stored next to a passage's vector: the post's metadata, the passage's place, and * its text when "Store the text" is on. A move rebuilds it with this, so it is identical. * * @param array{metadata: array} $doc * @param array{text: string, section: string} $chunk * @return array */ public static function metadataFor(array $doc, int $n, array $chunk): array { $metadata = $doc['metadata'] + ['chunk' => $n, 'section' => mb_substr($chunk['section'], 0, 200)]; if (Settings::storeText()) { $metadata['text'] = $chunk['text']; } return $metadata; } public static function vectorId(string $type, int $id, int $chunk): string { return Extractor::siteKey() . ':' . ($type === 'attachment' ? 'a' : 'p') . ':' . $id . ':' . $chunk; } // ================================================================ reconcile /** * Walks the whole selection (adding missing rows) and then the table (removing * rows that no longer qualify), a page at a time over as many ticks as it takes. */ public static function startReconcile(): void { State::update(['reconcile' => ['phase' => 'add', 'kind' => 0, 'page' => 1, 'after' => 0]]); } public static function reconcileStep(float $deadline): void { $r = State::get()['reconcile']; while ($r !== null && microtime(true) < $deadline) { if ($r['phase'] === 'add') { $kinds = Selection::kinds(); if ((int) $r['kind'] >= count($kinds)) { $r = ['phase' => 'sweep', 'after' => 0] + $r; continue; } $kind = $kinds[(int) $r['kind']]; $ids = Selection::ids($kind, (int) $r['page'], self::RECONCILE_PAGE); if ($ids === []) { $r['kind'] = (int) $r['kind'] + 1; $r['page'] = 1; continue; } $type = $kind === 'attachment' ? 'attachment' : 'post'; $known = array_flip(IndexTable::known($type, $ids)); IndexTable::markPendingMany($type, array_values(array_filter($ids, static fn(int $id): bool => !isset($known[$id])))); $r['page'] = (int) $r['page'] + 1; } else { $rows = IndexTable::page((int) $r['after'], self::RECONCILE_PAGE); if ($rows === []) { $r = null; break; } $edited = []; foreach ($rows as $row) { $r['after'] = (int) $row['id']; if ($row['status'] === IndexTable::DELETING) { continue; } $post = get_post((int) $row['object_id']); if (!$post instanceof \WP_Post || IndexTable::objectType($post) !== $row['object_type'] || !Selection::isEligible($post)) { IndexTable::markDeleting((string) $row['object_type'], (int) $row['object_id']); } elseif ($row['status'] === IndexTable::INDEXED && (string) $row['indexed_at'] !== '' && $post->post_modified_gmt > $row['indexed_at']) { // Edited since it was embedded without the save hook seeing it. $edited[(string) $row['object_type']][] = (int) $row['object_id']; } } foreach ($edited as $type => $ids) { IndexTable::markPendingMany($type, $ids); } self::releaseMemory(); } State::update(['reconcile' => $r]); } State::update(['reconcile' => $r]); } // ================================================================ rebuild, purge, search /** * Starts over in a fresh index: every indexed object becomes stale and is * embedded again; the old index is dropped once nothing is left to move. */ public static function rebuild(): void { $state = State::get(); $current = StoreFactory::current(); if ($current instanceof VectorStore && !$current->isSiteOwn()) { // A custom (possibly shared) index is never swapped or dropped: re-embed // this site's items in place, overwriting the same IDs. IndexTable::forceReembed(); State::update(['paused' => '', 'last_error' => '']); delete_transient('aipcf_embed_store_status'); return; } if ($state['previous_index'] !== '') { // A rebuild interrupted by another: the oldest index is two generations back. StoreFactory::dropNamed($state['previous_index']); } State::update([ 'generation' => $state['generation'] + 1, 'previous_index' => $current instanceof VectorStore ? $current->indexName() : '', // Readers stay on the previous index while the new one fills, if its vectors fit // the model in use (a rebuild after a model change cannot use them). 'previous_model' => $state['index_model'], 'index_model' => '', 'paused' => '', 'last_error' => '', ]); delete_transient('aipcf_embed_store_status'); } private static function finishRebuild(): void { $previous = State::get()['previous_index']; if ($previous === '') { return; } $c = IndexTable::counts(Settings::signature()); if ($c['pending'] + $c['stale'] + $c['deleting'] > 0) { return; } if (StoreFactory::dropNamed($previous)) { State::update(['previous_index' => '', 'previous_model' => '']); } } /** * Switches embeddings off and removes this site's vectors: drops the site's own * index (and any previous one), or deletes the site's vectors from a shared index. * * @return true|\WP_Error */ public static function purge() { $settings = get_option(AIPCF_SETTINGS_OPTION, []); $settings = is_array($settings) ? $settings : []; $settings['embeddings_enabled'] = 0; update_option(AIPCF_SETTINGS_OPTION, $settings); $store = StoreFactory::current(); if (is_wp_error($store)) { return $store; } $state = State::get(); if ($store->isSiteOwn()) { $r = $store->dropIndex(); } else { $r = true; $sig = Settings::signature(); $page = 0; while ($r === true && ($rows = IndexTable::page($page, 500)) !== []) { $ids = []; foreach ($rows as $row) { $page = (int) $row['id']; if ($row['signature'] === $sig) { for ($n = 0; $n < (int) $row['chunk_count']; $n++) { $ids[] = self::vectorId((string) $row['object_type'], (int) $row['object_id'], $n); } } } $r = $store->delete($ids); } } if (is_wp_error($r)) { return $r; } if ($state['previous_index'] !== '') { StoreFactory::dropNamed($state['previous_index']); } IndexTable::truncate(); // Related lists were worked out from the vectors just removed. delete_post_meta_by_key('_aipcf_related'); \WordPress\CloudflareWorkersAiProvider\Features\Related::flushCache(); \WordPress\CloudflareWorkersAiProvider\Features\LinkIndex::drop(); // A just-dropped Vectorize name is unusable for minutes: switching back on starts a new // generation. A custom name was not dropped, so it is kept. State::update(['generation' => $state['generation'] + ($store->isSiteOwn() ? 1 : 0), 'previous_index' => '', 'paused' => '', 'reconcile' => null, 'done' => 0, 'failed' => 0, 'last_error' => '', 'last_message' => __('Vectors deleted and Embeddings switched off.', 'aipcf-ai-provider-for-cloudflare')]); wp_clear_scheduled_hook(self::HOOK); delete_transient('aipcf_embed_store_status'); return true; } /** * Semantic search over this site's vectors (the admin "Test search"). * By default one result per post: its best-matching chunk, with how many * other chunks of the post also matched. * * @return list|\WP_Error */ public static function search(string $query, int $topK = 5, bool $groupByPost = true, string $task = EmbeddingClient::TASK) { $query = trim($query); if ($query === '') { return new \WP_Error('aipcf_embed_input', __('Enter something to search for.', 'aipcf-ai-provider-for-cloudflare')); } $store = StoreFactory::reading(); // the old store during a move or rebuild if (is_wp_error($store)) { return $store; } $vectors = EmbeddingClient::embed([$query], null, $task); if (is_wp_error($vectors)) { return $vectors; } \WordPress\CloudflareWorkersAiProvider\Usage\UsageTracker::recordQueries($task); // Ask for more chunks than results, so grouping by post still fills the list. $matches = $store->query($vectors[0], $groupByPost ? min(50, $topK * 4) : $topK, ['site' => Extractor::siteKey()]); if (is_wp_error($matches)) { return $matches; } $out = []; foreach ($matches as $m) { $md = $m['metadata']; $oid = (int) ($md['object_id'] ?? 0); $key = $groupByPost ? (string) ($md['object_type'] ?? 'post') . ':' . $oid : $m['id']; if (isset($out[$key])) { $out[$key]['more']++; // results come best first, so the first chunk of a post stays continue; } $out[$key] = [ 'id' => $m['id'], 'score' => $m['score'], 'object_id' => $oid, 'title' => (string) ($md['title'] ?? ''), 'url' => (string) ($md['url'] ?? ''), 'section' => (string) ($md['section'] ?? ''), 'text' => (string) ($md['text'] ?? ''), 'more' => 0, ]; } return array_slice(array_values($out), 0, $topK); } // ================================================================ helpers /** * @param array $out * @return array */ private static function stop(array $out, string $message, bool $pause): array { if ($pause) { State::pause($message); $out['message'] = self::pausedMessage($message); } else { $out['message'] = $message; } return $out; } private static function pausedMessage(string $message): string { /* translators: %s: why Embeddings paused */ return sprintf(__('Paused: %s', 'aipcf-ai-provider-for-cloudflare'), $message); } /** * @param array $out */ private static function summary(array $out): string { if (!$out['ran']) { return (string) $out['reason']; } return sprintf( /* translators: 1: embedded, 2: unchanged, 3: removed, 4: failed */ __('Embedded %1$d, unchanged %2$d, removed %3$d, failed %4$d.', 'aipcf-ai-provider-for-cloudflare'), $out['embedded'], $out['unchanged'], $out['deleted'], $out['failed'] ); } }