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/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
- await client.delete(collectionName, {
556
- filter: {
557
- must: [
558
- {
559
- key: 'url',
560
- match: {
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
- must_not: [
566
- {
567
- key: 'is_metadata',
568
- match: {
569
- value: true
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
- if (sourceConfig.type === 'github') {
183
- await this.processGithubRepo(sourceConfig, sourceLogger);
184
- }
185
- else if (sourceConfig.type === 'website') {
186
- await this.processWebsite(sourceConfig, sourceLogger);
187
- }
188
- else if (sourceConfig.type === 'local_directory') {
189
- await this.processLocalDirectory(sourceConfig, sourceLogger);
190
- }
191
- else if (sourceConfig.type === 'code') {
192
- await this.processCodeSource(sourceConfig, sourceLogger);
193
- }
194
- else if (sourceConfig.type === 'zendesk') {
195
- await this.processZendesk(sourceConfig, sourceLogger);
196
- }
197
- else if (sourceConfig.type === 's3') {
198
- await this.processS3(sourceConfig, sourceLogger);
199
- }
200
- else {
201
- sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
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
- const reportPath = path.join(this.configDir, '404.yaml');
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
- if (require.main === module) {
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
+ }