settings = new Settings(); $factory = new EmbeddingsFactory( $this->settings ); $this->embeddings = $factory->create(); $this->pinecone = new PineconeClient( $this->settings ); } /** * Create/update Pinecone vectors for a post payload. * * @param string $postTitle Post title. * @param int $postId Post ID. * @param string $textBody Post content (text). * @return array{processed:int, vectors:int} * * @since 1.0.0 */ public function upsertDocumentationVectors( string $title, string $link, string $content ): array { try { // Clean and prepare text. $content = wp_strip_all_tags( $content ); $content = trim( $content ); $content = $this->sanitize_utf8( $content ); // Sanitize inputs $link = $this->sanitize_utf8( $link ); $title = $this->sanitize_utf8( $title ); $id = base64_encode( $title ); if ( empty( $content ) ) { return [ 'processed' => 0, 'vectors' => 0, ]; } // Chunk the text. $chunks = $this->chunkText( $content ); // Generate embeddings and prepare vectors. $vectors = []; $expected_dims = $this->settings->get( 'vectorDBProviders.pinecone.embeddingDimensions' ); foreach ( $chunks as $index => $chunk ) { // Sanitize chunk $chunk = $this->sanitize_utf8( $chunk ); // Generate embedding for this chunk. $embedding = $this->embeddings->embedText( $chunk ); if ( is_int( $expected_dims ) && $expected_dims > 0 ) { $actual_dims = is_array( $embedding ) ? count( $embedding ) : 0; if ( $actual_dims !== $expected_dims ) { Log::error( 'Embedding dimension mismatch', [ 'expected' => $expected_dims, 'actual' => $actual_dims, ] ); throw new \Exception( "Embedding dimension {$actual_dims} does not match configured dimension {$expected_dims}. Update settings or index to match." ); } } $vectors[] = [ 'id' => $id, 'values' => $embedding, 'metadata' => [ 'chunk' => $index, 'link' => $this->sanitize_utf8( $link ), 'title' => $this->sanitize_utf8( $title ), 'content' => $this->sanitize_utf8( substr( $chunk, 0, 500 ) ), ], ]; } // Upsert to Pinecone. $this->pinecone->upsert( $vectors ); return [ 'processed' => 1, 'vectors' => count( $vectors ), ]; } catch ( \Exception $e ) { Log::error( "Failed to upsert vectors for link {$id}: {$link}", [ 'error' => $e->getMessage(), ] ); throw $e; } } /** * Chunk text into manageable pieces * * @param string $text Text to chunk. * @param int $chunk_size Approximate chunk size in characters. * @param int $overlap Overlap percentage (0-100). * @return array Array of text chunks. * * @since 1.0.0 */ private function chunkText( string $text, int $chunk_size = 3500, int $overlap = 10 ): array { $chunks = []; $text_length = strlen( $text ); if ( $text_length === 0 ) { return $chunks; } $overlap_size = max( 0, (int) ( $chunk_size * $overlap / 100 ) ); $start = 0; $max_loops = 10000; // hard safety cap to avoid infinite loops $loops = 0; while ( $start < $text_length ) { ++$loops; if ( $loops > $max_loops ) { // Safety break break; } $end = min( $start + $chunk_size, $text_length ); $chunk = substr( $text, $start, $end - $start ); $chunk = trim( $chunk ); if ( $chunk !== '' ) { $chunks[] = $chunk; } // If we've reached the end, break to avoid non-advancing start if ( $end >= $text_length ) { break; } $next_start = $end - $overlap_size; if ( $next_start <= $start ) { // Ensure forward progress $next_start = $end; } $start = $next_start; } return $chunks; } /** * GET request with proper UTF-8 encoding * * @param string $url URL to fetch * @return string Response body * * @since 1.0.0 */ public function http_get_raw( $url ) { $res = wp_remote_get( $url ); if ( is_wp_error( $res ) ) { return ''; } $body = wp_remote_retrieve_body( $res ); // Convert to UTF-8 if needed if ( ! mb_check_encoding( $body, 'UTF-8' ) ) { // Try to detect encoding and convert to UTF-8 $encoding = mb_detect_encoding( $body, [ 'UTF-8', 'ISO-8859-1', 'Windows-1252', 'ASCII' ], true ); if ( $encoding && $encoding !== 'UTF-8' ) { $body = mb_convert_encoding( $body, 'UTF-8', $encoding ); } else { // Fallback: force UTF-8 conversion $body = mb_convert_encoding( $body, 'UTF-8', 'UTF-8' ); } } // Remove any remaining invalid UTF-8 characters $body = $this->sanitize_utf8( $body ); return $body; } /** * Extract links from the BetterDocs container. * * @param string $home_url Home URL * @return array Array of links * * @since 1.0.0 */ public function get_betterdocs_links( $home_url ) { $html = $this->http_get_raw( $home_url ); libxml_use_internal_errors( true ); $dom = new DOMDocument(); @$dom->loadHTML( $html ); libxml_clear_errors(); $xpath = new DOMXPath( $dom ); // Extract ALL $linkNodes = $xpath->query( '//a[@href]' ); $rawLinks = []; foreach ( $linkNodes as $node ) { $href = trim( $node->getAttribute( 'href' ) ); if ( ! $href ) { continue; } $rawLinks[] = $href; } // Convert relative → absolute $finalLinks = []; $baseParsed = wp_parse_url( $home_url ); $base = $baseParsed['scheme'] . '://' . $baseParsed['host']; foreach ( $rawLinks as $href ) { // Skip anchors/mailto/javascript if ( strpos( $href, '#' ) === 0 ) { continue; } if ( strpos( $href, 'mailto:' ) === 0 ) { continue; } if ( strpos( $href, 'javascript:' ) === 0 ) { continue; } // Convert relative URL → absolute if ( strpos( $href, 'http' ) !== 0 ) { if ( 0 !== strpos( $href, '/' ) ) { $href = '/' . ltrim( $href, '/' ); } $href = $base . $href; } // Filter only the required documentation URLs if ( strpos( $href, 'https://support.themewinter.com/docs/plugins/plugin-docs/' ) === 0 ) { $finalLinks[] = $href; } } $finalLinks = array_unique( $finalLinks ); return array_values( $finalLinks ); } /** * Sanitize UTF-8 text - remove invalid characters * * @param string $text Text to sanitize * @return string Sanitized text * * @since 1.0.0 */ private function sanitize_utf8( $text ) { if ( empty( $text ) ) { return $text; } // Remove invalid UTF-8 characters $text = mb_convert_encoding( $text, 'UTF-8', 'UTF-8' ); // Additional cleanup: remove null bytes and other problematic characters $text = str_replace( "\0", '', $text ); // Remove any remaining non-UTF-8 sequences if ( function_exists( 'iconv' ) ) { $text = iconv( 'UTF-8', 'UTF-8//IGNORE', $text ); } return $text; } /** * Clean HTML into text * * @param string $html HTML content * @return string Cleaned text * * @since 1.0.0 */ public function clean_text( $html ) { $text = wp_strip_all_tags( $html ); // clear extra white space $text = trim( preg_replace( '/\s+/', ' ', $text ) ); // Ensure valid UTF-8 $text = $this->sanitize_utf8( $text ); return $text; } /** * Extract main BetterDocs article content using DOM + XPath * * @param string $html HTML content * @return string Article content * * @since 1.0.0 */ public function extract_betterdocs_content( $html ) { libxml_use_internal_errors( true ); // avoid unintentional error may come, while working with dom and html $dom = new DOMDocument(); // Add UTF-8 meta tag to ensure proper encoding $html = '' . $html; // Load HTML with UTF-8 encoding @$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); $xpath = new DOMXPath( $dom ); $selectors = [ "//div[contains(@id, 'betterdocs-single-content')]", ]; $contentHtml = ''; foreach ( $selectors as $query ) { $nodes = $xpath->query( $query ); if ( $nodes && $nodes->length > 0 ) { $contentHtml = $dom->saveHTML( $nodes->item( 0 ) ); break; } } // Fallback: use entire HTML if no content block found if ( empty( $contentHtml ) ) { $contentHtml = $html; } return $this->clean_text( $contentHtml ); } /** * Extract main BetterDocs article title using DOM + XPath * * @param string $html HTML content * @return string Article title * * @since 1.0.0 */ public function extract_betterdocs_title( $html ) { libxml_use_internal_errors( true ); $dom = new DOMDocument(); // Add UTF-8 meta tag to ensure proper encoding $html = '' . $html; // Load HTML with UTF-8 encoding @$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); $xpath = new DOMXPath( $dom ); $selectors = [ '//title', ]; $title = ''; foreach ( $selectors as $query ) { $nodes = $xpath->query( $query ); if ( $nodes && $nodes->length > 0 ) { $title = $dom->saveHTML( $nodes->item( 0 ) ); break; } } // Fallback: use entire HTML if no title block found if ( empty( $title ) ) { $title = $html; } return $this->clean_text( $title ); } /** * Sync documentation: Delete all vectors and re-ingest documentation * * @return array{deleted:bool, processed:int, vectors:int, errors:array} * * @since 1.0.0 */ public function syncDocumentation(): array { $DOC_HOME = 'https://support.themewinter.com/docs/plugins/plugin-docs/eventin/introductions-of-eventin/'; $processed = 0; $total_vectors = 0; $errors = []; try { // deleting previous data from vector db before sync $this->pinecone->deleteAll(); // Extract all documentation link from home page $links = $this->get_betterdocs_links( $DOC_HOME ); // iterate over each link to get title and content foreach ( $links as $index => $link ) { if ( $index > 80 ) { break; } $html = $this->http_get_raw( $link ); $content = $this->extract_betterdocs_content( $html ); $title = $this->extract_betterdocs_title( $html ); if ( ! $content ) { continue; } $result = $this->upsertDocumentationVectors( $title, $link, $content ); ++$processed; $total_vectors += $result['vectors'] ?? 0; } return [ 'deleted' => true, 'processed' => $processed, 'vectors' => $total_vectors, 'errors' => $errors, ]; } catch ( \Exception $e ) { Log::error( 'Vector sync failed', [ 'error' => $e->getMessage(), ] ); throw $e; } } }