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.
- package/dist/doc2vec.js +1 -1
- package/doc2vec.ts +1 -1
- package/package.json +1 -1
- package/Dockerfile.old +0 -19
- package/config copy.yaml +0 -182
- package/config-github.yaml +0 -15
- package/config.yaml.new +0 -253
- package/config.yaml.ok +0 -164
- package/config.yaml.solo +0 -251
- package/dist/solo-reference-architectures/Gloo_Edge/ALB_Last_Mile_Encryption/data/acm-waf-cdk/lib/acm-cdk-stack.js +0 -20
- package/dist/solo-reference-architectures/Gloo_Edge/ALB_Last_Mile_Encryption/data/acm-waf-cdk/lib/route53-cdk-stack.js +0 -33
- package/dist/solo-reference-architectures/Gloo_Edge/ALB_Last_Mile_Encryption/data/acm-waf-cdk/lib/waf-cdk-stack.js +0 -66
- package/dist/solo-reference-architectures/Gloo_Edge/ALB_Last_Mile_Encryption/data/acm-waf-cdk/test/s3-cloudfront-cdk.test.js +0 -16
- package/dist/solo-reference-architectures/Gloo_Edge/Cloudfront_NLB/data/s3-cloudfront-cdk/lib/acm-cdk-stack.js +0 -21
- package/dist/solo-reference-architectures/Gloo_Edge/Cloudfront_NLB/data/s3-cloudfront-cdk/lib/s3-cloudfront-cdk-stack.js +0 -95
- package/dist/solo-reference-architectures/Gloo_Edge/Cloudfront_NLB/data/s3-cloudfront-cdk/test/s3-cloudfront-cdk.test.js +0 -16
- package/dist/solo-reference-architectures/Gloo_Gateway/aws-nlb-tls-offloading/data/acm-cdk/bin/acm-cdk.js +0 -55
- package/dist/solo-reference-architectures/Gloo_Gateway/aws-nlb-tls-offloading/data/acm-cdk/lib/acm-cdk-stack.js +0 -20
- package/dist/solo-reference-architectures/Gloo_Gateway/aws-nlb-tls-offloading/data/acm-cdk/lib/route53-cdk-stack.js +0 -33
- package/dist/solo-reference-architectures/Gloo_Gateway/aws-nlb-tls-offloading/data/acm-cdk/test/s3-cloudfront-cdk.test.js +0 -16
- package/doc2vec.js +0 -1805
- package/doc2vec.ts.new +0 -1583
- package/doc2vec.ts.ok +0 -1730
- package/istio-issues.db +0 -0
- package/logger.js +0 -260
- package/tmp/README.md.old +0 -1
- 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
|
-
}
|