|null A search to write once the page is sent. */ private static ?array $pending = null; public static function boot(): void { add_action('wp', [self::class, 'captureSiteSearch']); add_action('aipcf_semantic_search', [self::class, 'captureSemantic'], 10, 4); add_action('shutdown', [self::class, 'write']); add_action(self::HOOK, [self::class, 'tick']); add_action('init', [self::class, 'schedule'], 20); } public static function enabled(): bool { return !empty(Settings::get('insights_enabled')); } public static function table(): string { global $wpdb; return $wpdb->prefix . 'aipcf_searches'; } public static function install(): void { if ((int) get_option(self::VERSION_OPTION) === self::DB_VERSION) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); dbDelta("CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ts datetime NOT NULL, source varchar(8) NOT NULL, q varchar(200) NOT NULL, q_hash char(32) NOT NULL, results int(10) unsigned NOT NULL DEFAULT 0, score float NULL, top_id bigint(20) unsigned NOT NULL DEFAULT 0, member tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY ts (ts), KEY q_hash (q_hash) ) {$wpdb->get_charset_collate()};"); update_option(self::VERSION_OPTION, self::DB_VERSION, false); } /** * Hourly while insights are on, and while logged searches remain after they are switched off, * so the retention period is kept either way. */ public static function schedule(): void { $next = wp_next_scheduled(self::HOOK); $need = self::enabled() || self::hasRows(); if ($need && !$next) { wp_schedule_event(time() + 600, 'hourly', self::HOOK); } elseif (!$need && $next) { wp_clear_scheduled_hook(self::HOOK); } } private static function hasRows(): bool { global $wpdb; if ((int) get_option(self::VERSION_OPTION) !== self::DB_VERSION) { return false; } return (bool) $wpdb->get_var($wpdb->prepare('SELECT 1 FROM %i LIMIT 1', self::table())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Deletes every logged search and the AI analysis made from them. */ public static function clear(): void { global $wpdb; if ((int) get_option(self::VERSION_OPTION) === self::DB_VERSION) { $wpdb->query($wpdb->prepare('DELETE FROM %i', self::table())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } delete_option(self::REPORT_OPTION); } // ================================================================ recording /** * A front-end WordPress search (first page only), written after the page is sent. */ public static function captureSiteSearch(\WP $wp): void { global $wp_query; if (!self::enabled() || is_admin() || wp_doing_ajax() || is_feed() || !$wp_query instanceof \WP_Query || !$wp_query->is_main_query() || !$wp_query->is_search() || $wp_query->get('aipcf_semantic') || (int) $wp_query->get('paged') > 1) { return; } self::$pending = ['source' => 'wp', 'q' => (string) $wp_query->get('s'), 'results' => (int) $wp_query->found_posts, 'score' => null]; } /** * @param string $q * @param int $results * @param float $top * @param int $topId */ public static function captureSemantic($q, $results, $top, $topId = 0): void { if (self::enabled()) { self::$pending = ['source' => 'semantic', 'q' => (string) $q, 'results' => (int) $results, 'score' => (float) $top, 'top_id' => (int) $topId]; } } /** * Writes the captured search, if any. Never throws: insights must not break a page. */ public static function write(): void { $p = self::$pending; self::$pending = null; if ($p === null) { return; } try { self::record($p['source'], $p['q'], $p['results'], $p['score'], is_user_logged_in(), self::visitorKey(), (int) ($p['top_id'] ?? 0)); } catch (\Throwable $e) { unset($e); // swallowed on purpose } } /** * @return bool Whether a row was written. */ public static function record(string $source, string $q, int $results, ?float $score, bool $member, string $visitor = '', int $topId = 0): bool { global $wpdb; $q = mb_strtolower(trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags($q)))); $q = mb_substr($q, 0, 200); if ($q === '' || mb_strlen($q) < 2) { return false; } $ua = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])) : ''; if ($ua === '' || preg_match(self::BOTS, $ua)) { return false; } // Limits first, before anything is written for this request. self::install(); $today = gmdate('Y-m-d 00:00:00'); $count = (int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE ts >= %s', self::table(), $today)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ($count >= (int) Settings::get('insights_daily_rows') || !self::withinMinute()) { return false; } if ($visitor !== '' && wp_using_ext_object_cache()) { // The same query from the same visitor within an hour counts once. Only with a // persistent object cache: the key never goes to the database, and without such a // cache one options row per visitor and query would let a flood fill the table. if (!wp_cache_add(md5($visitor . '|' . $source . '|' . $q), 1, 'aipcf_seen', HOUR_IN_SECONDS)) { return false; } } return (bool) $wpdb->insert(self::table(), [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery 'ts' => current_time('mysql', true), 'source' => $source === 'semantic' ? 'semantic' : 'wp', 'q' => $q, 'q_hash' => md5($q), 'results' => max(0, $results), 'score' => $score, 'top_id' => max(0, $topId), 'member' => $member ? 1 : 0, ], ['%s', '%s', '%s', '%s', '%d', $score === null ? '%s' : '%f', '%d', '%d']); } /** * Counts this minute's recorded searches site-wide; false once the minute is full. */ private static function withinMinute(): bool { $key = 'aipcf_insights_minute_' . gmdate('YmdHi'); if (wp_using_ext_object_cache()) { wp_cache_add($key, 0, 'aipcf_insights', 2 * MINUTE_IN_SECONDS); return (int) wp_cache_incr($key, 1, 'aipcf_insights') <= self::PER_MINUTE; } // One transient for the whole site, not one per request. $c = get_transient('aipcf_insights_minute'); $c = is_array($c) && ($c['m'] ?? '') === gmdate('YmdHi') ? $c : ['m' => gmdate('YmdHi'), 'n' => 0]; if ((int) $c['n'] >= self::PER_MINUTE) { return false; } $c['n'] = (int) $c['n'] + 1; set_transient('aipcf_insights_minute', $c, 2 * MINUTE_IN_SECONDS); return true; } /** * A one-way key for "the same visitor", from the IP address and the site's salt, used only * for the short-lived repeat check above. */ private static function visitorKey(): string { $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : ''; return $ip === '' ? '' : hash_hmac('sha256', $ip . '|' . gmdate('Y-m-d'), wp_salt('auth')); } // ================================================================ background work public static function tick(): void { if ((int) get_option(self::VERSION_OPTION) === self::DB_VERSION) { self::prune(); // also after insights are switched off: the retention promise holds } if (self::enabled()) { self::install(); self::scoreSiteSearches(); } self::schedule(); } public static function prune(): int { global $wpdb; $before = gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS * (int) Settings::get('insights_retention_days')); return (int) $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE ts < %s', self::table(), $before)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Gives unscored WordPress searches a best-match score and post: each distinct query is * embedded once, in one request, and compared with the index. * * @return int|\WP_Error Distinct queries scored. */ public static function scoreSiteSearches() { global $wpdb; if (!EmbeddingSettings::enabled()) { return 0; } $allowed = Budget::allows(self::TASK); if (is_wp_error($allowed)) { return $allowed; } $left = self::SCORE_DAILY_QUERIES - (int) (UsageTracker::todayTotals()['queries'][self::TASK] ?? 0); if ($left <= 0) { return 0; } $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT q_hash, MAX(q) AS q FROM %i WHERE source = 'wp' AND score IS NULL GROUP BY q_hash ORDER BY MAX(ts) DESC LIMIT %d", self::table(), min(self::SCORE_BATCH, $left) ), ARRAY_A); if (!$rows) { return 0; } $store = StoreFactory::reading(); // the old store during a move or rebuild if (is_wp_error($store)) { return $store; } $vectors = EmbeddingClient::embed(array_column($rows, 'q'), null, self::TASK); if (is_wp_error($vectors)) { return $vectors; } foreach ($rows as $i => $row) { $matches = $store->query($vectors[$i], 3, ['site' => Extractor::siteKey()]); UsageTracker::recordQueries(self::TASK); if (is_wp_error($matches)) { return $matches; } $best = $matches[0] ?? null; $score = $best === null ? 0.0 : (float) $best['score']; $top = $best === null ? 0 : (int) ($best['metadata']['object_id'] ?? 0); $wpdb->query($wpdb->prepare("UPDATE %i SET score = %f, top_id = %d WHERE q_hash = %s AND source = 'wp' AND score IS NULL", self::table(), $score, $top, $row['q_hash'])); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } return count($rows); } // ================================================================ report /** * @return array{total: int, members: int, semantic: int, unscored: int, top: list>, empty: list>, gaps: list>, previous: int} */ public static function report(int $days = 30): array { global $wpdb; self::install(); $t = self::table(); $since = gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS * $days); $prev = gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS * 2 * $days); $gap = (float) Settings::get('insights_gap_threshold'); // phpcs:disable WordPress.DB.DirectDatabaseQuery $totals = (array) $wpdb->get_row($wpdb->prepare("SELECT COUNT(*) AS total, SUM(member) AS members, SUM(source = 'semantic') AS semantic, SUM(score IS NULL) AS unscored FROM %i WHERE ts >= %s", $t, $since), ARRAY_A); $rows = static fn($r): array => array_map(static function (array $r): array { return ['q' => (string) $r['q'], 'n' => (int) $r['n'], 'results' => (int) $r['results'], 'score' => $r['score'] === null ? null : round((float) $r['score'], 3), 'top_id' => (int) $r['top_id']]; }, (array) $r); $out = [ 'total' => (int) ($totals['total'] ?? 0), 'members' => (int) ($totals['members'] ?? 0), 'semantic' => (int) ($totals['semantic'] ?? 0), 'unscored' => (int) ($totals['unscored'] ?? 0), 'top' => $rows($wpdb->get_results($wpdb->prepare( 'SELECT MAX(q) AS q, COUNT(*) AS n, MIN(results) AS results, MAX(score) AS score, MAX(top_id) AS top_id FROM %i WHERE ts >= %s GROUP BY q_hash ORDER BY n DESC, MAX(ts) DESC LIMIT 25', $t, $since ), ARRAY_A)), 'empty' => $rows($wpdb->get_results($wpdb->prepare( "SELECT MAX(q) AS q, COUNT(*) AS n, MIN(results) AS results, MAX(score) AS score, MAX(top_id) AS top_id FROM %i WHERE ts >= %s AND source = 'wp' AND results = 0 GROUP BY q_hash ORDER BY n DESC, MAX(ts) DESC LIMIT 25", $t, $since ), ARRAY_A)), 'gaps' => $rows($wpdb->get_results($wpdb->prepare( 'SELECT MAX(q) AS q, COUNT(*) AS n, MIN(results) AS results, MAX(score) AS score, MAX(top_id) AS top_id FROM %i WHERE ts >= %s AND score IS NOT NULL AND score < %f GROUP BY q_hash ORDER BY n DESC, MAX(ts) DESC LIMIT 25', $t, $since, $gap ), ARRAY_A)), 'previous' => (int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE ts >= %s AND ts < %s', $t, $prev, $since)), ]; // phpcs:enable return $out; } /** * Groups the content gaps into topics with the AI model, and suggests what to write. It only * sees real logged queries and the closest real posts; any post it names that is not one of * those is dropped, and URLs are removed. * * @return array{text: string, at: int, gaps: int}|\WP_Error */ public static function analyse(int $days = 30) { if (!class_exists('\\WordPress\\AiClient\\AiClient')) { return new \WP_Error('aipcf_env', __('The WordPress AI Client is not available.', 'aipcf-ai-provider-for-cloudflare')); } $allowed = Budget::allows(self::TASK); if (is_wp_error($allowed)) { return $allowed; } $gaps = self::report($days)['gaps']; if ($gaps === []) { return new \WP_Error('aipcf_insights_empty', __('No content gaps yet: every scored search found a close enough post.', 'aipcf-ai-provider-for-cloudflare')); } $ids = []; $data = []; foreach ($gaps as $g) { $row = ['search' => $g['q'], 'times' => $g['n'], 'best_match' => $g['score']]; if ($g['top_id'] > 0 && get_post_status($g['top_id']) === 'publish') { $row['closest_post'] = ['id' => $g['top_id'], 'title' => html_entity_decode(get_the_title($g['top_id']), ENT_QUOTES, 'UTF-8')]; $ids[] = $g['top_id']; } $data[] = $row; } $system = sprintf( "You are a content strategist for the website \"%s\". GAPS is a JSON list of searches people made on the site " . "that the site's content did not answer well, with how often each was searched, the best match score (0 to 1) and the closest existing post.\n" . "Group the searches into a few topics, most searched first. For each topic give: a heading, the searches it covers, " . "whether an existing post could be extended (refer to it as [#id], only ids given in GAPS) or a new article is needed, " . "and a suggested title with 3 to 5 outline points.\n" . "Rules: only use the searches given; never invent search terms, numbers or posts; never write a URL. Plain text, no markdown tables.", get_bloginfo('name') ); $settings = get_plugin_settings(); $modelId = get_default_model_id() !== '' ? get_default_model_id() : trim((string) ($settings['light_model'] ?? '')); try { TaskContext::pin(self::TASK); $model = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel('cloudflare-workers-ai', $modelId); $result = \WordPress\AiClient\AiClient::prompt("GAPS:\n" . wp_json_encode($data)) ->usingModel($model) ->usingSystemInstruction($system) ->usingMaxTokens(1200) ->generateTextResult(); $text = trim($result->toText()); } catch (\Throwable $e) { return new \WP_Error('aipcf_insights_ai', $e->getMessage()); } finally { TaskContext::clear(); } $text = (string) preg_replace('~\b(?:https?://|www\.)\S+~i', '', wp_strip_all_tags($text)); $text = (string) preg_replace_callback('/\[#(\d+)\]/', static fn(array $m): string => in_array((int) $m[1], $ids, true) ? $m[0] : '', $text); $out = ['text' => trim($text), 'at' => time(), 'gaps' => count($gaps)]; update_option(self::REPORT_OPTION, $out, false); return $out; } }