Skip to content
Merged
6 changes: 6 additions & 0 deletions workspaces/lightspeed/.changeset/chilled-guests-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': minor
---

All lightspeed query is now called with rhdh-docs vector_store. Notebooks app-config only now requires queryDefaults model and provider
All files uploaded to lightspeed-stack will be converted to .txt
8 changes: 2 additions & 6 deletions workspaces/lightspeed/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,8 @@ lightspeed:
notebooks:
enabled: false
queryDefaults:
model: redhataillama-31-8b-instruct
provider_id: vllm
sessionDefaults:
provider_id: notebooks
embedding_model: ${LLAMA_STACK_EMBEDDING_MODEL}
embedding_dimension: 768
model: ${NOTEBOOKS_QUERY_MODEL}
provider_id: ${NOTEBOOKS_QUERY_PROVIDER_ID}

backend:
# Used for enabling authentication, secret is shared by all backend plugins
Expand Down
17 changes: 3 additions & 14 deletions workspaces/lightspeed/plugins/lightspeed-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,8 @@ lightspeed:
# Required: Query defaults for RAG queries
# Both model and provider_id must be configured together
queryDefaults:
model: llama3.1-8b-instruct # Model to use for answering queries
provider_id: ollama # AI provider for the query model

# Required: Session defaults for creating vector stores
# All three fields are required when Notebooks is enabled
sessionDefaults:
provider_id: notebooks # Vector store provider ID (must match Llama Stack config)
embedding_model: sentence-transformers/all-mpnet-base-v2 # Model for generating embeddings
embedding_dimension: 768 # Embedding vector dimension (must match model output)
model: ${NOTEBOOKS_QUERY_MODEL} # Model to use for answering queries. Must map to a model available through the provider set in $NOTEBOOKS_QUERY_PROVIDER_ID
provider_id: ${NOTEBOOKS_QUERY_PROVIDER_ID} # AI provider for the query model. Must map to a provider enabled in your Lightspeed config.yaml

# Optional: Chunking strategy for document processing
chunkingStrategy:
Expand All @@ -143,11 +136,7 @@ lightspeed:
- **`queryDefaults.model`** _(required)_: The LLM model to use for answering RAG queries. Must be available in the configured provider.
- **`queryDefaults.provider_id`** _(required)_: The AI provider identifier for the query model (e.g., `ollama`, `vllm`). Both `model` and `provider_id` must be configured together.

**Session Defaults** _(required when enabled)_:

- **`sessionDefaults.provider_id`** _(required)_: Vector store provider identifier. Must match a provider configured in your Llama Stack instance (e.g., `notebooks`, `chromadb`). This determines where document embeddings are stored.
- **`sessionDefaults.embedding_model`** _(required)_: The embedding model to use for converting documents to vectors (e.g., `sentence-transformers/all-mpnet-base-v2`). Must be available in Llama Stack.
- **`sessionDefaults.embedding_dimension`** _(required)_: Dimension of the embedding vectors produced by the embedding model. Must match the model's output dimension (commonly `768`, `384`, or `1536`).
> **Important**: The `model` and `provider_id` values must map to a provider and model that are actually enabled in your Lightspeed config.yaml configuration. If the provider or model is not available in Lightspeed, queries will fail. For example, if `openai` enabled in Lightspeed via ENABLE_OPENAI, then model must be available, e.g (model=gpt-4o-mini).

**Chunking Strategy** _(optional)_:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,20 @@ export const lcsHandlers: HttpHandler[] = [
});
}),

// Vector stores list endpoint - returns mock RHDH product docs vector store
http.get(`${LOCAL_LCS_ADDR}/v1/vector-stores`, () => {
return HttpResponse.json({
data: [
{
id: 'vs-rhdh-product-docs',
name: 'rhdh-product-docs',
provider_id: 'notebooks',
metadata: {},
},
],
});
}),

// Catch-all handler for unknown paths
http.all(`${LOCAL_LCS_ADDR}/*`, ({ request }) => {
console.log(`Caught request to unknown path: ${request.url}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@
# provider_id: ollama # AI provider for query model (e.g., ollama, vllm)
# model: llama3.1-8b-instruct # Model to use for answering queries
#
# # REQUIRED when enabled: Session defaults for vector stores
# # All three fields are required
# sessionDefaults:
# provider_id: notebooks # Vector store provider ID (must match Llama Stack config)
# embedding_model: sentence-transformers/all-mpnet-base-v2 # Embedding model for documents
# embedding_dimension: 768 # Vector dimension (must match embedding model output)
#
# # OPTIONAL: Chunking strategy for document processing
# chunkingStrategy:
# type: auto # 'auto' (default) or 'static'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const DEFAULT_CHUNKING_STRATEGY_TYPE = 'auto'; // auto chunking
export const DEFAULT_MAX_CHUNK_SIZE_TOKENS = 512; // 512 tokens
export const DEFAULT_CHUNK_OVERLAP_TOKENS = 50; // 50 tokens
export const DEFAULT_LLAMA_STACK_PORT = 8321; // Llama Stack port
export const DEFAULT_LIGHTSPEED_SERVICE_HOST = '0.0.0.0'; // Lightspeed core service host
export const DEFAULT_LIGHTSPEED_SERVICE_PORT = 8080; // Lightspeed service port
export const DEFAULT_MAX_FILE_SIZE_MB = 20 * 1024 * 1024; // 20MB
export const NOTEBOOKS_SYSTEM_PROMPT =
Expand All @@ -36,16 +37,16 @@ Constraints:
Output Format:
1. Summary: A 1-2 sentence high-level answer.
2. Detailed Analysis: A structured breakdown using bullet points.
3. References: A list of sources used.
3. References: A list of sources used. References should be in the format of [Document Title] in a new line for each reference.

Disclaimer: Your answers **MUST** be grounded in the provided documents. If the answer isn't present, state: "I don't know based on the provided documents."
Remember, **ALL** references must be from the provided documents and provided documents only.
Make no mistakes.
`.trim();

/**
* HTTP and networking constants
*/
export const LIGHTSPEED_SERVICE_HOST = '0.0.0.0'; // Lightspeed core service host
Comment thread
Jdubrick marked this conversation as resolved.
export const URL_FETCH_TIMEOUT_MS = 30000; // 30 second timeout for URL fetching
export const USER_AGENT = 'RHDH-AI-Notebooks-Bot/1.0'; // User agent for HTTP requests
export const MAX_URL_CONTENT_SIZE = 10 * 1024 * 1024; // 10MB max for URL fetched content
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,45 @@ async function handleHttpError(
*
* This class provides the same interface as LlamaStackClient but proxies calls through
* lightspeed-core REST API instead of calling llama stack directly.
*
* Implemented as a singleton to ensure single instance across the application.
*/
export class VectorStoresOperator {
private static instance: VectorStoresOperator | null = null;
private baseURL: string;
private logger: LoggerService;

constructor(lightspeedCoreUrl: string, logger: LoggerService) {
private constructor(lightspeedCoreUrl: string, logger: LoggerService) {
this.baseURL = lightspeedCoreUrl;
this.logger = logger;
}

/**
* Get the singleton instance of VectorStoresOperator
* @param lightspeedCoreUrl - Lightspeed core URL (required on first call)
* @param logger - Logger service (required on first call)
* @returns The singleton instance
*/
static getInstance(
lightspeedCoreUrl: string,
logger: LoggerService,
): VectorStoresOperator {
if (!VectorStoresOperator.instance) {
VectorStoresOperator.instance = new VectorStoresOperator(
lightspeedCoreUrl,
logger,
);
}
return VectorStoresOperator.instance;
}

/**
* Reset the singleton instance (primarily for testing)
*/
static resetInstance(): void {
VectorStoresOperator.instance = null;
}

/**
* Vector Stores API - mirrors LlamaStackClient.vectorStores structure
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ describe('DocumentService', () => {
},
},
});
operator = new VectorStoresOperator(LIGHTSPEED_CORE_ADDR, logger);
VectorStoresOperator.resetInstance(); // Reset singleton before each test
operator = VectorStoresOperator.getInstance(LIGHTSPEED_CORE_ADDR, logger);
documentService = new DocumentService(operator, logger, config);
sessionService = new SessionService(operator, logger, config);
sessionService = new SessionService(operator, logger);

// Create a test session for document operations
const session = await sessionService.createSession(
Expand All @@ -84,7 +85,6 @@ describe('DocumentService', () => {
const fileId = await documentService.uploadFile(
'Test content',
'test-file.txt',
'txt',
);

expect(fileId).toBeDefined();
Expand All @@ -93,23 +93,13 @@ describe('DocumentService', () => {

it('should handle upload errors', async () => {
// Mock a failure by passing invalid content
await expect(
documentService.uploadFile('', '', 'txt'),
).resolves.toBeDefined();
await expect(documentService.uploadFile('', '')).resolves.toBeDefined();
});

it('should use correct MIME type based on file type', async () => {
const fileId1 = await documentService.uploadFile(
'{}',
'test.json',
'json',
);
const fileId2 = await documentService.uploadFile(
'text',
'test.txt',
'txt',
);
const fileId3 = await documentService.uploadFile('# MD', 'test.md', 'md');
const fileId1 = await documentService.uploadFile('{}', 'test.json');
const fileId2 = await documentService.uploadFile('text', 'test.txt');
const fileId3 = await documentService.uploadFile('# MD', 'test.md');

expect(fileId1).toBeDefined();
expect(fileId2).toBeDefined();
Expand All @@ -119,11 +109,7 @@ describe('DocumentService', () => {

describe('getFileStatus', () => {
it('should get file status for existing document', async () => {
const fileId = await documentService.uploadFile(
'Content',
'Test Doc',
'text',
);
const fileId = await documentService.uploadFile('Content', 'Test Doc');
await documentService.upsertDocument(
sessionId,
'Test Doc',
Expand All @@ -149,7 +135,6 @@ describe('DocumentService', () => {
const fileId = await documentService.uploadFile(
'This is test content',
'Test Document',
'text',
);

const result = await documentService.upsertDocument(
Expand All @@ -169,7 +154,6 @@ describe('DocumentService', () => {
const fileId1 = await documentService.uploadFile(
'Original content',
'Original Title',
'text',
);
await documentService.upsertDocument(
sessionId,
Expand All @@ -181,7 +165,6 @@ describe('DocumentService', () => {
const fileId2 = await documentService.uploadFile(
'Updated content',
'Original Title',
'text',
);
const result = await documentService.upsertDocument(
sessionId,
Expand All @@ -199,7 +182,6 @@ describe('DocumentService', () => {
const fileId1 = await documentService.uploadFile(
'Content',
'Original Title',
'text',
);
await documentService.upsertDocument(
sessionId,
Expand All @@ -211,7 +193,6 @@ describe('DocumentService', () => {
const fileId2 = await documentService.uploadFile(
'Updated content',
'New Title',
'text',
);
const result = await documentService.upsertDocument(
sessionId,
Expand All @@ -230,7 +211,6 @@ describe('DocumentService', () => {
const fileId1 = await documentService.uploadFile(
'Content 1',
'Document 1',
'text',
);
await documentService.upsertDocument(
sessionId,
Expand All @@ -242,7 +222,6 @@ describe('DocumentService', () => {
const fileId2 = await documentService.uploadFile(
'Content 2',
'Document 2',
'text',
);
await documentService.upsertDocument(
sessionId,
Expand All @@ -265,23 +244,15 @@ describe('DocumentService', () => {
});

it('should filter documents by file type', async () => {
const fileId1 = await documentService.uploadFile(
'Content',
'Text Doc',
'text',
);
const fileId1 = await documentService.uploadFile('Content', 'Text Doc');
await documentService.upsertDocument(
sessionId,
'Text Doc',
'text',
fileId1,
);

const fileId2 = await documentService.uploadFile(
'Content',
'PDF Doc',
'pdf',
);
const fileId2 = await documentService.uploadFile('Content', 'PDF Doc');
await documentService.upsertDocument(
sessionId,
'PDF Doc',
Expand All @@ -299,7 +270,6 @@ describe('DocumentService', () => {
const fileId = await documentService.uploadFile(
'Content',
'Test Document',
'text',
);
await documentService.upsertDocument(
sessionId,
Expand All @@ -323,7 +293,6 @@ describe('DocumentService', () => {
const fileId = await documentService.uploadFile(
'Content',
'Test Document',
'text',
);
await documentService.upsertDocument(
sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
DEFAULT_CHUNK_OVERLAP_TOKENS,
DEFAULT_CHUNKING_STRATEGY_TYPE,
DEFAULT_MAX_CHUNK_SIZE_TOKENS,
FILE_TYPE_TO_MIME,
} from '../../constant';
import { SessionDocument, UpsertResult } from '../types/notebooksTypes';
import { VectorStoresOperator } from '../VectorStoresOperator';
Expand Down Expand Up @@ -98,27 +97,13 @@ export class DocumentService {
* @returns File ID from the Files API
* @throws Error if upload fails
*/
/**
* Upload a file to the Files API
* @param content - File content as string
* @param title - File title/name
* @param fileType - Optional file type for MIME type detection
* @returns File ID from the Files API
* @throws Error if upload fails
*/
async uploadFile(
content: string,
title: string,
fileType?: string,
): Promise<string> {
async uploadFile(content: string, title: string): Promise<string> {
try {
// Determine MIME type from file type or default to text/plain
const mimeType = fileType
? FILE_TYPE_TO_MIME[fileType] || 'text/plain'
: 'text/plain';

const mimeType = 'text/plain';
const txtFilename = `${title.replace(/\.[^.]+$/, '')}.txt`;
const file = await this.client.files.create({
file: await toFile(Buffer.from(content, 'utf-8'), title, {
file: await toFile(Buffer.from(content, 'utf-8'), txtFilename, {
type: mimeType,
}),
purpose: 'assistants',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
resetMockStorage,
} from '../../../__fixtures__/lightspeedCoreHandlers';
import { createNotebooksRouter } from './notebooksRouters';
import { VectorStoresOperator } from './VectorStoresOperator';

const mockUserId = 'user:default/guest';

Expand Down Expand Up @@ -53,6 +54,7 @@ describe('Notebooks Router', () => {

beforeEach(async () => {
resetMockStorage();
VectorStoresOperator.resetInstance(); // Reset singleton before each test
const logger = mockServices.logger.mock();
const config = mockServices.rootConfig({
data: {
Expand Down
Loading
Loading