@retrivora-ai/rag-engine 2.0.5 → 2.0.7

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.
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import * as os from 'os';
3
4
  import { RagConfig } from '../config/RagConfig';
4
5
  import { LicenseVerifier } from './LicenseVerifier';
5
6
 
@@ -35,7 +36,10 @@ export class DatabaseStorage {
35
36
  private feedbackTableName: string;
36
37
 
37
38
  // Filesystem fallback configuration
38
- private fallbackDir = path.join(process.cwd(), '.retrivora');
39
+ private isServerless = Boolean(process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.LAMBDA_TASK_ROOT);
40
+ private fallbackDir = this.isServerless
41
+ ? path.join(os.tmpdir(), '.retrivora')
42
+ : path.join(process.cwd(), '.retrivora');
39
43
  private historyFile = path.join(this.fallbackDir, 'history.json');
40
44
  private feedbackFile = path.join(this.fallbackDir, 'feedback.json');
41
45
 
@@ -191,10 +195,28 @@ export class DatabaseStorage {
191
195
 
192
196
  // Helper for FS fallback writes
193
197
  private writeLocalFile(filePath: string, data: any[]): void {
194
- if (!fs.existsSync(this.fallbackDir)) {
195
- fs.mkdirSync(this.fallbackDir, { recursive: true });
198
+ try {
199
+ const dir = path.dirname(filePath);
200
+ if (!fs.existsSync(dir)) {
201
+ fs.mkdirSync(dir, { recursive: true });
202
+ }
203
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
204
+ } catch (err: any) {
205
+ if (err.code === 'EROFS') {
206
+ try {
207
+ const tmpDir = path.join(os.tmpdir(), '.retrivora');
208
+ if (!fs.existsSync(tmpDir)) {
209
+ fs.mkdirSync(tmpDir, { recursive: true });
210
+ }
211
+ const tmpFilePath = path.join(tmpDir, path.basename(filePath));
212
+ fs.writeFileSync(tmpFilePath, JSON.stringify(data, null, 2), 'utf-8');
213
+ } catch {
214
+ /* ignore write failure in read-only environment */
215
+ }
216
+ } else {
217
+ console.warn(`[DatabaseStorage] Failed to write local fallback file: ${err.message}`);
218
+ }
196
219
  }
197
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
198
220
  }
199
221
 
200
222
  // ─── Message History Operations ──────────────────────────────────────────
@@ -583,9 +583,20 @@ You are a helpful assistant. Use the provided context to answer questions accura
583
583
  const wantsExhaustiveList = hasNumericPredicates || hasMetadataFilter || /\b(list|all|show|provide|give|get|browse|find|search|display)\b/i.test(question);
584
584
  const retrievalLimit = wantsExhaustiveList ? Math.max(topK * 20, 100) : topK * 2;
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;
@@ -593,14 +604,14 @@ You are a helpful assistant. Use the provided context to answer questions accura
593
604
 
594
605
  // 3. Reranking
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);
610
+ : structuredSources.filter((m) => (m?.score ?? 0) >= scoreThreshold);
600
611
  const rerankLimit = wantsExhaustiveList ? retrievalLimit : topK;
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);
614
+ fullSources = (await this.reranker.rerank(fullSources, question, rerankLimit)).filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'));
604
615
  } else if (!wantsExhaustiveList) {
605
616
  fullSources = fullSources.slice(0, topK);
606
617
  }
@@ -608,7 +619,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
608
619
 
609
620
  // 4. Context Augmentation - Review all retrieved/reranked sources for full comprehension
610
621
  let context = fullSources.length
611
- ? fullSources.map((m, i) => `[Source ${i + 1}]\n${m.content}`).join('\n\n---\n\n')
622
+ ? fullSources.map((m, i) => `[Source ${i + 1}]\n${m?.content ?? ''}`).join('\n\n---\n\n')
612
623
  : 'No relevant context found.';
613
624
 
614
625
  // Count the records of relevant sources to pass into the slice dynamically
@@ -620,7 +631,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
620
631
  } else {
621
632
  // For general semantic queries, count the number of matches meeting high relevance threshold
622
633
  const baseThreshold = Math.max(scoreThreshold, 0.4);
623
- const highlyRelevant = fullSources.filter(m => m.score >= baseThreshold);
634
+ const highlyRelevant = fullSources.filter(m => (m?.score ?? 0) >= baseThreshold);
624
635
  displayCount = Math.max(highlyRelevant.length, topK);
625
636
  // Cap non-metadata semantic searches to a maximum of 15 to keep the UI clean
626
637
  if (displayCount > 15) {
@@ -630,9 +641,22 @@ You are a helpful assistant. Use the provided context to answer questions accura
630
641
 
631
642
  // Ensure they are sorted from highest to lowest score, then slice using our dynamic count
632
643
  const sources = [...fullSources]
633
- .sort((a, b) => b.score - a.score)
644
+ .filter((s): s is VectorMatch => Boolean(s && typeof s === 'object'))
645
+ .sort((a, b) => (b?.score ?? 0) - (a?.score ?? 0))
634
646
  .slice(0, displayCount);
635
647
 
648
+ console.log('[Pipeline] Final sources for prompt & UI:', {
649
+ count: sources.length,
650
+ sources: sources.map((s, idx) => ({
651
+ rank: idx + 1,
652
+ id: s?.id,
653
+ score: s?.score,
654
+ contentType: typeof s?.content,
655
+ contentSnippet: String(s?.content ?? '').substring(0, 150),
656
+ metadata: s?.metadata,
657
+ })),
658
+ });
659
+
636
660
  if (graphData && graphData.nodes.length > 0) {
637
661
  const graphContext = graphData.nodes.map(n =>
638
662
  `Entity: ${n.label} (${n.id})${n.properties ? ' - ' + JSON.stringify(n.properties) : ''}`
@@ -653,17 +677,17 @@ You are a helpful assistant. Use the provided context to answer questions accura
653
677
  // Yield sources immediately to support early UI citation rendering (highly responsive)
654
678
  yield {
655
679
  reply: '',
656
- sources: sources.map((s) => ({
680
+ sources: (sources || []).filter((s): s is VectorMatch => Boolean(s && typeof s === 'object')).map((s) => ({
657
681
  id: s.id,
658
- score: s.score,
659
- content: s.content,
660
- metadata: s.metadata ?? {},
682
+ score: s.score ?? 0,
683
+ content: s?.content ?? '',
684
+ metadata: s?.metadata ?? {},
661
685
  namespace: ns,
662
686
  } satisfies RetrievedChunk)),
663
687
  } as ChatResponse;
664
688
 
665
689
  // 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 || {}))));
690
+ const allMetadataKeys = Array.from(new Set(sources.filter(Boolean).flatMap(s => Object.keys(s?.metadata || {}))));
667
691
  const cachedSchema = allMetadataKeys.length > 0
668
692
  ? SchemaMapper.getCached(ns, allMetadataKeys)
669
693
  : undefined;
@@ -722,7 +746,12 @@ You are a helpful assistant. Use the provided context to answer questions accura
722
746
  finalRestrictionSuffix += '\n\n(IMPORTANT: You must think step-by-step before answering. Write your thinking process inside a `<think>...</think>` block. Write the `<think>` block first, then follow it with your final response. Keep the thinking process thorough and detailed. Example: `<think>Thinking details here...</think>Final answer text here.`)';
723
747
  }
724
748
 
725
- const hardenedHistory = [...history];
749
+ const hardenedHistory = (Array.isArray(history) ? history : [])
750
+ .filter((m): m is ChatMessage => Boolean(m && typeof m === 'object'))
751
+ .map((m) => ({
752
+ role: m.role || 'user',
753
+ content: typeof m.content === 'string' ? m.content : (m.content != null ? String(m.content) : ''),
754
+ }));
726
755
  const userQuestion = { role: 'user', content: question + finalRestrictionSuffix } as ChatMessage;
727
756
  const messages: ChatMessage[] = [...hardenedHistory, userQuestion];
728
757
 
@@ -880,11 +909,11 @@ You are a helpful assistant. Use the provided context to answer questions accura
880
909
  rewrittenQuery,
881
910
  systemPrompt,
882
911
  userPrompt: question + finalRestrictionSuffix,
883
- chunks: sources.map((s) => ({
912
+ chunks: (sources || []).filter(Boolean).map((s) => ({
884
913
  id: s.id,
885
914
  score: s.score,
886
- content: s.content,
887
- metadata: s.metadata ?? {},
915
+ content: s?.content ?? '',
916
+ metadata: s?.metadata ?? {},
888
917
  namespace: ns,
889
918
  } satisfies RetrievedChunk)),
890
919
  latency,
@@ -1010,6 +1039,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
1010
1039
  }
1011
1040
 
1012
1041
  private resolveNumericPredicateValue(source: VectorMatch, predicate: NumericPredicate): number | null {
1042
+ if (!source) return null;
1013
1043
  const meta = source.metadata || {};
1014
1044
  const field = predicate.field;
1015
1045
  const entries = Object.entries(meta)
@@ -1027,7 +1057,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
1027
1057
  if (value !== null) return value;
1028
1058
  }
1029
1059
 
1030
- const contentValue = this.extractNumericValueFromContent(source.content, field);
1060
+ const contentValue = this.extractNumericValueFromContent(source.content ?? '', field);
1031
1061
  if (contentValue !== null) return contentValue;
1032
1062
  }
1033
1063
 
@@ -1041,6 +1071,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
1041
1071
  }
1042
1072
 
1043
1073
  private extractNumericValueFromContent(content: string, field: string): number | null {
1074
+ if (!content || typeof content !== 'string') return null;
1044
1075
  const escapedWords = field
1045
1076
  .split(/\s+|_+|-+/)
1046
1077
  .filter(Boolean)
@@ -1159,6 +1190,18 @@ You are a helpful assistant. Use the provided context to answer questions accura
1159
1190
  }
1160
1191
  }
1161
1192
 
1193
+ console.log('[Pipeline] retrieve() response:', {
1194
+ query,
1195
+ namespace: ns,
1196
+ count: resolvedSources.length,
1197
+ sample: resolvedSources.slice(0, 2).map((s) => ({
1198
+ id: s?.id,
1199
+ score: s?.score,
1200
+ contentSnippet: String(s?.content ?? '').substring(0, 150),
1201
+ metadata: s?.metadata,
1202
+ })),
1203
+ });
1204
+
1162
1205
  return { sources: resolvedSources, graphData };
1163
1206
  }
1164
1207
 
@@ -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
  }
@@ -94,6 +94,27 @@ export class UniversalLLMAdapter implements ILLMProvider {
94
94
  };
95
95
  }
96
96
 
97
+ const isSelfHost = this.baseUrl.includes('retrivora.com') || this.baseUrl.includes('localhost');
98
+ if (isSelfHost || Boolean(process.env.VERCEL)) {
99
+ const _g = globalThis as any;
100
+ if (typeof _g.__retrivoraDispatchChat === 'function') {
101
+ try {
102
+ const res = await _g.__retrivoraDispatchChat({
103
+ model: this.model,
104
+ messages: formattedMessages as any,
105
+ max_tokens: this.maxTokens,
106
+ temperature: this.temperature,
107
+ }, this.apiKey);
108
+
109
+ if (res?.response?.choices?.[0]?.message?.content) {
110
+ return res.response.choices[0].message.content;
111
+ }
112
+ } catch (dispatchErr) {
113
+ throw dispatchErr;
114
+ }
115
+ }
116
+ }
117
+
97
118
  const { data } = await this.http.post(path, payload);
98
119
 
99
120
  const extractPath = this.opts.responseExtractPath ?? 'choices[0].message.content';
@@ -145,22 +166,54 @@ export class UniversalLLMAdapter implements ILLMProvider {
145
166
  };
146
167
  }
147
168
 
148
- const response = await fetch(url, {
149
- method: 'POST',
150
- headers: this.resolvedHeaders,
151
- body: JSON.stringify(payload),
152
- });
169
+ const isSelfHost = this.baseUrl.includes('retrivora.com') || this.baseUrl.includes('localhost');
170
+ let streamBody: ReadableStream<Uint8Array> | null = null;
153
171
 
154
- if (!response.ok) {
155
- const errorText = await response.text().catch(() => response.statusText);
156
- throw new Error(`[UniversalLLMAdapter] Streaming request failed (${response.status}): ${errorText}`);
172
+ if (isSelfHost || Boolean(process.env.VERCEL)) {
173
+ const _g = globalThis as any;
174
+ if (typeof _g.__retrivoraDispatchChat === 'function') {
175
+ try {
176
+ const res = await _g.__retrivoraDispatchChat({
177
+ model: this.model,
178
+ messages: formattedMessages as any,
179
+ max_tokens: this.maxTokens,
180
+ temperature: this.temperature,
181
+ stream: true,
182
+ }, this.apiKey);
183
+
184
+ if (res?.stream) {
185
+ 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;
189
+ }
190
+ } 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
+ throw dispatchErr;
194
+ }
195
+ }
157
196
  }
158
197
 
159
- if (!response.body) {
160
- throw new Error('[UniversalLLMAdapter] Response body is null — server did not send a streaming response.');
198
+ if (!streamBody) {
199
+ const response = await fetch(url, {
200
+ method: 'POST',
201
+ headers: this.resolvedHeaders,
202
+ body: JSON.stringify(payload),
203
+ });
204
+
205
+ if (!response.ok) {
206
+ const errorText = await response.text().catch(() => response.statusText);
207
+ throw new Error(`[UniversalLLMAdapter] Streaming request failed (${response.status}): ${errorText}`);
208
+ }
209
+
210
+ if (!response.body) {
211
+ throw new Error('[UniversalLLMAdapter] Response body is null — server did not send a streaming response.');
212
+ }
213
+ streamBody = response.body;
161
214
  }
162
215
 
163
- const reader = response.body.getReader();
216
+ const reader = streamBody.getReader();
164
217
  const decoder = new TextDecoder('utf-8');
165
218
  let buffer = '';
166
219
 
@@ -218,6 +271,25 @@ export class UniversalLLMAdapter implements ILLMProvider {
218
271
  };
219
272
  }
220
273
 
274
+ const isSelfHost = this.baseUrl.includes('retrivora.com') || this.baseUrl.includes('localhost');
275
+ if (isSelfHost || Boolean(process.env.VERCEL)) {
276
+ const _g = globalThis as any;
277
+ if (typeof _g.__retrivoraDispatchEmbedding === 'function') {
278
+ try {
279
+ const res = await _g.__retrivoraDispatchEmbedding({
280
+ model: this.model,
281
+ input: text,
282
+ }, this.apiKey);
283
+
284
+ if (res?.data?.[0]?.embedding) {
285
+ return res.data[0].embedding;
286
+ }
287
+ } catch (dispatchErr) {
288
+ throw dispatchErr;
289
+ }
290
+ }
291
+ }
292
+
221
293
  const { data } = await this.http.post(path, payload);
222
294
 
223
295
  const extractPath = this.opts.embedExtractPath ?? 'data[0].embedding';
@@ -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
@@ -760,7 +771,7 @@ RULES:
760
771
  type: 'radar_chart' as VisualizationType,
761
772
  title: 'Product Comparison',
762
773
  description: `Comparing ${data.length} items across ${radarData.length} attributes`,
763
- data: radarData.length > 0 ? radarData : data.map(d => ({ attribute: d.content.substring(0, 40) })),
774
+ data: radarData.length > 0 ? radarData : data.map(d => ({ attribute: (d?.content ?? '').substring(0, 40) })),
764
775
  };
765
776
  }
766
777
 
@@ -780,7 +791,7 @@ RULES:
780
791
  private static transformToText(data: VectorMatch[]): UITransformationResponse {
781
792
  return this.createTextResponse(
782
793
  'Retrieved Context',
783
- data.map(item => item.content).join('\n\n'),
794
+ data.map(item => item?.content ?? '').join('\n\n'),
784
795
  `Found ${data.length} relevant results`,
785
796
  );
786
797
  }
@@ -983,6 +994,7 @@ RULES:
983
994
 
984
995
 
985
996
  private static isProductData(item: VectorMatch): boolean {
997
+ if (!item) return false;
986
998
  const content = (item.content || '').toLowerCase();
987
999
  const productKeywords = [
988
1000
  'product', 'price', 'stock', 'sku', 'brand', 'msrp',
@@ -1297,7 +1309,7 @@ RULES:
1297
1309
  return {
1298
1310
  timestamp: (meta.timestamp ?? meta.date ?? new Date().toISOString()) as string | number,
1299
1311
  value: (meta.value ?? item.score ?? 0) as number,
1300
- label: (meta.label ?? item.content.substring(0, 50)) as string,
1312
+ label: (meta.label ?? (item?.content ?? '').substring(0, 50)) as string,
1301
1313
  };
1302
1314
  });
1303
1315
  }
@@ -1429,7 +1441,7 @@ RULES:
1429
1441
  }
1430
1442
 
1431
1443
  private static resolveTableCellValue(item: VectorMatch, column: string): string | number | boolean {
1432
- if (column === 'Content') return item.content.substring(0, 100);
1444
+ if (column === 'Content') return (item?.content ?? '').substring(0, 100);
1433
1445
 
1434
1446
  const meta = item.metadata || {};
1435
1447
  const normalizedColumn = this.normalizeComparableField(column);
@@ -1444,10 +1456,10 @@ RULES:
1444
1456
  const aliasValue = this.resolveAliasedTableCell(meta, column);
1445
1457
  if (aliasValue !== undefined) return this.toDisplayValue(aliasValue);
1446
1458
 
1447
- const contentValue = this.extractContentFieldValue(item.content, column);
1459
+ const contentValue = this.extractContentFieldValue(item?.content ?? '', column);
1448
1460
  if (contentValue !== null) return contentValue;
1449
1461
 
1450
- const aliasContentValue = this.extractAliasedContentFieldValue(item.content, column);
1462
+ const aliasContentValue = this.extractAliasedContentFieldValue(item?.content ?? '', column);
1451
1463
  return aliasContentValue ?? '';
1452
1464
  }
1453
1465
 
@@ -1519,6 +1531,7 @@ RULES:
1519
1531
  }
1520
1532
 
1521
1533
  private static determineStockStatus(item: VectorMatch): boolean {
1534
+ if (!item) return true;
1522
1535
  const meta = item.metadata || {};
1523
1536
  const stockValue = resolveMetadataValue(meta, 'stock');
1524
1537
  if (stockValue !== undefined) {
@@ -1583,42 +1596,74 @@ RULES:
1583
1596
  config?: import('../config/RagConfig').RagConfig,
1584
1597
  trainedSchema?: import('./SchemaMapper').SchemaMap,
1585
1598
  ): CarouselProduct | null {
1599
+ if (!item) return null;
1586
1600
  const meta = item.metadata || {};
1601
+ const content = item.content || '';
1587
1602
  const name = this.getDynamicVal(meta, 'name', config, trainedSchema);
1588
1603
  const price = this.getDynamicVal(meta, 'price', config, trainedSchema);
1589
1604
  const brand = this.getDynamicVal(meta, 'brand', config, trainedSchema);
1590
1605
  const description = this.cleanProductDescription(
1591
- this.extractProductDescriptionFromContent(item.content) ??
1606
+ this.extractProductDescriptionFromContent(content) ??
1592
1607
  this.getProductDescriptionValue(meta, config, trainedSchema),
1593
1608
  );
1594
1609
 
1595
1610
  if (name || this.isProductData(item)) {
1596
1611
  let finalName = name ? String(name) : undefined;
1612
+ if (!finalName && content) {
1613
+ const nameMatch = content.match(/(?:Product|Item|Name):\s*([^\n]+)/i);
1614
+ finalName = nameMatch ? nameMatch[1].trim() : content.split('\n')[0]?.substring(0, 60);
1615
+ }
1616
+
1597
1617
  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);
1618
+ console.warn(
1619
+ `[UITransformer] Unable to read product name from metadata or content for item ${item.id}. Defaulting to 'Unnamed Product'.`,
1620
+ { metadataKeys: Object.keys(meta), contentLength: content.length },
1621
+ );
1600
1622
  }
1601
1623
 
1602
1624
  let finalPrice: string | number | undefined =
1603
1625
  typeof price === 'number' || typeof price === 'string' ? price : undefined;
1604
- if (!finalPrice) {
1626
+ if (!finalPrice && content) {
1605
1627
  const priceMatch =
1606
- item.content.match(/(?:Price|Cost):\s*\$?([\d,.]+)/i) ||
1607
- item.content.match(/\$\s*([\d,.]+)/);
1628
+ content.match(/(?:Price|Cost):\s*\$?([\d,.]+)/i) ||
1629
+ content.match(/\$\s*([\d,.]+)/);
1608
1630
  if (priceMatch) finalPrice = priceMatch[1].replace(/,/g, '');
1609
1631
  }
1610
1632
 
1633
+ if (finalPrice === undefined) {
1634
+ console.warn(
1635
+ `[UITransformer] Unable to read product price from metadata or content for item ${item.id}.`,
1636
+ { metadataKeys: Object.keys(meta), contentSnippet: content.substring(0, 100) },
1637
+ );
1638
+ }
1639
+
1611
1640
  const imageValue = this.getDynamicVal(meta, 'image', config, trainedSchema);
1641
+ if (!imageValue) {
1642
+ console.debug(
1643
+ `[UITransformer] Unable to read product image URL from metadata for item ${item.id}.`,
1644
+ );
1645
+ }
1646
+
1647
+ if (!description) {
1648
+ console.debug(
1649
+ `[UITransformer] Unable to read product description from metadata or content for item ${item.id}.`,
1650
+ );
1651
+ }
1612
1652
 
1613
1653
  return {
1614
1654
  id: item.id,
1615
- name: finalName,
1655
+ name: finalName || 'Unnamed Product',
1616
1656
  price: finalPrice,
1617
1657
  image: typeof imageValue === 'string' ? imageValue : undefined,
1618
1658
  brand: brand ? String(brand) : undefined,
1619
1659
  description,
1620
1660
  inStock: this.determineStockStatus(item),
1621
1661
  };
1662
+ } else {
1663
+ console.warn(
1664
+ `[UITransformer] Item ${item.id} skipped: unable to read name or identify as product data from metadata/content.`,
1665
+ { metadataKeys: Object.keys(meta), contentSnippet: content.substring(0, 100) },
1666
+ );
1622
1667
  }
1623
1668
 
1624
1669
  return null;
@@ -1733,11 +1778,11 @@ RULES:
1733
1778
  }
1734
1779
 
1735
1780
  private static buildContextSummary(sources: VectorMatch[], maxChars = 6000): string {
1736
- const items = sources.map((s, i) => ({
1781
+ const items = (sources || []).filter(Boolean).map((s, i) => ({
1737
1782
  index: i + 1,
1738
- content: s.content?.substring(0, 400) ?? '',
1739
- metadata: s.metadata ?? {},
1740
- score: s.score ?? 0,
1783
+ content: s?.content?.substring(0, 400) ?? '',
1784
+ metadata: s?.metadata ?? {},
1785
+ score: s?.score ?? 0,
1741
1786
  }));
1742
1787
 
1743
1788
  const full = JSON.stringify(items, null, 2);