@retrivora-ai/rag-engine 1.9.0 → 1.9.2

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