/* oxlint-disable import/no-cycle -- Context embedding reuses the shared capability runtime. */ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { Cache, CopilotInvalidContext, NoCopilotProviderAvailable, OnEvent, } from '../../../base'; import { ContextConfig, ContextConfigSchema, ContextDoc, ContextEmbedStatus, ContextFile, Models, } from '../../../models'; import { CopilotEmbeddingClientService } from '../embedding/client'; import type { EmbeddingCallOptions, EmbeddingClient, EmbeddingRouteContext, } from '../embedding/types'; import { ContextSession } from './session'; const CONTEXT_SESSION_KEY = 'context-session'; @Injectable() export class CopilotContextService implements OnApplicationBootstrap { private supportEmbedding = false; private client: EmbeddingClient | undefined; constructor( private readonly embeddingClients: CopilotEmbeddingClientService, private readonly cache: Cache, private readonly models: Models ) {} @OnEvent('config.init') async onConfigInit() { await this.setup(); } @OnEvent('config.changed') async onConfigChanged() { await this.setup(); } private async setup() { this.client = await this.embeddingClients.refresh(); } async onApplicationBootstrap() { const supportEmbedding = await this.models.copilotContext.checkEmbeddingAvailable(); if (supportEmbedding) { this.supportEmbedding = true; } } get canEmbedding() { return this.supportEmbedding; } // public this client to allow overriding in tests get embeddingClient(): EmbeddingClient | undefined { return this.client ?? this.embeddingClients.getClient(); } private embeddingOptions( workspaceId: string, signal?: AbortSignal, routeContext: EmbeddingRouteContext = {} ): EmbeddingCallOptions { return { workspaceId, signal, ...routeContext, featureKind: 'embedding' }; } private async saveConfig( contextId: string, config: ContextConfig, refreshCache = false ): Promise { if (!refreshCache) { await this.models.copilotContext.update(contextId, { config }); } await this.cache.set(`${CONTEXT_SESSION_KEY}:${contextId}`, config); } private async getCachedSession( contextId: string ): Promise { const cachedSession = await this.cache.get( `${CONTEXT_SESSION_KEY}:${contextId}` ); if (cachedSession) { const config = ContextConfigSchema.safeParse(cachedSession); if (config.success) { return new ContextSession( this.embeddingClient, contextId, config.data, this.models, this.saveConfig.bind(this, contextId) ); } } return undefined; } // NOTE: we only cache config to avoid frequent database queries // but we do not need to cache session instances because a distributed // lock is already apply to mutation operation for the same context in // the resolver, so there will be no simultaneous writing to the config private async cacheSession( contextId: string, config: ContextConfig ): Promise { const dispatcher = this.saveConfig.bind(this, contextId); await dispatcher(config, true); return new ContextSession( this.embeddingClient, contextId, config, this.models, dispatcher ); } async create(sessionId: string): Promise { // keep the context unique per session const existsContext = await this.getBySessionId(sessionId); if (existsContext) return existsContext; const context = await this.models.copilotContext.create(sessionId); const config = ContextConfigSchema.parse(context.config); return await this.cacheSession(context.id, config); } async get(id: string): Promise { if (!this.embeddingClient) { throw new NoCopilotProviderAvailable( { modelId: 'embedding' }, 'embedding client not configured' ); } const context = await this.getCachedSession(id); if (context) return context; const config = await this.models.copilotContext.getConfig(id); if (config) { return this.cacheSession(id, config); } throw new CopilotInvalidContext({ contextId: id }); } async getOwnedContext( userId: string, contextId: string, options: { workspaceId?: string; sessionId?: string } = {} ): Promise { const accessInfo = await this.models.copilotContext.getAccessInfo(contextId); if ( !accessInfo || accessInfo.session.userId !== userId || (options.workspaceId && accessInfo.session.workspaceId !== options.workspaceId) || (options.sessionId && accessInfo.sessionId !== options.sessionId) ) { throw new CopilotInvalidContext({ contextId }); } return await this.get(contextId); } async getBySessionId(sessionId: string): Promise { const existsContext = await this.models.copilotContext.getBySessionId(sessionId); if (existsContext) return this.get(existsContext.id); return null; } async matchWorkspaceBlobs( workspaceId: string, content: string, topK: number = 5, signal?: AbortSignal, threshold: number = 0.5, routeContext?: EmbeddingRouteContext ) { const client = this.embeddingClient; if (!client) return []; const options = this.embeddingOptions(workspaceId, signal, routeContext); const embedding = await client.getEmbedding(content, options); if (!embedding) return []; const blobChunks = await this.models.copilotWorkspace.matchBlobEmbedding( workspaceId, embedding, topK * 2, threshold ); if (!blobChunks.length) return []; return await client.reRank(content, blobChunks, topK, options); } async matchWorkspaceFiles( workspaceId: string, content: string, topK: number = 5, signal?: AbortSignal, threshold: number = 0.5, routeContext?: EmbeddingRouteContext ) { const client = this.embeddingClient; if (!client) return []; const options = this.embeddingOptions(workspaceId, signal, routeContext); const embedding = await client.getEmbedding(content, options); if (!embedding) return []; const fileChunks = await this.models.copilotWorkspace.matchFileEmbedding( workspaceId, embedding, topK * 2, threshold ); if (!fileChunks.length) return []; return await client.reRank(content, fileChunks, topK, options); } async matchWorkspaceDocs( workspaceId: string, content: string, topK: number = 5, signal?: AbortSignal, threshold: number = 0.5, routeContext?: EmbeddingRouteContext ) { const client = this.embeddingClient; if (!client) return []; const options = this.embeddingOptions(workspaceId, signal, routeContext); const embedding = await client.getEmbedding(content, options); if (!embedding) return []; const workspaceChunks = await this.models.copilotContext.matchWorkspaceEmbedding( embedding, workspaceId, topK * 2, threshold ); if (!workspaceChunks.length) return []; return await client.reRank(content, workspaceChunks, topK, options); } async matchWorkspaceAll( workspaceId: string, content: string, topK: number, signal?: AbortSignal, threshold: number = 0.8, docIds?: string[], scopedThreshold: number = 0.85, routeContext?: EmbeddingRouteContext ) { const client = this.embeddingClient; if (!client) return []; const options = this.embeddingOptions(workspaceId, signal, routeContext); const embedding = await client.getEmbedding(content, options); if (!embedding) return []; const [fileChunks, blobChunks, workspaceChunks, scopedWorkspaceChunks] = await Promise.all([ this.models.copilotWorkspace.matchFileEmbedding( workspaceId, embedding, topK * 2, threshold ), this.models.copilotWorkspace.matchBlobEmbedding( workspaceId, embedding, topK * 2, threshold ), this.models.copilotContext.matchWorkspaceEmbedding( embedding, workspaceId, topK * 2, threshold ), docIds ? this.models.copilotContext.matchWorkspaceEmbedding( embedding, workspaceId, topK * 2, scopedThreshold, docIds ) : null, ]); if ( !fileChunks.length && !blobChunks.length && !workspaceChunks.length && !scopedWorkspaceChunks?.length ) { return []; } return await client.reRank( content, [ ...fileChunks, ...blobChunks, ...workspaceChunks, ...(scopedWorkspaceChunks || []), ], topK, options ); } @OnEvent('workspace.doc.embed.failed') async onDocEmbedFailed({ contextId, docId, }: Events['workspace.doc.embed.failed']) { const context = await this.get(contextId); await context.saveDocRecord(docId, doc => ({ ...(doc as ContextDoc), status: ContextEmbedStatus.failed, })); } @OnEvent('workspace.doc.embed.finished') async onDocEmbedFinished({ contextId, docId, }: Events['workspace.doc.embed.finished']) { const context = await this.get(contextId); await context.saveDocRecord(docId, doc => ({ ...(doc as ContextDoc), status: ContextEmbedStatus.finished, })); } @OnEvent('workspace.file.embed.finished') async onFileEmbedFinish({ contextId, fileId, chunkSize, }: Events['workspace.file.embed.finished']) { if (!contextId) return; const context = await this.get(contextId); await context.saveFileRecord(fileId, file => ({ ...(file as ContextFile), chunkSize, status: ContextEmbedStatus.finished, })); } @OnEvent('workspace.file.embed.failed') async onFileEmbedFailed({ contextId, fileId, error, }: Events['workspace.file.embed.failed']) { if (!contextId) return; const context = await this.get(contextId); await context.saveFileRecord(fileId, file => ({ ...(file as ContextFile), error, status: ContextEmbedStatus.failed, })); } }