doc2vec 1.1.1 → 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/dist/doc2vec.js CHANGED
@@ -37,23 +37,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
- const readability_1 = require("@mozilla/readability");
41
40
  const axios_1 = __importDefault(require("axios"));
42
- const cheerio_1 = require("cheerio");
43
41
  const crypto_1 = __importDefault(require("crypto"));
44
- const jsdom_1 = require("jsdom");
45
- const puppeteer_1 = __importDefault(require("puppeteer"));
46
- const sanitize_html_1 = __importDefault(require("sanitize-html"));
47
- const turndown_1 = __importDefault(require("turndown"));
48
42
  const yaml = __importStar(require("js-yaml"));
49
43
  const fs = __importStar(require("fs"));
50
44
  const path = __importStar(require("path"));
51
- const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
45
+ const buffer_1 = require("buffer");
52
46
  const openai_1 = require("openai");
53
47
  const dotenv = __importStar(require("dotenv"));
54
- const sqliteVec = __importStar(require("sqlite-vec"));
55
- const js_client_rest_1 = require("@qdrant/js-client-rest");
56
48
  const logger_1 = require("./logger");
49
+ const utils_1 = require("./utils");
50
+ const database_1 = require("./database");
51
+ const content_processor_1 = require("./content-processor");
57
52
  const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
58
53
  dotenv.config();
59
54
  class Doc2Vec {
@@ -67,17 +62,23 @@ class Doc2Vec {
67
62
  this.logger.info('Initializing Doc2Vec');
68
63
  this.config = this.loadConfig(configPath);
69
64
  this.openai = new openai_1.OpenAI({ apiKey: process.env.OPENAI_API_KEY });
70
- this.turndownService = new turndown_1.default({
71
- codeBlockStyle: 'fenced',
72
- headingStyle: 'atx'
73
- });
74
- this.setupTurndownRules();
65
+ this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
75
66
  }
76
67
  loadConfig(configPath) {
77
68
  try {
78
69
  const logger = this.logger.child('config');
79
70
  logger.info(`Loading configuration from ${configPath}`);
80
- 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
+ });
81
82
  let config = yaml.load(configFile);
82
83
  const typedConfig = config;
83
84
  logger.info(`Configuration loaded successfully, found ${typedConfig.sources.length} sources`);
@@ -88,89 +89,6 @@ class Doc2Vec {
88
89
  process.exit(1);
89
90
  }
90
91
  }
91
- setupTurndownRules() {
92
- const logger = this.logger.child('markdown');
93
- logger.debug('Setting up Turndown rules for markdown conversion');
94
- this.turndownService.addRule('codeBlocks', {
95
- filter: (node) => node.nodeName === 'PRE',
96
- replacement: (content, node) => {
97
- const htmlNode = node;
98
- const code = htmlNode.querySelector('code');
99
- let codeContent;
100
- if (code) {
101
- codeContent = code.textContent || '';
102
- }
103
- else {
104
- codeContent = htmlNode.textContent || '';
105
- }
106
- const lines = codeContent.split('\n');
107
- let minIndent = Infinity;
108
- for (const line of lines) {
109
- if (line.trim() === '')
110
- continue;
111
- const leadingWhitespace = line.match(/^\s*/)?.[0] || '';
112
- minIndent = Math.min(minIndent, leadingWhitespace.length);
113
- }
114
- const cleanedLines = lines.map(line => {
115
- return line.substring(minIndent);
116
- });
117
- let cleanContent = cleanedLines.join('\n');
118
- cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
119
- cleanContent = cleanContent.replace(/\n{2,}/g, '\n');
120
- return `\n\`\`\`\n${cleanContent}\n\`\`\`\n`;
121
- }
122
- });
123
- this.turndownService.addRule('tableCell', {
124
- filter: ['th', 'td'],
125
- replacement: (content, node) => {
126
- const htmlNode = node;
127
- let cellContent = '';
128
- if (htmlNode.querySelector('p')) {
129
- cellContent = Array.from(htmlNode.querySelectorAll('p'))
130
- .map(p => p.textContent || '')
131
- .join(' ')
132
- .trim();
133
- }
134
- else {
135
- cellContent = content.trim();
136
- }
137
- return ` ${cellContent.replace(/\|/g, '\\|')} |`;
138
- }
139
- });
140
- this.turndownService.addRule('tableRow', {
141
- filter: 'tr',
142
- replacement: (content, node) => {
143
- const htmlNode = node;
144
- const cells = Array.from(htmlNode.cells);
145
- const isHeader = htmlNode.parentNode?.nodeName === 'THEAD';
146
- let output = '|' + content.trimEnd();
147
- if (isHeader) {
148
- const separator = cells.map(() => '---').join(' | ');
149
- output += '\n|' + separator + '|';
150
- }
151
- if (!isHeader || !htmlNode.nextElementSibling) {
152
- output += '\n';
153
- }
154
- return output;
155
- }
156
- });
157
- this.turndownService.addRule('table', {
158
- filter: 'table',
159
- replacement: (content) => {
160
- return '\n' + content.replace(/\n+/g, '\n').trim() + '\n';
161
- }
162
- });
163
- this.turndownService.addRule('preserveTableWhitespace', {
164
- filter: (node) => {
165
- return ((node.nodeName === 'TD' || node.nodeName === 'TH') &&
166
- (node.textContent?.trim().length === 0));
167
- },
168
- replacement: () => {
169
- return ' |';
170
- }
171
- });
172
- logger.debug('Turndown rules setup complete');
173
- }
174
92
  async run() {
175
93
  this.logger.section('PROCESSING SOURCES');
176
94
  for (const sourceConfig of this.config.sources) {
@@ -185,140 +103,23 @@ class Doc2Vec {
185
103
  else if (sourceConfig.type === 'local_directory') {
186
104
  await this.processLocalDirectory(sourceConfig, sourceLogger);
187
105
  }
106
+ else if (sourceConfig.type === 'zendesk') {
107
+ await this.processZendesk(sourceConfig, sourceLogger);
108
+ }
188
109
  else {
189
110
  sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
190
111
  }
191
112
  }
192
113
  this.logger.section('PROCESSING COMPLETE');
193
114
  }
194
- getUrlPrefix(url) {
195
- try {
196
- const parsedUrl = new URL(url);
197
- return parsedUrl.origin + parsedUrl.pathname;
198
- }
199
- catch (error) {
200
- return url;
201
- }
202
- }
203
- generateMetadataUUID(repo) {
204
- // Simple deterministic approach - hash the repo name and convert to UUID format
205
- const hash = crypto_1.default.createHash('md5').update(`metadata_${repo}`).digest('hex');
206
- // Format as UUID with version bits set correctly (version 4)
207
- return `${hash.substr(0, 8)}-${hash.substr(8, 4)}-4${hash.substr(13, 3)}-${hash.substr(16, 4)}-${hash.substr(20, 12)}`;
208
- }
209
- async initDatabaseMetadata(dbConnection) {
210
- const logger = this.logger.child('metadata');
211
- if (dbConnection.type === 'sqlite') {
212
- const db = dbConnection.db;
213
- logger.debug('Creating metadata table if it doesn\'t exist');
214
- db.exec(`
215
- CREATE TABLE IF NOT EXISTS vec_metadata (
216
- key TEXT PRIMARY KEY,
217
- value TEXT
218
- );
219
- `);
220
- logger.info('SQLite metadata table initialized');
221
- }
222
- else if (dbConnection.type === 'qdrant') {
223
- // For Qdrant, we'll use the same collection but verify it exists
224
- logger.info(`Using existing Qdrant collection for metadata: ${dbConnection.collectionName}`);
225
- // Nothing special to initialize as we'll use the same collection
226
- }
227
- }
228
- async getLastRunDate(dbConnection, repo, defaultDate) {
229
- const logger = this.logger.child('metadata');
230
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
231
- try {
232
- if (dbConnection.type === 'sqlite') {
233
- const stmt = dbConnection.db.prepare('SELECT value FROM vec_metadata WHERE key = ?');
234
- const result = stmt.get(metadataKey);
235
- if (result) {
236
- logger.info(`Retrieved last run date for ${repo}: ${result.value}`);
237
- return result.value;
238
- }
239
- }
240
- else if (dbConnection.type === 'qdrant') {
241
- // Generate a UUID for this repo's metadata
242
- const metadataUUID = this.generateMetadataUUID(repo);
243
- logger.debug(`Looking up metadata with UUID: ${metadataUUID}`);
244
- try {
245
- // Try to retrieve the metadata point for this repo
246
- const response = await dbConnection.client.retrieve(dbConnection.collectionName, {
247
- ids: [metadataUUID],
248
- with_payload: true,
249
- with_vector: false
250
- });
251
- if (response.length > 0 && response[0].payload?.metadata_value) {
252
- const lastRunDate = response[0].payload.metadata_value;
253
- logger.info(`Retrieved last run date for ${repo}: ${lastRunDate}`);
254
- return lastRunDate;
255
- }
256
- }
257
- catch (error) {
258
- logger.warn(`Failed to retrieve metadata for ${repo}:`, error);
259
- }
260
- }
261
- }
262
- catch (error) {
263
- logger.warn(`Error retrieving last run date:`, error);
264
- }
265
- logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
266
- return defaultDate;
267
- }
268
- async updateLastRunDate(dbConnection, repo) {
269
- const logger = this.logger.child('metadata');
270
- const now = new Date().toISOString();
271
- try {
272
- if (dbConnection.type === 'sqlite') {
273
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
274
- const stmt = dbConnection.db.prepare(`
275
- INSERT INTO vec_metadata (key, value) VALUES (?, ?)
276
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
277
- `);
278
- stmt.run(metadataKey, now);
279
- logger.info(`Updated last run date for ${repo} to ${now}`);
280
- }
281
- else if (dbConnection.type === 'qdrant') {
282
- // Generate UUID for this repo's metadata
283
- const metadataUUID = this.generateMetadataUUID(repo);
284
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
285
- logger.debug(`Using UUID: ${metadataUUID} for metadata`);
286
- // Generate a dummy embedding (all zeros)
287
- const dummyEmbeddingSize = 3072; // Same size as your content embeddings
288
- const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
289
- // Create a point with special metadata payload
290
- const metadataPoint = {
291
- id: metadataUUID,
292
- vector: dummyEmbedding,
293
- payload: {
294
- metadata_key: metadataKey,
295
- metadata_value: now,
296
- is_metadata: true, // Flag to identify metadata points
297
- content: `Metadata: Last run date for ${repo}`,
298
- product_name: 'system',
299
- version: 'metadata',
300
- url: 'metadata://' + repo
301
- }
302
- };
303
- await dbConnection.client.upsert(dbConnection.collectionName, {
304
- wait: true,
305
- points: [metadataPoint]
306
- });
307
- logger.info(`Updated last run date for ${repo} to ${now}`);
308
- }
309
- }
310
- catch (error) {
311
- logger.error(`Failed to update last run date for ${repo}:`, error);
312
- }
313
- }
314
115
  async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
315
116
  const [owner, repoName] = repo.split('/');
316
117
  const GITHUB_API_URL = `https://api.github.com/repos/${owner}/${repoName}/issues`;
317
118
  // Initialize metadata storage if needed
318
- await this.initDatabaseMetadata(dbConnection);
119
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
319
120
  // Get the last run date from the database
320
121
  const startDate = sourceConfig.start_date || '2025-01-01';
321
- const lastRunDate = await this.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`);
122
+ const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
322
123
  const fetchWithRetry = async (url, params = {}, retries = 5, delay = 5000) => {
323
124
  for (let attempt = 0; attempt < retries; attempt++) {
324
125
  try {
@@ -405,14 +206,14 @@ class Doc2Vec {
405
206
  product_name: sourceConfig.product_name || repo,
406
207
  max_size: sourceConfig.max_size || Infinity
407
208
  };
408
- const chunks = await this.chunkMarkdown(markdown, issueConfig, url);
209
+ const chunks = await this.contentProcessor.chunkMarkdown(markdown, issueConfig, url);
409
210
  logger.info(`Issue #${issueNumber}: Created ${chunks.length} chunks`);
410
211
  // Process and store each chunk immediately
411
212
  for (const chunk of chunks) {
412
- const chunkHash = this.generateHash(chunk.content);
213
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
413
214
  const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
414
215
  if (dbConnection.type === 'sqlite') {
415
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
216
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
416
217
  const existing = checkHashStmt.get(chunk.metadata.chunk_id);
417
218
  if (existing && existing.hash === chunkHash) {
418
219
  logger.info(`Skipping unchanged chunk: ${chunkId}`);
@@ -420,7 +221,7 @@ class Doc2Vec {
420
221
  }
421
222
  const embeddings = await this.createEmbeddings([chunk.content]);
422
223
  if (embeddings.length) {
423
- this.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
224
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
424
225
  logger.debug(`Stored chunk ${chunkId} in SQLite`);
425
226
  }
426
227
  else {
@@ -432,8 +233,8 @@ class Doc2Vec {
432
233
  let pointId;
433
234
  try {
434
235
  pointId = chunk.metadata.chunk_id;
435
- if (!this.isValidUuid(pointId)) {
436
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
236
+ if (!utils_1.Utils.isValidUuid(pointId)) {
237
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
437
238
  }
438
239
  }
439
240
  catch (e) {
@@ -450,7 +251,7 @@ class Doc2Vec {
450
251
  }
451
252
  const embeddings = await this.createEmbeddings([chunk.content]);
452
253
  if (embeddings.length) {
453
- await this.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
254
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
454
255
  logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
455
256
  }
456
257
  else {
@@ -472,15 +273,15 @@ class Doc2Vec {
472
273
  await processIssue(issues[i]);
473
274
  }
474
275
  // Update the last run date in the database after processing all issues
475
- await this.updateLastRunDate(dbConnection, repo);
276
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger);
476
277
  logger.info(`Successfully processed ${issues.length} issues`);
477
278
  }
478
279
  async processGithubRepo(config, parentLogger) {
479
280
  const logger = parentLogger.child('process');
480
281
  logger.info(`Starting processing for GitHub repo: ${config.repo}`);
481
- const dbConnection = await this.initDatabase(config, logger);
282
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
482
283
  // Initialize metadata storage
483
- await this.initDatabaseMetadata(dbConnection);
284
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
484
285
  logger.section('GITHUB ISSUES');
485
286
  // Process GitHub issues
486
287
  await this.fetchAndProcessGitHubIssues(config.repo, config, dbConnection, logger);
@@ -489,16 +290,16 @@ class Doc2Vec {
489
290
  async processWebsite(config, parentLogger) {
490
291
  const logger = parentLogger.child('process');
491
292
  logger.info(`Starting processing for website: ${config.url}`);
492
- const dbConnection = await this.initDatabase(config, logger);
293
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
493
294
  const validChunkIds = new Set();
494
295
  const visitedUrls = new Set();
495
- const urlPrefix = this.getUrlPrefix(config.url);
296
+ const urlPrefix = utils_1.Utils.getUrlPrefix(config.url);
496
297
  logger.section('CRAWL AND EMBEDDING');
497
- await this.crawlWebsite(config.url, config, async (url, content) => {
298
+ const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
498
299
  visitedUrls.add(url);
499
300
  logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
500
301
  try {
501
- const chunks = await this.chunkMarkdown(content, config, url);
302
+ const chunks = await this.contentProcessor.chunkMarkdown(content, config, url);
502
303
  logger.info(`Created ${chunks.length} chunks`);
503
304
  if (chunks.length > 0) {
504
305
  const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
@@ -507,9 +308,9 @@ class Doc2Vec {
507
308
  validChunkIds.add(chunk.metadata.chunk_id);
508
309
  const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
509
310
  let needsEmbedding = true;
510
- const chunkHash = this.generateHash(chunk.content);
311
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
511
312
  if (dbConnection.type === 'sqlite') {
512
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
313
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
513
314
  const existing = checkHashStmt.get(chunk.metadata.chunk_id);
514
315
  if (existing && existing.hash === chunkHash) {
515
316
  needsEmbedding = false;
@@ -522,8 +323,8 @@ class Doc2Vec {
522
323
  let pointId;
523
324
  try {
524
325
  pointId = chunk.metadata.chunk_id;
525
- if (!this.isValidUuid(pointId)) {
526
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
326
+ if (!utils_1.Utils.isValidUuid(pointId)) {
327
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
527
328
  }
528
329
  }
529
330
  catch (e) {
@@ -549,11 +350,11 @@ class Doc2Vec {
549
350
  if (embeddings.length > 0) {
550
351
  const embedding = embeddings[0];
551
352
  if (dbConnection.type === 'sqlite') {
552
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
353
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
553
354
  chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
554
355
  }
555
356
  else if (dbConnection.type === 'qdrant') {
556
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
357
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
557
358
  chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
558
359
  }
559
360
  }
@@ -572,24 +373,29 @@ class Doc2Vec {
572
373
  }, logger, visitedUrls);
573
374
  logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
574
375
  logger.section('CLEANUP');
575
- if (dbConnection.type === 'sqlite') {
576
- logger.info(`Running SQLite cleanup for ${urlPrefix}`);
577
- this.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
376
+ if (crawlResult.hasNetworkErrors) {
377
+ logger.warn('Skipping cleanup due to network errors encountered during crawling. This prevents removal of valid chunks when the site is temporarily unreachable.');
578
378
  }
579
- else if (dbConnection.type === 'qdrant') {
580
- logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
581
- await this.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
379
+ else {
380
+ if (dbConnection.type === 'sqlite') {
381
+ logger.info(`Running SQLite cleanup for ${urlPrefix}`);
382
+ database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
383
+ }
384
+ else if (dbConnection.type === 'qdrant') {
385
+ logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
386
+ await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
387
+ }
582
388
  }
583
389
  logger.info(`Finished processing website: ${config.url}`);
584
390
  }
585
391
  async processLocalDirectory(config, parentLogger) {
586
392
  const logger = parentLogger.child('process');
587
393
  logger.info(`Starting processing for local directory: ${config.path}`);
588
- const dbConnection = await this.initDatabase(config, logger);
394
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
589
395
  const validChunkIds = new Set();
590
396
  const processedFiles = new Set();
591
397
  logger.section('FILE SCANNING AND EMBEDDING');
592
- await this.processDirectory(config.path, config, async (filePath, content) => {
398
+ await this.contentProcessor.processDirectory(config.path, config, async (filePath, content) => {
593
399
  processedFiles.add(filePath);
594
400
  logger.info(`Processing content from ${filePath} (${content.length} chars)`);
595
401
  try {
@@ -618,7 +424,7 @@ class Doc2Vec {
618
424
  // Use default file:// URL
619
425
  fileUrl = `file://${filePath}`;
620
426
  }
621
- const chunks = await this.chunkMarkdown(content, config, fileUrl);
427
+ const chunks = await this.contentProcessor.chunkMarkdown(content, config, fileUrl);
622
428
  logger.info(`Created ${chunks.length} chunks`);
623
429
  if (chunks.length > 0) {
624
430
  const chunkProgress = logger.progress(`Embedding chunks for ${filePath}`, chunks.length);
@@ -627,9 +433,9 @@ class Doc2Vec {
627
433
  validChunkIds.add(chunk.metadata.chunk_id);
628
434
  const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
629
435
  let needsEmbedding = true;
630
- const chunkHash = this.generateHash(chunk.content);
436
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
631
437
  if (dbConnection.type === 'sqlite') {
632
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
438
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
633
439
  const existing = checkHashStmt.get(chunk.metadata.chunk_id);
634
440
  if (existing && existing.hash === chunkHash) {
635
441
  needsEmbedding = false;
@@ -642,8 +448,8 @@ class Doc2Vec {
642
448
  let pointId;
643
449
  try {
644
450
  pointId = chunk.metadata.chunk_id;
645
- if (!this.isValidUuid(pointId)) {
646
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
451
+ if (!utils_1.Utils.isValidUuid(pointId)) {
452
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
647
453
  }
648
454
  }
649
455
  catch (e) {
@@ -669,11 +475,11 @@ class Doc2Vec {
669
475
  if (embeddings.length > 0) {
670
476
  const embedding = embeddings[0];
671
477
  if (dbConnection.type === 'sqlite') {
672
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
478
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
673
479
  chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
674
480
  }
675
481
  else if (dbConnection.type === 'qdrant') {
676
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
482
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
677
483
  chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
678
484
  }
679
485
  }
@@ -693,766 +499,351 @@ class Doc2Vec {
693
499
  logger.section('CLEANUP');
694
500
  if (dbConnection.type === 'sqlite') {
695
501
  logger.info(`Running SQLite cleanup for local directory ${config.path}`);
696
- this.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
502
+ database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
697
503
  }
698
504
  else if (dbConnection.type === 'qdrant') {
699
505
  logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
700
- await this.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
506
+ await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
701
507
  }
702
508
  logger.info(`Finished processing local directory: ${config.path}`);
703
509
  }
704
- async processDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set()) {
705
- const logger = parentLogger.child('directory-processor');
706
- logger.info(`Processing directory: ${dirPath}`);
707
- const recursive = config.recursive !== undefined ? config.recursive : true;
708
- const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm'];
709
- const excludeExtensions = config.exclude_extensions || [];
710
- const encoding = config.encoding || 'utf8';
711
- try {
712
- const files = fs.readdirSync(dirPath);
713
- let processedFiles = 0;
714
- let skippedFiles = 0;
715
- for (const file of files) {
716
- const filePath = path.join(dirPath, file);
717
- const stat = fs.statSync(filePath);
718
- // Skip already visited paths
719
- if (visitedPaths.has(filePath)) {
720
- logger.debug(`Skipping already visited path: ${filePath}`);
721
- continue;
722
- }
723
- visitedPaths.add(filePath);
724
- if (stat.isDirectory()) {
725
- if (recursive) {
726
- await this.processDirectory(filePath, config, processFileContent, logger, visitedPaths);
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;
727
548
  }
728
- else {
729
- logger.debug(`Skipping directory ${filePath} (recursive=false)`);
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`;
730
584
  }
731
585
  }
732
- else if (stat.isFile()) {
733
- const extension = path.extname(file).toLowerCase();
734
- // Apply extension filters
735
- if (excludeExtensions.includes(extension)) {
736
- logger.debug(`Skipping file with excluded extension: ${filePath}`);
737
- skippedFiles++;
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}`);
738
619
  continue;
739
620
  }
740
- if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
741
- logger.debug(`Skipping file with non-included extension: ${filePath}`);
742
- skippedFiles++;
743
- continue;
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}`);
744
628
  }
629
+ }
630
+ else if (dbConnection.type === 'qdrant') {
745
631
  try {
746
- logger.info(`Reading file: ${filePath}`);
747
- const content = fs.readFileSync(filePath, { encoding: encoding });
748
- if (content.length > config.max_size) {
749
- logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
750
- skippedFiles++;
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}`);
751
649
  continue;
752
650
  }
753
- // Convert HTML to Markdown if needed
754
- let processedContent;
755
- if (extension === '.html' || extension === '.htm') {
756
- logger.debug(`Converting HTML to Markdown for ${filePath}`);
757
- const cleanHtml = (0, sanitize_html_1.default)(content, {
758
- allowedTags: [
759
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
760
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
761
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
762
- ],
763
- allowedAttributes: {
764
- 'a': ['href'],
765
- 'pre': ['class', 'data-language'],
766
- 'code': ['class', 'data-language'],
767
- 'div': ['class'],
768
- 'span': ['class']
769
- }
770
- });
771
- processedContent = this.turndownService.turndown(cleanHtml);
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})`);
772
655
  }
773
656
  else {
774
- processedContent = content;
657
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
775
658
  }
776
- await processFileContent(filePath, processedContent);
777
- processedFiles++;
778
659
  }
779
660
  catch (error) {
780
- logger.error(`Error processing file ${filePath}:`, error);
661
+ logger.error(`Error processing chunk in Qdrant:`, error);
781
662
  }
782
663
  }
783
664
  }
784
- logger.info(`Directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
785
- }
786
- catch (error) {
787
- logger.error(`Error reading directory ${dirPath}:`, error);
788
- }
789
- }
790
- removeObsoleteFilesSQLite(db, processedFiles, pathConfig, logger) {
791
- const getChunksForPathStmt = db.prepare(`
792
- SELECT chunk_id, url FROM vec_items
793
- WHERE url LIKE ? || '%'
794
- `);
795
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
796
- // Determine if we're using URL rewriting or direct file paths
797
- const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
798
- // Set up the URL prefix for searching
799
- let urlPrefix;
800
- if (isRewriteMode) {
801
- // Handle URL rewriting case
802
- urlPrefix = pathConfig.url_rewrite_prefix || '';
803
- urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
804
- }
805
- else {
806
- // Handle direct file path case
807
- const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
808
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
809
- urlPrefix = `file://${cleanedDirPrefix}`;
810
- }
811
- logger.debug(`Searching for chunks with URL prefix: ${urlPrefix}`);
812
- const existingChunks = getChunksForPathStmt.all(urlPrefix);
813
- let deletedCount = 0;
814
- const transaction = db.transaction(() => {
815
- for (const { chunk_id, url } of existingChunks) {
816
- // Skip if it's not from our URL prefix (safety check)
817
- if (!url.startsWith(urlPrefix))
818
- continue;
819
- let filePath;
820
- let shouldDelete = false;
821
- if (isRewriteMode) {
822
- // URL rewrite mode: extract relative path and construct full file path
823
- const config = pathConfig;
824
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
825
- filePath = path.join(config.path, relativePath);
826
- shouldDelete = !processedFiles.has(filePath);
827
- }
828
- else {
829
- // Direct file path mode: remove file:// prefix to match with processedFiles
830
- filePath = url.substring(7); // Remove 'file://' prefix
831
- shouldDelete = !processedFiles.has(filePath);
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;
832
713
  }
833
- if (shouldDelete) {
834
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (File not processed: ${filePath})`);
835
- deleteChunkStmt.run(chunk_id);
836
- deletedCount++;
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)));
837
719
  }
838
720
  }
839
- });
840
- transaction();
841
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL prefix ${urlPrefix}`);
842
- }
843
- async removeObsoleteFilesQdrant(db, processedFiles, pathConfig, logger) {
844
- const { client, collectionName } = db;
845
- try {
846
- // Determine if we're using URL rewriting or direct file paths
847
- const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
848
- // Set up the URL prefix for searching
849
- let urlPrefix;
850
- if (isRewriteMode) {
851
- // Handle URL rewriting case
852
- urlPrefix = pathConfig.url_rewrite_prefix || '';
853
- urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
854
- }
855
- else {
856
- // Handle direct file path case
857
- const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
858
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
859
- urlPrefix = `file://${cleanedDirPrefix}`;
860
- }
861
- logger.debug(`Checking for obsolete chunks with URL prefix: ${urlPrefix}`);
862
- const response = await client.scroll(collectionName, {
863
- limit: 10000,
864
- with_payload: true,
865
- with_vector: false,
866
- filter: {
867
- must: [
868
- {
869
- key: "url",
870
- match: {
871
- text: urlPrefix
872
- }
873
- }
874
- ],
875
- must_not: [
876
- {
877
- key: "is_metadata",
878
- match: {
879
- value: true
880
- }
881
- }
882
- ]
883
- }
884
- });
885
- const obsoletePointIds = response.points
886
- .filter((point) => {
887
- const url = point.payload?.url;
888
- // Double check it's not a metadata record
889
- if (point.payload?.is_metadata === true) {
890
- return false;
891
- }
892
- if (!url || !url.startsWith(urlPrefix)) {
893
- return false;
894
- }
895
- let filePath;
896
- if (isRewriteMode) {
897
- // URL rewrite mode: extract relative path and construct full file path
898
- const config = pathConfig;
899
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
900
- filePath = path.join(config.path, relativePath);
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);
901
740
  }
902
741
  else {
903
- // Direct file path mode: remove file:// prefix to match with processedFiles
904
- filePath = url.startsWith('file://') ? url.substring(7) : '';
742
+ // Plain text content
743
+ cleanContent = articleBody;
905
744
  }
906
- return filePath && !processedFiles.has(filePath);
907
- })
908
- .map((point) => point.id);
909
- if (obsoletePointIds.length > 0) {
910
- await client.delete(collectionName, {
911
- points: obsoletePointIds,
912
- });
913
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL prefix ${urlPrefix}`);
914
745
  }
915
- else {
916
- logger.info(`No obsolete chunks to delete from Qdrant for URL prefix ${urlPrefix}`);
917
- }
918
- }
919
- catch (error) {
920
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
921
- }
922
- }
923
- async initDatabase(config, parentLogger) {
924
- const logger = parentLogger.child('database');
925
- const dbConfig = config.database_config;
926
- if (dbConfig.type === 'sqlite') {
927
- const params = dbConfig.params;
928
- const dbPath = params.db_path || path.join(process.cwd(), `${config.product_name.replace(/\s+/g, '_')}-${config.version}.db`);
929
- logger.info(`Opening SQLite database at ${dbPath}`);
930
- const db = new better_sqlite3_1.default(dbPath, { allowExtension: true });
931
- sqliteVec.load(db);
932
- logger.debug(`Creating vec_items table if it doesn't exist`);
933
- db.exec(`
934
- CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(
935
- embedding FLOAT[3072],
936
- product_name TEXT,
937
- version TEXT,
938
- heading_hierarchy TEXT,
939
- section TEXT,
940
- chunk_id TEXT UNIQUE,
941
- content TEXT,
942
- url TEXT,
943
- hash TEXT
944
- );
945
- `);
946
- logger.info(`SQLite database initialized successfully`);
947
- return { db, type: 'sqlite' };
948
- }
949
- else if (dbConfig.type === 'qdrant') {
950
- const params = dbConfig.params;
951
- const qdrantUrl = params.qdrant_url || 'http://localhost:6333';
952
- const qdrantPort = params.qdrant_port || 443;
953
- const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
954
- logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
955
- const qdrantClient = new js_client_rest_1.QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
956
- await this.createCollectionQdrant(qdrantClient, collectionName, logger);
957
- logger.info(`Qdrant connection established successfully`);
958
- return { client: qdrantClient, collectionName, type: 'qdrant' };
959
- }
960
- else {
961
- const errMsg = `Unsupported database type: ${dbConfig.type}`;
962
- logger.error(errMsg);
963
- throw new Error(errMsg);
964
- }
965
- }
966
- async createCollectionQdrant(qdrantClient, collectionName, logger) {
967
- try {
968
- logger.debug(`Checking if collection ${collectionName} exists`);
969
- const collections = await qdrantClient.getCollections();
970
- const collectionExists = collections.collections.some((collection) => collection.name === collectionName);
971
- if (collectionExists) {
972
- logger.info(`Collection ${collectionName} already exists`);
973
- return;
974
- }
975
- logger.info(`Creating new collection ${collectionName}`);
976
- await qdrantClient.createCollection(collectionName, {
977
- vectors: {
978
- size: 3072,
979
- distance: "Cosine",
980
- },
981
- });
982
- logger.info(`Collection ${collectionName} created successfully`);
983
- }
984
- catch (error) {
985
- if (error instanceof Error) {
986
- const errorMsg = error.message.toLowerCase();
987
- const errorString = JSON.stringify(error).toLowerCase();
988
- if (errorMsg.includes("already exists") ||
989
- errorString.includes("already exists") ||
990
- error?.status === 409 ||
991
- errorString.includes("conflict")) {
992
- logger.info(`Collection ${collectionName} already exists (from error response)`);
993
- return;
994
- }
995
- }
996
- logger.error(`Error creating Qdrant collection:`, error);
997
- logger.warn(`Continuing with existing collection...`);
998
- }
999
- }
1000
- prepareSQLiteStatements(db) {
1001
- return {
1002
- insertStmt: db.prepare(`
1003
- INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash)
1004
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1005
- `),
1006
- checkHashStmt: db.prepare(`SELECT hash FROM vec_items WHERE chunk_id = ?`),
1007
- updateStmt: db.prepare(`
1008
- UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?
1009
- WHERE chunk_id = ?
1010
- `),
1011
- getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
1012
- deleteChunkStmt: db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`)
746
+ md += `\n## Content\n\n${cleanContent}\n`;
747
+ return md;
1013
748
  };
1014
- }
1015
- insertVectorsSQLite(db, chunk, embedding, logger, chunkHash) {
1016
- const { insertStmt, updateStmt } = this.prepareSQLiteStatements(db);
1017
- const hash = chunkHash || this.generateHash(chunk.content);
1018
- const transaction = db.transaction(() => {
1019
- const params = [
1020
- new Float32Array(embedding),
1021
- chunk.metadata.product_name,
1022
- chunk.metadata.version,
1023
- JSON.stringify(chunk.metadata.heading_hierarchy),
1024
- chunk.metadata.section,
1025
- chunk.metadata.chunk_id,
1026
- chunk.content,
1027
- chunk.metadata.url,
1028
- hash
1029
- ];
1030
- try {
1031
- insertStmt.run(params);
1032
- }
1033
- catch (error) {
1034
- updateStmt.run([...params.slice(0, 8), chunk.metadata.chunk_id]);
1035
- }
1036
- });
1037
- transaction();
1038
- }
1039
- async storeChunkInQdrant(db, chunk, embedding, chunkHash) {
1040
- const { client, collectionName } = db;
1041
- try {
1042
- let pointId;
1043
- try {
1044
- pointId = chunk.metadata.chunk_id;
1045
- if (!this.isValidUuid(pointId)) {
1046
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
1047
- }
1048
- }
1049
- catch (e) {
1050
- pointId = crypto_1.default.randomUUID();
1051
- }
1052
- const hash = chunkHash || this.generateHash(chunk.content);
1053
- const pointItem = {
1054
- id: pointId,
1055
- vector: embedding,
1056
- payload: {
1057
- content: chunk.content,
1058
- product_name: chunk.metadata.product_name,
1059
- version: chunk.metadata.version,
1060
- heading_hierarchy: chunk.metadata.heading_hierarchy,
1061
- section: chunk.metadata.section,
1062
- url: chunk.metadata.url,
1063
- hash: hash,
1064
- original_chunk_id: chunk.metadata.chunk_id,
1065
- },
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
1066
760
  };
1067
- await client.upsert(collectionName, {
1068
- wait: true,
1069
- points: [pointItem],
1070
- });
1071
- }
1072
- catch (error) {
1073
- this.logger.error("Error storing chunk in Qdrant:", error);
1074
- }
1075
- }
1076
- removeObsoleteChunksSQLite(db, visitedUrls, urlPrefix, logger) {
1077
- const getChunksForUrlStmt = db.prepare(`
1078
- SELECT chunk_id, url FROM vec_items
1079
- WHERE url LIKE ? || '%'
1080
- `);
1081
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
1082
- const existingChunks = getChunksForUrlStmt.all(urlPrefix);
1083
- let deletedCount = 0;
1084
- const transaction = db.transaction(() => {
1085
- for (const { chunk_id, url } of existingChunks) {
1086
- if (!visitedUrls.has(url)) {
1087
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (URL not visited)`);
1088
- deleteChunkStmt.run(chunk_id);
1089
- deletedCount++;
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
+ }
1090
782
  }
1091
- }
1092
- });
1093
- transaction();
1094
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL ${urlPrefix}`);
1095
- }
1096
- isValidUuid(str) {
1097
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1098
- return uuidRegex.test(str);
1099
- }
1100
- hashToUuid(hash) {
1101
- const truncatedHash = hash.substring(0, 32);
1102
- return [
1103
- truncatedHash.substring(0, 8),
1104
- truncatedHash.substring(8, 12),
1105
- '5' + truncatedHash.substring(13, 16),
1106
- '8' + truncatedHash.substring(17, 20),
1107
- truncatedHash.substring(20, 32)
1108
- ].join('-');
1109
- }
1110
- async removeObsoleteChunksQdrant(db, visitedUrls, urlPrefix, logger) {
1111
- const { client, collectionName } = db;
1112
- try {
1113
- // Get all points that match the URL prefix but are not metadata points
1114
- const response = await client.scroll(collectionName, {
1115
- limit: 10000,
1116
- with_payload: true,
1117
- with_vector: false,
1118
- filter: {
1119
- must: [
1120
- {
1121
- key: "url",
1122
- match: {
1123
- text: urlPrefix
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);
1124
790
  }
1125
791
  }
1126
- ],
1127
- must_not: [
1128
- {
1129
- key: "is_metadata",
1130
- match: {
1131
- value: true
1132
- }
792
+ catch (e) {
793
+ pointId = crypto_1.default.randomUUID();
1133
794
  }
1134
- ]
1135
- }
1136
- });
1137
- const obsoletePointIds = response.points
1138
- .filter((point) => {
1139
- const url = point.payload?.url;
1140
- // Double check it's not a metadata record
1141
- if (point.payload?.is_metadata === true) {
1142
- return false;
1143
- }
1144
- return url && !visitedUrls.has(url);
1145
- })
1146
- .map((point) => point.id);
1147
- if (obsoletePointIds.length > 0) {
1148
- await client.delete(collectionName, {
1149
- points: obsoletePointIds,
1150
- });
1151
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL ${urlPrefix}`);
1152
- }
1153
- else {
1154
- logger.info(`No obsolete chunks to delete from Qdrant for URL ${urlPrefix}`);
1155
- }
1156
- }
1157
- catch (error) {
1158
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
1159
- }
1160
- }
1161
- async parseSitemap(sitemapUrl, logger) {
1162
- logger.info(`Parsing sitemap from ${sitemapUrl}`);
1163
- try {
1164
- const response = await axios_1.default.get(sitemapUrl);
1165
- const $ = (0, cheerio_1.load)(response.data, { xmlMode: true });
1166
- const urls = [];
1167
- // Handle standard sitemaps
1168
- $('url > loc').each((_, element) => {
1169
- const url = $(element).text().trim();
1170
- if (url) {
1171
- urls.push(url);
1172
- }
1173
- });
1174
- // Handle sitemap indexes (sitemaps that link to other sitemaps)
1175
- const sitemapLinks = [];
1176
- $('sitemap > loc').each((_, element) => {
1177
- const nestedSitemapUrl = $(element).text().trim();
1178
- if (nestedSitemapUrl) {
1179
- sitemapLinks.push(nestedSitemapUrl);
1180
- }
1181
- });
1182
- // Recursively process nested sitemaps
1183
- for (const nestedSitemapUrl of sitemapLinks) {
1184
- logger.debug(`Found nested sitemap: ${nestedSitemapUrl}`);
1185
- const nestedUrls = await this.parseSitemap(nestedSitemapUrl, logger);
1186
- urls.push(...nestedUrls);
1187
- }
1188
- logger.info(`Found ${urls.length} URLs in sitemap ${sitemapUrl}`);
1189
- return urls;
1190
- }
1191
- catch (error) {
1192
- logger.error(`Error parsing sitemap at ${sitemapUrl}:`, error);
1193
- return [];
1194
- }
1195
- }
1196
- async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls) {
1197
- const logger = parentLogger.child('crawler');
1198
- const queue = [baseUrl];
1199
- // Process sitemap if provided
1200
- if (sourceConfig.sitemap_url) {
1201
- logger.section('SITEMAP PROCESSING');
1202
- const sitemapUrls = await this.parseSitemap(sourceConfig.sitemap_url, logger);
1203
- // Add sitemap URLs to the queue if they're within the website scope
1204
- for (const url of sitemapUrls) {
1205
- if (url.startsWith(sourceConfig.url) && !queue.includes(url)) {
1206
- logger.debug(`Adding URL from sitemap to queue: ${url}`);
1207
- queue.push(url);
1208
- }
1209
- }
1210
- logger.info(`Added ${queue.length - 1} URLs from sitemap to the crawl queue`);
1211
- }
1212
- logger.info(`Starting crawl from ${baseUrl} with ${queue.length} URLs in initial queue`);
1213
- let processedCount = 0;
1214
- let skippedCount = 0;
1215
- let skippedSizeCount = 0;
1216
- let errorCount = 0;
1217
- while (queue.length > 0) {
1218
- const url = queue.shift();
1219
- if (!url)
1220
- continue;
1221
- const normalizedUrl = this.normalizeUrl(url);
1222
- if (visitedUrls.has(normalizedUrl))
1223
- continue;
1224
- visitedUrls.add(normalizedUrl);
1225
- if (!this.shouldProcessUrl(url)) {
1226
- logger.debug(`Skipping URL with unsupported extension: ${url}`);
1227
- skippedCount++;
1228
- continue;
1229
- }
1230
- try {
1231
- logger.info(`Crawling: ${url}`);
1232
- const content = await this.processPage(url, sourceConfig);
1233
- if (content !== null) {
1234
- await processPageContent(url, content);
1235
- processedCount++;
1236
- }
1237
- else {
1238
- skippedSizeCount++;
1239
- }
1240
- const response = await axios_1.default.get(url);
1241
- const $ = (0, cheerio_1.load)(response.data);
1242
- logger.debug(`Finding links on page ${url}`);
1243
- let newLinksFound = 0;
1244
- $('a[href]').each((_, element) => {
1245
- const href = $(element).attr('href');
1246
- if (!href || href.startsWith('#') || href.startsWith('mailto:'))
1247
- return;
1248
- const fullUrl = this.buildUrl(href, url);
1249
- if (fullUrl.startsWith(sourceConfig.url) && !visitedUrls.has(this.normalizeUrl(fullUrl))) {
1250
- if (!queue.includes(fullUrl)) {
1251
- queue.push(fullUrl);
1252
- newLinksFound++;
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;
1253
803
  }
1254
- }
1255
- });
1256
- logger.debug(`Found ${newLinksFound} new links on ${url}`);
1257
- }
1258
- catch (error) {
1259
- logger.error(`Failed during link discovery or initial fetch for ${url}:`, error);
1260
- errorCount++;
1261
- }
1262
- }
1263
- logger.info(`Crawl completed. Processed: ${processedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
1264
- }
1265
- shouldProcessUrl(url) {
1266
- const parsedUrl = new URL(url);
1267
- const pathname = parsedUrl.pathname;
1268
- const ext = path.extname(pathname);
1269
- if (!ext)
1270
- return true;
1271
- return ['.html', '.htm'].includes(ext.toLowerCase());
1272
- }
1273
- normalizeUrl(url) {
1274
- try {
1275
- const urlObj = new URL(url);
1276
- urlObj.hash = '';
1277
- urlObj.search = '';
1278
- return urlObj.toString();
1279
- }
1280
- catch (error) {
1281
- return url;
1282
- }
1283
- }
1284
- buildUrl(href, currentUrl) {
1285
- try {
1286
- return new URL(href, currentUrl).toString();
1287
- }
1288
- catch (error) {
1289
- this.logger.warn(`Invalid URL found: ${href}`);
1290
- return '';
1291
- }
1292
- }
1293
- async processPage(url, sourceConfig) {
1294
- const logger = this.logger.child('page-processor');
1295
- logger.debug(`Processing page content from ${url}`);
1296
- let browser = null;
1297
- try {
1298
- browser = await puppeteer_1.default.launch({
1299
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
1300
- });
1301
- const page = await browser.newPage();
1302
- logger.debug(`Navigating to ${url}`);
1303
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
1304
- const htmlContent = await page.evaluate(() => {
1305
- const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
1306
- return mainContentElement.innerHTML;
1307
- });
1308
- if (htmlContent.length > sourceConfig.max_size) {
1309
- logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
1310
- await browser.close();
1311
- return null;
1312
- }
1313
- logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
1314
- const dom = new jsdom_1.JSDOM(htmlContent);
1315
- const document = dom.window.document;
1316
- document.querySelectorAll('pre').forEach((pre) => {
1317
- pre.classList.add('article-content');
1318
- pre.setAttribute('data-readable-content-score', '100');
1319
- this.markCodeParents(pre.parentElement);
1320
- });
1321
- logger.debug(`Applying Readability to extract main content`);
1322
- const reader = new readability_1.Readability(document, {
1323
- charThreshold: 20,
1324
- classesToPreserve: ['article-content'],
1325
- });
1326
- const article = reader.parse();
1327
- if (!article) {
1328
- logger.warn(`Failed to parse article content with Readability for ${url}`);
1329
- await browser.close();
1330
- return null;
1331
- }
1332
- logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
1333
- const cleanHtml = (0, sanitize_html_1.default)(article.content, {
1334
- allowedTags: [
1335
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1336
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1337
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1338
- ],
1339
- allowedAttributes: {
1340
- 'a': ['href'],
1341
- 'pre': ['class', 'data-language'],
1342
- 'code': ['class', 'data-language'],
1343
- 'div': ['class'],
1344
- 'span': ['class']
1345
- }
1346
- });
1347
- logger.debug(`Converting HTML to Markdown`);
1348
- const markdown = this.turndownService.turndown(cleanHtml);
1349
- logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
1350
- return markdown;
1351
- }
1352
- catch (error) {
1353
- logger.error(`Error processing page ${url}:`, error);
1354
- return null;
1355
- }
1356
- finally {
1357
- if (browser && browser.isConnected()) {
1358
- await browser.close();
1359
- logger.debug(`Browser closed for ${url}`);
1360
- }
1361
- }
1362
- }
1363
- markCodeParents(node) {
1364
- if (!node)
1365
- return;
1366
- if (node.querySelector('pre, code')) {
1367
- node.classList.add('article-content');
1368
- node.setAttribute('data-readable-content-score', '100');
1369
- }
1370
- this.markCodeParents(node.parentElement);
1371
- }
1372
- async chunkMarkdown(markdown, sourceConfig, url) {
1373
- const logger = this.logger.child('chunker');
1374
- logger.debug(`Chunking markdown from ${url} (${markdown.length} chars)`);
1375
- const MAX_TOKENS = 1000;
1376
- const chunks = [];
1377
- const lines = markdown.split("\n");
1378
- let currentChunk = "";
1379
- let headingHierarchy = [];
1380
- const processChunk = () => {
1381
- if (currentChunk.trim()) {
1382
- const tokens = this.tokenize(currentChunk);
1383
- if (tokens.length > MAX_TOKENS) {
1384
- logger.debug(`Chunk exceeds max token count (${tokens.length}), splitting into smaller chunks`);
1385
- let subChunk = "";
1386
- let tokenCount = 0;
1387
- const overlapSize = Math.floor(MAX_TOKENS * 0.05);
1388
- let lastTokens = [];
1389
- for (const token of tokens) {
1390
- if (tokenCount + 1 > MAX_TOKENS) {
1391
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1392
- subChunk = lastTokens.join("") + token;
1393
- tokenCount = lastTokens.length + 1;
1394
- lastTokens = [];
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})`);
1395
808
  }
1396
809
  else {
1397
- subChunk += token;
1398
- tokenCount++;
1399
- lastTokens.push(token);
1400
- if (lastTokens.length > overlapSize) {
1401
- lastTokens.shift();
1402
- }
810
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
1403
811
  }
1404
812
  }
1405
- if (subChunk) {
1406
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
813
+ catch (error) {
814
+ logger.error(`Error processing chunk in Qdrant:`, error);
1407
815
  }
1408
816
  }
1409
- else {
1410
- chunks.push(createDocumentChunk(currentChunk, headingHierarchy));
1411
- }
1412
817
  }
1413
- currentChunk = "";
1414
- };
1415
- const createDocumentChunk = (content, hierarchy) => {
1416
- const chunkId = this.generateHash(content);
1417
- logger.debug(`Created chunk ${chunkId.substring(0, 8)}... with ${content.length} chars`);
1418
- return {
1419
- content,
1420
- metadata: {
1421
- product_name: sourceConfig.product_name,
1422
- version: sourceConfig.version,
1423
- heading_hierarchy: [...hierarchy],
1424
- section: hierarchy[hierarchy.length - 1] || "Introduction",
1425
- chunk_id: chunkId,
1426
- url: url,
1427
- hash: this.generateHash(content)
1428
- }
1429
- };
1430
818
  };
1431
- for (const line of lines) {
1432
- if (line.startsWith("#")) {
1433
- processChunk();
1434
- const levelMatch = line.match(/^(#+)/);
1435
- let level = levelMatch ? levelMatch[1].length : 1;
1436
- const heading = line.replace(/^#+\s*/, "").trim();
1437
- logger.debug(`Found heading (level ${level}): ${heading}`);
1438
- while (headingHierarchy.length < level - 1) {
1439
- headingHierarchy.push("");
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++;
1440
834
  }
1441
- if (level <= headingHierarchy.length) {
1442
- headingHierarchy = headingHierarchy.slice(0, level - 1);
835
+ else {
836
+ logger.debug(`Skipping article #${article.id} (updated ${article.updated_at}, before ${startDate})`);
1443
837
  }
1444
- headingHierarchy[level - 1] = heading;
1445
838
  }
1446
- else {
1447
- currentChunk += `${line}\n`;
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));
1448
844
  }
1449
845
  }
1450
- processChunk();
1451
- logger.debug(`Chunking complete, created ${chunks.length} chunks`);
1452
- return chunks;
1453
- }
1454
- tokenize(text) {
1455
- return text.split(/(\s+)/).filter(token => token.length > 0);
846
+ logger.info(`Successfully processed ${processedArticles} of ${totalArticles} articles (filtered by date >= ${startDate})`);
1456
847
  }
1457
848
  async createEmbeddings(texts) {
1458
849
  const logger = this.logger.child('embeddings');
@@ -1470,9 +861,6 @@ class Doc2Vec {
1470
861
  return [];
1471
862
  }
1472
863
  }
1473
- generateHash(content) {
1474
- return crypto_1.default.createHash("sha256").update(content).digest("hex");
1475
- }
1476
864
  }
1477
865
  if (require.main === module) {
1478
866
  const configPath = process.argv[2] || 'config.yaml';