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 +47 -4
- package/dist/content-processor.js +23 -0
- package/dist/doc2vec.js +353 -1
- package/dist/embedding-providers.js +157 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](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,
|
|
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 `'
|
|
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
|
-
|
|
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(/ /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
|
+
}
|