Files
AFFiNE/packages/backend/server/src/__tests__/models/copilot-context.spec.ts
DarkSky feb42e34be feat(server): attachment embedding (#13348)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for managing "blobs" in Copilot context, including
adding and removing blobs via new GraphQL mutations and UI fields.
* Introduced tracking and querying of blob embeddings within workspaces,
enabling search and similarity matching for blob content.
* Extended Copilot context and workspace APIs, schema, and UI to display
and manage blobs alongside existing documents and files.

* **Bug Fixes**
* Updated context and embedding status logic to handle blobs, ensuring
accurate status reporting and embedding management.

* **Tests**
* Added and updated test cases and snapshots to cover blob embedding
insertion, matching, and removal scenarios.

* **Documentation**
* Updated GraphQL schema and TypeScript types to reflect new
blob-related fields and mutations.

* **Chores**
* Refactored and cleaned up code to support new blob entity and
embedding logic, including renaming and updating internal methods and
types.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-30 22:07:28 +00:00

419 lines
11 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { PrismaClient, User, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { Config } from '../../base';
import {
ContextEmbedStatus,
CopilotContextModel,
CopilotSessionModel,
CopilotWorkspaceConfigModel,
UserModel,
WorkspaceModel,
} from '../../models';
import { createTestingModule, type TestingModule } from '../utils';
import { cleanObject } from '../utils/copilot';
interface Context {
config: Config;
module: TestingModule;
db: PrismaClient;
user: UserModel;
workspace: WorkspaceModel;
copilotSession: CopilotSessionModel;
copilotContext: CopilotContextModel;
copilotWorkspace: CopilotWorkspaceConfigModel;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule();
t.context.user = module.get(UserModel);
t.context.workspace = module.get(WorkspaceModel);
t.context.copilotSession = module.get(CopilotSessionModel);
t.context.copilotContext = module.get(CopilotContextModel);
t.context.copilotWorkspace = module.get(CopilotWorkspaceConfigModel);
t.context.db = module.get(PrismaClient);
t.context.config = module.get(Config);
t.context.module = module;
});
let user: User;
let workspace: Workspace;
let sessionId: string;
let docId = 'doc1';
test.beforeEach(async t => {
await t.context.module.initTestingDB();
await t.context.copilotSession.createPrompt('prompt-name', 'gpt-4.1');
user = await t.context.user.create({
email: 'test@affine.pro',
});
workspace = await t.context.workspace.create(user.id);
sessionId = await t.context.copilotSession.create({
sessionId: randomUUID(),
workspaceId: workspace.id,
docId,
userId: user.id,
title: null,
promptName: 'prompt-name',
promptAction: null,
});
});
test.after(async t => {
await t.context.module.close();
});
test('should create a copilot context', async t => {
const { id: contextId } = await t.context.copilotContext.create(sessionId);
t.truthy(contextId);
const context = await t.context.copilotContext.get(contextId);
t.is(context?.id, contextId, 'should get context by id');
const config = await t.context.copilotContext.getConfig(contextId);
t.is(config?.workspaceId, workspace.id, 'should get context config');
const context1 = await t.context.copilotContext.getBySessionId(sessionId);
t.is(context1?.id, contextId, 'should get context by session id');
});
test('should get null for non-exist job', async t => {
const job = await t.context.copilotContext.get('non-exist');
t.snapshot(job, 'should return null for non-exist job');
});
test('should update context', async t => {
const { id: contextId } = await t.context.copilotContext.create(sessionId);
const config = (await t.context.copilotContext.getConfig(contextId))!;
t.assert(config, 'should get context config');
const doc = {
id: docId,
createdAt: Date.now(),
};
config.docs.push(doc);
await t.context.copilotContext.update(contextId, { config });
const config1 = await t.context.copilotContext.getConfig(contextId);
t.deepEqual(config1, config);
});
test('should insert embedding by doc id', async t => {
const { id: contextId } = await t.context.copilotContext.create(sessionId);
{
await t.context.copilotContext.insertFileEmbedding(contextId, 'file-id', [
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]);
{
const ret = await t.context.copilotContext.matchFileEmbedding(
Array.from({ length: 1024 }, () => 0.9),
contextId,
1,
1
);
t.snapshot(
cleanObject(ret, ['chunk', 'content', 'distance']),
'should match file embedding'
);
}
{
await t.context.copilotContext.deleteFileEmbedding(contextId, 'file-id');
const ret = await t.context.copilotContext.matchFileEmbedding(
Array.from({ length: 1024 }, () => 0.9),
contextId,
1,
1
);
t.snapshot(ret, 'should return empty array when embedding is deleted');
}
}
{
await t.context.db.snapshot.create({
data: {
workspaceId: workspace.id,
id: docId,
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
updatedAt: new Date(),
createdAt: new Date(),
},
});
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
docId,
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
{
const ret = await t.context.copilotContext.listWorkspaceDocEmbedding(
workspace.id,
[docId]
);
t.true(
ret.includes(docId),
'should return doc id when embedding is inserted'
);
}
{
const ret = await t.context.copilotContext.matchWorkspaceEmbedding(
Array.from({ length: 1024 }, () => 0.9),
workspace.id,
1,
1
);
t.snapshot(
cleanObject(ret, ['chunk', 'content', 'distance']),
'should match workspace embedding'
);
}
{
await t.context.copilotWorkspace.updateIgnoredDocs(workspace.id, [docId]);
const ret = await t.context.copilotContext.matchWorkspaceEmbedding(
Array.from({ length: 1024 }, () => 0.9),
workspace.id,
1,
1
);
t.snapshot(ret, 'should return empty array when doc is ignored');
}
{
await t.context.copilotWorkspace.updateIgnoredDocs(
workspace.id,
undefined,
[docId]
);
const ret = await t.context.copilotContext.matchWorkspaceEmbedding(
Array.from({ length: 1024 }, () => 0.9),
workspace.id,
1,
1
);
t.snapshot(
cleanObject(ret, ['chunk', 'content', 'distance']),
'should return workspace embedding'
);
}
{
await t.context.copilotContext.deleteWorkspaceEmbedding(
workspace.id,
docId
);
const ret = await t.context.copilotContext.matchWorkspaceEmbedding(
Array.from({ length: 1024 }, () => 0.9),
workspace.id,
1,
1
);
t.snapshot(ret, 'should return empty array when embedding deleted');
}
}
});
test('should check embedding table', async t => {
{
const ret = await t.context.copilotContext.checkEmbeddingAvailable();
t.snapshot(ret, 'should return true when embedding table is available');
}
// {
// await t.context.db
// .$executeRaw`DROP TABLE IF EXISTS "ai_context_embeddings"`;
// const ret = await t.context.copilotContext.checkEmbeddingAvailable();
// t.false(ret, 'should return false when embedding table is not available');
// }
});
test('should merge doc status correctly', async t => {
const createDoc = (id: string, status?: string) => ({
id,
createdAt: Date.now(),
...(status && { status: status as any }),
});
const createDocWithEmbedding = async (docId: string) => {
await t.context.db.snapshot.create({
data: {
workspaceId: workspace.id,
id: docId,
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
updatedAt: new Date(),
createdAt: new Date(),
},
});
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
docId,
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
};
const emptyResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
[]
);
t.deepEqual(emptyResult, []);
const basicDocs = [
createDoc('doc1'),
createDoc('doc2'),
createDoc('doc3', 'failed'),
createDoc('doc4', 'processing'),
];
const basicResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
basicDocs
);
t.snapshot(
basicResult.map(d => ({ id: d.id, status: d.status })),
'basic doc status merge'
);
{
await createDocWithEmbedding('doc5');
const mixedDocs = [
createDoc('doc5'),
createDoc('doc5', 'processing'),
createDoc('doc6'),
createDoc('doc6', 'failed'),
createDoc('doc7'),
];
const mixedResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
mixedDocs
);
t.snapshot(
mixedResult.map(d => ({ id: d.id, status: d.status })),
'mixed doc status merge'
);
const hasEmbeddingStub = Sinon.stub(
t.context.copilotContext,
'listWorkspaceDocEmbedding'
).resolves([]);
const stubResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
[createDoc('doc5')]
);
t.is(stubResult[0].status, ContextEmbedStatus.processing);
hasEmbeddingStub.restore();
}
{
const testCases = [
{
workspaceId: 'invalid-workspace',
docs: [{ id: 'doc1', createdAt: Date.now() }],
},
{
workspaceId: workspace.id,
docs: [{ id: 'doc1', createdAt: Date.now(), status: undefined as any }],
},
{
workspaceId: workspace.id,
docs: Array.from({ length: 100 }, (_, i) => ({
id: `doc-${i}`,
createdAt: Date.now() + i,
})),
},
];
const results = await Promise.all(
testCases.map(testCase =>
t.context.copilotContext.mergeDocStatus(
testCase.workspaceId,
testCase.docs
)
)
);
t.snapshot(
results.map((result, index) => ({
case: index,
length: result.length,
statuses: result.map(d => d.status),
})),
'edge cases results'
);
}
});
test('should handle concurrent mergeDocStatus calls', async t => {
await t.context.db.snapshot.create({
data: {
workspaceId: workspace.id,
id: 'concurrent-doc',
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
updatedAt: new Date(),
createdAt: new Date(),
},
});
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
'concurrent-doc',
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
const concurrentDocs = [
[{ id: 'concurrent-doc', createdAt: Date.now() }],
[{ id: 'concurrent-doc', createdAt: Date.now() + 1000 }],
[{ id: 'non-existent-doc', createdAt: Date.now() }],
];
const results = await Promise.all(
concurrentDocs.map(docs =>
t.context.copilotContext.mergeDocStatus(workspace.id, docs)
)
);
t.snapshot(
results.map((result, index) => ({
call: index + 1,
status: result[0].status,
})),
'concurrent calls results'
);
});