@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/dist/handlers/index.js +776 -88
- package/dist/handlers/index.mjs +776 -88
- package/dist/index.js +24 -16
- package/dist/index.mjs +24 -16
- package/dist/server.js +776 -88
- package/dist/server.mjs +776 -88
- package/package.json +1 -1
- package/src/components/ChatWindow.tsx +22 -22
- package/src/core/Pipeline.ts +22 -40
- package/src/llm/providers/GeminiProvider.ts +4 -4
- package/src/llm/providers/GroqProvider.ts +16 -8
- package/src/llm/providers/OpenAIProvider.ts +16 -8
- package/src/llm/providers/QwenProvider.ts +16 -8
- package/src/llm/providers/UniversalLLMAdapter.ts +90 -18
- package/src/rendering/IntentClassifier.ts +3 -3
- package/src/rendering/rules/Rule1SpecificInfoRule.ts +1 -1
- package/src/utils/ProductExtractor.ts +4 -3
- package/src/utils/UITransformer.ts +16 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retrivora-ai/rag-engine",
|
|
3
|
-
"version": "2.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
298
|
-
<span className="
|
|
299
|
-
|
|
300
|
-
|
|
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" />}
|
package/src/core/Pipeline.ts
CHANGED
|
@@ -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 =
|
|
584
|
-
const retrievalLimit =
|
|
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 =
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
868
|
-
|
|
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
|
|
1007
|
-
|
|
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
|
|
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
|
|
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' ?
|
|
182
|
-
parts: [{ text: m.content
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
215
|
+
let dispatch = _g.__retrivoraDispatchChat;
|
|
216
|
+
if (typeof dispatch !== 'function') {
|
|
175
217
|
try {
|
|
176
|
-
const
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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
|
|
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.
|
|
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 : ''
|
|
225
|
+
description: description ? description : '',
|
|
225
226
|
};
|
|
226
227
|
});
|
|
227
228
|
}
|