@retrivora-ai/rag-engine 1.7.9 → 1.8.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/DocumentChunker-Dh9TvmGG.d.mts +45 -0
- package/dist/DocumentChunker-Dh9TvmGG.d.ts +45 -0
- package/dist/{index-wCRqMtdX.d.mts → ILLMProvider-BfRgI1Xh.d.mts} +59 -1
- package/dist/{index-wCRqMtdX.d.ts → ILLMProvider-BfRgI1Xh.d.ts} +59 -1
- package/dist/MultiTablePostgresProvider-YY7LPNJK.mjs +8 -0
- package/dist/{chunk-UHGYLTTY.mjs → chunk-BFYLQYQU.mjs} +846 -458
- package/dist/chunk-R3RGUMHE.mjs +218 -0
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1033 -622
- package/dist/handlers/index.mjs +1 -1
- package/dist/{index-BDqz2_Yu.d.ts → index-1Z4GuYBi.d.ts} +7 -1
- package/dist/{index-DhsG2o5q.d.mts → index-BV0z5mb6.d.mts} +7 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +507 -352
- package/dist/index.mjs +427 -253
- package/dist/server.d.mts +35 -5
- package/dist/server.d.ts +35 -5
- package/dist/server.js +1183 -795
- package/dist/server.mjs +163 -176
- package/package.json +4 -2
- package/src/app/page.tsx +1 -2
- package/src/components/DynamicChart.tsx +10 -35
- package/src/components/MessageBubble.tsx +223 -74
- package/src/components/ProductCard.tsx +2 -2
- package/src/components/VisualizationRenderer.tsx +347 -247
- package/src/config/RagConfig.ts +5 -0
- package/src/config/serverConfig.ts +3 -1
- package/src/core/Pipeline.ts +70 -209
- package/src/core/ProviderRegistry.ts +2 -2
- package/src/core/VectorPlugin.ts +9 -0
- package/src/handlers/index.ts +23 -7
- package/src/hooks/useRagChat.ts +2 -2
- package/src/llm/LLMFactory.ts +54 -2
- package/src/llm/providers/AnthropicProvider.ts +12 -8
- package/src/llm/providers/GeminiProvider.ts +188 -143
- package/src/llm/providers/OllamaProvider.ts +7 -3
- package/src/llm/providers/OpenAIProvider.ts +12 -8
- package/src/types/chat.ts +6 -0
- package/src/types/index.ts +81 -0
- package/src/utils/SchemaMapper.ts +129 -0
- package/src/utils/UITransformer.ts +524 -194
- package/dist/DocumentChunker-Bmscbh-X.d.ts +0 -93
- package/dist/DocumentChunker-DCuxrOdM.d.mts +0 -93
- package/dist/PostgreSQLProvider-BMOETDZA.mjs +0 -8
- package/dist/chunk-FLOSGE6A.mjs +0 -202
|
@@ -1,77 +1,5 @@
|
|
|
1
|
-
import { VectorMatch } from '../types';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Supported UI visualization types
|
|
5
|
-
*/
|
|
6
|
-
export type VisualizationType =
|
|
7
|
-
| 'pie_chart'
|
|
8
|
-
| 'bar_chart'
|
|
9
|
-
| 'line_chart'
|
|
10
|
-
| 'table'
|
|
11
|
-
| 'product_carousel'
|
|
12
|
-
| 'text';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Base structure for all UI transformation responses
|
|
16
|
-
*/
|
|
17
|
-
export interface UITransformationResponse {
|
|
18
|
-
type: VisualizationType;
|
|
19
|
-
title: string;
|
|
20
|
-
description?: string;
|
|
21
|
-
data: unknown;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Pie chart data structure
|
|
26
|
-
*/
|
|
27
|
-
export interface PieChartData {
|
|
28
|
-
label: string;
|
|
29
|
-
value: number;
|
|
30
|
-
inStock?: boolean;
|
|
31
|
-
[key: string]: unknown;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Bar chart data structure
|
|
36
|
-
*/
|
|
37
|
-
export interface BarChartData {
|
|
38
|
-
category: string;
|
|
39
|
-
value: number;
|
|
40
|
-
inStock?: boolean;
|
|
41
|
-
[key: string]: unknown;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Line chart data point
|
|
46
|
-
*/
|
|
47
|
-
export interface LineChartDataPoint {
|
|
48
|
-
timestamp: string | number;
|
|
49
|
-
value: number;
|
|
50
|
-
label?: string;
|
|
51
|
-
[key: string]: unknown;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Table representation
|
|
56
|
-
*/
|
|
57
|
-
export interface TableData {
|
|
58
|
-
columns: string[];
|
|
59
|
-
rows: (string | number | boolean)[][];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Product carousel item
|
|
64
|
-
*/
|
|
65
|
-
export interface CarouselProduct {
|
|
66
|
-
id: string | number;
|
|
67
|
-
name: string;
|
|
68
|
-
price?: number | string;
|
|
69
|
-
image?: string;
|
|
70
|
-
inStock?: boolean;
|
|
71
|
-
brand?: string;
|
|
72
|
-
description?: string;
|
|
73
|
-
[key: string]: unknown;
|
|
74
|
-
}
|
|
1
|
+
import { VectorMatch, UITransformationResponse, PieChartData, BarChartData, LineChartDataPoint, TableData, CarouselProduct, VisualizationType } from '../types';
|
|
2
|
+
import { ILLMProvider } from '../llm/ILLMProvider';
|
|
75
3
|
|
|
76
4
|
/**
|
|
77
5
|
* UITransformer — Converts vector database results into structured UI representations
|
|
@@ -80,109 +8,82 @@ export interface CarouselProduct {
|
|
|
80
8
|
* format based on the content and structure of the data.
|
|
81
9
|
*/
|
|
82
10
|
export class UITransformer {
|
|
11
|
+
/**
|
|
12
|
+
* Central dictionary of common synonyms for UI properties.
|
|
13
|
+
* This allows the system to be schema-agnostic by guessing field names.
|
|
14
|
+
*/
|
|
15
|
+
private static readonly SYNONYMS: Record<string, string[]> = {
|
|
16
|
+
name: ['product', 'item', 'title', 'label', 'heading', 'subject'],
|
|
17
|
+
price: ['cost', 'amount', 'msrp', 'price', 'rate', 'value', 'price_usd'],
|
|
18
|
+
brand: ['manufacturer', 'vendor', 'make', 'company', 'brand_name', 'supplier'],
|
|
19
|
+
image: ['imageUrl', 'thumbnail', 'img', 'url', 'photo', 'picture', 'media', 'image_url', 'main_image', 'product_image', 'thumb'],
|
|
20
|
+
stock: ['inventory', 'quantity', 'count', 'availability', 'stock_level', 'inStock', 'is_available'],
|
|
21
|
+
description: ['summary', 'content', 'body', 'text', 'info', 'details'],
|
|
22
|
+
};
|
|
23
|
+
|
|
83
24
|
/**
|
|
84
25
|
* Main transformation method
|
|
85
|
-
* Analyzes user query and retrieved data to determine
|
|
86
|
-
*
|
|
87
|
-
* @param userQuery - The original user question/query
|
|
88
|
-
* @param retrievedData - Vector database retrieval results
|
|
89
|
-
* @returns Structured JSON response for UI rendering
|
|
26
|
+
* Analyzes user query and retrieved data to determine if a product carousel is needed.
|
|
90
27
|
*/
|
|
91
28
|
static transform(
|
|
92
29
|
userQuery: string,
|
|
93
|
-
retrievedData: VectorMatch[]
|
|
30
|
+
retrievedData: VectorMatch[],
|
|
31
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
32
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
94
33
|
): UITransformationResponse {
|
|
95
34
|
if (!retrievedData || retrievedData.length === 0) {
|
|
96
35
|
return this.createTextResponse('No data available', 'No relevant data found for your query.');
|
|
97
36
|
}
|
|
98
37
|
|
|
99
|
-
|
|
100
|
-
const
|
|
38
|
+
const isStockRequest = this.isStockQuery(userQuery);
|
|
39
|
+
const filteredData = isStockRequest
|
|
40
|
+
? retrievedData.filter(item => this.determineStockStatus(item))
|
|
41
|
+
: retrievedData;
|
|
101
42
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
case 'pie_chart':
|
|
107
|
-
return this.transformToPieChart(retrievedData);
|
|
108
|
-
case 'bar_chart':
|
|
109
|
-
return this.transformToBarChart(retrievedData);
|
|
110
|
-
case 'line_chart':
|
|
111
|
-
return this.transformToLineChart(retrievedData);
|
|
112
|
-
case 'table':
|
|
113
|
-
return this.transformToTable(retrievedData);
|
|
114
|
-
default:
|
|
115
|
-
return this.transformToText(retrievedData);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
43
|
+
const categories = this.detectCategories(filteredData);
|
|
44
|
+
const hasProducts = filteredData.some(item => this.isProductData(item));
|
|
45
|
+
const isTimeSeries = filteredData.some(item => this.isTimeSeriesData(item));
|
|
46
|
+
const isTrendQuery = this.isTrendQuery(userQuery);
|
|
118
47
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
private static analyzeData(
|
|
123
|
-
query: string,
|
|
124
|
-
data: VectorMatch[]
|
|
125
|
-
): {
|
|
126
|
-
suggestedType: VisualizationType;
|
|
127
|
-
confidence: number;
|
|
128
|
-
hasProducts: boolean;
|
|
129
|
-
hasTimeSeries: boolean;
|
|
130
|
-
hasCategories: boolean;
|
|
131
|
-
} {
|
|
132
|
-
const queryLower = query.toLowerCase();
|
|
133
|
-
const hasProducts = data.some(item =>
|
|
134
|
-
this.isProductData(item)
|
|
135
|
-
);
|
|
136
|
-
const hasTimeSeries = data.some(item =>
|
|
137
|
-
this.isTimeSeriesData(item)
|
|
138
|
-
);
|
|
139
|
-
const hasCategories = this.detectCategories(data).length > 0;
|
|
140
|
-
const isDistributionQuery =
|
|
141
|
-
queryLower.includes('distribution') ||
|
|
142
|
-
queryLower.includes('breakdown') ||
|
|
143
|
-
queryLower.includes('percentage') ||
|
|
144
|
-
queryLower.includes('proportion');
|
|
145
|
-
const isComparisonQuery =
|
|
146
|
-
queryLower.includes('compare') ||
|
|
147
|
-
queryLower.includes('vs') ||
|
|
148
|
-
queryLower.includes('difference');
|
|
149
|
-
const isTrendQuery =
|
|
150
|
-
queryLower.includes('trend') ||
|
|
151
|
-
queryLower.includes('over time') ||
|
|
152
|
-
queryLower.includes('growth') ||
|
|
153
|
-
queryLower.includes('decline');
|
|
154
|
-
|
|
155
|
-
// Decision logic
|
|
156
|
-
if (hasProducts && !hasTimeSeries && !isDistributionQuery) {
|
|
157
|
-
return { suggestedType: 'product_carousel', confidence: 0.95, hasProducts, hasTimeSeries, hasCategories };
|
|
48
|
+
// 1. High Priority: Specific visualizations for trends
|
|
49
|
+
if (isTrendQuery && isTimeSeries) {
|
|
50
|
+
return this.transformToLineChart(filteredData);
|
|
158
51
|
}
|
|
159
52
|
|
|
160
|
-
|
|
161
|
-
|
|
53
|
+
// 2. High Priority: Products (Always prefer carousel for products unless it's a specific breakdown request)
|
|
54
|
+
if (hasProducts && !this.shouldShowCategoryChart(userQuery, categories)) {
|
|
55
|
+
return this.transformToProductCarousel(filteredData, config, trainedSchema);
|
|
162
56
|
}
|
|
163
57
|
|
|
164
|
-
|
|
165
|
-
|
|
58
|
+
// 3. Category breakdowns
|
|
59
|
+
if (categories.length > 1 && this.shouldShowCategoryChart(userQuery, categories)) {
|
|
60
|
+
return this.transformToPieChart(filteredData);
|
|
166
61
|
}
|
|
167
62
|
|
|
168
|
-
|
|
169
|
-
|
|
63
|
+
// 4. Secondary: Products as carousel if not already handled
|
|
64
|
+
if (hasProducts) {
|
|
65
|
+
return this.transformToProductCarousel(filteredData, config, trainedSchema);
|
|
170
66
|
}
|
|
171
67
|
|
|
172
|
-
|
|
173
|
-
|
|
68
|
+
// 5. Detailed data without clear product structure -> Table
|
|
69
|
+
if (this.hasMultipleFields(filteredData)) {
|
|
70
|
+
return this.transformToTable(filteredData);
|
|
174
71
|
}
|
|
175
72
|
|
|
176
|
-
return
|
|
73
|
+
return this.transformToText(filteredData);
|
|
177
74
|
}
|
|
178
75
|
|
|
179
76
|
/**
|
|
180
77
|
* Transform data to product carousel format
|
|
181
78
|
*/
|
|
182
|
-
private static transformToProductCarousel(
|
|
79
|
+
private static transformToProductCarousel(
|
|
80
|
+
data: VectorMatch[],
|
|
81
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
82
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
83
|
+
): UITransformationResponse {
|
|
183
84
|
const products: CarouselProduct[] = data
|
|
184
85
|
.filter(item => this.isProductData(item))
|
|
185
|
-
.map(item => this.extractProductInfo(item))
|
|
86
|
+
.map(item => this.extractProductInfo(item, config, trainedSchema))
|
|
186
87
|
.filter((p): p is CarouselProduct => p !== null);
|
|
187
88
|
|
|
188
89
|
return {
|
|
@@ -202,11 +103,15 @@ export class UITransformer {
|
|
|
202
103
|
const categories = this.detectCategories(data);
|
|
203
104
|
const categoryData = this.aggregateByCategory(data, categories);
|
|
204
105
|
|
|
205
|
-
const pieData: PieChartData[] = Object.entries(categoryData).map(([label, count]) =>
|
|
206
|
-
label,
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
106
|
+
const pieData: PieChartData[] = Object.entries(categoryData).map(([label, count]) => {
|
|
107
|
+
const { inStockCount, outOfStockCount } = this.calculateStockCounts(label, data);
|
|
108
|
+
return {
|
|
109
|
+
label,
|
|
110
|
+
value: count as number,
|
|
111
|
+
inStockCount,
|
|
112
|
+
outOfStockCount,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
210
115
|
|
|
211
116
|
return {
|
|
212
117
|
type: 'pie_chart',
|
|
@@ -225,11 +130,15 @@ export class UITransformer {
|
|
|
225
130
|
const categories = this.detectCategories(data);
|
|
226
131
|
const categoryData = this.aggregateByCategory(data, categories);
|
|
227
132
|
|
|
228
|
-
const barData: BarChartData[] = Object.entries(categoryData).map(([category, value]) =>
|
|
229
|
-
category,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
133
|
+
const barData: BarChartData[] = Object.entries(categoryData).map(([category, value]) => {
|
|
134
|
+
const { inStockCount, outOfStockCount } = this.calculateStockCounts(category, data);
|
|
135
|
+
return {
|
|
136
|
+
category,
|
|
137
|
+
value: value as number,
|
|
138
|
+
inStockCount,
|
|
139
|
+
outOfStockCount,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
233
142
|
|
|
234
143
|
return {
|
|
235
144
|
type: 'bar_chart',
|
|
@@ -310,16 +219,171 @@ export class UITransformer {
|
|
|
310
219
|
};
|
|
311
220
|
}
|
|
312
221
|
|
|
222
|
+
static parseTransformationResponse(raw: string): UITransformationResponse | null {
|
|
223
|
+
const payloadText = this.extractJsonCandidate(raw);
|
|
224
|
+
if (!payloadText) return null;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(payloadText);
|
|
228
|
+
return this.normalizeTransformation(parsed);
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private static extractJsonCandidate(raw: string): string | null {
|
|
235
|
+
if (!raw) return null;
|
|
236
|
+
|
|
237
|
+
const cleaned = raw
|
|
238
|
+
.replace(/```(?:json|chart|ui)?\s*/gi, '')
|
|
239
|
+
.replace(/```/g, '')
|
|
240
|
+
.trim();
|
|
241
|
+
|
|
242
|
+
const start = cleaned.indexOf('{');
|
|
243
|
+
if (start === -1) return null;
|
|
244
|
+
|
|
245
|
+
let depth = 0;
|
|
246
|
+
let inString = false;
|
|
247
|
+
let escape = false;
|
|
248
|
+
|
|
249
|
+
for (let i = start; i < cleaned.length; i += 1) {
|
|
250
|
+
const char = cleaned[i];
|
|
251
|
+
if (escape) {
|
|
252
|
+
escape = false;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (char === '\\') {
|
|
256
|
+
escape = true;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (char === '"') {
|
|
260
|
+
inString = !inString;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (inString) continue;
|
|
264
|
+
|
|
265
|
+
if (char === '{') depth += 1;
|
|
266
|
+
if (char === '}') {
|
|
267
|
+
depth -= 1;
|
|
268
|
+
if (depth === 0) {
|
|
269
|
+
return cleaned.slice(start, i + 1);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private static normalizeTransformation(payload: unknown): UITransformationResponse | null {
|
|
278
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
279
|
+
|
|
280
|
+
const payloadObj = payload as Record<string, unknown>;
|
|
281
|
+
const type = this.normalizeVisualizationType(String(payloadObj.type ?? payloadObj.view ?? payloadObj.chartType ?? ''));
|
|
282
|
+
if (!type) return null;
|
|
283
|
+
|
|
284
|
+
const title = String(payloadObj.title ?? payloadObj.heading ?? 'Visualization');
|
|
285
|
+
const description = payloadObj.description ? String(payloadObj.description) : undefined;
|
|
286
|
+
const rawData = payloadObj.data ?? payloadObj.table ?? payloadObj.rows ?? payloadObj.items ?? payloadObj.content ?? null;
|
|
287
|
+
|
|
288
|
+
const data = type === 'text' && typeof rawData === 'string'
|
|
289
|
+
? { content: rawData }
|
|
290
|
+
: rawData;
|
|
291
|
+
|
|
292
|
+
const transformation: UITransformationResponse = {
|
|
293
|
+
type,
|
|
294
|
+
title,
|
|
295
|
+
description,
|
|
296
|
+
data,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return this.validateTransformation(transformation) ? transformation : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private static normalizeVisualizationType(type: string): VisualizationType | null {
|
|
303
|
+
const mapping: Record<string, VisualizationType> = {
|
|
304
|
+
pie: 'pie_chart',
|
|
305
|
+
pie_chart: 'pie_chart',
|
|
306
|
+
bar: 'bar_chart',
|
|
307
|
+
bar_chart: 'bar_chart',
|
|
308
|
+
line: 'line_chart',
|
|
309
|
+
line_chart: 'line_chart',
|
|
310
|
+
radar: 'radar_chart',
|
|
311
|
+
radar_chart: 'radar_chart',
|
|
312
|
+
table: 'table',
|
|
313
|
+
text: 'text',
|
|
314
|
+
product_carousel: 'product_carousel',
|
|
315
|
+
carousel: 'carousel',
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
return mapping[type.toLowerCase()] ?? null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private static validateTransformation(transformation: UITransformationResponse): boolean {
|
|
322
|
+
const { type, data } = transformation;
|
|
323
|
+
|
|
324
|
+
switch (type) {
|
|
325
|
+
case 'pie_chart':
|
|
326
|
+
case 'bar_chart':
|
|
327
|
+
return Array.isArray(data) && data.every((item): item is Record<string, unknown> =>
|
|
328
|
+
item !== null && typeof item === 'object' &&
|
|
329
|
+
(typeof item.value === 'number' || !Number.isNaN(Number(item.value)))
|
|
330
|
+
);
|
|
331
|
+
case 'line_chart':
|
|
332
|
+
return Array.isArray(data) && data.every((item): item is Record<string, unknown> =>
|
|
333
|
+
item !== null && typeof item === 'object' &&
|
|
334
|
+
'timestamp' in item &&
|
|
335
|
+
(typeof item.value === 'number' || !Number.isNaN(Number(item.value)))
|
|
336
|
+
);
|
|
337
|
+
case 'radar_chart':
|
|
338
|
+
return Array.isArray(data) && data.every((item): item is Record<string, unknown> =>
|
|
339
|
+
item !== null && typeof item === 'object' &&
|
|
340
|
+
'attribute' in item
|
|
341
|
+
);
|
|
342
|
+
case 'table':
|
|
343
|
+
return (
|
|
344
|
+
typeof data === 'object' &&
|
|
345
|
+
data !== null &&
|
|
346
|
+
Array.isArray((data as Record<string, unknown>).columns) &&
|
|
347
|
+
Array.isArray((data as Record<string, unknown>).rows)
|
|
348
|
+
);
|
|
349
|
+
case 'text':
|
|
350
|
+
return (
|
|
351
|
+
typeof data === 'object' &&
|
|
352
|
+
data !== null &&
|
|
353
|
+
typeof (data as Record<string, unknown>).content === 'string'
|
|
354
|
+
);
|
|
355
|
+
case 'product_carousel':
|
|
356
|
+
case 'carousel':
|
|
357
|
+
return Array.isArray(data) && data.every((item): item is Record<string, unknown> =>
|
|
358
|
+
item !== null && typeof item === 'object' && (('id' in item) || ('name' in item))
|
|
359
|
+
);
|
|
360
|
+
default:
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
313
365
|
/**
|
|
314
366
|
* Helper: Check if data item is product-related
|
|
315
367
|
*/
|
|
316
368
|
private static isProductData(item: VectorMatch): boolean {
|
|
317
369
|
const content = (item.content || '').toLowerCase();
|
|
318
|
-
const productKeywords = [
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
370
|
+
const productKeywords = [
|
|
371
|
+
'product', 'price', 'stock', 'item', 'sku', 'brand', 'model', 'msrp', 'inventory', 'buy', 'shop',
|
|
372
|
+
'beauty', 'care', 'cosmetic', 'facial', 'cream', 'serum', 'mask', 'makeup', 'fragrance'
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
// Check content for keywords
|
|
376
|
+
const hasKeywords = productKeywords.some(kw => content.includes(kw));
|
|
377
|
+
|
|
378
|
+
// Check metadata keys
|
|
379
|
+
const hasMetadataKey = Object.keys(item.metadata || {}).some(k =>
|
|
380
|
+
['name', 'price', 'product', 'sku', 'brand', 'model', 'cost', 'item'].includes(k.toLowerCase())
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
// Failsafe: if we have a dollar sign followed by a number, it's likely a product
|
|
384
|
+
const hasPricePattern = /\$\s*\d+/.test(content);
|
|
385
|
+
|
|
386
|
+
return hasKeywords || hasMetadataKey || hasPricePattern;
|
|
323
387
|
}
|
|
324
388
|
|
|
325
389
|
/**
|
|
@@ -327,41 +391,156 @@ export class UITransformer {
|
|
|
327
391
|
*/
|
|
328
392
|
private static isTimeSeriesData(item: VectorMatch): boolean {
|
|
329
393
|
const content = (item.content || '').toLowerCase();
|
|
330
|
-
const timeKeywords = ['
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
394
|
+
const timeKeywords = ['trend', 'historical', 'growth', 'decline', 'change', 'increase', 'decrease'];
|
|
395
|
+
const hasTimeKeyword = timeKeywords.some(kw => content.includes(kw));
|
|
396
|
+
|
|
397
|
+
const metadata = item.metadata || {};
|
|
398
|
+
const maybeDateKeys = Object.keys(metadata).filter((k) =>
|
|
399
|
+
['date', 'timestamp', 'time', 'period'].includes(k.toLowerCase())
|
|
400
|
+
);
|
|
401
|
+
const hasValidDateValue = maybeDateKeys.some((key) => {
|
|
402
|
+
const value = metadata[key];
|
|
403
|
+
if (typeof value === 'string') {
|
|
404
|
+
return !Number.isNaN(Date.parse(value)) && value.trim().length > 0;
|
|
405
|
+
}
|
|
406
|
+
return typeof value === 'number';
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return hasTimeKeyword || hasValidDateValue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private static shouldShowCategoryChart(query: string, categories: string[]): boolean {
|
|
413
|
+
if (categories.length < 2) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const normalized = query.toLowerCase();
|
|
418
|
+
const chartKeywords = [
|
|
419
|
+
'distribution',
|
|
420
|
+
'breakdown',
|
|
421
|
+
'by category',
|
|
422
|
+
'by type',
|
|
423
|
+
'compare',
|
|
424
|
+
'share',
|
|
425
|
+
'percentage',
|
|
426
|
+
'segmentation',
|
|
427
|
+
'split',
|
|
428
|
+
'category breakdown',
|
|
429
|
+
'category distribution',
|
|
430
|
+
];
|
|
431
|
+
|
|
432
|
+
return chartKeywords.some(keyword => normalized.includes(keyword));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private static isTrendQuery(query: string): boolean {
|
|
436
|
+
const normalized = query.toLowerCase();
|
|
437
|
+
const trendKeywords = [
|
|
438
|
+
'trend',
|
|
439
|
+
'over time',
|
|
440
|
+
'historical',
|
|
441
|
+
'growth',
|
|
442
|
+
'decline',
|
|
443
|
+
'increase',
|
|
444
|
+
'decrease',
|
|
445
|
+
'year',
|
|
446
|
+
'month',
|
|
447
|
+
'week',
|
|
448
|
+
'day',
|
|
449
|
+
'comparison',
|
|
450
|
+
'compare',
|
|
451
|
+
'changes',
|
|
452
|
+
'timeline',
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
return trendKeywords.some(keyword => normalized.includes(keyword));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private static isStockQuery(query: string): boolean {
|
|
459
|
+
const normalized = query.toLowerCase();
|
|
460
|
+
return (
|
|
461
|
+
normalized.includes('in stock') ||
|
|
462
|
+
normalized.includes('available') ||
|
|
463
|
+
normalized.includes('availability') ||
|
|
464
|
+
normalized.includes('inventory') ||
|
|
465
|
+
normalized.includes('stock status')
|
|
466
|
+
);
|
|
335
467
|
}
|
|
336
468
|
|
|
337
469
|
/**
|
|
338
|
-
* Helper: Extract
|
|
470
|
+
* Helper: Extract property from metadata using mapping, AI training, case-insensitivity, and synonyms.
|
|
339
471
|
*/
|
|
340
|
-
private static
|
|
341
|
-
|
|
472
|
+
private static getDynamicVal(
|
|
473
|
+
meta: Record<string, unknown> | undefined,
|
|
474
|
+
uiKey: string,
|
|
475
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
476
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
477
|
+
): unknown {
|
|
478
|
+
if (!meta) return undefined;
|
|
479
|
+
|
|
480
|
+
const mapping = config?.rag?.uiMapping;
|
|
481
|
+
if (mapping && mapping[uiKey]) {
|
|
482
|
+
const mappedKey = mapping[uiKey];
|
|
483
|
+
if (meta[mappedKey] !== undefined) return meta[mappedKey];
|
|
484
|
+
}
|
|
342
485
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
return
|
|
346
|
-
id: item.id,
|
|
347
|
-
name: (meta.name || meta.product) as string,
|
|
348
|
-
price: meta.price as string | number | undefined,
|
|
349
|
-
image: (meta.image || meta.imageUrl) as string | undefined,
|
|
350
|
-
brand: meta.brand as string | undefined,
|
|
351
|
-
description: item.content,
|
|
352
|
-
inStock: this.determineStockStatus(item),
|
|
353
|
-
};
|
|
486
|
+
if (trainedSchema && typeof trainedSchema === 'object' && trainedSchema !== null) {
|
|
487
|
+
const trainedKey = (trainedSchema as Record<string, unknown>)[uiKey];
|
|
488
|
+
if (trainedKey && meta[trainedKey as string] !== undefined) return meta[trainedKey as string];
|
|
354
489
|
}
|
|
355
490
|
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
491
|
+
const synonyms = this.SYNONYMS[uiKey] || [];
|
|
492
|
+
const searchKeys = [uiKey, ...synonyms].map((k) => k.toLowerCase());
|
|
493
|
+
|
|
494
|
+
const foundDirectKey = Object.keys(meta).find((k) => searchKeys.includes(k.toLowerCase()));
|
|
495
|
+
if (foundDirectKey) return meta[foundDirectKey];
|
|
496
|
+
|
|
497
|
+
const foundPartialKey = Object.keys(meta).find((k) => {
|
|
498
|
+
const keyLow = k.toLowerCase();
|
|
499
|
+
return searchKeys.some((sk) => keyLow.includes(sk) || sk.includes(keyLow));
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
return foundPartialKey ? meta[foundPartialKey] : undefined;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private static extractProductInfo(
|
|
506
|
+
item: VectorMatch,
|
|
507
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
508
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
509
|
+
): CarouselProduct | null {
|
|
510
|
+
const meta = item.metadata || {};
|
|
511
|
+
|
|
512
|
+
const name = this.getDynamicVal(meta, 'name', config, trainedSchema);
|
|
513
|
+
const price = this.getDynamicVal(meta, 'price', config, trainedSchema);
|
|
514
|
+
const brand = this.getDynamicVal(meta, 'brand', config, trainedSchema);
|
|
515
|
+
|
|
516
|
+
// If we have at least a name or a clear product-like structure in content
|
|
517
|
+
if (name || this.isProductData(item)) {
|
|
518
|
+
// Robust name extraction: metadata > regex > content snippet
|
|
519
|
+
let finalName = name ? String(name) : undefined;
|
|
520
|
+
if (!finalName) {
|
|
521
|
+
const nameMatch = item.content.match(/(?:Product|Item|Name):\s*([^\n]+)/i);
|
|
522
|
+
finalName = nameMatch ? nameMatch[1].trim() : item.content.split('\n')[0].substring(0, 60);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Robust price extraction: metadata > regex
|
|
526
|
+
let finalPrice: string | number | undefined =
|
|
527
|
+
typeof price === 'number' || typeof price === 'string'
|
|
528
|
+
? price
|
|
529
|
+
: undefined;
|
|
530
|
+
|
|
531
|
+
if (!finalPrice) {
|
|
532
|
+
const priceMatch = item.content.match(/(?:Price|Cost):\s*\$?([\d,.]+)/i) || item.content.match(/\$\s*([\d,.]+)/);
|
|
533
|
+
if (priceMatch) finalPrice = priceMatch[1].replace(/,/g, '');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const imageValue = this.getDynamicVal(meta, 'image', config, trainedSchema);
|
|
359
537
|
|
|
360
|
-
if (nameMatch) {
|
|
361
538
|
return {
|
|
362
539
|
id: item.id,
|
|
363
|
-
name:
|
|
364
|
-
price:
|
|
540
|
+
name: finalName,
|
|
541
|
+
price: finalPrice,
|
|
542
|
+
image: typeof imageValue === 'string' ? imageValue : undefined,
|
|
543
|
+
brand: brand ? String(brand) : undefined,
|
|
365
544
|
description: item.content,
|
|
366
545
|
inStock: this.determineStockStatus(item),
|
|
367
546
|
};
|
|
@@ -391,7 +570,15 @@ export class UITransformer {
|
|
|
391
570
|
tags.forEach(t => categories.add(String(t)));
|
|
392
571
|
}
|
|
393
572
|
|
|
394
|
-
// Parse from content
|
|
573
|
+
// Parse category strings from content lines like "Shoes & Footwear: • Product"
|
|
574
|
+
const contentCategories = Array.from(new Set(
|
|
575
|
+
Array.from(item.content.matchAll(/^\s*([^:\n]+):\s*(?:•|\*|-)*/gm))
|
|
576
|
+
.map(match => match[1].trim())
|
|
577
|
+
.filter(Boolean)
|
|
578
|
+
));
|
|
579
|
+
contentCategories.forEach((category) => categories.add(category));
|
|
580
|
+
|
|
581
|
+
// Parse from structured category hints in content
|
|
395
582
|
const categoryMatch = item.content.match(/(?:Category|Type|Class):\s*([^\n]+)/i);
|
|
396
583
|
if (categoryMatch) {
|
|
397
584
|
categories.add(categoryMatch[1].trim());
|
|
@@ -477,15 +664,25 @@ export class UITransformer {
|
|
|
477
664
|
}
|
|
478
665
|
|
|
479
666
|
/**
|
|
480
|
-
* Helper:
|
|
667
|
+
* Helper: Calculate stock counts by category
|
|
481
668
|
*/
|
|
482
|
-
private static
|
|
483
|
-
|
|
669
|
+
private static calculateStockCounts(category: string, data: VectorMatch[]): { inStockCount: number; outOfStockCount: number } {
|
|
670
|
+
let inStock = 0;
|
|
671
|
+
let outOfStock = 0;
|
|
672
|
+
|
|
673
|
+
data.forEach(d => {
|
|
484
674
|
const meta = d.metadata || {};
|
|
485
|
-
|
|
675
|
+
const itemCategory = (meta.category || meta.type || 'Other') as string;
|
|
676
|
+
if (itemCategory === category) {
|
|
677
|
+
if (this.determineStockStatus(d)) {
|
|
678
|
+
inStock++;
|
|
679
|
+
} else {
|
|
680
|
+
outOfStock++;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
486
683
|
});
|
|
487
684
|
|
|
488
|
-
return
|
|
685
|
+
return { inStockCount: inStock, outOfStockCount: outOfStock };
|
|
489
686
|
}
|
|
490
687
|
|
|
491
688
|
/**
|
|
@@ -532,4 +729,137 @@ export class UITransformer {
|
|
|
532
729
|
|
|
533
730
|
return fieldCount.size > 2;
|
|
534
731
|
}
|
|
732
|
+
|
|
733
|
+
// ─── LLM-Driven Visualization Decision ────────────────────────────────────
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* analyzeAndDecide — sends user question + RAG data to the LLM with a
|
|
737
|
+
* structured system prompt and parses the JSON response into a
|
|
738
|
+
* UITransformationResponse.
|
|
739
|
+
*
|
|
740
|
+
* This is the recommended entry point for production use. The heuristic
|
|
741
|
+
* `transform()` method is used as a fallback if the LLM call fails.
|
|
742
|
+
*
|
|
743
|
+
* System prompt instructs the LLM to:
|
|
744
|
+
* - Analyze the question and retrieved data
|
|
745
|
+
* - Choose the best visualization: bar_chart | line_chart | pie_chart | table | text
|
|
746
|
+
* - Return a strict JSON object — no prose, no markdown fences
|
|
747
|
+
*
|
|
748
|
+
* @param query - the original user question
|
|
749
|
+
* @param sources - vector DB matches returned by RAG retrieval
|
|
750
|
+
* @param llm - any ILLMProvider instance (OpenAI, Anthropic, Ollama, Gemini, REST…)
|
|
751
|
+
* @returns - a validated UITransformationResponse (type + title + description + data)
|
|
752
|
+
*/
|
|
753
|
+
static async analyzeAndDecide(
|
|
754
|
+
query: string,
|
|
755
|
+
sources: VectorMatch[],
|
|
756
|
+
llm: ILLMProvider,
|
|
757
|
+
): Promise<UITransformationResponse> {
|
|
758
|
+
try {
|
|
759
|
+
const context = this.buildContextSummary(sources);
|
|
760
|
+
const systemPrompt = this.buildVisualizationSystemPrompt();
|
|
761
|
+
|
|
762
|
+
const userPrompt = [
|
|
763
|
+
`USER QUESTION: ${query}`,
|
|
764
|
+
'',
|
|
765
|
+
'RETRIEVED DATA (JSON):',
|
|
766
|
+
context,
|
|
767
|
+
].join('\n');
|
|
768
|
+
|
|
769
|
+
const rawResponse = await llm.chat(
|
|
770
|
+
[{ role: 'user', content: userPrompt }],
|
|
771
|
+
'',
|
|
772
|
+
{ systemPrompt, temperature: 0 },
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
const parsed = this.parseTransformationResponse(rawResponse);
|
|
776
|
+
if (parsed) {
|
|
777
|
+
console.debug('[UITransformer] LLM chose visualization type:', parsed.type);
|
|
778
|
+
return parsed;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
console.warn('[UITransformer] LLM returned unparseable response; falling back to heuristic.');
|
|
782
|
+
} catch (err) {
|
|
783
|
+
console.warn('[UITransformer] analyzeAndDecide LLM call failed; falling back to heuristic.', err);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Fallback: keyword-heuristic transform
|
|
787
|
+
return this.transform(query, sources);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Build the system prompt that instructs the LLM to return a visualization JSON.
|
|
792
|
+
*/
|
|
793
|
+
private static buildVisualizationSystemPrompt(): string {
|
|
794
|
+
return `You are a data visualization expert embedded in a RAG chat system.
|
|
795
|
+
You will receive a user question and structured data retrieved from a vector database.
|
|
796
|
+
Your ONLY job is to analyze this information and return a single JSON object that tells
|
|
797
|
+
the frontend how to visualize it.
|
|
798
|
+
|
|
799
|
+
Return ONLY a valid JSON object — no markdown code fences, no explanation, no prose.
|
|
800
|
+
|
|
801
|
+
The JSON must have this exact shape:
|
|
802
|
+
{
|
|
803
|
+
"type": "bar_chart" | "line_chart" | "pie_chart" | "radar_chart" | "table" | "text",
|
|
804
|
+
"title": "A concise, descriptive title for the visualization",
|
|
805
|
+
"description": "One sentence describing what the visualization shows",
|
|
806
|
+
"data": <structured data — see rules below>
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
DATA SHAPE per type:
|
|
810
|
+
- bar_chart: array of { "category": string, "value": number }
|
|
811
|
+
- line_chart: array of { "timestamp": string, "value": number, "label": string }
|
|
812
|
+
- pie_chart: array of { "label": string, "value": number }
|
|
813
|
+
- radar_chart: array of { "attribute": string, "[series1]": number, "[series2]": number, ... }
|
|
814
|
+
Example for radar_chart:
|
|
815
|
+
[
|
|
816
|
+
{ "attribute": "Longevity", "Dolce Shine": 4, "CK One": 3 },
|
|
817
|
+
{ "attribute": "Sillage", "Dolce Shine": 3, "CK One": 4 },
|
|
818
|
+
{ "attribute": "Freshness", "Dolce Shine": 5, "CK One": 4 }
|
|
819
|
+
]
|
|
820
|
+
- table: { "columns": string[], "rows": (string|number)[][] }
|
|
821
|
+
- text: { "content": "<prose answer>" }
|
|
822
|
+
|
|
823
|
+
DECISION RULES (follow strictly):
|
|
824
|
+
1. bar_chart → comparing quantities across categories (e.g. sales by region, price by product). Use this when there is only ONE value per category.
|
|
825
|
+
2. line_chart → trends or changes over time (dates, months, years, sequential events)
|
|
826
|
+
3. pie_chart → proportional breakdown or percentage distribution
|
|
827
|
+
4. radar_chart → comparing 2 or more products across MULTIPLE attributes (e.g. comparing features, ratings, or characteristics of 2 specific products). If the user asks to "compare" products and the data contains multiple dimensions or ratings for them, you MUST use this.
|
|
828
|
+
5. table → multi-field structured records where each item has ≥ 3 attributes
|
|
829
|
+
6. text → conversational or free-form answers where no chart adds value
|
|
830
|
+
|
|
831
|
+
IMPORTANT:
|
|
832
|
+
- Aggregate numeric values from the raw data — do not pass raw object arrays as data.
|
|
833
|
+
- Ensure all "value" fields are numbers, not strings.
|
|
834
|
+
- For bar/line/pie, keep at most 12 data points for readability.
|
|
835
|
+
- Never include nested objects or arrays inside bar_chart / line_chart / pie_chart data items.`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Serialize retrieved vector matches into a compact JSON context string.
|
|
840
|
+
* Limits the total character count to avoid exceeding LLM context windows.
|
|
841
|
+
*/
|
|
842
|
+
private static buildContextSummary(sources: VectorMatch[], maxChars = 6000): string {
|
|
843
|
+
const items = sources.map((s, i) => ({
|
|
844
|
+
index: i + 1,
|
|
845
|
+
content: s.content?.substring(0, 400) ?? '',
|
|
846
|
+
metadata: s.metadata ?? {},
|
|
847
|
+
score: s.score ?? 0,
|
|
848
|
+
}));
|
|
849
|
+
|
|
850
|
+
const full = JSON.stringify(items, null, 2);
|
|
851
|
+
if (full.length <= maxChars) return full;
|
|
852
|
+
|
|
853
|
+
// Truncate: include as many items as fit
|
|
854
|
+
const partial: typeof items = [];
|
|
855
|
+
let chars = 2; // for []
|
|
856
|
+
for (const item of items) {
|
|
857
|
+
const chunk = JSON.stringify(item);
|
|
858
|
+
if (chars + chunk.length + 2 > maxChars) break;
|
|
859
|
+
partial.push(item);
|
|
860
|
+
chars += chunk.length + 2;
|
|
861
|
+
}
|
|
862
|
+
return JSON.stringify(partial, null, 2) + '\n// ... truncated';
|
|
863
|
+
}
|
|
535
864
|
}
|
|
865
|
+
|