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/README.md +111 -15
- package/dist/content-processor.js +748 -0
- package/dist/database.js +507 -0
- package/dist/doc2vec.js +356 -968
- package/dist/embedding-providers.js +157 -0
- package/dist/types.js +2 -0
- package/dist/utils.js +118 -0
- package/package.json +8 -2
- package/Dockerfile +0 -29
- package/config.yaml +0 -167
- package/doc2vec.ts +0 -1734
- package/logger.ts +0 -287
- package/mcp/Dockerfile +0 -14
- package/mcp/README.md +0 -166
- package/mcp/package-lock.json +0 -1754
- package/mcp/package.json +0 -41
- package/mcp/src/index.ts +0 -214
- package/mcp/tsconfig.json +0 -16
- package/tsconfig.json +0 -18
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 } =
|
|
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
|
-
|
|
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 (!
|
|
436
|
-
pointId =
|
|
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
|
|
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
|
|
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
|
|
282
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
482
283
|
// Initialize metadata storage
|
|
483
|
-
await
|
|
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
|
|
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 =
|
|
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 =
|
|
311
|
+
const chunkHash = utils_1.Utils.generateHash(chunk.content);
|
|
511
312
|
if (dbConnection.type === 'sqlite') {
|
|
512
|
-
const { checkHashStmt } =
|
|
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 (!
|
|
526
|
-
pointId =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
576
|
-
logger.
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
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 =
|
|
436
|
+
const chunkHash = utils_1.Utils.generateHash(chunk.content);
|
|
631
437
|
if (dbConnection.type === 'sqlite') {
|
|
632
|
-
const { checkHashStmt } =
|
|
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 (!
|
|
646
|
-
pointId =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
705
|
-
const logger = parentLogger.child('
|
|
706
|
-
logger.info(`
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
729
|
-
|
|
549
|
+
return response.data;
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
logger.error(`Zendesk API error (attempt ${attempt + 1}):`, error.message);
|
|
553
|
+
if (attempt === retries - 1)
|
|
554
|
+
throw error;
|
|
555
|
+
await new Promise(res => setTimeout(res, 2000 * (attempt + 1)));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
const generateMarkdownForTicket = (ticket, comments) => {
|
|
560
|
+
let md = `# Ticket #${ticket.id}: ${ticket.subject}\n\n`;
|
|
561
|
+
md += `- **Status:** ${ticket.status}\n`;
|
|
562
|
+
md += `- **Priority:** ${ticket.priority || 'None'}\n`;
|
|
563
|
+
md += `- **Type:** ${ticket.type || 'None'}\n`;
|
|
564
|
+
md += `- **Requester:** ${ticket.requester_id}\n`;
|
|
565
|
+
md += `- **Assignee:** ${ticket.assignee_id || 'Unassigned'}\n`;
|
|
566
|
+
md += `- **Created:** ${new Date(ticket.created_at).toDateString()}\n`;
|
|
567
|
+
md += `- **Updated:** ${new Date(ticket.updated_at).toDateString()}\n`;
|
|
568
|
+
if (ticket.tags && ticket.tags.length > 0) {
|
|
569
|
+
md += `- **Tags:** ${ticket.tags.map((tag) => `\`${tag}\``).join(', ')}\n`;
|
|
570
|
+
}
|
|
571
|
+
// Handle ticket description
|
|
572
|
+
const description = ticket.description || '';
|
|
573
|
+
const cleanDescription = description || '_No description._';
|
|
574
|
+
md += `\n## Description\n\n${cleanDescription}\n\n`;
|
|
575
|
+
if (comments && comments.length > 0) {
|
|
576
|
+
md += `## Comments\n\n`;
|
|
577
|
+
for (const comment of comments) {
|
|
578
|
+
if (comment.public) {
|
|
579
|
+
md += `### ${comment.author_id} - ${new Date(comment.created_at).toDateString()}\n\n`;
|
|
580
|
+
// Handle comment body
|
|
581
|
+
const rawBody = comment.plain_body || comment.html_body || comment.body || '';
|
|
582
|
+
const commentBody = rawBody.replace(/ /g, " ") || '_No content._';
|
|
583
|
+
md += `${commentBody}\n\n---\n\n`;
|
|
730
584
|
}
|
|
731
585
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
logger.debug(`
|
|
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
|
-
|
|
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
|
|
661
|
+
logger.error(`Error processing chunk in Qdrant:`, error);
|
|
781
662
|
}
|
|
782
663
|
}
|
|
783
664
|
}
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
834
|
-
logger.
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
//
|
|
904
|
-
|
|
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
|
-
|
|
916
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
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
|
-
|
|
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';
|