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