doc2vec 1.1.0 → 1.2.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.
@@ -1,1437 +0,0 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
- if (k2 === undefined) k2 = k;
5
- var desc = Object.getOwnPropertyDescriptor(m, k);
6
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
- desc = { enumerable: true, get: function() { return m[k]; } };
8
- }
9
- Object.defineProperty(o, k2, desc);
10
- }) : (function(o, m, k, k2) {
11
- if (k2 === undefined) k2 = k;
12
- o[k2] = m[k];
13
- }));
14
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
- Object.defineProperty(o, "default", { enumerable: true, value: v });
16
- }) : function(o, v) {
17
- o["default"] = v;
18
- });
19
- var __importStar = (this && this.__importStar) || (function () {
20
- var ownKeys = function(o) {
21
- ownKeys = Object.getOwnPropertyNames || function (o) {
22
- var ar = [];
23
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
- return ar;
25
- };
26
- return ownKeys(o);
27
- };
28
- return function (mod) {
29
- if (mod && mod.__esModule) return mod;
30
- var result = {};
31
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
- __setModuleDefault(result, mod);
33
- return result;
34
- };
35
- })();
36
- var __importDefault = (this && this.__importDefault) || function (mod) {
37
- return (mod && mod.__esModule) ? mod : { "default": mod };
38
- };
39
- Object.defineProperty(exports, "__esModule", { value: true });
40
- const readability_1 = require("@mozilla/readability");
41
- const axios_1 = __importDefault(require("axios"));
42
- const cheerio_1 = require("cheerio");
43
- 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
- const yaml = __importStar(require("js-yaml"));
49
- const fs = __importStar(require("fs"));
50
- const path = __importStar(require("path"));
51
- const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
52
- const openai_1 = require("openai");
53
- const dotenv = __importStar(require("dotenv"));
54
- const sqliteVec = __importStar(require("sqlite-vec"));
55
- const js_client_rest_1 = require("@qdrant/js-client-rest");
56
- const logger_1 = require("./logger");
57
- const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
58
- dotenv.config();
59
- class Doc2Vec {
60
- constructor(configPath) {
61
- this.logger = new logger_1.Logger('Doc2Vec', {
62
- level: logger_1.LogLevel.DEBUG,
63
- useTimestamp: true,
64
- useColor: true,
65
- prettyPrint: true
66
- });
67
- this.logger.info('Initializing Doc2Vec');
68
- this.config = this.loadConfig(configPath);
69
- this.openai = new openai_1.OpenAI({ apiKey: process.env.OPENAI_API_KEY });
70
- this.turndownService = new turndown_1.default({
71
- codeBlockStyle: 'fenced',
72
- headingStyle: 'atx'
73
- });
74
- this.setupTurndownRules();
75
- }
76
- loadConfig(configPath) {
77
- try {
78
- const logger = this.logger.child('config');
79
- logger.info(`Loading configuration from ${configPath}`);
80
- const configFile = fs.readFileSync(configPath, 'utf8');
81
- let config = yaml.load(configFile);
82
- const typedConfig = config;
83
- logger.info(`Configuration loaded successfully, found ${typedConfig.sources.length} sources`);
84
- return typedConfig;
85
- }
86
- catch (error) {
87
- this.logger.error(`Failed to load or parse config file at ${configPath}:`, error);
88
- process.exit(1);
89
- }
90
- }
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
- async run() {
175
- this.logger.section('PROCESSING SOURCES');
176
- for (const sourceConfig of this.config.sources) {
177
- const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
178
- sourceLogger.info(`Processing ${sourceConfig.type} source for ${sourceConfig.product_name}@${sourceConfig.version}`);
179
- if (sourceConfig.type === 'github') {
180
- await this.processGithubRepo(sourceConfig, sourceLogger);
181
- }
182
- else if (sourceConfig.type === 'website') {
183
- await this.processWebsite(sourceConfig, sourceLogger);
184
- }
185
- else if (sourceConfig.type === 'local_directory') {
186
- await this.processLocalDirectory(sourceConfig, sourceLogger);
187
- }
188
- else {
189
- sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
190
- }
191
- }
192
- this.logger.section('PROCESSING COMPLETE');
193
- }
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
- async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
315
- const [owner, repoName] = repo.split('/');
316
- const GITHUB_API_URL = `https://api.github.com/repos/${owner}/${repoName}/issues`;
317
- // Initialize metadata storage if needed
318
- await this.initDatabaseMetadata(dbConnection);
319
- // Get the last run date from the database
320
- const startDate = sourceConfig.start_date || '2025-01-01';
321
- const lastRunDate = await this.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`);
322
- const fetchWithRetry = async (url, params = {}, retries = 5, delay = 5000) => {
323
- for (let attempt = 0; attempt < retries; attempt++) {
324
- try {
325
- const response = await axios_1.default.get(url, {
326
- headers: {
327
- Authorization: `token ${GITHUB_TOKEN}`,
328
- Accept: 'application/vnd.github.v3+json',
329
- },
330
- params,
331
- });
332
- return response.data;
333
- }
334
- catch (error) {
335
- if (error.response && error.response.status === 403) {
336
- const resetTime = error.response.headers['x-ratelimit-reset'];
337
- const currentTime = Math.floor(Date.now() / 1000);
338
- const waitTime = resetTime ? (resetTime - currentTime) * 1000 : delay * 2;
339
- logger.warn(`GitHub rate limit exceeded. Waiting ${waitTime / 1000}s`);
340
- await new Promise(res => setTimeout(res, waitTime));
341
- }
342
- else {
343
- logger.error(`GitHub fetch failed: ${error.message}`);
344
- throw error;
345
- }
346
- }
347
- }
348
- throw new Error('Max retries reached');
349
- };
350
- const fetchAllIssues = async (sinceDate) => {
351
- let issues = [];
352
- let page = 1;
353
- const perPage = 100;
354
- const sinceTimestamp = new Date(sinceDate);
355
- while (true) {
356
- const data = await fetchWithRetry(GITHUB_API_URL, {
357
- per_page: perPage,
358
- page,
359
- state: 'all',
360
- since: sinceDate,
361
- });
362
- if (data.length === 0)
363
- break;
364
- const filtered = data.filter((issue) => new Date(issue.created_at) >= sinceTimestamp);
365
- issues = issues.concat(filtered);
366
- if (filtered.length < data.length)
367
- break;
368
- page++;
369
- }
370
- return issues;
371
- };
372
- const fetchIssueComments = async (issueNumber) => {
373
- const url = `${GITHUB_API_URL}/${issueNumber}/comments`;
374
- return await fetchWithRetry(url);
375
- };
376
- const generateMarkdownForIssue = async (issue) => {
377
- const comments = await fetchIssueComments(issue.number);
378
- let md = `# Issue #${issue.number}: ${issue.title}\n\n`;
379
- md += `- **Author:** ${issue.user.login}\n`;
380
- md += `- **State:** ${issue.state}\n`;
381
- md += `- **Created on:** ${new Date(issue.created_at).toDateString()}\n`;
382
- md += `- **Updated on:** ${new Date(issue.updated_at).toDateString()}\n`;
383
- md += `- **Labels:** ${issue.labels.map((l) => `\`${l.name}\``).join(', ') || 'None'}\n\n`;
384
- md += `## Description\n\n${issue.body || '_No description._'}\n\n## Comments\n\n`;
385
- if (comments.length === 0) {
386
- md += '_No comments._\n';
387
- }
388
- else {
389
- for (const c of comments) {
390
- md += `### ${c.user.login} - ${new Date(c.created_at).toDateString()}\n\n${c.body}\n\n---\n\n`;
391
- }
392
- }
393
- return md;
394
- };
395
- // Process a single issue and store its chunks
396
- const processIssue = async (issue) => {
397
- const issueNumber = issue.number;
398
- const url = `https://github.com/${repo}/issues/${issueNumber}`;
399
- logger.info(`Processing issue #${issueNumber}`);
400
- // Generate markdown for the issue
401
- const markdown = await generateMarkdownForIssue(issue);
402
- // Chunk the markdown content
403
- const issueConfig = {
404
- ...sourceConfig,
405
- product_name: sourceConfig.product_name || repo,
406
- max_size: sourceConfig.max_size || Infinity
407
- };
408
- const chunks = await this.chunkMarkdown(markdown, issueConfig, url);
409
- logger.info(`Issue #${issueNumber}: Created ${chunks.length} chunks`);
410
- // Process and store each chunk immediately
411
- for (const chunk of chunks) {
412
- const chunkHash = this.generateHash(chunk.content);
413
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
414
- if (dbConnection.type === 'sqlite') {
415
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
416
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
417
- if (existing && existing.hash === chunkHash) {
418
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
419
- continue;
420
- }
421
- const embeddings = await this.createEmbeddings([chunk.content]);
422
- if (embeddings.length) {
423
- this.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
424
- logger.debug(`Stored chunk ${chunkId} in SQLite`);
425
- }
426
- else {
427
- logger.error(`Embedding failed for chunk: ${chunkId}`);
428
- }
429
- }
430
- else if (dbConnection.type === 'qdrant') {
431
- try {
432
- let pointId;
433
- try {
434
- pointId = chunk.metadata.chunk_id;
435
- if (!this.isValidUuid(pointId)) {
436
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
437
- }
438
- }
439
- catch (e) {
440
- pointId = crypto_1.default.randomUUID();
441
- }
442
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
443
- ids: [pointId],
444
- with_payload: true,
445
- with_vector: false,
446
- });
447
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
448
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
449
- continue;
450
- }
451
- const embeddings = await this.createEmbeddings([chunk.content]);
452
- if (embeddings.length) {
453
- await this.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
454
- logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
455
- }
456
- else {
457
- logger.error(`Embedding failed for chunk: ${chunkId}`);
458
- }
459
- }
460
- catch (error) {
461
- logger.error(`Error processing chunk in Qdrant:`, error);
462
- }
463
- }
464
- }
465
- };
466
- logger.info(`Fetching GitHub issues for ${repo} since ${lastRunDate}`);
467
- const issues = await fetchAllIssues(lastRunDate);
468
- logger.info(`Found ${issues.length} updated/new issues`);
469
- // Process each issue individually, one at a time
470
- for (let i = 0; i < issues.length; i++) {
471
- logger.info(`Processing issue ${i + 1}/${issues.length}`);
472
- await processIssue(issues[i]);
473
- }
474
- // Update the last run date in the database after processing all issues
475
- await this.updateLastRunDate(dbConnection, repo);
476
- logger.info(`Successfully processed ${issues.length} issues`);
477
- }
478
- async processGithubRepo(config, parentLogger) {
479
- const logger = parentLogger.child('process');
480
- logger.info(`Starting processing for GitHub repo: ${config.repo}`);
481
- const dbConnection = await this.initDatabase(config, logger);
482
- // Initialize metadata storage
483
- await this.initDatabaseMetadata(dbConnection);
484
- logger.section('GITHUB ISSUES');
485
- // Process GitHub issues
486
- await this.fetchAndProcessGitHubIssues(config.repo, config, dbConnection, logger);
487
- logger.info(`Finished processing GitHub repo: ${config.repo}`);
488
- }
489
- async processWebsite(config, parentLogger) {
490
- const logger = parentLogger.child('process');
491
- logger.info(`Starting processing for website: ${config.url}`);
492
- const dbConnection = await this.initDatabase(config, logger);
493
- const validChunkIds = new Set();
494
- const visitedUrls = new Set();
495
- const urlPrefix = this.getUrlPrefix(config.url);
496
- logger.section('CRAWL AND EMBEDDING');
497
- await this.crawlWebsite(config.url, config, async (url, content) => {
498
- visitedUrls.add(url);
499
- logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
500
- try {
501
- const chunks = await this.chunkMarkdown(content, config, url);
502
- logger.info(`Created ${chunks.length} chunks`);
503
- if (chunks.length > 0) {
504
- const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
505
- for (let i = 0; i < chunks.length; i++) {
506
- const chunk = chunks[i];
507
- validChunkIds.add(chunk.metadata.chunk_id);
508
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
509
- let needsEmbedding = true;
510
- const chunkHash = this.generateHash(chunk.content);
511
- if (dbConnection.type === 'sqlite') {
512
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
513
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
514
- if (existing && existing.hash === chunkHash) {
515
- needsEmbedding = false;
516
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
517
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
518
- }
519
- }
520
- else if (dbConnection.type === 'qdrant') {
521
- try {
522
- let pointId;
523
- try {
524
- pointId = chunk.metadata.chunk_id;
525
- if (!this.isValidUuid(pointId)) {
526
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
527
- }
528
- }
529
- catch (e) {
530
- pointId = crypto_1.default.randomUUID();
531
- }
532
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
533
- ids: [pointId],
534
- with_payload: true,
535
- with_vector: false,
536
- });
537
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
538
- needsEmbedding = false;
539
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
540
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
541
- }
542
- }
543
- catch (error) {
544
- logger.error(`Error checking existing point in Qdrant:`, error);
545
- }
546
- }
547
- if (needsEmbedding) {
548
- const embeddings = await this.createEmbeddings([chunk.content]);
549
- if (embeddings.length > 0) {
550
- const embedding = embeddings[0];
551
- if (dbConnection.type === 'sqlite') {
552
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
553
- chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
554
- }
555
- else if (dbConnection.type === 'qdrant') {
556
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
557
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
558
- }
559
- }
560
- else {
561
- logger.error(`Embedding failed for chunk: ${chunkId}`);
562
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
563
- }
564
- }
565
- }
566
- chunkProgress.complete();
567
- }
568
- }
569
- catch (error) {
570
- logger.error(`Error during chunking or embedding for ${url}:`, error);
571
- }
572
- }, logger, visitedUrls);
573
- logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
574
- logger.section('CLEANUP');
575
- if (dbConnection.type === 'sqlite') {
576
- logger.info(`Running SQLite cleanup for ${urlPrefix}`);
577
- this.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
578
- }
579
- else if (dbConnection.type === 'qdrant') {
580
- logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
581
- await this.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
582
- }
583
- logger.info(`Finished processing website: ${config.url}`);
584
- }
585
- async processLocalDirectory(config, parentLogger) {
586
- const logger = parentLogger.child('process');
587
- logger.info(`Starting processing for local directory: ${config.path}`);
588
- const dbConnection = await this.initDatabase(config, logger);
589
- const validChunkIds = new Set();
590
- const processedFiles = new Set();
591
- logger.section('FILE SCANNING AND EMBEDDING');
592
- await this.processDirectory(config.path, config, async (filePath, content) => {
593
- processedFiles.add(filePath);
594
- logger.info(`Processing content from ${filePath} (${content.length} chars)`);
595
- try {
596
- // Generate URL based on configuration
597
- let fileUrl;
598
- if (config.url_rewrite_prefix) {
599
- // Replace local path with URL prefix
600
- const relativePath = path.relative(config.path, filePath).replace(/\\/g, '/');
601
- // If relativePath starts with '..', it means the file is outside the base directory
602
- if (relativePath.startsWith('..')) {
603
- // For files outside the configured path, use the default file:// scheme
604
- fileUrl = `file://${filePath}`;
605
- logger.debug(`File outside configured path, using default URL: ${fileUrl}`);
606
- }
607
- else {
608
- // For files inside the configured path, rewrite the URL
609
- // Handle trailing slashes in the URL prefix to avoid double slashes
610
- const prefix = config.url_rewrite_prefix.endsWith('/')
611
- ? config.url_rewrite_prefix.slice(0, -1)
612
- : config.url_rewrite_prefix;
613
- fileUrl = `${prefix}/${relativePath}`;
614
- logger.debug(`URL rewritten: ${filePath} -> ${fileUrl}`);
615
- }
616
- }
617
- else {
618
- // Use default file:// URL
619
- fileUrl = `file://${filePath}`;
620
- }
621
- const chunks = await this.chunkMarkdown(content, config, fileUrl);
622
- logger.info(`Created ${chunks.length} chunks`);
623
- if (chunks.length > 0) {
624
- const chunkProgress = logger.progress(`Embedding chunks for ${filePath}`, chunks.length);
625
- for (let i = 0; i < chunks.length; i++) {
626
- const chunk = chunks[i];
627
- validChunkIds.add(chunk.metadata.chunk_id);
628
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
629
- let needsEmbedding = true;
630
- const chunkHash = this.generateHash(chunk.content);
631
- if (dbConnection.type === 'sqlite') {
632
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
633
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
634
- if (existing && existing.hash === chunkHash) {
635
- needsEmbedding = false;
636
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
637
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
638
- }
639
- }
640
- else if (dbConnection.type === 'qdrant') {
641
- try {
642
- let pointId;
643
- try {
644
- pointId = chunk.metadata.chunk_id;
645
- if (!this.isValidUuid(pointId)) {
646
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
647
- }
648
- }
649
- catch (e) {
650
- pointId = crypto_1.default.randomUUID();
651
- }
652
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
653
- ids: [pointId],
654
- with_payload: true,
655
- with_vector: false,
656
- });
657
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
658
- needsEmbedding = false;
659
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
660
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
661
- }
662
- }
663
- catch (error) {
664
- logger.error(`Error checking existing point in Qdrant:`, error);
665
- }
666
- }
667
- if (needsEmbedding) {
668
- const embeddings = await this.createEmbeddings([chunk.content]);
669
- if (embeddings.length > 0) {
670
- const embedding = embeddings[0];
671
- if (dbConnection.type === 'sqlite') {
672
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
673
- chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
674
- }
675
- else if (dbConnection.type === 'qdrant') {
676
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
677
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
678
- }
679
- }
680
- else {
681
- logger.error(`Embedding failed for chunk: ${chunkId}`);
682
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
683
- }
684
- }
685
- }
686
- chunkProgress.complete();
687
- }
688
- }
689
- catch (error) {
690
- logger.error(`Error during chunking or embedding for ${filePath}:`, error);
691
- }
692
- }, logger);
693
- logger.section('CLEANUP');
694
- if (dbConnection.type === 'sqlite') {
695
- logger.info(`Running SQLite cleanup for local directory ${config.path}`);
696
- this.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
697
- }
698
- else if (dbConnection.type === 'qdrant') {
699
- logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
700
- await this.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
701
- }
702
- logger.info(`Finished processing local directory: ${config.path}`);
703
- }
704
- async processDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set()) {
705
- const logger = parentLogger.child('directory-processor');
706
- logger.info(`Processing directory: ${dirPath}`);
707
- const recursive = config.recursive !== undefined ? config.recursive : true;
708
- const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm'];
709
- const excludeExtensions = config.exclude_extensions || [];
710
- const encoding = config.encoding || 'utf8';
711
- try {
712
- const files = fs.readdirSync(dirPath);
713
- let processedFiles = 0;
714
- let skippedFiles = 0;
715
- for (const file of files) {
716
- const filePath = path.join(dirPath, file);
717
- const stat = fs.statSync(filePath);
718
- // Skip already visited paths
719
- if (visitedPaths.has(filePath)) {
720
- logger.debug(`Skipping already visited path: ${filePath}`);
721
- continue;
722
- }
723
- visitedPaths.add(filePath);
724
- if (stat.isDirectory()) {
725
- if (recursive) {
726
- await this.processDirectory(filePath, config, processFileContent, logger, visitedPaths);
727
- }
728
- else {
729
- logger.debug(`Skipping directory ${filePath} (recursive=false)`);
730
- }
731
- }
732
- else if (stat.isFile()) {
733
- const extension = path.extname(file).toLowerCase();
734
- // Apply extension filters
735
- if (excludeExtensions.includes(extension)) {
736
- logger.debug(`Skipping file with excluded extension: ${filePath}`);
737
- skippedFiles++;
738
- continue;
739
- }
740
- if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
741
- logger.debug(`Skipping file with non-included extension: ${filePath}`);
742
- skippedFiles++;
743
- continue;
744
- }
745
- try {
746
- logger.info(`Reading file: ${filePath}`);
747
- const content = fs.readFileSync(filePath, { encoding: encoding });
748
- if (content.length > config.max_size) {
749
- logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
750
- skippedFiles++;
751
- continue;
752
- }
753
- // Convert HTML to Markdown if needed
754
- let processedContent;
755
- if (extension === '.html' || extension === '.htm') {
756
- logger.debug(`Converting HTML to Markdown for ${filePath}`);
757
- const cleanHtml = (0, sanitize_html_1.default)(content, {
758
- allowedTags: [
759
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
760
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
761
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
762
- ],
763
- allowedAttributes: {
764
- 'a': ['href'],
765
- 'pre': ['class', 'data-language'],
766
- 'code': ['class', 'data-language'],
767
- 'div': ['class'],
768
- 'span': ['class']
769
- }
770
- });
771
- processedContent = this.turndownService.turndown(cleanHtml);
772
- }
773
- else {
774
- processedContent = content;
775
- }
776
- await processFileContent(filePath, processedContent);
777
- processedFiles++;
778
- }
779
- catch (error) {
780
- logger.error(`Error processing file ${filePath}:`, error);
781
- }
782
- }
783
- }
784
- logger.info(`Directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
785
- }
786
- catch (error) {
787
- logger.error(`Error reading directory ${dirPath}:`, error);
788
- }
789
- }
790
- removeObsoleteFilesSQLite(db, processedFiles, pathConfig, logger) {
791
- const getChunksForPathStmt = db.prepare(`
792
- SELECT chunk_id, url FROM vec_items
793
- WHERE url LIKE ? || '%'
794
- `);
795
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
796
- // Determine if we're using URL rewriting or direct file paths
797
- const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
798
- // Set up the URL prefix for searching
799
- let urlPrefix;
800
- if (isRewriteMode) {
801
- // Handle URL rewriting case
802
- urlPrefix = pathConfig.url_rewrite_prefix || '';
803
- urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
804
- }
805
- else {
806
- // Handle direct file path case
807
- const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
808
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
809
- urlPrefix = `file://${cleanedDirPrefix}`;
810
- }
811
- logger.debug(`Searching for chunks with URL prefix: ${urlPrefix}`);
812
- const existingChunks = getChunksForPathStmt.all(urlPrefix);
813
- let deletedCount = 0;
814
- const transaction = db.transaction(() => {
815
- for (const { chunk_id, url } of existingChunks) {
816
- // Skip if it's not from our URL prefix (safety check)
817
- if (!url.startsWith(urlPrefix))
818
- continue;
819
- let filePath;
820
- let shouldDelete = false;
821
- if (isRewriteMode) {
822
- // URL rewrite mode: extract relative path and construct full file path
823
- const config = pathConfig;
824
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
825
- filePath = path.join(config.path, relativePath);
826
- shouldDelete = !processedFiles.has(filePath);
827
- }
828
- else {
829
- // Direct file path mode: remove file:// prefix to match with processedFiles
830
- filePath = url.substring(7); // Remove 'file://' prefix
831
- shouldDelete = !processedFiles.has(filePath);
832
- }
833
- if (shouldDelete) {
834
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (File not processed: ${filePath})`);
835
- deleteChunkStmt.run(chunk_id);
836
- deletedCount++;
837
- }
838
- }
839
- });
840
- transaction();
841
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL prefix ${urlPrefix}`);
842
- }
843
- async removeObsoleteFilesQdrant(db, processedFiles, pathConfig, logger) {
844
- const { client, collectionName } = db;
845
- try {
846
- // Determine if we're using URL rewriting or direct file paths
847
- const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
848
- // Set up the URL prefix for searching
849
- let urlPrefix;
850
- if (isRewriteMode) {
851
- // Handle URL rewriting case
852
- urlPrefix = pathConfig.url_rewrite_prefix || '';
853
- urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
854
- }
855
- else {
856
- // Handle direct file path case
857
- const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
858
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
859
- urlPrefix = `file://${cleanedDirPrefix}`;
860
- }
861
- logger.debug(`Checking for obsolete chunks with URL prefix: ${urlPrefix}`);
862
- const response = await client.scroll(collectionName, {
863
- limit: 10000,
864
- with_payload: true,
865
- with_vector: false,
866
- filter: {
867
- must: [
868
- {
869
- key: "url",
870
- match: {
871
- text: urlPrefix
872
- }
873
- }
874
- ],
875
- must_not: [
876
- {
877
- key: "is_metadata",
878
- match: {
879
- value: true
880
- }
881
- }
882
- ]
883
- }
884
- });
885
- const obsoletePointIds = response.points
886
- .filter((point) => {
887
- const url = point.payload?.url;
888
- // Double check it's not a metadata record
889
- if (point.payload?.is_metadata === true) {
890
- return false;
891
- }
892
- if (!url || !url.startsWith(urlPrefix)) {
893
- return false;
894
- }
895
- let filePath;
896
- if (isRewriteMode) {
897
- // URL rewrite mode: extract relative path and construct full file path
898
- const config = pathConfig;
899
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
900
- filePath = path.join(config.path, relativePath);
901
- }
902
- else {
903
- // Direct file path mode: remove file:// prefix to match with processedFiles
904
- filePath = url.startsWith('file://') ? url.substring(7) : '';
905
- }
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
- }
915
- else {
916
- logger.info(`No obsolete chunks to delete from Qdrant for URL prefix ${urlPrefix}`);
917
- }
918
- }
919
- catch (error) {
920
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
921
- }
922
- }
923
- async initDatabase(config, parentLogger) {
924
- const logger = parentLogger.child('database');
925
- const dbConfig = config.database_config;
926
- if (dbConfig.type === 'sqlite') {
927
- const params = dbConfig.params;
928
- const dbPath = params.db_path || path.join(process.cwd(), `${config.product_name.replace(/\s+/g, '_')}-${config.version}.db`);
929
- logger.info(`Opening SQLite database at ${dbPath}`);
930
- const db = new better_sqlite3_1.default(dbPath, { allowExtension: true });
931
- sqliteVec.load(db);
932
- logger.debug(`Creating vec_items table if it doesn't exist`);
933
- db.exec(`
934
- CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(
935
- embedding FLOAT[3072],
936
- product_name TEXT,
937
- version TEXT,
938
- heading_hierarchy TEXT,
939
- section TEXT,
940
- chunk_id TEXT UNIQUE,
941
- content TEXT,
942
- url TEXT,
943
- hash TEXT
944
- );
945
- `);
946
- logger.info(`SQLite database initialized successfully`);
947
- return { db, type: 'sqlite' };
948
- }
949
- else if (dbConfig.type === 'qdrant') {
950
- const params = dbConfig.params;
951
- const qdrantUrl = params.qdrant_url || 'http://localhost:6333';
952
- const qdrantPort = params.qdrant_port || 443;
953
- const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
954
- logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
955
- const qdrantClient = new js_client_rest_1.QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
956
- await this.createCollectionQdrant(qdrantClient, collectionName, logger);
957
- logger.info(`Qdrant connection established successfully`);
958
- return { client: qdrantClient, collectionName, type: 'qdrant' };
959
- }
960
- else {
961
- const errMsg = `Unsupported database type: ${dbConfig.type}`;
962
- logger.error(errMsg);
963
- throw new Error(errMsg);
964
- }
965
- }
966
- async createCollectionQdrant(qdrantClient, collectionName, logger) {
967
- try {
968
- logger.debug(`Checking if collection ${collectionName} exists`);
969
- const collections = await qdrantClient.getCollections();
970
- const collectionExists = collections.collections.some((collection) => collection.name === collectionName);
971
- if (collectionExists) {
972
- logger.info(`Collection ${collectionName} already exists`);
973
- return;
974
- }
975
- logger.info(`Creating new collection ${collectionName}`);
976
- await qdrantClient.createCollection(collectionName, {
977
- vectors: {
978
- size: 3072,
979
- distance: "Cosine",
980
- },
981
- });
982
- logger.info(`Collection ${collectionName} created successfully`);
983
- }
984
- catch (error) {
985
- if (error instanceof Error) {
986
- const errorMsg = error.message.toLowerCase();
987
- const errorString = JSON.stringify(error).toLowerCase();
988
- if (errorMsg.includes("already exists") ||
989
- errorString.includes("already exists") ||
990
- error?.status === 409 ||
991
- errorString.includes("conflict")) {
992
- logger.info(`Collection ${collectionName} already exists (from error response)`);
993
- return;
994
- }
995
- }
996
- logger.error(`Error creating Qdrant collection:`, error);
997
- logger.warn(`Continuing with existing collection...`);
998
- }
999
- }
1000
- prepareSQLiteStatements(db) {
1001
- return {
1002
- insertStmt: db.prepare(`
1003
- INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash)
1004
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1005
- `),
1006
- checkHashStmt: db.prepare(`SELECT hash FROM vec_items WHERE chunk_id = ?`),
1007
- updateStmt: db.prepare(`
1008
- UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?
1009
- WHERE chunk_id = ?
1010
- `),
1011
- getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
1012
- deleteChunkStmt: db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`)
1013
- };
1014
- }
1015
- insertVectorsSQLite(db, chunk, embedding, logger, chunkHash) {
1016
- const { insertStmt, updateStmt } = this.prepareSQLiteStatements(db);
1017
- const hash = chunkHash || this.generateHash(chunk.content);
1018
- const transaction = db.transaction(() => {
1019
- const params = [
1020
- new Float32Array(embedding),
1021
- chunk.metadata.product_name,
1022
- chunk.metadata.version,
1023
- JSON.stringify(chunk.metadata.heading_hierarchy),
1024
- chunk.metadata.section,
1025
- chunk.metadata.chunk_id,
1026
- chunk.content,
1027
- chunk.metadata.url,
1028
- hash
1029
- ];
1030
- try {
1031
- insertStmt.run(params);
1032
- }
1033
- catch (error) {
1034
- updateStmt.run([...params.slice(0, 8), chunk.metadata.chunk_id]);
1035
- }
1036
- });
1037
- transaction();
1038
- }
1039
- async storeChunkInQdrant(db, chunk, embedding, chunkHash) {
1040
- const { client, collectionName } = db;
1041
- try {
1042
- let pointId;
1043
- try {
1044
- pointId = chunk.metadata.chunk_id;
1045
- if (!this.isValidUuid(pointId)) {
1046
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
1047
- }
1048
- }
1049
- catch (e) {
1050
- pointId = crypto_1.default.randomUUID();
1051
- }
1052
- const hash = chunkHash || this.generateHash(chunk.content);
1053
- const pointItem = {
1054
- id: pointId,
1055
- vector: embedding,
1056
- payload: {
1057
- content: chunk.content,
1058
- product_name: chunk.metadata.product_name,
1059
- version: chunk.metadata.version,
1060
- heading_hierarchy: chunk.metadata.heading_hierarchy,
1061
- section: chunk.metadata.section,
1062
- url: chunk.metadata.url,
1063
- hash: hash,
1064
- original_chunk_id: chunk.metadata.chunk_id,
1065
- },
1066
- };
1067
- await client.upsert(collectionName, {
1068
- wait: true,
1069
- points: [pointItem],
1070
- });
1071
- }
1072
- catch (error) {
1073
- this.logger.error("Error storing chunk in Qdrant:", error);
1074
- }
1075
- }
1076
- removeObsoleteChunksSQLite(db, visitedUrls, urlPrefix, logger) {
1077
- const getChunksForUrlStmt = db.prepare(`
1078
- SELECT chunk_id, url FROM vec_items
1079
- WHERE url LIKE ? || '%'
1080
- `);
1081
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
1082
- const existingChunks = getChunksForUrlStmt.all(urlPrefix);
1083
- let deletedCount = 0;
1084
- const transaction = db.transaction(() => {
1085
- for (const { chunk_id, url } of existingChunks) {
1086
- if (!visitedUrls.has(url)) {
1087
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (URL not visited)`);
1088
- deleteChunkStmt.run(chunk_id);
1089
- deletedCount++;
1090
- }
1091
- }
1092
- });
1093
- transaction();
1094
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL ${urlPrefix}`);
1095
- }
1096
- isValidUuid(str) {
1097
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1098
- return uuidRegex.test(str);
1099
- }
1100
- hashToUuid(hash) {
1101
- const truncatedHash = hash.substring(0, 32);
1102
- return [
1103
- truncatedHash.substring(0, 8),
1104
- truncatedHash.substring(8, 12),
1105
- '5' + truncatedHash.substring(13, 16),
1106
- '8' + truncatedHash.substring(17, 20),
1107
- truncatedHash.substring(20, 32)
1108
- ].join('-');
1109
- }
1110
- async removeObsoleteChunksQdrant(db, visitedUrls, urlPrefix, logger) {
1111
- const { client, collectionName } = db;
1112
- try {
1113
- // Get all points that match the URL prefix but are not metadata points
1114
- const response = await client.scroll(collectionName, {
1115
- limit: 10000,
1116
- with_payload: true,
1117
- with_vector: false,
1118
- filter: {
1119
- must: [
1120
- {
1121
- key: "url",
1122
- match: {
1123
- text: urlPrefix
1124
- }
1125
- }
1126
- ],
1127
- must_not: [
1128
- {
1129
- key: "is_metadata",
1130
- match: {
1131
- value: true
1132
- }
1133
- }
1134
- ]
1135
- }
1136
- });
1137
- const obsoletePointIds = response.points
1138
- .filter((point) => {
1139
- const url = point.payload?.url;
1140
- // Double check it's not a metadata record
1141
- if (point.payload?.is_metadata === true) {
1142
- return false;
1143
- }
1144
- return url && !visitedUrls.has(url);
1145
- })
1146
- .map((point) => point.id);
1147
- if (obsoletePointIds.length > 0) {
1148
- await client.delete(collectionName, {
1149
- points: obsoletePointIds,
1150
- });
1151
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL ${urlPrefix}`);
1152
- }
1153
- else {
1154
- logger.info(`No obsolete chunks to delete from Qdrant for URL ${urlPrefix}`);
1155
- }
1156
- }
1157
- catch (error) {
1158
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
1159
- }
1160
- }
1161
- async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls) {
1162
- const logger = parentLogger.child('crawler');
1163
- const queue = [baseUrl];
1164
- logger.info(`Starting crawl from ${baseUrl}`);
1165
- let processedCount = 0;
1166
- let skippedCount = 0;
1167
- let skippedSizeCount = 0;
1168
- let errorCount = 0;
1169
- while (queue.length > 0) {
1170
- const url = queue.shift();
1171
- if (!url)
1172
- continue;
1173
- const normalizedUrl = this.normalizeUrl(url);
1174
- if (visitedUrls.has(normalizedUrl))
1175
- continue;
1176
- visitedUrls.add(normalizedUrl);
1177
- if (!this.shouldProcessUrl(url)) {
1178
- logger.debug(`Skipping URL with unsupported extension: ${url}`);
1179
- skippedCount++;
1180
- continue;
1181
- }
1182
- try {
1183
- logger.info(`Crawling: ${url}`);
1184
- const content = await this.processPage(url, sourceConfig);
1185
- if (content !== null) {
1186
- await processPageContent(url, content);
1187
- processedCount++;
1188
- }
1189
- else {
1190
- skippedSizeCount++;
1191
- }
1192
- const response = await axios_1.default.get(url);
1193
- const $ = (0, cheerio_1.load)(response.data);
1194
- logger.debug(`Finding links on page ${url}`);
1195
- let newLinksFound = 0;
1196
- $('a[href]').each((_, element) => {
1197
- const href = $(element).attr('href');
1198
- if (!href || href.startsWith('#') || href.startsWith('mailto:'))
1199
- return;
1200
- const fullUrl = this.buildUrl(href, url);
1201
- if (fullUrl.startsWith(sourceConfig.url) && !visitedUrls.has(this.normalizeUrl(fullUrl))) {
1202
- if (!queue.includes(fullUrl)) {
1203
- queue.push(fullUrl);
1204
- newLinksFound++;
1205
- }
1206
- }
1207
- });
1208
- logger.debug(`Found ${newLinksFound} new links on ${url}`);
1209
- }
1210
- catch (error) {
1211
- logger.error(`Failed during link discovery or initial fetch for ${url}:`, error);
1212
- errorCount++;
1213
- }
1214
- }
1215
- logger.info(`Crawl completed. Processed: ${processedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
1216
- }
1217
- shouldProcessUrl(url) {
1218
- const parsedUrl = new URL(url);
1219
- const pathname = parsedUrl.pathname;
1220
- const ext = path.extname(pathname);
1221
- if (!ext)
1222
- return true;
1223
- return ['.html', '.htm'].includes(ext.toLowerCase());
1224
- }
1225
- normalizeUrl(url) {
1226
- try {
1227
- const urlObj = new URL(url);
1228
- urlObj.hash = '';
1229
- urlObj.search = '';
1230
- return urlObj.toString();
1231
- }
1232
- catch (error) {
1233
- return url;
1234
- }
1235
- }
1236
- buildUrl(href, currentUrl) {
1237
- try {
1238
- return new URL(href, currentUrl).toString();
1239
- }
1240
- catch (error) {
1241
- this.logger.warn(`Invalid URL found: ${href}`);
1242
- return '';
1243
- }
1244
- }
1245
- async processPage(url, sourceConfig) {
1246
- const logger = this.logger.child('page-processor');
1247
- logger.debug(`Processing page content from ${url}`);
1248
- let browser = null;
1249
- try {
1250
- browser = await puppeteer_1.default.launch({
1251
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
1252
- });
1253
- const page = await browser.newPage();
1254
- logger.debug(`Navigating to ${url}`);
1255
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
1256
- const htmlContent = await page.evaluate(() => {
1257
- const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
1258
- return mainContentElement.innerHTML;
1259
- });
1260
- if (htmlContent.length > sourceConfig.max_size) {
1261
- logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
1262
- await browser.close();
1263
- return null;
1264
- }
1265
- logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
1266
- const dom = new jsdom_1.JSDOM(htmlContent);
1267
- const document = dom.window.document;
1268
- document.querySelectorAll('pre').forEach((pre) => {
1269
- pre.classList.add('article-content');
1270
- pre.setAttribute('data-readable-content-score', '100');
1271
- this.markCodeParents(pre.parentElement);
1272
- });
1273
- logger.debug(`Applying Readability to extract main content`);
1274
- const reader = new readability_1.Readability(document, {
1275
- charThreshold: 20,
1276
- classesToPreserve: ['article-content'],
1277
- });
1278
- const article = reader.parse();
1279
- if (!article) {
1280
- logger.warn(`Failed to parse article content with Readability for ${url}`);
1281
- await browser.close();
1282
- return null;
1283
- }
1284
- logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
1285
- const cleanHtml = (0, sanitize_html_1.default)(article.content, {
1286
- allowedTags: [
1287
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1288
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1289
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1290
- ],
1291
- allowedAttributes: {
1292
- 'a': ['href'],
1293
- 'pre': ['class', 'data-language'],
1294
- 'code': ['class', 'data-language'],
1295
- 'div': ['class'],
1296
- 'span': ['class']
1297
- }
1298
- });
1299
- logger.debug(`Converting HTML to Markdown`);
1300
- const markdown = this.turndownService.turndown(cleanHtml);
1301
- logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
1302
- return markdown;
1303
- }
1304
- catch (error) {
1305
- logger.error(`Error processing page ${url}:`, error);
1306
- return null;
1307
- }
1308
- finally {
1309
- if (browser && browser.isConnected()) {
1310
- await browser.close();
1311
- logger.debug(`Browser closed for ${url}`);
1312
- }
1313
- }
1314
- }
1315
- markCodeParents(node) {
1316
- if (!node)
1317
- return;
1318
- if (node.querySelector('pre, code')) {
1319
- node.classList.add('article-content');
1320
- node.setAttribute('data-readable-content-score', '100');
1321
- }
1322
- this.markCodeParents(node.parentElement);
1323
- }
1324
- async chunkMarkdown(markdown, sourceConfig, url) {
1325
- const logger = this.logger.child('chunker');
1326
- logger.debug(`Chunking markdown from ${url} (${markdown.length} chars)`);
1327
- const MAX_TOKENS = 1000;
1328
- const chunks = [];
1329
- const lines = markdown.split("\n");
1330
- let currentChunk = "";
1331
- let headingHierarchy = [];
1332
- const processChunk = () => {
1333
- if (currentChunk.trim()) {
1334
- const tokens = this.tokenize(currentChunk);
1335
- if (tokens.length > MAX_TOKENS) {
1336
- logger.debug(`Chunk exceeds max token count (${tokens.length}), splitting into smaller chunks`);
1337
- let subChunk = "";
1338
- let tokenCount = 0;
1339
- const overlapSize = Math.floor(MAX_TOKENS * 0.05);
1340
- let lastTokens = [];
1341
- for (const token of tokens) {
1342
- if (tokenCount + 1 > MAX_TOKENS) {
1343
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1344
- subChunk = lastTokens.join("") + token;
1345
- tokenCount = lastTokens.length + 1;
1346
- lastTokens = [];
1347
- }
1348
- else {
1349
- subChunk += token;
1350
- tokenCount++;
1351
- lastTokens.push(token);
1352
- if (lastTokens.length > overlapSize) {
1353
- lastTokens.shift();
1354
- }
1355
- }
1356
- }
1357
- if (subChunk) {
1358
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1359
- }
1360
- }
1361
- else {
1362
- chunks.push(createDocumentChunk(currentChunk, headingHierarchy));
1363
- }
1364
- }
1365
- currentChunk = "";
1366
- };
1367
- const createDocumentChunk = (content, hierarchy) => {
1368
- const chunkId = this.generateHash(content);
1369
- logger.debug(`Created chunk ${chunkId.substring(0, 8)}... with ${content.length} chars`);
1370
- return {
1371
- content,
1372
- metadata: {
1373
- product_name: sourceConfig.product_name,
1374
- version: sourceConfig.version,
1375
- heading_hierarchy: [...hierarchy],
1376
- section: hierarchy[hierarchy.length - 1] || "Introduction",
1377
- chunk_id: chunkId,
1378
- url: url,
1379
- hash: this.generateHash(content)
1380
- }
1381
- };
1382
- };
1383
- for (const line of lines) {
1384
- if (line.startsWith("#")) {
1385
- processChunk();
1386
- const levelMatch = line.match(/^(#+)/);
1387
- let level = levelMatch ? levelMatch[1].length : 1;
1388
- const heading = line.replace(/^#+\s*/, "").trim();
1389
- logger.debug(`Found heading (level ${level}): ${heading}`);
1390
- while (headingHierarchy.length < level - 1) {
1391
- headingHierarchy.push("");
1392
- }
1393
- if (level <= headingHierarchy.length) {
1394
- headingHierarchy = headingHierarchy.slice(0, level - 1);
1395
- }
1396
- headingHierarchy[level - 1] = heading;
1397
- }
1398
- else {
1399
- currentChunk += `${line}\n`;
1400
- }
1401
- }
1402
- processChunk();
1403
- logger.debug(`Chunking complete, created ${chunks.length} chunks`);
1404
- return chunks;
1405
- }
1406
- tokenize(text) {
1407
- return text.split(/(\s+)/).filter(token => token.length > 0);
1408
- }
1409
- async createEmbeddings(texts) {
1410
- const logger = this.logger.child('embeddings');
1411
- try {
1412
- logger.debug(`Creating embeddings for ${texts.length} texts`);
1413
- const response = await this.openai.embeddings.create({
1414
- model: "text-embedding-3-large",
1415
- input: texts,
1416
- });
1417
- logger.debug(`Successfully created ${response.data.length} embeddings`);
1418
- return response.data.map(d => d.embedding);
1419
- }
1420
- catch (error) {
1421
- logger.error('Failed to create embeddings:', error);
1422
- return [];
1423
- }
1424
- }
1425
- generateHash(content) {
1426
- return crypto_1.default.createHash("sha256").update(content).digest("hex");
1427
- }
1428
- }
1429
- if (require.main === module) {
1430
- const configPath = process.argv[2] || 'config.yaml';
1431
- if (!fs.existsSync(configPath)) {
1432
- console.error('Please provide a valid path to a YAML config file.');
1433
- process.exit(1);
1434
- }
1435
- const doc2Vec = new Doc2Vec(configPath);
1436
- doc2Vec.run().catch(console.error);
1437
- }