@retrivora-ai/rag-engine 2.0.7 → 2.1.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrivora-ai/rag-engine",
3
- "version": "2.0.7",
3
+ "version": "2.1.1",
4
4
  "description": "Retrivora AI is a plug-and-play AI engine for RAG chat experiences — generic vector DB + LLM provider, embeddable or standalone.",
5
5
  "author": "Abhinav Alkuchi",
6
6
  "license": "UNLICENSED",
@@ -52,10 +52,10 @@ interface WindowWithSpeech extends Window {
52
52
  webkitSpeechRecognition?: new () => ISpeechRecognition;
53
53
  }
54
54
 
55
- export function ChatWindow({
56
- className = '',
57
- style,
58
- onClose,
55
+ export function ChatWindow({
56
+ className = '',
57
+ style,
58
+ onClose,
59
59
  showClose = false,
60
60
  onResizeStart,
61
61
  onResetResize,
@@ -129,7 +129,7 @@ export function ChatWindow({
129
129
  alert('Speech recognition is not supported in your browser.');
130
130
  return;
131
131
  }
132
-
132
+
133
133
  if (isListening) {
134
134
  recognitionRef.current.stop();
135
135
  } else {
@@ -178,7 +178,7 @@ export function ChatWindow({
178
178
  const sendMessage = useCallback(async () => {
179
179
  const text = input.trim();
180
180
  if (!text || isLoading) return;
181
-
181
+
182
182
  if (isListening) {
183
183
  recognitionRef.current?.stop();
184
184
  }
@@ -250,11 +250,10 @@ export function ChatWindow({
250
250
  return (
251
251
  <div
252
252
  ref={windowRef}
253
- className={`relative flex flex-col h-full flex-1 border border-slate-200 dark:border-white/10 shadow-2xl transition-all duration-300 ${currentRadius} ${
254
- isGlass
255
- ? 'bg-white/90 dark:bg-[#0f0f1a]/90 backdrop-blur-xl'
253
+ className={`relative flex flex-col h-full flex-1 border border-slate-200 dark:border-white/10 shadow-2xl transition-all duration-300 ${currentRadius} ${isGlass
254
+ ? 'bg-white/90 dark:bg-[#0f0f1a]/90 backdrop-blur-xl'
256
255
  : 'bg-white dark:bg-[#0f0f1a]'
257
- } ${className}`}
256
+ } ${className}`}
258
257
  style={{ '--primary': ui.primaryColor, '--accent': ui.accentColor, ...style } as React.CSSProperties}
259
258
  >
260
259
  {/* Resize Handle (Top-Left) */}
@@ -293,11 +292,13 @@ export function ChatWindow({
293
292
  </div>
294
293
 
295
294
  <div className="flex items-center gap-1.5">
296
- {/* Online indicator */}
297
- <span className="flex items-center gap-1 text-[10px] text-emerald-500 dark:text-emerald-400 mr-2">
298
- <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400 animate-pulse" />
299
- Online
300
- </span>
295
+ {/* Online indicator & Deployment ID badge */}
296
+ <div className="flex items-center gap-1.5 mr-2">
297
+ <span className="flex items-center gap-1 text-[10px] text-emerald-500 dark:text-emerald-400">
298
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400 animate-pulse" />
299
+ Online
300
+ </span>
301
+ </div>
301
302
 
302
303
  <div className="flex items-center bg-slate-100/50 dark:bg-white/5 rounded-lg p-0.5 border border-slate-200/50 dark:border-white/5">
303
304
  {mounted && messages.length > 0 && (
@@ -427,9 +428,9 @@ export function ChatWindow({
427
428
  >
428
429
  <X className="w-5 h-5" />
429
430
  </button>
430
- <DocumentUpload
431
- namespace={projectId}
432
- onUploadComplete={() => setIsUploadModalOpen(false)}
431
+ <DocumentUpload
432
+ namespace={projectId}
433
+ onUploadComplete={() => setIsUploadModalOpen(false)}
433
434
  />
434
435
  </div>
435
436
  </div>
@@ -437,7 +438,7 @@ export function ChatWindow({
437
438
 
438
439
  {/* ── Input ── */}
439
440
  <div className={`px-4 pb-4 pt-2 border-t border-slate-200 dark:border-white/10 ${isGlass ? 'bg-transparent' : 'bg-white dark:bg-[#0f0f1a]'}`}>
440
-
441
+
441
442
  {/* Dynamic Suggestions */}
442
443
  {(suggestions.length > 0 || isSuggesting) && !isLoading && (
443
444
  <div className="mb-3 flex flex-wrap gap-2 animate-in fade-in slide-in-from-bottom-2 duration-300">
@@ -488,11 +489,10 @@ export function ChatWindow({
488
489
  type="button"
489
490
  onClick={toggleListening}
490
491
  disabled={isLoading}
491
- className={`flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100 ${
492
- isListening
492
+ className={`flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100 ${isListening
493
493
  ? 'bg-rose-500 text-white animate-pulse shadow-md shadow-rose-500/20'
494
494
  : 'text-slate-400 dark:text-white/40 hover:bg-slate-200 dark:hover:bg-white/10'
495
- }`}
495
+ }`}
496
496
  title={isListening ? 'Stop listening' : 'Start voice input'}
497
497
  >
498
498
  {isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
@@ -580,8 +580,8 @@ You are a helpful assistant. Use the provided context to answer questions accura
580
580
 
581
581
  const hasMetadataFilter = filter.metadata && Object.keys(filter.metadata).length > 0;
582
582
  const hasNumericPredicates = Array.isArray(filter.__numericPredicates) && filter.__numericPredicates.length > 0;
583
- const wantsExhaustiveList = hasNumericPredicates || hasMetadataFilter || /\b(list|all|show|provide|give|get|browse|find|search|display)\b/i.test(question);
584
- const retrievalLimit = wantsExhaustiveList ? Math.max(topK * 20, 100) : topK * 2;
583
+ const wantsExhaustiveList = true; // Always query all available resources across the database
584
+ const retrievalLimit = Math.max(topK * 50, 1000);
585
585
 
586
586
  const rawSources = ((strategyResult === 'vector' || strategyResult === 'both') && queryVector && queryVector.length > 0
587
587
  ? await this.vectorDB.query(queryVector, retrievalLimit, ns, filter)
@@ -602,52 +602,37 @@ You are a helpful assistant. Use the provided context to answer questions accura
602
602
  const embedMs = retrieveEnd - embedStart;
603
603
  const retrieveMs = retrieveEnd - embedStart;
604
604
 
605
- // 3. Reranking
605
+ // 3. Reranking & Filtering across ALL matching resources
606
606
  const rerankStart = performance.now();
607
607
  const structuredSources = this.applyStructuredFilters(rawSources, filter).filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'));
608
608
  let fullSources = hasNumericPredicates
609
609
  ? structuredSources
610
610
  : structuredSources.filter((m) => (m?.score ?? 0) >= scoreThreshold);
611
- const rerankLimit = wantsExhaustiveList ? retrievalLimit : topK;
611
+ const rerankLimit = Math.max(retrievalLimit, fullSources.length);
612
612
  const useReranking = arch !== 'naive' && (arch === 'retrieve-and-rerank' || this.config.rag?.useReranking);
613
613
  if (!hasNumericPredicates && useReranking) {
614
614
  fullSources = (await this.reranker.rerank(fullSources, question, rerankLimit)).filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'));
615
- } else if (!wantsExhaustiveList) {
616
- fullSources = fullSources.slice(0, topK);
617
615
  }
618
616
  const rerankMs = performance.now() - rerankStart;
619
617
 
620
- // 4. Context Augmentation - Review all retrieved/reranked sources for full comprehension
621
- let context = fullSources.length
622
- ? fullSources.map((m, i) => `[Source ${i + 1}]\n${m?.content ?? ''}`).join('\n\n---\n\n')
618
+ // 4. Context Augmentation Cap context sent to LLM to top 15 sources (~3000 tokens)
619
+ // to keep prompt under Groq's 6000 token limit while preserving ALL sources for UI citations.
620
+ const totalCount = fullSources.length;
621
+ const promptSources = fullSources.slice(0, 15);
622
+ let context = promptSources.length
623
+ ? `[SEARCH SUMMARY: Found ${totalCount} matching record(s) out of ${rawSources.length} total resources searched in database. Context includes top ${promptSources.length} relevant records]\n\n` +
624
+ promptSources.map((m, i) => `[Source ${i + 1}]\n${m?.content ?? ''}`).join('\n\n---\n\n')
623
625
  : 'No relevant context found.';
624
626
 
625
- // Count the records of relevant sources to pass into the slice dynamically
626
- let displayCount = 15; // default fallback
627
-
628
- if (hasMetadataFilter) {
629
- // If a metadata filter matches (e.g. brand, category), all of those matches are highly relevant
630
- displayCount = fullSources.length;
631
- } else {
632
- // For general semantic queries, count the number of matches meeting high relevance threshold
633
- const baseThreshold = Math.max(scoreThreshold, 0.4);
634
- const highlyRelevant = fullSources.filter(m => (m?.score ?? 0) >= baseThreshold);
635
- displayCount = Math.max(highlyRelevant.length, topK);
636
- // Cap non-metadata semantic searches to a maximum of 15 to keep the UI clean
637
- if (displayCount > 15) {
638
- displayCount = 15;
639
- }
640
- }
641
-
642
- // Ensure they are sorted from highest to lowest score, then slice using our dynamic count
627
+ // Do NOT truncate UI sources keep all qualified matches for UI citations & tables
643
628
  const sources = [...fullSources]
644
629
  .filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'))
645
- .sort((a, b) => (b?.score ?? 0) - (a?.score ?? 0))
646
- .slice(0, displayCount);
630
+ .sort((a, b) => (b?.score ?? 0) - (a?.score ?? 0));
647
631
 
648
632
  console.log('[Pipeline] Final sources for prompt & UI:', {
649
633
  count: sources.length,
650
- sources: sources.map((s, idx) => ({
634
+ totalRawCount: rawSources.length,
635
+ sources: sources.slice(0, 10).map((s, idx) => ({
651
636
  rank: idx + 1,
652
637
  id: s?.id,
653
638
  score: s?.score,
@@ -864,10 +849,8 @@ You are a helpful assistant. Use the provided context to answer questions accura
864
849
  yield fullReply;
865
850
  }
866
851
 
867
- // Kick off hallucination scoring now that we have the full reply.
868
- // It runs in parallel with the remaining post-processing below.
869
- const runHallucination = this.config.llm.options?.hallucinationScoring === true ||
870
- (this.config.llm.provider !== 'ollama' && this.config.llm.options?.hallucinationScoring !== false);
852
+ // Kick off hallucination scoring ONLY when explicitly enabled by options
853
+ const runHallucination = this.config.llm.options?.hallucinationScoring === true;
871
854
  if (runHallucination) {
872
855
  hallucinationScoringPromise = scoreHallucination(this.llmProvider, fullReply, context)
873
856
  .catch(() => undefined);
@@ -1003,9 +986,8 @@ You are a helpful assistant. Use the provided context to answer questions accura
1003
986
  );
1004
987
  }
1005
988
 
1006
- const isLocalProvider = this.config.llm.provider === 'ollama';
1007
- const disableLlmUiTransform = this.config.llm.options?.disableLlmUiTransform === true;
1008
- if (forceDeterministic || isLocalProvider || disableLlmUiTransform) {
989
+ const enableLlmUiTransform = this.config.llm.options?.enableLlmUiTransform === true;
990
+ if (forceDeterministic || !enableLlmUiTransform) {
1009
991
  return UITransformer.transform(question, sources, this.config, cachedSchema);
1010
992
  }
1011
993
 
@@ -1211,7 +1193,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
1211
1193
  Focus on extracting the core intent and entities. Do not answer the question, just rewrite it.
1212
1194
 
1213
1195
  History:
1214
- ${history.map(m => `${m.role}: ${m.content}`).join('\n')}
1196
+ ${(history || []).map(m => `${m?.role || 'user'}: ${m?.content ?? ''}`).join('\n')}
1215
1197
 
1216
1198
  New Question: ${question}
1217
1199
 
@@ -1240,9 +1222,9 @@ Optimized Search Query:`;
1240
1222
 
1241
1223
  try {
1242
1224
  const { sources } = await this.retrieve(query, { namespace: ns, topK: 5 });
1243
- if (sources.length === 0) return [];
1225
+ if (!sources || sources.length === 0) return [];
1244
1226
 
1245
- const context = sources.map((s) => s.content).join('\n\n---\n\n');
1227
+ const context = sources.map((s) => s?.content ?? '').join('\n\n---\n\n');
1246
1228
  const prompt = `Based on the following snippets from a document, what are at least 5 short, relevant questions a user might ask?
1247
1229
  Focus on questions that can be answered by the context.
1248
1230
  Keep each question under 10 words and make them very specific to the content.
@@ -175,11 +175,11 @@ export class GeminiProvider implements ILLMProvider {
175
175
  * - Messages must strictly alternate between `user` and `model`.
176
176
  */
177
177
  private buildGeminiContents(messages: ChatMessage[]): Array<{ role: 'user' | 'model'; parts: [{ text: string }] }> {
178
- return messages
179
- .filter((m) => m.role !== 'system')
178
+ return (messages || [])
179
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object' && m.role !== 'system'))
180
180
  .map((m) => ({
181
- role: m.role === 'assistant' ? ('model' as const) : ('user' as const),
182
- parts: [{ text: m.content }] as [{ text: string }],
181
+ role: m.role === 'assistant' ? 'model' : 'user',
182
+ parts: [{ text: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : '') }],
183
183
  }));
184
184
  }
185
185
 
@@ -95,12 +95,16 @@ export class GroqProvider implements ILLMProvider {
95
95
  content: buildSystemContent(basePrompt, context),
96
96
  };
97
97
 
98
+ const safeMessages = (Array.isArray(messages) ? messages : [])
99
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
100
+ .map((m) => ({
101
+ role: (m.role || 'user') as 'user' | 'assistant',
102
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
103
+ }));
104
+
98
105
  const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
99
106
  systemMessage,
100
- ...messages.map((m) => ({
101
- role: m.role as 'user' | 'assistant',
102
- content: m.content,
103
- })),
107
+ ...safeMessages,
104
108
  ];
105
109
 
106
110
  const completion = await this.client.chat.completions.create({
@@ -125,12 +129,16 @@ export class GroqProvider implements ILLMProvider {
125
129
  content: buildSystemContent(basePrompt, context),
126
130
  };
127
131
 
132
+ const safeStreamMessages = (Array.isArray(messages) ? messages : [])
133
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
134
+ .map((m) => ({
135
+ role: (m.role || 'user') as 'user' | 'assistant',
136
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
137
+ }));
138
+
128
139
  const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
129
140
  systemMessage,
130
- ...messages.map((m) => ({
131
- role: m.role as 'user' | 'assistant',
132
- content: m.content,
133
- })),
141
+ ...safeStreamMessages,
134
142
  ];
135
143
 
136
144
  const stream = await this.client.chat.completions.create({
@@ -91,12 +91,16 @@ export class OpenAIProvider implements ILLMProvider {
91
91
  content: buildSystemContent(basePrompt, context),
92
92
  };
93
93
 
94
+ const safeMessages = (Array.isArray(messages) ? messages : [])
95
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
96
+ .map((m) => ({
97
+ role: (m.role || 'user') as 'user' | 'assistant',
98
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
99
+ }));
100
+
94
101
  const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
95
102
  systemMessage,
96
- ...messages.map((m) => ({
97
- role: m.role as 'user' | 'assistant',
98
- content: m.content,
99
- })),
103
+ ...safeMessages,
100
104
  ];
101
105
 
102
106
  const completion = await this.client.chat.completions.create({
@@ -121,12 +125,16 @@ export class OpenAIProvider implements ILLMProvider {
121
125
  content: buildSystemContent(basePrompt, context),
122
126
  };
123
127
 
128
+ const safeStreamMessages = (Array.isArray(messages) ? messages : [])
129
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
130
+ .map((m) => ({
131
+ role: (m.role || 'user') as 'user' | 'assistant',
132
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
133
+ }));
134
+
124
135
  const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
125
136
  systemMessage,
126
- ...messages.map((m) => ({
127
- role: m.role as 'user' | 'assistant',
128
- content: m.content,
129
- })),
137
+ ...safeStreamMessages,
130
138
  ];
131
139
 
132
140
  const stream = await this.client.chat.completions.create({
@@ -99,12 +99,16 @@ export class QwenProvider implements ILLMProvider {
99
99
  content: buildSystemContent(basePrompt, context),
100
100
  };
101
101
 
102
+ const safeMessages = (Array.isArray(messages) ? messages : [])
103
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
104
+ .map((m) => ({
105
+ role: (m.role || 'user') as 'user' | 'assistant',
106
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
107
+ }));
108
+
102
109
  const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
103
110
  systemMessage,
104
- ...messages.map((m) => ({
105
- role: m.role as 'user' | 'assistant',
106
- content: m.content,
107
- })),
111
+ ...safeMessages,
108
112
  ];
109
113
 
110
114
  const completion = await this.client.chat.completions.create({
@@ -129,12 +133,16 @@ export class QwenProvider implements ILLMProvider {
129
133
  content: buildSystemContent(basePrompt, context),
130
134
  };
131
135
 
136
+ const safeStreamMessages = (Array.isArray(messages) ? messages : [])
137
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
138
+ .map((m) => ({
139
+ role: (m.role || 'user') as 'user' | 'assistant',
140
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
141
+ }));
142
+
132
143
  const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
133
144
  systemMessage,
134
- ...messages.map((m) => ({
135
- role: m.role as 'user' | 'assistant',
136
- content: m.content,
137
- })),
145
+ ...safeStreamMessages,
138
146
  ];
139
147
 
140
148
  const stream = await this.client.chat.completions.create({
@@ -18,6 +18,17 @@ interface UniversalLLMOptions {
18
18
  timeout?: number;
19
19
  }
20
20
 
21
+ export function extractContent(obj: unknown): string | undefined {
22
+ if (!obj || typeof obj !== 'object') return undefined;
23
+ const choice = (obj as any).choices?.[0];
24
+ if (!choice) return undefined;
25
+ if (typeof choice.message?.content === 'string') return choice.message.content;
26
+ if (typeof choice.delta?.content === 'string') return choice.delta.content;
27
+ if (typeof choice.text === 'string') return choice.text;
28
+ if (typeof choice.content === 'string') return choice.content;
29
+ return undefined;
30
+ }
31
+
21
32
  export class UniversalLLMAdapter implements ILLMProvider {
22
33
  private readonly http: AxiosInstance;
23
34
  private readonly model: string;
@@ -72,9 +83,16 @@ export class UniversalLLMAdapter implements ILLMProvider {
72
83
  async chat(messages: ChatMessage[], context?: string): Promise<string> {
73
84
  const path = this.opts.chatPath ?? '/chat/completions';
74
85
 
86
+ const safeMessages = (Array.isArray(messages) ? messages : [])
87
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
88
+ .map((m) => ({
89
+ role: m.role || 'user',
90
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
91
+ }));
92
+
75
93
  const formattedMessages = [
76
94
  { role: 'system', content: `${this.systemPrompt}\n\nContext:\n${context ?? 'None'}` },
77
- ...messages,
95
+ ...safeMessages,
78
96
  ];
79
97
 
80
98
  let payload: unknown;
@@ -97,28 +115,44 @@ export class UniversalLLMAdapter implements ILLMProvider {
97
115
  const isSelfHost = this.baseUrl.includes('retrivora.com') || this.baseUrl.includes('localhost');
98
116
  if (isSelfHost || Boolean(process.env.VERCEL)) {
99
117
  const _g = globalThis as any;
100
- if (typeof _g.__retrivoraDispatchChat === 'function') {
118
+ let dispatch = _g.__retrivoraDispatchChat;
119
+ if (typeof dispatch !== 'function') {
120
+ try {
121
+ const gateway = await import('../../../../../services/llm-gateway/router');
122
+ if (gateway?.dispatchChatCompletion) {
123
+ _g.__retrivoraDispatchChat = gateway.dispatchChatCompletion;
124
+ _g.__retrivoraDispatchEmbedding = gateway.dispatchEmbedding;
125
+ dispatch = gateway.dispatchChatCompletion;
126
+ }
127
+ } catch { /* proceed */ }
128
+ }
129
+
130
+ if (typeof dispatch === 'function') {
101
131
  try {
102
- const res = await _g.__retrivoraDispatchChat({
132
+ const res = await dispatch({
103
133
  model: this.model,
104
134
  messages: formattedMessages as any,
105
135
  max_tokens: this.maxTokens,
106
136
  temperature: this.temperature,
107
137
  }, this.apiKey);
108
138
 
109
- if (res?.response?.choices?.[0]?.message?.content) {
110
- return res.response.choices[0].message.content;
139
+ const content = extractContent(res?.response);
140
+ if (content !== undefined) {
141
+ return content;
111
142
  }
143
+ throw new Error(`[UniversalLLMAdapter] In-process dispatch returned empty content for model: ${this.model}`);
112
144
  } catch (dispatchErr) {
113
145
  throw dispatchErr;
114
146
  }
147
+ } else {
148
+ throw new Error(`[UniversalLLMAdapter] In-process gateway dispatch not registered. Direct self-referential HTTP calls to ${this.baseUrl} are disabled on Vercel to prevent HTTP 508 Loop Detected.`);
115
149
  }
116
150
  }
117
151
 
118
152
  const { data } = await this.http.post(path, payload);
119
153
 
120
154
  const extractPath = this.opts.responseExtractPath ?? 'choices[0].message.content';
121
- const result = resolvePath(data, extractPath);
155
+ const result = resolvePath(data, extractPath) ?? extractContent(data);
122
156
 
123
157
  if (result === undefined) {
124
158
  throw new Error(`[UniversalLLMAdapter] Could not extract text from path '${extractPath}' in response.`);
@@ -139,9 +173,16 @@ export class UniversalLLMAdapter implements ILLMProvider {
139
173
  const extractPath = (this.opts.responseExtractPath ?? 'choices[0].message.content')
140
174
  .replace('message.content', 'delta.content');
141
175
 
176
+ const safeMessages = (Array.isArray(messages) ? messages : [])
177
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
178
+ .map((m) => ({
179
+ role: m.role || 'user',
180
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
181
+ }));
182
+
142
183
  const formattedMessages = [
143
184
  { role: 'system', content: `${this.systemPrompt}\n\nContext:\n${context ?? 'None'}` },
144
- ...messages,
185
+ ...safeMessages,
145
186
  ];
146
187
 
147
188
  let payload: unknown;
@@ -171,9 +212,21 @@ export class UniversalLLMAdapter implements ILLMProvider {
171
212
 
172
213
  if (isSelfHost || Boolean(process.env.VERCEL)) {
173
214
  const _g = globalThis as any;
174
- if (typeof _g.__retrivoraDispatchChat === 'function') {
215
+ let dispatch = _g.__retrivoraDispatchChat;
216
+ if (typeof dispatch !== 'function') {
175
217
  try {
176
- const res = await _g.__retrivoraDispatchChat({
218
+ const gateway = await import('../../../../../services/llm-gateway/router');
219
+ if (gateway?.dispatchChatCompletion) {
220
+ _g.__retrivoraDispatchChat = gateway.dispatchChatCompletion;
221
+ _g.__retrivoraDispatchEmbedding = gateway.dispatchEmbedding;
222
+ dispatch = gateway.dispatchChatCompletion;
223
+ }
224
+ } catch { /* proceed */ }
225
+ }
226
+
227
+ if (typeof dispatch === 'function') {
228
+ try {
229
+ const res = await dispatch({
177
230
  model: this.model,
178
231
  messages: formattedMessages as any,
179
232
  max_tokens: this.maxTokens,
@@ -183,15 +236,19 @@ export class UniversalLLMAdapter implements ILLMProvider {
183
236
 
184
237
  if (res?.stream) {
185
238
  streamBody = res.stream as ReadableStream<Uint8Array>;
186
- } else if (res?.response?.choices?.[0]?.message?.content) {
187
- yield res.response.choices[0].message.content;
188
- return;
239
+ } else {
240
+ const content = extractContent(res?.response);
241
+ if (content !== undefined) {
242
+ yield content;
243
+ return;
244
+ }
245
+ throw new Error(`[UniversalLLMAdapter] In-process dispatch stream returned empty response for model: ${this.model}`);
189
246
  }
190
247
  } catch (dispatchErr) {
191
- // If in-process dispatch threw a real error (Rate limit, Auth, API error), rethrow it
192
- // rather than silently falling back to external HTTP fetch which masks the error.
193
248
  throw dispatchErr;
194
249
  }
250
+ } else {
251
+ throw new Error(`[UniversalLLMAdapter] In-process gateway dispatch not registered. Direct self-referential HTTP calls to ${this.baseUrl} are disabled on Vercel to prevent HTTP 508 Loop Detected.`);
195
252
  }
196
253
  }
197
254
 
@@ -233,7 +290,7 @@ export class UniversalLLMAdapter implements ILLMProvider {
233
290
 
234
291
  try {
235
292
  const json = JSON.parse(trimmed.slice(5).trim());
236
- const text = resolvePath(json, extractPath);
293
+ const text = (resolvePath(json, extractPath) as string | undefined) ?? extractContent(json);
237
294
  if (text && typeof text === 'string') yield text;
238
295
  } catch {
239
296
  // Malformed SSE line — skip
@@ -246,7 +303,7 @@ export class UniversalLLMAdapter implements ILLMProvider {
246
303
  const jsonStr = buffer.replace(/^data:\s*/, '').trim();
247
304
  try {
248
305
  const json = JSON.parse(jsonStr);
249
- const text = resolvePath(json, extractPath);
306
+ const text = (resolvePath(json, extractPath) as string | undefined) ?? extractContent(json);
250
307
  if (text && typeof text === 'string') yield text;
251
308
  } catch { /* ignore final partial line */ }
252
309
  }
@@ -274,9 +331,21 @@ export class UniversalLLMAdapter implements ILLMProvider {
274
331
  const isSelfHost = this.baseUrl.includes('retrivora.com') || this.baseUrl.includes('localhost');
275
332
  if (isSelfHost || Boolean(process.env.VERCEL)) {
276
333
  const _g = globalThis as any;
277
- if (typeof _g.__retrivoraDispatchEmbedding === 'function') {
334
+ let dispatch = _g.__retrivoraDispatchEmbedding;
335
+ if (typeof dispatch !== 'function') {
336
+ try {
337
+ const gateway = await import('../../../../../services/llm-gateway/router');
338
+ if (gateway?.dispatchEmbedding) {
339
+ _g.__retrivoraDispatchChat = gateway.dispatchChatCompletion;
340
+ _g.__retrivoraDispatchEmbedding = gateway.dispatchEmbedding;
341
+ dispatch = gateway.dispatchEmbedding;
342
+ }
343
+ } catch { /* proceed */ }
344
+ }
345
+
346
+ if (typeof dispatch === 'function') {
278
347
  try {
279
- const res = await _g.__retrivoraDispatchEmbedding({
348
+ const res = await dispatch({
280
349
  model: this.model,
281
350
  input: text,
282
351
  }, this.apiKey);
@@ -284,9 +353,12 @@ export class UniversalLLMAdapter implements ILLMProvider {
284
353
  if (res?.data?.[0]?.embedding) {
285
354
  return res.data[0].embedding;
286
355
  }
356
+ throw new Error(`[UniversalLLMAdapter] In-process embedding dispatch returned invalid vector for model: ${this.model}`);
287
357
  } catch (dispatchErr) {
288
358
  throw dispatchErr;
289
359
  }
360
+ } else {
361
+ throw new Error(`[UniversalLLMAdapter] In-process gateway dispatch not registered. Direct self-referential HTTP calls to ${this.baseUrl} are disabled on Vercel to prevent HTTP 508 Loop Detected.`);
290
362
  }
291
363
  }
292
364
 
@@ -126,10 +126,10 @@ export class IntentClassifier {
126
126
  // ─── Intent Heuristic Checkers ─────────────────────────────────────────────
127
127
 
128
128
  private static isInformationLookup(query: string): boolean {
129
- // Exact single-fact pattern matches e.g. "What is X?", "Explain Y", "Price of Z", "Can Auvira be assigned..."
130
- if (/^(what|who|explain|define|where|when|why|how|how does|can|could|does|do|is|are|has|have|will|should|would|meaning of)\s+/i.test(query)) {
129
+ // Exact single-fact pattern matches e.g. "What is X?", "Explain Y", "Explore Z", "Tell me about A"
130
+ if (/^(what|who|explain|explore|define|where|when|why|how|how does|can|could|does|do|is|are|has|have|will|should|would|meaning of|tell me about|show details of|overview of|summarize)\s+/i.test(query)) {
131
131
  // Exclude comparison or analytical phrasing inside "what is the trend..."
132
- if (!/\b(compare|versus|vs\.?|trend|monthly|revenue|top selling|best)\b/i.test(query)) {
132
+ if (!/\b(compare|versus|vs\.?|trend|monthly|revenue|top 10|top selling|best|chart|graph|plot|breakdown|analytics)\b/i.test(query)) {
133
133
  return true;
134
134
  }
135
135
  }
@@ -10,7 +10,7 @@ export class Rule1SpecificInfoRule implements IRenderRule {
10
10
  // Check specific fact/definition/explanation indicators
11
11
  const isFactQuery =
12
12
  intent === 'information_lookup' ||
13
- /^(what|who|explain|define|where|when|why|meaning of)\s+/i.test(q) ||
13
+ /^(what|who|explain|explore|define|where|when|why|meaning of|tell me about|show details of|overview of|summarize)\s+/i.test(q) ||
14
14
  /^(what is the price of|price of|cost of)\b/i.test(q);
15
15
 
16
16
  const isNonVisual = !/\b(compare|versus|vs\.?|trend|monthly|revenue|best|top 10|chart|table|list|grid)\b/i.test(q);
@@ -196,6 +196,7 @@ export function isLikelyUiOnlyMessage(text: string): boolean {
196
196
  export function extractProductsFromSources(sources: VectorMatch[] | undefined, isUser: boolean): Product[] {
197
197
  if (isUser || !sources) return [];
198
198
  return sources
199
+ .filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'))
199
200
  .filter(s => {
200
201
  const m = s.metadata ?? {};
201
202
  const keys = Object.keys(m).map(k => k.toLowerCase());
@@ -204,7 +205,7 @@ export function extractProductsFromSources(sources: VectorMatch[] | undefined, i
204
205
  );
205
206
  const hasProductIdentity = keys.some(k => ['brand', 'model'].includes(k)) &&
206
207
  keys.some(k => ['name', 'title', 'product_name', 'product'].includes(k));
207
- const hasPricePattern = /\$\s*\d+/.test(s.content);
208
+ const hasPricePattern = /\$\s*\d+/.test(s?.content ?? '');
208
209
  return hasStrongProductKey || hasProductIdentity || hasPricePattern || m.type === 'product';
209
210
  })
210
211
  .map(s => {
@@ -215,13 +216,13 @@ export function extractProductsFromSources(sources: VectorMatch[] | undefined, i
215
216
  const description = resolveMetadataValue(m, 'description') as string;
216
217
 
217
218
  return {
218
- id: s.id,
219
+ id: s?.id ?? `prod_${Math.random().toString(36).substring(2, 8)}`,
219
220
  name: name ?? (s?.content ?? '').split('\n')[0] ?? 'Unknown Product',
220
221
  brand: brand,
221
222
  price: price,
222
223
  image: resolveImage(m),
223
224
  link: resolveMetadataValue(m, 'link') as string,
224
- description: description ? description : '' //description ?? s.content,
225
+ description: description ? description : '',
225
226
  };
226
227
  });
227
228
  }