The block's matches for this page. */ private static array $best = []; private static string $bestQuery = ''; private static bool $shown = false; public static function boot(): void { add_shortcode('aipcf_search', [self::class, 'shortcode']); add_action('pre_get_posts', [self::class, 'takeOver']); add_action('loop_start', [self::class, 'bestBeforeLoop']); add_filter('render_block_core/query', [self::class, 'bestBeforeQueryBlock'], 10, 2); // For themes that place the block themselves: do_action('aipcf_best_matches') in the // search template, with add_filter('aipcf_best_matches_auto', '__return_false'). add_action('aipcf_best_matches', [self::class, 'printBest']); } public static function enabled(): bool { return !empty(Settings::get('search_enabled')) && EmbeddingSettings::enabled(); } /** * Whether the current user may use semantic search. */ public static function allowed(): bool { return self::enabled() && is_user_logged_in() && current_user_can((string) Settings::get('search_capability')); } public static function normalise(string $q): string { $q = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags($q))); return mb_substr($q, 0, self::MAX_QUERY); } /** * Posts matching a query by meaning, best first, that the current user may read. * * @return list|\WP_Error */ public static function run(string $q, int $limit = 10) { $q = self::normalise($q); if ($q === '') { return []; } $key = 'aipcf_search_' . md5(mb_strtolower($q) . '|' . EmbeddingSettings::signature() . '|' . $limit); $hit = get_transient($key); if (!is_array($hit)) { $limited = self::rateLimited(); if (is_wp_error($limited)) { return $limited; } $allowed = Budget::allows(self::TASK); if (is_wp_error($allowed)) { return $allowed; } // By meaning and by the words typed; ranked by a second model when switched on. $mode = !empty(Settings::get('search_rerank')) ? Retriever::MODE_RERANK : Retriever::MODE_HYBRID; $found = Retriever::find($q, min(20, $limit * 2), self::TASK, $mode); if (is_wp_error($found)) { return $found; } $hit = []; $seen = []; foreach ($found as $m) { if (isset($seen[$m['id']])) { continue; // one result per post, its best part } $seen[$m['id']] = true; $hit[] = ['id' => (int) $m['id'], 'score' => (float) ($m['rerank'] ?? $m['vector']), 'reranked' => $m['rerank'] !== null, 'words' => $m['words'] > 0, 'section' => '', 'text' => (string) $m['text']]; } set_transient($key, $hit, self::CACHE_TTL); } $min = (float) Settings::get('search_min_score'); $out = []; foreach ($hit as $m) { // Checked for this user on every request, cached or not. $match = !empty($m['reranked']) ? $m['score'] >= Retriever::RERANK_MIN : ($m['score'] >= $min || !empty($m['words'])); if (!$match || !self::readable($m['id'])) { continue; } /** * Whether to show the matching passage from the stored text instead of the excerpt. * Off by default: the stored text keeps what shortcodes wrap, so on a site with * members-only or paywalled sections it could quote them to any member. The excerpt * goes through the site's own filters for the person searching. * * @param bool $show * @param int $postId */ $passage = (bool) apply_filters('aipcf_search_show_passage', false, $m['id']); $snippet = $passage ? trim((string) preg_replace('/^Title:.*?\n(Section:.*?\n)?\n/s', '', $m['text'])) : ''; if ($snippet === '') { $snippet = wp_trim_words(get_the_excerpt($m['id']), 30); } $out[] = [ 'id' => $m['id'], 'title' => html_entity_decode(get_the_title($m['id']), ENT_QUOTES, 'UTF-8'), 'url' => (string) get_permalink($m['id']), 'section' => $passage ? $m['section'] : '', 'snippet' => mb_substr($snippet, 0, 240) . (mb_strlen($snippet) > 240 ? '...' : ''), 'score' => $m['score'], 'strength' => self::strength($m), ]; if (count($out) >= $limit) { break; } } /** * After a semantic search (Search insights records it when switched on). * * @param string $q The normalised query. * @param int $results How many results the user was shown. * @param float $top The best score, 0 when there was none. * @param int $topId The best-matching post, 0 when there was none. */ do_action('aipcf_semantic_search', $q, count($out), (float) ($out[0]['score'] ?? 0), (int) ($out[0]['id'] ?? 0)); return $out; } /** * How close a match is, in words: the reranker's score when it ran, else the meaning score. * * @param array{score: float, reranked?: bool, words?: bool} $m */ public static function strength(array $m): string { $very = !empty($m['reranked']) ? 0.5 : 0.7; $near = !empty($m['reranked']) ? 0.15 : 0.6; if ($m['score'] >= $very) { return __('Very close match', 'aipcf-ai-provider-for-cloudflare'); } if ($m['score'] >= $near) { return __('Close match', 'aipcf-ai-provider-for-cloudflare'); } return !empty($m['words']) && empty($m['reranked']) ? __('Has your words', 'aipcf-ai-provider-for-cloudflare') : __('Related', 'aipcf-ai-provider-for-cloudflare'); } /** * Escaped text with the query's words marked, whole words from where they start ("tomato" * marks "tomatoes"). */ public static function highlight(string $text, string $q): string { $words = Retriever::words($q); if ($words === []) { return esc_html($text); } $pattern = '/(? preg_quote($w, '/'), $words)) . ')[\p{L}\p{N}]*)/iu'; $parts = preg_split($pattern, $text, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$text]; $out = ''; foreach ($parts as $i => $part) { $out .= $i % 2 === 1 ? '' . esc_html($part) . '' : esc_html($part); } return $out; } /** * The results list, the same in the shortcode and in the Best matches block. * * @param list $results */ public static function items(array $results, string $q): string { $html = '
    '; foreach ($results as $r) { $html .= '
  1. ' . esc_html($r['title']) . ''; if ($r['section'] !== '') { $html .= ' › ' . esc_html($r['section']) . ''; } $html .= ' ' . esc_html($r['strength']) . ''; $html .= '

    ' . self::highlight($r['snippet'], $q) . '

  2. '; } return $html . '
'; } private static function styles(): void { // Themes that style the results themselves: add_filter('aipcf_search_styles', '__return_false'). if (!apply_filters('aipcf_search_styles', true)) { return; } wp_enqueue_style('aipcf-search', plugin_dir_url(AIPCF_FILE) . 'assets/search/search.css', [], AIPCF_VERSION); } private static function readable(int $id): bool { $p = get_post($id); return $p instanceof \WP_Post && $p->post_password === '' && ($p->post_status === 'publish' || current_user_can('read_post', $id)); } /** * @return true|\WP_Error */ private static function rateLimited() { $key = 'aipcf_search_rate_' . get_current_user_id(); $count = (int) get_transient($key); if ($count >= self::PER_MINUTE) { return new \WP_Error('aipcf_search_rate', __('Too many searches in a minute. Wait a moment and try again.', 'aipcf-ai-provider-for-cloudflare')); } set_transient($key, $count + 1, MINUTE_IN_SECONDS); return true; } // ================================================================ shortcode /** * [aipcf_search placeholder="Search by meaning" results="10"]. Works without JavaScript: * the form submits to the same page. Logged-out visitors get the site's normal search form. * * @param array|string $atts */ public static function shortcode($atts = []): string { if (!self::allowed()) { return get_search_form(['echo' => false]); } $a = shortcode_atts([ 'placeholder' => __('Search by meaning', 'aipcf-ai-provider-for-cloudflare'), 'results' => '10', ], is_array($atts) ? $atts : [], 'aipcf_search'); $q = isset($_GET[self::PARAM]) ? self::normalise(sanitize_text_field(wp_unslash((string) $_GET[self::PARAM]))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a read-only search, like WordPress's own ?s= $id = 'aipcf-search-' . wp_unique_id(); $html = sprintf( '', esc_url(remove_query_arg(self::PARAM)), esc_attr($id), esc_html__('Search', 'aipcf-ai-provider-for-cloudflare'), esc_attr(self::PARAM), esc_attr($q), esc_attr($a['placeholder']), self::MAX_QUERY, esc_html__('Search', 'aipcf-ai-provider-for-cloudflare') ); if ($q === '') { return $html; } self::styles(); $results = self::run($q, max(1, min(20, (int) $a['results']))); $html .= '
'; if (is_wp_error($results)) { // Limits are worth explaining; store or provider errors can name internal hosts. $message = in_array($results->get_error_code(), ['aipcf_search_rate', 'aipcf_budget'], true) || current_user_can('manage_options') ? $results->get_error_message() : __('Search is not available just now. Try again later.', 'aipcf-ai-provider-for-cloudflare'); $html .= '

' . esc_html($message) . '

'; } elseif ($results === []) { /* translators: %s: the search query */ $html .= '

' . esc_html(sprintf(__('Nothing close to "%s" yet.', 'aipcf-ai-provider-for-cloudflare'), $q)) . '

'; } else { $html .= self::items($results, $q); } return $html . '
'; } // ================================================================ the site's search /** * "The site's search": for a member, a Best matches block (found by meaning) sits above the * theme's own results, which stay as they are. When the words typed find nothing, the theme * lists the meaning matches instead. On any problem (limit, store down) the normal search * runs as if nothing happened. */ public static function takeOver(\WP_Query $query): void { if (!is_admin() && $query->is_main_query()) { self::$best = []; // a new page self::$shown = false; } if (is_admin() || !$query->is_main_query() || !$query->is_search() || empty(Settings::get('search_takeover')) || !self::allowed()) { return; } $q = self::normalise((string) $query->get('s')); if ($q === '') { return; } $semantic = self::run($q, 10); if (is_wp_error($semantic) || $semantic === []) { return; } // Keep whatever post types the site's search is limited to (a theme or plugin may have // set them); semantic matches of other types are dropped. $types = $query->get('post_type'); $types = $types === '' || $types === 'any' || $types === [] ? 'any' : (array) $types; $semantic = array_values(array_filter($semantic, static fn(array $r): bool => $types === 'any' || in_array(get_post_type($r['id']), $types, true))); if ($semantic === []) { return; } $best = array_slice($semantic, 0, self::BEST); $keyword = get_posts(['s' => $q, 'post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => count($best), 'orderby' => $query->get('orderby') ?: 'relevance', 'fields' => 'ids', 'suppress_filters' => false]); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- the same filters as the site's search if ($keyword !== []) { // When the theme's first results are already these posts, in this order, the block // would only repeat them. if (array_map('intval', array_column($best, 'id')) !== array_map('intval', $keyword)) { self::$best = $best; self::$bestQuery = $q; self::styles(); } return; } $query->set('post__in', array_column($semantic, 'id')); $query->set('orderby', 'post__in'); $query->set('post_type', $types); $query->set('aipcf_semantic', true); // Keep is_search() and the query text for the template, but not the keyword filter. add_filter('posts_search', [self::class, 'noKeywordFilter'], 10, 2); } /** * The Best matches block, once, above the site's search results. */ public static function bestBlock(): string { if (self::$best === [] || self::$shown) { return ''; } self::$shown = true; $id = 'aipcf-best-' . wp_unique_id(); return '

' . esc_html__('Best matches', 'aipcf-ai-provider-for-cloudflare') . '

' . esc_html__('By meaning, not only the words typed', 'aipcf-ai-provider-for-cloudflare') . '
' . self::items(self::$best, self::$bestQuery) . '
'; } /** * Where a theme asked for it: do_action('aipcf_best_matches'). */ public static function printBest(): void { echo self::bestBlock(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts } /** * Classic themes: printed where the search loop starts. A theme that lays results out in a * grid can turn this off with the aipcf_best_matches_auto filter and print the block itself. */ public static function bestBeforeLoop(\WP_Query $query): void { if ($query->is_main_query() && $query->is_search() && !wp_is_block_theme() && apply_filters('aipcf_best_matches_auto', true)) { echo self::bestBlock(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts } } /** * Block themes: put before the Query Loop block that shows the search results. * * @param string $html * @param array $block */ public static function bestBeforeQueryBlock($html, $block): string { if (!empty($block['attrs']['query']['inherit']) && is_search() && apply_filters('aipcf_best_matches_auto', true)) { return self::bestBlock() . $html; } return (string) $html; } /** * @param string $search The keyword SQL WordPress built. * @param \WP_Query $query */ public static function noKeywordFilter($search, $query): string { if ($query instanceof \WP_Query && $query->get('aipcf_semantic')) { remove_filter('posts_search', [self::class, 'noKeywordFilter'], 10); return ''; } return (string) $search; } }