import '../lib/loadenv'; import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters"; import FirecrawlApp from '@mendable/firecrawl-js'; import { z } from 'zod'; import { EmbeddingRecord } from "../lib/types/datasource_types"; import { DataSourceDoc } from "@/src/entities/models/data-source-doc"; import { embedMany, generateText } from 'ai'; import { embeddingModel } from '../lib/embedding'; import { qdrantClient } from '../lib/qdrant'; import { PrefixLogger } from "../lib/utils"; import { GoogleGenerativeAI } from "@google/generative-ai"; import crypto from 'crypto'; import { createOpenAI } from '@ai-sdk/openai'; import { USE_BILLING, USE_GEMINI_FILE_PARSING } from '../lib/feature_flags'; import { authorize, getCustomerIdForProject, logUsage, UsageTracker } from '../lib/billing'; import { BillingError } from '@/src/entities/errors/common'; import { DataSource } from '@/src/entities/models/data-source'; import { IDataSourcesRepository } from '@/src/application/repositories/data-sources.repository.interface'; import { IDataSourceDocsRepository } from '@/src/application/repositories/data-source-docs.repository.interface'; import { IUploadsStorageService } from '@/src/application/services/uploads-storage.service.interface'; import { container } from '@/di/container'; const FILE_PARSING_PROVIDER_API_KEY = process.env.FILE_PARSING_PROVIDER_API_KEY || process.env.OPENAI_API_KEY || ''; const FILE_PARSING_PROVIDER_BASE_URL = process.env.FILE_PARSING_PROVIDER_BASE_URL || undefined; const FILE_PARSING_MODEL = process.env.FILE_PARSING_MODEL || 'gpt-4.1'; const dataSourcesRepository = container.resolve('dataSourcesRepository'); const dataSourceDocsRepository = container.resolve('dataSourceDocsRepository'); const localUploadsStorageService = container.resolve('localUploadsStorageService'); const s3UploadsStorageService = container.resolve('s3UploadsStorageService'); const firecrawl = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY || "test" }); const geminiParsingModel = "gemini-2.5-flash"; const openai = createOpenAI({ apiKey: FILE_PARSING_PROVIDER_API_KEY, baseURL: FILE_PARSING_PROVIDER_BASE_URL, }); const splitter = new RecursiveCharacterTextSplitter({ separators: ['\n\n', '\n', '. ', '.', ''], chunkSize: 1024, chunkOverlap: 20, }); // Configure Google Gemini API const genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY || ''); async function retryable(fn: () => Promise, maxAttempts: number = 3): Promise { let attempts = 0; while (true) { try { return await fn(); } catch (e) { attempts++; if (attempts >= maxAttempts) { throw e; } } } } async function runProcessFilePipeline(_logger: PrefixLogger, usageTracker: UsageTracker, job: z.infer, doc: z.infer) { if (doc.data.type !== 'file_local' && doc.data.type !== 'file_s3') { throw new Error("Invalid data source type"); } const logger = _logger .child(doc.id) .child(doc.name); // Get file content let fileData: Buffer; if (doc.data.type === 'file_local') { logger.log("Fetching file from local"); fileData = await localUploadsStorageService.getFileContents(doc.id); } else { logger.log("Fetching file from S3"); fileData = await s3UploadsStorageService.getFileContents(doc.id); } let markdown = ""; const extractPrompt = "Extract and return only the text content from this document in markdown format. Exclude any formatting instructions or additional commentary."; if (!USE_GEMINI_FILE_PARSING) { // Use OpenAI to extract text content logger.log("Extracting content using OpenAI"); const { text, usage } = await generateText({ model: openai(FILE_PARSING_MODEL), system: extractPrompt, messages: [ { role: "user", content: [ { type: "file", data: fileData.toString('base64'), mimeType: doc.data.mimeType, } ] } ], }); markdown = text; usageTracker.track({ type: "LLM_USAGE", modelName: FILE_PARSING_MODEL, inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, context: "rag.files.llm_usage", }); } else { // Use Gemini to extract text content logger.log("Extracting content using Gemini"); const model = genAI.getGenerativeModel({ model: geminiParsingModel }); const result = await model.generateContent([ { inlineData: { data: fileData.toString('base64'), mimeType: doc.data.mimeType } }, extractPrompt, ]); markdown = result.response.text(); usageTracker.track({ type: "LLM_USAGE", modelName: geminiParsingModel, inputTokens: result.response.usageMetadata?.promptTokenCount || 0, outputTokens: result.response.usageMetadata?.candidatesTokenCount || 0, context: "rag.files.llm_usage", }); } // split into chunks logger.log("Splitting into chunks"); const splits = await splitter.createDocuments([markdown]); // generate embeddings logger.log("Generating embeddings"); const { embeddings, usage } = await embedMany({ model: embeddingModel, values: splits.map((split) => split.pageContent) }); usageTracker.track({ type: "EMBEDDING_MODEL_USAGE", modelName: embeddingModel.modelId, tokens: usage.tokens, context: "rag.files.embedding_usage", }); // store embeddings in qdrant logger.log("Storing embeddings in Qdrant"); const points: z.infer[] = embeddings.map((embedding, i) => ({ id: crypto.randomUUID(), vector: embedding, payload: { projectId: job.projectId, sourceId: job.id, docId: doc.id, content: splits[i].pageContent, title: doc.name, name: doc.name, }, })); await qdrantClient.upsert("embeddings", { points, }); // store content in doc record logger.log("Storing content in doc record"); await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, { content: markdown, status: "ready", }); } async function runScrapePipeline(_logger: PrefixLogger, usageTracker: UsageTracker, job: z.infer, doc: z.infer) { const logger = _logger .child(doc.id) .child(doc.name); // scrape the url using firecrawl logger.log("Scraping using Firecrawl"); const scrapeResult = await retryable(async () => { if (doc.data.type !== 'url') { throw new Error("Invalid data source type"); } const scrapeResult = await firecrawl.scrapeUrl(doc.data.url, { formats: ['markdown'], onlyMainContent: true, excludeTags: ['script', 'style', 'noscript', 'img',] }); if (!scrapeResult.success) { throw new Error("Unable to scrape URL: " + doc.data.url); } return scrapeResult; }, 3); // Retry up to 3 times usageTracker.track({ type: "FIRECRAWL_SCRAPE_USAGE", context: "rag.urls.firecrawl_scrape", }); // split into chunks logger.log("Splitting into chunks"); const splits = await splitter.createDocuments([scrapeResult.markdown || '']); // generate embeddings logger.log("Generating embeddings"); const { embeddings, usage } = await embedMany({ model: embeddingModel, values: splits.map((split) => split.pageContent) }); usageTracker.track({ type: "EMBEDDING_MODEL_USAGE", modelName: embeddingModel.modelId, tokens: usage.tokens, context: "rag.urls.embedding_usage", }); // store embeddings in qdrant logger.log("Storing embeddings in Qdrant"); const points: z.infer[] = embeddings.map((embedding, i) => ({ id: crypto.randomUUID(), vector: embedding, payload: { projectId: job.projectId, sourceId: job.id, docId: doc.id, content: splits[i].pageContent, title: scrapeResult.metadata?.title || '', name: doc.name, }, })); await qdrantClient.upsert("embeddings", { points, }); // store scraped markdown in doc record logger.log("Storing scraped markdown in doc record"); await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, { content: scrapeResult.markdown, status: "ready", }); } async function runProcessTextPipeline(_logger: PrefixLogger, usageTracker: UsageTracker, job: z.infer, doc: z.infer) { const logger = _logger .child(doc.id) .child(doc.name); if (doc.data.type !== 'text') { throw new Error("Invalid data source type"); } // split into chunks logger.log("Splitting into chunks"); const splits = await splitter.createDocuments([doc.data.content]); // generate embeddings logger.log("Generating embeddings"); const { embeddings, usage } = await embedMany({ model: embeddingModel, values: splits.map((split) => split.pageContent) }); usageTracker.track({ type: "EMBEDDING_MODEL_USAGE", modelName: embeddingModel.modelId, tokens: usage.tokens, context: "rag.text.embedding_usage", }); // store embeddings in qdrant logger.log("Storing embeddings in Qdrant"); const points: z.infer[] = embeddings.map((embedding, i) => ({ id: crypto.randomUUID(), vector: embedding, payload: { projectId: job.projectId, sourceId: job.id, docId: doc.id, content: splits[i].pageContent, title: doc.name, name: doc.name, }, })); await qdrantClient.upsert("embeddings", { points, }); // store content in doc record logger.log("Storing content in doc record"); await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, { content: doc.data.content, status: "ready", }); } async function runDeletionPipeline(_logger: PrefixLogger, job: z.infer, doc: z.infer): Promise { const logger = _logger .child(doc.id) .child(doc.name); // Delete embeddings from qdrant logger.log("Deleting embeddings from Qdrant"); await qdrantClient.delete("embeddings", { filter: { must: [ { key: "projectId", match: { value: job.projectId, } }, { key: "sourceId", match: { value: job.id, } }, { key: "docId", match: { value: doc.id, } } ], }, }); // Delete docs from db logger.log("Deleting doc from db"); await dataSourceDocsRepository.delete(doc.id); } // fetch next job from mongodb (async () => { while (true) { const now = Date.now(); let job: z.infer | null = null; // first try to find a job that needs deleting job = await dataSourcesRepository.pollDeleteJob(); if (job === null) { job = await dataSourcesRepository.pollPendingJob(); } if (job === null) { // if no doc found, sleep for a bit and start again await new Promise(resolve => setTimeout(resolve, 5 * 1000)); continue; } const logger = new PrefixLogger(`${job.id}-${job.version}`); logger.log(`Starting job ${job.id}. Type: ${job.data.type}. Status: ${job.status}`); let errors = false; try { if (job.status === "deleted") { // delete all embeddings for this source logger.log("Deleting embeddings from Qdrant"); await qdrantClient.delete("embeddings", { filter: { must: [ { key: "projectId", match: { value: job.projectId } }, { key: "sourceId", match: { value: job.id } }, ], }, }); // delete all docs for this source logger.log("Deleting docs from db"); await dataSourceDocsRepository.deleteBySourceId(job.id); // delete the source record from db logger.log("Deleting source record from db"); await dataSourcesRepository.delete(job.id); logger.log("Job deleted"); continue; } // fetch docs that need updating const pendingDocs = []; let cursor = undefined; do { const result = await dataSourceDocsRepository.list(job.id, { status: ["pending", "error"], }, cursor); pendingDocs.push(...result.items); cursor = result.nextCursor; } while (cursor); logger.log(`Found ${pendingDocs.length} docs to process`); // fetch project, user and billing data let billingCustomerId: string | null = null; if (USE_BILLING) { try { billingCustomerId = await getCustomerIdForProject(job.projectId); } catch (e) { logger.log("Unable to fetch billing customer id:", e); throw new Error("Unable to fetch billing customer id"); } } // for each doc for (const doc of pendingDocs) { // authorize with billing if (USE_BILLING && billingCustomerId) { const authResponse = await authorize(billingCustomerId, { type: "use_credits", }); if ('error' in authResponse) { throw new BillingError(authResponse.error || "Unknown billing error") } } const usageTracker = new UsageTracker(); try { if (doc.data.type === "file_local" || doc.data.type === "file_s3") { await runProcessFilePipeline(logger, usageTracker, job, doc); } else if (doc.data.type === "text") { await runProcessTextPipeline(logger, usageTracker, job, doc); } else if (doc.data.type === "url") { await runScrapePipeline(logger, usageTracker, job, doc); } } catch (e: any) { errors = true; logger.log("Error processing doc:", e); await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, { status: "error", error: "Error processing doc", }); } finally { // log usage in billing if (USE_BILLING && billingCustomerId) { await logUsage(billingCustomerId, { items: usageTracker.flush(), }); } } } // fetch docs that need to be deleted const deletedDocs = []; cursor = undefined; do { const result = await dataSourceDocsRepository.list(job.id, { status: ["deleted"], }, cursor); deletedDocs.push(...result.items); cursor = result.nextCursor; } while (cursor); logger.log(`Found ${deletedDocs.length} docs to delete`); for (const doc of deletedDocs) { try { await runDeletionPipeline(logger, job, doc); } catch (e: any) { errors = true; logger.log("Error deleting doc:", e); await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, { status: "error", error: "Error deleting doc", }); } } } catch (e) { if (e instanceof BillingError) { logger.log("Billing error:", e.message); await dataSourcesRepository.release(job.id, job.version, { status: "error", billingError: e.message, }); } logger.log("Error processing job; will retry:", e); await dataSourcesRepository.release(job.id, job.version, { status: "error", }); continue; } // mark job as complete logger.log("Marking job as completed..."); await dataSourcesRepository.release(job.id, job.version, { status: errors ? "error" : "ready", ...(errors ? { error: "There were some errors processing this job" } : {}), }); } })();