plugin))->register(); add_action('rest_api_init', function (): void { (new KnowledgeController($this->plugin))->register(); }); add_action('aiwend_register_job_handlers', function (Worker $worker): void { $worker->handle(self::INGEST, fn(Job $job) => $this->runIngest($job)); $worker->handle(self::REPAIR, fn(Job $job) => $this->repair($job)); $worker->handle(self::PURGE, function (Job $job): void { $this->plugin->purger()->purge($job->siteId, (int) ($job->payload['source_id'] ?? 0)); }); // Semantic generation jobs: guarded. They run only where the plugin has an authorized embedder (a test environment with an // injected one); everywhere else a job of these types is dead-lettered. Nothing enqueues them automatically. $this->plugin->generationRuntime()->register($worker); }); add_action('transition_post_status', [$this, 'onPostStatus'], 10, 3); add_action('post_updated', function (int $postId): void { $this->onPostChanged($postId); }); add_action('before_delete_post', function (int $postId): void { $source = $this->plugin->sources()->findByOrigin(get_current_blog_id(), 'post', (string) $postId); if ($source !== null) { $this->deleteSource((int) $source['id']); } }); } /** * Queues indexing. The idempotency key includes the content hash, so repeated saves of * unchanged content are not re-queued. Pass $force for events that must re-run even with * unchanged content (republish after unpublish, manual re-index). */ public function queueIngest(int $sourceId, string $contentHash, bool $force = false): int { $key = "ingest:$sourceId:$contentHash" . ($force ? ':' . wp_generate_password(8, false) : ''); return $this->plugin->queue->enqueue(get_current_blog_id(), self::INGEST, ['source_id' => $sourceId], $key, 0, self::INGEST_ATTEMPTS); } /** * Replaces an upload source's file (same source, same assistants and visibility) and re-indexes it as a new version. * False when the source is not an upload of this site or is being deleted. Forced: a file identical to an earlier one * must still be indexed again (its idempotency key may already be finished). */ public function replaceUpload(int $sourceId, string $name, string $type, string $content, ?string $title): bool { $hash = $this->plugin->sources()->replaceUpload(get_current_blog_id(), $sourceId, $name, $type, $content, $title); if ($hash === null) { return false; } $this->queueIngest($sourceId, $hash, true); return true; } public function deleteSource(int $sourceId): void { $site = get_current_blog_id(); if ($this->plugin->sources()->markDeleting($site, $sourceId)) { $this->plugin->queue->enqueue($site, self::PURGE, ['source_id' => $sourceId], "purge:$sourceId", 0, 5); } } public function onPostStatus(string $new, string $old, \WP_Post $post): void { $source = $this->plugin->sources()->findByOrigin(get_current_blog_id(), 'post', (string) $post->ID); if ($source === null || in_array($source['status'], ['deleting', 'deleted'], true)) { return; } if (!self::isPublic($post)) { // Immediately excluded: retrieval requires status "ready". $this->plugin->sources()->setStatus(get_current_blog_id(), (int) $source['id'], 'failed', 'The post is no longer published.'); return; } // A (re)publish must re-run even when the content is unchanged, to restore the source. $this->queueIngest((int) $source['id'], self::postHash($post), $old !== 'publish'); } private function onPostChanged(int $postId): void { $post = get_post($postId); if ($post instanceof \WP_Post && self::isPublic($post)) { $source = $this->plugin->sources()->findByOrigin(get_current_blog_id(), 'post', (string) $postId); if ($source !== null && !in_array($source['status'], ['deleting', 'deleted'], true)) { $this->queueIngest((int) $source['id'], self::postHash($post)); } } } private function runIngest(Job $job): void { $site = $job->siteId; $sourceId = (int) ($job->payload['source_id'] ?? 0); $source = $this->plugin->sources()->find($site, $sourceId); if ($source === null) { return; } if ($source['origin'] === 'post') { $post = get_post((int) $source['origin_ref']); if (!$post instanceof \WP_Post || !self::isPublic($post)) { $this->plugin->sources()->setStatus($site, $sourceId, 'failed', 'The post is no longer published.'); return; } $this->plugin->sources()->upsert($site, 'post', (string) $post->ID, get_the_title($post), (string) get_permalink($post), $source['visibility'], $source['owner_user_id']); $raw = strip_shortcodes((string) $post->post_content); $type = 'html'; } else { $blob = $this->plugin->sources()->blob($site, $sourceId); if ($blob === null) { $this->plugin->sources()->setStatus($site, $sourceId, 'failed', 'The uploaded content is missing.'); return; } $raw = $blob['content']; $type = $blob['type']; } try { $result = $this->plugin->ingestor()->ingest($site, $sourceId, $raw, $type); } catch (\Throwable $e) { if ($job->attempts >= self::INGEST_ATTEMPTS) { // The queue gives up after this attempt: the interim "will be retried" text would now be false. $this->plugin->sources()->setIndexingGaveUp($site, $sourceId); } throw $e; } $this->plugin->audit->record($site, 'system', 'knowledge.ingest', "source:$sourceId", $result['outcome'], null, $result['reason']); } /** * Daily repair: a source registered as "queued" whose indexing job was never queued (the request died in between) or was lost would stay * "Queued" for ever. Sources that have waited longer than the grace period without any job that will still run get indexing queued again, * at most REPAIR_LIMIT per run. Idempotent: a source with a live job is skipped, and the next run finds nothing left. */ private function repair(Job $job): void { $site = $job->siteId; $ids = $this->plugin->sources()->withoutIndexingJob($site, $this->plugin->clock->now() - self::REPAIR_GRACE, self::REPAIR_LIMIT); foreach ($ids as $id) { $this->queueIngest($id, 'repair', true); } if ($ids !== []) { $this->plugin->audit->record($site, 'system', 'knowledge.repair', 'sources:' . count($ids), 'ok'); } // What the Diagnostics page shows about the repair: when it last ran and how many sources it queued again (two numbers, no content). update_option(self::LAST_REPAIR_OPTION, ['at' => $this->plugin->clock->now(), 'count' => count($ids)], false); } /** Published, publicly viewable and not password-protected. */ public static function isPublic(\WP_Post $post): bool { $type = get_post_type_object($post->post_type); return $post->post_status === 'publish' && $post->post_password === '' && $type !== null && $type->public; } public static function postHash(\WP_Post $post): string { return hash('sha256', 'html' . "\0" . strip_shortcodes((string) $post->post_content)); } }