doc2vec 2.10.6 → 2.12.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 +98 -0
- package/dist/content-processor.js +122 -22
- package/dist/controller/chunk-inspector.js +209 -0
- package/dist/controller/server.js +29 -0
- package/dist/database.js +64 -19
- package/dist/doc2vec.js +184 -42
- package/dist/logger.js +89 -0
- package/dist/types.js +12 -0
- package/dist/ui/assets/index-CZSZsL3G.css +1 -0
- package/dist/ui/assets/{index-D-QOw3rk.js → index-vglJgkwy.js} +78 -78
- package/dist/ui/index.html +2 -2
- package/package.json +7 -2
- package/dist/ui/assets/index-BVg6n7Si.css +0 -1
package/dist/database.js
CHANGED
|
@@ -71,6 +71,7 @@ class DatabaseManager {
|
|
|
71
71
|
total_chunks INTEGER
|
|
72
72
|
);
|
|
73
73
|
`);
|
|
74
|
+
this.ensureChunkDatesTable(db);
|
|
74
75
|
logger.info(`SQLite database initialized successfully`);
|
|
75
76
|
return { db, type: 'sqlite' };
|
|
76
77
|
}
|
|
@@ -307,9 +308,24 @@ class DatabaseManager {
|
|
|
307
308
|
logger.error(`Failed to update last run date for ${repo}:`, error);
|
|
308
309
|
}
|
|
309
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Chunk creation timestamps live in a companion table because vec_items is
|
|
313
|
+
* a vec0 virtual table that cannot be ALTERed to add a column. Rows are
|
|
314
|
+
* written on every chunk insert/update; stale rows for deleted chunks are
|
|
315
|
+
* harmless (lookups always join against vec_items).
|
|
316
|
+
*/
|
|
317
|
+
static ensureChunkDatesTable(db) {
|
|
318
|
+
db.exec(`
|
|
319
|
+
CREATE TABLE IF NOT EXISTS vec_chunk_dates (
|
|
320
|
+
chunk_id TEXT PRIMARY KEY,
|
|
321
|
+
created_at TEXT NOT NULL
|
|
322
|
+
);
|
|
323
|
+
`);
|
|
324
|
+
}
|
|
310
325
|
static prepareSQLiteStatements(db) {
|
|
311
326
|
let cached = this.columnCache.get(db);
|
|
312
327
|
if (!cached) {
|
|
328
|
+
this.ensureChunkDatesTable(db);
|
|
313
329
|
cached = {
|
|
314
330
|
hasBranch: this.hasColumn(db, 'branch'),
|
|
315
331
|
hasRepo: this.hasColumn(db, 'repo')
|
|
@@ -360,12 +376,16 @@ class DatabaseManager {
|
|
|
360
376
|
`),
|
|
361
377
|
getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
|
|
362
378
|
deleteChunkStmt: db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`),
|
|
379
|
+
touchDateStmt: db.prepare(`
|
|
380
|
+
INSERT INTO vec_chunk_dates (chunk_id, created_at) VALUES (?, ?)
|
|
381
|
+
ON CONFLICT(chunk_id) DO UPDATE SET created_at = excluded.created_at
|
|
382
|
+
`),
|
|
363
383
|
hasBranchColumn,
|
|
364
384
|
hasRepoColumn
|
|
365
385
|
};
|
|
366
386
|
}
|
|
367
387
|
static insertVectorsSQLite(db, chunk, embedding, logger, chunkHash) {
|
|
368
|
-
const { insertStmt, updateStmt, hasBranchColumn, hasRepoColumn } = this.prepareSQLiteStatements(db);
|
|
388
|
+
const { insertStmt, updateStmt, touchDateStmt, hasBranchColumn, hasRepoColumn } = this.prepareSQLiteStatements(db);
|
|
369
389
|
const hash = chunkHash || utils_1.Utils.generateHash(chunk.content);
|
|
370
390
|
const transaction = db.transaction(() => {
|
|
371
391
|
// Use BigInt for true integer representation in SQLite vec0
|
|
@@ -401,6 +421,7 @@ class DatabaseManager {
|
|
|
401
421
|
updateValues.push(JSON.stringify(chunk.metadata.heading_hierarchy), chunk.metadata.section, chunk.content, chunk.metadata.url, hash, chunkIndex, totalChunks, chunk.metadata.chunk_id);
|
|
402
422
|
updateStmt.run(...updateValues);
|
|
403
423
|
}
|
|
424
|
+
touchDateStmt.run(chunk.metadata.chunk_id, new Date().toISOString());
|
|
404
425
|
});
|
|
405
426
|
transaction();
|
|
406
427
|
}
|
|
@@ -434,6 +455,7 @@ class DatabaseManager {
|
|
|
434
455
|
original_chunk_id: chunk.metadata.chunk_id,
|
|
435
456
|
chunk_index: chunk.metadata.chunk_index,
|
|
436
457
|
total_chunks: chunk.metadata.total_chunks,
|
|
458
|
+
created_at: new Date().toISOString(),
|
|
437
459
|
},
|
|
438
460
|
};
|
|
439
461
|
await client.upsert(collectionName, {
|
|
@@ -457,17 +479,20 @@ class DatabaseManager {
|
|
|
457
479
|
const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
|
|
458
480
|
const existingChunks = getChunksForUrlStmt.all(urlPrefix);
|
|
459
481
|
let deletedCount = 0;
|
|
482
|
+
const deletedUrls = new Set();
|
|
460
483
|
const transaction = db.transaction(() => {
|
|
461
484
|
for (const { chunk_id, url } of existingChunks) {
|
|
462
485
|
if (!visitedUrls.has(url)) {
|
|
463
486
|
logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (URL not visited)`);
|
|
464
487
|
deleteChunkStmt.run(chunk_id);
|
|
465
488
|
deletedCount++;
|
|
489
|
+
deletedUrls.add(url);
|
|
466
490
|
}
|
|
467
491
|
}
|
|
468
492
|
});
|
|
469
493
|
transaction();
|
|
470
494
|
logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL ${urlPrefix}`);
|
|
495
|
+
return { items: deletedUrls.size, chunks: deletedCount };
|
|
471
496
|
}
|
|
472
497
|
static async removeObsoleteChunksQdrant(db, visitedUrls, urlPrefix, logger) {
|
|
473
498
|
const { client, collectionName } = db;
|
|
@@ -496,6 +521,7 @@ class DatabaseManager {
|
|
|
496
521
|
]
|
|
497
522
|
};
|
|
498
523
|
const obsoletePointIds = [];
|
|
524
|
+
const obsoleteUrls = new Set();
|
|
499
525
|
let offset = undefined;
|
|
500
526
|
do {
|
|
501
527
|
const response = await client.scroll(collectionName, {
|
|
@@ -513,6 +539,7 @@ class DatabaseManager {
|
|
|
513
539
|
}
|
|
514
540
|
if (url && !visitedUrls.has(url)) {
|
|
515
541
|
obsoletePointIds.push(point.id);
|
|
542
|
+
obsoleteUrls.add(url);
|
|
516
543
|
}
|
|
517
544
|
}
|
|
518
545
|
offset = response.next_page_offset;
|
|
@@ -526,6 +553,7 @@ class DatabaseManager {
|
|
|
526
553
|
else {
|
|
527
554
|
logger.info(`No obsolete chunks to delete from Qdrant for URL ${urlPrefix}`);
|
|
528
555
|
}
|
|
556
|
+
return { items: obsoleteUrls.size, chunks: obsoletePointIds.length };
|
|
529
557
|
}
|
|
530
558
|
catch (error) {
|
|
531
559
|
// Rethrow so a failed cleanup fails the job instead of silently
|
|
@@ -548,31 +576,42 @@ class DatabaseManager {
|
|
|
548
576
|
const deleteStmt = db.prepare(`DELETE FROM vec_items WHERE url = ?`);
|
|
549
577
|
const result = deleteStmt.run(url);
|
|
550
578
|
logger.info(`Deleted ${result.changes} chunks from SQLite for URL ${url}`);
|
|
579
|
+
return result.changes;
|
|
551
580
|
}
|
|
552
581
|
static async removeChunksByUrlQdrant(db, url, logger) {
|
|
553
582
|
const { client, collectionName } = db;
|
|
554
583
|
try {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
value: url // Exact match — must match getChunkHashesByUrlQdrant's filter
|
|
562
|
-
}
|
|
584
|
+
const filter = {
|
|
585
|
+
must: [
|
|
586
|
+
{
|
|
587
|
+
key: 'url',
|
|
588
|
+
match: {
|
|
589
|
+
value: url // Exact match — must match getChunkHashesByUrlQdrant's filter
|
|
563
590
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
591
|
+
}
|
|
592
|
+
],
|
|
593
|
+
must_not: [
|
|
594
|
+
{
|
|
595
|
+
key: 'is_metadata',
|
|
596
|
+
match: {
|
|
597
|
+
value: true
|
|
571
598
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
599
|
+
}
|
|
600
|
+
]
|
|
601
|
+
};
|
|
602
|
+
// Count first so callers can report how many chunks the delete removed
|
|
603
|
+
// (Qdrant's delete-by-filter response doesn't include a count).
|
|
604
|
+
let deletedCount = 0;
|
|
605
|
+
try {
|
|
606
|
+
const countResult = await client.count(collectionName, { filter, exact: true });
|
|
607
|
+
deletedCount = countResult?.count ?? 0;
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// Count is informational only — never fail the delete over it
|
|
611
|
+
}
|
|
612
|
+
await client.delete(collectionName, { filter });
|
|
575
613
|
logger.info(`Deleted chunks from Qdrant for URL ${url}`);
|
|
614
|
+
return deletedCount;
|
|
576
615
|
}
|
|
577
616
|
catch (error) {
|
|
578
617
|
// Rethrow so the failure propagates and the job exits non-zero.
|
|
@@ -697,6 +736,7 @@ class DatabaseManager {
|
|
|
697
736
|
logger.debug(`Searching for chunks with URL prefix: ${urlPrefix}`);
|
|
698
737
|
const existingChunks = getChunksForPathStmt.all(urlPrefix);
|
|
699
738
|
let deletedCount = 0;
|
|
739
|
+
const deletedUrls = new Set();
|
|
700
740
|
const transaction = db.transaction(() => {
|
|
701
741
|
for (const { chunk_id, url } of existingChunks) {
|
|
702
742
|
// Skip if it's not from our URL prefix (safety check)
|
|
@@ -720,11 +760,13 @@ class DatabaseManager {
|
|
|
720
760
|
logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (File not processed: ${filePath})`);
|
|
721
761
|
deleteChunkStmt.run(chunk_id);
|
|
722
762
|
deletedCount++;
|
|
763
|
+
deletedUrls.add(url);
|
|
723
764
|
}
|
|
724
765
|
}
|
|
725
766
|
});
|
|
726
767
|
transaction();
|
|
727
768
|
logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL prefix ${urlPrefix}`);
|
|
769
|
+
return { items: deletedUrls.size, chunks: deletedCount };
|
|
728
770
|
}
|
|
729
771
|
static async removeObsoleteFilesQdrant(db, processedFiles, pathConfig, logger) {
|
|
730
772
|
const { client, collectionName } = db;
|
|
@@ -768,6 +810,7 @@ class DatabaseManager {
|
|
|
768
810
|
]
|
|
769
811
|
};
|
|
770
812
|
const obsoletePointIds = [];
|
|
813
|
+
const obsoleteUrls = new Set();
|
|
771
814
|
let offset = undefined;
|
|
772
815
|
do {
|
|
773
816
|
const response = await client.scroll(collectionName, {
|
|
@@ -799,6 +842,7 @@ class DatabaseManager {
|
|
|
799
842
|
}
|
|
800
843
|
if (filePath && !processedFiles.has(filePath)) {
|
|
801
844
|
obsoletePointIds.push(point.id);
|
|
845
|
+
obsoleteUrls.add(url);
|
|
802
846
|
}
|
|
803
847
|
}
|
|
804
848
|
offset = response.next_page_offset;
|
|
@@ -812,6 +856,7 @@ class DatabaseManager {
|
|
|
812
856
|
else {
|
|
813
857
|
logger.info(`No obsolete chunks to delete from Qdrant for URL prefix ${urlPrefix}`);
|
|
814
858
|
}
|
|
859
|
+
return { items: obsoleteUrls.size, chunks: obsoletePointIds.length };
|
|
815
860
|
}
|
|
816
861
|
catch (error) {
|
|
817
862
|
// Rethrow so a failed cleanup fails the job instead of silently
|
package/dist/doc2vec.js
CHANGED
|
@@ -53,6 +53,7 @@ const logger_1 = require("./logger");
|
|
|
53
53
|
const utils_1 = require("./utils");
|
|
54
54
|
const database_1 = require("./database");
|
|
55
55
|
const content_processor_1 = require("./content-processor");
|
|
56
|
+
const types_1 = require("./types");
|
|
56
57
|
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
57
58
|
const markdown_store_1 = require("./markdown-store");
|
|
58
59
|
const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
@@ -61,6 +62,9 @@ dotenv.config();
|
|
|
61
62
|
class Doc2Vec {
|
|
62
63
|
constructor(configPath) {
|
|
63
64
|
this.brokenLinksByWebsite = {};
|
|
65
|
+
// Change counters for the source currently being processed by run().
|
|
66
|
+
// Sources are processed sequentially, so a single slot is sufficient.
|
|
67
|
+
this.counters = (0, types_1.newSourceRunCounters)('items');
|
|
64
68
|
this.logger = new logger_1.Logger('Doc2Vec', {
|
|
65
69
|
level: logger_1.LogLevel.DEBUG,
|
|
66
70
|
useTimestamp: true,
|
|
@@ -176,36 +180,69 @@ class Doc2Vec {
|
|
|
176
180
|
await this.markdownStore.init();
|
|
177
181
|
}
|
|
178
182
|
this.logger.section('PROCESSING SOURCES');
|
|
183
|
+
const runStats = [];
|
|
179
184
|
for (const sourceConfig of this.config.sources) {
|
|
180
185
|
const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
|
|
181
186
|
sourceLogger.info(`Processing ${sourceConfig.type} source for ${sourceConfig.product_name}@${sourceConfig.version}`);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
187
|
+
const startTime = Date.now();
|
|
188
|
+
let ok = true;
|
|
189
|
+
let errorMessage;
|
|
190
|
+
const itemsKindByType = {
|
|
191
|
+
website: 'pages',
|
|
192
|
+
github: 'issues',
|
|
193
|
+
local_directory: 'files',
|
|
194
|
+
code: 'files',
|
|
195
|
+
s3: 'objects',
|
|
196
|
+
zendesk: 'items',
|
|
197
|
+
};
|
|
198
|
+
this.counters = (0, types_1.newSourceRunCounters)(itemsKindByType[sourceConfig.type] ?? 'items');
|
|
199
|
+
try {
|
|
200
|
+
if (sourceConfig.type === 'github') {
|
|
201
|
+
await this.processGithubRepo(sourceConfig, sourceLogger);
|
|
202
|
+
}
|
|
203
|
+
else if (sourceConfig.type === 'website') {
|
|
204
|
+
await this.processWebsite(sourceConfig, sourceLogger);
|
|
205
|
+
}
|
|
206
|
+
else if (sourceConfig.type === 'local_directory') {
|
|
207
|
+
await this.processLocalDirectory(sourceConfig, sourceLogger);
|
|
208
|
+
}
|
|
209
|
+
else if (sourceConfig.type === 'code') {
|
|
210
|
+
await this.processCodeSource(sourceConfig, sourceLogger);
|
|
211
|
+
}
|
|
212
|
+
else if (sourceConfig.type === 'zendesk') {
|
|
213
|
+
await this.processZendesk(sourceConfig, sourceLogger);
|
|
214
|
+
}
|
|
215
|
+
else if (sourceConfig.type === 's3') {
|
|
216
|
+
await this.processS3(sourceConfig, sourceLogger);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
ok = false;
|
|
220
|
+
errorMessage = `Unknown source type: ${sourceConfig.type}`;
|
|
221
|
+
sourceLogger.error(errorMessage);
|
|
222
|
+
}
|
|
202
223
|
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
ok = false;
|
|
226
|
+
errorMessage = error instanceof Error ? error.message : String(error);
|
|
227
|
+
sourceLogger.error(`Failed to process ${sourceConfig.type} source for ${sourceConfig.product_name}:`, error);
|
|
228
|
+
}
|
|
229
|
+
runStats.push({
|
|
230
|
+
product_name: sourceConfig.product_name,
|
|
231
|
+
type: sourceConfig.type,
|
|
232
|
+
version: sourceConfig.version,
|
|
233
|
+
duration_ms: Date.now() - startTime,
|
|
234
|
+
ok,
|
|
235
|
+
...(errorMessage && { error: errorMessage }),
|
|
236
|
+
counters: this.counters
|
|
237
|
+
});
|
|
203
238
|
}
|
|
204
239
|
// Close the Postgres markdown store connection pool
|
|
205
240
|
if (this.markdownStore) {
|
|
206
241
|
await this.markdownStore.close();
|
|
207
242
|
}
|
|
208
243
|
this.logger.section('PROCESSING COMPLETE');
|
|
244
|
+
this.logger.event('run-summary', { sources: runStats });
|
|
245
|
+
return runStats;
|
|
209
246
|
}
|
|
210
247
|
async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
|
|
211
248
|
const [owner, repoName] = repo.split('/');
|
|
@@ -357,11 +394,19 @@ class Doc2Vec {
|
|
|
357
394
|
// ones would otherwise linger — leaving stale state ("open") and missing
|
|
358
395
|
// the latest comments in search results. All chunks for an issue share
|
|
359
396
|
// its unique url, so delete-by-url reconciles them exactly.
|
|
397
|
+
let removedChunks = 0;
|
|
360
398
|
if (dbConnection.type === 'sqlite') {
|
|
361
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
399
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
362
400
|
}
|
|
363
401
|
else if (dbConnection.type === 'qdrant') {
|
|
364
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
402
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
403
|
+
}
|
|
404
|
+
if (removedChunks > 0) {
|
|
405
|
+
this.counters.items_updated++;
|
|
406
|
+
this.counters.chunks_deleted += removedChunks;
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
this.counters.items_new++;
|
|
365
410
|
}
|
|
366
411
|
// Process and store each chunk immediately
|
|
367
412
|
for (const chunk of chunks) {
|
|
@@ -377,6 +422,7 @@ class Doc2Vec {
|
|
|
377
422
|
const embeddings = await this.createEmbeddings([chunk.content]);
|
|
378
423
|
if (embeddings.length) {
|
|
379
424
|
database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
|
|
425
|
+
this.counters.chunks_added++;
|
|
380
426
|
logger.debug(`Stored chunk ${chunkId} in SQLite`);
|
|
381
427
|
}
|
|
382
428
|
else {
|
|
@@ -407,6 +453,7 @@ class Doc2Vec {
|
|
|
407
453
|
const embeddings = await this.createEmbeddings([chunk.content]);
|
|
408
454
|
if (embeddings.length) {
|
|
409
455
|
await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
|
|
456
|
+
this.counters.chunks_added++;
|
|
410
457
|
logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
|
|
411
458
|
}
|
|
412
459
|
else {
|
|
@@ -556,11 +603,16 @@ class Doc2Vec {
|
|
|
556
603
|
}
|
|
557
604
|
// Vector DB chunks
|
|
558
605
|
try {
|
|
606
|
+
let removedChunks = 0;
|
|
559
607
|
if (dbConnection.type === 'sqlite') {
|
|
560
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
608
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
561
609
|
}
|
|
562
610
|
else if (dbConnection.type === 'qdrant') {
|
|
563
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
611
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
612
|
+
}
|
|
613
|
+
if (removedChunks > 0) {
|
|
614
|
+
this.counters.items_deleted++;
|
|
615
|
+
this.counters.chunks_deleted += removedChunks;
|
|
564
616
|
}
|
|
565
617
|
}
|
|
566
618
|
catch (dbError) {
|
|
@@ -580,14 +632,17 @@ class Doc2Vec {
|
|
|
580
632
|
await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
|
|
581
633
|
logger.info('Full sync completed successfully — subsequent runs will use normal caching');
|
|
582
634
|
}
|
|
635
|
+
let removed = { items: 0, chunks: 0 };
|
|
583
636
|
if (dbConnection.type === 'sqlite') {
|
|
584
637
|
logger.info(`Running SQLite cleanup for ${urlPrefix}`);
|
|
585
|
-
database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
|
|
638
|
+
removed = database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
|
|
586
639
|
}
|
|
587
640
|
else if (dbConnection.type === 'qdrant') {
|
|
588
641
|
logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
|
|
589
|
-
await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
|
|
642
|
+
removed = await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
|
|
590
643
|
}
|
|
644
|
+
this.counters.items_deleted += removed.items;
|
|
645
|
+
this.counters.chunks_deleted += removed.chunks;
|
|
591
646
|
}
|
|
592
647
|
logger.info(`Finished processing website: ${config.url}`);
|
|
593
648
|
}
|
|
@@ -608,7 +663,11 @@ class Doc2Vec {
|
|
|
608
663
|
this.brokenLinksByWebsite[baseUrl] = unique;
|
|
609
664
|
}
|
|
610
665
|
writeBrokenLinksReport() {
|
|
611
|
-
|
|
666
|
+
// The default (next to the config file) breaks when the config lives on
|
|
667
|
+
// a read-only mount (e.g. a Kubernetes ConfigMap in controller mode) —
|
|
668
|
+
// DOC2VEC_REPORT_DIR points the report somewhere writable instead.
|
|
669
|
+
const reportDir = process.env.DOC2VEC_REPORT_DIR || this.configDir;
|
|
670
|
+
const reportPath = path.join(reportDir, '404.yaml');
|
|
612
671
|
const orderedEntries = Object.entries(this.brokenLinksByWebsite)
|
|
613
672
|
.sort(([a], [b]) => a.localeCompare(b));
|
|
614
673
|
const reportPayload = orderedEntries.map(([website, links]) => ({
|
|
@@ -671,14 +730,17 @@ class Doc2Vec {
|
|
|
671
730
|
}
|
|
672
731
|
}, logger);
|
|
673
732
|
logger.section('CLEANUP');
|
|
733
|
+
let removed = { items: 0, chunks: 0 };
|
|
674
734
|
if (dbConnection.type === 'sqlite') {
|
|
675
735
|
logger.info(`Running SQLite cleanup for local directory ${config.path}`);
|
|
676
|
-
database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
|
|
736
|
+
removed = database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
|
|
677
737
|
}
|
|
678
738
|
else if (dbConnection.type === 'qdrant') {
|
|
679
739
|
logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
|
|
680
|
-
await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
|
|
740
|
+
removed = await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
|
|
681
741
|
}
|
|
742
|
+
this.counters.items_deleted += removed.items;
|
|
743
|
+
this.counters.chunks_deleted += removed.chunks;
|
|
682
744
|
logger.info(`Finished processing local directory: ${config.path}`);
|
|
683
745
|
}
|
|
684
746
|
async processS3(config, parentLogger) {
|
|
@@ -859,11 +921,16 @@ class Doc2Vec {
|
|
|
859
921
|
else {
|
|
860
922
|
fileUrl = `s3://${config.bucket}/${deletedKey}`;
|
|
861
923
|
}
|
|
924
|
+
let removedChunks = 0;
|
|
862
925
|
if (dbConnection.type === 'sqlite') {
|
|
863
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, fileUrl, logger);
|
|
926
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, fileUrl, logger);
|
|
864
927
|
}
|
|
865
928
|
else if (dbConnection.type === 'qdrant') {
|
|
866
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, fileUrl, logger);
|
|
929
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, fileUrl, logger);
|
|
930
|
+
}
|
|
931
|
+
if (removedChunks > 0) {
|
|
932
|
+
this.counters.items_deleted++;
|
|
933
|
+
this.counters.chunks_deleted += removedChunks;
|
|
867
934
|
}
|
|
868
935
|
}
|
|
869
936
|
}
|
|
@@ -998,11 +1065,16 @@ class Doc2Vec {
|
|
|
998
1065
|
if (deleteUrls.length > 0) {
|
|
999
1066
|
logger.info(`Cleaning up ${deleteUrls.length} deleted/renamed files`);
|
|
1000
1067
|
for (const url of deleteUrls) {
|
|
1068
|
+
let removedChunks = 0;
|
|
1001
1069
|
if (dbConnection.type === 'sqlite') {
|
|
1002
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1070
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1003
1071
|
}
|
|
1004
1072
|
else if (dbConnection.type === 'qdrant') {
|
|
1005
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1073
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1074
|
+
}
|
|
1075
|
+
if (removedChunks > 0) {
|
|
1076
|
+
this.counters.items_deleted++;
|
|
1077
|
+
this.counters.chunks_deleted += removedChunks;
|
|
1006
1078
|
}
|
|
1007
1079
|
}
|
|
1008
1080
|
}
|
|
@@ -1016,11 +1088,16 @@ class Doc2Vec {
|
|
|
1016
1088
|
const deletedFiles = previousList.filter((filePath) => !trackedFiles?.has(filePath));
|
|
1017
1089
|
for (const deletedFile of deletedFiles) {
|
|
1018
1090
|
const url = this.buildCodeFileUrl(deletedFile, basePath, config, repoUrlPrefix);
|
|
1091
|
+
let removedChunks = 0;
|
|
1019
1092
|
if (dbConnection.type === 'sqlite') {
|
|
1020
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1093
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1021
1094
|
}
|
|
1022
1095
|
else if (dbConnection.type === 'qdrant') {
|
|
1023
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1096
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1097
|
+
}
|
|
1098
|
+
if (removedChunks > 0) {
|
|
1099
|
+
this.counters.items_deleted++;
|
|
1100
|
+
this.counters.chunks_deleted += removedChunks;
|
|
1024
1101
|
}
|
|
1025
1102
|
}
|
|
1026
1103
|
await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger, this.embeddingDimension);
|
|
@@ -1031,14 +1108,17 @@ class Doc2Vec {
|
|
|
1031
1108
|
}
|
|
1032
1109
|
}
|
|
1033
1110
|
else {
|
|
1111
|
+
let removed = { items: 0, chunks: 0 };
|
|
1034
1112
|
if (dbConnection.type === 'sqlite') {
|
|
1035
1113
|
logger.info(`Running SQLite cleanup for code source ${basePath}`);
|
|
1036
|
-
database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, cleanupPathConfig, logger);
|
|
1114
|
+
removed = database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, cleanupPathConfig, logger);
|
|
1037
1115
|
}
|
|
1038
1116
|
else if (dbConnection.type === 'qdrant') {
|
|
1039
1117
|
logger.info(`Running Qdrant cleanup for code source ${basePath} in collection ${dbConnection.collectionName}`);
|
|
1040
|
-
await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, cleanupPathConfig, logger);
|
|
1118
|
+
removed = await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, cleanupPathConfig, logger);
|
|
1041
1119
|
}
|
|
1120
|
+
this.counters.items_deleted += removed.items;
|
|
1121
|
+
this.counters.chunks_deleted += removed.chunks;
|
|
1042
1122
|
}
|
|
1043
1123
|
if (config.source === 'github' && basePath && repoBranch) {
|
|
1044
1124
|
const headSha = await this.getRepoHeadSha(basePath, logger);
|
|
@@ -1306,11 +1386,16 @@ class Doc2Vec {
|
|
|
1306
1386
|
// Deleted tickets — remove their chunks and stop
|
|
1307
1387
|
if (ticket.status === 'deleted') {
|
|
1308
1388
|
logger.info(`Ticket #${ticketId} was deleted in Zendesk — removing its chunks`);
|
|
1389
|
+
let removedChunks = 0;
|
|
1309
1390
|
if (dbConnection.type === 'sqlite') {
|
|
1310
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1391
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1311
1392
|
}
|
|
1312
1393
|
else {
|
|
1313
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1394
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1395
|
+
}
|
|
1396
|
+
if (removedChunks > 0) {
|
|
1397
|
+
this.counters.items_deleted++;
|
|
1398
|
+
this.counters.chunks_deleted += removedChunks;
|
|
1314
1399
|
}
|
|
1315
1400
|
return;
|
|
1316
1401
|
}
|
|
@@ -1572,6 +1657,7 @@ class Doc2Vec {
|
|
|
1572
1657
|
newHashesSorted.every((h, i) => h === existingHashesSorted[i]);
|
|
1573
1658
|
if (unchanged) {
|
|
1574
1659
|
logger.info(`Skipping unchanged URL (${chunks.length} chunks): ${url}`);
|
|
1660
|
+
this.counters.items_unchanged++;
|
|
1575
1661
|
return 0;
|
|
1576
1662
|
}
|
|
1577
1663
|
// 4. Content changed — delete all existing chunks for this URL
|
|
@@ -1583,6 +1669,11 @@ class Doc2Vec {
|
|
|
1583
1669
|
else {
|
|
1584
1670
|
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1585
1671
|
}
|
|
1672
|
+
this.counters.items_updated++;
|
|
1673
|
+
this.counters.chunks_deleted += existingHashesSorted.length;
|
|
1674
|
+
}
|
|
1675
|
+
else {
|
|
1676
|
+
this.counters.items_new++;
|
|
1586
1677
|
}
|
|
1587
1678
|
// 5. Embed and insert all new chunks
|
|
1588
1679
|
let embeddedCount = 0;
|
|
@@ -1610,6 +1701,7 @@ class Doc2Vec {
|
|
|
1610
1701
|
}
|
|
1611
1702
|
}
|
|
1612
1703
|
chunkProgress.complete();
|
|
1704
|
+
this.counters.chunks_added += embeddedCount;
|
|
1613
1705
|
return embeddedCount;
|
|
1614
1706
|
}
|
|
1615
1707
|
async createEmbeddings(texts) {
|
|
@@ -1651,14 +1743,64 @@ exports.Doc2Vec = Doc2Vec;
|
|
|
1651
1743
|
Doc2Vec.MAX_EMBEDDING_TOKENS = 8191;
|
|
1652
1744
|
Doc2Vec.CHARS_PER_TOKEN = 4;
|
|
1653
1745
|
Doc2Vec.MAX_EMBEDDING_CHARS = Doc2Vec.MAX_EMBEDDING_TOKENS * Doc2Vec.CHARS_PER_TOKEN;
|
|
1654
|
-
|
|
1655
|
-
const configPath = process.argv[2] || 'config.yaml';
|
|
1746
|
+
function runOneShot(configPath) {
|
|
1656
1747
|
if (!fs.existsSync(configPath)) {
|
|
1657
1748
|
console.error('Please provide a valid path to a YAML config file.');
|
|
1658
1749
|
process.exit(1);
|
|
1659
1750
|
}
|
|
1660
1751
|
const doc2Vec = new Doc2Vec(configPath);
|
|
1661
1752
|
doc2Vec.run()
|
|
1662
|
-
.then(() => process.exit(0))
|
|
1753
|
+
.then((stats) => process.exit(stats.some(s => !s.ok) ? 1 : 0))
|
|
1663
1754
|
.catch((err) => { console.error(err); process.exit(1); });
|
|
1664
1755
|
}
|
|
1756
|
+
if (require.main === module) {
|
|
1757
|
+
const { Command } = require('commander');
|
|
1758
|
+
const program = new Command();
|
|
1759
|
+
program
|
|
1760
|
+
.name('doc2vec')
|
|
1761
|
+
.description('Crawl documentation sources and store embeddings in vector databases')
|
|
1762
|
+
// Legacy invocation: `doc2vec [config.yaml]` runs a one-shot sync
|
|
1763
|
+
.argument('[config]', 'path to a YAML config file', 'config.yaml')
|
|
1764
|
+
.action((configPath) => runOneShot(configPath));
|
|
1765
|
+
program
|
|
1766
|
+
.command('run <config>')
|
|
1767
|
+
.description('Run a one-shot sync for a config file (what the controller spawns)')
|
|
1768
|
+
.action((configPath) => runOneShot(configPath));
|
|
1769
|
+
program
|
|
1770
|
+
.command('controller [configs...]')
|
|
1771
|
+
.description('Run as a long-lived controller: schedule sync jobs per config file, persist runs in Postgres, serve a web UI')
|
|
1772
|
+
.option('--database-url <url>', 'Postgres connection string (or DATABASE_URL env var)')
|
|
1773
|
+
.option('--port <port>', 'HTTP port for the API/UI (or PORT env var)', (v) => parseInt(v, 10))
|
|
1774
|
+
.option('--read-write', 'allow creating/editing configs from the UI (default: read-only)', false)
|
|
1775
|
+
.option('--config-dir <dir>', 'directory where UI-created configs are written (required with --read-write)')
|
|
1776
|
+
.option('--max-parallel <n>', 'maximum number of sync jobs running at once', (v) => parseInt(v, 10), 1)
|
|
1777
|
+
.option('--reload-interval <seconds>', 'how often to re-poll config files for changes', (v) => parseInt(v, 10), 30)
|
|
1778
|
+
.option('--log-retention-days <days>', 'delete run logs older than this many days', (v) => parseInt(v, 10), 14)
|
|
1779
|
+
.option('--slack-webhook-url <url>', 'Slack incoming webhook for run notifications (or SLACK_WEBHOOK_URL env var)')
|
|
1780
|
+
.option('--slack-notify <mode>', "which runs to notify about: 'all' or 'failures'", 'all')
|
|
1781
|
+
.option('--public-url <url>', 'externally reachable base URL, used for links in notifications (or PUBLIC_URL env var)')
|
|
1782
|
+
.action(async (configs, options) => {
|
|
1783
|
+
// Lazy-require so the one-shot path doesn't load express/pg
|
|
1784
|
+
const { startController } = require('./controller');
|
|
1785
|
+
try {
|
|
1786
|
+
await startController({
|
|
1787
|
+
configArgs: configs,
|
|
1788
|
+
databaseUrl: options.databaseUrl || process.env.DATABASE_URL,
|
|
1789
|
+
port: options.port || (process.env.PORT ? parseInt(process.env.PORT, 10) : 8080),
|
|
1790
|
+
readWrite: options.readWrite,
|
|
1791
|
+
configDir: options.configDir,
|
|
1792
|
+
maxParallel: options.maxParallel,
|
|
1793
|
+
reloadIntervalSec: options.reloadInterval,
|
|
1794
|
+
logRetentionDays: options.logRetentionDays,
|
|
1795
|
+
slackWebhookUrl: options.slackWebhookUrl || process.env.SLACK_WEBHOOK_URL,
|
|
1796
|
+
slackNotify: options.slackNotify === 'failures' ? 'failures' : 'all',
|
|
1797
|
+
publicUrl: options.publicUrl || process.env.PUBLIC_URL,
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
catch (err) {
|
|
1801
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1802
|
+
process.exit(1);
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
program.parse();
|
|
1806
|
+
}
|