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 +30 -1
- package/dist/content-processor.js +46 -36
- package/dist/database.js +2 -2
- package/dist/doc2vec.js +250 -71
- package/package.json +2 -1
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 `'
|
|
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
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
|
1115
|
-
const query = `updated>${lastRunDate.split('T')[0]} 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
|
-
|
|
1124
|
-
|
|
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
|
-
//
|
|
1134
|
-
|
|
1135
|
-
|
|
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.
|
|
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",
|