fix(server): query workspace embed files (#11982)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
	- Expanded file chunk matching to include both context and workspace file embeddings, providing broader and more relevant search results.
- **Improvements**
	- Enhanced result ranking by introducing a re-ranking step for combined embedding matches, improving the relevance of returned file chunks.
	- Adjusted file count reporting to reflect the total number of workspace files instead of ignored documents for more accurate workspace file statistics.
	- Renamed and streamlined workspace file management methods for clearer and more consistent API usage.
- **Bug Fixes**
	- Prevented embedding similarity queries when embedding is disabled for a workspace, improving system behavior consistency.
- **Tests**
	- Added comprehensive tests to verify workspace embedding management, including enabling, matching, and disabling embedding functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
darkskygit
2025-04-25 08:32:32 +00:00
parent 0abe65653b
commit 49c57ca649
13 changed files with 220 additions and 112 deletions
@@ -284,7 +284,15 @@ const actions = [
verifier: (t: ExecutionContext<Tester>, result: string) => { verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result); assertNotWrappedInCodeBlock(t, result);
assertCitation(t, result, (t, c) => { assertCitation(t, result, (t, c) => {
t.assert(c.length === 0, 'should not have citation'); t.assert(
c.length === 0 ||
// ignore web search result
c
.map(c => JSON.parse(c.citationJson).type)
.filter(type => ['attachment', 'doc'].includes(type)).length ===
0,
'should not have citation'
);
}); });
}, },
type: 'text' as const, type: 'text' as const,
@@ -404,8 +412,9 @@ const actions = [
messages: [{ role: 'user' as const, content: TestAssets.SSOT }], messages: [{ role: 'user' as const, content: TestAssets.SSOT }],
verifier: (t: ExecutionContext<Tester>, result: string) => { verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result); assertNotWrappedInCodeBlock(t, result);
const cleared = result.toLowerCase();
t.assert( t.assert(
result.toLowerCase().includes('single source of truth'), cleared.includes('single source of truth') || cleared.includes('ssot'),
'should include original keyword' 'should include original keyword'
); );
}, },
@@ -120,7 +120,7 @@ test.before(async t => {
t.context.jobs = jobs; t.context.jobs = jobs;
}); });
const promptName = 'prompt'; const textPromptName = 'prompt';
test.beforeEach(async t => { test.beforeEach(async t => {
Sinon.restore(); Sinon.restore();
const { app, prompt } = t.context; const { app, prompt } = t.context;
@@ -128,7 +128,7 @@ test.beforeEach(async t => {
await prompt.onApplicationBootstrap(); await prompt.onApplicationBootstrap();
t.context.u1 = await app.signupV1('u1@affine.pro'); t.context.u1 = await app.signupV1('u1@affine.pro');
await prompt.set(promptName, 'test', [ await prompt.set(textPromptName, 'test', [
{ role: 'system', content: 'hello {{word}}' }, { role: 'system', content: 'hello {{word}}' },
]); ]);
}); });
@@ -150,7 +150,7 @@ test('should create session correctly', async t => {
} }
) => { ) => {
await asserter( await asserter(
createCopilotSession(app, workspaceId, randomUUID(), promptName) createCopilotSession(app, workspaceId, randomUUID(), textPromptName)
); );
}; };
@@ -202,7 +202,7 @@ test('should update session correctly', async t => {
t.truthy(await x, error); t.truthy(await x, error);
} }
) => { ) => {
await asserter(updateCopilotSession(app, sessionId, promptName)); await asserter(updateCopilotSession(app, sessionId, textPromptName));
}; };
{ {
@@ -212,7 +212,7 @@ test('should update session correctly', async t => {
app, app,
workspaceId, workspaceId,
docId, docId,
promptName textPromptName
); );
await assertUpdateSession( await assertUpdateSession(
sessionId, sessionId,
@@ -225,7 +225,7 @@ test('should update session correctly', async t => {
app, app,
randomUUID(), randomUUID(),
randomUUID(), randomUUID(),
promptName textPromptName
); );
await assertUpdateSession( await assertUpdateSession(
sessionId, sessionId,
@@ -244,7 +244,7 @@ test('should update session correctly', async t => {
app, app,
workspaceId, workspaceId,
randomUUID(), randomUUID(),
promptName textPromptName
); );
await assertUpdateSession( await assertUpdateSession(
sessionId, sessionId,
@@ -294,7 +294,7 @@ test('should fork session correctly', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
let forkedSessionId: string; let forkedSessionId: string;
@@ -363,7 +363,7 @@ test('should be able to use test provider', async t => {
const { id } = await createWorkspace(app); const { id } = await createWorkspace(app);
t.truthy( t.truthy(
await createCopilotSession(app, id, randomUUID(), promptName), await createCopilotSession(app, id, randomUUID(), textPromptName),
'failed to create session' 'failed to create session'
); );
}); });
@@ -379,7 +379,7 @@ test('should create message correctly', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const messageId = await createCopilotMessage(app, sessionId); const messageId = await createCopilotMessage(app, sessionId);
t.truthy(messageId, 'should be able to create message with valid session'); t.truthy(messageId, 'should be able to create message with valid session');
@@ -393,7 +393,7 @@ test('should create message correctly', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const messageId = await createCopilotMessage(app, sessionId, undefined, [ const messageId = await createCopilotMessage(app, sessionId, undefined, [
'http://example.com/cat.jpg', 'http://example.com/cat.jpg',
@@ -408,7 +408,7 @@ test('should create message correctly', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const smallestPng = const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII'; 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
@@ -445,7 +445,7 @@ test('should be able to chat with api', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const messageId = await createCopilotMessage(app, sessionId); const messageId = await createCopilotMessage(app, sessionId);
const ret = await chatWithText(app, sessionId, messageId); const ret = await chatWithText(app, sessionId, messageId);
@@ -543,7 +543,7 @@ test('should be able to retry with api', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const messageId = await createCopilotMessage(app, sessionId); const messageId = await createCopilotMessage(app, sessionId);
// chat 2 times // chat 2 times
@@ -565,7 +565,7 @@ test('should be able to retry with api', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const messageId = await createCopilotMessage(app, sessionId); const messageId = await createCopilotMessage(app, sessionId);
await chatWithText(app, sessionId, messageId); await chatWithText(app, sessionId, messageId);
@@ -587,7 +587,7 @@ test('should be able to retry with api', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const messageId = await createCopilotMessage(app, sessionId); const messageId = await createCopilotMessage(app, sessionId);
await chatWithText(app, sessionId, messageId); await chatWithText(app, sessionId, messageId);
@@ -614,13 +614,13 @@ test('should reject message from different session', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const anotherSessionId = await createCopilotSession( const anotherSessionId = await createCopilotSession(
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const anotherMessageId = await createCopilotMessage(app, anotherSessionId); const anotherMessageId = await createCopilotMessage(app, anotherSessionId);
await t.throwsAsync( await t.throwsAsync(
@@ -639,7 +639,7 @@ test('should reject request from different user', async t => {
app, app,
id, id,
randomUUID(), randomUUID(),
promptName textPromptName
); );
// should reject message from different user // should reject message from different user
@@ -677,7 +677,7 @@ test('should be able to list history', async t => {
app, app,
workspaceId, workspaceId,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const messageId = await createCopilotMessage(app, sessionId, 'hello'); const messageId = await createCopilotMessage(app, sessionId, 'hello');
@@ -740,7 +740,7 @@ test('should reject request that user have not permission', async t => {
app, app,
workspaceId, workspaceId,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const messageId = await createCopilotMessage(app, sessionId); const messageId = await createCopilotMessage(app, sessionId);
@@ -777,7 +777,7 @@ test('should be able to manage context', async t => {
app, app,
workspaceId, workspaceId,
randomUUID(), randomUUID(),
promptName textPromptName
); );
// use mocked embedding client // use mocked embedding client
@@ -859,7 +859,7 @@ test('should be able to manage context', async t => {
app, app,
workspaceId, workspaceId,
randomUUID(), randomUUID(),
promptName textPromptName
); );
const contextId = await createCopilotContext(app, workspaceId, sessionId); const contextId = await createCopilotContext(app, workspaceId, sessionId);
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { ProjectRoot } from '@affine-tools/utils/path'; import { ProjectRoot } from '@affine-tools/utils/path';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@@ -6,11 +7,11 @@ import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { EventBus } from '../base'; import { EventBus, JobQueue } from '../base';
import { ConfigModule } from '../base/config'; import { ConfigModule } from '../base/config';
import { AuthService } from '../core/auth'; import { AuthService } from '../core/auth';
import { QuotaModule } from '../core/quota'; import { QuotaModule } from '../core/quota';
import { ContextCategories } from '../models'; import { ContextCategories, WorkspaceModel } from '../models';
import { CopilotModule } from '../plugins/copilot'; import { CopilotModule } from '../plugins/copilot';
import { import {
CopilotContextDocJob, CopilotContextDocJob,
@@ -47,6 +48,7 @@ import {
} from '../plugins/copilot/workflow/executor'; } from '../plugins/copilot/workflow/executor';
import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils'; import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils';
import { WorkflowGraphList } from '../plugins/copilot/workflow/graph'; import { WorkflowGraphList } from '../plugins/copilot/workflow/graph';
import { CopilotWorkspaceService } from '../plugins/copilot/workspace';
import { MockCopilotProvider } from './mocks'; import { MockCopilotProvider } from './mocks';
import { createTestingModule, TestingModule } from './utils'; import { createTestingModule, TestingModule } from './utils';
import { WorkflowTestCases } from './utils/copilot'; import { WorkflowTestCases } from './utils/copilot';
@@ -56,9 +58,11 @@ const test = ava as TestFn<{
module: TestingModule; module: TestingModule;
db: PrismaClient; db: PrismaClient;
event: EventBus; event: EventBus;
workspace: WorkspaceModel;
context: CopilotContextService; context: CopilotContextService;
prompt: PromptService; prompt: PromptService;
transcript: CopilotTranscriptionService; transcript: CopilotTranscriptionService;
workspaceEmbedding: CopilotWorkspaceService;
factory: CopilotProviderFactory; factory: CopilotProviderFactory;
session: ChatSessionService; session: ChatSessionService;
jobs: CopilotContextDocJob; jobs: CopilotContextDocJob;
@@ -95,6 +99,8 @@ test.before(async t => {
CopilotModule, CopilotModule,
], ],
tapModule: builder => { tapModule: builder => {
// use real JobQueue for testing
builder.overrideProvider(JobQueue).useClass(JobQueue);
builder.overrideProvider(OpenAIProvider).useClass(MockCopilotProvider); builder.overrideProvider(OpenAIProvider).useClass(MockCopilotProvider);
}, },
}); });
@@ -102,6 +108,7 @@ test.before(async t => {
const auth = module.get(AuthService); const auth = module.get(AuthService);
const db = module.get(PrismaClient); const db = module.get(PrismaClient);
const event = module.get(EventBus); const event = module.get(EventBus);
const workspace = module.get(WorkspaceModel);
const prompt = module.get(PromptService); const prompt = module.get(PromptService);
const factory = module.get(CopilotProviderFactory); const factory = module.get(CopilotProviderFactory);
@@ -112,11 +119,13 @@ test.before(async t => {
const context = module.get(CopilotContextService); const context = module.get(CopilotContextService);
const jobs = module.get(CopilotContextDocJob); const jobs = module.get(CopilotContextDocJob);
const transcript = module.get(CopilotTranscriptionService); const transcript = module.get(CopilotTranscriptionService);
const workspaceEmbedding = module.get(CopilotWorkspaceService);
t.context.module = module; t.context.module = module;
t.context.auth = auth; t.context.auth = auth;
t.context.db = db; t.context.db = db;
t.context.event = event; t.context.event = event;
t.context.workspace = workspace;
t.context.prompt = prompt; t.context.prompt = prompt;
t.context.factory = factory; t.context.factory = factory;
t.context.session = session; t.context.session = session;
@@ -125,6 +134,7 @@ test.before(async t => {
t.context.context = context; t.context.context = context;
t.context.jobs = jobs; t.context.jobs = jobs;
t.context.transcript = transcript; t.context.transcript = transcript;
t.context.workspaceEmbedding = workspaceEmbedding;
t.context.executors = { t.context.executors = {
image: module.get(CopilotChatImageExecutor), image: module.get(CopilotChatImageExecutor),
@@ -1426,3 +1436,70 @@ test('should be able to manage context', async t => {
} }
} }
}); });
// ==================== workspace embedding ====================
test('should be able to manage workspace embedding', async t => {
const { db, jobs, workspace, workspaceEmbedding, context, prompt, session } =
t.context;
// use mocked embedding client
Sinon.stub(context, 'embeddingClient').get(() => new MockEmbeddingClient());
Sinon.stub(jobs, 'embeddingClient').get(() => new MockEmbeddingClient());
const ws = await workspace.create(userId);
// should create workspace embedding
{
const { blobId, file } = await workspaceEmbedding.addFile(userId, ws.id, {
filename: 'test.txt',
mimetype: 'text/plain',
encoding: 'utf-8',
createReadStream: () => {
return new Readable({
read() {
this.push(Buffer.from('content'));
this.push(null);
},
});
},
});
await workspaceEmbedding.queueFileEmbedding({
userId,
workspaceId: ws.id,
blobId,
fileId: file.fileId,
fileName: file.fileName,
});
let ret = 0;
while (!ret) {
await new Promise(resolve => setTimeout(resolve, 1000));
ret = await db.aiWorkspaceFileEmbedding.count({
where: { workspaceId: ws.id, fileId: file.fileId },
});
}
}
// should create workspace embedding with file
{
await prompt.set('prompt', 'model', [
{ role: 'system', content: 'hello {{word}}' },
]);
const sessionId = await session.create({
docId: 'test',
workspaceId: ws.id,
userId,
promptName: 'prompt',
});
const contextSession = await context.create(sessionId);
const ret = await contextSession.matchFileChunks('test', 1, undefined, 1);
t.is(ret.length, 1, 'should match workspace context');
t.is(ret[0].content, 'content', 'should match content');
await workspace.update(ws.id, { enableDocEmbedding: false });
const ret2 = await contextSession.matchFileChunks('test', 1, undefined, 1);
t.is(ret2.length, 0, 'should not match workspace context');
}
});
@@ -96,20 +96,16 @@ test('should insert embedding by doc id', async t => {
const { id: contextId } = await t.context.copilotContext.create(session.id); const { id: contextId } = await t.context.copilotContext.create(session.id);
{ {
await t.context.copilotContext.insertContentEmbedding( await t.context.copilotContext.insertFileEmbedding(contextId, 'file-id', [
contextId, {
'file-id', index: 0,
[ content: 'content',
{ embedding: Array.from({ length: 1024 }, () => 1),
index: 0, },
content: 'content', ]);
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
{ {
const ret = await t.context.copilotContext.matchContentEmbedding( const ret = await t.context.copilotContext.matchFileEmbedding(
Array.from({ length: 1024 }, () => 0.9), Array.from({ length: 1024 }, () => 0.9),
contextId, contextId,
1, 1,
@@ -121,7 +117,7 @@ test('should insert embedding by doc id', async t => {
{ {
await t.context.copilotContext.deleteEmbedding(contextId, 'file-id'); await t.context.copilotContext.deleteEmbedding(contextId, 'file-id');
const ret = await t.context.copilotContext.matchContentEmbedding( const ret = await t.context.copilotContext.matchFileEmbedding(
Array.from({ length: 1024 }, () => 0.9), Array.from({ length: 1024 }, () => 0.9),
contextId, contextId,
1, 1,
@@ -110,16 +110,20 @@ test('should insert and search embedding', async t => {
mimeType: 'text/plain', mimeType: 'text/plain',
size: 1, size: 1,
}); });
await t.context.copilotWorkspace.addFileEmbeddings(workspace.id, fileId, [ await t.context.copilotWorkspace.insertFileEmbeddings(
{ workspace.id,
index: 0, fileId,
content: 'content', [
embedding: Array.from({ length: 1024 }, () => 1), {
}, index: 0,
]); content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
{ {
const ret = await t.context.copilotWorkspace.matchWorkspaceFileEmbedding( const ret = await t.context.copilotWorkspace.matchFileEmbedding(
workspace.id, workspace.id,
Array.from({ length: 1024 }, () => 0.9), Array.from({ length: 1024 }, () => 0.9),
1, 1,
@@ -153,7 +153,7 @@ export class CopilotContextModel extends BaseModel {
return Prisma.join(groups.map(row => Prisma.sql`(${Prisma.join(row)})`)); return Prisma.join(groups.map(row => Prisma.sql`(${Prisma.join(row)})`));
} }
async insertContentEmbedding( async insertFileEmbedding(
contextId: string, contextId: string,
fileId: string, fileId: string,
embeddings: Embedding[] embeddings: Embedding[]
@@ -168,7 +168,7 @@ export class CopilotContextModel extends BaseModel {
`; `;
} }
async matchContentEmbedding( async matchFileEmbedding(
embedding: number[], embedding: number[],
contextId: string, contextId: string,
topK: number, topK: number,
@@ -138,7 +138,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
} }
@Transactional() @Transactional()
async addFileEmbeddings( async insertFileEmbeddings(
workspaceId: string, workspaceId: string,
fileId: string, fileId: string,
embeddings: Embedding[] embeddings: Embedding[]
@@ -151,7 +151,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
`; `;
} }
async listWorkspaceFiles( async listFiles(
workspaceId: string, workspaceId: string,
options?: { options?: {
includeRead?: boolean; includeRead?: boolean;
@@ -168,7 +168,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
return files; return files;
} }
async countWorkspaceFiles(workspaceId: string): Promise<number> { async countFiles(workspaceId: string): Promise<number> {
const count = await this.db.aiWorkspaceFiles.count({ const count = await this.db.aiWorkspaceFiles.count({
where: { where: {
workspaceId, workspaceId,
@@ -177,12 +177,16 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
return count; return count;
} }
async matchWorkspaceFileEmbedding( async matchFileEmbedding(
workspaceId: string, workspaceId: string,
embedding: number[], embedding: number[],
topK: number, topK: number,
threshold: number threshold: number
): Promise<FileChunkSimilarity[]> { ): Promise<FileChunkSimilarity[]> {
if (!(await this.allowEmbedding(workspaceId))) {
return [];
}
const similarityChunks = await this.db.$queryRaw< const similarityChunks = await this.db.$queryRaw<
Array<FileChunkSimilarity> Array<FileChunkSimilarity>
>` >`
@@ -195,7 +199,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
return similarityChunks.filter(c => Number(c.distance) <= threshold); return similarityChunks.filter(c => Number(c.distance) <= threshold);
} }
async removeWorkspaceFile(workspaceId: string, fileId: string) { async removeFile(workspaceId: string, fileId: string) {
// embeddings will be removed by foreign key constraint // embeddings will be removed by foreign key constraint
await this.db.aiWorkspaceFiles.deleteMany({ await this.db.aiWorkspaceFiles.deleteMany({
where: { where: {
@@ -205,4 +209,8 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
}); });
return true; return true;
} }
private allowEmbedding(workspaceId: string) {
return this.models.workspace.allowEmbedding(workspaceId);
}
} }
@@ -124,24 +124,38 @@ export class CopilotContextDocJob {
for (const chunk of chunks) { for (const chunk of chunks) {
const embeddings = await this.embeddingClient.generateEmbeddings(chunk); const embeddings = await this.embeddingClient.generateEmbeddings(chunk);
await this.models.copilotContext.insertContentEmbedding( if (contextId) {
contextId, // for context files
fileId, await this.models.copilotContext.insertFileEmbedding(
embeddings contextId,
); fileId,
embeddings
);
} else {
// for workspace files
await this.models.copilotWorkspace.insertFileEmbeddings(
workspaceId,
fileId,
embeddings
);
}
} }
this.event.emit('workspace.file.embed.finished', { if (contextId) {
contextId, this.event.emit('workspace.file.embed.finished', {
fileId, contextId,
chunkSize: total, fileId,
}); chunkSize: total,
});
}
} catch (error: any) { } catch (error: any) {
this.event.emit('workspace.file.embed.failed', { if (contextId) {
contextId, this.event.emit('workspace.file.embed.failed', {
fileId, contextId,
error: mapAnyError(error).message, fileId,
}); error: mapAnyError(error).message,
});
}
// passthrough error to job queue // passthrough error to job queue
throw error; throw error;
@@ -8,6 +8,7 @@ import {
ContextEmbedStatus, ContextEmbedStatus,
ContextFile, ContextFile,
ContextList, ContextList,
FileChunkSimilarity,
Models, Models,
} from '../../../models'; } from '../../../models';
import { EmbeddingClient } from './types'; import { EmbeddingClient } from './types';
@@ -176,18 +177,28 @@ export class ContextSession implements AsyncDisposable {
topK: number = 5, topK: number = 5,
signal?: AbortSignal, signal?: AbortSignal,
threshold: number = 0.7 threshold: number = 0.7
) { ): Promise<FileChunkSimilarity[]> {
const embedding = await this.client const embedding = await this.client
.getEmbeddings([content], signal) .getEmbeddings([content], signal)
.then(r => r?.[0]?.embedding); .then(r => r?.[0]?.embedding);
if (!embedding) return []; if (!embedding) return [];
return this.models.copilotContext.matchContentEmbedding( const [context, workspace] = await Promise.all([
embedding, this.models.copilotContext.matchFileEmbedding(
this.id, embedding,
topK, this.id,
threshold topK,
); threshold
),
this.models.copilotWorkspace.matchFileEmbedding(
this.workspaceId,
embedding,
topK,
threshold
),
]);
return this.client.reRank([...context, ...workspace]);
} }
/** /**
@@ -1,7 +1,7 @@
import { File } from 'node:buffer'; import { File } from 'node:buffer';
import { CopilotContextFileNotSupported } from '../../../base'; import { CopilotContextFileNotSupported } from '../../../base';
import { Embedding } from '../../../models'; import { ChunkSimilarity, Embedding } from '../../../models';
import { parseDoc } from '../../../native'; import { parseDoc } from '../../../native';
declare global { declare global {
@@ -36,7 +36,7 @@ declare global {
}; };
'copilot.embedding.files': { 'copilot.embedding.files': {
contextId: string; contextId?: string;
userId: string; userId: string;
workspaceId: string; workspaceId: string;
blobId: string; blobId: string;
@@ -114,6 +114,15 @@ export abstract class EmbeddingClient {
return embeddings.map(e => ({ ...e, index: chunks[e.index].index })); return embeddings.map(e => ({ ...e, index: chunks[e.index].index }));
} }
async reRank<Chunk extends ChunkSimilarity = ChunkSimilarity>(
embeddings: Chunk[]
): Promise<Chunk[]> {
// sort by distance with ascending order
return embeddings.sort(
(a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity)
);
}
abstract getEmbeddings( abstract getEmbeddings(
input: string[], input: string[],
signal?: AbortSignal signal?: AbortSignal
@@ -135,7 +135,7 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
@Parent() config: CopilotWorkspaceConfigType, @Parent() config: CopilotWorkspaceConfigType,
@Args('pagination', PaginationInput.decode) pagination: PaginationInput @Args('pagination', PaginationInput.decode) pagination: PaginationInput
): Promise<PaginatedCopilotWorkspaceFileType> { ): Promise<PaginatedCopilotWorkspaceFileType> {
const [files, totalCount] = await this.copilotWorkspace.listWorkspaceFiles( const [files, totalCount] = await this.copilotWorkspace.listFiles(
config.workspaceId, config.workspaceId,
pagination pagination
); );
@@ -177,12 +177,12 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
} }
try { try {
const { blobId, file } = await this.copilotWorkspace.addWorkspaceFile( const { blobId, file } = await this.copilotWorkspace.addFile(
user.id, user.id,
workspaceId, workspaceId,
content content
); );
await this.copilotWorkspace.addWorkspaceFileEmbeddingQueue({ await this.copilotWorkspace.queueFileEmbedding({
userId: user.id, userId: user.id,
workspaceId, workspaceId,
blobId, blobId,
@@ -219,6 +219,6 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
.workspace(workspaceId) .workspace(workspaceId)
.assert('Workspace.Settings.Update'); .assert('Workspace.Settings.Update');
return await this.copilotWorkspace.removeWorkspaceFile(workspaceId, fileId); return await this.copilotWorkspace.removeFile(workspaceId, fileId);
} }
} }
@@ -53,11 +53,7 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
]); ]);
} }
async addWorkspaceFile( async addFile(userId: string, workspaceId: string, content: FileUpload) {
userId: string,
workspaceId: string,
content: FileUpload
) {
const fileName = content.filename; const fileName = content.filename;
const buffer = await readStream(content.createReadStream()); const buffer = await readStream(content.createReadStream());
const blobId = createHash('sha256').update(buffer).digest('base64url'); const blobId = createHash('sha256').update(buffer).digest('base64url');
@@ -70,29 +66,25 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
return { blobId, file }; return { blobId, file };
} }
async getWorkspaceFile(workspaceId: string, fileId: string) { async getFile(workspaceId: string, fileId: string) {
return await this.models.copilotWorkspace.getFile(workspaceId, fileId); return await this.models.copilotWorkspace.getFile(workspaceId, fileId);
} }
async listWorkspaceFiles( async listFiles(
workspaceId: string, workspaceId: string,
pagination?: { pagination?: {
includeRead?: boolean; includeRead?: boolean;
} & PaginationInput } & PaginationInput
) { ) {
return await Promise.all([ return await Promise.all([
this.models.copilotWorkspace.listWorkspaceFiles(workspaceId, pagination), this.models.copilotWorkspace.listFiles(workspaceId, pagination),
this.models.copilotWorkspace.countIgnoredDocs(workspaceId), this.models.copilotWorkspace.countFiles(workspaceId),
]); ]);
} }
async addWorkspaceFileEmbeddingQueue( async queueFileEmbedding(file: Jobs['copilot.embedding.files']) {
file: Jobs['copilot.workspace.embedding.files']
) {
if (!this.supportEmbedding) return;
const { userId, workspaceId, blobId, fileId, fileName } = file; const { userId, workspaceId, blobId, fileId, fileName } = file;
await this.queue.add('copilot.workspace.embedding.files', { await this.queue.add('copilot.embedding.files', {
userId, userId,
workspaceId, workspaceId,
blobId, blobId,
@@ -101,10 +93,7 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
}); });
} }
async removeWorkspaceFile(workspaceId: string, fileId: string) { async removeFile(workspaceId: string, fileId: string) {
return await this.models.copilotWorkspace.removeWorkspaceFile( return await this.models.copilotWorkspace.removeFile(workspaceId, fileId);
workspaceId,
fileId
);
} }
} }
@@ -13,15 +13,6 @@ declare global {
jobId: string; jobId: string;
}; };
} }
interface Jobs {
'copilot.workspace.embedding.files': {
userId: string;
workspaceId: string;
blobId: string;
fileId: string;
fileName: string;
};
}
} }
@ObjectType('CopilotWorkspaceIgnoredDoc') @ObjectType('CopilotWorkspaceIgnoredDoc')