0, 'stopped' => '']; if (!self::enabled() || !EmbeddingSettings::enabled()) { return $out; } if ($store === null) { $store = StoreFactory::reading(); // the old store during a move or rebuild if (is_wp_error($store)) { $out['stopped'] = $store->get_error_message(); return $out; } } // One run at a time: a background run and "Build lists now" (or WP-CLI) would otherwise // work the same posts and spend their store queries twice. if (!Lock::acquire('related')) { $out['stopped'] = __('Related lists are already being built.', 'aipcf-ai-provider-for-cloudflare'); return $out; } try { return self::refreshLocked($deadline, $store, $out); } finally { Lock::release('related'); } } /** * @param array{done: int, stopped: string} $out * @return array{done: int, stopped: string} */ private static function refreshLocked(float $deadline, VectorStore $store, array $out): array { $before = gmdate('Y-m-d H:i:s', time() - self::SETTLE); while (microtime(true) < $deadline) { $left = self::queriesLeft(); if ($left === 0) { $out['stopped'] = sprintf( /* translators: 1: feature name, like "Related posts", 2: number of vector store queries */ __('The %1$s daily limit (%2$s vector store queries) is used up. It resumes at midnight UTC.', 'aipcf-ai-provider-for-cloudflare'), __('Related posts', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n((int) Settings::get('related_daily_queries')) ); break; } $rows = IndexTable::relatedDue($left === null ? 20 : min(20, $left), $before); if ($rows === []) { break; } $ids = array_map(static fn(array $r): string => Ingest::vectorId('post', (int) $r['object_id'], 0), $rows); $vectors = $store->vectors($ids); if (is_wp_error($vectors)) { $out['stopped'] = $vectors->get_error_message(); break; } foreach ($rows as $i => $row) { $postId = (int) $row['object_id']; if (isset($vectors[$ids[$i]])) { $r = self::compute($postId, $vectors[$ids[$i]], $store); if (is_wp_error($r)) { $out['stopped'] = $r->get_error_message(); break 2; } $out['done']++; } // Not in the store (yet, or any more): marked anyway, so it is not asked for // again and again; re-embedding the post brings it back. IndexTable::markRelated($postId); if (microtime(true) >= $deadline) { break 2; } } } return $out; } /** * One post's list from its vector, and its place in the lists of the posts it found. * * @param list $vector * @return true|\WP_Error */ public static function compute(int $postId, array $vector, VectorStore $store) { $best = self::candidates($postId, $vector, $store, self::TASK); if (is_wp_error($best)) { return $best; } self::save($postId, $best); foreach ($best as $oid => $score) { self::offer((int) $oid, $postId, $score); } return true; } /** * The closest posts of the same type to a post's vector, best first, without saving. * * @param list $vector * @return array|\WP_Error post ID => score */ public static function candidates(int $postId, array $vector, VectorStore $store, string $task) { $post = get_post($postId); if (!$post instanceof \WP_Post) { return []; } $matches = $store->query($vector, 4 * self::KEEP, ['site' => Extractor::siteKey(), 'post_type' => $post->post_type]); UsageTracker::recordQueries($task); if (is_wp_error($matches)) { return $matches; } $best = []; foreach ($matches as $m) { $md = $m['metadata']; $oid = (int) ($md['object_id'] ?? 0); if ($oid === 0 || $oid === $postId || ($md['object_type'] ?? 'post') !== 'post' || $m['score'] < self::FLOOR) { continue; } // The store can still hold a post that was since unpublished or protected // (deletions are applied in the background). if (!isset($best[$oid]) && !self::showable(get_post($oid))) { continue; } if (!isset($best[$oid]) || $m['score'] > $best[$oid]) { $best[$oid] = (float) $m['score']; } } arsort($best); return array_slice($best, 0, self::KEEP, true); } /** * Adds a post to another post's list when it scores higher than that list's weakest entry. */ private static function offer(int $to, int $candidate, float $score): void { $list = self::stored($to); if ($list === null) { return; // that post's own run will find this one } if (isset($list[$candidate]) && $list[$candidate] >= $score) { return; } if (count($list) >= self::KEEP && !isset($list[$candidate]) && $score <= min($list)) { return; } $list[$candidate] = $score; arsort($list); self::save($to, array_slice($list, 0, self::KEEP, true)); } /** * @param array $list post ID => score, best first */ private static function save(int $postId, array $list): void { $items = []; foreach ($list as $id => $score) { $items[] = [(int) $id, round($score, 4)]; } update_post_meta($postId, self::META, ['at' => time(), 'items' => $items]); wp_cache_delete((string) $postId, self::CACHE_GROUP); } /** * @return array|null post ID => score, or null when no list was worked out yet. */ public static function stored(int $postId): ?array { $meta = get_post_meta($postId, self::META, true); if (!is_array($meta) || !isset($meta['items']) || !is_array($meta['items'])) { return null; } $out = []; foreach ($meta['items'] as $item) { if (is_array($item) && isset($item[0], $item[1])) { $out[(int) $item[0]] = (float) $item[1]; } } return $out; } /** * Store queries left today for working out lists; null means no limit. */ private static function queriesLeft(): ?int { $limit = (int) Settings::get('related_daily_queries'); if ($limit <= 0) { return null; } return max(0, $limit - (int) (UsageTracker::todayTotals()['queries'][self::TASK] ?? 0)); } /** * After each embeddings run: work lists out with what time is left. * * @param array $out The run's result. */ public static function afterTick(array $out, float $deadline): void { if (!empty($out['ran']) && microtime(true) < $deadline) { try { $r = self::refresh($deadline); if ($r['stopped'] === '') { self::comeBackSoon(); } } catch (\Throwable $e) { // A failure here must not stop the other features' work after the run. State::update(['last_error' => 'Related posts: ' . $e->getMessage()]); } } } /** * With lists still waiting (after a big Embeddings run, a rebuild or a move), the next run is * in minutes, not at the hourly run: the lists cost no neurons, only store queries, which * the daily query limit still caps. */ private static function comeBackSoon(): void { if (self::queriesLeft() === 0 || IndexTable::relatedCounts()['waiting'] === 0) { return; } $at = time() + self::SETTLE; $next = wp_next_scheduled(Ingest::HOOK); if ($next === false || $next > $at + MINUTE_IN_SECONDS) { wp_schedule_single_event($at, Ingest::HOOK); } } // ================================================================ showing lists /** * Whether a post may be shown or quoted to anyone: published, not password-protected, and * still in the embeddings selection (not opted out or excluded since its list was made). */ public static function showable($post): bool { return $post instanceof \WP_Post && $post->post_status === 'publish' && $post->post_password === '' && Selection::isEligible($post); } /** * The posts to show for a post: published, readable, above the display threshold. * * @return list<\WP_Post> */ public static function posts(int $postId, int $count): array { $list = self::stored($postId) ?? []; $min = (float) Settings::get('related_min_score'); $out = []; // Posts the index has since skipped (now under the minimum length) are left out. $skip = $list === [] ? [] : array_flip(IndexTable::skippedAmong('post', array_map('intval', array_keys($list)))); foreach ($list as $id => $score) { if ($score < $min) { break; // best first } if (isset($skip[(int) $id])) { continue; } $p = get_post($id); if (self::showable($p)) { $out[] = $p; if (count($out) >= $count) { break; } } } return $out; } /** * [aipcf_related count="5" title="Related posts" heading="h2" excerpt="no" thumbnail="no" why="no" post="123"] * * @param array|string $atts */ public static function shortcode($atts = []): string { if (!self::enabled()) { return self::note(__('Related posts are off. Switch them on in AIPCF > Features > Related posts.', 'aipcf-ai-provider-for-cloudflare')); } $html = self::build($atts); return $html !== '' ? $html : self::note(__('Related posts: none close enough yet.', 'aipcf-ai-provider-for-cloudflare')); } /** * Why the shortcode shows nothing, as an HTML comment for users who can edit posts; nothing * for visitors. Never cached: it depends on who is looking. */ private static function note(string $why): string { if (!current_user_can('edit_posts')) { return ''; } // No "--" (it could end the comment early) and no "<" (it could open another one). return ''; } /** * The list's HTML, or '' when there is nothing to show. * * @param array|string $atts */ private static function build($atts = []): string { // The Content tools settings, which the shortcode's own attributes override. $title = trim((string) Settings::get('related_title')); $a = shortcode_atts([ 'count' => (string) Settings::get('related_count'), 'title' => $title !== '' ? $title : __('Related posts', 'aipcf-ai-provider-for-cloudflare'), 'heading' => (string) Settings::get('related_heading'), 'excerpt' => !empty(Settings::get('related_excerpt')) ? 'yes' : 'no', 'thumbnail' => !empty(Settings::get('related_thumbnail')) ? 'yes' : 'no', 'why' => !empty(Settings::get('related_why')) ? 'yes' : 'no', 'post' => '', ], is_array($atts) ? $atts : [], 'aipcf_related'); $postId = $a['post'] !== '' ? (int) $a['post'] : (int) get_the_ID(); if ($postId <= 0) { return ''; } // Excerpts pass through the_content filters for the person viewing (membership plugins // show more to members), so a list with excerpts is never cached and shared. $cache = !in_array(strtolower($a['excerpt']), ['1', 'yes', 'true'], true); $key = (string) $postId; $sub = md5((string) wp_json_encode($a + ['min' => Settings::get('related_min_score'), 'gen' => self::generation()])); $hit = $cache ? wp_cache_get($key, self::CACHE_GROUP) : false; if (is_array($hit) && isset($hit[$sub])) { return (string) $hit[$sub]; } $html = self::render(self::posts($postId, max(1, min(self::KEEP, (int) $a['count']))), $a, $postId); if ($cache) { wp_cache_set($key, (is_array($hit) ? $hit : []) + [$sub => $html], self::CACHE_GROUP, HOUR_IN_SECONDS); } return $html; } /** * @param list<\WP_Post> $posts * @param array $a */ private static function render(array $posts, array $a, int $postId = 0): string { if ($posts === []) { return ''; } $heading = in_array($a['heading'], ['h2', 'h3', 'h4', 'p'], true) ? $a['heading'] : 'h2'; $yes = static fn(string $v): bool => in_array(strtolower($v), ['1', 'yes', 'true'], true); $thumbs = $yes($a['thumbnail']); $why = $yes($a['why']) && $postId > 0; $scores = $why ? (self::stored($postId) ?? []) : []; $html = ''; } /** * Why a post is in another's list, in words and without AI: the tags and categories they * share, the most specific first, leaving out those on more than a quarter of the posts in * the post's own section (its most specific category; the whole post type without one), so * "Non-fiction" on a site's book section, or the section itself, says nothing; otherwise * how close in meaning they are. */ public static function why(int $postId, \WP_Post $related, float $score): string { $type = get_post_type($postId) ?: 'post'; $default = (int) get_option('default_category'); $section = null; $cats = get_the_terms($postId, 'category'); foreach (is_array($cats) ? $cats : [] as $cat) { if ((int) $cat->term_id !== $default && ($section === null || $cat->count < $section->count)) { $section = $cat; } } $size = $section !== null ? (int) $section->count : (int) (wp_count_posts($type)->publish ?? 0); $broad = max(5, (int) floor($size / 4)); $shared = []; foreach (get_object_taxonomies($type, 'objects') as $tax) { if (!$tax->public || !$tax->show_ui) { continue; } $mine = get_the_terms($postId, $tax->name); $theirs = get_the_terms($related->ID, $tax->name); if (!is_array($mine) || !is_array($theirs)) { continue; } $theirIds = wp_list_pluck($theirs, 'term_id'); foreach ($mine as $term) { if (!in_array($term->term_id, $theirIds, true) || ($tax->name === 'category' && (int) $term->term_id === $default)) { continue; } $count = $section !== null ? self::inSection($term, $section) : (int) $term->count; if ($count <= $broad) { $shared[$term->term_id] = ['name' => html_entity_decode($term->name, ENT_QUOTES, 'UTF-8'), 'count' => $count]; } } } if ($shared !== []) { uasort($shared, static fn(array $x, array $y): int => $x['count'] <=> $y['count']); /* translators: %l: a list of tag and category names, like "Malcolm Gladwell, Popular science and Success" */ return wp_sprintf(__('Also about %l', 'aipcf-ai-provider-for-cloudflare'), array_slice(array_column($shared, 'name'), 0, 3)); } return $score >= 0.75 ? __('Very close in subject', 'aipcf-ai-provider-for-cloudflare') : __('Close in subject', 'aipcf-ai-provider-for-cloudflare'); } /** * Published posts that have both terms (a term and a section category), cached for an hour. */ private static function inSection(\WP_Term $term, \WP_Term $section): int { if ($term->term_taxonomy_id === $section->term_taxonomy_id) { return (int) $section->count; } $key = 'sec:' . $section->term_taxonomy_id . ':' . $term->term_taxonomy_id; $count = wp_cache_get($key, self::CACHE_GROUP); if ($count === false) { global $wpdb; $count = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT COUNT(DISTINCT a.object_id) FROM %i a JOIN %i b ON b.object_id = a.object_id JOIN %i p ON p.ID = a.object_id WHERE a.term_taxonomy_id = %d AND b.term_taxonomy_id = %d AND p.post_status = 'publish'", $wpdb->term_relationships, $wpdb->term_relationships, $wpdb->posts, $term->term_taxonomy_id, $section->term_taxonomy_id )); wp_cache_set($key, $count, self::CACHE_GROUP, HOUR_IN_SECONDS); } return (int) $count; } /** * "Add automatically": on the main post of a single view, for the embedded post types the * owner left ticked, after the content, before it or after a paragraph. A post that has the * shortcode keeps its own list, so there are never two. */ public static function appendToContent(string $content): string { static $busy = false; // excerpts of related posts run the_content again if ($busy || !self::enabled() || empty(Settings::get('related_auto')) || !is_singular() || !in_the_loop() || !is_main_query()) { return $content; } $post = get_post(); if ( !$post instanceof \WP_Post || !in_array($post->post_type, (array) (get_plugin_settings()['embed_post_types'] ?? []), true) || in_array($post->post_type, (array) Settings::get('related_skip_types'), true) || has_shortcode($post->post_content, 'aipcf_related') || strpos($content, 'class="aipcf-related') !== false ) { return $content; } $busy = true; try { $list = self::build(); } finally { $busy = false; } if ($list === '') { return $content; } switch ((string) Settings::get('related_position')) { case 'before': return $list . $content; case 'paragraph': return self::afterParagraph($content, $list, max(1, (int) Settings::get('related_paragraph'))); default: return $content . $list; } } /** * Puts $insert after the $n-th paragraph of the content itself (not one inside a quote, list, * table or group); at the end when there are fewer. */ public static function afterParagraph(string $content, string $insert, int $n): string { if (!preg_match_all('~<(/?)(blockquote|div|section|figure|table|ul|ol|details|aside|li)\b[^>]*>|

~i', $content, $m, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { return $content . $insert; } $depth = 0; $seen = 0; foreach ($m as $tag) { if (strtolower($tag[0][0]) === '

') { if ($depth === 0 && ++$seen === $n) { $at = $tag[0][1] + 4; return substr($content, 0, $at) . $insert . substr($content, $at); } continue; } $depth = max(0, $depth + ($tag[1][0] === '/' ? -1 : 1)); } return $content . $insert; } }