doc2vec 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -176,7 +176,7 @@ Configuration is managed through two files:
176
176
  **Structure:**
177
177
 
178
178
  * `sources`: An array of source configurations.
179
- * `type`: Either `'website'`, `'github'`, `'local_directory'`, `'code'`, or `'zendesk'`
179
+ * `type`: Either `'website'`, `'github'`, `'local_directory'`, `'code'`, `'zendesk'`, or `'s3'`
180
180
 
181
181
  For websites (`type: 'website'`):
182
182
  * `url`: The starting URL for crawling the documentation site.
@@ -219,6 +219,20 @@ Configuration is managed through two files:
219
219
  * `ticket_status`: (Optional) Filter tickets by status (defaults to `['new', 'open', 'pending', 'hold', 'solved']`).
220
220
  * `ticket_priority`: (Optional) Filter tickets by priority (defaults to all priorities).
221
221
 
222
+ For S3 buckets (`type: 's3'`):
223
+ * `bucket`: The S3 bucket name.
224
+ * `prefix`: (Optional) Key prefix to filter objects (e.g., `'docs/'`). Only objects under this prefix will be processed.
225
+ * `region`: (Optional) AWS region (defaults to `AWS_DEFAULT_REGION` environment variable or `'us-east-1'`).
226
+ * `endpoint`: (Optional) Custom S3 endpoint for S3-compatible services (MinIO, LocalStack, etc.).
227
+ * `include_extensions`: (Optional) Array of file extensions to include (e.g., `['.md', '.txt', '.pdf']`). Defaults to `['.md', '.txt', '.html', '.htm', '.pdf', '.doc', '.docx']`.
228
+ * `exclude_extensions`: (Optional) Array of file extensions to exclude.
229
+ * `encoding`: (Optional) Text file encoding (defaults to `'utf8'`). Does not apply to binary files (PDF, DOC, DOCX).
230
+ * `url_rewrite_prefix`: (Optional) URL prefix to rewrite `s3://` URLs (e.g., `'https://docs.example.com'`).
231
+
232
+ Authentication uses the AWS SDK default credential chain: environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), `~/.aws/credentials`, IAM roles, etc.
233
+
234
+ Incremental sync tracks object `LastModified` timestamps so only new or updated objects are processed on subsequent runs. Deleted objects are automatically cleaned up.
235
+
222
236
  Common configuration for all types:
223
237
  * `product_name`: A string identifying the product (used in metadata).
224
238
  * `version`: A string identifying the product version (used in metadata).
@@ -349,6 +363,21 @@ Configuration is managed through two files:
349
363
  params:
350
364
  db_path: './zendesk-kb.db'
351
365
 
366
+ # S3 bucket source example
367
+ - type: 's3'
368
+ product_name: 'my-docs'
369
+ version: 'latest'
370
+ bucket: 'my-documentation-bucket'
371
+ prefix: 'docs/v2/'
372
+ region: 'us-west-2'
373
+ include_extensions: ['.md', '.txt', '.pdf', '.html']
374
+ url_rewrite_prefix: 'https://docs.example.com'
375
+ max_size: 1048576
376
+ database_config:
377
+ type: 'sqlite'
378
+ params:
379
+ db_path: './s3-docs.db'
380
+
352
381
  # Qdrant example
353
382
  - type: 'website'
354
383
  product_name: 'Istio'
@@ -1306,6 +1306,39 @@ class ContentProcessor {
1306
1306
  throw error;
1307
1307
  }
1308
1308
  }
1309
+ async convertFileToMarkdown(filePath, extension, logger) {
1310
+ const ext = extension.toLowerCase();
1311
+ if (ext === '.pdf') {
1312
+ return this.convertPdfToMarkdown(filePath, logger);
1313
+ }
1314
+ else if (ext === '.doc') {
1315
+ return this.convertDocToMarkdown(filePath, logger);
1316
+ }
1317
+ else if (ext === '.docx') {
1318
+ return this.convertDocxToMarkdown(filePath, logger);
1319
+ }
1320
+ else if (ext === '.html' || ext === '.htm') {
1321
+ const content = fs.readFileSync(filePath, { encoding: 'utf8' });
1322
+ const cleanHtml = (0, sanitize_html_1.default)(content, {
1323
+ allowedTags: [
1324
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1325
+ 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1326
+ 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1327
+ ],
1328
+ allowedAttributes: {
1329
+ 'a': ['href'],
1330
+ 'pre': ['class', 'data-language'],
1331
+ 'code': ['class', 'data-language'],
1332
+ 'div': ['class'],
1333
+ 'span': ['class']
1334
+ }
1335
+ });
1336
+ return this.turndownService.turndown(cleanHtml);
1337
+ }
1338
+ else {
1339
+ throw new Error(`Unsupported file extension for conversion: ${extension}`);
1340
+ }
1341
+ }
1309
1342
  async downloadAndConvertPdfFromUrl(url, logger) {
1310
1343
  logger.debug(`Downloading and converting PDF from URL: ${url}`);
1311
1344
  try {
@@ -1428,20 +1461,18 @@ class ContentProcessor {
1428
1461
  logger.info(`Reading file: ${filePath}`);
1429
1462
  let content;
1430
1463
  let processedContent;
1431
- if (extension === '.pdf') {
1432
- // Handle PDF files
1433
- logger.debug(`Processing PDF file: ${filePath}`);
1434
- processedContent = await this.convertPdfToMarkdown(filePath, logger);
1435
- }
1436
- else if (extension === '.doc') {
1437
- // Handle legacy Word DOC files
1438
- logger.debug(`Processing DOC file: ${filePath}`);
1439
- processedContent = await this.convertDocToMarkdown(filePath, logger);
1440
- }
1441
- else if (extension === '.docx') {
1442
- // Handle modern Word DOCX files
1443
- logger.debug(`Processing DOCX file: ${filePath}`);
1444
- processedContent = await this.convertDocxToMarkdown(filePath, logger);
1464
+ const convertibleExtensions = ['.pdf', '.doc', '.docx', '.html', '.htm'];
1465
+ if (convertibleExtensions.includes(extension)) {
1466
+ if (extension === '.html' || extension === '.htm') {
1467
+ // For HTML, check raw file size before converting
1468
+ content = fs.readFileSync(filePath, { encoding: encoding });
1469
+ if (content.length > config.max_size) {
1470
+ logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
1471
+ skippedFiles++;
1472
+ continue;
1473
+ }
1474
+ }
1475
+ processedContent = await this.convertFileToMarkdown(filePath, extension, logger);
1445
1476
  }
1446
1477
  else {
1447
1478
  // Handle text-based files
@@ -1451,28 +1482,7 @@ class ContentProcessor {
1451
1482
  skippedFiles++;
1452
1483
  continue;
1453
1484
  }
1454
- // Convert HTML to Markdown if needed
1455
- if (extension === '.html' || extension === '.htm') {
1456
- logger.debug(`Converting HTML to Markdown for ${filePath}`);
1457
- const cleanHtml = (0, sanitize_html_1.default)(content, {
1458
- allowedTags: [
1459
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1460
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1461
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1462
- ],
1463
- allowedAttributes: {
1464
- 'a': ['href'],
1465
- 'pre': ['class', 'data-language'],
1466
- 'code': ['class', 'data-language'],
1467
- 'div': ['class'],
1468
- 'span': ['class']
1469
- }
1470
- });
1471
- processedContent = this.turndownService.turndown(cleanHtml);
1472
- }
1473
- else {
1474
- processedContent = content;
1475
- }
1485
+ processedContent = content;
1476
1486
  }
1477
1487
  // Check size limit for processed content
1478
1488
  if (processedContent.length > config.max_size) {
package/dist/database.js CHANGED
@@ -257,8 +257,8 @@ class DatabaseManager {
257
257
  logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
258
258
  return defaultDate;
259
259
  }
260
- static async updateLastRunDate(dbConnection, repo, logger, embeddingDimension) {
261
- const now = new Date().toISOString();
260
+ static async updateLastRunDate(dbConnection, repo, logger, embeddingDimension, date) {
261
+ const now = date;
262
262
  try {
263
263
  if (dbConnection.type === 'sqlite') {
264
264
  const metadataKey = `last_run_${repo.replace('/', '_')}`;
package/dist/doc2vec.js CHANGED
@@ -53,6 +53,7 @@ const logger_1 = require("./logger");
53
53
  const utils_1 = require("./utils");
54
54
  const database_1 = require("./database");
55
55
  const content_processor_1 = require("./content-processor");
56
+ const client_s3_1 = require("@aws-sdk/client-s3");
56
57
  const markdown_store_1 = require("./markdown-store");
57
58
  const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
58
59
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
@@ -189,6 +190,9 @@ class Doc2Vec {
189
190
  else if (sourceConfig.type === 'zendesk') {
190
191
  await this.processZendesk(sourceConfig, sourceLogger);
191
192
  }
193
+ else if (sourceConfig.type === 's3') {
194
+ await this.processS3(sourceConfig, sourceLogger);
195
+ }
192
196
  else {
193
197
  sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
194
198
  }
@@ -204,6 +208,8 @@ class Doc2Vec {
204
208
  const GITHUB_API_URL = `https://api.github.com/repos/${owner}/${repoName}/issues`;
205
209
  // Initialize metadata storage if needed
206
210
  await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
211
+ // Capture timestamp at the start so issues updated during sync are re-processed next run
212
+ const syncStartDate = new Date().toISOString();
207
213
  // Get the last run date from the database
208
214
  const startDate = sourceConfig.start_date || '2025-01-01';
209
215
  const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
@@ -402,7 +408,7 @@ class Doc2Vec {
402
408
  await processIssue(issues[i]);
403
409
  }
404
410
  // Update the last run date in the database after processing all issues
405
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension);
411
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension, syncStartDate);
406
412
  logger.info(`Successfully processed ${issues.length} issues`);
407
413
  }
408
414
  async processGithubRepo(config, parentLogger) {
@@ -637,6 +643,191 @@ class Doc2Vec {
637
643
  }
638
644
  logger.info(`Finished processing local directory: ${config.path}`);
639
645
  }
646
+ async processS3(config, parentLogger) {
647
+ const logger = parentLogger.child('process');
648
+ logger.info(`Starting processing for S3 bucket: ${config.bucket}${config.prefix ? ` (prefix: ${config.prefix})` : ''}`);
649
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
650
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
651
+ const processedFiles = new Set();
652
+ // Capture timestamp at the start so objects modified during sync are re-processed next run
653
+ const syncStartTimestamp = Date.now();
654
+ const s3Client = new client_s3_1.S3Client({
655
+ region: config.region || process.env.AWS_DEFAULT_REGION || 'us-east-1',
656
+ ...(config.endpoint ? { endpoint: config.endpoint, forcePathStyle: true } : {})
657
+ });
658
+ // Metadata keys for incremental sync
659
+ const sanitizedPrefix = (config.prefix || '').replace(/[^a-zA-Z0-9]+/g, '_');
660
+ const bucketKey = `${config.bucket}_${sanitizedPrefix}`;
661
+ const lastSyncKey = `s3_last_sync_${bucketKey}`;
662
+ const fileListKey = `s3_filelist_${bucketKey}`;
663
+ const lastSyncValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, lastSyncKey, '0', logger);
664
+ const lastSyncTimestamp = parseInt(lastSyncValue || '0', 10);
665
+ const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm', '.pdf', '.doc', '.docx'];
666
+ const excludeExtensions = config.exclude_extensions || [];
667
+ const encoding = config.encoding || 'utf8';
668
+ // List all matching objects
669
+ logger.section('S3 OBJECT LISTING');
670
+ const allObjects = [];
671
+ let continuationToken;
672
+ do {
673
+ const response = await s3Client.send(new client_s3_1.ListObjectsV2Command({
674
+ Bucket: config.bucket,
675
+ Prefix: config.prefix || undefined,
676
+ ContinuationToken: continuationToken,
677
+ }));
678
+ for (const obj of response.Contents || []) {
679
+ if (!obj.Key || obj.Key.endsWith('/'))
680
+ continue; // skip folder markers
681
+ const extension = path.extname(obj.Key).toLowerCase();
682
+ if (excludeExtensions.includes(extension))
683
+ continue;
684
+ if (includeExtensions.length > 0 && !includeExtensions.includes(extension))
685
+ continue;
686
+ allObjects.push({
687
+ key: obj.Key,
688
+ lastModified: obj.LastModified,
689
+ size: obj.Size || 0,
690
+ });
691
+ }
692
+ continuationToken = response.NextContinuationToken;
693
+ } while (continuationToken);
694
+ logger.info(`Found ${allObjects.length} matching objects in S3 bucket`);
695
+ // Process objects
696
+ logger.section('S3 FILE PROCESSING AND EMBEDDING');
697
+ let processedCount = 0;
698
+ let skippedCount = 0;
699
+ for (const obj of allObjects) {
700
+ processedFiles.add(obj.key);
701
+ // Incremental check: skip if object hasn't been modified since last sync
702
+ if (lastSyncTimestamp > 0 && obj.lastModified.getTime() <= lastSyncTimestamp) {
703
+ logger.debug(`Skipping unchanged object: ${obj.key}`);
704
+ skippedCount++;
705
+ continue;
706
+ }
707
+ // Size check
708
+ if (obj.size > config.max_size) {
709
+ logger.warn(`Object ${obj.key} (${obj.size} bytes) exceeds max_size (${config.max_size}). Skipping.`);
710
+ skippedCount++;
711
+ continue;
712
+ }
713
+ logger.info(`Processing S3 object: ${obj.key} (${obj.size} bytes)`);
714
+ try {
715
+ const getResponse = await s3Client.send(new client_s3_1.GetObjectCommand({
716
+ Bucket: config.bucket,
717
+ Key: obj.key,
718
+ }));
719
+ const extension = path.extname(obj.key).toLowerCase();
720
+ let content;
721
+ const binaryExtensions = ['.pdf', '.doc', '.docx'];
722
+ if (binaryExtensions.includes(extension)) {
723
+ // Binary files: write to temp file and convert
724
+ const bodyBytes = await getResponse.Body.transformToByteArray();
725
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 's3-doc2vec-'));
726
+ const tempFilePath = path.join(tempDir, path.basename(obj.key));
727
+ try {
728
+ fs.writeFileSync(tempFilePath, buffer_1.Buffer.from(bodyBytes));
729
+ content = await this.contentProcessor.convertFileToMarkdown(tempFilePath, extension, logger);
730
+ }
731
+ finally {
732
+ // Cleanup temp files
733
+ try {
734
+ fs.unlinkSync(tempFilePath);
735
+ }
736
+ catch { }
737
+ try {
738
+ fs.rmdirSync(tempDir);
739
+ }
740
+ catch { }
741
+ }
742
+ }
743
+ else if (extension === '.html' || extension === '.htm') {
744
+ // HTML files: write to temp, convert via contentProcessor
745
+ const bodyString = await getResponse.Body.transformToString(encoding);
746
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 's3-doc2vec-'));
747
+ const tempFilePath = path.join(tempDir, path.basename(obj.key));
748
+ try {
749
+ fs.writeFileSync(tempFilePath, bodyString, { encoding });
750
+ content = await this.contentProcessor.convertFileToMarkdown(tempFilePath, extension, logger);
751
+ }
752
+ finally {
753
+ try {
754
+ fs.unlinkSync(tempFilePath);
755
+ }
756
+ catch { }
757
+ try {
758
+ fs.rmdirSync(tempDir);
759
+ }
760
+ catch { }
761
+ }
762
+ }
763
+ else {
764
+ // Text files: read directly as string
765
+ content = await getResponse.Body.transformToString(encoding);
766
+ }
767
+ if (content.length > config.max_size) {
768
+ logger.warn(`Processed content for ${obj.key} (${content.length} chars) exceeds max_size. Skipping.`);
769
+ skippedCount++;
770
+ continue;
771
+ }
772
+ // Generate URL
773
+ let fileUrl;
774
+ if (config.url_rewrite_prefix) {
775
+ const prefix = config.url_rewrite_prefix.endsWith('/')
776
+ ? config.url_rewrite_prefix.slice(0, -1)
777
+ : config.url_rewrite_prefix;
778
+ const relativePath = config.prefix
779
+ ? obj.key.substring(config.prefix.length).replace(/^\//, '')
780
+ : obj.key;
781
+ fileUrl = `${prefix}/${relativePath}`;
782
+ }
783
+ else {
784
+ fileUrl = `s3://${config.bucket}/${obj.key}`;
785
+ }
786
+ const chunks = await this.contentProcessor.chunkMarkdown(content, config, fileUrl);
787
+ logger.info(`Created ${chunks.length} chunks for ${obj.key}`);
788
+ await this.processChunksForUrl(chunks, fileUrl, dbConnection, logger);
789
+ processedCount++;
790
+ }
791
+ catch (error) {
792
+ logger.error(`Error processing S3 object ${obj.key}:`, error);
793
+ }
794
+ }
795
+ logger.info(`Processed: ${processedCount}, Skipped: ${skippedCount}`);
796
+ // Cleanup: remove chunks for deleted objects
797
+ logger.section('CLEANUP');
798
+ const previousListValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, fileListKey, '[]', logger);
799
+ const previousList = previousListValue ? JSON.parse(previousListValue) : [];
800
+ const deletedKeys = previousList.filter(key => !processedFiles.has(key));
801
+ if (deletedKeys.length > 0) {
802
+ logger.info(`Removing chunks for ${deletedKeys.length} deleted objects`);
803
+ for (const deletedKey of deletedKeys) {
804
+ let fileUrl;
805
+ if (config.url_rewrite_prefix) {
806
+ const prefix = config.url_rewrite_prefix.endsWith('/')
807
+ ? config.url_rewrite_prefix.slice(0, -1)
808
+ : config.url_rewrite_prefix;
809
+ const relativePath = config.prefix
810
+ ? deletedKey.substring(config.prefix.length).replace(/^\//, '')
811
+ : deletedKey;
812
+ fileUrl = `${prefix}/${relativePath}`;
813
+ }
814
+ else {
815
+ fileUrl = `s3://${config.bucket}/${deletedKey}`;
816
+ }
817
+ if (dbConnection.type === 'sqlite') {
818
+ database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, fileUrl, logger);
819
+ }
820
+ else if (dbConnection.type === 'qdrant') {
821
+ await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, fileUrl, logger);
822
+ }
823
+ }
824
+ }
825
+ // Persist sync state
826
+ const currentKeys = Array.from(processedFiles);
827
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentKeys), logger, this.embeddingDimension);
828
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, lastSyncKey, `${syncStartTimestamp}`, logger, this.embeddingDimension);
829
+ logger.info(`Finished processing S3 bucket: ${config.bucket}`);
830
+ }
640
831
  async processCodeSource(config, parentLogger) {
641
832
  const logger = parentLogger.child('process');
642
833
  logger.info(`Starting processing for code source (${config.source})`);
@@ -974,9 +1165,14 @@ class Doc2Vec {
974
1165
  async fetchAndProcessZendeskTickets(config, dbConnection, logger) {
975
1166
  const baseUrl = `https://${config.zendesk_subdomain}.zendesk.com/api/v2`;
976
1167
  const auth = buffer_1.Buffer.from(`${config.email}/token:${config.api_token}`).toString('base64');
1168
+ // Capture timestamp at the start so tickets updated during sync are re-processed next run
1169
+ const syncStartDate = new Date().toISOString();
977
1170
  // Get the last run date from the database
978
1171
  const startDate = config.start_date || `${new Date().getFullYear()}-01-01`;
979
1172
  const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, `${startDate}T00:00:00Z`, logger);
1173
+ // Status filter applied client-side (includes 'closed' by default so tickets
1174
+ // transitioning to closed get updated rather than left stale)
1175
+ const statusFilter = new Set(config.ticket_status || ['new', 'open', 'pending', 'hold', 'solved', 'closed']);
980
1176
  const fetchWithRetry = async (url, retries = 3) => {
981
1177
  for (let attempt = 0; attempt < retries; attempt++) {
982
1178
  try {
@@ -986,15 +1182,19 @@ class Doc2Vec {
986
1182
  'Content-Type': 'application/json',
987
1183
  },
988
1184
  });
989
- if (response.status === 429) {
990
- const retryAfter = parseInt(response.headers['retry-after'] || '60');
991
- logger.warn(`Rate limited, waiting ${retryAfter}s before retry`);
992
- await new Promise(res => setTimeout(res, retryAfter * 1000));
993
- continue;
994
- }
995
1185
  return response.data;
996
1186
  }
997
1187
  catch (error) {
1188
+ // 403 is a permissions error — retrying won't help
1189
+ if (error.response?.status === 403)
1190
+ throw error;
1191
+ if (error.response?.status === 429) {
1192
+ const retryAfter = parseInt(error.response.headers?.['retry-after'] || '60', 10);
1193
+ logger.warn(`Rate limited by Zendesk, waiting ${retryAfter}s before retry`);
1194
+ await new Promise(res => setTimeout(res, retryAfter * 1000));
1195
+ attempt--; // Don't burn a retry on rate-limit waits
1196
+ continue;
1197
+ }
998
1198
  logger.error(`Zendesk API error (attempt ${attempt + 1}):`, error.message);
999
1199
  if (attempt === retries - 1)
1000
1200
  throw error;
@@ -1038,6 +1238,22 @@ class Doc2Vec {
1038
1238
  const processTicket = async (ticket) => {
1039
1239
  const ticketId = ticket.id;
1040
1240
  const url = `https://${config.zendesk_subdomain}.zendesk.com/agent/tickets/${ticketId}`;
1241
+ // Deleted tickets — remove their chunks and stop
1242
+ if (ticket.status === 'deleted') {
1243
+ logger.info(`Ticket #${ticketId} was deleted in Zendesk — removing its chunks`);
1244
+ if (dbConnection.type === 'sqlite') {
1245
+ database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1246
+ }
1247
+ else {
1248
+ await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1249
+ }
1250
+ return;
1251
+ }
1252
+ // Skip tickets whose status is outside the configured filter
1253
+ if (!statusFilter.has(ticket.status)) {
1254
+ logger.debug(`Ticket #${ticketId} has status '${ticket.status}' outside configured filter — skipping`);
1255
+ return;
1256
+ }
1041
1257
  logger.info(`Processing ticket #${ticketId}`);
1042
1258
  // Fetch ticket comments
1043
1259
  const commentsUrl = `${baseUrl}/tickets/${ticketId}/comments.json`;
@@ -1053,86 +1269,49 @@ class Doc2Vec {
1053
1269
  };
1054
1270
  const chunks = await this.contentProcessor.chunkMarkdown(markdown, ticketConfig, url);
1055
1271
  logger.info(`Ticket #${ticketId}: Created ${chunks.length} chunks`);
1056
- // Process and store each chunk
1057
- for (const chunk of chunks) {
1058
- const chunkHash = utils_1.Utils.generateHash(chunk.content);
1059
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
1060
- if (dbConnection.type === 'sqlite') {
1061
- const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
1062
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
1063
- if (existing && existing.hash === chunkHash) {
1064
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
1065
- continue;
1066
- }
1067
- const embeddings = await this.createEmbeddings([chunk.content]);
1068
- if (embeddings.length) {
1069
- database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
1070
- logger.debug(`Stored chunk ${chunkId} in SQLite`);
1071
- }
1072
- else {
1073
- logger.error(`Embedding failed for chunk: ${chunkId}`);
1074
- }
1075
- }
1076
- else if (dbConnection.type === 'qdrant') {
1077
- try {
1078
- let pointId;
1079
- try {
1080
- pointId = chunk.metadata.chunk_id;
1081
- if (!utils_1.Utils.isValidUuid(pointId)) {
1082
- pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
1083
- }
1084
- }
1085
- catch (e) {
1086
- pointId = crypto_1.default.randomUUID();
1087
- }
1088
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
1089
- ids: [pointId],
1090
- with_payload: true,
1091
- with_vector: false,
1092
- });
1093
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
1094
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
1095
- continue;
1096
- }
1097
- const embeddings = await this.createEmbeddings([chunk.content]);
1098
- if (embeddings.length) {
1099
- await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
1100
- logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
1101
- }
1102
- else {
1103
- logger.error(`Embedding failed for chunk: ${chunkId}`);
1104
- }
1105
- }
1106
- catch (error) {
1107
- logger.error(`Error processing chunk in Qdrant:`, error);
1108
- }
1109
- }
1110
- }
1272
+ // Use processChunksForUrl which performs a URL-level diff:
1273
+ // deletes all existing chunks for this URL before reinserting,
1274
+ // so stale chunks from previous versions are never left behind.
1275
+ await this.processChunksForUrl(chunks, url, dbConnection, logger);
1111
1276
  };
1112
1277
  logger.info(`Fetching Zendesk tickets updated since ${lastRunDate}`);
1113
- // Build query parameters
1114
- const statusFilter = config.ticket_status || ['new', 'open', 'pending', 'hold', 'solved'];
1115
- const query = `updated>${lastRunDate.split('T')[0]} status:${statusFilter.join(',status:')}`;
1278
+ // Build query parameters — use the status filter for the search query
1279
+ const statusList = Array.from(statusFilter);
1280
+ const query = `updated>${lastRunDate.split('T')[0]} status:${statusList.join(',status:')}`;
1116
1281
  let nextPage = `${baseUrl}/search.json?query=${encodeURIComponent(query)}&sort_by=updated_at&sort_order=asc`;
1117
1282
  let totalTickets = 0;
1283
+ let failedTickets = 0;
1118
1284
  while (nextPage) {
1119
1285
  const data = await fetchWithRetry(nextPage);
1120
1286
  const tickets = data.results || [];
1121
1287
  logger.info(`Processing batch of ${tickets.length} tickets`);
1122
1288
  for (const ticket of tickets) {
1123
- await processTicket(ticket);
1124
- totalTickets++;
1289
+ try {
1290
+ await processTicket(ticket);
1291
+ totalTickets++;
1292
+ }
1293
+ catch (error) {
1294
+ failedTickets++;
1295
+ logger.error(`Failed to process ticket #${ticket.id}, will retry next run: ${error.message}`);
1296
+ }
1125
1297
  }
1126
- nextPage = data.next_page;
1298
+ nextPage = data.next_page || null;
1127
1299
  if (nextPage) {
1128
1300
  logger.debug(`Fetching next page: ${nextPage}`);
1129
1301
  // Rate limiting: wait between requests
1130
1302
  await new Promise(res => setTimeout(res, 1000));
1131
1303
  }
1132
1304
  }
1133
- // Update the last run date in the database
1134
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension);
1135
- logger.info(`Successfully processed ${totalTickets} tickets`);
1305
+ // Only advance the watermark when all tickets succeeded
1306
+ if (failedTickets === 0) {
1307
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension, syncStartDate);
1308
+ logger.info(`Successfully processed ${totalTickets} tickets`);
1309
+ }
1310
+ else {
1311
+ logger.warn(`Run completed with ${failedTickets} ticket failure(s). ` +
1312
+ `Watermark NOT advanced — failed tickets will be retried next run. ` +
1313
+ `Successfully processed: ${totalTickets}.`);
1314
+ }
1136
1315
  }
1137
1316
  async fetchAndProcessZendeskArticles(config, dbConnection, logger) {
1138
1317
  const baseUrl = `https://${config.zendesk_subdomain}.zendesk.com/api/v2/help_center`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",
@@ -29,6 +29,7 @@
29
29
  "author": "",
30
30
  "license": "ISC",
31
31
  "dependencies": {
32
+ "@aws-sdk/client-s3": "^3.1004.0",
32
33
  "@chonkiejs/core": "^0.0.7",
33
34
  "@mozilla/readability": "^0.4.4",
34
35
  "@qdrant/js-client-rest": "^1.13.0",