doc2vec 1.0.2 → 1.0.4

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.
Files changed (27) hide show
  1. package/dist/doc2vec.js +1 -1
  2. package/doc2vec.ts +1 -1
  3. package/package.json +1 -1
  4. package/Dockerfile.old +0 -19
  5. package/config copy.yaml +0 -182
  6. package/config-github.yaml +0 -15
  7. package/config.yaml.new +0 -253
  8. package/config.yaml.ok +0 -164
  9. package/config.yaml.solo +0 -251
  10. package/dist/solo-reference-architectures/Gloo_Edge/ALB_Last_Mile_Encryption/data/acm-waf-cdk/lib/acm-cdk-stack.js +0 -20
  11. package/dist/solo-reference-architectures/Gloo_Edge/ALB_Last_Mile_Encryption/data/acm-waf-cdk/lib/route53-cdk-stack.js +0 -33
  12. package/dist/solo-reference-architectures/Gloo_Edge/ALB_Last_Mile_Encryption/data/acm-waf-cdk/lib/waf-cdk-stack.js +0 -66
  13. package/dist/solo-reference-architectures/Gloo_Edge/ALB_Last_Mile_Encryption/data/acm-waf-cdk/test/s3-cloudfront-cdk.test.js +0 -16
  14. package/dist/solo-reference-architectures/Gloo_Edge/Cloudfront_NLB/data/s3-cloudfront-cdk/lib/acm-cdk-stack.js +0 -21
  15. package/dist/solo-reference-architectures/Gloo_Edge/Cloudfront_NLB/data/s3-cloudfront-cdk/lib/s3-cloudfront-cdk-stack.js +0 -95
  16. package/dist/solo-reference-architectures/Gloo_Edge/Cloudfront_NLB/data/s3-cloudfront-cdk/test/s3-cloudfront-cdk.test.js +0 -16
  17. package/dist/solo-reference-architectures/Gloo_Gateway/aws-nlb-tls-offloading/data/acm-cdk/bin/acm-cdk.js +0 -55
  18. package/dist/solo-reference-architectures/Gloo_Gateway/aws-nlb-tls-offloading/data/acm-cdk/lib/acm-cdk-stack.js +0 -20
  19. package/dist/solo-reference-architectures/Gloo_Gateway/aws-nlb-tls-offloading/data/acm-cdk/lib/route53-cdk-stack.js +0 -33
  20. package/dist/solo-reference-architectures/Gloo_Gateway/aws-nlb-tls-offloading/data/acm-cdk/test/s3-cloudfront-cdk.test.js +0 -16
  21. package/doc2vec.js +0 -1805
  22. package/doc2vec.ts.new +0 -1583
  23. package/doc2vec.ts.ok +0 -1730
  24. package/istio-issues.db +0 -0
  25. package/logger.js +0 -260
  26. package/tmp/README.md.old +0 -1
  27. package/tmp.db +0 -0
package/doc2vec.ts.new DELETED
@@ -1,1583 +0,0 @@
1
- import { Readability } from '@mozilla/readability';
2
- import axios from 'axios';
3
- import { load } from 'cheerio';
4
- import crypto from 'crypto';
5
- import { JSDOM } from 'jsdom';
6
- import puppeteer, { Browser, Page } from 'puppeteer';
7
- import sanitizeHtml from 'sanitize-html';
8
- import TurndownService from 'turndown';
9
- import * as yaml from 'js-yaml';
10
- import * as fs from 'fs';
11
- import * as path from 'path';
12
- import BetterSqlite3, { Database } from "better-sqlite3";
13
- import { OpenAI } from "openai";
14
- import * as dotenv from "dotenv";
15
- import * as sqliteVec from "sqlite-vec";
16
- import { QdrantClient } from '@qdrant/js-client-rest';
17
- import { Logger, LogLevel } from './logger';
18
-
19
- const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
20
-
21
- dotenv.config();
22
-
23
- interface Config {
24
- sources: SourceConfig[];
25
- }
26
-
27
- // Base configuration that applies to all source types
28
- interface BaseSourceConfig {
29
- type: 'website' | 'github' | 'local_directory';
30
- product_name: string;
31
- version: string;
32
- max_size: number;
33
- database_config: DatabaseConfig;
34
- }
35
-
36
- // Configuration specific to local directory sources
37
- interface LocalDirectorySourceConfig extends BaseSourceConfig {
38
- type: 'local_directory';
39
- path: string; // Path to the local directory
40
- include_extensions?: string[]; // File extensions to include (e.g., ['.md', '.txt'])
41
- exclude_extensions?: string[]; // File extensions to exclude
42
- recursive?: boolean; // Whether to traverse subdirectories
43
- encoding?: BufferEncoding; // File encoding (default: 'utf8')
44
- }
45
-
46
- // Configuration specific to website sources
47
- interface WebsiteSourceConfig extends BaseSourceConfig {
48
- type: 'website';
49
- url: string;
50
- }
51
-
52
- // Configuration specific to GitHub repo sources
53
- interface GithubSourceConfig extends BaseSourceConfig {
54
- type: 'github';
55
- repo: string;
56
- start_date?: string;
57
- }
58
-
59
- // Union type for all possible source configurations
60
- type SourceConfig = WebsiteSourceConfig | GithubSourceConfig | LocalDirectorySourceConfig;
61
-
62
- // Database configuration
63
- interface DatabaseConfig {
64
- type: 'sqlite' | 'qdrant';
65
- params: SqliteDatabaseParams | QdrantDatabaseParams;
66
- }
67
-
68
- interface SqliteDatabaseParams {
69
- db_path?: string; // Optional, will use default if not provided
70
- }
71
-
72
- interface QdrantDatabaseParams {
73
- qdrant_url?: string;
74
- qdrant_port?: number;
75
- collection_name?: string;
76
- }
77
-
78
- interface DocumentChunk {
79
- content: string;
80
- metadata: {
81
- product_name: string;
82
- version: string;
83
- heading_hierarchy: string[];
84
- section: string;
85
- chunk_id: string;
86
- url: string;
87
- hash?: string;
88
- };
89
- }
90
-
91
- interface SqliteDB {
92
- db: Database;
93
- type: 'sqlite';
94
- }
95
-
96
- interface QdrantDB {
97
- client: QdrantClient;
98
- collectionName: string;
99
- type: 'qdrant';
100
- }
101
-
102
- type DatabaseConnection = SqliteDB | QdrantDB;
103
-
104
- class Doc2Vec {
105
- private config: Config;
106
- private openai: OpenAI;
107
- private turndownService: TurndownService;
108
- private logger: Logger;
109
-
110
- constructor(configPath: string) {
111
- this.logger = new Logger('Doc2Vec', {
112
- level: LogLevel.DEBUG,
113
- useTimestamp: true,
114
- useColor: true,
115
- prettyPrint: true
116
- });
117
-
118
- this.logger.info('Initializing Doc2Vec');
119
- this.config = this.loadConfig(configPath);
120
- this.openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
121
-
122
- this.turndownService = new TurndownService({
123
- codeBlockStyle: 'fenced',
124
- headingStyle: 'atx'
125
- });
126
- this.setupTurndownRules();
127
- }
128
-
129
- private loadConfig(configPath: string): Config {
130
- try {
131
- const logger = this.logger.child('config');
132
- logger.info(`Loading configuration from ${configPath}`);
133
-
134
- const configFile = fs.readFileSync(configPath, 'utf8');
135
- let config = yaml.load(configFile) as any;
136
-
137
- const typedConfig = config as Config;
138
- logger.info(`Configuration loaded successfully, found ${typedConfig.sources.length} sources`);
139
- return typedConfig;
140
- } catch (error) {
141
- this.logger.error(`Failed to load or parse config file at ${configPath}:`, error);
142
- process.exit(1);
143
- }
144
- }
145
-
146
- private setupTurndownRules() {
147
- const logger = this.logger.child('markdown');
148
- logger.debug('Setting up Turndown rules for markdown conversion');
149
-
150
- this.turndownService.addRule('codeBlocks', {
151
- filter: (node: Node): boolean => node.nodeName === 'PRE',
152
- replacement: (content: string, node: Node): string => {
153
- const htmlNode = node as HTMLElement;
154
- const code = htmlNode.querySelector('code');
155
-
156
- let codeContent;
157
- if (code) {
158
- codeContent = code.textContent || '';
159
- } else {
160
- codeContent = htmlNode.textContent || '';
161
- }
162
-
163
- const lines = codeContent.split('\n');
164
- let minIndent = Infinity;
165
- for (const line of lines) {
166
- if (line.trim() === '') continue;
167
- const leadingWhitespace = line.match(/^\s*/)?.[0] || '';
168
- minIndent = Math.min(minIndent, leadingWhitespace.length);
169
- }
170
-
171
- const cleanedLines = lines.map(line => {
172
- return line.substring(minIndent);
173
- });
174
-
175
- let cleanContent = cleanedLines.join('\n');
176
- cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
177
- cleanContent = cleanContent.replace(/\n{2,}/g, '\n');
178
-
179
- return `\n\`\`\`\n${cleanContent}\n\`\`\`\n`;
180
- }
181
- });
182
-
183
- this.turndownService.addRule('tableCell', {
184
- filter: ['th', 'td'],
185
- replacement: (content: string, node: Node): string => {
186
- const htmlNode = node as HTMLElement;
187
-
188
- let cellContent = '';
189
- if (htmlNode.querySelector('p')) {
190
- cellContent = Array.from(htmlNode.querySelectorAll('p'))
191
- .map(p => p.textContent || '')
192
- .join(' ')
193
- .trim();
194
- } else {
195
- cellContent = content.trim();
196
- }
197
-
198
- return ` ${cellContent.replace(/\|/g, '\\|')} |`;
199
- }
200
- });
201
-
202
- this.turndownService.addRule('tableRow', {
203
- filter: 'tr',
204
- replacement: (content: string, node: Node): string => {
205
- const htmlNode = node as HTMLTableRowElement;
206
- const cells = Array.from(htmlNode.cells);
207
- const isHeader = htmlNode.parentNode?.nodeName === 'THEAD';
208
-
209
- let output = '|' + content.trimEnd();
210
-
211
- if (isHeader) {
212
- const separator = cells.map(() => '---').join(' | ');
213
- output += '\n|' + separator + '|';
214
- }
215
-
216
- if (!isHeader || !htmlNode.nextElementSibling) {
217
- output += '\n';
218
- }
219
-
220
- return output;
221
- }
222
- });
223
-
224
- this.turndownService.addRule('table', {
225
- filter: 'table',
226
- replacement: (content: string): string => {
227
- return '\n' + content.replace(/\n+/g, '\n').trim() + '\n';
228
- }
229
- });
230
-
231
- this.turndownService.addRule('preserveTableWhitespace', {
232
- filter: (node: Node): boolean => {
233
- return (
234
- (node.nodeName === 'TD' || node.nodeName === 'TH') &&
235
- (node.textContent?.trim().length === 0)
236
- );
237
- },
238
- replacement: (): string => {
239
- return ' |';
240
- }
241
- });
242
-
243
- logger.debug('Turndown rules setup complete');
244
- }
245
-
246
- public async run(): Promise<void> {
247
- this.logger.section('PROCESSING SOURCES');
248
-
249
- for (const sourceConfig of this.config.sources) {
250
- const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
251
-
252
- sourceLogger.info(`Processing ${sourceConfig.type} source for ${sourceConfig.product_name}@${sourceConfig.version}`);
253
-
254
- if (sourceConfig.type === 'github') {
255
- await this.processGithubRepo(sourceConfig, sourceLogger);
256
- } else if (sourceConfig.type === 'website') {
257
- await this.processWebsite(sourceConfig, sourceLogger);
258
- } else if (sourceConfig.type === 'local_directory') {
259
- await this.processLocalDirectory(sourceConfig, sourceLogger);
260
- } else {
261
- sourceLogger.error(`Unknown source type: ${(sourceConfig as any).type}`);
262
- }
263
- }
264
-
265
- this.logger.section('PROCESSING COMPLETE');
266
- }
267
-
268
- private getUrlPrefix(url: string): string {
269
- try {
270
- const parsedUrl = new URL(url);
271
- return parsedUrl.origin + parsedUrl.pathname;
272
- } catch (error) {
273
- return url;
274
- }
275
- }
276
-
277
- private generateMetadataUUID(repo: string): string {
278
- // Simple deterministic approach - hash the repo name and convert to UUID format
279
- const hash = crypto.createHash('md5').update(`metadata_${repo}`).digest('hex');
280
- // Format as UUID with version bits set correctly (version 4)
281
- return `${hash.substr(0, 8)}-${hash.substr(8, 4)}-4${hash.substr(13, 3)}-${hash.substr(16, 4)}-${hash.substr(20, 12)}`;
282
- }
283
-
284
- private async initDatabaseMetadata(dbConnection: DatabaseConnection): Promise<void> {
285
- const logger = this.logger.child('metadata');
286
-
287
- if (dbConnection.type === 'sqlite') {
288
- const db = dbConnection.db;
289
- logger.debug('Creating metadata table if it doesn\'t exist');
290
- db.exec(`
291
- CREATE TABLE IF NOT EXISTS vec_metadata (
292
- key TEXT PRIMARY KEY,
293
- value TEXT
294
- );
295
- `);
296
- logger.info('SQLite metadata table initialized');
297
- } else if (dbConnection.type === 'qdrant') {
298
- // For Qdrant, we'll use the same collection but verify it exists
299
- logger.info(`Using existing Qdrant collection for metadata: ${dbConnection.collectionName}`);
300
- // Nothing special to initialize as we'll use the same collection
301
- }
302
- }
303
-
304
- private async getLastRunDate(dbConnection: DatabaseConnection, repo: string, defaultDate: string): Promise<string> {
305
- const logger = this.logger.child('metadata');
306
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
307
-
308
- try {
309
- if (dbConnection.type === 'sqlite') {
310
- const stmt = dbConnection.db.prepare('SELECT value FROM vec_metadata WHERE key = ?');
311
- const result = stmt.get(metadataKey) as { value: string } | undefined;
312
-
313
- if (result) {
314
- logger.info(`Retrieved last run date for ${repo}: ${result.value}`);
315
- return result.value;
316
- }
317
- } else if (dbConnection.type === 'qdrant') {
318
- // Generate a UUID for this repo's metadata
319
- const metadataUUID = this.generateMetadataUUID(repo);
320
- logger.debug(`Looking up metadata with UUID: ${metadataUUID}`);
321
-
322
- try {
323
- // Try to retrieve the metadata point for this repo
324
- const response = await dbConnection.client.retrieve(dbConnection.collectionName, {
325
- ids: [metadataUUID],
326
- with_payload: true,
327
- with_vector: false
328
- });
329
-
330
- if (response.length > 0 && response[0].payload?.metadata_value) {
331
- const lastRunDate = response[0].payload.metadata_value as string;
332
- logger.info(`Retrieved last run date for ${repo}: ${lastRunDate}`);
333
- return lastRunDate;
334
- }
335
- } catch (error) {
336
- logger.warn(`Failed to retrieve metadata for ${repo}:`, error);
337
- }
338
- }
339
- } catch (error) {
340
- logger.warn(`Error retrieving last run date:`, error);
341
- }
342
-
343
- logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
344
- return defaultDate;
345
- }
346
-
347
- private async updateLastRunDate(dbConnection: DatabaseConnection, repo: string): Promise<void> {
348
- const logger = this.logger.child('metadata');
349
- const now = new Date().toISOString();
350
-
351
- try {
352
- if (dbConnection.type === 'sqlite') {
353
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
354
- const stmt = dbConnection.db.prepare(`
355
- INSERT INTO vec_metadata (key, value) VALUES (?, ?)
356
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
357
- `);
358
- stmt.run(metadataKey, now);
359
- logger.info(`Updated last run date for ${repo} to ${now}`);
360
- } else if (dbConnection.type === 'qdrant') {
361
- // Generate UUID for this repo's metadata
362
- const metadataUUID = this.generateMetadataUUID(repo);
363
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
364
-
365
- logger.debug(`Using UUID: ${metadataUUID} for metadata`);
366
-
367
- // Generate a dummy embedding (all zeros)
368
- const dummyEmbeddingSize = 3072; // Same size as your content embeddings
369
- const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
370
-
371
- // Create a point with special metadata payload
372
- const metadataPoint = {
373
- id: metadataUUID,
374
- vector: dummyEmbedding,
375
- payload: {
376
- metadata_key: metadataKey,
377
- metadata_value: now,
378
- is_metadata: true, // Flag to identify metadata points
379
- content: `Metadata: Last run date for ${repo}`,
380
- product_name: 'system',
381
- version: 'metadata',
382
- url: 'metadata://' + repo
383
- }
384
- };
385
-
386
- await dbConnection.client.upsert(dbConnection.collectionName, {
387
- wait: true,
388
- points: [metadataPoint]
389
- });
390
-
391
- logger.info(`Updated last run date for ${repo} to ${now}`);
392
- }
393
- } catch (error) {
394
- logger.error(`Failed to update last run date for ${repo}:`, error);
395
- }
396
- }
397
-
398
- private async fetchAndProcessGitHubIssues(repo: string, sourceConfig: GithubSourceConfig, dbConnection: DatabaseConnection, logger: Logger): Promise<void> {
399
- const [owner, repoName] = repo.split('/');
400
- const GITHUB_API_URL = `https://api.github.com/repos/${owner}/${repoName}/issues`;
401
-
402
- // Initialize metadata storage if needed
403
- await this.initDatabaseMetadata(dbConnection);
404
-
405
- // Get the last run date from the database
406
- const startDate = sourceConfig.start_date || '2025-01-01';
407
- const lastRunDate = await this.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`);
408
-
409
- const fetchWithRetry = async (url: string, params = {}, retries = 5, delay = 5000): Promise<any> => {
410
- for (let attempt = 0; attempt < retries; attempt++) {
411
- try {
412
- const response = await axios.get(url, {
413
- headers: {
414
- Authorization: `token ${GITHUB_TOKEN}`,
415
- Accept: 'application/vnd.github.v3+json',
416
- },
417
- params,
418
- });
419
- return response.data;
420
- } catch (error: any) {
421
- if (error.response && error.response.status === 403) {
422
- const resetTime = error.response.headers['x-ratelimit-reset'];
423
- const currentTime = Math.floor(Date.now() / 1000);
424
- const waitTime = resetTime ? (resetTime - currentTime) * 1000 : delay * 2;
425
- logger.warn(`GitHub rate limit exceeded. Waiting ${waitTime / 1000}s`);
426
- await new Promise(res => setTimeout(res, waitTime));
427
- } else {
428
- logger.error(`GitHub fetch failed: ${error.message}`);
429
- throw error;
430
- }
431
- }
432
- }
433
- throw new Error('Max retries reached');
434
- };
435
-
436
- const fetchAllIssues = async (sinceDate: string): Promise<any[]> => {
437
- let issues: any[] = [];
438
- let page = 1;
439
- const perPage = 100;
440
- const sinceTimestamp = new Date(sinceDate);
441
-
442
- while (true) {
443
- const data = await fetchWithRetry(GITHUB_API_URL, {
444
- per_page: perPage,
445
- page,
446
- state: 'all',
447
- since: sinceDate,
448
- });
449
-
450
- if (data.length === 0) break;
451
-
452
- const filtered = data.filter((issue: any) => new Date(issue.created_at) >= sinceTimestamp);
453
- issues = issues.concat(filtered);
454
-
455
- if (filtered.length < data.length) break;
456
- page++;
457
- }
458
- return issues;
459
- };
460
-
461
- const fetchIssueComments = async (issueNumber: number): Promise<any[]> => {
462
- const url = `${GITHUB_API_URL}/${issueNumber}/comments`;
463
- return await fetchWithRetry(url);
464
- };
465
-
466
- const generateMarkdownForIssue = async (issue: any): Promise<string> => {
467
- const comments = await fetchIssueComments(issue.number);
468
- let md = `# Issue #${issue.number}: ${issue.title}\n\n`;
469
- md += `- **Author:** ${issue.user.login}\n`;
470
- md += `- **State:** ${issue.state}\n`;
471
- md += `- **Created on:** ${new Date(issue.created_at).toDateString()}\n`;
472
- md += `- **Updated on:** ${new Date(issue.updated_at).toDateString()}\n`;
473
- md += `- **Labels:** ${issue.labels.map((l: any) => `\`${l.name}\``).join(', ') || 'None'}\n\n`;
474
- md += `## Description\n\n${issue.body || '_No description._'}\n\n## Comments\n\n`;
475
-
476
- if (comments.length === 0) {
477
- md += '_No comments._\n';
478
- } else {
479
- for (const c of comments) {
480
- md += `### ${c.user.login} - ${new Date(c.created_at).toDateString()}\n\n${c.body}\n\n---\n\n`;
481
- }
482
- }
483
-
484
- return md;
485
- };
486
-
487
- // Process a single issue and store its chunks
488
- const processIssue = async (issue: any): Promise<void> => {
489
- const issueNumber = issue.number;
490
- const url = `https://github.com/${repo}/issues/${issueNumber}`;
491
-
492
- logger.info(`Processing issue #${issueNumber}`);
493
-
494
- // Generate markdown for the issue
495
- const markdown = await generateMarkdownForIssue(issue);
496
-
497
- // Chunk the markdown content
498
- const issueConfig = {
499
- ...sourceConfig,
500
- product_name: sourceConfig.product_name || repo,
501
- max_size: sourceConfig.max_size || Infinity
502
- };
503
-
504
- const chunks = await this.chunkMarkdown(markdown, issueConfig, url);
505
- logger.info(`Issue #${issueNumber}: Created ${chunks.length} chunks`);
506
-
507
- // Process and store each chunk immediately
508
- for (const chunk of chunks) {
509
- const chunkHash = this.generateHash(chunk.content);
510
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
511
-
512
- if (dbConnection.type === 'sqlite') {
513
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
514
- const existing = checkHashStmt.get(chunk.metadata.chunk_id) as { hash: string } | undefined;
515
-
516
- if (existing && existing.hash === chunkHash) {
517
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
518
- continue;
519
- }
520
-
521
- const embeddings = await this.createEmbeddings([chunk.content]);
522
- if (embeddings.length) {
523
- this.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
524
- logger.debug(`Stored chunk ${chunkId} in SQLite`);
525
- } else {
526
- logger.error(`Embedding failed for chunk: ${chunkId}`);
527
- }
528
- } else if (dbConnection.type === 'qdrant') {
529
- try {
530
- let pointId: string;
531
- try {
532
- pointId = chunk.metadata.chunk_id;
533
- if (!this.isValidUuid(pointId)) {
534
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
535
- }
536
- } catch (e) {
537
- pointId = crypto.randomUUID();
538
- }
539
-
540
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
541
- ids: [pointId],
542
- with_payload: true,
543
- with_vector: false,
544
- });
545
-
546
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
547
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
548
- continue;
549
- }
550
-
551
- const embeddings = await this.createEmbeddings([chunk.content]);
552
- if (embeddings.length) {
553
- await this.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
554
- logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
555
- } else {
556
- logger.error(`Embedding failed for chunk: ${chunkId}`);
557
- }
558
- } catch (error) {
559
- logger.error(`Error processing chunk in Qdrant:`, error);
560
- }
561
- }
562
- }
563
- };
564
-
565
- logger.info(`Fetching GitHub issues for ${repo} since ${lastRunDate}`);
566
- const issues = await fetchAllIssues(lastRunDate);
567
- logger.info(`Found ${issues.length} updated/new issues`);
568
-
569
- // Process each issue individually, one at a time
570
- for (let i = 0; i < issues.length; i++) {
571
- logger.info(`Processing issue ${i + 1}/${issues.length}`);
572
- await processIssue(issues[i]);
573
- }
574
-
575
- // Update the last run date in the database after processing all issues
576
- await this.updateLastRunDate(dbConnection, repo);
577
-
578
- logger.info(`Successfully processed ${issues.length} issues`);
579
- }
580
-
581
- private async processGithubRepo(config: GithubSourceConfig, parentLogger: Logger): Promise<void> {
582
- const logger = parentLogger.child('process');
583
- logger.info(`Starting processing for GitHub repo: ${config.repo}`);
584
-
585
- const dbConnection = await this.initDatabase(config, logger);
586
-
587
- // Initialize metadata storage
588
- await this.initDatabaseMetadata(dbConnection);
589
-
590
- logger.section('GITHUB ISSUES');
591
-
592
- // Process GitHub issues
593
- await this.fetchAndProcessGitHubIssues(config.repo, config, dbConnection, logger);
594
-
595
- logger.info(`Finished processing GitHub repo: ${config.repo}`);
596
- }
597
-
598
- private async processWebsite(config: WebsiteSourceConfig, parentLogger: Logger): Promise<void> {
599
- const logger = parentLogger.child('process');
600
- logger.info(`Starting processing for website: ${config.url}`);
601
-
602
- const dbConnection = await this.initDatabase(config, logger);
603
- const validChunkIds: Set<string> = new Set();
604
- const visitedUrls: Set<string> = new Set();
605
- const urlPrefix = this.getUrlPrefix(config.url);
606
-
607
- logger.section('CRAWL AND EMBEDDING');
608
-
609
- await this.crawlWebsite(config.url, config, async (url, content) => {
610
- visitedUrls.add(url);
611
-
612
- logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
613
- try {
614
- const chunks = await this.chunkMarkdown(content, config, url);
615
- logger.info(`Created ${chunks.length} chunks`);
616
-
617
- if (chunks.length > 0) {
618
- const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
619
-
620
- for (let i = 0; i < chunks.length; i++) {
621
- const chunk = chunks[i];
622
- validChunkIds.add(chunk.metadata.chunk_id);
623
-
624
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
625
-
626
- let needsEmbedding = true;
627
- const chunkHash = this.generateHash(chunk.content);
628
-
629
- if (dbConnection.type === 'sqlite') {
630
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
631
- const existing = checkHashStmt.get(chunk.metadata.chunk_id) as { hash: string } | undefined;
632
-
633
- if (existing && existing.hash === chunkHash) {
634
- needsEmbedding = false;
635
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
636
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
637
- }
638
- } else if (dbConnection.type === 'qdrant') {
639
- try {
640
- let pointId: string;
641
- try {
642
- pointId = chunk.metadata.chunk_id;
643
- if (!this.isValidUuid(pointId)) {
644
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
645
- }
646
- } catch (e) {
647
- pointId = crypto.randomUUID();
648
- }
649
-
650
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
651
- ids: [pointId],
652
- with_payload: true,
653
- with_vector: false,
654
- });
655
-
656
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
657
- needsEmbedding = false;
658
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
659
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
660
- }
661
- } catch (error) {
662
- logger.error(`Error checking existing point in Qdrant:`, error);
663
- }
664
- }
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
- } else if (dbConnection.type === 'qdrant') {
675
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
676
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
677
- }
678
- } else {
679
- logger.error(`Embedding failed for chunk: ${chunkId}`);
680
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
681
- }
682
- }
683
- }
684
-
685
- chunkProgress.complete();
686
- }
687
-
688
- } catch (error) {
689
- logger.error(`Error during chunking or embedding for ${url}:`, error);
690
- }
691
-
692
- }, logger, visitedUrls);
693
-
694
- logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
695
-
696
- logger.section('CLEANUP');
697
- if (dbConnection.type === 'sqlite') {
698
- logger.info(`Running SQLite cleanup for ${urlPrefix}`);
699
- this.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
700
- } else if (dbConnection.type === 'qdrant') {
701
- logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
702
- await this.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
703
- }
704
-
705
- logger.info(`Finished processing website: ${config.url}`);
706
- }
707
-
708
- private async processLocalDirectory(config: LocalDirectorySourceConfig, parentLogger: Logger): Promise<void> {
709
- const logger = parentLogger.child('process');
710
- logger.info(`Starting processing for local directory: ${config.path}`);
711
-
712
- const dbConnection = await this.initDatabase(config, logger);
713
- const validChunkIds: Set<string> = new Set();
714
- const processedFiles: Set<string> = new Set();
715
-
716
- logger.section('FILE SCANNING AND EMBEDDING');
717
-
718
- await this.processDirectory(
719
- config.path,
720
- config,
721
- async (filePath, content) => {
722
- processedFiles.add(filePath);
723
-
724
- logger.info(`Processing content from ${filePath} (${content.length} chars)`);
725
- try {
726
- // Use the file path as URL for tracking
727
- const fileUrl = `file://${filePath}`;
728
- const chunks = await this.chunkMarkdown(content, config, fileUrl);
729
- logger.info(`Created ${chunks.length} chunks`);
730
-
731
- if (chunks.length > 0) {
732
- const chunkProgress = logger.progress(`Embedding chunks for ${filePath}`, chunks.length);
733
-
734
- for (let i = 0; i < chunks.length; i++) {
735
- const chunk = chunks[i];
736
- validChunkIds.add(chunk.metadata.chunk_id);
737
-
738
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
739
-
740
- let needsEmbedding = true;
741
- const chunkHash = this.generateHash(chunk.content);
742
-
743
- if (dbConnection.type === 'sqlite') {
744
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
745
- const existing = checkHashStmt.get(chunk.metadata.chunk_id) as { hash: string } | undefined;
746
-
747
- if (existing && existing.hash === chunkHash) {
748
- needsEmbedding = false;
749
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
750
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
751
- }
752
- } else if (dbConnection.type === 'qdrant') {
753
- try {
754
- let pointId: string;
755
- try {
756
- pointId = chunk.metadata.chunk_id;
757
- if (!this.isValidUuid(pointId)) {
758
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
759
- }
760
- } catch (e) {
761
- pointId = crypto.randomUUID();
762
- }
763
-
764
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
765
- ids: [pointId],
766
- with_payload: true,
767
- with_vector: false,
768
- });
769
-
770
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
771
- needsEmbedding = false;
772
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
773
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
774
- }
775
- } catch (error) {
776
- logger.error(`Error checking existing point in Qdrant:`, error);
777
- }
778
- }
779
-
780
- if (needsEmbedding) {
781
- const embeddings = await this.createEmbeddings([chunk.content]);
782
- if (embeddings.length > 0) {
783
- const embedding = embeddings[0];
784
- if (dbConnection.type === 'sqlite') {
785
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
786
- chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
787
- } else if (dbConnection.type === 'qdrant') {
788
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
789
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
790
- }
791
- } else {
792
- logger.error(`Embedding failed for chunk: ${chunkId}`);
793
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
794
- }
795
- }
796
- }
797
-
798
- chunkProgress.complete();
799
- }
800
- } catch (error) {
801
- logger.error(`Error during chunking or embedding for ${filePath}:`, error);
802
- }
803
- },
804
- logger
805
- );
806
-
807
- logger.info(`Found ${validChunkIds.size} valid chunks across processed files for ${config.path}`);
808
-
809
- logger.section('CLEANUP');
810
- if (dbConnection.type === 'sqlite') {
811
- logger.info(`Running SQLite cleanup for local directory ${config.path}`);
812
- this.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config.path, logger);
813
- } else if (dbConnection.type === 'qdrant') {
814
- logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
815
- await this.removeObsoleteFilesQdrant(dbConnection, processedFiles, config.path, logger);
816
- }
817
-
818
- logger.info(`Finished processing local directory: ${config.path}`);
819
- }
820
-
821
- private async processDirectory(
822
- dirPath: string,
823
- config: LocalDirectorySourceConfig,
824
- processFileContent: (filePath: string, content: string) => Promise<void>,
825
- parentLogger: Logger,
826
- visitedPaths: Set<string> = new Set()
827
- ): Promise<void> {
828
- const logger = parentLogger.child('directory-processor');
829
- logger.info(`Processing directory: ${dirPath}`);
830
-
831
- const recursive = config.recursive !== undefined ? config.recursive : true;
832
- const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm'];
833
- const excludeExtensions = config.exclude_extensions || [];
834
- const encoding = config.encoding || 'utf8' as BufferEncoding;
835
-
836
- try {
837
- const files = fs.readdirSync(dirPath);
838
- let processedFiles = 0;
839
- let skippedFiles = 0;
840
-
841
- for (const file of files) {
842
- const filePath = path.join(dirPath, file);
843
- const stat = fs.statSync(filePath);
844
-
845
- // Skip already visited paths
846
- if (visitedPaths.has(filePath)) {
847
- logger.debug(`Skipping already visited path: ${filePath}`);
848
- continue;
849
- }
850
-
851
- visitedPaths.add(filePath);
852
-
853
- if (stat.isDirectory()) {
854
- if (recursive) {
855
- await this.processDirectory(filePath, config, processFileContent, logger, visitedPaths);
856
- } else {
857
- logger.debug(`Skipping directory ${filePath} (recursive=false)`);
858
- }
859
- } else if (stat.isFile()) {
860
- const extension = path.extname(file).toLowerCase();
861
-
862
- // Apply extension filters
863
- if (excludeExtensions.includes(extension)) {
864
- logger.debug(`Skipping file with excluded extension: ${filePath}`);
865
- skippedFiles++;
866
- continue;
867
- }
868
-
869
- if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
870
- logger.debug(`Skipping file with non-included extension: ${filePath}`);
871
- skippedFiles++;
872
- continue;
873
- }
874
-
875
- try {
876
- logger.info(`Reading file: ${filePath}`);
877
- const content = fs.readFileSync(filePath, { encoding: encoding as BufferEncoding });
878
-
879
- if (content.length > config.max_size) {
880
- logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
881
- skippedFiles++;
882
- continue;
883
- }
884
-
885
- // Convert HTML to Markdown if needed
886
- let processedContent: string;
887
- if (extension === '.html' || extension === '.htm') {
888
- logger.debug(`Converting HTML to Markdown for ${filePath}`);
889
- const cleanHtml = sanitizeHtml(content, {
890
- allowedTags: [
891
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
892
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
893
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
894
- ],
895
- allowedAttributes: {
896
- 'a': ['href'],
897
- 'pre': ['class', 'data-language'],
898
- 'code': ['class', 'data-language'],
899
- 'div': ['class'],
900
- 'span': ['class']
901
- }
902
- });
903
- processedContent = this.turndownService.turndown(cleanHtml);
904
- } else {
905
- processedContent = content;
906
- }
907
-
908
- await processFileContent(filePath, processedContent);
909
- processedFiles++;
910
- } catch (error) {
911
- logger.error(`Error processing file ${filePath}:`, error);
912
- }
913
- }
914
- }
915
-
916
- logger.info(`Directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
917
- } catch (error) {
918
- logger.error(`Error reading directory ${dirPath}:`, error);
919
- }
920
- }
921
-
922
- private async initDatabase(config: SourceConfig, parentLogger: Logger): Promise<DatabaseConnection> {
923
- const logger = parentLogger.child('database');
924
- const dbConfig = config.database_config;
925
-
926
- if (dbConfig.type === 'sqlite') {
927
- const params = dbConfig.params as SqliteDatabaseParams;
928
- const dbPath = params.db_path || path.join(process.cwd(), `${config.product_name.replace(/\s+/g, '_')}-${config.version}.db`);
929
-
930
- logger.info(`Opening SQLite database at ${dbPath}`);
931
-
932
- const db = new BetterSqlite3(dbPath, { allowExtension: true } as any);
933
- sqliteVec.load(db);
934
-
935
- logger.debug(`Creating vec_items table if it doesn't exist`);
936
- db.exec(`
937
- CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(
938
- embedding FLOAT[3072],
939
- product_name TEXT,
940
- version TEXT,
941
- heading_hierarchy TEXT,
942
- section TEXT,
943
- chunk_id TEXT UNIQUE,
944
- content TEXT,
945
- url TEXT,
946
- hash TEXT
947
- );
948
- `);
949
- logger.info(`SQLite database initialized successfully`);
950
- return { db, type: 'sqlite' };
951
- } else if (dbConfig.type === 'qdrant') {
952
- const params = dbConfig.params as QdrantDatabaseParams;
953
- const qdrantUrl = params.qdrant_url || 'http://localhost:6333';
954
- const qdrantPort = params.qdrant_port || 443;
955
- const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
956
-
957
- logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
958
- const qdrantClient = new QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
959
-
960
- await this.createCollectionQdrant(qdrantClient, collectionName, logger);
961
- logger.info(`Qdrant connection established successfully`);
962
- return { client: qdrantClient, collectionName, type: 'qdrant' };
963
- } else {
964
- const errMsg = `Unsupported database type: ${dbConfig.type}`;
965
- logger.error(errMsg);
966
- throw new Error(errMsg);
967
- }
968
- }
969
-
970
- private async createCollectionQdrant(qdrantClient: QdrantClient, collectionName: string, logger: Logger) {
971
- try {
972
- logger.debug(`Checking if collection ${collectionName} exists`);
973
- const collections = await qdrantClient.getCollections();
974
- const collectionExists = collections.collections.some(
975
- (collection: any) => collection.name === collectionName
976
- );
977
-
978
- if (collectionExists) {
979
- logger.info(`Collection ${collectionName} already exists`);
980
- return;
981
- }
982
-
983
- logger.info(`Creating new collection ${collectionName}`);
984
- await qdrantClient.createCollection(collectionName, {
985
- vectors: {
986
- size: 3072,
987
- distance: "Cosine",
988
- },
989
- });
990
- logger.info(`Collection ${collectionName} created successfully`);
991
- } catch (error) {
992
- if (error instanceof Error) {
993
- const errorMsg = error.message.toLowerCase();
994
- const errorString = JSON.stringify(error).toLowerCase();
995
-
996
- if (
997
- errorMsg.includes("already exists") ||
998
- errorString.includes("already exists") ||
999
- (error as any)?.status === 409 ||
1000
- errorString.includes("conflict")
1001
- ) {
1002
- logger.info(`Collection ${collectionName} already exists (from error response)`);
1003
- return;
1004
- }
1005
- }
1006
-
1007
- logger.error(`Error creating Qdrant collection:`, error);
1008
- logger.warn(`Continuing with existing collection...`);
1009
- }
1010
- }
1011
-
1012
- private prepareSQLiteStatements(db: Database) {
1013
- return {
1014
- insertStmt: db.prepare(`
1015
- INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash)
1016
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1017
- `),
1018
- checkHashStmt: db.prepare(`SELECT hash FROM vec_items WHERE chunk_id = ?`),
1019
- updateStmt: db.prepare(`
1020
- UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?
1021
- WHERE chunk_id = ?
1022
- `),
1023
- getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
1024
- deleteChunkStmt: db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`)
1025
- };
1026
- }
1027
-
1028
- private insertVectorsSQLite(db: Database, chunk: DocumentChunk, embedding: number[], logger: Logger, chunkHash?: string) {
1029
- const { insertStmt, updateStmt } = this.prepareSQLiteStatements(db);
1030
- const hash = chunkHash || this.generateHash(chunk.content);
1031
-
1032
- const transaction = db.transaction(() => {
1033
- const params = [
1034
- new Float32Array(embedding),
1035
- chunk.metadata.product_name,
1036
- chunk.metadata.version,
1037
- JSON.stringify(chunk.metadata.heading_hierarchy),
1038
- chunk.metadata.section,
1039
- chunk.metadata.chunk_id,
1040
- chunk.content,
1041
- chunk.metadata.url,
1042
- hash
1043
- ];
1044
-
1045
- try {
1046
- insertStmt.run(params);
1047
- } catch (error) {
1048
- updateStmt.run([...params.slice(0, 8), chunk.metadata.chunk_id]);
1049
- }
1050
- });
1051
-
1052
- transaction();
1053
- }
1054
-
1055
- private async storeChunkInQdrant(db: QdrantDB, chunk: DocumentChunk, embedding: number[], chunkHash?: string) {
1056
- const { client, collectionName } = db;
1057
- try {
1058
- let pointId: string;
1059
- try {
1060
- pointId = chunk.metadata.chunk_id;
1061
- if (!this.isValidUuid(pointId)) {
1062
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
1063
- }
1064
- } catch (e) {
1065
- pointId = crypto.randomUUID();
1066
- }
1067
-
1068
- const hash = chunkHash || this.generateHash(chunk.content);
1069
-
1070
- const pointItem = {
1071
- id: pointId,
1072
- vector: embedding,
1073
- payload: {
1074
- content: chunk.content,
1075
- product_name: chunk.metadata.product_name,
1076
- version: chunk.metadata.version,
1077
- heading_hierarchy: chunk.metadata.heading_hierarchy,
1078
- section: chunk.metadata.section,
1079
- url: chunk.metadata.url,
1080
- hash: hash,
1081
- original_chunk_id: chunk.metadata.chunk_id,
1082
- },
1083
- };
1084
-
1085
- await client.upsert(collectionName, {
1086
- wait: true,
1087
- points: [pointItem],
1088
- });
1089
- } catch (error) {
1090
- this.logger.error("Error storing chunk in Qdrant:", error);
1091
- }
1092
- }
1093
-
1094
- private removeObsoleteChunksSQLite(db: Database, visitedUrls: Set<string>, urlPrefix: string, logger: Logger) {
1095
- const getChunksForUrlStmt = db.prepare(`
1096
- SELECT chunk_id, url FROM vec_items
1097
- WHERE url LIKE ? || '%'
1098
- `);
1099
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
1100
-
1101
- const existingChunks = getChunksForUrlStmt.all(urlPrefix) as { chunk_id: string; url: string }[];
1102
- let deletedCount = 0;
1103
-
1104
- const transaction = db.transaction(() => {
1105
- for (const { chunk_id, url } of existingChunks) {
1106
- if (!visitedUrls.has(url)) {
1107
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (URL not visited)`);
1108
- deleteChunkStmt.run(chunk_id);
1109
- deletedCount++;
1110
- }
1111
- }
1112
- });
1113
- transaction();
1114
-
1115
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL ${urlPrefix}`);
1116
- }
1117
-
1118
- private removeObsoleteFilesSQLite(db: Database, processedFiles: Set<string>, dirPrefix: string, logger: Logger) {
1119
- const getChunksForDirStmt = db.prepare(`
1120
- SELECT chunk_id, url FROM vec_items
1121
- WHERE url LIKE 'file://%' AND url LIKE ?
1122
- `);
1123
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
1124
-
1125
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
1126
- const filePrefix = `file://${cleanedDirPrefix}`;
1127
-
1128
- const existingChunks = getChunksForDirStmt.all(`${filePrefix}%`) as { chunk_id: string; url: string }[];
1129
- let deletedCount = 0;
1130
-
1131
- const transaction = db.transaction(() => {
1132
- for (const { chunk_id, url } of existingChunks) {
1133
- // Remove file:// prefix to match with processedFiles
1134
- const filePath = url.substring(7);
1135
- if (!processedFiles.has(filePath)) {
1136
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (File not processed)`);
1137
- deleteChunkStmt.run(chunk_id);
1138
- deletedCount++;
1139
- }
1140
- }
1141
- });
1142
- transaction();
1143
-
1144
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for directory ${dirPrefix}`);
1145
- }
1146
-
1147
- private async removeObsoleteFilesQdrant(db: QdrantDB, processedFiles: Set<string>, dirPrefix: string, logger: Logger) {
1148
- const { client, collectionName } = db;
1149
- try {
1150
-
1151
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
1152
- const filePrefix = `file://${cleanedDirPrefix}`;
1153
-
1154
- // Get all points that match the file prefix but are not metadata points
1155
- const response = await client.scroll(collectionName, {
1156
- limit: 10000,
1157
- with_payload: true,
1158
- with_vector: false,
1159
- filter: {
1160
- must: [
1161
- {
1162
- key: "url",
1163
- match: {
1164
- text: `${filePrefix}`
1165
- }
1166
- }
1167
- ],
1168
- must_not: [
1169
- {
1170
- key: "is_metadata",
1171
- match: {
1172
- value: true
1173
- }
1174
- }
1175
- ]
1176
- }
1177
- });
1178
-
1179
- const obsoletePointIds = response.points
1180
- .filter((point: any) => {
1181
- const url = point.payload?.url;
1182
- // Double check it's not a metadata record
1183
- if (point.payload?.is_metadata === true) {
1184
- return false;
1185
- }
1186
- // Remove file:// prefix to match with processedFiles
1187
- const filePath = url && url.startsWith('file://') ? url.substring(7) : '';
1188
- return filePath && !processedFiles.has(filePath);
1189
- })
1190
- .map((point: any) => point.id);
1191
-
1192
- if (obsoletePointIds.length > 0) {
1193
- await client.delete(collectionName, {
1194
- points: obsoletePointIds,
1195
- });
1196
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for directory ${dirPrefix}`);
1197
- } else {
1198
- logger.info(`No obsolete chunks to delete from Qdrant for directory ${dirPrefix}`);
1199
- }
1200
- } catch (error) {
1201
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
1202
- }
1203
- }
1204
-
1205
- private isValidUuid(str: string): boolean {
1206
- 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;
1207
- return uuidRegex.test(str);
1208
- }
1209
-
1210
- private hashToUuid(hash: string): string {
1211
- const truncatedHash = hash.substring(0, 32);
1212
-
1213
- return [
1214
- truncatedHash.substring(0, 8),
1215
- truncatedHash.substring(8, 12),
1216
- '5' + truncatedHash.substring(13, 16),
1217
- '8' + truncatedHash.substring(17, 20),
1218
- truncatedHash.substring(20, 32)
1219
- ].join('-');
1220
- }
1221
-
1222
- private async removeObsoleteChunksQdrant(db: QdrantDB, visitedUrls: Set<string>, urlPrefix: string, logger: Logger) {
1223
- const { client, collectionName } = db;
1224
- try {
1225
- // Get all points that match the URL prefix but are not metadata points
1226
- const response = await client.scroll(collectionName, {
1227
- limit: 10000,
1228
- with_payload: true,
1229
- with_vector: false,
1230
- filter: {
1231
- must: [
1232
- {
1233
- key: "url",
1234
- match: {
1235
- text: urlPrefix
1236
- }
1237
- }
1238
- ],
1239
- must_not: [
1240
- {
1241
- key: "is_metadata",
1242
- match: {
1243
- value: true
1244
- }
1245
- }
1246
- ]
1247
- }
1248
- });
1249
-
1250
- const obsoletePointIds = response.points
1251
- .filter((point: any) => {
1252
- const url = point.payload?.url;
1253
- // Double check it's not a metadata record
1254
- if (point.payload?.is_metadata === true) {
1255
- return false;
1256
- }
1257
- return url && !visitedUrls.has(url);
1258
- })
1259
- .map((point: any) => point.id);
1260
-
1261
- if (obsoletePointIds.length > 0) {
1262
- await client.delete(collectionName, {
1263
- points: obsoletePointIds,
1264
- });
1265
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL ${urlPrefix}`);
1266
- } else {
1267
- logger.info(`No obsolete chunks to delete from Qdrant for URL ${urlPrefix}`);
1268
- }
1269
- } catch (error) {
1270
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
1271
- }
1272
- }
1273
-
1274
- private async crawlWebsite(
1275
- baseUrl: string,
1276
- sourceConfig: WebsiteSourceConfig,
1277
- processPageContent: (url: string, content: string) => Promise<void>,
1278
- parentLogger: Logger,
1279
- visitedUrls: Set<string>
1280
- ): Promise<void> {
1281
- const logger = parentLogger.child('crawler');
1282
- const queue: string[] = [baseUrl];
1283
-
1284
- logger.info(`Starting crawl from ${baseUrl}`);
1285
- let processedCount = 0;
1286
- let skippedCount = 0;
1287
- let skippedSizeCount = 0;
1288
- let errorCount = 0;
1289
-
1290
- while (queue.length > 0) {
1291
- const url = queue.shift();
1292
- if (!url) continue;
1293
-
1294
- const normalizedUrl = this.normalizeUrl(url);
1295
- if (visitedUrls.has(normalizedUrl)) continue;
1296
- visitedUrls.add(normalizedUrl);
1297
-
1298
- if (!this.shouldProcessUrl(url)) {
1299
- logger.debug(`Skipping URL with unsupported extension: ${url}`);
1300
- skippedCount++;
1301
- continue;
1302
- }
1303
-
1304
- try {
1305
- logger.info(`Crawling: ${url}`);
1306
- const content = await this.processPage(url, sourceConfig);
1307
-
1308
- if (content !== null) {
1309
- await processPageContent(url, content);
1310
- processedCount++;
1311
- } else {
1312
- skippedSizeCount++;
1313
- }
1314
-
1315
- const response = await axios.get(url);
1316
- const $ = load(response.data);
1317
-
1318
- logger.debug(`Finding links on page ${url}`);
1319
- let newLinksFound = 0;
1320
-
1321
- $('a[href]').each((_, element) => {
1322
- const href = $(element).attr('href');
1323
- if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
1324
-
1325
- const fullUrl = this.buildUrl(href, url);
1326
- if (fullUrl.startsWith(sourceConfig.url) && !visitedUrls.has(this.normalizeUrl(fullUrl))) {
1327
- if (!queue.includes(fullUrl)) {
1328
- queue.push(fullUrl);
1329
- newLinksFound++;
1330
- }
1331
- }
1332
- });
1333
-
1334
- logger.debug(`Found ${newLinksFound} new links on ${url}`);
1335
- } catch (error) {
1336
- logger.error(`Failed during link discovery or initial fetch for ${url}:`, error);
1337
- errorCount++;
1338
- }
1339
- }
1340
-
1341
- logger.info(`Crawl completed. Processed: ${processedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
1342
- }
1343
-
1344
- private shouldProcessUrl(url: string): boolean {
1345
- const parsedUrl = new URL(url);
1346
- const pathname = parsedUrl.pathname;
1347
- const ext = path.extname(pathname);
1348
-
1349
- if (!ext) return true;
1350
- return ['.html', '.htm'].includes(ext.toLowerCase());
1351
- }
1352
-
1353
- private normalizeUrl(url: string): string {
1354
- try {
1355
- const urlObj = new URL(url);
1356
- urlObj.hash = '';
1357
- urlObj.search = '';
1358
- return urlObj.toString();
1359
- } catch (error) {
1360
- return url;
1361
- }
1362
- }
1363
-
1364
- private buildUrl(href: string, currentUrl: string): string {
1365
- try {
1366
- return new URL(href, currentUrl).toString();
1367
- } catch (error) {
1368
- this.logger.warn(`Invalid URL found: ${href}`);
1369
- return '';
1370
- }
1371
- }
1372
-
1373
- private async processPage(url: string, sourceConfig: SourceConfig): Promise<string | null> {
1374
- const logger = this.logger.child('page-processor');
1375
- logger.debug(`Processing page content from ${url}`);
1376
-
1377
- const browser: Browser = await puppeteer.launch({
1378
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
1379
- });
1380
- try {
1381
- const page: Page = await browser.newPage();
1382
- logger.debug(`Navigating to ${url}`);
1383
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
1384
-
1385
- const htmlContent: string = await page.evaluate(() => {
1386
- const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
1387
- return mainContentElement.innerHTML;
1388
- });
1389
-
1390
- if (htmlContent.length > sourceConfig.max_size) {
1391
- logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
1392
- await browser.close();
1393
- return null;
1394
- }
1395
-
1396
- logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
1397
- const dom = new JSDOM(htmlContent);
1398
- const document = dom.window.document;
1399
-
1400
- document.querySelectorAll('pre').forEach((pre: HTMLElement) => {
1401
- pre.classList.add('article-content');
1402
- pre.setAttribute('data-readable-content-score', '100');
1403
- this.markCodeParents(pre.parentElement);
1404
- });
1405
-
1406
- logger.debug(`Applying Readability to extract main content`);
1407
- const reader = new Readability(document, {
1408
- charThreshold: 20,
1409
- classesToPreserve: ['article-content'],
1410
- });
1411
- const article = reader.parse();
1412
-
1413
- if (!article) {
1414
- logger.warn(`Failed to parse article content with Readability for ${url}`);
1415
- await browser.close();
1416
- return null;
1417
- }
1418
-
1419
- logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
1420
- const cleanHtml = sanitizeHtml(article.content, {
1421
- allowedTags: [
1422
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1423
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1424
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1425
- ],
1426
- allowedAttributes: {
1427
- 'a': ['href'],
1428
- 'pre': ['class', 'data-language'],
1429
- 'code': ['class', 'data-language'],
1430
- 'div': ['class'],
1431
- 'span': ['class']
1432
- }
1433
- });
1434
-
1435
- logger.debug(`Converting HTML to Markdown`);
1436
- const markdown = this.turndownService.turndown(cleanHtml);
1437
- logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
1438
- return markdown;
1439
- } catch (error) {
1440
- logger.error(`Error processing page ${url}:`, error);
1441
- return null;
1442
- } finally {
1443
- if (browser && browser.isConnected()) {
1444
- await browser.close();
1445
- logger.debug(`Browser closed for ${url}`);
1446
- }
1447
- }
1448
- }
1449
-
1450
- private markCodeParents(node: Element | null) {
1451
- if (!node) return;
1452
-
1453
- if (node.querySelector('pre, code')) {
1454
- node.classList.add('article-content');
1455
- node.setAttribute('data-readable-content-score', '100');
1456
- }
1457
- this.markCodeParents(node.parentElement);
1458
- }
1459
-
1460
- private async chunkMarkdown(markdown: string, sourceConfig: SourceConfig, url: string): Promise<DocumentChunk[]> {
1461
- const logger = this.logger.child('chunker');
1462
- logger.debug(`Chunking markdown from ${url} (${markdown.length} chars)`);
1463
-
1464
- const MAX_TOKENS = 1000;
1465
- const chunks: DocumentChunk[] = [];
1466
- const lines = markdown.split("\n");
1467
- let currentChunk = "";
1468
- let headingHierarchy: string[] = [];
1469
-
1470
- const processChunk = () => {
1471
- if (currentChunk.trim()) {
1472
- const tokens = this.tokenize(currentChunk);
1473
- if (tokens.length > MAX_TOKENS) {
1474
- logger.debug(`Chunk exceeds max token count (${tokens.length}), splitting into smaller chunks`);
1475
- let subChunk = "";
1476
- let tokenCount = 0;
1477
- const overlapSize = Math.floor(MAX_TOKENS * 0.05);
1478
- let lastTokens: string[] = [];
1479
-
1480
- for (const token of tokens) {
1481
- if (tokenCount + 1 > MAX_TOKENS) {
1482
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1483
- subChunk = lastTokens.join("") + token;
1484
- tokenCount = lastTokens.length + 1;
1485
- lastTokens = [];
1486
- } else {
1487
- subChunk += token;
1488
- tokenCount++;
1489
- lastTokens.push(token);
1490
- if (lastTokens.length > overlapSize) {
1491
- lastTokens.shift();
1492
- }
1493
- }
1494
- }
1495
- if (subChunk) {
1496
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1497
- }
1498
- } else {
1499
- chunks.push(createDocumentChunk(currentChunk, headingHierarchy));
1500
- }
1501
- }
1502
- currentChunk = "";
1503
- };
1504
-
1505
- const createDocumentChunk = (content: string, hierarchy: string[]): DocumentChunk => {
1506
- const chunkId = this.generateHash(content);
1507
- logger.debug(`Created chunk ${chunkId.substring(0, 8)}... with ${content.length} chars`);
1508
-
1509
- return {
1510
- content,
1511
- metadata: {
1512
- product_name: sourceConfig.product_name,
1513
- version: sourceConfig.version,
1514
- heading_hierarchy: [...hierarchy],
1515
- section: hierarchy[hierarchy.length - 1] || "Introduction",
1516
- chunk_id: chunkId,
1517
- url: url,
1518
- hash: this.generateHash(content)
1519
- }
1520
- };
1521
- };
1522
-
1523
- for (const line of lines) {
1524
- if (line.startsWith("#")) {
1525
- processChunk();
1526
- const levelMatch = line.match(/^(#+)/);
1527
- let level = levelMatch ? levelMatch[1].length : 1;
1528
- const heading = line.replace(/^#+\s*/, "").trim();
1529
-
1530
- logger.debug(`Found heading (level ${level}): ${heading}`);
1531
-
1532
- while (headingHierarchy.length < level - 1) {
1533
- headingHierarchy.push("");
1534
- }
1535
-
1536
- if (level <= headingHierarchy.length) {
1537
- headingHierarchy = headingHierarchy.slice(0, level - 1);
1538
- }
1539
- headingHierarchy[level - 1] = heading;
1540
- } else {
1541
- currentChunk += `${line}\n`;
1542
- }
1543
- }
1544
- processChunk();
1545
-
1546
- logger.debug(`Chunking complete, created ${chunks.length} chunks`);
1547
- return chunks;
1548
- }
1549
-
1550
- private tokenize(text: string): string[] {
1551
- return text.split(/(\s+)/).filter(token => token.length > 0);
1552
- }
1553
-
1554
- private async createEmbeddings(texts: string[]): Promise<number[][]> {
1555
- const logger = this.logger.child('embeddings');
1556
- try {
1557
- logger.debug(`Creating embeddings for ${texts.length} texts`);
1558
- const response = await this.openai.embeddings.create({
1559
- model: "text-embedding-3-large",
1560
- input: texts,
1561
- });
1562
- logger.debug(`Successfully created ${response.data.length} embeddings`);
1563
- return response.data.map(d => d.embedding);
1564
- } catch (error) {
1565
- logger.error('Failed to create embeddings:', error);
1566
- return [];
1567
- }
1568
- }
1569
-
1570
- private generateHash(content: string): string {
1571
- return crypto.createHash("sha256").update(content).digest("hex");
1572
- }
1573
- }
1574
-
1575
- if (require.main === module) {
1576
- const configPath = process.argv[2] || 'config.yaml';
1577
- if (!fs.existsSync(configPath)) {
1578
- console.error('Please provide a valid path to a YAML config file.');
1579
- process.exit(1);
1580
- }
1581
- const doc2Vec = new Doc2Vec(configPath);
1582
- doc2Vec.run().catch(console.error);
1583
- }