@retrivora-ai/rag-engine 1.9.0 → 1.9.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/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -0
- package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -0
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1719 -467
- package/dist/handlers/index.mjs +1718 -466
- package/dist/{index-BwpcaziY.d.ts → index-A0GqPsaG.d.ts} +1 -1
- package/dist/{index-D3V9Et2M.d.mts → index-CDftK3qs.d.mts} +1 -1
- package/dist/index.css +26 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +246 -76
- package/dist/index.mjs +248 -76
- package/dist/server.d.mts +32 -5
- package/dist/server.d.ts +32 -5
- package/dist/server.js +1726 -671
- package/dist/server.mjs +1724 -669
- package/package.json +1 -1
- package/src/components/MarkdownComponents.tsx +3 -3
- package/src/components/MessageBubble.tsx +49 -2
- package/src/components/ProductCard.tsx +33 -6
- package/src/components/UIDispatcher.tsx +1 -0
- package/src/components/VisualizationRenderer.tsx +143 -11
- package/src/config/EmbeddingStrategy.ts +5 -4
- package/src/config/RagConfig.ts +10 -0
- package/src/config/serverConfig.ts +16 -1
- package/src/core/LLMRouter.ts +79 -0
- package/src/core/Pipeline.ts +262 -45
- package/src/core/ProviderRegistry.ts +6 -0
- package/src/core/QueryProcessor.ts +98 -0
- package/src/handlers/index.ts +8 -5
- package/src/hooks/useRagChat.ts +53 -17
- package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
- package/src/providers/vectordb/ChromaDBProvider.ts +13 -2
- package/src/providers/vectordb/MilvusProvider.ts +18 -2
- package/src/providers/vectordb/MultiTablePostgresProvider.ts +12 -12
- package/src/providers/vectordb/PostgreSQLProvider.ts +1 -1
- package/src/providers/vectordb/QdrantProvider.ts +1 -1
- package/src/providers/vectordb/RedisProvider.ts +3 -4
- package/src/providers/vectordb/WeaviateProvider.ts +41 -3
- package/src/types/index.ts +26 -0
- package/src/utils/ProductExtractor.ts +5 -3
- package/src/utils/UITransformer.ts +1348 -489
- package/src/utils/synonyms.ts +2 -0
|
@@ -1,77 +1,487 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
VectorMatch,
|
|
3
|
+
UITransformationResponse,
|
|
4
|
+
PieChartData,
|
|
5
|
+
BarChartData,
|
|
6
|
+
LineChartDataPoint,
|
|
7
|
+
ScatterPlotDataPoint,
|
|
8
|
+
MetricCardData,
|
|
9
|
+
TableData,
|
|
10
|
+
CarouselProduct,
|
|
11
|
+
VisualizationType,
|
|
12
|
+
} from '../types';
|
|
2
13
|
import { ILLMProvider } from '../llm/ILLMProvider';
|
|
3
14
|
import { resolveMetadataValue } from './synonyms';
|
|
4
15
|
|
|
16
|
+
// ─── Intent Types ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Structured intent extracted from the user query.
|
|
20
|
+
* Produced either by the LLM (preferred) or by heuristic fallback.
|
|
21
|
+
*/
|
|
22
|
+
export interface QueryIntent {
|
|
23
|
+
/** Primary display mode the user is asking for */
|
|
24
|
+
visualizationHint:
|
|
25
|
+
| 'trend'
|
|
26
|
+
| 'comparison'
|
|
27
|
+
| 'distribution'
|
|
28
|
+
| 'composition'
|
|
29
|
+
| 'correlation'
|
|
30
|
+
| 'ranking'
|
|
31
|
+
| 'kpi'
|
|
32
|
+
| 'tabular'
|
|
33
|
+
| 'geographic'
|
|
34
|
+
| 'category_breakdown'
|
|
35
|
+
| 'product_browse'
|
|
36
|
+
| 'table'
|
|
37
|
+
| 'text';
|
|
38
|
+
|
|
39
|
+
/** Chart family that best matches the analytical intent */
|
|
40
|
+
recommendedChart:
|
|
41
|
+
| 'line_chart'
|
|
42
|
+
| 'bar_chart'
|
|
43
|
+
| 'histogram'
|
|
44
|
+
| 'pie_chart'
|
|
45
|
+
| 'donut_chart'
|
|
46
|
+
| 'scatter_plot'
|
|
47
|
+
| 'horizontal_bar'
|
|
48
|
+
| 'metric_card'
|
|
49
|
+
| 'table'
|
|
50
|
+
| 'geo_map'
|
|
51
|
+
| 'text';
|
|
52
|
+
|
|
53
|
+
/** User wants only in-stock / available items */
|
|
54
|
+
filterInStockOnly: boolean;
|
|
55
|
+
|
|
56
|
+
/** User wants a tabular / list-style answer explicitly */
|
|
57
|
+
wantsExplicitTable: boolean;
|
|
58
|
+
|
|
59
|
+
/** User is asking about time-based changes */
|
|
60
|
+
isTemporal: boolean;
|
|
61
|
+
|
|
62
|
+
/** User is comparing, ranking, or contrasting entities/categories */
|
|
63
|
+
isComparison: boolean;
|
|
64
|
+
|
|
65
|
+
/** The detected language of the query (BCP-47 tag, e.g. "en", "de", "hi") */
|
|
66
|
+
language: string;
|
|
67
|
+
|
|
68
|
+
/** Free-form note the LLM can add for transparency (not used in logic) */
|
|
69
|
+
reasoning?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type PrimitiveValue = string | number | boolean;
|
|
73
|
+
type AggregationOperation = 'sum' | 'average' | 'count' | 'min' | 'max' | 'median';
|
|
74
|
+
|
|
75
|
+
interface DataRecord {
|
|
76
|
+
id: string | number;
|
|
77
|
+
content: string;
|
|
78
|
+
score: number;
|
|
79
|
+
fields: Record<string, PrimitiveValue>;
|
|
80
|
+
source: VectorMatch;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface FieldProfile {
|
|
84
|
+
key: string;
|
|
85
|
+
label: string;
|
|
86
|
+
kind: 'number' | 'date' | 'category' | 'boolean' | 'text';
|
|
87
|
+
values: PrimitiveValue[];
|
|
88
|
+
uniqueCount: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface DataProfile {
|
|
92
|
+
records: DataRecord[];
|
|
93
|
+
fields: FieldProfile[];
|
|
94
|
+
numericFields: FieldProfile[];
|
|
95
|
+
dateFields: FieldProfile[];
|
|
96
|
+
categoricalFields: FieldProfile[];
|
|
97
|
+
booleanFields: FieldProfile[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── UITransformer ────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
5
102
|
/**
|
|
6
|
-
* UITransformer — Converts vector database results into structured UI representations
|
|
103
|
+
* UITransformer — Converts vector database results into structured UI representations.
|
|
7
104
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
105
|
+
* Intent detection is now delegated to the LLM via `detectIntent()`.
|
|
106
|
+
* Static keyword lists are used only as a last-resort fallback when no LLM
|
|
107
|
+
* is available (e.g. during unit tests or offline mode).
|
|
10
108
|
*/
|
|
11
109
|
export class UITransformer {
|
|
12
110
|
|
|
111
|
+
// ─── Public Entry Points ─────────────────────────────────────────────────
|
|
112
|
+
|
|
13
113
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
114
|
+
* Heuristic-only transform (no LLM required).
|
|
115
|
+
* Uses the lightweight heuristic intent detector as a fallback.
|
|
116
|
+
* Prefer `analyzeAndDecide()` in production.
|
|
16
117
|
*/
|
|
17
118
|
static transform(
|
|
18
119
|
userQuery: string,
|
|
19
120
|
retrievedData: VectorMatch[],
|
|
20
121
|
config?: import('../config/RagConfig').RagConfig,
|
|
21
|
-
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
122
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap,
|
|
123
|
+
/** Optionally pass a pre-computed intent to skip re-detection */
|
|
124
|
+
intent?: QueryIntent,
|
|
22
125
|
): UITransformationResponse {
|
|
23
126
|
if (!retrievedData || retrievedData.length === 0) {
|
|
24
127
|
return this.createTextResponse('No data available', 'No relevant data found for your query.');
|
|
25
128
|
}
|
|
26
129
|
|
|
27
|
-
const
|
|
28
|
-
|
|
130
|
+
const resolvedIntent = intent ?? this.detectIntentHeuristic(userQuery);
|
|
131
|
+
|
|
132
|
+
const filteredData = resolvedIntent.filterInStockOnly
|
|
29
133
|
? retrievedData.filter(item => this.determineStockStatus(item))
|
|
30
134
|
: retrievedData;
|
|
31
135
|
|
|
32
|
-
const
|
|
136
|
+
const profile = this.profileData(filteredData);
|
|
33
137
|
const hasProducts = filteredData.some(item => this.isProductData(item));
|
|
34
|
-
const
|
|
35
|
-
const isTrendQuery = this.isTrendQuery(userQuery);
|
|
138
|
+
const wantsPieLikeChart = ['pie_chart', 'donut_chart'].includes(resolvedIntent.recommendedChart);
|
|
36
139
|
|
|
37
|
-
//
|
|
38
|
-
if (
|
|
39
|
-
return this.transformToLineChart(
|
|
140
|
+
// Priority 1 – Explicit trends
|
|
141
|
+
if (resolvedIntent.visualizationHint === 'trend' && profile.dateFields.length > 0 && profile.numericFields.length > 0) {
|
|
142
|
+
return this.transformToLineChart(profile);
|
|
40
143
|
}
|
|
41
144
|
|
|
42
|
-
//
|
|
43
|
-
if (
|
|
44
|
-
|
|
145
|
+
// Priority 2 – Explicit pie/donut or composition/category distribution
|
|
146
|
+
if (wantsPieLikeChart || ['composition', 'category_breakdown'].includes(resolvedIntent.visualizationHint)) {
|
|
147
|
+
const pieChart = this.transformToPieChart(filteredData, profile, userQuery);
|
|
148
|
+
if (pieChart) return pieChart;
|
|
45
149
|
}
|
|
46
150
|
|
|
47
|
-
// 3
|
|
48
|
-
if (
|
|
49
|
-
return this.
|
|
151
|
+
// Priority 3 – Comparison/ranking → bar chart
|
|
152
|
+
if (['comparison', 'ranking'].includes(resolvedIntent.visualizationHint)) {
|
|
153
|
+
return this.transformToBarChart(filteredData, profile, userQuery, resolvedIntent.visualizationHint === 'ranking');
|
|
50
154
|
}
|
|
51
155
|
|
|
52
|
-
// 4
|
|
53
|
-
if (
|
|
54
|
-
return this.
|
|
156
|
+
// Priority 4 – Distribution/correlation/geographic/KPI fallback to supported views
|
|
157
|
+
if (resolvedIntent.visualizationHint === 'distribution') {
|
|
158
|
+
return this.transformToHistogram(profile, userQuery) ?? this.transformToBarChart(filteredData, profile, userQuery);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (resolvedIntent.visualizationHint === 'correlation') {
|
|
162
|
+
return this.transformToScatterPlot(profile, userQuery) ?? this.transformToBarChart(filteredData, profile, userQuery);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (resolvedIntent.visualizationHint === 'geographic') {
|
|
166
|
+
return this.transformToBarChart(filteredData, profile, userQuery);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Priority 5 – Structured list/filter questions should stay tabular even if
|
|
170
|
+
// source metadata happens to contain product-like fields such as category.
|
|
171
|
+
// This must run before KPI detection because fields such as "employee count"
|
|
172
|
+
// contain the word "count" but still describe records to list/filter.
|
|
173
|
+
if (this.isStructuredListQuery(userQuery)) {
|
|
174
|
+
return this.hasMultipleFields(filteredData)
|
|
175
|
+
? this.transformToTable(filteredData, userQuery)
|
|
176
|
+
: this.transformToText(filteredData);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (resolvedIntent.visualizationHint === 'kpi') {
|
|
180
|
+
return this.transformToMetricCard(profile, userQuery) ?? this.transformToText(filteredData);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Priority 6 – Explicit tabular/detail request
|
|
184
|
+
if (
|
|
185
|
+
['tabular', 'table'].includes(resolvedIntent.visualizationHint) ||
|
|
186
|
+
resolvedIntent.wantsExplicitTable
|
|
187
|
+
) {
|
|
188
|
+
return this.hasMultipleFields(filteredData)
|
|
189
|
+
? this.transformToTable(filteredData, userQuery)
|
|
190
|
+
: this.transformToText(filteredData);
|
|
55
191
|
}
|
|
56
192
|
|
|
57
|
-
//
|
|
58
|
-
if (this.
|
|
59
|
-
return this.
|
|
193
|
+
// Priority 7 – Product carousel only for explicit product intent.
|
|
194
|
+
if ((hasProducts || this.isProductQuery(userQuery)) && resolvedIntent.visualizationHint === 'product_browse') {
|
|
195
|
+
return this.transformToProductCarousel(filteredData, config, trainedSchema);
|
|
60
196
|
}
|
|
61
197
|
|
|
198
|
+
const automatic = this.chooseAutomaticVisualization(filteredData, profile, userQuery);
|
|
199
|
+
if (automatic) return automatic;
|
|
200
|
+
|
|
201
|
+
// Fallback – plain text
|
|
62
202
|
return this.transformToText(filteredData);
|
|
63
203
|
}
|
|
64
204
|
|
|
65
205
|
/**
|
|
66
|
-
*
|
|
206
|
+
* LLM-driven entry point (recommended for production).
|
|
207
|
+
*
|
|
208
|
+
* Step 1 — Detect intent via a dedicated, lightweight LLM call.
|
|
209
|
+
* Step 2 — Pass the intent + data to the visualization-selection prompt.
|
|
210
|
+
* Step 3 — Fall back to the heuristic `transform()` if either LLM call fails.
|
|
211
|
+
*/
|
|
212
|
+
static async analyzeAndDecide(
|
|
213
|
+
query: string,
|
|
214
|
+
sources: VectorMatch[],
|
|
215
|
+
llm: ILLMProvider,
|
|
216
|
+
): Promise<UITransformationResponse> {
|
|
217
|
+
// ── Step 1: Detect intent ───────────────────────────────────────────────
|
|
218
|
+
let intent: QueryIntent;
|
|
219
|
+
try {
|
|
220
|
+
intent = await this.detectIntent(query, llm);
|
|
221
|
+
console.debug('[UITransformer] Detected intent:', intent);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.warn('[UITransformer] Intent detection failed; using heuristic.', err);
|
|
224
|
+
intent = this.detectIntentHeuristic(query);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (this.isProductQuery(query) && ['text', 'product_browse'].includes(intent.visualizationHint)) {
|
|
228
|
+
return this.transform(
|
|
229
|
+
query,
|
|
230
|
+
sources,
|
|
231
|
+
undefined,
|
|
232
|
+
undefined,
|
|
233
|
+
{ ...intent, visualizationHint: 'product_browse', recommendedChart: 'text' },
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Step 2: Ask the LLM to pick a visualization ─────────────────────────
|
|
238
|
+
try {
|
|
239
|
+
const context = this.buildContextSummary(sources);
|
|
240
|
+
const systemPrompt = this.buildVisualizationSystemPrompt();
|
|
241
|
+
|
|
242
|
+
const userPrompt = [
|
|
243
|
+
`USER QUESTION: ${query}`,
|
|
244
|
+
'',
|
|
245
|
+
`DETECTED INTENT (JSON): ${JSON.stringify(intent)}`,
|
|
246
|
+
'',
|
|
247
|
+
'RETRIEVED DATA (JSON):',
|
|
248
|
+
context,
|
|
249
|
+
].join('\n');
|
|
250
|
+
|
|
251
|
+
const rawResponse = await llm.chat(
|
|
252
|
+
[{ role: 'user', content: userPrompt }],
|
|
253
|
+
'',
|
|
254
|
+
{ systemPrompt, temperature: 0 },
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const parsed = this.parseTransformationResponse(rawResponse);
|
|
258
|
+
if (parsed) {
|
|
259
|
+
const intentAllowsTable =
|
|
260
|
+
intent.wantsExplicitTable ||
|
|
261
|
+
['tabular', 'table', 'geographic'].includes(intent.visualizationHint);
|
|
262
|
+
const intentWantsPieLikeChart =
|
|
263
|
+
['pie_chart', 'donut_chart'].includes(intent.recommendedChart) ||
|
|
264
|
+
['composition', 'category_breakdown'].includes(intent.visualizationHint);
|
|
265
|
+
// Respect the intent: never sneak in a table the user didn't ask for
|
|
266
|
+
if (parsed.type === 'table' && !intentAllowsTable) {
|
|
267
|
+
console.debug('[UITransformer] LLM chose table but intent says no. Falling back to text.');
|
|
268
|
+
return this.transform(query, sources, undefined, undefined, intent);
|
|
269
|
+
}
|
|
270
|
+
if (intentWantsPieLikeChart && parsed.type !== 'pie_chart' && this.detectCategories(sources).length > 1) {
|
|
271
|
+
console.debug('[UITransformer] LLM ignored pie/composition intent. Using deterministic pie chart.');
|
|
272
|
+
return this.transform(query, sources, undefined, undefined, intent);
|
|
273
|
+
}
|
|
274
|
+
console.debug('[UITransformer] LLM chose visualization type:', parsed.type);
|
|
275
|
+
return parsed;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.warn('[UITransformer] LLM returned unparseable response; falling back to heuristic.');
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.warn('[UITransformer] analyzeAndDecide LLM call failed; falling back to heuristic.', err);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Step 3: Heuristic fallback ──────────────────────────────────────────
|
|
284
|
+
return this.transform(query, sources, undefined, undefined, intent);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Dynamic Intent Detection ─────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Calls the LLM with a compact, focused prompt to extract a structured
|
|
291
|
+
* `QueryIntent` from the user's query.
|
|
292
|
+
*
|
|
293
|
+
* Keeping this as a *separate* call from visualization selection means:
|
|
294
|
+
* - The prompt is shorter and more reliable.
|
|
295
|
+
* - The intent object can be reused across both the heuristic and LLM paths.
|
|
296
|
+
* - It is easy to unit-test intent detection in isolation.
|
|
297
|
+
*/
|
|
298
|
+
static async detectIntent(query: string, llm: ILLMProvider): Promise<QueryIntent> {
|
|
299
|
+
const systemPrompt = `You are an intent classifier for a product-search RAG system.
|
|
300
|
+
Given a user query, return ONLY a valid JSON object with this exact shape — no prose, no markdown:
|
|
301
|
+
|
|
302
|
+
{
|
|
303
|
+
"visualizationHint": "trend" | "comparison" | "distribution" | "composition" | "correlation" | "ranking" | "kpi" | "tabular" | "geographic" | "product_browse" | "table" | "text",
|
|
304
|
+
"recommendedChart": "line_chart" | "bar_chart" | "histogram" | "pie_chart" | "donut_chart" | "scatter_plot" | "horizontal_bar" | "metric_card" | "table" | "geo_map" | "text",
|
|
305
|
+
"filterInStockOnly": boolean,
|
|
306
|
+
"wantsExplicitTable": boolean,
|
|
307
|
+
"isTemporal": boolean,
|
|
308
|
+
"isComparison": boolean,
|
|
309
|
+
"language": "<BCP-47 language tag, e.g. en, de, hi, ja>",
|
|
310
|
+
"reasoning": "<one short sentence — why you chose these values>"
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
RULES:
|
|
314
|
+
- visualizationHint and recommendedChart
|
|
315
|
+
"trend" → keywords: trend, growth, over time; recommendedChart "line_chart"
|
|
316
|
+
"comparison" → keywords: compare, versus, top; recommendedChart "bar_chart"
|
|
317
|
+
"distribution" → keywords: distribution, spread; recommendedChart "histogram"
|
|
318
|
+
"composition" → keywords: share, percentage, breakup, breakdown; recommendedChart "pie_chart" or "donut_chart"
|
|
319
|
+
"correlation" → keywords: relation, correlation; recommendedChart "scatter_plot"
|
|
320
|
+
"ranking" → keywords: highest, lowest, top 10, ranking; recommendedChart "horizontal_bar"
|
|
321
|
+
"kpi" → keywords: total, average, count; recommendedChart "metric_card"
|
|
322
|
+
"tabular" → keywords: detailed records, table, grid, spreadsheet; recommendedChart "table"
|
|
323
|
+
"geographic" → keywords: region, country, map; recommendedChart "geo_map"
|
|
324
|
+
"product_browse" → user is browsing, searching, describing, viewing details for, or asking about one or more products without analytical visualization intent
|
|
325
|
+
"table" → legacy alias for "tabular"; use only if the query literally says table
|
|
326
|
+
"text" → conversational, factual, or other intent
|
|
327
|
+
- If the user explicitly asks for a chart type, honor that chart type in recommendedChart.
|
|
328
|
+
- If a query says "distribution ... in a pie chart", use visualizationHint "composition" and recommendedChart "pie_chart".
|
|
329
|
+
- filterInStockOnly: true only if user mentions stock, availability, in stock, etc.
|
|
330
|
+
- wantsExplicitTable: true if the user asks for a table, list, grid, spreadsheet, or detailed records.
|
|
331
|
+
- isTemporal: true if time words appear (trend, historical, over time, last year, monthly, etc.)
|
|
332
|
+
- isComparison: true if user compares, ranks, or contrasts entities or categories.
|
|
333
|
+
- language: detect from the query text itself; default "en" if uncertain.`;
|
|
334
|
+
|
|
335
|
+
const rawResponse = await llm.chat(
|
|
336
|
+
[{ role: 'user', content: `QUERY: ${query}` }],
|
|
337
|
+
'',
|
|
338
|
+
{ systemPrompt, temperature: 0 },
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const parsed = this.parseIntentResponse(rawResponse);
|
|
342
|
+
if (!parsed) {
|
|
343
|
+
throw new Error(`Could not parse intent JSON from LLM response: ${rawResponse}`);
|
|
344
|
+
}
|
|
345
|
+
return parsed;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Parse and validate the raw LLM response into a `QueryIntent`.
|
|
350
|
+
*/
|
|
351
|
+
private static parseIntentResponse(raw: string): QueryIntent | null {
|
|
352
|
+
const jsonStr = this.extractJsonCandidate(raw);
|
|
353
|
+
if (!jsonStr) return null;
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const obj = JSON.parse(jsonStr) as Partial<QueryIntent>;
|
|
357
|
+
|
|
358
|
+
const validHints = [
|
|
359
|
+
'trend', 'comparison', 'distribution', 'composition', 'correlation',
|
|
360
|
+
'ranking', 'kpi', 'tabular', 'geographic', 'category_breakdown',
|
|
361
|
+
'product_browse', 'table', 'text',
|
|
362
|
+
] as const;
|
|
363
|
+
const validCharts = [
|
|
364
|
+
'line_chart', 'bar_chart', 'histogram', 'pie_chart', 'donut_chart',
|
|
365
|
+
'scatter_plot', 'horizontal_bar', 'metric_card', 'table', 'geo_map', 'text',
|
|
366
|
+
] as const;
|
|
367
|
+
|
|
368
|
+
const hint = obj.visualizationHint;
|
|
369
|
+
if (!hint || !validHints.includes(hint as typeof validHints[number])) return null;
|
|
370
|
+
const normalizedHint = hint === 'category_breakdown' ? 'composition' : hint;
|
|
371
|
+
const recommendedChart =
|
|
372
|
+
typeof obj.recommendedChart === 'string' &&
|
|
373
|
+
validCharts.includes(obj.recommendedChart as typeof validCharts[number])
|
|
374
|
+
? obj.recommendedChart as QueryIntent['recommendedChart']
|
|
375
|
+
: this.getRecommendedChartForIntent(normalizedHint as QueryIntent['visualizationHint']);
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
visualizationHint: normalizedHint as QueryIntent['visualizationHint'],
|
|
379
|
+
recommendedChart,
|
|
380
|
+
filterInStockOnly: Boolean(obj.filterInStockOnly),
|
|
381
|
+
wantsExplicitTable: Boolean(obj.wantsExplicitTable),
|
|
382
|
+
isTemporal: Boolean(obj.isTemporal),
|
|
383
|
+
isComparison: Boolean(obj.isComparison),
|
|
384
|
+
language: typeof obj.language === 'string' && obj.language ? obj.language : 'en',
|
|
385
|
+
reasoning: typeof obj.reasoning === 'string' ? obj.reasoning : undefined,
|
|
386
|
+
};
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Heuristic intent detector — used when the LLM is unavailable.
|
|
394
|
+
* Intentionally minimal: it should only catch the most obvious signals.
|
|
395
|
+
* The LLM path handles everything subtle.
|
|
67
396
|
*/
|
|
397
|
+
static detectIntentHeuristic(query: string): QueryIntent {
|
|
398
|
+
const q = query.toLowerCase();
|
|
399
|
+
|
|
400
|
+
const isTemporal = /\b(trend|trends|over time|historical|history|growth|decline|monthly|yearly|weekly|daily|last (year|month|week)|timeline|forecast)\b/.test(q);
|
|
401
|
+
|
|
402
|
+
const isRanking = /\b(highest|lowest|top\s*\d+|bottom\s*\d+|rank(?:ing)?|best|worst|leading)\b/.test(q);
|
|
403
|
+
|
|
404
|
+
const isComparison = /\b(compare|comparison|vs\.?|versus|against|differ|difference|contrast|top)\b/.test(q) || isRanking;
|
|
405
|
+
|
|
406
|
+
const isDistribution = /\b(distribution|spread|histogram|frequency|variance|range)\b/.test(q);
|
|
407
|
+
|
|
408
|
+
const wantsPieLikeChart = /\b(pie|donut|doughnut)(?:\s+chart)?\b/.test(q);
|
|
409
|
+
|
|
410
|
+
const isComposition = wantsPieLikeChart || /\b(share|percentage|percent|breakup|breakdown|composition|split|segmentation|by category|by type|proportion)\b/.test(q);
|
|
411
|
+
|
|
412
|
+
const isCorrelation = /\b(relation|relationship|correlation|correlate|scatter|association|impact of|depend(?:s|ence)? on)\b/.test(q);
|
|
413
|
+
|
|
414
|
+
const isKpi = /\b(total|average|avg|count|sum|median|minimum|maximum|metric|kpi|how many|number of)\b/.test(q);
|
|
415
|
+
|
|
416
|
+
const isGeographic = /\b(region|country|countries|state|city|location|map|geo|geographic|territory)\b/.test(q);
|
|
417
|
+
|
|
418
|
+
const filterInStockOnly = /\b(in[- ]?stock|available|availability|inventory|stock status)\b/.test(q);
|
|
419
|
+
|
|
420
|
+
const wantsExplicitTable = /\b(table|spreadsheet|grid|detailed records|record details|list all|compare all)\b/.test(q);
|
|
421
|
+
|
|
422
|
+
let visualizationHint: QueryIntent['visualizationHint'] = 'text';
|
|
423
|
+
if (wantsExplicitTable) visualizationHint = 'tabular';
|
|
424
|
+
else if (isTemporal) visualizationHint = 'trend';
|
|
425
|
+
else if (wantsPieLikeChart) visualizationHint = 'composition';
|
|
426
|
+
else if (isRanking) visualizationHint = 'ranking';
|
|
427
|
+
else if (isComparison) visualizationHint = 'comparison';
|
|
428
|
+
else if (isDistribution) visualizationHint = 'distribution';
|
|
429
|
+
else if (isComposition) visualizationHint = 'composition';
|
|
430
|
+
else if (isCorrelation) visualizationHint = 'correlation';
|
|
431
|
+
else if (isGeographic) visualizationHint = 'geographic';
|
|
432
|
+
else if (isKpi) visualizationHint = 'kpi';
|
|
433
|
+
else if (this.isProductQuery(query)) visualizationHint = 'product_browse';
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
visualizationHint,
|
|
437
|
+
recommendedChart: this.getRecommendedChartForIntent(visualizationHint),
|
|
438
|
+
filterInStockOnly,
|
|
439
|
+
wantsExplicitTable,
|
|
440
|
+
isTemporal,
|
|
441
|
+
isComparison,
|
|
442
|
+
language: 'en', // heuristic cannot reliably detect language
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private static getRecommendedChartForIntent(
|
|
447
|
+
intent: QueryIntent['visualizationHint'],
|
|
448
|
+
): QueryIntent['recommendedChart'] {
|
|
449
|
+
switch (intent) {
|
|
450
|
+
case 'trend':
|
|
451
|
+
return 'line_chart';
|
|
452
|
+
case 'comparison':
|
|
453
|
+
return 'bar_chart';
|
|
454
|
+
case 'distribution':
|
|
455
|
+
return 'histogram';
|
|
456
|
+
case 'composition':
|
|
457
|
+
case 'category_breakdown':
|
|
458
|
+
return 'pie_chart';
|
|
459
|
+
case 'correlation':
|
|
460
|
+
return 'scatter_plot';
|
|
461
|
+
case 'ranking':
|
|
462
|
+
return 'horizontal_bar';
|
|
463
|
+
case 'kpi':
|
|
464
|
+
return 'metric_card';
|
|
465
|
+
case 'tabular':
|
|
466
|
+
case 'table':
|
|
467
|
+
return 'table';
|
|
468
|
+
case 'geographic':
|
|
469
|
+
return 'geo_map';
|
|
470
|
+
case 'product_browse':
|
|
471
|
+
case 'text':
|
|
472
|
+
default:
|
|
473
|
+
return 'text';
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── Transform Helpers ────────────────────────────────────────────────────
|
|
478
|
+
|
|
68
479
|
private static transformToProductCarousel(
|
|
69
480
|
data: VectorMatch[],
|
|
70
481
|
config?: import('../config/RagConfig').RagConfig,
|
|
71
|
-
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
482
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap,
|
|
72
483
|
): UITransformationResponse {
|
|
73
484
|
const products: CarouselProduct[] = data
|
|
74
|
-
.filter(item => this.isProductData(item))
|
|
75
485
|
.map(item => this.extractProductInfo(item, config, trainedSchema))
|
|
76
486
|
.filter((p): p is CarouselProduct => p !== null);
|
|
77
487
|
|
|
@@ -83,64 +493,214 @@ export class UITransformer {
|
|
|
83
493
|
};
|
|
84
494
|
}
|
|
85
495
|
|
|
86
|
-
/**
|
|
87
|
-
* Transform data to pie chart format
|
|
88
|
-
*/
|
|
89
496
|
private static transformToPieChart(
|
|
90
|
-
data: VectorMatch[]
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
497
|
+
data: VectorMatch[],
|
|
498
|
+
profile?: DataProfile,
|
|
499
|
+
query = '',
|
|
500
|
+
): UITransformationResponse | null {
|
|
501
|
+
const dimension = profile ? this.selectDimensionField(profile, query) : undefined;
|
|
502
|
+
const categories = dimension
|
|
503
|
+
? Array.from(new Set(profile!.records.map(record => String(record.fields[dimension.key] ?? '')).filter(Boolean)))
|
|
504
|
+
: this.detectCategories(data);
|
|
505
|
+
if (categories.length === 0) return null;
|
|
506
|
+
|
|
507
|
+
const categoryData = dimension && profile
|
|
508
|
+
? this.aggregateProfileByDimension(profile, dimension.key)
|
|
509
|
+
: this.aggregateByCategory(data, categories);
|
|
94
510
|
|
|
95
511
|
const pieData: PieChartData[] = Object.entries(categoryData).map(([label, count]) => {
|
|
96
512
|
const { inStockCount, outOfStockCount } = this.calculateStockCounts(label, data);
|
|
97
|
-
return {
|
|
98
|
-
label,
|
|
99
|
-
value: count as number,
|
|
100
|
-
inStockCount,
|
|
101
|
-
outOfStockCount,
|
|
102
|
-
};
|
|
513
|
+
return { label, value: count as number, inStockCount, outOfStockCount };
|
|
103
514
|
});
|
|
104
515
|
|
|
105
516
|
return {
|
|
106
517
|
type: 'pie_chart',
|
|
107
|
-
title:
|
|
108
|
-
description: `Showing breakdown across ${
|
|
518
|
+
title: `Distribution by ${dimension?.label ?? 'Category'}`,
|
|
519
|
+
description: `Showing breakdown across ${pieData.length} categories`,
|
|
109
520
|
data: pieData,
|
|
110
521
|
};
|
|
111
522
|
}
|
|
112
523
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const timePoints = this.extractTimeSeriesData(data);
|
|
524
|
+
private static transformToLineChart(profile: DataProfile): UITransformationResponse {
|
|
525
|
+
const dateField = profile.dateFields[0];
|
|
526
|
+
const valueField = profile.numericFields[0];
|
|
527
|
+
const buckets = new Map<string, number>();
|
|
118
528
|
|
|
119
|
-
|
|
120
|
-
timestamp
|
|
121
|
-
value
|
|
122
|
-
|
|
123
|
-
})
|
|
529
|
+
profile.records.forEach(record => {
|
|
530
|
+
const timestamp = String(record.fields[dateField.key] ?? '');
|
|
531
|
+
const value = this.toFiniteNumber(record.fields[valueField.key]) ?? 0;
|
|
532
|
+
buckets.set(timestamp, (buckets.get(timestamp) ?? 0) + value);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const lineData: LineChartDataPoint[] = Array.from(buckets.entries())
|
|
536
|
+
.sort(([a], [b]) => Date.parse(a) - Date.parse(b))
|
|
537
|
+
.slice(0, 24)
|
|
538
|
+
.map(([timestamp, value]) => ({ timestamp, value, label: timestamp }));
|
|
124
539
|
|
|
125
540
|
return {
|
|
126
541
|
type: 'line_chart',
|
|
127
|
-
title:
|
|
542
|
+
title: `${valueField.label} Over Time`,
|
|
128
543
|
description: `Showing ${lineData.length} data points`,
|
|
129
544
|
data: lineData,
|
|
130
545
|
};
|
|
131
546
|
}
|
|
132
547
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
548
|
+
private static transformToBarChart(
|
|
549
|
+
data: VectorMatch[],
|
|
550
|
+
profile?: DataProfile,
|
|
551
|
+
query = '',
|
|
552
|
+
horizontal = false,
|
|
553
|
+
): UITransformationResponse {
|
|
554
|
+
const dimension = profile ? this.selectDimensionField(profile, query) : undefined;
|
|
555
|
+
const measure = profile ? this.selectNumericField(profile, query) : undefined;
|
|
556
|
+
const aggregate = dimension && profile
|
|
557
|
+
? this.aggregateProfileByDimension(profile, dimension.key, measure?.key)
|
|
558
|
+
: this.aggregateByCategory(data, this.detectCategories(data));
|
|
559
|
+
|
|
560
|
+
const barData: BarChartData[] = Object.entries(aggregate)
|
|
561
|
+
.map(([category, value]) => ({ category, value: Number(value) }))
|
|
562
|
+
.sort((a, b) => horizontal ? b.value - a.value : 0)
|
|
563
|
+
.slice(0, 12);
|
|
564
|
+
|
|
565
|
+
const fallbackData: BarChartData[] = barData.length > 0
|
|
566
|
+
? barData
|
|
567
|
+
: data.slice(0, 12).map((item, index) => {
|
|
568
|
+
const meta = item.metadata || {};
|
|
569
|
+
const label = String(
|
|
570
|
+
this.getDynamicVal(meta, 'name') ??
|
|
571
|
+
meta.label ??
|
|
572
|
+
meta.title ??
|
|
573
|
+
`Result ${index + 1}`,
|
|
574
|
+
);
|
|
575
|
+
const value = this.extractNumericValue(meta) ?? item.score ?? 0;
|
|
576
|
+
return { category: label, value: Number(value) };
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
type: horizontal ? 'horizontal_bar' : 'bar_chart',
|
|
581
|
+
title: dimension ? `${measure?.label ?? 'Count'} by ${dimension.label}` : 'Comparison',
|
|
582
|
+
description: `Showing ${fallbackData.length} comparable values`,
|
|
583
|
+
data: fallbackData,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private static transformToHistogram(profile: DataProfile, query = ''): UITransformationResponse | null {
|
|
588
|
+
const field = this.selectNumericField(profile, query);
|
|
589
|
+
if (!field) return null;
|
|
590
|
+
|
|
591
|
+
const values = profile.records
|
|
592
|
+
.map(record => this.toFiniteNumber(record.fields[field.key]))
|
|
593
|
+
.filter((value): value is number => value !== null)
|
|
594
|
+
.sort((a, b) => a - b);
|
|
595
|
+
if (values.length === 0) return null;
|
|
596
|
+
|
|
597
|
+
const min = values[0];
|
|
598
|
+
const max = values[values.length - 1];
|
|
599
|
+
const bucketCount = Math.min(10, Math.max(3, Math.ceil(Math.sqrt(values.length))));
|
|
600
|
+
const width = max === min ? 1 : (max - min) / bucketCount;
|
|
601
|
+
const buckets = Array.from({ length: bucketCount }, (_, index) => {
|
|
602
|
+
const start = min + index * width;
|
|
603
|
+
const end = index === bucketCount - 1 ? max : start + width;
|
|
604
|
+
return { category: `${this.formatNumber(start)}-${this.formatNumber(end)}`, value: 0 };
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
values.forEach(value => {
|
|
608
|
+
const bucketIndex = max === min ? 0 : Math.min(bucketCount - 1, Math.floor((value - min) / width));
|
|
609
|
+
buckets[bucketIndex].value += 1;
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
type: 'histogram',
|
|
614
|
+
title: `${field.label} Distribution`,
|
|
615
|
+
description: `Showing ${values.length} values across ${bucketCount} buckets`,
|
|
616
|
+
data: buckets,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private static transformToScatterPlot(profile: DataProfile, query = ''): UITransformationResponse | null {
|
|
621
|
+
const fields = this.rankFieldsByQuery(profile.numericFields, query).slice(0, 2);
|
|
622
|
+
if (fields.length < 2) return null;
|
|
623
|
+
const [xField, yField] = fields;
|
|
624
|
+
|
|
625
|
+
const points = profile.records
|
|
626
|
+
.map((record): ScatterPlotDataPoint | null => {
|
|
627
|
+
const x = this.toFiniteNumber(record.fields[xField.key]);
|
|
628
|
+
const y = this.toFiniteNumber(record.fields[yField.key]);
|
|
629
|
+
if (x === null || y === null) return null;
|
|
630
|
+
return { x, y, label: String(this.getRecordLabel(record) ?? record.id) };
|
|
631
|
+
})
|
|
632
|
+
.filter((point): point is ScatterPlotDataPoint => point !== null)
|
|
633
|
+
.slice(0, 100);
|
|
634
|
+
|
|
635
|
+
if (points.length === 0) return null;
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
type: 'scatter_plot',
|
|
639
|
+
title: `${xField.label} vs ${yField.label}`,
|
|
640
|
+
description: `Showing ${points.length} paired values`,
|
|
641
|
+
data: points,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private static transformToMetricCard(profile: DataProfile, query = ''): UITransformationResponse | null {
|
|
646
|
+
const operation = this.detectAggregationOperation(query);
|
|
647
|
+
const numericField = this.selectNumericField(profile, query);
|
|
648
|
+
const values = numericField
|
|
649
|
+
? profile.records
|
|
650
|
+
.map(record => this.toFiniteNumber(record.fields[numericField.key]))
|
|
651
|
+
.filter((value): value is number => value !== null)
|
|
652
|
+
: [];
|
|
653
|
+
|
|
654
|
+
const value = operation === 'count' || values.length === 0
|
|
655
|
+
? profile.records.length
|
|
656
|
+
: this.calculateAggregate(values, operation);
|
|
657
|
+
|
|
658
|
+
const metric: MetricCardData = {
|
|
659
|
+
label: numericField ? `${operation} ${numericField.label}` : 'Count',
|
|
660
|
+
value,
|
|
661
|
+
operation,
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
type: 'metric_card',
|
|
666
|
+
title: metric.label,
|
|
667
|
+
description: `Calculated from ${profile.records.length} result${profile.records.length === 1 ? '' : 's'}`,
|
|
668
|
+
data: metric,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private static transformToRadarChart(data: VectorMatch[]): UITransformationResponse {
|
|
673
|
+
// Aggregate attribute scores across items for radar comparison
|
|
674
|
+
const attributeMap: Record<string, Record<string, number>> = {};
|
|
675
|
+
|
|
676
|
+
data.forEach(item => {
|
|
677
|
+
const meta = item.metadata || {};
|
|
678
|
+
const seriesName = String(meta.name ?? meta.product ?? item.id ?? 'Item');
|
|
679
|
+
Object.entries(meta).forEach(([key, val]) => {
|
|
680
|
+
if (typeof val === 'number' && !['price', 'id'].includes(key.toLowerCase())) {
|
|
681
|
+
if (!attributeMap[key]) attributeMap[key] = {};
|
|
682
|
+
attributeMap[key][seriesName] = val;
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
const radarData = Object.entries(attributeMap).map(([attribute, series]) => ({
|
|
688
|
+
attribute,
|
|
689
|
+
...series,
|
|
690
|
+
}));
|
|
139
691
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
692
|
+
return {
|
|
693
|
+
type: 'radar_chart' as VisualizationType,
|
|
694
|
+
title: 'Product Comparison',
|
|
695
|
+
description: `Comparing ${data.length} items across ${radarData.length} attributes`,
|
|
696
|
+
data: radarData.length > 0 ? radarData : data.map(d => ({ attribute: d.content.substring(0, 40) })),
|
|
143
697
|
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private static transformToTable(data: VectorMatch[], query = ''): UITransformationResponse {
|
|
701
|
+
const columns = this.extractTableColumns(data, query);
|
|
702
|
+
const rows = data.map(item => this.extractTableRow(item, columns));
|
|
703
|
+
const tableData: TableData = { columns, rows };
|
|
144
704
|
|
|
145
705
|
return {
|
|
146
706
|
type: 'table',
|
|
@@ -150,37 +710,24 @@ export class UITransformer {
|
|
|
150
710
|
};
|
|
151
711
|
}
|
|
152
712
|
|
|
153
|
-
/**
|
|
154
|
-
* Transform data to text format (fallback)
|
|
155
|
-
*/
|
|
156
713
|
private static transformToText(data: VectorMatch[]): UITransformationResponse {
|
|
157
|
-
const textContent = data
|
|
158
|
-
.map(item => item.content)
|
|
159
|
-
.join('\n\n');
|
|
160
|
-
|
|
161
714
|
return this.createTextResponse(
|
|
162
715
|
'Search Results',
|
|
163
|
-
|
|
164
|
-
`Found ${data.length} relevant results
|
|
716
|
+
data.map(item => item.content).join('\n\n'),
|
|
717
|
+
`Found ${data.length} relevant results`,
|
|
165
718
|
);
|
|
166
719
|
}
|
|
167
720
|
|
|
168
|
-
/**
|
|
169
|
-
* Helper: Create text response
|
|
170
|
-
*/
|
|
171
721
|
private static createTextResponse(
|
|
172
722
|
title: string,
|
|
173
723
|
content: string,
|
|
174
|
-
description?: string
|
|
724
|
+
description?: string,
|
|
175
725
|
): UITransformationResponse {
|
|
176
|
-
return {
|
|
177
|
-
type: 'text',
|
|
178
|
-
title,
|
|
179
|
-
description,
|
|
180
|
-
data: { content },
|
|
181
|
-
};
|
|
726
|
+
return { type: 'text', title, description, data: { content } };
|
|
182
727
|
}
|
|
183
728
|
|
|
729
|
+
// ─── LLM Response Parsing ─────────────────────────────────────────────────
|
|
730
|
+
|
|
184
731
|
static parseTransformationResponse(raw: string): UITransformationResponse | null {
|
|
185
732
|
const payloadText = this.extractJsonCandidate(raw);
|
|
186
733
|
if (!payloadText) return null;
|
|
@@ -210,26 +757,14 @@ export class UITransformer {
|
|
|
210
757
|
|
|
211
758
|
for (let i = start; i < cleaned.length; i += 1) {
|
|
212
759
|
const char = cleaned[i];
|
|
213
|
-
if (escape) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
if (char === '\\') {
|
|
218
|
-
escape = true;
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
if (char === '"') {
|
|
222
|
-
inString = !inString;
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
760
|
+
if (escape) { escape = false; continue; }
|
|
761
|
+
if (char === '\\') { escape = true; continue; }
|
|
762
|
+
if (char === '"') { inString = !inString; continue; }
|
|
225
763
|
if (inString) continue;
|
|
226
|
-
|
|
227
764
|
if (char === '{') depth += 1;
|
|
228
765
|
if (char === '}') {
|
|
229
766
|
depth -= 1;
|
|
230
|
-
if (depth === 0)
|
|
231
|
-
return cleaned.slice(start, i + 1);
|
|
232
|
-
}
|
|
767
|
+
if (depth === 0) return cleaned.slice(start, i + 1);
|
|
233
768
|
}
|
|
234
769
|
}
|
|
235
770
|
|
|
@@ -239,25 +774,20 @@ export class UITransformer {
|
|
|
239
774
|
private static normalizeTransformation(payload: unknown): UITransformationResponse | null {
|
|
240
775
|
if (!payload || typeof payload !== 'object') return null;
|
|
241
776
|
|
|
242
|
-
const
|
|
243
|
-
const type = this.normalizeVisualizationType(
|
|
777
|
+
const p = payload as Record<string, unknown>;
|
|
778
|
+
const type = this.normalizeVisualizationType(
|
|
779
|
+
String(p.type ?? p.view ?? p.chartType ?? ''),
|
|
780
|
+
);
|
|
244
781
|
if (!type) return null;
|
|
245
782
|
|
|
246
|
-
const title = String(
|
|
247
|
-
const description =
|
|
248
|
-
const rawData =
|
|
249
|
-
|
|
783
|
+
const title = String(p.title ?? p.heading ?? 'Visualization');
|
|
784
|
+
const description = p.description ? String(p.description) : undefined;
|
|
785
|
+
const rawData = p.data ?? p.table ?? p.rows ?? p.items ?? p.content ?? null;
|
|
250
786
|
const data = type === 'text' && typeof rawData === 'string'
|
|
251
787
|
? { content: rawData }
|
|
252
788
|
: rawData;
|
|
253
789
|
|
|
254
|
-
const transformation: UITransformationResponse = {
|
|
255
|
-
type,
|
|
256
|
-
title,
|
|
257
|
-
description,
|
|
258
|
-
data,
|
|
259
|
-
};
|
|
260
|
-
|
|
790
|
+
const transformation: UITransformationResponse = { type, title, description, data };
|
|
261
791
|
return this.validateTransformation(transformation) ? transformation : null;
|
|
262
792
|
}
|
|
263
793
|
|
|
@@ -267,529 +797,860 @@ export class UITransformer {
|
|
|
267
797
|
pie_chart: 'pie_chart',
|
|
268
798
|
bar: 'bar_chart',
|
|
269
799
|
bar_chart: 'bar_chart',
|
|
800
|
+
histogram: 'histogram',
|
|
801
|
+
horizontal_bar: 'horizontal_bar',
|
|
802
|
+
horizontalbar: 'horizontal_bar',
|
|
270
803
|
line: 'line_chart',
|
|
271
804
|
line_chart: 'line_chart',
|
|
805
|
+
scatter: 'scatter_plot',
|
|
806
|
+
scatter_plot: 'scatter_plot',
|
|
807
|
+
scatterplot: 'scatter_plot',
|
|
272
808
|
radar: 'radar_chart',
|
|
273
809
|
radar_chart: 'radar_chart',
|
|
810
|
+
metric: 'metric_card',
|
|
811
|
+
metric_card: 'metric_card',
|
|
812
|
+
card: 'metric_card',
|
|
813
|
+
kpi: 'metric_card',
|
|
814
|
+
geo: 'geo_map',
|
|
815
|
+
geo_map: 'geo_map',
|
|
816
|
+
map: 'geo_map',
|
|
274
817
|
table: 'table',
|
|
275
818
|
text: 'text',
|
|
276
819
|
product_carousel: 'product_carousel',
|
|
277
820
|
carousel: 'carousel',
|
|
278
821
|
};
|
|
279
|
-
|
|
280
822
|
return mapping[type.toLowerCase()] ?? null;
|
|
281
823
|
}
|
|
282
824
|
|
|
283
|
-
private static validateTransformation(
|
|
284
|
-
const { type, data } =
|
|
285
|
-
|
|
825
|
+
private static validateTransformation(t: UITransformationResponse): boolean {
|
|
826
|
+
const { type, data } = t;
|
|
286
827
|
switch (type) {
|
|
287
828
|
case 'pie_chart':
|
|
288
829
|
case 'bar_chart':
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
830
|
+
case 'histogram':
|
|
831
|
+
case 'horizontal_bar':
|
|
832
|
+
return Array.isArray(data) && data.every(
|
|
833
|
+
(i): i is Record<string, unknown> =>
|
|
834
|
+
i !== null && typeof i === 'object' &&
|
|
835
|
+
(typeof i.value === 'number' || !Number.isNaN(Number(i.value))),
|
|
836
|
+
);
|
|
837
|
+
case 'scatter_plot':
|
|
838
|
+
return Array.isArray(data) && data.every(
|
|
839
|
+
(i): i is Record<string, unknown> =>
|
|
840
|
+
i !== null && typeof i === 'object' &&
|
|
841
|
+
(typeof i.x === 'number' || !Number.isNaN(Number(i.x))) &&
|
|
842
|
+
(typeof i.y === 'number' || !Number.isNaN(Number(i.y))),
|
|
292
843
|
);
|
|
293
844
|
case 'line_chart':
|
|
294
|
-
return Array.isArray(data) && data.every(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
845
|
+
return Array.isArray(data) && data.every(
|
|
846
|
+
(i): i is Record<string, unknown> =>
|
|
847
|
+
i !== null && typeof i === 'object' && 'timestamp' in i &&
|
|
848
|
+
(typeof i.value === 'number' || !Number.isNaN(Number(i.value))),
|
|
298
849
|
);
|
|
850
|
+
case 'metric_card':
|
|
851
|
+
return (
|
|
852
|
+
typeof data === 'object' && data !== null &&
|
|
853
|
+
(typeof (data as Record<string, unknown>).value === 'number' ||
|
|
854
|
+
!Number.isNaN(Number((data as Record<string, unknown>).value)))
|
|
855
|
+
);
|
|
856
|
+
case 'geo_map':
|
|
857
|
+
return Array.isArray(data) || (typeof data === 'object' && data !== null);
|
|
299
858
|
case 'radar_chart':
|
|
300
|
-
return Array.isArray(data) && data.every(
|
|
301
|
-
|
|
302
|
-
|
|
859
|
+
return Array.isArray(data) && data.every(
|
|
860
|
+
(i): i is Record<string, unknown> =>
|
|
861
|
+
i !== null && typeof i === 'object' && 'attribute' in i,
|
|
303
862
|
);
|
|
304
863
|
case 'table':
|
|
305
864
|
return (
|
|
306
|
-
typeof data === 'object' &&
|
|
307
|
-
data !== null &&
|
|
865
|
+
typeof data === 'object' && data !== null &&
|
|
308
866
|
Array.isArray((data as Record<string, unknown>).columns) &&
|
|
309
867
|
Array.isArray((data as Record<string, unknown>).rows)
|
|
310
868
|
);
|
|
311
869
|
case 'text':
|
|
312
870
|
return (
|
|
313
|
-
typeof data === 'object' &&
|
|
314
|
-
data !== null &&
|
|
871
|
+
typeof data === 'object' && data !== null &&
|
|
315
872
|
typeof (data as Record<string, unknown>).content === 'string'
|
|
316
873
|
);
|
|
317
874
|
case 'product_carousel':
|
|
318
875
|
case 'carousel':
|
|
319
|
-
return Array.isArray(data) && data.every(
|
|
320
|
-
|
|
876
|
+
return Array.isArray(data) && data.every(
|
|
877
|
+
(i): i is Record<string, unknown> =>
|
|
878
|
+
i !== null && typeof i === 'object' && ('id' in i || 'name' in i),
|
|
321
879
|
);
|
|
322
880
|
default:
|
|
323
881
|
return false;
|
|
324
882
|
}
|
|
325
883
|
}
|
|
326
884
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
885
|
+
// ─── Data Inspection Helpers ──────────────────────────────────────────────
|
|
886
|
+
|
|
887
|
+
private static isStructuredListQuery(query: string): boolean {
|
|
888
|
+
const q = query.toLowerCase();
|
|
889
|
+
const asksForRecords = /\b(list|provide|show|give|get|find|which|whose|records?|details?)\b/.test(q);
|
|
890
|
+
const hasNumericPredicate = /\b(greater than|more than|above|over|less than|below|under|at least|at most|minimum|maximum|>=|<=|>|<|equals?|equal to)\b\s*\$?\d/i.test(q) ||
|
|
891
|
+
/\b\d+\b\s*(?:or more|or less|and above|and below)\b/i.test(q);
|
|
892
|
+
const hasEntityTerms = /\b(organization|organisations?|organizations?|company|companies|employee|employees|staff|headcount|workforce)\b/.test(q);
|
|
893
|
+
return asksForRecords && (hasNumericPredicate || hasEntityTerms);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private static isProductQuery(query: string): boolean {
|
|
897
|
+
const q = query.toLowerCase();
|
|
898
|
+
const productTerms = /\b(product|products|item|items|sku|catalog|catalogue|price|prices|brand|model|stock|inventory|in stock|out of stock|buy|shop)\b/.test(q);
|
|
899
|
+
const productAction = /\b(recommend|suggest|describe|description|detail|details|about|show|find|search|browse|list)\b/.test(q);
|
|
900
|
+
const nonProductEntityTerms = /\b(organization|organisations?|organizations?|company|companies|employee|employees|staff|headcount|workforce)\b/.test(q);
|
|
901
|
+
return productTerms && productAction && !nonProductEntityTerms;
|
|
902
|
+
}
|
|
903
|
+
|
|
330
904
|
private static isProductData(item: VectorMatch): boolean {
|
|
331
905
|
const content = (item.content || '').toLowerCase();
|
|
332
906
|
const productKeywords = [
|
|
333
|
-
'product', 'price', 'stock', 'item', 'sku', 'brand', 'model', 'msrp',
|
|
334
|
-
'
|
|
907
|
+
'product', 'price', 'stock', 'item', 'sku', 'brand', 'model', 'msrp',
|
|
908
|
+
'inventory', 'buy', 'shop', 'beauty', 'care', 'cosmetic', 'facial',
|
|
909
|
+
'cream', 'serum', 'mask', 'makeup', 'fragrance',
|
|
335
910
|
];
|
|
336
|
-
|
|
337
|
-
// Check content for keywords
|
|
338
911
|
const hasKeywords = productKeywords.some(kw => content.includes(kw));
|
|
339
912
|
|
|
340
|
-
|
|
341
|
-
const hasMetadataKey = Object.keys(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
913
|
+
const metadata = item.metadata || {};
|
|
914
|
+
const hasMetadataKey = Object.keys(metadata).some(k => {
|
|
915
|
+
const val = metadata[k];
|
|
916
|
+
return (
|
|
917
|
+
['price', 'product', 'sku', 'brand', 'model', 'cost', 'item'].includes(k.toLowerCase()) &&
|
|
918
|
+
val !== null && val !== undefined && val !== ''
|
|
919
|
+
);
|
|
920
|
+
});
|
|
347
921
|
|
|
922
|
+
const hasPricePattern = /\$\s*\d+\.\d{2}/.test(content);
|
|
348
923
|
return hasKeywords || hasMetadataKey || hasPricePattern;
|
|
349
924
|
}
|
|
350
925
|
|
|
351
|
-
/**
|
|
352
|
-
* Helper: Check if data contains time series
|
|
353
|
-
*/
|
|
354
926
|
private static isTimeSeriesData(item: VectorMatch): boolean {
|
|
355
|
-
const content = (item.content || '').toLowerCase();
|
|
356
|
-
const timeKeywords = ['trend', 'historical', 'growth', 'decline', 'change', 'increase', 'decrease'];
|
|
357
|
-
const hasTimeKeyword = timeKeywords.some(kw => content.includes(kw));
|
|
358
|
-
|
|
359
927
|
const metadata = item.metadata || {};
|
|
360
|
-
const maybeDateKeys = Object.keys(metadata).filter(
|
|
361
|
-
['date', 'timestamp', 'time', 'period'].includes(k.toLowerCase())
|
|
928
|
+
const maybeDateKeys = Object.keys(metadata).filter(k =>
|
|
929
|
+
['date', 'timestamp', 'time', 'period'].includes(k.toLowerCase()),
|
|
362
930
|
);
|
|
363
|
-
|
|
931
|
+
return maybeDateKeys.some(key => {
|
|
364
932
|
const value = metadata[key];
|
|
365
|
-
if (typeof value === 'string')
|
|
366
|
-
return !Number.isNaN(Date.parse(value)) && value.trim().length > 0;
|
|
367
|
-
}
|
|
933
|
+
if (typeof value === 'string') return !Number.isNaN(Date.parse(value)) && value.trim().length > 0;
|
|
368
934
|
return typeof value === 'number';
|
|
369
935
|
});
|
|
370
|
-
|
|
371
|
-
return hasTimeKeyword || hasValidDateValue;
|
|
372
936
|
}
|
|
373
937
|
|
|
374
|
-
private static
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
938
|
+
private static hasMultipleFields(data: VectorMatch[]): boolean {
|
|
939
|
+
const fieldCount = new Set<string>();
|
|
940
|
+
data.forEach(item => Object.keys(item.metadata || {}).forEach(k => fieldCount.add(k)));
|
|
941
|
+
return fieldCount.size > 2;
|
|
942
|
+
}
|
|
378
943
|
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
944
|
+
private static profileData(data: VectorMatch[]): DataProfile {
|
|
945
|
+
const records = data.map((item): DataRecord => {
|
|
946
|
+
const fields: Record<string, PrimitiveValue> = {};
|
|
947
|
+
Object.entries(item.metadata || {}).forEach(([key, value]) => {
|
|
948
|
+
const primitive = this.toPrimitive(value);
|
|
949
|
+
if (primitive !== null) fields[key] = primitive;
|
|
950
|
+
});
|
|
951
|
+
if (!fields.content && item.content) fields.content = item.content.substring(0, 500);
|
|
952
|
+
return {
|
|
953
|
+
id: item.id,
|
|
954
|
+
content: item.content,
|
|
955
|
+
score: item.score,
|
|
956
|
+
fields,
|
|
957
|
+
source: item,
|
|
958
|
+
};
|
|
959
|
+
});
|
|
393
960
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
'month',
|
|
409
|
-
'week',
|
|
410
|
-
'day',
|
|
411
|
-
'comparison',
|
|
412
|
-
'compare',
|
|
413
|
-
'changes',
|
|
414
|
-
'timeline',
|
|
415
|
-
];
|
|
961
|
+
const keys = Array.from(new Set(records.flatMap(record => Object.keys(record.fields))));
|
|
962
|
+
const fields = keys.map((key): FieldProfile => {
|
|
963
|
+
const values = records
|
|
964
|
+
.map(record => record.fields[key])
|
|
965
|
+
.filter((value): value is PrimitiveValue => value !== undefined && value !== null && String(value).trim() !== '');
|
|
966
|
+
const uniqueCount = new Set(values.map(value => String(value).toLowerCase())).size;
|
|
967
|
+
return {
|
|
968
|
+
key,
|
|
969
|
+
label: this.humanizeFieldName(key),
|
|
970
|
+
kind: this.inferFieldKind(key, values, records.length, uniqueCount),
|
|
971
|
+
values,
|
|
972
|
+
uniqueCount,
|
|
973
|
+
};
|
|
974
|
+
});
|
|
416
975
|
|
|
417
|
-
return
|
|
976
|
+
return {
|
|
977
|
+
records,
|
|
978
|
+
fields,
|
|
979
|
+
numericFields: fields.filter(field => field.kind === 'number'),
|
|
980
|
+
dateFields: fields.filter(field => field.kind === 'date'),
|
|
981
|
+
categoricalFields: fields.filter(field => field.kind === 'category'),
|
|
982
|
+
booleanFields: fields.filter(field => field.kind === 'boolean'),
|
|
983
|
+
};
|
|
418
984
|
}
|
|
419
985
|
|
|
420
|
-
private static
|
|
421
|
-
|
|
422
|
-
return
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
normalized.includes('availability') ||
|
|
426
|
-
normalized.includes('inventory') ||
|
|
427
|
-
normalized.includes('stock status')
|
|
428
|
-
);
|
|
986
|
+
private static toPrimitive(value: unknown): PrimitiveValue | null {
|
|
987
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
988
|
+
if (value === null || value === undefined) return null;
|
|
989
|
+
if (value instanceof Date) return value.toISOString();
|
|
990
|
+
return null;
|
|
429
991
|
}
|
|
430
992
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const mapping = config?.rag?.uiMapping;
|
|
443
|
-
if (mapping && mapping[uiKey]) {
|
|
444
|
-
const mappedKey = mapping[uiKey];
|
|
445
|
-
if (meta[mappedKey] !== undefined) return meta[mappedKey];
|
|
993
|
+
private static inferFieldKind(
|
|
994
|
+
key: string,
|
|
995
|
+
values: PrimitiveValue[],
|
|
996
|
+
rowCount: number,
|
|
997
|
+
uniqueCount: number,
|
|
998
|
+
): FieldProfile['kind'] {
|
|
999
|
+
const lowerKey = key.toLowerCase();
|
|
1000
|
+
if (values.length === 0) return 'text';
|
|
1001
|
+
if (values.every(value => typeof value === 'boolean' || /^(true|false|yes|no|in_stock|out_of_stock|available|unavailable)$/i.test(String(value)))) {
|
|
1002
|
+
return 'boolean';
|
|
446
1003
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
1004
|
+
if (/(date|time|timestamp|created|updated|month|year|period)/i.test(lowerKey) && values.some(value => this.isDateLike(value))) {
|
|
1005
|
+
return 'date';
|
|
1006
|
+
}
|
|
1007
|
+
const numericCount = values.filter(value => this.toFiniteNumber(value) !== null).length;
|
|
1008
|
+
if (numericCount / values.length >= 0.85 && !/(id|sku|ean|upc|zip|postal|phone|code)/i.test(lowerKey)) {
|
|
1009
|
+
return 'number';
|
|
451
1010
|
}
|
|
1011
|
+
if (
|
|
1012
|
+
uniqueCount <= Math.max(20, Math.ceil(rowCount * 0.7)) &&
|
|
1013
|
+
!/(id|sku|ean|upc|description|content|url|image|thumbnail)/i.test(lowerKey)
|
|
1014
|
+
) {
|
|
1015
|
+
return 'category';
|
|
1016
|
+
}
|
|
1017
|
+
return 'text';
|
|
1018
|
+
}
|
|
452
1019
|
|
|
453
|
-
|
|
1020
|
+
private static isDateLike(value: PrimitiveValue): boolean {
|
|
1021
|
+
if (typeof value === 'number') return value > 1900 && value < 3000;
|
|
1022
|
+
const text = String(value).trim();
|
|
1023
|
+
return text.length > 3 && !Number.isNaN(Date.parse(text));
|
|
454
1024
|
}
|
|
455
1025
|
|
|
456
|
-
private static
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
):
|
|
461
|
-
|
|
1026
|
+
private static chooseAutomaticVisualization(
|
|
1027
|
+
data: VectorMatch[],
|
|
1028
|
+
profile: DataProfile,
|
|
1029
|
+
query: string,
|
|
1030
|
+
): UITransformationResponse | null {
|
|
1031
|
+
if (profile.records.length === 0) return null;
|
|
1032
|
+
if (profile.dateFields.length > 0 && profile.numericFields.length > 0) {
|
|
1033
|
+
return this.transformToLineChart(profile);
|
|
1034
|
+
}
|
|
1035
|
+
if (profile.categoricalFields.length > 0 && profile.numericFields.length > 0) {
|
|
1036
|
+
return this.transformToBarChart(data, profile, query);
|
|
1037
|
+
}
|
|
1038
|
+
if (profile.categoricalFields.length > 0) {
|
|
1039
|
+
return this.transformToPieChart(data, profile, query);
|
|
1040
|
+
}
|
|
1041
|
+
if (profile.numericFields.length >= 2) {
|
|
1042
|
+
return this.transformToScatterPlot(profile, query);
|
|
1043
|
+
}
|
|
1044
|
+
if (profile.numericFields.length === 1) {
|
|
1045
|
+
return this.transformToMetricCard(profile, query);
|
|
1046
|
+
}
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
462
1049
|
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
1050
|
+
private static selectDimensionField(profile: DataProfile, query: string): FieldProfile | undefined {
|
|
1051
|
+
const productCategory = profile.categoricalFields.find(field =>
|
|
1052
|
+
/category|department|collection|type|group|segment|region|country|state|city/i.test(field.key),
|
|
1053
|
+
);
|
|
1054
|
+
const ranked = this.rankFieldsByQuery(profile.categoricalFields, query);
|
|
1055
|
+
return ranked[0] ?? productCategory ?? profile.categoricalFields[0];
|
|
1056
|
+
}
|
|
466
1057
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
let finalName = name ? String(name) : undefined;
|
|
471
|
-
if (!finalName) {
|
|
472
|
-
const nameMatch = item.content.match(/(?:Product|Item|Name):\s*([^\n]+)/i);
|
|
473
|
-
finalName = nameMatch ? nameMatch[1].trim() : item.content.split('\n')[0].substring(0, 60);
|
|
474
|
-
}
|
|
1058
|
+
private static selectNumericField(profile: DataProfile, query: string): FieldProfile | undefined {
|
|
1059
|
+
return this.rankFieldsByQuery(profile.numericFields, query)[0] ?? profile.numericFields[0];
|
|
1060
|
+
}
|
|
475
1061
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
: undefined;
|
|
1062
|
+
private static rankFieldsByQuery(fields: FieldProfile[], query: string): FieldProfile[] {
|
|
1063
|
+
const q = query.toLowerCase();
|
|
1064
|
+
return [...fields].sort((a, b) => this.fieldScore(b, q) - this.fieldScore(a, q));
|
|
1065
|
+
}
|
|
481
1066
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1067
|
+
private static fieldScore(field: FieldProfile, query: string): number {
|
|
1068
|
+
const key = field.key.toLowerCase();
|
|
1069
|
+
const label = field.label.toLowerCase();
|
|
1070
|
+
let score = 0;
|
|
1071
|
+
if (query.includes(key)) score += 4;
|
|
1072
|
+
if (query.includes(label)) score += 4;
|
|
1073
|
+
key.split(/[_\s-]+/).forEach(part => {
|
|
1074
|
+
if (part && query.includes(part)) score += 1;
|
|
1075
|
+
});
|
|
1076
|
+
if (/category|department|collection|type|group|segment/.test(key)) score += 2;
|
|
1077
|
+
if (/count|total|quantity|stock|inventory|sales|revenue|amount|price|value|score/.test(key)) score += 2;
|
|
1078
|
+
if (/id|sku|ean|upc|code/.test(key)) score -= 5;
|
|
1079
|
+
return score;
|
|
1080
|
+
}
|
|
486
1081
|
|
|
487
|
-
|
|
1082
|
+
private static normalizeComparableField(field: string): string {
|
|
1083
|
+
return field
|
|
1084
|
+
.toLowerCase()
|
|
1085
|
+
.replace(/&/g, 'and')
|
|
1086
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
1087
|
+
.trim();
|
|
1088
|
+
}
|
|
488
1089
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
1090
|
+
private static fieldTokens(field: string): string[] {
|
|
1091
|
+
return field
|
|
1092
|
+
.toLowerCase()
|
|
1093
|
+
.replace(/[_-]+/g, ' ')
|
|
1094
|
+
.split(/\s+/)
|
|
1095
|
+
.map(token => token.replace(/[^a-z0-9]/g, ''))
|
|
1096
|
+
.filter(Boolean);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private static aggregateProfileByDimension(
|
|
1100
|
+
profile: DataProfile,
|
|
1101
|
+
dimensionKey: string,
|
|
1102
|
+
measureKey?: string,
|
|
1103
|
+
): Record<string, number> {
|
|
1104
|
+
const result: Record<string, number> = {};
|
|
1105
|
+
profile.records.forEach(record => {
|
|
1106
|
+
const category = String(record.fields[dimensionKey] ?? 'Other').trim() || 'Other';
|
|
1107
|
+
const value = measureKey ? this.toFiniteNumber(record.fields[measureKey]) ?? 0 : 1;
|
|
1108
|
+
result[category] = (result[category] ?? 0) + value;
|
|
1109
|
+
});
|
|
1110
|
+
return result;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private static getRecordLabel(record: DataRecord): PrimitiveValue | undefined {
|
|
1114
|
+
const labelKeys = ['name', 'title', 'label', 'product', 'item', 'brand'];
|
|
1115
|
+
const key = Object.keys(record.fields).find(fieldKey =>
|
|
1116
|
+
labelKeys.some(labelKey => fieldKey.toLowerCase().includes(labelKey)),
|
|
1117
|
+
);
|
|
1118
|
+
return key ? record.fields[key] : undefined;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private static detectAggregationOperation(query: string): AggregationOperation {
|
|
1122
|
+
const q = query.toLowerCase();
|
|
1123
|
+
if (/\b(avg|average|mean)\b/.test(q)) return 'average';
|
|
1124
|
+
if (/\b(count|how many|number of)\b/.test(q)) return 'count';
|
|
1125
|
+
if (/\b(min|minimum|lowest)\b/.test(q)) return 'min';
|
|
1126
|
+
if (/\b(max|maximum|highest)\b/.test(q)) return 'max';
|
|
1127
|
+
if (/\bmedian\b/.test(q)) return 'median';
|
|
1128
|
+
return 'sum';
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
private static calculateAggregate(values: number[], operation: AggregationOperation): number {
|
|
1132
|
+
if (values.length === 0) return 0;
|
|
1133
|
+
switch (operation) {
|
|
1134
|
+
case 'average':
|
|
1135
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
1136
|
+
case 'count':
|
|
1137
|
+
return values.length;
|
|
1138
|
+
case 'min':
|
|
1139
|
+
return Math.min(...values);
|
|
1140
|
+
case 'max':
|
|
1141
|
+
return Math.max(...values);
|
|
1142
|
+
case 'median': {
|
|
1143
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1144
|
+
const middle = Math.floor(sorted.length / 2);
|
|
1145
|
+
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
|
1146
|
+
}
|
|
1147
|
+
case 'sum':
|
|
1148
|
+
default:
|
|
1149
|
+
return values.reduce((sum, value) => sum + value, 0);
|
|
498
1150
|
}
|
|
1151
|
+
}
|
|
499
1152
|
|
|
500
|
-
|
|
1153
|
+
private static humanizeFieldName(key: string): string {
|
|
1154
|
+
return key
|
|
1155
|
+
.replace(/[_-]+/g, ' ')
|
|
1156
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
1157
|
+
.replace(/\s+/g, ' ')
|
|
1158
|
+
.trim()
|
|
1159
|
+
.replace(/\b\w/g, char => char.toUpperCase());
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private static formatNumber(value: number): string {
|
|
1163
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
501
1164
|
}
|
|
502
1165
|
|
|
503
|
-
/**
|
|
504
|
-
* Helper: Detect categories in data
|
|
505
|
-
*/
|
|
506
1166
|
private static detectCategories(data: VectorMatch[]): string[] {
|
|
507
1167
|
const categories = new Set<string>();
|
|
508
|
-
|
|
509
1168
|
data.forEach(item => {
|
|
510
|
-
const
|
|
1169
|
+
const category = this.getProductCategory(item);
|
|
1170
|
+
if (category) categories.add(category);
|
|
1171
|
+
});
|
|
1172
|
+
return Array.from(categories);
|
|
1173
|
+
}
|
|
511
1174
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (meta.type) {
|
|
517
|
-
categories.add(String(meta.type));
|
|
518
|
-
}
|
|
519
|
-
if (meta.tag) {
|
|
520
|
-
const tags = Array.isArray(meta.tag) ? meta.tag : [meta.tag];
|
|
521
|
-
tags.forEach(t => categories.add(String(t)));
|
|
522
|
-
}
|
|
1175
|
+
private static getProductCategory(item: VectorMatch): string | null {
|
|
1176
|
+
const meta = item.metadata || {};
|
|
1177
|
+
const metadataCategory = resolveMetadataValue(meta, 'category');
|
|
1178
|
+
if (this.isUsableCategory(metadataCategory)) return String(metadataCategory).trim();
|
|
523
1179
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
// Parse from structured category hints in content
|
|
533
|
-
const categoryMatch = item.content.match(/(?:Category|Type|Class):\s*([^\n]+)/i);
|
|
534
|
-
if (categoryMatch) {
|
|
535
|
-
categories.add(categoryMatch[1].trim());
|
|
536
|
-
}
|
|
537
|
-
});
|
|
1180
|
+
const content = item.content || '';
|
|
1181
|
+
const explicitCategoryMatch = content.match(
|
|
1182
|
+
/(?:^|\n)\s*(?:product\s*)?(?:category|department|collection)\s*[:\-–]\s*([^\n]+)/i,
|
|
1183
|
+
);
|
|
1184
|
+
if (explicitCategoryMatch && this.isUsableCategory(explicitCategoryMatch[1])) {
|
|
1185
|
+
return explicitCategoryMatch[1].trim();
|
|
1186
|
+
}
|
|
538
1187
|
|
|
539
|
-
return
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private static isUsableCategory(value: unknown): value is string | number {
|
|
1192
|
+
if (value === null || value === undefined) return false;
|
|
1193
|
+
const category = String(value).trim();
|
|
1194
|
+
if (!category) return false;
|
|
1195
|
+
return !/^(category|type|tag|price|cost|stock|availability|description|product|item|name|brand|sku|id|ean|currency|color|size|index|internal id|other|\[?type\]?|\[?products?_?\d*\]?)$/i.test(category);
|
|
540
1196
|
}
|
|
541
1197
|
|
|
542
|
-
/**
|
|
543
|
-
* Helper: Aggregate data by category
|
|
544
|
-
*/
|
|
545
1198
|
private static aggregateByCategory(
|
|
546
1199
|
data: VectorMatch[],
|
|
547
|
-
categories: string[]
|
|
1200
|
+
categories: string[],
|
|
548
1201
|
): Record<string, number> {
|
|
549
|
-
const result: Record<string, number> =
|
|
550
|
-
|
|
551
|
-
categories.forEach(cat => {
|
|
552
|
-
result[cat] = 0;
|
|
553
|
-
});
|
|
554
|
-
|
|
1202
|
+
const result: Record<string, number> = Object.fromEntries(categories.map(c => [c, 0]));
|
|
555
1203
|
data.forEach(item => {
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (Object.prototype.hasOwnProperty.call(result, itemCategory)) {
|
|
560
|
-
result[itemCategory]++;
|
|
561
|
-
} else {
|
|
562
|
-
result['Other'] = (result['Other'] || 0) + 1;
|
|
563
|
-
}
|
|
1204
|
+
const cat = this.getProductCategory(item) ?? 'Other';
|
|
1205
|
+
if (Object.prototype.hasOwnProperty.call(result, cat)) result[cat]++;
|
|
1206
|
+
else result['Other'] = (result['Other'] || 0) + 1;
|
|
564
1207
|
});
|
|
565
|
-
|
|
566
1208
|
return result;
|
|
567
1209
|
}
|
|
568
1210
|
|
|
569
|
-
/**
|
|
570
|
-
* Helper: Extract time series data
|
|
571
|
-
*/
|
|
572
1211
|
private static extractTimeSeriesData(
|
|
573
|
-
data: VectorMatch[]
|
|
1212
|
+
data: VectorMatch[],
|
|
574
1213
|
): Array<{ timestamp: string | number; value: number; label?: string }> {
|
|
575
1214
|
return data.map(item => {
|
|
576
1215
|
const meta = item.metadata || {};
|
|
577
1216
|
return {
|
|
578
|
-
timestamp: (meta.timestamp
|
|
579
|
-
value: (meta.value
|
|
580
|
-
label: (meta.label
|
|
1217
|
+
timestamp: (meta.timestamp ?? meta.date ?? new Date().toISOString()) as string | number,
|
|
1218
|
+
value: (meta.value ?? item.score ?? 0) as number,
|
|
1219
|
+
label: (meta.label ?? item.content.substring(0, 50)) as string,
|
|
581
1220
|
};
|
|
582
1221
|
});
|
|
583
1222
|
}
|
|
584
1223
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1224
|
+
private static extractNumericValue(meta: Record<string, unknown>): number | null {
|
|
1225
|
+
const preferredKeys = [
|
|
1226
|
+
'value', 'count', 'total', 'average', 'avg', 'amount', 'sales',
|
|
1227
|
+
'revenue', 'score', 'quantity', 'price',
|
|
1228
|
+
];
|
|
1229
|
+
|
|
1230
|
+
for (const key of preferredKeys) {
|
|
1231
|
+
const raw = resolveMetadataValue(meta, key) ?? meta[key];
|
|
1232
|
+
const value = typeof raw === 'number' ? raw : Number(String(raw ?? '').replace(/[$,% ,]/g, ''));
|
|
1233
|
+
if (Number.isFinite(value)) return value;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
for (const value of Object.values(meta)) {
|
|
1237
|
+
const numeric = typeof value === 'number' ? value : Number(String(value ?? '').replace(/[$,% ,]/g, ''));
|
|
1238
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
private static extractTableColumns(data: VectorMatch[], query = ''): string[] {
|
|
1245
|
+
const q = query.toLowerCase();
|
|
1246
|
+
const availableFields = this.extractAvailableTableFields(data);
|
|
1247
|
+
|
|
1248
|
+
if (/\b(organization|organisations?|organizations?|company|companies)\b/.test(q) &&
|
|
1249
|
+
/\b(employee|employees|staff|headcount|workforce)\b/.test(q)) {
|
|
1250
|
+
const nameField = this.pickTableField(availableFields, [
|
|
1251
|
+
'company name',
|
|
1252
|
+
'organization name',
|
|
1253
|
+
'organisation name',
|
|
1254
|
+
'name',
|
|
1255
|
+
'company',
|
|
1256
|
+
'organization',
|
|
1257
|
+
'organisation',
|
|
1258
|
+
]);
|
|
1259
|
+
const employeeField = this.pickTableField(availableFields, [
|
|
1260
|
+
'number of employees',
|
|
1261
|
+
'employee count',
|
|
1262
|
+
'employees',
|
|
1263
|
+
'employee_count',
|
|
1264
|
+
'number_employees',
|
|
1265
|
+
'num employees',
|
|
1266
|
+
'staff count',
|
|
1267
|
+
'headcount',
|
|
1268
|
+
'workforce',
|
|
1269
|
+
]);
|
|
1270
|
+
|
|
1271
|
+
const columns = [nameField, employeeField].filter((field): field is string => Boolean(field));
|
|
1272
|
+
if (columns.length > 0) return columns;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const requestedFields = availableFields
|
|
1276
|
+
.map(field => ({
|
|
1277
|
+
field,
|
|
1278
|
+
score: this.tableFieldQueryScore(field, q),
|
|
1279
|
+
}))
|
|
1280
|
+
.filter(item => item.score > 0)
|
|
1281
|
+
.sort((a, b) => b.score - a.score)
|
|
1282
|
+
.map(item => item.field);
|
|
1283
|
+
|
|
1284
|
+
if (requestedFields.length > 0) {
|
|
1285
|
+
return requestedFields.slice(0, Math.min(6, requestedFields.length));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const metadataFields = Array.from(new Set(
|
|
1289
|
+
data.flatMap(item => Object.keys(item.metadata || {}).map(k => this.humanizeFieldName(k))),
|
|
1290
|
+
));
|
|
1291
|
+
|
|
1292
|
+
return metadataFields.length > 0 ? metadataFields : ['Content'];
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private static extractTableRow(
|
|
1296
|
+
item: VectorMatch,
|
|
1297
|
+
columns: string[],
|
|
1298
|
+
): (string | number | boolean)[] {
|
|
1299
|
+
return columns.map(col => this.resolveTableCellValue(item, col));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
private static extractAvailableTableFields(data: VectorMatch[]): string[] {
|
|
1303
|
+
const fields = new Map<string, string>();
|
|
1304
|
+
const addField = (field: string) => {
|
|
1305
|
+
const clean = this.humanizeFieldName(field);
|
|
1306
|
+
if (!clean || /^(content|\[?type\]?)$/i.test(clean)) return;
|
|
1307
|
+
const normalized = this.normalizeComparableField(clean);
|
|
1308
|
+
if (!fields.has(normalized)) fields.set(normalized, clean);
|
|
1309
|
+
};
|
|
591
1310
|
|
|
592
1311
|
data.forEach(item => {
|
|
593
|
-
Object.keys(item.metadata || {}).forEach(
|
|
594
|
-
|
|
595
|
-
|
|
1312
|
+
Object.keys(item.metadata || {}).forEach(addField);
|
|
1313
|
+
for (const match of (item.content || '').matchAll(/(?:^|\n)\s*([A-Za-z][A-Za-z0-9_ /-]{1,50})\s*[:\-–]\s*([^\n]+)/g)) {
|
|
1314
|
+
addField(match[1]);
|
|
1315
|
+
}
|
|
596
1316
|
});
|
|
597
1317
|
|
|
598
|
-
return Array.from(
|
|
1318
|
+
return Array.from(fields.values());
|
|
599
1319
|
}
|
|
600
1320
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1321
|
+
private static pickTableField(fields: string[], aliases: string[]): string | undefined {
|
|
1322
|
+
const normalizedAliases = aliases.map(alias => this.normalizeComparableField(alias));
|
|
1323
|
+
for (const alias of normalizedAliases) {
|
|
1324
|
+
const exact = fields.find(field => this.normalizeComparableField(field) === alias);
|
|
1325
|
+
if (exact) return exact;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
for (const alias of normalizedAliases) {
|
|
1329
|
+
const fuzzy = fields.find(field => {
|
|
1330
|
+
const normalizedField = this.normalizeComparableField(field);
|
|
1331
|
+
if (/(id|code|uuid)$/.test(normalizedField) && !/(id|code|uuid)$/.test(alias)) return false;
|
|
1332
|
+
return normalizedField.includes(alias) || alias.includes(normalizedField);
|
|
1333
|
+
});
|
|
1334
|
+
if (fuzzy) return fuzzy;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
return undefined;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
private static tableFieldQueryScore(field: string, query: string): number {
|
|
1341
|
+
const normalizedField = this.normalizeComparableField(field);
|
|
1342
|
+
if (!normalizedField) return 0;
|
|
1343
|
+
const fieldTokens = this.fieldTokens(field);
|
|
1344
|
+
return fieldTokens.reduce((score, token) => {
|
|
1345
|
+
if (token.length < 3) return score;
|
|
1346
|
+
return query.includes(token) ? score + 1 : score;
|
|
1347
|
+
}, query.includes(normalizedField) ? 3 : 0);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
private static resolveTableCellValue(item: VectorMatch, column: string): string | number | boolean {
|
|
1351
|
+
if (column === 'Content') return item.content.substring(0, 100);
|
|
1352
|
+
|
|
605
1353
|
const meta = item.metadata || {};
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1354
|
+
const normalizedColumn = this.normalizeComparableField(column);
|
|
1355
|
+
const exactMetadata = Object.entries(meta).find(([key]) =>
|
|
1356
|
+
this.normalizeComparableField(key) === normalizedColumn ||
|
|
1357
|
+
this.normalizeComparableField(this.humanizeFieldName(key)) === normalizedColumn,
|
|
1358
|
+
);
|
|
1359
|
+
if (exactMetadata && exactMetadata[1] !== undefined && exactMetadata[1] !== null) {
|
|
1360
|
+
return this.toDisplayValue(exactMetadata[1]);
|
|
1361
|
+
}
|
|
610
1362
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1363
|
+
const aliasValue = this.resolveAliasedTableCell(meta, column);
|
|
1364
|
+
if (aliasValue !== undefined) return this.toDisplayValue(aliasValue);
|
|
1365
|
+
|
|
1366
|
+
const contentValue = this.extractContentFieldValue(item.content, column);
|
|
1367
|
+
if (contentValue !== null) return contentValue;
|
|
1368
|
+
|
|
1369
|
+
const aliasContentValue = this.extractAliasedContentFieldValue(item.content, column);
|
|
1370
|
+
return aliasContentValue ?? '';
|
|
615
1371
|
}
|
|
616
1372
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
1373
|
+
private static resolveAliasedTableCell(meta: Record<string, unknown>, column: string): unknown {
|
|
1374
|
+
const normalizedColumn = this.normalizeComparableField(column);
|
|
1375
|
+
const aliases = normalizedColumn.includes('employee')
|
|
1376
|
+
? ['number of employees', 'employee count', 'employees', 'headcount', 'staff count', 'workforce']
|
|
1377
|
+
: normalizedColumn.includes('name')
|
|
1378
|
+
? ['company name', 'organization name', 'organisation name', 'name', 'company', 'organization', 'organisation']
|
|
1379
|
+
: [];
|
|
1380
|
+
|
|
1381
|
+
for (const alias of aliases) {
|
|
1382
|
+
const normalizedAlias = this.normalizeComparableField(alias);
|
|
1383
|
+
const match = Object.entries(meta).find(([key]) => {
|
|
1384
|
+
const normalizedKey = this.normalizeComparableField(key);
|
|
1385
|
+
return normalizedKey === normalizedAlias ||
|
|
1386
|
+
normalizedKey.includes(normalizedAlias) ||
|
|
1387
|
+
normalizedAlias.includes(normalizedKey);
|
|
1388
|
+
});
|
|
1389
|
+
if (match && match[1] !== undefined && match[1] !== null) return match[1];
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
return undefined;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
private static extractContentFieldValue(content: string, column: string): string | null {
|
|
1396
|
+
const escaped = column.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '[\\s_ -]+');
|
|
1397
|
+
const pattern = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*[:\\-–]\\s*([^\\n]+)`, 'i');
|
|
1398
|
+
const match = content.match(pattern);
|
|
1399
|
+
return match ? match[1].trim() : null;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
private static extractAliasedContentFieldValue(content: string, column: string): string | null {
|
|
1403
|
+
const normalizedColumn = this.normalizeComparableField(column);
|
|
1404
|
+
const aliases = normalizedColumn.includes('employee')
|
|
1405
|
+
? ['Number Of Employees', 'Number of Employees', 'Employee Count', 'Employees', 'Headcount', 'Staff Count', 'Workforce']
|
|
1406
|
+
: normalizedColumn.includes('name')
|
|
1407
|
+
? ['Company Name', 'Organization Name', 'Organisation Name', 'Name', 'Company', 'Organization', 'Organisation']
|
|
1408
|
+
: [];
|
|
1409
|
+
|
|
1410
|
+
for (const alias of aliases) {
|
|
1411
|
+
const value = this.extractContentFieldValue(content, alias);
|
|
1412
|
+
if (value !== null) return value;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
private static toDisplayValue(value: unknown): string | number | boolean {
|
|
1419
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
1420
|
+
return String(value ?? '');
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
private static calculateStockCounts(
|
|
1424
|
+
category: string,
|
|
1425
|
+
data: VectorMatch[],
|
|
1426
|
+
): { inStockCount: number; outOfStockCount: number } {
|
|
621
1427
|
let inStock = 0;
|
|
622
1428
|
let outOfStock = 0;
|
|
623
|
-
|
|
624
1429
|
data.forEach(d => {
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (this.determineStockStatus(d))
|
|
629
|
-
|
|
630
|
-
} else {
|
|
631
|
-
outOfStock++;
|
|
632
|
-
}
|
|
1430
|
+
const cat = this.getProductCategory(d) ?? 'Other';
|
|
1431
|
+
if (cat === category) {
|
|
1432
|
+
const quantity = this.extractStockQuantity(d) ?? 1;
|
|
1433
|
+
if (this.determineStockStatus(d)) inStock += quantity;
|
|
1434
|
+
else outOfStock += quantity;
|
|
633
1435
|
}
|
|
634
1436
|
});
|
|
635
|
-
|
|
636
1437
|
return { inStockCount: inStock, outOfStockCount: outOfStock };
|
|
637
1438
|
}
|
|
638
1439
|
|
|
639
|
-
/**
|
|
640
|
-
* Helper: Determine if item is in stock
|
|
641
|
-
*/
|
|
642
1440
|
private static determineStockStatus(item: VectorMatch): boolean {
|
|
643
1441
|
const meta = item.metadata || {};
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
if (meta.available !== undefined) {
|
|
653
|
-
return Boolean(meta.available);
|
|
1442
|
+
const stockValue = resolveMetadataValue(meta, 'stock');
|
|
1443
|
+
if (stockValue !== undefined) {
|
|
1444
|
+
const normalized = String(stockValue).toLowerCase();
|
|
1445
|
+
if (/out[_\s-]?of[_\s-]?stock|unavailable|false|no|sold out|0/.test(normalized)) return false;
|
|
1446
|
+
if (/in[_\s-]?stock|available|true|yes/.test(normalized)) return true;
|
|
1447
|
+
const numeric = this.toFiniteNumber(stockValue);
|
|
1448
|
+
if (numeric !== null) return numeric > 0;
|
|
654
1449
|
}
|
|
1450
|
+
if (meta.inStock !== undefined) return Boolean(meta.inStock);
|
|
1451
|
+
if (meta.stock !== undefined) return Boolean(meta.stock);
|
|
1452
|
+
if (meta.available !== undefined) return Boolean(meta.available);
|
|
655
1453
|
|
|
656
|
-
// Check content
|
|
657
1454
|
const content = (item.content || '').toLowerCase();
|
|
658
|
-
if (
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
if (content.includes('in stock') || content.includes('available')) {
|
|
662
|
-
return true;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Default to true if not specified
|
|
1455
|
+
if (/out[_\s-]?of[_\s-]?stock|unavailable|sold out/.test(content)) return false;
|
|
1456
|
+
if (/in[_\s-]?stock|available/.test(content)) return true;
|
|
666
1457
|
return true;
|
|
667
1458
|
}
|
|
668
1459
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1460
|
+
private static extractStockQuantity(item: VectorMatch): number | null {
|
|
1461
|
+
const meta = item.metadata || {};
|
|
1462
|
+
const stockValue = resolveMetadataValue(meta, 'stock');
|
|
1463
|
+
const numericStock = this.toFiniteNumber(stockValue);
|
|
1464
|
+
if (numericStock !== null) return numericStock;
|
|
674
1465
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
});
|
|
679
|
-
});
|
|
1466
|
+
const content = item.content || '';
|
|
1467
|
+
const stockMatch = content.match(/\b(?:stock|inventory|quantity|count)\s*[:\-–]?\s*([\d,]+)/i);
|
|
1468
|
+
if (!stockMatch) return null;
|
|
680
1469
|
|
|
681
|
-
return
|
|
1470
|
+
return this.toFiniteNumber(stockMatch[1]);
|
|
682
1471
|
}
|
|
683
1472
|
|
|
684
|
-
|
|
1473
|
+
private static toFiniteNumber(value: unknown): number | null {
|
|
1474
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
1475
|
+
const numeric = Number(String(value ?? '').replace(/[$,% ,]/g, ''));
|
|
1476
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
1477
|
+
}
|
|
685
1478
|
|
|
686
|
-
|
|
687
|
-
* analyzeAndDecide — sends user question + RAG data to the LLM with a
|
|
688
|
-
* structured system prompt and parses the JSON response into a
|
|
689
|
-
* UITransformationResponse.
|
|
690
|
-
*
|
|
691
|
-
* This is the recommended entry point for production use. The heuristic
|
|
692
|
-
* `transform()` method is used as a fallback if the LLM call fails.
|
|
693
|
-
*
|
|
694
|
-
* System prompt instructs the LLM to:
|
|
695
|
-
* - Analyze the question and retrieved data
|
|
696
|
-
* - Choose the best visualization: bar_chart | line_chart | pie_chart | table | text
|
|
697
|
-
* - Return a strict JSON object — no prose, no markdown fences
|
|
698
|
-
*
|
|
699
|
-
* @param query - the original user question
|
|
700
|
-
* @param sources - vector DB matches returned by RAG retrieval
|
|
701
|
-
* @param llm - any ILLMProvider instance (OpenAI, Anthropic, Ollama, Gemini, REST…)
|
|
702
|
-
* @returns - a validated UITransformationResponse (type + title + description + data)
|
|
703
|
-
*/
|
|
704
|
-
static async analyzeAndDecide(
|
|
705
|
-
query: string,
|
|
706
|
-
sources: VectorMatch[],
|
|
707
|
-
llm: ILLMProvider,
|
|
708
|
-
): Promise<UITransformationResponse> {
|
|
709
|
-
try {
|
|
710
|
-
const context = this.buildContextSummary(sources);
|
|
711
|
-
const systemPrompt = this.buildVisualizationSystemPrompt();
|
|
1479
|
+
// ─── Product Extraction ───────────────────────────────────────────────────
|
|
712
1480
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1481
|
+
private static getDynamicVal(
|
|
1482
|
+
meta: Record<string, unknown> | undefined,
|
|
1483
|
+
uiKey: string,
|
|
1484
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
1485
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap,
|
|
1486
|
+
): unknown {
|
|
1487
|
+
if (!meta) return undefined;
|
|
719
1488
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
'',
|
|
723
|
-
{ systemPrompt, temperature: 0 },
|
|
724
|
-
);
|
|
1489
|
+
const mapping = config?.rag?.uiMapping;
|
|
1490
|
+
if (mapping?.[uiKey] && meta[mapping[uiKey]] !== undefined) return meta[mapping[uiKey]];
|
|
725
1491
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1492
|
+
if (trainedSchema && typeof trainedSchema === 'object') {
|
|
1493
|
+
const trainedKey = (trainedSchema as Record<string, unknown>)[uiKey];
|
|
1494
|
+
if (trainedKey && meta[trainedKey as string] !== undefined) return meta[trainedKey as string];
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
return resolveMetadataValue(meta, uiKey);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
private static extractProductInfo(
|
|
1501
|
+
item: VectorMatch,
|
|
1502
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
1503
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap,
|
|
1504
|
+
): CarouselProduct | null {
|
|
1505
|
+
const meta = item.metadata || {};
|
|
1506
|
+
const name = this.getDynamicVal(meta, 'name', config, trainedSchema);
|
|
1507
|
+
const price = this.getDynamicVal(meta, 'price', config, trainedSchema);
|
|
1508
|
+
const brand = this.getDynamicVal(meta, 'brand', config, trainedSchema);
|
|
1509
|
+
const description = this.cleanProductDescription(
|
|
1510
|
+
this.extractProductDescriptionFromContent(item.content) ??
|
|
1511
|
+
this.getProductDescriptionValue(meta, config, trainedSchema),
|
|
1512
|
+
);
|
|
1513
|
+
|
|
1514
|
+
if (name || this.isProductData(item)) {
|
|
1515
|
+
let finalName = name ? String(name) : undefined;
|
|
1516
|
+
if (!finalName) {
|
|
1517
|
+
const nameMatch = item.content.match(/(?:Product|Item|Name):\s*([^\n]+)/i);
|
|
1518
|
+
finalName = nameMatch ? nameMatch[1].trim() : item.content.split('\n')[0].substring(0, 60);
|
|
730
1519
|
}
|
|
731
1520
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1521
|
+
let finalPrice: string | number | undefined =
|
|
1522
|
+
typeof price === 'number' || typeof price === 'string' ? price : undefined;
|
|
1523
|
+
if (!finalPrice) {
|
|
1524
|
+
const priceMatch =
|
|
1525
|
+
item.content.match(/(?:Price|Cost):\s*\$?([\d,.]+)/i) ||
|
|
1526
|
+
item.content.match(/\$\s*([\d,.]+)/);
|
|
1527
|
+
if (priceMatch) finalPrice = priceMatch[1].replace(/,/g, '');
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const imageValue = this.getDynamicVal(meta, 'image', config, trainedSchema);
|
|
1531
|
+
|
|
1532
|
+
return {
|
|
1533
|
+
id: item.id,
|
|
1534
|
+
name: finalName,
|
|
1535
|
+
price: finalPrice,
|
|
1536
|
+
image: typeof imageValue === 'string' ? imageValue : undefined,
|
|
1537
|
+
brand: brand ? String(brand) : undefined,
|
|
1538
|
+
description,
|
|
1539
|
+
inStock: this.determineStockStatus(item),
|
|
1540
|
+
};
|
|
735
1541
|
}
|
|
736
1542
|
|
|
737
|
-
|
|
738
|
-
return this.transform(query, sources);
|
|
1543
|
+
return null;
|
|
739
1544
|
}
|
|
740
1545
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1546
|
+
private static extractProductDescriptionFromContent(content: string): string | null {
|
|
1547
|
+
const bodyMatch = content.match(
|
|
1548
|
+
/\bBody\s*\(HTML\)\s*:\s*([\s\S]*?)(?=\s*[,.]?\s+\b(?:Handle|Title|Vendor|Type|Tags|Published|Option\d*\s+Name|Option\d*\s+Value|Option|Variant|Image|SKU|Price|Status|Category)\b\s*:|$)/i,
|
|
1549
|
+
);
|
|
1550
|
+
if (bodyMatch?.[1]) return bodyMatch[1];
|
|
1551
|
+
|
|
1552
|
+
const descriptionMatch = content.match(
|
|
1553
|
+
/\b(?:Description|Summary|Details|Body|Content)\s*:\s*([\s\S]*?)(?=\s*[,.]?\s+\b(?:Handle|Title|Vendor|Type|Tags|Published|Option\d*\s+Name|Option\d*\s+Value|Option|Variant|Image|SKU|Price|Status|Category)\b\s*:|$)/i,
|
|
1554
|
+
);
|
|
1555
|
+
if (descriptionMatch?.[1]) return descriptionMatch[1];
|
|
1556
|
+
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
private static getProductDescriptionValue(
|
|
1561
|
+
meta: Record<string, unknown>,
|
|
1562
|
+
config?: import('../config/RagConfig').RagConfig,
|
|
1563
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap,
|
|
1564
|
+
): unknown {
|
|
1565
|
+
const mapped = this.getDynamicVal(meta, 'description', config, trainedSchema);
|
|
1566
|
+
if (mapped !== undefined && mapped !== meta.content && mapped !== meta.text) return mapped;
|
|
1567
|
+
|
|
1568
|
+
const preferredKeys = [
|
|
1569
|
+
'body_html',
|
|
1570
|
+
'body html',
|
|
1571
|
+
'bodyHtml',
|
|
1572
|
+
'description',
|
|
1573
|
+
'summary',
|
|
1574
|
+
'details',
|
|
1575
|
+
'body',
|
|
1576
|
+
];
|
|
1577
|
+
|
|
1578
|
+
for (const key of preferredKeys) {
|
|
1579
|
+
const match = Object.keys(meta).find(candidate =>
|
|
1580
|
+
this.normalizeComparableField(candidate) === this.normalizeComparableField(key),
|
|
1581
|
+
);
|
|
1582
|
+
if (match && meta[match] !== undefined && meta[match] !== null) return meta[match];
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
return undefined;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
private static cleanProductDescription(raw: unknown): string | undefined {
|
|
1589
|
+
if (raw === null || raw === undefined) return undefined;
|
|
1590
|
+
|
|
1591
|
+
const extracted = this.extractProductDescriptionFromContent(String(raw));
|
|
1592
|
+
if (extracted && extracted !== String(raw)) return this.cleanProductDescription(extracted);
|
|
1593
|
+
|
|
1594
|
+
const text = String(raw)
|
|
1595
|
+
.replace(/<[^>]*>/g, ' ')
|
|
1596
|
+
.replace(/ /gi, ' ')
|
|
1597
|
+
.replace(/&/gi, '&')
|
|
1598
|
+
.replace(/"/gi, '"')
|
|
1599
|
+
.replace(/'/gi, "'")
|
|
1600
|
+
.replace(/\[[^\]]*TYPE[^\]]*\]/gi, ' ')
|
|
1601
|
+
.replace(/\b(?:Handle|Title|Vendor|Type|Tags|Published|Option\d*\s+Name|Option\d*\s+Value|Option|Variant|Image|SKU|Price|Status|Category)\s*:\s*[^,.\n:]+[,.]?/gi, ' ')
|
|
1602
|
+
.replace(/\bBody\s*\(HTML\)\s*:\s*/gi, ' ')
|
|
1603
|
+
.replace(/\b(?:Description|Summary|Details|Body|Content)\s*:\s*/gi, ' ')
|
|
1604
|
+
.replace(/\s+/g, ' ')
|
|
1605
|
+
.trim();
|
|
1606
|
+
|
|
1607
|
+
return text || undefined;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// ─── Visualization Prompt ─────────────────────────────────────────────────
|
|
1611
|
+
|
|
744
1612
|
private static buildVisualizationSystemPrompt(): string {
|
|
745
1613
|
return `You are a data visualization expert embedded in a RAG chat system.
|
|
746
|
-
You will receive a user question and structured data
|
|
747
|
-
Your ONLY job is to
|
|
748
|
-
|
|
1614
|
+
You will receive a user question, a pre-computed INTENT object, and structured data from a vector database.
|
|
1615
|
+
Your ONLY job is to return a single JSON object describing how to visualize the data.
|
|
1616
|
+
The INTENT object is authoritative — honor it. For example:
|
|
1617
|
+
- If intent.wantsExplicitTable is false, never return type "table".
|
|
1618
|
+
- If intent.isTemporal is true, prefer line_chart.
|
|
1619
|
+
- If intent.visualizationHint is "comparison" or "ranking", prefer bar_chart.
|
|
1620
|
+
- If intent.visualizationHint is "composition", prefer pie_chart.
|
|
1621
|
+
- If intent.recommendedChart is unsupported by the response schema, use the nearest supported type.
|
|
1622
|
+
- Always respond in the language specified by intent.language.
|
|
1623
|
+
|
|
1624
|
+
Return ONLY a valid JSON object — no markdown fences, no prose.
|
|
749
1625
|
|
|
750
|
-
Return ONLY a valid JSON object — no markdown code fences, no explanation, no prose.
|
|
751
|
-
|
|
752
|
-
The JSON must have this exact shape:
|
|
753
1626
|
{
|
|
754
|
-
"type": "bar_chart" | "line_chart" | "pie_chart" | "radar_chart" | "table" | "text",
|
|
755
|
-
"title": "
|
|
756
|
-
"description": "
|
|
757
|
-
"data": <structured data
|
|
1627
|
+
"type": "bar_chart" | "horizontal_bar" | "line_chart" | "pie_chart" | "histogram" | "scatter_plot" | "radar_chart" | "metric_card" | "geo_map" | "table" | "text",
|
|
1628
|
+
"title": "concise descriptive title",
|
|
1629
|
+
"description": "one sentence describing the visualization",
|
|
1630
|
+
"data": <structured data per type below>
|
|
758
1631
|
}
|
|
759
1632
|
|
|
760
|
-
DATA
|
|
761
|
-
- bar_chart:
|
|
762
|
-
-
|
|
763
|
-
-
|
|
764
|
-
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
5. table → multi-field structured records where each item has ≥ 3 attributes
|
|
780
|
-
6. text → conversational or free-form answers where no chart adds value
|
|
781
|
-
|
|
782
|
-
IMPORTANT:
|
|
783
|
-
- Aggregate numeric values from the raw data — do not pass raw object arrays as data.
|
|
784
|
-
- Ensure all "value" fields are numbers, not strings.
|
|
785
|
-
- For bar/line/pie, keep at most 12 data points for readability.
|
|
786
|
-
- Never include nested objects or arrays inside bar_chart / line_chart / pie_chart data items.`;
|
|
1633
|
+
DATA SHAPES:
|
|
1634
|
+
- bar_chart: [{ "category": string, "value": number }]
|
|
1635
|
+
- horizontal_bar: [{ "category": string, "value": number }]
|
|
1636
|
+
- histogram: [{ "category": string, "value": number }]
|
|
1637
|
+
- line_chart: [{ "timestamp": string, "value": number, "label": string }]
|
|
1638
|
+
- pie_chart: [{ "label": string, "value": number }]
|
|
1639
|
+
- scatter_plot:[{ "x": number, "y": number, "label": string }]
|
|
1640
|
+
- radar_chart: [{ "attribute": string, "<series1>": number, "<series2>": number, ... }]
|
|
1641
|
+
- metric_card: { "label": string, "value": number, "operation": "sum"|"average"|"count"|"min"|"max"|"median" }
|
|
1642
|
+
- geo_map: [{ "category": string, "value": number }]
|
|
1643
|
+
- table: { "columns": string[], "rows": (string|number)[][] }
|
|
1644
|
+
- text: { "content": "A concise plain-language answer." }
|
|
1645
|
+
|
|
1646
|
+
RULES:
|
|
1647
|
+
1. Aggregate values — never pass raw nested objects in chart arrays.
|
|
1648
|
+
2. All "value" fields must be numbers.
|
|
1649
|
+
3. Cap bar/line/pie at 12 data points.
|
|
1650
|
+
4. Use dollar signs only for monetary prices, not for stock quantities or years.
|
|
1651
|
+
5. If no relevant data is found, return text: "I cannot answer this question based on the available information."`;
|
|
787
1652
|
}
|
|
788
1653
|
|
|
789
|
-
/**
|
|
790
|
-
* Serialize retrieved vector matches into a compact JSON context string.
|
|
791
|
-
* Limits the total character count to avoid exceeding LLM context windows.
|
|
792
|
-
*/
|
|
793
1654
|
private static buildContextSummary(sources: VectorMatch[], maxChars = 6000): string {
|
|
794
1655
|
const items = sources.map((s, i) => ({
|
|
795
1656
|
index: i + 1,
|
|
@@ -801,9 +1662,8 @@ IMPORTANT:
|
|
|
801
1662
|
const full = JSON.stringify(items, null, 2);
|
|
802
1663
|
if (full.length <= maxChars) return full;
|
|
803
1664
|
|
|
804
|
-
// Truncate: include as many items as fit
|
|
805
1665
|
const partial: typeof items = [];
|
|
806
|
-
let chars = 2;
|
|
1666
|
+
let chars = 2;
|
|
807
1667
|
for (const item of items) {
|
|
808
1668
|
const chunk = JSON.stringify(item);
|
|
809
1669
|
if (chars + chunk.length + 2 > maxChars) break;
|
|
@@ -813,4 +1673,3 @@ IMPORTANT:
|
|
|
813
1673
|
return JSON.stringify(partial, null, 2) + '\n// ... truncated';
|
|
814
1674
|
}
|
|
815
1675
|
}
|
|
816
|
-
|