diff --git a/examples/01-agent-code-skill/04.1-chat-planner-coder.ts b/examples/01-agent-code-skill/04.1-chat-planner-coder.ts index b85aa577d..c827bd7c3 100644 --- a/examples/01-agent-code-skill/04.1-chat-planner-coder.ts +++ b/examples/01-agent-code-skill/04.1-chat-planner-coder.ts @@ -1,7 +1,7 @@ import { Agent, Chat, Component, Model, TAgentMode, TLLMEvent } from '@smythos/sdk'; import chalk from 'chalk'; import * as readline from 'readline'; -import { EmitUnit, PluginBase, TokenLoom } from 'tokenloom'; +import { EmitUnit, PluginAPI, PluginBase, TokenLoom } from 'tokenloom'; //Show the tasks list and status to the user at every step before performing the tasks, and also give a tasks status summary after tasks. //When you display the tasks list to a user show it in a concise way with a summary and checkboxes for each task. diff --git a/examples/05-VectorDB-with-agent/01-upsert-and-search.ts b/examples/05-VectorDB-with-agent/01-upsert-and-search.ts index 786d3199b..b75a7f5f1 100644 --- a/examples/05-VectorDB-with-agent/01-upsert-and-search.ts +++ b/examples/05-VectorDB-with-agent/01-upsert-and-search.ts @@ -1,4 +1,4 @@ -import { Agent, Doc, Model, Scope } from '@smythos/sdk'; +import { Agent, Doc, Model, Scope, TLLMEvent } from '@smythos/sdk'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -16,6 +16,7 @@ const pineconeSettings = { indexName: 'demo-vec', apiKey: process.env.PINECONE_API_KEY, embeddings: Model.OpenAI('text-embedding-3-large'), + //you can also use Model.GoogleAI('gemini-embedding-001', { dimensions: 1024 }) }; async function createAgent() { @@ -83,14 +84,25 @@ async function indexDataForAgent(agent: Agent) { await pinecone.insertDoc(parsedDoc.title, parsedDoc, { myEntry: 'My Metadata' }); } +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + async function main() { const agent = await createAgent(); + console.log('Indexing data for agent'); await indexDataForAgent(agent); + console.log('Waiting for 5 seconds before prompting the agent ... sometimes the index is not ready immediately'); + await delay(5000); + + console.log('Prompting the agent'); + //this will prompt the agent and use the agent's LLM to determine which skill to use - const promptResult = await agent.prompt('What is bitcoin Proof-of-Work ?'); + const promptStream = await agent.prompt('What is bitcoin Proof-of-Work ?').stream(); //the response comes back in natural language - console.log(promptResult); + console.log('\n'); + promptStream.on(TLLMEvent.Content, (content) => { + process.stdout.write(content); + }); } main(); diff --git a/examples/06-Storage-no-agent/01-localstorage.ts b/examples/06-Storage-no-agent/01-localstorage.ts index 04ebc4158..45f04ce02 100644 --- a/examples/06-Storage-no-agent/01-localstorage.ts +++ b/examples/06-Storage-no-agent/01-localstorage.ts @@ -2,7 +2,7 @@ import { Storage } from '@smythos/sdk'; async function main() { const localStorage = Storage.LocalStorage(); - + await localStorage.write('test.txt', 'Hello, world!'); const data = await localStorage.read('test.txt'); @@ -10,8 +10,6 @@ async function main() { const dataAsString = data.toString(); console.log(dataAsString); - - } -main(); \ No newline at end of file +main(); diff --git a/examples/11-zoom-rtms-integration/types.d.ts b/examples/11-zoom-rtms-integration/types.d.ts index c944e54d2..d7e5a7fd3 100644 --- a/examples/11-zoom-rtms-integration/types.d.ts +++ b/examples/11-zoom-rtms-integration/types.d.ts @@ -1,88 +1,71 @@ // Global type declarations for Node.js environment declare global { - namespace NodeJS { - interface ProcessEnv { - PORT?: string; - ZOOM_SECRET_TOKEN?: string; - ZOOM_CLIENT_ID?: string; - ZOOM_CLIENT_SECRET?: string; - WEBHOOK_PATH?: string; - OPENAI_API_KEY?: string; - ANTHROPIC_API_KEY?: string; - PINECONE_API_KEY?: string; - PINECONE_INDEX_NAME?: string; - AWS_ACCESS_KEY_ID?: string; - AWS_SECRET_ACCESS_KEY?: string; - AWS_REGION?: string; - AWS_S3_BUCKET?: string; - LOG_LEVEL?: string; + namespace NodeJS { + interface ProcessEnv { + PORT?: string; + ZOOM_SECRET_TOKEN?: string; + ZOOM_CLIENT_ID?: string; + ZOOM_CLIENT_SECRET?: string; + WEBHOOK_PATH?: string; + OPENAI_API_KEY?: string; + ANTHROPIC_API_KEY?: string; + PINECONE_API_KEY?: string; + PINECONE_INDEX_NAME?: string; + AWS_ACCESS_KEY_ID?: string; + AWS_SECRET_ACCESS_KEY?: string; + AWS_REGION?: string; + AWS_S3_BUCKET?: string; + LOG_LEVEL?: string; + } } - } - var process: NodeJS.Process; - var console: Console; - var Buffer: BufferConstructor; -} - -// Module declarations for packages that might not have types -declare module '@smythos/sdk' { - export class Agent { - constructor(config: any); - addSkill(skill: any): void; - prompt(message: string): Promise; - llm: any; - storage: any; - vectordb: any; - } - - export class Model { - static OpenAI(model: string): any; - static Anthropic(model: string): any; - } + var process: NodeJS.Process; + var console: Console; + var Buffer: BufferConstructor; } declare module 'crypto' { - export function createHmac(algorithm: string, key: string): any; + export function createHmac(algorithm: string, key: string): any; } declare module 'ws' { - export default class WebSocket { - constructor(url: string, options?: any); - on(event: string, callback: Function): void; - send(data: string): void; - close(): void; - } + export default class WebSocket { + constructor(url: string, options?: any); + on(event: string, callback: Function): void; + send(data: string): void; + close(): void; + } } declare module 'express' { - export interface Request { - body: any; - } - - export interface Response { - json(data: any): void; - sendStatus(code: number): void; - } - - interface Express { - use(middleware: any): void; - post(path: string, handler: any): void; - get(path: string, handler: any): void; - listen(port: string | number, callback?: () => void): void; - } - - interface ExpressStatic { - (): Express; - json(): any; - } - - const express: ExpressStatic; - export default express; - export { Request, Response }; + export interface Request { + body: any; + } + + export interface Response { + json(data: any): void; + sendStatus(code: number): void; + } + + interface Express { + use(middleware: any): void; + post(path: string, handler: any): void; + get(path: string, handler: any): void; + listen(port: string | number, callback?: () => void): void; + } + + interface ExpressStatic { + (): Express; + json(): any; + } + + const express: ExpressStatic; + export default express; + export { Request, Response }; } declare module 'dotenv' { - export function config(): void; + export function config(): void; } export {}; diff --git a/packages/core/package.json b/packages/core/package.json index f96986d29..384de7c3d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@smythos/sre", - "version": "1.5.67", + "version": "1.5.68", "description": "Smyth Runtime Environment", "author": "Alaa-eddine KADDOURI", "license": "MIT", diff --git a/packages/core/src/helpers/Conversation.helper.ts b/packages/core/src/helpers/Conversation.helper.ts index bb8aa840f..308fc4d74 100644 --- a/packages/core/src/helpers/Conversation.helper.ts +++ b/packages/core/src/helpers/Conversation.helper.ts @@ -282,7 +282,7 @@ export class Conversation extends EventEmitter { const reqMethods = this._reqMethods; const toolsConfig = this._toolsConfig; //deduplicate tools - toolsConfig.tools = toolsConfig.tools.filter((tool, index, self) => self.findIndex((t) => t.name === tool.name) === index); + toolsConfig.tools = toolsConfig.tools.filter((tool, index, self) => self.findIndex((t) => t.function.name === tool.function.name) === index); const endpoints = this._endpoints; const baseUrl = this._baseUrl; const message_id = 'msg_' + randomUUID(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 81c3eab98..bbc1c2db7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -166,6 +166,7 @@ export * from './subsystems/IO/VectorDB.service/connectors/MilvusVectorDB.class' export * from './subsystems/IO/VectorDB.service/connectors/PineconeVectorDB.class'; export * from './subsystems/IO/VectorDB.service/connectors/RAMVecrtorDB.class'; export * from './subsystems/IO/VectorDB.service/embed/BaseEmbedding'; +export * from './subsystems/IO/VectorDB.service/embed/GoogleEmbedding'; export * from './subsystems/IO/VectorDB.service/embed/index'; export * from './subsystems/IO/VectorDB.service/embed/OpenAIEmbedding'; export * from './subsystems/LLMManager/LLM.service/connectors/Anthropic.class'; diff --git a/packages/core/src/subsystems/IO/VectorDB.service/embed/GoogleEmbedding.ts b/packages/core/src/subsystems/IO/VectorDB.service/embed/GoogleEmbedding.ts new file mode 100644 index 000000000..6dccbe943 --- /dev/null +++ b/packages/core/src/subsystems/IO/VectorDB.service/embed/GoogleEmbedding.ts @@ -0,0 +1,118 @@ +import { GoogleGenAI } from '@google/genai'; +import { BaseEmbedding, TEmbeddings } from './BaseEmbedding'; +import { AccessCandidate } from '@sre/Security/AccessControl/AccessCandidate.class'; +import { getLLMCredentials } from '@sre/LLMManager/LLM.service/LLMCredentials.helper'; +import { TLLMCredentials, TLLMModel, BasicCredentials } from '@sre/types/LLM.types'; + +const DEFAULT_MODEL = 'gemini-embedding-001'; + +export class GoogleEmbeds extends BaseEmbedding { + protected client: GoogleGenAI; + + // Keep in sync with Gemini API supported embedding models + public static models = ['gemini-embedding-001', 'text-embedding-005', 'text-multilingual-embedding-002']; + public canSpecifyDimensions = true; + + constructor(private settings?: Partial) { + super({ model: settings?.model ?? DEFAULT_MODEL, ...settings }); + } + + async embedTexts(texts: string[], candidate: AccessCandidate): Promise { + const batches = this.chunkArr(this.processTexts(texts), this.chunkSize); + + const batchRequests = batches.map((batch) => { + return this.embed(batch, candidate); + }); + const batchResponses = await Promise.all(batchRequests); + + const embeddings: number[][] = []; + for (let i = 0; i < batchResponses.length; i += 1) { + const batch = batches[i]; + const batchResponse = batchResponses[i]; + for (let j = 0; j < batch.length; j += 1) { + embeddings.push(batchResponse[j]); + } + } + return embeddings; + } + + async embedText(text: string, candidate: AccessCandidate): Promise { + const processedText = this.processTexts([text])[0]; + const embeddings = await this.embed([processedText], candidate); + return embeddings[0]; + } + + protected async embed(texts: string[], candidate: AccessCandidate): Promise { + let apiKey: string | undefined; + + // Try to get from credentials first + try { + const modelInfo: TLLMModel = { + provider: 'GoogleAI', + modelId: this.model, + credentials: this.settings?.credentials as unknown as TLLMCredentials, + }; + const credentials = await getLLMCredentials(candidate, modelInfo); + apiKey = (credentials as BasicCredentials)?.apiKey; + } catch (e) { + // If credential system fails, fall back to environment variable + } + + // Fall back to environment variable if not found in credentials + if (!apiKey) { + apiKey = process.env.GOOGLE_AI_API_KEY; + } + + if (!apiKey) { + throw new Error('Please provide an API key for Google AI embeddings via credentials or GOOGLE_AI_API_KEY environment variable'); + } + + if (!this.client) { + this.client = new GoogleGenAI({ apiKey }); + } + + try { + const outputDimensionality = this.dimensions && Number.isFinite(this.dimensions) ? this.dimensions : undefined; + + // Batch request using the new SDK + const res = await this.client.models.embedContent({ + model: this.model, + contents: texts, + ...(outputDimensionality ? { outputDimensionality } : {}), + }); + + // The SDK can return either { embedding } for single or { embeddings } for batch + const vectors: number[][] = Array.isArray((res as any).embeddings) + ? (res as any).embeddings.map((e: any) => e.values as number[]) + : [((res as any).embedding?.values as number[]) || []]; + + // Enforce dimensions and normalization when requested or when non-3072 + const targetDim = outputDimensionality; + const processed = vectors.map((v) => this.postProcessEmbedding(v, targetDim)); + + return processed; + } catch (e) { + throw new Error(`Google Embeddings API error: ${e.message || e}`); + } + } + + private postProcessEmbedding(values: number[], targetDim?: number): number[] { + let v = Array.isArray(values) ? values.slice() : []; + if (targetDim && targetDim > 0) { + if (v.length > targetDim) { + // SDK ignored smaller dimension: truncate + v = v.slice(0, targetDim); + } else if (v.length < targetDim) { + // SDK returned shorter vector: pad with zeros + v = v.concat(Array(targetDim - v.length).fill(0)); + } + } + // Normalize for non-default 3072 dims (recommended by Google docs) + const needNormalize = (targetDim && targetDim !== 3072) || (!targetDim && v.length !== 3072); + if (needNormalize && v.length > 0) { + const norm = Math.sqrt(v.reduce((acc, x) => acc + x * x, 0)); + if (norm > 0) v = v.map((x) => x / norm); + } + return v; + } +} diff --git a/packages/core/src/subsystems/IO/VectorDB.service/embed/index.ts b/packages/core/src/subsystems/IO/VectorDB.service/embed/index.ts index aca0f6b78..16236b33e 100644 --- a/packages/core/src/subsystems/IO/VectorDB.service/embed/index.ts +++ b/packages/core/src/subsystems/IO/VectorDB.service/embed/index.ts @@ -1,4 +1,5 @@ import { OpenAIEmbeds } from './OpenAIEmbedding'; +import { GoogleEmbeds } from './GoogleEmbedding'; import { TEmbeddings } from './BaseEmbedding'; // a factory to get the correct embedding provider based on the provider name @@ -7,6 +8,10 @@ const supportedProviders = { embedder: OpenAIEmbeds, models: OpenAIEmbeds.models, }, + GoogleAI: { + embedder: GoogleEmbeds, + models: GoogleEmbeds.models, + }, } as const; export type SupportedProviders = keyof typeof supportedProviders; diff --git a/packages/core/tests/integration/connectors/vectordb/milvus.test.ts b/packages/core/tests/integration/connectors/vectordb/milvus.test.ts new file mode 100644 index 000000000..4e1f25608 --- /dev/null +++ b/packages/core/tests/integration/connectors/vectordb/milvus.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { setupSRE } from '../../../utils/sre'; +import { ConnectorService } from '@sre/Core/ConnectorsService'; +import { AccessCandidate } from '@sre/Security/AccessControl/AccessCandidate.class'; + +// Deterministic, offline embedding mock +vi.mock('@sre/IO/VectorDB.service/embed', async () => { + const base = await vi.importActual('@sre/IO/VectorDB.service/embed/BaseEmbedding'); + + function deterministicVector(text: string, dimensions: number): number[] { + const dims = dimensions || 8; + const vec = Array(dims).fill(0); + for (let i = 0; i < (text || '').length; i++) { + const code = text.charCodeAt(i); + vec[code % dims] += (code % 13) + 1; + } + return vec; + } + + class TestEmbeds extends base.BaseEmbedding { + constructor(cfg?: any) { + super(cfg); + if (!this.dimensions) this.dimensions = 8; + } + async embedText(text: string): Promise { + return deterministicVector(text, this.dimensions as number); + } + async embedTexts(texts: string[]): Promise { + return texts.map((t) => deterministicVector(t, this.dimensions as number)); + } + } + + return { + EmbeddingsFactory: { + create: (_provider: any, config: any) => new TestEmbeds(config), + }, + }; +}); + +function makeVector(text: string, dimensions = 8): number[] { + const vec = Array(dimensions).fill(0); + for (let i = 0; i < (text || '').length; i++) { + const code = text.charCodeAt(i); + vec[code % dimensions] += (code % 13) + 1; + } + return vec; +} + +const MILVUS_ADDRESS = process.env.MILVUS_ADDRESS as string; // e.g. localhost:19530 +const MILVUS_TOKEN = process.env.MILVUS_TOKEN as string | undefined; +const MILVUS_USER = process.env.MILVUS_USER as string | undefined; +const MILVUS_PASSWORD = process.env.MILVUS_PASSWORD as string | undefined; +const MILVUS_DIMENSIONS = Number(process.env.MILVUS_DIMENSIONS || 1024); + +beforeAll(() => { + const credentials = MILVUS_TOKEN + ? { address: MILVUS_ADDRESS, token: MILVUS_TOKEN } + : { address: MILVUS_ADDRESS, user: MILVUS_USER, password: MILVUS_PASSWORD }; + + setupSRE({ + VectorDB: { + Connector: 'Milvus', + Settings: { + credentials, + embeddings: { + provider: 'OpenAI', + model: 'text-embedding-3-large', + params: { dimensions: MILVUS_DIMENSIONS }, + }, + }, + }, + Log: { Connector: 'ConsoleLog' }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Milvus - VectorDB connector', () => { + it('should create namespace, add/list/get/delete datasource, search by string/vector', async () => { + const vdb = ConnectorService.getVectorDBConnector('Milvus'); + const user = AccessCandidate.user('test-user'); + const client = vdb.requester(user); + + // Create namespace and verify + await client.createNamespace('docs', { env: 'test' }); + await expect(client.namespaceExists('docs')).resolves.toBe(true); + + // Create datasource with chunking + const ds = await client.createDatasource('docs', { + id: 'mv-ds1', + label: 'MV DS1', + text: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + chunkSize: 10, + chunkOverlap: 2, + metadata: { provider: 'milvus' }, + }); + expect(ds.id).toBe('mv-ds1'); + expect(ds.vectorIds.length).toBeGreaterThan(0); + + // get/list datasource metadata + const got = await client.getDatasource('docs', 'mv-ds1'); + expect(got?.id).toBe('mv-ds1'); + const list = await client.listDatasources('docs'); + expect(list.map((d) => d.id)).toContain('mv-ds1'); + + // Search by string + const resText = await client.search('docs', 'KLM', { topK: 3, includeMetadata: true }); + expect(resText.length).toBeGreaterThan(0); + + // Search by vector + const qv = makeVector('KLM', MILVUS_DIMENSIONS); + const resVec = await client.search('docs', qv, { topK: 1 }); + expect(resVec.length).toBe(1); + + // topK behavior and sorting + const top1 = await client.search('docs', 'ALPHA', { topK: 1 }); + expect(top1.length).toBe(1); + const top3 = await client.search('docs', 'ALPHA', { topK: 3 }); + for (let i = 1; i < top3.length; i++) { + expect((top3[i - 1].score || 0) >= (top3[i].score || 0)).toBe(true); + } + + // Delete datasource and verify + await client.deleteDatasource('docs', 'mv-ds1'); + const maybeDeleted = await client.getDatasource('docs', 'mv-ds1'); + expect(maybeDeleted).toBeUndefined(); + + // Delete namespace + await client.deleteNamespace('docs'); + await expect(client.namespaceExists('docs')).resolves.toBe(false); + }, 60000); +}); diff --git a/packages/core/tests/integration/connectors/vectordb/pinecone.test.ts b/packages/core/tests/integration/connectors/vectordb/pinecone.test.ts new file mode 100644 index 000000000..386a4a16b --- /dev/null +++ b/packages/core/tests/integration/connectors/vectordb/pinecone.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { setupSRE } from '../../../utils/sre'; +import { ConnectorService } from '@sre/Core/ConnectorsService'; +import { AccessCandidate } from '@sre/Security/AccessControl/AccessCandidate.class'; + +// Deterministic, offline embedding mock +vi.mock('@sre/IO/VectorDB.service/embed', async () => { + const base = await vi.importActual('@sre/IO/VectorDB.service/embed/BaseEmbedding'); + + function deterministicVector(text: string, dimensions: number): number[] { + const dims = dimensions || 8; + const vec = Array(dims).fill(0); + for (let i = 0; i < (text || '').length; i++) { + const code = text.charCodeAt(i); + vec[code % dims] += (code % 13) + 1; + } + return vec; + } + + class TestEmbeds extends base.BaseEmbedding { + constructor(cfg?: any) { + super(cfg); + if (!this.dimensions) this.dimensions = 8; + } + async embedText(text: string): Promise { + return deterministicVector(text, this.dimensions as number); + } + async embedTexts(texts: string[]): Promise { + return texts.map((t) => deterministicVector(t, this.dimensions as number)); + } + } + + return { + EmbeddingsFactory: { + create: (_provider: any, config: any) => new TestEmbeds(config), + }, + }; +}); + +function makeVector(text: string, dimensions = 8): number[] { + const vec = Array(dimensions).fill(0); + for (let i = 0; i < (text || '').length; i++) { + const code = text.charCodeAt(i); + vec[code % dimensions] += (code % 13) + 1; + } + return vec; +} + +const PINECONE_API_KEY = process.env.PINECONE_API_KEY as string; +const PINECONE_INDEX_NAME = process.env.PINECONE_INDEX_NAME as string; +const PINECONE_DIMENSIONS = Number(process.env.PINECONE_DIMENSIONS || 1024); + +beforeAll(() => { + setupSRE({ + VectorDB: { + Connector: 'Pinecone', + Settings: { + apiKey: PINECONE_API_KEY, + indexName: PINECONE_INDEX_NAME, + embeddings: { + provider: 'OpenAI', + model: 'text-embedding-3-large', + params: { dimensions: PINECONE_DIMENSIONS }, + }, + }, + }, + Log: { Connector: 'ConsoleLog' }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Pinecone - VectorDB connector', () => { + it('should create namespace, add/list/get/delete datasource, search by string/vector', async () => { + const vdb = ConnectorService.getVectorDBConnector('Pinecone'); + const user = AccessCandidate.user('test-user'); + const client = vdb.requester(user); + + // Create namespace and verify + await client.createNamespace('docs', { env: 'test' }); + await expect(client.namespaceExists('docs')).resolves.toBe(true); + + // Create datasource with chunking + const ds = await client.createDatasource('docs', { + id: 'pc-ds1', + label: 'PC DS1', + text: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + chunkSize: 10, + chunkOverlap: 2, + metadata: { provider: 'pinecone' }, + }); + expect(ds.id).toBe('pc-ds1'); + expect(ds.vectorIds.length).toBeGreaterThan(0); + + // get/list datasource metadata (stored via NKV) + const got = await client.getDatasource('docs', 'pc-ds1'); + expect(got.id).toBe('pc-ds1'); + const list = await client.listDatasources('docs'); + expect(list.map((d) => d.id)).toContain('pc-ds1'); + + // Search by string + const resText = await client.search('docs', 'KLM', { topK: 3, includeMetadata: true }); + expect(resText.length).toBeGreaterThan(0); + + // Search by vector + const qv = makeVector('KLM', PINECONE_DIMENSIONS); + const resVec = await client.search('docs', qv, { topK: 1 }); + expect(resVec.length).toBe(1); + + // topK behavior and sorting + const top1 = await client.search('docs', 'ALPHA', { topK: 1 }); + expect(top1.length).toBe(1); + const top3 = await client.search('docs', 'ALPHA', { topK: 3 }); + for (let i = 1; i < top3.length; i++) { + expect((top3[i - 1].score || 0) >= (top3[i].score || 0)).toBe(true); + } + + // Delete datasource and verify + await client.deleteDatasource('docs', 'pc-ds1'); + await expect(client.getDatasource('docs', 'pc-ds1')).rejects.toThrow('Data source not found'); + + // Delete namespace + await client.deleteNamespace('docs'); + await expect(client.namespaceExists('docs')).resolves.toBe(false); + }, 60000); +}); diff --git a/packages/core/tests/integration/connectors/vectordb/ramvec.test.ts b/packages/core/tests/integration/connectors/vectordb/ramvec.test.ts new file mode 100644 index 000000000..b70804b89 --- /dev/null +++ b/packages/core/tests/integration/connectors/vectordb/ramvec.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { setupSRE } from '../../../utils/sre'; +import { ConnectorService } from '@sre/Core/ConnectorsService'; +import { AccessCandidate } from '@sre/Security/AccessControl/AccessCandidate.class'; + +// Deterministic, offline embedding mock +// We mock the EmbeddingsFactory to return a local embedder with stable vectors +vi.mock('@sre/IO/VectorDB.service/embed', async () => { + const base = await vi.importActual('@sre/IO/VectorDB.service/embed/BaseEmbedding'); + + function deterministicVector(text: string, dimensions: number): number[] { + const dims = dimensions || 8; + const vec = Array(dims).fill(0); + for (let i = 0; i < (text || '').length; i++) { + const code = text.charCodeAt(i); + vec[code % dims] += (code % 13) + 1; // stable contribution per char + } + return vec; + } + + class TestEmbeds extends base.BaseEmbedding { + constructor(cfg?: any) { + super(cfg); + if (!this.dimensions) this.dimensions = 8; + } + async embedText(text: string): Promise { + return deterministicVector(text, this.dimensions as number); + } + async embedTexts(texts: string[]): Promise { + return texts.map((t) => deterministicVector(t, this.dimensions as number)); + } + } + + return { + EmbeddingsFactory: { + create: (_provider: any, config: any) => new TestEmbeds(config), + }, + }; +}); + +// Helper to mirror the deterministic embedding used in the mock +function makeVector(text: string, dimensions = 8): number[] { + const vec = Array(dimensions).fill(0); + for (let i = 0; i < (text || '').length; i++) { + const code = text.charCodeAt(i); + vec[code % dimensions] += (code % 13) + 1; + } + return vec; +} + +// Initialize SRE with RAMVec and a small embedding dimension for faster tests +beforeAll(() => { + setupSRE({ + VectorDB: { + Connector: 'RAMVec', + Settings: { + embeddings: { + provider: 'OpenAI', + model: 'text-embedding-3-large', + params: { dimensions: 8 }, + }, + }, + }, + Log: { Connector: 'ConsoleLog' }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('RAMVec - VectorDB connector (in-memory)', () => { + it('should create, verify and delete a namespace; list only candidate namespaces', async () => { + const vdb = ConnectorService.getVectorDBConnector('RAMVec'); + const userA = AccessCandidate.user('test-user'); + const userB = AccessCandidate.user('other-user'); + + const clientA = vdb.requester(userA); + const clientB = vdb.requester(userB); + + // Namespace initially does not exist for either candidate (names are per-candidate) + await expect(clientA.namespaceExists('Docs')).resolves.toBe(false); + await expect(clientB.namespaceExists('Docs')).resolves.toBe(false); + + // Create namespace for userA + await clientA.createNamespace('Docs', { project: 'alpha' }); + await expect(clientA.namespaceExists('Docs')).resolves.toBe(true); + + // userB still cannot see userA namespace (different prepared namespace) + await expect(clientB.namespaceExists('Docs')).resolves.toBe(false); + + // No public listNamespaces API; verify per-candidate isolation via namespaceExists only + await expect(clientA.namespaceExists('Docs')).resolves.toBe(true); + await expect(clientB.namespaceExists('Docs')).resolves.toBe(false); + + // Delete A namespace + await clientA.deleteNamespace('Docs'); + await expect(clientA.namespaceExists('Docs')).resolves.toBe(false); + }); + + it('should create datasource with chunking and search by string and vector', async () => { + const vdb = ConnectorService.getVectorDBConnector('RAMVec'); + const user = AccessCandidate.user('test-user'); + const client = vdb.requester(user); + + // Prepare namespace and datasource + await client.createNamespace('docs'); + + const text = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // length 26 + // chunkSize 10 with overlap 2 => expected chunks: [0..9], [8..17], [16..25] => 3 chunks + const ds = await client.createDatasource('docs', { + id: 'ds1', + label: 'Alphabet', + text, + chunkSize: 10, + chunkOverlap: 2, + metadata: { source: 'unit-test' }, + }); + + expect(ds.id).toBe('ds1'); + expect(ds.vectorIds.length).toBe(3); + expect(ds.metadata).toBeDefined(); + + // get/list datasources + const fetched = await client.getDatasource('docs', 'ds1'); + expect(fetched.id).toBe('ds1'); + const list = await client.listDatasources('docs'); + expect(list.map((d) => d.id)).toContain('ds1'); + + // Search by string; expect the chunk containing 'KLM' (in second chunk) to surface + const byString = await client.search('docs', 'KLM', { topK: 3, includeMetadata: true }); + expect(byString.length).toBeGreaterThan(0); + expect(byString[0].text.includes('KLM')).toBe(true); + expect(byString[0].metadata).toBeDefined(); + expect(byString[0].values.length).toBe(8); // our mocked dimension + + // Search by vector using the same deterministic embedding + const queryVec = makeVector('KLM', 8); + const byVector = await client.search('docs', queryVec, { topK: 1, includeMetadata: false }); + expect(byVector.length).toBe(1); + expect(byVector[0].text.includes('KLM')).toBe(true); + expect(byVector[0].metadata).toBeUndefined(); // includeMetadata: false + }); + + it('should honor topK and includeMetadata options and return results sorted by similarity', async () => { + const vdb = ConnectorService.getVectorDBConnector('RAMVec'); + const user = AccessCandidate.user('test-user'); + const client = vdb.requester(user); + + await client.createNamespace('lib'); + + // Two datasources to provide more chunks + await client.createDatasource('lib', { + id: 'dsA', + label: 'DS A', + text: 'hello world hello again hello once more', + chunkSize: 11, + chunkOverlap: 3, + }); + await client.createDatasource('lib', { + id: 'dsB', + label: 'DS B', + text: 'different topic altogether with no hellos', + chunkSize: 12, + chunkOverlap: 2, + }); + + const q = 'hello again'; + + const top1 = await client.search('lib', q, { topK: 1, includeMetadata: false }); + expect(top1.length).toBe(1); + expect(top1[0].metadata).toBeUndefined(); + + const top3 = await client.search('lib', q, { topK: 3, includeMetadata: true }); + expect(top3.length).toBeLessThanOrEqual(3); + // ensure scores are non-increasing (sorted desc) + for (let i = 1; i < top3.length; i++) { + expect((top3[i - 1].score || 0) >= (top3[i].score || 0)).toBe(true); + expect(top3[i].metadata).toBeDefined(); + } + }); + + it('should delete datasource and make it unavailable', async () => { + const vdb = ConnectorService.getVectorDBConnector('RAMVec'); + const user = AccessCandidate.user('test-user'); + const client = vdb.requester(user); + + await client.createNamespace('workspace'); + await client.createDatasource('workspace', { id: 'dsX', label: 'X', text: 'SOME TEXT FOR DSX', chunkSize: 8, chunkOverlap: 2 }); + await client.createDatasource('workspace', { id: 'dsY', label: 'Y', text: 'OTHER TEXT FOR DSY', chunkSize: 8, chunkOverlap: 2 }); + + let list = await client.listDatasources('workspace'); + expect(list.map((d) => d.id).sort()).toEqual(['dsX', 'dsY']); + + // Delete dsX + await client.deleteDatasource('workspace', 'dsX'); + + list = await client.listDatasources('workspace'); + expect(list.map((d) => d.id)).not.toContain('dsX'); + // getDatasource for deleted should throw + await expect(client.getDatasource('workspace', 'dsX')).rejects.toThrow('Datasource dsX not found'); + }); + + it('should throw when searching non-existing namespace and after deleting namespace', async () => { + const vdb = ConnectorService.getVectorDBConnector('RAMVec'); + const user = AccessCandidate.user('test-user'); + const client = vdb.requester(user); + + // Non-existing namespace + await expect(client.search('ghost', 'anything')).rejects.toThrow('Namespace does not exist'); + + // Create and then delete + await client.createNamespace('temp'); + await client.createDatasource('temp', { id: 'tempDS', label: 'temp', text: 'short text', chunkSize: 10, chunkOverlap: 0 }); + await client.deleteNamespace('temp'); + + await expect(client.search('temp', 'short')).rejects.toThrow('Namespace does not exist'); + // listing datasources should be empty (no throw) + const list = await client.listDatasources('temp'); + expect(list).toEqual([]); + }); +}); diff --git a/packages/core/tests/unit/embeddings/GoogleEmbedding.test.ts b/packages/core/tests/unit/embeddings/GoogleEmbedding.test.ts new file mode 100644 index 000000000..46fe8c6ca --- /dev/null +++ b/packages/core/tests/unit/embeddings/GoogleEmbedding.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; +import { GoogleEmbeds } from '@sre/IO/VectorDB.service/embed/GoogleEmbedding'; +import { AccessCandidate } from '@sre/Security/AccessControl/AccessCandidate.class'; +import { getLLMCredentials } from '@sre/LLMManager/LLM.service/LLMCredentials.helper'; +import { GoogleGenerativeAI } from '@google/generative-ai'; + +// Mock the Google AI SDK +vi.mock('@google/generative-ai', () => ({ + GoogleGenerativeAI: vi.fn(), +})); + +// Mock the LLM credentials helper +vi.mock('@sre/LLMManager/LLM.service/LLMCredentials.helper', () => ({ + getLLMCredentials: vi.fn(), +})); + +describe('GoogleEmbeds - Unit Tests', () => { + let googleEmbeds: GoogleEmbeds; + let mockAccessCandidate: AccessCandidate; + let mockClient: any; + let mockModel: any; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Setup mock Google AI client + mockModel = { + embedContent: vi.fn(), + }; + + mockClient = { + getGenerativeModel: vi.fn().mockReturnValue(mockModel), + }; + + (GoogleGenerativeAI as any).mockImplementation(() => mockClient); + + // Setup mock access candidate + mockAccessCandidate = { + teamId: 'test-team', + agentId: 'test-agent', + } as unknown as AccessCandidate; + + // Setup default mock for getLLMCredentials + (getLLMCredentials as any).mockResolvedValue({ + apiKey: 'test-api-key', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.GOOGLE_AI_API_KEY; + }); + + describe('constructor', () => { + it('should initialize with default model', () => { + googleEmbeds = new GoogleEmbeds(); + expect(googleEmbeds.model).toBe('gemini-embedding-001'); + }); + + it('should initialize with custom model', () => { + googleEmbeds = new GoogleEmbeds({ model: 'gemini-embedding-001' }); + expect(googleEmbeds.model).toBe('gemini-embedding-001'); + }); + + it('should initialize with custom settings', () => { + const settings = { + model: 'gemini-embedding-001', + params: { + chunkSize: 256, + dimensions: 512, + stripNewLines: false, + }, + }; + googleEmbeds = new GoogleEmbeds(settings); + + expect(googleEmbeds.model).toBe('gemini-embedding-001'); + expect(googleEmbeds.chunkSize).toBe(256); + expect(googleEmbeds.dimensions).toBe(512); + expect(googleEmbeds.stripNewLines).toBe(false); + }); + + it('should support dimension specification', () => { + googleEmbeds = new GoogleEmbeds(); + expect(googleEmbeds.canSpecifyDimensions).toBe(true); + }); + + it('should have correct available models', () => { + expect(GoogleEmbeds.models).toEqual(['gemini-embedding-001']); + }); + }); + + describe('embedText', () => { + beforeEach(() => { + googleEmbeds = new GoogleEmbeds(); + }); + + it('should successfully embed a single text', async () => { + const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + const result = await googleEmbeds.embedText('test text', mockAccessCandidate); + + expect(result).toEqual(mockEmbedding); + expect(mockModel.embedContent).toHaveBeenCalledWith('test text'); + expect(GoogleGenerativeAI).toHaveBeenCalledWith('test-api-key'); + expect(mockClient.getGenerativeModel).toHaveBeenCalledWith({ + model: 'gemini-embedding-001', + }); + }); + + it('should process text by stripping newlines when stripNewLines is true', async () => { + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + await googleEmbeds.embedText('test\ntext\nwith\nnewlines', mockAccessCandidate); + + expect(mockModel.embedContent).toHaveBeenCalledWith('test text with newlines'); + }); + + it('should preserve newlines when stripNewLines is false', async () => { + googleEmbeds = new GoogleEmbeds({ params: { stripNewLines: false } }); + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + await googleEmbeds.embedText('test\ntext\nwith\nnewlines', mockAccessCandidate); + + expect(mockModel.embedContent).toHaveBeenCalledWith('test\ntext\nwith\nnewlines'); + }); + + it('should use environment variable when credentials fail', async () => { + (getLLMCredentials as any).mockRejectedValue(new Error('Credential error')); + process.env.GOOGLE_AI_API_KEY = 'env-api-key'; + + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + const result = await googleEmbeds.embedText('test text', mockAccessCandidate); + + expect(result).toEqual(mockEmbedding); + expect(GoogleGenerativeAI).toHaveBeenCalledWith('env-api-key'); + }); + + it('should throw error when no API key is available', async () => { + (getLLMCredentials as any).mockRejectedValue(new Error('Credential error')); + delete process.env.GOOGLE_AI_API_KEY; + + await expect(googleEmbeds.embedText('test text', mockAccessCandidate)).rejects.toThrow( + 'Please provide an API key for Google AI embeddings via credentials or GOOGLE_AI_API_KEY environment variable' + ); + }); + + it('should throw error when embedding response is invalid', async () => { + mockModel.embedContent.mockResolvedValue({ + embedding: null, + }); + + await expect(googleEmbeds.embedText('test text', mockAccessCandidate)).rejects.toThrow('Invalid embedding response from Google AI'); + }); + + it('should throw error when embedding values are missing', async () => { + mockModel.embedContent.mockResolvedValue({ + embedding: { values: null }, + }); + + await expect(googleEmbeds.embedText('test text', mockAccessCandidate)).rejects.toThrow('Invalid embedding response from Google AI'); + }); + + it('should wrap Google AI API errors', async () => { + const apiError = new Error('API quota exceeded'); + mockModel.embedContent.mockRejectedValue(apiError); + + await expect(googleEmbeds.embedText('test text', mockAccessCandidate)).rejects.toThrow('Google Embeddings API error: API quota exceeded'); + }); + }); + + describe('embedTexts', () => { + beforeEach(() => { + googleEmbeds = new GoogleEmbeds({ params: { chunkSize: 2 } }); + }); + + it('should successfully embed multiple texts', async () => { + const mockEmbeddings = [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + [0.7, 0.8, 0.9], + ]; + + // Mock each call to embedContent. The order depends on batch processing. + // Since batches are processed with Promise.all, order may vary but we need to ensure + // the correct embeddings are returned for the correct texts + mockModel.embedContent.mockImplementation((text) => { + if (text === 'text1') return Promise.resolve({ embedding: { values: mockEmbeddings[0] } }); + if (text === 'text2') return Promise.resolve({ embedding: { values: mockEmbeddings[1] } }); + if (text === 'text3') return Promise.resolve({ embedding: { values: mockEmbeddings[2] } }); + return Promise.reject(new Error('Unexpected text')); + }); + + const texts = ['text1', 'text2', 'text3']; + const result = await googleEmbeds.embedTexts(texts, mockAccessCandidate); + + expect(result).toEqual(mockEmbeddings); + expect(mockModel.embedContent).toHaveBeenCalledTimes(3); + expect(mockModel.embedContent).toHaveBeenCalledWith('text1'); + expect(mockModel.embedContent).toHaveBeenCalledWith('text2'); + expect(mockModel.embedContent).toHaveBeenCalledWith('text3'); + }); + + it('should handle chunking correctly', async () => { + googleEmbeds = new GoogleEmbeds({ params: { chunkSize: 2 } }); + + const mockEmbeddings = [ + [0.1, 0.2], + [0.3, 0.4], + [0.5, 0.6], + [0.7, 0.8], + [0.9, 1.0], + ]; + + // Mock each call based on the input text, regardless of call order + mockModel.embedContent.mockImplementation((text) => { + if (text === 'text1') return Promise.resolve({ embedding: { values: mockEmbeddings[0] } }); + if (text === 'text2') return Promise.resolve({ embedding: { values: mockEmbeddings[1] } }); + if (text === 'text3') return Promise.resolve({ embedding: { values: mockEmbeddings[2] } }); + if (text === 'text4') return Promise.resolve({ embedding: { values: mockEmbeddings[3] } }); + if (text === 'text5') return Promise.resolve({ embedding: { values: mockEmbeddings[4] } }); + return Promise.reject(new Error('Unexpected text')); + }); + + const texts = ['text1', 'text2', 'text3', 'text4', 'text5']; + const result = await googleEmbeds.embedTexts(texts, mockAccessCandidate); + + expect(result).toEqual(mockEmbeddings); + expect(mockModel.embedContent).toHaveBeenCalledTimes(5); + }); + + it('should handle empty texts array', async () => { + const result = await googleEmbeds.embedTexts([], mockAccessCandidate); + expect(result).toEqual([]); + expect(mockModel.embedContent).not.toHaveBeenCalled(); + }); + + it('should process texts consistently with embedText', async () => { + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + const texts = ['text\nwith\nnewlines']; + await googleEmbeds.embedTexts(texts, mockAccessCandidate); + + expect(mockModel.embedContent).toHaveBeenCalledWith('text with newlines'); + }); + }); + + describe('client initialization', () => { + beforeEach(() => { + googleEmbeds = new GoogleEmbeds(); + }); + + it('should initialize client with credentials from getLLMCredentials', async () => { + (getLLMCredentials as any).mockResolvedValue({ + apiKey: 'credentials-api-key', + }); + + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + await googleEmbeds.embedText('test', mockAccessCandidate); + + expect(getLLMCredentials).toHaveBeenCalledWith(mockAccessCandidate, { + provider: 'GoogleAI', + modelId: 'gemini-embedding-001', + credentials: undefined, + }); + expect(GoogleGenerativeAI).toHaveBeenCalledWith('credentials-api-key'); + }); + + it('should reuse client instance across multiple calls', async () => { + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + await googleEmbeds.embedText('test1', mockAccessCandidate); + await googleEmbeds.embedText('test2', mockAccessCandidate); + + // GoogleGenerativeAI constructor should only be called once + expect(GoogleGenerativeAI).toHaveBeenCalledTimes(1); + expect(mockModel.embedContent).toHaveBeenCalledTimes(2); + }); + + it('should pass custom credentials when provided in settings', async () => { + const customCredentials = { apiKey: 'custom-key' }; + googleEmbeds = new GoogleEmbeds({ credentials: customCredentials }); + + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + await googleEmbeds.embedText('test', mockAccessCandidate); + + expect(getLLMCredentials).toHaveBeenCalledWith(mockAccessCandidate, { + provider: 'GoogleAI', + modelId: 'gemini-embedding-001', + credentials: customCredentials, + }); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + googleEmbeds = new GoogleEmbeds(); + }); + + it('should handle network errors gracefully', async () => { + const networkError = new Error('Network timeout'); + mockModel.embedContent.mockRejectedValue(networkError); + + await expect(googleEmbeds.embedText('test', mockAccessCandidate)).rejects.toThrow('Google Embeddings API error: Network timeout'); + }); + + it('should handle API errors with custom messages', async () => { + const apiError = { message: 'Invalid model specified', code: 'INVALID_MODEL' }; + mockModel.embedContent.mockRejectedValue(apiError); + + await expect(googleEmbeds.embedText('test', mockAccessCandidate)).rejects.toThrow('Google Embeddings API error: Invalid model specified'); + }); + + it('should handle errors without message property', async () => { + const genericError = 'Something went wrong'; + mockModel.embedContent.mockRejectedValue(genericError); + + await expect(googleEmbeds.embedText('test', mockAccessCandidate)).rejects.toThrow('Google Embeddings API error: Something went wrong'); + }); + }); + + describe('text processing', () => { + it('should handle empty string input', async () => { + googleEmbeds = new GoogleEmbeds(); + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + const result = await googleEmbeds.embedText('', mockAccessCandidate); + expect(result).toEqual(mockEmbedding); + expect(mockModel.embedContent).toHaveBeenCalledWith(''); + }); + + it('should handle strings with only whitespace', async () => { + googleEmbeds = new GoogleEmbeds(); + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + const result = await googleEmbeds.embedText(' \t ', mockAccessCandidate); + expect(result).toEqual(mockEmbedding); + expect(mockModel.embedContent).toHaveBeenCalledWith(' \t '); + }); + + it('should handle very long text inputs', async () => { + googleEmbeds = new GoogleEmbeds(); + const longText = 'Lorem ipsum '.repeat(1000); + const mockEmbedding = [0.1, 0.2, 0.3]; + mockModel.embedContent.mockResolvedValue({ + embedding: { values: mockEmbedding }, + }); + + const result = await googleEmbeds.embedText(longText, mockAccessCandidate); + expect(result).toEqual(mockEmbedding); + // The text should be processed (newlines stripped if stripNewLines is true) + // Since stripNewLines is true by default and there are no newlines in this text, it should remain unchanged + expect(mockModel.embedContent).toHaveBeenCalledWith(longText); + }); + }); +}); + diff --git a/packages/sdk/docs/08-vector-db.md b/packages/sdk/docs/08-vector-db.md new file mode 100644 index 000000000..7c95fcfea --- /dev/null +++ b/packages/sdk/docs/08-vector-db.md @@ -0,0 +1,299 @@ +## Vector Databases + +This guide explains how to use Vector Databases with the SDK in two modes: + +- Standalone, directly from your app +- Through an Agent, with built-in access control and data isolation + +It also lists supported VectorDB connectors and embedding providers, details configuration, and shows complete examples for indexing and searching. + +### Key concepts + +- **Namespace**: A logical collection within a VectorDB (e.g., an index/collection scope). All operations in the SDK happen within a namespace. +- **Datasource**: A document you insert. In practice, text is chunked into multiple vectors and tracked as a single datasource id. +- **Scope & access control**: + - Standalone usage defaults to the current team scope. + - Agent usage defaults to the agent’s scope, isolating data between agents by default. + - You can explicitly share agent data at the team level. +- **Embeddings**: You must choose an embeddings provider/model. The SDK will generate vectors for you when you pass raw text or parsed documents. + +### Supported Vector Databases + +- **Pinecone** +- **Milvus/Zilliz** +- **RAMVec (in-memory, zero-config; for development only)** + +### Supported embeddings providers + +- **OpenAI**: models `text-embedding-3-large`, `text-embedding-ada-002` +- **Google AI**: model `gemini-embedding-001`, `text-embedding-005`, `text-multilingual-embedding-002` + +Notes: + +- OpenAI `text-embedding-ada-002` does not support custom `dimensions`. +- Google AI `gemini-embedding-001` defaults to 3072 dimensions and supports custom dimensions (recommended: 768, 1536, or 3072). If the SDK ignores the requested size, the SDK layer normalizes and truncates/pads vectors to your requested length. +- Default vector dimension used by connectors is 1024 when not provided. + +--- + +## Standalone usage + +Import from `@smythos/sdk`: + +```ts +import { VectorDB, Model, Doc } from '@smythos/sdk'; +``` + +### Configure an embeddings model + +Use the `Model` factory to declare the embeddings you want the VectorDB to use: + +```ts +// OpenAI embeddings +const openAIEmbeddings = Model.OpenAI('text-embedding-3-large'); + +// or Google AI embeddings +const googleEmbeddings = Model.GoogleAI('gemini-embedding-001'); +``` + +### Pinecone example (standalone) + +```ts +const pinecone = VectorDB.Pinecone('my_namespace', { + indexName: 'demo-vec', + apiKey: process.env.PINECONE_API_KEY!, + embeddings: Model.OpenAI('text-embedding-3-large'), +}); + +// Destructive: clears all vectors in the namespace +await pinecone.purge(); + +// Insert raw text +await pinecone.insertDoc('hello', 'Hello, world!', { topic: 'greeting' }); + +// Search +const results = await pinecone.search('Hello', { topK: 5 }); +``` + +### Milvus example (standalone) + +```ts +const milvus = VectorDB.Milvus('my_namespace', { + credentials: { + address: process.env.MILVUS_ADDRESS!, + // Either token OR user/password + user: process.env.MILVUS_USER, + password: process.env.MILVUS_PASSWORD, + token: process.env.MILVUS_TOKEN, + }, + embeddings: Model.OpenAI('text-embedding-3-large'), +}); + +await milvus.purge(); +const results = await milvus.search('my query', { topK: 5 }); +``` + +### RAMVec example (standalone, dev only) + +```ts +// Zero-config in-memory database for quick local testing +const ram = VectorDB.RAMVec('my_namespace'); +await ram.purge(); +await ram.insertDoc('hello', 'Hello, world!'); +const results = await ram.search('Hello'); +``` + +### Inserting parsed documents + +Use the SDK `Doc` parsers to turn files or strings into structured documents. The SDK will automatically chunk pages and index them correctly, enriching metadata with page numbers and titles. + +```ts +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const filePath = path.join(__dirname, './files/bitcoin.pdf'); + +const parsed = await Doc.pdf.parse(filePath); +await pinecone.insertDoc(parsed.title, parsed, { source: 'whitepaper' }); + +// Now search by semantics +const hits = await pinecone.search('Proof-of-Work', { topK: 5 }); +``` + +### Common operations + +```ts +// Update (appends new vectors – delete first if you want to replace) +await pinecone.updateDoc('hello', 'Hello again!', { version: '2' }); + +// Delete a document (by name you used during insert) +await pinecone.deleteDoc('hello'); + +// Purge entire namespace (destructive) +await pinecone.purge(); + +// Search options +const hits = await pinecone.search('query', { + topK: 10, // default 10 + includeEmbeddings: false, // default false; set true to include vectors +}); +``` + +Result shape from `search`: + +```ts +type SearchHit = { + embedding?: number[]; // present when includeEmbeddings is true + text?: string; // chunk text if available + metadata?: Record; // your metadata + SDK-added fields +}; +``` + +--- + +## Using VectorDBs with Agents + +When you initialize VectorDB connectors from an `Agent`, the SDK automatically enforces access control. Data inserted from an agent is isolated to that agent by default. You can opt-in to share at the team level. + +```ts +import { Agent, Doc, Model, Scope } from '@smythos/sdk'; + +// 1) Create an agent with a stable id for data isolation +const agent = new Agent({ + id: 'crypto-market-assistant', + name: 'CryptoMarket Assistant', + behavior: '…', + model: 'gpt-4o', +}); + +// 2) Initialize a VectorDB inside the agent context +const namespace = 'crypto-ns'; +const pineconeSettings = { + indexName: 'demo-vec', + apiKey: process.env.PINECONE_API_KEY!, + embeddings: Model.GoogleAI('gemini-embedding-001'), +}; + +// Default: agent scope (isolated) +const pinecone = agent.vectorDB.Pinecone(namespace, pineconeSettings); + +// Optional: share with the agent’s team instead of per-agent isolation +// const pinecone = agent.vectorDB.Pinecone(namespace, pineconeSettings, Scope.TEAM); + +await pinecone.purge(); + +const parsed = await Doc.md.parse('./files/bitcoin.md', { + title: 'Bitcoin', + author: 'Satoshi Nakamoto', + date: '2009-01-03', + tags: ['bitcoin', 'crypto', 'blockchain'], +}); + +await pinecone.insertDoc(parsed.title, parsed, { source: 'kb' }); + +// Query from inside a skill +agent + .addSkill({ + name: 'retrieve-info', + description: 'Retrieve information from knowledge base.', + process: async ({ question }) => { + const db = agent.vectorDB.Pinecone(namespace, pineconeSettings); + const hits = await db.search(question, { topK: 10 }); + return JSON.stringify(hits, null, 2); + }, + }) + .in({ question: { type: 'Text' } }); + +const reply = await agent.prompt('What is bitcoin Proof-of-Work?'); +console.log(reply); +``` + +Important: + +- **Agent ID**: set `id` on your agent to persist isolation boundaries across runs. +- **Scope**: omit the third parameter for agent-isolated data; pass `Scope.TEAM` to share with the team. +- **Standalone misuse**: Passing `Scope.AGENT`/`Scope.TEAM` to standalone `VectorDB.*` logs a warning and defaults to team scope. + +--- + +## Configuration reference + +### Pinecone + +```ts +type PineconeConfig = { + apiKey: string; // PINECONE_API_KEY + indexName: string; // existing index name + embeddings: TEmbeddings; // see Embeddings below +}; +``` + +### Milvus + +```ts +type MilvusConfig = { + credentials: { address: string; token: string } | { address: string; user: string; password: string; token?: string }; + embeddings: TEmbeddings; +}; +``` + +### RAMVec (in-memory) + +```ts +type RAMVectorDBConfig = { + embeddings?: TEmbeddings; // defaults to OpenAI text-embedding-3-large, 1024 dims +}; +``` + +### Embeddings + +```ts +type TEmbeddings = { + provider: 'OpenAI' | 'GoogleAI'; + model: 'text-embedding-3-large' | 'text-embedding-ada-002' | 'gemini-embedding-001'; + credentials?: { apiKey: string }; // optional; see notes below + params?: { + dimensions?: number; // default 1024 for OpenAI, 3072 for Google AI (ada-002 ignores this) + timeout?: number; + chunkSize?: number; // batching for bulk embed + stripNewLines?: boolean; // default true + }; +}; +``` + +- **OpenAI credentials**: resolved by the platform’s credential system; typically from your vault or environment. +- **Google AI credentials**: either via the credential system or fallback to `GOOGLE_AI_API_KEY` env var. + +### Environment variables + +- **PINECONE_API_KEY**: Pinecone API key +- **MILVUS_ADDRESS**, **MILVUS_USER**, **MILVUS_PASSWORD**, **MILVUS_TOKEN**: Milvus/Zilliz connection +- **GOOGLE_AI_API_KEY**: Fallback for Google AI embeddings + +--- + +## Tips & gotchas + +- **Destructive operations**: `purge()` deletes the entire namespace. +- **Names are normalized**: document names are lowercased and non-alphanumerics are converted to `_` for internal IDs. +- **Chunking**: the SDK chunks text automatically when you pass parsed docs or use `insertDoc` with long text, and attaches helpful metadata (page number, title). +- **Search defaults**: `topK` defaults to 10; set `includeEmbeddings: true` only when you truly need vectors. +- **Isolation**: agent-initialized VectorDBs default to agent scope; standalone default is team scope. + +--- + +## Extensibility + +You can extend SDK typings to reference custom providers from your app by augmenting `IVectorDBProviders`: + +```ts +declare module '@smythos/sdk' { + interface IVectorDBProviders { + Vectra: { indexId: string; apiSecret: string }; + } +} +``` + +Note: this exposes typed factory functions in the SDK (e.g., `VectorDB.Vectra`). A working connector must also exist in the SRE core to handle requests for the custom provider. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 42883c0d9..c78225a23 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@smythos/sdk", - "version": "1.1.9", + "version": "1.1.10", "description": "SRE SDK", "keywords": [ "smythos", diff --git a/packages/sdk/src/VectorDB/VectorDBInstance.class.ts b/packages/sdk/src/VectorDB/VectorDBInstance.class.ts index ef526574d..b5a134b39 100644 --- a/packages/sdk/src/VectorDB/VectorDBInstance.class.ts +++ b/packages/sdk/src/VectorDB/VectorDBInstance.class.ts @@ -135,7 +135,7 @@ export class VectorDBInstance extends SDKObject { * Delete a document from the vector database * @param name - The name of the document * @returns true if the document was deleted, false otherwise - * + * * @example * ```typescript * const pineConeSettings = {/* ... pinecone settings ... *\/} @@ -174,7 +174,7 @@ export class VectorDBInstance extends SDKObject { const results = await this._VectorDBRequest.search(this._namespace, query, { topK: options?.topK || 10, includeMetadata: true }); return results.map((result) => ({ embedding: options?.includeEmbeddings ? result.values : undefined, - text: result.metadata?.text, + text: result?.text, metadata: typeof result.metadata === 'string' ? JSON.parse(result.metadata) : result.metadata, })); }