@retrivora-ai/rag-engine 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -0
  2. package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -0
  3. package/dist/handlers/index.d.mts +2 -2
  4. package/dist/handlers/index.d.ts +2 -2
  5. package/dist/handlers/index.js +1719 -467
  6. package/dist/handlers/index.mjs +1718 -466
  7. package/dist/{index-BwpcaziY.d.ts → index-A0GqPsaG.d.ts} +1 -1
  8. package/dist/{index-D3V9Et2M.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 +1726 -671
  17. package/dist/server.mjs +1724 -669
  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 -0
  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 -2
  35. package/src/providers/vectordb/MilvusProvider.ts +18 -2
  36. package/src/providers/vectordb/MultiTablePostgresProvider.ts +12 -12
  37. package/src/providers/vectordb/PostgreSQLProvider.ts +1 -1
  38. package/src/providers/vectordb/QdrantProvider.ts +1 -1
  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 +1348 -489
  44. package/src/utils/synonyms.ts +2 -0
@@ -1,77 +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 categories = this.detectCategories(filteredData);
136
+ const profile = this.profileData(filteredData);
33
137
  const hasProducts = filteredData.some(item => this.isProductData(item));
34
- const isTimeSeries = filteredData.some(item => this.isTimeSeriesData(item));
35
- const isTrendQuery = this.isTrendQuery(userQuery);
138
+ const wantsPieLikeChart = ['pie_chart', 'donut_chart'].includes(resolvedIntent.recommendedChart);
36
139
 
37
- // 1. High Priority: Specific visualizations for trends
38
- if (isTrendQuery && isTimeSeries) {
39
- return this.transformToLineChart(filteredData);
140
+ // Priority 1 Explicit trends
141
+ if (resolvedIntent.visualizationHint === 'trend' && profile.dateFields.length > 0 && profile.numericFields.length > 0) {
142
+ return this.transformToLineChart(profile);
40
143
  }
41
144
 
42
- // 2. High Priority: Products (Always prefer carousel for products unless it's a specific breakdown request)
43
- if (hasProducts && !this.shouldShowCategoryChart(userQuery, categories)) {
44
- return this.transformToProductCarousel(filteredData, config, trainedSchema);
145
+ // Priority 2 Explicit pie/donut or composition/category distribution
146
+ if (wantsPieLikeChart || ['composition', 'category_breakdown'].includes(resolvedIntent.visualizationHint)) {
147
+ const pieChart = this.transformToPieChart(filteredData, profile, userQuery);
148
+ if (pieChart) return pieChart;
45
149
  }
46
150
 
47
- // 3. Category breakdowns
48
- if (categories.length > 1 && this.shouldShowCategoryChart(userQuery, categories)) {
49
- return this.transformToPieChart(filteredData);
151
+ // Priority 3 Comparison/ranking → bar chart
152
+ if (['comparison', 'ranking'].includes(resolvedIntent.visualizationHint)) {
153
+ return this.transformToBarChart(filteredData, profile, userQuery, resolvedIntent.visualizationHint === 'ranking');
50
154
  }
51
155
 
52
- // 4. Secondary: Products as carousel if not already handled
53
- if (hasProducts) {
54
- 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);
159
+ }
160
+
161
+ if (resolvedIntent.visualizationHint === 'correlation') {
162
+ return this.transformToScatterPlot(profile, userQuery) ?? this.transformToBarChart(filteredData, profile, userQuery);
163
+ }
164
+
165
+ if (resolvedIntent.visualizationHint === 'geographic') {
166
+ return this.transformToBarChart(filteredData, profile, userQuery);
167
+ }
168
+
169
+ // Priority 5 – Structured list/filter questions should stay tabular even if
170
+ // source metadata happens to contain product-like fields such as category.
171
+ // This must run before KPI detection because fields such as "employee count"
172
+ // contain the word "count" but still describe records to list/filter.
173
+ if (this.isStructuredListQuery(userQuery)) {
174
+ return this.hasMultipleFields(filteredData)
175
+ ? this.transformToTable(filteredData, userQuery)
176
+ : this.transformToText(filteredData);
177
+ }
178
+
179
+ if (resolvedIntent.visualizationHint === 'kpi') {
180
+ return this.transformToMetricCard(profile, userQuery) ?? this.transformToText(filteredData);
181
+ }
182
+
183
+ // Priority 6 – Explicit tabular/detail request
184
+ if (
185
+ ['tabular', 'table'].includes(resolvedIntent.visualizationHint) ||
186
+ resolvedIntent.wantsExplicitTable
187
+ ) {
188
+ return this.hasMultipleFields(filteredData)
189
+ ? this.transformToTable(filteredData, userQuery)
190
+ : this.transformToText(filteredData);
55
191
  }
56
192
 
57
- // 5. Detailed data without clear product structure -> Table
58
- if (this.hasMultipleFields(filteredData)) {
59
- return this.transformToTable(filteredData);
193
+ // Priority 7 Product carousel only for explicit product intent.
194
+ if ((hasProducts || this.isProductQuery(userQuery)) && resolvedIntent.visualizationHint === 'product_browse') {
195
+ return this.transformToProductCarousel(filteredData, config, trainedSchema);
60
196
  }
61
197
 
198
+ const automatic = this.chooseAutomaticVisualization(filteredData, profile, userQuery);
199
+ if (automatic) return automatic;
200
+
201
+ // Fallback – plain text
62
202
  return this.transformToText(filteredData);
63
203
  }
64
204
 
65
205
  /**
66
- * 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.
67
396
  */
397
+ static detectIntentHeuristic(query: string): QueryIntent {
398
+ const q = query.toLowerCase();
399
+
400
+ const isTemporal = /\b(trend|trends|over time|historical|history|growth|decline|monthly|yearly|weekly|daily|last (year|month|week)|timeline|forecast)\b/.test(q);
401
+
402
+ const isRanking = /\b(highest|lowest|top\s*\d+|bottom\s*\d+|rank(?:ing)?|best|worst|leading)\b/.test(q);
403
+
404
+ const isComparison = /\b(compare|comparison|vs\.?|versus|against|differ|difference|contrast|top)\b/.test(q) || isRanking;
405
+
406
+ const isDistribution = /\b(distribution|spread|histogram|frequency|variance|range)\b/.test(q);
407
+
408
+ const wantsPieLikeChart = /\b(pie|donut|doughnut)(?:\s+chart)?\b/.test(q);
409
+
410
+ const isComposition = wantsPieLikeChart || /\b(share|percentage|percent|breakup|breakdown|composition|split|segmentation|by category|by type|proportion)\b/.test(q);
411
+
412
+ const isCorrelation = /\b(relation|relationship|correlation|correlate|scatter|association|impact of|depend(?:s|ence)? on)\b/.test(q);
413
+
414
+ const isKpi = /\b(total|average|avg|count|sum|median|minimum|maximum|metric|kpi|how many|number of)\b/.test(q);
415
+
416
+ const isGeographic = /\b(region|country|countries|state|city|location|map|geo|geographic|territory)\b/.test(q);
417
+
418
+ const filterInStockOnly = /\b(in[- ]?stock|available|availability|inventory|stock status)\b/.test(q);
419
+
420
+ const wantsExplicitTable = /\b(table|spreadsheet|grid|detailed records|record details|list all|compare all)\b/.test(q);
421
+
422
+ let visualizationHint: QueryIntent['visualizationHint'] = 'text';
423
+ if (wantsExplicitTable) visualizationHint = 'tabular';
424
+ else if (isTemporal) visualizationHint = 'trend';
425
+ else if (wantsPieLikeChart) visualizationHint = 'composition';
426
+ else if (isRanking) visualizationHint = 'ranking';
427
+ else if (isComparison) visualizationHint = 'comparison';
428
+ else if (isDistribution) visualizationHint = 'distribution';
429
+ else if (isComposition) visualizationHint = 'composition';
430
+ else if (isCorrelation) visualizationHint = 'correlation';
431
+ else if (isGeographic) visualizationHint = 'geographic';
432
+ else if (isKpi) visualizationHint = 'kpi';
433
+ else if (this.isProductQuery(query)) visualizationHint = 'product_browse';
434
+
435
+ return {
436
+ visualizationHint,
437
+ recommendedChart: this.getRecommendedChartForIntent(visualizationHint),
438
+ filterInStockOnly,
439
+ wantsExplicitTable,
440
+ isTemporal,
441
+ isComparison,
442
+ language: 'en', // heuristic cannot reliably detect language
443
+ };
444
+ }
445
+
446
+ private static getRecommendedChartForIntent(
447
+ intent: QueryIntent['visualizationHint'],
448
+ ): QueryIntent['recommendedChart'] {
449
+ switch (intent) {
450
+ case 'trend':
451
+ return 'line_chart';
452
+ case 'comparison':
453
+ return 'bar_chart';
454
+ case 'distribution':
455
+ return 'histogram';
456
+ case 'composition':
457
+ case 'category_breakdown':
458
+ return 'pie_chart';
459
+ case 'correlation':
460
+ return 'scatter_plot';
461
+ case 'ranking':
462
+ return 'horizontal_bar';
463
+ case 'kpi':
464
+ return 'metric_card';
465
+ case 'tabular':
466
+ case 'table':
467
+ return 'table';
468
+ case 'geographic':
469
+ return 'geo_map';
470
+ case 'product_browse':
471
+ case 'text':
472
+ default:
473
+ return 'text';
474
+ }
475
+ }
476
+
477
+ // ─── Transform Helpers ────────────────────────────────────────────────────
478
+
68
479
  private static transformToProductCarousel(
69
480
  data: VectorMatch[],
70
481
  config?: import('../config/RagConfig').RagConfig,
71
- trainedSchema?: import('./SchemaMapper').SchemaMap
482
+ trainedSchema?: import('./SchemaMapper').SchemaMap,
72
483
  ): UITransformationResponse {
73
484
  const products: CarouselProduct[] = data
74
- .filter(item => this.isProductData(item))
75
485
  .map(item => this.extractProductInfo(item, config, trainedSchema))
76
486
  .filter((p): p is CarouselProduct => p !== null);
77
487
 
@@ -83,64 +493,214 @@ export class UITransformer {
83
493
  };
84
494
  }
85
495
 
86
- /**
87
- * Transform data to pie chart format
88
- */
89
496
  private static transformToPieChart(
90
- data: VectorMatch[]
91
- ): UITransformationResponse {
92
- const categories = this.detectCategories(data);
93
- const categoryData = this.aggregateByCategory(data, categories);
497
+ data: VectorMatch[],
498
+ profile?: DataProfile,
499
+ query = '',
500
+ ): UITransformationResponse | null {
501
+ const dimension = profile ? this.selectDimensionField(profile, query) : undefined;
502
+ const categories = dimension
503
+ ? Array.from(new Set(profile!.records.map(record => String(record.fields[dimension.key] ?? '')).filter(Boolean)))
504
+ : this.detectCategories(data);
505
+ if (categories.length === 0) return null;
506
+
507
+ const categoryData = dimension && profile
508
+ ? this.aggregateProfileByDimension(profile, dimension.key)
509
+ : this.aggregateByCategory(data, categories);
94
510
 
95
511
  const pieData: PieChartData[] = Object.entries(categoryData).map(([label, count]) => {
96
512
  const { inStockCount, outOfStockCount } = this.calculateStockCounts(label, data);
97
- return {
98
- label,
99
- value: count as number,
100
- inStockCount,
101
- outOfStockCount,
102
- };
513
+ return { label, value: count as number, inStockCount, outOfStockCount };
103
514
  });
104
515
 
105
516
  return {
106
517
  type: 'pie_chart',
107
- title: 'Distribution by Category',
108
- description: `Showing breakdown across ${categories.length} categories`,
518
+ title: `Distribution by ${dimension?.label ?? 'Category'}`,
519
+ description: `Showing breakdown across ${pieData.length} categories`,
109
520
  data: pieData,
110
521
  };
111
522
  }
112
523
 
113
- /**
114
- * Transform data to line chart format
115
- */
116
- private static transformToLineChart(data: VectorMatch[]): UITransformationResponse {
117
- const timePoints = this.extractTimeSeriesData(data);
524
+ private static transformToLineChart(profile: DataProfile): UITransformationResponse {
525
+ const dateField = profile.dateFields[0];
526
+ const valueField = profile.numericFields[0];
527
+ const buckets = new Map<string, number>();
118
528
 
119
- const lineData: LineChartDataPoint[] = timePoints.map(point => ({
120
- timestamp: point.timestamp,
121
- value: point.value,
122
- label: point.label,
123
- }));
529
+ profile.records.forEach(record => {
530
+ const timestamp = String(record.fields[dateField.key] ?? '');
531
+ const value = this.toFiniteNumber(record.fields[valueField.key]) ?? 0;
532
+ buckets.set(timestamp, (buckets.get(timestamp) ?? 0) + value);
533
+ });
534
+
535
+ const lineData: LineChartDataPoint[] = Array.from(buckets.entries())
536
+ .sort(([a], [b]) => Date.parse(a) - Date.parse(b))
537
+ .slice(0, 24)
538
+ .map(([timestamp, value]) => ({ timestamp, value, label: timestamp }));
124
539
 
125
540
  return {
126
541
  type: 'line_chart',
127
- title: 'Trend Over Time',
542
+ title: `${valueField.label} Over Time`,
128
543
  description: `Showing ${lineData.length} data points`,
129
544
  data: lineData,
130
545
  };
131
546
  }
132
547
 
133
- /**
134
- * Transform data to table format
135
- */
136
- private static transformToTable(data: VectorMatch[]): UITransformationResponse {
137
- const columns = this.extractTableColumns(data);
138
- 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
+ });
686
+
687
+ const radarData = Object.entries(attributeMap).map(([attribute, series]) => ({
688
+ attribute,
689
+ ...series,
690
+ }));
139
691
 
140
- const tableData: TableData = {
141
- columns,
142
- rows,
692
+ return {
693
+ type: 'radar_chart' as VisualizationType,
694
+ title: 'Product Comparison',
695
+ description: `Comparing ${data.length} items across ${radarData.length} attributes`,
696
+ data: radarData.length > 0 ? radarData : data.map(d => ({ attribute: d.content.substring(0, 40) })),
143
697
  };
698
+ }
699
+
700
+ private static transformToTable(data: VectorMatch[], query = ''): UITransformationResponse {
701
+ const columns = this.extractTableColumns(data, query);
702
+ const rows = data.map(item => this.extractTableRow(item, columns));
703
+ const tableData: TableData = { columns, rows };
144
704
 
145
705
  return {
146
706
  type: 'table',
@@ -150,37 +710,24 @@ export class UITransformer {
150
710
  };
151
711
  }
152
712
 
153
- /**
154
- * Transform data to text format (fallback)
155
- */
156
713
  private static transformToText(data: VectorMatch[]): UITransformationResponse {
157
- const textContent = data
158
- .map(item => item.content)
159
- .join('\n\n');
160
-
161
714
  return this.createTextResponse(
162
715
  'Search Results',
163
- textContent,
164
- `Found ${data.length} relevant results`
716
+ data.map(item => item.content).join('\n\n'),
717
+ `Found ${data.length} relevant results`,
165
718
  );
166
719
  }
167
720
 
168
- /**
169
- * Helper: Create text response
170
- */
171
721
  private static createTextResponse(
172
722
  title: string,
173
723
  content: string,
174
- description?: string
724
+ description?: string,
175
725
  ): UITransformationResponse {
176
- return {
177
- type: 'text',
178
- title,
179
- description,
180
- data: { content },
181
- };
726
+ return { type: 'text', title, description, data: { content } };
182
727
  }
183
728
 
729
+ // ─── LLM Response Parsing ─────────────────────────────────────────────────
730
+
184
731
  static parseTransformationResponse(raw: string): UITransformationResponse | null {
185
732
  const payloadText = this.extractJsonCandidate(raw);
186
733
  if (!payloadText) return null;
@@ -210,26 +757,14 @@ export class UITransformer {
210
757
 
211
758
  for (let i = start; i < cleaned.length; i += 1) {
212
759
  const char = cleaned[i];
213
- if (escape) {
214
- escape = false;
215
- continue;
216
- }
217
- if (char === '\\') {
218
- escape = true;
219
- continue;
220
- }
221
- if (char === '"') {
222
- inString = !inString;
223
- continue;
224
- }
760
+ if (escape) { escape = false; continue; }
761
+ if (char === '\\') { escape = true; continue; }
762
+ if (char === '"') { inString = !inString; continue; }
225
763
  if (inString) continue;
226
-
227
764
  if (char === '{') depth += 1;
228
765
  if (char === '}') {
229
766
  depth -= 1;
230
- if (depth === 0) {
231
- return cleaned.slice(start, i + 1);
232
- }
767
+ if (depth === 0) return cleaned.slice(start, i + 1);
233
768
  }
234
769
  }
235
770
 
@@ -239,25 +774,20 @@ export class UITransformer {
239
774
  private static normalizeTransformation(payload: unknown): UITransformationResponse | null {
240
775
  if (!payload || typeof payload !== 'object') return null;
241
776
 
242
- const payloadObj = payload as Record<string, unknown>;
243
- 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
+ );
244
781
  if (!type) return null;
245
782
 
246
- const title = String(payloadObj.title ?? payloadObj.heading ?? 'Visualization');
247
- const description = payloadObj.description ? String(payloadObj.description) : undefined;
248
- const rawData = payloadObj.data ?? payloadObj.table ?? payloadObj.rows ?? payloadObj.items ?? payloadObj.content ?? null;
249
-
783
+ const title = String(p.title ?? p.heading ?? 'Visualization');
784
+ const description = p.description ? String(p.description) : undefined;
785
+ const rawData = p.data ?? p.table ?? p.rows ?? p.items ?? p.content ?? null;
250
786
  const data = type === 'text' && typeof rawData === 'string'
251
787
  ? { content: rawData }
252
788
  : rawData;
253
789
 
254
- const transformation: UITransformationResponse = {
255
- type,
256
- title,
257
- description,
258
- data,
259
- };
260
-
790
+ const transformation: UITransformationResponse = { type, title, description, data };
261
791
  return this.validateTransformation(transformation) ? transformation : null;
262
792
  }
263
793
 
@@ -267,529 +797,860 @@ export class UITransformer {
267
797
  pie_chart: 'pie_chart',
268
798
  bar: 'bar_chart',
269
799
  bar_chart: 'bar_chart',
800
+ histogram: 'histogram',
801
+ horizontal_bar: 'horizontal_bar',
802
+ horizontalbar: 'horizontal_bar',
270
803
  line: 'line_chart',
271
804
  line_chart: 'line_chart',
805
+ scatter: 'scatter_plot',
806
+ scatter_plot: 'scatter_plot',
807
+ scatterplot: 'scatter_plot',
272
808
  radar: 'radar_chart',
273
809
  radar_chart: 'radar_chart',
810
+ metric: 'metric_card',
811
+ metric_card: 'metric_card',
812
+ card: 'metric_card',
813
+ kpi: 'metric_card',
814
+ geo: 'geo_map',
815
+ geo_map: 'geo_map',
816
+ map: 'geo_map',
274
817
  table: 'table',
275
818
  text: 'text',
276
819
  product_carousel: 'product_carousel',
277
820
  carousel: 'carousel',
278
821
  };
279
-
280
822
  return mapping[type.toLowerCase()] ?? null;
281
823
  }
282
824
 
283
- private static validateTransformation(transformation: UITransformationResponse): boolean {
284
- const { type, data } = transformation;
285
-
825
+ private static validateTransformation(t: UITransformationResponse): boolean {
826
+ const { type, data } = t;
286
827
  switch (type) {
287
828
  case 'pie_chart':
288
829
  case 'bar_chart':
289
- return Array.isArray(data) && data.every((item): item is Record<string, unknown> =>
290
- item !== null && typeof item === 'object' &&
291
- (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))),
292
843
  );
293
844
  case 'line_chart':
294
- return Array.isArray(data) && data.every((item): item is Record<string, unknown> =>
295
- item !== null && typeof item === 'object' &&
296
- 'timestamp' in item &&
297
- (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))),
298
849
  );
850
+ case 'metric_card':
851
+ return (
852
+ typeof data === 'object' && data !== null &&
853
+ (typeof (data as Record<string, unknown>).value === 'number' ||
854
+ !Number.isNaN(Number((data as Record<string, unknown>).value)))
855
+ );
856
+ case 'geo_map':
857
+ return Array.isArray(data) || (typeof data === 'object' && data !== null);
299
858
  case 'radar_chart':
300
- return Array.isArray(data) && data.every((item): item is Record<string, unknown> =>
301
- item !== null && typeof item === 'object' &&
302
- '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,
303
862
  );
304
863
  case 'table':
305
864
  return (
306
- typeof data === 'object' &&
307
- data !== null &&
865
+ typeof data === 'object' && data !== null &&
308
866
  Array.isArray((data as Record<string, unknown>).columns) &&
309
867
  Array.isArray((data as Record<string, unknown>).rows)
310
868
  );
311
869
  case 'text':
312
870
  return (
313
- typeof data === 'object' &&
314
- data !== null &&
871
+ typeof data === 'object' && data !== null &&
315
872
  typeof (data as Record<string, unknown>).content === 'string'
316
873
  );
317
874
  case 'product_carousel':
318
875
  case 'carousel':
319
- return Array.isArray(data) && data.every((item): item is Record<string, unknown> =>
320
- 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),
321
879
  );
322
880
  default:
323
881
  return false;
324
882
  }
325
883
  }
326
884
 
327
- /**
328
- * Helper: Check if data item is product-related
329
- */
885
+ // ─── Data Inspection Helpers ──────────────────────────────────────────────
886
+
887
+ private static isStructuredListQuery(query: string): boolean {
888
+ const q = query.toLowerCase();
889
+ const asksForRecords = /\b(list|provide|show|give|get|find|which|whose|records?|details?)\b/.test(q);
890
+ const hasNumericPredicate = /\b(greater than|more than|above|over|less than|below|under|at least|at most|minimum|maximum|>=|<=|>|<|equals?|equal to)\b\s*\$?\d/i.test(q) ||
891
+ /\b\d+\b\s*(?:or more|or less|and above|and below)\b/i.test(q);
892
+ const hasEntityTerms = /\b(organization|organisations?|organizations?|company|companies|employee|employees|staff|headcount|workforce)\b/.test(q);
893
+ return asksForRecords && (hasNumericPredicate || hasEntityTerms);
894
+ }
895
+
896
+ private static isProductQuery(query: string): boolean {
897
+ const q = query.toLowerCase();
898
+ const productTerms = /\b(product|products|item|items|sku|catalog|catalogue|price|prices|brand|model|stock|inventory|in stock|out of stock|buy|shop)\b/.test(q);
899
+ const productAction = /\b(recommend|suggest|describe|description|detail|details|about|show|find|search|browse|list)\b/.test(q);
900
+ const nonProductEntityTerms = /\b(organization|organisations?|organizations?|company|companies|employee|employees|staff|headcount|workforce)\b/.test(q);
901
+ return productTerms && productAction && !nonProductEntityTerms;
902
+ }
903
+
330
904
  private static isProductData(item: VectorMatch): boolean {
331
905
  const content = (item.content || '').toLowerCase();
332
906
  const productKeywords = [
333
- 'product', 'price', 'stock', 'item', 'sku', 'brand', 'model', 'msrp', 'inventory', 'buy', 'shop',
334
- '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',
335
910
  ];
336
-
337
- // Check content for keywords
338
911
  const hasKeywords = productKeywords.some(kw => content.includes(kw));
339
912
 
340
- // Check metadata keys
341
- const hasMetadataKey = Object.keys(item.metadata || {}).some(k =>
342
- ['name', 'price', 'product', 'sku', 'brand', 'model', 'cost', 'item'].includes(k.toLowerCase())
343
- );
344
-
345
- // Failsafe: if we have a dollar sign followed by a number, it's likely a product
346
- 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
+ });
347
921
 
922
+ const hasPricePattern = /\$\s*\d+\.\d{2}/.test(content);
348
923
  return hasKeywords || hasMetadataKey || hasPricePattern;
349
924
  }
350
925
 
351
- /**
352
- * Helper: Check if data contains time series
353
- */
354
926
  private static isTimeSeriesData(item: VectorMatch): boolean {
355
- const content = (item.content || '').toLowerCase();
356
- const timeKeywords = ['trend', 'historical', 'growth', 'decline', 'change', 'increase', 'decrease'];
357
- const hasTimeKeyword = timeKeywords.some(kw => content.includes(kw));
358
-
359
927
  const metadata = item.metadata || {};
360
- const maybeDateKeys = Object.keys(metadata).filter((k) =>
361
- ['date', 'timestamp', 'time', 'period'].includes(k.toLowerCase())
928
+ const maybeDateKeys = Object.keys(metadata).filter(k =>
929
+ ['date', 'timestamp', 'time', 'period'].includes(k.toLowerCase()),
362
930
  );
363
- const hasValidDateValue = maybeDateKeys.some((key) => {
931
+ return maybeDateKeys.some(key => {
364
932
  const value = metadata[key];
365
- if (typeof value === 'string') {
366
- return !Number.isNaN(Date.parse(value)) && value.trim().length > 0;
367
- }
933
+ if (typeof value === 'string') return !Number.isNaN(Date.parse(value)) && value.trim().length > 0;
368
934
  return typeof value === 'number';
369
935
  });
370
-
371
- return hasTimeKeyword || hasValidDateValue;
372
936
  }
373
937
 
374
- private static shouldShowCategoryChart(query: string, categories: string[]): boolean {
375
- if (categories.length < 2) {
376
- return false;
377
- }
938
+ private static hasMultipleFields(data: VectorMatch[]): boolean {
939
+ const fieldCount = new Set<string>();
940
+ data.forEach(item => Object.keys(item.metadata || {}).forEach(k => fieldCount.add(k)));
941
+ return fieldCount.size > 2;
942
+ }
378
943
 
379
- const normalized = query.toLowerCase();
380
- const chartKeywords = [
381
- 'distribution',
382
- 'breakdown',
383
- 'by category',
384
- 'by type',
385
- 'compare',
386
- 'share',
387
- 'percentage',
388
- 'segmentation',
389
- 'split',
390
- 'category breakdown',
391
- 'category distribution',
392
- ];
944
+ private static profileData(data: VectorMatch[]): DataProfile {
945
+ const records = data.map((item): DataRecord => {
946
+ const fields: Record<string, PrimitiveValue> = {};
947
+ Object.entries(item.metadata || {}).forEach(([key, value]) => {
948
+ const primitive = this.toPrimitive(value);
949
+ if (primitive !== null) fields[key] = primitive;
950
+ });
951
+ if (!fields.content && item.content) fields.content = item.content.substring(0, 500);
952
+ return {
953
+ id: item.id,
954
+ content: item.content,
955
+ score: item.score,
956
+ fields,
957
+ source: item,
958
+ };
959
+ });
393
960
 
394
- return chartKeywords.some(keyword => normalized.includes(keyword));
395
- }
396
-
397
- private static isTrendQuery(query: string): boolean {
398
- const normalized = query.toLowerCase();
399
- const trendKeywords = [
400
- 'trend',
401
- 'over time',
402
- 'historical',
403
- 'growth',
404
- 'decline',
405
- 'increase',
406
- 'decrease',
407
- 'year',
408
- 'month',
409
- 'week',
410
- 'day',
411
- 'comparison',
412
- 'compare',
413
- 'changes',
414
- 'timeline',
415
- ];
961
+ const keys = Array.from(new Set(records.flatMap(record => Object.keys(record.fields))));
962
+ const fields = keys.map((key): FieldProfile => {
963
+ const values = records
964
+ .map(record => record.fields[key])
965
+ .filter((value): value is PrimitiveValue => value !== undefined && value !== null && String(value).trim() !== '');
966
+ const uniqueCount = new Set(values.map(value => String(value).toLowerCase())).size;
967
+ return {
968
+ key,
969
+ label: this.humanizeFieldName(key),
970
+ kind: this.inferFieldKind(key, values, records.length, uniqueCount),
971
+ values,
972
+ uniqueCount,
973
+ };
974
+ });
416
975
 
417
- return trendKeywords.some(keyword => normalized.includes(keyword));
976
+ return {
977
+ records,
978
+ fields,
979
+ numericFields: fields.filter(field => field.kind === 'number'),
980
+ dateFields: fields.filter(field => field.kind === 'date'),
981
+ categoricalFields: fields.filter(field => field.kind === 'category'),
982
+ booleanFields: fields.filter(field => field.kind === 'boolean'),
983
+ };
418
984
  }
419
985
 
420
- private static isStockQuery(query: string): boolean {
421
- const normalized = query.toLowerCase();
422
- return (
423
- normalized.includes('in stock') ||
424
- normalized.includes('available') ||
425
- normalized.includes('availability') ||
426
- normalized.includes('inventory') ||
427
- normalized.includes('stock status')
428
- );
986
+ private static toPrimitive(value: unknown): PrimitiveValue | null {
987
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
988
+ if (value === null || value === undefined) return null;
989
+ if (value instanceof Date) return value.toISOString();
990
+ return null;
429
991
  }
430
992
 
431
- /**
432
- * Helper: Extract property from metadata using mapping, AI training, case-insensitivity, and synonyms.
433
- */
434
- private static getDynamicVal(
435
- meta: Record<string, unknown> | undefined,
436
- uiKey: string,
437
- config?: import('../config/RagConfig').RagConfig,
438
- trainedSchema?: import('./SchemaMapper').SchemaMap
439
- ): unknown {
440
- if (!meta) return undefined;
441
-
442
- const mapping = config?.rag?.uiMapping;
443
- if (mapping && mapping[uiKey]) {
444
- const mappedKey = mapping[uiKey];
445
- if (meta[mappedKey] !== undefined) return meta[mappedKey];
993
+ private static inferFieldKind(
994
+ key: string,
995
+ values: PrimitiveValue[],
996
+ rowCount: number,
997
+ uniqueCount: number,
998
+ ): FieldProfile['kind'] {
999
+ const lowerKey = key.toLowerCase();
1000
+ if (values.length === 0) return 'text';
1001
+ if (values.every(value => typeof value === 'boolean' || /^(true|false|yes|no|in_stock|out_of_stock|available|unavailable)$/i.test(String(value)))) {
1002
+ return 'boolean';
446
1003
  }
447
-
448
- if (trainedSchema && typeof trainedSchema === 'object' && trainedSchema !== null) {
449
- const trainedKey = (trainedSchema as Record<string, unknown>)[uiKey];
450
- 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';
451
1010
  }
1011
+ if (
1012
+ uniqueCount <= Math.max(20, Math.ceil(rowCount * 0.7)) &&
1013
+ !/(id|sku|ean|upc|description|content|url|image|thumbnail)/i.test(lowerKey)
1014
+ ) {
1015
+ return 'category';
1016
+ }
1017
+ return 'text';
1018
+ }
452
1019
 
453
- 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));
454
1024
  }
455
1025
 
456
- private static extractProductInfo(
457
- item: VectorMatch,
458
- config?: import('../config/RagConfig').RagConfig,
459
- trainedSchema?: import('./SchemaMapper').SchemaMap
460
- ): CarouselProduct | null {
461
- 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
+ }
462
1049
 
463
- const name = this.getDynamicVal(meta, 'name', config, trainedSchema);
464
- const price = this.getDynamicVal(meta, 'price', config, trainedSchema);
465
- 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
+ }
466
1057
 
467
- // If we have at least a name or a clear product-like structure in content
468
- if (name || this.isProductData(item)) {
469
- // Robust name extraction: metadata > regex > content snippet
470
- let finalName = name ? String(name) : undefined;
471
- if (!finalName) {
472
- const nameMatch = item.content.match(/(?:Product|Item|Name):\s*([^\n]+)/i);
473
- finalName = nameMatch ? nameMatch[1].trim() : item.content.split('\n')[0].substring(0, 60);
474
- }
1058
+ private static selectNumericField(profile: DataProfile, query: string): FieldProfile | undefined {
1059
+ return this.rankFieldsByQuery(profile.numericFields, query)[0] ?? profile.numericFields[0];
1060
+ }
475
1061
 
476
- // Robust price extraction: metadata > regex
477
- let finalPrice: string | number | undefined =
478
- typeof price === 'number' || typeof price === 'string'
479
- ? price
480
- : undefined;
1062
+ private static rankFieldsByQuery(fields: FieldProfile[], query: string): FieldProfile[] {
1063
+ const q = query.toLowerCase();
1064
+ return [...fields].sort((a, b) => this.fieldScore(b, q) - this.fieldScore(a, q));
1065
+ }
481
1066
 
482
- if (!finalPrice) {
483
- const priceMatch = item.content.match(/(?:Price|Cost):\s*\$?([\d,.]+)/i) || item.content.match(/\$\s*([\d,.]+)/);
484
- if (priceMatch) finalPrice = priceMatch[1].replace(/,/g, '');
485
- }
1067
+ private static fieldScore(field: FieldProfile, query: string): number {
1068
+ const key = field.key.toLowerCase();
1069
+ const label = field.label.toLowerCase();
1070
+ let score = 0;
1071
+ if (query.includes(key)) score += 4;
1072
+ if (query.includes(label)) score += 4;
1073
+ key.split(/[_\s-]+/).forEach(part => {
1074
+ if (part && query.includes(part)) score += 1;
1075
+ });
1076
+ if (/category|department|collection|type|group|segment/.test(key)) score += 2;
1077
+ if (/count|total|quantity|stock|inventory|sales|revenue|amount|price|value|score/.test(key)) score += 2;
1078
+ if (/id|sku|ean|upc|code/.test(key)) score -= 5;
1079
+ return score;
1080
+ }
486
1081
 
487
- 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
+ }
488
1089
 
489
- return {
490
- id: item.id,
491
- name: finalName,
492
- price: finalPrice,
493
- image: typeof imageValue === 'string' ? imageValue : undefined,
494
- brand: brand ? String(brand) : undefined,
495
- description: item.content,
496
- inStock: this.determineStockStatus(item),
497
- };
1090
+ private static fieldTokens(field: string): string[] {
1091
+ return field
1092
+ .toLowerCase()
1093
+ .replace(/[_-]+/g, ' ')
1094
+ .split(/\s+/)
1095
+ .map(token => token.replace(/[^a-z0-9]/g, ''))
1096
+ .filter(Boolean);
1097
+ }
1098
+
1099
+ private static aggregateProfileByDimension(
1100
+ profile: DataProfile,
1101
+ dimensionKey: string,
1102
+ measureKey?: string,
1103
+ ): Record<string, number> {
1104
+ const result: Record<string, number> = {};
1105
+ profile.records.forEach(record => {
1106
+ const category = String(record.fields[dimensionKey] ?? 'Other').trim() || 'Other';
1107
+ const value = measureKey ? this.toFiniteNumber(record.fields[measureKey]) ?? 0 : 1;
1108
+ result[category] = (result[category] ?? 0) + value;
1109
+ });
1110
+ return result;
1111
+ }
1112
+
1113
+ private static getRecordLabel(record: DataRecord): PrimitiveValue | undefined {
1114
+ const labelKeys = ['name', 'title', 'label', 'product', 'item', 'brand'];
1115
+ const key = Object.keys(record.fields).find(fieldKey =>
1116
+ labelKeys.some(labelKey => fieldKey.toLowerCase().includes(labelKey)),
1117
+ );
1118
+ return key ? record.fields[key] : undefined;
1119
+ }
1120
+
1121
+ private static detectAggregationOperation(query: string): AggregationOperation {
1122
+ const q = query.toLowerCase();
1123
+ if (/\b(avg|average|mean)\b/.test(q)) return 'average';
1124
+ if (/\b(count|how many|number of)\b/.test(q)) return 'count';
1125
+ if (/\b(min|minimum|lowest)\b/.test(q)) return 'min';
1126
+ if (/\b(max|maximum|highest)\b/.test(q)) return 'max';
1127
+ if (/\bmedian\b/.test(q)) return 'median';
1128
+ return 'sum';
1129
+ }
1130
+
1131
+ private static calculateAggregate(values: number[], operation: AggregationOperation): number {
1132
+ if (values.length === 0) return 0;
1133
+ switch (operation) {
1134
+ case 'average':
1135
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
1136
+ case 'count':
1137
+ return values.length;
1138
+ case 'min':
1139
+ return Math.min(...values);
1140
+ case 'max':
1141
+ return Math.max(...values);
1142
+ case 'median': {
1143
+ const sorted = [...values].sort((a, b) => a - b);
1144
+ const middle = Math.floor(sorted.length / 2);
1145
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
1146
+ }
1147
+ case 'sum':
1148
+ default:
1149
+ return values.reduce((sum, value) => sum + value, 0);
498
1150
  }
1151
+ }
499
1152
 
500
- return null;
1153
+ private static humanizeFieldName(key: string): string {
1154
+ return key
1155
+ .replace(/[_-]+/g, ' ')
1156
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
1157
+ .replace(/\s+/g, ' ')
1158
+ .trim()
1159
+ .replace(/\b\w/g, char => char.toUpperCase());
1160
+ }
1161
+
1162
+ private static formatNumber(value: number): string {
1163
+ return Number.isInteger(value) ? String(value) : value.toFixed(1);
501
1164
  }
502
1165
 
503
- /**
504
- * Helper: Detect categories in data
505
- */
506
1166
  private static detectCategories(data: VectorMatch[]): string[] {
507
1167
  const categories = new Set<string>();
508
-
509
1168
  data.forEach(item => {
510
- const meta = item.metadata || {};
1169
+ const category = this.getProductCategory(item);
1170
+ if (category) categories.add(category);
1171
+ });
1172
+ return Array.from(categories);
1173
+ }
511
1174
 
512
- // Check for category field
513
- if (meta.category) {
514
- categories.add(String(meta.category));
515
- }
516
- if (meta.type) {
517
- categories.add(String(meta.type));
518
- }
519
- if (meta.tag) {
520
- const tags = Array.isArray(meta.tag) ? meta.tag : [meta.tag];
521
- tags.forEach(t => categories.add(String(t)));
522
- }
1175
+ private static getProductCategory(item: VectorMatch): string | null {
1176
+ const meta = item.metadata || {};
1177
+ const metadataCategory = resolveMetadataValue(meta, 'category');
1178
+ if (this.isUsableCategory(metadataCategory)) return String(metadataCategory).trim();
523
1179
 
524
- // Parse category strings from content lines like "Shoes & Footwear: • Product"
525
- const contentCategories = Array.from(new Set(
526
- Array.from(item.content.matchAll(/^\s*([^:\n]+):\s*(?:•|\*|-)*/gm))
527
- .map(match => match[1].trim())
528
- .filter(Boolean)
529
- ));
530
- contentCategories.forEach((category) => categories.add(category));
531
-
532
- // Parse from structured category hints in content
533
- const categoryMatch = item.content.match(/(?:Category|Type|Class):\s*([^\n]+)/i);
534
- if (categoryMatch) {
535
- categories.add(categoryMatch[1].trim());
536
- }
537
- });
1180
+ const content = item.content || '';
1181
+ const explicitCategoryMatch = content.match(
1182
+ /(?:^|\n)\s*(?:product\s*)?(?:category|department|collection)\s*[:\-–]\s*([^\n]+)/i,
1183
+ );
1184
+ if (explicitCategoryMatch && this.isUsableCategory(explicitCategoryMatch[1])) {
1185
+ return explicitCategoryMatch[1].trim();
1186
+ }
538
1187
 
539
- return Array.from(categories);
1188
+ return null;
1189
+ }
1190
+
1191
+ private static isUsableCategory(value: unknown): value is string | number {
1192
+ if (value === null || value === undefined) return false;
1193
+ const category = String(value).trim();
1194
+ if (!category) return false;
1195
+ return !/^(category|type|tag|price|cost|stock|availability|description|product|item|name|brand|sku|id|ean|currency|color|size|index|internal id|other|\[?type\]?|\[?products?_?\d*\]?)$/i.test(category);
540
1196
  }
541
1197
 
542
- /**
543
- * Helper: Aggregate data by category
544
- */
545
1198
  private static aggregateByCategory(
546
1199
  data: VectorMatch[],
547
- categories: string[]
1200
+ categories: string[],
548
1201
  ): Record<string, number> {
549
- const result: Record<string, number> = {};
550
-
551
- categories.forEach(cat => {
552
- result[cat] = 0;
553
- });
554
-
1202
+ const result: Record<string, number> = Object.fromEntries(categories.map(c => [c, 0]));
555
1203
  data.forEach(item => {
556
- const meta = item.metadata || {};
557
- const itemCategory = (meta.category || meta.type || 'Other') as string;
558
-
559
- if (Object.prototype.hasOwnProperty.call(result, itemCategory)) {
560
- result[itemCategory]++;
561
- } else {
562
- result['Other'] = (result['Other'] || 0) + 1;
563
- }
1204
+ const cat = this.getProductCategory(item) ?? 'Other';
1205
+ if (Object.prototype.hasOwnProperty.call(result, cat)) result[cat]++;
1206
+ else result['Other'] = (result['Other'] || 0) + 1;
564
1207
  });
565
-
566
1208
  return result;
567
1209
  }
568
1210
 
569
- /**
570
- * Helper: Extract time series data
571
- */
572
1211
  private static extractTimeSeriesData(
573
- data: VectorMatch[]
1212
+ data: VectorMatch[],
574
1213
  ): Array<{ timestamp: string | number; value: number; label?: string }> {
575
1214
  return data.map(item => {
576
1215
  const meta = item.metadata || {};
577
1216
  return {
578
- timestamp: (meta.timestamp || meta.date || new Date().toISOString()) as string | number,
579
- value: (meta.value || item.score || 0) as number,
580
- 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,
581
1220
  };
582
1221
  });
583
1222
  }
584
1223
 
585
- /**
586
- * Helper: Extract table columns
587
- */
588
- private static extractTableColumns(data: VectorMatch[]): string[] {
589
- const columnSet = new Set<string>();
590
- 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
+ };
591
1310
 
592
1311
  data.forEach(item => {
593
- Object.keys(item.metadata || {}).forEach(key => {
594
- columnSet.add(key.charAt(0).toUpperCase() + key.slice(1));
595
- });
1312
+ Object.keys(item.metadata || {}).forEach(addField);
1313
+ for (const match of (item.content || '').matchAll(/(?:^|\n)\s*([A-Za-z][A-Za-z0-9_ /-]{1,50})\s*[:\-–]\s*([^\n]+)/g)) {
1314
+ addField(match[1]);
1315
+ }
596
1316
  });
597
1317
 
598
- return Array.from(columnSet);
1318
+ return Array.from(fields.values());
599
1319
  }
600
1320
 
601
- /**
602
- * Helper: Extract table row
603
- */
604
- 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
+
605
1353
  const meta = item.metadata || {};
606
- return columns.map(col => {
607
- if (col === 'Content') {
608
- return item.content.substring(0, 100);
609
- }
1354
+ const normalizedColumn = this.normalizeComparableField(column);
1355
+ const exactMetadata = Object.entries(meta).find(([key]) =>
1356
+ this.normalizeComparableField(key) === normalizedColumn ||
1357
+ this.normalizeComparableField(this.humanizeFieldName(key)) === normalizedColumn,
1358
+ );
1359
+ if (exactMetadata && exactMetadata[1] !== undefined && exactMetadata[1] !== null) {
1360
+ return this.toDisplayValue(exactMetadata[1]);
1361
+ }
610
1362
 
611
- const metaKey = col.charAt(0).toLowerCase() + col.slice(1);
612
- const value = meta[metaKey];
613
- return value !== undefined ? String(value) : '';
614
- });
1363
+ const aliasValue = this.resolveAliasedTableCell(meta, column);
1364
+ if (aliasValue !== undefined) return this.toDisplayValue(aliasValue);
1365
+
1366
+ const contentValue = this.extractContentFieldValue(item.content, column);
1367
+ if (contentValue !== null) return contentValue;
1368
+
1369
+ const aliasContentValue = this.extractAliasedContentFieldValue(item.content, column);
1370
+ return aliasContentValue ?? '';
615
1371
  }
616
1372
 
617
- /**
618
- * Helper: Calculate stock counts by category
619
- */
620
- private static calculateStockCounts(category: string, data: VectorMatch[]): { 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 } {
621
1427
  let inStock = 0;
622
1428
  let outOfStock = 0;
623
-
624
1429
  data.forEach(d => {
625
- const meta = d.metadata || {};
626
- const itemCategory = (meta.category || meta.type || 'Other') as string;
627
- if (itemCategory === category) {
628
- if (this.determineStockStatus(d)) {
629
- inStock++;
630
- } else {
631
- outOfStock++;
632
- }
1430
+ const cat = this.getProductCategory(d) ?? 'Other';
1431
+ if (cat === category) {
1432
+ const quantity = this.extractStockQuantity(d) ?? 1;
1433
+ if (this.determineStockStatus(d)) inStock += quantity;
1434
+ else outOfStock += quantity;
633
1435
  }
634
1436
  });
635
-
636
1437
  return { inStockCount: inStock, outOfStockCount: outOfStock };
637
1438
  }
638
1439
 
639
- /**
640
- * Helper: Determine if item is in stock
641
- */
642
1440
  private static determineStockStatus(item: VectorMatch): boolean {
643
1441
  const meta = item.metadata || {};
644
-
645
- // Check metadata for stock status
646
- if (meta.inStock !== undefined) {
647
- return Boolean(meta.inStock);
648
- }
649
- if (meta.stock !== undefined) {
650
- return Boolean(meta.stock);
651
- }
652
- if (meta.available !== undefined) {
653
- return Boolean(meta.available);
1442
+ const stockValue = resolveMetadataValue(meta, 'stock');
1443
+ if (stockValue !== undefined) {
1444
+ const normalized = String(stockValue).toLowerCase();
1445
+ if (/out[_\s-]?of[_\s-]?stock|unavailable|false|no|sold out|0/.test(normalized)) return false;
1446
+ if (/in[_\s-]?stock|available|true|yes/.test(normalized)) return true;
1447
+ const numeric = this.toFiniteNumber(stockValue);
1448
+ if (numeric !== null) return numeric > 0;
654
1449
  }
1450
+ if (meta.inStock !== undefined) return Boolean(meta.inStock);
1451
+ if (meta.stock !== undefined) return Boolean(meta.stock);
1452
+ if (meta.available !== undefined) return Boolean(meta.available);
655
1453
 
656
- // Check content
657
1454
  const content = (item.content || '').toLowerCase();
658
- if (content.includes('out of stock') || content.includes('unavailable')) {
659
- return false;
660
- }
661
- if (content.includes('in stock') || content.includes('available')) {
662
- return true;
663
- }
664
-
665
- // Default to true if not specified
1455
+ if (/out[_\s-]?of[_\s-]?stock|unavailable|sold out/.test(content)) return false;
1456
+ if (/in[_\s-]?stock|available/.test(content)) return true;
666
1457
  return true;
667
1458
  }
668
1459
 
669
- /**
670
- * Helper: Check if data has multiple fields
671
- */
672
- private static hasMultipleFields(data: VectorMatch[]): boolean {
673
- 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;
674
1465
 
675
- data.forEach(item => {
676
- Object.keys(item.metadata || {}).forEach(key => {
677
- fieldCount.add(key);
678
- });
679
- });
1466
+ const content = item.content || '';
1467
+ const stockMatch = content.match(/\b(?:stock|inventory|quantity|count)\s*[:\-–]?\s*([\d,]+)/i);
1468
+ if (!stockMatch) return null;
680
1469
 
681
- return fieldCount.size > 2;
1470
+ return this.toFiniteNumber(stockMatch[1]);
682
1471
  }
683
1472
 
684
- // ─── 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
+ }
685
1478
 
686
- /**
687
- * analyzeAndDecide — sends user question + RAG data to the LLM with a
688
- * structured system prompt and parses the JSON response into a
689
- * UITransformationResponse.
690
- *
691
- * This is the recommended entry point for production use. The heuristic
692
- * `transform()` method is used as a fallback if the LLM call fails.
693
- *
694
- * System prompt instructs the LLM to:
695
- * - Analyze the question and retrieved data
696
- * - Choose the best visualization: bar_chart | line_chart | pie_chart | table | text
697
- * - Return a strict JSON object — no prose, no markdown fences
698
- *
699
- * @param query - the original user question
700
- * @param sources - vector DB matches returned by RAG retrieval
701
- * @param llm - any ILLMProvider instance (OpenAI, Anthropic, Ollama, Gemini, REST…)
702
- * @returns - a validated UITransformationResponse (type + title + description + data)
703
- */
704
- static async analyzeAndDecide(
705
- query: string,
706
- sources: VectorMatch[],
707
- llm: ILLMProvider,
708
- ): Promise<UITransformationResponse> {
709
- try {
710
- const context = this.buildContextSummary(sources);
711
- const systemPrompt = this.buildVisualizationSystemPrompt();
1479
+ // ─── Product Extraction ───────────────────────────────────────────────────
712
1480
 
713
- const userPrompt = [
714
- `USER QUESTION: ${query}`,
715
- '',
716
- 'RETRIEVED DATA (JSON):',
717
- context,
718
- ].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;
719
1488
 
720
- const rawResponse = await llm.chat(
721
- [{ role: 'user', content: userPrompt }],
722
- '',
723
- { systemPrompt, temperature: 0 },
724
- );
1489
+ const mapping = config?.rag?.uiMapping;
1490
+ if (mapping?.[uiKey] && meta[mapping[uiKey]] !== undefined) return meta[mapping[uiKey]];
725
1491
 
726
- const parsed = this.parseTransformationResponse(rawResponse);
727
- if (parsed) {
728
- console.debug('[UITransformer] LLM chose visualization type:', parsed.type);
729
- 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);
730
1519
  }
731
1520
 
732
- console.warn('[UITransformer] LLM returned unparseable response; falling back to heuristic.');
733
- } catch (err) {
734
- 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
+ };
735
1541
  }
736
1542
 
737
- // Fallback: keyword-heuristic transform
738
- return this.transform(query, sources);
1543
+ return null;
739
1544
  }
740
1545
 
741
- /**
742
- * Build the system prompt that instructs the LLM to return a visualization JSON.
743
- */
1546
+ private static extractProductDescriptionFromContent(content: string): string | null {
1547
+ const bodyMatch = content.match(
1548
+ /\bBody\s*\(HTML\)\s*:\s*([\s\S]*?)(?=\s*[,.]?\s+\b(?:Handle|Title|Vendor|Type|Tags|Published|Option\d*\s+Name|Option\d*\s+Value|Option|Variant|Image|SKU|Price|Status|Category)\b\s*:|$)/i,
1549
+ );
1550
+ if (bodyMatch?.[1]) return bodyMatch[1];
1551
+
1552
+ const descriptionMatch = content.match(
1553
+ /\b(?:Description|Summary|Details|Body|Content)\s*:\s*([\s\S]*?)(?=\s*[,.]?\s+\b(?:Handle|Title|Vendor|Type|Tags|Published|Option\d*\s+Name|Option\d*\s+Value|Option|Variant|Image|SKU|Price|Status|Category)\b\s*:|$)/i,
1554
+ );
1555
+ if (descriptionMatch?.[1]) return descriptionMatch[1];
1556
+
1557
+ return null;
1558
+ }
1559
+
1560
+ private static getProductDescriptionValue(
1561
+ meta: Record<string, unknown>,
1562
+ config?: import('../config/RagConfig').RagConfig,
1563
+ trainedSchema?: import('./SchemaMapper').SchemaMap,
1564
+ ): unknown {
1565
+ const mapped = this.getDynamicVal(meta, 'description', config, trainedSchema);
1566
+ if (mapped !== undefined && mapped !== meta.content && mapped !== meta.text) return mapped;
1567
+
1568
+ const preferredKeys = [
1569
+ 'body_html',
1570
+ 'body html',
1571
+ 'bodyHtml',
1572
+ 'description',
1573
+ 'summary',
1574
+ 'details',
1575
+ 'body',
1576
+ ];
1577
+
1578
+ for (const key of preferredKeys) {
1579
+ const match = Object.keys(meta).find(candidate =>
1580
+ this.normalizeComparableField(candidate) === this.normalizeComparableField(key),
1581
+ );
1582
+ if (match && meta[match] !== undefined && meta[match] !== null) return meta[match];
1583
+ }
1584
+
1585
+ return undefined;
1586
+ }
1587
+
1588
+ private static cleanProductDescription(raw: unknown): string | undefined {
1589
+ if (raw === null || raw === undefined) return undefined;
1590
+
1591
+ const extracted = this.extractProductDescriptionFromContent(String(raw));
1592
+ if (extracted && extracted !== String(raw)) return this.cleanProductDescription(extracted);
1593
+
1594
+ const text = String(raw)
1595
+ .replace(/<[^>]*>/g, ' ')
1596
+ .replace(/&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
+
744
1612
  private static buildVisualizationSystemPrompt(): string {
745
1613
  return `You are a data visualization expert embedded in a RAG chat system.
746
- You will receive a user question and structured data retrieved from a vector database.
747
- Your ONLY job is to analyze this information and return a single JSON object that tells
748
- 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.
749
1625
 
750
- Return ONLY a valid JSON object — no markdown code fences, no explanation, no prose.
751
-
752
- The JSON must have this exact shape:
753
1626
  {
754
- "type": "bar_chart" | "line_chart" | "pie_chart" | "radar_chart" | "table" | "text",
755
- "title": "A concise, descriptive title for the visualization",
756
- "description": "One sentence describing what the visualization shows",
757
- "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>
758
1631
  }
759
1632
 
760
- DATA SHAPE per type:
761
- - bar_chart: array of { "category": string, "value": number }
762
- - line_chart: array of { "timestamp": string, "value": number, "label": string }
763
- - pie_chart: array of { "label": string, "value": number }
764
- - radar_chart: array of { "attribute": string, "[series1]": number, "[series2]": number, ... }
765
- Example for radar_chart:
766
- [
767
- { "attribute": "Longevity", "Dolce Shine": 4, "CK One": 3 },
768
- { "attribute": "Sillage", "Dolce Shine": 3, "CK One": 4 },
769
- { "attribute": "Freshness", "Dolce Shine": 5, "CK One": 4 }
770
- ]
771
- - table: { "columns": string[], "rows": (string|number)[][] }
772
- - text: { "content": "<prose answer>" }
773
-
774
- DECISION RULES (follow strictly):
775
- 1. bar_chart → comparing quantities across categories (e.g. sales by region, price by product). Use this when there is only ONE value per category.
776
- 2. line_chart trends or changes over time (dates, months, years, sequential events)
777
- 3. pie_chart → proportional breakdown or percentage distribution
778
- 4. radar_chart comparing 2 or more products across MULTIPLE attributes (e.g. comparing features, ratings, or characteristics of 2 specific products). If the user asks to "compare" products and the data contains multiple dimensions or ratings for them, you MUST use this.
779
- 5. table → multi-field structured records where each item has ≥ 3 attributes
780
- 6. text → conversational or free-form answers where no chart adds value
781
-
782
- IMPORTANT:
783
- - Aggregate numeric values from the raw data — do not pass raw object arrays as data.
784
- - Ensure all "value" fields are numbers, not strings.
785
- - For bar/line/pie, keep at most 12 data points for readability.
786
- - Never include nested objects or arrays inside bar_chart / line_chart / pie_chart data items.`;
1633
+ DATA SHAPES:
1634
+ - bar_chart: [{ "category": string, "value": number }]
1635
+ - horizontal_bar: [{ "category": string, "value": number }]
1636
+ - histogram: [{ "category": string, "value": number }]
1637
+ - line_chart: [{ "timestamp": string, "value": number, "label": string }]
1638
+ - pie_chart: [{ "label": string, "value": number }]
1639
+ - scatter_plot:[{ "x": number, "y": number, "label": string }]
1640
+ - radar_chart: [{ "attribute": string, "<series1>": number, "<series2>": number, ... }]
1641
+ - metric_card: { "label": string, "value": number, "operation": "sum"|"average"|"count"|"min"|"max"|"median" }
1642
+ - geo_map: [{ "category": string, "value": number }]
1643
+ - table: { "columns": string[], "rows": (string|number)[][] }
1644
+ - text: { "content": "A concise plain-language answer." }
1645
+
1646
+ RULES:
1647
+ 1. Aggregate values — never pass raw nested objects in chart arrays.
1648
+ 2. All "value" fields must be numbers.
1649
+ 3. Cap bar/line/pie at 12 data points.
1650
+ 4. Use dollar signs only for monetary prices, not for stock quantities or years.
1651
+ 5. If no relevant data is found, return text: "I cannot answer this question based on the available information."`;
787
1652
  }
788
1653
 
789
- /**
790
- * Serialize retrieved vector matches into a compact JSON context string.
791
- * Limits the total character count to avoid exceeding LLM context windows.
792
- */
793
1654
  private static buildContextSummary(sources: VectorMatch[], maxChars = 6000): string {
794
1655
  const items = sources.map((s, i) => ({
795
1656
  index: i + 1,
@@ -801,9 +1662,8 @@ IMPORTANT:
801
1662
  const full = JSON.stringify(items, null, 2);
802
1663
  if (full.length <= maxChars) return full;
803
1664
 
804
- // Truncate: include as many items as fit
805
1665
  const partial: typeof items = [];
806
- let chars = 2; // for []
1666
+ let chars = 2;
807
1667
  for (const item of items) {
808
1668
  const chunk = JSON.stringify(item);
809
1669
  if (chars + chunk.length + 2 > maxChars) break;
@@ -813,4 +1673,3 @@ IMPORTANT:
813
1673
  return JSON.stringify(partial, null, 2) + '\n// ... truncated';
814
1674
  }
815
1675
  }
816
-