chunk_size = $chunk_size > 0 ? $chunk_size : (int) resolver_get_setting('chunk_size'); } /** * Split text into chunks at paragraph boundaries within the character limit. * * With a title, every chunk starts with it, so each one says which page * it's from, not just the first: search then finds a passage by its page, * and the chatbot knows where a passage came from. The title's length * comes out of the limit. * * @param string $text The full text to chunk. * @param string $title The page's title, or '' for none. * @return array Array of chunk strings. */ public function chunk(string $text, string $title = ''): array { $text = trim($text); $title = trim($title); if ('' === $title) { return '' === $text ? [] : $this->chunk_paragraphs($text, $this->chunk_size); } if ('' === $text) { return [$title]; } // At least half a chunk for the text, however long the title. $prefix = $title . "\n\n"; $size = max($this->chunk_size - mb_strlen($prefix), intdiv($this->chunk_size, 2)); return array_map(fn (string $chunk) => $prefix . $chunk, $this->chunk_paragraphs($text, $size)); } /** * Split plain text into chunks of at most $size characters, at paragraph * boundaries. */ private function chunk_paragraphs(string $text, int $size): array { if (mb_strlen($text) <= $size) { return [$text]; } $paragraphs = preg_split('/\n\s*\n/', $text); $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn ($paragraph) => '' !== $paragraph)); $chunks = []; $current_chunk = ''; foreach ($paragraphs as $paragraph) { if (mb_strlen($paragraph) > $size) { if ('' !== $current_chunk) { $chunks[] = trim($current_chunk); $current_chunk = ''; } foreach ($this->split_long_block($paragraph, $size) as $sub) { $chunks[] = $sub; } continue; } if ('' !== $current_chunk && mb_strlen($current_chunk . "\n\n" . $paragraph) > $size) { $chunks[] = trim($current_chunk); $current_chunk = $paragraph; } else { $current_chunk .= ('' === $current_chunk ? '' : "\n\n") . $paragraph; } } if ('' !== $current_chunk) { $chunks[] = trim($current_chunk); } return $chunks; } /** * Split a block longer than a chunk: at sentence ends, and a sentence * that's longer still at spaces, and text with no spaces (Chinese or * Japanese, or a very long URL) at the chunk size itself, so no chunk is * ever longer than the chunk size. * * Sentences end at ".", "!" or "?" followed by spaces, which become a * single space, or at the full-width "。", "!" and "?" that Chinese and * Japanese end sentences with, which aren't followed by a space. */ private function split_long_block(string $text, int $size): array { $marked = (string) preg_replace(['/(?<=[.!?])\s+/u', '/(?<=[。!?])/u'], ["\x00 ", "\x00"], str_replace("\x00", '', $text)); $pieces = []; foreach (explode("\x00", $marked) as $sentence) { if (mb_strlen($sentence) <= $size) { $pieces[] = $sentence; continue; } foreach (preg_split('/(?= )/u', $sentence) as $word) { array_push($pieces, ...mb_str_split($word, $size)); } } return $this->assemble_chunks($pieces, $size); } /** * Join pieces of text, each carrying the space before it if it has one, * into chunks of at most $size characters. */ private function assemble_chunks(array $pieces, int $size): array { $chunks = []; $current = ''; foreach ($pieces as $piece) { if ('' !== trim($current) && mb_strlen(trim($current . $piece)) > $size) { $chunks[] = trim($current); $current = ''; } $current .= $piece; } if ('' !== trim($current)) { $chunks[] = trim($current); } return $chunks; } /** * Extract indexable text from a WordPress post: its title, and the rest of * its text, for chunk() to put the title on every chunk of. * * The title, excerpt and content go through html_to_text(), and the author * name through the parts of it that apply, so only plain text reaches the * embedding API. * * @param int|WP_Post $post The post ID or object. * @return array{title: string, text: string} Both '' when it can't be trained. */ public static function extract_post_content($post): array { $post = get_post($post); if (! $post || ! in_array($post->post_status, RSVL_Indexer::trainable_statuses(), true)) { return ['title' => '', 'text' => '']; } // Build the text as a logged-out visitor sees the page. Shortcodes and // field formatters run as whoever triggered training: the admin who // clicked Retrain, or nobody when WP-Cron runs auto-retrain. Content // shown only to logged-in users or certain roles would otherwise be // trained, then repeated by the chatbot to any visitor. // // This only lowers privileges: $previous_user is the user this request // already authenticated as, and the finally block restores exactly that // user even if a shortcode throws. Restoring also re-runs the core // listeners on set_current_user (kses_init), so later saves in this // request are filtered for the right user again. $previous_user = get_current_user_id(); wp_set_current_user(0); // Blocks and shortcodes read the current post from the global, as they // do inside the_content(); without it they don't know which page // they're on. Restored in the same finally block. Set through $GLOBALS // because `global $post` would overwrite this method's own $post. $previous_post = $GLOBALS['post'] ?? null; $GLOBALS['post'] = $post; // In the site's language, as visitors get it, not the personal // language of the admin who triggered training: the labels written // into the text, and whatever blocks and shortcodes translate. $switched_locale = switch_to_locale(get_locale()); try { $title = self::html_to_text($post->post_title); $parts = []; if (resolver_get_setting('include_author')) { // A display name can't hold shortcodes or block markup, only // stray tags and entities. $author_name = self::collapse_whitespace(self::strip_markup((string) get_the_author_meta('display_name', $post->post_author))); if ('' !== $author_name) { /* translators: %s: the post author's name. Written into the text the AI model is trained on, not shown to visitors, so keep it plain. */ $parts[] = sprintf(__('Author: %s', 'resolver-ai-chatbot'), $author_name); } } if ('' !== $post->post_excerpt && resolver_get_setting('include_excerpts')) { $excerpt = self::html_to_text($post->post_excerpt); if ('' !== $excerpt) { $parts[] = $excerpt; } } $content = self::html_to_text(self::render_blocks($post->post_content)); if ('' !== $content) { $parts[] = $content; } /** * Filter the extracted content before chunking. * Pro can use this to append ACF fields, WooCommerce data, etc. * * @param array $parts Array of text parts to be joined. * @param WP_Post $post The post being indexed. */ self::$last_found_fields = []; $parts = apply_filters('resolver_extract_post_content_parts', $parts, $post); return ['title' => $title, 'text' => implode("\n\n", $parts)]; } finally { if ($switched_locale) { restore_previous_locale(); } $GLOBALS['post'] = $previous_post; wp_set_current_user($previous_user); } } /** * Turn editor HTML into the plain text that gets embedded. * * Used for the title, excerpt and content, since any of them can hold * markup, entities or shortcodes, and for custom field text in the Pro * version, which can too: a WYSIWYG field is editor HTML. Each step is its * own method so text that can't hold some of those, like the author name, * runs only the steps it needs. * * Shortcodes run as whoever is current: during training that's a * logged-out visitor, set up by extract_post_content(). * * @param string $text HTML from the editor. * @return string Plain text. */ public static function html_to_text(string $text): string { $text = self::render_shortcodes($text); $text = self::normalize_line_endings($text); $text = self::headings_to_lines($text); $text = self::break_at_tag_boundaries($text); $text = self::strip_markup($text); return self::collapse_whitespace($text); } /** * Render the post's blocks the way the_content() does, so dynamic blocks * and synced patterns contribute their real output instead of an empty * block comment. Blocks in SKIPPED_BLOCKS are dropped, nested ones too. * * Only for post content: titles and excerpts can't hold blocks. * * @param string $text Post content. * @return string HTML with blocks rendered. */ private static function render_blocks(string $text): string { if (! has_blocks($text)) { return $text; } /** * Filter the blocks dropped, rather than rendered, when extracting a * post's text for training. * * @param string[] $blocks Block names, e.g. 'core/latest-posts'. */ $skipped = (array) apply_filters('resolver_extract_skipped_blocks', self::SKIPPED_BLOCKS); // pre_render_block runs for nested blocks as well as top-level ones; // any non-null return replaces the block's output. $skip = static function ($pre_render, $parsed_block) use ($skipped) { return in_array($parsed_block['blockName'] ?? '', $skipped, true) ? '' : $pre_render; }; add_filter('pre_render_block', $skip, 10, 2); try { return do_blocks($text); } finally { remove_filter('pre_render_block', $skip, 10); } } /** * Run registered shortcodes, then remove any registered ones left over. */ private static function render_shortcodes(string $text): string { return strip_shortcodes(do_shortcode($text)); } /** * Convert \r\n and \r to \n. * * Content saved with Windows line endings would otherwise send stray \r * characters to the embedding API and slip blank lines past * collapse_whitespace(), which only matches \n. */ private static function normalize_line_endings(string $text): string { return str_replace(["\r\n", "\r"], "\n", $text); } /** * Put each heading's text on its own line, before strip_markup() removes * the heading tags. */ private static function headings_to_lines(string $text): string { return preg_replace_callback('/]*>(.*?)<\/h[1-6]>/is', function ($matches) { return "\n" . wp_strip_all_tags($matches[1]) . "\n"; }, $text); } /** * Add whitespace where tags separate words. * * Stripping a tag leaves nothing in its place, so "SizePrice" * would become "SizePrice". End a line at each block boundary and put a * space after table cells and form controls. Only where the markup doesn't * already break the line: a second newline would make a blank line, and * the chunker splits paragraphs on blank lines, so every line of a list or * address would become its own paragraph. */ private static function break_at_tag_boundaries(string $text): string { $text = preg_replace( '#(?:|]*>|)(?![ \t]*\n)#i', '$0' . "\n", $text ); return preg_replace('##i', '$0 ', $text); } /** * Remove tags (and the contents of script and style elements), then decode * entities. Decoding comes second so an encoded "<b>" survives as the * visible text "" rather than being read as a tag. */ private static function strip_markup(string $text): string { return html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES, 'UTF-8'); } /** * Collapse runs of spaces, trim spaces at line edges, allow at most one * blank line in a row, and trim the result. */ private static function collapse_whitespace(string $text): string { // \h also covers the non-breaking space that   decodes to. The /u // flag makes preg_replace() return null on invalid UTF-8 (possible in // old imported content), so fall back to plain spaces and tabs then. $collapsed = preg_replace('/\h+/u', ' ', $text); $text = null === $collapsed ? preg_replace('/[ \t]+/', ' ', $text) : $collapsed; $text = preg_replace('/ ?\n ?/', "\n", $text); $text = preg_replace('/\n{3,}/', "\n\n", $text); return trim($text); } }