doc2vec 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -11
- package/dist/content-processor.js +725 -0
- package/dist/database.js +507 -0
- package/dist/doc2vec.js +48 -1012
- package/dist/types.js +2 -0
- package/dist/utils.js +118 -0
- package/package.json +8 -2
- package/Dockerfile +0 -29
- package/config.yaml +0 -167
- package/doc2vec.ts +0 -1734
- package/logger.ts +0 -287
- package/mcp/Dockerfile +0 -14
- package/mcp/README.md +0 -166
- package/mcp/package-lock.json +0 -1754
- package/mcp/package.json +0 -41
- package/mcp/src/index.ts +0 -214
- package/mcp/tsconfig.json +0 -16
- package/tsconfig.json +0 -18
package/dist/doc2vec.js
CHANGED
|
@@ -37,23 +37,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
38
|
};
|
|
39
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
-
const readability_1 = require("@mozilla/readability");
|
|
41
40
|
const axios_1 = __importDefault(require("axios"));
|
|
42
|
-
const cheerio_1 = require("cheerio");
|
|
43
41
|
const crypto_1 = __importDefault(require("crypto"));
|
|
44
|
-
const jsdom_1 = require("jsdom");
|
|
45
|
-
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
46
|
-
const sanitize_html_1 = __importDefault(require("sanitize-html"));
|
|
47
|
-
const turndown_1 = __importDefault(require("turndown"));
|
|
48
42
|
const yaml = __importStar(require("js-yaml"));
|
|
49
43
|
const fs = __importStar(require("fs"));
|
|
50
44
|
const path = __importStar(require("path"));
|
|
51
|
-
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
52
45
|
const openai_1 = require("openai");
|
|
53
46
|
const dotenv = __importStar(require("dotenv"));
|
|
54
|
-
const sqliteVec = __importStar(require("sqlite-vec"));
|
|
55
|
-
const js_client_rest_1 = require("@qdrant/js-client-rest");
|
|
56
47
|
const logger_1 = require("./logger");
|
|
48
|
+
const utils_1 = require("./utils");
|
|
49
|
+
const database_1 = require("./database");
|
|
50
|
+
const content_processor_1 = require("./content-processor");
|
|
57
51
|
const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
58
52
|
dotenv.config();
|
|
59
53
|
class Doc2Vec {
|
|
@@ -67,11 +61,7 @@ class Doc2Vec {
|
|
|
67
61
|
this.logger.info('Initializing Doc2Vec');
|
|
68
62
|
this.config = this.loadConfig(configPath);
|
|
69
63
|
this.openai = new openai_1.OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
70
|
-
this.
|
|
71
|
-
codeBlockStyle: 'fenced',
|
|
72
|
-
headingStyle: 'atx'
|
|
73
|
-
});
|
|
74
|
-
this.setupTurndownRules();
|
|
64
|
+
this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
|
|
75
65
|
}
|
|
76
66
|
loadConfig(configPath) {
|
|
77
67
|
try {
|
|
@@ -88,89 +78,6 @@ class Doc2Vec {
|
|
|
88
78
|
process.exit(1);
|
|
89
79
|
}
|
|
90
80
|
}
|
|
91
|
-
setupTurndownRules() {
|
|
92
|
-
const logger = this.logger.child('markdown');
|
|
93
|
-
logger.debug('Setting up Turndown rules for markdown conversion');
|
|
94
|
-
this.turndownService.addRule('codeBlocks', {
|
|
95
|
-
filter: (node) => node.nodeName === 'PRE',
|
|
96
|
-
replacement: (content, node) => {
|
|
97
|
-
const htmlNode = node;
|
|
98
|
-
const code = htmlNode.querySelector('code');
|
|
99
|
-
let codeContent;
|
|
100
|
-
if (code) {
|
|
101
|
-
codeContent = code.textContent || '';
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
codeContent = htmlNode.textContent || '';
|
|
105
|
-
}
|
|
106
|
-
const lines = codeContent.split('\n');
|
|
107
|
-
let minIndent = Infinity;
|
|
108
|
-
for (const line of lines) {
|
|
109
|
-
if (line.trim() === '')
|
|
110
|
-
continue;
|
|
111
|
-
const leadingWhitespace = line.match(/^\s*/)?.[0] || '';
|
|
112
|
-
minIndent = Math.min(minIndent, leadingWhitespace.length);
|
|
113
|
-
}
|
|
114
|
-
const cleanedLines = lines.map(line => {
|
|
115
|
-
return line.substring(minIndent);
|
|
116
|
-
});
|
|
117
|
-
let cleanContent = cleanedLines.join('\n');
|
|
118
|
-
cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
|
|
119
|
-
cleanContent = cleanContent.replace(/\n{2,}/g, '\n');
|
|
120
|
-
return `\n\`\`\`\n${cleanContent}\n\`\`\`\n`;
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
this.turndownService.addRule('tableCell', {
|
|
124
|
-
filter: ['th', 'td'],
|
|
125
|
-
replacement: (content, node) => {
|
|
126
|
-
const htmlNode = node;
|
|
127
|
-
let cellContent = '';
|
|
128
|
-
if (htmlNode.querySelector('p')) {
|
|
129
|
-
cellContent = Array.from(htmlNode.querySelectorAll('p'))
|
|
130
|
-
.map(p => p.textContent || '')
|
|
131
|
-
.join(' ')
|
|
132
|
-
.trim();
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
cellContent = content.trim();
|
|
136
|
-
}
|
|
137
|
-
return ` ${cellContent.replace(/\|/g, '\\|')} |`;
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
this.turndownService.addRule('tableRow', {
|
|
141
|
-
filter: 'tr',
|
|
142
|
-
replacement: (content, node) => {
|
|
143
|
-
const htmlNode = node;
|
|
144
|
-
const cells = Array.from(htmlNode.cells);
|
|
145
|
-
const isHeader = htmlNode.parentNode?.nodeName === 'THEAD';
|
|
146
|
-
let output = '|' + content.trimEnd();
|
|
147
|
-
if (isHeader) {
|
|
148
|
-
const separator = cells.map(() => '---').join(' | ');
|
|
149
|
-
output += '\n|' + separator + '|';
|
|
150
|
-
}
|
|
151
|
-
if (!isHeader || !htmlNode.nextElementSibling) {
|
|
152
|
-
output += '\n';
|
|
153
|
-
}
|
|
154
|
-
return output;
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
this.turndownService.addRule('table', {
|
|
158
|
-
filter: 'table',
|
|
159
|
-
replacement: (content) => {
|
|
160
|
-
return '\n' + content.replace(/\n+/g, '\n').trim() + '\n';
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
this.turndownService.addRule('preserveTableWhitespace', {
|
|
164
|
-
filter: (node) => {
|
|
165
|
-
return ((node.nodeName === 'TD' || node.nodeName === 'TH') &&
|
|
166
|
-
(node.textContent?.trim().length === 0));
|
|
167
|
-
},
|
|
168
|
-
replacement: () => {
|
|
169
|
-
return ' |';
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
logger.debug('Turndown rules setup complete');
|
|
173
|
-
}
|
|
174
81
|
async run() {
|
|
175
82
|
this.logger.section('PROCESSING SOURCES');
|
|
176
83
|
for (const sourceConfig of this.config.sources) {
|
|
@@ -191,134 +98,14 @@ class Doc2Vec {
|
|
|
191
98
|
}
|
|
192
99
|
this.logger.section('PROCESSING COMPLETE');
|
|
193
100
|
}
|
|
194
|
-
getUrlPrefix(url) {
|
|
195
|
-
try {
|
|
196
|
-
const parsedUrl = new URL(url);
|
|
197
|
-
return parsedUrl.origin + parsedUrl.pathname;
|
|
198
|
-
}
|
|
199
|
-
catch (error) {
|
|
200
|
-
return url;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
generateMetadataUUID(repo) {
|
|
204
|
-
// Simple deterministic approach - hash the repo name and convert to UUID format
|
|
205
|
-
const hash = crypto_1.default.createHash('md5').update(`metadata_${repo}`).digest('hex');
|
|
206
|
-
// Format as UUID with version bits set correctly (version 4)
|
|
207
|
-
return `${hash.substr(0, 8)}-${hash.substr(8, 4)}-4${hash.substr(13, 3)}-${hash.substr(16, 4)}-${hash.substr(20, 12)}`;
|
|
208
|
-
}
|
|
209
|
-
async initDatabaseMetadata(dbConnection) {
|
|
210
|
-
const logger = this.logger.child('metadata');
|
|
211
|
-
if (dbConnection.type === 'sqlite') {
|
|
212
|
-
const db = dbConnection.db;
|
|
213
|
-
logger.debug('Creating metadata table if it doesn\'t exist');
|
|
214
|
-
db.exec(`
|
|
215
|
-
CREATE TABLE IF NOT EXISTS vec_metadata (
|
|
216
|
-
key TEXT PRIMARY KEY,
|
|
217
|
-
value TEXT
|
|
218
|
-
);
|
|
219
|
-
`);
|
|
220
|
-
logger.info('SQLite metadata table initialized');
|
|
221
|
-
}
|
|
222
|
-
else if (dbConnection.type === 'qdrant') {
|
|
223
|
-
// For Qdrant, we'll use the same collection but verify it exists
|
|
224
|
-
logger.info(`Using existing Qdrant collection for metadata: ${dbConnection.collectionName}`);
|
|
225
|
-
// Nothing special to initialize as we'll use the same collection
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
async getLastRunDate(dbConnection, repo, defaultDate) {
|
|
229
|
-
const logger = this.logger.child('metadata');
|
|
230
|
-
const metadataKey = `last_run_${repo.replace('/', '_')}`;
|
|
231
|
-
try {
|
|
232
|
-
if (dbConnection.type === 'sqlite') {
|
|
233
|
-
const stmt = dbConnection.db.prepare('SELECT value FROM vec_metadata WHERE key = ?');
|
|
234
|
-
const result = stmt.get(metadataKey);
|
|
235
|
-
if (result) {
|
|
236
|
-
logger.info(`Retrieved last run date for ${repo}: ${result.value}`);
|
|
237
|
-
return result.value;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
else if (dbConnection.type === 'qdrant') {
|
|
241
|
-
// Generate a UUID for this repo's metadata
|
|
242
|
-
const metadataUUID = this.generateMetadataUUID(repo);
|
|
243
|
-
logger.debug(`Looking up metadata with UUID: ${metadataUUID}`);
|
|
244
|
-
try {
|
|
245
|
-
// Try to retrieve the metadata point for this repo
|
|
246
|
-
const response = await dbConnection.client.retrieve(dbConnection.collectionName, {
|
|
247
|
-
ids: [metadataUUID],
|
|
248
|
-
with_payload: true,
|
|
249
|
-
with_vector: false
|
|
250
|
-
});
|
|
251
|
-
if (response.length > 0 && response[0].payload?.metadata_value) {
|
|
252
|
-
const lastRunDate = response[0].payload.metadata_value;
|
|
253
|
-
logger.info(`Retrieved last run date for ${repo}: ${lastRunDate}`);
|
|
254
|
-
return lastRunDate;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
catch (error) {
|
|
258
|
-
logger.warn(`Failed to retrieve metadata for ${repo}:`, error);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
catch (error) {
|
|
263
|
-
logger.warn(`Error retrieving last run date:`, error);
|
|
264
|
-
}
|
|
265
|
-
logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
|
|
266
|
-
return defaultDate;
|
|
267
|
-
}
|
|
268
|
-
async updateLastRunDate(dbConnection, repo) {
|
|
269
|
-
const logger = this.logger.child('metadata');
|
|
270
|
-
const now = new Date().toISOString();
|
|
271
|
-
try {
|
|
272
|
-
if (dbConnection.type === 'sqlite') {
|
|
273
|
-
const metadataKey = `last_run_${repo.replace('/', '_')}`;
|
|
274
|
-
const stmt = dbConnection.db.prepare(`
|
|
275
|
-
INSERT INTO vec_metadata (key, value) VALUES (?, ?)
|
|
276
|
-
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
277
|
-
`);
|
|
278
|
-
stmt.run(metadataKey, now);
|
|
279
|
-
logger.info(`Updated last run date for ${repo} to ${now}`);
|
|
280
|
-
}
|
|
281
|
-
else if (dbConnection.type === 'qdrant') {
|
|
282
|
-
// Generate UUID for this repo's metadata
|
|
283
|
-
const metadataUUID = this.generateMetadataUUID(repo);
|
|
284
|
-
const metadataKey = `last_run_${repo.replace('/', '_')}`;
|
|
285
|
-
logger.debug(`Using UUID: ${metadataUUID} for metadata`);
|
|
286
|
-
// Generate a dummy embedding (all zeros)
|
|
287
|
-
const dummyEmbeddingSize = 3072; // Same size as your content embeddings
|
|
288
|
-
const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
|
|
289
|
-
// Create a point with special metadata payload
|
|
290
|
-
const metadataPoint = {
|
|
291
|
-
id: metadataUUID,
|
|
292
|
-
vector: dummyEmbedding,
|
|
293
|
-
payload: {
|
|
294
|
-
metadata_key: metadataKey,
|
|
295
|
-
metadata_value: now,
|
|
296
|
-
is_metadata: true, // Flag to identify metadata points
|
|
297
|
-
content: `Metadata: Last run date for ${repo}`,
|
|
298
|
-
product_name: 'system',
|
|
299
|
-
version: 'metadata',
|
|
300
|
-
url: 'metadata://' + repo
|
|
301
|
-
}
|
|
302
|
-
};
|
|
303
|
-
await dbConnection.client.upsert(dbConnection.collectionName, {
|
|
304
|
-
wait: true,
|
|
305
|
-
points: [metadataPoint]
|
|
306
|
-
});
|
|
307
|
-
logger.info(`Updated last run date for ${repo} to ${now}`);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
catch (error) {
|
|
311
|
-
logger.error(`Failed to update last run date for ${repo}:`, error);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
101
|
async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
|
|
315
102
|
const [owner, repoName] = repo.split('/');
|
|
316
103
|
const GITHUB_API_URL = `https://api.github.com/repos/${owner}/${repoName}/issues`;
|
|
317
104
|
// Initialize metadata storage if needed
|
|
318
|
-
await
|
|
105
|
+
await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
|
|
319
106
|
// Get the last run date from the database
|
|
320
107
|
const startDate = sourceConfig.start_date || '2025-01-01';
|
|
321
|
-
const lastRunDate = await
|
|
108
|
+
const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
|
|
322
109
|
const fetchWithRetry = async (url, params = {}, retries = 5, delay = 5000) => {
|
|
323
110
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
324
111
|
try {
|
|
@@ -405,14 +192,14 @@ class Doc2Vec {
|
|
|
405
192
|
product_name: sourceConfig.product_name || repo,
|
|
406
193
|
max_size: sourceConfig.max_size || Infinity
|
|
407
194
|
};
|
|
408
|
-
const chunks = await this.chunkMarkdown(markdown, issueConfig, url);
|
|
195
|
+
const chunks = await this.contentProcessor.chunkMarkdown(markdown, issueConfig, url);
|
|
409
196
|
logger.info(`Issue #${issueNumber}: Created ${chunks.length} chunks`);
|
|
410
197
|
// Process and store each chunk immediately
|
|
411
198
|
for (const chunk of chunks) {
|
|
412
|
-
const chunkHash =
|
|
199
|
+
const chunkHash = utils_1.Utils.generateHash(chunk.content);
|
|
413
200
|
const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
|
|
414
201
|
if (dbConnection.type === 'sqlite') {
|
|
415
|
-
const { checkHashStmt } =
|
|
202
|
+
const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
|
|
416
203
|
const existing = checkHashStmt.get(chunk.metadata.chunk_id);
|
|
417
204
|
if (existing && existing.hash === chunkHash) {
|
|
418
205
|
logger.info(`Skipping unchanged chunk: ${chunkId}`);
|
|
@@ -420,7 +207,7 @@ class Doc2Vec {
|
|
|
420
207
|
}
|
|
421
208
|
const embeddings = await this.createEmbeddings([chunk.content]);
|
|
422
209
|
if (embeddings.length) {
|
|
423
|
-
|
|
210
|
+
database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
|
|
424
211
|
logger.debug(`Stored chunk ${chunkId} in SQLite`);
|
|
425
212
|
}
|
|
426
213
|
else {
|
|
@@ -432,8 +219,8 @@ class Doc2Vec {
|
|
|
432
219
|
let pointId;
|
|
433
220
|
try {
|
|
434
221
|
pointId = chunk.metadata.chunk_id;
|
|
435
|
-
if (!
|
|
436
|
-
pointId =
|
|
222
|
+
if (!utils_1.Utils.isValidUuid(pointId)) {
|
|
223
|
+
pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
|
|
437
224
|
}
|
|
438
225
|
}
|
|
439
226
|
catch (e) {
|
|
@@ -450,7 +237,7 @@ class Doc2Vec {
|
|
|
450
237
|
}
|
|
451
238
|
const embeddings = await this.createEmbeddings([chunk.content]);
|
|
452
239
|
if (embeddings.length) {
|
|
453
|
-
await
|
|
240
|
+
await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
|
|
454
241
|
logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
|
|
455
242
|
}
|
|
456
243
|
else {
|
|
@@ -472,15 +259,15 @@ class Doc2Vec {
|
|
|
472
259
|
await processIssue(issues[i]);
|
|
473
260
|
}
|
|
474
261
|
// Update the last run date in the database after processing all issues
|
|
475
|
-
await
|
|
262
|
+
await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger);
|
|
476
263
|
logger.info(`Successfully processed ${issues.length} issues`);
|
|
477
264
|
}
|
|
478
265
|
async processGithubRepo(config, parentLogger) {
|
|
479
266
|
const logger = parentLogger.child('process');
|
|
480
267
|
logger.info(`Starting processing for GitHub repo: ${config.repo}`);
|
|
481
|
-
const dbConnection = await
|
|
268
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
482
269
|
// Initialize metadata storage
|
|
483
|
-
await
|
|
270
|
+
await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
|
|
484
271
|
logger.section('GITHUB ISSUES');
|
|
485
272
|
// Process GitHub issues
|
|
486
273
|
await this.fetchAndProcessGitHubIssues(config.repo, config, dbConnection, logger);
|
|
@@ -489,16 +276,16 @@ class Doc2Vec {
|
|
|
489
276
|
async processWebsite(config, parentLogger) {
|
|
490
277
|
const logger = parentLogger.child('process');
|
|
491
278
|
logger.info(`Starting processing for website: ${config.url}`);
|
|
492
|
-
const dbConnection = await
|
|
279
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
493
280
|
const validChunkIds = new Set();
|
|
494
281
|
const visitedUrls = new Set();
|
|
495
|
-
const urlPrefix =
|
|
282
|
+
const urlPrefix = utils_1.Utils.getUrlPrefix(config.url);
|
|
496
283
|
logger.section('CRAWL AND EMBEDDING');
|
|
497
|
-
await this.crawlWebsite(config.url, config, async (url, content) => {
|
|
284
|
+
const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
|
|
498
285
|
visitedUrls.add(url);
|
|
499
286
|
logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
|
|
500
287
|
try {
|
|
501
|
-
const chunks = await this.chunkMarkdown(content, config, url);
|
|
288
|
+
const chunks = await this.contentProcessor.chunkMarkdown(content, config, url);
|
|
502
289
|
logger.info(`Created ${chunks.length} chunks`);
|
|
503
290
|
if (chunks.length > 0) {
|
|
504
291
|
const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
|
|
@@ -507,9 +294,9 @@ class Doc2Vec {
|
|
|
507
294
|
validChunkIds.add(chunk.metadata.chunk_id);
|
|
508
295
|
const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
|
|
509
296
|
let needsEmbedding = true;
|
|
510
|
-
const chunkHash =
|
|
297
|
+
const chunkHash = utils_1.Utils.generateHash(chunk.content);
|
|
511
298
|
if (dbConnection.type === 'sqlite') {
|
|
512
|
-
const { checkHashStmt } =
|
|
299
|
+
const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
|
|
513
300
|
const existing = checkHashStmt.get(chunk.metadata.chunk_id);
|
|
514
301
|
if (existing && existing.hash === chunkHash) {
|
|
515
302
|
needsEmbedding = false;
|
|
@@ -522,8 +309,8 @@ class Doc2Vec {
|
|
|
522
309
|
let pointId;
|
|
523
310
|
try {
|
|
524
311
|
pointId = chunk.metadata.chunk_id;
|
|
525
|
-
if (!
|
|
526
|
-
pointId =
|
|
312
|
+
if (!utils_1.Utils.isValidUuid(pointId)) {
|
|
313
|
+
pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
|
|
527
314
|
}
|
|
528
315
|
}
|
|
529
316
|
catch (e) {
|
|
@@ -549,11 +336,11 @@ class Doc2Vec {
|
|
|
549
336
|
if (embeddings.length > 0) {
|
|
550
337
|
const embedding = embeddings[0];
|
|
551
338
|
if (dbConnection.type === 'sqlite') {
|
|
552
|
-
|
|
339
|
+
database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
|
|
553
340
|
chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
|
|
554
341
|
}
|
|
555
342
|
else if (dbConnection.type === 'qdrant') {
|
|
556
|
-
await
|
|
343
|
+
await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
|
|
557
344
|
chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
|
|
558
345
|
}
|
|
559
346
|
}
|
|
@@ -572,24 +359,29 @@ class Doc2Vec {
|
|
|
572
359
|
}, logger, visitedUrls);
|
|
573
360
|
logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
|
|
574
361
|
logger.section('CLEANUP');
|
|
575
|
-
if (
|
|
576
|
-
logger.
|
|
577
|
-
this.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
|
|
362
|
+
if (crawlResult.hasNetworkErrors) {
|
|
363
|
+
logger.warn('Skipping cleanup due to network errors encountered during crawling. This prevents removal of valid chunks when the site is temporarily unreachable.');
|
|
578
364
|
}
|
|
579
|
-
else
|
|
580
|
-
|
|
581
|
-
|
|
365
|
+
else {
|
|
366
|
+
if (dbConnection.type === 'sqlite') {
|
|
367
|
+
logger.info(`Running SQLite cleanup for ${urlPrefix}`);
|
|
368
|
+
database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
|
|
369
|
+
}
|
|
370
|
+
else if (dbConnection.type === 'qdrant') {
|
|
371
|
+
logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
|
|
372
|
+
await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
|
|
373
|
+
}
|
|
582
374
|
}
|
|
583
375
|
logger.info(`Finished processing website: ${config.url}`);
|
|
584
376
|
}
|
|
585
377
|
async processLocalDirectory(config, parentLogger) {
|
|
586
378
|
const logger = parentLogger.child('process');
|
|
587
379
|
logger.info(`Starting processing for local directory: ${config.path}`);
|
|
588
|
-
const dbConnection = await
|
|
380
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
589
381
|
const validChunkIds = new Set();
|
|
590
382
|
const processedFiles = new Set();
|
|
591
383
|
logger.section('FILE SCANNING AND EMBEDDING');
|
|
592
|
-
await this.processDirectory(config.path, config, async (filePath, content) => {
|
|
384
|
+
await this.contentProcessor.processDirectory(config.path, config, async (filePath, content) => {
|
|
593
385
|
processedFiles.add(filePath);
|
|
594
386
|
logger.info(`Processing content from ${filePath} (${content.length} chars)`);
|
|
595
387
|
try {
|
|
@@ -618,7 +410,7 @@ class Doc2Vec {
|
|
|
618
410
|
// Use default file:// URL
|
|
619
411
|
fileUrl = `file://${filePath}`;
|
|
620
412
|
}
|
|
621
|
-
const chunks = await this.chunkMarkdown(content, config, fileUrl);
|
|
413
|
+
const chunks = await this.contentProcessor.chunkMarkdown(content, config, fileUrl);
|
|
622
414
|
logger.info(`Created ${chunks.length} chunks`);
|
|
623
415
|
if (chunks.length > 0) {
|
|
624
416
|
const chunkProgress = logger.progress(`Embedding chunks for ${filePath}`, chunks.length);
|
|
@@ -627,9 +419,9 @@ class Doc2Vec {
|
|
|
627
419
|
validChunkIds.add(chunk.metadata.chunk_id);
|
|
628
420
|
const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
|
|
629
421
|
let needsEmbedding = true;
|
|
630
|
-
const chunkHash =
|
|
422
|
+
const chunkHash = utils_1.Utils.generateHash(chunk.content);
|
|
631
423
|
if (dbConnection.type === 'sqlite') {
|
|
632
|
-
const { checkHashStmt } =
|
|
424
|
+
const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
|
|
633
425
|
const existing = checkHashStmt.get(chunk.metadata.chunk_id);
|
|
634
426
|
if (existing && existing.hash === chunkHash) {
|
|
635
427
|
needsEmbedding = false;
|
|
@@ -642,8 +434,8 @@ class Doc2Vec {
|
|
|
642
434
|
let pointId;
|
|
643
435
|
try {
|
|
644
436
|
pointId = chunk.metadata.chunk_id;
|
|
645
|
-
if (!
|
|
646
|
-
pointId =
|
|
437
|
+
if (!utils_1.Utils.isValidUuid(pointId)) {
|
|
438
|
+
pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
|
|
647
439
|
}
|
|
648
440
|
}
|
|
649
441
|
catch (e) {
|
|
@@ -669,11 +461,11 @@ class Doc2Vec {
|
|
|
669
461
|
if (embeddings.length > 0) {
|
|
670
462
|
const embedding = embeddings[0];
|
|
671
463
|
if (dbConnection.type === 'sqlite') {
|
|
672
|
-
|
|
464
|
+
database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
|
|
673
465
|
chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
|
|
674
466
|
}
|
|
675
467
|
else if (dbConnection.type === 'qdrant') {
|
|
676
|
-
await
|
|
468
|
+
await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
|
|
677
469
|
chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
|
|
678
470
|
}
|
|
679
471
|
}
|
|
@@ -693,767 +485,14 @@ class Doc2Vec {
|
|
|
693
485
|
logger.section('CLEANUP');
|
|
694
486
|
if (dbConnection.type === 'sqlite') {
|
|
695
487
|
logger.info(`Running SQLite cleanup for local directory ${config.path}`);
|
|
696
|
-
|
|
488
|
+
database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
|
|
697
489
|
}
|
|
698
490
|
else if (dbConnection.type === 'qdrant') {
|
|
699
491
|
logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
|
|
700
|
-
await
|
|
492
|
+
await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
|
|
701
493
|
}
|
|
702
494
|
logger.info(`Finished processing local directory: ${config.path}`);
|
|
703
495
|
}
|
|
704
|
-
async processDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set()) {
|
|
705
|
-
const logger = parentLogger.child('directory-processor');
|
|
706
|
-
logger.info(`Processing directory: ${dirPath}`);
|
|
707
|
-
const recursive = config.recursive !== undefined ? config.recursive : true;
|
|
708
|
-
const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm'];
|
|
709
|
-
const excludeExtensions = config.exclude_extensions || [];
|
|
710
|
-
const encoding = config.encoding || 'utf8';
|
|
711
|
-
try {
|
|
712
|
-
const files = fs.readdirSync(dirPath);
|
|
713
|
-
let processedFiles = 0;
|
|
714
|
-
let skippedFiles = 0;
|
|
715
|
-
for (const file of files) {
|
|
716
|
-
const filePath = path.join(dirPath, file);
|
|
717
|
-
const stat = fs.statSync(filePath);
|
|
718
|
-
// Skip already visited paths
|
|
719
|
-
if (visitedPaths.has(filePath)) {
|
|
720
|
-
logger.debug(`Skipping already visited path: ${filePath}`);
|
|
721
|
-
continue;
|
|
722
|
-
}
|
|
723
|
-
visitedPaths.add(filePath);
|
|
724
|
-
if (stat.isDirectory()) {
|
|
725
|
-
if (recursive) {
|
|
726
|
-
await this.processDirectory(filePath, config, processFileContent, logger, visitedPaths);
|
|
727
|
-
}
|
|
728
|
-
else {
|
|
729
|
-
logger.debug(`Skipping directory ${filePath} (recursive=false)`);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
else if (stat.isFile()) {
|
|
733
|
-
const extension = path.extname(file).toLowerCase();
|
|
734
|
-
// Apply extension filters
|
|
735
|
-
if (excludeExtensions.includes(extension)) {
|
|
736
|
-
logger.debug(`Skipping file with excluded extension: ${filePath}`);
|
|
737
|
-
skippedFiles++;
|
|
738
|
-
continue;
|
|
739
|
-
}
|
|
740
|
-
if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
|
|
741
|
-
logger.debug(`Skipping file with non-included extension: ${filePath}`);
|
|
742
|
-
skippedFiles++;
|
|
743
|
-
continue;
|
|
744
|
-
}
|
|
745
|
-
try {
|
|
746
|
-
logger.info(`Reading file: ${filePath}`);
|
|
747
|
-
const content = fs.readFileSync(filePath, { encoding: encoding });
|
|
748
|
-
if (content.length > config.max_size) {
|
|
749
|
-
logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
|
|
750
|
-
skippedFiles++;
|
|
751
|
-
continue;
|
|
752
|
-
}
|
|
753
|
-
// Convert HTML to Markdown if needed
|
|
754
|
-
let processedContent;
|
|
755
|
-
if (extension === '.html' || extension === '.htm') {
|
|
756
|
-
logger.debug(`Converting HTML to Markdown for ${filePath}`);
|
|
757
|
-
const cleanHtml = (0, sanitize_html_1.default)(content, {
|
|
758
|
-
allowedTags: [
|
|
759
|
-
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
760
|
-
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
761
|
-
'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
|
|
762
|
-
],
|
|
763
|
-
allowedAttributes: {
|
|
764
|
-
'a': ['href'],
|
|
765
|
-
'pre': ['class', 'data-language'],
|
|
766
|
-
'code': ['class', 'data-language'],
|
|
767
|
-
'div': ['class'],
|
|
768
|
-
'span': ['class']
|
|
769
|
-
}
|
|
770
|
-
});
|
|
771
|
-
processedContent = this.turndownService.turndown(cleanHtml);
|
|
772
|
-
}
|
|
773
|
-
else {
|
|
774
|
-
processedContent = content;
|
|
775
|
-
}
|
|
776
|
-
await processFileContent(filePath, processedContent);
|
|
777
|
-
processedFiles++;
|
|
778
|
-
}
|
|
779
|
-
catch (error) {
|
|
780
|
-
logger.error(`Error processing file ${filePath}:`, error);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
logger.info(`Directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
|
|
785
|
-
}
|
|
786
|
-
catch (error) {
|
|
787
|
-
logger.error(`Error reading directory ${dirPath}:`, error);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
removeObsoleteFilesSQLite(db, processedFiles, pathConfig, logger) {
|
|
791
|
-
const getChunksForPathStmt = db.prepare(`
|
|
792
|
-
SELECT chunk_id, url FROM vec_items
|
|
793
|
-
WHERE url LIKE ? || '%'
|
|
794
|
-
`);
|
|
795
|
-
const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
|
|
796
|
-
// Determine if we're using URL rewriting or direct file paths
|
|
797
|
-
const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
|
|
798
|
-
// Set up the URL prefix for searching
|
|
799
|
-
let urlPrefix;
|
|
800
|
-
if (isRewriteMode) {
|
|
801
|
-
// Handle URL rewriting case
|
|
802
|
-
urlPrefix = pathConfig.url_rewrite_prefix || '';
|
|
803
|
-
urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
|
|
804
|
-
}
|
|
805
|
-
else {
|
|
806
|
-
// Handle direct file path case
|
|
807
|
-
const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
|
|
808
|
-
const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
|
|
809
|
-
urlPrefix = `file://${cleanedDirPrefix}`;
|
|
810
|
-
}
|
|
811
|
-
logger.debug(`Searching for chunks with URL prefix: ${urlPrefix}`);
|
|
812
|
-
const existingChunks = getChunksForPathStmt.all(urlPrefix);
|
|
813
|
-
let deletedCount = 0;
|
|
814
|
-
const transaction = db.transaction(() => {
|
|
815
|
-
for (const { chunk_id, url } of existingChunks) {
|
|
816
|
-
// Skip if it's not from our URL prefix (safety check)
|
|
817
|
-
if (!url.startsWith(urlPrefix))
|
|
818
|
-
continue;
|
|
819
|
-
let filePath;
|
|
820
|
-
let shouldDelete = false;
|
|
821
|
-
if (isRewriteMode) {
|
|
822
|
-
// URL rewrite mode: extract relative path and construct full file path
|
|
823
|
-
const config = pathConfig;
|
|
824
|
-
const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
|
|
825
|
-
filePath = path.join(config.path, relativePath);
|
|
826
|
-
shouldDelete = !processedFiles.has(filePath);
|
|
827
|
-
}
|
|
828
|
-
else {
|
|
829
|
-
// Direct file path mode: remove file:// prefix to match with processedFiles
|
|
830
|
-
filePath = url.substring(7); // Remove 'file://' prefix
|
|
831
|
-
shouldDelete = !processedFiles.has(filePath);
|
|
832
|
-
}
|
|
833
|
-
if (shouldDelete) {
|
|
834
|
-
logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (File not processed: ${filePath})`);
|
|
835
|
-
deleteChunkStmt.run(chunk_id);
|
|
836
|
-
deletedCount++;
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
});
|
|
840
|
-
transaction();
|
|
841
|
-
logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL prefix ${urlPrefix}`);
|
|
842
|
-
}
|
|
843
|
-
async removeObsoleteFilesQdrant(db, processedFiles, pathConfig, logger) {
|
|
844
|
-
const { client, collectionName } = db;
|
|
845
|
-
try {
|
|
846
|
-
// Determine if we're using URL rewriting or direct file paths
|
|
847
|
-
const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
|
|
848
|
-
// Set up the URL prefix for searching
|
|
849
|
-
let urlPrefix;
|
|
850
|
-
if (isRewriteMode) {
|
|
851
|
-
// Handle URL rewriting case
|
|
852
|
-
urlPrefix = pathConfig.url_rewrite_prefix || '';
|
|
853
|
-
urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
|
|
854
|
-
}
|
|
855
|
-
else {
|
|
856
|
-
// Handle direct file path case
|
|
857
|
-
const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
|
|
858
|
-
const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
|
|
859
|
-
urlPrefix = `file://${cleanedDirPrefix}`;
|
|
860
|
-
}
|
|
861
|
-
logger.debug(`Checking for obsolete chunks with URL prefix: ${urlPrefix}`);
|
|
862
|
-
const response = await client.scroll(collectionName, {
|
|
863
|
-
limit: 10000,
|
|
864
|
-
with_payload: true,
|
|
865
|
-
with_vector: false,
|
|
866
|
-
filter: {
|
|
867
|
-
must: [
|
|
868
|
-
{
|
|
869
|
-
key: "url",
|
|
870
|
-
match: {
|
|
871
|
-
text: urlPrefix
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
],
|
|
875
|
-
must_not: [
|
|
876
|
-
{
|
|
877
|
-
key: "is_metadata",
|
|
878
|
-
match: {
|
|
879
|
-
value: true
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
]
|
|
883
|
-
}
|
|
884
|
-
});
|
|
885
|
-
const obsoletePointIds = response.points
|
|
886
|
-
.filter((point) => {
|
|
887
|
-
const url = point.payload?.url;
|
|
888
|
-
// Double check it's not a metadata record
|
|
889
|
-
if (point.payload?.is_metadata === true) {
|
|
890
|
-
return false;
|
|
891
|
-
}
|
|
892
|
-
if (!url || !url.startsWith(urlPrefix)) {
|
|
893
|
-
return false;
|
|
894
|
-
}
|
|
895
|
-
let filePath;
|
|
896
|
-
if (isRewriteMode) {
|
|
897
|
-
// URL rewrite mode: extract relative path and construct full file path
|
|
898
|
-
const config = pathConfig;
|
|
899
|
-
const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
|
|
900
|
-
filePath = path.join(config.path, relativePath);
|
|
901
|
-
}
|
|
902
|
-
else {
|
|
903
|
-
// Direct file path mode: remove file:// prefix to match with processedFiles
|
|
904
|
-
filePath = url.startsWith('file://') ? url.substring(7) : '';
|
|
905
|
-
}
|
|
906
|
-
return filePath && !processedFiles.has(filePath);
|
|
907
|
-
})
|
|
908
|
-
.map((point) => point.id);
|
|
909
|
-
if (obsoletePointIds.length > 0) {
|
|
910
|
-
await client.delete(collectionName, {
|
|
911
|
-
points: obsoletePointIds,
|
|
912
|
-
});
|
|
913
|
-
logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL prefix ${urlPrefix}`);
|
|
914
|
-
}
|
|
915
|
-
else {
|
|
916
|
-
logger.info(`No obsolete chunks to delete from Qdrant for URL prefix ${urlPrefix}`);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
catch (error) {
|
|
920
|
-
logger.error(`Error removing obsolete chunks from Qdrant:`, error);
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
async initDatabase(config, parentLogger) {
|
|
924
|
-
const logger = parentLogger.child('database');
|
|
925
|
-
const dbConfig = config.database_config;
|
|
926
|
-
if (dbConfig.type === 'sqlite') {
|
|
927
|
-
const params = dbConfig.params;
|
|
928
|
-
const dbPath = params.db_path || path.join(process.cwd(), `${config.product_name.replace(/\s+/g, '_')}-${config.version}.db`);
|
|
929
|
-
logger.info(`Opening SQLite database at ${dbPath}`);
|
|
930
|
-
const db = new better_sqlite3_1.default(dbPath, { allowExtension: true });
|
|
931
|
-
sqliteVec.load(db);
|
|
932
|
-
logger.debug(`Creating vec_items table if it doesn't exist`);
|
|
933
|
-
db.exec(`
|
|
934
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(
|
|
935
|
-
embedding FLOAT[3072],
|
|
936
|
-
product_name TEXT,
|
|
937
|
-
version TEXT,
|
|
938
|
-
heading_hierarchy TEXT,
|
|
939
|
-
section TEXT,
|
|
940
|
-
chunk_id TEXT UNIQUE,
|
|
941
|
-
content TEXT,
|
|
942
|
-
url TEXT,
|
|
943
|
-
hash TEXT
|
|
944
|
-
);
|
|
945
|
-
`);
|
|
946
|
-
logger.info(`SQLite database initialized successfully`);
|
|
947
|
-
return { db, type: 'sqlite' };
|
|
948
|
-
}
|
|
949
|
-
else if (dbConfig.type === 'qdrant') {
|
|
950
|
-
const params = dbConfig.params;
|
|
951
|
-
const qdrantUrl = params.qdrant_url || 'http://localhost:6333';
|
|
952
|
-
const qdrantPort = params.qdrant_port || 443;
|
|
953
|
-
const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
|
|
954
|
-
logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
|
|
955
|
-
const qdrantClient = new js_client_rest_1.QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
|
|
956
|
-
await this.createCollectionQdrant(qdrantClient, collectionName, logger);
|
|
957
|
-
logger.info(`Qdrant connection established successfully`);
|
|
958
|
-
return { client: qdrantClient, collectionName, type: 'qdrant' };
|
|
959
|
-
}
|
|
960
|
-
else {
|
|
961
|
-
const errMsg = `Unsupported database type: ${dbConfig.type}`;
|
|
962
|
-
logger.error(errMsg);
|
|
963
|
-
throw new Error(errMsg);
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
async createCollectionQdrant(qdrantClient, collectionName, logger) {
|
|
967
|
-
try {
|
|
968
|
-
logger.debug(`Checking if collection ${collectionName} exists`);
|
|
969
|
-
const collections = await qdrantClient.getCollections();
|
|
970
|
-
const collectionExists = collections.collections.some((collection) => collection.name === collectionName);
|
|
971
|
-
if (collectionExists) {
|
|
972
|
-
logger.info(`Collection ${collectionName} already exists`);
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
logger.info(`Creating new collection ${collectionName}`);
|
|
976
|
-
await qdrantClient.createCollection(collectionName, {
|
|
977
|
-
vectors: {
|
|
978
|
-
size: 3072,
|
|
979
|
-
distance: "Cosine",
|
|
980
|
-
},
|
|
981
|
-
});
|
|
982
|
-
logger.info(`Collection ${collectionName} created successfully`);
|
|
983
|
-
}
|
|
984
|
-
catch (error) {
|
|
985
|
-
if (error instanceof Error) {
|
|
986
|
-
const errorMsg = error.message.toLowerCase();
|
|
987
|
-
const errorString = JSON.stringify(error).toLowerCase();
|
|
988
|
-
if (errorMsg.includes("already exists") ||
|
|
989
|
-
errorString.includes("already exists") ||
|
|
990
|
-
error?.status === 409 ||
|
|
991
|
-
errorString.includes("conflict")) {
|
|
992
|
-
logger.info(`Collection ${collectionName} already exists (from error response)`);
|
|
993
|
-
return;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
logger.error(`Error creating Qdrant collection:`, error);
|
|
997
|
-
logger.warn(`Continuing with existing collection...`);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
prepareSQLiteStatements(db) {
|
|
1001
|
-
return {
|
|
1002
|
-
insertStmt: db.prepare(`
|
|
1003
|
-
INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash)
|
|
1004
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1005
|
-
`),
|
|
1006
|
-
checkHashStmt: db.prepare(`SELECT hash FROM vec_items WHERE chunk_id = ?`),
|
|
1007
|
-
updateStmt: db.prepare(`
|
|
1008
|
-
UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?
|
|
1009
|
-
WHERE chunk_id = ?
|
|
1010
|
-
`),
|
|
1011
|
-
getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
|
|
1012
|
-
deleteChunkStmt: db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`)
|
|
1013
|
-
};
|
|
1014
|
-
}
|
|
1015
|
-
insertVectorsSQLite(db, chunk, embedding, logger, chunkHash) {
|
|
1016
|
-
const { insertStmt, updateStmt } = this.prepareSQLiteStatements(db);
|
|
1017
|
-
const hash = chunkHash || this.generateHash(chunk.content);
|
|
1018
|
-
const transaction = db.transaction(() => {
|
|
1019
|
-
const params = [
|
|
1020
|
-
new Float32Array(embedding),
|
|
1021
|
-
chunk.metadata.product_name,
|
|
1022
|
-
chunk.metadata.version,
|
|
1023
|
-
JSON.stringify(chunk.metadata.heading_hierarchy),
|
|
1024
|
-
chunk.metadata.section,
|
|
1025
|
-
chunk.metadata.chunk_id,
|
|
1026
|
-
chunk.content,
|
|
1027
|
-
chunk.metadata.url,
|
|
1028
|
-
hash
|
|
1029
|
-
];
|
|
1030
|
-
try {
|
|
1031
|
-
insertStmt.run(params);
|
|
1032
|
-
}
|
|
1033
|
-
catch (error) {
|
|
1034
|
-
updateStmt.run([...params.slice(0, 8), chunk.metadata.chunk_id]);
|
|
1035
|
-
}
|
|
1036
|
-
});
|
|
1037
|
-
transaction();
|
|
1038
|
-
}
|
|
1039
|
-
async storeChunkInQdrant(db, chunk, embedding, chunkHash) {
|
|
1040
|
-
const { client, collectionName } = db;
|
|
1041
|
-
try {
|
|
1042
|
-
let pointId;
|
|
1043
|
-
try {
|
|
1044
|
-
pointId = chunk.metadata.chunk_id;
|
|
1045
|
-
if (!this.isValidUuid(pointId)) {
|
|
1046
|
-
pointId = this.hashToUuid(chunk.metadata.chunk_id);
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
catch (e) {
|
|
1050
|
-
pointId = crypto_1.default.randomUUID();
|
|
1051
|
-
}
|
|
1052
|
-
const hash = chunkHash || this.generateHash(chunk.content);
|
|
1053
|
-
const pointItem = {
|
|
1054
|
-
id: pointId,
|
|
1055
|
-
vector: embedding,
|
|
1056
|
-
payload: {
|
|
1057
|
-
content: chunk.content,
|
|
1058
|
-
product_name: chunk.metadata.product_name,
|
|
1059
|
-
version: chunk.metadata.version,
|
|
1060
|
-
heading_hierarchy: chunk.metadata.heading_hierarchy,
|
|
1061
|
-
section: chunk.metadata.section,
|
|
1062
|
-
url: chunk.metadata.url,
|
|
1063
|
-
hash: hash,
|
|
1064
|
-
original_chunk_id: chunk.metadata.chunk_id,
|
|
1065
|
-
},
|
|
1066
|
-
};
|
|
1067
|
-
await client.upsert(collectionName, {
|
|
1068
|
-
wait: true,
|
|
1069
|
-
points: [pointItem],
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
catch (error) {
|
|
1073
|
-
this.logger.error("Error storing chunk in Qdrant:", error);
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
removeObsoleteChunksSQLite(db, visitedUrls, urlPrefix, logger) {
|
|
1077
|
-
const getChunksForUrlStmt = db.prepare(`
|
|
1078
|
-
SELECT chunk_id, url FROM vec_items
|
|
1079
|
-
WHERE url LIKE ? || '%'
|
|
1080
|
-
`);
|
|
1081
|
-
const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
|
|
1082
|
-
const existingChunks = getChunksForUrlStmt.all(urlPrefix);
|
|
1083
|
-
let deletedCount = 0;
|
|
1084
|
-
const transaction = db.transaction(() => {
|
|
1085
|
-
for (const { chunk_id, url } of existingChunks) {
|
|
1086
|
-
if (!visitedUrls.has(url)) {
|
|
1087
|
-
logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (URL not visited)`);
|
|
1088
|
-
deleteChunkStmt.run(chunk_id);
|
|
1089
|
-
deletedCount++;
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
});
|
|
1093
|
-
transaction();
|
|
1094
|
-
logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL ${urlPrefix}`);
|
|
1095
|
-
}
|
|
1096
|
-
isValidUuid(str) {
|
|
1097
|
-
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1098
|
-
return uuidRegex.test(str);
|
|
1099
|
-
}
|
|
1100
|
-
hashToUuid(hash) {
|
|
1101
|
-
const truncatedHash = hash.substring(0, 32);
|
|
1102
|
-
return [
|
|
1103
|
-
truncatedHash.substring(0, 8),
|
|
1104
|
-
truncatedHash.substring(8, 12),
|
|
1105
|
-
'5' + truncatedHash.substring(13, 16),
|
|
1106
|
-
'8' + truncatedHash.substring(17, 20),
|
|
1107
|
-
truncatedHash.substring(20, 32)
|
|
1108
|
-
].join('-');
|
|
1109
|
-
}
|
|
1110
|
-
async removeObsoleteChunksQdrant(db, visitedUrls, urlPrefix, logger) {
|
|
1111
|
-
const { client, collectionName } = db;
|
|
1112
|
-
try {
|
|
1113
|
-
// Get all points that match the URL prefix but are not metadata points
|
|
1114
|
-
const response = await client.scroll(collectionName, {
|
|
1115
|
-
limit: 10000,
|
|
1116
|
-
with_payload: true,
|
|
1117
|
-
with_vector: false,
|
|
1118
|
-
filter: {
|
|
1119
|
-
must: [
|
|
1120
|
-
{
|
|
1121
|
-
key: "url",
|
|
1122
|
-
match: {
|
|
1123
|
-
text: urlPrefix
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
],
|
|
1127
|
-
must_not: [
|
|
1128
|
-
{
|
|
1129
|
-
key: "is_metadata",
|
|
1130
|
-
match: {
|
|
1131
|
-
value: true
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
]
|
|
1135
|
-
}
|
|
1136
|
-
});
|
|
1137
|
-
const obsoletePointIds = response.points
|
|
1138
|
-
.filter((point) => {
|
|
1139
|
-
const url = point.payload?.url;
|
|
1140
|
-
// Double check it's not a metadata record
|
|
1141
|
-
if (point.payload?.is_metadata === true) {
|
|
1142
|
-
return false;
|
|
1143
|
-
}
|
|
1144
|
-
return url && !visitedUrls.has(url);
|
|
1145
|
-
})
|
|
1146
|
-
.map((point) => point.id);
|
|
1147
|
-
if (obsoletePointIds.length > 0) {
|
|
1148
|
-
await client.delete(collectionName, {
|
|
1149
|
-
points: obsoletePointIds,
|
|
1150
|
-
});
|
|
1151
|
-
logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL ${urlPrefix}`);
|
|
1152
|
-
}
|
|
1153
|
-
else {
|
|
1154
|
-
logger.info(`No obsolete chunks to delete from Qdrant for URL ${urlPrefix}`);
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
catch (error) {
|
|
1158
|
-
logger.error(`Error removing obsolete chunks from Qdrant:`, error);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
async parseSitemap(sitemapUrl, logger) {
|
|
1162
|
-
logger.info(`Parsing sitemap from ${sitemapUrl}`);
|
|
1163
|
-
try {
|
|
1164
|
-
const response = await axios_1.default.get(sitemapUrl);
|
|
1165
|
-
const $ = (0, cheerio_1.load)(response.data, { xmlMode: true });
|
|
1166
|
-
const urls = [];
|
|
1167
|
-
// Handle standard sitemaps
|
|
1168
|
-
$('url > loc').each((_, element) => {
|
|
1169
|
-
const url = $(element).text().trim();
|
|
1170
|
-
if (url) {
|
|
1171
|
-
urls.push(url);
|
|
1172
|
-
}
|
|
1173
|
-
});
|
|
1174
|
-
// Handle sitemap indexes (sitemaps that link to other sitemaps)
|
|
1175
|
-
const sitemapLinks = [];
|
|
1176
|
-
$('sitemap > loc').each((_, element) => {
|
|
1177
|
-
const nestedSitemapUrl = $(element).text().trim();
|
|
1178
|
-
if (nestedSitemapUrl) {
|
|
1179
|
-
sitemapLinks.push(nestedSitemapUrl);
|
|
1180
|
-
}
|
|
1181
|
-
});
|
|
1182
|
-
// Recursively process nested sitemaps
|
|
1183
|
-
for (const nestedSitemapUrl of sitemapLinks) {
|
|
1184
|
-
logger.debug(`Found nested sitemap: ${nestedSitemapUrl}`);
|
|
1185
|
-
const nestedUrls = await this.parseSitemap(nestedSitemapUrl, logger);
|
|
1186
|
-
urls.push(...nestedUrls);
|
|
1187
|
-
}
|
|
1188
|
-
logger.info(`Found ${urls.length} URLs in sitemap ${sitemapUrl}`);
|
|
1189
|
-
return urls;
|
|
1190
|
-
}
|
|
1191
|
-
catch (error) {
|
|
1192
|
-
logger.error(`Error parsing sitemap at ${sitemapUrl}:`, error);
|
|
1193
|
-
return [];
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls) {
|
|
1197
|
-
const logger = parentLogger.child('crawler');
|
|
1198
|
-
const queue = [baseUrl];
|
|
1199
|
-
// Process sitemap if provided
|
|
1200
|
-
if (sourceConfig.sitemap_url) {
|
|
1201
|
-
logger.section('SITEMAP PROCESSING');
|
|
1202
|
-
const sitemapUrls = await this.parseSitemap(sourceConfig.sitemap_url, logger);
|
|
1203
|
-
// Add sitemap URLs to the queue if they're within the website scope
|
|
1204
|
-
for (const url of sitemapUrls) {
|
|
1205
|
-
if (url.startsWith(sourceConfig.url) && !queue.includes(url)) {
|
|
1206
|
-
logger.debug(`Adding URL from sitemap to queue: ${url}`);
|
|
1207
|
-
queue.push(url);
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
logger.info(`Added ${queue.length - 1} URLs from sitemap to the crawl queue`);
|
|
1211
|
-
}
|
|
1212
|
-
logger.info(`Starting crawl from ${baseUrl} with ${queue.length} URLs in initial queue`);
|
|
1213
|
-
let processedCount = 0;
|
|
1214
|
-
let skippedCount = 0;
|
|
1215
|
-
let skippedSizeCount = 0;
|
|
1216
|
-
let errorCount = 0;
|
|
1217
|
-
while (queue.length > 0) {
|
|
1218
|
-
const url = queue.shift();
|
|
1219
|
-
if (!url)
|
|
1220
|
-
continue;
|
|
1221
|
-
const normalizedUrl = this.normalizeUrl(url);
|
|
1222
|
-
if (visitedUrls.has(normalizedUrl))
|
|
1223
|
-
continue;
|
|
1224
|
-
visitedUrls.add(normalizedUrl);
|
|
1225
|
-
if (!this.shouldProcessUrl(url)) {
|
|
1226
|
-
logger.debug(`Skipping URL with unsupported extension: ${url}`);
|
|
1227
|
-
skippedCount++;
|
|
1228
|
-
continue;
|
|
1229
|
-
}
|
|
1230
|
-
try {
|
|
1231
|
-
logger.info(`Crawling: ${url}`);
|
|
1232
|
-
const content = await this.processPage(url, sourceConfig);
|
|
1233
|
-
if (content !== null) {
|
|
1234
|
-
await processPageContent(url, content);
|
|
1235
|
-
processedCount++;
|
|
1236
|
-
}
|
|
1237
|
-
else {
|
|
1238
|
-
skippedSizeCount++;
|
|
1239
|
-
}
|
|
1240
|
-
const response = await axios_1.default.get(url);
|
|
1241
|
-
const $ = (0, cheerio_1.load)(response.data);
|
|
1242
|
-
logger.debug(`Finding links on page ${url}`);
|
|
1243
|
-
let newLinksFound = 0;
|
|
1244
|
-
$('a[href]').each((_, element) => {
|
|
1245
|
-
const href = $(element).attr('href');
|
|
1246
|
-
if (!href || href.startsWith('#') || href.startsWith('mailto:'))
|
|
1247
|
-
return;
|
|
1248
|
-
const fullUrl = this.buildUrl(href, url);
|
|
1249
|
-
if (fullUrl.startsWith(sourceConfig.url) && !visitedUrls.has(this.normalizeUrl(fullUrl))) {
|
|
1250
|
-
if (!queue.includes(fullUrl)) {
|
|
1251
|
-
queue.push(fullUrl);
|
|
1252
|
-
newLinksFound++;
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
});
|
|
1256
|
-
logger.debug(`Found ${newLinksFound} new links on ${url}`);
|
|
1257
|
-
}
|
|
1258
|
-
catch (error) {
|
|
1259
|
-
logger.error(`Failed during link discovery or initial fetch for ${url}:`, error);
|
|
1260
|
-
errorCount++;
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
logger.info(`Crawl completed. Processed: ${processedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
|
|
1264
|
-
}
|
|
1265
|
-
shouldProcessUrl(url) {
|
|
1266
|
-
const parsedUrl = new URL(url);
|
|
1267
|
-
const pathname = parsedUrl.pathname;
|
|
1268
|
-
const ext = path.extname(pathname);
|
|
1269
|
-
if (!ext)
|
|
1270
|
-
return true;
|
|
1271
|
-
return ['.html', '.htm'].includes(ext.toLowerCase());
|
|
1272
|
-
}
|
|
1273
|
-
normalizeUrl(url) {
|
|
1274
|
-
try {
|
|
1275
|
-
const urlObj = new URL(url);
|
|
1276
|
-
urlObj.hash = '';
|
|
1277
|
-
urlObj.search = '';
|
|
1278
|
-
return urlObj.toString();
|
|
1279
|
-
}
|
|
1280
|
-
catch (error) {
|
|
1281
|
-
return url;
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
buildUrl(href, currentUrl) {
|
|
1285
|
-
try {
|
|
1286
|
-
return new URL(href, currentUrl).toString();
|
|
1287
|
-
}
|
|
1288
|
-
catch (error) {
|
|
1289
|
-
this.logger.warn(`Invalid URL found: ${href}`);
|
|
1290
|
-
return '';
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
async processPage(url, sourceConfig) {
|
|
1294
|
-
const logger = this.logger.child('page-processor');
|
|
1295
|
-
logger.debug(`Processing page content from ${url}`);
|
|
1296
|
-
let browser = null;
|
|
1297
|
-
try {
|
|
1298
|
-
browser = await puppeteer_1.default.launch({
|
|
1299
|
-
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
1300
|
-
});
|
|
1301
|
-
const page = await browser.newPage();
|
|
1302
|
-
logger.debug(`Navigating to ${url}`);
|
|
1303
|
-
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
1304
|
-
const htmlContent = await page.evaluate(() => {
|
|
1305
|
-
const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
|
|
1306
|
-
return mainContentElement.innerHTML;
|
|
1307
|
-
});
|
|
1308
|
-
if (htmlContent.length > sourceConfig.max_size) {
|
|
1309
|
-
logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
|
|
1310
|
-
await browser.close();
|
|
1311
|
-
return null;
|
|
1312
|
-
}
|
|
1313
|
-
logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
|
|
1314
|
-
const dom = new jsdom_1.JSDOM(htmlContent);
|
|
1315
|
-
const document = dom.window.document;
|
|
1316
|
-
document.querySelectorAll('pre').forEach((pre) => {
|
|
1317
|
-
pre.classList.add('article-content');
|
|
1318
|
-
pre.setAttribute('data-readable-content-score', '100');
|
|
1319
|
-
this.markCodeParents(pre.parentElement);
|
|
1320
|
-
});
|
|
1321
|
-
logger.debug(`Applying Readability to extract main content`);
|
|
1322
|
-
const reader = new readability_1.Readability(document, {
|
|
1323
|
-
charThreshold: 20,
|
|
1324
|
-
classesToPreserve: ['article-content'],
|
|
1325
|
-
});
|
|
1326
|
-
const article = reader.parse();
|
|
1327
|
-
if (!article) {
|
|
1328
|
-
logger.warn(`Failed to parse article content with Readability for ${url}`);
|
|
1329
|
-
await browser.close();
|
|
1330
|
-
return null;
|
|
1331
|
-
}
|
|
1332
|
-
logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
|
|
1333
|
-
const cleanHtml = (0, sanitize_html_1.default)(article.content, {
|
|
1334
|
-
allowedTags: [
|
|
1335
|
-
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
1336
|
-
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
1337
|
-
'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
|
|
1338
|
-
],
|
|
1339
|
-
allowedAttributes: {
|
|
1340
|
-
'a': ['href'],
|
|
1341
|
-
'pre': ['class', 'data-language'],
|
|
1342
|
-
'code': ['class', 'data-language'],
|
|
1343
|
-
'div': ['class'],
|
|
1344
|
-
'span': ['class']
|
|
1345
|
-
}
|
|
1346
|
-
});
|
|
1347
|
-
logger.debug(`Converting HTML to Markdown`);
|
|
1348
|
-
const markdown = this.turndownService.turndown(cleanHtml);
|
|
1349
|
-
logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
|
|
1350
|
-
return markdown;
|
|
1351
|
-
}
|
|
1352
|
-
catch (error) {
|
|
1353
|
-
logger.error(`Error processing page ${url}:`, error);
|
|
1354
|
-
return null;
|
|
1355
|
-
}
|
|
1356
|
-
finally {
|
|
1357
|
-
if (browser && browser.isConnected()) {
|
|
1358
|
-
await browser.close();
|
|
1359
|
-
logger.debug(`Browser closed for ${url}`);
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
markCodeParents(node) {
|
|
1364
|
-
if (!node)
|
|
1365
|
-
return;
|
|
1366
|
-
if (node.querySelector('pre, code')) {
|
|
1367
|
-
node.classList.add('article-content');
|
|
1368
|
-
node.setAttribute('data-readable-content-score', '100');
|
|
1369
|
-
}
|
|
1370
|
-
this.markCodeParents(node.parentElement);
|
|
1371
|
-
}
|
|
1372
|
-
async chunkMarkdown(markdown, sourceConfig, url) {
|
|
1373
|
-
const logger = this.logger.child('chunker');
|
|
1374
|
-
logger.debug(`Chunking markdown from ${url} (${markdown.length} chars)`);
|
|
1375
|
-
const MAX_TOKENS = 1000;
|
|
1376
|
-
const chunks = [];
|
|
1377
|
-
const lines = markdown.split("\n");
|
|
1378
|
-
let currentChunk = "";
|
|
1379
|
-
let headingHierarchy = [];
|
|
1380
|
-
const processChunk = () => {
|
|
1381
|
-
if (currentChunk.trim()) {
|
|
1382
|
-
const tokens = this.tokenize(currentChunk);
|
|
1383
|
-
if (tokens.length > MAX_TOKENS) {
|
|
1384
|
-
logger.debug(`Chunk exceeds max token count (${tokens.length}), splitting into smaller chunks`);
|
|
1385
|
-
let subChunk = "";
|
|
1386
|
-
let tokenCount = 0;
|
|
1387
|
-
const overlapSize = Math.floor(MAX_TOKENS * 0.05);
|
|
1388
|
-
let lastTokens = [];
|
|
1389
|
-
for (const token of tokens) {
|
|
1390
|
-
if (tokenCount + 1 > MAX_TOKENS) {
|
|
1391
|
-
chunks.push(createDocumentChunk(subChunk, headingHierarchy));
|
|
1392
|
-
subChunk = lastTokens.join("") + token;
|
|
1393
|
-
tokenCount = lastTokens.length + 1;
|
|
1394
|
-
lastTokens = [];
|
|
1395
|
-
}
|
|
1396
|
-
else {
|
|
1397
|
-
subChunk += token;
|
|
1398
|
-
tokenCount++;
|
|
1399
|
-
lastTokens.push(token);
|
|
1400
|
-
if (lastTokens.length > overlapSize) {
|
|
1401
|
-
lastTokens.shift();
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
if (subChunk) {
|
|
1406
|
-
chunks.push(createDocumentChunk(subChunk, headingHierarchy));
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
else {
|
|
1410
|
-
chunks.push(createDocumentChunk(currentChunk, headingHierarchy));
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
currentChunk = "";
|
|
1414
|
-
};
|
|
1415
|
-
const createDocumentChunk = (content, hierarchy) => {
|
|
1416
|
-
const chunkId = this.generateHash(content);
|
|
1417
|
-
logger.debug(`Created chunk ${chunkId.substring(0, 8)}... with ${content.length} chars`);
|
|
1418
|
-
return {
|
|
1419
|
-
content,
|
|
1420
|
-
metadata: {
|
|
1421
|
-
product_name: sourceConfig.product_name,
|
|
1422
|
-
version: sourceConfig.version,
|
|
1423
|
-
heading_hierarchy: [...hierarchy],
|
|
1424
|
-
section: hierarchy[hierarchy.length - 1] || "Introduction",
|
|
1425
|
-
chunk_id: chunkId,
|
|
1426
|
-
url: url,
|
|
1427
|
-
hash: this.generateHash(content)
|
|
1428
|
-
}
|
|
1429
|
-
};
|
|
1430
|
-
};
|
|
1431
|
-
for (const line of lines) {
|
|
1432
|
-
if (line.startsWith("#")) {
|
|
1433
|
-
processChunk();
|
|
1434
|
-
const levelMatch = line.match(/^(#+)/);
|
|
1435
|
-
let level = levelMatch ? levelMatch[1].length : 1;
|
|
1436
|
-
const heading = line.replace(/^#+\s*/, "").trim();
|
|
1437
|
-
logger.debug(`Found heading (level ${level}): ${heading}`);
|
|
1438
|
-
while (headingHierarchy.length < level - 1) {
|
|
1439
|
-
headingHierarchy.push("");
|
|
1440
|
-
}
|
|
1441
|
-
if (level <= headingHierarchy.length) {
|
|
1442
|
-
headingHierarchy = headingHierarchy.slice(0, level - 1);
|
|
1443
|
-
}
|
|
1444
|
-
headingHierarchy[level - 1] = heading;
|
|
1445
|
-
}
|
|
1446
|
-
else {
|
|
1447
|
-
currentChunk += `${line}\n`;
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
processChunk();
|
|
1451
|
-
logger.debug(`Chunking complete, created ${chunks.length} chunks`);
|
|
1452
|
-
return chunks;
|
|
1453
|
-
}
|
|
1454
|
-
tokenize(text) {
|
|
1455
|
-
return text.split(/(\s+)/).filter(token => token.length > 0);
|
|
1456
|
-
}
|
|
1457
496
|
async createEmbeddings(texts) {
|
|
1458
497
|
const logger = this.logger.child('embeddings');
|
|
1459
498
|
try {
|
|
@@ -1470,9 +509,6 @@ class Doc2Vec {
|
|
|
1470
509
|
return [];
|
|
1471
510
|
}
|
|
1472
511
|
}
|
|
1473
|
-
generateHash(content) {
|
|
1474
|
-
return crypto_1.default.createHash("sha256").update(content).digest("hex");
|
|
1475
|
-
}
|
|
1476
512
|
}
|
|
1477
513
|
if (require.main === module) {
|
|
1478
514
|
const configPath = process.argv[2] || 'config.yaml';
|