* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; use League\HTMLToMarkdown\HtmlConverter; use League\HTMLToMarkdown\Converter\TableConverter; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Chunk_Extractor class. */ class Chunk_Extractor { use Singleton; /** * Default maximum tokens per chunk for embedding models */ public const DEFAULT_MAX_CHUNK_TOKENS = 8000; /** * Token buffer ratio to account for tokenizer differences (10%) */ private const TOKEN_BUFFER_RATIO = 0.9; /** * Minimum tokens per chunk - chunks below this threshold are merged */ private const MIN_CHUNK_TOKENS = 50; /** * Extract chunks from post content for embedding generation. * * Implements section-based chunking with markdown conversion. Content is first * converted to markdown, then split by headings to keep sections together. * Large sections are further split to respect token limits. Small chunks are * merged with adjacent chunks. Includes post metadata (title, type) as first chunk. * * Returns a result array containing: * - 'chunks': Array of chunk objects with 'content', 'source', 'position', 'length' * - 'content_md5': MD5 hash of the rendered markdown content (for change detection) * * Chunk sources: * - 'title': Generated from post title and type * - 'excerpt': From post excerpt * - 'content': From markdown-rendered post content (has position/length) * * @param \WP_Post $post The post to extract chunks from * @param int $max_tokens Maximum tokens per chunk (default: 8000) * @return array Result array with 'chunks' and 'content_md5' keys * @since 1.0.0 */ public function extract_chunks( $post, $max_tokens = self::DEFAULT_MAX_CHUNK_TOKENS ) { $chunks = array(); $effective_max = (int) ( $max_tokens * self::TOKEN_BUFFER_RATIO ); $token_counter = Token_Counter::get_instance(); // Get basic post metadata for context $post_type = $post->post_type; // Add title as first chunk with minimal context (if not empty) if ( ! empty( trim( $post->post_title ) ) ) { $title_chunk = String_Utils::replace_tags( __( '{type}: {title}', 'agenistrator' ), array( 'type' => esc_html( ucfirst( $post_type ) ), 'title' => esc_html( trim( $post->post_title ) ), ) ); $chunks[] = array( 'content' => $title_chunk, 'source' => 'title', 'position' => null, 'length' => null, ); } // Add excerpt as chunk (if exists) if ( ! empty( trim( $post->post_excerpt ) ) ) { $chunks[] = array( 'content' => trim( $post->post_excerpt ), 'source' => 'excerpt', 'position' => null, 'length' => null, ); } // Convert content to markdown $markdown = $this->convert_post_to_markdown( $post ); $content_md5 = md5( $markdown ); if ( ! empty( $markdown ) ) { // Extract content chunks with position tracking $content_chunks = $this->extract_content_chunks( $markdown, $effective_max, $token_counter ); $chunks = array_merge( $chunks, $content_chunks ); } return array( 'chunks' => $chunks, 'content_md5' => $content_md5, ); } /** * Extract chunks from markdown content with position tracking. * * Uses section-based chunking: splits on markdown headings to keep headings * with their content. Small chunks are merged with adjacent chunks. * * @param string $markdown The full markdown content * @param int $effective_max Maximum tokens per chunk (after buffer applied) * @param Token_Counter $token_counter Token counter instance * @return array Array of chunk objects with 'content', 'source', 'position', 'length' */ private function extract_content_chunks( $markdown, $effective_max, $token_counter ) { // Split into sections based on markdown headings $sections = $this->split_into_sections( $markdown ); $chunks = array(); foreach ( $sections as $section ) { $content = rtrim( $section['content'] ); $position = $section['position']; if ( empty( trim( $content ) ) ) { continue; } // Check if section fits within token limit $token_count = $token_counter->count_tokens( $content ); if ( $token_count <= $effective_max ) { $chunks[] = array( 'content' => $content, 'source' => 'content', 'position' => $position, 'length' => strlen( $content ), ); } else { // Split large section into smaller chunks with position tracking $sub_chunks = $this->split_text_by_tokens_with_positions( $content, $position, $effective_max, $token_counter ); $chunks = array_merge( $chunks, $sub_chunks ); } } // Merge small chunks with adjacent chunks return $this->merge_small_chunks( $chunks, $token_counter, $effective_max ); } /** * Split markdown content into sections based on headings. * * Each section starts with a heading (or is content before the first heading) * and includes all content until the next heading of equal or higher level. * * @param string $markdown The markdown content * @return array Array of sections with 'content' and 'position' keys */ private function split_into_sections( $markdown ) { $sections = array(); // Find all heading positions with their levels // Matches lines starting with 1-6 # characters followed by space preg_match_all( '/^(#{1,6}) /m', $markdown, $matches, PREG_OFFSET_CAPTURE ); if ( empty( $matches[0] ) ) { // No headings - return entire content as single section $content = trim( $markdown ); if ( ! empty( $content ) ) { $sections[] = array( 'content' => $content, 'position' => 0, ); } return $sections; } // Extract heading positions $heading_positions = array(); foreach ( $matches[0] as $index => $match ) { $heading_positions[] = array( 'offset' => $match[1], 'level' => strlen( $matches[1][ $index ][0] ), ); } // Content before first heading (if any) $first_heading_pos = $heading_positions[0]['offset']; if ( $first_heading_pos > 0 ) { $content = trim( substr( $markdown, 0, $first_heading_pos ) ); if ( ! empty( $content ) ) { $sections[] = array( 'content' => $content, 'position' => 0, ); } } // Extract each section (heading + content until next heading) $total_headings = count( $heading_positions ); for ( $i = 0; $i < $total_headings; $i++ ) { $start = $heading_positions[ $i ]['offset']; $end = ( $i + 1 < $total_headings ) ? $heading_positions[ $i + 1 ]['offset'] : strlen( $markdown ); $content = trim( substr( $markdown, $start, $end - $start ) ); if ( ! empty( $content ) ) { $sections[] = array( 'content' => $content, 'position' => $start, ); } } return $sections; } /** * Merge chunks that are below the minimum token threshold. * * Small chunks are merged with the following chunk to avoid fragmentation. * This keeps related content together (e.g., a short heading with its content). * * @param array $chunks Array of chunk objects * @param Token_Counter $token_counter Token counter instance * @param int $max_tokens Maximum tokens per chunk * @return array Merged array of chunk objects */ private function merge_small_chunks( $chunks, $token_counter, $max_tokens ) { if ( count( $chunks ) <= 1 ) { return $chunks; } $merged = array(); $pending = null; foreach ( $chunks as $chunk ) { if ( null === $pending ) { $pending = $chunk; continue; } $pending_tokens = $token_counter->count_tokens( $pending['content'] ); // If pending chunk is small, try to merge with current chunk if ( $pending_tokens < self::MIN_CHUNK_TOKENS ) { $combined_content = $pending['content'] . "\n\n" . $chunk['content']; $combined_tokens = $token_counter->count_tokens( $combined_content ); if ( $combined_tokens <= $max_tokens ) { // Merge: update pending with combined content $pending = array( 'content' => $combined_content, 'source' => 'content', 'position' => $pending['position'], 'length' => strlen( $combined_content ), ); continue; } } // Can't merge - save pending and move to next $merged[] = $pending; $pending = $chunk; } // Don't forget the last pending chunk if ( null !== $pending ) { $merged[] = $pending; } return $merged; } /** * Convert post HTML content to markdown format. * * Uses raw post_content without shortcode expansion to ensure embeddings * remain stable (shortcode output can change without post updates). * * @param \WP_Post $post The post to convert * @return string Markdown-formatted content * @since 1.0.0 */ public function convert_post_to_markdown( $post ) { $html = $post->post_content; if ( empty( trim( $html ) ) ) { return ''; } // Wrap in minimal HTML structure to ensure proper parsing $html = '' . $html . ''; try { $converter = new HtmlConverter( array( 'strip_tags' => true, 'remove_nodes' => 'script style', 'hard_break' => true, 'header_style' => 'atx', 'suppress_errors' => true, ) ); // Enable table support (not enabled by default in league/html-to-markdown) $converter->getEnvironment()->addConverter( new TableConverter() ); return $converter->convert( $html ); } catch ( \Throwable $e ) { $error_message = String_Utils::replace_tags( __( 'Agenistrator: HTML to markdown conversion failed for post {post_id}: {error}', 'agenistrator' ), array( 'post_id' => $post->ID, 'error' => $e->getMessage(), ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for content extraction failures error_log( $error_message ); // Return plain text fallback $plain_text = wp_strip_all_tags( $post->post_content ); return trim( $plain_text ); } } /** * Reconstruct a chunk from a post based on stored metadata. * * Used to retrieve chunk content when serving search results without * storing the actual chunk text in the database. * * @param \WP_Post $post The post to extract chunk from * @param string $source Chunk source: 'title', 'excerpt', or 'content' * @param int|null $position Byte position in markdown content (for 'content' source) * @param int|null $length Byte length of chunk (for 'content' source) * @param string|null $expected_md5 Expected MD5 hash of rendered markdown (for 'content' source) * @return string|null The chunk content, or null if content has changed or invalid metadata * @since 1.0.0 */ public function reconstruct_chunk( $post, $source, $position = null, $length = null, $expected_md5 = null ) { switch ( $source ) { case 'title': return String_Utils::replace_tags( __( '{type}: {title}', 'agenistrator' ), array( 'type' => esc_html( ucfirst( $post->post_type ) ), 'title' => esc_html( trim( $post->post_title ) ), ) ); case 'excerpt': return trim( $post->post_excerpt ); case 'content': // Validate required parameters if ( null === $position || null === $length || null === $expected_md5 ) { return null; } // Re-render markdown and check if it has changed $markdown = $this->convert_post_to_markdown( $post ); $current_md5 = md5( $markdown ); if ( $current_md5 !== $expected_md5 ) { return null; } // Extract chunk using byte offset/length return substr( $markdown, $position, $length ); default: return null; } } /** * Split text into chunks that fit within a token limit, tracking positions. * * First attempts to split at sentence boundaries, then falls back to * word boundaries for very long sentences. * * @param string $text The text to split * @param int $base_position Position of this text in the full markdown * @param int $max_tokens Maximum tokens per chunk * @param Token_Counter $token_counter Token counter instance * @return array Array of chunk objects with 'content', 'source', 'position', 'length' */ private function split_text_by_tokens_with_positions( $text, $base_position, $max_tokens, $token_counter ) { $chunks = array(); // Try splitting at sentence boundaries first $sentences = preg_split( '/(?<=[.!?])\s+/', $text, -1, PREG_SPLIT_NO_EMPTY ); $current_chunk = ''; $current_chunk_start = 0; $current_pos_in_text = 0; foreach ( $sentences as $sentence ) { $sentence_trimmed = trim( $sentence ); if ( empty( $sentence_trimmed ) ) { continue; } // Find position of this sentence in the text $sentence_pos = strpos( $text, $sentence_trimmed, $current_pos_in_text ); if ( false === $sentence_pos ) { $sentence_pos = $current_pos_in_text; } $sentence_tokens = $token_counter->count_tokens( $sentence_trimmed ); // If single sentence exceeds limit, split by words if ( $sentence_tokens > $max_tokens ) { // Flush current chunk first if ( ! empty( $current_chunk ) ) { $chunks[] = array( 'content' => trim( $current_chunk ), 'source' => 'content', 'position' => $base_position + $current_chunk_start, 'length' => strlen( trim( $current_chunk ) ), ); $current_chunk = ''; } // Split sentence by words with position tracking $word_chunks = $this->split_by_words_with_positions( $sentence_trimmed, $base_position + $sentence_pos, $max_tokens, $token_counter ); $chunks = array_merge( $chunks, $word_chunks ); $current_pos_in_text = $sentence_pos + strlen( $sentence_trimmed ); $current_chunk_start = $current_pos_in_text; continue; } // Check if adding this sentence would exceed limit $test_chunk = empty( $current_chunk ) ? $sentence_trimmed : $current_chunk . ' ' . $sentence_trimmed; $test_tokens = $token_counter->count_tokens( $test_chunk ); if ( $test_tokens > $max_tokens ) { // Save current chunk and start new one if ( ! empty( $current_chunk ) ) { $chunks[] = array( 'content' => trim( $current_chunk ), 'source' => 'content', 'position' => $base_position + $current_chunk_start, 'length' => strlen( trim( $current_chunk ) ), ); } $current_chunk = $sentence_trimmed; $current_chunk_start = $sentence_pos; } else { if ( empty( $current_chunk ) ) { $current_chunk_start = $sentence_pos; } $current_chunk = $test_chunk; } $current_pos_in_text = $sentence_pos + strlen( $sentence_trimmed ); } // Don't forget the last chunk if ( ! empty( $current_chunk ) ) { $chunks[] = array( 'content' => trim( $current_chunk ), 'source' => 'content', 'position' => $base_position + $current_chunk_start, 'length' => strlen( trim( $current_chunk ) ), ); } return $chunks; } /** * Split text by words when sentence splitting isn't sufficient, tracking positions. * * @param string $text The text to split * @param int $base_position Position of this text in the full markdown * @param int $max_tokens Maximum tokens per chunk * @param Token_Counter $token_counter Token counter instance * @return array Array of chunk objects with 'content', 'source', 'position', 'length' */ private function split_by_words_with_positions( $text, $base_position, $max_tokens, $token_counter ) { $chunks = array(); $words = preg_split( '/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY ); $current_chunk = ''; $current_chunk_start = 0; $current_pos_in_text = 0; foreach ( $words as $word ) { // Find position of this word in the text $word_pos = strpos( $text, $word, $current_pos_in_text ); if ( false === $word_pos ) { $word_pos = $current_pos_in_text; } $test_chunk = empty( $current_chunk ) ? $word : $current_chunk . ' ' . $word; $test_tokens = $token_counter->count_tokens( $test_chunk ); if ( $test_tokens > $max_tokens ) { if ( ! empty( $current_chunk ) ) { $chunks[] = array( 'content' => trim( $current_chunk ), 'source' => 'content', 'position' => $base_position + $current_chunk_start, 'length' => strlen( trim( $current_chunk ) ), ); } $current_chunk = $word; $current_chunk_start = $word_pos; } else { if ( empty( $current_chunk ) ) { $current_chunk_start = $word_pos; } $current_chunk = $test_chunk; } $current_pos_in_text = $word_pos + strlen( $word ); } if ( ! empty( $current_chunk ) ) { $chunks[] = array( 'content' => trim( $current_chunk ), 'source' => 'content', 'position' => $base_position + $current_chunk_start, 'length' => strlen( trim( $current_chunk ) ), ); } return $chunks; } }