doc2vec 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/doc2vec.svg)](https://www.npmjs.com/package/doc2vec)
4
4
 
5
- This project provides a configurable tool (`doc2vec`) to crawl specified websites (typically documentation sites), GitHub repositories, and local directories, extract relevant content, convert it to Markdown, chunk it intelligently, generate vector embeddings using OpenAI, and store the chunks along with their embeddings in a vector database (SQLite with `sqlite-vec` or Qdrant).
5
+ This project provides a configurable tool (`doc2vec`) to crawl specified websites (typically documentation sites), GitHub repositories, local directories, and Zendesk support systems, extract relevant content, convert it to Markdown, chunk it intelligently, generate vector embeddings using OpenAI, and store the chunks along with their embeddings in a vector database (SQLite with `sqlite-vec` or Qdrant).
6
6
 
7
7
  The primary goal is to prepare documentation content for Retrieval-Augmented Generation (RAG) systems or semantic search applications.
8
8
 
@@ -12,6 +12,11 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
12
12
  * **Sitemap Support:** Extracts URLs from XML sitemaps to discover pages not linked in navigation.
13
13
  * **PDF Support:** Automatically downloads and processes PDF files linked from websites.
14
14
  * **GitHub Issues Integration:** Retrieves GitHub issues and comments, processing them into searchable chunks.
15
+ * **Zendesk Integration:** Fetches support tickets and knowledge base articles from Zendesk, converting them to searchable chunks.
16
+ * **Support Tickets:** Processes tickets with metadata, descriptions, and comments.
17
+ * **Knowledge Base Articles:** Converts help center articles from HTML to clean Markdown.
18
+ * **Incremental Updates:** Only processes tickets/articles updated since the last run.
19
+ * **Flexible Filtering:** Filter tickets by status and priority.
15
20
  * **Local Directory Processing:** Scans local directories for files, converts content to searchable chunks.
16
21
  * **PDF Support:** Automatically extracts text from PDF files and converts them to Markdown format using Mozilla's PDF.js.
17
22
  * **Content Extraction:** Uses Puppeteer for rendering JavaScript-heavy pages and `@mozilla/readability` to extract the main article content.
@@ -22,9 +27,9 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
22
27
  * **SQLite:** Using `better-sqlite3` and the `sqlite-vec` extension for efficient vector search.
23
28
  * **Qdrant:** A dedicated vector database, using the `@qdrant/js-client-rest`.
24
29
  * **Change Detection:** Uses content hashing to detect changes and only re-embeds and updates chunks that have actually been modified.
25
- * **Incremental Updates:** For GitHub sources, tracks the last run date to only fetch new or updated issues.
30
+ * **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
26
31
  * **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
27
- * **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, database types, metadata, and other parameters.
32
+ * **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, Zendesk instances, database types, metadata, and other parameters.
28
33
  * **Structured Logging:** Uses a custom logger (`logger.ts`) with levels, timestamps, colors, progress bars, and child loggers for clear execution monitoring.
29
34
 
30
35
  ## Prerequisites
@@ -34,6 +39,7 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
34
39
  * **TypeScript:** As the project is written in TypeScript (`ts-node` is used for execution via `npm start`).
35
40
  * **OpenAI API Key:** You need an API key from OpenAI to generate embeddings.
36
41
  * **GitHub Personal Access Token:** Required for accessing GitHub issues (set as `GITHUB_PERSONAL_ACCESS_TOKEN` in your environment).
42
+ * **Zendesk API Token:** Required for accessing Zendesk tickets and articles (set as `ZENDESK_API_TOKEN` in your environment).
37
43
  * **(Optional) Qdrant Instance:** If using the `qdrant` database type, you need a running Qdrant instance accessible from where you run the script.
38
44
  * **(Optional) Build Tools:** Dependencies like `better-sqlite3` and `sqlite-vec` might require native compilation, which could necessitate build tools like `python`, `make`, and a C++ compiler (like `g++` or Clang) depending on your operating system.
39
45
 
@@ -68,6 +74,9 @@ Configuration is managed through two files:
68
74
  # Required for GitHub sources
69
75
  GITHUB_PERSONAL_ACCESS_TOKEN="ghp_..."
70
76
 
77
+ # Required for Zendesk sources
78
+ ZENDESK_API_TOKEN="your-zendesk-api-token"
79
+
71
80
  # Optional: Required only if using Qdrant
72
81
  QDRANT_API_KEY="your-qdrant-api-key"
73
82
  ```
@@ -78,7 +87,7 @@ Configuration is managed through two files:
78
87
  **Structure:**
79
88
 
80
89
  * `sources`: An array of source configurations.
81
- * `type`: Either `'website'`, `'github'`, or `'local_directory'`
90
+ * `type`: Either `'website'`, `'github'`, `'local_directory'`, or `'zendesk'`
82
91
 
83
92
  For websites (`type: 'website'`):
84
93
  * `url`: The starting URL for crawling the documentation site.
@@ -96,6 +105,16 @@ Configuration is managed through two files:
96
105
  * `url_rewrite_prefix` (Optional) URL prefix to rewrite `file://` URLs (e.g., `https://mydomain.com`)
97
106
  * `encoding`: (Optional) File encoding to use (defaults to `'utf8'`). Note: PDF files are processed as binary and this setting doesn't apply to them.
98
107
 
108
+ For Zendesk (`type: 'zendesk'`):
109
+ * `zendesk_subdomain`: Your Zendesk subdomain (e.g., `'mycompany'` for mycompany.zendesk.com).
110
+ * `email`: Your Zendesk admin email address.
111
+ * `api_token`: Your Zendesk API token (reference environment variable as `'${ZENDESK_API_TOKEN}'`).
112
+ * `fetch_tickets`: (Optional) Whether to fetch support tickets (defaults to `true`).
113
+ * `fetch_articles`: (Optional) Whether to fetch knowledge base articles (defaults to `true`).
114
+ * `start_date`: (Optional) Only process tickets/articles updated since this date (e.g., `'2025-01-01'`).
115
+ * `ticket_status`: (Optional) Filter tickets by status (defaults to `['new', 'open', 'pending', 'hold', 'solved']`).
116
+ * `ticket_priority`: (Optional) Filter tickets by priority (defaults to all priorities).
117
+
99
118
  Common configuration for all types:
100
119
  * `product_name`: A string identifying the product (used in metadata).
101
120
  * `version`: A string identifying the product version (used in metadata).
@@ -150,6 +169,24 @@ Configuration is managed through two files:
150
169
  params:
151
170
  db_path: './project-docs.db'
152
171
 
172
+ # Zendesk example
173
+ - type: 'zendesk'
174
+ product_name: 'MyCompany'
175
+ version: 'latest'
176
+ zendesk_subdomain: 'mycompany'
177
+ email: 'admin@mycompany.com'
178
+ api_token: '${ZENDESK_API_TOKEN}'
179
+ fetch_tickets: true
180
+ fetch_articles: true
181
+ start_date: '2025-01-01'
182
+ ticket_status: ['open', 'pending']
183
+ ticket_priority: ['high']
184
+ max_size: 1048576
185
+ database_config:
186
+ type: 'sqlite'
187
+ params:
188
+ db_path: './zendesk-kb.db'
189
+
153
190
  # Qdrant example
154
191
  - type: 'website'
155
192
  product_name: 'Istio'
@@ -188,6 +225,7 @@ The script will then:
188
225
  - For websites: Crawl the site, process any sitemaps, extract content from HTML pages and download/process PDF files, convert to Markdown
189
226
  - For GitHub repos: Fetch issues and comments, convert to Markdown
190
227
  - For local directories: Scan files, process content (converting HTML and PDF files to Markdown if needed)
228
+ - For Zendesk: Fetch tickets and articles, convert to Markdown
191
229
  6. For all sources: Chunk content, check for changes, generate embeddings (if needed), and store/update in the database.
192
230
  7. Cleanup obsolete chunks.
193
231
  8. Output detailed logs.
@@ -296,6 +334,11 @@ If you don't specify a config path, it will look for config.yaml in the current
296
334
  * Read file content, converting HTML to Markdown if needed.
297
335
  * For PDF files, extract text using Mozilla's PDF.js and convert to Markdown format with proper page structure.
298
336
  * Process each file's content.
337
+ - **For Zendesk:**
338
+ * Fetch tickets and articles using the Zendesk API.
339
+ * Convert tickets to formatted Markdown.
340
+ * Convert articles to formatted Markdown.
341
+ * Track last run date to support incremental updates.
299
342
  3. **Process Content:** For each processed page, issue, or file:
300
343
  * **Chunk:** Split Markdown into smaller `DocumentChunk` objects based on headings and size.
301
344
  * **Hash Check:** Generate a hash of the chunk content. Check if a chunk with the same ID exists in the DB and if its hash matches.
@@ -139,6 +139,29 @@ class ContentProcessor {
139
139
  });
140
140
  logger.debug('Turndown rules setup complete');
141
141
  }
142
+ convertHtmlToMarkdown(html) {
143
+ if (!html || !html.trim()) {
144
+ return '';
145
+ }
146
+ // Sanitize the HTML first
147
+ const cleanHtml = (0, sanitize_html_1.default)(html, {
148
+ allowedTags: [
149
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
150
+ 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
151
+ 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
152
+ 'blockquote', 'br'
153
+ ],
154
+ allowedAttributes: {
155
+ 'a': ['href'],
156
+ 'pre': ['class', 'data-language'],
157
+ 'code': ['class', 'data-language'],
158
+ 'div': ['class'],
159
+ 'span': ['class']
160
+ }
161
+ });
162
+ // Convert to markdown using TurndownService
163
+ return this.turndownService.turndown(cleanHtml).trim();
164
+ }
142
165
  async parseSitemap(sitemapUrl, logger) {
143
166
  logger.info(`Parsing sitemap from ${sitemapUrl}`);
144
167
  try {
package/dist/doc2vec.js CHANGED
@@ -42,6 +42,7 @@ const crypto_1 = __importDefault(require("crypto"));
42
42
  const yaml = __importStar(require("js-yaml"));
43
43
  const fs = __importStar(require("fs"));
44
44
  const path = __importStar(require("path"));
45
+ const buffer_1 = require("buffer");
45
46
  const openai_1 = require("openai");
46
47
  const dotenv = __importStar(require("dotenv"));
47
48
  const logger_1 = require("./logger");
@@ -67,7 +68,17 @@ class Doc2Vec {
67
68
  try {
68
69
  const logger = this.logger.child('config');
69
70
  logger.info(`Loading configuration from ${configPath}`);
70
- const configFile = fs.readFileSync(configPath, 'utf8');
71
+ let configFile = fs.readFileSync(configPath, 'utf8');
72
+ // Substitute environment variables in the format ${VAR_NAME}
73
+ configFile = configFile.replace(/\$\{([^}]+)\}/g, (match, varName) => {
74
+ const envValue = process.env[varName];
75
+ if (envValue === undefined) {
76
+ logger.warn(`Environment variable ${varName} not found, keeping placeholder ${match}`);
77
+ return match;
78
+ }
79
+ logger.debug(`Substituted ${match} with environment variable value`);
80
+ return envValue;
81
+ });
71
82
  let config = yaml.load(configFile);
72
83
  const typedConfig = config;
73
84
  logger.info(`Configuration loaded successfully, found ${typedConfig.sources.length} sources`);
@@ -92,6 +103,9 @@ class Doc2Vec {
92
103
  else if (sourceConfig.type === 'local_directory') {
93
104
  await this.processLocalDirectory(sourceConfig, sourceLogger);
94
105
  }
106
+ else if (sourceConfig.type === 'zendesk') {
107
+ await this.processZendesk(sourceConfig, sourceLogger);
108
+ }
95
109
  else {
96
110
  sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
97
111
  }
@@ -493,6 +507,344 @@ class Doc2Vec {
493
507
  }
494
508
  logger.info(`Finished processing local directory: ${config.path}`);
495
509
  }
510
+ async processZendesk(config, parentLogger) {
511
+ const logger = parentLogger.child('process');
512
+ logger.info(`Starting processing for Zendesk: ${config.zendesk_subdomain}.zendesk.com`);
513
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
514
+ // Initialize metadata storage
515
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
516
+ const fetchTickets = config.fetch_tickets !== false; // default true
517
+ const fetchArticles = config.fetch_articles !== false; // default true
518
+ if (fetchTickets) {
519
+ logger.section('ZENDESK TICKETS');
520
+ await this.fetchAndProcessZendeskTickets(config, dbConnection, logger);
521
+ }
522
+ if (fetchArticles) {
523
+ logger.section('ZENDESK ARTICLES');
524
+ await this.fetchAndProcessZendeskArticles(config, dbConnection, logger);
525
+ }
526
+ logger.info(`Finished processing Zendesk: ${config.zendesk_subdomain}.zendesk.com`);
527
+ }
528
+ async fetchAndProcessZendeskTickets(config, dbConnection, logger) {
529
+ const baseUrl = `https://${config.zendesk_subdomain}.zendesk.com/api/v2`;
530
+ const auth = buffer_1.Buffer.from(`${config.email}/token:${config.api_token}`).toString('base64');
531
+ // Get the last run date from the database
532
+ const startDate = config.start_date || `${new Date().getFullYear()}-01-01`;
533
+ const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, `${startDate}T00:00:00Z`, logger);
534
+ const fetchWithRetry = async (url, retries = 3) => {
535
+ for (let attempt = 0; attempt < retries; attempt++) {
536
+ try {
537
+ const response = await axios_1.default.get(url, {
538
+ headers: {
539
+ 'Authorization': `Basic ${auth}`,
540
+ 'Content-Type': 'application/json',
541
+ },
542
+ });
543
+ if (response.status === 429) {
544
+ const retryAfter = parseInt(response.headers['retry-after'] || '60');
545
+ logger.warn(`Rate limited, waiting ${retryAfter}s before retry`);
546
+ await new Promise(res => setTimeout(res, retryAfter * 1000));
547
+ continue;
548
+ }
549
+ return response.data;
550
+ }
551
+ catch (error) {
552
+ logger.error(`Zendesk API error (attempt ${attempt + 1}):`, error.message);
553
+ if (attempt === retries - 1)
554
+ throw error;
555
+ await new Promise(res => setTimeout(res, 2000 * (attempt + 1)));
556
+ }
557
+ }
558
+ };
559
+ const generateMarkdownForTicket = (ticket, comments) => {
560
+ let md = `# Ticket #${ticket.id}: ${ticket.subject}\n\n`;
561
+ md += `- **Status:** ${ticket.status}\n`;
562
+ md += `- **Priority:** ${ticket.priority || 'None'}\n`;
563
+ md += `- **Type:** ${ticket.type || 'None'}\n`;
564
+ md += `- **Requester:** ${ticket.requester_id}\n`;
565
+ md += `- **Assignee:** ${ticket.assignee_id || 'Unassigned'}\n`;
566
+ md += `- **Created:** ${new Date(ticket.created_at).toDateString()}\n`;
567
+ md += `- **Updated:** ${new Date(ticket.updated_at).toDateString()}\n`;
568
+ if (ticket.tags && ticket.tags.length > 0) {
569
+ md += `- **Tags:** ${ticket.tags.map((tag) => `\`${tag}\``).join(', ')}\n`;
570
+ }
571
+ // Handle ticket description
572
+ const description = ticket.description || '';
573
+ const cleanDescription = description || '_No description._';
574
+ md += `\n## Description\n\n${cleanDescription}\n\n`;
575
+ if (comments && comments.length > 0) {
576
+ md += `## Comments\n\n`;
577
+ for (const comment of comments) {
578
+ if (comment.public) {
579
+ md += `### ${comment.author_id} - ${new Date(comment.created_at).toDateString()}\n\n`;
580
+ // Handle comment body
581
+ const rawBody = comment.plain_body || comment.html_body || comment.body || '';
582
+ const commentBody = rawBody.replace(/&nbsp;/g, " ") || '_No content._';
583
+ md += `${commentBody}\n\n---\n\n`;
584
+ }
585
+ }
586
+ }
587
+ else {
588
+ md += `## Comments\n\n_No comments._\n`;
589
+ }
590
+ return md;
591
+ };
592
+ const processTicket = async (ticket) => {
593
+ const ticketId = ticket.id;
594
+ const url = `https://${config.zendesk_subdomain}.zendesk.com/agent/tickets/${ticketId}`;
595
+ logger.info(`Processing ticket #${ticketId}`);
596
+ // Fetch ticket comments
597
+ const commentsUrl = `${baseUrl}/tickets/${ticketId}/comments.json`;
598
+ const commentsData = await fetchWithRetry(commentsUrl);
599
+ const comments = commentsData?.comments || [];
600
+ // Generate markdown for the ticket
601
+ const markdown = generateMarkdownForTicket(ticket, comments);
602
+ // Chunk the markdown content
603
+ const ticketConfig = {
604
+ ...config,
605
+ product_name: config.product_name || `zendesk_${config.zendesk_subdomain}`,
606
+ max_size: config.max_size || Infinity
607
+ };
608
+ const chunks = await this.contentProcessor.chunkMarkdown(markdown, ticketConfig, url);
609
+ logger.info(`Ticket #${ticketId}: Created ${chunks.length} chunks`);
610
+ // Process and store each chunk
611
+ for (const chunk of chunks) {
612
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
613
+ const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
614
+ if (dbConnection.type === 'sqlite') {
615
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
616
+ const existing = checkHashStmt.get(chunk.metadata.chunk_id);
617
+ if (existing && existing.hash === chunkHash) {
618
+ logger.info(`Skipping unchanged chunk: ${chunkId}`);
619
+ continue;
620
+ }
621
+ const embeddings = await this.createEmbeddings([chunk.content]);
622
+ if (embeddings.length) {
623
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
624
+ logger.debug(`Stored chunk ${chunkId} in SQLite`);
625
+ }
626
+ else {
627
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
628
+ }
629
+ }
630
+ else if (dbConnection.type === 'qdrant') {
631
+ try {
632
+ let pointId;
633
+ try {
634
+ pointId = chunk.metadata.chunk_id;
635
+ if (!utils_1.Utils.isValidUuid(pointId)) {
636
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
637
+ }
638
+ }
639
+ catch (e) {
640
+ pointId = crypto_1.default.randomUUID();
641
+ }
642
+ const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
643
+ ids: [pointId],
644
+ with_payload: true,
645
+ with_vector: false,
646
+ });
647
+ if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
648
+ logger.info(`Skipping unchanged chunk: ${chunkId}`);
649
+ continue;
650
+ }
651
+ const embeddings = await this.createEmbeddings([chunk.content]);
652
+ if (embeddings.length) {
653
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
654
+ logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
655
+ }
656
+ else {
657
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
658
+ }
659
+ }
660
+ catch (error) {
661
+ logger.error(`Error processing chunk in Qdrant:`, error);
662
+ }
663
+ }
664
+ }
665
+ };
666
+ logger.info(`Fetching Zendesk tickets updated since ${lastRunDate}`);
667
+ // Build query parameters
668
+ const statusFilter = config.ticket_status || ['new', 'open', 'pending', 'hold', 'solved'];
669
+ const query = `updated>${lastRunDate.split('T')[0]} status:${statusFilter.join(',status:')}`;
670
+ let nextPage = `${baseUrl}/search.json?query=${encodeURIComponent(query)}&sort_by=updated_at&sort_order=asc`;
671
+ let totalTickets = 0;
672
+ while (nextPage) {
673
+ const data = await fetchWithRetry(nextPage);
674
+ const tickets = data.results || [];
675
+ logger.info(`Processing batch of ${tickets.length} tickets`);
676
+ for (const ticket of tickets) {
677
+ await processTicket(ticket);
678
+ totalTickets++;
679
+ }
680
+ nextPage = data.next_page;
681
+ if (nextPage) {
682
+ logger.debug(`Fetching next page: ${nextPage}`);
683
+ // Rate limiting: wait between requests
684
+ await new Promise(res => setTimeout(res, 1000));
685
+ }
686
+ }
687
+ // Update the last run date in the database
688
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger);
689
+ logger.info(`Successfully processed ${totalTickets} tickets`);
690
+ }
691
+ async fetchAndProcessZendeskArticles(config, dbConnection, logger) {
692
+ const baseUrl = `https://${config.zendesk_subdomain}.zendesk.com/api/v2/help_center`;
693
+ const auth = buffer_1.Buffer.from(`${config.email}/token:${config.api_token}`).toString('base64');
694
+ // Get the start date for filtering
695
+ const startDate = config.start_date || `${new Date().getFullYear()}-01-01`;
696
+ const startDateObj = new Date(startDate);
697
+ const fetchWithRetry = async (url, retries = 3) => {
698
+ for (let attempt = 0; attempt < retries; attempt++) {
699
+ try {
700
+ const response = await axios_1.default.get(url, {
701
+ headers: {
702
+ 'Authorization': `Basic ${auth}`,
703
+ 'Content-Type': 'application/json',
704
+ },
705
+ });
706
+ if (response.status === 429) {
707
+ const retryAfter = parseInt(response.headers['retry-after'] || '60');
708
+ logger.warn(`Rate limited, waiting ${retryAfter}s before retry`);
709
+ await new Promise(res => setTimeout(res, retryAfter * 1000));
710
+ continue;
711
+ }
712
+ return response.data;
713
+ }
714
+ catch (error) {
715
+ logger.error(`Zendesk API error (attempt ${attempt + 1}):`, error.message);
716
+ if (attempt === retries - 1)
717
+ throw error;
718
+ await new Promise(res => setTimeout(res, 2000 * (attempt + 1)));
719
+ }
720
+ }
721
+ };
722
+ const generateMarkdownForArticle = (article) => {
723
+ let md = `# ${article.title}\n\n`;
724
+ md += `- **Author:** ${article.author_id}\n`;
725
+ md += `- **Section:** ${article.section_id}\n`;
726
+ md += `- **Created:** ${new Date(article.created_at).toDateString()}\n`;
727
+ md += `- **Updated:** ${new Date(article.updated_at).toDateString()}\n`;
728
+ md += `- **Vote Sum:** ${article.vote_sum || 0}\n`;
729
+ md += `- **Vote Count:** ${article.vote_count || 0}\n`;
730
+ if (article.label_names && article.label_names.length > 0) {
731
+ md += `- **Labels:** ${article.label_names.map((label) => `\`${label}\``).join(', ')}\n`;
732
+ }
733
+ // Handle article content - convert HTML to markdown
734
+ const articleBody = article.body || '';
735
+ let cleanContent = '_No content._';
736
+ if (articleBody.trim()) {
737
+ if (articleBody.includes('<')) {
738
+ // HTML content - use ContentProcessor to convert to markdown
739
+ cleanContent = this.contentProcessor.convertHtmlToMarkdown(articleBody);
740
+ }
741
+ else {
742
+ // Plain text content
743
+ cleanContent = articleBody;
744
+ }
745
+ }
746
+ md += `\n## Content\n\n${cleanContent}\n`;
747
+ return md;
748
+ };
749
+ const processArticle = async (article) => {
750
+ const articleId = article.id;
751
+ const url = article.html_url || `https://${config.zendesk_subdomain}.zendesk.com/hc/articles/${articleId}`;
752
+ logger.info(`Processing article #${articleId}: ${article.title}`);
753
+ // Generate markdown for the article
754
+ const markdown = generateMarkdownForArticle(article);
755
+ // Chunk the markdown content
756
+ const articleConfig = {
757
+ ...config,
758
+ product_name: config.product_name || `zendesk_${config.zendesk_subdomain}`,
759
+ max_size: config.max_size || Infinity
760
+ };
761
+ const chunks = await this.contentProcessor.chunkMarkdown(markdown, articleConfig, url);
762
+ logger.info(`Article #${articleId}: Created ${chunks.length} chunks`);
763
+ // Process and store each chunk (similar to ticket processing)
764
+ for (const chunk of chunks) {
765
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
766
+ const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
767
+ if (dbConnection.type === 'sqlite') {
768
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
769
+ const existing = checkHashStmt.get(chunk.metadata.chunk_id);
770
+ if (existing && existing.hash === chunkHash) {
771
+ logger.info(`Skipping unchanged chunk: ${chunkId}`);
772
+ continue;
773
+ }
774
+ const embeddings = await this.createEmbeddings([chunk.content]);
775
+ if (embeddings.length) {
776
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
777
+ logger.debug(`Stored chunk ${chunkId} in SQLite`);
778
+ }
779
+ else {
780
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
781
+ }
782
+ }
783
+ else if (dbConnection.type === 'qdrant') {
784
+ try {
785
+ let pointId;
786
+ try {
787
+ pointId = chunk.metadata.chunk_id;
788
+ if (!utils_1.Utils.isValidUuid(pointId)) {
789
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
790
+ }
791
+ }
792
+ catch (e) {
793
+ pointId = crypto_1.default.randomUUID();
794
+ }
795
+ const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
796
+ ids: [pointId],
797
+ with_payload: true,
798
+ with_vector: false,
799
+ });
800
+ if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
801
+ logger.info(`Skipping unchanged chunk: ${chunkId}`);
802
+ continue;
803
+ }
804
+ const embeddings = await this.createEmbeddings([chunk.content]);
805
+ if (embeddings.length) {
806
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
807
+ logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
808
+ }
809
+ else {
810
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
811
+ }
812
+ }
813
+ catch (error) {
814
+ logger.error(`Error processing chunk in Qdrant:`, error);
815
+ }
816
+ }
817
+ }
818
+ };
819
+ logger.info(`Fetching Zendesk help center articles updated since ${startDate}`);
820
+ let nextPage = `${baseUrl}/articles.json`;
821
+ let totalArticles = 0;
822
+ let processedArticles = 0;
823
+ while (nextPage) {
824
+ const data = await fetchWithRetry(nextPage);
825
+ const articles = data.articles || [];
826
+ logger.info(`Processing batch of ${articles.length} articles`);
827
+ for (const article of articles) {
828
+ totalArticles++;
829
+ // Check if article was updated since the start date
830
+ const updatedAt = new Date(article.updated_at);
831
+ if (updatedAt >= startDateObj) {
832
+ await processArticle(article);
833
+ processedArticles++;
834
+ }
835
+ else {
836
+ logger.debug(`Skipping article #${article.id} (updated ${article.updated_at}, before ${startDate})`);
837
+ }
838
+ }
839
+ nextPage = data.next_page;
840
+ if (nextPage) {
841
+ logger.debug(`Fetching next page: ${nextPage}`);
842
+ // Rate limiting: wait between requests
843
+ await new Promise(res => setTimeout(res, 1000));
844
+ }
845
+ }
846
+ logger.info(`Successfully processed ${processedArticles} of ${totalArticles} articles (filtered by date >= ${startDate})`);
847
+ }
496
848
  async createEmbeddings(texts) {
497
849
  const logger = this.logger.child('embeddings');
498
850
  try {
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TransformersEmbeddingProvider = exports.OpenAIEmbeddingProvider = void 0;
4
+ exports.createEmbeddingProvider = createEmbeddingProvider;
5
+ const openai_1 = require("openai");
6
+ const transformers_1 = require("@huggingface/transformers");
7
+ class OpenAIEmbeddingProvider {
8
+ constructor(params, logger) {
9
+ this.logger = logger.child('openai-embeddings');
10
+ this.model = params.model || 'text-embedding-3-large';
11
+ this.openai = new openai_1.OpenAI({
12
+ apiKey: params.api_key || process.env.OPENAI_API_KEY
13
+ });
14
+ this.logger.info(`OpenAI embedding provider initialized with model: ${this.model}`);
15
+ }
16
+ async createEmbeddings(texts) {
17
+ try {
18
+ this.logger.debug(`Creating embeddings for ${texts.length} texts using OpenAI`);
19
+ const response = await this.openai.embeddings.create({
20
+ model: this.model,
21
+ input: texts,
22
+ });
23
+ this.logger.debug(`Successfully created ${response.data.length} embeddings`);
24
+ return response.data.map(d => d.embedding);
25
+ }
26
+ catch (error) {
27
+ this.logger.error('Failed to create OpenAI embeddings:', error);
28
+ return [];
29
+ }
30
+ }
31
+ getDimensions() {
32
+ // Return dimensions based on model
33
+ if (this.model === 'text-embedding-3-large')
34
+ return 3072;
35
+ if (this.model === 'text-embedding-3-small')
36
+ return 1536;
37
+ if (this.model === 'text-embedding-ada-002')
38
+ return 1536;
39
+ return 1536; // Default fallback
40
+ }
41
+ getModelName() {
42
+ return this.model;
43
+ }
44
+ }
45
+ exports.OpenAIEmbeddingProvider = OpenAIEmbeddingProvider;
46
+ class TransformersEmbeddingProvider {
47
+ constructor(params, logger) {
48
+ this.params = params;
49
+ this.dimensions = 384; // Default, will be updated after model loading
50
+ this.isInitialized = false;
51
+ this.logger = logger.child('transformers-embeddings');
52
+ this.modelName = params.model;
53
+ // Configure transformers.js environment
54
+ if (params.local_files_only) {
55
+ transformers_1.env.allowLocalModels = true;
56
+ transformers_1.env.allowRemoteModels = false;
57
+ }
58
+ if (params.cache_dir) {
59
+ transformers_1.env.cacheDir = params.cache_dir;
60
+ }
61
+ this.logger.info(`Transformers embedding provider initialized with model: ${this.modelName}`);
62
+ }
63
+ async initialize() {
64
+ if (this.isInitialized)
65
+ return;
66
+ try {
67
+ this.logger.info(`Loading transformers model: ${this.modelName}`);
68
+ // Load tokenizer and model
69
+ this.tokenizer = await transformers_1.AutoTokenizer.from_pretrained(this.modelName);
70
+ this.model = await transformers_1.AutoModel.from_pretrained(this.modelName);
71
+ // Try to determine dimensions from model config
72
+ if (this.model.config && this.model.config.hidden_size) {
73
+ this.dimensions = this.model.config.hidden_size;
74
+ }
75
+ this.isInitialized = true;
76
+ this.logger.info(`Transformers model loaded successfully. Dimensions: ${this.dimensions}`);
77
+ }
78
+ catch (error) {
79
+ this.logger.error(`Failed to load transformers model: ${this.modelName}`, error);
80
+ throw error;
81
+ }
82
+ }
83
+ async createEmbeddings(texts) {
84
+ await this.initialize();
85
+ try {
86
+ this.logger.debug(`Creating embeddings for ${texts.length} texts using transformers.js`);
87
+ const embeddings = [];
88
+ // Process texts in batches to avoid memory issues
89
+ const batchSize = Math.min(8, texts.length);
90
+ for (let i = 0; i < texts.length; i += batchSize) {
91
+ const batch = texts.slice(i, i + batchSize);
92
+ for (const text of batch) {
93
+ // Tokenize the text
94
+ const tokens = await this.tokenizer(text, {
95
+ truncation: true,
96
+ padding: true,
97
+ max_length: 512,
98
+ return_tensors: 'pt'
99
+ });
100
+ // Get model output
101
+ const output = await this.model(tokens);
102
+ // Mean pooling to get sentence embeddings
103
+ let embedding;
104
+ if (output.last_hidden_state) {
105
+ // Mean pooling over sequence length
106
+ const tensor = output.last_hidden_state;
107
+ const meanPooled = tensor.mean(1); // Mean along sequence dimension
108
+ embedding = Array.from(meanPooled.data);
109
+ }
110
+ else if (output.pooler_output) {
111
+ // Use pooler output if available
112
+ embedding = Array.from(output.pooler_output.data);
113
+ }
114
+ else {
115
+ throw new Error('Unable to extract embeddings from model output');
116
+ }
117
+ embeddings.push(embedding);
118
+ }
119
+ // Log progress for large batches
120
+ if (texts.length > 10) {
121
+ this.logger.debug(`Processed ${Math.min(i + batchSize, texts.length)}/${texts.length} texts`);
122
+ }
123
+ }
124
+ this.logger.debug(`Successfully created ${embeddings.length} embeddings`);
125
+ return embeddings;
126
+ }
127
+ catch (error) {
128
+ this.logger.error('Failed to create transformers embeddings:', error);
129
+ return [];
130
+ }
131
+ }
132
+ getDimensions() {
133
+ return this.dimensions;
134
+ }
135
+ getModelName() {
136
+ return this.modelName;
137
+ }
138
+ async cleanup() {
139
+ // Clean up model resources if needed
140
+ this.model = null;
141
+ this.tokenizer = null;
142
+ this.isInitialized = false;
143
+ this.logger.info('Transformers embedding provider cleaned up');
144
+ }
145
+ }
146
+ exports.TransformersEmbeddingProvider = TransformersEmbeddingProvider;
147
+ function createEmbeddingProvider(config, logger) {
148
+ if (config.provider === 'openai') {
149
+ return new OpenAIEmbeddingProvider(config.params, logger);
150
+ }
151
+ else if (config.provider === 'transformers') {
152
+ return new TransformersEmbeddingProvider(config.params, logger);
153
+ }
154
+ else {
155
+ throw new Error(`Unsupported embedding provider: ${config.provider}`);
156
+ }
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",