import { DyNTS_LVS_VectorDataService } from './lvs-local-vector-search.data-service'; import { DyFM_DataModel_Params, DyFM_Metadata, DyFM_BasicProperty_Type, DyFM_EnvironmentFlag } from '@futdevpro/fsm-dynamo'; import { DyFM_OAI_Settings, DyFM_OAI_Model } from '@futdevpro/fsm-dynamo/ai/open-ai'; import { LVS_Search_Mode } from '../_enums/lvs-search-mode.enum'; import { LVS_VectorPool_ControlService } from './lvs-vector-pool.control-service'; import { DyFM_Error } from '@futdevpro/fsm-dynamo'; import { DyNTS_global_settings } from '../../../_collections/global-settings.const'; import { DyNTS_GlobalService } from '../../../_services/core/global.service'; class TestDataModel extends DyFM_Metadata { content: string = ''; contentVectorized?: number[]; } const testDataParams: DyFM_DataModel_Params = new DyFM_DataModel_Params({ dataName: 'test-data', properties: { content: { key: 'content', type: DyFM_BasicProperty_Type.string }, contentVectorized: { key: 'contentVectorized', type: DyFM_BasicProperty_Type.array, vectorizedFrom: ['content'], embeddingModel: DyFM_OAI_Model.textEmbedding_3Small, }, }, }); describe('| DyNTS_LVS_VectorDataService', () => { let service: DyNTS_LVS_VectorDataService; let mockOpenAISettings: DyFM_OAI_Settings; let testData: TestDataModel; let mockDBService: jasmine.SpyObj<{ find: (f: unknown) => Promise; getAll: () => Promise; getDataById: (id: string) => Promise; }>; beforeAll(() => { if (!DyNTS_global_settings.systemShortCodeName) { (DyNTS_global_settings as { systemShortCodeName?: string }).systemShortCodeName = 'TEST'; } if (!DyNTS_global_settings.env_settings) { (DyNTS_global_settings as { env_settings?: unknown }).env_settings = { environment: DyFM_EnvironmentFlag.local, }; } }); beforeEach(() => { mockDBService = jasmine.createSpyObj('DyNTS_DBService', [ 'find', 'findOne', 'getDataById', 'getAll', 'getDataListByIds', 'getDataByDependencyId', 'getDataListByDependencyId', 'getDataListByDependencyIds', 'createData', 'modifyData', 'updateOne', 'markDeletedById', 'trueDeleteDataById', 'trueDeleteAllData', 'restoreDeletedById', 'aggregate', ]); spyOn(DyNTS_GlobalService, 'getDBService').and.returnValue(mockDBService as never); spyOn(DyNTS_GlobalService, 'getDBServiceByKey').and.returnValue(mockDBService as never); mockOpenAISettings = { config: { apiKey: 'test-api-key', organization: 'test-org' }, defaultSettings: { useModel: DyFM_OAI_Model.textEmbedding_3Small }, }; testData = new TestDataModel(); service = new DyNTS_LVS_VectorDataService( testData, testDataParams, mockOpenAISettings, 'test-issuer' ); }); describe('| constructor', () => { it('| should initialize with default search mode', () => { expect(service.defaultSearchMode).toBe(LVS_Search_Mode.cosineSimilarity); }); it('| should initialize with L2 normalization enabled', () => { expect(service.useL2Normalization).toBe(true); }); it('| should initialize vector pool', () => { expect((service as any).vectorPool).toBeInstanceOf(LVS_VectorPool_ControlService); }); }); describe('| vectorSearch', () => { it('| should throw error when input is missing', async () => { try { await service.vectorSearch({ input: '', searchInKey: 'contentVectorized', }); fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(DyFM_Error); expect((err as DyFM_Error)._errorCode).toContain('DyNTS-LVS-VS1'); } }); it('| should throw error when searchInKey property not found', async () => { try { await service.vectorSearch({ input: 'test query', searchInKey: 'nonExistentKey', }); fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(DyFM_Error); expect((err as DyFM_Error)._errorCode).toContain('DyNTS-LVS-VS2'); } }); it('| should throw error when searchInKey is not vectorized', async () => { try { await service.vectorSearch({ input: 'test query', searchInKey: 'content', }); fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(DyFM_Error); expect((err as DyFM_Error)._errorCode).toContain('DyNTS-LVS-VS3'); } }); it('| should return empty array when no data found', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve([])); const result = await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', }); expect(result).toEqual([]); }); it('| should perform vector search with filterBy', async () => { const mockData1: TestDataModel = new TestDataModel(); mockData1._id = 'data-1'; mockData1.content = 'Test content 1'; mockData1.contentVectorized = [0.1, 0.2, 0.3]; const mockData2: TestDataModel = new TestDataModel(); mockData2._id = 'data-2'; mockData2.content = 'Test content 2'; mockData2.contentVectorized = [0.4, 0.5, 0.6]; spyOn(service, 'findDataList').and.returnValue(Promise.resolve([mockData1, mockData2])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.1, 0.2, 0.3])); const result = await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', filterBy: { content: 'Test' }, limit: 2, }); expect(service.findDataList).toHaveBeenCalled(); expect(result).toBeDefined(); }); it('| should perform vector search without filterBy', async () => { const mockData: TestDataModel = new TestDataModel(); mockData._id = 'data-1'; mockData.content = 'Test content'; mockData.contentVectorized = [0.1, 0.2, 0.3]; spyOn(service, 'getAll').and.returnValue(Promise.resolve([mockData])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.1, 0.2, 0.3])); const result = await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', limit: 1, }); expect(service.getAll).toHaveBeenCalled(); expect(result).toBeDefined(); }); it('| should use default limit of 3 when not provided', async () => { const mockData: TestDataModel = new TestDataModel(); mockData._id = 'data-1'; mockData.content = 'Test content'; mockData.contentVectorized = [0.1, 0.2, 0.3]; spyOn(service, 'getAll').and.returnValue(Promise.resolve([mockData])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.1, 0.2, 0.3])); spyOn((service as any).vectorPool, 'search').and.returnValue([ { id: 'data-1', score: 0.9 }, ]); await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', }); expect((service as any).vectorPool.search).toHaveBeenCalledWith( jasmine.any(Array), 3, LVS_Search_Mode.cosineSimilarity ); }); it('| should use custom search mode when provided', async () => { const mockData: TestDataModel = new TestDataModel(); mockData._id = 'data-1'; mockData.content = 'Test content'; mockData.contentVectorized = [0.1, 0.2, 0.3]; spyOn(service, 'getAll').and.returnValue(Promise.resolve([mockData])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.1, 0.2, 0.3])); spyOn((service as any).vectorPool, 'search').and.returnValue([ { id: 'data-1', score: 0.5 }, ]); await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.l2Distance, }); expect((service as any).vectorPool.search).toHaveBeenCalledWith( jasmine.any(Array), 3, LVS_Search_Mode.l2Distance ); }); it('| should skip items without _id', async () => { const mockData1: TestDataModel = new TestDataModel(); mockData1._id = 'data-1'; mockData1.content = 'Test content 1'; mockData1.contentVectorized = [0.1, 0.2, 0.3]; const mockData2: TestDataModel = new TestDataModel(); // No _id mockData2.content = 'Test content 2'; mockData2.contentVectorized = [0.4, 0.5, 0.6]; spyOn(service, 'getAll').and.returnValue(Promise.resolve([mockData1, mockData2])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.1, 0.2, 0.3])); spyOn((service as any).vectorPool, 'addVector'); await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', }); expect((service as any).vectorPool.addVector).toHaveBeenCalledTimes(1); expect((service as any).vectorPool.addVector).toHaveBeenCalledWith('data-1', [0.1, 0.2, 0.3]); }); it('| should skip items without vectorized value', async () => { const mockData1: TestDataModel = new TestDataModel(); mockData1._id = 'data-1'; mockData1.content = 'Test content 1'; mockData1.contentVectorized = [0.1, 0.2, 0.3]; const mockData2: TestDataModel = new TestDataModel(); mockData2._id = 'data-2'; mockData2.content = 'Test content 2'; // No contentVectorized spyOn(service, 'getAll').and.returnValue(Promise.resolve([mockData1, mockData2])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.1, 0.2, 0.3])); spyOn((service as any).vectorPool, 'addVector'); await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', }); expect((service as any).vectorPool.addVector).toHaveBeenCalledTimes(1); expect((service as any).vectorPool.addVector).toHaveBeenCalledWith('data-1', [0.1, 0.2, 0.3]); }); it('| should clear vector pool after search', async () => { const mockData: TestDataModel = new TestDataModel(); mockData._id = 'data-1'; mockData.content = 'Test content'; mockData.contentVectorized = [0.1, 0.2, 0.3]; spyOn(service, 'getAll').and.returnValue(Promise.resolve([mockData])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.1, 0.2, 0.3])); spyOn((service as any).vectorPool, 'clearPool'); spyOn((service as any).vectorPool, 'search').and.returnValue([]); await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', }); expect((service as any).vectorPool.clearPool).toHaveBeenCalled(); }); it('| should clear vector pool on error', async () => { spyOn(service, 'getAll').and.returnValue(Promise.reject(new Error('Database error'))); spyOn((service as any).vectorPool, 'clearPool'); try { await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', }); fail('Should have thrown an error'); } catch (err) { expect((service as any).vectorPool.clearPool).toHaveBeenCalled(); expect(err).toBeInstanceOf(DyFM_Error); expect((err as DyFM_Error)._errorCode).toContain('DyNTS-LVS-VS0'); } }); it('| should map search results back to data objects', async () => { const mockData1: TestDataModel = new TestDataModel(); mockData1._id = 'data-1'; mockData1.content = 'Test content 1'; mockData1.contentVectorized = [0.1, 0.2, 0.3]; const mockData2: TestDataModel = new TestDataModel(); mockData2._id = 'data-2'; mockData2.content = 'Test content 2'; mockData2.contentVectorized = [0.4, 0.5, 0.6]; spyOn(service, 'getAll').and.returnValue(Promise.resolve([mockData1, mockData2])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.1, 0.2, 0.3])); spyOn((service as any).vectorPool, 'search').and.returnValue([ { id: 'data-2', score: 0.9 }, { id: 'data-1', score: 0.8 }, ]); const result = await service.vectorSearch({ input: 'test query', searchInKey: 'contentVectorized', limit: 2, }); expect(result.length).toBe(2); expect(result[0]._id).toBe('data-2'); expect(result[1]._id).toBe('data-1'); }); }); describe('| vectorSearch hybrid (FR-004)', () => { const buildHybridCorpus = (): TestDataModel[] => { const d1: TestDataModel = new TestDataModel(); d1._id = 'doc-user'; d1.content = 'the UserController handles authentication flow'; d1.contentVectorized = [0.4, 0.5, 0.6]; const d2: TestDataModel = new TestDataModel(); d2._id = 'doc-recipe'; d2.content = 'cooking recipes for desserts and cakes'; d2.contentVectorized = [0.45, 0.55, 0.65]; const d3: TestDataModel = new TestDataModel(); d3._id = 'doc-db'; d3.content = 'database setup guide for MongoDB'; d3.contentVectorized = [0.42, 0.52, 0.62]; return [d1, d2, d3]; }; it('| throws ha textSearchKey hianyzik hybrid modban (VS4)', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve(buildHybridCorpus())); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.4, 0.5, 0.6])); try { await service.vectorSearch({ input: 'UserController', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.hybrid, }); fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(DyFM_Error); expect((err as DyFM_Error)._errorCode).toContain('DyNTS-LVS-VS4'); } }); it('| throws ha hybridWeight invalid (negativ) (VS5)', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve(buildHybridCorpus())); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.4, 0.5, 0.6])); try { await service.vectorSearch({ input: 'UserController', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.hybrid, textSearchKey: 'content', hybridWeight: { vector: -0.5, text: 1.5 }, }); fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(DyFM_Error); expect((err as DyFM_Error)._errorCode).toContain('DyNTS-LVS-VS5'); } }); it('| basic hybrid: text-relevant doc top-en', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve(buildHybridCorpus())); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.4, 0.5, 0.6])); const result: TestDataModel[] = await service.vectorSearch({ input: 'UserController', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.hybrid, textSearchKey: 'content', limit: 3, }); expect(result.length).toBe(3); expect(result[0]._id).toBe('doc-user'); }); it('| weight {vector:1, text:0} → effektivan pure cosine', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve(buildHybridCorpus())); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.42, 0.52, 0.62])); const result: TestDataModel[] = await service.vectorSearch({ input: 'UserController', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.hybrid, textSearchKey: 'content', hybridWeight: { vector: 1, text: 0 }, limit: 3, }); expect(result.length).toBe(3); expect(result[0]._id).toBe('doc-db'); }); it('| weight {vector:0, text:1} → effektivan pure BM25', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve(buildHybridCorpus())); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.45, 0.55, 0.65])); const result: TestDataModel[] = await service.vectorSearch({ input: 'authentication', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.hybrid, textSearchKey: 'content', hybridWeight: { vector: 0, text: 1 }, limit: 3, }); expect(result.length).toBe(3); expect(result[0]._id).toBe('doc-user'); }); it('| all-zero BM25 fallback → cosine-rendezes marad', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve(buildHybridCorpus())); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.45, 0.55, 0.65])); const result: TestDataModel[] = await service.vectorSearch({ input: 'xyzzy-nonexistent-token', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.hybrid, textSearchKey: 'content', limit: 3, }); expect(result.length).toBe(3); expect(result[0]._id).toBe('doc-recipe'); }); it('| limit honored hybrid modban', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve(buildHybridCorpus())); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.4, 0.5, 0.6])); const result: TestDataModel[] = await service.vectorSearch({ input: 'UserController', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.hybrid, textSearchKey: 'content', limit: 1, }); expect(result.length).toBe(1); }); it('| ures candidate-szet → ures eredmeny', async () => { spyOn(service, 'getAll').and.returnValue(Promise.resolve([])); spyOn(service, 'vectorize').and.returnValue(Promise.resolve([0.4, 0.5, 0.6])); const result: TestDataModel[] = await service.vectorSearch({ input: 'UserController', searchInKey: 'contentVectorized', searchMode: LVS_Search_Mode.hybrid, textSearchKey: 'content', }); expect(result.length).toBe(0); }); }); });