@retrivora-ai/rag-engine 1.8.0 → 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-DPsQodME.d.mts → ILLMProvider-BfRgI1Xh.d.mts} +58 -1
- package/dist/{index-DPsQodME.d.ts → ILLMProvider-BfRgI1Xh.d.ts} +58 -1
- package/dist/MultiTablePostgresProvider-YY7LPNJK.mjs +8 -0
- package/dist/{chunk-PV3MFHWU.mjs → chunk-BFYLQYQU.mjs} +808 -439
- 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 +995 -603
- package/dist/handlers/index.mjs +1 -1
- package/dist/{index-Bb2yEopi.d.mts → index-1Z4GuYBi.d.ts} +7 -1
- package/dist/{index-CkbTzj9J.d.ts → index-BV0z5mb6.d.mts} +7 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +491 -316
- package/dist/index.mjs +411 -217
- package/dist/server.d.mts +35 -5
- package/dist/server.d.ts +35 -5
- package/dist/server.js +1146 -777
- package/dist/server.mjs +163 -176
- package/package.json +4 -2
- package/src/app/page.tsx +1 -2
- package/src/components/MessageBubble.tsx +211 -69
- 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 +65 -206
- 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 +80 -0
- package/src/utils/SchemaMapper.ts +129 -0
- package/src/utils/UITransformer.ts +491 -181
- package/dist/DocumentChunker-C1GEEosY.d.ts +0 -93
- package/dist/DocumentChunker-CFEiRopR.d.mts +0 -93
- package/dist/PostgreSQLProvider-BMOETDZA.mjs +0 -8
- package/dist/chunk-FLOSGE6A.mjs +0 -202
|
@@ -1,79 +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
|
-
inStockCount?: number;
|
|
31
|
-
outOfStockCount?: number;
|
|
32
|
-
[key: string]: unknown;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Bar chart data structure
|
|
37
|
-
*/
|
|
38
|
-
export interface BarChartData {
|
|
39
|
-
category: string;
|
|
40
|
-
value: number;
|
|
41
|
-
inStockCount?: number;
|
|
42
|
-
outOfStockCount?: number;
|
|
43
|
-
[key: string]: unknown;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Line chart data point
|
|
48
|
-
*/
|
|
49
|
-
export interface LineChartDataPoint {
|
|
50
|
-
timestamp: string | number;
|
|
51
|
-
value: number;
|
|
52
|
-
label?: string;
|
|
53
|
-
[key: string]: unknown;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Table representation
|
|
58
|
-
*/
|
|
59
|
-
export interface TableData {
|
|
60
|
-
columns: string[];
|
|
61
|
-
rows: (string | number | boolean)[][];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Product carousel item
|
|
66
|
-
*/
|
|
67
|
-
export interface CarouselProduct {
|
|
68
|
-
id: string | number;
|
|
69
|
-
name: string;
|
|
70
|
-
price?: number | string;
|
|
71
|
-
image?: string;
|
|
72
|
-
inStock?: boolean;
|
|
73
|
-
brand?: string;
|
|
74
|
-
description?: string;
|
|
75
|
-
[key: string]: unknown;
|
|
76
|
-
}
|
|
1
|
+
import { VectorMatch, UITransformationResponse, PieChartData, BarChartData, LineChartDataPoint, TableData, CarouselProduct, VisualizationType } from '../types';
|
|
2
|
+
import { ILLMProvider } from '../llm/ILLMProvider';
|
|
77
3
|
|
|
78
4
|
/**
|
|
79
5
|
* UITransformer — Converts vector database results into structured UI representations
|
|
@@ -82,109 +8,82 @@ export interface CarouselProduct {
|
|
|
82
8
|
* format based on the content and structure of the data.
|
|
83
9
|
*/
|
|
84
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
|
+
|
|
85
24
|
/**
|
|
86
25
|
* Main transformation method
|
|
87
|
-
* Analyzes user query and retrieved data to determine
|
|
88
|
-
*
|
|
89
|
-
* @param userQuery - The original user question/query
|
|
90
|
-
* @param retrievedData - Vector database retrieval results
|
|
91
|
-
* @returns Structured JSON response for UI rendering
|
|
26
|
+
* Analyzes user query and retrieved data to determine if a product carousel is needed.
|
|
92
27
|
*/
|
|
93
28
|
static transform(
|
|
94
29
|
userQuery: string,
|
|
95
|
-
retrievedData: VectorMatch[]
|
|
30
|
+
retrievedData: VectorMatch[],
|
|
31
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
32
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
96
33
|
): UITransformationResponse {
|
|
97
34
|
if (!retrievedData || retrievedData.length === 0) {
|
|
98
35
|
return this.createTextResponse('No data available', 'No relevant data found for your query.');
|
|
99
36
|
}
|
|
100
37
|
|
|
101
|
-
|
|
102
|
-
const
|
|
38
|
+
const isStockRequest = this.isStockQuery(userQuery);
|
|
39
|
+
const filteredData = isStockRequest
|
|
40
|
+
? retrievedData.filter(item => this.determineStockStatus(item))
|
|
41
|
+
: retrievedData;
|
|
103
42
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
case 'pie_chart':
|
|
109
|
-
return this.transformToPieChart(retrievedData);
|
|
110
|
-
case 'bar_chart':
|
|
111
|
-
return this.transformToBarChart(retrievedData);
|
|
112
|
-
case 'line_chart':
|
|
113
|
-
return this.transformToLineChart(retrievedData);
|
|
114
|
-
case 'table':
|
|
115
|
-
return this.transformToTable(retrievedData);
|
|
116
|
-
default:
|
|
117
|
-
return this.transformToText(retrievedData);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
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);
|
|
120
47
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
private static analyzeData(
|
|
125
|
-
query: string,
|
|
126
|
-
data: VectorMatch[]
|
|
127
|
-
): {
|
|
128
|
-
suggestedType: VisualizationType;
|
|
129
|
-
confidence: number;
|
|
130
|
-
hasProducts: boolean;
|
|
131
|
-
hasTimeSeries: boolean;
|
|
132
|
-
hasCategories: boolean;
|
|
133
|
-
} {
|
|
134
|
-
const queryLower = query.toLowerCase();
|
|
135
|
-
const hasProducts = data.some(item =>
|
|
136
|
-
this.isProductData(item)
|
|
137
|
-
);
|
|
138
|
-
const hasTimeSeries = data.some(item =>
|
|
139
|
-
this.isTimeSeriesData(item)
|
|
140
|
-
);
|
|
141
|
-
const hasCategories = this.detectCategories(data).length > 0;
|
|
142
|
-
const isDistributionQuery =
|
|
143
|
-
queryLower.includes('distribution') ||
|
|
144
|
-
queryLower.includes('breakdown') ||
|
|
145
|
-
queryLower.includes('percentage') ||
|
|
146
|
-
queryLower.includes('proportion');
|
|
147
|
-
const isComparisonQuery =
|
|
148
|
-
queryLower.includes('compare') ||
|
|
149
|
-
queryLower.includes('vs') ||
|
|
150
|
-
queryLower.includes('difference');
|
|
151
|
-
const isTrendQuery =
|
|
152
|
-
queryLower.includes('trend') ||
|
|
153
|
-
queryLower.includes('over time') ||
|
|
154
|
-
queryLower.includes('growth') ||
|
|
155
|
-
queryLower.includes('decline');
|
|
156
|
-
|
|
157
|
-
// Decision logic
|
|
158
|
-
if (hasProducts && !hasTimeSeries && !isDistributionQuery) {
|
|
159
|
-
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);
|
|
160
51
|
}
|
|
161
52
|
|
|
162
|
-
|
|
163
|
-
|
|
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);
|
|
164
56
|
}
|
|
165
57
|
|
|
166
|
-
|
|
167
|
-
|
|
58
|
+
// 3. Category breakdowns
|
|
59
|
+
if (categories.length > 1 && this.shouldShowCategoryChart(userQuery, categories)) {
|
|
60
|
+
return this.transformToPieChart(filteredData);
|
|
168
61
|
}
|
|
169
62
|
|
|
170
|
-
|
|
171
|
-
|
|
63
|
+
// 4. Secondary: Products as carousel if not already handled
|
|
64
|
+
if (hasProducts) {
|
|
65
|
+
return this.transformToProductCarousel(filteredData, config, trainedSchema);
|
|
172
66
|
}
|
|
173
67
|
|
|
174
|
-
|
|
175
|
-
|
|
68
|
+
// 5. Detailed data without clear product structure -> Table
|
|
69
|
+
if (this.hasMultipleFields(filteredData)) {
|
|
70
|
+
return this.transformToTable(filteredData);
|
|
176
71
|
}
|
|
177
72
|
|
|
178
|
-
return
|
|
73
|
+
return this.transformToText(filteredData);
|
|
179
74
|
}
|
|
180
75
|
|
|
181
76
|
/**
|
|
182
77
|
* Transform data to product carousel format
|
|
183
78
|
*/
|
|
184
|
-
private static transformToProductCarousel(
|
|
79
|
+
private static transformToProductCarousel(
|
|
80
|
+
data: VectorMatch[],
|
|
81
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
82
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
83
|
+
): UITransformationResponse {
|
|
185
84
|
const products: CarouselProduct[] = data
|
|
186
85
|
.filter(item => this.isProductData(item))
|
|
187
|
-
.map(item => this.extractProductInfo(item))
|
|
86
|
+
.map(item => this.extractProductInfo(item, config, trainedSchema))
|
|
188
87
|
.filter((p): p is CarouselProduct => p !== null);
|
|
189
88
|
|
|
190
89
|
return {
|
|
@@ -320,16 +219,171 @@ export class UITransformer {
|
|
|
320
219
|
};
|
|
321
220
|
}
|
|
322
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
|
+
|
|
323
365
|
/**
|
|
324
366
|
* Helper: Check if data item is product-related
|
|
325
367
|
*/
|
|
326
368
|
private static isProductData(item: VectorMatch): boolean {
|
|
327
369
|
const content = (item.content || '').toLowerCase();
|
|
328
|
-
const productKeywords = [
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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;
|
|
333
387
|
}
|
|
334
388
|
|
|
335
389
|
/**
|
|
@@ -337,41 +391,156 @@ export class UITransformer {
|
|
|
337
391
|
*/
|
|
338
392
|
private static isTimeSeriesData(item: VectorMatch): boolean {
|
|
339
393
|
const content = (item.content || '').toLowerCase();
|
|
340
|
-
const timeKeywords = ['
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
);
|
|
345
467
|
}
|
|
346
468
|
|
|
347
469
|
/**
|
|
348
|
-
* Helper: Extract
|
|
470
|
+
* Helper: Extract property from metadata using mapping, AI training, case-insensitivity, and synonyms.
|
|
349
471
|
*/
|
|
350
|
-
private static
|
|
351
|
-
|
|
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
|
+
}
|
|
352
485
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return
|
|
356
|
-
id: item.id,
|
|
357
|
-
name: (meta.name || meta.product) as string,
|
|
358
|
-
price: meta.price as string | number | undefined,
|
|
359
|
-
image: (meta.image || meta.imageUrl) as string | undefined,
|
|
360
|
-
brand: meta.brand as string | undefined,
|
|
361
|
-
description: item.content,
|
|
362
|
-
inStock: this.determineStockStatus(item),
|
|
363
|
-
};
|
|
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];
|
|
364
489
|
}
|
|
365
490
|
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
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);
|
|
369
537
|
|
|
370
|
-
if (nameMatch) {
|
|
371
538
|
return {
|
|
372
539
|
id: item.id,
|
|
373
|
-
name:
|
|
374
|
-
price:
|
|
540
|
+
name: finalName,
|
|
541
|
+
price: finalPrice,
|
|
542
|
+
image: typeof imageValue === 'string' ? imageValue : undefined,
|
|
543
|
+
brand: brand ? String(brand) : undefined,
|
|
375
544
|
description: item.content,
|
|
376
545
|
inStock: this.determineStockStatus(item),
|
|
377
546
|
};
|
|
@@ -401,7 +570,15 @@ export class UITransformer {
|
|
|
401
570
|
tags.forEach(t => categories.add(String(t)));
|
|
402
571
|
}
|
|
403
572
|
|
|
404
|
-
// 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
|
|
405
582
|
const categoryMatch = item.content.match(/(?:Category|Type|Class):\s*([^\n]+)/i);
|
|
406
583
|
if (categoryMatch) {
|
|
407
584
|
categories.add(categoryMatch[1].trim());
|
|
@@ -552,4 +729,137 @@ export class UITransformer {
|
|
|
552
729
|
|
|
553
730
|
return fieldCount.size > 2;
|
|
554
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>
|
|
555
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
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|