@retrivora-ai/rag-engine 1.8.9 → 1.9.1

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