@retrivora-ai/rag-engine 1.9.2 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +27 -0
  2. package/dist/{ILLMProvider-Bw2A28nU.d.mts → ILLMProvider-DNhyOYoK.d.mts} +41 -1
  3. package/dist/{ILLMProvider-Bw2A28nU.d.ts → ILLMProvider-DNhyOYoK.d.ts} +41 -1
  4. package/dist/handlers/index.d.mts +2 -2
  5. package/dist/handlers/index.d.ts +2 -2
  6. package/dist/handlers/index.js +563 -205
  7. package/dist/handlers/index.mjs +563 -205
  8. package/dist/index-C9v7-tWd.d.mts +183 -0
  9. package/dist/{index-B70ZLkfG.d.mts → index-CHL1jdYm.d.mts} +1 -1
  10. package/dist/index-CjQdL0cX.d.ts +183 -0
  11. package/dist/{index-DVu-mkAM.d.ts → index-Hgbwl9X4.d.ts} +1 -1
  12. package/dist/index.css +40 -0
  13. package/dist/index.d.mts +3 -3
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js +326 -158
  16. package/dist/index.mjs +312 -151
  17. package/dist/server.d.mts +15 -94
  18. package/dist/server.d.ts +15 -94
  19. package/dist/server.js +623 -205
  20. package/dist/server.mjs +615 -205
  21. package/package.json +1 -1
  22. package/src/app/api/chat/route.ts +2 -2
  23. package/src/app/api/health/route.ts +3 -2
  24. package/src/app/api/ingest/route.ts +3 -2
  25. package/src/app/api/suggestions/route.ts +3 -2
  26. package/src/app/api/upload/route.ts +3 -2
  27. package/src/app/constants.tsx +85 -30
  28. package/src/app/layout.tsx +18 -7
  29. package/src/components/MessageBubble.tsx +39 -2
  30. package/src/components/ThinkingBlock.tsx +75 -0
  31. package/src/components/VisualizationRenderer.tsx +27 -1
  32. package/src/config/RagConfig.ts +47 -0
  33. package/src/config/serverConfig.ts +25 -0
  34. package/src/core/ConfigResolver.ts +56 -4
  35. package/src/core/ConfigValidator.ts +2 -1
  36. package/src/core/Pipeline.ts +226 -68
  37. package/src/core/ProviderRegistry.ts +11 -2
  38. package/src/core/QueryProcessor.ts +38 -4
  39. package/src/core/Retrivora.ts +51 -0
  40. package/src/exceptions/index.ts +59 -0
  41. package/src/handlers/index.ts +2 -0
  42. package/src/hooks/useRagChat.ts +26 -4
  43. package/src/index.ts +25 -1
  44. package/src/lib/plugin.ts +24 -0
  45. package/src/llm/LLMFactory.ts +4 -1
  46. package/src/llm/providers/AnthropicProvider.ts +70 -20
  47. package/src/llm/providers/GeminiProvider.ts +14 -15
  48. package/src/llm/providers/OllamaProvider.ts +13 -16
  49. package/src/llm/providers/OpenAIProvider.ts +9 -14
  50. package/src/llm/providers/UniversalLLMAdapter.ts +5 -5
  51. package/src/llm/utils.ts +46 -0
  52. package/src/providers/vectordb/MongoDBProvider.ts +19 -7
  53. package/src/providers/vectordb/MultiTablePostgresProvider.ts +45 -13
  54. package/src/rag/EntityExtractor.ts +2 -2
  55. package/src/rag/Reranker.ts +9 -16
  56. package/src/server.ts +27 -1
  57. package/src/types/chat.ts +7 -0
  58. package/src/utils/UITransformer.ts +73 -4
  59. package/dist/DocumentChunker-Dh9TvmGG.d.mts +0 -45
  60. package/dist/DocumentChunker-Dh9TvmGG.d.ts +0 -45
@@ -25,11 +25,12 @@ import { useStoredMessages } from './useStoredMessages';
25
25
  // ─── SSE Frame Parser ──────────────────────────────────────────────────────────
26
26
 
27
27
  interface SseTextFrame { type: 'text'; text: string }
28
- interface SseMetaFrame { type: 'metadata'; sources?: VectorMatch[] }
28
+ interface SseThinkingFrame { type: 'thinking'; text: string }
29
+ interface SseMetaFrame { type: 'metadata'; sources?: VectorMatch[]; thinkingMs?: number }
29
30
  interface SseErrorFrame { type: 'error'; error: string }
30
31
  interface SseUiFrame { type: 'ui_transformation'; data: unknown }
31
32
  interface SseObservabilityFrame { type: 'observability'; data: ObservabilityTrace }
32
- type SseFrame = SseTextFrame | SseMetaFrame | SseErrorFrame | SseUiFrame | SseObservabilityFrame;
33
+ type SseFrame = SseTextFrame | SseThinkingFrame | SseMetaFrame | SseErrorFrame | SseUiFrame | SseObservabilityFrame;
33
34
 
34
35
  /**
35
36
  * Parse all complete SSE frames from a raw chunk string.
@@ -135,6 +136,8 @@ export function useRagChat(
135
136
  const reader = response.body.getReader();
136
137
  const decoder = new TextDecoder();
137
138
  let assistantContent = '';
139
+ let thinkingContent = '';
140
+ let thinkingMs: number | undefined;
138
141
  let sources: VectorMatch[] = [];
139
142
  let uiTransformation: unknown = null;
140
143
  let trace: ObservabilityTrace | undefined;
@@ -146,14 +149,20 @@ export function useRagChat(
146
149
  // O(N) ReactMarkdown re-parses where N = number of token chunks.
147
150
  let pendingFlush = false;
148
151
  const pendingContentRef = { current: '' };
152
+ const pendingThinkingRef = { current: '' };
149
153
 
150
154
  const flushTextUpdate = (msgId: string) => {
151
155
  pendingFlush = false;
152
156
  const snapshot = pendingContentRef.current;
157
+ const thinkingSnapshot = pendingThinkingRef.current;
153
158
  setMessages((prev) =>
154
159
  prev.map((msg) =>
155
160
  msg.id === msgId
156
- ? { ...msg, content: snapshot }
161
+ ? {
162
+ ...msg,
163
+ content: snapshot,
164
+ thinking: thinkingSnapshot || undefined,
165
+ }
157
166
  : msg
158
167
  )
159
168
  );
@@ -199,8 +208,13 @@ export function useRagChat(
199
208
  pendingContentRef.current = assistantContent;
200
209
  // Schedule a batched RAF flush instead of immediate setMessages
201
210
  scheduleFlush(assistantMessageId);
211
+ } else if (frame.type === 'thinking' && frame.text) {
212
+ thinkingContent += frame.text;
213
+ pendingThinkingRef.current = thinkingContent;
214
+ scheduleFlush(assistantMessageId);
202
215
  } else if (frame.type === 'metadata') {
203
216
  sources = frame.sources ?? [];
217
+ thinkingMs = (frame as any).thinkingMs;
204
218
  hasNonTextFrame = true;
205
219
  } else if (frame.type === 'ui_transformation') {
206
220
  uiTransformation = frame.data;
@@ -222,6 +236,8 @@ export function useRagChat(
222
236
  ? {
223
237
  ...msg,
224
238
  content: assistantContent,
239
+ thinking: thinkingContent || undefined,
240
+ thinkingMs: thinkingMs,
225
241
  sources: sources.length > 0 ? sources : msg.sources,
226
242
  uiTransformation: uiTransformation || (msg as RagMessage).uiTransformation,
227
243
  }
@@ -235,7 +251,11 @@ export function useRagChat(
235
251
  if (buffer.trim()) {
236
252
  for (const frame of parseSseChunk(buffer)) {
237
253
  if (frame.type === 'text' && frame.text) assistantContent += frame.text;
238
- else if (frame.type === 'metadata') sources = frame.sources ?? [];
254
+ else if (frame.type === 'thinking' && frame.text) thinkingContent += frame.text;
255
+ else if (frame.type === 'metadata') {
256
+ sources = frame.sources ?? [];
257
+ thinkingMs = (frame as any).thinkingMs;
258
+ }
239
259
  else if (frame.type === 'observability') trace = (frame as SseObservabilityFrame).data;
240
260
  }
241
261
  }
@@ -248,6 +268,8 @@ export function useRagChat(
248
268
  sources: sources.length > 0 ? sources : undefined,
249
269
  uiTransformation: uiTransformation ?? undefined,
250
270
  trace,
271
+ thinking: thinkingContent || undefined,
272
+ thinkingMs: thinkingMs,
251
273
  createdAt: new Date().toISOString(),
252
274
  };
253
275
  setMessages((prev) =>
package/src/index.ts CHANGED
@@ -21,9 +21,33 @@ export { useRagChat } from './hooks/useRagChat';
21
21
  export { addSynonyms } from './utils/synonyms';
22
22
 
23
23
  // ── Types (Interfaces/Types only, no Node.js deps) ─────────────
24
- export type { RagConfig, VectorDBConfig, VectorDBProvider, LLMConfig, LLMProvider, EmbeddingConfig, EmbeddingProvider, UIConfig, RAGConfig } from './config/RagConfig';
24
+ export type {
25
+ RagConfig,
26
+ UniversalRagConfig,
27
+ RetrievalConfig,
28
+ WorkflowConfig,
29
+ VectorDBConfig,
30
+ VectorDBProvider,
31
+ LLMConfig,
32
+ LLMProvider,
33
+ EmbeddingConfig,
34
+ EmbeddingProvider,
35
+ UIConfig,
36
+ RAGConfig,
37
+ } from './config/RagConfig';
25
38
  export type { ILLMProvider, EmbedOptions } from './llm/ILLMProvider';
26
39
  export type { Chunk, ChunkOptions } from './rag/DocumentChunker';
40
+ export type { Retrivora } from './core/Retrivora';
41
+ export type { RetrivoraErrorCode } from './exceptions';
42
+ export {
43
+ RetrivoraError,
44
+ ProviderNotFoundException,
45
+ EmbeddingFailedException,
46
+ RetrievalException,
47
+ RateLimitException,
48
+ ConfigurationException,
49
+ AuthenticationException,
50
+ } from './exceptions';
27
51
  export type {
28
52
  VectorMatch,
29
53
  UpsertDocument,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * lib/plugin.ts — Singleton VectorPlugin for this Next.js application.
3
+ *
4
+ * All API route handlers import this module to share ONE plugin instance
5
+ * (and therefore one pipeline + one DB connection pool) across the process.
6
+ *
7
+ * Why a singleton?
8
+ * - Prevents a new MongoDB/Postgres connection being opened on every request.
9
+ * - Ensures the LRU embedding cache is shared across all chat requests.
10
+ * - In serverless environments (Vercel) the module is cached per function
11
+ * instance, so cold-starts initialize once and stay warm.
12
+ *
13
+ * To swap provider at runtime, change .env.local and restart the dev server.
14
+ */
15
+
16
+ import { VectorPlugin } from '@/core/VectorPlugin';
17
+ import { getRagConfig } from '@/config/serverConfig';
18
+
19
+ // Build config once from environment variables at module load time.
20
+ const ragConfig = getRagConfig();
21
+
22
+ // Create and export the singleton.
23
+ // VectorPlugin is lazy — it doesn't open connections until the first request.
24
+ export const plugin = new VectorPlugin(ragConfig);
@@ -6,6 +6,7 @@ import { OllamaProvider } from './providers/OllamaProvider';
6
6
  import { GeminiProvider } from './providers/GeminiProvider';
7
7
  import { UniversalLLMAdapter } from './providers/UniversalLLMAdapter';
8
8
  import { IProviderValidator, IProviderHealthChecker } from '../core/ProviderInterfaces';
9
+ import { ProviderNotFoundException } from '../exceptions';
9
10
 
10
11
  interface LLMProviderStatic {
11
12
  getValidator?: () => IProviderValidator;
@@ -81,7 +82,9 @@ export class LLMFactory {
81
82
  if (llmConfig.baseUrl || (llmConfig.options as Record<string, unknown>)?.baseUrl) {
82
83
  return new UniversalLLMAdapter(llmConfig);
83
84
  }
84
- throw new Error(
85
+ throw new ProviderNotFoundException(
86
+ 'LLM',
87
+ String(llmConfig.provider),
85
88
  `[LLMFactory] Unknown provider "${llmConfig.provider}". ` +
86
89
  `Built-in providers: ${LLMFactory.listProviders().join(', ')}. ` +
87
90
  `Register a custom provider with LLMFactory.register().`
@@ -8,6 +8,7 @@ import { ChatMessage, ChatOptions } from '../../types';
8
8
  import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
9
9
  import { IProviderValidator, IProviderHealthChecker, HealthCheckResult } from '../../core/ProviderInterfaces';
10
10
  import { ValidationError } from '../../core/ConfigValidator';
11
+ import { buildSystemContent } from '../utils';
11
12
 
12
13
  export class AnthropicProvider implements ILLMProvider {
13
14
  private readonly client: Anthropic;
@@ -75,63 +76,112 @@ export class AnthropicProvider implements ILLMProvider {
75
76
  }
76
77
 
77
78
  async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
78
- const resolvedPrompt = options?.systemPrompt ??
79
+ const basePrompt =
80
+ options?.systemPrompt ??
79
81
  this.llmConfig.systemPrompt ??
80
- `You are a helpful assistant. Use the context below to answer the user's question accurately.\n\nContext:\n${context}`;
82
+ 'You are a helpful assistant. Use the context below to answer the user\'s question accurately.';
81
83
 
82
- const system = resolvedPrompt.includes('{{context}}')
83
- ? resolvedPrompt.replace('{{context}}', context)
84
- : options?.systemPrompt
85
- ? resolvedPrompt // per-call: context is already in the user message
86
- : `${resolvedPrompt}\n\nContext:\n${context}`;
84
+ const system = buildSystemContent(basePrompt, context);
87
85
 
88
86
  const anthropicMessages: Anthropic.MessageParam[] = messages.map((m) => ({
89
87
  role: m.role === 'assistant' ? 'assistant' : 'user',
90
88
  content: m.content,
91
89
  }));
92
90
 
91
+ const isClaude37 = this.llmConfig.model.includes('claude-3-7-sonnet');
92
+ const isThinkingEnabled = this.llmConfig.options?.thinking === true ||
93
+ this.llmConfig.options?.thinking === 'enabled' ||
94
+ (isClaude37 && this.llmConfig.options?.thinking !== false);
95
+
96
+ const extraParams: any = {};
97
+ if (isThinkingEnabled && isClaude37) {
98
+ extraParams.betas = ['interleaved-thinking-2025-05-14'];
99
+ const maxTokens = options?.maxTokens ?? this.llmConfig.maxTokens ?? 4096;
100
+ const budget = Math.min(
101
+ typeof this.llmConfig.options?.thinkingBudget === 'number'
102
+ ? this.llmConfig.options?.thinkingBudget
103
+ : 2048,
104
+ maxTokens - 1
105
+ );
106
+ extraParams.thinking = {
107
+ type: 'enabled',
108
+ budget_tokens: budget,
109
+ };
110
+ extraParams.max_tokens = Math.max(maxTokens, budget + 1024);
111
+ }
112
+
93
113
  const response = await this.client.messages.create({
94
114
  model: this.llmConfig.model,
95
115
  max_tokens: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
96
116
  system,
97
117
  messages: anthropicMessages,
98
- });
118
+ ...extraParams,
119
+ } as any);
99
120
 
100
- const block = response.content[0];
101
- return block.type === 'text' ? block.text : '';
121
+ let reply = '';
122
+ for (const block of response.content) {
123
+ if (block.type === 'text') {
124
+ reply += block.text;
125
+ }
126
+ }
127
+ return reply;
102
128
  }
103
129
 
104
130
  async *chatStream(messages: ChatMessage[], context: string, options?: ChatOptions): AsyncIterable<string> {
105
- const resolvedPrompt = options?.systemPrompt ??
131
+ const basePrompt =
132
+ options?.systemPrompt ??
106
133
  this.llmConfig.systemPrompt ??
107
- `You are a helpful assistant. Use the context below to answer the user's question accurately.\n\nContext:\n${context}`;
134
+ 'You are a helpful assistant. Use the context below to answer the user\'s question accurately.';
108
135
 
109
- const system = resolvedPrompt.includes('{{context}}')
110
- ? resolvedPrompt.replace('{{context}}', context)
111
- : options?.systemPrompt
112
- ? resolvedPrompt
113
- : `${resolvedPrompt}\n\nContext:\n${context}`;
136
+ const system = buildSystemContent(basePrompt, context);
114
137
 
115
138
  const anthropicMessages: Anthropic.MessageParam[] = messages.map((m) => ({
116
139
  role: m.role === 'assistant' ? 'assistant' : 'user',
117
140
  content: m.content,
118
141
  }));
119
142
 
143
+ const isClaude37 = this.llmConfig.model.includes('claude-3-7-sonnet');
144
+ const isThinkingEnabled = this.llmConfig.options?.thinking === true ||
145
+ this.llmConfig.options?.thinking === 'enabled' ||
146
+ (isClaude37 && this.llmConfig.options?.thinking !== false);
147
+
148
+ const extraParams: any = {};
149
+ if (isThinkingEnabled && isClaude37) {
150
+ extraParams.betas = ['interleaved-thinking-2025-05-14'];
151
+ const maxTokens = options?.maxTokens ?? this.llmConfig.maxTokens ?? 4096;
152
+ const budget = Math.min(
153
+ typeof this.llmConfig.options?.thinkingBudget === 'number'
154
+ ? this.llmConfig.options?.thinkingBudget
155
+ : 2048,
156
+ maxTokens - 1
157
+ );
158
+ extraParams.thinking = {
159
+ type: 'enabled',
160
+ budget_tokens: budget,
161
+ };
162
+ extraParams.max_tokens = Math.max(maxTokens, budget + 1024);
163
+ }
164
+
120
165
  const stream = await this.client.messages.create({
121
166
  model: this.llmConfig.model,
122
167
  max_tokens: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
123
168
  system,
124
169
  messages: anthropicMessages,
125
170
  stream: true,
126
- });
171
+ ...extraParams,
172
+ } as any) as any;
127
173
 
128
174
  if (!stream) {
129
175
  throw new Error('[AnthropicProvider] messages.create stream is undefined');
130
176
  }
131
177
 
132
178
  for await (const chunk of stream) {
133
- if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
134
- yield chunk.delta.text;
179
+ if (chunk.type === 'content_block_delta') {
180
+ if (chunk.delta.type === 'text_delta') {
181
+ yield chunk.delta.text;
182
+ } else if ((chunk.delta as any).type === 'thinking_delta') {
183
+ yield `\x00THINKING\x00${(chunk.delta as any).thinking}`;
184
+ }
135
185
  }
136
186
  }
137
187
  }
@@ -20,6 +20,7 @@ import { ChatMessage, ChatOptions } from '../../types';
20
20
  import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
21
21
  import { IProviderValidator, IProviderHealthChecker, HealthCheckResult } from '../../core/ProviderInterfaces';
22
22
  import { ValidationError } from '../../core/ConfigValidator';
23
+ import { buildSystemContent } from '../utils';
23
24
 
24
25
  const DEFAULT_EMBEDDING_MODEL = 'text-embedding-004';
25
26
  const DEFAULT_TEMPERATURE = 0.7;
@@ -165,18 +166,6 @@ export class GeminiProvider implements ILLMProvider {
165
166
  return sanitizeModel(optionsModel ?? this.embeddingConfig?.model ?? DEFAULT_EMBEDDING_MODEL);
166
167
  }
167
168
 
168
- /**
169
- * Build the system instruction string, inserting the RAG context either via
170
- * the `{{context}}` placeholder or by appending it.
171
- */
172
- private buildSystemInstruction(context: string): string {
173
- const base =
174
- this.llmConfig.systemPrompt ??
175
- `You are a helpful assistant. Answer questions based on the provided context.\n\nContext:\n${context}`;
176
-
177
- return base.includes('{{context}}') ? base.replace('{{context}}', context) : `${base}\n\nContext:\n${context}`;
178
- }
179
-
180
169
  /**
181
170
  * Convert ChatMessage[] to the Gemini contents format.
182
171
  *
@@ -199,9 +188,14 @@ export class GeminiProvider implements ILLMProvider {
199
188
  // -------------------------------------------------------------------------
200
189
 
201
190
  async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
191
+ const basePrompt =
192
+ options?.systemPrompt ??
193
+ this.llmConfig.systemPrompt ??
194
+ 'You are a helpful assistant. Answer questions based on the provided context.';
195
+
202
196
  const model = this.client.getGenerativeModel({
203
197
  model: this.llmConfig.model,
204
- systemInstruction: this.buildSystemInstruction(context),
198
+ systemInstruction: buildSystemContent(basePrompt, context),
205
199
  });
206
200
 
207
201
  const result = await model.generateContent({
@@ -217,9 +211,14 @@ export class GeminiProvider implements ILLMProvider {
217
211
  }
218
212
 
219
213
  async *chatStream(messages: ChatMessage[], context: string, options?: ChatOptions): AsyncIterable<string> {
214
+ const basePrompt =
215
+ options?.systemPrompt ??
216
+ this.llmConfig.systemPrompt ??
217
+ 'You are a helpful assistant. Answer questions based on the provided context.';
218
+
220
219
  const model = this.client.getGenerativeModel({
221
220
  model: this.llmConfig.model,
222
- systemInstruction: this.buildSystemInstruction(context),
221
+ systemInstruction: buildSystemContent(basePrompt, context),
223
222
  });
224
223
 
225
224
  const result = await model.generateContentStream({
@@ -309,4 +308,4 @@ export class GeminiProvider implements ILLMProvider {
309
308
  return false;
310
309
  }
311
310
  }
312
- }
311
+ }
@@ -8,6 +8,7 @@ import { ChatMessage, ChatOptions } from '../../types';
8
8
  import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
9
9
  import { IProviderValidator, IProviderHealthChecker, HealthCheckResult } from '../../core/ProviderInterfaces';
10
10
  import { ValidationError } from '../../core/ConfigValidator';
11
+ import { buildSystemContent } from '../utils';
11
12
 
12
13
  interface OllamaChatResponse {
13
14
  message: { content: string };
@@ -24,7 +25,8 @@ export class OllamaProvider implements ILLMProvider {
24
25
 
25
26
  constructor(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig) {
26
27
  const baseURL = llmConfig.baseUrl ?? 'http://localhost:11434';
27
- this.http = axios.create({ baseURL, timeout: 120_000 });
28
+ const timeout = Number(llmConfig.options?.timeout) || 300_000;
29
+ this.http = axios.create({ baseURL, timeout });
28
30
  this.llmConfig = llmConfig;
29
31
  this.embeddingConfig = embeddingConfig;
30
32
  }
@@ -85,7 +87,11 @@ export class OllamaProvider implements ILLMProvider {
85
87
  }
86
88
 
87
89
  async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
88
- const system = this.buildSystemPrompt(context, options?.systemPrompt);
90
+ const basePrompt =
91
+ options?.systemPrompt ??
92
+ this.llmConfig.systemPrompt ??
93
+ 'You are a helpful assistant. Use the provided context to answer the user\'s question.';
94
+ const system = buildSystemContent(basePrompt, context);
89
95
 
90
96
  const { data } = await this.http.post<OllamaChatResponse>('/api/chat', {
91
97
  model: this.llmConfig.model,
@@ -104,7 +110,11 @@ export class OllamaProvider implements ILLMProvider {
104
110
  }
105
111
 
106
112
  async *chatStream(messages: ChatMessage[], context: string, options?: ChatOptions): AsyncIterable<string> {
107
- const system = this.buildSystemPrompt(context, options?.systemPrompt);
113
+ const basePrompt =
114
+ options?.systemPrompt ??
115
+ this.llmConfig.systemPrompt ??
116
+ 'You are a helpful assistant. Use the provided context to answer the user\'s question.';
117
+ const system = buildSystemContent(basePrompt, context);
108
118
 
109
119
  const response = await this.http.post('/api/chat', {
110
120
  model: this.llmConfig.model,
@@ -159,19 +169,6 @@ export class OllamaProvider implements ILLMProvider {
159
169
  }
160
170
  }
161
171
 
162
- private buildSystemPrompt(context: string, overridePrompt?: string): string {
163
- if (overridePrompt) {
164
- // Per-call override: use as-is (context is already embedded in user message)
165
- return overridePrompt;
166
- }
167
- const systemPrompt =
168
- this.llmConfig.systemPrompt ??
169
- `You are a helpful assistant. Use the provided context to answer the user's question.\n\nContext:\n${context}`;
170
-
171
- return systemPrompt.includes('{{context}}')
172
- ? systemPrompt.replace('{{context}}', context)
173
- : `${systemPrompt}\n\nContext:\n${context}`;
174
- }
175
172
 
176
173
  async embed(text: string, options?: EmbedOptions): Promise<number[]> {
177
174
  const model =
@@ -8,6 +8,7 @@ import { ChatMessage, ChatOptions } from '../../types';
8
8
  import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
9
9
  import { IProviderValidator, IProviderHealthChecker, HealthCheckResult } from '../../core/ProviderInterfaces';
10
10
  import { ValidationError } from '../../core/ConfigValidator';
11
+ import { buildSystemContent } from '../utils';
11
12
 
12
13
  export class OpenAIProvider implements ILLMProvider {
13
14
  private readonly client: OpenAI;
@@ -80,17 +81,14 @@ export class OpenAIProvider implements ILLMProvider {
80
81
  }
81
82
 
82
83
  async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
83
- const resolvedSystemPrompt = options?.systemPrompt ??
84
+ const basePrompt =
85
+ options?.systemPrompt ??
84
86
  this.llmConfig.systemPrompt ??
85
- `You are a helpful assistant. Answer questions based on the provided context.\n\nContext:\n${context}`;
87
+ 'You are a helpful assistant. Answer questions based on the provided context.';
86
88
 
87
89
  const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
88
90
  role: 'system',
89
- content: resolvedSystemPrompt.includes('{{context}}')
90
- ? resolvedSystemPrompt.replace('{{context}}', context)
91
- : options?.systemPrompt
92
- ? resolvedSystemPrompt // per-call override: use as-is, context already in user message
93
- : `${resolvedSystemPrompt}\n\nContext:\n${context}`,
91
+ content: buildSystemContent(basePrompt, context),
94
92
  };
95
93
 
96
94
  const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -113,17 +111,14 @@ export class OpenAIProvider implements ILLMProvider {
113
111
  }
114
112
 
115
113
  async *chatStream(messages: ChatMessage[], context: string, options?: ChatOptions): AsyncIterable<string> {
116
- const resolvedSystemPrompt = options?.systemPrompt ??
114
+ const basePrompt =
115
+ options?.systemPrompt ??
117
116
  this.llmConfig.systemPrompt ??
118
- `You are a helpful assistant. Answer questions based on the provided context.\n\nContext:\n${context}`;
117
+ 'You are a helpful assistant. Answer questions based on the provided context.';
119
118
 
120
119
  const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
121
120
  role: 'system',
122
- content: resolvedSystemPrompt.includes('{{context}}')
123
- ? resolvedSystemPrompt.replace('{{context}}', context)
124
- : options?.systemPrompt
125
- ? resolvedSystemPrompt
126
- : `${resolvedSystemPrompt}\n\nContext:\n${context}`,
121
+ content: buildSystemContent(basePrompt, context),
127
122
  };
128
123
 
129
124
  const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -31,20 +31,20 @@ export class UniversalLLMAdapter implements ILLMProvider {
31
31
 
32
32
  constructor(config: LLMConfig | EmbeddingConfig) {
33
33
  this.model = config.model;
34
-
34
+
35
35
  // Type narrow to extract LLM-specific fields safely
36
36
  const llmConfig = config as LLMConfig;
37
-
37
+
38
38
  // Merge profile defaults if specified in options
39
39
  const options = (llmConfig.options as Record<string, unknown>) ?? {};
40
40
  let profile: Record<string, unknown> = {};
41
-
41
+
42
42
  if (typeof options.profile === 'string') {
43
43
  profile = (LLM_PROFILES as Record<string, Record<string, unknown>>)[options.profile] || {};
44
44
  } else if (typeof options.profile === 'object' && options.profile !== null) {
45
45
  profile = options.profile as Record<string, unknown>;
46
46
  }
47
-
47
+
48
48
  this.opts = { ...profile, ...(llmConfig.options ?? {}) } as UniversalLLMOptions;
49
49
  this.systemPrompt = llmConfig.systemPrompt ?? 'You are a helpful AI assistant. Use the provided context to answer the user.';
50
50
  this.maxTokens = llmConfig.maxTokens ?? 1024;
@@ -71,7 +71,7 @@ export class UniversalLLMAdapter implements ILLMProvider {
71
71
 
72
72
  async chat(messages: ChatMessage[], context?: string): Promise<string> {
73
73
  const path = this.opts.chatPath ?? '/chat/completions';
74
-
74
+
75
75
  const formattedMessages = [
76
76
  { role: 'system', content: `${this.systemPrompt}\n\nContext:\n${context ?? 'None'}` },
77
77
  ...messages,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * LLM utility helpers shared across all provider implementations.
3
+ *
4
+ * These are pure functions with no external dependencies so they can
5
+ * be imported by any provider without creating circular references.
6
+ */
7
+
8
+ /**
9
+ * Builds the final system message content by injecting the RAG context
10
+ * into a system prompt using one of two strategies:
11
+ *
12
+ * 1. **Placeholder substitution** — if the prompt contains `{{context}}`,
13
+ * that token is replaced with the retrieved context string.
14
+ *
15
+ * 2. **Appendage** — if there is no placeholder, the context is appended
16
+ * after a separator so the LLM always receives the retrieved evidence,
17
+ * regardless of whether the caller supplied a per-call `systemPrompt`
18
+ * override.
19
+ *
20
+ * This function is intentionally strict: it never silently drops context.
21
+ * If `context` is empty or the sentinel "No relevant context found." the
22
+ * prompt is returned as-is so the LLM is not polluted with an empty block.
23
+ *
24
+ * @param systemPrompt - The base system prompt (from config or per-call override).
25
+ * @param context - The retrieved RAG context string.
26
+ * @returns - The resolved system message content ready to send.
27
+ */
28
+ export function buildSystemContent(systemPrompt: string, context: string): string {
29
+ const noContext =
30
+ !context ||
31
+ context.trim() === '' ||
32
+ context.trim() === 'No relevant context found.';
33
+
34
+ // Replace {{context}} placeholder regardless of whether context is empty
35
+ // so the prompt is never sent with a raw placeholder token.
36
+ if (systemPrompt.includes('{{context}}')) {
37
+ return systemPrompt.replace('{{context}}', noContext ? '' : context);
38
+ }
39
+
40
+ // Without a placeholder: only append context when there is actual content.
41
+ if (noContext) {
42
+ return systemPrompt;
43
+ }
44
+
45
+ return `${systemPrompt}\n\n---\nContext:\n${context}`;
46
+ }
@@ -153,7 +153,11 @@ export class MongoDBProvider extends BaseVectorProvider {
153
153
  if (key === 'namespace') {
154
154
  vectorSearchFilter.namespace = value;
155
155
  } else {
156
- matchFilter[key] = value;
156
+ if (typeof value === 'string') {
157
+ matchFilter[key] = { $regex: new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') };
158
+ } else {
159
+ matchFilter[key] = value;
160
+ }
157
161
  }
158
162
  }
159
163
 
@@ -191,12 +195,20 @@ export class MongoDBProvider extends BaseVectorProvider {
191
195
 
192
196
  const results = await this.collection!.aggregate(pipeline).toArray();
193
197
 
194
- return (results as unknown as Array<{ _id: string; score: number; [key: string]: unknown }>).map((res) => ({
195
- id: res._id,
196
- content: res[this.contentKey] as string,
197
- metadata: (res[this.metadataKey] as Record<string, unknown>) || {},
198
- score: res.score,
199
- }));
198
+ return (results as unknown as Array<{ _id: string; score: number; [key: string]: unknown }>).map((res) => {
199
+ // Atlas Vector Search returns cosine similarity mapped to [0, 1] via (1 + cos_sim) / 2.
200
+ // We reverse this mapping with (score * 2) - 1 to restore the standard cosine range
201
+ // of [-1, 1], so downstream scoreThreshold comparisons work consistently across
202
+ // all vector DB providers (which natively return values in [-1, 1]).
203
+ const normalizedScore = (res.score * 2) - 1;
204
+
205
+ return {
206
+ id: res._id,
207
+ content: res[this.contentKey] as string,
208
+ metadata: (res[this.metadataKey] as Record<string, unknown>) || {},
209
+ score: normalizedScore,
210
+ };
211
+ });
200
212
  }
201
213
 
202
214
  async delete(id: string | number, namespace?: string): Promise<void> {