@retrivora-ai/rag-engine 2.0.6 → 2.0.8

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.6",
3
+ "version": "2.0.8",
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",
@@ -580,58 +580,67 @@ 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
- const rawSources = (strategyResult === 'vector' || strategyResult === 'both') && queryVector && queryVector.length > 0
586
+ const rawSources = ((strategyResult === 'vector' || strategyResult === 'both') && queryVector && queryVector.length > 0
587
587
  ? await this.vectorDB.query(queryVector, retrievalLimit, ns, filter)
588
- : [];
588
+ : []).filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'));
589
+
590
+ console.log('[Pipeline] Raw vectorDB.query response:', {
591
+ rawCount: rawSources.length,
592
+ sample: rawSources.slice(0, 2).map((s) => ({
593
+ id: s?.id,
594
+ score: s?.score,
595
+ contentType: typeof s?.content,
596
+ contentSnippet: String(s?.content ?? '').substring(0, 150),
597
+ metadataKeys: Object.keys(s?.metadata || {}),
598
+ })),
599
+ });
589
600
 
590
601
  const retrieveEnd = performance.now();
591
602
  const embedMs = retrieveEnd - embedStart;
592
603
  const retrieveMs = retrieveEnd - embedStart;
593
604
 
594
- // 3. Reranking
605
+ // 3. Reranking & Filtering across ALL matching resources
595
606
  const rerankStart = performance.now();
596
- const structuredSources = this.applyStructuredFilters(rawSources, filter);
607
+ const structuredSources = this.applyStructuredFilters(rawSources, filter).filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'));
597
608
  let fullSources = hasNumericPredicates
598
609
  ? structuredSources
599
- : structuredSources.filter((m) => m.score >= scoreThreshold);
600
- const rerankLimit = wantsExhaustiveList ? retrievalLimit : topK;
610
+ : structuredSources.filter((m) => (m?.score ?? 0) >= scoreThreshold);
611
+ const rerankLimit = Math.max(retrievalLimit, fullSources.length);
601
612
  const useReranking = arch !== 'naive' && (arch === 'retrieve-and-rerank' || this.config.rag?.useReranking);
602
613
  if (!hasNumericPredicates && useReranking) {
603
- fullSources = await this.reranker.rerank(fullSources, question, rerankLimit);
604
- } else if (!wantsExhaustiveList) {
605
- fullSources = fullSources.slice(0, topK);
614
+ fullSources = (await this.reranker.rerank(fullSources, question, rerankLimit)).filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'));
606
615
  }
607
616
  const rerankMs = performance.now() - rerankStart;
608
617
 
609
- // 4. Context Augmentation - Review all retrieved/reranked sources for full comprehension
610
- let context = fullSources.length
611
- ? 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')
612
625
  : 'No relevant context found.';
613
626
 
614
- // Count the records of relevant sources to pass into the slice dynamically
615
- let displayCount = 15; // default fallback
616
-
617
- if (hasMetadataFilter) {
618
- // If a metadata filter matches (e.g. brand, category), all of those matches are highly relevant
619
- displayCount = fullSources.length;
620
- } else {
621
- // For general semantic queries, count the number of matches meeting high relevance threshold
622
- const baseThreshold = Math.max(scoreThreshold, 0.4);
623
- const highlyRelevant = fullSources.filter(m => m.score >= baseThreshold);
624
- displayCount = Math.max(highlyRelevant.length, topK);
625
- // Cap non-metadata semantic searches to a maximum of 15 to keep the UI clean
626
- if (displayCount > 15) {
627
- displayCount = 15;
628
- }
629
- }
630
-
631
- // 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
632
628
  const sources = [...fullSources]
633
- .sort((a, b) => b.score - a.score)
634
- .slice(0, displayCount);
629
+ .filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'))
630
+ .sort((a, b) => (b?.score ?? 0) - (a?.score ?? 0));
631
+
632
+ console.log('[Pipeline] Final sources for prompt & UI:', {
633
+ count: sources.length,
634
+ totalRawCount: rawSources.length,
635
+ sources: sources.slice(0, 10).map((s, idx) => ({
636
+ rank: idx + 1,
637
+ id: s?.id,
638
+ score: s?.score,
639
+ contentType: typeof s?.content,
640
+ contentSnippet: String(s?.content ?? '').substring(0, 150),
641
+ metadata: s?.metadata,
642
+ })),
643
+ });
635
644
 
636
645
  if (graphData && graphData.nodes.length > 0) {
637
646
  const graphContext = graphData.nodes.map(n =>
@@ -653,17 +662,17 @@ You are a helpful assistant. Use the provided context to answer questions accura
653
662
  // Yield sources immediately to support early UI citation rendering (highly responsive)
654
663
  yield {
655
664
  reply: '',
656
- sources: sources.map((s) => ({
665
+ sources: (sources || []).filter((s): s is VectorMatch => Boolean(s && typeof s === 'object')).map((s) => ({
657
666
  id: s.id,
658
- score: s.score,
659
- content: s.content,
660
- metadata: s.metadata ?? {},
667
+ score: s.score ?? 0,
668
+ content: s?.content ?? '',
669
+ metadata: s?.metadata ?? {},
661
670
  namespace: ns,
662
671
  } satisfies RetrievedChunk)),
663
672
  } as ChatResponse;
664
673
 
665
674
  // 4.5 Schema Training — true fire-and-forget, never gates anything.
666
- const allMetadataKeys = Array.from(new Set(sources.flatMap(s => Object.keys(s.metadata || {}))));
675
+ const allMetadataKeys = Array.from(new Set(sources.filter(Boolean).flatMap(s => Object.keys(s?.metadata || {}))));
667
676
  const cachedSchema = allMetadataKeys.length > 0
668
677
  ? SchemaMapper.getCached(ns, allMetadataKeys)
669
678
  : undefined;
@@ -840,10 +849,8 @@ You are a helpful assistant. Use the provided context to answer questions accura
840
849
  yield fullReply;
841
850
  }
842
851
 
843
- // Kick off hallucination scoring now that we have the full reply.
844
- // It runs in parallel with the remaining post-processing below.
845
- const runHallucination = this.config.llm.options?.hallucinationScoring === true ||
846
- (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;
847
854
  if (runHallucination) {
848
855
  hallucinationScoringPromise = scoreHallucination(this.llmProvider, fullReply, context)
849
856
  .catch(() => undefined);
@@ -979,9 +986,8 @@ You are a helpful assistant. Use the provided context to answer questions accura
979
986
  );
980
987
  }
981
988
 
982
- const isLocalProvider = this.config.llm.provider === 'ollama';
983
- const disableLlmUiTransform = this.config.llm.options?.disableLlmUiTransform === true;
984
- if (forceDeterministic || isLocalProvider || disableLlmUiTransform) {
989
+ const enableLlmUiTransform = this.config.llm.options?.enableLlmUiTransform === true;
990
+ if (forceDeterministic || !enableLlmUiTransform) {
985
991
  return UITransformer.transform(question, sources, this.config, cachedSchema);
986
992
  }
987
993
 
@@ -1015,6 +1021,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
1015
1021
  }
1016
1022
 
1017
1023
  private resolveNumericPredicateValue(source: VectorMatch, predicate: NumericPredicate): number | null {
1024
+ if (!source) return null;
1018
1025
  const meta = source.metadata || {};
1019
1026
  const field = predicate.field;
1020
1027
  const entries = Object.entries(meta)
@@ -1032,7 +1039,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
1032
1039
  if (value !== null) return value;
1033
1040
  }
1034
1041
 
1035
- const contentValue = this.extractNumericValueFromContent(source.content, field);
1042
+ const contentValue = this.extractNumericValueFromContent(source.content ?? '', field);
1036
1043
  if (contentValue !== null) return contentValue;
1037
1044
  }
1038
1045
 
@@ -1046,6 +1053,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
1046
1053
  }
1047
1054
 
1048
1055
  private extractNumericValueFromContent(content: string, field: string): number | null {
1056
+ if (!content || typeof content !== 'string') return null;
1049
1057
  const escapedWords = field
1050
1058
  .split(/\s+|_+|-+/)
1051
1059
  .filter(Boolean)
@@ -1164,6 +1172,18 @@ You are a helpful assistant. Use the provided context to answer questions accura
1164
1172
  }
1165
1173
  }
1166
1174
 
1175
+ console.log('[Pipeline] retrieve() response:', {
1176
+ query,
1177
+ namespace: ns,
1178
+ count: resolvedSources.length,
1179
+ sample: resolvedSources.slice(0, 2).map((s) => ({
1180
+ id: s?.id,
1181
+ score: s?.score,
1182
+ contentSnippet: String(s?.content ?? '').substring(0, 150),
1183
+ metadata: s?.metadata,
1184
+ })),
1185
+ });
1186
+
1167
1187
  return { sources: resolvedSources, graphData };
1168
1188
  }
1169
1189
 
@@ -360,10 +360,10 @@ Return ONLY 'vector', 'graph', or 'both'. No explanation.`;
360
360
  { temperature: 0, maxTokens: 10 }
361
361
  );
362
362
 
363
- const cleanResponse = response.trim().toLowerCase();
364
- if (cleanResponse.includes('vector')) return 'vector';
365
- if (cleanResponse.includes('graph')) return 'graph';
363
+ const cleanResponse = (response ?? '').trim().toLowerCase();
366
364
  if (cleanResponse.includes('both')) return 'both';
365
+ if (cleanResponse.includes('graph')) return 'graph';
366
+ if (cleanResponse.includes('vector')) return 'vector';
367
367
  } catch (error) {
368
368
  console.warn('[QueryProcessor] LLM strategy classification failed, falling back to keywords:', error);
369
369
  }
@@ -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;
@@ -96,29 +107,45 @@ export class UniversalLLMAdapter implements ILLMProvider {
96
107
 
97
108
  const isSelfHost = this.baseUrl.includes('retrivora.com') || this.baseUrl.includes('localhost');
98
109
  if (isSelfHost || Boolean(process.env.VERCEL)) {
99
- try {
100
- const _g = globalThis as any;
101
- if (typeof _g.__retrivoraDispatchChat === 'function') {
102
- const res = await _g.__retrivoraDispatchChat({
110
+ const _g = globalThis as any;
111
+ let dispatch = _g.__retrivoraDispatchChat;
112
+ if (typeof dispatch !== 'function') {
113
+ try {
114
+ const gateway = await import('../../../../../services/llm-gateway/router');
115
+ if (gateway?.dispatchChatCompletion) {
116
+ _g.__retrivoraDispatchChat = gateway.dispatchChatCompletion;
117
+ _g.__retrivoraDispatchEmbedding = gateway.dispatchEmbedding;
118
+ dispatch = gateway.dispatchChatCompletion;
119
+ }
120
+ } catch { /* proceed */ }
121
+ }
122
+
123
+ if (typeof dispatch === 'function') {
124
+ try {
125
+ const res = await dispatch({
103
126
  model: this.model,
104
127
  messages: formattedMessages as any,
105
128
  max_tokens: this.maxTokens,
106
129
  temperature: this.temperature,
107
130
  }, this.apiKey);
108
131
 
109
- if (res?.response?.choices?.[0]?.message?.content) {
110
- return res.response.choices[0].message.content;
132
+ const content = extractContent(res?.response);
133
+ if (content !== undefined) {
134
+ return content;
111
135
  }
136
+ throw new Error(`[UniversalLLMAdapter] In-process dispatch returned empty content for model: ${this.model}`);
137
+ } catch (dispatchErr) {
138
+ throw dispatchErr;
112
139
  }
113
- } catch {
114
- /* proceed to HTTP fetch */
140
+ } else {
141
+ 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
142
  }
116
143
  }
117
144
 
118
145
  const { data } = await this.http.post(path, payload);
119
146
 
120
147
  const extractPath = this.opts.responseExtractPath ?? 'choices[0].message.content';
121
- const result = resolvePath(data, extractPath);
148
+ const result = resolvePath(data, extractPath) ?? extractContent(data);
122
149
 
123
150
  if (result === undefined) {
124
151
  throw new Error(`[UniversalLLMAdapter] Could not extract text from path '${extractPath}' in response.`);
@@ -170,10 +197,22 @@ export class UniversalLLMAdapter implements ILLMProvider {
170
197
  let streamBody: ReadableStream<Uint8Array> | null = null;
171
198
 
172
199
  if (isSelfHost || Boolean(process.env.VERCEL)) {
173
- try {
174
- const _g = globalThis as any;
175
- if (typeof _g.__retrivoraDispatchChat === 'function') {
176
- const res = await _g.__retrivoraDispatchChat({
200
+ const _g = globalThis as any;
201
+ let dispatch = _g.__retrivoraDispatchChat;
202
+ if (typeof dispatch !== 'function') {
203
+ try {
204
+ const gateway = await import('../../../../../services/llm-gateway/router');
205
+ if (gateway?.dispatchChatCompletion) {
206
+ _g.__retrivoraDispatchChat = gateway.dispatchChatCompletion;
207
+ _g.__retrivoraDispatchEmbedding = gateway.dispatchEmbedding;
208
+ dispatch = gateway.dispatchChatCompletion;
209
+ }
210
+ } catch { /* proceed */ }
211
+ }
212
+
213
+ if (typeof dispatch === 'function') {
214
+ try {
215
+ const res = await dispatch({
177
216
  model: this.model,
178
217
  messages: formattedMessages as any,
179
218
  max_tokens: this.maxTokens,
@@ -183,13 +222,19 @@ export class UniversalLLMAdapter implements ILLMProvider {
183
222
 
184
223
  if (res?.stream) {
185
224
  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;
225
+ } else {
226
+ const content = extractContent(res?.response);
227
+ if (content !== undefined) {
228
+ yield content;
229
+ return;
230
+ }
231
+ throw new Error(`[UniversalLLMAdapter] In-process dispatch stream returned empty response for model: ${this.model}`);
189
232
  }
233
+ } catch (dispatchErr) {
234
+ throw dispatchErr;
190
235
  }
191
- } catch {
192
- /* proceed to HTTP fetch */
236
+ } else {
237
+ 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.`);
193
238
  }
194
239
  }
195
240
 
@@ -231,7 +276,7 @@ export class UniversalLLMAdapter implements ILLMProvider {
231
276
 
232
277
  try {
233
278
  const json = JSON.parse(trimmed.slice(5).trim());
234
- const text = resolvePath(json, extractPath);
279
+ const text = (resolvePath(json, extractPath) as string | undefined) ?? extractContent(json);
235
280
  if (text && typeof text === 'string') yield text;
236
281
  } catch {
237
282
  // Malformed SSE line — skip
@@ -244,7 +289,7 @@ export class UniversalLLMAdapter implements ILLMProvider {
244
289
  const jsonStr = buffer.replace(/^data:\s*/, '').trim();
245
290
  try {
246
291
  const json = JSON.parse(jsonStr);
247
- const text = resolvePath(json, extractPath);
292
+ const text = (resolvePath(json, extractPath) as string | undefined) ?? extractContent(json);
248
293
  if (text && typeof text === 'string') yield text;
249
294
  } catch { /* ignore final partial line */ }
250
295
  }
@@ -271,10 +316,22 @@ export class UniversalLLMAdapter implements ILLMProvider {
271
316
 
272
317
  const isSelfHost = this.baseUrl.includes('retrivora.com') || this.baseUrl.includes('localhost');
273
318
  if (isSelfHost || Boolean(process.env.VERCEL)) {
274
- try {
275
- const _g = globalThis as any;
276
- if (typeof _g.__retrivoraDispatchEmbedding === 'function') {
277
- const res = await _g.__retrivoraDispatchEmbedding({
319
+ const _g = globalThis as any;
320
+ let dispatch = _g.__retrivoraDispatchEmbedding;
321
+ if (typeof dispatch !== 'function') {
322
+ try {
323
+ const gateway = await import('../../../../../services/llm-gateway/router');
324
+ if (gateway?.dispatchEmbedding) {
325
+ _g.__retrivoraDispatchChat = gateway.dispatchChatCompletion;
326
+ _g.__retrivoraDispatchEmbedding = gateway.dispatchEmbedding;
327
+ dispatch = gateway.dispatchEmbedding;
328
+ }
329
+ } catch { /* proceed */ }
330
+ }
331
+
332
+ if (typeof dispatch === 'function') {
333
+ try {
334
+ const res = await dispatch({
278
335
  model: this.model,
279
336
  input: text,
280
337
  }, this.apiKey);
@@ -282,9 +339,12 @@ export class UniversalLLMAdapter implements ILLMProvider {
282
339
  if (res?.data?.[0]?.embedding) {
283
340
  return res.data[0].embedding;
284
341
  }
342
+ throw new Error(`[UniversalLLMAdapter] In-process embedding dispatch returned invalid vector for model: ${this.model}`);
343
+ } catch (dispatchErr) {
344
+ throw dispatchErr;
285
345
  }
286
- } catch {
287
- /* proceed to HTTP fetch */
346
+ } else {
347
+ 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.`);
288
348
  }
289
349
  }
290
350
 
@@ -25,7 +25,7 @@ export class Reranker {
25
25
  }
26
26
 
27
27
  const scoredMatches = matches.map(match => {
28
- const contentLower = match.content.toLowerCase();
28
+ const contentLower = (match?.content ?? '').toLowerCase();
29
29
  let keywordScore = 0;
30
30
 
31
31
  keywords.forEach(keyword => {
@@ -57,7 +57,7 @@ export class Reranker {
57
57
  const topN = matches.slice(0, 10);
58
58
 
59
59
  const response = await llm.chat(
60
- [{ role: 'user', content: `Query: "${query}"\n\nDocuments:\n${topN.map((m, i) => `[${i}] ${m.content.replace(/\n/g, ' ')}`).join('\n')}` }],
60
+ [{ role: 'user', content: `Query: "${query}"\n\nDocuments:\n${topN.map((m, i) => `[${i}] ${(m?.content ?? '').replace(/\n/g, ' ')}`).join('\n')}` }],
61
61
  '',
62
62
  {
63
63
  systemPrompt: 'You are a relevance ranking expert. Given the user query and a list of retrieved document chunks, rank the chunks by relevance to the query. Return the result as a comma-separated list of document indices in order of relevance, from most relevant to least relevant. Use the indices provided in brackets like [0], [1], etc. Only return the indices (e.g., "2,0,1"), nothing else. Do not include brackets in the output.',
@@ -107,7 +107,7 @@ export class IntentClassifier {
107
107
  numericFieldCount = numericKeys.size;
108
108
  categoricalFieldCount = catKeys.size;
109
109
 
110
- if (productKeyMatches >= 2 || docs.some(d => d.content.toLowerCase().includes('price') || d.content.toLowerCase().includes('brand'))) {
110
+ if (productKeyMatches >= 2 || docs.some(d => (d?.content ?? '').toLowerCase().includes('price') || (d?.content ?? '').toLowerCase().includes('brand'))) {
111
111
  isProductLike = true;
112
112
  }
113
113
  }
@@ -13,7 +13,7 @@ export class TextRendererStrategy implements IRendererStrategy {
13
13
  return { type: 'text', title, data: { content: data } };
14
14
  }
15
15
  const docs = Array.isArray(data) ? (data as VectorMatch[]) : [];
16
- const text = docs.map(d => d.content).join('\n\n');
16
+ const text = docs.map(d => d?.content ?? '').join('\n\n');
17
17
  return { type: 'text', title, data: { content: text || 'No detailed content available.' } };
18
18
  }
19
19
  }
@@ -216,7 +216,7 @@ export function extractProductsFromSources(sources: VectorMatch[] | undefined, i
216
216
 
217
217
  return {
218
218
  id: s.id,
219
- name: name ?? s.content.split('\n')[0] ?? 'Unknown Product',
219
+ name: name ?? (s?.content ?? '').split('\n')[0] ?? 'Unknown Product',
220
220
  brand: brand,
221
221
  price: price,
222
222
  image: resolveImage(m),
@@ -128,6 +128,17 @@ export class UITransformer {
128
128
  return this.createTextResponse('No data available', 'No relevant data found for your query.');
129
129
  }
130
130
 
131
+ console.log('[UITransformer.transform] Processing retrievedData:', {
132
+ userQuery,
133
+ retrievedCount: retrievedData.length,
134
+ sample: retrievedData.slice(0, 2).map((item) => ({
135
+ id: item?.id,
136
+ contentType: typeof item?.content,
137
+ contentSnippet: String(item?.content ?? '').substring(0, 150),
138
+ metadataKeys: Object.keys(item?.metadata || {}),
139
+ })),
140
+ });
141
+
131
142
  const resolvedIntent = intent ?? this.detectIntentHeuristic(userQuery);
132
143
 
133
144
  const filteredData = resolvedIntent.filterInStockOnly
@@ -549,8 +560,7 @@ RULES:
549
560
  ): UITransformationResponse {
550
561
  const products: CarouselProduct[] = data
551
562
  .map(item => this.extractProductInfo(item, config, trainedSchema))
552
- .filter((p): p is CarouselProduct => p !== null)
553
- .slice(0, 15);
563
+ .filter((p): p is CarouselProduct => p !== null);
554
564
 
555
565
  return {
556
566
  type: 'product_carousel',
@@ -760,7 +770,7 @@ RULES:
760
770
  type: 'radar_chart' as VisualizationType,
761
771
  title: 'Product Comparison',
762
772
  description: `Comparing ${data.length} items across ${radarData.length} attributes`,
763
- data: radarData.length > 0 ? radarData : data.map(d => ({ attribute: d.content.substring(0, 40) })),
773
+ data: radarData.length > 0 ? radarData : data.map(d => ({ attribute: (d?.content ?? '').substring(0, 40) })),
764
774
  };
765
775
  }
766
776
 
@@ -780,7 +790,7 @@ RULES:
780
790
  private static transformToText(data: VectorMatch[]): UITransformationResponse {
781
791
  return this.createTextResponse(
782
792
  'Retrieved Context',
783
- data.map(item => item.content).join('\n\n'),
793
+ data.map(item => item?.content ?? '').join('\n\n'),
784
794
  `Found ${data.length} relevant results`,
785
795
  );
786
796
  }
@@ -983,6 +993,7 @@ RULES:
983
993
 
984
994
 
985
995
  private static isProductData(item: VectorMatch): boolean {
996
+ if (!item) return false;
986
997
  const content = (item.content || '').toLowerCase();
987
998
  const productKeywords = [
988
999
  'product', 'price', 'stock', 'sku', 'brand', 'msrp',
@@ -1023,7 +1034,7 @@ RULES:
1023
1034
  }
1024
1035
 
1025
1036
  private static profileData(data: VectorMatch[]): DataProfile {
1026
- const records = data.map((item): DataRecord => {
1037
+ const records = (data || []).filter((item): item is VectorMatch => Boolean(item && typeof item === 'object')).map((item): DataRecord => {
1027
1038
  const fields: Record<string, PrimitiveValue> = {};
1028
1039
  Object.entries(item.metadata || {}).forEach(([key, value]) => {
1029
1040
  const primitive = this.toPrimitive(value);
@@ -1032,8 +1043,8 @@ RULES:
1032
1043
  if (!fields.content && item.content) fields.content = item.content.substring(0, 500);
1033
1044
  return {
1034
1045
  id: item.id,
1035
- content: item.content,
1036
- score: item.score,
1046
+ content: item.content ?? '',
1047
+ score: item.score ?? 0,
1037
1048
  fields,
1038
1049
  source: item,
1039
1050
  };
@@ -1297,7 +1308,7 @@ RULES:
1297
1308
  return {
1298
1309
  timestamp: (meta.timestamp ?? meta.date ?? new Date().toISOString()) as string | number,
1299
1310
  value: (meta.value ?? item.score ?? 0) as number,
1300
- label: (meta.label ?? item.content.substring(0, 50)) as string,
1311
+ label: (meta.label ?? (item?.content ?? '').substring(0, 50)) as string,
1301
1312
  };
1302
1313
  });
1303
1314
  }
@@ -1429,7 +1440,7 @@ RULES:
1429
1440
  }
1430
1441
 
1431
1442
  private static resolveTableCellValue(item: VectorMatch, column: string): string | number | boolean {
1432
- if (column === 'Content') return item.content.substring(0, 100);
1443
+ if (column === 'Content') return (item?.content ?? '').substring(0, 100);
1433
1444
 
1434
1445
  const meta = item.metadata || {};
1435
1446
  const normalizedColumn = this.normalizeComparableField(column);
@@ -1444,10 +1455,10 @@ RULES:
1444
1455
  const aliasValue = this.resolveAliasedTableCell(meta, column);
1445
1456
  if (aliasValue !== undefined) return this.toDisplayValue(aliasValue);
1446
1457
 
1447
- const contentValue = this.extractContentFieldValue(item.content, column);
1458
+ const contentValue = this.extractContentFieldValue(item?.content ?? '', column);
1448
1459
  if (contentValue !== null) return contentValue;
1449
1460
 
1450
- const aliasContentValue = this.extractAliasedContentFieldValue(item.content, column);
1461
+ const aliasContentValue = this.extractAliasedContentFieldValue(item?.content ?? '', column);
1451
1462
  return aliasContentValue ?? '';
1452
1463
  }
1453
1464
 
@@ -1519,6 +1530,7 @@ RULES:
1519
1530
  }
1520
1531
 
1521
1532
  private static determineStockStatus(item: VectorMatch): boolean {
1533
+ if (!item) return true;
1522
1534
  const meta = item.metadata || {};
1523
1535
  const stockValue = resolveMetadataValue(meta, 'stock');
1524
1536
  if (stockValue !== undefined) {
@@ -1583,42 +1595,74 @@ RULES:
1583
1595
  config?: import('../config/RagConfig').RagConfig,
1584
1596
  trainedSchema?: import('./SchemaMapper').SchemaMap,
1585
1597
  ): CarouselProduct | null {
1598
+ if (!item) return null;
1586
1599
  const meta = item.metadata || {};
1600
+ const content = item.content || '';
1587
1601
  const name = this.getDynamicVal(meta, 'name', config, trainedSchema);
1588
1602
  const price = this.getDynamicVal(meta, 'price', config, trainedSchema);
1589
1603
  const brand = this.getDynamicVal(meta, 'brand', config, trainedSchema);
1590
1604
  const description = this.cleanProductDescription(
1591
- this.extractProductDescriptionFromContent(item.content) ??
1605
+ this.extractProductDescriptionFromContent(content) ??
1592
1606
  this.getProductDescriptionValue(meta, config, trainedSchema),
1593
1607
  );
1594
1608
 
1595
1609
  if (name || this.isProductData(item)) {
1596
1610
  let finalName = name ? String(name) : undefined;
1611
+ if (!finalName && content) {
1612
+ const nameMatch = content.match(/(?:Product|Item|Name):\s*([^\n]+)/i);
1613
+ finalName = nameMatch ? nameMatch[1].trim() : content.split('\n')[0]?.substring(0, 60);
1614
+ }
1615
+
1597
1616
  if (!finalName) {
1598
- const nameMatch = item.content.match(/(?:Product|Item|Name):\s*([^\n]+)/i);
1599
- finalName = nameMatch ? nameMatch[1].trim() : item.content.split('\n')[0].substring(0, 60);
1617
+ console.warn(
1618
+ `[UITransformer] Unable to read product name from metadata or content for item ${item.id}. Defaulting to 'Unnamed Product'.`,
1619
+ { metadataKeys: Object.keys(meta), contentLength: content.length },
1620
+ );
1600
1621
  }
1601
1622
 
1602
1623
  let finalPrice: string | number | undefined =
1603
1624
  typeof price === 'number' || typeof price === 'string' ? price : undefined;
1604
- if (!finalPrice) {
1625
+ if (!finalPrice && content) {
1605
1626
  const priceMatch =
1606
- item.content.match(/(?:Price|Cost):\s*\$?([\d,.]+)/i) ||
1607
- item.content.match(/\$\s*([\d,.]+)/);
1627
+ content.match(/(?:Price|Cost):\s*\$?([\d,.]+)/i) ||
1628
+ content.match(/\$\s*([\d,.]+)/);
1608
1629
  if (priceMatch) finalPrice = priceMatch[1].replace(/,/g, '');
1609
1630
  }
1610
1631
 
1632
+ if (finalPrice === undefined) {
1633
+ console.warn(
1634
+ `[UITransformer] Unable to read product price from metadata or content for item ${item.id}.`,
1635
+ { metadataKeys: Object.keys(meta), contentSnippet: content.substring(0, 100) },
1636
+ );
1637
+ }
1638
+
1611
1639
  const imageValue = this.getDynamicVal(meta, 'image', config, trainedSchema);
1640
+ if (!imageValue) {
1641
+ console.debug(
1642
+ `[UITransformer] Unable to read product image URL from metadata for item ${item.id}.`,
1643
+ );
1644
+ }
1645
+
1646
+ if (!description) {
1647
+ console.debug(
1648
+ `[UITransformer] Unable to read product description from metadata or content for item ${item.id}.`,
1649
+ );
1650
+ }
1612
1651
 
1613
1652
  return {
1614
1653
  id: item.id,
1615
- name: finalName,
1654
+ name: finalName || 'Unnamed Product',
1616
1655
  price: finalPrice,
1617
1656
  image: typeof imageValue === 'string' ? imageValue : undefined,
1618
1657
  brand: brand ? String(brand) : undefined,
1619
1658
  description,
1620
1659
  inStock: this.determineStockStatus(item),
1621
1660
  };
1661
+ } else {
1662
+ console.warn(
1663
+ `[UITransformer] Item ${item.id} skipped: unable to read name or identify as product data from metadata/content.`,
1664
+ { metadataKeys: Object.keys(meta), contentSnippet: content.substring(0, 100) },
1665
+ );
1622
1666
  }
1623
1667
 
1624
1668
  return null;
@@ -1733,11 +1777,11 @@ RULES:
1733
1777
  }
1734
1778
 
1735
1779
  private static buildContextSummary(sources: VectorMatch[], maxChars = 6000): string {
1736
- const items = sources.map((s, i) => ({
1780
+ const items = (sources || []).filter(Boolean).map((s, i) => ({
1737
1781
  index: i + 1,
1738
- content: s.content?.substring(0, 400) ?? '',
1739
- metadata: s.metadata ?? {},
1740
- score: s.score ?? 0,
1782
+ content: s?.content?.substring(0, 400) ?? '',
1783
+ metadata: s?.metadata ?? {},
1784
+ score: s?.score ?? 0,
1741
1785
  }));
1742
1786
 
1743
1787
  const full = JSON.stringify(items, null, 2);