@retrivora-ai/rag-engine 1.8.9 → 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-CbUtvWAW.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -2
- package/dist/{ILLMProvider-CbUtvWAW.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -2
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1726 -522
- package/dist/handlers/index.mjs +1725 -521
- package/dist/{index-DgzcnqZs.d.ts → index-A0GqPsaG.d.ts} +1 -1
- package/dist/{index-c_y_5qdw.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 +1735 -733
- package/dist/server.mjs +1733 -731
- 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 -2
- 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 -6
- package/src/providers/vectordb/MilvusProvider.ts +18 -2
- package/src/providers/vectordb/MultiTablePostgresProvider.ts +16 -29
- package/src/providers/vectordb/PostgreSQLProvider.ts +4 -15
- package/src/providers/vectordb/QdrantProvider.ts +2 -5
- 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 +1345 -534
- package/src/utils/synonyms.ts +2 -0
|
@@ -1,80 +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
|
|
33
|
-
const categories = this.detectCategories(filteredData, groupByField);
|
|
136
|
+
const profile = this.profileData(filteredData);
|
|
34
137
|
const hasProducts = filteredData.some(item => this.isProductData(item));
|
|
35
|
-
const
|
|
36
|
-
|
|
138
|
+
const wantsPieLikeChart = ['pie_chart', 'donut_chart'].includes(resolvedIntent.recommendedChart);
|
|
139
|
+
|
|
140
|
+
// Priority 1 – Explicit trends
|
|
141
|
+
if (resolvedIntent.visualizationHint === 'trend' && profile.dateFields.length > 0 && profile.numericFields.length > 0) {
|
|
142
|
+
return this.transformToLineChart(profile);
|
|
143
|
+
}
|
|
37
144
|
|
|
38
|
-
//
|
|
39
|
-
if (
|
|
40
|
-
|
|
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;
|
|
41
149
|
}
|
|
42
150
|
|
|
43
|
-
|
|
151
|
+
// Priority 3 – Comparison/ranking → bar chart
|
|
152
|
+
if (['comparison', 'ranking'].includes(resolvedIntent.visualizationHint)) {
|
|
153
|
+
return this.transformToBarChart(filteredData, profile, userQuery, resolvedIntent.visualizationHint === 'ranking');
|
|
154
|
+
}
|
|
44
155
|
|
|
45
|
-
//
|
|
46
|
-
if (
|
|
47
|
-
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);
|
|
48
159
|
}
|
|
49
160
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return this.transformToPieChart(filteredData, groupByField);
|
|
161
|
+
if (resolvedIntent.visualizationHint === 'correlation') {
|
|
162
|
+
return this.transformToScatterPlot(profile, userQuery) ?? this.transformToBarChart(filteredData, profile, userQuery);
|
|
53
163
|
}
|
|
54
164
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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);
|
|
58
181
|
}
|
|
59
182
|
|
|
60
|
-
//
|
|
61
|
-
if (
|
|
62
|
-
|
|
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);
|
|
63
191
|
}
|
|
64
192
|
|
|
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);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const automatic = this.chooseAutomaticVisualization(filteredData, profile, userQuery);
|
|
199
|
+
if (automatic) return automatic;
|
|
200
|
+
|
|
201
|
+
// Fallback – plain text
|
|
65
202
|
return this.transformToText(filteredData);
|
|
66
203
|
}
|
|
67
204
|
|
|
68
205
|
/**
|
|
69
|
-
*
|
|
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.
|
|
70
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
|
+
|
|
71
479
|
private static transformToProductCarousel(
|
|
72
480
|
data: VectorMatch[],
|
|
73
481
|
config?: import('../config/RagConfig').RagConfig,
|
|
74
|
-
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
482
|
+
trainedSchema?: import('./SchemaMapper').SchemaMap,
|
|
75
483
|
): UITransformationResponse {
|
|
76
484
|
const products: CarouselProduct[] = data
|
|
77
|
-
.filter(item => this.isProductData(item))
|
|
78
485
|
.map(item => this.extractProductInfo(item, config, trainedSchema))
|
|
79
486
|
.filter((p): p is CarouselProduct => p !== null);
|
|
80
487
|
|
|
@@ -86,67 +493,214 @@ export class UITransformer {
|
|
|
86
493
|
};
|
|
87
494
|
}
|
|
88
495
|
|
|
89
|
-
/**
|
|
90
|
-
* Transform data to pie chart format
|
|
91
|
-
*/
|
|
92
496
|
private static transformToPieChart(
|
|
93
497
|
data: VectorMatch[],
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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);
|
|
100
510
|
|
|
101
511
|
const pieData: PieChartData[] = Object.entries(categoryData).map(([label, count]) => {
|
|
102
|
-
const { inStockCount, outOfStockCount } = this.calculateStockCounts(label, data
|
|
103
|
-
return {
|
|
104
|
-
label,
|
|
105
|
-
value: count as number,
|
|
106
|
-
inStockCount,
|
|
107
|
-
outOfStockCount,
|
|
108
|
-
};
|
|
512
|
+
const { inStockCount, outOfStockCount } = this.calculateStockCounts(label, data);
|
|
513
|
+
return { label, value: count as number, inStockCount, outOfStockCount };
|
|
109
514
|
});
|
|
110
515
|
|
|
111
516
|
return {
|
|
112
517
|
type: 'pie_chart',
|
|
113
|
-
title: `Distribution by ${
|
|
114
|
-
description: `Showing breakdown across ${
|
|
518
|
+
title: `Distribution by ${dimension?.label ?? 'Category'}`,
|
|
519
|
+
description: `Showing breakdown across ${pieData.length} categories`,
|
|
115
520
|
data: pieData,
|
|
116
521
|
};
|
|
117
522
|
}
|
|
118
523
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
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>();
|
|
124
528
|
|
|
125
|
-
|
|
126
|
-
timestamp
|
|
127
|
-
value
|
|
128
|
-
|
|
129
|
-
})
|
|
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 }));
|
|
130
539
|
|
|
131
540
|
return {
|
|
132
541
|
type: 'line_chart',
|
|
133
|
-
title:
|
|
542
|
+
title: `${valueField.label} Over Time`,
|
|
134
543
|
description: `Showing ${lineData.length} data points`,
|
|
135
544
|
data: lineData,
|
|
136
545
|
};
|
|
137
546
|
}
|
|
138
547
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
});
|
|
145
686
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
687
|
+
const radarData = Object.entries(attributeMap).map(([attribute, series]) => ({
|
|
688
|
+
attribute,
|
|
689
|
+
...series,
|
|
690
|
+
}));
|
|
691
|
+
|
|
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) })),
|
|
149
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 };
|
|
150
704
|
|
|
151
705
|
return {
|
|
152
706
|
type: 'table',
|
|
@@ -156,37 +710,24 @@ export class UITransformer {
|
|
|
156
710
|
};
|
|
157
711
|
}
|
|
158
712
|
|
|
159
|
-
/**
|
|
160
|
-
* Transform data to text format (fallback)
|
|
161
|
-
*/
|
|
162
713
|
private static transformToText(data: VectorMatch[]): UITransformationResponse {
|
|
163
|
-
const textContent = data
|
|
164
|
-
.map(item => item.content)
|
|
165
|
-
.join('\n\n');
|
|
166
|
-
|
|
167
714
|
return this.createTextResponse(
|
|
168
715
|
'Search Results',
|
|
169
|
-
|
|
170
|
-
`Found ${data.length} relevant results
|
|
716
|
+
data.map(item => item.content).join('\n\n'),
|
|
717
|
+
`Found ${data.length} relevant results`,
|
|
171
718
|
);
|
|
172
719
|
}
|
|
173
720
|
|
|
174
|
-
/**
|
|
175
|
-
* Helper: Create text response
|
|
176
|
-
*/
|
|
177
721
|
private static createTextResponse(
|
|
178
722
|
title: string,
|
|
179
723
|
content: string,
|
|
180
|
-
description?: string
|
|
724
|
+
description?: string,
|
|
181
725
|
): UITransformationResponse {
|
|
182
|
-
return {
|
|
183
|
-
type: 'text',
|
|
184
|
-
title,
|
|
185
|
-
description,
|
|
186
|
-
data: { content },
|
|
187
|
-
};
|
|
726
|
+
return { type: 'text', title, description, data: { content } };
|
|
188
727
|
}
|
|
189
728
|
|
|
729
|
+
// ─── LLM Response Parsing ─────────────────────────────────────────────────
|
|
730
|
+
|
|
190
731
|
static parseTransformationResponse(raw: string): UITransformationResponse | null {
|
|
191
732
|
const payloadText = this.extractJsonCandidate(raw);
|
|
192
733
|
if (!payloadText) return null;
|
|
@@ -216,26 +757,14 @@ export class UITransformer {
|
|
|
216
757
|
|
|
217
758
|
for (let i = start; i < cleaned.length; i += 1) {
|
|
218
759
|
const char = cleaned[i];
|
|
219
|
-
if (escape) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
if (char === '\\') {
|
|
224
|
-
escape = true;
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (char === '"') {
|
|
228
|
-
inString = !inString;
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
760
|
+
if (escape) { escape = false; continue; }
|
|
761
|
+
if (char === '\\') { escape = true; continue; }
|
|
762
|
+
if (char === '"') { inString = !inString; continue; }
|
|
231
763
|
if (inString) continue;
|
|
232
|
-
|
|
233
764
|
if (char === '{') depth += 1;
|
|
234
765
|
if (char === '}') {
|
|
235
766
|
depth -= 1;
|
|
236
|
-
if (depth === 0)
|
|
237
|
-
return cleaned.slice(start, i + 1);
|
|
238
|
-
}
|
|
767
|
+
if (depth === 0) return cleaned.slice(start, i + 1);
|
|
239
768
|
}
|
|
240
769
|
}
|
|
241
770
|
|
|
@@ -245,25 +774,20 @@ export class UITransformer {
|
|
|
245
774
|
private static normalizeTransformation(payload: unknown): UITransformationResponse | null {
|
|
246
775
|
if (!payload || typeof payload !== 'object') return null;
|
|
247
776
|
|
|
248
|
-
const
|
|
249
|
-
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
|
+
);
|
|
250
781
|
if (!type) return null;
|
|
251
782
|
|
|
252
|
-
const title = String(
|
|
253
|
-
const description =
|
|
254
|
-
const rawData =
|
|
255
|
-
|
|
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;
|
|
256
786
|
const data = type === 'text' && typeof rawData === 'string'
|
|
257
787
|
? { content: rawData }
|
|
258
788
|
: rawData;
|
|
259
789
|
|
|
260
|
-
const transformation: UITransformationResponse = {
|
|
261
|
-
type,
|
|
262
|
-
title,
|
|
263
|
-
description,
|
|
264
|
-
data,
|
|
265
|
-
};
|
|
266
|
-
|
|
790
|
+
const transformation: UITransformationResponse = { type, title, description, data };
|
|
267
791
|
return this.validateTransformation(transformation) ? transformation : null;
|
|
268
792
|
}
|
|
269
793
|
|
|
@@ -273,571 +797,860 @@ export class UITransformer {
|
|
|
273
797
|
pie_chart: 'pie_chart',
|
|
274
798
|
bar: 'bar_chart',
|
|
275
799
|
bar_chart: 'bar_chart',
|
|
800
|
+
histogram: 'histogram',
|
|
801
|
+
horizontal_bar: 'horizontal_bar',
|
|
802
|
+
horizontalbar: 'horizontal_bar',
|
|
276
803
|
line: 'line_chart',
|
|
277
804
|
line_chart: 'line_chart',
|
|
805
|
+
scatter: 'scatter_plot',
|
|
806
|
+
scatter_plot: 'scatter_plot',
|
|
807
|
+
scatterplot: 'scatter_plot',
|
|
278
808
|
radar: 'radar_chart',
|
|
279
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',
|
|
280
817
|
table: 'table',
|
|
281
818
|
text: 'text',
|
|
282
819
|
product_carousel: 'product_carousel',
|
|
283
820
|
carousel: 'carousel',
|
|
284
821
|
};
|
|
285
|
-
|
|
286
822
|
return mapping[type.toLowerCase()] ?? null;
|
|
287
823
|
}
|
|
288
824
|
|
|
289
|
-
private static validateTransformation(
|
|
290
|
-
const { type, data } =
|
|
291
|
-
|
|
825
|
+
private static validateTransformation(t: UITransformationResponse): boolean {
|
|
826
|
+
const { type, data } = t;
|
|
292
827
|
switch (type) {
|
|
293
828
|
case 'pie_chart':
|
|
294
829
|
case 'bar_chart':
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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))),
|
|
298
843
|
);
|
|
299
844
|
case 'line_chart':
|
|
300
|
-
return Array.isArray(data) && data.every(
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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))),
|
|
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)))
|
|
304
855
|
);
|
|
856
|
+
case 'geo_map':
|
|
857
|
+
return Array.isArray(data) || (typeof data === 'object' && data !== null);
|
|
305
858
|
case 'radar_chart':
|
|
306
|
-
return Array.isArray(data) && data.every(
|
|
307
|
-
|
|
308
|
-
|
|
859
|
+
return Array.isArray(data) && data.every(
|
|
860
|
+
(i): i is Record<string, unknown> =>
|
|
861
|
+
i !== null && typeof i === 'object' && 'attribute' in i,
|
|
309
862
|
);
|
|
310
863
|
case 'table':
|
|
311
864
|
return (
|
|
312
|
-
typeof data === 'object' &&
|
|
313
|
-
data !== null &&
|
|
865
|
+
typeof data === 'object' && data !== null &&
|
|
314
866
|
Array.isArray((data as Record<string, unknown>).columns) &&
|
|
315
867
|
Array.isArray((data as Record<string, unknown>).rows)
|
|
316
868
|
);
|
|
317
869
|
case 'text':
|
|
318
870
|
return (
|
|
319
|
-
typeof data === 'object' &&
|
|
320
|
-
data !== null &&
|
|
871
|
+
typeof data === 'object' && data !== null &&
|
|
321
872
|
typeof (data as Record<string, unknown>).content === 'string'
|
|
322
873
|
);
|
|
323
874
|
case 'product_carousel':
|
|
324
875
|
case 'carousel':
|
|
325
|
-
return Array.isArray(data) && data.every(
|
|
326
|
-
|
|
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),
|
|
327
879
|
);
|
|
328
880
|
default:
|
|
329
881
|
return false;
|
|
330
882
|
}
|
|
331
883
|
}
|
|
332
884
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
|
|
336
904
|
private static isProductData(item: VectorMatch): boolean {
|
|
337
905
|
const content = (item.content || '').toLowerCase();
|
|
338
906
|
const productKeywords = [
|
|
339
|
-
'product', 'price', 'stock', 'item', 'sku', 'brand', 'model', 'msrp',
|
|
340
|
-
'
|
|
907
|
+
'product', 'price', 'stock', 'item', 'sku', 'brand', 'model', 'msrp',
|
|
908
|
+
'inventory', 'buy', 'shop', 'beauty', 'care', 'cosmetic', 'facial',
|
|
909
|
+
'cream', 'serum', 'mask', 'makeup', 'fragrance',
|
|
341
910
|
];
|
|
342
|
-
|
|
343
|
-
// Check content for keywords
|
|
344
911
|
const hasKeywords = productKeywords.some(kw => content.includes(kw));
|
|
345
912
|
|
|
346
|
-
|
|
347
|
-
const hasMetadataKey = Object.keys(
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
+
});
|
|
353
921
|
|
|
922
|
+
const hasPricePattern = /\$\s*\d+\.\d{2}/.test(content);
|
|
354
923
|
return hasKeywords || hasMetadataKey || hasPricePattern;
|
|
355
924
|
}
|
|
356
925
|
|
|
357
|
-
/**
|
|
358
|
-
* Helper: Check if data contains time series
|
|
359
|
-
*/
|
|
360
926
|
private static isTimeSeriesData(item: VectorMatch): boolean {
|
|
361
|
-
const content = (item.content || '').toLowerCase();
|
|
362
|
-
const timeKeywords = ['trend', 'historical', 'growth', 'decline', 'change', 'increase', 'decrease'];
|
|
363
|
-
const hasTimeKeyword = timeKeywords.some(kw => content.includes(kw));
|
|
364
|
-
|
|
365
927
|
const metadata = item.metadata || {};
|
|
366
|
-
const maybeDateKeys = Object.keys(metadata).filter(
|
|
367
|
-
['date', 'timestamp', 'time', 'period'].includes(k.toLowerCase())
|
|
928
|
+
const maybeDateKeys = Object.keys(metadata).filter(k =>
|
|
929
|
+
['date', 'timestamp', 'time', 'period'].includes(k.toLowerCase()),
|
|
368
930
|
);
|
|
369
|
-
|
|
931
|
+
return maybeDateKeys.some(key => {
|
|
370
932
|
const value = metadata[key];
|
|
371
|
-
if (typeof value === 'string')
|
|
372
|
-
return !Number.isNaN(Date.parse(value)) && value.trim().length > 0;
|
|
373
|
-
}
|
|
933
|
+
if (typeof value === 'string') return !Number.isNaN(Date.parse(value)) && value.trim().length > 0;
|
|
374
934
|
return typeof value === 'number';
|
|
375
935
|
});
|
|
936
|
+
}
|
|
376
937
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
return normalized.includes('pie chart') ||
|
|
383
|
-
normalized.includes('piechart') ||
|
|
384
|
-
normalized.includes('bar chart') ||
|
|
385
|
-
normalized.includes('barchart') ||
|
|
386
|
-
normalized.includes('radar chart');
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
private static shouldShowCategoryChart(query: string): boolean {
|
|
390
|
-
const normalized = query.toLowerCase();
|
|
391
|
-
const chartKeywords = [
|
|
392
|
-
'distribution',
|
|
393
|
-
'breakdown',
|
|
394
|
-
'by category',
|
|
395
|
-
'by type',
|
|
396
|
-
'compare',
|
|
397
|
-
'share',
|
|
398
|
-
'percentage',
|
|
399
|
-
'segmentation',
|
|
400
|
-
'split',
|
|
401
|
-
'category breakdown',
|
|
402
|
-
'category distribution',
|
|
403
|
-
'pie chart',
|
|
404
|
-
'piechart',
|
|
405
|
-
'bar chart',
|
|
406
|
-
'barchart'
|
|
407
|
-
];
|
|
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
|
+
}
|
|
408
943
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
'day',
|
|
426
|
-
'comparison',
|
|
427
|
-
'compare',
|
|
428
|
-
'changes',
|
|
429
|
-
'timeline',
|
|
430
|
-
];
|
|
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
|
+
});
|
|
431
960
|
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
});
|
|
434
975
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
);
|
|
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
|
+
};
|
|
444
984
|
}
|
|
445
985
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
config?: import('../config/RagConfig').RagConfig,
|
|
453
|
-
trainedSchema?: import('./SchemaMapper').SchemaMap
|
|
454
|
-
): unknown {
|
|
455
|
-
if (!meta) return undefined;
|
|
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;
|
|
991
|
+
}
|
|
456
992
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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';
|
|
461
1003
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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';
|
|
466
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
|
+
}
|
|
467
1019
|
|
|
468
|
-
|
|
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));
|
|
469
1024
|
}
|
|
470
1025
|
|
|
471
|
-
private static
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
):
|
|
476
|
-
|
|
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
|
+
}
|
|
477
1049
|
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
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
|
+
}
|
|
481
1057
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
let finalName = name ? String(name) : undefined;
|
|
486
|
-
if (!finalName) {
|
|
487
|
-
const nameMatch = item.content.match(/(?:Product|Item|Name):\s*([^\n]+)/i);
|
|
488
|
-
finalName = nameMatch ? nameMatch[1].trim() : item.content.split('\n')[0].substring(0, 60);
|
|
489
|
-
}
|
|
1058
|
+
private static selectNumericField(profile: DataProfile, query: string): FieldProfile | undefined {
|
|
1059
|
+
return this.rankFieldsByQuery(profile.numericFields, query)[0] ?? profile.numericFields[0];
|
|
1060
|
+
}
|
|
490
1061
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
: 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
|
+
}
|
|
496
1066
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
+
}
|
|
501
1081
|
|
|
502
|
-
|
|
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
|
+
}
|
|
503
1089
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
};
|
|
513
|
-
}
|
|
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
|
+
}
|
|
514
1098
|
|
|
515
|
-
|
|
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;
|
|
516
1111
|
}
|
|
517
1112
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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];
|
|
535
1146
|
}
|
|
1147
|
+
case 'sum':
|
|
1148
|
+
default:
|
|
1149
|
+
return values.reduce((sum, value) => sum + value, 0);
|
|
536
1150
|
}
|
|
1151
|
+
}
|
|
537
1152
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
+
}
|
|
544
1161
|
|
|
545
|
-
|
|
1162
|
+
private static formatNumber(value: number): string {
|
|
1163
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
546
1164
|
}
|
|
547
1165
|
|
|
548
|
-
|
|
549
|
-
* Helper: Detect grouping values dynamically
|
|
550
|
-
*/
|
|
551
|
-
private static detectCategories(data: VectorMatch[], groupByField: string): string[] {
|
|
1166
|
+
private static detectCategories(data: VectorMatch[]): string[] {
|
|
552
1167
|
const categories = new Set<string>();
|
|
553
|
-
|
|
554
1168
|
data.forEach(item => {
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
if (meta[groupByField] !== undefined) {
|
|
558
|
-
const val = meta[groupByField];
|
|
559
|
-
if (Array.isArray(val)) val.forEach(v => categories.add(String(v)));
|
|
560
|
-
else categories.add(String(val));
|
|
561
|
-
} else if (groupByField === 'category') {
|
|
562
|
-
// Fallbacks for legacy 'category' extraction
|
|
563
|
-
if (meta.type) categories.add(String(meta.type));
|
|
564
|
-
if (meta.tag) {
|
|
565
|
-
const tags = Array.isArray(meta.tag) ? meta.tag : [meta.tag];
|
|
566
|
-
tags.forEach(t => categories.add(String(t)));
|
|
567
|
-
}
|
|
568
|
-
const categoryMatch = item.content.match(/(?:Category|Type|Class):\s*([^\n]+)/i);
|
|
569
|
-
if (categoryMatch) categories.add(categoryMatch[1].trim());
|
|
570
|
-
}
|
|
1169
|
+
const category = this.getProductCategory(item);
|
|
1170
|
+
if (category) categories.add(category);
|
|
571
1171
|
});
|
|
572
|
-
|
|
573
1172
|
return Array.from(categories);
|
|
574
1173
|
}
|
|
575
1174
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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();
|
|
1179
|
+
|
|
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
|
+
}
|
|
1187
|
+
|
|
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);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
579
1198
|
private static aggregateByCategory(
|
|
580
1199
|
data: VectorMatch[],
|
|
581
1200
|
categories: string[],
|
|
582
|
-
groupByField: string
|
|
583
1201
|
): Record<string, number> {
|
|
584
|
-
const result: Record<string, number> =
|
|
585
|
-
|
|
586
|
-
categories.forEach(cat => {
|
|
587
|
-
result[cat] = 0;
|
|
588
|
-
});
|
|
589
|
-
|
|
1202
|
+
const result: Record<string, number> = Object.fromEntries(categories.map(c => [c, 0]));
|
|
590
1203
|
data.forEach(item => {
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
if (meta[groupByField] !== undefined) {
|
|
595
|
-
itemCategory = String(meta[groupByField]);
|
|
596
|
-
} else if (groupByField === 'category' && meta.type) {
|
|
597
|
-
itemCategory = String(meta.type);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
if (Object.prototype.hasOwnProperty.call(result, itemCategory)) {
|
|
601
|
-
result[itemCategory]++;
|
|
602
|
-
} else {
|
|
603
|
-
result['Other'] = (result['Other'] || 0) + 1;
|
|
604
|
-
}
|
|
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;
|
|
605
1207
|
});
|
|
606
|
-
|
|
607
1208
|
return result;
|
|
608
1209
|
}
|
|
609
1210
|
|
|
610
|
-
/**
|
|
611
|
-
* Helper: Extract time series data
|
|
612
|
-
*/
|
|
613
1211
|
private static extractTimeSeriesData(
|
|
614
|
-
data: VectorMatch[]
|
|
1212
|
+
data: VectorMatch[],
|
|
615
1213
|
): Array<{ timestamp: string | number; value: number; label?: string }> {
|
|
616
1214
|
return data.map(item => {
|
|
617
1215
|
const meta = item.metadata || {};
|
|
618
1216
|
return {
|
|
619
|
-
timestamp: (meta.timestamp
|
|
620
|
-
value: (meta.value
|
|
621
|
-
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,
|
|
622
1220
|
};
|
|
623
1221
|
});
|
|
624
1222
|
}
|
|
625
1223
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
+
};
|
|
632
1310
|
|
|
633
1311
|
data.forEach(item => {
|
|
634
|
-
Object.keys(item.metadata || {}).forEach(
|
|
635
|
-
|
|
636
|
-
|
|
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
|
+
}
|
|
637
1316
|
});
|
|
638
1317
|
|
|
639
|
-
return Array.from(
|
|
1318
|
+
return Array.from(fields.values());
|
|
640
1319
|
}
|
|
641
1320
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
|
|
646
1353
|
const meta = item.metadata || {};
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
+
}
|
|
651
1362
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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 ?? '';
|
|
656
1371
|
}
|
|
657
1372
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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 } {
|
|
662
1427
|
let inStock = 0;
|
|
663
1428
|
let outOfStock = 0;
|
|
664
|
-
|
|
665
1429
|
data.forEach(d => {
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
itemCategory = String(meta.type);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
if (itemCategory === category) {
|
|
675
|
-
if (this.determineStockStatus(d)) {
|
|
676
|
-
inStock++;
|
|
677
|
-
} else {
|
|
678
|
-
outOfStock++;
|
|
679
|
-
}
|
|
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;
|
|
680
1435
|
}
|
|
681
1436
|
});
|
|
682
|
-
|
|
683
1437
|
return { inStockCount: inStock, outOfStockCount: outOfStock };
|
|
684
1438
|
}
|
|
685
1439
|
|
|
686
|
-
/**
|
|
687
|
-
* Helper: Determine if item is in stock
|
|
688
|
-
*/
|
|
689
1440
|
private static determineStockStatus(item: VectorMatch): boolean {
|
|
690
1441
|
const meta = item.metadata || {};
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
699
|
-
if (meta.available !== undefined) {
|
|
700
|
-
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;
|
|
701
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);
|
|
702
1453
|
|
|
703
|
-
// Check content
|
|
704
1454
|
const content = (item.content || '').toLowerCase();
|
|
705
|
-
if (
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
if (content.includes('in stock') || content.includes('available')) {
|
|
709
|
-
return true;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// 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;
|
|
713
1457
|
return true;
|
|
714
1458
|
}
|
|
715
1459
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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;
|
|
721
1465
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
});
|
|
726
|
-
});
|
|
1466
|
+
const content = item.content || '';
|
|
1467
|
+
const stockMatch = content.match(/\b(?:stock|inventory|quantity|count)\s*[:\-–]?\s*([\d,]+)/i);
|
|
1468
|
+
if (!stockMatch) return null;
|
|
727
1469
|
|
|
728
|
-
return
|
|
1470
|
+
return this.toFiniteNumber(stockMatch[1]);
|
|
729
1471
|
}
|
|
730
1472
|
|
|
731
|
-
|
|
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
|
+
}
|
|
732
1478
|
|
|
733
|
-
|
|
734
|
-
* analyzeAndDecide — sends user question + RAG data to the LLM with a
|
|
735
|
-
* structured system prompt and parses the JSON response into a
|
|
736
|
-
* UITransformationResponse.
|
|
737
|
-
*
|
|
738
|
-
* This is the recommended entry point for production use. The heuristic
|
|
739
|
-
* `transform()` method is used as a fallback if the LLM call fails.
|
|
740
|
-
*
|
|
741
|
-
* System prompt instructs the LLM to:
|
|
742
|
-
* - Analyze the question and retrieved data
|
|
743
|
-
* - Choose the best visualization: bar_chart | line_chart | pie_chart | table | text
|
|
744
|
-
* - Return a strict JSON object — no prose, no markdown fences
|
|
745
|
-
*
|
|
746
|
-
* @param query - the original user question
|
|
747
|
-
* @param sources - vector DB matches returned by RAG retrieval
|
|
748
|
-
* @param llm - any ILLMProvider instance (OpenAI, Anthropic, Ollama, Gemini, REST…)
|
|
749
|
-
* @returns - a validated UITransformationResponse (type + title + description + data)
|
|
750
|
-
*/
|
|
751
|
-
static async analyzeAndDecide(
|
|
752
|
-
query: string,
|
|
753
|
-
sources: VectorMatch[],
|
|
754
|
-
llm: ILLMProvider,
|
|
755
|
-
): Promise<UITransformationResponse> {
|
|
756
|
-
try {
|
|
757
|
-
const context = this.buildContextSummary(sources);
|
|
758
|
-
const systemPrompt = this.buildVisualizationSystemPrompt();
|
|
1479
|
+
// ─── Product Extraction ───────────────────────────────────────────────────
|
|
759
1480
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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;
|
|
766
1488
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
'',
|
|
770
|
-
{ systemPrompt, temperature: 0 },
|
|
771
|
-
);
|
|
1489
|
+
const mapping = config?.rag?.uiMapping;
|
|
1490
|
+
if (mapping?.[uiKey] && meta[mapping[uiKey]] !== undefined) return meta[mapping[uiKey]];
|
|
772
1491
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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);
|
|
777
1519
|
}
|
|
778
1520
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
+
};
|
|
782
1541
|
}
|
|
783
1542
|
|
|
784
|
-
|
|
785
|
-
return this.transform(query, sources);
|
|
1543
|
+
return null;
|
|
786
1544
|
}
|
|
787
1545
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
+
|
|
791
1612
|
private static buildVisualizationSystemPrompt(): string {
|
|
792
1613
|
return `You are a data visualization expert embedded in a RAG chat system.
|
|
793
|
-
You will receive a user question and structured data
|
|
794
|
-
Your ONLY job is to
|
|
795
|
-
|
|
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.
|
|
796
1625
|
|
|
797
|
-
Return ONLY a valid JSON object — no markdown code fences, no explanation, no prose.
|
|
798
|
-
|
|
799
|
-
The JSON must have this exact shape:
|
|
800
1626
|
{
|
|
801
|
-
"type": "bar_chart" | "line_chart" | "pie_chart" | "radar_chart" | "table" | "text",
|
|
802
|
-
"title": "
|
|
803
|
-
"description": "
|
|
804
|
-
"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>
|
|
805
1631
|
}
|
|
806
1632
|
|
|
807
|
-
DATA
|
|
808
|
-
- bar_chart:
|
|
809
|
-
-
|
|
810
|
-
-
|
|
811
|
-
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
5. radar_chart → comparing 2 or more products across MULTIPLE attributes.
|
|
827
|
-
6. table → multi-field structured records where each item has ≥ 3 attributes
|
|
828
|
-
7. text → conversational or free-form answers where no chart adds value
|
|
829
|
-
|
|
830
|
-
IMPORTANT:
|
|
831
|
-
- Aggregate numeric values from the raw data — do not pass raw object arrays as data.
|
|
832
|
-
- Ensure all "value" fields are numbers, not strings.
|
|
833
|
-
- For bar/line/pie, keep at most 12 data points for readability.
|
|
834
|
-
- 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."`;
|
|
835
1652
|
}
|
|
836
1653
|
|
|
837
|
-
/**
|
|
838
|
-
* Serialize retrieved vector matches into a compact JSON context string.
|
|
839
|
-
* Limits the total character count to avoid exceeding LLM context windows.
|
|
840
|
-
*/
|
|
841
1654
|
private static buildContextSummary(sources: VectorMatch[], maxChars = 6000): string {
|
|
842
1655
|
const items = sources.map((s, i) => ({
|
|
843
1656
|
index: i + 1,
|
|
@@ -849,9 +1662,8 @@ IMPORTANT:
|
|
|
849
1662
|
const full = JSON.stringify(items, null, 2);
|
|
850
1663
|
if (full.length <= maxChars) return full;
|
|
851
1664
|
|
|
852
|
-
// Truncate: include as many items as fit
|
|
853
1665
|
const partial: typeof items = [];
|
|
854
|
-
let chars = 2;
|
|
1666
|
+
let chars = 2;
|
|
855
1667
|
for (const item of items) {
|
|
856
1668
|
const chunk = JSON.stringify(item);
|
|
857
1669
|
if (chars + chunk.length + 2 > maxChars) break;
|
|
@@ -861,4 +1673,3 @@ IMPORTANT:
|
|
|
861
1673
|
return JSON.stringify(partial, null, 2) + '\n// ... truncated';
|
|
862
1674
|
}
|
|
863
1675
|
}
|
|
864
|
-
|