doc2vec 1.3.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doc2vec.js CHANGED
@@ -37,11 +37,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.Doc2Vec = void 0;
40
41
  const axios_1 = __importDefault(require("axios"));
41
42
  const crypto_1 = __importDefault(require("crypto"));
42
43
  const yaml = __importStar(require("js-yaml"));
43
44
  const fs = __importStar(require("fs"));
44
45
  const path = __importStar(require("path"));
46
+ const os = __importStar(require("os"));
47
+ const child_process_1 = require("child_process");
48
+ const util_1 = require("util");
45
49
  const buffer_1 = require("buffer");
46
50
  const openai_1 = require("openai");
47
51
  const dotenv = __importStar(require("dotenv"));
@@ -50,9 +54,11 @@ const utils_1 = require("./utils");
50
54
  const database_1 = require("./database");
51
55
  const content_processor_1 = require("./content-processor");
52
56
  const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
57
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
53
58
  dotenv.config();
54
59
  class Doc2Vec {
55
60
  constructor(configPath) {
61
+ this.brokenLinksByWebsite = {};
56
62
  this.logger = new logger_1.Logger('Doc2Vec', {
57
63
  level: logger_1.LogLevel.DEBUG,
58
64
  useTimestamp: true,
@@ -61,7 +67,40 @@ class Doc2Vec {
61
67
  });
62
68
  this.logger.info('Initializing Doc2Vec');
63
69
  this.config = this.loadConfig(configPath);
64
- this.openai = new openai_1.OpenAI({ apiKey: process.env.OPENAI_API_KEY });
70
+ this.configDir = path.dirname(path.resolve(configPath));
71
+ // Initialize OpenAI or Azure OpenAI based on configuration
72
+ // Check environment variable if not specified in config
73
+ const embeddingProvider = this.config.embedding?.provider || process.env.EMBEDDING_PROVIDER || 'openai';
74
+ const embeddingConfig = this.config.embedding || { provider: embeddingProvider };
75
+ if (embeddingProvider === 'azure') {
76
+ const azureApiKey = embeddingConfig.azure?.api_key || process.env.AZURE_OPENAI_KEY;
77
+ const azureEndpoint = embeddingConfig.azure?.endpoint || process.env.AZURE_OPENAI_ENDPOINT;
78
+ const azureDeploymentName = embeddingConfig.azure?.deployment_name || process.env.AZURE_OPENAI_DEPLOYMENT_NAME || 'text-embedding-3-large';
79
+ const azureApiVersion = embeddingConfig.azure?.api_version || process.env.AZURE_OPENAI_API_VERSION || '2024-10-21';
80
+ if (!azureApiKey || !azureEndpoint) {
81
+ this.logger.error('Azure OpenAI requires api_key and endpoint to be configured');
82
+ process.exit(1);
83
+ }
84
+ this.openai = new openai_1.AzureOpenAI({
85
+ apiKey: azureApiKey,
86
+ endpoint: azureEndpoint,
87
+ deployment: azureDeploymentName,
88
+ apiVersion: azureApiVersion,
89
+ });
90
+ this.embeddingModel = azureDeploymentName;
91
+ this.logger.info(`Using Azure OpenAI with deployment: ${azureDeploymentName}`);
92
+ }
93
+ else {
94
+ const openaiApiKey = embeddingConfig.openai?.api_key || process.env.OPENAI_API_KEY;
95
+ const openaiModel = embeddingConfig.openai?.model || process.env.OPENAI_MODEL || 'text-embedding-3-large';
96
+ if (!openaiApiKey) {
97
+ this.logger.error('OpenAI requires api_key to be configured');
98
+ process.exit(1);
99
+ }
100
+ this.openai = new openai_1.OpenAI({ apiKey: openaiApiKey });
101
+ this.embeddingModel = openaiModel;
102
+ this.logger.info(`Using OpenAI with model: ${openaiModel}`);
103
+ }
65
104
  this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
66
105
  }
67
106
  loadConfig(configPath) {
@@ -81,6 +120,22 @@ class Doc2Vec {
81
120
  });
82
121
  let config = yaml.load(configFile);
83
122
  const typedConfig = config;
123
+ for (const source of typedConfig.sources) {
124
+ if (source.type === 'code') {
125
+ if (!source.version || String(source.version).trim().length === 0) {
126
+ if (source.branch && String(source.branch).trim().length > 0) {
127
+ source.version = source.branch;
128
+ }
129
+ else {
130
+ source.version = 'local';
131
+ }
132
+ }
133
+ }
134
+ else if (!source.version || String(source.version).trim().length === 0) {
135
+ logger.error(`Missing required version for ${source.type} source: ${source.product_name}`);
136
+ process.exit(1);
137
+ }
138
+ }
84
139
  logger.info(`Configuration loaded successfully, found ${typedConfig.sources.length} sources`);
85
140
  return typedConfig;
86
141
  }
@@ -103,6 +158,9 @@ class Doc2Vec {
103
158
  else if (sourceConfig.type === 'local_directory') {
104
159
  await this.processLocalDirectory(sourceConfig, sourceLogger);
105
160
  }
161
+ else if (sourceConfig.type === 'code') {
162
+ await this.processCodeSource(sourceConfig, sourceLogger);
163
+ }
106
164
  else if (sourceConfig.type === 'zendesk') {
107
165
  await this.processZendesk(sourceConfig, sourceLogger);
108
166
  }
@@ -123,26 +181,64 @@ class Doc2Vec {
123
181
  const fetchWithRetry = async (url, params = {}, retries = 5, delay = 5000) => {
124
182
  for (let attempt = 0; attempt < retries; attempt++) {
125
183
  try {
184
+ // Only log on retries to reduce noise during pagination
185
+ if (attempt > 0) {
186
+ logger.debug(`GitHub API retry: ${url} (attempt ${attempt + 1}/${retries})`);
187
+ }
126
188
  const response = await axios_1.default.get(url, {
127
189
  headers: {
128
190
  Authorization: `token ${GITHUB_TOKEN}`,
129
191
  Accept: 'application/vnd.github.v3+json',
130
192
  },
131
193
  params,
194
+ timeout: 30000, // 30 second timeout
132
195
  });
133
196
  return response.data;
134
197
  }
135
198
  catch (error) {
199
+ // Enhanced error logging for debugging
200
+ const errorDetails = {
201
+ code: error.code,
202
+ message: error.message,
203
+ status: error.response?.status,
204
+ isTimeout: error.code === 'ECONNABORTED' || error.message?.includes('timeout'),
205
+ isNetworkError: !error.response && error.code,
206
+ };
207
+ logger.debug(`GitHub API error details: ${JSON.stringify(errorDetails)}`);
136
208
  if (error.response && error.response.status === 403) {
209
+ // Check if this is actually a rate limit error
210
+ const rateLimitRemaining = error.response.headers['x-ratelimit-remaining'];
137
211
  const resetTime = error.response.headers['x-ratelimit-reset'];
138
- const currentTime = Math.floor(Date.now() / 1000);
139
- const waitTime = resetTime ? (resetTime - currentTime) * 1000 : delay * 2;
140
- logger.warn(`GitHub rate limit exceeded. Waiting ${waitTime / 1000}s`);
141
- await new Promise(res => setTimeout(res, waitTime));
212
+ if (rateLimitRemaining === '0' && resetTime) {
213
+ const currentTime = Math.floor(Date.now() / 1000);
214
+ const resetTimestamp = parseInt(resetTime, 10);
215
+ let waitTime = (resetTimestamp - currentTime) * 1000;
216
+ // Ensure waitTime is at least 1 second (in case resetTime is in the past)
217
+ if (waitTime < 1000) {
218
+ waitTime = 1000;
219
+ }
220
+ logger.warn(`GitHub rate limit exceeded. Waiting ${Math.ceil(waitTime / 1000)}s (attempt ${attempt + 1}/${retries})`);
221
+ await new Promise(res => setTimeout(res, waitTime));
222
+ // Retry the request after waiting
223
+ continue;
224
+ }
225
+ else {
226
+ // Other 403 errors (e.g., forbidden access)
227
+ logger.error(`GitHub API returned 403 (not rate limit): ${error.message}`);
228
+ throw error;
229
+ }
142
230
  }
143
231
  else {
144
- logger.error(`GitHub fetch failed: ${error.message}`);
145
- throw error;
232
+ // For non-403 errors, wait before retrying (exponential backoff)
233
+ if (attempt < retries - 1) {
234
+ const backoffDelay = delay * Math.pow(2, attempt);
235
+ logger.warn(`GitHub fetch failed (attempt ${attempt + 1}/${retries}): ${error.message}. Retrying in ${backoffDelay}ms`);
236
+ await new Promise(res => setTimeout(res, backoffDelay));
237
+ }
238
+ else {
239
+ logger.error(`GitHub fetch failed after ${retries} attempts: ${error.message} (code: ${error.code || 'unknown'})`);
240
+ throw error;
241
+ }
146
242
  }
147
243
  }
148
244
  }
@@ -154,6 +250,10 @@ class Doc2Vec {
154
250
  const perPage = 100;
155
251
  const sinceTimestamp = new Date(sinceDate);
156
252
  while (true) {
253
+ // Log progress every 10 pages to reduce noise
254
+ if (page === 1 || page % 10 === 0) {
255
+ logger.debug(`Fetching issues page ${page}... (${issues.length} issues so far)`);
256
+ }
157
257
  const data = await fetchWithRetry(GITHUB_API_URL, {
158
258
  per_page: perPage,
159
259
  page,
@@ -371,6 +471,8 @@ class Doc2Vec {
371
471
  logger.error(`Error during chunking or embedding for ${url}:`, error);
372
472
  }
373
473
  }, logger, visitedUrls);
474
+ this.recordBrokenLinks(config.url, crawlResult.brokenLinks);
475
+ this.writeBrokenLinksReport();
374
476
  logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
375
477
  logger.section('CLEANUP');
376
478
  if (crawlResult.hasNetworkErrors) {
@@ -388,6 +490,38 @@ class Doc2Vec {
388
490
  }
389
491
  logger.info(`Finished processing website: ${config.url}`);
390
492
  }
493
+ recordBrokenLinks(baseUrl, brokenLinks) {
494
+ const uniqueByKey = new Map();
495
+ for (const link of brokenLinks) {
496
+ const key = `${link.source} -> ${link.target}`;
497
+ if (!uniqueByKey.has(key)) {
498
+ uniqueByKey.set(key, link);
499
+ }
500
+ }
501
+ const unique = Array.from(uniqueByKey.values()).sort((a, b) => {
502
+ const sourceCompare = a.source.localeCompare(b.source);
503
+ if (sourceCompare !== 0)
504
+ return sourceCompare;
505
+ return a.target.localeCompare(b.target);
506
+ });
507
+ this.brokenLinksByWebsite[baseUrl] = unique;
508
+ }
509
+ writeBrokenLinksReport() {
510
+ const reportPath = path.join(this.configDir, '404.yaml');
511
+ const orderedEntries = Object.entries(this.brokenLinksByWebsite)
512
+ .sort(([a], [b]) => a.localeCompare(b));
513
+ const reportPayload = orderedEntries.map(([website, links]) => ({
514
+ website,
515
+ 'broken-links': links
516
+ }));
517
+ try {
518
+ fs.writeFileSync(reportPath, yaml.dump(reportPayload, { noRefs: true }), 'utf8');
519
+ this.logger.info(`Wrote broken link report to ${reportPath}`);
520
+ }
521
+ catch (error) {
522
+ this.logger.error(`Failed to write broken link report to ${reportPath}:`, error);
523
+ }
524
+ }
391
525
  async processLocalDirectory(config, parentLogger) {
392
526
  const logger = parentLogger.child('process');
393
527
  logger.info(`Starting processing for local directory: ${config.path}`);
@@ -507,6 +641,383 @@ class Doc2Vec {
507
641
  }
508
642
  logger.info(`Finished processing local directory: ${config.path}`);
509
643
  }
644
+ async processCodeSource(config, parentLogger) {
645
+ const logger = parentLogger.child('process');
646
+ logger.info(`Starting processing for code source (${config.source})`);
647
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
648
+ const validChunkIds = new Set();
649
+ const processedFiles = new Set();
650
+ let basePath;
651
+ let cleanupPathConfig;
652
+ let tempDir = null;
653
+ let repoUrlPrefix;
654
+ let repoBranch;
655
+ let incrementalMode = false;
656
+ let deleteUrls = [];
657
+ let allowedFiles;
658
+ let mtimeCutoff;
659
+ let fileListKey;
660
+ let lastMtimeKey;
661
+ let trackedFiles;
662
+ let maxObservedMtime = 0;
663
+ if (config.source === 'local_directory') {
664
+ if (!config.path) {
665
+ logger.error('Code source type local_directory requires a path.');
666
+ return;
667
+ }
668
+ basePath = config.path;
669
+ cleanupPathConfig = config.url_rewrite_prefix
670
+ ? { path: basePath, url_rewrite_prefix: config.url_rewrite_prefix }
671
+ : basePath;
672
+ const resolvedPath = path.resolve(basePath);
673
+ const pathKey = resolvedPath.replace(/[^a-zA-Z0-9]+/g, '_');
674
+ lastMtimeKey = `code_last_mtime_${pathKey}`;
675
+ fileListKey = `code_filelist_${pathKey}`;
676
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
677
+ const lastMtimeValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, lastMtimeKey, '0', logger);
678
+ mtimeCutoff = lastMtimeValue ? parseFloat(lastMtimeValue) : 0;
679
+ trackedFiles = new Set();
680
+ incrementalMode = true;
681
+ }
682
+ else if (config.source === 'github') {
683
+ if (!config.repo) {
684
+ logger.error('Code source type github requires a repo in owner/repo format.');
685
+ return;
686
+ }
687
+ const cloneResult = await this.cloneGithubRepo(config, logger);
688
+ basePath = cloneResult.path;
689
+ tempDir = cloneResult.path;
690
+ repoUrlPrefix = cloneResult.urlPrefix;
691
+ repoBranch = cloneResult.branch;
692
+ cleanupPathConfig = { path: basePath, url_rewrite_prefix: repoUrlPrefix };
693
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
694
+ const shaKey = this.buildCodeShaMetadataKey(config.repo, repoBranch);
695
+ const lastSha = await database_1.DatabaseManager.getMetadataValue(dbConnection, shaKey, undefined, logger);
696
+ const headSha = await this.getRepoHeadSha(basePath, logger);
697
+ if (lastSha && headSha) {
698
+ if (headSha === lastSha) {
699
+ incrementalMode = true;
700
+ allowedFiles = new Set();
701
+ deleteUrls = [];
702
+ }
703
+ else {
704
+ const diffResult = await this.getGitChangedFiles(basePath, lastSha, repoBranch, logger);
705
+ if (diffResult.mode === 'incremental') {
706
+ incrementalMode = true;
707
+ allowedFiles = diffResult.changedFiles;
708
+ deleteUrls = diffResult.deletedPaths
709
+ .map((relativePath) => this.buildCodeFileUrl(path.join(basePath, relativePath), basePath, config, repoUrlPrefix));
710
+ }
711
+ else {
712
+ logger.warn('Falling back to full scan for GitHub code source.');
713
+ }
714
+ }
715
+ }
716
+ }
717
+ else {
718
+ logger.error(`Unknown code source: ${config.source}`);
719
+ return;
720
+ }
721
+ logger.section('CODE SCANNING AND EMBEDDING');
722
+ try {
723
+ const scanResult = await this.contentProcessor.processCodeDirectory(basePath, config, async (filePath, content) => {
724
+ processedFiles.add(filePath);
725
+ const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
726
+ const fileUrl = this.buildCodeFileUrl(filePath, basePath, config, repoUrlPrefix);
727
+ logger.info(`Processing code from ${relativePath || filePath} (${content.length} chars)`);
728
+ try {
729
+ const chunks = await this.contentProcessor.chunkCode(content, config, fileUrl, relativePath || filePath, repoBranch || config.branch, config.repo);
730
+ logger.info(`Created ${chunks.length} chunks`);
731
+ if (chunks.length > 0) {
732
+ const chunkProgress = logger.progress(`Embedding chunks for ${relativePath || filePath}`, chunks.length);
733
+ for (let i = 0; i < chunks.length; i++) {
734
+ const chunk = chunks[i];
735
+ validChunkIds.add(chunk.metadata.chunk_id);
736
+ const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
737
+ let needsEmbedding = true;
738
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
739
+ if (dbConnection.type === 'sqlite') {
740
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
741
+ const existing = checkHashStmt.get(chunk.metadata.chunk_id);
742
+ if (existing && existing.hash === chunkHash) {
743
+ needsEmbedding = false;
744
+ chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
745
+ logger.info(`Skipping unchanged chunk: ${chunkId}`);
746
+ }
747
+ }
748
+ else if (dbConnection.type === 'qdrant') {
749
+ try {
750
+ let pointId;
751
+ try {
752
+ pointId = chunk.metadata.chunk_id;
753
+ if (!utils_1.Utils.isValidUuid(pointId)) {
754
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
755
+ }
756
+ }
757
+ catch (e) {
758
+ pointId = crypto_1.default.randomUUID();
759
+ }
760
+ const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
761
+ ids: [pointId],
762
+ with_payload: true,
763
+ with_vector: false,
764
+ });
765
+ if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
766
+ needsEmbedding = false;
767
+ chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
768
+ logger.info(`Skipping unchanged chunk: ${chunkId}`);
769
+ }
770
+ }
771
+ catch (error) {
772
+ logger.error(`Error checking existing point in Qdrant:`, error);
773
+ }
774
+ }
775
+ if (needsEmbedding) {
776
+ const embeddings = await this.createEmbeddings([chunk.content]);
777
+ if (embeddings.length > 0) {
778
+ const embedding = embeddings[0];
779
+ if (dbConnection.type === 'sqlite') {
780
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
781
+ chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
782
+ }
783
+ else if (dbConnection.type === 'qdrant') {
784
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
785
+ chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
786
+ }
787
+ }
788
+ else {
789
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
790
+ chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
791
+ }
792
+ }
793
+ }
794
+ chunkProgress.complete();
795
+ }
796
+ }
797
+ catch (error) {
798
+ logger.error(`Error during code chunking or embedding for ${filePath}:`, error);
799
+ }
800
+ }, logger, undefined, {
801
+ allowedFiles,
802
+ mtimeCutoff,
803
+ trackFiles: trackedFiles
804
+ });
805
+ if (trackedFiles) {
806
+ maxObservedMtime = scanResult.maxMtime;
807
+ }
808
+ }
809
+ finally {
810
+ logger.section('CLEANUP');
811
+ if (incrementalMode) {
812
+ if (deleteUrls.length > 0) {
813
+ logger.info(`Cleaning up ${deleteUrls.length} deleted/renamed files`);
814
+ for (const url of deleteUrls) {
815
+ if (dbConnection.type === 'sqlite') {
816
+ database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
817
+ }
818
+ else if (dbConnection.type === 'qdrant') {
819
+ await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
820
+ }
821
+ }
822
+ }
823
+ else {
824
+ logger.info('No deleted/renamed files to clean up.');
825
+ }
826
+ if (trackedFiles && fileListKey) {
827
+ const previousListValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, fileListKey, '[]', logger);
828
+ const previousList = previousListValue ? JSON.parse(previousListValue) : [];
829
+ const currentList = Array.from(trackedFiles);
830
+ const deletedFiles = previousList.filter((filePath) => !trackedFiles?.has(filePath));
831
+ for (const deletedFile of deletedFiles) {
832
+ const url = this.buildCodeFileUrl(deletedFile, basePath, config, repoUrlPrefix);
833
+ if (dbConnection.type === 'sqlite') {
834
+ database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
835
+ }
836
+ else if (dbConnection.type === 'qdrant') {
837
+ await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
838
+ }
839
+ }
840
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger);
841
+ if (lastMtimeKey) {
842
+ const nextMtime = maxObservedMtime > 0 ? maxObservedMtime : Date.now();
843
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, lastMtimeKey, `${nextMtime}`, logger);
844
+ }
845
+ }
846
+ }
847
+ else {
848
+ if (dbConnection.type === 'sqlite') {
849
+ logger.info(`Running SQLite cleanup for code source ${basePath}`);
850
+ database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, cleanupPathConfig, logger);
851
+ }
852
+ else if (dbConnection.type === 'qdrant') {
853
+ logger.info(`Running Qdrant cleanup for code source ${basePath} in collection ${dbConnection.collectionName}`);
854
+ await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, cleanupPathConfig, logger);
855
+ }
856
+ }
857
+ if (config.source === 'github' && basePath && repoBranch) {
858
+ const headSha = await this.getRepoHeadSha(basePath, logger);
859
+ if (headSha) {
860
+ const shaKey = this.buildCodeShaMetadataKey(config.repo, repoBranch);
861
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, shaKey, headSha, logger);
862
+ }
863
+ }
864
+ if (tempDir) {
865
+ try {
866
+ fs.rmSync(tempDir, { recursive: true, force: true });
867
+ logger.debug(`Removed temporary repo at ${tempDir}`);
868
+ }
869
+ catch (error) {
870
+ logger.warn(`Failed to remove temporary repo at ${tempDir}:`, error);
871
+ }
872
+ }
873
+ }
874
+ logger.info(`Finished processing code source (${config.source})`);
875
+ }
876
+ buildCodeShaMetadataKey(repo, branch) {
877
+ const normalizedRepo = repo.replace(/[^a-zA-Z0-9]+/g, '_');
878
+ const normalizedBranch = branch.replace(/[^a-zA-Z0-9]+/g, '_');
879
+ return `code_last_sha_${normalizedRepo}_${normalizedBranch}`;
880
+ }
881
+ async getRepoHeadSha(repoPath, logger) {
882
+ try {
883
+ const { stdout } = await execAsync(`git -C "${repoPath}" rev-parse HEAD`);
884
+ return stdout.trim() || undefined;
885
+ }
886
+ catch (error) {
887
+ logger.warn(`Failed to resolve HEAD sha for ${repoPath}:`, error);
888
+ return undefined;
889
+ }
890
+ }
891
+ async getGitChangedFiles(repoPath, lastSha, branch, logger) {
892
+ const diffCommand = `git -C "${repoPath}" diff --name-status ${lastSha}..HEAD`;
893
+ const attemptDiff = async () => {
894
+ const { stdout } = await execAsync(diffCommand);
895
+ return stdout;
896
+ };
897
+ let diffOutput;
898
+ try {
899
+ diffOutput = await attemptDiff();
900
+ }
901
+ catch (error) {
902
+ logger.warn(`Failed to diff against ${lastSha}. Fetching more history...`);
903
+ const fetchDepths = [200, 1000, 5000];
904
+ let fetched = false;
905
+ for (const depth of fetchDepths) {
906
+ try {
907
+ logger.info(`Fetching with --depth=${depth}...`);
908
+ await execAsync(`git -C "${repoPath}" fetch --depth=${depth} origin "${branch}"`);
909
+ diffOutput = await attemptDiff();
910
+ fetched = true;
911
+ break;
912
+ }
913
+ catch (fetchError) {
914
+ logger.warn(`Diff still failed at --depth=${depth}.`);
915
+ }
916
+ }
917
+ if (!fetched) {
918
+ try {
919
+ logger.info(`Attempting full unshallow fetch...`);
920
+ await execAsync(`git -C "${repoPath}" fetch --unshallow origin "${branch}"`);
921
+ diffOutput = await attemptDiff();
922
+ }
923
+ catch (unshallowError) {
924
+ logger.warn(`Failed to diff even after full unshallow. Falling back to full scan.`, unshallowError);
925
+ return { mode: 'full', changedFiles: new Set(), deletedPaths: [] };
926
+ }
927
+ }
928
+ }
929
+ if (!diffOutput) {
930
+ logger.warn('No diff output available. Falling back to full scan.');
931
+ return { mode: 'full', changedFiles: new Set(), deletedPaths: [] };
932
+ }
933
+ const changedFiles = new Set();
934
+ const deletedPaths = [];
935
+ for (const line of diffOutput.split('\n')) {
936
+ const trimmed = line.trim();
937
+ if (!trimmed)
938
+ continue;
939
+ const parts = trimmed.split('\t');
940
+ const status = parts[0];
941
+ if (status.startsWith('R')) {
942
+ const oldPath = parts[1];
943
+ const newPath = parts[2];
944
+ if (oldPath)
945
+ deletedPaths.push(oldPath);
946
+ if (newPath)
947
+ changedFiles.add(path.join(repoPath, newPath));
948
+ }
949
+ else if (status === 'D') {
950
+ const deletedPath = parts[1];
951
+ if (deletedPath)
952
+ deletedPaths.push(deletedPath);
953
+ }
954
+ else if (status === 'A' || status === 'M') {
955
+ const changedPath = parts[1];
956
+ if (changedPath)
957
+ changedFiles.add(path.join(repoPath, changedPath));
958
+ }
959
+ }
960
+ logger.info(`Git diff changes: ${changedFiles.size} modified/added, ${deletedPaths.length} deleted/renamed.`);
961
+ return { mode: 'incremental', changedFiles, deletedPaths };
962
+ }
963
+ buildCodeFileUrl(filePath, basePath, config, repoUrlPrefix) {
964
+ const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
965
+ if (repoUrlPrefix) {
966
+ return `${repoUrlPrefix}/${relativePath}`;
967
+ }
968
+ if (config.url_rewrite_prefix) {
969
+ if (relativePath.startsWith('..')) {
970
+ return `file://${filePath}`;
971
+ }
972
+ const prefix = config.url_rewrite_prefix.endsWith('/')
973
+ ? config.url_rewrite_prefix.slice(0, -1)
974
+ : config.url_rewrite_prefix;
975
+ return `${prefix}/${relativePath}`;
976
+ }
977
+ return `file://${filePath}`;
978
+ }
979
+ async cloneGithubRepo(config, logger) {
980
+ const repo = config.repo;
981
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doc2vec-code-'));
982
+ const requestedBranch = config.branch;
983
+ const encodedToken = GITHUB_TOKEN ? encodeURIComponent(GITHUB_TOKEN) : '';
984
+ const repoUrl = encodedToken
985
+ ? `https://x-access-token:${encodedToken}@github.com/${repo}.git`
986
+ : `https://github.com/${repo}.git`;
987
+ const branchArg = requestedBranch ? `--branch "${requestedBranch}"` : '';
988
+ logger.info(`Cloning ${repo} to ${tempDir}`);
989
+ try {
990
+ await execAsync(`git clone --depth 1 ${branchArg} "${repoUrl}" "${tempDir}"`);
991
+ }
992
+ catch (error) {
993
+ try {
994
+ fs.rmSync(tempDir, { recursive: true, force: true });
995
+ }
996
+ catch (cleanupError) {
997
+ logger.warn(`Failed to clean up temp dir after clone failure: ${tempDir}`, cleanupError);
998
+ }
999
+ logger.error(`Failed to clone repo ${repo}:`, error);
1000
+ throw error;
1001
+ }
1002
+ let resolvedBranch = requestedBranch;
1003
+ if (!resolvedBranch) {
1004
+ resolvedBranch = await this.getRepoBranch(tempDir, logger);
1005
+ }
1006
+ const branch = resolvedBranch || 'main';
1007
+ const urlPrefix = `https://github.com/${repo}/blob/${branch}`;
1008
+ return { path: tempDir, branch, urlPrefix };
1009
+ }
1010
+ async getRepoBranch(repoPath, logger) {
1011
+ try {
1012
+ const { stdout } = await execAsync(`git -C "${repoPath}" symbolic-ref --short HEAD`);
1013
+ const branch = stdout.trim();
1014
+ return branch || undefined;
1015
+ }
1016
+ catch (error) {
1017
+ logger.warn(`Failed to resolve repo branch for ${repoPath}:`, error);
1018
+ return undefined;
1019
+ }
1020
+ }
510
1021
  async processZendesk(config, parentLogger) {
511
1022
  const logger = parentLogger.child('process');
512
1023
  logger.info(`Starting processing for Zendesk: ${config.zendesk_subdomain}.zendesk.com`);
@@ -850,7 +1361,7 @@ class Doc2Vec {
850
1361
  try {
851
1362
  logger.debug(`Creating embeddings for ${texts.length} texts`);
852
1363
  const response = await this.openai.embeddings.create({
853
- model: "text-embedding-3-large",
1364
+ model: this.embeddingModel,
854
1365
  input: texts,
855
1366
  });
856
1367
  logger.debug(`Successfully created ${response.data.length} embeddings`);
@@ -862,6 +1373,7 @@ class Doc2Vec {
862
1373
  }
863
1374
  }
864
1375
  }
1376
+ exports.Doc2Vec = Doc2Vec;
865
1377
  if (require.main === module) {
866
1378
  const configPath = process.argv[2] || 'config.yaml';
867
1379
  if (!fs.existsSync(configPath)) {