doc2vec 2.11.0 → 2.13.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 +57 -4
- package/dist/content-processor.js +12 -3
- package/dist/controller/chunk-inspector.js +209 -0
- package/dist/controller/migrations.js +5 -0
- package/dist/controller/server.js +79 -3
- package/dist/controller/store.js +39 -2
- package/dist/database.js +64 -19
- package/dist/doc2vec.js +180 -38
- package/dist/types.js +12 -0
- package/dist/ui/assets/index-C-uNubcD.css +1 -0
- package/dist/ui/assets/index-zutKK2b4.js +126 -0
- package/dist/ui/index.html +2 -2
- package/dist/utils.js +58 -0
- package/package.json +1 -1
- package/dist/ui/assets/index-BVg6n7Si.css +0 -1
- package/dist/ui/assets/index-D-QOw3rk.js +0 -126
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,
|
|
@@ -183,6 +187,15 @@ class Doc2Vec {
|
|
|
183
187
|
const startTime = Date.now();
|
|
184
188
|
let ok = true;
|
|
185
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');
|
|
186
199
|
try {
|
|
187
200
|
if (sourceConfig.type === 'github') {
|
|
188
201
|
await this.processGithubRepo(sourceConfig, sourceLogger);
|
|
@@ -219,7 +232,8 @@ class Doc2Vec {
|
|
|
219
232
|
version: sourceConfig.version,
|
|
220
233
|
duration_ms: Date.now() - startTime,
|
|
221
234
|
ok,
|
|
222
|
-
...(errorMessage && { error: errorMessage })
|
|
235
|
+
...(errorMessage && { error: errorMessage }),
|
|
236
|
+
counters: this.counters
|
|
223
237
|
});
|
|
224
238
|
}
|
|
225
239
|
// Close the Postgres markdown store connection pool
|
|
@@ -240,22 +254,31 @@ class Doc2Vec {
|
|
|
240
254
|
// Get the last run date from the database
|
|
241
255
|
const startDate = sourceConfig.start_date || '2025-01-01';
|
|
242
256
|
const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
|
|
243
|
-
|
|
257
|
+
// Retryable: 5xx, 429, rate-limited 403s, and network/timeout errors.
|
|
258
|
+
// Everything else is deterministic — the same request fails identically,
|
|
259
|
+
// so retrying only burns wall-clock. In particular a 422 from deep
|
|
260
|
+
// page-number pagination used to cost ~75s of backoff before failing.
|
|
261
|
+
const isRetryableStatus = (status) => status === undefined || status >= 500 || status === 429;
|
|
262
|
+
const requestWithRetry = async (url, params, retries = 5, delay = 5000) => {
|
|
244
263
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
245
264
|
try {
|
|
246
265
|
// Only log on retries to reduce noise during pagination
|
|
247
266
|
if (attempt > 0) {
|
|
248
267
|
logger.debug(`GitHub API retry: ${url} (attempt ${attempt + 1}/${retries})`);
|
|
249
268
|
}
|
|
269
|
+
// Callers need the response headers (Link) for cursor
|
|
270
|
+
// pagination, so the whole response is returned.
|
|
250
271
|
const response = await axios_1.default.get(url, {
|
|
251
272
|
headers: {
|
|
252
273
|
Authorization: `token ${GITHUB_TOKEN}`,
|
|
253
274
|
Accept: 'application/vnd.github.v3+json',
|
|
254
275
|
},
|
|
255
|
-
|
|
276
|
+
// Subsequent pages come from a Link URL that already
|
|
277
|
+
// carries its query string — adding params would duplicate it
|
|
278
|
+
...(params && { params }),
|
|
256
279
|
timeout: 30000, // 30 second timeout
|
|
257
280
|
});
|
|
258
|
-
return response
|
|
281
|
+
return response;
|
|
259
282
|
}
|
|
260
283
|
catch (error) {
|
|
261
284
|
// Enhanced error logging for debugging
|
|
@@ -290,6 +313,13 @@ class Doc2Vec {
|
|
|
290
313
|
throw error;
|
|
291
314
|
}
|
|
292
315
|
}
|
|
316
|
+
else if (!isRetryableStatus(error.response?.status)) {
|
|
317
|
+
// Deterministic failure (422, 404, 401, ...) — surface the
|
|
318
|
+
// API's own message, which explains what to do instead.
|
|
319
|
+
const apiMessage = error.response?.data?.message;
|
|
320
|
+
logger.error(`GitHub API returned ${error.response.status} (not retryable): ${apiMessage || error.message}`);
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
293
323
|
else {
|
|
294
324
|
// For non-403 errors, wait before retrying (exponential backoff)
|
|
295
325
|
if (attempt < retries - 1) {
|
|
@@ -306,23 +336,45 @@ class Doc2Vec {
|
|
|
306
336
|
}
|
|
307
337
|
throw new Error('Max retries reached');
|
|
308
338
|
};
|
|
339
|
+
/**
|
|
340
|
+
* Walk every issue updated since `sinceDate` by following GitHub's
|
|
341
|
+
* `Link: rel="next"` cursor URLs.
|
|
342
|
+
*
|
|
343
|
+
* Page-number pagination (`?page=N`) cannot be used here: GitHub caps it
|
|
344
|
+
* on this endpoint and answers HTTP 422 ("Pagination with the page
|
|
345
|
+
* parameter is not supported for large datasets, please use cursor based
|
|
346
|
+
* pagination") once the offset grows past its limit. That limit is well
|
|
347
|
+
* below the size of a full-window rebuild on a busy repo, so page-based
|
|
348
|
+
* paging aborted the whole source and left the collection empty. The
|
|
349
|
+
* Link URL already contains the opaque `after=` cursor and is passed
|
|
350
|
+
* through verbatim.
|
|
351
|
+
*/
|
|
309
352
|
const fetchAllIssues = async (sinceDate) => {
|
|
310
353
|
let issues = [];
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
354
|
+
// sort=updated&direction=asc makes the walk stable: results are
|
|
355
|
+
// ordered by the same field `since` filters on, so a page boundary
|
|
356
|
+
// can't shuffle items in or out mid-walk.
|
|
357
|
+
let url = GITHUB_API_URL;
|
|
358
|
+
let params = {
|
|
359
|
+
per_page: 100,
|
|
360
|
+
state: 'all',
|
|
361
|
+
since: sinceDate,
|
|
362
|
+
sort: 'updated',
|
|
363
|
+
direction: 'asc',
|
|
364
|
+
};
|
|
365
|
+
let page = 0;
|
|
366
|
+
while (url) {
|
|
367
|
+
page++;
|
|
314
368
|
// Log progress every 10 pages to reduce noise
|
|
315
369
|
if (page === 1 || page % 10 === 0) {
|
|
316
370
|
logger.debug(`Fetching issues page ${page}... (${issues.length} issues so far)`);
|
|
317
371
|
}
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
since: sinceDate,
|
|
323
|
-
});
|
|
324
|
-
if (data.length === 0)
|
|
372
|
+
const response = await requestWithRetry(url, params);
|
|
373
|
+
const data = response.data;
|
|
374
|
+
if (!Array.isArray(data)) {
|
|
375
|
+
logger.warn(`Unexpected non-array response while paginating issues for ${repo}; stopping the walk`);
|
|
325
376
|
break;
|
|
377
|
+
}
|
|
326
378
|
// The `since` param already scopes results to issues whose
|
|
327
379
|
// updated_at is after sinceDate — i.e. anything created, closed,
|
|
328
380
|
// reopened, edited, or newly commented since the last run. Do NOT
|
|
@@ -330,19 +382,45 @@ class Doc2Vec {
|
|
|
330
382
|
// received a new comment has an old created_at and would be
|
|
331
383
|
// dropped, leaving its status and comments permanently stale.
|
|
332
384
|
issues = issues.concat(data);
|
|
333
|
-
|
|
385
|
+
const nextUrl = utils_1.Utils.parseNextLink(response.headers?.link ?? response.headers?.Link);
|
|
386
|
+
// Guard against a server echoing the same cursor forever
|
|
387
|
+
if (!nextUrl || nextUrl === url)
|
|
334
388
|
break;
|
|
335
|
-
|
|
389
|
+
url = nextUrl;
|
|
390
|
+
params = undefined; // the cursor URL is already fully-formed
|
|
336
391
|
}
|
|
392
|
+
logger.info(`Fetched ${issues.length} issues/PRs for ${repo} across ${page} page(s)`);
|
|
337
393
|
return issues;
|
|
338
394
|
};
|
|
339
395
|
const fetchIssueComments = async (issueNumber) => {
|
|
340
|
-
|
|
341
|
-
|
|
396
|
+
// GitHub returns 30 comments per page by default and paginates the
|
|
397
|
+
// rest behind Link headers; requesting the first page only silently
|
|
398
|
+
// truncated long discussions, so the stored chunks were missing the
|
|
399
|
+
// newest comments.
|
|
400
|
+
const comments = [];
|
|
401
|
+
let url = `${GITHUB_API_URL}/${issueNumber}/comments`;
|
|
402
|
+
let params = { per_page: 100 };
|
|
403
|
+
while (url) {
|
|
404
|
+
const response = await requestWithRetry(url, params);
|
|
405
|
+
if (!Array.isArray(response.data))
|
|
406
|
+
break;
|
|
407
|
+
comments.push(...response.data);
|
|
408
|
+
const nextUrl = utils_1.Utils.parseNextLink(response.headers?.link ?? response.headers?.Link);
|
|
409
|
+
if (!nextUrl || nextUrl === url)
|
|
410
|
+
break;
|
|
411
|
+
url = nextUrl;
|
|
412
|
+
params = undefined;
|
|
413
|
+
}
|
|
414
|
+
return comments;
|
|
342
415
|
};
|
|
343
416
|
const generateMarkdownForIssue = async (issue) => {
|
|
344
417
|
const comments = await fetchIssueComments(issue.number);
|
|
345
|
-
|
|
418
|
+
// This endpoint returns both issues and pull requests; only PRs carry
|
|
419
|
+
// a `pull_request` object. Labelling every item "Issue #N" made
|
|
420
|
+
// retrieved PR chunks read as issues, so use the real kind.
|
|
421
|
+
const itemLabel = issue.pull_request ? 'PR' : 'Issue';
|
|
422
|
+
let md = `# ${itemLabel} #${issue.number}: ${issue.title}\n\n`;
|
|
423
|
+
md += `- **Type:** ${issue.pull_request ? 'Pull request' : 'Issue'}\n`;
|
|
346
424
|
md += `- **Author:** ${issue.user.login}\n`;
|
|
347
425
|
md += `- **State:** ${issue.state}\n`;
|
|
348
426
|
md += `- **Created on:** ${new Date(issue.created_at).toDateString()}\n`;
|
|
@@ -359,6 +437,9 @@ class Doc2Vec {
|
|
|
359
437
|
}
|
|
360
438
|
return md;
|
|
361
439
|
};
|
|
440
|
+
// Chunks that could not be stored (e.g. a rejected Qdrant upsert).
|
|
441
|
+
// Tallied across all issues and escalated once the walk completes.
|
|
442
|
+
let failedChunks = 0;
|
|
362
443
|
// Process a single issue and store its chunks
|
|
363
444
|
const processIssue = async (issue) => {
|
|
364
445
|
const issueNumber = issue.number;
|
|
@@ -380,11 +461,19 @@ class Doc2Vec {
|
|
|
380
461
|
// ones would otherwise linger — leaving stale state ("open") and missing
|
|
381
462
|
// the latest comments in search results. All chunks for an issue share
|
|
382
463
|
// its unique url, so delete-by-url reconciles them exactly.
|
|
464
|
+
let removedChunks = 0;
|
|
383
465
|
if (dbConnection.type === 'sqlite') {
|
|
384
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
466
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
385
467
|
}
|
|
386
468
|
else if (dbConnection.type === 'qdrant') {
|
|
387
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
469
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
470
|
+
}
|
|
471
|
+
if (removedChunks > 0) {
|
|
472
|
+
this.counters.items_updated++;
|
|
473
|
+
this.counters.chunks_deleted += removedChunks;
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
this.counters.items_new++;
|
|
388
477
|
}
|
|
389
478
|
// Process and store each chunk immediately
|
|
390
479
|
for (const chunk of chunks) {
|
|
@@ -400,6 +489,7 @@ class Doc2Vec {
|
|
|
400
489
|
const embeddings = await this.createEmbeddings([chunk.content]);
|
|
401
490
|
if (embeddings.length) {
|
|
402
491
|
database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
|
|
492
|
+
this.counters.chunks_added++;
|
|
403
493
|
logger.debug(`Stored chunk ${chunkId} in SQLite`);
|
|
404
494
|
}
|
|
405
495
|
else {
|
|
@@ -430,6 +520,7 @@ class Doc2Vec {
|
|
|
430
520
|
const embeddings = await this.createEmbeddings([chunk.content]);
|
|
431
521
|
if (embeddings.length) {
|
|
432
522
|
await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
|
|
523
|
+
this.counters.chunks_added++;
|
|
433
524
|
logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
|
|
434
525
|
}
|
|
435
526
|
else {
|
|
@@ -437,7 +528,11 @@ class Doc2Vec {
|
|
|
437
528
|
}
|
|
438
529
|
}
|
|
439
530
|
catch (error) {
|
|
440
|
-
|
|
531
|
+
// Keep going so one bad chunk can't cost the whole repo,
|
|
532
|
+
// but remember it: the count is escalated below so the run
|
|
533
|
+
// fails and the last-run date is not advanced.
|
|
534
|
+
failedChunks++;
|
|
535
|
+
logger.error(`Error processing chunk ${chunkId} for ${url} in Qdrant:`, error);
|
|
441
536
|
}
|
|
442
537
|
}
|
|
443
538
|
}
|
|
@@ -450,6 +545,12 @@ class Doc2Vec {
|
|
|
450
545
|
logger.info(`Processing issue ${i + 1}/${issues.length}`);
|
|
451
546
|
await processIssue(issues[i]);
|
|
452
547
|
}
|
|
548
|
+
// A chunk that failed to store is silent data loss, so refuse to advance
|
|
549
|
+
// the last-run date: the next run reprocesses this window instead of
|
|
550
|
+
// treating the gap as synced. Everything that did store is kept.
|
|
551
|
+
if (failedChunks > 0) {
|
|
552
|
+
throw new Error(`${failedChunks} chunk(s) failed to store for ${repo}; not advancing the last-run date so the next run retries them`);
|
|
553
|
+
}
|
|
453
554
|
// Update the last run date in the database after processing all issues
|
|
454
555
|
await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension, syncStartDate);
|
|
455
556
|
logger.info(`Successfully processed ${issues.length} issues`);
|
|
@@ -579,11 +680,16 @@ class Doc2Vec {
|
|
|
579
680
|
}
|
|
580
681
|
// Vector DB chunks
|
|
581
682
|
try {
|
|
683
|
+
let removedChunks = 0;
|
|
582
684
|
if (dbConnection.type === 'sqlite') {
|
|
583
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
685
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
584
686
|
}
|
|
585
687
|
else if (dbConnection.type === 'qdrant') {
|
|
586
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
688
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
689
|
+
}
|
|
690
|
+
if (removedChunks > 0) {
|
|
691
|
+
this.counters.items_deleted++;
|
|
692
|
+
this.counters.chunks_deleted += removedChunks;
|
|
587
693
|
}
|
|
588
694
|
}
|
|
589
695
|
catch (dbError) {
|
|
@@ -603,14 +709,17 @@ class Doc2Vec {
|
|
|
603
709
|
await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
|
|
604
710
|
logger.info('Full sync completed successfully — subsequent runs will use normal caching');
|
|
605
711
|
}
|
|
712
|
+
let removed = { items: 0, chunks: 0 };
|
|
606
713
|
if (dbConnection.type === 'sqlite') {
|
|
607
714
|
logger.info(`Running SQLite cleanup for ${urlPrefix}`);
|
|
608
|
-
database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
|
|
715
|
+
removed = database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
|
|
609
716
|
}
|
|
610
717
|
else if (dbConnection.type === 'qdrant') {
|
|
611
718
|
logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
|
|
612
|
-
await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
|
|
719
|
+
removed = await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
|
|
613
720
|
}
|
|
721
|
+
this.counters.items_deleted += removed.items;
|
|
722
|
+
this.counters.chunks_deleted += removed.chunks;
|
|
614
723
|
}
|
|
615
724
|
logger.info(`Finished processing website: ${config.url}`);
|
|
616
725
|
}
|
|
@@ -698,14 +807,17 @@ class Doc2Vec {
|
|
|
698
807
|
}
|
|
699
808
|
}, logger);
|
|
700
809
|
logger.section('CLEANUP');
|
|
810
|
+
let removed = { items: 0, chunks: 0 };
|
|
701
811
|
if (dbConnection.type === 'sqlite') {
|
|
702
812
|
logger.info(`Running SQLite cleanup for local directory ${config.path}`);
|
|
703
|
-
database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
|
|
813
|
+
removed = database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
|
|
704
814
|
}
|
|
705
815
|
else if (dbConnection.type === 'qdrant') {
|
|
706
816
|
logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
|
|
707
|
-
await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
|
|
817
|
+
removed = await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
|
|
708
818
|
}
|
|
819
|
+
this.counters.items_deleted += removed.items;
|
|
820
|
+
this.counters.chunks_deleted += removed.chunks;
|
|
709
821
|
logger.info(`Finished processing local directory: ${config.path}`);
|
|
710
822
|
}
|
|
711
823
|
async processS3(config, parentLogger) {
|
|
@@ -886,11 +998,16 @@ class Doc2Vec {
|
|
|
886
998
|
else {
|
|
887
999
|
fileUrl = `s3://${config.bucket}/${deletedKey}`;
|
|
888
1000
|
}
|
|
1001
|
+
let removedChunks = 0;
|
|
889
1002
|
if (dbConnection.type === 'sqlite') {
|
|
890
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, fileUrl, logger);
|
|
1003
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, fileUrl, logger);
|
|
891
1004
|
}
|
|
892
1005
|
else if (dbConnection.type === 'qdrant') {
|
|
893
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, fileUrl, logger);
|
|
1006
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, fileUrl, logger);
|
|
1007
|
+
}
|
|
1008
|
+
if (removedChunks > 0) {
|
|
1009
|
+
this.counters.items_deleted++;
|
|
1010
|
+
this.counters.chunks_deleted += removedChunks;
|
|
894
1011
|
}
|
|
895
1012
|
}
|
|
896
1013
|
}
|
|
@@ -1025,11 +1142,16 @@ class Doc2Vec {
|
|
|
1025
1142
|
if (deleteUrls.length > 0) {
|
|
1026
1143
|
logger.info(`Cleaning up ${deleteUrls.length} deleted/renamed files`);
|
|
1027
1144
|
for (const url of deleteUrls) {
|
|
1145
|
+
let removedChunks = 0;
|
|
1028
1146
|
if (dbConnection.type === 'sqlite') {
|
|
1029
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1147
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1030
1148
|
}
|
|
1031
1149
|
else if (dbConnection.type === 'qdrant') {
|
|
1032
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1150
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1151
|
+
}
|
|
1152
|
+
if (removedChunks > 0) {
|
|
1153
|
+
this.counters.items_deleted++;
|
|
1154
|
+
this.counters.chunks_deleted += removedChunks;
|
|
1033
1155
|
}
|
|
1034
1156
|
}
|
|
1035
1157
|
}
|
|
@@ -1043,11 +1165,16 @@ class Doc2Vec {
|
|
|
1043
1165
|
const deletedFiles = previousList.filter((filePath) => !trackedFiles?.has(filePath));
|
|
1044
1166
|
for (const deletedFile of deletedFiles) {
|
|
1045
1167
|
const url = this.buildCodeFileUrl(deletedFile, basePath, config, repoUrlPrefix);
|
|
1168
|
+
let removedChunks = 0;
|
|
1046
1169
|
if (dbConnection.type === 'sqlite') {
|
|
1047
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1170
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1048
1171
|
}
|
|
1049
1172
|
else if (dbConnection.type === 'qdrant') {
|
|
1050
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1173
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1174
|
+
}
|
|
1175
|
+
if (removedChunks > 0) {
|
|
1176
|
+
this.counters.items_deleted++;
|
|
1177
|
+
this.counters.chunks_deleted += removedChunks;
|
|
1051
1178
|
}
|
|
1052
1179
|
}
|
|
1053
1180
|
await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger, this.embeddingDimension);
|
|
@@ -1058,14 +1185,17 @@ class Doc2Vec {
|
|
|
1058
1185
|
}
|
|
1059
1186
|
}
|
|
1060
1187
|
else {
|
|
1188
|
+
let removed = { items: 0, chunks: 0 };
|
|
1061
1189
|
if (dbConnection.type === 'sqlite') {
|
|
1062
1190
|
logger.info(`Running SQLite cleanup for code source ${basePath}`);
|
|
1063
|
-
database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, cleanupPathConfig, logger);
|
|
1191
|
+
removed = database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, cleanupPathConfig, logger);
|
|
1064
1192
|
}
|
|
1065
1193
|
else if (dbConnection.type === 'qdrant') {
|
|
1066
1194
|
logger.info(`Running Qdrant cleanup for code source ${basePath} in collection ${dbConnection.collectionName}`);
|
|
1067
|
-
await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, cleanupPathConfig, logger);
|
|
1195
|
+
removed = await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, cleanupPathConfig, logger);
|
|
1068
1196
|
}
|
|
1197
|
+
this.counters.items_deleted += removed.items;
|
|
1198
|
+
this.counters.chunks_deleted += removed.chunks;
|
|
1069
1199
|
}
|
|
1070
1200
|
if (config.source === 'github' && basePath && repoBranch) {
|
|
1071
1201
|
const headSha = await this.getRepoHeadSha(basePath, logger);
|
|
@@ -1333,11 +1463,16 @@ class Doc2Vec {
|
|
|
1333
1463
|
// Deleted tickets — remove their chunks and stop
|
|
1334
1464
|
if (ticket.status === 'deleted') {
|
|
1335
1465
|
logger.info(`Ticket #${ticketId} was deleted in Zendesk — removing its chunks`);
|
|
1466
|
+
let removedChunks = 0;
|
|
1336
1467
|
if (dbConnection.type === 'sqlite') {
|
|
1337
|
-
database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1468
|
+
removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
|
|
1338
1469
|
}
|
|
1339
1470
|
else {
|
|
1340
|
-
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1471
|
+
removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1472
|
+
}
|
|
1473
|
+
if (removedChunks > 0) {
|
|
1474
|
+
this.counters.items_deleted++;
|
|
1475
|
+
this.counters.chunks_deleted += removedChunks;
|
|
1341
1476
|
}
|
|
1342
1477
|
return;
|
|
1343
1478
|
}
|
|
@@ -1599,6 +1734,7 @@ class Doc2Vec {
|
|
|
1599
1734
|
newHashesSorted.every((h, i) => h === existingHashesSorted[i]);
|
|
1600
1735
|
if (unchanged) {
|
|
1601
1736
|
logger.info(`Skipping unchanged URL (${chunks.length} chunks): ${url}`);
|
|
1737
|
+
this.counters.items_unchanged++;
|
|
1602
1738
|
return 0;
|
|
1603
1739
|
}
|
|
1604
1740
|
// 4. Content changed — delete all existing chunks for this URL
|
|
@@ -1610,6 +1746,11 @@ class Doc2Vec {
|
|
|
1610
1746
|
else {
|
|
1611
1747
|
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
1612
1748
|
}
|
|
1749
|
+
this.counters.items_updated++;
|
|
1750
|
+
this.counters.chunks_deleted += existingHashesSorted.length;
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
this.counters.items_new++;
|
|
1613
1754
|
}
|
|
1614
1755
|
// 5. Embed and insert all new chunks
|
|
1615
1756
|
let embeddedCount = 0;
|
|
@@ -1637,6 +1778,7 @@ class Doc2Vec {
|
|
|
1637
1778
|
}
|
|
1638
1779
|
}
|
|
1639
1780
|
chunkProgress.complete();
|
|
1781
|
+
this.counters.chunks_added += embeddedCount;
|
|
1640
1782
|
return embeddedCount;
|
|
1641
1783
|
}
|
|
1642
1784
|
async createEmbeddings(texts) {
|